diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 2ea4ac0bd..ed9314520 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -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 } @@ -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 } @@ -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 "" diff --git a/internal/agent/types.go b/internal/agent/types.go index 1b12e0c78..91f9d1eea 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -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 diff --git a/internal/cron/store.go b/internal/cron/store.go index cee2fa0f2..2e5a78f75 100644 --- a/internal/cron/store.go +++ b/internal/cron/store.go @@ -7,7 +7,9 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" + "syscall" "time" ) @@ -144,7 +146,7 @@ func (s *Store) writeJob(job Job) error { if err := os.WriteFile(tmp, data, 0o600); err != nil { return err } - return os.Rename(tmp, filepath.Join(dir, "metadata.json")) + return renameWithRetry(tmp, filepath.Join(dir, "metadata.json")) } func (s *Store) Get(id string) (Job, error) { @@ -322,3 +324,30 @@ func (s *Store) Runs(id string) ([]RunRecord, error) { } return runs, scanner.Err() } + +func renameWithRetry(src, dst string) error { + var err error + for i := 0; i < 10; i++ { + err = os.Rename(src, dst) + if err == nil { + return nil + } + if runtime.GOOS == "windows" { + if os.IsPermission(err) || isWindowsSharingViolation(err) { + time.Sleep(10 * time.Millisecond) + continue + } + } + break + } + return err +} + +func isWindowsSharingViolation(err error) bool { + var errno syscall.Errno + if errors.As(err, &errno) { + const ERROR_SHARING_VIOLATION syscall.Errno = 32 + return errno == ERROR_SHARING_VIOLATION + } + return false +} diff --git a/internal/sandbox/windows_token_windows.go b/internal/sandbox/windows_token_windows.go index 5c5498e74..6807147d8 100644 --- a/internal/sandbox/windows_token_windows.go +++ b/internal/sandbox/windows_token_windows.go @@ -92,14 +92,24 @@ func createWindowsRestrictedTokenFromBase(base windows.Token, capabilitySIDs []w if err != nil { return 0, fmt.Errorf("create world SID: %w", err) } + usersSID, err := windows.CreateWellKnownSid(windows.WinBuiltinUsersSid) + if err != nil { + return 0, fmt.Errorf("create users SID: %w", err) + } + authUserSID, err := windows.CreateWellKnownSid(windows.WinAuthenticatedUserSid) + if err != nil { + return 0, fmt.Errorf("create authenticated user SID: %w", err) + } - entries := make([]windows.SIDAndAttributes, 0, len(capabilitySIDs)+2) + entries := make([]windows.SIDAndAttributes, 0, len(capabilitySIDs)+4) for _, sid := range capabilitySIDs { entries = append(entries, windows.SIDAndAttributes{Sid: sid.sid}) } entries = append(entries, windows.SIDAndAttributes{Sid: sidFromBytes(logonSID)}, windows.SIDAndAttributes{Sid: worldSID}, + windows.SIDAndAttributes{Sid: usersSID}, + windows.SIDAndAttributes{Sid: authUserSID}, ) var restricted windows.Token diff --git a/internal/sandbox/windows_unelevated.go b/internal/sandbox/windows_unelevated.go index 6934071de..5d387b17e 100644 --- a/internal/sandbox/windows_unelevated.go +++ b/internal/sandbox/windows_unelevated.go @@ -6,7 +6,10 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" + "syscall" + "time" ) const windowsUnelevatedSetupMarkerSchemaVersion = 1 @@ -138,9 +141,36 @@ func recordWindowsUnelevatedAppliedPlan(sandboxHome string, applied WindowsUnele _ = os.Remove(tmpPath) return fmt.Errorf("close windows unelevated setup marker temp file: %w", err) } - if err := os.Rename(tmpPath, path); err != nil { + if err := renameWithRetry(tmpPath, path); err != nil { _ = os.Remove(tmpPath) return fmt.Errorf("replace windows unelevated setup marker: %w", err) } return nil } + +func renameWithRetry(src, dst string) error { + var err error + for i := 0; i < 10; i++ { + err = os.Rename(src, dst) + if err == nil { + return nil + } + if runtime.GOOS == "windows" { + if os.IsPermission(err) || isWindowsSharingViolation(err) { + time.Sleep(10 * time.Millisecond) + continue + } + } + break + } + return err +} + +func isWindowsSharingViolation(err error) bool { + var errno syscall.Errno + if errors.As(err, &errno) { + const ERROR_SHARING_VIOLATION syscall.Errno = 32 + return errno == ERROR_SHARING_VIOLATION + } + return false +} diff --git a/internal/sessions/store.go b/internal/sessions/store.go index b0da699aa..3e44501db 100644 --- a/internal/sessions/store.go +++ b/internal/sessions/store.go @@ -13,6 +13,7 @@ import ( "strings" "sync" "sync/atomic" + "syscall" "time" ) @@ -841,7 +842,7 @@ func (store *Store) writeMetadata(session Metadata) error { if err := writeFileSync(tmp, append(data, '\n'), 0o600); err != nil { return fmt.Errorf("write zero session metadata: %w", err) } - if err := os.Rename(tmp, path); err != nil { + if err := renameWithRetry(tmp, path); err != nil { _ = os.Remove(tmp) return fmt.Errorf("replace zero session metadata: %w", err) } @@ -904,7 +905,7 @@ func (store *Store) writeFileAtomicSync(path string, content []byte, perm os.Fil if err := writeFileSync(tmp, content, perm); err != nil { return err } - if err := os.Rename(tmp, path); err != nil { + if err := renameWithRetry(tmp, path); err != nil { _ = os.Remove(tmp) return err } @@ -1091,3 +1092,30 @@ func applySpecRecord(session *Metadata, input RecordSpecInput, status SpecStatus session.SpecImplSessionID = implID } } + +func renameWithRetry(src, dst string) error { + var err error + for i := 0; i < 10; i++ { + err = os.Rename(src, dst) + if err == nil { + return nil + } + if runtime.GOOS == "windows" { + if os.IsPermission(err) || isWindowsSharingViolation(err) { + time.Sleep(10 * time.Millisecond) + continue + } + } + break + } + return err +} + +func isWindowsSharingViolation(err error) bool { + var errno syscall.Errno + if errors.As(err, &errno) { + const ERROR_SHARING_VIOLATION syscall.Errno = 32 + return errno == ERROR_SHARING_VIOLATION + } + return false +} diff --git a/internal/swarm/mailbox.go b/internal/swarm/mailbox.go index fef0caf69..d435afa45 100644 --- a/internal/swarm/mailbox.go +++ b/internal/swarm/mailbox.go @@ -7,8 +7,10 @@ import ( "os" "path/filepath" "regexp" + "runtime" "strings" "sync/atomic" + "syscall" "time" ) @@ -322,7 +324,7 @@ func atomicWriteJSON(path string, data any) error { if err := tmp.Close(); err != nil { return fmt.Errorf("swarm: close temp inbox: %w", err) } - if err := os.Rename(tmpName, path); err != nil { + if err := renameWithRetry(tmpName, path); err != nil { return fmt.Errorf("swarm: commit inbox: %w", err) } return nil @@ -392,3 +394,30 @@ func acquireLock(lockPath string, timeout time.Duration) (func(), error) { time.Sleep(2 * time.Millisecond) } } + +func renameWithRetry(src, dst string) error { + var err error + for i := 0; i < 10; i++ { + err = os.Rename(src, dst) + if err == nil { + return nil + } + if runtime.GOOS == "windows" { + if os.IsPermission(err) || isWindowsSharingViolation(err) { + time.Sleep(10 * time.Millisecond) + continue + } + } + break + } + return err +} + +func isWindowsSharingViolation(err error) bool { + var errno syscall.Errno + if errors.As(err, &errno) { + const ERROR_SHARING_VIOLATION syscall.Errno = 32 + return errno == ERROR_SHARING_VIOLATION + } + return false +} diff --git a/internal/swarm/mailbox_test.go b/internal/swarm/mailbox_test.go index 2164eb911..ebb79cd85 100644 --- a/internal/swarm/mailbox_test.go +++ b/internal/swarm/mailbox_test.go @@ -295,6 +295,7 @@ func TestMailboxConcurrentSends(t *testing.T) { defer wg.Done() if err := mb.Send("team", "bob", Message{From: "a", Body: "concurrent"}); err != nil { failures.Add(1) + t.Logf("Send error: %v", err) } }() } diff --git a/internal/tui/model.go b/internal/tui/model.go index 908777668..40bf1cc48 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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 @@ -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: diff --git a/internal/tui/plan_command.go b/internal/tui/plan_command.go index 8baf629da..5d4dd5b83 100644 --- a/internal/tui/plan_command.go +++ b/internal/tui/plan_command.go @@ -2,8 +2,14 @@ package tui import ( "fmt" + "os" + "os/exec" "strings" + tea "charm.land/bubbletea/v2" + + "github.com/Gitlawb/zero/internal/agent" + "github.com/Gitlawb/zero/internal/planmode" "github.com/Gitlawb/zero/internal/tools" ) @@ -11,20 +17,126 @@ 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 + } + 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) @@ -38,3 +150,11 @@ func (m model) planText() string { } return strings.Join(lines, "\n") } + +func fileExists(path string) (struct{}, bool) { + _, err := os.Stat(path) + if err != nil { + return struct{}{}, false + } + return struct{}{}, true +} diff --git a/internal/tui/run.go b/internal/tui/run.go index f2925dea2..ccde3964d 100644 --- a/internal/tui/run.go +++ b/internal/tui/run.go @@ -57,6 +57,7 @@ func Run(ctx context.Context, options Options) int { initialModel.mouseCapture = true } program = tea.NewProgram(initialModel, programOpts...) + initialModel.program = program if _, err := program.Run(); err != nil { // Surface the failure: exiting 1 with zero diagnostics left users