5261 lines
182 KiB
Go
5261 lines
182 KiB
Go
// Package control is the transport-agnostic session driver. A Controller owns
|
||
// the agent run loop and session lifecycle, takes commands (Send/Cancel/Approve/
|
||
// SetPlanMode/Compact/NewSession/…), and emits everything that happens —
|
||
// reasoning, tool calls, approvals, turn completion — as a typed event stream to
|
||
// a single event.Sink.
|
||
//
|
||
// The point is one orchestration layer behind every frontend: a terminal TUI, a
|
||
// desktop webview, or an HTTP/SSE server each drive the Controller identically
|
||
// (issue commands, render events) and none of them re-implement turn lifecycle,
|
||
// cancellation, or approval. The Controller depends on no frontend.
|
||
package control
|
||
|
||
import (
|
||
"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
|
||
}
|