mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
6149 lines
224 KiB
Go
6149 lines
224 KiB
Go
// Package control is the transport-agnostic session driver. A Controller owns
|
||
// the agent run loop and session lifecycle, takes commands (Send/Cancel/Approve/
|
||
// SetPlanMode/Compact/NewSession/…), and emits everything that happens —
|
||
// reasoning, tool calls, approvals, turn completion — as a typed event stream to
|
||
// a single event.Sink.
|
||
//
|
||
// The point is one orchestration layer behind every frontend: a terminal TUI, a
|
||
// desktop webview, or an HTTP/SSE server each drive the Controller identically
|
||
// (issue commands, render events) and none of them re-implement turn lifecycle,
|
||
// cancellation, or approval. The Controller depends on no frontend.
|
||
package control
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"slices"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"reasonix/internal/ablation"
|
||
"reasonix/internal/agent"
|
||
"reasonix/internal/autoresearch"
|
||
"reasonix/internal/billing"
|
||
"reasonix/internal/capability"
|
||
"reasonix/internal/checkpoint"
|
||
"reasonix/internal/command"
|
||
"reasonix/internal/config"
|
||
"reasonix/internal/event"
|
||
"reasonix/internal/evidence"
|
||
"reasonix/internal/extension"
|
||
"reasonix/internal/extension/dispatch"
|
||
"reasonix/internal/extension/uihub"
|
||
"reasonix/internal/goaleval"
|
||
"reasonix/internal/guardian"
|
||
"reasonix/internal/hook"
|
||
"reasonix/internal/i18n"
|
||
"reasonix/internal/jobs"
|
||
"reasonix/internal/memory"
|
||
"reasonix/internal/nilutil"
|
||
"reasonix/internal/permission"
|
||
"reasonix/internal/plugin"
|
||
"reasonix/internal/provider"
|
||
"reasonix/internal/recovery"
|
||
"reasonix/internal/sandbox"
|
||
"reasonix/internal/sessiontemp"
|
||
"reasonix/internal/shellrun"
|
||
"reasonix/internal/skill"
|
||
"reasonix/internal/store"
|
||
"reasonix/internal/taskmonitor"
|
||
"reasonix/internal/tool"
|
||
"reasonix/internal/workspacelease"
|
||
)
|
||
|
||
// ErrTurnRunning reports that a caller tried to start a second foreground turn
|
||
// while one is already active in the same Controller.
|
||
var ErrTurnRunning = errors.New("turn already running")
|
||
|
||
// errTurnRunningRotation and errRotationInProgress are returned by the
|
||
// session-rotation gate (beginRotation) when a rotation cannot proceed: a turn
|
||
// is in flight, or another rotation already holds the gate.
|
||
var (
|
||
errTurnRunningRotation = errors.New("cannot start a new session while a turn is running")
|
||
errRotationInProgress = errors.New("cannot start a new session while another session change is in progress")
|
||
)
|
||
|
||
// errNoSessionPath is returned by snapshot when a session has content to persist
|
||
// but no resolved session path — a misconfiguration (e.g. an unresolvable data
|
||
// dir in a bot deployment) that previously dropped conversations silently
|
||
// (#4414). Callers log it and continue; it must never be swallowed quietly.
|
||
var errNoSessionPath = errors.New("session has content but no session path; conversation cannot be persisted")
|
||
|
||
// Controller drives one chat session. Construct with New; drive with the command
|
||
// methods; observe through the Sink passed in Options.
|
||
type Controller struct {
|
||
runner agent.Runner
|
||
executor *agent.Agent
|
||
guardianSess *guardian.Session // nil when guardian is disabled
|
||
guardianPath string // persisted guardian session file ("" when disabled)
|
||
// recoveryGate is the shared Auto Guard state for this controller.
|
||
// nil when the feature is not wired for this controller.
|
||
recoveryGate *recovery.Gate
|
||
// evaluator is the bounded Goal completion evaluator consulted when the
|
||
// working model submits no update_goal report. nil fails closed: the goal
|
||
// pauses instead of defaulting to continue.
|
||
evaluator goaleval.Evaluator
|
||
// goalUsageTee accounts billable usage events into the active goal turn's
|
||
// observational token total. It wraps the public sink when the caller didn't provide one.
|
||
goalUsageTee *goalUsageTee
|
||
sink event.Sink
|
||
policy permission.Policy
|
||
// subagentGate is the shared gate every headless-only sub-agent surface
|
||
// reads from (see Options.SubagentGate). Nil when the caller didn't build
|
||
// one — sub-agents then keep whatever gate they were constructed with.
|
||
subagentGate *SharedHeadlessGate
|
||
|
||
label string
|
||
modelRef string
|
||
systemPrompt string
|
||
sessionDir string
|
||
commands atomic.Pointer[[]command.Command]
|
||
// skills owns the session's discovered skills (enabled subset, full set, and
|
||
// the reloadable stores) — the skills slice of the Capabilities concern. See
|
||
// skill.go.
|
||
skills skillSet
|
||
skillRunner skill.SubagentRunner
|
||
readOnlySkillRunner skill.SubagentRunner
|
||
skillProfile skill.ProfileResolver
|
||
slashSkillSeq atomic.Uint64
|
||
hooks *hook.Runner // session hook runner; nil-safe (no hooks configured)
|
||
// hookContexts carries one-shot lifecycle hook context into the next real
|
||
// user turn without changing the cache-stable system prompt.
|
||
hookContexts []string
|
||
// memory owns the loaded memory snapshot, the pending turn-tail notes queue,
|
||
// and write serialization behind its own locks, off c.mu — so a memory-panel
|
||
// save never stalls an approval or status poll. See memory.go.
|
||
memory memoryManager
|
||
cleanup func()
|
||
responseLanguage string
|
||
reasoningLanguage string
|
||
disableColdResumePrune bool // legacy; rewrite elision removed, still gates cold notice
|
||
// testCacheColdAfter overrides cacheColdAfter() in tests. Zero uses the
|
||
// vendor-aware resolution from config.
|
||
testCacheColdAfter time.Duration
|
||
|
||
shell sandbox.Shell // interpreter for user-invoked "!" commands; zero = auto
|
||
startedOnce bool // guards the one-shot SessionStart hook on first turn
|
||
closeOnce sync.Once // makes close idempotent under racing teardown paths
|
||
onRemember func(rule string) RememberResult // set via Options; invoked when user picks "always allow"
|
||
onRememberPlanModeReadOnlyCommand func(prefix string) PlanModeReadOnlyCommandTrustResult
|
||
sessionRecoveryMeta func(SessionRecoveryRequest) agent.BranchMeta
|
||
onSessionRecovered func(SessionRecoveryInfo) error
|
||
|
||
// balanceURL/balanceKey target the active provider's optional wallet-balance
|
||
// endpoint (empty when the provider declares none). Captured at build so a
|
||
// model/key switch — which rebuilds the controller — refreshes them.
|
||
balanceURL string
|
||
balanceKey string
|
||
balanceClient *http.Client
|
||
|
||
// jobs is the session-scoped background-job manager. The agent's background
|
||
// tools spawn into it; Compose drains its completion notes into the next turn;
|
||
// Close cancels its still-running jobs.
|
||
jobs *jobs.Manager
|
||
// workspaceLease is the Delivery writer owner shared with the executor.
|
||
// It is exposed only through a sanitized state snapshot for Desktop recovery.
|
||
workspaceLease *workspacelease.Owner
|
||
|
||
// mcp owns the session's live tool/plugin surface — the MCP plugin Host, the
|
||
// tool registry the executor reads each turn, and the session-scoped context a
|
||
// hot-added stdio server binds its subprocess to — behind its own lock, off
|
||
// c.mu. The Controller keeps the config-facing orchestration (persisting
|
||
// MCP entries to their global/project source on add/remove, building specs
|
||
// from entries). See mcp.go.
|
||
mcp mcpManager
|
||
mcpDefaultCallTimeout time.Duration
|
||
mcpConfigureSpec func(*plugin.Spec)
|
||
capabilityRuntime *agent.MCPCapabilityRuntime
|
||
|
||
// extensions is the frozen extension dispatcher for this controller
|
||
// generation, or nil when no v1 runtime packages are installed (the
|
||
// universal pre-dispatch fast path). It is installed before the controller
|
||
// starts serving (Options.Extensions or SetExtensions) and never swapped
|
||
// afterwards, so wiring points read it without locking.
|
||
extensions *dispatch.Dispatcher
|
||
// extensionUI is the host extension UI hub for this controller generation
|
||
// (stage 8a), or nil when no v1 runtime packages started. Installed via
|
||
// SetExtensionUI before serving and never swapped; readers take c.mu.
|
||
extensionUI *uihub.Hub
|
||
// providerResolver is the build's merged provider catalog (extension
|
||
// sidecar providers over the config/broker base), or nil when no sidecar
|
||
// declared providers. Immutable after New; ProviderCatalog reads it.
|
||
providerResolver provider.Resolver
|
||
|
||
// Capability routing (Delivery hybrid route + dual-model Planner proxy).
|
||
// Not part of the provider-visible prefix; only seeds the turn-scoped ledger
|
||
// and optional semantic router.
|
||
pluginCfg []config.PluginEntry
|
||
capCachedTools map[string][]plugin.CachedTool
|
||
capCacheKeyOK map[string]bool
|
||
semanticRouter *capability.SemanticRouter
|
||
capabilityAudit *capability.Audit
|
||
// capabilityProxy directs unready MCP candidates to use_capability in the
|
||
// transient route block (Delivery and dual-model Planner).
|
||
capabilityProxy bool
|
||
// proxyToolsFn returns live tools observed through use_capability without
|
||
// entering the provider-visible registry (Balanced dual-model Planner).
|
||
proxyToolsFn func() map[string][]plugin.CachedTool
|
||
runtimeProfile capability.Profile
|
||
ablation ablation.Set
|
||
|
||
// goals owns the active goal's FSM (status, intercepts, idle/turn counters)
|
||
// and its persistence, behind its own mutex so a per-turn goal save never
|
||
// stalls an approval or status poll on c.mu. See goal.go.
|
||
goals goalMachine
|
||
// autoResearch wraps the workspace autoresearch.Store as a strict-leaf
|
||
// collaborator; goal/task resolution stays on Controller. See
|
||
// autoresearch_manager.go.
|
||
autoResearch autoResearchManager
|
||
|
||
// workspaceRoot is the workspace root: the base for resolving @-refs and slash
|
||
// path refs, the working directory for user "!" shell commands and custom
|
||
// command discovery, and the guard root for checkpoint restore writes. It is
|
||
// surfaced to frontends via WorkspaceRoot().
|
||
workspaceRoot string
|
||
|
||
// externalFolderRefs maps session-generated @ tokens to user-dropped
|
||
// directories outside workspaceRoot. It is intentionally per-controller:
|
||
// dragging a folder authorizes that folder for this chat session only, without
|
||
// widening scoped @ resolution to arbitrary absolute paths.
|
||
externalFolderRefsMu sync.RWMutex
|
||
externalFolderRefs map[string]string
|
||
externalFolderToolRefs externalFolderToolRefs
|
||
|
||
// checkpoints owns the snapshot-based rewind bookkeeping (the per-session
|
||
// store, the monotonic turn counter, and the conversation-rewind boundary map)
|
||
// behind its own lock, off c.mu — so a boundary read for a rewind/fork never
|
||
// contends on the run-state lock. The Controller keeps the rewind/fork/summarize
|
||
// orchestration (truncating the session, restoring code, emitting events). See
|
||
// checkpoint.go.
|
||
checkpoints checkpointManager
|
||
// mutationObserver is the host-side file mutation observer for v2 checkpoints.
|
||
mutationObserver *checkpoint.MutationObserver
|
||
// sessionRevision increments on successful rewind/undo and is used as a
|
||
// prepare/commit freshness token.
|
||
sessionRevision int64
|
||
|
||
// approval owns the approval/ask prompt bookkeeping and the runtime approval
|
||
// posture (ask/auto/yolo, session grants, the just-approved-plan window)
|
||
// behind its own locks, off c.mu. The Controller keeps the I/O orchestration
|
||
// (requestApproval/Ask emit events + fire hooks + rebuild the executor gate).
|
||
// See approval.go.
|
||
approval approvalManager
|
||
|
||
// mu guards the run state; every critical section under it is short and
|
||
// non-blocking.
|
||
mu sync.Mutex
|
||
cancel context.CancelFunc
|
||
running bool
|
||
finishing bool // TurnDone is still being delivered; park a replacement turn
|
||
canceling bool
|
||
// closed marks the controller as terminally torn down (close() ran). It
|
||
// seals turn admission: without it, a submit arriving AFTER close cleared
|
||
// the parked queue — but while a still-running turn's TurnDone delivery
|
||
// was in flight — would park again and then start against freed resources
|
||
// when the window closed.
|
||
closed bool
|
||
// parkedTurns holds turn bodies that arrived during the finishing window,
|
||
// FIFO. finishGuardedTurn starts the oldest one as it closes the window
|
||
// (see runGuarded/finishGuardedTurn); close() discards any remainder.
|
||
parkedTurns []func(ctx context.Context) error
|
||
// rotating is set under mu while NewSession/ClearSession swap the executor
|
||
// session out. Checking running once and then swapping later leaves a
|
||
// TOCTOU window: a turn can start (running=false at check time) during the
|
||
// intervening Snapshot() and then have its live session replaced. running
|
||
// and rotating are mutually exclusive gates — a turn refuses to start while
|
||
// a rotation is in progress, and a rotation refuses to start while a turn
|
||
// runs — so the run loop's session reference cannot change under it.
|
||
rotating bool
|
||
autosaveWG sync.WaitGroup
|
||
planMode bool
|
||
sessionPath string
|
||
// sessionTemp owns the logical-session private temporary directory shared
|
||
// by Bash calls. Retained for this Controller's lifetime; rotated on
|
||
// /new, /clear, resume of another session, and branch switches.
|
||
sessionTemp *sessiontemp.Manager
|
||
// recoveryDepthCapNotices records session paths that already surfaced the
|
||
// depth-cap recovery warning. Repeated saves on the same conflict copy are
|
||
// diagnostic noise for the UI; keep logging/diagnostics, but emit the user
|
||
// notice once per controller/session path.
|
||
recoveryDepthCapNotices map[string]bool
|
||
// snapshotMu serializes the whole save/recovery handoff for this controller.
|
||
// Agent-level path locks protect individual files, but recovery also moves
|
||
// controller-owned state (sessionPath, guardianPath, checkpoints, rewrite
|
||
// baseline). Letting a second snapshot observe that migration halfway through
|
||
// can turn one conflict into a recovery cascade. Session/path swaps
|
||
// (new/clear/fork/branch/switch/resume/SetSessionPath) hold it for the same
|
||
// reason: a save that reads the old path but the new session would write one
|
||
// transcript's messages into another's file, or manufacture a bogus conflict.
|
||
// Not reentrant — never call snapshot (or anything that snapshots, such as
|
||
// recoverInterruptedTurn or maybeColdResumePrune) while holding it.
|
||
snapshotMu sync.Mutex
|
||
// turn counts model turns this session, passed to hooks in their payload.
|
||
turn int
|
||
|
||
displayRecorder func(content, display string)
|
||
}
|
||
|
||
type approvalReply struct {
|
||
allow bool
|
||
session bool
|
||
persist bool // true = write "always allow" rule to config
|
||
}
|
||
|
||
type pendingApproval struct {
|
||
id string
|
||
tool string
|
||
subject string
|
||
reason string
|
||
rawInput json.RawMessage
|
||
fresh bool
|
||
requireHuman bool
|
||
autoDrain bool
|
||
kind string // tool | plan | recovery; empty = tool
|
||
recovery *event.RecoveryApproval
|
||
reply chan approvalReply
|
||
}
|
||
|
||
// pendingAsk is an in-flight ask question batch. questions is retained so the
|
||
// AskRequest can be re-emitted to a frontend that reconnected after the original
|
||
// event (see ReplayPendingPrompts).
|
||
type pendingAsk struct {
|
||
questions []event.AskQuestion
|
||
reply chan []event.AskAnswer
|
||
}
|
||
|
||
type AutoResearchEvidenceInput struct {
|
||
ID string
|
||
Kind string
|
||
Summary string
|
||
Source string
|
||
Command string
|
||
Paths []string
|
||
Accepted bool
|
||
}
|
||
|
||
type plannerSessionResetter interface {
|
||
ResetPlannerSession()
|
||
}
|
||
|
||
// RuntimeStatus is the frontend-facing snapshot of foreground turn state. It is
|
||
// intentionally more explicit than the legacy Running bool so UI code can
|
||
// distinguish a cancellable foreground turn from pending prompts and background
|
||
// jobs.
|
||
type RuntimeStatus struct {
|
||
Running bool
|
||
PendingPrompt bool
|
||
BackgroundJobs int
|
||
CancelRequested bool
|
||
Cancellable bool
|
||
}
|
||
|
||
const (
|
||
ToolApprovalAsk = "ask"
|
||
ToolApprovalAuto = "auto"
|
||
ToolApprovalDontAsk = "dontAsk"
|
||
ToolApprovalYolo = "yolo"
|
||
)
|
||
|
||
const (
|
||
memoryRememberTool = "remember"
|
||
memoryForgetTool = "forget"
|
||
)
|
||
|
||
// RememberResult describes what happened when an approval rule was persisted.
|
||
type RememberResult struct {
|
||
Rule string
|
||
Path string
|
||
Saved bool
|
||
CoveredBy string
|
||
Err error
|
||
}
|
||
|
||
// PlanModeReadOnlyCommandTrustResult describes what happened when a trusted bash
|
||
// command prefix was persisted for plan-mode research.
|
||
type PlanModeReadOnlyCommandTrustResult struct {
|
||
Prefix string
|
||
Path string
|
||
Saved bool
|
||
CoveredBy string
|
||
Err error
|
||
}
|
||
|
||
type SessionRecoveryRequest struct {
|
||
OriginalPath string
|
||
Reason string
|
||
Mode string
|
||
}
|
||
|
||
type SessionRecoveryInfo struct {
|
||
OriginalPath string
|
||
RecoveryPath string
|
||
Existing bool
|
||
Reason string
|
||
Meta agent.BranchMeta
|
||
}
|
||
|
||
type externalFolderToolRefs interface {
|
||
RegisterReadRoot(token, root string)
|
||
}
|
||
|
||
// Options carries the already-built pieces setup assembles. Lifecycle metadata
|
||
// lets the controller mint and rotate session files; Host/Commands are surfaced
|
||
// to frontends that resolve MCP prompts and slash commands.
|
||
type Options struct {
|
||
Runner agent.Runner
|
||
Executor *agent.Agent
|
||
Guardian *guardian.Session
|
||
// RecoveryReviewer is the optional independent recovery reviewer (nil =
|
||
// rule-only path with fail-closed human confirmation for ambiguous cases).
|
||
RecoveryReviewer recovery.Reviewer
|
||
// RecoveryHeadless blocks mutations that need confirmation instead of
|
||
// waiting forever when no human decision channel exists.
|
||
RecoveryHeadless bool
|
||
// GoalEvaluator is the optional bounded Goal completion evaluator consulted
|
||
// when the working model submits no update_goal report. nil fails closed:
|
||
// the goal pauses instead of defaulting to continue.
|
||
GoalEvaluator goaleval.Evaluator
|
||
Sink event.Sink
|
||
Policy permission.Policy
|
||
// SubagentGate is the shared, mutable gate every headless-only sub-agent
|
||
// surface (task, writer-capable skill sub-agents, planner) reads from. Nil
|
||
// disables gating for those surfaces same as before this field existed.
|
||
// SetToolApprovalMode and ApplyHeadlessApprovalMode call Update on it so a
|
||
// runtime approval-mode switch reaches sub-agents, not just the parent
|
||
// executor's own gate.
|
||
SubagentGate *SharedHeadlessGate
|
||
Label string
|
||
ModelRef string
|
||
SystemPrompt string
|
||
SessionDir string
|
||
SessionPath string
|
||
Host *plugin.Host
|
||
Commands []command.Command
|
||
Skills []skill.Skill
|
||
AllSkills []skill.Skill
|
||
SkillStore *skill.Store
|
||
AllSkillStore *skill.Store
|
||
// SkillRunner executes a runAs=subagent skill in an isolated child loop.
|
||
// ReadOnlySkillRunner is reserved for explicitly read-only entry points;
|
||
// Plan itself is a workflow instruction and uses SkillRunner with the shared
|
||
// Permissions/Sandbox gate. SkillProfile supplies model/effort display
|
||
// metadata for the synthetic top-level run_skill event.
|
||
SkillRunner skill.SubagentRunner
|
||
ReadOnlySkillRunner skill.SubagentRunner
|
||
SkillProfile skill.ProfileResolver
|
||
Hooks *hook.Runner
|
||
Memory *memory.Set
|
||
Cleanup func()
|
||
// BalanceURL/BalanceKey wire the active provider's optional wallet-balance
|
||
// endpoint and bearer key; empty when the provider declares no balance_url.
|
||
BalanceURL string
|
||
BalanceKey string
|
||
BalanceClient *http.Client
|
||
// Jobs is the session-scoped background-job manager (nil disables background jobs).
|
||
Jobs *jobs.Manager
|
||
// WorkspaceLease is the Delivery writer owner shared with the executor.
|
||
WorkspaceLease *workspacelease.Owner
|
||
// Registry is the executor's live tool set, and PluginCtx the session-scoped
|
||
// context; both are needed for hot-adding MCP servers via AddMCPServer.
|
||
Registry *tool.Registry
|
||
PluginCtx context.Context
|
||
// MCPDefaultCallTimeout is the global MCP call cap used by hot-connected
|
||
// servers when they do not declare a server- or tool-specific override.
|
||
MCPDefaultCallTimeout time.Duration
|
||
// MCPConfigureSpec injects host-local launch and isolation policy into every
|
||
// hot-connected server without persisting that state in project config.
|
||
MCPConfigureSpec func(*plugin.Spec)
|
||
// CapabilityRuntime is the controller-local authoritative MCP inventory used
|
||
// by stable use_capability frontends. It shares Host processes with sibling
|
||
// tabs but never shares their enabled/disabled state.
|
||
CapabilityRuntime *agent.MCPCapabilityRuntime
|
||
// WorkspaceRoot is the project root checkpoint restores are confined to ("" =
|
||
// no confinement). Frontends pass the cwd they launched the session in.
|
||
WorkspaceRoot string
|
||
ExternalFolderToolRefs externalFolderToolRefs
|
||
// ResponseLanguage controls final-answer language preference. Empty/auto
|
||
// means no transient injection because the stable language policy follows the
|
||
// current user turn.
|
||
ResponseLanguage string
|
||
// ReasoningLanguage controls visible reasoning language preference. Empty/auto
|
||
// means no transient injection because the stable language policy already
|
||
// follows the conversation language.
|
||
ReasoningLanguage string
|
||
// DisableColdResumePrune suppresses the cold-resume cache-state notice.
|
||
// Resume never rewrites history regardless of this flag.
|
||
DisableColdResumePrune bool
|
||
// Shell is the interpreter user-invoked "!" commands run under, so /shell
|
||
// matches the agent's configured [tools.shell] choice. Zero value = auto.
|
||
Shell sandbox.Shell
|
||
// OnRemember, when set, is invoked with a new allow rule the user chose to
|
||
// persist to disk (e.g. "Bash(go test:*)"). The callback is wired into the
|
||
// permission Gate on EnableInteractiveApproval.
|
||
OnRemember func(rule string) RememberResult
|
||
// OnRememberPlanModeReadOnlyCommand persists a bash command prefix as trusted
|
||
// read-only when the user chooses "always allow" from the plan-mode trust
|
||
// prompt.
|
||
OnRememberPlanModeReadOnlyCommand func(prefix string) PlanModeReadOnlyCommandTrustResult
|
||
// SessionRecoveryMeta lets a frontend attach scope/topic/profile metadata to
|
||
// an automatic recovery branch before it is written.
|
||
SessionRecoveryMeta func(SessionRecoveryRequest) agent.BranchMeta
|
||
// OnSessionRecovered is called after a stale runtime's transcript has been
|
||
// saved as a recovery branch, before the controller commits to that branch.
|
||
OnSessionRecovered func(SessionRecoveryInfo) error
|
||
// ApprovalTimeout bounds how long a tool-approval or ask prompt blocks waiting
|
||
// for a user decision. Zero (default) waits forever — right for an interactive
|
||
// terminal. Bot/headless frontends set a positive value so an unanswered
|
||
// prompt can't wedge the session indefinitely (#4626, #4402).
|
||
ApprovalTimeout time.Duration
|
||
// RuntimeProfile selects capability routing/filtering behavior. Empty keeps
|
||
// the backward-compatible Balanced profile.
|
||
RuntimeProfile capability.Profile
|
||
// Extensions is the frozen extension dispatcher for this controller
|
||
// generation (Extension Protocol v1, stage 6b1). Nil means no v1 runtime
|
||
// packages are installed: every extension wiring point takes an untouched
|
||
// fast path. Boot installs it through SetExtensions because sidecars (and
|
||
// therefore the dispatcher) only exist after snapshot assembly, which runs
|
||
// after New.
|
||
Extensions *dispatch.Dispatcher
|
||
// ProviderResolver is the build's merged provider catalog — extension
|
||
// sidecar providers folded over the config/broker base (stage 7). Nil when
|
||
// no v1 runtime sidecar declared providers; ProviderCatalog then returns
|
||
// nil and frontends enumerate providers from config alone, as before.
|
||
ProviderResolver provider.Resolver
|
||
// Ablation switches subsystems off for a benchmark arm. The zero value runs
|
||
// everything.
|
||
Ablation ablation.Set
|
||
// SessionTemp is the logical-session private temporary directory manager
|
||
// shared by sandboxed Bash calls. Nil creates a fresh Manager owned by this
|
||
// Controller. Hot rebuilds pass the previous Controller's Manager so the
|
||
// temporary directory survives model/settings swaps.
|
||
SessionTemp *sessiontemp.Manager
|
||
}
|
||
|
||
// New builds a Controller. A nil Sink is replaced with event.Discard. When the
|
||
// caller did not already provide a goalUsageTee (see NewGoalUsageTee), the
|
||
// public sink is wrapped in one so billable usage can be accounted to Goal
|
||
// budgets; frontends observe the same forwarded stream either way.
|
||
func New(opts Options) *Controller {
|
||
sink := opts.Sink
|
||
if nilutil.IsNil(sink) {
|
||
sink = event.Discard
|
||
}
|
||
usageTee, ok := sink.(*goalUsageTee)
|
||
if !ok {
|
||
usageTee = NewGoalUsageTee(sink).(*goalUsageTee)
|
||
sink = usageTee
|
||
}
|
||
pluginCtx := opts.PluginCtx
|
||
if pluginCtx == nil {
|
||
pluginCtx = context.Background()
|
||
}
|
||
runtimeProfile := opts.RuntimeProfile
|
||
if runtimeProfile == "" {
|
||
runtimeProfile = capability.ProfileBalanced
|
||
}
|
||
if opts.Hooks != nil {
|
||
opts.Hooks.SetSessionID(agent.BranchID(opts.SessionPath))
|
||
}
|
||
c := &Controller{
|
||
runner: opts.Runner,
|
||
executor: opts.Executor,
|
||
guardianSess: opts.Guardian,
|
||
guardianPath: guardian.PathFor(opts.SessionPath),
|
||
evaluator: opts.GoalEvaluator,
|
||
goalUsageTee: usageTee,
|
||
sink: sink,
|
||
policy: opts.Policy,
|
||
subagentGate: opts.SubagentGate,
|
||
label: opts.Label,
|
||
modelRef: opts.ModelRef,
|
||
systemPrompt: opts.SystemPrompt,
|
||
sessionDir: opts.SessionDir,
|
||
sessionPath: opts.SessionPath,
|
||
commands: atomic.Pointer[[]command.Command]{},
|
||
skills: newSkillSet(opts.Skills, opts.AllSkills, opts.SkillStore, opts.AllSkillStore),
|
||
skillRunner: opts.SkillRunner,
|
||
readOnlySkillRunner: opts.ReadOnlySkillRunner,
|
||
skillProfile: opts.SkillProfile,
|
||
hooks: opts.Hooks,
|
||
memory: newMemoryManager(opts.Memory),
|
||
cleanup: opts.Cleanup,
|
||
responseLanguage: config.NormalizeLanguage(opts.ResponseLanguage),
|
||
reasoningLanguage: config.NormalizeReasoningLanguage(opts.ReasoningLanguage),
|
||
disableColdResumePrune: opts.DisableColdResumePrune,
|
||
shell: opts.Shell,
|
||
onRemember: opts.OnRemember,
|
||
onRememberPlanModeReadOnlyCommand: opts.OnRememberPlanModeReadOnlyCommand,
|
||
sessionRecoveryMeta: opts.SessionRecoveryMeta,
|
||
onSessionRecovered: opts.OnSessionRecovered,
|
||
balanceURL: opts.BalanceURL,
|
||
balanceKey: opts.BalanceKey,
|
||
balanceClient: opts.BalanceClient,
|
||
jobs: opts.Jobs,
|
||
workspaceLease: opts.WorkspaceLease,
|
||
mcp: newMcpManager(opts.Host, opts.Registry, pluginCtx),
|
||
mcpDefaultCallTimeout: opts.MCPDefaultCallTimeout,
|
||
mcpConfigureSpec: opts.MCPConfigureSpec,
|
||
capabilityRuntime: opts.CapabilityRuntime,
|
||
runtimeProfile: runtimeProfile,
|
||
ablation: opts.Ablation,
|
||
workspaceRoot: opts.WorkspaceRoot,
|
||
externalFolderToolRefs: opts.ExternalFolderToolRefs,
|
||
providerResolver: opts.ProviderResolver,
|
||
approval: newApprovalManager(opts.Policy, ToolApprovalAsk, opts.ApprovalTimeout),
|
||
}
|
||
// Session-private temporary directory: reuse a shared Manager on hot
|
||
// rebuild, otherwise create one. Retain so ReleaseResources/Close drop the
|
||
// owner reference without racing a replacement Controller.
|
||
if opts.SessionTemp != nil {
|
||
c.sessionTemp = opts.SessionTemp
|
||
} else {
|
||
c.sessionTemp = sessiontemp.New()
|
||
}
|
||
c.sessionTemp.Retain()
|
||
|
||
if strings.TrimSpace(opts.WorkspaceRoot) != "" {
|
||
c.autoResearch = autoResearchManager{store: autoresearch.NewStore(opts.WorkspaceRoot)}
|
||
}
|
||
if opts.Extensions != nil {
|
||
c.extensions = opts.Extensions
|
||
c.sink = newFrontendEventSink(c.sink, opts.Extensions)
|
||
if c.executor != nil {
|
||
c.executor.SetExtensions(opts.Extensions)
|
||
}
|
||
}
|
||
// Checkpoints: bind a store to the session and route writer pre-edits into it.
|
||
c.rebindCheckpoints(opts.SessionPath)
|
||
c.setActiveJobSession(opts.SessionPath)
|
||
cmdsInit := opts.Commands
|
||
c.commands.Store(&cmdsInit)
|
||
if c.executor != nil {
|
||
c.wireMutationObserver()
|
||
c.executor.SetMemoryQueue(c)
|
||
}
|
||
// Auto Guard is built into Auto. Ask and YOLO bypass it through the mode
|
||
// provider, so no separate enablement state is needed.
|
||
c.initRecoveryGate(opts.RecoveryReviewer, opts.RecoveryHeadless)
|
||
|
||
// Task monitoring: record background-job lifecycle into the project-local
|
||
// task store so CLI, Desktop, scripts, and future clients observe the same
|
||
// state/event evidence. The recorder swallows its own failures — monitoring
|
||
// must never affect the agent pipeline. The session id is resolved lazily
|
||
// because the session path is only fixed once the first turn begins.
|
||
if c.jobs != nil && c.workspaceRoot != "" {
|
||
c.jobs.SetTaskRecorder(taskmonitor.NewTaskRecorder(
|
||
taskmonitor.NewFileStore(filepath.Join(".reasonix", "tasks")),
|
||
c.workspaceRoot,
|
||
func() string { return c.parentSessionID() },
|
||
))
|
||
}
|
||
return c
|
||
}
|
||
|
||
// SetDisplayRecorder installs an optional hook used by frontends that persist a
|
||
// shorter user-facing transcript than the fully composed model prompt.
|
||
func (c *Controller) SetDisplayRecorder(fn func(content, display string)) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.displayRecorder = fn
|
||
}
|
||
|
||
// SetExtensions installs the extension dispatcher after construction. Boot
|
||
// uses it because sidecars — and therefore the dispatcher — only exist after
|
||
// snapshot assembly, which runs after New. It must be called before the
|
||
// controller starts serving turns: c.sink is swapped here and emission call
|
||
// sites read it without locking. The first non-nil install wins; a controller
|
||
// generation never swaps dispatchers. Nil is a no-op (the pre-dispatch path).
|
||
// The executor agent receives the same dispatcher so the run loop consults
|
||
// the agent-side intercept points (stage 6b2).
|
||
func (c *Controller) SetExtensions(d *dispatch.Dispatcher) {
|
||
if d == nil {
|
||
return
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if c.extensions != nil {
|
||
return
|
||
}
|
||
c.extensions = d
|
||
c.sink = newFrontendEventSink(c.sink, d)
|
||
if c.executor != nil {
|
||
c.executor.SetExtensions(d)
|
||
}
|
||
}
|
||
|
||
// ApplyExtensionSystemPrompt swaps the executor to a fresh session carrying
|
||
// the extension strategy's final system prompt and makes it the controller's
|
||
// rotation prompt, so /new and /clear keep the strategy-composed prompt too.
|
||
// Boot calls it when a system_prompt.build replacement changed the prompt
|
||
// after the controller (and its session) was built with the host-composed
|
||
// one. It must run before any turn or history resume: the fresh session holds
|
||
// only the system message, so a later resume cleanly layers history on top.
|
||
func (c *Controller) ApplyExtensionSystemPrompt(prompt string) {
|
||
if c == nil || c.executor == nil {
|
||
return
|
||
}
|
||
c.mu.Lock()
|
||
c.systemPrompt = prompt
|
||
c.mu.Unlock()
|
||
c.executor.SetSession(agent.NewSession(prompt))
|
||
}
|
||
|
||
// SetOnSessionRecovered installs the ownership handoff invoked before the
|
||
// controller commits to an automatically created recovery branch. Frontends
|
||
// that acquire their session owner after controller construction (for example
|
||
// reasonix serve) use this before publishing the controller.
|
||
func (c *Controller) SetOnSessionRecovered(fn func(SessionRecoveryInfo) error) {
|
||
if c == nil {
|
||
return
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
c.onSessionRecovered = fn
|
||
}
|
||
|
||
func (c *Controller) sessionRecoveredHandler() func(SessionRecoveryInfo) error {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return c.onSessionRecovered
|
||
}
|
||
|
||
func (c *Controller) recordDisplay(content, display string) {
|
||
if strings.TrimSpace(display) == "" || content == display {
|
||
return
|
||
}
|
||
c.mu.Lock()
|
||
record := c.displayRecorder
|
||
c.mu.Unlock()
|
||
if record != nil {
|
||
record(content, display)
|
||
}
|
||
}
|
||
|
||
// ToolContractEntries returns a stable snapshot of the executor's live tool
|
||
// contract: provider-visible names, descriptions, canonical schemas, and
|
||
// read-only flags. It is intended for diagnostics and regression tests.
|
||
func (c *Controller) ToolContractEntries() []tool.ContractEntry {
|
||
if c == nil {
|
||
return nil
|
||
}
|
||
reg := c.mcp.registry()
|
||
if reg == nil {
|
||
return nil
|
||
}
|
||
return reg.ContractEntries()
|
||
}
|
||
|
||
// ProviderCatalog returns the session's merged provider catalog: the config
|
||
// (or broker) base plus every provider a live extension sidecar declared,
|
||
// keyed by ref — extension refs carry their plugin/<plugin>/<provider>/<model>
|
||
// namespace. Nil when no sidecar declared providers, so frontends can tell
|
||
// "enumerate config only" apart from "the extension catalog is empty".
|
||
func (c *Controller) ProviderCatalog() []provider.Descriptor {
|
||
if c == nil || c.providerResolver == nil {
|
||
return nil
|
||
}
|
||
return c.providerResolver.Catalog()
|
||
}
|
||
|
||
func (c *Controller) recordDisplayForNewUser(startMessages int, display string) {
|
||
if strings.TrimSpace(display) == "" {
|
||
return
|
||
}
|
||
msgs := c.History()
|
||
if startMessages > len(msgs) {
|
||
startMessages = len(msgs)
|
||
}
|
||
for _, m := range msgs[startMessages:] {
|
||
if m.Role == provider.RoleUser {
|
||
c.recordDisplay(m.Content, display)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
func (c *Controller) markEditedForNewUser(startMessages int, original string) {
|
||
if strings.TrimSpace(original) == "" || c.executor == nil {
|
||
return
|
||
}
|
||
s := c.executor.Session()
|
||
msgs := s.Snapshot()
|
||
if startMessages > len(msgs) {
|
||
startMessages = len(msgs)
|
||
}
|
||
for i := startMessages; i < len(msgs); i++ {
|
||
if msgs[i].Role != provider.RoleUser {
|
||
continue
|
||
}
|
||
if agent.UserMessageText(msgs[i]) == original {
|
||
return
|
||
}
|
||
msgs[i].Edited = true
|
||
msgs[i].Original = original
|
||
// A periodic autosave may already contain this user message without its
|
||
// local edit metadata. Classify the mutation atomically so the turn-end
|
||
// save performs an owned rewrite instead of forking a bogus
|
||
// same-revision recovery branch. Edited/Original are local-only display
|
||
// metadata (provider requests ignore them), so this must not report a
|
||
// cache-prefix change — ReplaceLocalMetadata, not Rewrite.
|
||
s.ReplaceLocalMetadata(msgs)
|
||
return
|
||
}
|
||
}
|
||
|
||
// ckptDir derives a session's checkpoint directory from its file path
|
||
// (…/<id>.jsonl → …/<id>.ckpt). Empty path → empty (in-memory checkpoints).
|
||
func ckptDir(sessionPath string) string {
|
||
return store.SessionCheckpointDir(sessionPath)
|
||
}
|
||
|
||
// rebindCheckpoints points the store at the (possibly new) session, loading any
|
||
// checkpoints already on disk, and resets the turn boundaries. Called on
|
||
// construction and whenever the session path changes (NewSession/Resume/SetSessionPath).
|
||
// Also re-wires the mutation observer so capture targets the new store.
|
||
func (c *Controller) rebindCheckpoints(sessionPath string) {
|
||
c.goals.setStatePath(goalStatePath(sessionPath))
|
||
c.checkpoints.rebind(ckptDir(sessionPath), c.workspaceRoot)
|
||
if c.executor != nil {
|
||
c.wireMutationObserver()
|
||
}
|
||
}
|
||
|
||
// beginCheckpoint opens a checkpoint for the turn about to run, recording the
|
||
// current message count as the conversation-rewind boundary. Called at the top of
|
||
// runTurn, before the user message is appended.
|
||
func (c *Controller) beginCheckpoint(input string) {
|
||
if c.executor == nil {
|
||
return
|
||
}
|
||
atomic.AddInt64(&c.sessionRevision, 1)
|
||
c.checkpoints.beginWithObserver(input, len(c.executor.Session().Messages), c.mutationObserver)
|
||
}
|
||
|
||
// commands (frontend → controller)
|
||
|
||
// admissionResult classifies what runGuarded did with a turn body.
|
||
type admissionResult int
|
||
|
||
const (
|
||
// turnStarted: admission was open; the turn is running now.
|
||
turnStarted admissionResult = iota
|
||
// turnParked: the body landed inside the finishing window (TurnDone was
|
||
// being delivered) and will start the moment the window closes. From the
|
||
// caller's perspective the turn WILL run — nothing was lost.
|
||
turnParked
|
||
// turnDroppedRunning: a turn is genuinely in flight. Deliberately silent,
|
||
// as before: interactive frontends prevent this with their own
|
||
// steer/queue UX, and internal opportunistic callers (goal-loop
|
||
// continuations, replays) rely on a quiet no-op.
|
||
turnDroppedRunning
|
||
// turnDroppedRotating: the executor session is being swapped out
|
||
// (NewSession/ClearSession). The input's intended session is ambiguous,
|
||
// so it is refused with a user-visible Notice asking to resend rather
|
||
// than silently running against a session the user didn't see.
|
||
turnDroppedRotating
|
||
// turnDroppedClosed: the controller has been closed. Deliberately silent:
|
||
// this controller's transports are being (or have been) torn down and the
|
||
// input's home is the replacement controller the host swaps in — a Notice
|
||
// here would go to a dead surface.
|
||
turnDroppedClosed
|
||
)
|
||
|
||
// runGuarded runs body on a background goroutine under a fresh cancellable
|
||
// context, guarding against concurrent turns and emitting a TurnDone event when
|
||
// it finishes (Err set on failure; nil also for a user Cancel).
|
||
//
|
||
// Admission is NOT first-come-first-served across all states — see
|
||
// admissionResult. In particular, a body arriving during the finishing window
|
||
// is parked, not dropped: TurnDone is emitted inside that window, so every
|
||
// caller that reacts to TurnDone by submitting again (a frontend's queued
|
||
// auto-send, a bot, a fast Enter) would otherwise race a silent drop. That
|
||
// exact loss was observed in CI and reproduced on a clean main-v2 worktree,
|
||
// and the desktop composer already carries a workaround gating its auto-send
|
||
// on submitDisabled rather than turn_done (Composer.tsx).
|
||
func (c *Controller) runGuarded(body func(ctx context.Context) error) admissionResult {
|
||
return c.admitGuardedTurn(body, false)
|
||
}
|
||
|
||
// runGuardedOrPark admits like runGuarded but parks the body while another
|
||
// turn is running instead of using the deliberately-silent running drop.
|
||
// Reserved for inputs that are the user's own words (the steer fallback):
|
||
// the FIFO drain in finishGuardedTurn delivers them the moment the current
|
||
// turn finishes.
|
||
func (c *Controller) runGuardedOrPark(body func(ctx context.Context) error) admissionResult {
|
||
return c.admitGuardedTurn(body, true)
|
||
}
|
||
|
||
func (c *Controller) admitGuardedTurn(body func(ctx context.Context) error, parkWhileRunning bool) admissionResult {
|
||
c.mu.Lock()
|
||
if c.closed {
|
||
c.mu.Unlock()
|
||
return turnDroppedClosed
|
||
}
|
||
if c.rotating {
|
||
c.mu.Unlock()
|
||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "input was not accepted: the session is being switched — please resend"})
|
||
return turnDroppedRotating
|
||
}
|
||
if c.running {
|
||
if parkWhileRunning {
|
||
c.parkedTurns = append(c.parkedTurns, body)
|
||
c.mu.Unlock()
|
||
return turnParked
|
||
}
|
||
c.mu.Unlock()
|
||
return turnDroppedRunning
|
||
}
|
||
if c.finishing {
|
||
c.parkedTurns = append(c.parkedTurns, body)
|
||
c.mu.Unlock()
|
||
return turnParked
|
||
}
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
c.cancel = cancel
|
||
c.running = true
|
||
c.canceling = false
|
||
c.mu.Unlock()
|
||
c.spawnGuardedTurn(ctx, cancel, body)
|
||
return turnStarted
|
||
}
|
||
|
||
// spawnGuardedTurn launches an admitted turn body plus its autosave companion.
|
||
// The caller must already have claimed admission (running=true) under c.mu.
|
||
func (c *Controller) spawnGuardedTurn(ctx context.Context, cancel context.CancelFunc, body func(ctx context.Context) error) {
|
||
c.autosaveWG.Go(func() {
|
||
c.autosaveWhileRunning(ctx)
|
||
})
|
||
go func() {
|
||
defer cancel()
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
c.finishGuardedTurn(fmt.Errorf("internal error: %v", r))
|
||
}
|
||
}()
|
||
err := body(ctx)
|
||
c.finishGuardedTurn(explainError(err))
|
||
}()
|
||
}
|
||
|
||
// finishGuardedTurn keeps admission closed while TurnDone is delivered. The
|
||
// sink fan-out may detach per-turn transports; allowing a replacement turn in
|
||
// after running=false but before that fan-out completed let the old completion
|
||
// clear or inherit the replacement turn's transport.
|
||
//
|
||
// When the window closes, the oldest parked turn (if any) is started under the
|
||
// SAME critical section that clears finishing: opening the gate first and then
|
||
// re-admitting would let an unrelated submit slip in ahead and bounce the
|
||
// parked turn back to a drop. Remaining parked turns drain one per
|
||
// finishGuardedTurn, preserving FIFO order. Rotation cannot interleave here:
|
||
// beginRotation refuses while running or finishing, and the drain flips
|
||
// finishing directly into running.
|
||
func (c *Controller) finishGuardedTurn(err error) {
|
||
c.memory.clearAutoRemember()
|
||
c.mu.Lock()
|
||
cancelRequested := c.canceling
|
||
c.running = false
|
||
// A live controller keeps admission closed until TurnDone fan-out finishes.
|
||
// Close has already sealed admission permanently, so a late completion must
|
||
// not resurrect a finishing state after teardown.
|
||
c.finishing = !c.closed
|
||
c.cancel = nil
|
||
c.canceling = false
|
||
c.mu.Unlock()
|
||
|
||
defer func() {
|
||
c.mu.Lock()
|
||
c.finishing = false
|
||
if c.closed || len(c.parkedTurns) == 0 {
|
||
// A closed controller must not start a parked turn against freed
|
||
// resources; close() also cleared the queue, this guards the
|
||
// close-raced-with-delivery ordering.
|
||
c.mu.Unlock()
|
||
return
|
||
}
|
||
next := c.parkedTurns[0]
|
||
c.parkedTurns = c.parkedTurns[1:]
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
c.cancel = cancel
|
||
c.running = true
|
||
c.canceling = false
|
||
c.mu.Unlock()
|
||
c.spawnGuardedTurn(ctx, cancel, next)
|
||
}()
|
||
done := event.Event{Kind: event.TurnDone, Err: err, Cancelled: cancelRequested, Outcome: turnOutcome(err)}
|
||
var readinessErr *agent.FinalReadinessError
|
||
if errors.As(err, &readinessErr) {
|
||
done.Readiness = &event.FinalReadiness{Attempts: readinessErr.Attempts, Missing: append([]string(nil), readinessErr.Missing...)}
|
||
}
|
||
c.sink.Emit(done)
|
||
}
|
||
|
||
func turnOutcome(err error) string {
|
||
var readinessErr *agent.FinalReadinessError
|
||
if errors.As(err, &readinessErr) {
|
||
return event.TurnOutcomeFinalReadiness
|
||
}
|
||
var pauseErr *agent.RecoveryPauseError
|
||
if errors.As(err, &pauseErr) {
|
||
return event.TurnOutcomeRecoveryPaused
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// Send starts a turn with an uncomposed message. The controller applies
|
||
// plan-mode, memory, and background-job framing inside the async turn path.
|
||
func (c *Controller) Send(input string) {
|
||
c.SendWithRaw(input, input)
|
||
}
|
||
|
||
// SendWithRaw starts a turn with separate model input and raw prompt text.
|
||
func (c *Controller) SendWithRaw(input, raw string) {
|
||
c.runGuarded(func(ctx context.Context) error { return c.runGoalLoopWithRaw(ctx, input, raw) })
|
||
}
|
||
|
||
// planApprovalTool is the Tool name on the ApprovalRequest the controller emits
|
||
// to gate a proposed plan. Frontends key their plan-approval UI on it (the
|
||
// desktop renders a plan card; the chat TUI a plan banner).
|
||
const planApprovalTool = "exit_plan_mode"
|
||
|
||
// PlanDecisionAction preserves the three user-owned meanings of the Plan card.
|
||
// Revise and exit both deny execution at the approval gate, but they are not the
|
||
// same product decision and must remain distinguishable in durable receipts.
|
||
type PlanDecisionAction string
|
||
|
||
const (
|
||
PlanDecisionStartExecution PlanDecisionAction = "start_execution"
|
||
PlanDecisionRevisePlan PlanDecisionAction = "revise_plan"
|
||
PlanDecisionExitPlan PlanDecisionAction = "exit_plan"
|
||
)
|
||
|
||
// SandboxEscapeApprovalTool is the internal Tool name used for one-shot approval
|
||
// to rerun a shell command without the OS sandbox after the sandbox failed.
|
||
const SandboxEscapeApprovalTool = "sandbox_escape"
|
||
|
||
// ManagedConfigWriteApprovalTool is the internal Tool name used for per-write
|
||
// approval when a file tool targets a Reasonix-managed config file outside the
|
||
// workspace write roots. It is a fresh human decision: config files control
|
||
// providers, sandbox rules, permissions, and MCP servers for future sessions,
|
||
// so YOLO/auto approval must never answer it.
|
||
const ManagedConfigWriteApprovalTool = "config_write"
|
||
|
||
// planApprovedMessage is the follow-up turn sent once the user approves a plan —
|
||
// the in-context nudge to execute and keep the (already-seeded) task list honest.
|
||
const planApprovedMessage = "Plan approved — plan mode is off. Implement the plan now. The ordinary writer fallback is approved for this execution turn; explicit ask/deny rules and forced fresh reviews still apply. Use this serial workflow: 1) mark the first sub-step in_progress with todo_write (this establishes the task list); 2) execute the sub-step; 3) call complete_step with evidence — the host then marks that sub-step completed and moves the next one to in_progress for you. Repeat 2–3 for each remaining sub-step. You don’t need another todo_write to mark steps completed; each complete_step advances the list. Sign off one sub-step at a time — never batch multiple completions."
|
||
|
||
// runTurn runs one model turn, then applies the plan-approval gate. This is the
|
||
// single, frontend-agnostic plan flow: in Plan the model is instructed to
|
||
// research and write its plan as a normal answer, while any tool calls still use
|
||
// the active Permissions/Sandbox path.
|
||
// When the turn ends with a text proposal, the controller asks the user to
|
||
// approve (reusing the ApprovalRequest channel both frontends already render);
|
||
// on approval it exits plan mode, seeds the task list from the plan, and
|
||
// continues straight into execution; on rejection it stays in plan mode so the
|
||
// next turn can revise. Plan mode is only ever set interactively, so the headless
|
||
// `Run` path (which doesn't call this) never blocks on a prompt.
|
||
func (c *Controller) runTurn(ctx context.Context, input string) error {
|
||
return c.runGoalLoopWithRaw(ctx, input, input)
|
||
}
|
||
|
||
// RunTurn executes one foreground turn synchronously through the same lifecycle
|
||
// used by interactive frontends: transient memory/background-job
|
||
// composition, checkpoints, hooks, and plan approval. It is for transports that
|
||
// need a blocking request/response boundary, such as ACP session/prompt.
|
||
func (c *Controller) RunTurn(ctx context.Context, input string) error {
|
||
ctx, cancel := context.WithCancel(ctx)
|
||
c.mu.Lock()
|
||
// finishing is part of the gate: TurnDone delivery for the previous turn
|
||
// is still fanning out, and starting a synchronous turn inside that
|
||
// window recreates the completion/transport crosstalk the window exists
|
||
// to prevent (Running() already reports true here). closed seals a torn-
|
||
// down controller. Synchronous callers get an error rather than parking:
|
||
// they hold a request/response boundary open and already handle busy.
|
||
if c.running || c.finishing || c.rotating || c.closed {
|
||
c.mu.Unlock()
|
||
cancel()
|
||
return ErrTurnRunning
|
||
}
|
||
c.cancel = cancel
|
||
c.running = true
|
||
c.canceling = false
|
||
c.mu.Unlock()
|
||
defer event.RecordTurnCompletion(c.sink)
|
||
|
||
defer func() {
|
||
c.mu.Lock()
|
||
c.running = false
|
||
c.cancel = nil
|
||
c.canceling = false
|
||
c.mu.Unlock()
|
||
cancel()
|
||
}()
|
||
return c.runTurn(ctx, input)
|
||
}
|
||
|
||
func (c *Controller) runTurnWithRaw(ctx context.Context, input, raw string) error {
|
||
return c.runTurnWithRawDisplay(ctx, input, raw, "")
|
||
}
|
||
|
||
func (c *Controller) runGoalLoopWithRaw(ctx context.Context, input, raw string) error {
|
||
return c.runGoalLoopWithRawDisplay(ctx, input, raw, "")
|
||
}
|
||
|
||
// withTurnFormat binds a structured-output format to the turn context
|
||
// (empty is a no-op). Extracted from the runGoalLoop closure so tests can
|
||
// assert the format actually reaches the agent request path.
|
||
func (c *Controller) withTurnFormat(ctx context.Context, format string) context.Context {
|
||
if format == "" {
|
||
return ctx
|
||
}
|
||
return agent.WithResponseFormat(ctx, format)
|
||
}
|
||
|
||
func (c *Controller) runGoalLoopWithRawDisplay(ctx context.Context, input, raw, display string) error {
|
||
// Structured-output format is bound to the submitted turn (passed via
|
||
// submitHTTPWithFormat → submitCommandOrTurn → runGoalLoop closure);
|
||
// no global one-shot slot to race across concurrent requests.
|
||
return newTurnOrchestrator(c).runGoalLoopWithRawDisplay(ctx, input, raw, display)
|
||
}
|
||
|
||
func (c *Controller) runEditedGoalLoopWithRawDisplay(ctx context.Context, input, raw, display, original string) error {
|
||
return newTurnOrchestrator(c).runEditedGoalLoopWithRawDisplay(ctx, input, raw, display, original)
|
||
}
|
||
|
||
func (c *Controller) runTurnWithRawDisplay(ctx context.Context, input, raw, display string) error {
|
||
return newTurnOrchestrator(c).runTurnWithRawDisplay(ctx, input, raw, display)
|
||
}
|
||
|
||
func (c *Controller) runSubagentSkillSlash(sk skill.Skill, task, raw, display string) {
|
||
sk = c.skills.prepare(sk)
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
planMode := c.PlanMode()
|
||
runner := c.skillRunner
|
||
if runner == nil {
|
||
return fmt.Errorf("subagent skill runner is unavailable for /%s", sk.Name)
|
||
}
|
||
return newTurnOrchestrator(c).runSubagentSkillGoalLoop(ctx, sk, task, raw, display, runner, planMode)
|
||
})
|
||
}
|
||
|
||
func (c *Controller) stopGoal(status string) {
|
||
path, data, ok := c.goals.stop(status, c.goalTodos())
|
||
c.persistGoalState(path, data, ok)
|
||
}
|
||
|
||
// lastAssistantText returns the content of the most recent assistant message with
|
||
// non-empty text — the model's final answer for the turn (its plan, in plan mode).
|
||
func lastAssistantText(msgs []provider.Message) string {
|
||
for _, msg := range slices.Backward(msgs) {
|
||
if msg.Role == provider.RoleAssistant && strings.TrimSpace(msg.Content) != "" {
|
||
return msg.Content
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// Submit is the one-call entry for a simple frontend: it takes raw user input
|
||
// and does everything — slash-command dispatch, @-reference expansion, plan-mode
|
||
// composition — emitting all output as events. The HTTP/SSE server uses this so
|
||
// a browser client only POSTs the typed line.
|
||
//
|
||
// Slash commands route to the matching primitive: /compact, /new, and /clear
|
||
// run their session op and emit a Notice; /mcp__server__prompt and custom /commands
|
||
// resolve to a turn; an unknown slash emits a Notice. Anything else is a normal
|
||
// turn with its @-references resolved first.
|
||
func (c *Controller) Submit(input string) {
|
||
c.submit(input, "", "")
|
||
}
|
||
|
||
// SubmitHTTP accepts input from the unauthenticated localhost HTTP frontend. It
|
||
// deliberately omits the trusted TUI-only "!cmd" shell shortcut and resolves file
|
||
// references only through the controller's workspace root.
|
||
func (c *Controller) SubmitHTTP(input string) {
|
||
c.submitHTTP(input, "")
|
||
}
|
||
|
||
// SubmitHTTPFormat is SubmitHTTP with an optional structured-output format
|
||
// ("json_object") applied to the turn's completion requests. Empty format
|
||
// behaves exactly like SubmitHTTP. A format attached to a slash command,
|
||
// or other non-turn input is discarded; @reference turns preserve it because
|
||
// the format is bound to every submitted turn rather than a global slot.
|
||
func (c *Controller) SubmitHTTPFormat(input, format string) {
|
||
// format 绑定到本次提交的 turn(随请求参数传递),不再写入 Controller
|
||
// 全局一次性槽——评审 #7234 第 2 点:全局槽存在跨请求串用的逻辑竞态
|
||
// (后提交的 JSON 请求先写槽,更早的普通请求先启动消费掉)。
|
||
f := strings.TrimSpace(format)
|
||
if f != "" && isNonTurnHTTPInput(input) {
|
||
f = "" // 非 turn 输入(slash 命令/! 前缀)不携带 format
|
||
}
|
||
// @ 引用 turn(FileRefLine/SlashPathLineRef 等)同样绑定 format——
|
||
// runRefTurnWithFormat 族 wrapper 注入 ctx(review fix7234and7168:
|
||
// format 是每个被接纳 turn 的属性,统一架构)。
|
||
c.submitHTTPWithFormat(input, "", f)
|
||
}
|
||
|
||
// isNonTurnHTTPInput reports inputs that never reach the agent turn loop, so a
|
||
// structured-output request attached to them would otherwise leak into the
|
||
// next real turn (the format slot is consumed only by runGoalLoopWithRawDisplay).
|
||
func isNonTurnHTTPInput(input string) bool {
|
||
trimmed := strings.TrimSpace(input)
|
||
if trimmed == "" {
|
||
return true
|
||
}
|
||
// Memory quick-add / remember shortcuts and goal commands bypass turns.
|
||
if _, ok := MemoryQuickAddNote(trimmed); ok {
|
||
return true
|
||
}
|
||
if _, ok := RememberCommandNote(trimmed); ok {
|
||
return true
|
||
}
|
||
// "!" shell commands are rejected by submitHTTP before the turn loop
|
||
// (403 over HTTP); a format attached to them would never be consumed.
|
||
if strings.HasPrefix(trimmed, "!") {
|
||
return true
|
||
}
|
||
// Slash commands are management verbs (/compact /new /clear /model ...)
|
||
// or notices, not completion turns.
|
||
if strings.HasPrefix(trimmed, "/") {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// SubmitDisplay runs input as a turn while remembering the user-facing display
|
||
// text for transcript replay when controller-side composition expands input.
|
||
func (c *Controller) SubmitDisplay(display, input string) {
|
||
c.submit(input, display, "")
|
||
}
|
||
|
||
// SubmitDeliveryRecovery runs the same visible prompt path as SubmitDisplay but
|
||
// first authorizes the executor to retain the immediately preceding exhausted
|
||
// delivery ledger. The agent consumes that authorization once; if the card came
|
||
// from an older/reloaded session this safely degrades to an ordinary turn.
|
||
func (c *Controller) SubmitDeliveryRecovery(display, input string) {
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
if c.executor != nil {
|
||
c.executor.PrepareDeliveryRecovery()
|
||
}
|
||
return c.runGoalLoopWithRawDisplay(ctx, input, input, display)
|
||
})
|
||
}
|
||
|
||
// SubmitInvocationDisplay executes composer-selected invocation entities
|
||
// independently of slash-command parsing. Plain string submit entry points keep
|
||
// their existing behavior for CLI, HTTP, and backward-compatible clients.
|
||
func (c *Controller) SubmitInvocationDisplay(display, input string, invocations []InvocationRequest) {
|
||
c.submitInvocations(input, display, invocations)
|
||
}
|
||
|
||
func (c *Controller) submitInvocations(input, display string, requests []InvocationRequest) {
|
||
if len(requests) == 0 {
|
||
c.SubmitDisplay(display, input)
|
||
return
|
||
}
|
||
ordered := append([]InvocationRequest(nil), requests...)
|
||
sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Offset < ordered[j].Offset })
|
||
inline := make([]skill.Skill, 0, len(ordered))
|
||
subagents := make([]skill.Skill, 0, len(ordered))
|
||
for _, request := range ordered {
|
||
sk, _, ok := c.resolveSkillInvocation("/" + strings.TrimSpace(request.Name))
|
||
if !ok {
|
||
c.notice("unknown invocation: /" + strings.TrimSpace(request.Name))
|
||
return
|
||
}
|
||
kind := "skill"
|
||
if sk.RunAs == skill.RunSubagent {
|
||
kind = "subagent"
|
||
}
|
||
if request.Kind != kind {
|
||
c.notice(fmt.Sprintf("invocation /%s is %s, not %s", sk.SlashName(), kind, request.Kind))
|
||
return
|
||
}
|
||
if sk.RunAs == skill.RunSubagent {
|
||
subagents = append(subagents, sk)
|
||
} else {
|
||
inline = append(inline, sk)
|
||
}
|
||
}
|
||
|
||
parts := make([]string, 0, len(inline)+1)
|
||
for _, sk := range inline {
|
||
parts = append(parts, c.skills.render(sk, ""))
|
||
}
|
||
if strings.TrimSpace(input) != "" {
|
||
parts = append(parts, input)
|
||
}
|
||
composed := strings.Join(parts, "\n\n")
|
||
if len(subagents) == 0 {
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
return c.runGoalLoopWithRawDisplay(ctx, composed, input, display)
|
||
})
|
||
return
|
||
}
|
||
if strings.TrimSpace(input) == "" {
|
||
c.notice("subagent invocation requires a task")
|
||
return
|
||
}
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
planMode := c.PlanMode()
|
||
runner := c.skillRunner
|
||
if runner == nil {
|
||
return fmt.Errorf("subagent skill runner is unavailable")
|
||
}
|
||
return newTurnOrchestrator(c).runSubagentSkillTurnsGoalLoop(ctx, subagents, composed, input, display, runner, planMode)
|
||
})
|
||
}
|
||
|
||
// SubmitEditedDisplay is SubmitDisplay for an inline-edited prompt. The model
|
||
// sees input; the saved user message also keeps the pre-edit prompt as local UI
|
||
// metadata so the edit survives session rewrites.
|
||
func (c *Controller) SubmitEditedDisplay(display, input, original string) {
|
||
c.submit(input, display, original)
|
||
}
|
||
|
||
// SubmitUserTurn starts a normal model turn without interpreting shell or slash
|
||
// commands. It still resolves references, so callers can submit trusted
|
||
// user-authored prompt text without expanding the command surface.
|
||
func (c *Controller) SubmitUserTurn(input, display string) {
|
||
c.runRefTurn(input, display)
|
||
}
|
||
|
||
func (c *Controller) submit(input, display, editedOriginal string) {
|
||
trimmed := strings.TrimSpace(input)
|
||
if note, ok := MemoryQuickAddNote(trimmed); ok {
|
||
c.rememberProjectNote(note)
|
||
return
|
||
}
|
||
if note, ok := RememberCommandNote(trimmed); ok {
|
||
c.rememberProjectNote(note)
|
||
return
|
||
}
|
||
if c.applyGoalCommand(trimmed, display) {
|
||
return
|
||
}
|
||
if strings.HasPrefix(trimmed, "!") {
|
||
c.RunShell(trimmed[1:])
|
||
return
|
||
}
|
||
c.submitCommandOrTurn(trimmed, input, display, false, editedOriginal, "")
|
||
}
|
||
|
||
func (c *Controller) submitHTTP(input, display string) {
|
||
c.submitHTTPWithFormat(input, display, "")
|
||
}
|
||
|
||
func (c *Controller) submitHTTPWithFormat(input, display, format string) {
|
||
trimmed := strings.TrimSpace(input)
|
||
if note, ok := MemoryQuickAddNote(trimmed); ok {
|
||
c.rememberProjectNote(note)
|
||
return
|
||
}
|
||
if note, ok := RememberCommandNote(trimmed); ok {
|
||
c.rememberProjectNote(note)
|
||
return
|
||
}
|
||
if c.applyGoalCommand(trimmed, display) {
|
||
return
|
||
}
|
||
if strings.HasPrefix(trimmed, "!") {
|
||
c.notice("shell commands are unavailable from this frontend")
|
||
return
|
||
}
|
||
c.submitCommandOrTurn(trimmed, input, display, true, "", format)
|
||
}
|
||
|
||
func (c *Controller) submitCommandOrTurn(trimmed, input, display string, scopedRefsOnly bool, editedOriginal, format string) {
|
||
runRefTurn := func(input, display string) {
|
||
c.runRefTurnWithFormat(input, display, format)
|
||
}
|
||
runRefTurnWithRefs := func(input, refLine, display string) {
|
||
c.runRefTurnWithRefsFormat(input, refLine, display, format)
|
||
}
|
||
runGoalLoop := func(ctx context.Context, input, raw, display string) error {
|
||
return c.runGoalLoopWithRawDisplay(c.withTurnFormat(ctx, format), input, raw, display)
|
||
}
|
||
if scopedRefsOnly {
|
||
runRefTurn = func(input, display string) {
|
||
c.runScopedRefTurnWithFormat(input, display, format)
|
||
}
|
||
runRefTurnWithRefs = func(input, refLine, display string) {
|
||
c.runScopedRefTurnWithRefsFormat(input, refLine, display, format)
|
||
}
|
||
}
|
||
if strings.TrimSpace(editedOriginal) != "" {
|
||
runRefTurn = func(input, display string) {
|
||
c.runEditedRefTurnWithFormat(input, display, editedOriginal, format)
|
||
}
|
||
runRefTurnWithRefs = func(input, refLine, display string) {
|
||
c.runEditedRefTurnWithRefsFormat(input, refLine, display, editedOriginal, format)
|
||
}
|
||
runGoalLoop = func(ctx context.Context, input, raw, display string) error {
|
||
return c.runEditedGoalLoopWithRawDisplay(ctx, input, raw, display, editedOriginal)
|
||
}
|
||
}
|
||
switch {
|
||
case trimmed == "/compact" || strings.HasPrefix(trimmed, "/compact "):
|
||
focus := strings.TrimSpace(strings.TrimPrefix(trimmed, "/compact"))
|
||
go func() {
|
||
if err := c.Compact(context.Background(), focus); err != nil {
|
||
c.notice("compaction failed: " + err.Error())
|
||
} else {
|
||
c.notice("compacted")
|
||
if err := c.SnapshotRewrite(); err != nil {
|
||
slog.Warn("controller: snapshot after compact", "err", err)
|
||
}
|
||
}
|
||
}()
|
||
case trimmed == "/new":
|
||
go func() {
|
||
if err := c.NewSession(); err != nil {
|
||
c.notice("new session failed: " + err.Error())
|
||
} else {
|
||
c.notice("new session")
|
||
}
|
||
}()
|
||
case trimmed == "/clear":
|
||
go func() {
|
||
if err := c.ClearSession(); err != nil {
|
||
c.notice("clear context failed: " + err.Error())
|
||
} else {
|
||
c.notice("context cleared")
|
||
}
|
||
}()
|
||
case strings.HasPrefix(trimmed, "/mcp__"):
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
sent, found, err := c.MCPPrompt(ctx, trimmed)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !found {
|
||
c.notice("unknown command: " + trimmed)
|
||
return nil
|
||
}
|
||
return runGoalLoop(ctx, sent, sent, display)
|
||
})
|
||
case SlashCodeCommentLine(trimmed):
|
||
// Slash-prefixed code comments are prompt text, not slash commands.
|
||
runRefTurn(input, display)
|
||
case strings.HasPrefix(trimmed, "/"):
|
||
if ref, ok := FileRefLine(trimmed); ok {
|
||
runRefTurn(ref, display)
|
||
return
|
||
}
|
||
if ref, ok := SlashPathLineRef(trimmed, c.workspaceRoot); ok {
|
||
runRefTurnWithRefs(input, ref, display)
|
||
return
|
||
}
|
||
if SlashPathLikeLine(trimmed) {
|
||
runRefTurn(input, display)
|
||
return
|
||
}
|
||
// Management verbs (/model /memory /skills /hooks /mcp) emit a Notice, so
|
||
// Submit-based frontends (desktop, HTTP) get them with no extra wiring.
|
||
// The chat TUI handles these itself with richer output.
|
||
fields := strings.Fields(trimmed)
|
||
switch fields[0] {
|
||
case "/tree":
|
||
c.notice(c.BranchTreeText())
|
||
return
|
||
case "/branch":
|
||
args := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0]))
|
||
if turn, name, fromTurn, err := ParseBranchTarget(args); err != nil {
|
||
c.notice(err.Error())
|
||
} else if fromTurn {
|
||
if _, err := c.ForkNamed(turn-1, name); err != nil {
|
||
c.notice(err.Error())
|
||
}
|
||
} else {
|
||
if _, err := c.Branch(name); err != nil {
|
||
c.notice(err.Error())
|
||
}
|
||
}
|
||
return
|
||
case "/switch":
|
||
ref := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0]))
|
||
if _, err := c.SwitchBranch(ref); err != nil {
|
||
c.notice(err.Error())
|
||
}
|
||
return
|
||
case "/rewind":
|
||
args := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0]))
|
||
turn, scope, err := parseRewind(args, c.Checkpoints())
|
||
if err != nil {
|
||
c.notice("usage: /rewind [turn] [code|conversation|both]")
|
||
return
|
||
}
|
||
if err := c.Rewind(turn, scope); err != nil {
|
||
c.notice(err.Error())
|
||
}
|
||
return
|
||
case "/plan-exec":
|
||
c.applyPlanExec(trimmed, display)
|
||
return
|
||
case "/prometheus":
|
||
c.applyPrometheus(trimmed, display)
|
||
return
|
||
}
|
||
if c.managementNotice(trimmed) {
|
||
return
|
||
}
|
||
if IsBuiltinDocsSlash(fields[0], c.Commands(), c.SlashSkills()) {
|
||
query := strings.TrimSpace(strings.TrimPrefix(trimmed, fields[0]))
|
||
if query == "" {
|
||
text, err := DocsCommandOverviewFor(fields[0])
|
||
if err != nil {
|
||
c.notice("docs: " + err.Error())
|
||
} else {
|
||
c.notice(text)
|
||
}
|
||
return
|
||
}
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
sent, err := docsCommandPrompt(ctx, query)
|
||
if err != nil {
|
||
return fmt.Errorf("docs: %w", err)
|
||
}
|
||
return runGoalLoop(ctx, sent, sent, display)
|
||
})
|
||
return
|
||
}
|
||
// A custom command wins over a skill of the same name; both resolve to a
|
||
// turn. Built-ins and their explicit Reasonix namespace are handled above.
|
||
if sent, ok := c.CustomCommand(trimmed); ok {
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
return runGoalLoop(ctx, sent, sent, display)
|
||
})
|
||
return
|
||
}
|
||
if sk, task, ok := c.resolveSkillInvocation(trimmed); ok {
|
||
if sk.RunAs == skill.RunSubagent {
|
||
if strings.TrimSpace(task) == "" {
|
||
c.notice("usage: /" + sk.Name + " <task>")
|
||
return
|
||
}
|
||
c.runSubagentSkillSlash(sk, task, trimmed, display)
|
||
return
|
||
}
|
||
sent := c.skills.render(sk, task)
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
return runGoalLoop(ctx, sent, sent, display)
|
||
})
|
||
return
|
||
}
|
||
// Unknown slash input is prose more often than a typo ("/etc/hosts
|
||
// looks wrong", pasted paths, half-remembered commands) — send it as a
|
||
// regular message instead of dead-ending the submission, with a notice
|
||
// so real typos are still visible (#5756).
|
||
c.notice("unknown command: " + trimmed + " — sent as a regular message")
|
||
runRefTurn(input, display)
|
||
default:
|
||
runRefTurn(input, display)
|
||
}
|
||
}
|
||
|
||
func (c *Controller) rememberProjectNote(note string) {
|
||
if note == "" {
|
||
c.notice("nothing to remember")
|
||
return
|
||
}
|
||
if path, err := c.QuickAdd(memory.ScopeProject, note); err != nil {
|
||
c.notice("memory: " + err.Error())
|
||
} else {
|
||
c.notice("remembered → " + path)
|
||
}
|
||
}
|
||
|
||
func (c *Controller) applyGoalCommand(input, display string) bool {
|
||
cmd, ok := ParseGoalCommand(input)
|
||
if !ok {
|
||
return false
|
||
}
|
||
switch cmd.Action {
|
||
case GoalCommandSet:
|
||
c.SetPlanMode(false)
|
||
c.SetGoalWithResearchMode(cmd.Text, cmd.ResearchMode)
|
||
c.GoalStrict(cmd.Strict)
|
||
c.notice(fmt.Sprintf(i18n.M.GoalSetFmt, ShortGoalForNotice(cmd.Text)))
|
||
if c.runner != nil {
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
return c.runGoalLoopWithRawDisplay(ctx, "Start pursuing the active goal now.", cmd.Text, display)
|
||
})
|
||
}
|
||
case GoalCommandClear:
|
||
c.ClearGoal()
|
||
c.notice(i18n.M.GoalCleared)
|
||
case GoalCommandPause:
|
||
if !c.PauseGoal() {
|
||
c.notice(i18n.M.GoalNotRunning)
|
||
}
|
||
case GoalCommandResume:
|
||
if !c.ResumeGoal() {
|
||
c.notice(i18n.M.GoalNotPaused)
|
||
}
|
||
default:
|
||
goal := c.Goal()
|
||
if strings.TrimSpace(goal) == "" {
|
||
c.notice(i18n.M.GoalEmpty)
|
||
break
|
||
}
|
||
rt := c.GoalRuntime()
|
||
c.notice(fmt.Sprintf(i18n.M.GoalCurrentFmt, goal))
|
||
c.notice(fmt.Sprintf(i18n.M.GoalRuntimeFmt,
|
||
rt.TurnsUsed, rt.TurnsLimit, rt.TokensUsed,
|
||
rt.NoProgressTurns, rt.NoProgressLimit, rt.BudgetExtensions))
|
||
if rt.LastReason != "" {
|
||
c.noticeDetail(i18n.M.GoalRuntimeLastReason, rt.LastReason)
|
||
}
|
||
if rt.StopCause != "" {
|
||
c.notice(fmt.Sprintf(i18n.M.GoalPausedFmt, rt.StopCause))
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// applyPlanExec reads the current canonical todo list and starts a goal that
|
||
// analyzes and dispatches independent steps concurrently via parallel_tasks.
|
||
// Supports --strict flag: /plan-exec --strict enables strict goal mode.
|
||
func (c *Controller) applyPlanExec(input, display string) {
|
||
todos := c.executor.CanonicalTodoState()
|
||
if len(todos) == 0 {
|
||
c.notice("no active plan with todos to execute")
|
||
return
|
||
}
|
||
|
||
// Parse --strict flag.
|
||
strict := slices.Contains(strings.Fields(input), "--strict")
|
||
|
||
// Count completion status.
|
||
total := len(todos)
|
||
done := 0
|
||
for _, t := range todos {
|
||
if t.Status == "completed" {
|
||
done++
|
||
}
|
||
}
|
||
|
||
var b strings.Builder
|
||
b.WriteString("You are the execution conductor. Route each step to the right sub-agent by module.\n\n")
|
||
|
||
// Detect project structure for module-aware routing.
|
||
modules := c.detectProjectModules()
|
||
if len(modules) > 0 {
|
||
b.WriteString("## Project modules detected\n\n")
|
||
for _, m := range modules {
|
||
fmt.Fprintf(&b, "- %s/", m)
|
||
}
|
||
b.WriteString("\n\nRoute steps to the module they belong to. Steps in different modules can run in parallel.\n\n")
|
||
}
|
||
|
||
b.WriteString("## Plan steps\n\n")
|
||
for _, t := range todos {
|
||
status := t.Status
|
||
if status == "" {
|
||
status = "pending"
|
||
}
|
||
mark := " "
|
||
if status == "completed" {
|
||
mark = "x"
|
||
}
|
||
fmt.Fprintf(&b, "- [%s] %s (%s)\n", mark, t.Content, status)
|
||
}
|
||
b.WriteString("\n## Routing rules\n")
|
||
b.WriteString("1. Group steps by MODULE \u2014 same module = serial, different modules = parallel batches\n")
|
||
b.WriteString("2. Research/exploration across modules = use parallel_tasks\n")
|
||
b.WriteString("3. Dispatch each batch via parallel_tasks \u2014 each sub-agent gets one module\u2019s context\n")
|
||
b.WriteString("4. Verify each batch before the next\n")
|
||
b.WriteString("5. Failures: fix before moving on\n")
|
||
b.WriteString("\nGoal: each sub-agent focuses on one module and does not carry irrelevant context.\n")
|
||
if done > 0 {
|
||
fmt.Fprintf(&b, "\nNote: %d/%d steps are already completed. Focus on the remaining %d steps.\n", done, total, total-done)
|
||
}
|
||
prompt := b.String()
|
||
|
||
// Show module preview.
|
||
if len(modules) > 0 {
|
||
c.notice(fmt.Sprintf("plan-exec: detected %d modules — %s", len(modules), strings.Join(modules, ", ")))
|
||
}
|
||
|
||
c.SetPlanMode(false)
|
||
c.SetGoal("execute plan: " + ShortGoalForNotice(todos[0].Content))
|
||
c.GoalStrict(strict)
|
||
c.notice(fmt.Sprintf("plan-exec: dispatching %d plan steps (strict=%v)", total, strict))
|
||
if c.runner != nil {
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
return c.runGoalLoopWithRawDisplay(ctx, prompt, prompt, display)
|
||
})
|
||
}
|
||
}
|
||
|
||
// prometheusPrompt is the strategic planner system prompt.
|
||
const prometheusPrompt = "You are Prometheus, a strategic planner. Interview the user one question at a time. Cover: scope, modules, files, constraints, tests. When ready, output a numbered plan with each step tagged by module. End by calling update_goal with status complete. Do not implement.\n\nFor independent research directions, use parallel_tasks before planning."
|
||
|
||
// applyPrometheus starts an interactive planning interview, inspired by OMO's
|
||
// Prometheus agent. It enters goal mode with a structured interview prompt.
|
||
func (c *Controller) applyPrometheus(input, display string) {
|
||
args := strings.TrimSpace(strings.TrimPrefix(input, "/prometheus"))
|
||
if args == "" || args == "--strict" {
|
||
c.notice("usage: /prometheus <your task description>")
|
||
return
|
||
}
|
||
strict := false
|
||
if strings.HasPrefix(args, "--strict ") {
|
||
strict = true
|
||
args = strings.TrimPrefix(args, "--strict ")
|
||
}
|
||
prompt := prometheusPrompt + "\n\n## User request\n\n" + args + "\n\nBegin the interview by asking your first clarifying question."
|
||
c.SetPlanMode(false)
|
||
c.SetGoal("plan: " + ShortGoalForNotice(args))
|
||
c.GoalStrict(strict)
|
||
c.notice("prometheus: starting planning interview")
|
||
if c.runner != nil {
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
return c.runGoalLoopWithRawDisplay(ctx, prompt, prompt, display)
|
||
})
|
||
}
|
||
}
|
||
|
||
// shellTimeout is the maximum time a user-invoked "!command" may run. Matches
|
||
// the bash tool's timeout so behaviour is consistent across invocation paths.
|
||
const shellTimeout = 120 * time.Second
|
||
|
||
// shellWaitDelay bounds how long cmd.Run() waits after context cancellation for
|
||
// the child's pipes to drain, matching the bash tool's WaitDelay.
|
||
const shellWaitDelay = 5 * time.Second
|
||
|
||
func shellCommandPreview(command string) string {
|
||
command = strings.TrimSpace(strings.ReplaceAll(command, "\n", " "))
|
||
const max = 48
|
||
r := []rune(command)
|
||
if len(r) > max {
|
||
return string(r[:max]) + "…"
|
||
}
|
||
return command
|
||
}
|
||
|
||
// RunShell executes a shell command directly (bypassing the model) and streams
|
||
// the output as ToolDispatch/ToolProgress/ToolResult events. It uses the same
|
||
// bash-tool infrastructure (shell resolution, timeout) and shares the runGuarded
|
||
// lock with model turns — only one can run at a time. User-invoked "!" commands
|
||
// run without the OS sandbox (the user typed the command explicitly).
|
||
func (c *Controller) RunShell(command string) {
|
||
command = strings.TrimSpace(command)
|
||
if command == "" {
|
||
c.notice(i18n.M.ShellExecEmpty)
|
||
return
|
||
}
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
sh := c.shell
|
||
if sh.Path == "" {
|
||
sh = sandbox.ResolveShell("", "", nil)
|
||
}
|
||
argv, _ := sandbox.Command(sandbox.Spec{}, sh, command) // false = unsandboxed (user invoked)
|
||
|
||
preview := []rune(command)
|
||
if len(preview) > 32 {
|
||
preview = preview[:32]
|
||
}
|
||
id := "shell-" + string(preview)
|
||
diagnosticPreview := shellCommandPreview(command)
|
||
desc := shellrun.DescriptorFromShell(sh)
|
||
|
||
c.sink.Emit(event.Event{
|
||
Kind: event.ToolDispatch,
|
||
Tool: event.Tool{
|
||
ID: id,
|
||
Name: "bash",
|
||
Args: fmt.Sprintf(`{"command":%q}`, command),
|
||
Execution: &event.ShellExecution{
|
||
Kind: desc.Kind, Shell: desc.Shell, ShellVersion: desc.ShellVersion,
|
||
Platform: desc.Platform, SupportsAndAnd: desc.SupportsAndAnd,
|
||
State: tool.ShellStateRunning,
|
||
},
|
||
},
|
||
})
|
||
|
||
start := time.Now()
|
||
res := shellrun.RunForeground(ctx, shellrun.Request{
|
||
Argv: argv,
|
||
Dir: c.workspaceRoot,
|
||
Timeout: shellTimeout,
|
||
WaitDelay: shellWaitDelay,
|
||
CommandPreview: diagnosticPreview,
|
||
ShellKind: sh.Kind.String(),
|
||
ShellPath: sh.Path,
|
||
Source: "user_shell",
|
||
Track: true,
|
||
Progress: func(chunk string) {
|
||
c.sink.Emit(event.Event{
|
||
Kind: event.ToolProgress,
|
||
Tool: event.Tool{ID: id, Output: chunk},
|
||
})
|
||
},
|
||
})
|
||
durationMs := time.Since(start).Milliseconds()
|
||
ex := &event.ShellExecution{
|
||
Kind: desc.Kind, Shell: desc.Shell, ShellVersion: desc.ShellVersion,
|
||
Platform: desc.Platform, SupportsAndAnd: desc.SupportsAndAnd,
|
||
State: res.State, FailurePhase: res.FailurePhase,
|
||
OutputTail: res.OutputTail, DurationMs: durationMs,
|
||
MutationRisk: tool.ShellMutationNone,
|
||
Verification: tool.ShellVerificationNotVerification,
|
||
}
|
||
if res.ExitCode != nil {
|
||
code := *res.ExitCode
|
||
ex.ExitCode = &code
|
||
}
|
||
switch res.State {
|
||
case tool.ShellStateCompleted:
|
||
ex.MutationRisk = tool.ShellMutationNone
|
||
case tool.ShellStateNotRun:
|
||
ex.MutationRisk = tool.ShellMutationNotStarted
|
||
case tool.ShellStateFailed:
|
||
if res.FailurePhase == tool.ShellPhaseLaunch {
|
||
ex.MutationRisk = tool.ShellMutationNotStarted
|
||
} else {
|
||
ex.MutationRisk = tool.ShellMutationMayBePartial
|
||
}
|
||
case tool.ShellStateTimedOut, tool.ShellStateCancelled:
|
||
ex.MutationRisk = tool.ShellMutationMayBePartial
|
||
}
|
||
|
||
errText := ""
|
||
switch res.State {
|
||
case tool.ShellStateCancelled:
|
||
errText = i18n.M.TurnCancelled
|
||
case tool.ShellStateTimedOut:
|
||
errText = fmt.Sprintf(i18n.M.ShellExecTimeoutFmt, shellTimeout)
|
||
case tool.ShellStateFailed, tool.ShellStateNotRun:
|
||
if res.Err != nil {
|
||
errText = fmt.Sprintf(i18n.M.ShellExecFailedFmt, res.Err)
|
||
}
|
||
}
|
||
c.sink.Emit(event.Event{
|
||
Kind: event.ToolResult,
|
||
Tool: event.Tool{
|
||
ID: id, Name: "bash", Output: res.Combined, Err: errText,
|
||
DurationMs: durationMs, Execution: ex,
|
||
},
|
||
})
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// runRefTurn resolves a line's @references into a context block and starts a
|
||
// turn with it prepended (or the raw line when nothing resolved).
|
||
func (c *Controller) runRefTurn(input, display string) {
|
||
c.runRefTurnWithRefs(input, input, display)
|
||
}
|
||
|
||
// runRefTurnWithFormat runs a reference turn with a structured-output
|
||
// format bound to its context (symmetric with runGoalLoop's withTurnFormat
|
||
// injection — format is a property of every accepted turn, not just the
|
||
// plain-goal path; review #7234 binds format to the accepted turn).
|
||
func (c *Controller) runRefTurnWithFormat(input, display, format string) {
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, input, display, "", c.ResolveRefs)
|
||
})
|
||
}
|
||
|
||
func (c *Controller) runScopedRefTurnWithFormat(input, display, format string) {
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, input, display, "", c.ResolveScopedRefs)
|
||
})
|
||
}
|
||
|
||
func (c *Controller) runRefTurnWithRefsFormat(input, refLine, display, format string) {
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, refLine, display, "", c.ResolveRefs)
|
||
})
|
||
}
|
||
|
||
func (c *Controller) runScopedRefTurnWithRefsFormat(input, refLine, display, format string) {
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, refLine, display, "", c.ResolveScopedRefs)
|
||
})
|
||
}
|
||
|
||
func (c *Controller) runEditedRefTurnWithFormat(input, display, original, format string) {
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, input, display, original, c.ResolveRefs)
|
||
})
|
||
}
|
||
|
||
func (c *Controller) runEditedRefTurnWithRefsFormat(input, refLine, display, original, format string) {
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
return c.runRefTurnWithResolverSync(c.withTurnFormat(ctx, format), input, refLine, display, original, c.ResolveRefs)
|
||
})
|
||
}
|
||
|
||
// runRefTurnWithRefs resolves references from refLine while preserving input as
|
||
// the user's actual prompt text. This lets compiler diagnostics such as
|
||
// "/path/File.kt:12: error" attach @/path/File.kt without rewriting the error.
|
||
func (c *Controller) runRefTurnWithRefs(input, refLine, display string) {
|
||
c.runRefTurnWithResolver(input, refLine, display, c.ResolveRefs)
|
||
}
|
||
|
||
func (c *Controller) runRefTurnWithResolver(input, refLine, display string, resolve func(context.Context, string) (string, []string)) {
|
||
c.runGuarded(func(ctx context.Context) error {
|
||
return c.runRefTurnWithResolverSync(ctx, input, refLine, display, "", resolve)
|
||
})
|
||
}
|
||
|
||
func (c *Controller) runRefTurnWithResolverSync(ctx context.Context, input, refLine, display, original string, resolve func(context.Context, string) (string, []string)) error {
|
||
block, errs := resolve(ctx, refLine)
|
||
for _, e := range errs {
|
||
c.notice(e)
|
||
}
|
||
sent := input
|
||
if block != "" {
|
||
sent = "Referenced context:\n\n" + block + "\n\n" + input
|
||
}
|
||
if strings.TrimSpace(original) != "" {
|
||
return c.runEditedGoalLoopWithRawDisplay(ctx, sent, input, display, original)
|
||
}
|
||
return c.runGoalLoopWithRawDisplay(ctx, sent, input, display)
|
||
}
|
||
|
||
// notice emits an informational Notice event.
|
||
func (c *Controller) notice(text string) {
|
||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})
|
||
}
|
||
|
||
func (c *Controller) noticeDetail(text, detail string) {
|
||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text, Detail: detail})
|
||
}
|
||
|
||
// Run executes a turn synchronously, returning the agent's error. Used by the
|
||
// headless `reasonix run` path, where the Sink renders to stdout and the caller
|
||
// just needs the exit status — no TurnDone event, no cancel bookkeeping.
|
||
func (c *Controller) Run(ctx context.Context, input string) (err error) {
|
||
defer event.RecordTurnCompletion(c.sink)
|
||
c.maybeSessionStart(ctx)
|
||
parentSession := c.parentSessionID()
|
||
ctx = agent.WithParentSession(ctx, parentSession)
|
||
ctx = jobs.WithSession(ctx, parentSession)
|
||
ctx = agent.WithUserImages(ctx, c.inputImages(input))
|
||
rawInput := input
|
||
ctx = agent.WithRawUserInput(ctx, rawInput)
|
||
input = c.Compose(input)
|
||
// input.receive: same interception seam as the orchestrated turn — the
|
||
// composed headless input crosses the extension chain before it enters
|
||
// the session.
|
||
input, blocked, interceptErr := c.interceptInputReceive(ctx, input)
|
||
if interceptErr != nil {
|
||
return interceptErr
|
||
}
|
||
if blocked {
|
||
return nil
|
||
}
|
||
startMessages := c.messageCount()
|
||
defer c.snapshotActivityIfChanged(startMessages)
|
||
c.beginCheckpoint(input)
|
||
if c.guardianSess != nil {
|
||
c.guardianSess.ResetTurn()
|
||
}
|
||
if c.hooks.Enabled() {
|
||
c.mu.Lock()
|
||
c.turn++
|
||
turn := c.turn
|
||
c.mu.Unlock()
|
||
if block, _ := c.hooks.PromptSubmit(ctx, input, turn); block {
|
||
return nil
|
||
}
|
||
defer func() { c.hooks.StopResult(context.Background(), lastAssistantText(c.History()), turn, err) }()
|
||
}
|
||
c.markInFlightTurn(startMessages, true)
|
||
defer c.clearInFlightTurn()
|
||
ctx = c.withPlannerTurnMetadata(ctx, rawInput, false, startMessages)
|
||
err = c.runner.Run(ctx, c.withCapabilityRoute(ctx, input, rawInput))
|
||
return err
|
||
}
|
||
|
||
// RunSubagentProfile executes one named runAs=subagent skill synchronously and
|
||
// returns only its final answer. It is the headless CLI counterpart to explicit
|
||
// slash invocation: the child keeps an isolated session, while the caller owns
|
||
// stdout rendering and exit status. readOnly selects the preview-safe runner
|
||
// used by `reasonix subagent try`.
|
||
func (c *Controller) RunSubagentProfile(ctx context.Context, name, task string, readOnly bool) (string, error) {
|
||
name = strings.TrimSpace(name)
|
||
task = strings.TrimSpace(task)
|
||
if name == "" {
|
||
return "", fmt.Errorf("subagent name is required")
|
||
}
|
||
if task == "" {
|
||
return "", fmt.Errorf("subagent task is required")
|
||
}
|
||
sk, ok := c.skills.bySlashName(name)
|
||
if !ok {
|
||
return "", fmt.Errorf("unknown or disabled subagent profile %q", name)
|
||
}
|
||
if sk.RunAs != skill.RunSubagent {
|
||
return "", fmt.Errorf("skill %q is not runAs=subagent", name)
|
||
}
|
||
sk = c.skills.prepare(sk)
|
||
runner := c.skillRunner
|
||
if readOnly {
|
||
runner = c.readOnlySkillRunner
|
||
}
|
||
if runner == nil {
|
||
return "", fmt.Errorf("subagent skill runner is unavailable for %q", name)
|
||
}
|
||
|
||
c.maybeSessionStart(ctx)
|
||
parentSession := c.parentSessionID()
|
||
ctx = agent.WithParentSession(ctx, parentSession)
|
||
ctx = jobs.WithSession(ctx, parentSession)
|
||
ctx = agent.WithUserImages(ctx, c.inputImages(task))
|
||
ctx = agent.WithResponseLanguagePreference(ctx, c.responseLanguage)
|
||
ctx = agent.WithReasoningLanguagePreference(ctx, c.reasoningLanguage)
|
||
ctx = agent.WithSubagentDepth(ctx, 0)
|
||
answer, err := runner(ctx, sk, task, skill.SubagentRunOptions{HostInitiated: true})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return tool.GuardSubagentHostDecisionText(answer), nil
|
||
}
|
||
|
||
// Cancel aborts the in-flight turn. A goroutine blocked awaiting approval
|
||
// unblocks via the cancelled context.
|
||
func (c *Controller) Cancel() {
|
||
c.mu.Lock()
|
||
cancel := c.cancel
|
||
if cancel != nil {
|
||
c.canceling = true
|
||
}
|
||
c.mu.Unlock()
|
||
if cancel != nil {
|
||
c.approval.clearAll()
|
||
cancel()
|
||
return
|
||
}
|
||
if c.goals.active() {
|
||
c.stopGoal(GoalStatusStopped)
|
||
}
|
||
}
|
||
|
||
// Running reports whether a turn is currently in flight.
|
||
func (c *Controller) Running() bool {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return c.running || c.finishing
|
||
}
|
||
|
||
// beginRotation claims the session-rotation gate. It fails if a turn is running
|
||
// or another rotation is already in progress, so the caller holds exclusive
|
||
// rights to swap the executor session from the check here through endRotation.
|
||
// This closes the TOCTOU window that a bare `if c.running` check left open:
|
||
// between that check and the actual SetSession, a turn could start and then be
|
||
// yanked out from under the run loop.
|
||
func (c *Controller) beginRotation() error {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
if c.running || c.finishing {
|
||
return errTurnRunningRotation
|
||
}
|
||
if c.rotating {
|
||
return errRotationInProgress
|
||
}
|
||
c.rotating = true
|
||
return nil
|
||
}
|
||
|
||
func (c *Controller) endRotation() {
|
||
c.mu.Lock()
|
||
c.rotating = false
|
||
c.mu.Unlock()
|
||
}
|
||
|
||
// CancelRequested reports whether Cancel has been requested for the active turn.
|
||
func (c *Controller) CancelRequested() bool {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return c.canceling
|
||
}
|
||
|
||
// PendingPrompt reports whether the current turn is blocked waiting for a user
|
||
// approval, plan approval, memory approval, or ask-tool answer.
|
||
func (c *Controller) PendingPrompt() bool {
|
||
return c.approval.hasPending()
|
||
}
|
||
|
||
// RuntimeStatus reports the active work owned by the foreground controller.
|
||
func (c *Controller) RuntimeStatus() RuntimeStatus {
|
||
c.mu.Lock()
|
||
running := c.running
|
||
active := running || c.finishing
|
||
canceling := c.canceling
|
||
c.mu.Unlock()
|
||
pending := c.approval.hasPending()
|
||
backgroundJobs := len(c.Jobs())
|
||
return RuntimeStatus{
|
||
Running: active,
|
||
PendingPrompt: pending,
|
||
BackgroundJobs: backgroundJobs,
|
||
CancelRequested: canceling,
|
||
Cancellable: running || pending,
|
||
}
|
||
}
|
||
|
||
// Turn returns the current turn number (0 before the first submit).
|
||
func (c *Controller) Turn() int {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return c.turn
|
||
}
|
||
|
||
// Approve answers a pending ApprovalRequest by ID: allow runs the call, session
|
||
// also remembers a grant for the rest of the session so the same approval scope
|
||
// is not re-prompted. Unknown/expired IDs are ignored.
|
||
func (c *Controller) Approve(id string, allow, session, persist bool) {
|
||
// Recovery cards are strict fresh decisions. Prefer ResolveRecovery so a
|
||
// continue/deny from an old client that only knows Approve still maps onto
|
||
// the recovery state machine (allow=continue, deny=revise without feedback).
|
||
// Session/persist grants are intentionally ignored for recovery.
|
||
//
|
||
// Lookup must use the live waiter table (HasApproval), not Snapshot: pre-
|
||
// normal-execution plan prompts park a waiter without an armed taskRuntime, so
|
||
// they never appear in the persistence snapshot.
|
||
c.mu.Lock()
|
||
gate := c.recoveryGate
|
||
c.mu.Unlock()
|
||
if gate != nil && gate.HasApproval(id) {
|
||
action := agent.RecoveryActionRevise
|
||
if allow {
|
||
action = agent.RecoveryActionContinue
|
||
}
|
||
_ = c.ResolveRecovery(id, action, "")
|
||
return
|
||
}
|
||
pending := c.approval.resolve(id)
|
||
if pending.reply == nil {
|
||
return
|
||
}
|
||
outcome := "deny"
|
||
if pending.tool == planApprovalTool {
|
||
outcome = string(PlanDecisionRevisePlan)
|
||
if allow {
|
||
outcome = string(PlanDecisionStartExecution)
|
||
}
|
||
} else if allow {
|
||
switch {
|
||
case persist:
|
||
outcome = "allow_persistent"
|
||
case session:
|
||
outcome = "allow_session"
|
||
default:
|
||
outcome = "allow_once"
|
||
}
|
||
}
|
||
c.recordDecisionReceipt(pending, outcome)
|
||
pending.reply <- approvalReply{allow: allow, session: session, persist: persist} // buffered, never blocks
|
||
}
|
||
|
||
// ResolvePlanDecision answers the Plan card without collapsing revise and exit
|
||
// into the generic approval boolean used by older clients.
|
||
func (c *Controller) ResolvePlanDecision(id string, action PlanDecisionAction) error {
|
||
if c == nil {
|
||
return fmt.Errorf("controller is nil")
|
||
}
|
||
id = strings.TrimSpace(id)
|
||
if id == "" {
|
||
return fmt.Errorf("empty plan approval id")
|
||
}
|
||
switch action {
|
||
case PlanDecisionStartExecution, PlanDecisionRevisePlan, PlanDecisionExitPlan:
|
||
default:
|
||
return fmt.Errorf("unknown plan decision %q", action)
|
||
}
|
||
pending, ok := c.approval.resolveTool(id, planApprovalTool)
|
||
if !ok || pending.reply == nil {
|
||
return fmt.Errorf("plan approval %q is no longer pending", id)
|
||
}
|
||
pending.kind = "plan"
|
||
c.recordDecisionReceipt(pending, string(action))
|
||
pending.reply <- approvalReply{allow: action == PlanDecisionStartExecution}
|
||
return nil
|
||
}
|
||
|
||
func (c *Controller) recordDecisionReceipt(pending pendingApproval, outcome string) {
|
||
if c == nil || c.executor == nil || pending.reply == nil {
|
||
return
|
||
}
|
||
kind := pending.kind
|
||
if kind == "" {
|
||
kind = "tool"
|
||
if pending.tool == planApprovalTool {
|
||
kind = "plan"
|
||
}
|
||
}
|
||
receipt := &provider.DecisionReceipt{
|
||
ID: pending.id,
|
||
Kind: kind,
|
||
Tool: strings.TrimSpace(pending.tool),
|
||
Subject: clipUTF8(strings.TrimSpace(pending.subject), 240),
|
||
Outcome: strings.TrimSpace(outcome),
|
||
}
|
||
// Keep the receipt bounded and provider-excluded even when an older caller
|
||
// omits optional approval metadata.
|
||
c.executor.Session().AddDecisionReceipt(receipt)
|
||
c.sink.Emit(event.Event{
|
||
Kind: event.Notice,
|
||
Code: event.NoticeCodeDecisionReceipt,
|
||
Level: event.LevelInfo,
|
||
Text: "Decision recorded: " + receipt.Outcome,
|
||
DecisionReceipt: receipt,
|
||
})
|
||
}
|
||
|
||
// EnableInteractiveApproval swaps the executor's gate for one that routes
|
||
// approval decisions to the frontend via ApprovalRequest events, and wires the
|
||
// controller in as the executor's Asker so the `ask` tool can question the user.
|
||
// Interactive frontends (chat, desktop) call this; the headless run keeps the
|
||
// silent gate and a nil asker from setup.
|
||
func (c *Controller) EnableInteractiveApproval() {
|
||
trustGate := planModeReadOnlyTrustApprover{c}
|
||
escapeApprover := sandboxEscapeApprover{c}
|
||
configApprover := managedConfigWriteApprover{c}
|
||
if c.executor != nil {
|
||
c.executor.SetGate(c.newInteractiveGate())
|
||
c.executor.SetPlanModeReadOnlyTrustGate(trustGate)
|
||
c.executor.SetSandboxEscapeApprover(escapeApprover)
|
||
c.executor.SetConfigWriteApprover(configApprover)
|
||
c.executor.SetAsker(c)
|
||
}
|
||
if setter, ok := c.runner.(interface {
|
||
SetPlanModeReadOnlyTrustGate(agent.PlanModeReadOnlyTrustGate)
|
||
}); ok {
|
||
setter.SetPlanModeReadOnlyTrustGate(trustGate)
|
||
}
|
||
if setter, ok := c.runner.(interface {
|
||
SetSandboxEscapeApprover(sandbox.EscapeApprover)
|
||
}); ok {
|
||
setter.SetSandboxEscapeApprover(escapeApprover)
|
||
}
|
||
if setter, ok := c.runner.(interface {
|
||
SetConfigWriteApprover(tool.ConfigWriteApprover)
|
||
}); ok {
|
||
setter.SetConfigWriteApprover(configApprover)
|
||
}
|
||
if setter, ok := c.runner.(interface {
|
||
SetPlannerPlanApprover(agent.PlannerPlanApprover)
|
||
}); ok {
|
||
setter.SetPlannerPlanApprover(plannerPlanApprover{c: c})
|
||
}
|
||
if setter, ok := c.runner.(interface {
|
||
SetPlannerUserDecisionAsker(agent.PlannerUserDecisionAsker)
|
||
}); ok {
|
||
setter.SetPlannerUserDecisionAsker(plannerUserDecisionAsker{c: c})
|
||
}
|
||
}
|
||
|
||
type plannerPlanApprover struct {
|
||
c *Controller
|
||
}
|
||
|
||
func (p plannerPlanApprover) RunWithPlannerApproval(ctx context.Context, plan string, run func(context.Context) error) error {
|
||
c := p.c
|
||
allow, _, err := c.requestApprovalWithReason(ctx, planApprovalTool, "", nil, "Planner requested host approval before execution.")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !allow {
|
||
return nil
|
||
}
|
||
todoArgs := c.seedPlanTodos(plan)
|
||
execStart := c.sessionMessageCount()
|
||
c.approval.setPlanAutoApprove(true)
|
||
defer c.approval.setPlanAutoApprove(false)
|
||
if err := run(ctx); err != nil {
|
||
return err
|
||
}
|
||
if todoArgs != "" && !c.hasTodoUpdateSince(execStart) {
|
||
c.completePlanTodos(todoArgs)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type plannerUserDecisionAsker struct {
|
||
c *Controller
|
||
}
|
||
|
||
func (p plannerUserDecisionAsker) RunWithPlannerUserDecision(ctx context.Context, _ string, question event.AskQuestion, run func(context.Context, string) error) error {
|
||
answers, err := p.c.Ask(ctx, []event.AskQuestion{question})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
answer := plannerUserDecisionAnswer(question, answers)
|
||
if strings.TrimSpace(answer) == "" {
|
||
return nil
|
||
}
|
||
return run(ctx, answer)
|
||
}
|
||
|
||
func plannerUserDecisionAnswer(question event.AskQuestion, answers []event.AskAnswer) string {
|
||
for _, answer := range answers {
|
||
if answer.QuestionID != question.ID {
|
||
continue
|
||
}
|
||
selected := make([]string, 0, len(answer.Selected))
|
||
for _, item := range answer.Selected {
|
||
if s := strings.TrimSpace(item); s != "" {
|
||
selected = append(selected, s)
|
||
}
|
||
}
|
||
return strings.Join(selected, ", ")
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (c *Controller) newInteractiveGate() *permission.Gate {
|
||
policy := c.policy
|
||
mode := c.approval.mode()
|
||
switch mode {
|
||
case ToolApprovalAuto, ToolApprovalYolo:
|
||
policy.Mode = permission.Allow
|
||
case ToolApprovalDontAsk:
|
||
policy.Mode = permission.Deny
|
||
default:
|
||
policy.Mode = permission.Ask
|
||
}
|
||
// A session allowlist (e.g. --allowed-tools) must never satisfy a tool that
|
||
// requires fresh human approval on every call — memory remember/forget, plan
|
||
// approval, sandbox escape, managed config write. SessionAllow is checked
|
||
// before Ask in Policy.Decide, so leaving those entries in would let
|
||
// `--allowed-tools remember` write memory with no prompt. Strip them so the
|
||
// forced Ask rules below stay authoritative.
|
||
policy.SessionAllow = rulesWithoutFreshHumanApproval(policy.SessionAllow)
|
||
policy.Ask = append(policy.Ask,
|
||
permission.Rule{Tool: memoryRememberTool},
|
||
permission.Rule{Tool: memoryForgetTool},
|
||
)
|
||
var approver permission.Approver = gateApprover{c}
|
||
if mode == ToolApprovalDontAsk {
|
||
approver = denyPermissionApprover{}
|
||
}
|
||
gate := permission.NewGate(policy, approver)
|
||
gate.OnRemember = func(rule string) {
|
||
if c.onRemember != nil {
|
||
_ = c.onRemember(rule)
|
||
}
|
||
}
|
||
return gate
|
||
}
|
||
|
||
func (c *Controller) allowLowRiskRemember(args json.RawMessage) bool {
|
||
mem := c.Memory()
|
||
if mem != nil {
|
||
if assessment := memory.AssessRememberWrite(mem.Store, args); assessment.AutoAllow {
|
||
c.memory.authorizeAutoRemember(args)
|
||
return true
|
||
}
|
||
}
|
||
c.memory.revokeAutoRemember(args)
|
||
return false
|
||
}
|
||
|
||
func (c *Controller) newHeadlessGate(mode string) *freshHumanHeadlessGate {
|
||
gate := BuildHeadlessApprovalGate(c.policy, mode)
|
||
gate.allowLowRiskFreshAction = func(toolName string, args json.RawMessage) bool {
|
||
return toolName == memoryRememberTool && c.allowLowRiskRemember(args)
|
||
}
|
||
return gate
|
||
}
|
||
|
||
type denyPermissionApprover struct{}
|
||
|
||
func (denyPermissionApprover) Approve(context.Context, string, string, json.RawMessage) (bool, bool, error) {
|
||
return false, false, nil
|
||
}
|
||
|
||
// rulesWithoutFreshHumanApproval drops any session-allow rule that targets a
|
||
// tool requiring fresh human approval, so an explicit allowlist cannot bypass
|
||
// the always-prompt contract for those tools.
|
||
func rulesWithoutFreshHumanApproval(rules []permission.Rule) []permission.Rule {
|
||
if len(rules) == 0 {
|
||
return rules
|
||
}
|
||
filtered := make([]permission.Rule, 0, len(rules))
|
||
for _, r := range rules {
|
||
if RequiresFreshHumanApprovalTool(r.Tool) {
|
||
continue
|
||
}
|
||
filtered = append(filtered, r)
|
||
}
|
||
return filtered
|
||
}
|
||
|
||
// ApplyHeadlessApprovalMode configures the executor gate for a non-interactive
|
||
// (`reasonix run`) session from an explicit --permission-mode. Unlike
|
||
// EnableInteractiveApproval it installs no blocking approver, asker, or
|
||
// fresh-approval prompt: there is no key loop to answer them, and the default
|
||
// infinite approval timeout would wedge the run forever on an Ask rule, the
|
||
// `ask` tool, or a sandbox/config approval. Modes map straight onto a headless
|
||
// gate, and each preserves the interactive contract as closely as a run with no
|
||
// one to prompt allows:
|
||
//
|
||
// - auto: auto-approve the writer fallback (Mode=Allow) but PRESERVE explicit
|
||
// ask rules. Interactive auto prompts on those (it never auto-approves them);
|
||
// headless can't prompt, so a would-ask decision fails closed (deny) rather
|
||
// than running silently. Only bypass may run such a command unattended.
|
||
// - yolo/bypassPermissions: skip ordinary approval-gated decisions (nil
|
||
// approver); deny rules and fresh decisions still fail closed.
|
||
// - dontAsk: deny anything that would ask, and deny the writer fallback too.
|
||
//
|
||
// Deny rules and fresh-human tools (memory, plan, sandbox, config) stay enforced
|
||
// by the gate for every mode. The only exception is a controller-assessed,
|
||
// create-only project/reference memory; every other memory write remains denied.
|
||
func (c *Controller) ApplyHeadlessApprovalMode(mode string) {
|
||
mode = normalizeToolApprovalMode(mode)
|
||
c.approval.setMode(mode)
|
||
if c.subagentGate != nil {
|
||
c.subagentGate.Update(mode)
|
||
}
|
||
if c.executor != nil {
|
||
c.executor.SetGate(c.newHeadlessGate(mode))
|
||
}
|
||
}
|
||
|
||
func (c *Controller) refreshInteractiveGate() {
|
||
if c.executor != nil {
|
||
c.executor.SetGate(c.newInteractiveGate())
|
||
}
|
||
}
|
||
|
||
// TrySteer queues mid-turn guidance only when the active agent turn accepts it.
|
||
func (c *Controller) TrySteer(text string) bool {
|
||
c.mu.Lock()
|
||
exec := c.executor
|
||
running := c.running
|
||
c.mu.Unlock()
|
||
return running && exec != nil && exec.Steer(text)
|
||
}
|
||
|
||
// Steer is the compatibility path for callers that cannot observe admission.
|
||
// Interactive hosts should call TrySteer so a rejected steer remains in their
|
||
// draft/queue and can be retried as a regular follow-up.
|
||
func (c *Controller) Steer(text string) {
|
||
if c.TrySteer(text) {
|
||
return
|
||
}
|
||
// No active turn accepted the steer: the frontend's runningRef was stale,
|
||
// the turn exited between our running check and the enqueue, or no
|
||
// executor is bound yet. Deliver it as a regular turn instead.
|
||
c.submitSteerFallback(text)
|
||
}
|
||
|
||
// submitSteerFallback records steer text that no active turn accepted as
|
||
// unapplied guidance, not as a new task. This compatibility path deliberately
|
||
// never opens a provider turn: replaying stale historical guidance as the
|
||
// user's current request caused unintended code changes (#7045).
|
||
func (c *Controller) submitSteerFallback(text string) admissionResult {
|
||
return c.runGuardedOrPark(func(context.Context) error {
|
||
if c.executor != nil {
|
||
c.executor.RecordUnappliedSteer(text)
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// SteerConsumed returns true when the steer queue is empty after the last consume.
|
||
func (c *Controller) SteerConsumed() bool {
|
||
c.mu.Lock()
|
||
exec := c.executor
|
||
c.mu.Unlock()
|
||
if exec != nil {
|
||
return exec.SteerConsumed()
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Ask implements agent.Asker: it emits an AskRequest and blocks until
|
||
// AnswerQuestion(ID, …) answers or ctx is cancelled. promptMu serialises it
|
||
// against tool-approval prompts so at most one user prompt is outstanding.
|
||
// Unlike tool-approval gates, Ask is NOT bypassed in YOLO mode — the `ask`
|
||
// tool exists to get a genuine user decision, and YOLO only auto-approves
|
||
// tool calls; it must not answer the user's questions for them.
|
||
func (c *Controller) Ask(ctx context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error) {
|
||
c.approval.promptMu.Lock()
|
||
defer c.approval.promptMu.Unlock()
|
||
|
||
c.approval.promptEmitMu.Lock()
|
||
id, reply := c.approval.registerAsk(questions)
|
||
c.sink.Emit(event.Event{Kind: event.AskRequest, Ask: event.Ask{ID: id, Questions: questions}})
|
||
c.approval.promptEmitMu.Unlock()
|
||
|
||
waitCtx, cancelWait := c.approval.waitContext(ctx)
|
||
defer cancelWait()
|
||
|
||
select {
|
||
case ans := <-reply:
|
||
return ans, nil
|
||
case <-waitCtx.Done():
|
||
c.approval.cancelAsk(id)
|
||
return nil, waitCtx.Err()
|
||
}
|
||
}
|
||
|
||
// AnswerQuestion resolves a pending AskRequest by ID with the user's selections.
|
||
// Unknown/expired IDs are ignored.
|
||
func (c *Controller) AnswerQuestion(id string, answers []event.AskAnswer) {
|
||
if pending, ok := c.approval.resolveAsk(id); ok {
|
||
// An answer batch with no selections is the explicit "skip and continue
|
||
// chat" path. End the current turn instead of feeding a prose dismissal
|
||
// back to the model and trusting it not to ask again (#6869).
|
||
if !askAnswersHaveSelection(answers) {
|
||
c.mu.Lock()
|
||
activeTurn := c.cancel != nil
|
||
c.mu.Unlock()
|
||
if activeTurn {
|
||
c.Cancel()
|
||
return
|
||
}
|
||
}
|
||
c.recordAskDecisionReceipt(id, pending, answers)
|
||
pending.reply <- answers // buffered, never blocks
|
||
}
|
||
}
|
||
|
||
func (c *Controller) recordAskDecisionReceipt(id string, pending pendingAsk, answers []event.AskAnswer) {
|
||
if c == nil || c.executor == nil {
|
||
return
|
||
}
|
||
selected := make(map[string][]string, len(answers))
|
||
for _, answer := range answers {
|
||
selected[answer.QuestionID] = append([]string(nil), answer.Selected...)
|
||
}
|
||
parts := make([]string, 0, len(pending.questions))
|
||
for _, question := range pending.questions {
|
||
answer := strings.TrimSpace(strings.Join(selected[question.ID], ", "))
|
||
if answer == "" {
|
||
answer = "—"
|
||
}
|
||
prompt := strings.TrimSpace(question.Prompt)
|
||
if prompt == "" {
|
||
prompt = strings.TrimSpace(question.Header)
|
||
}
|
||
if prompt == "" {
|
||
prompt = question.ID
|
||
}
|
||
parts = append(parts, prompt+": "+answer)
|
||
}
|
||
receipt := &provider.DecisionReceipt{
|
||
ID: id,
|
||
Kind: "ask",
|
||
Subject: clipUTF8(strings.Join(parts, " · "), 240),
|
||
Outcome: "answered",
|
||
}
|
||
c.executor.Session().AddDecisionReceipt(receipt)
|
||
c.sink.Emit(event.Event{
|
||
Kind: event.Notice,
|
||
Code: event.NoticeCodeDecisionReceipt,
|
||
Level: event.LevelInfo,
|
||
Text: "Decision recorded: answered",
|
||
DecisionReceipt: receipt,
|
||
})
|
||
}
|
||
|
||
func askAnswersHaveSelection(answers []event.AskAnswer) bool {
|
||
for _, answer := range answers {
|
||
if len(answer.Selected) > 0 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ReplayPendingPrompts re-emits the ApprovalRequest / AskRequest event for every
|
||
// prompt currently blocking the run loop. A frontend that reconnected or reloaded
|
||
// after the original event has no way to rebuild its approval/ask modal otherwise,
|
||
// so the blocked gate goroutine stays stuck forever while the session shows a
|
||
// "waiting" status with no actionable prompt. promptMu serialises Ask and
|
||
// requestApproval, so in practice at most one prompt is outstanding; the loops
|
||
// stay general so a future concurrent prompt would still replay correctly.
|
||
func (c *Controller) ReplayPendingPrompts() {
|
||
c.approval.promptEmitMu.Lock()
|
||
noApprovals := c.replayPendingPromptsTo(c.sink)
|
||
c.approval.promptEmitMu.Unlock()
|
||
if noApprovals {
|
||
// Retained compatibility hook; live Auto Guard cards are ordinary approvals.
|
||
c.ReplayUnresolvedRecoveries()
|
||
}
|
||
}
|
||
|
||
// ReplayPendingPromptsTo re-emits pending prompts to one frontend sink. Serve
|
||
// uses this for a newly attached SSE client so existing browsers do not receive
|
||
// duplicate approval/ask cards when another client reconnects.
|
||
func (c *Controller) ReplayPendingPromptsTo(sink event.Sink) {
|
||
c.approval.promptEmitMu.Lock()
|
||
defer c.approval.promptEmitMu.Unlock()
|
||
c.replayPendingPromptsTo(sink)
|
||
}
|
||
|
||
// ReplayPendingPromptsWith performs an SSE connection handoff while prompt
|
||
// registration and emission are paused. The factory must subscribe the new
|
||
// client and return a sink that targets it; this closes the attach race where
|
||
// the original prompt could otherwise land between Subscribe and replay.
|
||
func (c *Controller) ReplayPendingPromptsWith(sinkFactory func() event.Sink) {
|
||
if sinkFactory == nil {
|
||
return
|
||
}
|
||
c.approval.promptEmitMu.Lock()
|
||
defer c.approval.promptEmitMu.Unlock()
|
||
c.replayPendingPromptsTo(sinkFactory())
|
||
}
|
||
|
||
func (c *Controller) replayPendingPromptsTo(sink event.Sink) bool {
|
||
approvals, asks := c.approval.snapshotPrompts()
|
||
c.emitPendingPrompts(sink, approvals, asks)
|
||
return len(approvals) == 0
|
||
}
|
||
|
||
func (c *Controller) emitPendingPrompts(sink event.Sink, approvals []event.Approval, asks []event.Ask) {
|
||
if sink == nil {
|
||
return
|
||
}
|
||
for _, a := range approvals {
|
||
sink.Emit(c.approvalRequestEvent(a))
|
||
}
|
||
for _, a := range asks {
|
||
sink.Emit(event.Event{Kind: event.AskRequest, Ask: a})
|
||
}
|
||
}
|
||
|
||
// SetPlanMode flips the executor's plan-first workflow flag without touching the
|
||
// cache-stable system/tool prefix, and remembers the state so Compose can prepend
|
||
// the plan-mode marker to outgoing user turns.
|
||
func (c *Controller) SetPlanMode(v bool) {
|
||
c.applyPlanMode(v)
|
||
}
|
||
|
||
func (c *Controller) applyPlanMode(v bool) {
|
||
c.mu.Lock()
|
||
c.planMode = v
|
||
c.mu.Unlock()
|
||
if setter, ok := c.runner.(interface{ SetPlanMode(bool) }); ok {
|
||
setter.SetPlanMode(v)
|
||
return
|
||
}
|
||
if c.executor != nil {
|
||
c.executor.SetPlanMode(v)
|
||
}
|
||
}
|
||
|
||
// SetResponseLanguage updates the final-answer language preference for
|
||
// subsequent turns.
|
||
func (c *Controller) SetResponseLanguage(lang string) {
|
||
mode := config.NormalizeLanguage(lang)
|
||
c.mu.Lock()
|
||
c.responseLanguage = mode
|
||
c.mu.Unlock()
|
||
if setter, ok := c.runner.(interface{ SetResponseLanguage(string) }); ok {
|
||
setter.SetResponseLanguage(mode)
|
||
} else if c.executor != nil {
|
||
c.executor.SetResponseLanguage(mode)
|
||
}
|
||
}
|
||
|
||
// SetReasoningLanguage updates the visible reasoning language preference for
|
||
// subsequent turns.
|
||
func (c *Controller) SetReasoningLanguage(lang string) {
|
||
mode := config.NormalizeReasoningLanguage(lang)
|
||
c.mu.Lock()
|
||
c.reasoningLanguage = mode
|
||
c.mu.Unlock()
|
||
if setter, ok := c.runner.(interface{ SetReasoningLanguage(string) }); ok {
|
||
setter.SetReasoningLanguage(mode)
|
||
} else if c.executor != nil {
|
||
c.executor.SetReasoningLanguage(mode)
|
||
}
|
||
}
|
||
|
||
// PlanMode reports whether outgoing turns currently receive the plan-mode
|
||
// marker.
|
||
func (c *Controller) PlanMode() bool {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return c.planMode
|
||
}
|
||
|
||
// GoalStrict enables or disables strict goal mode. Since the structured
|
||
// protocol, every complete claim is validated against host readiness and an
|
||
// incomplete-todo intercept can never be overridden, so the flag is persisted
|
||
// for compatibility with older frontends but no longer changes FSM behavior.
|
||
func (c *Controller) GoalStrict(strict bool) {
|
||
path, data, ok := c.goals.setStrict(strict, c.goalTodos())
|
||
c.persistGoalState(path, data, ok)
|
||
}
|
||
|
||
// SetGoal stores a session-scoped active goal. Compose injects it into outgoing
|
||
// user turns, not the system prompt or tool schema, so it does not disturb the
|
||
// cache-stable prefix.
|
||
func (c *Controller) SetGoal(goal string) {
|
||
c.SetGoalWithResearchMode(goal, GoalResearchAuto)
|
||
}
|
||
|
||
// SetGoalDurable updates the Goal only when its sidecar can be replaced
|
||
// atomically. Remote Profile transactions persist autoResearchCreateToken
|
||
// before calling this method so crash recovery owns any newly-created task.
|
||
func (c *Controller) SetGoalDurable(goal, autoResearchCreateToken string) error {
|
||
snapshot := c.goals.capture()
|
||
setup := c.prepareAutoResearchTask(goal, GoalResearchAuto, autoResearchCreateToken)
|
||
path, data, persist := c.goals.set(goal, GoalResearchAuto, setup.taskID, c.goalTodos())
|
||
if setup.blockReason != "" {
|
||
path, data, persist = c.goals.stop(GoalStatusBlocked, c.goalTodos())
|
||
}
|
||
if persist {
|
||
if err := c.goals.writeStateErr(path, data); err != nil {
|
||
c.goals.restore(snapshot)
|
||
if setup.created && c.autoResearch.enabled() {
|
||
if removeErr := c.autoResearch.removeTask(setup.taskID, setup.createToken); removeErr != nil {
|
||
slog.Warn("controller: rollback autoresearch task", "task_id", setup.taskID, "err", removeErr)
|
||
}
|
||
}
|
||
return err
|
||
}
|
||
}
|
||
if setup.notice != "" {
|
||
c.notice(setup.notice)
|
||
}
|
||
if setup.blockReason != "" {
|
||
c.notice("autoresearch resume failed: " + setup.blockReason)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c *Controller) SetGoalWithResearchMode(goal string, researchMode GoalResearchMode) {
|
||
setup := c.prepareAutoResearchTask(goal, researchMode, "")
|
||
if setup.notice != "" {
|
||
c.notice(setup.notice)
|
||
}
|
||
path, data, ok := c.goals.set(goal, researchMode, setup.taskID, c.goalTodos())
|
||
c.persistGoalState(path, data, ok)
|
||
if setup.blockReason != "" {
|
||
path, data, ok := c.goals.stop(GoalStatusBlocked, c.goalTodos())
|
||
c.persistGoalState(path, data, ok)
|
||
c.notice("autoresearch resume failed: " + setup.blockReason)
|
||
}
|
||
}
|
||
|
||
// ResumeGoal re-enters a recoverable blocked/stopped Goal without resetting its
|
||
// delivery evidence scope or AutoResearch identity. A budget-paused Goal gets
|
||
// one extra slice of its budget class; accumulated consumption is preserved.
|
||
func (c *Controller) ResumeGoal() bool {
|
||
path, data, persist, resumed, extended := c.goals.resume(c.goalTodos())
|
||
if !resumed {
|
||
return false
|
||
}
|
||
c.persistGoalState(path, data, persist)
|
||
if extended {
|
||
c.notice(i18n.M.GoalBudgetExtended)
|
||
}
|
||
if c.executor != nil {
|
||
c.executor.RestoreDeliveryCheckpoint(c.goals.deliveryState())
|
||
}
|
||
return true
|
||
}
|
||
|
||
// PauseGoal suspends a running Goal without losing its todo list, Delivery
|
||
// checkpoint, or budget history; ResumeGoal restores it. Returns false when no
|
||
// running Goal exists.
|
||
func (c *Controller) PauseGoal() bool {
|
||
if !c.goals.active() {
|
||
return false
|
||
}
|
||
path, data, ok := c.goals.pauseFor(stopCauseManual, i18n.M.GoalPausedReason, c.goalTodos())
|
||
c.persistGoalState(path, data, ok)
|
||
c.notice(i18n.M.GoalPaused)
|
||
return true
|
||
}
|
||
|
||
// GoalRuntime returns the active Goal's budget/runtime summary for frontends.
|
||
func (c *Controller) GoalRuntime() GoalRuntimeView {
|
||
return c.goals.runtimeView()
|
||
}
|
||
|
||
// goalEvaluatorEvidence assembles the bounded evaluator's evidence: the goal
|
||
// contract, the current assistant final, a todo/readiness summary, the
|
||
// AutoResearch success-criteria summary, turn/budget state, and the last
|
||
// continuation reason. Every field is treated as untrusted by the evaluator.
|
||
func (c *Controller) goalEvaluatorEvidence() goaleval.GoalEvidence {
|
||
goal, _, mode, taskID := c.goals.snapshot()
|
||
ev := goaleval.GoalEvidence{
|
||
GoalContract: goal,
|
||
LastContinuationReason: c.goals.lastContinuationReasonText(),
|
||
}
|
||
if c.executor != nil {
|
||
ev.AssistantFinal = lastAssistantText(c.History())
|
||
todos := c.goalTodos()
|
||
incomplete := 0
|
||
for _, t := range todos {
|
||
if t.Status != "completed" {
|
||
incomplete++
|
||
}
|
||
}
|
||
rr := c.executor.ReadinessResult()
|
||
readinessText := "ready"
|
||
if rr.Reason != "" {
|
||
readinessText = rr.Reason
|
||
}
|
||
ev.TodoSummary = fmt.Sprintf("todos: %d total, %d incomplete; delivery readiness: %s", len(todos), incomplete, readinessText)
|
||
}
|
||
if c.autoResearch.enabled() && strings.TrimSpace(taskID) != "" {
|
||
if summary, err := c.autoResearch.summary(taskID); err == nil {
|
||
ev.AutoResearchSummary = fmt.Sprintf("task %s: iteration %d, %d open success criteria, next required action: %s",
|
||
summary.TaskID, summary.Iteration, len(summary.OpenCriteria), summary.NextRequiredAction)
|
||
}
|
||
}
|
||
ev.TurnStatus = c.goals.budgetStatusText() + "; research mode: " + goalResearchModeText(mode)
|
||
return ev
|
||
}
|
||
|
||
func goalResearchModeText(mode GoalResearchMode) string {
|
||
switch mode {
|
||
case GoalResearchOn:
|
||
return "on"
|
||
case GoalResearchOff:
|
||
return "off"
|
||
default:
|
||
return "auto"
|
||
}
|
||
}
|
||
|
||
func (c *Controller) persistGoalDeliveryCheckpoint() {
|
||
if c.executor == nil {
|
||
return
|
||
}
|
||
checkpoint := c.executor.DeliveryCheckpoint()
|
||
path, data, ok := c.goals.setDeliveryCheckpoint(checkpoint, c.goalTodos())
|
||
c.persistGoalState(path, data, ok)
|
||
}
|
||
|
||
func (c *Controller) ClearGoal() {
|
||
c.SetGoal("")
|
||
}
|
||
|
||
func (c *Controller) Goal() string {
|
||
return c.goals.goalText()
|
||
}
|
||
|
||
func (c *Controller) GoalStatus() string {
|
||
return c.goals.statusForDisplay()
|
||
}
|
||
|
||
// Compact runs one compaction pass on the executor's session on demand.
|
||
// instructions is optional `/compact <focus>` guidance steering what to keep.
|
||
func (c *Controller) Compact(ctx context.Context, instructions string) error {
|
||
if c.executor == nil {
|
||
return nil
|
||
}
|
||
// The run loop is the only sanctioned writer of the live session during a
|
||
// turn; a manual compact would rewrite the log underneath it. The rotation
|
||
// gate (not a bare Running() check) also blocks a turn from starting while
|
||
// the compaction rewrites the session — see beginRotation.
|
||
if err := c.beginRotation(); err != nil {
|
||
if errors.Is(err, errTurnRunningRotation) {
|
||
return fmt.Errorf("cannot compact while a turn is running")
|
||
}
|
||
return err
|
||
}
|
||
defer c.endRotation()
|
||
return c.executor.CompactNow(ctx, instructions)
|
||
}
|
||
|
||
// maybeSessionStart fires the SessionStart hook exactly once per session, lazily
|
||
// on the first turn — by then the sink/notify is wired, and a resumed session
|
||
// fires it too (its first post-resume turn).
|
||
func (c *Controller) maybeSessionStart(ctx context.Context) {
|
||
c.hooks.SetSessionID(c.parentSessionID())
|
||
c.mu.Lock()
|
||
if c.startedOnce {
|
||
c.mu.Unlock()
|
||
return
|
||
}
|
||
c.startedOnce = true
|
||
c.mu.Unlock()
|
||
c.enqueueHookContexts(c.hooks.SessionStart(ctx))
|
||
c.extensionSessionEvent(extension.PointSessionStart, dispatch.PhaseStart, c.SessionPath())
|
||
}
|
||
|
||
// NewSession snapshots the current conversation, rotates to a fresh file, and
|
||
// resets the executor to a clean session carrying the same system prompt. It
|
||
// ends the old session and starts the new one for lifecycle hooks.
|
||
func (c *Controller) NewSession() error {
|
||
if c.executor == nil {
|
||
return nil
|
||
}
|
||
// Claim the rotation gate for the whole snapshot-then-swap sequence. A bare
|
||
// `if c.running` check released before Snapshot() left a window where a turn
|
||
// could start during the snapshot and then have its live session replaced by
|
||
// the SetSession below. Submit ("/new") and the bot gateway call this
|
||
// asynchronously, so the gate is load-bearing, not defensive.
|
||
if err := c.beginRotation(); err != nil {
|
||
return err
|
||
}
|
||
defer c.endRotation()
|
||
// Retire asynchronous recovery writes before Snapshot publishes the final
|
||
// old-session checkpoint. Otherwise an earlier write can outlive the path
|
||
// rotation (or process teardown) and race cleanup of the old session.
|
||
oldPath := c.SessionPath()
|
||
c.flushRecoveryPersistence(oldPath)
|
||
if err := c.Snapshot(); err != nil {
|
||
return err
|
||
}
|
||
// session.rotate: the session_policy owner rules on the rotation before
|
||
// anything is torn down, so its failure (required-class) aborts the
|
||
// rotation cleanly. SessionPath is the file being rotated away from; the
|
||
// fresh path arrives with the session.start event below.
|
||
if err := c.extensionSessionPhase(context.Background(), extension.PointSessionRotate, dispatch.PhaseRotate, oldPath); err != nil {
|
||
return err
|
||
}
|
||
c.hooks.SessionEnd(context.Background(), "clear")
|
||
c.extensionSessionEvent(extension.PointSessionEnd, dispatch.PhaseEnd, oldPath)
|
||
// Hold snapshotMu across the swap so an in-flight save cannot pair the old
|
||
// path with the fresh session (or the fresh path with the old session).
|
||
c.snapshotMu.Lock()
|
||
if c.sessionDir != "" {
|
||
c.mu.Lock()
|
||
c.sessionPath = agent.NewSessionPath(c.sessionDir, c.label)
|
||
c.guardianPath = guardian.PathFor(c.sessionPath)
|
||
c.mu.Unlock()
|
||
}
|
||
c.setActiveJobSession(c.SessionPath())
|
||
c.executor.SetSession(agent.NewSession(c.systemPrompt))
|
||
c.bindExecutorProjection(c.SessionPath(), false)
|
||
if c.guardianSess != nil {
|
||
c.guardianSess.Reset()
|
||
}
|
||
c.ResetPlannerSession()
|
||
freshPath := c.SessionPath()
|
||
c.rebindCheckpoints(freshPath)
|
||
c.resetRecoveryForNewSession(freshPath)
|
||
c.rotateSessionTemp()
|
||
c.snapshotMu.Unlock()
|
||
// A new session starts with no active goal: without this, a running goal's
|
||
// text kept injecting into the fresh session's first turns. The old
|
||
// session's goal-state sidecar was persisted before the rotation and stays
|
||
// intact, so resuming it restores its goal; the cleared state below lands
|
||
// on the NEW path (rebindCheckpoints just moved it).
|
||
c.ClearGoal()
|
||
c.mu.Lock()
|
||
c.startedOnce = true // NewSession fires SessionStart itself; don't re-fire on the next turn
|
||
c.mu.Unlock()
|
||
c.hooks.SetSessionID(c.parentSessionID())
|
||
c.enqueueHookContexts(c.hooks.SessionStart(context.Background(), "clear"))
|
||
c.extensionSessionEvent(extension.PointSessionStart, dispatch.PhaseStart, c.SessionPath())
|
||
return nil
|
||
}
|
||
|
||
// ClearSession discards the current conversation without preserving it in
|
||
// resume/history, then rotates to a clean session carrying the same system prompt.
|
||
func (c *Controller) ClearSession() error {
|
||
if c.executor == nil {
|
||
return nil
|
||
}
|
||
// Same rotation gate as NewSession: hold it across the whole
|
||
// destroy-then-swap so a turn cannot start during the sequence and have its
|
||
// live session replaced.
|
||
if err := c.beginRotation(); err != nil {
|
||
if errors.Is(err, errTurnRunningRotation) {
|
||
return fmt.Errorf("cannot clear while a turn is running")
|
||
}
|
||
return err
|
||
}
|
||
defer c.endRotation()
|
||
c.mu.Lock()
|
||
oldPath := c.sessionPath
|
||
c.mu.Unlock()
|
||
preMarkedCleanup := c.hasUnfinishedSessionJobs(oldPath)
|
||
if preMarkedCleanup {
|
||
if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
// Retire the old recovery state before deleting its artifacts. Async gate
|
||
// snapshots are path-bound, so wait for every already-scheduled old-path
|
||
// write; otherwise one can recreate the sidecar after removeSessionArtifacts.
|
||
c.loadRecoveryState("")
|
||
c.flushRecoveryPersistence(oldPath)
|
||
// session.rotate: the session_policy owner rules on the rotation before any
|
||
// artifact is destroyed, so its failure (required-class) aborts the clear
|
||
// with the old session fully intact. SessionPath is the file being rotated
|
||
// away from; the fresh path arrives with the session.start event below.
|
||
if err := c.extensionSessionPhase(context.Background(), extension.PointSessionRotate, dispatch.PhaseRotate, oldPath); err != nil {
|
||
return err
|
||
}
|
||
// Hold snapshotMu from artifact removal through the swap: a save slipping
|
||
// in between would resurrect the just-removed transcript, and one that
|
||
// overlapped the swap could pair the old path with the fresh session.
|
||
c.snapshotMu.Lock()
|
||
destroy := c.BeginDestroySession(oldPath)
|
||
if !destroy.Async {
|
||
if err := removeSessionArtifacts(oldPath); err != nil {
|
||
destroy.Finish()
|
||
c.snapshotMu.Unlock()
|
||
return err
|
||
}
|
||
destroy.Finish()
|
||
}
|
||
c.hooks.SessionEnd(context.Background(), "clear")
|
||
c.extensionSessionEvent(extension.PointSessionEnd, dispatch.PhaseEnd, oldPath)
|
||
if c.sessionDir != "" {
|
||
c.mu.Lock()
|
||
c.sessionPath = agent.NewSessionPath(c.sessionDir, c.label)
|
||
c.guardianPath = guardian.PathFor(c.sessionPath)
|
||
c.mu.Unlock()
|
||
}
|
||
c.setActiveJobSession(c.SessionPath())
|
||
c.executor.SetSession(agent.NewSession(c.systemPrompt))
|
||
c.bindExecutorProjection(c.SessionPath(), false)
|
||
if c.guardianSess != nil {
|
||
c.guardianSess.Reset()
|
||
}
|
||
c.ResetPlannerSession()
|
||
freshPath := c.SessionPath()
|
||
c.rebindCheckpoints(freshPath)
|
||
c.resetRecoveryForNewSession(freshPath)
|
||
c.rotateSessionTemp()
|
||
c.snapshotMu.Unlock()
|
||
// Same contract as NewSession: the fresh session starts with no active goal.
|
||
c.ClearGoal()
|
||
c.mu.Lock()
|
||
c.startedOnce = true
|
||
c.mu.Unlock()
|
||
c.hooks.SetSessionID(c.parentSessionID())
|
||
c.enqueueHookContexts(c.hooks.SessionStart(context.Background(), "clear"))
|
||
c.extensionSessionEvent(extension.PointSessionStart, dispatch.PhaseStart, c.SessionPath())
|
||
if destroy.Async {
|
||
go func() {
|
||
result := destroy.Wait()
|
||
if result.HasTimedOut() && destroy.WaitAll != nil {
|
||
if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil {
|
||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "mark cleanup pending failed: " + err.Error()})
|
||
}
|
||
destroy.WaitAll()
|
||
}
|
||
if err := removeSessionArtifacts(oldPath); err != nil {
|
||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "clear session cleanup failed: " + err.Error()})
|
||
}
|
||
destroy.Finish()
|
||
}()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (c *Controller) hasUnfinishedSessionJobs(sessionPath string) bool {
|
||
if c.jobs == nil {
|
||
return false
|
||
}
|
||
return c.jobs.HasUnfinishedForSession(agent.BranchID(sessionPath))
|
||
}
|
||
|
||
func removeSessionArtifacts(path string) error {
|
||
if path == "" {
|
||
return nil
|
||
}
|
||
if err := jobs.RemoveArtifacts(path); err != nil {
|
||
return err
|
||
}
|
||
remove := []string{path}
|
||
// Sidecars include the event log — the authoritative transcript. Leaving
|
||
// it behind would both leak the cleared conversation and let LoadSession
|
||
// resurrect it on the recycled path. The guardian transcript saves through
|
||
// the same session layer, so its sidecars are swept too.
|
||
remove = append(remove, store.SessionSidecarFiles(path)...)
|
||
remove = append(remove, guardian.PathFor(path), guardian.CursorPathFor(path))
|
||
remove = append(remove, store.SessionSidecarFiles(guardian.PathFor(path))...)
|
||
for _, p := range remove {
|
||
if p == "" {
|
||
continue
|
||
}
|
||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||
return err
|
||
}
|
||
}
|
||
if dir := ckptDir(path); dir != "" {
|
||
if err := os.RemoveAll(dir); err != nil && !os.IsNotExist(err) {
|
||
return err
|
||
}
|
||
}
|
||
if err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {
|
||
return err
|
||
}
|
||
if err := agent.ClearCleanupPending(path); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RemoveSessionArtifacts removes a transcript and every durable artifact owned
|
||
// by it. Remote runtimes use this when a newly-created fork fails before it can
|
||
// be registered as a live session.
|
||
func RemoveSessionArtifacts(path string) error {
|
||
return removeSessionArtifacts(path)
|
||
}
|
||
|
||
// ReconcileCleanupPending retries physical cleanup for logically removed
|
||
// sessions that were left behind by a previous process.
|
||
func ReconcileCleanupPending(dir string) error {
|
||
return agent.ReconcileCleanupPending(dir, func(item agent.CleanupPendingInfo) error {
|
||
return removeSessionArtifacts(item.SessionPath)
|
||
})
|
||
}
|
||
|
||
// RewindScope selects what a Rewind restores.
|
||
type RewindScope int
|
||
|
||
const (
|
||
RewindCode RewindScope = iota // files only
|
||
RewindConversation // message log only
|
||
RewindBoth // both
|
||
)
|
||
|
||
// Checkpoints lists the session's rewind points (one per user turn), oldest first.
|
||
//
|
||
// Each Meta.Prompt is reduced to what the user typed. A checkpoint opens with
|
||
// the composed turn, so the stored prompt can carry the plan-mode marker and
|
||
// transient blocks; every consumer of this list is a label (the rewind picker,
|
||
// the desktop change list, the workbench projection) and the picker also
|
||
// restores the prompt into the composer, so composed text must not reach them.
|
||
// Stripping on read rather than only on write keeps checkpoints already on disk
|
||
// readable — they were recorded composed.
|
||
func (c *Controller) Checkpoints() []checkpoint.Meta {
|
||
metas := c.checkpoints.list()
|
||
for i := range metas {
|
||
metas[i].Prompt = StripComposePrefixes(metas[i].Prompt)
|
||
}
|
||
return metas
|
||
}
|
||
|
||
func (c *Controller) CheckpointFileState(path string) (checkpoint.FileState, bool) {
|
||
return c.checkpoints.fileState(path)
|
||
}
|
||
|
||
func (c *Controller) CheckpointTurnsByMessageIndex() map[int]int {
|
||
return c.checkpoints.turnsByMessageIndex()
|
||
}
|
||
|
||
// rewindFail emits the error as a Warn notice (so a frontend that swallows the
|
||
// returned error — e.g. the desktop bridge's .catch — still shows the user why
|
||
// the rewind did nothing) and returns it.
|
||
func (c *Controller) rewindFail(err error) error {
|
||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: err.Error()})
|
||
return err
|
||
}
|
||
|
||
// Rewind is implemented in rewind.go (transactional conversation+file restore).
|
||
|
||
// Fork branches the conversation at the start of turn into a NEW session file,
|
||
// preserving the current one as the branch point, and switches to the branch. Code
|
||
// is untouched (it's a conversation operation). Like a conversation rewind it needs
|
||
// the live boundary, so it is unavailable for resumed-session turns and refused
|
||
// while a turn runs. Returns the new session path.
|
||
func (c *Controller) Fork(turn int) (string, error) {
|
||
return c.ForkNamed(turn, "")
|
||
}
|
||
|
||
func (c *Controller) ForkNamed(turn int, name string) (string, error) {
|
||
return c.forkNamed(turn, name, true)
|
||
}
|
||
|
||
// ForkSession copies the conversation at the start of turn into a new session
|
||
// file without switching this controller to it. Desktop uses this to open the
|
||
// branch in a new tab while the source tab keeps its current transcript.
|
||
func (c *Controller) ForkSession(turn int, name string) (string, error) {
|
||
return c.forkNamed(turn, name, false)
|
||
}
|
||
|
||
func (c *Controller) forkNamed(turn int, name string, switchToFork bool) (string, error) {
|
||
if c.executor == nil {
|
||
return "", c.rewindFail(fmt.Errorf("checkpoints unavailable"))
|
||
}
|
||
if c.sessionDir == "" {
|
||
return "", c.rewindFail(fmt.Errorf("fork needs session persistence, which is disabled"))
|
||
}
|
||
// Hold the rotation gate from before the pre-fork Snapshot through the
|
||
// switch below: a bare Running() check released here would let a turn start
|
||
// during the snapshot and then be switched onto the fork.
|
||
if err := c.beginRotation(); err != nil {
|
||
if errors.Is(err, errTurnRunningRotation) {
|
||
return "", c.rewindFail(fmt.Errorf("cannot fork while a turn is running"))
|
||
}
|
||
return "", c.rewindFail(err)
|
||
}
|
||
defer c.endRotation()
|
||
boundary, hasBound := c.checkpoints.boundary(turn)
|
||
if !hasBound {
|
||
return "", c.rewindFail(fmt.Errorf("fork unavailable for turn %d (resumed session)", turn))
|
||
}
|
||
|
||
// Persist the current conversation first so the branch point survives, then
|
||
// seed a fresh session with the messages up to the fork and switch to it.
|
||
if err := c.Snapshot(); err != nil {
|
||
slog.Warn("controller: pre-fork snapshot", "err", err)
|
||
}
|
||
parentPath := c.SessionPath()
|
||
parentID := agent.BranchID(parentPath)
|
||
src := c.executor.Session().Snapshot()
|
||
if boundary > len(src) {
|
||
boundary = len(src)
|
||
}
|
||
forked := append([]provider.Message(nil), src[:boundary]...)
|
||
sess := agent.NewSession("")
|
||
sess.Messages = forked
|
||
|
||
newPath := agent.NewSessionPath(c.sessionDir, c.label)
|
||
if err := sess.Save(newPath); err != nil {
|
||
return "", c.rewindFail(err)
|
||
}
|
||
forkPreview, forkTurns := agent.SessionPreviewFromMessages(forked)
|
||
if err := agent.SaveBranchMeta(newPath, agent.BranchMeta{
|
||
Name: strings.TrimSpace(name),
|
||
ParentID: parentID,
|
||
ForkTurn: turn,
|
||
ForkMessageIndex: boundary,
|
||
Preview: forkPreview,
|
||
Turns: forkTurns,
|
||
SchemaVersion: agent.BranchMetaCountsVersion,
|
||
}); err != nil {
|
||
return "", c.rewindFail(err)
|
||
}
|
||
if switchToFork {
|
||
// See snapshotMu: the swap must not interleave with an in-flight save.
|
||
c.snapshotMu.Lock()
|
||
c.executor.SetSession(sess)
|
||
c.mu.Lock()
|
||
c.sessionPath = newPath
|
||
c.guardianPath = guardian.PathFor(newPath)
|
||
c.mu.Unlock()
|
||
// New lineage: rebind sidecar path and clear any in-memory projection
|
||
// without deleting the parent session's .context.json.
|
||
c.bindExecutorProjection(newPath, false)
|
||
c.ResetPlannerSession()
|
||
c.setActiveJobSession(newPath)
|
||
c.rebindCheckpoints(newPath)
|
||
// A historical fork rewinds before later failures, so it starts with no
|
||
// active recovery event even though it inherits the session preference.
|
||
c.loadRecoveryState(newPath)
|
||
if c.guardianSess != nil {
|
||
c.guardianSess.Reset()
|
||
}
|
||
// Switching into the fork is a new logical session for temporary files.
|
||
c.rotateSessionTemp()
|
||
c.snapshotMu.Unlock()
|
||
}
|
||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
|
||
Text: fmt.Sprintf("forked conversation at turn %d into a new session", turn)})
|
||
return newPath, nil
|
||
}
|
||
|
||
func (c *Controller) CheckpointHasBoundary(turn int) bool {
|
||
boundary, ok := c.checkpoints.boundary(turn)
|
||
if !ok {
|
||
return false
|
||
}
|
||
// After compaction the key may still exist but the boundary value is
|
||
// stale (it points past the truncated message log). Treat those
|
||
// turns the same as "no boundary" so the UI can disable the button.
|
||
// Len is lock-guarded: this runs on frontend goroutines while a turn appends.
|
||
return boundary <= c.executor.Session().Len()
|
||
}
|
||
|
||
// Branch copies the current conversation into a child branch and switches to it.
|
||
// Unlike Fork, it branches at the current tip and does not require a checkpoint.
|
||
func (c *Controller) Branch(name string) (string, error) {
|
||
if c.executor == nil {
|
||
return "", c.rewindFail(fmt.Errorf("branch unavailable"))
|
||
}
|
||
if c.sessionDir == "" {
|
||
return "", c.rewindFail(fmt.Errorf("branch needs session persistence, which is disabled"))
|
||
}
|
||
// Hold the rotation gate across the Snapshot and the switch below so a turn
|
||
// cannot start mid-branch and then have its session replaced.
|
||
if err := c.beginRotation(); err != nil {
|
||
if errors.Is(err, errTurnRunningRotation) {
|
||
return "", c.rewindFail(fmt.Errorf("cannot branch while a turn is running"))
|
||
}
|
||
return "", c.rewindFail(err)
|
||
}
|
||
defer c.endRotation()
|
||
if !c.executor.Session().HasContent() {
|
||
return "", c.rewindFail(fmt.Errorf("nothing to branch yet"))
|
||
}
|
||
if err := c.Snapshot(); err != nil {
|
||
return "", c.rewindFail(err)
|
||
}
|
||
parentPath := c.SessionPath()
|
||
parentID := agent.BranchID(parentPath)
|
||
src := c.executor.Session().Snapshot()
|
||
branched := append([]provider.Message(nil), src...)
|
||
sess := agent.NewSession("")
|
||
sess.Messages = branched
|
||
|
||
newPath := agent.NewSessionPath(c.sessionDir, c.label)
|
||
if err := sess.Save(newPath); err != nil {
|
||
return "", c.rewindFail(err)
|
||
}
|
||
branchPreview, branchTurns := agent.SessionPreviewFromMessages(branched)
|
||
if err := agent.SaveBranchMeta(newPath, agent.BranchMeta{
|
||
Name: strings.TrimSpace(name),
|
||
ParentID: parentID,
|
||
ForkTurn: -1,
|
||
ForkMessageIndex: len(branched),
|
||
Preview: branchPreview,
|
||
Turns: branchTurns,
|
||
SchemaVersion: agent.BranchMetaCountsVersion,
|
||
}); err != nil {
|
||
return "", c.rewindFail(err)
|
||
}
|
||
// See snapshotMu: the swap must not interleave with an in-flight save.
|
||
c.snapshotMu.Lock()
|
||
c.executor.SetSession(sess)
|
||
c.mu.Lock()
|
||
c.sessionPath = newPath
|
||
c.guardianPath = guardian.PathFor(newPath)
|
||
c.mu.Unlock()
|
||
c.bindExecutorProjection(newPath, false)
|
||
c.ResetPlannerSession()
|
||
c.setActiveJobSession(newPath)
|
||
c.rebindCheckpoints(newPath)
|
||
if c.guardianSess != nil {
|
||
c.guardianSess.Reset()
|
||
}
|
||
c.carryRecoveryState(newPath)
|
||
c.rotateSessionTemp()
|
||
c.snapshotMu.Unlock()
|
||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
|
||
Text: fmt.Sprintf("created branch %s", agent.BranchID(newPath))})
|
||
return newPath, nil
|
||
}
|
||
|
||
// Branches lists saved conversation branches in this controller's session dir.
|
||
func (c *Controller) Branches() ([]agent.BranchInfo, error) {
|
||
if c.sessionDir == "" {
|
||
return nil, fmt.Errorf("session persistence is disabled")
|
||
}
|
||
if err := c.Snapshot(); err != nil {
|
||
return nil, err
|
||
}
|
||
return agent.ListBranches(c.sessionDir)
|
||
}
|
||
|
||
func (c *Controller) SwitchBranch(ref string) (agent.BranchInfo, error) {
|
||
ref = strings.TrimSpace(ref)
|
||
if ref == "" {
|
||
return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("usage: /switch <branch id|name>"))
|
||
}
|
||
// Hold the rotation gate across the branch listing/load and the switch so a
|
||
// turn cannot start between the check and the SetSession below.
|
||
if err := c.beginRotation(); err != nil {
|
||
if errors.Is(err, errTurnRunningRotation) {
|
||
return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("cannot switch branches while a turn is running"))
|
||
}
|
||
return agent.BranchInfo{}, c.rewindFail(err)
|
||
}
|
||
defer c.endRotation()
|
||
branches, err := c.Branches()
|
||
if err != nil {
|
||
return agent.BranchInfo{}, c.rewindFail(err)
|
||
}
|
||
match, err := resolveBranch(branches, ref)
|
||
if err != nil {
|
||
return agent.BranchInfo{}, c.rewindFail(err)
|
||
}
|
||
if !agent.IsVisibleSession(match.Path) {
|
||
return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("branch %q not found", ref))
|
||
}
|
||
loaded, err := agent.LoadSession(match.Path)
|
||
if err != nil {
|
||
return agent.BranchInfo{}, c.rewindFail(err)
|
||
}
|
||
// See snapshotMu: the swap must not interleave with an in-flight save.
|
||
c.snapshotMu.Lock()
|
||
if c.executor != nil {
|
||
c.executor.SetSession(loaded)
|
||
}
|
||
c.mu.Lock()
|
||
c.sessionPath = match.Path
|
||
c.guardianPath = guardian.PathFor(match.Path)
|
||
c.mu.Unlock()
|
||
c.bindExecutorProjection(match.Path, true)
|
||
c.ResetPlannerSession()
|
||
c.setActiveJobSession(match.Path)
|
||
c.rebindCheckpoints(match.Path)
|
||
c.restoreTerminalGoalTodos(match.Path)
|
||
c.loadGuardianSession()
|
||
c.loadRecoveryState(match.Path)
|
||
c.rotateSessionTemp()
|
||
c.snapshotMu.Unlock()
|
||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
|
||
Text: fmt.Sprintf("switched to branch %s", branchDisplayName(match))})
|
||
return match, nil
|
||
}
|
||
|
||
// ResolveBranchRef resolves a /switch-style branch reference (id, unique
|
||
// prefix, name, or path) against a branch listing, using the same matching
|
||
// rules as SwitchBranch. Frontends use it to learn the target session path
|
||
// before switching — e.g. to move their session lease first.
|
||
func ResolveBranchRef(branches []agent.BranchInfo, ref string) (agent.BranchInfo, error) {
|
||
return resolveBranch(branches, strings.TrimSpace(ref))
|
||
}
|
||
|
||
func resolveBranch(branches []agent.BranchInfo, ref string) (agent.BranchInfo, error) {
|
||
refLower := strings.ToLower(ref)
|
||
var matches []agent.BranchInfo
|
||
for _, b := range branches {
|
||
nameLower := strings.ToLower(strings.TrimSpace(b.Name))
|
||
switch {
|
||
case b.ID == ref || strings.EqualFold(b.ID, ref):
|
||
return b, nil
|
||
case b.Name != "" && nameLower == refLower:
|
||
matches = append(matches, b)
|
||
case strings.HasPrefix(strings.ToLower(b.ID), refLower):
|
||
matches = append(matches, b)
|
||
case strings.HasPrefix(strings.ToLower(shortBranchID(b.ID)), refLower):
|
||
matches = append(matches, b)
|
||
case b.Path == ref:
|
||
return b, nil
|
||
}
|
||
}
|
||
if len(matches) == 1 {
|
||
return matches[0], nil
|
||
}
|
||
if len(matches) > 1 {
|
||
return agent.BranchInfo{}, fmt.Errorf("branch %q is ambiguous", ref)
|
||
}
|
||
return agent.BranchInfo{}, fmt.Errorf("branch %q not found", ref)
|
||
}
|
||
|
||
func branchDisplayName(b agent.BranchInfo) string {
|
||
if strings.TrimSpace(b.Name) != "" {
|
||
return fmt.Sprintf("%s (%s)", b.Name, b.ID)
|
||
}
|
||
return b.ID
|
||
}
|
||
|
||
// SummarizeFrom compresses the conversation from turn onward into one summary;
|
||
// SummarizeUpTo compresses everything before it. Both are Claude Code's "summarize
|
||
// from/up to here" — they restructure the message log (keeping code untouched), so
|
||
// afterwards the per-turn boundaries no longer map and conversation rewind/fork
|
||
// report "unavailable" until new turns rebuild them (code rewind, file-based, is
|
||
// unaffected). Refused while a turn runs; need the live boundary.
|
||
func (c *Controller) SummarizeFrom(ctx context.Context, turn int) error {
|
||
return c.summarizeAt(ctx, turn, true)
|
||
}
|
||
|
||
func (c *Controller) SummarizeUpTo(ctx context.Context, turn int) error {
|
||
return c.summarizeAt(ctx, turn, false)
|
||
}
|
||
|
||
func (c *Controller) summarizeAt(ctx context.Context, turn int, from bool) error {
|
||
if c.executor == nil {
|
||
return c.rewindFail(fmt.Errorf("checkpoints unavailable"))
|
||
}
|
||
// Summarize rewrites the live session AFTER a provider round-trip, so the
|
||
// bare Running() check left a seconds-wide window for a turn to start and
|
||
// then have the log replaced under it. Hold the rotation gate from the
|
||
// boundary read through the post-rewrite snapshot.
|
||
if err := c.beginRotation(); err != nil {
|
||
if errors.Is(err, errTurnRunningRotation) {
|
||
return c.rewindFail(fmt.Errorf("cannot summarize while a turn is running"))
|
||
}
|
||
return c.rewindFail(err)
|
||
}
|
||
defer c.endRotation()
|
||
boundary, hasBound := c.checkpoints.boundary(turn)
|
||
if !hasBound {
|
||
return c.rewindFail(fmt.Errorf("summarize unavailable for turn %d (resumed session)", turn))
|
||
}
|
||
var err error
|
||
if from {
|
||
err = c.executor.SummarizeFrom(ctx, boundary)
|
||
} else {
|
||
err = c.executor.SummarizeUpTo(ctx, boundary)
|
||
}
|
||
if err != nil {
|
||
return c.rewindFail(err)
|
||
}
|
||
// The log was restructured; existing boundaries no longer map. Drop them (keep
|
||
// the turn counter monotonic so new turns don't collide with the store) —
|
||
// conversation rewind degrades to "unavailable" until fresh turns rebuild them.
|
||
c.checkpoints.clearBounds()
|
||
atomic.AddInt64(&c.sessionRevision, 1)
|
||
if err := c.SnapshotRewrite(); err != nil {
|
||
slog.Warn("controller: post-summarize snapshot", "err", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Resume seeds the session from a loaded transcript and pins the active file to
|
||
// its path so auto-save keeps appending there.
|
||
//
|
||
// When the controller already has a different non-empty session path, Resume
|
||
// rotates the private temporary generation so the loaded conversation cannot
|
||
// see the previous session's temporary files. Same-path Resume (hot rebuild
|
||
// migration via AdoptHistory) keeps the generation.
|
||
func (c *Controller) Resume(s *agent.Session, path string) {
|
||
// See snapshotMu: the swap must not interleave with an in-flight save.
|
||
// recoverInterruptedTurn and maybeColdResumePrune snapshot on their own,
|
||
// so they stay outside the locked section (snapshotMu is not reentrant).
|
||
prevPath := c.SessionPath()
|
||
c.snapshotMu.Lock()
|
||
if c.executor != nil {
|
||
c.executor.SetSession(s)
|
||
}
|
||
c.mu.Lock()
|
||
c.sessionPath = path
|
||
c.guardianPath = guardian.PathFor(path)
|
||
c.mu.Unlock()
|
||
c.bindExecutorProjection(path, true)
|
||
c.ResetPlannerSession()
|
||
c.setActiveJobSession(path)
|
||
c.rebindCheckpoints(path)
|
||
if migPath, migData, migrated := c.goals.restoreFromState(path); migrated {
|
||
// Persist legacy budget_tokens → running (and tokensLimit=0) so the
|
||
// next cold start does not re-enter the removed hard-limit pause.
|
||
// restoreFromState never issues a provider request.
|
||
c.persistGoalState(migPath, migData, true)
|
||
}
|
||
if c.executor != nil {
|
||
c.executor.RestoreDeliveryCheckpoint(c.goals.deliveryState())
|
||
}
|
||
c.restoreTerminalGoalTodos(path)
|
||
c.loadGuardianSession()
|
||
c.loadRecoveryState(path)
|
||
if shouldRotateSessionTempOnResume(prevPath, path) {
|
||
c.rotateSessionTemp()
|
||
}
|
||
c.snapshotMu.Unlock()
|
||
c.recoverCheckpointTransactions()
|
||
c.recoverInterruptedTurn(path)
|
||
c.maybeColdResumePrune(path)
|
||
// session.load: Resume has no failure channel, so the session_policy
|
||
// strategy is advisory this stage — a required-class failure is surfaced
|
||
// as a warning and the load stands. The event still carries the final
|
||
// (possibly owner-adjusted) phase payload.
|
||
if err := c.extensionSessionPhase(context.Background(), extension.PointSessionLoad, dispatch.PhaseLoad, path); err != nil {
|
||
c.extensionWarn("session policy failed at session.load", err)
|
||
}
|
||
}
|
||
|
||
func shouldRotateSessionTempOnResume(prevPath, nextPath string) bool {
|
||
prevPath = strings.TrimSpace(prevPath)
|
||
nextPath = strings.TrimSpace(nextPath)
|
||
if prevPath == "" || nextPath == "" {
|
||
return false
|
||
}
|
||
return filepath.Clean(prevPath) != filepath.Clean(nextPath)
|
||
}
|
||
|
||
func (c *Controller) loadGuardianSession() {
|
||
if c.guardianSess == nil {
|
||
return
|
||
}
|
||
c.guardianSess.Reset()
|
||
path := c.guardianPath
|
||
if path == "" {
|
||
return
|
||
}
|
||
if err := c.guardianSess.Load(path); err != nil && !os.IsNotExist(err) {
|
||
slog.Warn("controller: load guardian session", "err", err)
|
||
}
|
||
}
|
||
|
||
// ResetPlannerSession clears the planner's conversation history so the next
|
||
// plan starts fresh. In dual-model (Plan+Execute) mode, this prevents stale
|
||
// planner output from a previous session or tab from contaminating the current
|
||
// executor's handoff. Safe to call on a single-model controller (no-op).
|
||
func (c *Controller) ResetPlannerSession() {
|
||
runner, ok := c.runner.(plannerSessionResetter)
|
||
if ok {
|
||
runner.ResetPlannerSession()
|
||
}
|
||
}
|
||
|
||
// cacheColdAfter resolves how long the active provider keeps a prompt prefix
|
||
// cached. A session idle longer than this resumes against a cold cache, so a
|
||
// history rewrite at that moment costs no extra cache misses — it only shrinks
|
||
// the full-price first request. The TTL is vendor-aware: DeepSeek/unknown
|
||
// 24h (legacy default deliberately preserved), DashScope 5m, Anthropic 5m.
|
||
// Users can override per-provider
|
||
// with cache_ttl_minutes in config.toml.
|
||
func (c *Controller) cacheColdAfter() time.Duration {
|
||
if c.testCacheColdAfter != 0 {
|
||
if c.testCacheColdAfter == -1 {
|
||
return 0
|
||
}
|
||
return c.testCacheColdAfter
|
||
}
|
||
// 查询路径只读:LoadForRootReadOnly 不触发配置迁移写盘(评审 #7168
|
||
// 第 4 点);失败时保守回退 24h(DeepSeek/未知 vendor 默认),避免
|
||
// 提前触发 PruneStaleToolResults 改写仍可命中的缓存历史。
|
||
cfg, err := config.LoadForRootReadOnly(c.workspaceRoot)
|
||
if err != nil {
|
||
return 24 * time.Hour
|
||
}
|
||
ref := c.modelRef
|
||
if ref == "" {
|
||
ref = cfg.DefaultModel
|
||
}
|
||
entry, ok := cfg.ResolveModel(ref)
|
||
if !ok {
|
||
return 24 * time.Hour
|
||
}
|
||
return entry.EffectiveCacheTTL()
|
||
}
|
||
|
||
// Snapshot writes the executor's conversation to the active session file. No-op
|
||
// when the executor is absent or the session has never been used (no user
|
||
// interaction). Returns errNoSessionPath when there IS content but no resolved
|
||
// path, so a misconfigured deployment surfaces instead of dropping data.
|
||
// Called after every turn so a crash loses at most one in-flight prompt.
|
||
func (c *Controller) Snapshot() error {
|
||
return c.snapshot(false, false, false)
|
||
}
|
||
|
||
// SnapshotForShutdown performs the final session snapshot and, only when the
|
||
// compatibility file lock remains held for the full bounded wait, persists the
|
||
// in-memory transcript to a distinct recovery branch before teardown proceeds.
|
||
// Other snapshot errors retain their normal behavior and remain visible to the
|
||
// caller.
|
||
func (c *Controller) SnapshotForShutdown() error {
|
||
return c.snapshot(false, false, true)
|
||
}
|
||
|
||
// SnapshotActivity writes the active conversation and marks the session as
|
||
// recently active. Use it only after a real user/model turn changes the
|
||
// transcript; switch/close snapshots should call Snapshot so they do not reorder
|
||
// recent-session pickers.
|
||
func (c *Controller) SnapshotActivity() error {
|
||
return c.snapshot(true, false, false)
|
||
}
|
||
|
||
// SnapshotRewrite persists an intentional history rewrite, such as rewind or
|
||
// manual compaction. Ordinary autosave paths should use Snapshot so stale
|
||
// controllers cannot overwrite a newer transcript.
|
||
func (c *Controller) SnapshotRewrite() error {
|
||
return c.snapshot(false, true, false)
|
||
}
|
||
|
||
// midTurnSnapshotInterval is atomic (nanoseconds) so a test shrinking it
|
||
// cannot race a previous test's still-parking autosave goroutine.
|
||
var midTurnSnapshotInterval atomic.Int64
|
||
|
||
func init() { midTurnSnapshotInterval.Store(int64(30 * time.Second)) }
|
||
|
||
// autosaveWhileRunning snapshots the session periodically while a turn runs,
|
||
// so an abrupt kill (SSH drop, force-quit) loses at most one interval of a
|
||
// long turn instead of all of it (#3772). Session.Save copies under the lock
|
||
// and replaces the file atomically, so racing the turn's appends is safe.
|
||
func (c *Controller) autosaveWhileRunning(ctx context.Context) {
|
||
t := time.NewTicker(time.Duration(midTurnSnapshotInterval.Load()))
|
||
defer t.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-t.C:
|
||
if err := c.snapshot(false, false, false); err != nil {
|
||
slog.Warn("controller: mid-turn snapshot", "err", err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (c *Controller) snapshot(markActivity, forceRewrite, shutdownRecovery bool) error {
|
||
c.snapshotMu.Lock()
|
||
defer c.snapshotMu.Unlock()
|
||
|
||
c.mu.Lock()
|
||
path := c.sessionPath
|
||
modelRef := c.modelRef
|
||
c.mu.Unlock()
|
||
if c.executor == nil {
|
||
return nil
|
||
}
|
||
s := c.executor.Session()
|
||
if !s.HasContent() {
|
||
// Nothing to persist yet (e.g. a fresh session with only a system
|
||
// prompt) — staying quiet here is correct, not a data-loss path.
|
||
return nil
|
||
}
|
||
if !s.HasSystemMessage() {
|
||
// The session has user/assistant/tool messages but no leading system
|
||
// prompt. Persisting it would create a session file that, when
|
||
// reloaded, has no agent-identity contract — the model falls back to
|
||
// its training-data defaults, giving wrong answers to identity
|
||
// queries ("who are you?"). Log the anomaly so the root cause
|
||
// (typically an empty sysPrompt reaching NewSession) can be
|
||
// diagnosed, then refuse to write a corrupted transcript.
|
||
slog.Warn("controller: refusing to snapshot session with content but no system message",
|
||
"label", c.Label(), "session_dir", c.SessionDir(), "message_count", len(s.Snapshot()))
|
||
return nil
|
||
}
|
||
if path == "" {
|
||
// There IS content but nowhere to write it: this silently dropped whole
|
||
// bot conversations (#4414). Surface it loudly instead of returning nil
|
||
// so the missing session path can be diagnosed and fixed at the source.
|
||
slog.Warn("controller: session has content but no session path; conversation will not be persisted",
|
||
"label", c.Label(), "session_dir", c.SessionDir())
|
||
return errNoSessionPath
|
||
}
|
||
// session.save: the session_policy owner rules on the impending save; a
|
||
// failure (required-class) vetoes the write. The event goes out after a
|
||
// successful save carrying the final payload. The early no-content and
|
||
// no-path returns above are not saves and stay unobserved. Conflict
|
||
// recovery below may rewrite the path; the phase payload reports the path
|
||
// the save targeted.
|
||
savePayload, strategyErr := c.extensionSessionStrategy(context.Background(), extension.PointSessionSave, dispatch.PhaseSave, path)
|
||
if strategyErr != nil {
|
||
return strategyErr
|
||
}
|
||
forceRewrite = forceRewrite || s.NeedsRewriteSave()
|
||
var err error
|
||
if forceRewrite {
|
||
err = s.SaveRewrite(path)
|
||
} else {
|
||
err = s.SaveSnapshot(path)
|
||
if errors.Is(err, agent.ErrSessionSnapshotConflict) {
|
||
// The no-rewrite decision may already be stale: auto-compaction
|
||
// can rewrite history between the decision and the write. Re-check
|
||
// and retry once as an owned rewrite before treating the failure as
|
||
// a real cross-runtime conflict.
|
||
if s.NeedsRewriteSave() {
|
||
forceRewrite = true
|
||
err = s.SaveRewrite(path)
|
||
}
|
||
}
|
||
}
|
||
if err != nil {
|
||
if shutdownRecovery && errors.Is(err, agent.ErrSessionFileLockHeld) {
|
||
recoveredPath, recoverErr := c.recoverShutdownSnapshot(path, err)
|
||
if recoverErr != nil {
|
||
return recoverErr
|
||
}
|
||
path = recoveredPath
|
||
s = c.executor.Session()
|
||
err = nil
|
||
}
|
||
}
|
||
if err != nil {
|
||
if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
|
||
return err
|
||
}
|
||
recoveredPath, outcome, recoverErr := c.recoverSnapshotConflict(path, err, forceRewrite)
|
||
if recoverErr != nil {
|
||
if shutdownRecovery && errors.Is(recoverErr, agent.ErrSessionFileLockHeld) {
|
||
recoveredPath, recoverErr = c.recoverShutdownSnapshot(path, recoverErr)
|
||
if recoverErr != nil {
|
||
return recoverErr
|
||
}
|
||
path = recoveredPath
|
||
s = c.executor.Session()
|
||
} else {
|
||
return recoverErr
|
||
}
|
||
} else {
|
||
if outcome == conflictDropped {
|
||
return nil
|
||
}
|
||
// Whatever recovery did — adopted the disk transcript, force-saved
|
||
// the depth-capped branch, or forked — the rewrite baseline lives on
|
||
// the session object and was advanced by the save that succeeded, so
|
||
// there is nothing to re-anchor here.
|
||
path = recoveredPath
|
||
s = c.executor.Session()
|
||
}
|
||
}
|
||
// Persist guardian session so the prefix cache stays warm after restart.
|
||
if c.guardianSess != nil {
|
||
gp := c.guardianPath
|
||
if gp != "" {
|
||
if gerr := c.guardianSess.Save(gp); gerr != nil {
|
||
slog.Warn("controller: guardian snapshot", "err", gerr)
|
||
}
|
||
}
|
||
}
|
||
// Persist recovery gate state so unresolved checkpoints survive restart.
|
||
c.saveRecoveryState(path)
|
||
// Record the listing-only sidecar fields (model, preview, user-turn count)
|
||
// straight from the in-memory conversation, so the sidebar and resume picker
|
||
// never have to decode the whole .jsonl just to show them. markActivity bumps
|
||
// UpdatedAt exactly like the previous TouchBranchMeta did; false preserves it
|
||
// like SetBranchModelPreserveUpdated. The single write subsumes the old
|
||
// EnsureBranchMeta / SetBranchModel / TouchBranchMeta sequence.
|
||
preview, turns := agent.SessionPreviewFromMessages(s.Snapshot())
|
||
if err := agent.UpdateSessionMeta(path, modelRef, preview, turns, markActivity); err != nil {
|
||
return err
|
||
}
|
||
c.extensionSessionPayloadEvent(extension.PointSessionSave, savePayload)
|
||
return nil
|
||
}
|
||
|
||
// snapshotConflictLogAttrs flattens a snapshot-conflict error into slog attrs.
|
||
// Field reports of #6069-class "session changed on disk" spam are only
|
||
// diagnosable when the logs say which trigger fired and what the revision
|
||
// ledger looked like, so every recoverSnapshotConflict outcome logs these.
|
||
func snapshotConflictLogAttrs(saveErr error, path, mode string) []any {
|
||
attrs := []any{"path", path, "mode", mode}
|
||
var conflict *agent.SessionSnapshotConflictError
|
||
if errors.As(saveErr, &conflict) && conflict != nil {
|
||
attrs = append(attrs,
|
||
"kind", string(conflict.Kind),
|
||
"disk_messages", conflict.ExistingMessages,
|
||
"snapshot_messages", conflict.SnapshotMessages,
|
||
"base_revision", conflict.BaseRevision,
|
||
"disk_revision", conflict.DiskRevision,
|
||
)
|
||
}
|
||
return attrs
|
||
}
|
||
|
||
type snapshotConflictDiagnostic struct {
|
||
At time.Time `json:"at"`
|
||
BranchID string `json:"branch_id"`
|
||
Mode string `json:"mode"`
|
||
Outcome string `json:"outcome"`
|
||
Kind string `json:"kind,omitempty"`
|
||
DiskMessages int `json:"disk_messages,omitempty"`
|
||
SnapshotMessages int `json:"snapshot_messages,omitempty"`
|
||
BaseRevision int64 `json:"base_revision,omitempty"`
|
||
DiskRevision int64 `json:"disk_revision,omitempty"`
|
||
RecoveryBranchID string `json:"recovery_branch_id,omitempty"`
|
||
ExistingRecovery bool `json:"existing_recovery,omitempty"`
|
||
}
|
||
|
||
func appendSnapshotConflictDiagnostic(path, mode, outcome string, saveErr error, recoveryPath string, existing bool) {
|
||
path = strings.TrimSpace(path)
|
||
if path == "" {
|
||
return
|
||
}
|
||
rec := snapshotConflictDiagnostic{
|
||
At: time.Now(),
|
||
BranchID: agent.BranchID(path),
|
||
Mode: mode,
|
||
Outcome: outcome,
|
||
}
|
||
var conflict *agent.SessionSnapshotConflictError
|
||
if errors.As(saveErr, &conflict) && conflict != nil {
|
||
rec.Kind = string(conflict.Kind)
|
||
rec.DiskMessages = conflict.ExistingMessages
|
||
rec.SnapshotMessages = conflict.SnapshotMessages
|
||
rec.BaseRevision = conflict.BaseRevision
|
||
rec.DiskRevision = conflict.DiskRevision
|
||
}
|
||
if recoveryPath != "" {
|
||
rec.RecoveryBranchID = agent.BranchID(recoveryPath)
|
||
rec.ExistingRecovery = existing
|
||
}
|
||
data, err := json.Marshal(rec)
|
||
if err != nil {
|
||
return
|
||
}
|
||
logPath := store.SessionConflictLog(path)
|
||
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
|
||
return
|
||
}
|
||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||
if err != nil {
|
||
return
|
||
}
|
||
defer f.Close()
|
||
_, _ = f.Write(append(data, '\n'))
|
||
}
|
||
|
||
// conflictOutcome is recoverSnapshotConflict's declared result. Callers act
|
||
// on it directly instead of re-deriving what happened from path or session
|
||
// pointer comparisons — the misclassification that broke the depth-cap
|
||
// rewrite baseline (#6120) hid in exactly that inference.
|
||
type conflictOutcome int
|
||
|
||
const (
|
||
// conflictDropped: nothing was recovered and the disk transcript could
|
||
// not be adopted; this snapshot was deliberately dropped.
|
||
conflictDropped conflictOutcome = iota
|
||
// conflictAdoptedDisk: the executor session object was replaced by the
|
||
// newer disk transcript; adoptDiskSession already reset its baselines.
|
||
conflictAdoptedDisk
|
||
// conflictForceSavedBranch: recovery depth was exhausted and the same
|
||
// in-memory session was force-saved onto the same branch; that save
|
||
// advanced the session-owned rewrite baseline like any other full save.
|
||
conflictForceSavedBranch
|
||
// conflictForkedBranch: the same in-memory session moved to a freshly
|
||
// forked recovery branch path.
|
||
conflictForkedBranch
|
||
)
|
||
|
||
const recoveryDepthCapNoticeText = "repeated save conflicts were detected; saved the current conflict copy in place"
|
||
|
||
func sessionRecoveryNotice(code, text string) event.Event {
|
||
return event.Event{
|
||
Kind: event.Notice,
|
||
Level: event.LevelWarn,
|
||
Audience: event.NoticeAudienceOperator,
|
||
Code: code,
|
||
Text: text,
|
||
}
|
||
}
|
||
|
||
func (c *Controller) emitRecoveryDepthCapNotice(path string) {
|
||
key := filepath.Clean(strings.TrimSpace(path))
|
||
c.mu.Lock()
|
||
if c.recoveryDepthCapNotices == nil {
|
||
c.recoveryDepthCapNotices = make(map[string]bool)
|
||
}
|
||
if c.recoveryDepthCapNotices[key] {
|
||
c.mu.Unlock()
|
||
return
|
||
}
|
||
c.recoveryDepthCapNotices[key] = true
|
||
c.mu.Unlock()
|
||
c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionRecoveryDepthCap, recoveryDepthCapNoticeText))
|
||
}
|
||
|
||
func (c *Controller) recoverSnapshotConflict(path string, saveErr error, forceRewrite bool) (string, conflictOutcome, error) {
|
||
if c.executor == nil || strings.TrimSpace(path) == "" {
|
||
return "", conflictDropped, saveErr
|
||
}
|
||
mode := "snapshot"
|
||
if forceRewrite {
|
||
mode = "rewrite"
|
||
}
|
||
logAttrs := snapshotConflictLogAttrs(saveErr, path, mode)
|
||
if kind, ok := agent.SnapshotConflictKind(saveErr); ok && kind == agent.SessionSnapshotConflictStalePrefix {
|
||
if c.adoptDiskSession(path) {
|
||
appendSnapshotConflictDiagnostic(path, mode, "adopted_newer_disk_transcript", saveErr, "", false)
|
||
slog.Warn("controller: snapshot conflict; adopted newer disk transcript", logAttrs...)
|
||
c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionRecoveryAdopted,
|
||
"session changed on disk; adopted the newer transcript"))
|
||
return path, conflictAdoptedDisk, nil
|
||
}
|
||
}
|
||
reason := "snapshot conflict"
|
||
if forceRewrite {
|
||
reason = "rewrite conflict"
|
||
}
|
||
req := SessionRecoveryRequest{OriginalPath: path, Reason: reason, Mode: mode}
|
||
meta := agent.BranchMeta{}
|
||
if c.sessionRecoveryMeta != nil {
|
||
meta = c.sessionRecoveryMeta(req)
|
||
}
|
||
info, err := c.executor.Session().SaveRecoveryBranch(agent.RecoveryBranchOptions{
|
||
OriginalPath: path,
|
||
Reason: reason,
|
||
BranchMeta: meta,
|
||
})
|
||
if err != nil {
|
||
if errors.Is(err, agent.ErrSessionRecoveryDepthExceeded) {
|
||
// Saves keep conflicting on recovery branches this runtime itself
|
||
// created; forking again multiplies session files without
|
||
// converging (#5993 reached 8 nested levels). This runtime is the
|
||
// only writer of its own recovery branches, so force-writing the
|
||
// transcript back onto the current branch keeps the data and
|
||
// stops the chain.
|
||
if forceErr := c.executor.Session().Save(path); forceErr != nil {
|
||
return "", conflictDropped, fmt.Errorf("recovery chain depth exceeded; force save failed: %w", forceErr)
|
||
}
|
||
appendSnapshotConflictDiagnostic(path, mode, "recovery_depth_cap_force_saved", saveErr, path, false)
|
||
slog.Warn("controller: snapshot conflict; recovery depth cap reached, force-saved onto current branch", logAttrs...)
|
||
c.emitRecoveryDepthCapNotice(path)
|
||
return path, conflictForceSavedBranch, nil
|
||
}
|
||
if errors.Is(err, agent.ErrSessionRecoveryNotNeeded) {
|
||
if c.adoptDiskSession(path) {
|
||
appendSnapshotConflictDiagnostic(path, mode, "recovery_not_needed_adopted_disk_transcript", saveErr, "", false)
|
||
slog.Warn("controller: snapshot conflict; recovery not needed, adopted disk transcript", logAttrs...)
|
||
c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionRecoveryAdoptedCovered,
|
||
"session changed on disk; adopted the newer transcript (local changes already covered)"))
|
||
return path, conflictAdoptedDisk, nil
|
||
}
|
||
// Nothing was recovered AND the disk transcript could not be
|
||
// adopted: the snapshot is silently dropped. Leave a trace so
|
||
// "my last turns vanished" reports can be tied to this path.
|
||
appendSnapshotConflictDiagnostic(path, mode, "recovery_not_needed_adopt_failed", saveErr, "", false)
|
||
slog.Warn("controller: snapshot conflict; recovery not needed but disk transcript could not be adopted", logAttrs...)
|
||
return "", conflictDropped, nil
|
||
}
|
||
return "", conflictDropped, fmt.Errorf("recover stale session snapshot: %w", err)
|
||
}
|
||
if err := c.commitRecoveredSession(path, reason, info); err != nil {
|
||
return "", conflictDropped, err
|
||
}
|
||
appendSnapshotConflictDiagnostic(path, mode, "forked_recovery_branch", saveErr, info.Path, info.Existing)
|
||
slog.Warn("controller: snapshot conflict; forked recovery branch",
|
||
append(logAttrs, "recovery", info.Path, "existing", info.Existing)...)
|
||
c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionRecoveryForked,
|
||
"session changed on disk; unsaved local transcript was saved as a conflict copy"))
|
||
return info.Path, conflictForkedBranch, nil
|
||
}
|
||
|
||
func (c *Controller) recoverShutdownSnapshot(path string, saveErr error) (string, error) {
|
||
if c.executor == nil || strings.TrimSpace(path) == "" {
|
||
return "", saveErr
|
||
}
|
||
const reason = "shutdown session file lock timeout"
|
||
req := SessionRecoveryRequest{OriginalPath: path, Reason: reason, Mode: "shutdown"}
|
||
meta := agent.BranchMeta{}
|
||
if c.sessionRecoveryMeta != nil {
|
||
meta = c.sessionRecoveryMeta(req)
|
||
}
|
||
info, err := c.executor.Session().SaveShutdownRecoveryBranch(agent.RecoveryBranchOptions{
|
||
OriginalPath: path,
|
||
Reason: reason,
|
||
BranchMeta: meta,
|
||
})
|
||
if err != nil {
|
||
return "", fmt.Errorf("save shutdown recovery branch: %w", err)
|
||
}
|
||
if err := c.commitRecoveredSession(path, reason, info); err != nil {
|
||
return "", err
|
||
}
|
||
appendSnapshotConflictDiagnostic(path, "shutdown", "forked_file_lock_recovery", saveErr, info.Path, info.Existing)
|
||
slog.Warn("controller: shutdown snapshot lock timed out; forked recovery branch",
|
||
"path", path, "recovery", info.Path, "existing", info.Existing)
|
||
c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionShutdownRecoveryForked,
|
||
"session file stayed busy during shutdown; unsaved transcript was saved as a recovery copy"))
|
||
return info.Path, nil
|
||
}
|
||
|
||
func (c *Controller) commitRecoveredSession(originalPath, reason string, info agent.RecoveryBranchInfo) error {
|
||
recoveryInfo := SessionRecoveryInfo{
|
||
OriginalPath: originalPath,
|
||
RecoveryPath: info.Path,
|
||
Existing: info.Existing,
|
||
Reason: reason,
|
||
Meta: info.Meta,
|
||
}
|
||
if onSessionRecovered := c.sessionRecoveredHandler(); onSessionRecovered != nil {
|
||
if err := onSessionRecovered(recoveryInfo); err != nil {
|
||
return fmt.Errorf("commit recovered session: %w", err)
|
||
}
|
||
}
|
||
c.mu.Lock()
|
||
c.sessionPath = info.Path
|
||
c.guardianPath = guardian.PathFor(info.Path)
|
||
c.mu.Unlock()
|
||
// Recovery branch is a new lineage path; do not keep writing the original
|
||
// session's projection sidecar.
|
||
c.bindExecutorProjection(info.Path, false)
|
||
c.setActiveJobSession(info.Path)
|
||
c.rebindCheckpoints(info.Path)
|
||
c.transplantInFlightTurnMarker(originalPath, info.Path)
|
||
return nil
|
||
}
|
||
|
||
func (c *Controller) adoptDiskSession(path string) bool {
|
||
loaded, err := agent.LoadSession(path)
|
||
if err != nil || loaded == nil {
|
||
return false
|
||
}
|
||
c.executor.SetSession(loaded)
|
||
c.bindExecutorProjection(path, true)
|
||
c.ResetPlannerSession()
|
||
c.rebindCheckpoints(path)
|
||
c.setActiveJobSession(path)
|
||
return true
|
||
}
|
||
|
||
func (c *Controller) messageCount() int {
|
||
if c.executor == nil {
|
||
return 0
|
||
}
|
||
return c.executor.Session().Len()
|
||
}
|
||
|
||
func (c *Controller) markInFlightTurn(startMessageIndex int, preserveUser bool) {
|
||
path := c.SessionPath()
|
||
if path == "" {
|
||
return
|
||
}
|
||
if err := agent.MarkSessionInFlightTurn(path, startMessageIndex, preserveUser); err != nil {
|
||
slog.Warn("controller: mark in-flight turn", "err", err)
|
||
}
|
||
}
|
||
|
||
func (c *Controller) clearInFlightTurn() {
|
||
path := c.SessionPath()
|
||
if path == "" {
|
||
return
|
||
}
|
||
if err := agent.ClearSessionInFlightTurn(path); err != nil {
|
||
slog.Warn("controller: clear in-flight turn", "err", err)
|
||
}
|
||
}
|
||
|
||
// transplantInFlightTurnMarker moves a pending in-flight-turn marker from the
|
||
// session path a recovery fork abandoned onto the branch the turn continues
|
||
// on. Left behind, the stale marker would fire recoverInterruptedTurn on the
|
||
// next open of the original branch and strip messages from a turn that in
|
||
// fact kept running on the recovery branch; missing from the recovery branch,
|
||
// a crash before turn end would leave its partial tail unmarked.
|
||
func (c *Controller) transplantInFlightTurnMarker(fromPath, toPath string) {
|
||
if strings.TrimSpace(fromPath) == "" || strings.TrimSpace(toPath) == "" || fromPath == toPath {
|
||
return
|
||
}
|
||
meta, ok, err := agent.LoadBranchMeta(fromPath)
|
||
if err != nil || !ok || meta.InFlightTurn == nil {
|
||
if err != nil {
|
||
slog.Warn("controller: load in-flight turn marker for transplant", "path", fromPath, "err", err)
|
||
}
|
||
return
|
||
}
|
||
marker := meta.InFlightTurn
|
||
if err := agent.SetSessionInFlightTurn(toPath, *marker); err != nil {
|
||
// Keep the original marker: a turn boundary on the wrong branch beats
|
||
// no boundary anywhere if the runtime dies before the turn completes.
|
||
slog.Warn("controller: transplant in-flight turn marker", "path", toPath, "err", err)
|
||
return
|
||
}
|
||
if err := agent.ClearSessionInFlightTurn(fromPath); err != nil {
|
||
slog.Warn("controller: clear in-flight turn marker on forked-from branch", "path", fromPath, "err", err)
|
||
}
|
||
}
|
||
|
||
func (c *Controller) recoverInterruptedTurn(path string) {
|
||
if c.executor == nil || path == "" {
|
||
return
|
||
}
|
||
meta, ok, err := agent.LoadBranchMeta(path)
|
||
if err != nil || !ok || meta.InFlightTurn == nil {
|
||
if err != nil {
|
||
slog.Warn("controller: load in-flight turn marker", "err", err)
|
||
}
|
||
return
|
||
}
|
||
marker := meta.InFlightTurn
|
||
if interruptedTurnContinuedOnRecoveryBranch(path, marker) {
|
||
// The "interrupted" turn did not die with a runtime: a recovery branch
|
||
// forked off this session after the marker was set, so the turn kept
|
||
// running (and completing) there. Runtimes predating the marker
|
||
// transplant in recoverSnapshotConflict left the marker behind on the
|
||
// forked-from branch; stripping now would truncate a transcript the
|
||
// completed turn already superseded. Clear the stale marker instead.
|
||
if err := agent.ClearSessionInFlightTurn(path); err != nil {
|
||
slog.Warn("controller: clear fork-orphaned in-flight turn", "err", err)
|
||
}
|
||
return
|
||
}
|
||
msgs := c.executor.Session().Snapshot()
|
||
start, found := resolveInterruptedTurnStart(msgs, marker.StartMessageIndex, marker.PreserveUser, marker.StartedAt, provider.Message{})
|
||
changed := found && len(msgs) > start
|
||
if changed {
|
||
if marker.PreserveUser {
|
||
c.stripCancelledVisibleTurnMessagesAfterWithFallbackAt(start, provider.Message{}, marker.StartedAt)
|
||
} else {
|
||
c.stripTurnMessagesAfter(start)
|
||
}
|
||
if err := c.snapshot(false, true, false); err != nil {
|
||
slog.Warn("controller: post-interrupted-turn snapshot", "err", err)
|
||
}
|
||
}
|
||
if err := agent.ClearSessionInFlightTurn(path); err != nil {
|
||
slog.Warn("controller: clear stale in-flight turn", "err", err)
|
||
}
|
||
}
|
||
|
||
// interruptedTurnContinuedOnRecoveryBranch reports whether a recovery branch
|
||
// forked off path after its in-flight-turn marker was set. Markers only exist
|
||
// while a turn runs and recovery forks happen on saves, so a child recovery
|
||
// branch younger than the marker means the marked turn itself moved there —
|
||
// the marker is a leftover from a runtime that switched paths mid-turn, not a
|
||
// crashed turn whose partial tail needs stripping. A marker without a start
|
||
// time is treated as continued whenever any recovery child exists: erring
|
||
// toward keeping messages is the data-safe direction.
|
||
func interruptedTurnContinuedOnRecoveryBranch(path string, marker *agent.InFlightTurnMeta) bool {
|
||
if marker == nil {
|
||
return false
|
||
}
|
||
branches, err := agent.ListBranches(filepath.Dir(path))
|
||
if err != nil {
|
||
return false
|
||
}
|
||
id := agent.BranchID(path)
|
||
for _, b := range branches {
|
||
if b.Recovered && b.ParentID == id && b.CreatedAt.After(marker.StartedAt) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// stripTurnMessagesAfter truncates the executor's session to keep only messages
|
||
// before the given index, discarding an incomplete synthetic turn (the synthetic
|
||
// user prompt plus every assistant/tool message that followed).
|
||
func (c *Controller) stripTurnMessagesAfter(idx int) {
|
||
if c.executor == nil {
|
||
return
|
||
}
|
||
msgs := c.executor.Session().Snapshot()
|
||
if len(msgs) <= idx {
|
||
return
|
||
}
|
||
c.replaceSessionAfterCancel(msgs[:idx])
|
||
}
|
||
|
||
// stripInterruptedSyntheticTurnMessagesAfter relocates a synthetic turn after
|
||
// an in-turn compaction has rewritten the pre-turn message index, then drops
|
||
// that whole controller-created turn.
|
||
func (c *Controller) stripInterruptedSyntheticTurnMessagesAfter(idx int) {
|
||
if c.executor == nil {
|
||
return
|
||
}
|
||
msgs := c.executor.Session().Snapshot()
|
||
startedAt := c.inFlightTurnStartedAt()
|
||
if start, ok := resolveInterruptedTurnStart(msgs, idx, false, startedAt, provider.Message{}); ok {
|
||
idx = start
|
||
}
|
||
c.stripTurnMessagesAfter(idx)
|
||
}
|
||
|
||
// stripCancelledVisibleTurnMessagesAfterWithFallback preserves the real user
|
||
// prompt and fully paired tool rounds from a cancelled visible turn. Unsafe
|
||
// assistant/tool fragments are retained as provider-excluded display history.
|
||
// It also covers coordinator
|
||
// cancellation before the executor has appended the visible user message. The
|
||
// orchestrator owns that input, so it supplies the exact message rather than
|
||
// letting cancellation infer the current turn from older transcript history.
|
||
func (c *Controller) stripCancelledVisibleTurnMessagesAfterWithFallback(idx int, fallback provider.Message) {
|
||
c.stripCancelledVisibleTurnMessagesAfterWithFallbackAt(idx, fallback, c.inFlightTurnStartedAt())
|
||
}
|
||
|
||
func (c *Controller) stripCancelledVisibleTurnMessagesAfterWithFallbackAt(idx int, fallback provider.Message, startedAt time.Time) {
|
||
if c.executor == nil {
|
||
return
|
||
}
|
||
msgs := c.executor.Session().Snapshot()
|
||
if start, ok := resolveInterruptedTurnStart(msgs, idx, true, startedAt, fallback); ok {
|
||
idx = start
|
||
}
|
||
if idx < 0 {
|
||
idx = 0
|
||
}
|
||
if idx > len(msgs) {
|
||
idx = len(msgs)
|
||
}
|
||
next := append([]provider.Message{}, msgs[:idx]...)
|
||
keptUser := false
|
||
userEnd := idx
|
||
for i, m := range msgs[idx:] {
|
||
if m.Role != provider.RoleUser {
|
||
continue
|
||
}
|
||
if IsSyntheticUserMessage(m.Content) {
|
||
continue
|
||
}
|
||
if _, ok := agent.SteerText(m.Content); ok {
|
||
continue
|
||
}
|
||
m.Content = StripComposePrefixes(m.Content)
|
||
next = append(next, m)
|
||
keptUser = true
|
||
userEnd = idx + i + 1
|
||
break
|
||
}
|
||
if !keptUser && fallback.Role == provider.RoleUser {
|
||
fallback.Content = StripComposePrefixes(fallback.Content)
|
||
if strings.TrimSpace(fallback.Content) != "" {
|
||
fallback.Images = append([]string(nil), fallback.Images...)
|
||
next = append(next, fallback)
|
||
keptUser = true
|
||
userEnd = idx
|
||
}
|
||
}
|
||
if !keptUser && len(msgs) <= idx {
|
||
return
|
||
}
|
||
recovery := &provider.InterruptedTurnRecovery{Pending: true}
|
||
localIndexes := make([]int, 0, 1)
|
||
for i := userEnd; i < len(msgs); {
|
||
m := msgs[i]
|
||
if m.LocalOnly {
|
||
m.Role = provider.RoleTool
|
||
m.ToolCallID = provider.LocalOnlyToolID
|
||
m.Name = provider.LocalOnlyToolName
|
||
m.InterruptedTurn = nil
|
||
m.ToolCalls = displayOnlyToolCalls(m.ToolCalls)
|
||
next = append(next, m)
|
||
localIndexes = append(localIndexes, len(next)-1)
|
||
recovery.DroppedPartialText = recovery.DroppedPartialText || strings.TrimSpace(m.Content) != ""
|
||
recovery.DroppedPartialReasoning = recovery.DroppedPartialReasoning || strings.TrimSpace(m.ReasoningContent) != ""
|
||
for _, call := range m.ToolCalls {
|
||
recovery.InterruptedTools = appendUniqueString(recovery.InterruptedTools, call.Name)
|
||
}
|
||
i++
|
||
continue
|
||
}
|
||
// Auto-compaction can install a digest between the pinned current user
|
||
// message and its recent tool tail. It summarizes pre-turn/current work
|
||
// that is no longer present verbatim, so keep it provider-visible rather
|
||
// than silently dropping context during recovery.
|
||
if agent.IsCompactionSummary(m) {
|
||
next = append(next, m)
|
||
i++
|
||
continue
|
||
}
|
||
if end, ok := completeToolTurnEnd(msgs, i); ok {
|
||
next = append(next, msgs[i:end]...)
|
||
for k, call := range m.ToolCalls {
|
||
if toolResultWasInterrupted(msgs[i+1+k].Content) {
|
||
recovery.InterruptedTools = appendUniqueString(recovery.InterruptedTools, call.Name)
|
||
continue
|
||
}
|
||
recovery.CompletedTools = append(recovery.CompletedTools, interruptedToolSummary(call))
|
||
}
|
||
i = end
|
||
continue
|
||
}
|
||
switch m.Role {
|
||
case provider.RoleAssistant:
|
||
local := m
|
||
local.Role = provider.RoleTool
|
||
local.LocalOnly = true
|
||
local.ToolCallID = provider.LocalOnlyToolID
|
||
local.Name = provider.LocalOnlyToolName
|
||
local.InterruptedTurn = nil
|
||
local.ReasoningSignature = ""
|
||
local.ToolCalls = displayOnlyToolCalls(local.ToolCalls)
|
||
next = append(next, local)
|
||
localIndexes = append(localIndexes, len(next)-1)
|
||
recovery.DroppedPartialText = recovery.DroppedPartialText || strings.TrimSpace(local.Content) != ""
|
||
recovery.DroppedPartialReasoning = recovery.DroppedPartialReasoning || strings.TrimSpace(local.ReasoningContent) != ""
|
||
for _, call := range local.ToolCalls {
|
||
recovery.InterruptedTools = appendUniqueString(recovery.InterruptedTools, call.Name)
|
||
}
|
||
case provider.RoleTool:
|
||
local := m
|
||
local.LocalOnly = true
|
||
local.ToolCalls = []provider.ToolCall{{ID: m.ToolCallID, Name: m.Name}}
|
||
recovery.InterruptedTools = appendUniqueString(recovery.InterruptedTools, m.Name)
|
||
local.ToolCallID = provider.LocalOnlyToolID
|
||
local.Name = provider.LocalOnlyToolName
|
||
next = append(next, local)
|
||
localIndexes = append(localIndexes, len(next)-1)
|
||
}
|
||
i++
|
||
}
|
||
if len(localIndexes) == 0 {
|
||
next = append(next, provider.Message{
|
||
Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID,
|
||
Name: provider.LocalOnlyToolName, LocalOnly: true,
|
||
})
|
||
localIndexes = append(localIndexes, len(next)-1)
|
||
}
|
||
next[localIndexes[len(localIndexes)-1]].InterruptedTurn = recovery
|
||
c.replaceSessionAfterCancel(next)
|
||
}
|
||
|
||
func (c *Controller) inFlightTurnStartedAt() time.Time {
|
||
path := c.SessionPath()
|
||
if path == "" {
|
||
return time.Time{}
|
||
}
|
||
meta, ok, err := agent.LoadBranchMeta(path)
|
||
if err != nil || !ok || meta.InFlightTurn == nil {
|
||
return time.Time{}
|
||
}
|
||
return meta.InFlightTurn.StartedAt
|
||
}
|
||
|
||
// resolveInterruptedTurnStart turns the pre-run array index into a stable
|
||
// boundary after compaction. New user messages carry a creation timestamp set
|
||
// after the marker, and graceful cleanup also has the exact composed prompt as
|
||
// a fallback. We only fall back to the legacy index when it still points at a
|
||
// plausible turn-start user message, keeping recovery data-safe for older
|
||
// sidecars without timestamps.
|
||
func resolveInterruptedTurnStart(msgs []provider.Message, idx int, preserveUser bool, startedAt time.Time, fallback provider.Message) (int, bool) {
|
||
fallbackContent := ""
|
||
if fallback.Role == provider.RoleUser {
|
||
fallbackContent = StripComposePrefixes(fallback.Content)
|
||
}
|
||
matchesKind := func(m provider.Message) bool {
|
||
if m.Role != provider.RoleUser {
|
||
return false
|
||
}
|
||
if preserveUser {
|
||
if IsSyntheticUserMessage(m.Content) {
|
||
return false
|
||
}
|
||
if _, ok := agent.SteerText(m.Content); ok {
|
||
return false
|
||
}
|
||
if fallbackContent != "" && StripComposePrefixes(m.Content) != fallbackContent {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
startedMillis := startedAt.UnixMilli()
|
||
if !startedAt.IsZero() {
|
||
for i, m := range msgs {
|
||
if matchesKind(m) && m.CreatedAt >= startedMillis {
|
||
return i, true
|
||
}
|
||
}
|
||
}
|
||
// Tests/headless runners may not persist an in-flight sidecar. The exact
|
||
// graceful fallback still distinguishes the current visible turn; search
|
||
// backward so a repeated prompt selects the newest occurrence.
|
||
if fallbackContent != "" {
|
||
for i, msg := range slices.Backward(msgs) {
|
||
if matchesKind(msg) {
|
||
return i, true
|
||
}
|
||
}
|
||
}
|
||
if idx >= 0 && idx < len(msgs) && matchesKind(msgs[idx]) {
|
||
return idx, true
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func (c *Controller) hasInterruptedDisplayAfter(idx int, fallback provider.Message) bool {
|
||
if c.executor == nil {
|
||
return false
|
||
}
|
||
msgs := c.executor.Session().Snapshot()
|
||
if start, ok := resolveInterruptedTurnStart(msgs, idx, true, c.inFlightTurnStartedAt(), fallback); ok {
|
||
idx = start
|
||
}
|
||
idx = max(0, min(idx, len(msgs)))
|
||
for _, m := range msgs[idx:] {
|
||
if m.LocalOnly && m.InterruptedTurn != nil {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func completeToolTurnEnd(msgs []provider.Message, i int) (int, bool) {
|
||
if i < 0 || i >= len(msgs) {
|
||
return i, false
|
||
}
|
||
m := msgs[i]
|
||
if m.LocalOnly || m.Role != provider.RoleAssistant || len(m.ToolCalls) == 0 {
|
||
return i, false
|
||
}
|
||
end := i + 1
|
||
for end < len(msgs) && msgs[end].Role == provider.RoleTool && !msgs[end].LocalOnly {
|
||
end++
|
||
}
|
||
results := msgs[i+1 : end]
|
||
if len(results) != len(m.ToolCalls) {
|
||
return i, false
|
||
}
|
||
for k, call := range m.ToolCalls {
|
||
if strings.TrimSpace(call.Name) == "" || (call.Arguments != "" && !json.Valid([]byte(call.Arguments))) {
|
||
return i, false
|
||
}
|
||
if results[k].ToolCallID != call.ID || results[k].Name != call.Name {
|
||
return i, false
|
||
}
|
||
}
|
||
return end, true
|
||
}
|
||
|
||
func toolResultWasInterrupted(content string) bool {
|
||
content = strings.ToLower(strings.TrimSpace(content))
|
||
return strings.HasPrefix(content, "cancelled:") || strings.Contains(content, "context canceled") || strings.Contains(content, "context cancelled")
|
||
}
|
||
|
||
func displayOnlyToolCalls(calls []provider.ToolCall) []provider.ToolCall {
|
||
out := make([]provider.ToolCall, 0, len(calls))
|
||
for _, call := range calls {
|
||
out = append(out, provider.ToolCall{ID: call.ID, Name: strings.TrimSpace(call.Name)})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func appendUniqueString(dst []string, value string) []string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return dst
|
||
}
|
||
if slices.Contains(dst, value) {
|
||
return dst
|
||
}
|
||
return append(dst, value)
|
||
}
|
||
|
||
func interruptedToolSummary(call provider.ToolCall) provider.InterruptedToolSummary {
|
||
summary := provider.InterruptedToolSummary{
|
||
ID: call.ID, Name: strings.TrimSpace(call.Name), Added: call.Added, Removed: call.Removed,
|
||
}
|
||
addFile := func(path string) {
|
||
path = strings.TrimSpace(path)
|
||
if path == "" || path == "/dev/null" || len(summary.Files) >= 8 {
|
||
return
|
||
}
|
||
if slices.Contains(summary.Files, path) {
|
||
return
|
||
}
|
||
summary.Files = append(summary.Files, path)
|
||
}
|
||
var args map[string]any
|
||
if json.Unmarshal([]byte(call.Arguments), &args) == nil {
|
||
for _, key := range []string{"path", "file", "file_path", "filename"} {
|
||
if value, ok := args[key].(string); ok && strings.TrimSpace(value) != "" {
|
||
addFile(value)
|
||
}
|
||
}
|
||
}
|
||
for line := range strings.SplitSeq(call.Diff, "\n") {
|
||
line = strings.TrimSpace(line)
|
||
switch {
|
||
case strings.HasPrefix(line, "+++ b/"):
|
||
addFile(strings.TrimPrefix(line, "+++ b/"))
|
||
case strings.HasPrefix(line, "--- a/"):
|
||
addFile(strings.TrimPrefix(line, "--- a/"))
|
||
case strings.HasPrefix(line, "*** Update File: "):
|
||
addFile(strings.TrimPrefix(line, "*** Update File: "))
|
||
case strings.HasPrefix(line, "*** Add File: "):
|
||
addFile(strings.TrimPrefix(line, "*** Add File: "))
|
||
case strings.HasPrefix(line, "*** Delete File: "):
|
||
addFile(strings.TrimPrefix(line, "*** Delete File: "))
|
||
}
|
||
}
|
||
return summary
|
||
}
|
||
|
||
func (c *Controller) replaceSessionAfterCancel(msgs []provider.Message) {
|
||
// The whole cleanup is a save/recovery handoff like snapshot's: hold
|
||
// snapshotMu from the in-memory truncation onward. Truncating outside the
|
||
// lock would let an in-flight save capture the shortened transcript, read
|
||
// the longer partial autosave on disk as a stale-prefix conflict, and
|
||
// adopt it back into the executor — silently undoing the cancel cleanup
|
||
// before the flush below could persist it.
|
||
c.snapshotMu.Lock()
|
||
defer c.snapshotMu.Unlock()
|
||
c.executor.Session().Replace(append([]provider.Message(nil), msgs...))
|
||
// Rebuild canonical todo state from the truncated transcript so
|
||
// Controller.Todos(), goal readiness, and the task panel no longer see
|
||
// the in_progress items written by the cancelled turn.
|
||
c.executor.RebuildTodoState()
|
||
// The mid-turn autosave may have already written a partial transcript to
|
||
// disk. snapshotActivityIfChanged skips the write when messageCount()
|
||
// returns to startMessages, so flush the cleaned transcript here. SaveRewrite
|
||
// still checks that this controller owns the current on-disk baseline before
|
||
// overwriting it, and also covers the edge case where the strip leaves only a
|
||
// system message (HasContent() == false). The path is read under the lock so
|
||
// an in-flight recovery retarget cannot leave it stale.
|
||
c.mu.Lock()
|
||
path := c.sessionPath
|
||
c.mu.Unlock()
|
||
if path != "" {
|
||
if err := c.executor.Session().SaveRewrite(path); err != nil {
|
||
if errors.Is(err, agent.ErrSessionSnapshotConflict) {
|
||
if _, outcome, recoverErr := c.recoverSnapshotConflict(path, err, true); recoverErr != nil {
|
||
slog.Warn("controller: post-cancel transcript recovery", "err", recoverErr)
|
||
} else if outcome == conflictDropped {
|
||
slog.Warn("controller: post-cancel transcript dropped after conflict", "path", path)
|
||
}
|
||
} else {
|
||
slog.Warn("controller: post-cancel transcript flush", "err", err)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func (c *Controller) snapshotActivityIfChanged(startMessages int) {
|
||
if c.messageCount() <= startMessages {
|
||
return
|
||
}
|
||
if err := c.SnapshotActivity(); err != nil {
|
||
slog.Warn("controller: activity snapshot", "err", err)
|
||
}
|
||
}
|
||
|
||
// SetSessionPath rebinds auto-save without changing the current session
|
||
// preference. Callers creating a genuinely fresh conversation should use
|
||
// SetFreshSessionPath; callers resuming history should use Resume.
|
||
func (c *Controller) SetSessionPath(p string) {
|
||
c.setSessionPath(p, false)
|
||
}
|
||
|
||
// SetFreshSessionPath binds a path that is known to belong to a newly-created
|
||
// session and samples the configured new-session recovery default.
|
||
func (c *Controller) SetFreshSessionPath(p string) {
|
||
c.setSessionPath(p, true)
|
||
}
|
||
|
||
func (c *Controller) setSessionPath(p string, fresh bool) {
|
||
// See snapshotMu: the swap must not interleave with an in-flight save.
|
||
c.snapshotMu.Lock()
|
||
c.mu.Lock()
|
||
c.sessionPath = p
|
||
c.guardianPath = guardian.PathFor(p)
|
||
c.mu.Unlock()
|
||
// Fresh paths clear projection; rebinds keep/load the target sidecar.
|
||
c.bindExecutorProjection(p, !fresh)
|
||
c.setActiveJobSession(p)
|
||
c.rebindCheckpoints(p)
|
||
if fresh {
|
||
c.resetRecoveryForNewSession(p)
|
||
// A newly-created conversation must not share the previous logical
|
||
// session's temporary files (e.g. after EnsureSessionPath on a
|
||
// controller that already ran commands).
|
||
c.rotateSessionTemp()
|
||
} else {
|
||
c.loadRecoveryState(p)
|
||
}
|
||
c.snapshotMu.Unlock()
|
||
if !fresh {
|
||
c.recoverCheckpointTransactions()
|
||
}
|
||
}
|
||
|
||
// SessionDestroyHandle separates waiting for cancelled jobs from ending the
|
||
// destroy window, so callers can move/delete persistent artifacts in between.
|
||
type SessionDestroyHandle struct {
|
||
Wait func() jobs.TeardownResult
|
||
WaitAll func()
|
||
Finish func()
|
||
Async bool
|
||
}
|
||
|
||
// BeginDestroySession marks a session as leaving active use and cancels its
|
||
// background jobs. Call Wait before moving/deleting artifacts, then Finish after
|
||
// persistent cleanup/move work is complete.
|
||
func (c *Controller) BeginDestroySession(sessionPath string) SessionDestroyHandle {
|
||
parentSession := agent.BranchID(sessionPath)
|
||
if c.jobs == nil || parentSession == "" {
|
||
wait := func() jobs.TeardownResult { return jobs.TeardownResult{} }
|
||
noop := func() {}
|
||
return SessionDestroyHandle{Wait: wait, WaitAll: noop, Finish: noop}
|
||
}
|
||
teardown := c.jobs.BeginDestroySession(parentSession)
|
||
return SessionDestroyHandle{
|
||
Wait: func() jobs.TeardownResult {
|
||
return c.jobs.WaitTeardown(context.Background(), teardown, c.jobs.TeardownGrace())
|
||
},
|
||
WaitAll: func() {
|
||
for _, ch := range teardown.DoneChannels() {
|
||
<-ch
|
||
}
|
||
},
|
||
Finish: func() {
|
||
c.jobs.FinishDestroySession(parentSession)
|
||
},
|
||
Async: teardown.Async(),
|
||
}
|
||
}
|
||
|
||
// IsDestroyingSession reports whether sessionPath is currently in the destroy
|
||
// window for this controller's job manager.
|
||
func (c *Controller) IsDestroyingSession(sessionPath string) bool {
|
||
if c.jobs == nil {
|
||
return false
|
||
}
|
||
return c.jobs.IsDestroying(agent.BranchID(sessionPath))
|
||
}
|
||
|
||
func (c *Controller) setActiveJobSession(sessionPath string) {
|
||
if c.jobs != nil {
|
||
c.jobs.SetActiveSessionPath(agent.BranchID(sessionPath), sessionPath)
|
||
}
|
||
}
|
||
|
||
// SessionDir reports the directory new session files land in ("" disables
|
||
// persistence), so the caller can decide whether to mint a path.
|
||
func (c *Controller) SessionDir() string { return c.sessionDir }
|
||
|
||
// SessionPath reports the file the current conversation auto-saves to ("" when
|
||
// persistence is disabled), so a history view can mark the active session.
|
||
func (c *Controller) SessionPath() string {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
return c.sessionPath
|
||
}
|
||
|
||
func (c *Controller) parentSessionID() string {
|
||
return agent.BranchID(c.SessionPath())
|
||
}
|
||
|
||
// History returns the executor's current message log (for repopulating a
|
||
// resumed frontend's view).
|
||
func (c *Controller) History() []provider.Message {
|
||
if c.executor == nil {
|
||
return nil
|
||
}
|
||
return c.executor.Session().Snapshot() // copy — a turn may be appending concurrently
|
||
}
|
||
|
||
// ContextSnapshot returns (usedTokens, contextWindow) from the most recent
|
||
// turn. Both zero means no data yet — a gauge hides itself.
|
||
// usedTokens is promptTokens + completionTokens so the GUI breakdown and
|
||
// gauge reflect the full token usage, not just the prompt fill.
|
||
func (c *Controller) ContextSnapshot() (int, int) {
|
||
if c.executor == nil {
|
||
return 0, 0
|
||
}
|
||
u := c.executor.LastUsage()
|
||
if u == nil {
|
||
return 0, c.executor.ContextWindow()
|
||
}
|
||
return u.PromptTokens + u.CompletionTokens, c.executor.ContextWindow()
|
||
}
|
||
|
||
// CompactRatio returns the auto-compaction threshold as a fraction of the window
|
||
// (0 when the executor is unset). The status line shows headroom against it.
|
||
func (c *Controller) CompactRatio() float64 {
|
||
if c.executor == nil {
|
||
return 0
|
||
}
|
||
return c.executor.CompactRatio()
|
||
}
|
||
|
||
// LastUsage returns the most recent turn's token telemetry (nil before the first
|
||
// turn), so frontends can derive the prompt cache-hit rate for the status line.
|
||
func (c *Controller) LastUsage() *provider.Usage {
|
||
if c.executor == nil {
|
||
return nil
|
||
}
|
||
return c.executor.LastUsage()
|
||
}
|
||
|
||
// SessionCache returns cumulative cache hit/miss prompt tokens for the session,
|
||
// so a frontend can render the aggregate (session-wide) cache-hit rate — steadier
|
||
// than the single-turn rate and unaffected by compaction.
|
||
func (c *Controller) SessionCache() (hit, miss int) {
|
||
if c.executor == nil {
|
||
return 0, 0
|
||
}
|
||
return c.executor.SessionCache()
|
||
}
|
||
|
||
// Todos returns a copy of the canonical task list (the latest todo_write state
|
||
// merged with complete_step advances) so frontends can render a live task panel.
|
||
func (c *Controller) Todos() []evidence.TodoItem {
|
||
if c.executor == nil {
|
||
return nil
|
||
}
|
||
return c.executor.CanonicalTodoState()
|
||
}
|
||
|
||
// ToolResultData holds the full arguments and output for one tool call, loaded
|
||
// on demand when a frontend expands a collapsed tool card.
|
||
type ToolResultData struct {
|
||
Args string `json:"args"`
|
||
Output string `json:"output"`
|
||
Execution *provider.ToolExecution `json:"execution,omitempty"`
|
||
}
|
||
|
||
// ToolResult looks up a tool call by its ID in the session history and returns
|
||
// the full arguments + output that were elided from the frontend's items[].
|
||
// Returns nil when the tool ID isn't found (e.g. a sub-agent's tool call that
|
||
// lives in a different session).
|
||
func (c *Controller) ToolResult(toolID string) *ToolResultData {
|
||
if c.executor == nil {
|
||
return nil
|
||
}
|
||
msgs := c.executor.Session().Snapshot()
|
||
// Search backwards: tool result first (most recent), then find the args
|
||
// from the preceding assistant turn.
|
||
for i, msg := range slices.Backward(msgs) {
|
||
if msg.Role != provider.RoleTool || msg.ToolCallID != toolID {
|
||
continue
|
||
}
|
||
out := &ToolResultData{
|
||
Args: "",
|
||
Output: msg.Content,
|
||
Execution: msg.ToolExecution,
|
||
}
|
||
// Walk back to find the assistant turn that issued this call.
|
||
for j := i; j >= 0; j-- {
|
||
if msgs[j].Role != provider.RoleAssistant {
|
||
continue
|
||
}
|
||
for _, tc := range msgs[j].ToolCalls {
|
||
if tc.ID == toolID {
|
||
out.Args = tc.Arguments
|
||
return out
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Balance queries the active provider's wallet balance, or (nil, nil) when the
|
||
// provider declares no balance_url — so a caller treats "not configured" and
|
||
// "fetched" the same and just omits the readout when nil.
|
||
func (c *Controller) Balance(ctx context.Context) (*billing.Balance, error) {
|
||
if strings.TrimSpace(c.balanceURL) == "" {
|
||
return nil, nil
|
||
}
|
||
ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||
defer cancel()
|
||
return billing.FetchWithClient(ctx, c.balanceClient, c.balanceURL, c.balanceKey)
|
||
}
|
||
|
||
// Host returns the running MCP host (nil when no plugins), for frontends that
|
||
// list servers / resolve MCP prompts.
|
||
func (c *Controller) Host() *plugin.Host { return c.mcp.hostRef() }
|
||
|
||
// Commands returns the loaded custom slash commands.
|
||
func (c *Controller) Commands() []command.Command {
|
||
if p := c.commands.Load(); p != nil {
|
||
return *p
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ReloadCommands rescans all command directories and hot-swaps the slash_command
|
||
// tool and the internal command slice — no MCP restart, no hook rerun.
|
||
func (c *Controller) ReloadCommands(ctx context.Context) error {
|
||
select {
|
||
case <-ctx.Done():
|
||
return ctx.Err()
|
||
default:
|
||
}
|
||
cmds, loadErr := command.LoadRoots(config.CommandRootsForRoot(c.workspaceRoot)...)
|
||
cmdSkills := c.SlashSkills()
|
||
|
||
entries := make([]command.SlashEntry, 0, len(cmdSkills)+len(cmds))
|
||
for _, sk := range cmdSkills {
|
||
|
||
entries = append(entries, command.SlashEntry{
|
||
Name: sk.SlashName(),
|
||
Description: sk.Description,
|
||
Render: func(args []string) string { return c.skills.render(sk, strings.Join(args, " ")) },
|
||
})
|
||
}
|
||
for _, cmd := range cmds {
|
||
if cmd.Hidden {
|
||
continue
|
||
}
|
||
|
||
entries = append(entries, command.SlashEntry{
|
||
Name: cmd.Name,
|
||
Description: cmd.Description,
|
||
ArgHint: cmd.ArgHint,
|
||
Render: func(args []string) string { return cmd.Render(args) },
|
||
})
|
||
}
|
||
c.mcp.registerTool(command.NewSlashCommandTool(entries))
|
||
cmdSlice := cmds
|
||
c.commands.Store(&cmdSlice)
|
||
return loadErr
|
||
}
|
||
|
||
// Skills returns the discoverable skills (for the slash menu and `/skills`).
|
||
// When a live Store is available, scan it on demand so skills installed during
|
||
// this session appear without rewriting the cache-stable system prompt.
|
||
// Executor returns the underlying agent when present (nil for pure runners).
|
||
func (c *Controller) Executor() *agent.Agent {
|
||
if c == nil {
|
||
return nil
|
||
}
|
||
return c.executor
|
||
}
|
||
|
||
func (c *Controller) Skills() []skill.Skill {
|
||
return c.skills.list()
|
||
}
|
||
|
||
// SlashSkills returns the user-visible skill directory. Plugin skills use
|
||
// package-qualified names while Skills keeps bare model/run_skill identifiers.
|
||
func (c *Controller) SlashSkills() []skill.Skill {
|
||
return c.skills.slashList()
|
||
}
|
||
|
||
// AllSkills returns every discoverable skill, including disabled ones, for
|
||
// management surfaces that need to re-enable a hidden skill.
|
||
func (c *Controller) AllSkills() []skill.Skill {
|
||
return c.skills.listAll()
|
||
}
|
||
|
||
// DisabledSkills returns all discoverable skills that are disabled in config.
|
||
func (c *Controller) DisabledSkills() []skill.Skill {
|
||
cfg, err := config.Load()
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var out []skill.Skill
|
||
for _, sk := range c.AllSkills() {
|
||
if cfg.IsSkillDisabled(sk.Name) {
|
||
out = append(out, sk)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// SkillEnabled reports whether a discoverable skill is enabled.
|
||
func (c *Controller) SkillEnabled(name string) bool {
|
||
cfg, err := config.Load()
|
||
if err != nil {
|
||
return true
|
||
}
|
||
return !cfg.IsSkillDisabled(name)
|
||
}
|
||
|
||
// SetSkillEnabled persists a skill enable/disable preference. The caller should
|
||
// rebuild the controller for the prompt/tool registry to reflect it immediately.
|
||
func (c *Controller) SetSkillEnabled(name string, enabled bool) error {
|
||
found := false
|
||
for _, sk := range c.AllSkills() {
|
||
if config.SkillNameKey(sk.Name) == config.SkillNameKey(name) {
|
||
name = sk.Name
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
return fmt.Errorf("unknown skill: %s", name)
|
||
}
|
||
// Serialize the load-modify-save against other in-process user-config
|
||
// editors so concurrent writers (bot mapping persistence, desktop
|
||
// settings) don't drop this toggle or lose their own fields.
|
||
unlock := config.LockUserConfigEdits()
|
||
defer unlock()
|
||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||
if err := cfg.SetSkillEnabled(name, enabled); err != nil {
|
||
return err
|
||
}
|
||
return cfg.SaveTo(config.UserConfigPath())
|
||
}
|
||
|
||
// CreateSkill writes a new skill file at the given scope and returns its
|
||
// path. Skills()/AllSkills()/RunSkill() read the live store on demand, so the
|
||
// new skill is usable (by name) immediately with no rebuild; the caller
|
||
// should still rebuild the controller for the pinned Skills index and tool
|
||
// registry to reflect it on the model's next turn, mirroring how
|
||
// SetSkillEnabled's callers already rebuild after a config change.
|
||
func (c *Controller) CreateSkill(name string, scope skill.Scope, content string) (string, error) {
|
||
w := c.skills.writer()
|
||
if w == nil {
|
||
return "", fmt.Errorf("no writable skill store in this session")
|
||
}
|
||
return w.CreateWithContent(name, scope, content)
|
||
}
|
||
|
||
// UpdateSkill overwrites an existing user-authored skill file in place. See
|
||
// skill.Store.UpdateContent for the builtin-refusal and scope-match rules.
|
||
func (c *Controller) UpdateSkill(name string, scope skill.Scope, content string) error {
|
||
w := c.skills.writer()
|
||
if w == nil {
|
||
return fmt.Errorf("no writable skill store in this session")
|
||
}
|
||
return w.UpdateContent(name, scope, content)
|
||
}
|
||
|
||
// DeleteSkill removes a user-authored skill file at the given scope. See
|
||
// skill.Store.Delete for the builtin-refusal and scope-match rules.
|
||
func (c *Controller) DeleteSkill(name string, scope skill.Scope) error {
|
||
w := c.skills.writer()
|
||
if w == nil {
|
||
return fmt.Errorf("no writable skill store in this session")
|
||
}
|
||
return w.Delete(name, scope)
|
||
}
|
||
|
||
// HookRunner returns the session's hook runner (nil-safe; may hold zero hooks),
|
||
// so a frontend can list the active hooks via `/hooks`.
|
||
func (c *Controller) HookRunner() *hook.Runner { return c.hooks }
|
||
|
||
// AddMCPServer connects an MCP server live and persists it to the user-global
|
||
// config. Its tools are registered immediately and become available on the next
|
||
// turn (the agent reads the registry per turn). The raw entry — ${VARS} intact —
|
||
// is what's written to disk; the live connection uses the expanded form. Returns
|
||
// the number of tools the server exposed. Persistence is transactional: a config
|
||
// or activation failure removes the just-connected client so the live registry
|
||
// never claims an install that will disappear after restart.
|
||
func (c *Controller) AddMCPServer(e config.PluginEntry) (int, error) {
|
||
// AddMCPServer is an explicit user action. Mark the live entry with the same
|
||
// provenance it will receive when the saved user config is loaded next time,
|
||
// so /mcp add is add-and-use in the current session too.
|
||
e.Source = config.MCPSourceUserConfig
|
||
if effective, loadErr := config.LoadForRootReadOnly(c.workspaceRoot); loadErr != nil {
|
||
return 0, loadErr
|
||
} else {
|
||
for _, configured := range effective.Plugins {
|
||
if configured.Name != e.Name {
|
||
continue
|
||
}
|
||
if configured.Source != config.MCPSourceUserConfig && configured.Source != config.MCPSourceLegacyUser {
|
||
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)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
n, err := c.connectMCPServer(e)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
if _, err := config.InstallUserPluginForRoot(c.workspaceRoot, e, true); err != nil {
|
||
c.DisconnectMCPServer(e.Name)
|
||
return 0, fmt.Errorf("saving MCP server config: %w", err)
|
||
}
|
||
return n, nil
|
||
}
|
||
|
||
// ConnectMCPServer connects an MCP server entry for this session without writing
|
||
// it to config. Desktop owns config placement so it can keep user-level settings
|
||
// out of project reasonix.toml while preserving the CLI AddMCPServer semantics.
|
||
func (c *Controller) ConnectMCPServer(e config.PluginEntry) (int, error) {
|
||
return c.connectMCPServer(e)
|
||
}
|
||
|
||
// RegisterMCPServerOnDemand restores a configured server's cached provider
|
||
// surface without forcing a handshake. It is the durable-enable counterpart to
|
||
// ConnectMCPServer, which remains the explicit install/retry operation.
|
||
func (c *Controller) RegisterMCPServerOnDemand(e config.PluginEntry) (int, error) {
|
||
spec := c.mcpSpec(e)
|
||
n, err := c.mcp.registerSpecOnDemand(spec)
|
||
if err == nil && c.capabilityRuntime != nil {
|
||
c.capabilityRuntime.UpsertServer(e, spec, true)
|
||
}
|
||
return n, err
|
||
}
|
||
|
||
// connectMCPServer expands an entry's ${VARS}, applies the known-server
|
||
// overrides scoped to the workspace, and connects it live via the mcp manager.
|
||
func (c *Controller) connectMCPServer(e config.PluginEntry) (int, error) {
|
||
spec := c.mcpSpec(e)
|
||
n, err := c.mcp.connectSpec(spec)
|
||
if err == nil && c.capabilityRuntime != nil {
|
||
c.capabilityRuntime.UpsertServer(e, spec, true)
|
||
}
|
||
return n, err
|
||
}
|
||
|
||
func (c *Controller) mcpSpec(e config.PluginEntry) plugin.Spec {
|
||
exp := e.ExpandedPlugin()
|
||
configSource := strings.TrimSpace(string(exp.Source))
|
||
spec := plugin.ApplyKnownOverrides(plugin.Spec{
|
||
Name: exp.Name,
|
||
Type: exp.Type,
|
||
Command: exp.Command,
|
||
Args: exp.Args,
|
||
Env: exp.Env,
|
||
URL: exp.URL,
|
||
Headers: exp.Headers,
|
||
StartupTimeout: controllerMCPTimeout(exp.StartupTimeoutSeconds),
|
||
DefaultCallTimeout: c.mcpDefaultCallTimeout,
|
||
CallTimeout: controllerMCPTimeout(exp.CallTimeoutSeconds),
|
||
ToolTimeouts: controllerMCPToolTimeouts(exp.ToolTimeoutSeconds),
|
||
WorkspaceRoot: c.WorkspaceRoot(),
|
||
ConfigSource: configSource,
|
||
Authorized: exp.Source.UserAuthorized(),
|
||
// Explicit user installs and reconnects run as trusted host processes.
|
||
ProcessMode: plugin.MCPProcessHost,
|
||
}, c.WorkspaceRoot())
|
||
if exp.Source.ProjectScoped() && strings.TrimSpace(spec.Dir) == "" {
|
||
spec.Dir = c.WorkspaceRoot()
|
||
}
|
||
if c.mcpConfigureSpec != nil {
|
||
c.mcpConfigureSpec(&spec)
|
||
if spec.ProcessMode == "" {
|
||
spec.ProcessMode = plugin.MCPProcessHost
|
||
}
|
||
}
|
||
return spec
|
||
}
|
||
|
||
// syncCapabilityRuntimeFromConfig restores one server's authoritative runtime
|
||
// entry after a transactional disconnect/rollback. enabledOverride is used for
|
||
// a session-only disconnect; nil re-resolves the durable activation state.
|
||
func (c *Controller) syncCapabilityRuntimeFromConfig(name string, enabledOverride *bool) {
|
||
if c == nil || c.capabilityRuntime == nil {
|
||
return
|
||
}
|
||
name = strings.TrimSpace(name)
|
||
cfg, err := config.LoadForRoot(c.workspaceRoot)
|
||
if err != nil {
|
||
// The caller revokes first. A config read failure must not re-enable a
|
||
// potentially stale spec or shared-Host client.
|
||
return
|
||
}
|
||
for _, entry := range cfg.Plugins {
|
||
if strings.TrimSpace(entry.Name) != name {
|
||
continue
|
||
}
|
||
enabled := entry.ShouldAutoStart()
|
||
if enabledOverride != nil {
|
||
enabled = *enabledOverride
|
||
} else if resolved, resolveErr := config.DefaultMCPActivationStore().IsEnabled(entry, c.workspaceRoot); resolveErr == nil {
|
||
enabled = resolved
|
||
}
|
||
c.capabilityRuntime.UpsertServer(entry, c.mcpSpec(entry), enabled)
|
||
return
|
||
}
|
||
c.capabilityRuntime.RemoveServer(name)
|
||
}
|
||
|
||
func controllerMCPTimeout(seconds int) time.Duration {
|
||
if seconds <= 0 {
|
||
return 0
|
||
}
|
||
return time.Duration(seconds) * time.Second
|
||
}
|
||
|
||
func controllerMCPToolTimeouts(values map[string]int) map[string]time.Duration {
|
||
if len(values) == 0 {
|
||
return nil
|
||
}
|
||
out := make(map[string]time.Duration, len(values))
|
||
for name, seconds := range values {
|
||
if name = strings.TrimSpace(name); name != "" && seconds > 0 {
|
||
out[name] = time.Duration(seconds) * time.Second
|
||
}
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ImportMCPEntries persists selected MCP entries and attempts to connect them
|
||
// live. A connection failure does not roll back the config import: the user can
|
||
// fix local dependencies and reconnect in a later session.
|
||
func (c *Controller) ImportMCPEntries(entries []config.PluginEntry) (total, added, updated, connected, failed, skipped int, err error) {
|
||
total, added, updated, err = config.ImportCCSwitchMCPEntries(entries)
|
||
if err != nil {
|
||
return 0, 0, 0, 0, 0, 0, err
|
||
}
|
||
effectiveCfg, loadErr := config.LoadForRoot(c.workspaceRoot)
|
||
if loadErr != nil {
|
||
return 0, 0, 0, 0, 0, 0, loadErr
|
||
}
|
||
effective := make(map[string]config.PluginEntry, len(effectiveCfg.Plugins))
|
||
for _, entry := range effectiveCfg.Plugins {
|
||
effective[entry.Name] = entry
|
||
}
|
||
for _, imported := range entries {
|
||
e, ok := effective[imported.Name]
|
||
if !ok || e.Source != config.MCPSourceUserConfig {
|
||
// A project declaration with the same name remains effective. The
|
||
// imported global entry is saved as its lower-priority fallback.
|
||
skipped++
|
||
continue
|
||
}
|
||
if c.mcp.hasServer(e.Name) {
|
||
if c.capabilityRuntime != nil {
|
||
// Import updates may intentionally keep an existing live client, but
|
||
// future proxy reconnects must use the newly persisted spec.
|
||
c.capabilityRuntime.UpsertServer(e, c.mcpSpec(e), true)
|
||
}
|
||
skipped++
|
||
continue
|
||
}
|
||
if _, err := c.AddMCPServer(e); err != nil {
|
||
failed++
|
||
continue
|
||
}
|
||
connected++
|
||
}
|
||
return total, added, updated, connected, failed, skipped, nil
|
||
}
|
||
|
||
func (c *Controller) ConfiguredMCPNames() []string {
|
||
cfg, err := config.LoadForRootReadOnly(c.workspaceRoot)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
names := make([]string, 0, len(cfg.Plugins))
|
||
for _, p := range cfg.Plugins {
|
||
names = append(names, p.Name)
|
||
}
|
||
return names
|
||
}
|
||
|
||
func (c *Controller) DisconnectedMCPNames() []string {
|
||
cfg, err := config.LoadForRootReadOnly(c.workspaceRoot)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
connected := map[string]bool{}
|
||
for _, name := range c.mcp.serverNames() {
|
||
connected[name] = true
|
||
}
|
||
var names []string
|
||
for _, p := range cfg.Plugins {
|
||
if !connected[p.Name] {
|
||
names = append(names, p.Name)
|
||
}
|
||
}
|
||
return names
|
||
}
|
||
|
||
func (c *Controller) ConnectConfiguredMCPServer(name string) (int, error) {
|
||
p, err := c.configuredMCPServer(name)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return c.connectMCPServer(p)
|
||
}
|
||
|
||
func (c *Controller) configuredMCPServer(name string) (config.PluginEntry, error) {
|
||
cfg, err := config.LoadForRoot(c.workspaceRoot)
|
||
if err != nil {
|
||
return config.PluginEntry{}, err
|
||
}
|
||
for _, p := range cfg.Plugins {
|
||
if p.Name == name {
|
||
return p, nil
|
||
}
|
||
}
|
||
return config.PluginEntry{}, fmt.Errorf("no configured MCP server named %q", name)
|
||
}
|
||
|
||
// RemoveMCPServer removes a writable MCP configuration before disconnecting the
|
||
// live server, so a persistence failure never produces a false-successful
|
||
// session-only removal. MCPs contributed by an installed plugin package are
|
||
// managed with that package and cannot be removed independently.
|
||
func (c *Controller) RemoveMCPServer(name string) (disconnected bool, err error) {
|
||
cfg, lerr := config.LoadForRoot(c.workspaceRoot)
|
||
if lerr != nil {
|
||
return false, lerr
|
||
}
|
||
if owner, ok := cfg.PluginPackageOwner(name); ok {
|
||
return false, fmt.Errorf("MCP server %q is managed by plugin %q; disable or remove the plugin instead", name, owner)
|
||
}
|
||
entry, removed, _, rerr := config.RemovePluginFromEffectiveSourceForRoot(c.workspaceRoot, name)
|
||
if rerr != nil {
|
||
return false, rerr
|
||
}
|
||
if !removed {
|
||
return false, fmt.Errorf("no removable MCP server named %q", name)
|
||
}
|
||
_ = config.DefaultMCPActivationStore().ClearServer(entry, c.workspaceRoot)
|
||
if c.capabilityRuntime != nil {
|
||
// Revoke before touching the shared Host so an overlapping resolver cannot
|
||
// reuse a sibling tab's still-connected client.
|
||
c.capabilityRuntime.RemoveServer(name)
|
||
}
|
||
disconnected = c.mcp.disconnect(name)
|
||
if !disconnected {
|
||
c.mcp.removeToolPrefix(name)
|
||
}
|
||
// A lower-priority same-name declaration may now be effective. Restore its
|
||
// cached/on-demand surface without starting a process; otherwise ensure the
|
||
// removed name stays absent.
|
||
if fallback, fallbackErr := c.configuredMCPServer(name); fallbackErr == nil {
|
||
enabled := fallback.ShouldAutoStart()
|
||
if resolved, resolveErr := config.DefaultMCPActivationStore().IsEnabled(fallback, c.workspaceRoot); resolveErr == nil {
|
||
enabled = resolved
|
||
}
|
||
if enabled {
|
||
_, _ = c.RegisterMCPServerOnDemand(fallback)
|
||
} else {
|
||
c.syncCapabilityRuntimeFromConfig(name, &enabled)
|
||
}
|
||
} else {
|
||
c.syncCapabilityRuntimeFromConfig(name, nil)
|
||
}
|
||
return disconnected, nil
|
||
}
|
||
|
||
// DisconnectMCPServer disconnects a live server for this session without touching
|
||
// config — the connector toggle's "off". Its tools vanish next turn; it reconnects
|
||
// on the next session start, or now via ConnectConfiguredMCPServer (the "on").
|
||
// Reports whether a live server was actually disconnected.
|
||
func (c *Controller) DisconnectMCPServer(name string) bool {
|
||
if c.capabilityRuntime != nil {
|
||
c.capabilityRuntime.SetServerEnabled(name, false)
|
||
}
|
||
disconnected := c.mcp.disconnect(name)
|
||
removedPlaceholder := 0
|
||
if !disconnected {
|
||
removedPlaceholder = c.mcp.removeToolPrefix(name)
|
||
}
|
||
// Keep configured servers discoverable as disabled, but forget runtime-only
|
||
// or rolled-back installs that no longer exist in configuration.
|
||
disabled := false
|
||
c.syncCapabilityRuntimeFromConfig(name, &disabled)
|
||
return disconnected || removedPlaceholder > 0
|
||
}
|
||
|
||
// UnregisterMCPServerTools hides a shared MCP server from this controller only.
|
||
// The desktop shared-host path uses this for per-tab connector toggles: the
|
||
// shared client stays alive for sibling tabs, while this session's registry drops
|
||
// the server's provider-visible tools before the next turn.
|
||
func (c *Controller) UnregisterMCPServerTools(name string) bool {
|
||
if c.capabilityRuntime != nil {
|
||
c.capabilityRuntime.SetServerEnabled(name, false)
|
||
}
|
||
return c.mcp.suspendToolPrefix(name)
|
||
}
|
||
|
||
// Label returns the human-readable model label, e.g. "deepseek-flash".
|
||
func (c *Controller) Label() string { return c.label }
|
||
|
||
// ModelRef returns the canonical provider/model reference for the session.
|
||
func (c *Controller) ModelRef() string { return c.modelRef }
|
||
|
||
// WorkspaceRoot returns the workspace root for this controller's session
|
||
// (the directory that file-writers and @-references are scoped to).
|
||
// Empty means no scoping is in effect.
|
||
func (c *Controller) WorkspaceRoot() string { return c.workspaceRoot }
|
||
|
||
func (c *Controller) imageInputEnabled() bool {
|
||
ref := c.modelRef
|
||
cfg, err := config.LoadForRoot(c.workspaceRoot)
|
||
if err == nil && ref == "" {
|
||
ref = cfg.DefaultModel
|
||
}
|
||
if err != nil || ref == "" {
|
||
return false
|
||
}
|
||
entry, ok := cfg.ResolveModel(ref)
|
||
return ok && config.EffectiveVision(entry)
|
||
}
|
||
|
||
// ImageInputEnabled reports whether the current model accepts direct image
|
||
// inputs, so frontends can gate image-only UX before a turn starts.
|
||
func (c *Controller) ImageInputEnabled() bool { return c.imageInputEnabled() }
|
||
|
||
// InheritLifecycleFrom carries same-session lifecycle state across controller
|
||
// rebuilds, such as model switches that preserve the conversation.
|
||
func (c *Controller) InheritLifecycleFrom(prev *Controller) {
|
||
if prev == nil {
|
||
return
|
||
}
|
||
prev.mu.Lock()
|
||
started := prev.startedOnce
|
||
turn := prev.turn
|
||
prev.mu.Unlock()
|
||
|
||
c.mu.Lock()
|
||
c.startedOnce = started
|
||
if c.turn < turn {
|
||
c.turn = turn
|
||
}
|
||
c.mu.Unlock()
|
||
}
|
||
|
||
// SessionAuthorizations snapshots this controller's same-session tool
|
||
// grants ("Allow for this session") and Plan-mode read-only command trust,
|
||
// for carrying into a replacement controller across a rebuild — see
|
||
// RestoreSessionAuthorizations.
|
||
func (c *Controller) SessionAuthorizations() SessionAuthorizations {
|
||
return c.approval.snapshotSessionAuthorizations()
|
||
}
|
||
|
||
// RestoreSessionAuthorizations re-applies session authorizations captured
|
||
// from a prior controller in the same session (see SessionAuthorizations). A
|
||
// model/effort/profile switch rebuilds the controller, and without this the
|
||
// replacement forgets every grant the user already made this session.
|
||
func (c *Controller) RestoreSessionAuthorizations(auth SessionAuthorizations) {
|
||
c.approval.restoreSessionAuthorizations(auth)
|
||
}
|
||
|
||
// ReleaseResources stops plugin subprocesses and releases resources without
|
||
// firing SessionEnd. Use it only when replacing the controller for the same
|
||
// logical session.
|
||
func (c *Controller) ReleaseResources() {
|
||
c.close(false, closeJobsWithGrace)
|
||
}
|
||
|
||
// Close stops plugin subprocesses and releases resources. A session that ever
|
||
// started fires SessionEnd so a teardown hook runs.
|
||
func (c *Controller) Close() {
|
||
c.close(true, closeJobsWithGrace)
|
||
}
|
||
|
||
// CloseAfterDestroy releases controller resources after the caller has already
|
||
// begun session-specific job teardown. It avoids a second synchronous job grace
|
||
// wait while still cancelling the manager root and reaping temporary artifacts
|
||
// once every job goroutine finally exits.
|
||
func (c *Controller) CloseAfterDestroy() {
|
||
c.close(true, closeJobsAsync)
|
||
}
|
||
|
||
type closeJobsMode int
|
||
|
||
const (
|
||
closeJobsWithGrace closeJobsMode = iota
|
||
closeJobsAsync
|
||
)
|
||
|
||
func (c *Controller) close(fireSessionEnd bool, jobsMode closeJobsMode) {
|
||
// Desktop tab lifecycles can race a rebind/model-switch/close on the same
|
||
// controller; make teardown idempotent so a duplicate Close cannot re-fire
|
||
// SessionEnd hooks or re-run cleanup. The first caller's jobsMode wins.
|
||
c.closeOnce.Do(func() {
|
||
c.mu.Lock()
|
||
started := c.startedOnce
|
||
cancel := c.cancel
|
||
// Seal turn admission and drop anything already parked: a parked turn
|
||
// must not start against a controller that is being torn down, and
|
||
// without the closed flag a submit landing after this critical
|
||
// section (while a running turn's TurnDone delivery is still in
|
||
// flight) would park again and start after teardown.
|
||
c.closed = true
|
||
c.parkedTurns = nil
|
||
// A finishing-only controller no longer needs the delivery gate because
|
||
// closed seals every admission path. Keep running truthful until the
|
||
// foreground goroutine actually exits; clearing it here would report idle
|
||
// while tools and prompt waiters were still live.
|
||
c.finishing = false
|
||
if cancel != nil {
|
||
c.canceling = true
|
||
}
|
||
c.mu.Unlock()
|
||
if cancel != nil {
|
||
// clearAll deliberately does not signal waiters. Pair it with the
|
||
// foreground cancellation so approval/ask waits always unblock.
|
||
c.approval.clearAll()
|
||
cancel()
|
||
}
|
||
if fireSessionEnd && started {
|
||
c.hooks.SessionEnd(context.Background(), "other")
|
||
c.extensionSessionEvent(extension.PointSessionEnd, dispatch.PhaseEnd, c.SessionPath())
|
||
}
|
||
if c.jobs != nil {
|
||
switch jobsMode {
|
||
case closeJobsAsync:
|
||
c.jobs.CloseAsync()
|
||
default:
|
||
c.jobs.Close() // cancel any still-running background jobs
|
||
}
|
||
}
|
||
if c.cleanup != nil {
|
||
c.cleanup()
|
||
}
|
||
// Drop the Controller owner reference last so background job leases
|
||
// that outlive close still pin retired generations until they exit.
|
||
if c.sessionTemp != nil {
|
||
c.sessionTemp.Release()
|
||
}
|
||
})
|
||
}
|
||
|
||
// SessionTemp returns the logical-session private temporary directory manager.
|
||
// Hot rebuilds pass this to the replacement Controller so the directory survives
|
||
// model/settings swaps. Nil only when the Controller was constructed without one
|
||
// (should not happen after New).
|
||
func (c *Controller) SessionTemp() *sessiontemp.Manager {
|
||
if c == nil {
|
||
return nil
|
||
}
|
||
return c.sessionTemp
|
||
}
|
||
|
||
// rotateSessionTemp advances the private temporary generation so a new logical
|
||
// session cannot see the previous session's temporary files. In-flight command
|
||
// leases keep the old generation alive until they release.
|
||
func (c *Controller) rotateSessionTemp() {
|
||
if c == nil || c.sessionTemp == nil {
|
||
return
|
||
}
|
||
c.sessionTemp.Rotate()
|
||
}
|
||
|
||
// Jobs returns the still-running background jobs for the status bar (nil when
|
||
// background jobs are disabled).
|
||
func (c *Controller) Jobs() []jobs.View {
|
||
if c.jobs == nil {
|
||
return nil
|
||
}
|
||
return c.jobs.RunningForSession(c.parentSessionID())
|
||
}
|
||
|
||
// KillJob cancels a running background job by ID.
|
||
func (c *Controller) KillJob(id string) bool {
|
||
if c.jobs == nil {
|
||
return false
|
||
}
|
||
return c.jobs.Kill(id)
|
||
}
|
||
|
||
// CancelJob stops one background job owned by this controller's session.
|
||
func (c *Controller) CancelJob(id string) bool {
|
||
if c.jobs == nil {
|
||
return false
|
||
}
|
||
return c.jobs.KillForSession(c.parentSessionID(), id)
|
||
}
|
||
|
||
// WorkspaceLeaseState reports only whether this controller owns or is waiting
|
||
// for the Delivery workspace writer lease. It never exposes filesystem or
|
||
// process identity.
|
||
func (c *Controller) WorkspaceLeaseState() workspacelease.State {
|
||
return c.workspaceLease.State()
|
||
}
|
||
|
||
// SetToolApprovalMode changes the runtime approval posture for permission-gated
|
||
// tools. It does not answer business asks or plan approval. Sub-agents (task,
|
||
// writer-capable skill sub-agents, the planner) have no UI to prompt through,
|
||
// so this also pushes the mode to the shared headless gate they read from —
|
||
// without it, a mode switch (Shift+Tab) would only rebuild the parent
|
||
// executor's gate and leave sub-agents pinned to whatever mode was active
|
||
// when the session booted.
|
||
func (c *Controller) SetToolApprovalMode(mode string) {
|
||
c.ApplyToolApprovalMode(mode)
|
||
}
|
||
|
||
// ApplyToolApprovalMode is SetToolApprovalMode reporting which pending
|
||
// approval prompt ids the new posture auto-allowed. Prompts NOT in the
|
||
// returned set are still pending here — fresh user decisions (plan, memory,
|
||
// sandbox escape) never drain, and auto keeps approvals an allow policy would
|
||
// not cover — so a frontend must keep showing them instead of assuming the
|
||
// posture switch resolved everything (#6432).
|
||
func (c *Controller) ApplyToolApprovalMode(mode string) []string {
|
||
mode = normalizeToolApprovalMode(mode)
|
||
// Capture mode-change recovery dismissals before approval drain so a
|
||
// same-value hydrate/reconcile never rotates Episode state, while a real
|
||
// Auto↔Yolo/Ask switch clears temporary failure/reviewer locks and waiters
|
||
// without auto-approving the original mutation.
|
||
var recoveryDismissed []string
|
||
c.mu.Lock()
|
||
gate := c.recoveryGate
|
||
c.mu.Unlock()
|
||
if gate != nil {
|
||
if ctrl, ok := any(gate).(agent.RecoveryEpisodeControl); ok {
|
||
// Do not hold controller/approval locks while rotating the gate.
|
||
recoveryDismissed = ctrl.OnModeChange(mode)
|
||
}
|
||
}
|
||
pending := c.approval.setMode(mode)
|
||
if c.subagentGate != nil {
|
||
c.subagentGate.Update(mode)
|
||
}
|
||
c.refreshInteractiveGate()
|
||
// Clear recovery cards dismissed by the mode switch outside the gate lock.
|
||
for _, id := range recoveryDismissed {
|
||
p := c.approval.resolve(id)
|
||
if p.reply != nil {
|
||
// Do not approve the pending mutation; signal cancel/deny so legacy
|
||
// paths drop the card.
|
||
select {
|
||
case p.reply <- approvalReply{allow: false}:
|
||
default:
|
||
}
|
||
}
|
||
}
|
||
drained := make([]string, 0, len(pending))
|
||
for _, p := range pending {
|
||
p.reply <- approvalReply{allow: true}
|
||
drained = append(drained, p.id)
|
||
}
|
||
return drained
|
||
}
|
||
|
||
func (c *Controller) ToolApprovalMode() string {
|
||
return c.approval.mode()
|
||
}
|
||
|
||
// SetAutoApproveTools turns YOLO tool auto-approval on or off for the session:
|
||
// while on, every tool approval request is auto-allowed (writers and bash run
|
||
// without asking). Ask requests and plan approval still reach the user. Deny
|
||
// rules still block. Runtime-only — never written to config.
|
||
func (c *Controller) SetAutoApproveTools(on bool) {
|
||
if on {
|
||
c.SetToolApprovalMode(ToolApprovalYolo)
|
||
return
|
||
}
|
||
c.SetToolApprovalMode(ToolApprovalAsk)
|
||
}
|
||
|
||
// SetBypass is the legacy name for SetAutoApproveTools. Keep it for existing
|
||
// desktop/serve bindings and CLI code that still uses the bypass wording.
|
||
func (c *Controller) SetBypass(on bool) {
|
||
c.SetAutoApproveTools(on)
|
||
}
|
||
|
||
// SetMode applies the Plan workflow flag and tool auto-approval together so a turn
|
||
// submitted right after a composer mode switch can't observe a half-applied
|
||
// gate. Turning tool auto-approval on drains any pending tool approval.
|
||
func (c *Controller) SetMode(plan, autoApproveTools bool) {
|
||
c.ApplyMode(plan, autoApproveTools)
|
||
}
|
||
|
||
// ApplyMode is SetMode reporting which pending approval prompt ids the tool
|
||
// approval switch auto-allowed (see ApplyToolApprovalMode).
|
||
func (c *Controller) ApplyMode(plan, autoApproveTools bool) []string {
|
||
c.applyPlanMode(plan)
|
||
if autoApproveTools {
|
||
return c.ApplyToolApprovalMode(ToolApprovalYolo)
|
||
}
|
||
return c.ApplyToolApprovalMode(ToolApprovalAsk)
|
||
}
|
||
|
||
// AutoApproveTools reports whether YOLO tool auto-approval is on,
|
||
// for status indicators and mode persistence.
|
||
func (c *Controller) AutoApproveTools() bool {
|
||
return c.ToolApprovalMode() == ToolApprovalYolo
|
||
}
|
||
|
||
// Bypass is the legacy name for AutoApproveTools.
|
||
func (c *Controller) Bypass() bool {
|
||
return c.AutoApproveTools()
|
||
}
|
||
|
||
// memory
|
||
//
|
||
// The memory snapshot, the pending turn-tail notes queue, and write serialization
|
||
// live in c.memory (a memoryManager) behind its own locks, off c.mu — so a
|
||
// memory-panel save never stalls an approval or status poll. These methods are
|
||
// the SessionAPI surface; each is a thin delegation. See memory.go.
|
||
|
||
// QuickAdd appends a one-line note to the doc-memory file for scope (project
|
||
// REASONIX.md by default) — the write side of "#<note>". Returns the file written.
|
||
func (c *Controller) QuickAdd(scope memory.Scope, note string) (string, error) {
|
||
return c.memory.quickAdd(scope, note)
|
||
}
|
||
|
||
// SaveDoc overwrites a recognized memory doc with body — the save side of the
|
||
// desktop panel's in-place editor. Returns the file written.
|
||
func (c *Controller) SaveDoc(path, body string) (string, error) {
|
||
return c.memory.saveDoc(path, body)
|
||
}
|
||
|
||
// SaveMemory writes an active auto-memory fact and refreshes the in-session
|
||
// snapshot. It is the explicit user-confirmed counterpart to the model-owned
|
||
// remember tool, used by management surfaces that preview a candidate first.
|
||
func (c *Controller) SaveMemory(m memory.Memory) (string, error) {
|
||
return c.memory.saveMemory(m)
|
||
}
|
||
|
||
// ForgetMemory removes a saved auto-memory by name — the panel/TUI forget action,
|
||
// the manual counterpart to the model's `forget` tool.
|
||
func (c *Controller) ForgetMemory(name string) error {
|
||
return c.memory.forget(name)
|
||
}
|
||
|
||
// QueueMemory implements memory.Queue: when the model runs the remember/forget
|
||
// tool, the tool calls this with a note that rides the next turn so the change
|
||
// applies this session without touching the cache-stable prefix. It also
|
||
// refreshes the snapshot a memory panel reads.
|
||
func (c *Controller) QueueMemory(note string) {
|
||
c.memory.queue(note)
|
||
}
|
||
|
||
// ClaimAutoMemoryWrite consumes the one-shot create-only authorization issued
|
||
// by gateApprover for a low-risk project fact.
|
||
func (c *Controller) ClaimAutoMemoryWrite(args json.RawMessage) bool {
|
||
return c.memory.claimAutoRemember(args)
|
||
}
|
||
|
||
func (c *Controller) MemoryRevisions(ref string) []memory.Memory {
|
||
return c.memory.revisions(ref)
|
||
}
|
||
|
||
// RestoreMemory restores an older active-memory revision as a new audited
|
||
// revision and applies it to the next user turn.
|
||
func (c *Controller) RestoreMemory(ref string, revision int) (memory.Memory, error) {
|
||
return c.memory.restore(ref, revision)
|
||
}
|
||
|
||
// RestoreArchivedMemory recovers an archived fact as a new audited revision and
|
||
// applies it to the next user turn.
|
||
func (c *Controller) RestoreArchivedMemory(archivePath string) (memory.Memory, error) {
|
||
return c.memory.restoreArchived(archivePath)
|
||
}
|
||
|
||
// Memory returns the loaded memory snapshot (nil when memory is disabled), for
|
||
// frontends that surface a memory panel or the /memory command. The returned
|
||
// *Set is immutable — mutations go through QuickAdd / SaveDoc.
|
||
func (c *Controller) Memory() *memory.Set {
|
||
return c.memory.current()
|
||
}
|
||
|
||
// approval bridge (agent gate → events)
|
||
|
||
// gateApprover adapts the Controller to permission.Approver. It is distinct
|
||
// from the public Approve command (different signature, different direction).
|
||
type gateApprover struct{ c *Controller }
|
||
|
||
const dynamicBashApprovalReason = "This command uses nested or indirect shell execution. Auto and broad allow rules cannot verify the inner command; approve this exact command or use YOLO."
|
||
|
||
func (g gateApprover) Approve(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, error) {
|
||
allow, remember, _, err := g.ApproveWithReason(ctx, tool, subject, args)
|
||
return allow, remember, err
|
||
}
|
||
|
||
func (g gateApprover) ApproveWithReason(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, string, error) {
|
||
return g.approveWithPolicyReason(ctx, tool, subject, args, "")
|
||
}
|
||
|
||
func (g gateApprover) ApproveWithPolicyReason(ctx context.Context, tool, subject string, args json.RawMessage, policyReason string) (bool, bool, string, error) {
|
||
return g.approveWithPolicyReason(ctx, tool, subject, args, policyReason)
|
||
}
|
||
|
||
func combineApprovalReasons(reasons ...string) string {
|
||
var kept []string
|
||
for _, reason := range reasons {
|
||
if reason = strings.TrimSpace(reason); reason != "" {
|
||
kept = append(kept, reason)
|
||
}
|
||
}
|
||
return strings.Join(kept, "\n")
|
||
}
|
||
|
||
func (g gateApprover) approveWithPolicyReason(ctx context.Context, tool, subject string, args json.RawMessage, policyReason string) (bool, bool, string, error) {
|
||
if tool == memoryRememberTool && g.c.allowLowRiskRemember(args) {
|
||
return true, false, "", nil
|
||
}
|
||
subject = approvalDisplaySubject(tool, subject, args)
|
||
requireHuman := strings.EqualFold(tool, "bash") && permission.BashSubjectRequiresExplicitApproval(subject)
|
||
// Check pre-approval first, before any prompt or Guardian review. Dynamic
|
||
// Bash accepts only YOLO or an exact session grant here; ordinary calls also
|
||
// accept the just-approved-plan window. Deny rules already bit at the policy
|
||
// level before this point.
|
||
if requireHuman && g.c.approval.preApprovedForRequiredHuman(tool, subject) {
|
||
return true, false, "", nil
|
||
}
|
||
if !requireHuman && g.c.approval.preApproved(tool, subject, args) {
|
||
return true, false, "", nil
|
||
}
|
||
if g.c.guardianSess != nil && !requireHuman {
|
||
allow, reason, reviewErr := g.c.guardianSess.Review(ctx, tool, args, g.c.executor.Session())
|
||
if reviewErr != nil {
|
||
return false, false, "", reviewErr
|
||
}
|
||
if allow && !requiresFreshApprovalTool(tool) {
|
||
return true, false, "", nil
|
||
}
|
||
reason = combineApprovalReasons(policyReason, reason)
|
||
humanAllow, remember, err := g.c.requestApprovalWithReason(ctx, tool, subject, args, reason)
|
||
if err != nil {
|
||
return false, false, reason, err
|
||
}
|
||
if !humanAllow {
|
||
return false, false, reason, nil
|
||
}
|
||
return true, remember, "", nil
|
||
}
|
||
if requireHuman {
|
||
reason := combineApprovalReasons(policyReason, dynamicBashApprovalReason)
|
||
allow, remember, err := g.c.requestApprovalWithReasonOptions(ctx, tool, subject, args, reason, approvalDecisionOptions{requireHuman: true})
|
||
return allow, remember, "", err
|
||
}
|
||
allow, remember, err := g.c.requestApprovalWithReason(ctx, tool, subject, args, policyReason)
|
||
return allow, remember, "", err
|
||
}
|
||
|
||
type planModeReadOnlyTrustApprover struct{ c *Controller }
|
||
|
||
type sandboxEscapeApprover struct{ c *Controller }
|
||
|
||
func (s sandboxEscapeApprover) ApproveSandboxEscape(ctx context.Context, req sandbox.EscapeRequest) (bool, string, error) {
|
||
subject := sandboxEscapeApprovalSubject(req.Command)
|
||
reason := sandboxEscapeApprovalReason(req.Reason)
|
||
reply, err := s.c.requestFreshApprovalDecision(ctx, SandboxEscapeApprovalTool, subject, req.Args, reason)
|
||
if err != nil {
|
||
return false, "approval aborted", err
|
||
}
|
||
if !reply.allow {
|
||
return false, i18n.M.SandboxEscapeDeclined, nil
|
||
}
|
||
if reply.session {
|
||
s.c.approval.grantSession(SandboxEscapeApprovalTool, subject)
|
||
}
|
||
return true, "", nil
|
||
}
|
||
|
||
func (s sandboxEscapeApprover) SandboxEscapeSessionAllowed(_ context.Context, req sandbox.EscapeRequest) bool {
|
||
return s.c.approval.preApprovedForDecision(SandboxEscapeApprovalTool, sandboxEscapeApprovalSubject(req.Command), nil, true)
|
||
}
|
||
|
||
func sandboxEscapeApprovalSubject(command string) string {
|
||
subject := strings.TrimSpace(command)
|
||
if subject == "" {
|
||
return i18n.M.SandboxEscapeSubjectFallback
|
||
}
|
||
return i18n.M.SandboxEscapeSubjectPrefix + subject
|
||
}
|
||
|
||
func sandboxEscapeApprovalReason(reason string) string {
|
||
reason = strings.TrimSpace(reason)
|
||
if reason == "" {
|
||
return i18n.M.SandboxEscapeRuntimeReason
|
||
}
|
||
return reason
|
||
}
|
||
|
||
// managedConfigWriteApprover routes a file tool's Reasonix-managed config write
|
||
// through the fresh-human approval prompt (see ManagedConfigWriteApprovalTool).
|
||
// A session grant is tool-wide (mirroring sandbox_escape): one "allow for this
|
||
// session" covers the rest of the repair flow across the handful of managed
|
||
// config files without re-prompting on every incremental edit.
|
||
type managedConfigWriteApprover struct{ c *Controller }
|
||
|
||
func (m managedConfigWriteApprover) ApproveManagedConfigWrite(ctx context.Context, req tool.ConfigWriteRequest) (bool, string, error) {
|
||
subject := managedConfigWriteApprovalSubject(req.Path)
|
||
args, _ := json.Marshal(map[string]string{"path": req.Path})
|
||
reply, err := m.c.requestFreshApprovalDecision(ctx, ManagedConfigWriteApprovalTool, subject, args, i18n.M.ConfigWriteReason)
|
||
if err != nil {
|
||
return false, "approval aborted", err
|
||
}
|
||
if !reply.allow {
|
||
return false, i18n.M.ConfigWriteDeclined, nil
|
||
}
|
||
if reply.session {
|
||
m.c.approval.grantSession(ManagedConfigWriteApprovalTool, subject)
|
||
}
|
||
return true, "", nil
|
||
}
|
||
|
||
func (m managedConfigWriteApprover) ManagedConfigWriteSessionAllowed(_ context.Context, req tool.ConfigWriteRequest) bool {
|
||
return m.c.approval.preApprovedForDecision(ManagedConfigWriteApprovalTool, managedConfigWriteApprovalSubject(req.Path), nil, true)
|
||
}
|
||
|
||
func managedConfigWriteApprovalSubject(path string) string {
|
||
return i18n.M.ConfigWriteSubjectPrefix + strings.TrimSpace(path)
|
||
}
|
||
|
||
func (p planModeReadOnlyTrustApprover) CheckPlanModeReadOnlyTrust(ctx context.Context, req agent.PlanModeReadOnlyTrustRequest) (bool, string, error) {
|
||
prefix := normalizePlanModeReadOnlyCommandPrefix(req.Prefix)
|
||
if prefix == "" {
|
||
return false, "missing plan-mode read-only command prefix", nil
|
||
}
|
||
return p.checkBashReadOnlyCommandTrust(ctx, req, prefix)
|
||
}
|
||
|
||
func (p planModeReadOnlyTrustApprover) checkBashReadOnlyCommandTrust(ctx context.Context, req agent.PlanModeReadOnlyTrustRequest, prefix string) (bool, string, error) {
|
||
if p.c.approval.planModeReadOnlyCommandTrusted(prefix) {
|
||
return true, "", nil
|
||
}
|
||
command := strings.TrimSpace(req.Command)
|
||
if command == "" {
|
||
command = strings.TrimSpace(string(req.Args))
|
||
}
|
||
subject := fmt.Sprintf(i18n.M.PlanModeBashTrustSubjectFmt, prefix, command)
|
||
reason := i18n.M.PlanModeBashTrustReason
|
||
reply, err := p.c.requestFreshApprovalDecision(ctx, agent.PlanModeReadOnlyCommandApprovalTool, subject, req.Args, reason)
|
||
if err != nil {
|
||
return false, "approval aborted", err
|
||
}
|
||
if !reply.allow {
|
||
return false, i18n.M.PlanModeBashTrustDeclined, nil
|
||
}
|
||
if reply.session {
|
||
p.c.approval.grantPlanModeReadOnlyCommand(prefix)
|
||
}
|
||
if reply.persist && p.c.onRememberPlanModeReadOnlyCommand != nil {
|
||
p.c.emitPlanModeReadOnlyCommandTrustResult(p.c.onRememberPlanModeReadOnlyCommand(prefix))
|
||
p.c.approval.grantPlanModeReadOnlyCommand(prefix)
|
||
}
|
||
return true, "", nil
|
||
}
|
||
|
||
func approvalDisplaySubject(tool, subject string, args json.RawMessage) string {
|
||
switch tool {
|
||
case memoryRememberTool:
|
||
return rememberApprovalSubject(subject, args)
|
||
case memoryForgetTool:
|
||
return forgetApprovalSubject(subject, args)
|
||
case "move_file":
|
||
return moveApprovalSubject(subject, args)
|
||
default:
|
||
return subject
|
||
}
|
||
}
|
||
|
||
func moveApprovalSubject(fallback string, args json.RawMessage) string {
|
||
if len(args) == 0 {
|
||
return fallback
|
||
}
|
||
var in struct {
|
||
SourcePath string `json:"source_path"`
|
||
DestinationPath string `json:"destination_path"`
|
||
}
|
||
if err := json.Unmarshal(args, &in); err != nil {
|
||
return fallback
|
||
}
|
||
if in.SourcePath == "" || in.DestinationPath == "" {
|
||
return fallback
|
||
}
|
||
return in.SourcePath + " -> " + in.DestinationPath
|
||
}
|
||
|
||
func rememberApprovalSubject(fallback string, args json.RawMessage) string {
|
||
if len(args) == 0 {
|
||
return fallback
|
||
}
|
||
var in struct {
|
||
Name string `json:"name"`
|
||
Title string `json:"title"`
|
||
Description string `json:"description"`
|
||
Type string `json:"type"`
|
||
Body string `json:"body"`
|
||
}
|
||
if err := json.Unmarshal(args, &in); err != nil {
|
||
return fallback
|
||
}
|
||
name := approvalCompactText(firstNonEmpty(in.Name, in.Title))
|
||
desc := approvalTruncate(approvalCompactText(in.Description), 180)
|
||
body := approvalTruncate(approvalCompactText(in.Body), 240)
|
||
typ := string(memory.NormalizeType(in.Type))
|
||
|
||
var b strings.Builder
|
||
b.WriteString(i18n.M.MemoryApprovalSaveUpdate)
|
||
baseLen := b.Len()
|
||
if name != "" {
|
||
fmt.Fprintf(&b, " %q", name)
|
||
}
|
||
if typ != "" {
|
||
fmt.Fprintf(&b, " [%s]", typ)
|
||
}
|
||
if desc != "" {
|
||
b.WriteString(": ")
|
||
b.WriteString(desc)
|
||
}
|
||
if body != "" {
|
||
if desc == "" {
|
||
b.WriteString(": ")
|
||
} else {
|
||
b.WriteString(" | ")
|
||
}
|
||
b.WriteString(i18n.M.MemoryApprovalBodyLabel)
|
||
b.WriteString(": ")
|
||
b.WriteString(body)
|
||
}
|
||
if b.Len() == baseLen && fallback != "" {
|
||
return fallback
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func forgetApprovalSubject(fallback string, args json.RawMessage) string {
|
||
if len(args) == 0 {
|
||
return fallback
|
||
}
|
||
var in struct {
|
||
Name string `json:"name"`
|
||
}
|
||
if err := json.Unmarshal(args, &in); err != nil {
|
||
return fallback
|
||
}
|
||
name := approvalCompactText(in.Name)
|
||
if name == "" {
|
||
return fallback
|
||
}
|
||
return fmt.Sprintf(i18n.M.MemoryApprovalArchiveFmt, name)
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
if strings.TrimSpace(value) != "" {
|
||
return value
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func approvalCompactText(s string) string {
|
||
return strings.Join(strings.Fields(s), " ")
|
||
}
|
||
|
||
func approvalTruncate(s string, maxRunes int) string {
|
||
if maxRunes <= 0 {
|
||
return ""
|
||
}
|
||
runes := []rune(s)
|
||
if len(runes) <= maxRunes {
|
||
return s
|
||
}
|
||
return string(runes[:maxRunes]) + "..."
|
||
}
|
||
|
||
func (c *Controller) sessionMessageCount() int {
|
||
if c.executor == nil {
|
||
return 0
|
||
}
|
||
return c.executor.Session().Len()
|
||
}
|
||
|
||
// parseRewind parses the arguments after "/rewind". The user may provide:
|
||
//
|
||
// /rewind → latest checkpoint, both
|
||
// /rewind <turn> → that turn, both
|
||
// /rewind <turn> <scope> → that turn, code|conversation|both
|
||
//
|
||
// If no turn is given, the latest checkpoint is used. If no scope is given, Both is assumed.
|
||
func parseRewind(args string, cps []checkpoint.Meta) (int, RewindScope, error) {
|
||
fields := strings.Fields(args)
|
||
if len(fields) == 0 {
|
||
if len(cps) == 0 {
|
||
return 0, RewindBoth, fmt.Errorf("no checkpoints available")
|
||
}
|
||
return cps[len(cps)-1].Turn, RewindBoth, nil
|
||
}
|
||
turn, err := strconv.Atoi(fields[0])
|
||
if err != nil {
|
||
return 0, RewindBoth, fmt.Errorf("invalid turn: %w", err)
|
||
}
|
||
scope := RewindBoth
|
||
if len(fields) >= 2 {
|
||
switch strings.ToLower(fields[1]) {
|
||
case "code":
|
||
scope = RewindCode
|
||
case "conversation":
|
||
scope = RewindConversation
|
||
case "both":
|
||
scope = RewindBoth
|
||
default:
|
||
return 0, RewindBoth, fmt.Errorf("unknown scope %q", fields[1])
|
||
}
|
||
}
|
||
return turn, scope, nil
|
||
}
|
||
|
||
// requestApproval emits an ApprovalRequest and blocks until Approve(ID, …)
|
||
// answers or ctx is cancelled. A prior session grant (or a bypass posture) for
|
||
// the same approval scope short-circuits. The approvalManager's promptMu
|
||
// serialises outstanding prompts; this method keeps the I/O (events, hooks,
|
||
// remember) that the manager deliberately stays out of.
|
||
func (c *Controller) requestApproval(ctx context.Context, tool, subject string, args json.RawMessage) (bool, bool, error) {
|
||
return c.requestApprovalWithReason(ctx, tool, subject, args, "")
|
||
}
|
||
|
||
func (c *Controller) requestApprovalWithReason(ctx context.Context, tool, subject string, args json.RawMessage, reason string) (bool, bool, error) {
|
||
return c.requestApprovalWithReasonOptions(ctx, tool, subject, args, reason, approvalDecisionOptions{})
|
||
}
|
||
|
||
func (c *Controller) requestApprovalWithReasonOptions(ctx context.Context, tool, subject string, args json.RawMessage, reason string, opts approvalDecisionOptions) (bool, bool, error) {
|
||
r, err := c.requestApprovalDecisionWithOptions(ctx, tool, subject, args, reason, opts)
|
||
if err != nil {
|
||
return false, false, err
|
||
}
|
||
// Plan approvals are one-shot — never persist a session grant for them, or
|
||
// every future plan would auto-approve.
|
||
if r.allow && r.session && !requiresFreshApprovalTool(tool) {
|
||
c.approval.grantSession(tool, subject)
|
||
}
|
||
if r.allow && r.persist && !requiresFreshApprovalTool(tool) && c.onRemember != nil {
|
||
c.emitRememberResult(c.onRemember(permission.RememberRuleForScope(tool, subject)))
|
||
}
|
||
return r.allow, false, nil
|
||
}
|
||
|
||
func (c *Controller) requestFreshApprovalDecision(ctx context.Context, tool, subject string, args json.RawMessage, reason string) (approvalReply, error) {
|
||
return c.requestApprovalDecisionWithOptions(ctx, tool, subject, args, reason, approvalDecisionOptions{fresh: true})
|
||
}
|
||
|
||
type approvalDecisionOptions struct {
|
||
// fresh marks a user trust/business decision rather than an ordinary tool
|
||
// permission. It may reuse an explicit session grant, but YOLO/auto approval
|
||
// must not answer or drain the prompt.
|
||
fresh bool
|
||
// requireHuman marks an ordinary tool approval that Auto, an approved-plan
|
||
// window, Guardian, or an allowing hook must not answer. Unlike fresh it
|
||
// retains the ordinary four-choice UI and YOLO remains an explicit bypass.
|
||
requireHuman bool
|
||
}
|
||
|
||
func (c *Controller) requestApprovalDecisionWithOptions(ctx context.Context, tool, subject string, args json.RawMessage, reason string, opts approvalDecisionOptions) (approvalReply, error) {
|
||
// YOLO/full access and the just-approved-plan execution window auto-allow
|
||
// approval-gated tools without prompting. Plan approval is a user decision,
|
||
// not a tool permission, so it deliberately stays interactive.
|
||
if c.approval.preApprovedForDecisionOptions(tool, subject, args, opts.fresh, opts.requireHuman) {
|
||
return approvalReply{allow: true}, nil
|
||
}
|
||
|
||
c.approval.promptMu.Lock()
|
||
defer c.approval.promptMu.Unlock()
|
||
|
||
// Re-check: a session grant may have landed while we queued behind another
|
||
// prompt for the same subject.
|
||
if c.approval.preApprovedForDecisionOptions(tool, subject, args, opts.fresh, opts.requireHuman) {
|
||
return approvalReply{allow: true}, nil
|
||
}
|
||
|
||
// Claude's PermissionRequest contract answers the dialog on the plugin's
|
||
// behalf (auto-allow/auto-deny) instead of merely observing it, so a
|
||
// decision here must preempt the prompt rather than just notify — this
|
||
// runs synchronously and before the dialog is shown. Native Reasonix
|
||
// PermissionRequest hooks stay advisory-only (see claudePermissionBlocking).
|
||
//
|
||
// A hook's auto-allow must never stand in for a human-required decision:
|
||
// sandbox escapes, Reasonix config writes, memory remember/forget, and
|
||
// plan approval (RequiresFreshHumanApprovalTool) are deliberately excluded
|
||
// from YOLO/auto-approval and Guardian too, so a broadly-matched plugin
|
||
// hook returning "allow" can't silently rubber-stamp them. A deny still
|
||
// applies universally — refusing is always safe to honor automatically.
|
||
if hookSubject, hookArgs, ok := permissionRequestHookPayload(tool, subject, args); ok {
|
||
if decision, _ := c.hooks.PermissionRequest(ctx, tool, hookSubject, hookArgs); decision != nil {
|
||
switch {
|
||
case !*decision:
|
||
return approvalReply{}, nil
|
||
case !opts.fresh && !opts.requireHuman && !requiresFreshApprovalTool(tool):
|
||
return approvalReply{allow: true}, nil
|
||
}
|
||
// An "allow" opinion on a fresh-human-required decision is
|
||
// ignored; fall through to the normal interactive prompt.
|
||
}
|
||
}
|
||
|
||
c.approval.promptEmitMu.Lock()
|
||
var id string
|
||
var reply chan approvalReply
|
||
if opts.fresh || opts.requireHuman || tool == planApprovalTool {
|
||
kind := ""
|
||
if tool == planApprovalTool {
|
||
kind = "plan"
|
||
}
|
||
id, reply = c.approval.registerDecisionKindWithInput(tool, subject, reason, args, opts.fresh, opts.requireHuman, kind, nil)
|
||
} else {
|
||
id, reply = c.approval.registerWithInput(tool, subject, reason, args)
|
||
}
|
||
|
||
c.sink.Emit(c.approvalRequestEvent(event.Approval{ID: id, Tool: tool, Subject: subject, Reason: reason, RawInput: append(json.RawMessage(nil), args...), Fresh: opts.fresh}))
|
||
c.approval.promptEmitMu.Unlock()
|
||
// The agent now needs the user's attention; a Notification hook can ping an
|
||
// external channel (desktop notice, phone) while the run blocks on the reply.
|
||
go c.hooks.Notification(ctx, approvalNotificationText(tool, subject), "permission_prompt")
|
||
|
||
waitCtx, cancelWait := c.approval.waitContext(ctx)
|
||
defer cancelWait()
|
||
|
||
select {
|
||
case r := <-reply:
|
||
return r, nil
|
||
case <-waitCtx.Done():
|
||
c.approval.cancel(id)
|
||
return approvalReply{}, waitCtx.Err()
|
||
}
|
||
}
|
||
|
||
func (c *Controller) approvalRequestEvent(approval event.Approval) event.Event {
|
||
return event.Event{Kind: event.ApprovalRequest, Approval: approval}
|
||
}
|
||
|
||
func (c *Controller) emitRememberResult(r RememberResult) {
|
||
if r.Err != nil {
|
||
c.sink.Emit(event.Event{
|
||
Kind: event.Notice,
|
||
Level: event.LevelWarn,
|
||
Text: fmt.Sprintf(i18n.M.PermissionSaveFailedFmt, r.Rule, r.Err),
|
||
})
|
||
return
|
||
}
|
||
switch {
|
||
case r.Saved:
|
||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PermissionSavedFmt, r.Path, r.Rule)})
|
||
case strings.TrimSpace(r.CoveredBy) != "":
|
||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PermissionAlreadyAllowedFmt, r.Path, r.CoveredBy)})
|
||
}
|
||
}
|
||
|
||
func (c *Controller) emitPlanModeReadOnlyCommandTrustResult(r PlanModeReadOnlyCommandTrustResult) {
|
||
prefix := strings.TrimSpace(r.Prefix)
|
||
if r.Err != nil {
|
||
c.sink.Emit(event.Event{
|
||
Kind: event.Notice,
|
||
Level: event.LevelWarn,
|
||
Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustFailedFmt, prefix, r.Err),
|
||
})
|
||
return
|
||
}
|
||
switch {
|
||
case r.Saved:
|
||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustSavedFmt, r.Path, prefix)})
|
||
case strings.TrimSpace(r.CoveredBy) != "":
|
||
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: fmt.Sprintf(i18n.M.PlanModeReadOnlyCommandTrustAlreadyFmt, r.Path, r.CoveredBy)})
|
||
}
|
||
}
|