| 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 |