项目文件夹

文件
2026-07-13 13:00:08 +08:00

5261 行
182 KiB
Go

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