Skip to content
Closed
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
25 changes: 23 additions & 2 deletions internal/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -898,12 +898,18 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}, nil
}
tool, toolFound := registry.Get(call.Name)
if permissionMode == PermissionModeSpecDraft && toolFound && !ToolAdvertised(tool, permissionMode) {
if (permissionMode == PermissionModeSpecDraft || permissionMode == PermissionModePlan) && toolFound && !ToolAdvertised(tool, permissionMode) {
modeName := string(permissionMode)
if permissionMode == PermissionModePlan {
modeName = "plan"
} else {
modeName = "spec-draft"
}
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: `Error: Tool "` + call.Name + `" is not available in spec-draft mode.`,
Output: `Error: Tool "` + call.Name + `" is not available in ` + modeName + ` mode.`,
DenialReason: DenialFiltered,
}, nil
}
Expand Down Expand Up @@ -2857,6 +2863,9 @@ func ToolAdvertised(tool tools.Tool, permissionMode PermissionMode) bool {
if permissionMode == PermissionModeSpecDraft {
return toolAdvertisedInSpecDraft(tool)
}
if permissionMode == PermissionModePlan {
return toolAdvertisedInPlan(tool)
}
if permissionMode == PermissionModeAuto {
return tool.Safety().Permission == tools.PermissionAllow || tool.Safety().AdvertiseInAuto
}
Expand Down Expand Up @@ -2889,6 +2898,18 @@ func toolAdvertisedInSpecDraft(tool tools.Tool) bool {
return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow
}

// toolAdvertisedInPlan mirrors toolAdvertisedInSpecDraft: the agent may only
// read the workspace, ask the user, and shape the plan with update_plan. No
// mutating tool is advertised, so plan mode stays strictly read-only.
func toolAdvertisedInPlan(tool tools.Tool) bool {
switch tool.Name() {
case "ask_user", "update_plan":
return true
}
safety := tool.Safety()
return safety.SideEffect == tools.SideEffectRead && safety.Permission == tools.PermissionAllow
}

func stopReasonFromToolResult(result ToolResult) StopReason {
if result.Meta == nil {
return ""
Expand Down
6 changes: 6 additions & 0 deletions internal/agent/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ const (
PermissionModeAsk PermissionMode = "ask"
PermissionModeUnsafe PermissionMode = "unsafe"
PermissionModeSpecDraft PermissionMode = "spec-draft"
// PermissionModePlan is an interactive, read-only planning mode toggled from
// the TUI with /plan. It applies to the CURRENT session (unlike spec-draft,
// which drafts in a separate session): the agent may inspect the workspace
// and shape the plan with update_plan/ask_user, but no mutating tool is
// advertised, so it cannot write files, run shell, or implement while planning.
PermissionModePlan PermissionMode = "plan"
// PermissionModeMemberAuto is a headless mode for swarm/specialist MEMBERS: it
// advertises the in-workspace mutators a member needs to build (write/edit +
// shell) on top of the Auto set, while the sandbox engine still gates them at
Expand Down
6 changes: 4 additions & 2 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ type model struct {
agentOptions agent.Options
notifier *notify.Notifier
permissionMode agent.PermissionMode
// program is the live Bubble Tea program, set right before Run so /plan open
// can suspend the TUI, launch $EDITOR, and resume on exit.
program *tea.Program
selfCorrectTests bool
reasoningEffort modelregistry.ReasoningEffort
responseStyle string
Expand Down Expand Up @@ -4204,8 +4207,7 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.debugText()})
return m, nil
case commandPlan:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.planText()})
return m, nil
return m.handlePlanCommand(command.text)
case commandDoctor:
return m.startDoctorCommand(command.text)
case commandSearch:
Expand Down
130 changes: 125 additions & 5 deletions internal/tui/plan_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,141 @@

import (
"fmt"
"os"
"os/exec"
"strings"

tea "charm.land/bubbletea/v2"

"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/planmode"

Check failure on line 12 in internal/tui/plan_command.go

View workflow job for this annotation

GitHub Actions / Security & code health

could not import github.com/Gitlawb/zero/internal/planmode (invalid package name: "")

Check failure on line 12 in internal/tui/plan_command.go

View workflow job for this annotation

GitHub Actions / Security & code health

no required module provides package github.com/Gitlawb/zero/internal/planmode; to add it:

Check failure on line 12 in internal/tui/plan_command.go

View workflow job for this annotation

GitHub Actions / Zero Review

no required module provides package github.com/Gitlawb/zero/internal/planmode; to add it:

Check failure on line 12 in internal/tui/plan_command.go

View workflow job for this annotation

GitHub Actions / Zero Review

no required module provides package github.com/Gitlawb/zero/internal/planmode; to add it:

Check failure on line 12 in internal/tui/plan_command.go

View workflow job for this annotation

GitHub Actions / Zero Review

no required module provides package github.com/Gitlawb/zero/internal/planmode; to add it:

Check failure on line 12 in internal/tui/plan_command.go

View workflow job for this annotation

GitHub Actions / Smoke (windows-latest)

no required module provides package github.com/Gitlawb/zero/internal/planmode; to add it:

Check failure on line 12 in internal/tui/plan_command.go

View workflow job for this annotation

GitHub Actions / Smoke (windows-latest)

no required module provides package github.com/Gitlawb/zero/internal/planmode; to add it:

Check failure on line 12 in internal/tui/plan_command.go

View workflow job for this annotation

GitHub Actions / Smoke (windows-latest)

no required module provides package github.com/Gitlawb/zero/internal/planmode; to add it:

Check failure on line 12 in internal/tui/plan_command.go

View workflow job for this annotation

GitHub Actions / Smoke (macos-latest)

no required module provides package github.com/Gitlawb/zero/internal/planmode; to add it:

Check failure on line 12 in internal/tui/plan_command.go

View workflow job for this annotation

GitHub Actions / Smoke (macos-latest)

no required module provides package github.com/Gitlawb/zero/internal/planmode; to add it:

Check failure on line 12 in internal/tui/plan_command.go

View workflow job for this annotation

GitHub Actions / Smoke (macos-latest)

no required module provides package github.com/Gitlawb/zero/internal/planmode; to add it:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | 🏗️ Heavy lift

Restore the missing planmode package or correct this import.

Current checks fail because github.com/Gitlawb/zero/internal/planmode is not provided by the module. This blocks compilation of the TUI; commit the package implementing PlanFilePath, ReadPlan, and WritePlan, or import the existing package path.

🧰 Tools
🪛 GitHub Check: Security & code health

[failure] 12-12:
could not import github.com/Gitlawb/zero/internal/planmode (invalid package name: "")


[failure] 12-12:
no required module provides package github.com/Gitlawb/zero/internal/planmode; to add it:

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/plan_command.go` at line 12, The TUI imports an unavailable
planmode package, preventing compilation. In internal/tui/plan_command.go,
correct the import to the existing package path or restore the internal/planmode
package with PlanFilePath, ReadPlan, and WritePlan implementations matching the
command’s usage.

Source: Linters/SAST tools

"github.com/Gitlawb/zero/internal/tools"
)

type currentPlanReader interface {
CurrentPlan() []tools.PlanItem
}

// handlePlanCommand toggles plan mode on the current session, in the style of
// openclaude's /plan:
//
// /plan toggle plan mode on/off; when on, show the current plan
// /plan open open the session's plan file in $VISUAL/$EDITOR
// /plan off exit plan mode (alias: /plan exit)
//
// Plan mode is read-only: tool advertisement (see agent.toolAdvertisedInPlan)
// only exposes read tools, update_plan, and ask_user, so the agent cannot
// mutate the workspace while planning.
func (m model) handlePlanCommand(text string) (tea.Model, tea.Cmd) {
if _, ok := m.registry.Get("update_plan"); !ok {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "No plan is active."})
return m, nil
}

arg := strings.ToLower(strings.TrimSpace(text))
switch arg {
case "off", "exit":
if m.permissionMode != agent.PermissionModePlan {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Plan mode is not active."})
return m, nil
}
m.permissionMode = agent.PermissionModeAuto
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Exited plan mode. The agent can now implement."})
return m, nil
case "open":
return m.openPlanInEditor()
}

// No subcommand: toggle plan mode, then surface the current plan.
if m.permissionMode == agent.PermissionModePlan {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.planText()})
return m, nil
Comment on lines +50 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make bare /plan actually toggle plan mode.

When plan mode is already active, this branch only displays the plan and never exits the mode. That conflicts with the documented /plan toggle behavior and the PR objective. Reset permissionMode to PermissionModeAuto here, or revise the command contract to make /plan view-only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/plan_command.go` around lines 50 - 53, Update the bare `/plan`
handling in the branch checking `m.permissionMode == agent.PermissionModePlan`
so it resets `m.permissionMode` to `agent.PermissionModeAuto` before appending
the current plan, preserving the documented toggle behavior.

}
if m.pending || m.exiting {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "Cannot enter plan mode while a run is active."})
return m, nil
}
m.permissionMode = agent.PermissionModePlan
textToShow := planEnterText(m) + "\n\n" + m.planText()
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: textToShow})
return m, nil
}

