Files
DeepSeek-Reasonix/internal/control/controller.go
T
SivanColaandreasonix b87a878cad feat(desktop): project topic tabs and context inspector (#2925)
* feat(desktop): add project tabs and context inspector

* feat(desktop): refine project workspace UI

* fix 打开一致加载

* 顶部统一logo+左侧边栏+统一菜单

* 底部最低宽度

* update much

* update code

* update code

* feat(desktop): refine workspace layout and message actions

* fix(desktop): scope file refs to active tab root

* test(desktop): stabilize capabilities config dirs

* fix(desktop): protect sessions open in other tabs

* fix(desktop): resume scoped history in matching tab

* chore(desktop): drop committed scratch artifacts; ignore tmp/ and .codex/

The project-tabs branch carried 30 throwaway screenshots, a mock HTML,
and a codex env file into the tree because tmp/ and .codex/ were never
ignored. Remove them and add the ignore rules so scratch output stays
out of future commits.

* test(desktop): isolate AppData per test so the suite passes on Windows

The new project/topic/workspace tests only overrode HOME and
XDG_CONFIG_HOME, but desktopConfigDir() and config.MemoryUserDir()
resolve through os.UserConfigDir(), which reads %AppData% on Windows and
ignores those vars. With AppData pointed at one shared TestMain dir, the
project/topic/workspace registries leaked across tests: full-package
runs failed (project tree len 2 want 1, trash mixing other tests'
sessions, Plugins[0] reading a neighbor's MCP entry) while each test
passed alone.

Route every test through isolateDesktopUserDirs, which already sets
HOME, USERPROFILE, XDG_CONFIG_HOME, and AppData to a per-test temp dir.
go test ./... (and -race) now pass on Windows.

---------

Co-authored-by: reasonix <[email protected]>
2026-06-04 19:41:16 -07:00

1882 lines
62 KiB
Go

// Package control is the transport-agnostic session driver. A Controller owns
// the agent run loop and session lifecycle, takes commands (Send/Cancel/Approve/
// SetPlanMode/Compact/NewSession/…), and emits everything that happens —
// reasoning, tool calls, approvals, turn completion — as a typed event stream to
// a single event.Sink.
//
// The point is one orchestration layer behind every frontend: a terminal TUI, a
// desktop webview, or an HTTP/SSE server each drive the Controller identically
// (issue commands, render events) and none of them re-implement turn lifecycle,
// cancellation, or approval. The Controller depends on no frontend.
package control
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"reasonix/internal/agent"
"reasonix/internal/billing"
"reasonix/internal/checkpoint"
"reasonix/internal/codegraph"
"reasonix/internal/command"
"reasonix/internal/config"
"reasonix/internal/diff"
"reasonix/internal/event"
"reasonix/internal/hook"
"reasonix/internal/jobs"
"reasonix/internal/memory"
"reasonix/internal/nilutil"
"reasonix/internal/permission"
"reasonix/internal/plugin"
"reasonix/internal/provider"
"reasonix/internal/skill"
"reasonix/internal/tool"
)
// 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
sink event.Sink
policy permission.Policy
label string
systemPrompt string
sessionDir string
host *plugin.Host
commands []command.Command
skills []skill.Skill
allSkills []skill.Skill
hooks *hook.Runner // session hook runner; nil-safe (no hooks configured)
mem *memory.Set
cleanup func()
autoPlan string
classifier autoPlanClassifier
startedOnce bool // guards the one-shot SessionStart hook on first turn
onRemember func(rule string) // set via Options; invoked when user picks "always allow"
// 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
// reg is the live tool registry the executor reads each turn; pluginCtx is the
// session-scoped context a hot-added stdio server binds its subprocess to.
// Together they let AddMCPServer connect a server mid-session and have its tools
// available on the next turn (see AddMCPServer / RemoveMCPServer).
reg *tool.Registry
pluginCtx context.Context
// Checkpoints (snapshot-based rewind). cp is the per-session store rebound when
// the session path changes; cpRoot is the workspace root used to guard restore
// writes. cpTurn is the monotonic turn counter (decoupled from the store so it
// never collides after a restructure); cpBound[turn] records len(Session.Messages)
// at that turn's start — the truncation boundary for a conversation rewind/fork.
// Boundaries are persisted in each checkpoint and rebuilt from the store on
// resume (so a reopened session can still rewind conversation / fork), but
// dropped after a summarize restructures the log so those operations report
// "unavailable" rather than mis-truncating; code rewind (file-based) is unaffected.
cp *checkpoint.Store
cpRoot string
cpTurn int
cpBound map[int]int
// promptMu serialises approval prompts so at most one is outstanding at a
// time (parallel read-only tool calls don't normally gate, writers run
// serially — but this keeps the contract explicit). Held across the blocking
// wait, so it must never be taken by the Approve command path.
promptMu sync.Mutex
// mu guards the run state and approval bookkeeping; every critical section
// under it is short and non-blocking.
mu sync.Mutex
cancel context.CancelFunc
running bool
planMode bool
sessionPath string
approvals map[string]chan approvalReply
asks map[string]chan []event.AskAnswer
granted map[string]bool
nextID int
// turn counts model turns this session, passed to hooks in their payload.
turn int
// autoApprove auto-allows writer tool calls without prompting. Set only while
// executing a just-approved plan: approving the plan is the go-ahead, so the
// model shouldn't re-prompt for every write of the work it just got cleared to
// do. Deny rules still bite (those never reach the approver). Reset when the
// execution turn returns.
autoApprove bool
// bypass is "YOLO" mode: while set, every approval prompt is auto-allowed for
// the rest of the session (writers and bash run without asking). It is a
// deliberate, session-scoped opt-in (the --dangerously-skip-permissions flag or
// a runtime toggle), never persisted. Deny rules are unaffected — they're
// resolved before the approver, so a denied tool is still blocked in YOLO mode.
bypass bool
// pendingMemory holds memory notes added mid-session (via "#" quick-add or a
// memory edit) that haven't yet been folded into a turn. Compose drains it
// onto the next outgoing turn — never into the cache-stable system prefix — so
// a fresh memory takes effect this session without busting the prompt cache;
// it joins the prefix naturally on the next session.
pendingMemory []string
}
type approvalReply struct {
allow bool
session bool
persist bool // true = write "always allow" rule to config
}
// 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
Sink event.Sink
Policy permission.Policy
Label string
SystemPrompt string
SessionDir string
SessionPath string
Host *plugin.Host
Commands []command.Command
Skills []skill.Skill
AllSkills []skill.Skill
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
AutoPlan string
Classifier autoPlanClassifier
// OnRemember, when set, is invoked with a new allow rule the user chose to
// persist to disk (e.g. "bash(go build*)"). The callback is wired into the
// permission Gate on EnableInteractiveApproval.
OnRemember func(rule string)
}
// 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()
}
c := &Controller{
runner: opts.Runner,
executor: opts.Executor,
sink: sink,
policy: opts.Policy,
label: opts.Label,
systemPrompt: opts.SystemPrompt,
sessionDir: opts.SessionDir,
sessionPath: opts.SessionPath,
host: opts.Host,
commands: opts.Commands,
skills: opts.Skills,
allSkills: opts.AllSkills,
hooks: opts.Hooks,
mem: opts.Memory,
cleanup: opts.Cleanup,
autoPlan: normalizeAutoPlan(opts.AutoPlan),
classifier: classifier,
onRemember: opts.OnRemember,
balanceURL: opts.BalanceURL,
balanceKey: opts.BalanceKey,
balanceClient: opts.BalanceClient,
jobs: opts.Jobs,
reg: opts.Registry,
pluginCtx: pluginCtx,
cpRoot: opts.WorkspaceRoot,
approvals: map[string]chan approvalReply{},
asks: map[string]chan []event.AskAnswer{},
granted: map[string]bool{},
}
// Checkpoints: bind a store to the session and route writer pre-edits into it.
c.rebindCheckpoints(opts.SessionPath)
if c.executor != nil {
c.executor.SetPreEditHook(func(ch diff.Change) {
if c.cp != nil {
c.cp.Snapshot(ch)
}
})
c.executor.SetMemoryQueue(c)
}
return c
}
// 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 {
if sessionPath == "" {
return ""
}
return strings.TrimSuffix(sessionPath, ".jsonl") + ".ckpt"
}
// 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.mu.Lock()
defer c.mu.Unlock()
c.cp = checkpoint.New(ckptDir(sessionPath), c.cpRoot)
c.cpTurn = c.cp.NextTurn() // continue numbering past any checkpoints on disk
c.cpBound = c.cp.Bounds() // rebuilt from persisted checkpoints so a resumed
if c.cpBound == nil { // session can still rewind conversation / fork
c.cpBound = map[int]int{}
}
}
// 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.cp == nil || c.executor == nil {
return
}
c.mu.Lock()
turn := c.cpTurn
c.cpTurn++
msgIndex := len(c.executor.Session().Messages)
c.cpBound[turn] = msgIndex
c.mu.Unlock()
c.cp.Begin(turn, input, msgIndex)
}
// --- commands (frontend → controller) ---
// 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). A no-op if a
// turn is already in flight.
func (c *Controller) runGuarded(body func(ctx context.Context) error) {
c.mu.Lock()
if c.running {
c.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(context.Background())
c.cancel = cancel
c.running = true
c.mu.Unlock()
go func() {
defer cancel()
err := body(ctx)
c.mu.Lock()
c.running = false
c.cancel = nil
c.mu.Unlock()
c.sink.Emit(event.Event{Kind: event.TurnDone, Err: explainError(err)})
}()
}
// 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.runTurnWithRaw(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"
// 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. Keep the task list current with todo_write, preserving its two-level shape (phases at level 0, their sub-steps at level 1): mark the sub-step you start as in_progress, one in_progress at a time. Sign off each finished sub-step with complete_step, attaching the evidence it's done — the verification you ran, the diff/files you changed, or a manual check. Don't claim a step is done without evidence."
// 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.runTurnWithRaw(ctx, input, input)
}
func (c *Controller) runTurnWithRaw(ctx context.Context, input, raw string) error {
c.maybeSessionStart(ctx)
c.maybeAutoPlan(ctx, raw)
input = c.Compose(input)
startMessages := c.messageCount()
defer c.snapshotActivityIfChanged(startMessages)
// Open a checkpoint for this turn before the user message is appended, so the
// recorded message boundary precedes it and pre-edit snapshots land here.
c.beginCheckpoint(input)
// UserPromptSubmit / Stop hooks bracket the whole turn (incl. the plan
// research + approved-execution sub-turns below): a gating UserPromptSubmit
// aborts before any model call; Stop fires once when the turn returns.
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 // the hook's notify callback already surfaced the reason
}
defer func() { c.hooks.Stop(ctx, lastAssistantText(c.History()), turn) }()
}
if err := c.runner.Run(ctx, input); err != nil {
return err
}
c.mu.Lock()
plan := c.planMode
c.mu.Unlock()
if !plan {
return nil
}
proposal := lastAssistantText(c.History())
if proposal == "" {
return nil // no substantive proposal to gate
}
// The plan is already visible as the assistant's answer, so the request
// carries no subject — it's purely the gate.
allow, _, err := c.requestApproval(ctx, planApprovalTool, "")
if err != nil {
return err
}
if !allow {
return nil // keep planning; plan mode stays on
}
c.SetPlanMode(false)
c.seedPlanTodos(proposal)
// The plan is the go-ahead: don't re-prompt for each write of the approved
// work. Auto-approve writers for the duration of this execution turn only.
c.mu.Lock()
c.autoApprove = true
c.mu.Unlock()
defer func() {
c.mu.Lock()
c.autoApprove = false
c.mu.Unlock()
}()
return c.runner.Run(ctx, planApprovedMessage)
}
// 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 and /new 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) {
trimmed := strings.TrimSpace(input)
if note, ok := MemoryQuickAddNote(trimmed); ok {
c.rememberProjectNote(note)
return
}
if note, ok := RememberCommandNote(trimmed); ok {
c.rememberProjectNote(note)
return
}
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.Snapshot(); 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 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 c.runTurnWithRaw(ctx, sent, sent)
})
case strings.HasPrefix(trimmed, "/"):
if ref, ok := FileRefLine(trimmed); ok {
c.runRefTurn(ref)
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
}
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 c.runTurnWithRaw(ctx, sent, sent)
})
return
}
if sent, ok := c.RunSkill(trimmed); ok {
c.runGuarded(func(ctx context.Context) error {
return c.runTurnWithRaw(ctx, sent, sent)
})
return
}
c.notice("unknown command: " + trimmed)
default:
c.runRefTurn(input)
}
}
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)
}
}
// 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 string) {
c.runGuarded(func(ctx context.Context) error {
block, errs := c.ResolveRefs(ctx, input)
for _, e := range errs {
c.notice(e)
}
sent := input
if block != "" {
sent = "Referenced context:\n\n" + block + "\n\n" + input
}
return c.runTurnWithRaw(ctx, sent, input)
})
}
// 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})
}
// 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)
startMessages := c.messageCount()
defer c.snapshotActivityIfChanged(startMessages)
if c.hooks.Enabled() {
c.turn++
if block, _ := c.hooks.PromptSubmit(ctx, input, c.turn); block {
return nil
}
defer func() { c.hooks.Stop(ctx, lastAssistantText(c.History()), c.turn) }()
}
return c.runner.Run(ctx, input)
}
// 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
c.mu.Unlock()
if cancel != nil {
cancel()
}
}
// Running reports whether a turn is currently in flight.
func (c *Controller) Running() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.running
}
// 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 tool+subject
// isn't re-prompted. Unknown/expired IDs are ignored.
func (c *Controller) Approve(id string, allow, session, persist bool) {
c.mu.Lock()
reply := c.approvals[id]
delete(c.approvals, id)
c.mu.Unlock()
if reply != nil {
reply <- approvalReply{allow: allow, session: session, persist: persist} // buffered, never blocks
}
}
// EnableInteractiveApproval swaps the executor's gate for one that routes "ask"
// 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() {
if c.executor != nil {
gate := permission.NewGate(c.policy, gateApprover{c})
gate.OnRemember = c.onRemember // wire "always allow" persistence callback
c.executor.SetGate(gate)
c.executor.SetAsker(c)
}
}
// 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.
func (c *Controller) Ask(ctx context.Context, questions []event.AskQuestion) ([]event.AskAnswer, error) {
c.promptMu.Lock()
defer c.promptMu.Unlock()
c.mu.Lock()
c.nextID++
id := strconv.Itoa(c.nextID)
reply := make(chan []event.AskAnswer, 1)
c.asks[id] = reply
c.mu.Unlock()
c.sink.Emit(event.Event{Kind: event.AskRequest, Ask: event.Ask{ID: id, Questions: questions}})
select {
case ans := <-reply:
return ans, nil
case <-ctx.Done():
c.mu.Lock()
delete(c.asks, id)
c.mu.Unlock()
return nil, ctx.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) {
c.mu.Lock()
reply := c.asks[id]
delete(c.asks, id)
c.mu.Unlock()
if reply != nil {
reply <- answers // buffered, never blocks
}
}
// 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)
}
}
// 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
}
// 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
}
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.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
}
if err := c.Snapshot(); err != nil {
return err
}
c.hooks.SessionEnd(context.Background())
if c.sessionDir != "" {
c.mu.Lock()
c.sessionPath = agent.NewSessionPath(c.sessionDir, c.label)
c.mu.Unlock()
}
c.executor.SetSession(agent.NewSession(c.systemPrompt))
c.rebindCheckpoints(c.SessionPath())
c.mu.Lock()
c.startedOnce = true // NewSession fires SessionStart itself; don't re-fire on the next turn
c.mu.Unlock()
c.hooks.SessionStart(context.Background())
return nil
}
// 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 {
if c.cp == nil {
return nil
}
return c.cp.List()
}
// 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.cp == nil || c.executor == nil {
return c.rewindFail(fmt.Errorf("checkpoints unavailable"))
}
c.mu.Lock()
running := c.running
boundary, hasBound := c.cpBound[turn]
c.mu.Unlock()
if running {
return c.rewindFail(fmt.Errorf("cannot rewind while a turn is running"))
}
if scope == RewindCode || scope == RewindBoth {
written, deleted, err := c.cp.RestoreCode(turn)
if err != nil {
return c.rewindFail(fmt.Errorf("rewind code: %w", err))
}
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()
if boundary <= len(s.Messages) {
s.Messages = s.Messages[:boundary]
c.mu.Lock()
c.cpTurn = turn // renumber future turns from here; later turns are gone
for k := range c.cpBound {
if k >= turn {
delete(c.cpBound, k)
}
}
c.mu.Unlock()
if err := c.Snapshot(); 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"))
}
c.mu.Lock()
running := c.running
boundary, hasBound := c.cpBound[turn]
c.mu.Unlock()
if running {
return "", c.rewindFail(fmt.Errorf("cannot fork while a turn is running"))
}
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)
}
if err := agent.SaveBranchMeta(newPath, agent.BranchMeta{
Name: strings.TrimSpace(name),
ParentID: parentID,
ForkTurn: turn,
ForkMessageIndex: boundary,
}); err != nil {
return "", c.rewindFail(err)
}
if switchToFork {
c.executor.SetSession(sess)
c.mu.Lock()
c.sessionPath = newPath
c.mu.Unlock()
c.rebindCheckpoints(newPath)
}
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 {
c.mu.Lock()
defer c.mu.Unlock()
_, ok := c.cpBound[turn]
return ok
}
// 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"))
}
c.mu.Lock()
running := c.running
c.mu.Unlock()
if running {
return "", c.rewindFail(fmt.Errorf("cannot branch while a turn is running"))
}
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)
}
if err := agent.SaveBranchMeta(newPath, agent.BranchMeta{
Name: strings.TrimSpace(name),
ParentID: parentID,
ForkTurn: -1,
ForkMessageIndex: len(branched),
}); err != nil {
return "", c.rewindFail(err)
}
c.executor.SetSession(sess)
c.mu.Lock()
c.sessionPath = newPath
c.mu.Unlock()
c.rebindCheckpoints(newPath)
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>"))
}
c.mu.Lock()
running := c.running
c.mu.Unlock()
if running {
return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("cannot switch branches while a turn is running"))
}
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)
}
loaded, err := agent.LoadSession(match.Path)
if err != nil {
return agent.BranchInfo{}, c.rewindFail(err)
}
if c.executor != nil {
c.executor.SetSession(loaded)
}
c.mu.Lock()
c.sessionPath = match.Path
c.mu.Unlock()
c.rebindCheckpoints(match.Path)
c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
Text: fmt.Sprintf("switched to branch %s", branchDisplayName(match))})
return match, nil
}
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"))
}
c.mu.Lock()
running := c.running
boundary, hasBound := c.cpBound[turn]
c.mu.Unlock()
if running {
return c.rewindFail(fmt.Errorf("cannot summarize while a turn is running"))
}
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
// cpTurn monotonic so new turns don't collide with the store) — conversation
// rewind degrades to "unavailable" until fresh turns rebuild boundaries.
c.mu.Lock()
c.cpBound = map[int]int{}
c.mu.Unlock()
if err := c.Snapshot(); 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) {
if c.executor != nil {
c.executor.SetSession(s)
}
c.mu.Lock()
c.sessionPath = path
c.mu.Unlock()
c.rebindCheckpoints(path)
}
// Snapshot writes the executor's conversation to the active session file. No-op
// when persistence is unavailable or the session has never been used (no user
// interaction). Called after every turn so a crash loses at most one in-flight
// prompt.
func (c *Controller) Snapshot() error {
return c.snapshot(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)
}
func (c *Controller) snapshot(markActivity bool) error {
c.mu.Lock()
path := c.sessionPath
c.mu.Unlock()
if c.executor == nil || path == "" {
return nil
}
s := c.executor.Session()
if !s.HasContent() {
return nil
}
if !markActivity {
if _, err := agent.EnsureBranchMeta(path); err != nil {
return err
}
}
if err := s.Save(path); err != nil {
return err
}
if markActivity {
return agent.TouchBranchMeta(path)
}
return nil
}
func (c *Controller) messageCount() int {
if c.executor == nil {
return 0
}
return len(c.executor.Session().Snapshot())
}
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) {
c.mu.Lock()
c.sessionPath = p
c.mu.Unlock()
c.rebindCheckpoints(p)
}
// 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
}
// 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 (promptTokens, contextWindow) from the most recent
// turn. Both zero means no data yet — a gauge hides itself.
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, 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()
}
// 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.host }
// Commands returns the loaded custom slash commands.
func (c *Controller) Commands() []command.Command { return c.commands }
// Skills returns the discoverable skills (for the slash menu and `/skills`).
func (c *Controller) Skills() []skill.Skill { return c.skills }
// 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 {
if len(c.allSkills) > 0 {
return c.allSkills
}
return c.skills
}
// 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)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if err := cfg.SetSkillEnabled(name, enabled); err != nil {
return err
}
return cfg.SaveTo(config.UserConfigPath())
}
// 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)
}
func (c *Controller) connectMCPServer(e config.PluginEntry) (int, error) {
exp := e.ExpandedPlugin()
return c.connectMCPSpec(plugin.Spec{
Name: exp.Name,
Type: exp.Type,
Command: exp.Command,
Args: exp.Args,
Env: exp.Env,
URL: exp.URL,
Headers: exp.Headers,
})
}
func (c *Controller) connectMCPSpec(s plugin.Spec) (int, error) {
if c.host == nil {
c.host = plugin.NewHost()
}
tools, err := c.host.Add(c.pluginCtx, s)
if err != nil {
return 0, err
}
if c.reg != nil {
c.reg.RemovePrefix(plugin.ToolPrefix(s.Name))
for _, t := range tools {
c.reg.Add(t)
}
}
return len(tools), 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{}
if c.host != nil {
for _, name := range c.host.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)
}
}
if name == "codegraph" {
return c.connectCodegraphMCPServer(cfg)
}
return 0, fmt.Errorf("no configured MCP server named %q", name)
}
// ConnectCodegraphMCPServer connects the built-in CodeGraph server using an
// already-resolved config. Desktop uses this after saving user-level settings so
// a stale project config cannot override the just-applied choice.
func (c *Controller) ConnectCodegraphMCPServer(cfg *config.Config) (int, error) {
return c.connectCodegraphMCPServer(cfg)
}
func (c *Controller) connectCodegraphMCPServer(cfg *config.Config) (int, error) {
if !cfg.Codegraph.Enabled {
return 0, fmt.Errorf("codegraph is disabled in config")
}
bin, ok := codegraph.Resolve(cfg.Codegraph.Path)
if !ok {
return 0, fmt.Errorf("codegraph is not installed")
}
cwd, err := os.Getwd()
if err != nil {
return 0, err
}
if err := codegraph.EnsureInit(c.pluginCtx, bin, cwd); err != nil {
return 0, fmt.Errorf("codegraph init: %w", err)
}
return c.connectMCPSpec(plugin.Spec{
Name: "codegraph",
Command: bin,
Args: []string{"serve", "--mcp"},
Dir: cwd,
ReadOnlyToolNames: codegraph.ReadOnlyToolNames(),
})
}
// RemoveMCPServer disconnects a live MCP server — its tools vanish from the next
// turn — and removes it from the config file. It reports whether a live server was
// disconnected; an error only when the name is neither connected nor in config (or
// the config save fails). A server declared in .mcp.json disconnects for this
// session but returns on the next start, since that file isn't ours to edit.
func (c *Controller) RemoveMCPServer(name string) (disconnected bool, err error) {
if c.host != nil {
if prefix, ok := c.host.Remove(name); ok {
disconnected = true
if c.reg != nil {
c.reg.RemovePrefix(prefix)
}
}
}
cfg, lerr := config.Load()
if lerr != nil {
return disconnected, lerr
}
inConfig := cfg.RemovePlugin(name)
if inConfig {
if !disconnected && c.reg != nil {
c.reg.RemovePrefix(plugin.ToolPrefix(name))
}
if serr := cfg.Save(); serr != nil {
return disconnected, serr
}
}
if !disconnected && !inConfig {
return false, fmt.Errorf("no MCP server named %q", 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 := false
if c.host != nil {
if prefix, ok := c.host.Remove(name); ok {
disconnected = true
if c.reg != nil {
c.reg.RemovePrefix(prefix)
}
}
}
removedPlaceholder := 0
if !disconnected && c.reg != nil {
removedPlaceholder = c.reg.RemovePrefix(plugin.ToolPrefix(name))
}
return disconnected || removedPlaceholder > 0
}
// Label returns the human-readable model label, e.g. "deepseek-flash".
func (c *Controller) Label() string { return c.label }
// Close stops plugin subprocesses and releases resources. A session that ever
// started fires SessionEnd so a teardown hook runs.
func (c *Controller) Close() {
c.mu.Lock()
started := c.startedOnce
c.mu.Unlock()
if started {
c.hooks.SessionEnd(context.Background())
}
if c.jobs != nil {
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.Running()
}
// SetBypass turns YOLO/bypass mode on or off for the session: while on, every
// approval prompt is auto-allowed (writers and bash run without asking). Deny
// rules still block. Runtime-only — never written to config.
func (c *Controller) SetBypass(on bool) {
var pending []chan approvalReply
c.mu.Lock()
c.bypass = on
if on {
pending = c.drainApprovalsLocked()
}
c.mu.Unlock()
for _, reply := range pending {
reply <- approvalReply{allow: true}
}
}
// SetMode applies plan (read-only) and bypass (auto-approve) together so a turn
// submitted right after a composer mode switch can't observe a half-applied
// gate. Turning bypass on drains any approval already waiting.
func (c *Controller) SetMode(plan, bypass bool) {
var pending []chan approvalReply
c.mu.Lock()
c.planMode = plan
c.bypass = bypass
if bypass {
pending = c.drainApprovalsLocked()
}
c.mu.Unlock()
if c.executor != nil {
c.executor.SetPlanMode(plan)
}
for _, reply := range pending {
reply <- approvalReply{allow: true}
}
}
// drainApprovalsLocked removes every pending approval gate and returns their
// reply channels; caller holds c.mu and sends {allow:true} after unlocking.
func (c *Controller) drainApprovalsLocked() []chan approvalReply {
pending := make([]chan approvalReply, 0, len(c.approvals))
for id, reply := range c.approvals {
delete(c.approvals, id)
pending = append(pending, reply)
}
return pending
}
// Bypass reports whether YOLO/bypass mode is on, for the status-bar indicator.
func (c *Controller) Bypass() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.bypass
}
// --- memory ---
//
// c.mem is treated as an immutable snapshot guarded by c.mu: reads take the lock
// and return the pointer; writes mutate disk then swap in a freshly discovered
// snapshot. A turn-tail note is queued for each write so the change applies this
// session without disturbing the cache-stable system prefix (it folds into the
// prefix on the next session). All of these are no-ops returning "" when memory
// is disabled.
// 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) {
c.mu.Lock()
defer c.mu.Unlock()
if c.mem == nil {
return "", nil
}
path := c.mem.DocPath(scope)
if path == "" {
return "", fmt.Errorf("no target file for memory scope %q", scope)
}
if err := memory.AppendDoc(path, note); err != nil {
return "", err
}
c.pendingMemory = append(c.pendingMemory, note)
c.refreshMemoryLocked()
return path, nil
}
// 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) {
c.mu.Lock()
defer c.mu.Unlock()
if c.mem == nil {
return "", nil
}
written, err := c.mem.WriteDoc(path, body)
if err != nil {
return "", err
}
// Inject the new content once on the next turn: the cached prefix still holds
// the pre-edit version this session, so handing the model the current text
// avoids a stale-guidance gap until the next session re-folds it into the
// prefix. Trimmed to a single tail note (drained by Compose), not per-turn.
c.pendingMemory = append(c.pendingMemory,
"Memory file "+written+" was just edited. Its current contents:\n"+strings.TrimSpace(body))
c.refreshMemoryLocked()
return written, nil
}
// ForgetMemory deletes a saved auto-memory by name — the panel/TUI delete action,
// the manual counterpart to the model's `forget` tool. It queues a turn-tail note
// so the deletion applies this session (the cached prefix still lists the fact
// until the next session re-folds the index).
func (c *Controller) ForgetMemory(name string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.mem == nil {
return nil
}
if err := c.mem.Store.Delete(name); err != nil {
return err
}
c.pendingMemory = append(c.pendingMemory,
"Deleted memory \""+name+"\" — disregard its line still shown in the saved-memories index until next session.")
c.refreshMemoryLocked()
return nil
}
// 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.mu.Lock()
defer c.mu.Unlock()
c.pendingMemory = append(c.pendingMemory, note)
c.refreshMemoryLocked()
}
// 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 {
c.mu.Lock()
defer c.mu.Unlock()
return c.mem
}
// refreshMemoryLocked re-discovers memory from disk so a later Memory() reflects
// a just-applied write. Caller holds c.mu.
func (c *Controller) refreshMemoryLocked() {
if c.mem == nil {
return
}
c.mem = memory.Load(memory.Options{CWD: c.mem.CWD, UserDir: c.mem.UserDir})
}
// --- 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) {
// Auto-allow without prompting while executing a just-approved plan (the plan
// was the approval) or while YOLO/bypass mode is on. Deny rules already bit
// before this point, so they still block.
g.c.mu.Lock()
auto := g.c.autoApprove || g.c.bypass
g.c.mu.Unlock()
if auto {
return true, false, nil
}
return g.c.requestApproval(ctx, tool, subject)
}
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) {
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})
}
// 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)
}
// 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
}
// 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
}
// requestApproval emits an ApprovalRequest and blocks until Approve(ID, …)
// answers or ctx is cancelled. A prior session grant for the same tool+subject
// short-circuits. promptMu serialises outstanding prompts.
func (c *Controller) requestApproval(ctx context.Context, tool, subject string) (bool, bool, error) {
key := tool + "\x00" + subject
c.mu.Lock()
// YOLO/bypass and the just-approved-plan window auto-allow every approval
// without prompting; the plan gate routes through here too, so this is what
// stops a bypass session from blocking on plan approval. Deny rules bit upstream.
if c.bypass || c.autoApprove || c.granted[key] {
c.mu.Unlock()
return true, false, nil
}
c.mu.Unlock()
c.promptMu.Lock()
defer c.promptMu.Unlock()
// Re-check the grant: a session grant may have landed while we queued behind
// another prompt for the same subject.
c.mu.Lock()
if c.bypass || c.autoApprove || c.granted[key] {
c.mu.Unlock()
return true, false, nil
}
c.nextID++
id := strconv.Itoa(c.nextID)
reply := make(chan approvalReply, 1)
c.approvals[id] = reply
c.mu.Unlock()
c.sink.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: id, Tool: tool, Subject: subject}})
// 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.
if subject != "" {
go c.hooks.Notification(ctx, "approval needed: "+tool+" "+subject)
} else {
go c.hooks.Notification(ctx, "approval needed: "+tool)
}
select {
case r := <-reply:
// Plan approvals are one-shot — never persist a session grant for them, or
// every future plan would auto-approve.
if r.allow && r.session && tool != planApprovalTool {
c.mu.Lock()
c.granted[key] = true
c.mu.Unlock()
}
// When persist is true, remember=true signals Gate.OnRemember to write
// the rule to the on-disk config. Plan approvals are excluded.
remember := r.persist && tool != planApprovalTool
return r.allow, remember, nil
case <-ctx.Done():
c.mu.Lock()
delete(c.approvals, id)
c.mu.Unlock()
return false, false, ctx.Err()
}
}