返回 DeepSeek-Reasonix
app.go
根目录 / desktop / app.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "crypto/rand"
7 "crypto/sha256"
8 "encoding/base64"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "log/slog"
15 "mime"
16 "net/http"
17 "net/url"
18 "os"
19 "os/exec"
20 "path/filepath"
21 "regexp"
22 goruntime "runtime"
23 "sort"
24 "strconv"
25 "strings"
26 "sync"
27 "sync/atomic"
28 "time"
29 "unicode/utf8"
30
31 "github.com/wailsapp/wails/v2/pkg/runtime"
32
33 "reasonix/internal/agent"
34 "reasonix/internal/autoresearch"
35 "reasonix/internal/billing"
36 "reasonix/internal/boot"
37 "reasonix/internal/botruntime"
38 "reasonix/internal/checkpoint"
39 "reasonix/internal/config"
40 "reasonix/internal/control"
41 "reasonix/internal/event"
42 "reasonix/internal/evidence"
43 "reasonix/internal/extension/providerext"
44 "reasonix/internal/fileref"
45 fileenc "reasonix/internal/fileutil/encoding"
46 "reasonix/internal/i18n"
47 "reasonix/internal/jobs"
48 "reasonix/internal/mcpdiag"
49 "reasonix/internal/mcplaunch"
50 "reasonix/internal/mcpregistry"
51 "reasonix/internal/memory"
52 "reasonix/internal/notify"
53 "reasonix/internal/plugin"
54 "reasonix/internal/pluginpkg"
55 "reasonix/internal/provider"
56 "reasonix/internal/repair"
57 "reasonix/internal/sessiontemp"
58 "reasonix/internal/skill"
59 "reasonix/internal/stats"
60 "reasonix/internal/store"
61 "reasonix/internal/taskmonitor"
62 "reasonix/internal/tool"
63 )
64
65 // sessionTempFromController returns the logical-session private temporary
66 // directory manager for a same-session controller rebuild. Nil when the
67 // controller is missing or is not a *control.Controller.
68 func sessionTempFromController(ctrl control.SessionAPI) *sessiontemp.Manager {
69 c, ok := ctrl.(*control.Controller)
70 if !ok || c == nil {
71 return nil
72 }
73 return c.SessionTemp()
74 }
75
76 // eventChannel is the Wails runtime event name the frontend subscribes to for the
77 // agent's typed event stream. One channel carries every event kind; the payload's
78 // `kind` field discriminates — the desktop analogue of the serve transport's SSE
79 // `data:` frames.
80 const eventChannel = "agent:event"
81
82 const singleInstanceIDPrefix = "com.reasonix.desktop"
83
84 // singleInstanceID is used by Wails to route a second desktop launch back to the
85 // process that owns the same Reasonix data home. Basing the identity on the
86 // executable path let installed, portable, stable, and canary binaries write the
87 // same sessions concurrently. Explicit REASONIX_HOME isolation still produces
88 // an independent instance; REASONIX_DEV continues to bypass the lock entirely.
89 func singleInstanceID() string {
90 root := strings.TrimSpace(config.ReasonixHomeDir())
91 if root == "" {
92 return singleInstanceIDPrefix
93 }
94 // Reuse the lease path canonicalizer so a missing home below a symlink or
95 // junction still hashes to the same physical data directory.
96 if marker := agent.CanonicalSessionPath(filepath.Join(root, ".reasonix-home.identity")); marker != "" {
97 root = filepath.Dir(marker)
98 }
99 root = filepath.Clean(root)
100 sum := sha256.Sum256([]byte(root))
101 return singleInstanceIDPrefix + "." + hex.EncodeToString(sum[:8])
102 }
103
104 // PromptHistoryEntry is one user prompt extracted from a session JSONL file.
105 // The frontend uses these for ↑/↓ prompt-history navigation.
106 type PromptHistoryEntry struct {
107 Text string `json:"text"`
108 At int64 `json:"at"` // unix ms
109 SessionPath string `json:"sessionPath"`
110 Turn int `json:"turn"`
111 }
112
113 // PromptHistoryResult is returned as one Wails value. It carries one loaded tape
114 // segment plus the cursor needed to keep walking toward older prompts.
115 type PromptHistoryResult struct {
116 Entries []PromptHistoryEntry `json:"entries"`
117 Nonce string `json:"nonce"`
118 OlderCursor string `json:"olderCursor,omitempty"`
119 HasOlder bool `json:"hasOlder"`
120 }
121
122 // App is the Wails-bound application object: the desktop frontend's command
123 // surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS
124 // bindings. The app manages multiple WorkspaceTabs — each with its own controller
125 // scoped to a project workspace — and routes commands to the active tab. Events
126 // flow the other way: each tab's controller emits to a tabEventSink that
127 // forwards events tagged with tabId to the webview via runtime.EventsEmit.
128 type App struct {
129 ctx context.Context
130
131 // taskCtrl is the process-wide task-monitor control service (lazy; see
132 // taskControl). One instance serializes control operations in-process.
133 taskCtrl *taskmonitor.ControlService
134 taskCtrlOnce sync.Once
135
136 // mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read
137 // from bound methods. All bound methods that touch a controller use activeCtrl().
138 mu sync.RWMutex
139 tabs map[string]*WorkspaceTab
140 tabOrder []string
141 activeTabID string
142 readyHook func()
143
144 // runtimeByID/runtimeBySessionKey form the process-local ownership registry.
145 // App.mu guards both maps and every desktopSessionRuntime field.
146 runtimeByID map[string]*desktopSessionRuntime
147 runtimeBySessionKey map[string]*desktopSessionRuntime
148
149 // tabsRestored is closed when restoreOrBuildTabs has finished populating
150 // a.tabs from desktop-tabs.json (or built the first-launch tab). Startup
151 // work that inspects "which sessions are open" or persists the tab list
152 // (recovery GC's DeleteSession does both) must wait on it: running against
153 // the pre-restore empty tab map would treat every saved tab's session as
154 // closed and could overwrite desktop-tabs.json with an empty snapshot.
155 tabsRestored chan struct{}
156
157 // projectTreeChangedHook is test-only: set once before any concurrency
158 // starts, then read lock-free from emitProjectTreeChanged (whose callers
159 // may or may not hold a.mu, so it cannot re-lock). Never write it after
160 // startup.
161 projectTreeChangedHook func()
162
163 // singleSurfaceMu serializes open/reuse plus visible-tab pruning for the
164 // one-conversation layout so overlapping navigation cannot remove the tab
165 // another navigation is still activating.
166 singleSurfaceMu sync.Mutex
167
168 // sessionRemovalMu serializes operations that remove visible or detached
169 // session bindings. Those operations may snapshot controllers before
170 // deletion; keep that snapshot outside a.mu, but do not let DeleteSession or
171 // topic/workspace removal trash the same files while it is in flight.
172 sessionRemovalMu sync.Mutex
173
174 // runtimeRebuildMu serializes controller rebuilds (build + swap), teardown,
175 // and MCP lifecycle mutations. Two concurrent rebuilds of the same tab both
176 // pass the tab-identity check at swap time, while MCP launch authorization racing
177 // a toggle/reconnect can restore stale tools or launch a second single-instance
178 // server. Keep the lock order runtimeRebuildMu -> runtimeAdmissionMu -> App.mu
179 // -> Host/Registry.
180 runtimeRebuildMu sync.Mutex
181 // runtimeAdmissionMu is the runtime lifecycle barrier. Foreground turn-start
182 // tokens and controller builds hold the read side; runtime teardown and MCP
183 // lifecycle mutations hold the write side so their captured controller/Host
184 // cannot be replaced, closed, or handed a late turn in flight. Writers already
185 // hold runtimeRebuildMu, making them mutually exclusive. Read holders must never
186 // acquire runtimeRebuildMu, or a queued writer would deadlock the pair.
187 runtimeAdmissionMu sync.RWMutex
188 // runtimeMutationBeforeLockHook is test-only. Set it before starting concurrent
189 // calls and never mutate it afterward.
190 runtimeMutationBeforeLockHook func(string)
191 // modelSwitchTimingHook is test-only. Production diagnostics use the same
192 // sanitized timing record through debug logging.
193 modelSwitchTimingHook func(modelSwitchTiming)
194 // rebindCandidateHook is test-only. It exposes deterministic transaction
195 // boundaries without weakening the production lock order. Set it before
196 // starting a rebind and never mutate it until that rebind returns.
197 rebindCandidateHook func(string) error
198 // providerCatalogBeforeCredentialLockHook is test-only. It pauses catalog
199 // compare-and-apply after its optimistic credential snapshot but before the
200 // shared credential lock and authoritative re-read.
201 providerCatalogBeforeCredentialLockHook func(string)
202
203 // tryRunMu guards tryRunCancel — the cancel handle for the single
204 // in-flight settings-page subagent try run (TrySubagentProfile /
205 // CancelTrySubagentProfile).
206 tryRunMu sync.Mutex
207 tryRunCancel context.CancelFunc
208
209 // updaterOperationMu guards the single native download/install operation.
210 // Checks are read-only and may overlap; cache mutation and installation fail
211 // fast when another updater operation is already active.
212 updaterOperationMu sync.Mutex
213 updaterOperationID string
214
215 // deferredRebuild tracks tabs whose settings were saved but whose runtime
216 // could not refresh because the session lease was held by another process.
217 deferredRebuild deferredRebuildState
218
219 // detachedSessions keeps live session runtimes whose visible tab was closed.
220 // It is process-local by design: shutdown closes every detached controller.
221 detachedSessions map[string]*WorkspaceTab
222
223 // sharedHosts holds one *plugin.Host per workspace root, shared by all
224 // controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are
225 // spawned once instead of N times. Lifecycle: first Acquire creates the
226 // host, last Release closes it.
227 sharedHosts map[string]*sharedPluginHost
228 sharedHostsMu sync.Mutex
229
230 // tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.
231 tabsSaveMu sync.Mutex
232 tabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot
233 tabsLastWrittenVersion uint64 // protected by tabsSaveMu
234
235 forceQuit atomic.Bool
236 backgroundMaximised atomic.Bool
237 desktopLocale atomic.Int32
238 trayReady bool
239 tray *desktopTray
240 hangWatchdogMu sync.Mutex
241 hangWatchdogCancel context.CancelFunc
242
243 mediaTokens *mediaTokenStore
244 botInstalls map[string]*botInstallSession
245 botRuntime *desktopBotRuntime
246 // botBridge gives the embedded bot gateway a god view over desktop
247 // sessions (/desktop commands). Set once in NewApp before any tab exists,
248 // read-only afterwards, so tabEventSink.Emit reads it without a lock.
249 botBridge *botBridgeHub
250
251 metrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics
252
253 notificationSenderOnce sync.Once
254 notificationSender notify.Sender
255
256 runtimeEvents asyncRuntimeEmitter
257
258 // terminals owns local PTY/ConPTY sessions. It is intentionally separate
259 // from chat runtimes: terminal lifecycle must never acquire App.mu or the
260 // controller rebuild locks while process I/O is blocked.
261 terminals *terminalManager
262
263 // Remote SSH module: the manager is created lazily on the first remote
264 // binding call and closed on shutdown.
265 remoteMu sync.Mutex
266 remoteRuntime remoteKernel
267
268 // Remote web windows (SSH Serve child processes). The main process tracks
269 // the live child plus transient handoff processes for each host. Host-scoped
270 // lifecycle operations are generation-fenced and serialized so an overlapping
271 // disconnect/stop cannot miss a window that is still being spawned. Closing a
272 // window releases only its registration, while the remote Serve and the SSH
273 // connection keep running. The child deliberately skips local runtimes.
274 remoteWindows *remoteWindowRegistry
275 remoteWindowLifecycles remoteWindowLifecycleRegistry
276 remoteWindowOpener func(remoteWindowLaunch) error // test-only injection
277 // remoteWindowTicket/remoteWindowHostKey are set from argv before Wails
278 // starts in a child process. They gate the blank-shell middleware and the
279 // startup branches so the child never initializes local runtimes.
280 remoteWindowTicket string
281 remoteWindowHostKey string
282 // remoteWindowOwnerID scopes child single-instance locks to one primary
283 // Desktop process. remoteWindowParentPID is set only in children and lets
284 // them exit when that owner (and therefore its SSH tunnel) disappears.
285 remoteWindowOwnerID string
286 remoteWindowParentPID int
287 // remoteWindowMu serializes ticket consumption and navigation in a child
288 // process so a handoff arriving before domReady cannot be overridden by the
289 // initial ticket (or vice versa). remoteWindowTicketConsumed makes the
290 // initial handoff idempotent because WebKit fires OnDomReady again after the
291 // shell navigates to the remote Serve page.
292 remoteWindowMu sync.Mutex
293 remoteWindowTicketConsumed bool
294 remoteWindow *remoteWindowLaunch
295
296 // promptHistoryTape is a lazy, cursor-addressed view of prompt history. It
297 // stores session order and per-session parsed entries only after that session is
298 // reached by ↑ navigation. See ScanPromptHistory.
299 promptHistoryMu sync.Mutex
300 promptHistoryTape *promptHistoryTape
301
302 skillRootsMu sync.Mutex
303 skillRootsCache skillRootsCache
304
305 heartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup
306
307 previousRun repair.PreviousRunObservation
308 // Healthy-update identity is captured before Wails starts. A process may
309 // commit only the complete probationary transaction it actually booted from,
310 // never a rewritten or later same-version retry.
311 healthyUpdateCreatedAt string
312 healthyUpdateTransactionID string
313 // startupReady records that the window reached domReady so LKG config
314 // snapshots and update health are only committed after a real UI boot.
315 startupReady atomic.Bool
316 }
317
318 type skillRootsCache struct {
319 key string
320 at time.Time
321 roots []SkillRootView
322 }
323
324 // mediaTokenEntry holds metadata for a workspace media file served via temporary URL.
325 type mediaTokenEntry struct {
326 absPath string
327 filename string
328 mime string
329 kind string
330 size int64
331 modTime time.Time
332 createdAt time.Time
333 expiresAt time.Time
334 }
335
336 // mediaTokenStore manages temporary tokens that grant access to workspace files
337 // through the AssetServer middleware. Tokens expire after a fixed TTL and are
338 // capped at a maximum count; creating a new token evicts the oldest entry when
339 // the store is full.
340 type mediaTokenStore struct {
341 mu sync.Mutex
342 byTok map[string]*mediaTokenEntry
343 order []string // oldest first
344 maxN int
345 ttl time.Duration
346 }
347
348 const mediaTokenMax = 256
349
350 func newMediaTokenStore() *mediaTokenStore {
351 return &mediaTokenStore{
352 byTok: map[string]*mediaTokenEntry{},
353 maxN: mediaTokenMax,
354 ttl: 10 * time.Minute,
355 }
356 }
357
358 func (s *mediaTokenStore) cleanupLocked() {
359 now := time.Now()
360 for len(s.order) > 0 {
361 tok := s.order[0]
362 e := s.byTok[tok]
363 if e == nil {
364 s.order = s.order[1:]
365 continue
366 }
367 if !now.Before(e.expiresAt) {
368 delete(s.byTok, tok)
369 s.order = s.order[1:]
370 continue
371 }
372 break
373 }
374 for len(s.order) > s.maxN {
375 oldest := s.order[0]
376 delete(s.byTok, oldest)
377 s.order = s.order[1:]
378 }
379 }
380
381 func (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string {
382 s.mu.Lock()
383 defer s.mu.Unlock()
384
385 s.cleanupLocked()
386
387 tok := make([]byte, 16)
388 if _, err := rand.Read(tok); err != nil {
389 panic("crypto/rand.Read failed: " + err.Error())
390 }
391 token := hex.EncodeToString(tok)
392
393 now := time.Now()
394 s.byTok[token] = &mediaTokenEntry{
395 absPath: absPath,
396 filename: filename,
397 mime: mime,
398 kind: kind,
399 size: size,
400 modTime: modTime,
401 createdAt: now,
402 expiresAt: now.Add(s.ttl),
403 }
404 s.order = append(s.order, token)
405
406 // Trim oldest if the new token pushed us over the limit.
407 for len(s.order) > s.maxN {
408 oldest := s.order[0]
409 delete(s.byTok, oldest)
410 s.order = s.order[1:]
411 }
412
413 return token
414 }
415
416 func (s *mediaTokenStore) get(token string) *mediaTokenEntry {
417 s.mu.Lock()
418 defer s.mu.Unlock()
419 e := s.byTok[token]
420 if e == nil {
421 return nil
422 }
423 if time.Now().After(e.expiresAt) {
424 delete(s.byTok, token)
425 return nil
426 }
427 return e
428 }
429
430 func (a *App) ensureMediaTokenStore() *mediaTokenStore {
431 a.mu.Lock()
432 defer a.mu.Unlock()
433 if a.mediaTokens == nil {
434 a.mediaTokens = newMediaTokenStore()
435 }
436 return a.mediaTokens
437 }
438
439 // jsProfilingMiddleware opts every asset response into the JS Self-Profiling
440 // document policy so the frontend performance monitor can attach sampled stacks
441 // to long-task reports. Chromium WebViews (WebView2) honor it; WebKit ignores
442 // both the header and the API, so the frontend degrades to unattributed reports.
443 func (a *App) jsProfilingMiddleware() func(http.Handler) http.Handler {
444 return func(next http.Handler) http.Handler {
445 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
446 w.Header().Set("Document-Policy", "js-profiling")
447 next.ServeHTTP(w, r)
448 })
449 }
450 }
451
452 // workspaceMediaMiddleware returns an HTTP middleware that intercepts
453 // /__reasonix_workspace_media/{token}/{filename} requests and serves the
454 // corresponding workspace file. All other paths pass through to the Wails
455 // default asset handler unchanged.
456 func (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler {
457 return func(next http.Handler) http.Handler {
458 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
459 prefix := "/__reasonix_workspace_media/"
460 if !strings.HasPrefix(r.URL.Path, prefix) {
461 next.ServeHTTP(w, r)
462 return
463 }
464
465 if r.Method != http.MethodGet && r.Method != http.MethodHead {
466 w.WriteHeader(http.StatusMethodNotAllowed)
467 return
468 }
469
470 rest := strings.TrimPrefix(r.URL.Path, prefix)
471 parts := strings.SplitN(rest, "/", 2)
472 if len(parts) == 0 || parts[0] == "" {
473 http.NotFound(w, r)
474 return
475 }
476 token := parts[0]
477
478 entry := a.ensureMediaTokenStore().get(token)
479 if entry == nil {
480 http.NotFound(w, r)
481 return
482 }
483
484 f, err := os.Open(entry.absPath)
485 if err != nil {
486 http.NotFound(w, r)
487 return
488 }
489 defer f.Close()
490
491 w.Header().Set("Content-Type", entry.mime)
492 w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": entry.filename}))
493 w.Header().Set("X-Content-Type-Options", "nosniff")
494 w.Header().Set("Cache-Control", "private, max-age=600")
495 http.ServeContent(w, r, entry.filename, entry.modTime, f)
496 })
497 }
498 }
499
500 // NewApp constructs the bound object. Tabs are restored in startup from the
501 // last session's desktop-tabs.json.
502 func NewApp() *App {
503 a := &App{
504 tabs: map[string]*WorkspaceTab{},
505 runtimeByID: map[string]*desktopSessionRuntime{},
506 runtimeBySessionKey: map[string]*desktopSessionRuntime{},
507 detachedSessions: map[string]*WorkspaceTab{},
508 mediaTokens: newMediaTokenStore(),
509 botInstalls: map[string]*botInstallSession{},
510 botRuntime: newDesktopBotRuntime(),
511 remoteWindows: newRemoteWindowRegistry(),
512 remoteWindowOwnerID: newRemoteWindowOwnerID(),
513 }
514 a.terminals = newTerminalManager(a)
515 a.botBridge = a.newBotBridge()
516 return a
517 }
518
519 func (a *App) bootContext() context.Context {
520 if a.ctx != nil {
521 return a.ctx
522 }
523 return context.Background()
524 }
525
526 // Platform exposes the native OS to the frontend so chrome/layout affordances can
527 // stay platform-scoped instead of relying on browser user-agent guesses.
528 func (a *App) Platform() string {
529 return goruntime.GOOS
530 }
531
532 // startup runs once the webview process is up, before the frontend can issue any
533 // bound call. It captures the Wails context (needed for EventsEmit), then kicks
534 // off the initialization in a background goroutine so the webview loads immediately.
535 func (a *App) startup(ctx context.Context) {
536 a.ctx = ctx
537 a.startWindowsWebView2StartupFallback(ctx)
538 if a.remoteWindowTicket != "" {
539 // Remote web window child: no local tabs, tray, heartbeat, providers,
540 // or remote manager. domReady consumes the ticket and navigates; the
541 // owner watcher closes the window if the primary Desktop disappears.
542 a.watchRemoteWindowOwner(ctx)
543 return
544 }
545 installSystemQuitHook()
546 a.startTray()
547 a.enableDeferredRebuildRetry()
548 a.goSafe("repairDesktopIconIntegration", func() {
549 if err := repairDesktopIconIntegration(); err != nil {
550 slog.Debug("desktop: repair native icon integration", "err", err)
551 }
552 })
553
554 if cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != "dev" {
555 a.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
556 a.recordSettingsMetricsSnapshot(cfg)
557 }
558 a.recordPreviousRunDiagnostics()
559 a.observeIncompleteWindowRestore()
560 a.startMainThreadWatchdog()
561
562 a.heartbeat = newHeartbeatEngine(a)
563 a.heartbeat.Start()
564
565 a.mu.Lock()
566 a.tabsRestored = make(chan struct{})
567 a.mu.Unlock()
568 go a.restoreOrBuildTabs()
569 a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
570 a.goSafe("sendStartupPing", a.sendStartupPing)
571 a.goSafe("flushMetrics", a.flushMetrics)
572 a.goSafe("flushPendingCrash", a.flushPendingCrash)
573 // After restoreOrBuildTabs is launched: the GC's first sweep waits on
574 // tabsRestored so it never observes the pre-restore empty tab map.
575 a.startRecoveryGC()
576 }
577
578 func (a *App) beforeClose(ctx context.Context) bool {
579 if a.remoteWindowTicket != "" {
580 // A remote web window closes immediately — nothing to snapshot, lease,
581 // or hide. Closing it must not stop the remote Serve or the main
582 // process's SSH connection.
583 return false
584 }
585 if a.forceQuit.Swap(false) || consumeSystemQuitRequested() {
586 return false
587 }
588 cfg, _, err := a.loadDesktopUserConfigForView()
589 if err != nil {
590 cfg = config.LoadForEdit(config.UserConfigPath())
591 }
592 if cfg.DesktopCloseBehavior() == "background" {
593 if !a.backgroundCloseHasRestorePath() {
594 return false
595 }
596 // Never query native maximise state here: during close the Win32 DPI
597 // path can report 0 and panic inside Wails ScaleToDefaultDPI. Use the
598 // last frontend-reported geometry instead.
599 a.backgroundMaximised.Store(a.lastKnownMaximised())
600 a.saveWindowStateSync()
601 a.snapshotAllTabs()
602 hideForBackground(ctx)
603 return true
604 }
605 return false
606 }
607
608 const backgroundCloseTrayReadyTimeout = 500 * time.Millisecond
609
610 func (a *App) backgroundCloseHasRestorePath() bool {
611 if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
612 return backgroundCloseHasRestorePathFor(goruntime.GOOS, false, false)
613 }
614 if !a.startTray() {
615 return false
616 }
617 return backgroundCloseHasRestorePathFor(goruntime.GOOS, true, a.waitForTrayReady(backgroundCloseTrayReadyTimeout))
618 }
619
620 func (a *App) waitForTrayReady(timeout time.Duration) bool {
621 if a.isTrayReady() {
622 return true
623 }
624 ready := a.trayReadySignal()
625 if ready == nil {
626 return false
627 }
628 if timeout <= 0 {
629 select {
630 case <-ready:
631 return a.isTrayReady()
632 default:
633 return false
634 }
635 }
636 timer := time.NewTimer(timeout)
637 defer timer.Stop()
638 select {
639 case <-ready:
640 return a.isTrayReady()
641 case <-timer.C:
642 return a.isTrayReady()
643 }
644 }
645
646 func (a *App) isTrayReady() bool {
647 a.mu.RLock()
648 defer a.mu.RUnlock()
649 return a.trayReady
650 }
651
652 func (a *App) trayReadySignal() <-chan struct{} {
653 a.mu.RLock()
654 defer a.mu.RUnlock()
655 if a.tray == nil {
656 return nil
657 }
658 return a.tray.ready
659 }
660
661 // markTabsRestored closes the tabsRestored gate exactly once. Safe when the
662 // channel was never created (tests that drive App without startup).
663 func (a *App) markTabsRestored() {
664 a.mu.Lock()
665 defer a.mu.Unlock()
666 if a.tabsRestored == nil {
667 return
668 }
669 select {
670 case <-a.tabsRestored:
671 default:
672 close(a.tabsRestored)
673 }
674 }
675
676 // tabsRestoredSignal returns a channel closed once tab restore has completed.
677 // When startup never armed the gate (tests), it reports already-restored.
678 func (a *App) tabsRestoredSignal() <-chan struct{} {
679 a.mu.RLock()
680 defer a.mu.RUnlock()
681 if a.tabsRestored == nil {
682 closed := make(chan struct{})
683 close(closed)
684 return closed
685 }
686 return a.tabsRestored
687 }
688
689 func (a *App) showMainWindow() {
690 a.showMainWindowFrom("menu")
691 }
692
693 func (a *App) secondInstanceLaunch() {
694 a.showMainWindowFrom("second_instance")
695 }
696
697 func (a *App) quitApp() {
698 if a.ctx == nil {
699 return
700 }
701 a.forceQuit.Store(true)
702 runtime.Quit(a.ctx)
703 }
704
705 func hideForBackground(ctx context.Context) {
706 if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
707 runtime.Hide(ctx)
708 return
709 }
710 runtime.WindowHide(ctx)
711 }
712
713 func showFromBackground(ctx context.Context, wasMaximised bool) {
714 if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
715 runtime.Show(ctx)
716 }
717 plan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised)
718 if plan.maximiseBeforeShow {
719 runtime.WindowMaximise(ctx)
720 }
721 runtime.WindowShow(ctx)
722 if plan.unminimiseAfterShow {
723 runtime.WindowUnminimise(ctx)
724 }
725 }
726
727 func backgroundCloseUsesApplicationHide(goos string) bool {
728 return goos == "darwin"
729 }
730
731 func backgroundCloseHasRestorePathFor(goos string, trayStarted, trayReady bool) bool {
732 return backgroundCloseUsesApplicationHide(goos) || (trayStarted && trayReady)
733 }
734
735 type backgroundRestorePlan struct {
736 maximiseBeforeShow bool
737 unminimiseAfterShow bool
738 }
739
740 func backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {
741 if backgroundRestoreShouldMaximise(goos, wasMaximised) {
742 return backgroundRestorePlan{maximiseBeforeShow: true}
743 }
744 return backgroundRestorePlan{unminimiseAfterShow: true}
745 }
746
747 func backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {
748 return wasMaximised && !backgroundCloseUsesApplicationHide(goos)
749 }
750
751 // restoreOrBuildTabs restores the tabs from the last session, or creates a
752 // default Global tab on first launch.
753 func (a *App) restoreOrBuildTabs() {
754 defer a.recoverToPending("restoreOrBuildTabs")
755 // Unblock startup work gated on the restore (recovery GC) no matter how
756 // this returns — including the recover path above.
757 defer a.markTabsRestored()
758 // Reap any orphaned codegraph processes from a previous crash or older
759 // version that leaked them, so they don't accumulate across restarts.
760 a.reapOrphanCodeGraph()
761 ctx := a.ctx
762 ensureWorkspace()
763
764 // Run legacy config migration before the first config load so the
765 // freshly written config (including the user's default_model) is
766 // picked up by Load instead of falling back to built-in defaults.
767 _, _ = config.MigrateLegacyIfNeeded()
768 f := loadTabsFile()
769 _, _ = recoverLegacyProjectSidebarRoots(f)
770 _, _ = config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath())
771 _, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))
772
773 // Load i18n from the first available config.
774 // Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),
775 // so the user's language choice in desktop settings takes effect.
776 startupCfg, cfgErr := config.Load()
777 if cfgErr == nil {
778 cfg := startupCfg
779 lang := cfg.DesktopLanguage()
780 if lang == "" {
781 lang = cfg.Language
782 }
783 a.setDesktopLocale(i18n.DetectLanguage(lang))
784 }
785 if cfgErr != nil || singleSurfaceLayoutStyle(startupCfg.DesktopLayoutStyle()) {
786 f = singleSurfaceTabsFile(f)
787 }
788
789 if len(f.Tabs) > 0 {
790 toBuild := make([]*WorkspaceTab, 0, len(f.Tabs))
791 for _, entry := range f.Tabs {
792 a.mu.Lock()
793 id := a.restoredTabIDLocked(entry.ID)
794 a.mu.Unlock()
795
796 var tab *WorkspaceTab
797 if entry.Scope == "project" {
798 tab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)
799 } else {
800 tab = a.createTabEntryWithID("global", globalTabWorkspaceRoot(), entry.TopicID, id)
801 }
802 tab.model = entry.Model
803 tab.effort = cloneStringPtr(entry.Effort)
804 tab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode)
805 tab.mode = persistedTabMode(entry.Mode)
806 // Validate the persisted goal against the session's goal-state
807 // sidecar: a typed /new or /clear rotates the session through the
808 // controller without passing App.NewSession/ClearSession, so
809 // entry.Goal can be stale. Session rotation writes a stopped
810 // goal-state onto the fresh path; reading it here stops a restart
811 // from re-seeding the cleared goal into the rotated session. A
812 // session without a sidecar keeps the persisted goal (legacy).
813 tab.goal = runningTabSessionGoal(strings.TrimSpace(entry.SessionPath), strings.TrimSpace(entry.Goal))
814 tab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)
815 if tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {
816 tab.toolApprovalMode = control.ToolApprovalYolo
817 }
818 tab.SessionPath = strings.TrimSpace(entry.SessionPath)
819 tab.ReadOnly = entry.ReadOnly
820 tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}
821 a.mu.Lock()
822 a.tabs[tab.ID] = tab
823 a.tabOrder = append(a.tabOrder, tab.ID)
824 a.mu.Unlock()
825 toBuild = append(toBuild, tab)
826 }
827 a.mu.Lock()
828 if _, ok := a.tabs[f.ActiveTab]; ok {
829 a.activeTabID = f.ActiveTab
830 } else {
831 ordered := a.orderedTabIDsLocked()
832 if len(ordered) > 0 {
833 a.activeTabID = ordered[0]
834 }
835 }
836 a.saveTabsLocked()
837 a.mu.Unlock()
838 for _, tab := range toBuild {
839 a.startTabControllerBuild(tab)
840 }
841 return
842 }
843
844 // First launch: create a default Global tab.
845 tab := a.createTabEntry("global", globalTabWorkspaceRoot(), "")
846 tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}
847 tab.TopicTitle = "Global"
848 a.mu.Lock()
849 a.tabs[tab.ID] = tab
850 a.tabOrder = append(a.tabOrder, tab.ID)
851 a.activeTabID = tab.ID
852 a.mu.Unlock()
853 a.startTabControllerBuild(tab)
854 }
855
856 func (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {
857 return a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())
858 }
859
860 func desktopNewSessionDefaults(scope, workspaceRoot string) (string, string) {
861 userCfg := config.LoadForEdit(config.UserConfigPath())
862 modelCfg := userCfg
863 if strings.TrimSpace(scope) == "project" && strings.TrimSpace(workspaceRoot) != "" {
864 if cfg, err := config.LoadForRootReadOnly(workspaceRoot); err == nil {
865 modelCfg = cfg
866 }
867 }
868 return resolveNewSessionModel(modelCfg), normalizeToolApprovalMode(userCfg.DesktopDefaultToolApprovalMode())
869 }
870
871 // resolveNewSessionModel picks the model a fresh session starts on. A
872 // default_model that resolves but has no API key in the current environment
873 // would boot every new tab straight into the missing-key notice, so fall
874 // through to the first provider that is actually configured, mirroring the
875 // Configured() gate in Config.ResolveModelWithFallback's fallback chain. An
876 // allowed chat default is preserved when every eligible provider is keyless so
877 // the existing missing-key notice still tells the user what to fix. When no
878 // desktop-accessible chat model exists, the empty result lets tab startup show
879 // an actionable setup error instead of re-admitting an ineligible default.
880 func resolveNewSessionModel(cfg *config.Config) string {
881 def := strings.TrimSpace(cfg.DefaultModel)
882 config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, def)
883 if resolved, _, ok := cfg.ResolveDesktopNewSessionModel(); ok {
884 // Keep provider identity explicit at the new-session boundary. A bare
885 // model id is ambiguous when two configured gateways expose the same
886 // model, and a provider-only ref otherwise compares unequal to the
887 // canonical ref stored on a running tab.
888 if entry, found := cfg.ResolveModel(resolved); found {
889 return entry.Name + "/" + entry.Model
890 }
891 return resolved
892 }
893 return ""
894 }
895
896 func (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {
897 model, toolApprovalMode := desktopNewSessionDefaults(scope, workspaceRoot)
898 return &WorkspaceTab{
899 ID: id,
900 Scope: scope,
901 WorkspaceRoot: workspaceRoot,
902 TopicID: topicID,
903 TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),
904 topicTitleSource: loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID),
905 model: model,
906 tokenMode: boot.TokenModeFull,
907 mode: tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
908 toolApprovalMode: toolApprovalMode,
909 disabledMCP: map[string]ServerView{},
910 }
911 }
912
913 func (a *App) snapshotAllTabs() {
914 a.mu.RLock()
915 tabs := a.runtimeTabsLocked()
916 a.mu.RUnlock()
917 for _, t := range tabs {
918 if err := a.snapshotTab(t); err != nil {
919 slog.Warn("desktop: snapshot all tabs failed", "tab", t.ID, "err", err)
920 }
921 }
922 }
923
924 // shutdown snapshots all tabs, saves the final window geometry, and closes tabs.
925 func (a *App) shutdown(context.Context) {
926 if a.remoteWindowTicket != "" {
927 // Remote web window child: nothing to snapshot or stop locally.
928 return
929 }
930 // A real quit also terminates surviving web windows: their tunnels die with
931 // this process, so a leftover window would only show a dead Serve page.
932 // The remote Serve itself stays resident by design. Background (tray)
933 // close never reaches shutdown and keeps the windows alive.
934 a.closeAllRemoteWindows()
935 // Run after controller teardown (and after its deferred lifecycle unlocks)
936 // so every accepted usage record reaches disk before a normal app exit.
937 defer func() {
938 flushCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
939 defer cancel()
940 _ = stats.Flush(flushCtx, config.StatsDir())
941 }()
942 a.stopDeferredRebuildRetry()
943 a.stopMainThreadWatchdog()
944 if a.heartbeat != nil {
945 a.heartbeat.Stop()
946 }
947 a.stopBotRuntime()
948 a.stopRemoteRuntime()
949 a.stopTray()
950 // Terminal process shutdown is independent from controller teardown. Do it
951 // before acquiring runtime lifecycle locks so a slow PTY cannot delay while
952 // holding locks used by Wails-bound chat calls.
953 if a.terminals != nil {
954 a.terminals.closeAll()
955 }
956 // Save window geometry synchronously from Go so it's persisted even if the
957 // frontend's beforeunload promise hasn't resolved yet.
958 a.saveWindowStateSync()
959 // Serialize shutdown with controller rebuilds and live MCP mutations. This
960 // uses the same lifecycle lock order as lockMCPMutation so launch authorization
961 // or reconnect cannot have its captured Host closed underneath it.
962 a.runtimeRebuildMu.Lock()
963 defer a.runtimeRebuildMu.Unlock()
964 a.runtimeAdmissionMu.Lock()
965 defer a.runtimeAdmissionMu.Unlock()
966 // Close every shared plugin host before releasing the lifecycle barrier,
967 // even if a tab cleanup panics.
968 defer a.closeAllSharedHosts()
969
970 a.mu.RLock()
971 tabs := a.runtimeTabsLocked()
972 type shutdownItem struct {
973 tab *WorkspaceTab
974 ctrl control.SessionAPI
975 readOnly bool
976 }
977 items := make([]shutdownItem, 0, len(tabs))
978 for _, t := range tabs {
979 if t.Ctrl != nil {
980 items = append(items, shutdownItem{tab: t, ctrl: t.Ctrl, readOnly: t.ReadOnly})
981 }
982 }
983 a.mu.RUnlock()
984 for _, it := range items {
985 if !it.readOnly {
986 if err := it.ctrl.SnapshotForShutdown(); err != nil {
987 slog.Warn("desktop: shutdown snapshot failed", "tab", it.tab.ID, "err", err)
988 }
989 }
990 it.ctrl.Close()
991 it.tab.releaseSessionLease()
992 a.mu.Lock()
993 a.releaseSessionRuntimeLocked(it.tab)
994 a.mu.Unlock()
995 }
996 if a.startupReady.Load() {
997 // A visible UI is sufficient health evidence even if the user closes the
998 // window before the delayed post-DOM task runs.
999 if err := a.commitPendingUpdateHealth(); err != nil {
1000 slog.Warn("desktop: commit healthy update during shutdown", "err", err)
1001 }
1002 if archived, err := archiveSupersededPendingUpdateAfterReady(); err != nil {
1003 slog.Warn("desktop: retire superseded update during shutdown", "err", err)
1004 } else if archived {
1005 slog.Info("desktop: archived superseded update transaction during shutdown")
1006 }
1007 // Independent last-known-good config snapshot after a successful UI session.
1008 _ = repair.RecordHealthyConfig(version)
1009 }
1010 }
1011
1012 // domReady is called (via OnDomReady) after the webview finishes loading its DOM
1013 // but before the window is shown (StartHidden). It restores the saved window
1014 // position and size, then calls WindowShow so the user never sees the default
1015 // size/position flash.
1016 func (a *App) domReady(_ context.Context) {
1017 // JSC has installed its lazy signal handlers by this point. Restore the
1018 // SA_ONSTACK flags required by Go; this is a no-op outside Linux.
1019 repairWebKitSignalHandlers()
1020
1021 if a.remoteWindowTicket != "" {
1022 a.domReadyRemoteWindow()
1023 return
1024 }
1025
1026 state, ok := loadWindowState()
1027 if ok {
1028 // Validate saved position against current screens. Wails v2 doesn't
1029 // expose per-screen origin (x,y offsets) so we can only do a basic
1030 // sanity check. Windows border insets (commonly x=-8,y=-8) are legal;
1031 // large off-screen positions (unplugged external display) re-center.
1032 maxW, maxH := 0, 0
1033 screens, err := runtime.ScreenGetAll(a.ctx)
1034 if err == nil {
1035 for _, sc := range screens {
1036 if sc.Size.Width > maxW {
1037 maxW = sc.Size.Width
1038 }
1039 if sc.Size.Height > maxH {
1040 maxH = sc.Size.Height
1041 }
1042 }
1043 }
1044 if windowPositionRestorable(state, maxW, maxH) {
1045 runtime.WindowSetPosition(a.ctx, state.X, state.Y)
1046 } else {
1047 runtime.WindowCenter(a.ctx)
1048 }
1049 } else {
1050 runtime.WindowCenter(a.ctx)
1051 }
1052
1053 if ok && state.Maximised {
1054 runtime.WindowMaximise(a.ctx)
1055 }
1056
1057 runtime.WindowShow(a.ctx)
1058 a.startupReady.Store(true)
1059 // Record last-known-good config after the UI is actually visible. This is
1060 // independent of any startup health probation or crash-loop policy.
1061 ctx := a.ctx
1062 a.goSafe("recordHealthyConfig", func() {
1063 timer := time.NewTimer(2 * time.Second)
1064 defer timer.Stop()
1065 select {
1066 case <-timer.C:
1067 case <-ctx.Done():
1068 return
1069 }
1070 if err := a.commitPendingUpdateHealth(); err != nil {
1071 slog.Warn("desktop: commit healthy update", "err", err)
1072 }
1073 if err := repair.RecordHealthyConfig(version); err != nil {
1074 slog.Debug("desktop: record last-known-good config", "err", err)
1075 }
1076 if archived, err := archiveSupersededPendingUpdateAfterReady(); err != nil {
1077 slog.Warn("desktop: retire superseded update", "err", err)
1078 } else if archived {
1079 slog.Info("desktop: archived superseded update transaction")
1080 }
1081 })
1082 }
1083
1084 func (a *App) commitPendingUpdateHealth() error {
1085 if a == nil || strings.TrimSpace(a.healthyUpdateCreatedAt) == "" ||
1086 strings.TrimSpace(a.healthyUpdateTransactionID) == "" {
1087 return nil
1088 }
1089 return markPendingUpdateHealthyAfterReady(
1090 version,
1091 a.healthyUpdateCreatedAt,
1092 a.healthyUpdateTransactionID,
1093 )
1094 }
1095
1096 // --- bound command surface (frontend → controller) ---
1097 // Each method guards on a nil controller so a pre-startup or failed-build call is
1098 // a no-op, never a panic.
1099
1100 // Submit runs raw user input as a turn; slash commands and @-references are
1101 // resolved by the controller. Output arrives asynchronously on eventChannel.
1102 func (a *App) Submit(input string) error {
1103 return a.SubmitToTab("", input)
1104 }
1105
1106 var errEmptyTurnInput = errors.New("message cannot be empty")
1107
1108 func validateTurnInput(input string) error {
1109 if strings.TrimSpace(input) == "" {
1110 return errEmptyTurnInput
1111 }
1112 return nil
1113 }
1114
1115 func (a *App) SubmitToTab(tabID, input string) error {
1116 if err := validateTurnInput(input); err != nil {
1117 return err
1118 }
1119 return a.submitToTab(tabID, input, false)
1120 }
1121
1122 // tabTurnAdmission owns both locks acquired while a foreground turn starts.
1123 // Call finish after invoking the controller; callers should also defer abort so
1124 // every early-return and recovered-panic path releases the admission exactly
1125 // once.
1126 type tabTurnAdmission struct {
1127 app *App
1128 tab *WorkspaceTab
1129 released bool
1130 }
1131
1132 func (admission *tabTurnAdmission) finish(ctrl control.SessionAPI) bool {
1133 if admission == nil || admission.released {
1134 return false
1135 }
1136 admission.released = true
1137 tab := admission.tab
1138 if tab != nil {
1139 // Release even if a controller implementation panics while reporting its
1140 // status. The caller's deferred abort sees released=true and cannot be the
1141 // fallback once finish has taken ownership of the release.
1142 defer admission.app.runtimeAdmissionMu.RUnlock()
1143 defer tab.turnStartMu.Unlock()
1144 }
1145 started := ctrl != nil && ctrl.RuntimeStatus().Running
1146 if !started && tab != nil && tab.sink != nil {
1147 tab.sink.cancelTurnStart()
1148 }
1149 return started
1150 }
1151
1152 func (admission *tabTurnAdmission) abort() {
1153 admission.finish(nil)
1154 }
1155
1156 // beginTabTurn locks the tab's foreground-turn admission gate and reserves the
1157 // event sink until TurnDone has completed all of its fan-out.
1158 func (a *App) beginTabTurn(tabID string, reclaim bool) (*tabTurnAdmission, control.SessionAPI, error) {
1159 tab, ctrl := a.tabAndCtrlByID(tabID)
1160 if a.tabIsReadOnly(tab) {
1161 return nil, nil, readOnlyChannelErr()
1162 }
1163 if err := a.workspaceRuntimeAdmissionErr(tab, ctrl); err != nil {
1164 return nil, nil, a.workspaceNotReadyErr(tab)
1165 }
1166 // Runtime work-admission barrier: held (shared) from here until the turn is
1167 // observably running and the returned admission token releases it, so an MCP
1168 // MCP authorization or plugin uninstall holding the write side either waits out
1169 // this admission or sees its work in the gated re-check. Never acquire
1170 // runtimeRebuildMu while this read lock is held.
1171 a.runtimeAdmissionMu.RLock()
1172 abort := func() {
1173 tab.turnStartMu.Unlock()
1174 a.runtimeAdmissionMu.RUnlock()
1175 }
1176 tab.turnStartMu.Lock()
1177 if a.tabIsReadOnly(tab) {
1178 abort()
1179 return nil, nil, readOnlyChannelErr()
1180 }
1181 if reclaim && a.botBridge != nil {
1182 a.botBridge.reclaimFromDesktop(tab.ID)
1183 }
1184 ctrl = a.controllerForTab(tab)
1185 if err := a.workspaceRuntimeAdmissionErr(tab, ctrl); err != nil {
1186 abort()
1187 return nil, nil, err
1188 }
1189 if err := a.ensureTabControllerWorkspaceAdmissionHeld(tab); err != nil {
1190 abort()
1191 return nil, nil, err
1192 }
1193 ctrl = a.controllerForTab(tab)
1194 if err := a.workspaceRuntimeAdmissionErr(tab, ctrl); err != nil {
1195 abort()
1196 return nil, nil, err
1197 }
1198 if ctrl.RuntimeStatus().Running || (tab.sink != nil && !tab.sink.tryBeginTurn()) {
1199 abort()
1200 return nil, nil, control.ErrTurnRunning
1201 }
1202 return &tabTurnAdmission{app: a, tab: tab}, ctrl, nil
1203 }
1204
1205 // submitToTab is the shared submit body. fromBridge marks submissions driven
1206 // by the IM takeover bridge; local (frontend) submissions on a taken-over tab
1207 // reclaim remote control first — typing locally is the grab-back gesture.
1208 func (a *App) submitToTab(tabID, input string, fromBridge bool) error {
1209 trimmed := strings.TrimSpace(input)
1210 if trimmed == "/effort" || strings.HasPrefix(trimmed, "/effort ") {
1211 tab, _ := a.tabAndCtrlByID(tabID)
1212 if a.tabIsReadOnly(tab) {
1213 return readOnlyChannelErr()
1214 }
1215 if tab == nil {
1216 return a.workspaceNotReadyErr(tab)
1217 }
1218 if !fromBridge && a.botBridge != nil {
1219 a.botBridge.reclaimFromDesktop(tab.ID)
1220 }
1221 a.runEffortCommandForTab(tabID, trimmed)
1222 return nil
1223 }
1224 admission, ctrl, err := a.beginTabTurn(tabID, !fromBridge)
1225 if err != nil {
1226 return err
1227 }
1228 defer admission.abort()
1229 tab := admission.tab
1230 a.ensureTabTopicIndexedForUserTurn(tab)
1231 ctrl.SubmitDisplay(input, input)
1232 admission.finish(ctrl)
1233 return nil
1234 }
1235
1236 func (a *App) submitUserTurnToTabWithSink(tabID, input string, forwarder event.Sink) bool {
1237 admission, ctrl, err := a.beginTabTurn(tabID, false)
1238 if err != nil {
1239 return false
1240 }
1241 defer admission.abort()
1242 tab := admission.tab
1243 var generation uint64
1244 if forwarder != nil {
1245 generation = tab.sink.SetBotSink(forwarder)
1246 }
1247 a.ensureTabTopicIndexedForUserTurn(tab)
1248 ctrl.SubmitUserTurn(input, input)
1249 started := admission.finish(ctrl)
1250 if !started && forwarder != nil {
1251 tab.sink.clearBotSink(generation)
1252 }
1253 return started
1254 }
1255
1256 // RunShell executes a shell command directly (bypassing the model) and streams
1257 // output as events on eventChannel.
1258 func (a *App) RunShell(command string) error {
1259 return a.RunShellForTab("", command)
1260 }
1261
1262 func (a *App) RunShellForTab(tabID, command string) error {
1263 admission, ctrl, err := a.beginTabTurn(tabID, true)
1264 if err != nil {
1265 return err
1266 }
1267 defer admission.abort()
1268 tab := admission.tab
1269 a.ensureTabTopicIndexedForUserTurn(tab)
1270 ctrl.RunShell(command)
1271 admission.finish(ctrl)
1272 return nil
1273 }
1274
1275 // SubmitDisplay runs input as a turn while recording a shorter UI-only display
1276 // string for the saved desktop transcript. The model still receives input.
1277 func (a *App) SubmitDisplay(display, input string) error {
1278 return a.SubmitDisplayToTab("", display, input)
1279 }
1280
1281 func (a *App) SubmitDisplayToTab(tabID, display, input string) error {
1282 if err := validateTurnInput(input); err != nil {
1283 return err
1284 }
1285 admission, ctrl, err := a.beginTabTurn(tabID, true)
1286 if err != nil {
1287 return err
1288 }
1289 defer admission.abort()
1290 tab := admission.tab
1291 a.ensureTabTopicIndexedForUserTurn(tab)
1292 ctrl.SubmitDisplay(display, input)
1293 admission.finish(ctrl)
1294 return nil
1295 }
1296
1297 func (a *App) SubmitDeliveryRecoveryToTab(tabID, display, input string) error {
1298 if err := validateTurnInput(input); err != nil {
1299 return err
1300 }
1301 admission, ctrl, err := a.beginTabTurn(tabID, true)
1302 if err != nil {
1303 return err
1304 }
1305 defer admission.abort()
1306 tab := admission.tab
1307 a.ensureTabTopicIndexedForUserTurn(tab)
1308 ctrl.SubmitDeliveryRecovery(display, input)
1309 admission.finish(ctrl)
1310 return nil
1311 }
1312
1313 // InvocationRequest is the Wails-bound form of a composer invocation entity.
1314 type InvocationRequest struct {
1315 Name string `json:"name"`
1316 Kind string `json:"kind"`
1317 Offset int `json:"offset"`
1318 }
1319
1320 func controlInvocationRequests(invocations []InvocationRequest) []control.InvocationRequest {
1321 out := make([]control.InvocationRequest, 0, len(invocations))
1322 for _, invocation := range invocations {
1323 out = append(out, control.InvocationRequest{
1324 Name: invocation.Name, Kind: invocation.Kind, Offset: invocation.Offset,
1325 })
1326 }
1327 return out
1328 }
1329
1330 func (a *App) SubmitInvocationsToTab(tabID, display, input string, invocations []InvocationRequest) error {
1331 if err := validateInvocationTurnInput(input, invocations); err != nil {
1332 return err
1333 }
1334 admission, ctrl, err := a.beginTabTurn(tabID, true)
1335 if err != nil {
1336 return err
1337 }
1338 defer admission.abort()
1339 tab := admission.tab
1340 a.ensureTabTopicIndexedForUserTurn(tab)
1341 ctrl.SubmitInvocationDisplay(display, input, controlInvocationRequests(invocations))
1342 admission.finish(ctrl)
1343 return nil
1344 }
1345
1346 func validateInvocationTurnInput(input string, invocations []InvocationRequest) error {
1347 // A skill-only turn legitimately has no explicit task: the resolved
1348 // invocation content becomes the provider input. Without an invocation,
1349 // keep the same empty-input protection as every other submit path.
1350 if len(invocations) > 0 {
1351 return nil
1352 }
1353 return validateTurnInput(input)
1354 }
1355
1356 func (a *App) submitInitialGoalToLocalTab(
1357 tabID, toolApprovalMode, goal, display, input string,
1358 invocations []InvocationRequest,
1359 ) ([]string, error) {
1360 admission, ctrl, err := a.beginTabTurn(tabID, true)
1361 if err != nil {
1362 return []string{}, err
1363 }
1364 defer admission.abort()
1365
1366 tab := admission.tab
1367 toolApprovalMode = normalizeToolApprovalMode(toolApprovalMode)
1368 goal = strings.TrimSpace(goal)
1369 if goal == "" {
1370 return []string{}, fmt.Errorf("goal is required")
1371 }
1372 a.mu.Lock()
1373 if a.tabs[tab.ID] != tab {
1374 a.mu.Unlock()
1375 return []string{}, a.workspaceNotReadyErr(nil)
1376 }
1377 tab.toolApprovalMode = toolApprovalMode
1378 tab.goal = goal
1379 tab.mode = tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo)
1380 a.saveTabsLocked()
1381 a.mu.Unlock()
1382
1383 ctrl.SetPlanMode(false)
1384 drained := applyTabToolApprovalModeToController(ctrl, toolApprovalMode)
1385 syncTabGoalToController(ctrl, goal)
1386 a.ensureTabTopicIndexedForUserTurn(tab)
1387 if len(invocations) > 0 {
1388 ctrl.SubmitInvocationDisplay(display, input, controlInvocationRequests(invocations))
1389 } else {
1390 ctrl.SubmitDisplay(display, input)
1391 }
1392 admission.finish(ctrl)
1393 return drained, nil
1394 }
1395
1396 // SubmitInitialGoalToTab activates a Goal and submits its first turn on the
1397 // requested tab.
1398 func (a *App) SubmitInitialGoalToTab(
1399 tabID, goal, display, input string,
1400 invocations []InvocationRequest,
1401 collaborationMode, toolApprovalMode string,
1402 ) ([]string, error) {
1403 if err := validateInvocationTurnInput(input, invocations); err != nil {
1404 return []string{}, err
1405 }
1406 return a.submitInitialGoalToLocalTab(
1407 tabID, toolApprovalMode, goal, display, input, invocations,
1408 )
1409 }
1410
1411 func (a *App) SubmitEditedDisplayToTab(tabID, display, input, original string) error {
1412 if err := validateTurnInput(input); err != nil {
1413 return err
1414 }
1415 admission, ctrl, err := a.beginTabTurn(tabID, true)
1416 if err != nil {
1417 return err
1418 }
1419 defer admission.abort()
1420 tab := admission.tab
1421 a.ensureTabTopicIndexedForUserTurn(tab)
1422 ctrl.SubmitEditedDisplay(display, input, original)
1423 admission.finish(ctrl)
1424 return nil
1425 }
1426
1427 func (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {
1428 if ctrl == nil {
1429 return
1430 }
1431 ctrl.SetDisplayRecorder(func(content, display string) {
1432 dir := ctrl.SessionDir()
1433 if dir == "" {
1434 dir = config.SessionDir()
1435 }
1436 _ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)
1437 })
1438 }
1439
1440 // Cancel aborts the in-flight turn.
1441 func (a *App) Cancel() {
1442 a.CancelTab("")
1443 }
1444
1445 func (a *App) CancelTab(tabID string) {
1446 if ctrl := a.ctrlByTabID(tabID); ctrl != nil {
1447 ctrl.Cancel()
1448 }
1449 }
1450
1451 // Steer sends mid-turn guidance to the agent without interrupting the in-flight request.
1452 func (a *App) Steer(text string) error {
1453 return a.SteerForTab("", text)
1454 }
1455
1456 // SteerForTab sends mid-turn guidance to a specific tab's active agent turn.
1457 // A rejected steer is returned to the frontend so its guidance shelf retains
1458 // the text and submits it as a regular follow-up after the turn completes.
1459 func (a *App) SteerForTab(tabID, text string) error {
1460 tab, ctrl := a.tabAndCtrlByID(tabID)
1461 if a.tabIsReadOnly(tab) {
1462 return readOnlyChannelErr()
1463 }
1464 if ctrl == nil {
1465 return a.workspaceNotReadyErr(tab)
1466 }
1467 if err := a.ensureTabControllerWorkspace(tab); err != nil {
1468 return err
1469 }
1470 ctrl = a.controllerForTab(tab)
1471 if ctrl == nil {
1472 return a.workspaceNotReadyErr(tab)
1473 }
1474 steerer, ok := ctrl.(interface{ TrySteer(string) bool })
1475 if !ok {
1476 return fmt.Errorf("this runtime cannot accept mid-turn guidance")
1477 }
1478 if !steerer.TrySteer(text) {
1479 return fmt.Errorf("the turn ended before guidance could be applied; it will remain queued for the next turn")
1480 }
1481 return nil
1482 }
1483
1484 func (a *App) tabAndCtrlByID(tabID string) (*WorkspaceTab, control.SessionAPI) {
1485 a.mu.RLock()
1486 tab := a.tabByIDLocked(tabID)
1487 if tab == nil {
1488 a.mu.RUnlock()
1489 return nil, nil
1490 }
1491 ctrl := tab.Ctrl
1492 retryStartup := ctrl == nil && tab.StartupErrLeaseHeld
1493 a.mu.RUnlock()
1494 if retryStartup && a.tryRecoverStartupLeaseHeldTab(tab) {
1495 a.mu.RLock()
1496 defer a.mu.RUnlock()
1497 if a.tabs[tab.ID] != tab {
1498 return nil, nil
1499 }
1500 return tab, tab.Ctrl
1501 }
1502 return tab, ctrl
1503 }
1504
1505 // activeTabAndCtrl snapshots the active tab and its controller in one locked
1506 // read, so callers never do a check-then-use on tab.Ctrl after the lock is
1507 // released (a rebuild can swap the controller in between).
1508 func (a *App) activeTabAndCtrl() (*WorkspaceTab, control.SessionAPI) {
1509 a.mu.RLock()
1510 defer a.mu.RUnlock()
1511 tab := a.activeTabLocked()
1512 if tab == nil {
1513 return nil, nil
1514 }
1515 return tab, tab.Ctrl
1516 }
1517
1518 // activeMCPRuntime snapshots the complete target of a Wails MCP action in one
1519 // critical section. MCP operations may outlive a frontend tab switch; carrying
1520 // the invoking workspace root prevents config/authorization reads from drifting to the
1521 // newly active tab while controller calls still target the original runtime.
1522 func (a *App) activeMCPRuntime() (*WorkspaceTab, control.SessionAPI, string) {
1523 a.mu.RLock()
1524 defer a.mu.RUnlock()
1525 tab := a.activeTabLocked()
1526 if tab == nil {
1527 return nil, nil, ""
1528 }
1529 return tab, tab.Ctrl, tab.WorkspaceRoot
1530 }
1531
1532 func (a *App) controllerForTab(tab *WorkspaceTab) control.SessionAPI {
1533 if tab == nil {
1534 return nil
1535 }
1536 a.mu.RLock()
1537 defer a.mu.RUnlock()
1538 if tab.ID != "" && a.tabs[tab.ID] != tab {
1539 return nil
1540 }
1541 return tab.Ctrl
1542 }
1543
1544 // currentSessionPathFor is the locked form of tab.currentSessionPath: it
1545 // snapshots Ctrl/SessionPath under a.mu, then queries the controller off-lock.
1546 // Use it on paths that do not otherwise hold a.mu.
1547 func (a *App) currentSessionPathFor(tab *WorkspaceTab) string {
1548 if tab == nil {
1549 return ""
1550 }
1551 a.mu.RLock()
1552 ctrl := tab.Ctrl
1553 fallback := strings.TrimSpace(tab.SessionPath)
1554 a.mu.RUnlock()
1555 if ctrl != nil {
1556 if path := strings.TrimSpace(ctrl.SessionPath()); path != "" {
1557 return path
1558 }
1559 }
1560 return fallback
1561 }
1562
1563 // sessionDirForSnapshot mirrors tabSessionDir for callers that hold a
1564 // tabRuntimeSnapshot instead of reading the live tab.
1565 func sessionDirForSnapshot(s tabRuntimeSnapshot) string {
1566 if s.workspaceRoot != "" {
1567 return desktopSessionDir(s.workspaceRoot)
1568 }
1569 if s.ctrl != nil {
1570 if dir := s.ctrl.SessionDir(); dir != "" {
1571 return dir
1572 }
1573 }
1574 return desktopSessionDir("")
1575 }
1576
1577 func readOnlyChannelErr() error {
1578 return fmt.Errorf("channel session is read-only")
1579 }
1580
1581 func (a *App) snapshotTab(tab *WorkspaceTab) error {
1582 if tab == nil {
1583 return nil
1584 }
1585 a.mu.RLock()
1586 readOnly := tab.ReadOnly
1587 ctrl := tab.Ctrl
1588 a.mu.RUnlock()
1589 if readOnly || ctrl == nil {
1590 return nil
1591 }
1592 return ctrl.Snapshot()
1593 }
1594
1595 func (a *App) snapshotTabForAction(tab *WorkspaceTab, action string) error {
1596 if err := a.snapshotTab(tab); err != nil {
1597 a.reportTabSnapshotError(tab, action, err)
1598 if strings.TrimSpace(action) == "" {
1599 return fmt.Errorf("save current session: %w", err)
1600 }
1601 return fmt.Errorf("save current session before %s: %w", action, err)
1602 }
1603 return nil
1604 }
1605
1606 func (a *App) reportTabSnapshotError(tab *WorkspaceTab, action string, err error) {
1607 if err == nil {
1608 return
1609 }
1610 tabID := ""
1611 if tab != nil {
1612 tabID = tab.ID
1613 }
1614 slog.Warn("desktop: session snapshot failed", "tab", tabID, "action", action, "err", err)
1615 if tab == nil || tab.sink == nil {
1616 return
1617 }
1618 // Autosave fires once per turn; on a persistently failing disk that would
1619 // stream a chat warning after every turn. Rate-limit the user-facing
1620 // notice per tab (the slog line above always records every failure). Saves
1621 // triggered by an explicit action are one-shot and always surface.
1622 if action == "autosave" {
1623 tab.saveMu.Lock()
1624 now := time.Now()
1625 if !tab.lastAutosaveWarnAt.IsZero() && now.Sub(tab.lastAutosaveWarnAt) < autosaveWarnInterval {
1626 tab.saveMu.Unlock()
1627 return
1628 }
1629 tab.lastAutosaveWarnAt = now
1630 tab.saveMu.Unlock()
1631 }
1632 prefix := "Session autosave failed"
1633 if strings.TrimSpace(action) != "" && action != "autosave" {
1634 prefix = "Session save failed before " + action
1635 }
1636 tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: prefix + ": " + err.Error()})
1637 }
1638
1639 func (a *App) reconciledSessionPathForTab(tab *WorkspaceTab) string {
1640 if tab == nil {
1641 return ""
1642 }
1643 path, _ := a.reconcileTabWithPinnedSessionMeta(tab)
1644 if ctrl := a.controllerForTab(tab); path == "" && ctrl != nil {
1645 path = ctrl.SessionPath()
1646 }
1647 return path
1648 }
1649
1650 func (a *App) ensureTabControllerWorkspace(tab *WorkspaceTab) error {
1651 a.runtimeAdmissionMu.RLock()
1652 defer a.runtimeAdmissionMu.RUnlock()
1653 return a.ensureTabControllerWorkspaceAdmissionHeld(tab)
1654 }
1655
1656 // ensureTabControllerWorkspaceAdmissionHeld repairs a stale controller binding
1657 // while the caller holds either side of runtimeAdmissionMu. The repair may build
1658 // a controller synchronously, so it must not recursively acquire the read side:
1659 // sync.RWMutex blocks new readers once a writer is queued.
1660 func (a *App) ensureTabControllerWorkspaceAdmissionHeld(tab *WorkspaceTab) error {
1661 if tab == nil {
1662 return nil
1663 }
1664 tab.reconcileMu.Lock()
1665 defer tab.reconcileMu.Unlock()
1666
1667 a.mu.RLock()
1668 current := a.tabs[tab.ID]
1669 ctrl := tab.Ctrl
1670 readOnly := tab.ReadOnly
1671 a.mu.RUnlock()
1672 if current != tab || ctrl == nil || readOnly {
1673 return nil
1674 }
1675 if controllerHasActiveRuntimeWork(ctrl) {
1676 return nil
1677 }
1678 path, hasBinding := a.reconcileTabWithPinnedSessionMeta(tab)
1679 desiredRoot := strings.TrimSpace(tab.WorkspaceRoot)
1680 ctrlRoot, rootOK := safeControllerWorkspaceRoot(ctrl)
1681 ctrlDir, dirOK := safeControllerSessionDir(ctrl)
1682 if !rootOK || !dirOK {
1683 return nil
1684 }
1685 if !hasBinding {
1686 if desiredRoot == "" || strings.TrimSpace(ctrlRoot) == "" || sameDesktopPath(ctrlRoot, desiredRoot) {
1687 return nil
1688 }
1689 }
1690 desiredDir := tabSessionDir(tab)
1691 rootMatches := desiredRoot == "" || sameDesktopPath(ctrlRoot, desiredRoot)
1692 dirMatches := desiredDir == "" || sameDesktopPath(ctrlDir, desiredDir)
1693 if !dirMatches && path != "" {
1694 if validPath, _, err := validateSessionPath(ctrlDir, path); err == nil && sessionRuntimeKey(validPath) == sessionRuntimeKey(path) {
1695 dirMatches = true
1696 }
1697 }
1698 if strings.TrimSpace(ctrlRoot) == "" && dirMatches {
1699 rootMatches = true
1700 }
1701 if tab.Scope == "global" {
1702 if strings.TrimSpace(ctrlRoot) == "" {
1703 rootMatches = true
1704 }
1705 if sameDesktopPath(ctrlDir, config.SessionDir()) || sameDesktopPath(ctrlDir, desktopSessionDir(globalWorkspaceRoot())) {
1706 dirMatches = true
1707 }
1708 }
1709 sessionMatches := path == "" || sessionRuntimeKey(ctrl.SessionPath()) == sessionRuntimeKey(path)
1710 if rootMatches && dirMatches && sessionMatches {
1711 return nil
1712 }
1713 if err := ctrl.Snapshot(); err != nil {
1714 return err
1715 }
1716 ctrl.Close()
1717
1718 a.mu.Lock()
1719 var hostKey string
1720 if current := a.tabs[tab.ID]; current == tab {
1721 tab.Ctrl = nil
1722 tab.Ready = false
1723 clearTabStartupError(tab)
1724 tab.ActivityStatus = ""
1725 if tab.sink == nil {
1726 tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
1727 }
1728 hostKey = takeTabSharedHostKey(tab)
1729 a.saveTabsLocked()
1730 }
1731 a.mu.Unlock()
1732 if hostKey != "" {
1733 a.releaseSharedHost(hostKey)
1734 }
1735
1736 a.buildTabControllerAdmissionHeld(tab)
1737 if tab.Ctrl == nil {
1738 if tab.StartupErr != "" {
1739 return fmt.Errorf("workspace failed to restart with corrected root: %s", tab.StartupErr)
1740 }
1741 return fmt.Errorf("workspace failed to restart with corrected root")
1742 }
1743 return nil
1744 }
1745
1746 func safeControllerWorkspaceRoot(ctrl control.SessionAPI) (root string, ok bool) {
1747 if ctrl == nil {
1748 return "", false
1749 }
1750 defer func() {
1751 if recover() != nil {
1752 root = ""
1753 ok = false
1754 }
1755 }()
1756 return ctrl.WorkspaceRoot(), true
1757 }
1758
1759 func safeControllerSessionDir(ctrl control.SessionAPI) (dir string, ok bool) {
1760 if ctrl == nil {
1761 return "", false
1762 }
1763 defer func() {
1764 if recover() != nil {
1765 dir = ""
1766 ok = false
1767 }
1768 }()
1769 return ctrl.SessionDir(), true
1770 }
1771
1772 // Approve answers a pending approval_request by ID: allow runs the call, session
1773 // also remembers the grant for the rest of the session.
1774 func (a *App) Approve(id string, allow, session, persist bool) {
1775 ctrl := a.ctrlByTabID("")
1776 if ctrl != nil {
1777 ctrl.Approve(id, allow, session, persist)
1778 }
1779 }
1780
1781 // ApproveTab is like Approve but scoped to a specific tab.
1782 func (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {
1783 ctrl := a.ctrlForRuntimeTabID(tabID)
1784 if ctrl != nil {
1785 ctrl.Approve(id, allow, session, persist)
1786 }
1787 }
1788
1789 // ResolvePlanDecision answers a Plan card while preserving whether the user
1790 // chose to start execution, revise the plan, or exit without executing.
1791 func (a *App) ResolvePlanDecision(id, action string) error {
1792 ctrl := a.ctrlByTabID("")
1793 if ctrl == nil {
1794 return fmt.Errorf("no active session")
1795 }
1796 return ctrl.ResolvePlanDecision(id, control.PlanDecisionAction(action))
1797 }
1798
1799 // ResolvePlanDecisionTab is like ResolvePlanDecision but scoped to a runtime
1800 // tab so a delayed bridge call cannot answer a prompt in another tab.
1801 func (a *App) ResolvePlanDecisionTab(tabID, id, action string) error {
1802 ctrl := a.ctrlForRuntimeTabID(tabID)
1803 if ctrl == nil {
1804 return fmt.Errorf("no active session")
1805 }
1806 return ctrl.ResolvePlanDecision(id, control.PlanDecisionAction(action))
1807 }
1808
1809 // ResolveRecovery answers an Auto Guard card. action is continue|revise. For
1810 // revise, feedback is steered into the
1811 // agent and the pending mutation is refused in the same operation.
1812 func (a *App) ResolveRecovery(id, action, feedback string) error {
1813 return a.ResolveRecoveryTab("", id, action, feedback)
1814 }
1815
1816 // ResolveRecoveryTab is like ResolveRecovery but scoped to a specific tab.
1817 func (a *App) ResolveRecoveryTab(tabID, id, action, feedback string) error {
1818 ctrl := a.ctrlByTabID(tabID)
1819 if ctrl == nil {
1820 return fmt.Errorf("no active session")
1821 }
1822 return ctrl.ResolveRecovery(id, agent.RecoveryAction(action), feedback)
1823 }
1824
1825 // SetRecoveryCheckpointEnabled is retained as a no-op Wails surface for older
1826 // generated frontends. Auto Guard is always built into Auto.
1827 func (a *App) SetRecoveryCheckpointEnabled(_ bool) {}
1828
1829 // SetRecoveryCheckpointEnabledTab is retained as a no-op Wails surface.
1830 func (a *App) SetRecoveryCheckpointEnabledTab(_ string, _ bool) {}
1831
1832 // RecoveryCheckpointEnabled is retained for older generated frontends. Auto
1833 // Guard is always built into Auto, so it always reports true.
1834 func (a *App) RecoveryCheckpointEnabled() bool {
1835 return true
1836 }
1837
1838 // RecoveryCheckpointEnabledTab is the tab-scoped compatibility alias.
1839 func (a *App) RecoveryCheckpointEnabledTab(_ string) bool {
1840 return true
1841 }
1842
1843 // ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask
1844 // prompt that is currently blocking its run loop. The frontend calls this once
1845 // its event subscription is live (on load/reconnect) so a session that was
1846 // already awaiting confirmation rebuilds its modal instead of showing a
1847 // "waiting" status with no way to answer — and no way to stop.
1848 func (a *App) ReplayPendingPrompts() {
1849 a.mu.RLock()
1850 tabs := a.runtimeTabsLocked()
1851 ctrls := make([]control.SessionAPI, 0, len(tabs))
1852 for _, t := range tabs {
1853 if t.Ctrl != nil {
1854 ctrls = append(ctrls, t.Ctrl)
1855 }
1856 }
1857 a.mu.RUnlock()
1858 for _, ctrl := range ctrls {
1859 ctrl.ReplayPendingPrompts()
1860 }
1861 }
1862
1863 // SetPlanMode toggles the plan-first workflow while preserving the current
1864 // tool-approval posture and sandbox settings.
1865 func (a *App) SetPlanMode(on bool) {
1866 a.setPlanModeForTab("", on)
1867 }
1868
1869 func (a *App) setPlanModeForTab(tabID string, on bool) {
1870 if on {
1871 a.SetCollaborationModeForTab(tabID, "plan")
1872 return
1873 }
1874 a.SetCollaborationModeForTab(tabID, "normal")
1875 }
1876
1877 // SetMode applies a composer gating mode ("plan" | "yolo" | "plan-yolo" |
1878 // anything else =
1879 // normal) in one call, so a turn submitted right after the switch can't race a
1880 // half-applied plan/tool-auto-approval pair.
1881 func (a *App) SetMode(mode string) {
1882 a.SetModeForTab("", mode)
1883 }
1884
1885 // SetModeForTab returns the pending approval prompt ids the switch
1886 // auto-allowed, so the frontend dismisses exactly those cards and keeps the
1887 // ones the backend still holds (plan/memory/sandbox-escape never drain, and
1888 // auto keeps approvals an allow policy would not cover — #6432).
1889 func (a *App) SetModeForTab(tabID, mode string) []string {
1890 tab := a.tabByID(tabID)
1891 if tab == nil {
1892 return nil
1893 }
1894 tab.turnStartMu.Lock()
1895 defer tab.turnStartMu.Unlock()
1896 normalized := normalizeTabMode(mode)
1897 a.mu.Lock()
1898 if a.tabs[tab.ID] != tab {
1899 a.mu.Unlock()
1900 return nil
1901 }
1902 tab.mode = normalized
1903 tab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)
1904 if tabModeHasAutoApproveTools(normalized) {
1905 tab.toolApprovalMode = control.ToolApprovalYolo
1906 } else if tab.toolApprovalMode == control.ToolApprovalYolo {
1907 tab.toolApprovalMode = control.ToolApprovalAsk
1908 }
1909 ctrl := tab.Ctrl
1910 approvalMode := tab.toolApprovalMode
1911 tabIDForSave := tab.ID
1912 a.mu.Unlock()
1913 drained := applyTabModeToController(ctrl, normalized)
1914 drained = append(drained, applyTabToolApprovalModeToController(ctrl, approvalMode)...)
1915 a.mu.Lock()
1916 if a.tabs[tabIDForSave] == tab {
1917 a.saveTabsLocked()
1918 }
1919 a.mu.Unlock()
1920 return drained
1921 }
1922
1923 // modeApplier / toolApprovalApplier are the drained-id-reporting variants of
1924 // SessionAPI's SetMode / SetToolApprovalMode. Asserted optionally so test
1925 // fakes implementing the plain SessionAPI keep compiling (they report nil).
1926 type modeApplier interface {
1927 ApplyMode(plan, autoApproveTools bool) []string
1928 }
1929
1930 type toolApprovalApplier interface {
1931 ApplyToolApprovalMode(mode string) []string
1932 }
1933
1934 func applyTabModeToController(ctrl control.SessionAPI, mode string) []string {
1935 if ctrl == nil {
1936 return nil
1937 }
1938 plan, yolo := false, false
1939 switch normalizeTabMode(mode) {
1940 case "plan":
1941 plan = true
1942 case "yolo":
1943 yolo = true
1944 case "plan-yolo":
1945 plan, yolo = true, true
1946 }
1947 if applier, ok := ctrl.(modeApplier); ok {
1948 return applier.ApplyMode(plan, yolo)
1949 }
1950 ctrl.SetMode(plan, yolo)
1951 return nil
1952 }
1953
1954 func applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) []string {
1955 if ctrl == nil {
1956 return nil
1957 }
1958 mode = normalizeToolApprovalMode(mode)
1959 if applier, ok := ctrl.(toolApprovalApplier); ok {
1960 return applier.ApplyToolApprovalMode(mode)
1961 }
1962 ctrl.SetToolApprovalMode(mode)
1963 return nil
1964 }
1965
1966 func normalizeCollaborationMode(mode string) string {
1967 switch strings.ToLower(strings.TrimSpace(mode)) {
1968 case "plan":
1969 return "plan"
1970 case "goal":
1971 return "goal"
1972 default:
1973 return "normal"
1974 }
1975 }
1976
1977 func (a *App) SetCollaborationMode(mode string) {
1978 a.SetCollaborationModeForTab("", mode)
1979 }
1980
1981 // SetComposerProfileForTab applies the controller-facing profile axes under one
1982 // turn gate. Frontends use this before submit and after controller rebuilds so a
1983 // turn cannot observe collaboration, approval, and goal from different UI
1984 // generations.
1985 func (a *App) SetComposerProfileForTab(tabID, collaborationMode, toolApprovalMode, goal string) ([]string, error) {
1986 collaborationMode = normalizeCollaborationMode(collaborationMode)
1987 toolApprovalMode = normalizeToolApprovalMode(toolApprovalMode)
1988 goal = strings.TrimSpace(goal)
1989
1990 tab := a.tabByID(tabID)
1991 if tab == nil {
1992 return []string{}, fmt.Errorf("tab is no longer available")
1993 }
1994 tab.turnStartMu.Lock()
1995 defer tab.turnStartMu.Unlock()
1996
1997 a.mu.Lock()
1998 if a.tabs[tab.ID] != tab {
1999 a.mu.Unlock()
2000 return []string{}, fmt.Errorf("tab is no longer available")
2001 }
2002 tab.toolApprovalMode = toolApprovalMode
2003 if goal != "" {
2004 tab.goal = goal
2005 tab.mode = tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo)
2006 } else {
2007 tab.goal = ""
2008 tab.mode = tabModeFromAxes(collaborationMode == "plan", toolApprovalMode == control.ToolApprovalYolo)
2009 }
2010 ctrl := tab.Ctrl
2011 mode := tab.mode
2012 goal = tab.goal
2013 tabIDForSave := tab.ID
2014 a.mu.Unlock()
2015
2016 if ctrl != nil {
2017 ctrl.SetPlanMode(tabModeHasPlan(mode))
2018 }
2019 drained := applyTabToolApprovalModeToController(ctrl, toolApprovalMode)
2020 syncTabGoalToController(ctrl, goal)
2021
2022 a.mu.Lock()
2023 if a.tabs[tabIDForSave] == tab {
2024 a.saveTabsLocked()
2025 }
2026 a.mu.Unlock()
2027 if drained == nil {
2028 return []string{}, nil
2029 }
2030 return drained, nil
2031 }
2032
2033 func (a *App) SetCollaborationModeForTab(tabID, mode string) {
2034 tab := a.tabByID(tabID)
2035 if tab == nil {
2036 return
2037 }
2038 tab.turnStartMu.Lock()
2039 defer tab.turnStartMu.Unlock()
2040 mode = normalizeCollaborationMode(mode)
2041 approvalMode := a.tabRuntimeSnapshot(tab).currentToolApprovalMode()
2042 a.mu.Lock()
2043 if a.tabs[tab.ID] != tab {
2044 a.mu.Unlock()
2045 return
2046 }
2047 switch mode {
2048 case "plan":
2049 tab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)
2050 tab.goal = ""
2051 case "goal":
2052 tab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)
2053 default:
2054 tab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)
2055 tab.goal = ""
2056 }
2057 ctrl := tab.Ctrl
2058 goal := tab.goal
2059 plan := tabModeHasPlan(tab.mode)
2060 tabIDForSave := tab.ID
2061 a.mu.Unlock()
2062 if ctrl != nil {
2063 ctrl.SetPlanMode(plan)
2064 syncTabGoalToController(ctrl, goal)
2065 }
2066 a.mu.Lock()
2067 if a.tabs[tabIDForSave] == tab {
2068 a.saveTabsLocked()
2069 }
2070 a.mu.Unlock()
2071 }
2072
2073 // QuestionAnswer is the frontend's reply to one question in an ask_request.
2074 type QuestionAnswer struct {
2075 QuestionID string `json:"questionId"`
2076 Selected []string `json:"selected"`
2077 }
2078
2079 // AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the
2080 // user's selections per question.
2081 func (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {
2082 a.AnswerQuestionForTab("", id, answers)
2083 }
2084
2085 func (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {
2086 ctrl := a.ctrlByTabID(tabID)
2087 if ctrl == nil {
2088 return
2089 }
2090 out := make([]event.AskAnswer, len(answers))
2091 for i, an := range answers {
2092 out[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}
2093 }
2094 ctrl.AnswerQuestion(id, out)
2095 }
2096
2097 // Compact runs a plain compaction pass (the "compact now" button). Focus-guided
2098 // compaction goes through Submit("/compact <focus>") instead.
2099 func (a *App) Compact() error {
2100 return a.CompactForTab("")
2101 }
2102
2103 // CompactForTab compacts the requested tab without depending on which tab is
2104 // focused when the asynchronous frontend call reaches the backend.
2105 func (a *App) CompactForTab(tabID string) error {
2106 tab, ctrl := a.tabAndCtrlByID(tabID)
2107 if a.tabIsReadOnly(tab) {
2108 return readOnlyChannelErr()
2109 }
2110 if ctrl == nil {
2111 return nil
2112 }
2113 if err := a.ensureTabControllerWorkspace(tab); err != nil {
2114 return err
2115 }
2116 ctrl = a.controllerForTab(tab)
2117 if ctrl == nil {
2118 return nil
2119 }
2120 return ctrl.Compact(a.ctx, "")
2121 }
2122
2123 // workspaceNotReadyErr names why a session action arrived before the tab's
2124 // controller existed: still starting, or failed to start. Silently returning
2125 // nil here swallowed the click with no feedback (#3938).
2126 //
2127 // This is the bound-method form: StartupErr is written under a.mu by the
2128 // build goroutine while Submit-family calls race it, so read it under the
2129 // lock. Callers must not hold a.mu.
2130 func (a *App) workspaceNotReadyErr(tab *WorkspaceTab) error {
2131 a.mu.RLock()
2132 defer a.mu.RUnlock()
2133 return a.workspaceNotReadyErrLocked(tab)
2134 }
2135
2136 func (a *App) workspaceNotReadyErrLocked(tab *WorkspaceTab) error {
2137 startupErr := ""
2138 var issue *SessionRuntimeIssue
2139 if tab != nil {
2140 startupErr = tab.StartupErr
2141 issue = a.sessionRuntimeViewLocked(tab).Issue
2142 }
2143 if strings.TrimSpace(startupErr) != "" {
2144 return fmt.Errorf("workspace failed to start: %s", startupErr)
2145 }
2146 if issue != nil && strings.TrimSpace(issue.Message) != "" {
2147 return fmt.Errorf("workspace failed to start: %s", issue.Message)
2148 }
2149 return fmt.Errorf("workspace is still starting")
2150 }
2151
2152 // workspaceRuntimeAdmissionErr is the backend half of the composer readiness
2153 // contract. The frontend gate avoids an optimistic bubble for known startup
2154 // states; this check closes the race where a rebuild or lease failure lands
2155 // after the last metadata refresh but before a bound Submit call.
2156 func (a *App) workspaceRuntimeAdmissionErr(tab *WorkspaceTab, ctrl control.SessionAPI) error {
2157 a.mu.RLock()
2158 defer a.mu.RUnlock()
2159 if tab != nil && ctrl != nil && tab.Ctrl == ctrl {
2160 runtimeView := a.sessionRuntimeViewLocked(tab)
2161 if runtimeView.Phase == sessionRuntimeReady {
2162 return nil
2163 }
2164 }
2165 return a.workspaceNotReadyErrLocked(tab)
2166 }
2167
2168 // tabIsReadOnly reads tab.ReadOnly under a.mu; setTabReadOnly can flip it
2169 // concurrently with Submit-family bound calls. Callers must not hold a.mu.
2170 func (a *App) tabIsReadOnly(tab *WorkspaceTab) bool {
2171 if tab == nil {
2172 return false
2173 }
2174 a.mu.RLock()
2175 defer a.mu.RUnlock()
2176 return tab.ReadOnly
2177 }
2178
2179 // NewSession snapshots the current conversation and rotates to a fresh one.
2180 func (a *App) NewSession() error {
2181 return a.NewSessionForTab("")
2182 }
2183
2184 // NewSessionForTab snapshots and rotates the requested tab regardless of which
2185 // tab becomes active while the Wails call is in flight.
2186 func (a *App) NewSessionForTab(tabID string) error {
2187 tab, ctrl := a.tabAndCtrlByID(tabID)
2188 if a.tabIsReadOnly(tab) {
2189 return readOnlyChannelErr()
2190 }
2191 if ctrl == nil {
2192 return a.workspaceNotReadyErr(tab)
2193 }
2194 if err := a.ensureTabControllerWorkspace(tab); err != nil {
2195 return err
2196 }
2197 ctrl = a.controllerForTab(tab)
2198 if ctrl == nil {
2199 return a.workspaceNotReadyErr(tab)
2200 }
2201 // Tab is already blank — just persist and skip the new-session dance.
2202 if !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) {
2203 a.persistTabSessionPath(tab, ctrl.SessionPath())
2204 return nil
2205 }
2206
2207 if err := ctrl.NewSession(); err != nil {
2208 return err
2209 }
2210 // The rotated session starts with zero spend: without this reset the tab
2211 // telemetry keeps the previous session's totals and the status bar 会话费用
2212 // silently turns into an all-sessions running total (#5850).
2213 tab.resetTelemetry(ctrl.SessionPath())
2214 // Mirror the controller: NewSession cleared the active goal, and the tab's
2215 // persisted copy must follow — otherwise the next rebuild/restart would
2216 // re-seed the old goal into the fresh session via SetGoal(tab.goal).
2217 a.clearTabGoal(tab)
2218 a.assignFreshSessionTopic(tab)
2219 a.persistTabSessionPath(tab, ctrl.SessionPath())
2220 a.invalidatePromptHistoryCache()
2221 a.emitProjectTreeChangedForSessionDirs(ctrl.SessionDir())
2222 return nil
2223 }
2224
2225 func (a *App) assignFreshSessionTopic(tab *WorkspaceTab) {
2226 if tab == nil {
2227 return
2228 }
2229 topicID := newTopicID()
2230 a.mu.Lock()
2231 scope := tab.Scope
2232 workspaceRoot := tab.WorkspaceRoot
2233 tab.TopicID = topicID
2234 tab.TopicTitle = defaultTopicTitle
2235 tab.topicTitleSource = topicTitleSourceAuto
2236 if current := a.tabs[tab.ID]; current == tab {
2237 a.saveTabsLocked()
2238 }
2239 a.mu.Unlock()
2240 if strings.TrimSpace(scope) == "global" {
2241 workspaceRoot = ""
2242 } else {
2243 workspaceRoot = normalizeProjectRoot(workspaceRoot)
2244 }
2245 // NewSession already rotated the runtime to a fresh session. If the sidebar
2246 // topic index repair fails here, keep the session usable and let persisted
2247 // session metadata repair the topic index later instead of surfacing a false
2248 // "new session failed" error to the frontend.
2249 _ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
2250 _ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
2251 }
2252
2253 func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) {
2254 if tab == nil {
2255 return
2256 }
2257 topicID := newTopicID()
2258 a.mu.Lock()
2259 if strings.TrimSpace(tab.TopicID) != "" {
2260 a.mu.Unlock()
2261 return
2262 }
2263 scope := tab.Scope
2264 workspaceRoot := tab.WorkspaceRoot
2265 tab.TopicID = topicID
2266 tab.TopicTitle = defaultTopicTitle
2267 tab.topicTitleSource = topicTitleSourceAuto
2268 if current := a.tabs[tab.ID]; current == tab {
2269 a.saveTabsLocked()
2270 }
2271 a.mu.Unlock()
2272 if strings.TrimSpace(scope) == "global" {
2273 scope = "global"
2274 workspaceRoot = ""
2275 } else {
2276 scope = "project"
2277 workspaceRoot = normalizeProjectRoot(workspaceRoot)
2278 }
2279
2280 _ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto)
2281 _ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli())
2282 path := a.currentSessionPathFor(tab)
2283 a.persistTabSessionPath(tab, path)
2284 a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(path))
2285 }
2286
2287 func messagesHaveConversationContent(messages []provider.Message) bool {
2288 for _, msg := range messages {
2289 if msg.Role != provider.RoleSystem {
2290 return true
2291 }
2292 }
2293 return false
2294 }
2295
2296 // ClearSession discards the current conversation and rotates to a fresh unsaved one.
2297 func (a *App) ClearSession() error {
2298 return a.ClearSessionForTab("")
2299 }
2300
2301 // ClearSessionForTab clears the requested tab regardless of later focus changes.
2302 func (a *App) ClearSessionForTab(tabID string) error {
2303 tab, ctrl := a.tabAndCtrlByID(tabID)
2304 if a.tabIsReadOnly(tab) {
2305 return readOnlyChannelErr()
2306 }
2307 if ctrl == nil {
2308 return a.workspaceNotReadyErr(tab)
2309 }
2310 if err := a.ensureTabControllerWorkspace(tab); err != nil {
2311 return err
2312 }
2313 ctrl = a.controllerForTab(tab)
2314 if ctrl == nil {
2315 return a.workspaceNotReadyErr(tab)
2316 }
2317 if controllerHasActiveRuntimeWork(ctrl) {
2318 return a.clearActiveSessionRuntime(tab, ctrl)
2319 }
2320 if err := ctrl.ClearSession(); err != nil {
2321 return err
2322 }
2323 if err := a.ensureTabSessionLeaseForRebuild(tab, ctrl.SessionPath(), ""); err != nil {
2324 // Wails bridge return: a raw lease error would carry the session path
2325 // and holder id across to the frontend.
2326 return userFacingSessionLeaseError("", err)
2327 }
2328 tab.resetTelemetry(ctrl.SessionPath())
2329 // Mirror the controller: ClearSession cleared the active goal.
2330 a.clearTabGoal(tab)
2331 a.persistTabSessionPath(tab, ctrl.SessionPath())
2332 a.invalidatePromptHistoryCache()
2333 return nil
2334 }
2335
2336 // clearTabGoal drops the tab's persisted goal copy so rebuilds and restarts
2337 // cannot re-seed a goal the controller has already cleared on session rotation.
2338 func (a *App) clearTabGoal(tab *WorkspaceTab) {
2339 if tab == nil {
2340 return
2341 }
2342 a.mu.Lock()
2343 tab.goal = ""
2344 if current := a.tabs[tab.ID]; current == tab {
2345 a.saveTabsLocked()
2346 }
2347 a.mu.Unlock()
2348 }
2349
2350 func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error {
2351 if tab == nil || oldCtrl == nil {
2352 return fmt.Errorf("workspace is still starting")
2353 }
2354 // This is a build+swap of the tab's controller; serialize with the other
2355 // rebuild paths (see runtimeRebuildMu) so a concurrent model/effort/settings
2356 // rebuild cannot interleave a second swap. Lock order:
2357 // runtimeRebuildMu → sessionRemovalMu (no path acquires them in reverse).
2358 a.runtimeRebuildMu.Lock()
2359 defer a.runtimeRebuildMu.Unlock()
2360 tab.turnStartMu.Lock()
2361 defer tab.turnStartMu.Unlock()
2362 // This path destroys the old session's files (removeDesktopSessionArtifacts);
2363 // serialize with DeleteSession/TrashTopic/workspace removal so they never
2364 // trash or restore the same files mid-clear.
2365 a.sessionRemovalMu.Lock()
2366 defer a.sessionRemovalMu.Unlock()
2367
2368 a.reconciledSessionPathForTab(tab)
2369 oldPath := oldCtrl.SessionPath()
2370 // Snapshot the tab profile under a.mu: bound methods write these fields
2371 // under the lock while this rebuild runs off-lock.
2372 snap := a.tabRuntimeSnapshot(tab)
2373 oldSink := snap.sink
2374 if oldSink != nil {
2375 // Rebind under the runtime key, matching the id cloneDetachedRuntimeTab
2376 // derives — a raw path here would hash to a different detached id on
2377 // Windows where keys are case-folded.
2378 oldSink.setBinding(detachedRuntimeTabID(sessionRuntimeKey(oldPath)), nil)
2379 oldSink.clearContext()
2380 }
2381 if oldCtrl.RuntimeStatus().Cancellable {
2382 oldCtrl.Cancel()
2383 if err := waitControllerStopped(oldCtrl); err != nil {
2384 return err
2385 }
2386 }
2387 destroy := oldCtrl.BeginDestroySession(oldPath)
2388 destroys := []control.SessionDestroyHandle{destroy}
2389 teardownTimedOut := waitDestroyHandles(destroys)
2390 if teardownTimedOut {
2391 if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil {
2392 return err
2393 }
2394 }
2395
2396 newSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
2397 sharedHost := a.lookupSharedHost(snap.sharedHostKey)
2398 newCtrl, err := boot.Build(a.bootContext(), boot.Options{
2399 Model: snap.model,
2400 RequireKey: false,
2401 AutoPricingCurrency: a.desktopAutoPricingCurrency(),
2402 StatsSource: "desktop",
2403 Sink: newSink,
2404 WorkspaceRoot: snap.workspaceRoot,
2405 SessionDir: sessionDirForSnapshot(snap),
2406 EffortOverride: cloneStringPtr(snap.effort),
2407 TokenMode: snap.currentTokenMode(),
2408 SharedHost: sharedHost,
2409 CleanupPendingReconciler: reconcileDesktopCleanupPending,
2410 SubagentParentLive: a.subagentParentProbeForBuild(tab),
2411 SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
2412 OnSessionRecovered: a.handleTabSessionRecovered(tab),
2413 })
2414 if err != nil {
2415 if teardownTimedOut {
2416 // The old session was already marked cleanup-pending, so finish the
2417 // destroy cleanup instead of re-exposing a runtime in teardown.
2418 go delayedDesktopSessionCleanup(oldPath, destroys)
2419 } else {
2420 finishDestroyHandles(destroys)
2421 }
2422 if oldSink != nil {
2423 oldSink.setBinding(tab.ID, nil)
2424 oldSink.setContext(a.ctx)
2425 }
2426 return err
2427 }
2428 if teardownTimedOut {
2429 go delayedDesktopSessionCleanup(oldPath, destroys)
2430 } else {
2431 if err := removeDesktopSessionArtifacts(oldPath); err != nil {
2432 finishDestroyHandles(destroys)
2433 newCtrl.Close()
2434 return err
2435 }
2436 finishDestroyHandles(destroys)
2437 }
2438 a.bindControllerDisplayRecorder(newCtrl)
2439 newCtrl.EnableInteractiveApproval()
2440 applyTabModeToController(newCtrl, snap.mode)
2441 applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
2442 // Keep the replacement controller's Auto Guard default from construction
2443 // (merged project+user config). Do not re-apply a user-only helper here.
2444 // Clearing the session clears the active goal too (same contract as
2445 // Controller.ClearSession): the snapshot's goal belongs to the destroyed
2446 // conversation and must not seed the replacement.
2447 path := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())
2448 if err := a.ensureTabSessionLeaseForRebuild(tab, path, ""); err != nil {
2449 newCtrl.Close()
2450 // Surfaces through ClearSession's Wails return; keep the holder's
2451 // path/pid/writer id out of it.
2452 return userFacingSessionLeaseError("", err)
2453 }
2454 newCtrl.SetFreshSessionPath(path)
2455
2456 a.mu.Lock()
2457 if current := a.tabs[tab.ID]; current != tab {
2458 a.mu.Unlock()
2459 // The old session is already destroyed either way; release what this
2460 // clear acquired for the replaced tab (fresh controller and its
2461 // lease) so neither leaks, and still finish the old runtime teardown.
2462 newCtrl.Close()
2463 tab.releaseSessionLease()
2464 oldCtrl.CloseAfterDestroy()
2465 a.emitProjectTreeChangedForSessionDirs(newCtrl.SessionDir())
2466 return fmt.Errorf("tab %q changed while clearing the session", tab.ID)
2467 }
2468 tab.Ctrl = newCtrl
2469 tab.sink = newSink
2470 tab.SessionPath = path
2471 tab.Label = newCtrl.Label()
2472 tab.Ready = true
2473 clearTabStartupError(tab)
2474 tab.goal = ""
2475 // Supersede any in-flight startup build: the session it was resuming
2476 // was just destroyed, and finishing later would pass the generation
2477 // check and overwrite this controller.
2478 a.supersedeTabBuildLocked(tab)
2479 a.saveTabsLocked()
2480 a.mu.Unlock()
2481 // Same contract as ClearSession's non-running path: the replacement
2482 // session starts with zero spend.
2483 tab.resetTelemetry(path)
2484 a.persistTabSessionPath(tab, path)
2485 oldCtrl.CloseAfterDestroy()
2486 a.emitProjectTreeChangedForSessionDirs(newCtrl.SessionDir())
2487 a.notifyTabRuntimeRebuilt(tab)
2488 return nil
2489 }
2490
2491 func removeDesktopSessionArtifacts(path string) error {
2492 if strings.TrimSpace(path) == "" {
2493 return nil
2494 }
2495 guard, err := acquireSessionRemovalGuard(path)
2496 if err != nil {
2497 return err
2498 }
2499 defer guard.Release()
2500 if err := invalidateTopicDirMarkers(filepath.Dir(path)); err != nil {
2501 return err
2502 }
2503 defer invalidateTopicSessionIndexForPath(path)
2504 for _, p := range sessionOwnedArtifactPaths(path) {
2505 if strings.TrimSpace(p) == "" {
2506 continue
2507 }
2508 if err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {
2509 return err
2510 }
2511 }
2512 if err := guard.RemoveSidecarsAndRelease(); err != nil {
2513 return err
2514 }
2515 if err := removeSessionDisplay(filepath.Dir(path), path); err != nil {
2516 return err
2517 }
2518 if err := removeSessionPlannerDisplay(filepath.Dir(path), path); err != nil {
2519 return err
2520 }
2521 if err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {
2522 return err
2523 }
2524 return agent.ClearCleanupPending(path)
2525 }
2526
2527 // CheckpointMeta summarises one rewind point (a user turn) for the desktop.
2528 // Optional v2 fields use omitempty so older frontends keep reading the rest.
2529 type CheckpointMeta struct {
2530 Turn int `json:"turn"`
2531 Prompt string `json:"prompt"`
2532 Files []string `json:"files"` // stable preview of cumulative files RestoreCode would affect from this turn
2533 FileCount int `json:"fileCount"` // full cumulative file count, including entries omitted from Files
2534 FilesTruncated bool `json:"filesTruncated,omitempty"`
2535 TurnFileCount int `json:"turnFileCount"` // files changed during this turn only
2536 Time int64 `json:"time"` // unix milliseconds
2537 CanCode bool `json:"canCode"`
2538 CanConversation bool `json:"canConversation"`
2539 Coverage string `json:"coverage,omitempty"`
2540 CoverageGaps []string `json:"coverageGaps,omitempty"`
2541 ExpiredFilePayload bool `json:"expiredFilePayload,omitempty"`
2542 ActiveWriters int `json:"activeWriters,omitempty"`
2543 Legacy bool `json:"legacy,omitempty"`
2544 CanUndoFiles bool `json:"canUndoFiles,omitempty"`
2545 DisabledReason string `json:"disabledReason,omitempty"`
2546 }
2547
2548 // RewindPlanView is the desktop-facing prepare result.
2549 type RewindPlanView struct {
2550 PlanID string `json:"planId"`
2551 Turn int `json:"turn"`
2552 Scope string `json:"scope"`
2553 Coverage string `json:"coverage,omitempty"`
2554 CoverageGaps []string `json:"coverageGaps,omitempty"`
2555 Legacy bool `json:"legacy,omitempty"`
2556 ExpiredFilePayload bool `json:"expiredFilePayload,omitempty"`
2557 CanFiles bool `json:"canFiles"`
2558 CanConversation bool `json:"canConversation"`
2559 DisabledReason string `json:"disabledReason,omitempty"`
2560 Conflicts []string `json:"conflicts,omitempty"`
2561 Files []string `json:"files,omitempty"`
2562 FileCount int `json:"fileCount"`
2563 ActiveWriters int `json:"activeWriters,omitempty"`
2564 Path string `json:"path,omitempty"`
2565 OK bool `json:"ok"`
2566 Error string `json:"error,omitempty"`
2567 }
2568
2569 // RewindResultView is the desktop-facing commit/undo result.
2570 type RewindResultView struct {
2571 OK bool `json:"ok"`
2572 TransactionID string `json:"transactionId,omitempty"`
2573 UndoAvailable bool `json:"undoAvailable"`
2574 Written []string `json:"written,omitempty"`
2575 Deleted []string `json:"deleted,omitempty"`
2576 ConversationOK bool `json:"conversationOk,omitempty"`
2577 Error string `json:"error,omitempty"`
2578 Conflicts []string `json:"conflicts,omitempty"`
2579 Coverage string `json:"coverage,omitempty"`
2580 }
2581
2582 const checkpointFilePreviewLimit = 60
2583
2584 // Checkpoints lists the session's rewind points, oldest first, for the rewind UI.
2585 func (a *App) Checkpoints() []CheckpointMeta {
2586 return a.CheckpointsForTab("")
2587 }
2588
2589 func (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {
2590 a.mu.RLock()
2591 var ctrl control.SessionAPI
2592 if tab := a.tabByIDLocked(tabID); tab != nil {
2593 ctrl = tab.Ctrl
2594 }
2595 a.mu.RUnlock()
2596 if ctrl == nil {
2597 return []CheckpointMeta{}
2598 }
2599 metas := ctrl.Checkpoints()
2600 out := make([]CheckpointMeta, 0, len(metas))
2601 for _, m := range metas {
2602 gaps := make([]string, 0, len(m.CoverageGaps))
2603 for _, g := range m.CoverageGaps {
2604 if g.Detail != "" {
2605 gaps = append(gaps, g.Reason+": "+g.Detail)
2606 } else {
2607 gaps = append(gaps, g.Reason)
2608 }
2609 }
2610 cov := string(m.Coverage)
2611 meta := CheckpointMeta{
2612 Turn: m.Turn,
2613 Prompt: m.Prompt,
2614 Files: m.Paths,
2615 TurnFileCount: len(m.Paths),
2616 Time: m.Time.UnixMilli(),
2617 CanCode: len(m.Paths) > 0 && m.CanUndoFiles,
2618 CanConversation: ctrl.CheckpointHasBoundary(m.Turn),
2619 Coverage: cov,
2620 CoverageGaps: gaps,
2621 ExpiredFilePayload: m.ExpiredFilePayload,
2622 ActiveWriters: len(m.ActiveWriters),
2623 Legacy: m.Legacy,
2624 CanUndoFiles: m.CanUndoFiles,
2625 DisabledReason: m.DisabledReason,
2626 }
2627 out = append(out, meta)
2628 }
2629 // RestoreCode(turn) reverts every file touched in this turn or any later one, so
2630 // a turn can rewind code even when it changed no files itself — as long as a
2631 // later turn did. Propagate CanCode backwards over the oldest-first list.
2632 // Also propagate the cumulative unique file count so the UI shows how many
2633 // files RestoreCode would actually affect from this turn.
2634 hasCodeAfter := false
2635 canCodeAfter := true
2636 codeFileSet := make(map[string]bool, len(metas)*2)
2637 codeFilePreview := []string{}
2638 for i := len(out) - 1; i >= 0; i-- {
2639 if len(out[i].Files) > 0 {
2640 hasCodeAfter = true
2641 if !out[i].CanUndoFiles {
2642 canCodeAfter = false
2643 }
2644 }
2645 for _, f := range out[i].Files {
2646 if codeFileSet[f] {
2647 continue
2648 }
2649 codeFileSet[f] = true
2650 codeFilePreview = insertCheckpointFilePreview(codeFilePreview, f, checkpointFilePreviewLimit)
2651 }
2652 out[i].CanCode = hasCodeAfter && canCodeAfter
2653 out[i].FileCount = len(codeFileSet)
2654 out[i].Files = append([]string{}, codeFilePreview...)
2655 out[i].FilesTruncated = out[i].FileCount > len(out[i].Files)
2656 }
2657 return out
2658 }
2659
2660 func insertCheckpointFilePreview(preview []string, path string, limit int) []string {
2661 if limit <= 0 || path == "" {
2662 return preview
2663 }
2664 idx := sort.SearchStrings(preview, path)
2665 if idx < len(preview) && preview[idx] == path {
2666 return preview
2667 }
2668 if len(preview) < limit {
2669 preview = append(preview, "")
2670 copy(preview[idx+1:], preview[idx:])
2671 preview[idx] = path
2672 return preview
2673 }
2674 if idx >= limit {
2675 return preview
2676 }
2677 copy(preview[idx+1:], preview[idx:limit-1])
2678 preview[idx] = path
2679 return preview
2680 }
2681
2682 // ToolResultForTab returns the full arguments and output for one tool call that
2683 // were elided from the frontend's in-memory items[] for memory efficiency. The
2684 // caller (frontend ToolCard) loads this on demand when the user expands a
2685 // collapsed tool card. Returns nil when the tool ID is not found.
2686 func (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {
2687 a.mu.RLock()
2688 var ctrl control.SessionAPI
2689 if tab := a.tabByIDLocked(tabID); tab != nil {
2690 ctrl = tab.Ctrl
2691 }
2692 a.mu.RUnlock()
2693 if ctrl == nil {
2694 return nil
2695 }
2696 return ctrl.ToolResult(toolID)
2697 }
2698
2699 // Rewind restores the session to the start of turn. scope is "code",
2700 // "conversation", or "both" (anything else is treated as "both"). The frontend
2701 // re-reads History after this resolves.
2702 func (a *App) Rewind(turn int, scope string) error {
2703 return a.RewindForTab("", turn, scope)
2704 }
2705
2706 // RewindForTab rewinds the requested tab instead of resolving the active tab at
2707 // execution time, which may have changed after frontend confirmation.
2708 // Compatibility wrapper over the transactional path when available; falls back
2709 // to Controller.Rewind which prechecks conversation before files for both scope.
2710 func (a *App) RewindForTab(tabID string, turn int, scope string) error {
2711 tab, ctrl := a.tabAndCtrlByID(tabID)
2712 if a.tabIsReadOnly(tab) {
2713 return readOnlyChannelErr()
2714 }
2715 if ctrl == nil {
2716 return nil
2717 }
2718 s := control.RewindBoth
2719 switch scope {
2720 case "code":
2721 s = control.RewindCode
2722 case "conversation":
2723 s = control.RewindConversation
2724 }
2725 return ctrl.Rewind(turn, s)
2726 }
2727
2728 // PreviewRewindForTab returns a structured precheck without mutating state.
2729 func (a *App) PreviewRewindForTab(tabID string, turn int, scope string) RewindPlanView {
2730 tab, ctrl := a.tabAndCtrlByID(tabID)
2731 if a.tabIsReadOnly(tab) {
2732 return RewindPlanView{OK: false, Error: readOnlyChannelErr().Error()}
2733 }
2734 if ctrl == nil {
2735 return RewindPlanView{OK: false, Error: "no controller"}
2736 }
2737 s := control.RewindBoth
2738 switch scope {
2739 case "code":
2740 s = control.RewindCode
2741 case "conversation":
2742 s = control.RewindConversation
2743 }
2744 plan, err := ctrl.PrepareRewind(turn, s)
2745 view := rewindPlanToView(plan, scope)
2746 if err != nil {
2747 view.OK = false
2748 view.Error = err.Error()
2749 return view
2750 }
2751 view.OK = true
2752 return view
2753 }
2754
2755 // CommitRewindForTab executes prepare (if planID empty) then commit immediately.
2756 func (a *App) CommitRewindForTab(tabID, planID string, turn int, scope string) RewindResultView {
2757 tab, ctrl := a.tabAndCtrlByID(tabID)
2758 if a.tabIsReadOnly(tab) {
2759 return RewindResultView{OK: false, Error: readOnlyChannelErr().Error()}
2760 }
2761 if ctrl == nil {
2762 return RewindResultView{OK: false, Error: "no controller"}
2763 }
2764 s := control.RewindBoth
2765 switch scope {
2766 case "code":
2767 s = control.RewindCode
2768 case "conversation":
2769 s = control.RewindConversation
2770 }
2771 if planID == "" {
2772 plan, err := ctrl.PrepareRewind(turn, s)
2773 if err != nil {
2774 return RewindResultView{OK: false, Error: err.Error()}
2775 }
2776 // Conversation-only is allowed when its boundary is valid. File scopes
2777 // never fall back to the legacy force-restore path.
2778 if s == control.RewindConversation {
2779 if !plan.CanConversation {
2780 return RewindResultView{OK: false, Error: nonEmptyStr(plan.DisabledReason, "conversation rewind unavailable")}
2781 }
2782 } else if !plan.CanFiles {
2783 return RewindResultView{OK: false, Error: nonEmptyStr(plan.DisabledReason, "file rewind unavailable"), Conflicts: conflictStrings(plan), Coverage: string(plan.Coverage)}
2784 }
2785 planID = plan.PlanID
2786 }
2787 result, err := ctrl.CommitRewind(planID)
2788 view := rewindResultToView(result)
2789 if err != nil {
2790 view.OK = false
2791 if view.Error == "" {
2792 view.Error = err.Error()
2793 }
2794 }
2795 return view
2796 }
2797
2798 // UndoRewindForTab undoes the last successful rewind on the tab when available.
2799 func (a *App) UndoRewindForTab(tabID, transactionID string) RewindResultView {
2800 tab, ctrl := a.tabAndCtrlByID(tabID)
2801 if a.tabIsReadOnly(tab) {
2802 return RewindResultView{OK: false, Error: readOnlyChannelErr().Error()}
2803 }
2804 if ctrl == nil {
2805 return RewindResultView{OK: false, Error: "no controller"}
2806 }
2807 result, err := ctrl.UndoRewind(transactionID)
2808 view := rewindResultToView(result)
2809 if err != nil {
2810 view.OK = false
2811 if view.Error == "" {
2812 view.Error = err.Error()
2813 }
2814 }
2815 return view
2816 }
2817
2818 // PreviewWorkspaceFileRevertForTab prepares a single-file session-owned revert.
2819 func (a *App) PreviewWorkspaceFileRevertForTab(tabID, path string) RewindPlanView {
2820 tab, ctrl := a.tabAndCtrlByID(tabID)
2821 if a.tabIsReadOnly(tab) {
2822 return RewindPlanView{OK: false, Error: readOnlyChannelErr().Error(), Path: path}
2823 }
2824 if ctrl == nil {
2825 return RewindPlanView{OK: false, Error: "no controller", Path: path}
2826 }
2827 plan, err := ctrl.PrepareFileRevert(path)
2828 view := rewindPlanToView(plan, "code")
2829 view.Path = path
2830 if err != nil {
2831 view.OK = false
2832 view.Error = err.Error()
2833 return view
2834 }
2835 view.OK = plan.CanFiles || len(plan.Conflicts) > 0
2836 return view
2837 }
2838
2839 // CommitWorkspaceFileRevertForTab commits a single-file revert.
2840 // resolution is "keep_current" or "overwrite_checkpoint".
2841 func (a *App) CommitWorkspaceFileRevertForTab(tabID, planID, resolution string) RewindResultView {
2842 tab, ctrl := a.tabAndCtrlByID(tabID)
2843 if a.tabIsReadOnly(tab) {
2844 return RewindResultView{OK: false, Error: readOnlyChannelErr().Error()}
2845 }
2846 if ctrl == nil {
2847 return RewindResultView{OK: false, Error: "no controller"}
2848 }
2849 res := checkpoint.ConflictResolution("")
2850 switch resolution {
2851 case "keep_current":
2852 res = checkpoint.ResolveKeepCurrent
2853 case "overwrite_checkpoint":
2854 res = checkpoint.ResolveOverwriteCheckpoint
2855 }
2856 result, err := ctrl.CommitFileRevert(planID, res)
2857 view := rewindResultToView(result)
2858 if err != nil {
2859 view.OK = false
2860 if view.Error == "" {
2861 view.Error = err.Error()
2862 }
2863 }
2864 return view
2865 }
2866
2867 func rewindPlanToView(plan checkpoint.RewindPlan, scope string) RewindPlanView {
2868 gaps := make([]string, 0, len(plan.CoverageGaps))
2869 for _, g := range plan.CoverageGaps {
2870 if g.Detail != "" {
2871 gaps = append(gaps, g.Reason+": "+g.Detail)
2872 } else {
2873 gaps = append(gaps, g.Reason)
2874 }
2875 }
2876 return RewindPlanView{
2877 PlanID: plan.PlanID,
2878 Turn: plan.Turn,
2879 Scope: scope,
2880 Coverage: string(plan.Coverage),
2881 CoverageGaps: gaps,
2882 Legacy: plan.Legacy,
2883 ExpiredFilePayload: plan.ExpiredFilePayload,
2884 CanFiles: plan.CanFiles,
2885 CanConversation: plan.CanConversation,
2886 DisabledReason: plan.DisabledReason,
2887 Conflicts: conflictStrings(plan),
2888 Files: plan.Files,
2889 FileCount: plan.FileCount,
2890 ActiveWriters: len(plan.ActiveWriters),
2891 Path: plan.Path,
2892 }
2893 }
2894
2895 func conflictStrings(plan checkpoint.RewindPlan) []string {
2896 out := make([]string, 0, len(plan.Conflicts))
2897 for _, c := range plan.Conflicts {
2898 if c.Path != "" {
2899 out = append(out, c.Path+": "+c.Reason)
2900 } else {
2901 out = append(out, c.Reason)
2902 }
2903 }
2904 return out
2905 }
2906
2907 func rewindResultToView(result checkpoint.RewindResult) RewindResultView {
2908 conflicts := make([]string, 0, len(result.Conflicts))
2909 for _, c := range result.Conflicts {
2910 if c.Path != "" {
2911 conflicts = append(conflicts, c.Path+": "+c.Reason)
2912 } else {
2913 conflicts = append(conflicts, c.Reason)
2914 }
2915 }
2916 return RewindResultView{
2917 OK: result.OK,
2918 TransactionID: result.TransactionID,
2919 UndoAvailable: result.UndoAvailable,
2920 Written: result.Written,
2921 Deleted: result.Deleted,
2922 ConversationOK: result.ConversationOK,
2923 Error: result.Error,
2924 Conflicts: conflicts,
2925 Coverage: string(result.Coverage),
2926 }
2927 }
2928
2929 func nonEmptyStr(s, fallback string) string {
2930 if s != "" {
2931 return s
2932 }
2933 return fallback
2934 }
2935
2936 // Fork branches the conversation at the start of turn into a new session tab
2937 // (preserving the current tab), keeping code intact, and switches to the new tab.
2938 func (a *App) Fork(turn int) (TabMeta, error) {
2939 return a.ForkForTab("", turn)
2940 }
2941
2942 // ForkForTab forks the requested source tab even if focus changes before the
2943 // backend begins processing the request. The fork becomes active only while the
2944 // source tab still owns focus, so a later tab selection remains authoritative.
2945 func (a *App) ForkForTab(tabID string, turn int) (TabMeta, error) {
2946 sourceTab, ctrl := a.tabAndCtrlByID(tabID)
2947 if sourceTab == nil || ctrl == nil {
2948 return TabMeta{}, nil
2949 }
2950 if a.tabIsReadOnly(sourceTab) {
2951 return TabMeta{}, readOnlyChannelErr()
2952 }
2953
2954 if err := a.ensureTabControllerWorkspace(sourceTab); err != nil {
2955 return TabMeta{}, err
2956 }
2957 a.mu.RLock()
2958 if a.tabs[sourceTab.ID] != sourceTab || sourceTab.Ctrl == nil {
2959 a.mu.RUnlock()
2960 return TabMeta{}, nil
2961 }
2962 ctrl = sourceTab.Ctrl
2963 scope := sourceTab.Scope
2964 workspaceRoot := sourceTab.WorkspaceRoot
2965 sourceTitle := sourceTab.TopicTitle
2966 model := sourceTab.model
2967 effort := cloneStringPtr(sourceTab.effort)
2968 mode := currentTabMode(sourceTab)
2969 toolApprovalMode := currentTabToolApprovalMode(sourceTab)
2970 disabledMCP := cloneServerViewMap(sourceTab.disabledMCP)
2971 mcpOrder := append([]string(nil), sourceTab.mcpOrder...)
2972 a.mu.RUnlock()
2973
2974 newPath, err := ctrl.ForkSession(turn, "")
2975 if err != nil {
2976 return TabMeta{}, err
2977 }
2978 topicID := newTopicID()
2979 topicTitle := a.forkTopicTitle(sourceTitle)
2980 titleRoot := workspaceRoot
2981 if scope == "global" {
2982 titleRoot = ""
2983 }
2984 if err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {
2985 return TabMeta{}, err
2986 }
2987 m, _ := agent.EnsureBranchMeta(newPath)
2988 m.Scope = scope
2989 m.WorkspaceRoot = workspaceRoot
2990 m.TopicID = topicID
2991 m.TopicTitle = topicTitle
2992 if err := agent.SaveBranchMeta(newPath, m); err != nil {
2993 return TabMeta{}, err
2994 }
2995 invalidateTopicSessionIndexForPath(newPath)
2996
2997 a.mu.Lock()
2998 newTabID := a.newUniqueTabIDLocked()
2999 tab := &WorkspaceTab{
3000 ID: newTabID,
3001 Scope: scope,
3002 WorkspaceRoot: workspaceRoot,
3003 TopicID: topicID,
3004 TopicTitle: topicTitle,
3005 topicTitleSource: topicTitleSourceManual,
3006 SessionPath: newPath,
3007 model: model,
3008 effort: effort,
3009 mode: mode,
3010 toolApprovalMode: toolApprovalMode,
3011 disabledMCP: disabledMCP,
3012 mcpOrder: mcpOrder,
3013 }
3014 tab.sink = &tabEventSink{tabID: newTabID, app: a}
3015 a.tabs[newTabID] = tab
3016 a.tabOrder = append(a.tabOrder, newTabID)
3017 activateFork := a.activeTabID == sourceTab.ID
3018 if activateFork {
3019 a.activeTabID = newTabID
3020 }
3021 a.saveTabsLocked()
3022 meta := a.tabMeta(tab, activateFork)
3023 a.mu.Unlock()
3024
3025 a.emitProjectTreeChangedForSessionDirs(sessionListCacheDirForPath(newPath))
3026 a.startTabControllerBuild(tab)
3027 return meta, nil
3028 }
3029
3030 // SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start
3031 // of turn into one summary (Claude Code's "summarize from/up to here"), keeping
3032 // code intact. The frontend re-reads History after this resolves.
3033 func (a *App) SummarizeFrom(turn int) error {
3034 return a.SummarizeFromForTab("", turn)
3035 }
3036
3037 func (a *App) SummarizeFromForTab(tabID string, turn int) error {
3038 tab, ctrl := a.tabAndCtrlByID(tabID)
3039 if a.tabIsReadOnly(tab) {
3040 return readOnlyChannelErr()
3041 }
3042 if ctrl == nil {
3043 return nil
3044 }
3045 return ctrl.SummarizeFrom(a.ctx, turn)
3046 }
3047
3048 func (a *App) SummarizeUpTo(turn int) error {
3049 return a.SummarizeUpToForTab("", turn)
3050 }
3051
3052 func (a *App) SummarizeUpToForTab(tabID string, turn int) error {
3053 tab, ctrl := a.tabAndCtrlByID(tabID)
3054 if a.tabIsReadOnly(tab) {
3055 return readOnlyChannelErr()
3056 }
3057 if ctrl == nil {
3058 return nil
3059 }
3060 return ctrl.SummarizeUpTo(a.ctx, turn)
3061 }
3062
3063 // SessionMeta summarises one saved session for the history panel.
3064 type SessionMeta struct {
3065 Path string `json:"path"`
3066 Preview string `json:"preview"` // first user message
3067 Title string `json:"title,omitempty"` // user-chosen name, when set (overrides preview)
3068 Turns int `json:"turns"`
3069 CreatedAt int64 `json:"createdAt"` // unix milliseconds
3070 LastActivityAt int64 `json:"lastActivityAt"` // unix milliseconds
3071 ModTime int64 `json:"modTime"` // compatibility alias for lastActivityAt
3072 DeletedAt int64 `json:"deletedAt,omitempty"`
3073 Current bool `json:"current"`
3074 Open bool `json:"open"`
3075 Scope string `json:"scope,omitempty"`
3076 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
3077 TopicID string `json:"topicId,omitempty"`
3078 TopicTitle string `json:"topicTitle,omitempty"`
3079 Kind string `json:"kind,omitempty"` // "channel" for external IM transcripts
3080 Channel string `json:"channel,omitempty"`
3081 ChannelLabel string `json:"channelLabel,omitempty"`
3082 RemoteID string `json:"remoteId,omitempty"`
3083 ChatType string `json:"chatType,omitempty"`
3084 UserID string `json:"userId,omitempty"`
3085 ThreadID string `json:"threadId,omitempty"`
3086 SessionSource string `json:"sessionSource,omitempty"`
3087 Recovered bool `json:"recovered,omitempty"` // created by conflict recovery, including an adopted/continued branch
3088 RecoveryCopy bool `json:"recoveryCopy,omitempty"` // actual branch content is unchanged and covered by its parent
3089 }
3090
3091 type channelSessionRoute struct {
3092 channel string
3093 channelLabel string
3094 remoteID string
3095 chatType string
3096 userID string
3097 threadID string
3098 sessionSource string
3099 }
3100
3101 type WorkspaceMeta struct {
3102 Path string `json:"path"`
3103 Name string `json:"name"`
3104 Current bool `json:"current"`
3105 }
3106
3107 func controllerSessionDir(ctrl control.SessionAPI) string {
3108 if ctrl != nil {
3109 if dir := ctrl.SessionDir(); dir != "" {
3110 return dir
3111 }
3112 }
3113 return desktopSessionDir("")
3114 }
3115
3116 func tabSessionDir(tab *WorkspaceTab) string {
3117 if tab != nil {
3118 if tab.WorkspaceRoot != "" {
3119 return desktopSessionDir(tab.WorkspaceRoot)
3120 }
3121 if tab.Ctrl != nil {
3122 if dir := tab.Ctrl.SessionDir(); dir != "" {
3123 return dir
3124 }
3125 }
3126 }
3127 return desktopSessionDir("")
3128 }
3129
3130 func tabRuntimeSessionDir(tab *WorkspaceTab) string {
3131 if tab != nil && tab.Ctrl != nil {
3132 if dir, ok := safeControllerSessionDir(tab.Ctrl); ok && strings.TrimSpace(dir) != "" {
3133 if path := strings.TrimSpace(tab.currentSessionPath()); path != "" {
3134 if _, _, err := validateSessionPath(dir, path); err == nil {
3135 return dir
3136 }
3137 } else {
3138 return dir
3139 }
3140 }
3141 }
3142 return tabSessionDir(tab)
3143 }
3144
3145 func (a *App) activeSessionDir() string {
3146 tab := a.activeTab()
3147 if path, ok := a.reconcileTabWithPinnedSessionMeta(tab); ok && strings.TrimSpace(path) != "" {
3148 return filepath.Dir(path)
3149 }
3150 if tab != nil && tab.Ctrl != nil {
3151 return tabRuntimeSessionDir(tab)
3152 }
3153 return tabSessionDir(tab)
3154 }
3155
3156 // ListSessions returns the saved sessions newest-first for the history panel,
3157 // marking the one the current conversation is writing to and attaching any
3158 // user-chosen titles.
3159 func (a *App) ListSessions() []SessionMeta {
3160 dir := a.activeSessionDir()
3161 return a.listSessionsFromDir(dir, a.activeSessionPath(dir))
3162 }
3163
3164 // ListSessionsForTab returns sessions from the directory owned by tabID. Task
3165 // Monitor uses this stable target after asynchronous control lookups so a tab
3166 // switch cannot redirect the eventual session lookup to another workspace.
3167 func (a *App) ListSessionsForTab(tabID string) []SessionMeta {
3168 target, err := a.taskMonitorTargetForTab(tabID)
3169 if err != nil {
3170 return []SessionMeta{}
3171 }
3172 return a.listSessionsFromDir(target.sessionDir, target.sessionPath)
3173 }
3174
3175 func (a *App) listSessionsFromDir(dir, active string) []SessionMeta {
3176 infos, err := agent.ListSessions(dir)
3177 if err != nil {
3178 return []SessionMeta{}
3179 }
3180 open := a.openSessionPaths(dir)
3181 protectedDisplays := make(map[string]struct{}, len(open))
3182 for path := range open {
3183 if key := filepath.Base(path); store.IsSessionTranscriptName(key) {
3184 protectedDisplays[key] = struct{}{}
3185 }
3186 }
3187 _ = pruneSessionDisplays(dir, protectedDisplays)
3188 _ = pruneSessionPlannerDisplays(dir, protectedDisplays)
3189 titles := loadSessionTitles(dir)
3190 channelRoutes := channelSessionRoutesForDir(dir)
3191 out := make([]SessionMeta, 0, len(infos))
3192 for _, s := range infos {
3193 _, isOpen := open[s.Path]
3194 title := strings.TrimSpace(s.CustomTitle)
3195 if title == "" {
3196 title = titles[filepath.Base(s.Path)]
3197 }
3198 meta := sessionMetaFromInfo(s, title, s.Path == active, isOpen, 0, dir)
3199 if route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok {
3200 applyChannelSessionRoute(&meta, route)
3201 }
3202 out = append(out, meta)
3203 }
3204 return out
3205 }
3206
3207 // ListTrashedSessions returns sessions that were moved to the local trash,
3208 // newest-deleted first. These can be previewed, restored, or permanently purged.
3209 func (a *App) ListTrashedSessions() []SessionMeta {
3210 out := []SessionMeta{}
3211 for _, dir := range a.knownSessionDirs() {
3212 paths, err := listTrashedSessionFiles(dir)
3213 if err != nil {
3214 continue
3215 }
3216 titles := loadSessionTitles(dir)
3217 for _, path := range paths {
3218 infos, err := agent.ListSessions(filepath.Dir(path))
3219 if err != nil || len(infos) == 0 {
3220 continue
3221 }
3222 deletedAt := trashedSessionDeletedAt(path)
3223 title := strings.TrimSpace(infos[0].CustomTitle)
3224 if title == "" {
3225 title = titles[filepath.Base(path)]
3226 }
3227 out = append(out, sessionMetaFromInfo(infos[0], title, false, false, deletedAt, dir))
3228 }
3229 }
3230 sort.Slice(out, func(i, j int) bool {
3231 if out[i].DeletedAt == out[j].DeletedAt {
3232 return out[i].LastActivityAt > out[j].LastActivityAt
3233 }
3234 return out[i].DeletedAt > out[j].DeletedAt
3235 })
3236 return out
3237 }
3238
3239 func (a *App) trashedSessionDir(path string) (string, error) {
3240 for _, dir := range a.knownSessionDirs() {
3241 if _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {
3242 return dir, nil
3243 }
3244 }
3245 return "", fmt.Errorf("trashed session path outside known session dirs: %s", path)
3246 }
3247
3248 func (a *App) sessionDirForPath(path string) (string, string, error) {
3249 for _, dir := range a.knownSessionDirs() {
3250 sessionPath, _, err := validateSessionPath(dir, path)
3251 if err == nil {
3252 return dir, sessionPath, nil
3253 }
3254 }
3255 return "", "", fmt.Errorf("session path outside known session dirs: %s", path)
3256 }
3257
3258 func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64, parentDir string) SessionMeta {
3259 return SessionMeta{
3260 Path: s.Path,
3261 Preview: s.Preview,
3262 Title: title,
3263 Turns: s.Turns,
3264 CreatedAt: s.CreatedAt.UnixMilli(),
3265 LastActivityAt: s.LastActivityAt.UnixMilli(),
3266 ModTime: s.LastActivityAt.UnixMilli(),
3267 DeletedAt: deletedAt,
3268 Current: current,
3269 Open: open,
3270 Scope: s.Scope,
3271 WorkspaceRoot: s.WorkspaceRoot,
3272 TopicID: s.TopicID,
3273 TopicTitle: s.TopicTitle,
3274 Recovered: sessionInfoIsAutomaticRecovery(s),
3275 RecoveryCopy: sessionInfoIsUnmodifiedRecoveryCopy(s, parentDir),
3276 }
3277 }
3278
3279 func applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {
3280 if meta == nil {
3281 return
3282 }
3283 meta.Kind = "channel"
3284 meta.Channel = route.channel
3285 meta.ChannelLabel = route.channelLabel
3286 meta.RemoteID = route.remoteID
3287 meta.ChatType = route.chatType
3288 meta.UserID = route.userID
3289 meta.ThreadID = route.threadID
3290 meta.SessionSource = route.sessionSource
3291 }
3292
3293 func channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {
3294 userPath := config.UserConfigPath()
3295 if strings.TrimSpace(userPath) == "" {
3296 return nil
3297 }
3298 cfg := config.LoadForEdit(userPath)
3299 out := map[string]channelSessionRoute{}
3300 for _, conn := range cfg.Bot.Connections {
3301 channel := strings.TrimSpace(conn.Provider)
3302 if channel == "" {
3303 continue
3304 }
3305 channelLabel := strings.TrimSpace(conn.Label)
3306 if channelLabel == "" {
3307 channelLabel = channelDisplayName(channel, conn.Domain)
3308 }
3309 for _, mapping := range conn.SessionMappings {
3310 if strings.TrimSpace(mapping.SessionSource) != "auto" {
3311 continue
3312 }
3313 sessionPath := botSessionPathTarget(mapping.SessionID)
3314 if sessionPath == "" {
3315 continue
3316 }
3317 validPath, _, err := validateSessionPath(dir, sessionPath)
3318 if err != nil {
3319 continue
3320 }
3321 key := sessionRuntimeKey(validPath)
3322 if key == "" {
3323 continue
3324 }
3325 out[key] = channelSessionRoute{
3326 channel: channel,
3327 channelLabel: channelLabel,
3328 remoteID: strings.TrimSpace(mapping.RemoteID),
3329 chatType: strings.TrimSpace(mapping.ChatType),
3330 userID: strings.TrimSpace(mapping.UserID),
3331 threadID: strings.TrimSpace(mapping.ThreadID),
3332 sessionSource: strings.TrimSpace(mapping.SessionSource),
3333 }
3334 }
3335 }
3336 if len(out) == 0 {
3337 return nil
3338 }
3339 return out
3340 }
3341
3342 func botSessionPathTarget(sessionID string) string {
3343 sessionID = strings.TrimSpace(sessionID)
3344 if sessionID == "" {
3345 return ""
3346 }
3347 if strings.HasPrefix(strings.ToLower(sessionID), "path:") {
3348 return strings.TrimSpace(sessionID[5:])
3349 }
3350 if strings.HasSuffix(sessionID, ".jsonl") || strings.Contains(sessionID, "/") || strings.Contains(sessionID, `\`) || strings.HasPrefix(sessionID, "~") {
3351 return sessionID
3352 }
3353 return ""
3354 }
3355
3356 func channelDisplayName(provider, domain string) string {
3357 provider = strings.TrimSpace(provider)
3358 domain = strings.TrimSpace(domain)
3359 switch provider {
3360 case "feishu":
3361 if strings.EqualFold(domain, "lark") {
3362 return "Lark"
3363 }
3364 return "Feishu"
3365 case "weixin":
3366 return "WeChat"
3367 case "qq":
3368 return "QQ"
3369 default:
3370 return provider
3371 }
3372 }
3373
3374 // DeleteSession moves a saved session to the local trash. If the session still
3375 // has an in-process runtime, the runtime is cancelled and removed first so
3376 // autosave cannot recreate or append to the deleted file later.
3377 func (a *App) DeleteSession(path string) error {
3378 return friendlySessionFileError(a.deleteSession(path, false))
3379 }
3380
3381 // DeleteRecoveryCopy is the guarded bulk-cleanup path. The frontend's copy
3382 // marker is only a hint; the backend re-reads the branch and parent immediately
3383 // before changing runtime state or moving any files.
3384 func (a *App) DeleteRecoveryCopy(path string) error {
3385 return friendlySessionFileError(a.deleteSession(path, true))
3386 }
3387
3388 var errRecoveryCopyNotRedundant = errors.New("recovery session contains content not preserved by its parent")
3389
3390 func (a *App) deleteSession(path string, requireRedundantRecovery bool) error {
3391 dir := a.activeSessionDir()
3392 sessionPath, key, err := validateSessionPath(dir, path)
3393 if err != nil {
3394 var foundErr error
3395 if dir, sessionPath, foundErr = a.sessionDirForPath(path); foundErr != nil {
3396 return err
3397 }
3398 key = filepath.Base(sessionPath)
3399 }
3400 if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
3401 return err
3402 }
3403 var fallback fallbackRuntimeTarget
3404 if err := func() error {
3405 defer a.lockRuntimeMutation("delete-session")()
3406 a.sessionRemovalMu.Lock()
3407 defer a.sessionRemovalMu.Unlock()
3408 if requireRedundantRecovery && !agent.RecoveryBranchCoveredByParent(sessionPath, dir) {
3409 return errRecoveryCopyNotRedundant
3410 }
3411
3412 removed, nextFallback := a.removeSessionRuntimeBindings(dir, sessionPath)
3413 fallback = nextFallback
3414 if err := a.prepareRemovedSessionRuntimes(removed); err != nil {
3415 a.closeRemainingRemovedSessionRuntimesAdmissionHeld(removed, map[control.SessionAPI]bool{})
3416 return err
3417 }
3418 closedRemoved := map[control.SessionAPI]bool{}
3419 destroys := a.destroyHandlesForSession(dir, sessionPath, removed)
3420 teardownTimedOut := waitDestroyHandles(destroys)
3421 a.closeRemovedSessionRuntimesForSessionAfterDestroyAdmissionHeld(removed, dir, sessionPath, closedRemoved)
3422 if teardownTimedOut {
3423 if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
3424 a.closeRemainingRemovedSessionRuntimesAfterDestroyAdmissionHeld(removed, closedRemoved)
3425 return err
3426 }
3427 go delayedDesktopSessionTrash(dir, sessionPath, key, destroys)
3428 } else {
3429 err = trashSessionArtifacts(dir, sessionPath, key)
3430 finishDestroyHandles(destroys)
3431 if err != nil {
3432 a.closeRemainingRemovedSessionRuntimesAfterDestroyAdmissionHeld(removed, closedRemoved)
3433 return err
3434 }
3435 }
3436 a.closeRemainingRemovedSessionRuntimesAfterDestroyAdmissionHeld(removed, closedRemoved)
3437 return nil
3438 }(); err != nil {
3439 return err
3440 }
3441 if err := botruntime.ForgetAutoSessionMappingsForPath(sessionPath); err != nil {
3442 slog.Warn("desktop: failed to clear auto bot session mapping", "err", err)
3443 }
3444 if fallback.needs {
3445 fallback = a.sessionDeleteFallbackTarget(fallback)
3446 if err := a.openFallbackRuntime(fallback); err != nil {
3447 return err
3448 }
3449 }
3450 a.emitProjectTreeChangedForSessionDirs(dir)
3451 a.invalidatePromptHistoryCache()
3452 return nil
3453 }
3454
3455 type removedSessionRuntime struct {
3456 tab *WorkspaceTab
3457 ctrl control.SessionAPI
3458 sink *tabEventSink
3459 sessionDir string
3460 sessionPath string
3461 scope string
3462 workspaceRoot string
3463 topicID string
3464 readOnly bool
3465 }
3466
3467 type fallbackRuntimeTarget struct {
3468 needs bool
3469 scope string
3470 workspaceRoot string
3471 topicID string
3472 }
3473
3474 func (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {
3475 var removed []removedSessionRuntime
3476 var fallback fallbackRuntimeTarget
3477
3478 a.mu.Lock()
3479 for id, tab := range a.tabs {
3480 if !tabMatchesSession(tab, dir, sessionPath) {
3481 continue
3482 }
3483 if len(removed) == 0 {
3484 fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}
3485 }
3486 removed = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))
3487 a.markTabRemovedLocked(tab)
3488 delete(a.tabs, id)
3489 a.removeTabOrderLocked(id)
3490 if a.activeTabID == id {
3491 a.activeTabID = ""
3492 }
3493 }
3494 for key, tab := range a.detachedSessions {
3495 if !tabMatchesSession(tab, dir, sessionPath) {
3496 continue
3497 }
3498 if len(removed) == 0 {
3499 fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}
3500 }
3501 removed = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))
3502 a.markTabRemovedLocked(tab)
3503 delete(a.detachedSessions, key)
3504 }
3505 if a.activeTabID == "" && len(a.tabOrder) > 0 {
3506 a.activeTabID = a.tabOrder[0]
3507 }
3508 fallback.needs = len(removed) > 0 && len(a.tabs) == 0
3509 dir, entries, activeID, version := a.saveTabsCollectLocked()
3510 a.mu.Unlock()
3511
3512 a.saveTabsWrite(dir, entries, activeID, version)
3513
3514 return removed, fallback
3515 }
3516
3517 func (a *App) sessionDeleteFallbackTarget(target fallbackRuntimeTarget) fallbackRuntimeTarget {
3518 topicID := strings.TrimSpace(target.topicID)
3519 if topicID == "" {
3520 return target
3521 }
3522 if path, _ := a.findTopicContentSessionForTarget(target.scope, target.workspaceRoot, topicID); path != "" {
3523 return target
3524 }
3525 target.topicID = ""
3526 return target
3527 }
3528
3529 func (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {
3530 var removed []removedSessionRuntime
3531 var fallback fallbackRuntimeTarget
3532
3533 a.mu.Lock()
3534 for id, tab := range a.tabs {
3535 if tab == nil || tab.TopicID != topicID {
3536 continue
3537 }
3538 sessionDir := tabRuntimeSessionDir(tab)
3539 sessionPath := canonicalTabSessionPath(tab.currentSessionPath())
3540 if len(removed) == 0 {
3541 fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
3542 }
3543 removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))
3544 a.markTabRemovedLocked(tab)
3545 delete(a.tabs, id)
3546 a.removeTabOrderLocked(id)
3547 if a.activeTabID == id {
3548 a.activeTabID = ""
3549 }
3550 }
3551 for key, tab := range a.detachedSessions {
3552 if tab == nil || tab.TopicID != topicID {
3553 continue
3554 }
3555 sessionDir := tabRuntimeSessionDir(tab)
3556 sessionPath := canonicalTabSessionPath(tab.currentSessionPath())
3557 if len(removed) == 0 {
3558 fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
3559 }
3560 removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))
3561 a.markTabRemovedLocked(tab)
3562 delete(a.detachedSessions, key)
3563 }
3564 if a.activeTabID == "" && len(a.tabOrder) > 0 {
3565 a.activeTabID = a.tabOrder[0]
3566 }
3567 fallback.needs = len(removed) > 0 && len(a.tabs) == 0
3568 dir, entries, activeID, version := a.saveTabsCollectLocked()
3569 a.mu.Unlock()
3570
3571 a.saveTabsWrite(dir, entries, activeID, version)
3572
3573 return removed, fallback
3574 }
3575
3576 func removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {
3577 return removedSessionRuntime{
3578 tab: tab,
3579 ctrl: tab.Ctrl,
3580 sink: tab.sink,
3581 sessionDir: dir,
3582 sessionPath: sessionPath,
3583 scope: tab.Scope,
3584 workspaceRoot: tab.WorkspaceRoot,
3585 topicID: tab.TopicID,
3586 readOnly: tab.ReadOnly,
3587 }
3588 }
3589
3590 func tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {
3591 if tab == nil {
3592 return false
3593 }
3594 currentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())
3595 if err == nil && currentPath == sessionPath {
3596 return true
3597 }
3598 if tabRuntimeSessionDir(tab) != dir {
3599 return false
3600 }
3601 currentPath, _, err = validateSessionPath(dir, tab.currentSessionPath())
3602 return err == nil && currentPath == sessionPath
3603 }
3604
3605 func (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {
3606 for _, item := range removed {
3607 if item.sink != nil {
3608 item.sink.clearContext()
3609 }
3610 if item.ctrl == nil {
3611 continue
3612 }
3613 if item.ctrl.Running() {
3614 item.ctrl.Cancel()
3615 if err := waitControllerStopped(item.ctrl); err != nil {
3616 return err
3617 }
3618 }
3619 if item.readOnly {
3620 continue
3621 }
3622 if err := item.ctrl.Snapshot(); err != nil {
3623 if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
3624 return err
3625 }
3626 slog.Warn("desktop: skipping stale runtime snapshot before removing session",
3627 "session", item.sessionPath, "err", err)
3628 }
3629 item.ctrl.SetSessionPath("")
3630 a.quiesceTabAutosave(item.tab)
3631 }
3632 return nil
3633 }
3634
3635 func waitControllerStopped(ctrl control.SessionAPI) error {
3636 deadline := time.Now().Add(5 * time.Second)
3637 for ctrl.Running() {
3638 if time.Now().After(deadline) {
3639 return fmt.Errorf("timed out waiting for cancelled session work to stop")
3640 }
3641 time.Sleep(10 * time.Millisecond)
3642 }
3643 return nil
3644 }
3645
3646 func (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {
3647 destroys := a.beginDestroySessionJobs(dir, sessionPath)
3648 for _, item := range removed {
3649 if item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {
3650 continue
3651 }
3652 destroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))
3653 }
3654 return destroys
3655 }
3656
3657 func waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {
3658 results := make(chan jobs.TeardownResult, len(destroys))
3659 waits := 0
3660 for _, destroy := range destroys {
3661 if destroy.Wait == nil {
3662 continue
3663 }
3664 waits++
3665 go func(wait func() jobs.TeardownResult) {
3666 results <- wait()
3667 }(destroy.Wait)
3668 }
3669
3670 timedOut := false
3671 for range waits {
3672 if (<-results).HasTimedOut() {
3673 timedOut = true
3674 }
3675 }
3676 return timedOut
3677 }
3678
3679 func waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {
3680 for _, destroy := range destroys {
3681 if destroy.WaitAll != nil {
3682 destroy.WaitAll()
3683 }
3684 }
3685 }
3686
3687 func finishDestroyHandles(destroys []control.SessionDestroyHandle) {
3688 for _, destroy := range destroys {
3689 if destroy.Finish != nil {
3690 destroy.Finish()
3691 }
3692 }
3693 }
3694
3695 func delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {
3696 waitAllDestroyHandles(destroys)
3697 if err := removeDesktopSessionArtifacts(path); err != nil {
3698 slog.Warn("desktop: delayed session cleanup failed", "path", path, "err", err)
3699 }
3700 finishDestroyHandles(destroys)
3701 }
3702
3703 func delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {
3704 waitAllDestroyHandles(destroys)
3705 if err := trashSessionArtifacts(dir, sessionPath, key); err != nil {
3706 slog.Warn("desktop: delayed session trash failed", "path", sessionPath, "err", err)
3707 }
3708 finishDestroyHandles(destroys)
3709 }
3710
3711 func (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {
3712 defer a.lockRuntimeMutation("close-removed-session-runtimes")()
3713 a.closeRemainingRemovedSessionRuntimesAdmissionHeld(removed, map[control.SessionAPI]bool{})
3714 }
3715
3716 func (a *App) closeRemovedSessionRuntimesForSessionAfterDestroyAdmissionHeld(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {
3717 releasedTabs := map[*WorkspaceTab]bool{}
3718 for _, item := range removed {
3719 if item.sessionDir != dir || item.sessionPath != sessionPath {
3720 continue
3721 }
3722 a.closeRemovedSessionRuntime(item, closed, releasedTabs, true)
3723 }
3724 }
3725
3726 func (a *App) closeRemainingRemovedSessionRuntimesAdmissionHeld(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {
3727 releasedTabs := map[*WorkspaceTab]bool{}
3728 for _, item := range removed {
3729 a.closeRemovedSessionRuntime(item, closed, releasedTabs, false)
3730 }
3731 }
3732
3733 func (a *App) closeRemainingRemovedSessionRuntimesAfterDestroyAdmissionHeld(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {
3734 releasedTabs := map[*WorkspaceTab]bool{}
3735 for _, item := range removed {
3736 a.closeRemovedSessionRuntime(item, closed, releasedTabs, true)
3737 }
3738 }
3739
3740 func (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {
3741 if item.tab != nil {
3742 if releasedTabs == nil || !releasedTabs[item.tab] {
3743 if releasedTabs != nil {
3744 releasedTabs[item.tab] = true
3745 }
3746 a.releaseTabSharedHost(item.tab)
3747 item.tab.releaseSessionLease()
3748 }
3749 }
3750 if item.ctrl == nil {
3751 return
3752 }
3753 if closed == nil {
3754 closed = map[control.SessionAPI]bool{}
3755 }
3756 if closed[item.ctrl] {
3757 return
3758 }
3759 closed[item.ctrl] = true
3760 if afterDestroy {
3761 item.ctrl.CloseAfterDestroy()
3762 return
3763 }
3764 item.ctrl.Close()
3765 }
3766
3767 func (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {
3768 scope := target.scope
3769 root := target.workspaceRoot
3770 topicID := strings.TrimSpace(target.topicID)
3771 if scope == "global" {
3772 root = ""
3773 }
3774 if topicID == "" {
3775 return a.openTransientBlankRuntime(scope, root)
3776 }
3777 var err error
3778 if a.singleSurfaceLayoutEnabled() {
3779 _, err = a.ActivateTopic(scope, root, topicID, "")
3780 } else if scope == "global" {
3781 _, err = a.OpenGlobalTab(topicID)
3782 } else {
3783 _, err = a.OpenProjectTab(root, topicID)
3784 }
3785 return err
3786 }
3787
3788 func (a *App) openTransientBlankRuntime(scope, workspaceRoot string) error {
3789 scope = strings.TrimSpace(scope)
3790 if scope != "project" {
3791 scope = "global"
3792 }
3793 actualRoot := ""
3794 if scope == "project" {
3795 workspaceRoot = normalizeProjectRoot(workspaceRoot)
3796 if workspaceRoot == "" {
3797 return fmt.Errorf("workspaceRoot is required")
3798 }
3799 saveWorkspace(workspaceRoot)
3800 a.registerProjectRoot(workspaceRoot)
3801 actualRoot = workspaceRoot
3802 } else {
3803 actualRoot = globalWorkspaceRoot()
3804 if err := os.MkdirAll(actualRoot, 0o755); err != nil {
3805 return fmt.Errorf("create global workspace: %w", err)
3806 }
3807 }
3808
3809 model, toolApprovalMode := desktopNewSessionDefaults(scope, actualRoot)
3810 sessionPath, err := createEmptySessionFile(desktopSessionDir(actualRoot), model)
3811 if err != nil {
3812 return err
3813 }
3814 if err := pinNewEmptySessionBranchMeta(sessionPath, scope, actualRoot, "", defaultTopicTitle); err != nil {
3815 return err
3816 }
3817 tab := &WorkspaceTab{
3818 Scope: scope,
3819 WorkspaceRoot: actualRoot,
3820 TopicTitle: defaultTopicTitle,
3821 topicTitleSource: topicTitleSourceAuto,
3822 SessionPath: sessionPath,
3823 model: model,
3824 tokenMode: boot.TokenModeFull,
3825 mode: tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
3826 toolApprovalMode: toolApprovalMode,
3827 disabledMCP: map[string]ServerView{},
3828 }
3829 a.mu.Lock()
3830 tab.ID = a.newUniqueTabIDLocked()
3831 tab.sink = &tabEventSink{tabID: tab.ID, app: a}
3832 a.tabs[tab.ID] = tab
3833 a.tabOrder = append(a.tabOrder, tab.ID)
3834 a.activeTabID = tab.ID
3835 a.saveTabsLocked()
3836 a.mu.Unlock()
3837
3838 a.startTabControllerBuild(tab)
3839 return nil
3840 }
3841
3842 func (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {
3843 a.mu.RLock()
3844 defer a.mu.RUnlock()
3845 var destroys []control.SessionDestroyHandle
3846 for _, tab := range a.runtimeTabsLocked() {
3847 if tab == nil || tab.Ctrl == nil || tabRuntimeSessionDir(tab) != dir {
3848 continue
3849 }
3850 destroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))
3851 }
3852 return destroys
3853 }
3854
3855 func (a *App) openSessionPaths(dir string) map[string]struct{} {
3856 a.mu.RLock()
3857 paths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))
3858 for _, tab := range a.runtimeTabsLocked() {
3859 if tab != nil {
3860 paths = append(paths, tab.currentSessionPath())
3861 }
3862 }
3863 a.mu.RUnlock()
3864
3865 out := make(map[string]struct{}, len(paths))
3866 for _, path := range paths {
3867 currentPath, _, err := validateSessionPath(dir, path)
3868 if err == nil {
3869 out[currentPath] = struct{}{}
3870 }
3871 }
3872 return out
3873 }
3874
3875 func (a *App) activeSessionPath(dir string) string {
3876 a.mu.RLock()
3877 var path string
3878 if tab := a.tabs[a.activeTabID]; tab != nil {
3879 path = tab.currentSessionPath()
3880 }
3881 a.mu.RUnlock()
3882 currentPath, _, err := validateSessionPath(dir, path)
3883 if err != nil {
3884 return ""
3885 }
3886 return currentPath
3887 }
3888
3889 // RestoreSession moves a trashed session back into the saved-session list.
3890 func (a *App) RestoreSession(path string) error {
3891 return friendlySessionFileError(a.restoreSession(path))
3892 }
3893
3894 func (a *App) restoreSession(path string) error {
3895 dir, err := a.trashedSessionDir(path)
3896 if err != nil {
3897 return err
3898 }
3899 _, key, _, err := validateTrashedSessionPath(dir, path)
3900 if err != nil {
3901 return err
3902 }
3903 // The destroying/open checks and the trash-entry move must not interleave
3904 // with DeleteSession/TrashTopic trashing the same entry.
3905 a.sessionRemovalMu.Lock()
3906 defer a.sessionRemovalMu.Unlock()
3907 target := filepath.Join(dir, key)
3908 if a.sessionDestroying(dir, target) {
3909 return fmt.Errorf("session cleanup is still in progress: %s", key)
3910 }
3911 if a.sessionOpen(dir, target) {
3912 return fmt.Errorf("session is open: %s", key)
3913 }
3914 if err := restoreTrashedSessionFile(dir, path); err != nil {
3915 return err
3916 }
3917 if err := restoreSessionTopicIndex(dir, target); err != nil {
3918 return err
3919 }
3920 a.emitProjectTreeChangedForSessionDirs(dir)
3921 a.invalidatePromptHistoryCache()
3922 return nil
3923 }
3924
3925 func (a *App) sessionDestroying(dir, sessionPath string) bool {
3926 a.mu.RLock()
3927 defer a.mu.RUnlock()
3928 for _, tab := range a.runtimeTabsLocked() {
3929 if tab == nil || tab.Ctrl == nil || tabRuntimeSessionDir(tab) != dir {
3930 continue
3931 }
3932 if tab.Ctrl.IsDestroyingSession(sessionPath) {
3933 return true
3934 }
3935 }
3936 return false
3937 }
3938
3939 func (a *App) sessionOpen(dir, sessionPath string) bool {
3940 a.mu.RLock()
3941 defer a.mu.RUnlock()
3942 for _, tab := range a.runtimeTabsLocked() {
3943 if tabMatchesSession(tab, dir, sessionPath) {
3944 return true
3945 }
3946 }
3947 return false
3948 }
3949
3950 // PurgeTrashedSession permanently removes a trashed session and its title/display
3951 // sidecars.
3952 func (a *App) PurgeTrashedSession(path string) error {
3953 return friendlySessionFileError(a.purgeTrashedSession(path, false))
3954 }
3955
3956 // PurgeRecoveryCopy is the guarded permanent-cleanup path. A trashed branch is
3957 // rechecked against its live parent; missing, stale, or divergent data is kept.
3958 func (a *App) PurgeRecoveryCopy(path string) error {
3959 return friendlySessionFileError(a.purgeTrashedSession(path, true))
3960 }
3961
3962 func (a *App) purgeTrashedSession(path string, requireRedundantRecovery bool) error {
3963 dir, err := a.trashedSessionDir(path)
3964 if err != nil {
3965 return err
3966 }
3967 a.sessionRemovalMu.Lock()
3968 defer a.sessionRemovalMu.Unlock()
3969 var parentGuard *agent.SessionRemovalGuard
3970 if requireRedundantRecovery {
3971 parentGuard, err = agent.TryAcquireRecoveryParentGuard(path, dir)
3972 if err != nil {
3973 switch {
3974 case errors.Is(err, agent.ErrRecoveryBranchNotCovered):
3975 return errRecoveryCopyNotRedundant
3976 case errors.Is(err, agent.ErrSessionLeaseHeld):
3977 return errSessionBusyElsewhere
3978 default:
3979 return err
3980 }
3981 }
3982 defer parentGuard.Release()
3983 }
3984 if err := purgeTrashedSessionFile(dir, path); err != nil {
3985 return err
3986 }
3987 a.invalidatePromptHistoryCache()
3988 return nil
3989 }
3990
3991 // RenameSession sets a custom display name for a session (empty clears it back to
3992 // the preview). The transcript file is unchanged; the canonical name lives in
3993 // the branch meta sidecar, with the legacy .titles.json map kept as a
3994 // compatibility write-through for older desktop data paths.
3995 func (a *App) RenameSession(path, title string) error {
3996 dir := a.activeSessionDir()
3997 sessionPath, _, err := validateSessionPath(dir, path)
3998 if err != nil {
3999 return err
4000 }
4001 if err := agent.RenameSession(sessionPath, title); err != nil {
4002 return err
4003 }
4004 if err := setSessionTitle(dir, sessionPath, title); err != nil {
4005 return err
4006 }
4007 a.invalidatePromptHistoryCache()
4008 a.emitProjectTreeChangedForSessionDirs(dir)
4009 return nil
4010 }
4011
4012 // ResumeSession snapshots the current conversation, then loads the session at
4013 // path and continues it on the active tab. The model and working folder are
4014 // unchanged; only the transcript is swapped. Returns the resumed messages for
4015 // the frontend to render.
4016 func (a *App) ResumeSession(path string) ([]HistoryMessage, error) {
4017 return a.ResumeSessionForTab("", path)
4018 }
4019
4020 func (a *App) ResumeSessionPage(path string, limit int) (HistoryPage, error) {
4021 return a.ResumeSessionPageForTab("", path, limit)
4022 }
4023
4024 func (a *App) ResumeSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) {
4025 tab, ctrl := a.tabAndCtrlByID(tabID)
4026 if tab == nil || ctrl == nil {
4027 return HistoryPage{}, fmt.Errorf("tab is not ready")
4028 }
4029 sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
4030 if err != nil {
4031 return HistoryPage{}, err
4032 }
4033 loaded, err := loadResumableSession(sessionPath)
4034 if err != nil {
4035 return HistoryPage{}, err
4036 }
4037 if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
4038 if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
4039 return HistoryPage{}, err
4040 }
4041 }
4042 a.setTabReadOnly(tab.ID, false)
4043 return a.HistoryPageForTab(tab.ID, 0, limit), nil
4044 }
4045
4046 // ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session
4047 // path is a runtime identity, so changing to a different path must replace the
4048 // tab's controller binding rather than mutating the current controller in place.
4049 func (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {
4050 tab, ctrl := a.tabAndCtrlByID(tabID)
4051 if tab == nil || ctrl == nil {
4052 return []HistoryMessage{}, fmt.Errorf("tab is not ready")
4053 }
4054 sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
4055 if err != nil {
4056 return nil, err
4057 }
4058 loaded, err := loadResumableSession(sessionPath)
4059 if err != nil {
4060 return nil, err
4061 }
4062 if sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {
4063 a.setTabReadOnly(tab.ID, false)
4064 return a.HistoryForTab(tabID), nil
4065 }
4066
4067 if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
4068 return nil, err
4069 }
4070 a.setTabReadOnly(tab.ID, false)
4071 return a.HistoryForTab(tab.ID), nil
4072 }
4073
4074 func (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {
4075 tab, ctrl := a.tabAndCtrlByID(tabID)
4076 if tab == nil || ctrl == nil {
4077 return []HistoryMessage{}, fmt.Errorf("tab is not ready")
4078 }
4079 sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
4080 if err != nil {
4081 return nil, err
4082 }
4083 loaded, err := loadResumableSession(sessionPath)
4084 if err != nil {
4085 return nil, err
4086 }
4087 if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
4088 if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
4089 return nil, err
4090 }
4091 }
4092 a.setTabReadOnly(tab.ID, true)
4093 return a.HistoryForTab(tab.ID), nil
4094 }
4095
4096 func (a *App) OpenChannelSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) {
4097 tab, ctrl := a.tabAndCtrlByID(tabID)
4098 if tab == nil || ctrl == nil {
4099 return HistoryPage{}, fmt.Errorf("tab is not ready")
4100 }
4101 sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
4102 if err != nil {
4103 return HistoryPage{}, err
4104 }
4105 loaded, err := loadResumableSession(sessionPath)
4106 if err != nil {
4107 return HistoryPage{}, err
4108 }
4109 if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
4110 if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
4111 return HistoryPage{}, err
4112 }
4113 }
4114 a.setTabReadOnly(tab.ID, true)
4115 return a.HistoryPageForTab(tab.ID, 0, limit), nil
4116 }
4117
4118 func (a *App) setTabReadOnly(tabID string, readOnly bool) {
4119 var terminalSessions []*terminalSession
4120 a.mu.Lock()
4121 tab := a.tabs[tabID]
4122 if tab == nil || tab.ReadOnly == readOnly {
4123 a.mu.Unlock()
4124 return
4125 }
4126 if a.terminals != nil {
4127 if readOnly {
4128 // Close the creation gate and detach existing sessions before
4129 // exposing the tab as read-only. The process I/O cleanup happens
4130 // after App.mu is released.
4131 terminalSessions = a.terminals.detachForTab(tabID)
4132 } else {
4133 // Reopen the terminal gate before exposing the tab as writable. A
4134 // concurrent create must never observe writable App state while
4135 // the terminal manager still treats this tab as closed.
4136 a.terminals.reopenForTab(tabID)
4137 }
4138 }
4139 tab.ReadOnly = readOnly
4140 a.saveTabsLocked()
4141 a.mu.Unlock()
4142 if len(terminalSessions) > 0 {
4143 // Existing shells can keep modifying the workspace without renderer
4144 // input, so entering a read-only channel must terminate them as part of
4145 // the same capability transition.
4146 a.terminals.closeSessions(terminalSessions)
4147 }
4148 }
4149
4150 func (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {
4151 sessionPath = canonicalTabSessionPath(sessionPath)
4152 if sessionPath == "" {
4153 return fmt.Errorf("session path is required")
4154 }
4155 loaded, err := loadResumableSession(sessionPath)
4156 if err != nil {
4157 return err
4158 }
4159 return a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)
4160 }
4161
4162 func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {
4163 if tab == nil {
4164 return fmt.Errorf("tab is not ready")
4165 }
4166 sessionPath = canonicalTabSessionPath(sessionPath)
4167 if sessionPath == "" {
4168 return fmt.Errorf("session path is required")
4169 }
4170 if agent.IsCleanupPending(sessionPath) {
4171 return fmt.Errorf("session is pending cleanup")
4172 }
4173 if loaded == nil {
4174 var err error
4175 loaded, err = loadResumableSession(sessionPath)
4176 if err != nil {
4177 return err
4178 }
4179 }
4180 // Session rebinding is a candidate transaction. Keep the source controller,
4181 // lease, runtime key, epoch, and profile live until the target controller has
4182 // built, restored, validated, and acquired its own lease. The lifecycle
4183 // barrier blocks new turns and startup publication across the transaction.
4184 a.runtimeRebuildMu.Lock()
4185 defer a.runtimeRebuildMu.Unlock()
4186
4187 // Fence an in-flight startup before waiting for the admission barrier. The
4188 // startup build holds the barrier's read side for its whole build; cancelling
4189 // its generation first lets it retire instead of making this writer wait on
4190 // a build that still believes it can publish. App.mu is released before the
4191 // barrier acquisition, so no inverted lock nesting is introduced.
4192 a.mu.Lock()
4193 if tab.removed || a.tabs[tab.ID] != tab {
4194 a.mu.Unlock()
4195 return fmt.Errorf("tab is not ready")
4196 }
4197 currentPath := ""
4198 if tab.Ctrl != nil {
4199 currentPath = strings.TrimSpace(tab.Ctrl.SessionPath())
4200 }
4201 if currentPath == "" {
4202 currentPath = strings.TrimSpace(tab.SessionPath)
4203 }
4204 if sessionRuntimeKey(currentPath) == sessionRuntimeKey(sessionPath) {
4205 // Same session: leave any in-flight build alone — resuming the
4206 // session a build is already binding must stay a no-op.
4207 a.mu.Unlock()
4208 return nil
4209 }
4210 a.supersedeTabBuildLocked(tab)
4211 source := snapshotTabRuntimeLocked(tab)
4212 a.mu.Unlock()
4213
4214 // If the target session has a detached runtime (from a recent running-session
4215 // detach), reattach it instead of building a new controller. This avoids the
4216 // Windows LockFileEx/LOCKFILE_EXCLUSIVE_LOCK conflict where a second handle
4217 // from the same process cannot lock a file already held by the detached
4218 // controller's fd (#6955).
4219 targetKey := sessionRuntimeKey(sessionPath)
4220 a.mu.Lock()
4221 detached := a.detachedSessions[targetKey]
4222 hasDetached := detached != nil && detached.Ctrl != nil
4223 a.mu.Unlock()
4224
4225 if hasDetached {
4226 a.runtimeAdmissionMu.Lock()
4227 tab.turnStartMu.Lock()
4228
4229 a.mu.Lock()
4230 if tab.removed || a.tabs[tab.ID] != tab || tab.Ctrl != source.ctrl {
4231 a.mu.Unlock()
4232 tab.turnStartMu.Unlock()
4233 a.runtimeAdmissionMu.Unlock()
4234 return fmt.Errorf("tab changed while reattaching session; retry")
4235 }
4236 a.mu.Unlock()
4237
4238 if source.ctrl != nil {
4239 if err := a.snapshotTabForAction(tab, "switching sessions"); err != nil {
4240 tab.turnStartMu.Unlock()
4241 a.runtimeAdmissionMu.Unlock()
4242 return err
4243 }
4244 if oldPath := a.reconciledSessionPathForTab(tab); oldPath != "" {
4245 if err := a.saveTabSessionMeta(tab, oldPath); err != nil {
4246 tab.turnStartMu.Unlock()
4247 a.runtimeAdmissionMu.Unlock()
4248 return fmt.Errorf("save current session metadata before switching sessions: %w", err)
4249 }
4250 }
4251 }
4252
4253 a.mu.Lock()
4254 if tab.removed || a.tabs[tab.ID] != tab || tab.Ctrl != source.ctrl {
4255 a.mu.Unlock()
4256 tab.turnStartMu.Unlock()
4257 a.runtimeAdmissionMu.Unlock()
4258 return fmt.Errorf("tab changed while reattaching session; retry")
4259 }
4260 a.mu.Unlock()
4261
4262 detachSource := controllerHasActiveRuntimeWork(source.ctrl)
4263 oldCtrl, oldSink, oldLease, oldHostKey, attached := a.reattachDetachedSessionRuntimeForRebind(
4264 tab, source, sessionPath, detachSource,
4265 )
4266 if !attached {
4267 tab.turnStartMu.Unlock()
4268 a.runtimeAdmissionMu.Unlock()
4269 return fmt.Errorf("failed to reattach detached session runtime")
4270 }
4271
4272 if oldSink != nil {
4273 oldSink.setBinding("", nil)
4274 oldSink.clearContext()
4275 }
4276 if oldCtrl != nil {
4277 oldCtrl.Close()
4278 }
4279 if oldHostKey != "" {
4280 a.releaseSharedHost(oldHostKey)
4281 }
4282 if oldLease != nil {
4283 oldLease.Release()
4284 }
4285
4286 a.clearDeferredRebuild(tab.ID)
4287 a.emitReady(a.ctx, tab.ID)
4288
4289 tab.turnStartMu.Unlock()
4290 a.runtimeAdmissionMu.Unlock()
4291 return nil
4292 }
4293
4294 a.runtimeAdmissionMu.Lock()
4295 defer a.runtimeAdmissionMu.Unlock()
4296 tab.turnStartMu.Lock()
4297 defer tab.turnStartMu.Unlock()
4298 a.mu.Lock()
4299 if tab.removed || a.tabs[tab.ID] != tab || tab.Ctrl != source.ctrl {
4300 a.mu.Unlock()
4301 return fmt.Errorf("tab changed while preparing to switch sessions; retry")
4302 }
4303 source = snapshotTabRuntimeLocked(tab)
4304 a.mu.Unlock()
4305
4306 if source.ctrl != nil {
4307 if err := a.snapshotTabForAction(tab, "switching sessions"); err != nil {
4308 return err
4309 }
4310 if oldPath := a.reconciledSessionPathForTab(tab); oldPath != "" {
4311 if err := a.saveTabSessionMeta(tab, oldPath); err != nil {
4312 return fmt.Errorf("save current session metadata before switching sessions: %w", err)
4313 }
4314 }
4315 }
4316
4317 // Snapshot recovery may have retargeted the source controller and runtime.
4318 // Refresh the identity before reserving the target alias.
4319 a.mu.Lock()
4320 if tab.removed || a.tabs[tab.ID] != tab || tab.Ctrl != source.ctrl {
4321 a.mu.Unlock()
4322 return fmt.Errorf("tab changed while preparing to switch sessions; retry")
4323 }
4324 source = snapshotTabRuntimeLocked(tab)
4325 a.mu.Unlock()
4326
4327 transition, err := a.reserveSessionRuntimePath(tab, sessionPath)
4328 if err != nil {
4329 return userFacingSessionLeaseError("", err)
4330 }
4331 committed := false
4332 defer func() {
4333 if !committed {
4334 a.rollbackSessionRuntimePath(transition)
4335 }
4336 }()
4337
4338 profile := loadTabSessionProfile(sessionPath)
4339 detachSource := controllerHasActiveRuntimeWork(source.ctrl)
4340 candidateNeedsHostRef := detachSource || source.ctrl == nil
4341 candidate, err := a.buildSessionRebindCandidate(tab, source, sessionPath, loaded, profile, candidateNeedsHostRef)
4342 if err != nil {
4343 return fmt.Errorf("resume session: %w", err)
4344 }
4345 defer func() {
4346 if !committed {
4347 candidate.close()
4348 }
4349 }()
4350
4351 targetLease, err := a.acquireCandidateSessionLease(tab, sessionPath)
4352 if err != nil {
4353 return err
4354 }
4355 defer func() {
4356 if !committed {
4357 targetLease.Release()
4358 }
4359 }()
4360 if err := a.runRebindCandidateHook("lease_acquired"); err != nil {
4361 return fmt.Errorf("resume session: %w", err)
4362 }
4363
4364 // All fallible candidate work is complete. Revalidate the source runtime
4365 // generation, atomically publish the target controller/lease/profile/path,
4366 // and advance the epoch in the same App.mu commit.
4367 a.mu.Lock()
4368 if tab.removed ||
4369 a.tabs[tab.ID] != tab ||
4370 tab.Ctrl != source.ctrl ||
4371 a.runtimeForTabLocked(tab) != transition.runtime ||
4372 !a.sessionRuntimePathTransitionValidLocked(transition) {
4373 a.mu.Unlock()
4374 return fmt.Errorf("tab runtime changed while switching sessions; retry")
4375 }
4376 var oldLease *agent.SessionLease
4377 oldCtrl := tab.Ctrl
4378 oldSink := tab.sink
4379 if detachSource {
4380 if !a.detachRuntimeForReplacementLocked(tab) {
4381 a.mu.Unlock()
4382 return fmt.Errorf("current session runtime cannot be detached")
4383 }
4384 if a.runtimeBySessionKey[transition.targetKey] == transition.runtime {
4385 delete(a.runtimeBySessionKey, transition.targetKey)
4386 }
4387 } else {
4388 if !a.commitSessionRuntimePathLocked(transition) {
4389 a.mu.Unlock()
4390 return fmt.Errorf("tab runtime changed while switching sessions; retry")
4391 }
4392 oldLease = tab.takeSessionLease()
4393 }
4394 tab.adoptSessionLease(targetLease)
4395 targetLease = nil
4396 tab.Ctrl = candidate.ctrl
4397 tab.sink = candidate.sink
4398 tab.SessionPath = sessionPath
4399 tab.model = candidate.model
4400 tab.Label = candidate.ctrl.Label()
4401 applyNormalizedRuntimeToTabLocked(tab, candidate.runtime)
4402 tab.Ready = true
4403 clearTabStartupError(tab)
4404 tab.ActivityStatus = ""
4405 tab.telemMu.Lock()
4406 tab.readTelemetry = append([]readFileRecord(nil), candidate.telemetry.ReadFiles...)
4407 tab.usageTelemetry = cloneSessionUsageStats(candidate.telemetry.Usage)
4408 tab.telemetrySessionKey = sessionRuntimeKey(sessionPath)
4409 tab.telemMu.Unlock()
4410 if tab.sink != nil {
4411 tab.sink.setBinding(tab.ID, a)
4412 tab.sink.setContext(a.ctx)
4413 }
4414 if detachSource {
4415 a.newSessionRuntimeLocked(tab, transition.targetKey)
4416 }
4417 newEpoch := a.advanceSessionRuntimeEpochLocked(tab)
4418 a.saveTabsLocked()
4419 candidate.ctrl = nil
4420 candidate.sink = nil
4421 committed = true
4422 a.mu.Unlock()
4423 // Test-only observation point: the replacement is committed but the retired
4424 // sink still carries its old epoch. Production has no hook and immediately
4425 // fences that sink below.
4426 _ = a.runRebindCandidateHook("committed")
4427
4428 // Teardown happens after publication and outside App.mu. The old lease is
4429 // released only now, so every target failure above leaves source ownership
4430 // intact. Fence the retired sink before closing the old controller so a
4431 // close-time event cannot mutate or autosave the replacement runtime.
4432 if !detachSource {
4433 if oldSink != nil {
4434 oldSink.setBinding("", nil)
4435 oldSink.clearContext()
4436 }
4437 if oldCtrl != nil {
4438 oldCtrl.Close()
4439 }
4440 if oldLease != nil {
4441 oldLease.Release()
4442 }
4443 }
4444 a.persistTabSessionPath(tab, sessionPath)
4445 a.clearDeferredRebuild(tab.ID)
4446 a.notifyTabRuntimeRebuiltAtEpoch(tab, newEpoch)
4447 a.emitReady(a.ctx, tab.ID)
4448 return nil
4449 }
4450
4451 // reattachDetachedSessionRuntimeForRebind atomically replaces tab with the
4452 // already-running detached target. If the visible source is still active, its
4453 // controller, sink, lease, and runtime registry entry move to detachedSessions
4454 // in the same App.mu transaction; an idle source is returned for off-lock
4455 // teardown. The caller must hold runtimeRebuildMu, runtimeAdmissionMu, and
4456 // tab.turnStartMu so detachSource cannot become stale through new turn admission.
4457 func (a *App) reattachDetachedSessionRuntimeForRebind(
4458 tab *WorkspaceTab,
4459 source tabRuntimeSnapshot,
4460 sessionPath string,
4461 detachSource bool,
4462 ) (control.SessionAPI, *tabEventSink, *agent.SessionLease, string, bool) {
4463 key := sessionRuntimeKey(sessionPath)
4464 if tab == nil || key == "" {
4465 return nil, nil, nil, "", false
4466 }
4467
4468 a.mu.Lock()
4469 if tab.removed || a.tabs[tab.ID] != tab || tab.Ctrl != source.ctrl {
4470 a.mu.Unlock()
4471 return nil, nil, nil, "", false
4472 }
4473 detached := a.detachedSessions[key]
4474 if detached == nil || detached.Ctrl == nil {
4475 a.mu.Unlock()
4476 return nil, nil, nil, "", false
4477 }
4478 if rt := a.runtimeForTabLocked(detached); rt != nil {
4479 if rt.Phase != sessionRuntimeReady {
4480 a.mu.Unlock()
4481 return nil, nil, nil, "", false
4482 }
4483 } else if !detached.Ready {
4484 // Compatibility for detached runtimes created before the process-local
4485 // registry existed.
4486 a.mu.Unlock()
4487 return nil, nil, nil, "", false
4488 }
4489
4490 oldCtrl := tab.Ctrl
4491 oldSink := tab.sink
4492 var oldLease *agent.SessionLease
4493 oldHostKey := ""
4494 if detachSource {
4495 if !a.detachRuntimeForReplacementLocked(tab) {
4496 a.mu.Unlock()
4497 return nil, nil, nil, "", false
4498 }
4499 // Ownership moved to the detached clone. Nothing from the source may be
4500 // closed or released after the target becomes visible.
4501 oldCtrl = nil
4502 oldSink = nil
4503 } else {
4504 // Prevent applyRuntimeTab from overwriting resources owned by the idle
4505 // source. Teardown remains outside the app lock as on the normal rebuild
4506 // path; the detached target already owns a separate shared-host ref.
4507 oldLease = tab.takeSessionLease()
4508 oldHostKey = takeTabSharedHostKey(tab)
4509 }
4510
4511 delete(a.detachedSessions, key)
4512 applyRuntimeTab(tab, detached, sessionPath, a.ctx, a)
4513 a.saveTabsLocked()
4514 attachedCtrl := tab.Ctrl
4515 a.mu.Unlock()
4516
4517 if attachedCtrl != nil {
4518 attachedCtrl.ReplayPendingPrompts()
4519 }
4520 return oldCtrl, oldSink, oldLease, oldHostKey, true
4521 }
4522
4523 type sessionRebindCandidate struct {
4524 app *App
4525 ctrl control.SessionAPI
4526 sink *tabEventSink
4527 model string
4528 runtime normalizedTabRuntime
4529 telemetry tabTelemetrySnapshot
4530 sharedHostKey string
4531 ownsSharedHostRef bool
4532 }
4533
4534 func (c *sessionRebindCandidate) close() {
4535 if c == nil {
4536 return
4537 }
4538 if c.sink != nil {
4539 c.sink.clearContext()
4540 }
4541 if c.ctrl != nil {
4542 c.ctrl.Close()
4543 c.ctrl = nil
4544 }
4545 if c.ownsSharedHostRef && c.app != nil && c.sharedHostKey != "" {
4546 c.app.releaseSharedHost(c.sharedHostKey)
4547 c.ownsSharedHostRef = false
4548 }
4549 }
4550
4551 func normalizedRuntimeForSessionProfile(profile tabSessionProfile) normalizedTabRuntime {
4552 temp := &WorkspaceTab{}
4553 applyTabSessionProfile(temp, profile)
4554 return snapshotTabRuntimeLocked(temp).normalizedRuntime()
4555 }
4556
4557 func (a *App) runRebindCandidateHook(stage string) error {
4558 if a == nil || a.rebindCandidateHook == nil {
4559 return nil
4560 }
4561 return a.rebindCandidateHook(stage)
4562 }
4563
4564 func (a *App) buildSessionRebindCandidate(
4565 tab *WorkspaceTab,
4566 source tabRuntimeSnapshot,
4567 sessionPath string,
4568 loaded *agent.Session,
4569 profile tabSessionProfile,
4570 separateRuntime bool,
4571 ) (*sessionRebindCandidate, error) {
4572 root := strings.TrimSpace(source.workspaceRoot)
4573 if root == "" {
4574 if wd, err := os.Getwd(); err == nil {
4575 root = wd
4576 }
4577 }
4578 _ = config.MigrateLegacyCredentialsForRoot(root)
4579 cfg, err := config.LoadForRoot(root)
4580 if err != nil {
4581 return nil, err
4582 }
4583
4584 model := strings.TrimSpace(source.model)
4585 if sessionModel, ok := agent.LoadSessionModel(sessionPath); ok {
4586 config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, sessionModel)
4587 if _, ok := cfg.ResolveModel(sessionModel); ok {
4588 model = sessionModel
4589 }
4590 }
4591 if model == "" {
4592 model = cfg.DefaultModel
4593 }
4594 config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, model)
4595 if resolved, _, ok := cfg.ResolveModelWithFallback(model); ok {
4596 model = resolved
4597 }
4598
4599 sessionDir := controllerSessionDir(source.ctrl)
4600 if strings.TrimSpace(sessionDir) == "" {
4601 sessionDir = filepath.Dir(sessionPath)
4602 }
4603 sink := &tabEventSink{tabID: tab.ID, app: a}
4604 runtimeProfile := normalizedRuntimeForSessionProfile(profile)
4605 sharedHost := a.lookupSharedHost(source.sharedHostKey)
4606 ownsSharedHostRef := false
4607 if separateRuntime && source.sharedHostKey != "" {
4608 sharedHost = a.acquireSharedHost(source.sharedHostKey)
4609 ownsSharedHostRef = true
4610 }
4611 ctrl, err := boot.Build(a.bootContext(), boot.Options{
4612 Model: model,
4613 RequireKey: false,
4614 AutoPricingCurrency: a.desktopAutoPricingCurrency(),
4615 StatsSource: "desktop",
4616 Sink: a.desktopControllerSink(sink, cfg.Notifications),
4617 WorkspaceRoot: root,
4618 SessionDir: sessionDir,
4619 EffortOverride: cloneStringPtr(source.effort),
4620 TokenMode: runtimeProfile.tokenMode,
4621 SharedHost: sharedHost,
4622 CleanupPendingReconciler: reconcileDesktopCleanupPending,
4623 SubagentParentLive: a.subagentParentProbeForBuild(tab),
4624 SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
4625 OnSessionRecovered: a.handleTabSessionRecovered(tab),
4626 })
4627 if err != nil {
4628 sink.clearContext()
4629 if ownsSharedHostRef {
4630 a.releaseSharedHost(source.sharedHostKey)
4631 }
4632 return nil, err
4633 }
4634 candidate := &sessionRebindCandidate{
4635 app: a, ctrl: ctrl, sink: sink, model: model, runtime: runtimeProfile,
4636 sharedHostKey: source.sharedHostKey, ownsSharedHostRef: ownsSharedHostRef,
4637 }
4638 a.bindControllerDisplayRecorder(ctrl)
4639 configureControllerRuntime(ctrl, nil, runtimeProfile)
4640 if err := a.runRebindCandidateHook("built"); err != nil {
4641 candidate.close()
4642 return nil, err
4643 }
4644 restoredRuntime, err := resumeControllerRuntimeWithSession(ctrl, loaded, sessionPath, runtimeProfile)
4645 if err != nil {
4646 candidate.close()
4647 return nil, err
4648 }
4649 candidate.runtime = restoredRuntime
4650 candidate.telemetry = loadTelemetry(sessionPath + ".telemetry.json")
4651 if err := a.runRebindCandidateHook("restored"); err != nil {
4652 candidate.close()
4653 return nil, err
4654 }
4655 return candidate, nil
4656 }
4657
4658 func (a *App) acquireCandidateSessionLease(tab *WorkspaceTab, path string) (*agent.SessionLease, error) {
4659 lease, err := withSessionLeaseContentionRetry(func() (*agent.SessionLease, error) {
4660 lease, err := agent.TryAcquireSessionLease(path)
4661 if err == nil {
4662 return lease, nil
4663 }
4664 if a.canReclaimCurrentProcessSessionLease(tab, path, err) {
4665 if reclaimed, reclaimErr := agent.TryReclaimCurrentProcessSessionLease(path); reclaimErr == nil {
4666 return reclaimed, nil
4667 } else {
4668 err = reclaimErr
4669 }
4670 }
4671 return nil, err
4672 })
4673 if err != nil {
4674 return nil, userFacingSessionLeaseError("", err)
4675 }
4676 return lease, nil
4677 }
4678
4679 func loadResumableSession(sessionPath string) (*agent.Session, error) {
4680 if agent.IsCleanupPending(sessionPath) {
4681 return nil, fmt.Errorf("session is pending cleanup")
4682 }
4683 return agent.LoadSession(sessionPath)
4684 }
4685
4686 // PreviewSession reads a saved session for display only. It does not snapshot or
4687 // swap the active controller, so the history drawer can call it while a turn runs.
4688 func (a *App) PreviewSession(path string) ([]HistoryMessage, error) {
4689 sessionDir, sessionPath, err := a.sessionDirForPath(path)
4690 if err != nil {
4691 return nil, err
4692 }
4693 return previewSessionMessages(sessionDir, sessionPath)
4694 }
4695
4696 // invalidatePromptHistoryCache resets the lazy prompt-history tape so the next
4697 // ScanPromptHistory call rebuilds session order and reloads sessions on demand.
4698 // Called from every session-mutating path: NewSession, ClearSession,
4699 // DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.
4700 func (a *App) invalidatePromptHistoryCache() {
4701 a.promptHistoryMu.Lock()
4702 a.promptHistoryTape = nil
4703 a.promptHistoryMu.Unlock()
4704 }
4705
4706 const (
4707 promptHistoryPageLimit = 50
4708 promptHistoryMaxPageLimit = 200
4709 )
4710
4711 type promptHistoryRequest struct {
4712 Nonce string `json:"nonce,omitempty"`
4713 Cursor string `json:"cursor,omitempty"`
4714 Limit int `json:"limit,omitempty"`
4715 legacy bool
4716 }
4717
4718 type promptHistoryCursor struct {
4719 Nonce string `json:"n"`
4720 Session int `json:"s"`
4721 Offset int `json:"o"`
4722 }
4723
4724 type promptHistoryTape struct {
4725 nonce string
4726 dir string
4727 currentPath string
4728 displays sessionDisplayMap
4729 sessions []promptHistorySessionFile
4730 loaded map[string][]PromptHistoryEntry
4731 }
4732
4733 // ScanPromptHistory returns the next prompt-history tape segment. The request is
4734 // a JSON string so the Wails binding stays one-argument while the protocol can
4735 // carry a cursor and page limit. Older clients may still pass a bare nonce; that
4736 // path keeps the old cache-hit behavior.
4737 func (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {
4738 req := parsePromptHistoryRequest(rawRequest)
4739 dir := a.activeSessionDir()
4740 sessionPath := a.activeSessionPath(dir)
4741
4742 a.promptHistoryMu.Lock()
4743 tape, err := a.promptHistoryTapeForLocked(dir, sessionPath)
4744 if err != nil {
4745 a.promptHistoryMu.Unlock()
4746 return PromptHistoryResult{}, err
4747 }
4748 if req.legacy && req.Nonce != "" && req.Nonce == tape.nonce {
4749 a.promptHistoryMu.Unlock()
4750 return PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil
4751 }
4752 result := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))
4753 a.promptHistoryMu.Unlock()
4754 return result, nil
4755 }
4756
4757 func parsePromptHistoryRequest(raw string) promptHistoryRequest {
4758 raw = strings.TrimSpace(raw)
4759 if raw == "" {
4760 return promptHistoryRequest{}
4761 }
4762 if strings.HasPrefix(raw, "{") {
4763 var req promptHistoryRequest
4764 if err := json.Unmarshal([]byte(raw), &req); err == nil {
4765 return req
4766 }
4767 }
4768 return promptHistoryRequest{Nonce: raw, legacy: true}
4769 }
4770
4771 func promptHistoryLimit(limit int) int {
4772 if limit <= 0 {
4773 return promptHistoryPageLimit
4774 }
4775 if limit > promptHistoryMaxPageLimit {
4776 return promptHistoryMaxPageLimit
4777 }
4778 return limit
4779 }
4780
4781 func (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {
4782 currentPath := ""
4783 if path, _, err := validateSessionPath(dir, sessionPath); err == nil {
4784 currentPath = path
4785 }
4786 if a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {
4787 return a.promptHistoryTape, nil
4788 }
4789 tape, err := newPromptHistoryTape(dir, currentPath)
4790 if err != nil {
4791 return nil, err
4792 }
4793 a.promptHistoryTape = tape
4794 return tape, nil
4795 }
4796
4797 func (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {
4798 tape, err := newPromptHistoryTape(dir, "")
4799 if err != nil {
4800 return nil, err
4801 }
4802 return tape.readAll(), nil
4803 }
4804
4805 func newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {
4806 tape := &promptHistoryTape{
4807 nonce: fmt.Sprintf("%d", time.Now().UnixNano()),
4808 dir: dir,
4809 currentPath: currentPath,
4810 displays: loadSessionDisplays(dir),
4811 loaded: map[string][]PromptHistoryEntry{},
4812 }
4813 sessions, err := promptHistorySessionFiles(dir)
4814 if err != nil {
4815 return nil, err
4816 }
4817 if currentPath != "" {
4818 currentPath = filepath.Clean(currentPath)
4819 currentSession := promptHistorySessionFile{}
4820 currentIndex := -1
4821 for i, session := range sessions {
4822 if filepath.Clean(session.path) == currentPath {
4823 currentSession = session
4824 currentIndex = i
4825 break
4826 }
4827 }
4828 if currentIndex >= 0 {
4829 sessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)
4830 } else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {
4831 sessions = append([]promptHistorySessionFile{{
4832 path: currentPath,
4833 }}, sessions...)
4834 }
4835 }
4836 tape.sessions = sessions
4837 return tape, nil
4838 }
4839
4840 func (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {
4841 c := promptHistoryCursor{Nonce: t.nonce}
4842 if decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {
4843 c = decoded
4844 }
4845 if c.Session < 0 {
4846 c.Session = 0
4847 }
4848 if c.Offset < 0 {
4849 c.Offset = 0
4850 }
4851
4852 out := make([]PromptHistoryEntry, 0, limit)
4853 sessionIndex := c.Session
4854 offset := c.Offset
4855 for sessionIndex < len(t.sessions) && len(out) < limit {
4856 entries, err := t.entriesForSession(sessionIndex)
4857 if err != nil || offset >= len(entries) {
4858 sessionIndex++
4859 offset = 0
4860 continue
4861 }
4862
4863 end := min(len(entries), offset+limit-len(out))
4864 out = append(out, entries[offset:end]...)
4865 offset = end
4866 if offset >= len(entries) && len(out) < limit {
4867 sessionIndex++
4868 offset = 0
4869 }
4870 }
4871
4872 if sessionIndex < len(t.sessions) {
4873 if entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {
4874 sessionIndex++
4875 offset = 0
4876 }
4877 }
4878 hasOlder := sessionIndex < len(t.sessions)
4879 olderCursor := ""
4880 if hasOlder {
4881 olderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})
4882 }
4883 return PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}
4884 }
4885
4886 func (t *promptHistoryTape) readAll() []PromptHistoryEntry {
4887 out := []PromptHistoryEntry{}
4888 cursor := ""
4889 for {
4890 page := t.readOlder(cursor, promptHistoryMaxPageLimit)
4891 out = append(out, page.Entries...)
4892 if !page.HasOlder || page.OlderCursor == "" {
4893 return out
4894 }
4895 cursor = page.OlderCursor
4896 }
4897 }
4898
4899 func (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {
4900 if index < 0 || index >= len(t.sessions) {
4901 return nil, nil
4902 }
4903 path := t.sessions[index].path
4904 if entries, ok := t.loaded[path]; ok {
4905 return entries, nil
4906 }
4907 info, err := os.Stat(path)
4908 if err != nil {
4909 t.loaded[path] = nil
4910 if os.IsNotExist(err) {
4911 return nil, nil
4912 }
4913 return nil, err
4914 }
4915 entries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))
4916 if err != nil {
4917 t.loaded[path] = nil
4918 return nil, err
4919 }
4920 t.loaded[path] = entries
4921 return entries, nil
4922 }
4923
4924 func encodePromptHistoryCursor(cursor promptHistoryCursor) string {
4925 b, err := json.Marshal(cursor)
4926 if err != nil {
4927 return ""
4928 }
4929 return base64.RawURLEncoding.EncodeToString(b)
4930 }
4931
4932 func decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {
4933 if strings.TrimSpace(value) == "" {
4934 return promptHistoryCursor{}, false
4935 }
4936 b, err := base64.RawURLEncoding.DecodeString(value)
4937 if err != nil {
4938 return promptHistoryCursor{}, false
4939 }
4940 var cursor promptHistoryCursor
4941 if err := json.Unmarshal(b, &cursor); err != nil {
4942 return promptHistoryCursor{}, false
4943 }
4944 return cursor, true
4945 }
4946
4947 func scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {
4948 entries, err := collectPromptHistoryEntries(path, info, resolveUserContent)
4949 if err != nil {
4950 return nil, err
4951 }
4952 sortPromptHistoryNewestFirst(entries)
4953 return entries, nil
4954 }
4955
4956 type promptHistorySessionFile struct {
4957 path string
4958 }
4959
4960 func promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {
4961 infos, err := agent.ListSessionOrder(dir)
4962 if err != nil {
4963 return nil, err
4964 }
4965 sessions := make([]promptHistorySessionFile, 0, len(infos))
4966 for _, info := range infos {
4967 sessions = append(sessions, promptHistorySessionFile{path: info.Path})
4968 }
4969 return sessions, nil
4970 }
4971
4972 func promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {
4973 if a.At != b.At {
4974 return a.At > b.At
4975 }
4976 if a.SessionPath != b.SessionPath {
4977 return a.SessionPath > b.SessionPath
4978 }
4979 return a.Turn > b.Turn
4980 }
4981
4982 func sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {
4983 sort.Slice(entries, func(i, j int) bool {
4984 return promptHistoryEntryNewer(entries[i], entries[j])
4985 })
4986 }
4987
4988 func collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {
4989 var out []PromptHistoryEntry
4990 emit := func(entry PromptHistoryEntry) {
4991 out = append(out, entry)
4992 }
4993 // Sessions with an event log must replay it: the .jsonl checkpoint stops
4994 // gaining turns between checkpoints, so scanning it directly would freeze
4995 // ↑-recall at each session's last checkpoint.
4996 if handled, err := collectEventLogUserPrompts(path, info, resolveUserContent, emit); handled {
4997 return out, err
4998 }
4999 err := collectJSONLUserPrompts(path, info, resolveUserContent, emit)
5000 return out, err
5001 }
5002
5003 func collectEventLogUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) (bool, error) {
5004 logPath := store.SessionEventLog(path)
5005 if logPath == "" {
5006 return false, nil
5007 }
5008 if logInfo, err := os.Stat(logPath); err != nil || logInfo.IsDir() || logInfo.Size() == 0 {
5009 return false, nil
5010 }
5011 users, err := agent.LoadSessionUserMessages(path)
5012 if err != nil {
5013 return true, err
5014 }
5015 fallbackAt := promptHistoryFallbackMillis(path, info)
5016 turn := 0
5017 for _, user := range users {
5018 text := strings.TrimSpace(resolveUserContent(strings.TrimSpace(user.Text)))
5019 if text == "" || control.IsSyntheticUserMessage(text) {
5020 continue
5021 }
5022 at := fallbackAt
5023 if !user.At.IsZero() {
5024 at = user.At.UnixMilli()
5025 }
5026 emit(PromptHistoryEntry{
5027 Text: text,
5028 At: at,
5029 SessionPath: path,
5030 Turn: turn,
5031 })
5032 turn++
5033 }
5034 return true, nil
5035 }
5036
5037 func collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {
5038 f, err := os.Open(path)
5039 if err != nil {
5040 return err
5041 }
5042 defer f.Close()
5043
5044 fallbackAt := promptHistoryFallbackMillis(path, info)
5045
5046 dec := json.NewDecoder(f)
5047 turn := 0
5048 for {
5049 var rec previewEventRecord
5050 if err := dec.Decode(&rec); err != nil {
5051 if errors.Is(err, io.EOF) {
5052 break
5053 }
5054 return nil // partial results are better than none
5055 }
5056 // Format compatibility:
5057 // 1) Legacy event format: {"kind":"user.message","text":"..."}
5058 // 2) Early event format: {"type":"user.message","text":"..."}
5059 // 3) Current provider.Message format: {"role":"user","content":"..."}
5060 text := ""
5061 kindOrType := strings.TrimSpace(rec.Kind)
5062 if kindOrType == "" {
5063 kindOrType = strings.TrimSpace(rec.Type)
5064 }
5065 if kindOrType == "user.message" {
5066 text = strings.TrimSpace(rec.Text)
5067 } else if strings.TrimSpace(rec.Role) == "user" {
5068 text = strings.TrimSpace(rec.Content)
5069 }
5070 if text != "" {
5071 text = resolveUserContent(text)
5072 text = strings.TrimSpace(text)
5073 if text == "" {
5074 continue
5075 }
5076 if control.IsSyntheticUserMessage(text) {
5077 continue
5078 }
5079 at := fallbackAt
5080 if eventAt, ok := promptHistoryEventMillis(rec); ok {
5081 at = eventAt
5082 }
5083 entry := PromptHistoryEntry{
5084 Text: text,
5085 At: at,
5086 SessionPath: path,
5087 Turn: turn,
5088 }
5089 emit(entry)
5090 turn++
5091 }
5092 }
5093 return nil
5094 }
5095
5096 func promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {
5097 if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {
5098 return meta.UpdatedAt.UnixMilli()
5099 }
5100 if info != nil {
5101 return info.ModTime().UnixMilli()
5102 }
5103 return 0
5104 }
5105
5106 func promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {
5107 for _, raw := range []json.RawMessage{
5108 rec.TS,
5109 rec.Time,
5110 rec.Timestamp,
5111 rec.CreatedAt,
5112 rec.CreatedAtSnake,
5113 rec.UpdatedAt,
5114 rec.UpdatedAtSnake,
5115 } {
5116 if at, ok := parseJSONTimestampMillis(raw); ok {
5117 return at, true
5118 }
5119 }
5120 return 0, false
5121 }
5122
5123 func parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {
5124 if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
5125 return 0, false
5126 }
5127
5128 var s string
5129 if err := json.Unmarshal(raw, &s); err == nil {
5130 s = strings.TrimSpace(s)
5131 if s == "" {
5132 return 0, false
5133 }
5134 if n, err := strconv.ParseInt(s, 10, 64); err == nil {
5135 return normalizeTimestampMillis(n)
5136 }
5137 if f, err := strconv.ParseFloat(s, 64); err == nil {
5138 return normalizeTimestampMillisFloat(f)
5139 }
5140 if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
5141 return t.UnixMilli(), true
5142 }
5143 return 0, false
5144 }
5145
5146 dec := json.NewDecoder(bytes.NewReader(raw))
5147 dec.UseNumber()
5148 var n json.Number
5149 if err := dec.Decode(&n); err != nil {
5150 return 0, false
5151 }
5152 if i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {
5153 return normalizeTimestampMillis(i)
5154 }
5155 if f, err := strconv.ParseFloat(n.String(), 64); err == nil {
5156 return normalizeTimestampMillisFloat(f)
5157 }
5158 return 0, false
5159 }
5160
5161 func normalizeTimestampMillis(v int64) (int64, bool) {
5162 if v <= 0 {
5163 return 0, false
5164 }
5165 switch {
5166 case v >= 1_000_000_000_000_000_000:
5167 return v / 1_000_000, true // nanoseconds
5168 case v >= 1_000_000_000_000_000:
5169 return v / 1_000, true // microseconds
5170 case v >= 100_000_000_000:
5171 return v, true // milliseconds
5172 case v >= 1_000_000_000:
5173 return v * 1_000, true // seconds
5174 default:
5175 return 0, false
5176 }
5177 }
5178
5179 func normalizeTimestampMillisFloat(v float64) (int64, bool) {
5180 if v <= 0 {
5181 return 0, false
5182 }
5183 switch {
5184 case v >= 1_000_000_000_000_000_000:
5185 return int64(v / 1_000_000), true
5186 case v >= 1_000_000_000_000_000:
5187 return int64(v / 1_000), true
5188 case v >= 100_000_000_000:
5189 return int64(v), true
5190 case v >= 1_000_000_000:
5191 return int64(v * 1_000), true
5192 default:
5193 return 0, false
5194 }
5195 }
5196
5197 // PickWorkspace opens a folder chooser and, on a pick, opens a new project tab
5198 // scoped to that folder. Returns the chosen path ("" if cancelled).
5199 func (a *App) PickWorkspace() (string, error) {
5200 if a.ctx == nil {
5201 return "", nil
5202 }
5203 cur, _ := os.Getwd()
5204 a.mu.RLock()
5205 if tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != "" {
5206 cur = tab.WorkspaceRoot
5207 }
5208 a.mu.RUnlock()
5209 dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
5210 Title: "Choose working folder",
5211 DefaultDirectory: dialogDefaultDirectory(cur),
5212 })
5213 if err != nil || dir == "" {
5214 return "", err
5215 }
5216 return a.SwitchWorkspace(dir)
5217 }
5218
5219 func dialogDefaultDirectory(preferred string) string {
5220 if dir := nearestExistingDirectory(preferred); dir != "" {
5221 return dir
5222 }
5223 if cwd, err := os.Getwd(); err == nil {
5224 if dir := nearestExistingDirectory(cwd); dir != "" {
5225 return dir
5226 }
5227 }
5228 if home, err := os.UserHomeDir(); err == nil {
5229 if dir := nearestExistingDirectory(home); dir != "" {
5230 return dir
5231 }
5232 }
5233 return ""
5234 }
5235
5236 func nearestExistingDirectory(path string) string {
5237 path = strings.TrimSpace(path)
5238 if path == "" {
5239 return ""
5240 }
5241 if abs, err := filepath.Abs(path); err == nil {
5242 path = abs
5243 }
5244 for {
5245 info, err := os.Stat(path)
5246 if err == nil {
5247 if info.IsDir() {
5248 return path
5249 }
5250 path = filepath.Dir(path)
5251 continue
5252 }
5253 parent := filepath.Dir(path)
5254 if parent == path {
5255 return ""
5256 }
5257 path = parent
5258 }
5259 }
5260
5261 func (a *App) ListWorkspaces() []WorkspaceMeta {
5262 migrateLegacyWorkspacesIntoProjects()
5263 activeRoot := ""
5264 cur, _ := os.Getwd()
5265 a.mu.RLock()
5266 if tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != "" {
5267 activeRoot = normalizeProjectRoot(tab.WorkspaceRoot)
5268 }
5269 a.mu.RUnlock()
5270 if activeRoot == "" {
5271 activeRoot = normalizeProjectRoot(cur)
5272 }
5273 projects := loadProjectsFile().Projects
5274 out := make([]WorkspaceMeta, 0, len(projects))
5275 for _, project := range projects {
5276 out = append(out, WorkspaceMeta{
5277 Path: project.Root,
5278 Name: projectDisplayName(project),
5279 Current: activeRoot != "" && sameProjectRoot(project.Root, activeRoot),
5280 })
5281 }
5282 return out
5283 }
5284
5285 func (a *App) RemoveWorkspace(dir string) error {
5286 if dir == "" {
5287 return fmt.Errorf("workspace path is required")
5288 }
5289 dir = normalizeProjectRoot(dir)
5290
5291 var fallback *WorkspaceTab
5292 // sessionRemovalMu covers every step that can still touch this workspace's
5293 // session files: snapshotting, unlinking the tab/runtime bindings, and
5294 // closing the unlinked runtimes (quiescing autosave). Once a runtime is
5295 // unlinked from a.tabs/detachedSessions it is invisible to
5296 // DeleteSession/TrashTopic/RestoreSession, so it must stop writing before
5297 // the lock is released. Project bookkeeping, the fallback controller build,
5298 // and notifications run after release.
5299 if err := func() error {
5300 defer a.lockRuntimeMutation("remove-workspace")()
5301 a.sessionRemovalMu.Lock()
5302 defer a.sessionRemovalMu.Unlock()
5303
5304 type workspaceTabCandidate struct {
5305 id string
5306 tab *WorkspaceTab
5307 }
5308
5309 var closeTabs []*WorkspaceTab
5310 var closeDetached []*WorkspaceTab
5311 a.mu.Lock()
5312 for _, tab := range a.tabs {
5313 if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
5314 a.mu.Unlock()
5315 return fmt.Errorf("workspace has running sessions; stop them before removing")
5316 }
5317 }
5318 for _, tab := range a.detachedSessions {
5319 if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
5320 a.mu.Unlock()
5321 return fmt.Errorf("workspace has running sessions; stop them before removing")
5322 }
5323 }
5324 candidates := make([]workspaceTabCandidate, 0)
5325 for id, tab := range a.tabs {
5326 if !tabInWorkspace(tab, dir) {
5327 continue
5328 }
5329 candidates = append(candidates, workspaceTabCandidate{id: id, tab: tab})
5330 }
5331 a.mu.Unlock()
5332
5333 snapshotted := make(map[string]*WorkspaceTab, len(candidates))
5334 for _, candidate := range candidates {
5335 id, tab := candidate.id, candidate.tab
5336 snapshotted[id] = tab
5337 if err := a.snapshotTab(tab); err != nil {
5338 slog.Warn("desktop: snapshot before removing workspace failed", "tab", id, "workspace", dir, "err", err)
5339 return fmt.Errorf("save current session before removing workspace: %w", err)
5340 }
5341 }
5342
5343 a.mu.Lock()
5344 for _, tab := range a.tabs {
5345 if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
5346 a.mu.Unlock()
5347 return fmt.Errorf("workspace has running sessions; stop them before removing")
5348 }
5349 }
5350 for _, tab := range a.detachedSessions {
5351 if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() {
5352 a.mu.Unlock()
5353 return fmt.Errorf("workspace has running sessions; stop them before removing")
5354 }
5355 }
5356 for id, tab := range a.tabs {
5357 if tabInWorkspace(tab, dir) && snapshotted[id] != tab {
5358 a.mu.Unlock()
5359 return fmt.Errorf("workspace tabs changed while removing; retry")
5360 }
5361 }
5362 for _, candidate := range candidates {
5363 id, tab := candidate.id, candidate.tab
5364 if tab == nil || a.tabs[id] != tab || !tabInWorkspace(tab, dir) {
5365 continue
5366 }
5367 a.markTabRemovedLocked(tab)
5368 closeTabs = append(closeTabs, tab)
5369 delete(a.tabs, id)
5370 a.removeTabOrderLocked(id)
5371 if a.activeTabID == id {
5372 a.activeTabID = ""
5373 }
5374 }
5375 for key, tab := range a.detachedSessions {
5376 if !tabInWorkspace(tab, dir) {
5377 continue
5378 }
5379 closeDetached = append(closeDetached, tab)
5380 delete(a.detachedSessions, key)
5381 }
5382 if len(a.tabs) == 0 {
5383 fallback = a.createTabEntry("global", globalTabWorkspaceRoot(), "")
5384 fallback.TopicTitle = "Global"
5385 fallback.sink = &tabEventSink{tabID: fallback.ID, app: a, ctx: a.ctx}
5386 a.tabs[fallback.ID] = fallback
5387 a.tabOrder = append(a.tabOrder, fallback.ID)
5388 a.activeTabID = fallback.ID
5389 } else if a.activeTabID == "" {
5390 if ordered := a.orderedTabIDsLocked(); len(ordered) > 0 {
5391 a.activeTabID = ordered[0]
5392 }
5393 }
5394 a.saveTabsLocked()
5395 a.mu.Unlock()
5396
5397 for _, tab := range closeTabs {
5398 a.closeTabRuntimeAdmissionHeld(tab)
5399 }
5400 for _, tab := range closeDetached {
5401 a.closeTabRuntimeAdmissionHeld(tab)
5402 }
5403 return nil
5404 }(); err != nil {
5405 return err
5406 }
5407
5408 // The fallback tab is already linked into a.tabs; its controller build is
5409 // asynchronous and does not touch removed session files, so it does not
5410 // need the removal lock.
5411 if fallback != nil {
5412 a.startTabControllerBuild(fallback)
5413 }
5414
5415 forgetWorkspace(dir)
5416 if err := removeProject(dir); err != nil {
5417 return err
5418 }
5419 // If the removed workspace was the active one, clear the pointer
5420 // so we don't leave a stale reference to a deleted project.
5421 if loadWorkspace() == dir {
5422 if remaining := loadProjectsFile(); len(remaining.Projects) > 0 {
5423 // Fall back to the first remaining project
5424 saveWorkspace(remaining.Projects[0].Root)
5425 } else {
5426 // No projects left; clear the active pointer entirely
5427 clearWorkspace()
5428 }
5429 }
5430 projectSessionCache.forgetDirs(desktopSessionDir(dir))
5431 a.emitProjectTreeMetadataChanged()
5432 return nil
5433 }
5434
5435 func migrateLegacyWorkspacesIntoProjects() {
5436 legacy := loadWorkspaces()
5437 if len(legacy) == 0 {
5438 return
5439 }
5440 _ = updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
5441 seen := make(map[string]bool, len(f.Projects)+len(legacy))
5442 for _, p := range f.Projects {
5443 seen[p.Root] = true
5444 }
5445 changed := false
5446 for _, path := range legacy {
5447 root := normalizeProjectRoot(path)
5448 if root == "" || seen[root] {
5449 continue
5450 }
5451 f.Projects = append(f.Projects, desktopProject{Root: root})
5452 seen[root] = true
5453 changed = true
5454 }
5455 return changed, nil
5456 })
5457 }
5458
5459 func workspaceName(path string) string {
5460 name := filepath.Base(path)
5461 if name == "." || name == string(filepath.Separator) || name == "" {
5462 return path
5463 }
5464 return name
5465 }
5466
5467 // tabWorkspaceNameForScope resolves the display name for a tab's workspace.
5468 // Callers pass tab.Scope copied under a.mu instead of re-reading the tab.
5469 func tabWorkspaceNameForScope(scope, cwd string) string {
5470 if scope == "global" {
5471 return globalProjectTitle()
5472 }
5473 return workspaceName(cwd)
5474 }
5475
5476 func (a *App) SwitchWorkspace(dir string) (string, error) {
5477 if dir == "" {
5478 home, err := os.UserHomeDir()
5479 if err != nil {
5480 return "", err
5481 }
5482 dir = home
5483 }
5484 if abs, err := filepath.Abs(dir); err == nil {
5485 dir = abs
5486 }
5487 info, err := os.Stat(dir)
5488 if err != nil {
5489 return "", err
5490 }
5491 if !info.IsDir() {
5492 return "", fmt.Errorf("%s is not a directory", dir)
5493 }
5494 saveWorkspace(dir)
5495
5496 // Open a registered topic so the new workspace appears in the project tree
5497 // immediately instead of only existing as an in-memory tab.
5498 topic, err := a.CreateTopic("project", dir, "")
5499 if err != nil {
5500 return "", err
5501 }
5502 var meta TabMeta
5503 if a.singleSurfaceLayoutEnabled() {
5504 meta, err = a.ActivateTopic("project", dir, topic.ID, "")
5505 } else {
5506 meta, err = a.OpenProjectTab(dir, topic.ID)
5507 }
5508 if err != nil {
5509 return "", err
5510 }
5511 return meta.WorkspaceRoot, nil
5512 }
5513
5514 func (a *App) singleSurfaceLayoutEnabled() bool {
5515 cfg, _, err := a.loadDesktopUserConfigForView()
5516 if err != nil {
5517 return true
5518 }
5519 return singleSurfaceLayoutStyle(cfg.DesktopLayoutStyle())
5520 }
5521
5522 // HistoryMessage is one prior turn, for the frontend to repopulate its transcript
5523 // after a reload.
5524 type HistoryMessage struct {
5525 Role string `json:"role"`
5526 Content string `json:"content"`
5527 Detail string `json:"detail,omitempty"`
5528 Code string `json:"code,omitempty"`
5529 SubmitText string `json:"submitText,omitempty"`
5530 CheckpointTurn *int `json:"checkpointTurn,omitempty"`
5531 CreatedAt int64 `json:"createdAt,omitempty"`
5532 Reasoning string `json:"reasoning,omitempty"`
5533 MemoryCitations []provider.MemoryCitation `json:"memoryCitations,omitempty"`
5534 WorkDurationMs int64 `json:"workDurationMs,omitempty"`
5535 Level string `json:"level,omitempty"`
5536 ToolCalls []HistoryToolCall `json:"toolCalls,omitempty"`
5537 ToolCallID string `json:"toolCallId,omitempty"`
5538 ToolName string `json:"toolName,omitempty"`
5539 ToolResultArchived bool `json:"toolResultArchived,omitempty"`
5540 ToolResultError string `json:"toolResultError,omitempty"`
5541 // Execution is local shell metadata restored onto ToolCards after history
5542 // reload. Omitted when absent so older frontends ignore it safely.
5543 Execution *provider.ToolExecution `json:"execution,omitempty"`
5544 Pending bool `json:"pending,omitempty"`
5545 Trigger string `json:"trigger,omitempty"`
5546 Messages int `json:"messages,omitempty"`
5547 Summary string `json:"summary,omitempty"`
5548 Archive string `json:"archive,omitempty"`
5549 DecisionReceipt *provider.DecisionReceipt `json:"decisionReceipt,omitempty"`
5550 }
5551
5552 type HistoryToolCall struct {
5553 ID string `json:"id"`
5554 Name string `json:"name"`
5555 Arguments string `json:"arguments"`
5556 ResolvedName string `json:"resolvedName,omitempty"`
5557 CapabilityID string `json:"capabilityId,omitempty"`
5558 ResolvedReadOnly *bool `json:"resolvedReadOnly,omitempty"`
5559 Subject string `json:"subject,omitempty"`
5560 Summary string `json:"summary,omitempty"`
5561 Diff string `json:"diff,omitempty"`
5562 Added int `json:"added,omitempty"`
5563 Removed int `json:"removed,omitempty"`
5564 ArgumentsArchived bool `json:"argumentsArchived,omitempty"`
5565 }
5566
5567 const (
5568 defaultHistoryPageTurns = 60
5569 maxHistoryPageTurns = 200
5570 )
5571
5572 type HistoryPage struct {
5573 Messages []HistoryMessage `json:"messages"`
5574 StartTurn int `json:"startTurn"`
5575 EndTurn int `json:"endTurn"`
5576 TotalTurns int `json:"totalTurns"`
5577 HasOlder bool `json:"hasOlder"`
5578 }
5579
5580 // historyProviderMessagesWithPersistedTimes overlays legacy event-record
5581 // timestamps onto a copy for display. It deliberately leaves the controller's
5582 // provider transcript untouched: timestamp migration must not change session
5583 // digests, conflict detection, or model-request cache prefixes.
5584 func historyProviderMessagesWithPersistedTimes(msgs []provider.Message, sessionPath string) []provider.Message {
5585 if len(msgs) == 0 || strings.TrimSpace(sessionPath) == "" {
5586 return msgs
5587 }
5588 needsPersistedTime := false
5589 for _, msg := range msgs {
5590 if msg.Role == provider.RoleUser && msg.CreatedAt <= 0 && agent.IsUserAuthoredTurn(agent.UserMessageText(msg)) {
5591 needsPersistedTime = true
5592 break
5593 }
5594 }
5595 if !needsPersistedTime {
5596 return msgs
5597 }
5598 users, err := agent.LoadSessionUserMessages(sessionPath)
5599 if err != nil || len(users) == 0 {
5600 return msgs
5601 }
5602 out := append([]provider.Message(nil), msgs...)
5603 userIndex := 0
5604 for i := range out {
5605 if out[i].Role != provider.RoleUser {
5606 continue
5607 }
5608 if userIndex >= len(users) {
5609 break
5610 }
5611 user := users[userIndex]
5612 userIndex++
5613 if out[i].CreatedAt <= 0 && !user.At.IsZero() {
5614 out[i].CreatedAt = user.At.UnixMilli()
5615 }
5616 }
5617 return out
5618 }
5619
5620 // History returns the session's message log.
5621 func (a *App) History() []HistoryMessage {
5622 return a.HistoryForTab("")
5623 }
5624
5625 func (a *App) HistoryPage(beforeTurn, limit int) HistoryPage {
5626 return a.HistoryPageForTab("", beforeTurn, limit)
5627 }
5628
5629 func (a *App) HistoryPageForTab(tabID string, beforeTurn, limit int) HistoryPage {
5630 a.mu.RLock()
5631 tab := a.tabByIDLocked(tabID)
5632 var ctrl control.SessionAPI
5633 var sessionDir, sessionPath string
5634 if tab != nil {
5635 ctrl = tab.Ctrl
5636 sessionDir = tabSessionDir(tab)
5637 sessionPath = tab.currentSessionPath()
5638 }
5639 a.mu.RUnlock()
5640 if ctrl == nil {
5641 if strings.TrimSpace(sessionPath) == "" {
5642 return HistoryPage{Messages: []HistoryMessage{}}
5643 }
5644 page, err := previewSessionPage(sessionDir, sessionPath, beforeTurn, limit)
5645 if err != nil {
5646 return HistoryPage{Messages: []HistoryMessage{}}
5647 }
5648 return page
5649 }
5650 dir := controllerSessionDir(ctrl)
5651 path := ctrl.SessionPath()
5652 msgs := historyProviderMessagesWithPersistedTimes(ctrl.History(), path)
5653 return historyPageFromProviderMessages(
5654 msgs,
5655 sessionDisplayResolver(dir, path),
5656 sessionPlannerDisplayTurns(dir, path),
5657 ctrl.CheckpointTurnsByMessageIndex(),
5658 beforeTurn,
5659 limit,
5660 )
5661 }
5662
5663 func normalizeHistoryPageLimit(limit int) int {
5664 if limit <= 0 {
5665 return defaultHistoryPageTurns
5666 }
5667 if limit > maxHistoryPageTurns {
5668 return maxHistoryPageTurns
5669 }
5670 return limit
5671 }
5672
5673 func historyPageFromMessages(messages []HistoryMessage, beforeTurn, limit int) HistoryPage {
5674 limit = normalizeHistoryPageLimit(limit)
5675 totalTurns := 0
5676 for _, msg := range messages {
5677 if msg.Role == "user" {
5678 totalTurns++
5679 }
5680 }
5681 if beforeTurn <= 0 || beforeTurn > totalTurns {
5682 beforeTurn = totalTurns
5683 }
5684 startTurn := beforeTurn - limit
5685 if startTurn < 0 {
5686 startTurn = 0
5687 }
5688 page := HistoryPage{
5689 StartTurn: startTurn,
5690 EndTurn: beforeTurn,
5691 TotalTurns: totalTurns,
5692 HasOlder: startTurn > 0,
5693 }
5694 if len(messages) == 0 || startTurn >= beforeTurn {
5695 page.Messages = []HistoryMessage{}
5696 return page
5697 }
5698 page.Messages = historyMessagesForTurnRange(messages, startTurn, beforeTurn)
5699 return page
5700 }
5701
5702 func historyMessagesForTurnRange(messages []HistoryMessage, startTurn, endTurn int) []HistoryMessage {
5703 out := make([]HistoryMessage, 0, len(messages))
5704 turn := -1
5705 for _, msg := range messages {
5706 if msg.Role == "user" {
5707 turn++
5708 }
5709 if turn < 0 {
5710 if startTurn == 0 {
5711 out = append(out, msg)
5712 }
5713 continue
5714 }
5715 if turn >= startTurn && turn < endTurn {
5716 out = append(out, msg)
5717 }
5718 }
5719 return out
5720 }
5721
5722 func (a *App) HistoryForTab(tabID string) []HistoryMessage {
5723 a.mu.RLock()
5724 tab := a.tabByIDLocked(tabID)
5725 var ctrl control.SessionAPI
5726 var sessionDir, sessionPath string
5727 if tab != nil {
5728 ctrl = tab.Ctrl
5729 sessionDir = tabSessionDir(tab)
5730 sessionPath = tab.currentSessionPath()
5731 }
5732 a.mu.RUnlock()
5733 if ctrl == nil {
5734 if strings.TrimSpace(sessionPath) == "" {
5735 return []HistoryMessage{}
5736 }
5737 messages, err := previewSessionMessages(sessionDir, sessionPath)
5738 if err != nil {
5739 return []HistoryMessage{}
5740 }
5741 return messages
5742 }
5743 dir := controllerSessionDir(ctrl)
5744 path := ctrl.SessionPath()
5745 msgs := historyProviderMessagesWithPersistedTimes(ctrl.History(), path)
5746 return historyMessagesWithPlannerDisplays(
5747 msgs,
5748 sessionDisplayResolver(dir, path),
5749 sessionPlannerDisplayTurns(dir, path),
5750 ctrl.CheckpointTurnsByMessageIndex(),
5751 )
5752 }
5753
5754 func (a *App) HistoryCheckpointTurnsForTab(tabID string) []int {
5755 a.mu.RLock()
5756 tab := a.tabByIDLocked(tabID)
5757 var ctrl control.SessionAPI
5758 if tab != nil {
5759 ctrl = tab.Ctrl
5760 }
5761 a.mu.RUnlock()
5762 if ctrl == nil {
5763 return []int{}
5764 }
5765 return historyCheckpointTurns(
5766 ctrl.History(),
5767 sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()),
5768 ctrl.CheckpointTurnsByMessageIndex(),
5769 )
5770 }
5771
5772 var pastedTextDisplayLabelPattern = regexp.MustCompile(`^\[(?:已粘贴文本|已貼上文字|Pasted text) #[0-9]+ · [0-9]+ (?:行|lines)\]$`)
5773
5774 // historyReplayUserContent keeps only user-authored replay data. Provider-facing
5775 // capability, goal, hook, and resolved-reference context must not be resubmitted.
5776 func historyReplayUserContent(content string) string {
5777 return control.StripReferencedContextPrefix(control.StripComposePrefixes(content))
5778 }
5779
5780 // collapseLegacyExpandedPasteDisplay repairs sessions whose user-authored replay
5781 // source still contains an expanded pasted-text block. This includes transcripts
5782 // written before RawContent existed. The expanded block remains in SubmitText so
5783 // edit replay can still reconstruct the card and recover its full payload.
5784 func collapseLegacyExpandedPasteDisplay(content string) string {
5785 const beginPrefix = "--- Begin "
5786 for scan := 0; scan < len(content); {
5787 beginOffset := strings.Index(content[scan:], beginPrefix)
5788 if beginOffset < 0 {
5789 break
5790 }
5791 begin := scan + beginOffset
5792 labelStart := begin + len(beginPrefix)
5793 labelEndOffset := strings.Index(content[labelStart:], " ---")
5794 if labelEndOffset < 0 {
5795 break
5796 }
5797 labelEnd := labelStart + labelEndOffset
5798 label := content[labelStart:labelEnd]
5799 beginEnd := labelEnd + len(" ---")
5800 if !pastedTextDisplayLabelPattern.MatchString(label) {
5801 scan = beginEnd
5802 continue
5803 }
5804 endMarker := "--- End " + label + " ---"
5805 endOffset := strings.Index(content[beginEnd:], endMarker)
5806 if endOffset < 0 {
5807 scan = beginEnd
5808 continue
5809 }
5810 labelCopy := strings.LastIndex(content[:begin], label)
5811 if labelCopy < 0 || strings.TrimSpace(content[labelCopy+len(label):begin]) != "" {
5812 scan = beginEnd
5813 continue
5814 }
5815 end := beginEnd + endOffset + len(endMarker)
5816 content = content[:labelCopy+len(label)] + content[end:]
5817 scan = labelCopy + len(label)
5818 }
5819 return strings.TrimSpace(content)
5820 }
5821
5822 // historyUserDisplayContent prefers a persisted display sidecar when one exists.
5823 // Comparing it with the deterministic fallback distinguishes a sidecar hit
5824 // without changing the resolver API used throughout history pagination.
5825 func historyUserDisplayContent(msg provider.Message, resolveUserContent func(string) string) string {
5826 resolved := strings.TrimSpace(resolveUserContent(msg.Content))
5827 fallback := strings.TrimSpace(historyReplayUserContent(msg.Content))
5828 if resolved != "" && resolved != fallback {
5829 return resolved
5830 }
5831 replaySource := agent.UserMessageText(msg)
5832 if msg.RawContent == "" {
5833 replaySource = fallback
5834 }
5835 return collapseLegacyExpandedPasteDisplay(replaySource)
5836 }
5837
5838 func historyCheckpointTurns(msgs []provider.Message, resolveUserContent func(string) string, checkpointTurns map[int]int) []int {
5839 out := make([]int, 0)
5840 for index, msg := range msgs {
5841 if msg.Role != provider.RoleUser {
5842 continue
5843 }
5844 content := agent.UserMessageText(msg)
5845 if _, isSteer := agent.SteerText(content); isSteer {
5846 continue
5847 }
5848 content = historyUserDisplayContent(msg, resolveUserContent)
5849 if control.IsSyntheticUserMessage(content) {
5850 continue
5851 }
5852 turn, ok := checkpointTurns[index]
5853 if !ok {
5854 turn = -1
5855 }
5856 out = append(out, turn)
5857 }
5858 return out
5859 }
5860
5861 func historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage {
5862 return historyMessagesWithPlannerDisplays(msgs, resolveUserContent, nil, nil)
5863 }
5864
5865 func historyMessagesWithPlannerDisplays(msgs []provider.Message, resolveUserContent func(string) string, plannerTurns []plannerDisplayTurn, checkpointTurns map[int]int) []HistoryMessage {
5866 replayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs)
5867 toolResults := historyToolResultsByID(msgs)
5868 return historyMessagesWithPlannerDisplaysAndLookups(msgs, resolveUserContent, plannerTurns, checkpointTurns, replayedTodoArgs, toolResults)
5869 }
5870
5871 func historyMessagesWithPlannerDisplaysAndLookups(
5872 msgs []provider.Message,
5873 resolveUserContent func(string) string,
5874 plannerTurns []plannerDisplayTurn,
5875 checkpointTurns map[int]int,
5876 replayedTodoArgs map[string]string,
5877 toolResults map[string]provider.Message,
5878 ) []HistoryMessage {
5879 out := make([]HistoryMessage, 0, len(msgs))
5880 plannerByUserHash := plannerTurnsByUserHash(plannerTurns)
5881 suppressCanonicalTurn := false
5882 for index, m := range msgs {
5883 if m.DecisionReceipt != nil {
5884 out = append(out, HistoryMessage{
5885 Role: "notice",
5886 Code: event.NoticeCodeDecisionReceipt,
5887 Level: "info",
5888 DecisionReceipt: cloneDecisionReceipt(m.DecisionReceipt),
5889 })
5890 continue
5891 }
5892 if m.LocalOnly {
5893 if steerText, isSteer := agent.SteerText(agent.UserMessageText(m)); isSteer {
5894 out = append(out, HistoryMessage{
5895 Role: "notice",
5896 Content: agent.UnappliedSteerNotice(steerText),
5897 Code: event.NoticeCodeUnappliedSteer,
5898 Level: "warn",
5899 })
5900 continue
5901 }
5902 }
5903 if suppressCanonicalTurn {
5904 if m.Role != provider.RoleUser || !agent.IsUserAuthoredTurn(agent.UserMessageText(m)) {
5905 continue
5906 }
5907 suppressCanonicalTurn = false
5908 }
5909 content := m.Content
5910 var checkpointTurn *int
5911 if m.Role == provider.RoleUser {
5912 // Mid-turn steer messages are persisted in the session so they
5913 // survive tab switches. They are surfaced as a notice (↪ text)
5914 // — matching the live Steer event look — rather than as a
5915 // regular user bubble or being filtered as synthetic (#4044).
5916 // Check against the raw m.Content: resolveUserContent applies
5917 // StripComposePrefixes which trims trailing whitespace.
5918 if steerText, isSteer := agent.SteerText(agent.UserMessageText(m)); isSteer {
5919 out = append(out, HistoryMessage{Role: "notice", Content: "↪ " + steerText})
5920 continue
5921 }
5922 content = historyUserDisplayContent(m, resolveUserContent)
5923 if control.IsSyntheticUserMessage(content) {
5924 continue
5925 }
5926 if turn, ok := checkpointTurns[index]; ok {
5927 turnCopy := turn
5928 checkpointTurn = &turnCopy
5929 }
5930 }
5931 reasoning := ""
5932 if m.Role == provider.RoleAssistant || m.LocalOnly {
5933 reasoning = m.ReasoningContent
5934 }
5935 displayRole := string(m.Role)
5936 if m.LocalOnly {
5937 displayRole = "assistant"
5938 }
5939 hm := HistoryMessage{Role: displayRole, Content: content, CheckpointTurn: checkpointTurn, CreatedAt: m.CreatedAt, Reasoning: reasoning, WorkDurationMs: m.WorkDurationMs}
5940 if m.Role == provider.RoleAssistant && len(m.MemoryCitations) > 0 {
5941 hm.MemoryCitations = append([]provider.MemoryCitation(nil), m.MemoryCitations...)
5942 }
5943 if m.Role == provider.RoleUser && content != m.Content {
5944 replay := historyReplayUserContent(m.Content)
5945 if agent.ContainsMemoryCompilerExecution(m.Content) {
5946 // Never expose the compiler contract itself. A safely unwrapped
5947 // slash invocation is useful display metadata, though: it lets the
5948 // frontend restore the selected skill/subagent in history and trash.
5949 if strings.HasPrefix(strings.TrimSpace(replay), "/") && replay != content {
5950 hm.SubmitText = replay
5951 }
5952 } else if replay != content {
5953 hm.SubmitText = replay
5954 }
5955 }
5956 if (m.Role == provider.RoleAssistant || m.LocalOnly) && len(m.ToolCalls) > 0 {
5957 hm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))
5958 for i, tc := range m.ToolCalls {
5959 args := tc.Arguments
5960 if tc.Name == "todo_write" {
5961 if replayed, ok := replayedTodoArgs[tc.ID]; ok {
5962 args = replayed
5963 }
5964 }
5965 hm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID])
5966 }
5967 }
5968 if m.Role == provider.RoleTool && !m.LocalOnly {
5969 hm.ToolCallID = m.ToolCallID
5970 hm.ToolName = m.Name
5971 hm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != "")
5972 hm.Execution = m.ToolExecution
5973 }
5974 hasVisibleLocalContent := strings.TrimSpace(hm.Content) != "" || strings.TrimSpace(hm.Reasoning) != "" || len(hm.ToolCalls) > 0 || (!m.LocalOnly && m.Role == provider.RoleTool)
5975 if !m.LocalOnly || hasVisibleLocalContent {
5976 out = append(out, hm)
5977 }
5978 for _, receipt := range m.DecisionReceipts {
5979 if receipt == nil {
5980 continue
5981 }
5982 out = append(out, HistoryMessage{
5983 Role: "notice",
5984 Code: event.NoticeCodeDecisionReceipt,
5985 Level: "info",
5986 DecisionReceipt: cloneDecisionReceipt(receipt),
5987 })
5988 }
5989 if m.LocalOnly && m.InterruptedTurn != nil {
5990 out = append(out, HistoryMessage{
5991 Role: "notice", Level: "info", Code: event.NoticeCodeCancelledTurn,
5992 Content: "This turn was interrupted. Partial output is kept for reference; only completed tool pairs and a bounded recovery summary enter the next model turn. Inspect the workspace before continuing or reverting changes.",
5993 })
5994 }
5995 if m.Role == provider.RoleUser {
5996 key := messageDisplayKey(agent.UserMessageText(m))
5997 if turns := plannerByUserHash[key]; len(turns) > 0 {
5998 out = append(out, cloneHistoryMessages(turns[0].Messages)...)
5999 suppressCanonicalTurn = plannerDisplaySuppressesCanonical(turns[0])
6000 plannerByUserHash[key] = turns[1:]
6001 }
6002 }
6003 }
6004 return out
6005 }
6006
6007 func cloneDecisionReceipt(in *provider.DecisionReceipt) *provider.DecisionReceipt {
6008 if in == nil {
6009 return nil
6010 }
6011 copy := *in
6012 return &copy
6013 }
6014
6015 func plannerDisplaySuppressesCanonical(turn plannerDisplayTurn) bool {
6016 for _, message := range turn.Messages {
6017 if message.Role == "notice" && message.Code == event.NoticeCodeCancelledTurn {
6018 return true
6019 }
6020 }
6021 return false
6022 }
6023
6024 func historyPageFromProviderMessages(
6025 msgs []provider.Message,
6026 resolveUserContent func(string) string,
6027 plannerTurns []plannerDisplayTurn,
6028 checkpointTurns map[int]int,
6029 beforeTurn, limit int,
6030 ) HistoryPage {
6031 limit = normalizeHistoryPageLimit(limit)
6032 totalTurns := visibleHistoryUserTurns(msgs, resolveUserContent)
6033 if beforeTurn <= 0 || beforeTurn > totalTurns {
6034 beforeTurn = totalTurns
6035 }
6036 startTurn := beforeTurn - limit
6037 if startTurn < 0 {
6038 startTurn = 0
6039 }
6040 page := HistoryPage{
6041 StartTurn: startTurn,
6042 EndTurn: beforeTurn,
6043 TotalTurns: totalTurns,
6044 HasOlder: startTurn > 0,
6045 }
6046 if len(msgs) == 0 || startTurn >= beforeTurn {
6047 page.Messages = []HistoryMessage{}
6048 return page
6049 }
6050 pageMessages, originalIndexes := providerMessagesForVisibleTurnRange(msgs, resolveUserContent, startTurn, beforeTurn)
6051 page.Messages = historyMessagesWithPlannerDisplaysAndLookups(
6052 pageMessages,
6053 resolveUserContent,
6054 plannerTurns,
6055 checkpointTurnsForProviderWindow(checkpointTurns, originalIndexes),
6056 historyTodoArgsWithCompleteSteps(msgs),
6057 historyToolResultsByID(msgs),
6058 )
6059 return page
6060 }
6061
6062 func visibleHistoryUserTurns(msgs []provider.Message, resolveUserContent func(string) string) int {
6063 total := 0
6064 for _, msg := range msgs {
6065 if isVisibleHistoryUser(msg, resolveUserContent) {
6066 total++
6067 }
6068 }
6069 return total
6070 }
6071
6072 func isVisibleHistoryUser(msg provider.Message, resolveUserContent func(string) string) bool {
6073 if msg.Role != provider.RoleUser {
6074 return false
6075 }
6076 content := agent.UserMessageText(msg)
6077 if _, isSteer := agent.SteerText(content); isSteer {
6078 return false
6079 }
6080 content = historyUserDisplayContent(msg, resolveUserContent)
6081 return !control.IsSyntheticUserMessage(content)
6082 }
6083
6084 func providerMessagesForVisibleTurnRange(msgs []provider.Message, resolveUserContent func(string) string, startTurn, endTurn int) ([]provider.Message, []int) {
6085 out := make([]provider.Message, 0, len(msgs))
6086 indexes := make([]int, 0, len(msgs))
6087 turn := -1
6088 for index, msg := range msgs {
6089 if isVisibleHistoryUser(msg, resolveUserContent) {
6090 turn++
6091 }
6092 if turn < 0 {
6093 if startTurn == 0 {
6094 out = append(out, msg)
6095 indexes = append(indexes, index)
6096 }
6097 continue
6098 }
6099 if turn >= startTurn && turn < endTurn {
6100 out = append(out, msg)
6101 indexes = append(indexes, index)
6102 }
6103 }
6104 return out, indexes
6105 }
6106
6107 func checkpointTurnsForProviderWindow(checkpointTurns map[int]int, originalIndexes []int) map[int]int {
6108 if len(checkpointTurns) == 0 || len(originalIndexes) == 0 {
6109 return nil
6110 }
6111 out := map[int]int{}
6112 for pageIndex, originalIndex := range originalIndexes {
6113 if turn, ok := checkpointTurns[originalIndex]; ok {
6114 out[pageIndex] = turn
6115 }
6116 }
6117 return out
6118 }
6119
6120 func plannerTurnsByUserHash(turns []plannerDisplayTurn) map[string][]plannerDisplayTurn {
6121 out := map[string][]plannerDisplayTurn{}
6122 for _, turn := range turns {
6123 if strings.TrimSpace(turn.UserHash) == "" || len(turn.Messages) == 0 {
6124 continue
6125 }
6126 out[turn.UserHash] = append(out[turn.UserHash], turn)
6127 }
6128 return out
6129 }
6130
6131 func cloneHistoryMessages(in []HistoryMessage) []HistoryMessage {
6132 if len(in) == 0 {
6133 return nil
6134 }
6135 out := make([]HistoryMessage, len(in))
6136 copy(out, in)
6137 for i := range out {
6138 if len(in[i].MemoryCitations) > 0 {
6139 out[i].MemoryCitations = append([]provider.MemoryCitation(nil), in[i].MemoryCitations...)
6140 }
6141 if len(in[i].ToolCalls) > 0 {
6142 out[i].ToolCalls = append([]HistoryToolCall(nil), in[i].ToolCalls...)
6143 }
6144 }
6145 return out
6146 }
6147
6148 const historyToolPreviewLimit = 2_000
6149
6150 func historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {
6151 call := HistoryToolCall{
6152 ID: tc.ID,
6153 Name: tc.Name,
6154 ResolvedName: tc.ResolvedName,
6155 CapabilityID: tc.CapabilityID,
6156 ResolvedReadOnly: tc.ResolvedReadOnly,
6157 Subject: historyToolSubject(tc.Name, args),
6158 Summary: historyToolSummary(tc.Name, args, result.Content),
6159 Diff: tc.Diff,
6160 Added: tc.Added,
6161 Removed: tc.Removed,
6162 }
6163 if tc.Name == "todo_write" {
6164 call.Arguments = args
6165 return call
6166 }
6167 if tc.ID == "" {
6168 call.Arguments = args
6169 return call
6170 }
6171 if args != "" {
6172 call.ArgumentsArchived = true
6173 }
6174 return call
6175 }
6176
6177 func historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {
6178 out := map[string]provider.Message{}
6179 for _, msg := range msgs {
6180 if msg.Role != provider.RoleTool || msg.ToolCallID == "" {
6181 continue
6182 }
6183 out[msg.ToolCallID] = msg
6184 }
6185 return out
6186 }
6187
6188 func historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {
6189 if content == "" {
6190 return "", false, ""
6191 }
6192 if !canArchive {
6193 if historyToolResultFailed(content) {
6194 return content, false, content
6195 }
6196 return content, false, ""
6197 }
6198 if historyToolResultFailed(content) {
6199 display = clipHistoryToolPreview(strings.TrimSpace(content))
6200 return display, display != content, display
6201 }
6202 return "", true, ""
6203 }
6204
6205 func clipHistoryToolPreview(s string) string {
6206 if len(s) <= historyToolPreviewLimit {
6207 return s
6208 }
6209 return strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + "\n..."
6210 }
6211
6212 func historyToolSubject(name, args string) string {
6213 a := parseHistoryToolArgs(args)
6214 var subject string
6215 switch name {
6216 case "bash":
6217 subject = historyArgString(a, "command")
6218 case "grep", "glob":
6219 subject = firstNonEmpty(historyArgString(a, "pattern"), historyArgString(a, "path"))
6220 case "web_fetch":
6221 subject = historyArgString(a, "url")
6222 case "task":
6223 subject = firstNonEmpty(historyArgString(a, "description"), historyArgString(a, "prompt"))
6224 case "run_skill":
6225 subject = historyArgString(a, "name")
6226 case "move_file":
6227 src := historyArgString(a, "source_path")
6228 dst := historyArgString(a, "destination_path")
6229 if src != "" && dst != "" {
6230 subject = src + " -> " + dst
6231 } else {
6232 subject = firstNonEmpty(src, dst)
6233 }
6234 case "remember":
6235 subject = firstNonEmpty(historyArgString(a, "name"), historyArgString(a, "description"))
6236 case "todo_write", "exit_plan_mode":
6237 subject = ""
6238 default:
6239 subject = firstNonEmpty(historyArgString(a, "path"), historyArgString(a, "file_path"))
6240 }
6241 return clipSingleLine(subject, 240)
6242 }
6243
6244 func historyToolSummary(name, args, output string) string {
6245 if historyToolResultFailed(output) {
6246 return ""
6247 }
6248 a := parseHistoryToolArgs(args)
6249 switch name {
6250 case "write_file":
6251 if content := historyArgString(a, "content"); content != "" {
6252 return fmt.Sprintf("%d lines", historyLineCount(content))
6253 }
6254 case "edit_file":
6255 oldText := historyArgString(a, "old_string")
6256 newText := historyArgString(a, "new_string")
6257 if oldText != "" || newText != "" {
6258 return fmt.Sprintf("%d -> %d lines", historyLineCount(oldText), historyLineCount(newText))
6259 }
6260 case "multi_edit":
6261 if edits, ok := a["edits"].([]any); ok && len(edits) > 0 {
6262 return fmt.Sprintf("%d edits", len(edits))
6263 }
6264 }
6265 if output == "" {
6266 return ""
6267 }
6268 switch name {
6269 case "read_file":
6270 if strings.HasPrefix(output, "(empty file)") {
6271 return "empty file"
6272 }
6273 if arrows := strings.Count(output, "→"); arrows > 0 {
6274 return fmt.Sprintf("%d lines", arrows)
6275 }
6276 return fmt.Sprintf("%d lines", historyLineCount(output))
6277 case "grep":
6278 return fmt.Sprintf("%d matches", historyNonEmptyLineCount(output))
6279 case "glob":
6280 return fmt.Sprintf("%d files", historyNonEmptyLineCount(output))
6281 case "ls":
6282 return fmt.Sprintf("%d entries", historyNonEmptyLineCount(output))
6283 case "web_fetch":
6284 return clipSingleLine(strings.SplitN(output, "\n", 2)[0], 80)
6285 case "bash":
6286 if strings.TrimSpace(output) == "" {
6287 return "no output"
6288 }
6289 return fmt.Sprintf("%d lines", historyLineCount(output))
6290 default:
6291 return ""
6292 }
6293 }
6294
6295 func parseHistoryToolArgs(args string) map[string]any {
6296 if args == "" {
6297 return map[string]any{}
6298 }
6299 var out map[string]any
6300 if err := json.Unmarshal([]byte(args), &out); err != nil {
6301 return map[string]any{}
6302 }
6303 return out
6304 }
6305
6306 func historyArgString(args map[string]any, key string) string {
6307 if v, ok := args[key].(string); ok {
6308 return v
6309 }
6310 return ""
6311 }
6312
6313 func historyLineCount(s string) int {
6314 if s == "" {
6315 return 0
6316 }
6317 s = strings.TrimSuffix(s, "\n")
6318 if s == "" {
6319 return 0
6320 }
6321 return strings.Count(s, "\n") + 1
6322 }
6323
6324 func historyNonEmptyLineCount(s string) int {
6325 count := 0
6326 for _, line := range strings.Split(s, "\n") {
6327 if strings.TrimSpace(line) != "" {
6328 count++
6329 }
6330 }
6331 return count
6332 }
6333
6334 func clipSingleLine(s string, max int) string {
6335 s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
6336 if len(s) <= max {
6337 return s
6338 }
6339 if max <= 3 {
6340 return clipStringBytes(s, max)
6341 }
6342 return clipStringBytes(s, max-3) + "..."
6343 }
6344
6345 func clipStringBytes(s string, max int) string {
6346 if max <= 0 {
6347 return ""
6348 }
6349 if len(s) <= max {
6350 return s
6351 }
6352 for max > 0 && !utf8.RuneStart(s[max]) {
6353 max--
6354 }
6355 return s[:max]
6356 }
6357
6358 func historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string {
6359 successful := successfulHistoryToolCallIDs(msgs)
6360 out := map[string]string{}
6361 var todos []evidence.TodoItem
6362 latestTodoID := ""
6363 for _, m := range msgs {
6364 for _, tc := range m.ToolCalls {
6365 if tc.ID == "" || !successful[tc.ID] {
6366 continue
6367 }
6368 switch tc.Name {
6369 case "todo_write":
6370 rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)
6371 if len(rec.Todos) == 0 {
6372 continue
6373 }
6374 todos = evidence.NormalizeSerialTodos(rec.Todos)
6375 latestTodoID = tc.ID
6376 if args, ok := todoArgsJSON(todos); ok {
6377 out[latestTodoID] = args
6378 }
6379 case "complete_step":
6380 if latestTodoID == "" || len(todos) == 0 {
6381 continue
6382 }
6383 rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true)
6384 match, ok := evidence.MatchStep(rec.Step, todos)
6385 if !ok || !evidence.AdvanceSerialTodo(todos, match.Index-1) {
6386 continue
6387 }
6388 if args, ok := todoArgsJSON(todos); ok {
6389 out[latestTodoID] = args
6390 }
6391 }
6392 }
6393 }
6394 return out
6395 }
6396
6397 func successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool {
6398 successful := map[string]bool{}
6399 for _, msg := range msgs {
6400 if msg.Role != provider.RoleTool || msg.ToolCallID == "" {
6401 continue
6402 }
6403 if !historyToolResultFailed(msg.Content) {
6404 successful[msg.ToolCallID] = true
6405 }
6406 }
6407 return successful
6408 }
6409
6410 func historyToolResultFailed(content string) bool {
6411 content = strings.TrimSpace(content)
6412 return strings.HasPrefix(content, "error:") ||
6413 strings.HasPrefix(content, "blocked:") ||
6414 strings.HasPrefix(content, "Error:") ||
6415 strings.HasPrefix(content, "[error")
6416 }
6417
6418 func todoArgsJSON(todos []evidence.TodoItem) (string, bool) {
6419 b, err := json.Marshal(map[string]any{"todos": todos})
6420 if err != nil {
6421 return "", false
6422 }
6423 return string(b), true
6424 }
6425
6426 func previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {
6427 sessionPath, _, err := validateSessionPath(sessionDir, path)
6428 if err != nil {
6429 return nil, err
6430 }
6431 if out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {
6432 return out, err
6433 }
6434 loaded, err := agent.LoadSession(sessionPath)
6435 if err != nil {
6436 return nil, err
6437 }
6438 return historyMessagesWithPlannerDisplays(
6439 historyProviderMessagesWithPersistedTimes(loaded.Snapshot(), sessionPath),
6440 sessionDisplayResolver(sessionDir, sessionPath),
6441 sessionPlannerDisplayTurns(sessionDir, sessionPath),
6442 nil,
6443 ), nil
6444 }
6445
6446 func previewSessionPage(sessionDir, path string, beforeTurn, limit int) (HistoryPage, error) {
6447 sessionPath, _, err := validateSessionPath(sessionDir, path)
6448 if err != nil {
6449 return HistoryPage{}, err
6450 }
6451 if out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {
6452 if err != nil {
6453 return HistoryPage{}, err
6454 }
6455 return historyPageFromMessages(out, beforeTurn, limit), nil
6456 }
6457 loaded, err := agent.LoadSession(sessionPath)
6458 if err != nil {
6459 return HistoryPage{}, err
6460 }
6461 return historyPageFromProviderMessages(
6462 historyProviderMessagesWithPersistedTimes(loaded.Snapshot(), sessionPath),
6463 sessionDisplayResolver(sessionDir, sessionPath),
6464 sessionPlannerDisplayTurns(sessionDir, sessionPath),
6465 nil,
6466 beforeTurn,
6467 limit,
6468 ), nil
6469 }
6470
6471 type previewEventRecord struct {
6472 Kind string `json:"kind"`
6473 Type string `json:"type"`
6474 Role string `json:"role"`
6475 TS json.RawMessage `json:"ts"`
6476 Time json.RawMessage `json:"time"`
6477 Timestamp json.RawMessage `json:"timestamp"`
6478 CreatedAt json.RawMessage `json:"createdAt"`
6479 CreatedAtSnake json.RawMessage `json:"created_at"`
6480 UpdatedAt json.RawMessage `json:"updatedAt"`
6481 UpdatedAtSnake json.RawMessage `json:"updated_at"`
6482 Text string `json:"text"`
6483 Detail string `json:"detail"`
6484 Code string `json:"code"`
6485 Content string `json:"content"`
6486 Reasoning string `json:"reasoning"`
6487 ReasoningContent string `json:"reasoningContent"`
6488 MemoryCitations []provider.MemoryCitation `json:"memoryCitations"`
6489 Level string `json:"level"`
6490 ToolCalls []previewToolCall `json:"toolCalls"`
6491 CallID string `json:"callId"`
6492 ToolCallID string `json:"toolCallId"`
6493 ToolName string `json:"toolName"`
6494 Name string `json:"name"`
6495 Output string `json:"output"`
6496 Compaction *previewCompaction `json:"compaction"`
6497 Trigger string `json:"trigger"`
6498 Messages int `json:"messages"`
6499 Summary string `json:"summary"`
6500 Archive string `json:"archive"`
6501 }
6502
6503 type previewToolCall struct {
6504 ID string `json:"id"`
6505 Name string `json:"name"`
6506 Arguments string `json:"arguments"`
6507 Function struct {
6508 Name string `json:"name"`
6509 Arguments string `json:"arguments"`
6510 } `json:"function"`
6511 }
6512
6513 type previewCompaction struct {
6514 Trigger string `json:"trigger"`
6515 Messages int `json:"messages"`
6516 Summary string `json:"summary"`
6517 Archive string `json:"archive"`
6518 }
6519
6520 func previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {
6521 f, err := os.Open(path)
6522 if err != nil {
6523 return nil, false, err
6524 }
6525 defer f.Close()
6526
6527 dec := json.NewDecoder(f)
6528 out := []HistoryMessage{}
6529 toolName := map[string]string{}
6530 sawEvent := false
6531 for {
6532 var rec previewEventRecord
6533 if err := dec.Decode(&rec); err != nil {
6534 if errors.Is(err, io.EOF) {
6535 break
6536 }
6537 if sawEvent {
6538 return out, true, nil
6539 }
6540 return nil, false, nil
6541 }
6542 eventName := strings.TrimSpace(rec.Kind)
6543 if eventName == "" {
6544 eventName = strings.TrimSpace(rec.Type)
6545 }
6546 if eventName == "" {
6547 continue
6548 }
6549 sawEvent = true
6550 switch eventName {
6551 case "user.message":
6552 if rec.Text != "" {
6553 hm := HistoryMessage{Role: "user", Content: rec.Text}
6554 if at, ok := promptHistoryEventMillis(rec); ok {
6555 hm.CreatedAt = at
6556 }
6557 out = append(out, hm)
6558 }
6559 case "model.final":
6560 hm := HistoryMessage{Role: "assistant", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}
6561 if len(rec.MemoryCitations) > 0 {
6562 hm.MemoryCitations = append([]provider.MemoryCitation(nil), rec.MemoryCitations...)
6563 }
6564 for _, tc := range rec.ToolCalls {
6565 id := tc.ID
6566 name := firstNonEmpty(tc.Name, tc.Function.Name)
6567 args := firstNonEmpty(tc.Arguments, tc.Function.Arguments)
6568 hm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))
6569 if id != "" {
6570 toolName[id] = name
6571 }
6572 }
6573 out = append(out, hm)
6574 case "tool.result":
6575 callID := firstNonEmpty(rec.CallID, rec.ToolCallID)
6576 content := firstNonEmpty(rec.Output, rec.Content)
6577 display, archived, errPreview := historyToolResultContent(content, callID != "")
6578 if len(out) > 0 && callID != "" {
6579 updateHistoryToolCallSummary(out, callID, content)
6580 }
6581 out = append(out, HistoryMessage{
6582 Role: "tool",
6583 ToolCallID: callID,
6584 ToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),
6585 Content: display,
6586 ToolResultArchived: archived,
6587 ToolResultError: errPreview,
6588 })
6589 case "phase":
6590 out = append(out, HistoryMessage{Role: "phase", Content: firstNonEmpty(rec.Text, rec.Content)})
6591 case "notice":
6592 level := rec.Level
6593 if level != "warn" {
6594 level = "info"
6595 }
6596 out = append(out, HistoryMessage{Role: "notice", Level: level, Content: firstNonEmpty(rec.Text, rec.Content), Detail: rec.Detail, Code: rec.Code})
6597 case "compaction_started":
6598 c := rec.compactionPayload()
6599 out = append(out, HistoryMessage{Role: "compaction", Pending: true, Trigger: c.Trigger})
6600 case "compaction_done":
6601 c := rec.compactionPayload()
6602 out = append(out, HistoryMessage{
6603 Role: "compaction",
6604 Trigger: c.Trigger,
6605 Messages: c.Messages,
6606 Summary: c.Summary,
6607 Archive: c.Archive,
6608 })
6609 }
6610 }
6611 return out, sawEvent, nil
6612 }
6613
6614 func (r previewEventRecord) compactionPayload() previewCompaction {
6615 if r.Compaction != nil {
6616 return *r.Compaction
6617 }
6618 return previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}
6619 }
6620
6621 func updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {
6622 if callID == "" {
6623 return
6624 }
6625 for i := len(out) - 1; i >= 0; i-- {
6626 for j := range out[i].ToolCalls {
6627 call := &out[i].ToolCalls[j]
6628 if call.ID != callID {
6629 continue
6630 }
6631 if call.Summary == "" {
6632 call.Summary = historyToolSummary(call.Name, call.Arguments, output)
6633 }
6634 return
6635 }
6636 }
6637 }
6638
6639 func firstNonEmpty(values ...string) string {
6640 for _, value := range values {
6641 if value != "" {
6642 return value
6643 }
6644 }
6645 return ""
6646 }
6647
6648 // ContextInfo is the prompt-vs-window gauge payload plus session totals. Used
6649 // and Window both zero means no context-window data yet.
6650 type ContextInfo struct {
6651 Used int `json:"used"`
6652 Window int `json:"window"`
6653 SessionTokens int `json:"sessionTokens"`
6654 CompactRatio float64 `json:"compactRatio,omitempty"`
6655 SessionCost float64 `json:"sessionCost,omitempty"`
6656 SessionCurrency string `json:"sessionCurrency,omitempty"`
6657 CacheHitTokens int `json:"cacheHitTokens,omitempty"`
6658 CacheMissTokens int `json:"cacheMissTokens,omitempty"`
6659 Estimated bool `json:"estimated,omitempty"`
6660 Sources map[string]usageSourceStats `json:"sources,omitempty"`
6661 }
6662
6663 // ContextUsage returns the latest context-window gauge numbers.
6664 func (a *App) ContextUsage() ContextInfo {
6665 return a.ContextUsageForTab("")
6666 }
6667
6668 func (a *App) ContextUsageForTab(tabID string) ContextInfo {
6669 a.mu.RLock()
6670 tab := a.tabByIDLocked(tabID)
6671 var ctrl control.SessionAPI
6672 if tab != nil {
6673 ctrl = tab.Ctrl
6674 }
6675 a.mu.RUnlock()
6676
6677 var info ContextInfo
6678 var snap tabTelemetrySnapshot
6679 if tab != nil {
6680 // Re-key first: a controller-side rotation (typed /new) may have
6681 // swapped sessions without the App noticing, and the stale totals
6682 // would otherwise be reported — and then persisted — under the new
6683 // session (#5850).
6684 if ctrl != nil {
6685 if sp := ctrl.SessionPath(); sp != "" {
6686 tab.syncTelemetryToSession(sp)
6687 }
6688 }
6689 snap = tab.telemetrySnapshot()
6690 info.SessionTokens = snap.Usage.TotalTokens
6691 info.SessionCost = snap.Usage.SessionCost
6692 info.SessionCurrency = snap.Usage.SessionCurrency
6693 info.CacheHitTokens = snap.Usage.CacheHitTokens
6694 info.CacheMissTokens = snap.Usage.CacheMissTokens
6695 info.Estimated = snap.Usage.Estimated
6696 info.Sources = snap.Usage.Sources
6697 }
6698 if ctrl == nil {
6699 return info
6700 }
6701 used, window := ctrl.ContextSnapshot()
6702 info.Used = used
6703 info.Window = window
6704 // Session rebind (project-tree switch) rebuilds the controller: the fresh
6705 // executor has no per-turn usage yet, so ContextSnapshot reports used=0.
6706 // Fall back to the telemetry-persisted last-used value so the status bar
6707 // shows the fill percentage from the last turn instead of 0%.
6708 if used == 0 && snap.Usage.LastUsedTokens > 0 {
6709 info.Used = snap.Usage.LastUsedTokens
6710 }
6711 info.CompactRatio = ctrl.CompactRatio()
6712 return info
6713 }
6714
6715 // BalanceInfo is the wallet-balance readout for the status bar. Available is true
6716 // only when a balance was fetched; Display is the formatted amount (e.g. "¥110.00")
6717 // and is "" when the active provider declares no balance_url — the frontend then
6718 // omits the readout. Err carries a fetch failure for an optional tooltip.
6719 type BalanceInfo struct {
6720 Available bool `json:"available"`
6721 Display string `json:"display"`
6722 Err string `json:"err,omitempty"`
6723 }
6724
6725 // Balance queries the active provider's wallet balance (a network call). It
6726 // returns an empty (unavailable) readout when no provider balance_url is set, the
6727 // controller is down, or the fetch fails — so the status bar simply shows nothing
6728 // rather than an error.
6729 func (a *App) Balance() BalanceInfo {
6730 return a.BalanceForTab("")
6731 }
6732
6733 func (a *App) BalanceForTab(tabID string) BalanceInfo {
6734 currency := a.balanceDisplayCurrency()
6735 ctrl := a.ctrlByTabID(tabID)
6736 if ctrl == nil {
6737 return BalanceInfo{}
6738 }
6739 b, err := ctrl.Balance(a.ctx)
6740 if err != nil {
6741 return BalanceInfo{Err: err.Error()}
6742 }
6743 if b == nil {
6744 return BalanceInfo{} // provider declares no balance endpoint
6745 }
6746 return BalanceInfo{Available: true, Display: b.DisplayForCurrency(currency)}
6747 }
6748
6749 // balanceDisplayCurrency mirrors the effective pricing currency selected in
6750 // Settings. Auto resolves through the current desktop locale, matching the
6751 // controller rebuild path used by cost telemetry.
6752 func (a *App) balanceDisplayCurrency() string {
6753 cfg, _, err := a.loadDesktopUserConfigForView()
6754 if err != nil {
6755 return ""
6756 }
6757 return a.desktopEffectivePricingCurrency(cfg)
6758 }
6759
6760 // JobView is one running background job (bash/task started with
6761 // run_in_background) for the status-bar indicator.
6762 type JobView struct {
6763 ID string `json:"id"`
6764 Kind string `json:"kind"`
6765 Label string `json:"label"`
6766 Status string `json:"status"`
6767 StartedAt int64 `json:"startedAt"`
6768 }
6769
6770 // Jobs returns the still-running background jobs for the status bar. It refreshes
6771 // on demand (mount, turn end, and on each notice the frontend receives).
6772 func (a *App) Jobs() []JobView {
6773 return a.JobsForTab("")
6774 }
6775
6776 func (a *App) JobsForTab(tabID string) []JobView {
6777 out := []JobView{}
6778 ctrl := a.ctrlForRuntimeTabID(tabID)
6779 return a.jobsForCtrl(ctrl, out)
6780 }
6781
6782 // CancelJob stops one running background job in the active tab.
6783 func (a *App) CancelJob(jobID string) (bool, error) {
6784 return a.CancelJobForTab("", jobID)
6785 }
6786
6787 // CancelJobForTab stops one running background job without relying on whatever
6788 // tab happens to be active when the asynchronous frontend call completes.
6789 func (a *App) CancelJobForTab(tabID, jobID string) (bool, error) {
6790 jobID = strings.TrimSpace(jobID)
6791 if jobID == "" {
6792 return false, fmt.Errorf("job id is required")
6793 }
6794 if tabID != "" {
6795 ctrl := a.ctrlForRuntimeTabID(tabID)
6796 if ctrl != nil {
6797 return cancelJobForController(ctrl, jobID)
6798 }
6799 return false, nil
6800 }
6801 return cancelJobForController(a.ctrlForRuntimeTabID(tabID), jobID)
6802 }
6803
6804 func cancelJobForController(ctrl control.SessionAPI, jobID string) (bool, error) {
6805 if ctrl == nil {
6806 return false, nil
6807 }
6808 canceller, ok := ctrl.(interface{ CancelJob(string) bool })
6809 if !ok {
6810 return false, fmt.Errorf("background job cancellation is unavailable")
6811 }
6812 return canceller.CancelJob(jobID), nil
6813 }
6814
6815 func (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {
6816 if ctrl == nil {
6817 return out
6818 }
6819 for _, v := range ctrl.Jobs() {
6820 out = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})
6821 }
6822 return out
6823 }
6824
6825 // Meta describes the session for the frontend's header and status line.
6826 type Meta struct {
6827 Label string `json:"label"`
6828 Ready bool `json:"ready"`
6829 Runtime SessionRuntimeView `json:"runtime"`
6830 StartupErr string `json:"startupErr,omitempty"`
6831 EventChannel string `json:"eventChannel"`
6832 Cwd string `json:"cwd"`
6833 WorkspaceRoot string `json:"workspaceRoot,omitempty"`
6834 WorkspaceName string `json:"workspaceName,omitempty"`
6835 WorkspacePath string `json:"workspacePath,omitempty"`
6836 GitBranch string `json:"gitBranch,omitempty"`
6837 ImageInputEnabled bool `json:"imageInputEnabled"`
6838 AutoApproveTools bool `json:"autoApproveTools"`
6839 Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval
6840 CollaborationMode string `json:"collaborationMode"`
6841 ToolApprovalMode string `json:"toolApprovalMode"`
6842 TokenMode string `json:"tokenMode"`
6843 Goal string `json:"goal,omitempty"`
6844 GoalStatus string `json:"goalStatus,omitempty"`
6845 GoalRuntime *GoalRuntimeView `json:"goalRuntime,omitempty"`
6846 AutoResearch *AutoResearchCompactView `json:"autoResearch,omitempty"`
6847 // A nil pointer means the controller cannot provide an authoritative snapshot;
6848 // a non-nil pointer preserves an empty list as an explicit panel clear.
6849 CanonicalTodos *[]evidence.TodoItem `json:"canonicalTodos,omitempty"`
6850 }
6851
6852 // GoalRuntimeView is the desktop-facing Goal budget/runtime summary.
6853 type GoalRuntimeView struct {
6854 TurnsUsed int `json:"turnsUsed"`
6855 TurnsLimit int `json:"turnsLimit"`
6856 TokensUsed int `json:"tokensUsed"`
6857 TokensLimit int `json:"tokensLimit"` // Deprecated: always 0; retained for bridge compatibility.
6858 NoProgressTurns int `json:"noProgressTurns"`
6859 NoProgressLimit int `json:"noProgressLimit"`
6860 LastReason string `json:"lastReason,omitempty"`
6861 StopCause string `json:"stopCause,omitempty"`
6862 BudgetExtensions int `json:"budgetExtensions"`
6863 }
6864
6865 func goalRuntimeViewFromController(ctrl control.SessionAPI) *GoalRuntimeView {
6866 if ctrl == nil {
6867 return nil
6868 }
6869 rt := ctrl.GoalRuntime()
6870 return &GoalRuntimeView{
6871 TurnsUsed: rt.TurnsUsed,
6872 TurnsLimit: rt.TurnsLimit,
6873 TokensUsed: rt.TokensUsed,
6874 TokensLimit: rt.TokensLimit,
6875 NoProgressTurns: rt.NoProgressTurns,
6876 NoProgressLimit: rt.NoProgressLimit,
6877 LastReason: rt.LastReason,
6878 StopCause: rt.StopCause,
6879 BudgetExtensions: rt.BudgetExtensions,
6880 }
6881 }
6882
6883 type AutoResearchCompactView struct {
6884 TaskID string `json:"taskId"`
6885 Status string `json:"status"`
6886 Iteration int `json:"iteration"`
6887 PivotRequired bool `json:"pivotRequired"`
6888 StaleCount int `json:"staleCount"`
6889 }
6890
6891 type AutoResearchCriterionView struct {
6892 ID string `json:"id"`
6893 Description string `json:"description"`
6894 Required bool `json:"required"`
6895 EvidenceCount int `json:"evidenceCount"`
6896 Status string `json:"status"`
6897 }
6898
6899 type AutoResearchStatusView struct {
6900 TaskID string `json:"taskId"`
6901 Goal string `json:"goal"`
6902 Status string `json:"status"`
6903 Iteration int `json:"iteration"`
6904 CurrentDirection string `json:"currentDirection"`
6905 StaleCount int `json:"staleCount"`
6906 PivotCount int `json:"pivotCount"`
6907 PivotRequired bool `json:"pivotRequired"`
6908 LastHeartbeatAt string `json:"lastHeartbeatAt"`
6909 FindingCount int `json:"findingCount"`
6910 OpenCriteria []AutoResearchCriterionView `json:"openCriteria"`
6911 Blocker string `json:"blocker"`
6912 TaskPath string `json:"taskPath"`
6913 NextRequiredAction string `json:"nextRequiredAction"`
6914 }
6915
6916 type AutoResearchFindingView struct {
6917 ID string `json:"id"`
6918 Kind string `json:"kind"`
6919 Summary string `json:"summary"`
6920 Source string `json:"source"`
6921 Command string `json:"command,omitempty"`
6922 Paths []string `json:"paths,omitempty"`
6923 Accepted bool `json:"accepted"`
6924 CreatedAt string `json:"createdAt"`
6925 }
6926
6927 type AutoResearchEvidenceView struct {
6928 ID string `json:"id"`
6929 Kind string `json:"kind"`
6930 Summary string `json:"summary"`
6931 Source string `json:"source"`
6932 Command string `json:"command,omitempty"`
6933 Paths []string `json:"paths,omitempty"`
6934 Accepted bool `json:"accepted"`
6935 }
6936
6937 // Meta reports the model label, readiness, any startup error, the working
6938 // directory (for the status line), and the runtime event channel the frontend
6939 // subscribes to.
6940 func (a *App) Meta() Meta {
6941 return a.MetaForTab("")
6942 }
6943
6944 func (a *App) imageInputEnabledForTab(tabID string) bool {
6945 a.mu.RLock()
6946 tab := a.tabByIDLocked(tabID)
6947 var ref, root string
6948 if tab != nil {
6949 ref = tab.model
6950 root = tab.WorkspaceRoot
6951 }
6952 a.mu.RUnlock()
6953 if tab == nil {
6954 return false
6955 }
6956 cfg, err := config.LoadForRoot(root)
6957 if err == nil && ref == "" {
6958 ref = cfg.DefaultModel
6959 }
6960 if err != nil || ref == "" {
6961 return false
6962 }
6963 entry, ok := cfg.ResolveModel(ref)
6964 return ok && config.EffectiveVision(entry)
6965 }
6966
6967 func (a *App) MetaForTab(tabID string) Meta {
6968 a.mu.RLock()
6969 tab := a.tabByIDLocked(tabID)
6970 snap := snapshotTabRuntimeLocked(tab)
6971 runtimeView := a.sessionRuntimeViewLocked(tab)
6972 a.mu.RUnlock()
6973 if tab == nil {
6974 return Meta{EventChannel: eventChannel}
6975 }
6976 cwd := snap.workspaceRoot
6977 if cwd == "" {
6978 cwd, _ = os.Getwd()
6979 }
6980 autoApproveTools := snap.ctrl != nil && snap.ctrl.AutoApproveTools()
6981 collaborationMode := snap.collaborationMode()
6982 toolApprovalMode := snap.currentToolApprovalMode()
6983 tokenMode := snap.currentTokenMode()
6984 goal := snap.currentGoal()
6985 goalStatus := snap.currentGoalStatus()
6986 return Meta{
6987 Label: snap.label,
6988 Ready: runtimeView.Phase == sessionRuntimeReady && snap.ctrl != nil,
6989 Runtime: runtimeView,
6990 StartupErr: snap.startupErr,
6991 EventChannel: eventChannel,
6992 Cwd: cwd,
6993 WorkspaceRoot: cwd,
6994 WorkspaceName: tabWorkspaceNameForScope(snap.scope, cwd),
6995 WorkspacePath: cwd,
6996 GitBranch: workspaceGitBranchForMeta(cwd),
6997 ImageInputEnabled: a.imageInputEnabledForTab(tabID),
6998 AutoApproveTools: autoApproveTools,
6999 Bypass: autoApproveTools,
7000 CollaborationMode: collaborationMode,
7001 ToolApprovalMode: toolApprovalMode,
7002 TokenMode: tokenMode,
7003 Goal: goal,
7004 GoalStatus: goalStatus,
7005 GoalRuntime: goalRuntimeViewFromController(snap.ctrl),
7006 AutoResearch: compactAutoResearchFromController(snap.ctrl),
7007 CanonicalTodos: ctrlTodos(snap.ctrl),
7008 }
7009 }
7010
7011 func compactAutoResearchFromController(ctrl control.SessionAPI) *AutoResearchCompactView {
7012 if ctrl == nil {
7013 return nil
7014 }
7015
7016 summary, ok := ctrl.AutoResearchSummary()
7017 if !ok || summary == nil || summary.TaskID == "" {
7018 return nil
7019 }
7020 return &AutoResearchCompactView{
7021 TaskID: summary.TaskID,
7022 Status: summary.Status,
7023 Iteration: summary.Iteration,
7024 PivotRequired: summary.PivotRequired,
7025 StaleCount: summary.StaleCount,
7026 }
7027 }
7028
7029 // ctrlTodos returns the canonical task list from a session controller, or nil
7030 // if the controller is not yet bound. Used by MetaForTab so the frontend
7031 // task panel has access to the authoritative server-side todo state.
7032 func ctrlTodos(ctrl control.SessionAPI) *[]evidence.TodoItem {
7033 if ctrl == nil {
7034 return nil
7035 }
7036 todos := ctrl.Todos()
7037 if todos == nil {
7038 todos = []evidence.TodoItem{}
7039 }
7040 return &todos
7041 }
7042
7043 func compactAutoResearch(tab *WorkspaceTab) *AutoResearchCompactView {
7044 if tab == nil || tab.Ctrl == nil {
7045 return nil
7046 }
7047 summary, ok := tab.Ctrl.AutoResearchSummary()
7048 if !ok || summary == nil || summary.TaskID == "" {
7049 return nil
7050 }
7051 return &AutoResearchCompactView{
7052 TaskID: summary.TaskID,
7053 Status: summary.Status,
7054 Iteration: summary.Iteration,
7055 PivotRequired: summary.PivotRequired,
7056 StaleCount: summary.StaleCount,
7057 }
7058 }
7059
7060 func autoResearchStatusView(summary *autoresearch.Summary) AutoResearchStatusView {
7061 if summary == nil {
7062 return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
7063 }
7064 open := make([]AutoResearchCriterionView, 0, len(summary.OpenCriteria))
7065 for _, criterion := range summary.OpenCriteria {
7066 open = append(open, AutoResearchCriterionView{
7067 ID: criterion.ID,
7068 Description: criterion.Description,
7069 Required: criterion.Required,
7070 EvidenceCount: criterion.EvidenceCount,
7071 Status: criterion.Status,
7072 })
7073 }
7074 return AutoResearchStatusView{
7075 TaskID: summary.TaskID,
7076 Goal: summary.Goal,
7077 Status: summary.Status,
7078 Iteration: summary.Iteration,
7079 CurrentDirection: summary.CurrentDirection,
7080 StaleCount: summary.StaleCount,
7081 PivotCount: summary.PivotCount,
7082 PivotRequired: summary.PivotRequired,
7083 LastHeartbeatAt: summary.LastHeartbeatAt.Format(time.RFC3339),
7084 FindingCount: summary.FindingCount,
7085 OpenCriteria: open,
7086 Blocker: summary.Blocker,
7087 TaskPath: summary.TaskPath,
7088 NextRequiredAction: summary.NextRequiredAction,
7089 }
7090 }
7091
7092 func (a *App) AutoResearchCurrent() AutoResearchStatusView {
7093 return a.AutoResearchStatus("")
7094 }
7095
7096 func (a *App) AutoResearchStatus(tabID string) AutoResearchStatusView {
7097 ctrl := a.ctrlByTabID(tabID)
7098 if ctrl == nil {
7099 return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
7100 }
7101 summary, ok := ctrl.AutoResearchSummary()
7102 if !ok {
7103 return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}}
7104 }
7105 return autoResearchStatusView(summary)
7106 }
7107
7108 func (a *App) AutoResearchList(tabID string) []AutoResearchStatusView {
7109 ctrl := a.ctrlByTabID(tabID)
7110 if ctrl == nil {
7111 return []AutoResearchStatusView{}
7112 }
7113 summaries, ok := ctrl.AutoResearchList()
7114 if !ok {
7115 return []AutoResearchStatusView{}
7116 }
7117 out := make([]AutoResearchStatusView, 0, len(summaries))
7118 for i := range summaries {
7119 out = append(out, autoResearchStatusView(&summaries[i]))
7120 }
7121 return out
7122 }
7123
7124 func (a *App) AutoResearchFindings(tabID string, limit int) []AutoResearchFindingView {
7125 ctrl := a.ctrlByTabID(tabID)
7126 if ctrl == nil {
7127 return []AutoResearchFindingView{}
7128 }
7129 findings, ok := ctrl.AutoResearchFindings(limit)
7130 if !ok {
7131 return []AutoResearchFindingView{}
7132 }
7133 out := make([]AutoResearchFindingView, 0, len(findings))
7134 for _, finding := range findings {
7135 out = append(out, AutoResearchFindingView{
7136 ID: finding.ID,
7137 Kind: finding.Kind,
7138 Summary: finding.Summary,
7139 Source: finding.Source,
7140 Command: finding.Command,
7141 Paths: append([]string(nil), finding.Paths...),
7142 Accepted: finding.Accepted,
7143 CreatedAt: finding.CreatedAt.Format(time.RFC3339),
7144 })
7145 }
7146 return out
7147 }
7148
7149 func (a *App) AutoResearchOpenTask(tabID string) error {
7150 status := a.AutoResearchStatus(tabID)
7151 if strings.TrimSpace(status.TaskPath) == "" {
7152 return os.ErrInvalid
7153 }
7154 return a.RevealPath(status.TaskPath)
7155 }
7156
7157 func (a *App) AutoResearchRecordEvidence(tabID, criterionID string, input AutoResearchEvidenceView) error {
7158 ctrl := a.ctrlByTabID(tabID)
7159 if ctrl == nil {
7160 return os.ErrInvalid
7161 }
7162 return ctrl.RecordAutoResearchEvidence(criterionID, control.AutoResearchEvidenceInput{
7163 ID: input.ID,
7164 Kind: input.Kind,
7165 Summary: input.Summary,
7166 Source: input.Source,
7167 Command: input.Command,
7168 Paths: append([]string(nil), input.Paths...),
7169 Accepted: input.Accepted,
7170 })
7171 }
7172
7173 func (a *App) SetGoal(goal string) error {
7174 return a.SetGoalForTab("", goal)
7175 }
7176
7177 // SetGoalForTab activates or clears a Goal on the given tab.
7178 //
7179 // Failures must return error so the Wails Promise rejects: the first Goal turn
7180 // can submit a structured Skill without a /goal prose fallback, and the
7181 // frontend aborts that submit when activation fails.
7182 func (a *App) SetGoalForTab(tabID, goal string) error {
7183 tab := a.tabByID(tabID)
7184 if tab == nil {
7185 return a.workspaceNotReadyErr(nil)
7186 }
7187 tab.turnStartMu.Lock()
7188 defer tab.turnStartMu.Unlock()
7189 goal = strings.TrimSpace(goal)
7190 approvalMode := a.tabRuntimeSnapshot(tab).currentToolApprovalMode()
7191 a.mu.Lock()
7192 if a.tabs[tab.ID] != tab {
7193 a.mu.Unlock()
7194 return a.workspaceNotReadyErr(nil)
7195 }
7196 tab.goal = goal
7197 if goal != "" {
7198 tab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)
7199 }
7200 ctrl := tab.Ctrl
7201 plan := tabModeHasPlan(tab.mode)
7202 tabIDForSave := tab.ID
7203 a.mu.Unlock()
7204 if ctrl != nil {
7205 ctrl.SetPlanMode(plan)
7206 syncTabGoalToController(ctrl, goal)
7207 }
7208 a.mu.Lock()
7209 if a.tabs[tabIDForSave] == tab {
7210 a.saveTabsLocked()
7211 }
7212 a.mu.Unlock()
7213 return nil
7214 }
7215
7216 // The composer re-syncs collaboration mode and Goal immediately before every
7217 // send. Keep those acknowledgements idempotent so one multi-turn Goal retains
7218 // its delivery scope; a terminal Goal with the same text still starts a fresh
7219 // scope when the user explicitly enters it again.
7220 func syncTabGoalToController(ctrl control.SessionAPI, goal string) {
7221 if ctrl == nil {
7222 return
7223 }
7224 goal = strings.TrimSpace(goal)
7225 if goal != "" && strings.TrimSpace(ctrl.Goal()) == goal && ctrl.GoalStatus() == control.GoalStatusRunning {
7226 return
7227 }
7228 ctrl.SetGoal(goal)
7229 }
7230
7231 func (a *App) ClearGoal() error {
7232 return a.SetGoal("")
7233 }
7234
7235 func (a *App) ClearGoalForTab(tabID string) error {
7236 return a.SetGoalForTab(tabID, "")
7237 }
7238
7239 // ResumeGoalForTab re-enters a blocked or stopped Goal while preserving its
7240 // delivery scope, budget history, and persisted verification checkpoint.
7241 func (a *App) ResumeGoalForTab(tabID string) bool {
7242 tab := a.tabByID(tabID)
7243 if tab == nil {
7244 return false
7245 }
7246 tab.turnStartMu.Lock()
7247 defer tab.turnStartMu.Unlock()
7248 ctrl := a.controllerForTab(tab)
7249 if ctrl == nil || !ctrl.ResumeGoal() {
7250 return false
7251 }
7252 a.mu.Lock()
7253 if a.tabs[tab.ID] == tab {
7254 tab.goal = strings.TrimSpace(ctrl.Goal())
7255 a.saveTabsLocked()
7256 }
7257 a.mu.Unlock()
7258 return true
7259 }
7260
7261 // PauseGoalForTab suspends a running Goal without clearing it; ResumeGoalForTab
7262 // restores it (with one extra budget slice when it was budget-paused).
7263 func (a *App) PauseGoalForTab(tabID string) bool {
7264 tab := a.tabByID(tabID)
7265 if tab == nil {
7266 return false
7267 }
7268 tab.turnStartMu.Lock()
7269 defer tab.turnStartMu.Unlock()
7270 ctrl := a.controllerForTab(tab)
7271 return ctrl != nil && ctrl.PauseGoal()
7272 }
7273
7274 // SetAutoApproveTools toggles YOLO/full-access tool auto-approval:
7275 // approval-gated tool calls run without asking, while ask questions and plan
7276 // approvals still wait for the user. Runtime-only — not written to config.
7277 func (a *App) SetAutoApproveTools(on bool) {
7278 if on {
7279 a.SetToolApprovalModeForTab("", control.ToolApprovalYolo)
7280 return
7281 }
7282 a.SetToolApprovalModeForTab("", control.ToolApprovalAsk)
7283 }
7284
7285 // SetBypass is the legacy Wails binding for SetAutoApproveTools.
7286 func (a *App) SetBypass(on bool) {
7287 a.SetAutoApproveTools(on)
7288 }
7289
7290 func (a *App) SetToolApprovalMode(mode string) {
7291 a.SetToolApprovalModeForTab("", mode)
7292 }
7293
7294 // SetToolApprovalModeForTab returns the pending approval prompt ids the
7295 // switch auto-allowed (see SetModeForTab).
7296 func (a *App) SetToolApprovalModeForTab(tabID, mode string) []string {
7297 tab := a.tabByID(tabID)
7298 if tab == nil {
7299 return nil
7300 }
7301 tab.turnStartMu.Lock()
7302 defer tab.turnStartMu.Unlock()
7303 mode = normalizeToolApprovalMode(mode)
7304 plan := tabModeHasPlan(a.tabRuntimeSnapshot(tab).currentMode())
7305 a.mu.Lock()
7306 if a.tabs[tab.ID] != tab {
7307 a.mu.Unlock()
7308 return nil
7309 }
7310 tab.toolApprovalMode = mode
7311 tab.mode = tabModeFromAxes(plan, mode == control.ToolApprovalYolo)
7312 ctrl := tab.Ctrl
7313 tabIDForSave := tab.ID
7314 a.mu.Unlock()
7315 drained := applyTabToolApprovalModeToController(ctrl, mode)
7316 a.mu.Lock()
7317 if a.tabs[tabIDForSave] == tab {
7318 a.saveTabsLocked()
7319 }
7320 a.mu.Unlock()
7321 return drained
7322 }
7323
7324 // CommandInfo describes one available slash command for the composer's "/" menu.
7325 type CommandInfo struct {
7326 Name string `json:"name"` // without the leading slash
7327 Description string `json:"description"`
7328 Hint string `json:"hint,omitempty"` // argument hint, if any
7329 Kind string `json:"kind"` // "builtin" | "custom" | "mcp" | "skill" | "subagent"
7330 Group string `json:"group,omitempty"` // menu group; older frontends can ignore it
7331 Plugin string `json:"plugin,omitempty"`
7332 Color string `json:"color,omitempty"`
7333 }
7334
7335 // Commands lists the slash commands available this session — built-in actions,
7336 // custom commands (.reasonix/commands), and MCP prompts — for the composer's "/"
7337 // autocomplete menu.
7338 func (a *App) Commands() []CommandInfo {
7339 out := []CommandInfo{
7340 {Name: "new", Description: i18n.M.CmdNew, Kind: "builtin", Group: "actions"},
7341 {Name: "clear", Description: i18n.M.CmdClear, Kind: "builtin", Group: "actions"},
7342 {Name: "compact", Description: i18n.M.CmdCompact, Kind: "builtin", Group: "actions"},
7343 {Name: "model", Description: i18n.M.CmdModel, Kind: "builtin", Group: "actions"},
7344 {Name: "provider", Description: i18n.M.CmdProvider, Kind: "builtin", Group: "management"},
7345 {Name: "effort", Description: i18n.M.CmdEffort, Kind: "builtin", Group: "actions"},
7346 {Name: "memory", Description: i18n.M.CmdMemory, Kind: "builtin", Group: "management"},
7347 {Name: "migrate", Description: i18n.M.CmdMigrate, Kind: "builtin", Group: "management"},
7348 {Name: "goal", Description: i18n.M.CmdGoal, Kind: "builtin", Group: "actions"},
7349 {Name: "remember", Description: i18n.M.CmdRemember, Kind: "builtin", Group: "management"},
7350 {Name: "mcp", Description: i18n.M.CmdMcp, Kind: "builtin", Group: "integrations"},
7351 {Name: "hooks", Description: i18n.M.CmdHooks, Kind: "builtin", Group: "management"},
7352 {Name: "plugins", Description: i18n.M.CmdPlugins, Kind: "builtin", Group: "integrations"},
7353 {Name: "theme", Description: i18n.M.CmdTheme, Kind: "builtin", Group: "management"},
7354 {Name: "skill", Description: i18n.M.CmdSkill, Kind: "builtin", Group: "skills"},
7355 {Name: "reload-cmd", Description: i18n.M.CmdReloadCmd, Kind: "builtin", Group: "management"},
7356 }
7357 a.mu.RLock()
7358 ctrl := a.activeCtrlLocked()
7359 a.mu.RUnlock()
7360 if ctrl == nil {
7361 return append(out, docsBuiltinCommand(control.DocsSlashName))
7362 }
7363 commands := ctrl.Commands()
7364 slashSkills := ctrl.SlashSkills()
7365 out = append(out, docsBuiltinCommand(control.ResolvedBuiltinSlashName(control.DocsSlashName, commands, slashSkills)))
7366 // Skills are invocable as slash commands (the model runs inline ones; subagent ones
7367 // run isolated). Listing them here is what surfaces /init, /explore, … in the
7368 // composer's slash menu; selecting one submits its displayed slash name, which the controller
7369 // resolves via RunSkill.
7370 for _, s := range slashSkills {
7371 kind := "skill"
7372 if s.RunAs == skill.RunSubagent {
7373 kind = "subagent"
7374 }
7375 group := "skills"
7376 if kind == "subagent" {
7377 group = "subagents"
7378 }
7379 out = append(out, CommandInfo{Name: s.SlashName(), Description: s.Description, Kind: kind, Group: group, Plugin: s.Plugin, Color: s.Color})
7380 }
7381 for _, c := range commands {
7382 if c.Hidden {
7383 continue
7384 }
7385 out = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: "custom", Group: "skills", Plugin: c.Plugin})
7386 }
7387 if h := ctrl.Host(); h != nil {
7388 for _, p := range h.Prompts() {
7389 out = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: "mcp", Group: "integrations"})
7390 }
7391 }
7392 return resolveDocsCommand(out)
7393 }
7394
7395 func docsBuiltinCommand(name string) CommandInfo {
7396 return CommandInfo{Name: name, Description: i18n.M.CmdDocs, Hint: "<question>", Kind: "builtin", Group: "integrations"}
7397 }
7398
7399 func resolveDocsCommand(commands []CommandInfo) []CommandInfo {
7400 winner := -1
7401 winnerRank := -1
7402 for i, cmd := range commands {
7403 if cmd.Name != "docs" {
7404 continue
7405 }
7406 rank := 0
7407 switch cmd.Kind {
7408 case "custom":
7409 rank = 2
7410 case "skill", "subagent":
7411 rank = 1
7412 }
7413 if rank > winnerRank {
7414 winner = i
7415 winnerRank = rank
7416 }
7417 }
7418 if winner < 0 {
7419 return commands
7420 }
7421 out := make([]CommandInfo, 0, len(commands))
7422 for i, cmd := range commands {
7423 if cmd.Name != "docs" || i == winner {
7424 out = append(out, cmd)
7425 }
7426 }
7427 return out
7428 }
7429
7430 // SlashArgItem is one sub-command / argument suggestion for the composer's slash
7431 // menu (the part after the command word). Mirrors the CLI's arg completion via
7432 // the shared control.SlashArgItems, so desktop and CLI offer the same hints.
7433 type SlashArgItem struct {
7434 Label string `json:"label"`
7435 Insert string `json:"insert"`
7436 Hint string `json:"hint"`
7437 Descend bool `json:"descend"`
7438 }
7439
7440 // SlashArgsResult carries the suggestions plus the byte offset in the input where
7441 // the current token begins, so the composer replaces just that token.
7442 type SlashArgsResult struct {
7443 Items []SlashArgItem `json:"items"`
7444 From int `json:"from"`
7445 }
7446
7447 // SlashArgs completes the arguments of a management slash command (/mcp, /model,
7448 // /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty
7449 // Items means the input has no structured arguments to complete.
7450 func (a *App) SlashArgs(input string) SlashArgsResult {
7451 a.mu.RLock()
7452 ctrl := a.activeCtrlLocked()
7453 model := ""
7454 if tab := a.activeTabLocked(); tab != nil {
7455 model = tab.model
7456 }
7457 a.mu.RUnlock()
7458 if ctrl == nil {
7459 return SlashArgsResult{Items: []SlashArgItem{}}
7460 }
7461 data := control.ArgData{
7462 Skills: ctrl.Skills(),
7463 DisabledSkills: ctrl.DisabledSkills(),
7464 ConfiguredMCP: ctrl.ConfiguredMCPNames(),
7465 DisconnectedMCP: ctrl.DisconnectedMCPNames(),
7466 CurrentModel: model,
7467 }
7468 if names, err := pluginpkg.InstalledNames(config.ReasonixHomeDir()); err == nil {
7469 data.PluginNames = names
7470 }
7471 seen := map[string]bool{}
7472 for _, m := range a.Models() {
7473 data.ModelRefs = append(data.ModelRefs, m.Ref)
7474 if m.Provider != "" && !seen[m.Provider] {
7475 seen[m.Provider] = true
7476 data.ProviderNames = append(data.ProviderNames, m.Provider)
7477 }
7478 if m.Current {
7479 data.CurrentProvider = m.Provider
7480 }
7481 }
7482 if h := ctrl.Host(); h != nil {
7483 data.ServerNames = h.ServerNames()
7484 }
7485 data.MemoryRefs, data.MemoryArchives = control.MemoryCompletionData(ctrl.Memory())
7486 items, from := control.SlashArgItems(input, data)
7487 // Non-nil so it serializes as a JSON array, never null — the frontend filters
7488 // over it directly.
7489 out := SlashArgsResult{Items: []SlashArgItem{}, From: from}
7490 for _, it := range items {
7491 out.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend})
7492 }
7493 return out
7494 }
7495
7496 // CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP
7497 // servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.
7498 type CapabilitiesView struct {
7499 Servers []ServerView `json:"servers"`
7500 Skills []SkillView `json:"skills"`
7501 SkillRoots []SkillRootView `json:"skillRoots"`
7502 Plugins []PluginView `json:"plugins"`
7503 }
7504
7505 // SkillsSettingsView is the skills management page's data, split from MCP
7506 // status so opening MCP settings does not scan skill roots.
7507 type SkillsSettingsView struct {
7508 Skills []SkillView `json:"skills"`
7509 SkillRoots []SkillRootView `json:"skillRoots"`
7510 }
7511
7512 // ServerView is one MCP server for the drawer. Status is "connected" (with
7513 // tool/prompt/resource counts), "deferred" (enabled but idle), "failed" (with
7514 // the connection error), "initializing" (background startup in progress), or
7515 // "disabled".
7516 //
7517 // Product fields for the simplified MCP panel are Enabled/Installed/
7518 // Availability/RuntimeState/ToolCount/ToolList/Action. Legacy AutoStart, Tier,
7519 // and StartIntent remain for one major as derived compatibility fields only.
7520 type ServerView struct {
7521 Name string `json:"name"`
7522 Transport string `json:"transport"`
7523 Status string `json:"status"`
7524 StartIntent string `json:"startIntent,omitempty"` // deprecated: derived from Enabled
7525 RuntimeState string `json:"runtimeState,omitempty"`
7526 Availability string `json:"availability,omitempty"`
7527 Enabled bool `json:"enabled"`
7528 Installed bool `json:"installed"`
7529 Action string `json:"action,omitempty"`
7530 Source string `json:"source,omitempty"`
7531 ConfigSource string `json:"configSource,omitempty"`
7532 BuiltIn bool `json:"builtIn,omitempty"`
7533 Configured bool `json:"configured,omitempty"`
7534 AutoStart bool `json:"autoStart"` // deprecated: same as Enabled
7535 Tier string `json:"tier,omitempty"`
7536 Command string `json:"command,omitempty"`
7537 Args []string `json:"args,omitempty"`
7538 URL string `json:"url,omitempty"`
7539 EnvKeys []string `json:"envKeys,omitempty"`
7540 HeaderKeys []string `json:"headerKeys,omitempty"`
7541 Tools int `json:"tools"`
7542 ToolCount int `json:"toolCount"`
7543 Prompts int `json:"prompts"`
7544 Resources int `json:"resources"`
7545 HasTools bool `json:"hasTools,omitempty"`
7546 Error string `json:"error,omitempty"`
7547 ToolList []ToolView `json:"toolList"`
7548 CallTimeoutSeconds int `json:"callTimeoutSeconds,omitempty"`
7549 ToolTimeoutSeconds map[string]int `json:"toolTimeoutSeconds,omitempty"`
7550 RequiresLaunchApproval bool `json:"requiresLaunchApproval,omitempty"`
7551 AuthStatus string `json:"authStatus,omitempty"`
7552 AuthURL string `json:"authUrl,omitempty"`
7553 AuthConfigured bool `json:"authConfigured,omitempty"`
7554 ManagedByPlugin string `json:"managedByPlugin,omitempty"`
7555 }
7556
7557 type ToolView struct {
7558 Name string `json:"name"`
7559 Description string `json:"description"`
7560 ReadOnlyHint bool `json:"readOnlyHint,omitempty"`
7561 DestructiveHint bool `json:"destructiveHint,omitempty"`
7562 SchemaError string `json:"schemaError,omitempty"`
7563 }
7564
7565 // SkillView is one discoverable skill for the drawer. Also backs the
7566 // Subagents settings surface: the frontend filters this same list to
7567 // RunAs=="subagent" rather than calling a second, redundant endpoint.
7568 type SkillView struct {
7569 Name string `json:"name"`
7570 Description string `json:"description"`
7571 Scope string `json:"scope"`
7572 RunAs string `json:"runAs"`
7573 Enabled bool `json:"enabled"`
7574 Plugin string `json:"plugin,omitempty"`
7575 Model string `json:"model,omitempty"`
7576 Effort string `json:"effort,omitempty"`
7577 AllowedTools []string `json:"allowedTools,omitempty"`
7578 // ReadOnly mirrors frontmatter read-only; omitted/false keeps the legacy
7579 // writable default for older profiles.
7580 ReadOnly bool `json:"readOnly,omitempty"`
7581 Color string `json:"color,omitempty"`
7582 // Invocation is the user-facing slash name; InvocationMode preserves the
7583 // frontmatter policy used by the subagent profile editor.
7584 Invocation string `json:"invocation,omitempty"`
7585 InvocationMode string `json:"invocationMode,omitempty"`
7586 // Body is the skill's full markdown body (post-frontmatter) — the
7587 // subagent profile editor pre-fills its system-prompt field from this.
7588 Body string `json:"body,omitempty"`
7589 // ConfiguredModel/ConfiguredEffort are the per-name overrides from
7590 // cfg.Agent.SubagentModels/SubagentEfforts (internal/boot's
7591 // subagentModelRef/subagentEffortRef read the same map at dispatch time).
7592 // This is the only lever for a built-in subagent's model/effort, since
7593 // built-ins have no editable frontmatter file to carry Model/Effort.
7594 ConfiguredModel string `json:"configuredModel,omitempty"`
7595 ConfiguredEffort string `json:"configuredEffort,omitempty"`
7596 }
7597
7598 type SkillRootSkillView struct {
7599 Name string `json:"name"`
7600 Description string `json:"description"`
7601 Scope string `json:"scope"`
7602 RunAs string `json:"runAs"`
7603 Plugin string `json:"plugin,omitempty"`
7604 Model string `json:"model,omitempty"`
7605 Effort string `json:"effort,omitempty"`
7606 AllowedTools []string `json:"allowedTools,omitempty"`
7607 Color string `json:"color,omitempty"`
7608 Invocation string `json:"invocation,omitempty"`
7609 }
7610
7611 // SkillRootView is one skill discovery root for the drawer's Sources section.
7612 type SkillRootView struct {
7613 Dir string `json:"dir"`
7614 Scope string `json:"scope"`
7615 Priority int `json:"priority"`
7616 Status string `json:"status"`
7617 Configured bool `json:"configured"`
7618 Removable bool `json:"removable"`
7619 Skills int `json:"skills"`
7620 SkillItems []SkillRootSkillView `json:"skillItems,omitempty"`
7621 Warning string `json:"warning,omitempty"`
7622 }
7623
7624 // Capabilities projects the session's MCP servers (connected + failed) and skills
7625 // for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.
7626 func (a *App) Capabilities() CapabilitiesView {
7627 skills := a.SkillsSettings()
7628 return CapabilitiesView{
7629 Servers: a.MCPServers(),
7630 Skills: skills.Skills,
7631 SkillRoots: skills.SkillRoots,
7632 Plugins: a.Plugins(),
7633 }
7634 }
7635
7636 // MCPServers returns only MCP server status for settings pages that do not need
7637 // skill discovery.
7638 func (a *App) MCPServers() []ServerView {
7639 return a.mcpServersView()
7640 }
7641
7642 type MCPMarketplaceEntryView struct {
7643 Name string `json:"name"`
7644 SuggestedName string `json:"suggestedName"`
7645 Title string `json:"title,omitempty"`
7646 Description string `json:"description,omitempty"`
7647 Version string `json:"version,omitempty"`
7648 RepositoryURL string `json:"repositoryUrl,omitempty"`
7649 Installable bool `json:"installable"`
7650 UnavailableReason string `json:"unavailableReason,omitempty"`
7651 Transport string `json:"transport,omitempty"`
7652 Command string `json:"command,omitempty"`
7653 Args []string `json:"args"`
7654 URL string `json:"url,omitempty"`
7655 }
7656
7657 type MCPMarketplaceView struct {
7658 Servers []MCPMarketplaceEntryView `json:"servers"`
7659 Cached bool `json:"cached"`
7660 Warning string `json:"warning,omitempty"`
7661 }
7662
7663 // MCPMarketplace explicitly queries the official MCP Registry. It is only
7664 // called from the settings marketplace; startup and tool discovery never touch
7665 // the network. A query-specific cache keeps the page useful during a registry
7666 // outage without treating cached entries as installed servers.
7667 func (a *App) MCPMarketplace(query string) (MCPMarketplaceView, error) {
7668 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
7669 defer cancel()
7670 result, err := mcpregistry.New(mcpRegistryCachePath()).Search(ctx, query, 50)
7671 if err != nil {
7672 return MCPMarketplaceView{Servers: []MCPMarketplaceEntryView{}}, err
7673 }
7674 view := MCPMarketplaceView{
7675 Servers: make([]MCPMarketplaceEntryView, 0, len(result.Entries)),
7676 Cached: result.Cached,
7677 Warning: result.Warning,
7678 }
7679 for _, entry := range result.Entries {
7680 view.Servers = append(view.Servers, mcpMarketplaceEntryView(entry))
7681 }
7682 return view, nil
7683 }
7684
7685 // MCPMarketplaceResolve re-fetches one Registry entry immediately before the
7686 // settings UI installs it. Offline cache remains useful for browsing, but it is
7687 // never accepted as installation metadata.
7688 func (a *App) MCPMarketplaceResolve(registryName string) (MCPMarketplaceEntryView, error) {
7689 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
7690 defer cancel()
7691 entry, _, err := mcpregistry.New(mcpRegistryCachePath()).Resolve(ctx, registryName)
7692 if err != nil {
7693 return MCPMarketplaceEntryView{}, err
7694 }
7695 if _, err := entry.PluginEntry(""); err != nil {
7696 return MCPMarketplaceEntryView{}, err
7697 }
7698 return mcpMarketplaceEntryView(entry), nil
7699 }
7700
7701 func mcpRegistryCachePath() string {
7702 if cacheDir := config.CacheDir(); cacheDir != "" {
7703 return filepath.Join(cacheDir, "mcp-registry-v0.1.json")
7704 }
7705 return ""
7706 }
7707
7708 func mcpMarketplaceEntryView(entry mcpregistry.Entry) MCPMarketplaceEntryView {
7709 return MCPMarketplaceEntryView{
7710 Name: entry.Name,
7711 SuggestedName: entry.SuggestedName,
7712 Title: entry.Title,
7713 Description: entry.Description,
7714 Version: entry.Version,
7715 RepositoryURL: entry.RepositoryURL,
7716 Installable: entry.Installable,
7717 UnavailableReason: entry.UnavailableReason,
7718 Transport: entry.Transport,
7719 Command: entry.Command,
7720 Args: append([]string{}, entry.Args...),
7721 URL: entry.URL,
7722 }
7723 }
7724
7725 // lockRuntimeMutation serializes controller rebuild/teardown operations and
7726 // freezes runtime admission so a captured controller or Host cannot be replaced
7727 // or closed in flight. The caller must not hold App.mu; the lock order is
7728 // runtimeRebuildMu -> runtimeAdmissionMu -> App/Host/Registry.
7729 func (a *App) lockRuntimeMutation(operation string) func() {
7730 if hook := a.runtimeMutationBeforeLockHook; hook != nil {
7731 hook(operation)
7732 }
7733 a.runtimeRebuildMu.Lock()
7734 a.runtimeAdmissionMu.Lock()
7735 return func() {
7736 a.runtimeAdmissionMu.Unlock()
7737 a.runtimeRebuildMu.Unlock()
7738 }
7739 }
7740
7741 // lockMCPMutation is the MCP-specific spelling retained at lifecycle call sites.
7742 func (a *App) lockMCPMutation(operation string) func() {
7743 return a.lockRuntimeMutation(operation)
7744 }
7745
7746 // AuthorizeAndConnectMCPServer is retained for older generated Wails clients.
7747 // Project configuration is trusted by default now, so the normal path simply
7748 // reconnects the effective entry. Explicitly gated host specs still record
7749 // their exact launch grant before reconnecting.
7750 func (a *App) AuthorizeAndConnectMCPServer(name string) error {
7751 defer a.lockMCPMutation("authorize-connect")()
7752
7753 tab, ctrl, root := a.activeMCPRuntime()
7754 if tab == nil || ctrl == nil {
7755 return fmt.Errorf("no active session")
7756 }
7757 host, releaseGates, err := a.lockMCPHostTurnGates("MCP authorization", ctrl)
7758 if err != nil {
7759 return err
7760 }
7761 defer releaseGates()
7762 entry, found, err := desktopEffectiveMCPServer(root, name)
7763 if err != nil {
7764 return err
7765 }
7766 if !found {
7767 return fmt.Errorf("no configured MCP server named %q", name)
7768 }
7769 spec, err := a.mcpLaunchSpec(root, name)
7770 if err != nil {
7771 return err
7772 }
7773 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
7774 defer cancel()
7775 if spec.RequireLaunchApproval {
7776 if err := plugin.AuthorizeProjectSpecLaunch(ctx, spec); err != nil {
7777 return err
7778 }
7779 }
7780
7781 controllers := a.mcpControllersSharingHost(host, name, ctrl)
7782 for i := range controllers {
7783 if controllers[i].ctrl == ctrl {
7784 controllers[i].enabled = true
7785 }
7786 }
7787 // Drop any previous identity, then start the effective configured server
7788 // once and refresh every enabled registry sharing this Host.
7789 disconnectMCPServerControllers(name, ctrl, controllers)
7790 if host != nil {
7791 host.ClearFailure(name)
7792 }
7793 if err := reconnectMCPServerControllers(entry, controllers); err != nil {
7794 recordMCPFailure(ctrl, entry, err)
7795 return err
7796 }
7797 a.mu.Lock()
7798 delete(tab.disabledMCP, name)
7799 a.mu.Unlock()
7800 return nil
7801 }
7802
7803 type mcpControllerTarget struct {
7804 ctrl control.SessionAPI
7805 enabled bool
7806 }
7807
7808 // lockMCPHostTurnGates freezes every runtime sharing ctrl's Host. Callers hold
7809 // lockMCPMutation, so runtimeAdmissionMu's write side already prevents new turn
7810 // admissions, builds, and teardown while this helper snapshots and gates the
7811 // existing runtimes.
7812 func (a *App) lockMCPHostTurnGates(setting string, ctrl control.SessionAPI) (*plugin.Host, func(), error) {
7813 if ctrl == nil {
7814 return nil, nil, fmt.Errorf("no active session")
7815 }
7816 host := ctrl.Host()
7817 release, err := a.lockRuntimeTurnGates(setting, func(tab *WorkspaceTab) bool {
7818 if host == nil {
7819 return tab.Ctrl == ctrl
7820 }
7821 return tab.Ctrl != nil && tab.Ctrl.Host() == host
7822 })
7823 return host, release, err
7824 }
7825
7826 func disconnectMCPServerControllers(name string, preferred control.SessionAPI, controllers []mcpControllerTarget) bool {
7827 for _, target := range controllers {
7828 target.ctrl.UnregisterMCPServerTools(name)
7829 }
7830 disconnected := false
7831 if preferred != nil {
7832 disconnected = preferred.DisconnectMCPServer(name)
7833 }
7834 // Every controller owns an independent capability runtime even when the Host
7835 // process is shared. Reconcile each one after the preferred controller drops
7836 // the client so remove/update/rollback cannot leave sibling tabs with stale
7837 // specs or live-tool snapshots.
7838 for _, target := range controllers {
7839 if target.ctrl == preferred {
7840 continue
7841 }
7842 disconnected = target.ctrl.DisconnectMCPServer(name) || disconnected
7843 }
7844 return disconnected
7845 }
7846
7847 func (a *App) clearMCPServerTabState(name string, controllers []mcpControllerTarget) {
7848 selected := make(map[control.SessionAPI]bool, len(controllers))
7849 for _, target := range controllers {
7850 selected[target.ctrl] = true
7851 }
7852 a.mu.Lock()
7853 for _, tab := range a.runtimeTabsLocked() {
7854 if tab == nil || !selected[tab.Ctrl] {
7855 continue
7856 }
7857 delete(tab.disabledMCP, name)
7858 tab.mcpOrder = removeServerOrder(tab.mcpOrder, name)
7859 }
7860 a.mu.Unlock()
7861 }
7862
7863 // reconnectMCPServerControllers establishes one shared client, then refreshes
7864 // every enabled controller's provider-visible Registry. Disabled tabs remain
7865 // suspended and reconnect only when explicitly enabled.
7866 func reconnectMCPServerControllers(entry config.PluginEntry, controllers []mcpControllerTarget) error {
7867 var startErrors []error
7868 connectedTarget := -1
7869 for i, target := range controllers {
7870 if !target.enabled {
7871 continue
7872 }
7873 if _, err := target.ctrl.ConnectMCPServer(entry); err != nil {
7874 startErrors = append(startErrors, err)
7875 continue
7876 }
7877 connectedTarget = i
7878 break
7879 }
7880 if connectedTarget < 0 {
7881 // All tabs may have disabled this server. Keeping it disconnected
7882 // preserves their explicit state.
7883 return errors.Join(startErrors...)
7884 }
7885
7886 var refreshErrors []error
7887 for i, target := range controllers {
7888 if !target.enabled || i == connectedTarget {
7889 continue
7890 }
7891 if _, err := target.ctrl.ConnectMCPServer(entry); err != nil {
7892 refreshErrors = append(refreshErrors, err)
7893 }
7894 }
7895 return errors.Join(refreshErrors...)
7896 }
7897
7898 // mcpControllersSharingHost snapshots visible and detached runtimes before
7899 // calling controller methods. App.mu is never held across Host/controller
7900 // locks or network work. preferred (normally the active tab) is returned first.
7901 func (a *App) mcpControllersSharingHost(host *plugin.Host, name string, preferred control.SessionAPI) []mcpControllerTarget {
7902 if host == nil {
7903 enabled := true
7904 a.mu.RLock()
7905 for _, tab := range a.runtimeTabsLocked() {
7906 if tab != nil && tab.Ctrl == preferred {
7907 _, disabled := tab.disabledMCP[name]
7908 enabled = !disabled
7909 break
7910 }
7911 }
7912 a.mu.RUnlock()
7913 return []mcpControllerTarget{{ctrl: preferred, enabled: enabled}}
7914 }
7915 a.mu.RLock()
7916 candidates := make([]mcpControllerTarget, 0, len(a.tabs)+len(a.detachedSessions))
7917 for _, tab := range a.runtimeTabsLocked() {
7918 if tab == nil || tab.Ctrl == nil {
7919 continue
7920 }
7921 _, disabled := tab.disabledMCP[name]
7922 candidates = append(candidates, mcpControllerTarget{ctrl: tab.Ctrl, enabled: !disabled})
7923 }
7924 a.mu.RUnlock()
7925
7926 byController := make(map[control.SessionAPI]int, len(candidates))
7927 targets := make([]mcpControllerTarget, 0, len(candidates))
7928 for _, candidate := range candidates {
7929 if candidate.ctrl.Host() != host {
7930 continue
7931 }
7932 if idx, ok := byController[candidate.ctrl]; ok {
7933 targets[idx].enabled = targets[idx].enabled || candidate.enabled
7934 continue
7935 }
7936 byController[candidate.ctrl] = len(targets)
7937 targets = append(targets, candidate)
7938 }
7939 if len(targets) == 0 {
7940 return []mcpControllerTarget{{ctrl: preferred, enabled: true}}
7941 }
7942 if idx, ok := byController[preferred]; ok && idx > 0 {
7943 targets[0], targets[idx] = targets[idx], targets[0]
7944 }
7945 return targets
7946 }
7947
7948 // lockRuntimeTurnGates locks the turn gate of every runtime tab selected by
7949 // affected (nil selects all visible and detached runtime tabs) in stable tab-ID
7950 // order, then verifies under the gates that no gated controller has active
7951 // runtime work. Callers must hold runtimeRebuildMu and the write side of
7952 // runtimeAdmissionMu (normally through lockMCPMutation), which freezes new turn
7953 // admission, controller builds, and runtime teardown before this snapshot.
7954 // On success the returned release func unlocks the per-tab gates in reverse
7955 // order; on error every gate acquired here is already unlocked.
7956 func (a *App) lockRuntimeTurnGates(setting string, affected func(*WorkspaceTab) bool) (func(), error) {
7957 a.mu.RLock()
7958 all := a.runtimeTabsLocked()
7959 tabs := make([]*WorkspaceTab, 0, len(all))
7960 for _, tab := range all {
7961 if tab == nil || (affected != nil && !affected(tab)) {
7962 continue
7963 }
7964 tabs = append(tabs, tab)
7965 }
7966 a.mu.RUnlock()
7967 sort.Slice(tabs, func(i, j int) bool { return tabs[i].ID < tabs[j].ID })
7968 locked := 0
7969 release := func() {
7970 for i := locked - 1; i >= 0; i-- {
7971 tabs[i].turnStartMu.Unlock()
7972 }
7973 }
7974 for _, tab := range tabs {
7975 tab.turnStartMu.Lock()
7976 locked++
7977 }
7978 // Read tab.Ctrl under a.mu rather than through controllerForTab: detached
7979 // runtimes live in detachedSessions, not a.tabs, and their work counts too.
7980 a.mu.RLock()
7981 for _, tab := range tabs {
7982 if err := rebuildControllerActiveWorkErrorFor(tab.Ctrl, setting); err != nil {
7983 a.mu.RUnlock()
7984 release()
7985 return nil, err
7986 }
7987 }
7988 a.mu.RUnlock()
7989 return release, nil
7990 }
7991
7992 // disconnectMCPServerAllRuntimes removes an uninstalled MCP server from every
7993 // live runtime: all visible and detached runtime tabs, across every shared
7994 // Host — a global plugin uninstall must not leave sibling tabs exposing stale
7995 // provider-visible tools or other workspaces running the removed server.
7996 // DisconnectMCPServer stops the shared client once per Host and drops the tool
7997 // prefix from every other controller's registry.
7998 func (a *App) disconnectMCPServerAllRuntimes(serverName string) bool {
7999 a.mu.RLock()
8000 ctrls := make([]control.SessionAPI, 0, len(a.tabs)+len(a.detachedSessions))
8001 seen := make(map[control.SessionAPI]bool, len(a.tabs)+len(a.detachedSessions))
8002 for _, tab := range a.runtimeTabsLocked() {
8003 if tab == nil || tab.Ctrl == nil || seen[tab.Ctrl] {
8004 continue
8005 }
8006 seen[tab.Ctrl] = true
8007 ctrls = append(ctrls, tab.Ctrl)
8008 }
8009 a.mu.RUnlock()
8010 disconnected := false
8011 for _, ctrl := range ctrls {
8012 if ctrl.DisconnectMCPServer(serverName) {
8013 disconnected = true
8014 }
8015 }
8016 return disconnected
8017 }
8018
8019 func (a *App) mcpLaunchSpec(root, name string) (plugin.Spec, error) {
8020 cfg, err := config.LoadForRoot(root)
8021 if err != nil {
8022 return plugin.Spec{}, err
8023 }
8024 var entry *config.PluginEntry
8025 for i := range cfg.Plugins {
8026 if cfg.Plugins[i].Name == name {
8027 entry = &cfg.Plugins[i]
8028 break
8029 }
8030 }
8031 if entry == nil {
8032 return plugin.Spec{}, fmt.Errorf("no configured MCP server named %q", name)
8033 }
8034 return a.mcpLaunchSpecForEntryWithConfig(root, *entry, cfg)
8035 }
8036
8037 func (a *App) mcpLaunchSpecForEntry(root string, entry config.PluginEntry) (plugin.Spec, error) {
8038 cfg, err := config.LoadForRoot(root)
8039 if err != nil {
8040 return plugin.Spec{}, err
8041 }
8042 return a.mcpLaunchSpecForEntryWithConfig(root, entry, cfg)
8043 }
8044
8045 func (a *App) mcpLaunchSpecForEntryWithConfig(root string, entry config.PluginEntry, cfg *config.Config) (plugin.Spec, error) {
8046 specs := boot.PluginSpecsForRootWithOptions([]config.PluginEntry{entry}, root, boot.PluginSpecOptions{
8047 DefaultCallTimeout: time.Duration(cfg.MCPCallTimeoutSeconds()) * time.Second,
8048 LaunchManager: mcplaunch.ForWorkspace(config.ReasonixHomeDir(), root),
8049 ConfigSource: "workspace_config", StateHome: config.ReasonixHomeDir(),
8050 WriterRoots: cfg.WriteRootsForRoot(root), ForbidReadRoots: boot.RuntimeForbidReadRoots(cfg, root),
8051 Network: cfg.Sandbox.Network,
8052 })
8053 if len(specs) != 1 {
8054 return plugin.Spec{}, fmt.Errorf("failed to build MCP server %q", entry.Name)
8055 }
8056 return specs[0], nil
8057 }
8058
8059 // SkillsSettings returns the skills management snapshot without MCP status.
8060 func (a *App) SkillsSettings() SkillsSettingsView {
8061 out := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}}
8062 a.mu.RLock()
8063 tab := a.activeTabLocked()
8064 var ctrl control.SessionAPI
8065 if tab != nil {
8066 ctrl = tab.Ctrl
8067 }
8068 a.mu.RUnlock()
8069 if ctrl == nil {
8070 return out
8071 }
8072
8073 disabled := map[string]bool{}
8074 var configuredModels, configuredEfforts map[string]string
8075 if cfg, err := config.Load(); err == nil {
8076 for _, name := range cfg.Skills.DisabledSkills {
8077 if key := config.SkillNameKey(name); key != "" {
8078 disabled[key] = true
8079 }
8080 }
8081 configuredModels = cfg.Agent.SubagentModels
8082 configuredEfforts = cfg.Agent.SubagentEfforts
8083 }
8084 for _, s := range ctrl.AllSkills() {
8085 view := SkillView{
8086 Name: s.Name, Description: s.Description,
8087 Scope: string(s.Scope), RunAs: string(s.RunAs),
8088 Enabled: !disabled[config.SkillNameKey(s.Name)],
8089 Plugin: s.Plugin,
8090 Model: s.Model,
8091 Effort: s.Effort,
8092 AllowedTools: append([]string{}, s.AllowedTools...),
8093 ReadOnly: s.ReadOnly,
8094 Color: s.Color,
8095 Invocation: "/" + s.SlashName(),
8096 InvocationMode: s.Invocation,
8097 ConfiguredModel: subagentOverrideFor(configuredModels, s.Name),
8098 ConfiguredEffort: subagentOverrideFor(configuredEfforts, s.Name),
8099 }
8100 // Body feeds only the Subagents editor's prompt prefill. Inline skills
8101 // fold references/ into Body at load time (hundreds of KB for a rich
8102 // skill library), and every Capabilities/Settings fetch would ship all
8103 // of it across the JSON bridge for nothing.
8104 if s.RunAs == skill.RunSubagent {
8105 view.Body = s.Body
8106 }
8107 out.Skills = append(out.Skills, view)
8108 }
8109 out.SkillRoots = a.cachedSkillRootsView()
8110 return out
8111 }
8112
8113 // subagentOverrideFor resolves a per-name subagent override with the same
8114 // underscore/hyphen alias fallback the runtime dispatch uses
8115 // (boot.SubagentModelKeys) — an exact-key read would show a legacy
8116 // `security_review` config entry as "inherit default" while it still won at
8117 // dispatch time.
8118 func subagentOverrideFor(overrides map[string]string, name string) string {
8119 for _, key := range boot.SubagentModelKeys(name) {
8120 if v := strings.TrimSpace(overrides[key]); v != "" {
8121 return v
8122 }
8123 }
8124 return ""
8125 }
8126
8127 // AvailableSubagentTools lists the tool names a subagent profile's
8128 // "available tools" picker may offer. Scoped to compile-time builtins for
8129 // v1 — MCP/plugin tools are per-session/per-connection and would need a new
8130 // live-registry accessor on control.Capabilities to enumerate safely; a
8131 // profile's allowed-tools already degrades gracefully (FilterRegistry drops
8132 // unknown names silently) if extended to MCP names by hand later. Tools that
8133 // are always excluded from every subagent regardless of an explicit
8134 // allowlist (agent.AlwaysHiddenSubagentTools) are left out entirely — they'd
8135 // be a selectable no-op otherwise.
8136 func (a *App) AvailableSubagentTools() []ToolView {
8137 hidden := map[string]bool{}
8138 for _, name := range agent.AlwaysHiddenSubagentTools() {
8139 hidden[name] = true
8140 }
8141 entries := tool.BuiltinContractEntries()
8142 out := make([]ToolView, 0, len(entries))
8143 for _, e := range entries {
8144 if hidden[e.Name] {
8145 continue
8146 }
8147 out = append(out, ToolView{Name: e.Name, Description: e.Description, ReadOnlyHint: e.ReadOnly})
8148 }
8149 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
8150 return out
8151 }
8152
8153 func (a *App) mcpServersView() []ServerView {
8154 out := []ServerView{}
8155 a.mu.RLock()
8156 tab := a.activeTabLocked()
8157 if tab == nil {
8158 a.mu.RUnlock()
8159 return out
8160 }
8161 ctrl := tab.Ctrl
8162 disabled := make(map[string]ServerView, len(tab.disabledMCP))
8163 for name, s := range tab.disabledMCP {
8164 disabled[name] = s
8165 }
8166 order := append([]string(nil), tab.mcpOrder...)
8167 workspaceRoot := tab.WorkspaceRoot
8168 tabID := tab.ID
8169 a.mu.RUnlock()
8170 if ctrl == nil {
8171 return out
8172 }
8173 seen := map[string]bool{}
8174 connected := map[string]bool{}
8175 retainedDisabled := map[string]ServerView{}
8176 configured := map[string]config.PluginEntry{}
8177 managedByPlugin := map[string]string{}
8178 var configuredEntries []config.PluginEntry
8179 if cfg, err := config.LoadForRoot(workspaceRoot); err == nil {
8180 configuredEntries = append(configuredEntries, cfg.Plugins...)
8181 for _, p := range configuredEntries {
8182 configured[p.Name] = p
8183 if owner, ok := cfg.PluginPackageOwner(p.Name); ok {
8184 managedByPlugin[p.Name] = owner
8185 }
8186 }
8187 }
8188 if h := ctrl.Host(); h != nil {
8189 for _, s := range h.Servers() {
8190 if disabledView, ok := disabled[s.Name]; ok {
8191 disabledView.Status = "disabled"
8192 disabledView.RuntimeState = "idle"
8193 disabledView.StartIntent = "off"
8194 disabledView.Error = ""
8195 if p, ok := configured[s.Name]; ok {
8196 disabledView = withPluginConfigInWorkspace(disabledView, p, workspaceRoot)
8197 }
8198 out = append(out, disabledView)
8199 retainedDisabled[s.Name] = disabledView
8200 seen[s.Name] = true
8201 delete(disabled, s.Name)
8202 continue
8203 }
8204 seen[s.Name] = true
8205 connected[s.Name] = true
8206 view := ServerView{
8207 Name: s.Name, Transport: s.Transport, Status: "connected", RuntimeState: "ready",
8208 Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
8209 HasTools: s.HasTools,
8210 ToolList: pluginToolsToView(s.ToolList),
8211 }
8212 if p, ok := configured[s.Name]; ok {
8213 view = withPluginConfigInWorkspace(view, p, workspaceRoot)
8214 }
8215 out = append(out, view)
8216 }
8217 for _, f := range h.Failures() {
8218 seen[f.Name] = true
8219 view := ServerView{
8220 Name: f.Name, Transport: f.Transport, Status: "failed", RuntimeState: "issue", Error: f.Error,
8221 RequiresLaunchApproval: f.RequiresLaunchApproval,
8222 }
8223 if p, ok := configured[f.Name]; ok {
8224 view = withPluginConfigInWorkspace(view, p, workspaceRoot)
8225 }
8226 out = append(out, view)
8227 }
8228 for _, name := range h.ConnectingServers() {
8229 if seen[name] {
8230 continue
8231 }
8232 seen[name] = true
8233 view := ServerView{Name: name, Status: "initializing", RuntimeState: "connecting"}
8234 if p, ok := configured[name]; ok {
8235 view = withPluginConfigInWorkspace(view, p, workspaceRoot)
8236 }
8237 out = append(out, view)
8238 }
8239 }
8240 // Configured servers that are neither connected, connecting, nor failed are
8241 // idle: disabled/off or automatic background startup waiting for its next kick.
8242 if len(configuredEntries) > 0 {
8243 for _, p := range configuredEntries {
8244 if seen[p.Name] {
8245 continue
8246 }
8247 if s, ok := disabled[p.Name]; ok {
8248 s.Status = "disabled"
8249 s.RuntimeState = "idle"
8250 s.StartIntent = "off"
8251 s = withPluginConfigInWorkspace(s, p, workspaceRoot)
8252 s.Error = ""
8253 out = append(out, s)
8254 retainedDisabled[p.Name] = s
8255 seen[p.Name] = true
8256 delete(disabled, p.Name)
8257 continue
8258 }
8259 status := "disabled"
8260 startIntent := "off"
8261 if mcpEntryEnabled(p, workspaceRoot) {
8262 status = "deferred"
8263 startIntent = "automatic"
8264 }
8265 out = append(out, withPluginConfigInWorkspace(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: "idle"}, p, workspaceRoot))
8266 seen[p.Name] = true
8267 }
8268 }
8269 out = orderServerViews(out, order)
8270 for i := range out {
8271 out[i].ManagedByPlugin = managedByPlugin[out[i].Name]
8272 out[i] = finalizeServerView(out[i])
8273 }
8274
8275 a.mu.Lock()
8276 if tab, ok := a.tabs[tabID]; ok {
8277 for name := range connected {
8278 delete(retainedDisabled, name)
8279 }
8280 tab.disabledMCP = retainedDisabled
8281 tab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)
8282 }
8283 a.mu.Unlock()
8284 return out
8285 }
8286
8287 func mcpEntryEnabled(p config.PluginEntry, workspace string) bool {
8288 enabled, err := config.DefaultMCPActivationStore().IsEnabled(p, workspace)
8289 if err != nil {
8290 return p.ShouldAutoStart()
8291 }
8292 return enabled
8293 }
8294
8295 func mcpRuntimeState(status string) string {
8296 switch status {
8297 case "connected":
8298 return "ready"
8299 case "initializing":
8300 return "connecting"
8301 case "failed":
8302 return "issue"
8303 default:
8304 return "idle"
8305 }
8306 }
8307
8308 func mcpAvailability(v ServerView) string {
8309 if !v.Enabled {
8310 return "disabled"
8311 }
8312 switch v.RuntimeState {
8313 case "ready":
8314 return "connected"
8315 case "connecting":
8316 return "starting"
8317 case "issue":
8318 if v.RequiresLaunchApproval {
8319 return "project_auth_changed"
8320 }
8321 if v.AuthStatus == "required" || v.AuthStatus == "possible" {
8322 return "auth_required"
8323 }
8324 return "start_failed"
8325 default:
8326 // Idle enabled servers are available on demand, not "disconnected".
8327 return "available_on_demand"
8328 }
8329 }
8330
8331 func mcpActionForView(v ServerView) string {
8332 if v.RequiresLaunchApproval {
8333 return "authorize"
8334 }
8335 if v.AuthStatus == "required" {
8336 return "authenticate"
8337 }
8338 if v.RuntimeState == "issue" {
8339 return "retry"
8340 }
8341 return "none"
8342 }
8343
8344 func finalizeServerView(v ServerView) ServerView {
8345 if v.ToolList == nil {
8346 v.ToolList = []ToolView{}
8347 }
8348 if v.Args == nil {
8349 v.Args = []string{}
8350 }
8351 if v.EnvKeys == nil {
8352 v.EnvKeys = []string{}
8353 }
8354 if v.HeaderKeys == nil {
8355 v.HeaderKeys = []string{}
8356 }
8357 v.ToolCount = v.Tools
8358 if v.ToolCount == 0 && len(v.ToolList) > 0 {
8359 v.ToolCount = len(v.ToolList)
8360 v.Tools = v.ToolCount
8361 }
8362 v.Installed = v.Configured || v.BuiltIn || v.Status != ""
8363 if v.Source == "" {
8364 switch {
8365 case v.BuiltIn:
8366 v.Source = "builtin"
8367 case v.ManagedByPlugin != "":
8368 v.Source = "plugin"
8369 case v.Configured:
8370 v.Source = "user"
8371 }
8372 }
8373 if v.RuntimeState == "" {
8374 v.RuntimeState = mcpRuntimeState(v.Status)
8375 }
8376 v.Availability = mcpAvailability(v)
8377 if v.Action == "" {
8378 v.Action = mcpActionForView(v)
8379 }
8380 // Keep deprecated fields derived from the new product state.
8381 v.AutoStart = v.Enabled
8382 if !v.Enabled {
8383 v.StartIntent = "off"
8384 } else if v.StartIntent == "" {
8385 v.StartIntent = "automatic"
8386 }
8387 return v
8388 }
8389
8390 func withPluginConfig(v ServerView, p config.PluginEntry) ServerView {
8391 return withPluginConfigInWorkspace(v, p, "")
8392 }
8393
8394 func withPluginConfigInWorkspace(v ServerView, p config.PluginEntry, workspace string) ServerView {
8395 tt := p.Type
8396 if tt == "" {
8397 tt = "stdio"
8398 }
8399 v.Transport = tt
8400 v.Configured = true
8401 v.Installed = true
8402 v.Source, v.ConfigSource = mcpServerSource(p.Source)
8403 v.Enabled = mcpEntryEnabled(p, workspace)
8404 v.AutoStart = v.Enabled
8405 v.Tier = p.ResolvedTier()
8406 if v.StartIntent == "" {
8407 if v.Enabled {
8408 v.StartIntent = "automatic"
8409 } else {
8410 v.StartIntent = "off"
8411 }
8412 }
8413 if !v.Enabled || v.Status == "disabled" {
8414 v.Status = "disabled"
8415 v.StartIntent = "off"
8416 v.RuntimeState = "idle"
8417 }
8418 if v.RuntimeState == "" {
8419 v.RuntimeState = mcpRuntimeState(v.Status)
8420 }
8421 v.Command = p.Command
8422 v.Args = append([]string(nil), p.Args...)
8423 v.URL = p.URL
8424 v.CallTimeoutSeconds = p.CallTimeoutSeconds
8425 v.ToolTimeoutSeconds = cloneStringIntMap(p.ToolTimeoutSeconds)
8426 // Configured MCP entries are explicit installs, including project sources.
8427 v.RequiresLaunchApproval = false
8428 v.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)
8429 v.EnvKeys = nil
8430 v.HeaderKeys = nil
8431 if len(p.Env) > 0 {
8432 v.EnvKeys = make([]string, 0, len(p.Env))
8433 for k := range p.Env {
8434 v.EnvKeys = append(v.EnvKeys, k)
8435 }
8436 sort.Strings(v.EnvKeys)
8437 }
8438 if len(p.Headers) > 0 {
8439 v.HeaderKeys = make([]string, 0, len(p.Headers))
8440 for k := range p.Headers {
8441 v.HeaderKeys = append(v.HeaderKeys, k)
8442 }
8443 sort.Strings(v.HeaderKeys)
8444 }
8445 auth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)
8446 v.AuthStatus = auth.Status
8447 v.AuthURL = auth.URL
8448 return v
8449 }
8450
8451 func mcpServerSource(source config.MCPConfigSource) (kind, configSource string) {
8452 switch source {
8453 case config.MCPSourceProjectConfig:
8454 return "project", "reasonix.toml"
8455 case config.MCPSourceProjectMCPJSON:
8456 return "project", ".mcp.json"
8457 case config.MCPSourcePluginPackage:
8458 return "plugin", "plugin"
8459 case config.MCPSourceLegacyUser:
8460 return "user", "legacy config"
8461 case config.MCPSourceUserConfig:
8462 return "user", "config.toml"
8463 default:
8464 return "", ""
8465 }
8466 }
8467
8468 const skillRootsCacheTTL = 10 * time.Second
8469
8470 func (a *App) cachedSkillRootsView() []SkillRootView {
8471 cwd, _ := os.Getwd()
8472 cfg, _ := config.Load()
8473 userCfg := config.LoadForEdit(config.UserConfigPath())
8474 key := skillRootsCacheKey(cwd, cfg, userCfg)
8475
8476 now := time.Now()
8477 a.skillRootsMu.Lock()
8478 if a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {
8479 roots := cloneSkillRootViews(a.skillRootsCache.roots)
8480 a.skillRootsMu.Unlock()
8481 return roots
8482 }
8483 a.skillRootsMu.Unlock()
8484
8485 roots := skillRootsViewFrom(cwd, cfg, userCfg)
8486
8487 a.skillRootsMu.Lock()
8488 a.skillRootsCache = skillRootsCache{
8489 key: key,
8490 at: now,
8491 roots: cloneSkillRootViews(roots),
8492 }
8493 a.skillRootsMu.Unlock()
8494 return roots
8495 }
8496
8497 func (a *App) invalidateSkillRootsCache() {
8498 a.skillRootsMu.Lock()
8499 a.skillRootsCache = skillRootsCache{}
8500 a.skillRootsMu.Unlock()
8501 }
8502
8503 func skillRootsView() []SkillRootView {
8504 cwd, _ := os.Getwd()
8505 cfg, _ := config.Load()
8506 userCfg := config.LoadForEdit(config.UserConfigPath())
8507 return skillRootsViewFrom(cwd, cfg, userCfg)
8508 }
8509
8510 func skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView {
8511 var custom []string
8512 var excluded []string
8513 maxDepth := 3
8514 if cfg != nil {
8515 custom = cfg.SkillCustomPaths()
8516 excluded = cfg.SkillExcludedPaths()
8517 maxDepth = cfg.SkillMaxDepth()
8518 }
8519 var pluginPaths map[string][]string
8520 var pluginAgentPaths map[string][]string
8521 if cfg != nil {
8522 pluginPaths = cfg.PluginPackageSkillOwners()
8523 pluginAgentPaths = cfg.PluginPackageAgentOwners()
8524 }
8525 st := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, PluginPaths: pluginPaths, PluginAgentPaths: pluginAgentPaths, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})
8526 counts := map[string]int{}
8527 skillItems := map[string][]SkillRootSkillView{}
8528 roots := st.Roots()
8529 for _, sk := range st.SlashList() {
8530 root := skillDisplayRoot(sk, roots)
8531 counts[root]++
8532 skillItems[root] = append(skillItems[root], SkillRootSkillView{
8533 Name: sk.Name,
8534 Description: sk.Description,
8535 Scope: string(sk.Scope),
8536 RunAs: string(sk.RunAs),
8537 Plugin: sk.Plugin,
8538 Model: sk.Model,
8539 Effort: sk.Effort,
8540 AllowedTools: append([]string{}, sk.AllowedTools...),
8541 Color: sk.Color,
8542 Invocation: "/" + sk.SlashName(),
8543 })
8544 }
8545 for root := range skillItems {
8546 sort.Slice(skillItems[root], func(i, j int) bool {
8547 return skillItems[root][i].Invocation < skillItems[root][j].Invocation
8548 })
8549 }
8550 userConfigured := map[string]bool{}
8551 if userCfg != nil {
8552 for _, p := range userCfg.Skills.Paths {
8553 userConfigured[config.CanonicalSkillPath(p)] = true
8554 }
8555 }
8556 out := []SkillRootView{}
8557 seenRoots := map[string]int{}
8558 for _, r := range roots {
8559 dir := config.CanonicalSkillPath(r.Dir)
8560 view := SkillRootView{
8561 Dir: r.Dir,
8562 Scope: string(r.Scope),
8563 Priority: r.Priority + 1,
8564 Status: string(r.Status),
8565 Configured: r.Scope == skill.ScopeCustom && userConfigured[dir],
8566 Removable: true,
8567 Skills: counts[dir],
8568 SkillItems: skillItems[dir],
8569 }
8570 if idx, ok := seenRoots[dir]; ok {
8571 out[idx] = mergeDuplicateSkillRootView(out[idx], view)
8572 continue
8573 }
8574 seenRoots[dir] = len(out)
8575 out = append(out, view)
8576 }
8577 if userCfg != nil {
8578 for _, p := range userCfg.Skills.Paths {
8579 if rootActive(out, p) {
8580 continue
8581 }
8582 out = append(out, SkillRootView{
8583 Dir: p,
8584 Scope: string(skill.ScopeCustom),
8585 Status: "inactive",
8586 Configured: true,
8587 Removable: true,
8588 Warning: "configured in user config but not active in this workspace; project [skills].paths may override it",
8589 })
8590 }
8591 }
8592 return out
8593 }
8594
8595 func mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {
8596 existing.Configured = existing.Configured || duplicate.Configured
8597 existing.Removable = existing.Removable || duplicate.Removable
8598 if existing.Status != "ok" && duplicate.Status == "ok" {
8599 existing.Status = duplicate.Status
8600 }
8601 if existing.Skills == 0 && duplicate.Skills > 0 {
8602 existing.Skills = duplicate.Skills
8603 existing.SkillItems = duplicate.SkillItems
8604 }
8605 if existing.Warning == "" {
8606 existing.Warning = duplicate.Warning
8607 }
8608 return existing
8609 }
8610
8611 func skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string {
8612 type cacheKey struct {
8613 CWD string `json:"cwd"`
8614 Custom []string `json:"custom"`
8615 Plugins []string `json:"plugins"`
8616 Excluded []string `json:"excluded"`
8617 MaxDepth int `json:"maxDepth"`
8618 UserPaths []string `json:"userPaths"`
8619 }
8620 key := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3}
8621 if cfg != nil {
8622 key.Custom = canonicalSkillPaths(cfg.SkillCustomPaths())
8623 for path, owners := range cfg.PluginPackageSkillOwners() {
8624 for _, owner := range owners {
8625 key.Plugins = append(key.Plugins, config.CanonicalSkillPath(path)+"\x00"+owner)
8626 }
8627 }
8628 sort.Strings(key.Plugins)
8629 key.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths())
8630 key.MaxDepth = cfg.SkillMaxDepth()
8631 }
8632 if userCfg != nil {
8633 key.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths)
8634 }
8635 b, err := json.Marshal(key)
8636 if err != nil {
8637 return fmt.Sprintf("%s|%v|%v|%v|%d|%v", key.CWD, key.Custom, key.Plugins, key.Excluded, key.MaxDepth, key.UserPaths)
8638 }
8639 return string(b)
8640 }
8641
8642 func canonicalSkillPaths(paths []string) []string {
8643 out := make([]string, 0, len(paths))
8644 for _, p := range paths {
8645 out = append(out, config.CanonicalSkillPath(p))
8646 }
8647 sort.Strings(out)
8648 return out
8649 }
8650
8651 func cloneSkillRootViews(in []SkillRootView) []SkillRootView {
8652 out := make([]SkillRootView, len(in))
8653 for i, r := range in {
8654 out[i] = r
8655 out[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)
8656 }
8657 return out
8658 }
8659
8660 func rootActive(roots []SkillRootView, path string) bool {
8661 want := config.CanonicalSkillPath(path)
8662 for _, r := range roots {
8663 if config.CanonicalSkillPath(r.Dir) == want {
8664 return true
8665 }
8666 }
8667 return false
8668 }
8669
8670 // PickSkillFolder opens a directory picker for adding custom skill roots. It only
8671 // returns a path; AddSkillPath performs normalization and writes config.
8672 func (a *App) PickSkillFolder() (string, error) {
8673 if a.ctx == nil {
8674 return "", nil
8675 }
8676 cur, _ := os.Getwd()
8677 dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
8678 Title: "Choose skills folder",
8679 DefaultDirectory: dialogDefaultDirectory(cur),
8680 })
8681 if err != nil || dir == "" {
8682 return "", err
8683 }
8684 return normalizeSkillPath(dir), nil
8685 }
8686
8687 // PickPluginFolder opens a directory picker for choosing a local plugin package
8688 // source. It returns the selected directory path; plugin install/plan performs
8689 // manifest validation and decides whether to copy or link the package.
8690 func (a *App) PickPluginFolder() (string, error) {
8691 if a.ctx == nil {
8692 return "", nil
8693 }
8694 cur := a.activeWorkspaceRoot()
8695 if strings.TrimSpace(cur) == "" {
8696 cur, _ = os.Getwd()
8697 }
8698 dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
8699 Title: "Choose plugin folder",
8700 DefaultDirectory: dialogDefaultDirectory(cur),
8701 })
8702 if err != nil || dir == "" {
8703 return "", err
8704 }
8705 return filepath.Clean(dir), nil
8706 }
8707
8708 // AddSkillPath adds a custom skill root to the user config and rebuilds the
8709 // controller so the skills index and slash menu reflect it immediately.
8710 func (a *App) AddSkillPath(path string) error {
8711 path = normalizeSkillPath(path)
8712 workspaceRoot := a.activeWorkspaceRoot()
8713 err := a.applyConfigChange(func(c *config.Config) error {
8714 if isConventionSkillRoot(path, workspaceRoot) {
8715 return c.RestoreSkillPath(path)
8716 }
8717 return c.AddSkillPath(path)
8718 })
8719 if err == nil {
8720 a.invalidateSkillRootsCache()
8721 }
8722 return err
8723 }
8724
8725 // RemoveSkillPath removes a skill source from the user config and rebuilds. For
8726 // convention roots, it records a pseudo-delete in excluded_paths.
8727 func (a *App) RemoveSkillPath(path string) error {
8728 path = normalizeSkillPath(path)
8729 err := a.applyConfigChange(func(c *config.Config) error {
8730 removed, err := c.RemoveSkillPath(path)
8731 if err != nil || removed {
8732 return err
8733 }
8734 return c.ExcludeSkillPath(path)
8735 })
8736 if err == nil {
8737 a.invalidateSkillRootsCache()
8738 }
8739 return err
8740 }
8741
8742 // RefreshSkills rebuilds the controller without changing config, reloading skill
8743 // discovery, the system prompt index, and slash completions.
8744 func (a *App) RefreshSkills() error {
8745 a.invalidateSkillRootsCache()
8746 if err := a.rebuild(); err != nil {
8747 // The skill cache is already invalidated; refresh the runtime once the
8748 // other window releases the session lease.
8749 if _, ok := a.deferredRebuildWarning("skills", err); ok {
8750 return nil
8751 }
8752 return err
8753 }
8754 return nil
8755 }
8756
8757 // ReloadCommands rescans command directories and hot-swaps without restarting
8758 // the controller — no MCP disconnect, no hook rerun.
8759 func (a *App) ReloadCommands() error {
8760 if a.ctx == nil {
8761 return nil
8762 }
8763 _, ctrl := a.activeTabAndCtrl()
8764 if ctrl == nil {
8765 return fmt.Errorf("no active session")
8766 }
8767 if ctrl.Running() {
8768 return fmt.Errorf("wait for the current turn to finish, then retry")
8769 }
8770 return ctrl.ReloadCommands(a.ctx)
8771 }
8772
8773 // SetSkillEnabled persists a skill toggle and rebuilds the controller so the
8774 // prompt index, slash menu, and skill tools reflect it immediately.
8775 func (a *App) SetSkillEnabled(name string, enabled bool) error {
8776 err := a.applyConfigChange(func(c *config.Config) error {
8777 return c.SetSkillEnabled(name, enabled)
8778 })
8779 if err == nil {
8780 a.invalidateSkillRootsCache()
8781 }
8782 return err
8783 }
8784
8785 func normalizeSkillPath(path string) string {
8786 path = strings.TrimSpace(path)
8787 if path == "" {
8788 return ""
8789 }
8790 if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
8791 if home, err := os.UserHomeDir(); err == nil {
8792 if path == "~" {
8793 path = home
8794 } else {
8795 path = filepath.Join(home, path[2:])
8796 }
8797 }
8798 }
8799 if abs, err := filepath.Abs(path); err == nil {
8800 path = abs
8801 }
8802 info, err := os.Stat(path)
8803 if err != nil {
8804 return filepath.Clean(path)
8805 }
8806 if info.Mode().IsRegular() {
8807 if filepath.Base(path) == skill.SkillFile {
8808 return filepath.Clean(filepath.Dir(filepath.Dir(path)))
8809 }
8810 return filepath.Clean(filepath.Dir(path))
8811 }
8812 if info.IsDir() {
8813 if _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {
8814 return filepath.Clean(filepath.Dir(path))
8815 }
8816 }
8817 return filepath.Clean(path)
8818 }
8819
8820 func isConventionSkillRoot(path, workspaceRoot string) bool {
8821 want := config.CanonicalSkillPath(path)
8822 if want == "" {
8823 return false
8824 }
8825 bases := []string{workspaceRoot}
8826 if home, err := os.UserHomeDir(); err == nil {
8827 bases = append(bases, home)
8828 }
8829 for _, base := range bases {
8830 base = strings.TrimSpace(base)
8831 if base == "" {
8832 continue
8833 }
8834 for _, dir := range config.ConventionDirs {
8835 if want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) {
8836 return true
8837 }
8838 }
8839 }
8840 return false
8841 }
8842
8843 func skillRootPath(path string) string {
8844 if filepath.Base(path) == skill.SkillFile {
8845 return filepath.Dir(path)
8846 }
8847 return path
8848 }
8849
8850 func skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {
8851 cleanPath := filepath.Clean(sk.Path)
8852 for _, r := range roots {
8853 if r.Scope != sk.Scope {
8854 continue
8855 }
8856 cleanRoot := filepath.Clean(r.Dir)
8857 prefix := cleanRoot + string(filepath.Separator)
8858 if cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {
8859 return config.CanonicalSkillPath(r.Dir)
8860 }
8861 }
8862 return config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))
8863 }
8864
8865 // MCPServerInput is the drawer's "add server" form. Transport is "stdio" (Command
8866 // + Args + Env) or "http"/"sse" (URL). Mirrors config.PluginEntry's writable shape.
8867 type MCPServerInput struct {
8868 Name string `json:"name"`
8869 Transport string `json:"transport"`
8870 Command string `json:"command"`
8871 Args []string `json:"args"`
8872 URL string `json:"url"`
8873 Env map[string]string `json:"env"`
8874 Headers map[string]string `json:"headers"`
8875 AutoStart *bool `json:"autoStart"`
8876 CallTimeoutSeconds *int `json:"callTimeoutSeconds"`
8877 ToolTimeoutSeconds map[string]int `json:"toolTimeoutSeconds"`
8878 }
8879
8880 func mcpServerInputEntry(in MCPServerInput) config.PluginEntry {
8881 entry := config.PluginEntry{
8882 Name: strings.TrimSpace(in.Name),
8883 Type: normalizeMCPTransport(in.Transport),
8884 Command: strings.TrimSpace(in.Command),
8885 Args: append([]string(nil), in.Args...),
8886 URL: strings.TrimSpace(in.URL),
8887 Env: in.Env,
8888 Headers: in.Headers,
8889 AutoStart: in.AutoStart,
8890 CallTimeoutSeconds: mcpIntValue(in.CallTimeoutSeconds),
8891 ToolTimeoutSeconds: cloneStringIntMap(in.ToolTimeoutSeconds),
8892 Source: config.MCPSourceUserConfig,
8893 }
8894 entry, _ = config.NormalizePluginCommandLine(entry)
8895 return entry
8896 }
8897
8898 // InstallMCPServer is the desktop's high-level install transaction. A normal
8899 // handshake failure leaves no config behind; authentication-required servers
8900 // are retained so the user can complete OAuth and retry. Only a ready result is
8901 // published to every controller sharing the Host.
8902 func (a *App) InstallMCPServer(in MCPServerInput) (plugin.MCPInstallResult, error) {
8903 defer a.lockMCPMutation("add")()
8904
8905 _, ctrl, root := a.activeMCPRuntime()
8906 if ctrl == nil {
8907 return plugin.MCPInstallResult{}, fmt.Errorf("no active session")
8908 }
8909 host, releaseGates, err := a.lockMCPHostTurnGates("MCP server", ctrl)
8910 if err != nil {
8911 return plugin.MCPInstallResult{}, err
8912 }
8913 defer releaseGates()
8914
8915 entry := mcpServerInputEntry(in)
8916 if entry.Name == "" {
8917 return plugin.InstallResultForError(entry.Name, fmt.Errorf("MCP server name is required")), nil
8918 }
8919 if _, found, lookupErr := desktopEffectiveMCPServer(root, entry.Name); lookupErr != nil {
8920 return plugin.MCPInstallResult{}, lookupErr
8921 } else if found {
8922 return plugin.InstallResultForError(entry.Name, fmt.Errorf("MCP server %q is already installed", entry.Name)), nil
8923 }
8924
8925 controllers := a.mcpControllersSharingHost(host, entry.Name, ctrl)
8926 toolCount, connectErr := ctrl.ConnectMCPServer(entry)
8927 if connectErr != nil {
8928 result := plugin.InstallResultForError(entry.Name, connectErr)
8929 if result.State != "action_required" {
8930 if host != nil {
8931 host.ClearFailure(entry.Name)
8932 }
8933 return result, nil
8934 }
8935 if err := a.saveDesktopMCPServer(root, entry); err != nil {
8936 return plugin.MCPInstallResult{}, err
8937 }
8938 if err := persistMCPInstallActivation(entry, root); err != nil {
8939 _, rollbackErr := a.removeDesktopMCPServer(root, entry.Name)
8940 if host != nil {
8941 host.ClearFailure(entry.Name)
8942 }
8943 return plugin.MCPInstallResult{}, errors.Join(err, rollbackErr)
8944 }
8945 recordMCPFailure(ctrl, entry, connectErr)
8946 return result, nil
8947 }
8948
8949 var publishErrs []error
8950 for _, target := range controllers {
8951 if target.ctrl == ctrl || !target.enabled {
8952 continue
8953 }
8954 if _, err := target.ctrl.ConnectMCPServer(entry); err != nil {
8955 publishErrs = append(publishErrs, err)
8956 }
8957 }
8958 if err := errors.Join(publishErrs...); err != nil {
8959 disconnectMCPServerControllers(entry.Name, ctrl, controllers)
8960 return plugin.MCPInstallResult{}, fmt.Errorf("publish MCP tools: %w", err)
8961 }
8962
8963 if err := a.saveDesktopMCPServer(root, entry); err != nil {
8964 disconnectMCPServerControllers(entry.Name, ctrl, controllers)
8965 return plugin.MCPInstallResult{}, err
8966 }
8967 if err := persistMCPInstallActivation(entry, root); err != nil {
8968 disconnectMCPServerControllers(entry.Name, ctrl, controllers)
8969 _, rollbackErr := a.removeDesktopMCPServer(root, entry.Name)
8970 // The first disconnect happened while the just-saved config still
8971 // existed, so controller runtimes retained it as disabled. Reconcile once
8972 // more after rollback removes the config to prevent a phantom proxy entry.
8973 disconnectMCPServerControllers(entry.Name, ctrl, controllers)
8974 return plugin.MCPInstallResult{}, errors.Join(err, rollbackErr)
8975 }
8976 return plugin.ReadyInstallResult(entry.Name, toolCount), nil
8977 }
8978
8979 func persistMCPInstallActivation(entry config.PluginEntry, root string) error {
8980 store := config.DefaultMCPActivationStore()
8981 if !entry.ShouldAutoStart() {
8982 return store.ClearServer(entry, root)
8983 }
8984 return store.SetServerEnabled(entry, root, true)
8985 }
8986
8987 // AddMCPServer is retained for old generated Wails clients. New clients use
8988 // InstallMCPServer so authentication and retry states remain structured.
8989 func (a *App) AddMCPServer(in MCPServerInput) (int, error) {
8990 result, err := a.InstallMCPServer(in)
8991 if err != nil {
8992 return 0, err
8993 }
8994 if result.State != "ready" {
8995 return 0, fmt.Errorf("%s", result.Message)
8996 }
8997 return result.ToolCount, nil
8998 }
8999
9000 // UpdateMCPServer edits a persisted external MCP server. The name is the stable
9001 // identity; callers must remove + add if they want to rename a server.
9002 func (a *App) UpdateMCPServer(name string, in MCPServerInput) error {
9003 defer a.lockMCPMutation("update")()
9004
9005 tab, ctrl, root := a.activeMCPRuntime()
9006 if tab == nil || ctrl == nil {
9007 return fmt.Errorf("no active session")
9008 }
9009 host, releaseGates, err := a.lockMCPHostTurnGates("MCP server", ctrl)
9010 if err != nil {
9011 return err
9012 }
9013 defer releaseGates()
9014 controllers := a.mcpControllersSharingHost(host, name, ctrl)
9015 if strings.TrimSpace(in.Name) != "" && strings.TrimSpace(in.Name) != name {
9016 return fmt.Errorf("renaming MCP servers is not supported; remove and add a new server")
9017 }
9018 updated, found, err := a.desktopMCPServerForEdit(root, name)
9019 if err != nil {
9020 return err
9021 }
9022 if !found {
9023 return fmt.Errorf("no configured MCP server named %q", name)
9024 }
9025 original := updated
9026 updated.Type = normalizeMCPTransport(in.Transport)
9027 updated.Command = strings.TrimSpace(in.Command)
9028 updated.Args = append([]string(nil), in.Args...)
9029 updated.URL = strings.TrimSpace(in.URL)
9030 updated.Tier = ""
9031 if in.Env != nil {
9032 updated.Env = in.Env
9033 }
9034 if in.Headers != nil {
9035 updated.Headers = in.Headers
9036 }
9037 if in.AutoStart != nil {
9038 value := *in.AutoStart
9039 updated.AutoStart = &value
9040 }
9041 if in.CallTimeoutSeconds != nil {
9042 updated.CallTimeoutSeconds = *in.CallTimeoutSeconds
9043 }
9044 if in.ToolTimeoutSeconds != nil {
9045 updated.ToolTimeoutSeconds = cloneStringIntMap(in.ToolTimeoutSeconds)
9046 }
9047 updated, _ = config.NormalizePluginCommandLine(updated)
9048 if updated.Type == "stdio" {
9049 updated.URL = ""
9050 } else {
9051 updated.Command = ""
9052 updated.Args = nil
9053 }
9054 enabled := false
9055 for _, target := range controllers {
9056 enabled = enabled || target.enabled
9057 }
9058 if !enabled {
9059 return a.saveDesktopMCPServer(root, updated)
9060 }
9061 spec, specErr := a.mcpLaunchSpecForEntry(root, updated)
9062 if specErr != nil {
9063 return specErr
9064 }
9065 if spec.RequireLaunchApproval {
9066 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
9067 defer cancel()
9068 if err := plugin.AuthorizeProjectSpecLaunch(ctx, spec); err != nil {
9069 return err
9070 }
9071 }
9072 disconnectMCPServerControllers(name, ctrl, controllers)
9073 if err := reconnectMCPServerControllers(updated, controllers); err != nil {
9074 rollbackErr := reconnectMCPServerControllers(original, controllers)
9075 recordMCPFailure(ctrl, updated, err)
9076 return errors.Join(err, rollbackErr)
9077 }
9078 if err := a.saveDesktopMCPServer(root, updated); err != nil {
9079 disconnectMCPServerControllers(name, ctrl, controllers)
9080 rollbackErr := reconnectMCPServerControllers(original, controllers)
9081 return errors.Join(err, rollbackErr)
9082 }
9083 return nil
9084 }
9085
9086 // RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).
9087 // Uninstall also clears durable activation overrides for that server.
9088 func (a *App) RemoveMCPServer(name string) error {
9089 defer a.lockMCPMutation("remove")()
9090
9091 tab, ctrl, root := a.activeMCPRuntime()
9092 if tab == nil || ctrl == nil {
9093 return fmt.Errorf("no active session")
9094 }
9095 host, releaseGates, err := a.lockMCPHostTurnGates("MCP server", ctrl)
9096 if err != nil {
9097 return err
9098 }
9099 defer releaseGates()
9100 controllers := a.mcpControllersSharingHost(host, name, ctrl)
9101 if err := ensureMCPServerDirectlyWritable(root, name); err != nil {
9102 return err
9103 }
9104 entry, hasEntry, _ := desktopEffectiveMCPServer(root, name)
9105 removed, err := a.removeDesktopMCPServer(root, name)
9106 if err != nil {
9107 return err
9108 }
9109 if !removed {
9110 return fmt.Errorf("no removable MCP server named %q", name)
9111 }
9112 if hasEntry {
9113 _ = config.DefaultMCPActivationStore().ClearServer(entry, root)
9114 }
9115 disconnectMCPServerControllers(name, ctrl, controllers)
9116 if host != nil {
9117 host.ClearFailure(name)
9118 }
9119 restoreMCPServerFallbacks(name, controllers)
9120 a.clearMCPServerTabState(name, controllers)
9121 return nil
9122 }
9123
9124 // restoreMCPServerFallbacks makes a lower-priority declaration immediately
9125 // available after its project override is removed. Registration is cache-first:
9126 // it restores cached tools or a connect placeholder without starting a process.
9127 func restoreMCPServerFallbacks(name string, controllers []mcpControllerTarget) {
9128 for _, target := range controllers {
9129 root := target.ctrl.WorkspaceRoot()
9130 cfg, err := config.LoadForRoot(root)
9131 if err != nil {
9132 slog.Warn("desktop: reload MCP fallback after remove", "name", name, "workspace", root, "err", err)
9133 continue
9134 }
9135 entry, found := findPluginEntry(cfg.Plugins, name)
9136 if !found || !mcpEntryEnabled(entry, root) {
9137 continue
9138 }
9139 if _, err := target.ctrl.RegisterMCPServerOnDemand(entry); err != nil {
9140 slog.Warn("desktop: restore MCP fallback after remove", "name", name, "workspace", root, "err", err)
9141 }
9142 }
9143 }
9144
9145 // ReconnectMCPServer disconnects the server if it is already connected (to force
9146 // a fresh handshake and tool re-registration), then reconnects. Failures are
9147 // recorded on the Host so the UI can render them.
9148 func (a *App) ReconnectMCPServer(name string) error {
9149 defer a.lockMCPMutation("reconnect")()
9150
9151 tab, ctrl, root := a.activeMCPRuntime()
9152 if tab == nil || ctrl == nil {
9153 return fmt.Errorf("no active session")
9154 }
9155 host, releaseGates, err := a.lockMCPHostTurnGates("MCP server", ctrl)
9156 if err != nil {
9157 return err
9158 }
9159 defer releaseGates()
9160 entry, found, err := desktopEffectiveMCPServer(root, name)
9161 if err != nil {
9162 return err
9163 }
9164 if !found {
9165 return fmt.Errorf("no configured MCP server named %q", name)
9166 }
9167 controllers := a.mcpControllersSharingHost(host, name, ctrl)
9168 for i := range controllers {
9169 if controllers[i].ctrl == ctrl {
9170 controllers[i].enabled = true
9171 }
9172 }
9173 disconnectMCPServerControllers(name, ctrl, controllers)
9174 if host != nil {
9175 host.ClearFailure(name)
9176 }
9177 if err := reconnectMCPServerControllers(entry, controllers); err != nil {
9178 recordMCPFailure(ctrl, entry, err)
9179 return err
9180 }
9181 a.mu.Lock()
9182 delete(tab.disabledMCP, name)
9183 a.mu.Unlock()
9184 return nil
9185 }
9186
9187 // ClearMCPServerAuthentication removes local auth-like config for one MCP and
9188 // clears the current session's cached connection failure. It does not remove the
9189 // server itself or try to sign the user out of the third-party browser session.
9190 func (a *App) ClearMCPServerAuthentication(name string) error {
9191 defer a.lockMCPMutation("clear-auth")()
9192
9193 tab, ctrl, root := a.activeMCPRuntime()
9194 if tab == nil || ctrl == nil {
9195 return fmt.Errorf("no active session")
9196 }
9197 host, releaseGates, err := a.lockMCPHostTurnGates("MCP server", ctrl)
9198 if err != nil {
9199 return err
9200 }
9201 defer releaseGates()
9202 controllers := a.mcpControllersSharingHost(host, name, ctrl)
9203 if err := ensureMCPServerDirectlyWritable(root, name); err != nil {
9204 return err
9205 }
9206 if _, _, _, err := config.ClearPluginAuthenticationInSourceForRoot(root, name); err != nil {
9207 return err
9208 }
9209 disconnectMCPServerControllers(name, ctrl, controllers)
9210 if host != nil {
9211 host.ClearFailure(name)
9212 }
9213 return nil
9214 }
9215
9216 // SetMCPServerEnabled is the durable enable/disable switch for an installed MCP
9217 // server. It writes $REASONIX_HOME/mcp-activation.json and updates the live
9218 // registry: disable removes tools and may stop the process; enable restores
9219 // cached tools and starts the process only on the next real tool call.
9220 func (a *App) SetMCPServerEnabled(name string, enabled bool) error {
9221 defer a.lockMCPMutation("set-enabled")()
9222
9223 tab, ctrl, root := a.activeMCPRuntime()
9224 if tab == nil || ctrl == nil {
9225 return fmt.Errorf("no active session")
9226 }
9227 a.mu.RLock()
9228 hostKey := tab.SharedHostKey
9229 a.mu.RUnlock()
9230 if err := rebuildControllerActiveWorkErrorFor(ctrl, "MCP server"); err != nil {
9231 return err
9232 }
9233 configuredEntry, hasConfiguredEntry, err := desktopEffectiveMCPServer(root, name)
9234 if err != nil {
9235 return err
9236 }
9237 if !hasConfiguredEntry {
9238 return fmt.Errorf("no configured MCP server named %q", name)
9239 }
9240 activationStore := config.DefaultMCPActivationStore()
9241 scope, workspaceFP, source, owner := config.ActivationIdentity(configuredEntry, root)
9242 previousEnabled, previousFound, err := activationStore.Lookup(scope, workspaceFP, source, owner, configuredEntry.Name)
9243 if err != nil {
9244 return err
9245 }
9246 if err := activationStore.SetServerEnabled(configuredEntry, root, enabled); err != nil {
9247 return err
9248 }
9249 if enabled {
9250 // Restore cached tools (or a cache-miss connect stub) without forcing a
9251 // process start. Explicit install/retry remains the readiness-probed path.
9252 _, err := a.registerConfiguredMCPServerForTab(tab, name)
9253 if err == nil {
9254 a.mu.Lock()
9255 delete(tab.disabledMCP, name)
9256 a.mu.Unlock()
9257 return nil
9258 }
9259 var rollbackErr error
9260 if previousFound {
9261 rollbackErr = activationStore.SetServerEnabled(configuredEntry, root, previousEnabled)
9262 } else {
9263 rollbackErr = activationStore.ClearServer(configuredEntry, root)
9264 }
9265 return errors.Join(err, rollbackErr)
9266 }
9267 if s, ok := findMCPServerView(ctrl, name); ok {
9268 s.Status = "disabled"
9269 s.Enabled = false
9270 s.Error = ""
9271 s = finalizeServerView(s)
9272 a.mu.Lock()
9273 if tab.disabledMCP == nil {
9274 tab.disabledMCP = map[string]ServerView{}
9275 }
9276 tab.disabledMCP[name] = s
9277 tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})
9278 a.mu.Unlock()
9279 } else {
9280 s := finalizeServerView(withPluginConfig(ServerView{Name: name, Status: "disabled", Enabled: false}, configuredEntry))
9281 a.mu.Lock()
9282 if tab.disabledMCP == nil {
9283 tab.disabledMCP = map[string]ServerView{}
9284 }
9285 tab.disabledMCP[name] = s
9286 tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})
9287 a.mu.Unlock()
9288 }
9289 if hostKey != "" {
9290 ctrl.UnregisterMCPServerTools(name)
9291 } else {
9292 ctrl.DisconnectMCPServer(name)
9293 }
9294 return nil
9295 }
9296
9297 func (a *App) registerConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {
9298 a.mu.RLock()
9299 var ctrl control.SessionAPI
9300 root := ""
9301 if tab != nil {
9302 ctrl = tab.Ctrl
9303 root = tab.WorkspaceRoot
9304 }
9305 a.mu.RUnlock()
9306 if ctrl == nil {
9307 return 0, fmt.Errorf("no active session")
9308 }
9309 cfg, err := config.LoadForRoot(root)
9310 if err != nil {
9311 return 0, err
9312 }
9313 for _, p := range cfg.Plugins {
9314 if p.Name == name {
9315 return ctrl.RegisterMCPServerOnDemand(p)
9316 }
9317 }
9318 return 0, fmt.Errorf("no configured MCP server named %q", name)
9319 }
9320
9321 // SetMCPServerTier is kept for old desktop bindings. New config writes drop the
9322 // retired tier field.
9323 func (a *App) SetMCPServerTier(name, tier string) error {
9324 defer a.lockMCPMutation("set-tier")()
9325
9326 tier = normalizeMCPTier(tier)
9327 tab, ctrl, root := a.activeMCPRuntime()
9328 if tab != nil {
9329 if err := rebuildControllerActiveWorkErrorFor(ctrl, "MCP server"); err != nil {
9330 return err
9331 }
9332 }
9333 updated, found, err := a.desktopMCPServerForEdit(root, name)
9334 if err != nil {
9335 return err
9336 }
9337 if !found {
9338 return fmt.Errorf("no configured MCP server named %q", name)
9339 }
9340 updated.Tier = tier
9341 if !updated.ShouldAutoStart() {
9342 on := true
9343 updated.AutoStart = &on
9344 }
9345 if err := a.saveDesktopMCPServer(root, updated); err != nil {
9346 return err
9347 }
9348 if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) {
9349 if _, err := ctrl.ConnectMCPServer(updated); err != nil {
9350 recordMCPFailure(ctrl, updated, err)
9351 return nil
9352 }
9353 a.mu.Lock()
9354 delete(tab.disabledMCP, name)
9355 a.mu.Unlock()
9356 }
9357 return nil
9358 }
9359
9360 func (a *App) desktopMCPServerForEdit(root, name string) (config.PluginEntry, bool, error) {
9361 // Edit the same effective declaration the runtime selected. The entry's
9362 // provenance is retained so saveDesktopMCPServer writes it back to the
9363 // owning project/global file instead of promoting it across scopes.
9364 return desktopEffectiveMCPServer(root, name)
9365 }
9366
9367 // desktopEffectiveMCPServer returns the same merged entry the runtime starts.
9368 // Its provenance identifies the exact project or global declaration that edit
9369 // and remove operations must mutate.
9370 func desktopEffectiveMCPServer(root, name string) (config.PluginEntry, bool, error) {
9371 cfg, err := config.LoadForRoot(root)
9372 if err != nil {
9373 return config.PluginEntry{}, false, err
9374 }
9375 p, ok := findPluginEntry(cfg.Plugins, name)
9376 return p, ok, nil
9377 }
9378
9379 func (a *App) saveDesktopMCPServer(root string, entry config.PluginEntry) error {
9380 if err := ensureMCPServerDirectlyWritable(root, entry.Name); err != nil {
9381 return err
9382 }
9383 _, err := config.UpsertPluginInSourceForRoot(root, entry)
9384 return err
9385 }
9386
9387 func ensureMCPServerDirectlyWritable(root, name string) error {
9388 cfg, err := config.LoadForRoot(root)
9389 if err != nil {
9390 return err
9391 }
9392 if owner, ok := cfg.PluginPackageOwner(name); ok {
9393 return fmt.Errorf("MCP server %q is managed by plugin %q; disable or remove the plugin instead", name, owner)
9394 }
9395 return nil
9396 }
9397
9398 func (a *App) removeDesktopMCPServer(root, name string) (bool, error) {
9399 _, removed, _, err := config.RemovePluginFromEffectiveSourceForRoot(root, name)
9400 return removed, err
9401 }
9402
9403 func findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {
9404 for _, p := range entries {
9405 if p.Name == name {
9406 return p, true
9407 }
9408 }
9409 return config.PluginEntry{}, false
9410 }
9411
9412 func normalizeMCPTier(tier string) string {
9413 switch strings.ToLower(strings.TrimSpace(tier)) {
9414 case "eager":
9415 return "eager"
9416 case "background", "lazy":
9417 return "background"
9418 case "":
9419 return "background"
9420 default:
9421 return "background"
9422 }
9423 }
9424
9425 func normalizeMCPTransport(transport string) string {
9426 switch strings.ToLower(strings.TrimSpace(transport)) {
9427 case "http", "streamable-http":
9428 return "http"
9429 case "sse":
9430 return "sse"
9431 case "", "stdio":
9432 return "stdio"
9433 default:
9434 return strings.ToLower(strings.TrimSpace(transport))
9435 }
9436 }
9437
9438 func mcpIntValue(value *int) int {
9439 if value == nil {
9440 return 0
9441 }
9442 return *value
9443 }
9444
9445 func cloneStringIntMap(values map[string]int) map[string]int {
9446 if values == nil {
9447 return nil
9448 }
9449 out := make(map[string]int, len(values))
9450 for key, value := range values {
9451 out[key] = value
9452 }
9453 return out
9454 }
9455
9456 func mcpConnected(ctrl control.SessionAPI, name string) bool {
9457 if ctrl == nil || ctrl.Host() == nil {
9458 return false
9459 }
9460 for _, s := range ctrl.Host().Servers() {
9461 if s.Name == name {
9462 return true
9463 }
9464 }
9465 return false
9466 }
9467
9468 func mcpFailed(ctrl control.SessionAPI, name string) bool {
9469 if ctrl == nil || ctrl.Host() == nil {
9470 return false
9471 }
9472 for _, f := range ctrl.Host().Failures() {
9473 if f.Name == name {
9474 return true
9475 }
9476 }
9477 return false
9478 }
9479
9480 func recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {
9481 if ctrl == nil || ctrl.Host() == nil || err == nil {
9482 return
9483 }
9484 exp := e.ExpandedPlugin()
9485 ctrl.Host().RecordFailure(plugin.Spec{
9486 Name: exp.Name,
9487 Type: exp.Type,
9488 Command: exp.Command,
9489 Args: exp.Args,
9490 Env: exp.Env,
9491 URL: exp.URL,
9492 Headers: exp.Headers,
9493 }, err)
9494 }
9495
9496 func findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {
9497 if ctrl == nil || ctrl.Host() == nil {
9498 return ServerView{}, false
9499 }
9500 for _, s := range ctrl.Host().Servers() {
9501 if s.Name == name {
9502 view := ServerView{
9503 Name: s.Name, Transport: s.Transport, Status: "connected",
9504 Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources,
9505 HasTools: s.HasTools,
9506 ToolList: pluginToolsToView(s.ToolList),
9507 }
9508 return view, true
9509 }
9510 }
9511 for _, f := range ctrl.Host().Failures() {
9512 if f.Name == name {
9513 return ServerView{
9514 Name: f.Name, Transport: f.Transport, Status: "failed", Error: f.Error,
9515 RequiresLaunchApproval: f.RequiresLaunchApproval,
9516 }, true
9517 }
9518 }
9519 return ServerView{}, false
9520 }
9521
9522 func pluginToolsToView(tools []plugin.ToolInfo) []ToolView {
9523 if len(tools) == 0 {
9524 return []ToolView{}
9525 }
9526 out := make([]ToolView, 0, len(tools))
9527 for _, t := range tools {
9528 out = append(out, ToolView{
9529 Name: t.Name, Description: t.Description, ReadOnlyHint: t.ReadOnlyHint, DestructiveHint: t.DestructiveHint, SchemaError: t.SchemaError,
9530 })
9531 }
9532 return out
9533 }
9534
9535 func sameStringList(a, b []string) bool {
9536 if len(a) != len(b) {
9537 return false
9538 }
9539 for i := range a {
9540 if a[i] != b[i] {
9541 return false
9542 }
9543 }
9544 return true
9545 }
9546
9547 func orderServerViews(servers []ServerView, order []string) []ServerView {
9548 pos := make(map[string]int, len(order))
9549 for i, name := range order {
9550 pos[name] = i
9551 }
9552 sort.SliceStable(servers, func(i, j int) bool {
9553 pi, iok := pos[servers[i].Name]
9554 pj, jok := pos[servers[j].Name]
9555 switch {
9556 case iok && jok:
9557 return pi < pj
9558 case iok:
9559 return true
9560 case jok:
9561 return false
9562 default:
9563 return false
9564 }
9565 })
9566 return servers
9567 }
9568
9569 func mergeServerOrder(order []string, servers []ServerView) []string {
9570 seen := make(map[string]bool, len(order)+len(servers))
9571 next := make([]string, 0, len(order)+len(servers))
9572 for _, name := range order {
9573 if name == "" || seen[name] {
9574 continue
9575 }
9576 seen[name] = true
9577 next = append(next, name)
9578 }
9579 for _, s := range servers {
9580 if s.Name == "" || seen[s.Name] {
9581 continue
9582 }
9583 seen[s.Name] = true
9584 next = append(next, s.Name)
9585 }
9586 return next
9587 }
9588
9589 func removeServerOrder(order []string, name string) []string {
9590 if name == "" || len(order) == 0 {
9591 return order
9592 }
9593 next := order[:0]
9594 for _, n := range order {
9595 if n != name {
9596 next = append(next, n)
9597 }
9598 }
9599 return next
9600 }
9601
9602 // ModelInfo is one (provider, model) the bottom switcher can pick. Ref ("provider/
9603 // model") is what SetModel takes; Provider/Model are for display.
9604 type ModelInfo struct {
9605 Ref string `json:"ref"`
9606 Provider string `json:"provider"`
9607 Model string `json:"model"`
9608 Current bool `json:"current"`
9609 }
9610
9611 type EffortInfo struct {
9612 Supported bool `json:"supported"`
9613 Current string `json:"current"`
9614 Default string `json:"default"`
9615 Levels []string `json:"levels"`
9616 }
9617
9618 // Models flattens the configured providers into their (provider, model) pairs —
9619 // the switcher's options — marking the active one. A vendor with a `models` list
9620 // yields one entry per model, all sharing the same endpoint/key. Unconfigured
9621 // providers are skipped. Result is non-nil: the frontend reads .length, so a nil
9622 // slice (JSON null) would crash the switcher on an empty list.
9623 func (a *App) Models() []ModelInfo {
9624 return a.ModelsForTab("")
9625 }
9626
9627 func (a *App) ModelsForTab(tabID string) []ModelInfo {
9628 a.mu.RLock()
9629 curModel := ""
9630 workspaceRoot := ""
9631 var ctrl control.SessionAPI
9632 if tab := a.tabByIDLocked(tabID); tab != nil {
9633 curModel = tab.model
9634 workspaceRoot = tab.WorkspaceRoot
9635 ctrl = tab.Ctrl
9636 }
9637 a.mu.RUnlock()
9638 // The tab controller's merged catalog carries extension sidecar providers
9639 // (plugin/... refs). Read it off-lock: a cold catalog fetch can block on a
9640 // sidecar RPC and must never park a.mu.
9641 var extensionCatalog []provider.Descriptor
9642 if ctrl != nil {
9643 extensionCatalog = ctrl.ProviderCatalog()
9644 }
9645 cfg, err := config.LoadForRoot(workspaceRoot)
9646 if err != nil {
9647 return []ModelInfo{}
9648 }
9649 if entry, ok := cfg.ResolveModel(curModel); ok {
9650 curModel = entry.Name + "/" + entry.Model
9651 }
9652 out := []ModelInfo{}
9653 for i := range cfg.Providers {
9654 p := &cfg.Providers[i]
9655 if !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, p.Name) || !p.Configured() {
9656 continue
9657 }
9658 for _, m := range p.ChatModelList() {
9659 ref := p.Name + "/" + m
9660 out = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel})
9661 }
9662 }
9663 return mergeExtensionModelInfos(out, extensionCatalog, curModel)
9664 }
9665
9666 // mergeExtensionModelInfos folds the tab controller's extension provider
9667 // catalog into the config-backed switcher list. Extension refs arrive fully
9668 // namespaced (plugin/<plugin>/<provider>/<model>) and need no provider-access
9669 // gate: installing/enabling the plugin package is the host-level grant. A nil
9670 // catalog (no provider-declaring sidecar) leaves the list untouched.
9671 func mergeExtensionModelInfos(out []ModelInfo, catalog []provider.Descriptor, curModel string) []ModelInfo {
9672 if len(catalog) == 0 {
9673 return out
9674 }
9675 seen := make(map[string]bool, len(out)+len(catalog))
9676 for _, info := range out {
9677 seen[info.Ref] = true
9678 }
9679 for _, d := range catalog {
9680 ref := strings.TrimSpace(d.Ref)
9681 if ref == "" || seen[ref] {
9682 continue
9683 }
9684 seen[ref] = true
9685 providerName, model := "plugin", ref
9686 if owner := providerext.PluginRefOwner(ref); owner != "" {
9687 providerName = "plugin/" + owner
9688 model = strings.TrimPrefix(ref, "plugin/"+owner+"/")
9689 }
9690 out = append(out, ModelInfo{Ref: ref, Provider: providerName, Model: model, Current: ref == curModel})
9691 }
9692 return out
9693 }
9694
9695 // extensionModelDescriptor finds a plugin-namespaced ref in a controller's
9696 // merged catalog: an exact match, or the prefix form where ref names the
9697 // provider and the descriptor adds the model segment. Non-plugin refs never
9698 // match — they belong to the config catalog.
9699 func extensionModelDescriptor(catalog []provider.Descriptor, ref string) (provider.Descriptor, bool) {
9700 ref = strings.TrimSpace(ref)
9701 if providerext.PluginRefOwner(ref) == "" {
9702 return provider.Descriptor{}, false
9703 }
9704 for _, d := range catalog {
9705 if d.Ref == ref || strings.HasPrefix(d.Ref, ref+"/") {
9706 return d, true
9707 }
9708 }
9709 return provider.Descriptor{}, false
9710 }
9711
9712 func modelProviderAccessAllowed(access []string, name string) bool {
9713 if access == nil {
9714 return true
9715 }
9716 name = strings.TrimSpace(name)
9717 for _, candidate := range access {
9718 if strings.TrimSpace(candidate) == name {
9719 return true
9720 }
9721 }
9722 return false
9723 }
9724
9725 // providerCatalogForTab returns the tab controller's merged provider catalog
9726 // (extension sidecar providers over the config base), or nil when the tab has
9727 // no live controller or no sidecar declared providers.
9728 func (a *App) providerCatalogForTab(tab *WorkspaceTab) []provider.Descriptor {
9729 if tab == nil {
9730 return nil
9731 }
9732 if ctrl := a.controllerForTab(tab); ctrl != nil {
9733 return ctrl.ProviderCatalog()
9734 }
9735 return nil
9736 }
9737
9738 type activeRuntimeWork struct {
9739 running bool
9740 pendingPrompt bool
9741 backgroundJobs int
9742 }
9743
9744 func controllerActiveRuntimeWork(ctrl control.SessionAPI) activeRuntimeWork {
9745 if ctrl == nil {
9746 return activeRuntimeWork{}
9747 }
9748 status := ctrl.RuntimeStatus()
9749 return activeRuntimeWork{
9750 running: status.Running,
9751 pendingPrompt: status.PendingPrompt,
9752 backgroundJobs: status.BackgroundJobs,
9753 }
9754 }
9755
9756 func (w activeRuntimeWork) active() bool {
9757 return w.running || w.pendingPrompt || w.backgroundJobs > 0
9758 }
9759
9760 func controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {
9761 return controllerActiveRuntimeWork(ctrl).active()
9762 }
9763
9764 // rebuildBusyError reports a rebuild rejected because the controller still has
9765 // a running turn, pending prompt, or background jobs. Typed so the
9766 // deferred-rebuild retry loop can keep waiting instead of giving up.
9767 type rebuildBusyError struct {
9768 setting string
9769 work activeRuntimeWork
9770 }
9771
9772 func (e *rebuildBusyError) Error() string {
9773 return fmt.Sprintf(
9774 "active work is still running; running=%t; pending_prompt=%t; background_jobs=%d; finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s",
9775 e.work.running,
9776 e.work.pendingPrompt,
9777 e.work.backgroundJobs,
9778 e.setting,
9779 )
9780 }
9781
9782 func rebuildControllerActiveWorkErrorFor(ctrl control.SessionAPI, setting string) error {
9783 work := controllerActiveRuntimeWork(ctrl)
9784 if !work.active() {
9785 return nil
9786 }
9787 return &rebuildBusyError{setting: setting, work: work}
9788 }
9789
9790 type sessionLeaseBusyError struct {
9791 setting string
9792 err error
9793 }
9794
9795 func (e *sessionLeaseBusyError) Error() string {
9796 // The raw SessionLeaseError text carries the session path and the
9797 // holder's host-pid-writer id; every user-facing surface must render
9798 // this wrapper instead. An empty setting means the failure gated opening
9799 // the session itself (startup bind), not changing a setting on it.
9800 setting := strings.TrimSpace(e.setting)
9801 if setting == "" {
9802 return "this session is already open in another Reasonix window or still running in the background; close the other window or open a copy"
9803 }
9804 return fmt.Sprintf("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing %s", setting)
9805 }
9806
9807 func (e *sessionLeaseBusyError) Unwrap() error {
9808 if e == nil {
9809 return nil
9810 }
9811 return e.err
9812 }
9813
9814 func userFacingSessionLeaseError(setting string, err error) error {
9815 if err == nil {
9816 return nil
9817 }
9818 if errors.Is(err, agent.ErrSessionLeaseHeld) {
9819 return &sessionLeaseBusyError{setting: setting, err: err}
9820 }
9821 return err
9822 }
9823
9824 // sessionPathAfterSnapshot returns where a controller rebuild should keep
9825 // persisting after the old controller was snapshotted. Snapshotting is not
9826 // path-neutral: a snapshot conflict can recover by retargeting the controller
9827 // (and the tab's session lease, via handleTabSessionRecovered) to a recovery
9828 // branch, so a prevPath captured before the snapshot may be stale. Reusing the
9829 // stale path would bind the rebuilt controller — carrying the just-recovered
9830 // transcript — back to the original file, turning every later save into a new
9831 // conflict that derives yet another recovery branch. Falls back to fallback
9832 // when the controller is gone or persistence is disabled (empty SessionPath).
9833 func sessionPathAfterSnapshot(ctrl control.SessionAPI, fallback string) string {
9834 if ctrl == nil {
9835 return fallback
9836 }
9837 if path := strings.TrimSpace(ctrl.SessionPath()); path != "" {
9838 return path
9839 }
9840 return fallback
9841 }
9842
9843 var (
9844 // sessionLeaseContentionRetryInterval and sessionLeaseContentionRetryAttempts
9845 // bound the retry window for startup session-lease binds that hit a
9846 // transient in-process holder. CleanupStaleRunning probes a running
9847 // sub-agent's parent session lease inside every controller build, holding
9848 // it only for the duration of a metadata rewrite (sub-millisecond); a
9849 // concurrent tab build that races that probe must not surface a spurious
9850 // "already open in another Reasonix window" error for a lease that is
9851 // genuinely free once the probe releases it. A lease held by another
9852 // window or process stays held for its whole lifetime, so the bounded
9853 // retry still fails fast there.
9854 sessionLeaseContentionRetryInterval = 50 * time.Millisecond
9855 sessionLeaseContentionRetryAttempts = 2
9856 )
9857
9858 // withSessionLeaseContentionRetry retries acquire while it fails with
9859 // agent.ErrSessionLeaseHeld, absorbing sub-second contention windows created
9860 // by transient in-process lease probes. Any other error is returned
9861 // immediately, and a lease that remains held after the bounded retries is
9862 // reported as-is.
9863 func withSessionLeaseContentionRetry[T any](acquire func() (T, error)) (T, error) {
9864 var zero T
9865 for attempt := 0; ; attempt++ {
9866 got, err := acquire()
9867 if err == nil {
9868 return got, nil
9869 }
9870 if !errors.Is(err, agent.ErrSessionLeaseHeld) || attempt >= sessionLeaseContentionRetryAttempts {
9871 return zero, err
9872 }
9873 time.Sleep(sessionLeaseContentionRetryInterval)
9874 }
9875 }
9876
9877 func (a *App) ensureTabSessionLeaseForRebuild(tab *WorkspaceTab, path, setting string) error {
9878 transition, reserveErr := a.reserveSessionRuntimePath(tab, path)
9879 if reserveErr != nil {
9880 return userFacingSessionLeaseError(setting, reserveErr)
9881 }
9882 if _, err := withSessionLeaseContentionRetry(func() (struct{}, error) {
9883 if err := tab.ensureSessionLease(path); err != nil {
9884 if a.canReclaimCurrentProcessSessionLease(tab, path, err) {
9885 if lease, reclaimErr := agent.TryReclaimCurrentProcessSessionLease(path); reclaimErr == nil {
9886 tab.adoptSessionLease(lease)
9887 return struct{}{}, nil
9888 } else {
9889 err = reclaimErr
9890 }
9891 }
9892 return struct{}{}, err
9893 }
9894 return struct{}{}, nil
9895 }); err != nil {
9896 a.rollbackSessionRuntimePath(transition)
9897 return userFacingSessionLeaseError(setting, err)
9898 }
9899 a.commitSessionRuntimePath(transition)
9900 return nil
9901 }
9902
9903 func (a *App) canReclaimCurrentProcessSessionLease(tab *WorkspaceTab, path string, err error) bool {
9904 key := sessionRuntimeKey(path)
9905 if tab == nil || key == "" || !errors.Is(err, agent.ErrSessionLeaseHeld) {
9906 return false
9907 }
9908 var leaseErr *agent.SessionLeaseError
9909 if !errors.As(err, &leaseErr) || leaseErr == nil {
9910 return false
9911 }
9912 // A readable info naming a foreign runtime is respected here; reclaim
9913 // would refuse it anyway. A nil Info (lease.json deleted by the user,
9914 // quarantined by AV, or torn by a crash) must still attempt the reclaim:
9915 // the OS lock is the arbiter there, and refusing on missing metadata
9916 // wedges a session nobody actually holds as permanently busy.
9917 if leaseErr.Info != nil &&
9918 (leaseErr.Info.PID != os.Getpid() || leaseErr.Info.WriterID != agent.SessionWriterID()) {
9919 return false
9920 }
9921 a.mu.RLock()
9922 defer a.mu.RUnlock()
9923 for _, candidate := range a.runtimeTabsLocked() {
9924 if candidate == nil || candidate == tab {
9925 continue
9926 }
9927 if candidate.sessionLeaseRuntimeKey() == key {
9928 return false
9929 }
9930 if candidate.Ctrl != nil && sessionRuntimeKey(candidate.currentSessionPath()) == key {
9931 return false
9932 }
9933 }
9934 // A detached runtime's controller still holds the OS lock; refuse reclaim
9935 // even when PID matches (#6955).
9936 if detached := a.detachedSessions[key]; detached != nil && detached.Ctrl != nil {
9937 return false
9938 }
9939 return true
9940 }
9941
9942 // SetModel switches the active model and carries the current conversation into the
9943 // new model's session, so the chat continues seamlessly and subsequent turns use
9944 // the new model. No-op if name is already active or the controller is down.
9945 func (a *App) SetModel(name string) error {
9946 return a.SetModelForTab("", name)
9947 }
9948
9949 // persistTabModelIfCurrent repairs stale model metadata without letting an
9950 // older default overwrite a newer explicit model switch. Model switches use
9951 // the same runtimeRebuildMu, so whichever operation acquires it last owns the
9952 // persisted provider identity.
9953 func (a *App) persistTabModelIfCurrent(tab *WorkspaceTab, model string) error {
9954 model = strings.TrimSpace(model)
9955 if tab == nil || model == "" {
9956 return nil
9957 }
9958 a.runtimeRebuildMu.Lock()
9959 defer a.runtimeRebuildMu.Unlock()
9960
9961 a.mu.RLock()
9962 if tab.removed || a.tabs[tab.ID] != tab {
9963 a.mu.RUnlock()
9964 return fmt.Errorf("tab %q changed while persisting model; retry", tab.ID)
9965 }
9966 if tab.Ctrl == nil || strings.TrimSpace(tab.model) != model {
9967 a.mu.RUnlock()
9968 return nil
9969 }
9970 a.mu.RUnlock()
9971
9972 path := a.currentSessionPathFor(tab)
9973 if path == "" {
9974 return nil
9975 }
9976 if err := agent.SetBranchModelPreserveUpdated(path, model); err != nil {
9977 return fmt.Errorf("persist selected model: %w", err)
9978 }
9979 return nil
9980 }
9981
9982 type modelSwitchTiming struct {
9983 Total time.Duration
9984 LockWait time.Duration
9985 Prepare time.Duration
9986 Config time.Duration
9987 Snapshot time.Duration
9988 Build time.Duration
9989 LeaseAndResume time.Duration
9990 SwapAndPersist time.Duration
9991 Outcome string
9992 }
9993
9994 func (a *App) SetModelForTab(tabID, name string) (retErr error) {
9995 if a.ctx == nil || name == "" {
9996 return nil
9997 }
9998 tab := a.tabByID(tabID)
9999 if tab == nil {
10000 return nil
10001 }
10002 a.mu.RLock()
10003 currentModel := tab.model
10004 a.mu.RUnlock()
10005 if name == currentModel {
10006 return nil
10007 }
10008 timing := modelSwitchTiming{}
10009 totalStarted := time.Now()
10010 defer func() {
10011 timing.Total = time.Since(totalStarted)
10012 if retErr != nil {
10013 timing.Outcome = "failed"
10014 } else {
10015 timing.Outcome = "ok"
10016 }
10017 slog.Debug(
10018 "desktop: model switch timing",
10019 "tab", tab.ID,
10020 "outcome", timing.Outcome,
10021 "total_ms", timing.Total.Milliseconds(),
10022 "lock_wait_ms", timing.LockWait.Milliseconds(),
10023 "prepare_ms", timing.Prepare.Milliseconds(),
10024 "config_ms", timing.Config.Milliseconds(),
10025 "snapshot_ms", timing.Snapshot.Milliseconds(),
10026 "build_ms", timing.Build.Milliseconds(),
10027 "lease_resume_ms", timing.LeaseAndResume.Milliseconds(),
10028 "swap_persist_ms", timing.SwapAndPersist.Milliseconds(),
10029 )
10030 if a.modelSwitchTimingHook != nil {
10031 a.modelSwitchTimingHook(timing)
10032 }
10033 }()
10034 // Same build+swap shape as rebuildSetting; hold the same lock so a settings
10035 // rebuild (manual or from the deferred-rebuild retry loop) and a model
10036 // switch cannot interleave on one tab.
10037 stageStarted := time.Now()
10038 a.runtimeRebuildMu.Lock()
10039 timing.LockWait = time.Since(stageStarted)
10040 defer a.runtimeRebuildMu.Unlock()
10041 stageStarted = time.Now()
10042 tab.turnStartMu.Lock()
10043 defer tab.turnStartMu.Unlock()
10044 prevPath := a.reconciledSessionPathForTab(tab)
10045 if prevPath == "" {
10046 prevPath = a.currentSessionPathFor(tab)
10047 }
10048 if a.controllerForTab(tab) == nil && prevPath != "" {
10049 a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
10050 }
10051 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "model"); err != nil {
10052 return err
10053 }
10054 if err := a.ensureTabControllerWorkspace(tab); err != nil {
10055 return err
10056 }
10057 prevPath = a.reconciledSessionPathForTab(tab)
10058 if prevPath == "" {
10059 prevPath = a.currentSessionPathFor(tab)
10060 }
10061 if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
10062 prevPath = a.reconciledSessionPathForTab(tab)
10063 if prevPath == "" {
10064 prevPath = a.currentSessionPathFor(tab)
10065 }
10066 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "model"); err != nil {
10067 return err
10068 }
10069 }
10070 timing.Prepare = time.Since(stageStarted)
10071 // Snapshot the tab profile under a.mu: SetModeForTab/SetGoalForTab and the
10072 // event sink write these fields under the lock while this rebuild runs
10073 // off-lock.
10074 stageStarted = time.Now()
10075 snap := a.tabRuntimeSnapshot(tab)
10076 runtime := snap.normalizedRuntime()
10077 cfg, err := config.LoadForRoot(snap.workspaceRoot)
10078 if err != nil {
10079 return err
10080 }
10081 entry, ok := cfg.ResolveModel(name)
10082 pluginRef := false
10083 if !ok {
10084 // Plugin-namespaced refs belong to extension sidecars: validate them
10085 // against the tab controller's merged catalog instead of the config.
10086 if d, found := extensionModelDescriptor(a.providerCatalogForTab(tab), name); found {
10087 pluginRef = true
10088 ok = true
10089 name = d.Ref
10090 }
10091 }
10092 if !ok {
10093 return fmt.Errorf("unknown model %q", name)
10094 }
10095 if !pluginRef {
10096 if !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, entry.Name) {
10097 return fmt.Errorf("model %q is not available because provider %q is not added", name, entry.Name)
10098 }
10099 name = entry.Name + "/" + entry.Model
10100 }
10101 effortOverride := cloneStringPtr(snap.effort)
10102 if effortOverride != nil && !pluginRef {
10103 normalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride}))
10104 if err != nil {
10105 effortOverride = nil
10106 } else {
10107 effortOverride = &normalized
10108 }
10109 }
10110 timing.Config = time.Since(stageStarted)
10111
10112 stageStarted = time.Now()
10113 var carried []provider.Message
10114 oldCtrl := a.controllerForTab(tab)
10115 if oldCtrl != nil {
10116 if prevPath == "" {
10117 prevPath = oldCtrl.SessionPath()
10118 }
10119 if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "model"); err != nil {
10120 return err
10121 }
10122 if err := a.snapshotTabForAction(tab, "changing model"); err != nil {
10123 return err
10124 }
10125 prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
10126 carried = oldCtrl.History()
10127 }
10128 timing.Snapshot = time.Since(stageStarted)
10129
10130 // Preserve the shared plugin host across controller rebuilds — the tab
10131 // stays in the same workspace root, so MCP processes must not be restarted.
10132 sharedHost := a.lookupSharedHost(snap.sharedHostKey)
10133
10134 stageStarted = time.Now()
10135 newCtrl, err := boot.Build(a.bootContext(), boot.Options{
10136 Model: name,
10137 RequireKey: false,
10138 AutoPricingCurrency: a.desktopAutoPricingCurrency(),
10139 StatsSource: "desktop",
10140 Sink: snap.sink,
10141 WorkspaceRoot: snap.workspaceRoot,
10142 SessionDir: sessionDirForSnapshot(snap),
10143 EffortOverride: cloneStringPtr(effortOverride),
10144 TokenMode: runtime.tokenMode,
10145 SharedHost: sharedHost,
10146 CleanupPendingReconciler: reconcileDesktopCleanupPending,
10147 SubagentParentLive: a.subagentParentProbeForBuild(tab),
10148 SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
10149 OnSessionRecovered: a.handleTabSessionRecovered(tab),
10150 // Same logical session: keep the private temporary directory across
10151 // model switches (Issue #7575).
10152 SessionTemp: sessionTempFromController(oldCtrl),
10153 })
10154 if err != nil {
10155 return err
10156 }
10157 timing.Build = time.Since(stageStarted)
10158 a.bindControllerDisplayRecorder(newCtrl)
10159 configureControllerRuntime(newCtrl, oldCtrl, runtime)
10160
10161 stageStarted = time.Now()
10162 path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
10163 if err := a.ensureTabSessionLeaseForRebuild(tab, path, "model"); err != nil {
10164 newCtrl.Close()
10165 return err
10166 }
10167 restoredRuntime, err := resumeControllerRuntimeWithMessages(newCtrl, carried, path, runtime)
10168 if err != nil {
10169 newCtrl.Close()
10170 return err
10171 }
10172 timing.LeaseAndResume = time.Since(stageStarted)
10173 stageStarted = time.Now()
10174 a.mu.Lock()
10175 if current := a.tabs[tab.ID]; current != tab {
10176 // The tab was closed/replaced while we built the new controller off-lock;
10177 // adopting it now would leak the runtime onto an orphaned tab and pin the
10178 // session lease forever.
10179 a.mu.Unlock()
10180 newCtrl.Close()
10181 tab.releaseSessionLease()
10182 return fmt.Errorf("tab %q changed while switching model; retry", tab.ID)
10183 }
10184 tab.Ctrl = newCtrl
10185 tab.model = name
10186 tab.effort = cloneStringPtr(effortOverride)
10187 tab.Label = newCtrl.Label()
10188 applyNormalizedRuntimeToTabLocked(tab, restoredRuntime)
10189 // Supersede any in-flight startup build: it would otherwise finish later,
10190 // overwrite this controller, and release/steal the tab's session lease.
10191 a.supersedeTabBuildLocked(tab)
10192 a.saveTabsLocked()
10193 a.mu.Unlock()
10194 if oldCtrl != nil {
10195 oldCtrl.Close()
10196 }
10197 // The runtime now reflects the on-disk config; drop any deferred refresh.
10198 a.clearDeferredRebuild(tab.ID)
10199 a.persistTabSessionPath(tab, path)
10200 // Keep the provider identity in the session sidecar inside the same
10201 // runtimeRebuildMu transaction as the controller swap. Empty sessions do
10202 // not autosave a turn, so without this write a later startup can prefer the
10203 // outgoing provider from stale metadata. Serializing it here also preserves
10204 // last-click-wins when a new-session default switch overlaps an explicit
10205 // model selection.
10206 if path != "" {
10207 if err := agent.SetBranchModelPreserveUpdated(path, name); err != nil {
10208 return fmt.Errorf("persist selected model: %w", err)
10209 }
10210 }
10211 a.notifyTabRuntimeRebuilt(tab)
10212 timing.SwapAndPersist = time.Since(stageStarted)
10213 return nil
10214 }
10215
10216 func (a *App) Effort() EffortInfo {
10217 return a.EffortForTab("")
10218 }
10219
10220 func (a *App) EffortForTab(tabID string) EffortInfo {
10221 entry, err := a.currentProviderEntryForTab(tabID)
10222 if err != nil {
10223 return EffortInfo{Current: "auto", Levels: []string{}}
10224 }
10225 cap := config.EffortCapabilityForEntry(entry)
10226 if !cap.Supported {
10227 return EffortInfo{Supported: false, Current: "auto", Default: cap.Default, Levels: []string{}}
10228 }
10229 levels := cap.Levels
10230 if levels == nil {
10231 levels = []string{}
10232 }
10233 return EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels}
10234 }
10235
10236 func (a *App) SetEffort(level string) error {
10237 return a.SetEffortForTab("", level)
10238 }
10239
10240 func (a *App) SetEffortForTab(tabID, level string) error {
10241 tab := a.tabByID(tabID)
10242 if tab == nil {
10243 if strings.TrimSpace(tabID) == "" {
10244 entry, err := a.currentProviderEntryForTab("")
10245 if err != nil {
10246 return err
10247 }
10248 effort, err := config.NormalizeEffort(entry, level)
10249 if err != nil {
10250 return err
10251 }
10252 return a.applyProviderEffortConfig(entry, effort)
10253 }
10254 return fmt.Errorf("tab %q not found", tabID)
10255 }
10256 // Build+swap path; serialize with the other rebuild paths (see
10257 // runtimeRebuildMu). The tab==nil branch above goes through
10258 // applyProviderEffortConfig → rebuildSetting, which takes the lock itself.
10259 a.runtimeRebuildMu.Lock()
10260 defer a.runtimeRebuildMu.Unlock()
10261 tab.turnStartMu.Lock()
10262 defer tab.turnStartMu.Unlock()
10263 prevPath := a.reconciledSessionPathForTab(tab)
10264 if prevPath == "" {
10265 prevPath = a.currentSessionPathFor(tab)
10266 }
10267 // Recomputing prevPath after this attach would be a dead store: it is
10268 // unconditionally derived again after ensureTabControllerWorkspace below.
10269 if a.controllerForTab(tab) == nil && prevPath != "" {
10270 a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
10271 }
10272 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "effort"); err != nil {
10273 return err
10274 }
10275 if err := a.ensureTabControllerWorkspace(tab); err != nil {
10276 return err
10277 }
10278 prevPath = a.reconciledSessionPathForTab(tab)
10279 if prevPath == "" {
10280 prevPath = a.currentSessionPathFor(tab)
10281 }
10282 if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
10283 prevPath = a.reconciledSessionPathForTab(tab)
10284 if prevPath == "" {
10285 prevPath = a.currentSessionPathFor(tab)
10286 }
10287 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "effort"); err != nil {
10288 return err
10289 }
10290 }
10291 snap := a.tabRuntimeSnapshot(tab)
10292 runtime := snap.normalizedRuntime()
10293 entry, err := a.currentProviderEntryForTab(tabID)
10294 if err != nil {
10295 return err
10296 }
10297 modelRef := entry.Name + "/" + entry.Model
10298 effort, err := config.NormalizeEffort(entry, level)
10299 if err != nil {
10300 return err
10301 }
10302 var carried []provider.Message
10303 oldCtrl := a.controllerForTab(tab)
10304 if oldCtrl != nil {
10305 if prevPath == "" {
10306 prevPath = oldCtrl.SessionPath()
10307 }
10308 if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "effort"); err != nil {
10309 return err
10310 }
10311 if err := a.snapshotTabForAction(tab, "changing effort"); err != nil {
10312 return err
10313 }
10314 prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
10315 carried = oldCtrl.History()
10316 }
10317 sharedHost := a.lookupSharedHost(snap.sharedHostKey)
10318 newCtrl, err := boot.Build(a.bootContext(), boot.Options{
10319 Model: modelRef,
10320 RequireKey: false,
10321 AutoPricingCurrency: a.desktopAutoPricingCurrency(),
10322 StatsSource: "desktop",
10323 Sink: snap.sink,
10324 WorkspaceRoot: snap.workspaceRoot,
10325 SessionDir: sessionDirForSnapshot(snap),
10326 EffortOverride: &effort,
10327 TokenMode: runtime.tokenMode,
10328 SharedHost: sharedHost,
10329 CleanupPendingReconciler: reconcileDesktopCleanupPending,
10330 SubagentParentLive: a.subagentParentProbeForBuild(tab),
10331 SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
10332 OnSessionRecovered: a.handleTabSessionRecovered(tab),
10333 // Same logical session: keep the private temporary directory across
10334 // effort switches (Issue #7575).
10335 SessionTemp: sessionTempFromController(oldCtrl),
10336 })
10337 if err != nil {
10338 return err
10339 }
10340 a.bindControllerDisplayRecorder(newCtrl)
10341 configureControllerRuntime(newCtrl, oldCtrl, runtime)
10342 path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
10343 if err := a.ensureTabSessionLeaseForRebuild(tab, path, "effort"); err != nil {
10344 newCtrl.Close()
10345 return err
10346 }
10347 restoredRuntime, err := resumeControllerRuntimeWithMessages(newCtrl, carried, path, runtime)
10348 if err != nil {
10349 newCtrl.Close()
10350 return err
10351 }
10352 a.mu.Lock()
10353 if current := a.tabs[tab.ID]; current != tab {
10354 a.mu.Unlock()
10355 newCtrl.Close()
10356 tab.releaseSessionLease()
10357 return fmt.Errorf("tab %q changed while switching effort; retry", tab.ID)
10358 }
10359 tab.Ctrl = newCtrl
10360 tab.model = modelRef
10361 tab.effort = &effort
10362 tab.Label = newCtrl.Label()
10363 applyNormalizedRuntimeToTabLocked(tab, restoredRuntime)
10364 clearTabStartupError(tab)
10365 tab.Ready = true
10366 a.supersedeTabBuildLocked(tab)
10367 a.saveTabsLocked()
10368 a.mu.Unlock()
10369 if oldCtrl != nil {
10370 oldCtrl.Close()
10371 }
10372 // The rebuilt runtime reflects the on-disk config; drop any deferred refresh.
10373 a.clearDeferredRebuild(tab.ID)
10374 a.persistTabSessionPath(tab, path)
10375 a.notifyTabRuntimeRebuilt(tab)
10376 return nil
10377 }
10378
10379 func (a *App) SetTokenMode(mode string) error {
10380 return a.SetTokenModeForTab("", mode)
10381 }
10382
10383 func (a *App) SetTokenModeForTab(tabID, mode string) error {
10384 mode = boot.NormalizeTokenMode(mode)
10385 tab := a.tabByID(tabID)
10386 if tab == nil {
10387 if strings.TrimSpace(tabID) == "" {
10388 return nil
10389 }
10390 return fmt.Errorf("tab %q not found", tabID)
10391 }
10392 a.mu.RLock()
10393 currentMode := boot.NormalizeTokenMode(tab.tokenMode)
10394 a.mu.RUnlock()
10395 if mode == currentMode {
10396 return nil
10397 }
10398 // Build+swap path; serialize with the other rebuild paths (see runtimeRebuildMu).
10399 a.runtimeRebuildMu.Lock()
10400 defer a.runtimeRebuildMu.Unlock()
10401 tab.turnStartMu.Lock()
10402 defer tab.turnStartMu.Unlock()
10403 prevPath := a.reconciledSessionPathForTab(tab)
10404 if prevPath == "" {
10405 prevPath = a.currentSessionPathFor(tab)
10406 }
10407 // Recomputing prevPath after this attach would be a dead store: it is
10408 // unconditionally derived again after ensureTabControllerWorkspace below.
10409 if a.controllerForTab(tab) == nil && prevPath != "" {
10410 a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
10411 }
10412 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "token mode"); err != nil {
10413 return err
10414 }
10415 if err := a.ensureTabControllerWorkspace(tab); err != nil {
10416 return err
10417 }
10418 prevPath = a.reconciledSessionPathForTab(tab)
10419 if prevPath == "" {
10420 prevPath = a.currentSessionPathFor(tab)
10421 }
10422 if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
10423 prevPath = a.reconciledSessionPathForTab(tab)
10424 if prevPath == "" {
10425 prevPath = a.currentSessionPathFor(tab)
10426 }
10427 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "token mode"); err != nil {
10428 return err
10429 }
10430 }
10431 modelRef, fallback, err := a.resolvedModelForTab(tab)
10432 if err != nil {
10433 return err
10434 }
10435 snap := a.tabRuntimeSnapshot(tab)
10436 runtime := snap.normalizedRuntime()
10437 runtime.tokenMode = mode
10438 if fallback && strings.TrimSpace(snap.model) != "" {
10439 a.noticeForTab(tab.ID, fmt.Sprintf("model %q is no longer available; switched to %s", snap.model, modelRef))
10440 }
10441
10442 var carried []provider.Message
10443 oldCtrl := a.controllerForTab(tab)
10444 if oldCtrl != nil {
10445 if prevPath == "" {
10446 prevPath = oldCtrl.SessionPath()
10447 }
10448 if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "token mode"); err != nil {
10449 return err
10450 }
10451 if err := a.snapshotTabForAction(tab, "changing token mode"); err != nil {
10452 return err
10453 }
10454 prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
10455 carried = oldCtrl.History()
10456 }
10457 sharedHost := a.lookupSharedHost(snap.sharedHostKey)
10458 newCtrl, err := boot.Build(a.bootContext(), boot.Options{
10459 Model: modelRef,
10460 RequireKey: false,
10461 AutoPricingCurrency: a.desktopAutoPricingCurrency(),
10462 StatsSource: "desktop",
10463 Sink: snap.sink,
10464 WorkspaceRoot: snap.workspaceRoot,
10465 SessionDir: sessionDirForSnapshot(snap),
10466 EffortOverride: cloneStringPtr(snap.effort),
10467 TokenMode: mode,
10468 SharedHost: sharedHost,
10469 CleanupPendingReconciler: reconcileDesktopCleanupPending,
10470 SubagentParentLive: a.subagentParentProbeForBuild(tab),
10471 SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
10472 OnSessionRecovered: a.handleTabSessionRecovered(tab),
10473 // Same logical session: keep the private temporary directory across
10474 // token-mode switches (Issue #7575).
10475 SessionTemp: sessionTempFromController(oldCtrl),
10476 })
10477 if err != nil {
10478 return err
10479 }
10480 a.bindControllerDisplayRecorder(newCtrl)
10481 configureControllerRuntime(newCtrl, oldCtrl, runtime)
10482 path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
10483 if err := a.ensureTabSessionLeaseForRebuild(tab, path, "token mode"); err != nil {
10484 newCtrl.Close()
10485 return err
10486 }
10487 restoredRuntime, err := resumeControllerRuntimeWithMessages(newCtrl, carried, path, runtime)
10488 if err != nil {
10489 newCtrl.Close()
10490 return err
10491 }
10492 a.mu.Lock()
10493 if current := a.tabs[tab.ID]; current != tab {
10494 a.mu.Unlock()
10495 newCtrl.Close()
10496 tab.releaseSessionLease()
10497 return fmt.Errorf("tab %q changed while switching token mode; retry", tab.ID)
10498 }
10499 tab.Ctrl = newCtrl
10500 tab.model = modelRef
10501 tab.Label = newCtrl.Label()
10502 applyNormalizedRuntimeToTabLocked(tab, restoredRuntime)
10503 clearTabStartupError(tab)
10504 tab.Ready = true
10505 a.supersedeTabBuildLocked(tab)
10506 a.saveTabsLocked()
10507 a.mu.Unlock()
10508 if oldCtrl != nil {
10509 oldCtrl.Close()
10510 }
10511 // The rebuilt runtime reflects the on-disk config; drop any deferred refresh.
10512 a.clearDeferredRebuild(tab.ID)
10513 a.persistTabSessionPath(tab, path)
10514 a.notifyTabRuntimeRebuilt(tab)
10515 return nil
10516 }
10517
10518 func (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {
10519 return a.applyConfigChange(func(cfg *config.Config) error {
10520 if _, ok := cfg.Provider(entry.Name); !ok {
10521 if err := cfg.UpsertProvider(*entry); err != nil {
10522 return err
10523 }
10524 }
10525 if entry.Kind == "anthropic" && effort != "" && entry.Thinking == "" {
10526 if err := cfg.SetProviderThinking(entry.Name, "adaptive"); err != nil {
10527 return err
10528 }
10529 }
10530 for _, name := range providerEffortTargetNames(cfg, entry) {
10531 if err := cfg.SetProviderEffort(name, effort); err != nil {
10532 return err
10533 }
10534 }
10535 return nil
10536 })
10537 }
10538
10539 func providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {
10540 if cfg == nil || entry == nil {
10541 return nil
10542 }
10543 out := []string{entry.Name}
10544 seen := map[string]bool{entry.Name: true}
10545 kind := officialProviderKindFromEntry(*entry)
10546 if kind == "" {
10547 return out
10548 }
10549 var family []string
10550 switch kind {
10551 case "deepseek":
10552 family = []string{"deepseek", "deepseek-flash", "deepseek-pro"}
10553 }
10554 for _, name := range family {
10555 if seen[name] {
10556 continue
10557 }
10558 p, ok := cfg.Provider(name)
10559 if !ok || officialProviderKindFromEntry(*p) != kind {
10560 continue
10561 }
10562 seen[name] = true
10563 out = append(out, name)
10564 }
10565 return out
10566 }
10567
10568 // DirEntry is one entry in the "@" file-reference menu.
10569 type DirEntry struct {
10570 Name string `json:"name"`
10571 Path string `json:"path,omitempty"`
10572 IsDir bool `json:"isDir"`
10573 DisplayName string `json:"displayName,omitempty"`
10574 DisplayPath string `json:"displayPath,omitempty"`
10575 }
10576
10577 // FilePreview is a bounded, read-only file payload for the workspace side panel.
10578 type FilePreview struct {
10579 Path string `json:"path"`
10580 Body string `json:"body"`
10581 Size int64 `json:"size"`
10582 Truncated bool `json:"truncated"`
10583 Binary bool `json:"binary"`
10584 Kind string `json:"kind,omitempty"`
10585 Mime string `json:"mime,omitempty"`
10586 URL string `json:"url,omitempty"`
10587 Err string `json:"err,omitempty"`
10588 }
10589
10590 type WorkspaceChangeView struct {
10591 Path string `json:"path"`
10592 OldPath string `json:"oldPath,omitempty"`
10593 Sources []string `json:"sources"`
10594 GitStatus string `json:"gitStatus,omitempty"`
10595 Turns []int `json:"turns,omitempty"`
10596 LatestPrompt string `json:"latestPrompt,omitempty"`
10597 LatestTime int64 `json:"latestTime,omitempty"`
10598 CanSessionRevert bool `json:"canSessionRevert,omitempty"`
10599 }
10600
10601 type WorkspaceChangesView struct {
10602 Files []WorkspaceChangeView `json:"files"`
10603 GitAvailable bool `json:"gitAvailable"`
10604 GitErr string `json:"gitErr,omitempty"`
10605 GitBranch string `json:"gitBranch,omitempty"`
10606 }
10607
10608 type WorkspaceChangeDetailView struct {
10609 Diff *string `json:"diff,omitempty"`
10610 Source string `json:"source,omitempty"`
10611 Added int `json:"added,omitempty"`
10612 Removed int `json:"removed,omitempty"`
10613 Binary bool `json:"binary,omitempty"`
10614 Truncated bool `json:"truncated,omitempty"`
10615 }
10616
10617 const filePreviewLimit = 2 * 1024 * 1024 // 2 MiB — full file preview for the workspace panel
10618 const fileRefSearchLimit = 20
10619
10620 var previewMediaMIMEs = map[string]string{
10621 ".bmp": "image/bmp",
10622 ".gif": "image/gif",
10623 ".jpeg": "image/jpeg",
10624 ".jpg": "image/jpeg",
10625 ".pdf": "application/pdf",
10626 ".png": "image/png",
10627 ".svg": "image/svg+xml",
10628 ".webp": "image/webp",
10629 }
10630
10631 func trimUTF8PartialSuffix(data []byte) []byte {
10632 if utf8.Valid(data) {
10633 return data
10634 }
10635 for i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {
10636 if !utf8.RuneStart(data[i]) {
10637 continue
10638 }
10639 if !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {
10640 return data
10641 }
10642 return data[:i]
10643 }
10644 return data
10645 }
10646
10647 func previewMediaKind(path string) (kind string, mime string) {
10648 mime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]
10649 if mime == "" {
10650 return "", ""
10651 }
10652 if strings.HasPrefix(mime, "image/") {
10653 return "image", mime
10654 }
10655 if mime == "application/pdf" {
10656 return "pdf", mime
10657 }
10658 return "", ""
10659 }
10660
10661 func workspaceEntryRel(rel, name string) string {
10662 rel = strings.Trim(filepath.ToSlash(rel), "/")
10663 if rel == "" || rel == "." {
10664 return name
10665 }
10666 return rel + "/" + name
10667 }
10668
10669 func skipWorkspaceEntry(rel, name string, isDir bool) bool {
10670 return fileref.SkipEntry(workspaceEntryRel(rel, name), name, isDir)
10671 }
10672
10673 func (a *App) activeWorkspaceBase() (string, error) {
10674 return workspaceBaseFromRoot(a.activeWorkspaceRoot())
10675 }
10676
10677 func (a *App) workspaceTargetForTab(tabID string) (string, control.SessionAPI, bool) {
10678 tabID = strings.TrimSpace(tabID)
10679 a.mu.RLock()
10680 defer a.mu.RUnlock()
10681 tab := a.tabByIDLocked(tabID)
10682 if tab == nil {
10683 if tabID == "" {
10684 return ".", nil, true
10685 }
10686 return "", nil, false
10687 }
10688 return tab.WorkspaceRoot, tab.Ctrl, true
10689 }
10690
10691 func workspaceBaseFromRoot(root string) (string, error) {
10692 if strings.TrimSpace(root) == "" || root == "." {
10693 return os.Getwd()
10694 }
10695 if abs, err := filepath.Abs(root); err == nil {
10696 root = abs
10697 }
10698 return filepath.Clean(root), nil
10699 }
10700
10701 func workspacePathForBase(base, rel string) (string, bool, error) {
10702 base = filepath.Clean(base)
10703 if rel == "" {
10704 return "", false, os.ErrInvalid
10705 }
10706 path := rel
10707 if !filepath.IsAbs(path) {
10708 path = filepath.Join(base, rel)
10709 }
10710 path = filepath.Clean(path)
10711 r, err := filepath.Rel(base, path)
10712 if err != nil {
10713 return "", false, err
10714 }
10715 if r == ".." || strings.HasPrefix(r, ".."+string(os.PathSeparator)) {
10716 return "", false, os.ErrPermission
10717 }
10718 return path, true, nil
10719 }
10720
10721 // ListDir lists one directory level (directories first, then files, each
10722 // alphabetical) for the "@" file-reference menu. rel resolves against the active
10723 // tab workspace. The menu navigates one level at a time, never recursively —
10724 // bounded for huge trees.
10725 func (a *App) ListDir(rel string) []DirEntry {
10726 return a.ListDirForTab("", rel)
10727 }
10728
10729 // ListDirForTab is the tab-scoped variant used by multi-tab frontend surfaces.
10730 func (a *App) ListDirForTab(tabID, rel string) []DirEntry {
10731 root, ctrl, ok := a.workspaceTargetForTab(tabID)
10732 if !ok {
10733 return []DirEntry{}
10734 }
10735 if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
10736 if entries, handled := browser.ListExternalFolderRefDir(rel); handled {
10737 return externalFolderDirEntries(entries)
10738 }
10739 }
10740 base, err := workspaceBaseFromRoot(root)
10741 if err != nil {
10742 return []DirEntry{}
10743 }
10744 dir := base
10745 if rel != "" {
10746 path, ok, err := workspacePathForBase(base, rel)
10747 if err != nil || !ok {
10748 return []DirEntry{}
10749 }
10750 dir = path
10751 }
10752 es, err := os.ReadDir(dir)
10753 if err != nil {
10754 return []DirEntry{}
10755 }
10756 dirs, files := []DirEntry{}, []DirEntry{}
10757 for _, e := range es {
10758 name := e.Name()
10759 if skipWorkspaceEntry(rel, name, e.IsDir()) {
10760 continue
10761 }
10762 if e.IsDir() {
10763 dirs = append(dirs, DirEntry{Name: name, IsDir: true})
10764 continue
10765 }
10766 info, err := e.Info()
10767 if err != nil || !info.Mode().IsRegular() {
10768 continue
10769 }
10770 files = append(files, DirEntry{Name: name, IsDir: false})
10771 }
10772 sort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) })
10773 sort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) })
10774 return append(dirs, files...)
10775 }
10776
10777 // SearchFileRefs finds workspace files by basename for bare "@token" completion.
10778 func (a *App) SearchFileRefs(query string) []DirEntry {
10779 return a.SearchFileRefsForTab("", query)
10780 }
10781
10782 // SearchFileRefsForTab is the tab-scoped variant used by multi-tab frontend surfaces.
10783 func (a *App) SearchFileRefsForTab(tabID, query string) []DirEntry {
10784 root, ctrl, ok := a.workspaceTargetForTab(tabID)
10785 if !ok {
10786 return []DirEntry{}
10787 }
10788 base, err := workspaceBaseFromRoot(root)
10789 if err != nil {
10790 return []DirEntry{}
10791 }
10792 results := fileref.Search(base, query, fileRefSearchLimit)
10793 out := make([]DirEntry, 0, len(results))
10794 for _, r := range results {
10795 out = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})
10796 }
10797 if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
10798 out = append(out, externalFolderDirEntries(browser.SearchExternalFolderRefs(query, fileRefSearchLimit))...)
10799 }
10800 return out
10801 }
10802
10803 type externalFolderRefBrowser interface {
10804 ListExternalFolderRefDir(tokenPath string) ([]control.ExternalFolderRefEntry, bool)
10805 SearchExternalFolderRefs(query string, limit int) []control.ExternalFolderRefEntry
10806 ExternalFolderRefLocalPath(tokenPath string) (path, displayPath string, ok bool)
10807 }
10808
10809 func externalFolderRefBrowserFromController(ctrl control.SessionAPI) externalFolderRefBrowser {
10810 if browser, ok := ctrl.(externalFolderRefBrowser); ok {
10811 return browser
10812 }
10813 return nil
10814 }
10815
10816 func externalFolderDirEntries(entries []control.ExternalFolderRefEntry) []DirEntry {
10817 out := make([]DirEntry, 0, len(entries))
10818 for _, e := range entries {
10819 out = append(out, DirEntry{
10820 Name: e.Name,
10821 Path: e.Path,
10822 IsDir: e.IsDir,
10823 DisplayName: e.DisplayName,
10824 DisplayPath: e.DisplayPath,
10825 })
10826 }
10827 return out
10828 }
10829
10830 func (a *App) workspaceOrExternalPathForTab(tabID, rel string) (string, bool, error) {
10831 root, ctrl, ok := a.workspaceTargetForTab(tabID)
10832 if !ok {
10833 return "", false, os.ErrNotExist
10834 }
10835 if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
10836 if path, _, ok := browser.ExternalFolderRefLocalPath(rel); ok {
10837 return path, true, nil
10838 }
10839 }
10840 base, err := workspaceBaseFromRoot(root)
10841 if err != nil {
10842 return "", false, err
10843 }
10844 return workspacePathForBase(base, rel)
10845 }
10846
10847 // ReadFile returns a small text preview for a file under the current workspace
10848 // or a session-authorized external folder ref.
10849 func (a *App) ReadFile(rel string) FilePreview {
10850 return a.ReadFileForTab("", rel)
10851 }
10852
10853 // ReadFileForTab returns a preview resolved against the requested tab.
10854 func (a *App) ReadFileForTab(tabID, rel string) FilePreview {
10855 out := FilePreview{Path: rel}
10856 path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
10857 if err != nil || !ok {
10858 out.Err = "invalid path"
10859 return out
10860 }
10861 info, err := os.Stat(path)
10862 if err != nil {
10863 out.Err = err.Error()
10864 return out
10865 }
10866 if info.IsDir() {
10867 out.Err = "path is a directory"
10868 return out
10869 }
10870 if !info.Mode().IsRegular() {
10871 out.Err = "path is not a regular file"
10872 return out
10873 }
10874 out.Size = info.Size()
10875 if kind, mime := previewMediaKind(path); kind != "" {
10876 token := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime())
10877 out.Kind = kind
10878 out.Mime = mime
10879 out.URL = "/__reasonix_workspace_media/" + token + "/" + url.PathEscape(info.Name())
10880 return out
10881 }
10882 f, err := os.Open(path)
10883 if err != nil {
10884 out.Err = err.Error()
10885 return out
10886 }
10887 defer f.Close()
10888
10889 buf := make([]byte, filePreviewLimit+1)
10890 n, err := f.Read(buf)
10891 if err != nil && err != io.EOF {
10892 out.Err = err.Error()
10893 return out
10894 }
10895 data := buf[:n]
10896 if len(data) > filePreviewLimit {
10897 data = data[:filePreviewLimit]
10898 out.Truncated = true
10899 }
10900
10901 // Check for BOM first (just the first 2-3 bytes — always complete
10902 // even at a truncation boundary). BOM-prefixed files skip the NUL
10903 // check since UTF-16 normally contains 0x00 for ASCII characters.
10904 bomKind := fileenc.DetectQuick(data)
10905 if bomKind != fileenc.UTF8 {
10906 enc, _ := fileenc.Detect(data)
10907 if enc == fileenc.LossyUTF8 {
10908 out.Binary = true
10909 return out
10910 }
10911 decoded := fileenc.Decode(data, enc)
10912 out.Body = string(decoded)
10913 return out
10914 }
10915
10916 // No BOM — NUL in raw bytes is a binary signal.
10917 if bytes.Contains(data, []byte{0}) {
10918 out.Binary = true
10919 return out
10920 }
10921
10922 // Trim any partial multi-byte rune at the truncation boundary BEFORE
10923 // encoding detection. Without this, a large UTF-8 file truncated
10924 // mid-character would fail utf8.Valid and be misdetected as GB18030
10925 // or LossyUTF8, producing mojibake or a false binary classification.
10926 if out.Truncated {
10927 data = trimUTF8PartialSuffix(data)
10928 }
10929 enc, _ := fileenc.Detect(data)
10930 if enc == fileenc.LossyUTF8 {
10931 out.Binary = true
10932 return out
10933 }
10934 out.Body = string(fileenc.Decode(data, enc))
10935 return out
10936 }
10937
10938 // OpenWorkspacePath opens a workspace or authorized external-ref file/folder in
10939 // the OS default app.
10940 func (a *App) OpenWorkspacePath(rel string) error {
10941 return a.OpenWorkspacePathForTab("", rel)
10942 }
10943
10944 // OpenWorkspacePathForTab opens a path resolved against the requested tab.
10945 func (a *App) OpenWorkspacePathForTab(tabID, rel string) error {
10946 path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
10947 if err != nil || !ok {
10948 return os.ErrInvalid
10949 }
10950 return openWorkspacePath(path)
10951 }
10952
10953 // RevealWorkspacePath shows a workspace or authorized external-ref file in the
10954 // native file manager.
10955 func (a *App) RevealWorkspacePath(rel string) error {
10956 return a.RevealWorkspacePathForTab("", rel)
10957 }
10958
10959 // RevealWorkspacePathForTab reveals a path resolved against the requested tab.
10960 func (a *App) RevealWorkspacePathForTab(tabID, rel string) error {
10961 path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
10962 if err != nil || !ok {
10963 return os.ErrInvalid
10964 }
10965 return revealPath(path)
10966 }
10967
10968 // RevealPath shows an arbitrary absolute path in the native file manager.
10969 func (a *App) RevealPath(path string) error {
10970 path = strings.TrimSpace(path)
10971 if path == "" {
10972 return os.ErrInvalid
10973 }
10974 if abs, err := filepath.Abs(path); err == nil {
10975 path = abs
10976 }
10977 return revealPath(path)
10978 }
10979
10980 var revealPath = defaultRevealPath
10981
10982 func defaultRevealPath(path string) error {
10983 switch goruntime.GOOS {
10984 case "darwin":
10985 return exec.Command("open", "-R", path).Start()
10986 case "windows":
10987 // explorer.exe lives in %SystemRoot%, which isn't always on PATH (the
10988 // launch environment can strip it), so resolve it directly rather than
10989 // relying on a PATH lookup.
10990 explorer := "explorer.exe"
10991 root := os.Getenv("SystemRoot")
10992 if root == "" {
10993 root = os.Getenv("windir")
10994 }
10995 if root != "" {
10996 explorer = filepath.Join(root, "explorer.exe")
10997 }
10998 return exec.Command(explorer, "/select,", path).Start()
10999 default:
11000 dir := path
11001 if info, err := os.Stat(path); err == nil && !info.IsDir() {
11002 dir = filepath.Dir(path)
11003 }
11004 return exec.Command("xdg-open", dir).Start()
11005 }
11006 }
11007
11008 func (a *App) noticeForTab(tabID, text string) {
11009 tab := a.tabByID(tabID)
11010 if tab != nil && tab.sink != nil {
11011 tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})
11012 }
11013 }
11014
11015 func (a *App) warnForTab(tabID, text string) {
11016 tab := a.tabByID(tabID)
11017 if tab != nil && tab.sink != nil {
11018 tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text})
11019 }
11020 }
11021
11022 func (a *App) runEffortCommandForTab(tabID, input string) {
11023 entry, err := a.currentProviderEntryForTab(tabID)
11024 if err != nil {
11025 a.noticeForTab(tabID, "effort: "+err.Error())
11026 return
11027 }
11028 cap := config.EffortCapabilityForEntry(entry)
11029 if !cap.Supported {
11030 a.noticeForTab(tabID, fmt.Sprintf("effort is not configurable for %s", entry.Name))
11031 return
11032 }
11033 args := strings.Fields(input)
11034 if len(args) < 2 {
11035 a.noticeForTab(tabID, fmt.Sprintf("effort for %s: %s (default: %s; options: %s)", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, "|")))
11036 return
11037 }
11038 if len(args) > 2 {
11039 a.noticeForTab(tabID, "usage: /effort "+strings.Join(cap.Levels, "|"))
11040 return
11041 }
11042 effort, err := config.NormalizeEffort(entry, args[1])
11043 if err != nil {
11044 a.noticeForTab(tabID, err.Error())
11045 return
11046 }
11047 if err := a.SetEffortForTab(tabID, args[1]); err != nil {
11048 a.noticeForTab(tabID, "effort: "+err.Error())
11049 return
11050 }
11051 display := effort
11052 if display == "" {
11053 display = "auto"
11054 }
11055 a.noticeForTab(tabID, fmt.Sprintf("effort for %s set to %s", entry.Name, display))
11056 }
11057
11058 func (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {
11059 if tab := a.tabByID(tabID); tab != nil {
11060 a.reconcileTabWithPinnedSessionMeta(tab)
11061 }
11062 a.mu.RLock()
11063 ref := ""
11064 workspaceRoot := ""
11065 effortOverride := (*string)(nil)
11066 if tab := a.tabByIDLocked(tabID); tab != nil {
11067 ref = tab.model
11068 workspaceRoot = tab.WorkspaceRoot
11069 effortOverride = cloneStringPtr(tab.effort)
11070 }
11071 a.mu.RUnlock()
11072 cfg, err := config.LoadForRoot(workspaceRoot)
11073 if err != nil {
11074 return nil, err
11075 }
11076 if strings.TrimSpace(ref) == "" {
11077 ref = cfg.DefaultModel
11078 }
11079 config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)
11080 resolved, _, ok := cfg.ResolveModelWithFallback(ref)
11081 if !ok {
11082 return nil, fmt.Errorf("unknown model %q", ref)
11083 }
11084 entry, ok := cfg.ResolveModel(resolved)
11085 if !ok {
11086 return nil, fmt.Errorf("unknown model %q", resolved)
11087 }
11088 if effortOverride != nil {
11089 entry.Effort = *effortOverride
11090 }
11091 return entry, nil
11092 }
11093
11094 func (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) {
11095 if tab == nil {
11096 return "", false, fmt.Errorf("no active tab")
11097 }
11098 cfg, err := config.LoadForRoot(tab.WorkspaceRoot)
11099 if err != nil {
11100 return "", false, err
11101 }
11102 ref := strings.TrimSpace(tab.model)
11103 if ref == "" {
11104 ref = cfg.DefaultModel
11105 }
11106 config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)
11107 resolved, fallback, ok := cfg.ResolveModelWithFallback(ref)
11108 if !ok {
11109 return "", false, fmt.Errorf("unknown model %q", ref)
11110 }
11111 return resolved, fallback, nil
11112 }
11113
11114 func (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) {
11115 var result string
11116 err := a.withActiveWorkspaceDo(func() error {
11117 var err error
11118 result, err = fn()
11119 return err
11120 })
11121 return result, err
11122 }
11123
11124 func (a *App) withActiveWorkspaceDo(fn func() error) error {
11125 root := a.activeWorkspaceRoot()
11126 if root != "" && root != "." {
11127 prev, err := os.Getwd()
11128 if err != nil {
11129 return err
11130 }
11131 if err := os.Chdir(root); err != nil {
11132 return err
11133 }
11134 defer func() { _ = os.Chdir(prev) }()
11135 }
11136 return fn()
11137 }
11138
11139 // SavePastedImage stores a browser clipboard image data URL under the active
11140 // tab's workspace .reasonix/attachments and returns the relative @-reference path.
11141 func (a *App) SavePastedImage(dataURL string) (string, error) {
11142 return a.withActiveWorkspace(func() (string, error) {
11143 return control.SaveImageDataURL(dataURL)
11144 })
11145 }
11146
11147 // SaveClipboardImage reads the native OS clipboard image under the active tab's
11148 // workspace .reasonix/attachments and returns the relative @-reference path.
11149 func (a *App) SaveClipboardImage() (string, error) {
11150 return a.withActiveWorkspace(control.SaveClipboardImage)
11151 }
11152
11153 // SavePastedFile stores a dropped non-image file (the browser exposes its bytes
11154 // as a data URL but not a real path) under the active tab's workspace
11155 // .reasonix/attachments and returns the relative @-reference path.
11156 func (a *App) SavePastedFile(name, dataURL string) (string, error) {
11157 return a.withActiveWorkspace(func() (string, error) {
11158 return control.SaveAttachmentDataURL(name, dataURL)
11159 })
11160 }
11161
11162 // PickExportFile opens the native save dialog and returns the selected path. It
11163 // returns "" when the user cancels.
11164 func (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {
11165 if a.ctx == nil {
11166 return "", nil
11167 }
11168 defaultFilename = safeExportFilename(defaultFilename)
11169 ext := strings.ToLower(filepath.Ext(defaultFilename))
11170 path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
11171 Title: "Export session",
11172 DefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),
11173 DefaultFilename: defaultFilename,
11174 CanCreateDirectories: true,
11175 Filters: exportFileFilters(mimeType, ext),
11176 })
11177 if err != nil || path == "" {
11178 return "", err
11179 }
11180 if ext != "" && filepath.Ext(path) == "" {
11181 path += ext
11182 }
11183 return path, nil
11184 }
11185
11186 // SaveExportFile writes an exported session payload to a path previously picked
11187 // by PickExportFile. An empty path is treated as a cancelled export.
11188 func (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {
11189 if strings.TrimSpace(path) == "" {
11190 return nil
11191 }
11192 var data []byte
11193 var err error
11194 if base64Encoded {
11195 data, err = base64.StdEncoding.DecodeString(payload)
11196 if err != nil {
11197 return fmt.Errorf("decode export payload: %w", err)
11198 }
11199 } else {
11200 data = []byte(payload)
11201 }
11202 if err := os.WriteFile(path, data, 0o644); err != nil {
11203 return exportOperationError("save export file", path, err)
11204 }
11205 return nil
11206 }
11207
11208 // SaveExportImageFiles writes one or more base64-encoded image parts. A single
11209 // image keeps the native save dialog's normal overwrite semantics. Multi-part
11210 // exports use numbered sibling paths and never overwrite an existing sibling;
11211 // every payload is staged before any target is committed, and a failed commit
11212 // removes only files created by this call.
11213 func (a *App) SaveExportImageFiles(path string, payloads []string) error {
11214 if strings.TrimSpace(path) == "" {
11215 return nil
11216 }
11217 if len(payloads) == 0 {
11218 return errors.New("no image payloads to export")
11219 }
11220 if len(payloads) == 1 {
11221 return a.SaveExportFile(path, payloads[0], true)
11222 }
11223
11224 targets := make([]string, len(payloads))
11225 for i := range payloads {
11226 targets[i] = numberedExportPath(path, i, len(payloads))
11227 }
11228
11229 return saveExclusiveExportPayloads(targets, len(payloads), func(index int) ([]byte, error) {
11230 decoded, err := base64.StdEncoding.DecodeString(payloads[index])
11231 if err != nil {
11232 return nil, fmt.Errorf("decode export image part %d: %w", index+1, err)
11233 }
11234 return decoded, nil
11235 })
11236 }
11237
11238 type stagedExportFile struct {
11239 targetPath string
11240 tempPath string
11241 }
11242
11243 type committedExportFile struct {
11244 path string
11245 info os.FileInfo
11246 }
11247
11248 const exportTempCreateAttempts = 100
11249
11250 func numberedExportPath(path string, partIndex, partCount int) string {
11251 if partCount <= 1 {
11252 return path
11253 }
11254 ext := filepath.Ext(path)
11255 stem := strings.TrimSuffix(path, ext)
11256 return fmt.Sprintf("%s-%d-of-%d%s", stem, partIndex+1, partCount, ext)
11257 }
11258
11259 func saveExclusiveExportFiles(targets []string, payloads [][]byte) error {
11260 return saveExclusiveExportPayloads(targets, len(payloads), func(index int) ([]byte, error) {
11261 return payloads[index], nil
11262 })
11263 }
11264
11265 func saveExclusiveExportPayloads(targets []string, payloadCount int, payloadAt func(int) ([]byte, error)) error {
11266 if len(targets) == 0 || len(targets) != payloadCount || payloadAt == nil {
11267 return errors.New("invalid export image batch")
11268 }
11269 for _, target := range targets {
11270 if _, err := os.Lstat(target); err == nil {
11271 return fmt.Errorf("export file already exists: %s", filepath.Base(target))
11272 } else if !errors.Is(err, os.ErrNotExist) {
11273 return exportOperationError("inspect export target", target, err)
11274 }
11275 }
11276
11277 staged := make([]stagedExportFile, 0, len(targets))
11278 defer func() {
11279 for _, file := range staged {
11280 _ = os.Remove(file.tempPath)
11281 }
11282 }()
11283 for i, target := range targets {
11284 payload, err := payloadAt(i)
11285 if err != nil {
11286 return err
11287 }
11288 file, finalMode, err := createExportTempFile(filepath.Dir(target))
11289 if err != nil {
11290 return exportOperationError("stage export file", target, err)
11291 }
11292 tempPath := file.Name()
11293 staged = append(staged, stagedExportFile{targetPath: target, tempPath: tempPath})
11294 if _, err = file.Write(payload); err == nil {
11295 err = file.Sync()
11296 }
11297 // Keep staged payloads private while they are incomplete, then restore
11298 // the same umask-adjusted mode used by SaveExportFile before publishing.
11299 if err == nil {
11300 err = file.Chmod(finalMode)
11301 }
11302 if err == nil {
11303 err = file.Sync()
11304 }
11305 if closeErr := file.Close(); err == nil {
11306 err = closeErr
11307 }
11308 if err != nil {
11309 return exportOperationError("stage export file", target, err)
11310 }
11311 }
11312
11313 committed := make([]committedExportFile, 0, len(staged))
11314 for _, file := range staged {
11315 info, err := commitStagedExportFile(file.tempPath, file.targetPath)
11316 if err != nil {
11317 rollbackCommittedExportFiles(committed)
11318 return exportOperationError("save export file", file.targetPath, err)
11319 }
11320 committed = append(committed, committedExportFile{path: file.targetPath, info: info})
11321 }
11322 return nil
11323 }
11324
11325 // createExportTempFile reserves a cryptographically random sibling path with
11326 // the same requested mode as a normal export. It immediately narrows the mode
11327 // while bytes are staged; the caller restores finalMode only after the payload
11328 // has been completely written and synced.
11329 func createExportTempFile(dir string) (*os.File, os.FileMode, error) {
11330 for attempt := 0; attempt < exportTempCreateAttempts; attempt++ {
11331 var suffix [12]byte
11332 if _, err := rand.Read(suffix[:]); err != nil {
11333 return nil, 0, fmt.Errorf("generate export temp name: %w", err)
11334 }
11335 path := filepath.Join(dir, ".reasonix-export-"+hex.EncodeToString(suffix[:]))
11336 file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
11337 if errors.Is(err, os.ErrExist) {
11338 continue
11339 }
11340 if err != nil {
11341 return nil, 0, err
11342 }
11343 info, err := file.Stat()
11344 if err == nil {
11345 err = file.Chmod(0o600)
11346 }
11347 if err != nil {
11348 _ = file.Close()
11349 _ = os.Remove(path)
11350 return nil, 0, err
11351 }
11352 return file, info.Mode().Perm(), nil
11353 }
11354 return nil, 0, errors.New("could not reserve a unique export temp file")
11355 }
11356
11357 func commitStagedExportFile(tempPath, targetPath string) (os.FileInfo, error) {
11358 stagedInfo, err := os.Lstat(tempPath)
11359 if err != nil {
11360 return nil, err
11361 }
11362 // A hard link publishes a fully written staged file atomically and fails if
11363 // the target already exists. Some filesystems do not support hard links, so
11364 // fall back to an exclusive create while preserving the no-overwrite rule.
11365 if err := os.Link(tempPath, targetPath); err == nil {
11366 current, statErr := os.Lstat(targetPath)
11367 if statErr != nil {
11368 removeExportFileIfSame(targetPath, stagedInfo)
11369 return nil, statErr
11370 }
11371 if !os.SameFile(current, stagedInfo) {
11372 return nil, errors.New("export target changed while it was being saved")
11373 }
11374 return stagedInfo, nil
11375 }
11376
11377 source, err := os.Open(tempPath)
11378 if err != nil {
11379 return nil, err
11380 }
11381 defer source.Close()
11382 target, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
11383 if err != nil {
11384 return nil, err
11385 }
11386 info, statErr := target.Stat()
11387 if statErr == nil {
11388 _, err = io.Copy(target, source)
11389 }
11390 if err == nil && statErr == nil {
11391 err = target.Sync()
11392 }
11393 if closeErr := target.Close(); err == nil && statErr == nil {
11394 err = closeErr
11395 }
11396 if statErr != nil {
11397 err = statErr
11398 }
11399 if err != nil {
11400 removeExportFileIfSame(targetPath, info)
11401 return nil, err
11402 }
11403 return info, nil
11404 }
11405
11406 func rollbackCommittedExportFiles(files []committedExportFile) {
11407 for _, file := range files {
11408 removeExportFileIfSame(file.path, file.info)
11409 }
11410 }
11411
11412 func removeExportFileIfSame(path string, created os.FileInfo) {
11413 if created == nil {
11414 return
11415 }
11416 current, err := os.Lstat(path)
11417 if err == nil && os.SameFile(current, created) {
11418 _ = os.Remove(path)
11419 }
11420 }
11421
11422 func exportOperationError(operation, path string, err error) error {
11423 var pathErr *os.PathError
11424 if errors.As(err, &pathErr) {
11425 return fmt.Errorf("%s %s: %v", operation, filepath.Base(path), pathErr.Err)
11426 }
11427 return fmt.Errorf("%s %s: %w", operation, filepath.Base(path), err)
11428 }
11429
11430 func safeExportFilename(name string) string {
11431 name = strings.TrimSpace(name)
11432 if name == "" {
11433 return "reasonix-session.md"
11434 }
11435 return filepath.Base(name)
11436 }
11437
11438 func exportFileFilters(mimeType, ext string) []runtime.FileFilter {
11439 switch mimeType {
11440 case "text/markdown":
11441 return []runtime.FileFilter{{DisplayName: "Markdown (*.md)", Pattern: "*.md"}}
11442 case "application/json":
11443 return []runtime.FileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}}
11444 case "application/pdf":
11445 return []runtime.FileFilter{{DisplayName: "PDF (*.pdf)", Pattern: "*.pdf"}}
11446 case "image/png":
11447 return []runtime.FileFilter{{DisplayName: "PNG image (*.png)", Pattern: "*.png"}}
11448 }
11449 if ext != "" {
11450 return []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, ".")) + " files (*" + ext + ")", Pattern: "*" + ext}}
11451 }
11452 return []runtime.FileFilter{{DisplayName: "All files (*.*)", Pattern: "*.*"}}
11453 }
11454
11455 // AttachmentDataURL returns a safe data URL for a stored image attachment.
11456 func (a *App) AttachmentDataURL(path string) (string, error) {
11457 return a.withActiveWorkspace(func() (string, error) {
11458 return control.ImageDataURL(path)
11459 })
11460 }
11461
11462 // DroppedItem is one OS-dropped file resolved into a composer context entry: an
11463 // in-tree file becomes a workspace @reference (read in place, no copy), while an
11464 // outside directory becomes a session-scoped workspace @reference; an image or
11465 // out-of-tree file is copied into .reasonix/attachments.
11466 type DroppedItem struct {
11467 Kind string `json:"kind"` // "workspace" | "attachment"
11468 Path string `json:"path"`
11469 IsDir bool `json:"isDir,omitempty"`
11470 DisplayPath string `json:"displayPath,omitempty"`
11471 PreviewURL string `json:"previewUrl,omitempty"`
11472 }
11473
11474 // AttachDropped turns an absolute path from the native file-drop bridge into a
11475 // composer context entry. Images are stored as attachments so the chip shows a
11476 // thumbnail; in-workspace files are referenced relatively (no copy); directories
11477 // outside the workspace are registered as current-session folder references;
11478 // files outside the workspace are copied into .reasonix/attachments.
11479 func (a *App) AttachDropped(path string) (DroppedItem, error) {
11480 var item DroppedItem
11481 err := a.withActiveWorkspaceDo(func() error {
11482 info, err := os.Lstat(path)
11483 if err != nil {
11484 return err
11485 }
11486 if isImageExt(path) {
11487 if rel, err := control.SaveImageFile(path); err == nil {
11488 preview, _ := control.ImageDataURL(rel)
11489 item = DroppedItem{Kind: "attachment", Path: rel, PreviewURL: preview}
11490 return nil
11491 }
11492 }
11493 if rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok {
11494 item = DroppedItem{Kind: "workspace", Path: rel, IsDir: info.IsDir()}
11495 return nil
11496 }
11497 if info.IsDir() {
11498 tab, ctrl := a.tabAndCtrlByID("")
11499 if err := a.ensureTabControllerWorkspace(tab); err != nil {
11500 return err
11501 }
11502 if tab != nil {
11503 ctrl = a.controllerForTab(tab)
11504 }
11505 if ctrl == nil {
11506 return fmt.Errorf("workspace is not ready")
11507 }
11508 token, displayPath, err := ctrl.RegisterExternalFolderRef(path)
11509 if err != nil {
11510 return err
11511 }
11512 item = DroppedItem{Kind: "workspace", Path: token, IsDir: true, DisplayPath: displayPath}
11513 return nil
11514 }
11515 rel, err := control.SaveAttachmentFile(path)
11516 if err != nil {
11517 return err
11518 }
11519 item = DroppedItem{Kind: "attachment", Path: rel}
11520 return nil
11521 })
11522 if err != nil {
11523 return DroppedItem{}, err
11524 }
11525 return item, nil
11526 }
11527
11528 func isImageExt(path string) bool {
11529 switch strings.ToLower(filepath.Ext(path)) {
11530 case ".png", ".jpg", ".jpeg", ".gif", ".webp":
11531 return true
11532 }
11533 return false
11534 }
11535
11536 func workspaceRelativeIn(path, workspaceRoot string) (string, bool) {
11537 root := workspaceRoot
11538 if !filepath.IsAbs(root) {
11539 abs, err := filepath.Abs(root)
11540 if err != nil {
11541 return "", false
11542 }
11543 root = abs
11544 }
11545 rel, err := filepath.Rel(root, path)
11546 if err != nil {
11547 return "", false
11548 }
11549 if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
11550 return "", false
11551 }
11552 return filepath.ToSlash(rel), true
11553 }
11554
11555 // --- memory panel (frontend ⇄ controller) ---
11556
11557 type MemoryImport struct {
11558 Path string `json:"path"`
11559 SourcePath string `json:"sourcePath"`
11560 }
11561
11562 // MemoryDoc is one resolved instruction file with applicability metadata.
11563 type MemoryDoc struct {
11564 Path string `json:"path"`
11565 Scope string `json:"scope"`
11566 Directory string `json:"directory,omitempty"`
11567 Body string `json:"body"`
11568 Imports []MemoryImport `json:"imports"`
11569 Depth int `json:"depth"`
11570 Order int `json:"order"`
11571 Precedence int `json:"precedence"`
11572 }
11573
11574 type InstructionDiagnostic struct {
11575 Code string `json:"code"`
11576 Path string `json:"path"`
11577 SourcePath string `json:"sourcePath,omitempty"`
11578 Line int `json:"line,omitempty"`
11579 Message string `json:"message"`
11580 }
11581
11582 // MemoryFact is one saved auto-memory, surfaced read-only in the panel.
11583 type MemoryFact struct {
11584 ID string `json:"id,omitempty"`
11585 Revision int `json:"revision,omitempty"`
11586 CreatedAt string `json:"createdAt,omitempty"`
11587 UpdatedAt string `json:"updatedAt,omitempty"`
11588 Name string `json:"name"`
11589 Title string `json:"title,omitempty"`
11590 Description string `json:"description"`
11591 Type string `json:"type"`
11592 Scope string `json:"scope"`
11593 Body string `json:"body"`
11594 Freshness string `json:"freshness"`
11595 }
11596
11597 type MemoryConflict struct {
11598 Key string `json:"key"`
11599 ProjectID string `json:"projectId"`
11600 ProjectName string `json:"projectName"`
11601 GlobalID string `json:"globalId"`
11602 GlobalName string `json:"globalName"`
11603 Resolution string `json:"resolution"`
11604 }
11605
11606 type MemoryRecallHit struct {
11607 ID string `json:"id"`
11608 Revision int `json:"revision"`
11609 Name string `json:"name"`
11610 Title string `json:"title,omitempty"`
11611 Type string `json:"type"`
11612 Scope string `json:"scope"`
11613 Score float64 `json:"score"`
11614 Freshness string `json:"freshness"`
11615 Reason string `json:"reason"`
11616 Snippet string `json:"snippet"`
11617 }
11618
11619 type MemoryRecallTrace struct {
11620 Query string `json:"query"`
11621 Hits []MemoryRecallHit `json:"hits"`
11622 Omitted int `json:"omitted"`
11623 CharBudget int `json:"charBudget"`
11624 UsedChars int `json:"usedChars"`
11625 Suppressed string `json:"suppressed,omitempty"`
11626 }
11627
11628 // MemoryArchive is one archived auto-memory kept only for inspection.
11629 type MemoryArchive struct {
11630 ID string `json:"id,omitempty"`
11631 Revision int `json:"revision,omitempty"`
11632 CreatedAt string `json:"createdAt,omitempty"`
11633 UpdatedAt string `json:"updatedAt,omitempty"`
11634 Name string `json:"name"`
11635 Title string `json:"title,omitempty"`
11636 Description string `json:"description"`
11637 Type string `json:"type"`
11638 Scope string `json:"scope"`
11639 Body string `json:"body"`
11640 Freshness string `json:"freshness"`
11641 Path string `json:"path"`
11642 ArchivedAt string `json:"archivedAt,omitempty"`
11643 }
11644
11645 // MemoryScope is one writable quick-add target (scope id + the file it writes to).
11646 type MemoryScope struct {
11647 Scope string `json:"scope"`
11648 Path string `json:"path"`
11649 }
11650
11651 // MemoryView is the whole memory panel payload: hierarchical docs, active saved
11652 // facts, archived facts, and the writable scopes for the quick-add selector.
11653 type MemoryView struct {
11654 Docs []MemoryDoc `json:"docs"`
11655 Facts []MemoryFact `json:"facts"`
11656 Archives []MemoryArchive `json:"archives"`
11657 Scopes []MemoryScope `json:"scopes"`
11658 InstructionDiagnostics []InstructionDiagnostic `json:"instructionDiagnostics"`
11659 Conflicts []MemoryConflict `json:"conflicts"`
11660 LastRecall MemoryRecallTrace `json:"lastRecall"`
11661 StoreDir string `json:"storeDir"`
11662 StoreGlobalDir string `json:"storeGlobalDir,omitempty"`
11663 Available bool `json:"available"`
11664 }
11665
11666 // writableScopes are the quick-add targets the panel offers, broad → specific.
11667 var writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}
11668
11669 // Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,
11670 // active/archived auto-memories, and the writable scopes. Read-only; mutations
11671 // go through Remember / SaveDoc.
11672 func (a *App) Memory() MemoryView {
11673 return a.memoryForCtrl(nil, true)
11674 }
11675
11676 // MemoryForTab returns the loaded memory for a specific tab's controller,
11677 // so the panel can show memory for any open project, not just the active tab.
11678 // If the tab does not exist or has no controller, returns an empty view
11679 // instead of falling back to the active tab (which would show the wrong data).
11680 // An empty tabID is treated as "no tab specified" and falls back to the
11681 // active tab for backward compatibility.
11682 func (a *App) MemoryForTab(tabID string) MemoryView {
11683 if tabID == "" {
11684 return a.memoryForCtrl(nil, true)
11685 }
11686 return a.memoryForCtrl(a.ctrlByTabID(tabID), false)
11687 }
11688
11689 func (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {
11690 view := emptyMemoryView()
11691 if ctrl == nil {
11692 if !fallback {
11693 return view
11694 }
11695 a.mu.RLock()
11696 ctrl = a.activeCtrlLocked()
11697 a.mu.RUnlock()
11698 if ctrl == nil {
11699 return view
11700 }
11701 }
11702 set := ctrl.Memory()
11703 if set == nil {
11704 return view
11705 }
11706 view.StoreDir = set.Store.Dir
11707 view.StoreGlobalDir = set.Store.GlobalDir
11708 view.Available = true
11709 for _, d := range set.Docs {
11710 imports := make([]MemoryImport, 0, len(d.Imports))
11711 for _, imported := range d.Imports {
11712 imports = append(imports, MemoryImport{Path: imported.Path, SourcePath: imported.SourcePath})
11713 }
11714 view.Docs = append(view.Docs, MemoryDoc{
11715 Path: d.Path, Scope: string(d.Scope), Directory: d.Directory, Body: d.Body,
11716 Imports: imports, Depth: d.Depth, Order: d.Order, Precedence: d.Order,
11717 })
11718 }
11719 for _, diagnostic := range set.InstructionDiagnostics {
11720 view.InstructionDiagnostics = append(view.InstructionDiagnostics, InstructionDiagnostic{
11721 Code: diagnostic.Code, Path: diagnostic.Path, SourcePath: diagnostic.SourcePath,
11722 Line: diagnostic.Line, Message: diagnostic.Message,
11723 })
11724 }
11725 allFacts := set.Store.ListAll()
11726 for _, f := range allFacts {
11727 view.Facts = append(view.Facts, memoryFactView(f))
11728 }
11729 for _, conflict := range memory.FindOverrides(allFacts) {
11730 view.Conflicts = append(view.Conflicts, MemoryConflict{
11731 Key: conflict.Key, ProjectID: conflict.Project.ID, ProjectName: conflict.Project.Name,
11732 GlobalID: conflict.Global.ID, GlobalName: conflict.Global.Name, Resolution: "project_over_global",
11733 })
11734 }
11735 view.LastRecall = memoryRecallTraceView(ctrl.LastMemoryRecall())
11736 for _, f := range set.Store.ListArchived() {
11737 archivedAt := ""
11738 if !f.ArchivedAt.IsZero() {
11739 archivedAt = f.ArchivedAt.Format(time.RFC3339)
11740 }
11741 view.Archives = append(view.Archives, MemoryArchive{
11742 ID: f.ID, Revision: f.Revision, CreatedAt: formatMemoryTime(f.CreatedAt), UpdatedAt: formatMemoryTime(f.UpdatedAt),
11743 Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Scope: string(f.Scope), Body: f.Body,
11744 Freshness: memory.FreshnessFor(f.Memory, time.Now().UTC()), Path: f.Path, ArchivedAt: archivedAt,
11745 })
11746 }
11747 for _, sc := range writableScopes {
11748 if p := set.DocPath(sc); p != "" {
11749 view.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})
11750 }
11751 }
11752 return view
11753 }
11754
11755 func formatMemoryTime(value time.Time) string {
11756 if value.IsZero() {
11757 return ""
11758 }
11759 return value.UTC().Format(time.RFC3339Nano)
11760 }
11761
11762 func emptyMemoryView() MemoryView {
11763 return MemoryView{
11764 Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{},
11765 InstructionDiagnostics: []InstructionDiagnostic{}, Conflicts: []MemoryConflict{},
11766 LastRecall: MemoryRecallTrace{Hits: []MemoryRecallHit{}},
11767 }
11768 }
11769
11770 // Remember quick-adds a one-line note to the doc-memory file for scope — the
11771 // panel's explicit "remember" action, equivalent to typing "/remember <note>".
11772 // An unknown scope falls back to project. Returns the file written.
11773 func (a *App) Remember(scope, note string) (string, error) {
11774 return a.rememberForCtrl(nil, scope, note, true)
11775 }
11776
11777 func (a *App) RememberForTab(tabID, scope, note string) (string, error) {
11778 if tabID == "" {
11779 return a.rememberForCtrl(nil, scope, note, true)
11780 }
11781 return a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)
11782 }
11783
11784 func (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {
11785 if ctrl == nil {
11786 if !fallback {
11787 return "", nil
11788 }
11789 a.mu.RLock()
11790 ctrl = a.activeCtrlLocked()
11791 a.mu.RUnlock()
11792 if ctrl == nil {
11793 return "", nil
11794 }
11795 }
11796 return ctrl.QuickAdd(parseScope(scope), note)
11797 }
11798
11799 // Forget deletes a saved auto-memory by name — the panel's delete action for a
11800 // fact the model owns. A no-op when no controller is attached.
11801 func (a *App) Forget(name string) error {
11802 return a.forgetForCtrl(nil, name, true)
11803 }
11804
11805 func (a *App) ForgetForTab(tabID, name string) error {
11806 if tabID == "" {
11807 return a.forgetForCtrl(nil, name, true)
11808 }
11809 return a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)
11810 }
11811
11812 func (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {
11813 if ctrl == nil {
11814 if !fallback {
11815 return nil
11816 }
11817 a.mu.RLock()
11818 ctrl = a.activeCtrlLocked()
11819 a.mu.RUnlock()
11820 if ctrl == nil {
11821 return nil
11822 }
11823 }
11824 return ctrl.ForgetMemory(name)
11825 }
11826
11827 // RestoreArchivedMemory recovers one archived fact without replacing active
11828 // memory. The store preserves its identity and creates a new audited revision.
11829 func (a *App) RestoreArchivedMemory(archivePath string) (MemoryFact, error) {
11830 return a.restoreArchivedMemoryForCtrl(nil, archivePath, true)
11831 }
11832
11833 func (a *App) RestoreArchivedMemoryForTab(tabID, archivePath string) (MemoryFact, error) {
11834 if tabID == "" {
11835 return a.restoreArchivedMemoryForCtrl(nil, archivePath, true)
11836 }
11837 return a.restoreArchivedMemoryForCtrl(a.ctrlByTabID(tabID), archivePath, false)
11838 }
11839
11840 func (a *App) restoreArchivedMemoryForCtrl(ctrl control.SessionAPI, archivePath string, fallback bool) (MemoryFact, error) {
11841 if ctrl == nil {
11842 if !fallback {
11843 return MemoryFact{}, nil
11844 }
11845 a.mu.RLock()
11846 ctrl = a.activeCtrlLocked()
11847 a.mu.RUnlock()
11848 if ctrl == nil {
11849 return MemoryFact{}, nil
11850 }
11851 }
11852 restored, err := ctrl.RestoreArchivedMemory(archivePath)
11853 if err != nil {
11854 return MemoryFact{}, err
11855 }
11856 return memoryFactView(restored), nil
11857 }
11858
11859 func memoryFactView(f memory.Memory) MemoryFact {
11860 return MemoryFact{
11861 ID: f.ID, Revision: f.Revision, CreatedAt: formatMemoryTime(f.CreatedAt), UpdatedAt: formatMemoryTime(f.UpdatedAt),
11862 Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Scope: string(f.Scope), Body: f.Body,
11863 Freshness: memory.FreshnessFor(f, time.Now().UTC()),
11864 }
11865 }
11866
11867 func memoryRecallTraceView(trace memory.RecallResult) MemoryRecallTrace {
11868 view := MemoryRecallTrace{
11869 Query: trace.Query, Hits: []MemoryRecallHit{}, Omitted: trace.Omitted,
11870 CharBudget: trace.CharBudget, UsedChars: trace.UsedChars, Suppressed: trace.Suppressed,
11871 }
11872 for _, hit := range trace.Hits {
11873 view.Hits = append(view.Hits, MemoryRecallHit{
11874 ID: hit.Memory.ID, Revision: hit.Memory.Revision, Name: hit.Memory.Name, Title: hit.Memory.Title,
11875 Type: string(hit.Memory.Type), Scope: string(hit.Memory.Scope), Score: hit.Score,
11876 Freshness: hit.Freshness, Reason: hit.Reason, Snippet: hit.Snippet,
11877 })
11878 }
11879 return view
11880 }
11881
11882 func (a *App) MemoryRevisions(ref string) []MemoryFact {
11883 return a.memoryRevisionsForCtrl(nil, ref, true)
11884 }
11885
11886 func (a *App) MemoryRevisionsForTab(tabID, ref string) []MemoryFact {
11887 if tabID == "" {
11888 return a.memoryRevisionsForCtrl(nil, ref, true)
11889 }
11890 return a.memoryRevisionsForCtrl(a.ctrlByTabID(tabID), ref, false)
11891 }
11892
11893 func (a *App) memoryRevisionsForCtrl(ctrl control.SessionAPI, ref string, fallback bool) []MemoryFact {
11894 out := []MemoryFact{}
11895 if ctrl == nil {
11896 if !fallback {
11897 return out
11898 }
11899 a.mu.RLock()
11900 ctrl = a.activeCtrlLocked()
11901 a.mu.RUnlock()
11902 if ctrl == nil {
11903 return out
11904 }
11905 }
11906 for _, revision := range ctrl.MemoryRevisions(ref) {
11907 out = append(out, memoryFactView(revision))
11908 }
11909 return out
11910 }
11911
11912 func (a *App) RestoreMemoryRevision(ref string, revision int) (MemoryFact, error) {
11913 return a.restoreMemoryRevisionForCtrl(nil, ref, revision, true)
11914 }
11915
11916 func (a *App) RestoreMemoryRevisionForTab(tabID, ref string, revision int) (MemoryFact, error) {
11917 if tabID == "" {
11918 return a.restoreMemoryRevisionForCtrl(nil, ref, revision, true)
11919 }
11920 return a.restoreMemoryRevisionForCtrl(a.ctrlByTabID(tabID), ref, revision, false)
11921 }
11922
11923 func (a *App) restoreMemoryRevisionForCtrl(ctrl control.SessionAPI, ref string, revision int, fallback bool) (MemoryFact, error) {
11924 if ctrl == nil {
11925 if !fallback {
11926 return MemoryFact{}, nil
11927 }
11928 a.mu.RLock()
11929 ctrl = a.activeCtrlLocked()
11930 a.mu.RUnlock()
11931 if ctrl == nil {
11932 return MemoryFact{}, nil
11933 }
11934 }
11935 restored, err := ctrl.RestoreMemory(ref, revision)
11936 if err != nil {
11937 return MemoryFact{}, err
11938 }
11939 return memoryFactView(restored), nil
11940 }
11941
11942 // SaveDoc overwrites a memory doc with the panel editor's contents. The controller
11943 // validates path against the recognized memory files. Returns the file written.
11944 func (a *App) SaveDoc(path, body string) (string, error) {
11945 return a.saveDocForCtrl(nil, path, body, true)
11946 }
11947
11948 func (a *App) SaveDocForTab(tabID, path, body string) (string, error) {
11949 if tabID == "" {
11950 return a.saveDocForCtrl(nil, path, body, true)
11951 }
11952 return a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)
11953 }
11954
11955 func (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {
11956 if ctrl == nil {
11957 if !fallback {
11958 return "", nil
11959 }
11960 a.mu.RLock()
11961 ctrl = a.activeCtrlLocked()
11962 a.mu.RUnlock()
11963 if ctrl == nil {
11964 return "", nil
11965 }
11966 }
11967 return ctrl.SaveDoc(path, body)
11968 }
11969
11970 // parseScope maps a frontend scope id to a memory.Scope, defaulting to project.
11971 func parseScope(s string) memory.Scope {
11972 switch memory.Scope(s) {
11973 case memory.ScopeUser:
11974 return memory.ScopeUser
11975 case memory.ScopeLocal:
11976 return memory.ScopeLocal
11977 default:
11978 return memory.ScopeProject
11979 }
11980 }
11981
11982 // taskStore is the Store backing the task monitor panel.
11983 func (a *App) taskStore() taskmonitor.WriteStore {
11984 return taskmonitor.NewFileStore(filepath.Join(".reasonix", "tasks"))
11985 }
11986
11987 // taskControl returns the process-wide ControlService backing the task
11988 // monitor panel. A single instance keeps control operations serialized within
11989 // this process (across processes the FileStore's per-task lock still
11990 // arbitrates), and avoids re-creating the service on every Wails call.
11991 func (a *App) taskControl() *taskmonitor.ControlService {
11992 a.taskCtrlOnce.Do(func() {
11993 a.taskCtrl = taskmonitor.NewControlService(a.taskStore())
11994 })
11995 return a.taskCtrl
11996 }
11997
11998 func (a *App) projectDir() string {
11999 return a.activeWorkspaceRoot()
12000 }
12001
12002 type taskMonitorTabTarget struct {
12003 projectDir string
12004 sessionDir string
12005 sessionPath string
12006 sessionID string
12007 }
12008
12009 // taskMonitorTargetForTab snapshots the workspace and session identity owned by
12010 // tabID. Wails dispatches bound calls concurrently, so resolving the active tab
12011 // inside a task operation would allow a later tab switch to retarget it.
12012 func (a *App) taskMonitorTargetForTab(tabID string) (taskMonitorTabTarget, error) {
12013 tabID = strings.TrimSpace(tabID)
12014 if tabID == "" {
12015 return taskMonitorTabTarget{}, fmt.Errorf("task monitor tab id is required")
12016 }
12017
12018 a.mu.RLock()
12019 tab := a.tabByIDLocked(tabID)
12020 if tab == nil {
12021 a.mu.RUnlock()
12022 return taskMonitorTabTarget{}, fmt.Errorf("task monitor tab %q is unavailable", tabID)
12023 }
12024 workspaceRoot := strings.TrimSpace(tab.WorkspaceRoot)
12025 tabSessionPath := strings.TrimSpace(tab.SessionPath)
12026 ctrl := tab.Ctrl
12027 leaseKey := tab.sessionLeaseRuntimeKey()
12028 a.mu.RUnlock()
12029
12030 projectDir := workspaceRoot
12031 if projectDir == "" {
12032 projectDir = "."
12033 }
12034 sessionDir := desktopSessionDir(workspaceRoot)
12035 sessionPath := tabSessionPath
12036 if ctrl != nil {
12037 if dir := strings.TrimSpace(ctrl.SessionDir()); dir != "" {
12038 sessionDir = dir
12039 }
12040 if path := strings.TrimSpace(ctrl.SessionPath()); path != "" {
12041 sessionPath = path
12042 }
12043 }
12044 // During a recovery handoff the lease-backed tab path is newer than the
12045 // controller path until the controller commits the handoff.
12046 if tabSessionPath != "" && sessionRuntimeKey(tabSessionPath) == leaseKey {
12047 sessionPath = tabSessionPath
12048 sessionDir = filepath.Dir(tabSessionPath)
12049 } else if ctrl == nil && tabSessionPath != "" {
12050 sessionDir = filepath.Dir(tabSessionPath)
12051 }
12052
12053 target := taskMonitorTabTarget{
12054 projectDir: projectDir,
12055 sessionDir: sessionDir,
12056 sessionPath: sessionPath,
12057 }
12058 if sessionPath != "" {
12059 target.sessionID = agent.BranchID(sessionPath)
12060 }
12061 return target, nil
12062 }
12063
12064 func (a *App) ListTasks() ([]taskmonitor.TaskSnapshot, error) {
12065 return a.taskStore().ListTasks(a.ctx, a.projectDir())
12066 }
12067
12068 // CurrentTaskSessionID returns the stable branch ID for the active desktop
12069 // session. Task Monitor uses this as an optional view filter; an empty value
12070 // means that the active tab has no session controller yet.
12071 func (a *App) CurrentTaskSessionID() string {
12072 _, ctrl := a.activeTabAndCtrl()
12073 if ctrl == nil {
12074 return ""
12075 }
12076 return agent.BranchID(ctrl.SessionPath())
12077 }
12078
12079 // ListTasksForSession limits the project task view to one desktop session.
12080 // The unfiltered ListTasks method remains for compatibility with existing
12081 // callers and project-wide diagnostics.
12082 func (a *App) ListTasksForSession(sessionID string) ([]taskmonitor.TaskSnapshot, error) {
12083 tasks, err := a.ListTasks()
12084 if err != nil || strings.TrimSpace(sessionID) == "" {
12085 return tasks, err
12086 }
12087 return filterTasksBySession(tasks, sessionID), nil
12088 }
12089
12090 // ListTasksForTab returns the task view owned by tabID and filters it to that
12091 // tab's session when one is available. It deliberately avoids active-tab state.
12092 func (a *App) ListTasksForTab(tabID string) ([]taskmonitor.TaskSnapshot, error) {
12093 target, err := a.taskMonitorTargetForTab(tabID)
12094 if err != nil {
12095 return nil, err
12096 }
12097 tasks, err := a.taskStore().ListTasks(a.ctx, target.projectDir)
12098 if err != nil || target.sessionID == "" {
12099 return tasks, err
12100 }
12101 return filterTasksBySession(tasks, target.sessionID), nil
12102 }
12103
12104 func filterTasksBySession(tasks []taskmonitor.TaskSnapshot, sessionID string) []taskmonitor.TaskSnapshot {
12105 filtered := make([]taskmonitor.TaskSnapshot, 0, len(tasks))
12106 for _, task := range tasks {
12107 if task.SessionID == sessionID {
12108 filtered = append(filtered, task)
12109 }
12110 }
12111 return filtered
12112 }
12113
12114 func (a *App) GetTask(taskID string) (*taskmonitor.TaskSnapshot, error) {
12115 return a.taskStore().GetTask(a.ctx, a.projectDir(), taskID)
12116 }
12117
12118 func (a *App) ListTaskEvents(taskID string, afterSequence int) ([]taskmonitor.TaskEvent, error) {
12119 return a.taskStore().ListEvents(a.ctx, a.projectDir(), taskID, afterSequence)
12120 }
12121
12122 func (a *App) ListTaskEventsForTab(tabID, taskID string, afterSequence int) ([]taskmonitor.TaskEvent, error) {
12123 target, err := a.taskMonitorTargetForTab(tabID)
12124 if err != nil {
12125 return nil, err
12126 }
12127 return a.taskStore().ListEvents(a.ctx, target.projectDir, taskID, afterSequence)
12128 }
12129
12130 func (a *App) StopTask(taskID string, expectedVersion uint64, reason, idemKey string) (taskmonitor.ControlResult, error) {
12131 projectDir := a.projectDir()
12132 return a.taskControl().StopTaskWithKiller(
12133 a.ctx, projectDir, taskID, expectedVersion, reason, idemKey,
12134 desktopTaskJobKiller{app: a, projectDir: projectDir},
12135 )
12136 }
12137
12138 func (a *App) StopTaskForTab(tabID, taskID string, expectedVersion uint64, reason, idemKey string) (taskmonitor.ControlResult, error) {
12139 target, err := a.taskMonitorTargetForTab(tabID)
12140 if err != nil {
12141 return taskmonitor.ControlResult{}, err
12142 }
12143 return a.taskControl().StopTaskWithKiller(
12144 a.ctx, target.projectDir, taskID, expectedVersion, reason, idemKey,
12145 desktopTaskJobKiller{app: a, projectDir: target.projectDir},
12146 )
12147 }
12148
12149 func (a *App) CancelTask(taskID string, expectedVersion uint64, reason, idemKey string) (taskmonitor.ControlResult, error) {
12150 projectDir := a.projectDir()
12151 return a.taskControl().CancelTaskWithKiller(
12152 a.ctx, projectDir, taskID, expectedVersion, reason, idemKey,
12153 desktopTaskJobKiller{app: a, projectDir: projectDir},
12154 )
12155 }
12156
12157 func (a *App) CancelTaskForTab(tabID, taskID string, expectedVersion uint64, reason, idemKey string) (taskmonitor.ControlResult, error) {
12158 target, err := a.taskMonitorTargetForTab(tabID)
12159 if err != nil {
12160 return taskmonitor.ControlResult{}, err
12161 }
12162 return a.taskControl().CancelTaskWithKiller(
12163 a.ctx, target.projectDir, taskID, expectedVersion, reason, idemKey,
12164 desktopTaskJobKiller{app: a, projectDir: target.projectDir},
12165 )
12166 }
12167
12168 func (a *App) RequeueTask(taskID string, expectedVersion uint64, idemKey string) (taskmonitor.ControlResult, error) {
12169 return a.taskControl().RequeueTask(a.ctx, a.projectDir(), taskID, expectedVersion, idemKey)
12170 }
12171
12172 func (a *App) RequeueTaskForTab(tabID, taskID string, expectedVersion uint64, idemKey string) (taskmonitor.ControlResult, error) {
12173 target, err := a.taskMonitorTargetForTab(tabID)
12174 if err != nil {
12175 return taskmonitor.ControlResult{}, err
12176 }
12177 return a.taskControl().RequeueTask(a.ctx, target.projectDir, taskID, expectedVersion, idemKey)
12178 }
12179
12180 func (a *App) OpenTaskSession(taskID string) (taskmonitor.ControlResult, error) {
12181 return a.taskControl().OpenTaskSession(a.ctx, a.projectDir(), taskID)
12182 }
12183
12184 func (a *App) OpenTaskSessionForTab(tabID, taskID string) (taskmonitor.ControlResult, error) {
12185 target, err := a.taskMonitorTargetForTab(tabID)
12186 if err != nil {
12187 return taskmonitor.ControlResult{}, err
12188 }
12189 return a.taskControl().OpenTaskSession(a.ctx, target.projectDir, taskID)
12190 }
12191
12192 type desktopTaskJobKiller struct {
12193 app *App
12194 projectDir string
12195 }
12196
12197 func (k desktopTaskJobKiller) Kill(sessionID, taskID string) bool {
12198 // Legacy task records without a session ID cannot be routed safely because
12199 // jobs.Manager IDs restart at task-1 for each controller.
12200 if k.app == nil || sessionID == "" || strings.TrimSpace(k.projectDir) == "" {
12201 return false
12202 }
12203
12204 k.app.mu.RLock()
12205 tabs := k.app.runtimeTabsLocked()
12206 controllers := make([]control.SessionAPI, 0, len(tabs))
12207 for _, tab := range tabs {
12208 if tab != nil && tab.Ctrl != nil && sameProjectRoot(tab.WorkspaceRoot, k.projectDir) {
12209 controllers = append(controllers, tab.Ctrl)
12210 }
12211 }
12212 k.app.mu.RUnlock()
12213
12214 for _, ctrl := range controllers {
12215 if agent.BranchID(ctrl.SessionPath()) != sessionID {
12216 continue
12217 }
12218 if killer, ok := ctrl.(interface{ CancelJob(string) bool }); ok && killer.CancelJob(taskID) {
12219 return true
12220 }
12221 }
12222 return false
12223 }
12224
12225 // onboardingKeyEnv is the default provider (deepseek) key from config.Default().
12226 const onboardingKeyEnv = "DEEPSEEK_API_KEY"
12227
12228 // onboardingBalanceURL doubles as a zero-token connectivity + auth probe:
12229 // billing.FetchWithClient surfaces 401/403 for a bad key.
12230 const onboardingBalanceURL = "https://api.deepseek.com/user/balance"
12231
12232 var connectKeyBalanceFetch = billing.FetchWithClient
12233
12234 // NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation
12235 // dialog that replaces web-style confirm() for destructive or important actions.
12236 type NativeConfirmRequest struct {
12237 Title string `json:"title"`
12238 Message string `json:"message"`
12239 Detail string `json:"detail"`
12240 ConfirmLabel string `json:"confirmLabel"`
12241 CancelLabel string `json:"cancelLabel"`
12242 Destructive bool `json:"destructive"`
12243 }
12244
12245 // ConfirmAction shows a native confirmation dialog and returns true when the user
12246 // clicks the confirm button. For destructive actions the dialog type is Warning so
12247 // the platform can apply its danger styling (red tint on macOS, etc.).
12248 func (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {
12249 if a.ctx == nil {
12250 return false, nil
12251 }
12252 dialogType := runtime.QuestionDialog
12253 if req.Destructive {
12254 dialogType = runtime.WarningDialog
12255 }
12256 confirm := req.ConfirmLabel
12257 if confirm == "" {
12258 confirm = "OK"
12259 }
12260 cancel := req.CancelLabel
12261 if cancel == "" {
12262 cancel = "Cancel"
12263 }
12264 title := req.Title
12265 if title == "" {
12266 title = req.Message
12267 }
12268 body := req.Message
12269 if req.Detail != "" {
12270 if body != "" {
12271 body += "\n\n" + req.Detail
12272 } else {
12273 body = req.Detail
12274 }
12275 }
12276 defaultBtn := confirm
12277 if req.Destructive {
12278 // On destructive actions, make cancel the default so Enter / Space
12279 // does NOT accidentally confirm. ESC always maps to CancelButton.
12280 defaultBtn = cancel
12281 }
12282 result, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{
12283 Type: dialogType,
12284 Title: title,
12285 Message: body,
12286 Buttons: []string{confirm, cancel},
12287 DefaultButton: defaultBtn,
12288 CancelButton: cancel,
12289 })
12290 if err != nil {
12291 return false, err
12292 }
12293 return result == confirm, nil
12294 }
12295
12296 func (a *App) NeedsOnboarding() bool {
12297 cfg, err := config.LoadForRootReadOnly(a.activeWorkspaceRoot())
12298 if err != nil {
12299 // Configuration errors already surface through the startup error banner.
12300 // Do not cover their recovery path with an onboarding gate.
12301 return false
12302 }
12303 for i := range cfg.Providers {
12304 p := &cfg.Providers[i]
12305 if !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, p.Name) || !p.Configured() || len(p.ChatModelList()) == 0 {
12306 continue
12307 }
12308 return false
12309 }
12310 return true
12311 }
12312
12313 // ConnectKey validates apiKey against the balance endpoint, persists it to
12314 // Reasonix's global .env, and rebuilds the controller so the new key takes effect.
12315 func (a *App) ConnectKey(apiKey string) (string, error) {
12316 apiKey = strings.TrimSpace(apiKey)
12317 if apiKey == "" {
12318 return "", fmt.Errorf("key is required")
12319 }
12320 if tab := a.activeTab(); tab != nil {
12321 if err := rebuildControllerActiveWorkErrorFor(tab.Ctrl, "provider key"); err != nil {
12322 return "", err
12323 }
12324 }
12325 ctx, cancel := context.WithTimeout(a.ctx, 8*time.Second)
12326 defer cancel()
12327 if _, err := connectKeyBalanceFetch(ctx, nil, onboardingBalanceURL, apiKey); err != nil {
12328 return "", fmt.Errorf("validate: %w", err)
12329 }
12330 warning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)
12331 if err != nil {
12332 return "", fmt.Errorf("save: %w", err)
12333 }
12334 if err := a.ensureProviderAccessForKey(onboardingKeyEnv); err != nil {
12335 return "", fmt.Errorf("enable provider: %w", err)
12336 }
12337 if err := a.rebuildSetting("provider key"); err != nil {
12338 if rebuildWarning, ok := a.deferredRebuildWarning("provider key", err); ok {
12339 warning = appendSettingsWarning(warning, rebuildWarning)
12340 } else {
12341 return "", err
12342 }
12343 }
12344 return warning, nil
12345 }
12346
12346 lines GO