// openPlanInEditor writes the session plan file (if missing) and suspends the
// TUI to launch $VISUAL/$EDITOR on it, resuming on exit.
func (m model) openPlanInEditor() (tea.Model, tea.Cmd) {
if m.permissionMode != agent.PermissionModePlan {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Enter plan mode (/plan) before opening the plan file."})
return m, nil
}
path, err := planmode.PlanFilePath(m.cwd, m.activeSession.SessionID)
if err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "plan path error: " + err.Error()})
return m, nil
}
if _, ok := fileExists(path); !ok {
if _, err := planmode.WritePlan(m.cwd, m.activeSession.SessionID, ""); err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendError, text: "plan write error: " + err.Error()})
return m, nil
}
}
editor := strings.TrimSpace(os.Getenv("VISUAL"))
if editor == "" {
editor = strings.TrimSpace(os.Getenv("EDITOR"))
}
if editor == "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Set $VISUAL or $EDITOR to open the plan file:\n" + path})
return m, nil
}
if m.program == nil {
// No live program (e.g. under test): just report the path.
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Plan file: " + path})
return m, nil
}
parts := strings.Fields(editor)
cmd := exec.Command(parts[0], append(parts[1:], path)...) //nolint:gosec // editor path from $VISUAL/$EDITOR
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return m, tea.ExecProcess(cmd, func(err error) tea.Msg {
return nil
})
}

