返回 DeepSeek-Reasonix
controller.go
根目录 / internal / control / controller.go
1 // Package control is the transport-agnostic session driver. A Controller owns
2 // the agent run loop and session lifecycle, takes commands (Send/Cancel/Approve/
3 // SetPlanMode/Compact/NewSession/…), and emits everything that happens —
4 // reasoning, tool calls, approvals, turn completion — as a typed event stream to
5 // a single event.Sink.
6 //
7 // The point is one orchestration layer behind every frontend: a terminal TUI, a
8 // desktop webview, or an HTTP/SSE server each drive the Controller identically
9 // (issue commands, render events) and none of them re-implement turn lifecycle,
10 // cancellation, or approval. The Controller depends on no frontend.
11 package control
12
13 import (
14 "context"
15 "encoding/json"
16 "errors"
17 "fmt"
18 "log/slog"
19 "net/http"
20 "os"
21 "path/filepath"
22 "sort"
23 "strconv"
24 "strings"
25 "sync"
26 "sync/atomic"
27 "time"
28
29 "reasonix/internal/ablation"
30 "reasonix/internal/agent"
31 "reasonix/internal/autoresearch"
32 "reasonix/internal/billing"
33 "reasonix/internal/capability"
34 "reasonix/internal/checkpoint"
35 "reasonix/internal/command"
36 "reasonix/internal/config"
37 "reasonix/internal/event"
38 "reasonix/internal/evidence"
39 "reasonix/internal/extension"
40 "reasonix/internal/extension/dispatch"
41 "reasonix/internal/extension/uihub"
42 "reasonix/internal/goaleval"
43 "reasonix/internal/guardian"
44 "reasonix/internal/hook"
45 "reasonix/internal/i18n"
46 "reasonix/internal/jobs"
47 "reasonix/internal/memory"
48 "reasonix/internal/nilutil"
49 "reasonix/internal/permission"
50 "reasonix/internal/plugin"
51 "reasonix/internal/provider"
52 "reasonix/internal/recovery"
53 "reasonix/internal/sandbox"
54 "reasonix/internal/sessiontemp"
55 "reasonix/internal/shellrun"
56 "reasonix/internal/skill"
57 "reasonix/internal/store"
58 "reasonix/internal/taskmonitor"
59 "reasonix/internal/tool"
60 "reasonix/internal/workspacelease"
61 )
62
63 // ErrTurnRunning reports that a caller tried to start a second foreground turn
64 // while one is already active in the same Controller.
65 var ErrTurnRunning = errors.New("turn already running")
66
67 // errTurnRunningRotation and errRotationInProgress are returned by the
68 // session-rotation gate (beginRotation) when a rotation cannot proceed: a turn
69 // is in flight, or another rotation already holds the gate.
70 var (
71 errTurnRunningRotation = errors.New("cannot start a new session while a turn is running")
72 errRotationInProgress = errors.New("cannot start a new session while another session change is in progress")
73 )
74
75 // errNoSessionPath is returned by snapshot when a session has content to persist
76 // but no resolved session path — a misconfiguration (e.g. an unresolvable data
77 // dir in a bot deployment) that previously dropped conversations silently
78 // (#4414). Callers log it and continue; it must never be swallowed quietly.
79 var errNoSessionPath = errors.New("session has content but no session path; conversation cannot be persisted")
80
81 // Controller drives one chat session. Construct with New; drive with the command
82 // methods; observe through the Sink passed in Options.
83 type Controller struct {
84 runner agent.Runner
85 executor *agent.Agent
86 guardianSess *guardian.Session // nil when guardian is disabled
87 guardianPath string // persisted guardian session file ("" when disabled)
88 // recoveryGate is the shared Auto Guard state for this controller.
89 // nil when the feature is not wired for this controller.
90 recoveryGate *recovery.Gate
91 // evaluator is the bounded Goal completion evaluator consulted when the
92 // working model submits no update_goal report. nil fails closed: the goal
93 // pauses instead of defaulting to continue.
94 evaluator goaleval.Evaluator
95 // goalUsageTee accounts billable usage events into the active goal turn's
96 // observational token total. It wraps the public sink when the caller didn't provide one.
97 goalUsageTee *goalUsageTee
98 sink event.Sink
99 policy permission.Policy
100 // subagentGate is the shared gate every headless-only sub-agent surface
101 // reads from (see Options.SubagentGate). Nil when the caller didn't build
102 // one — sub-agents then keep whatever gate they were constructed with.
103 subagentGate *SharedHeadlessGate
104
105 label string
106 modelRef string
107 systemPrompt string
108 sessionDir string
109 commands atomic.Pointer[[]command.Command]
110 // skills owns the session's discovered skills (enabled subset, full set, and
111 // the reloadable stores) — the skills slice of the Capabilities concern. See
112 // skill.go.
113 skills skillSet
114 skillRunner skill.SubagentRunner
115 readOnlySkillRunner skill.SubagentRunner
116 skillProfile skill.ProfileResolver
117 slashSkillSeq atomic.Uint64
118 hooks *hook.Runner // session hook runner; nil-safe (no hooks configured)
119 // hookContexts carries one-shot lifecycle hook context into the next real
120 // user turn without changing the cache-stable system prompt.
121 hookContexts []string
122 // memory owns the loaded memory snapshot, the pending turn-tail notes queue,
123 // and write serialization behind its own locks, off c.mu — so a memory-panel
124 // save never stalls an approval or status poll. See memory.go.
125 memory memoryManager
126 cleanup func()
127 responseLanguage string
128 reasoningLanguage string
129 // disableColdResumePrune skips stale-tool-result elision on cold resume.
130 // Zero value keeps the prune on (the cheaper default).
131 disableColdResumePrune bool
132 // testCacheColdAfter overrides cacheColdAfter() in tests. Zero uses the
133 // vendor-aware resolution from config.
134 testCacheColdAfter time.Duration
135
136 shell sandbox.Shell // interpreter for user-invoked "!" commands; zero = auto
137 startedOnce bool // guards the one-shot SessionStart hook on first turn
138 closeOnce sync.Once // makes close idempotent under racing teardown paths
139 onRemember func(rule string) RememberResult // set via Options; invoked when user picks "always allow"
140 onRememberPlanModeReadOnlyCommand func(prefix string) PlanModeReadOnlyCommandTrustResult
141 sessionRecoveryMeta func(SessionRecoveryRequest) agent.BranchMeta
142 onSessionRecovered func(SessionRecoveryInfo) error
143
144 // balanceURL/balanceKey target the active provider's optional wallet-balance
145 // endpoint (empty when the provider declares none). Captured at build so a
146 // model/key switch — which rebuilds the controller — refreshes them.
147 balanceURL string
148 balanceKey string
149 balanceClient *http.Client
150
151 // jobs is the session-scoped background-job manager. The agent's background
152 // tools spawn into it; Compose drains its completion notes into the next turn;
153 // Close cancels its still-running jobs.
154 jobs *jobs.Manager
155 // workspaceLease is the Delivery writer owner shared with the executor.
156 // It is exposed only through a sanitized state snapshot for Desktop recovery.
157 workspaceLease *workspacelease.Owner
158
159 // mcp owns the session's live tool/plugin surface — the MCP plugin Host, the
160 // tool registry the executor reads each turn, and the session-scoped context a
161 // hot-added stdio server binds its subprocess to — behind its own lock, off
162 // c.mu. The Controller keeps the config-facing orchestration (persisting
163 // MCP entries to their global/project source on add/remove, building specs
164 // from entries). See mcp.go.
165 mcp mcpManager
166 mcpDefaultCallTimeout time.Duration
167 mcpConfigureSpec func(*plugin.Spec)
168 capabilityRuntime *agent.MCPCapabilityRuntime
169
170 // extensions is the frozen extension dispatcher for this controller
171 // generation, or nil when no v1 runtime packages are installed (the
172 // universal pre-dispatch fast path). It is installed before the controller
173 // starts serving (Options.Extensions or SetExtensions) and never swapped
174 // afterwards, so wiring points read it without locking.
175 extensions *dispatch.Dispatcher
176 // extensionUI is the host extension UI hub for this controller generation
177 // (stage 8a), or nil when no v1 runtime packages started. Installed via
178 // SetExtensionUI before serving and never swapped; readers take c.mu.
179 extensionUI *uihub.Hub
180 // providerResolver is the build's merged provider catalog (extension
181 // sidecar providers over the config/broker base), or nil when no sidecar
182 // declared providers. Immutable after New; ProviderCatalog reads it.
183 providerResolver provider.Resolver
184
185 // Capability routing (Delivery hybrid route + dual-model Planner proxy).
186 // Not part of the provider-visible prefix; only seeds the turn-scoped ledger
187 // and optional semantic router.
188 pluginCfg []config.PluginEntry
189 capCachedTools map[string][]plugin.CachedTool
190 capCacheKeyOK map[string]bool
191 semanticRouter *capability.SemanticRouter
192 capabilityAudit *capability.Audit
193 // capabilityProxy directs unready MCP candidates to use_capability in the
194 // transient route block (Delivery and dual-model Planner).
195 capabilityProxy bool
196 // proxyToolsFn returns live tools observed through use_capability without
197 // entering the provider-visible registry (Balanced dual-model Planner).
198 proxyToolsFn func() map[string][]plugin.CachedTool
199 runtimeProfile capability.Profile
200 ablation ablation.Set
201
202 // goals owns the active goal's FSM (status, intercepts, idle/turn counters)
203 // and its persistence, behind its own mutex so a per-turn goal save never
204 // stalls an approval or status poll on c.mu. See goal.go.
205 goals goalMachine
206 autoResearch *autoresearch.Store
207
208 // workspaceRoot is the workspace root: the base for resolving @-refs and slash
209 // path refs, the working directory for user "!" shell commands and custom
210 // command discovery, and the guard root for checkpoint restore writes. It is
211 // surfaced to frontends via WorkspaceRoot().
212 workspaceRoot string
213
214 // externalFolderRefs maps session-generated @ tokens to user-dropped
215 // directories outside workspaceRoot. It is intentionally per-controller:
216 // dragging a folder authorizes that folder for this chat session only, without
217 // widening scoped @ resolution to arbitrary absolute paths.
218 externalFolderRefsMu sync.RWMutex
219 externalFolderRefs map[string]string
220 externalFolderToolRefs externalFolderToolRefs
221
222 // checkpoints owns the snapshot-based rewind bookkeeping (the per-session
223 // store, the monotonic turn counter, and the conversation-rewind boundary map)
224 // behind its own lock, off c.mu — so a boundary read for a rewind/fork never
225 // contends on the run-state lock. The Controller keeps the rewind/fork/summarize
226 // orchestration (truncating the session, restoring code, emitting events). See
227 // checkpoint.go.
228 checkpoints checkpointManager
229 // mutationObserver is the host-side file mutation observer for v2 checkpoints.
230 mutationObserver *checkpoint.MutationObserver
231 // sessionRevision increments on successful rewind/undo and is used as a
232 // prepare/commit freshness token.
233 sessionRevision int64
234
235 // approval owns the approval/ask prompt bookkeeping and the runtime approval
236 // posture (ask/auto/yolo, session grants, the just-approved-plan window)
237 // behind its own locks, off c.mu. The Controller keeps the I/O orchestration
238 // (requestApproval/Ask emit events + fire hooks + rebuild the executor gate).
239 // See approval.go.
240 approval approvalManager
241
242 // mu guards the run state; every critical section under it is short and
243 // non-blocking.
244 mu sync.Mutex
245 cancel context.CancelFunc
246 running bool
247 finishing bool // TurnDone is still being delivered; park a replacement turn
248 canceling bool
249 // closed marks the controller as terminally torn down (close() ran). It
250 // seals turn admission: without it, a submit arriving AFTER close cleared
251 // the parked queue — but while a still-running turn's TurnDone delivery
252 // was in flight — would park again and then start against freed resources
253 // when the window closed.
254 closed bool
255 // parkedTurns holds turn bodies that arrived during the finishing window,
256 // FIFO. finishGuardedTurn starts the oldest one as it closes the window
257 // (see runGuarded/finishGuardedTurn); close() discards any remainder.
258 parkedTurns []func(ctx context.Context) error
259 // rotating is set under mu while NewSession/ClearSession swap the executor
260 // session out. Checking running once and then swapping later leaves a
261 // TOCTOU window: a turn can start (running=false at check time) during the
262 // intervening Snapshot() and then have its live session replaced. running
263 // and rotating are mutually exclusive gates — a turn refuses to start while
264 // a rotation is in progress, and a rotation refuses to start while a turn
265 // runs — so the run loop's session reference cannot change under it.
266 rotating bool
267 autosaveWG sync.WaitGroup
268 planMode bool
269 sessionPath string
270 // sessionTemp owns the logical-session private temporary directory shared
271 // by Bash calls. Retained for this Controller's lifetime; rotated on
272 // /new, /clear, resume of another session, and branch switches.
273 sessionTemp *sessiontemp.Manager
274 // recoveryDepthCapNotices records session paths that already surfaced the
275 // depth-cap recovery warning. Repeated saves on the same conflict copy are
276 // diagnostic noise for the UI; keep logging/diagnostics, but emit the user
277 // notice once per controller/session path.
278 recoveryDepthCapNotices map[string]bool
279 // snapshotMu serializes the whole save/recovery handoff for this controller.
280 // Agent-level path locks protect individual files, but recovery also moves
281 // controller-owned state (sessionPath, guardianPath, checkpoints, rewrite
282 // baseline). Letting a second snapshot observe that migration halfway through
283 // can turn one conflict into a recovery cascade. Session/path swaps
284 // (new/clear/fork/branch/switch/resume/SetSessionPath) hold it for the same
285 // reason: a save that reads the old path but the new session would write one
286 // transcript's messages into another's file, or manufacture a bogus conflict.
287 // Not reentrant — never call snapshot (or anything that snapshots, such as
288 // recoverInterruptedTurn or maybeColdResumePrune) while holding it.
289 snapshotMu sync.Mutex
290 // turn counts model turns this session, passed to hooks in their payload.
291 turn int
292
293 displayRecorder func(content, display string)
294 }
295
296 type approvalReply struct {
297 allow bool
298 session bool
299 persist bool // true = write "always allow" rule to config
300 }
301
302 type pendingApproval struct {
303 id string
304 tool string
305 subject string
306 reason string
307 rawInput json.RawMessage
308 fresh bool
309 requireHuman bool
310 autoDrain bool
311 kind string // tool | plan | recovery; empty = tool
312 recovery *event.RecoveryApproval
313 reply chan approvalReply
314 }
315
316 // pendingAsk is an in-flight ask question batch. questions is retained so the
317 // AskRequest can be re-emitted to a frontend that reconnected after the original
318 // event (see ReplayPendingPrompts).
319 type pendingAsk struct {
320 questions []event.AskQuestion
321 reply chan []event.AskAnswer
322 }
323
324 type AutoResearchEvidenceInput struct {
325 ID string
326 Kind string
327 Summary string
328 Source string
329 Command string
330 Paths []string
331 Accepted bool
332 }
333
334 type plannerSessionResetter interface {
335 ResetPlannerSession()
336 }
337
338 // RuntimeStatus is the frontend-facing snapshot of foreground turn state. It is
339 // intentionally more explicit than the legacy Running bool so UI code can
340 // distinguish a cancellable foreground turn from pending prompts and background
341 // jobs.
342 type RuntimeStatus struct {
343 Running bool
344 PendingPrompt bool
345 BackgroundJobs int
346 CancelRequested bool
347 Cancellable bool
348 }
349
350 const (
351 ToolApprovalAsk = "ask"
352 ToolApprovalAuto = "auto"
353 ToolApprovalDontAsk = "dontAsk"
354 ToolApprovalYolo = "yolo"
355 )
356
357 const (
358 memoryRememberTool = "remember"
359 memoryForgetTool = "forget"
360 )
361
362 // RememberResult describes what happened when an approval rule was persisted.
363 type RememberResult struct {
364 Rule string
365 Path string
366 Saved bool
367 CoveredBy string
368 Err error
369 }
370
371 // PlanModeReadOnlyCommandTrustResult describes what happened when a trusted bash
372 // command prefix was persisted for plan-mode research.
373 type PlanModeReadOnlyCommandTrustResult struct {
374 Prefix string
375 Path string
376 Saved bool
377 CoveredBy string
378 Err error
379 }
380
381 type SessionRecoveryRequest struct {
382 OriginalPath string
383 Reason string
384 Mode string
385 }
386
387 type SessionRecoveryInfo struct {
388 OriginalPath string
389 RecoveryPath string
390 Existing bool
391 Reason string
392 Meta agent.BranchMeta
393 }
394
395 type externalFolderToolRefs interface {
396 RegisterReadRoot(token, root string)
397 }
398
399 // Options carries the already-built pieces setup assembles. Lifecycle metadata
400 // lets the controller mint and rotate session files; Host/Commands are surfaced
401 // to frontends that resolve MCP prompts and slash commands.
402 type Options struct {
403 Runner agent.Runner
404 Executor *agent.Agent
405 Guardian *guardian.Session
406 // RecoveryReviewer is the optional independent recovery reviewer (nil =
407 // rule-only path with fail-closed human confirmation for ambiguous cases).
408 RecoveryReviewer recovery.Reviewer
409 // RecoveryHeadless blocks mutations that need confirmation instead of
410 // waiting forever when no human decision channel exists.
411 RecoveryHeadless bool
412 // GoalEvaluator is the optional bounded Goal completion evaluator consulted
413 // when the working model submits no update_goal report. nil fails closed:
414 // the goal pauses instead of defaulting to continue.
415 GoalEvaluator goaleval.Evaluator
416 Sink event.Sink
417 Policy permission.Policy
418 // SubagentGate is the shared, mutable gate every headless-only sub-agent
419 // surface (task, writer-capable skill sub-agents, planner) reads from. Nil
420 // disables gating for those surfaces same as before this field existed.
421 // SetToolApprovalMode and ApplyHeadlessApprovalMode call Update on it so a
422 // runtime approval-mode switch reaches sub-agents, not just the parent
423 // executor's own gate.
424 SubagentGate *SharedHeadlessGate
425 Label string
426 ModelRef string
427 SystemPrompt string
428 SessionDir string
429 SessionPath string
430 Host *plugin.Host
431 Commands []command.Command
432 Skills []skill.Skill
433 AllSkills []skill.Skill
434 SkillStore *skill.Store
435 AllSkillStore *skill.Store
436 // SkillRunner executes a runAs=subagent skill in an isolated child loop.
437 // ReadOnlySkillRunner is reserved for explicitly read-only entry points;
438 // Plan itself is a workflow instruction and uses SkillRunner with the shared
439 // Permissions/Sandbox gate. SkillProfile supplies model/effort display
440 // metadata for the synthetic top-level run_skill event.
441 SkillRunner skill.SubagentRunner
442 ReadOnlySkillRunner skill.SubagentRunner
443 SkillProfile skill.ProfileResolver
444 Hooks *hook.Runner
445 Memory *memory.Set
446 Cleanup func()
447 // BalanceURL/BalanceKey wire the active provider's optional wallet-balance
448 // endpoint and bearer key; empty when the provider declares no balance_url.
449 BalanceURL string
450 BalanceKey string
451 BalanceClient *http.Client
452 // Jobs is the session-scoped background-job manager (nil disables background jobs).
453 Jobs *jobs.Manager
454 // WorkspaceLease is the Delivery writer owner shared with the executor.
455 WorkspaceLease *workspacelease.Owner
456 // Registry is the executor's live tool set, and PluginCtx the session-scoped
457 // context; both are needed for hot-adding MCP servers via AddMCPServer.
458 Registry *tool.Registry
459 PluginCtx context.Context
460 // MCPDefaultCallTimeout is the global MCP call cap used by hot-connected
461 // servers when they do not declare a server- or tool-specific override.
462 MCPDefaultCallTimeout time.Duration
463 // MCPConfigureSpec injects host-local launch and isolation policy into every
464 // hot-connected server without persisting that state in project config.
465 MCPConfigureSpec func(*plugin.Spec)
466 // CapabilityRuntime is the controller-local authoritative MCP inventory used
467 // by stable use_capability frontends. It shares Host processes with sibling
468 // tabs but never shares their enabled/disabled state.
469 CapabilityRuntime *agent.MCPCapabilityRuntime
470 // WorkspaceRoot is the project root checkpoint restores are confined to ("" =
471 // no confinement). Frontends pass the cwd they launched the session in.
472 WorkspaceRoot string
473 ExternalFolderToolRefs externalFolderToolRefs
474 // ResponseLanguage controls final-answer language preference. Empty/auto
475 // means no transient injection because the stable language policy follows the
476 // current user turn.
477 ResponseLanguage string
478 // ReasoningLanguage controls visible reasoning language preference. Empty/auto
479 // means no transient injection because the stable language policy already
480 // follows the conversation language.
481 ReasoningLanguage string
482 // DisableColdResumePrune skips the stale-tool-result elision that otherwise
483 // runs when a session resumes past the provider cache window. Zero value
484 // keeps the prune on (the cheaper default).
485 DisableColdResumePrune bool
486 // Shell is the interpreter user-invoked "!" commands run under, so /shell
487 // matches the agent's configured [tools.shell] choice. Zero value = auto.
488 Shell sandbox.Shell
489 // OnRemember, when set, is invoked with a new allow rule the user chose to
490 // persist to disk (e.g. "Bash(go test:*)"). The callback is wired into the
491 // permission Gate on EnableInteractiveApproval.
492 OnRemember func(rule string) RememberResult
493 // OnRememberPlanModeReadOnlyCommand persists a bash command prefix as trusted
494 // read-only when the user chooses "always allow" from the plan-mode trust
495 // prompt.
496 OnRememberPlanModeReadOnlyCommand func(prefix string) PlanModeReadOnlyCommandTrustResult
497 // SessionRecoveryMeta lets a frontend attach scope/topic/profile metadata to
498 // an automatic recovery branch before it is written.
499 SessionRecoveryMeta func(SessionRecoveryRequest) agent.BranchMeta
500 // OnSessionRecovered is called after a stale runtime's transcript has been
501 // saved as a recovery branch, before the controller commits to that branch.
502 OnSessionRecovered func(SessionRecoveryInfo) error
503 // ApprovalTimeout bounds how long a tool-approval or ask prompt blocks waiting
504 // for a user decision. Zero (default) waits forever — right for an interactive
505 // terminal. Bot/headless frontends set a positive value so an unanswered
506 // prompt can't wedge the session indefinitely (#4626, #4402).
507 ApprovalTimeout time.Duration
508 // RuntimeProfile selects capability routing/filtering behavior. Empty keeps
509 // the backward-compatible Balanced profile.
510 RuntimeProfile capability.Profile
511 // Extensions is the frozen extension dispatcher for this controller
512 // generation (Extension Protocol v1, stage 6b1). Nil means no v1 runtime
513 // packages are installed: every extension wiring point takes an untouched
514 // fast path. Boot installs it through SetExtensions because sidecars (and
515 // therefore the dispatcher) only exist after snapshot assembly, which runs
516 // after New.
517 Extensions *dispatch.Dispatcher
518 // ProviderResolver is the build's merged provider catalog — extension
519 // sidecar providers folded over the config/broker base (stage 7). Nil when
520 // no v1 runtime sidecar declared providers; ProviderCatalog then returns
521 // nil and frontends enumerate providers from config alone, as before.
522 ProviderResolver provider.Resolver
523 // Ablation switches subsystems off for a benchmark arm. The zero value runs
524 // everything.
525 Ablation ablation.Set
526 // SessionTemp is the logical-session private temporary directory manager
527 // shared by sandboxed Bash calls. Nil creates a fresh Manager owned by this
528 // Controller. Hot rebuilds pass the previous Controller's Manager so the
529 // temporary directory survives model/settings swaps.
530 SessionTemp *sessiontemp.Manager
531 }
532
533 // New builds a Controller. A nil Sink is replaced with event.Discard. When the
534 // caller did not already provide a goalUsageTee (see NewGoalUsageTee), the
535 // public sink is wrapped in one so billable usage can be accounted to Goal
536 // budgets; frontends observe the same forwarded stream either way.
537 func New(opts Options) *Controller {
538 sink := opts.Sink
539 if nilutil.IsNil(sink) {
540 sink = event.Discard
541 }
542 usageTee, ok := sink.(*goalUsageTee)
543 if !ok {
544 usageTee = NewGoalUsageTee(sink).(*goalUsageTee)
545 sink = usageTee
546 }
547 pluginCtx := opts.PluginCtx
548 if pluginCtx == nil {
549 pluginCtx = context.Background()
550 }
551 runtimeProfile := opts.RuntimeProfile
552 if runtimeProfile == "" {
553 runtimeProfile = capability.ProfileBalanced
554 }
555 if opts.Hooks != nil {
556 opts.Hooks.SetSessionID(agent.BranchID(opts.SessionPath))
557 }
558 c := &Controller{
559 runner: opts.Runner,
560 executor: opts.Executor,
561 guardianSess: opts.Guardian,
562 guardianPath: guardian.PathFor(opts.SessionPath),
563 evaluator: opts.GoalEvaluator,
564 goalUsageTee: usageTee,
565 sink: sink,
566 policy: opts.Policy,
567 subagentGate: opts.SubagentGate,
568 label: opts.Label,
569 modelRef: opts.ModelRef,
570 systemPrompt: opts.SystemPrompt,
571 sessionDir: opts.SessionDir,
572 sessionPath: opts.SessionPath,
573 commands: atomic.Pointer[[]command.Command]{},
574 skills: newSkillSet(opts.Skills, opts.AllSkills, opts.SkillStore, opts.AllSkillStore),
575 skillRunner: opts.SkillRunner,
576 readOnlySkillRunner: opts.ReadOnlySkillRunner,
577 skillProfile: opts.SkillProfile,
578 hooks: opts.Hooks,
579 memory: newMemoryManager(opts.Memory),
580 cleanup: opts.Cleanup,
581 responseLanguage: config.NormalizeLanguage(opts.ResponseLanguage),
582 reasoningLanguage: config.NormalizeReasoningLanguage(opts.ReasoningLanguage),
583 disableColdResumePrune: opts.DisableColdResumePrune,
584 shell: opts.Shell,
585 onRemember: opts.OnRemember,
586 onRememberPlanModeReadOnlyCommand: opts.OnRememberPlanModeReadOnlyCommand,
587 sessionRecoveryMeta: opts.SessionRecoveryMeta,
588 onSessionRecovered: opts.OnSessionRecovered,
589 balanceURL: opts.BalanceURL,
590 balanceKey: opts.BalanceKey,
591 balanceClient: opts.BalanceClient,
592 jobs: opts.Jobs,
593 workspaceLease: opts.WorkspaceLease,
594 mcp: newMcpManager(opts.Host, opts.Registry, pluginCtx),
595 mcpDefaultCallTimeout: opts.MCPDefaultCallTimeout,
596 mcpConfigureSpec: opts.MCPConfigureSpec,
597 capabilityRuntime: opts.CapabilityRuntime,
598 runtimeProfile: runtimeProfile,
599 ablation: opts.Ablation,
600 workspaceRoot: opts.WorkspaceRoot,
601 externalFolderToolRefs: opts.ExternalFolderToolRefs,
602 providerResolver: opts.ProviderResolver,
603 approval: newApprovalManager(opts.Policy, ToolApprovalAsk, opts.ApprovalTimeout),
604 }
605 // Session-private temporary directory: reuse a shared Manager on hot
606 // rebuild, otherwise create one. Retain so ReleaseResources/Close drop the
607 // owner reference without racing a replacement Controller.
608 if opts.SessionTemp != nil {
609 c.sessionTemp = opts.SessionTemp
610 } else {
611 c.sessionTemp = sessiontemp.New()
612 }
613 c.sessionTemp.Retain()
614
615 if strings.TrimSpace(opts.WorkspaceRoot) != "" {
616 c.autoResearch = autoresearch.NewStore(opts.WorkspaceRoot)
617 }
618 if opts.Extensions != nil {
619 c.extensions = opts.Extensions
620 c.sink = newFrontendEventSink(c.sink, opts.Extensions)
621 if c.executor != nil {
622 c.executor.SetExtensions(opts.Extensions)
623 }
624 }
625 // Checkpoints: bind a store to the session and route writer pre-edits into it.
626 c.rebindCheckpoints(opts.SessionPath)
627 c.setActiveJobSession(opts.SessionPath)
628 cmdsInit := opts.Commands
629 c.commands.Store(&cmdsInit)
630 if c.executor != nil {
631 c.wireMutationObserver()
632 c.executor.SetMemoryQueue(c)
633 }
634 // Auto Guard is built into Auto. Ask and YOLO bypass it through the mode
635 // provider, so no separate enablement state is needed.
636 c.initRecoveryGate(opts.RecoveryReviewer, opts.RecoveryHeadless)
637
638 // Task monitoring: record background-job lifecycle into the project-local
639 // task store so CLI, Desktop, scripts, and future clients observe the same
640 // state/event evidence. The recorder swallows its own failures — monitoring
641 // must never affect the agent pipeline. The session id is resolved lazily
642 // because the session path is only fixed once the first turn begins.
643 if c.jobs != nil && c.workspaceRoot != "" {
644 c.jobs.SetTaskRecorder(taskmonitor.NewTaskRecorder(
645 taskmonitor.NewFileStore(filepath.Join(".reasonix", "tasks")),
646 c.workspaceRoot,
647 func() string { return c.parentSessionID() },
648 ))
649 }
650 return c
651 }
652
653 // SetDisplayRecorder installs an optional hook used by frontends that persist a
654 // shorter user-facing transcript than the fully composed model prompt.
655 func (c *Controller) SetDisplayRecorder(fn func(content, display string)) {
656 c.mu.Lock()
657 defer c.mu.Unlock()
658 c.displayRecorder = fn
659 }
660
661 // SetExtensions installs the extension dispatcher after construction. Boot
662 // uses it because sidecars — and therefore the dispatcher — only exist after
663 // snapshot assembly, which runs after New. It must be called before the
664 // controller starts serving turns: c.sink is swapped here and emission call
665 // sites read it without locking. The first non-nil install wins; a controller
666 // generation never swaps dispatchers. Nil is a no-op (the pre-dispatch path).
667 // The executor agent receives the same dispatcher so the run loop consults
668 // the agent-side intercept points (stage 6b2).
669 func (c *Controller) SetExtensions(d *dispatch.Dispatcher) {
670 if d == nil {
671 return
672 }
673 c.mu.Lock()
674 defer c.mu.Unlock()
675 if c.extensions != nil {
676 return
677 }
678 c.extensions = d
679 c.sink = newFrontendEventSink(c.sink, d)
680 if c.executor != nil {
681 c.executor.SetExtensions(d)
682 }
683 }
684
685 // ApplyExtensionSystemPrompt swaps the executor to a fresh session carrying
686 // the extension strategy's final system prompt and makes it the controller's
687 // rotation prompt, so /new and /clear keep the strategy-composed prompt too.
688 // Boot calls it when a system_prompt.build replacement changed the prompt
689 // after the controller (and its session) was built with the host-composed
690 // one. It must run before any turn or history resume: the fresh session holds
691 // only the system message, so a later resume cleanly layers history on top.
692 func (c *Controller) ApplyExtensionSystemPrompt(prompt string) {
693 if c == nil || c.executor == nil {
694 return
695 }
696 c.mu.Lock()
697 c.systemPrompt = prompt
698 c.mu.Unlock()
699 c.executor.SetSession(agent.NewSession(prompt))
700 }
701
702 // SetOnSessionRecovered installs the ownership handoff invoked before the
703 // controller commits to an automatically created recovery branch. Frontends
704 // that acquire their session owner after controller construction (for example
705 // reasonix serve) use this before publishing the controller.
706 func (c *Controller) SetOnSessionRecovered(fn func(SessionRecoveryInfo) error) {
707 if c == nil {
708 return
709 }
710 c.mu.Lock()
711 defer c.mu.Unlock()
712 c.onSessionRecovered = fn
713 }
714
715 func (c *Controller) sessionRecoveredHandler() func(SessionRecoveryInfo) error {
716 c.mu.Lock()
717 defer c.mu.Unlock()
718 return c.onSessionRecovered
719 }
720
721 func (c *Controller) recordDisplay(content, display string) {
722 if strings.TrimSpace(display) == "" || content == display {
723 return
724 }
725 c.mu.Lock()
726 record := c.displayRecorder
727 c.mu.Unlock()
728 if record != nil {
729 record(content, display)
730 }
731 }
732
733 // ToolContractEntries returns a stable snapshot of the executor's live tool
734 // contract: provider-visible names, descriptions, canonical schemas, and
735 // read-only flags. It is intended for diagnostics and regression tests.
736 func (c *Controller) ToolContractEntries() []tool.ContractEntry {
737 if c == nil {
738 return nil
739 }
740 reg := c.mcp.registry()
741 if reg == nil {
742 return nil
743 }
744 return reg.ContractEntries()
745 }
746
747 // ProviderCatalog returns the session's merged provider catalog: the config
748 // (or broker) base plus every provider a live extension sidecar declared,
749 // keyed by ref — extension refs carry their plugin/<plugin>/<provider>/<model>
750 // namespace. Nil when no sidecar declared providers, so frontends can tell
751 // "enumerate config only" apart from "the extension catalog is empty".
752 func (c *Controller) ProviderCatalog() []provider.Descriptor {
753 if c == nil || c.providerResolver == nil {
754 return nil
755 }
756 return c.providerResolver.Catalog()
757 }
758
759 func (c *Controller) recordDisplayForNewUser(startMessages int, display string) {
760 if strings.TrimSpace(display) == "" {
761 return
762 }
763 msgs := c.History()
764 if startMessages > len(msgs) {
765 startMessages = len(msgs)
766 }
767 for _, m := range msgs[startMessages:] {
768 if m.Role == provider.RoleUser {
769 c.recordDisplay(m.Content, display)
770 return
771 }
772 }
773 }
774
775 func (c *Controller) markEditedForNewUser(startMessages int, original string) {
776 if strings.TrimSpace(original) == "" || c.executor == nil {
777 return
778 }
779 s := c.executor.Session()
780 msgs := s.Snapshot()
781 if startMessages > len(msgs) {
782 startMessages = len(msgs)
783 }
784 for i := startMessages; i < len(msgs); i++ {
785 if msgs[i].Role != provider.RoleUser {
786 continue
787 }
788 if agent.UserMessageText(msgs[i]) == original {
789 return
790 }
791 msgs[i].Edited = true
792 msgs[i].Original = original
793 // A periodic autosave may already contain this user message without its
794 // local edit metadata. Classify the mutation atomically so the turn-end
795 // save performs an owned rewrite instead of forking a bogus
796 // same-revision recovery branch. Edited/Original are local-only display
797 // metadata (provider requests ignore them), so this must not report a
798 // cache-prefix change — ReplaceLocalMetadata, not Rewrite.
799 s.ReplaceLocalMetadata(msgs)
800 return
801 }
802 }
803
804 // ckptDir derives a session's checkpoint directory from its file path
805 // (…/<id>.jsonl → …/<id>.ckpt). Empty path → empty (in-memory checkpoints).
806 func ckptDir(sessionPath string) string {
807 return store.SessionCheckpointDir(sessionPath)
808 }
809
810 // rebindCheckpoints points the store at the (possibly new) session, loading any
811 // checkpoints already on disk, and resets the turn boundaries. Called on
812 // construction and whenever the session path changes (NewSession/Resume/SetSessionPath).
813 // Also re-wires the mutation observer so capture targets the new store.
814 func (c *Controller) rebindCheckpoints(sessionPath string) {
815 c.goals.setStatePath(goalStatePath(sessionPath))
816 c.checkpoints.rebind(ckptDir(sessionPath), c.workspaceRoot)
817 if c.executor != nil {
818 c.wireMutationObserver()
819 }
820 }
821
822 // beginCheckpoint opens a checkpoint for the turn about to run, recording the
823 // current message count as the conversation-rewind boundary. Called at the top of
824 // runTurn, before the user message is appended.
825 func (c *Controller) beginCheckpoint(input string) {
826 if c.executor == nil {
827 return
828 }
829 atomic.AddInt64(&c.sessionRevision, 1)
830 c.checkpoints.beginWithObserver(input, len(c.executor.Session().Messages), c.mutationObserver)
831 }
832
833 // --- commands (frontend → controller) ---
834
835 // admissionResult classifies what runGuarded did with a turn body.
836 type admissionResult int
837
838 const (
839 // turnStarted: admission was open; the turn is running now.
840 turnStarted admissionResult = iota
841 // turnParked: the body landed inside the finishing window (TurnDone was
842 // being delivered) and will start the moment the window closes. From the
843 // caller's perspective the turn WILL run — nothing was lost.
844 turnParked
845 // turnDroppedRunning: a turn is genuinely in flight. Deliberately silent,
846 // as before: interactive frontends prevent this with their own
847 // steer/queue UX, and internal opportunistic callers (goal-loop
848 // continuations, replays) rely on a quiet no-op.
849 turnDroppedRunning
850 // turnDroppedRotating: the executor session is being swapped out
851 // (NewSession/ClearSession). The input's intended session is ambiguous,
852 // so it is refused with a user-visible Notice asking to resend rather
853 // than silently running against a session the user didn't see.
854 turnDroppedRotating
855 // turnDroppedClosed: the controller has been closed. Deliberately silent:
856 // this controller's transports are being (or have been) torn down and the
857 // input's home is the replacement controller the host swaps in — a Notice
858 // here would go to a dead surface.
859 turnDroppedClosed
860 )
861
862 // runGuarded runs body on a background goroutine under a fresh cancellable
863 // context, guarding against concurrent turns and emitting a TurnDone event when
864 // it finishes (Err set on failure; nil also for a user Cancel).
865 //
866 // Admission is NOT first-come-first-served across all states — see
867 // admissionResult. In particular, a body arriving during the finishing window
868 // is parked, not dropped: TurnDone is emitted inside that window, so every
869 // caller that reacts to TurnDone by submitting again (a frontend's queued
870 // auto-send, a bot, a fast Enter) would otherwise race a silent drop. That
871 // exact loss was observed in CI and reproduced on a clean main-v2 worktree,
872 // and the desktop composer already carries a workaround gating its auto-send
873 // on submitDisabled rather than turn_done (Composer.tsx).
874 func (c *Controller) runGuarded(body func(ctx context.Context) error) admissionResult {
875 return c.admitGuardedTurn(body, false)
876 }
877
878 // runGuardedOrPark admits like runGuarded but parks the body while another
879 // turn is running instead of using the deliberately-silent running drop.
880 // Reserved for inputs that are the user's own words (the steer fallback):
881 // the FIFO drain in finishGuardedTurn delivers them the moment the current
882 // turn finishes.
883 func (c *Controller) runGuardedOrPark(body func(ctx context.Context) error) admissionResult {
884 return c.admitGuardedTurn(body, true)
885 }
886
887 func (c *Controller) admitGuardedTurn(body func(ctx context.Context) error, parkWhileRunning bool) admissionResult {
888 c.mu.Lock()
889 if c.closed {
890 c.mu.Unlock()
891 return turnDroppedClosed
892 }
893 if c.rotating {
894 c.mu.Unlock()
895 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "input was not accepted: the session is being switched — please resend"})
896 return turnDroppedRotating
897 }
898 if c.running {
899 if parkWhileRunning {
900 c.parkedTurns = append(c.parkedTurns, body)
901 c.mu.Unlock()
902 return turnParked
903 }
904 c.mu.Unlock()
905 return turnDroppedRunning
906 }
907 if c.finishing {
908 c.parkedTurns = append(c.parkedTurns, body)
909 c.mu.Unlock()
910 return turnParked
911 }
912 ctx, cancel := context.WithCancel(context.Background())
913 c.cancel = cancel
914 c.running = true
915 c.canceling = false
916 c.mu.Unlock()
917 c.spawnGuardedTurn(ctx, cancel, body)
918 return turnStarted
919 }
920
921 // spawnGuardedTurn launches an admitted turn body plus its autosave companion.
922 // The caller must already have claimed admission (running=true) under c.mu.
923 func (c *Controller) spawnGuardedTurn(ctx context.Context, cancel context.CancelFunc, body func(ctx context.Context) error) {
924 c.autosaveWG.Add(1)
925 go func() {
926 defer c.autosaveWG.Done()
927 c.autosaveWhileRunning(ctx)
928 }()
929 go func() {
930 defer cancel()
931 defer func() {
932 if r := recover(); r != nil {
933 c.finishGuardedTurn(fmt.Errorf("internal error: %v", r))
934 }
935 }()
936 err := body(ctx)
937 c.finishGuardedTurn(explainError(err))
938 }()
939 }
940
941 // finishGuardedTurn keeps admission closed while TurnDone is delivered. The
942 // sink fan-out may detach per-turn transports; allowing a replacement turn in
943 // after running=false but before that fan-out completed let the old completion
944 // clear or inherit the replacement turn's transport.
945 //
946 // When the window closes, the oldest parked turn (if any) is started under the
947 // SAME critical section that clears finishing: opening the gate first and then
948 // re-admitting would let an unrelated submit slip in ahead and bounce the
949 // parked turn back to a drop. Remaining parked turns drain one per
950 // finishGuardedTurn, preserving FIFO order. Rotation cannot interleave here:
951 // beginRotation refuses while running or finishing, and the drain flips
952 // finishing directly into running.
953 func (c *Controller) finishGuardedTurn(err error) {
954 c.memory.clearAutoRemember()
955 c.mu.Lock()
956 cancelRequested := c.canceling
957 c.running = false
958 // A live controller keeps admission closed until TurnDone fan-out finishes.
959 // Close has already sealed admission permanently, so a late completion must
960 // not resurrect a finishing state after teardown.
961 c.finishing = !c.closed
962 c.cancel = nil
963 c.canceling = false
964 c.mu.Unlock()
965
966 defer func() {
967 c.mu.Lock()
968 c.finishing = false
969 if c.closed || len(c.parkedTurns) == 0 {
970 // A closed controller must not start a parked turn against freed
971 // resources; close() also cleared the queue, this guards the
972 // close-raced-with-delivery ordering.
973 c.mu.Unlock()
974 return
975 }
976 next := c.parkedTurns[0]
977 c.parkedTurns = c.parkedTurns[1:]
978 ctx, cancel := context.WithCancel(context.Background())
979 c.cancel = cancel
980 c.running = true
981 c.canceling = false
982 c.mu.Unlock()
983 c.spawnGuardedTurn(ctx, cancel, next)
984 }()
985 done := event.Event{Kind: event.TurnDone, Err: err, Cancelled: cancelRequested, Outcome: turnOutcome(err)}
986 var readinessErr *agent.FinalReadinessError
987 if errors.As(err, &readinessErr) {
988 done.Readiness = &event.FinalReadiness{Attempts: readinessErr.Attempts, Missing: append([]string(nil), readinessErr.Missing...)}
989 }
990 c.sink.Emit(done)
991 }
992
993 func turnOutcome(err error) string {
994 var readinessErr *agent.FinalReadinessError
995 if errors.As(err, &readinessErr) {
996 return event.TurnOutcomeFinalReadiness
997 }
998 var pauseErr *agent.RecoveryPauseError
999 if errors.As(err, &pauseErr) {
1000 return event.TurnOutcomeRecoveryPaused
1001 }
1002 return ""
1003 }
1004
1005 // Send starts a turn with an uncomposed message. The controller applies
1006 // plan-mode, memory, and background-job framing inside the async turn path.
1007 func (c *Controller) Send(input string) {
1008 c.SendWithRaw(input, input)
1009 }
1010
1011 // SendWithRaw starts a turn with separate model input and raw prompt text.
1012 func (c *Controller) SendWithRaw(input, raw string) {
1013 c.runGuarded(func(ctx context.Context) error { return c.runGoalLoopWithRaw(ctx, input, raw) })
1014 }
1015
1016 // planApprovalTool is the Tool name on the ApprovalRequest the controller emits
1017 // to gate a proposed plan. Frontends key their plan-approval UI on it (the
1018 // desktop renders a plan card; the chat TUI a plan banner).
1019 const planApprovalTool = "exit_plan_mode"
1020
1021 // PlanDecisionAction preserves the three user-owned meanings of the Plan card.
1022 // Revise and exit both deny execution at the approval gate, but they are not the
1023 // same product decision and must remain distinguishable in durable receipts.
1024 type PlanDecisionAction string
1025
1026 const (
1027 PlanDecisionStartExecution PlanDecisionAction = "start_execution"
1028 PlanDecisionRevisePlan PlanDecisionAction = "revise_plan"
1029 PlanDecisionExitPlan PlanDecisionAction = "exit_plan"
1030 )
1031
1032 // SandboxEscapeApprovalTool is the internal Tool name used for one-shot approval
1033 // to rerun a shell command without the OS sandbox after the sandbox failed.
1034 const SandboxEscapeApprovalTool = "sandbox_escape"
1035
1036 // ManagedConfigWriteApprovalTool is the internal Tool name used for per-write
1037 // approval when a file tool targets a Reasonix-managed config file outside the
1038 // workspace write roots. It is a fresh human decision: config files control
1039 // providers, sandbox rules, permissions, and MCP servers for future sessions,
1040 // so YOLO/auto approval must never answer it.
1041 const ManagedConfigWriteApprovalTool = "config_write"
1042
1043 // planApprovedMessage is the follow-up turn sent once the user approves a plan —
1044 // the in-context nudge to execute and keep the (already-seeded) task list honest.
1045 const planApprovedMessage = "Plan approved — plan mode is off. Implement the plan now. The ordinary writer fallback is approved for this execution turn; explicit ask/deny rules and forced fresh reviews still apply. 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."
1046
1047 // runTurn runs one model turn, then applies the plan-approval gate. This is the
1048 // single, frontend-agnostic plan flow: in Plan the model is instructed to
1049 // research and write its plan as a normal answer, while any tool calls still use
1050 // the active Permissions/Sandbox path.
1051 // When the turn ends with a text proposal, the controller asks the user to
1052 // approve (reusing the ApprovalRequest channel both frontends already render);
1053 // on approval it exits plan mode, seeds the task list from the plan, and
1054 // continues straight into execution; on rejection it stays in plan mode so the
1055 // next turn can revise. Plan mode is only ever set interactively, so the headless
1056 // `Run` path (which doesn't call this) never blocks on a prompt.
1057 func (c *Controller) runTurn(ctx context.Context, input string) error {
1058 return c.runGoalLoopWithRaw(ctx, input, input)
1059 }
1060
1061 // RunTurn executes one foreground turn synchronously through the same lifecycle
1062 // used by interactive frontends: transient memory/background-job
1063 // composition, checkpoints, hooks, and plan approval. It is for transports that
1064 // need a blocking request/response boundary, such as ACP session/prompt.
1065 func (c *Controller) RunTurn(ctx context.Context, input string) error {
1066 ctx, cancel := context.WithCancel(ctx)
1067 c.mu.Lock()
1068 // finishing is part of the gate: TurnDone delivery for the previous turn
1069 // is still fanning out, and starting a synchronous turn inside that
1070 // window recreates the completion/transport crosstalk the window exists
1071 // to prevent (Running() already reports true here). closed seals a torn-
1072 // down controller. Synchronous callers get an error rather than parking:
1073 // they hold a request/response boundary open and already handle busy.
1074 if c.running || c.finishing || c.rotating || c.closed {
1075 c.mu.Unlock()
1076 cancel()
1077 return ErrTurnRunning
1078 }
1079 c.cancel = cancel
1080 c.running = true
1081 c.canceling = false
1082 c.mu.Unlock()
1083 defer event.RecordTurnCompletion(c.sink)
1084
1085 defer func() {
1086 c.mu.Lock()
1087 c.running = false
1088 c.cancel = nil
1089 c.canceling = false
1090 c.mu.Unlock()
1091 cancel()
1092 }()
1093 return c.runTurn(ctx, input)
1094 }
1095
1096 func (c *Controller) runTurnWithRaw(ctx context.Context, input, raw string) error {
1097 return c.runTurnWithRawDisplay(ctx, input, raw, "")
1098 }
1099
1100 func (c *Controller) runGoalLoopWithRaw(ctx context.Context, input, raw string) error {
1101 return c.runGoalLoopWithRawDisplay(ctx, input, raw, "")
1102 }
1103
1104 // withTurnFormat binds a structured-output format to the turn context
1105 // (empty is a no-op). Extracted from the runGoalLoop closure so tests can
1106 // assert the format actually reaches the agent request path.
1107 func (c *Controller) withTurnFormat(ctx context.Context, format string) context.Context {
1108 if format == "" {
1109 return ctx
1110 }
1111 return agent.WithResponseFormat(ctx, format)
1112 }
1113
1114 func (c *Controller) runGoalLoopWithRawDisplay(ctx context.Context, input, raw, display string) error {
1115 // Structured-output format is bound to the submitted turn (passed via
1116 // submitHTTPWithFormat → submitCommandOrTurn → runGoalLoop closure);
1117 // no global one-shot slot to race across concurrent requests.
1118 return newTurnOrchestrator(c).runGoalLoopWithRawDisplay(ctx, input, raw, display)
1119 }
1120
1121 func (c *Controller) runEditedGoalLoopWithRawDisplay(ctx context.Context, input, raw, display, original string) error {
1122 return newTurnOrchestrator(c).runEditedGoalLoopWithRawDisplay(ctx, input, raw, display, original)
1123 }
1124
1125 func (c *Controller) runTurnWithRawDisplay(ctx context.Context, input, raw, display string) error {
1126 return newTurnOrchestrator(c).runTurnWithRawDisplay(ctx, input, raw, display)
1127 }
1128
1129 func (c *Controller) runSubagentSkillSlash(sk skill.Skill, task, raw, display string) {
1130 sk = c.skills.prepare(sk)
1131 c.runGuarded(func(ctx context.Context) error {
1132 planMode := c.PlanMode()
1133 runner := c.skillRunner
1134 if runner == nil {
1135 return fmt.Errorf("subagent skill runner is unavailable for /%s", sk.Name)
1136 }
1137 return newTurnOrchestrator(c).runSubagentSkillGoalLoop(ctx, sk, task, raw, display, runner, planMode)
1138 })
1139 }
1140
1141 func (c *Controller) stopGoal(status string) {
1142 path, data, ok := c.goals.stop(status, c.goalTodos())
1143 c.persistGoalState(path, data, ok)
1144 }
1145
1146 // lastAssistantText returns the content of the most recent assistant message with
1147 // non-empty text — the model's final answer for the turn (its plan, in plan mode).
1148 func lastAssistantText(msgs []provider.Message) string {
1149 for i := len(msgs) - 1; i >= 0; i-- {
1150 if msgs[i].Role == provider.RoleAssistant && strings.TrimSpace(msgs[i].Content) != "" {
1151 return msgs[i].Content
1152 }
1153 }
1154 return ""
1155 }
1156
1157 // Submit is the one-call entry for a simple frontend: it takes raw user input
1158 // and does everything — slash-command dispatch, @-reference expansion, plan-mode
1159 // composition — emitting all output as events. The HTTP/SSE server uses this so
1160 // a browser client only POSTs the typed line.
1161 //
1162 // Slash commands route to the matching primitive: /compact, /new, and /clear
1163 // run their session op and emit a Notice; /mcp__server__prompt and custom /commands
1164 // resolve to a turn; an unknown slash emits a Notice. Anything else is a normal
1165 // turn with its @-references resolved first.
1166 func (c *Controller) Submit(input string) {
1167 c.submit(input, "", "")
1168 }
1169
1170 // SubmitHTTP accepts input from the unauthenticated localhost HTTP frontend. It
1171 // deliberately omits the trusted TUI-only "!cmd" shell shortcut and resolves file
1172 // references only through the controller's workspace root.
1173 func (c *Controller) SubmitHTTP(input string) {
1174 c.submitHTTP(input, "")
1175 }
1176
1177 // SubmitHTTPFormat is SubmitHTTP with an optional structured-output format
1178 // ("json_object") applied to the turn's completion requests. Empty format
1179 // behaves exactly like SubmitHTTP. A format attached to a slash command,
1180 // or other non-turn input is discarded; @reference turns preserve it because
1181 // the format is bound to every submitted turn rather than a global slot.
1182 func (c *Controller) SubmitHTTPFormat(input, format string) {
1183 // format 绑定到本次提交的 turn(随请求参数传递),不再写入 Controller
1184 // 全局一次性槽——评审 #7234 第 2 点:全局槽存在跨请求串用的逻辑竞态
1185 // (后提交的 JSON 请求先写槽,更早的普通请求先启动消费掉)。
1186 f := strings.TrimSpace(format)
1187 if f != "" && isNonTurnHTTPInput(input) {
1188 f = "" // 非 turn 输入(slash 命令/! 前缀)不携带 format
1189 }
1190 // @ 引用 turn(FileRefLine/SlashPathLineRef 等)同样绑定 format——
1191 // runRefTurnWithFormat 族 wrapper 注入 ctx(review fix7234and7168:
1192 // format 是每个被接纳 turn 的属性,统一架构)。
1193 c.submitHTTPWithFormat(input, "", f)
1194 }
1195
1196 // isNonTurnHTTPInput reports inputs that never reach the agent turn loop, so a
1197 // structured-output request attached to them would otherwise leak into the
1198 // next real turn (the format slot is consumed only by runGoalLoopWithRawDisplay).
1199 func isNonTurnHTTPInput(input string) bool {
1200 trimmed := strings.TrimSpace(input)
1201 if trimmed == "" {
1202 return true
1203 }
1204 // Memory quick-add / remember shortcuts and goal commands bypass turns.
1205 if _, ok := MemoryQuickAddNote(trimmed); ok {
1206 return true
1207 }
1208 if _, ok := RememberCommandNote(trimmed); ok {
1209 return true
1210 }
1211 // "!" shell commands are rejected by submitHTTP before the turn loop
1212 // (403 over HTTP); a format attached to them would never be consumed.
1213 if strings.HasPrefix(trimmed, "!") {
1214 return true
1215 }
1216 // Slash commands are management verbs (/compact /new /clear /model ...)
1217 // or notices, not completion turns.
1218 if strings.HasPrefix(trimmed, "/") {
1219 return true
1220 }
1221 return false
1222 }
1223
1224 // SubmitDisplay runs input as a turn while remembering the user-facing display
1225 // text for transcript replay when controller-side composition expands input.
1226 func (c *Controller) SubmitDisplay(display, input string) {
1227 c.submit(input, display, "")
1228 }
1229
1230 // SubmitDeliveryRecovery runs the same visible prompt path as SubmitDisplay but
1231 // first authorizes the executor to retain the immediately preceding exhausted
1232 // delivery ledger. The agent consumes that authorization once; if the card came
1233 // from an older/reloaded session this safely degrades to an ordinary turn.
1234 func (c *Controller) SubmitDeliveryRecovery(display, input string) {
1235 c.runGuarded(func(ctx context.Context) error {
1236 if c.executor != nil {
1237 c.executor.PrepareDeliveryRecovery()
1238 }
1239 return c.runGoalLoopWithRawDisplay(ctx, input, input, display)
1240 })
1241 }
1242
1243 // SubmitInvocationDisplay executes composer-selected invocation entities
1244 // independently of slash-command parsing. Plain string submit entry points keep
1245 // their existing behavior for CLI, HTTP, and backward-compatible clients.
1246 func (c *Controller) SubmitInvocationDisplay(display, input string, invocations []InvocationRequest) {
1247 c.submitInvocations(input, display, invocations)
1248 }
1249
1250 func (c *Controller) submitInvocations(input, display string, requests []InvocationRequest) {
1251 if len(requests) == 0 {
1252 c.SubmitDisplay(display, input)
1253 return
1254 }
1255 ordered := append([]InvocationRequest(nil), requests...)
1256 sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Offset < ordered[j].Offset })
1257 inline := make([]skill.Skill, 0, len(ordered))
1258 subagents := make([]skill.Skill, 0, len(ordered))
1259 for _, request := range ordered {
1260 sk, _, ok := c.resolveSkillInvocation("/" + strings.TrimSpace(request.Name))
1261 if !ok {
1262 c.notice("unknown invocation: /" + strings.TrimSpace(request.Name))
1263 return
1264 }
1265 kind := "skill"
1266 if sk.RunAs == skill.RunSubagent {
1267 kind = "subagent"
1268 }
1269 if request.Kind != kind {
1270 c.notice(fmt.Sprintf("invocation /%s is %s, not %s", sk.SlashName(), kind, request.Kind))
1271 return
1272 }
1273 if sk.RunAs == skill.RunSubagent {
1274 subagents = append(subagents, sk)
1275 } else {
1276 inline = append(inline, sk)
1277 }
1278 }
1279
1280 parts := make([]string, 0, len(inline)+1)
1281 for _, sk := range inline {
1282 parts = append(parts, c.skills.render(sk, ""))
1283 }
1284 if strings.TrimSpace(input) != "" {
1285 parts = append(parts, input)
1286 }
1287 composed := strings.Join(parts, "\n\n")
1288 if len(subagents) == 0 {
1289 c.runGuarded(func(ctx context.Context) error {
1290 return c.runGoalLoopWithRawDisplay(ctx, composed, input, display)
1291 })
1292 return
1293 }
1294 if strings.TrimSpace(input) == "" {
1295 c.notice("subagent invocation requires a task")
1296 return
1297 }
1298 c.runGuarded(func(ctx context.Context) error {
1299 planMode := c.PlanMode()
1300 runner := c.skillRunner
1301 if runner == nil {
1302 return fmt.Errorf("subagent skill runner is unavailable")
1303 }
1304 return newTurnOrchestrator(c).runSubagentSkillTurnsGoalLoop(ctx, subagents, composed, input, display, runner, planMode)
1305 })
1306 }
1307
1308 // SubmitEditedDisplay is SubmitDisplay for an inline-edited prompt. The model
1309 // sees input; the saved user message also keeps the pre-edit prompt as local UI
1310 // metadata so the edit survives session rewrites.
1311 func (c *Controller) SubmitEditedDisplay(display, input, original string) {
1312 c.submit(input, display, original)
1313 }
1314
1315 // SubmitUserTurn starts a normal model turn without interpreting shell or slash
1316 // commands. It still resolves references, so callers can submit trusted
1317 // user-authored prompt text without expanding the command surface.
1318 func (c *Controller) SubmitUserTurn(input, display string) {
1319 c.runRefTurn(input, display)
1320 }
1321
1322 func (c *Controller) submit(input, display, editedOriginal string) {
1323 trimmed := strings.TrimSpace(input)
1324 if note, ok := MemoryQuickAddNote(trimmed); ok {
1325 c.rememberProjectNote(note)
1326 return
1327 }
1328 if note, ok := RememberCommandNote(trimmed); ok {
1329 c.rememberProjectNote(note)
1330 return
1331 }
1332 if c.applyGoalCommand(trimmed, display) {
1333 return
1334 }
1335 if strings.HasPrefix(trimmed, "!") {
1336 c.RunShell(trimmed[1:])
1337 return
1338 }
1339 c.submitCommandOrTurn(trimmed, input, display, false, editedOriginal, "")
1340 }
1341
1342 func (c *Controller) submitHTTP(input, display string) {
1343 c.submitHTTPWithFormat(input, display, "")
1344 }
1345
1346 func (c *Controller) submitHTTPWithFormat(input, display, format string) {
1347 trimmed := strings.TrimSpace(input)
1348 if note, ok := MemoryQuickAddNote(trimmed); ok {
1349 c.rememberProjectNote(note)
1350 return
1351 }
1352 if note, ok := RememberCommandNote(trimmed); ok {
1353 c.rememberProjectNote(note)
1354 return
1355 }
1356 if c.applyGoalCommand(trimmed, display) {
1357 return
1358 }
1359 if strings.HasPrefix(trimmed, "!") {
1360 c.notice("shell commands are unavailable from this frontend")
1361 return
1362 }
1363 c.submitCommandOrTurn(trimmed, input, display, true, "", format)
1364 }
1365
1366 func (c *Controller) submitCommandOrTurn(trimmed, input, display string, scopedRefsOnly bool, editedOriginal, format string) {
1367 runRefTurn := func(input, display string) {
1368 c.runRefTurnWithFormat(input, display, format)
1369 }
1370 runRefTurnWithRefs := func(input, refLine, display string) {
1371 c.runRefTurnWithRefsFormat(input, refLine, display, format)
1372 }
1373 runGoalLoop := func(ctx context.Context, input, raw, display string) error {
1374 return c.runGoalLoopWithRawDisplay(c.withTurnFormat(ctx, format), input, raw, display)
1375 }
1376 if scopedRefsOnly {
1377 runRefTurn = func(input, display string) {
1378 c.runScopedRefTurnWithFormat(input, display, format)
1379 }
1380 runRefTurnWithRefs = func(input, refLine, display string) {
1381 c.runScopedRefTurnWithRefsFormat(input, refLine, display, format)
1382 }
1383 }
1384 if strings.TrimSpace(editedOriginal) != "" {
1385 runRefTurn = func(input, display string) {
1386 c.runEditedRefTurnWithFormat(input, display, editedOriginal, format)
1387 }
1388 runRefTurnWithRefs = func(input, refLine, display string) {
1389 c.runEditedRefTurnWithRefsFormat(input, refLine, display, editedOriginal, format)
1390 }
1391 runGoalLoop = func(ctx context.Context, input, raw, display string) error {
1392 return c.runEditedGoalLoopWithRawDisplay(ctx, input, raw, display, editedOriginal)
1393 }
1394 }
1395 switch {
1396 case trimmed == "/compact" || strings.HasPrefix(trimmed, "/compact "):
1397 focus := strings.TrimSpace(strings.TrimPrefix(trimmed, "/compact"))
1398 go func() {
1399 if err := c.Compact(context.Background(), focus); err != nil {
1400 c.notice("compaction failed: " + err.Error())
1401 } else {
1402 c.notice("compacted")
1403 if err := c.SnapshotRewrite(); err != nil {
1404 slog.Warn("controller: snapshot after compact", "err", err)
1405 }
1406 }
1407 }()
1408 case trimmed == "/new":
1409 go func() {
1410 if err := c.NewSession(); err != nil {
1411 c.notice("new session failed: " + err.Error())
1412 } else {
1413 c.notice("new session")
1414 }
1415 }()
1416 case trimmed == "/clear":
1417 go func() {
1418 if err := c.ClearSession(); err != nil {
1419 c.notice("clear context failed: " + err.Error())
1420 } else {
1421 c.notice("context cleared")
1422 }
1423 }()
1424 case strings.HasPrefix(trimmed, "/mcp__"):
1425 c.runGuarded(func(ctx context.Context) error {
1426 sent, found, err := c.MCPPrompt(ctx, trimmed)
1427 if err != nil {
1428 return err
1429 }
1430 if !found {
1431 c.notice("unknown command: " + trimmed)
1432 return nil
1433 }
1434 return runGoalLoop(ctx, sent, sent, display)
1435 })
1436 case SlashCodeCommentLine(trimmed):
1437 // Slash-prefixed code comments are prompt text, not slash commands.
1438 runRefTurn(input, display)
1439 case strings.HasPrefix(trimmed, "/"):
1440 if ref, ok := FileRefLine(trimmed); ok {
1441 runRefTurn(ref, display)
1442 return
1443 }
1444 if ref, ok := SlashPathLineRef(trimmed, c.workspaceRoot); ok {
1445 runRefTurnWithRefs(input, ref, display)
1446 return
1447 }
1448 if SlashPathLikeLine(trimmed) {
1449 runRefTurn(input, display)
1450 return
1451 }
1452 // Management verbs (/model /memory /skills /hooks /mcp) emit a Notice, so
1453 // Submit-based frontends (desktop, HTTP) get them with no extra wiring.
1454 // The chat TUI handles these itself with richer output.
1455 fields := strings.Fields(trimmed)
1456 switch fields[0] {
1457 case "/tree":
1458 c.notice(c.BranchTreeText())
1459 return
1460 case "/branch":
1461 args := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0]))
1462 if turn, name, fromTurn, err := ParseBranchTarget(args); err != nil {
1463 c.notice(err.Error())
1464 } else if fromTurn {
1465 if _, err := c.ForkNamed(turn-1, name); err != nil {
1466 c.notice(err.Error())
1467 }
1468 } else {
1469 if _, err := c.Branch(name); err != nil {
1470 c.notice(err.Error())
1471 }
1472 }
1473 return
1474 case "/switch":
1475 ref := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0]))
1476 if _, err := c.SwitchBranch(ref); err != nil {
1477 c.notice(err.Error())
1478 }
1479 return
1480 case "/rewind":
1481 args := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0]))
1482 turn, scope, err := parseRewind(args, c.Checkpoints())
1483 if err != nil {
1484 c.notice("usage: /rewind [turn] [code|conversation|both]")
1485 return
1486 }
1487 if err := c.Rewind(turn, scope); err != nil {
1488 c.notice(err.Error())
1489 }
1490 return
1491 case "/plan-exec":
1492 c.applyPlanExec(trimmed, display)
1493 return
1494 case "/prometheus":
1495 c.applyPrometheus(trimmed, display)
1496 return
1497 }
1498 if c.managementNotice(trimmed) {
1499 return
1500 }
1501 if IsBuiltinDocsSlash(fields[0], c.Commands(), c.SlashSkills()) {
1502 query := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0]))
1503 if query == "" {
1504 text, err := DocsCommandOverviewFor(fields[0])
1505 if err != nil {
1506 c.notice("docs: " + err.Error())
1507 } else {
1508 c.notice(text)
1509 }
1510 return
1511 }
1512 c.runGuarded(func(ctx context.Context) error {
1513 sent, err := docsCommandPrompt(ctx, query)
1514 if err != nil {
1515 return fmt.Errorf("docs: %w", err)
1516 }
1517 return runGoalLoop(ctx, sent, sent, display)
1518 })
1519 return
1520 }
1521 // A custom command wins over a skill of the same name; both resolve to a
1522 // turn. Built-ins and their explicit Reasonix namespace are handled above.
1523 if sent, ok := c.CustomCommand(trimmed); ok {
1524 c.runGuarded(func(ctx context.Context) error {
1525 return runGoalLoop(ctx, sent, sent, display)
1526 })
1527 return
1528 }
1529 if sk, task, ok := c.resolveSkillInvocation(trimmed); ok {
1530 if sk.RunAs == skill.RunSubagent {
1531 if strings.TrimSpace(task) == "" {
1532 c.notice("usage: /" + sk.Name + " <task>")
1533 return
1534 }
1535 c.runSubagentSkillSlash(sk, task, trimmed, display)
1536 return
1537 }
1538 sent := c.skills.render(sk, task)
1539 c.runGuarded(func(ctx context.Context) error {
1540 return runGoalLoop(ctx, sent, sent, display)
1541 })
1542 return
1543 }
1544 // Unknown slash input is prose more often than a typo ("/etc/hosts
1545 // looks wrong", pasted paths, half-remembered commands) — send it as a
1546 // regular message instead of dead-ending the submission, with a notice
1547 // so real typos are still visible (#5756).
1548 c.notice("unknown command: " + trimmed + " — sent as a regular message")
1549 runRefTurn(input, display)
1550 default:
1551 runRefTurn(input, display)
1552 }
1553 }
1554
1555 func (c *Controller) rememberProjectNote(note string) {
1556 if note == "" {
1557 c.notice("nothing to remember")
1558 return
1559 }
1560 if path, err := c.QuickAdd(memory.ScopeProject, note); err != nil {
1561 c.notice("memory: " + err.Error())
1562 } else {
1563 c.notice("remembered → " + path)
1564 }
1565 }
1566
1567 func (c *Controller) applyGoalCommand(input, display string) bool {
1568 cmd, ok := ParseGoalCommand(input)
1569 if !ok {
1570 return false
1571 }
1572 switch cmd.Action {
1573 case GoalCommandSet:
1574 c.SetPlanMode(false)
1575 c.SetGoalWithResearchMode(cmd.Text, cmd.ResearchMode)
1576 c.GoalStrict(cmd.Strict)
1577 c.notice(fmt.Sprintf(i18n.M.GoalSetFmt, ShortGoalForNotice(cmd.Text)))
1578 if c.runner != nil {
1579 c.runGuarded(func(ctx context.Context) error {
1580 return c.runGoalLoopWithRawDisplay(ctx, "Start pursuing the active goal now.", cmd.Text, display)
1581 })
1582 }
1583 case GoalCommandClear:
1584 c.ClearGoal()
1585 c.notice(i18n.M.GoalCleared)
1586 case GoalCommandPause:
1587 if !c.PauseGoal() {
1588 c.notice(i18n.M.GoalNotRunning)
1589 }
1590 case GoalCommandResume:
1591 if !c.ResumeGoal() {
1592 c.notice(i18n.M.GoalNotPaused)
1593 }
1594 default:
1595 goal := c.Goal()
1596 if strings.TrimSpace(goal) == "" {
1597 c.notice(i18n.M.GoalEmpty)
1598 break
1599 }
1600 rt := c.GoalRuntime()
1601 c.notice(fmt.Sprintf(i18n.M.GoalCurrentFmt, goal))
1602 c.notice(fmt.Sprintf(i18n.M.GoalRuntimeFmt,
1603 rt.TurnsUsed, rt.TurnsLimit, rt.TokensUsed,
1604 rt.NoProgressTurns, rt.NoProgressLimit, rt.BudgetExtensions))
1605 if rt.LastReason != "" {
1606 c.noticeDetail(i18n.M.GoalRuntimeLastReason, rt.LastReason)
1607 }
1608 if rt.StopCause != "" {
1609 c.notice(fmt.Sprintf(i18n.M.GoalPausedFmt, rt.StopCause))
1610 }
1611 }
1612 return true
1613 }
1614
1615 // applyPlanExec reads the current canonical todo list and starts a goal that
1616 // analyzes and dispatches independent steps concurrently via parallel_tasks.
1617 // Supports --strict flag: /plan-exec --strict enables strict goal mode.
1618 func (c *Controller) applyPlanExec(input, display string) {
1619 todos := c.executor.CanonicalTodoState()
1620 if len(todos) == 0 {
1621 c.notice("no active plan with todos to execute")
1622 return
1623 }
1624
1625 // Parse --strict flag.
1626 strict := false
1627 fields := strings.Fields(input)
1628 for _, f := range fields {
1629 if f == "--strict" {
1630 strict = true
1631 break
1632 }
1633 }
1634
1635 // Count completion status.
1636 total := len(todos)
1637 done := 0
1638 for _, t := range todos {
1639 if t.Status == "completed" {
1640 done++
1641 }
1642 }
1643
1644 var b strings.Builder
1645 b.WriteString("You are the execution conductor. Route each step to the right sub-agent by module.\n\n")
1646
1647 // Detect project structure for module-aware routing.
1648 modules := c.detectProjectModules()
1649 if len(modules) > 0 {
1650 b.WriteString("## Project modules detected\n\n")
1651 for _, m := range modules {
1652 fmt.Fprintf(&b, "- %s/", m)
1653 }
1654 b.WriteString("\n\nRoute steps to the module they belong to. Steps in different modules can run in parallel.\n\n")
1655 }
1656
1657 b.WriteString("## Plan steps\n\n")
1658 for _, t := range todos {
1659 status := t.Status
1660 if status == "" {
1661 status = "pending"
1662 }
1663 mark := " "
1664 if status == "completed" {
1665 mark = "x"
1666 }
1667 fmt.Fprintf(&b, "- [%s] %s (%s)\n", mark, t.Content, status)
1668 }
1669 b.WriteString("\n## Routing rules\n")
1670 b.WriteString("1. Group steps by MODULE \u2014 same module = serial, different modules = parallel batches\n")
1671 b.WriteString("2. Research/exploration across modules = use parallel_tasks\n")
1672 b.WriteString("3. Dispatch each batch via parallel_tasks \u2014 each sub-agent gets one module\u2019s context\n")
1673 b.WriteString("4. Verify each batch before the next\n")
1674 b.WriteString("5. Failures: fix before moving on\n")
1675 b.WriteString("\nGoal: each sub-agent focuses on one module and does not carry irrelevant context.\n")
1676 if done > 0 {
1677 fmt.Fprintf(&b, "\nNote: %d/%d steps are already completed. Focus on the remaining %d steps.\n", done, total, total-done)
1678 }
1679 prompt := b.String()
1680
1681 // Show module preview.
1682 if len(modules) > 0 {
1683 c.notice(fmt.Sprintf("plan-exec: detected %d modules — %s", len(modules), strings.Join(modules, ", ")))
1684 }
1685
1686 c.SetPlanMode(false)
1687 c.SetGoal("execute plan: " + ShortGoalForNotice(todos[0].Content))
1688 c.GoalStrict(strict)
1689 c.notice(fmt.Sprintf("plan-exec: dispatching %d plan steps (strict=%v)", total, strict))
1690 if c.runner != nil {
1691 c.runGuarded(func(ctx context.Context) error {
1692 return c.runGoalLoopWithRawDisplay(ctx, prompt, prompt, display)
1693 })
1694 }
1695 }
1696
1697 // prometheusPrompt is the strategic planner system prompt.
1698 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 by calling update_goal with status complete. Do not implement.\n\nFor independent research directions, use parallel_tasks before planning."
1699
1700 // applyPrometheus starts an interactive planning interview, inspired by OMO's
1701 // Prometheus agent. It enters goal mode with a structured interview prompt.
1702 func (c *Controller) applyPrometheus(input, display string) {
1703 args := strings.TrimSpace(strings.TrimPrefix(input, "/prometheus"))
1704 if args == "" || args == "--strict" {
1705 c.notice("usage: /prometheus <your task description>")
1706 return
1707 }
1708 strict := false
1709 if strings.HasPrefix(args, "--strict ") {
1710 strict = true
1711 args = strings.TrimPrefix(args, "--strict ")
1712 }
1713 prompt := prometheusPrompt + "\n\n## User request\n\n" + args + "\n\nBegin the interview by asking your first clarifying question."
1714 c.SetPlanMode(false)
1715 c.SetGoal("plan: " + ShortGoalForNotice(args))
1716 c.GoalStrict(strict)
1717 c.notice("prometheus: starting planning interview")
1718 if c.runner != nil {
1719 c.runGuarded(func(ctx context.Context) error {
1720 return c.runGoalLoopWithRawDisplay(ctx, prompt, prompt, display)
1721 })
1722 }
1723 }
1724
1725 // shellTimeout is the maximum time a user-invoked "!command" may run. Matches
1726 // the bash tool's timeout so behaviour is consistent across invocation paths.
1727 const shellTimeout = 120 * time.Second
1728
1729 // shellWaitDelay bounds how long cmd.Run() waits after context cancellation for
1730 // the child's pipes to drain, matching the bash tool's WaitDelay.
1731 const shellWaitDelay = 5 * time.Second
1732
1733 func shellCommandPreview(command string) string {
1734 command = strings.TrimSpace(strings.ReplaceAll(command, "\n", " "))
1735 const max = 48
1736 r := []rune(command)
1737 if len(r) > max {
1738 return string(r[:max]) + "…"
1739 }
1740 return command
1741 }
1742
1743 // RunShell executes a shell command directly (bypassing the model) and streams
1744 // the output as ToolDispatch/ToolProgress/ToolResult events. It uses the same
1745 // bash-tool infrastructure (shell resolution, timeout) and shares the runGuarded
1746 // lock with model turns — only one can run at a time. User-invoked "!" commands
1747 // run without the OS sandbox (the user typed the command explicitly).
1748 func (c *Controller) RunShell(command string) {
1749 command = strings.TrimSpace(command)
1750 if command == "" {
1751 c.notice(i18n.M.ShellExecEmpty)
1752 return
1753 }
1754 c.runGuarded(func(ctx context.Context) error {
1755 sh := c.shell
1756 if sh.Path == "" {
1757 sh = sandbox.ResolveShell("", "", nil)
1758 }
1759 argv, _ := sandbox.Command(sandbox.Spec{}, sh, command) // false = unsandboxed (user invoked)
1760
1761 preview := []rune(command)
1762 if len(preview) > 32 {
1763 preview = preview[:32]
1764 }
1765 id := "shell-" + string(preview)
1766 diagnosticPreview := shellCommandPreview(command)
1767 desc := shellrun.DescriptorFromShell(sh)
1768
1769 c.sink.Emit(event.Event{
1770 Kind: event.ToolDispatch,
1771 Tool: event.Tool{
1772 ID: id,
1773 Name: "bash",
1774 Args: fmt.Sprintf(`{"command":%q}`, command),
1775 Execution: &event.ShellExecution{
1776 Kind: desc.Kind, Shell: desc.Shell, ShellVersion: desc.ShellVersion,
1777 Platform: desc.Platform, SupportsAndAnd: desc.SupportsAndAnd,
1778 State: tool.ShellStateRunning,
1779 },
1780 },
1781 })
1782
1783 start := time.Now()
1784 res := shellrun.RunForeground(ctx, shellrun.Request{
1785 Argv: argv,
1786 Dir: c.workspaceRoot,
1787 Timeout: shellTimeout,
1788 WaitDelay: shellWaitDelay,
1789 CommandPreview: diagnosticPreview,
1790 ShellKind: sh.Kind.String(),
1791 ShellPath: sh.Path,
1792 Source: "user_shell",
1793 Track: true,
1794 Progress: func(chunk string) {
1795 c.sink.Emit(event.Event{
1796 Kind: event.ToolProgress,
1797 Tool: event.Tool{ID: id, Output: chunk},
1798 })
1799 },
1800 })
1801 durationMs := time.Since(start).Milliseconds()
1802 ex := &event.ShellExecution{
1803 Kind: desc.Kind, Shell: desc.Shell, ShellVersion: desc.ShellVersion,
1804 Platform: desc.Platform, SupportsAndAnd: desc.SupportsAndAnd,
1805 State: res.State, FailurePhase: res.FailurePhase,
1806 OutputTail: res.OutputTail, DurationMs: durationMs,
1807 MutationRisk: tool.ShellMutationNone,
1808 Verification: tool.ShellVerificationNotVerification,
1809 }
1810 if res.ExitCode != nil {
1811 code := *res.ExitCode
1812 ex.ExitCode = &code
1813 }
1814 switch res.State {
1815 case tool.ShellStateCompleted:
1816 ex.MutationRisk = tool.ShellMutationNone
1817 case tool.ShellStateNotRun:
1818 ex.MutationRisk = tool.ShellMutationNotStarted
1819 case tool.ShellStateFailed:
1820 if res.FailurePhase == tool.ShellPhaseLaunch {
1821 ex.MutationRisk = tool.ShellMutationNotStarted
1822 } else {
1823 ex.MutationRisk = tool.ShellMutationMayBePartial
1824 }
1825 case tool.ShellStateTimedOut, tool.ShellStateCancelled:
1826 ex.MutationRisk = tool.ShellMutationMayBePartial
1827 }
1828
1829 errText := ""
1830 switch res.State {
1831 case tool.ShellStateCancelled:
1832 errText = i18n.M.TurnCancelled
1833 case tool.ShellStateTimedOut:
1834 errText = fmt.Sprintf(i18n.M.ShellExecTimeoutFmt, shellTimeout)
1835 case tool.ShellStateFailed, tool.ShellStateNotRun:
1836 if res.Err != nil {
1837 errText = fmt.Sprintf(i18n.M.ShellExecFailedFmt, res.Err)
1838 }
1839 }
1840 c.sink.Emit(event.Event{
1841 Kind: event.ToolResult,
1842 Tool: event.Tool{
1843 ID: id, Name: "bash", Output: res.Combined, Err: errText,
1844 DurationMs: durationMs, Execution: ex,
1845 },
1846 })
1847 return nil
1848 })
1849 }
1850
1851 // runRefTurn resolves a line's @references into a context block and starts a
1852 // turn with it prepended (or the raw line when nothing resolved).
1853 func (c *Controller) runRefTurn(input, display string) {
1854 c.runRefTurnWithRefs(input, input, display)
1855 }
1856
1857 // runRefTurnWithFormat runs a reference turn with a structured-output
1858 // format bound to its context (symmetric with runGoalLoop's withTurnFormat
1859 // injection — format is a property of every accepted turn, not just the
1860 // plain-goal path; review #7234 binds format to the accepted turn).
1861 func (c *Controller) runRefTurnWithFormat(input, display, format string) {
1862 c.runGuarded(func(ctx context.Context) error {
1863 return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, input, display, "", c.ResolveRefs)
1864 })
1865 }
1866
1867 func (c *Controller) runScopedRefTurnWithFormat(input, display, format string) {
1868 c.runGuarded(func(ctx context.Context) error {
1869 return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, input, display, "", c.ResolveScopedRefs)
1870 })
1871 }
1872
1873 func (c *Controller) runRefTurnWithRefsFormat(input, refLine, display, format string) {
1874 c.runGuarded(func(ctx context.Context) error {
1875 return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, refLine, display, "", c.ResolveRefs)
1876 })
1877 }
1878
1879 func (c *Controller) runScopedRefTurnWithRefsFormat(input, refLine, display, format string) {
1880 c.runGuarded(func(ctx context.Context) error {
1881 return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, refLine, display, "", c.ResolveScopedRefs)
1882 })
1883 }
1884
1885 func (c *Controller) runEditedRefTurnWithFormat(input, display, original, format string) {
1886 c.runGuarded(func(ctx context.Context) error {
1887 return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, input, display, original, c.ResolveRefs)
1888 })
1889 }
1890
1891 func (c *Controller) runEditedRefTurnWithRefsFormat(input, refLine, display, original, format string) {
1892 c.runGuarded(func(ctx context.Context) error {
1893 return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, refLine, display, original, c.ResolveRefs)
1894 })
1895 }
1896
1897 // runRefTurnWithRefs resolves references from refLine while preserving input as
1898 // the user's actual prompt text. This lets compiler diagnostics such as
1899 // "/path/File.kt:12: error" attach @/path/File.kt without rewriting the error.
1900 func (c *Controller) runRefTurnWithRefs(input, refLine, display string) {
1901 c.runRefTurnWithResolver(input, refLine, display, c.ResolveRefs)
1902 }
1903
1904 func (c *Controller) runRefTurnWithResolver(input, refLine, display string, resolve func(context.Context, string) (string, []string)) {
1905 c.runGuarded(func(ctx context.Context) error {
1906 return c.runRefTurnWithResolverSync(ctx, input, refLine, display, "", resolve)
1907 })
1908 }
1909
1910 func (c *Controller) runRefTurnWithResolverSync(ctx context.Context, input, refLine, display, original string, resolve func(context.Context, string) (string, []string)) error {
1911 block, errs := resolve(ctx, refLine)
1912 for _, e := range errs {
1913 c.notice(e)
1914 }
1915 sent := input
1916 if block != "" {
1917 sent = "Referenced context:\n\n" + block + "\n\n" + input
1918 }
1919 if strings.TrimSpace(original) != "" {
1920 return c.runEditedGoalLoopWithRawDisplay(ctx, sent, input, display, original)
1921 }
1922 return c.runGoalLoopWithRawDisplay(ctx, sent, input, display)
1923 }
1924
1925 // notice emits an informational Notice event.
1926 func (c *Controller) notice(text string) {
1927 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})
1928 }
1929
1930 func (c *Controller) noticeDetail(text, detail string) {
1931 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text, Detail: detail})
1932 }
1933
1934 // Run executes a turn synchronously, returning the agent's error. Used by the
1935 // headless `reasonix run` path, where the Sink renders to stdout and the caller
1936 // just needs the exit status — no TurnDone event, no cancel bookkeeping.
1937 func (c *Controller) Run(ctx context.Context, input string) (err error) {
1938 defer event.RecordTurnCompletion(c.sink)
1939 c.maybeSessionStart(ctx)
1940 parentSession := c.parentSessionID()
1941 ctx = agent.WithParentSession(ctx, parentSession)
1942 ctx = jobs.WithSession(ctx, parentSession)
1943 ctx = agent.WithUserImages(ctx, c.inputImages(input))
1944 rawInput := input
1945 ctx = agent.WithRawUserInput(ctx, rawInput)
1946 input = c.Compose(input)
1947 // input.receive: same interception seam as the orchestrated turn — the
1948 // composed headless input crosses the extension chain before it enters
1949 // the session.
1950 input, blocked, interceptErr := c.interceptInputReceive(ctx, input)
1951 if interceptErr != nil {
1952 return interceptErr
1953 }
1954 if blocked {
1955 return nil
1956 }
1957 startMessages := c.messageCount()
1958 defer c.snapshotActivityIfChanged(startMessages)
1959 c.beginCheckpoint(input)
1960 if c.guardianSess != nil {
1961 c.guardianSess.ResetTurn()
1962 }
1963 if c.hooks.Enabled() {
1964 c.mu.Lock()
1965 c.turn++
1966 turn := c.turn
1967 c.mu.Unlock()
1968 if block, _ := c.hooks.PromptSubmit(ctx, input, turn); block {
1969 return nil
1970 }
1971 defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }()
1972 }
1973 c.markInFlightTurn(startMessages, true)
1974 defer c.clearInFlightTurn()
1975 ctx = c.withPlannerTurnMetadata(ctx, rawInput, false, startMessages)
1976 err = c.runner.Run(ctx, c.withCapabilityRoute(ctx, input, rawInput))
1977 return err
1978 }
1979
1980 // RunSubagentProfile executes one named runAs=subagent skill synchronously and
1981 // returns only its final answer. It is the headless CLI counterpart to explicit
1982 // slash invocation: the child keeps an isolated session, while the caller owns
1983 // stdout rendering and exit status. readOnly selects the preview-safe runner
1984 // used by `reasonix subagent try`.
1985 func (c *Controller) RunSubagentProfile(ctx context.Context, name, task string, readOnly bool) (string, error) {
1986 name = strings.TrimSpace(name)
1987 task = strings.TrimSpace(task)
1988 if name == "" {
1989 return "", fmt.Errorf("subagent name is required")
1990 }
1991 if task == "" {
1992 return "", fmt.Errorf("subagent task is required")
1993 }
1994 sk, ok := c.skills.bySlashName(name)
1995 if !ok {
1996 return "", fmt.Errorf("unknown or disabled subagent profile %q", name)
1997 }
1998 if sk.RunAs != skill.RunSubagent {
1999 return "", fmt.Errorf("skill %q is not runAs=subagent", name)
2000 }
2001 sk = c.skills.prepare(sk)
2002 runner := c.skillRunner
2003 if readOnly {
2004 runner = c.readOnlySkillRunner
2005 }
2006 if runner == nil {
2007 return "", fmt.Errorf("subagent skill runner is unavailable for %q", name)
2008 }
2009
2010 c.maybeSessionStart(ctx)
2011 parentSession := c.parentSessionID()
2012 ctx = agent.WithParentSession(ctx, parentSession)
2013 ctx = jobs.WithSession(ctx, parentSession)
2014 ctx = agent.WithUserImages(ctx, c.inputImages(task))
2015 ctx = agent.WithResponseLanguagePreference(ctx, c.responseLanguage)
2016 ctx = agent.WithReasoningLanguagePreference(ctx, c.reasoningLanguage)
2017 ctx = agent.WithSubagentDepth(ctx, 0)
2018 answer, err := runner(ctx, sk, task, skill.SubagentRunOptions{HostInitiated: true})
2019 if err != nil {
2020 return "", err
2021 }
2022 return tool.GuardSubagentHostDecisionText(answer), nil
2023 }
2024
2025 // Cancel aborts the in-flight turn. A goroutine blocked awaiting approval
2026 // unblocks via the cancelled context.
2027 func (c *Controller) Cancel() {
2028 c.mu.Lock()
2029 cancel := c.cancel
2030 if cancel != nil {
2031 c.canceling = true
2032 }
2033 c.mu.Unlock()
2034 if cancel != nil {
2035 c.approval.clearAll()
2036 cancel()
2037 return
2038 }
2039 if c.goals.active() {
2040 c.stopGoal(GoalStatusStopped)
2041 }
2042 }
2043
2044 // Running reports whether a turn is currently in flight.
2045 func (c *Controller) Running() bool {
2046 c.mu.Lock()
2047 defer c.mu.Unlock()
2048 return c.running || c.finishing
2049 }
2050
2051 // beginRotation claims the session-rotation gate. It fails if a turn is running
2052 // or another rotation is already in progress, so the caller holds exclusive
2053 // rights to swap the executor session from the check here through endRotation.
2054 // This closes the TOCTOU window that a bare `if c.running` check left open:
2055 // between that check and the actual SetSession, a turn could start and then be
2056 // yanked out from under the run loop.
2057 func (c *Controller) beginRotation() error {
2058 c.mu.Lock()
2059 defer c.mu.Unlock()
2060 if c.running || c.finishing {
2061 return errTurnRunningRotation
2062 }
2063 if c.rotating {
2064 return errRotationInProgress
2065 }
2066 c.rotating = true
2067 return nil
2068 }
2069
2070 func (c *Controller) endRotation() {
2071 c.mu.Lock()
2072 c.rotating = false
2073 c.mu.Unlock()
2074 }
2075
2076 // CancelRequested reports whether Cancel has been requested for the active turn.
2077 func (c *Controller) CancelRequested() bool {
2078 c.mu.Lock()
2079 defer c.mu.Unlock()
2080 return c.canceling
2081 }
2082
2083 // PendingPrompt reports whether the current turn is blocked waiting for a user
2084 // approval, plan approval, memory approval, or ask-tool answer.
2085 func (c *Controller) PendingPrompt() bool {
2086 return c.approval.hasPending()
2087 }
2088
2089 // RuntimeStatus reports the active work owned by the foreground controller.
2090 func (c *Controller) RuntimeStatus() RuntimeStatus {
2091 c.mu.Lock()
2092 running := c.running
2093 active := running || c.finishing
2094 canceling := c.canceling
2095 c.mu.Unlock()
2096 pending := c.approval.hasPending()
2097 backgroundJobs := len(c.Jobs())
2098 return RuntimeStatus{
2099 Running: active,
2100 PendingPrompt: pending,
2101 BackgroundJobs: backgroundJobs,
2102 CancelRequested: canceling,
2103 Cancellable: running || pending,
2104 }
2105 }
2106
2107 // Turn returns the current turn number (0 before the first submit).
2108 func (c *Controller) Turn() int {
2109 c.mu.Lock()
2110 defer c.mu.Unlock()
2111 return c.turn
2112 }
2113
2114 // Approve answers a pending ApprovalRequest by ID: allow runs the call, session
2115 // also remembers a grant for the rest of the session so the same approval scope
2116 // is not re-prompted. Unknown/expired IDs are ignored.
2117 func (c *Controller) Approve(id string, allow, session, persist bool) {
2118 // Recovery cards are strict fresh decisions. Prefer ResolveRecovery so a
2119 // continue/deny from an old client that only knows Approve still maps onto
2120 // the recovery state machine (allow=continue, deny=revise without feedback).
2121 // Session/persist grants are intentionally ignored for recovery.
2122 //
2123 // Lookup must use the live waiter table (HasApproval), not Snapshot: pre-
2124 // normal-execution plan prompts park a waiter without an armed taskRuntime, so
2125 // they never appear in the persistence snapshot.
2126 c.mu.Lock()
2127 gate := c.recoveryGate
2128 c.mu.Unlock()
2129 if gate != nil && gate.HasApproval(id) {
2130 action := agent.RecoveryActionRevise
2131 if allow {
2132 action = agent.RecoveryActionContinue
2133 }
2134 _ = c.ResolveRecovery(id, action, "")
2135 return
2136 }
2137 pending := c.approval.resolve(id)
2138 if pending.reply == nil {
2139 return
2140 }
2141 outcome := "deny"
2142 if pending.tool == planApprovalTool {
2143 outcome = string(PlanDecisionRevisePlan)
2144 if allow {
2145 outcome = string(PlanDecisionStartExecution)
2146 }
2147 } else if allow {
2148 switch {
2149 case persist:
2150 outcome = "allow_persistent"
2151 case session:
2152 outcome = "allow_session"
2153 default:
2154 outcome = "allow_once"
2155 }
2156 }
2157 c.recordDecisionReceipt(pending, outcome)
2158 pending.reply <- approvalReply{allow: allow, session: session, persist: persist} // buffered, never blocks
2159 }
2160
2161 // ResolvePlanDecision answers the Plan card without collapsing revise and exit
2162 // into the generic approval boolean used by older clients.
2163 func (c *Controller) ResolvePlanDecision(id string, action PlanDecisionAction) error {
2164 if c == nil {
2165 return fmt.Errorf("controller is nil")
2166 }
2167 id = strings.TrimSpace(id)
2168 if id == "" {
2169 return fmt.Errorf("empty plan approval id")
2170 }
2171 switch action {
2172 case PlanDecisionStartExecution, PlanDecisionRevisePlan, PlanDecisionExitPlan:
2173 default:
2174 return fmt.Errorf("unknown plan decision %q", action)
2175 }
2176 pending, ok := c.approval.resolveTool(id, planApprovalTool)
2177 if !ok || pending.reply == nil {
2178 return fmt.Errorf("plan approval %q is no longer pending", id)
2179 }
2180 pending.kind = "plan"
2181 c.recordDecisionReceipt(pending, string(action))
2182 pending.reply <- approvalReply{allow: action == PlanDecisionStartExecution}
2183 return nil
2184 }
2185
2186 func (c *Controller) recordDecisionReceipt(pending pendingApproval, outcome string) {
2187 if c == nil || c.executor == nil || pending.reply == nil {
2188 return
2189 }
2190 kind := pending.kind
2191 if kind == "" {
2192 kind = "tool"
2193 if pending.tool == planApprovalTool {
2194 kind = "plan"
2195 }
2196 }
2197 receipt := &provider.DecisionReceipt{
2198 ID: pending.id,
2199 Kind: kind,
2200 Tool: strings.TrimSpace(pending.tool),
2201 Subject: clipUTF8(strings.TrimSpace(pending.subject), 240),
2202 Outcome: strings.TrimSpace(outcome),
2203 }
2204 // Keep the receipt bounded and provider-excluded even when an older caller
2205 // omits optional approval metadata.
2206 c.executor.Session().AddDecisionReceipt(receipt)
2207 c.sink.Emit(event.Event{
2208 Kind: event.Notice,
2209 Code: event.NoticeCodeDecisionReceipt,
2210 Level: event.LevelInfo,
2211 Text: "Decision recorded: " + receipt.Outcome,
2212 DecisionReceipt: receipt,
2213 })
2214 }
2215
2216 // EnableInteractiveApproval swaps the executor's gate for one that routes
2217 // approval decisions to the frontend via ApprovalRequest events, and wires the
2218 // controller in as the executor's Asker so the `ask` tool can question the user.
2219 // Interactive frontends (chat, desktop) call this; the headless run keeps the
2220 // silent gate and a nil asker from setup.
2221 func (c *Controller) EnableInteractiveApproval() {
2222 trustGate := planModeReadOnlyTrustApprover{c}
2223 escapeApprover := sandboxEscapeApprover{c}
2224 configApprover := managedConfigWriteApprover{c}
2225 if c.executor != nil {
2226 c.executor.SetGate(c.newInteractiveGate())
2227 c.executor.SetPlanModeReadOnlyTrustGate(trustGate)
2228 c.executor.SetSandboxEscapeApprover(escapeApprover)
2229 c.executor.SetConfigWriteApprover(configApprover)
2230 c.executor.SetAsker(c)
2231 }
2232 if setter, ok := c.runner.(interface {
2233 SetPlanModeReadOnlyTrustGate(agent.PlanModeReadOnlyTrustGate)
2234 }); ok {
2235 setter.SetPlanModeReadOnlyTrustGate(trustGate)
2236 }
2237 if setter, ok := c.runner.(interface {
2238 SetSandboxEscapeApprover(sandbox.EscapeApprover)
2239 }); ok {
2240 setter.SetSandboxEscapeApprover(escapeApprover)
2241 }
2242 if setter, ok := c.runner.(interface {
2243 SetConfigWriteApprover(tool.ConfigWriteApprover)
2244 }); ok {
2245 setter.SetConfigWriteApprover(configApprover)
2246 }
2247 if setter, ok := c.runner.(interface {
2248 SetPlannerPlanApprover(agent.PlannerPlanApprover)
2249 }); ok {
2250 setter.SetPlannerPlanApprover(plannerPlanApprover{c: c})
2251 }
2252 if setter, ok := c.runner.(interface {
2253 SetPlannerUserDecisionAsker(agent.PlannerUserDecisionAsker)
2254 }); ok {
2255 setter.SetPlannerUserDecisionAsker(plannerUserDecisionAsker{c: c})
2256 }
2257 }
2258
2259 type plannerPlanApprover struct {
2260 c *Controller
2261 }
2262
2263 func (p plannerPlanApprover) RunWithPlannerApproval(ctx context.Context, plan string, run func(context.Context) error) error {
2264 c := p.c
2265 allow, _, err := c.requestApprovalWithReason(ctx, planApprovalTool, "", nil, "Planner requested host approval before execution.")
2266 if err != nil {
2267 return err
2268 }
2269 if !allow {
2270 return nil
2271 }
2272 todoArgs := c.seedPlanTodos(plan)
2273 execStart := c.sessionMessageCount()
2274 c.approval.setPlanAutoApprove(true)
2275 defer c.approval.setPlanAutoApprove(false)
2276 if err := run(ctx); err != nil {
2277 return err
2278 }
2279 if todoArgs != "" && !c.hasTodoUpdateSince(execStart) {
2280 c.completePlanTodos(todoArgs)
2281 }
2282 return nil
2283 }
2284
2285 type plannerUserDecisionAsker struct {
2286 c *Controller
2287 }
2288
2289 func (p plannerUserDecisionAsker) RunWithPlannerUserDecision(ctx context.Context, _ string, question event.AskQuestion, run func(context.Context, string) error) error {
2290 answers, err := p.c.Ask(ctx, []event.AskQuestion{question})
2291 if err != nil {
2292 return err
2293 }
2294 answer := plannerUserDecisionAnswer(question, answers)
2295 if strings.TrimSpace(answer) == "" {
2296 return nil
2297 }
2298 return run(ctx, answer)
2299 }
2300
2301 func plannerUserDecisionAnswer(question event.AskQuestion, answers []event.AskAnswer) string {
2302 for _, answer := range answers {
2303 if answer.QuestionID != question.ID {
2304 continue
2305 }
2306 selected := make([]string, 0, len(answer.Selected))
2307 for _, item := range answer.Selected {
2308 if s := strings.TrimSpace(item); s != "" {
2309 selected = append(selected, s)
2310 }
2311 }
2312 return strings.Join(selected, ", ")
2313 }
2314 return ""
2315 }
2316
2317 func (c *Controller) newInteractiveGate() *permission.Gate {
2318 policy := c.policy
2319 mode := c.approval.mode()
2320 switch mode {
2321 case ToolApprovalAuto, ToolApprovalYolo:
2322 policy.Mode = permission.Allow
2323 case ToolApprovalDontAsk:
2324 policy.Mode = permission.Deny
2325 default:
2326 policy.Mode = permission.Ask
2327 }
2328 // A session allowlist (e.g. --allowed-tools) must never satisfy a tool that
2329 // requires fresh human approval on every call — memory remember/forget, plan
2330 // approval, sandbox escape, managed config write. SessionAllow is checked
2331 // before Ask in Policy.Decide, so leaving those entries in would let
2332 // `--allowed-tools remember` write memory with no prompt. Strip them so the
2333 // forced Ask rules below stay authoritative.
2334 policy.SessionAllow = rulesWithoutFreshHumanApproval(policy.SessionAllow)
2335 policy.Ask = append(policy.Ask,
2336 permission.Rule{Tool: memoryRememberTool},
2337 permission.Rule{Tool: memoryForgetTool},
2338 )
2339 var approver permission.Approver = gateApprover{c}
2340 if mode == ToolApprovalDontAsk {
2341 approver = denyPermissionApprover{}
2342 }
2343 gate := permission.NewGate(policy, approver)
2344 gate.OnRemember = func(rule string) {
2345 if c.onRemember != nil {
2346 _ = c.onRemember(rule)
2347 }
2348 }
2349 return gate
2350 }
2351
2352 func (c *Controller) allowLowRiskRemember(args json.RawMessage) bool {
2353 mem := c.Memory()
2354 if mem != nil {
2355 if assessment := memory.AssessRememberWrite(mem.Store, args); assessment.AutoAllow {
2356 c.memory.authorizeAutoRemember(args)
2357 return true
2358 }
2359 }
2360 c.memory.revokeAutoRemember(args)
2361 return false
2362 }
2363
2364 func (c *Controller) newHeadlessGate(mode string) *freshHumanHeadlessGate {
2365 gate := BuildHeadlessApprovalGate(c.policy, mode)
2366 gate.allowLowRiskFreshAction = func(toolName string, args json.RawMessage) bool {
2367 return toolName == memoryRememberTool && c.allowLowRiskRemember(args)
2368 }
2369 return gate
2370 }
2371
2372 type denyPermissionApprover struct{}
2373
2374 func (denyPermissionApprover) Approve(context.Context, string, string, json.RawMessage) (bool, bool, error) {
2375 return false, false, nil
2376 }
2377
2378 // rulesWithoutFreshHumanApproval drops any session-allow rule that targets a
2379 // tool requiring fresh human approval, so an explicit allowlist cannot bypass
2380 // the always-prompt contract for those tools.
2381 func rulesWithoutFreshHumanApproval(rules []permission.Rule) []permission.Rule {
2382 if len(rules) == 0 {
2383 return rules
2384 }
2385 filtered := make([]permission.Rule, 0, len(rules))
2386 for _, r := range rules {
2387 if RequiresFreshHumanApprovalTool(r.Tool) {
2388 continue
2389 }
2390 filtered = append(filtered, r)
2391 }
2392 return filtered
2393 }
2394
2395 // ApplyHeadlessApprovalMode configures the executor gate for a non-interactive
2396 // (`reasonix run`) session from an explicit --permission-mode. Unlike
2397 // EnableInteractiveApproval it installs no blocking approver, asker, or
2398 // fresh-approval prompt: there is no key loop to answer them, and the default
2399 // infinite approval timeout would wedge the run forever on an Ask rule, the
2400 // `ask` tool, or a sandbox/config approval. Modes map straight onto a headless
2401 // gate, and each preserves the interactive contract as closely as a run with no
2402 // one to prompt allows:
2403 //
2404 // - auto: auto-approve the writer fallback (Mode=Allow) but PRESERVE explicit
2405 // ask rules. Interactive auto prompts on those (it never auto-approves them);
2406 // headless can't prompt, so a would-ask decision fails closed (deny) rather
2407 // than running silently. Only bypass may run such a command unattended.
2408 // - yolo/bypassPermissions: skip ordinary approval-gated decisions (nil
2409 // approver); deny rules and fresh decisions still fail closed.
2410 // - dontAsk: deny anything that would ask, and deny the writer fallback too.
2411 //
2412 // Deny rules and fresh-human tools (memory, plan, sandbox, config) stay enforced
2413 // by the gate for every mode. The only exception is a controller-assessed,
2414 // create-only project/reference memory; every other memory write remains denied.
2415 func (c *Controller) ApplyHeadlessApprovalMode(mode string) {
2416 mode = normalizeToolApprovalMode(mode)
2417 c.approval.setMode(mode)
2418 if c.subagentGate != nil {
2419 c.subagentGate.Update(mode)
2420 }
2421 if c.executor != nil {
2422 c.executor.SetGate(c.newHeadlessGate(mode))
2423 }
2424 }
2425
2426 func (c *Controller) refreshInteractiveGate() {
2427 if c.executor != nil {
2428 c.executor.SetGate(c.newInteractiveGate())
2429 }
2430 }
2431
2432 // TrySteer queues mid-turn guidance only when the active agent turn accepts it.
2433 func (c *Controller) TrySteer(text string) bool {
2434 c.mu.Lock()
2435 exec := c.executor
2436 running := c.running
2437 c.mu.Unlock()
2438 return running && exec != nil && exec.Steer(text)
2439 }
2440
2441 // Steer is the compatibility path for callers that cannot observe admission.
2442 // Interactive hosts should call TrySteer so a rejected steer remains in their
2443 // draft/queue and can be retried as a regular follow-up.
2444 func (c *Controller) Steer(text string) {
2445 if c.TrySteer(text) {
2446 return
2447 }
2448 // No active turn accepted the steer: the frontend's runningRef was stale,
2449 // the turn exited between our running check and the enqueue, or no
2450 // executor is bound yet. Deliver it as a regular turn instead.
2451 c.submitSteerFallback(text)
2452 }
2453
2454 // submitSteerFallback records steer text that no active turn accepted as
2455 // unapplied guidance, not as a new task. This compatibility path deliberately
2456 // never opens a provider turn: replaying stale historical guidance as the
2457 // user's current request caused unintended code changes (#7045).
2458 func (c *Controller) submitSteerFallback(text string) admissionResult {
2459 return c.runGuardedOrPark(func(context.Context) error {
2460 if c.executor != nil {
2461 c.executor.RecordUnappliedSteer(text)
2462 }
2463 return nil
2464 })
2465 }
2466
2467 // SteerConsumed returns true when the steer queue is empty after the last consume.
2468 func (c *Controller) SteerConsumed() bool {
2469 c.mu.Lock()
2470 exec := c.executor
2471 c.mu.Unlock()
2472 if exec != nil {
2473 return exec.SteerConsumed()
2474 }
2475 return true
2476 }
2477
2478 // Ask implements agent.Asker: it emits an AskRequest and blocks until
2479 // AnswerQuestion(ID, …) answers or ctx is cancelled. promptMu serialises it
2480 // against tool-approval prompts so at most one user prompt is outstanding.
2481 // Unlike tool-approval gates, Ask is NOT bypassed in YOLO mode — the `ask`
2482 // tool exists to get a genuine user decision, and YOLO only auto-approves
2483 // tool calls; it must not answer the user's questions for them.
2484 func (c *Controller) Ask(ctx context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error) {
2485 c.approval.promptMu.Lock()
2486 defer c.approval.promptMu.Unlock()
2487
2488 c.approval.promptEmitMu.Lock()
2489 id, reply := c.approval.registerAsk(questions)
2490 c.sink.Emit(event.Event{Kind: event.AskRequest, Ask: event.Ask{ID: id, Questions: questions}})
2491 c.approval.promptEmitMu.Unlock()
2492
2493 waitCtx, cancelWait := c.approval.waitContext(ctx)
2494 defer cancelWait()
2495
2496 select {
2497 case ans := <-reply:
2498 return ans, nil
2499 case <-waitCtx.Done():
2500 c.approval.cancelAsk(id)
2501 return nil, waitCtx.Err()
2502 }
2503 }
2504
2505 // AnswerQuestion resolves a pending AskRequest by ID with the user's selections.
2506 // Unknown/expired IDs are ignored.
2507 func (c *Controller) AnswerQuestion(id string, answers []event.AskAnswer) {
2508 if pending, ok := c.approval.resolveAsk(id); ok {
2509 // An answer batch with no selections is the explicit "skip and continue
2510 // chat" path. End the current turn instead of feeding a prose dismissal
2511 // back to the model and trusting it not to ask again (#6869).
2512 if !askAnswersHaveSelection(answers) {
2513 c.mu.Lock()
2514 activeTurn := c.cancel != nil
2515 c.mu.Unlock()
2516 if activeTurn {
2517 c.Cancel()
2518 return
2519 }
2520 }
2521 c.recordAskDecisionReceipt(id, pending, answers)
2522 pending.reply <- answers // buffered, never blocks
2523 }
2524 }
2525
2526 func (c *Controller) recordAskDecisionReceipt(id string, pending pendingAsk, answers []event.AskAnswer) {
2527 if c == nil || c.executor == nil {
2528 return
2529 }
2530 selected := make(map[string][]string, len(answers))
2531 for _, answer := range answers {
2532 selected[answer.QuestionID] = append([]string(nil), answer.Selected...)
2533 }
2534 parts := make([]string, 0, len(pending.questions))
2535 for _, question := range pending.questions {
2536 answer := strings.TrimSpace(strings.Join(selected[question.ID], ", "))
2537 if answer == "" {
2538 answer = "—"
2539 }
2540 prompt := strings.TrimSpace(question.Prompt)
2541 if prompt == "" {
2542 prompt = strings.TrimSpace(question.Header)
2543 }
2544 if prompt == "" {
2545 prompt = question.ID
2546 }
2547 parts = append(parts, prompt+": "+answer)
2548 }
2549 receipt := &provider.DecisionReceipt{
2550 ID: id,
2551 Kind: "ask",
2552 Subject: clipUTF8(strings.Join(parts, " · "), 240),
2553 Outcome: "answered",
2554 }
2555 c.executor.Session().AddDecisionReceipt(receipt)
2556 c.sink.Emit(event.Event{
2557 Kind: event.Notice,
2558 Code: event.NoticeCodeDecisionReceipt,
2559 Level: event.LevelInfo,
2560 Text: "Decision recorded: answered",
2561 DecisionReceipt: receipt,
2562 })
2563 }
2564
2565 func askAnswersHaveSelection(answers []event.AskAnswer) bool {
2566 for _, answer := range answers {
2567 if len(answer.Selected) > 0 {
2568 return true
2569 }
2570 }
2571 return false
2572 }
2573
2574 // ReplayPendingPrompts re-emits the ApprovalRequest / AskRequest event for every
2575 // prompt currently blocking the run loop. A frontend that reconnected or reloaded
2576 // after the original event has no way to rebuild its approval/ask modal otherwise,
2577 // so the blocked gate goroutine stays stuck forever while the session shows a
2578 // "waiting" status with no actionable prompt. promptMu serialises Ask and
2579 // requestApproval, so in practice at most one prompt is outstanding; the loops
2580 // stay general so a future concurrent prompt would still replay correctly.
2581 func (c *Controller) ReplayPendingPrompts() {
2582 c.approval.promptEmitMu.Lock()
2583 noApprovals := c.replayPendingPromptsTo(c.sink)
2584 c.approval.promptEmitMu.Unlock()
2585 if noApprovals {
2586 // Retained compatibility hook; live Auto Guard cards are ordinary approvals.
2587 c.ReplayUnresolvedRecoveries()
2588 }
2589 }
2590
2591 // ReplayPendingPromptsTo re-emits pending prompts to one frontend sink. Serve
2592 // uses this for a newly attached SSE client so existing browsers do not receive
2593 // duplicate approval/ask cards when another client reconnects.
2594 func (c *Controller) ReplayPendingPromptsTo(sink event.Sink) {
2595 c.approval.promptEmitMu.Lock()
2596 defer c.approval.promptEmitMu.Unlock()
2597 c.replayPendingPromptsTo(sink)
2598 }
2599
2600 // ReplayPendingPromptsWith performs an SSE connection handoff while prompt
2601 // registration and emission are paused. The factory must subscribe the new
2602 // client and return a sink that targets it; this closes the attach race where
2603 // the original prompt could otherwise land between Subscribe and replay.
2604 func (c *Controller) ReplayPendingPromptsWith(sinkFactory func() event.Sink) {
2605 if sinkFactory == nil {
2606 return
2607 }
2608 c.approval.promptEmitMu.Lock()
2609 defer c.approval.promptEmitMu.Unlock()
2610 c.replayPendingPromptsTo(sinkFactory())
2611 }
2612
2613 func (c *Controller) replayPendingPromptsTo(sink event.Sink) bool {
2614 approvals, asks := c.approval.snapshotPrompts()
2615 c.emitPendingPrompts(sink, approvals, asks)
2616 return len(approvals) == 0
2617 }
2618
2619 func (c *Controller) emitPendingPrompts(sink event.Sink, approvals []event.Approval, asks []event.Ask) {
2620 if sink == nil {
2621 return
2622 }
2623 for _, a := range approvals {
2624 sink.Emit(c.approvalRequestEvent(a))
2625 }
2626 for _, a := range asks {
2627 sink.Emit(event.Event{Kind: event.AskRequest, Ask: a})
2628 }
2629 }
2630
2631 // SetPlanMode flips the executor's plan-first workflow flag without touching the
2632 // cache-stable system/tool prefix, and remembers the state so Compose can prepend
2633 // the plan-mode marker to outgoing user turns.
2634 func (c *Controller) SetPlanMode(v bool) {
2635 c.applyPlanMode(v)
2636 }
2637
2638 func (c *Controller) applyPlanMode(v bool) {
2639 c.mu.Lock()
2640 c.planMode = v
2641 c.mu.Unlock()
2642 if setter, ok := c.runner.(interface{ SetPlanMode(bool) }); ok {
2643 setter.SetPlanMode(v)
2644 return
2645 }
2646 if c.executor != nil {
2647 c.executor.SetPlanMode(v)
2648 }
2649 }
2650
2651 // SetResponseLanguage updates the final-answer language preference for
2652 // subsequent turns.
2653 func (c *Controller) SetResponseLanguage(lang string) {
2654 mode := config.NormalizeLanguage(lang)
2655 c.mu.Lock()
2656 c.responseLanguage = mode
2657 c.mu.Unlock()
2658 if setter, ok := c.runner.(interface{ SetResponseLanguage(string) }); ok {
2659 setter.SetResponseLanguage(mode)
2660 } else if c.executor != nil {
2661 c.executor.SetResponseLanguage(mode)
2662 }
2663 }
2664
2665 // SetReasoningLanguage updates the visible reasoning language preference for
2666 // subsequent turns.
2667 func (c *Controller) SetReasoningLanguage(lang string) {
2668 mode := config.NormalizeReasoningLanguage(lang)
2669 c.mu.Lock()
2670 c.reasoningLanguage = mode
2671 c.mu.Unlock()
2672 if setter, ok := c.runner.(interface{ SetReasoningLanguage(string) }); ok {
2673 setter.SetReasoningLanguage(mode)
2674 } else if c.executor != nil {
2675 c.executor.SetReasoningLanguage(mode)
2676 }
2677 }
2678
2679 // PlanMode reports whether outgoing turns currently receive the plan-mode
2680 // marker.
2681 func (c *Controller) PlanMode() bool {
2682 c.mu.Lock()
2683 defer c.mu.Unlock()
2684 return c.planMode
2685 }
2686
2687 // GoalStrict enables or disables strict goal mode. Since the structured
2688 // protocol, every complete claim is validated against host readiness and an
2689 // incomplete-todo intercept can never be overridden, so the flag is persisted
2690 // for compatibility with older frontends but no longer changes FSM behavior.
2691 func (c *Controller) GoalStrict(strict bool) {
2692 path, data, ok := c.goals.setStrict(strict, c.goalTodos())
2693 c.persistGoalState(path, data, ok)
2694 }
2695
2696 // SetGoal stores a session-scoped active goal. Compose injects it into outgoing
2697 // user turns, not the system prompt or tool schema, so it does not disturb the
2698 // cache-stable prefix.
2699 func (c *Controller) SetGoal(goal string) {
2700 c.SetGoalWithResearchMode(goal, GoalResearchAuto)
2701 }
2702
2703 // SetGoalDurable updates the Goal only when its sidecar can be replaced
2704 // atomically. Remote Profile transactions persist autoResearchCreateToken
2705 // before calling this method so crash recovery owns any newly-created task.
2706 func (c *Controller) SetGoalDurable(goal, autoResearchCreateToken string) error {
2707 snapshot := c.goals.capture()
2708 setup := c.prepareAutoResearchTask(goal, GoalResearchAuto, autoResearchCreateToken)
2709 path, data, persist := c.goals.set(goal, GoalResearchAuto, setup.taskID, c.goalTodos())
2710 if setup.blockReason != "" {
2711 path, data, persist = c.goals.stop(GoalStatusBlocked, c.goalTodos())
2712 }
2713 if persist {
2714 if err := c.goals.writeStateErr(path, data); err != nil {
2715 c.goals.restore(snapshot)
2716 if setup.created && c.autoResearch != nil {
2717 if removeErr := c.autoResearch.RemoveTask(setup.taskID, setup.createToken); removeErr != nil {
2718 slog.Warn("controller: rollback autoresearch task", "task_id", setup.taskID, "err", removeErr)
2719 }
2720 }
2721 return err
2722 }
2723 }
2724 if setup.notice != "" {
2725 c.notice(setup.notice)
2726 }
2727 if setup.blockReason != "" {
2728 c.notice("autoresearch resume failed: " + setup.blockReason)
2729 }
2730 return nil
2731 }
2732
2733 func (c *Controller) SetGoalWithResearchMode(goal string, researchMode GoalResearchMode) {
2734 setup := c.prepareAutoResearchTask(goal, researchMode, "")
2735 if setup.notice != "" {
2736 c.notice(setup.notice)
2737 }
2738 path, data, ok := c.goals.set(goal, researchMode, setup.taskID, c.goalTodos())
2739 c.persistGoalState(path, data, ok)
2740 if setup.blockReason != "" {
2741 path, data, ok := c.goals.stop(GoalStatusBlocked, c.goalTodos())
2742 c.persistGoalState(path, data, ok)
2743 c.notice("autoresearch resume failed: " + setup.blockReason)
2744 }
2745 }
2746
2747 // ResumeGoal re-enters a recoverable blocked/stopped Goal without resetting its
2748 // delivery evidence scope or AutoResearch identity. A budget-paused Goal gets
2749 // one extra slice of its budget class; accumulated consumption is preserved.
2750 func (c *Controller) ResumeGoal() bool {
2751 path, data, persist, resumed, extended := c.goals.resume(c.goalTodos())
2752 if !resumed {
2753 return false
2754 }
2755 c.persistGoalState(path, data, persist)
2756 if extended {
2757 c.notice(i18n.M.GoalBudgetExtended)
2758 }
2759 if c.executor != nil {
2760 c.executor.RestoreDeliveryCheckpoint(c.goals.deliveryState())
2761 }
2762 return true
2763 }
2764
2765 // PauseGoal suspends a running Goal without losing its todo list, Delivery
2766 // checkpoint, or budget history; ResumeGoal restores it. Returns false when no
2767 // running Goal exists.
2768 func (c *Controller) PauseGoal() bool {
2769 if !c.goals.active() {
2770 return false
2771 }
2772 path, data, ok := c.goals.pauseFor(stopCauseManual, i18n.M.GoalPausedReason, c.goalTodos())
2773 c.persistGoalState(path, data, ok)
2774 c.notice(i18n.M.GoalPaused)
2775 return true
2776 }
2777
2778 // GoalRuntime returns the active Goal's budget/runtime summary for frontends.
2779 func (c *Controller) GoalRuntime() GoalRuntimeView {
2780 return c.goals.runtimeView()
2781 }
2782
2783 // goalEvaluatorEvidence assembles the bounded evaluator's evidence: the goal
2784 // contract, the current assistant final, a todo/readiness summary, the
2785 // AutoResearch success-criteria summary, turn/budget state, and the last
2786 // continuation reason. Every field is treated as untrusted by the evaluator.
2787 func (c *Controller) goalEvaluatorEvidence() goaleval.GoalEvidence {
2788 goal, _, mode, taskID := c.goals.snapshot()
2789 ev := goaleval.GoalEvidence{
2790 GoalContract: goal,
2791 LastContinuationReason: c.goals.lastContinuationReasonText(),
2792 }
2793 if c.executor != nil {
2794 ev.AssistantFinal = lastAssistantText(c.History())
2795 todos := c.goalTodos()
2796 incomplete := 0
2797 for _, t := range todos {
2798 if t.Status != "completed" {
2799 incomplete++
2800 }
2801 }
2802 rr := c.executor.ReadinessResult()
2803 readinessText := "ready"
2804 if rr.Reason != "" {
2805 readinessText = rr.Reason
2806 }
2807 ev.TodoSummary = fmt.Sprintf("todos: %d total, %d incomplete; delivery readiness: %s", len(todos), incomplete, readinessText)
2808 }
2809 if c.autoResearch != nil && strings.TrimSpace(taskID) != "" {
2810 if summary, err := c.autoResearch.Summary(taskID); err == nil {
2811 ev.AutoResearchSummary = fmt.Sprintf("task %s: iteration %d, %d open success criteria, next required action: %s",
2812 summary.TaskID, summary.Iteration, len(summary.OpenCriteria), summary.NextRequiredAction)
2813 }
2814 }
2815 ev.TurnStatus = c.goals.budgetStatusText() + "; research mode: " + goalResearchModeText(mode)
2816 return ev
2817 }
2818
2819 func goalResearchModeText(mode GoalResearchMode) string {
2820 switch mode {
2821 case GoalResearchOn:
2822 return "on"
2823 case GoalResearchOff:
2824 return "off"
2825 default:
2826 return "auto"
2827 }
2828 }
2829
2830 func (c *Controller) persistGoalDeliveryCheckpoint() {
2831 if c.executor == nil {
2832 return
2833 }
2834 checkpoint := c.executor.DeliveryCheckpoint()
2835 path, data, ok := c.goals.setDeliveryCheckpoint(checkpoint, c.goalTodos())
2836 c.persistGoalState(path, data, ok)
2837 }
2838
2839 type autoResearchSetup struct {
2840 taskID string
2841 createToken string
2842 blockReason string
2843 notice string
2844 created bool
2845 }
2846
2847 func (c *Controller) prepareAutoResearchTask(goal string, researchMode GoalResearchMode, createToken string) autoResearchSetup {
2848 goal = strings.TrimSpace(goal)
2849 if goal == "" || c.autoResearch == nil || !shouldUseAutoResearch(goal, researchMode) {
2850 return autoResearchSetup{}
2851 }
2852 currentGoal, currentStatus, _, currentTaskID := c.goals.snapshot()
2853 if strings.TrimSpace(currentGoal) == goal && currentStatus == GoalStatusRunning && strings.TrimSpace(currentTaskID) != "" {
2854 return autoResearchSetup{taskID: currentTaskID}
2855 }
2856 if task, ok, err := c.autoResearch.ResumeFromGoalText(goal); err != nil {
2857 slog.Warn("controller: resume autoresearch task", "err", err)
2858 if ok {
2859 return autoResearchSetup{blockReason: err.Error()}
2860 }
2861 } else if ok {
2862 return autoResearchSetup{taskID: task.ID, notice: "autoresearch task resumed: " + task.ID}
2863 }
2864 task, err := c.autoResearch.CreateTask(goal, autoresearch.CreateOptions{
2865 CreateToken: createToken,
2866 AllowedOperations: autoresearch.AllowedOperations{
2867 Write: true,
2868 Network: false,
2869 Publish: false,
2870 },
2871 SuccessCriteria: defaultAutoResearchSuccessCriteria(),
2872 })
2873 if err != nil {
2874 slog.Warn("controller: create autoresearch task", "err", err)
2875 return autoResearchSetup{}
2876 }
2877 return autoResearchSetup{
2878 taskID: task.ID,
2879 createToken: task.CreateToken,
2880 notice: "autoresearch task created: " + task.ID,
2881 created: true,
2882 }
2883 }
2884
2885 func defaultAutoResearchSuccessCriteria() []autoresearch.SuccessCriterion {
2886 return []autoresearch.SuccessCriterion{
2887 {
2888 ID: "objective_evidence",
2889 Description: "The goal outcome is supported by direct evidence, such as inspected code, reproduced behavior, source material, or concrete findings.",
2890 Required: true,
2891 },
2892 {
2893 ID: "verification",
2894 Description: "The result has relevant verification evidence, such as tests, commands, benchmarks, manual checks, or a documented reason why verification is not applicable.",
2895 Required: true,
2896 },
2897 }
2898 }
2899
2900 func (c *Controller) appendAutoResearchHeartbeat(taskID, status, message string) {
2901 if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
2902 return
2903 }
2904 iteration := 0
2905 if summary, err := c.autoResearch.Summary(taskID); err == nil {
2906 iteration = summary.Iteration
2907 }
2908 if err := c.autoResearch.AppendHeartbeat(taskID, autoresearch.Heartbeat{
2909 Status: status,
2910 Iteration: iteration,
2911 Message: message,
2912 CreatedAt: time.Now().UTC(),
2913 }); err != nil {
2914 slog.Warn("controller: append autoresearch heartbeat", "task_id", taskID, "status", status, "err", err)
2915 }
2916 }
2917
2918 func (c *Controller) autoResearchAcceptedEvidenceIDs(taskID string) map[string]bool {
2919 if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
2920 return nil
2921 }
2922 findings, err := c.autoResearch.Findings(taskID, 0)
2923 if err != nil {
2924 slog.Warn("controller: read autoresearch findings", "task_id", taskID, "err", err)
2925 return nil
2926 }
2927 accepted := make(map[string]bool, len(findings))
2928 for _, finding := range findings {
2929 if finding.Accepted {
2930 accepted[finding.ID] = true
2931 }
2932 }
2933 return accepted
2934 }
2935
2936 func (c *Controller) recordAutoResearchTurnProgress(taskID string, acceptedBefore map[string]bool) {
2937 if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
2938 return
2939 }
2940 acceptedAfter := c.autoResearchAcceptedEvidenceIDs(taskID)
2941 newAccepted := make([]string, 0)
2942 for id := range acceptedAfter {
2943 if acceptedBefore == nil || !acceptedBefore[id] {
2944 newAccepted = append(newAccepted, id)
2945 }
2946 }
2947 sort.Strings(newAccepted)
2948 summary := autoResearchDirectionSummary(lastAssistantText(c.History()))
2949 if _, err := c.autoResearch.RecordDirection(taskID, autoresearch.Direction{
2950 Summary: summary,
2951 AcceptedEvidenceIDs: newAccepted,
2952 Now: time.Now().UTC(),
2953 }); err != nil {
2954 slog.Warn("controller: record autoresearch direction", "task_id", taskID, "err", err)
2955 }
2956 }
2957
2958 func (c *Controller) recordAutoResearchEvidenceFromAssistant(taskID, text string) {
2959 if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
2960 return
2961 }
2962 for _, item := range parseAutoResearchEvidenceBlocks(text) {
2963 if err := c.recordAutoResearchEvidenceForTask(taskID, item.CriterionID, AutoResearchEvidenceInput{
2964 ID: item.ID,
2965 Kind: item.Kind,
2966 Summary: item.Summary,
2967 Source: item.Source,
2968 Command: item.Command,
2969 Paths: append([]string(nil), item.Paths...),
2970 Accepted: item.Accepted,
2971 }); err != nil {
2972 slog.Warn("controller: record autoresearch evidence block", "task_id", taskID, "criterion_id", item.CriterionID, "err", err)
2973 }
2974 }
2975 }
2976
2977 type autoResearchEvidenceBlock struct {
2978 CriterionID string `json:"criterion_id"`
2979 ID string `json:"id"`
2980 Kind string `json:"kind"`
2981 Summary string `json:"summary"`
2982 Source string `json:"source"`
2983 Command string `json:"command"`
2984 Paths []string `json:"paths"`
2985 Accepted bool `json:"accepted"`
2986 }
2987
2988 const (
2989 autoResearchEvidenceOpen = "<autoresearch-evidence>"
2990 autoResearchEvidenceClose = "</autoresearch-evidence>"
2991 )
2992
2993 func parseAutoResearchEvidenceBlocks(text string) []autoResearchEvidenceBlock {
2994 var out []autoResearchEvidenceBlock
2995 rest := text
2996 for {
2997 start := strings.Index(rest, autoResearchEvidenceOpen)
2998 if start < 0 {
2999 return out
3000 }
3001 rest = rest[start+len(autoResearchEvidenceOpen):]
3002 end := strings.Index(rest, autoResearchEvidenceClose)
3003 if end < 0 {
3004 return out
3005 }
3006 raw := strings.TrimSpace(rest[:end])
3007 rest = rest[end+len(autoResearchEvidenceClose):]
3008 if raw == "" {
3009 continue
3010 }
3011 var many []autoResearchEvidenceBlock
3012 if err := json.Unmarshal([]byte(raw), &many); err == nil {
3013 out = append(out, many...)
3014 continue
3015 }
3016 var one autoResearchEvidenceBlock
3017 if err := json.Unmarshal([]byte(raw), &one); err == nil {
3018 out = append(out, one)
3019 }
3020 }
3021 }
3022
3023 func autoResearchDirectionSummary(text string) string {
3024 text = agent.StripAutoResearchEvidenceBlocks(text)
3025 for _, line := range strings.Split(text, "\n") {
3026 line = strings.TrimSpace(line)
3027 lower := strings.ToLower(line)
3028 if line == "" || strings.HasPrefix(lower, "[goal:") {
3029 continue
3030 }
3031 if len(line) > 160 {
3032 line = line[:160]
3033 }
3034 return line
3035 }
3036 return "turn completed"
3037 }
3038
3039 func (c *Controller) autoResearchReadinessFailure() string {
3040 taskID := c.goals.currentAutoResearchTaskID()
3041 if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
3042 return ""
3043 }
3044 report, err := c.autoResearch.Readiness(taskID)
3045 if err != nil {
3046 return "AutoResearch readiness check failed: " + err.Error()
3047 }
3048 if report.Ready {
3049 return ""
3050 }
3051 var parts []string
3052 if len(report.MissingCriteria) > 0 {
3053 parts = append(parts, "missing criteria: "+strings.Join(report.MissingCriteria, ", "))
3054 }
3055 if report.BlockedReason != "" {
3056 parts = append(parts, "blocked: "+report.BlockedReason)
3057 }
3058 if len(report.Errors) > 0 {
3059 parts = append(parts, "state errors: "+strings.Join(report.Errors, "; "))
3060 }
3061 if len(parts) == 0 {
3062 parts = append(parts, "task is not ready")
3063 }
3064 return "AutoResearch readiness check failed: " + strings.Join(parts, "; ")
3065 }
3066
3067 func (c *Controller) AutoResearchSummary() (*autoresearch.Summary, bool) {
3068 taskID := c.goals.currentAutoResearchTaskID()
3069 if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
3070 return nil, false
3071 }
3072 summary, err := c.autoResearch.Summary(taskID)
3073 if err != nil {
3074 return &autoresearch.Summary{
3075 TaskID: taskID,
3076 Status: autoresearch.StatusInvalid,
3077 Blocker: err.Error(),
3078 }, true
3079 }
3080 return summary, true
3081 }
3082
3083 func (c *Controller) AutoResearchList() ([]autoresearch.Summary, bool) {
3084 if c.autoResearch == nil {
3085 return nil, false
3086 }
3087 summaries, err := c.autoResearch.ListSummaries()
3088 if err != nil {
3089 slog.Warn("controller: list autoresearch tasks", "err", err)
3090 return nil, true
3091 }
3092 return summaries, true
3093 }
3094
3095 func (c *Controller) AutoResearchFindings(limit int) ([]autoresearch.Finding, bool) {
3096 taskID := c.goals.currentAutoResearchTaskID()
3097 if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
3098 return nil, false
3099 }
3100 findings, err := c.autoResearch.Findings(taskID, limit)
3101 if err != nil {
3102 return nil, true
3103 }
3104 return findings, true
3105 }
3106
3107 func (c *Controller) RecordAutoResearchEvidence(criterionID string, input AutoResearchEvidenceInput) error {
3108 taskID := c.goals.currentAutoResearchTaskID()
3109 if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
3110 return errors.New("autoresearch: no active task")
3111 }
3112 return c.recordAutoResearchEvidenceForTask(taskID, criterionID, input)
3113 }
3114
3115 func (c *Controller) recordAutoResearchEvidenceForTask(taskID, criterionID string, input AutoResearchEvidenceInput) error {
3116 if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
3117 return errors.New("autoresearch: no active task")
3118 }
3119 id := strings.TrimSpace(input.ID)
3120 if id == "" {
3121 id = c.nextAutoResearchFindingID(taskID)
3122 }
3123 kind := strings.TrimSpace(input.Kind)
3124 if kind == "" {
3125 kind = autoresearch.FindingKindManual
3126 }
3127 source := strings.TrimSpace(input.Source)
3128 if source == "" {
3129 source = autoresearch.FindingSourceManual
3130 }
3131 finding := autoresearch.Finding{
3132 ID: id,
3133 Kind: kind,
3134 Summary: strings.TrimSpace(input.Summary),
3135 Source: source,
3136 Command: strings.TrimSpace(input.Command),
3137 Paths: append([]string(nil), input.Paths...),
3138 Accepted: input.Accepted,
3139 CreatedAt: time.Now().UTC(),
3140 }
3141 return c.autoResearch.RecordEvidence(taskID, criterionID, finding)
3142 }
3143
3144 func (c *Controller) nextAutoResearchFindingID(taskID string) string {
3145 findings, err := c.autoResearch.Findings(taskID, 0)
3146 if err != nil {
3147 return fmt.Sprintf("f%d", time.Now().UTC().UnixNano())
3148 }
3149 used := make(map[string]bool, len(findings))
3150 for _, finding := range findings {
3151 used[finding.ID] = true
3152 }
3153 for i := 1; ; i++ {
3154 id := fmt.Sprintf("f%d", len(findings)+i)
3155 if !used[id] {
3156 return id
3157 }
3158 }
3159 }
3160
3161 func (c *Controller) ClearGoal() {
3162 c.SetGoal("")
3163 }
3164
3165 func (c *Controller) Goal() string {
3166 return c.goals.goalText()
3167 }
3168
3169 func (c *Controller) GoalStatus() string {
3170 return c.goals.statusForDisplay()
3171 }
3172
3173 // Compact runs one compaction pass on the executor's session on demand.
3174 // instructions is optional `/compact <focus>` guidance steering what to keep.
3175 func (c *Controller) Compact(ctx context.Context, instructions string) error {
3176 if c.executor == nil {
3177 return nil
3178 }
3179 // The run loop is the only sanctioned writer of the live session during a
3180 // turn; a manual compact would rewrite the log underneath it. The rotation
3181 // gate (not a bare Running() check) also blocks a turn from starting while
3182 // the compaction rewrites the session — see beginRotation.
3183 if err := c.beginRotation(); err != nil {
3184 if errors.Is(err, errTurnRunningRotation) {
3185 return fmt.Errorf("cannot compact while a turn is running")
3186 }
3187 return err
3188 }
3189 defer c.endRotation()
3190 return c.executor.CompactNow(ctx, instructions)
3191 }
3192
3193 // maybeSessionStart fires the SessionStart hook exactly once per session, lazily
3194 // on the first turn — by then the sink/notify is wired, and a resumed session
3195 // fires it too (its first post-resume turn).
3196 func (c *Controller) maybeSessionStart(ctx context.Context) {
3197 c.hooks.SetSessionID(c.parentSessionID())
3198 c.mu.Lock()
3199 if c.startedOnce {
3200 c.mu.Unlock()
3201 return
3202 }
3203 c.startedOnce = true
3204 c.mu.Unlock()
3205 c.enqueueHookContexts(c.hooks.SessionStart(ctx))
3206 c.extensionSessionEvent(extension.PointSessionStart, dispatch.PhaseStart, c.SessionPath())
3207 }
3208
3209 // NewSession snapshots the current conversation, rotates to a fresh file, and
3210 // resets the executor to a clean session carrying the same system prompt. It
3211 // ends the old session and starts the new one for lifecycle hooks.
3212 func (c *Controller) NewSession() error {
3213 if c.executor == nil {
3214 return nil
3215 }
3216 // Claim the rotation gate for the whole snapshot-then-swap sequence. A bare
3217 // `if c.running` check released before Snapshot() left a window where a turn
3218 // could start during the snapshot and then have its live session replaced by
3219 // the SetSession below. Submit ("/new") and the bot gateway call this
3220 // asynchronously, so the gate is load-bearing, not defensive.
3221 if err := c.beginRotation(); err != nil {
3222 return err
3223 }
3224 defer c.endRotation()
3225 // Retire asynchronous recovery writes before Snapshot publishes the final
3226 // old-session checkpoint. Otherwise an earlier write can outlive the path
3227 // rotation (or process teardown) and race cleanup of the old session.
3228 oldPath := c.SessionPath()
3229 c.flushRecoveryPersistence(oldPath)
3230 if err := c.Snapshot(); err != nil {
3231 return err
3232 }
3233 // session.rotate: the session_policy owner rules on the rotation before
3234 // anything is torn down, so its failure (required-class) aborts the
3235 // rotation cleanly. SessionPath is the file being rotated away from; the
3236 // fresh path arrives with the session.start event below.
3237 if err := c.extensionSessionPhase(context.Background(), extension.PointSessionRotate, dispatch.PhaseRotate, oldPath); err != nil {
3238 return err
3239 }
3240 c.hooks.SessionEnd(context.Background(), "clear")
3241 c.extensionSessionEvent(extension.PointSessionEnd, dispatch.PhaseEnd, oldPath)
3242 // Hold snapshotMu across the swap so an in-flight save cannot pair the old
3243 // path with the fresh session (or the fresh path with the old session).
3244 c.snapshotMu.Lock()
3245 if c.sessionDir != "" {
3246 c.mu.Lock()
3247 c.sessionPath = agent.NewSessionPath(c.sessionDir, c.label)
3248 c.guardianPath = guardian.PathFor(c.sessionPath)
3249 c.mu.Unlock()
3250 }
3251 c.setActiveJobSession(c.SessionPath())
3252 c.executor.SetSession(agent.NewSession(c.systemPrompt))
3253 if c.guardianSess != nil {
3254 c.guardianSess.Reset()
3255 }
3256 c.ResetPlannerSession()
3257 freshPath := c.SessionPath()
3258 c.rebindCheckpoints(freshPath)
3259 c.resetRecoveryForNewSession(freshPath)
3260 c.rotateSessionTemp()
3261 c.snapshotMu.Unlock()
3262 // A new session starts with no active goal: without this, a running goal's
3263 // text kept injecting into the fresh session's first turns. The old
3264 // session's goal-state sidecar was persisted before the rotation and stays
3265 // intact, so resuming it restores its goal; the cleared state below lands
3266 // on the NEW path (rebindCheckpoints just moved it).
3267 c.ClearGoal()
3268 c.mu.Lock()
3269 c.startedOnce = true // NewSession fires SessionStart itself; don't re-fire on the next turn
3270 c.mu.Unlock()
3271 c.hooks.SetSessionID(c.parentSessionID())
3272 c.enqueueHookContexts(c.hooks.SessionStart(context.Background(), "clear"))
3273 c.extensionSessionEvent(extension.PointSessionStart, dispatch.PhaseStart, c.SessionPath())
3274 return nil
3275 }
3276
3277 // ClearSession discards the current conversation without preserving it in
3278 // resume/history, then rotates to a clean session carrying the same system prompt.
3279 func (c *Controller) ClearSession() error {
3280 if c.executor == nil {
3281 return nil
3282 }
3283 // Same rotation gate as NewSession: hold it across the whole
3284 // destroy-then-swap so a turn cannot start during the sequence and have its
3285 // live session replaced.
3286 if err := c.beginRotation(); err != nil {
3287 if errors.Is(err, errTurnRunningRotation) {
3288 return fmt.Errorf("cannot clear while a turn is running")
3289 }
3290 return err
3291 }
3292 defer c.endRotation()
3293 c.mu.Lock()
3294 oldPath := c.sessionPath
3295 c.mu.Unlock()
3296 preMarkedCleanup := c.hasUnfinishedSessionJobs(oldPath)
3297 if preMarkedCleanup {
3298 if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil {
3299 return err
3300 }
3301 }
3302 // Retire the old recovery state before deleting its artifacts. Async gate
3303 // snapshots are path-bound, so wait for every already-scheduled old-path
3304 // write; otherwise one can recreate the sidecar after removeSessionArtifacts.
3305 c.loadRecoveryState("")
3306 c.flushRecoveryPersistence(oldPath)
3307 // session.rotate: the session_policy owner rules on the rotation before any
3308 // artifact is destroyed, so its failure (required-class) aborts the clear
3309 // with the old session fully intact. SessionPath is the file being rotated
3310 // away from; the fresh path arrives with the session.start event below.
3311 if err := c.extensionSessionPhase(context.Background(), extension.PointSessionRotate, dispatch.PhaseRotate, oldPath); err != nil {
3312 return err
3313 }
3314 // Hold snapshotMu from artifact removal through the swap: a save slipping
3315 // in between would resurrect the just-removed transcript, and one that
3316 // overlapped the swap could pair the old path with the fresh session.
3317 c.snapshotMu.Lock()
3318 destroy := c.BeginDestroySession(oldPath)
3319 if !destroy.Async {
3320 if err := removeSessionArtifacts(oldPath); err != nil {
3321 destroy.Finish()
3322 c.snapshotMu.Unlock()
3323 return err
3324 }
3325 destroy.Finish()
3326 }
3327 c.hooks.SessionEnd(context.Background(), "clear")
3328 c.extensionSessionEvent(extension.PointSessionEnd, dispatch.PhaseEnd, oldPath)
3329 if c.sessionDir != "" {
3330 c.mu.Lock()
3331 c.sessionPath = agent.NewSessionPath(c.sessionDir, c.label)
3332 c.guardianPath = guardian.PathFor(c.sessionPath)
3333 c.mu.Unlock()
3334 }
3335 c.setActiveJobSession(c.SessionPath())
3336 c.executor.SetSession(agent.NewSession(c.systemPrompt))
3337 if c.guardianSess != nil {
3338 c.guardianSess.Reset()
3339 }
3340 c.ResetPlannerSession()
3341 freshPath := c.SessionPath()
3342 c.rebindCheckpoints(freshPath)
3343 c.resetRecoveryForNewSession(freshPath)
3344 c.rotateSessionTemp()
3345 c.snapshotMu.Unlock()
3346 // Same contract as NewSession: the fresh session starts with no active goal.
3347 c.ClearGoal()
3348 c.mu.Lock()
3349 c.startedOnce = true
3350 c.mu.Unlock()
3351 c.hooks.SetSessionID(c.parentSessionID())
3352 c.enqueueHookContexts(c.hooks.SessionStart(context.Background(), "clear"))
3353 c.extensionSessionEvent(extension.PointSessionStart, dispatch.PhaseStart, c.SessionPath())
3354 if destroy.Async {
3355 go func() {
3356 result := destroy.Wait()
3357 if result.HasTimedOut() && destroy.WaitAll != nil {
3358 if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil {
3359 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "mark cleanup pending failed: " + err.Error()})
3360 }
3361 destroy.WaitAll()
3362 }
3363 if err := removeSessionArtifacts(oldPath); err != nil {
3364 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "clear session cleanup failed: " + err.Error()})
3365 }
3366 destroy.Finish()
3367 }()
3368 }
3369 return nil
3370 }
3371
3372 func (c *Controller) hasUnfinishedSessionJobs(sessionPath string) bool {
3373 if c.jobs == nil {
3374 return false
3375 }
3376 return c.jobs.HasUnfinishedForSession(agent.BranchID(sessionPath))
3377 }
3378
3379 func removeSessionArtifacts(path string) error {
3380 if path == "" {
3381 return nil
3382 }
3383 if err := jobs.RemoveArtifacts(path); err != nil {
3384 return err
3385 }
3386 remove := []string{path}
3387 // Sidecars include the event log — the authoritative transcript. Leaving
3388 // it behind would both leak the cleared conversation and let LoadSession
3389 // resurrect it on the recycled path. The guardian transcript saves through
3390 // the same session layer, so its sidecars are swept too.
3391 remove = append(remove, store.SessionSidecarFiles(path)...)
3392 remove = append(remove, guardian.PathFor(path), guardian.CursorPathFor(path))
3393 remove = append(remove, store.SessionSidecarFiles(guardian.PathFor(path))...)
3394 for _, p := range remove {
3395 if p == "" {
3396 continue
3397 }
3398 if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
3399 return err
3400 }
3401 }
3402 if dir := ckptDir(path); dir != "" {
3403 if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) {
3404 return err
3405 }
3406 }
3407 if err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {
3408 return err
3409 }
3410 if err := agent.ClearCleanupPending(path); err != nil {
3411 return err
3412 }
3413 return nil
3414 }
3415
3416 // RemoveSessionArtifacts removes a transcript and every durable artifact owned
3417 // by it. Remote runtimes use this when a newly-created fork fails before it can
3418 // be registered as a live session.
3419 func RemoveSessionArtifacts(path string) error {
3420 return removeSessionArtifacts(path)
3421 }
3422
3423 // ReconcileCleanupPending retries physical cleanup for logically removed
3424 // sessions that were left behind by a previous process.
3425 func ReconcileCleanupPending(dir string) error {
3426 return agent.ReconcileCleanupPending(dir, func(item agent.CleanupPendingInfo) error {
3427 return removeSessionArtifacts(item.SessionPath)
3428 })
3429 }
3430
3431 // RewindScope selects what a Rewind restores.
3432 type RewindScope int
3433
3434 const (
3435 RewindCode RewindScope = iota // files only
3436 RewindConversation // message log only
3437 RewindBoth // both
3438 )
3439
3440 // Checkpoints lists the session's rewind points (one per user turn), oldest first.
3441 //
3442 // Each Meta.Prompt is reduced to what the user typed. A checkpoint opens with
3443 // the composed turn, so the stored prompt can carry the plan-mode marker and
3444 // transient blocks; every consumer of this list is a label (the rewind picker,
3445 // the desktop change list, the workbench projection) and the picker also
3446 // restores the prompt into the composer, so composed text must not reach them.
3447 // Stripping on read rather than only on write keeps checkpoints already on disk
3448 // readable — they were recorded composed.
3449 func (c *Controller) Checkpoints() []checkpoint.Meta {
3450 metas := c.checkpoints.list()
3451 for i := range metas {
3452 metas[i].Prompt = StripComposePrefixes(metas[i].Prompt)
3453 }
3454 return metas
3455 }
3456
3457 func (c *Controller) CheckpointFileState(path string) (checkpoint.FileState, bool) {
3458 return c.checkpoints.fileState(path)
3459 }
3460
3461 func (c *Controller) CheckpointTurnsByMessageIndex() map[int]int {
3462 return c.checkpoints.turnsByMessageIndex()
3463 }
3464
3465 // rewindFail emits the error as a Warn notice (so a frontend that swallows the
3466 // returned error — e.g. the desktop bridge's .catch — still shows the user why
3467 // the rewind did nothing) and returns it.
3468 func (c *Controller) rewindFail(err error) error {
3469 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: err.Error()})
3470 return err
3471 }
3472
3473 // Rewind is implemented in rewind.go (transactional conversation+file restore).
3474
3475 // Fork branches the conversation at the start of turn into a NEW session file,
3476 // preserving the current one as the branch point, and switches to the branch. Code
3477 // is untouched (it's a conversation operation). Like a conversation rewind it needs
3478 // the live boundary, so it is unavailable for resumed-session turns and refused
3479 // while a turn runs. Returns the new session path.
3480 func (c *Controller) Fork(turn int) (string, error) {
3481 return c.ForkNamed(turn, "")
3482 }
3483
3484 func (c *Controller) ForkNamed(turn int, name string) (string, error) {
3485 return c.forkNamed(turn, name, true)
3486 }
3487
3488 // ForkSession copies the conversation at the start of turn into a new session
3489 // file without switching this controller to it. Desktop uses this to open the
3490 // branch in a new tab while the source tab keeps its current transcript.
3491 func (c *Controller) ForkSession(turn int, name string) (string, error) {
3492 return c.forkNamed(turn, name, false)
3493 }
3494
3495 func (c *Controller) forkNamed(turn int, name string, switchToFork bool) (string, error) {
3496 if c.executor == nil {
3497 return "", c.rewindFail(fmt.Errorf("checkpoints unavailable"))
3498 }
3499 if c.sessionDir == "" {
3500 return "", c.rewindFail(fmt.Errorf("fork needs session persistence, which is disabled"))
3501 }
3502 // Hold the rotation gate from before the pre-fork Snapshot through the
3503 // switch below: a bare Running() check released here would let a turn start
3504 // during the snapshot and then be switched onto the fork.
3505 if err := c.beginRotation(); err != nil {
3506 if errors.Is(err, errTurnRunningRotation) {
3507 return "", c.rewindFail(fmt.Errorf("cannot fork while a turn is running"))
3508 }
3509 return "", c.rewindFail(err)
3510 }
3511 defer c.endRotation()
3512 boundary, hasBound := c.checkpoints.boundary(turn)
3513 if !hasBound {
3514 return "", c.rewindFail(fmt.Errorf("fork unavailable for turn %d (resumed session)", turn))
3515 }
3516
3517 // Persist the current conversation first so the branch point survives, then
3518 // seed a fresh session with the messages up to the fork and switch to it.
3519 if err := c.Snapshot(); err != nil {
3520 slog.Warn("controller: pre-fork snapshot", "err", err)
3521 }
3522 parentPath := c.SessionPath()
3523 parentID := agent.BranchID(parentPath)
3524 src := c.executor.Session().Snapshot()
3525 if boundary > len(src) {
3526 boundary = len(src)
3527 }
3528 forked := append([]provider.Message(nil), src[:boundary]...)
3529 sess := agent.NewSession("")
3530 sess.Messages = forked
3531
3532 newPath := agent.NewSessionPath(c.sessionDir, c.label)
3533 if err := sess.Save(newPath); err != nil {
3534 return "", c.rewindFail(err)
3535 }
3536 forkPreview, forkTurns := agent.SessionPreviewFromMessages(forked)
3537 if err := agent.SaveBranchMeta(newPath, agent.BranchMeta{
3538 Name: strings.TrimSpace(name),
3539 ParentID: parentID,
3540 ForkTurn: turn,
3541 ForkMessageIndex: boundary,
3542 Preview: forkPreview,
3543 Turns: forkTurns,
3544 SchemaVersion: agent.BranchMetaCountsVersion,
3545 }); err != nil {
3546 return "", c.rewindFail(err)
3547 }
3548 if switchToFork {
3549 // See snapshotMu: the swap must not interleave with an in-flight save.
3550 c.snapshotMu.Lock()
3551 c.executor.SetSession(sess)
3552 c.ResetPlannerSession()
3553 c.mu.Lock()
3554 c.sessionPath = newPath
3555 c.guardianPath = guardian.PathFor(newPath)
3556 c.mu.Unlock()
3557 c.setActiveJobSession(newPath)
3558 c.rebindCheckpoints(newPath)
3559 // A historical fork rewinds before later failures, so it starts with no
3560 // active recovery event even though it inherits the session preference.
3561 c.loadRecoveryState(newPath)
3562 if c.guardianSess != nil {
3563 c.guardianSess.Reset()
3564 }
3565 // Switching into the fork is a new logical session for temporary files.
3566 c.rotateSessionTemp()
3567 c.snapshotMu.Unlock()
3568 }
3569 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
3570 Text: fmt.Sprintf("forked conversation at turn %d into a new session", turn)})
3571 return newPath, nil
3572 }
3573
3574 func (c *Controller) CheckpointHasBoundary(turn int) bool {
3575 boundary, ok := c.checkpoints.boundary(turn)
3576 if !ok {
3577 return false
3578 }
3579 // After compaction the key may still exist but the boundary value is
3580 // stale (it points past the truncated message log). Treat those
3581 // turns the same as "no boundary" so the UI can disable the button.
3582 // Len is lock-guarded: this runs on frontend goroutines while a turn appends.
3583 return boundary <= c.executor.Session().Len()
3584 }
3585
3586 // Branch copies the current conversation into a child branch and switches to it.
3587 // Unlike Fork, it branches at the current tip and does not require a checkpoint.
3588 func (c *Controller) Branch(name string) (string, error) {
3589 if c.executor == nil {
3590 return "", c.rewindFail(fmt.Errorf("branch unavailable"))
3591 }
3592 if c.sessionDir == "" {
3593 return "", c.rewindFail(fmt.Errorf("branch needs session persistence, which is disabled"))
3594 }
3595 // Hold the rotation gate across the Snapshot and the switch below so a turn
3596 // cannot start mid-branch and then have its session replaced.
3597 if err := c.beginRotation(); err != nil {
3598 if errors.Is(err, errTurnRunningRotation) {
3599 return "", c.rewindFail(fmt.Errorf("cannot branch while a turn is running"))
3600 }
3601 return "", c.rewindFail(err)
3602 }
3603 defer c.endRotation()
3604 if !c.executor.Session().HasContent() {
3605 return "", c.rewindFail(fmt.Errorf("nothing to branch yet"))
3606 }
3607 if err := c.Snapshot(); err != nil {
3608 return "", c.rewindFail(err)
3609 }
3610 parentPath := c.SessionPath()
3611 parentID := agent.BranchID(parentPath)
3612 src := c.executor.Session().Snapshot()
3613 branched := append([]provider.Message(nil), src...)
3614 sess := agent.NewSession("")
3615 sess.Messages = branched
3616
3617 newPath := agent.NewSessionPath(c.sessionDir, c.label)
3618 if err := sess.Save(newPath); err != nil {
3619 return "", c.rewindFail(err)
3620 }
3621 branchPreview, branchTurns := agent.SessionPreviewFromMessages(branched)
3622 if err := agent.SaveBranchMeta(newPath, agent.BranchMeta{
3623 Name: strings.TrimSpace(name),
3624 ParentID: parentID,
3625 ForkTurn: -1,
3626 ForkMessageIndex: len(branched),
3627 Preview: branchPreview,
3628 Turns: branchTurns,
3629 SchemaVersion: agent.BranchMetaCountsVersion,
3630 }); err != nil {
3631 return "", c.rewindFail(err)
3632 }
3633 // See snapshotMu: the swap must not interleave with an in-flight save.
3634 c.snapshotMu.Lock()
3635 c.executor.SetSession(sess)
3636 c.ResetPlannerSession()
3637 c.mu.Lock()
3638 c.sessionPath = newPath
3639 c.guardianPath = guardian.PathFor(newPath)
3640 c.mu.Unlock()
3641 c.setActiveJobSession(newPath)
3642 c.rebindCheckpoints(newPath)
3643 if c.guardianSess != nil {
3644 c.guardianSess.Reset()
3645 }
3646 c.carryRecoveryState(newPath)
3647 c.rotateSessionTemp()
3648 c.snapshotMu.Unlock()
3649 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
3650 Text: fmt.Sprintf("created branch %s", agent.BranchID(newPath))})
3651 return newPath, nil
3652 }
3653
3654 // Branches lists saved conversation branches in this controller's session dir.
3655 func (c *Controller) Branches() ([]agent.BranchInfo, error) {
3656 if c.sessionDir == "" {
3657 return nil, fmt.Errorf("session persistence is disabled")
3658 }
3659 if err := c.Snapshot(); err != nil {
3660 return nil, err
3661 }
3662 return agent.ListBranches(c.sessionDir)
3663 }
3664
3665 func (c *Controller) SwitchBranch(ref string) (agent.BranchInfo, error) {
3666 ref = strings.TrimSpace(ref)
3667 if ref == "" {
3668 return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("usage: /switch <branch id|name>"))
3669 }
3670 // Hold the rotation gate across the branch listing/load and the switch so a
3671 // turn cannot start between the check and the SetSession below.
3672 if err := c.beginRotation(); err != nil {
3673 if errors.Is(err, errTurnRunningRotation) {
3674 return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("cannot switch branches while a turn is running"))
3675 }
3676 return agent.BranchInfo{}, c.rewindFail(err)
3677 }
3678 defer c.endRotation()
3679 branches, err := c.Branches()
3680 if err != nil {
3681 return agent.BranchInfo{}, c.rewindFail(err)
3682 }
3683 match, err := resolveBranch(branches, ref)
3684 if err != nil {
3685 return agent.BranchInfo{}, c.rewindFail(err)
3686 }
3687 if !agent.IsVisibleSession(match.Path) {
3688 return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("branch %q not found", ref))
3689 }
3690 loaded, err := agent.LoadSession(match.Path)
3691 if err != nil {
3692 return agent.BranchInfo{}, c.rewindFail(err)
3693 }
3694 // See snapshotMu: the swap must not interleave with an in-flight save.
3695 c.snapshotMu.Lock()
3696 if c.executor != nil {
3697 c.executor.SetSession(loaded)
3698 }
3699 c.ResetPlannerSession()
3700 c.mu.Lock()
3701 c.sessionPath = match.Path
3702 c.guardianPath = guardian.PathFor(match.Path)
3703 c.mu.Unlock()
3704 c.setActiveJobSession(match.Path)
3705 c.rebindCheckpoints(match.Path)
3706 c.restoreTerminalGoalTodos(match.Path)
3707 c.loadGuardianSession()
3708 c.loadRecoveryState(match.Path)
3709 c.rotateSessionTemp()
3710 c.snapshotMu.Unlock()
3711 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
3712 Text: fmt.Sprintf("switched to branch %s", branchDisplayName(match))})
3713 return match, nil
3714 }
3715
3716 // ResolveBranchRef resolves a /switch-style branch reference (id, unique
3717 // prefix, name, or path) against a branch listing, using the same matching
3718 // rules as SwitchBranch. Frontends use it to learn the target session path
3719 // before switching — e.g. to move their session lease first.
3720 func ResolveBranchRef(branches []agent.BranchInfo, ref string) (agent.BranchInfo, error) {
3721 return resolveBranch(branches, strings.TrimSpace(ref))
3722 }
3723
3724 func resolveBranch(branches []agent.BranchInfo, ref string) (agent.BranchInfo, error) {
3725 refLower := strings.ToLower(ref)
3726 var matches []agent.BranchInfo
3727 for _, b := range branches {
3728 nameLower := strings.ToLower(strings.TrimSpace(b.Name))
3729 switch {
3730 case b.ID == ref || strings.EqualFold(b.ID, ref):
3731 return b, nil
3732 case b.Name != "" && nameLower == refLower:
3733 matches = append(matches, b)
3734 case strings.HasPrefix(strings.ToLower(b.ID), refLower):
3735 matches = append(matches, b)
3736 case strings.HasPrefix(strings.ToLower(shortBranchID(b.ID)), refLower):
3737 matches = append(matches, b)
3738 case b.Path == ref:
3739 return b, nil
3740 }
3741 }
3742 if len(matches) == 1 {
3743 return matches[0], nil
3744 }
3745 if len(matches) > 1 {
3746 return agent.BranchInfo{}, fmt.Errorf("branch %q is ambiguous", ref)
3747 }
3748 return agent.BranchInfo{}, fmt.Errorf("branch %q not found", ref)
3749 }
3750
3751 func branchDisplayName(b agent.BranchInfo) string {
3752 if strings.TrimSpace(b.Name) != "" {
3753 return fmt.Sprintf("%s (%s)", b.Name, b.ID)
3754 }
3755 return b.ID
3756 }
3757
3758 // SummarizeFrom compresses the conversation from turn onward into one summary;
3759 // SummarizeUpTo compresses everything before it. Both are Claude Code's "summarize
3760 // from/up to here" — they restructure the message log (keeping code untouched), so
3761 // afterwards the per-turn boundaries no longer map and conversation rewind/fork
3762 // report "unavailable" until new turns rebuild them (code rewind, file-based, is
3763 // unaffected). Refused while a turn runs; need the live boundary.
3764 func (c *Controller) SummarizeFrom(ctx context.Context, turn int) error {
3765 return c.summarizeAt(ctx, turn, true)
3766 }
3767
3768 func (c *Controller) SummarizeUpTo(ctx context.Context, turn int) error {
3769 return c.summarizeAt(ctx, turn, false)
3770 }
3771
3772 func (c *Controller) summarizeAt(ctx context.Context, turn int, from bool) error {
3773 if c.executor == nil {
3774 return c.rewindFail(fmt.Errorf("checkpoints unavailable"))
3775 }
3776 // Summarize rewrites the live session AFTER a provider round-trip, so the
3777 // bare Running() check left a seconds-wide window for a turn to start and
3778 // then have the log replaced under it. Hold the rotation gate from the
3779 // boundary read through the post-rewrite snapshot.
3780 if err := c.beginRotation(); err != nil {
3781 if errors.Is(err, errTurnRunningRotation) {
3782 return c.rewindFail(fmt.Errorf("cannot summarize while a turn is running"))
3783 }
3784 return c.rewindFail(err)
3785 }
3786 defer c.endRotation()
3787 boundary, hasBound := c.checkpoints.boundary(turn)
3788 if !hasBound {
3789 return c.rewindFail(fmt.Errorf("summarize unavailable for turn %d (resumed session)", turn))
3790 }
3791 var err error
3792 if from {
3793 err = c.executor.SummarizeFrom(ctx, boundary)
3794 } else {
3795 err = c.executor.SummarizeUpTo(ctx, boundary)
3796 }
3797 if err != nil {
3798 return c.rewindFail(err)
3799 }
3800 // The log was restructured; existing boundaries no longer map. Drop them (keep
3801 // the turn counter monotonic so new turns don't collide with the store) —
3802 // conversation rewind degrades to "unavailable" until fresh turns rebuild them.
3803 c.checkpoints.clearBounds()
3804 atomic.AddInt64(&c.sessionRevision, 1)
3805 if err := c.SnapshotRewrite(); err != nil {
3806 slog.Warn("controller: post-summarize snapshot", "err", err)
3807 }
3808 return nil
3809 }
3810
3811 // Resume seeds the session from a loaded transcript and pins the active file to
3812 // its path so auto-save keeps appending there.
3813 //
3814 // When the controller already has a different non-empty session path, Resume
3815 // rotates the private temporary generation so the loaded conversation cannot
3816 // see the previous session's temporary files. Same-path Resume (hot rebuild
3817 // migration via AdoptHistory) keeps the generation.
3818 func (c *Controller) Resume(s *agent.Session, path string) {
3819 // See snapshotMu: the swap must not interleave with an in-flight save.
3820 // recoverInterruptedTurn and maybeColdResumePrune snapshot on their own,
3821 // so they stay outside the locked section (snapshotMu is not reentrant).
3822 prevPath := c.SessionPath()
3823 c.snapshotMu.Lock()
3824 if c.executor != nil {
3825 c.executor.SetSession(s)
3826 }
3827 c.ResetPlannerSession()
3828 c.mu.Lock()
3829 c.sessionPath = path
3830 c.guardianPath = guardian.PathFor(path)
3831 c.mu.Unlock()
3832 c.setActiveJobSession(path)
3833 c.rebindCheckpoints(path)
3834 if migPath, migData, migrated := c.goals.restoreFromState(path); migrated {
3835 // Persist legacy budget_tokens → running (and tokensLimit=0) so the
3836 // next cold start does not re-enter the removed hard-limit pause.
3837 // restoreFromState never issues a provider request.
3838 c.persistGoalState(migPath, migData, true)
3839 }
3840 if c.executor != nil {
3841 c.executor.RestoreDeliveryCheckpoint(c.goals.deliveryState())
3842 }
3843 c.restoreTerminalGoalTodos(path)
3844 c.loadGuardianSession()
3845 c.loadRecoveryState(path)
3846 if shouldRotateSessionTempOnResume(prevPath, path) {
3847 c.rotateSessionTemp()
3848 }
3849 c.snapshotMu.Unlock()
3850 c.recoverCheckpointTransactions()
3851 c.recoverInterruptedTurn(path)
3852 c.maybeColdResumePrune(path)
3853 // session.load: Resume has no failure channel, so the session_policy
3854 // strategy is advisory this stage — a required-class failure is surfaced
3855 // as a warning and the load stands. The event still carries the final
3856 // (possibly owner-adjusted) phase payload.
3857 if err := c.extensionSessionPhase(context.Background(), extension.PointSessionLoad, dispatch.PhaseLoad, path); err != nil {
3858 c.extensionWarn("session policy failed at session.load", err)
3859 }
3860 }
3861
3862 func shouldRotateSessionTempOnResume(prevPath, nextPath string) bool {
3863 prevPath = strings.TrimSpace(prevPath)
3864 nextPath = strings.TrimSpace(nextPath)
3865 if prevPath == "" || nextPath == "" {
3866 return false
3867 }
3868 return filepath.Clean(prevPath) != filepath.Clean(nextPath)
3869 }
3870
3871 func (c *Controller) loadGuardianSession() {
3872 if c.guardianSess == nil {
3873 return
3874 }
3875 c.guardianSess.Reset()
3876 path := c.guardianPath
3877 if path == "" {
3878 return
3879 }
3880 if err := c.guardianSess.Load(path); err != nil && !os.IsNotExist(err) {
3881 slog.Warn("controller: load guardian session", "err", err)
3882 }
3883 }
3884
3885 // ResetPlannerSession clears the planner's conversation history so the next
3886 // plan starts fresh. In dual-model (Plan+Execute) mode, this prevents stale
3887 // planner output from a previous session or tab from contaminating the current
3888 // executor's handoff. Safe to call on a single-model controller (no-op).
3889 func (c *Controller) ResetPlannerSession() {
3890 runner, ok := c.runner.(plannerSessionResetter)
3891 if ok {
3892 runner.ResetPlannerSession()
3893 }
3894 }
3895
3896 // cacheColdAfter resolves how long the active provider keeps a prompt prefix
3897 // cached. A session idle longer than this resumes against a cold cache, so a
3898 // history rewrite at that moment costs no extra cache misses — it only shrinks
3899 // the full-price first request. The TTL is vendor-aware: DeepSeek/unknown
3900 // 24h (legacy default deliberately preserved), DashScope 5m, Anthropic 5m.
3901 // Users can override per-provider
3902 // with cache_ttl_minutes in config.toml.
3903 func (c *Controller) cacheColdAfter() time.Duration {
3904 if c.testCacheColdAfter != 0 {
3905 if c.testCacheColdAfter == -1 {
3906 return 0
3907 }
3908 return c.testCacheColdAfter
3909 }
3910 // 查询路径只读:LoadForRootReadOnly 不触发配置迁移写盘(评审 #7168
3911 // 第 4 点);失败时保守回退 24h(DeepSeek/未知 vendor 默认),避免
3912 // 提前触发 PruneStaleToolResults 改写仍可命中的缓存历史。
3913 cfg, err := config.LoadForRootReadOnly(c.workspaceRoot)
3914 if err != nil {
3915 return 24 * time.Hour
3916 }
3917 ref := c.modelRef
3918 if ref == "" {
3919 ref = cfg.DefaultModel
3920 }
3921 entry, ok := cfg.ResolveModel(ref)
3922 if !ok {
3923 return 24 * time.Hour
3924 }
3925 return entry.EffectiveCacheTTL()
3926 }
3927
3928 // maybeColdResumePrune elides stale tool results when a resumed session has
3929 // been idle past the provider's cache retention, then persists the pruned
3930 // transcript so the saved file and the prompt stay in sync.
3931 func (c *Controller) maybeColdResumePrune(path string) {
3932 if c.disableColdResumePrune || c.executor == nil || path == "" {
3933 return
3934 }
3935 // Idle time comes from branch meta only — every session the controller has
3936 // ever snapshotted carries one. A meta-less transcript (e.g. a legacy import
3937 // not yet saved) skips the prune until its first snapshot creates the meta.
3938 m, ok, err := agent.LoadBranchMeta(path)
3939 if err != nil || !ok || m.UpdatedAt.IsZero() {
3940 return
3941 }
3942 last := m.UpdatedAt
3943 if time.Since(last) < c.cacheColdAfter() {
3944 return
3945 }
3946 st, err := c.executor.PruneStaleToolResults()
3947 if err != nil || st.Results == 0 {
3948 return
3949 }
3950 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(
3951 "resumed after %s idle (provider cache expired) — elided %d stale tool results to cheapen the cold restart",
3952 time.Since(last).Round(time.Minute), st.Results)})
3953 if err := c.SnapshotRewrite(); err != nil {
3954 slog.Warn("controller: post-prune snapshot", "err", err)
3955 }
3956 }
3957
3958 // Snapshot writes the executor's conversation to the active session file. No-op
3959 // when the executor is absent or the session has never been used (no user
3960 // interaction). Returns errNoSessionPath when there IS content but no resolved
3961 // path, so a misconfigured deployment surfaces instead of dropping data.
3962 // Called after every turn so a crash loses at most one in-flight prompt.
3963 func (c *Controller) Snapshot() error {
3964 return c.snapshot(false, false, false)
3965 }
3966
3967 // SnapshotForShutdown performs the final session snapshot and, only when the
3968 // compatibility file lock remains held for the full bounded wait, persists the
3969 // in-memory transcript to a distinct recovery branch before teardown proceeds.
3970 // Other snapshot errors retain their normal behavior and remain visible to the
3971 // caller.
3972 func (c *Controller) SnapshotForShutdown() error {
3973 return c.snapshot(false, false, true)
3974 }
3975
3976 // SnapshotActivity writes the active conversation and marks the session as
3977 // recently active. Use it only after a real user/model turn changes the
3978 // transcript; switch/close snapshots should call Snapshot so they do not reorder
3979 // recent-session pickers.
3980 func (c *Controller) SnapshotActivity() error {
3981 return c.snapshot(true, false, false)
3982 }
3983
3984 // SnapshotRewrite persists an intentional history rewrite, such as rewind or
3985 // manual compaction. Ordinary autosave paths should use Snapshot so stale
3986 // controllers cannot overwrite a newer transcript.
3987 func (c *Controller) SnapshotRewrite() error {
3988 return c.snapshot(false, true, false)
3989 }
3990
3991 // midTurnSnapshotInterval is atomic (nanoseconds) so a test shrinking it
3992 // cannot race a previous test's still-parking autosave goroutine.
3993 var midTurnSnapshotInterval atomic.Int64
3994
3995 func init() { midTurnSnapshotInterval.Store(int64(30 * time.Second)) }
3996
3997 // autosaveWhileRunning snapshots the session periodically while a turn runs,
3998 // so an abrupt kill (SSH drop, force-quit) loses at most one interval of a
3999 // long turn instead of all of it (#3772). Session.Save copies under the lock
4000 // and replaces the file atomically, so racing the turn's appends is safe.
4001 func (c *Controller) autosaveWhileRunning(ctx context.Context) {
4002 t := time.NewTicker(time.Duration(midTurnSnapshotInterval.Load()))
4003 defer t.Stop()
4004 for {
4005 select {
4006 case <-ctx.Done():
4007 return
4008 case <-t.C:
4009 if err := c.snapshot(false, false, false); err != nil {
4010 slog.Warn("controller: mid-turn snapshot", "err", err)
4011 }
4012 }
4013 }
4014 }
4015
4016 func (c *Controller) snapshot(markActivity, forceRewrite, shutdownRecovery bool) error {
4017 c.snapshotMu.Lock()
4018 defer c.snapshotMu.Unlock()
4019
4020 c.mu.Lock()
4021 path := c.sessionPath
4022 modelRef := c.modelRef
4023 c.mu.Unlock()
4024 if c.executor == nil {
4025 return nil
4026 }
4027 s := c.executor.Session()
4028 if !s.HasContent() {
4029 // Nothing to persist yet (e.g. a fresh session with only a system
4030 // prompt) — staying quiet here is correct, not a data-loss path.
4031 return nil
4032 }
4033 if !s.HasSystemMessage() {
4034 // The session has user/assistant/tool messages but no leading system
4035 // prompt. Persisting it would create a session file that, when
4036 // reloaded, has no agent-identity contract — the model falls back to
4037 // its training-data defaults, giving wrong answers to identity
4038 // queries ("who are you?"). Log the anomaly so the root cause
4039 // (typically an empty sysPrompt reaching NewSession) can be
4040 // diagnosed, then refuse to write a corrupted transcript.
4041 slog.Warn("controller: refusing to snapshot session with content but no system message",
4042 "label", c.Label(), "session_dir", c.SessionDir(), "message_count", len(s.Snapshot()))
4043 return nil
4044 }
4045 if path == "" {
4046 // There IS content but nowhere to write it: this silently dropped whole
4047 // bot conversations (#4414). Surface it loudly instead of returning nil
4048 // so the missing session path can be diagnosed and fixed at the source.
4049 slog.Warn("controller: session has content but no session path; conversation will not be persisted",
4050 "label", c.Label(), "session_dir", c.SessionDir())
4051 return errNoSessionPath
4052 }
4053 // session.save: the session_policy owner rules on the impending save; a
4054 // failure (required-class) vetoes the write. The event goes out after a
4055 // successful save carrying the final payload. The early no-content and
4056 // no-path returns above are not saves and stay unobserved. Conflict
4057 // recovery below may rewrite the path; the phase payload reports the path
4058 // the save targeted.
4059 savePayload, strategyErr := c.extensionSessionStrategy(context.Background(), extension.PointSessionSave, dispatch.PhaseSave, path)
4060 if strategyErr != nil {
4061 return strategyErr
4062 }
4063 forceRewrite = forceRewrite || s.NeedsRewriteSave()
4064 var err error
4065 if forceRewrite {
4066 err = s.SaveRewrite(path)
4067 } else {
4068 err = s.SaveSnapshot(path)
4069 if errors.Is(err, agent.ErrSessionSnapshotConflict) {
4070 // The no-rewrite decision may already be stale: auto-compaction
4071 // can rewrite history between the decision and the write. Re-check
4072 // and retry once as an owned rewrite before treating the failure as
4073 // a real cross-runtime conflict.
4074 if s.NeedsRewriteSave() {
4075 forceRewrite = true
4076 err = s.SaveRewrite(path)
4077 }
4078 }
4079 }
4080 if err != nil {
4081 if shutdownRecovery && errors.Is(err, agent.ErrSessionFileLockHeld) {
4082 recoveredPath, recoverErr := c.recoverShutdownSnapshot(path, err)
4083 if recoverErr != nil {
4084 return recoverErr
4085 }
4086 path = recoveredPath
4087 s = c.executor.Session()
4088 err = nil
4089 }
4090 }
4091 if err != nil {
4092 if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
4093 return err
4094 }
4095 recoveredPath, outcome, recoverErr := c.recoverSnapshotConflict(path, err, forceRewrite)
4096 if recoverErr != nil {
4097 if shutdownRecovery && errors.Is(recoverErr, agent.ErrSessionFileLockHeld) {
4098 recoveredPath, recoverErr = c.recoverShutdownSnapshot(path, recoverErr)
4099 if recoverErr != nil {
4100 return recoverErr
4101 }
4102 path = recoveredPath
4103 s = c.executor.Session()
4104 } else {
4105 return recoverErr
4106 }
4107 } else {
4108 if outcome == conflictDropped {
4109 return nil
4110 }
4111 // Whatever recovery did — adopted the disk transcript, force-saved
4112 // the depth-capped branch, or forked — the rewrite baseline lives on
4113 // the session object and was advanced by the save that succeeded, so
4114 // there is nothing to re-anchor here.
4115 path = recoveredPath
4116 s = c.executor.Session()
4117 }
4118 }
4119 // Persist guardian session so the prefix cache stays warm after restart.
4120 if c.guardianSess != nil {
4121 gp := c.guardianPath
4122 if gp != "" {
4123 if gerr := c.guardianSess.Save(gp); gerr != nil {
4124 slog.Warn("controller: guardian snapshot", "err", gerr)
4125 }
4126 }
4127 }
4128 // Persist recovery gate state so unresolved checkpoints survive restart.
4129 c.saveRecoveryState(path)
4130 // Record the listing-only sidecar fields (model, preview, user-turn count)
4131 // straight from the in-memory conversation, so the sidebar and resume picker
4132 // never have to decode the whole .jsonl just to show them. markActivity bumps
4133 // UpdatedAt exactly like the previous TouchBranchMeta did; false preserves it
4134 // like SetBranchModelPreserveUpdated. The single write subsumes the old
4135 // EnsureBranchMeta / SetBranchModel / TouchBranchMeta sequence.
4136 preview, turns := agent.SessionPreviewFromMessages(s.Snapshot())
4137 if err := agent.UpdateSessionMeta(path, modelRef, preview, turns, markActivity); err != nil {
4138 return err
4139 }
4140 c.extensionSessionPayloadEvent(extension.PointSessionSave, savePayload)
4141 return nil
4142 }
4143
4144 // snapshotConflictLogAttrs flattens a snapshot-conflict error into slog attrs.
4145 // Field reports of #6069-class "session changed on disk" spam are only
4146 // diagnosable when the logs say which trigger fired and what the revision
4147 // ledger looked like, so every recoverSnapshotConflict outcome logs these.
4148 func snapshotConflictLogAttrs(saveErr error, path, mode string) []any {
4149 attrs := []any{"path", path, "mode", mode}
4150 var conflict *agent.SessionSnapshotConflictError
4151 if errors.As(saveErr, &conflict) && conflict != nil {
4152 attrs = append(attrs,
4153 "kind", string(conflict.Kind),
4154 "disk_messages", conflict.ExistingMessages,
4155 "snapshot_messages", conflict.SnapshotMessages,
4156 "base_revision", conflict.BaseRevision,
4157 "disk_revision", conflict.DiskRevision,
4158 )
4159 }
4160 return attrs
4161 }
4162
4163 type snapshotConflictDiagnostic struct {
4164 At time.Time `json:"at"`
4165 BranchID string `json:"branch_id"`
4166 Mode string `json:"mode"`
4167 Outcome string `json:"outcome"`
4168 Kind string `json:"kind,omitempty"`
4169 DiskMessages int `json:"disk_messages,omitempty"`
4170 SnapshotMessages int `json:"snapshot_messages,omitempty"`
4171 BaseRevision int64 `json:"base_revision,omitempty"`
4172 DiskRevision int64 `json:"disk_revision,omitempty"`
4173 RecoveryBranchID string `json:"recovery_branch_id,omitempty"`
4174 ExistingRecovery bool `json:"existing_recovery,omitempty"`
4175 }
4176
4177 func appendSnapshotConflictDiagnostic(path, mode, outcome string, saveErr error, recoveryPath string, existing bool) {
4178 path = strings.TrimSpace(path)
4179 if path == "" {
4180 return
4181 }
4182 rec := snapshotConflictDiagnostic{
4183 At: time.Now(),
4184 BranchID: agent.BranchID(path),
4185 Mode: mode,
4186 Outcome: outcome,
4187 }
4188 var conflict *agent.SessionSnapshotConflictError
4189 if errors.As(saveErr, &conflict) && conflict != nil {
4190 rec.Kind = string(conflict.Kind)
4191 rec.DiskMessages = conflict.ExistingMessages
4192 rec.SnapshotMessages = conflict.SnapshotMessages
4193 rec.BaseRevision = conflict.BaseRevision
4194 rec.DiskRevision = conflict.DiskRevision
4195 }
4196 if recoveryPath != "" {
4197 rec.RecoveryBranchID = agent.BranchID(recoveryPath)
4198 rec.ExistingRecovery = existing
4199 }
4200 data, err := json.Marshal(rec)
4201 if err != nil {
4202 return
4203 }
4204 logPath := store.SessionConflictLog(path)
4205 if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
4206 return
4207 }
4208 f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
4209 if err != nil {
4210 return
4211 }
4212 defer f.Close()
4213 _, _ = f.Write(append(data, '\n'))
4214 }
4215
4216 // conflictOutcome is recoverSnapshotConflict's declared result. Callers act
4217 // on it directly instead of re-deriving what happened from path or session
4218 // pointer comparisons — the misclassification that broke the depth-cap
4219 // rewrite baseline (#6120) hid in exactly that inference.
4220 type conflictOutcome int
4221
4222 const (
4223 // conflictDropped: nothing was recovered and the disk transcript could
4224 // not be adopted; this snapshot was deliberately dropped.
4225 conflictDropped conflictOutcome = iota
4226 // conflictAdoptedDisk: the executor session object was replaced by the
4227 // newer disk transcript; adoptDiskSession already reset its baselines.
4228 conflictAdoptedDisk
4229 // conflictForceSavedBranch: recovery depth was exhausted and the same
4230 // in-memory session was force-saved onto the same branch; that save
4231 // advanced the session-owned rewrite baseline like any other full save.
4232 conflictForceSavedBranch
4233 // conflictForkedBranch: the same in-memory session moved to a freshly
4234 // forked recovery branch path.
4235 conflictForkedBranch
4236 )
4237
4238 const recoveryDepthCapNoticeText = "repeated save conflicts were detected; saved the current conflict copy in place"
4239
4240 func sessionRecoveryNotice(code, text string) event.Event {
4241 return event.Event{
4242 Kind: event.Notice,
4243 Level: event.LevelWarn,
4244 Audience: event.NoticeAudienceOperator,
4245 Code: code,
4246 Text: text,
4247 }
4248 }
4249
4250 func (c *Controller) emitRecoveryDepthCapNotice(path string) {
4251 key := filepath.Clean(strings.TrimSpace(path))
4252 c.mu.Lock()
4253 if c.recoveryDepthCapNotices == nil {
4254 c.recoveryDepthCapNotices = make(map[string]bool)
4255 }
4256 if c.recoveryDepthCapNotices[key] {
4257 c.mu.Unlock()
4258 return
4259 }
4260 c.recoveryDepthCapNotices[key] = true
4261 c.mu.Unlock()
4262 c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionRecoveryDepthCap, recoveryDepthCapNoticeText))
4263 }
4264
4265 func (c *Controller) recoverSnapshotConflict(path string, saveErr error, forceRewrite bool) (string, conflictOutcome, error) {
4266 if c.executor == nil || strings.TrimSpace(path) == "" {
4267 return "", conflictDropped, saveErr
4268 }
4269 mode := "snapshot"
4270 if forceRewrite {
4271 mode = "rewrite"
4272 }
4273 logAttrs := snapshotConflictLogAttrs(saveErr, path, mode)
4274 if kind, ok := agent.SnapshotConflictKind(saveErr); ok && kind == agent.SessionSnapshotConflictStalePrefix {
4275 if c.adoptDiskSession(path) {
4276 appendSnapshotConflictDiagnostic(path, mode, "adopted_newer_disk_transcript", saveErr, "", false)
4277 slog.Warn("controller: snapshot conflict; adopted newer disk transcript", logAttrs...)
4278 c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionRecoveryAdopted,
4279 "session changed on disk; adopted the newer transcript"))
4280 return path, conflictAdoptedDisk, nil
4281 }
4282 }
4283 reason := "snapshot conflict"
4284 if forceRewrite {
4285 reason = "rewrite conflict"
4286 }
4287 req := SessionRecoveryRequest{OriginalPath: path, Reason: reason, Mode: mode}
4288 meta := agent.BranchMeta{}
4289 if c.sessionRecoveryMeta != nil {
4290 meta = c.sessionRecoveryMeta(req)
4291 }
4292 info, err := c.executor.Session().SaveRecoveryBranch(agent.RecoveryBranchOptions{
4293 OriginalPath: path,
4294 Reason: reason,
4295 BranchMeta: meta,
4296 })
4297 if err != nil {
4298 if errors.Is(err, agent.ErrSessionRecoveryDepthExceeded) {
4299 // Saves keep conflicting on recovery branches this runtime itself
4300 // created; forking again multiplies session files without
4301 // converging (#5993 reached 8 nested levels). This runtime is the
4302 // only writer of its own recovery branches, so force-writing the
4303 // transcript back onto the current branch keeps the data and
4304 // stops the chain.
4305 if forceErr := c.executor.Session().Save(path); forceErr != nil {
4306 return "", conflictDropped, fmt.Errorf("recovery chain depth exceeded; force save failed: %w", forceErr)
4307 }
4308 appendSnapshotConflictDiagnostic(path, mode, "recovery_depth_cap_force_saved", saveErr, path, false)
4309 slog.Warn("controller: snapshot conflict; recovery depth cap reached, force-saved onto current branch", logAttrs...)
4310 c.emitRecoveryDepthCapNotice(path)
4311 return path, conflictForceSavedBranch, nil
4312 }
4313 if errors.Is(err, agent.ErrSessionRecoveryNotNeeded) {
4314 if c.adoptDiskSession(path) {
4315 appendSnapshotConflictDiagnostic(path, mode, "recovery_not_needed_adopted_disk_transcript", saveErr, "", false)
4316 slog.Warn("controller: snapshot conflict; recovery not needed, adopted disk transcript", logAttrs...)
4317 c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionRecoveryAdoptedCovered,
4318 "session changed on disk; adopted the newer transcript (local changes already covered)"))
4319 return path, conflictAdoptedDisk, nil
4320 }
4321 // Nothing was recovered AND the disk transcript could not be
4322 // adopted: the snapshot is silently dropped. Leave a trace so
4323 // "my last turns vanished" reports can be tied to this path.
4324 appendSnapshotConflictDiagnostic(path, mode, "recovery_not_needed_adopt_failed", saveErr, "", false)
4325 slog.Warn("controller: snapshot conflict; recovery not needed but disk transcript could not be adopted", logAttrs...)
4326 return "", conflictDropped, nil
4327 }
4328 return "", conflictDropped, fmt.Errorf("recover stale session snapshot: %w", err)
4329 }
4330 if err := c.commitRecoveredSession(path, reason, info); err != nil {
4331 return "", conflictDropped, err
4332 }
4333 appendSnapshotConflictDiagnostic(path, mode, "forked_recovery_branch", saveErr, info.Path, info.Existing)
4334 slog.Warn("controller: snapshot conflict; forked recovery branch",
4335 append(logAttrs, "recovery", info.Path, "existing", info.Existing)...)
4336 c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionRecoveryForked,
4337 "session changed on disk; unsaved local transcript was saved as a conflict copy"))
4338 return info.Path, conflictForkedBranch, nil
4339 }
4340
4341 func (c *Controller) recoverShutdownSnapshot(path string, saveErr error) (string, error) {
4342 if c.executor == nil || strings.TrimSpace(path) == "" {
4343 return "", saveErr
4344 }
4345 const reason = "shutdown session file lock timeout"
4346 req := SessionRecoveryRequest{OriginalPath: path, Reason: reason, Mode: "shutdown"}
4347 meta := agent.BranchMeta{}
4348 if c.sessionRecoveryMeta != nil {
4349 meta = c.sessionRecoveryMeta(req)
4350 }
4351 info, err := c.executor.Session().SaveShutdownRecoveryBranch(agent.RecoveryBranchOptions{
4352 OriginalPath: path,
4353 Reason: reason,
4354 BranchMeta: meta,
4355 })
4356 if err != nil {
4357 return "", fmt.Errorf("save shutdown recovery branch: %w", err)
4358 }
4359 if err := c.commitRecoveredSession(path, reason, info); err != nil {
4360 return "", err
4361 }
4362 appendSnapshotConflictDiagnostic(path, "shutdown", "forked_file_lock_recovery", saveErr, info.Path, info.Existing)
4363 slog.Warn("controller: shutdown snapshot lock timed out; forked recovery branch",
4364 "path", path, "recovery", info.Path, "existing", info.Existing)
4365 c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionShutdownRecoveryForked,
4366 "session file stayed busy during shutdown; unsaved transcript was saved as a recovery copy"))
4367 return info.Path, nil
4368 }
4369
4370 func (c *Controller) commitRecoveredSession(originalPath, reason string, info agent.RecoveryBranchInfo) error {
4371 recoveryInfo := SessionRecoveryInfo{
4372 OriginalPath: originalPath,
4373 RecoveryPath: info.Path,
4374 Existing: info.Existing,
4375 Reason: reason,
4376 Meta: info.Meta,
4377 }
4378 if onSessionRecovered := c.sessionRecoveredHandler(); onSessionRecovered != nil {
4379 if err := onSessionRecovered(recoveryInfo); err != nil {
4380 return fmt.Errorf("commit recovered session: %w", err)
4381 }
4382 }
4383 c.mu.Lock()
4384 c.sessionPath = info.Path
4385 c.guardianPath = guardian.PathFor(info.Path)
4386 c.mu.Unlock()
4387 c.setActiveJobSession(info.Path)
4388 c.rebindCheckpoints(info.Path)
4389 c.transplantInFlightTurnMarker(originalPath, info.Path)
4390 return nil
4391 }
4392
4393 func (c *Controller) adoptDiskSession(path string) bool {
4394 loaded, err := agent.LoadSession(path)
4395 if err != nil || loaded == nil {
4396 return false
4397 }
4398 c.executor.SetSession(loaded)
4399 c.ResetPlannerSession()
4400 c.rebindCheckpoints(path)
4401 c.setActiveJobSession(path)
4402 return true
4403 }
4404
4405 func (c *Controller) messageCount() int {
4406 if c.executor == nil {
4407 return 0
4408 }
4409 return c.executor.Session().Len()
4410 }
4411
4412 func (c *Controller) markInFlightTurn(startMessageIndex int, preserveUser bool) {
4413 path := c.SessionPath()
4414 if path == "" {
4415 return
4416 }
4417 if err := agent.MarkSessionInFlightTurn(path, startMessageIndex, preserveUser); err != nil {
4418 slog.Warn("controller: mark in-flight turn", "err", err)
4419 }
4420 }
4421
4422 func (c *Controller) clearInFlightTurn() {
4423 path := c.SessionPath()
4424 if path == "" {
4425 return
4426 }
4427 if err := agent.ClearSessionInFlightTurn(path); err != nil {
4428 slog.Warn("controller: clear in-flight turn", "err", err)
4429 }
4430 }
4431
4432 // transplantInFlightTurnMarker moves a pending in-flight-turn marker from the
4433 // session path a recovery fork abandoned onto the branch the turn continues
4434 // on. Left behind, the stale marker would fire recoverInterruptedTurn on the
4435 // next open of the original branch and strip messages from a turn that in
4436 // fact kept running on the recovery branch; missing from the recovery branch,
4437 // a crash before turn end would leave its partial tail unmarked.
4438 func (c *Controller) transplantInFlightTurnMarker(fromPath, toPath string) {
4439 if strings.TrimSpace(fromPath) == "" || strings.TrimSpace(toPath) == "" || fromPath == toPath {
4440 return
4441 }
4442 meta, ok, err := agent.LoadBranchMeta(fromPath)
4443 if err != nil || !ok || meta.InFlightTurn == nil {
4444 if err != nil {
4445 slog.Warn("controller: load in-flight turn marker for transplant", "path", fromPath, "err", err)
4446 }
4447 return
4448 }
4449 marker := meta.InFlightTurn
4450 if err := agent.SetSessionInFlightTurn(toPath, *marker); err != nil {
4451 // Keep the original marker: a turn boundary on the wrong branch beats
4452 // no boundary anywhere if the runtime dies before the turn completes.
4453 slog.Warn("controller: transplant in-flight turn marker", "path", toPath, "err", err)
4454 return
4455 }
4456 if err := agent.ClearSessionInFlightTurn(fromPath); err != nil {
4457 slog.Warn("controller: clear in-flight turn marker on forked-from branch", "path", fromPath, "err", err)
4458 }
4459 }
4460
4461 func (c *Controller) recoverInterruptedTurn(path string) {
4462 if c.executor == nil || path == "" {
4463 return
4464 }
4465 meta, ok, err := agent.LoadBranchMeta(path)
4466 if err != nil || !ok || meta.InFlightTurn == nil {
4467 if err != nil {
4468 slog.Warn("controller: load in-flight turn marker", "err", err)
4469 }
4470 return
4471 }
4472 marker := meta.InFlightTurn
4473 if interruptedTurnContinuedOnRecoveryBranch(path, marker) {
4474 // The "interrupted" turn did not die with a runtime: a recovery branch
4475 // forked off this session after the marker was set, so the turn kept
4476 // running (and completing) there. Runtimes predating the marker
4477 // transplant in recoverSnapshotConflict left the marker behind on the
4478 // forked-from branch; stripping now would truncate a transcript the
4479 // completed turn already superseded. Clear the stale marker instead.
4480 if err := agent.ClearSessionInFlightTurn(path); err != nil {
4481 slog.Warn("controller: clear fork-orphaned in-flight turn", "err", err)
4482 }
4483 return
4484 }
4485 msgs := c.executor.Session().Snapshot()
4486 start, found := resolveInterruptedTurnStart(msgs, marker.StartMessageIndex, marker.PreserveUser, marker.StartedAt, provider.Message{})
4487 changed := found && len(msgs) > start
4488 if changed {
4489 if marker.PreserveUser {
4490 c.stripCancelledVisibleTurnMessagesAfterWithFallbackAt(start, provider.Message{}, marker.StartedAt)
4491 } else {
4492 c.stripTurnMessagesAfter(start)
4493 }
4494 if err := c.snapshot(false, true, false); err != nil {
4495 slog.Warn("controller: post-interrupted-turn snapshot", "err", err)
4496 }
4497 }
4498 if err := agent.ClearSessionInFlightTurn(path); err != nil {
4499 slog.Warn("controller: clear stale in-flight turn", "err", err)
4500 }
4501 }
4502
4503 // interruptedTurnContinuedOnRecoveryBranch reports whether a recovery branch
4504 // forked off path after its in-flight-turn marker was set. Markers only exist
4505 // while a turn runs and recovery forks happen on saves, so a child recovery
4506 // branch younger than the marker means the marked turn itself moved there —
4507 // the marker is a leftover from a runtime that switched paths mid-turn, not a
4508 // crashed turn whose partial tail needs stripping. A marker without a start
4509 // time is treated as continued whenever any recovery child exists: erring
4510 // toward keeping messages is the data-safe direction.
4511 func interruptedTurnContinuedOnRecoveryBranch(path string, marker *agent.InFlightTurnMeta) bool {
4512 if marker == nil {
4513 return false
4514 }
4515 branches, err := agent.ListBranches(filepath.Dir(path))
4516 if err != nil {
4517 return false
4518 }
4519 id := agent.BranchID(path)
4520 for _, b := range branches {
4521 if b.Recovered && b.ParentID == id && b.CreatedAt.After(marker.StartedAt) {
4522 return true
4523 }
4524 }
4525 return false
4526 }
4527
4528 // stripTurnMessagesAfter truncates the executor's session to keep only messages
4529 // before the given index, discarding an incomplete synthetic turn (the synthetic
4530 // user prompt plus every assistant/tool message that followed).
4531 func (c *Controller) stripTurnMessagesAfter(idx int) {
4532 if c.executor == nil {
4533 return
4534 }
4535 msgs := c.executor.Session().Snapshot()
4536 if len(msgs) <= idx {
4537 return
4538 }
4539 c.replaceSessionAfterCancel(msgs[:idx])
4540 }
4541
4542 // stripInterruptedSyntheticTurnMessagesAfter relocates a synthetic turn after
4543 // an in-turn compaction has rewritten the pre-turn message index, then drops
4544 // that whole controller-created turn.
4545 func (c *Controller) stripInterruptedSyntheticTurnMessagesAfter(idx int) {
4546 if c.executor == nil {
4547 return
4548 }
4549 msgs := c.executor.Session().Snapshot()
4550 startedAt := c.inFlightTurnStartedAt()
4551 if start, ok := resolveInterruptedTurnStart(msgs, idx, false, startedAt, provider.Message{}); ok {
4552 idx = start
4553 }
4554 c.stripTurnMessagesAfter(idx)
4555 }
4556
4557 // stripCancelledVisibleTurnMessagesAfterWithFallback preserves the real user
4558 // prompt and fully paired tool rounds from a cancelled visible turn. Unsafe
4559 // assistant/tool fragments are retained as provider-excluded display history.
4560 // It also covers coordinator
4561 // cancellation before the executor has appended the visible user message. The
4562 // orchestrator owns that input, so it supplies the exact message rather than
4563 // letting cancellation infer the current turn from older transcript history.
4564 func (c *Controller) stripCancelledVisibleTurnMessagesAfterWithFallback(idx int, fallback provider.Message) {
4565 c.stripCancelledVisibleTurnMessagesAfterWithFallbackAt(idx, fallback, c.inFlightTurnStartedAt())
4566 }
4567
4568 func (c *Controller) stripCancelledVisibleTurnMessagesAfterWithFallbackAt(idx int, fallback provider.Message, startedAt time.Time) {
4569 if c.executor == nil {
4570 return
4571 }
4572 msgs := c.executor.Session().Snapshot()
4573 if start, ok := resolveInterruptedTurnStart(msgs, idx, true, startedAt, fallback); ok {
4574 idx = start
4575 }
4576 if idx < 0 {
4577 idx = 0
4578 }
4579 if idx > len(msgs) {
4580 idx = len(msgs)
4581 }
4582 next := append([]provider.Message{}, msgs[:idx]...)
4583 keptUser := false
4584 userEnd := idx
4585 for i, m := range msgs[idx:] {
4586 if m.Role != provider.RoleUser {
4587 continue
4588 }
4589 if IsSyntheticUserMessage(m.Content) {
4590 continue
4591 }
4592 if _, ok := agent.SteerText(m.Content); ok {
4593 continue
4594 }
4595 m.Content = StripComposePrefixes(m.Content)
4596 next = append(next, m)
4597 keptUser = true
4598 userEnd = idx + i + 1
4599 break
4600 }
4601 if !keptUser && fallback.Role == provider.RoleUser {
4602 fallback.Content = StripComposePrefixes(fallback.Content)
4603 if strings.TrimSpace(fallback.Content) != "" {
4604 fallback.Images = append([]string(nil), fallback.Images...)
4605 next = append(next, fallback)
4606 keptUser = true
4607 userEnd = idx
4608 }
4609 }
4610 if !keptUser && len(msgs) <= idx {
4611 return
4612 }
4613 recovery := &provider.InterruptedTurnRecovery{Pending: true}
4614 localIndexes := make([]int, 0, 1)
4615 for i := userEnd; i < len(msgs); {
4616 m := msgs[i]
4617 if m.LocalOnly {
4618 m.Role = provider.RoleTool
4619 m.ToolCallID = provider.LocalOnlyToolID
4620 m.Name = provider.LocalOnlyToolName
4621 m.InterruptedTurn = nil
4622 m.ToolCalls = displayOnlyToolCalls(m.ToolCalls)
4623 next = append(next, m)
4624 localIndexes = append(localIndexes, len(next)-1)
4625 recovery.DroppedPartialText = recovery.DroppedPartialText || strings.TrimSpace(m.Content) != ""
4626 recovery.DroppedPartialReasoning = recovery.DroppedPartialReasoning || strings.TrimSpace(m.ReasoningContent) != ""
4627 for _, call := range m.ToolCalls {
4628 recovery.InterruptedTools = appendUniqueString(recovery.InterruptedTools, call.Name)
4629 }
4630 i++
4631 continue
4632 }
4633 // Auto-compaction can install a digest between the pinned current user
4634 // message and its recent tool tail. It summarizes pre-turn/current work
4635 // that is no longer present verbatim, so keep it provider-visible rather
4636 // than silently dropping context during recovery.
4637 if agent.IsCompactionSummary(m) {
4638 next = append(next, m)
4639 i++
4640 continue
4641 }
4642 if end, ok := completeToolTurnEnd(msgs, i); ok {
4643 next = append(next, msgs[i:end]...)
4644 for k, call := range m.ToolCalls {
4645 if toolResultWasInterrupted(msgs[i+1+k].Content) {
4646 recovery.InterruptedTools = appendUniqueString(recovery.InterruptedTools, call.Name)
4647 continue
4648 }
4649 recovery.CompletedTools = append(recovery.CompletedTools, interruptedToolSummary(call))
4650 }
4651 i = end
4652 continue
4653 }
4654 switch m.Role {
4655 case provider.RoleAssistant:
4656 local := m
4657 local.Role = provider.RoleTool
4658 local.LocalOnly = true
4659 local.ToolCallID = provider.LocalOnlyToolID
4660 local.Name = provider.LocalOnlyToolName
4661 local.InterruptedTurn = nil
4662 local.ReasoningSignature = ""
4663 local.ToolCalls = displayOnlyToolCalls(local.ToolCalls)
4664 next = append(next, local)
4665 localIndexes = append(localIndexes, len(next)-1)
4666 recovery.DroppedPartialText = recovery.DroppedPartialText || strings.TrimSpace(local.Content) != ""
4667 recovery.DroppedPartialReasoning = recovery.DroppedPartialReasoning || strings.TrimSpace(local.ReasoningContent) != ""
4668 for _, call := range local.ToolCalls {
4669 recovery.InterruptedTools = appendUniqueString(recovery.InterruptedTools, call.Name)
4670 }
4671 case provider.RoleTool:
4672 local := m
4673 local.LocalOnly = true
4674 local.ToolCalls = []provider.ToolCall{{ID: m.ToolCallID, Name: m.Name}}
4675 recovery.InterruptedTools = appendUniqueString(recovery.InterruptedTools, m.Name)
4676 local.ToolCallID = provider.LocalOnlyToolID
4677 local.Name = provider.LocalOnlyToolName
4678 next = append(next, local)
4679 localIndexes = append(localIndexes, len(next)-1)
4680 }
4681 i++
4682 }
4683 if len(localIndexes) == 0 {
4684 next = append(next, provider.Message{
4685 Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID,
4686 Name: provider.LocalOnlyToolName, LocalOnly: true,
4687 })
4688 localIndexes = append(localIndexes, len(next)-1)
4689 }
4690 next[localIndexes[len(localIndexes)-1]].InterruptedTurn = recovery
4691 c.replaceSessionAfterCancel(next)
4692 }
4693
4694 func (c *Controller) inFlightTurnStartedAt() time.Time {
4695 path := c.SessionPath()
4696 if path == "" {
4697 return time.Time{}
4698 }
4699 meta, ok, err := agent.LoadBranchMeta(path)
4700 if err != nil || !ok || meta.InFlightTurn == nil {
4701 return time.Time{}
4702 }
4703 return meta.InFlightTurn.StartedAt
4704 }
4705
4706 // resolveInterruptedTurnStart turns the pre-run array index into a stable
4707 // boundary after compaction. New user messages carry a creation timestamp set
4708 // after the marker, and graceful cleanup also has the exact composed prompt as
4709 // a fallback. We only fall back to the legacy index when it still points at a
4710 // plausible turn-start user message, keeping recovery data-safe for older
4711 // sidecars without timestamps.
4712 func resolveInterruptedTurnStart(msgs []provider.Message, idx int, preserveUser bool, startedAt time.Time, fallback provider.Message) (int, bool) {
4713 fallbackContent := ""
4714 if fallback.Role == provider.RoleUser {
4715 fallbackContent = StripComposePrefixes(fallback.Content)
4716 }
4717 matchesKind := func(m provider.Message) bool {
4718 if m.Role != provider.RoleUser {
4719 return false
4720 }
4721 if preserveUser {
4722 if IsSyntheticUserMessage(m.Content) {
4723 return false
4724 }
4725 if _, ok := agent.SteerText(m.Content); ok {
4726 return false
4727 }
4728 if fallbackContent != "" && StripComposePrefixes(m.Content) != fallbackContent {
4729 return false
4730 }
4731 }
4732 return true
4733 }
4734 startedMillis := startedAt.UnixMilli()
4735 if !startedAt.IsZero() {
4736 for i, m := range msgs {
4737 if matchesKind(m) && m.CreatedAt >= startedMillis {
4738 return i, true
4739 }
4740 }
4741 }
4742 // Tests/headless runners may not persist an in-flight sidecar. The exact
4743 // graceful fallback still distinguishes the current visible turn; search
4744 // backward so a repeated prompt selects the newest occurrence.
4745 if fallbackContent != "" {
4746 for i := len(msgs) - 1; i >= 0; i-- {
4747 if matchesKind(msgs[i]) {
4748 return i, true
4749 }
4750 }
4751 }
4752 if idx >= 0 && idx < len(msgs) && matchesKind(msgs[idx]) {
4753 return idx, true
4754 }
4755 return 0, false
4756 }
4757
4758 func (c *Controller) hasInterruptedDisplayAfter(idx int, fallback provider.Message) bool {
4759 if c.executor == nil {
4760 return false
4761 }
4762 msgs := c.executor.Session().Snapshot()
4763 if start, ok := resolveInterruptedTurnStart(msgs, idx, true, c.inFlightTurnStartedAt(), fallback); ok {
4764 idx = start
4765 }
4766 idx = max(0, min(idx, len(msgs)))
4767 for _, m := range msgs[idx:] {
4768 if m.LocalOnly && m.InterruptedTurn != nil {
4769 return true
4770 }
4771 }
4772 return false
4773 }
4774
4775 func completeToolTurnEnd(msgs []provider.Message, i int) (int, bool) {
4776 if i < 0 || i >= len(msgs) {
4777 return i, false
4778 }
4779 m := msgs[i]
4780 if m.LocalOnly || m.Role != provider.RoleAssistant || len(m.ToolCalls) == 0 {
4781 return i, false
4782 }
4783 end := i + 1
4784 for end < len(msgs) && msgs[end].Role == provider.RoleTool && !msgs[end].LocalOnly {
4785 end++
4786 }
4787 results := msgs[i+1 : end]
4788 if len(results) != len(m.ToolCalls) {
4789 return i, false
4790 }
4791 for k, call := range m.ToolCalls {
4792 if strings.TrimSpace(call.Name) == "" || (call.Arguments != "" && !json.Valid([]byte(call.Arguments))) {
4793 return i, false
4794 }
4795 if results[k].ToolCallID != call.ID || results[k].Name != call.Name {
4796 return i, false
4797 }
4798 }
4799 return end, true
4800 }
4801
4802 func toolResultWasInterrupted(content string) bool {
4803 content = strings.ToLower(strings.TrimSpace(content))
4804 return strings.HasPrefix(content, "cancelled:") || strings.Contains(content, "context canceled") || strings.Contains(content, "context cancelled")
4805 }
4806
4807 func displayOnlyToolCalls(calls []provider.ToolCall) []provider.ToolCall {
4808 out := make([]provider.ToolCall, 0, len(calls))
4809 for _, call := range calls {
4810 out = append(out, provider.ToolCall{ID: call.ID, Name: strings.TrimSpace(call.Name)})
4811 }
4812 return out
4813 }
4814
4815 func appendUniqueString(dst []string, value string) []string {
4816 value = strings.TrimSpace(value)
4817 if value == "" {
4818 return dst
4819 }
4820 for _, existing := range dst {
4821 if existing == value {
4822 return dst
4823 }
4824 }
4825 return append(dst, value)
4826 }
4827
4828 func interruptedToolSummary(call provider.ToolCall) provider.InterruptedToolSummary {
4829 summary := provider.InterruptedToolSummary{
4830 ID: call.ID, Name: strings.TrimSpace(call.Name), Added: call.Added, Removed: call.Removed,
4831 }
4832 addFile := func(path string) {
4833 path = strings.TrimSpace(path)
4834 if path == "" || path == "/dev/null" || len(summary.Files) >= 8 {
4835 return
4836 }
4837 for _, existing := range summary.Files {
4838 if existing == path {
4839 return
4840 }
4841 }
4842 summary.Files = append(summary.Files, path)
4843 }
4844 var args map[string]any
4845 if json.Unmarshal([]byte(call.Arguments), &args) == nil {
4846 for _, key := range []string{"path", "file", "file_path", "filename"} {
4847 if value, ok := args[key].(string); ok && strings.TrimSpace(value) != "" {
4848 addFile(value)
4849 }
4850 }
4851 }
4852 for _, line := range strings.Split(call.Diff, "\n") {
4853 line = strings.TrimSpace(line)
4854 switch {
4855 case strings.HasPrefix(line, "+++ b/"):
4856 addFile(strings.TrimPrefix(line, "+++ b/"))
4857 case strings.HasPrefix(line, "--- a/"):
4858 addFile(strings.TrimPrefix(line, "--- a/"))
4859 case strings.HasPrefix(line, "*** Update File: "):
4860 addFile(strings.TrimPrefix(line, "*** Update File: "))
4861 case strings.HasPrefix(line, "*** Add File: "):
4862 addFile(strings.TrimPrefix(line, "*** Add File: "))
4863 case strings.HasPrefix(line, "*** Delete File: "):
4864 addFile(strings.TrimPrefix(line, "*** Delete File: "))
4865 }
4866 }
4867 return summary
4868 }
4869
4870 func (c *Controller) replaceSessionAfterCancel(msgs []provider.Message) {
4871 // The whole cleanup is a save/recovery handoff like snapshot's: hold
4872 // snapshotMu from the in-memory truncation onward. Truncating outside the
4873 // lock would let an in-flight save capture the shortened transcript, read
4874 // the longer partial autosave on disk as a stale-prefix conflict, and
4875 // adopt it back into the executor — silently undoing the cancel cleanup
4876 // before the flush below could persist it.
4877 c.snapshotMu.Lock()
4878 defer c.snapshotMu.Unlock()
4879 c.executor.Session().Replace(append([]provider.Message(nil), msgs...))
4880 // Rebuild canonical todo state from the truncated transcript so
4881 // Controller.Todos(), goal readiness, and the task panel no longer see
4882 // the in_progress items written by the cancelled turn.
4883 c.executor.RebuildTodoState()
4884 // The mid-turn autosave may have already written a partial transcript to
4885 // disk. snapshotActivityIfChanged skips the write when messageCount()
4886 // returns to startMessages, so flush the cleaned transcript here. SaveRewrite
4887 // still checks that this controller owns the current on-disk baseline before
4888 // overwriting it, and also covers the edge case where the strip leaves only a
4889 // system message (HasContent() == false). The path is read under the lock so
4890 // an in-flight recovery retarget cannot leave it stale.
4891 c.mu.Lock()
4892 path := c.sessionPath
4893 c.mu.Unlock()
4894 if path != "" {
4895 if err := c.executor.Session().SaveRewrite(path); err != nil {
4896 if errors.Is(err, agent.ErrSessionSnapshotConflict) {
4897 if _, outcome, recoverErr := c.recoverSnapshotConflict(path, err, true); recoverErr != nil {
4898 slog.Warn("controller: post-cancel transcript recovery", "err", recoverErr)
4899 } else if outcome == conflictDropped {
4900 slog.Warn("controller: post-cancel transcript dropped after conflict", "path", path)
4901 }
4902 } else {
4903 slog.Warn("controller: post-cancel transcript flush", "err", err)
4904 }
4905 }
4906 }
4907 }
4908
4909 func (c *Controller) snapshotActivityIfChanged(startMessages int) {
4910 if c.messageCount() <= startMessages {
4911 return
4912 }
4913 if err := c.SnapshotActivity(); err != nil {
4914 slog.Warn("controller: activity snapshot", "err", err)
4915 }
4916 }
4917
4918 // SetSessionPath rebinds auto-save without changing the current session
4919 // preference. Callers creating a genuinely fresh conversation should use
4920 // SetFreshSessionPath; callers resuming history should use Resume.
4921 func (c *Controller) SetSessionPath(p string) {
4922 c.setSessionPath(p, false)
4923 }
4924
4925 // SetFreshSessionPath binds a path that is known to belong to a newly-created
4926 // session and samples the configured new-session recovery default.
4927 func (c *Controller) SetFreshSessionPath(p string) {
4928 c.setSessionPath(p, true)
4929 }
4930
4931 func (c *Controller) setSessionPath(p string, fresh bool) {
4932 // See snapshotMu: the swap must not interleave with an in-flight save.
4933 c.snapshotMu.Lock()
4934 c.mu.Lock()
4935 c.sessionPath = p
4936 c.guardianPath = guardian.PathFor(p)
4937 c.mu.Unlock()
4938 c.setActiveJobSession(p)
4939 c.rebindCheckpoints(p)
4940 if fresh {
4941 c.resetRecoveryForNewSession(p)
4942 // A newly-created conversation must not share the previous logical
4943 // session's temporary files (e.g. after EnsureSessionPath on a
4944 // controller that already ran commands).
4945 c.rotateSessionTemp()
4946 } else {
4947 c.loadRecoveryState(p)
4948 }
4949 c.snapshotMu.Unlock()
4950 if !fresh {
4951 c.recoverCheckpointTransactions()
4952 }
4953 }
4954
4955 // SessionDestroyHandle separates waiting for cancelled jobs from ending the
4956 // destroy window, so callers can move/delete persistent artifacts in between.
4957 type SessionDestroyHandle struct {
4958 Wait func() jobs.TeardownResult
4959 WaitAll func()
4960 Finish func()
4961 Async bool
4962 }
4963
4964 // BeginDestroySession marks a session as leaving active use and cancels its
4965 // background jobs. Call Wait before moving/deleting artifacts, then Finish after
4966 // persistent cleanup/move work is complete.
4967 func (c *Controller) BeginDestroySession(sessionPath string) SessionDestroyHandle {
4968 parentSession := agent.BranchID(sessionPath)
4969 if c.jobs == nil || parentSession == "" {
4970 wait := func() jobs.TeardownResult { return jobs.TeardownResult{} }
4971 noop := func() {}
4972 return SessionDestroyHandle{Wait: wait, WaitAll: noop, Finish: noop}
4973 }
4974 teardown := c.jobs.BeginDestroySession(parentSession)
4975 return SessionDestroyHandle{
4976 Wait: func() jobs.TeardownResult {
4977 return c.jobs.WaitTeardown(context.Background(), teardown, c.jobs.TeardownGrace())
4978 },
4979 WaitAll: func() {
4980 for _, ch := range teardown.DoneChannels() {
4981 <-ch
4982 }
4983 },
4984 Finish: func() {
4985 c.jobs.FinishDestroySession(parentSession)
4986 },
4987 Async: teardown.Async(),
4988 }
4989 }
4990
4991 // IsDestroyingSession reports whether sessionPath is currently in the destroy
4992 // window for this controller's job manager.
4993 func (c *Controller) IsDestroyingSession(sessionPath string) bool {
4994 if c.jobs == nil {
4995 return false
4996 }
4997 return c.jobs.IsDestroying(agent.BranchID(sessionPath))
4998 }
4999
5000 func (c *Controller) setActiveJobSession(sessionPath string) {
5001 if c.jobs != nil {
5002 c.jobs.SetActiveSessionPath(agent.BranchID(sessionPath), sessionPath)
5003 }
5004 }
5005
5006 // SessionDir reports the directory new session files land in ("" disables
5007 // persistence), so the caller can decide whether to mint a path.
5008 func (c *Controller) SessionDir() string { return c.sessionDir }
5009
5010 // SessionPath reports the file the current conversation auto-saves to ("" when
5011 // persistence is disabled), so a history view can mark the active session.
5012 func (c *Controller) SessionPath() string {
5013 c.mu.Lock()
5014 defer c.mu.Unlock()
5015 return c.sessionPath
5016 }
5017
5018 func (c *Controller) parentSessionID() string {
5019 return agent.BranchID(c.SessionPath())
5020 }
5021
5022 // History returns the executor's current message log (for repopulating a
5023 // resumed frontend's view).
5024 func (c *Controller) History() []provider.Message {
5025 if c.executor == nil {
5026 return nil
5027 }
5028 return c.executor.Session().Snapshot() // copy — a turn may be appending concurrently
5029 }
5030
5031 // ContextSnapshot returns (usedTokens, contextWindow) from the most recent
5032 // turn. Both zero means no data yet — a gauge hides itself.
5033 // usedTokens is promptTokens + completionTokens so the GUI breakdown and
5034 // gauge reflect the full token usage, not just the prompt fill.
5035 func (c *Controller) ContextSnapshot() (int, int) {
5036 if c.executor == nil {
5037 return 0, 0
5038 }
5039 u := c.executor.LastUsage()
5040 if u == nil {
5041 return 0, c.executor.ContextWindow()
5042 }
5043 return u.PromptTokens + u.CompletionTokens, c.executor.ContextWindow()
5044 }
5045
5046 // CompactRatio returns the auto-compaction threshold as a fraction of the window
5047 // (0 when the executor is unset). The status line shows headroom against it.
5048 func (c *Controller) CompactRatio() float64 {
5049 if c.executor == nil {
5050 return 0
5051 }
5052 return c.executor.CompactRatio()
5053 }
5054
5055 // LastUsage returns the most recent turn's token telemetry (nil before the first
5056 // turn), so frontends can derive the prompt cache-hit rate for the status line.
5057 func (c *Controller) LastUsage() *provider.Usage {
5058 if c.executor == nil {
5059 return nil
5060 }
5061 return c.executor.LastUsage()
5062 }
5063
5064 // SessionCache returns cumulative cache hit/miss prompt tokens for the session,
5065 // so a frontend can render the aggregate (session-wide) cache-hit rate — steadier
5066 // than the single-turn rate and unaffected by compaction.
5067 func (c *Controller) SessionCache() (hit, miss int) {
5068 if c.executor == nil {
5069 return 0, 0
5070 }
5071 return c.executor.SessionCache()
5072 }
5073
5074 // Todos returns a copy of the canonical task list (the latest todo_write state
5075 // merged with complete_step advances) so frontends can render a live task panel.
5076 func (c *Controller) Todos() []evidence.TodoItem {
5077 if c.executor == nil {
5078 return nil
5079 }
5080 return c.executor.CanonicalTodoState()
5081 }
5082
5083 // ToolResultData holds the full arguments and output for one tool call, loaded
5084 // on demand when a frontend expands a collapsed tool card.
5085 type ToolResultData struct {
5086 Args string `json:"args"`
5087 Output string `json:"output"`
5088 Execution *provider.ToolExecution `json:"execution,omitempty"`
5089 }
5090
5091 // ToolResult looks up a tool call by its ID in the session history and returns
5092 // the full arguments + output that were elided from the frontend's items[].
5093 // Returns nil when the tool ID isn't found (e.g. a sub-agent's tool call that
5094 // lives in a different session).
5095 func (c *Controller) ToolResult(toolID string) *ToolResultData {
5096 if c.executor == nil {
5097 return nil
5098 }
5099 msgs := c.executor.Session().Snapshot()
5100 // Search backwards: tool result first (most recent), then find the args
5101 // from the preceding assistant turn.
5102 for i := len(msgs) - 1; i >= 0; i-- {
5103 if msgs[i].Role != provider.RoleTool || msgs[i].ToolCallID != toolID {
5104 continue
5105 }
5106 out := &ToolResultData{
5107 Args: "",
5108 Output: msgs[i].Content,
5109 Execution: msgs[i].ToolExecution,
5110 }
5111 // Walk back to find the assistant turn that issued this call.
5112 for j := i; j >= 0; j-- {
5113 if msgs[j].Role != provider.RoleAssistant {
5114 continue
5115 }
5116 for _, tc := range msgs[j].ToolCalls {
5117 if tc.ID == toolID {
5118 out.Args = tc.Arguments
5119 return out
5120 }
5121 }
5122 }
5123 return out
5124 }
5125 return nil
5126 }
5127
5128 // Balance queries the active provider's wallet balance, or (nil, nil) when the
5129 // provider declares no balance_url — so a caller treats "not configured" and
5130 // "fetched" the same and just omits the readout when nil.
5131 func (c *Controller) Balance(ctx context.Context) (*billing.Balance, error) {
5132 if strings.TrimSpace(c.balanceURL) == "" {
5133 return nil, nil
5134 }
5135 ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
5136 defer cancel()
5137 return billing.FetchWithClient(ctx, c.balanceClient, c.balanceURL, c.balanceKey)
5138 }
5139
5140 // Host returns the running MCP host (nil when no plugins), for frontends that
5141 // list servers / resolve MCP prompts.
5142 func (c *Controller) Host() *plugin.Host { return c.mcp.hostRef() }
5143
5144 // Commands returns the loaded custom slash commands.
5145 func (c *Controller) Commands() []command.Command {
5146 if p := c.commands.Load(); p != nil {
5147 return *p
5148 }
5149 return nil
5150 }
5151
5152 // ReloadCommands rescans all command directories and hot-swaps the slash_command
5153 // tool and the internal command slice — no MCP restart, no hook rerun.
5154 func (c *Controller) ReloadCommands(ctx context.Context) error {
5155 select {
5156 case <-ctx.Done():
5157 return ctx.Err()
5158 default:
5159 }
5160 cmds, loadErr := command.LoadRoots(config.CommandRootsForRoot(c.workspaceRoot)...)
5161 cmdSkills := c.SlashSkills()
5162
5163 entries := make([]command.SlashEntry, 0, len(cmdSkills)+len(cmds))
5164 for _, sk := range cmdSkills {
5165 sk := sk
5166 entries = append(entries, command.SlashEntry{
5167 Name: sk.SlashName(),
5168 Description: sk.Description,
5169 Render: func(args []string) string { return c.skills.render(sk, strings.Join(args, " ")) },
5170 })
5171 }
5172 for _, cmd := range cmds {
5173 if cmd.Hidden {
5174 continue
5175 }
5176 cmd := cmd
5177 entries = append(entries, command.SlashEntry{
5178 Name: cmd.Name,
5179 Description: cmd.Description,
5180 ArgHint: cmd.ArgHint,
5181 Render: func(args []string) string { return cmd.Render(args) },
5182 })
5183 }
5184 c.mcp.registerTool(command.NewSlashCommandTool(entries))
5185 cmdSlice := cmds
5186 c.commands.Store(&cmdSlice)
5187 return loadErr
5188 }
5189
5190 // Skills returns the discoverable skills (for the slash menu and `/skills`).
5191 // When a live Store is available, scan it on demand so skills installed during
5192 // this session appear without rewriting the cache-stable system prompt.
5193 // Executor returns the underlying agent when present (nil for pure runners).
5194 func (c *Controller) Executor() *agent.Agent {
5195 if c == nil {
5196 return nil
5197 }
5198 return c.executor
5199 }
5200
5201 func (c *Controller) Skills() []skill.Skill {
5202 return c.skills.list()
5203 }
5204
5205 // SlashSkills returns the user-visible skill directory. Plugin skills use
5206 // package-qualified names while Skills keeps bare model/run_skill identifiers.
5207 func (c *Controller) SlashSkills() []skill.Skill {
5208 return c.skills.slashList()
5209 }
5210
5211 // AllSkills returns every discoverable skill, including disabled ones, for
5212 // management surfaces that need to re-enable a hidden skill.
5213 func (c *Controller) AllSkills() []skill.Skill {
5214 return c.skills.listAll()
5215 }
5216
5217 // DisabledSkills returns all discoverable skills that are disabled in config.
5218 func (c *Controller) DisabledSkills() []skill.Skill {
5219 cfg, err := config.Load()
5220 if err != nil {
5221 return nil
5222 }
5223 var out []skill.Skill
5224 for _, sk := range c.AllSkills() {
5225 if cfg.IsSkillDisabled(sk.Name) {
5226 out = append(out, sk)
5227 }
5228 }
5229 return out
5230 }
5231
5232 // SkillEnabled reports whether a discoverable skill is enabled.
5233 func (c *Controller) SkillEnabled(name string) bool {
5234 cfg, err := config.Load()
5235 if err != nil {
5236 return true
5237 }
5238 return !cfg.IsSkillDisabled(name)
5239 }
5240
5241 // SetSkillEnabled persists a skill enable/disable preference. The caller should
5242 // rebuild the controller for the prompt/tool registry to reflect it immediately.
5243 func (c *Controller) SetSkillEnabled(name string, enabled bool) error {
5244 found := false
5245 for _, sk := range c.AllSkills() {
5246 if config.SkillNameKey(sk.Name) == config.SkillNameKey(name) {
5247 name = sk.Name
5248 found = true
5249 break
5250 }
5251 }
5252 if !found {
5253 return fmt.Errorf("unknown skill: %s", name)
5254 }
5255 // Serialize the load-modify-save against other in-process user-config
5256 // editors so concurrent writers (bot mapping persistence, desktop
5257 // settings) don't drop this toggle or lose their own fields.
5258 unlock := config.LockUserConfigEdits()
5259 defer unlock()
5260 cfg := config.LoadForEdit(config.UserConfigPath())
5261 if err := cfg.SetSkillEnabled(name, enabled); err != nil {
5262 return err
5263 }
5264 return cfg.SaveTo(config.UserConfigPath())
5265 }
5266
5267 // CreateSkill writes a new skill file at the given scope and returns its
5268 // path. Skills()/AllSkills()/RunSkill() read the live store on demand, so the
5269 // new skill is usable (by name) immediately with no rebuild; the caller
5270 // should still rebuild the controller for the pinned Skills index and tool
5271 // registry to reflect it on the model's next turn, mirroring how
5272 // SetSkillEnabled's callers already rebuild after a config change.
5273 func (c *Controller) CreateSkill(name string, scope skill.Scope, content string) (string, error) {
5274 w := c.skills.writer()
5275 if w == nil {
5276 return "", fmt.Errorf("no writable skill store in this session")
5277 }
5278 return w.CreateWithContent(name, scope, content)
5279 }
5280
5281 // UpdateSkill overwrites an existing user-authored skill file in place. See
5282 // skill.Store.UpdateContent for the builtin-refusal and scope-match rules.
5283 func (c *Controller) UpdateSkill(name string, scope skill.Scope, content string) error {
5284 w := c.skills.writer()
5285 if w == nil {
5286 return fmt.Errorf("no writable skill store in this session")
5287 }
5288 return w.UpdateContent(name, scope, content)
5289 }
5290
5291 // DeleteSkill removes a user-authored skill file at the given scope. See
5292 // skill.Store.Delete for the builtin-refusal and scope-match rules.
5293 func (c *Controller) DeleteSkill(name string, scope skill.Scope) error {
5294 w := c.skills.writer()
5295 if w == nil {
5296 return fmt.Errorf("no writable skill store in this session")
5297 }
5298 return w.Delete(name, scope)
5299 }
5300
5301 // HookRunner returns the session's hook runner (nil-safe; may hold zero hooks),
5302 // so a frontend can list the active hooks via `/hooks`.
5303 func (c *Controller) HookRunner() *hook.Runner { return c.hooks }
5304
5305 // AddMCPServer connects an MCP server live and persists it to the user-global
5306 // config. Its tools are registered immediately and become available on the next
5307 // turn (the agent reads the registry per turn). The raw entry — ${VARS} intact —
5308 // is what's written to disk; the live connection uses the expanded form. Returns
5309 // the number of tools the server exposed. Persistence is transactional: a config
5310 // or activation failure removes the just-connected client so the live registry
5311 // never claims an install that will disappear after restart.
5312 func (c *Controller) AddMCPServer(e config.PluginEntry) (int, error) {
5313 // AddMCPServer is an explicit user action. Mark the live entry with the same
5314 // provenance it will receive when the saved user config is loaded next time,
5315 // so /mcp add is add-and-use in the current session too.
5316 e.Source = config.MCPSourceUserConfig
5317 if effective, loadErr := config.LoadForRootReadOnly(c.workspaceRoot); loadErr != nil {
5318 return 0, loadErr
5319 } else {
5320 for _, configured := range effective.Plugins {
5321 if configured.Name != e.Name {
5322 continue
5323 }
5324 if configured.Source != config.MCPSourceUserConfig && configured.Source != config.MCPSourceLegacyUser {
5325 return 0, fmt.Errorf("MCP server %q is already configured by %s; edit or remove that declaration before installing a global server with the same name", e.Name, configured.Source)
5326 }
5327 break
5328 }
5329 }
5330 n, err := c.connectMCPServer(e)
5331 if err != nil {
5332 return 0, err
5333 }
5334 if _, err := config.InstallUserPluginForRoot(c.workspaceRoot, e, true); err != nil {
5335 c.DisconnectMCPServer(e.Name)
5336 return 0, fmt.Errorf("saving MCP server config: %w", err)
5337 }
5338 return n, nil
5339 }
5340
5341 // ConnectMCPServer connects an MCP server entry for this session without writing
5342 // it to config. Desktop owns config placement so it can keep user-level settings
5343 // out of project reasonix.toml while preserving the CLI AddMCPServer semantics.
5344 func (c *Controller) ConnectMCPServer(e config.PluginEntry) (int, error) {
5345 return c.connectMCPServer(e)
5346 }
5347
5348 // RegisterMCPServerOnDemand restores a configured server's cached provider
5349 // surface without forcing a handshake. It is the durable-enable counterpart to
5350 // ConnectMCPServer, which remains the explicit install/retry operation.
5351 func (c *Controller) RegisterMCPServerOnDemand(e config.PluginEntry) (int, error) {
5352 spec := c.mcpSpec(e)
5353 n, err := c.mcp.registerSpecOnDemand(spec)
5354 if err == nil && c.capabilityRuntime != nil {
5355 c.capabilityRuntime.UpsertServer(e, spec, true)
5356 }
5357 return n, err
5358 }
5359
5360 // connectMCPServer expands an entry's ${VARS}, applies the known-server
5361 // overrides scoped to the workspace, and connects it live via the mcp manager.
5362 func (c *Controller) connectMCPServer(e config.PluginEntry) (int, error) {
5363 spec := c.mcpSpec(e)
5364 n, err := c.mcp.connectSpec(spec)
5365 if err == nil && c.capabilityRuntime != nil {
5366 c.capabilityRuntime.UpsertServer(e, spec, true)
5367 }
5368 return n, err
5369 }
5370
5371 func (c *Controller) mcpSpec(e config.PluginEntry) plugin.Spec {
5372 exp := e.ExpandedPlugin()
5373 configSource := strings.TrimSpace(string(exp.Source))
5374 spec := plugin.ApplyKnownOverrides(plugin.Spec{
5375 Name: exp.Name,
5376 Type: exp.Type,
5377 Command: exp.Command,
5378 Args: exp.Args,
5379 Env: exp.Env,
5380 URL: exp.URL,
5381 Headers: exp.Headers,
5382 StartupTimeout: controllerMCPTimeout(exp.StartupTimeoutSeconds),
5383 DefaultCallTimeout: c.mcpDefaultCallTimeout,
5384 CallTimeout: controllerMCPTimeout(exp.CallTimeoutSeconds),
5385 ToolTimeouts: controllerMCPToolTimeouts(exp.ToolTimeoutSeconds),
5386 WorkspaceRoot: c.WorkspaceRoot(),
5387 ConfigSource: configSource,
5388 Authorized: exp.Source.UserAuthorized(),
5389 // Explicit user installs and reconnects run as trusted host processes.
5390 ProcessMode: plugin.MCPProcessHost,
5391 }, c.WorkspaceRoot())
5392 if exp.Source.ProjectScoped() && strings.TrimSpace(spec.Dir) == "" {
5393 spec.Dir = c.WorkspaceRoot()
5394 }
5395 if c.mcpConfigureSpec != nil {
5396 c.mcpConfigureSpec(&spec)
5397 if spec.ProcessMode == "" {
5398 spec.ProcessMode = plugin.MCPProcessHost
5399 }
5400 }
5401 return spec
5402 }
5403
5404 // syncCapabilityRuntimeFromConfig restores one server's authoritative runtime
5405 // entry after a transactional disconnect/rollback. enabledOverride is used for
5406 // a session-only disconnect; nil re-resolves the durable activation state.
5407 func (c *Controller) syncCapabilityRuntimeFromConfig(name string, enabledOverride *bool) {
5408 if c == nil || c.capabilityRuntime == nil {
5409 return
5410 }
5411 name = strings.TrimSpace(name)
5412 cfg, err := config.LoadForRoot(c.workspaceRoot)
5413 if err != nil {
5414 // The caller revokes first. A config read failure must not re-enable a
5415 // potentially stale spec or shared-Host client.
5416 return
5417 }
5418 for _, entry := range cfg.Plugins {
5419 if strings.TrimSpace(entry.Name) != name {
5420 continue
5421 }
5422 enabled := entry.ShouldAutoStart()
5423 if enabledOverride != nil {
5424 enabled = *enabledOverride
5425 } else if resolved, resolveErr := config.DefaultMCPActivationStore().IsEnabled(entry, c.workspaceRoot); resolveErr == nil {
5426 enabled = resolved
5427 }
5428 c.capabilityRuntime.UpsertServer(entry, c.mcpSpec(entry), enabled)
5429 return
5430 }
5431 c.capabilityRuntime.RemoveServer(name)
5432 }
5433
5434 func controllerMCPTimeout(seconds int) time.Duration {
5435 if seconds <= 0 {
5436 return 0
5437 }
5438 return time.Duration(seconds) * time.Second
5439 }
5440
5441 func controllerMCPToolTimeouts(values map[string]int) map[string]time.Duration {
5442 if len(values) == 0 {
5443 return nil
5444 }
5445 out := make(map[string]time.Duration, len(values))
5446 for name, seconds := range values {
5447 if name = strings.TrimSpace(name); name != "" && seconds > 0 {
5448 out[name] = time.Duration(seconds) * time.Second
5449 }
5450 }
5451 if len(out) == 0 {
5452 return nil
5453 }
5454 return out
5455 }
5456
5457 // ImportMCPEntries persists selected MCP entries and attempts to connect them
5458 // live. A connection failure does not roll back the config import: the user can
5459 // fix local dependencies and reconnect in a later session.
5460 func (c *Controller) ImportMCPEntries(entries []config.PluginEntry) (total, added, updated, connected, failed, skipped int, err error) {
5461 total, added, updated, err = config.ImportCCSwitchMCPEntries(entries)
5462 if err != nil {
5463 return 0, 0, 0, 0, 0, 0, err
5464 }
5465 effectiveCfg, loadErr := config.LoadForRoot(c.workspaceRoot)
5466 if loadErr != nil {
5467 return 0, 0, 0, 0, 0, 0, loadErr
5468 }
5469 effective := make(map[string]config.PluginEntry, len(effectiveCfg.Plugins))
5470 for _, entry := range effectiveCfg.Plugins {
5471 effective[entry.Name] = entry
5472 }
5473 for _, imported := range entries {
5474 e, ok := effective[imported.Name]
5475 if !ok || e.Source != config.MCPSourceUserConfig {
5476 // A project declaration with the same name remains effective. The
5477 // imported global entry is saved as its lower-priority fallback.
5478 skipped++
5479 continue
5480 }
5481 if c.mcp.hasServer(e.Name) {
5482 if c.capabilityRuntime != nil {
5483 // Import updates may intentionally keep an existing live client, but
5484 // future proxy reconnects must use the newly persisted spec.
5485 c.capabilityRuntime.UpsertServer(e, c.mcpSpec(e), true)
5486 }
5487 skipped++
5488 continue
5489 }
5490 if _, err := c.AddMCPServer(e); err != nil {
5491 failed++
5492 continue
5493 }
5494 connected++
5495 }
5496 return total, added, updated, connected, failed, skipped, nil
5497 }
5498
5499 func (c *Controller) ConfiguredMCPNames() []string {
5500 cfg, err := config.LoadForRootReadOnly(c.workspaceRoot)
5501 if err != nil {
5502 return nil
5503 }
5504 names := make([]string, 0, len(cfg.Plugins))
5505 for _, p := range cfg.Plugins {
5506 names = append(names, p.Name)
5507 }
5508 return names
5509 }
5510
5511 func (c *Controller) DisconnectedMCPNames() []string {
5512 cfg, err := config.LoadForRootReadOnly(c.workspaceRoot)
5513 if err != nil {
5514 return nil
5515 }
5516 connected := map[string]bool{}
5517 for _, name := range c.mcp.serverNames() {
5518 connected[name] = true
5519 }
5520 var names []string
5521 for _, p := range cfg.Plugins {
5522 if !connected[p.Name] {
5523 names = append(names, p.Name)
5524 }
5525 }
5526 return names
5527 }
5528
5529 func (c *Controller) ConnectConfiguredMCPServer(name string) (int, error) {
5530 p, err := c.configuredMCPServer(name)
5531 if err != nil {
5532 return 0, err
5533 }
5534 return c.connectMCPServer(p)
5535 }
5536
5537 func (c *Controller) configuredMCPServer(name string) (config.PluginEntry, error) {
5538 cfg, err := config.LoadForRoot(c.workspaceRoot)
5539 if err != nil {
5540 return config.PluginEntry{}, err
5541 }
5542 for _, p := range cfg.Plugins {
5543 if p.Name == name {
5544 return p, nil
5545 }
5546 }
5547 return config.PluginEntry{}, fmt.Errorf("no configured MCP server named %q", name)
5548 }
5549
5550 // RemoveMCPServer removes a writable MCP configuration before disconnecting the
5551 // live server, so a persistence failure never produces a false-successful
5552 // session-only removal. MCPs contributed by an installed plugin package are
5553 // managed with that package and cannot be removed independently.
5554 func (c *Controller) RemoveMCPServer(name string) (disconnected bool, err error) {
5555 cfg, lerr := config.LoadForRoot(c.workspaceRoot)
5556 if lerr != nil {
5557 return false, lerr
5558 }
5559 if owner, ok := cfg.PluginPackageOwner(name); ok {
5560 return false, fmt.Errorf("MCP server %q is managed by plugin %q; disable or remove the plugin instead", name, owner)
5561 }
5562 entry, removed, _, rerr := config.RemovePluginFromEffectiveSourceForRoot(c.workspaceRoot, name)
5563 if rerr != nil {
5564 return false, rerr
5565 }
5566 if !removed {
5567 return false, fmt.Errorf("no removable MCP server named %q", name)
5568 }
5569 _ = config.DefaultMCPActivationStore().ClearServer(entry, c.workspaceRoot)
5570 if c.capabilityRuntime != nil {
5571 // Revoke before touching the shared Host so an overlapping resolver cannot
5572 // reuse a sibling tab's still-connected client.
5573 c.capabilityRuntime.RemoveServer(name)
5574 }
5575 disconnected = c.mcp.disconnect(name)
5576 if !disconnected {
5577 c.mcp.removeToolPrefix(name)
5578 }
5579 // A lower-priority same-name declaration may now be effective. Restore its
5580 // cached/on-demand surface without starting a process; otherwise ensure the
5581 // removed name stays absent.
5582 if fallback, fallbackErr := c.configuredMCPServer(name); fallbackErr == nil {
5583 enabled := fallback.ShouldAutoStart()
5584 if resolved, resolveErr := config.DefaultMCPActivationStore().IsEnabled(fallback, c.workspaceRoot); resolveErr == nil {
5585 enabled = resolved
5586 }
5587 if enabled {
5588 _, _ = c.RegisterMCPServerOnDemand(fallback)
5589 } else {
5590 c.syncCapabilityRuntimeFromConfig(name, &enabled)
5591 }
5592 } else {
5593 c.syncCapabilityRuntimeFromConfig(name, nil)
5594 }
5595 return disconnected, nil
5596 }
5597
5598 // DisconnectMCPServer disconnects a live server for this session without touching
5599 // config — the connector toggle's "off". Its tools vanish next turn; it reconnects
5600 // on the next session start, or now via ConnectConfiguredMCPServer (the "on").
5601 // Reports whether a live server was actually disconnected.
5602 func (c *Controller) DisconnectMCPServer(name string) bool {
5603 if c.capabilityRuntime != nil {
5604 c.capabilityRuntime.SetServerEnabled(name, false)
5605 }
5606 disconnected := c.mcp.disconnect(name)
5607 removedPlaceholder := 0
5608 if !disconnected {
5609 removedPlaceholder = c.mcp.removeToolPrefix(name)
5610 }
5611 // Keep configured servers discoverable as disabled, but forget runtime-only
5612 // or rolled-back installs that no longer exist in configuration.
5613 disabled := false
5614 c.syncCapabilityRuntimeFromConfig(name, &disabled)
5615 return disconnected || removedPlaceholder > 0
5616 }
5617
5618 // UnregisterMCPServerTools hides a shared MCP server from this controller only.
5619 // The desktop shared-host path uses this for per-tab connector toggles: the
5620 // shared client stays alive for sibling tabs, while this session's registry drops
5621 // the server's provider-visible tools before the next turn.
5622 func (c *Controller) UnregisterMCPServerTools(name string) bool {
5623 if c.capabilityRuntime != nil {
5624 c.capabilityRuntime.SetServerEnabled(name, false)
5625 }
5626 return c.mcp.suspendToolPrefix(name)
5627 }
5628
5629 // Label returns the human-readable model label, e.g. "deepseek-flash".
5630 func (c *Controller) Label() string { return c.label }
5631
5632 // ModelRef returns the canonical provider/model reference for the session.
5633 func (c *Controller) ModelRef() string { return c.modelRef }
5634
5635 // WorkspaceRoot returns the workspace root for this controller's session
5636 // (the directory that file-writers and @-references are scoped to).
5637 // Empty means no scoping is in effect.
5638 func (c *Controller) WorkspaceRoot() string { return c.workspaceRoot }
5639
5640 func (c *Controller) imageInputEnabled() bool {
5641 ref := c.modelRef
5642 cfg, err := config.LoadForRoot(c.workspaceRoot)
5643 if err == nil && ref == "" {
5644 ref = cfg.DefaultModel
5645 }
5646 if err != nil || ref == "" {
5647 return false
5648 }
5649 entry, ok := cfg.ResolveModel(ref)
5650 return ok && config.EffectiveVision(entry)
5651 }
5652
5653 // ImageInputEnabled reports whether the current model accepts direct image
5654 // inputs, so frontends can gate image-only UX before a turn starts.
5655 func (c *Controller) ImageInputEnabled() bool { return c.imageInputEnabled() }
5656
5657 // InheritLifecycleFrom carries same-session lifecycle state across controller
5658 // rebuilds, such as model switches that preserve the conversation.
5659 func (c *Controller) InheritLifecycleFrom(prev *Controller) {
5660 if prev == nil {
5661 return
5662 }
5663 prev.mu.Lock()
5664 started := prev.startedOnce
5665 turn := prev.turn
5666 prev.mu.Unlock()
5667
5668 c.mu.Lock()
5669 c.startedOnce = started
5670 if c.turn < turn {
5671 c.turn = turn
5672 }
5673 c.mu.Unlock()
5674 }
5675
5676 // SessionAuthorizations snapshots this controller's same-session tool
5677 // grants ("Allow for this session") and Plan-mode read-only command trust,
5678 // for carrying into a replacement controller across a rebuild — see
5679 // RestoreSessionAuthorizations.
5680 func (c *Controller) SessionAuthorizations() SessionAuthorizations {
5681 return c.approval.snapshotSessionAuthorizations()
5682 }
5683
5684 // RestoreSessionAuthorizations re-applies session authorizations captured
5685 // from a prior controller in the same session (see SessionAuthorizations). A
5686 // model/effort/profile switch rebuilds the controller, and without this the
5687 // replacement forgets every grant the user already made this session.
5688 func (c *Controller) RestoreSessionAuthorizations(auth SessionAuthorizations) {
5689 c.approval.restoreSessionAuthorizations(auth)
5690 }
5691
5692 // ReleaseResources stops plugin subprocesses and releases resources without
5693 // firing SessionEnd. Use it only when replacing the controller for the same
5694 // logical session.
5695 func (c *Controller) ReleaseResources() {
5696 c.close(false, closeJobsWithGrace)
5697 }
5698
5699 // Close stops plugin subprocesses and releases resources. A session that ever
5700 // started fires SessionEnd so a teardown hook runs.
5701 func (c *Controller) Close() {
5702 c.close(true, closeJobsWithGrace)
5703 }
5704
5705 // CloseAfterDestroy releases controller resources after the caller has already
5706 // begun session-specific job teardown. It avoids a second synchronous job grace
5707 // wait while still cancelling the manager root and reaping temporary artifacts
5708 // once every job goroutine finally exits.
5709 func (c *Controller) CloseAfterDestroy() {
5710 c.close(true, closeJobsAsync)
5711 }
5712
5713 type closeJobsMode int
5714
5715 const (
5716 closeJobsWithGrace closeJobsMode = iota
5717 closeJobsAsync
5718 )
5719
5720 func (c *Controller) close(fireSessionEnd bool, jobsMode closeJobsMode) {
5721 // Desktop tab lifecycles can race a rebind/model-switch/close on the same
5722 // controller; make teardown idempotent so a duplicate Close cannot re-fire
5723 // SessionEnd hooks or re-run cleanup. The first caller's jobsMode wins.
5724 c.closeOnce.Do(func() {
5725 c.mu.Lock()
5726 started := c.startedOnce
5727 cancel := c.cancel
5728 // Seal turn admission and drop anything already parked: a parked turn
5729 // must not start against a controller that is being torn down, and
5730 // without the closed flag a submit landing after this critical
5731 // section (while a running turn's TurnDone delivery is still in
5732 // flight) would park again and start after teardown.
5733 c.closed = true
5734 c.parkedTurns = nil
5735 // A finishing-only controller no longer needs the delivery gate because
5736 // closed seals every admission path. Keep running truthful until the
5737 // foreground goroutine actually exits; clearing it here would report idle
5738 // while tools and prompt waiters were still live.
5739 c.finishing = false
5740 if cancel != nil {
5741 c.canceling = true
5742 }
5743 c.mu.Unlock()
5744 if cancel != nil {
5745 // clearAll deliberately does not signal waiters. Pair it with the
5746 // foreground cancellation so approval/ask waits always unblock.
5747 c.approval.clearAll()
5748 cancel()
5749 }
5750 if fireSessionEnd && started {
5751 c.hooks.SessionEnd(context.Background(), "other")
5752 c.extensionSessionEvent(extension.PointSessionEnd, dispatch.PhaseEnd, c.SessionPath())
5753 }
5754 if c.jobs != nil {
5755 switch jobsMode {
5756 case closeJobsAsync:
5757 c.jobs.CloseAsync()
5758 default:
5759 c.jobs.Close() // cancel any still-running background jobs
5760 }
5761 }
5762 if c.cleanup != nil {
5763 c.cleanup()
5764 }
5765 // Drop the Controller owner reference last so background job leases
5766 // that outlive close still pin retired generations until they exit.
5767 if c.sessionTemp != nil {
5768 c.sessionTemp.Release()
5769 }
5770 })
5771 }
5772
5773 // SessionTemp returns the logical-session private temporary directory manager.
5774 // Hot rebuilds pass this to the replacement Controller so the directory survives
5775 // model/settings swaps. Nil only when the Controller was constructed without one
5776 // (should not happen after New).
5777 func (c *Controller) SessionTemp() *sessiontemp.Manager {
5778 if c == nil {
5779 return nil
5780 }
5781 return c.sessionTemp
5782 }
5783
5784 // rotateSessionTemp advances the private temporary generation so a new logical
5785 // session cannot see the previous session's temporary files. In-flight command
5786 // leases keep the old generation alive until they release.
5787 func (c *Controller) rotateSessionTemp() {
5788 if c == nil || c.sessionTemp == nil {
5789 return
5790 }
5791 c.sessionTemp.Rotate()
5792 }
5793
5794 // Jobs returns the still-running background jobs for the status bar (nil when
5795 // background jobs are disabled).
5796 func (c *Controller) Jobs() []jobs.View {
5797 if c.jobs == nil {
5798 return nil
5799 }
5800 return c.jobs.RunningForSession(c.parentSessionID())
5801 }
5802
5803 // KillJob cancels a running background job by ID.
5804 func (c *Controller) KillJob(id string) bool {
5805 if c.jobs == nil {
5806 return false
5807 }
5808 return c.jobs.Kill(id)
5809 }
5810
5811 // CancelJob stops one background job owned by this controller's session.
5812 func (c *Controller) CancelJob(id string) bool {
5813 if c.jobs == nil {
5814 return false
5815 }
5816 return c.jobs.KillForSession(c.parentSessionID(), id)
5817 }
5818
5819 // WorkspaceLeaseState reports only whether this controller owns or is waiting
5820 // for the Delivery workspace writer lease. It never exposes filesystem or
5821 // process identity.
5822 func (c *Controller) WorkspaceLeaseState() workspacelease.State {
5823 return c.workspaceLease.State()
5824 }
5825
5826 // SetToolApprovalMode changes the runtime approval posture for permission-gated
5827 // tools. It does not answer business asks or plan approval. Sub-agents (task,
5828 // writer-capable skill sub-agents, the planner) have no UI to prompt through,
5829 // so this also pushes the mode to the shared headless gate they read from —
5830 // without it, a mode switch (Shift+Tab) would only rebuild the parent
5831 // executor's gate and leave sub-agents pinned to whatever mode was active
5832 // when the session booted.
5833 func (c *Controller) SetToolApprovalMode(mode string) {
5834 c.ApplyToolApprovalMode(mode)
5835 }
5836
5837 // ApplyToolApprovalMode is SetToolApprovalMode reporting which pending
5838 // approval prompt ids the new posture auto-allowed. Prompts NOT in the
5839 // returned set are still pending here — fresh user decisions (plan, memory,
5840 // sandbox escape) never drain, and auto keeps approvals an allow policy would
5841 // not cover — so a frontend must keep showing them instead of assuming the
5842 // posture switch resolved everything (#6432).
5843 func (c *Controller) ApplyToolApprovalMode(mode string) []string {
5844 mode = normalizeToolApprovalMode(mode)
5845 // Capture mode-change recovery dismissals before approval drain so a
5846 // same-value hydrate/reconcile never rotates Episode state, while a real
5847 // Auto↔Yolo/Ask switch clears temporary failure/reviewer locks and waiters
5848 // without auto-approving the original mutation.
5849 var recoveryDismissed []string
5850 c.mu.Lock()
5851 gate := c.recoveryGate
5852 c.mu.Unlock()
5853 if gate != nil {
5854 if ctrl, ok := any(gate).(agent.RecoveryEpisodeControl); ok {
5855 // Do not hold controller/approval locks while rotating the gate.
5856 recoveryDismissed = ctrl.OnModeChange(mode)
5857 }
5858 }
5859 pending := c.approval.setMode(mode)
5860 if c.subagentGate != nil {
5861 c.subagentGate.Update(mode)
5862 }
5863 c.refreshInteractiveGate()
5864 // Clear recovery cards dismissed by the mode switch outside the gate lock.
5865 for _, id := range recoveryDismissed {
5866 p := c.approval.resolve(id)
5867 if p.reply != nil {
5868 // Do not approve the pending mutation; signal cancel/deny so legacy
5869 // paths drop the card.
5870 select {
5871 case p.reply <- approvalReply{allow: false}:
5872 default:
5873 }
5874 }
5875 }
5876 drained := make([]string, 0, len(pending))
5877 for _, p := range pending {
5878 p.reply <- approvalReply{allow: true}
5879 drained = append(drained, p.id)
5880 }
5881 return drained
5882 }
5883
5884 func (c *Controller) ToolApprovalMode() string {
5885 return c.approval.mode()
5886 }
5887
5888 // SetAutoApproveTools turns YOLO tool auto-approval on or off for the session:
5889 // while on, every tool approval request is auto-allowed (writers and bash run
5890 // without asking). Ask requests and plan approval still reach the user. Deny
5891 // rules still block. Runtime-only — never written to config.
5892 func (c *Controller) SetAutoApproveTools(on bool) {
5893 if on {
5894 c.SetToolApprovalMode(ToolApprovalYolo)
5895 return
5896 }
5897 c.SetToolApprovalMode(ToolApprovalAsk)
5898 }
5899
5900 // SetBypass is the legacy name for SetAutoApproveTools. Keep it for existing
5901 // desktop/serve bindings and CLI code that still uses the bypass wording.
5902 func (c *Controller) SetBypass(on bool) {
5903 c.SetAutoApproveTools(on)
5904 }
5905
5906 // SetMode applies the Plan workflow flag and tool auto-approval together so a turn
5907 // submitted right after a composer mode switch can't observe a half-applied
5908 // gate. Turning tool auto-approval on drains any pending tool approval.
5909 func (c *Controller) SetMode(plan, autoApproveTools bool) {
5910 c.ApplyMode(plan, autoApproveTools)
5911 }
5912
5913 // ApplyMode is SetMode reporting which pending approval prompt ids the tool
5914 // approval switch auto-allowed (see ApplyToolApprovalMode).
5915 func (c *Controller) ApplyMode(plan, autoApproveTools bool) []string {
5916 c.applyPlanMode(plan)
5917 if autoApproveTools {
5918 return c.ApplyToolApprovalMode(ToolApprovalYolo)
5919 }
5920 return c.ApplyToolApprovalMode(ToolApprovalAsk)
5921 }
5922
5923 // AutoApproveTools reports whether YOLO tool auto-approval is on,
5924 // for status indicators and mode persistence.
5925 func (c *Controller) AutoApproveTools() bool {
5926 return c.ToolApprovalMode() == ToolApprovalYolo
5927 }
5928
5929 // Bypass is the legacy name for AutoApproveTools.
5930 func (c *Controller) Bypass() bool {
5931 return c.AutoApproveTools()
5932 }
5933
5934 // --- memory ---
5935 //
5936 // The memory snapshot, the pending turn-tail notes queue, and write serialization
5937 // live in c.memory (a memoryManager) behind its own locks, off c.mu — so a
5938 // memory-panel save never stalls an approval or status poll. These methods are
5939 // the SessionAPI surface; each is a thin delegation. See memory.go.
5940
5941 // QuickAdd appends a one-line note to the doc-memory file for scope (project
5942 // REASONIX.md by default) — the write side of "#<note>". Returns the file written.
5943 func (c *Controller) QuickAdd(scope memory.Scope, note string) (string, error) {
5944 return c.memory.quickAdd(scope, note)
5945 }
5946
5947 // SaveDoc overwrites a recognized memory doc with body — the save side of the
5948 // desktop panel's in-place editor. Returns the file written.
5949 func (c *Controller) SaveDoc(path, body string) (string, error) {
5950 return c.memory.saveDoc(path, body)
5951 }
5952
5953 // SaveMemory writes an active auto-memory fact and refreshes the in-session
5954 // snapshot. It is the explicit user-confirmed counterpart to the model-owned
5955 // remember tool, used by management surfaces that preview a candidate first.
5956 func (c *Controller) SaveMemory(m memory.Memory) (string, error) {
5957 return c.memory.saveMemory(m)
5958 }
5959
5960 // ForgetMemory removes a saved auto-memory by name — the panel/TUI forget action,
5961 // the manual counterpart to the model's `forget` tool.
5962 func (c *Controller) ForgetMemory(name string) error {
5963 return c.memory.forget(name)
5964 }
5965
5966 // QueueMemory implements memory.Queue: when the model runs the remember/forget
5967 // tool, the tool calls this with a note that rides the next turn so the change
5968 // applies this session without touching the cache-stable prefix. It also
5969 // refreshes the snapshot a memory panel reads.
5970 func (c *Controller) QueueMemory(note string) {
5971 c.memory.queue(note)
5972 }
5973
5974 // ClaimAutoMemoryWrite consumes the one-shot create-only authorization issued
5975 // by gateApprover for a low-risk project fact.
5976 func (c *Controller) ClaimAutoMemoryWrite(args json.RawMessage) bool {
5977 return c.memory.claimAutoRemember(args)
5978 }
5979
5980 func (c *Controller) MemoryRevisions(ref string) []memory.Memory {
5981 return c.memory.revisions(ref)
5982 }
5983
5984 // RestoreMemory restores an older active-memory revision as a new audited
5985 // revision and applies it to the next user turn.
5986 func (c *Controller) RestoreMemory(ref string, revision int) (memory.Memory, error) {
5987 return c.memory.restore(ref, revision)
5988 }
5989
5990 // RestoreArchivedMemory recovers an archived fact as a new audited revision and
5991 // applies it to the next user turn.
5992 func (c *Controller) RestoreArchivedMemory(archivePath string) (memory.Memory, error) {
5993 return c.memory.restoreArchived(archivePath)
5994 }
5995
5996 // Memory returns the loaded memory snapshot (nil when memory is disabled), for
5997 // frontends that surface a memory panel or the /memory command. The returned
5998 // *Set is immutable — mutations go through QuickAdd / SaveDoc.
5999 func (c *Controller) Memory() *memory.Set {
6000 return c.memory.current()
6001 }
6002
6003 // --- approval bridge (agent gate → events) ---
6004
6005 // gateApprover adapts the Controller to permission.Approver. It is distinct
6006 // from the public Approve command (different signature, different direction).
6007 type gateApprover struct{ c *Controller }
6008
6009 const dynamicBashApprovalReason = "This command uses nested or indirect shell execution. Auto and broad allow rules cannot verify the inner command; approve this exact command or use YOLO."
6010
6011 func (g gateApprover) Approve(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, error) {
6012 allow, remember, _, err := g.ApproveWithReason(ctx, tool, subject, args)
6013 return allow, remember, err
6014 }
6015
6016 func (g gateApprover) ApproveWithReason(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, string, error) {
6017 return g.approveWithPolicyReason(ctx, tool, subject, args, "")
6018 }
6019
6020 func (g gateApprover) ApproveWithPolicyReason(ctx context.Context, tool, subject string, args json.RawMessage, policyReason string) (bool, bool, string, error) {
6021 return g.approveWithPolicyReason(ctx, tool, subject, args, policyReason)
6022 }
6023
6024 func combineApprovalReasons(reasons ...string) string {
6025 var kept []string
6026 for _, reason := range reasons {
6027 if reason = strings.TrimSpace(reason); reason != "" {
6028 kept = append(kept, reason)
6029 }
6030 }
6031 return strings.Join(kept, "\n")
6032 }
6033
6034 func (g gateApprover) approveWithPolicyReason(ctx context.Context, tool, subject string, args json.RawMessage, policyReason string) (bool, bool, string, error) {
6035 if tool == memoryRememberTool && g.c.allowLowRiskRemember(args) {
6036 return true, false, "", nil
6037 }
6038 subject = approvalDisplaySubject(tool, subject, args)
6039 requireHuman := strings.EqualFold(tool, "bash") && permission.BashSubjectRequiresExplicitApproval(subject)
6040 // Check pre-approval first, before any prompt or Guardian review. Dynamic
6041 // Bash accepts only YOLO or an exact session grant here; ordinary calls also
6042 // accept the just-approved-plan window. Deny rules already bit at the policy
6043 // level before this point.
6044 if requireHuman && g.c.approval.preApprovedForRequiredHuman(tool, subject) {
6045 return true, false, "", nil
6046 }
6047 if !requireHuman && g.c.approval.preApproved(tool, subject, args) {
6048 return true, false, "", nil
6049 }
6050 if g.c.guardianSess != nil && !requireHuman {
6051 allow, reason, reviewErr := g.c.guardianSess.Review(ctx, tool, args, g.c.executor.Session())
6052 if reviewErr != nil {
6053 return false, false, "", reviewErr
6054 }
6055 if allow && !requiresFreshApprovalTool(tool) {
6056 return true, false, "", nil
6057 }
6058 reason = combineApprovalReasons(policyReason, reason)
6059 humanAllow, remember, err := g.c.requestApprovalWithReason(ctx, tool, subject, args, reason)
6060 if err != nil {
6061 return false, false, reason, err
6062 }
6063 if !humanAllow {
6064 return false, false, reason, nil
6065 }
6066 return true, remember, "", nil
6067 }
6068 if requireHuman {
6069 reason := combineApprovalReasons(policyReason, dynamicBashApprovalReason)
6070 allow, remember, err := g.c.requestApprovalWithReasonOptions(ctx, tool, subject, args, reason, approvalDecisionOptions{requireHuman: true})
6071 return allow, remember, "", err
6072 }
6073 allow, remember, err := g.c.requestApprovalWithReason(ctx, tool, subject, args, policyReason)
6074 return allow, remember, "", err
6075 }
6076
6077 type planModeReadOnlyTrustApprover struct{ c *Controller }
6078
6079 type sandboxEscapeApprover struct{ c *Controller }
6080
6081 func (s sandboxEscapeApprover) ApproveSandboxEscape(ctx context.Context, req sandbox.EscapeRequest) (bool, string, error) {
6082 subject := sandboxEscapeApprovalSubject(req.Command)
6083 reason := sandboxEscapeApprovalReason(req.Reason)
6084 reply, err := s.c.requestFreshApprovalDecision(ctx, SandboxEscapeApprovalTool, subject, req.Args, reason)
6085 if err != nil {
6086 return false, "approval aborted", err
6087 }
6088 if !reply.allow {
6089 return false, i18n.M.SandboxEscapeDeclined, nil
6090 }
6091 if reply.session {
6092 s.c.approval.grantSession(SandboxEscapeApprovalTool, subject)
6093 }
6094 return true, "", nil
6095 }
6096
6097 func (s sandboxEscapeApprover) SandboxEscapeSessionAllowed(_ context.Context, req sandbox.EscapeRequest) bool {
6098 return s.c.approval.preApprovedForDecision(SandboxEscapeApprovalTool, sandboxEscapeApprovalSubject(req.Command), nil, true)
6099 }
6100
6101 func sandboxEscapeApprovalSubject(command string) string {
6102 subject := strings.TrimSpace(command)
6103 if subject == "" {
6104 return i18n.M.SandboxEscapeSubjectFallback
6105 }
6106 return i18n.M.SandboxEscapeSubjectPrefix + subject
6107 }
6108
6109 func sandboxEscapeApprovalReason(reason string) string {
6110 reason = strings.TrimSpace(reason)
6111 if reason == "" {
6112 return i18n.M.SandboxEscapeRuntimeReason
6113 }
6114 return reason
6115 }
6116
6117 // managedConfigWriteApprover routes a file tool's Reasonix-managed config write
6118 // through the fresh-human approval prompt (see ManagedConfigWriteApprovalTool).
6119 // A session grant is tool-wide (mirroring sandbox_escape): one "allow for this
6120 // session" covers the rest of the repair flow across the handful of managed
6121 // config files without re-prompting on every incremental edit.
6122 type managedConfigWriteApprover struct{ c *Controller }
6123
6124 func (m managedConfigWriteApprover) ApproveManagedConfigWrite(ctx context.Context, req tool.ConfigWriteRequest) (bool, string, error) {
6125 subject := managedConfigWriteApprovalSubject(req.Path)
6126 args, _ := json.Marshal(map[string]string{"path": req.Path})
6127 reply, err := m.c.requestFreshApprovalDecision(ctx, ManagedConfigWriteApprovalTool, subject, args, i18n.M.ConfigWriteReason)
6128 if err != nil {
6129 return false, "approval aborted", err
6130 }
6131 if !reply.allow {
6132 return false, i18n.M.ConfigWriteDeclined, nil
6133 }
6134 if reply.session {
6135 m.c.approval.grantSession(ManagedConfigWriteApprovalTool, subject)
6136 }
6137 return true, "", nil
6138 }
6139
6140 func (m managedConfigWriteApprover) ManagedConfigWriteSessionAllowed(_ context.Context, req tool.ConfigWriteRequest) bool {
6141 return m.c.approval.preApprovedForDecision(ManagedConfigWriteApprovalTool, managedConfigWriteApprovalSubject(req.Path), nil, true)
6142 }
6143
6144 func managedConfigWriteApprovalSubject(path string) string {
6145 return i18n.M.ConfigWriteSubjectPrefix + strings.TrimSpace(path)
6146 }
6147
6148 func (p planModeReadOnlyTrustApprover) CheckPlanModeReadOnlyTrust(ctx context.Context, req agent.PlanModeReadOnlyTrustRequest) (bool, string, error) {
6149 prefix := normalizePlanModeReadOnlyCommandPrefix(req.Prefix)
6150 if prefix == "" {
6151 return false, "missing plan-mode read-only command prefix", nil
6152 }
6153 return p.checkBashReadOnlyCommandTrust(ctx, req, prefix)
6154 }
6155
6156 func (p planModeReadOnlyTrustApprover) checkBashReadOnlyCommandTrust(ctx context.Context, req agent.PlanModeReadOnlyTrustRequest, prefix string) (bool, string, error) {
6157 if p.c.approval.planModeReadOnlyCommandTrusted(prefix) {
6158 return true, "", nil
6159 }
6160 command := strings.TrimSpace(req.Command)
6161 if command == "" {
6162 command = strings.TrimSpace(string(req.Args))
6163 }
6164 subject := fmt.Sprintf(i18n.M.PlanModeBashTrustSubjectFmt, prefix, command)
6165 reason := i18n.M.PlanModeBashTrustReason
6166 reply, err := p.c.requestFreshApprovalDecision(ctx, agent.PlanModeReadOnlyCommandApprovalTool, subject, req.Args, reason)
6167 if err != nil {
6168 return false, "approval aborted", err
6169 }
6170 if !reply.allow {
6171 return false, i18n.M.PlanModeBashTrustDeclined, nil
6172 }
6173 if reply.session {
6174 p.c.approval.grantPlanModeReadOnlyCommand(prefix)
6175 }
6176 if reply.persist && p.c.onRememberPlanModeReadOnlyCommand != nil {
6177 p.c.emitPlanModeReadOnlyCommandTrustResult(p.c.onRememberPlanModeReadOnlyCommand(prefix))
6178 p.c.approval.grantPlanModeReadOnlyCommand(prefix)
6179 }
6180 return true, "", nil
6181 }
6182
6183 func approvalDisplaySubject(tool, subject string, args json.RawMessage) string {
6184 switch tool {
6185 case memoryRememberTool:
6186 return rememberApprovalSubject(subject, args)
6187 case memoryForgetTool:
6188 return forgetApprovalSubject(subject, args)
6189 case "move_file":
6190 return moveApprovalSubject(subject, args)
6191 default:
6192 return subject
6193 }
6194 }
6195
6196 func moveApprovalSubject(fallback string, args json.RawMessage) string {
6197 if len(args) == 0 {
6198 return fallback
6199 }
6200 var in struct {
6201 SourcePath string `json:"source_path"`
6202 DestinationPath string `json:"destination_path"`
6203 }
6204 if err := json.Unmarshal(args, &in); err != nil {
6205 return fallback
6206 }
6207 if in.SourcePath == "" || in.DestinationPath == "" {
6208 return fallback
6209 }
6210 return in.SourcePath + " -> " + in.DestinationPath
6211 }
6212
6213 func rememberApprovalSubject(fallback string, args json.RawMessage) string {
6214 if len(args) == 0 {
6215 return fallback
6216 }
6217 var in struct {
6218 Name string `json:"name"`
6219 Title string `json:"title"`
6220 Description string `json:"description"`
6221 Type string `json:"type"`
6222 Body string `json:"body"`
6223 }
6224 if err := json.Unmarshal(args, &in); err != nil {
6225 return fallback
6226 }
6227 name := approvalCompactText(firstNonEmpty(in.Name, in.Title))
6228 desc := approvalTruncate(approvalCompactText(in.Description), 180)
6229 body := approvalTruncate(approvalCompactText(in.Body), 240)
6230 typ := string(memory.NormalizeType(in.Type))
6231
6232 var b strings.Builder
6233 b.WriteString(i18n.M.MemoryApprovalSaveUpdate)
6234 baseLen := b.Len()
6235 if name != "" {
6236 fmt.Fprintf(&b, " %q", name)
6237 }
6238 if typ != "" {
6239 fmt.Fprintf(&b, " [%s]", typ)
6240 }
6241 if desc != "" {
6242 b.WriteString(": ")
6243 b.WriteString(desc)
6244 }
6245 if body != "" {
6246 if desc == "" {
6247 b.WriteString(": ")
6248 } else {
6249 b.WriteString(" | ")
6250 }
6251 b.WriteString(i18n.M.MemoryApprovalBodyLabel)
6252 b.WriteString(": ")
6253 b.WriteString(body)
6254 }
6255 if b.Len() == baseLen && fallback != "" {
6256 return fallback
6257 }
6258 return b.String()
6259 }
6260
6261 func forgetApprovalSubject(fallback string, args json.RawMessage) string {
6262 if len(args) == 0 {
6263 return fallback
6264 }
6265 var in struct {
6266 Name string `json:"name"`
6267 }
6268 if err := json.Unmarshal(args, &in); err != nil {
6269 return fallback
6270 }
6271 name := approvalCompactText(in.Name)
6272 if name == "" {
6273 return fallback
6274 }
6275 return fmt.Sprintf(i18n.M.MemoryApprovalArchiveFmt, name)
6276 }
6277
6278 func firstNonEmpty(values ...string) string {
6279 for _, value := range values {
6280 if strings.TrimSpace(value) != "" {
6281 return value
6282 }
6283 }
6284 return ""
6285 }
6286
6287 func approvalCompactText(s string) string {
6288 return strings.Join(strings.Fields(s), " ")
6289 }
6290
6291 func approvalTruncate(s string, maxRunes int) string {
6292 if maxRunes <= 0 {
6293 return ""
6294 }
6295 runes := []rune(s)
6296 if len(runes) <= maxRunes {
6297 return s
6298 }
6299 return string(runes[:maxRunes]) + "..."
6300 }
6301
6302 type seedTodo struct {
6303 Content string `json:"content"`
6304 Status string `json:"status"`
6305 Level int `json:"level,omitempty"`
6306 }
6307
6308 // seedPlanTodos turns an approved plan into a starter task list and emits it as a
6309 // synthetic todo_write event, so the live task panel populates the instant the
6310 // user approves — a structural guarantee, not a prompt the model might ignore.
6311 // The model still flips item status as it works (only it knows its own
6312 // progress); this just makes the list exist. No-op when the plan has no list.
6313 func (c *Controller) seedPlanTodos(plan string) string {
6314 args := PlanTodosJSON(plan)
6315 if args == "" {
6316 return ""
6317 }
6318 t := event.Tool{ID: "plan-seed", Name: "todo_write", Args: args, ReadOnly: true}
6319 c.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: t})
6320 t.Output = "task list seeded from the approved plan"
6321 c.sink.Emit(event.Event{Kind: event.ToolResult, Tool: t})
6322 c.seedAgentTodoState(args)
6323 return args
6324 }
6325
6326 func (c *Controller) seedAgentTodoState(args string) {
6327 if c.executor == nil {
6328 return
6329 }
6330 todos := agentTodoStateFromArgs(args)
6331 if len(todos) == 0 {
6332 return
6333 }
6334 c.executor.SeedTodoState(todos)
6335 }
6336
6337 func (c *Controller) completePlanTodos(args string) {
6338 if args == "" {
6339 return
6340 }
6341 done := completedPlanTodosJSON(args)
6342 if done == "" {
6343 return
6344 }
6345 t := event.Tool{ID: "plan-seed", Name: "todo_write", Args: done, ReadOnly: true}
6346 c.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: t})
6347 t.Output = "approved plan finished"
6348 c.sink.Emit(event.Event{Kind: event.ToolResult, Tool: t})
6349 c.replaceAgentTodoState(done)
6350 }
6351
6352 func (c *Controller) replaceAgentTodoState(args string) {
6353 if c.executor == nil {
6354 return
6355 }
6356 todos := agentTodoStateFromArgs(args)
6357 if len(todos) == 0 {
6358 return
6359 }
6360 c.executor.ReplaceTodoState(todos)
6361 }
6362
6363 func agentTodoStateFromArgs(args string) []evidence.TodoItem {
6364 var payload struct {
6365 Todos []evidence.TodoItem `json:"todos"`
6366 }
6367 if err := json.Unmarshal([]byte(args), &payload); err != nil {
6368 return nil
6369 }
6370 return payload.Todos
6371 }
6372
6373 // PlanTodosJSON parses an approved plan's markdown into todo_write-shaped args
6374 // JSON ({"todos":[...]}), or "" when the plan has no list items. The exit_plan_mode
6375 // path seeds via seedPlanTodos (an event); a frontend whose own approval flow
6376 // bypasses exit_plan_mode (the chat TUI's text-plan approval) calls this directly
6377 // to render the same starter checklist. Shared parsing keeps the two consistent.
6378 func PlanTodosJSON(plan string) string {
6379 items := parsePlanTodos(plan)
6380 if len(items) == 0 {
6381 return ""
6382 }
6383 // Host-generated state must obey the same contract as a model todo_write.
6384 // Returning no seed is safer than showing a list the agent cannot advance.
6385 if err := evidence.ValidateSerialTodos(seedTodoEvidenceState(items)); err != nil {
6386 return ""
6387 }
6388 b, err := json.Marshal(map[string]any{"todos": items})
6389 if err != nil {
6390 return ""
6391 }
6392 return string(b)
6393 }
6394
6395 func completedPlanTodosJSON(args string) string {
6396 var p struct {
6397 Todos []seedTodo `json:"todos"`
6398 }
6399 if err := json.Unmarshal([]byte(args), &p); err != nil || len(p.Todos) == 0 {
6400 return ""
6401 }
6402 for i := range p.Todos {
6403 p.Todos[i].Status = "completed"
6404 }
6405 b, err := json.Marshal(map[string]any{"todos": p.Todos})
6406 if err != nil {
6407 return ""
6408 }
6409 return string(b)
6410 }
6411
6412 // parsePlanTodos extracts a starter task list from an approved plan's markdown
6413 // list items (bulleted or numbered), capped so a long plan can't flood the panel.
6414 // A flat list starts its first item; a layered list starts the first sub-step and
6415 // keeps its phase pending until every child is complete, matching todo_write's
6416 // serial state machine. It understands ONLY markdown lists
6417 // — an unambiguous, standard structure — and deliberately does not guess at prose,
6418 // tables, or arrow sequences (those need brittle, language-specific heuristics).
6419 // The plan-mode marker steers the model to present its plan as a list, so this
6420 // catches the normal case; anything it misses is covered by the model's own
6421 // todo_write calls as it executes.
6422 func parsePlanTodos(plan string) []seedTodo {
6423 var todos []seedTodo
6424 for _, raw := range strings.Split(plan, "\n") {
6425 item, level, ok := listItem(raw)
6426 if !ok {
6427 continue
6428 }
6429 todos = append(todos, seedTodo{Content: item, Status: "pending", Level: level})
6430 if len(todos) >= 20 {
6431 break
6432 }
6433 }
6434 if len(todos) == 0 {
6435 return nil
6436 }
6437 // Be tolerant of a model that emits an indented bullet before a phase
6438 // heading: promote the first item so the host never seeds an orphan L1.
6439 if todos[0].Level == 1 {
6440 todos[0].Level = 0
6441 }
6442 normalized := evidence.NormalizeSerialTodos(seedTodoEvidenceState(todos))
6443 for i := range todos {
6444 todos[i].Status = normalized[i].Status
6445 todos[i].Level = normalized[i].Level
6446 }
6447 return todos
6448 }
6449
6450 func seedTodoEvidenceState(todos []seedTodo) []evidence.TodoItem {
6451 state := make([]evidence.TodoItem, len(todos))
6452 for i, todo := range todos {
6453 state[i] = evidence.TodoItem{
6454 Content: todo.Content,
6455 Status: todo.Status,
6456 Level: todo.Level,
6457 }
6458 }
6459 return state
6460 }
6461
6462 func (c *Controller) sessionMessageCount() int {
6463 if c.executor == nil {
6464 return 0
6465 }
6466 return c.executor.Session().Len()
6467 }
6468
6469 // hasTodoUpdateSince reports whether the model emitted its own todo_write after
6470 // index start, so the seeded plan todos aren't auto-completed over the model's
6471 // own bookkeeping.
6472 func (c *Controller) hasTodoUpdateSince(start int) bool {
6473 if c.executor == nil {
6474 return false
6475 }
6476 msgs := c.executor.Session().Messages
6477 if start < 0 || start > len(msgs) {
6478 start = len(msgs)
6479 }
6480 _, ok := latestTodoArgsSince(msgs, start)
6481 return ok
6482 }
6483
6484 func latestTodoArgsSince(msgs []provider.Message, start int) (string, bool) {
6485 for i := len(msgs) - 1; i >= start; i-- {
6486 for j := len(msgs[i].ToolCalls) - 1; j >= 0; j-- {
6487 tc := msgs[i].ToolCalls[j]
6488 if tc.Name == "todo_write" {
6489 return tc.Arguments, true
6490 }
6491 }
6492 }
6493 return "", false
6494 }
6495
6496 // listItem parses a markdown list line ("- x", "* x", "1. x", "2) x") into its
6497 // task text and a nesting level derived from leading indentation (0 for a
6498 // top-level item, 1 for an indented sub-step — capped at 1 since the plan is
6499 // two-level). ok is false when the line isn't a list item. Light inline-markdown
6500 // stripping keeps the checklist readable.
6501 func listItem(line string) (content string, level int, ok bool) {
6502 trimmed := strings.TrimLeft(line, " \t")
6503 if trimmed == "" {
6504 return "", 0, false
6505 }
6506 indent := 0
6507 for _, c := range line[:len(line)-len(trimmed)] {
6508 if c == '\t' {
6509 indent += 4
6510 } else {
6511 indent++
6512 }
6513 }
6514 s := trimmed
6515 // A numbered markdown heading ("### 1. Add the loader") is how models often
6516 // write a phase even when asked for a list; strip the heading marker and
6517 // treat it as a top-level phase. A heading without a number (a section
6518 // title like "## Plan") falls through and is ignored.
6519 heading := false
6520 if h := strings.TrimLeft(s, "#"); h != s && strings.HasPrefix(h, " ") {
6521 heading = true
6522 s = strings.TrimSpace(h)
6523 }
6524 switch {
6525 case strings.HasPrefix(s, "- "), strings.HasPrefix(s, "* "), strings.HasPrefix(s, "+ "):
6526 s = s[2:]
6527 default:
6528 // numbered: leading digits, then "." or ")", then a space
6529 i := 0
6530 for i < len(s) && s[i] >= '0' && s[i] <= '9' {
6531 i++
6532 }
6533 if i == 0 || i+1 >= len(s) || (s[i] != '.' && s[i] != ')') || s[i+1] != ' ' {
6534 return "", 0, false
6535 }
6536 s = s[i+2:]
6537 }
6538 s = strings.TrimSpace(s)
6539 s = strings.TrimPrefix(s, "[ ] ")
6540 s = strings.TrimPrefix(s, "[x] ")
6541 s = strings.ReplaceAll(s, "`", "")
6542 s = strings.ReplaceAll(s, "**", "")
6543 s = strings.TrimSpace(s)
6544 if s == "" {
6545 return "", 0, false
6546 }
6547 if heading {
6548 return s, 0, true // a heading is always a top-level phase
6549 }
6550 if indent >= 2 {
6551 return s, 1, true
6552 }
6553 return s, 0, true
6554 }
6555
6556 // parseRewind parses the arguments after "/rewind". The user may provide:
6557 //
6558 // /rewind → latest checkpoint, both
6559 // /rewind <turn> → that turn, both
6560 // /rewind <turn> <scope> → that turn, code|conversation|both
6561 //
6562 // If no turn is given, the latest checkpoint is used. If no scope is given, Both is assumed.
6563 func parseRewind(args string, cps []checkpoint.Meta) (int, RewindScope, error) {
6564 fields := strings.Fields(args)
6565 if len(fields) == 0 {
6566 if len(cps) == 0 {
6567 return 0, RewindBoth, fmt.Errorf("no checkpoints available")
6568 }
6569 return cps[len(cps)-1].Turn, RewindBoth, nil
6570 }
6571 turn, err := strconv.Atoi(fields[0])
6572 if err != nil {
6573 return 0, RewindBoth, fmt.Errorf("invalid turn: %w", err)
6574 }
6575 scope := RewindBoth
6576 if len(fields) >= 2 {
6577 switch strings.ToLower(fields[1]) {
6578 case "code":
6579 scope = RewindCode
6580 case "conversation":
6581 scope = RewindConversation
6582 case "both":
6583 scope = RewindBoth
6584 default:
6585 return 0, RewindBoth, fmt.Errorf("unknown scope %q", fields[1])
6586 }
6587 }
6588 return turn, scope, nil
6589 }
6590
6591 // requestApproval emits an ApprovalRequest and blocks until Approve(ID, …)
6592 // answers or ctx is cancelled. A prior session grant (or a bypass posture) for
6593 // the same approval scope short-circuits. The approvalManager's promptMu
6594 // serialises outstanding prompts; this method keeps the I/O (events, hooks,
6595 // remember) that the manager deliberately stays out of.
6596 func (c *Controller) requestApproval(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, error) {
6597 return c.requestApprovalWithReason(ctx, tool, subject, args, "")
6598 }
6599
6600 func (c *Controller) requestApprovalWithReason(ctx context.Context, tool, subject string, args json.RawMessage, reason string) (bool, bool, error) {
6601 return c.requestApprovalWithReasonOptions(ctx, tool, subject, args, reason, approvalDecisionOptions{})
6602 }
6603
6604 func (c *Controller) requestApprovalWithReasonOptions(ctx context.Context, tool, subject string, args json.RawMessage, reason string, opts approvalDecisionOptions) (bool, bool, error) {
6605 r, err := c.requestApprovalDecisionWithOptions(ctx, tool, subject, args, reason, opts)
6606 if err != nil {
6607 return false, false, err
6608 }
6609 // Plan approvals are one-shot — never persist a session grant for them, or
6610 // every future plan would auto-approve.
6611 if r.allow && r.session && !requiresFreshApprovalTool(tool) {
6612 c.approval.grantSession(tool, subject)
6613 }
6614 if r.allow && r.persist && !requiresFreshApprovalTool(tool) && c.onRemember != nil {
6615 c.emitRememberResult(c.onRemember(permission.RememberRuleForScope(tool, subject)))
6616 }
6617 return r.allow, false, nil
6618 }
6619
6620 func (c *Controller) requestFreshApprovalDecision(ctx context.Context, tool, subject string, args json.RawMessage, reason string) (approvalReply, error) {
6621 return c.requestApprovalDecisionWithOptions(ctx, tool, subject, args, reason, approvalDecisionOptions{fresh: true})
6622 }
6623
6624 type approvalDecisionOptions struct {
6625 // fresh marks a user trust/business decision rather than an ordinary tool
6626 // permission. It may reuse an explicit session grant, but YOLO/auto approval
6627 // must not answer or drain the prompt.
6628 fresh bool
6629 // requireHuman marks an ordinary tool approval that Auto, an approved-plan
6630 // window, Guardian, or an allowing hook must not answer. Unlike fresh it
6631 // retains the ordinary four-choice UI and YOLO remains an explicit bypass.
6632 requireHuman bool
6633 }
6634
6635 func (c *Controller) requestApprovalDecisionWithOptions(ctx context.Context, tool, subject string, args json.RawMessage, reason string, opts approvalDecisionOptions) (approvalReply, error) {
6636 // YOLO/full access and the just-approved-plan execution window auto-allow
6637 // approval-gated tools without prompting. Plan approval is a user decision,
6638 // not a tool permission, so it deliberately stays interactive.
6639 if c.approval.preApprovedForDecisionOptions(tool, subject, args, opts.fresh, opts.requireHuman) {
6640 return approvalReply{allow: true}, nil
6641 }
6642
6643 c.approval.promptMu.Lock()
6644 defer c.approval.promptMu.Unlock()
6645
6646 // Re-check: a session grant may have landed while we queued behind another
6647 // prompt for the same subject.
6648 if c.approval.preApprovedForDecisionOptions(tool, subject, args, opts.fresh, opts.requireHuman) {
6649 return approvalReply{allow: true}, nil
6650 }
6651
6652 // Claude's PermissionRequest contract answers the dialog on the plugin's
6653 // behalf (auto-allow/auto-deny) instead of merely observing it, so a
6654 // decision here must preempt the prompt rather than just notify — this
6655 // runs synchronously and before the dialog is shown. Native Reasonix
6656 // PermissionRequest hooks stay advisory-only (see claudePermissionBlocking).
6657 //
6658 // A hook's auto-allow must never stand in for a human-required decision:
6659 // sandbox escapes, Reasonix config writes, memory remember/forget, and
6660 // plan approval (RequiresFreshHumanApprovalTool) are deliberately excluded
6661 // from YOLO/auto-approval and Guardian too, so a broadly-matched plugin
6662 // hook returning "allow" can't silently rubber-stamp them. A deny still
6663 // applies universally — refusing is always safe to honor automatically.
6664 if hookSubject, hookArgs, ok := permissionRequestHookPayload(tool, subject, args); ok {
6665 if decision, _ := c.hooks.PermissionRequest(ctx, tool, hookSubject, hookArgs); decision != nil {
6666 switch {
6667 case !*decision:
6668 return approvalReply{}, nil
6669 case !opts.fresh && !opts.requireHuman && !requiresFreshApprovalTool(tool):
6670 return approvalReply{allow: true}, nil
6671 }
6672 // An "allow" opinion on a fresh-human-required decision is
6673 // ignored; fall through to the normal interactive prompt.
6674 }
6675 }
6676
6677 c.approval.promptEmitMu.Lock()
6678 var id string
6679 var reply chan approvalReply
6680 if opts.fresh || opts.requireHuman || tool == planApprovalTool {
6681 kind := ""
6682 if tool == planApprovalTool {
6683 kind = "plan"
6684 }
6685 id, reply = c.approval.registerDecisionKindWithInput(tool, subject, reason, args, opts.fresh, opts.requireHuman, kind, nil)
6686 } else {
6687 id, reply = c.approval.registerWithInput(tool, subject, reason, args)
6688 }
6689
6690 c.sink.Emit(c.approvalRequestEvent(event.Approval{ID: id, Tool: tool, Subject: subject, Reason: reason, RawInput: append(json.RawMessage(nil), args...), Fresh: opts.fresh}))
6691 c.approval.promptEmitMu.Unlock()
6692 // The agent now needs the user's attention; a Notification hook can ping an
6693 // external channel (desktop notice, phone) while the run blocks on the reply.
6694 go c.hooks.Notification(ctx, approvalNotificationText(tool, subject), "permission_prompt")
6695
6696 waitCtx, cancelWait := c.approval.waitContext(ctx)
6697 defer cancelWait()
6698
6699 select {
6700 case r := <-reply:
6701 return r, nil
6702 case <-waitCtx.Done():
6703 c.approval.cancel(id)
6704 return approvalReply{}, waitCtx.Err()
6705 }
6706 }
6707
6708 func (c *Controller) approvalRequestEvent(approval event.Approval) event.Event {
6709 return event.Event{Kind: event.ApprovalRequest, Approval: approval}
6710 }
6711
6712 func (c *Controller) emitRememberResult(r RememberResult) {
6713 if r.Err != nil {
6714 c.sink.Emit(event.Event{
6715 Kind: event.Notice,
6716 Level: event.LevelWarn,
6717 Text: fmt.Sprintf(i18n.M.PermissionSaveFailedFmt, r.Rule, r.Err),
6718 })
6719 return
6720 }
6721 switch {
6722 case r.Saved:
6723 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PermissionSavedFmt, r.Path, r.Rule)})
6724 case strings.TrimSpace(r.CoveredBy) != "":
6725 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PermissionAlreadyAllowedFmt, r.Path, r.CoveredBy)})
6726 }
6727 }
6728
6729 func (c *Controller) emitPlanModeReadOnlyCommandTrustResult(r PlanModeReadOnlyCommandTrustResult) {
6730 prefix := strings.TrimSpace(r.Prefix)
6731 if r.Err != nil {
6732 c.sink.Emit(event.Event{
6733 Kind: event.Notice,
6734 Level: event.LevelWarn,
6735 Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustFailedFmt, prefix, r.Err),
6736 })
6737 return
6738 }
6739 switch {
6740 case r.Saved:
6741 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustSavedFmt, r.Path, prefix)})
6742 case strings.TrimSpace(r.CoveredBy) != "":
6743 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustAlreadyFmt, r.Path, r.CoveredBy)})
6744 }
6745 }
6746
6747 // detectProjectModules scans the workspace root for top-level source directories
6748 // to enable module-aware task routing in /plan-exec.
6749 func (c *Controller) detectProjectModules() []string {
6750 root := c.sessionDir
6751 for i := 0; i < 3 && root != ""; i++ {
6752 if hasFile(root, "go.mod") || hasFile(root, "package.json") || hasFile(root, ".git") {
6753 return listSourceDirs(root, 2)
6754 }
6755 root = filepath.Dir(root)
6756 if root == filepath.Dir(root) {
6757 break
6758 }
6759 }
6760 return nil
6761 }
6762
6763 func hasFile(dir, name string) bool {
6764 _, err := os.Stat(filepath.Join(dir, name))
6765 return err == nil
6766 }
6767
6768 func listSourceDirs(root string, maxDepth int) []string {
6769 skip := map[string]bool{
6770 ".git": true, ".github": true, "node_modules": true,
6771 "vendor": true, ".reasonix": true, "desktop": true,
6772 "dist": true, "build": true, ".cache": true, "bin": true,
6773 }
6774 var dirs []string
6775 walkDir(root, "", skip, maxDepth, &dirs)
6776 return dirs
6777 }
6778
6779 func walkDir(root, rel string, skip map[string]bool, depth int, out *[]string) {
6780 if depth <= 0 {
6781 return
6782 }
6783 dir := root
6784 if rel != "" {
6785 dir = filepath.Join(root, rel)
6786 }
6787 entries, err := os.ReadDir(dir)
6788 if err != nil {
6789 return
6790 }
6791 for _, e := range entries {
6792 name := e.Name()
6793 if !e.IsDir() || skip[name] || strings.HasPrefix(name, ".") {
6794 continue
6795 }
6796 childRel := name
6797 if rel != "" {
6798 childRel = rel + "/" + name
6799 }
6800 if hasSourceFiles(filepath.Join(root, childRel)) {
6801 *out = append(*out, childRel)
6802 }
6803 walkDir(root, childRel, skip, depth-1, out)
6804 }
6805 }
6806
6807 func hasSourceFiles(dir string) bool {
6808 entries, err := os.ReadDir(dir)
6809 if err != nil {
6810 return false
6811 }
6812 for _, e := range entries {
6813 if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
6814 return true
6815 }
6816 }
6817 return false
6818 }
6819
6819 lines GO