返回 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 "reflect"
23 "slices"
24 "sort"
25 "strconv"
26 "strings"
27 "sync"
28 "sync/atomic"
29 "time"
30
31 "reasonix/internal/ablation"
32 "reasonix/internal/agent"
33 "reasonix/internal/agentpreset"
34 "reasonix/internal/autoresearch"
35 "reasonix/internal/billing"
36 "reasonix/internal/capability"
37 "reasonix/internal/checkpoint"
38 "reasonix/internal/command"
39 "reasonix/internal/config"
40 "reasonix/internal/event"
41 "reasonix/internal/evidence"
42 "reasonix/internal/extension"
43 "reasonix/internal/extension/dispatch"
44 "reasonix/internal/extension/uihub"
45 goaldomain "reasonix/internal/goal"
46 "reasonix/internal/guardian"
47 "reasonix/internal/hook"
48 "reasonix/internal/i18n"
49 "reasonix/internal/jobs"
50 "reasonix/internal/mcpinteraction"
51 "reasonix/internal/memory"
52 "reasonix/internal/nilutil"
53 "reasonix/internal/permission"
54 "reasonix/internal/permissionpreset"
55 "reasonix/internal/persistentshell"
56 "reasonix/internal/plugin"
57 "reasonix/internal/provider"
58 "reasonix/internal/sandbox"
59 "reasonix/internal/session"
60 "reasonix/internal/sessioncontext"
61 "reasonix/internal/sessioninbox"
62 "reasonix/internal/sessiontemp"
63 "reasonix/internal/shellrun"
64 "reasonix/internal/skill"
65 "reasonix/internal/store"
66 "reasonix/internal/taskmonitor"
67 "reasonix/internal/tool"
68 "reasonix/internal/workspacelease"
69 )
70
71 // ErrTurnRunning reports that a caller tried to start a second foreground turn
72 // while one is already active in the same Controller.
73 var ErrTurnRunning = errors.New("turn already running")
74
75 // ErrNoFinalReadinessRecovery is retained for old recovery-action clients.
76 var ErrNoFinalReadinessRecovery = errors.New("final_readiness_recovery_retired: readiness recovery actions can no longer restore evidence or replay checks")
77
78 // ErrRuntimeDraining reports that a caller targeted a controller generation
79 // superseded by a successful rebuild.
80 var ErrRuntimeDraining = errors.New("runtime is draining after rebuild")
81
82 // ErrRecoveryRequired reports that the previous foreground activity did not
83 // stop inside the cancellation grace period. The controller keeps queued input
84 // and write ownership sealed until the process is restarted.
85 var ErrRecoveryRequired = errors.New("session recovery is required before another turn can start")
86
87 // errTurnRunningRotation and errRotationInProgress are returned by the
88 // session-rotation gate (beginRotation) when a rotation cannot proceed: a turn
89 // is in flight, or another rotation already holds the gate.
90 var (
91 errTurnRunningRotation = errors.New("cannot start a new session while a turn is running")
92 errRotationInProgress = errors.New("cannot start a new session while another session change is in progress")
93 )
94
95 // errNoSessionPath is returned by snapshot when a session has content to persist
96 // but no resolved session path — a misconfiguration (e.g. an unresolvable data
97 // dir in a bot deployment) that previously dropped conversations silently
98 // (#4414). Callers log it and continue; it must never be swallowed quietly.
99 var errNoSessionPath = errors.New("session has content but no session path; conversation cannot be persisted")
100
101 // Controller drives one chat session. Construct with New; drive with the command
102 // methods; observe through the Sink passed in Options.
103 type Controller struct {
104 lifecycleDiagnostics lifecycleDiagnosticBuffer
105 runtimeState controllerRuntimeState
106 controllerPromptRouting
107 authentication authenticationGate
108 runner agent.Runner
109 executor *agent.Agent
110 guardianSess *guardian.Session // nil when guardian is disabled
111 guardianPath string // persisted guardian session file ("" when disabled)
112 // taskBudget is the configured spend gate, as passed at construction.
113 taskBudget agent.TaskBudget
114 // goalTokenBudget bounds an unattended Goal loop; 0 leaves it unbounded.
115 goalTokenBudget int
116 goalResourceMu sync.Mutex
117 goalTokensUsed int
118 goalRequestsUsed int
119 goalTokenLimit int
120 goalBudgetExtensions int
121
122 // goalUsageTee accounts billable usage events into the active goal turn's
123 // observational token total. It wraps the public sink when the caller didn't provide one.
124 goalUsageTee *goalUsageTee
125 sink event.Sink
126 policy permission.Policy
127 // subagentGate is the shared gate every headless-only sub-agent surface
128 // reads from (see Options.SubagentGate). Nil when the caller didn't build
129 // one — sub-agents then keep whatever gate they were constructed with.
130 subagentGate *SharedHeadlessGate
131
132 label string
133 selection modelSelection
134 resolveSessionModel func(string, string) (string, error)
135 visionModel string
136 visionProviderResolver func(string) (provider.Provider, error)
137 visionModelSelector func(string, string) (string, bool)
138 modelCapabilityResolver func(*config.ProviderEntry) config.ResolvedModelCapability
139 frozenImageInput *bool
140 imageCapabilityChanged func() bool
141 controllerAttachmentState
142 modelSettings controllerModelSettings
143 prompt controllerPromptState
144 pinnedContextLoader PinnedContextLoader
145 sessionContextStatic sessioncontext.Sections
146 sessionDir string
147 controllerSessionBinding
148 // managedSessionEvents is set for hosts that publish controllers only after
149 // a session-lease handoff. An unpublished replacement may read the shared
150 // v3 projection, but it must not mutate that projection before its final
151 // write authority is bound.
152 managedSessionEvents atomic.Bool
153 commands atomic.Pointer[[]command.Command]
154 // skills owns the session's discovered skills (enabled subset, full set, and
155 // the reloadable stores) — the skills slice of the Capabilities concern. See
156 // skill.go.
157 skills skillSet
158 skillRunner skill.SubagentRunner
159 readOnlySkillRunner skill.SubagentRunner
160 skillProfile skill.ProfileResolver
161 disableImplicitSkillInvocation bool
162 slashSkillSeq atomic.Uint64
163 hooks *hook.Runner // session hook runner; nil-safe (no hooks configured)
164 // hookContexts carries one-shot lifecycle hook context into the next real
165 // user turn without changing the cache-stable system prompt.
166 hookContexts []string
167 // memory owns the loaded memory snapshot, the pending turn-tail notes queue,
168 // and write serialization behind its own locks, off c.mu — so a memory-panel
169 // save never stalls an approval or status poll. See memory.go.
170 memory memoryManager
171 cleanup func()
172 responseLanguage string
173 reasoningLanguage string
174 disableColdResumePrune bool // legacy; rewrite elision removed, still gates cold notice
175 headPolicy sessionHeadPolicy
176 // testCacheColdAfter overrides cacheColdAfter() in tests. Zero uses the
177 // vendor-aware resolution from config.
178 testCacheColdAfter time.Duration
179 // testCancelGrace overrides the production cancellation grace in tests.
180 // Zero uses the documented 15 second boundary.
181 testCancelGrace time.Duration
182
183 shell sandbox.Shell // interpreter for user-invoked "!" commands; zero = auto
184 startedOnce bool // guards the one-shot SessionStart hook on first turn
185 closeOnce sync.Once // makes close idempotent under racing teardown paths
186 closeFinalizeOnce sync.Once // releases persistence/resources only after the terminal boundary
187 closeFinalized chan struct{} // closes after every controller-owned resource has been released
188 closeFireSessionEnd bool
189 closeJobsMode closeJobsMode
190 onRemember func(rule string) RememberResult // set via Options; invoked when user picks "always allow"
191 onRememberPlanModeReadOnlyCommand func(prefix string) PlanModeReadOnlyCommandTrustResult
192 writeAccess controllerWriteAccess
193 sessionRecoveryMeta func(SessionRecoveryRequest) agent.BranchMeta
194 onSessionRecovered func(SessionRecoveryInfo) error
195 onSessionTransition func(SessionTransitionInfo) error
196 onSessionRotation func(context.Context, SessionRotationRequest) (SessionRotationPlan, error)
197
198 // balanceURL/balanceKey target the active provider's optional wallet-balance
199 // endpoint (empty when the provider declares none). Captured at build so a
200 // model/key switch — which rebuilds the controller — refreshes them.
201 balanceURL string
202 balanceKey string
203 balanceClient *http.Client
204
205 // jobs is the session-scoped background-job manager. The agent's background
206 // tools spawn into it; Compose drains its completion notes into the next turn;
207 // Close cancels its still-running jobs.
208 jobs *jobs.Manager
209 // workspaceLease is the Delivery writer owner shared with the executor.
210 // It is exposed only through a sanitized state snapshot for Desktop recovery.
211 workspaceLease *workspacelease.Owner
212
213 // mcp owns the session's live tool/plugin surface behind its own lock, off
214 // c.mu; the Controller keeps config-facing orchestration. See mcp.go.
215 mcp mcpManager
216 mcpDefaultCallTimeout time.Duration
217 mcpConfigureSpec func(*plugin.Spec)
218 capabilityRuntime *agent.MCPCapabilityRuntime
219
220 runtimeGeneration uint64 // PublishGate gen; 0 disables
221 permissionMu sync.Mutex
222 permissionStateMu sync.RWMutex
223 permissionRevision atomic.Uint64
224 runtimeOwner *extension.RuntimeOwner
225 lastResumeDecision extension.ResumeDecision
226 // extensions is the frozen extension dispatcher for this controller
227 // generation, or nil when no v2 runtime packages are installed (the
228 // universal pre-dispatch fast path). It is installed before the controller
229 // starts serving (Options.Extensions or SetExtensions) and never swapped
230 // afterwards, so wiring points read it without locking.
231 extensions *dispatch.Dispatcher
232 // extensionUI is the host extension UI hub for this controller generation
233 // (stage 8a), or nil when no v2 runtime packages started. Installed via
234 // SetExtensionUI before serving and never swapped; readers take c.mu.
235 extensionUI *uihub.Hub
236 // providerResolver is the build's merged provider catalog (extension
237 // sidecar providers over the config/broker base), or nil when no sidecar
238 // declared providers. Immutable after New; ProviderCatalog reads it.
239 providerResolver provider.Resolver
240
241 // Capability routing (Delivery hybrid route + dual-model Planner proxy).
242 // Not part of the provider-visible prefix; only seeds the turn-scoped ledger
243 // and optional semantic router.
244 pluginCfg []config.PluginEntry
245 capCachedTools map[string][]plugin.CachedTool
246 capCacheKeyOK map[string]bool
247 semanticRouter *capability.SemanticRouter
248 capabilityAudit *capability.Audit
249 // capabilityProxy directs unready MCP candidates to use_capability in the
250 // transient route block (Delivery and dual-model Planner).
251 capabilityProxy bool
252 // proxyToolsFn returns live tools observed through use_capability without
253 // entering the provider-visible registry (dual-model Planner).
254 proxyToolsFn func() map[string][]plugin.CachedTool
255 ablation ablation.Set
256
257 // goals owns the active goal's FSM (status, intercepts, idle/turn counters)
258 // and its persistence, behind its own mutex so a per-turn goal save never
259 // stalls an approval or status poll on c.mu. See goal.go.
260 goals goalMachine
261 // goalLifecycle is the versioned session goal authority. The legacy
262 // goalMachine remains only while old sidecars are imported and must not be
263 // used as the execution source once the v3 lifecycle cutover is complete.
264 goalLifecycleMu sync.RWMutex
265 goalLifecycleMutationMu sync.Mutex
266 goalLifecycle *goaldomain.Machine
267 goalLifecycleLoadErr error
268 // goalDriver is a level-triggered, process-local scheduler. It never owns a
269 // cross-turn Activity: each accepted continuation enters through the normal
270 // guarded top-level turn path.
271 goalDriverMu sync.Mutex
272 goalDriverWG sync.WaitGroup
273 goalDriverPending bool
274 goalDriverActive *goalRoundReservation
275 goalDriverControl goalDriverControl
276 // legacyResearchArchive reads explicit pre-unification task paths. It never
277 // creates or mutates archive state. See
278 // autoresearch_manager.go.
279 legacyResearchArchive legacyResearchArchive
280 legacyRestoreMu sync.Mutex
281 legacyRestore legacyGoalRestore
282
283 // workspaceRoot is the workspace root: the base for resolving @-refs and slash
284 // path refs, the working directory for user "!" shell commands and custom
285 // command discovery, and the guard root for checkpoint restore writes. It is
286 // surfaced to frontends via WorkspaceRoot().
287 workspaceRoot string
288
289 // externalFolderRefs maps session-generated @ tokens to user-dropped
290 // directories outside workspaceRoot. It is intentionally per-controller:
291 // dragging a folder authorizes that folder for this chat session only, without
292 // widening scoped @ resolution to arbitrary absolute paths.
293 externalFolderRefsMu sync.RWMutex
294 externalFolderRefs map[string]string
295 externalFolderToolRefs externalFolderToolRefs
296
297 // checkpoints owns the snapshot-based rewind bookkeeping (the per-session
298 // store, the monotonic turn counter, and the conversation-rewind boundary map)
299 // behind its own lock, off c.mu — so a boundary read for a rewind/fork never
300 // contends on the run-state lock. The Controller keeps the rewind/fork/summarize
301 // orchestration (truncating the session, restoring code, emitting events). See
302 // checkpoint.go.
303 checkpoints checkpointManager
304 // mutationObserver is the host-side file mutation observer for v2 checkpoints.
305 mutationObserver *checkpoint.MutationObserver
306 // sessionRevision increments on successful rewind/undo and is used as a
307 // prepare/commit freshness token.
308 sessionRevision int64
309
310 // approval owns the approval/ask prompt bookkeeping and the runtime approval
311 // posture (ask/auto/yolo, session grants, the just-approved-plan window)
312 // behind its own locks, off c.mu. The Controller keeps the I/O orchestration
313 // (requestApproval/Ask emit events + fire hooks + rebuild the executor gate).
314 // See approval.go.
315 approval approvalManager
316
317 // mu guards the run state; every critical section under it is short and
318 // non-blocking.
319 mu sync.Mutex
320 // turns is the sole execution authority: phase, current cancel/done/token,
321 // and the FIFO pending queue.
322 turns turnLoop
323 // executionGeneration is the Runtime BindExecution generation for this
324 // controller. Unbind uses the exact value so a rebuilt controller cannot
325 // clear the replacement's control.
326 executionGeneration atomic.Uint64
327 // closed marks the controller as terminally torn down (close() ran). It
328 // seals turn admission: without it, a submit arriving AFTER close cleared
329 // the parked queue — but while a still-running turn's TurnDone delivery
330 // was in flight — would park again and then start against freed resources
331 // when the window closed.
332 closed bool
333 // rotating is set under mu while NewSession/ClearSession swap the executor
334 // session out. Checking running once and then swapping later leaves a
335 // TOCTOU window: a turn can start (running=false at check time) during the
336 // intervening Snapshot() and then have its live session replaced. running
337 // and rotating are mutually exclusive gates — a turn refuses to start while
338 // a rotation is in progress, and a rotation refuses to start while a turn
339 // runs — so the run loop's session reference cannot change under it.
340 rotating bool
341 autosaveWG sync.WaitGroup
342 // sessionSettings groups the per-session posture knobs that share one
343 // lifetime: swapped together on session rotation.
344 sessionSettings sessionSettings
345 sessionPath string
346 // sessionTemp owns the logical-session private temporary directory shared
347 // by Bash calls. Retained for this Controller's lifetime; rotated on
348 // /new, /clear, resume of another session, and branch switches.
349 sessionTemp *sessiontemp.Manager
350 persistentShell *persistentshell.Manager
351 // snapshotMu serializes the whole save/recovery handoff for this controller.
352 // Agent-level path locks protect individual files, but recovery also moves
353 // controller-owned state (sessionPath, guardianPath, checkpoints, rewrite
354 // baseline). Letting a second snapshot observe that migration halfway through
355 // can turn one conflict into a recovery cascade. Session/path swaps
356 // (new/clear/fork/branch/switch/resume/SetSessionPath) hold it for the same
357 // reason: a save that reads the old path but the new session would write one
358 // transcript's messages into another's file, or manufacture a bogus conflict.
359 // Not reentrant — never call snapshot (or anything that snapshots, such as
360 // recoverInterruptedTurn or maybeColdResumePrune) while holding it.
361 snapshotMu sync.Mutex
362 // turn counts model turns this session, passed to hooks in their payload.
363 turn int
364 turnEvents turnEventState
365 submissions submissionIdentityState
366 liveness turnLiveness
367
368 displayRecorder func(content, display string)
369
370 // inbox is the durable session-level instruction queue. Disk I/O never
371 // runs under c.mu; the store owns its own lock.
372 inbox inboxState
373 }
374
375 type approvalReply struct {
376 allow bool
377 session bool
378 persist bool // true = write "always allow" rule to config
379 onceDirs []string
380 persistErr error
381 }
382
383 type pendingApproval struct {
384 id string
385 tool string
386 subject string
387 reason string
388 rawInput json.RawMessage
389 fresh bool
390 requireHuman bool
391 autoDrain bool
392 kind string // tool | plan | recovery | write_access; empty = tool
393 recovery *event.RecoveryApproval
394 writeAccess *event.WriteAccessApproval
395 reply chan approvalReply
396 }
397
398 // pendingAsk is an in-flight ask question batch. questions is retained so the
399 // AskRequest can be re-emitted to a frontend that reconnected after the original
400 // event (see ReplayPendingPrompts).
401 type pendingAsk struct {
402 questions []event.AskQuestion
403 reply chan []event.AskAnswer
404 queued bool // registered but not yet shown; replay must skip it
405 }
406
407 type plannerSessionResetter interface {
408 ResetPlannerSession()
409 }
410
411 type controllerSessionBinding struct {
412 // sessionRuntime is the final identity-bound v3 owner. When exclusiveSession is
413 // set, SessionPath is a legacy import/display locator only and no production
414 // transcript or business sidecar may be written through it.
415 sessionService *session.Service
416 sessionRuntime *session.Runtime
417 sessionBinding *session.ClientBinding
418 exclusiveSession bool
419 v3BindingMu sync.RWMutex
420 }
421
422 type controllerPromptRouting struct {
423 // promptEpochMu protects only the routing epoch. One-shot resolution is
424 // owned by PendingPromptOwner and the typed registries; cancellation must
425 // never wait behind an answer callback.
426 promptEpochMu sync.RWMutex
427 promptRuntimeEpoch string
428 promptOwner PendingPromptOwner
429 // promptResolveMu serializes permission-generation changes with legacy
430 // resolver entry points while they transition onto PendingPromptOwner.
431 // It is never held while waiting for a user answer.
432 promptResolveMu sync.Mutex
433 }
434
435 // RuntimeStatus is the frontend-facing snapshot of foreground turn state. It is
436 // intentionally more explicit than the legacy Running bool so UI code can
437 // distinguish a cancellable foreground turn from pending prompts and background
438 // jobs.
439 type RuntimeStatus struct {
440 Running bool
441 PendingPrompt bool
442 BackgroundJobs int
443 CancelRequested bool
444 Cancellable bool
445 TurnID string
446 Status event.TurnStatus
447 TurnEventSeq uint64
448 ReplayAfterSeq uint64
449 }
450
451 const (
452 ToolApprovalReadOnly = string(permissionpreset.ReadOnly)
453 ToolApprovalWorkspaceWrite = string(permissionpreset.WorkspaceWrite)
454 ToolApprovalDangerFullAccess = string(permissionpreset.DangerFullAccess)
455 ToolApprovalDontAsk = "dontAsk"
456
457 // Deprecated source aliases. Persisted legacy strings are migrated by
458 // normalizeToolApprovalMode; these names keep older integrations building.
459 ToolApprovalAsk = ToolApprovalReadOnly
460 ToolApprovalAuto = ToolApprovalWorkspaceWrite
461 ToolApprovalYolo = ToolApprovalDangerFullAccess
462 )
463
464 const (
465 memoryRememberTool = "remember"
466 memoryForgetTool = "forget"
467 )
468
469 // RememberResult describes what happened when an approval rule was persisted.
470 type RememberResult struct {
471 Rule string
472 Path string
473 Saved bool
474 CoveredBy string
475 Err error
476 }
477
478 // PlanModeReadOnlyCommandTrustResult describes what happened when a trusted bash
479 // command prefix was persisted for plan-mode research.
480 type PlanModeReadOnlyCommandTrustResult struct {
481 Prefix string
482 Path string
483 Saved bool
484 CoveredBy string
485 Err error
486 }
487
488 type SessionRecoveryRequest struct {
489 OriginalPath string
490 Reason string
491 Mode string
492 }
493
494 type SessionRecoveryInfo struct {
495 OriginalPath string
496 RecoveryPath string
497 Existing bool
498 Reason string
499 BaseRevision int64
500 DiskRevision int64
501 Meta agent.BranchMeta
502 commit *sessionRecoveryCommit
503 }
504
505 func snapshotConflictRevisions(err error) (base, disk int64) {
506 var conflict *agent.SessionSnapshotConflictError
507 if errors.As(err, &conflict) && conflict != nil {
508 return conflict.BaseRevision, conflict.DiskRevision
509 }
510 return 0, 0
511 }
512
513 // OnCommit defers publication work until the controller has installed the
514 // recovery path and rebound all path-scoped state.
515 func (i SessionRecoveryInfo) OnCommit(fn func()) {
516 if i.commit != nil && fn != nil {
517 i.commit.hooks = append(i.commit.hooks, fn)
518 }
519 }
520
521 type sessionRecoveryCommit struct {
522 hooks []func()
523 }
524
525 func (c *sessionRecoveryCommit) publish() {
526 for _, hook := range c.hooks {
527 hook()
528 }
529 c.hooks = nil
530 }
531
532 type externalFolderToolRefs interface {
533 RegisterReadRoot(token, root string)
534 }
535
536 // Options carries the already-built pieces setup assembles. Lifecycle metadata
537 // lets the controller mint and rotate session files; Host/Commands are surfaced
538 // to frontends that resolve MCP prompts and slash commands.
539 type Options struct {
540 ImageRouteConfig *config.Config
541 Runner agent.Runner
542 Executor *agent.Agent
543 // Authentication is the frozen runtime credential snapshot's initial
544 // admission state. An empty value remains Ready for source compatibility.
545 Authentication AuthenticationState
546 AuthenticationForModel func(string) AuthenticationState
547 Guardian *guardian.Session
548 // RecoveryHeadless is decoded for source compatibility and ignored. Auto
549 // Guard cannot be re-enabled through Controller options.
550 RecoveryHeadless bool
551 // TaskBudget is the configured spend gate; unset leaves a turn unbounded.
552 TaskBudget agent.TaskBudget
553 // GoalTokenBudget bounds an unattended Goal loop by cumulative tokens.
554 GoalTokenBudget int
555 // GoalEvaluator is accepted only for source compatibility.
556 // when the working model submits no update_goal report. nil fails closed:
557 // the goal pauses instead of defaulting to continue.
558 GoalEvaluator any // deprecated: accepted but never invoked
559 Sink event.Sink
560 Policy permission.Policy
561 // SubagentGate is the shared, mutable gate every headless-only sub-agent
562 // surface (task, writer-capable skill sub-agents, planner) reads from. Nil
563 // disables gating for those surfaces same as before this field existed.
564 // SetToolApprovalMode and ApplyHeadlessApprovalMode call Update on it so a
565 // runtime approval-mode switch reaches sub-agents, not just the parent
566 // executor's own gate.
567 SubagentGate *SharedHeadlessGate
568 Label string
569 ModelRef string
570 ModelIdentity string
571 ResolveSessionModel func(string, string) (string, error)
572 // VisionModel is empty (off), "auto", or a canonical provider/model ref.
573 // The resolver and selector are assembled by boot so the controller remains
574 // transport-agnostic and tests can inject deterministic fake providers.
575 VisionModel string
576 VisionProviderResolver func(string) (provider.Provider, error)
577 VisionModelSelector func(string, string) (string, bool)
578 // ModelCapabilityResolver returns the adapter/config-resolved metadata for
579 // the exact active model. Nil keeps the legacy config-only behavior.
580 ModelCapabilityResolver func(*config.ProviderEntry) config.ResolvedModelCapability
581 // FrozenImageInput belongs to the provider instance built for this runtime.
582 FrozenImageInput *bool
583 ImageCapabilityChanged func() bool
584 ModelSettingsRevision string
585 ModelSettingsSourceRevision string
586 ModelSettingsCurrent func() (string, error)
587 // BeforeInboxDispatch lets the owner reserve runtime admission before a
588 // queued message becomes a new turn. The returned release runs after claim
589 // and synchronous turn admission, outside every controller lock.
590 BeforeInboxDispatch func(*Controller) (func(), error)
591 SystemPrompt string
592 // PinnedContextLoader snapshots the current session sidecar at turn
593 // admission. The Agent persists changes as append-only user-role revisions.
594 PinnedContextLoader PinnedContextLoader
595 SessionDir string
596 SessionPath string
597 // SessionRuntime binds this Controller/Agent view to an already-published
598 // immutable v3 session identity. SessionService owns exact-instance close,
599 // fork, query and cancellation. ExclusiveSession disables legacy
600 // transcript and business-sidecar writes.
601 SessionService *session.Service
602 SessionRuntime *session.Runtime
603 ExclusiveSession bool
604 Host *plugin.Host
605 // MCPHostProfile is the surface lazily created hosts declare; injected
606 // hosts keep their own profile.
607 MCPHostProfile plugin.HostProfile
608 Commands []command.Command
609 Skills []skill.Skill
610 AllSkills []skill.Skill
611 SkillStore *skill.Store
612 AllSkillStore *skill.Store
613 // DisableImplicitSkillInvocation controls model-facing discovery only;
614 // explicit /skill commands and management remain host-side capabilities.
615 DisableImplicitSkillInvocation bool
616 // SkillRunner executes a runAs=subagent skill in an isolated child loop.
617 // ReadOnlySkillRunner is reserved for explicitly read-only entry points;
618 // Plan itself is a workflow instruction and uses SkillRunner with the shared
619 // Permissions/Sandbox gate. SkillProfile supplies model/effort display
620 // metadata for the synthetic top-level run_skill event.
621 SkillRunner skill.SubagentRunner
622 ReadOnlySkillRunner skill.SubagentRunner
623 SkillProfile skill.ProfileResolver
624 Hooks *hook.Runner
625 Memory *memory.Set
626 Cleanup func()
627 // BalanceURL/BalanceKey wire the active provider's optional wallet-balance
628 // endpoint and bearer key; empty when the provider declares no balance_url.
629 BalanceURL string
630 BalanceKey string
631 BalanceClient *http.Client
632 // Jobs is the session-scoped background-job manager (nil disables background jobs).
633 Jobs *jobs.Manager
634 // TaskStore remains a FileStore-compatible authority. Desktop injects one
635 // observed instance so recorder and task-control APIs share post-commit
636 // projection hints; nil preserves the ordinary FileStore.
637 TaskStore taskmonitor.WriteStore
638 // WorkspaceLease is the Delivery writer owner shared with the executor.
639 WorkspaceLease *workspacelease.Owner
640 // Registry is the executor's live tool set, and PluginCtx the session-scoped
641 // context; both are needed for hot-adding MCP servers via AddMCPServer.
642 Registry *tool.Registry
643 PluginCtx context.Context
644 // MCPDefaultCallTimeout is the global MCP call cap used by hot-connected
645 // servers when they do not declare a server- or tool-specific override.
646 MCPDefaultCallTimeout time.Duration
647 // MCPConfigureSpec injects host-local launch and isolation policy into every
648 // hot-connected server without persisting that state in project config.
649 MCPConfigureSpec func(*plugin.Spec)
650 // CapabilityRuntime is the controller-local authoritative MCP inventory used
651 // by stable use_capability frontends. It shares Host processes with sibling
652 // tabs but never shares their enabled/disabled state.
653 CapabilityRuntime *agent.MCPCapabilityRuntime
654 RuntimeGeneration uint64 // PublishGate generation for admission
655 // RuntimeOwner isolates publish/drain gates and receipts to one
656 // controller/session rebuild lineage. Nil preserves compatibility behavior.
657 RuntimeOwner *extension.RuntimeOwner
658 // WorkspaceRoot is the project root checkpoint restores are confined to ("" =
659 // no confinement). Frontends pass the cwd they launched the session in.
660 WorkspaceRoot string
661 ExternalFolderToolRefs externalFolderToolRefs
662 // ResponseLanguage controls final-answer language preference. Empty/auto
663 // means no transient injection because the stable language policy follows the
664 // current user turn.
665 ResponseLanguage string
666 // ReasoningLanguage controls visible reasoning language preference. Empty/auto
667 // means no transient injection because the stable language policy already
668 // follows the conversation language.
669 ReasoningLanguage string
670 // SessionContextStatic carries boot-observed runtime facts that belong in a
671 // host user-turn snapshot rather than the cache-stable system prompt. Only
672 // Environment and Workspace are consumed; memory and skills stay live.
673 SessionContextStatic sessioncontext.Sections
674 // FileBranchesOnly keeps fork, branch, switch, and conversation rewind on
675 // separate session files even for schema-2 logs. Hosts that expose forks as
676 // independent conversations (desktop tabs and multi-session Serve) set it.
677 FileBranchesOnly bool
678 // DisableColdResumePrune suppresses the cold-resume cache-state notice.
679 // Resume never rewrites history regardless of this flag.
680 DisableColdResumePrune bool
681 // Shell is the interpreter user-invoked "!" commands run under, so /shell
682 // matches the agent's configured [tools.shell] choice. Zero value = auto.
683 Shell sandbox.Shell
684 // OnRemember, when set, is invoked with a new allow rule the user chose to
685 // persist to disk (e.g. "Bash(go test:*)"). The callback is wired into the
686 // permission Gate on EnableInteractiveApproval.
687 OnRemember func(rule string) RememberResult
688 // OnRememberPlanModeReadOnlyCommand persists a bash command prefix as trusted
689 // read-only when the user chooses "always allow" from the plan-mode trust
690 // prompt.
691 OnRememberPlanModeReadOnlyCommand func(prefix string) PlanModeReadOnlyCommandTrustResult
692 // OnPersistWriteAccess writes sandbox.allow_write and an optional permission
693 // rule to the workspace reasonix.toml as one transaction.
694 OnPersistWriteAccess PersistWriteAccessFunc
695 // WriteRoots is the session-scoped writable directory manager shared with
696 // built-in file tools and bash.
697 WriteRoots *sandbox.WritableRootSet
698 // BashSandboxEnforced is true when this session's bash tool actually wraps
699 // commands in an OS sandbox. Windows and bash=off leave this false so
700 // directory prompts are not implied for unisolated shell writes.
701 BashSandboxEnforced bool
702 // SessionRecoveryMeta lets a frontend attach scope/topic/profile metadata to
703 // an automatic recovery branch before it is written.
704 SessionRecoveryMeta func(SessionRecoveryRequest) agent.BranchMeta
705 // OnSessionRecovered is called after a stale runtime's transcript has been
706 // saved as a recovery branch, before the controller commits to that branch.
707 OnSessionRecovered func(SessionRecoveryInfo) error
708 // OnSessionTransition transfers write ownership before an intentional
709 // fork, branch, or switch publishes a different Session.
710 OnSessionTransition func(SessionTransitionInfo) error
711 // OnSessionRotation lets an identity-owning host durably reserve a fresh
712 // SessionID before /new or /clear publishes it. When installed, the host
713 // also owns clear archival; the controller never permanently deletes the
714 // source session.
715 OnSessionRotation func(context.Context, SessionRotationRequest) (SessionRotationPlan, error)
716 // ApprovalTimeout bounds how long a tool-approval or ask prompt blocks waiting
717 // for a user decision. Zero (default) waits forever — right for an interactive
718 // terminal. Bot/headless frontends set a positive value so an unanswered
719 // prompt can't wedge the session indefinitely (#4626, #4402).
720 ApprovalTimeout time.Duration
721 // Extensions is the frozen extension dispatcher for this controller
722 // generation (Extension Protocol v2, stage 6b1). Nil means no v2 runtime
723 // packages are installed: every extension wiring point takes an untouched
724 // fast path. Boot installs it through SetExtensions because sidecars (and
725 // therefore the dispatcher) only exist after snapshot assembly, which runs
726 // after New.
727 Extensions *dispatch.Dispatcher
728 // ProviderResolver is the build's merged provider catalog — extension
729 // sidecar providers folded over the config/broker base (stage 7). Nil when
730 // no v2 runtime sidecar declared providers; ProviderCatalog then returns
731 // nil and frontends enumerate providers from config alone, as before.
732 ProviderResolver provider.Resolver
733 // Ablation switches subsystems off for a benchmark arm. The zero value runs
734 // everything.
735 Ablation ablation.Set
736 // SessionTemp is the logical-session private temporary directory manager
737 // shared by sandboxed Bash calls. Nil creates a fresh Manager owned by this
738 // Controller. Hot rebuilds pass the previous Controller's Manager so the
739 // temporary directory survives model/settings swaps.
740 SessionTemp *sessiontemp.Manager
741 // PersistentShell is the session-scoped PTY used by ordinary foreground
742 // bash. Nil creates a fresh Manager owned by this Controller. Hot rebuilds
743 // pass the previous Controller's Manager so cwd and exported environment
744 // survive model/settings swaps.
745 PersistentShell *persistentshell.Manager
746 }
747
748 // New builds a Controller. A nil Sink becomes event.Discard; unless the caller
749 // already provided a goalUsageTee (NewGoalUsageTee), the sink is wrapped in one
750 // so billable usage can be accounted to Goal budgets.
751 func controllerSessionTemp(existing *sessiontemp.Manager) *sessiontemp.Manager {
752 if existing != nil {
753 return existing
754 }
755 return sessiontemp.New()
756 }
757
758 func controllerPersistentShell(existing *persistentshell.Manager) *persistentshell.Manager {
759 if existing != nil {
760 return existing
761 }
762 return persistentshell.New()
763 }
764
765 func New(opts Options) *Controller {
766 sink := opts.Sink
767 if nilutil.IsNil(sink) {
768 sink = event.Discard
769 }
770 usageTee, ok := sink.(*goalUsageTee)
771 if !ok {
772 usageTee = NewGoalUsageTee(sink).(*goalUsageTee)
773 sink = usageTee
774 }
775 pluginCtx := opts.PluginCtx
776 if pluginCtx == nil {
777 pluginCtx = context.Background()
778 }
779 runtimeOwner := runtimeOwnerOrDefault(opts.RuntimeOwner)
780 pluginCtx = extension.ContextWithRuntimeOwner(pluginCtx, runtimeOwner)
781 goalDriverCtx, goalDriverCancel := context.WithCancel(context.Background())
782 if opts.Hooks != nil {
783 opts.Hooks.SetSessionID(agent.BranchID(opts.SessionPath))
784 }
785 sessionRuntime, sessionBinding := bindInitialSessionRuntime(opts)
786 c := &Controller{
787 authentication: newAuthenticationGate(opts.Authentication, opts.ModelRef),
788 taskBudget: opts.TaskBudget,
789 goalTokenBudget: opts.GoalTokenBudget,
790 goalTokenLimit: opts.GoalTokenBudget,
791 goals: goalMachine{tokenBudget: opts.GoalTokenBudget},
792 runner: opts.Runner,
793 executor: opts.Executor,
794 guardianSess: opts.Guardian,
795 guardianPath: guardian.PathFor(opts.SessionPath),
796 goalUsageTee: usageTee,
797 sink: sink,
798 policy: opts.Policy,
799 subagentGate: opts.SubagentGate,
800 label: opts.Label,
801 selection: modelSelection{ref: opts.ModelRef, identity: opts.ModelIdentity},
802 resolveSessionModel: opts.ResolveSessionModel,
803 visionModel: strings.TrimSpace(opts.VisionModel),
804 visionProviderResolver: opts.VisionProviderResolver,
805 visionModelSelector: opts.VisionModelSelector,
806 modelCapabilityResolver: opts.ModelCapabilityResolver,
807 frozenImageInput: opts.FrozenImageInput,
808 imageCapabilityChanged: opts.ImageCapabilityChanged,
809 modelSettings: newControllerModelSettings(opts),
810 prompt: newControllerPromptState(opts.SystemPrompt, opts.Executor),
811 pinnedContextLoader: opts.PinnedContextLoader,
812 sessionContextStatic: opts.SessionContextStatic,
813 sessionDir: opts.SessionDir,
814 sessionPath: opts.SessionPath,
815 controllerSessionBinding: controllerSessionBinding{sessionService: opts.SessionService, sessionRuntime: sessionRuntime, sessionBinding: sessionBinding, exclusiveSession: opts.ExclusiveSession},
816 commands: atomic.Pointer[[]command.Command]{},
817 skills: newSkillSet(opts.Skills, opts.AllSkills, opts.SkillStore, opts.AllSkillStore),
818 disableImplicitSkillInvocation: opts.DisableImplicitSkillInvocation,
819 skillRunner: opts.SkillRunner,
820 readOnlySkillRunner: opts.ReadOnlySkillRunner,
821 skillProfile: opts.SkillProfile,
822 hooks: opts.Hooks,
823 memory: newMemoryManager(opts.Memory),
824 cleanup: opts.Cleanup,
825 responseLanguage: config.NormalizeLanguage(opts.ResponseLanguage),
826 reasoningLanguage: config.NormalizeReasoningLanguage(opts.ReasoningLanguage),
827 disableColdResumePrune: opts.DisableColdResumePrune,
828 headPolicy: sessionHeadPolicy{fileBranchesOnly: opts.FileBranchesOnly},
829 shell: opts.Shell,
830 onRemember: opts.OnRemember,
831 onRememberPlanModeReadOnlyCommand: opts.OnRememberPlanModeReadOnlyCommand,
832 writeAccess: newControllerWriteAccess(opts),
833 sessionRecoveryMeta: opts.SessionRecoveryMeta,
834 onSessionRecovered: opts.OnSessionRecovered,
835 onSessionTransition: opts.OnSessionTransition,
836 onSessionRotation: opts.OnSessionRotation,
837 balanceURL: opts.BalanceURL,
838 balanceKey: opts.BalanceKey,
839 balanceClient: opts.BalanceClient,
840 jobs: opts.Jobs,
841 workspaceLease: opts.WorkspaceLease,
842 mcp: newMcpManager(opts.Host, opts.Registry, pluginCtx, opts.MCPHostProfile),
843 mcpDefaultCallTimeout: opts.MCPDefaultCallTimeout,
844 mcpConfigureSpec: opts.MCPConfigureSpec,
845 capabilityRuntime: opts.CapabilityRuntime,
846 ablation: opts.Ablation,
847 workspaceRoot: opts.WorkspaceRoot,
848 externalFolderToolRefs: opts.ExternalFolderToolRefs,
849 providerResolver: opts.ProviderResolver,
850 runtimeGeneration: opts.RuntimeGeneration,
851 runtimeOwner: runtimeOwner,
852 goalDriverControl: goalDriverControl{ctx: goalDriverCtx, cancel: goalDriverCancel},
853 approval: newApprovalManager(opts.Policy, ToolApprovalAsk, opts.ApprovalTimeout),
854 turns: turnLoop{phase: session.RuntimeIdle},
855 closeFinalized: make(chan struct{}),
856 }
857 c.authentication.initialForModel = opts.AuthenticationForModel
858 c.initializeOwnedResources(opts)
859 c.bindAttachmentService()
860 if opts.ImageRouteConfig != nil {
861 c.imageRoutesOnce.Do(func() { c.captureImageRoutes(opts.ImageRouteConfig) })
862 }
863 return c
864 }
865
866 func (c *Controller) initializeOwnedResources(opts Options) {
867 if c.executor != nil {
868 c.executor.SetImageRequestResolver(c)
869 }
870 c.goalUsageTee.setLifecycleUsageRecorder(c.recordGoalLifecycleUsage)
871 c.installGoalLifecycle(opts.SessionRuntime)
872 c.managedSessionEvents.Store(opts.OnSessionTransition != nil)
873 c.permissionRevision.Store(1)
874 // Session-private temporary directory: reuse a shared Manager on hot
875 // rebuild, otherwise create one. Retain so ReleaseResources/Close drop the
876 // owner reference without racing a replacement Controller.
877 c.sessionTemp = controllerSessionTemp(opts.SessionTemp)
878 c.sessionTemp.Retain()
879 c.persistentShell = controllerPersistentShell(opts.PersistentShell)
880 c.persistentShell.Retain()
881 if strings.TrimSpace(opts.WorkspaceRoot) != "" {
882 c.legacyResearchArchive = legacyResearchArchive{store: autoresearch.NewStore(opts.WorkspaceRoot)}
883 }
884 if opts.Extensions != nil {
885 c.extensions = opts.Extensions
886 c.sink = newFrontendEventSink(c.sink, opts.Extensions)
887 if c.executor != nil {
888 c.executor.SetExtensions(opts.Extensions)
889 }
890 }
891 // Checkpoints: bind a store to the session and route writer pre-edits into it.
892 c.rebindCheckpoints(opts.SessionPath)
893 c.setActiveJobSession(opts.SessionPath)
894 c.rebindInbox()
895 // Observe Steer / unapplied-steer for durable inbox state transitions.
896 // Must wrap both the controller sink and the executor sink: agent.Steer
897 // emits on the executor path, TurnDone on the controller path.
898 c.sink = &inboxEventSink{inner: newTurnEventSink(c.sink, c), c: c}
899 if runner, ok := c.runner.(interface{ SetSink(event.Sink) }); ok {
900 runner.SetSink(c.sink)
901 }
902 // Establish mutation authority before any constructor-time session seed.
903 // A hot-rebuild candidate sharing an already-bound Runtime remains at
904 // generation zero and can restore from the projection without writing it.
905 c.bindExecutionControl()
906 if c.executor != nil {
907 c.executor.SetSink(c.sink)
908 c.executor.SetSessionCheckpointer(c)
909 if _, runtime, _ := c.v3Binding(); runtime != nil {
910 if runtime.StateSnapshot().Session.EventSequence == 0 {
911 if err := c.seedSessionEventsFromExecutor("session-open"); err != nil {
912 c.failTurnEventLedger(err)
913 }
914 }
915 c.restoreExecutorFromSessionEvents()
916 } else if err := c.seedSessionEventsFromExecutor("session-open"); err != nil {
917 c.failTurnEventLedger(err)
918 }
919 }
920 cmdsInit := opts.Commands
921 c.commands.Store(&cmdsInit)
922 if c.executor != nil {
923 c.wireMutationObserver()
924 c.executor.SetMemoryQueue(c)
925 }
926 // Task monitoring: record background-job lifecycle into the project-local
927 // task store so CLI, Desktop, scripts, and future clients observe the same
928 // state/event evidence. The recorder swallows its own failures — monitoring
929 // must never affect the agent pipeline. The session id is resolved lazily
930 // because the session path is only fixed once the first turn begins.
931 c.initializeTaskRecorder(opts.TaskStore)
932 c.initializeRuntimeState()
933 }
934
935 func (c *Controller) initializeTaskRecorder(store taskmonitor.WriteStore) {
936 if c.jobs == nil || c.workspaceRoot == "" {
937 return
938 }
939 if store == nil {
940 store = taskmonitor.NewFileStore(filepath.Join(".reasonix", "tasks"))
941 }
942 c.jobs.SetTaskRecorder(taskmonitor.NewTaskRecorder(
943 store, c.workspaceRoot, func() string { return c.parentSessionID() },
944 ))
945 }
946
947 // SetDisplayRecorder installs an optional hook used by frontends that persist a
948 // shorter user-facing transcript than the fully composed model prompt.
949 func (c *Controller) SetDisplayRecorder(fn func(content, display string)) {
950 c.mu.Lock()
951 defer c.mu.Unlock()
952 c.displayRecorder = fn
953 }
954
955 // SetExtensions installs the extension dispatcher after construction. Boot
956 // uses it because sidecars — and therefore the dispatcher — only exist after
957 // snapshot assembly, which runs after New. First non-nil install wins for the
958 // cold-start path; use ReplaceExtensions for generation-safe rebuild swaps.
959 // Nil is a no-op. The executor agent receives the same dispatcher (stage 6b2).
960 func (c *Controller) SetExtensions(d *dispatch.Dispatcher) {
961 if d == nil {
962 return
963 }
964 c.mu.Lock()
965 defer c.mu.Unlock()
966 if c.extensions != nil {
967 return
968 }
969 c.installExtensionsLocked(d)
970 }
971
972 // ReplaceExtensions atomically swaps the dispatcher for a reused controller
973 // after a narrow rebuild. Updates sink strategy owner and executor together.
974 func (c *Controller) ReplaceExtensions(d *dispatch.Dispatcher) {
975 if c == nil || d == nil {
976 return
977 }
978 c.mu.Lock()
979 defer c.mu.Unlock()
980 c.installExtensionsLocked(d)
981 }
982
983 func (c *Controller) installExtensionsLocked(d *dispatch.Dispatcher) {
984 c.extensions = d
985 // Keep the inbox observer as the outermost sink so Steer/unapplied events
986 // always update durable state, while still installing/updating the
987 // frontendEventSink wrapper underneath for extension rulings.
988 switch sink := c.sink.(type) {
989 case *inboxEventSink:
990 if lifecycle, ok := sink.inner.(*turnEventSink); ok {
991 current := lifecycle.innerSnapshot()
992 if existing, ok := current.(*frontendEventSink); ok {
993 existing.setDispatcher(d)
994 } else {
995 lifecycle.setInner(newFrontendEventSink(current, d))
996 }
997 } else if existing, ok := sink.inner.(*frontendEventSink); ok {
998 existing.setDispatcher(d)
999 } else {
1000 sink.inner = newTurnEventSink(newFrontendEventSink(sink.inner, d), c)
1001 }
1002 case *frontendEventSink:
1003 sink.setDispatcher(d)
1004 // Ensure inbox observer stays outer.
1005 c.sink = &inboxEventSink{inner: newTurnEventSink(sink, c), c: c}
1006 default:
1007 c.sink = &inboxEventSink{inner: newTurnEventSink(newFrontendEventSink(c.sink, d), c), c: c}
1008 }
1009 if c.executor != nil {
1010 c.executor.SetExtensions(d)
1011 c.executor.SetSink(c.sink)
1012 }
1013 if runner, ok := c.runner.(interface{ SetSink(event.Sink) }); ok {
1014 runner.SetSink(c.sink)
1015 }
1016 }
1017
1018 // SetProviderResolver replaces the session's merged provider catalog (narrow
1019 // rebuild after sidecar Manager roll). Nil clears extension-hosted providers.
1020 func (c *Controller) SetProviderResolver(r provider.Resolver) {
1021 if c == nil {
1022 return
1023 }
1024 c.mu.Lock()
1025 c.providerResolver = r
1026 c.mu.Unlock()
1027 }
1028
1029 // SetOnSessionRecovered installs the ownership handoff invoked before the
1030 // controller commits to an automatically created recovery branch. Frontends
1031 // that acquire their session owner after controller construction (for example
1032 // reasonix serve) use this before publishing the controller.
1033 func (c *Controller) SetOnSessionRecovered(fn func(SessionRecoveryInfo) error) {
1034 if c == nil {
1035 return
1036 }
1037 c.mu.Lock()
1038 defer c.mu.Unlock()
1039 c.onSessionRecovered = fn
1040 }
1041
1042 func (c *Controller) sessionRecoveredHandler() func(SessionRecoveryInfo) error {
1043 c.mu.Lock()
1044 defer c.mu.Unlock()
1045 return c.onSessionRecovered
1046 }
1047
1048 func (c *Controller) recordDisplay(content, display string) {
1049 if strings.TrimSpace(display) == "" || content == display {
1050 return
1051 }
1052 c.mu.Lock()
1053 record := c.displayRecorder
1054 c.mu.Unlock()
1055 if record != nil {
1056 record(content, display)
1057 }
1058 }
1059
1060 // ToolContractEntries returns a stable snapshot of the executor's live tool
1061 // contract: provider-visible names, descriptions, canonical schemas, and
1062 // read-only flags. It is intended for diagnostics and regression tests.
1063 func (c *Controller) ToolContractEntries() []tool.ContractEntry {
1064 if c == nil {
1065 return nil
1066 }
1067 reg := c.mcp.registry()
1068 if reg == nil {
1069 return nil
1070 }
1071 return reg.ContractEntries()
1072 }
1073
1074 // AllToolContractEntries returns every registered tool, including those hidden
1075 // from the provider-visible schema and only reachable via use_capability.
1076 func (c *Controller) AllToolContractEntries() []tool.ContractEntry {
1077 if c == nil {
1078 return nil
1079 }
1080 reg := c.mcp.registry()
1081 if reg == nil {
1082 return nil
1083 }
1084 return reg.AllContractEntries()
1085 }
1086
1087 // ProviderCatalog returns the session's merged provider catalog: the config
1088 // (or broker) base plus every provider a live extension sidecar declared,
1089 // keyed by ref — extension refs carry their plugin/<plugin>/<provider>/<model>
1090 // namespace. Nil when no sidecar declared providers, so frontends can tell
1091 // "enumerate config only" apart from "the extension catalog is empty".
1092 func (c *Controller) ProviderCatalog() []provider.Descriptor {
1093 if c == nil {
1094 return nil
1095 }
1096 c.mu.Lock()
1097 r := c.providerResolver
1098 c.mu.Unlock()
1099 if r == nil {
1100 return nil
1101 }
1102 return r.Catalog()
1103 }
1104
1105 func (c *Controller) recordDisplayForNewUser(startMessages int, display string) {
1106 if strings.TrimSpace(display) == "" {
1107 return
1108 }
1109 msgs := c.History()
1110 if startMessages > len(msgs) {
1111 startMessages = len(msgs)
1112 }
1113 for _, m := range msgs[startMessages:] {
1114 if agent.IsUserAuthoredTurnMessage(m) {
1115 c.recordDisplay(m.Content, display)
1116 return
1117 }
1118 }
1119 }
1120
1121 func (c *Controller) markEditedForNewUser(startMessages int, original string) {
1122 if strings.TrimSpace(original) == "" || c.executor == nil {
1123 return
1124 }
1125 s := c.executor.Session()
1126 msgs := s.Snapshot()
1127 if startMessages > len(msgs) {
1128 startMessages = len(msgs)
1129 }
1130 for i := startMessages; i < len(msgs); i++ {
1131 if !agent.IsUserAuthoredTurnMessage(msgs[i]) {
1132 continue
1133 }
1134 if agent.UserMessageText(msgs[i]) == original {
1135 return
1136 }
1137 msgs[i].Edited = true
1138 msgs[i].Original = original
1139 // A periodic autosave may already contain this user message without its
1140 // local edit metadata. Classify the mutation atomically so the turn-end
1141 // save performs an owned rewrite instead of forking a bogus
1142 // same-revision recovery branch. Edited/Original are local-only display
1143 // metadata (provider requests ignore them), so this must not report a
1144 // cache-prefix change — ReplaceLocalMetadata, not Rewrite.
1145 s.ReplaceLocalMetadata(msgs)
1146 return
1147 }
1148 }
1149
1150 // ckptDir derives a session's checkpoint directory from its file path
1151 // (…/<id>.jsonl → …/<id>.ckpt). Empty path → empty (in-memory checkpoints).
1152 func ckptDir(sessionPath string) string {
1153 return store.SessionCheckpointDir(sessionPath)
1154 }
1155
1156 // rebindCheckpoints points the store at the (possibly new) session, loading any
1157 // checkpoints already on disk, and resets the turn boundaries. Called on
1158 // construction and whenever the session path changes (NewSession/Resume/SetSessionPath).
1159 // Also re-wires the mutation observer so capture targets the new store.
1160 func (c *Controller) rebindCheckpoints(sessionPath string) {
1161 if c.sessionEngineEnabled() {
1162 // Goal and runtime business state are v3 events. Legacy goal/checkpoint
1163 // sidecars must not become a second restore source in exclusive mode.
1164 c.goals.setStatePath("")
1165 c.checkpoints.rebind("", c.workspaceRoot, c.checkpointOptions()...)
1166 c.rebindTurnEvents(sessionPath)
1167 if c.executor != nil {
1168 c.wireMutationObserver()
1169 }
1170 return
1171 }
1172 c.goals.setStatePath(goalStatePath(sessionPath))
1173 c.checkpoints.rebind(ckptDir(sessionPath), c.workspaceRoot, c.checkpointOptions()...)
1174 c.rebindTurnEvents(sessionPath)
1175 if c.executor != nil {
1176 c.wireMutationObserver()
1177 }
1178 }
1179
1180 // commands (frontend → controller)
1181
1182 func (c *Controller) Send(input string) {
1183 c.SendWithRaw(input, input)
1184 }
1185
1186 // SendWithRaw starts a turn with separate model input and raw prompt text.
1187 func (c *Controller) SendWithRaw(input, raw string) {
1188 _, _ = c.submitIdentifiedWithSetup(SubmissionRequest{Input: input, Display: raw}, nil, func(admission turnAdmission) {
1189 c.runGuardedWithAdmission(func(ctx context.Context) error { return c.runGoalLoopWithRaw(ctx, input, raw) }, admission)
1190 })
1191 }
1192
1193 // planApprovalTool is the Tool name on the ApprovalRequest the controller emits
1194 // to gate a proposed plan. Frontends key their plan-approval UI on it (the
1195 // desktop renders a plan card; the chat TUI a plan banner).
1196 const planApprovalTool = "exit_plan_mode"
1197
1198 // PlanDecisionAction preserves the three user-owned meanings of the Plan card.
1199 // Revise and exit both deny execution at the approval gate, but they are not the
1200 // same product decision and must remain distinguishable in durable receipts.
1201 type PlanDecisionAction string
1202
1203 const (
1204 PlanDecisionStartExecution PlanDecisionAction = "start_execution"
1205 PlanDecisionRevisePlan PlanDecisionAction = "revise_plan"
1206 PlanDecisionExitPlan PlanDecisionAction = "exit_plan"
1207 )
1208
1209 // SandboxEscapeApprovalTool is the internal Tool name used for one-shot approval
1210 // to rerun a shell command without the OS sandbox after the sandbox failed.
1211 const SandboxEscapeApprovalTool = "sandbox_escape"
1212
1213 // ManagedConfigWriteApprovalTool is the internal Tool name used for per-write
1214 // approval when a file tool targets a Reasonix-managed config file outside the
1215 // workspace write roots. It is a fresh human decision: config files control
1216 // providers, sandbox rules, permissions, and MCP servers for future sessions,
1217 // so YOLO/auto approval must never answer it.
1218 const ManagedConfigWriteApprovalTool = "config_write"
1219
1220 // planApprovedMessage is the follow-up turn sent once the user approves a plan.
1221 // Approval grants the plan scope; the model owns any fresh todo list it chooses
1222 // to write during the execution turn.
1223 const planApprovedMessage = "Plan approved — plan mode is off. Implement the approved plan and user feedback. Explicit scope, permission and sandbox restrictions still apply. Update todos to reflect actual progress. Use the plan’s checks and acceptance notes as task instructions, and report what you changed and verified."
1224
1225 // runTurn runs one model turn, then applies the plan-approval gate. This is the
1226 // single, frontend-agnostic plan flow: in Plan the model is instructed to
1227 // research and write its plan as a normal answer, while any tool calls still use
1228 // the active Permissions/Sandbox path.
1229 // When the turn ends with a text proposal, the controller asks the user to
1230 // approve (reusing the ApprovalRequest channel both frontends already render);
1231 // on approval it exits plan mode and continues straight into execution; on
1232 // rejection it stays in plan mode so the
1233 // next turn can revise. Plan mode is only ever set interactively, so the headless
1234 // `Run` path (which doesn't call this) never blocks on a prompt.
1235 func (c *Controller) runTurn(ctx context.Context, input string) error {
1236 return c.runGoalLoopWithRaw(ctx, input, input)
1237 }
1238
1239 func (c *Controller) runTurnWithRaw(ctx context.Context, input, raw string) error {
1240 return c.runTurnWithRawDisplay(ctx, input, raw, "")
1241 }
1242
1243 func (c *Controller) runGoalLoopWithRaw(ctx context.Context, input, raw string) error {
1244 return c.runGoalLoopWithRawDisplay(ctx, input, raw, "")
1245 }
1246
1247 // withTurnFormat binds a structured-output format to the turn context
1248 // (empty is a no-op). Extracted from the runGoalLoop closure so tests can
1249 // assert the format actually reaches the agent request path.
1250 func (c *Controller) withTurnFormat(ctx context.Context, format string) context.Context {
1251 if format == "" {
1252 return ctx
1253 }
1254 return agent.WithResponseFormat(ctx, format)
1255 }
1256
1257 func (c *Controller) runGoalLoopWithRawDisplay(ctx context.Context, input, raw, display string) error {
1258 // Structured-output format is bound to the submitted turn (passed via
1259 // submitHTTPWithFormat → submitCommandOrTurn → runGoalLoop closure);
1260 // no global one-shot slot to race across concurrent requests.
1261 return newTurnOrchestrator(c).runGoalLoopWithRawDisplay(ctx, input, raw, display)
1262 }
1263
1264 func (c *Controller) runEditedGoalLoopWithRawDisplay(ctx context.Context, input, raw, display, original string) error {
1265 return newTurnOrchestrator(c).runEditedGoalLoopWithRawDisplay(ctx, input, raw, display, original)
1266 }
1267
1268 func (c *Controller) runTurnWithRawDisplay(ctx context.Context, input, raw, display string) error {
1269 return newTurnOrchestrator(c).runTurnWithRawDisplay(ctx, input, raw, display)
1270 }
1271
1272 func (c *Controller) runSubagentSkillSlash(sk skill.Skill, task, raw, display string, admission turnAdmission) {
1273 sk = c.skills.prepare(sk)
1274 c.runGuardedWithAdmission(func(ctx context.Context) error {
1275 planMode := c.PlanMode()
1276 runner := c.skillRunner
1277 if runner == nil {
1278 return fmt.Errorf("subagent skill runner is unavailable for /%s", sk.Name)
1279 }
1280 return newTurnOrchestrator(c).runSubagentSkillGoalLoop(ctx, sk, task, raw, display, runner, planMode)
1281 }, admission)
1282 }
1283
1284 func (c *Controller) stopGoal(status string) {
1285 path, data, ok := c.goals.stop(status)
1286 c.persistGoalState(path, data, ok)
1287 }
1288
1289 // lastAssistantText returns the content of the most recent assistant message with
1290 // non-empty text — the model's final answer for the turn (its plan, in plan mode).
1291 func lastAssistantText(msgs []provider.Message) string {
1292 for _, msg := range slices.Backward(msgs) {
1293 if msg.Role == provider.RoleAssistant && strings.TrimSpace(msg.Content) != "" {
1294 return msg.Content
1295 }
1296 }
1297 return ""
1298 }
1299
1300 // Submit is the one-call entry for a simple frontend: it takes raw user input
1301 // and does everything — slash-command dispatch, @-reference expansion, plan-mode
1302 // composition — emitting all output as events. The HTTP/SSE server uses this so
1303 // a browser client only POSTs the typed line.
1304 //
1305 // Slash commands route to the matching primitive: /compact, /new, and /clear
1306 // run their session op and emit a Notice; /mcp__server__prompt and custom /commands
1307 // resolve to a turn; an unknown slash emits a Notice. Anything else is a normal
1308 // turn with its @-references resolved first.
1309 func (c *Controller) Submit(input string) {
1310 c.submit(input, "", "")
1311 }
1312
1313 // SubmitHTTP accepts input from the unauthenticated localhost HTTP frontend. It
1314 // deliberately omits the trusted TUI-only "!cmd" shell shortcut and resolves file
1315 // references only through the controller's workspace root.
1316 func (c *Controller) SubmitHTTP(input string) {
1317 c.submitHTTP(input, "")
1318 }
1319
1320 // SubmitDisplay runs input as a turn while remembering the user-facing display
1321 // text for transcript replay when controller-side composition expands input.
1322 func (c *Controller) SubmitDisplay(display, input string) {
1323 c.submit(input, display, "")
1324 }
1325
1326 // SubmitInvocationDisplay executes composer-selected invocation entities
1327 // independently of slash-command parsing. Plain string submit entry points keep
1328 // their existing behavior for CLI, HTTP, and backward-compatible clients.
1329 func (c *Controller) SubmitInvocationDisplay(display, input string, invocations []InvocationRequest) {
1330 c.submitInvocations(input, display, invocations)
1331 }
1332
1333 func (c *Controller) submitInvocations(input, display string, requests []InvocationRequest) {
1334 _, _ = c.submitIdentifiedWithSetup(SubmissionRequest{Input: input, Display: display, Invocations: requests}, nil, func(admission turnAdmission) {
1335 c.submitInvocationsLocked(input, display, requests, admission)
1336 })
1337 }
1338
1339 func (c *Controller) submitInvocationsLocked(input, display string, requests []InvocationRequest, admission turnAdmission) {
1340 if len(requests) == 0 {
1341 c.submitLocked(input, display, "", admission)
1342 return
1343 }
1344 prepared, err := c.prepareInvocationTurn(input, requests)
1345 if err != nil {
1346 c.notice(err.Error())
1347 return
1348 }
1349 c.runGuardedWithAdmission(func(ctx context.Context) error {
1350 return c.runPreparedInvocationTurn(ctx, prepared, input, input, display, nil)
1351 }, admission)
1352 }
1353
1354 type preparedInvocationTurn struct {
1355 composed string
1356 subagents []skill.Skill
1357 }
1358
1359 func (c *Controller) prepareInvocationTurn(input string, requests []InvocationRequest) (preparedInvocationTurn, error) {
1360 ordered := append([]InvocationRequest(nil), requests...)
1361 sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Offset < ordered[j].Offset })
1362 inline := make([]skill.Skill, 0, len(ordered))
1363 subagents := make([]skill.Skill, 0, len(ordered))
1364 for _, request := range ordered {
1365 sk, _, ok := c.resolveSkillInvocation("/" + strings.TrimSpace(request.Name))
1366 if !ok {
1367 return preparedInvocationTurn{}, fmt.Errorf("unknown invocation: /%s", strings.TrimSpace(request.Name))
1368 }
1369 kind := "skill"
1370 if sk.RunAs == skill.RunSubagent {
1371 kind = "subagent"
1372 }
1373 if strings.TrimSpace(request.Kind) != "" && request.Kind != kind {
1374 return preparedInvocationTurn{}, fmt.Errorf("invocation /%s is %s, not %s", sk.SlashName(), kind, request.Kind)
1375 }
1376 if sk.RunAs == skill.RunSubagent {
1377 subagents = append(subagents, sk)
1378 } else {
1379 inline = append(inline, sk)
1380 }
1381 }
1382
1383 parts := make([]string, 0, len(inline)+1)
1384 for _, sk := range inline {
1385 parts = append(parts, c.skills.render(sk, ""))
1386 }
1387 if strings.TrimSpace(input) != "" {
1388 parts = append(parts, input)
1389 }
1390 composed := strings.Join(parts, "\n\n")
1391 if strings.TrimSpace(input) == "" {
1392 if len(subagents) > 0 {
1393 return preparedInvocationTurn{}, fmt.Errorf("subagent invocation requires a task")
1394 }
1395 }
1396 return preparedInvocationTurn{composed: composed, subagents: subagents}, nil
1397 }
1398
1399 func (c *Controller) runPreparedInvocationTurn(
1400 ctx context.Context,
1401 prepared preparedInvocationTurn,
1402 input, raw, display string,
1403 frozenImages []string,
1404 ) error {
1405 if len(prepared.subagents) == 0 {
1406 return c.runGoalLoopWithFrozenImagesRawDisplay(ctx, prepared.composed, raw, display, frozenImages)
1407 }
1408 runner := c.skillRunner
1409 if runner == nil {
1410 return fmt.Errorf("subagent skill runner is unavailable")
1411 }
1412 return newTurnOrchestrator(c).runSubagentSkillTurnsGoalLoop(
1413 ctx,
1414 prepared.subagents,
1415 prepared.composed,
1416 input,
1417 display,
1418 runner,
1419 c.PlanMode(),
1420 frozenImages,
1421 )
1422 }
1423
1424 // SubmitEditedDisplay is SubmitDisplay for an inline-edited prompt. The model
1425 // sees input; the saved user message also keeps the pre-edit prompt as local UI
1426 // metadata so the edit survives session rewrites.
1427 func (c *Controller) SubmitEditedDisplay(display, input, original string) {
1428 c.submit(input, display, original)
1429 }
1430
1431 // SubmitUserTurn starts a normal model turn without interpreting shell or slash
1432 // commands. It still resolves references, so callers can submit trusted
1433 // user-authored prompt text without expanding the command surface.
1434 func (c *Controller) SubmitUserTurn(input, display string) {
1435 _, _ = c.submitIdentifiedWithSetup(SubmissionRequest{Input: input, Display: display}, nil, func(admission turnAdmission) {
1436 c.runRefTurnWithAdmission(input, display, admission)
1437 })
1438 }
1439
1440 func (c *Controller) submit(input, display, editedOriginal string) {
1441 if isSessionManagementSubmission(input) {
1442 c.submissions.mu.Lock()
1443 defer c.releaseSubmissionAdmission()
1444 c.submitLocked(input, display, editedOriginal, turnAdmission{})
1445 return
1446 }
1447 _, _ = c.submitIdentifiedWithSetup(SubmissionRequest{Input: input, Display: display, Original: editedOriginal}, nil, func(admission turnAdmission) {
1448 c.submitLocked(input, display, editedOriginal, admission)
1449 })
1450 }
1451
1452 func (c *Controller) submitLocked(input, display, editedOriginal string, admission turnAdmission) {
1453 trimmed := strings.TrimSpace(input)
1454 if note, ok := MemoryQuickAddNote(trimmed); ok {
1455 c.rememberProjectNote(note)
1456 return
1457 }
1458 if note, ok := RememberCommandNote(trimmed); ok {
1459 c.rememberProjectNote(note)
1460 return
1461 }
1462 if c.applyGoalCommandWithAdmission(trimmed, display, admission) {
1463 return
1464 }
1465 if strings.HasPrefix(trimmed, "!") {
1466 c.RunShell(trimmed[1:])
1467 return
1468 }
1469 c.submitCommandOrTurn(trimmed, input, display, false, editedOriginal, "", admission)
1470 }
1471
1472 func (c *Controller) submitHTTP(input, display string) {
1473 c.submitHTTPWithFormat(input, display, "")
1474 }
1475
1476 func (c *Controller) submitHTTPWithFormat(input, display, format string) {
1477 if isSessionManagementSubmission(input) {
1478 c.submissions.mu.Lock()
1479 defer c.releaseSubmissionAdmission()
1480 c.submitHTTPWithFormatLocked(input, display, format, turnAdmission{})
1481 return
1482 }
1483 _, _ = c.submitIdentifiedWithSetup(SubmissionRequest{Input: input, Display: display, HTTP: true, Format: format}, nil, func(admission turnAdmission) {
1484 c.submitHTTPWithFormatLocked(input, display, format, admission)
1485 })
1486 }
1487
1488 func (c *Controller) submitHTTPWithFormatLocked(input, display, format string, admission turnAdmission) {
1489 trimmed := strings.TrimSpace(input)
1490 if note, ok := MemoryQuickAddNote(trimmed); ok {
1491 c.rememberProjectNote(note)
1492 return
1493 }
1494 if note, ok := RememberCommandNote(trimmed); ok {
1495 c.rememberProjectNote(note)
1496 return
1497 }
1498 if c.applyGoalCommandWithAdmission(trimmed, display, admission) {
1499 return
1500 }
1501 if strings.HasPrefix(trimmed, "!") {
1502 c.notice("shell commands are unavailable from this frontend")
1503 return
1504 }
1505 c.submitCommandOrTurn(trimmed, input, display, true, "", format, admission)
1506 }
1507
1508 func (c *Controller) submitCommandOrTurnReady(trimmed, input, display string, scopedRefsOnly bool, editedOriginal, format string, admission turnAdmission) {
1509 runRefTurn := func(input, display string) {
1510 c.runRefTurnWithFormat(input, display, format, admission)
1511 }
1512 runRefTurnWithRefs := func(input, refLine, display string) {
1513 c.runRefTurnWithRefsFormat(input, refLine, display, format, admission)
1514 }
1515 runGoalLoop := func(ctx context.Context, input, raw, display string) error {
1516 return c.runGoalLoopWithRawDisplay(c.withTurnFormat(ctx, format), input, raw, display)
1517 }
1518 if scopedRefsOnly {
1519 runRefTurn = func(input, display string) {
1520 c.runScopedRefTurnWithFormat(input, display, format, admission)
1521 }
1522 runRefTurnWithRefs = func(input, refLine, display string) {
1523 c.runScopedRefTurnWithRefsFormat(input, refLine, display, format, admission)
1524 }
1525 }
1526 if strings.TrimSpace(editedOriginal) != "" {
1527 runRefTurn = func(input, display string) {
1528 c.runEditedRefTurnWithFormat(input, display, editedOriginal, format, admission)
1529 }
1530 runRefTurnWithRefs = func(input, refLine, display string) {
1531 c.runEditedRefTurnWithRefsFormat(input, refLine, display, editedOriginal, format, admission)
1532 }
1533 runGoalLoop = func(ctx context.Context, input, raw, display string) error {
1534 return c.runEditedGoalLoopWithRawDisplay(ctx, input, raw, display, editedOriginal)
1535 }
1536 }
1537 if id, guidance, ok := ParseProtocolRecoveryCommand(trimmed); ok {
1538 c.submitProtocolRecoveryLocked(id, guidance, admission)
1539 return
1540 }
1541 if c.submitFinalReadinessCommand(trimmed, display, admission) {
1542 return
1543 }
1544 switch {
1545 case trimmed == "/compact" || strings.HasPrefix(trimmed, "/compact "):
1546 focus := strings.TrimSpace(strings.TrimPrefix(trimmed, "/compact"))
1547 go func() {
1548 // CompactionDone already carries the outcome card to every sink; a
1549 // second "compacted" notice only adds a folded duplicate row.
1550 if err := c.Compact(context.Background(), focus); err != nil {
1551 c.notice("compaction failed: " + err.Error())
1552 } else if err := c.SnapshotRewrite(); err != nil {
1553 slog.Warn("controller: snapshot after compact", "err", err)
1554 }
1555 }()
1556 case trimmed == "/context":
1557 c.noticeDetail(c.ContextReport())
1558 case trimmed == "/new":
1559 c.runSessionVerb(c.NewSession, "new session", "new session failed: ")
1560 case trimmed == "/clear":
1561 c.runSessionVerb(c.ClearSession, "context cleared", "clear context failed: ")
1562 case strings.HasPrefix(trimmed, "/mcp__"):
1563 c.runGuardedWithAdmission(func(ctx context.Context) error {
1564 sent, found, err := c.MCPPrompt(ctx, trimmed)
1565 if err != nil {
1566 return err
1567 }
1568 if !found {
1569 c.notice("unknown command: " + trimmed)
1570 return nil
1571 }
1572 return runGoalLoop(ctx, sent, sent, display)
1573 }, admission)
1574 case SlashCodeCommentLine(trimmed):
1575 // Slash-prefixed code comments are prompt text, not slash commands.
1576 runRefTurn(input, display)
1577 case strings.HasPrefix(trimmed, "/"):
1578 if ref, ok := FileRefLine(trimmed); ok {
1579 runRefTurn(ref, display)
1580 return
1581 }
1582 if ref, ok := SlashPathLineRef(trimmed, c.workspaceRoot); ok {
1583 runRefTurnWithRefs(input, ref, display)
1584 return
1585 }
1586 if SlashPathLikeLine(trimmed) {
1587 runRefTurn(input, display)
1588 return
1589 }
1590 // Management verbs (/model /memory /skills /hooks /mcp) emit a Notice, so
1591 // Submit-based frontends (desktop, HTTP) get them with no extra wiring.
1592 // The chat TUI handles these itself with richer output.
1593 fields := strings.Fields(trimmed)
1594 switch fields[0] {
1595 case "/tree":
1596 c.notice(c.BranchTreeText())
1597 return
1598 case "/branch":
1599 args := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0]))
1600 if turn, name, fromTurn, err := ParseBranchTarget(args); err != nil {
1601 c.notice(err.Error())
1602 } else if fromTurn {
1603 if _, err := c.ForkNamed(turn-1, name); err != nil {
1604 c.notice(err.Error())
1605 }
1606 } else {
1607 if _, err := c.Branch(name); err != nil {
1608 c.notice(err.Error())
1609 }
1610 }
1611 return
1612 case "/switch":
1613 ref := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0]))
1614 if _, err := c.SwitchBranch(ref); err != nil {
1615 c.notice(err.Error())
1616 }
1617 return
1618 case "/rewind":
1619 args := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0]))
1620 turn, scope, err := parseRewind(args, c.Checkpoints())
1621 if err != nil {
1622 c.notice("usage: /rewind [turn] [code|conversation|both]")
1623 return
1624 }
1625 if err := c.Rewind(turn, scope); err != nil {
1626 c.notice(err.Error())
1627 }
1628 return
1629 case "/plan-exec":
1630 c.applyPlanExec(trimmed, display)
1631 return
1632 case "/prometheus":
1633 c.applyPrometheus(trimmed, display, admission)
1634 return
1635 }
1636 if c.managementNotice(trimmed) {
1637 return
1638 }
1639 if IsBuiltinDocsSlash(fields[0], c.Commands(), c.SlashSkills()) {
1640 query := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0]))
1641 if query == "" {
1642 text, err := DocsCommandOverviewFor(fields[0])
1643 if err != nil {
1644 c.notice("docs: " + err.Error())
1645 } else {
1646 c.notice(text)
1647 }
1648 return
1649 }
1650 c.runGuardedWithAdmission(func(ctx context.Context) error {
1651 sent, err := docsCommandPrompt(ctx, query)
1652 if err != nil {
1653 return fmt.Errorf("docs: %w", err)
1654 }
1655 return runGoalLoop(ctx, sent, sent, display)
1656 }, admission)
1657 return
1658 }
1659 // A custom command wins over a skill of the same name; both resolve to a
1660 // turn. Built-ins and their explicit Reasonix namespace are handled above.
1661 if sent, ok := c.CustomCommand(trimmed); ok {
1662 c.runGuardedWithAdmission(func(ctx context.Context) error {
1663 return runGoalLoop(ctx, sent, sent, display)
1664 }, admission)
1665 return
1666 }
1667 if sk, task, ok := c.resolveSkillInvocation(trimmed); ok {
1668 if sk.RunAs == skill.RunSubagent {
1669 if strings.TrimSpace(task) == "" {
1670 c.notice("usage: /" + sk.Name + " <task>")
1671 return
1672 }
1673 c.runSubagentSkillSlash(sk, task, trimmed, display, admission)
1674 return
1675 }
1676 sent := c.skills.render(sk, task)
1677 c.runGuardedWithAdmission(func(ctx context.Context) error {
1678 return runGoalLoop(ctx, sent, sent, display)
1679 }, admission)
1680 return
1681 }
1682 // Unknown slash input is prose more often than a typo ("/etc/hosts
1683 // looks wrong", pasted paths, half-remembered commands) — send it as a
1684 // regular message instead of dead-ending the submission, with a notice
1685 // so real typos are still visible (#5756).
1686 c.notice("unknown command: " + trimmed + " — sent as a regular message")
1687 runRefTurn(input, display)
1688 default:
1689 runRefTurn(input, display)
1690 }
1691 }
1692
1693 func (c *Controller) rememberProjectNote(note string) {
1694 if note == "" {
1695 c.notice("nothing to remember")
1696 return
1697 }
1698 if path, err := c.QuickAdd(memory.ScopeProject, note); err != nil {
1699 c.notice("memory: " + err.Error())
1700 } else {
1701 c.notice("remembered → " + path)
1702 }
1703 }
1704
1705 // applyPlanExec is a command tombstone. The old path coupled Plan approval,
1706 // todo state and Goal continuation and is intentionally absent from the runtime.
1707 func (c *Controller) applyPlanExec(_, _ string) {
1708 c.notice("/plan-exec is retired; approve the Plan, then let the model create a fresh todo list for the new turn")
1709 }
1710
1711 // prometheusPrompt is the strategic planner system prompt.
1712 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. Read the current goal with get_goal, then call update_goal with its exact ID/revision and action complete. Do not implement.\n\nFor independent research directions, use parallel_tasks before planning."
1713
1714 // applyPrometheus starts an interactive planning interview, inspired by OMO's
1715 // Prometheus agent. It enters goal mode with a structured interview prompt.
1716 func (c *Controller) applyPrometheus(input, display string, admission turnAdmission) {
1717 args := strings.TrimSpace(strings.TrimPrefix(input, "/prometheus"))
1718 if args == "" || args == "--strict" {
1719 c.notice("usage: /prometheus <your task description>")
1720 return
1721 }
1722 strict := false
1723 if strings.HasPrefix(args, "--strict ") {
1724 strict = true
1725 args = strings.TrimPrefix(args, "--strict ")
1726 }
1727 prompt := prometheusPrompt + "\n\n## User request\n\n" + args + "\n\nBegin the interview by asking your first clarifying question."
1728 c.SetPlanMode(false)
1729 c.SetGoal("plan: " + ShortGoalForNotice(args))
1730 c.GoalStrict(strict)
1731 c.notice("prometheus: starting planning interview")
1732 if c.runner != nil {
1733 c.runGuardedWithAdmission(func(ctx context.Context) error {
1734 return c.runGoalLoopWithRawDisplay(ctx, prompt, prompt, display)
1735 }, admission)
1736 }
1737 }
1738
1739 // shellTimeout is the maximum time a user-invoked "!command" may run. Matches
1740 // the bash tool's timeout so behaviour is consistent across invocation paths.
1741 const shellTimeout = 120 * time.Second
1742
1743 // shellWaitDelay bounds how long cmd.Run() waits after context cancellation for
1744 // the child's pipes to drain, matching the bash tool's WaitDelay.
1745 const shellWaitDelay = 5 * time.Second
1746
1747 func shellCommandPreview(command string) string {
1748 command = strings.TrimSpace(strings.ReplaceAll(command, "\n", " "))
1749 const max = 48
1750 r := []rune(command)
1751 if len(r) > max {
1752 return string(r[:max]) + "…"
1753 }
1754 return command
1755 }
1756
1757 // RunShell executes a shell command directly (bypassing the model) and streams
1758 // the output as ToolDispatch/ToolProgress/ToolResult events. It uses the same
1759 // bash-tool infrastructure (shell resolution, timeout) and shares the runGuarded
1760 // lock with model turns — only one can run at a time. User-invoked "!" commands
1761 // run without the OS sandbox (the user typed the command explicitly).
1762 func (c *Controller) RunShell(command string) {
1763 c.runShell(command, turnAdmission{})
1764 }
1765
1766 func (c *Controller) runShell(command string, admission turnAdmission) {
1767 command = strings.TrimSpace(command)
1768 if command == "" {
1769 c.notice(i18n.M.ShellExecEmpty)
1770 return
1771 }
1772 c.runGuardedWithAdmission(func(ctx context.Context) error {
1773 sh := c.shell
1774 if sh.Path == "" {
1775 sh = sandbox.ResolveShell("", "", nil)
1776 }
1777 argv, _ := sandbox.Command(sandbox.Spec{}, sh, command) // false = unsandboxed (user invoked)
1778
1779 preview := []rune(command)
1780 if len(preview) > 32 {
1781 preview = preview[:32]
1782 }
1783 id := "shell-" + string(preview)
1784 diagnosticPreview := shellCommandPreview(command)
1785 desc := shellrun.DescriptorFromShell(sh)
1786 toolName := "bash"
1787 if sh.Kind == sandbox.ShellPowerShell {
1788 toolName = "pwsh"
1789 }
1790
1791 if err := event.EmitChecked(c.sink, event.Event{
1792 Kind: event.ToolDispatch,
1793 Tool: event.Tool{
1794 ID: id,
1795 Name: toolName,
1796 Args: fmt.Sprintf(`{"command":%q}`, command),
1797 Execution: &event.ShellExecution{
1798 Kind: desc.Kind, Shell: desc.Shell, ShellVersion: desc.ShellVersion,
1799 Platform: desc.Platform, SupportsAndAnd: desc.SupportsAndAnd,
1800 State: tool.ShellStateRunning,
1801 },
1802 },
1803 }); err != nil {
1804 return fmt.Errorf("persist shell dispatch: %w", err)
1805 }
1806
1807 start := time.Now()
1808 res := shellrun.RunForeground(ctx, shellrun.Request{
1809 Argv: argv,
1810 Dir: c.workspaceRoot,
1811 Timeout: shellTimeout,
1812 WaitDelay: shellWaitDelay,
1813 CommandPreview: diagnosticPreview,
1814 ShellKind: sh.Kind.String(),
1815 ShellPath: sh.Path,
1816 Source: "user_shell",
1817 Track: true,
1818 Progress: func(chunk string) {
1819 c.sink.Emit(event.Event{
1820 Kind: event.ToolProgress,
1821 Tool: event.Tool{ID: id, Output: chunk},
1822 })
1823 },
1824 })
1825 durationMs := time.Since(start).Milliseconds()
1826 ex := &event.ShellExecution{
1827 Kind: desc.Kind, Shell: desc.Shell, ShellVersion: desc.ShellVersion,
1828 Platform: desc.Platform, SupportsAndAnd: desc.SupportsAndAnd,
1829 State: res.State, FailurePhase: res.FailurePhase,
1830 OutputTail: res.OutputTail, DurationMs: durationMs,
1831 MutationRisk: tool.ShellMutationNone,
1832 Verification: tool.ShellVerificationNotVerification,
1833 }
1834 if res.ExitCode != nil {
1835 code := *res.ExitCode
1836 ex.ExitCode = &code
1837 }
1838 switch res.State {
1839 case tool.ShellStateCompleted:
1840 ex.MutationRisk = tool.ShellMutationNone
1841 case tool.ShellStateNotRun:
1842 ex.MutationRisk = tool.ShellMutationNotStarted
1843 case tool.ShellStateFailed:
1844 if res.FailurePhase == tool.ShellPhaseLaunch {
1845 ex.MutationRisk = tool.ShellMutationNotStarted
1846 } else {
1847 ex.MutationRisk = tool.ShellMutationMayBePartial
1848 }
1849 case tool.ShellStateTimedOut, tool.ShellStateCancelled:
1850 ex.MutationRisk = tool.ShellMutationMayBePartial
1851 }
1852
1853 errText := ""
1854 switch res.State {
1855 case tool.ShellStateCancelled:
1856 errText = i18n.M.TurnCancelled
1857 case tool.ShellStateTimedOut:
1858 errText = fmt.Sprintf(i18n.M.ShellExecTimeoutFmt, shellTimeout)
1859 case tool.ShellStateFailed, tool.ShellStateNotRun:
1860 if res.Err != nil {
1861 errText = fmt.Sprintf(i18n.M.ShellExecFailedFmt, res.Err)
1862 }
1863 }
1864 c.sink.Emit(event.Event{
1865 Kind: event.ToolResult,
1866 Tool: event.Tool{
1867 ID: id, Name: "bash", Output: res.Combined, Err: errText,
1868 DurationMs: durationMs, Execution: ex,
1869 },
1870 })
1871 return nil
1872 }, admission)
1873 }
1874
1875 // runRefTurn resolves a line's @references into a context block and starts a
1876 // turn with it prepended (or the raw line when nothing resolved).
1877 func (c *Controller) runRefTurn(input, display string) {
1878 c.runRefTurnWithAdmission(input, display, turnAdmission{})
1879 }
1880
1881 func (c *Controller) runRefTurnWithAdmission(input, display string, admission turnAdmission) {
1882 c.runRefTurnWithRefs(input, input, display, admission)
1883 }
1884
1885 // runRefTurnWithFormat runs a reference turn with a structured-output
1886 // format bound to its context (symmetric with runGoalLoop's withTurnFormat
1887 // injection — format is a property of every accepted turn, not just the
1888 // plain-goal path; review #7234 binds format to the accepted turn).
1889 func (c *Controller) runRefTurnWithFormat(input, display, format string, admission turnAdmission) {
1890 c.runPreparedRefTurn(input, input, display, "", c.resolveUnscopedRefsForTurn, func(ctx context.Context) context.Context {
1891 return c.withTurnFormat(ctx, format)
1892 }, admission)
1893 }
1894
1895 func (c *Controller) runScopedRefTurnWithFormat(input, display, format string, admission turnAdmission) {
1896 c.runPreparedRefTurn(input, input, display, "", c.resolveScopedRefsForTurn, func(ctx context.Context) context.Context {
1897 return c.withTurnFormat(ctx, format)
1898 }, admission)
1899 }
1900
1901 func (c *Controller) runRefTurnWithRefsFormat(input, refLine, display, format string, admission turnAdmission) {
1902 c.runPreparedRefTurn(input, refLine, display, "", c.resolveUnscopedRefsForTurn, func(ctx context.Context) context.Context {
1903 return c.withTurnFormat(ctx, format)
1904 }, admission)
1905 }
1906
1907 func (c *Controller) runScopedRefTurnWithRefsFormat(input, refLine, display, format string, admission turnAdmission) {
1908 c.runPreparedRefTurn(input, refLine, display, "", c.resolveScopedRefsForTurn, func(ctx context.Context) context.Context {
1909 return c.withTurnFormat(ctx, format)
1910 }, admission)
1911 }
1912
1913 func (c *Controller) runEditedRefTurnWithFormat(input, display, original, format string, admission turnAdmission) {
1914 c.runPreparedRefTurn(input, input, display, original, c.resolveUnscopedRefsForTurn, func(ctx context.Context) context.Context {
1915 return c.withTurnFormat(ctx, format)
1916 }, admission)
1917 }
1918
1919 func (c *Controller) runEditedRefTurnWithRefsFormat(input, refLine, display, original, format string, admission turnAdmission) {
1920 c.runPreparedRefTurn(input, refLine, display, original, c.resolveUnscopedRefsForTurn, func(ctx context.Context) context.Context {
1921 return c.withTurnFormat(ctx, format)
1922 }, admission)
1923 }
1924
1925 // runRefTurnWithRefs resolves references from refLine while preserving input as
1926 // the user's actual prompt text. This lets compiler diagnostics such as
1927 // "/path/File.kt:12: error" attach @/path/File.kt without rewriting the error.
1928 func (c *Controller) runRefTurnWithRefs(input, refLine, display string, admission turnAdmission) {
1929 c.runRefTurnWithResolver(input, refLine, display, c.resolveUnscopedRefsForTurn, admission)
1930 }
1931
1932 func (c *Controller) runRefTurnWithResolver(input, refLine, display string, resolve func(context.Context, string) resolvedReferences, admission turnAdmission) {
1933 c.runPreparedRefTurn(input, refLine, display, "", resolve, func(ctx context.Context) context.Context { return ctx }, admission)
1934 }
1935
1936 func (c *Controller) runRefTurnWithResolverSync(ctx context.Context, input, refLine, display, original string, resolve func(context.Context, string) resolvedReferences) error {
1937 resolved := resolve(ctx, refLine)
1938 return c.runResolvedRefTurnSync(ctx, input, display, original, resolved)
1939 }
1940
1941 func (c *Controller) runResolvedRefTurnSync(ctx context.Context, input, display, original string, resolved resolvedReferences) error {
1942 if len(resolved.imageErrs) > 0 {
1943 return ImageReferenceFailures(resolved.imageErrs)
1944 }
1945 for _, e := range resolved.errs {
1946 c.notice(e)
1947 }
1948 sent := input
1949 if resolved.block != "" {
1950 sent = "Referenced context:\n\n" + resolved.block + "\n\n" + input
1951 }
1952 if strings.TrimSpace(original) != "" {
1953 return c.runEditedGoalLoopWithFrozenImagesRawDisplay(ctx, sent, input, display, original, resolved.images)
1954 }
1955 return c.runGoalLoopWithFrozenImagesRawDisplay(ctx, sent, input, display, resolved.images)
1956 }
1957
1958 // notice emits an informational Notice event.
1959 func (c *Controller) notice(text string) {
1960 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})
1961 }
1962
1963 func (c *Controller) noticeDetail(text, detail string) {
1964 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text, Detail: detail})
1965 }
1966
1967 // Run executes a turn synchronously, returning the agent's error. Used by the
1968 // headless `reasonix run` path, where the Sink renders to stdout and the caller
1969 // just needs the exit status — no TurnDone event, no cancel bookkeeping.
1970 func (c *Controller) runReady(ctx context.Context, input string) (err error) {
1971 ctx = extension.ContextWithRuntimeOwner(ctx, c.RuntimeOwner())
1972 if c.RuntimePhase() == RuntimePhaseDraining {
1973 c.emitDrainingNotice()
1974 return ErrRuntimeDraining
1975 }
1976 c.maybeSessionStart(ctx)
1977 parentSession := c.parentSessionID()
1978 ctx = agent.WithParentSession(ctx, parentSession)
1979 ctx = jobs.WithSession(ctx, parentSession)
1980 rawInput := input
1981 ctx = c.withTurnImages(ctx, rawInput)
1982 ctx = agent.WithRawUserInput(ctx, rawInput)
1983 input = c.Compose(input)
1984 // input.receive: same interception seam as the orchestrated turn — the
1985 // composed headless input crosses the extension chain before it enters
1986 // the session.
1987 input, blocked, interceptErr := c.interceptInputReceive(ctx, input)
1988 if interceptErr != nil {
1989 return interceptErr
1990 }
1991 if blocked {
1992 return nil
1993 }
1994 startMessages := c.messageCount()
1995 var marker agent.InFlightTurnMeta
1996 defer func() { c.finishInFlightTurn(startMessages, marker) }()
1997 c.beginCheckpoint(ctx, rawInput)
1998 if c.guardianSess != nil {
1999 c.guardianSess.ResetTurn()
2000 }
2001 if c.hooks.Enabled() {
2002 c.mu.Lock()
2003 c.turn++
2004 turn := c.turn
2005 c.mu.Unlock()
2006 if block, _ := c.hooks.PromptSubmit(ctx, input, turn); block {
2007 return nil
2008 }
2009 defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }()
2010 }
2011 marker = c.markInFlightTurn(startMessages, true)
2012 ctx = c.withTurnContext(ctx, true)
2013 ctx = c.withPlannerTurnMetadata(ctx, rawInput, false, startMessages)
2014 modelInput := c.withCapabilityRoute(ctx, input, rawInput)
2015 modelInput, ctx, err = c.prepareVisionTurn(ctx, modelInput, agent.SubagentImageCandidates(ctx))
2016 if err != nil {
2017 return err
2018 }
2019 err = c.runModelTurn(ctx, modelInput)
2020 return err
2021 }
2022
2023 // beginRotation claims the session-rotation gate. It fails if a turn is running
2024 // or another rotation is already in progress, so the caller holds exclusive
2025 // rights to swap the executor session from the check here through endRotation.
2026 // This closes the TOCTOU window that a bare `if c.running` check left open:
2027 // between that check and the actual SetSession, a turn could start and then be
2028 // yanked out from under the run loop.
2029 func (c *Controller) beginRotation() error {
2030 c.mu.Lock()
2031 defer c.mu.Unlock()
2032 if c.bodyActiveLocked() || c.finalizingLocked() {
2033 return errTurnRunningRotation
2034 }
2035 if c.rotating {
2036 return errRotationInProgress
2037 }
2038 c.rotating = true
2039 return nil
2040 }
2041
2042 // CancelRequested reports whether Cancel has been requested for the active turn.
2043 func (c *Controller) CancelRequested() bool {
2044 c.mu.Lock()
2045 defer c.mu.Unlock()
2046 return c.cancelRequestedLocked()
2047 }
2048
2049 // PendingPrompt reports whether the current turn is blocked waiting for a user
2050 // approval, plan approval, memory approval, or ask-tool answer.
2051 func (c *Controller) PendingPrompt() bool {
2052 return len(c.promptOwner.Identities()) > 0
2053 }
2054
2055 // RuntimeStatus reports the active work owned by the foreground controller.
2056 func (c *Controller) RuntimeStatus() RuntimeStatus {
2057 snapshot := c.RuntimeStateSnapshot()
2058 _, _, _, replayAfterSeq := c.turnEventRuntimeStatus()
2059 return RuntimeStatus{
2060 Running: snapshot.Running,
2061 PendingPrompt: snapshot.PendingPrompt,
2062 BackgroundJobs: snapshot.BackgroundJobs,
2063 CancelRequested: snapshot.CancelRequested,
2064 Cancellable: snapshot.Cancellable,
2065 TurnID: snapshot.TurnID,
2066 Status: snapshot.TurnStatus,
2067 TurnEventSeq: snapshot.TurnEventSeq,
2068 ReplayAfterSeq: replayAfterSeq,
2069 }
2070 }
2071
2072 // Turn returns the current turn number (0 before the first submit).
2073 func (c *Controller) Turn() int {
2074 c.mu.Lock()
2075 defer c.mu.Unlock()
2076 return c.turn
2077 }
2078
2079 func (c *Controller) recordDecisionReceipt(pending pendingApproval, outcome string) {
2080 if c == nil || c.executor == nil || pending.reply == nil {
2081 return
2082 }
2083 kind := pending.kind
2084 if kind == "" {
2085 kind = "tool"
2086 if pending.tool == planApprovalTool {
2087 kind = "plan"
2088 }
2089 }
2090 receipt := &provider.DecisionReceipt{
2091 ID: pending.id,
2092 Kind: kind,
2093 Tool: strings.TrimSpace(pending.tool),
2094 Subject: clipUTF8(strings.TrimSpace(pending.subject), 240),
2095 Outcome: strings.TrimSpace(outcome),
2096 }
2097 // Keep the receipt bounded and provider-excluded even when an older caller
2098 // omits optional approval metadata.
2099 c.executor.Session().AddDecisionReceipt(receipt)
2100 c.sink.Emit(event.Event{
2101 Kind: event.Notice,
2102 Code: event.NoticeCodeDecisionReceipt,
2103 Level: event.LevelInfo,
2104 Text: "Decision recorded: " + receipt.Outcome,
2105 DecisionReceipt: receipt,
2106 })
2107 }
2108
2109 // EnableInteractiveApproval swaps the executor's gate for one that routes
2110 // approval decisions to the frontend via ApprovalRequest events, and wires the
2111 // controller in as the executor's Asker so the `ask` tool can question the user.
2112 // Interactive frontends (chat, desktop) call this; the headless run keeps the
2113 // silent gate and a nil asker from setup.
2114 func (c *Controller) EnableInteractiveApproval() {
2115 trustGate := planModeReadOnlyTrustApprover{c}
2116 escapeApprover := sandboxEscapeApprover{c}
2117 configApprover := managedConfigWriteApprover{c}
2118 c.writeAccess.interactive = true
2119 if c.executor != nil {
2120 c.executor.SetGate(c.newInteractiveGate())
2121 c.executor.SetPlanModeReadOnlyTrustGate(trustGate)
2122 c.executor.SetSandboxEscapeApprover(escapeApprover)
2123 c.executor.SetConfigWriteApprover(configApprover)
2124 c.executor.SetWriteAccessGate(c)
2125 c.executor.SetWriteRoots(c.writeAccess.roots)
2126 c.executor.SetPermissionPresetProvider(c.ToolApprovalMode)
2127 c.executor.SetAsker(c)
2128 c.executor.SetInteractionBroker(c)
2129 }
2130 if setter, ok := c.runner.(interface {
2131 SetPlanModeReadOnlyTrustGate(agent.PlanModeReadOnlyTrustGate)
2132 }); ok {
2133 setter.SetPlanModeReadOnlyTrustGate(trustGate)
2134 }
2135 if setter, ok := c.runner.(interface {
2136 SetSandboxEscapeApprover(sandbox.EscapeApprover)
2137 }); ok {
2138 setter.SetSandboxEscapeApprover(escapeApprover)
2139 }
2140 if setter, ok := c.runner.(interface {
2141 SetConfigWriteApprover(tool.ConfigWriteApprover)
2142 }); ok {
2143 setter.SetConfigWriteApprover(configApprover)
2144 }
2145 if setter, ok := c.runner.(interface {
2146 SetWriteAccessGate(agent.WriteAccessGate)
2147 }); ok {
2148 setter.SetWriteAccessGate(c)
2149 }
2150 if setter, ok := c.runner.(interface{ SetPermissionPresetProvider(func() string) }); ok {
2151 setter.SetPermissionPresetProvider(c.ToolApprovalMode)
2152 }
2153 if setter, ok := c.runner.(interface {
2154 SetWriteRoots(*sandbox.WritableRootSet)
2155 }); ok {
2156 setter.SetWriteRoots(c.writeAccess.roots)
2157 }
2158 if setter, ok := c.runner.(interface {
2159 SetPlannerPlanApprover(agent.PlannerPlanApprover)
2160 }); ok {
2161 setter.SetPlannerPlanApprover(plannerPlanApprover{c: c})
2162 }
2163 // The planner holds the real ask tool, so it reaches the same approval
2164 // surface the executor does instead of a parallel prose-question path.
2165 if setter, ok := c.runner.(interface{ SetAsker(agent.Asker) }); ok {
2166 setter.SetAsker(c)
2167 }
2168 if setter, ok := c.runner.(interface{ SetInteractionBroker(mcpinteraction.Broker) }); ok {
2169 setter.SetInteractionBroker(c)
2170 }
2171 }
2172
2173 type plannerPlanApprover struct {
2174 c *Controller
2175 }
2176
2177 func (p plannerPlanApprover) RunWithPlannerApproval(ctx context.Context, plan string, run func(context.Context) error) error {
2178 c := p.c
2179 allow, _, err := c.requestApprovalWithReason(ctx, planApprovalTool, "", nil, "Planner requested host approval before execution.")
2180 if err != nil {
2181 return err
2182 }
2183 if !allow {
2184 return nil
2185 }
2186 c.approval.setPlanAutoApprove(true)
2187 defer c.approval.setPlanAutoApprove(false)
2188 if err := run(ctx); err != nil {
2189 return err
2190 }
2191 return nil
2192 }
2193
2194 func (c *Controller) newInteractiveGate() *permission.Gate {
2195 policy := c.policy
2196 mode := c.approval.mode()
2197 switch mode {
2198 case ToolApprovalWorkspaceWrite, ToolApprovalDangerFullAccess:
2199 policy.Mode = permission.Allow
2200 case ToolApprovalDontAsk:
2201 policy.Mode = permission.Deny
2202 default:
2203 policy.Mode = permission.Ask
2204 }
2205 // SessionAllow must not cover fresh-human tools: it is checked before Ask,
2206 // so `--allowed-tools remember` would skip the prompt. Interactive Auto and
2207 // YOLO treat remember/forget as ordinary policy decisions; Auto still
2208 // preserves an explicit configured Ask rule, while YOLO bypasses it.
2209 policy.SessionAllow = rulesWithoutFreshHumanApproval(policy.SessionAllow)
2210 if mode != ToolApprovalWorkspaceWrite && mode != ToolApprovalDangerFullAccess {
2211 policy.Ask = append(policy.Ask,
2212 permission.Rule{Tool: memoryRememberTool},
2213 permission.Rule{Tool: memoryForgetTool},
2214 )
2215 }
2216 // The OS sandbox, rather than shell syntax heuristics, owns the write
2217 // boundary for all three presets. Explicit ask and deny rules still win.
2218 var approver permission.Approver = gateApprover{c}
2219 if mode == ToolApprovalDontAsk {
2220 approver = denyPermissionApprover{}
2221 }
2222 gate := permission.NewGate(policy, approver)
2223 gate.OnRemember = func(rule string) {
2224 if c.onRemember != nil {
2225 _ = c.onRemember(rule)
2226 }
2227 }
2228 return gate
2229 }
2230
2231 func (c *Controller) allowLowRiskRemember(args json.RawMessage) bool {
2232 mem := c.Memory()
2233 if mem != nil {
2234 if assessment := memory.AssessRememberWrite(mem.Store, args); assessment.AutoAllow {
2235 c.memory.authorizeAutoRemember(args)
2236 return true
2237 }
2238 }
2239 c.memory.revokeAutoRemember(args)
2240 return false
2241 }
2242
2243 func (c *Controller) newHeadlessGate(mode string) *freshHumanHeadlessGate {
2244 gate := BuildHeadlessApprovalGate(c.policy, mode)
2245 gate.allowLowRiskFreshAction = func(toolName string, args json.RawMessage) bool {
2246 return toolName == memoryRememberTool && c.allowLowRiskRemember(args)
2247 }
2248 return gate
2249 }
2250
2251 type denyPermissionApprover struct{}
2252
2253 func (denyPermissionApprover) Approve(context.Context, string, string, json.RawMessage) (bool, bool, error) {
2254 return false, false, nil
2255 }
2256
2257 // rulesWithoutFreshHumanApproval drops any session-allow rule that targets a
2258 // tool requiring fresh human approval, so an explicit allowlist cannot bypass
2259 // the always-prompt contract for those tools.
2260 func rulesWithoutFreshHumanApproval(rules []permission.Rule) []permission.Rule {
2261 if len(rules) == 0 {
2262 return rules
2263 }
2264 filtered := make([]permission.Rule, 0, len(rules))
2265 for _, r := range rules {
2266 if RequiresFreshHumanApprovalTool(r.Tool) {
2267 continue
2268 }
2269 filtered = append(filtered, r)
2270 }
2271 return filtered
2272 }
2273
2274 // ApplyHeadlessApprovalMode configures the executor gate for a non-interactive
2275 // (`reasonix run`) session from an explicit --permission-mode. Unlike
2276 // EnableInteractiveApproval it installs no blocking approver, asker, or
2277 // fresh-approval prompt: there is no key loop to answer them, and the default
2278 // infinite approval timeout would wedge the run forever on an Ask rule, the
2279 // `ask` tool, or a sandbox/config approval. Modes map straight onto a headless
2280 // gate, and each preserves the interactive contract as closely as a run with no
2281 // one to prompt allows:
2282 //
2283 // - auto: auto-approve the writer fallback (Mode=Allow) but PRESERVE explicit
2284 // ask rules. Interactive auto prompts on those (it never auto-approves them);
2285 // headless can't prompt, so a would-ask decision fails closed (deny) rather
2286 // than running silently. Only bypass may run such a command unattended.
2287 // - yolo/bypassPermissions: skip ordinary approval-gated decisions (nil
2288 // approver); deny rules and fresh decisions still fail closed.
2289 // - dontAsk: deny anything that would ask, and deny the writer fallback too.
2290 //
2291 // Deny rules and fresh-human tools (memory, plan, sandbox, config) stay enforced
2292 // by the gate for every mode. The only exception is a controller-assessed,
2293 // create-only project/reference memory; every other memory write remains denied.
2294 func (c *Controller) ApplyHeadlessApprovalMode(mode string) {
2295 mode = normalizeToolApprovalMode(mode)
2296 c.permissionStateMu.Lock()
2297 defer c.permissionStateMu.Unlock()
2298 c.approval.setMode(mode)
2299 if c.subagentGate != nil {
2300 c.subagentGate.Update(mode)
2301 }
2302 c.writeAccess.interactive = false
2303 if c.executor != nil {
2304 c.executor.SetGate(c.newHeadlessGate(mode))
2305 c.executor.SetWriteAccessGate(c)
2306 c.executor.SetWriteRoots(c.writeAccess.roots)
2307 c.executor.SetPermissionPresetProvider(c.ToolApprovalMode)
2308 }
2309 }
2310
2311 func (c *Controller) refreshInteractiveGate() {
2312 if c.executor != nil {
2313 c.executor.SetGate(c.newInteractiveGate())
2314 }
2315 }
2316
2317 // TrySteer queues mid-turn guidance only when the active agent turn accepts it.
2318 func (c *Controller) TrySteer(text string) bool {
2319 c.mu.Lock()
2320 exec := c.executor
2321 running := c.bodyActiveLocked()
2322 c.mu.Unlock()
2323 return running && exec != nil && exec.Steer(text)
2324 }
2325
2326 // Steer is the compatibility path for callers that cannot observe admission.
2327 // Interactive hosts should call TrySteer so a rejected steer remains in their
2328 // draft/queue and can be retried as a regular follow-up.
2329 func (c *Controller) Steer(text string) {
2330 if c.TrySteer(text) {
2331 return
2332 }
2333 // No active turn accepted the steer: the frontend's runningRef was stale,
2334 // the turn exited between our running check and the enqueue, or no
2335 // executor is bound yet. Deliver it as a regular turn instead.
2336 c.submitSteerFallback(text)
2337 }
2338
2339 // submitSteerFallback records steer text that no active turn accepted as
2340 // unapplied guidance, not as a new task. This compatibility path deliberately
2341 // never opens a provider turn: replaying stale historical guidance as the
2342 // user's current request caused unintended code changes (#7045).
2343 func (c *Controller) submitSteerFallback(text string) admissionResult {
2344 return c.runGuardedOrPark(func(context.Context) error {
2345 if c.executor != nil {
2346 c.executor.RecordUnappliedSteer(text)
2347 }
2348 return nil
2349 })
2350 }
2351
2352 // SteerConsumed returns true when the steer queue is empty after the last consume.
2353 func (c *Controller) SteerConsumed() bool {
2354 c.mu.Lock()
2355 exec := c.executor
2356 c.mu.Unlock()
2357 if exec != nil {
2358 return exec.SteerConsumed()
2359 }
2360 return true
2361 }
2362
2363 // Ask implements agent.Asker: it emits an AskRequest and blocks until
2364 // AnswerQuestion(ID, …) answers or ctx is cancelled. Multiple requests may be
2365 // outstanding; the frontend presents the shared pending list one at a time.
2366 // Unlike tool-approval gates, Ask is NOT bypassed in YOLO mode — the `ask`
2367 // tool exists to get a genuine user decision, and YOLO only auto-approves
2368 // tool calls; it must not answer the user's questions for them.
2369 func (c *Controller) Ask(ctx context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error) {
2370 c.approval.promptEmitMu.Lock()
2371 id, reply := c.approval.registerAsk(questions)
2372 c.registerOwnedPrompt(id, PromptAsk)
2373 turnID, _, _, _ := c.turnEventRuntimeStatus()
2374 _, runtimeEpoch := c.promptIdentitySnapshot()
2375 if identity := c.bindOwnedPromptRouting(id, turnID, runtimeEpoch); identity.TurnID != "" {
2376 turnID = identity.TurnID
2377 }
2378 if err := event.EmitChecked(c.sink, event.Event{Kind: event.AskRequest, TurnID: turnID, ItemID: id, Ask: event.Ask{ID: id, Questions: questions, TurnID: turnID}}); err != nil {
2379 c.approval.promptEmitMu.Unlock()
2380 c.cancelOwnedPrompt(id)
2381 return nil, fmt.Errorf("persist ask request: %w", err)
2382 }
2383 c.approval.markAskEmitted(id)
2384 c.approval.promptEmitMu.Unlock()
2385
2386 waitCtx, cancelWait := c.approval.waitContext(ctx)
2387 defer cancelWait()
2388
2389 select {
2390 case ans := <-reply:
2391 return ans, nil
2392 case <-waitCtx.Done():
2393 c.cancelOwnedPrompt(id)
2394 return nil, waitCtx.Err()
2395 }
2396 }
2397
2398 // AnswerQuestion resolves a pending AskRequest by ID with the user's selections.
2399 // Unknown/expired IDs are ignored.
2400 func (c *Controller) AnswerQuestion(id string, answers []event.AskAnswer) {
2401 _ = c.AnswerQuestionChecked(id, answers)
2402 }
2403
2404 // AnswerQuestionChecked persists the prompt transition before releasing the
2405 // agent loop. A failed ledger write leaves the prompt pending and retryable.
2406 func (c *Controller) AnswerQuestionChecked(id string, answers []event.AskAnswer) error {
2407 defer c.refreshRuntimeState(event.Event{})
2408 return c.answerQuestionCheckedLocked(id, answers)
2409 }
2410
2411 func (c *Controller) answerQuestionCheckedLocked(id string, answers []event.AskAnswer) error {
2412 pending, ok, err := c.approval.resolveAskAfter(id, func(p pendingAsk) error {
2413 return c.emitTurnEventChecked(event.Event{Kind: event.PromptAnswered, ItemID: id, InteractionState: string(PromptAnswered), Status: event.TurnInProgress})
2414 })
2415 if err != nil {
2416 return err
2417 }
2418 if ok {
2419 c.promptOwner.MarkIDTerminal(id, PromptAnswered)
2420 // An answer batch with no selections is the explicit "skip and continue
2421 // chat" path. End the current turn instead of feeding a prose dismissal
2422 // back to the model and trusting it not to ask again (#6869).
2423 if !askAnswersHaveSelection(answers) {
2424 c.mu.Lock()
2425 activeTurn := c.turns.cancel != nil
2426 c.mu.Unlock()
2427 if activeTurn {
2428 c.cancelLocked()
2429 return nil
2430 }
2431 }
2432 c.recordAskDecisionReceipt(id, pending, answers)
2433 pending.reply <- answers // buffered, never blocks
2434 }
2435 return nil
2436 }
2437
2438 func (c *Controller) recordAskDecisionReceipt(id string, pending pendingAsk, answers []event.AskAnswer) {
2439 if c == nil || c.executor == nil {
2440 return
2441 }
2442 selected := make(map[string][]string, len(answers))
2443 for _, answer := range answers {
2444 selected[answer.QuestionID] = append([]string(nil), answer.Selected...)
2445 }
2446 parts := make([]string, 0, len(pending.questions))
2447 for _, question := range pending.questions {
2448 answer := strings.TrimSpace(strings.Join(selected[question.ID], ", "))
2449 if answer == "" {
2450 answer = "—"
2451 }
2452 prompt := strings.TrimSpace(question.Prompt)
2453 if prompt == "" {
2454 prompt = strings.TrimSpace(question.Header)
2455 }
2456 if prompt == "" {
2457 prompt = question.ID
2458 }
2459 parts = append(parts, prompt+": "+answer)
2460 }
2461 receipt := &provider.DecisionReceipt{
2462 ID: id,
2463 Kind: "ask",
2464 Subject: clipUTF8(strings.Join(parts, " · "), 240),
2465 Outcome: "answered",
2466 }
2467 c.executor.Session().AddDecisionReceipt(receipt)
2468 c.sink.Emit(event.Event{
2469 Kind: event.Notice,
2470 Code: event.NoticeCodeDecisionReceipt,
2471 Level: event.LevelInfo,
2472 Text: "Decision recorded: answered",
2473 DecisionReceipt: receipt,
2474 })
2475 }
2476
2477 func askAnswersHaveSelection(answers []event.AskAnswer) bool {
2478 for _, answer := range answers {
2479 if len(answer.Selected) > 0 {
2480 return true
2481 }
2482 }
2483 return false
2484 }
2485
2486 // ReplayPendingPrompts re-emits the ApprovalRequest / AskRequest event for every
2487 // prompt currently blocking the run loop. A frontend that reconnected or reloaded
2488 // after the original event has no way to rebuild its approval/ask modal otherwise,
2489 // so the blocked gate goroutine stays stuck forever while the session shows a
2490 // "waiting" status with no actionable prompt. All outstanding interactions
2491 // are replayed from the same registry; the frontend presents them in order.
2492 func (c *Controller) ReplayPendingPrompts() {
2493 c.approval.promptEmitMu.Lock()
2494 noApprovals := c.replayPendingPromptsTo(c.sink)
2495 c.approval.promptEmitMu.Unlock()
2496 if noApprovals {
2497 // Retained compatibility hook; live Auto Guard cards are ordinary approvals.
2498 c.ReplayUnresolvedRecoveries()
2499 }
2500 }
2501
2502 // ReplayPendingPromptsTo re-emits pending prompts to one frontend sink. Serve
2503 // uses this for a newly attached SSE client so existing browsers do not receive
2504 // duplicate approval/ask cards when another client reconnects.
2505 func (c *Controller) ReplayPendingPromptsTo(sink event.Sink) {
2506 c.approval.promptEmitMu.Lock()
2507 defer c.approval.promptEmitMu.Unlock()
2508 c.replayPendingPromptsTo(sink)
2509 }
2510
2511 // ReplayPendingPromptsWith performs an SSE connection handoff while prompt
2512 // registration and emission are paused. The factory must subscribe the new
2513 // client and return a sink that targets it; this closes the attach race where
2514 // the original prompt could otherwise land between Subscribe and replay.
2515 func (c *Controller) ReplayPendingPromptsWith(sinkFactory func() event.Sink) {
2516 if sinkFactory == nil {
2517 return
2518 }
2519 c.approval.promptEmitMu.Lock()
2520 defer c.approval.promptEmitMu.Unlock()
2521 c.replayPendingPromptsTo(sinkFactory())
2522 }
2523
2524 func (c *Controller) replayPendingPromptsTo(sink event.Sink) bool {
2525 approvals, asks := c.approval.snapshotPrompts()
2526 interactions := c.approval.snapshotMCPInteractions()
2527 c.emitPendingPrompts(sink, approvals, asks, interactions)
2528 return len(approvals) == 0
2529 }
2530
2531 func (c *Controller) emitPendingPrompts(sink event.Sink, approvals []event.Approval, asks []event.Ask, interactions []event.MCPInteraction) {
2532 if sink == nil {
2533 return
2534 }
2535 for _, a := range approvals {
2536 if identity, ok := c.promptOwner.Identity(a.ID); ok {
2537 a.TurnID = identity.TurnID
2538 }
2539 sink.Emit(c.approvalRequestEvent(a))
2540 }
2541 for _, a := range asks {
2542 if identity, ok := c.promptOwner.Identity(a.ID); ok {
2543 a.TurnID = identity.TurnID
2544 }
2545 sink.Emit(event.Event{Kind: event.AskRequest, TurnID: a.TurnID, ItemID: a.ID, Ask: a})
2546 }
2547 for _, i := range interactions {
2548 if identity, ok := c.promptOwner.Identity(i.ID); ok {
2549 i.TurnID = identity.TurnID
2550 }
2551 sink.Emit(event.Event{Kind: event.MCPInteractionRequest, TurnID: i.TurnID, ItemID: i.ID, MCPInteraction: i})
2552 }
2553 }
2554
2555 // SetPlanMode flips the executor's plan-first workflow flag without touching the
2556 // cache-stable system/tool prefix, and remembers the state so Compose can prepend
2557 // the plan-mode marker to outgoing user turns.
2558 func (c *Controller) SetPlanMode(v bool) {
2559 c.applyPlanMode(v)
2560 }
2561
2562 // SetAgentPreset accepts retired role inputs for compatibility. Recognized
2563 // values no longer change runtime behavior.
2564 func (c *Controller) SetAgentPreset(preset string) {
2565 if c == nil {
2566 return
2567 }
2568 if p, err := agentpreset.Normalize(preset); err == nil {
2569 _ = c.SetQualityFloor(string(p))
2570 }
2571 }
2572
2573 // AgentPreset returns the fixed compatibility label.
2574 func (c *Controller) AgentPreset() string {
2575 return string(agentpreset.Standard)
2576 }
2577
2578 // SetResponseLanguage updates the final-answer language preference for
2579 // subsequent turns.
2580 func (c *Controller) SetResponseLanguage(lang string) {
2581 mode := config.NormalizeLanguage(lang)
2582 c.mu.Lock()
2583 c.responseLanguage = mode
2584 c.mu.Unlock()
2585 if setter, ok := c.runner.(interface{ SetResponseLanguage(string) }); ok {
2586 setter.SetResponseLanguage(mode)
2587 } else if c.executor != nil {
2588 c.executor.SetResponseLanguage(mode)
2589 }
2590 }
2591
2592 // SetReasoningLanguage updates the visible reasoning language preference for
2593 // subsequent turns.
2594 func (c *Controller) SetReasoningLanguage(lang string) {
2595 mode := config.NormalizeReasoningLanguage(lang)
2596 c.mu.Lock()
2597 c.reasoningLanguage = mode
2598 c.mu.Unlock()
2599 if setter, ok := c.runner.(interface{ SetReasoningLanguage(string) }); ok {
2600 setter.SetReasoningLanguage(mode)
2601 } else if c.executor != nil {
2602 c.executor.SetReasoningLanguage(mode)
2603 }
2604 }
2605
2606 // PlanMode reports whether outgoing turns currently receive the plan-mode
2607 // marker.
2608 func (c *Controller) PlanMode() bool {
2609 c.mu.Lock()
2610 defer c.mu.Unlock()
2611 return c.sessionSettings.planMode
2612 }
2613
2614 // GoalStrict enables or disables strict goal mode. Since the structured
2615 // protocol, every complete claim is validated against host readiness and an
2616 // incomplete-todo intercept can never be overridden, so the flag is persisted
2617 // for compatibility with older frontends but no longer changes FSM behavior.
2618 func (c *Controller) GoalStrict(strict bool) {
2619 if c.sessionEngineEnabled() {
2620 return
2621 }
2622 path, data, ok := c.goals.setStrict(strict)
2623 c.persistGoalState(path, data, ok)
2624 }
2625
2626 // SetGoal stores a session-scoped active goal. Compose injects it into outgoing
2627 // user turns, not the system prompt or tool schema, so it does not disturb the
2628 // cache-stable prefix.
2629 func (c *Controller) SetGoal(goal string) {
2630 c.SetGoalWithResearchMode(goal, GoalResearchAuto)
2631 }
2632
2633 // LoadInactiveGoal restores a legacy metadata-only objective without starting
2634 // execution or writing a sidecar during read-only history access.
2635 func (c *Controller) LoadInactiveGoal(goal string) {
2636 c.goals.mu.Lock()
2637 defer c.goals.mu.Unlock()
2638 c.goals.installGoalLocked(strings.TrimSpace(goal), ClassifyGoalBudget(goal))
2639 c.goals.disarmed = true
2640 }
2641
2642 // SetGoalDurable updates the Goal only when its sidecar can be replaced
2643 // atomically.
2644 func (c *Controller) SetGoalDurable(goal string) error {
2645 if c.sessionEngineEnabled() {
2646 goal = strings.TrimSpace(goal)
2647 current, err := c.goalLifecycleView()
2648 if err != nil {
2649 return err
2650 }
2651 if goal == "" {
2652 if current == nil {
2653 return nil
2654 }
2655 _, err = c.applyHostGoalMutation(context.Background(), "clear", func(machine *goaldomain.Machine) (*goaldomain.View, error) {
2656 if clearErr := machine.Clear(current.Ref()); clearErr != nil {
2657 return nil, clearErr
2658 }
2659 return nil, nil
2660 })
2661 if err == nil {
2662 c.resetGoalResourceBudget()
2663 }
2664 return err
2665 }
2666 if current != nil && current.Objective == goal && current.Phase == goaldomain.PhaseActive && current.Activation == goaldomain.ActivationArmed {
2667 return nil
2668 }
2669 _, err = c.applyHostGoalMutation(context.Background(), "set", func(machine *goaldomain.Machine) (*goaldomain.View, error) {
2670 created, createErr := machine.Replace(goaldomain.CreateRequest{Objective: goal})
2671 return &created, createErr
2672 })
2673 if err == nil {
2674 c.resetGoalResourceBudget()
2675 }
2676 return err
2677 }
2678 snapshot := c.goals.capture()
2679 legacySnapshot, hadLegacySnapshot := c.legacyRestoreSnapshot()
2680 resolved, setup := c.resolveGoalText(goal, GoalResearchAuto)
2681 var path string
2682 var data []byte
2683 var persist bool
2684 if setup.blockReason != "" {
2685 path, data, persist = c.goals.setLegacyArchiveBlockedWithTaskID(resolved, setup.budgetClass, setup.blockReason, setup.legacyTaskID)
2686 c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken(), explicit: setup.explicit})
2687 } else {
2688 path, data, persist = c.goals.set(resolved, setup.budgetClass)
2689 c.replaceLegacyRestore(legacyGoalRestore{})
2690 }
2691 if persist {
2692 if err := c.goals.writeStateErr(path, data); err != nil {
2693 c.goals.restore(snapshot)
2694 if hadLegacySnapshot {
2695 legacySnapshot.epoch = c.goals.continuationToken()
2696 c.replaceLegacyRestore(legacySnapshot)
2697 } else {
2698 c.replaceLegacyRestore(legacyGoalRestore{})
2699 }
2700 return err
2701 }
2702 }
2703 if setup.notice != "" {
2704 c.notice(setup.notice)
2705 }
2706 if setup.blockReason != "" {
2707 c.notice("legacy research archive resume failed: " + setup.blockReason)
2708 }
2709 return nil
2710 }
2711
2712 func (c *Controller) SetGoalWithResearchMode(goal string, researchMode GoalResearchMode) {
2713 if c.sessionEngineEnabled() {
2714 if err := c.SetGoalDurable(goal); err != nil {
2715 c.notice("goal: " + err.Error())
2716 }
2717 return
2718 }
2719 resolved, setup := c.resolveGoalText(goal, researchMode)
2720 if setup.notice != "" {
2721 c.notice(setup.notice)
2722 }
2723 var path string
2724 var data []byte
2725 var ok bool
2726 if setup.blockReason != "" {
2727 path, data, ok = c.goals.setLegacyArchiveBlockedWithTaskID(resolved, setup.budgetClass, setup.blockReason, setup.legacyTaskID)
2728 c.replaceLegacyRestore(legacyGoalRestore{taskID: setup.legacyTaskID, epoch: c.goals.continuationToken(), explicit: setup.explicit})
2729 c.notice("legacy research archive resume failed: " + setup.blockReason)
2730 } else {
2731 path, data, ok = c.goals.set(resolved, setup.budgetClass)
2732 c.replaceLegacyRestore(legacyGoalRestore{})
2733 }
2734 c.persistGoalState(path, data, ok)
2735 }
2736
2737 // goalSetSetup is the resolved objective and budget class after archive lookup.
2738 type goalSetSetup struct {
2739 budgetClass string
2740 notice string
2741 blockReason string
2742 legacyTaskID string
2743 explicit bool
2744 }
2745
2746 func (c *Controller) resolveGoalText(goal string, researchMode GoalResearchMode) (string, goalSetSetup) {
2747 setup := goalSetSetup{budgetClass: budgetClassForLegacyMode(goal, researchMode)}
2748 legacy := c.prepareLegacyResearchTask(goal)
2749 if !legacy.explicit {
2750 return goal, setup
2751 }
2752 setup.notice, setup.blockReason, setup.legacyTaskID, setup.explicit = legacy.notice, legacy.blockReason, legacy.taskID, legacy.explicit
2753 if legacy.blockReason != "" {
2754 return goal, setup
2755 }
2756 setup.budgetClass = budgetClassResearch
2757 return legacy.goal, setup
2758 }
2759
2760 // ResumeGoal re-enters a recoverable blocked/stopped Goal without resetting its
2761 // delivery evidence scope or accumulated usage statistics.
2762 func (c *Controller) ResumeGoal() bool {
2763 if c.sessionEngineEnabled() {
2764 current, err := c.goalLifecycleView()
2765 if err != nil || current == nil {
2766 return false
2767 }
2768 _, err = c.applyHostGoalMutation(context.Background(), "resume", func(machine *goaldomain.Machine) (*goaldomain.View, error) {
2769 resumed, resumeErr := machine.Resume(current.Ref(), true)
2770 return &resumed, resumeErr
2771 })
2772 if err != nil {
2773 return false
2774 }
2775 if current.BlockedReason != nil && current.BlockedReason.Code == "resource-budget" && c.goalTokenBudget > 0 {
2776 c.goalResourceMu.Lock()
2777 c.goalTokenLimit += c.goalTokenBudget
2778 c.goalBudgetExtensions++
2779 c.goalResourceMu.Unlock()
2780 }
2781 c.kickGoalDriver()
2782 return true
2783 }
2784 if handled, resumed := c.retryBlockedLegacyGoal(); handled {
2785 return resumed
2786 }
2787 spentBudget := c.goals.runtimeView().StopCause == stopCauseBudgetSpend
2788 path, data, persist, resumed := c.goals.resume()
2789 if !resumed {
2790 return false
2791 }
2792 c.persistGoalState(path, data, persist)
2793 if c.executor != nil {
2794 if spentBudget {
2795 c.executor.ResetTaskBudget()
2796 }
2797 c.executor.RestoreDeliveryCheckpoint(c.goals.deliveryState())
2798 }
2799 return true
2800 }
2801
2802 // PauseGoal suspends a running Goal without losing its Delivery checkpoint or
2803 // runtime history; ResumeGoal restores it. Returns false when no
2804 // running Goal exists.
2805 func (c *Controller) PauseGoal() bool {
2806 if c.sessionEngineEnabled() {
2807 current, err := c.goalLifecycleView()
2808 if err != nil || current == nil || current.Phase != goaldomain.PhaseActive {
2809 return false
2810 }
2811 // Revoke automatic execution before persistence or cancellation can block.
2812 c.disarmGoalLifecycle("user-paused")
2813 _, err = c.applyHostGoalMutation(context.Background(), "pause", func(machine *goaldomain.Machine) (*goaldomain.View, error) {
2814 paused, pauseErr := machine.Pause(current.Ref())
2815 return &paused, pauseErr
2816 })
2817 if err != nil {
2818 return false
2819 }
2820 c.goalDriverMu.Lock()
2821 activeGoalRound := c.goalDriverActive != nil
2822 c.goalDriverMu.Unlock()
2823 if activeGoalRound {
2824 c.Cancel()
2825 }
2826 c.notice(i18n.M.GoalPaused)
2827 return true
2828 }
2829 if !c.goals.active() {
2830 return false
2831 }
2832 path, data, ok := c.goals.pauseFor(stopCauseManual, i18n.M.GoalPausedReason)
2833 c.persistGoalState(path, data, ok)
2834 c.notice(i18n.M.GoalPaused)
2835 return true
2836 }
2837
2838 // GoalRuntime returns the active Goal's usage/runtime summary for frontends.
2839 func (c *Controller) GoalRuntime() GoalRuntimeView {
2840 if c.sessionEngineEnabled() {
2841 view, _ := c.goalLifecycleView()
2842 if view == nil {
2843 return GoalRuntimeView{}
2844 }
2845 limit := 0
2846 if view.MaxGoalRounds != nil {
2847 limit = int(*view.MaxGoalRounds)
2848 }
2849 c.goalResourceMu.Lock()
2850 used, requests, tokenLimit, extensions := c.goalTokensUsed, c.goalRequestsUsed, c.goalTokenLimit, c.goalBudgetExtensions
2851 c.goalResourceMu.Unlock()
2852 return GoalRuntimeView{TurnsUsed: int(view.RoundsStarted), TurnsLimit: limit, TokensUsed: used,
2853 RequestsUsed: requests, TokensLimit: tokenLimit, StopCause: view.StopReason, BudgetExtensions: extensions}
2854 }
2855 return c.goals.runtimeView()
2856 }
2857
2858 func (c *Controller) ClearGoal() {
2859 if c.sessionEngineEnabled() {
2860 c.disarmGoalLifecycle("cleared")
2861 _ = c.SetGoalDurable("")
2862 c.goalDriverMu.Lock()
2863 activeGoalRound := c.goalDriverActive != nil
2864 c.goalDriverMu.Unlock()
2865 if activeGoalRound {
2866 c.Cancel()
2867 }
2868 return
2869 }
2870 c.SetGoal("")
2871 }
2872
2873 func (c *Controller) Goal() string {
2874 if c.sessionEngineEnabled() {
2875 view, _ := c.goalLifecycleView()
2876 if view == nil {
2877 return ""
2878 }
2879 return view.Objective
2880 }
2881 return c.goals.goalText()
2882 }
2883
2884 func (c *Controller) GoalStatus() string {
2885 if c.sessionEngineEnabled() {
2886 view, err := c.goalLifecycleView()
2887 if err != nil || view == nil {
2888 return GoalStatusStopped
2889 }
2890 switch view.Phase {
2891 case goaldomain.PhaseComplete:
2892 return GoalStatusComplete
2893 case goaldomain.PhaseBlocked:
2894 return GoalStatusBlocked
2895 case goaldomain.PhaseActive:
2896 if view.Activation == goaldomain.ActivationArmed {
2897 return GoalStatusRunning
2898 }
2899 }
2900 return GoalStatusStopped
2901 }
2902 return c.goals.statusForDisplay()
2903 }
2904
2905 // Compact runs one compaction pass on the executor's session on demand.
2906 // instructions is optional `/compact <focus>` guidance steering what to keep.
2907 func (c *Controller) Compact(ctx context.Context, instructions string) error {
2908 ctx = c.withAuthentication(ctx)
2909 if err := c.authentication.admissionError(); err != nil {
2910 return err
2911 }
2912 if c.executor == nil {
2913 return nil
2914 }
2915 // The rotation gate keeps a turn from starting while a manual compaction is
2916 // building and installing a new model-visible projection.
2917 if err := c.beginRotation(); err != nil {
2918 if errors.Is(err, errTurnRunningRotation) {
2919 return fmt.Errorf("cannot compact while a turn is running")
2920 }
2921 return err
2922 }
2923 defer c.endRotation()
2924 err := c.executor.CompactNow(ctx, instructions)
2925 c.authentication.recordFailure(err, c.ModelRef())
2926 return err
2927 }
2928
2929 // maybeSessionStart fires the SessionStart hook exactly once per session, lazily
2930 // on the first turn — by then the sink/notify is wired, and a resumed session
2931 // fires it too (its first post-resume turn).
2932 func (c *Controller) maybeSessionStart(ctx context.Context) {
2933 c.hooks.SetSessionID(c.parentSessionID())
2934 c.mu.Lock()
2935 if c.startedOnce {
2936 c.mu.Unlock()
2937 return
2938 }
2939 c.startedOnce = true
2940 c.mu.Unlock()
2941 c.enqueueHookContexts(c.hooks.SessionStart(ctx))
2942 c.extensionSessionEvent(extension.PointSessionStart, dispatch.PhaseStart, c.SessionPath())
2943 }
2944
2945 // NewSession snapshots the current conversation, rotates to a fresh file, and
2946 // resets the executor to a clean session carrying the same base system prompt.
2947 // Session-owned pinned context intentionally starts empty. It ends the old
2948 // session and starts the new one for lifecycle hooks.
2949 func (c *Controller) NewSession() error {
2950 if c.executor == nil {
2951 return nil
2952 }
2953 // Claim the rotation gate for the whole snapshot-then-swap sequence. A bare
2954 // `if c.running` check released before Snapshot() left a window where a turn
2955 // could start during the snapshot and then have its live session replaced by
2956 // the SetSession below. Submit ("/new") and the bot gateway call this
2957 // asynchronously, so the gate is load-bearing, not defensive.
2958 if err := c.beginRotation(); err != nil {
2959 return err
2960 }
2961 defer c.endRotation()
2962 if c.sessionEngineEnabled() {
2963 return c.rotateExclusiveSession(false)
2964 }
2965 // Retire asynchronous recovery writes before Snapshot publishes the final
2966 // old-session checkpoint. Otherwise an earlier write can outlive the path
2967 // rotation (or process teardown) and race cleanup of the old session.
2968 oldPath := c.SessionPath()
2969 c.flushRecoveryPersistence(oldPath)
2970 if err := c.Snapshot(); err != nil {
2971 return err
2972 }
2973 // session.rotate: the session_policy owner rules on the rotation before
2974 // anything is torn down, so its failure (required-class) aborts the
2975 // rotation cleanly. SessionPath is the file being rotated away from; the
2976 // fresh path arrives with the session.start event below.
2977 if err := c.extensionSessionPhase(context.Background(), extension.PointSessionRotate, dispatch.PhaseRotate, oldPath); err != nil {
2978 return err
2979 }
2980 c.hooks.SessionEnd(context.Background(), "clear")
2981 c.extensionSessionEvent(extension.PointSessionEnd, dispatch.PhaseEnd, oldPath)
2982 freshPath := oldPath
2983 if c.sessionDir != "" {
2984 freshPath = agent.NewSessionPath(c.sessionDir, c.label)
2985 }
2986 freshSession := agent.NewSession(c.basePrompt())
2987 commitTransition, err := c.prepareSessionTransition(freshPath, "new", freshSession)
2988 if err != nil {
2989 return fmt.Errorf("bind new session: %w", err)
2990 }
2991 // Hold snapshotMu across the swap so an in-flight save cannot pair the old
2992 // path with the fresh session (or the fresh path with the old session).
2993 c.snapshotMu.Lock()
2994 commitTransition.publish()
2995 c.bindExecutorProjection(c.SessionPath(), false)
2996 if c.guardianSess != nil {
2997 c.guardianSess.Reset()
2998 }
2999 c.ResetPlannerSession()
3000 c.rebindCheckpoints(freshPath)
3001 seedErr := c.seedSessionEventsFromExecutor("session-new")
3002 c.resetRecoveryForNewSession(freshPath)
3003 c.rotateSessionTemp()
3004 c.snapshotMu.Unlock()
3005 // Old session keeps its inbox (paused); the fresh session starts empty.
3006 c.pauseInboxOnRotate()
3007 c.rebindInbox()
3008 // A new session starts with no active goal: without this, a running goal's
3009 // text kept injecting into the fresh session's first turns. The old
3010 // session's goal-state sidecar was persisted before the rotation and stays
3011 // intact, so resuming it restores its goal; the cleared state below lands
3012 // on the NEW path (rebindCheckpoints just moved it).
3013 c.ClearGoal()
3014 c.mu.Lock()
3015 c.startedOnce = true // NewSession fires SessionStart itself; don't re-fire on the next turn
3016 c.mu.Unlock()
3017 c.hooks.SetSessionID(c.parentSessionID())
3018 c.enqueueHookContexts(c.hooks.SessionStart(context.Background(), "clear"))
3019 c.extensionSessionEvent(extension.PointSessionStart, dispatch.PhaseStart, c.SessionPath())
3020 c.clearSessionWriteAccess()
3021 if seedErr != nil {
3022 return fmt.Errorf("seed new session events: %w", seedErr)
3023 }
3024 return nil
3025 }
3026
3027 func (c *Controller) hasUnfinishedSessionJobs(sessionPath string) bool {
3028 if c.jobs == nil {
3029 return false
3030 }
3031 return c.jobs.HasUnfinishedForSession(agent.BranchID(sessionPath))
3032 }
3033
3034 func removeSessionArtifacts(path string) error {
3035 if path == "" {
3036 return nil
3037 }
3038 if err := jobs.RemoveArtifacts(path); err != nil {
3039 return err
3040 }
3041 remove := []string{path}
3042 // Sidecars include the event log — the authoritative transcript. Leaving
3043 // it behind would both leak the cleared conversation and let LoadSession
3044 // resurrect it on the recycled path. The guardian transcript saves through
3045 // the same session layer, so its sidecars are swept too.
3046 remove = append(remove, store.SessionSidecarFiles(path)...)
3047 remove = append(remove, guardian.PathFor(path), guardian.CursorPathFor(path))
3048 remove = append(remove, store.SessionSidecarFiles(guardian.PathFor(path))...)
3049 for _, p := range remove {
3050 if p == "" {
3051 continue
3052 }
3053 if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
3054 return err
3055 }
3056 }
3057 if err := sessioninbox.RemoveDir(path); err != nil && !os.IsNotExist(err) {
3058 return err
3059 }
3060 if dir := ckptDir(path); dir != "" {
3061 if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) {
3062 return err
3063 }
3064 }
3065 if err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {
3066 return err
3067 }
3068 if err := agent.ClearCleanupPending(path); err != nil {
3069 return err
3070 }
3071 return nil
3072 }
3073
3074 // RemoveSessionArtifacts removes a transcript and every durable artifact owned
3075 // by it. Remote runtimes use this when a newly-created fork fails before it can
3076 // be registered as a live session.
3077 func RemoveSessionArtifacts(path string) error {
3078 return removeSessionArtifacts(path)
3079 }
3080
3081 // ReconcileCleanupPending retries physical cleanup for logically removed
3082 // sessions that were left behind by a previous process.
3083 func ReconcileCleanupPending(dir string) error {
3084 return agent.ReconcileCleanupPending(dir, func(item agent.CleanupPendingInfo) error {
3085 return removeSessionArtifacts(item.SessionPath)
3086 })
3087 }
3088
3089 // RewindScope selects what a Rewind restores.
3090 type RewindScope int
3091
3092 const (
3093 RewindCode RewindScope = iota // files only
3094 RewindConversation // message log only
3095 RewindBoth // both
3096 )
3097
3098 // Checkpoints lists the session's rewind points (one per user turn), oldest first.
3099 //
3100 // Each Meta.Prompt is reduced to what the user typed. A checkpoint opens with
3101 // the composed turn, so the stored prompt can carry the plan-mode marker and
3102 // transient blocks; every consumer of this list is a label (the rewind picker,
3103 // the desktop change list, the workbench projection) and the picker also
3104 // restores the prompt into the composer, so composed text must not reach them.
3105 // Stripping on read rather than only on write keeps checkpoints already on disk
3106 // readable — they were recorded composed.
3107 func (c *Controller) Checkpoints() []checkpoint.Meta {
3108 metas := c.checkpoints.list()
3109 for i := range metas {
3110 metas[i].Prompt = StripComposePrefixes(metas[i].Prompt)
3111 }
3112 return metas
3113 }
3114
3115 func (c *Controller) CheckpointFileState(path string) (checkpoint.FileState, bool) {
3116 return c.checkpoints.fileState(path)
3117 }
3118
3119 func (c *Controller) CheckpointTurnsByMessageIndex() map[int]int {
3120 return c.checkpoints.turnsByMessageIndex()
3121 }
3122
3123 // rewindFail emits the error as a Warn notice (so a frontend that swallows the
3124 // returned error — e.g. the desktop bridge's .catch — still shows the user why
3125 // the rewind did nothing) and returns it.
3126 func (c *Controller) rewindFail(err error) error {
3127 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: err.Error()})
3128 return err
3129 }
3130
3131 // Rewind is implemented in rewind.go (transactional conversation+file restore).
3132
3133 // SummarizeFrom and SummarizeUpTo preserve the historical turn-index API while
3134 // changing only the model-visible context projection. The canonical transcript
3135 // and checkpoint boundaries remain available for rewind, undo, and fork.
3136 func (c *Controller) SummarizeFrom(ctx context.Context, turn int) error {
3137 return c.summarizeAt(ctx, turn, true)
3138 }
3139
3140 func (c *Controller) SummarizeUpTo(ctx context.Context, turn int) error {
3141 return c.summarizeAt(ctx, turn, false)
3142 }
3143
3144 func (c *Controller) summarizeAt(ctx context.Context, turn int, from bool) error {
3145 ctx = c.withAuthentication(ctx)
3146 if err := c.authentication.admissionError(); err != nil {
3147 return err
3148 }
3149 if c.executor == nil {
3150 return c.rewindFail(fmt.Errorf("checkpoints unavailable"))
3151 }
3152 // Hold the rotation gate from the checkpoint-boundary lookup through
3153 // projection installation so a turn cannot start against an intermediate
3154 // context view.
3155 if err := c.beginRotation(); err != nil {
3156 if errors.Is(err, errTurnRunningRotation) {
3157 return c.rewindFail(fmt.Errorf("cannot summarize while a turn is running"))
3158 }
3159 return c.rewindFail(err)
3160 }
3161 defer c.endRotation()
3162 boundary, hasBound := c.checkpoints.boundary(turn)
3163 if !hasBound {
3164 return c.rewindFail(fmt.Errorf("summarize unavailable for turn %d (resumed session)", turn))
3165 }
3166 var err error
3167 if from {
3168 err = c.executor.SummarizeFrom(ctx, boundary)
3169 } else {
3170 err = c.executor.SummarizeUpTo(ctx, boundary)
3171 }
3172 c.authentication.recordFailure(err, c.ModelRef())
3173 if err != nil {
3174 return c.rewindFail(err)
3175 }
3176 return nil
3177 }
3178
3179 // Resume seeds the session from a loaded transcript and pins the active file to
3180 // its path so auto-save keeps appending there.
3181 //
3182 // When the controller already has a different non-empty session path, Resume
3183 // rotates the private temporary generation so the loaded conversation cannot
3184 // see the previous session's temporary files. Same-path Resume (hot rebuild
3185 // migration via AdoptHistory) keeps the generation.
3186 func (c *Controller) Resume(s *agent.Session, path string) {
3187 if c.sessionEngineEnabled() {
3188 if _, runtime, _ := c.v3Binding(); runtime != nil && strings.TrimSpace(path) == "" {
3189 c.restoreExecutorFromSessionEvents()
3190 return
3191 }
3192 if strings.TrimSpace(path) == "" {
3193 if _, err := c.BindFreshSession(context.Background(), ""); err != nil {
3194 c.failTurnEventLedger(err)
3195 }
3196 return
3197 }
3198 if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
3199 if _, err := c.BindFreshSession(context.Background(), ""); err != nil {
3200 c.failTurnEventLedger(err)
3201 return
3202 }
3203 if s != nil && len(s.Snapshot()) > 0 {
3204 if err := c.replaceSessionEventProjection(context.Background(), "fresh-resume", s.Snapshot()); err != nil {
3205 c.failTurnEventLedger(err)
3206 } else {
3207 c.restoreExecutorFromSessionEvents()
3208 }
3209 }
3210 return
3211 }
3212 if _, err := c.ContinueLegacySession(context.Background(), path, ""); err != nil {
3213 slog.Warn("controller: migrate legacy resume into v3", "path", path, "err", err)
3214 c.failTurnEventLedger(err)
3215 }
3216 return
3217 }
3218 // See snapshotMu: the swap must not interleave with an in-flight save.
3219 // recoverInterruptedTurn and maybeColdResumePrune snapshot on their own,
3220 // so they stay outside the locked section (snapshotMu is not reentrant).
3221 prevPath := c.SessionPath()
3222 c.snapshotMu.Lock()
3223 if c.executor != nil {
3224 c.executor.SetSession(s)
3225 }
3226 c.mu.Lock()
3227 c.sessionPath = path
3228 c.guardianPath = guardian.PathFor(path)
3229 c.mu.Unlock()
3230 c.bindExecutorProjection(path, true)
3231 c.ResetPlannerSession()
3232 c.setActiveJobSession(path)
3233 c.rebindCheckpoints(path)
3234 if err := c.seedSessionEventsFromExecutor("legacy-resume"); err != nil {
3235 slog.Warn("controller: import resumed transcript into v3", "err", err)
3236 }
3237 if err := c.importLegacyResumeOverPlaceholder(s); err != nil {
3238 slog.Warn("controller: replace placeholder v3 history from legacy resume", "err", err)
3239 }
3240 if err := c.adoptResumeSystemPrompt(s); err != nil {
3241 slog.Warn("controller: record refreshed system prompt in v3", "err", err)
3242 }
3243 // A host-managed replacement is deliberately unbound until its final lease
3244 // handoff. Keep the exact carried transcript on that private candidate; the
3245 // successful BindSessionWriteAuthority call publishes it to the shared v3
3246 // projection. Restoring the currently-active projection here would erase the
3247 // carried tail before the candidate had any chance to become authoritative.
3248 if c.sessionEventCommitAllowed() {
3249 c.restoreExecutorFromSessionEvents()
3250 }
3251 migPath, migData, migrated, legacy := c.goals.restoreFromState(path)
3252 if !c.restorePendingLegacyGoal(legacy) && migrated {
3253 c.persistGoalState(migPath, migData, true)
3254 }
3255 if c.executor != nil {
3256 c.executor.RestoreDeliveryCheckpoint(c.goals.deliveryState())
3257 }
3258 c.loadGuardianSession()
3259 c.loadRecoveryState(path)
3260 if shouldRotateSessionTempOnResume(prevPath, path) {
3261 c.rotateSessionTemp()
3262 }
3263 c.snapshotMu.Unlock()
3264 c.rebindInbox()
3265 c.recoverCheckpointTransactions()
3266 c.recoverInterruptedTurn(path)
3267 c.maybeColdResumePrune(path)
3268 // session.load: Resume has no failure channel, so the session_policy
3269 // strategy is advisory this stage — a required-class failure is surfaced
3270 // as a warning and the load stands. The event still carries the final
3271 // (possibly owner-adjusted) phase payload.
3272 if err := c.extensionSessionPhase(context.Background(), extension.PointSessionLoad, dispatch.PhaseLoad, path); err != nil {
3273 c.extensionWarn("session policy failed at session.load", err)
3274 }
3275 }
3276
3277 func shouldRotateSessionTempOnResume(prevPath, nextPath string) bool {
3278 prevPath = strings.TrimSpace(prevPath)
3279 nextPath = strings.TrimSpace(nextPath)
3280 if prevPath == "" || nextPath == "" {
3281 return false
3282 }
3283 return filepath.Clean(prevPath) != filepath.Clean(nextPath)
3284 }
3285
3286 func (c *Controller) loadGuardianSession() {
3287 if c.guardianSess == nil {
3288 return
3289 }
3290 c.guardianSess.Reset()
3291 path := c.guardianPath
3292 if path == "" {
3293 return
3294 }
3295 if err := c.guardianSess.Load(path); err != nil && !os.IsNotExist(err) {
3296 slog.Warn("controller: load guardian session", "err", err)
3297 }
3298 }
3299
3300 // ResetPlannerSession clears the planner's conversation history so the next
3301 // plan starts fresh. In dual-model (Plan+Execute) mode, this prevents stale
3302 // planner output from a previous session or tab from contaminating the current
3303 // executor's handoff. Safe to call on a single-model controller (no-op).
3304 func (c *Controller) ResetPlannerSession() {
3305 runner, ok := c.runner.(plannerSessionResetter)
3306 if ok {
3307 runner.ResetPlannerSession()
3308 }
3309 }
3310
3311 // cacheColdAfter resolves how long the active provider keeps a prompt prefix
3312 // cached. A session idle longer than this resumes against a cold cache, so a
3313 // history rewrite at that moment costs no extra cache misses — it only shrinks
3314 // the full-price first request. The TTL is vendor-aware: DeepSeek/unknown
3315 // 24h (legacy default deliberately preserved), DashScope 5m, Anthropic 5m.
3316 // Users can override per-provider
3317 // with cache_ttl_minutes in config.toml.
3318 func (c *Controller) cacheColdAfter() time.Duration {
3319 if c.testCacheColdAfter != 0 {
3320 if c.testCacheColdAfter == -1 {
3321 return 0
3322 }
3323 return c.testCacheColdAfter
3324 }
3325 // 查询路径只读:LoadForRootReadOnly 不触发配置迁移写盘(评审 #7168
3326 // 第 4 点);失败时保守回退 24h(DeepSeek/未知 vendor 默认),避免
3327 // 把 cache TTL 过期误当成历史改写信号(resume 只记录 warm/cold/unknown)。
3328 cfg, err := config.LoadForRootReadOnly(c.workspaceRoot)
3329 if err != nil {
3330 return 24 * time.Hour
3331 }
3332 ref := c.selection.ref
3333 if ref == "" {
3334 ref = cfg.DefaultModel
3335 }
3336 entry, ok := cfg.ResolveModel(ref)
3337 if !ok {
3338 return 24 * time.Hour
3339 }
3340 return entry.EffectiveCacheTTL()
3341 }
3342
3343 // Snapshot writes the executor's conversation to the active session file. No-op
3344 // when the executor is absent or the session has never been used (no user
3345 // interaction). Returns errNoSessionPath when there IS content but no resolved
3346 // path, so a misconfigured deployment surfaces instead of dropping data.
3347 // Called after every turn so a crash loses at most one in-flight prompt.
3348 func (c *Controller) Snapshot() error {
3349 return c.snapshot(false, false, false)
3350 }
3351
3352 // SnapshotForShutdown performs the final session snapshot. Only when the
3353 // compatibility file lock stays held for the full bounded wait does it fall
3354 // back: a schema-2 log takes the unsaved tail without the lock, a schema-1
3355 // session gets a distinct recovery branch. Other snapshot errors retain their
3356 // normal behavior and remain visible to the caller.
3357 func (c *Controller) SnapshotForShutdown() error {
3358 return c.snapshot(false, false, true)
3359 }
3360
3361 // SnapshotActivity writes the active conversation and marks the session as
3362 // recently active. Use it only after a real user/model turn changes the
3363 // transcript; switch/close snapshots should call Snapshot so they do not reorder
3364 // recent-session pickers.
3365 func (c *Controller) SnapshotActivity() error {
3366 return c.snapshot(true, false, false)
3367 }
3368
3369 // SnapshotRewrite persists an intentional history rewrite, such as rewind or
3370 // manual compaction. Ordinary autosave paths should use Snapshot so stale
3371 // controllers cannot overwrite a newer transcript.
3372 func (c *Controller) SnapshotRewrite() error {
3373 return c.snapshot(false, true, false)
3374 }
3375
3376 func (c *Controller) snapshot(markActivity, forceRewrite, shutdownRecovery bool) error {
3377 _, err := c.snapshotWithDurability(markActivity, forceRewrite, shutdownRecovery)
3378 return err
3379 }
3380
3381 // snapshotWithDurability reports whether the canonical transcript reached disk
3382 // even when a later sidecar update failed. Callers that guard a crash marker
3383 // need this distinction: a metadata error must not make a complete transcript
3384 // look like an in-memory-only turn.
3385 func (c *Controller) snapshotWithDurability(markActivity, forceRewrite, shutdownRecovery bool) (bool, error) {
3386 c.snapshotMu.Lock()
3387 defer c.snapshotMu.Unlock()
3388
3389 c.mu.Lock()
3390 path := c.sessionPath
3391 modelRef := c.selection.ref
3392 c.mu.Unlock()
3393 if c.executor == nil {
3394 return false, nil
3395 }
3396 s := c.executor.Session()
3397 if !s.HasContent() {
3398 // Nothing to persist yet (e.g. a fresh session with only a system
3399 // prompt) — staying quiet here is correct, not a data-loss path.
3400 return false, nil
3401 }
3402 if !s.HasSystemMessage() {
3403 // The session has user/assistant/tool messages but no leading system
3404 // prompt. Persisting it would create a session file that, when
3405 // reloaded, has no agent-identity contract — the model falls back to
3406 // its training-data defaults, giving wrong answers to identity
3407 // queries ("who are you?"). Log the anomaly so the root cause
3408 // (typically an empty sysPrompt reaching NewSession) can be
3409 // diagnosed, then refuse to write a corrupted transcript.
3410 slog.Warn("controller: refusing to snapshot session with content but no system message",
3411 "label", c.Label(), "session_dir", c.SessionDir(), "message_count", len(s.Snapshot()))
3412 return false, nil
3413 }
3414 if c.sessionEngineEnabled() {
3415 if _, runtime, _ := c.v3Binding(); runtime == nil || c.sessionEventStore() == nil {
3416 return false, errors.New("exclusive v3 controller has no bound session runtime")
3417 }
3418 // Export/switch/shutdown callers ask for durability explicitly. The
3419 // transcript, Goal, Plan, Todo and runtime facts already live in the one
3420 // event sequence; no legacy transcript or business sidecar is refreshed.
3421 if _, err := c.flushSessionEvents(context.Background()); err != nil {
3422 return false, err
3423 }
3424 return true, nil
3425 }
3426 if path == "" {
3427 // There IS content but nowhere to write it: this silently dropped whole
3428 // bot conversations (#4414). Surface it loudly instead of returning nil
3429 // so the missing session path can be diagnosed and fixed at the source.
3430 slog.Warn("controller: session has content but no session path; conversation will not be persisted",
3431 "label", c.Label(), "session_dir", c.SessionDir())
3432 return false, errNoSessionPath
3433 }
3434 // Snapshot is an explicit persistence request (session switch, export or
3435 // shutdown), so it is also a v3 semantic checkpoint. Live events normally
3436 // remain eligible for the 200 ms write-behind window; callers asking for a
3437 // snapshot must receive a durable receipt before the rebuildable legacy
3438 // transcript cache is refreshed.
3439 if _, err := c.flushSessionEvents(context.Background()); err != nil {
3440 return false, err
3441 }
3442 // session.save: the session_policy owner rules on the impending save; a
3443 // failure (required-class) vetoes the write. The event goes out after a
3444 // successful save carrying the final payload. The early no-content and
3445 // no-path returns above are not saves and stay unobserved. Conflict
3446 // recovery below may rewrite the path; the phase payload reports the path
3447 // the save targeted.
3448 savePayload, strategyErr := c.extensionSessionStrategy(context.Background(), extension.PointSessionSave, dispatch.PhaseSave, path)
3449 if strategyErr != nil {
3450 return false, strategyErr
3451 }
3452 err, forceRewrite := persistSessionSnapshot(s, path, forceRewrite)
3453 if authoritySaveError(err) {
3454 // Missing/stale authority must not enter diverged/recovery. Frontends
3455 // rebind the lease or surface the typed error.
3456 return false, err
3457 }
3458 if err != nil {
3459 if shutdownRecovery && errors.Is(err, agent.ErrSessionFileLockHeld) {
3460 recoveredPath, recoverErr := c.recoverShutdownSave(s, path, err, forceRewrite)
3461 if recoverErr != nil {
3462 return false, recoverErr
3463 }
3464 path = recoveredPath
3465 s = c.executor.Session()
3466 err = nil
3467 }
3468 }
3469 if err != nil {
3470 if errors.Is(err, agent.ErrSessionExternallyRemoved) {
3471 recoveredPath, recoverErr := c.recoverExternallyRemovedSession(path, err)
3472 if recoverErr != nil {
3473 return false, recoverErr
3474 }
3475 path = recoveredPath
3476 s = c.executor.Session()
3477 err = nil
3478 }
3479 }
3480 if err != nil {
3481 if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
3482 return false, err
3483 }
3484 recoveredPath, outcome, recoverErr := c.recoverSnapshotConflict(path, err, forceRewrite)
3485 if recoverErr != nil {
3486 if shutdownRecovery && errors.Is(recoverErr, agent.ErrSessionFileLockHeld) {
3487 recoveredPath, recoverErr = c.recoverShutdownSave(s, path, recoverErr, forceRewrite)
3488 if recoverErr != nil {
3489 return false, recoverErr
3490 }
3491 path = recoveredPath
3492 s = c.executor.Session()
3493 } else {
3494 return false, recoverErr
3495 }
3496 } else {
3497 if outcome == conflictDropped {
3498 return false, nil
3499 }
3500 // Whatever recovery did — adopted the disk transcript, isolated the
3501 // depth-capped copy, or forked — the rewrite baseline lives on
3502 // the session object and was advanced by the save that succeeded, so
3503 // there is nothing to re-anchor here.
3504 path = recoveredPath
3505 s = c.executor.Session()
3506 }
3507 }
3508 // Persist guardian session so the prefix cache stays warm after restart.
3509 if gp := c.guardianPath; c.guardianSess != nil && gp != "" {
3510 if gerr := c.guardianSess.Save(gp); gerr != nil {
3511 slog.Warn("controller: guardian snapshot", "err", gerr)
3512 }
3513 }
3514 c.emitHeadEvents()
3515 transcriptDurable := true
3516 // Persist recovery gate state so unresolved checkpoints survive restart.
3517 c.saveRecoveryState(path)
3518 // Record the listing-only sidecar fields (model, preview, user-turn count)
3519 // straight from the in-memory conversation, so the sidebar and resume picker
3520 // never have to decode the whole .jsonl just to show them. markActivity bumps
3521 // UpdatedAt exactly like the previous TouchBranchMeta did; false preserves it
3522 // like SetBranchModelPreserveUpdated. The single write subsumes the old
3523 // EnsureBranchMeta / SetBranchModel / TouchBranchMeta sequence.
3524 preview, turns := agent.SessionPreviewFromMessages(s.Snapshot())
3525 if err := updateSessionModelProjection(s, path, modelRef, c.selection.identity, preview, turns, markActivity); err != nil && !listingDeferredAfterUnlockedAppend(s, path, err) {
3526 return transcriptDurable, err
3527 }
3528 c.extensionSessionPayloadEvent(extension.PointSessionSave, savePayload)
3529 return transcriptDurable, nil
3530 }
3531
3532 func updateSessionModelProjection(s *agent.Session, path, modelRef, identity, preview string, turns int, markActivity bool) error {
3533 persisted, ok := s.PersistedState(path)
3534 if !ok {
3535 return fmt.Errorf("session persistence baseline missing after save")
3536 }
3537 var err error
3538 if s.WriteAuthorityRequired() {
3539 _, err = agent.UpdateOwnedSessionListingProjectionIfCurrent(path, modelRef, identity, preview, turns, markActivity, persisted, s.WriteAuthority())
3540 } else {
3541 _, err = agent.UpdateSessionListingProjectionIfCurrent(path, modelRef, identity, preview, turns, markActivity, persisted)
3542 }
3543 return err
3544 }
3545
3546 func (c *Controller) recoverExternallyRemovedSession(path string, saveErr error) (string, error) {
3547 if c.executor == nil || strings.TrimSpace(path) == "" {
3548 return "", saveErr
3549 }
3550 const reason = "session removed while open"
3551 req := SessionRecoveryRequest{OriginalPath: path, Reason: reason, Mode: "external-removal"}
3552 meta := agent.BranchMeta{}
3553 if c.sessionRecoveryMeta != nil {
3554 meta = c.sessionRecoveryMeta(req)
3555 }
3556 info, err := c.executor.Session().SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{
3557 OriginalPath: path,
3558 Reason: reason,
3559 BranchMeta: meta,
3560 })
3561 if err != nil {
3562 return "", fmt.Errorf("preserve externally removed session: %w", err)
3563 }
3564 if err := c.commitRecoveredSession(path, reason, info); err != nil {
3565 return "", err
3566 }
3567 appendSnapshotConflictDiagnostic(path, "external-removal", "moved_to_stable_recovery", saveErr, info.Path, info.Existing)
3568 slog.Warn("controller: active session was removed externally; moved runtime to stable recovery path",
3569 "path", path, "recovery", info.Path, "existing", info.Existing)
3570 c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionRecoveryForked,
3571 "the open session file was removed outside Reasonix; your active conversation was preserved as one recovery copy"))
3572 return info.Path, nil
3573 }
3574
3575 // snapshotConflictLogAttrs flattens a snapshot-conflict error into slog attrs.
3576 // Field reports of #6069-class "session changed on disk" spam are only
3577 // diagnosable when the logs say which trigger fired and what the revision
3578 // ledger looked like, so every recoverSnapshotConflict outcome logs these.
3579 func snapshotConflictLogAttrs(saveErr error, path, mode string) []any {
3580 attrs := []any{"path", path, "mode", mode}
3581 var conflict *agent.SessionSnapshotConflictError
3582 if errors.As(saveErr, &conflict) && conflict != nil {
3583 attrs = append(attrs,
3584 "kind", string(conflict.Kind),
3585 "disk_messages", conflict.ExistingMessages,
3586 "snapshot_messages", conflict.SnapshotMessages,
3587 "base_revision", conflict.BaseRevision,
3588 "disk_revision", conflict.DiskRevision,
3589 )
3590 }
3591 return attrs
3592 }
3593
3594 // conflictOutcome is recoverSnapshotConflict's declared result. Callers act
3595 // on it directly instead of re-deriving what happened from path or session
3596 // pointer comparisons — the misclassification that broke the depth-cap
3597 // rewrite baseline (#6120) hid in exactly that inference.
3598 type conflictOutcome int
3599
3600 const (
3601 // conflictDropped: nothing was recovered and the disk transcript could
3602 // not be adopted; this snapshot was deliberately dropped.
3603 conflictDropped conflictOutcome = iota
3604 // conflictAdoptedDisk: the executor session object was replaced by the
3605 // newer disk transcript; adoptDiskSession already reset its baselines.
3606 conflictAdoptedDisk
3607 // conflictForkedBranch: the same in-memory session moved to a freshly
3608 // forked recovery branch path.
3609 conflictForkedBranch
3610 )
3611
3612 func sessionRecoveryNotice(code, text string) event.Event {
3613 return event.Event{
3614 Kind: event.Notice,
3615 Level: event.LevelWarn,
3616 Audience: event.NoticeAudienceOperator,
3617 Code: code,
3618 Text: text,
3619 }
3620 }
3621
3622 func (c *Controller) recoverSnapshotConflict(path string, saveErr error, forceRewrite bool) (string, conflictOutcome, error) {
3623 if c.executor == nil || strings.TrimSpace(path) == "" {
3624 return "", conflictDropped, saveErr
3625 }
3626 mode := "snapshot"
3627 if forceRewrite {
3628 mode = "rewrite"
3629 }
3630 logAttrs := snapshotConflictLogAttrs(saveErr, path, mode)
3631 if kind, ok := agent.SnapshotConflictKind(saveErr); ok && kind == agent.SessionSnapshotConflictStalePrefix {
3632 if c.adoptDiskSession(path) {
3633 appendSnapshotConflictDiagnostic(path, mode, "adopted_newer_disk_transcript", saveErr, "", false)
3634 slog.Warn("controller: snapshot conflict; adopted newer disk transcript", logAttrs...)
3635 c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionRecoveryAdopted,
3636 "session changed on disk; adopted the newer transcript"))
3637 return path, conflictAdoptedDisk, nil
3638 }
3639 }
3640 reason := "snapshot conflict"
3641 if forceRewrite {
3642 reason = "rewrite conflict"
3643 }
3644 req := SessionRecoveryRequest{OriginalPath: path, Reason: reason, Mode: mode}
3645 meta := agent.BranchMeta{}
3646 if c.sessionRecoveryMeta != nil {
3647 meta = c.sessionRecoveryMeta(req)
3648 }
3649 baseRevision, diskRevision := snapshotConflictRevisions(saveErr)
3650 info, err := c.executor.Session().SaveRecoveryBranch(agent.RecoveryBranchOptions{
3651 OriginalPath: path,
3652 Reason: reason,
3653 BranchMeta: meta,
3654 BaseRevision: baseRevision,
3655 DiskRevision: diskRevision,
3656 })
3657 if err != nil {
3658 if errors.Is(err, agent.ErrSessionRecoveryNotNeeded) {
3659 if c.adoptDiskSession(path) {
3660 appendSnapshotConflictDiagnostic(path, mode, "recovery_not_needed_adopted_disk_transcript", saveErr, "", false)
3661 slog.Warn("controller: snapshot conflict; recovery not needed, adopted disk transcript", logAttrs...)
3662 c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionRecoveryAdoptedCovered,
3663 "session changed on disk; adopted the newer transcript (local changes already covered)"))
3664 return path, conflictAdoptedDisk, nil
3665 }
3666 // Nothing was recovered AND the disk transcript could not be
3667 // adopted: the snapshot is silently dropped. Leave a trace so
3668 // "my last turns vanished" reports can be tied to this path.
3669 appendSnapshotConflictDiagnostic(path, mode, "recovery_not_needed_adopt_failed", saveErr, "", false)
3670 slog.Warn("controller: snapshot conflict; recovery not needed but disk transcript could not be adopted", logAttrs...)
3671 return "", conflictDropped, nil
3672 }
3673 return "", conflictDropped, fmt.Errorf("recover stale session snapshot: %w", err)
3674 }
3675 if err := c.commitRecoveredSession(path, reason, info); err != nil {
3676 return "", conflictDropped, err
3677 }
3678 appendSnapshotConflictDiagnostic(path, mode, "forked_recovery_branch", saveErr, info.Path, info.Existing)
3679 slog.Warn("controller: snapshot conflict; forked recovery branch",
3680 append(logAttrs, "recovery", info.Path, "existing", info.Existing)...)
3681 c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionRecoveryForked,
3682 "session changed on disk; unsaved local transcript was saved as a conflict copy"))
3683 return info.Path, conflictForkedBranch, nil
3684 }
3685
3686 func (c *Controller) recoverShutdownSnapshot(path string, saveErr error) (string, error) {
3687 if c.executor == nil || strings.TrimSpace(path) == "" {
3688 return "", saveErr
3689 }
3690 const reason = "shutdown session file lock timeout"
3691 req := SessionRecoveryRequest{OriginalPath: path, Reason: reason, Mode: "shutdown"}
3692 meta := agent.BranchMeta{}
3693 if c.sessionRecoveryMeta != nil {
3694 meta = c.sessionRecoveryMeta(req)
3695 }
3696 info, err := c.executor.Session().SaveShutdownRecoveryBranch(agent.RecoveryBranchOptions{
3697 OriginalPath: path,
3698 Reason: reason,
3699 BranchMeta: meta,
3700 })
3701 if err != nil {
3702 return "", fmt.Errorf("save shutdown recovery branch: %w", err)
3703 }
3704 if err := c.commitRecoveredSession(path, reason, info); err != nil {
3705 return "", err
3706 }
3707 appendSnapshotConflictDiagnostic(path, "shutdown", "forked_file_lock_recovery", saveErr, info.Path, info.Existing)
3708 slog.Warn("controller: shutdown snapshot lock timed out; forked recovery branch",
3709 "path", path, "recovery", info.Path, "existing", info.Existing)
3710 c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionShutdownRecoveryForked,
3711 "session file stayed busy during shutdown; unsaved transcript was saved as a recovery copy"))
3712 return info.Path, nil
3713 }
3714
3715 func (c *Controller) commitRecoveredSession(originalPath, reason string, info agent.RecoveryBranchInfo) error {
3716 commit := &sessionRecoveryCommit{}
3717 recoveryInfo := SessionRecoveryInfo{
3718 OriginalPath: originalPath,
3719 RecoveryPath: info.Path,
3720 Existing: info.Existing,
3721 Reason: reason,
3722 BaseRevision: info.Meta.BaseRevision,
3723 DiskRevision: info.Meta.DiskRevision,
3724 Meta: info.Meta,
3725 commit: commit,
3726 }
3727 if onSessionRecovered := c.sessionRecoveredHandler(); onSessionRecovered != nil {
3728 if err := onSessionRecovered(recoveryInfo); err != nil {
3729 return fmt.Errorf("commit recovered session: %w", err)
3730 }
3731 }
3732 c.mu.Lock()
3733 c.sessionPath = info.Path
3734 c.guardianPath = guardian.PathFor(info.Path)
3735 c.mu.Unlock()
3736 // Recovery branch is a new lineage path. Load an inherited projection
3737 // sidecar when present so the model view stays compressed across the fork.
3738 c.bindExecutorProjection(info.Path, true)
3739 c.setActiveJobSession(info.Path)
3740 c.rebindCheckpoints(info.Path)
3741 c.transplantInFlightTurnMarker(originalPath, info.Path)
3742 commit.publish()
3743 return nil
3744 }
3745
3746 func (c *Controller) adoptDiskSession(path string) bool {
3747 loaded, err := agent.LoadSession(path)
3748 if err != nil || loaded == nil {
3749 return false
3750 }
3751 c.executor.SetSession(loaded)
3752 c.bindExecutorProjection(path, true)
3753 c.ResetPlannerSession()
3754 c.rebindCheckpoints(path)
3755 c.setActiveJobSession(path)
3756 return true
3757 }
3758
3759 func (c *Controller) messageCount() int {
3760 if c.executor == nil {
3761 return 0
3762 }
3763 return c.executor.Session().Len()
3764 }
3765
3766 // stripTurnMessagesAfter truncates the executor's session to keep only messages
3767 // before the given index, discarding an incomplete synthetic turn (the synthetic
3768 // user prompt plus every assistant/tool message that followed).
3769 func (c *Controller) stripTurnMessagesAfter(idx int) {
3770 if c.executor == nil {
3771 return
3772 }
3773 msgs := c.executor.Session().Snapshot()
3774 if len(msgs) <= idx {
3775 // Compaction may have removed the entire synthetic workset. The
3776 // explicit turn identities still need retraction from display history.
3777 c.replaceSessionAfterCancel(msgs)
3778 return
3779 }
3780 c.replaceSessionAfterCancel(msgs[:idx])
3781 }
3782
3783 // stripInterruptedSyntheticTurnMessagesAfter relocates a synthetic turn after
3784 // an in-turn compaction has rewritten the pre-turn message index, then drops
3785 // that whole controller-created turn.
3786 func (c *Controller) stripInterruptedSyntheticTurnMessagesAfter(idx int) {
3787 if c.executor == nil {
3788 return
3789 }
3790 msgs := c.executor.Session().Snapshot()
3791 startedAt := c.inFlightTurnStartedAt()
3792 if start, ok := resolveInterruptedTurnStart(msgs, idx, false, startedAt, provider.Message{}); ok {
3793 idx = start
3794 }
3795 c.stripTurnMessagesAfter(idx)
3796 }
3797
3798 // stripCancelledVisibleTurnMessagesAfterWithFallback preserves the real user
3799 // prompt and fully paired tool rounds from a cancelled visible turn. Unsafe
3800 // assistant/tool fragments are retained as provider-excluded display history.
3801 // It also covers coordinator
3802 // cancellation before the executor has appended the visible user message. The
3803 // orchestrator owns that input, so it supplies the exact message rather than
3804 // letting cancellation infer the current turn from older transcript history.
3805 func (c *Controller) stripCancelledVisibleTurnMessagesAfterWithFallback(idx int, fallback provider.Message) {
3806 c.stripCancelledVisibleTurnMessagesAfterWithFallbackAt(idx, fallback, c.inFlightTurnStartedAt())
3807 }
3808
3809 func (c *Controller) stripCancelledVisibleTurnMessagesAfterWithFallbackAt(idx int, fallback provider.Message, startedAt time.Time) {
3810 if c.executor == nil {
3811 return
3812 }
3813 before := c.executor.Session().Snapshot()
3814 next := planCancelledMessages(before, idx, fallback, startedAt, c.executor.CanReplayAssistantMessage, c.ledgerTailEvidence())
3815 if next != nil {
3816 c.replaceSessionAfterCancelFrom(before, next)
3817 }
3818 }
3819
3820 func (c *Controller) inFlightTurnStartedAt() time.Time {
3821 path := c.SessionPath()
3822 if path == "" {
3823 return time.Time{}
3824 }
3825 meta, ok, err := agent.LoadBranchMeta(path)
3826 if err != nil || !ok || meta.InFlightTurn == nil {
3827 return time.Time{}
3828 }
3829 return meta.InFlightTurn.StartedAt
3830 }
3831
3832 // resolveInterruptedTurnStart turns the pre-run array index into a stable
3833 // boundary after compaction. New user messages carry a creation timestamp set
3834 // after the marker, and graceful cleanup also has the exact composed prompt as
3835 // a fallback. We only fall back to the legacy index when it still points at a
3836 // plausible turn-start user message, keeping recovery data-safe for older
3837 // sidecars without timestamps.
3838 func resolveInterruptedTurnStart(msgs []provider.Message, idx int, preserveUser bool, startedAt time.Time, fallback provider.Message) (int, bool) {
3839 fallbackContent := ""
3840 if agent.IsUserAuthoredTurnMessage(fallback) {
3841 fallbackContent = StripComposePrefixes(fallback.Content)
3842 }
3843 matchesKind := func(m provider.Message) bool {
3844 if m.Role != provider.RoleUser || agent.IsPinnedContextRevision(m) {
3845 return false
3846 }
3847 if preserveUser {
3848 if !agent.IsUserAuthoredTurnMessage(m) {
3849 return false
3850 }
3851 if fallbackContent != "" && StripComposePrefixes(m.Content) != fallbackContent {
3852 return false
3853 }
3854 }
3855 return true
3856 }
3857 startedMillis := startedAt.UnixMilli()
3858 if !startedAt.IsZero() {
3859 for i, m := range msgs {
3860 if matchesKind(m) && m.CreatedAt >= startedMillis {
3861 return i, true
3862 }
3863 }
3864 }
3865 // Tests/headless runners may not persist an in-flight sidecar. The exact
3866 // graceful fallback still distinguishes the current visible turn; search
3867 // backward so a repeated prompt selects the newest occurrence.
3868 if fallbackContent != "" {
3869 for i, msg := range slices.Backward(msgs) {
3870 if matchesKind(msg) {
3871 return i, true
3872 }
3873 }
3874 }
3875 if idx >= 0 && idx < len(msgs) && matchesKind(msgs[idx]) {
3876 return idx, true
3877 }
3878 return 0, false
3879 }
3880
3881 func (c *Controller) hasInterruptedDisplayAfter(idx int, fallback provider.Message) bool {
3882 if c.executor == nil {
3883 return false
3884 }
3885 msgs := c.executor.Session().Snapshot()
3886 if start, ok := resolveInterruptedTurnStart(msgs, idx, true, c.inFlightTurnStartedAt(), fallback); ok {
3887 idx = start
3888 }
3889 idx = max(0, min(idx, len(msgs)))
3890 for _, m := range msgs[idx:] {
3891 if m.LocalOnly && m.InterruptedTurn != nil {
3892 return true
3893 }
3894 }
3895 return false
3896 }
3897
3898 func completeToolTurnEnd(msgs []provider.Message, i int) (int, bool) {
3899 if i < 0 || i >= len(msgs) {
3900 return i, false
3901 }
3902 m := msgs[i]
3903 if m.LocalOnly || m.Role != provider.RoleAssistant || len(m.ToolCalls) == 0 {
3904 return i, false
3905 }
3906 end := i + 1
3907 for end < len(msgs) && msgs[end].Role == provider.RoleTool && !msgs[end].LocalOnly {
3908 end++
3909 }
3910 results := msgs[i+1 : end]
3911 if len(results) != len(m.ToolCalls) {
3912 return i, false
3913 }
3914 for k, call := range m.ToolCalls {
3915 if strings.TrimSpace(call.Name) == "" || (call.Arguments != "" && !json.Valid([]byte(call.Arguments))) {
3916 return i, false
3917 }
3918 if results[k].ToolCallID != call.ID || results[k].Name != call.Name {
3919 return i, false
3920 }
3921 }
3922 return end, true
3923 }
3924
3925 func interruptedToolSummary(call provider.ToolCall) provider.InterruptedToolSummary {
3926 summary := provider.InterruptedToolSummary{
3927 ID: call.ID, Name: strings.TrimSpace(call.Name), Added: call.Added, Removed: call.Removed,
3928 }
3929 addFile := func(path string) {
3930 path = strings.TrimSpace(path)
3931 if path == "" || path == "/dev/null" || len(summary.Files) >= 8 {
3932 return
3933 }
3934 if slices.Contains(summary.Files, path) {
3935 return
3936 }
3937 summary.Files = append(summary.Files, path)
3938 }
3939 var args map[string]any
3940 if json.Unmarshal([]byte(call.Arguments), &args) == nil {
3941 for _, key := range []string{"path", "file", "file_path", "filename"} {
3942 if value, ok := args[key].(string); ok && strings.TrimSpace(value) != "" {
3943 addFile(value)
3944 }
3945 }
3946 }
3947 for line := range strings.SplitSeq(call.Diff, "\n") {
3948 line = strings.TrimSpace(line)
3949 switch {
3950 case strings.HasPrefix(line, "+++ b/"):
3951 addFile(strings.TrimPrefix(line, "+++ b/"))
3952 case strings.HasPrefix(line, "--- a/"):
3953 addFile(strings.TrimPrefix(line, "--- a/"))
3954 case strings.HasPrefix(line, "*** Update File: "):
3955 addFile(strings.TrimPrefix(line, "*** Update File: "))
3956 case strings.HasPrefix(line, "*** Add File: "):
3957 addFile(strings.TrimPrefix(line, "*** Add File: "))
3958 case strings.HasPrefix(line, "*** Delete File: "):
3959 addFile(strings.TrimPrefix(line, "*** Delete File: "))
3960 }
3961 }
3962 return summary
3963 }
3964
3965 func (c *Controller) replaceSessionAfterCancel(msgs []provider.Message) {
3966 if c.executor == nil {
3967 return
3968 }
3969 c.replaceSessionAfterCancelFromScoped(c.executor.Session().Snapshot(), msgs, true)
3970 }
3971
3972 func (c *Controller) replaceLegacySessionAfterCancelLocked(msgs []provider.Message) {
3973 // The whole cleanup is a save/recovery handoff like snapshot's: hold
3974 // snapshotMu from the in-memory truncation onward. Truncating outside the
3975 // lock would let an in-flight save capture the shortened transcript, read
3976 // the longer partial autosave on disk as a stale-prefix conflict, and
3977 // adopt it back into the executor — silently undoing the cancel cleanup
3978 // before the flush below could persist it.
3979 c.executor.Session().Replace(append([]provider.Message(nil), msgs...))
3980 // The mid-turn autosave may have already written a partial transcript to
3981 // disk. snapshotActivityIfChanged skips the write when messageCount()
3982 // returns to startMessages, so flush the cleaned transcript here. SaveRewrite
3983 // still checks that this controller owns the current on-disk baseline before
3984 // overwriting it, and also covers the edge case where the strip leaves only a
3985 // system message (HasContent() == false). The path is read under the lock so
3986 // an in-flight recovery retarget cannot leave it stale.
3987 c.mu.Lock()
3988 path := c.sessionPath
3989 c.mu.Unlock()
3990 if path != "" {
3991 if err := c.executor.Session().SaveRewrite(path); err != nil {
3992 if errors.Is(err, agent.ErrSessionSnapshotConflict) {
3993 if _, outcome, recoverErr := c.recoverSnapshotConflict(path, err, true); recoverErr != nil {
3994 slog.Warn("controller: post-cancel transcript recovery", "err", recoverErr)
3995 } else if outcome == conflictDropped {
3996 slog.Warn("controller: post-cancel transcript dropped after conflict", "path", path)
3997 }
3998 } else {
3999 slog.Warn("controller: post-cancel transcript flush", "err", err)
4000 }
4001 }
4002 }
4003 }
4004
4005 func (c *Controller) snapshotActivityIfChanged(startMessages int) (bool, error) {
4006 if c.messageCount() <= startMessages {
4007 return true, nil
4008 }
4009 return c.snapshotWithDurability(true, false, false)
4010 }
4011
4012 // SetSessionPath rebinds auto-save without changing the current session
4013 // preference. Callers creating a genuinely fresh conversation should use
4014 // SetFreshSessionPath; callers resuming history should use Resume.
4015 func (c *Controller) SetSessionPath(p string) {
4016 if c.sessionEngineEnabled() {
4017 // Path-based execution rebinding is intentionally unavailable. Hosts use
4018 // ContinueLegacySession for imports or publish an exact SessionRuntime.
4019 slog.Warn("controller: ignored legacy path rebind for exclusive v3 session", "path", p)
4020 return
4021 }
4022 c.setSessionPath(p, false)
4023 }
4024
4025 // SetFreshSessionPath binds a path that is known to belong to a newly-created
4026 // session and samples the configured new-session recovery default.
4027 func (c *Controller) SetFreshSessionPath(p string) {
4028 if service, _, exclusive := c.v3Binding(); exclusive && service != nil {
4029 if _, err := c.BindFreshSession(context.Background(), ""); err != nil {
4030 c.failTurnEventLedger(err)
4031 }
4032 return
4033 }
4034 c.setSessionPath(p, true)
4035 }
4036
4037 func (c *Controller) setSessionPath(p string, fresh bool) {
4038 defer c.refreshRuntimeState(event.Event{})
4039 // See snapshotMu: the swap must not interleave with an in-flight save.
4040 c.snapshotMu.Lock()
4041 c.mu.Lock()
4042 c.sessionPath = p
4043 c.guardianPath = guardian.PathFor(p)
4044 c.mu.Unlock()
4045 // Fresh paths clear projection; rebinds keep/load the target sidecar.
4046 c.bindExecutorProjection(p, !fresh)
4047 c.setActiveJobSession(p)
4048 c.rebindCheckpoints(p)
4049 // A path binding is the ownership boundary for the typed session log. Seed
4050 // the exact current transcript before any subsequent runtime or UI event can
4051 // make an otherwise empty v3 projection look authoritative. This covers the
4052 // initial EnsureSessionPath path as well as compatibility callers that bind
4053 // an already-loaded transcript without going through Resume.
4054 if err := c.seedSessionEventsFromExecutor("session-path-bind"); err != nil {
4055 slog.Warn("controller: seed bound session events", "path", p, "err", err)
4056 c.failTurnEventLedger(err)
4057 }
4058 if fresh {
4059 c.resetRecoveryForNewSession(p)
4060 // A newly-created conversation must not share the previous logical
4061 // session's temporary files (e.g. after EnsureSessionPath on a
4062 // controller that already ran commands).
4063 c.rotateSessionTemp()
4064 } else {
4065 c.loadRecoveryState(p)
4066 }
4067 c.snapshotMu.Unlock()
4068 c.rebindInbox()
4069 if !fresh {
4070 c.recoverCheckpointTransactions()
4071 }
4072 }
4073
4074 func (c *Controller) setActiveJobSession(sessionPath string) {
4075 if c.jobs != nil {
4076 c.jobs.SetActiveSessionPath(agent.BranchID(sessionPath), sessionPath)
4077 }
4078 }
4079
4080 // SessionDir reports the directory new session files land in ("" disables
4081 // persistence), so the caller can decide whether to mint a path.
4082 func (c *Controller) SessionDir() string { return c.sessionDir }
4083
4084 // SessionPath reports the file the current conversation auto-saves to ("" when
4085 // persistence is disabled), so a history view can mark the active session.
4086 func (c *Controller) SessionPath() string {
4087 c.mu.Lock()
4088 defer c.mu.Unlock()
4089 return c.sessionPath
4090 }
4091
4092 // SessionRef returns the immutable v3 execution identity. The legacy path is
4093 // intentionally absent from this contract and may only remain as an import or
4094 // display locator while hosts complete their catalog transition.
4095 func (c *Controller) SessionRef() (session.SessionRef, bool) {
4096 _, runtime, _ := c.v3Binding()
4097 if runtime == nil {
4098 return session.SessionRef{}, false
4099 }
4100 return runtime.Ref(), true
4101 }
4102
4103 func (c *Controller) parentSessionID() string {
4104 if ref, ok := c.SessionRef(); ok {
4105 return ref.SessionID
4106 }
4107 return agent.BranchID(c.SessionPath())
4108 }
4109
4110 // History returns the executor's current message log (for repopulating a
4111 // resumed frontend's view).
4112 func (c *Controller) History() []provider.Message {
4113 if c.executor == nil {
4114 return nil
4115 }
4116 if snapshot, ok := c.sessionEventSnapshot(); ok && snapshot.EventSequence > 0 {
4117 if c.sessionEngineEnabled() {
4118 // Controller.History is the provider workset compatibility API used by
4119 // rebuilds and model switches. Durable UI history is deliberately
4120 // separate and flows through SessionService.Query/TranscriptReplay.
4121 return append([]provider.Message(nil), snapshot.Projection.ModelMessages...)
4122 }
4123 projected := snapshot.Projection.ModelMessages
4124 if store := c.sessionEventStore(); store != nil {
4125 // The retired path-bound compatibility store still owns its complete
4126 // persisted transcript. Its provider projection intentionally strips
4127 // host-origin metadata, so explicit history reads use the stored view.
4128 projected = store.Snapshot().Projection.Messages
4129 }
4130 current := c.executor.Session().Snapshot()
4131 // Compatibility runners and an interrupted pre-v3 caller can still leave
4132 // a live, unsaved message tail in the legacy Session cache. Keep that tail
4133 // visible without inferring or persisting business state from it. Normal
4134 // agent execution records exact messages first, so this branch disappears
4135 // as the remaining compatibility callers are retired.
4136 if len(current) > len(projected) && (len(projected) == 0 || reflect.DeepEqual(current[:len(projected)], projected)) {
4137 return current
4138 }
4139 return append([]provider.Message(nil), projected...)
4140 }
4141 return c.executor.Session().Snapshot() // copy — a turn may be appending concurrently
4142 }
4143
4144 // SessionHasUnsavedChanges tells desktop history whether it may safely refresh
4145 // an idle view from the durable WAL. A failed or contended save can leave the
4146 // controller with a newer in-memory transcript; replacing that view from disk
4147 // would hide the user's latest turn until the next retry.
4148 func (c *Controller) SessionHasUnsavedChanges() bool {
4149 if c == nil || c.executor == nil {
4150 return false
4151 }
4152 if snapshot, ok := c.sessionEventSnapshot(); ok && snapshot.EventSequence > 0 {
4153 // The v3 projection is the authoritative transcript. A mismatch identifies
4154 // obsolete compatibility code that bypassed the explicit event recorder;
4155 // checkpoints deliberately do not infer or mirror that unrecorded tail.
4156 current := c.executor.Session().Snapshot()
4157 return snapshot.EventSequence > snapshot.DurableSequence ||
4158 snapshot.PersistenceStatus == "failed" ||
4159 snapshot.PersistenceStatus == "uncertain" ||
4160 !reflect.DeepEqual(snapshot.Projection.ModelMessages, current)
4161 }
4162 return c.executor.Session().HasUnsavedChanges(c.SessionPath())
4163 }
4164
4165 // HistoryLen returns the number of messages in the live log.
4166 func (c *Controller) HistoryLen() int {
4167 if c.executor == nil {
4168 return 0
4169 }
4170 return c.executor.Session().Len()
4171 }
4172
4173 // HistoryWindow returns a copy of the messages in [start, end) of the live
4174 // log. Paging frontends use it to convert a display window without copying
4175 // the whole history.
4176 func (c *Controller) HistoryWindow(start, end int) []provider.Message {
4177 if c.executor == nil {
4178 return []provider.Message{}
4179 }
4180 return c.executor.Session().MessageRange(start, end)
4181 }
4182
4183 // SessionPersistedState exposes the session's persistence baseline for the
4184 // controller's current session path, so a paging frontend can validate a
4185 // display-index sidecar against the live session.
4186 func (c *Controller) SessionPersistedState() (agent.PersistedState, bool) {
4187 if c.executor == nil {
4188 return agent.PersistedState{}, false
4189 }
4190 return c.executor.Session().PersistedState(c.SessionPath())
4191 }
4192
4193 // ContextSnapshot returns (usedTokens, contextWindow) for the gauge. usedTokens
4194 // is what the next request will send, measured the way the compaction trigger
4195 // measures it, so the gauge and the trigger can never disagree. Both zero means
4196 // no data yet — a gauge hides itself.
4197 func (c *Controller) ContextSnapshot() (int, int) {
4198 if c.executor == nil {
4199 return 0, 0
4200 }
4201 return c.executor.ContextUsedTokens(), c.executor.ContextWindow()
4202 }
4203
4204 // CompactRatio returns the auto-compaction threshold as a fraction of the window
4205 // (0 when the executor is unset). The status line shows headroom against it.
4206 func (c *Controller) CompactRatio() float64 {
4207 if c.executor == nil {
4208 return 0
4209 }
4210 return c.executor.CompactRatio()
4211 }
4212
4213 // LastUsage returns the most recent turn's token telemetry (nil before the first
4214 // turn), so frontends can derive the prompt cache-hit rate for the status line.
4215 func (c *Controller) LastUsage() *provider.Usage {
4216 if c.executor == nil {
4217 return nil
4218 }
4219 return c.executor.LastUsage()
4220 }
4221
4222 // SessionCache returns cumulative cache hit/miss prompt tokens for the session,
4223 // so a frontend can render the aggregate (session-wide) cache-hit rate — steadier
4224 // than the single-turn rate and unaffected by compaction.
4225 func (c *Controller) SessionCache() (hit, miss int) {
4226 if c.executor == nil {
4227 return 0, 0
4228 }
4229 return c.executor.SessionCache()
4230 }
4231
4232 // Todos returns the committed event projection used by every frontend. The
4233 // executor's mutable copy is only a tool-loop convenience and cannot override
4234 // a failed or superseded durable commit.
4235 func (c *Controller) Todos() []evidence.TodoItem {
4236 var todos []event.Todo
4237 if ledger := c.turnEventLedger(); ledger != nil {
4238 todos, _ = ledger.TodoState()
4239 } else {
4240 todos, _ = c.volatileTodoState()
4241 }
4242 out := make([]evidence.TodoItem, len(todos))
4243 for i, todo := range todos {
4244 out[i] = evidence.TodoItem{Content: todo.Content, Status: todo.Status}
4245 }
4246 return out
4247 }
4248
4249 // Balance queries the active provider's wallet balance, or (nil, nil) when the
4250 // provider declares no balance_url — so a caller treats "not configured" and
4251 // "fetched" the same and just omits the readout when nil.
4252 func (c *Controller) Balance(ctx context.Context) (*billing.Balance, error) {
4253 if strings.TrimSpace(c.balanceURL) == "" {
4254 return nil, nil
4255 }
4256 ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
4257 defer cancel()
4258 return billing.FetchWithClient(ctx, c.balanceClient, c.balanceURL, c.balanceKey)
4259 }
4260
4261 // Host returns the running MCP host (nil when no plugins), for frontends that
4262 // list servers / resolve MCP prompts.
4263 func (c *Controller) Host() *plugin.Host { return c.mcp.hostRef() }
4264
4265 // Commands returns the loaded custom slash commands.
4266 func (c *Controller) Commands() []command.Command {
4267 if p := c.commands.Load(); p != nil {
4268 return *p
4269 }
4270 return nil
4271 }
4272
4273 // ReloadCommands rescans all command directories and hot-swaps the slash_command
4274 // tool and the internal command slice — no MCP restart, no hook rerun.
4275 func (c *Controller) ReloadCommands(ctx context.Context) error {
4276 select {
4277 case <-ctx.Done():
4278 return ctx.Err()
4279 default:
4280 }
4281 cmds, loadErr := command.LoadRoots(config.CommandRootsForRoot(c.workspaceRoot)...)
4282 var cmdSkills []skill.Skill
4283 if !c.disableImplicitSkillInvocation {
4284 cmdSkills = c.SlashSkills()
4285 }
4286
4287 entries := make([]command.SlashEntry, 0, len(cmdSkills)+len(cmds))
4288 for _, sk := range cmdSkills {
4289
4290 entries = append(entries, command.SlashEntry{
4291 Name: sk.SlashName(),
4292 Description: sk.Description,
4293 Render: func(args []string) string { return c.skills.render(sk, strings.Join(args, " ")) },
4294 })
4295 }
4296 for _, cmd := range cmds {
4297 if cmd.Hidden {
4298 continue
4299 }
4300
4301 entries = append(entries, command.SlashEntry{
4302 Name: cmd.Name,
4303 Description: cmd.Description,
4304 ArgHint: cmd.ArgHint,
4305 Render: func(args []string) string { return cmd.Render(args) },
4306 })
4307 }
4308 c.mcp.registerTool(command.NewSlashCommandTool(entries))
4309 cmdSlice := cmds
4310 c.commands.Store(&cmdSlice)
4311 return loadErr
4312 }
4313
4314 // Skills returns the discoverable skills (for the slash menu and `/skills`).
4315 // When a live Store is available, scan it on demand so skills installed during
4316 // this session appear without rewriting the cache-stable system prompt.
4317 // Executor returns the underlying agent when present (nil for pure runners).
4318 func (c *Controller) Executor() *agent.Agent {
4319 if c == nil {
4320 return nil
4321 }
4322 return c.executor
4323 }
4324
4325 func (c *Controller) Skills() []skill.Skill {
4326 return c.skills.list()
4327 }
4328
4329 // ImplicitSkillInvocationEnabled reports whether skills are exposed to the
4330 // model for automatic discovery and invocation. Explicit /skill handling is
4331 // independent of this model-facing capability.
4332 func (c *Controller) ImplicitSkillInvocationEnabled() bool {
4333 return c != nil && !c.disableImplicitSkillInvocation
4334 }
4335
4336 // SlashSkills returns the user-visible skill directory. Plugin skills use
4337 // package-qualified names while Skills keeps bare model/run_skill identifiers.
4338 func (c *Controller) SlashSkills() []skill.Skill {
4339 return c.skills.slashList()
4340 }
4341
4342 // AllSkills returns every discoverable skill, including disabled ones, for
4343 // management surfaces that need to re-enable a hidden skill.
4344 func (c *Controller) AllSkills() []skill.Skill {
4345 return c.skills.listAll()
4346 }
4347
4348 // LoadSkill reads the selected skill body without expanding every catalog
4349 // candidate. It is intentionally outside the shared capability interface:
4350 // management surfaces may opt into body loading, while search/list stay
4351 // metadata-only and cache-stable.
4352 func (c *Controller) LoadSkill(name string) (skill.Skill, bool) {
4353 return c.skills.load(name)
4354 }
4355
4356 // DisabledSkills returns all discoverable skills that are disabled in config.
4357 func (c *Controller) DisabledSkills() []skill.Skill {
4358 cfg, err := config.Load()
4359 if err != nil {
4360 return nil
4361 }
4362 var out []skill.Skill
4363 for _, sk := range c.AllSkills() {
4364 if cfg.IsSkillDisabled(sk.Name) {
4365 out = append(out, sk)
4366 }
4367 }
4368 return out
4369 }
4370
4371 // SkillEnabled reports whether a discoverable skill is enabled.
4372 func (c *Controller) SkillEnabled(name string) bool {
4373 cfg, err := config.Load()
4374 if err != nil {
4375 return true
4376 }
4377 return !cfg.IsSkillDisabled(name)
4378 }
4379
4380 // SetSkillEnabled persists a skill enable/disable preference. The caller should
4381 // rebuild the controller for the prompt/tool registry to reflect it immediately.
4382 func (c *Controller) SetSkillEnabled(name string, enabled bool) error {
4383 found := false
4384 for _, sk := range c.AllSkills() {
4385 if config.SkillNameKey(sk.Name) == config.SkillNameKey(name) {
4386 name = sk.Name
4387 found = true
4388 break
4389 }
4390 }
4391 if !found {
4392 return fmt.Errorf("unknown skill: %s", name)
4393 }
4394 // Serialize the load-modify-save against other in-process user-config
4395 // editors so concurrent writers (bot mapping persistence, desktop
4396 // settings) don't drop this toggle or lose their own fields.
4397 unlock := config.LockUserConfigEdits()
4398 defer unlock()
4399 cfg := config.LoadForEdit(config.UserConfigPath())
4400 if err := cfg.SetSkillEnabled(name, enabled); err != nil {
4401 return err
4402 }
4403 return cfg.SaveTo(config.UserConfigPath())
4404 }
4405
4406 // CreateSkill writes a new skill file at the given scope and returns its
4407 // path. Skills()/AllSkills()/RunSkill() read the live store on demand, so the
4408 // new skill is usable (by name) immediately with no rebuild and appears in the
4409 // next real user turn's live session-context catalog. Rebuilds remain necessary
4410 // when the tool registry or enabled-skill configuration changes.
4411 func (c *Controller) CreateSkill(name string, scope skill.Scope, content string) (string, error) {
4412 w := c.skills.writer()
4413 if w == nil {
4414 return "", fmt.Errorf("no writable skill store in this session")
4415 }
4416 return w.CreateWithContent(name, scope, content)
4417 }
4418
4419 // UpdateSkill overwrites an existing user-authored skill file in place. See
4420 // skill.Store.UpdateContent for the builtin-refusal and scope-match rules.
4421 func (c *Controller) UpdateSkill(name string, scope skill.Scope, content string) error {
4422 w := c.skills.writer()
4423 if w == nil {
4424 return fmt.Errorf("no writable skill store in this session")
4425 }
4426 return w.UpdateContent(name, scope, content)
4427 }
4428
4429 // DeleteSkill removes a user-authored skill file at the given scope. See
4430 // skill.Store.Delete for the builtin-refusal and scope-match rules.
4431 func (c *Controller) DeleteSkill(name string, scope skill.Scope) error {
4432 w := c.skills.writer()
4433 if w == nil {
4434 return fmt.Errorf("no writable skill store in this session")
4435 }
4436 return w.Delete(name, scope)
4437 }
4438
4439 // HookRunner returns the session's hook runner (nil-safe; may hold zero hooks),
4440 // so a frontend can list the active hooks via `/hooks`.
4441 func (c *Controller) HookRunner() *hook.Runner { return c.hooks }
4442
4443 // AddMCPServer connects an MCP server live and persists it to the user-global
4444 // config. Its tools are registered immediately and become available on the next
4445 // turn (the agent reads the registry per turn). The raw entry — ${VARS} intact —
4446 // is what's written to disk; the live connection uses the expanded form. Returns
4447 // the number of tools the server exposed. Persistence is transactional: a config
4448 // or activation failure removes the just-connected client so the live registry
4449 // never claims an install that will disappear after restart.
4450 func (c *Controller) AddMCPServer(e config.PluginEntry) (int, error) {
4451 // AddMCPServer is an explicit user action. Mark the live entry with the same
4452 // provenance it will receive when the saved user config is loaded next time,
4453 // so /mcp add is add-and-use in the current session too.
4454 e.Source = config.MCPSourceUserConfig
4455 if effective, loadErr := config.LoadForRootReadOnly(c.workspaceRoot); loadErr != nil {
4456 return 0, loadErr
4457 } else {
4458 for _, configured := range effective.Plugins {
4459 if configured.Name != e.Name {
4460 continue
4461 }
4462 if configured.Source != config.MCPSourceUserConfig && configured.Source != config.MCPSourceLegacyUser {
4463 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)
4464 }
4465 break
4466 }
4467 }
4468 n, err := c.connectMCPServer(e)
4469 if err != nil {
4470 return 0, err
4471 }
4472 if _, err := config.InstallUserPluginForRoot(c.workspaceRoot, e, true); err != nil {
4473 c.DisconnectMCPServer(e.Name)
4474 return 0, fmt.Errorf("saving MCP server config: %w", err)
4475 }
4476 return n, nil
4477 }
4478
4479 // ConnectMCPServer connects an MCP server entry for this session without writing
4480 // it to config. Desktop owns config placement so it can keep user-level settings
4481 // out of project reasonix.toml while preserving the CLI AddMCPServer semantics.
4482 func (c *Controller) ConnectMCPServer(e config.PluginEntry) (int, error) {
4483 return c.connectMCPServer(e)
4484 }
4485
4486 // RegisterMCPServerOnDemand restores a configured server's cached provider
4487 // surface without forcing a handshake. It is the durable-enable counterpart to
4488 // ConnectMCPServer, which remains the explicit install/retry operation.
4489 func (c *Controller) RegisterMCPServerOnDemand(e config.PluginEntry) (int, error) {
4490 spec := c.mcpSpec(e)
4491 n, err := c.mcp.registerSpecOnDemand(spec)
4492 if err == nil && c.capabilityRuntime != nil {
4493 c.capabilityRuntime.UpsertServer(e, spec, true)
4494 }
4495 return n, err
4496 }
4497
4498 // connectMCPServer expands an entry's ${VARS}, applies the known-server
4499 // overrides scoped to the workspace, and connects it live via the mcp manager.
4500 func (c *Controller) connectMCPServer(e config.PluginEntry) (int, error) {
4501 spec := c.mcpSpec(e)
4502 n, err := c.mcp.connectSpec(spec)
4503 if err == nil && c.capabilityRuntime != nil {
4504 c.capabilityRuntime.UpsertServer(e, spec, true)
4505 }
4506 return n, err
4507 }
4508
4509 func (c *Controller) mcpSpec(e config.PluginEntry) plugin.Spec {
4510 exp := e.ExpandedPlugin()
4511 configSource := strings.TrimSpace(string(exp.Source))
4512 spec := plugin.ApplyKnownOverrides(plugin.Spec{
4513 Name: exp.Name,
4514 Type: exp.Type,
4515 Command: exp.Command,
4516 Args: exp.Args,
4517 Env: exp.Env,
4518 URL: exp.URL,
4519 Headers: exp.Headers,
4520 StartupTimeout: controllerMCPTimeout(exp.StartupTimeoutSeconds),
4521 DefaultCallTimeout: c.mcpDefaultCallTimeout,
4522 CallTimeout: controllerMCPTimeout(exp.CallTimeoutSeconds),
4523 ToolTimeouts: controllerMCPToolTimeouts(exp.ToolTimeoutSeconds),
4524 WorkspaceRoot: c.WorkspaceRoot(),
4525 ConfigSource: configSource,
4526 Authorized: exp.Source.UserAuthorized(),
4527 // Explicit user installs and reconnects run as trusted host processes.
4528 ProcessMode: plugin.MCPProcessHost,
4529 }, c.WorkspaceRoot())
4530 if exp.Source.ProjectScoped() && strings.TrimSpace(spec.Dir) == "" {
4531 spec.Dir = c.WorkspaceRoot()
4532 }
4533 if c.mcpConfigureSpec != nil {
4534 c.mcpConfigureSpec(&spec)
4535 if spec.ProcessMode == "" {
4536 spec.ProcessMode = plugin.MCPProcessHost
4537 }
4538 }
4539 return spec
4540 }
4541
4542 // syncCapabilityRuntimeFromConfig restores one server's authoritative runtime
4543 // entry after a transactional disconnect/rollback. enabledOverride is used for
4544 // a session-only disconnect; nil re-resolves the durable activation state.
4545 func (c *Controller) syncCapabilityRuntimeFromConfig(name string, enabledOverride *bool) {
4546 if c == nil || c.capabilityRuntime == nil {
4547 return
4548 }
4549 name = strings.TrimSpace(name)
4550 cfg, err := config.LoadForRoot(c.workspaceRoot)
4551 if err != nil {
4552 // The caller revokes first. A config read failure must not re-enable a
4553 // potentially stale spec or shared-Host client.
4554 return
4555 }
4556 for _, entry := range cfg.Plugins {
4557 if strings.TrimSpace(entry.Name) != name {
4558 continue
4559 }
4560 enabled := entry.ShouldAutoStart()
4561 if enabledOverride != nil {
4562 enabled = *enabledOverride
4563 } else if resolved, resolveErr := config.DefaultMCPActivationStore().IsEnabled(entry, c.workspaceRoot); resolveErr == nil {
4564 enabled = resolved
4565 }
4566 c.capabilityRuntime.UpsertServer(entry, c.mcpSpec(entry), enabled)
4567 return
4568 }
4569 c.capabilityRuntime.RemoveServer(name)
4570 }
4571
4572 func controllerMCPTimeout(seconds int) time.Duration {
4573 if seconds <= 0 {
4574 return 0
4575 }
4576 return time.Duration(seconds) * time.Second
4577 }
4578
4579 func controllerMCPToolTimeouts(values map[string]int) map[string]time.Duration {
4580 if len(values) == 0 {
4581 return nil
4582 }
4583 out := make(map[string]time.Duration, len(values))
4584 for name, seconds := range values {
4585 if name = strings.TrimSpace(name); name != "" && seconds > 0 {
4586 out[name] = time.Duration(seconds) * time.Second
4587 }
4588 }
4589 if len(out) == 0 {
4590 return nil
4591 }
4592 return out
4593 }
4594
4595 // ImportMCPEntries persists selected MCP entries and attempts to connect them
4596 // live. A connection failure does not roll back the config import: the user can
4597 // fix local dependencies and reconnect in a later session.
4598 func (c *Controller) ImportMCPEntries(entries []config.PluginEntry) (total, added, updated, connected, failed, skipped int, err error) {
4599 total, added, updated, err = config.ImportCCSwitchMCPEntries(entries)
4600 if err != nil {
4601 return 0, 0, 0, 0, 0, 0, err
4602 }
4603 effectiveCfg, loadErr := config.LoadForRoot(c.workspaceRoot)
4604 if loadErr != nil {
4605 return 0, 0, 0, 0, 0, 0, loadErr
4606 }
4607 effective := make(map[string]config.PluginEntry, len(effectiveCfg.Plugins))
4608 for _, entry := range effectiveCfg.Plugins {
4609 effective[entry.Name] = entry
4610 }
4611 for _, imported := range entries {
4612 e, ok := effective[imported.Name]
4613 if !ok || e.Source != config.MCPSourceUserConfig {
4614 // A project declaration with the same name remains effective. The
4615 // imported global entry is saved as its lower-priority fallback.
4616 skipped++
4617 continue
4618 }
4619 if c.mcp.hasServer(e.Name) {
4620 if c.capabilityRuntime != nil {
4621 // Import updates may intentionally keep an existing live client, but
4622 // future proxy reconnects must use the newly persisted spec.
4623 c.capabilityRuntime.UpsertServer(e, c.mcpSpec(e), true)
4624 }
4625 skipped++
4626 continue
4627 }
4628 if _, err := c.AddMCPServer(e); err != nil {
4629 failed++
4630 continue
4631 }
4632 connected++
4633 }
4634 return total, added, updated, connected, failed, skipped, nil
4635 }
4636
4637 func (c *Controller) ConfiguredMCPNames() []string {
4638 cfg, err := config.LoadForRootReadOnly(c.workspaceRoot)
4639 if err != nil {
4640 return nil
4641 }
4642 names := make([]string, 0, len(cfg.Plugins))
4643 for _, p := range cfg.Plugins {
4644 names = append(names, p.Name)
4645 }
4646 return names
4647 }
4648
4649 func (c *Controller) DisconnectedMCPNames() []string {
4650 cfg, err := config.LoadForRootReadOnly(c.workspaceRoot)
4651 if err != nil {
4652 return nil
4653 }
4654 connected := map[string]bool{}
4655 for _, name := range c.mcp.serverNames() {
4656 connected[name] = true
4657 }
4658 var names []string
4659 for _, p := range cfg.Plugins {
4660 if !connected[p.Name] {
4661 names = append(names, p.Name)
4662 }
4663 }
4664 return names
4665 }
4666
4667 func (c *Controller) ConnectConfiguredMCPServer(name string) (int, error) {
4668 p, err := c.configuredMCPServer(name)
4669 if err != nil {
4670 return 0, err
4671 }
4672 return c.connectMCPServer(p)
4673 }
4674
4675 func (c *Controller) configuredMCPServer(name string) (config.PluginEntry, error) {
4676 cfg, err := config.LoadForRoot(c.workspaceRoot)
4677 if err != nil {
4678 return config.PluginEntry{}, err
4679 }
4680 for _, p := range cfg.Plugins {
4681 if p.Name == name {
4682 return p, nil
4683 }
4684 }
4685 return config.PluginEntry{}, fmt.Errorf("no configured MCP server named %q", name)
4686 }
4687
4688 // RemoveMCPServer removes writable config before disconnecting the live server.
4689 // A persistence failure must not produce a false-successful session-only removal.
4690 // MCPs contributed by installed plugin packages cannot be removed independently.
4691 func (c *Controller) RemoveMCPServer(name string) (disconnected bool, err error) {
4692 cfg, lerr := config.LoadForRoot(c.workspaceRoot)
4693 if lerr != nil {
4694 return false, lerr
4695 }
4696 if owner, ok := cfg.PluginPackageOwner(name); ok {
4697 return false, fmt.Errorf("MCP server %q is managed by plugin %q; disable or remove the plugin instead", name, owner)
4698 }
4699 entry, removed, _, rerr := config.RemovePluginFromEffectiveSourceForRoot(c.workspaceRoot, name)
4700 if rerr != nil {
4701 return false, rerr
4702 }
4703 if !removed {
4704 return false, fmt.Errorf("no removable MCP server named %q", name)
4705 }
4706 _ = config.DefaultMCPActivationStore().ClearServer(entry, c.workspaceRoot)
4707 removedState := reconcileRemovedMCPState(c.workspaceRoot, name)
4708 if c.capabilityRuntime != nil {
4709 // Revoke before touching the shared Host so an overlapping resolver cannot
4710 // reuse a sibling tab's still-connected client.
4711 c.capabilityRuntime.RemoveServer(name)
4712 }
4713 disconnected = c.mcp.disconnect(name)
4714 if !disconnected {
4715 c.mcp.removeToolPrefix(name)
4716 }
4717 // A lower-priority same-name declaration may now be effective. Restore its
4718 // cached/on-demand surface without starting a process; otherwise ensure the
4719 // removed name stays absent.
4720 if removedState.fallbackFound {
4721 enabled := removedState.fallback.ShouldAutoStart()
4722 if resolved, resolveErr := config.DefaultMCPActivationStore().IsEnabled(removedState.fallback, c.workspaceRoot); resolveErr == nil {
4723 enabled = resolved
4724 }
4725 if enabled {
4726 _, _ = c.RegisterMCPServerOnDemand(removedState.fallback)
4727 } else {
4728 c.syncCapabilityRuntimeFromConfig(name, &enabled)
4729 }
4730 } else {
4731 c.syncCapabilityRuntimeFromConfig(name, nil)
4732 }
4733 return disconnected, removedState.cleanupErr
4734 }
4735
4736 // DisconnectMCPServer disconnects a live server for this session without touching
4737 // config — the connector toggle's "off". Its tools vanish next turn; it reconnects
4738 // on the next session start, or now via ConnectConfiguredMCPServer (the "on").
4739 // Reports whether a live server was actually disconnected.
4740 func (c *Controller) DisconnectMCPServer(name string) bool {
4741 if c.capabilityRuntime != nil {
4742 c.capabilityRuntime.SetServerEnabled(name, false)
4743 }
4744 disconnected := c.mcp.disconnect(name)
4745 removedPlaceholder := 0
4746 if !disconnected {
4747 removedPlaceholder = c.mcp.removeToolPrefix(name)
4748 }
4749 // Keep configured servers discoverable as disabled, but forget runtime-only
4750 // or rolled-back installs that no longer exist in configuration.
4751 disabled := false
4752 c.syncCapabilityRuntimeFromConfig(name, &disabled)
4753 return disconnected || removedPlaceholder > 0
4754 }
4755
4756 // UnregisterMCPServerTools hides a shared MCP server from this controller only.
4757 // The desktop shared-host path uses this for per-tab connector toggles: the
4758 // shared client stays alive for sibling tabs, while this session's registry drops
4759 // the server's provider-visible tools before the next turn.
4760 func (c *Controller) UnregisterMCPServerTools(name string) bool {
4761 if c.capabilityRuntime != nil {
4762 c.capabilityRuntime.SetServerEnabled(name, false)
4763 }
4764 return c.mcp.suspendToolPrefix(name)
4765 }
4766
4767 // Label returns the human-readable model label, e.g. "deepseek-flash".
4768 func (c *Controller) Label() string { return c.label }
4769
4770 // ModelRef returns the canonical provider/model reference for the session.
4771 func (c *Controller) ModelRef() string { return c.selection.ref }
4772
4773 // ModelSelectionIdentity is frozen with the provider assembled for this runtime.
4774 func (c *Controller) ModelSelectionIdentity() string { return c.selection.identity }
4775
4776 // WorkspaceRoot returns the workspace root for this controller's session
4777 // (the directory that file-writers and @-references are scoped to).
4778 // Empty means no scoping is in effect.
4779 func (c *Controller) WorkspaceRoot() string { return c.workspaceRoot }
4780
4781 func (c *Controller) imageInputEnabled() bool {
4782 if c.frozenImageInput != nil {
4783 return *c.frozenImageInput
4784 }
4785 ref := c.selection.ref
4786 cfg, err := config.LoadForRoot(c.workspaceRoot)
4787 if err == nil && ref == "" {
4788 ref = cfg.DefaultModel
4789 }
4790 if err != nil || ref == "" {
4791 return false
4792 }
4793 entry, ok := cfg.ResolveModel(ref)
4794 if !ok {
4795 return false
4796 }
4797 if c.modelCapabilityResolver != nil {
4798 return c.modelCapabilityResolver(entry).State == config.CapabilitySupported
4799 }
4800 return config.EffectiveVision(entry)
4801 }
4802
4803 // ImageInputEnabled reports whether the current model accepts direct image
4804 // inputs, so frontends can gate image-only UX before a turn starts.
4805 func (c *Controller) ImageInputEnabled() bool { return c.imageInputEnabled() }
4806
4807 // ImageInputSnapshot avoids configuration reads on the Desktop metadata path.
4808 // Legacy/custom controllers without a frozen boot snapshot use the existing
4809 // background metadata fallback instead.
4810 func (c *Controller) ImageInputSnapshot() (enabled, fallback, available bool) {
4811 if c == nil || c.frozenImageInput == nil {
4812 return false, false, false
4813 }
4814 return *c.frozenImageInput, c.visionModel != "", true
4815 }
4816
4817 // ImageCapabilityChanged lets desktop refresh an idle runtime before admission.
4818 func (c *Controller) ImageCapabilityChanged() bool {
4819 return c.imageCapabilityChanged != nil && c.imageCapabilityChanged()
4820 }
4821
4822 // SessionAuthorizations snapshots this controller's same-session tool
4823 // grants ("Allow for this session") and Plan-mode read-only command trust,
4824 // for carrying into a replacement controller across a rebuild — see
4825 // RestoreSessionAuthorizations.
4826 func (c *Controller) SessionAuthorizations() SessionAuthorizations {
4827 auth := c.approval.snapshotSessionAuthorizations()
4828 if c.writeAccess.roots != nil {
4829 auth.WriteRoots = c.writeAccess.roots.SessionRoots()
4830 if auth.WriteRoots == nil {
4831 auth.WriteRoots = []string{}
4832 }
4833 }
4834 return auth
4835 }
4836
4837 // ReleaseResources stops plugin subprocesses and releases resources without
4838 // firing SessionEnd. Use it only when replacing the controller for the same
4839 // logical session.
4840 func (c *Controller) ReleaseResources() {
4841 c.close(false, closeJobsWithGrace)
4842 }
4843
4844 // Close stops plugin subprocesses and releases resources. A session that ever
4845 // started fires SessionEnd so a teardown hook runs.
4846 func (c *Controller) Close() {
4847 c.recordLifecycle("close", "controller_close", "", 0, "")
4848 c.close(true, closeJobsWithGrace)
4849 }
4850
4851 // Closed is signalled after teardown has released all Controller-owned stores.
4852 // Close itself only requests teardown when a turn is still finalizing.
4853 func (c *Controller) Closed() <-chan struct{} { return c.closeFinalized }
4854
4855 // CloseAfterDestroy releases controller resources after the caller has already
4856 // begun session-specific job teardown. It avoids a second synchronous job grace
4857 // wait while still cancelling the manager root and reaping temporary artifacts
4858 // once every job goroutine finally exits.
4859 func (c *Controller) CloseAfterDestroy() {
4860 c.close(true, closeJobsAsync)
4861 }
4862
4863 type closeJobsMode int
4864
4865 const (
4866 closeJobsWithGrace closeJobsMode = iota
4867 closeJobsAsync
4868 )
4869
4870 func (c *Controller) close(fireSessionEnd bool, jobsMode closeJobsMode) {
4871 defer c.refreshRuntimeState(event.Event{})
4872 // Desktop tab lifecycles can race a rebind/model-switch/close on the same
4873 // controller; make teardown idempotent so a duplicate Close cannot re-fire
4874 // SessionEnd hooks or re-run cleanup. The first caller's jobsMode wins.
4875 c.closeOnce.Do(func() {
4876 c.mu.Lock()
4877 cancel := c.turns.cancel
4878 done := c.turns.done
4879 // A phase marker alone is not a live turn: recovery may retain one after
4880 // cancel/done ownership has gone. Only a live body or terminal fanout
4881 // defers final resource release.
4882 turnActive := done != nil || c.finalizingLocked() || c.turns.recoveryFanout
4883 // Seal turn admission and drop anything already parked: a parked turn
4884 // must not start against a controller that is being torn down, and
4885 // without the closed flag a submit landing after this critical
4886 // section (while a running turn's TurnDone delivery is still in
4887 // flight) would park again and start after teardown.
4888 c.closed = true
4889 c.closeFireSessionEnd = fireSessionEnd
4890 c.closeJobsMode = jobsMode
4891 c.turns.pending = nil
4892 c.turns.wake = false
4893 if cancel != nil {
4894 c.turns.cancelRequested = true
4895 if c.turns.phase == session.RuntimeRunning {
4896 c.turns.phase = session.RuntimeCancelling
4897 c.noteExecutionLocked(session.RuntimeCancelling, "cancelling")
4898 }
4899 } else {
4900 c.turns.cancelRequested = false
4901 }
4902 if !turnActive {
4903 c.turns.phase = session.RuntimeClosed
4904 c.turns.finishingBound.end()
4905 c.turns.finishingBound.endIdle()
4906 }
4907 c.mu.Unlock()
4908 if cancel != nil {
4909 // Signal the owned turn before prompt bookkeeping or callbacks. A
4910 // stalled registry/adapter must never delay Stop during shutdown.
4911 cancel()
4912 c.startCancellationWatchdog(done)
4913 c.promptOwner.CancelAll()
4914 c.approval.clearAll()
4915 } else {
4916 c.promptOwner.Clear()
4917 }
4918 if c.goalDriverControl.cancel != nil {
4919 c.goalDriverControl.cancel()
4920 }
4921 if !turnActive {
4922 c.finalizeControllerClose()
4923 }
4924 })
4925 }
4926
4927 // finalizeControllerClose releases stores and process resources only after an
4928 // active turn has published its terminal boundary. Closing the ledger or the
4929 // session binding earlier makes the final TurnDone impossible to accept.
4930 func (c *Controller) finalizeControllerClose() {
4931 c.closeFinalizeOnce.Do(func() {
4932 if c.closeFinalized != nil {
4933 defer close(c.closeFinalized)
4934 }
4935 c.mu.Lock()
4936 started := c.startedOnce
4937 fireSessionEnd := c.closeFireSessionEnd
4938 jobsMode := c.closeJobsMode
4939 c.mu.Unlock()
4940 // Goal-driver workers may be inside the pre-admission durability
4941 // checkpoint. Join them before closing the v3 writer so teardown cannot
4942 // race a late Flush or recreate files under a test/session directory.
4943 c.goalDriverWG.Wait()
4944 // Join sidecar creation and queue scans without waiting for the
4945 // dispatcher itself: host admission may retire its own controller.
4946 c.inbox.scanMu.Lock()
4947 c.inbox.mu.Lock()
4948 c.inbox.closed = true
4949 if c.inbox.store != nil {
4950 c.inbox.store.Close()
4951 c.inbox.store = nil
4952 }
4953 if c.inbox.tempLease != nil {
4954 c.inbox.tempLease.Release()
4955 c.inbox.tempLease = nil
4956 }
4957 c.inbox.mu.Unlock()
4958 c.inbox.scanMu.Unlock()
4959 if fireSessionEnd && started {
4960 c.hooks.SessionEnd(context.Background(), "other")
4961 c.extensionSessionEvent(extension.PointSessionEnd, dispatch.PhaseEnd, c.SessionPath())
4962 }
4963 if c.jobs != nil {
4964 switch jobsMode {
4965 case closeJobsAsync:
4966 c.jobs.CloseAsync()
4967 default:
4968 c.jobs.Close() // cancel any still-running background jobs
4969 }
4970 }
4971 if ledger := c.turnEventLedger(); ledger != nil {
4972 if err := ledger.Close(); err != nil {
4973 slog.Warn("controller: close turn event ledger", "err", err)
4974 }
4975 }
4976 c.turnEvents.commitMu.Lock()
4977 if pending := c.turnEvents.pendingExecutionCommit; pending != nil {
4978 pending.Release()
4979 c.turnEvents.pendingExecutionCommit = nil
4980 }
4981 c.turnEvents.commitMu.Unlock()
4982 service, runtime, exclusive := c.v3Binding()
4983 if exclusive && runtime != nil {
4984 c.releaseSessionRuntimeBinding(service)
4985 } else if v3 := c.sessionEventStore(); v3 != nil {
4986 c.turnEvents.mu.RLock()
4987 release := c.turnEvents.v3Release
4988 c.turnEvents.mu.RUnlock()
4989 var err error
4990 if release != nil {
4991 err = release(context.Background())
4992 } else {
4993 err = v3.Close(context.Background())
4994 }
4995 if err != nil {
4996 slog.Warn("controller: flush and close v3 session", "err", err)
4997 }
4998 }
4999 if c.cleanup != nil {
5000 c.cleanup()
5001 }
5002 // Drop the Controller owner reference last so background job leases
5003 // that outlive close still pin retired generations until they exit.
5004 if c.sessionTemp != nil {
5005 c.sessionTemp.Release()
5006 }
5007 if c.persistentShell != nil {
5008 c.persistentShell.Release()
5009 }
5010 })
5011 }
5012
5013 // SessionTemp returns the logical-session private temporary directory manager.
5014 // Hot rebuilds pass this to the replacement Controller so the directory survives
5015 // model/settings swaps. Nil only when the Controller was constructed without one
5016 // (should not happen after New).
5017 func (c *Controller) SessionTemp() *sessiontemp.Manager {
5018 if c == nil {
5019 return nil
5020 }
5021 return c.sessionTemp
5022 }
5023
5024 // rotateSessionTemp advances the private temporary generation so a new logical
5025 // session cannot see the previous session's temporary files. In-flight command
5026 // leases keep the old generation alive until they release.
5027 func (c *Controller) rotateSessionTemp() {
5028 if c == nil {
5029 return
5030 }
5031 if c.sessionTemp != nil {
5032 c.sessionTemp.Rotate()
5033 }
5034 if c.persistentShell != nil {
5035 c.persistentShell.Rotate()
5036 }
5037 }
5038
5039 // PersistentShell returns the session-scoped PTY manager. Hot rebuilds pass
5040 // this to the replacement Controller so shell state survives model/settings
5041 // swaps. Nil only when the Controller was constructed without one.
5042 func (c *Controller) PersistentShell() *persistentshell.Manager {
5043 if c == nil {
5044 return nil
5045 }
5046 return c.persistentShell
5047 }
5048
5049 // Jobs returns the still-running background jobs for the status bar (nil when
5050 // background jobs are disabled).
5051 func (c *Controller) Jobs() []jobs.View {
5052 if c.jobs == nil {
5053 return nil
5054 }
5055 return c.jobs.RunningForSession(c.parentSessionID())
5056 }
5057
5058 // KillJob cancels a running background job by ID.
5059 func (c *Controller) KillJob(id string) bool {
5060 if c.jobs == nil {
5061 return false
5062 }
5063 return c.jobs.Kill(id)
5064 }
5065
5066 // TaskRuntimeOwnerID identifies the recorder that admitted this runtime's jobs.
5067 func (c *Controller) TaskRuntimeOwnerID() string {
5068 if c.jobs == nil {
5069 return ""
5070 }
5071 return c.jobs.TaskRuntimeOwnerID()
5072 }
5073
5074 // CancelJob stops one background job owned by this controller's session.
5075 func (c *Controller) CancelJob(id string) bool {
5076 if c.jobs == nil {
5077 return false
5078 }
5079 return c.jobs.KillForSession(c.parentSessionID(), id)
5080 }
5081
5082 // WorkspaceLeaseState reports the process-local held and waiting lease scope.
5083 // Canonical keys are used only for matching another local controller and are
5084 // never copied into user-facing payloads.
5085 func (c *Controller) WorkspaceLeaseState() workspacelease.State {
5086 return c.workspaceLease.State()
5087 }
5088
5089 // WorkspaceLeaseHeldKeys is the lock-domain identity this controller currently
5090 // holds. Empty when no write lease is held.
5091 func (c *Controller) WorkspaceLeaseHeldKeys() []string {
5092 return c.workspaceLease.HeldKeys()
5093 }
5094
5095 // SetToolApprovalMode changes the runtime approval posture for permission-gated
5096 // tools. It does not answer business asks or plan approval. Sub-agents (task,
5097 // writer-capable skill sub-agents, the planner) have no UI to prompt through,
5098 // so this also pushes the mode to the shared headless gate they read from —
5099 // without it, a mode switch (Shift+Tab) would only rebuild the parent
5100 // executor's gate and leave sub-agents pinned to whatever mode was active
5101 // when the session booted.
5102 func (c *Controller) SetToolApprovalMode(mode string) {
5103 c.ApplyToolApprovalMode(mode)
5104 }
5105
5106 // ApplyToolApprovalMode updates the preset without answering an older prompt.
5107 // A real mode change invalidates the active turn and its request IDs, so an
5108 // approval created under an earlier permission revision can never authorize a
5109 // call under the new revision.
5110 func (c *Controller) ApplyToolApprovalMode(mode string) []string {
5111 c.permissionMu.Lock()
5112 defer c.permissionMu.Unlock()
5113 return c.applyToolApprovalModeLocked(mode)
5114 }
5115
5116 func (c *Controller) applyToolApprovalModeLocked(mode string) []string {
5117 mode = normalizeToolApprovalMode(mode)
5118 c.promptResolveMu.Lock()
5119 previousMode := c.approval.mode()
5120 if previousMode == mode {
5121 c.promptResolveMu.Unlock()
5122 return nil
5123 }
5124 c.permissionStateMu.Lock()
5125 pending := c.approval.setMode(mode)
5126 if c.subagentGate != nil {
5127 c.subagentGate.Update(mode)
5128 }
5129 c.refreshInteractiveGate()
5130 // Publish the revision only after every enforcement owner has adopted the
5131 // new mode. promptResolveMu makes this one transaction with approval commit.
5132 c.permissionRevision.Add(1)
5133 c.permissionStateMu.Unlock()
5134 drained := make([]string, 0, len(pending))
5135 for _, p := range pending {
5136 p.reply <- approvalReply{allow: true}
5137 drained = append(drained, p.id)
5138 }
5139 // A permission revision change invalidates the active turn so an older
5140 // approval can never authorize work under the new snapshot. Avoid the idle
5141 // Cancel path: it intentionally stops an active Goal and permission
5142 // selection is an independent composer axis.
5143 turnID, cancelled := "", false
5144 if c.Running() {
5145 turnID, cancelled = c.cancelTurnLocked()
5146 }
5147 c.promptResolveMu.Unlock()
5148 if cancelled {
5149 c.finishCancel(turnID, true)
5150 }
5151 // Processes admitted under a broader preset may outlive their spawning
5152 // turn. Only a downgrade must terminate them; an upgrade does not revoke
5153 // any capability they already held.
5154 if permissionPresetRank(mode) < permissionPresetRank(previousMode) {
5155 for _, job := range c.Jobs() {
5156 c.CancelJob(job.ID)
5157 }
5158 }
5159 c.refreshRuntimeState(event.Event{})
5160 return drained
5161 }
5162
5163 func permissionPresetRank(mode string) int {
5164 switch normalizeToolApprovalMode(mode) {
5165 case ToolApprovalDangerFullAccess:
5166 return 2
5167 case ToolApprovalWorkspaceWrite:
5168 return 1
5169 default:
5170 return 0
5171 }
5172 }
5173
5174 func (c *Controller) ToolApprovalMode() string {
5175 return c.approval.mode()
5176 }
5177
5178 // SetAutoApproveTools is the legacy boolean compatibility binding. Both values
5179 // migrate to the safe workspace preset; full access requires an explicit preset.
5180 func (c *Controller) SetAutoApproveTools(on bool) {
5181 _ = on
5182 c.SetToolApprovalMode(ToolApprovalWorkspaceWrite)
5183 }
5184
5185 // SetBypass is the legacy name for SetAutoApproveTools. Keep it for existing
5186 // desktop/serve bindings and CLI code that still uses the bypass wording.
5187 func (c *Controller) SetBypass(on bool) {
5188 c.SetAutoApproveTools(on)
5189 }
5190
5191 // SetMode is the legacy combined Plan/permission binding. New callers use
5192 // ApplyComposerProfile with an explicit permission preset.
5193 func (c *Controller) SetMode(plan, autoApproveTools bool) {
5194 c.ApplyMode(plan, autoApproveTools)
5195 }
5196
5197 // ApplyMode is the legacy SetMode variant that reports invalidated prompt IDs.
5198 func (c *Controller) ApplyMode(plan, autoApproveTools bool) []string {
5199 c.applyPlanMode(plan)
5200 _ = autoApproveTools
5201 return c.ApplyToolApprovalMode(ToolApprovalWorkspaceWrite)
5202 }
5203
5204 // ApplyComposerProfile publishes the collaboration, approval, and Goal axes as
5205 // one controller operation. The only fallible mutation (durable Goal state)
5206 // commits first, so a persistence failure leaves Plan and approval unchanged.
5207 // Serve serializes this call with turn admission and controller replacement.
5208 func (c *Controller) ApplyComposerProfile(plan bool, toolApprovalMode, goal string) ([]string, error) {
5209 c.permissionMu.Lock()
5210 defer c.permissionMu.Unlock()
5211 return c.applyComposerProfileLocked(plan, toolApprovalMode, goal)
5212 }
5213
5214 // ApplyComposerProfileAt is the revision-checked protocol entry point used by
5215 // desktop and remote clients. It keeps Goal, Plan and permission changes under
5216 // one controller mutation boundary.
5217 func (c *Controller) ApplyComposerProfileAt(plan bool, toolApprovalMode, goal string, expectedPermissionRevision uint64) ([]string, error) {
5218 c.permissionMu.Lock()
5219 defer c.permissionMu.Unlock()
5220 if current := c.permissionRevision.Load(); current != expectedPermissionRevision {
5221 return nil, fmt.Errorf("permission revision changed: have %d, expected %d", current, expectedPermissionRevision)
5222 }
5223 return c.applyComposerProfileLocked(plan, toolApprovalMode, goal)
5224 }
5225
5226 func (c *Controller) applyComposerProfileLocked(plan bool, toolApprovalMode, goal string) ([]string, error) {
5227 if raw := strings.ToLower(strings.TrimSpace(toolApprovalMode)); raw != "ask" && raw != "auto" && raw != "yolo" &&
5228 raw != ToolApprovalReadOnly && raw != ToolApprovalWorkspaceWrite && raw != ToolApprovalDangerFullAccess {
5229 return nil, fmt.Errorf("permission preset must be read-only, workspace-write, or danger-full-access")
5230 }
5231 toolApprovalMode = normalizeToolApprovalMode(toolApprovalMode)
5232 goal = strings.TrimSpace(goal)
5233 if strings.TrimSpace(c.Goal()) != goal {
5234 if err := c.SetGoalDurable(goal); err != nil {
5235 return nil, fmt.Errorf("persist goal state: %w", err)
5236 }
5237 }
5238 if goal != "" {
5239 plan = false
5240 }
5241 c.applyPlanMode(plan)
5242 return c.applyToolApprovalModeLocked(toolApprovalMode), nil
5243 }
5244
5245 // AutoApproveTools is the legacy status query for explicit full access.
5246 func (c *Controller) AutoApproveTools() bool {
5247 return c.ToolApprovalMode() == ToolApprovalDangerFullAccess
5248 }
5249
5250 // Bypass is the legacy name for AutoApproveTools.
5251 func (c *Controller) Bypass() bool {
5252 return c.AutoApproveTools()
5253 }
5254
5255 // memory
5256 //
5257 // The memory snapshot, pending standing-doc notes, and write serialization
5258 // live in c.memory (a memoryManager) behind its own locks, off c.mu — so a
5259 // memory-panel save never stalls an approval or status poll. These methods are
5260 // the SessionAPI surface; each is a thin delegation. See memory.go.
5261
5262 // QuickAdd appends a one-line note to the doc-memory file for scope (project
5263 // REASONIX.md by default) — the write side of "#<note>". Returns the file written.
5264 func (c *Controller) QuickAdd(scope memory.Scope, note string) (string, error) {
5265 return c.memory.quickAdd(scope, note)
5266 }
5267
5268 // SaveDoc overwrites a recognized memory doc with body — the save side of the
5269 // desktop panel's in-place editor. Returns the file written.
5270 func (c *Controller) SaveDoc(path, body string) (string, error) {
5271 return c.memory.saveDoc(path, body)
5272 }
5273
5274 // SaveMemory writes an active auto-memory fact and refreshes the in-session
5275 // snapshot. It is the explicit user-confirmed counterpart to the model-owned
5276 // remember tool, used by management surfaces that preview a candidate first.
5277 func (c *Controller) SaveMemory(m memory.Memory) (string, error) {
5278 return c.memory.saveMemory(m)
5279 }
5280
5281 // ForgetMemory removes a saved auto-memory by name — the panel/TUI forget action,
5282 // the manual counterpart to the model's `forget` tool.
5283 func (c *Controller) ForgetMemory(name string) error {
5284 return c.memory.forget(name)
5285 }
5286
5287 // QueueMemory implements memory.Queue: model remember/forget tool results are
5288 // already visible in the current loop, so this refreshes the background snapshot
5289 // that will be published in session-context on the next real user turn.
5290 func (c *Controller) QueueMemory(note string) {
5291 c.memory.queue(note)
5292 }
5293
5294 // ClaimAutoMemoryWrite consumes the one-shot create-only authorization issued
5295 // by gateApprover for a low-risk project fact.
5296 func (c *Controller) ClaimAutoMemoryWrite(args json.RawMessage) bool {
5297 return c.memory.claimAutoRemember(args)
5298 }
5299
5300 func (c *Controller) MemoryRevisions(ref string) []memory.Memory {
5301 return c.memory.revisions(ref)
5302 }
5303
5304 // RestoreMemory restores an older active-memory revision as a new audited
5305 // revision and applies it to the next user turn.
5306 func (c *Controller) RestoreMemory(ref string, revision int) (memory.Memory, error) {
5307 return c.memory.restore(ref, revision)
5308 }
5309
5310 // RestoreArchivedMemory recovers an archived fact as a new audited revision and
5311 // applies it to the next user turn.
5312 func (c *Controller) RestoreArchivedMemory(archivePath string) (memory.Memory, error) {
5313 return c.memory.restoreArchived(archivePath)
5314 }
5315
5316 // Memory returns the loaded memory snapshot (nil when memory is disabled), for
5317 // frontends that surface a memory panel or the /memory command. The returned
5318 // *Set is immutable — mutations go through QuickAdd / SaveDoc.
5319 func (c *Controller) Memory() *memory.Set {
5320 return c.memory.current()
5321 }
5322
5323 // approval bridge (agent gate → events)
5324
5325 // gateApprover adapts the Controller to permission.Approver. It is distinct
5326 // from the public Approve command (different signature, different direction).
5327 type gateApprover struct{ c *Controller }
5328
5329 func (g gateApprover) Approve(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, error) {
5330 allow, remember, _, err := g.ApproveWithReason(ctx, tool, subject, args)
5331 return allow, remember, err
5332 }
5333
5334 func (g gateApprover) ApproveWithReason(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, string, error) {
5335 return g.approveWithPolicyReason(ctx, tool, subject, args, "")
5336 }
5337
5338 func (g gateApprover) ApproveWithPolicyReason(ctx context.Context, tool, subject string, args json.RawMessage, policyReason string) (bool, bool, string, error) {
5339 return g.approveWithPolicyReason(ctx, tool, subject, args, policyReason)
5340 }
5341
5342 func (g gateApprover) approveWithPolicyReason(ctx context.Context, tool, subject string, args json.RawMessage, policyReason string) (bool, bool, string, error) {
5343 if tool == memoryRememberTool && g.c.allowLowRiskRemember(args) {
5344 return true, false, "", nil
5345 }
5346 subject = approvalDisplaySubject(tool, subject, args)
5347 // Check pre-approval before any prompt or Guardian review. OS sandboxing is
5348 // the authority for nested shell syntax, so pipes, command substitutions and
5349 // inline interpreters follow the same permission decision as other commands.
5350 if g.c.approval.preApproved(tool, subject, args) {
5351 return true, false, "", nil
5352 }
5353 allow, remember, err := g.c.requestApprovalWithReason(ctx, tool, subject, args, policyReason)
5354 return allow, remember, "", err
5355 }
5356
5357 type planModeReadOnlyTrustApprover struct{ c *Controller }
5358
5359 type sandboxEscapeApprover struct{ c *Controller }
5360
5361 func (s sandboxEscapeApprover) ApproveSandboxEscape(ctx context.Context, req sandbox.EscapeRequest) (bool, string, error) {
5362 subject := sandboxEscapeApprovalSubject(req.Command)
5363 reason := sandboxEscapeApprovalReason(req.Reason)
5364 reply, err := s.c.requestFreshApprovalDecision(ctx, SandboxEscapeApprovalTool, subject, req.Args, reason)
5365 if err != nil {
5366 return false, "approval aborted", err
5367 }
5368 if !reply.allow {
5369 return false, i18n.M.SandboxEscapeDeclined, nil
5370 }
5371 if reply.session {
5372 s.c.permissionStateMu.Lock()
5373 s.c.approval.grantSession(SandboxEscapeApprovalTool, subject)
5374 s.c.permissionStateMu.Unlock()
5375 }
5376 return true, "", nil
5377 }
5378
5379 func (s sandboxEscapeApprover) SandboxEscapeSessionAllowed(_ context.Context, req sandbox.EscapeRequest) bool {
5380 return s.c.approval.preApprovedForDecision(SandboxEscapeApprovalTool, sandboxEscapeApprovalSubject(req.Command), nil, true)
5381 }
5382
5383 func sandboxEscapeApprovalSubject(command string) string {
5384 subject := strings.TrimSpace(command)
5385 if subject == "" {
5386 return i18n.M.SandboxEscapeSubjectFallback
5387 }
5388 return i18n.M.SandboxEscapeSubjectPrefix + subject
5389 }
5390
5391 func sandboxEscapeApprovalReason(reason string) string {
5392 reason = strings.TrimSpace(reason)
5393 if reason == "" {
5394 return i18n.M.SandboxEscapeRuntimeReason
5395 }
5396 return reason
5397 }
5398
5399 // managedConfigWriteApprover routes a file tool's Reasonix-managed config write
5400 // through the fresh-human approval prompt (see ManagedConfigWriteApprovalTool).
5401 // A session grant is tool-wide (mirroring sandbox_escape): one "allow for this
5402 // session" covers the rest of the repair flow across the handful of managed
5403 // config files without re-prompting on every incremental edit.
5404 type managedConfigWriteApprover struct{ c *Controller }
5405
5406 func (m managedConfigWriteApprover) ApproveManagedConfigWrite(ctx context.Context, req tool.ConfigWriteRequest) (bool, string, error) {
5407 subject := managedConfigWriteApprovalSubject(req.Path)
5408 args, _ := json.Marshal(map[string]string{"path": req.Path})
5409 reply, err := m.c.requestFreshApprovalDecision(ctx, ManagedConfigWriteApprovalTool, subject, args, i18n.M.ConfigWriteReason)
5410 if err != nil {
5411 return false, "approval aborted", err
5412 }
5413 if !reply.allow {
5414 return false, i18n.M.ConfigWriteDeclined, nil
5415 }
5416 if reply.session {
5417 m.c.permissionStateMu.Lock()
5418 m.c.approval.grantSession(ManagedConfigWriteApprovalTool, subject)
5419 m.c.permissionStateMu.Unlock()
5420 }
5421 return true, "", nil
5422 }
5423
5424 func (m managedConfigWriteApprover) ManagedConfigWriteSessionAllowed(_ context.Context, req tool.ConfigWriteRequest) bool {
5425 return m.c.approval.preApprovedForDecision(ManagedConfigWriteApprovalTool, managedConfigWriteApprovalSubject(req.Path), nil, true)
5426 }
5427
5428 func managedConfigWriteApprovalSubject(path string) string {
5429 return i18n.M.ConfigWriteSubjectPrefix + strings.TrimSpace(path)
5430 }
5431
5432 func (p planModeReadOnlyTrustApprover) CheckPlanModeReadOnlyTrust(ctx context.Context, req agent.PlanModeReadOnlyTrustRequest) (bool, string, error) {
5433 prefix := normalizePlanModeReadOnlyCommandPrefix(req.Prefix)
5434 if prefix == "" {
5435 return false, "missing plan-mode read-only command prefix", nil
5436 }
5437 return p.checkBashReadOnlyCommandTrust(ctx, req, prefix)
5438 }
5439
5440 func (p planModeReadOnlyTrustApprover) checkBashReadOnlyCommandTrust(ctx context.Context, req agent.PlanModeReadOnlyTrustRequest, prefix string) (bool, string, error) {
5441 if p.c.approval.planModeReadOnlyCommandTrusted(prefix) {
5442 return true, "", nil
5443 }
5444 command := strings.TrimSpace(req.Command)
5445 if command == "" {
5446 command = strings.TrimSpace(string(req.Args))
5447 }
5448 subject := fmt.Sprintf(i18n.M.PlanModeBashTrustSubjectFmt, prefix, command)
5449 reason := i18n.M.PlanModeBashTrustReason
5450 reply, err := p.c.requestFreshApprovalDecision(ctx, agent.PlanModeReadOnlyCommandApprovalTool, subject, req.Args, reason)
5451 if err != nil {
5452 return false, "approval aborted", err
5453 }
5454 if !reply.allow {
5455 return false, i18n.M.PlanModeBashTrustDeclined, nil
5456 }
5457 if reply.session {
5458 p.c.permissionStateMu.Lock()
5459 p.c.approval.grantPlanModeReadOnlyCommand(prefix)
5460 p.c.permissionStateMu.Unlock()
5461 }
5462 if reply.persist && p.c.onRememberPlanModeReadOnlyCommand != nil {
5463 p.c.emitPlanModeReadOnlyCommandTrustResult(p.c.onRememberPlanModeReadOnlyCommand(prefix))
5464 p.c.permissionStateMu.Lock()
5465 p.c.approval.grantPlanModeReadOnlyCommand(prefix)
5466 p.c.permissionStateMu.Unlock()
5467 }
5468 return true, "", nil
5469 }
5470
5471 func approvalDisplaySubject(tool, subject string, args json.RawMessage) string {
5472 switch tool {
5473 case memoryRememberTool:
5474 return rememberApprovalSubject(subject, args)
5475 case memoryForgetTool:
5476 return forgetApprovalSubject(subject, args)
5477 case "move_file":
5478 return moveApprovalSubject(subject, args)
5479 default:
5480 return subject
5481 }
5482 }
5483
5484 func moveApprovalSubject(fallback string, args json.RawMessage) string {
5485 if len(args) == 0 {
5486 return fallback
5487 }
5488 var in struct {
5489 SourcePath string `json:"source_path"`
5490 DestinationPath string `json:"destination_path"`
5491 }
5492 if err := json.Unmarshal(args, &in); err != nil {
5493 return fallback
5494 }
5495 if in.SourcePath == "" || in.DestinationPath == "" {
5496 return fallback
5497 }
5498 return in.SourcePath + " -> " + in.DestinationPath
5499 }
5500
5501 func rememberApprovalSubject(fallback string, args json.RawMessage) string {
5502 if len(args) == 0 {
5503 return fallback
5504 }
5505 var in struct {
5506 Name string `json:"name"`
5507 Title string `json:"title"`
5508 Description string `json:"description"`
5509 Type string `json:"type"`
5510 Body string `json:"body"`
5511 }
5512 if err := json.Unmarshal(args, &in); err != nil {
5513 return fallback
5514 }
5515 name := approvalCompactText(firstNonEmpty(in.Name, in.Title))
5516 desc := approvalTruncate(approvalCompactText(in.Description), 180)
5517 body := approvalTruncate(approvalCompactText(in.Body), 240)
5518 typ := string(memory.NormalizeType(in.Type))
5519
5520 var b strings.Builder
5521 b.WriteString(i18n.M.MemoryApprovalSaveUpdate)
5522 baseLen := b.Len()
5523 if name != "" {
5524 fmt.Fprintf(&b, " %q", name)
5525 }
5526 if typ != "" {
5527 fmt.Fprintf(&b, " [%s]", typ)
5528 }
5529 if desc != "" {
5530 b.WriteString(": ")
5531 b.WriteString(desc)
5532 }
5533 if body != "" {
5534 if desc == "" {
5535 b.WriteString(": ")
5536 } else {
5537 b.WriteString(" | ")
5538 }
5539 b.WriteString(i18n.M.MemoryApprovalBodyLabel)
5540 b.WriteString(": ")
5541 b.WriteString(body)
5542 }
5543 if b.Len() == baseLen && fallback != "" {
5544 return fallback
5545 }
5546 return b.String()
5547 }
5548
5549 func forgetApprovalSubject(fallback string, args json.RawMessage) string {
5550 if len(args) == 0 {
5551 return fallback
5552 }
5553 var in struct {
5554 Name string `json:"name"`
5555 }
5556 if err := json.Unmarshal(args, &in); err != nil {
5557 return fallback
5558 }
5559 name := approvalCompactText(in.Name)
5560 if name == "" {
5561 return fallback
5562 }
5563 return fmt.Sprintf(i18n.M.MemoryApprovalArchiveFmt, name)
5564 }
5565
5566 func firstNonEmpty(values ...string) string {
5567 for _, value := range values {
5568 if strings.TrimSpace(value) != "" {
5569 return value
5570 }
5571 }
5572 return ""
5573 }
5574
5575 func approvalCompactText(s string) string {
5576 return strings.Join(strings.Fields(s), " ")
5577 }
5578
5579 func approvalTruncate(s string, maxRunes int) string {
5580 if maxRunes <= 0 {
5581 return ""
5582 }
5583 runes := []rune(s)
5584 if len(runes) <= maxRunes {
5585 return s
5586 }
5587 return string(runes[:maxRunes]) + "..."
5588 }
5589
5590 func (c *Controller) sessionMessageCount() int {
5591 if c.executor == nil {
5592 return 0
5593 }
5594 return c.executor.Session().Len()
5595 }
5596
5597 // parseRewind parses the arguments after "/rewind". The user may provide:
5598 //
5599 // /rewind → latest checkpoint, both
5600 // /rewind <turn> → that turn, both
5601 // /rewind <turn> <scope> → that turn, code|conversation|both
5602 //
5603 // If no turn is given, the latest checkpoint is used. If no scope is given, Both is assumed.
5604 func parseRewind(args string, cps []checkpoint.Meta) (int, RewindScope, error) {
5605 fields := strings.Fields(args)
5606 if len(fields) == 0 {
5607 if len(cps) == 0 {
5608 return 0, RewindBoth, fmt.Errorf("no checkpoints available")
5609 }
5610 return cps[len(cps)-1].Turn, RewindBoth, nil
5611 }
5612 turn, err := strconv.Atoi(fields[0])
5613 if err != nil {
5614 return 0, RewindBoth, fmt.Errorf("invalid turn: %w", err)
5615 }
5616 scope := RewindBoth
5617 if len(fields) >= 2 {
5618 switch strings.ToLower(fields[1]) {
5619 case "code":
5620 scope = RewindCode
5621 case "conversation":
5622 scope = RewindConversation
5623 case "both":
5624 scope = RewindBoth
5625 default:
5626 return 0, RewindBoth, fmt.Errorf("unknown scope %q", fields[1])
5627 }
5628 }
5629 return turn, scope, nil
5630 }
5631
5632 // requestApproval emits an ApprovalRequest and blocks until Approve(ID, …)
5633 // answers or ctx is cancelled. A prior session grant (or a bypass posture) for
5634 // the same approval scope short-circuits. Each prompt waits independently;
5635 // this method keeps the I/O (events, hooks, remember) out of the registry.
5636 func (c *Controller) requestApproval(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, error) {
5637 return c.requestApprovalWithReason(ctx, tool, subject, args, "")
5638 }
5639
5640 func (c *Controller) requestApprovalWithReason(ctx context.Context, tool, subject string, args json.RawMessage, reason string) (bool, bool, error) {
5641 return c.requestApprovalWithReasonOptions(ctx, tool, subject, args, reason, approvalDecisionOptions{})
5642 }
5643
5644 func (c *Controller) requestApprovalWithReasonOptions(ctx context.Context, tool, subject string, args json.RawMessage, reason string, opts approvalDecisionOptions) (bool, bool, error) {
5645 r, err := c.requestApprovalDecisionWithOptions(ctx, tool, subject, args, reason, opts)
5646 if err != nil {
5647 return false, false, err
5648 }
5649 // Plan approvals are one-shot — never persist a session grant for them, or
5650 // every future plan would auto-approve.
5651 if r.allow && r.session && !requiresFreshApprovalTool(tool) {
5652 c.permissionStateMu.Lock()
5653 c.approval.grantSession(tool, subject)
5654 c.permissionStateMu.Unlock()
5655 }
5656 if r.allow && r.persist && !requiresFreshApprovalTool(tool) && c.onRemember != nil {
5657 c.emitRememberResult(c.onRemember(permission.RememberRuleForScope(tool, subject)))
5658 }
5659 return r.allow, false, nil
5660 }
5661
5662 func (c *Controller) requestFreshApprovalDecision(ctx context.Context, tool, subject string, args json.RawMessage, reason string) (approvalReply, error) {
5663 return c.requestApprovalDecisionWithOptions(ctx, tool, subject, args, reason, approvalDecisionOptions{fresh: true})
5664 }
5665
5666 type approvalDecisionOptions struct {
5667 // fresh marks a user trust/business decision rather than an ordinary tool
5668 // permission. It may reuse an explicit session grant, but YOLO/auto approval
5669 // must not answer or drain the prompt.
5670 fresh bool
5671 // requireHuman marks an ordinary tool approval that Auto, an approved-plan
5672 // window, Guardian, or an allowing hook must not answer. Unlike fresh it
5673 // retains the ordinary four-choice UI and YOLO remains an explicit bypass.
5674 requireHuman bool
5675 }
5676
5677 func (c *Controller) requestApprovalDecisionWithOptions(ctx context.Context, tool, subject string, args json.RawMessage, reason string, opts approvalDecisionOptions) (approvalReply, error) {
5678 // YOLO/full access and the just-approved-plan execution window auto-allow
5679 // approval-gated tools without prompting. Plan approval is a user decision,
5680 // not a tool permission, so it deliberately stays interactive.
5681 if c.approval.preApprovedForDecisionOptions(tool, subject, args, opts.fresh, opts.requireHuman) {
5682 return approvalReply{allow: true}, nil
5683 }
5684
5685 // Claude's PermissionRequest contract answers the dialog on the plugin's
5686 // behalf (auto-allow/auto-deny) instead of merely observing it, so a
5687 // decision here must preempt the prompt rather than just notify — this
5688 // runs synchronously and before the dialog is shown. Native Reasonix
5689 // PermissionRequest hooks stay advisory-only (see claudePermissionBlocking).
5690 //
5691 // Hook auto-allow cannot replace a fresh-human decision. Interactive
5692 // YOLO may already have skipped remember/forget via preApproved. Deny
5693 // still applies universally — refusing is always safe to honor.
5694 if hookSubject, hookArgs, ok := permissionRequestHookPayload(tool, subject, args); ok {
5695 if decision, _ := c.hooks.PermissionRequest(ctx, tool, hookSubject, hookArgs); decision != nil {
5696 switch {
5697 case !*decision:
5698 return approvalReply{}, nil
5699 case !opts.fresh && !opts.requireHuman && !requiresFreshApprovalTool(tool):
5700 return approvalReply{allow: true}, nil
5701 }
5702 // An "allow" opinion on a fresh-human-required decision is
5703 // ignored; fall through to the normal interactive prompt.
5704 }
5705 }
5706
5707 c.approval.promptEmitMu.Lock()
5708 // Re-check at the publication boundary: a session grant may have landed
5709 // after the initial policy check.
5710 if c.approval.preApprovedForDecisionOptions(tool, subject, args, opts.fresh, opts.requireHuman) {
5711 c.approval.promptEmitMu.Unlock()
5712 return approvalReply{allow: true}, nil
5713 }
5714 var id string
5715 var reply chan approvalReply
5716 kind := ""
5717 if opts.fresh || opts.requireHuman || tool == planApprovalTool {
5718 if tool == planApprovalTool {
5719 kind = "plan"
5720 }
5721 id, reply = c.approval.registerDecisionKindWithInput(tool, subject, reason, args, opts.fresh, opts.requireHuman, kind, nil)
5722 ownerKind := PromptApproval
5723 if kind == "plan" {
5724 ownerKind = PromptPlan
5725 }
5726 c.registerOwnedPrompt(id, ownerKind)
5727 } else {
5728 id, reply = c.approval.registerWithInput(tool, subject, reason, args)
5729 c.registerOwnedPrompt(id, PromptApproval)
5730 }
5731
5732 if err := event.EmitChecked(c.sink, c.approvalRequestEvent(event.Approval{ID: id, Tool: tool, Subject: subject, Reason: reason, RawInput: append(json.RawMessage(nil), args...), Fresh: opts.fresh, Kind: kind})); err != nil {
5733 c.approval.promptEmitMu.Unlock()
5734 c.cancelOwnedPrompt(id)
5735 return approvalReply{}, fmt.Errorf("persist approval request: %w", err)
5736 }
5737 c.approval.promptEmitMu.Unlock()
5738 // The agent now needs the user's attention; a Notification hook can ping an
5739 // external channel (desktop notice, phone) while the run blocks on the reply.
5740 go c.hooks.Notification(ctx, approvalNotificationText(tool, subject), "permission_prompt")
5741
5742 waitCtx, cancelWait := c.approval.waitContext(ctx)
5743 defer cancelWait()
5744
5745 select {
5746 case r := <-reply:
5747 return r, nil
5748 case <-waitCtx.Done():
5749 c.cancelOwnedPrompt(id)
5750 return approvalReply{}, waitCtx.Err()
5751 }
5752 }
5753
5754 func (c *Controller) approvalRequestEvent(approval event.Approval) event.Event {
5755 approval.Generation = c.runtimeGeneration
5756 approval.PermissionRevision = c.permissionRevision.Load()
5757 if approval.TurnID == "" {
5758 approval.TurnID, _, _, _ = c.turnEventRuntimeStatus()
5759 }
5760 _, runtimeEpoch := c.promptIdentitySnapshot()
5761 if identity := c.bindOwnedPromptRouting(approval.ID, approval.TurnID, runtimeEpoch); identity.TurnID != "" {
5762 approval.TurnID = identity.TurnID
5763 }
5764 return event.Event{Kind: event.ApprovalRequest, TurnID: approval.TurnID, ItemID: approval.ID, Approval: approval}
5765 }
5766
5767 func (c *Controller) emitRememberResult(r RememberResult) {
5768 if r.Err != nil {
5769 c.sink.Emit(event.Event{
5770 Kind: event.Notice,
5771 Level: event.LevelWarn,
5772 Text: fmt.Sprintf(i18n.M.PermissionSaveFailedFmt, r.Rule, r.Err),
5773 })
5774 return
5775 }
5776 switch {
5777 case r.Saved:
5778 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PermissionSavedFmt, r.Path, r.Rule)})
5779 case strings.TrimSpace(r.CoveredBy) != "":
5780 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PermissionAlreadyAllowedFmt, r.Path, r.CoveredBy)})
5781 }
5782 }
5783
5784 func (c *Controller) emitPlanModeReadOnlyCommandTrustResult(r PlanModeReadOnlyCommandTrustResult) {
5785 prefix := strings.TrimSpace(r.Prefix)
5786 if r.Err != nil {
5787 c.sink.Emit(event.Event{
5788 Kind: event.Notice,
5789 Level: event.LevelWarn,
5790 Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustFailedFmt, prefix, r.Err),
5791 })
5792 return
5793 }
5794 switch {
5795 case r.Saved:
5796 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustSavedFmt, r.Path, prefix)})
5797 case strings.TrimSpace(r.CoveredBy) != "":
5798 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustAlreadyFmt, r.Path, r.CoveredBy)})
5799 }
5800 }
5801
5801 lines GO