func planEnterText(m model) string {
path, err := planmode.PlanFilePath(m.cwd, m.activeSession.SessionID)
planNote := ""
if err == nil && path != "" {
planNote = "\nPlan file: " + path
}
return "Entered plan mode. The agent can inspect the workspace and shape the plan with update_plan, but cannot edit files or run commands until you exit.\n" +
"Use /plan to view the plan, /plan open to edit it, or /plan off to implement." + planNote
}

func (m model) planText() string {
// Prefer the session plan file when present.
if path, err := planmode.PlanFilePath(m.cwd, m.activeSession.SessionID); err == nil && path != "" {
if content, exists, err := planmode.ReadPlan(m.cwd, m.activeSession.SessionID); err == nil && exists {
header := "Current Plan (plan mode)"
if path != "" {
header += "\n" + path
}
return header + "\n" + strings.TrimRight(content, "\n")
}
}

// Fall back to the update_plan list the agent has been building.
tool, ok := m.registry.Get("update_plan")
if !ok {
return "No plan is active."
return "Plan mode is active. No plan written yet. Use update_plan to outline steps, or /plan open to draft the plan file."
}

reader, ok := tool.(currentPlanReader)
if !ok {
return "No plan is active."
return "Plan mode is active. No plan written yet."
}

plan := reader.CurrentPlan()
if len(plan) == 0 {
return "No plan is active."
return "Plan mode is active. No plan written yet. Use update_plan to outline steps, or /plan open to draft the plan file."
}

lines := make([]string, 0, len(plan)+1)
Expand All @@ -38,3 +150,11 @@
}
return strings.Join(lines, "\n")
}

func fileExists(path string) (struct{}, bool) {
_, err := os.Stat(path)
if err != nil {
return struct{}{}, false
}
return struct{}{}, true
}
1 change: 1 addition & 0 deletions internal/tui/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func Run(ctx context.Context, options Options) int {
initialModel.mouseCapture = true
}
program = tea.NewProgram(initialModel, programOpts...)
initialModel.program = program

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

rg -n -C 3 'tea\.NewProgram\(|initialModel\.program|m\.program|tea\.ExecProcess' internal/tui

Repository: Gitlawb/zero

Length of output: 1613


Initialize program before calling tea.NewProgram
initialModel.program = program happens after tea.NewProgram(initialModel, ...), so the running model still sees m.program == nil. That forces /plan open down the “under test” path and skips launching the editor. Set the field before handing the model to Bubble Tea, or remove the guard and rely on the returned tea.ExecProcess command.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/run.go` at line 60, Initialize the model’s program reference
before constructing the Bubble Tea program: update the setup around
initialModel.program and tea.NewProgram so the model receives a non-nil program
when it starts, or remove the under-test guard and rely on the returned
tea.ExecProcess command.


if _, err := program.Run(); err != nil {
// Surface the failure: exiting 1 with zero diagnostics left users
Expand Down
Loading