// Package control is the transport-agnostic session driver. A Controller owns // the agent run loop and session lifecycle, takes commands (Send/Cancel/Approve/ // SetPlanMode/Compact/NewSession/…), and emits everything that happens — // reasoning, tool calls, approvals, turn completion — as a typed event stream to // a single event.Sink. // // The point is one orchestration layer behind every frontend: a terminal TUI, a // desktop webview, or an HTTP/SSE server each drive the Controller identically // (issue commands, render events) and none of them re-implement turn lifecycle, // cancellation, or approval. The Controller depends on no frontend. package control import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "reasonix/internal/agent" "reasonix/internal/autoresearch" "reasonix/internal/billing" "reasonix/internal/capability" "reasonix/internal/checkpoint" "reasonix/internal/command" "reasonix/internal/config" "reasonix/internal/diff" "reasonix/internal/event" "reasonix/internal/evidence" "reasonix/internal/guardian" "reasonix/internal/hook" "reasonix/internal/i18n" "reasonix/internal/jobs" "reasonix/internal/memory" "reasonix/internal/memorycompiler" "reasonix/internal/nilutil" "reasonix/internal/permission" "reasonix/internal/plugin" "reasonix/internal/proc" "reasonix/internal/provider" "reasonix/internal/sandbox" "reasonix/internal/skill" "reasonix/internal/store" "reasonix/internal/tool" ) // ErrTurnRunning reports that a caller tried to start a second foreground turn // while one is already active in the same Controller. var ErrTurnRunning = errors.New("turn already running") // errTurnRunningRotation and errRotationInProgress are returned by the // session-rotation gate (beginRotation) when a rotation cannot proceed: a turn // is in flight, or another rotation already holds the gate. var ( errTurnRunningRotation = errors.New("cannot start a new session while a turn is running") errRotationInProgress = errors.New("cannot start a new session while another session change is in progress") ) // errNoSessionPath is returned by snapshot when a session has content to persist // but no resolved session path — a misconfiguration (e.g. an unresolvable data // dir in a bot deployment) that previously dropped conversations silently // (#4414). Callers log it and continue; it must never be swallowed quietly. var errNoSessionPath = errors.New("session has content but no session path; conversation cannot be persisted") // Controller drives one chat session. Construct with New; drive with the command // methods; observe through the Sink passed in Options. type Controller struct { runner agent.Runner executor *agent.Agent guardianSess *guardian.Session // nil when guardian is disabled guardianPath string // persisted guardian session file ("" when disabled) sink event.Sink policy permission.Policy label string modelRef string systemPrompt string sessionDir string commands atomic.Pointer[[]command.Command] // skills owns the session's discovered skills (enabled subset, full set, and // the reloadable stores) — the skills slice of the Capabilities concern. See // skill.go. skills skillSet skillRunner skill.SubagentRunner readOnlySkillRunner skill.SubagentRunner skillProfile skill.ProfileResolver slashSkillSeq atomic.Uint64 hooks *hook.Runner // session hook runner; nil-safe (no hooks configured) // hookContexts carries one-shot lifecycle hook context into the next real // user turn without changing the cache-stable system prompt. hookContexts []string // memory owns the loaded memory snapshot, the pending turn-tail notes queue, // and write serialization behind its own locks, off c.mu — so a memory-panel // save never stalls an approval or status poll. See memory.go. memory memoryManager cleanup func() autoPlan string responseLanguage string reasoningLanguage string // disableColdResumePrune skips stale-tool-result elision on cold resume. // Zero value keeps the prune on (the cheaper default). disableColdResumePrune bool shell sandbox.Shell // interpreter for user-invoked "!" commands; zero = auto classifier autoPlanClassifier startedOnce bool // guards the one-shot SessionStart hook on first turn closeOnce sync.Once // makes close idempotent under racing teardown paths onRemember func(rule string) RememberResult // set via Options; invoked when user picks "always allow" onRememberMCPReadOnlyTrust func(serverName, rawToolName string) MCPReadOnlyTrustResult onRememberPlanModeReadOnlyCommand func(prefix string) PlanModeReadOnlyCommandTrustResult sessionRecoveryMeta func(SessionRecoveryRequest) agent.BranchMeta onSessionRecovered func(SessionRecoveryInfo) error // balanceURL/balanceKey target the active provider's optional wallet-balance // endpoint (empty when the provider declares none). Captured at build so a // model/key switch — which rebuilds the controller — refreshes them. balanceURL string balanceKey string balanceClient *http.Client // jobs is the session-scoped background-job manager. The agent's background // tools spawn into it; Compose drains its completion notes into the next turn; // Close cancels its still-running jobs. jobs *jobs.Manager // mcp owns the session's live tool/plugin surface — the MCP plugin Host, the // tool registry the executor reads each turn, and the session-scoped context a // hot-added stdio server binds its subprocess to — behind its own lock, off // c.mu. The Controller keeps the config-facing orchestration (persisting // reasonix.toml on add/remove, building specs from entries). See mcp.go. mcp mcpManager // Capability routing (Delivery hybrid route). Not part of the provider-visible // prefix; only seeds the turn-scoped ledger and optional semantic router. pluginCfg []config.PluginEntry capCachedTools map[string][]plugin.CachedTool capCacheHashOK map[string]bool semanticRouter *capability.SemanticRouter capabilityAudit *capability.Audit runtimeProfile capability.Profile // goals owns the active goal's FSM (status, intercepts, idle/turn counters) // and its persistence, behind its own mutex so a per-turn goal save never // stalls an approval or status poll on c.mu. See goal.go. goals goalMachine autoResearch *autoresearch.Store // workspaceRoot is the workspace root: the base for resolving @-refs and slash // path refs, the working directory for user "!" shell commands and custom // command discovery, and the guard root for checkpoint restore writes. It is // surfaced to frontends via WorkspaceRoot(). workspaceRoot string // externalFolderRefs maps session-generated @ tokens to user-dropped // directories outside workspaceRoot. It is intentionally per-controller: // dragging a folder authorizes that folder for this chat session only, without // widening scoped @ resolution to arbitrary absolute paths. externalFolderRefsMu sync.RWMutex externalFolderRefs map[string]string externalFolderToolRefs externalFolderToolRefs // checkpoints owns the snapshot-based rewind bookkeeping (the per-session // store, the monotonic turn counter, and the conversation-rewind boundary map) // behind its own lock, off c.mu — so a boundary read for a rewind/fork never // contends on the run-state lock. The Controller keeps the rewind/fork/summarize // orchestration (truncating the session, restoring code, emitting events). See // checkpoint.go. checkpoints checkpointManager // approval owns the approval/ask prompt bookkeeping and the runtime approval // posture (ask/auto/yolo, session grants, the just-approved-plan window) // behind its own locks, off c.mu. The Controller keeps the I/O orchestration // (requestApproval/Ask emit events + fire hooks + rebuild the executor gate). // See approval.go. approval approvalManager // mu guards the run state; every critical section under it is short and // non-blocking. mu sync.Mutex cancel context.CancelFunc running bool finishing bool // TurnDone is still being delivered; park a replacement turn canceling bool // closed marks the controller as terminally torn down (close() ran). It // seals turn admission: without it, a submit arriving AFTER close cleared // the parked queue — but while a still-running turn's TurnDone delivery // was in flight — would park again and then start against freed resources // when the window closed. closed bool // parkedTurns holds turn bodies that arrived during the finishing window, // FIFO. finishGuardedTurn starts the oldest one as it closes the window // (see runGuarded/finishGuardedTurn); close() discards any remainder. parkedTurns []func(ctx context.Context) error // rotating is set under mu while NewSession/ClearSession swap the executor // session out. Checking running once and then swapping later leaves a // TOCTOU window: a turn can start (running=false at check time) during the // intervening Snapshot() and then have its live session replaced. running // and rotating are mutually exclusive gates — a turn refuses to start while // a rotation is in progress, and a rotation refuses to start while a turn // runs — so the run loop's session reference cannot change under it. rotating bool autosaveWG sync.WaitGroup planMode bool sessionPath string // recoveryDepthCapNotices records session paths that already surfaced the // depth-cap recovery warning. Repeated saves on the same conflict copy are // diagnostic noise for the UI; keep logging/diagnostics, but emit the user // notice once per controller/session path. recoveryDepthCapNotices map[string]bool // snapshotMu serializes the whole save/recovery handoff for this controller. // Agent-level path locks protect individual files, but recovery also moves // controller-owned state (sessionPath, guardianPath, checkpoints, rewrite // baseline). Letting a second snapshot observe that migration halfway through // can turn one conflict into a recovery cascade. Session/path swaps // (new/clear/fork/branch/switch/resume/SetSessionPath) hold it for the same // reason: a save that reads the old path but the new session would write one // transcript's messages into another's file, or manufacture a bogus conflict. // Not reentrant — never call snapshot (or anything that snapshots, such as // recoverInterruptedTurn or maybeColdResumePrune) while holding it. snapshotMu sync.Mutex // turn counts model turns this session, passed to hooks in their payload. turn int displayRecorder func(content, display string) } type approvalReply struct { allow bool session bool persist bool // true = write "always allow" rule to config } type pendingApproval struct { tool string subject string reason string fresh bool autoDrain bool reply chan approvalReply } // pendingAsk is an in-flight ask question batch. questions is retained so the // AskRequest can be re-emitted to a frontend that reconnected after the original // event (see ReplayPendingPrompts). type pendingAsk struct { questions []event.AskQuestion reply chan []event.AskAnswer } type AutoResearchEvidenceInput struct { ID string Kind string Summary string Source string Command string Paths []string Accepted bool } type plannerSessionResetter interface { ResetPlannerSession() } // RuntimeStatus is the frontend-facing snapshot of foreground turn state. It is // intentionally more explicit than the legacy Running bool so UI code can // distinguish a cancellable foreground turn from pending prompts and background // jobs. type RuntimeStatus struct { Running bool PendingPrompt bool BackgroundJobs int CancelRequested bool Cancellable bool } const ( ToolApprovalAsk = "ask" ToolApprovalAuto = "auto" ToolApprovalYolo = "yolo" ) const ( memoryRememberTool = "remember" memoryForgetTool = "forget" ) // RememberResult describes what happened when an approval rule was persisted. type RememberResult struct { Rule string Path string Saved bool CoveredBy string Err error } // MCPReadOnlyTrustResult describes what happened when a trusted MCP read-only // tool was persisted. type MCPReadOnlyTrustResult struct { Server string Tool string Path string Saved bool CoveredBy string Err error } // PlanModeReadOnlyCommandTrustResult describes what happened when a trusted bash // command prefix was persisted for plan-mode research. type PlanModeReadOnlyCommandTrustResult struct { Prefix string Path string Saved bool CoveredBy string Err error } type SessionRecoveryRequest struct { OriginalPath string Reason string Mode string } type SessionRecoveryInfo struct { OriginalPath string RecoveryPath string Existing bool Reason string Meta agent.BranchMeta } type externalFolderToolRefs interface { RegisterReadRoot(token, root string) } // Options carries the already-built pieces setup assembles. Lifecycle metadata // lets the controller mint and rotate session files; Host/Commands are surfaced // to frontends that resolve MCP prompts and slash commands. type Options struct { Runner agent.Runner Executor *agent.Agent Guardian *guardian.Session Sink event.Sink Policy permission.Policy Label string ModelRef string SystemPrompt string SessionDir string SessionPath string Host *plugin.Host Commands []command.Command Skills []skill.Skill AllSkills []skill.Skill SkillStore *skill.Store AllSkillStore *skill.Store // SkillRunner executes a runAs=subagent skill in an isolated child loop. // ReadOnlySkillRunner is the plan-mode-safe variant used by direct slash // invocation while planning; SkillProfile supplies model/effort display // metadata for the synthetic top-level run_skill event. SkillRunner skill.SubagentRunner ReadOnlySkillRunner skill.SubagentRunner SkillProfile skill.ProfileResolver Hooks *hook.Runner Memory *memory.Set Cleanup func() // BalanceURL/BalanceKey wire the active provider's optional wallet-balance // endpoint and bearer key; empty when the provider declares no balance_url. BalanceURL string BalanceKey string BalanceClient *http.Client // Jobs is the session-scoped background-job manager (nil disables background jobs). Jobs *jobs.Manager // Registry is the executor's live tool set, and PluginCtx the session-scoped // context; both are needed for hot-adding MCP servers via AddMCPServer. Registry *tool.Registry PluginCtx context.Context // WorkspaceRoot is the project root checkpoint restores are confined to ("" = // no confinement). Frontends pass the cwd they launched the session in. WorkspaceRoot string ExternalFolderToolRefs externalFolderToolRefs AutoPlan string // ResponseLanguage controls final-answer language preference. Empty/auto // means no transient injection because the stable language policy follows the // current user turn. ResponseLanguage string // ReasoningLanguage controls visible reasoning language preference. Empty/auto // means no transient injection because the stable language policy already // follows the conversation language. ReasoningLanguage string // DisableColdResumePrune skips the stale-tool-result elision that otherwise // runs when a session resumes past the provider cache window. Zero value // keeps the prune on (the cheaper default). DisableColdResumePrune bool // Shell is the interpreter user-invoked "!" commands run under, so /shell // matches the agent's configured [tools.shell] choice. Zero value = auto. Shell sandbox.Shell Classifier autoPlanClassifier // OnRemember, when set, is invoked with a new allow rule the user chose to // persist to disk (e.g. "Bash(go test:*)"). The callback is wired into the // permission Gate on EnableInteractiveApproval. OnRemember func(rule string) RememberResult // OnRememberMCPReadOnlyTrust persists a raw MCP tool name as trusted // read-only when the user chooses "always allow" from the plan-mode trust // prompt. OnRememberMCPReadOnlyTrust func(serverName, rawToolName string) MCPReadOnlyTrustResult // OnRememberPlanModeReadOnlyCommand persists a bash command prefix as trusted // read-only when the user chooses "always allow" from the plan-mode trust // prompt. OnRememberPlanModeReadOnlyCommand func(prefix string) PlanModeReadOnlyCommandTrustResult // SessionRecoveryMeta lets a frontend attach scope/topic/profile metadata to // an automatic recovery branch before it is written. SessionRecoveryMeta func(SessionRecoveryRequest) agent.BranchMeta // OnSessionRecovered is called after a stale runtime's transcript has been // saved as a recovery branch, before the controller commits to that branch. OnSessionRecovered func(SessionRecoveryInfo) error // PlanModeAllowedTools names extra custom tools the plan-mode policy may treat // as read-only. Known blocked tools and unsafe bash still lose. PlanModeAllowedTools []string // ApprovalTimeout bounds how long a tool-approval or ask prompt blocks waiting // for a user decision. Zero (default) waits forever — right for an interactive // terminal. Bot/headless frontends set a positive value so an unanswered // prompt can't wedge the session indefinitely (#4626, #4402). ApprovalTimeout time.Duration // RuntimeProfile selects capability routing/filtering behavior. Empty keeps // the backward-compatible Balanced profile. RuntimeProfile capability.Profile } // New builds a Controller. A nil Sink is replaced with event.Discard. func New(opts Options) *Controller { sink := opts.Sink if nilutil.IsNil(sink) { sink = event.Discard } classifier := opts.Classifier if nilutil.IsNil(classifier) { classifier = nil } pluginCtx := opts.PluginCtx if pluginCtx == nil { pluginCtx = context.Background() } runtimeProfile := opts.RuntimeProfile if runtimeProfile == "" { runtimeProfile = capability.ProfileBalanced } c := &Controller{ runner: opts.Runner, executor: opts.Executor, guardianSess: opts.Guardian, guardianPath: guardian.PathFor(opts.SessionPath), sink: sink, policy: opts.Policy, label: opts.Label, modelRef: opts.ModelRef, systemPrompt: opts.SystemPrompt, sessionDir: opts.SessionDir, sessionPath: opts.SessionPath, commands: atomic.Pointer[[]command.Command]{}, skills: newSkillSet(opts.Skills, opts.AllSkills, opts.SkillStore, opts.AllSkillStore), skillRunner: opts.SkillRunner, readOnlySkillRunner: opts.ReadOnlySkillRunner, skillProfile: opts.SkillProfile, hooks: opts.Hooks, memory: newMemoryManager(opts.Memory), cleanup: opts.Cleanup, autoPlan: normalizeAutoPlan(opts.AutoPlan), responseLanguage: config.NormalizeLanguage(opts.ResponseLanguage), reasoningLanguage: config.NormalizeReasoningLanguage(opts.ReasoningLanguage), disableColdResumePrune: opts.DisableColdResumePrune, shell: opts.Shell, classifier: classifier, onRemember: opts.OnRemember, onRememberMCPReadOnlyTrust: opts.OnRememberMCPReadOnlyTrust, onRememberPlanModeReadOnlyCommand: opts.OnRememberPlanModeReadOnlyCommand, sessionRecoveryMeta: opts.SessionRecoveryMeta, onSessionRecovered: opts.OnSessionRecovered, balanceURL: opts.BalanceURL, balanceKey: opts.BalanceKey, balanceClient: opts.BalanceClient, jobs: opts.Jobs, mcp: newMcpManager(opts.Host, opts.Registry, pluginCtx), runtimeProfile: runtimeProfile, workspaceRoot: opts.WorkspaceRoot, externalFolderToolRefs: opts.ExternalFolderToolRefs, approval: newApprovalManager(opts.Policy, ToolApprovalAsk, opts.ApprovalTimeout), } if strings.TrimSpace(opts.WorkspaceRoot) != "" { c.autoResearch = autoresearch.NewStore(opts.WorkspaceRoot) } // Checkpoints: bind a store to the session and route writer pre-edits into it. c.rebindCheckpoints(opts.SessionPath) c.setActiveJobSession(opts.SessionPath) cmdsInit := opts.Commands c.commands.Store(&cmdsInit) if c.executor != nil { c.executor.SetPreEditHook(func(ch diff.Change) { c.checkpoints.snapshot(ch) }) c.executor.SetMemoryQueue(c) } return c } // SetDisplayRecorder installs an optional hook used by frontends that persist a // shorter user-facing transcript than the fully composed model prompt. func (c *Controller) SetDisplayRecorder(fn func(content, display string)) { c.mu.Lock() defer c.mu.Unlock() c.displayRecorder = fn } func (c *Controller) recordDisplay(content, display string) { if strings.TrimSpace(display) == "" || content == display { return } c.mu.Lock() record := c.displayRecorder c.mu.Unlock() if record != nil { record(content, display) } } // ToolContractEntries returns a stable snapshot of the executor's live tool // contract: provider-visible names, descriptions, canonical schemas, and // read-only flags. It is intended for diagnostics and regression tests. func (c *Controller) ToolContractEntries() []tool.ContractEntry { if c == nil { return nil } reg := c.mcp.registry() if reg == nil { return nil } return reg.ContractEntries() } func (c *Controller) recordDisplayForNewUser(startMessages int, display string) { if strings.TrimSpace(display) == "" { return } msgs := c.History() if startMessages > len(msgs) { startMessages = len(msgs) } for _, m := range msgs[startMessages:] { if m.Role == provider.RoleUser { c.recordDisplay(m.Content, display) return } } } func (c *Controller) markEditedForNewUser(startMessages int, original string) { if strings.TrimSpace(original) == "" || c.executor == nil { return } s := c.executor.Session() msgs := s.Snapshot() if startMessages > len(msgs) { startMessages = len(msgs) } for i := startMessages; i < len(msgs); i++ { if msgs[i].Role != provider.RoleUser { continue } if msgs[i].Content == original { return } msgs[i].Edited = true msgs[i].Original = original s.Replace(msgs) return } } // ckptDir derives a session's checkpoint directory from its file path // (…/.jsonl → …/.ckpt). Empty path → empty (in-memory checkpoints). func ckptDir(sessionPath string) string { return store.SessionCheckpointDir(sessionPath) } // rebindCheckpoints points the store at the (possibly new) session, loading any // checkpoints already on disk, and resets the turn boundaries. Called on // construction and whenever the session path changes (NewSession/Resume/SetSessionPath). func (c *Controller) rebindCheckpoints(sessionPath string) { c.goals.setStatePath(goalStatePath(sessionPath)) c.checkpoints.rebind(ckptDir(sessionPath), c.workspaceRoot) } // beginCheckpoint opens a checkpoint for the turn about to run, recording the // current message count as the conversation-rewind boundary. Called at the top of // runTurn, before the user message is appended. func (c *Controller) beginCheckpoint(input string) { if c.executor == nil { return } c.checkpoints.begin(input, len(c.executor.Session().Messages)) } // --- commands (frontend → controller) --- // admissionResult classifies what runGuarded did with a turn body. type admissionResult int const ( // turnStarted: admission was open; the turn is running now. turnStarted admissionResult = iota // turnParked: the body landed inside the finishing window (TurnDone was // being delivered) and will start the moment the window closes. From the // caller's perspective the turn WILL run — nothing was lost. turnParked // turnDroppedRunning: a turn is genuinely in flight. Deliberately silent, // as before: interactive frontends prevent this with their own // steer/queue UX, and internal opportunistic callers (goal-loop // continuations, replays) rely on a quiet no-op. turnDroppedRunning // turnDroppedRotating: the executor session is being swapped out // (NewSession/ClearSession). The input's intended session is ambiguous, // so it is refused with a user-visible Notice asking to resend rather // than silently running against a session the user didn't see. turnDroppedRotating // turnDroppedClosed: the controller has been closed. Deliberately silent: // this controller's transports are being (or have been) torn down and the // input's home is the replacement controller the host swaps in — a Notice // here would go to a dead surface. turnDroppedClosed ) // runGuarded runs body on a background goroutine under a fresh cancellable // context, guarding against concurrent turns and emitting a TurnDone event when // it finishes (Err set on failure; nil also for a user Cancel). // // Admission is NOT first-come-first-served across all states — see // admissionResult. In particular, a body arriving during the finishing window // is parked, not dropped: TurnDone is emitted inside that window, so every // caller that reacts to TurnDone by submitting again (a frontend's queued // auto-send, a bot, a fast Enter) would otherwise race a silent drop. That // exact loss was observed in CI and reproduced on a clean main-v2 worktree, // and the desktop composer already carries a workaround gating its auto-send // on submitDisabled rather than turn_done (Composer.tsx). func (c *Controller) runGuarded(body func(ctx context.Context) error) admissionResult { return c.admitGuardedTurn(body, false) } // runGuardedOrPark admits like runGuarded but parks the body while another // turn is running instead of using the deliberately-silent running drop. // Reserved for inputs that are the user's own words (the steer fallback): // the FIFO drain in finishGuardedTurn delivers them the moment the current // turn finishes. func (c *Controller) runGuardedOrPark(body func(ctx context.Context) error) admissionResult { return c.admitGuardedTurn(body, true) } func (c *Controller) admitGuardedTurn(body func(ctx context.Context) error, parkWhileRunning bool) admissionResult { c.mu.Lock() if c.closed { c.mu.Unlock() return turnDroppedClosed } if c.rotating { c.mu.Unlock() c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "input was not accepted: the session is being switched — please resend"}) return turnDroppedRotating } if c.running { if parkWhileRunning { c.parkedTurns = append(c.parkedTurns, body) c.mu.Unlock() return turnParked } c.mu.Unlock() return turnDroppedRunning } if c.finishing { c.parkedTurns = append(c.parkedTurns, body) c.mu.Unlock() return turnParked } ctx, cancel := context.WithCancel(context.Background()) c.cancel = cancel c.running = true c.canceling = false c.mu.Unlock() c.spawnGuardedTurn(ctx, cancel, body) return turnStarted } // spawnGuardedTurn launches an admitted turn body plus its autosave companion. // The caller must already have claimed admission (running=true) under c.mu. func (c *Controller) spawnGuardedTurn(ctx context.Context, cancel context.CancelFunc, body func(ctx context.Context) error) { c.autosaveWG.Add(1) go func() { defer c.autosaveWG.Done() c.autosaveWhileRunning(ctx) }() go func() { defer cancel() defer func() { if r := recover(); r != nil { c.finishGuardedTurn(fmt.Errorf("internal error: %v", r)) } }() err := body(ctx) c.finishGuardedTurn(explainError(err)) }() } // finishGuardedTurn keeps admission closed while TurnDone is delivered. The // sink fan-out may detach per-turn transports; allowing a replacement turn in // after running=false but before that fan-out completed let the old completion // clear or inherit the replacement turn's transport. // // When the window closes, the oldest parked turn (if any) is started under the // SAME critical section that clears finishing: opening the gate first and then // re-admitting would let an unrelated submit slip in ahead and bounce the // parked turn back to a drop. Remaining parked turns drain one per // finishGuardedTurn, preserving FIFO order. Rotation cannot interleave here: // beginRotation refuses while running or finishing, and the drain flips // finishing directly into running. func (c *Controller) finishGuardedTurn(err error) { c.mu.Lock() c.running = false c.finishing = true c.cancel = nil c.canceling = false c.mu.Unlock() defer func() { c.mu.Lock() c.finishing = false if c.closed || len(c.parkedTurns) == 0 { // A closed controller must not start a parked turn against freed // resources; close() also cleared the queue, this guards the // close-raced-with-delivery ordering. c.mu.Unlock() return } next := c.parkedTurns[0] c.parkedTurns = c.parkedTurns[1:] ctx, cancel := context.WithCancel(context.Background()) c.cancel = cancel c.running = true c.canceling = false c.mu.Unlock() c.spawnGuardedTurn(ctx, cancel, next) }() c.sink.Emit(event.Event{Kind: event.TurnDone, Err: err, Outcome: turnOutcome(err)}) } func turnOutcome(err error) string { var readinessErr *agent.FinalReadinessError if errors.As(err, &readinessErr) { return event.TurnOutcomeFinalReadiness } return "" } // Send starts a turn with an uncomposed message. The controller applies // auto-plan, plan-mode, memory, and background-job framing inside the async turn // path so frontends do not block on classifier I/O. func (c *Controller) Send(input string) { c.SendWithRaw(input, input) } // SendWithRaw starts a turn with separate model input and raw prompt text. The // raw prompt is used only for auto-plan scoring; it deliberately excludes // resolved @-reference payloads so referenced file contents cannot inflate the // complexity score. func (c *Controller) SendWithRaw(input, raw string) { c.runGuarded(func(ctx context.Context) error { return c.runGoalLoopWithRaw(ctx, input, raw) }) } // planApprovalTool is the Tool name on the ApprovalRequest the controller emits // to gate a proposed plan. Frontends key their plan-approval UI on it (the // desktop renders a plan card; the chat TUI a plan banner). const planApprovalTool = "exit_plan_mode" // SandboxEscapeApprovalTool is the internal Tool name used for one-shot approval // to rerun a shell command without the OS sandbox after the sandbox failed. const SandboxEscapeApprovalTool = "sandbox_escape" // ManagedConfigWriteApprovalTool is the internal Tool name used for per-write // approval when a file tool targets a Reasonix-managed config file outside the // workspace write roots. It is a fresh human decision: config files control // providers, sandbox rules, permissions, and MCP servers for future sessions, // so YOLO/auto approval must never answer it. const ManagedConfigWriteApprovalTool = "config_write" // planApprovedMessage is the follow-up turn sent once the user approves a plan — // the in-context nudge to execute and keep the (already-seeded) task list honest. const planApprovedMessage = "Plan approved — plan mode is off; you’re cleared to make the changes without asking again. Implement the plan now. Use this serial workflow: 1) mark the first sub-step in_progress with todo_write (this establishes the task list); 2) execute the sub-step; 3) call complete_step with evidence — the host then marks that sub-step completed and moves the next one to in_progress for you. Repeat 2–3 for each remaining sub-step. You don’t need another todo_write to mark steps completed; each complete_step advances the list. Sign off one sub-step at a time — never batch multiple completions." // runTurn runs one model turn, then applies the plan-approval gate. This is the // single, frontend-agnostic plan flow: in plan mode the model just researches // (writers are blocked) and writes its plan as a normal answer — no special tool. // When the turn ends with a text proposal, the controller asks the user to // approve (reusing the ApprovalRequest channel both frontends already render); // on approval it exits plan mode, seeds the task list from the plan, and // continues straight into execution; on rejection it stays in plan mode so the // next turn can revise. Plan mode is only ever set interactively, so the headless // `Run` path (which doesn't call this) never blocks on a prompt. func (c *Controller) runTurn(ctx context.Context, input string) error { return c.runGoalLoopWithRaw(ctx, input, input) } // RunTurn executes one foreground turn synchronously through the same lifecycle // used by interactive frontends: auto-plan, transient memory/background-job // composition, checkpoints, hooks, and plan approval. It is for transports that // need a blocking request/response boundary, such as ACP session/prompt. func (c *Controller) RunTurn(ctx context.Context, input string) error { ctx, cancel := context.WithCancel(ctx) c.mu.Lock() // finishing is part of the gate: TurnDone delivery for the previous turn // is still fanning out, and starting a synchronous turn inside that // window recreates the completion/transport crosstalk the window exists // to prevent (Running() already reports true here). closed seals a torn- // down controller. Synchronous callers get an error rather than parking: // they hold a request/response boundary open and already handle busy. if c.running || c.finishing || c.rotating || c.closed { c.mu.Unlock() cancel() return ErrTurnRunning } c.cancel = cancel c.running = true c.canceling = false c.mu.Unlock() defer func() { c.mu.Lock() c.running = false c.cancel = nil c.canceling = false c.mu.Unlock() cancel() }() return c.runTurn(ctx, input) } func (c *Controller) runTurnWithRaw(ctx context.Context, input, raw string) error { return c.runTurnWithRawDisplay(ctx, input, raw, "") } func (c *Controller) runGoalLoopWithRaw(ctx context.Context, input, raw string) error { return c.runGoalLoopWithRawDisplay(ctx, input, raw, "") } func (c *Controller) runGoalLoopWithRawDisplay(ctx context.Context, input, raw, display string) error { return newTurnOrchestrator(c).runGoalLoopWithRawDisplay(ctx, input, raw, display) } func (c *Controller) runEditedGoalLoopWithRawDisplay(ctx context.Context, input, raw, display, original string) error { return newTurnOrchestrator(c).runEditedGoalLoopWithRawDisplay(ctx, input, raw, display, original) } func (c *Controller) runTurnWithRawDisplay(ctx context.Context, input, raw, display string) error { return newTurnOrchestrator(c).runTurnWithRawDisplay(ctx, input, raw, display) } func (c *Controller) runSubagentSkillSlash(sk skill.Skill, task, raw, display string) { c.runGuarded(func(ctx context.Context) error { planMode := c.PlanMode() runner := c.skillRunner if planMode { runner = c.readOnlySkillRunner } if runner == nil { return fmt.Errorf("subagent skill runner is unavailable for /%s", sk.Name) } return newTurnOrchestrator(c).runSubagentSkillGoalLoop(ctx, sk, task, raw, display, runner, planMode) }) } // toolWasCalledLastTurn reports whether the most recent assistant message // contained any tool calls, indicating the agent made observable progress. func (c *Controller) toolWasCalledLastTurn() bool { msgs := c.History() for i := len(msgs) - 1; i >= 0; i-- { m := msgs[i] if m.Role == provider.RoleAssistant { return len(m.ToolCalls) > 0 } if m.Role == provider.RoleUser { return false } } return false } func (c *Controller) stopGoal(status string) { path, data, ok := c.goals.stop(status, c.goalTodos()) c.persistGoalState(path, data, ok) } // lastAssistantText returns the content of the most recent assistant message with // non-empty text — the model's final answer for the turn (its plan, in plan mode). func lastAssistantText(msgs []provider.Message) string { for i := len(msgs) - 1; i >= 0; i-- { if msgs[i].Role == provider.RoleAssistant && strings.TrimSpace(msgs[i].Content) != "" { return msgs[i].Content } } return "" } // Submit is the one-call entry for a simple frontend: it takes raw user input // and does everything — slash-command dispatch, @-reference expansion, plan-mode // composition — emitting all output as events. The HTTP/SSE server uses this so // a browser client only POSTs the typed line. // // Slash commands route to the matching primitive: /compact, /new, and /clear // run their session op and emit a Notice; /mcp__server__prompt and custom /commands // resolve to a turn; an unknown slash emits a Notice. Anything else is a normal // turn with its @-references resolved first. func (c *Controller) Submit(input string) { c.submit(input, "", "") } // SubmitHTTP accepts input from the unauthenticated localhost HTTP frontend. It // deliberately omits the trusted TUI-only "!cmd" shell shortcut and resolves file // references only through the controller's workspace root. func (c *Controller) SubmitHTTP(input string) { c.submitHTTP(input, "") } // SubmitDisplay runs input as a turn while remembering the user-facing display // text for transcript replay when controller-side composition expands input. func (c *Controller) SubmitDisplay(display, input string) { c.submit(input, display, "") } // SubmitInvocationDisplay executes composer-selected invocation entities // independently of slash-command parsing. Plain string submit entry points keep // their existing behavior for CLI, HTTP, and backward-compatible clients. func (c *Controller) SubmitInvocationDisplay(display, input string, invocations []InvocationRequest) { c.submitInvocations(input, display, invocations) } func (c *Controller) submitInvocations(input, display string, requests []InvocationRequest) { if len(requests) == 0 { c.SubmitDisplay(display, input) return } ordered := append([]InvocationRequest(nil), requests...) sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Offset < ordered[j].Offset }) inline := make([]skill.Skill, 0, len(ordered)) subagents := make([]skill.Skill, 0, len(ordered)) for _, request := range ordered { sk, _, ok := c.resolveSkillInvocation("/" + strings.TrimSpace(request.Name)) if !ok { c.notice("unknown invocation: /" + strings.TrimSpace(request.Name)) return } kind := "skill" if sk.RunAs == skill.RunSubagent { kind = "subagent" } if request.Kind != kind { c.notice(fmt.Sprintf("invocation /%s is %s, not %s", sk.SlashName(), kind, request.Kind)) return } if sk.RunAs == skill.RunSubagent { subagents = append(subagents, sk) } else { inline = append(inline, sk) } } parts := make([]string, 0, len(inline)+1) for _, sk := range inline { parts = append(parts, skill.Render(sk, "")) } if strings.TrimSpace(input) != "" { parts = append(parts, input) } composed := strings.Join(parts, "\n\n") if len(subagents) == 0 { c.runGuarded(func(ctx context.Context) error { return c.runGoalLoopWithRawDisplay(ctx, composed, input, display) }) return } if strings.TrimSpace(input) == "" { c.notice("subagent invocation requires a task") return } c.runGuarded(func(ctx context.Context) error { planMode := c.PlanMode() runner := c.skillRunner if planMode { runner = c.readOnlySkillRunner } if runner == nil { return fmt.Errorf("subagent skill runner is unavailable") } return newTurnOrchestrator(c).runSubagentSkillTurnsGoalLoop(ctx, subagents, composed, input, display, runner, planMode) }) } // SubmitEditedDisplay is SubmitDisplay for an inline-edited prompt. The model // sees input; the saved user message also keeps the pre-edit prompt as local UI // metadata so the edit survives session rewrites. func (c *Controller) SubmitEditedDisplay(display, input, original string) { c.submit(input, display, original) } // SubmitUserTurn starts a normal model turn without interpreting shell or slash // commands. It still resolves references, so callers can submit trusted // user-authored prompt text without expanding the command surface. func (c *Controller) SubmitUserTurn(input, display string) { c.runRefTurn(input, display) } func (c *Controller) submit(input, display, editedOriginal string) { trimmed := strings.TrimSpace(input) if note, ok := MemoryQuickAddNote(trimmed); ok { c.rememberProjectNote(note) return } if note, ok := RememberCommandNote(trimmed); ok { c.rememberProjectNote(note) return } if c.applyGoalCommand(trimmed, display) { return } if strings.HasPrefix(trimmed, "!") { c.RunShell(trimmed[1:]) return } c.submitCommandOrTurn(trimmed, input, display, false, editedOriginal) } func (c *Controller) submitHTTP(input, display string) { trimmed := strings.TrimSpace(input) if note, ok := MemoryQuickAddNote(trimmed); ok { c.rememberProjectNote(note) return } if note, ok := RememberCommandNote(trimmed); ok { c.rememberProjectNote(note) return } if c.applyGoalCommand(trimmed, display) { return } if strings.HasPrefix(trimmed, "!") { c.notice("shell commands are unavailable from this frontend") return } c.submitCommandOrTurn(trimmed, input, display, true, "") } func (c *Controller) submitCommandOrTurn(trimmed, input, display string, scopedRefsOnly bool, editedOriginal string) { runRefTurn := c.runRefTurn runRefTurnWithRefs := c.runRefTurnWithRefs runGoalLoop := c.runGoalLoopWithRawDisplay if scopedRefsOnly { runRefTurn = c.runScopedRefTurn runRefTurnWithRefs = c.runScopedRefTurnWithRefs } if strings.TrimSpace(editedOriginal) != "" { runRefTurn = func(input, display string) { c.runEditedRefTurn(input, display, editedOriginal) } runRefTurnWithRefs = func(input, refLine, display string) { c.runEditedRefTurnWithRefs(input, refLine, display, editedOriginal) } runGoalLoop = func(ctx context.Context, input, raw, display string) error { return c.runEditedGoalLoopWithRawDisplay(ctx, input, raw, display, editedOriginal) } } switch { case trimmed == "/compact" || strings.HasPrefix(trimmed, "/compact "): focus := strings.TrimSpace(strings.TrimPrefix(trimmed, "/compact")) go func() { if err := c.Compact(context.Background(), focus); err != nil { c.notice("compaction failed: " + err.Error()) } else { c.notice("compacted") if err := c.SnapshotRewrite(); err != nil { slog.Warn("controller: snapshot after compact", "err", err) } } }() case trimmed == "/new": go func() { if err := c.NewSession(); err != nil { c.notice("new session failed: " + err.Error()) } else { c.notice("new session") } }() case trimmed == "/clear": go func() { if err := c.ClearSession(); err != nil { c.notice("clear context failed: " + err.Error()) } else { c.notice("context cleared") } }() case strings.HasPrefix(trimmed, "/mcp__"): c.runGuarded(func(ctx context.Context) error { sent, found, err := c.MCPPrompt(ctx, trimmed) if err != nil { return err } if !found { c.notice("unknown command: " + trimmed) return nil } return runGoalLoop(ctx, sent, sent, display) }) case SlashCodeCommentLine(trimmed): // Slash-prefixed code comments are prompt text, not slash commands. runRefTurn(input, display) case strings.HasPrefix(trimmed, "/"): if ref, ok := FileRefLine(trimmed); ok { runRefTurn(ref, display) return } if ref, ok := SlashPathLineRef(trimmed, c.workspaceRoot); ok { runRefTurnWithRefs(input, ref, display) return } if SlashPathLikeLine(trimmed) { runRefTurn(input, display) return } // Read-only management verbs (/model /memory /skills /hooks /mcp) emit a // listing Notice, so Submit-based frontends (desktop, HTTP) get them with // no extra wiring. (The chat TUI handles these itself with richer output.) fields := strings.Fields(trimmed) switch fields[0] { case "/tree": c.notice(c.BranchTreeText()) return case "/branch": args := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0])) if turn, name, fromTurn, err := ParseBranchTarget(args); err != nil { c.notice(err.Error()) } else if fromTurn { if _, err := c.ForkNamed(turn-1, name); err != nil { c.notice(err.Error()) } } else { if _, err := c.Branch(name); err != nil { c.notice(err.Error()) } } return case "/switch": ref := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0])) if _, err := c.SwitchBranch(ref); err != nil { c.notice(err.Error()) } return case "/rewind": args := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0])) turn, scope, err := parseRewind(args, c.Checkpoints()) if err != nil { c.notice("usage: /rewind [turn] [code|conversation|both]") return } if err := c.Rewind(turn, scope); err != nil { c.notice(err.Error()) } return case "/plan-exec": c.applyPlanExec(trimmed, display) return case "/prometheus": c.applyPrometheus(trimmed, display) return } if c.managementNotice(trimmed) { return } // A custom command wins over a skill of the same name; both resolve to a // turn. (Built-in slash verbs like /compact are handled above.) if sent, ok := c.CustomCommand(trimmed); ok { c.runGuarded(func(ctx context.Context) error { return runGoalLoop(ctx, sent, sent, display) }) return } if sk, task, ok := c.resolveSkillInvocation(trimmed); ok { if sk.RunAs == skill.RunSubagent { if strings.TrimSpace(task) == "" { c.notice("usage: /" + sk.Name + " ") return } c.runSubagentSkillSlash(sk, task, trimmed, display) return } sent := skill.Render(sk, task) c.runGuarded(func(ctx context.Context) error { return runGoalLoop(ctx, sent, sent, display) }) return } c.notice("unknown command: " + trimmed) default: if c.maybeAutoStartResearchGoal(input, display, editedOriginal) { return } runRefTurn(input, display) } } func (c *Controller) maybeAutoStartResearchGoal(input, display, editedOriginal string) bool { goal, ok := c.autoStartResearchGoalCandidate(input) if !ok { return false } if c.runner != nil { displayText := display if strings.TrimSpace(displayText) == "" { displayText = goal } c.runGuarded(func(ctx context.Context) error { c.SetGoalWithResearchMode(goal, GoalResearchOn) c.notice(fmt.Sprintf(i18n.M.GoalSetFmt, ShortGoalForNotice(goal))) block, errs := c.ResolveRefs(ctx, goal) for _, e := range errs { c.notice(e) } sent := "Start pursuing the active goal now." if block != "" { sent = "Referenced context:\n\n" + block + "\n\n" + sent } if strings.TrimSpace(editedOriginal) != "" { return c.runEditedGoalLoopWithRawDisplay(ctx, sent, goal, displayText, editedOriginal) } return c.runGoalLoopWithRawDisplay(ctx, sent, goal, displayText) }) } return true } // AutoStartResearchGoal upgrades a strong long-horizon ordinary prompt into a // Goal + AutoResearch run for frontends that already accepted an idle turn. func (c *Controller) AutoStartResearchGoal(input string) (string, bool) { goal, ok := c.autoStartResearchGoalCandidate(input) if !ok { return "", false } c.SetGoalWithResearchMode(goal, GoalResearchOn) c.notice(fmt.Sprintf(i18n.M.GoalSetFmt, ShortGoalForNotice(goal))) return goal, true } func (c *Controller) autoStartResearchGoalCandidate(input string) (string, bool) { goal := strings.TrimSpace(input) if !shouldAutoStartResearchGoal(goal) { return "", false } c.mu.Lock() plan := c.planMode running := c.running c.mu.Unlock() if plan || running || c.goals.active() { return "", false } return goal, true } func (c *Controller) rememberProjectNote(note string) { if note == "" { c.notice("nothing to remember") return } if path, err := c.QuickAdd(memory.ScopeProject, note); err != nil { c.notice("memory: " + err.Error()) } else { c.notice("remembered → " + path) } } func (c *Controller) applyGoalCommand(input, display string) bool { cmd, ok := ParseGoalCommand(input) if !ok { return false } switch cmd.Action { case GoalCommandSet: c.SetPlanMode(false) c.SetGoalWithResearchMode(cmd.Text, cmd.ResearchMode) c.GoalStrict(cmd.Strict) c.notice(fmt.Sprintf(i18n.M.GoalSetFmt, ShortGoalForNotice(cmd.Text))) if c.runner != nil { c.runGuarded(func(ctx context.Context) error { return c.runGoalLoopWithRawDisplay(ctx, "Start pursuing the active goal now.", cmd.Text, display) }) } case GoalCommandClear: c.ClearGoal() c.notice(i18n.M.GoalCleared) default: goal := c.Goal() if strings.TrimSpace(goal) == "" { c.notice(i18n.M.GoalEmpty) } else { c.notice(fmt.Sprintf(i18n.M.GoalCurrentFmt, goal)) } } return true } // applyPlanExec reads the current canonical todo list and starts a goal that // analyzes and dispatches independent steps concurrently via parallel_tasks. // Supports --strict flag: /plan-exec --strict enables strict goal mode. func (c *Controller) applyPlanExec(input, display string) { todos := c.executor.CanonicalTodoState() if len(todos) == 0 { c.notice("no active plan with todos to execute") return } // Parse --strict flag. strict := false fields := strings.Fields(input) for _, f := range fields { if f == "--strict" { strict = true break } } // Count completion status. total := len(todos) done := 0 for _, t := range todos { if t.Status == "completed" { done++ } } var b strings.Builder b.WriteString("You are the execution conductor. Route each step to the right sub-agent by module.\n\n") // Detect project structure for module-aware routing. modules := c.detectProjectModules() if len(modules) > 0 { b.WriteString("## Project modules detected\n\n") for _, m := range modules { fmt.Fprintf(&b, "- %s/", m) } b.WriteString("\n\nRoute steps to the module they belong to. Steps in different modules can run in parallel.\n\n") } b.WriteString("## Plan steps\n\n") for _, t := range todos { status := t.Status if status == "" { status = "pending" } mark := " " if status == "completed" { mark = "x" } fmt.Fprintf(&b, "- [%s] %s (%s)\n", mark, t.Content, status) } b.WriteString("\n## Routing rules\n") b.WriteString("1. Group steps by MODULE \u2014 same module = serial, different modules = parallel batches\n") b.WriteString("2. Research/exploration across modules = use parallel_tasks\n") b.WriteString("3. Dispatch each batch via parallel_tasks \u2014 each sub-agent gets one module\u2019s context\n") b.WriteString("4. Verify each batch before the next\n") b.WriteString("5. Failures: fix before moving on\n") b.WriteString("\nGoal: each sub-agent focuses on one module and does not carry irrelevant context.\n") if done > 0 { fmt.Fprintf(&b, "\nNote: %d/%d steps are already completed. Focus on the remaining %d steps.\n", done, total, total-done) } prompt := b.String() // Show module preview. if len(modules) > 0 { c.notice(fmt.Sprintf("plan-exec: detected %d modules — %s", len(modules), strings.Join(modules, ", "))) } c.SetPlanMode(false) c.SetGoal("execute plan: " + ShortGoalForNotice(todos[0].Content)) c.GoalStrict(strict) c.notice(fmt.Sprintf("plan-exec: dispatching %d plan steps (strict=%v)", total, strict)) if c.runner != nil { c.runGuarded(func(ctx context.Context) error { return c.runGoalLoopWithRawDisplay(ctx, prompt, prompt, display) }) } } // prometheusPrompt is the strategic planner system prompt. const prometheusPrompt = "You are Prometheus, a strategic planner. Interview the user one question at a time. Cover: scope, modules, files, constraints, tests. When ready, output a numbered plan with each step tagged by module. End with [goal:complete]. Do not implement.\n\nFor independent research directions, use parallel_tasks before planning." // applyPrometheus starts an interactive planning interview, inspired by OMO's // Prometheus agent. It enters goal mode with a structured interview prompt. func (c *Controller) applyPrometheus(input, display string) { args := strings.TrimSpace(strings.TrimPrefix(input, "/prometheus")) if args == "" || args == "--strict" { c.notice("usage: /prometheus ") return } strict := false if strings.HasPrefix(args, "--strict ") { strict = true args = strings.TrimPrefix(args, "--strict ") } prompt := prometheusPrompt + "\n\n## User request\n\n" + args + "\n\nBegin the interview by asking your first clarifying question." c.SetPlanMode(false) c.SetGoal("plan: " + ShortGoalForNotice(args)) c.GoalStrict(strict) c.notice("prometheus: starting planning interview") if c.runner != nil { c.runGuarded(func(ctx context.Context) error { return c.runGoalLoopWithRawDisplay(ctx, prompt, prompt, display) }) } } // shellTimeout is the maximum time a user-invoked "!command" may run. Matches // the bash tool's timeout so behaviour is consistent across invocation paths. const shellTimeout = 120 * time.Second // shellWaitDelay bounds how long cmd.Run() waits after context cancellation for // the child's pipes to drain, matching the bash tool's WaitDelay. const shellWaitDelay = 5 * time.Second // shellWriter forwards each chunk of shell output to a callback, so RunShell // can stream live progress to the frontend as the command produces output. type shellWriter struct{ emit func(string) } func (w *shellWriter) Write(p []byte) (int, error) { w.emit(string(p)) return len(p), nil } func shellCommandPreview(command string) string { command = strings.TrimSpace(strings.ReplaceAll(command, "\n", " ")) const max = 48 r := []rune(command) if len(r) > max { return string(r[:max]) + "…" } return command } // RunShell executes a shell command directly (bypassing the model) and streams // the output as ToolDispatch/ToolProgress/ToolResult events. It uses the same // bash-tool infrastructure (shell resolution, timeout) and shares the runGuarded // lock with model turns — only one can run at a time. User-invoked "!" commands // run without the OS sandbox (the user typed the command explicitly). func (c *Controller) RunShell(command string) { command = strings.TrimSpace(command) if command == "" { c.notice(i18n.M.ShellExecEmpty) return } c.runGuarded(func(ctx context.Context) error { sh := c.shell if sh.Path == "" { sh = sandbox.ResolveShell("", "", nil) } argv, _ := sandbox.Command(sandbox.Spec{}, sh, command) // false = unsandboxed (user invoked) preview := []rune(command) if len(preview) > 32 { preview = preview[:32] } id := "shell-" + string(preview) diagnosticPreview := shellCommandPreview(command) c.sink.Emit(event.Event{ Kind: event.ToolDispatch, Tool: event.Tool{ ID: id, Name: "bash", Args: fmt.Sprintf(`{"command":%q}`, command), }, }) ctx, cancel := context.WithTimeout(ctx, shellTimeout) defer cancel() cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) cmd.WaitDelay = shellWaitDelay cmd.Dir = c.workspaceRoot var buf bytes.Buffer w := io.MultiWriter(&buf, &shellWriter{emit: func(chunk string) { c.sink.Emit(event.Event{ Kind: event.ToolProgress, Tool: event.Tool{ID: id, Output: chunk}, }) }}) cmd.Stdout = w cmd.Stderr = w start := time.Now() _, err := proc.RunCommand(ctx, cmd, proc.RunOptions{ Track: true, CancelWaitGrace: shellWaitDelay + time.Second, Source: "user_shell", ShellKind: sh.Kind.String(), ShellPath: sh.Path, CommandPreview: diagnosticPreview, }) durationMs := time.Since(start).Milliseconds() out := buf.String() if ctx.Err() == context.Canceled { c.sink.Emit(event.Event{ Kind: event.ToolResult, Tool: event.Tool{ID: id, Name: "bash", Output: out, Err: i18n.M.TurnCancelled, DurationMs: durationMs}, }) return nil } if ctx.Err() == context.DeadlineExceeded { c.sink.Emit(event.Event{ Kind: event.ToolResult, Tool: event.Tool{ID: id, Name: "bash", Output: out, Err: fmt.Sprintf(i18n.M.ShellExecTimeoutFmt, shellTimeout), DurationMs: durationMs}, }) return nil } if err != nil { c.sink.Emit(event.Event{ Kind: event.ToolResult, Tool: event.Tool{ID: id, Name: "bash", Output: out, Err: fmt.Sprintf(i18n.M.ShellExecFailedFmt, err), DurationMs: durationMs}, }) return nil } c.sink.Emit(event.Event{ Kind: event.ToolResult, Tool: event.Tool{ID: id, Name: "bash", Output: out, DurationMs: durationMs}, }) return nil }) } // runRefTurn resolves a line's @references into a context block and starts a // turn with it prepended (or the raw line when nothing resolved). func (c *Controller) runRefTurn(input, display string) { c.runRefTurnWithRefs(input, input, display) } func (c *Controller) runEditedRefTurn(input, display, original string) { c.runEditedRefTurnWithRefs(input, input, display, original) } func (c *Controller) runScopedRefTurn(input, display string) { c.runScopedRefTurnWithRefs(input, input, display) } // runRefTurnWithRefs resolves references from refLine while preserving input as // the user's actual prompt text. This lets compiler diagnostics such as // "/path/File.kt:12: error" attach @/path/File.kt without rewriting the error. func (c *Controller) runRefTurnWithRefs(input, refLine, display string) { c.runRefTurnWithResolver(input, refLine, display, c.ResolveRefs) } func (c *Controller) runEditedRefTurnWithRefs(input, refLine, display, original string) { c.runEditedRefTurnWithResolver(input, refLine, display, original, c.ResolveRefs) } func (c *Controller) runScopedRefTurnWithRefs(input, refLine, display string) { c.runRefTurnWithResolver(input, refLine, display, c.ResolveScopedRefs) } func (c *Controller) runRefTurnWithResolver(input, refLine, display string, resolve func(context.Context, string) (string, []string)) { c.runGuarded(func(ctx context.Context) error { return c.runRefTurnWithResolverSync(ctx, input, refLine, display, "", resolve) }) } func (c *Controller) runEditedRefTurnWithResolver(input, refLine, display, original string, resolve func(context.Context, string) (string, []string)) { c.runGuarded(func(ctx context.Context) error { return c.runRefTurnWithResolverSync(ctx, input, refLine, display, original, resolve) }) } func (c *Controller) runRefTurnWithResolverSync(ctx context.Context, input, refLine, display, original string, resolve func(context.Context, string) (string, []string)) error { block, errs := resolve(ctx, refLine) for _, e := range errs { c.notice(e) } sent := input if block != "" { sent = "Referenced context:\n\n" + block + "\n\n" + input } if strings.TrimSpace(original) != "" { return c.runEditedGoalLoopWithRawDisplay(ctx, sent, input, display, original) } return c.runGoalLoopWithRawDisplay(ctx, sent, input, display) } // notice emits an informational Notice event. func (c *Controller) notice(text string) { c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text}) } func (c *Controller) noticeDetail(text, detail string) { c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text, Detail: detail}) } // Run executes a turn synchronously, returning the agent's error. Used by the // headless `reasonix run` path, where the Sink renders to stdout and the caller // just needs the exit status — no TurnDone event, no cancel bookkeeping. func (c *Controller) Run(ctx context.Context, input string) error { c.maybeSessionStart(ctx) parentSession := c.parentSessionID() ctx = agent.WithParentSession(ctx, parentSession) ctx = jobs.WithSession(ctx, parentSession) ctx = agent.WithUserImages(ctx, c.inputImages(input)) rawInput := input input = c.Compose(input) startMessages := c.messageCount() defer c.snapshotActivityIfChanged(startMessages) if c.guardianSess != nil { c.guardianSess.ResetTurn() } if c.hooks.Enabled() { c.mu.Lock() c.turn++ turn := c.turn c.mu.Unlock() if block, _ := c.hooks.PromptSubmit(ctx, input, turn); block { return nil } defer func() { c.hooks.Stop(context.Background(), lastAssistantText(c.History()), turn) }() } c.markInFlightTurn(startMessages, true) defer c.clearInFlightTurn() return c.runner.Run(ctx, c.withCapabilityRoute(input, rawInput)) } // RunSubagentProfile executes one named runAs=subagent skill synchronously and // returns only its final answer. It is the headless CLI counterpart to explicit // slash invocation: the child keeps an isolated session, while the caller owns // stdout rendering and exit status. readOnly selects the preview-safe runner // used by `reasonix subagent try`. func (c *Controller) RunSubagentProfile(ctx context.Context, name, task string, readOnly bool) (string, error) { name = strings.TrimSpace(name) task = strings.TrimSpace(task) if name == "" { return "", fmt.Errorf("subagent name is required") } if task == "" { return "", fmt.Errorf("subagent task is required") } sk, ok := c.skills.bySlashName(name) if !ok { return "", fmt.Errorf("unknown or disabled subagent profile %q", name) } if sk.RunAs != skill.RunSubagent { return "", fmt.Errorf("skill %q is not runAs=subagent", name) } runner := c.skillRunner if readOnly { runner = c.readOnlySkillRunner } if runner == nil { return "", fmt.Errorf("subagent skill runner is unavailable for %q", name) } c.maybeSessionStart(ctx) parentSession := c.parentSessionID() ctx = agent.WithParentSession(ctx, parentSession) ctx = jobs.WithSession(ctx, parentSession) ctx = agent.WithUserImages(ctx, c.inputImages(task)) ctx = agent.WithResponseLanguagePreference(ctx, c.responseLanguage) ctx = agent.WithReasoningLanguagePreference(ctx, c.reasoningLanguage) ctx = agent.WithSubagentDepth(ctx, 0) answer, err := runner(ctx, sk, task, skill.SubagentRunOptions{HostInitiated: true}) if err != nil { return "", err } return tool.GuardSubagentHostDecisionText(answer), nil } // Cancel aborts the in-flight turn. A goroutine blocked awaiting approval // unblocks via the cancelled context. func (c *Controller) Cancel() { c.mu.Lock() cancel := c.cancel if cancel != nil { c.canceling = true } c.mu.Unlock() if cancel != nil { c.approval.clearAll() cancel() return } if c.goals.active() { c.stopGoal(GoalStatusStopped) } } // Running reports whether a turn is currently in flight. func (c *Controller) Running() bool { c.mu.Lock() defer c.mu.Unlock() return c.running || c.finishing } // beginRotation claims the session-rotation gate. It fails if a turn is running // or another rotation is already in progress, so the caller holds exclusive // rights to swap the executor session from the check here through endRotation. // This closes the TOCTOU window that a bare `if c.running` check left open: // between that check and the actual SetSession, a turn could start and then be // yanked out from under the run loop. func (c *Controller) beginRotation() error { c.mu.Lock() defer c.mu.Unlock() if c.running || c.finishing { return errTurnRunningRotation } if c.rotating { return errRotationInProgress } c.rotating = true return nil } func (c *Controller) endRotation() { c.mu.Lock() c.rotating = false c.mu.Unlock() } // CancelRequested reports whether Cancel has been requested for the active turn. func (c *Controller) CancelRequested() bool { c.mu.Lock() defer c.mu.Unlock() return c.canceling } // PendingPrompt reports whether the current turn is blocked waiting for a user // approval, plan approval, memory approval, or ask-tool answer. func (c *Controller) PendingPrompt() bool { return c.approval.hasPending() } // RuntimeStatus reports the active work owned by the foreground controller. func (c *Controller) RuntimeStatus() RuntimeStatus { c.mu.Lock() running := c.running active := running || c.finishing canceling := c.canceling c.mu.Unlock() pending := c.approval.hasPending() backgroundJobs := len(c.Jobs()) return RuntimeStatus{ Running: active, PendingPrompt: pending, BackgroundJobs: backgroundJobs, CancelRequested: canceling, Cancellable: running || pending, } } // Turn returns the current turn number (0 before the first submit). func (c *Controller) Turn() int { c.mu.Lock() defer c.mu.Unlock() return c.turn } // Approve answers a pending ApprovalRequest by ID: allow runs the call, session // also remembers a grant for the rest of the session so the same approval scope // is not re-prompted. Unknown/expired IDs are ignored. func (c *Controller) Approve(id string, allow, session, persist bool) { pending := c.approval.resolve(id) if pending.reply != nil { pending.reply <- approvalReply{allow: allow, session: session, persist: persist} // buffered, never blocks } } // EnableInteractiveApproval swaps the executor's gate for one that routes // approval decisions to the frontend via ApprovalRequest events, and wires the // controller in as the executor's Asker so the `ask` tool can question the user. // Interactive frontends (chat, desktop) call this; the headless run keeps the // silent gate and a nil asker from setup. func (c *Controller) EnableInteractiveApproval() { trustGate := planModeReadOnlyTrustApprover{c} escapeApprover := sandboxEscapeApprover{c} configApprover := managedConfigWriteApprover{c} if c.executor != nil { c.executor.SetGate(c.newInteractiveGate()) c.executor.SetPlanModeReadOnlyTrustGate(trustGate) c.executor.SetSandboxEscapeApprover(escapeApprover) c.executor.SetConfigWriteApprover(configApprover) c.executor.SetAsker(c) } if setter, ok := c.runner.(interface { SetPlanModeReadOnlyTrustGate(agent.PlanModeReadOnlyTrustGate) }); ok { setter.SetPlanModeReadOnlyTrustGate(trustGate) } if setter, ok := c.runner.(interface { SetSandboxEscapeApprover(sandbox.EscapeApprover) }); ok { setter.SetSandboxEscapeApprover(escapeApprover) } if setter, ok := c.runner.(interface { SetConfigWriteApprover(tool.ConfigWriteApprover) }); ok { setter.SetConfigWriteApprover(configApprover) } if setter, ok := c.runner.(interface { SetPlannerPlanApprover(agent.PlannerPlanApprover) }); ok { setter.SetPlannerPlanApprover(plannerPlanApprover{c: c}) } if setter, ok := c.runner.(interface { SetPlannerUserDecisionAsker(agent.PlannerUserDecisionAsker) }); ok { setter.SetPlannerUserDecisionAsker(plannerUserDecisionAsker{c: c}) } } type plannerPlanApprover struct { c *Controller } func (p plannerPlanApprover) RunWithPlannerApproval(ctx context.Context, plan string, run func(context.Context) error) error { c := p.c allow, _, err := c.requestApprovalWithReason(ctx, planApprovalTool, "", nil, "Planner requested host approval before execution.") if err != nil { return err } if !allow { return nil } todoArgs := c.seedPlanTodos(plan) execStart := c.sessionMessageCount() c.approval.setPlanAutoApprove(true) defer c.approval.setPlanAutoApprove(false) if err := run(ctx); err != nil { return err } if todoArgs != "" && !c.hasTodoUpdateSince(execStart) { c.completePlanTodos(todoArgs) } return nil } type plannerUserDecisionAsker struct { c *Controller } func (p plannerUserDecisionAsker) RunWithPlannerUserDecision(ctx context.Context, _ string, question event.AskQuestion, run func(context.Context, string) error) error { answers, err := p.c.Ask(ctx, []event.AskQuestion{question}) if err != nil { return err } answer := plannerUserDecisionAnswer(question, answers) if strings.TrimSpace(answer) == "" { return nil } return run(ctx, answer) } func plannerUserDecisionAnswer(question event.AskQuestion, answers []event.AskAnswer) string { for _, answer := range answers { if answer.QuestionID != question.ID { continue } selected := make([]string, 0, len(answer.Selected)) for _, item := range answer.Selected { if s := strings.TrimSpace(item); s != "" { selected = append(selected, s) } } return strings.Join(selected, ", ") } return "" } func (c *Controller) newInteractiveGate() *permission.Gate { policy := c.policy mode := c.approval.mode() switch mode { case ToolApprovalAuto, ToolApprovalYolo: policy.Mode = permission.Allow default: policy.Mode = permission.Ask } policy.Ask = append(policy.Ask, permission.Rule{Tool: memoryRememberTool}, permission.Rule{Tool: memoryForgetTool}, ) gate := permission.NewGate(policy, gateApprover{c}) gate.OnRemember = func(rule string) { if c.onRemember != nil { _ = c.onRemember(rule) } } return gate } func (c *Controller) refreshInteractiveGate() { if c.executor != nil { c.executor.SetGate(c.newInteractiveGate()) } } // Steer queues mid-turn guidance without interrupting the in-flight request. func (c *Controller) Steer(text string) { c.mu.Lock() exec := c.executor running := c.running c.mu.Unlock() if running && exec != nil && exec.Steer(text) { return } // No active turn accepted the steer: the frontend's runningRef was stale, // the turn exited between our running check and the enqueue, or no // executor is bound yet. Deliver it as a regular turn instead. c.submitSteerFallback(text) } // submitSteerFallback delivers steer text that no active turn accepted as a // regular turn. Steers are the user's own words, so admission parks the body // while another turn is running or finishing rather than dropping it — the // window between a turn's steer-queue flush and running=false would // otherwise lose the text silently. The text is submitted verbatim; steers // are never command-interpreted. func (c *Controller) submitSteerFallback(text string) admissionResult { return c.runGuardedOrPark(func(ctx context.Context) error { return c.runRefTurnWithResolverSync(ctx, text, text, text, "", c.ResolveRefs) }) } // SteerConsumed returns true when the steer queue is empty after the last consume. func (c *Controller) SteerConsumed() bool { c.mu.Lock() exec := c.executor c.mu.Unlock() if exec != nil { return exec.SteerConsumed() } return true } // Ask implements agent.Asker: it emits an AskRequest and blocks until // AnswerQuestion(ID, …) answers or ctx is cancelled. promptMu serialises it // against tool-approval prompts so at most one user prompt is outstanding. // Unlike tool-approval gates, Ask is NOT bypassed in YOLO mode — the `ask` // tool exists to get a genuine user decision, and YOLO only auto-approves // tool calls; it must not answer the user's questions for them. func (c *Controller) Ask(ctx context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error) { c.approval.promptMu.Lock() defer c.approval.promptMu.Unlock() id, reply := c.approval.registerAsk(questions) c.sink.Emit(event.Event{Kind: event.AskRequest, Ask: event.Ask{ID: id, Questions: questions}}) waitCtx, cancelWait := c.approval.waitContext(ctx) defer cancelWait() select { case ans := <-reply: return ans, nil case <-waitCtx.Done(): c.approval.cancelAsk(id) return nil, waitCtx.Err() } } // AnswerQuestion resolves a pending AskRequest by ID with the user's selections. // Unknown/expired IDs are ignored. func (c *Controller) AnswerQuestion(id string, answers []event.AskAnswer) { if pending, ok := c.approval.resolveAsk(id); ok { pending.reply <- answers // buffered, never blocks } } // ReplayPendingPrompts re-emits the ApprovalRequest / AskRequest event for every // prompt currently blocking the run loop. A frontend that reconnected or reloaded // after the original event has no way to rebuild its approval/ask modal otherwise, // so the blocked gate goroutine stays stuck forever while the session shows a // "waiting" status with no actionable prompt. promptMu serialises Ask and // requestApproval, so in practice at most one prompt is outstanding; the loops // stay general so a future concurrent prompt would still replay correctly. func (c *Controller) ReplayPendingPrompts() { approvals, asks := c.approval.snapshotPrompts() for _, a := range approvals { c.sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: a}) } for _, a := range asks { c.sink.Emit(event.Event{Kind: event.AskRequest, Ask: a}) } } // SetPlanMode flips the executor's read-only gate without touching the // cache-stable prompt prefix, and remembers the state so Compose can prepend the // plan-mode marker to outgoing turns. func (c *Controller) SetPlanMode(v bool) { c.mu.Lock() c.planMode = v c.mu.Unlock() if c.executor != nil { c.executor.SetPlanMode(v) } if setter, ok := c.runner.(interface{ SetPlanMode(bool) }); ok { setter.SetPlanMode(v) } } // SetAutoPlan updates the interactive auto-plan gate for subsequent turns. func (c *Controller) SetAutoPlan(mode string) { c.mu.Lock() c.autoPlan = normalizeAutoPlan(mode) c.mu.Unlock() } // SetResponseLanguage updates the final-answer language preference for // subsequent turns. func (c *Controller) SetResponseLanguage(lang string) { mode := config.NormalizeLanguage(lang) c.mu.Lock() c.responseLanguage = mode c.mu.Unlock() if setter, ok := c.runner.(interface{ SetResponseLanguage(string) }); ok { setter.SetResponseLanguage(mode) } else if c.executor != nil { c.executor.SetResponseLanguage(mode) } } // SetReasoningLanguage updates the visible reasoning language preference for // subsequent turns. func (c *Controller) SetReasoningLanguage(lang string) { mode := config.NormalizeReasoningLanguage(lang) c.mu.Lock() c.reasoningLanguage = mode c.mu.Unlock() if setter, ok := c.runner.(interface{ SetReasoningLanguage(string) }); ok { setter.SetReasoningLanguage(mode) } else if c.executor != nil { c.executor.SetReasoningLanguage(mode) } } // SetMemoryCompilerEnabled updates the Memory v5 runtime for subsequent turns // without rebuilding the controller or changing the stable provider prefix. func (c *Controller) SetMemoryCompilerEnabled(enabled bool) { if c == nil || c.executor == nil { return } var rt *memorycompiler.Runtime if enabled { rt = memorycompiler.New(config.MemoryCompilerDir(c.workspaceRoot)) } c.executor.SetMemoryCompiler(rt) } func (c *Controller) SetMemoryCompilerVerbosity(verbosity string) { if c == nil || c.executor == nil { return } c.executor.SetMemoryCompilerVerbosity(verbosity) } // PlanMode reports whether outgoing turns currently receive the plan-mode // marker. Frontends use it after Compose because auto-plan may flip the mode. func (c *Controller) PlanMode() bool { c.mu.Lock() defer c.mu.Unlock() return c.planMode } // GoalStrict enables or disables strict goal mode. In strict mode the agent // cannot override an incomplete-todo intercept — it must actually finish or // update all items before [goal:complete] is accepted. func (c *Controller) GoalStrict(strict bool) { path, data, ok := c.goals.setStrict(strict, c.goalTodos()) c.persistGoalState(path, data, ok) } // SetGoal stores a session-scoped active goal. Compose injects it into outgoing // user turns, not the system prompt or tool schema, so it does not disturb the // cache-stable prefix. func (c *Controller) SetGoal(goal string) { c.SetGoalWithResearchMode(goal, GoalResearchAuto) } func (c *Controller) SetGoalWithResearchMode(goal string, researchMode GoalResearchMode) { taskID, blockReason := c.ensureAutoResearchTask(goal, researchMode) path, data, ok := c.goals.set(goal, researchMode, taskID, c.goalTodos()) c.persistGoalState(path, data, ok) if blockReason != "" { path, data, ok := c.goals.stop(GoalStatusBlocked, c.goalTodos()) c.persistGoalState(path, data, ok) c.notice("autoresearch resume failed: " + blockReason) } } func (c *Controller) ensureAutoResearchTask(goal string, researchMode GoalResearchMode) (string, string) { goal = strings.TrimSpace(goal) if goal == "" || c.autoResearch == nil || !shouldUseAutoResearch(goal, researchMode) { return "", "" } currentGoal, currentStatus, _, currentTaskID := c.goals.snapshot() if strings.TrimSpace(currentGoal) == goal && currentStatus == GoalStatusRunning && strings.TrimSpace(currentTaskID) != "" { return currentTaskID, "" } if task, ok, err := c.autoResearch.ResumeFromGoalText(goal); err != nil { slog.Warn("controller: resume autoresearch task", "err", err) if ok { return "", err.Error() } } else if ok { c.notice("autoresearch task resumed: " + task.ID) return task.ID, "" } task, err := c.autoResearch.CreateTask(goal, autoresearch.CreateOptions{ AllowedOperations: autoresearch.AllowedOperations{ Write: true, Network: false, Publish: false, }, SuccessCriteria: defaultAutoResearchSuccessCriteria(), }) if err != nil { slog.Warn("controller: create autoresearch task", "err", err) return "", "" } c.notice("autoresearch task created: " + task.ID) return task.ID, "" } func defaultAutoResearchSuccessCriteria() []autoresearch.SuccessCriterion { return []autoresearch.SuccessCriterion{ { ID: "objective_evidence", Description: "The goal outcome is supported by direct evidence, such as inspected code, reproduced behavior, source material, or concrete findings.", Required: true, }, { ID: "verification", Description: "The result has relevant verification evidence, such as tests, commands, benchmarks, manual checks, or a documented reason why verification is not applicable.", Required: true, }, } } func (c *Controller) appendAutoResearchHeartbeat(taskID, status, message string) { if c.autoResearch == nil || strings.TrimSpace(taskID) == "" { return } iteration := 0 if summary, err := c.autoResearch.Summary(taskID); err == nil { iteration = summary.Iteration } if err := c.autoResearch.AppendHeartbeat(taskID, autoresearch.Heartbeat{ Status: status, Iteration: iteration, Message: message, CreatedAt: time.Now().UTC(), }); err != nil { slog.Warn("controller: append autoresearch heartbeat", "task_id", taskID, "status", status, "err", err) } } func (c *Controller) autoResearchAcceptedEvidenceIDs(taskID string) map[string]bool { if c.autoResearch == nil || strings.TrimSpace(taskID) == "" { return nil } findings, err := c.autoResearch.Findings(taskID, 0) if err != nil { slog.Warn("controller: read autoresearch findings", "task_id", taskID, "err", err) return nil } accepted := make(map[string]bool, len(findings)) for _, finding := range findings { if finding.Accepted { accepted[finding.ID] = true } } return accepted } func (c *Controller) recordAutoResearchTurnProgress(taskID string, acceptedBefore map[string]bool) { if c.autoResearch == nil || strings.TrimSpace(taskID) == "" { return } acceptedAfter := c.autoResearchAcceptedEvidenceIDs(taskID) newAccepted := make([]string, 0) for id := range acceptedAfter { if acceptedBefore == nil || !acceptedBefore[id] { newAccepted = append(newAccepted, id) } } sort.Strings(newAccepted) summary := autoResearchDirectionSummary(lastAssistantText(c.History())) if _, err := c.autoResearch.RecordDirection(taskID, autoresearch.Direction{ Summary: summary, AcceptedEvidenceIDs: newAccepted, Now: time.Now().UTC(), }); err != nil { slog.Warn("controller: record autoresearch direction", "task_id", taskID, "err", err) } } func (c *Controller) recordAutoResearchEvidenceFromAssistant(taskID, text string) { if c.autoResearch == nil || strings.TrimSpace(taskID) == "" { return } for _, item := range parseAutoResearchEvidenceBlocks(text) { if err := c.recordAutoResearchEvidenceForTask(taskID, item.CriterionID, AutoResearchEvidenceInput{ ID: item.ID, Kind: item.Kind, Summary: item.Summary, Source: item.Source, Command: item.Command, Paths: append([]string(nil), item.Paths...), Accepted: item.Accepted, }); err != nil { slog.Warn("controller: record autoresearch evidence block", "task_id", taskID, "criterion_id", item.CriterionID, "err", err) } } } type autoResearchEvidenceBlock struct { CriterionID string `json:"criterion_id"` ID string `json:"id"` Kind string `json:"kind"` Summary string `json:"summary"` Source string `json:"source"` Command string `json:"command"` Paths []string `json:"paths"` Accepted bool `json:"accepted"` } const ( autoResearchEvidenceOpen = "" autoResearchEvidenceClose = "" ) func parseAutoResearchEvidenceBlocks(text string) []autoResearchEvidenceBlock { var out []autoResearchEvidenceBlock rest := text for { start := strings.Index(rest, autoResearchEvidenceOpen) if start < 0 { return out } rest = rest[start+len(autoResearchEvidenceOpen):] end := strings.Index(rest, autoResearchEvidenceClose) if end < 0 { return out } raw := strings.TrimSpace(rest[:end]) rest = rest[end+len(autoResearchEvidenceClose):] if raw == "" { continue } var many []autoResearchEvidenceBlock if err := json.Unmarshal([]byte(raw), &many); err == nil { out = append(out, many...) continue } var one autoResearchEvidenceBlock if err := json.Unmarshal([]byte(raw), &one); err == nil { out = append(out, one) } } } func autoResearchDirectionSummary(text string) string { text = stripAutoResearchEvidenceBlocks(text) for _, line := range strings.Split(text, "\n") { line = strings.TrimSpace(line) lower := strings.ToLower(line) if line == "" || strings.HasPrefix(lower, "[goal:") { continue } if len(line) > 160 { line = line[:160] } return line } return "turn completed" } func stripAutoResearchEvidenceBlocks(text string) string { var b strings.Builder rest := text for { start := strings.Index(rest, autoResearchEvidenceOpen) if start < 0 { b.WriteString(rest) return b.String() } b.WriteString(rest[:start]) afterOpen := rest[start+len(autoResearchEvidenceOpen):] end := strings.Index(afterOpen, autoResearchEvidenceClose) if end < 0 { return b.String() } rest = afterOpen[end+len(autoResearchEvidenceClose):] } } func (c *Controller) autoResearchReadinessFailure() string { taskID := c.goals.currentAutoResearchTaskID() if c.autoResearch == nil || strings.TrimSpace(taskID) == "" { return "" } report, err := c.autoResearch.Readiness(taskID) if err != nil { return "AutoResearch readiness check failed: " + err.Error() } if report.Ready { return "" } var parts []string if len(report.MissingCriteria) > 0 { parts = append(parts, "missing criteria: "+strings.Join(report.MissingCriteria, ", ")) } if report.BlockedReason != "" { parts = append(parts, "blocked: "+report.BlockedReason) } if len(report.Errors) > 0 { parts = append(parts, "state errors: "+strings.Join(report.Errors, "; ")) } if len(parts) == 0 { parts = append(parts, "task is not ready") } return "AutoResearch readiness check failed: " + strings.Join(parts, "; ") } func (c *Controller) AutoResearchSummary() (*autoresearch.Summary, bool) { taskID := c.goals.currentAutoResearchTaskID() if c.autoResearch == nil || strings.TrimSpace(taskID) == "" { return nil, false } summary, err := c.autoResearch.Summary(taskID) if err != nil { return &autoresearch.Summary{ TaskID: taskID, Status: autoresearch.StatusInvalid, Blocker: err.Error(), }, true } return summary, true } func (c *Controller) AutoResearchList() ([]autoresearch.Summary, bool) { if c.autoResearch == nil { return nil, false } summaries, err := c.autoResearch.ListSummaries() if err != nil { slog.Warn("controller: list autoresearch tasks", "err", err) return nil, true } return summaries, true } func (c *Controller) AutoResearchFindings(limit int) ([]autoresearch.Finding, bool) { taskID := c.goals.currentAutoResearchTaskID() if c.autoResearch == nil || strings.TrimSpace(taskID) == "" { return nil, false } findings, err := c.autoResearch.Findings(taskID, limit) if err != nil { return nil, true } return findings, true } func (c *Controller) RecordAutoResearchEvidence(criterionID string, input AutoResearchEvidenceInput) error { taskID := c.goals.currentAutoResearchTaskID() if c.autoResearch == nil || strings.TrimSpace(taskID) == "" { return errors.New("autoresearch: no active task") } return c.recordAutoResearchEvidenceForTask(taskID, criterionID, input) } func (c *Controller) recordAutoResearchEvidenceForTask(taskID, criterionID string, input AutoResearchEvidenceInput) error { if c.autoResearch == nil || strings.TrimSpace(taskID) == "" { return errors.New("autoresearch: no active task") } id := strings.TrimSpace(input.ID) if id == "" { id = c.nextAutoResearchFindingID(taskID) } kind := strings.TrimSpace(input.Kind) if kind == "" { kind = autoresearch.FindingKindManual } source := strings.TrimSpace(input.Source) if source == "" { source = autoresearch.FindingSourceManual } finding := autoresearch.Finding{ ID: id, Kind: kind, Summary: strings.TrimSpace(input.Summary), Source: source, Command: strings.TrimSpace(input.Command), Paths: append([]string(nil), input.Paths...), Accepted: input.Accepted, CreatedAt: time.Now().UTC(), } return c.autoResearch.RecordEvidence(taskID, criterionID, finding) } func (c *Controller) nextAutoResearchFindingID(taskID string) string { findings, err := c.autoResearch.Findings(taskID, 0) if err != nil { return fmt.Sprintf("f%d", time.Now().UTC().UnixNano()) } used := make(map[string]bool, len(findings)) for _, finding := range findings { used[finding.ID] = true } for i := 1; ; i++ { id := fmt.Sprintf("f%d", len(findings)+i) if !used[id] { return id } } } func (c *Controller) ClearGoal() { c.SetGoal("") } func (c *Controller) Goal() string { return c.goals.goalText() } func (c *Controller) GoalStatus() string { return c.goals.statusForDisplay() } // Compact runs one compaction pass on the executor's session on demand. // instructions is optional `/compact ` guidance steering what to keep. func (c *Controller) Compact(ctx context.Context, instructions string) error { if c.executor == nil { return nil } // The run loop is the only sanctioned writer of the live session during a // turn; a manual compact would rewrite the log underneath it. The rotation // gate (not a bare Running() check) also blocks a turn from starting while // the compaction rewrites the session — see beginRotation. if err := c.beginRotation(); err != nil { if errors.Is(err, errTurnRunningRotation) { return fmt.Errorf("cannot compact while a turn is running") } return err } defer c.endRotation() return c.executor.CompactNow(ctx, instructions) } // maybeSessionStart fires the SessionStart hook exactly once per session, lazily // on the first turn — by then the sink/notify is wired, and a resumed session // fires it too (its first post-resume turn). func (c *Controller) maybeSessionStart(ctx context.Context) { c.mu.Lock() if c.startedOnce { c.mu.Unlock() return } c.startedOnce = true c.mu.Unlock() c.enqueueHookContexts(c.hooks.SessionStart(ctx)) } // NewSession snapshots the current conversation, rotates to a fresh file, and // resets the executor to a clean session carrying the same system prompt. It // ends the old session and starts the new one for lifecycle hooks. func (c *Controller) NewSession() error { if c.executor == nil { return nil } // Claim the rotation gate for the whole snapshot-then-swap sequence. A bare // `if c.running` check released before Snapshot() left a window where a turn // could start during the snapshot and then have its live session replaced by // the SetSession below. Submit ("/new") and the bot gateway call this // asynchronously, so the gate is load-bearing, not defensive. if err := c.beginRotation(); err != nil { return err } defer c.endRotation() if err := c.Snapshot(); err != nil { return err } c.hooks.SessionEnd(context.Background()) // Hold snapshotMu across the swap so an in-flight save cannot pair the old // path with the fresh session (or the fresh path with the old session). c.snapshotMu.Lock() if c.sessionDir != "" { c.mu.Lock() c.sessionPath = agent.NewSessionPath(c.sessionDir, c.label) c.guardianPath = guardian.PathFor(c.sessionPath) c.mu.Unlock() } c.setActiveJobSession(c.SessionPath()) c.executor.SetSession(agent.NewSession(c.systemPrompt)) if c.guardianSess != nil { c.guardianSess.Reset() } c.ResetPlannerSession() c.rebindCheckpoints(c.SessionPath()) c.snapshotMu.Unlock() // A new session starts with no active goal: without this, a running goal's // text kept injecting into the fresh session's first turns. The old // session's goal-state sidecar was persisted before the rotation and stays // intact, so resuming it restores its goal; the cleared state below lands // on the NEW path (rebindCheckpoints just moved it). c.ClearGoal() c.mu.Lock() c.startedOnce = true // NewSession fires SessionStart itself; don't re-fire on the next turn c.mu.Unlock() c.enqueueHookContexts(c.hooks.SessionStart(context.Background())) return nil } // ClearSession discards the current conversation without preserving it in // resume/history, then rotates to a clean session carrying the same system prompt. func (c *Controller) ClearSession() error { if c.executor == nil { return nil } // Same rotation gate as NewSession: hold it across the whole // destroy-then-swap so a turn cannot start during the sequence and have its // live session replaced. if err := c.beginRotation(); err != nil { if errors.Is(err, errTurnRunningRotation) { return fmt.Errorf("cannot clear while a turn is running") } return err } defer c.endRotation() c.mu.Lock() oldPath := c.sessionPath c.mu.Unlock() preMarkedCleanup := c.hasUnfinishedSessionJobs(oldPath) if preMarkedCleanup { if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil { return err } } // Hold snapshotMu from artifact removal through the swap: a save slipping // in between would resurrect the just-removed transcript, and one that // overlapped the swap could pair the old path with the fresh session. c.snapshotMu.Lock() destroy := c.BeginDestroySession(oldPath) if !destroy.Async { if err := removeSessionArtifacts(oldPath); err != nil { destroy.Finish() c.snapshotMu.Unlock() return err } destroy.Finish() } c.hooks.SessionEnd(context.Background()) if c.sessionDir != "" { c.mu.Lock() c.sessionPath = agent.NewSessionPath(c.sessionDir, c.label) c.guardianPath = guardian.PathFor(c.sessionPath) c.mu.Unlock() } c.setActiveJobSession(c.SessionPath()) c.executor.SetSession(agent.NewSession(c.systemPrompt)) if c.guardianSess != nil { c.guardianSess.Reset() } c.ResetPlannerSession() c.rebindCheckpoints(c.SessionPath()) c.snapshotMu.Unlock() // Same contract as NewSession: the fresh session starts with no active goal. c.ClearGoal() c.mu.Lock() c.startedOnce = true c.mu.Unlock() c.enqueueHookContexts(c.hooks.SessionStart(context.Background())) if destroy.Async { go func() { result := destroy.Wait() if result.HasTimedOut() && destroy.WaitAll != nil { if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil { c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "mark cleanup pending failed: " + err.Error()}) } destroy.WaitAll() } if err := removeSessionArtifacts(oldPath); err != nil { c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "clear session cleanup failed: " + err.Error()}) } destroy.Finish() }() } return nil } func (c *Controller) hasUnfinishedSessionJobs(sessionPath string) bool { if c.jobs == nil { return false } return c.jobs.HasUnfinishedForSession(agent.BranchID(sessionPath)) } func removeSessionArtifacts(path string) error { if path == "" { return nil } if err := jobs.RemoveArtifacts(path); err != nil { return err } remove := []string{path} // Sidecars include the event log — the authoritative transcript. Leaving // it behind would both leak the cleared conversation and let LoadSession // resurrect it on the recycled path. The guardian transcript saves through // the same session layer, so its sidecars are swept too. remove = append(remove, store.SessionSidecarFiles(path)...) remove = append(remove, guardian.PathFor(path), guardian.CursorPathFor(path)) remove = append(remove, store.SessionSidecarFiles(guardian.PathFor(path))...) for _, p := range remove { if p == "" { continue } if err := os.Remove(p); err != nil && !os.IsNotExist(err) { return err } } if dir := ckptDir(path); dir != "" { if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) { return err } } if err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil { return err } if err := agent.ClearCleanupPending(path); err != nil { return err } return nil } // ReconcileCleanupPending retries physical cleanup for logically removed // sessions that were left behind by a previous process. func ReconcileCleanupPending(dir string) error { return agent.ReconcileCleanupPending(dir, func(item agent.CleanupPendingInfo) error { return removeSessionArtifacts(item.SessionPath) }) } // RewindScope selects what a Rewind restores. type RewindScope int const ( RewindCode RewindScope = iota // files only RewindConversation // message log only RewindBoth // both ) // Checkpoints lists the session's rewind points (one per user turn), oldest first. func (c *Controller) Checkpoints() []checkpoint.Meta { return c.checkpoints.list() } func (c *Controller) CheckpointTurnsByMessageIndex() map[int]int { return c.checkpoints.turnsByMessageIndex() } // rewindFail emits the error as a Warn notice (so a frontend that swallows the // returned error — e.g. the desktop bridge's .catch — still shows the user why // the rewind did nothing) and returns it. func (c *Controller) rewindFail(err error) error { c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: err.Error()}) return err } // Rewind restores the session to the start of `turn`: Code reverts every file that // turn (or a later one) changed to its pre-turn content; Conversation truncates the // message log back to that turn; Both does both. Refused while a turn is running. // Conversation rewind relies on the live boundary recorded at turn start, so it is // unavailable for turns inherited from a resumed session (code rewind still works). // Frontends re-render their transcript from History after the call. func (c *Controller) Rewind(turn int, scope RewindScope) error { if !c.checkpoints.enabled() || c.executor == nil { return c.rewindFail(fmt.Errorf("checkpoints unavailable")) } // Rewind rewrites the live session (conversation scope) and restores files; // hold the rotation gate across the whole operation so a turn cannot start // between the check and the Replace/SnapshotRewrite below. if err := c.beginRotation(); err != nil { if errors.Is(err, errTurnRunningRotation) { return c.rewindFail(fmt.Errorf("cannot rewind while a turn is running")) } return c.rewindFail(err) } defer c.endRotation() boundary, hasBound := c.checkpoints.boundary(turn) if scope == RewindCode || scope == RewindBoth { written, deleted, err := c.checkpoints.restoreCode(turn) if err != nil { return c.rewindFail(fmt.Errorf("rewind code: %w", err)) } if len(written) > 0 || len(deleted) > 0 { c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("rewound code to turn %d — %d file(s) restored, %d removed", turn, len(written), len(deleted))}) } } if scope == RewindConversation || scope == RewindBoth { if !hasBound { return c.rewindFail(fmt.Errorf("conversation rewind unavailable for turn %d (resumed session)", turn)) } s := c.executor.Session() // boundary is the message-log index at turn start; compaction shrinks the // log without rewriting boundaries, so a stale boundary past the end means // the turn was compacted away — fail loudly instead of skipping silently. // Snapshot/Replace keep the truncation safe against concurrent History/Save // readers on other goroutines. msgs := s.Snapshot() if boundary > len(msgs) { return c.rewindFail(fmt.Errorf("conversation rewind unavailable for turn %d: the conversation was compacted past this point", turn)) } s.Replace(msgs[:boundary]) c.checkpoints.truncateFrom(turn) // renumber future turns from here; later turns are gone if err := c.SnapshotRewrite(); err != nil { slog.Warn("controller: snapshot after rewind", "err", err) } c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("rewound conversation to turn %d", turn)}) } return nil } // Fork branches the conversation at the start of turn into a NEW session file, // preserving the current one as the branch point, and switches to the branch. Code // is untouched (it's a conversation operation). Like a conversation rewind it needs // the live boundary, so it is unavailable for resumed-session turns and refused // while a turn runs. Returns the new session path. func (c *Controller) Fork(turn int) (string, error) { return c.ForkNamed(turn, "") } func (c *Controller) ForkNamed(turn int, name string) (string, error) { return c.forkNamed(turn, name, true) } // ForkSession copies the conversation at the start of turn into a new session // file without switching this controller to it. Desktop uses this to open the // branch in a new tab while the source tab keeps its current transcript. func (c *Controller) ForkSession(turn int, name string) (string, error) { return c.forkNamed(turn, name, false) } func (c *Controller) forkNamed(turn int, name string, switchToFork bool) (string, error) { if c.executor == nil { return "", c.rewindFail(fmt.Errorf("checkpoints unavailable")) } if c.sessionDir == "" { return "", c.rewindFail(fmt.Errorf("fork needs session persistence, which is disabled")) } // Hold the rotation gate from before the pre-fork Snapshot through the // switch below: a bare Running() check released here would let a turn start // during the snapshot and then be switched onto the fork. if err := c.beginRotation(); err != nil { if errors.Is(err, errTurnRunningRotation) { return "", c.rewindFail(fmt.Errorf("cannot fork while a turn is running")) } return "", c.rewindFail(err) } defer c.endRotation() boundary, hasBound := c.checkpoints.boundary(turn) if !hasBound { return "", c.rewindFail(fmt.Errorf("fork unavailable for turn %d (resumed session)", turn)) } // Persist the current conversation first so the branch point survives, then // seed a fresh session with the messages up to the fork and switch to it. if err := c.Snapshot(); err != nil { slog.Warn("controller: pre-fork snapshot", "err", err) } parentPath := c.SessionPath() parentID := agent.BranchID(parentPath) src := c.executor.Session().Snapshot() if boundary > len(src) { boundary = len(src) } forked := append([]provider.Message(nil), src[:boundary]...) sess := agent.NewSession("") sess.Messages = forked newPath := agent.NewSessionPath(c.sessionDir, c.label) if err := sess.Save(newPath); err != nil { return "", c.rewindFail(err) } forkPreview, forkTurns := agent.SessionPreviewFromMessages(forked) if err := agent.SaveBranchMeta(newPath, agent.BranchMeta{ Name: strings.TrimSpace(name), ParentID: parentID, ForkTurn: turn, ForkMessageIndex: boundary, Preview: forkPreview, Turns: forkTurns, SchemaVersion: agent.BranchMetaCountsVersion, }); err != nil { return "", c.rewindFail(err) } if switchToFork { // See snapshotMu: the swap must not interleave with an in-flight save. c.snapshotMu.Lock() c.executor.SetSession(sess) c.ResetPlannerSession() c.mu.Lock() c.sessionPath = newPath c.guardianPath = guardian.PathFor(newPath) c.mu.Unlock() c.setActiveJobSession(newPath) c.rebindCheckpoints(newPath) if c.guardianSess != nil { c.guardianSess.Reset() } c.snapshotMu.Unlock() } c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("forked conversation at turn %d into a new session", turn)}) return newPath, nil } func (c *Controller) CheckpointHasBoundary(turn int) bool { boundary, ok := c.checkpoints.boundary(turn) if !ok { return false } // After compaction the key may still exist but the boundary value is // stale (it points past the truncated message log). Treat those // turns the same as "no boundary" so the UI can disable the button. // Len is lock-guarded: this runs on frontend goroutines while a turn appends. return boundary <= c.executor.Session().Len() } // Branch copies the current conversation into a child branch and switches to it. // Unlike Fork, it branches at the current tip and does not require a checkpoint. func (c *Controller) Branch(name string) (string, error) { if c.executor == nil { return "", c.rewindFail(fmt.Errorf("branch unavailable")) } if c.sessionDir == "" { return "", c.rewindFail(fmt.Errorf("branch needs session persistence, which is disabled")) } // Hold the rotation gate across the Snapshot and the switch below so a turn // cannot start mid-branch and then have its session replaced. if err := c.beginRotation(); err != nil { if errors.Is(err, errTurnRunningRotation) { return "", c.rewindFail(fmt.Errorf("cannot branch while a turn is running")) } return "", c.rewindFail(err) } defer c.endRotation() if !c.executor.Session().HasContent() { return "", c.rewindFail(fmt.Errorf("nothing to branch yet")) } if err := c.Snapshot(); err != nil { return "", c.rewindFail(err) } parentPath := c.SessionPath() parentID := agent.BranchID(parentPath) src := c.executor.Session().Snapshot() branched := append([]provider.Message(nil), src...) sess := agent.NewSession("") sess.Messages = branched newPath := agent.NewSessionPath(c.sessionDir, c.label) if err := sess.Save(newPath); err != nil { return "", c.rewindFail(err) } branchPreview, branchTurns := agent.SessionPreviewFromMessages(branched) if err := agent.SaveBranchMeta(newPath, agent.BranchMeta{ Name: strings.TrimSpace(name), ParentID: parentID, ForkTurn: -1, ForkMessageIndex: len(branched), Preview: branchPreview, Turns: branchTurns, SchemaVersion: agent.BranchMetaCountsVersion, }); err != nil { return "", c.rewindFail(err) } // See snapshotMu: the swap must not interleave with an in-flight save. c.snapshotMu.Lock() c.executor.SetSession(sess) c.ResetPlannerSession() c.mu.Lock() c.sessionPath = newPath c.guardianPath = guardian.PathFor(newPath) c.mu.Unlock() c.setActiveJobSession(newPath) c.rebindCheckpoints(newPath) if c.guardianSess != nil { c.guardianSess.Reset() } c.snapshotMu.Unlock() c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("created branch %s", agent.BranchID(newPath))}) return newPath, nil } // Branches lists saved conversation branches in this controller's session dir. func (c *Controller) Branches() ([]agent.BranchInfo, error) { if c.sessionDir == "" { return nil, fmt.Errorf("session persistence is disabled") } if err := c.Snapshot(); err != nil { return nil, err } return agent.ListBranches(c.sessionDir) } func (c *Controller) SwitchBranch(ref string) (agent.BranchInfo, error) { ref = strings.TrimSpace(ref) if ref == "" { return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("usage: /switch ")) } // Hold the rotation gate across the branch listing/load and the switch so a // turn cannot start between the check and the SetSession below. if err := c.beginRotation(); err != nil { if errors.Is(err, errTurnRunningRotation) { return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("cannot switch branches while a turn is running")) } return agent.BranchInfo{}, c.rewindFail(err) } defer c.endRotation() branches, err := c.Branches() if err != nil { return agent.BranchInfo{}, c.rewindFail(err) } match, err := resolveBranch(branches, ref) if err != nil { return agent.BranchInfo{}, c.rewindFail(err) } if !agent.IsVisibleSession(match.Path) { return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("branch %q not found", ref)) } loaded, err := agent.LoadSession(match.Path) if err != nil { return agent.BranchInfo{}, c.rewindFail(err) } // See snapshotMu: the swap must not interleave with an in-flight save. c.snapshotMu.Lock() if c.executor != nil { c.executor.SetSession(loaded) } c.ResetPlannerSession() c.mu.Lock() c.sessionPath = match.Path c.guardianPath = guardian.PathFor(match.Path) c.mu.Unlock() c.setActiveJobSession(match.Path) c.rebindCheckpoints(match.Path) c.restoreTerminalGoalTodos(match.Path) c.loadGuardianSession() c.snapshotMu.Unlock() c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf("switched to branch %s", branchDisplayName(match))}) return match, nil } // ResolveBranchRef resolves a /switch-style branch reference (id, unique // prefix, name, or path) against a branch listing, using the same matching // rules as SwitchBranch. Frontends use it to learn the target session path // before switching — e.g. to move their session lease first. func ResolveBranchRef(branches []agent.BranchInfo, ref string) (agent.BranchInfo, error) { return resolveBranch(branches, strings.TrimSpace(ref)) } func resolveBranch(branches []agent.BranchInfo, ref string) (agent.BranchInfo, error) { refLower := strings.ToLower(ref) var matches []agent.BranchInfo for _, b := range branches { nameLower := strings.ToLower(strings.TrimSpace(b.Name)) switch { case b.ID == ref || strings.EqualFold(b.ID, ref): return b, nil case b.Name != "" && nameLower == refLower: matches = append(matches, b) case strings.HasPrefix(strings.ToLower(b.ID), refLower): matches = append(matches, b) case strings.HasPrefix(strings.ToLower(shortBranchID(b.ID)), refLower): matches = append(matches, b) case b.Path == ref: return b, nil } } if len(matches) == 1 { return matches[0], nil } if len(matches) > 1 { return agent.BranchInfo{}, fmt.Errorf("branch %q is ambiguous", ref) } return agent.BranchInfo{}, fmt.Errorf("branch %q not found", ref) } func branchDisplayName(b agent.BranchInfo) string { if strings.TrimSpace(b.Name) != "" { return fmt.Sprintf("%s (%s)", b.Name, b.ID) } return b.ID } // SummarizeFrom compresses the conversation from turn onward into one summary; // SummarizeUpTo compresses everything before it. Both are Claude Code's "summarize // from/up to here" — they restructure the message log (keeping code untouched), so // afterwards the per-turn boundaries no longer map and conversation rewind/fork // report "unavailable" until new turns rebuild them (code rewind, file-based, is // unaffected). Refused while a turn runs; need the live boundary. func (c *Controller) SummarizeFrom(ctx context.Context, turn int) error { return c.summarizeAt(ctx, turn, true) } func (c *Controller) SummarizeUpTo(ctx context.Context, turn int) error { return c.summarizeAt(ctx, turn, false) } func (c *Controller) summarizeAt(ctx context.Context, turn int, from bool) error { if c.executor == nil { return c.rewindFail(fmt.Errorf("checkpoints unavailable")) } // Summarize rewrites the live session AFTER a provider round-trip, so the // bare Running() check left a seconds-wide window for a turn to start and // then have the log replaced under it. Hold the rotation gate from the // boundary read through the post-rewrite snapshot. if err := c.beginRotation(); err != nil { if errors.Is(err, errTurnRunningRotation) { return c.rewindFail(fmt.Errorf("cannot summarize while a turn is running")) } return c.rewindFail(err) } defer c.endRotation() boundary, hasBound := c.checkpoints.boundary(turn) if !hasBound { return c.rewindFail(fmt.Errorf("summarize unavailable for turn %d (resumed session)", turn)) } var err error if from { err = c.executor.SummarizeFrom(ctx, boundary) } else { err = c.executor.SummarizeUpTo(ctx, boundary) } if err != nil { return c.rewindFail(err) } // The log was restructured; existing boundaries no longer map. Drop them (keep // the turn counter monotonic so new turns don't collide with the store) — // conversation rewind degrades to "unavailable" until fresh turns rebuild them. c.checkpoints.clearBounds() if err := c.SnapshotRewrite(); err != nil { slog.Warn("controller: post-summarize snapshot", "err", err) } return nil } // Resume seeds the session from a loaded transcript and pins the active file to // its path so auto-save keeps appending there. func (c *Controller) Resume(s *agent.Session, path string) { // See snapshotMu: the swap must not interleave with an in-flight save. // recoverInterruptedTurn and maybeColdResumePrune snapshot on their own, // so they stay outside the locked section (snapshotMu is not reentrant). c.snapshotMu.Lock() if c.executor != nil { c.executor.SetSession(s) } c.ResetPlannerSession() c.mu.Lock() c.sessionPath = path c.guardianPath = guardian.PathFor(path) c.mu.Unlock() c.setActiveJobSession(path) c.rebindCheckpoints(path) c.goals.restoreRunningFromState(path) c.restoreTerminalGoalTodos(path) c.loadGuardianSession() c.snapshotMu.Unlock() c.recoverInterruptedTurn(path) c.maybeColdResumePrune(path) } func (c *Controller) loadGuardianSession() { if c.guardianSess == nil { return } c.guardianSess.Reset() path := c.guardianPath if path == "" { return } if err := c.guardianSess.Load(path); err != nil && !os.IsNotExist(err) { slog.Warn("controller: load guardian session", "err", err) } } // ResetPlannerSession clears the planner's conversation history so the next // plan starts fresh. In dual-model (Plan+Execute) mode, this prevents stale // planner output from a previous session or tab from contaminating the current // executor's handoff. Safe to call on a single-model controller (no-op). func (c *Controller) ResetPlannerSession() { runner, ok := c.runner.(plannerSessionResetter) if ok { runner.ResetPlannerSession() } } // cacheColdAfter approximates how long the provider keeps a prompt prefix // cached. A session idle longer than this resumes against a cold cache, so a // history rewrite at that moment costs no extra cache misses — it only shrinks // the full-price first request. Deliberately conservative: too small burns a // live cache (~4× the miss tokens, measured), too large only forgoes a prune. // Tighten from benchmarks/cache-ttl-probe data, never below measured retention. var cacheColdAfter = 24 * time.Hour // maybeColdResumePrune elides stale tool results when a resumed session has // been idle past the provider's cache retention, then persists the pruned // transcript so the saved file and the prompt stay in sync. func (c *Controller) maybeColdResumePrune(path string) { if c.disableColdResumePrune || c.executor == nil || path == "" { return } // Idle time comes from branch meta only — every session the controller has // ever snapshotted carries one. A meta-less transcript (e.g. a legacy import // not yet saved) skips the prune until its first snapshot creates the meta. m, ok, err := agent.LoadBranchMeta(path) if err != nil || !ok || m.UpdatedAt.IsZero() { return } last := m.UpdatedAt if time.Since(last) < cacheColdAfter { return } st, err := c.executor.PruneStaleToolResults() if err != nil || st.Results == 0 { return } c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf( "resumed after %s idle (provider cache expired) — elided %d stale tool results to cheapen the cold restart", time.Since(last).Round(time.Minute), st.Results)}) if err := c.SnapshotRewrite(); err != nil { slog.Warn("controller: post-prune snapshot", "err", err) } } // Snapshot writes the executor's conversation to the active session file. No-op // when the executor is absent or the session has never been used (no user // interaction). Returns errNoSessionPath when there IS content but no resolved // path, so a misconfigured deployment surfaces instead of dropping data. // Called after every turn so a crash loses at most one in-flight prompt. func (c *Controller) Snapshot() error { return c.snapshot(false, false) } // SnapshotActivity writes the active conversation and marks the session as // recently active. Use it only after a real user/model turn changes the // transcript; switch/close snapshots should call Snapshot so they do not reorder // recent-session pickers. func (c *Controller) SnapshotActivity() error { return c.snapshot(true, false) } // SnapshotRewrite persists an intentional history rewrite, such as rewind or // manual compaction. Ordinary autosave paths should use Snapshot so stale // controllers cannot overwrite a newer transcript. func (c *Controller) SnapshotRewrite() error { return c.snapshot(false, true) } // midTurnSnapshotInterval is atomic (nanoseconds) so a test shrinking it // cannot race a previous test's still-parking autosave goroutine. var midTurnSnapshotInterval atomic.Int64 func init() { midTurnSnapshotInterval.Store(int64(30 * time.Second)) } // autosaveWhileRunning snapshots the session periodically while a turn runs, // so an abrupt kill (SSH drop, force-quit) loses at most one interval of a // long turn instead of all of it (#3772). Session.Save copies under the lock // and replaces the file atomically, so racing the turn's appends is safe. func (c *Controller) autosaveWhileRunning(ctx context.Context) { t := time.NewTicker(time.Duration(midTurnSnapshotInterval.Load())) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: if err := c.snapshot(false, false); err != nil { slog.Warn("controller: mid-turn snapshot", "err", err) } } } } func (c *Controller) snapshot(markActivity, forceRewrite bool) error { c.snapshotMu.Lock() defer c.snapshotMu.Unlock() c.mu.Lock() path := c.sessionPath modelRef := c.modelRef c.mu.Unlock() if c.executor == nil { return nil } s := c.executor.Session() if !s.HasContent() { // Nothing to persist yet (e.g. a fresh session with only a system // prompt) — staying quiet here is correct, not a data-loss path. return nil } if !s.HasSystemMessage() { // The session has user/assistant/tool messages but no leading system // prompt. Persisting it would create a session file that, when // reloaded, has no agent-identity contract — the model falls back to // its training-data defaults, giving wrong answers to identity // queries ("who are you?"). Log the anomaly so the root cause // (typically an empty sysPrompt reaching NewSession) can be // diagnosed, then refuse to write a corrupted transcript. slog.Warn("controller: refusing to snapshot session with content but no system message", "label", c.Label(), "session_dir", c.SessionDir(), "message_count", len(s.Snapshot())) return nil } if path == "" { // There IS content but nowhere to write it: this silently dropped whole // bot conversations (#4414). Surface it loudly instead of returning nil // so the missing session path can be diagnosed and fixed at the source. slog.Warn("controller: session has content but no session path; conversation will not be persisted", "label", c.Label(), "session_dir", c.SessionDir()) return errNoSessionPath } forceRewrite = forceRewrite || s.NeedsRewriteSave() var err error if forceRewrite { err = s.SaveRewrite(path) } else { err = s.SaveSnapshot(path) if errors.Is(err, agent.ErrSessionSnapshotConflict) { // The no-rewrite decision may already be stale: auto-compaction // can rewrite history between the decision and the write. Re-check // and retry once as an owned rewrite before treating the failure as // a real cross-runtime conflict. if s.NeedsRewriteSave() { forceRewrite = true err = s.SaveRewrite(path) } } } if err != nil { if !errors.Is(err, agent.ErrSessionSnapshotConflict) { return err } recoveredPath, outcome, recoverErr := c.recoverSnapshotConflict(path, err, forceRewrite) if recoverErr != nil { return recoverErr } if outcome == conflictDropped { return nil } // Whatever recovery did — adopted the disk transcript, force-saved // the depth-capped branch, or forked — the rewrite baseline lives on // the session object and was advanced by the save that succeeded, so // there is nothing to re-anchor here. path = recoveredPath s = c.executor.Session() } // Persist guardian session so the prefix cache stays warm after restart. if c.guardianSess != nil { gp := c.guardianPath if gp != "" { if gerr := c.guardianSess.Save(gp); gerr != nil { slog.Warn("controller: guardian snapshot", "err", gerr) } } } // Record the listing-only sidecar fields (model, preview, user-turn count) // straight from the in-memory conversation, so the sidebar and resume picker // never have to decode the whole .jsonl just to show them. markActivity bumps // UpdatedAt exactly like the previous TouchBranchMeta did; false preserves it // like SetBranchModelPreserveUpdated. The single write subsumes the old // EnsureBranchMeta / SetBranchModel / TouchBranchMeta sequence. preview, turns := agent.SessionPreviewFromMessages(s.Snapshot()) if err := agent.UpdateSessionMeta(path, modelRef, preview, turns, markActivity); err != nil { return err } return nil } // snapshotConflictLogAttrs flattens a snapshot-conflict error into slog attrs. // Field reports of #6069-class "session changed on disk" spam are only // diagnosable when the logs say which trigger fired and what the revision // ledger looked like, so every recoverSnapshotConflict outcome logs these. func snapshotConflictLogAttrs(saveErr error, path, mode string) []any { attrs := []any{"path", path, "mode", mode} var conflict *agent.SessionSnapshotConflictError if errors.As(saveErr, &conflict) && conflict != nil { attrs = append(attrs, "kind", string(conflict.Kind), "disk_messages", conflict.ExistingMessages, "snapshot_messages", conflict.SnapshotMessages, "base_revision", conflict.BaseRevision, "disk_revision", conflict.DiskRevision, ) } return attrs } type snapshotConflictDiagnostic struct { At time.Time `json:"at"` BranchID string `json:"branch_id"` Mode string `json:"mode"` Outcome string `json:"outcome"` Kind string `json:"kind,omitempty"` DiskMessages int `json:"disk_messages,omitempty"` SnapshotMessages int `json:"snapshot_messages,omitempty"` BaseRevision int64 `json:"base_revision,omitempty"` DiskRevision int64 `json:"disk_revision,omitempty"` RecoveryBranchID string `json:"recovery_branch_id,omitempty"` ExistingRecovery bool `json:"existing_recovery,omitempty"` } func appendSnapshotConflictDiagnostic(path, mode, outcome string, saveErr error, recoveryPath string, existing bool) { path = strings.TrimSpace(path) if path == "" { return } rec := snapshotConflictDiagnostic{ At: time.Now(), BranchID: agent.BranchID(path), Mode: mode, Outcome: outcome, } var conflict *agent.SessionSnapshotConflictError if errors.As(saveErr, &conflict) && conflict != nil { rec.Kind = string(conflict.Kind) rec.DiskMessages = conflict.ExistingMessages rec.SnapshotMessages = conflict.SnapshotMessages rec.BaseRevision = conflict.BaseRevision rec.DiskRevision = conflict.DiskRevision } if recoveryPath != "" { rec.RecoveryBranchID = agent.BranchID(recoveryPath) rec.ExistingRecovery = existing } data, err := json.Marshal(rec) if err != nil { return } logPath := store.SessionConflictLog(path) if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil { return } f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return } defer f.Close() _, _ = f.Write(append(data, '\n')) } // conflictOutcome is recoverSnapshotConflict's declared result. Callers act // on it directly instead of re-deriving what happened from path or session // pointer comparisons — the misclassification that broke the depth-cap // rewrite baseline (#6120) hid in exactly that inference. type conflictOutcome int const ( // conflictDropped: nothing was recovered and the disk transcript could // not be adopted; this snapshot was deliberately dropped. conflictDropped conflictOutcome = iota // conflictAdoptedDisk: the executor session object was replaced by the // newer disk transcript; adoptDiskSession already reset its baselines. conflictAdoptedDisk // conflictForceSavedBranch: recovery depth was exhausted and the same // in-memory session was force-saved onto the same branch; that save // advanced the session-owned rewrite baseline like any other full save. conflictForceSavedBranch // conflictForkedBranch: the same in-memory session moved to a freshly // forked recovery branch path. conflictForkedBranch ) const recoveryDepthCapNoticeText = "repeated save conflicts were detected; saved the current conflict copy in place" func (c *Controller) emitRecoveryDepthCapNotice(path string) { key := filepath.Clean(strings.TrimSpace(path)) c.mu.Lock() if c.recoveryDepthCapNotices == nil { c.recoveryDepthCapNotices = make(map[string]bool) } if c.recoveryDepthCapNotices[key] { c.mu.Unlock() return } c.recoveryDepthCapNotices[key] = true c.mu.Unlock() c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: recoveryDepthCapNoticeText}) } func (c *Controller) recoverSnapshotConflict(path string, saveErr error, forceRewrite bool) (string, conflictOutcome, error) { if c.executor == nil || strings.TrimSpace(path) == "" { return "", conflictDropped, saveErr } mode := "snapshot" if forceRewrite { mode = "rewrite" } logAttrs := snapshotConflictLogAttrs(saveErr, path, mode) if kind, ok := agent.SnapshotConflictKind(saveErr); ok && kind == agent.SessionSnapshotConflictStalePrefix { if c.adoptDiskSession(path) { appendSnapshotConflictDiagnostic(path, mode, "adopted_newer_disk_transcript", saveErr, "", false) slog.Warn("controller: snapshot conflict; adopted newer disk transcript", logAttrs...) c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "session changed on disk; adopted the newer transcript"}) return path, conflictAdoptedDisk, nil } } reason := "snapshot conflict" if forceRewrite { reason = "rewrite conflict" } req := SessionRecoveryRequest{OriginalPath: path, Reason: reason, Mode: mode} meta := agent.BranchMeta{} if c.sessionRecoveryMeta != nil { meta = c.sessionRecoveryMeta(req) } info, err := c.executor.Session().SaveRecoveryBranch(agent.RecoveryBranchOptions{ OriginalPath: path, Reason: reason, BranchMeta: meta, }) if err != nil { if errors.Is(err, agent.ErrSessionRecoveryDepthExceeded) { // Saves keep conflicting on recovery branches this runtime itself // created; forking again multiplies session files without // converging (#5993 reached 8 nested levels). This runtime is the // only writer of its own recovery branches, so force-writing the // transcript back onto the current branch keeps the data and // stops the chain. if forceErr := c.executor.Session().Save(path); forceErr != nil { return "", conflictDropped, fmt.Errorf("recovery chain depth exceeded; force save failed: %w", forceErr) } appendSnapshotConflictDiagnostic(path, mode, "recovery_depth_cap_force_saved", saveErr, path, false) slog.Warn("controller: snapshot conflict; recovery depth cap reached, force-saved onto current branch", logAttrs...) c.emitRecoveryDepthCapNotice(path) return path, conflictForceSavedBranch, nil } if errors.Is(err, agent.ErrSessionRecoveryNotNeeded) { if c.adoptDiskSession(path) { appendSnapshotConflictDiagnostic(path, mode, "recovery_not_needed_adopted_disk_transcript", saveErr, "", false) slog.Warn("controller: snapshot conflict; recovery not needed, adopted disk transcript", logAttrs...) c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "session changed on disk; adopted the newer transcript (local changes already covered)"}) return path, conflictAdoptedDisk, nil } // Nothing was recovered AND the disk transcript could not be // adopted: the snapshot is silently dropped. Leave a trace so // "my last turns vanished" reports can be tied to this path. appendSnapshotConflictDiagnostic(path, mode, "recovery_not_needed_adopt_failed", saveErr, "", false) slog.Warn("controller: snapshot conflict; recovery not needed but disk transcript could not be adopted", logAttrs...) return "", conflictDropped, nil } return "", conflictDropped, fmt.Errorf("recover stale session snapshot: %w", err) } recoveryInfo := SessionRecoveryInfo{ OriginalPath: path, RecoveryPath: info.Path, Existing: info.Existing, Reason: reason, Meta: info.Meta, } if c.onSessionRecovered != nil { if err := c.onSessionRecovered(recoveryInfo); err != nil { return "", conflictDropped, fmt.Errorf("commit recovered session: %w", err) } } c.mu.Lock() c.sessionPath = info.Path c.guardianPath = guardian.PathFor(info.Path) c.mu.Unlock() c.setActiveJobSession(info.Path) c.rebindCheckpoints(info.Path) c.transplantInFlightTurnMarker(path, info.Path) appendSnapshotConflictDiagnostic(path, mode, "forked_recovery_branch", saveErr, info.Path, info.Existing) slog.Warn("controller: snapshot conflict; forked recovery branch", append(logAttrs, "recovery", info.Path, "existing", info.Existing)...) c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "session changed on disk; unsaved local transcript was saved as a conflict copy"}) return info.Path, conflictForkedBranch, nil } func (c *Controller) adoptDiskSession(path string) bool { loaded, err := agent.LoadSession(path) if err != nil || loaded == nil { return false } c.executor.SetSession(loaded) c.ResetPlannerSession() c.rebindCheckpoints(path) c.setActiveJobSession(path) return true } func (c *Controller) messageCount() int { if c.executor == nil { return 0 } return c.executor.Session().Len() } func (c *Controller) markInFlightTurn(startMessageIndex int, preserveUser bool) { path := c.SessionPath() if path == "" { return } if err := agent.MarkSessionInFlightTurn(path, startMessageIndex, preserveUser); err != nil { slog.Warn("controller: mark in-flight turn", "err", err) } } func (c *Controller) clearInFlightTurn() { path := c.SessionPath() if path == "" { return } if err := agent.ClearSessionInFlightTurn(path); err != nil { slog.Warn("controller: clear in-flight turn", "err", err) } } // transplantInFlightTurnMarker moves a pending in-flight-turn marker from the // session path a recovery fork abandoned onto the branch the turn continues // on. Left behind, the stale marker would fire recoverInterruptedTurn on the // next open of the original branch and strip messages from a turn that in // fact kept running on the recovery branch; missing from the recovery branch, // a crash before turn end would leave its partial tail unmarked. func (c *Controller) transplantInFlightTurnMarker(fromPath, toPath string) { if strings.TrimSpace(fromPath) == "" || strings.TrimSpace(toPath) == "" || fromPath == toPath { return } meta, ok, err := agent.LoadBranchMeta(fromPath) if err != nil || !ok || meta.InFlightTurn == nil { if err != nil { slog.Warn("controller: load in-flight turn marker for transplant", "path", fromPath, "err", err) } return } marker := meta.InFlightTurn if err := agent.MarkSessionInFlightTurn(toPath, marker.StartMessageIndex, marker.PreserveUser); err != nil { // Keep the original marker: a turn boundary on the wrong branch beats // no boundary anywhere if the runtime dies before the turn completes. slog.Warn("controller: transplant in-flight turn marker", "path", toPath, "err", err) return } if err := agent.ClearSessionInFlightTurn(fromPath); err != nil { slog.Warn("controller: clear in-flight turn marker on forked-from branch", "path", fromPath, "err", err) } } func (c *Controller) recoverInterruptedTurn(path string) { if c.executor == nil || path == "" { return } meta, ok, err := agent.LoadBranchMeta(path) if err != nil || !ok || meta.InFlightTurn == nil { if err != nil { slog.Warn("controller: load in-flight turn marker", "err", err) } return } marker := meta.InFlightTurn if interruptedTurnContinuedOnRecoveryBranch(path, marker) { // The "interrupted" turn did not die with a runtime: a recovery branch // forked off this session after the marker was set, so the turn kept // running (and completing) there. Runtimes predating the marker // transplant in recoverSnapshotConflict left the marker behind on the // forked-from branch; stripping now would truncate a transcript the // completed turn already superseded. Clear the stale marker instead. if err := agent.ClearSessionInFlightTurn(path); err != nil { slog.Warn("controller: clear fork-orphaned in-flight turn", "err", err) } return } msgs := c.executor.Session().Snapshot() changed := marker.StartMessageIndex >= 0 && len(msgs) > marker.StartMessageIndex if changed { if marker.PreserveUser { c.stripCancelledVisibleTurnMessagesAfter(marker.StartMessageIndex) } else { c.stripTurnMessagesAfter(marker.StartMessageIndex) } if err := c.snapshot(false, true); err != nil { slog.Warn("controller: post-interrupted-turn snapshot", "err", err) } } if err := agent.ClearSessionInFlightTurn(path); err != nil { slog.Warn("controller: clear stale in-flight turn", "err", err) } } // interruptedTurnContinuedOnRecoveryBranch reports whether a recovery branch // forked off path after its in-flight-turn marker was set. Markers only exist // while a turn runs and recovery forks happen on saves, so a child recovery // branch younger than the marker means the marked turn itself moved there — // the marker is a leftover from a runtime that switched paths mid-turn, not a // crashed turn whose partial tail needs stripping. A marker without a start // time is treated as continued whenever any recovery child exists: erring // toward keeping messages is the data-safe direction. func interruptedTurnContinuedOnRecoveryBranch(path string, marker *agent.InFlightTurnMeta) bool { if marker == nil { return false } branches, err := agent.ListBranches(filepath.Dir(path)) if err != nil { return false } id := agent.BranchID(path) for _, b := range branches { if b.Recovered && b.ParentID == id && b.CreatedAt.After(marker.StartedAt) { return true } } return false } // stripTurnMessagesAfter truncates the executor's session to keep only messages // before the given index, discarding an incomplete synthetic turn (the synthetic // user prompt plus every assistant/tool message that followed). func (c *Controller) stripTurnMessagesAfter(idx int) { if c.executor == nil { return } msgs := c.executor.Session().Snapshot() if len(msgs) <= idx { return } c.replaceSessionAfterCancel(msgs[:idx]) } // stripCancelledVisibleTurnMessagesAfter removes assistant/tool remnants from a // cancelled visible turn while preserving the real user prompt that started it. func (c *Controller) stripCancelledVisibleTurnMessagesAfter(idx int) { if c.executor == nil { return } msgs := c.executor.Session().Snapshot() if len(msgs) <= idx { return } next := append([]provider.Message{}, msgs[:idx]...) for _, m := range msgs[idx:] { if m.Role != provider.RoleUser { continue } if IsSyntheticUserMessage(m.Content) { continue } if _, ok := agent.SteerText(m.Content); ok { continue } next = append(next, m) break } c.replaceSessionAfterCancel(next) } func (c *Controller) replaceSessionAfterCancel(msgs []provider.Message) { // The whole cleanup is a save/recovery handoff like snapshot's: hold // snapshotMu from the in-memory truncation onward. Truncating outside the // lock would let an in-flight save capture the shortened transcript, read // the longer partial autosave on disk as a stale-prefix conflict, and // adopt it back into the executor — silently undoing the cancel cleanup // before the flush below could persist it. c.snapshotMu.Lock() defer c.snapshotMu.Unlock() c.executor.Session().Replace(append([]provider.Message(nil), msgs...)) // Rebuild canonical todo state from the truncated transcript so // Controller.Todos(), goal readiness, and the task panel no longer see // the in_progress items written by the cancelled turn. c.executor.RebuildTodoState() // The mid-turn autosave may have already written a partial transcript to // disk. snapshotActivityIfChanged skips the write when messageCount() // returns to startMessages, so flush the cleaned transcript here. SaveRewrite // still checks that this controller owns the current on-disk baseline before // overwriting it, and also covers the edge case where the strip leaves only a // system message (HasContent() == false). The path is read under the lock so // an in-flight recovery retarget cannot leave it stale. c.mu.Lock() path := c.sessionPath c.mu.Unlock() if path != "" { if err := c.executor.Session().SaveRewrite(path); err != nil { if errors.Is(err, agent.ErrSessionSnapshotConflict) { if _, outcome, recoverErr := c.recoverSnapshotConflict(path, err, true); recoverErr != nil { slog.Warn("controller: post-cancel transcript recovery", "err", recoverErr) } else if outcome == conflictDropped { slog.Warn("controller: post-cancel transcript dropped after conflict", "path", path) } } else { slog.Warn("controller: post-cancel transcript flush", "err", err) } } } } func (c *Controller) snapshotActivityIfChanged(startMessages int) { if c.messageCount() <= startMessages { return } if err := c.SnapshotActivity(); err != nil { slog.Warn("controller: activity snapshot", "err", err) } } // SetSessionPath pins where auto-save lands (a fresh session file minted by the // caller when no resume path applies). func (c *Controller) SetSessionPath(p string) { // See snapshotMu: the swap must not interleave with an in-flight save. c.snapshotMu.Lock() defer c.snapshotMu.Unlock() c.mu.Lock() c.sessionPath = p c.guardianPath = guardian.PathFor(p) c.mu.Unlock() c.setActiveJobSession(p) c.rebindCheckpoints(p) } // SessionDestroyHandle separates waiting for cancelled jobs from ending the // destroy window, so callers can move/delete persistent artifacts in between. type SessionDestroyHandle struct { Wait func() jobs.TeardownResult WaitAll func() Finish func() Async bool } // BeginDestroySession marks a session as leaving active use and cancels its // background jobs. Call Wait before moving/deleting artifacts, then Finish after // persistent cleanup/move work is complete. func (c *Controller) BeginDestroySession(sessionPath string) SessionDestroyHandle { parentSession := agent.BranchID(sessionPath) if c.jobs == nil || parentSession == "" { wait := func() jobs.TeardownResult { return jobs.TeardownResult{} } noop := func() {} return SessionDestroyHandle{Wait: wait, WaitAll: noop, Finish: noop} } teardown := c.jobs.BeginDestroySession(parentSession) return SessionDestroyHandle{ Wait: func() jobs.TeardownResult { return c.jobs.WaitTeardown(context.Background(), teardown, c.jobs.TeardownGrace()) }, WaitAll: func() { for _, ch := range teardown.DoneChannels() { <-ch } }, Finish: func() { c.jobs.FinishDestroySession(parentSession) }, Async: teardown.Async(), } } // IsDestroyingSession reports whether sessionPath is currently in the destroy // window for this controller's job manager. func (c *Controller) IsDestroyingSession(sessionPath string) bool { if c.jobs == nil { return false } return c.jobs.IsDestroying(agent.BranchID(sessionPath)) } func (c *Controller) setActiveJobSession(sessionPath string) { if c.jobs != nil { c.jobs.SetActiveSessionPath(agent.BranchID(sessionPath), sessionPath) } } // SessionDir reports the directory new session files land in ("" disables // persistence), so the caller can decide whether to mint a path. func (c *Controller) SessionDir() string { return c.sessionDir } // SessionPath reports the file the current conversation auto-saves to ("" when // persistence is disabled), so a history view can mark the active session. func (c *Controller) SessionPath() string { c.mu.Lock() defer c.mu.Unlock() return c.sessionPath } func (c *Controller) parentSessionID() string { return agent.BranchID(c.SessionPath()) } // History returns the executor's current message log (for repopulating a // resumed frontend's view). func (c *Controller) History() []provider.Message { if c.executor == nil { return nil } return c.executor.Session().Snapshot() // copy — a turn may be appending concurrently } // ContextSnapshot returns (usedTokens, contextWindow) from the most recent // turn. Both zero means no data yet — a gauge hides itself. // usedTokens is promptTokens + completionTokens so the GUI breakdown and // gauge reflect the full token usage, not just the prompt fill. func (c *Controller) ContextSnapshot() (int, int) { if c.executor == nil { return 0, 0 } u := c.executor.LastUsage() if u == nil { return 0, c.executor.ContextWindow() } return u.PromptTokens + u.CompletionTokens, c.executor.ContextWindow() } // CompactRatio returns the auto-compaction threshold as a fraction of the window // (0 when the executor is unset). The status line shows headroom against it. func (c *Controller) CompactRatio() float64 { if c.executor == nil { return 0 } return c.executor.CompactRatio() } // LastUsage returns the most recent turn's token telemetry (nil before the first // turn), so frontends can derive the prompt cache-hit rate for the status line. func (c *Controller) LastUsage() *provider.Usage { if c.executor == nil { return nil } return c.executor.LastUsage() } // SessionCache returns cumulative cache hit/miss prompt tokens for the session, // so a frontend can render the aggregate (session-wide) cache-hit rate — steadier // than the single-turn rate and unaffected by compaction. func (c *Controller) SessionCache() (hit, miss int) { if c.executor == nil { return 0, 0 } return c.executor.SessionCache() } // Todos returns a copy of the canonical task list (the latest todo_write state // merged with complete_step advances) so frontends can render a live task panel. func (c *Controller) Todos() []evidence.TodoItem { if c.executor == nil { return nil } return c.executor.CanonicalTodoState() } // ToolResultData holds the full arguments and output for one tool call, loaded // on demand when a frontend expands a collapsed tool card. type ToolResultData struct { Args string `json:"args"` Output string `json:"output"` } // ToolResult looks up a tool call by its ID in the session history and returns // the full arguments + output that were elided from the frontend's items[]. // Returns nil when the tool ID isn't found (e.g. a sub-agent's tool call that // lives in a different session). func (c *Controller) ToolResult(toolID string) *ToolResultData { if c.executor == nil { return nil } msgs := c.executor.Session().Snapshot() // Search backwards: tool result first (most recent), then find the args // from the preceding assistant turn. for i := len(msgs) - 1; i >= 0; i-- { if msgs[i].Role != provider.RoleTool || msgs[i].ToolCallID != toolID { continue } out := &ToolResultData{ Args: "", Output: msgs[i].Content, } // Walk back to find the assistant turn that issued this call. for j := i; j >= 0; j-- { if msgs[j].Role != provider.RoleAssistant { continue } for _, tc := range msgs[j].ToolCalls { if tc.ID == toolID { out.Args = tc.Arguments return out } } } return out } return nil } // Balance queries the active provider's wallet balance, or (nil, nil) when the // provider declares no balance_url — so a caller treats "not configured" and // "fetched" the same and just omits the readout when nil. func (c *Controller) Balance(ctx context.Context) (*billing.Balance, error) { if strings.TrimSpace(c.balanceURL) == "" { return nil, nil } ctx, cancel := context.WithTimeout(ctx, 12*time.Second) defer cancel() return billing.FetchWithClient(ctx, c.balanceClient, c.balanceURL, c.balanceKey) } // Host returns the running MCP host (nil when no plugins), for frontends that // list servers / resolve MCP prompts. func (c *Controller) Host() *plugin.Host { return c.mcp.hostRef() } // Commands returns the loaded custom slash commands. func (c *Controller) Commands() []command.Command { if p := c.commands.Load(); p != nil { return *p } return nil } // ReloadCommands rescans all command directories and hot-swaps the slash_command // tool and the internal command slice — no MCP restart, no hook rerun. func (c *Controller) ReloadCommands(ctx context.Context) error { select { case <-ctx.Done(): return ctx.Err() default: } cmds, loadErr := command.LoadRoots(config.CommandRootsForRoot(c.workspaceRoot)...) cmdSkills := c.SlashSkills() entries := make([]command.SlashEntry, 0, len(cmdSkills)+len(cmds)) for _, sk := range cmdSkills { sk := sk entries = append(entries, command.SlashEntry{ Name: sk.SlashName(), Description: sk.Description, Render: func(args []string) string { return skill.Render(sk, strings.Join(args, " ")) }, }) } for _, cmd := range cmds { if cmd.Hidden { continue } cmd := cmd entries = append(entries, command.SlashEntry{ Name: cmd.Name, Description: cmd.Description, ArgHint: cmd.ArgHint, Render: func(args []string) string { return cmd.Render(args) }, }) } c.mcp.registerTool(command.NewSlashCommandTool(entries)) cmdSlice := cmds c.commands.Store(&cmdSlice) return loadErr } // Skills returns the discoverable skills (for the slash menu and `/skills`). // When a live Store is available, scan it on demand so skills installed during // this session appear without rewriting the cache-stable system prompt. // Executor returns the underlying agent when present (nil for pure runners). func (c *Controller) Executor() *agent.Agent { if c == nil { return nil } return c.executor } func (c *Controller) Skills() []skill.Skill { return c.skills.list() } // SlashSkills returns the user-visible skill directory. Plugin skills use // package-qualified names while Skills keeps bare model/run_skill identifiers. func (c *Controller) SlashSkills() []skill.Skill { return c.skills.slashList() } // AllSkills returns every discoverable skill, including disabled ones, for // management surfaces that need to re-enable a hidden skill. func (c *Controller) AllSkills() []skill.Skill { return c.skills.listAll() } // DisabledSkills returns all discoverable skills that are disabled in config. func (c *Controller) DisabledSkills() []skill.Skill { cfg, err := config.Load() if err != nil { return nil } var out []skill.Skill for _, sk := range c.AllSkills() { if cfg.IsSkillDisabled(sk.Name) { out = append(out, sk) } } return out } // SkillEnabled reports whether a discoverable skill is enabled. func (c *Controller) SkillEnabled(name string) bool { cfg, err := config.Load() if err != nil { return true } return !cfg.IsSkillDisabled(name) } // SetSkillEnabled persists a skill enable/disable preference. The caller should // rebuild the controller for the prompt/tool registry to reflect it immediately. func (c *Controller) SetSkillEnabled(name string, enabled bool) error { found := false for _, sk := range c.AllSkills() { if config.SkillNameKey(sk.Name) == config.SkillNameKey(name) { name = sk.Name found = true break } } if !found { return fmt.Errorf("unknown skill: %s", name) } // Serialize the load-modify-save against other in-process user-config // editors so concurrent writers (bot mapping persistence, desktop // settings) don't drop this toggle or lose their own fields. unlock := config.LockUserConfigEdits() defer unlock() cfg := config.LoadForEdit(config.UserConfigPath()) if err := cfg.SetSkillEnabled(name, enabled); err != nil { return err } return cfg.SaveTo(config.UserConfigPath()) } // CreateSkill writes a new skill file at the given scope and returns its // path. Skills()/AllSkills()/RunSkill() read the live store on demand, so the // new skill is usable (by name) immediately with no rebuild; the caller // should still rebuild the controller for the pinned Skills index and tool // registry to reflect it on the model's next turn, mirroring how // SetSkillEnabled's callers already rebuild after a config change. func (c *Controller) CreateSkill(name string, scope skill.Scope, content string) (string, error) { w := c.skills.writer() if w == nil { return "", fmt.Errorf("no writable skill store in this session") } return w.CreateWithContent(name, scope, content) } // UpdateSkill overwrites an existing user-authored skill file in place. See // skill.Store.UpdateContent for the builtin-refusal and scope-match rules. func (c *Controller) UpdateSkill(name string, scope skill.Scope, content string) error { w := c.skills.writer() if w == nil { return fmt.Errorf("no writable skill store in this session") } return w.UpdateContent(name, scope, content) } // DeleteSkill removes a user-authored skill file at the given scope. See // skill.Store.Delete for the builtin-refusal and scope-match rules. func (c *Controller) DeleteSkill(name string, scope skill.Scope) error { w := c.skills.writer() if w == nil { return fmt.Errorf("no writable skill store in this session") } return w.Delete(name, scope) } // HookRunner returns the session's hook runner (nil-safe; may hold zero hooks), // so a frontend can list the active hooks via `/hooks`. func (c *Controller) HookRunner() *hook.Runner { return c.hooks } // AddMCPServer connects an MCP server live and persists it to the config file. Its // tools are registered immediately and become available on the next turn (the // agent reads the registry per turn). The raw entry — ${VARS} intact — is what's // written to disk; the live connection uses the expanded form. Returns the number // of tools the server exposed. A save failure after a successful connect is // reported but non-fatal: the server still works this session. func (c *Controller) AddMCPServer(e config.PluginEntry) (int, error) { n, err := c.connectMCPServer(e) if err != nil { return 0, err } cfg, lerr := config.Load() if lerr != nil { return n, fmt.Errorf("connected, but reloading config to save failed: %w", lerr) } if err := cfg.UpsertPlugin(e); err != nil { return n, fmt.Errorf("connected, but config rejected the entry: %w", err) } if err := cfg.Save(); err != nil { return n, fmt.Errorf("connected, but saving config failed: %w", err) } return n, nil } // ConnectMCPServer connects an MCP server entry for this session without writing // it to config. Desktop owns config placement so it can keep user-level settings // out of project reasonix.toml while preserving the CLI AddMCPServer semantics. func (c *Controller) ConnectMCPServer(e config.PluginEntry) (int, error) { return c.connectMCPServer(e) } // connectMCPServer expands an entry's ${VARS}, applies the known-server // overrides scoped to the workspace, and connects it live via the mcp manager. func (c *Controller) connectMCPServer(e config.PluginEntry) (int, error) { exp := e.ExpandedPlugin() return c.mcp.connectSpec(plugin.ApplyKnownOverrides(plugin.Spec{ Name: exp.Name, Type: exp.Type, Command: exp.Command, Args: exp.Args, Env: exp.Env, URL: exp.URL, Headers: exp.Headers, ReadOnlyToolNames: trustedReadOnlyToolNames(exp.TrustedReadOnlyTools), }, c.WorkspaceRoot())) } func trustedReadOnlyToolNames(names []string) map[string]bool { if len(names) == 0 { return nil } out := map[string]bool{} for _, name := range names { name = strings.TrimSpace(name) if name != "" { out[name] = true } } if len(out) == 0 { return nil } return out } // ImportMCPEntries persists selected MCP entries and attempts to connect them // live. A connection failure does not roll back the config import: the user can // fix local dependencies and reconnect in a later session. func (c *Controller) ImportMCPEntries(entries []config.PluginEntry) (total, added, updated, connected, failed, skipped int, err error) { cfg, lerr := config.Load() if lerr != nil { return 0, 0, 0, 0, 0, 0, lerr } existing := make(map[string]bool, len(cfg.Plugins)) for _, p := range cfg.Plugins { existing[p.Name] = true } for _, e := range entries { if existing[e.Name] { updated++ } else { added++ } if err := cfg.UpsertPlugin(e); err != nil { return 0, 0, 0, 0, 0, 0, err } existing[e.Name] = true } if err := cfg.Save(); err != nil { return 0, 0, 0, 0, 0, 0, err } for _, e := range entries { if c.mcp.hasServer(e.Name) { skipped++ continue } if _, err := c.AddMCPServer(e); err != nil { failed++ continue } connected++ } return len(entries), added, updated, connected, failed, skipped, nil } func (c *Controller) ConfiguredMCPNames() []string { cfg, err := config.Load() if err != nil { return nil } names := make([]string, 0, len(cfg.Plugins)) for _, p := range cfg.Plugins { names = append(names, p.Name) } return names } func (c *Controller) DisconnectedMCPNames() []string { cfg, err := config.Load() if err != nil { return nil } connected := map[string]bool{} for _, name := range c.mcp.serverNames() { connected[name] = true } var names []string for _, p := range cfg.Plugins { if !connected[p.Name] { names = append(names, p.Name) } } return names } func (c *Controller) ConnectConfiguredMCPServer(name string) (int, error) { cfg, err := config.Load() if err != nil { return 0, err } for _, p := range cfg.Plugins { if p.Name == name { return c.connectMCPServer(p) } } return 0, fmt.Errorf("no configured MCP server named %q", name) } // RemoveMCPServer removes a writable MCP configuration before disconnecting the // live server, so a persistence failure never produces a false-successful // session-only removal. MCPs contributed by an installed plugin package are // managed with that package and cannot be removed independently. func (c *Controller) RemoveMCPServer(name string) (disconnected bool, err error) { cfg, lerr := config.LoadForRoot(c.workspaceRoot) if lerr != nil { return false, lerr } if owner, ok := cfg.PluginPackageOwner(name); ok { return false, fmt.Errorf("MCP server %q is managed by plugin %q; disable or remove the plugin instead", name, owner) } removed, rerr := config.RemovePluginFromSourcesForRoot(c.workspaceRoot, name) if rerr != nil { return false, rerr } if !removed { return false, fmt.Errorf("no removable MCP server named %q", name) } disconnected = c.mcp.disconnect(name) if !disconnected { c.mcp.removeToolPrefix(name) } return disconnected, nil } // DisconnectMCPServer disconnects a live server for this session without touching // config — the connector toggle's "off". Its tools vanish next turn; it reconnects // on the next session start, or now via ConnectConfiguredMCPServer (the "on"). // Reports whether a live server was actually disconnected. func (c *Controller) DisconnectMCPServer(name string) bool { disconnected := c.mcp.disconnect(name) removedPlaceholder := 0 if !disconnected { removedPlaceholder = c.mcp.removeToolPrefix(name) } return disconnected || removedPlaceholder > 0 } // UnregisterMCPServerTools hides a shared MCP server from this controller only. // The desktop shared-host path uses this for per-tab connector toggles: the // shared client stays alive for sibling tabs, while this session's registry drops // the server's provider-visible tools before the next turn. func (c *Controller) UnregisterMCPServerTools(name string) bool { return c.mcp.suspendToolPrefix(name) } // Label returns the human-readable model label, e.g. "deepseek-flash". func (c *Controller) Label() string { return c.label } // ModelRef returns the canonical provider/model reference for the session. func (c *Controller) ModelRef() string { return c.modelRef } // WorkspaceRoot returns the workspace root for this controller's session // (the directory that file-writers and @-references are scoped to). // Empty means no scoping is in effect. func (c *Controller) WorkspaceRoot() string { return c.workspaceRoot } func (c *Controller) imageInputEnabled() bool { ref := c.modelRef cfg, err := config.LoadForRoot(c.workspaceRoot) if err == nil && ref == "" { ref = cfg.DefaultModel } if err != nil || ref == "" { return false } entry, ok := cfg.ResolveModel(ref) return ok && config.EffectiveVision(entry) } // ImageInputEnabled reports whether the current model accepts direct image // inputs, so frontends can gate image-only UX before a turn starts. func (c *Controller) ImageInputEnabled() bool { return c.imageInputEnabled() } // InheritLifecycleFrom carries same-session lifecycle state across controller // rebuilds, such as model switches that preserve the conversation. func (c *Controller) InheritLifecycleFrom(prev *Controller) { if prev == nil { return } prev.mu.Lock() started := prev.startedOnce turn := prev.turn prev.mu.Unlock() c.mu.Lock() c.startedOnce = started if c.turn < turn { c.turn = turn } c.mu.Unlock() } // ReleaseResources stops plugin subprocesses and releases resources without // firing SessionEnd. Use it only when replacing the controller for the same // logical session. func (c *Controller) ReleaseResources() { c.close(false, closeJobsWithGrace) } // Close stops plugin subprocesses and releases resources. A session that ever // started fires SessionEnd so a teardown hook runs. func (c *Controller) Close() { c.close(true, closeJobsWithGrace) } // CloseAfterDestroy releases controller resources after the caller has already // begun session-specific job teardown. It avoids a second synchronous job grace // wait while still cancelling the manager root and reaping temporary artifacts // once every job goroutine finally exits. func (c *Controller) CloseAfterDestroy() { c.close(true, closeJobsAsync) } type closeJobsMode int const ( closeJobsWithGrace closeJobsMode = iota closeJobsAsync ) func (c *Controller) close(fireSessionEnd bool, jobsMode closeJobsMode) { // Desktop tab lifecycles can race a rebind/model-switch/close on the same // controller; make teardown idempotent so a duplicate Close cannot re-fire // SessionEnd hooks or re-run cleanup. The first caller's jobsMode wins. c.closeOnce.Do(func() { c.mu.Lock() started := c.startedOnce // Seal turn admission and drop anything already parked: a parked turn // must not start against a controller that is being torn down, and // without the closed flag a submit landing after this critical // section (while a running turn's TurnDone delivery is still in // flight) would park again and start after teardown. c.closed = true c.parkedTurns = nil c.mu.Unlock() if fireSessionEnd && started { c.hooks.SessionEnd(context.Background()) } if c.jobs != nil { switch jobsMode { case closeJobsAsync: c.jobs.CloseAsync() default: c.jobs.Close() // cancel any still-running background jobs } } if c.cleanup != nil { c.cleanup() } }) } // Jobs returns the still-running background jobs for the status bar (nil when // background jobs are disabled). func (c *Controller) Jobs() []jobs.View { if c.jobs == nil { return nil } return c.jobs.RunningForSession(c.parentSessionID()) } // SetToolApprovalMode changes the runtime approval posture for permission-gated // tools. It does not answer business asks or plan approval. func (c *Controller) SetToolApprovalMode(mode string) { pending := c.approval.setMode(normalizeToolApprovalMode(mode)) c.refreshInteractiveGate() for _, reply := range pending { reply <- approvalReply{allow: true} } } func (c *Controller) ToolApprovalMode() string { return c.approval.mode() } // SetAutoApproveTools turns YOLO/full-access mode on or off for the session: // while on, every tool approval request is auto-allowed (writers and bash run // without asking). Ask requests and plan approval still reach the user. Deny // rules still block. Runtime-only — never written to config. func (c *Controller) SetAutoApproveTools(on bool) { if on { c.SetToolApprovalMode(ToolApprovalYolo) return } c.SetToolApprovalMode(ToolApprovalAsk) } // SetBypass is the legacy name for SetAutoApproveTools. Keep it for existing // desktop/serve bindings and CLI code that still uses the bypass wording. func (c *Controller) SetBypass(on bool) { c.SetAutoApproveTools(on) } // SetMode applies plan (read-only) and tool auto-approval together so a turn // submitted right after a composer mode switch can't observe a half-applied // gate. Turning tool auto-approval on drains any pending tool approval. func (c *Controller) SetMode(plan, autoApproveTools bool) { c.mu.Lock() c.planMode = plan c.mu.Unlock() if c.executor != nil { c.executor.SetPlanMode(plan) } if autoApproveTools { c.SetToolApprovalMode(ToolApprovalYolo) } else { c.SetToolApprovalMode(ToolApprovalAsk) } } // AutoApproveTools reports whether YOLO/full-access tool auto-approval is on, // for status indicators and mode persistence. func (c *Controller) AutoApproveTools() bool { return c.ToolApprovalMode() == ToolApprovalYolo } // Bypass is the legacy name for AutoApproveTools. func (c *Controller) Bypass() bool { return c.AutoApproveTools() } // --- memory --- // // The memory snapshot, the pending turn-tail notes queue, and write serialization // live in c.memory (a memoryManager) behind its own locks, off c.mu — so a // memory-panel save never stalls an approval or status poll. These methods are // the SessionAPI surface; each is a thin delegation. See memory.go. // QuickAdd appends a one-line note to the doc-memory file for scope (project // REASONIX.md by default) — the write side of "#". Returns the file written. func (c *Controller) QuickAdd(scope memory.Scope, note string) (string, error) { return c.memory.quickAdd(scope, note) } // SaveDoc overwrites a recognized memory doc with body — the save side of the // desktop panel's in-place editor. Returns the file written. func (c *Controller) SaveDoc(path, body string) (string, error) { return c.memory.saveDoc(path, body) } // SaveMemory writes an active auto-memory fact and refreshes the in-session // snapshot. It is the explicit user-confirmed counterpart to the model-owned // remember tool, used by management surfaces that preview a candidate first. func (c *Controller) SaveMemory(m memory.Memory) (string, error) { return c.memory.saveMemory(m) } // ForgetMemory removes a saved auto-memory by name — the panel/TUI forget action, // the manual counterpart to the model's `forget` tool. func (c *Controller) ForgetMemory(name string) error { return c.memory.forget(name) } // QueueMemory implements memory.Queue: when the model runs the remember/forget // tool, the tool calls this with a note that rides the next turn so the change // applies this session without touching the cache-stable prefix. It also // refreshes the snapshot a memory panel reads. func (c *Controller) QueueMemory(note string) { c.memory.queue(note) } // Memory returns the loaded memory snapshot (nil when memory is disabled), for // frontends that surface a memory panel or the /memory command. The returned // *Set is immutable — mutations go through QuickAdd / SaveDoc. func (c *Controller) Memory() *memory.Set { return c.memory.current() } // --- approval bridge (agent gate → events) --- // gateApprover adapts the Controller to permission.Approver. It is distinct // from the public Approve command (different signature, different direction). type gateApprover struct{ c *Controller } func (g gateApprover) Approve(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, error) { allow, remember, _, err := g.ApproveWithReason(ctx, tool, subject, args) return allow, remember, err } func (g gateApprover) ApproveWithReason(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, string, error) { subject = approvalDisplaySubject(tool, subject, args) // requestApproval short-circuits the YOLO / just-approved-plan window and any // session grant before it emits a prompt, so the auto-allow paths need no // special-casing here. Deny rules already bit before this point. if g.c.guardianSess != nil && !g.c.approval.preApproved(tool, subject) { allow, reason, reviewErr := g.c.guardianSess.Review(ctx, tool, args, g.c.executor.Session()) if reviewErr != nil { return false, false, "", reviewErr } if allow && !requiresFreshApprovalTool(tool) { return true, false, "", nil } humanAllow, remember, err := g.c.requestApprovalWithReason(ctx, tool, subject, args, reason) if err != nil { return false, false, reason, err } if !humanAllow { return false, false, reason, nil } return true, remember, "", nil } allow, remember, err := g.c.requestApproval(ctx, tool, subject, args) return allow, remember, "", err } type planModeReadOnlyTrustApprover struct{ c *Controller } type sandboxEscapeApprover struct{ c *Controller } func (s sandboxEscapeApprover) ApproveSandboxEscape(ctx context.Context, req sandbox.EscapeRequest) (bool, string, error) { subject := sandboxEscapeApprovalSubject(req.Command) reason := sandboxEscapeApprovalReason(req.Reason) reply, err := s.c.requestFreshApprovalDecision(ctx, SandboxEscapeApprovalTool, subject, req.Args, reason) if err != nil { return false, "approval aborted", err } if !reply.allow { return false, i18n.M.SandboxEscapeDeclined, nil } if reply.session { s.c.approval.grantSession(SandboxEscapeApprovalTool, subject) } return true, "", nil } func (s sandboxEscapeApprover) SandboxEscapeSessionAllowed(_ context.Context, req sandbox.EscapeRequest) bool { return s.c.approval.preApprovedForDecision(SandboxEscapeApprovalTool, sandboxEscapeApprovalSubject(req.Command), true) } func sandboxEscapeApprovalSubject(command string) string { subject := strings.TrimSpace(command) if subject == "" { return i18n.M.SandboxEscapeSubjectFallback } return i18n.M.SandboxEscapeSubjectPrefix + subject } func sandboxEscapeApprovalReason(reason string) string { reason = strings.TrimSpace(reason) if reason == "" { return i18n.M.SandboxEscapeRuntimeReason } return reason } // managedConfigWriteApprover routes a file tool's Reasonix-managed config write // through the fresh-human approval prompt (see ManagedConfigWriteApprovalTool). // A session grant is tool-wide (mirroring sandbox_escape): one "allow for this // session" covers the rest of the repair flow across the handful of managed // config files without re-prompting on every incremental edit. type managedConfigWriteApprover struct{ c *Controller } func (m managedConfigWriteApprover) ApproveManagedConfigWrite(ctx context.Context, req tool.ConfigWriteRequest) (bool, string, error) { subject := managedConfigWriteApprovalSubject(req.Path) args, _ := json.Marshal(map[string]string{"path": req.Path}) reply, err := m.c.requestFreshApprovalDecision(ctx, ManagedConfigWriteApprovalTool, subject, args, i18n.M.ConfigWriteReason) if err != nil { return false, "approval aborted", err } if !reply.allow { return false, i18n.M.ConfigWriteDeclined, nil } if reply.session { m.c.approval.grantSession(ManagedConfigWriteApprovalTool, subject) } return true, "", nil } func (m managedConfigWriteApprover) ManagedConfigWriteSessionAllowed(_ context.Context, req tool.ConfigWriteRequest) bool { return m.c.approval.preApprovedForDecision(ManagedConfigWriteApprovalTool, managedConfigWriteApprovalSubject(req.Path), true) } func managedConfigWriteApprovalSubject(path string) string { return i18n.M.ConfigWriteSubjectPrefix + strings.TrimSpace(path) } func (p planModeReadOnlyTrustApprover) CheckPlanModeReadOnlyTrust(ctx context.Context, req agent.PlanModeReadOnlyTrustRequest) (bool, string, error) { if prefix := normalizePlanModeReadOnlyCommandPrefix(req.Prefix); prefix != "" { return p.checkBashReadOnlyCommandTrust(ctx, req, prefix) } server := strings.TrimSpace(req.ServerName) rawTool := strings.TrimSpace(req.RawToolName) if server == "" || rawTool == "" { return false, i18n.M.PlanModeMCPTrustMetadataMissing, nil } subject := fmt.Sprintf(i18n.M.PlanModeMCPTrustSubjectFmt, server, rawTool) reason := i18n.M.PlanModeMCPTrustReason reply, err := p.c.requestFreshApprovalDecision(ctx, req.ToolName, subject, req.Args, reason) if err != nil { return false, "approval aborted", err } if !reply.allow { return false, i18n.M.PlanModeMCPTrustDeclined, nil } if reply.session { p.c.approval.grantSession(req.ToolName, subject) } if reply.persist && p.c.onRememberMCPReadOnlyTrust != nil { p.c.emitMCPReadOnlyTrustResult(p.c.onRememberMCPReadOnlyTrust(server, rawTool)) } return true, "", nil } func (p planModeReadOnlyTrustApprover) checkBashReadOnlyCommandTrust(ctx context.Context, req agent.PlanModeReadOnlyTrustRequest, prefix string) (bool, string, error) { if p.c.approval.planModeReadOnlyCommandTrusted(prefix) { return true, "", nil } command := strings.TrimSpace(req.Command) if command == "" { command = strings.TrimSpace(string(req.Args)) } subject := fmt.Sprintf(i18n.M.PlanModeBashTrustSubjectFmt, prefix, command) reason := i18n.M.PlanModeBashTrustReason reply, err := p.c.requestFreshApprovalDecision(ctx, agent.PlanModeReadOnlyCommandApprovalTool, subject, req.Args, reason) if err != nil { return false, "approval aborted", err } if !reply.allow { return false, i18n.M.PlanModeBashTrustDeclined, nil } if reply.session { p.c.approval.grantPlanModeReadOnlyCommand(prefix) } if reply.persist && p.c.onRememberPlanModeReadOnlyCommand != nil { p.c.emitPlanModeReadOnlyCommandTrustResult(p.c.onRememberPlanModeReadOnlyCommand(prefix)) p.c.approval.grantPlanModeReadOnlyCommand(prefix) } return true, "", nil } func approvalDisplaySubject(tool, subject string, args json.RawMessage) string { switch tool { case memoryRememberTool: return rememberApprovalSubject(subject, args) case memoryForgetTool: return forgetApprovalSubject(subject, args) case "move_file": return moveApprovalSubject(subject, args) default: return subject } } func moveApprovalSubject(fallback string, args json.RawMessage) string { if len(args) == 0 { return fallback } var in struct { SourcePath string `json:"source_path"` DestinationPath string `json:"destination_path"` } if err := json.Unmarshal(args, &in); err != nil { return fallback } if in.SourcePath == "" || in.DestinationPath == "" { return fallback } return in.SourcePath + " -> " + in.DestinationPath } func rememberApprovalSubject(fallback string, args json.RawMessage) string { if len(args) == 0 { return fallback } var in struct { Name string `json:"name"` Title string `json:"title"` Description string `json:"description"` Type string `json:"type"` Body string `json:"body"` } if err := json.Unmarshal(args, &in); err != nil { return fallback } name := approvalCompactText(firstNonEmpty(in.Name, in.Title)) desc := approvalTruncate(approvalCompactText(in.Description), 180) body := approvalTruncate(approvalCompactText(in.Body), 240) typ := string(memory.NormalizeType(in.Type)) var b strings.Builder b.WriteString(i18n.M.MemoryApprovalSaveUpdate) baseLen := b.Len() if name != "" { fmt.Fprintf(&b, " %q", name) } if typ != "" { fmt.Fprintf(&b, " [%s]", typ) } if desc != "" { b.WriteString(": ") b.WriteString(desc) } if body != "" { if desc == "" { b.WriteString(": ") } else { b.WriteString(" | ") } b.WriteString(i18n.M.MemoryApprovalBodyLabel) b.WriteString(": ") b.WriteString(body) } if b.Len() == baseLen && fallback != "" { return fallback } return b.String() } func forgetApprovalSubject(fallback string, args json.RawMessage) string { if len(args) == 0 { return fallback } var in struct { Name string `json:"name"` } if err := json.Unmarshal(args, &in); err != nil { return fallback } name := approvalCompactText(in.Name) if name == "" { return fallback } return fmt.Sprintf(i18n.M.MemoryApprovalArchiveFmt, name) } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" } func approvalCompactText(s string) string { return strings.Join(strings.Fields(s), " ") } func approvalTruncate(s string, maxRunes int) string { if maxRunes <= 0 { return "" } runes := []rune(s) if len(runes) <= maxRunes { return s } return string(runes[:maxRunes]) + "..." } type seedTodo struct { Content string `json:"content"` Status string `json:"status"` Level int `json:"level,omitempty"` } // seedPlanTodos turns an approved plan into a starter task list and emits it as a // synthetic todo_write event, so the live task panel populates the instant the // user approves — a structural guarantee, not a prompt the model might ignore. // The model still flips item status as it works (only it knows its own // progress); this just makes the list exist. No-op when the plan has no list. func (c *Controller) seedPlanTodos(plan string) string { args := PlanTodosJSON(plan) if args == "" { return "" } t := event.Tool{ID: "plan-seed", Name: "todo_write", Args: args, ReadOnly: true} c.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: t}) t.Output = "task list seeded from the approved plan" c.sink.Emit(event.Event{Kind: event.ToolResult, Tool: t}) c.seedAgentTodoState(args) return args } func (c *Controller) seedAgentTodoState(args string) { if c.executor == nil { return } todos := agentTodoStateFromArgs(args) if len(todos) == 0 { return } c.executor.SeedTodoState(todos) } func (c *Controller) completePlanTodos(args string) { if args == "" { return } done := completedPlanTodosJSON(args) if done == "" { return } t := event.Tool{ID: "plan-seed", Name: "todo_write", Args: done, ReadOnly: true} c.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: t}) t.Output = "approved plan finished" c.sink.Emit(event.Event{Kind: event.ToolResult, Tool: t}) c.replaceAgentTodoState(done) } func (c *Controller) replaceAgentTodoState(args string) { if c.executor == nil { return } todos := agentTodoStateFromArgs(args) if len(todos) == 0 { return } c.executor.ReplaceTodoState(todos) } func agentTodoStateFromArgs(args string) []evidence.TodoItem { var payload struct { Todos []evidence.TodoItem `json:"todos"` } if err := json.Unmarshal([]byte(args), &payload); err != nil { return nil } return payload.Todos } // PlanTodosJSON parses an approved plan's markdown into todo_write-shaped args // JSON ({"todos":[...]}), or "" when the plan has no list items. The exit_plan_mode // path seeds via seedPlanTodos (an event); a frontend whose own approval flow // bypasses exit_plan_mode (the chat TUI's text-plan approval) calls this directly // to render the same starter checklist. Shared parsing keeps the two consistent. func PlanTodosJSON(plan string) string { items := parsePlanTodos(plan) if len(items) == 0 { return "" } b, err := json.Marshal(map[string]any{"todos": items}) if err != nil { return "" } return string(b) } func completedPlanTodosJSON(args string) string { var p struct { Todos []seedTodo `json:"todos"` } if err := json.Unmarshal([]byte(args), &p); err != nil || len(p.Todos) == 0 { return "" } for i := range p.Todos { p.Todos[i].Status = "completed" } b, err := json.Marshal(map[string]any{"todos": p.Todos}) if err != nil { return "" } return string(b) } // parsePlanTodos extracts a starter task list from an approved plan's markdown // list items (bulleted or numbered): the first is in_progress, the rest pending, // capped so a long plan can't flood the panel. It understands ONLY markdown lists // — an unambiguous, standard structure — and deliberately does not guess at prose, // tables, or arrow sequences (those need brittle, language-specific heuristics). // The plan-mode marker steers the model to present its plan as a list, so this // catches the normal case; anything it misses is covered by the model's own // todo_write calls as it executes. func parsePlanTodos(plan string) []seedTodo { var todos []seedTodo for _, raw := range strings.Split(plan, "\n") { item, level, ok := listItem(raw) if !ok { continue } status := "pending" if len(todos) == 0 { status = "in_progress" } todos = append(todos, seedTodo{Content: item, Status: status, Level: level}) if len(todos) >= 20 { break } } return todos } func (c *Controller) sessionMessageCount() int { if c.executor == nil { return 0 } return c.executor.Session().Len() } // hasTodoUpdateSince reports whether the model emitted its own todo_write after // index start, so the seeded plan todos aren't auto-completed over the model's // own bookkeeping. func (c *Controller) hasTodoUpdateSince(start int) bool { if c.executor == nil { return false } msgs := c.executor.Session().Messages if start < 0 || start > len(msgs) { start = len(msgs) } _, ok := latestTodoArgsSince(msgs, start) return ok } func latestTodoArgsSince(msgs []provider.Message, start int) (string, bool) { for i := len(msgs) - 1; i >= start; i-- { for j := len(msgs[i].ToolCalls) - 1; j >= 0; j-- { tc := msgs[i].ToolCalls[j] if tc.Name == "todo_write" { return tc.Arguments, true } } } return "", false } // listItem parses a markdown list line ("- x", "* x", "1. x", "2) x") into its // task text and a nesting level derived from leading indentation (0 for a // top-level item, 1 for an indented sub-step — capped at 1 since the plan is // two-level). ok is false when the line isn't a list item. Light inline-markdown // stripping keeps the checklist readable. func listItem(line string) (content string, level int, ok bool) { trimmed := strings.TrimLeft(line, " \t") if trimmed == "" { return "", 0, false } indent := 0 for _, c := range line[:len(line)-len(trimmed)] { if c == '\t' { indent += 4 } else { indent++ } } s := trimmed // A numbered markdown heading ("### 1. Add the loader") is how models often // write a phase even when asked for a list; strip the heading marker and // treat it as a top-level phase. A heading without a number (a section // title like "## Plan") falls through and is ignored. heading := false if h := strings.TrimLeft(s, "#"); h != s && strings.HasPrefix(h, " ") { heading = true s = strings.TrimSpace(h) } switch { case strings.HasPrefix(s, "- "), strings.HasPrefix(s, "* "), strings.HasPrefix(s, "+ "): s = s[2:] default: // numbered: leading digits, then "." or ")", then a space i := 0 for i < len(s) && s[i] >= '0' && s[i] <= '9' { i++ } if i == 0 || i+1 >= len(s) || (s[i] != '.' && s[i] != ')') || s[i+1] != ' ' { return "", 0, false } s = s[i+2:] } s = strings.TrimSpace(s) s = strings.TrimPrefix(s, "[ ] ") s = strings.TrimPrefix(s, "[x] ") s = strings.ReplaceAll(s, "`", "") s = strings.ReplaceAll(s, "**", "") s = strings.TrimSpace(s) if s == "" { return "", 0, false } if heading { return s, 0, true // a heading is always a top-level phase } if indent >= 2 { return s, 1, true } return s, 0, true } // parseRewind parses the arguments after "/rewind". The user may provide: // // /rewind → latest checkpoint, both // /rewind → that turn, both // /rewind → that turn, code|conversation|both // // If no turn is given, the latest checkpoint is used. If no scope is given, Both is assumed. func parseRewind(args string, cps []checkpoint.Meta) (int, RewindScope, error) { fields := strings.Fields(args) if len(fields) == 0 { if len(cps) == 0 { return 0, RewindBoth, fmt.Errorf("no checkpoints available") } return cps[len(cps)-1].Turn, RewindBoth, nil } turn, err := strconv.Atoi(fields[0]) if err != nil { return 0, RewindBoth, fmt.Errorf("invalid turn: %w", err) } scope := RewindBoth if len(fields) >= 2 { switch strings.ToLower(fields[1]) { case "code": scope = RewindCode case "conversation": scope = RewindConversation case "both": scope = RewindBoth default: return 0, RewindBoth, fmt.Errorf("unknown scope %q", fields[1]) } } return turn, scope, nil } // requestApproval emits an ApprovalRequest and blocks until Approve(ID, …) // answers or ctx is cancelled. A prior session grant (or a bypass posture) for // the same approval scope short-circuits. The approvalManager's promptMu // serialises outstanding prompts; this method keeps the I/O (events, hooks, // remember) that the manager deliberately stays out of. func (c *Controller) requestApproval(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, error) { return c.requestApprovalWithReason(ctx, tool, subject, args, "") } func (c *Controller) requestApprovalWithReason(ctx context.Context, tool, subject string, args json.RawMessage, reason string) (bool, bool, error) { r, err := c.requestApprovalDecision(ctx, tool, subject, args, reason) if err != nil { return false, false, err } // Plan approvals are one-shot — never persist a session grant for them, or // every future plan would auto-approve. if r.allow && r.session && !requiresFreshApprovalTool(tool) { c.approval.grantSession(tool, subject) } if r.allow && r.persist && !requiresFreshApprovalTool(tool) && c.onRemember != nil { c.emitRememberResult(c.onRemember(permission.RememberRuleForScope(tool, subject))) } return r.allow, false, nil } func (c *Controller) requestApprovalDecision(ctx context.Context, tool, subject string, args json.RawMessage, reason string) (approvalReply, error) { return c.requestApprovalDecisionWithOptions(ctx, tool, subject, args, reason, approvalDecisionOptions{}) } func (c *Controller) requestFreshApprovalDecision(ctx context.Context, tool, subject string, args json.RawMessage, reason string) (approvalReply, error) { return c.requestApprovalDecisionWithOptions(ctx, tool, subject, args, reason, approvalDecisionOptions{fresh: true}) } type approvalDecisionOptions struct { // fresh marks a user trust/business decision rather than an ordinary tool // permission. It may reuse an explicit session grant, but YOLO/auto approval // must not answer or drain the prompt. fresh bool } func (c *Controller) requestApprovalDecisionWithOptions(ctx context.Context, tool, subject string, args json.RawMessage, reason string, opts approvalDecisionOptions) (approvalReply, error) { // YOLO/full access and the just-approved-plan execution window auto-allow // approval-gated tools without prompting. Plan approval is a user decision, // not a tool permission, so it deliberately stays interactive. if c.approval.preApprovedForDecision(tool, subject, opts.fresh) { return approvalReply{allow: true}, nil } c.approval.promptMu.Lock() defer c.approval.promptMu.Unlock() // Re-check: a session grant may have landed while we queued behind another // prompt for the same subject. if c.approval.preApprovedForDecision(tool, subject, opts.fresh) { return approvalReply{allow: true}, nil } var id string var reply chan approvalReply if opts.fresh { id, reply = c.approval.registerDecision(tool, subject, reason, true) } else { id, reply = c.approval.register(tool, subject, reason) } c.sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: id, Tool: tool, Subject: subject, Reason: reason}}) if hookSubject, hookArgs, ok := permissionRequestHookPayload(tool, subject, args); ok { go c.hooks.PermissionRequest(ctx, tool, hookSubject, hookArgs) } // The agent now needs the user's attention; a Notification hook can ping an // external channel (desktop notice, phone) while the run blocks on the reply. go c.hooks.Notification(ctx, approvalNotificationText(tool, subject)) waitCtx, cancelWait := c.approval.waitContext(ctx) defer cancelWait() select { case r := <-reply: return r, nil case <-waitCtx.Done(): c.approval.cancel(id) return approvalReply{}, waitCtx.Err() } } func (c *Controller) emitRememberResult(r RememberResult) { if r.Err != nil { c.sink.Emit(event.Event{ Kind: event.Notice, Level: event.LevelWarn, Text: fmt.Sprintf(i18n.M.PermissionSaveFailedFmt, r.Rule, r.Err), }) return } switch { case r.Saved: c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PermissionSavedFmt, r.Path, r.Rule)}) case strings.TrimSpace(r.CoveredBy) != "": c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PermissionAlreadyAllowedFmt, r.Path, r.CoveredBy)}) } } func (c *Controller) emitMCPReadOnlyTrustResult(r MCPReadOnlyTrustResult) { server := strings.TrimSpace(r.Server) toolName := strings.TrimSpace(r.Tool) if r.Err != nil { c.sink.Emit(event.Event{ Kind: event.Notice, Level: event.LevelWarn, Text: fmt.Sprintf(i18n.M.MCPReadOnlyTrustFailedFmt, server, toolName, r.Err), }) return } switch { case r.Saved: c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.MCPReadOnlyTrustSavedFmt, r.Path, server, toolName)}) case strings.TrimSpace(r.CoveredBy) != "": c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.MCPReadOnlyTrustAlreadyFmt, r.Path, server, r.CoveredBy)}) } } func (c *Controller) emitPlanModeReadOnlyCommandTrustResult(r PlanModeReadOnlyCommandTrustResult) { prefix := strings.TrimSpace(r.Prefix) if r.Err != nil { c.sink.Emit(event.Event{ Kind: event.Notice, Level: event.LevelWarn, Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustFailedFmt, prefix, r.Err), }) return } switch { case r.Saved: c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustSavedFmt, r.Path, prefix)}) case strings.TrimSpace(r.CoveredBy) != "": c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustAlreadyFmt, r.Path, r.CoveredBy)}) } } // detectProjectModules scans the workspace root for top-level source directories // to enable module-aware task routing in /plan-exec. func (c *Controller) detectProjectModules() []string { root := c.sessionDir for i := 0; i < 3 && root != ""; i++ { if hasFile(root, "go.mod") || hasFile(root, "package.json") || hasFile(root, ".git") { return listSourceDirs(root, 2) } root = filepath.Dir(root) if root == filepath.Dir(root) { break } } return nil } func hasFile(dir, name string) bool { _, err := os.Stat(filepath.Join(dir, name)) return err == nil } func listSourceDirs(root string, maxDepth int) []string { skip := map[string]bool{ ".git": true, ".github": true, "node_modules": true, "vendor": true, ".reasonix": true, "desktop": true, "dist": true, "build": true, ".cache": true, "bin": true, } var dirs []string walkDir(root, "", skip, maxDepth, &dirs) return dirs } func walkDir(root, rel string, skip map[string]bool, depth int, out *[]string) { if depth <= 0 { return } dir := root if rel != "" { dir = filepath.Join(root, rel) } entries, err := os.ReadDir(dir) if err != nil { return } for _, e := range entries { name := e.Name() if !e.IsDir() || skip[name] || strings.HasPrefix(name, ".") { continue } childRel := name if rel != "" { childRel = rel + "/" + name } if hasSourceFiles(filepath.Join(root, childRel)) { *out = append(*out, childRel) } walkDir(root, childRel, skip, depth-1, out) } } func hasSourceFiles(dir string) bool { entries, err := os.ReadDir(dir) if err != nil { return false } for _, e := range entries { if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") { return true } } return false }