From d0a91523a70f1e9ff4f8655d36ce8c69b38b57e5 Mon Sep 17 00:00:00 2001 From: Roman Romenskyi Date: Mon, 19 Oct 2026 18:33:05 -0600 Subject: [PATCH 1/3] =?UTF-8?q?Modes:=20manual=20=E2=80=94=20execute,=20bu?= =?UTF-8?q?t=20ask=20before=20every=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A third mode next to auto and plan, as in Claude Code: the agent works, but every change stops for a yes — any shell command, every file write, append, edit and mkdir, mutating git, sub-agent spawns and MCP — even where /permissions or an allow rule would have let it through. Reads stay free, an 'a' at a prompt still covers its kind for the session, and the deny floor holds in every mode. It rides on the call gate the risk check already uses, so sub-agents and background jobs follow it too. shift+tab cycles auto → plan → manual (a single press still means plan), /manual sets it, and the /config mode row steps through all three. Co-Authored-By: Claude Opus 5.5 --- README.md | 20 +++++--- cmd/agent/config_panel.go | 9 ++-- cmd/agent/main.go | 62 ++++++++++++++++++---- cmd/agent/main_test.go | 14 +++-- cmd/agent/manual_mode_test.go | 97 +++++++++++++++++++++++++++++++++++ cmd/agent/risk.go | 29 ++++++++++- cmd/agent/staging.go | 9 ++-- cmd/agent/tui.go | 40 ++++++++------- 8 files changed, 228 insertions(+), 52 deletions(-) create mode 100644 cmd/agent/manual_mode_test.go diff --git a/README.md b/README.md index 3265b8f..8cd1ade 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ the main model then merges the results into one answer. Safety: every spawn **asks for approval** (even local ones cost compute) until you relax it with `/permissions agents on`. Sub-agents read/write files and use git but **only run shell commands if you enable it** with `/agents exec on`. They inherit -the current plan/auto mode, can't spawn their own sub-agents (depth 1), and their +the current mode, can't spawn their own sub-agents (depth 1), and their tokens are recorded in `/usage` like any other. ### External CLI agents @@ -443,15 +443,23 @@ In the TUI, `/sessions` lists/switches/deletes threads, `/new ` starts a fresh named thread (the old one stays in `/sessions`), and `/new` clears the active one. -## Modes: auto vs plan +## Modes: auto, plan, manual -Toggle with **shift+tab** (or `/plan` / `/auto`); the current mode shows at the -bottom of the screen. +Cycle with **shift+tab** (auto → plan → manual), or use `/auto`, `/plan` and +`/manual`. The current mode shows at the bottom of the screen. The mode lasts +for the session only. -- **auto** (default) — the agent executes the task with tools. +- **auto** (default) — the agent executes the task with tools, under your + permission policy. - **plan** — read-only: it investigates and proposes a numbered plan, and every state-changing tool call is **blocked at the engine**, so it can't touch anything until you accept. +- **manual** — the agent executes, but **asks before every change**: every + shell command, every file write, append, edit and mkdir, mutating git, + sub-agent spawns and MCP. It asks even where `/permissions` or an allow + rule would let the call through. Reads and searches stay free. An **`a`** at + a prompt still covers its kind for the rest of the session. The deny floor + holds in every mode. When a plan-mode task finishes with a plan, you get a one-key **handshake**: **enter** to accept — switches to auto and immediately executes the plan — or @@ -827,7 +835,7 @@ Anything not starting with `/` is run as a task. Tab completes commands. | command | what | |---|---| -| `/plan`, `/auto` | plan mode (propose only) vs auto mode (execute) — also shift+tab | +| `/plan`, `/auto`, `/manual` | plan mode (propose only), auto mode (execute) or manual mode (execute, ask before every change) — also shift+tab | | `/goal ` | set & pursue a multi-turn goal (judge re-feeds until met); `go` · `clear` · `ttl ` · `off` | | `/skills` | list / toggle / install on-demand instruction packs | | `/permissions` | relax approval for non-destructive file/shell actions | diff --git a/cmd/agent/config_panel.go b/cmd/agent/config_panel.go index 07fd312..9d35e89 100644 --- a/cmd/agent/config_panel.go +++ b/cmd/agent/config_panel.go @@ -428,11 +428,8 @@ func (m *tuiModel) configRowView(key string) (label, value, hint string) { case "idle_timeout": return "idle timeout", idleTimeoutLabel(act.IdleTimeoutSeconds), "enter: type seconds (no response/stream data → retry)" case "mode": - v := "⏵⏵ auto" - if m.app.planMode { - v = "⏸ plan" - } - return "mode", v, "enter: toggle · this session only (shift+tab does the same)" + v := map[mode]string{modeAuto: "⏵⏵ auto", modePlan: "⏸ plan", modeManual: "✋ manual"}[m.app.mode()] + return "mode", v, "enter: next mode · this session only (shift+tab does the same)" case "perm_files": return "file writes", m.app.cfg.File.Default, "enter: ask/allow/deny" case "perm_run": @@ -608,7 +605,7 @@ func (m *tuiModel) activateConfigRow(key string) (tea.Model, tea.Cmd) { case "provider": m.cfgPick = &cfgPicker{key: key, p: newPicker(m.app.providerPickItems(), m.app.providerName())} case "mode": - m.app.setMode(!m.app.planMode) + m.app.setMode(m.app.mode().next()) case "perm_files": m.cyclePerm(&m.app.cfg.File.Default) case "perm_run": diff --git a/cmd/agent/main.go b/cmd/agent/main.go index fec341d..eb4c2c6 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -618,6 +618,7 @@ type app struct { promptSrc string // "built-in" or the system.md override path facts []string // durable project facts learned over time (per workspace) planMode bool // plan (propose) vs auto (execute); survives re-wire + manual atomic.Bool // manual mode: every change asks (see manualStops); read by tool goroutines readOnly bool // -read-only: answer one task, change nothing, keep no session goal goalState // standing goal pursued by the judge loop (per workspace) windowDetected bool // got the real loaded context window (vs a default/guess) @@ -1061,7 +1062,7 @@ func (a *app) runSpawnPlan(ctx context.Context, plan spawnPlan, task string, onL // behind a shared lock — the approver (TUI bridge / stdin prompt) already // serializes concurrent prompts on its own, and a mutex held here would // block an unrelated job's own spawn/mcp approval behind this one's. - if plan.spawnDefault != "allow" || plan.outsideJail || a.riskStops(ctx, "spawn agent") { + if plan.spawnDefault != "allow" || plan.outsideJail || a.riskStops(ctx, "spawn agent") || a.manual.Load() { // Outside the workspace is its own kind of approval, not a "spawn …" // one: an allow-for-this-session given to ordinary spawns must not // cover a sub-agent sent outside the jail. @@ -3305,15 +3306,52 @@ func (a *app) wire() error { // runaway session, not something a normal session ever reaches. const rawMemoryMaxHistory = 500 -// setMode switches between plan (investigate + propose) and auto (execute) and -// returns a one-line confirmation. -func (a *app) setMode(plan bool) string { - a.planMode = plan +// mode is how the agent goes about a task: auto executes under the permission +// policy, manual executes but asks before every change, plan only proposes. +type mode int + +const ( + modeAuto mode = iota + modePlan + modeManual +) + +// next is the mode shift+tab moves to: auto → plan → manual → auto. Plan comes +// first, so a single press still does what it always did. +func (m mode) next() mode { return (m + 1) % 3 } + +func (m mode) String() string { + switch m { + case modePlan: + return "plan" + case modeManual: + return "manual" + } + return "auto" +} + +func (a *app) mode() mode { + switch { + case a.planMode: + return modePlan + case a.manual.Load(): + return modeManual + } + return modeAuto +} + +// setMode switches the mode and returns a one-line confirmation. +func (a *app) setMode(m mode) string { + a.planMode = m == modePlan + a.manual.Store(m == modeManual) if a.ag != nil { - a.ag.SetPlanMode(plan) + a.ag.SetPlanMode(a.planMode) } - if plan { + switch m { + case modePlan: return "plan mode on — investigates and proposes a plan; changes nothing" + case modeManual: + return "manual mode on — executes, but asks before every change: file writes, shell commands, git, sub-agents, MCP" } return "auto mode on — executes the task" } @@ -4620,9 +4658,11 @@ func (a *app) command(ctx context.Context, line string) (quit bool) { fmt.Printf("compacted %d messages → summary.\n", n) } case "/plan": - fmt.Println(a.setMode(true)) + fmt.Println(a.setMode(modePlan)) case "/auto": - fmt.Println(a.setMode(false)) + fmt.Println(a.setMode(modeAuto)) + case "/manual": + fmt.Println(a.setMode(modeManual)) case "/license", "/licenses": fmt.Println(legal.Summary()) fmt.Println("\nFull texts: ipsupport-code --license") @@ -5449,7 +5489,7 @@ func helpText() string { /new [name] start a NEW session (the old one stays in /sessions) /clear wipe this session's context (same session) /compact summarize the session so far to free up context - /plan, /auto plan mode (propose only) vs auto mode (execute) + /plan, /auto, /manual plan (propose only) · auto (execute) · manual (execute, ask before every change) /ai [name] switch AI provider; /ai key ; /ai add [model] (custom) /model [name] list the provider's models, or pick one /config control panel: all settings + how to change them @@ -5477,7 +5517,7 @@ func helpText() string { /exit, /quit leave keys: Enter send · alt+enter newline · Tab complete /commands + @file paths · - ctrl+r history search · shift+tab plan/auto · ctrl+u clear input · esc cancel (twice = force-detach a stuck task) + ctrl+r history search · shift+tab auto/plan/manual · ctrl+u clear input · esc cancel (twice = force-detach a stuck task) sub-agents: define profiles in /config → Sub-agents (provider → model → name), then ask e.g. "review internal/tool across grok and claude, then merge". The diff --git a/cmd/agent/main_test.go b/cmd/agent/main_test.go index 190efd8..eefd93f 100644 --- a/cmd/agent/main_test.go +++ b/cmd/agent/main_test.go @@ -1815,18 +1815,22 @@ func TestDeferredModeSwitchWhileRunning(t *testing.T) { m.app.planMode = false // start in auto m.handleKey(tea.KeyMsg{Type: tea.KeyShiftTab}) - if m.pendingMode == nil || !*m.pendingMode { + if m.pendingMode == nil || *m.pendingMode != modePlan { t.Fatalf("shift+tab while running should queue plan mode, pending=%v", m.pendingMode) } if m.app.planMode { t.Error("mode must NOT flip live while a task runs") } - m.handleKey(tea.KeyMsg{Type: tea.KeyShiftTab}) // toggles the queued target back - if *m.pendingMode { - t.Error("second shift+tab should flip the queued target back to auto") + m.handleKey(tea.KeyMsg{Type: tea.KeyShiftTab}) // moves the queued target on + if *m.pendingMode != modeManual { + t.Errorf("second shift+tab should queue manual, got %v", *m.pendingMode) + } + m.handleKey(tea.KeyMsg{Type: tea.KeyShiftTab}) + if *m.pendingMode != modeAuto { + t.Errorf("third shift+tab should come back to auto, got %v", *m.pendingMode) } - plan := true + plan := modePlan m.pendingMode = &plan m.applyPendingMode() if !m.app.planMode { diff --git a/cmd/agent/manual_mode_test.go b/cmd/agent/manual_mode_test.go new file mode 100644 index 0000000..8e3422c --- /dev/null +++ b/cmd/agent/manual_mode_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/ipsupport-llc/ipsupport-code/internal/config" +) + +// Manual mode asks before every change the policy would have let through +// unasked — a shell command, a file write, edit or mkdir — and leaves reads +// alone. An 'a' at a prompt still covers its kind for the session, and auto +// mode is untouched. +func TestManualModeAsksBeforeEveryChange(t *testing.T) { + a, pol, ap := gateApp(t, "run.default=allow", "file.default=allow") + gate := a.riskGate(pol) + ctx := context.Background() + ls := map[string]any{"command": "ls"} + note := map[string]any{"path": "note.txt"} + + if msg := gate(ctx, "run", "shell", ls); msg != "" || ap.calls != 0 { + t.Fatalf("auto mode asked about an allowed command: %q, %d call(s)", msg, ap.calls) + } + + a.setMode(modeManual) + for _, c := range []struct{ name, action string }{{"run", "shell"}, {"file", "write"}, {"file", "append"}, {"file", "edit"}, {"file", "mkdir"}} { + before := ap.calls + params := note + if c.name == "run" { + params = ls + } + msg := gate(ctx, c.name, c.action, params) + if ap.calls != before+1 || !strings.Contains(msg, "denied by user") || strings.Contains(msg, "risk check") { + t.Errorf("%s %s in manual: asked %d time(s), result %q — want one prompt and a plain refusal", c.name, c.action, ap.calls-before, msg) + } + } + for _, action := range []string{"read", "list", "find", "search"} { + before := ap.calls + if msg := gate(ctx, "file", action, note); msg != "" || ap.calls != before { + t.Errorf("file %s in manual was stopped: %q", action, msg) + } + } + + a.allowSession("run") // 'a' at a shell prompt + before := ap.calls + if msg := gate(ctx, "run", "shell", ls); msg != "" || ap.calls != before { + t.Errorf("after 'a', manual still asked about a shell command: %q", msg) + } +} + +// A prompt manual mode raised that nobody answered says so, without a risk +// flag it never had. +func TestManualModeTimeoutSaysNobodyAnswered(t *testing.T) { + a, pol, _ := gateApp(t, "run.default=allow") + a.approver = silentApprover{} + a.setMode(modeManual) + msg := a.riskGate(pol)(context.Background(), "run", "shell", map[string]any{"command": "ls"}) + if !strings.Contains(msg, "nobody answered") || strings.Contains(msg, "risk check") { + t.Errorf("result %q, want that nobody answered and no risk flag", msg) + } +} + +// A sub-agent spawn asks in manual even with spawns set to allow. +func TestManualModeAsksBeforeASpawn(t *testing.T) { + ap := &countingApprover{} + a := &app{cfg: config.Default(), workspace: t.TempDir(), approver: ap} + a.setMode(modeManual) + plan := spawnPlan{profile: "p", llmCfg: config.LLM{Model: "m"}, subWorkspace: a.workspace, spawnDefault: "allow"} + if _, err := a.runSpawnPlan(context.Background(), plan, "a task", nil); err == nil || ap.calls != 1 { + t.Errorf("spawn in manual: err %v, asked %d time(s); want one prompt and a refusal", err, ap.calls) + } +} + +// shift+tab and the /config row step auto → plan → manual → auto, and each +// lands where the agent and the indicator agree. +func TestModesCycle(t *testing.T) { + a := &app{cfg: config.Default()} + m := &tuiModel{app: a} + want := []mode{modePlan, modeManual, modeAuto} + for _, w := range want { + a.setMode(a.mode().next()) + if a.mode() != w || a.planMode != (w == modePlan) || a.manual.Load() != (w == modeManual) { + t.Fatalf("mode = %v (plan %v, manual %v), want %v", a.mode(), a.planMode, a.manual.Load(), w) + } + if line := m.modeLine(); !strings.Contains(line, w.String()+" mode on") { + t.Errorf("indicator %q, want %s", line, w) + } + } + a.setMode(modeManual) + if _, v, _ := m.configRowView("mode"); !strings.Contains(v, "manual") { + t.Errorf("/config shows %q for manual", v) + } + if got := m.shadowLabel("mode", m.liveShadow()); !strings.Contains(got, "manual") { + t.Errorf("/config preview shows %q for manual", got) + } +} diff --git a/cmd/agent/risk.go b/cmd/agent/risk.go index 11b98a5..105f436 100644 --- a/cmd/agent/risk.go +++ b/cmd/agent/risk.go @@ -126,13 +126,20 @@ func (a *app) riskGate(pol *policy.Engine) func(ctx context.Context, tool, actio default: return "" } - if !a.riskStops(ctx, kind) || policyVerdict(pol, name, action, params) != risk.VerdictAllow { + flagged := a.riskStops(ctx, kind) + if !flagged && !a.manualStops(name, action) || policyVerdict(pol, name, action, params) != risk.VerdictAllow { return "" } ok, gone := tool.AskWith(ctx, kind, detail, a.approveGated) if ok { return "" } + if !flagged { // asked because of manual mode alone + if gone != "" { + return gone + } + return fmt.Sprintf("%s %s denied by user", kind, detail) + } if gone != "" { return gone + " (The risk check had flagged it: " + riskNote(ctx) + ".)" } @@ -140,6 +147,26 @@ func (a *app) riskGate(pol *policy.Engine) func(ctx context.Context, tool, actio } } +// manualStops reports whether manual mode stops a call the policy would run +// without asking: in manual every change asks — any shell command, since any +// can change something, and every file write, append, edit and mkdir. Reads +// stay free, and an 'a' at a prompt still covers its kind for the session. +func (a *app) manualStops(name, action string) bool { + if !a.manual.Load() { + return false + } + switch name { + case "run": + return true + case "file": + switch action { + case "write", "append", "edit", "mkdir": + return true + } + } + return false +} + // withLaunchScore scores a command line a call is about to start: an external // agent's or an MCP server's. It comes from config, not from the call the model // made, so the call's own score never saw it — a checkout's file can put diff --git a/cmd/agent/staging.go b/cmd/agent/staging.go index 5c34df1..3bf3e62 100644 --- a/cmd/agent/staging.go +++ b/cmd/agent/staging.go @@ -255,12 +255,12 @@ func (m *tuiModel) reasoningWhileBusy(rest string) bool { // row would land can be worked out without touching a.cfg. type shadow struct { cfg config.Config - plan bool + mode mode accentIdx int } func (m *tuiModel) liveShadow() shadow { - return shadow{cfg: m.app.cfg, plan: m.app.planMode, accentIdx: m.accentIdx} + return shadow{cfg: m.app.cfg, mode: m.app.mode(), accentIdx: m.accentIdx} } // cycleRow is a row that steps through its values on enter: get reads where @@ -273,7 +273,7 @@ type cycleRow struct { func flip(b *bool) { *b = !*b } var cycleRows = map[string]cycleRow{ - "mode": {func(s *shadow) string { return strconv.FormatBool(s.plan) }, func(s *shadow) { flip(&s.plan) }}, + "mode": {func(s *shadow) string { return s.mode.String() }, func(s *shadow) { s.mode = s.mode.next() }}, "perm_files": {func(s *shadow) string { return s.cfg.File.Default }, func(s *shadow) { s.cfg.File.Default = nextStr(s.cfg.File.Default, permCycle) }}, "perm_run": {func(s *shadow) string { return s.cfg.Run.Default }, func(s *shadow) { s.cfg.Run.Default = nextStr(s.cfg.Run.Default, permCycle) }}, "timeout": {func(s *shadow) string { return strconv.Itoa(s.cfg.Run.TimeoutSeconds) }, @@ -327,8 +327,9 @@ var cycleRows = map[string]cycleRow{ // shadowLabel is how a row shows the shadow's value — the row's own wording. func (m *tuiModel) shadowLabel(key string, s shadow) string { - v := &tuiModel{app: &app{cfg: s.cfg, planMode: s.plan, workspace: m.app.workspace}, + v := &tuiModel{app: &app{cfg: s.cfg, workspace: m.app.workspace}, accent: lipgloss.Color(colorCycle[s.accentIdx]), accentIdx: s.accentIdx} + v.app.setMode(s.mode) _, value, _ := v.configRowView(key) return value } diff --git a/cmd/agent/tui.go b/cmd/agent/tui.go index 5a6ae5f..e2d28e8 100644 --- a/cmd/agent/tui.go +++ b/cmd/agent/tui.go @@ -83,7 +83,7 @@ type tuiModel struct { steer []string // /btw asides for the current run — pinned above the input, not in scrollback; cleared when the task ends histIdx int // recall cursor into app.promptHist; == len means "not browsing" histDraft string // in-progress input saved when history browsing begins - pendingMode *bool // plan(true)/auto(false) requested via shift+tab mid-task; applied on task done + pendingMode *mode // a mode requested via shift+tab mid-task; applied on task done taskDoneAway bool // a task finished while a modal panel was open over it; finalize on panel close planTask bool // the running task started in plan mode → offer accept-plan when it finishes taskCancelled bool // the running task was cancelled by esc → don't offer accept-plan @@ -442,7 +442,7 @@ func bannerLines(name, ver, provider, model, cwd string, window int, accent lipg Padding(0, 2). Render(body) tip := cDim.Render(`type a task — e.g. "explain what main.go does" — or /help for commands`) - keys := cDim.Render("Tab complete · ctrl+r history · alt+enter newline · ctrl+u clear · ctrl+g redraw · ctrl+c quit · shift+tab plan⇄auto") + keys := cDim.Render("Tab complete · ctrl+r history · alt+enter newline · ctrl+u clear · ctrl+g redraw · ctrl+c quit · shift+tab auto/plan/manual") return append(strings.Split(box, "\n"), "", tip, keys) } @@ -1001,16 +1001,16 @@ func (m *tuiModel) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case "shift+tab": switch m.state { case stIdle: - m.app.setMode(!m.app.planMode) // cycle plan/auto; the bottom indicator updates + m.app.setMode(m.app.mode().next()) // cycle auto → plan → manual; the bottom indicator updates m.pendingMode = nil case stRunning: // Can't flip live — the running agent reads the mode (flipping races + // splits the run). Remember it and apply when this task finishes. - cur := m.app.planMode + cur := m.app.mode() if m.pendingMode != nil { cur = *m.pendingMode } - next := !cur + next := cur.next() m.pendingMode = &next m.push(cDim.Render(" ⇄ " + modeName(next) + " — applies on the next task")) } @@ -1141,7 +1141,7 @@ func (m *tuiModel) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case stPlanReview: switch k.String() { case "enter", "y", "a": // accept: switch to auto and execute the proposed plan - m.app.setMode(false) + m.app.setMode(modeAuto) m.push(cYou.Render("❯ ") + "execute the plan") return m, m.runTask("Execute the plan you just proposed above, step by step. If a detail is unclear, make a reasonable call and proceed.") case "esc", "n", "q": // keep planning — stay in plan mode; type to refine or shift+tab to auto @@ -1337,13 +1337,8 @@ func (m *tuiModel) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } } -// modeName labels a plan/auto boolean for the mid-task mode-switch hint. -func modeName(plan bool) string { - if plan { - return "plan mode" - } - return "auto mode" -} +// modeName labels a mode for the mid-task mode-switch hint. +func modeName(m mode) string { return m.String() + " mode" } // applyPendingMode applies a plan/auto switch requested (via shift+tab) while a // task was running — deferred so it never flips the mode mid-run. No-op if none @@ -1354,7 +1349,7 @@ func (m *tuiModel) applyPendingMode() { } want := *m.pendingMode m.pendingMode = nil - if want != m.app.planMode { + if want != m.app.mode() { m.push(cDim.Render(" " + m.app.setMode(want))) } } @@ -1680,9 +1675,11 @@ func (m *tuiModel) runCommand(line string) (tea.Model, tea.Cmd) { case "/compact": return m, m.startCompact(false, rest) case "/plan": - m.push(cDim.Render(m.app.setMode(true))) + m.push(cDim.Render(m.app.setMode(modePlan))) case "/auto": - m.push(cDim.Render(m.app.setMode(false))) + m.push(cDim.Render(m.app.setMode(modeAuto))) + case "/manual": + m.push(cDim.Render(m.app.setMode(modeManual))) case "/license", "/licenses": m.pushLines(append(strings.Split(legal.Summary(), "\n"), "", "Full texts: ipsupport-code --license")) case "/update": @@ -2381,11 +2378,15 @@ func (m *tuiModel) approvePrompt() string { return m.attention("⚠ APPROVE") + " " + detail + " " + yes + no + cDim.Render(" (a = allow all this session)") + m.approvalLeft() } -// modeLine is the bottom indicator: auto (executes) or plan (proposes only), -// cycled with shift+tab — the same affordance as Claude Code. +// modeLine is the bottom indicator: auto (executes), plan (proposes only) or +// manual (asks before every change), cycled with shift+tab — the same +// affordance as Claude Code. func (m *tuiModel) modeLine() string { - if m.app.planMode { + switch m.app.mode() { + case modePlan: return " " + m.accentBold().Render("⏸ plan mode on") + cDim.Render(" (shift+tab to cycle · read-only, it proposes)") + case modeManual: + return " " + m.accentBold().Render("✋ manual mode on") + cDim.Render(" (shift+tab to cycle · asks before every change)") } return " " + cOk.Render("⏵⏵ auto mode on") + cDim.Render(" (shift+tab to cycle)") } @@ -2709,6 +2710,7 @@ var commandList = []cmdInfo{ {"/compact", "[focus] — summarize the session so far to free up context"}, {"/plan", "plan mode — propose a plan, change nothing"}, {"/auto", "auto mode — execute the task (default)"}, + {"/manual", "manual mode — execute, but ask before every change"}, {"/ai", "switch/add AI provider; key ; add [model] [key=] (custom, one step)"}, {"/model", "list the provider's models, or pick one"}, {"/config", "settings panel — interactive in the TUI (↑↓ · enter · esc); a static overview in the REPL"}, From b107cb4380bf059a63850a7fafc1ab4c546426f8 Mon Sep 17 00:00:00 2001 From: Roman Romenskyi Date: Mon, 19 Oct 2026 18:47:54 -0600 Subject: [PATCH 2/3] =?UTF-8?q?Modes:=20review=20fixes=20=E2=80=94=20judge?= =?UTF-8?q?=20a=20recovered=20call,=20keep=20manual=20on=20plan=20accept?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A call whose value landed in the action field ("touch x" as run's action) was scored, plan-checked and gated as nothing, then recovered inside Dispatch and run — manual mode, the risk check and plan mode all missed it. Registry.Normalize applies Dispatch's recovery up front, and execOne judges the call as it will run. - The gate reads params as the tools do: a numeric path is a path. - Accepting a plan ran it in auto even after shift+tab had chosen manual; it now keeps manual, and m at PLAN READY runs a plan in manual from auto. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++-- cmd/agent/manual_mode_test.go | 50 +++++++++++++++++++++++++++++++++++ cmd/agent/risk.go | 12 ++++----- cmd/agent/tui.go | 28 +++++++++++++++++--- internal/agent/agent.go | 3 +++ internal/agent/agent_test.go | 44 ++++++++++++++++++++++++++++++ internal/tool/registry.go | 36 +++++++++++++++++++++++++ 7 files changed, 167 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8cd1ade..e6465d0 100644 --- a/README.md +++ b/README.md @@ -462,8 +462,10 @@ for the session only. holds in every mode. When a plan-mode task finishes with a plan, you get a one-key **handshake**: -**enter** to accept — switches to auto and immediately executes the plan — or -**esc** to keep planning (stay in plan mode; type to refine). Toggling the mode +**enter** to accept — switches to auto and immediately executes the plan — +**m** to execute it in manual instead, asking before each change, or **esc** to +keep planning (stay in plan mode; type to refine). If you switched to manual +with shift+tab while the plan was being written, **enter** runs it in manual. Toggling the mode **mid-task** applies at the next turn, never mid-run (the running agent reads the mode live). diff --git a/cmd/agent/manual_mode_test.go b/cmd/agent/manual_mode_test.go index 8e3422c..0cdbe9e 100644 --- a/cmd/agent/manual_mode_test.go +++ b/cmd/agent/manual_mode_test.go @@ -5,7 +5,11 @@ import ( "strings" "testing" + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/ipsupport-llc/ipsupport-code/internal/config" + "github.com/ipsupport-llc/ipsupport-code/internal/tool" ) // Manual mode asks before every change the policy would have let through @@ -95,3 +99,49 @@ func TestModesCycle(t *testing.T) { t.Errorf("/config preview shows %q for manual", got) } } + +// Accepting a plan runs it in manual when manual was chosen while it was being +// written, or when accepted with m — it used to switch to auto regardless, and +// every change then ran without the yes the user had asked to give. +func TestAnAcceptedPlanKeepsManual(t *testing.T) { + for _, c := range []struct { + name string + from mode + key tea.KeyMsg + want mode + }{ + {"enter from plan", modePlan, tea.KeyMsg{Type: tea.KeyEnter}, modeAuto}, + {"m from plan", modePlan, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}, modeManual}, + {"enter after shift+tab to manual", modeManual, tea.KeyMsg{Type: tea.KeyEnter}, modeManual}, + } { + t.Run(c.name, func(t *testing.T) { + m := &tuiModel{app: &app{cfg: config.Default()}, input: textarea.New(), state: stPlanReview} + m.app.setMode(c.from) + if c.from == modeManual && !strings.Contains(m.planReviewKeys(), "in manual") { + t.Errorf("keys %q do not say the plan runs in manual", m.planReviewKeys()) + } + mode := m.planTarget(c.key.String() == "m") + if mode != c.want { + t.Errorf("accepting with %q from %v runs in %v, want %v", c.key.String(), c.from, mode, c.want) + } + }) + } +} + +// A run call whose command arrived in the action field is judged as the +// command it runs as: manual mode asks about it. It used to pass the gate as +// nothing, then run once Dispatch recovered the command. +func TestManualModeJudgesARecoveredCall(t *testing.T) { + a, pol, ap := gateApp(t, "run.default=allow", "file.default=allow") + a.setMode(modeManual) + reg := tool.NewRegistry(tool.NewRun(pol, ap, 0, 0, tool.Shell(""))) + action, params := reg.Normalize("run", "touch example.txt", map[string]any{}) + if msg := a.riskGate(pol)(context.Background(), "run", action, params); ap.calls != 1 || !strings.Contains(msg, "touch example.txt") { + t.Errorf("recovered call: asked %d time(s), result %q; want it asked about as the command it runs", ap.calls, msg) + } + // A number is a path to the tool, so it is one to the gate. + before := ap.calls + if msg := a.riskGate(pol)(context.Background(), "file", "write", map[string]any{"path": 123}); ap.calls != before+1 || !strings.Contains(msg, "write 123") { + t.Errorf("numeric path: asked %d time(s), result %q; want a prompt", ap.calls-before, msg) + } +} diff --git a/cmd/agent/risk.go b/cmd/agent/risk.go index 105f436..4d3ef14 100644 --- a/cmd/agent/risk.go +++ b/cmd/agent/risk.go @@ -119,10 +119,10 @@ func (a *app) riskGate(pol *policy.Engine) func(ctx context.Context, tool, actio kind, detail := name, "" switch name { case "run": - detail, _ = params["command"].(string) + detail = tool.Str(params, "command") // read as the tool reads it: a number is a path too case "file": kind = action - detail, _ = params["path"].(string) + detail = tool.Str(params, "path") default: return "" } @@ -274,19 +274,19 @@ func (a *app) persistRiskLearning(c risk.Correction) { // the shadow log records as "no comparison available" instead of inventing a // verdict for the disagreement column to be wrong about. A disagreement count // built on a guess is worse than a smaller one built on facts. -func policyVerdict(pol *policy.Engine, tool, action string, params map[string]any) risk.PolicyVerdict { +func policyVerdict(pol *policy.Engine, name, action string, params map[string]any) risk.PolicyVerdict { if pol == nil { return risk.VerdictUnknown } - switch tool { + switch name { case "run": - cmd, _ := params["command"].(string) + cmd := tool.Str(params, "command") if cmd == "" { return risk.VerdictUnknown } return fromDecision(pol.Run(cmd)) case "file": - path, _ := params["path"].(string) + path := tool.Str(params, "path") if path == "" { switch action { case "list", "find", "search": // the tool reads these from "." then diff --git a/cmd/agent/tui.go b/cmd/agent/tui.go index e2d28e8..59392bf 100644 --- a/cmd/agent/tui.go +++ b/cmd/agent/tui.go @@ -722,7 +722,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // A plan-mode task just proposed a plan — offer the accept→execute handshake. m.planTask = false m.state = stPlanReview - m.push(" " + m.attention("▸ PLAN READY") + " enter: do it (switch to auto & execute) · esc: keep planning (type to refine)") + m.push(" " + m.attention("▸ PLAN READY") + " " + m.planReviewKeys() + " (type to refine)") return m, detect } m.planTask = false @@ -1140,8 +1140,8 @@ func (m *tuiModel) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case stPlanReview: switch k.String() { - case "enter", "y", "a": // accept: switch to auto and execute the proposed plan - m.app.setMode(modeAuto) + case "enter", "y", "a", "m": // accept: execute the proposed plan, in auto — or manual (see planTarget) + m.app.setMode(m.planTarget(k.String() == "m")) m.push(cYou.Render("❯ ") + "execute the plan") return m, m.runTask("Execute the plan you just proposed above, step by step. If a detail is unclear, make a reasonable call and proceed.") case "esc", "n", "q": // keep planning — stay in plan mode; type to refine or shift+tab to auto @@ -1337,6 +1337,26 @@ func (m *tuiModel) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { } } +// planTarget is the mode an accepted plan runs in: manual when asked for with +// m, or when manual was already chosen with shift+tab while the plan was being +// written — accepting used to switch to auto regardless, and every change +// then ran without the yes the user had asked to give. Auto otherwise. +func (m *tuiModel) planTarget(manual bool) mode { + if manual || m.app.mode() == modeManual { + return modeManual + } + return modeAuto +} + +// planReviewKeys is the PLAN READY handshake's key line, naming the mode the +// plan will run in. +func (m *tuiModel) planReviewKeys() string { + if m.app.mode() == modeManual { + return "enter: do it in manual (asks before each change) · esc: not now" + } + return "enter: do it (switch to auto & execute) · m: do it in manual (asks before each change) · esc: keep planning" +} + // modeName labels a mode for the mid-task mode-switch hint. func modeName(m mode) string { return m.String() + " mode" } @@ -2235,7 +2255,7 @@ func (m *tuiModel) View() string { } case m.state == stPlanReview: // Waiting for a keypress — animated spinner + inverse badge, NOT a dim footnote. - status = m.spin.View() + " " + m.attention("▸ PLAN READY") + " enter: do it (switch to auto & execute) · esc: keep planning" + status = m.spin.View() + " " + m.attention("▸ PLAN READY") + " " + m.planReviewKeys() case m.state == stGoalResume: status = m.spin.View() + " " + m.attention("◎ RESUME GOAL") + " enter: resume · esc: not now" case m.state == stHistSearch: diff --git a/internal/agent/agent.go b/internal/agent/agent.go index cb7a124..3e19e75 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -2590,6 +2590,9 @@ func (a *Agent) anyMutating(calls []llm.ToolCall) bool { func (a *Agent) execOne(ctx context.Context, c llm.ToolCall) (llm.Message, bool) { action, params, argWarn := parseArgs(c.Arguments) + // Judged as it will run: a missing or garbled action is recovered here, not + // only inside Dispatch after every check below has passed it by. + action, params = a.reg.Normalize(c.Name, action, params) // Risk scoring, if anything is listening. Called with the parsed call and // nothing else: the observer decides what to score and what to compare it diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 8e9ead2..f83ccfd 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -4630,3 +4630,47 @@ func TestDegenerateNudgeIsAHarnessMessage(t *testing.T) { t.Error("degenerateNudge is not recognised as a harness message") } } + +// A call whose value landed in the action field is judged as the call it runs +// as: the observer, plan mode and the gate all see the recovered action and +// params. It used to be judged as nothing, then recovered inside Dispatch and +// run. +func TestARecoveredCallIsJudgedAsItRuns(t *testing.T) { + ran := "" + reg := tool.NewRegistry(tool.NewDomain(tool.DomainSpec{ + Name: "run", Summary: "shell", + Actions: []tool.Action{{Name: "shell", Mutates: true, Params: []tool.Param{tool.Req("command", "str")}, + Run: func(_ context.Context, a tool.Args) tool.Result { ran = a.Str("command"); return tool.Ok("ok") }}}, + })) + fake := &scriptLLM{replies: []llm.Message{ + toolCallReply("c1", "run", `{"action":"touch x","params":{}}`), + {Role: "assistant", Content: "done"}, + }} + a := New(fake, reg, nil, nil, "", 5) + var scored, gated string + a.SetRiskObserver(func(ctx context.Context, _, action string, p map[string]any) (context.Context, string) { + scored = action + " " + tool.Str(p, "command") + return ctx, "" + }) + a.SetCallGate(func(_ context.Context, _, action string, p map[string]any) string { + gated = action + " " + tool.Str(p, "command") + return "denied" + }) + a.Run(context.Background(), "make x") + if scored != "shell touch x" || gated != "shell touch x" { + t.Errorf("scored %q, gated %q; want both to see shell touch x", scored, gated) + } + if ran != "" { + t.Errorf("the gate said no, but %q ran", ran) + } + + a = New(&scriptLLM{replies: []llm.Message{ + toolCallReply("c1", "run", `{"action":"touch x","params":{}}`), + {Role: "assistant", Content: "plan"}, + }}, reg, nil, nil, "", 5) + a.SetPlanMode(true) + a.Run(context.Background(), "make x") + if ran != "" { + t.Errorf("plan mode let a recovered mutating call run: %q", ran) + } +} diff --git a/internal/tool/registry.go b/internal/tool/registry.go index 773ddf5..1821380 100644 --- a/internal/tool/registry.go +++ b/internal/tool/registry.go @@ -135,6 +135,42 @@ func inferAction(t Tool, acts []string, params map[string]any) string { return "" } +// Normalize is the action and params Dispatch will actually run a call with, +// by the same recovery of a missing or garbled action. Anything that judges a +// call before it runs — the risk score, plan mode, the call gate — must see it +// this way, or a call recovered here runs unjudged: "touch x" dumped into +// run's action field was scored and gated as nothing, then run as a command +// (found by review). A call nothing recovers comes back as given, for +// Dispatch to refuse. +func (r *Registry) Normalize(name, action string, params map[string]any) (string, map[string]any) { + t, ok := r.tools[name] + if !ok { + return action, params + } + acts := t.Actions() + switch { + case contains(acts, action): + return action, params + case action == "": + if a := inferAction(t, acts, params); a != "" { + return a, params + } + if d, isDomain := t.(*Domain); isDomain { + if a, p, ok := d.soleRequiredParam(); ok && !isEmpty(params[p]) { + return a, params + } + } + case len(otherOwners(r.actionToTool[action], name)) == 0: + if a := inferAction(t, acts, params); a != "" { + return a, params + } + if a, p, ok := garbledActionAsParam(t, action, params); ok { + return a, p + } + } + return action, params +} + // garbledActionAsParam recovers a call where the model dumped its intended // value directly into "action" instead of params (see Domain. // soleRequiredParam's doc). Safe specifically because it only fires for a From 986ba0d39d31616e5560c7ca45118d6ea1e9f820 Mon Sep 17 00:00:00 2001 From: Roman Romenskyi Date: Mon, 19 Oct 2026 19:04:52 -0600 Subject: [PATCH 3/3] Modes: M accepts a plan in manual, like m Co-Authored-By: Claude Opus 5.5 --- cmd/agent/tui.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/agent/tui.go b/cmd/agent/tui.go index 59392bf..f4b9f69 100644 --- a/cmd/agent/tui.go +++ b/cmd/agent/tui.go @@ -1140,8 +1140,8 @@ func (m *tuiModel) handleKey(k tea.KeyMsg) (tea.Model, tea.Cmd) { case stPlanReview: switch k.String() { - case "enter", "y", "a", "m": // accept: execute the proposed plan, in auto — or manual (see planTarget) - m.app.setMode(m.planTarget(k.String() == "m")) + case "enter", "y", "a", "m", "M": // accept: execute the proposed plan, in auto — or manual (see planTarget) + m.app.setMode(m.planTarget(k.String() == "m" || k.String() == "M")) m.push(cYou.Render("❯ ") + "execute the plan") return m, m.runTask("Execute the plan you just proposed above, step by step. If a detail is unclear, make a reasonable call and proceed.") case "esc", "n", "q": // keep planning — stay in plan mode; type to refine or shift+tab to auto