Skip to content
Merged
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
26 changes: 18 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -443,19 +443,29 @@ In the TUI, `/sessions` lists/switches/deletes threads, `/new <name>` 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
**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).

Expand Down Expand Up @@ -827,7 +837,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 <text>` | set & pursue a multi-turn goal (judge re-feeds until met); `go` · `clear` · `ttl <n>` · `off` |
| `/skills` | list / toggle / install on-demand instruction packs |
| `/permissions` | relax approval for non-destructive file/shell actions |
Expand Down
9 changes: 3 additions & 6 deletions cmd/agent/config_panel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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":
Expand Down
62 changes: 51 additions & 11 deletions cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 <name> <tok>; /ai add <name> <url> [model] (custom)
/model [name] list the provider's models, or pick one
/config control panel: all settings + how to change them
Expand Down Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions cmd/agent/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
147 changes: 147 additions & 0 deletions cmd/agent/manual_mode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package main

import (
"context"
"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
// 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)
}
}

// 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)
}
}
Loading
Loading