返回 DeepSeek-Reasonix
app.go
根目录 / desktop / app.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "crypto/rand"
7 "encoding/base64"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "io"
13 "log/slog"
14 "maps"
15 "net/http"
16 "net/url"
17 "os"
18 "path/filepath"
19 "reasonix/desktop/internal/browserops"
20 "reasonix/desktop/internal/instanceidentity"
21 "reasonix/internal/agent"
22 "reasonix/internal/billing"
23 "reasonix/internal/boot"
24 "reasonix/internal/botruntime"
25 "reasonix/internal/checkpoint"
26 "reasonix/internal/config"
27 "reasonix/internal/control"
28 "reasonix/internal/event"
29 "reasonix/internal/evidence"
30 "reasonix/internal/extension/providerext"
31 "reasonix/internal/fileref"
32 fileenc "reasonix/internal/fileutil/encoding"
33 "reasonix/internal/historywork"
34 "reasonix/internal/i18n"
35 "reasonix/internal/mcpdiag"
36 "reasonix/internal/mcpregistry"
37 "reasonix/internal/memory"
38 "reasonix/internal/notify"
39 "reasonix/internal/plugin"
40 "reasonix/internal/proc"
41 "reasonix/internal/provider"
42 "reasonix/internal/repair"
43 "reasonix/internal/session"
44 "reasonix/internal/sessioncatalog"
45 "reasonix/internal/sessiontemp"
46 "reasonix/internal/skill"
47 "reasonix/internal/skill/skillwatch"
48 "reasonix/internal/store"
49 "reasonix/internal/taskcatalog"
50 "reasonix/internal/taskmonitor"
51 "reasonix/internal/tool"
52 "reasonix/internal/tool/builtin"
53 "reasonix/internal/transcript"
54 "regexp"
55 goruntime "runtime"
56 "slices"
57 "sort"
58 "strconv"
59 "strings"
60 "sync"
61 "sync/atomic"
62 "time"
63 "unicode/utf8"
64 )
65
66 // sessionTempFromController returns the logical-session private temporary
67 // directory manager for a same-session controller rebuild. Nil when the
68 // controller is missing or is not a *control.Controller.
69 func sessionTempFromController(ctrl control.SessionAPI) *sessiontemp.Manager {
70 c, ok := ctrl.(*control.Controller)
71 if !ok || c == nil {
72 return nil
73 }
74 return c.SessionTemp()
75 }
76
77 // eventChannel is the Wails runtime event name the frontend subscribes to for the
78 // agent's typed event stream. One channel carries every event kind; the payload's
79 // `kind` field discriminates — the desktop analogue of the serve transport's SSE
80 // `data:` frames.
81 const eventChannel = "agent:event"
82
83 const singleInstanceIDPrefix = instanceidentity.Prefix
84
85 func singleInstanceID() string { return instanceidentity.ForHome(config.ReasonixHomeDir()) }
86
87 // PromptHistoryEntry is one user prompt extracted from a session JSONL file.
88 // The frontend uses these for ↑/↓ prompt-history navigation.
89 type PromptHistoryEntry struct {
90 Text string `json:"text"`
91 At int64 `json:"at"` // unix ms
92 SessionPath string `json:"sessionPath"`
93 Turn int `json:"turn"`
94 }
95
96 // PromptHistoryResult is returned as one Wails value. It carries one loaded tape
97 // segment plus the cursor needed to keep walking toward older prompts.
98 type PromptHistoryResult struct {
99 Entries []PromptHistoryEntry `json:"entries"`
100 Nonce string `json:"nonce"`
101 OlderCursor string `json:"olderCursor,omitempty"`
102 HasOlder bool `json:"hasOlder"`
103 }
104
105 // App is the Wails-bound application object: the desktop frontend's command
106 // surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS
107 // bindings. The app manages multiple WorkspaceTabs — each with its own controller
108 // scoped to a project workspace — and routes commands to the active tab. Events
109 // flow the other way: each tab's controller emits to a tabEventSink that
110 // forwards events tagged with tabId to the webview via runtime.EventsEmit.
111 type App struct {
112 sessionExportMu sync.Mutex
113 sessionExports map[string]*sessionExportJob
114 ctx context.Context
115 host nativeHost
116 workspaceHub *workspaceChangeHub
117 topicState *topicStateManager
118 // topicTitleMutationMu keeps the authoritative title commit and its Tab /
119 // session-sidecar publication in the same order for manual and automatic
120 // renames. It is never held by generic topic-state reads or other metadata.
121 topicTitleMutationMu sync.Mutex
122 // aiSessionTitleMu deduplicates explicit AI rename requests by durable
123 // session identity. It never serializes different sessions.
124 aiSessionTitleMu sync.Mutex
125 aiSessionTitleInFlight map[string]aiSessionTitleOperation
126 // auxiliaryProviderGeneration invalidates bounded provider-only work when
127 // model credentials/configuration or extension packages change. Cancellation
128 // is an optimization; title CAS remains the final acceptance authority.
129 auxiliaryProviderGeneration atomic.Uint64
130
131 // sessionCatalog is a disposable, asynchronously opened projection of
132 // authoritative session sidecars. Project-shell APIs must tolerate nil here:
133 // opening, migration, repair, and corruption recovery never gate the UI.
134 sessionCatalog atomic.Pointer[sessioncatalog.Catalog]
135 historyMaintenance historywork.Coordinator
136 historyIdlePool session.IdlePool
137 historyReaders desktopHistoryReaders
138 catalogLifecycleMu sync.Mutex
139 catalogCancel context.CancelFunc
140 catalogDone, catalogInitialReconcileDone chan struct{}
141 catalogMetadataRequests chan struct{} // guarded by catalogLifecycleMu
142 catalogClosing *sessionCatalogClose // guarded by catalogLifecycleMu
143 catalogRebuildMu sync.Mutex
144 catalogRebuild *sessionCatalogRebuildFlight
145 catalogRebuilding atomic.Bool
146 shuttingDown atomic.Bool
147 shutdownMu sync.Mutex
148 shutdownCoordinator *desktopShutdownCoordinator
149 // catalogReconcileJobs coalesces both the legacy pre-scan and catalog scan.
150 // Catalog deduplicates its worker; this also prevents callers from
151 // stampeding the otherwise-unbounded pre-scan goroutines.
152 catalogReconcileMu sync.Mutex
153 catalogReconcileJobs map[string]*desktopCatalogReconcileJob
154 // Test-only deterministic boundary, set before concurrent requests.
155 catalogReconcileHook func(sessioncatalog.DirectoryTarget)
156 // catalogRebuildJoinHook is test-only: it proves concurrent Wails callers
157 // joined the published rebuild flight before its completion was released.
158 catalogRebuildJoinHook func()
159 // projectTreeCatalogRefreshHook is test-only: it proves runtime-only
160 // navigation never falls back to the broad catalog refresh path.
161 projectTreeCatalogRefreshHook func()
162 catalogReconcileDoneHook func(sessioncatalog.DirectoryTarget)
163 // catalogRegisteredProjectRoots bounds activation-triggered discovery to
164 // once per project per process. Failed pre-catalog attempts are removed so
165 // a later activation retries after the asynchronous catalog opens.
166 catalogRegisteredProjectRoots sync.Map
167
168 // taskCtrl is the process-wide task-monitor control service (lazy; see
169 // taskControl). One instance serializes control operations in-process.
170 taskCtrl *taskmonitor.ControlService
171 taskCtrlOnce sync.Once
172
173 // mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read
174 // from bound methods. All bound methods that touch a controller use activeCtrl().
175 mu sync.RWMutex
176 tabs map[string]*WorkspaceTab
177 tabOrder []string
178 activeTabID string
179 readyHook func()
180 attachmentTargetState
181 // tabSelectionMu serializes cross-registry activation. A remote selection
182 // must not overtake the local-session snapshot that makes switching safe.
183 tabSelectionMu sync.Mutex
184 // sessionVersionActivationMu serializes version selection's validation,
185 // preference update, and tab rebind so concurrent Wails calls cannot publish
186 // a different active version than the one persisted as preferred.
187 sessionVersionActivationMu sync.Mutex
188
189 // Ticketed topic activation bookkeeping (StartTopicActivation). Guarded by
190 // mu. activationGen bumps on every activation-or-supersede so a background
191 // completion can tell whether it still owns publication; the pending
192 // request/tab pair identifies the in-flight ticketed activation whose
193 // completion may still prune and emit "ready".
194 topicActivationState
195 // activationEventHook is test-only: when set it replaces the
196 // "topic:activation" runtime event emission so tests capture events
197 // synchronously. Set before starting concurrent work, never mutate after.
198 activationEventHook func(TopicActivationEvent)
199 // tabBuildStartHook is test-only: called at the top of every tab
200 // controller build (even already-superseded ones) so ordering tests can
201 // gate builds. Same set-before-concurrency rule.
202 tabBuildStartHook func(tabID string)
203 // configLoadForRootHook is test-only: called from the background meta
204 // extras refresh so tests can prove MetaForTab itself never loads config.
205 configLoadForRootHook func(root string)
206
207 // runtimeByID/runtimeBySessionKey form the process-local ownership registry.
208 // App.mu guards both maps and every desktopSessionRuntime field.
209 runtimeByID map[string]*desktopSessionRuntime
210 runtimeBySessionKey map[string]*desktopSessionRuntime
211 // sessionServices contains one SessionID-only registry for the whole local
212 // Desktop host. desktopSessions owns its persistence and navigation state.
213 sessionServicesMu sync.Mutex
214 sessionServices map[string]*session.Service
215 historicalSessionServices map[string]*session.Service
216 // skillWatch is the one skill-watch service this host shares across every
217 // controller build, so the host owns a single watcher helper process
218 // instead of one per rebuild. Created on first use; closed, never cleared, at shutdown.
219 skillWatchMu sync.Mutex
220 skillWatch *skillwatch.Service
221 desktopPersistenceState
222
223 // tabsRestored is closed when restoreOrBuildTabs has finished populating
224 // a.tabs from desktop-tabs.json (or built the first-launch tab). Startup
225 // work that inspects "which sessions are open" or persists the tab list
226 // (recovery GC's DeleteSession does both) must wait on it: running against
227 // the pre-restore empty tab map would treat every saved tab's session as
228 // closed and could overwrite desktop-tabs.json with an empty snapshot.
229 tabsRestored chan struct{}
230
231 // projectTreeChangedHook is test-only: set once before any concurrency
232 // starts, then read lock-free from emitProjectTreeChanged (whose callers
233 // may or may not hold a.mu, so it cannot re-lock). Never write it after
234 // startup.
235 projectTreeChangedHook func()
236 projectTreeRuntime projectTreeRuntimeState
237 runtimeStateProjection desktopRuntimeProjection
238 remoteRuntimeSync remoteRuntimeSync
239
240 // singleSurfaceMu serializes open/reuse plus visible-tab pruning for the
241 // one-conversation layout so overlapping navigation cannot remove the tab
242 // another navigation is still activating.
243 singleSurfaceMu sync.Mutex
244 // worktreeMergeMu serializes the inspect-confirm-merge/finalize mutation
245 // boundary. Git identities are still revalidated after workspace leases are
246 // acquired; this mutex only prevents duplicate in-process Wails calls.
247 worktreeMergeMu sync.Mutex
248 // Worktree runtime reservations are ordered before App.mu. Runtime owners
249 // hold this gate through final publication; callers must never acquire it
250 // under App.mu. Merge reservations cover both the source and isolated roots,
251 // while cleanup reservations cover the complete allocation through removal.
252 worktreeReservations worktreeRuntimeReservations
253 // navigationIntent linearizes frontend intent publication with the final
254 // merged-worktree removal before the runtime mutation barrier and App.mu.
255 navigationIntent navigationIntentFence
256
257 // sessionRemovalMu serializes operations that remove visible or detached
258 // session bindings. Those operations may snapshot controllers before
259 // deletion; keep that snapshot outside a.mu, but do not let DeleteSession or
260 // topic/workspace removal trash the same files while it is in flight.
261 sessionRemovalMu sync.Mutex
262
263 // runtimeRebuildMu serializes controller rebuilds (build + swap), teardown,
264 // and MCP lifecycle mutations. Two concurrent rebuilds of the same tab both
265 // pass the tab-identity check at swap time, while MCP launch authorization racing
266 // a toggle/reconnect can restore stale tools or launch a second single-instance
267 // server. MCP paths insert extensionBuildMu between runtimeRebuildMu and
268 // runtimeAdmissionMu; both orders end at App.mu -> Host/Registry.
269 runtimeRebuildMu sync.Mutex
270 // runtimeAdmissionMu is the runtime lifecycle barrier. Foreground turn-start
271 // tokens and the short publication phase of asynchronous controller builds
272 // hold the read side; runtime teardown and MCP lifecycle mutations hold the
273 // write side so their captured controller/Host cannot be replaced, closed, or
274 // handed a late turn in flight. Writers already hold runtimeRebuildMu, making
275 // them mutually exclusive. Read holders must never acquire runtimeRebuildMu,
276 // or a queued writer would deadlock the pair.
277 runtimeAdmissionMu sync.RWMutex
278 appLifecycleTestHooks
279 // modelSwitchTimingHook is test-only. Production diagnostics use the same
280 // sanitized timing record through debug logging.
281 modelSwitchTimingHook func(modelSwitchTiming)
282 // rebindCandidateHook is test-only. It exposes deterministic transaction
283 // boundaries without weakening the production lock order. Set it before
284 // starting a rebind and never mutate it until that rebind returns.
285 rebindCandidateHook func(string) error
286 // providerCatalogBeforeCredentialLockHook is test-only. It pauses catalog
287 // compare-and-apply after its optimistic credential snapshot but before the
288 // shared credential lock and authoritative re-read.
289 providerCatalogBeforeCredentialLockHook func(string)
290
291 // tryRunMu guards tryRunCancel — the cancel handle for the single
292 // in-flight settings-page subagent try run (TrySubagentProfile /
293 // CancelTrySubagentProfile).
294 tryRunMu sync.Mutex
295 tryRunCancel context.CancelFunc
296
297 // updaterOperationMu guards the single native download/install operation.
298 // Checks are read-only and may overlap; cache mutation and installation fail
299 // fast when another updater operation is already active.
300 updaterOperationMu sync.Mutex
301 updaterOperationID string
302
303 // deferredRebuild tracks tabs whose settings were saved but whose runtime
304 // could not refresh because the session lease was held by another process.
305 deferredRebuild deferredRebuildState
306
307 // historySliceMu guards the windowed-history background bookkeeping:
308 // single-flight display-index rebuilds for live sessions and the startup
309 // index-migration worker's cancel handle. Never held while calling
310 // controller or session methods.
311 historySliceMu sync.Mutex
312 historyIndexRebuilds map[string]chan struct{}
313
314 // detachedSessions keeps live session runtimes whose visible tab was closed.
315 // It is process-local by design: shutdown closes every detached controller.
316 detachedSessions map[string]*WorkspaceTab
317
318 // takeoverMirrors tracks sessions this desktop took over from a local
319 // serve: the tab writes locally while its events mirror to the remote tab.
320 takeoverMirrors map[string]*takeoverMirror
321 takeoverAdoptRevisions map[string]uint64
322 takeoverMu sync.Mutex
323 // serveProbeUntil suppresses serve probing after a failed handshake
324 // (rotated token file); guarded by serveProbeMu.
325 serveProbeUntil map[string]time.Time
326 serveProbeMu sync.Mutex
327
328 // sharedHosts holds one *plugin.Host per workspace root, shared by all
329 // controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are
330 // spawned once instead of N times. Lifecycle: first Acquire creates the
331 // host, last Release closes it.
332 sharedHosts map[string]*sharedPluginHost
333 sharedHostsMu sync.Mutex
334 // extensionGeneration fences off-lock shared-host boot against MCP mutations;
335 // stale generations abandon publication instead of restoring old tools.
336 extensionGeneration atomic.Uint64
337 extensionBuildMu sync.RWMutex
338
339 // tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path.
340 tabsSaveMu sync.Mutex
341 tabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot
342 tabsLastWrittenVersion uint64 // protected by tabsSaveMu
343 tabsFileExtra map[string]json.RawMessage // protected by tabsSaveMu; unknown top-level persistence fields
344
345 forceQuit atomic.Bool
346 backgroundMaximised atomic.Bool
347 desktopLocale atomic.Int32
348 trayReady bool
349 tray *desktopTray
350 desktopShell desktopShellRuntimeState
351
352 mediaTokens *mediaTokenStore
353 presentPreview *workspacePreviewOrigin
354 botInstalls map[string]*botInstallSession
355 botRuntime *desktopBotRuntime
356 // botBridge gives the embedded bot gateway a god view over desktop
357 // sessions (/desktop commands). Set once in NewApp before any tab exists,
358 // read-only afterwards, so tabEventSink.Emit reads it without a lock.
359 botBridge *botBridgeHub
360
361 metrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics
362
363 notificationSenderOnce sync.Once
364 notificationSender notify.Sender
365
366 runtimeEvents asyncRuntimeEmitter
367 mcpAppsSandbox mcpAppsSandbox
368
369 // terminals owns local PTY/ConPTY sessions. It is intentionally separate
370 // from chat runtimes: terminal lifecycle must never acquire App.mu or the
371 // controller rebuild locks while process I/O is blocked.
372 terminals *terminalManager
373
374 // Remote SSH module: the manager is created lazily on the first remote
375 // binding call and closed on shutdown.
376 remoteMu sync.Mutex
377 remoteRuntime remoteKernel
378
379 // Remote web windows (SSH Serve pages). The Electron shell owns one
380 // BrowserWindow per host; the bridge tracks which host keys are open.
381 // Host-scoped lifecycle operations are generation-fenced and serialized so
382 // an overlapping disconnect/stop cannot miss a window that is still being
383 // opened. Closing a window never stops the remote Serve or the SSH
384 // connection.
385 remoteWindows *remoteWindowRegistry
386 remoteWindowLifecycles remoteWindowLifecycleRegistry
387 remoteWindowOpener func(remoteWindowLaunch) error // test-only injection
388 // Remote project tabs are in-app surfaces bound to a remote workspace.
389 // Project pins persist in user config; open tab shells persist separately
390 // and restore disconnected until the user activates them.
391 remoteTabMu sync.Mutex
392 remoteTabs map[string]*remoteTab
393 remoteTabLayout remoteTabLayoutState
394 remoteTabTasks sync.WaitGroup
395 // remoteTabModelMu makes the caller's current-model snapshot, the remote
396 // Serve rebuild, and the tab metadata commit one transaction. Without it,
397 // overlapping switches could roll remote config back to a stale model.
398 remoteTabModelMu sync.Mutex
399 modelSettingsSubmitMu sync.Mutex
400 modelSettingsReceipts map[string]modelSettingsReceipt
401 modelSettingsReceiptOrder []string
402 // remoteEventHook observes remote events in tests; production leaves it nil.
403 remoteEventHook func(name string, payload any)
404 // credProxy is the lazy app-wide key holder for local-proxy mode.
405 credProxyMu sync.Mutex
406 credProxy *credentialProxy
407 // browserBroker is the lazy app-wide loopback broker remote serves reach
408 // through SSH reverse tunnels; per-generation tokens isolate hosts.
409 browserBrokerMu sync.Mutex
410 browserBroker *browserBroker
411
412 // promptHistoryTape is a lazy, cursor-addressed view of prompt history. It
413 // stores session order and per-session parsed entries only after that session is
414 // reached by ↑ navigation. See ScanPromptHistory.
415 promptHistoryMu sync.Mutex
416 promptHistoryTape *promptHistoryTape
417
418 skillRootsMu sync.Mutex
419 skillRootsCache skillRootsCache
420
421 heartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup
422 lifecycle desktopLifecycleRuntime
423 // diagnosticsOwner is acquired before Wails starts so Linux's OnStartup
424 // ordering cannot let a second-instance handoff create lifecycle evidence.
425 diagnosticsOwner bool
426 diagnosticsOwnerRelease func()
427 diagnosticsConfigLoaded bool
428 diagnosticsTelemetry bool
429 // Healthy-update identity is captured before Wails starts. A process may
430 // commit only the complete probationary transaction it actually booted from,
431 // never a rewritten or later same-version retry.
432 healthyUpdateCreatedAt string
433 healthyUpdateTransactionID string
434 // startupReady records that React rendered and the host bridge heartbeat
435 // succeeded. DOM navigation alone is not application health.
436 startupReady atomic.Bool
437 // hostShell is non-nil only under the Electron shell (--host-rpc).
438 hostShell *hostShellBridge
439 // browserExecutors are per-tab browser grants over the shell; browserOps is
440 // the durable write ledger shared by all of them. Both lazily created.
441 browserExecMu sync.Mutex
442 browserExecutors map[string]*hostBrowserExecutor
443 browserOps *browserops.Ledger
444 // Browser operations and lifecycle invalidation have separate lock domains.
445 filePreviews fileBrowserPreviewRegistry
446 // browserControl is the shell-pushed switch that decides whether new
447 // sessions may drive the built-in browser at all.
448 browserControl browserControl
449 }
450
451 type desktopShellRuntimeState struct {
452 coordinator *desktopShellCoordinator
453 trayState string
454 trayReason string
455 }
456
457 type skillRootsCache struct {
458 key string
459 at time.Time
460 roots []SkillRootView
461 }
462
463 // jsProfilingMiddleware opts every asset response into the JS Self-Profiling
464 // document policy so the frontend performance monitor can attach sampled stacks
465 // to long-task reports. Chromium honors both the header and the API; other
466 // engines degrade to unattributed reports.
467 func (a *App) jsProfilingMiddleware() func(http.Handler) http.Handler {
468 return func(next http.Handler) http.Handler {
469 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
470 w.Header().Set("Document-Policy", "js-profiling")
471 next.ServeHTTP(w, r)
472 })
473 }
474 }
475
476 // NewApp constructs the bound object. Tabs are restored in startup from the
477 // last session's desktop-tabs.json.
478 func NewApp() *App {
479 a := &App{
480 tabs: map[string]*WorkspaceTab{},
481 runtimeByID: map[string]*desktopSessionRuntime{},
482 runtimeBySessionKey: map[string]*desktopSessionRuntime{},
483 sessionServices: map[string]*session.Service{},
484 aiSessionTitleInFlight: map[string]aiSessionTitleOperation{},
485 desktopPersistenceState: newDesktopPersistenceState(),
486 catalogReconcileJobs: map[string]*desktopCatalogReconcileJob{},
487 detachedSessions: map[string]*WorkspaceTab{},
488 mediaTokens: newMediaTokenStore(),
489 presentPreview: newWorkspacePreviewOrigin(),
490 botInstalls: map[string]*botInstallSession{},
491 botRuntime: newDesktopBotRuntime(),
492 remoteWindows: newRemoteWindowRegistry(),
493 topicState: desktopTopicState,
494 worktreeReservations: worktreeRuntimeReservations{
495 cleanup: map[string]struct{}{},
496 merge: map[string]struct{}{},
497 },
498 }
499 a.desktopShell.trayState = "probing"
500 a.desktopShell.coordinator = newDesktopShellCoordinator(a)
501 a.workspaceHub = newWorkspaceChangeHub(a)
502 a.terminals = newTerminalManager(a)
503 a.botBridge = a.newBotBridge()
504 return a
505 }
506
507 func (a *App) bootContext() context.Context {
508 if a.ctx != nil {
509 return a.ctx
510 }
511 return context.Background()
512 }
513
514 // Platform exposes the native OS to the frontend so chrome/layout affordances can
515 // stay platform-scoped instead of relying on browser user-agent guesses.
516 func (a *App) Platform() string {
517 return goruntime.GOOS
518 }
519
520 // startup runs once the shell's renderer is up, before the frontend can issue
521 // any bound call. It stores the service-lifetime context, then kicks off the
522 // initialization in a background goroutine so the page loads immediately.
523 func (a *App) startup(ctx context.Context) {
524 a.ctx = ctx
525 a.shuttingDown.Store(false)
526 a.initializeDesktopSessionRoot()
527 // Only the process that claimed the pre-shell diagnostics lock consumes
528 // lifecycle evidence.
529 initializeLifecycleDiagnostics(a)
530 a.desktopShell.coordinator.start(ctx)
531 a.lifecycle.tracker.markAsync("ready")
532 a.startNativeShellSupport()
533 a.enableDeferredRebuildRetry()
534 // Historical bodies are prepared only when a reader requests them.
535 a.mu.Lock()
536 a.tabsRestored = make(chan struct{})
537 a.mu.Unlock()
538 a.startDesktopSessionMigration(ctx)
539
540 if cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != "dev" {
541 a.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
542 a.recordSettingsMetricsSnapshot(cfg)
543 }
544 a.recordPreviousRunDiagnostics()
545
546 a.heartbeat = newHeartbeatEngine(a)
547 a.heartbeat.Start()
548
549 go a.restoreOrBuildTabs()
550 a.startDesktopPersistenceReconciliation()
551 a.registerHistoryIndexEvents()
552 a.startSessionCatalog()
553 a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
554 a.goSafe("sendStartupPing", a.sendStartupPing)
555 a.goSafe("flushMetrics", a.flushMetrics)
556 a.goSafe("flushPendingCrash", a.flushPendingCrash)
557 // After restoreOrBuildTabs is launched: the GC's first sweep waits on
558 // tabsRestored so it never observes the pre-restore empty tab map.
559 a.startRecoveryGC()
560 }
561
562 func (a *App) beforeClose(ctx context.Context) bool {
563 if a.forceQuit.Swap(false) || consumeSystemQuitRequested() {
564 return false
565 }
566 cfg, _, err := a.loadDesktopUserConfigForView()
567 if err != nil {
568 cfg = config.LoadForEdit(config.UserConfigPath())
569 }
570 if cfg.DesktopCloseBehavior() == "background" {
571 if !a.backgroundCloseHasRestorePath() {
572 return false
573 }
574 // Never query native maximise state here: during close the Win32 DPI
575 // path can report 0 and panic inside Wails ScaleToDefaultDPI. Use the
576 // last frontend-reported geometry instead.
577 a.backgroundMaximised.Store(a.lastKnownMaximised())
578 a.saveWindowStateSync()
579 a.snapshotAllTabs()
580 if a.desktopShell.coordinator != nil {
581 return a.desktopShell.coordinator.hideToBackground(ctx, func() bool {
582 return backgroundCloseUsesApplicationHide(goruntime.GOOS) || a.isTrayReady()
583 })
584 }
585 hideForBackground(ctx, a.nativeHost())
586 return true
587 }
588 return false
589 }
590
591 const backgroundCloseTrayReadyTimeout = 500 * time.Millisecond
592
593 func (a *App) backgroundCloseHasRestorePath() bool {
594 if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
595 return backgroundCloseHasRestorePathFor(goruntime.GOOS, false, false)
596 }
597 if !a.startTray() {
598 return false
599 }
600 return backgroundCloseHasRestorePathFor(goruntime.GOOS, true, a.waitForTrayReady(backgroundCloseTrayReadyTimeout))
601 }
602
603 func (a *App) waitForTrayReady(timeout time.Duration) bool {
604 if a.isTrayReady() {
605 return true
606 }
607 ready := a.trayReadySignal()
608 if ready == nil {
609 return false
610 }
611 if timeout <= 0 {
612 select {
613 case <-ready:
614 return a.isTrayReady()
615 default:
616 return false
617 }
618 }
619 timer := time.NewTimer(timeout)
620 defer timer.Stop()
621 select {
622 case <-ready:
623 return a.isTrayReady()
624 case <-timer.C:
625 return a.isTrayReady()
626 }
627 }
628
629 func (a *App) isTrayReady() bool {
630 a.mu.RLock()
631 defer a.mu.RUnlock()
632 return a.trayReady
633 }
634
635 func (a *App) trayReadySignal() <-chan struct{} {
636 a.mu.RLock()
637 defer a.mu.RUnlock()
638 if a.tray == nil {
639 return nil
640 }
641 return a.tray.ready
642 }
643
644 // markTabsRestored closes the tabsRestored gate exactly once. Safe when the
645 // channel was never created (tests that drive App without startup).
646 func (a *App) markTabsRestored() {
647 a.mu.Lock()
648 defer a.mu.Unlock()
649 if a.tabsRestored == nil {
650 return
651 }
652 select {
653 case <-a.tabsRestored:
654 default:
655 close(a.tabsRestored)
656 }
657 }
658
659 // tabsRestoredSignal returns a channel closed once tab restore has completed.
660 // When startup never armed the gate (tests), it reports already-restored.
661 func (a *App) tabsRestoredSignal() <-chan struct{} {
662 a.mu.RLock()
663 defer a.mu.RUnlock()
664 if a.tabsRestored == nil {
665 closed := make(chan struct{})
666 close(closed)
667 return closed
668 }
669 return a.tabsRestored
670 }
671
672 func (a *App) showMainWindow() {
673 a.showMainWindowFrom("menu")
674 }
675
676 func (a *App) secondInstanceLaunch() {
677 a.showMainWindowFrom("second_instance")
678 }
679
680 func (a *App) quitApp() {
681 if a.ctx == nil {
682 return
683 }
684 a.forceQuit.Store(true)
685 a.nativeHost().Quit(a.ctx)
686 }
687
688 func hideForBackground(ctx context.Context, host nativeHost) {
689 if backgroundCloseUsesApplicationHide(goruntime.GOOS) {
690 host.HideApplication(ctx)
691 return
692 }
693 host.HideWindow(ctx)
694 }
695
696 func backgroundCloseUsesApplicationHide(goos string) bool {
697 return goos == "darwin"
698 }
699
700 func backgroundCloseHasRestorePathFor(goos string, trayStarted, trayReady bool) bool {
701 return backgroundCloseUsesApplicationHide(goos) || (trayStarted && trayReady)
702 }
703
704 type backgroundRestorePlan struct {
705 maximiseBeforeShow bool
706 unminimiseAfterShow bool
707 }
708
709 func backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan {
710 if backgroundRestoreShouldMaximise(goos, wasMaximised) {
711 return backgroundRestorePlan{maximiseBeforeShow: true}
712 }
713 return backgroundRestorePlan{unminimiseAfterShow: true}
714 }
715
716 func backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool {
717 return wasMaximised && !backgroundCloseUsesApplicationHide(goos)
718 }
719
720 // restoreOrBuildTabs restores the tabs from the last session, or creates a
721 // default Global tab on first launch.
722 func (a *App) restoreOrBuildTabs() {
723 defer a.recoverToPending("restoreOrBuildTabs")
724 // Unblock startup work gated on the restore (recovery GC) no matter how
725 // this returns — including the recover path above.
726 defer a.markTabsRestored()
727 // Reap any orphaned codegraph processes from a previous crash or older
728 // version that leaked them, so they don't accumulate across restarts.
729 a.reapOrphanCodeGraph()
730 ctx := a.ctx
731 ensureWorkspace()
732
733 // Run legacy config migration before the first config load so the
734 // freshly written config (including the user's default_model) is
735 // picked up by Load instead of falling back to built-in defaults.
736 _, _ = config.MigrateLegacyIfNeeded()
737 if err := reconcileTopicArchiveMetadataPending(a.deleteTopic); err != nil {
738 slog.Warn("desktop: topic archive metadata reconciliation remains pending")
739 }
740 f, tabsVersion := a.loadTabsForRestore()
741 _, _ = recoverLegacyProjectSidebarRoots(f)
742 _, _ = config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath())
743 _, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f))
744
745 // Load i18n from the first available config.
746 // Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting),
747 // so the user's language choice in desktop settings takes effect.
748 a.loadStartupLocale()
749 f, _, restoreCurrent := a.reconcileTabsBeforeRestore(ctx, f, tabsVersion)
750 if !restoreCurrent {
751 return
752 }
753 // Every surviving layout style is single-surface, and a config that failed
754 // to load already took this path when the predicate could still be false.
755 f = singleSurfaceTabsFile(f)
756 // Restore remote tabs as disconnected shells; activation performs the
757 // first network work so desktop startup remains offline-safe.
758 a.restoreRemoteTabShells(f)
759 if len(f.Tabs) > 0 {
760 toBuild := make([]*WorkspaceTab, 0, len(f.Tabs))
761 for _, entry := range f.Tabs {
762 releaseAdmission, admissionErr := a.beginProjectRuntimeAdmission(entry.Scope, entry.WorkspaceRoot)
763 if admissionErr != nil {
764 continue
765 }
766 a.mu.Lock()
767 id := a.restoredTabIDLocked(entry.ID)
768 a.mu.Unlock()
769
770 var tab *WorkspaceTab
771 if entry.Scope == "project" {
772 tab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id)
773 } else {
774 tab = a.createTabEntryWithID("global", globalTabWorkspaceRoot(), entry.TopicID, id)
775 }
776 tab.model = entry.Model
777 tab.SessionWorkspace.ID = restoredWorkspaceID(entry)
778 tab.effort = cloneStringPtr(entry.Effort)
779 // Legacy role fields remain readable, but the retired setting no
780 // longer changes restored-session behavior.
781 tab.qualityFloor = control.QualityFloorStandard
782 tab.mode = persistedTabMode(entry.Mode)
783 // Validate the persisted goal against the session's goal-state
784 // sidecar: a typed /new or /clear rotates the session through the
785 // controller without passing App.NewSession/ClearSession, so
786 // entry.Goal can be stale. Session rotation writes a stopped
787 // goal-state onto the fresh path; reading it here stops a restart
788 // from re-seeding the cleared goal into the rotated session. A
789 // session without a sidecar keeps the persisted goal (legacy).
790 restoreRuntime := prepareRestoredTabIdentity(tab, entry)
791 if id := strings.TrimSpace(entry.SessionID); id != "" {
792 tab.toolApprovalMode = a.sessionPresets.restore(id, tab.toolApprovalMode)
793 } else {
794 tab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode)
795 if tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) {
796 tab.toolApprovalMode = control.ToolApprovalYolo
797 }
798 }
799 tab.SessionPath = strings.TrimSpace(entry.SessionPath)
800 tab.SessionID = strings.TrimSpace(entry.SessionID)
801 tab.SessionHeadID = strings.TrimSpace(entry.SessionHeadID)
802 tab.PendingCreateOperationID = strings.TrimSpace(entry.CreateOperationID)
803 tab.persistenceExtra = cloneDesktopJSONFields(entry.extra)
804 tab.ReadOnly = entry.ReadOnly
805 tab.Takeover.Spectator = entry.TakeoverSpectator
806 tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx}
807 a.publishRestoredTab(tab, releaseAdmission)
808 if restoreRuntime {
809 toBuild = append(toBuild, tab)
810 }
811 }
812 a.finishRestoredLocalTabs(f, toBuild)
813 return
814 }
815 if len(f.RemoteTabs) > 0 {
816 // Remote-only layout: the remote shell is the visible surface, but local
817 // commands still need a workspace tab to target.
818 a.restoreDormantWorkspaceTab(ctx)
819 return
820 }
821
822 // First launch intentionally has no runtime. The renderer opens a persisted
823 // Global draft after this restore gate closes; the first execution creates
824 // the canonical Session and Controller.
825 }
826
827 func (a *App) loadStartupLocale() {
828 cfg, err := config.Load()
829 if err != nil {
830 return
831 }
832 lang := cfg.DesktopLanguage()
833 if lang == "" {
834 lang = cfg.Language
835 }
836 a.setDesktopLocale(i18n.DetectLanguage(lang))
837 }
838
839 func (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab {
840 return a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID())
841 }
842
843 func desktopNewSessionDefaults(scope, workspaceRoot string) (string, string) {
844 userCfg := config.LoadForEdit(config.UserConfigPath())
845 modelCfg := userCfg
846 if strings.TrimSpace(scope) == "project" && strings.TrimSpace(workspaceRoot) != "" {
847 if cfg, err := config.LoadForRootReadOnly(workspaceRoot); err == nil {
848 modelCfg = cfg
849 }
850 }
851 return resolveNewSessionModel(modelCfg), newSessionPreset(userCfg)
852 }
853
854 func newSessionPreset(userCfg *config.Config) string {
855 return normalizeToolApprovalMode(userCfg.DesktopDefaultToolApprovalMode())
856 }
857
858 // resolveNewSessionModel picks the model a fresh session starts on. A
859 // default_model that resolves but has no API key in the current environment
860 // would boot every new tab straight into the missing-key notice, so fall
861 // through to the first provider that is actually configured, mirroring the
862 // Configured() gate in Config.ResolveModelWithFallback's fallback chain. An
863 // allowed chat default is preserved when every eligible provider is keyless so
864 // the existing missing-key notice still tells the user what to fix. When no
865 // desktop-accessible chat model exists, the empty result lets tab startup show
866 // an actionable setup error instead of re-admitting an ineligible default.
867 func resolveNewSessionModel(cfg *config.Config) string {
868 def := strings.TrimSpace(cfg.DefaultModel)
869 config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, def)
870 if resolved, _, ok := cfg.ResolveDesktopNewSessionModel(); ok {
871 // Keep provider identity explicit at the new-session boundary. A bare
872 // model id is ambiguous when two configured gateways expose the same
873 // model, and a provider-only ref otherwise compares unequal to the
874 // canonical ref stored on a running tab.
875 if entry, found := cfg.ResolveModel(resolved); found {
876 return entry.Name + "/" + entry.Model
877 }
878 return resolved
879 }
880 return ""
881 }
882
883 func (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab {
884 model, toolApprovalMode := desktopNewSessionDefaults(scope, workspaceRoot)
885 return &WorkspaceTab{
886 ID: id,
887 Scope: scope,
888 WorkspaceRoot: workspaceRoot,
889 SessionWorkspace: desktopTabWorkspace{ID: desktopWorkspaceID(scope, workspaceRoot)},
890 TopicID: topicID,
891 TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),
892 topicTitleSource: loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID),
893 model: model,
894 qualityFloor: "",
895 mode: tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
896 toolApprovalMode: toolApprovalMode,
897 disabledMCP: map[string]ServerView{},
898 }
899 }
900
901 func (a *App) snapshotAllTabs() {
902 a.mu.RLock()
903 tabs := a.runtimeTabsLocked()
904 a.mu.RUnlock()
905 for _, t := range tabs {
906 if err := a.snapshotTab(t); err != nil {
907 slog.Warn("desktop: snapshot all tabs failed", "tab", t.ID, "err", err)
908 }
909 }
910 }
911
912 // shutdown snapshots all tabs, saves the final window geometry, and closes tabs.
913 func (a *App) shutdown(ctx context.Context) {
914 _, _ = a.requestShutdown(ctx, shutdownRequest{
915 RequestID: newDesktopLifecycleRunID(),
916 Reason: shutdownReasonUserQuit,
917 })
918 }
919
920 // domReady is called (via the shell's DOMReady hook) after the renderer
921 // finishes loading its DOM but before the hidden window is presented. It
922 // restores saved geometry, then delegates presentation to the shell
923 // coordinator.
924 func (a *App) domReady(_ context.Context) {
925 if a.desktopShell.coordinator != nil {
926 a.desktopShell.coordinator.markDOMReady()
927 }
928
929 a.restoreWindowGeometry()
930 a.showMainWindowFrom("startup_dom_ready")
931 }
932
933 func (a *App) completeFrontendStartup() {
934 a.markDesktopHealthy()
935 ctx := a.ctx
936 a.goSafe("recordHealthyConfig", func() {
937 timer := time.NewTimer(2 * time.Second)
938 defer timer.Stop()
939 select {
940 case <-timer.C:
941 case <-ctx.Done():
942 return
943 }
944 if err := a.commitPendingUpdateHealth(); err != nil {
945 slog.Warn("desktop: commit healthy update", "err", err)
946 }
947 if err := repair.RecordHealthyConfig(version); err != nil {
948 slog.Debug("desktop: record last-known-good config", "err", err)
949 }
950 if archived, err := archiveSupersededPendingUpdateAfterReady(); err != nil {
951 slog.Warn("desktop: retire superseded update", "err", err)
952 } else if archived {
953 slog.Info("desktop: archived superseded update transaction")
954 }
955 })
956 }
957
958 // ReportDesktopWebViewReady is the content-process heartbeat. DOMReady proves
959 // navigation completed; this bound call additionally proves that React and the
960 // host bridge are responsive after a renderer reload.
961 func (a *App) ReportDesktopWebViewReady() {
962 if a == nil || a.shuttingDown.Load() || a.forceQuit.Load() {
963 return
964 }
965 if a.desktopShell.coordinator != nil {
966 first, healthy := a.desktopShell.coordinator.markFrontendHeartbeat(time.Now())
967 if first {
968 a.goSafe("startDesktopTrayAfterFrontendReady", func() { a.startTray() })
969 }
970 if healthy {
971 a.completeFrontendStartup()
972 }
973 }
974 }
975
976 func (a *App) commitPendingUpdateHealth() error {
977 if a == nil || strings.TrimSpace(a.healthyUpdateCreatedAt) == "" ||
978 strings.TrimSpace(a.healthyUpdateTransactionID) == "" {
979 return nil
980 }
981 return markPendingUpdateHealthyAfterReady(
982 version,
983 a.healthyUpdateCreatedAt,
984 a.healthyUpdateTransactionID,
985 )
986 }
987
988 // bound command surface (frontend → controller)
989 // Each method guards on a nil controller so a pre-startup or failed-build call is
990 // a no-op, never a panic.
991
992 // Submit runs raw user input as a turn; slash commands and @-references are
993 // resolved by the controller. Output arrives asynchronously on eventChannel.
994 func (a *App) Submit(input string) error {
995 return a.SubmitToTab("", input)
996 }
997
998 var errEmptyTurnInput = errors.New("message cannot be empty")
999
1000 func validateTurnInput(input string) error {
1001 if strings.TrimSpace(input) == "" {
1002 return errEmptyTurnInput
1003 }
1004 return nil
1005 }
1006
1007 func (a *App) SubmitToTab(tabID, input string) error {
1008 if err := validateTurnInput(input); err != nil {
1009 return err
1010 }
1011 return a.submitToTab(tabID, input, false)
1012 }
1013
1014 // submitToTab is the shared submit body. fromBridge marks submissions driven
1015 // by the IM takeover bridge; local (frontend) submissions on a taken-over tab
1016 // reclaim remote control first — typing locally is the grab-back gesture.
1017 func (a *App) submitToTab(tabID, input string, fromBridge bool, submissionID ...string) error {
1018 _, err := a.submitToTabResult(tabID, input, fromBridge, false, submissionID...)
1019 return err
1020 }
1021
1022 func (a *App) submitUserTurnToTabWithSink(tabID, input string, forwarder event.Sink) bool {
1023 admission, ctrl, err := a.beginTabTurn(tabID, false)
1024 if err != nil {
1025 return false
1026 }
1027 defer admission.abort()
1028 tab := admission.tab
1029 var generation uint64
1030 if forwarder != nil {
1031 generation = tab.sink.SetBotSink(forwarder)
1032 }
1033 if err := a.ensureTabTopicIndexedForUserTurn(tab); err != nil {
1034 if forwarder != nil {
1035 tab.sink.clearBotSink(generation)
1036 }
1037 return false
1038 }
1039 ctrl.SubmitUserTurn(input, input)
1040 started := admission.finish(ctrl)
1041 if !started && forwarder != nil {
1042 tab.sink.clearBotSink(generation)
1043 }
1044 return started
1045 }
1046
1047 func (a *App) RunShellForTab(tabID, command string) error {
1048 admission, ctrl, err := a.beginTabTurn(tabID, true)
1049 if err != nil {
1050 return err
1051 }
1052 defer admission.abort()
1053 tab := admission.tab
1054 if err := a.ensureTabTopicIndexedForUserTurn(tab); err != nil {
1055 return err
1056 }
1057 ctrl.RunShell(command)
1058 admission.finish(ctrl)
1059 return nil
1060 }
1061
1062 // SubmitDisplay runs input as a turn while recording a shorter UI-only display
1063 // string for the saved desktop transcript. The model still receives input.
1064 func (a *App) SubmitDisplay(display, input string) error {
1065 return a.SubmitDisplayToTab("", display, input)
1066 }
1067
1068 func (a *App) SubmitDisplayToTab(tabID, display, input string) error {
1069 return a.submitDisplayToTab(tabID, display, input, "")
1070 }
1071
1072 func (a *App) SubmitDeliveryRecoveryToTab(tabID, display, input string) error {
1073 return a.submitDeliveryRecoveryToTab(tabID, display, input, "")
1074 }
1075
1076 // InvocationRequest is the Wails-bound form of a composer invocation entity.
1077 type InvocationRequest struct {
1078 Name string `json:"name"`
1079 Kind string `json:"kind"`
1080 Offset int `json:"offset"`
1081 }
1082
1083 func controlInvocationRequests(invocations []InvocationRequest) []control.InvocationRequest {
1084 out := make([]control.InvocationRequest, 0, len(invocations))
1085 for _, invocation := range invocations {
1086 out = append(out, control.InvocationRequest{
1087 Name: invocation.Name, Kind: invocation.Kind, Offset: invocation.Offset,
1088 })
1089 }
1090 return out
1091 }
1092
1093 func (a *App) SubmitInvocationsToTab(tabID, display, input string, invocations []InvocationRequest) error {
1094 return a.submitInvocationsToTab(tabID, display, input, invocations, "")
1095 }
1096
1097 func validateInvocationTurnInput(input string, invocations []InvocationRequest) error {
1098 // A skill-only turn legitimately has no explicit task: the resolved
1099 // invocation content becomes the provider input. Without an invocation,
1100 // keep the same empty-input protection as every other submit path.
1101 if len(invocations) > 0 {
1102 return nil
1103 }
1104 return validateTurnInput(input)
1105 }
1106
1107 func (a *App) submitInitialGoalToLocalTab(
1108 tabID, toolApprovalMode, goal, display, input string,
1109 invocations []InvocationRequest,
1110 submissionID ...string,
1111 ) ([]string, error) {
1112 req := control.SubmissionRequest{ID: firstSubmissionID(submissionID), Input: input, Display: display,
1113 Goal: strings.TrimSpace(goal), ToolApprovalMode: normalizeToolApprovalMode(toolApprovalMode), Invocations: controlInvocationRequests(invocations)}
1114 if found, err := a.knownSubmission(tabID, req); found || err != nil {
1115 return []string{}, err
1116 }
1117 admission, ctrl, err := a.beginTabTurn(tabID, true, submissionID...)
1118 if err != nil {
1119 return []string{}, a.submissionAdmissionError(tabID, req, err)
1120 }
1121 defer admission.abort()
1122
1123 tab := admission.tab
1124 toolApprovalMode = normalizeToolApprovalMode(toolApprovalMode)
1125 goal = strings.TrimSpace(goal)
1126 if goal == "" {
1127 return []string{}, fmt.Errorf("goal is required")
1128 }
1129 var drained []string
1130 setup := func() error {
1131 if err := syncTabGoalToController(ctrl, goal); err != nil {
1132 return fmt.Errorf("activate goal: %w", err)
1133 }
1134 a.mu.Lock()
1135 if a.tabs[tab.ID] != tab {
1136 a.mu.Unlock()
1137 return a.workspaceNotReadyErr(nil)
1138 }
1139 tab.toolApprovalMode = toolApprovalMode
1140 tab.goal = goal
1141 tab.mode = tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo)
1142 a.saveTabsLocked()
1143 a.mu.Unlock()
1144
1145 ctrl.SetPlanMode(false)
1146 drained = applyTabToolApprovalModeToController(ctrl, toolApprovalMode)
1147 return a.ensureTabTopicIndexedForUserTurn(tab)
1148 }
1149 if err := submitIdentifiedWithSetup(ctrl, req, setup, func() {
1150 if len(invocations) > 0 {
1151 ctrl.SubmitInvocationDisplay(display, input, controlInvocationRequests(invocations))
1152 } else {
1153 ctrl.SubmitDisplay(display, input)
1154 }
1155 }); err != nil {
1156 return []string{}, err
1157 }
1158 admission.finish(ctrl)
1159 return drained, nil
1160 }
1161
1162 // SubmitInitialGoalToTab activates a Goal and submits its first turn on the
1163 // requested tab.
1164 func (a *App) SubmitInitialGoalToTab(
1165 tabID, goal, display, input string,
1166 invocations []InvocationRequest,
1167 collaborationMode, toolApprovalMode string,
1168 ) ([]string, error) {
1169 if err := validateInvocationTurnInput(input, invocations); err != nil {
1170 return []string{}, err
1171 }
1172 return a.submitInitialGoalToLocalTab(
1173 tabID, toolApprovalMode, goal, display, input, invocations,
1174 )
1175 }
1176
1177 func (a *App) SubmitEditedDisplayToTab(tabID, display, input, original string) error {
1178 return a.submitEditedDisplayToTab(tabID, display, input, original, "")
1179 }
1180
1181 func (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) {
1182 if ctrl == nil {
1183 return
1184 }
1185 ctrl.SetDisplayRecorder(func(content, display string) {
1186 dir := ctrl.SessionDir()
1187 if dir == "" {
1188 dir = config.SessionDir()
1189 }
1190 _ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display)
1191 })
1192 }
1193
1194 // Cancel aborts the in-flight turn.
1195 func (a *App) Cancel() {
1196 a.CancelTab("")
1197 }
1198
1199 func (a *App) CancelTab(tabID string) {
1200 if ctrl := a.ctrlByTabID(tabID); ctrl != nil {
1201 ctrl.Cancel()
1202 }
1203 }
1204
1205 // Steer sends mid-turn guidance to the agent without interrupting the in-flight request.
1206 func (a *App) Steer(text string) error {
1207 return a.SteerForTab("", text)
1208 }
1209
1210 // SteerForTab sends mid-turn guidance to a specific tab's active agent turn.
1211 // A rejected steer is returned to the frontend so its guidance shelf retains
1212 // the text and submits it as a regular follow-up after the turn completes.
1213 func (a *App) SteerForTab(tabID, text string) error {
1214 tab, ctrl := a.tabAndCtrlByID(tabID)
1215 if a.tabIsReadOnly(tab) {
1216 return readOnlyChannelErr()
1217 }
1218 if ctrl == nil {
1219 return a.workspaceNotReadyErr(tab)
1220 }
1221 if err := a.ensureTabControllerWorkspace(tab); err != nil {
1222 return err
1223 }
1224 ctrl = a.controllerForTab(tab)
1225 if ctrl == nil {
1226 return a.workspaceNotReadyErr(tab)
1227 }
1228 steerer, ok := ctrl.(interface{ TrySteer(string) bool })
1229 if !ok {
1230 return fmt.Errorf("this runtime cannot accept mid-turn guidance")
1231 }
1232 if !steerer.TrySteer(text) {
1233 return fmt.Errorf("the turn ended before guidance could be applied; it will remain queued for the next turn")
1234 }
1235 return nil
1236 }
1237
1238 func (a *App) tabAndCtrlByID(tabID string) (*WorkspaceTab, control.SessionAPI) {
1239 a.mu.RLock()
1240 tab := a.tabByIDLocked(tabID)
1241 if tab == nil {
1242 a.mu.RUnlock()
1243 return nil, nil
1244 }
1245 ctrl := tab.Ctrl
1246 retryStartup := ctrl == nil && (tab.StartupErrLeaseHeld || tab.modelApplication.startupRetry)
1247 a.mu.RUnlock()
1248 if retryStartup && a.tryRecoverStartupLeaseHeldTab(tab) {
1249 a.mu.RLock()
1250 defer a.mu.RUnlock()
1251 if a.tabs[tab.ID] != tab {
1252 return nil, nil
1253 }
1254 return tab, tab.Ctrl
1255 }
1256 return tab, ctrl
1257 }
1258
1259 // activeTabAndCtrl snapshots the active tab and its controller in one locked
1260 // read, so callers never do a check-then-use on tab.Ctrl after the lock is
1261 // released (a rebuild can swap the controller in between).
1262 func (a *App) activeTabAndCtrl() (*WorkspaceTab, control.SessionAPI) {
1263 a.mu.RLock()
1264 defer a.mu.RUnlock()
1265 tab := a.activeTabLocked()
1266 if tab == nil {
1267 return nil, nil
1268 }
1269 return tab, tab.Ctrl
1270 }
1271
1272 // activeMCPRuntime snapshots the complete target of a Wails MCP action in one
1273 // critical section. MCP operations may outlive a frontend tab switch; carrying
1274 // the invoking workspace root prevents config/authorization reads from drifting to the
1275 // newly active tab while controller calls still target the original runtime.
1276 // mcpAppsSandboxAvailable reports whether Desktop may declare the Apps
1277 // capability profile for newly acquired shared hosts.
1278 func (a *App) mcpAppsSandboxAvailable() bool { return a.mcpAppsSandbox.available() }
1279
1280 func (a *App) activeMCPRuntime() (*WorkspaceTab, control.SessionAPI, string) {
1281 a.mu.RLock()
1282 defer a.mu.RUnlock()
1283 tab := a.activeTabLocked()
1284 if tab == nil {
1285 return nil, nil, ""
1286 }
1287 return tab, tab.Ctrl, tab.WorkspaceRoot
1288 }
1289
1290 func (a *App) controllerForTab(tab *WorkspaceTab) control.SessionAPI {
1291 if tab == nil {
1292 return nil
1293 }
1294 a.mu.RLock()
1295 defer a.mu.RUnlock()
1296 if tab.ID != "" && !a.ownsRuntimeTabLocked(tab) {
1297 return nil
1298 }
1299 return tab.Ctrl
1300 }
1301
1302 // currentSessionPathFor is the locked form of tab.currentSessionPath: it
1303 // snapshots Ctrl/SessionPath under a.mu, then queries the controller off-lock.
1304 // Use it on paths that do not otherwise hold a.mu.
1305 func (a *App) currentSessionPathFor(tab *WorkspaceTab) string {
1306 if tab == nil {
1307 return ""
1308 }
1309 a.mu.RLock()
1310 ctrl := tab.Ctrl
1311 fallback := strings.TrimSpace(tab.SessionPath)
1312 a.mu.RUnlock()
1313 if ctrl != nil {
1314 if path := strings.TrimSpace(ctrl.SessionPath()); path != "" {
1315 return path
1316 }
1317 }
1318 return fallback
1319 }
1320
1321 // sessionDirForSnapshot mirrors tabSessionDir for callers that hold a
1322 // tabRuntimeSnapshot instead of reading the live tab.
1323 func sessionDirForSnapshot(s tabRuntimeSnapshot) string {
1324 if s.workspaceRoot != "" {
1325 return desktopSessionDir(s.workspaceRoot)
1326 }
1327 if s.ctrl != nil {
1328 if dir := s.ctrl.SessionDir(); dir != "" {
1329 return dir
1330 }
1331 }
1332 return desktopSessionDir("")
1333 }
1334
1335 func readOnlyChannelErr() error {
1336 return fmt.Errorf("channel session is read-only")
1337 }
1338
1339 func (a *App) snapshotTab(tab *WorkspaceTab) error {
1340 if tab == nil {
1341 return nil
1342 }
1343 a.mu.RLock()
1344 readOnly := tab.ReadOnly
1345 ctrl := tab.Ctrl
1346 a.mu.RUnlock()
1347 if readOnly || ctrl == nil || historicalPreview(ctrl) {
1348 return nil
1349 }
1350 return ctrl.Snapshot()
1351 }
1352
1353 func (a *App) snapshotTabForAction(tab *WorkspaceTab, action string) error {
1354 if err := a.snapshotTab(tab); err != nil {
1355 a.reportTabSnapshotError(tab, action, err)
1356 if strings.TrimSpace(action) == "" {
1357 return fmt.Errorf("save current session: %w", err)
1358 }
1359 return fmt.Errorf("save current session before %s: %w", action, err)
1360 }
1361 return nil
1362 }
1363
1364 func (a *App) reportTabSnapshotError(tab *WorkspaceTab, action string, err error) {
1365 if err == nil {
1366 return
1367 }
1368 tabID := ""
1369 if tab != nil {
1370 tabID = tab.ID
1371 }
1372 slog.Warn("desktop: session snapshot failed", "tab", tabID, "action", action, "err", err)
1373 if tab == nil || tab.sink == nil {
1374 return
1375 }
1376 // Autosave fires once per turn; on a persistently failing disk that would
1377 // stream a chat warning after every turn. Rate-limit the user-facing
1378 // notice per tab (the slog line above always records every failure). Saves
1379 // triggered by an explicit action are one-shot and always surface.
1380 if action == "autosave" {
1381 tab.saveMu.Lock()
1382 now := time.Now()
1383 if !tab.lastAutosaveWarnAt.IsZero() && now.Sub(tab.lastAutosaveWarnAt) < autosaveWarnInterval {
1384 tab.saveMu.Unlock()
1385 return
1386 }
1387 tab.lastAutosaveWarnAt = now
1388 tab.saveMu.Unlock()
1389 }
1390 prefix := "Session autosave failed"
1391 if strings.TrimSpace(action) != "" && action != "autosave" {
1392 prefix = "Session save failed before " + action
1393 }
1394 tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: prefix + ": " + err.Error()})
1395 }
1396
1397 func (a *App) reconciledSessionPathForTab(tab *WorkspaceTab) string {
1398 if tab == nil {
1399 return ""
1400 }
1401 path, _ := a.reconcileTabWithPinnedSessionMeta(tab)
1402 if ctrl := a.controllerForTab(tab); path == "" && ctrl != nil {
1403 path = ctrl.SessionPath()
1404 }
1405 return path
1406 }
1407
1408 func (a *App) ensureTabControllerWorkspace(tab *WorkspaceTab) error {
1409 if tab == nil {
1410 return nil
1411 }
1412 tab.reconcileMu.Lock()
1413 defer tab.reconcileMu.Unlock()
1414
1415 a.mu.RLock()
1416 current := a.tabs[tab.ID]
1417 ctrl := tab.Ctrl
1418 readOnly := tab.ReadOnly
1419 a.mu.RUnlock()
1420 if current != tab || ctrl == nil || readOnly {
1421 return nil
1422 }
1423 if controllerHasActiveRuntimeWork(ctrl) {
1424 return nil
1425 }
1426 path, hasBinding := a.reconcileTabWithPinnedSessionMeta(tab)
1427 desiredRoot := strings.TrimSpace(tab.WorkspaceRoot)
1428 ctrlRoot, rootOK := safeControllerWorkspaceRoot(ctrl)
1429 ctrlDir, dirOK := safeControllerSessionDir(ctrl)
1430 if !rootOK || !dirOK {
1431 return nil
1432 }
1433 if !hasBinding {
1434 if desiredRoot == "" || strings.TrimSpace(ctrlRoot) == "" || sameDesktopPath(ctrlRoot, desiredRoot) {
1435 return nil
1436 }
1437 }
1438 desiredDir := tabSessionDir(tab)
1439 rootMatches := desiredRoot == "" || sameDesktopPath(ctrlRoot, desiredRoot)
1440 dirMatches := controllerSessionDirectoryMatches(desiredDir, ctrlDir, path)
1441 if strings.TrimSpace(ctrlRoot) == "" && dirMatches {
1442 rootMatches = true
1443 }
1444 if tab.Scope == "global" {
1445 if strings.TrimSpace(ctrlRoot) == "" {
1446 rootMatches = true
1447 }
1448 if sameDesktopPath(ctrlDir, config.SessionDir()) || sameDesktopPath(ctrlDir, desktopSessionDir(globalWorkspaceRoot())) {
1449 dirMatches = true
1450 }
1451 }
1452 sessionMatches := a.controllerMatchesSessionPath(ctrl, path)
1453 if rootMatches && dirMatches && sessionMatches {
1454 return nil
1455 }
1456 if !historicalPreview(ctrl) {
1457 if err := ctrl.Snapshot(); err != nil {
1458 return err
1459 }
1460 }
1461 ctrl.Close()
1462
1463 a.mu.Lock()
1464 var hostKey string
1465 if current := a.tabs[tab.ID]; current == tab {
1466 tab.Ctrl = nil
1467 tab.Ready = false
1468 clearTabStartupError(tab)
1469 tab.ActivityStatus = ""
1470 if tab.sink == nil {
1471 tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
1472 }
1473 hostKey = takeTabSharedHostKey(tab)
1474 a.saveTabsLocked()
1475 }
1476 a.mu.Unlock()
1477 if hostKey != "" {
1478 a.releaseSharedHost(hostKey)
1479 }
1480
1481 a.buildTabController(tab)
1482 if tab.Ctrl == nil {
1483 if tab.StartupErr != "" {
1484 return fmt.Errorf("workspace failed to restart with corrected root: %s", tab.StartupErr)
1485 }
1486 return fmt.Errorf("workspace failed to restart with corrected root")
1487 }
1488 return nil
1489 }
1490
1491 func safeControllerWorkspaceRoot(ctrl control.SessionAPI) (root string, ok bool) {
1492 if ctrl == nil {
1493 return "", false
1494 }
1495 defer func() {
1496 if recover() != nil {
1497 root = ""
1498 ok = false
1499 }
1500 }()
1501 return ctrl.WorkspaceRoot(), true
1502 }
1503
1504 func safeControllerSessionDir(ctrl control.SessionAPI) (dir string, ok bool) {
1505 if ctrl == nil {
1506 return "", false
1507 }
1508 defer func() {
1509 if recover() != nil {
1510 dir = ""
1511 ok = false
1512 }
1513 }()
1514 return ctrl.SessionDir(), true
1515 }
1516
1517 // Approve answers a pending approval_request by ID: allow runs the call, session
1518 // also remembers the grant for the rest of the session.
1519 func (a *App) Approve(id string, allow, session, persist bool) {
1520 ctrl := a.ctrlByTabID("")
1521 if ctrl != nil {
1522 ctrl.Approve(id, allow, session, persist)
1523 }
1524 }
1525
1526 // ApproveTab is like Approve but scoped to a specific tab.
1527 func (a *App) ApproveTab(tabID, id string, allow, session, persist bool) {
1528 ctrl := a.ctrlForRuntimeTabID(tabID)
1529 if ctrl != nil {
1530 ctrl.Approve(id, allow, session, persist)
1531 }
1532 }
1533
1534 // ResolvePlanDecision answers a Plan card while preserving whether the user
1535 // chose to start execution, revise the plan, or exit without executing.
1536 func (a *App) ResolvePlanDecision(id, action string) error {
1537 ctrl := a.ctrlByTabID("")
1538 if ctrl == nil {
1539 return fmt.Errorf("no active session")
1540 }
1541 return ctrl.ResolvePlanDecision(id, control.PlanDecisionAction(action))
1542 }
1543
1544 // ResolvePlanDecisionTab is like ResolvePlanDecision but scoped to a runtime
1545 // tab so a delayed bridge call cannot answer a prompt in another tab.
1546 func (a *App) ResolvePlanDecisionTab(tabID, id, action string) error {
1547 ctrl := a.ctrlForRuntimeTabID(tabID)
1548 if ctrl == nil {
1549 return fmt.Errorf("no active session")
1550 }
1551 return ctrl.ResolvePlanDecision(id, control.PlanDecisionAction(action))
1552 }
1553
1554 // ResolveRecovery retains the old bridge signature. The controller returns a
1555 // stable recovery_retired error and never confirms or replays an operation.
1556 func (a *App) ResolveRecovery(id, action, feedback string) error {
1557 return a.ResolveRecoveryTab("", id, action, feedback)
1558 }
1559
1560 // ResolveRecoveryTab is like ResolveRecovery but scoped to a specific tab.
1561 func (a *App) ResolveRecoveryTab(tabID, id, action, feedback string) error {
1562 ctrl := a.ctrlByTabID(tabID)
1563 if ctrl == nil {
1564 return fmt.Errorf("no active session")
1565 }
1566 return ctrl.ResolveRecovery(id, agent.RecoveryAction(action), feedback)
1567 }
1568
1569 // SetRecoveryCheckpointEnabled is retained as a no-op Wails surface for older
1570 // generated frontends. Auto Guard is retired.
1571 func (a *App) SetRecoveryCheckpointEnabled(_ bool) {}
1572
1573 // SetRecoveryCheckpointEnabledTab is retained as a no-op Wails surface.
1574 func (a *App) SetRecoveryCheckpointEnabledTab(_ string, _ bool) {}
1575
1576 // RecoveryCheckpointEnabled is retained for older generated frontends and
1577 // reports false because no runtime recovery checkpoint can be enabled.
1578 func (a *App) RecoveryCheckpointEnabled() bool {
1579 return false
1580 }
1581
1582 // RecoveryCheckpointEnabledTab is the tab-scoped compatibility alias.
1583 func (a *App) RecoveryCheckpointEnabledTab(_ string) bool {
1584 return false
1585 }
1586
1587 // ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask
1588 // prompt that is currently blocking its run loop. The frontend calls this once
1589 // its event subscription is live (on load/reconnect) so a session that was
1590 // already awaiting confirmation rebuilds its modal instead of showing a
1591 // "waiting" status with no way to answer — and no way to stop.
1592 func (a *App) ReplayPendingPrompts() {
1593 a.mu.RLock()
1594 tabs := a.runtimeTabsLocked()
1595 ctrls := make([]control.SessionAPI, 0, len(tabs))
1596 for _, t := range tabs {
1597 if t.Ctrl != nil {
1598 ctrls = append(ctrls, t.Ctrl)
1599 }
1600 }
1601 a.mu.RUnlock()
1602 for _, ctrl := range ctrls {
1603 ctrl.ReplayPendingPrompts()
1604 }
1605 }
1606
1607 // ReplayPendingPromptsForTab re-emits only the prompt owned by tabID. Tab
1608 // switches use this scoped form so a background session's ask/approval cannot
1609 // depend on whichever tab happens to be backend-active when the replay RPC
1610 // arrives. ReplayPendingPrompts remains bound for reconnect compatibility.
1611 func (a *App) ReplayPendingPromptsForTab(tabID string) {
1612 ctrl := a.ctrlByTabID(tabID)
1613 if ctrl != nil {
1614 ctrl.ReplayPendingPrompts()
1615 }
1616 }
1617
1618 // SetPlanMode toggles the plan-first workflow while preserving the current
1619 // tool-approval posture and sandbox settings.
1620 func (a *App) SetPlanMode(on bool) {
1621 a.setPlanModeForTab("", on)
1622 }
1623
1624 func (a *App) setPlanModeForTab(tabID string, on bool) {
1625 if on {
1626 _ = a.SetCollaborationModeForTab(tabID, "plan")
1627 return
1628 }
1629 _ = a.SetCollaborationModeForTab(tabID, "normal")
1630 }
1631
1632 // SetMode applies a composer gating mode ("plan" | "yolo" | "plan-yolo" |
1633 // anything else =
1634 // normal) in one call, so a turn submitted right after the switch can't race a
1635 // half-applied plan/tool-auto-approval pair.
1636 func (a *App) SetMode(mode string) {
1637 a.SetModeForTab("", mode)
1638 }
1639
1640 // SetModeForTab returns the pending approval prompt ids the switch
1641 // auto-allowed, so the frontend dismisses exactly those cards and keeps the
1642 // ones the backend still holds (plan/memory/sandbox-escape never drain, and
1643 // auto keeps approvals an allow policy would not cover — #6432).
1644 func (a *App) SetModeForTab(tabID, mode string) []string {
1645 tab := a.tabByID(tabID)
1646 if tab == nil {
1647 return nil
1648 }
1649 tab.turnStartMu.Lock()
1650 defer tab.turnStartMu.Unlock()
1651 normalized := normalizeTabMode(mode)
1652 a.mu.Lock()
1653 if a.tabs[tab.ID] != tab {
1654 a.mu.Unlock()
1655 return nil
1656 }
1657 tab.mode = normalized
1658 tab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode)
1659 if tabModeHasAutoApproveTools(normalized) {
1660 tab.toolApprovalMode = control.ToolApprovalYolo
1661 } else if tab.toolApprovalMode == control.ToolApprovalYolo {
1662 tab.toolApprovalMode = control.ToolApprovalAsk
1663 }
1664 ctrl := tab.Ctrl
1665 approvalMode := tab.toolApprovalMode
1666 tabIDForSave := tab.ID
1667 a.mu.Unlock()
1668 drained := applyTabModeToController(ctrl, normalized)
1669 drained = append(drained, applyTabToolApprovalModeToController(ctrl, approvalMode)...)
1670 a.mu.Lock()
1671 if a.tabs[tabIDForSave] == tab {
1672 a.saveTabsLocked()
1673 }
1674 a.mu.Unlock()
1675 return drained
1676 }
1677
1678 // modeApplier / toolApprovalApplier are the drained-id-reporting variants of
1679 // SessionAPI's SetMode / SetToolApprovalMode. Asserted optionally so test
1680 // fakes implementing the plain SessionAPI keep compiling (they report nil).
1681 type modeApplier interface {
1682 ApplyMode(plan, autoApproveTools bool) []string
1683 }
1684
1685 type toolApprovalApplier interface {
1686 ApplyToolApprovalMode(mode string) []string
1687 }
1688
1689 func applyTabModeToController(ctrl control.SessionAPI, mode string) []string {
1690 if ctrl == nil {
1691 return nil
1692 }
1693 plan := false
1694 switch normalizeTabMode(mode) {
1695 case "plan":
1696 plan = true
1697 case "yolo":
1698 // Legacy persisted Yolo is conservatively migrated to workspace-write.
1699 case "plan-yolo":
1700 plan = true
1701 }
1702 if applier, ok := ctrl.(modeApplier); ok {
1703 return applier.ApplyMode(plan, false)
1704 }
1705 ctrl.SetMode(plan, false)
1706 return nil
1707 }
1708
1709 func applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) []string {
1710 if ctrl == nil {
1711 return nil
1712 }
1713 mode = normalizeToolApprovalMode(mode)
1714 if applier, ok := ctrl.(toolApprovalApplier); ok {
1715 return applier.ApplyToolApprovalMode(mode)
1716 }
1717 ctrl.SetToolApprovalMode(mode)
1718 return nil
1719 }
1720
1721 func normalizeCollaborationMode(mode string) string {
1722 switch strings.ToLower(strings.TrimSpace(mode)) {
1723 case "plan":
1724 return "plan"
1725 case "goal":
1726 return "goal"
1727 default:
1728 return "normal"
1729 }
1730 }
1731
1732 // SetComposerProfileForTab applies the controller-facing profile axes under one
1733 // turn gate. Frontends use this before submit and after controller rebuilds so a
1734 // turn cannot observe collaboration, approval, and goal from different UI
1735 // generations.
1736 func (a *App) SetComposerProfileForTab(tabID, collaborationMode, toolApprovalMode, goal string) ([]string, error) {
1737 if a.isRemoteTab(tabID) {
1738 return []string{}, nil
1739 }
1740 collaborationMode = normalizeCollaborationMode(collaborationMode)
1741 toolApprovalMode = normalizeToolApprovalMode(toolApprovalMode)
1742 goal = strings.TrimSpace(goal)
1743
1744 tab := a.tabByID(tabID)
1745 if tab == nil {
1746 return []string{}, fmt.Errorf("tab is no longer available")
1747 }
1748 tab.turnStartMu.Lock()
1749 defer tab.turnStartMu.Unlock()
1750
1751 a.mu.Lock()
1752 if a.tabs[tab.ID] != tab {
1753 a.mu.Unlock()
1754 return []string{}, fmt.Errorf("tab is no longer available")
1755 }
1756 ctrl := tab.Ctrl
1757 tabIDForSave := tab.ID
1758 a.mu.Unlock()
1759
1760 plan := collaborationMode == "plan" && goal == ""
1761 var drained []string
1762 if concrete, ok := ctrl.(*control.Controller); ok && concrete != nil {
1763 var err error
1764 drained, err = concrete.ApplyComposerProfileAt(plan, toolApprovalMode, goal, concrete.PermissionSnapshot().Revision)
1765 if err != nil {
1766 return []string{}, err
1767 }
1768 } else {
1769 if ctrl != nil {
1770 ctrl.SetPlanMode(plan)
1771 }
1772 drained = applyTabToolApprovalModeToController(ctrl, toolApprovalMode)
1773 if err := syncTabGoalToController(ctrl, goal); err != nil {
1774 return []string{}, err
1775 }
1776 }
1777
1778 a.mu.Lock()
1779 if a.tabs[tabIDForSave] == tab {
1780 tab.toolApprovalMode = toolApprovalMode
1781 tab.goal = goal
1782 tab.mode = tabModeFromAxes(plan, toolApprovalMode == control.ToolApprovalDangerFullAccess)
1783 a.saveTabsLocked()
1784 }
1785 a.mu.Unlock()
1786 if drained == nil {
1787 return []string{}, nil
1788 }
1789 return drained, nil
1790 }
1791
1792 func (a *App) SetCollaborationModeForTab(tabID, mode string) error {
1793 tab := a.tabByID(tabID)
1794 if tab == nil {
1795 return a.workspaceNotReadyErr(nil)
1796 }
1797 tab.turnStartMu.Lock()
1798 defer tab.turnStartMu.Unlock()
1799 mode = normalizeCollaborationMode(mode)
1800 approvalMode := a.tabRuntimeSnapshot(tab).currentToolApprovalMode()
1801 a.mu.Lock()
1802 if a.tabs[tab.ID] != tab {
1803 a.mu.Unlock()
1804 return a.workspaceNotReadyErr(nil)
1805 }
1806 nextGoal := tab.goal
1807 nextMode := tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo)
1808 if mode == "plan" {
1809 nextMode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo)
1810 nextGoal = ""
1811 } else if mode != "goal" {
1812 nextGoal = ""
1813 }
1814 ctrl := tab.Ctrl
1815 plan := tabModeHasPlan(nextMode)
1816 tabIDForSave := tab.ID
1817 a.mu.Unlock()
1818 if ctrl != nil {
1819 if err := syncTabGoalToController(ctrl, nextGoal); err != nil {
1820 return err
1821 }
1822 ctrl.SetPlanMode(plan)
1823 }
1824 a.mu.Lock()
1825 if a.tabs[tabIDForSave] == tab {
1826 tab.mode = nextMode
1827 tab.goal = nextGoal
1828 a.saveTabsLocked()
1829 }
1830 a.mu.Unlock()
1831 return nil
1832 }
1833
1834 // QuestionAnswer is the frontend's reply to one question in an ask_request.
1835 type QuestionAnswer struct {
1836 QuestionID string `json:"questionId"`
1837 Selected []string `json:"selected"`
1838 }
1839
1840 // AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the
1841 // user's selections per question.
1842 func (a *App) AnswerQuestion(id string, answers []QuestionAnswer) {
1843 a.AnswerQuestionForTab("", id, answers)
1844 }
1845
1846 func (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) {
1847 ctrl := a.ctrlByTabID(tabID)
1848 if ctrl == nil {
1849 return
1850 }
1851 out := make([]event.AskAnswer, len(answers))
1852 for i, an := range answers {
1853 out[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}
1854 }
1855 ctrl.AnswerQuestion(id, out)
1856 }
1857
1858 // Compact runs a plain compaction pass (the "compact now" button). Focus-guided
1859 // compaction goes through Submit("/compact <focus>") instead.
1860 func (a *App) Compact() error {
1861 return a.CompactForTab("")
1862 }
1863
1864 // CompactForTab compacts the requested tab without depending on which tab is
1865 // focused when the asynchronous frontend call reaches the backend.
1866 func (a *App) CompactForTab(tabID string) error {
1867 tab, ctrl := a.tabAndCtrlByID(tabID)
1868 if a.tabIsReadOnly(tab) {
1869 return readOnlyChannelErr()
1870 }
1871 if ctrl == nil {
1872 return nil
1873 }
1874 if err := a.ensureTabControllerWorkspace(tab); err != nil {
1875 return err
1876 }
1877 ctrl = a.controllerForTab(tab)
1878 if ctrl == nil {
1879 return nil
1880 }
1881 return ctrl.Compact(a.ctx, "")
1882 }
1883
1884 // workspaceNotReadyErr names why a session action arrived before the tab's
1885 // controller existed: still starting, or failed to start. Silently returning
1886 // nil here swallowed the click with no feedback (#3938).
1887 //
1888 // This is the bound-method form: StartupErr is written under a.mu by the
1889 // build goroutine while Submit-family calls race it, so read it under the
1890 // lock. Callers must not hold a.mu.
1891 func (a *App) workspaceNotReadyErr(tab *WorkspaceTab) error {
1892 a.mu.RLock()
1893 defer a.mu.RUnlock()
1894 return a.workspaceNotReadyErrLocked(tab)
1895 }
1896
1897 func (a *App) workspaceNotReadyErrLocked(tab *WorkspaceTab) error {
1898 startupErr := ""
1899 var issue *SessionRuntimeIssue
1900 if tab != nil {
1901 startupErr = tab.StartupErr
1902 issue = a.sessionRuntimeViewLocked(tab).Issue
1903 }
1904 if strings.TrimSpace(startupErr) != "" {
1905 return fmt.Errorf("workspace failed to start: %s", startupErr)
1906 }
1907 if issue != nil && strings.TrimSpace(issue.Message) != "" {
1908 return fmt.Errorf("workspace failed to start: %s", issue.Message)
1909 }
1910 return fmt.Errorf("workspace is still starting")
1911 }
1912
1913 // tabIsReadOnly reads tab.ReadOnly under a.mu; setTabReadOnly can flip it
1914 // concurrently with Submit-family bound calls. Callers must not hold a.mu.
1915 func (a *App) tabIsReadOnly(tab *WorkspaceTab) bool {
1916 if tab == nil {
1917 return false
1918 }
1919 a.mu.RLock()
1920 defer a.mu.RUnlock()
1921 return tab.ReadOnly
1922 }
1923
1924 // applyNewSessionDefaultModel makes a freshly rotated or reused blank session
1925 // obey the same default as EnsureBlankTab. Existing conversations keep their
1926 // saved model until the user starts a new one.
1927 func (a *App) applyNewSessionDefaultModel(tab *WorkspaceTab) error {
1928 if tab == nil {
1929 return nil
1930 }
1931 a.mu.RLock()
1932 scope := tab.Scope
1933 root := tab.WorkspaceRoot
1934 a.mu.RUnlock()
1935 if strings.TrimSpace(scope) != "project" {
1936 scope = "global"
1937 root = ""
1938 }
1939 defaultModel, _ := desktopNewSessionDefaults(scope, root)
1940 return a.alignReusableBlankTabModel(tab, defaultModel)
1941 }
1942
1943 func (a *App) assignFreshSessionTopic(tab *WorkspaceTab) error {
1944 if tab == nil {
1945 return nil
1946 }
1947 topicID := newTopicID()
1948 a.mu.Lock()
1949 scope := tab.Scope
1950 workspaceRoot := tab.WorkspaceRoot
1951 sessionID := tab.SessionID
1952 if tab.SessionWorkspace.ID == "" {
1953 // Legacy controllers still persist their topic through branch metadata.
1954 sessionID = ""
1955 }
1956 tab.TopicID = topicID
1957 tab.TopicTitle = defaultTopicTitle
1958 tab.topicTitleSource = topicTitleSourceAuto
1959 if current := a.tabs[tab.ID]; current == tab {
1960 a.saveTabsLocked()
1961 }
1962 a.mu.Unlock()
1963 if err := a.workspaceRegistry().EnsureSessionTopic(a.bootContext(), sessionID, topicID, defaultTopicTitle); err != nil {
1964 return err
1965 }
1966 if strings.TrimSpace(scope) == "global" {
1967 workspaceRoot = ""
1968 } else {
1969 workspaceRoot = normalizeProjectRoot(workspaceRoot)
1970 }
1971 // NewSession already rotated the runtime to a fresh session. If the sidebar
1972 // topic index repair fails here, keep the session usable and let persisted
1973 // session metadata repair the topic index later instead of surfacing a false
1974 // "new session failed" error to the frontend.
1975 _ = ensureTopicIndexedWithCreatedAt(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto, time.Now().UnixMilli())
1976 return nil
1977 }
1978
1979 func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) error {
1980 if tab == nil {
1981 return nil
1982 }
1983 topicID := newTopicID()
1984 a.mu.Lock()
1985 if strings.TrimSpace(tab.TopicID) != "" {
1986 a.mu.Unlock()
1987 return a.persistCanonicalTopicForTab(tab)
1988 }
1989 scope := tab.Scope
1990 workspaceRoot := tab.WorkspaceRoot
1991 tab.TopicID = topicID
1992 tab.TopicTitle = defaultTopicTitle
1993 tab.topicTitleSource = topicTitleSourceAuto
1994 if current := a.tabs[tab.ID]; current == tab {
1995 a.saveTabsLocked()
1996 }
1997 a.mu.Unlock()
1998 if err := a.persistCanonicalTopicForTab(tab); err != nil {
1999 return err
2000 }
2001 if strings.TrimSpace(scope) == "global" {
2002 scope = "global"
2003 workspaceRoot = ""
2004 } else {
2005 scope = "project"
2006 workspaceRoot = normalizeProjectRoot(workspaceRoot)
2007 }
2008
2009 _ = ensureTopicIndexedWithCreatedAt(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto, time.Now().UnixMilli())
2010 path := a.currentSessionPathFor(tab)
2011 a.persistTabSessionPath(tab, path)
2012 a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(path))
2013 return nil
2014 }
2015
2016 func (a *App) persistCanonicalTopicForTab(tab *WorkspaceTab) error {
2017 a.mu.RLock()
2018 sessionID, workspaceID, topicID, title := tab.SessionID, tab.SessionWorkspace.ID, tab.TopicID, tab.TopicTitle
2019 a.mu.RUnlock()
2020 if workspaceID == "" {
2021 return nil
2022 }
2023 return a.workspaceRegistry().EnsureSessionTopic(a.bootContext(), sessionID, topicID, title)
2024 }
2025
2026 func messagesHaveConversationContent(messages []provider.Message) bool {
2027 for _, msg := range messages {
2028 if msg.Role != provider.RoleSystem {
2029 return true
2030 }
2031 }
2032 return false
2033 }
2034
2035 func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) (SessionClearResult, error) {
2036 if tab == nil || oldCtrl == nil {
2037 return SessionClearResult{}, fmt.Errorf("workspace is still starting")
2038 }
2039 // This is a build+swap of the tab's controller; serialize with the other
2040 // rebuild paths (see runtimeRebuildMu) so a concurrent model/effort/settings
2041 // rebuild cannot interleave a second swap. Lock order:
2042 // runtimeRebuildMu → sessionRemovalMu (no path acquires them in reverse).
2043 a.runtimeRebuildMu.Lock()
2044 defer a.runtimeRebuildMu.Unlock()
2045 tab.turnStartMu.Lock()
2046 defer tab.turnStartMu.Unlock()
2047 // This path destroys the old session's files (removeDesktopSessionArtifacts);
2048 // serialize with DeleteSession/TrashTopic/workspace removal so they never
2049 // trash or restore the same files mid-clear.
2050 a.sessionRemovalMu.Lock()
2051 defer a.sessionRemovalMu.Unlock()
2052
2053 if _, _, exclusive := exclusiveSessionBinding(oldCtrl); exclusive {
2054 if oldCtrl.RuntimeStatus().Cancellable {
2055 oldCtrl.Cancel()
2056 if err := waitControllerStopped(oldCtrl); err != nil {
2057 return SessionClearResult{}, err
2058 }
2059 }
2060 if err := oldCtrl.ClearSession(); err != nil {
2061 return SessionClearResult{}, err
2062 }
2063 a.syncTabSessionIdentity(tab, oldCtrl)
2064 tab.setPinnedFiles(nil)
2065 a.clearTabGoal(tab)
2066 tab.resetTelemetry(tab.currentSessionIdentity())
2067 a.invalidatePromptHistoryCache()
2068 a.notifyTabRuntimeRebuilt(tab)
2069 return a.bumpAndSnapshotSessionClear(tab), nil
2070 }
2071
2072 return a.clearLegacySessionRuntimeLocked(tab, oldCtrl)
2073 }
2074
2075 func (a *App) clearLegacySessionRuntimeLocked(tab *WorkspaceTab, oldCtrl control.SessionAPI) (SessionClearResult, error) {
2076 a.reconciledSessionPathForTab(tab)
2077 oldPath := oldCtrl.SessionPath()
2078 // Snapshot the tab profile under a.mu: bound methods write these fields
2079 // under the lock while this rebuild runs off-lock.
2080 snap := a.tabRuntimeSnapshot(tab)
2081 oldSink := snap.sink
2082 if oldSink != nil {
2083 // Rebind under the runtime key, matching the id cloneDetachedRuntimeTab
2084 // derives — a raw path here would hash to a different detached id on
2085 // Windows where keys are case-folded.
2086 oldSink.setBinding(detachedRuntimeTabID(sessionRuntimeKey(oldPath)), nil)
2087 oldSink.clearContext()
2088 }
2089 if oldCtrl.RuntimeStatus().Cancellable {
2090 oldCtrl.Cancel()
2091 if err := waitControllerStopped(oldCtrl); err != nil {
2092 return SessionClearResult{}, err
2093 }
2094 }
2095 destroy := oldCtrl.BeginDestroySession(oldPath)
2096 destroys := []control.SessionDestroyHandle{destroy}
2097 teardownTimedOut := waitDestroyHandles(destroys)
2098 if teardownTimedOut {
2099 if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil {
2100 return SessionClearResult{}, err
2101 }
2102 }
2103
2104 newSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
2105 sharedHost := a.lookupSharedHost(snap.sharedHostKey)
2106 newCtrl, err := a.buildTabControllerBoot(a.bootContext(), boot.Options{
2107 Model: snap.model,
2108 RequireKey: false,
2109 StatsSource: "desktop",
2110 TaskStore: a.taskStore(),
2111 OnConfigLoadWarnings: a.configLoadWarningsHandler(),
2112 Sink: newSink,
2113 WorkspaceRoot: snap.workspaceRoot,
2114 SessionDir: sessionDirForSnapshot(snap),
2115 EffortOverride: cloneStringPtr(snap.effort),
2116 EffortModel: snap.model,
2117 SharedHost: sharedHost, BrowserExecutor: a.browserExecutorForRuntime(tab.ID, newSink),
2118 MCPHostProfile: plugin.HostProfileDesktopApps,
2119 CleanupPendingReconciler: reconcileDesktopCleanupPending,
2120 SubagentParentLive: a.subagentParentProbeForBuild(tab),
2121 SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
2122 PinnedContextLoader: pinnedContextLoader(snap.workspaceRoot),
2123 OnSessionRecovered: a.handleTabSessionRecovered(tab),
2124 OnSessionTransition: a.handleTabSessionTransition(tab),
2125 BeforeInboxDispatch: a.beforeInboxDispatch,
2126 OnSessionTitleChanged: a.onSessionTitleChanged,
2127 })
2128 if err != nil {
2129 if teardownTimedOut {
2130 // The old session was already marked cleanup-pending, so finish the
2131 // destroy cleanup instead of re-exposing a runtime in teardown.
2132 go delayedDesktopSessionCleanup(oldPath, destroys)
2133 } else {
2134 finishDestroyHandles(destroys)
2135 }
2136 if oldSink != nil {
2137 oldSink.setBinding(tab.ID, nil)
2138 oldSink.setContext(a.ctx)
2139 }
2140 return SessionClearResult{}, err
2141 }
2142 if teardownTimedOut {
2143 go delayedDesktopSessionCleanup(oldPath, destroys)
2144 } else {
2145 if err := removeDesktopSessionArtifacts(oldPath); err != nil {
2146 finishDestroyHandles(destroys)
2147 newCtrl.Close()
2148 return SessionClearResult{}, err
2149 }
2150 finishDestroyHandles(destroys)
2151 }
2152 a.bindControllerDisplayRecorder(newCtrl)
2153 newCtrl.EnableInteractiveApproval()
2154 applyTabModeToController(newCtrl, snap.mode)
2155 applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode)
2156 // Clearing drops the active goal, which must not seed the replacement conversation.
2157 path := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label())
2158 if err := a.ensureTabSessionLeaseForRebuild(tab, path, ""); err != nil {
2159 newCtrl.Close()
2160 // Surfaces through ClearSession's Wails return; keep the holder's
2161 // path/pid/writer id out of it.
2162 return SessionClearResult{}, userFacingSessionLeaseError("", err)
2163 }
2164 setFreshControllerPath(newCtrl, path)
2165 if err := initClearedPins(path, newCtrl, oldCtrl, tab); err != nil {
2166 return SessionClearResult{}, err
2167 }
2168
2169 a.mu.Lock()
2170 if err := a.authorizeTabReplacementLocked(tab, newCtrl, "clearing the session", "fresh"); err != nil {
2171 a.mu.Unlock()
2172 // The old session is already destroyed either way; release what this
2173 // clear acquired for the replaced tab (fresh controller and its
2174 // lease) so neither leaks, and still finish the old runtime teardown.
2175 newCtrl.Close()
2176 tab.releaseSessionLease()
2177 oldCtrl.CloseAfterDestroy()
2178 a.emitProjectTreeChangedForSessionDirs(newCtrl.SessionDir())
2179 return SessionClearResult{}, err
2180 }
2181 installClearedTabRuntime(tab, newCtrl, newSink, path)
2182 clearTabStartupError(tab)
2183 tab.goal = ""
2184 // Supersede any in-flight startup build: the session it was resuming
2185 // was just destroyed, and finishing later would pass the generation
2186 // check and overwrite this controller.
2187 a.supersedeTabBuildLocked(tab)
2188 a.saveTabsLocked()
2189 a.mu.Unlock()
2190 // Same contract as ClearSession's non-running path: the replacement
2191 // session starts with zero spend.
2192 tab.resetTelemetry(path)
2193 a.persistTabSessionPath(tab, path)
2194 oldCtrl.CloseAfterDestroy()
2195 a.emitProjectTreeChangedForSessionDirs(newCtrl.SessionDir())
2196 a.notifyTabRuntimeRebuilt(tab)
2197 return a.bumpAndSnapshotSessionClear(tab), nil
2198 }
2199
2200 func removeDesktopSessionArtifacts(path string) error {
2201 if strings.TrimSpace(path) == "" {
2202 return nil
2203 }
2204 guard, err := acquireSessionRemovalGuard(path)
2205 if err != nil {
2206 return err
2207 }
2208 return removeDesktopSessionArtifactsWithGuard(path, guard)
2209 }
2210
2211 // CheckpointMeta summarises one rewind point (a user turn) for the desktop.
2212 // Optional v2 fields use omitempty so older frontends keep reading the rest.
2213 type CheckpointMeta struct {
2214 Turn int `json:"turn"`
2215 Prompt string `json:"prompt"`
2216 Files []string `json:"files"` // stable preview of cumulative files RestoreCode would affect from this turn
2217 FileCount int `json:"fileCount"` // full cumulative file count, including entries omitted from Files
2218 FilesTruncated bool `json:"filesTruncated,omitempty"`
2219 TurnFileCount int `json:"turnFileCount"` // files changed during this turn only
2220 Time int64 `json:"time"` // unix milliseconds
2221 CanCode bool `json:"canCode"`
2222 CanConversation bool `json:"canConversation"`
2223 Coverage string `json:"coverage,omitempty"`
2224 CoverageGaps []string `json:"coverageGaps,omitempty"`
2225 ExpiredFilePayload bool `json:"expiredFilePayload,omitempty"`
2226 ActiveWriters int `json:"activeWriters,omitempty"`
2227 Legacy bool `json:"legacy,omitempty"`
2228 CanUndoFiles bool `json:"canUndoFiles,omitempty"`
2229 DisabledReason string `json:"disabledReason,omitempty"`
2230 }
2231
2232 // RewindPlanView is the desktop-facing prepare result.
2233 type RewindPlanView struct {
2234 PlanID string `json:"planId"`
2235 Turn int `json:"turn"`
2236 Scope string `json:"scope"`
2237 Coverage string `json:"coverage,omitempty"`
2238 CoverageGaps []string `json:"coverageGaps,omitempty"`
2239 Legacy bool `json:"legacy,omitempty"`
2240 ExpiredFilePayload bool `json:"expiredFilePayload,omitempty"`
2241 CanFiles bool `json:"canFiles"`
2242 CanConversation bool `json:"canConversation"`
2243 DisabledReason string `json:"disabledReason,omitempty"`
2244 Conflicts []string `json:"conflicts,omitempty"`
2245 Files []string `json:"files,omitempty"`
2246 FileCount int `json:"fileCount"`
2247 ActiveWriters int `json:"activeWriters,omitempty"`
2248 Path string `json:"path,omitempty"`
2249 ConversationAction string `json:"conversationAction,omitempty"`
2250 OK bool `json:"ok"`
2251 Error string `json:"error,omitempty"`
2252 }
2253
2254 // RewindResultView is the desktop-facing commit/undo result.
2255 type RewindResultView struct {
2256 OK bool `json:"ok"`
2257 TransactionID string `json:"transactionId,omitempty"`
2258 UndoAvailable bool `json:"undoAvailable"`
2259 Written []string `json:"written,omitempty"`
2260 Deleted []string `json:"deleted,omitempty"`
2261 ConversationOK bool `json:"conversationOk,omitempty"`
2262 ConversationForked bool `json:"conversationForked,omitempty"`
2263 OperationID string `json:"operationId,omitempty"`
2264 Branch string `json:"branch,omitempty"`
2265 Partial bool `json:"partial,omitempty"`
2266 TabID string `json:"tabId,omitempty"`
2267 Tab *TabMeta `json:"tab,omitempty"`
2268 Error string `json:"error,omitempty"`
2269 Conflicts []string `json:"conflicts,omitempty"`
2270 Coverage string `json:"coverage,omitempty"`
2271 }
2272
2273 const checkpointFilePreviewLimit = 60
2274
2275 // Checkpoints lists the session's rewind points, oldest first, for the rewind UI.
2276 func (a *App) Checkpoints() []CheckpointMeta {
2277 return a.CheckpointsForTab("")
2278 }
2279
2280 func (a *App) CheckpointsForTab(tabID string) []CheckpointMeta {
2281 a.mu.RLock()
2282 var ctrl control.SessionAPI
2283 if tab := a.tabByIDLocked(tabID); tab != nil {
2284 ctrl = tab.Ctrl
2285 }
2286 a.mu.RUnlock()
2287 if ctrl == nil {
2288 return []CheckpointMeta{}
2289 }
2290 metas := ctrl.Checkpoints()
2291 out := make([]CheckpointMeta, 0, len(metas))
2292 for _, m := range metas {
2293 gaps := make([]string, 0, len(m.CoverageGaps))
2294 for _, g := range m.CoverageGaps {
2295 if g.Detail != "" {
2296 gaps = append(gaps, g.Reason+": "+g.Detail)
2297 } else {
2298 gaps = append(gaps, g.Reason)
2299 }
2300 }
2301 cov := string(m.Coverage)
2302 meta := CheckpointMeta{
2303 Turn: m.Turn,
2304 Prompt: m.Prompt,
2305 Files: m.Paths,
2306 TurnFileCount: len(m.Paths),
2307 Time: m.Time.UnixMilli(),
2308 CanCode: len(m.Paths) > 0 && m.CanUndoFiles,
2309 CanConversation: ctrl.CheckpointHasBoundary(m.Turn),
2310 Coverage: cov,
2311 CoverageGaps: gaps,
2312 ExpiredFilePayload: m.ExpiredFilePayload,
2313 ActiveWriters: len(m.ActiveWriters),
2314 Legacy: m.Legacy,
2315 CanUndoFiles: m.CanUndoFiles,
2316 DisabledReason: m.DisabledReason,
2317 }
2318 out = append(out, meta)
2319 }
2320 // RestoreCode(turn) reverts every file touched in this turn or any later one, so
2321 // a turn can rewind code even when it changed no files itself — as long as a
2322 // later turn did. Propagate CanCode backwards over the oldest-first list.
2323 // Also propagate the cumulative unique file count so the UI shows how many
2324 // files RestoreCode would actually affect from this turn.
2325 hasCodeAfter := false
2326 canCodeAfter := true
2327 codeFileSet := make(map[string]bool, len(metas)*2)
2328 codeFilePreview := []string{}
2329 //nolint:modernize // slices.Backward yields element copies; this body writes through the index.
2330 for i := len(out) - 1; i >= 0; i-- {
2331 if len(out[i].Files) > 0 {
2332 hasCodeAfter = true
2333 if !out[i].CanUndoFiles {
2334 canCodeAfter = false
2335 }
2336 }
2337 for _, f := range out[i].Files {
2338 if codeFileSet[f] {
2339 continue
2340 }
2341 codeFileSet[f] = true
2342 codeFilePreview = insertCheckpointFilePreview(codeFilePreview, f, checkpointFilePreviewLimit)
2343 }
2344 out[i].CanCode = hasCodeAfter && canCodeAfter
2345 out[i].FileCount = len(codeFileSet)
2346 out[i].Files = append([]string{}, codeFilePreview...)
2347 out[i].FilesTruncated = out[i].FileCount > len(out[i].Files)
2348 }
2349 return out
2350 }
2351
2352 func insertCheckpointFilePreview(preview []string, path string, limit int) []string {
2353 if limit <= 0 || path == "" {
2354 return preview
2355 }
2356 idx := sort.SearchStrings(preview, path)
2357 if idx < len(preview) && preview[idx] == path {
2358 return preview
2359 }
2360 if len(preview) < limit {
2361 preview = append(preview, "")
2362 copy(preview[idx+1:], preview[idx:])
2363 preview[idx] = path
2364 return preview
2365 }
2366 if idx >= limit {
2367 return preview
2368 }
2369 copy(preview[idx+1:], preview[idx:limit-1])
2370 preview[idx] = path
2371 return preview
2372 }
2373
2374 // ToolResultForTab returns the full arguments and output for one tool call that
2375 // were elided from the frontend's in-memory items[] for memory efficiency. The
2376 // caller (frontend ToolCard) loads this on demand when the user expands a
2377 // collapsed tool card. Returns nil when the tool ID is not found.
2378 func (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData {
2379 a.mu.RLock()
2380 var ctrl control.SessionAPI
2381 if tab := a.tabByIDLocked(tabID); tab != nil {
2382 ctrl = tab.Ctrl
2383 }
2384 a.mu.RUnlock()
2385 if ctrl == nil {
2386 return nil
2387 }
2388 return ctrl.ToolResult(toolID)
2389 }
2390
2391 // Rewind restores the session to the start of turn. scope is "code",
2392 // "conversation", or "both" (anything else is treated as "both"). The frontend
2393 // re-reads History after this resolves.
2394 func (a *App) Rewind(turn int, scope string) error {
2395 return a.RewindForTab("", turn, scope)
2396 }
2397
2398 // RewindForTab rewinds the requested tab instead of resolving the active tab at
2399 // execution time, which may have changed after frontend confirmation.
2400 // Compatibility wrapper over the structured fork-first path. Conversation
2401 // rewind opens the fork as a new tab; it never retargets the source controller.
2402 func (a *App) RewindForTab(tabID string, turn int, scope string) error {
2403 result := a.CommitRewindForTab(tabID, "", turn, scope)
2404 if result.OK {
2405 return nil
2406 }
2407 return errors.New(nonEmptyStr(result.Error, "rewind failed"))
2408 }
2409
2410 // PreviewRewindForTab returns a structured precheck without mutating state.
2411 func (a *App) PreviewRewindForTab(tabID string, turn int, scope string) RewindPlanView {
2412 tab, ctrl := a.tabAndCtrlByID(tabID)
2413 if a.tabIsReadOnly(tab) {
2414 return RewindPlanView{OK: false, Error: readOnlyChannelErr().Error()}
2415 }
2416 if ctrl == nil {
2417 return RewindPlanView{OK: false, Error: "no controller"}
2418 }
2419 s := control.RewindBoth
2420 switch scope {
2421 case "code":
2422 s = control.RewindCode
2423 case "conversation":
2424 s = control.RewindConversation
2425 }
2426 plan, err := ctrl.PrepareRewind(turn, s)
2427 view := rewindPlanToView(plan, scope)
2428 if err != nil {
2429 view.OK = false
2430 view.Error = err.Error()
2431 return view
2432 }
2433 view.OK = true
2434 return view
2435 }
2436
2437 // PreviewWorkspaceFileRevertForTab prepares a single-file session-owned revert.
2438 func (a *App) PreviewWorkspaceFileRevertForTab(tabID, path string) RewindPlanView {
2439 tab, ctrl := a.tabAndCtrlByID(tabID)
2440 if a.tabIsReadOnly(tab) {
2441 return RewindPlanView{OK: false, Error: readOnlyChannelErr().Error(), Path: path}
2442 }
2443 if ctrl == nil {
2444 return RewindPlanView{OK: false, Error: "no controller", Path: path}
2445 }
2446 plan, err := ctrl.PrepareFileRevert(path)
2447 view := rewindPlanToView(plan, "code")
2448 view.Path = path
2449 if err != nil {
2450 view.OK = false
2451 view.Error = err.Error()
2452 return view
2453 }
2454 view.OK = plan.CanFiles || len(plan.Conflicts) > 0
2455 return view
2456 }
2457
2458 // CommitWorkspaceFileRevertForTab commits a single-file revert.
2459 // resolution is "keep_current" or "overwrite_checkpoint".
2460 func (a *App) CommitWorkspaceFileRevertForTab(tabID, planID, resolution string) RewindResultView {
2461 tab, ctrl := a.tabAndCtrlByID(tabID)
2462 if a.tabIsReadOnly(tab) {
2463 return RewindResultView{OK: false, Error: readOnlyChannelErr().Error()}
2464 }
2465 if ctrl == nil {
2466 return RewindResultView{OK: false, Error: "no controller"}
2467 }
2468 res := checkpoint.ConflictResolution("")
2469 switch resolution {
2470 case "keep_current":
2471 res = checkpoint.ResolveKeepCurrent
2472 case "overwrite_checkpoint":
2473 res = checkpoint.ResolveOverwriteCheckpoint
2474 }
2475 result, err := ctrl.CommitFileRevert(planID, res)
2476 view := rewindResultToView(result)
2477 if err != nil {
2478 view.OK = false
2479 if view.Error == "" {
2480 view.Error = err.Error()
2481 }
2482 }
2483 return view
2484 }
2485
2486 func rewindPlanToView(plan checkpoint.RewindPlan, scope string) RewindPlanView {
2487 gaps := make([]string, 0, len(plan.CoverageGaps))
2488 for _, g := range plan.CoverageGaps {
2489 if g.Detail != "" {
2490 gaps = append(gaps, g.Reason+": "+g.Detail)
2491 } else {
2492 gaps = append(gaps, g.Reason)
2493 }
2494 }
2495 return RewindPlanView{
2496 PlanID: plan.PlanID,
2497 Turn: plan.Turn,
2498 Scope: scope,
2499 Coverage: string(plan.Coverage),
2500 CoverageGaps: gaps,
2501 Legacy: plan.Legacy,
2502 ExpiredFilePayload: plan.ExpiredFilePayload,
2503 CanFiles: plan.CanFiles,
2504 CanConversation: plan.CanConversation,
2505 DisabledReason: plan.DisabledReason,
2506 Conflicts: conflictStrings(plan),
2507 Files: plan.Files,
2508 FileCount: plan.FileCount,
2509 ActiveWriters: len(plan.ActiveWriters),
2510 Path: plan.Path,
2511 ConversationAction: plan.ConversationAction,
2512 }
2513 }
2514
2515 func conflictStrings(plan checkpoint.RewindPlan) []string {
2516 out := make([]string, 0, len(plan.Conflicts))
2517 for _, c := range plan.Conflicts {
2518 if c.Path != "" {
2519 out = append(out, c.Path+": "+c.Reason)
2520 } else {
2521 out = append(out, c.Reason)
2522 }
2523 }
2524 return out
2525 }
2526
2527 func rewindResultToView(result checkpoint.RewindResult) RewindResultView {
2528 conflicts := make([]string, 0, len(result.Conflicts))
2529 for _, c := range result.Conflicts {
2530 if c.Path != "" {
2531 conflicts = append(conflicts, c.Path+": "+c.Reason)
2532 } else {
2533 conflicts = append(conflicts, c.Reason)
2534 }
2535 }
2536 txID := result.TransactionID
2537 if result.OperationID != "" {
2538 txID = result.OperationID
2539 }
2540 return RewindResultView{
2541 OK: result.OK,
2542 TransactionID: txID,
2543 OperationID: nonEmptyStr(result.OperationID, result.TransactionID),
2544 UndoAvailable: result.UndoAvailable,
2545 Written: result.Written,
2546 Deleted: result.Deleted,
2547 ConversationOK: result.ConversationOK || result.ConversationForked,
2548 ConversationForked: result.ConversationForked,
2549 Branch: result.Branch,
2550 Partial: result.Partial,
2551 Error: result.Error,
2552 Conflicts: conflicts,
2553 Coverage: string(result.Coverage),
2554 }
2555 }
2556
2557 func nonEmptyStr(s, fallback string) string {
2558 if s != "" {
2559 return s
2560 }
2561 return fallback
2562 }
2563
2564 // Fork branches the conversation at the start of turn into a new session tab
2565 // (preserving the current tab), keeping code intact, and switches to the new tab.
2566 func (a *App) Fork(turn int) (TabMeta, error) {
2567 return a.ForkForTab("", turn)
2568 }
2569
2570 // ForkForTab forks the requested source tab even if focus changes before the
2571 // backend begins processing the request. The fork becomes active only while the
2572 // source tab still owns focus, so a later tab selection remains authoritative.
2573 func (a *App) ForkForTab(tabID string, turn int) (TabMeta, error) {
2574 result, err := a.forkForTabWithOptions(tabID, turn, false)
2575 return result.Tab, err
2576 }
2577
2578 // ForkWorktreeForTab forks the requested source tab into an isolated Git worktree.
2579 func (a *App) ForkWorktreeForTab(tabID string, turn int) (ForkWorktreeResultView, error) {
2580 return a.forkForTabWithOptions(tabID, turn, true)
2581 }
2582
2583 // SummarizeFrom / SummarizeUpTo compress model context after / before the start
2584 // of a selected turn. Visible history and checkpoints remain unchanged.
2585 func (a *App) SummarizeFrom(turn int) error {
2586 return a.SummarizeFromForTab("", turn)
2587 }
2588
2589 func (a *App) SummarizeFromForTab(tabID string, turn int) error {
2590 tab, ctrl := a.tabAndCtrlByID(tabID)
2591 if a.tabIsReadOnly(tab) {
2592 return readOnlyChannelErr()
2593 }
2594 if ctrl == nil {
2595 return nil
2596 }
2597 return ctrl.SummarizeFrom(a.ctx, turn)
2598 }
2599
2600 func (a *App) SummarizeUpTo(turn int) error {
2601 return a.SummarizeUpToForTab("", turn)
2602 }
2603
2604 func (a *App) SummarizeUpToForTab(tabID string, turn int) error {
2605 tab, ctrl := a.tabAndCtrlByID(tabID)
2606 if a.tabIsReadOnly(tab) {
2607 return readOnlyChannelErr()
2608 }
2609 if ctrl == nil {
2610 return nil
2611 }
2612 return ctrl.SummarizeUpTo(a.ctx, turn)
2613 }
2614
2615 type channelSessionRoute struct {
2616 channel string
2617 channelLabel string
2618 remoteID string
2619 chatType string
2620 userID string
2621 threadID string
2622 sessionSource string
2623 }
2624
2625 type WorkspaceMeta struct {
2626 Path string `json:"path"`
2627 Name string `json:"name"`
2628 Current bool `json:"current"`
2629 }
2630
2631 func controllerSessionDir(ctrl control.SessionAPI) string {
2632 if ctrl != nil {
2633 if dir := ctrl.SessionDir(); dir != "" {
2634 return dir
2635 }
2636 }
2637 return desktopSessionDir("")
2638 }
2639
2640 func tabSessionDir(tab *WorkspaceTab) string {
2641 if tab != nil {
2642 if tab.WorkspaceRoot != "" {
2643 return desktopSessionDir(tab.WorkspaceRoot)
2644 }
2645 if tab.Ctrl != nil {
2646 if dir := tab.Ctrl.SessionDir(); dir != "" {
2647 return dir
2648 }
2649 }
2650 }
2651 return desktopSessionDir("")
2652 }
2653
2654 func tabRuntimeSessionDir(tab *WorkspaceTab) string {
2655 if tab != nil && tab.Ctrl != nil {
2656 if dir, ok := safeControllerSessionDir(tab.Ctrl); ok && strings.TrimSpace(dir) != "" {
2657 if path := strings.TrimSpace(tab.currentSessionPath()); path != "" {
2658 if _, _, err := validateSessionPath(dir, path); err == nil {
2659 return dir
2660 }
2661 } else {
2662 return dir
2663 }
2664 }
2665 }
2666 return tabSessionDir(tab)
2667 }
2668
2669 func (a *App) activeSessionDir() string {
2670 tab := a.activeTab()
2671 if path, ok := a.reconcileTabWithPinnedSessionMeta(tab); ok && strings.TrimSpace(path) != "" {
2672 return filepath.Dir(path)
2673 }
2674 if tab != nil && tab.Ctrl != nil {
2675 return tabRuntimeSessionDir(tab)
2676 }
2677 return tabSessionDir(tab)
2678 }
2679
2680 // ListTrashedSessions returns sessions that were moved to the local trash,
2681 // newest-deleted first. These can be previewed, restored, or permanently purged.
2682 func (a *App) ListTrashedSessions() []SessionMeta {
2683 out := []SessionMeta{}
2684 state, stateErr := a.workspaceRegistry().Load(a.bootContext())
2685 if stateErr != nil {
2686 return out
2687 }
2688 for _, dir := range a.knownSessionDirs() {
2689 paths, err := listTrashedSessionFiles(dir)
2690 if err != nil {
2691 continue
2692 }
2693 titles := loadSessionTitles(dir)
2694 for _, path := range paths {
2695 if !explicitlyDeletedLegacyEntry(path) {
2696 continue
2697 }
2698 if _, adopted, err := state.ResolveSource(desktopSourceKey(path, "")); adopted || err != nil {
2699 continue
2700 }
2701 infos, err := agent.ListSessions(filepath.Dir(path))
2702 if err != nil || len(infos) == 0 {
2703 continue
2704 }
2705 deletedAt := trashedSessionDeletedAt(path)
2706 title := strings.TrimSpace(infos[0].CustomTitle)
2707 if title == "" {
2708 title = titles[filepath.Base(path)]
2709 }
2710 out = append(out, sessionMetaFromInfo(infos[0], title, false, false, deletedAt, dir))
2711 }
2712 }
2713 sort.Slice(out, func(i, j int) bool {
2714 if out[i].DeletedAt == out[j].DeletedAt {
2715 return out[i].LastActivityAt > out[j].LastActivityAt
2716 }
2717 return out[i].DeletedAt > out[j].DeletedAt
2718 })
2719 return out
2720 }
2721
2722 func (a *App) trashedSessionDir(path string) (string, error) {
2723 for _, dir := range a.knownSessionDirs() {
2724 if _, _, _, err := validateTrashedSessionPath(dir, path); err == nil {
2725 return dir, nil
2726 }
2727 }
2728 return "", fmt.Errorf("trashed session path outside known session dirs: %s", path)
2729 }
2730
2731 func (a *App) sessionDirForPath(path string) (string, string, error) {
2732 for _, dir := range a.knownSessionDirs() {
2733 sessionPath, _, err := validateSessionPath(dir, path)
2734 if err == nil {
2735 return dir, sessionPath, nil
2736 }
2737 }
2738 return "", "", fmt.Errorf("session path outside known session dirs: %s", path)
2739 }
2740
2741 func applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) {
2742 if meta == nil {
2743 return
2744 }
2745 meta.Kind = "channel"
2746 meta.Channel = route.channel
2747 meta.ChannelLabel = route.channelLabel
2748 meta.RemoteID = route.remoteID
2749 meta.ChatType = route.chatType
2750 meta.UserID = route.userID
2751 meta.ThreadID = route.threadID
2752 meta.SessionSource = route.sessionSource
2753 }
2754
2755 func channelSessionRoutesForDir(dir string) map[string]channelSessionRoute {
2756 userPath := config.UserConfigPath()
2757 if strings.TrimSpace(userPath) == "" {
2758 return nil
2759 }
2760 cfg := config.LoadForEdit(userPath)
2761 out := map[string]channelSessionRoute{}
2762 for _, conn := range cfg.Bot.Connections {
2763 channel := strings.TrimSpace(conn.Provider)
2764 if channel == "" {
2765 continue
2766 }
2767 channelLabel := strings.TrimSpace(conn.Label)
2768 if channelLabel == "" {
2769 channelLabel = channelDisplayName(channel, conn.Domain)
2770 }
2771 for _, mapping := range conn.SessionMappings {
2772 if strings.TrimSpace(mapping.SessionSource) != "auto" {
2773 continue
2774 }
2775 sessionPath := botSessionPathTarget(mapping.SessionID)
2776 if sessionPath == "" {
2777 continue
2778 }
2779 validPath, _, err := validateSessionPath(dir, sessionPath)
2780 if err != nil {
2781 continue
2782 }
2783 key := sessionRuntimeKey(validPath)
2784 if key == "" {
2785 continue
2786 }
2787 out[key] = channelSessionRoute{
2788 channel: channel,
2789 channelLabel: channelLabel,
2790 remoteID: strings.TrimSpace(mapping.RemoteID),
2791 chatType: strings.TrimSpace(mapping.ChatType),
2792 userID: strings.TrimSpace(mapping.UserID),
2793 threadID: strings.TrimSpace(mapping.ThreadID),
2794 sessionSource: strings.TrimSpace(mapping.SessionSource),
2795 }
2796 }
2797 }
2798 if len(out) == 0 {
2799 return nil
2800 }
2801 return out
2802 }
2803
2804 func botSessionPathTarget(sessionID string) string {
2805 sessionID = strings.TrimSpace(sessionID)
2806 if sessionID == "" {
2807 return ""
2808 }
2809 if strings.HasPrefix(strings.ToLower(sessionID), "path:") {
2810 return strings.TrimSpace(sessionID[5:])
2811 }
2812 if strings.HasSuffix(sessionID, ".jsonl") || strings.Contains(sessionID, "/") || strings.Contains(sessionID, `\`) || strings.HasPrefix(sessionID, "~") {
2813 return sessionID
2814 }
2815 return ""
2816 }
2817
2818 func channelDisplayName(provider, domain string) string {
2819 provider = strings.TrimSpace(provider)
2820 domain = strings.TrimSpace(domain)
2821 switch provider {
2822 case "feishu":
2823 if strings.EqualFold(domain, "lark") {
2824 return "Lark"
2825 }
2826 return "Feishu"
2827 case "weixin":
2828 return "WeChat"
2829 case "qq":
2830 return "QQ"
2831 case "dingtalk":
2832 return "DingTalk"
2833 default:
2834 return provider
2835 }
2836 }
2837
2838 // DeleteRecoveryCopy is the guarded bulk-cleanup path. The frontend's copy
2839 // marker is only a hint. Open copies are preserved, and the backend holds both
2840 // parent and branch removal guards while re-proving coverage and publishing a
2841 // recoverable trash entry.
2842 func (a *App) DeleteRecoveryCopy(path string) error {
2843 return friendlySessionFileError(a.deleteRecoveryCopy(path))
2844 }
2845
2846 var errRecoveryCopyNotRedundant = errors.New("recovery session contains content not preserved by its parent")
2847
2848 func (a *App) deleteSession(path string) error {
2849 dir := a.activeSessionDir()
2850 sessionPath, key, err := validateSessionPath(dir, path)
2851 if err != nil {
2852 var foundErr error
2853 if dir, sessionPath, foundErr = a.sessionDirForPath(path); foundErr != nil {
2854 return err
2855 }
2856 key = filepath.Base(sessionPath)
2857 }
2858 if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
2859 return err
2860 }
2861 var fallback fallbackRuntimeTarget
2862 if err := func() error {
2863 defer a.lockRuntimeMutation("delete-session")()
2864 a.sessionRemovalMu.Lock()
2865 defer a.sessionRemovalMu.Unlock()
2866 removed, nextFallback := a.removeSessionRuntimeBindings(dir, sessionPath)
2867 fallback = nextFallback
2868 if err := a.prepareRemovedSessionRuntimes(removed); err != nil {
2869 a.closeRemainingRemovedSessionRuntimesAdmissionHeld(removed, map[control.SessionAPI]bool{})
2870 return err
2871 }
2872 closedRemoved := map[control.SessionAPI]bool{}
2873 destroys := a.destroyHandlesForSession(dir, sessionPath, removed)
2874 teardownTimedOut := waitDestroyHandles(destroys)
2875 a.closeRemovedSessionRuntimesForSessionAfterDestroyAdmissionHeld(removed, dir, sessionPath, closedRemoved)
2876 if teardownTimedOut {
2877 if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil {
2878 a.closeRemainingRemovedSessionRuntimesAfterDestroyAdmissionHeld(removed, closedRemoved)
2879 return err
2880 }
2881 go delayedDesktopSessionTrash(dir, sessionPath, key, destroys)
2882 } else {
2883 err = trashSessionArtifacts(dir, sessionPath, key)
2884 finishDestroyHandles(destroys)
2885 if err != nil {
2886 a.closeRemainingRemovedSessionRuntimesAfterDestroyAdmissionHeld(removed, closedRemoved)
2887 return err
2888 }
2889 }
2890 a.closeRemainingRemovedSessionRuntimesAfterDestroyAdmissionHeld(removed, closedRemoved)
2891 return nil
2892 }(); err != nil {
2893 return err
2894 }
2895 if err := botruntime.ForgetAutoSessionMappingsForPath(sessionPath); err != nil {
2896 slog.Warn("desktop: failed to clear auto bot session mapping", "err", err)
2897 }
2898 if fallback.needs {
2899 fallback = a.sessionDeleteFallbackTarget(fallback)
2900 if err := a.openFallbackRuntime(fallback); err != nil {
2901 return err
2902 }
2903 }
2904 a.removeSessionCatalogPath(sessionPath, "session_deleted")
2905 a.emitProjectTreeChangedForSessionDirs(dir)
2906 a.invalidatePromptHistoryCache()
2907 return nil
2908 }
2909
2910 type fallbackRuntimeTarget struct {
2911 needs bool
2912 scope string
2913 workspaceRoot string
2914 topicID string
2915 }
2916
2917 func (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) {
2918 var removed []removedSessionRuntime
2919 var fallback fallbackRuntimeTarget
2920
2921 a.mu.Lock()
2922 for id, tab := range a.tabs {
2923 if !tabMatchesSession(tab, dir, sessionPath) {
2924 continue
2925 }
2926 if len(removed) == 0 {
2927 fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}
2928 }
2929 removed = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))
2930 a.markTabRemovedLocked(tab)
2931 delete(a.tabs, id)
2932 a.removeTabOrderLocked(id)
2933 if a.activeTabID == id {
2934 a.activeTabID = ""
2935 }
2936 }
2937 for key, tab := range a.detachedSessions {
2938 if !tabMatchesSession(tab, dir, sessionPath) {
2939 continue
2940 }
2941 if len(removed) == 0 {
2942 fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID}
2943 }
2944 removed = append(removed, removedRuntimeFromTab(tab, dir, sessionPath))
2945 a.markTabRemovedLocked(tab)
2946 delete(a.detachedSessions, key)
2947 }
2948 if a.activeTabID == "" && len(a.tabOrder) > 0 {
2949 a.activeTabID = a.tabOrder[0]
2950 }
2951 fallback.needs = len(removed) > 0 && len(a.tabs) == 0
2952 dir, entries, activeID, version := a.saveTabsCollectLocked()
2953 a.mu.Unlock()
2954
2955 a.saveTabsWrite(dir, entries, activeID, version)
2956
2957 return removed, fallback
2958 }
2959
2960 func (a *App) sessionDeleteFallbackTarget(target fallbackRuntimeTarget) fallbackRuntimeTarget {
2961 topicID := strings.TrimSpace(target.topicID)
2962 if topicID == "" {
2963 return target
2964 }
2965 if path, _ := a.findTopicContentSessionForTarget(target.scope, target.workspaceRoot, topicID); path != "" {
2966 return target
2967 }
2968 target.topicID = ""
2969 return target
2970 }
2971
2972 func removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {
2973 return removedSessionRuntime{
2974 tab: tab,
2975 ctrl: tab.Ctrl,
2976 sink: tab.sink,
2977 sessionDir: dir,
2978 sessionPath: sessionPath,
2979 scope: tab.Scope,
2980 workspaceRoot: tab.WorkspaceRoot,
2981 topicID: tab.TopicID,
2982 readOnly: tab.ReadOnly,
2983 }
2984 }
2985
2986 func tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool {
2987 if tab == nil {
2988 return false
2989 }
2990 // Canonical migration can clear the legacy path while the runtime still
2991 // owns its compatibility lease. Include that owner when removing bindings
2992 // or checking whether a legacy file is open.
2993 if key := tab.sessionLeaseRuntimeKey(); key != "" && key == sessionRuntimeKey(sessionPath) {
2994 return true
2995 }
2996 currentPath, _, err := validateSessionPath(dir, tab.currentSessionPath())
2997 if err == nil && currentPath == sessionPath {
2998 return true
2999 }
3000 if tabRuntimeSessionDir(tab) != dir {
3001 return false
3002 }
3003 currentPath, _, err = validateSessionPath(dir, tab.currentSessionPath())
3004 return err == nil && currentPath == sessionPath
3005 }
3006
3007 func (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error {
3008 for _, item := range removed {
3009 if item.sink != nil {
3010 item.sink.clearContext()
3011 }
3012 if item.ctrl == nil {
3013 continue
3014 }
3015 if item.ctrl.Running() {
3016 item.ctrl.Cancel()
3017 if err := waitControllerStopped(item.ctrl); err != nil {
3018 return err
3019 }
3020 }
3021 if item.readOnly || historicalPreview(item.ctrl) {
3022 continue
3023 }
3024 if err := item.ctrl.Snapshot(); err != nil {
3025 if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
3026 return err
3027 }
3028 slog.Warn("desktop: skipping stale runtime snapshot before removing session",
3029 "session", item.sessionPath, "err", err)
3030 }
3031 item.ctrl.SetSessionPath("")
3032 a.quiesceTabAutosave(item.tab)
3033 }
3034 return nil
3035 }
3036
3037 func waitControllerStopped(ctrl control.SessionAPI) error {
3038 deadline := time.Now().Add(5 * time.Second)
3039 for ctrl.Running() {
3040 if time.Now().After(deadline) {
3041 return fmt.Errorf("timed out waiting for cancelled session work to stop")
3042 }
3043 time.Sleep(10 * time.Millisecond)
3044 }
3045 return nil
3046 }
3047
3048 func (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle {
3049 destroys := a.beginDestroySessionJobs(dir, sessionPath)
3050 for _, item := range removed {
3051 if item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath {
3052 continue
3053 }
3054 destroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath))
3055 }
3056 return destroys
3057 }
3058
3059 func waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {
3060 for _, destroy := range destroys {
3061 if destroy.WaitAll != nil {
3062 destroy.WaitAll()
3063 }
3064 }
3065 }
3066
3067 func finishDestroyHandles(destroys []control.SessionDestroyHandle) {
3068 for _, destroy := range destroys {
3069 if destroy.Finish != nil {
3070 destroy.Finish()
3071 }
3072 }
3073 }
3074
3075 func delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) {
3076 waitAllDestroyHandles(destroys)
3077 if err := removeDesktopSessionArtifacts(path); err != nil {
3078 slog.Warn("desktop: delayed session cleanup failed", "path", path, "err", err)
3079 }
3080 finishDestroyHandles(destroys)
3081 }
3082
3083 func delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) {
3084 waitAllDestroyHandles(destroys)
3085 if err := trashSessionArtifacts(dir, sessionPath, key); err != nil {
3086 slog.Warn("desktop: delayed session trash failed", "path", sessionPath, "err", err)
3087 }
3088 finishDestroyHandles(destroys)
3089 }
3090
3091 func (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) {
3092 defer a.lockRuntimeMutation("close-removed-session-runtimes")()
3093 a.closeRemainingRemovedSessionRuntimesAdmissionHeld(removed, map[control.SessionAPI]bool{})
3094 }
3095
3096 func (a *App) closeRemovedSessionRuntimesForSessionAfterDestroyAdmissionHeld(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) {
3097 releasedTabs := map[*WorkspaceTab]bool{}
3098 for _, item := range removed {
3099 if item.sessionDir != dir || item.sessionPath != sessionPath {
3100 continue
3101 }
3102 a.closeRemovedSessionRuntime(item, closed, releasedTabs, true)
3103 }
3104 }
3105
3106 func (a *App) closeRemainingRemovedSessionRuntimesAdmissionHeld(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {
3107 releasedTabs := map[*WorkspaceTab]bool{}
3108 for _, item := range removed {
3109 a.closeRemovedSessionRuntime(item, closed, releasedTabs, false)
3110 }
3111 }
3112
3113 func (a *App) closeRemainingRemovedSessionRuntimesAfterDestroyAdmissionHeld(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) {
3114 releasedTabs := map[*WorkspaceTab]bool{}
3115 for _, item := range removed {
3116 a.closeRemovedSessionRuntime(item, closed, releasedTabs, true)
3117 }
3118 }
3119
3120 func (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) {
3121 if item.tab != nil {
3122 if releasedTabs == nil || !releasedTabs[item.tab] {
3123 if releasedTabs != nil {
3124 releasedTabs[item.tab] = true
3125 }
3126 a.mu.Lock()
3127 if owner := a.tabByEventSinkIDLocked(item.tab.ID); owner == nil || owner == item.tab {
3128 a.forgetBrowserExecutorLocked(item.tab.ID)
3129 }
3130 a.mu.Unlock()
3131 a.releaseTabSharedHost(item.tab)
3132 item.tab.releaseSessionLease()
3133 }
3134 }
3135 if item.ctrl == nil {
3136 return
3137 }
3138 if closed == nil {
3139 closed = map[control.SessionAPI]bool{}
3140 }
3141 if closed[item.ctrl] {
3142 return
3143 }
3144 closed[item.ctrl] = true
3145 if afterDestroy {
3146 item.ctrl.CloseAfterDestroy()
3147 return
3148 }
3149 item.ctrl.Close()
3150 }
3151
3152 // openFallbackRuntime re-activates the topic that still owns content after one
3153 // of its sessions was removed. When no topic remains, the surface stays empty
3154 // and the frontend lands on the workspace draft: a replacement blank session
3155 // would be registered as a real sidebar row that can be archived again, so the
3156 // workspace could never become empty.
3157 func (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error {
3158 topicID := strings.TrimSpace(target.topicID)
3159 if topicID == "" {
3160 return nil
3161 }
3162 root := target.workspaceRoot
3163 if target.scope == "global" {
3164 root = ""
3165 }
3166 _, err := a.ActivateTopic(target.scope, root, topicID, "")
3167 return err
3168 }
3169
3170 func (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle {
3171 a.mu.RLock()
3172 defer a.mu.RUnlock()
3173 var destroys []control.SessionDestroyHandle
3174 for _, tab := range a.runtimeTabsLocked() {
3175 if tab == nil || tab.Ctrl == nil || tabRuntimeSessionDir(tab) != dir {
3176 continue
3177 }
3178 destroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath))
3179 }
3180 return destroys
3181 }
3182
3183 func (a *App) openSessionPaths(dir string) map[string]struct{} {
3184 a.mu.RLock()
3185 paths := make([]string, 0, len(a.tabs)+len(a.detachedSessions))
3186 for _, tab := range a.runtimeTabsLocked() {
3187 if tab != nil {
3188 paths = append(paths, tab.currentSessionPath())
3189 }
3190 }
3191 a.mu.RUnlock()
3192
3193 out := make(map[string]struct{}, len(paths))
3194 for _, path := range paths {
3195 currentPath, _, err := validateSessionPath(dir, path)
3196 if err == nil {
3197 out[currentPath] = struct{}{}
3198 }
3199 }
3200 return out
3201 }
3202
3203 func (a *App) activeSessionPath(dir string) string {
3204 a.mu.RLock()
3205 var path string
3206 if tab := a.tabs[a.activeTabID]; tab != nil {
3207 path = tab.currentSessionPath()
3208 }
3209 a.mu.RUnlock()
3210 currentPath, _, err := validateSessionPath(dir, path)
3211 if err != nil {
3212 return ""
3213 }
3214 return currentPath
3215 }
3216
3217 // RestoreSession moves a trashed session back into the saved-session list.
3218 func (a *App) RestoreSession(path string) error {
3219 return friendlySessionFileError(a.restoreLegacyRecoveryPath(path))
3220 }
3221
3222 func (a *App) restoreSession(path string) error {
3223 dir, err := a.trashedSessionDir(path)
3224 if err != nil {
3225 return err
3226 }
3227 _, key, _, err := validateTrashedSessionPath(dir, path)
3228 if err != nil {
3229 return err
3230 }
3231 // The destroying/open checks and the trash-entry move must not interleave
3232 // with DeleteSession/TrashTopic trashing the same entry.
3233 a.sessionRemovalMu.Lock()
3234 defer a.sessionRemovalMu.Unlock()
3235 target := filepath.Join(dir, key)
3236 if a.sessionDestroying(dir, target) {
3237 return fmt.Errorf("session cleanup is still in progress: %s", key)
3238 }
3239 // A committed archive may have moved the transcript into trash while a
3240 // Windows file handle temporarily kept one of its sidecars at the live
3241 // path. The durable cleanup marker makes that partial move recoverable.
3242 // Finish it before restore preflights the live destinations; otherwise the
3243 // leftover sidecar is misreported as an unrelated restore conflict.
3244 if agent.IsCleanupPending(target) {
3245 _ = reconcileDesktopCleanupPending(dir)
3246 if agent.IsCleanupPending(target) {
3247 return fmt.Errorf("session cleanup is still in progress: %s", key)
3248 }
3249 }
3250 if a.sessionOpen(dir, target) {
3251 return fmt.Errorf("session is open: %s", key)
3252 }
3253 if err := restoreTrashedSessionFile(dir, path); err != nil {
3254 return err
3255 }
3256 if err := restoreSessionTopicIndex(dir, target); err != nil {
3257 return err
3258 }
3259 a.requestSessionCatalogPath("", "", target)
3260 a.emitProjectTreeChangedForSessionDirs(dir)
3261 a.invalidatePromptHistoryCache()
3262 return nil
3263 }
3264
3265 func (a *App) sessionDestroying(dir, sessionPath string) bool {
3266 a.mu.RLock()
3267 defer a.mu.RUnlock()
3268 for _, tab := range a.runtimeTabsLocked() {
3269 if tab == nil || tab.Ctrl == nil || tabRuntimeSessionDir(tab) != dir {
3270 continue
3271 }
3272 if tab.Ctrl.IsDestroyingSession(sessionPath) {
3273 return true
3274 }
3275 }
3276 return false
3277 }
3278
3279 func (a *App) sessionOpen(dir, sessionPath string) bool {
3280 a.mu.RLock()
3281 defer a.mu.RUnlock()
3282 for _, tab := range a.runtimeTabsLocked() {
3283 if tabMatchesSession(tab, dir, sessionPath) {
3284 return true
3285 }
3286 }
3287 return false
3288 }
3289
3290 // PurgeRecoveryCopy is the guarded permanent-cleanup path. A trashed branch is
3291 // rechecked against its live parent; missing, stale, or divergent data is kept.
3292 func (a *App) PurgeRecoveryCopy(path string) error {
3293 return friendlySessionFileError(a.purgeTrashedSession(path, true))
3294 }
3295
3296 func (a *App) purgeTrashedSession(path string, requireRedundantRecovery bool) error {
3297 dir, err := a.trashedSessionDir(path)
3298 if err != nil {
3299 return err
3300 }
3301 state, err := a.workspaceRegistry().Load(a.bootContext())
3302 if err != nil {
3303 return err
3304 }
3305 if _, adopted, err := state.ResolveSource(desktopSourceKey(path, "")); adopted || err != nil {
3306 return errors.New("the historical source is preserved for its restored session")
3307 }
3308 if !explicitlyDeletedLegacyEntry(path) {
3309 return errors.New("historical recovery entries cannot be permanently cleared")
3310 }
3311 a.sessionRemovalMu.Lock()
3312 defer a.sessionRemovalMu.Unlock()
3313 var parentGuard *agent.SessionRemovalGuard
3314 if requireRedundantRecovery {
3315 parentGuard, err = agent.TryAcquireRecoveryParentGuard(path, dir)
3316 if err != nil {
3317 switch {
3318 case errors.Is(err, agent.ErrRecoveryBranchNotCovered):
3319 return errRecoveryCopyNotRedundant
3320 case errors.Is(err, agent.ErrSessionLeaseHeld):
3321 return errSessionBusyElsewhere
3322 default:
3323 return err
3324 }
3325 }
3326 defer parentGuard.Release()
3327 }
3328 if err := purgeTrashedSessionFile(dir, path); err != nil {
3329 return err
3330 }
3331 a.invalidatePromptHistoryCache()
3332 return nil
3333 }
3334
3335 // RenameSession sets a custom display name for a session (empty clears it back to
3336 // the preview). The transcript file is unchanged; the canonical name lives in
3337 // the branch meta sidecar, with the legacy .titles.json map kept as a
3338 // compatibility write-through for older desktop data paths.
3339 func (a *App) RenameSession(path, title string) error {
3340 if target, err := a.resolveSessionTarget(sessionTargetSelector{SessionPath: strings.TrimSpace(path)}); err == nil {
3341 a.cancelAISessionTitle(target.key())
3342 }
3343 a.topicTitleMutationMu.Lock()
3344 defer a.topicTitleMutationMu.Unlock()
3345 if id, ok := parseSessionRoute(path); ok {
3346 service := a.desktopSessionService("")
3347 ref := session.SessionRef{HostID: localDesktopHostID, SessionID: id}
3348 if err := validateLocalSessionRef(ref); err != nil {
3349 return errors.New("session version is unavailable")
3350 }
3351 if err := service.SetTitle(a.bootContext(), ref, title); err != nil {
3352 return friendlySessionFileError(err)
3353 }
3354 a.invalidatePromptHistoryCache()
3355 a.emitProjectTreeChanged()
3356 return nil
3357 }
3358 dir, _, err := a.sessionDirForPath(path)
3359 if err != nil {
3360 return errors.New("session version is unavailable")
3361 }
3362 return friendlySessionFileError(a.renameSessionInDir(dir, path, title))
3363 }
3364
3365 func (a *App) renameSessionInDir(dir, path, title string) error {
3366 sessionPath, _, err := validateSessionPath(dir, path)
3367 if err != nil {
3368 return err
3369 }
3370 if err := agent.RenameSession(sessionPath, title); err != nil {
3371 return err
3372 }
3373 return a.onSessionTitleChanged(dir, sessionPath, title)
3374 }
3375
3376 func (a *App) renameSessionInDirIfTitleUnchanged(dir, path, expectedTitle, title string) error {
3377 sessionPath, _, err := validateSessionPath(dir, path)
3378 if err != nil {
3379 return err
3380 }
3381 if err := agent.RenameSessionIfTitleRevision(sessionPath, expectedTitle, title); err != nil {
3382 return err
3383 }
3384 return a.onSessionTitleChanged(dir, sessionPath, title)
3385 }
3386
3387 // onSessionTitleChanged projects the canonical BranchMeta custom title into
3388 // the legacy desktop map and live catalog/UI indexes. The session directory is
3389 // supplied by the owning boot so background tabs never route through whichever
3390 // tab happens to be active when the tool finishes.
3391 func (a *App) onSessionTitleChanged(dir, sessionPath, _ string) error {
3392 validated, _, err := validateSessionPath(dir, sessionPath)
3393 if err != nil {
3394 return err
3395 }
3396 if err := syncSessionTitleFromBranchMeta(dir, validated); err != nil {
3397 return err
3398 }
3399 a.projectLegacySessionTitleToTabs(validated)
3400 a.requestSessionCatalogPath("", "", validated)
3401 a.invalidatePromptHistoryCache()
3402 a.emitProjectTreeChangedForSessionDirs(dir)
3403 return nil
3404 }
3405
3406 // ResumeSession snapshots the current conversation, then loads the session at
3407 // path and continues it on the active tab. The model and working folder are
3408 // unchanged; only the transcript is swapped. Returns the resumed messages for
3409 // the frontend to render.
3410 func (a *App) ResumeSession(path string) ([]HistoryMessage, error) {
3411 return a.ResumeSessionForTab("", path)
3412 }
3413
3414 func (a *App) ResumeSessionPage(path string, limit int) (HistoryPage, error) {
3415 return a.ResumeSessionPageForTab("", path, limit)
3416 }
3417
3418 func (a *App) ResumeSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) {
3419 return a.resumeSessionPageForTab(tabID, path, limit)
3420 }
3421
3422 // ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session
3423 // path is a runtime identity, so changing to a different path must replace the
3424 // tab's controller binding rather than mutating the current controller in place.
3425 func (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) {
3426 if ref, adopted, err := a.legacyCanonicalRef(a.bootContext(), path); err != nil {
3427 return nil, err
3428 } else if adopted {
3429 path = sessionRoute(ref.SessionID)
3430 }
3431 tab, ctrl := a.tabAndCtrlByID(tabID)
3432 if tab == nil || ctrl == nil {
3433 return []HistoryMessage{}, fmt.Errorf("tab is not ready")
3434 }
3435 if _, isV3 := parseSessionRoute(path); isV3 {
3436 if _, err := a.resumeCanonicalSessionForTranscript(tab, ctrl, path, defaultHistoryPageTurns, false); err != nil {
3437 return nil, err
3438 }
3439 return a.HistoryForTab(tab.ID), nil
3440 }
3441 if continued := a.continuePathForOpen(path); continued != "" {
3442 path = continued
3443 }
3444 sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
3445 if err != nil {
3446 return nil, err
3447 }
3448 if sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) {
3449 a.mu.RLock()
3450 takeoverSpectator := a.tabs[tab.ID] == tab && tab.Takeover.Spectator
3451 a.mu.RUnlock()
3452 if takeoverSpectator {
3453 return nil, fmt.Errorf("session is held by the remote side; use TakeoverSession to reclaim it")
3454 }
3455 a.setTabReadOnly(tab.ID, false)
3456 // A read-only transcript explicitly reopened for writing re-announces
3457 // itself so a resident Serve can mirror and later reclaim it.
3458 a.attachTakeoverMirror(tab.ID, sessionPath)
3459 go a.adoptSessionFromLocalServe(tab.ID, sessionPath)
3460 return a.HistoryForTab(tabID), nil
3461 }
3462 loaded, err := loadResumableSession(sessionPath)
3463 if err != nil {
3464 return nil, err
3465 }
3466
3467 if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
3468 return nil, err
3469 }
3470 a.setTabReadOnly(tab.ID, false)
3471 a.attachTakeoverMirror(tab.ID, sessionPath)
3472 go a.adoptSessionFromLocalServe(tab.ID, sessionPath)
3473 return a.HistoryForTab(tabID), nil
3474 }
3475
3476 // validateChannelSessionPath 校验 bot/channel 会话路径:channel 会话可能位于
3477 // 当前 controller 的 session dir(project scope)或全局 session dir
3478 // (global scope),单 tab 无法同时覆盖两者,因此都放行。
3479 func validateChannelSessionPath(ctrlDir, path string) (string, string, error) {
3480 if p, b, err := validateSessionPath(ctrlDir, path); err == nil {
3481 return p, b, nil
3482 }
3483 if globalDir := config.SessionDir(); globalDir != "" && globalDir != ctrlDir {
3484 if p, b, err := validateSessionPath(globalDir, path); err == nil {
3485 return p, b, nil
3486 }
3487 }
3488 return validateSessionPath(ctrlDir, path)
3489 }
3490
3491 func (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {
3492 tab, ctrl := a.tabAndCtrlByID(tabID)
3493 if tab == nil || ctrl == nil {
3494 return []HistoryMessage{}, fmt.Errorf("tab is not ready")
3495 }
3496 if _, isV3 := parseSessionRoute(path); isV3 {
3497 if _, err := a.resumeCanonicalSessionForTranscript(tab, ctrl, path, defaultHistoryPageTurns, false); err != nil {
3498 return nil, err
3499 }
3500 a.setTabReadOnly(tab.ID, true)
3501 return a.HistoryForTab(tab.ID), nil
3502 }
3503 sessionPath, _, err := validateChannelSessionPath(controllerSessionDir(ctrl), path)
3504 if err != nil {
3505 return nil, err
3506 }
3507 loaded, err := loadResumableSession(sessionPath)
3508 if err != nil {
3509 return nil, err
3510 }
3511 if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) {
3512 if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil {
3513 return nil, err
3514 }
3515 }
3516 a.setTabReadOnly(tab.ID, true)
3517 return a.HistoryForTab(tab.ID), nil
3518 }
3519
3520 func (a *App) OpenChannelSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) {
3521 return a.openChannelSessionForTranscript(tabID, path, limit, true)
3522 }
3523
3524 func (a *App) openChannelSessionForTranscript(tabID, path string, limit int, includeHistory bool) (HistoryPage, error) {
3525 if ref, adopted, err := a.legacyCanonicalRef(a.bootContext(), path); err != nil {
3526 return HistoryPage{}, err
3527 } else if adopted {
3528 path = sessionRoute(ref.SessionID)
3529 }
3530 started := time.Now()
3531 phases := HistorySwitchPhases{Outcome: "ok"}
3532 defer func() { logSessionSwitchPhases(phases, started) }()
3533 tab, ctrl := a.tabAndCtrlByID(tabID)
3534 if tab == nil || ctrl == nil {
3535 phases.Outcome = "tab_not_ready"
3536 return HistoryPage{}, fmt.Errorf("tab is not ready")
3537 }
3538 if _, isV3 := parseSessionRoute(path); isV3 {
3539 page, err := a.resumeCanonicalSessionForTranscript(tab, ctrl, path, limit, includeHistory)
3540 if err != nil {
3541 phases.Outcome = "v3_rebind_failed"
3542 return HistoryPage{}, err
3543 }
3544 a.setTabReadOnly(tab.ID, true)
3545 phases.TotalMs = elapsedMs(started)
3546 page.Switch = &phases
3547 return page, nil
3548 }
3549 resolveStarted := time.Now()
3550 sessionPath, _, err := validateChannelSessionPath(controllerSessionDir(ctrl), path)
3551 if err != nil {
3552 phases.Outcome = "invalid_path"
3553 return HistoryPage{}, err
3554 }
3555 phases.ResolveMs = elapsedMs(resolveStarted)
3556
3557 loadStarted := time.Now()
3558 phases.DurableReads++
3559 loaded, err := loadResumableSession(sessionPath)
3560 if err != nil {
3561 phases.Outcome = "load_failed"
3562 return HistoryPage{}, err
3563 }
3564 phases.LoadMs = elapsedMs(loadStarted)
3565 phases.LoadedCount = loaded.Len()
3566 phases.LoadedBytes = sessionFileBytes(sessionPath)
3567 page, err := a.switchToLoadedSessionPage(tab, loaded, sessionPath, true, includeHistory, limit, &phases)
3568 if err != nil {
3569 return HistoryPage{}, err
3570 }
3571 phases.TotalMs = elapsedMs(started)
3572 page.Switch = &phases
3573 return page, nil
3574 }
3575
3576 func (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error {
3577 sessionPath = canonicalTabSessionPath(sessionPath)
3578 if sessionPath == "" {
3579 return fmt.Errorf("session path is required")
3580 }
3581 loaded, err := loadResumableSession(sessionPath)
3582 if err != nil {
3583 return err
3584 }
3585 return a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded)
3586 }
3587
3588 func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error {
3589 if tab == nil {
3590 return fmt.Errorf("tab is not ready")
3591 }
3592 pendingSequence := a.deferredRebuildSequence(tab.ID)
3593 sessionPath = canonicalTabSessionPath(sessionPath)
3594 if sessionPath == "" {
3595 return fmt.Errorf("session path is required")
3596 }
3597 if agent.IsCleanupPending(sessionPath) {
3598 return fmt.Errorf("session is pending cleanup")
3599 }
3600 if loaded == nil {
3601 var err error
3602 loaded, err = loadResumableSession(sessionPath)
3603 if err != nil {
3604 return err
3605 }
3606 }
3607 // Session rebinding is a candidate transaction. Keep the source controller,
3608 // lease, runtime key, epoch, and profile live until the target controller has
3609 // built, restored, validated, and acquired its own lease. The lifecycle
3610 // barrier blocks new turns and startup publication across the transaction.
3611 a.runtimeRebuildMu.Lock()
3612 defer a.runtimeRebuildMu.Unlock()
3613
3614 // Fence an in-flight startup before waiting for the admission barrier. Startup
3615 // work now runs outside that barrier and will discard itself at its short
3616 // publication check once this generation is superseded. App.mu is released
3617 // before barrier acquisition, so no inverted lock nesting is introduced.
3618 a.mu.Lock()
3619 if tab.removed || a.tabs[tab.ID] != tab {
3620 a.mu.Unlock()
3621 return fmt.Errorf("tab is not ready")
3622 }
3623 currentPath := ""
3624 if tab.Ctrl != nil {
3625 currentPath = strings.TrimSpace(tab.Ctrl.SessionPath())
3626 }
3627 if currentPath == "" {
3628 currentPath = strings.TrimSpace(tab.SessionPath)
3629 }
3630 if sessionRuntimeKey(currentPath) == sessionRuntimeKey(sessionPath) {
3631 // Same session: leave any in-flight build alone — resuming the
3632 // session a build is already binding must stay a no-op.
3633 a.mu.Unlock()
3634 return nil
3635 }
3636 a.supersedeTabBuildLocked(tab)
3637 source := snapshotTabRuntimeLocked(tab)
3638 a.mu.Unlock()
3639
3640 // If the target session has a detached runtime (from a recent running-session
3641 // detach), reattach it instead of building a new controller. This avoids the
3642 // Windows LockFileEx/LOCKFILE_EXCLUSIVE_LOCK conflict where a second handle
3643 // from the same process cannot lock a file already held by the detached
3644 // controller's fd (#6955).
3645 targetKey := sessionRuntimeKey(sessionPath)
3646 a.mu.Lock()
3647 detached := a.detachedSessions[targetKey]
3648 hasDetached := detached != nil && detached.Ctrl != nil
3649 a.mu.Unlock()
3650
3651 if hasDetached {
3652 a.runtimeAdmissionMu.Lock()
3653 tab.turnStartMu.Lock()
3654
3655 a.mu.Lock()
3656 if tab.removed || a.tabs[tab.ID] != tab || tab.Ctrl != source.ctrl {
3657 a.mu.Unlock()
3658 tab.turnStartMu.Unlock()
3659 a.runtimeAdmissionMu.Unlock()
3660 return fmt.Errorf("tab changed while reattaching session; retry")
3661 }
3662 a.mu.Unlock()
3663
3664 if source.ctrl != nil {
3665 if err := a.snapshotTabForAction(tab, "switching sessions"); err != nil {
3666 tab.turnStartMu.Unlock()
3667 a.runtimeAdmissionMu.Unlock()
3668 return err
3669 }
3670 if oldPath := a.reconciledSessionPathForTab(tab); oldPath != "" {
3671 if err := a.saveTabSessionMeta(tab, oldPath); err != nil {
3672 tab.turnStartMu.Unlock()
3673 a.runtimeAdmissionMu.Unlock()
3674 return fmt.Errorf("save current session metadata before switching sessions: %w", err)
3675 }
3676 }
3677 }
3678
3679 a.mu.Lock()
3680 if tab.removed || a.tabs[tab.ID] != tab || tab.Ctrl != source.ctrl {
3681 a.mu.Unlock()
3682 tab.turnStartMu.Unlock()
3683 a.runtimeAdmissionMu.Unlock()
3684 return fmt.Errorf("tab changed while reattaching session; retry")
3685 }
3686 a.mu.Unlock()
3687
3688 detachSource := controllerHasActiveRuntimeWork(source.ctrl)
3689 oldCtrl, oldSink, oldLease, oldHostKey, attached := a.reattachDetachedSessionRuntimeForRebind(
3690 tab, source, sessionPath, detachSource,
3691 )
3692 if !attached {
3693 tab.turnStartMu.Unlock()
3694 a.runtimeAdmissionMu.Unlock()
3695 return fmt.Errorf("failed to reattach detached session runtime")
3696 }
3697
3698 if oldSink != nil {
3699 oldSink.setBinding("", nil)
3700 oldSink.clearContext()
3701 }
3702 if oldCtrl != nil {
3703 oldCtrl.Close()
3704 }
3705 if oldHostKey != "" {
3706 a.releaseSharedHost(oldHostKey)
3707 }
3708 if oldLease != nil {
3709 oldLease.Release()
3710 }
3711
3712 a.clearDeferredRebuildVersion(tab.ID, pendingSequence)
3713 a.emitReady(a.ctx, tab.ID)
3714
3715 tab.turnStartMu.Unlock()
3716 a.runtimeAdmissionMu.Unlock()
3717 return nil
3718 }
3719
3720 a.runtimeAdmissionMu.Lock()
3721 defer a.runtimeAdmissionMu.Unlock()
3722 tab.turnStartMu.Lock()
3723 defer tab.turnStartMu.Unlock()
3724 a.mu.Lock()
3725 if tab.removed || a.tabs[tab.ID] != tab || tab.Ctrl != source.ctrl {
3726 a.mu.Unlock()
3727 return fmt.Errorf("tab changed while preparing to switch sessions; retry")
3728 }
3729 source = snapshotTabRuntimeLocked(tab)
3730 a.mu.Unlock()
3731
3732 if source.ctrl != nil {
3733 if err := a.snapshotTabForAction(tab, "switching sessions"); err != nil {
3734 return err
3735 }
3736 if oldPath := a.reconciledSessionPathForTab(tab); oldPath != "" {
3737 if err := a.saveTabSessionMeta(tab, oldPath); err != nil {
3738 return fmt.Errorf("save current session metadata before switching sessions: %w", err)
3739 }
3740 }
3741 }
3742
3743 // Snapshot recovery may have retargeted the source controller and runtime.
3744 // Refresh the identity before reserving the target alias.
3745 a.mu.Lock()
3746 if tab.removed || a.tabs[tab.ID] != tab || tab.Ctrl != source.ctrl {
3747 a.mu.Unlock()
3748 return fmt.Errorf("tab changed while preparing to switch sessions; retry")
3749 }
3750 source = snapshotTabRuntimeLocked(tab)
3751 a.mu.Unlock()
3752
3753 transition, err := a.reserveSessionRuntimePath(tab, sessionPath)
3754 if err != nil {
3755 return userFacingSessionLeaseError("", err)
3756 }
3757 committed := false
3758 defer func() {
3759 if !committed {
3760 a.rollbackSessionRuntimePath(transition)
3761 }
3762 }()
3763
3764 profile := loadTabSessionProfile(sessionPath)
3765 detachSource := controllerHasActiveRuntimeWork(source.ctrl)
3766 candidateNeedsHostRef := detachSource || source.ctrl == nil
3767 candidate, err := a.buildSessionRebindCandidate(tab, source, sessionPath, loaded, profile, candidateNeedsHostRef)
3768 if err != nil {
3769 return fmt.Errorf("resume session: %w", err)
3770 }
3771 defer func() {
3772 if !committed {
3773 candidate.close()
3774 }
3775 }()
3776
3777 targetLease, err := a.acquireCandidateSessionLease(tab, sessionPath)
3778 if err != nil {
3779 return err
3780 }
3781 defer func() {
3782 if !committed {
3783 targetLease.Release()
3784 }
3785 }()
3786 if err := a.runRebindCandidateHook("lease_acquired"); err != nil {
3787 return fmt.Errorf("resume session: %w", err)
3788 }
3789
3790 // All fallible candidate work is complete. Revalidate the source runtime
3791 // generation, atomically publish the target controller/lease/profile/path,
3792 // and advance the epoch in the same App.mu commit.
3793 a.mu.Lock()
3794 if err := a.validateAndBindSessionRebindLocked(tab, source, transition, candidate, targetLease); err != nil {
3795 a.mu.Unlock()
3796 return err
3797 }
3798 var oldLease *agent.SessionLease
3799 oldCtrl := tab.Ctrl
3800 oldSink := tab.sink
3801 if detachSource {
3802 if !a.detachRuntimeForReplacementLocked(tab) {
3803 a.mu.Unlock()
3804 return fmt.Errorf("current session runtime cannot be detached")
3805 }
3806 if a.runtimeBySessionKey[transition.targetKey] == transition.runtime {
3807 delete(a.runtimeBySessionKey, transition.targetKey)
3808 }
3809 } else {
3810 if !a.commitSessionRuntimePathLocked(transition) {
3811 a.mu.Unlock()
3812 return fmt.Errorf("tab runtime changed while switching sessions; retry")
3813 }
3814 oldLease = tab.takeSessionLease()
3815 }
3816 tab.adoptSessionLease(targetLease)
3817 targetLease = nil
3818 tab.Ctrl = candidate.ctrl
3819 tab.sink = candidate.sink
3820 tab.SessionPath = sessionPath
3821 tab.SessionID = ""
3822 tab.SessionHeadID = ""
3823 tab.model = candidate.model
3824 tab.Label = candidate.ctrl.Label()
3825 applyNormalizedRuntimeToTabLocked(tab, candidate.runtime)
3826 tab.Ready = true
3827 clearTabStartupError(tab)
3828 tab.ActivityStatus = ""
3829 tab.replaceTelemetry(candidate.telemetry, sessionRuntimeKey(sessionPath))
3830 if tab.sink != nil {
3831 tab.sink.setBinding(tab.ID, a, tab.SessionGeneration)
3832 tab.sink.setContext(a.ctx)
3833 }
3834 // Wiring a mirror inspects App state under a read lock, so defer it until
3835 // after this transaction releases App.mu. The same applies to asynchronous
3836 // adoption: publishing the committed identity first lets its stale-result
3837 // fence observe one coherent runtime generation.
3838 shouldAdopt := !tab.ReadOnly
3839 if detachSource {
3840 a.newSessionRuntimeLocked(tab, transition.targetKey)
3841 }
3842 newEpoch := a.advanceSessionRuntimeEpochLocked(tab)
3843 a.saveTabsLocked()
3844 candidate.ctrl = nil
3845 candidate.sink = nil
3846 committed = true
3847 a.mu.Unlock()
3848 a.attachTakeoverMirror(tab.ID, sessionPath)
3849 if shouldAdopt {
3850 go a.adoptSessionFromLocalServe(tab.ID, sessionPath)
3851 }
3852 // Test-only observation point: the replacement is committed but the retired
3853 // sink still carries its old epoch. Production has no hook and immediately
3854 // fences that sink below.
3855 _ = a.runRebindCandidateHook("committed")
3856
3857 // Teardown happens after publication and outside App.mu. The old lease is
3858 // released only now, so every target failure above leaves source ownership
3859 // intact. Fence the retired sink before closing the old controller so a
3860 // close-time event cannot mutate or autosave the replacement runtime.
3861 if !detachSource {
3862 if oldSink != nil {
3863 oldSink.setBinding("", nil)
3864 oldSink.clearContext()
3865 }
3866 if oldCtrl != nil {
3867 oldCtrl.Close()
3868 }
3869 if oldLease != nil {
3870 oldLease.Release()
3871 }
3872 }
3873 a.persistTabSessionPath(tab, sessionPath)
3874 a.clearDeferredRebuildVersion(tab.ID, pendingSequence)
3875 a.notifyTabRuntimeRebuiltAtEpoch(tab, newEpoch)
3876 a.emitReady(a.ctx, tab.ID)
3877 return nil
3878 }
3879
3880 // reattachDetachedSessionRuntimeForRebind atomically replaces tab with the
3881 // already-running detached target. If the visible source is still active, its
3882 // controller, sink, lease, and runtime registry entry move to detachedSessions
3883 // in the same App.mu transaction; an idle source is returned for off-lock
3884 // teardown. The caller must hold runtimeRebuildMu, runtimeAdmissionMu, and
3885 // tab.turnStartMu so detachSource cannot become stale through new turn admission.
3886 func (a *App) reattachDetachedSessionRuntimeForRebind(
3887 tab *WorkspaceTab,
3888 source tabRuntimeSnapshot,
3889 sessionPath string,
3890 detachSource bool,
3891 ) (control.SessionAPI, *tabEventSink, *agent.SessionLease, string, bool) {
3892 key := sessionRuntimeKey(sessionPath)
3893 if tab == nil || key == "" {
3894 return nil, nil, nil, "", false
3895 }
3896
3897 a.mu.Lock()
3898 if tab.removed || a.tabs[tab.ID] != tab || tab.Ctrl != source.ctrl {
3899 a.mu.Unlock()
3900 return nil, nil, nil, "", false
3901 }
3902 detached := a.detachedSessions[key]
3903 if detached == nil || detached.Ctrl == nil {
3904 a.mu.Unlock()
3905 return nil, nil, nil, "", false
3906 }
3907 if rt := a.runtimeForTabLocked(detached); rt != nil {
3908 if rt.Phase != sessionRuntimeReady {
3909 a.mu.Unlock()
3910 return nil, nil, nil, "", false
3911 }
3912 } else if !detached.Ready {
3913 // Compatibility for detached runtimes created before the process-local
3914 // registry existed.
3915 a.mu.Unlock()
3916 return nil, nil, nil, "", false
3917 }
3918
3919 oldCtrl := tab.Ctrl
3920 oldSink := tab.sink
3921 var oldLease *agent.SessionLease
3922 oldHostKey := ""
3923 if detachSource {
3924 if !a.detachRuntimeForReplacementLocked(tab) {
3925 a.mu.Unlock()
3926 return nil, nil, nil, "", false
3927 }
3928 // Ownership moved to the detached clone. Nothing from the source may be
3929 // closed or released after the target becomes visible.
3930 oldCtrl = nil
3931 oldSink = nil
3932 } else {
3933 // Prevent applyRuntimeTab from overwriting resources owned by the idle
3934 // source. Teardown remains outside the app lock as on the normal rebuild
3935 // path; the detached target already owns a separate shared-host ref.
3936 oldLease = tab.takeSessionLease()
3937 oldHostKey = takeTabSharedHostKey(tab)
3938 }
3939
3940 delete(a.detachedSessions, key)
3941 applyRuntimeTab(tab, detached, sessionPath, a.ctx, a)
3942 a.saveTabsLocked()
3943 attachedCtrl := tab.Ctrl
3944 attachedSink := tab.sink
3945 attachedEpoch := a.runtimeEpochForTabLocked(tab)
3946 a.mu.Unlock()
3947
3948 a.replayPendingPromptsAfterRuntimeAttach(tab.ID, attachedSink, attachedCtrl, attachedEpoch)
3949 return oldCtrl, oldSink, oldLease, oldHostKey, true
3950 }
3951
3952 type sessionRebindCandidate struct {
3953 app *App
3954 ctrl control.SessionAPI
3955 sink *tabEventSink
3956 model string
3957 runtime normalizedTabRuntime
3958 telemetry tabTelemetrySnapshot
3959 sharedHostKey string
3960 ownsSharedHostRef bool
3961 }
3962
3963 func (c *sessionRebindCandidate) close() {
3964 if c == nil {
3965 return
3966 }
3967 if c.sink != nil {
3968 c.sink.clearContext()
3969 }
3970 if c.ctrl != nil {
3971 c.ctrl.Close()
3972 c.ctrl = nil
3973 }
3974 if c.ownsSharedHostRef && c.app != nil && c.sharedHostKey != "" {
3975 c.app.releaseSharedHost(c.sharedHostKey)
3976 c.ownsSharedHostRef = false
3977 }
3978 }
3979
3980 func normalizedRuntimeForSessionProfile(profile tabSessionProfile) normalizedTabRuntime {
3981 temp := &WorkspaceTab{}
3982 applyTabSessionProfile(temp, profile)
3983 return snapshotTabRuntimeLocked(temp).normalizedRuntime()
3984 }
3985
3986 func (a *App) runRebindCandidateHook(stage string) error {
3987 if a == nil || a.rebindCandidateHook == nil {
3988 return nil
3989 }
3990 return a.rebindCandidateHook(stage)
3991 }
3992
3993 func (a *App) buildSessionRebindCandidate(
3994 tab *WorkspaceTab,
3995 source tabRuntimeSnapshot,
3996 sessionPath string,
3997 loaded *agent.Session,
3998 profile tabSessionProfile,
3999 separateRuntime bool,
4000 ) (*sessionRebindCandidate, error) {
4001 root := strings.TrimSpace(source.workspaceRoot)
4002 if root == "" {
4003 if wd, err := os.Getwd(); err == nil {
4004 root = wd
4005 }
4006 }
4007 _ = config.MigrateLegacyCredentialsForRoot(root)
4008 cfg, err := config.LoadForRoot(root)
4009 if err != nil {
4010 return nil, err
4011 }
4012
4013 model := strings.TrimSpace(source.model)
4014 if sessionModel, ok := agent.LoadSessionModel(sessionPath); ok {
4015 config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, sessionModel)
4016 if _, ok := cfg.ResolveModel(sessionModel); ok {
4017 model = sessionModel
4018 }
4019 }
4020 if model == "" {
4021 model = cfg.DefaultModel
4022 }
4023 config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, model)
4024 if resolved, _, ok := cfg.ResolveModelWithFallback(model); ok {
4025 model = resolved
4026 }
4027
4028 sessionDir := controllerSessionDir(source.ctrl)
4029 if strings.TrimSpace(sessionDir) == "" {
4030 sessionDir = filepath.Dir(sessionPath)
4031 }
4032 sink := &tabEventSink{tabID: tab.ID, app: a}
4033 runtimeProfile := normalizedRuntimeForSessionProfile(profile)
4034 sharedHost := a.lookupSharedHost(source.sharedHostKey)
4035 ownsSharedHostRef := false
4036 if separateRuntime && source.sharedHostKey != "" {
4037 sharedHost = a.acquireSharedHost(source.sharedHostKey)
4038 ownsSharedHostRef = true
4039 }
4040 if _, err := loadPinnedContextState(sessionPath); err != nil {
4041 return nil, err
4042 }
4043 nativeService, err := a.historicalSessionService(desktopSessionRoot(filepath.Dir(sessionPath)))
4044 if err != nil {
4045 return nil, err
4046 }
4047 ctrl, err := a.buildTabControllerBoot(a.bootContext(), boot.Options{
4048 Model: model,
4049 RequireKey: false,
4050 StatsSource: "desktop",
4051 TaskStore: a.taskStore(),
4052 OnConfigLoadWarnings: a.configLoadWarningsHandler(),
4053 Sink: a.desktopControllerSink(sink, cfg.Notifications),
4054 WorkspaceRoot: root,
4055 SessionDir: sessionDir,
4056 NativeLegacySession: true,
4057 SessionService: nativeService,
4058 EffortOverride: cloneStringPtr(source.effort),
4059 EffortModel: source.model,
4060 SharedHost: sharedHost, BrowserExecutor: a.browserExecutorForRuntime(tab.ID, sink),
4061 MCPHostProfile: plugin.HostProfileDesktopApps,
4062 CleanupPendingReconciler: reconcileDesktopCleanupPending,
4063 SubagentParentLive: a.subagentParentProbeForBuild(tab),
4064 SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
4065 PinnedContextLoader: pinnedContextLoader(root),
4066 OnSessionRecovered: a.handleTabSessionRecovered(tab),
4067 OnSessionTransition: a.handleTabSessionTransition(tab),
4068 BeforeInboxDispatch: a.beforeInboxDispatch,
4069 OnSessionTitleChanged: a.onSessionTitleChanged,
4070 })
4071 if err != nil {
4072 sink.clearContext()
4073 if ownsSharedHostRef {
4074 a.releaseSharedHost(source.sharedHostKey)
4075 }
4076 return nil, err
4077 }
4078 candidate := &sessionRebindCandidate{
4079 app: a, ctrl: ctrl, sink: sink, model: model, runtime: runtimeProfile,
4080 sharedHostKey: source.sharedHostKey, ownsSharedHostRef: ownsSharedHostRef,
4081 }
4082 a.bindControllerDisplayRecorder(ctrl)
4083 configureControllerRuntime(ctrl, nil, runtimeProfile)
4084 if err := a.runRebindCandidateHook("built"); err != nil {
4085 candidate.close()
4086 return nil, err
4087 }
4088 restoredRuntime, err := resumeControllerRuntimeWithSession(ctrl, loaded, sessionPath, runtimeProfile)
4089 if err != nil {
4090 candidate.close()
4091 return nil, err
4092 }
4093 candidate.runtime = restoredRuntime
4094 candidate.telemetry = loadTelemetry(sessionPath + ".telemetry.json")
4095 if err := a.runRebindCandidateHook("restored"); err != nil {
4096 candidate.close()
4097 return nil, err
4098 }
4099 return candidate, nil
4100 }
4101
4102 func (a *App) acquireCandidateSessionLease(tab *WorkspaceTab, path string) (*agent.SessionLease, error) {
4103 lease, err := withSessionLeaseContentionRetry(func() (*agent.SessionLease, error) {
4104 lease, err := agent.TryAcquireSessionLease(path)
4105 if err == nil {
4106 return lease, nil
4107 }
4108 if a.canReclaimCurrentProcessSessionLease(tab, path, err) {
4109 if reclaimed, reclaimErr := agent.TryReclaimCurrentProcessSessionLease(path); reclaimErr == nil {
4110 return reclaimed, nil
4111 } else {
4112 err = reclaimErr
4113 }
4114 }
4115 return nil, err
4116 })
4117 if err != nil {
4118 return nil, userFacingSessionLeaseError("", err)
4119 }
4120 return lease, nil
4121 }
4122
4123 func loadResumableSession(sessionPath string) (*agent.Session, error) {
4124 if agent.IsCleanupPending(sessionPath) {
4125 return nil, fmt.Errorf("session is pending cleanup")
4126 }
4127 return agent.LoadSession(sessionPath)
4128 }
4129
4130 func loadResumableSessionContext(ctx context.Context, sessionPath string) (*agent.Session, error) {
4131 if agent.IsCleanupPending(sessionPath) {
4132 return nil, fmt.Errorf("session is pending cleanup")
4133 }
4134 return agent.LoadSessionContext(ctx, sessionPath)
4135 }
4136
4137 // PreviewSession reads a saved session for display only. It does not snapshot or
4138 // swap the active controller, so the history drawer can call it while a turn runs.
4139 func (a *App) PreviewSession(path string) ([]HistoryMessage, error) {
4140 sessionDir, sessionPath, err := a.sessionDirForPath(path)
4141 if err != nil {
4142 return nil, err
4143 }
4144 return previewSessionMessages(sessionDir, sessionPath)
4145 }
4146
4147 // invalidatePromptHistoryCache resets the lazy prompt-history tape so the next
4148 // ScanPromptHistory call rebuilds session order and reloads sessions on demand.
4149 // Called from every session-mutating path: NewSession, ClearSession,
4150 // DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession.
4151 func (a *App) invalidatePromptHistoryCache() {
4152 a.promptHistoryMu.Lock()
4153 a.promptHistoryTape = nil
4154 a.promptHistoryMu.Unlock()
4155 }
4156
4157 const (
4158 promptHistoryPageLimit = 50
4159 promptHistoryMaxPageLimit = 200
4160 )
4161
4162 type promptHistoryRequest struct {
4163 Nonce string `json:"nonce,omitempty"`
4164 Cursor string `json:"cursor,omitempty"`
4165 Limit int `json:"limit,omitempty"`
4166 legacy bool
4167 }
4168
4169 type promptHistoryCursor struct {
4170 Nonce string `json:"n"`
4171 Session int `json:"s"`
4172 Offset int `json:"o"`
4173 }
4174
4175 type promptHistoryTape struct {
4176 nonce string
4177 dir string
4178 currentPath string
4179 displays sessionDisplayMap
4180 sessions []promptHistorySessionFile
4181 loaded map[string][]PromptHistoryEntry
4182 }
4183
4184 // ScanPromptHistory returns the next prompt-history tape segment. The request is
4185 // a JSON string so the Wails binding stays one-argument while the protocol can
4186 // carry a cursor and page limit. Older clients may still pass a bare nonce; that
4187 // path keeps the old cache-hit behavior.
4188 func (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) {
4189 req := parsePromptHistoryRequest(rawRequest)
4190 dir := a.activeSessionDir()
4191 sessionPath := a.activeSessionPath(dir)
4192
4193 a.promptHistoryMu.Lock()
4194 tape, err := a.promptHistoryTapeForLocked(dir, sessionPath)
4195 if err != nil {
4196 a.promptHistoryMu.Unlock()
4197 return PromptHistoryResult{}, err
4198 }
4199 if req.legacy && req.Nonce != "" && req.Nonce == tape.nonce {
4200 a.promptHistoryMu.Unlock()
4201 return PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil
4202 }
4203 result := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit))
4204 a.promptHistoryMu.Unlock()
4205 return result, nil
4206 }
4207
4208 func parsePromptHistoryRequest(raw string) promptHistoryRequest {
4209 raw = strings.TrimSpace(raw)
4210 if raw == "" {
4211 return promptHistoryRequest{}
4212 }
4213 if strings.HasPrefix(raw, "{") {
4214 var req promptHistoryRequest
4215 if err := json.Unmarshal([]byte(raw), &req); err == nil {
4216 return req
4217 }
4218 }
4219 return promptHistoryRequest{Nonce: raw, legacy: true}
4220 }
4221
4222 func promptHistoryLimit(limit int) int {
4223 if limit <= 0 {
4224 return promptHistoryPageLimit
4225 }
4226 if limit > promptHistoryMaxPageLimit {
4227 return promptHistoryMaxPageLimit
4228 }
4229 return limit
4230 }
4231
4232 func (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) {
4233 currentPath := ""
4234 if path, _, err := validateSessionPath(dir, sessionPath); err == nil {
4235 currentPath = path
4236 }
4237 if a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath {
4238 return a.promptHistoryTape, nil
4239 }
4240 tape, err := newPromptHistoryTape(dir, currentPath)
4241 if err != nil {
4242 return nil, err
4243 }
4244 a.promptHistoryTape = tape
4245 return tape, nil
4246 }
4247
4248 func (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) {
4249 tape, err := newPromptHistoryTape(dir, "")
4250 if err != nil {
4251 return nil, err
4252 }
4253 return tape.readAll(), nil
4254 }
4255
4256 func newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) {
4257 tape := &promptHistoryTape{
4258 nonce: rand.Text(),
4259 dir: dir,
4260 currentPath: currentPath,
4261 displays: loadSessionDisplays(dir),
4262 loaded: map[string][]PromptHistoryEntry{},
4263 }
4264 sessions, err := promptHistorySessionFiles(dir)
4265 if err != nil {
4266 return nil, err
4267 }
4268 if currentPath != "" {
4269 currentPath = filepath.Clean(currentPath)
4270 currentSession := promptHistorySessionFile{}
4271 currentIndex := -1
4272 for i, session := range sessions {
4273 if filepath.Clean(session.path) == currentPath {
4274 currentSession = session
4275 currentIndex = i
4276 break
4277 }
4278 }
4279 if currentIndex >= 0 {
4280 sessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...)
4281 } else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() {
4282 sessions = append([]promptHistorySessionFile{{
4283 path: currentPath,
4284 }}, sessions...)
4285 }
4286 }
4287 tape.sessions = sessions
4288 return tape, nil
4289 }
4290
4291 func (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult {
4292 c := promptHistoryCursor{Nonce: t.nonce}
4293 if decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce {
4294 c = decoded
4295 }
4296 if c.Session < 0 {
4297 c.Session = 0
4298 }
4299 if c.Offset < 0 {
4300 c.Offset = 0
4301 }
4302
4303 out := make([]PromptHistoryEntry, 0, limit)
4304 sessionIndex := c.Session
4305 offset := c.Offset
4306 for sessionIndex < len(t.sessions) && len(out) < limit {
4307 entries, err := t.entriesForSession(sessionIndex)
4308 if err != nil || offset >= len(entries) {
4309 sessionIndex++
4310 offset = 0
4311 continue
4312 }
4313
4314 end := min(len(entries), offset+limit-len(out))
4315 out = append(out, entries[offset:end]...)
4316 offset = end
4317 if offset >= len(entries) && len(out) < limit {
4318 sessionIndex++
4319 offset = 0
4320 }
4321 }
4322
4323 if sessionIndex < len(t.sessions) {
4324 if entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) {
4325 sessionIndex++
4326 offset = 0
4327 }
4328 }
4329 hasOlder := sessionIndex < len(t.sessions)
4330 olderCursor := ""
4331 if hasOlder {
4332 olderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset})
4333 }
4334 return PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder}
4335 }
4336
4337 func (t *promptHistoryTape) readAll() []PromptHistoryEntry {
4338 out := []PromptHistoryEntry{}
4339 cursor := ""
4340 for {
4341 page := t.readOlder(cursor, promptHistoryMaxPageLimit)
4342 out = append(out, page.Entries...)
4343 if !page.HasOlder || page.OlderCursor == "" {
4344 return out
4345 }
4346 cursor = page.OlderCursor
4347 }
4348 }
4349
4350 func (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) {
4351 if index < 0 || index >= len(t.sessions) {
4352 return nil, nil
4353 }
4354 path := t.sessions[index].path
4355 if entries, ok := t.loaded[path]; ok {
4356 return entries, nil
4357 }
4358 info, err := os.Stat(path)
4359 if err != nil {
4360 t.loaded[path] = nil
4361 if os.IsNotExist(err) {
4362 return nil, nil
4363 }
4364 return nil, err
4365 }
4366 entries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path))
4367 if err != nil {
4368 t.loaded[path] = nil
4369 return nil, err
4370 }
4371 t.loaded[path] = entries
4372 return entries, nil
4373 }
4374
4375 func encodePromptHistoryCursor(cursor promptHistoryCursor) string {
4376 b, err := json.Marshal(cursor)
4377 if err != nil {
4378 return ""
4379 }
4380 return base64.RawURLEncoding.EncodeToString(b)
4381 }
4382
4383 func decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) {
4384 if strings.TrimSpace(value) == "" {
4385 return promptHistoryCursor{}, false
4386 }
4387 b, err := base64.RawURLEncoding.DecodeString(value)
4388 if err != nil {
4389 return promptHistoryCursor{}, false
4390 }
4391 var cursor promptHistoryCursor
4392 if err := json.Unmarshal(b, &cursor); err != nil {
4393 return promptHistoryCursor{}, false
4394 }
4395 return cursor, true
4396 }
4397
4398 func scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {
4399 entries, err := collectPromptHistoryEntries(path, info, resolveUserContent)
4400 if err != nil {
4401 return nil, err
4402 }
4403 sortPromptHistoryNewestFirst(entries)
4404 return entries, nil
4405 }
4406
4407 type promptHistorySessionFile struct {
4408 path string
4409 }
4410
4411 func promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) {
4412 infos, err := agent.ListSessionOrder(dir)
4413 if err != nil {
4414 return nil, err
4415 }
4416 sessions := make([]promptHistorySessionFile, 0, len(infos))
4417 for _, info := range infos {
4418 sessions = append(sessions, promptHistorySessionFile{path: info.Path})
4419 }
4420 return sessions, nil
4421 }
4422
4423 func promptHistoryEntryNewer(a, b PromptHistoryEntry) bool {
4424 if a.At != b.At {
4425 return a.At > b.At
4426 }
4427 if a.SessionPath != b.SessionPath {
4428 return a.SessionPath > b.SessionPath
4429 }
4430 return a.Turn > b.Turn
4431 }
4432
4433 func sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) {
4434 sort.Slice(entries, func(i, j int) bool {
4435 return promptHistoryEntryNewer(entries[i], entries[j])
4436 })
4437 }
4438
4439 func collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) {
4440 var out []PromptHistoryEntry
4441 emit := func(entry PromptHistoryEntry) {
4442 out = append(out, entry)
4443 }
4444 // Sessions with an event log must replay it: the .jsonl checkpoint stops
4445 // gaining turns between checkpoints, so scanning it directly would freeze
4446 // ↑-recall at each session's last checkpoint.
4447 if handled, err := collectEventLogUserPrompts(path, info, resolveUserContent, emit); handled {
4448 return out, err
4449 }
4450 err := collectJSONLUserPrompts(path, info, resolveUserContent, emit)
4451 return out, err
4452 }
4453
4454 func collectEventLogUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) (bool, error) {
4455 logPath := store.SessionEventLog(path)
4456 if logPath == "" {
4457 return false, nil
4458 }
4459 if logInfo, err := os.Stat(logPath); err != nil || logInfo.IsDir() || logInfo.Size() == 0 {
4460 return false, nil
4461 }
4462 users, err := agent.LoadSessionUserMessages(path)
4463 if err != nil {
4464 return true, err
4465 }
4466 fallbackAt := promptHistoryFallbackMillis(path, info)
4467 turn := 0
4468 for _, user := range users {
4469 if !agent.IsUserAuthoredTurnMessage(user.Message) {
4470 continue
4471 }
4472 text := sessionUserPromptText(user.Message, resolveUserContent)
4473 if text == "" {
4474 continue
4475 }
4476 at := fallbackAt
4477 if !user.At.IsZero() {
4478 at = user.At.UnixMilli()
4479 }
4480 emit(PromptHistoryEntry{
4481 Text: text,
4482 At: at,
4483 SessionPath: path,
4484 Turn: turn,
4485 })
4486 turn++
4487 }
4488 return true, nil
4489 }
4490
4491 func collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error {
4492 f, err := os.Open(path)
4493 if err != nil {
4494 return err
4495 }
4496 defer f.Close()
4497
4498 fallbackAt := promptHistoryFallbackMillis(path, info)
4499
4500 dec := json.NewDecoder(f)
4501 turn := 0
4502 for {
4503 var rec previewEventRecord
4504 if err := dec.Decode(&rec); err != nil {
4505 if errors.Is(err, io.EOF) {
4506 break
4507 }
4508 return nil // partial results are better than none
4509 }
4510 // Format compatibility:
4511 // 1) Legacy event format: {"kind":"user.message","text":"..."}
4512 // 2) Early event format: {"type":"user.message","text":"..."}
4513 // 3) Current provider.Message format: {"role":"user","content":"..."}
4514 var message provider.Message
4515 kindOrType := strings.TrimSpace(rec.Kind)
4516 if kindOrType == "" {
4517 kindOrType = strings.TrimSpace(rec.Type)
4518 }
4519 if kindOrType == "user.message" {
4520 message = provider.Message{Role: provider.RoleUser, Content: strings.TrimSpace(rec.Text)}
4521 } else if strings.TrimSpace(rec.Role) == "user" {
4522 message = provider.Message{
4523 Role: provider.RoleUser, Origin: rec.Origin,
4524 Content: strings.TrimSpace(rec.Content), RawContent: strings.TrimSpace(rec.RawContent),
4525 }
4526 }
4527 if message.Content != "" {
4528 if !agent.IsUserAuthoredTurnMessage(message) {
4529 continue
4530 }
4531 text := sessionUserPromptText(message, resolveUserContent)
4532 if text == "" {
4533 continue
4534 }
4535 at := fallbackAt
4536 if eventAt, ok := promptHistoryEventMillis(rec); ok {
4537 at = eventAt
4538 }
4539 entry := PromptHistoryEntry{
4540 Text: text,
4541 At: at,
4542 SessionPath: path,
4543 Turn: turn,
4544 }
4545 emit(entry)
4546 turn++
4547 }
4548 }
4549 return nil
4550 }
4551
4552 func sessionUserPromptText(message provider.Message, resolveUserContent func(string) string) string {
4553 if strings.TrimSpace(message.RawContent) != "" {
4554 return strings.TrimSpace(agent.UserMessageText(message))
4555 }
4556 return strings.TrimSpace(resolveUserContent(strings.TrimSpace(message.Content)))
4557 }
4558
4559 func promptHistoryFallbackMillis(path string, info os.FileInfo) int64 {
4560 if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() {
4561 return meta.UpdatedAt.UnixMilli()
4562 }
4563 if info != nil {
4564 return info.ModTime().UnixMilli()
4565 }
4566 return 0
4567 }
4568
4569 func promptHistoryEventMillis(rec previewEventRecord) (int64, bool) {
4570 for _, raw := range []json.RawMessage{
4571 rec.TS,
4572 rec.Time,
4573 rec.Timestamp,
4574 rec.CreatedAt,
4575 rec.CreatedAtSnake,
4576 rec.UpdatedAt,
4577 rec.UpdatedAtSnake,
4578 } {
4579 if at, ok := parseJSONTimestampMillis(raw); ok {
4580 return at, true
4581 }
4582 }
4583 return 0, false
4584 }
4585
4586 func parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) {
4587 if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
4588 return 0, false
4589 }
4590
4591 var s string
4592 if err := json.Unmarshal(raw, &s); err == nil {
4593 s = strings.TrimSpace(s)
4594 if s == "" {
4595 return 0, false
4596 }
4597 if n, err := strconv.ParseInt(s, 10, 64); err == nil {
4598 return normalizeTimestampMillis(n)
4599 }
4600 if f, err := strconv.ParseFloat(s, 64); err == nil {
4601 return normalizeTimestampMillisFloat(f)
4602 }
4603 if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
4604 return t.UnixMilli(), true
4605 }
4606 return 0, false
4607 }
4608
4609 dec := json.NewDecoder(bytes.NewReader(raw))
4610 dec.UseNumber()
4611 var n json.Number
4612 if err := dec.Decode(&n); err != nil {
4613 return 0, false
4614 }
4615 if i, err := strconv.ParseInt(n.String(), 10, 64); err == nil {
4616 return normalizeTimestampMillis(i)
4617 }
4618 if f, err := strconv.ParseFloat(n.String(), 64); err == nil {
4619 return normalizeTimestampMillisFloat(f)
4620 }
4621 return 0, false
4622 }
4623
4624 func normalizeTimestampMillis(v int64) (int64, bool) {
4625 if v <= 0 {
4626 return 0, false
4627 }
4628 switch {
4629 case v >= 1_000_000_000_000_000_000:
4630 return v / 1_000_000, true // nanoseconds
4631 case v >= 1_000_000_000_000_000:
4632 return v / 1_000, true // microseconds
4633 case v >= 100_000_000_000:
4634 return v, true // milliseconds
4635 case v >= 1_000_000_000:
4636 return v * 1_000, true // seconds
4637 default:
4638 return 0, false
4639 }
4640 }
4641
4642 func normalizeTimestampMillisFloat(v float64) (int64, bool) {
4643 if v <= 0 {
4644 return 0, false
4645 }
4646 switch {
4647 case v >= 1_000_000_000_000_000_000:
4648 return int64(v / 1_000_000), true
4649 case v >= 1_000_000_000_000_000:
4650 return int64(v / 1_000), true
4651 case v >= 100_000_000_000:
4652 return int64(v), true
4653 case v >= 1_000_000_000:
4654 return int64(v * 1_000), true
4655 default:
4656 return 0, false
4657 }
4658 }
4659
4660 // PickWorkspace opens a folder chooser and, on a pick, opens a new project tab
4661 // scoped to that folder. Returns the chosen path ("" if cancelled).
4662 func (a *App) PickWorkspace() (string, error) {
4663 if a.ctx == nil {
4664 return "", nil
4665 }
4666 cur, _ := os.Getwd()
4667 a.mu.RLock()
4668 if tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != "" {
4669 cur = tab.WorkspaceRoot
4670 }
4671 a.mu.RUnlock()
4672 dir, err := a.nativeHost().OpenDirectoryDialog(a.ctx, nativeDialogOptions{
4673 Title: "Choose working folder",
4674 DefaultDirectory: dialogDefaultDirectory(cur),
4675 })
4676 if err != nil || dir == "" {
4677 return "", err
4678 }
4679 return a.SwitchWorkspace(dir)
4680 }
4681
4682 func dialogDefaultDirectory(preferred string) string {
4683 if dir := nearestExistingDirectory(preferred); dir != "" {
4684 return dir
4685 }
4686 if cwd, err := os.Getwd(); err == nil {
4687 if dir := nearestExistingDirectory(cwd); dir != "" {
4688 return dir
4689 }
4690 }
4691 if home, err := os.UserHomeDir(); err == nil {
4692 if dir := nearestExistingDirectory(home); dir != "" {
4693 return dir
4694 }
4695 }
4696 return ""
4697 }
4698
4699 func nearestExistingDirectory(path string) string {
4700 path = strings.TrimSpace(path)
4701 if path == "" {
4702 return ""
4703 }
4704 if abs, err := filepath.Abs(path); err == nil {
4705 path = abs
4706 }
4707 for {
4708 info, err := os.Stat(path)
4709 if err == nil {
4710 if info.IsDir() {
4711 return path
4712 }
4713 path = filepath.Dir(path)
4714 continue
4715 }
4716 parent := filepath.Dir(path)
4717 if parent == path {
4718 return ""
4719 }
4720 path = parent
4721 }
4722 }
4723
4724 func (a *App) ListWorkspaces() []WorkspaceMeta {
4725 migrateLegacyWorkspacesIntoProjects()
4726 activeRoot := ""
4727 cur, _ := os.Getwd()
4728 a.mu.RLock()
4729 if tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != "" {
4730 activeRoot = normalizeProjectRoot(tab.WorkspaceRoot)
4731 }
4732 a.mu.RUnlock()
4733 if activeRoot == "" {
4734 activeRoot = normalizeProjectRoot(cur)
4735 }
4736 projects := loadProjectsFile().Projects
4737 out := make([]WorkspaceMeta, 0, len(projects))
4738 for _, project := range projects {
4739 out = append(out, WorkspaceMeta{
4740 Path: project.Root,
4741 Name: projectDisplayName(project),
4742 Current: activeRoot != "" && sameProjectRoot(project.Root, activeRoot),
4743 })
4744 }
4745 return out
4746 }
4747
4748 func (a *App) RemoveWorkspace(dir string) error {
4749 if dir == "" {
4750 return fmt.Errorf("workspace path is required")
4751 }
4752 dir = normalizeProjectRoot(dir)
4753 a.singleSurfaceMu.Lock()
4754 defer a.singleSurfaceMu.Unlock()
4755 releaseRemoval, err := a.reserveWorkspaceRemoval(dir)
4756 if err != nil {
4757 return err
4758 }
4759 defer releaseRemoval()
4760
4761 var fallback *WorkspaceTab
4762 // sessionRemovalMu covers every step that can still touch this workspace's
4763 // session files: snapshotting, unlinking the tab/runtime bindings, and
4764 // closing the unlinked runtimes (quiescing autosave). Once a runtime is
4765 // unlinked from a.tabs/detachedSessions it is invisible to
4766 // DeleteSession/TrashTopic/RestoreSession, so it must stop writing before
4767 // the lock is released. Durable project bookkeeping precedes unlinking;
4768 // the fallback controller build and notifications run after release.
4769 if err := func() error {
4770 defer a.lockRuntimeMutation("remove-workspace")()
4771 a.sessionRemovalMu.Lock()
4772 defer a.sessionRemovalMu.Unlock()
4773
4774 candidates, err := a.snapshotWorkspaceTabsForRemoval(dir)
4775 if err != nil {
4776 return err
4777 }
4778 if err := a.hideWorkspaceForRemoval(dir); err != nil {
4779 return err
4780 }
4781 var closeTabs, closeDetached []*WorkspaceTab
4782 fallback, closeTabs, closeDetached = a.unlinkWorkspaceTabsForRemoval(dir, candidates)
4783
4784 for _, tab := range closeTabs {
4785 a.closeTabRuntimeAdmissionHeld(tab)
4786 }
4787 for _, tab := range closeDetached {
4788 a.closeTabRuntimeAdmissionHeld(tab)
4789 }
4790 return nil
4791 }(); err != nil {
4792 return err
4793 }
4794
4795 // The fallback tab is already linked into a.tabs; its controller build is
4796 // asynchronous and does not touch removed session files, so it does not
4797 // need the removal lock.
4798 if fallback != nil {
4799 a.startTabControllerBuild(fallback)
4800 }
4801
4802 forgetWorkspace(dir)
4803 // If the removed workspace was the active one, clear the pointer
4804 // so we don't leave a stale reference to a deleted project.
4805 if loadWorkspace() == dir {
4806 if remaining := loadProjectsFile(); len(remaining.Projects) > 0 {
4807 // Fall back to the first remaining project
4808 saveWorkspace(remaining.Projects[0].Root)
4809 } else {
4810 // No projects left; clear the active pointer entirely
4811 clearWorkspace()
4812 }
4813 }
4814 a.emitProjectTreeMetadataChanged()
4815 return nil
4816 }
4817
4818 func migrateLegacyWorkspacesIntoProjects() {
4819 legacy := loadWorkspaces()
4820 if len(legacy) == 0 {
4821 return
4822 }
4823 _ = updateProjectsFilePreservingLegacyState(func(f *desktopProjectFile) (bool, error) {
4824 seen := make(map[string]bool, len(f.Projects)+len(legacy))
4825 for _, p := range f.Projects {
4826 seen[p.Root] = true
4827 }
4828 changed := false
4829 for _, path := range legacy {
4830 root := normalizeProjectRoot(path)
4831 if root == "" || seen[root] {
4832 continue
4833 }
4834 f.Projects = append(f.Projects, desktopProject{Root: root})
4835 seen[root] = true
4836 changed = true
4837 }
4838 return changed, nil
4839 })
4840 }
4841
4842 func workspaceName(path string) string {
4843 name := filepath.Base(path)
4844 if name == "." || name == string(filepath.Separator) || name == "" {
4845 return path
4846 }
4847 return name
4848 }
4849
4850 // tabWorkspaceNameForScope resolves the display name for a tab's workspace.
4851 // Callers pass tab.Scope copied under a.mu instead of re-reading the tab.
4852 func tabWorkspaceNameForScope(scope, cwd string) string {
4853 if scope == "global" {
4854 return globalProjectTitle()
4855 }
4856 return workspaceName(cwd)
4857 }
4858
4859 func (a *App) SwitchWorkspace(dir string) (string, error) {
4860 if dir == "" {
4861 home, err := os.UserHomeDir()
4862 if err != nil {
4863 return "", err
4864 }
4865 dir = home
4866 }
4867 if abs, err := filepath.Abs(dir); err == nil {
4868 dir = abs
4869 }
4870 info, err := os.Stat(dir)
4871 if err != nil {
4872 return "", err
4873 }
4874 if !info.IsDir() {
4875 return "", fmt.Errorf("%s is not a directory", dir)
4876 }
4877 // Selecting a workspace is navigation, not a new-session command. Serialize
4878 // selection and initial-topic creation so duplicate/retried opens reuse it.
4879 navigation := a.desktopSessions.navigationSeq.Add(1)
4880 a.singleSurfaceMu.Lock()
4881 defer a.singleSurfaceMu.Unlock()
4882 if a.desktopSessions.navigationSeq.Load() != navigation {
4883 return "", errSessionNavigationSuperseded
4884 }
4885 releaseAdmission, err := a.beginProjectRuntimeAdmission("project", dir)
4886 if err != nil {
4887 return "", err
4888 }
4889 releaseAdmission()
4890
4891 // Adding a folder is an explicit request to show that workspace again.
4892 // EnsureWorkspaceResolved deliberately preserves an existing workspace's
4893 // presentation, including Visible=false after RemoveWorkspace, so restore
4894 // visibility through the dedicated presentation mutation before opening it.
4895 workspaceID, err := a.ensureDesktopWorkspace(a.bootContext(), "project", dir)
4896 if err != nil {
4897 return "", err
4898 }
4899 registry := a.workspaceRegistry()
4900 state, err := registry.Load(a.bootContext())
4901 if err != nil {
4902 return "", err
4903 }
4904 wasVisible := state.Workspaces[workspaceID].Visible
4905 if !wasVisible {
4906 if err := registry.SetWorkspaceVisible(a.bootContext(), workspaceID, true); err != nil {
4907 return "", err
4908 }
4909 }
4910 rollbackVisibility := func(cause error) error {
4911 if wasVisible {
4912 return cause
4913 }
4914 if restoreErr := registry.SetWorkspaceVisible(a.bootContext(), workspaceID, false); restoreErr != nil {
4915 return errors.Join(cause, fmt.Errorf("restore workspace visibility: %w", restoreErr))
4916 }
4917 return cause
4918 }
4919
4920 // Ensure project metadata is present before querying its existing topics,
4921 // including placeholders left by an interrupted initial open.
4922 if err := addProject(dir, ""); err != nil {
4923 return "", rollbackVisibility(err)
4924 }
4925 topicID, sessionPath, err := a.workspaceEntryConversation(dir)
4926 if err != nil {
4927 return "", rollbackVisibility(err)
4928 }
4929 if topicID == "" && sessionPath == "" {
4930 topic, err := a.CreateTopic("project", dir, "")
4931 if err != nil {
4932 return "", rollbackVisibility(err)
4933 }
4934 topicID = topic.ID
4935 }
4936 meta, err := a.activateTopicLocked("project", dir, topicID, sessionPath, navigation)
4937 if err != nil {
4938 return "", rollbackVisibility(err)
4939 }
4940 if !wasVisible {
4941 // A restored workspace can reuse an existing topic, so no topic-created
4942 // event follows the visibility write. Publish the completed membership
4943 // change for already-mounted project trees as well as fresh readers.
4944 a.emitProjectTreeMetadataChanged()
4945 }
4946 return meta.WorkspaceRoot, nil
4947 }
4948
4949 // HistoryMessage is one prior turn, for the frontend to repopulate its transcript
4950 // after a reload.
4951 type HistoryMessage = transcript.Message
4952
4953 func interruptedTurnHistoryNotice(recovery *provider.InterruptedTurnRecovery) HistoryMessage {
4954 if recovery != nil && recovery.TerminalStatus == "failed" {
4955 diagnostic := recovery.FailureDiagnostic
4956 message := "The provider request failed. Check the connection settings and try again."
4957 detail := provider.FailureDiagnosticDetail(diagnostic)
4958 if diagnostic != nil {
4959 if statusMessage := i18n.M.ProviderStatusMessage(diagnostic.Status); statusMessage != "" {
4960 message = statusMessage
4961 } else if diagnostic.Status > 0 {
4962 message = fmt.Sprintf("Provider request failed (HTTP %d).", diagnostic.Status)
4963 }
4964 label := provider.ProviderDisplayLabel(diagnostic.ProviderID, diagnostic.ProviderDisplayName, diagnostic.Protocol)
4965 if label != "" {
4966 message = label + ": " + message
4967 }
4968 }
4969 return HistoryMessage{Role: "notice", Level: "warn", Code: event.NoticeCodeProviderRequestFailed, Content: message, Detail: detail, Diagnostic: diagnostic}
4970 }
4971 return HistoryMessage{
4972 Role: "notice", Level: "info", Code: event.NoticeCodeCancelledTurn,
4973 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.",
4974 }
4975 }
4976
4977 type HistoryToolCall = transcript.ToolCall
4978
4979 const (
4980 defaultHistoryPageTurns = 60
4981 maxHistoryPageTurns = 200
4982 )
4983
4984 type HistoryPage struct {
4985 Messages []HistoryMessage `json:"messages"`
4986 StartTurn int `json:"startTurn"`
4987 EndTurn int `json:"endTurn"`
4988 TotalTurns int `json:"totalTurns"`
4989 HasOlder bool `json:"hasOlder"`
4990 Revision int64 `json:"revision,omitempty"`
4991 Digest string `json:"digest,omitempty"`
4992 Switch *HistorySwitchPhases `json:"switch,omitempty"`
4993 }
4994
4995 // HistorySwitchPhases records a session adoption and optional legacy page.
4996 // It carries durations, counts and sizes, never paths or message content.
4997 // Snapshot adoption leaves HistoryMs and HistoryCount zero; the frontend
4998 // measures its authoritative snapshot separately. A changed controller may
4999 // require another durable read instead of reusing an obsolete preload.
5000 type HistorySwitchPhases struct {
5001 ResolveMs int64 `json:"resolveMs"`
5002 LoadMs int64 `json:"loadMs"`
5003 RebindMs int64 `json:"rebindMs"`
5004 HistoryMs int64 `json:"historyMs"`
5005 TotalMs int64 `json:"totalMs"`
5006 LoadedCount int `json:"loadedMessages"`
5007 LoadedBytes int64 `json:"loadedBytes"`
5008 HistoryCount int `json:"historyEntries"`
5009 // DurableReads counts target log reads, including refreshes after preload invalidation.
5010 DurableReads int `json:"durableReads"`
5011 Outcome string `json:"outcome"`
5012 }
5013
5014 // historyProviderMessagesWithPersistedTimes overlays legacy event-record
5015 // timestamps onto a copy for display. It deliberately leaves the controller's
5016 // provider transcript untouched: timestamp migration must not change session
5017 // digests, conflict detection, or model-request cache prefixes.
5018 func historyProviderMessagesWithPersistedTimes(msgs []provider.Message, sessionPath string) []provider.Message {
5019 if len(msgs) == 0 || strings.TrimSpace(sessionPath) == "" {
5020 return msgs
5021 }
5022 needsPersistedTime := false
5023 for _, msg := range msgs {
5024 if msg.CreatedAt <= 0 && agent.IsUserAuthoredTurnMessage(msg) {
5025 needsPersistedTime = true
5026 break
5027 }
5028 }
5029 if !needsPersistedTime {
5030 return msgs
5031 }
5032 users, err := agent.LoadSessionUserMessages(sessionPath)
5033 if err != nil || len(users) == 0 {
5034 return msgs
5035 }
5036 out := append([]provider.Message(nil), msgs...)
5037 userIndex := 0
5038 for i := range out {
5039 if out[i].Role != provider.RoleUser || agent.IsPinnedContextRevision(out[i]) {
5040 continue
5041 }
5042 if userIndex >= len(users) {
5043 break
5044 }
5045 user := users[userIndex]
5046 userIndex++
5047 if out[i].CreatedAt <= 0 && !user.At.IsZero() {
5048 out[i].CreatedAt = user.At.UnixMilli()
5049 }
5050 }
5051 return out
5052 }
5053
5054 // History returns the session's message log.
5055 func (a *App) History() []HistoryMessage {
5056 return a.HistoryForTab("")
5057 }
5058
5059 func (a *App) HistoryPage(beforeTurn, limit int) HistoryPage {
5060 return a.HistoryPageForTab("", beforeTurn, limit)
5061 }
5062
5063 func (a *App) HistoryPageForTab(tabID string, beforeTurn, limit int) HistoryPage {
5064 a.mu.RLock()
5065 tab := a.tabByIDLocked(tabID)
5066 var ctrl control.SessionAPI
5067 var sessionDir, sessionPath string
5068 if tab != nil {
5069 ctrl = tab.Ctrl
5070 sessionDir = tabSessionDir(tab)
5071 sessionPath = tab.currentSessionPath()
5072 }
5073 a.mu.RUnlock()
5074 if ctrl == nil {
5075 if strings.TrimSpace(sessionPath) == "" {
5076 return HistoryPage{Messages: []HistoryMessage{}}
5077 }
5078 sessionDir, sessionPath, err := a.historyReadSource(sessionDir, sessionPath)
5079 if err != nil {
5080 return HistoryPage{Messages: []HistoryMessage{}}
5081 }
5082 page, err := previewSessionPage(sessionDir, sessionPath, beforeTurn, limit)
5083 if err != nil {
5084 return HistoryPage{Messages: []HistoryMessage{}}
5085 }
5086 return page
5087 }
5088 page, _ := historyPageForController(tab, ctrl, nil, "", beforeTurn, limit)
5089 return page
5090 }
5091
5092 // historyPageForController converts the controller's log into one visible page.
5093 // preloaded is a read of this controller's own session that the caller already
5094 // paid for (a session switch loads the target to build the replacement
5095 // controller); nil makes this read the durable log itself.
5096 func historyPageForController(tab *WorkspaceTab, ctrl control.SessionAPI, preloaded *agent.Session, preloadedPath string, beforeTurn, limit int) (HistoryPage, bool) {
5097 msgs := ctrl.History()
5098 durable, readLog := durableHistorySnapshot(ctrl, preloaded, preloadedPath, msgs)
5099 if durable != nil {
5100 msgs = durable
5101 }
5102 return historyPageFromMessagesForTab(tab, ctrl, msgs, beforeTurn, limit), readLog
5103 }
5104
5105 // durableHistorySnapshot returns the durable transcript while the controller is
5106 // idle and fully persisted, so a stale in-memory log cannot hide an
5107 // assistant/tool suffix written after restart or cross-runtime recovery. It
5108 // returns nil when the controller's own log is already the source of truth.
5109 // Reuse preloaded only when it still describes the controller's captured
5110 // history. A reused runtime can have committed more work since that read.
5111 func durableHistorySnapshot(ctrl control.SessionAPI, preloaded *agent.Session, preloadedPath string, current []provider.Message) ([]provider.Message, bool) {
5112 status := ctrl.RuntimeStatus()
5113 path := strings.TrimSpace(ctrl.SessionPath())
5114 if status.Running || status.PendingPrompt || ctrl.SessionHasUnsavedChanges() || path == "" {
5115 return nil, false
5116 }
5117 if preloaded != nil && sessionRuntimeKey(preloadedPath) == sessionRuntimeKey(path) {
5118 // A same-session or detached controller can finish and persist after the
5119 // preload. Path equality alone does not prove it still owns this cut.
5120 loadedDigest, loadedErr := preloaded.ContentDigest()
5121 currentDigest, currentErr := agent.ContentDigestForMessages(current)
5122 if loadedErr == nil && currentErr == nil && loadedDigest == currentDigest {
5123 return preloaded.Snapshot(), false
5124 }
5125 }
5126 loaded, err := agent.LoadSession(path)
5127 if err != nil || loaded == nil {
5128 return nil, false
5129 }
5130 return loaded.Snapshot(), true
5131 }
5132
5133 // historyPageFromMessagesForTab renders a page from messages already in hand.
5134 // Session switching reuses the snapshot it loaded to build the replacement
5135 // controller instead of re-reading and re-converting the same idle transcript.
5136 func historyPageFromMessagesForTab(tab *WorkspaceTab, ctrl control.SessionAPI, msgs []provider.Message, beforeTurn, limit int) HistoryPage {
5137 if tab == nil || ctrl == nil {
5138 return HistoryPage{Messages: []HistoryMessage{}}
5139 }
5140 dir := controllerSessionDir(ctrl)
5141 path := ctrl.SessionPath()
5142 page := historyPageFromProviderMessages(
5143 msgs,
5144 sessionDisplayResolver(dir, path),
5145 sessionPlannerDisplayTurns(dir, path),
5146 ctrl.CheckpointTurnsByMessageIndex(),
5147 beforeTurn,
5148 limit,
5149 )
5150 digest, _ := agent.ContentDigestForMessages(msgs)
5151 identity := path
5152 if sessionIdentity, ok := ctrl.(control.IdentityLifecycle); ok && sessionIdentity.UsesExclusiveSession() {
5153 if ref, bound := sessionIdentity.SessionRef(); bound {
5154 identity = sessionRoute(ref.SessionID)
5155 }
5156 }
5157 return historyPageWithFingerprint(page, identity, digest)
5158 }
5159
5160 func historyPageWithFingerprint(page HistoryPage, sessionPath, contentDigest string) HistoryPage {
5161 contentDigest = strings.TrimSpace(contentDigest)
5162 if strings.TrimSpace(sessionPath) == "" || contentDigest == "" {
5163 return page
5164 }
5165 // Digest is derived from the exact full transcript used to build the page.
5166 // Never copy a newer sidecar digest onto older page content.
5167 page.Digest = contentDigest
5168 if _, isV3 := parseSessionRoute(sessionPath); !isV3 {
5169 if meta, ok, err := agent.LoadBranchMeta(sessionPath); err == nil && ok {
5170 if strings.TrimSpace(meta.ContentDigest) == contentDigest {
5171 page.Revision = meta.Revision
5172 }
5173 }
5174 }
5175 return page
5176 }
5177
5178 func normalizeHistoryPageLimit(limit int) int {
5179 if limit <= 0 {
5180 return defaultHistoryPageTurns
5181 }
5182 if limit > maxHistoryPageTurns {
5183 return maxHistoryPageTurns
5184 }
5185 return limit
5186 }
5187
5188 func historyPageFromMessages(messages []HistoryMessage, beforeTurn, limit int) HistoryPage {
5189 limit = normalizeHistoryPageLimit(limit)
5190 totalTurns := 0
5191 for _, msg := range messages {
5192 if msg.Role == "user" {
5193 totalTurns++
5194 }
5195 }
5196 if beforeTurn <= 0 || beforeTurn > totalTurns {
5197 beforeTurn = totalTurns
5198 }
5199 startTurn := max(beforeTurn-limit, 0)
5200 page := HistoryPage{
5201 StartTurn: startTurn,
5202 EndTurn: beforeTurn,
5203 TotalTurns: totalTurns,
5204 HasOlder: startTurn > 0,
5205 }
5206 if len(messages) == 0 || startTurn >= beforeTurn {
5207 page.Messages = []HistoryMessage{}
5208 return page
5209 }
5210 page.Messages = historyMessagesForTurnRange(messages, startTurn, beforeTurn)
5211 return page
5212 }
5213
5214 func historyMessagesForTurnRange(messages []HistoryMessage, startTurn, endTurn int) []HistoryMessage {
5215 out := make([]HistoryMessage, 0, len(messages))
5216 turn := -1
5217 for _, msg := range messages {
5218 if msg.Role == "user" {
5219 turn++
5220 }
5221 if turn < 0 {
5222 if startTurn == 0 {
5223 out = append(out, msg)
5224 }
5225 continue
5226 }
5227 if turn >= startTurn && turn < endTurn {
5228 out = append(out, msg)
5229 }
5230 }
5231 return out
5232 }
5233
5234 func (a *App) HistoryForTab(tabID string) []HistoryMessage {
5235 a.mu.RLock()
5236 tab := a.tabByIDLocked(tabID)
5237 var ctrl control.SessionAPI
5238 var sessionDir, sessionPath string
5239 if tab != nil {
5240 ctrl = tab.Ctrl
5241 sessionDir = tabSessionDir(tab)
5242 sessionPath = tab.currentSessionPath()
5243 }
5244 a.mu.RUnlock()
5245 if ctrl == nil {
5246 if strings.TrimSpace(sessionPath) == "" {
5247 return []HistoryMessage{}
5248 }
5249 sessionDir, sessionPath, err := a.historyReadSource(sessionDir, sessionPath)
5250 if err != nil {
5251 return []HistoryMessage{}
5252 }
5253 messages, err := previewSessionMessages(sessionDir, sessionPath)
5254 if err != nil {
5255 return []HistoryMessage{}
5256 }
5257 return messages
5258 }
5259 dir := controllerSessionDir(ctrl)
5260 path := ctrl.SessionPath()
5261 msgs := historyProviderMessagesWithPersistedTimes(ctrl.History(), path)
5262 return historyMessagesWithPlannerDisplays(
5263 msgs,
5264 sessionDisplayResolver(dir, path),
5265 sessionPlannerDisplayTurns(dir, path),
5266 ctrl.CheckpointTurnsByMessageIndex(),
5267 )
5268 }
5269
5270 func (a *App) HistoryCheckpointTurnsForTab(tabID string) []int {
5271 a.mu.RLock()
5272 tab := a.tabByIDLocked(tabID)
5273 var ctrl control.SessionAPI
5274 if tab != nil {
5275 ctrl = tab.Ctrl
5276 }
5277 a.mu.RUnlock()
5278 if ctrl == nil {
5279 return []int{}
5280 }
5281 return historyCheckpointTurns(
5282 ctrl.History(),
5283 sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()),
5284 ctrl.CheckpointTurnsByMessageIndex(),
5285 )
5286 }
5287
5288 var pastedTextDisplayLabelPattern = regexp.MustCompile(`^\[(?:已粘贴文本|已貼上文字|Pasted text) #[0-9]+ · [0-9]+ (?:行|lines)\]$`)
5289
5290 // historyReplayUserContent keeps only user-authored replay data. Provider-facing
5291 // capability, goal, hook, and resolved-reference context must not be resubmitted.
5292 func historyReplayUserContent(content string) string {
5293 return control.StripReferencedContextPrefix(control.StripComposePrefixes(content))
5294 }
5295
5296 // collapseLegacyExpandedPasteDisplay repairs sessions whose user-authored replay
5297 // source still contains an expanded pasted-text block. This includes transcripts
5298 // written before RawContent existed. The expanded block remains in SubmitText so
5299 // edit replay can still reconstruct the card and recover its full payload.
5300 func collapseLegacyExpandedPasteDisplay(content string) string {
5301 const beginPrefix = "--- Begin "
5302 for scan := 0; scan < len(content); {
5303 beginOffset := strings.Index(content[scan:], beginPrefix)
5304 if beginOffset < 0 {
5305 break
5306 }
5307 begin := scan + beginOffset
5308 labelStart := begin + len(beginPrefix)
5309 labelEndOffset := strings.Index(content[labelStart:], " ---")
5310 if labelEndOffset < 0 {
5311 break
5312 }
5313 labelEnd := labelStart + labelEndOffset
5314 label := content[labelStart:labelEnd]
5315 beginEnd := labelEnd + len(" ---")
5316 if !pastedTextDisplayLabelPattern.MatchString(label) {
5317 scan = beginEnd
5318 continue
5319 }
5320 endMarker := "--- End " + label + " ---"
5321 endOffset := strings.Index(content[beginEnd:], endMarker)
5322 if endOffset < 0 {
5323 scan = beginEnd
5324 continue
5325 }
5326 labelCopy := strings.LastIndex(content[:begin], label)
5327 if labelCopy < 0 || strings.TrimSpace(content[labelCopy+len(label):begin]) != "" {
5328 scan = beginEnd
5329 continue
5330 }
5331 end := beginEnd + endOffset + len(endMarker)
5332 content = content[:labelCopy+len(label)] + content[end:]
5333 scan = labelCopy + len(label)
5334 }
5335 return strings.TrimSpace(content)
5336 }
5337
5338 // historyUserDisplayContent prefers a persisted display sidecar when one exists.
5339 // Comparing it with the deterministic fallback distinguishes a sidecar hit
5340 // without changing the resolver API used throughout history pagination.
5341 func historyUserDisplayContent(msg provider.Message, resolveUserContent func(string) string) string {
5342 resolved := strings.TrimSpace(resolveUserContent(msg.Content))
5343 fallback := strings.TrimSpace(historyReplayUserContent(msg.Content))
5344 if resolved != "" && resolved != fallback {
5345 return resolved
5346 }
5347 replaySource := agent.UserMessageText(msg)
5348 if msg.RawContent == "" {
5349 replaySource = fallback
5350 }
5351 return collapseLegacyExpandedPasteDisplay(replaySource)
5352 }
5353
5354 func historyCheckpointTurns(msgs []provider.Message, resolveUserContent func(string) string, checkpointTurns map[int]int) []int {
5355 out := make([]int, 0)
5356 for index, msg := range msgs {
5357 if !agent.IsUserAuthoredTurnMessage(msg) {
5358 continue
5359 }
5360 turn, ok := checkpointTurns[index]
5361 if !ok {
5362 turn = -1
5363 }
5364 out = append(out, turn)
5365 }
5366 return out
5367 }
5368
5369 func historyMessagesWithPlannerDisplays(msgs []provider.Message, resolveUserContent func(string) string, plannerTurns []plannerDisplayTurn, checkpointTurns map[int]int) []HistoryMessage {
5370 toolResults := historyToolResultsByID(msgs)
5371 return historyMessagesWithPlannerDisplaysAndLookups(msgs, resolveUserContent, plannerTurns, checkpointTurns, toolResults)
5372 }
5373
5374 // historyMessageConvertState carries the cross-message state of a provider→
5375 // HistoryMessage conversion pass: the planner-display queue (consumed in order
5376 // per user-text hash) and the canonical-turn suppression a planner interrupt
5377 // notice arms. Keeping it explicit lets the windowed history slice API convert
5378 // one message at a time with exactly the same semantics as a full pass.
5379 type historyMessageConvertState struct {
5380 plannerByUserHash map[string][]plannerDisplayTurn
5381 suppressCanonicalTurn bool
5382 }
5383
5384 func newHistoryMessageConvertState(plannerTurns []plannerDisplayTurn) *historyMessageConvertState {
5385 return &historyMessageConvertState{plannerByUserHash: plannerTurnsByUserHash(plannerTurns)}
5386 }
5387
5388 func historyMessagesWithPlannerDisplaysAndLookups(
5389 msgs []provider.Message,
5390 resolveUserContent func(string) string,
5391 plannerTurns []plannerDisplayTurn,
5392 checkpointTurns map[int]int,
5393 toolResults map[string]provider.Message,
5394 ) []HistoryMessage {
5395 out := make([]HistoryMessage, 0, len(msgs))
5396 state := newHistoryMessageConvertState(plannerTurns)
5397 for index, m := range msgs {
5398 out = append(out, state.convertHistoryMessage(index, m, resolveUserContent, checkpointTurns, toolResults)...)
5399 }
5400 return out
5401 }
5402
5403 // convertHistoryMessage converts one provider message into its 0..n history
5404 // rows. index is the message's position in the coordinate system of
5405 // checkpointTurns (window-relative for the legacy full-pass callers, absolute
5406 // for the windowed slice API).
5407 func (state *historyMessageConvertState) convertHistoryMessage(
5408 index int,
5409 m provider.Message,
5410 resolveUserContent func(string) string,
5411 checkpointTurns map[int]int,
5412 toolResults map[string]provider.Message,
5413 ) []HistoryMessage {
5414 var out []HistoryMessage
5415 if m.Role == provider.Role("compaction") {
5416 return maintenanceHistoryMessage(m)
5417 }
5418 if m.DecisionReceipt != nil {
5419 return append(out, HistoryMessage{
5420 Role: "notice",
5421 Code: event.NoticeCodeDecisionReceipt,
5422 Level: "info",
5423 DecisionReceipt: cloneDecisionReceipt(m.DecisionReceipt),
5424 })
5425 }
5426 if rows, handled := historyLocalOnlyRows(m); handled {
5427 return append(out, rows...)
5428 }
5429 if state.suppressCanonicalTurn {
5430 if !agent.IsUserAuthoredTurnMessage(m) {
5431 return out
5432 }
5433 state.suppressCanonicalTurn = false
5434 }
5435 content := m.Content
5436 var checkpointTurn *int
5437 if m.Role == provider.RoleUser {
5438 // Mid-turn steer messages are persisted in the session so they
5439 // survive tab switches. They are surfaced as a notice (↪ text)
5440 // — matching the live Steer event look — rather than as a
5441 // regular user bubble or being filtered as synthetic (#4044).
5442 // Check against the raw m.Content: resolveUserContent applies
5443 // StripComposePrefixes which trims trailing whitespace.
5444 if rows, handled := historySteerRows(m.Content, false); handled {
5445 return append(out, rows...)
5446 }
5447 content = historyUserDisplayContent(m, resolveUserContent)
5448 if agent.IsHostGeneratedUserMessage(m) {
5449 return out
5450 }
5451 if turn, ok := checkpointTurns[index]; ok {
5452 turnCopy := turn
5453 checkpointTurn = &turnCopy
5454 }
5455 }
5456 reasoning := ""
5457 if m.Role == provider.RoleAssistant || m.LocalOnly {
5458 reasoning = m.ReasoningContent
5459 }
5460 displayRole := string(m.Role)
5461 if m.LocalOnly {
5462 displayRole = "assistant"
5463 }
5464 hm := HistoryMessage{MessageID: m.ID, Role: displayRole, Content: content, CheckpointTurn: checkpointTurn, CreatedAt: m.CreatedAt, Reasoning: reasoning, WorkDurationMs: m.WorkDurationMs}
5465 if m.Role == provider.RoleAssistant && len(m.MemoryCitations) > 0 {
5466 hm.MemoryCitations = append([]provider.MemoryCitation(nil), m.MemoryCitations...)
5467 }
5468 if m.Role == provider.RoleUser && content != m.Content {
5469 replay := historyReplayUserContent(m.Content)
5470 if agent.ContainsMemoryCompilerExecution(m.Content) {
5471 // Never expose the compiler contract itself. A safely unwrapped
5472 // slash invocation is useful display metadata, though: it lets the
5473 // frontend restore the selected skill/subagent in history and trash.
5474 if strings.HasPrefix(strings.TrimSpace(replay), "/") && replay != content {
5475 hm.SubmitText = replay
5476 }
5477 } else if replay != content {
5478 hm.SubmitText = replay
5479 }
5480 }
5481 hm.ServerSearch = historyServerSearch(m.ServerSearch)
5482 hm.Attachments = historyDisplayAttachments(m.ImageInputs)
5483 if (m.Role == provider.RoleAssistant || m.LocalOnly) && len(m.ToolCalls) > 0 {
5484 hm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls))
5485 for i, tc := range m.ToolCalls {
5486 // Historical tool calls are immutable facts. Never rewrite todo_write
5487 // arguments by interpreting later results as current todo state.
5488 hm.ToolCalls[i] = historyToolCall(tc, tc.Arguments, toolResults[tc.ID])
5489 }
5490 }
5491 if m.Role == provider.RoleTool && !m.LocalOnly {
5492 hm.ToolCallID = m.ToolCallID
5493 hm.ToolName = m.Name
5494 hm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != "")
5495 hm.Execution = m.ToolExecution
5496 }
5497 hasVisibleLocalContent := strings.TrimSpace(hm.Content) != "" || strings.TrimSpace(hm.Reasoning) != "" || len(hm.ToolCalls) > 0 || (!m.LocalOnly && m.Role == provider.RoleTool)
5498 if !m.LocalOnly || hasVisibleLocalContent {
5499 out = append(out, hm)
5500 }
5501 for _, receipt := range m.DecisionReceipts {
5502 if receipt == nil {
5503 continue
5504 }
5505 out = append(out, HistoryMessage{
5506 Role: "notice",
5507 Code: event.NoticeCodeDecisionReceipt,
5508 Level: "info",
5509 DecisionReceipt: cloneDecisionReceipt(receipt),
5510 })
5511 }
5512 if m.LocalOnly && m.InterruptedTurn != nil {
5513 out = append(out, interruptedTurnHistoryNotice(m.InterruptedTurn))
5514 }
5515 if m.Role == provider.RoleUser {
5516 key := messageDisplayKey(agent.UserMessageText(m))
5517 if turns := state.plannerByUserHash[key]; len(turns) > 0 {
5518 out = append(out, cloneHistoryMessages(turns[0].Messages)...)
5519 state.suppressCanonicalTurn = plannerDisplaySuppressesCanonical(turns[0])
5520 state.plannerByUserHash[key] = turns[1:]
5521 }
5522 }
5523 return out
5524 }
5525
5526 // consumeHistoryPlannerState advances only the cross-message planner state.
5527 // Windowed pages call it for the prefix they do not render, so repeated user
5528 // text and a planner interrupt at a page boundary behave exactly as one full
5529 // conversion pass. Keep the early returns in lock-step with
5530 // convertHistoryMessage: those rows never reach the planner attachment at its
5531 // tail.
5532 func (state *historyMessageConvertState) consumeHistoryPlannerState(m provider.Message, resolveUserContent func(string) string) {
5533 if m.DecisionReceipt != nil {
5534 return
5535 }
5536 if m.LocalOnly {
5537 if _, isSteer := agent.SteerText(m.Content); isSteer {
5538 return
5539 }
5540 }
5541 if state.suppressCanonicalTurn {
5542 if !agent.IsUserAuthoredTurnMessage(m) {
5543 return
5544 }
5545 state.suppressCanonicalTurn = false
5546 }
5547 if m.Role != provider.RoleUser {
5548 return
5549 }
5550 if agent.IsHostGeneratedUserMessage(m) {
5551 return
5552 }
5553 key := messageDisplayKey(agent.UserMessageText(m))
5554 if turns := state.plannerByUserHash[key]; len(turns) > 0 {
5555 state.suppressCanonicalTurn = plannerDisplaySuppressesCanonical(turns[0])
5556 state.plannerByUserHash[key] = turns[1:]
5557 }
5558 }
5559
5560 func cloneDecisionReceipt(in *provider.DecisionReceipt) *provider.DecisionReceipt {
5561 if in == nil {
5562 return nil
5563 }
5564 copy := *in
5565 return &copy
5566 }
5567
5568 func plannerDisplaySuppressesCanonical(turn plannerDisplayTurn) bool {
5569 for _, message := range turn.Messages {
5570 if message.Role == "notice" && message.Code == event.NoticeCodeCancelledTurn {
5571 return true
5572 }
5573 }
5574 return false
5575 }
5576
5577 func historyPageFromProviderMessages(
5578 msgs []provider.Message,
5579 resolveUserContent func(string) string,
5580 plannerTurns []plannerDisplayTurn,
5581 checkpointTurns map[int]int,
5582 beforeTurn, limit int,
5583 ) HistoryPage {
5584 limit = normalizeHistoryPageLimit(limit)
5585 totalTurns := visibleHistoryUserTurns(msgs, resolveUserContent)
5586 if beforeTurn <= 0 || beforeTurn > totalTurns {
5587 beforeTurn = totalTurns
5588 }
5589 startTurn := max(beforeTurn-limit, 0)
5590 page := HistoryPage{
5591 StartTurn: startTurn,
5592 EndTurn: beforeTurn,
5593 TotalTurns: totalTurns,
5594 HasOlder: startTurn > 0,
5595 }
5596 if len(msgs) == 0 || startTurn >= beforeTurn {
5597 page.Messages = []HistoryMessage{}
5598 return page
5599 }
5600 pageMessages, originalIndexes := providerMessagesForVisibleTurnRange(msgs, resolveUserContent, startTurn, beforeTurn)
5601 page.Messages = historyMessagesWithPlannerDisplaysAndLookups(
5602 pageMessages,
5603 resolveUserContent,
5604 plannerTurns,
5605 checkpointTurnsForProviderWindow(checkpointTurns, originalIndexes),
5606 historyToolResultsByID(msgs),
5607 )
5608 return page
5609 }
5610
5611 func visibleHistoryUserTurns(msgs []provider.Message, resolveUserContent func(string) string) int {
5612 total := 0
5613 for _, msg := range msgs {
5614 if isVisibleHistoryUser(msg, resolveUserContent) {
5615 total++
5616 }
5617 }
5618 return total
5619 }
5620
5621 func isVisibleHistoryUser(msg provider.Message, resolveUserContent func(string) string) bool {
5622 return agent.IsUserAuthoredTurnMessage(msg)
5623 }
5624
5625 func providerMessagesForVisibleTurnRange(msgs []provider.Message, resolveUserContent func(string) string, startTurn, endTurn int) ([]provider.Message, []int) {
5626 out := make([]provider.Message, 0, len(msgs))
5627 indexes := make([]int, 0, len(msgs))
5628 turn := -1
5629 for index, msg := range msgs {
5630 if isVisibleHistoryUser(msg, resolveUserContent) {
5631 turn++
5632 }
5633 if turn < 0 {
5634 if startTurn == 0 {
5635 out = append(out, msg)
5636 indexes = append(indexes, index)
5637 }
5638 continue
5639 }
5640 if turn >= startTurn && turn < endTurn {
5641 out = append(out, msg)
5642 indexes = append(indexes, index)
5643 }
5644 }
5645 return out, indexes
5646 }
5647
5648 func checkpointTurnsForProviderWindow(checkpointTurns map[int]int, originalIndexes []int) map[int]int {
5649 if len(checkpointTurns) == 0 || len(originalIndexes) == 0 {
5650 return nil
5651 }
5652 out := map[int]int{}
5653 for pageIndex, originalIndex := range originalIndexes {
5654 if turn, ok := checkpointTurns[originalIndex]; ok {
5655 out[pageIndex] = turn
5656 }
5657 }
5658 return out
5659 }
5660
5661 func plannerTurnsByUserHash(turns []plannerDisplayTurn) map[string][]plannerDisplayTurn {
5662 out := map[string][]plannerDisplayTurn{}
5663 for _, turn := range turns {
5664 if strings.TrimSpace(turn.UserHash) == "" || len(turn.Messages) == 0 {
5665 continue
5666 }
5667 out[turn.UserHash] = append(out[turn.UserHash], turn)
5668 }
5669 return out
5670 }
5671
5672 func cloneHistoryMessages(in []HistoryMessage) []HistoryMessage {
5673 if len(in) == 0 {
5674 return nil
5675 }
5676 out := make([]HistoryMessage, len(in))
5677 copy(out, in)
5678 for i := range out {
5679 if len(in[i].MemoryCitations) > 0 {
5680 out[i].MemoryCitations = append([]provider.MemoryCitation(nil), in[i].MemoryCitations...)
5681 }
5682 if len(in[i].ToolCalls) > 0 {
5683 out[i].ToolCalls = append([]HistoryToolCall(nil), in[i].ToolCalls...)
5684 }
5685 if len(in[i].Attachments) > 0 {
5686 out[i].Attachments = append([]transcript.Attachment(nil), in[i].Attachments...)
5687 }
5688 }
5689 return out
5690 }
5691
5692 const historyToolPreviewLimit = 2_000
5693
5694 func historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall {
5695 call := HistoryToolCall{
5696 ID: tc.ID,
5697 Name: tc.Name,
5698 ResolvedName: tc.ResolvedName,
5699 CapabilityID: tc.CapabilityID,
5700 ResolvedReadOnly: tc.ResolvedReadOnly,
5701 Subject: historyToolSubject(tc.Name, args),
5702 Summary: historyToolSummary(tc.Name, args, result.Content),
5703 Diff: tc.Diff,
5704 Added: tc.Added,
5705 Removed: tc.Removed,
5706 }
5707 if tc.Name == "todo_write" {
5708 call.Arguments = args
5709 return call
5710 }
5711 if tc.ID == "" {
5712 call.Arguments = args
5713 return call
5714 }
5715 if args != "" {
5716 call.ArgumentsArchived = true
5717 }
5718 return call
5719 }
5720
5721 func historyToolResultsByID(msgs []provider.Message) map[string]provider.Message {
5722 out := map[string]provider.Message{}
5723 for _, msg := range msgs {
5724 if msg.Role != provider.RoleTool || msg.ToolCallID == "" {
5725 continue
5726 }
5727 out[msg.ToolCallID] = msg
5728 }
5729 return out
5730 }
5731
5732 func historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) {
5733 if content == "" {
5734 return "", false, ""
5735 }
5736 if !canArchive {
5737 if historyToolResultFailed(content) {
5738 return content, false, content
5739 }
5740 return content, false, ""
5741 }
5742 if historyToolResultFailed(content) {
5743 display = clipHistoryToolPreview(strings.TrimSpace(content))
5744 return display, display != content, display
5745 }
5746 return "", true, ""
5747 }
5748
5749 func clipHistoryToolPreview(s string) string {
5750 if len(s) <= historyToolPreviewLimit {
5751 return s
5752 }
5753 return strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + "\n..."
5754 }
5755
5756 func historyToolSubject(name, args string) string {
5757 a := parseHistoryToolArgs(args)
5758 var subject string
5759 if historyShellToolName(name) {
5760 return clipSingleLine(historyArgString(a, "command"), 240)
5761 }
5762 switch name {
5763 case "grep", "glob":
5764 subject = firstNonEmpty(historyArgString(a, "pattern"), historyArgString(a, "path"))
5765 case "web_fetch":
5766 subject = historyArgString(a, "url")
5767 case "task":
5768 subject = firstNonEmpty(historyArgString(a, "description"), historyArgString(a, "prompt"))
5769 case "run_skill":
5770 subject = historyArgString(a, "name")
5771 case "move_file":
5772 src := historyArgString(a, "source_path")
5773 dst := historyArgString(a, "destination_path")
5774 if src != "" && dst != "" {
5775 subject = src + " -> " + dst
5776 } else {
5777 subject = firstNonEmpty(src, dst)
5778 }
5779 case "remember":
5780 subject = firstNonEmpty(historyArgString(a, "name"), historyArgString(a, "description"))
5781 case "todo_write", "exit_plan_mode":
5782 subject = ""
5783 default:
5784 subject = firstNonEmpty(historyArgString(a, "path"), historyArgString(a, "file_path"))
5785 }
5786 return clipSingleLine(subject, 240)
5787 }
5788
5789 func historyToolSummary(name, args, output string) string {
5790 if historyToolResultFailed(output) {
5791 return ""
5792 }
5793 if historyShellToolName(name) {
5794 if strings.TrimSpace(output) == "" {
5795 return "no output"
5796 }
5797 return fmt.Sprintf("%d lines", historyLineCount(output))
5798 }
5799 a := parseHistoryToolArgs(args)
5800 switch name {
5801 case "write_file":
5802 if content := historyArgString(a, "content"); content != "" {
5803 return fmt.Sprintf("%d lines", historyLineCount(content))
5804 }
5805 case "edit_file":
5806 oldText := historyArgString(a, "old_string")
5807 newText := historyArgString(a, "new_string")
5808 if oldText != "" || newText != "" {
5809 return fmt.Sprintf("%d -> %d lines", historyLineCount(oldText), historyLineCount(newText))
5810 }
5811 case "multi_edit":
5812 if edits, ok := a["edits"].([]any); ok && len(edits) > 0 {
5813 return fmt.Sprintf("%d edits", len(edits))
5814 }
5815 }
5816 if output == "" {
5817 return ""
5818 }
5819 switch name {
5820 case "read_file":
5821 if strings.HasPrefix(output, "(empty file)") {
5822 return "empty file"
5823 }
5824 if arrows := strings.Count(output, "→"); arrows > 0 {
5825 return fmt.Sprintf("%d lines", arrows)
5826 }
5827 return fmt.Sprintf("%d lines", historyLineCount(output))
5828 case "grep":
5829 return fmt.Sprintf("%d matches", historyNonEmptyLineCount(output))
5830 case "glob":
5831 return fmt.Sprintf("%d files", historyNonEmptyLineCount(output))
5832 case "ls":
5833 return fmt.Sprintf("%d entries", historyNonEmptyLineCount(output))
5834 case "web_fetch":
5835 return clipSingleLine(strings.SplitN(output, "\n", 2)[0], 80)
5836 default:
5837 return ""
5838 }
5839 }
5840
5841 func historyShellToolName(name string) bool {
5842 switch strings.ToLower(strings.TrimSpace(name)) {
5843 case "bash", "pwsh", "powershell", "shell":
5844 return true
5845 default:
5846 return false
5847 }
5848 }
5849
5850 func parseHistoryToolArgs(args string) map[string]any {
5851 if args == "" {
5852 return map[string]any{}
5853 }
5854 var out map[string]any
5855 if err := json.Unmarshal([]byte(args), &out); err != nil {
5856 return map[string]any{}
5857 }
5858 return out
5859 }
5860
5861 func historyArgString(args map[string]any, key string) string {
5862 if v, ok := args[key].(string); ok {
5863 return v
5864 }
5865 return ""
5866 }
5867
5868 func historyLineCount(s string) int {
5869 if s == "" {
5870 return 0
5871 }
5872 s = strings.TrimSuffix(s, "\n")
5873 if s == "" {
5874 return 0
5875 }
5876 return strings.Count(s, "\n") + 1
5877 }
5878
5879 func historyNonEmptyLineCount(s string) int {
5880 count := 0
5881 for line := range strings.SplitSeq(s, "\n") {
5882 if strings.TrimSpace(line) != "" {
5883 count++
5884 }
5885 }
5886 return count
5887 }
5888
5889 func clipSingleLine(s string, max int) string {
5890 s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
5891 if len(s) <= max {
5892 return s
5893 }
5894 if max <= 3 {
5895 return clipStringBytes(s, max)
5896 }
5897 return clipStringBytes(s, max-3) + "..."
5898 }
5899
5900 func clipStringBytes(s string, max int) string {
5901 if max <= 0 {
5902 return ""
5903 }
5904 if len(s) <= max {
5905 return s
5906 }
5907 for max > 0 && !utf8.RuneStart(s[max]) {
5908 max--
5909 }
5910 return s[:max]
5911 }
5912
5913 func historyToolResultFailed(content string) bool {
5914 content = strings.TrimSpace(content)
5915 return strings.HasPrefix(content, "error:") ||
5916 strings.HasPrefix(content, "blocked:") ||
5917 strings.HasPrefix(content, "Error:") ||
5918 strings.HasPrefix(content, "[error")
5919 }
5920
5921 func previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) {
5922 sessionPath, _, err := validateSessionPath(sessionDir, path)
5923 if err != nil {
5924 return nil, err
5925 }
5926 if out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {
5927 return out, err
5928 }
5929 loaded, err := agent.LoadSession(sessionPath)
5930 if err != nil {
5931 return nil, err
5932 }
5933 return historyMessagesWithPlannerDisplays(
5934 historyProviderMessagesWithPersistedTimes(loaded.Snapshot(), sessionPath),
5935 sessionDisplayResolver(sessionDir, sessionPath),
5936 sessionPlannerDisplayTurns(sessionDir, sessionPath),
5937 nil,
5938 ), nil
5939 }
5940
5941 func previewSessionPage(sessionDir, path string, beforeTurn, limit int) (HistoryPage, error) {
5942 sessionPath, _, err := validateSessionPath(sessionDir, path)
5943 if err != nil {
5944 return HistoryPage{}, err
5945 }
5946 if out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil {
5947 if err != nil {
5948 return HistoryPage{}, err
5949 }
5950 return historyPageFromMessages(out, beforeTurn, limit), nil
5951 }
5952 loaded, err := agent.LoadSession(sessionPath)
5953 if err != nil {
5954 return HistoryPage{}, err
5955 }
5956 msgs := loaded.Snapshot()
5957 digest, _ := agent.ContentDigestForMessages(msgs)
5958 return historyPageWithFingerprint(historyPageFromProviderMessages(
5959 historyProviderMessagesWithPersistedTimes(msgs, sessionPath),
5960 sessionDisplayResolver(sessionDir, sessionPath),
5961 sessionPlannerDisplayTurns(sessionDir, sessionPath),
5962 nil,
5963 beforeTurn,
5964 limit,
5965 ), sessionPath, digest), nil
5966 }
5967
5968 type previewEventRecord struct {
5969 Kind string `json:"kind"`
5970 Type string `json:"type"`
5971 Role string `json:"role"`
5972 Origin provider.MessageOrigin `json:"origin"`
5973 TS json.RawMessage `json:"ts"`
5974 Time json.RawMessage `json:"time"`
5975 Timestamp json.RawMessage `json:"timestamp"`
5976 CreatedAt json.RawMessage `json:"createdAt"`
5977 CreatedAtSnake json.RawMessage `json:"created_at"`
5978 UpdatedAt json.RawMessage `json:"updatedAt"`
5979 UpdatedAtSnake json.RawMessage `json:"updated_at"`
5980 Text string `json:"text"`
5981 Detail string `json:"detail"`
5982 Code string `json:"code"`
5983 Content string `json:"content"`
5984 RawContent string `json:"raw_content"`
5985 Reasoning string `json:"reasoning"`
5986 ReasoningContent string `json:"reasoningContent"`
5987 MemoryCitations []provider.MemoryCitation `json:"memoryCitations"`
5988 Level string `json:"level"`
5989 ToolCalls []previewToolCall `json:"toolCalls"`
5990 CallID string `json:"callId"`
5991 ToolCallID string `json:"toolCallId"`
5992 ToolName string `json:"toolName"`
5993 Name string `json:"name"`
5994 Output string `json:"output"`
5995 Compaction *previewCompaction `json:"compaction"`
5996 Trigger string `json:"trigger"`
5997 Messages int `json:"messages"`
5998 Summary string `json:"summary"`
5999 Archive string `json:"archive"`
6000 SessionOperation *event.SessionOperationInfo `json:"sessionOperation"`
6001 }
6002
6003 type previewToolCall struct {
6004 ID string `json:"id"`
6005 Name string `json:"name"`
6006 Arguments string `json:"arguments"`
6007 Function struct {
6008 Name string `json:"name"`
6009 Arguments string `json:"arguments"`
6010 } `json:"function"`
6011 }
6012
6013 type previewCompaction struct {
6014 Trigger string `json:"trigger"`
6015 Messages int `json:"messages"`
6016 Summary string `json:"summary"`
6017 Archive string `json:"archive"`
6018 }
6019
6020 func previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) {
6021 f, err := os.Open(path)
6022 if err != nil {
6023 return nil, false, err
6024 }
6025 defer f.Close()
6026
6027 dec := json.NewDecoder(f)
6028 out := []HistoryMessage{}
6029 toolName := map[string]string{}
6030 sawEvent := false
6031 for {
6032 var rec previewEventRecord
6033 if err := dec.Decode(&rec); err != nil {
6034 if errors.Is(err, io.EOF) {
6035 break
6036 }
6037 if sawEvent {
6038 return out, true, nil
6039 }
6040 return nil, false, nil
6041 }
6042 eventName := strings.TrimSpace(rec.Kind)
6043 if eventName == "" {
6044 eventName = strings.TrimSpace(rec.Type)
6045 }
6046 if eventName == "" {
6047 continue
6048 }
6049 sawEvent = true
6050 switch eventName {
6051 case "user.message":
6052 if rec.Text != "" {
6053 hm := HistoryMessage{Role: "user", Content: rec.Text}
6054 if at, ok := promptHistoryEventMillis(rec); ok {
6055 hm.CreatedAt = at
6056 }
6057 out = append(out, hm)
6058 }
6059 case "model.final":
6060 hm := HistoryMessage{Role: "assistant", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)}
6061 if len(rec.MemoryCitations) > 0 {
6062 hm.MemoryCitations = append([]provider.MemoryCitation(nil), rec.MemoryCitations...)
6063 }
6064 for _, tc := range rec.ToolCalls {
6065 id := tc.ID
6066 name := firstNonEmpty(tc.Name, tc.Function.Name)
6067 args := firstNonEmpty(tc.Arguments, tc.Function.Arguments)
6068 hm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{}))
6069 if id != "" {
6070 toolName[id] = name
6071 }
6072 }
6073 out = append(out, hm)
6074 case "tool.result":
6075 callID := firstNonEmpty(rec.CallID, rec.ToolCallID)
6076 content := firstNonEmpty(rec.Output, rec.Content)
6077 display, archived, errPreview := historyToolResultContent(content, callID != "")
6078 if len(out) > 0 && callID != "" {
6079 updateHistoryToolCallSummary(out, callID, content)
6080 }
6081 out = append(out, HistoryMessage{
6082 Role: "tool",
6083 ToolCallID: callID,
6084 ToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]),
6085 Content: display,
6086 ToolResultArchived: archived,
6087 ToolResultError: errPreview,
6088 })
6089 case "phase":
6090 out = append(out, HistoryMessage{Role: "phase", Content: firstNonEmpty(rec.Text, rec.Content)})
6091 case "notice":
6092 level := rec.Level
6093 if level != "warn" {
6094 level = "info"
6095 }
6096 out = append(out, HistoryMessage{Role: "notice", Level: level, Content: firstNonEmpty(rec.Text, rec.Content), Detail: rec.Detail, Code: rec.Code})
6097 case "compaction_started":
6098 c := rec.compactionPayload()
6099 out = append(out, HistoryMessage{Role: "compaction", Pending: true, Trigger: c.Trigger})
6100 case "compaction_done":
6101 c := rec.compactionPayload()
6102 out = append(out, HistoryMessage{
6103 Role: "compaction",
6104 Trigger: c.Trigger,
6105 Messages: c.Messages,
6106 Summary: c.Summary,
6107 Archive: c.Archive,
6108 })
6109 case "session_operation":
6110 out = upsertMaintenancePreview(out, rec.SessionOperation)
6111 }
6112 }
6113 return out, sawEvent, nil
6114 }
6115
6116 func (r previewEventRecord) compactionPayload() previewCompaction {
6117 if r.Compaction != nil {
6118 return *r.Compaction
6119 }
6120 return previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive}
6121 }
6122
6123 func updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) {
6124 if callID == "" {
6125 return
6126 }
6127 for _, v := range slices.Backward(out) {
6128 for j := range v.ToolCalls {
6129 call := &v.ToolCalls[j]
6130 if call.ID != callID {
6131 continue
6132 }
6133 if call.Summary == "" {
6134 call.Summary = historyToolSummary(call.Name, call.Arguments, output)
6135 }
6136 return
6137 }
6138 }
6139 }
6140
6141 func firstNonEmpty(values ...string) string {
6142 for _, value := range values {
6143 if value != "" {
6144 return value
6145 }
6146 }
6147 return ""
6148 }
6149
6150 func (a *App) ContextUsageForTab(tabID string) ContextInfo {
6151 if a.isRemoteTab(tabID) {
6152 used, window, ok := a.remoteContextSnapshot(tabID)
6153 if ok {
6154 return ContextInfo{Used: used, Window: window}
6155 }
6156 return ContextInfo{}
6157 }
6158 read := a.captureContextRead(tabID)
6159 ctrl := read.ctrl
6160 var info ContextInfo
6161 if read.tab != nil {
6162 snap := read.telemetry
6163 info.SessionTokens = snap.Usage.TotalTokens
6164 info.SessionCost = snap.Usage.SessionCost
6165 info.SessionCurrency = snap.Usage.SessionCurrency
6166 info.CacheHitTokens = snap.Usage.CacheHitTokens
6167 info.CacheMissTokens = snap.Usage.CacheMissTokens
6168 info.Estimated = snap.Usage.Estimated
6169 info.SessionCostComplete = snap.Usage.SessionCostComplete
6170 info.SessionCostQuote = snap.Usage.SessionCostQuote
6171 info.Sources = snap.Usage.Sources
6172 }
6173 if ctrl == nil {
6174 return info
6175 }
6176 // The gauge measures the loaded view, so a rebound session reports its real
6177 // fill immediately and no longer needs the persisted last-turn fallback.
6178 used, window := ctrl.ContextSnapshot()
6179 info.Used = used
6180 info.Window = window
6181 info.CompactRatio = ctrl.CompactRatio()
6182 snapshot := ctrl.ContextMaintenanceSnapshot()
6183 info.Maintenance = contextMaintenanceInfo(snapshot)
6184 if snapshot.ContextBudget != nil {
6185 info.ContextBudget = contextBudgetInfo(snapshot.ContextBudget)
6186 }
6187 if !read.current(a) {
6188 return ContextInfo{}
6189 }
6190 return info
6191 }
6192
6193 // BalanceInfo is the wallet-balance readout for the status bar. Available is true
6194 // only when a balance was fetched; Display is the exact formatted amount (e.g.
6195 // "¥110.00")
6196 // and is "" when the active provider declares no balance_url — the frontend then
6197 // omits the readout. Err carries a fetch failure for an optional tooltip.
6198 // Wallet balances are displayed in their original currencies; no conversion
6199 // or cross-currency sum is performed.
6200 type BalanceInfo struct {
6201 Available bool `json:"available"`
6202 Display string `json:"display"`
6203 Detail string `json:"detail,omitempty"` // per-wallet original balances
6204 Complete bool `json:"complete"`
6205 RateDate string `json:"rateDate,omitempty"`
6206 Approx bool `json:"approx,omitempty"`
6207 Currencies []string `json:"currencies,omitempty"`
6208 PrimaryCurrency string `json:"primaryCurrency,omitempty"`
6209 CostDisplayCurrency string `json:"costDisplayCurrency,omitempty"`
6210 MultiCurrency bool `json:"multiCurrency,omitempty"`
6211 Err string `json:"err,omitempty"`
6212 }
6213
6214 // Balance queries the active provider's wallet balance (a network call). It
6215 // returns an empty (unavailable) readout when no provider balance_url is set, the
6216 // controller is down, or the fetch fails — so the status bar simply shows nothing
6217 // rather than an error.
6218 func (a *App) Balance() BalanceInfo {
6219 return a.BalanceForTab("")
6220 }
6221
6222 func (a *App) BalanceForTab(tabID string) BalanceInfo {
6223 currency := a.balanceDisplayCurrency()
6224 tab, ctrl, generation := a.balanceRequestTarget(tabID)
6225 if ctrl == nil {
6226 return BalanceInfo{}
6227 }
6228 b, err := ctrl.Balance(a.ctx)
6229 if err != nil {
6230 return BalanceInfo{Err: err.Error()}
6231 }
6232 if b == nil {
6233 return BalanceInfo{} // provider declares no balance endpoint
6234 }
6235 display := b.DisplayForCurrency(currency)
6236 currencies := b.Currencies()
6237 primary := b.PrimaryCurrency()
6238 a.applyBalanceDisplayHint(tabID, tab, ctrl, currency, primary, generation)
6239 detail := balanceDetail(b)
6240 return BalanceInfo{
6241 Available: true,
6242 Display: display,
6243 Detail: detail,
6244 Complete: true,
6245 Currencies: currencies,
6246 PrimaryCurrency: primary,
6247 CostDisplayCurrency: firstNonEmptyString(currency, primary),
6248 MultiCurrency: len(currencies) > 1,
6249 }
6250 }
6251
6252 func balanceDetail(b *billing.Balance) string {
6253 if b == nil || len(b.Infos) == 0 {
6254 return ""
6255 }
6256 parts := make([]string, 0, len(b.Infos))
6257 for _, info := range b.Infos {
6258 cur := strings.ToUpper(strings.TrimSpace(info.Currency))
6259 if cur == "" {
6260 cur = "UNKNOWN"
6261 }
6262 parts = append(parts, cur+" "+strings.TrimSpace(info.TotalBalance))
6263 }
6264 return strings.Join(parts, "\n")
6265 }
6266
6267 func firstNonEmptyString(values ...string) string {
6268 for _, value := range values {
6269 if strings.TrimSpace(value) != "" {
6270 return value
6271 }
6272 }
6273 return ""
6274 }
6275
6276 // balanceDisplayCurrency resolves only an explicit global display currency.
6277 // Automatic mode leaves the wallet in its original currency.
6278 func (a *App) balanceDisplayCurrency() string {
6279 cfg, _, err := a.loadDesktopUserConfigForView()
6280 if err != nil {
6281 return ""
6282 }
6283 if pref := cfg.DisplayCurrencyPref(); pref != "" {
6284 return pref
6285 }
6286 return cfg.ExplicitDisplayCurrency()
6287 }
6288
6289 // JobView is one running background job (bash/task started with
6290 // run_in_background) for the status-bar indicator.
6291 type JobView struct {
6292 ID string `json:"id"`
6293 Kind string `json:"kind"`
6294 Label string `json:"label"`
6295 Status string `json:"status"`
6296 StartedAt int64 `json:"startedAt"`
6297 }
6298
6299 // Jobs returns the still-running background jobs for the status bar. It refreshes
6300 // on demand (mount, turn end, and on each notice the frontend receives).
6301 func (a *App) Jobs() []JobView {
6302 return a.JobsForTab("")
6303 }
6304
6305 func (a *App) JobsForTab(tabID string) []JobView {
6306 out := []JobView{}
6307 ctrl := a.ctrlForRuntimeTabID(tabID)
6308 return a.jobsForCtrl(ctrl, out)
6309 }
6310
6311 // CancelJob stops one running background job in the active tab.
6312 func (a *App) CancelJob(jobID string) (bool, error) {
6313 return a.CancelJobForTab("", jobID)
6314 }
6315
6316 // CancelJobForTab stops one running background job without relying on whatever
6317 // tab happens to be active when the asynchronous frontend call completes.
6318 func (a *App) CancelJobForTab(tabID, jobID string) (bool, error) {
6319 jobID = strings.TrimSpace(jobID)
6320 if jobID == "" {
6321 return false, fmt.Errorf("job id is required")
6322 }
6323 if tabID != "" {
6324 if a.isRemoteTab(tabID) {
6325 if err := a.CancelRemoteTabJobs(tabID, []string{jobID}); err != nil {
6326 return false, err
6327 }
6328 return true, nil
6329 }
6330 ctrl := a.ctrlForRuntimeTabID(tabID)
6331 if ctrl != nil {
6332 return cancelJobForController(ctrl, jobID)
6333 }
6334 return false, nil
6335 }
6336 return cancelJobForController(a.ctrlForRuntimeTabID(tabID), jobID)
6337 }
6338
6339 func cancelJobForController(ctrl control.SessionAPI, jobID string) (bool, error) {
6340 if ctrl == nil {
6341 return false, nil
6342 }
6343 canceller, ok := ctrl.(interface{ CancelJob(string) bool })
6344 if !ok {
6345 return false, fmt.Errorf("background job cancellation is unavailable")
6346 }
6347 return canceller.CancelJob(jobID), nil
6348 }
6349
6350 func (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView {
6351 if ctrl == nil {
6352 return out
6353 }
6354 for _, v := range ctrl.Jobs() {
6355 out = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt})
6356 }
6357 return out
6358 }
6359
6360 func goalRuntimeViewFromController(ctrl control.SessionAPI) *GoalRuntimeView {
6361 if ctrl == nil {
6362 return nil
6363 }
6364 rt := ctrl.GoalRuntime()
6365 return &GoalRuntimeView{
6366 TurnsUsed: rt.TurnsUsed,
6367 TurnsLimit: rt.TurnsLimit,
6368 TokensUsed: rt.TokensUsed,
6369 RequestsUsed: rt.RequestsUsed,
6370 WorkDurationMs: rt.WorkDurationMs,
6371 TokensLimit: rt.TokensLimit,
6372 NoProgressTurns: rt.NoProgressTurns,
6373 NoProgressLimit: rt.NoProgressLimit,
6374 LastReason: rt.LastReason,
6375 StopCause: rt.StopCause,
6376 BudgetExtensions: rt.BudgetExtensions,
6377 }
6378 }
6379
6380 // Meta reports the model label, readiness, any startup error, the working
6381 // directory (for the status line), and the runtime event channel the frontend
6382 // subscribes to.
6383 func (a *App) Meta() Meta {
6384 return a.MetaForTab("")
6385 }
6386
6387 func (a *App) loadConfigForVision(root string) (*config.Config, error) {
6388 if hook := a.configLoadForRootHook; hook != nil {
6389 hook(root)
6390 }
6391 return config.LoadForRootWithoutCredentialsReadOnly(root)
6392 }
6393
6394 func (a *App) MetaForTab(tabID string) Meta {
6395 return a.metaForTab(tabID)
6396 }
6397
6398 // ctrlTodos returns the canonical task list from a session controller, or nil
6399 // if the controller is not yet bound. Used by MetaForTab so the frontend
6400 // task panel has access to the authoritative server-side todo state.
6401 func ctrlTodos(ctrl control.SessionAPI) *[]evidence.TodoItem {
6402 if ctrl == nil {
6403 return nil
6404 }
6405 todos := ctrl.Todos()
6406 if todos == nil {
6407 todos = []evidence.TodoItem{}
6408 }
6409 return &todos
6410 }
6411
6412 // SetAutoApproveTools is retained for older desktop bundles. Both legacy
6413 // states migrate to workspace-write; full access must be selected explicitly
6414 // through SetToolApprovalModeForTab.
6415 func (a *App) SetAutoApproveTools(on bool) {
6416 _ = on
6417 a.SetToolApprovalModeForTab("", control.ToolApprovalWorkspaceWrite)
6418 }
6419
6420 // SetBypass is the legacy Wails binding for SetAutoApproveTools.
6421 func (a *App) SetBypass(on bool) {
6422 a.SetAutoApproveTools(on)
6423 }
6424
6425 func (a *App) SetToolApprovalMode(mode string) {
6426 a.SetToolApprovalModeForTab("", mode)
6427 }
6428
6429 // SetToolApprovalModeForTab returns the pending approval prompt ids the
6430 // switch auto-allowed (see SetModeForTab).
6431 func (a *App) SetToolApprovalModeForTab(tabID, mode string) []string {
6432 tab := a.tabByID(tabID)
6433 if tab == nil {
6434 return nil
6435 }
6436 tab.turnStartMu.Lock()
6437 defer tab.turnStartMu.Unlock()
6438 mode = normalizeToolApprovalMode(mode)
6439 plan := tabModeHasPlan(a.tabRuntimeSnapshot(tab).currentMode())
6440 a.mu.Lock()
6441 if a.tabs[tab.ID] != tab {
6442 a.mu.Unlock()
6443 return nil
6444 }
6445 tab.toolApprovalMode = mode
6446 tab.mode = tabModeFromAxes(plan, mode == control.ToolApprovalDangerFullAccess)
6447 ctrl := tab.Ctrl
6448 tabIDForSave := tab.ID
6449 a.mu.Unlock()
6450 drained := applyTabToolApprovalModeToController(ctrl, mode)
6451 a.mu.Lock()
6452 if a.tabs[tabIDForSave] == tab {
6453 a.saveTabsLocked()
6454 }
6455 a.mu.Unlock()
6456 return drained
6457 }
6458
6459 // PermissionSnapshotForTab returns the authoritative preset, capability and
6460 // same-session grant state for one desktop session.
6461 func (a *App) PermissionSnapshotForTab(tabID string) (control.PermissionSnapshot, error) {
6462 if a.isRemoteTab(tabID) {
6463 if err := a.requireRemotePermissionPresets(tabID); err != nil {
6464 return control.PermissionSnapshot{}, err
6465 }
6466 client, base, expectedPath, err := a.remoteTabCommandTarget(tabID)
6467 if err != nil {
6468 return control.PermissionSnapshot{}, err
6469 }
6470 ctx, cancel := commandContext(a)
6471 defer cancel()
6472 return remotePermissionSnapshot(ctx, client, base, expectedPath)
6473 }
6474 tab := a.tabByID(tabID)
6475 if tab == nil {
6476 return control.PermissionSnapshot{}, fmt.Errorf("tab not found")
6477 }
6478 ctrl, ok := a.controllerForTab(tab).(*control.Controller)
6479 if !ok || ctrl == nil {
6480 return control.PermissionSnapshot{}, fmt.Errorf("permission snapshot is unavailable")
6481 }
6482 return ctrl.PermissionSnapshot(), nil
6483 }
6484
6485 // SetPermissionPresetForTab applies a permission update fenced by the session
6486 // and revision of the snapshot the caller read. The revision alone is per
6487 // controller, so a snapshot of another session can carry the same number.
6488 func (a *App) SetPermissionPresetForTab(tabID, expectedSessionID, preset string, expectedRevision uint64) (control.PermissionSnapshot, error) {
6489 if a.isRemoteTab(tabID) {
6490 if err := a.requireRemoteExecutionProtocol(tabID); err != nil {
6491 return control.PermissionSnapshot{}, err
6492 }
6493 if err := a.requireRemotePermissionPresets(tabID); err != nil {
6494 return control.PermissionSnapshot{}, err
6495 }
6496 client, base, expectedPath, err := a.remoteTabCommandTarget(tabID)
6497 if err != nil {
6498 return control.PermissionSnapshot{}, err
6499 }
6500 ctx, cancel := commandContext(a)
6501 defer cancel()
6502 live, err := remotePermissionSnapshot(ctx, client, base, expectedPath)
6503 if err != nil {
6504 return control.PermissionSnapshot{}, err
6505 }
6506 if err := requirePermissionSession(live, expectedSessionID); err != nil {
6507 return live, err
6508 }
6509 return setRemotePermissionPresetAt(ctx, client, base, expectedPath, preset, expectedRevision)
6510 }
6511 tab := a.tabByID(tabID)
6512 if tab == nil {
6513 return control.PermissionSnapshot{}, fmt.Errorf("tab not found")
6514 }
6515 tab.turnStartMu.Lock()
6516 defer tab.turnStartMu.Unlock()
6517 ctrl, ok := a.controllerForTab(tab).(*control.Controller)
6518 if !ok || ctrl == nil {
6519 return control.PermissionSnapshot{}, fmt.Errorf("permission presets are unavailable")
6520 }
6521 // turnStartMu also serializes session navigation, so the session checked
6522 // here is the one the preset lands on.
6523 live := ctrl.PermissionSnapshot()
6524 if err := requirePermissionSession(live, expectedSessionID); err != nil {
6525 return live, err
6526 }
6527 snapshot, _, err := ctrl.SetPermissionPreset(preset, expectedRevision)
6528 if err != nil {
6529 return snapshot, err
6530 }
6531 a.mu.Lock()
6532 chosenFor := ""
6533 if a.tabs[tab.ID] == tab {
6534 tab.toolApprovalMode = snapshot.Preset
6535 tab.mode = tabModeFromAxes(tabModeHasPlan(tab.mode), snapshot.Preset == control.ToolApprovalDangerFullAccess)
6536 if tab.SessionID == snapshot.SessionID {
6537 chosenFor = tab.SessionID
6538 }
6539 a.saveTabsLocked()
6540 }
6541 a.mu.Unlock()
6542 a.sessionPresets.record(chosenFor, snapshot.Preset)
6543 return snapshot, nil
6544 }
6545
6546 // RevokePermissionGrantForTab removes one exact same-session authorization.
6547 func (a *App) RevokePermissionGrantForTab(tabID, scope, target string, expectedRevision uint64) (control.PermissionSnapshot, error) {
6548 if a.isRemoteTab(tabID) {
6549 if err := a.requireRemoteExecutionProtocol(tabID); err != nil {
6550 return control.PermissionSnapshot{}, err
6551 }
6552 if err := a.requireRemotePermissionPresets(tabID); err != nil {
6553 return control.PermissionSnapshot{}, err
6554 }
6555 client, base, expectedPath, err := a.remoteTabCommandTarget(tabID)
6556 if err != nil {
6557 return control.PermissionSnapshot{}, err
6558 }
6559 ctx, cancel := commandContext(a)
6560 defer cancel()
6561 return revokeRemotePermissionGrantAt(ctx, client, base, expectedPath, scope, target, expectedRevision)
6562 }
6563 tab := a.tabByID(tabID)
6564 if tab == nil {
6565 return control.PermissionSnapshot{}, fmt.Errorf("tab not found")
6566 }
6567 tab.turnStartMu.Lock()
6568 defer tab.turnStartMu.Unlock()
6569 ctrl, ok := a.controllerForTab(tab).(*control.Controller)
6570 if !ok || ctrl == nil {
6571 return control.PermissionSnapshot{}, fmt.Errorf("permission grants are unavailable")
6572 }
6573 return ctrl.RevokeSessionGrant(scope, target, expectedRevision)
6574 }
6575
6576 // CommandInfo describes one available slash command for the composer's "/" menu.
6577 type CommandInfo struct {
6578 Name string `json:"name"` // without the leading slash
6579 Description string `json:"description"`
6580 Hint string `json:"hint,omitempty"` // argument hint, if any
6581 Kind string `json:"kind"` // "builtin" | "custom" | "mcp" | "skill" | "subagent"
6582 Group string `json:"group,omitempty"` // menu group; older frontends can ignore it
6583 Plugin string `json:"plugin,omitempty"`
6584 Color string `json:"color,omitempty"`
6585 DraftBehavior string `json:"draftBehavior,omitempty"` // submit | setting | direct | unavailable
6586 }
6587
6588 // Commands lists the slash commands available this session — built-in actions,
6589 // custom commands (.reasonix/commands), and MCP prompts — for the composer's "/"
6590 // autocomplete menu.
6591 func (a *App) Commands() []CommandInfo {
6592 out := builtinCommandInfos()
6593 a.mu.RLock()
6594 ctrl := a.activeCtrlLocked()
6595 a.mu.RUnlock()
6596 if ctrl == nil {
6597 return append(out, docsBuiltinCommand(control.DocsSlashName))
6598 }
6599 commands := ctrl.Commands()
6600 slashSkills := ctrl.SlashSkills()
6601 out = append(out, docsBuiltinCommand(control.ResolvedBuiltinSlashName(control.DocsSlashName, commands, slashSkills)))
6602 // Skills are invocable as slash commands (the model runs inline ones; subagent ones
6603 // run isolated). Listing them here is what surfaces /init, /explore, … in the
6604 // composer's slash menu; selecting one submits its displayed slash name, which the controller
6605 // resolves via RunSkill.
6606 for _, s := range slashSkills {
6607 kind := "skill"
6608 if s.RunAs == skill.RunSubagent {
6609 kind = "subagent"
6610 }
6611 group := "skills"
6612 if kind == "subagent" {
6613 group = "subagents"
6614 }
6615 out = append(out, CommandInfo{Name: s.SlashName(), Description: s.Description, Kind: kind, Group: group, Plugin: s.Plugin, Color: s.Color})
6616 }
6617 for _, c := range commands {
6618 if c.Hidden {
6619 continue
6620 }
6621 out = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: "custom", Group: "skills", Plugin: c.Plugin})
6622 }
6623 if h := ctrl.Host(); h != nil {
6624 for _, p := range h.Prompts() {
6625 out = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: "mcp", Group: "integrations"})
6626 }
6627 }
6628 return resolveDocsCommand(out)
6629 }
6630
6631 func builtinCommandInfos() []CommandInfo {
6632 return []CommandInfo{
6633 {Name: "new", Description: i18n.M.CmdNew, Kind: "builtin", Group: "actions", DraftBehavior: "unavailable"},
6634 {Name: "clear", Description: i18n.M.CmdClear, Kind: "builtin", Group: "actions", DraftBehavior: "unavailable"},
6635 {Name: "compact", Description: i18n.M.CmdCompact, Kind: "builtin", Group: "actions", DraftBehavior: "unavailable"},
6636 {Name: "model", Description: i18n.M.CmdModel, Kind: "builtin", Group: "actions", DraftBehavior: "setting"},
6637 {Name: "provider", Description: i18n.M.CmdProvider, Kind: "builtin", Group: "management", DraftBehavior: "unavailable"},
6638 {Name: "effort", Description: i18n.M.CmdEffort, Kind: "builtin", Group: "actions", DraftBehavior: "setting"},
6639 {Name: "memory", Description: i18n.M.CmdMemory, Kind: "builtin", Group: "management", DraftBehavior: "unavailable"},
6640 {Name: "migrate", Description: i18n.M.CmdMigrate, Kind: "builtin", Group: "management", DraftBehavior: "unavailable"},
6641 {Name: "goal", Description: i18n.M.CmdGoal, Kind: "builtin", Group: "actions"},
6642 {Name: "remember", Description: i18n.M.CmdRemember, Kind: "builtin", Group: "management", DraftBehavior: "unavailable"},
6643 {Name: "mcp", Description: i18n.M.CmdMcp, Kind: "builtin", Group: "integrations", DraftBehavior: "unavailable"},
6644 {Name: "hooks", Description: i18n.M.CmdHooks, Kind: "builtin", Group: "management", DraftBehavior: "unavailable"},
6645 {Name: "plugins", Description: i18n.M.CmdPlugins, Kind: "builtin", Group: "integrations", DraftBehavior: "unavailable"},
6646 {Name: "theme", Description: i18n.M.CmdTheme, Kind: "builtin", Group: "management", DraftBehavior: "direct"},
6647 {Name: "skill", Description: i18n.M.CmdSkill, Kind: "builtin", Group: "skills", DraftBehavior: "unavailable"},
6648 {Name: "reload-cmd", Description: i18n.M.CmdReloadCmd, Kind: "builtin", Group: "management", DraftBehavior: "unavailable"},
6649 {Name: "reload", Description: i18n.M.CmdReload, Kind: "builtin", Group: "management", DraftBehavior: "unavailable"},
6650 }
6651 }
6652
6653 func docsBuiltinCommand(name string) CommandInfo {
6654 return CommandInfo{Name: name, Description: i18n.M.CmdDocs, Hint: "<question>", Kind: "builtin", Group: "integrations"}
6655 }
6656
6657 func resolveDocsCommand(commands []CommandInfo) []CommandInfo {
6658 winner := -1
6659 winnerRank := -1
6660 for i, cmd := range commands {
6661 if cmd.Name != "docs" {
6662 continue
6663 }
6664 rank := 0
6665 switch cmd.Kind {
6666 case "custom":
6667 rank = 2
6668 case "skill", "subagent":
6669 rank = 1
6670 }
6671 if rank > winnerRank {
6672 winner = i
6673 winnerRank = rank
6674 }
6675 }
6676 if winner < 0 {
6677 return commands
6678 }
6679 out := make([]CommandInfo, 0, len(commands))
6680 for i, cmd := range commands {
6681 if cmd.Name != "docs" || i == winner {
6682 out = append(out, cmd)
6683 }
6684 }
6685 return out
6686 }
6687
6688 // CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP
6689 // servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`.
6690 type CapabilitiesView struct {
6691 Servers []ServerView `json:"servers"`
6692 Skills []SkillView `json:"skills"`
6693 SkillRoots []SkillRootView `json:"skillRoots"`
6694 Plugins []PluginView `json:"plugins"`
6695 }
6696
6697 // SkillsSettingsView is the skills management page's data, split from MCP
6698 // status so opening MCP settings does not scan skill roots.
6699 type SkillsSettingsView struct {
6700 Skills []SkillView `json:"skills"`
6701 SkillRoots []SkillRootView `json:"skillRoots"`
6702 AllowImplicitInvocation bool `json:"allowImplicitInvocation"`
6703 }
6704
6705 // ServerView is one MCP server for the drawer. Status is "connected" (with
6706 // tool/prompt/resource counts), "deferred" (enabled but idle), "failed" (with
6707 // the connection error), "initializing" (background startup in progress), or
6708 // "disabled".
6709 //
6710 // Product fields for the simplified MCP panel are Enabled/Installed/
6711 // Availability/RuntimeState/ToolCount/ToolList/Action. Legacy AutoStart, Tier,
6712 // and StartIntent remain for one major as derived compatibility fields only.
6713 type ServerView struct {
6714 Name string `json:"name"`
6715 Transport string `json:"transport"`
6716 Status string `json:"status"`
6717 HostProfile string `json:"hostProfile,omitempty"`
6718 ElicitationNegotiated bool `json:"elicitationNegotiated,omitempty"`
6719 AppsNegotiated bool `json:"appsNegotiated,omitempty"`
6720 StartIntent string `json:"startIntent,omitempty"` // deprecated: derived from Enabled
6721 RuntimeState string `json:"runtimeState,omitempty"`
6722 ProtocolVersion string `json:"protocolVersion,omitempty"`
6723 SessionState string `json:"sessionState,omitempty"`
6724 ReconnectAttempts int `json:"reconnectAttempts,omitempty"`
6725 ErrorKind string `json:"errorKind,omitempty"`
6726 Availability string `json:"availability,omitempty"`
6727 Enabled bool `json:"enabled"`
6728 Installed bool `json:"installed"`
6729 Action string `json:"action,omitempty"`
6730 Source string `json:"source,omitempty"`
6731 ConfigSource string `json:"configSource,omitempty"`
6732 BuiltIn bool `json:"builtIn,omitempty"`
6733 Configured bool `json:"configured,omitempty"`
6734 AutoStart bool `json:"autoStart"` // deprecated: same as Enabled
6735 Tier string `json:"tier,omitempty"`
6736 Command string `json:"command,omitempty"`
6737 Args []string `json:"args,omitempty"`
6738 URL string `json:"url,omitempty"`
6739 EnvKeys []string `json:"envKeys,omitempty"`
6740 HeaderKeys []string `json:"headerKeys,omitempty"`
6741 Tools int `json:"tools"`
6742 ToolCount int `json:"toolCount"`
6743 Prompts int `json:"prompts"`
6744 Resources int `json:"resources"`
6745 HasTools bool `json:"hasTools,omitempty"`
6746 Error string `json:"error,omitempty"`
6747 ToolList []ToolView `json:"toolList"`
6748 CallTimeoutSeconds int `json:"callTimeoutSeconds,omitempty"`
6749 ToolTimeoutSeconds map[string]int `json:"toolTimeoutSeconds,omitempty"`
6750 RequiresLaunchApproval bool `json:"requiresLaunchApproval,omitempty"`
6751 AuthStatus string `json:"authStatus,omitempty"`
6752 AuthURL string `json:"authUrl,omitempty"`
6753 AuthConfigured bool `json:"authConfigured,omitempty"`
6754 ManagedByPlugin string `json:"managedByPlugin,omitempty"`
6755 }
6756
6757 type ToolView struct {
6758 Name string `json:"name"`
6759 Description string `json:"description"`
6760 ReadOnlyHint bool `json:"readOnlyHint,omitempty"`
6761 DestructiveHint bool `json:"destructiveHint,omitempty"`
6762 SchemaError string `json:"schemaError,omitempty"`
6763 }
6764
6765 // SkillView is one discoverable skill for the drawer. Also backs the
6766 // Subagents settings surface: the frontend filters this same list to
6767 // RunAs=="subagent" rather than calling a second, redundant endpoint.
6768 type SkillView struct {
6769 Name string `json:"name"`
6770 Description string `json:"description"`
6771 Scope string `json:"scope"`
6772 SourceDir string `json:"sourceDir,omitempty"`
6773 RunAs string `json:"runAs"`
6774 Enabled bool `json:"enabled"`
6775 Plugin string `json:"plugin,omitempty"`
6776 Model string `json:"model,omitempty"`
6777 Effort string `json:"effort,omitempty"`
6778 AllowedTools []string `json:"allowedTools,omitempty"`
6779 // ReadOnly mirrors frontmatter read-only; omitted/false keeps the legacy
6780 // writable default for older profiles.
6781 ReadOnly bool `json:"readOnly,omitempty"`
6782 Color string `json:"color,omitempty"`
6783 // Invocation is the user-facing slash name; InvocationMode preserves the
6784 // frontmatter policy used by the subagent profile editor.
6785 Invocation string `json:"invocation,omitempty"`
6786 InvocationMode string `json:"invocationMode,omitempty"`
6787 // Body is the skill's full markdown body (post-frontmatter) — the
6788 // subagent profile editor pre-fills its system-prompt field from this.
6789 Body string `json:"body,omitempty"`
6790 // ConfiguredModel/ConfiguredEffort are the per-name overrides from
6791 // cfg.Agent.SubagentModels/SubagentEfforts (internal/boot's
6792 // subagentModelRef/subagentEffortRef read the same map at dispatch time).
6793 // This is the only lever for a built-in subagent's model/effort, since
6794 // built-ins have no editable frontmatter file to carry Model/Effort.
6795 ConfiguredModel string `json:"configuredModel,omitempty"`
6796 ConfiguredEffort string `json:"configuredEffort,omitempty"`
6797 }
6798
6799 type SkillRootSkillView struct {
6800 Name string `json:"name"`
6801 Description string `json:"description"`
6802 Scope string `json:"scope"`
6803 RunAs string `json:"runAs"`
6804 Plugin string `json:"plugin,omitempty"`
6805 Model string `json:"model,omitempty"`
6806 Effort string `json:"effort,omitempty"`
6807 AllowedTools []string `json:"allowedTools,omitempty"`
6808 Color string `json:"color,omitempty"`
6809 Invocation string `json:"invocation,omitempty"`
6810 }
6811
6812 // SkillRootView is one skill discovery root for the drawer's Sources section.
6813 type SkillRootView struct {
6814 Dir string `json:"dir"`
6815 Scope string `json:"scope"`
6816 Priority int `json:"priority"`
6817 Status string `json:"status"`
6818 Enabled bool `json:"enabled"`
6819 Configured bool `json:"configured"`
6820 Removable bool `json:"removable"`
6821 Skills int `json:"skills"`
6822 SkillItems []SkillRootSkillView `json:"skillItems,omitempty"`
6823 Warning string `json:"warning,omitempty"`
6824 }
6825
6826 // Capabilities projects the session's MCP servers (connected + failed) and skills
6827 // for the MCP & Skills drawer. Non-nil slices so the frontend can map over them.
6828 func (a *App) Capabilities() CapabilitiesView {
6829 skills := a.SkillsSettings()
6830 return CapabilitiesView{
6831 Servers: a.MCPServers(),
6832 Skills: skills.Skills,
6833 SkillRoots: skills.SkillRoots,
6834 Plugins: a.Plugins(),
6835 }
6836 }
6837
6838 // MCPServers returns only MCP server status for settings pages that do not need
6839 // skill discovery.
6840 func (a *App) MCPServers() []ServerView {
6841 return a.mcpServersView()
6842 }
6843
6844 type MCPMarketplaceEntryView struct {
6845 Name string `json:"name"`
6846 SuggestedName string `json:"suggestedName"`
6847 Title string `json:"title,omitempty"`
6848 Description string `json:"description,omitempty"`
6849 Version string `json:"version,omitempty"`
6850 RepositoryURL string `json:"repositoryUrl,omitempty"`
6851 Installable bool `json:"installable"`
6852 UnavailableReason string `json:"unavailableReason,omitempty"`
6853 Transport string `json:"transport,omitempty"`
6854 Command string `json:"command,omitempty"`
6855 Args []string `json:"args"`
6856 URL string `json:"url,omitempty"`
6857 }
6858
6859 type MCPMarketplaceView struct {
6860 Servers []MCPMarketplaceEntryView `json:"servers"`
6861 Cached bool `json:"cached"`
6862 Warning string `json:"warning,omitempty"`
6863 }
6864
6865 // MCPMarketplace explicitly queries the official MCP Registry. It is only
6866 // called from the settings marketplace; startup and tool discovery never touch
6867 // the network. A query-specific cache keeps the page useful during a registry
6868 // outage without treating cached entries as installed servers.
6869 func (a *App) MCPMarketplace(query string) (MCPMarketplaceView, error) {
6870 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
6871 defer cancel()
6872 result, err := mcpregistry.New(mcpRegistryCachePath()).Search(ctx, query, 50)
6873 if err != nil {
6874 return MCPMarketplaceView{Servers: []MCPMarketplaceEntryView{}}, err
6875 }
6876 view := MCPMarketplaceView{
6877 Servers: make([]MCPMarketplaceEntryView, 0, len(result.Entries)),
6878 Cached: result.Cached,
6879 Warning: result.Warning,
6880 }
6881 for _, entry := range result.Entries {
6882 view.Servers = append(view.Servers, mcpMarketplaceEntryView(entry))
6883 }
6884 return view, nil
6885 }
6886
6887 // MCPMarketplaceResolve re-fetches one Registry entry immediately before the
6888 // settings UI installs it. Offline cache remains useful for browsing, but it is
6889 // never accepted as installation metadata.
6890 func (a *App) MCPMarketplaceResolve(registryName string) (MCPMarketplaceEntryView, error) {
6891 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
6892 defer cancel()
6893 entry, _, err := mcpregistry.New(mcpRegistryCachePath()).Resolve(ctx, registryName)
6894 if err != nil {
6895 return MCPMarketplaceEntryView{}, err
6896 }
6897 if _, err := entry.PluginEntry(""); err != nil {
6898 return MCPMarketplaceEntryView{}, err
6899 }
6900 return mcpMarketplaceEntryView(entry), nil
6901 }
6902
6903 func mcpRegistryCachePath() string {
6904 if cacheDir := config.CacheDir(); cacheDir != "" {
6905 return filepath.Join(cacheDir, "mcp-registry-v0.1.json")
6906 }
6907 return ""
6908 }
6909
6910 func mcpMarketplaceEntryView(entry mcpregistry.Entry) MCPMarketplaceEntryView {
6911 return MCPMarketplaceEntryView{
6912 Name: entry.Name,
6913 SuggestedName: entry.SuggestedName,
6914 Title: entry.Title,
6915 Description: entry.Description,
6916 Version: entry.Version,
6917 RepositoryURL: entry.RepositoryURL,
6918 Installable: entry.Installable,
6919 UnavailableReason: entry.UnavailableReason,
6920 Transport: entry.Transport,
6921 Command: entry.Command,
6922 Args: append([]string{}, entry.Args...),
6923 URL: entry.URL,
6924 }
6925 }
6926
6927 // lockRuntimeMutation serializes controller rebuild/teardown operations and
6928 // freezes runtime admission so a captured controller or Host cannot be replaced
6929 // or closed in flight. The caller must not hold App.mu; the lock order is
6930 // runtimeRebuildMu -> runtimeAdmissionMu -> App/Host/Registry.
6931 func (a *App) lockRuntimeMutation(operation string) func() {
6932 if hook := a.runtimeMutationBeforeLockHook; hook != nil {
6933 hook(operation)
6934 }
6935 a.runtimeRebuildMu.Lock()
6936 a.runtimeAdmissionMu.Lock()
6937 return func() {
6938 a.runtimeAdmissionMu.Unlock()
6939 a.runtimeRebuildMu.Unlock()
6940 }
6941 }
6942
6943 // AuthorizeAndConnectMCPServer is retained for older generated Wails clients.
6944 // Project configuration is trusted by default now, so the normal path simply
6945 // reconnects the effective entry. Explicitly gated host specs still record
6946 // their exact launch grant before reconnecting.
6947 func (a *App) AuthorizeAndConnectMCPServer(name string) error {
6948 defer a.lockMCPMutation("authorize-connect")()
6949
6950 tab, ctrl, root := a.activeMCPRuntime()
6951 if tab == nil || ctrl == nil {
6952 return fmt.Errorf("no active session")
6953 }
6954 host, releaseGates, err := a.lockMCPHostTurnGates("MCP authorization", ctrl)
6955 if err != nil {
6956 return err
6957 }
6958 defer releaseGates()
6959 entry, found, err := desktopEffectiveMCPServer(root, name)
6960 if err != nil {
6961 return err
6962 }
6963 if !found {
6964 return fmt.Errorf("no configured MCP server named %q", name)
6965 }
6966 spec, err := a.mcpLaunchSpec(root, name)
6967 if err != nil {
6968 return err
6969 }
6970 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
6971 defer cancel()
6972 if spec.RequireLaunchApproval {
6973 if err := plugin.AuthorizeProjectSpecLaunch(ctx, spec); err != nil {
6974 return err
6975 }
6976 }
6977
6978 controllers := a.mcpControllersSharingHost(host, name, ctrl)
6979 for i := range controllers {
6980 if controllers[i].ctrl == ctrl {
6981 controllers[i].enabled = true
6982 }
6983 }
6984 // Drop any previous identity, then start the effective configured server
6985 // once and refresh every enabled registry sharing this Host.
6986 disconnectMCPServerControllers(name, ctrl, controllers)
6987 if host != nil {
6988 host.ClearFailure(name)
6989 }
6990 if err := reconnectMCPServerControllers(entry, controllers); err != nil {
6991 recordMCPFailure(ctrl, entry, err)
6992 return err
6993 }
6994 a.mu.Lock()
6995 delete(tab.disabledMCP, name)
6996 a.mu.Unlock()
6997 return nil
6998 }
6999
7000 type mcpControllerTarget struct {
7001 ctrl control.SessionAPI
7002 enabled bool
7003 }
7004
7005 // lockMCPHostTurnGates freezes every runtime sharing ctrl's Host. Callers hold
7006 // lockMCPMutation, so runtimeAdmissionMu's write side already prevents new turn
7007 // admissions, builds, and teardown while this helper snapshots and gates the
7008 // existing runtimes.
7009 func (a *App) lockMCPHostTurnGates(setting string, ctrl control.SessionAPI) (*plugin.Host, func(), error) {
7010 if ctrl == nil {
7011 return nil, nil, fmt.Errorf("no active session")
7012 }
7013 host := ctrl.Host()
7014 release, err := a.lockRuntimeTurnGates(setting, func(tab *WorkspaceTab) bool {
7015 if host == nil {
7016 return tab.Ctrl == ctrl
7017 }
7018 return tab.Ctrl != nil && tab.Ctrl.Host() == host
7019 })
7020 return host, release, err
7021 }
7022
7023 func disconnectMCPServerControllers(name string, preferred control.SessionAPI, controllers []mcpControllerTarget) bool {
7024 for _, target := range controllers {
7025 target.ctrl.UnregisterMCPServerTools(name)
7026 }
7027 disconnected := false
7028 if preferred != nil {
7029 disconnected = preferred.DisconnectMCPServer(name)
7030 }
7031 // Every controller owns an independent capability runtime even when the Host
7032 // process is shared. Reconcile each one after the preferred controller drops
7033 // the client so remove/update/rollback cannot leave sibling tabs with stale
7034 // specs or live-tool snapshots.
7035 for _, target := range controllers {
7036 if target.ctrl == preferred {
7037 continue
7038 }
7039 disconnected = target.ctrl.DisconnectMCPServer(name) || disconnected
7040 }
7041 return disconnected
7042 }
7043
7044 func (a *App) clearMCPServerTabState(name string, controllers []mcpControllerTarget) {
7045 selected := make(map[control.SessionAPI]bool, len(controllers))
7046 for _, target := range controllers {
7047 selected[target.ctrl] = true
7048 }
7049 a.mu.Lock()
7050 for _, tab := range a.runtimeTabsLocked() {
7051 if tab == nil || !selected[tab.Ctrl] {
7052 continue
7053 }
7054 delete(tab.disabledMCP, name)
7055 tab.mcpOrder = removeServerOrder(tab.mcpOrder, name)
7056 }
7057 a.mu.Unlock()
7058 }
7059
7060 // reconnectMCPServerControllers establishes one shared client, then refreshes
7061 // every enabled controller's provider-visible Registry. Disabled tabs remain
7062 // suspended and reconnect only when explicitly enabled.
7063 func reconnectMCPServerControllers(entry config.PluginEntry, controllers []mcpControllerTarget) error {
7064 var startErrors []error
7065 connectedTarget := -1
7066 for i, target := range controllers {
7067 if !target.enabled {
7068 continue
7069 }
7070 if _, err := target.ctrl.ConnectMCPServer(entry); err != nil {
7071 startErrors = append(startErrors, err)
7072 continue
7073 }
7074 connectedTarget = i
7075 break
7076 }
7077 if connectedTarget < 0 {
7078 // All tabs may have disabled this server. Keeping it disconnected
7079 // preserves their explicit state.
7080 return errors.Join(startErrors...)
7081 }
7082
7083 var refreshErrors []error
7084 for i, target := range controllers {
7085 if !target.enabled || i == connectedTarget {
7086 continue
7087 }
7088 if _, err := target.ctrl.ConnectMCPServer(entry); err != nil {
7089 refreshErrors = append(refreshErrors, err)
7090 }
7091 }
7092 return errors.Join(refreshErrors...)
7093 }
7094
7095 // mcpControllersSharingHost snapshots visible and detached runtimes before
7096 // calling controller methods. App.mu is never held across Host/controller
7097 // locks or network work. preferred (normally the active tab) is returned first.
7098 func (a *App) mcpControllersSharingHost(host *plugin.Host, name string, preferred control.SessionAPI) []mcpControllerTarget {
7099 if host == nil {
7100 enabled := true
7101 a.mu.RLock()
7102 for _, tab := range a.runtimeTabsLocked() {
7103 if tab != nil && tab.Ctrl == preferred {
7104 _, disabled := tab.disabledMCP[name]
7105 enabled = !disabled
7106 break
7107 }
7108 }
7109 a.mu.RUnlock()
7110 return []mcpControllerTarget{{ctrl: preferred, enabled: enabled}}
7111 }
7112 a.mu.RLock()
7113 candidates := make([]mcpControllerTarget, 0, len(a.tabs)+len(a.detachedSessions))
7114 for _, tab := range a.runtimeTabsLocked() {
7115 if tab == nil || tab.Ctrl == nil {
7116 continue
7117 }
7118 _, disabled := tab.disabledMCP[name]
7119 candidates = append(candidates, mcpControllerTarget{ctrl: tab.Ctrl, enabled: !disabled})
7120 }
7121 a.mu.RUnlock()
7122
7123 byController := make(map[control.SessionAPI]int, len(candidates))
7124 targets := make([]mcpControllerTarget, 0, len(candidates))
7125 for _, candidate := range candidates {
7126 if candidate.ctrl.Host() != host {
7127 continue
7128 }
7129 if idx, ok := byController[candidate.ctrl]; ok {
7130 targets[idx].enabled = targets[idx].enabled || candidate.enabled
7131 continue
7132 }
7133 byController[candidate.ctrl] = len(targets)
7134 targets = append(targets, candidate)
7135 }
7136 if len(targets) == 0 {
7137 return []mcpControllerTarget{{ctrl: preferred, enabled: true}}
7138 }
7139 if idx, ok := byController[preferred]; ok && idx > 0 {
7140 targets[0], targets[idx] = targets[idx], targets[0]
7141 }
7142 return targets
7143 }
7144
7145 // lockRuntimeTurnGates locks the turn gate of every runtime tab selected by
7146 // affected (nil selects all visible and detached runtime tabs) in stable tab-ID
7147 // order, then verifies under the gates that no gated controller has active
7148 // runtime work. Callers must hold runtimeRebuildMu and the write side of
7149 // runtimeAdmissionMu (normally through lockMCPMutation), which freezes new turn
7150 // admission, controller builds, and runtime teardown before this snapshot.
7151 // On success the returned release func unlocks the per-tab gates in reverse
7152 // order; on error every gate acquired here is already unlocked.
7153 func (a *App) lockRuntimeTurnGates(setting string, affected func(*WorkspaceTab) bool) (func(), error) {
7154 a.mu.RLock()
7155 all := a.runtimeTabsLocked()
7156 tabs := make([]*WorkspaceTab, 0, len(all))
7157 for _, tab := range all {
7158 if tab == nil || (affected != nil && !affected(tab)) {
7159 continue
7160 }
7161 tabs = append(tabs, tab)
7162 }
7163 a.mu.RUnlock()
7164 sort.Slice(tabs, func(i, j int) bool { return tabs[i].ID < tabs[j].ID })
7165 locked := 0
7166 release := func() {
7167 for i := locked - 1; i >= 0; i-- {
7168 tabs[i].turnStartMu.Unlock()
7169 }
7170 }
7171 for _, tab := range tabs {
7172 tab.turnStartMu.Lock()
7173 locked++
7174 }
7175 // Read tab.Ctrl under a.mu rather than through controllerForTab: detached
7176 // runtimes live in detachedSessions, not a.tabs, and their work counts too.
7177 a.mu.RLock()
7178 for _, tab := range tabs {
7179 if err := rebuildControllerActiveWorkErrorFor(tab.Ctrl, setting); err != nil {
7180 a.mu.RUnlock()
7181 release()
7182 return nil, err
7183 }
7184 }
7185 a.mu.RUnlock()
7186 return release, nil
7187 }
7188
7189 // disconnectMCPServerAllRuntimes removes an uninstalled MCP server from every
7190 // live runtime: all visible and detached runtime tabs, across every shared
7191 // Host — a global plugin uninstall must not leave sibling tabs exposing stale
7192 // provider-visible tools or other workspaces running the removed server.
7193 // DisconnectMCPServer stops the shared client once per Host and drops the tool
7194 // prefix from every other controller's registry.
7195 func (a *App) disconnectMCPServerAllRuntimes(serverName string) bool {
7196 a.mu.RLock()
7197 ctrls := make([]control.SessionAPI, 0, len(a.tabs)+len(a.detachedSessions))
7198 seen := make(map[control.SessionAPI]bool, len(a.tabs)+len(a.detachedSessions))
7199 for _, tab := range a.runtimeTabsLocked() {
7200 if tab == nil || tab.Ctrl == nil || seen[tab.Ctrl] {
7201 continue
7202 }
7203 seen[tab.Ctrl] = true
7204 ctrls = append(ctrls, tab.Ctrl)
7205 }
7206 a.mu.RUnlock()
7207 disconnected := false
7208 for _, ctrl := range ctrls {
7209 if ctrl.DisconnectMCPServer(serverName) {
7210 disconnected = true
7211 }
7212 }
7213 return disconnected
7214 }
7215
7216 // SkillsSettings returns the skills management snapshot without MCP status.
7217 func (a *App) SkillsSettings() SkillsSettingsView {
7218 out := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}, AllowImplicitInvocation: true}
7219 a.mu.RLock()
7220 tab := a.activeTabLocked()
7221 var ctrl control.SessionAPI
7222 workspaceRoot := "."
7223 if tab != nil {
7224 ctrl = tab.Ctrl
7225 if strings.TrimSpace(tab.WorkspaceRoot) != "" {
7226 workspaceRoot = tab.WorkspaceRoot
7227 }
7228 }
7229 a.mu.RUnlock()
7230 if ctrl == nil {
7231 return out
7232 }
7233
7234 disabled := map[string]bool{}
7235 var configuredModels, configuredEfforts map[string]string
7236 if cfg, err := config.LoadForRootReadOnly(workspaceRoot); err == nil {
7237 out.AllowImplicitInvocation = cfg.ImplicitSkillInvocationEnabled()
7238 for _, name := range cfg.Skills.DisabledSkills {
7239 if key := config.SkillNameKey(name); key != "" {
7240 disabled[key] = true
7241 }
7242 }
7243 configuredModels = cfg.Agent.SubagentModels
7244 configuredEfforts = cfg.Agent.SubagentEfforts
7245 }
7246 out.SkillRoots = a.cachedSkillRootsView(workspaceRoot)
7247 for _, s := range ctrl.AllSkills() {
7248 view := SkillView{
7249 Name: s.Name, Description: s.Description,
7250 Scope: string(s.Scope), SourceDir: skillSourceDir(s, out.SkillRoots), RunAs: string(s.RunAs),
7251 Enabled: !disabled[config.SkillNameKey(s.Name)],
7252 Plugin: s.Plugin,
7253 Model: s.Model,
7254 Effort: s.Effort,
7255 AllowedTools: append([]string{}, s.AllowedTools...),
7256 ReadOnly: s.ReadOnly,
7257 Color: s.Color,
7258 Invocation: "/" + s.SlashName(),
7259 InvocationMode: s.Invocation,
7260 ConfiguredModel: subagentOverrideFor(configuredModels, s.Name),
7261 ConfiguredEffort: subagentOverrideFor(configuredEfforts, s.Name),
7262 }
7263 // Body feeds only the Subagents editor's prompt prefill. Inline skills
7264 // fold references/ into Body at load time (hundreds of KB for a rich
7265 // skill library), and every Capabilities/Settings fetch would ship all
7266 // of it across the JSON bridge for nothing.
7267 if s.RunAs == skill.RunSubagent {
7268 if loaded, ok := ctrl.LoadSkill(s.Name); ok {
7269 view.Body = loaded.Body
7270 }
7271 }
7272 out.Skills = append(out.Skills, view)
7273 }
7274 return out
7275 }
7276
7277 // SetSkillImplicitInvocation persists whether the model may discover and
7278 // invoke skills automatically, then rebuilds the active runtime. Explicit
7279 // /skill invocation and skill management remain available in either mode.
7280 func (a *App) SetSkillImplicitInvocation(enabled bool) error {
7281 err := a.applySkillConfigChange("disable_implicit_invocation", "skills policy", func(c *config.Config) error {
7282 c.SetSkillImplicitInvocation(enabled)
7283 return nil
7284 })
7285 if err == nil {
7286 a.invalidateSkillRootsCache()
7287 }
7288 return err
7289 }
7290
7291 // subagentOverrideFor resolves a per-name subagent override with the same
7292 // underscore/hyphen alias fallback the runtime dispatch uses
7293 // (boot.SubagentModelKeys) — an exact-key read would show a legacy
7294 // `security_review` config entry as "inherit default" while it still won at
7295 // dispatch time.
7296 func subagentOverrideFor(overrides map[string]string, name string) string {
7297 for _, key := range boot.SubagentModelKeys(name) {
7298 if v := strings.TrimSpace(overrides[key]); v != "" {
7299 return v
7300 }
7301 }
7302 return ""
7303 }
7304
7305 // AvailableSubagentTools lists the tool names a subagent profile's
7306 // "available tools" picker may offer. Scoped to compile-time builtins for
7307 // v1 — MCP/plugin tools are per-session/per-connection and would need a new
7308 // live-registry accessor on control.Capabilities to enumerate safely; a
7309 // profile's allowed-tools already degrades gracefully (FilterRegistry drops
7310 // unknown names silently) if extended to MCP names by hand later. Tools that
7311 // are always excluded from every subagent regardless of an explicit
7312 // allowlist (agent.AlwaysHiddenSubagentTools) are left out entirely — they'd
7313 // be a selectable no-op otherwise.
7314 func (a *App) AvailableSubagentTools() []ToolView {
7315 hidden := map[string]bool{}
7316 for _, name := range agent.AlwaysHiddenSubagentTools() {
7317 hidden[name] = true
7318 }
7319 entries := tool.BuiltinContractEntries()
7320 out := make([]ToolView, 0, len(entries))
7321 for _, e := range entries {
7322 if hidden[e.Name] {
7323 continue
7324 }
7325 out = append(out, ToolView{Name: e.Name, Description: e.Description, ReadOnlyHint: e.ReadOnly})
7326 }
7327 sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
7328 return out
7329 }
7330
7331 func (a *App) mcpServersView() []ServerView {
7332 out := []ServerView{}
7333 a.mu.RLock()
7334 tab := a.activeTabLocked()
7335 if tab == nil {
7336 a.mu.RUnlock()
7337 return out
7338 }
7339 ctrl := tab.Ctrl
7340 disabled := make(map[string]ServerView, len(tab.disabledMCP))
7341 maps.Copy(disabled, tab.disabledMCP)
7342 order := append([]string(nil), tab.mcpOrder...)
7343 workspaceRoot := tab.WorkspaceRoot
7344 tabID := tab.ID
7345 a.mu.RUnlock()
7346 if ctrl == nil {
7347 return out
7348 }
7349 seen := map[string]bool{}
7350 connected := map[string]bool{}
7351 retainedDisabled := map[string]ServerView{}
7352 configured := map[string]config.PluginEntry{}
7353 managedByPlugin := map[string]string{}
7354 var configuredEntries []config.PluginEntry
7355 if cfg, err := config.LoadForRoot(workspaceRoot); err == nil {
7356 configuredEntries = append(configuredEntries, cfg.Plugins...)
7357 for _, p := range configuredEntries {
7358 configured[p.Name] = p
7359 if owner, ok := cfg.PluginPackageOwner(p.Name); ok {
7360 managedByPlugin[p.Name] = owner
7361 }
7362 }
7363 }
7364 if h := ctrl.Host(); h != nil {
7365 for _, s := range h.Servers() {
7366 if disabledView, ok := disabled[s.Name]; ok {
7367 disabledView.Status = "disabled"
7368 disabledView.RuntimeState = "idle"
7369 disabledView.StartIntent = "off"
7370 disabledView.Error = ""
7371 if p, ok := configured[s.Name]; ok {
7372 disabledView = withPluginConfigInWorkspace(disabledView, p, workspaceRoot)
7373 }
7374 out = append(out, disabledView)
7375 retainedDisabled[s.Name] = disabledView
7376 seen[s.Name] = true
7377 delete(disabled, s.Name)
7378 continue
7379 }
7380 seen[s.Name] = true
7381 connected[s.Name] = true
7382 view := pluginServerToView(s)
7383 if p, ok := configured[s.Name]; ok {
7384 view = withPluginConfigInWorkspace(view, p, workspaceRoot)
7385 }
7386 out = append(out, view)
7387 }
7388 for _, f := range h.Failures() {
7389 seen[f.Name] = true
7390 view := ServerView{
7391 Name: f.Name, Transport: f.Transport, Status: "failed", RuntimeState: "issue", Error: f.Error,
7392 RequiresLaunchApproval: f.RequiresLaunchApproval,
7393 }
7394 if p, ok := configured[f.Name]; ok {
7395 view = withPluginConfigInWorkspace(view, p, workspaceRoot)
7396 }
7397 out = append(out, view)
7398 }
7399 for _, name := range h.ConnectingServers() {
7400 if seen[name] {
7401 continue
7402 }
7403 seen[name] = true
7404 view := ServerView{Name: name, Status: "initializing", RuntimeState: "connecting"}
7405 if p, ok := configured[name]; ok {
7406 view = withPluginConfigInWorkspace(view, p, workspaceRoot)
7407 }
7408 out = append(out, view)
7409 }
7410 }
7411 // Configured servers that are neither connected, connecting, nor failed are
7412 // idle: disabled/off or automatic background startup waiting for its next kick.
7413 if len(configuredEntries) > 0 {
7414 for _, p := range configuredEntries {
7415 if seen[p.Name] {
7416 continue
7417 }
7418 if s, ok := disabled[p.Name]; ok {
7419 s.Status = "disabled"
7420 s.RuntimeState = "idle"
7421 s.StartIntent = "off"
7422 s = withPluginConfigInWorkspace(s, p, workspaceRoot)
7423 s.Error = ""
7424 out = append(out, s)
7425 retainedDisabled[p.Name] = s
7426 seen[p.Name] = true
7427 delete(disabled, p.Name)
7428 continue
7429 }
7430 status := "disabled"
7431 startIntent := "off"
7432 if mcpEntryEnabled(p, workspaceRoot) {
7433 status = "deferred"
7434 startIntent = "automatic"
7435 }
7436 out = append(out, withPluginConfigInWorkspace(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: "idle"}, p, workspaceRoot))
7437 seen[p.Name] = true
7438 }
7439 }
7440 out = orderServerViews(out, order)
7441 for i := range out {
7442 out[i].ManagedByPlugin = managedByPlugin[out[i].Name]
7443 out[i] = finalizeServerView(out[i])
7444 }
7445
7446 a.mu.Lock()
7447 if tab, ok := a.tabs[tabID]; ok {
7448 for name := range connected {
7449 delete(retainedDisabled, name)
7450 }
7451 tab.disabledMCP = retainedDisabled
7452 tab.mcpOrder = mergeServerOrder(tab.mcpOrder, out)
7453 }
7454 a.mu.Unlock()
7455 return out
7456 }
7457
7458 func mcpEntryEnabled(p config.PluginEntry, workspace string) bool {
7459 return config.MCPServerEnabled(p, workspace)
7460 }
7461
7462 func mcpRuntimeState(status string) string {
7463 switch status {
7464 case "connected":
7465 return "ready"
7466 case "initializing":
7467 return "connecting"
7468 case "failed":
7469 return "issue"
7470 default:
7471 return "idle"
7472 }
7473 }
7474
7475 func mcpAvailability(v ServerView) string {
7476 if !v.Enabled {
7477 return "disabled"
7478 }
7479 switch v.RuntimeState {
7480 case "ready":
7481 return "connected"
7482 case "connecting":
7483 return "starting"
7484 case "issue":
7485 if v.RequiresLaunchApproval {
7486 return "project_auth_changed"
7487 }
7488 if v.AuthStatus == "required" || v.AuthStatus == "possible" {
7489 return "auth_required"
7490 }
7491 return "start_failed"
7492 default:
7493 // Idle enabled servers are available on demand, not "disconnected".
7494 return "available_on_demand"
7495 }
7496 }
7497
7498 func mcpActionForView(v ServerView) string {
7499 if v.RequiresLaunchApproval {
7500 return "authorize"
7501 }
7502 if v.AuthStatus == "required" {
7503 return "authenticate"
7504 }
7505 if v.RuntimeState == "issue" {
7506 return "retry"
7507 }
7508 return "none"
7509 }
7510
7511 func finalizeServerView(v ServerView) ServerView {
7512 if v.ToolList == nil {
7513 v.ToolList = []ToolView{}
7514 }
7515 if v.Args == nil {
7516 v.Args = []string{}
7517 }
7518 if v.EnvKeys == nil {
7519 v.EnvKeys = []string{}
7520 }
7521 if v.HeaderKeys == nil {
7522 v.HeaderKeys = []string{}
7523 }
7524 v.ToolCount = v.Tools
7525 if v.ToolCount == 0 && len(v.ToolList) > 0 {
7526 v.ToolCount = len(v.ToolList)
7527 v.Tools = v.ToolCount
7528 }
7529 v.Installed = v.Configured || v.BuiltIn || v.Status != ""
7530 if v.Source == "" {
7531 switch {
7532 case v.BuiltIn:
7533 v.Source = "builtin"
7534 case v.ManagedByPlugin != "":
7535 v.Source = "plugin"
7536 case v.Configured:
7537 v.Source = "user"
7538 }
7539 }
7540 if v.RuntimeState == "" {
7541 v.RuntimeState = mcpRuntimeState(v.Status)
7542 }
7543 v.Availability = mcpAvailability(v)
7544 if v.Action == "" {
7545 v.Action = mcpActionForView(v)
7546 }
7547 // Keep deprecated fields derived from the new product state.
7548 v.AutoStart = v.Enabled
7549 if !v.Enabled {
7550 v.StartIntent = "off"
7551 } else if v.StartIntent == "" {
7552 v.StartIntent = "automatic"
7553 }
7554 return v
7555 }
7556
7557 func withPluginConfig(v ServerView, p config.PluginEntry) ServerView {
7558 return withPluginConfigInWorkspace(v, p, "")
7559 }
7560
7561 func withPluginConfigInWorkspace(v ServerView, p config.PluginEntry, workspace string) ServerView {
7562 tt := p.Type
7563 if tt == "" {
7564 tt = "stdio"
7565 }
7566 v.Transport = tt
7567 v.Configured = true
7568 v.Installed = true
7569 v.Source, v.ConfigSource = mcpServerSource(p.Source)
7570 v.Enabled = mcpEntryEnabled(p, workspace)
7571 v.AutoStart = v.Enabled
7572 v.Tier = p.ResolvedTier()
7573 if v.StartIntent == "" {
7574 if v.Enabled {
7575 v.StartIntent = "automatic"
7576 } else {
7577 v.StartIntent = "off"
7578 }
7579 }
7580 if !v.Enabled || v.Status == "disabled" {
7581 v.Status = "disabled"
7582 v.StartIntent = "off"
7583 v.RuntimeState = "idle"
7584 }
7585 if v.RuntimeState == "" {
7586 v.RuntimeState = mcpRuntimeState(v.Status)
7587 }
7588 v.Command = p.Command
7589 v.Args = append([]string(nil), p.Args...)
7590 v.URL = p.URL
7591 v.CallTimeoutSeconds = p.CallTimeoutSeconds
7592 v.ToolTimeoutSeconds = cloneStringIntMap(p.ToolTimeoutSeconds)
7593 // Configured MCP entries are explicit installs, including project sources.
7594 v.RequiresLaunchApproval = false
7595 v.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL)
7596 v.EnvKeys = nil
7597 v.HeaderKeys = nil
7598 if len(p.Env) > 0 {
7599 v.EnvKeys = make([]string, 0, len(p.Env))
7600 for k := range p.Env {
7601 v.EnvKeys = append(v.EnvKeys, k)
7602 }
7603 sort.Strings(v.EnvKeys)
7604 }
7605 if len(p.Headers) > 0 {
7606 v.HeaderKeys = make([]string, 0, len(p.Headers))
7607 for k := range p.Headers {
7608 v.HeaderKeys = append(v.HeaderKeys, k)
7609 }
7610 sort.Strings(v.HeaderKeys)
7611 }
7612 auth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured)
7613 v.AuthStatus = auth.Status
7614 v.AuthURL = auth.URL
7615 return v
7616 }
7617
7618 func mcpServerSource(source config.MCPConfigSource) (kind, configSource string) {
7619 switch source {
7620 case config.MCPSourceProjectConfig:
7621 return "project", "reasonix.toml"
7622 case config.MCPSourceProjectMCPJSON:
7623 return "project", ".mcp.json"
7624 case config.MCPSourcePluginPackage:
7625 return "plugin", "plugin"
7626 case config.MCPSourceLegacyUser:
7627 return "user", "legacy config"
7628 case config.MCPSourceUserConfig:
7629 return "user", "config.toml"
7630 default:
7631 return "", ""
7632 }
7633 }
7634
7635 const skillRootsCacheTTL = 10 * time.Second
7636
7637 func (a *App) cachedSkillRootsView(workspaceRoots ...string) []SkillRootView {
7638 workspaceRoot := "."
7639 if len(workspaceRoots) > 0 {
7640 workspaceRoot = workspaceRoots[0]
7641 }
7642 workspaceRoot = normalizeWorkspaceRoot(workspaceRoot)
7643 cfg, _ := config.LoadForRootReadOnly(workspaceRoot)
7644 userCfg := config.LoadForEdit(config.UserConfigPath())
7645 key := skillRootsCacheKey(workspaceRoot, cfg, userCfg)
7646
7647 now := time.Now()
7648 a.skillRootsMu.Lock()
7649 if a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL {
7650 roots := cloneSkillRootViews(a.skillRootsCache.roots)
7651 a.skillRootsMu.Unlock()
7652 return roots
7653 }
7654 a.skillRootsMu.Unlock()
7655
7656 roots := skillRootsViewFrom(workspaceRoot, cfg, userCfg)
7657
7658 a.skillRootsMu.Lock()
7659 a.skillRootsCache = skillRootsCache{
7660 key: key,
7661 at: now,
7662 roots: cloneSkillRootViews(roots),
7663 }
7664 a.skillRootsMu.Unlock()
7665 return roots
7666 }
7667
7668 func (a *App) invalidateSkillRootsCache() {
7669 a.skillRootsMu.Lock()
7670 a.skillRootsCache = skillRootsCache{}
7671 a.skillRootsMu.Unlock()
7672 }
7673
7674 func skillRootsViewFrom(workspaceRoot string, cfg, userCfg *config.Config) []SkillRootView {
7675 workspaceRoot = normalizeWorkspaceRoot(workspaceRoot)
7676 var custom []string
7677 var excluded []string
7678 maxDepth := 3
7679 if cfg != nil {
7680 custom = cfg.SkillCustomPaths()
7681 excluded = cfg.SkillExcludedPaths()
7682 maxDepth = cfg.SkillMaxDepth()
7683 }
7684 var pluginPaths map[string][]string
7685 var pluginAgentPaths map[string][]string
7686 if cfg != nil {
7687 pluginPaths = cfg.PluginPackageSkillOwners()
7688 pluginAgentPaths = cfg.PluginPackageAgentOwners()
7689 }
7690 st := skill.New(skill.Options{ProjectRoot: workspaceRoot, CustomPaths: custom, PluginPaths: pluginPaths, PluginAgentPaths: pluginAgentPaths, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard})
7691 counts := map[string]int{}
7692 skillItems := map[string][]SkillRootSkillView{}
7693 roots := st.Roots()
7694 for _, sk := range st.SlashList() {
7695 root := skillDisplayRoot(sk, roots)
7696 counts[root]++
7697 skillItems[root] = append(skillItems[root], SkillRootSkillView{
7698 Name: sk.Name,
7699 Description: sk.Description,
7700 Scope: string(sk.Scope),
7701 RunAs: string(sk.RunAs),
7702 Plugin: sk.Plugin,
7703 Model: sk.Model,
7704 Effort: sk.Effort,
7705 AllowedTools: append([]string{}, sk.AllowedTools...),
7706 Color: sk.Color,
7707 Invocation: "/" + sk.SlashName(),
7708 })
7709 }
7710 for root := range skillItems {
7711 sort.Slice(skillItems[root], func(i, j int) bool {
7712 return skillItems[root][i].Invocation < skillItems[root][j].Invocation
7713 })
7714 }
7715 userConfigured := map[string]bool{}
7716 if userCfg != nil {
7717 for _, p := range userCfg.Skills.Paths {
7718 userConfigured[canonicalSkillPathForRoot(p, workspaceRoot)] = true
7719 }
7720 }
7721 effectiveConfigured := map[string]bool{}
7722 effectiveExcluded := map[string]bool{}
7723 if cfg != nil {
7724 for _, p := range cfg.Skills.Paths {
7725 effectiveConfigured[canonicalSkillPathForRoot(p, workspaceRoot)] = true
7726 }
7727 for _, p := range cfg.Skills.ExcludedPaths {
7728 effectiveExcluded[canonicalSkillPathForRoot(p, workspaceRoot)] = true
7729 }
7730 }
7731 out := []SkillRootView{}
7732 seenRoots := map[string]int{}
7733 for _, r := range roots {
7734 dir := canonicalSkillPathForRoot(r.Dir, workspaceRoot)
7735 view := SkillRootView{
7736 Dir: r.Dir,
7737 Scope: string(r.Scope),
7738 Priority: r.Priority + 1,
7739 Status: string(r.Status),
7740 Enabled: true,
7741 Configured: r.Scope == skill.ScopeCustom && (userConfigured[dir] || effectiveConfigured[dir]),
7742 Removable: true,
7743 Skills: counts[dir],
7744 SkillItems: skillItems[dir],
7745 }
7746 if idx, ok := seenRoots[dir]; ok {
7747 out[idx] = mergeDuplicateSkillRootView(out[idx], view)
7748 continue
7749 }
7750 seenRoots[dir] = len(out)
7751 out = append(out, view)
7752 }
7753 if cfg != nil {
7754 for _, p := range cfg.Skills.Paths {
7755 if rootActive(out, p, workspaceRoot) {
7756 continue
7757 }
7758 dir := canonicalSkillPathForRoot(p, workspaceRoot)
7759 enabled := !effectiveExcluded[dir]
7760 status := "inactive"
7761 warning := "configured in project/user config but not active in this workspace"
7762 if !enabled {
7763 status = "disabled"
7764 warning = ""
7765 }
7766 appendSkillRootView(&out, &seenRoots, SkillRootView{
7767 Dir: dir, Scope: string(skill.ScopeCustom), Status: status, Enabled: enabled,
7768 Configured: true, Removable: true, Warning: warning,
7769 }, workspaceRoot)
7770 }
7771 for _, p := range cfg.Skills.ExcludedPaths {
7772 if rootActive(out, p, workspaceRoot) {
7773 continue
7774 }
7775 dir := canonicalSkillPathForRoot(p, workspaceRoot)
7776 scope := skillRootScopeForPath(p, workspaceRoot)
7777 appendSkillRootView(&out, &seenRoots, SkillRootView{
7778 Dir: dir, Scope: string(scope), Status: "disabled", Enabled: false,
7779 Configured: scope == skill.ScopeCustom || effectiveConfigured[dir], Removable: true,
7780 }, workspaceRoot)
7781 }
7782 }
7783 if userCfg != nil {
7784 userExcluded := map[string]bool{}
7785 for _, p := range userCfg.Skills.ExcludedPaths {
7786 userExcluded[canonicalSkillPathForRoot(p, workspaceRoot)] = true
7787 }
7788 for _, p := range userCfg.Skills.Paths {
7789 if rootActive(out, p, workspaceRoot) {
7790 continue
7791 }
7792 enabled := !userExcluded[canonicalSkillPathForRoot(p, workspaceRoot)]
7793 status := "inactive"
7794 warning := "configured in user config but not active in this workspace; project [skills].paths may override it"
7795 if !enabled {
7796 status = "disabled"
7797 warning = ""
7798 }
7799 appendSkillRootView(&out, &seenRoots, SkillRootView{
7800 Dir: canonicalSkillPathForRoot(p, workspaceRoot),
7801 Scope: string(skill.ScopeCustom),
7802 Status: status,
7803 Enabled: enabled,
7804 Configured: true,
7805 Removable: true,
7806 Warning: warning,
7807 }, workspaceRoot)
7808 }
7809 for _, p := range userCfg.Skills.ExcludedPaths {
7810 if rootActive(out, p, workspaceRoot) || userConfigured[canonicalSkillPathForRoot(p, workspaceRoot)] {
7811 continue
7812 }
7813 scope := skillRootScopeForPath(p, workspaceRoot)
7814 appendSkillRootView(&out, &seenRoots, SkillRootView{
7815 Dir: canonicalSkillPathForRoot(p, workspaceRoot), Scope: string(scope), Status: "disabled", Enabled: false,
7816 Configured: scope == skill.ScopeCustom, Removable: true,
7817 }, workspaceRoot)
7818 }
7819 }
7820 return out
7821 }
7822
7823 func appendSkillRootView(out *[]SkillRootView, seen *map[string]int, view SkillRootView, workspaceRoot string) {
7824 dir := canonicalSkillPathForRoot(view.Dir, workspaceRoot)
7825 if idx, ok := (*seen)[dir]; ok {
7826 (*out)[idx] = mergeDuplicateSkillRootView((*out)[idx], view)
7827 return
7828 }
7829 (*seen)[dir] = len(*out)
7830 *out = append(*out, view)
7831 }
7832
7833 func mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView {
7834 existing.Configured = existing.Configured || duplicate.Configured
7835 existing.Removable = existing.Removable || duplicate.Removable
7836 if existing.Status != "ok" && duplicate.Status == "ok" {
7837 existing.Status = duplicate.Status
7838 existing.Enabled = duplicate.Enabled
7839 }
7840 if existing.Skills == 0 && duplicate.Skills > 0 {
7841 existing.Skills = duplicate.Skills
7842 existing.SkillItems = duplicate.SkillItems
7843 }
7844 if existing.Warning == "" {
7845 existing.Warning = duplicate.Warning
7846 }
7847 return existing
7848 }
7849
7850 func skillRootsCacheKey(workspaceRoot string, cfg, userCfg *config.Config) string {
7851 type cacheKey struct {
7852 CWD string `json:"cwd"`
7853 Custom []string `json:"custom"`
7854 Plugins []string `json:"plugins"`
7855 Excluded []string `json:"excluded"`
7856 MaxDepth int `json:"maxDepth"`
7857 UserPaths []string `json:"userPaths"`
7858 }
7859 workspaceRoot = normalizeWorkspaceRoot(workspaceRoot)
7860 key := cacheKey{CWD: canonicalSkillPathForRoot(workspaceRoot, workspaceRoot), MaxDepth: 3}
7861 if cfg != nil {
7862 key.Custom = canonicalSkillPathsForRoot(cfg.SkillCustomPaths(), workspaceRoot)
7863 for path, owners := range cfg.PluginPackageSkillOwners() {
7864 for _, owner := range owners {
7865 key.Plugins = append(key.Plugins, canonicalSkillPathForRoot(path, workspaceRoot)+"\x00"+owner)
7866 }
7867 }
7868 sort.Strings(key.Plugins)
7869 key.Excluded = canonicalSkillPathsForRoot(cfg.SkillExcludedPaths(), workspaceRoot)
7870 key.MaxDepth = cfg.SkillMaxDepth()
7871 }
7872 if userCfg != nil {
7873 key.UserPaths = canonicalSkillPathsForRoot(userCfg.Skills.Paths, workspaceRoot)
7874 }
7875 b, err := json.Marshal(key)
7876 if err != nil {
7877 return fmt.Sprintf("%s|%v|%v|%v|%d|%v", key.CWD, key.Custom, key.Plugins, key.Excluded, key.MaxDepth, key.UserPaths)
7878 }
7879 return string(b)
7880 }
7881
7882 func canonicalSkillPathsForRoot(paths []string, workspaceRoot string) []string {
7883 out := make([]string, 0, len(paths))
7884 for _, p := range paths {
7885 out = append(out, canonicalSkillPathForRoot(p, workspaceRoot))
7886 }
7887 sort.Strings(out)
7888 return out
7889 }
7890
7891 func normalizeWorkspaceRoot(root string) string {
7892 root = strings.TrimSpace(root)
7893 if root == "" || root == "." {
7894 if cwd, err := os.Getwd(); err == nil {
7895 return filepath.Clean(cwd)
7896 }
7897 return "."
7898 }
7899 if abs, err := filepath.Abs(root); err == nil {
7900 return filepath.Clean(abs)
7901 }
7902 return filepath.Clean(root)
7903 }
7904
7905 // canonicalSkillPathForRoot mirrors skill.Store's path resolution while keeping
7906 // comparisons independent of the desktop process CWD. Config may intentionally
7907 // contain relative paths; those are relative to the active workspace.
7908 func canonicalSkillPathForRoot(path, workspaceRoot string) string {
7909 path = config.ExpandVars(strings.TrimSpace(path))
7910 if path == "" {
7911 return ""
7912 }
7913 if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
7914 if home, err := os.UserHomeDir(); err == nil {
7915 if path == "~" {
7916 path = home
7917 } else {
7918 path = filepath.Join(home, path[2:])
7919 }
7920 }
7921 }
7922 if !filepath.IsAbs(path) {
7923 path = filepath.Join(normalizeWorkspaceRoot(workspaceRoot), path)
7924 }
7925 return config.CanonicalSkillPath(path)
7926 }
7927
7928 func cloneSkillRootViews(in []SkillRootView) []SkillRootView {
7929 out := make([]SkillRootView, len(in))
7930 for i, r := range in {
7931 out[i] = r
7932 out[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...)
7933 }
7934 return out
7935 }
7936
7937 func rootActive(roots []SkillRootView, path string, workspaceRoots ...string) bool {
7938 workspaceRoot := "."
7939 if len(workspaceRoots) > 0 {
7940 workspaceRoot = workspaceRoots[0]
7941 }
7942 want := canonicalSkillPathForRoot(path, workspaceRoot)
7943 for _, r := range roots {
7944 if canonicalSkillPathForRoot(r.Dir, workspaceRoot) == want {
7945 return true
7946 }
7947 }
7948 return false
7949 }
7950
7951 // PickSkillFolder opens a directory picker for adding custom skill roots. It only
7952 // returns a path; AddSkillPath performs normalization and writes config.
7953 func (a *App) PickSkillFolder() (string, error) {
7954 if a.ctx == nil {
7955 return "", nil
7956 }
7957 cur, _ := os.Getwd()
7958 dir, err := a.nativeHost().OpenDirectoryDialog(a.ctx, nativeDialogOptions{
7959 Title: "Choose skills folder",
7960 DefaultDirectory: dialogDefaultDirectory(cur),
7961 })
7962 if err != nil || dir == "" {
7963 return "", err
7964 }
7965 return normalizeSkillPath(dir), nil
7966 }
7967
7968 // PickPluginFolder opens a directory picker for choosing a local plugin package
7969 // source. It returns the selected directory path; plugin install/plan performs
7970 // manifest validation and decides whether to copy or link the package.
7971 func (a *App) PickPluginFolder() (string, error) {
7972 if a.ctx == nil {
7973 return "", nil
7974 }
7975 cur := a.activeWorkspaceRoot()
7976 if strings.TrimSpace(cur) == "" {
7977 cur, _ = os.Getwd()
7978 }
7979 dir, err := a.nativeHost().OpenDirectoryDialog(a.ctx, nativeDialogOptions{
7980 Title: "Choose plugin folder",
7981 DefaultDirectory: dialogDefaultDirectory(cur),
7982 })
7983 if err != nil || dir == "" {
7984 return "", err
7985 }
7986 return filepath.Clean(dir), nil
7987 }
7988
7989 // AddSkillPath adds a custom skill root to the user config and rebuilds the
7990 // controller so the skills index and slash menu reflect it immediately.
7991 func (a *App) AddSkillPath(path string) error {
7992 path = normalizeSkillPath(path)
7993 workspaceRoot := a.activeWorkspaceRoot()
7994 field := "paths"
7995 if isConventionSkillRoot(path, workspaceRoot) {
7996 field = "excluded_paths"
7997 }
7998 err := a.applySkillConfigChange(field, "skills source", func(c *config.Config) error {
7999 if isConventionSkillRoot(path, workspaceRoot) {
8000 return c.RestoreSkillPath(path)
8001 }
8002 return c.AddSkillPath(path)
8003 })
8004 if err == nil {
8005 a.invalidateSkillRootsCache()
8006 }
8007 return err
8008 }
8009
8010 // RemoveSkillPath removes a skill source from the user config and rebuilds. For
8011 // convention roots, it records a pseudo-delete in excluded_paths.
8012 func (a *App) RemoveSkillPath(path string) error {
8013 path = normalizeSkillPath(path)
8014 workspaceRoot := a.activeWorkspaceRoot()
8015 field := "paths"
8016 if isConventionSkillRoot(path, workspaceRoot) {
8017 field = "excluded_paths"
8018 }
8019 err := a.applySkillConfigChange(field, "skills source", func(c *config.Config) error {
8020 removed, err := c.RemoveSkillPath(path)
8021 if err != nil || removed {
8022 return err
8023 }
8024 return c.ExcludeSkillPath(path)
8025 })
8026 if err == nil {
8027 a.invalidateSkillRootsCache()
8028 }
8029 return err
8030 }
8031
8032 // SetSkillPathEnabled persists a reversible source toggle and rebuilds the
8033 // controller so the source is immediately included or excluded from discovery.
8034 func (a *App) SetSkillPathEnabled(path string, enabled bool) error {
8035 path = normalizeSkillPath(path)
8036 workspaceRoot := a.activeWorkspaceRoot()
8037 field := "paths"
8038 if isConventionSkillRoot(path, workspaceRoot) {
8039 field = "excluded_paths"
8040 }
8041 err := a.applySkillConfigChange(field, "skills source", func(c *config.Config) error {
8042 return c.SetSkillPathEnabled(path, enabled)
8043 })
8044 if err == nil {
8045 a.invalidateSkillRootsCache()
8046 }
8047 return err
8048 }
8049
8050 // RefreshSkills rebuilds the controller without changing config, reloading skill
8051 // discovery, the system prompt index, and slash completions.
8052 func (a *App) RefreshSkills() error {
8053 a.invalidateSkillRootsCache()
8054 if err := a.rebuild(); err != nil {
8055 // The skill cache is already invalidated; refresh the runtime once the
8056 // other window releases the session lease.
8057 if _, ok := a.deferredRebuildWarning("skills", err); ok {
8058 return nil
8059 }
8060 return err
8061 }
8062 return nil
8063 }
8064
8065 // ReloadCommands rescans command directories and hot-swaps without restarting
8066 // the controller — no MCP disconnect, no hook rerun.
8067 func (a *App) ReloadCommands() error {
8068 if a.ctx == nil {
8069 return nil
8070 }
8071 _, ctrl := a.activeTabAndCtrl()
8072 if ctrl == nil {
8073 return fmt.Errorf("no active session")
8074 }
8075 if ctrl.Running() {
8076 return fmt.Errorf("wait for the current turn to finish, then retry")
8077 }
8078 return ctrl.ReloadCommands(a.ctx)
8079 }
8080
8081 // SetSkillEnabled persists a skill toggle and rebuilds the controller so the
8082 // prompt index, slash menu, and skill tools reflect it immediately.
8083 func (a *App) SetSkillEnabled(name string, enabled bool) error {
8084 err := a.applySkillConfigChange("disabled_skills", "skill", func(c *config.Config) error {
8085 return c.SetSkillEnabled(name, enabled)
8086 })
8087 if err == nil {
8088 a.invalidateSkillRootsCache()
8089 }
8090 return err
8091 }
8092
8093 func normalizeSkillPath(path string) string {
8094 path = strings.TrimSpace(path)
8095 if path == "" {
8096 return ""
8097 }
8098 if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) {
8099 if home, err := os.UserHomeDir(); err == nil {
8100 if path == "~" {
8101 path = home
8102 } else {
8103 path = filepath.Join(home, path[2:])
8104 }
8105 }
8106 }
8107 if abs, err := filepath.Abs(path); err == nil {
8108 path = abs
8109 }
8110 info, err := os.Stat(path)
8111 if err != nil {
8112 return filepath.Clean(path)
8113 }
8114 if info.Mode().IsRegular() {
8115 if filepath.Base(path) == skill.SkillFile {
8116 return filepath.Clean(filepath.Dir(filepath.Dir(path)))
8117 }
8118 return filepath.Clean(filepath.Dir(path))
8119 }
8120 if info.IsDir() {
8121 if _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil {
8122 return filepath.Clean(filepath.Dir(path))
8123 }
8124 }
8125 return filepath.Clean(path)
8126 }
8127
8128 func isConventionSkillRoot(path, workspaceRoot string) bool {
8129 want := canonicalSkillPathForRoot(path, workspaceRoot)
8130 if want == "" {
8131 return false
8132 }
8133 bases := []string{normalizeWorkspaceRoot(workspaceRoot)}
8134 if home, err := os.UserHomeDir(); err == nil {
8135 bases = append(bases, home)
8136 }
8137 for _, base := range bases {
8138 base = strings.TrimSpace(base)
8139 if base == "" {
8140 continue
8141 }
8142 for _, dir := range config.ConventionDirs {
8143 if want == canonicalSkillPathForRoot(filepath.Join(base, dir, skill.SkillsDirname), workspaceRoot) {
8144 return true
8145 }
8146 }
8147 }
8148 return false
8149 }
8150
8151 func skillRootScopeForPath(path, workspaceRoot string) skill.Scope {
8152 want := canonicalSkillPathForRoot(path, workspaceRoot)
8153 if home, err := os.UserHomeDir(); err == nil {
8154 for _, dir := range config.ConventionDirs {
8155 if want == canonicalSkillPathForRoot(filepath.Join(home, dir, skill.SkillsDirname), workspaceRoot) {
8156 return skill.ScopeGlobal
8157 }
8158 }
8159 }
8160 if isConventionSkillRoot(path, workspaceRoot) {
8161 return skill.ScopeProject
8162 }
8163 return skill.ScopeCustom
8164 }
8165
8166 func skillRootPath(path string) string {
8167 if filepath.Base(path) == skill.SkillFile {
8168 return filepath.Dir(path)
8169 }
8170 return path
8171 }
8172
8173 func skillDisplayRoot(sk skill.Skill, roots []skill.Root) string {
8174 cleanPath := filepath.Clean(sk.Path)
8175 for _, r := range roots {
8176 if r.Scope != sk.Scope {
8177 continue
8178 }
8179 cleanRoot := filepath.Clean(r.Dir)
8180 prefix := cleanRoot + string(filepath.Separator)
8181 if cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) {
8182 return config.CanonicalSkillPath(r.Dir)
8183 }
8184 }
8185 return config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path)))
8186 }
8187
8188 func skillSourceDir(sk skill.Skill, roots []SkillRootView) string {
8189 path := strings.TrimSpace(sk.Path)
8190 if path == "" || strings.HasPrefix(path, "(builtin") {
8191 return ""
8192 }
8193 cleanPath := config.CanonicalSkillPath(path)
8194 bestDir := ""
8195 bestLen := -1
8196 for _, root := range roots {
8197 if root.Scope != "" && root.Scope != string(sk.Scope) {
8198 continue
8199 }
8200 cleanRoot := config.CanonicalSkillPath(root.Dir)
8201 if cleanRoot == "" {
8202 continue
8203 }
8204 prefix := cleanRoot + string(filepath.Separator)
8205 if cleanPath != cleanRoot && !strings.HasPrefix(cleanPath, prefix) {
8206 continue
8207 }
8208 if len(cleanRoot) > bestLen {
8209 bestDir = root.Dir
8210 bestLen = len(cleanRoot)
8211 }
8212 }
8213 if bestDir != "" {
8214 return bestDir
8215 }
8216 return config.CanonicalSkillPath(filepath.Dir(skillRootPath(path)))
8217 }
8218
8219 // MCPServerInput is the drawer's "add server" form. Transport is "stdio" (Command
8220 // + Args + Env) or "http"/"sse" (URL). Mirrors config.PluginEntry's writable shape.
8221 type MCPServerInput struct {
8222 Name string `json:"name"`
8223 Transport string `json:"transport"`
8224 Command string `json:"command"`
8225 Args []string `json:"args"`
8226 URL string `json:"url"`
8227 Env map[string]string `json:"env"`
8228 Headers map[string]string `json:"headers"`
8229 AutoStart *bool `json:"autoStart"`
8230 CallTimeoutSeconds *int `json:"callTimeoutSeconds"`
8231 ToolTimeoutSeconds map[string]int `json:"toolTimeoutSeconds"`
8232 }
8233
8234 func mcpServerInputEntry(in MCPServerInput) config.PluginEntry {
8235 entry := config.PluginEntry{
8236 Name: strings.TrimSpace(in.Name),
8237 Type: normalizeMCPTransport(in.Transport),
8238 Command: strings.TrimSpace(in.Command),
8239 Args: append([]string(nil), in.Args...),
8240 URL: strings.TrimSpace(in.URL),
8241 Env: in.Env,
8242 Headers: in.Headers,
8243 AutoStart: in.AutoStart,
8244 CallTimeoutSeconds: mcpIntValue(in.CallTimeoutSeconds),
8245 ToolTimeoutSeconds: cloneStringIntMap(in.ToolTimeoutSeconds),
8246 Source: config.MCPSourceUserConfig,
8247 }
8248 entry, _ = config.NormalizePluginCommandLine(entry)
8249 return entry
8250 }
8251
8252 // InstallMCPServer is the desktop's high-level install transaction. A normal
8253 // handshake failure leaves no config behind; authentication-required servers
8254 // are retained so the user can complete OAuth and retry. Only a ready result is
8255 // published to every controller sharing the Host.
8256 func (a *App) InstallMCPServer(in MCPServerInput) (plugin.MCPInstallResult, error) {
8257 defer a.lockMCPMutation("add")()
8258
8259 _, ctrl, root := a.activeMCPRuntime()
8260 if ctrl == nil {
8261 return plugin.MCPInstallResult{}, fmt.Errorf("no active session")
8262 }
8263 host, releaseGates, err := a.lockMCPHostTurnGates("MCP server", ctrl)
8264 if err != nil {
8265 return plugin.MCPInstallResult{}, err
8266 }
8267 defer releaseGates()
8268
8269 entry := mcpServerInputEntry(in)
8270 if entry.Name == "" {
8271 return plugin.InstallResultForError(entry.Name, fmt.Errorf("MCP server name is required")), nil
8272 }
8273 if _, found, lookupErr := desktopEffectiveMCPServer(root, entry.Name); lookupErr != nil {
8274 return plugin.MCPInstallResult{}, lookupErr
8275 } else if found {
8276 return plugin.InstallResultForError(entry.Name, fmt.Errorf("MCP server %q is already installed", entry.Name)), nil
8277 }
8278
8279 controllers := a.mcpControllersSharingHost(host, entry.Name, ctrl)
8280 toolCount, connectErr := ctrl.ConnectMCPServer(entry)
8281 if connectErr != nil {
8282 result := plugin.InstallResultForError(entry.Name, connectErr)
8283 if result.State != "action_required" {
8284 if host != nil {
8285 host.ClearFailure(entry.Name)
8286 }
8287 return result, nil
8288 }
8289 if err := a.saveDesktopMCPServer(root, entry); err != nil {
8290 return plugin.MCPInstallResult{}, err
8291 }
8292 if err := persistMCPInstallActivation(entry, root); err != nil {
8293 _, rollbackErr := a.removeDesktopMCPServer(root, entry.Name)
8294 if host != nil {
8295 host.ClearFailure(entry.Name)
8296 }
8297 return plugin.MCPInstallResult{}, errors.Join(err, rollbackErr)
8298 }
8299 a.bumpExtensionGeneration()
8300 recordMCPFailure(ctrl, entry, connectErr)
8301 return result, nil
8302 }
8303 var publishErrs []error
8304 for _, target := range controllers {
8305 if target.ctrl == ctrl || !target.enabled {
8306 continue
8307 }
8308 if _, err := target.ctrl.ConnectMCPServer(entry); err != nil {
8309 publishErrs = append(publishErrs, err)
8310 }
8311 }
8312 if err := errors.Join(publishErrs...); err != nil {
8313 disconnectMCPServerControllers(entry.Name, ctrl, controllers)
8314 return plugin.MCPInstallResult{}, fmt.Errorf("publish MCP tools: %w", err)
8315 }
8316 if err := a.saveDesktopMCPServer(root, entry); err != nil {
8317 disconnectMCPServerControllers(entry.Name, ctrl, controllers)
8318 return plugin.MCPInstallResult{}, err
8319 }
8320 if err := persistMCPInstallActivation(entry, root); err != nil {
8321 disconnectMCPServerControllers(entry.Name, ctrl, controllers)
8322 _, rollbackErr := a.removeDesktopMCPServer(root, entry.Name)
8323 // The first disconnect happened while the just-saved config still
8324 // existed, so controller runtimes retained it as disabled. Reconcile once
8325 // more after rollback removes the config to prevent a phantom proxy entry.
8326 disconnectMCPServerControllers(entry.Name, ctrl, controllers)
8327 return plugin.MCPInstallResult{}, errors.Join(err, rollbackErr)
8328 }
8329 a.bumpExtensionGeneration()
8330 return plugin.ReadyInstallResult(entry.Name, toolCount), nil
8331 }
8332 func persistMCPInstallActivation(entry config.PluginEntry, root string) error {
8333 store := config.DefaultMCPActivationStore()
8334 if !entry.ShouldAutoStart() {
8335 return store.ClearServer(entry, root)
8336 }
8337 return store.SetServerEnabled(entry, root, true)
8338 }
8339
8340 // AddMCPServer is retained for old generated Wails clients. New clients use
8341 // InstallMCPServer so authentication and retry states remain structured.
8342 func (a *App) AddMCPServer(in MCPServerInput) (int, error) {
8343 result, err := a.InstallMCPServer(in)
8344 if err != nil {
8345 return 0, err
8346 }
8347 if result.State != "ready" {
8348 return 0, fmt.Errorf("%s", result.Message)
8349 }
8350 return result.ToolCount, nil
8351 }
8352
8353 // UpdateMCPServer edits a persisted external MCP server. The name is the stable
8354 // identity; callers must remove + add if they want to rename a server.
8355 func (a *App) UpdateMCPServer(name string, in MCPServerInput) error {
8356 defer a.lockMCPMutation("update")()
8357
8358 tab, ctrl, root := a.activeMCPRuntime()
8359 if tab == nil || ctrl == nil {
8360 return fmt.Errorf("no active session")
8361 }
8362 host, releaseGates, err := a.lockMCPHostTurnGates("MCP server", ctrl)
8363 if err != nil {
8364 return err
8365 }
8366 defer releaseGates()
8367 controllers := a.mcpControllersSharingHost(host, name, ctrl)
8368 if strings.TrimSpace(in.Name) != "" && strings.TrimSpace(in.Name) != name {
8369 return fmt.Errorf("renaming MCP servers is not supported; remove and add a new server")
8370 }
8371 updated, found, err := a.desktopMCPServerForEdit(root, name)
8372 if err != nil {
8373 return err
8374 }
8375 if !found {
8376 return fmt.Errorf("no configured MCP server named %q", name)
8377 }
8378 original := updated
8379 updated.Type = normalizeMCPTransport(in.Transport)
8380 updated.Command = strings.TrimSpace(in.Command)
8381 updated.Args = append([]string(nil), in.Args...)
8382 updated.URL = strings.TrimSpace(in.URL)
8383 updated.Tier = ""
8384 if in.Env != nil {
8385 updated.Env = in.Env
8386 }
8387 if in.Headers != nil {
8388 updated.Headers = in.Headers
8389 }
8390 if in.AutoStart != nil {
8391 value := *in.AutoStart
8392 updated.AutoStart = &value
8393 }
8394 if in.CallTimeoutSeconds != nil {
8395 updated.CallTimeoutSeconds = *in.CallTimeoutSeconds
8396 }
8397 if in.ToolTimeoutSeconds != nil {
8398 updated.ToolTimeoutSeconds = cloneStringIntMap(in.ToolTimeoutSeconds)
8399 }
8400 updated, _ = config.NormalizePluginCommandLine(updated)
8401 if updated.Type == "stdio" {
8402 updated.URL = ""
8403 } else {
8404 updated.Command = ""
8405 updated.Args = nil
8406 }
8407 enabled := false
8408 for _, target := range controllers {
8409 enabled = enabled || target.enabled
8410 }
8411 if !enabled {
8412 return a.saveDesktopMCPServerAndBump(root, updated)
8413 }
8414 spec, specErr := a.mcpLaunchSpecForEntry(root, updated)
8415 if specErr != nil {
8416 return specErr
8417 }
8418 if spec.RequireLaunchApproval {
8419 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
8420 defer cancel()
8421 if err := plugin.AuthorizeProjectSpecLaunch(ctx, spec); err != nil {
8422 return err
8423 }
8424 }
8425 disconnectMCPServerControllers(name, ctrl, controllers)
8426 if err := reconnectMCPServerControllers(updated, controllers); err != nil {
8427 rollbackErr := reconnectMCPServerControllers(original, controllers)
8428 recordMCPFailure(ctrl, updated, err)
8429 return errors.Join(err, rollbackErr)
8430 }
8431 if err := a.saveDesktopMCPServer(root, updated); err != nil {
8432 disconnectMCPServerControllers(name, ctrl, controllers)
8433 rollbackErr := reconnectMCPServerControllers(original, controllers)
8434 return errors.Join(err, rollbackErr)
8435 }
8436 a.bumpExtensionGeneration()
8437 return nil
8438 }
8439
8440 // RemoveMCPServer disconnects a live server and drops it from config (the row's ✕).
8441 // Uninstall also clears durable activation overrides for that server.
8442 func (a *App) RemoveMCPServer(name string) error {
8443 defer a.lockMCPMutation("remove")()
8444
8445 tab, ctrl, root := a.activeMCPRuntime()
8446 if tab == nil || ctrl == nil {
8447 return fmt.Errorf("no active session")
8448 }
8449 host, releaseGates, err := a.lockMCPHostTurnGates("MCP server", ctrl)
8450 if err != nil {
8451 return err
8452 }
8453 defer releaseGates()
8454 controllers := a.mcpControllersSharingHost(host, name, ctrl)
8455 if err := ensureMCPServerDirectlyWritable(root, name); err != nil {
8456 return err
8457 }
8458 entry, hasEntry, _ := desktopEffectiveMCPServer(root, name)
8459 removed, err := a.removeDesktopMCPServer(root, name)
8460 if err != nil {
8461 return err
8462 }
8463 if !removed {
8464 return fmt.Errorf("no removable MCP server named %q", name)
8465 }
8466 if hasEntry {
8467 _ = config.DefaultMCPActivationStore().ClearServer(entry, root)
8468 }
8469 authCleanupErr := reconcileRemovedMCPAuthentication(name, a.mcpWorkspaceRoots(root))
8470 disconnectMCPServerControllers(name, ctrl, controllers)
8471 if host != nil {
8472 host.ClearFailure(name)
8473 }
8474 restoreMCPServerFallbacks(name, controllers)
8475 a.clearMCPServerTabState(name, controllers)
8476 a.bumpExtensionGeneration()
8477 return authCleanupErr
8478 }
8479
8480 // restoreMCPServerFallbacks makes a lower-priority declaration immediately
8481 // available after its project override is removed. Registration is cache-first:
8482 // it restores cached tools or a connect placeholder without starting a process.
8483 func restoreMCPServerFallbacks(name string, controllers []mcpControllerTarget) {
8484 for _, target := range controllers {
8485 root := target.ctrl.WorkspaceRoot()
8486 cfg, err := config.LoadForRoot(root)
8487 if err != nil {
8488 slog.Warn("desktop: reload MCP fallback after remove", "name", name, "workspace", root, "err", err)
8489 continue
8490 }
8491 entry, found := findPluginEntry(cfg.Plugins, name)
8492 if !found || !mcpEntryEnabled(entry, root) {
8493 continue
8494 }
8495 if _, err := target.ctrl.RegisterMCPServerOnDemand(entry); err != nil {
8496 slog.Warn("desktop: restore MCP fallback after remove", "name", name, "workspace", root, "err", err)
8497 }
8498 }
8499 }
8500
8501 // ReconnectMCPServer disconnects the server if it is already connected (to force
8502 // a fresh handshake and tool re-registration), then reconnects. Failures are
8503 // recorded on the Host so the UI can render them.
8504 func (a *App) ReconnectMCPServer(name string) error {
8505 defer a.lockMCPMutation("reconnect")()
8506
8507 tab, ctrl, root := a.activeMCPRuntime()
8508 if tab == nil || ctrl == nil {
8509 return fmt.Errorf("no active session")
8510 }
8511 host, releaseGates, err := a.lockMCPHostTurnGates("MCP server", ctrl)
8512 if err != nil {
8513 return err
8514 }
8515 defer releaseGates()
8516 entry, found, err := desktopEffectiveMCPServer(root, name)
8517 if err != nil {
8518 return err
8519 }
8520 if !found {
8521 return fmt.Errorf("no configured MCP server named %q", name)
8522 }
8523 if err := config.RecordExplicitStart(entry, root); err != nil {
8524 return err
8525 }
8526 controllers := a.mcpControllersSharingHost(host, name, ctrl)
8527 for i := range controllers {
8528 if controllers[i].ctrl == ctrl {
8529 controllers[i].enabled = true
8530 }
8531 }
8532 disconnectMCPServerControllers(name, ctrl, controllers)
8533 if host != nil {
8534 host.ClearFailure(name)
8535 }
8536 if err := reconnectMCPServerControllers(entry, controllers); err != nil {
8537 recordMCPFailure(ctrl, entry, err)
8538 return err
8539 }
8540 a.mu.Lock()
8541 delete(tab.disabledMCP, name)
8542 a.mu.Unlock()
8543 a.bumpExtensionGeneration()
8544 return nil
8545 }
8546
8547 // SetMCPServerEnabled is the durable enable/disable switch for an installed MCP
8548 // server. It writes $REASONIX_HOME/mcp-activation.json and updates the live
8549 // registry: disable removes tools and may stop the process; enable restores
8550 // cached tools and starts the process only on the next real tool call.
8551 func (a *App) SetMCPServerEnabled(name string, enabled bool) error {
8552 defer a.lockMCPMutation("set-enabled")()
8553
8554 tab, ctrl, root := a.activeMCPRuntime()
8555 if tab == nil || ctrl == nil {
8556 return fmt.Errorf("no active session")
8557 }
8558 a.mu.RLock()
8559 hostKey := tab.SharedHostKey
8560 a.mu.RUnlock()
8561 if err := rebuildControllerActiveWorkErrorFor(ctrl, "MCP server"); err != nil {
8562 return err
8563 }
8564 configuredEntry, hasConfiguredEntry, err := desktopEffectiveMCPServer(root, name)
8565 if err != nil {
8566 return err
8567 }
8568 if !hasConfiguredEntry {
8569 return fmt.Errorf("no configured MCP server named %q", name)
8570 }
8571 activationStore := config.DefaultMCPActivationStore()
8572 scope, workspaceFP, source, owner := config.ActivationIdentity(configuredEntry, root)
8573 previousEnabled, previousFound, err := activationStore.Lookup(scope, workspaceFP, source, owner, configuredEntry.Name)
8574 if err != nil {
8575 return err
8576 }
8577 if err := activationStore.SetServerEnabled(configuredEntry, root, enabled); err != nil {
8578 return err
8579 }
8580 a.bumpExtensionGeneration()
8581 if enabled {
8582 // Restore cached tools (or a cache-miss connect stub) without forcing a
8583 // process start. Explicit install/retry remains the readiness-probed path.
8584 _, err := a.registerConfiguredMCPServerForTab(tab, name)
8585 if err == nil {
8586 a.mu.Lock()
8587 delete(tab.disabledMCP, name)
8588 a.mu.Unlock()
8589 return nil
8590 }
8591 var rollbackErr error
8592 if previousFound {
8593 rollbackErr = activationStore.SetServerEnabled(configuredEntry, root, previousEnabled)
8594 } else {
8595 rollbackErr = activationStore.ClearServer(configuredEntry, root)
8596 }
8597 return errors.Join(err, rollbackErr)
8598 }
8599 if s, ok := findMCPServerView(ctrl, name); ok {
8600 s.Status = "disabled"
8601 s.Enabled = false
8602 s.Error = ""
8603 s = finalizeServerView(s)
8604 a.mu.Lock()
8605 if tab.disabledMCP == nil {
8606 tab.disabledMCP = map[string]ServerView{}
8607 }
8608 tab.disabledMCP[name] = s
8609 tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})
8610 a.mu.Unlock()
8611 } else {
8612 s := finalizeServerView(withPluginConfig(ServerView{Name: name, Status: "disabled", Enabled: false}, configuredEntry))
8613 a.mu.Lock()
8614 if tab.disabledMCP == nil {
8615 tab.disabledMCP = map[string]ServerView{}
8616 }
8617 tab.disabledMCP[name] = s
8618 tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s})
8619 a.mu.Unlock()
8620 }
8621 if hostKey != "" {
8622 ctrl.UnregisterMCPServerTools(name)
8623 } else {
8624 ctrl.DisconnectMCPServer(name)
8625 }
8626 return nil
8627 }
8628
8629 func (a *App) registerConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) {
8630 a.mu.RLock()
8631 var ctrl control.SessionAPI
8632 root := ""
8633 if tab != nil {
8634 ctrl = tab.Ctrl
8635 root = tab.WorkspaceRoot
8636 }
8637 a.mu.RUnlock()
8638 if ctrl == nil {
8639 return 0, fmt.Errorf("no active session")
8640 }
8641 cfg, err := config.LoadForRoot(root)
8642 if err != nil {
8643 return 0, err
8644 }
8645 for _, p := range cfg.Plugins {
8646 if p.Name == name {
8647 return ctrl.RegisterMCPServerOnDemand(p)
8648 }
8649 }
8650 return 0, fmt.Errorf("no configured MCP server named %q", name)
8651 }
8652
8653 // SetMCPServerTier is kept for old desktop bindings. New config writes drop the
8654 // retired tier field.
8655 func (a *App) SetMCPServerTier(name, tier string) error {
8656 defer a.lockMCPMutation("set-tier")()
8657
8658 tier = normalizeMCPTier(tier)
8659 tab, ctrl, root := a.activeMCPRuntime()
8660 if tab != nil {
8661 if err := rebuildControllerActiveWorkErrorFor(ctrl, "MCP server"); err != nil {
8662 return err
8663 }
8664 }
8665 updated, found, err := a.desktopMCPServerForEdit(root, name)
8666 if err != nil {
8667 return err
8668 }
8669 if !found {
8670 return fmt.Errorf("no configured MCP server named %q", name)
8671 }
8672 updated.Tier = tier
8673 if !updated.ShouldAutoStart() {
8674 on := true
8675 updated.AutoStart = &on
8676 }
8677 if err := a.saveDesktopMCPServer(root, updated); err != nil {
8678 return err
8679 }
8680 a.bumpExtensionGeneration()
8681 if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) {
8682 if err := config.RecordExplicitStart(updated, root); err != nil {
8683 return err
8684 }
8685 if _, err := ctrl.ConnectMCPServer(updated); err != nil {
8686 recordMCPFailure(ctrl, updated, err)
8687 return nil
8688 }
8689 a.mu.Lock()
8690 delete(tab.disabledMCP, name)
8691 a.mu.Unlock()
8692 }
8693 return nil
8694 }
8695
8696 func (a *App) desktopMCPServerForEdit(root, name string) (config.PluginEntry, bool, error) {
8697 // Edit the same effective declaration the runtime selected. The entry's
8698 // provenance is retained so saveDesktopMCPServer writes it back to the
8699 // owning project/global file instead of promoting it across scopes.
8700 return desktopEffectiveMCPServer(root, name)
8701 }
8702
8703 // desktopEffectiveMCPServer returns the same merged entry the runtime starts.
8704 // Its provenance identifies the exact project or global declaration that edit
8705 // and remove operations must mutate.
8706 func desktopEffectiveMCPServer(root, name string) (config.PluginEntry, bool, error) {
8707 cfg, err := config.LoadForRoot(root)
8708 if err != nil {
8709 return config.PluginEntry{}, false, err
8710 }
8711 p, ok := findPluginEntry(cfg.Plugins, name)
8712 return p, ok, nil
8713 }
8714
8715 func (a *App) saveDesktopMCPServer(root string, entry config.PluginEntry) error {
8716 if err := ensureMCPServerDirectlyWritable(root, entry.Name); err != nil {
8717 return err
8718 }
8719 _, err := config.UpsertPluginKeepingDecision(root, entry)
8720 return err
8721 }
8722
8723 func ensureMCPServerDirectlyWritable(root, name string) error {
8724 cfg, err := config.LoadForRoot(root)
8725 if err != nil {
8726 return err
8727 }
8728 if owner, ok := cfg.PluginPackageOwner(name); ok {
8729 return fmt.Errorf("MCP server %q is managed by plugin %q; disable or remove the plugin instead", name, owner)
8730 }
8731 return nil
8732 }
8733
8734 func (a *App) removeDesktopMCPServer(root, name string) (bool, error) {
8735 _, removed, _, err := config.RemovePluginFromEffectiveSourceForRoot(root, name)
8736 return removed, err
8737 }
8738
8739 func findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {
8740 for _, p := range entries {
8741 if p.Name == name {
8742 return p, true
8743 }
8744 }
8745 return config.PluginEntry{}, false
8746 }
8747
8748 func normalizeMCPTier(tier string) string {
8749 switch strings.ToLower(strings.TrimSpace(tier)) {
8750 case "eager":
8751 return "eager"
8752 case "background", "lazy":
8753 return "background"
8754 case "":
8755 return "background"
8756 default:
8757 return "background"
8758 }
8759 }
8760
8761 func normalizeMCPTransport(transport string) string {
8762 switch strings.ToLower(strings.TrimSpace(transport)) {
8763 case "http", "streamable-http":
8764 return "http"
8765 case "sse":
8766 return "sse"
8767 case "", "stdio":
8768 return "stdio"
8769 default:
8770 return strings.ToLower(strings.TrimSpace(transport))
8771 }
8772 }
8773
8774 func mcpIntValue(value *int) int {
8775 if value == nil {
8776 return 0
8777 }
8778 return *value
8779 }
8780
8781 func cloneStringIntMap(values map[string]int) map[string]int {
8782 if values == nil {
8783 return nil
8784 }
8785 out := make(map[string]int, len(values))
8786 maps.Copy(out, values)
8787 return out
8788 }
8789
8790 func mcpConnected(ctrl control.SessionAPI, name string) bool {
8791 if ctrl == nil || ctrl.Host() == nil {
8792 return false
8793 }
8794 for _, s := range ctrl.Host().Servers() {
8795 if s.Name == name {
8796 return true
8797 }
8798 }
8799 return false
8800 }
8801
8802 func recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) {
8803 if ctrl == nil || ctrl.Host() == nil || err == nil {
8804 return
8805 }
8806 exp := e.ExpandedPlugin()
8807 ctrl.Host().RecordFailure(plugin.Spec{
8808 Name: exp.Name,
8809 Type: exp.Type,
8810 Command: exp.Command,
8811 Args: exp.Args,
8812 Env: exp.Env,
8813 URL: exp.URL,
8814 Headers: exp.Headers,
8815 }, err)
8816 }
8817
8818 func findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) {
8819 if ctrl == nil || ctrl.Host() == nil {
8820 return ServerView{}, false
8821 }
8822 for _, s := range ctrl.Host().Servers() {
8823 if s.Name == name {
8824 return pluginServerToView(s), true
8825 }
8826 }
8827 for _, f := range ctrl.Host().Failures() {
8828 if f.Name == name {
8829 return ServerView{
8830 Name: f.Name, Transport: f.Transport, Status: "failed", Error: f.Error,
8831 RequiresLaunchApproval: f.RequiresLaunchApproval,
8832 }, true
8833 }
8834 }
8835 return ServerView{}, false
8836 }
8837
8838 func pluginToolsToView(tools []plugin.ToolInfo) []ToolView {
8839 if len(tools) == 0 {
8840 return []ToolView{}
8841 }
8842 out := make([]ToolView, 0, len(tools))
8843 for _, t := range tools {
8844 out = append(out, ToolView{
8845 Name: t.Name, Description: t.Description, ReadOnlyHint: t.ReadOnlyHint, DestructiveHint: t.DestructiveHint, SchemaError: t.SchemaError,
8846 })
8847 }
8848 return out
8849 }
8850
8851 func sameStringList(a, b []string) bool {
8852 if len(a) != len(b) {
8853 return false
8854 }
8855 for i := range a {
8856 if a[i] != b[i] {
8857 return false
8858 }
8859 }
8860 return true
8861 }
8862
8863 func orderServerViews(servers []ServerView, order []string) []ServerView {
8864 pos := make(map[string]int, len(order))
8865 for i, name := range order {
8866 pos[name] = i
8867 }
8868 sort.SliceStable(servers, func(i, j int) bool {
8869 pi, iok := pos[servers[i].Name]
8870 pj, jok := pos[servers[j].Name]
8871 switch {
8872 case iok && jok:
8873 return pi < pj
8874 case iok:
8875 return true
8876 case jok:
8877 return false
8878 default:
8879 return false
8880 }
8881 })
8882 return servers
8883 }
8884
8885 func mergeServerOrder(order []string, servers []ServerView) []string {
8886 seen := make(map[string]bool, len(order)+len(servers))
8887 next := make([]string, 0, len(order)+len(servers))
8888 for _, name := range order {
8889 if name == "" || seen[name] {
8890 continue
8891 }
8892 seen[name] = true
8893 next = append(next, name)
8894 }
8895 for _, s := range servers {
8896 if s.Name == "" || seen[s.Name] {
8897 continue
8898 }
8899 seen[s.Name] = true
8900 next = append(next, s.Name)
8901 }
8902 return next
8903 }
8904
8905 func removeServerOrder(order []string, name string) []string {
8906 if name == "" || len(order) == 0 {
8907 return order
8908 }
8909 next := order[:0]
8910 for _, n := range order {
8911 if n != name {
8912 next = append(next, n)
8913 }
8914 }
8915 return next
8916 }
8917
8918 // ModelInfo is one (provider, model) the bottom switcher can pick. Ref ("provider/
8919 // model") is what SetModel takes; Provider/Model are for display.
8920 type ModelInfo struct {
8921 Ref string `json:"ref"`
8922 Provider string `json:"provider"`
8923 Model string `json:"model"`
8924 Current bool `json:"current"`
8925 ContextWindow int `json:"contextWindow,omitempty"`
8926 Vision bool `json:"vision,omitempty"`
8927 DisplayName string `json:"displayName,omitempty"`
8928 }
8929
8930 type EffortInfo struct {
8931 Options []provider.ReasoningOption `json:"options,omitempty"`
8932 Supported bool `json:"supported"`
8933 Current string `json:"current"`
8934 Default string `json:"default"`
8935 Levels []string `json:"levels"`
8936 }
8937
8938 // Models flattens the configured providers into their (provider, model) pairs —
8939 // the switcher's options — marking the active one. A vendor with a `models` list
8940 // yields one entry per model, all sharing the same endpoint/key. Unconfigured
8941 // providers are skipped. Result is non-nil: the frontend reads .length, so a nil
8942 // slice (JSON null) would crash the switcher on an empty list.
8943 func (a *App) Models() []ModelInfo {
8944 return a.ModelsForTab("")
8945 }
8946
8947 // mergeExtensionModelInfos adds namespaced plugin models from the controller's
8948 // merged provider catalog. Base descriptors are already represented by out;
8949 // plugin refs need no provider-access gate because enabling the package grants
8950 // access. A nil catalog leaves the config-backed list untouched.
8951 func mergeExtensionModelInfos(out []ModelInfo, catalog []provider.Descriptor, curModel string) []ModelInfo {
8952 if len(catalog) == 0 {
8953 return out
8954 }
8955 seen := make(map[string]bool, len(out)+len(catalog))
8956 for _, info := range out {
8957 seen[info.Ref] = true
8958 }
8959 for _, d := range catalog {
8960 ref := strings.TrimSpace(d.Ref)
8961 owner := providerext.PluginRefOwner(ref)
8962 if ref == "" || owner == "" || seen[ref] {
8963 continue
8964 }
8965 seen[ref] = true
8966 providerName := "plugin/" + owner
8967 model := strings.TrimPrefix(ref, providerName+"/")
8968 out = append(out, ModelInfo{Ref: ref, Provider: providerName, Model: model, Current: ref == curModel})
8969 }
8970 return out
8971 }
8972
8973 // extensionModelDescriptor finds a plugin-namespaced ref in a controller's
8974 // merged catalog: an exact match, or the prefix form where ref names the
8975 // provider and the descriptor adds the model segment. Non-plugin refs never
8976 // match — they belong to the config catalog.
8977 func extensionModelDescriptor(catalog []provider.Descriptor, ref string) (provider.Descriptor, bool) {
8978 ref = strings.TrimSpace(ref)
8979 if providerext.PluginRefOwner(ref) == "" {
8980 return provider.Descriptor{}, false
8981 }
8982 for _, d := range catalog {
8983 if d.Ref == ref || strings.HasPrefix(d.Ref, ref+"/") {
8984 return d, true
8985 }
8986 }
8987 return provider.Descriptor{}, false
8988 }
8989
8990 func modelProviderAccessAllowed(access []string, name string) bool {
8991 if access == nil {
8992 return true
8993 }
8994 name = strings.TrimSpace(name)
8995 for _, candidate := range access {
8996 if strings.TrimSpace(candidate) == name {
8997 return true
8998 }
8999 }
9000 return false
9001 }
9002
9003 // providerCatalogForTab returns the tab controller's merged provider catalog
9004 // (extension sidecar providers over the config base), or nil when the tab has
9005 // no live controller or no sidecar declared providers.
9006 func (a *App) providerCatalogForTab(tab *WorkspaceTab) []provider.Descriptor {
9007 if tab == nil {
9008 return nil
9009 }
9010 if ctrl := a.controllerForTab(tab); ctrl != nil {
9011 return ctrl.ProviderCatalog()
9012 }
9013 return nil
9014 }
9015
9016 type activeRuntimeWork struct {
9017 running bool
9018 pendingPrompt bool
9019 backgroundJobs int
9020 }
9021
9022 func controllerActiveRuntimeWork(ctrl control.SessionAPI) activeRuntimeWork {
9023 if ctrl == nil {
9024 return activeRuntimeWork{}
9025 }
9026 status := ctrl.RuntimeStatus()
9027 return activeRuntimeWork{
9028 running: status.Running,
9029 pendingPrompt: status.PendingPrompt,
9030 backgroundJobs: status.BackgroundJobs,
9031 }
9032 }
9033
9034 func (w activeRuntimeWork) active() bool {
9035 return w.running || w.pendingPrompt || w.backgroundJobs > 0
9036 }
9037
9038 func controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool {
9039 return controllerActiveRuntimeWork(ctrl).active()
9040 }
9041
9042 // rebuildBusyError reports a rebuild rejected because the controller still has
9043 // a running turn, pending prompt, or background jobs. Typed so the
9044 // deferred-rebuild retry loop can keep waiting instead of giving up.
9045 type rebuildBusyError struct {
9046 setting string
9047 work activeRuntimeWork
9048 }
9049
9050 func (e *rebuildBusyError) Error() string {
9051 return fmt.Sprintf(
9052 "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",
9053 e.work.running,
9054 e.work.pendingPrompt,
9055 e.work.backgroundJobs,
9056 e.setting,
9057 )
9058 }
9059
9060 func rebuildControllerActiveWorkErrorFor(ctrl control.SessionAPI, setting string) error {
9061 work := controllerActiveRuntimeWork(ctrl)
9062 if setting == "model" || setting == "saved model settings" {
9063 if !control.ModelReplacementBlocked(ctrl) {
9064 return nil
9065 }
9066 if concrete, ok := ctrl.(*control.Controller); ok {
9067 work.backgroundJobs = len(concrete.ModelReplacementJobs())
9068 }
9069 return &rebuildBusyError{setting: setting, work: work}
9070 }
9071 if !work.active() {
9072 return nil
9073 }
9074 return &rebuildBusyError{setting: setting, work: work}
9075 }
9076
9077 type sessionLeaseBusyError struct {
9078 setting string
9079 err error
9080 }
9081
9082 func (e *sessionLeaseBusyError) Error() string {
9083 // The raw SessionLeaseError text carries the session path and the
9084 // holder's host-pid-writer id; every user-facing surface must render
9085 // this wrapper instead. An empty setting means the failure gated opening
9086 // the session itself (startup bind), not changing a setting on it.
9087 setting := strings.TrimSpace(e.setting)
9088 if setting == "" {
9089 return "this session is already open in another Reasonix window or still running in the background; close the other window or open a copy"
9090 }
9091 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)
9092 }
9093
9094 func (e *sessionLeaseBusyError) Unwrap() error {
9095 if e == nil {
9096 return nil
9097 }
9098 return e.err
9099 }
9100
9101 func userFacingSessionLeaseError(setting string, err error) error {
9102 if err == nil {
9103 return nil
9104 }
9105 if errors.Is(err, agent.ErrSessionLeaseHeld) {
9106 return &sessionLeaseBusyError{setting: setting, err: err}
9107 }
9108 return err
9109 }
9110
9111 // sessionPathAfterSnapshot returns where a controller rebuild should keep
9112 // persisting after the old controller was snapshotted. Snapshotting is not
9113 // path-neutral: a snapshot conflict can recover by retargeting the controller
9114 // (and the tab's session lease, via handleTabSessionRecovered) to a recovery
9115 // branch, so a prevPath captured before the snapshot may be stale. Reusing the
9116 // stale path would bind the rebuilt controller — carrying the just-recovered
9117 // transcript — back to the original file, turning every later save into a new
9118 // conflict that derives yet another recovery branch. Falls back to fallback
9119 // when the controller is gone or persistence is disabled (empty SessionPath).
9120 func sessionPathAfterSnapshot(ctrl control.SessionAPI, fallback string) string {
9121 if ctrl == nil {
9122 return fallback
9123 }
9124 if path := strings.TrimSpace(ctrl.SessionPath()); path != "" {
9125 return path
9126 }
9127 return fallback
9128 }
9129
9130 var (
9131 // sessionLeaseContentionRetryInterval and sessionLeaseContentionRetryAttempts
9132 // bound the retry window for lease or removal-guard acquisition that hits a
9133 // transient in-process holder. CleanupStaleRunning and catalog persistence
9134 // can hold the session lease or save lock briefly while a concurrent tab
9135 // bind or archive begins; those callers must not surface a spurious
9136 // "already open in another Reasonix window" error for ownership that is
9137 // genuinely free once the short operation finishes. A lease held by another
9138 // window or process stays held for its whole lifetime, so the bounded retry
9139 // still fails fast there.
9140 sessionLeaseContentionRetryInterval = 50 * time.Millisecond
9141 sessionLeaseContentionRetryAttempts = 2
9142 )
9143
9144 // withSessionLeaseContentionRetry retries acquire while it fails with
9145 // agent.ErrSessionLeaseHeld, absorbing sub-second contention windows created
9146 // by transient in-process lease or save-lock holders. Any other error is
9147 // returned immediately, and a lease that remains held after the bounded
9148 // retries is reported as-is.
9149 func withSessionLeaseContentionRetry[T any](acquire func() (T, error)) (T, error) {
9150 var zero T
9151 for attempt := 0; ; attempt++ {
9152 got, err := acquire()
9153 if err == nil {
9154 return got, nil
9155 }
9156 if !errors.Is(err, agent.ErrSessionLeaseHeld) || attempt >= sessionLeaseContentionRetryAttempts {
9157 return zero, err
9158 }
9159 time.Sleep(sessionLeaseContentionRetryInterval)
9160 }
9161 }
9162
9163 func (a *App) ensureTabSessionLeaseForRebuild(tab *WorkspaceTab, path, setting string) error {
9164 transition, reserveErr := a.reserveSessionRuntimePath(tab, path)
9165 if reserveErr != nil {
9166 return userFacingSessionLeaseError(setting, reserveErr)
9167 }
9168 if _, err := withSessionLeaseContentionRetry(func() (struct{}, error) {
9169 if err := tab.ensureSessionLease(path); err != nil {
9170 if a.canReclaimCurrentProcessSessionLease(tab, path, err) {
9171 if lease, reclaimErr := agent.TryReclaimCurrentProcessSessionLease(path); reclaimErr == nil {
9172 tab.adoptSessionLease(lease)
9173 return struct{}{}, nil
9174 } else {
9175 err = reclaimErr
9176 }
9177 }
9178 return struct{}{}, err
9179 }
9180 return struct{}{}, nil
9181 }); err != nil {
9182 a.rollbackSessionRuntimePath(transition)
9183 return userFacingSessionLeaseError(setting, err)
9184 }
9185 a.commitSessionRuntimePath(transition)
9186 return nil
9187 }
9188
9189 func (a *App) canReclaimCurrentProcessSessionLease(tab *WorkspaceTab, path string, err error) bool {
9190 key := sessionRuntimeKey(path)
9191 if tab == nil || key == "" || !errors.Is(err, agent.ErrSessionLeaseHeld) {
9192 return false
9193 }
9194 var leaseErr *agent.SessionLeaseError
9195 if !errors.As(err, &leaseErr) || leaseErr == nil {
9196 return false
9197 }
9198 // A readable info naming a foreign runtime is respected here; reclaim
9199 // would refuse it anyway. A nil Info (lease.json deleted by the user,
9200 // quarantined by AV, or torn by a crash) must still attempt the reclaim:
9201 // the OS lock is the arbiter there, and refusing on missing metadata
9202 // wedges a session nobody actually holds as permanently busy.
9203 if leaseErr.Info != nil &&
9204 (leaseErr.Info.PID != os.Getpid() || leaseErr.Info.WriterID != agent.SessionWriterID()) {
9205 return false
9206 }
9207 a.mu.RLock()
9208 defer a.mu.RUnlock()
9209 for _, candidate := range a.runtimeTabsLocked() {
9210 if candidate == nil || candidate == tab {
9211 continue
9212 }
9213 if candidate.sessionLeaseRuntimeKey() == key {
9214 return false
9215 }
9216 if candidate.Ctrl != nil && sessionRuntimeKey(candidate.currentSessionPath()) == key {
9217 return false
9218 }
9219 }
9220 // A detached runtime's controller still holds the OS lock; refuse reclaim
9221 // even when PID matches (#6955).
9222 if detached := a.detachedSessions[key]; detached != nil && detached.Ctrl != nil {
9223 return false
9224 }
9225 return true
9226 }
9227
9228 // SetModel switches the active model and carries the current conversation into the
9229 // new model's session, so the chat continues seamlessly and subsequent turns use
9230 // the new model. No-op if name is already active or the controller is down.
9231 func (a *App) SetModel(name string) error {
9232 return a.SetModelForTab("", name)
9233 }
9234
9235 type modelSwitchTiming struct {
9236 Total time.Duration
9237 LockWait time.Duration
9238 Prepare time.Duration
9239 Config time.Duration
9240 Snapshot time.Duration
9241 Build time.Duration
9242 LeaseAndResume time.Duration
9243 SwapAndPersist time.Duration
9244 Outcome string
9245 }
9246
9247 func (a *App) SetModelForTab(tabID, name string) (retErr error) {
9248 if name == "" {
9249 return nil
9250 }
9251 if a.isRemoteTab(tabID) {
9252 return a.SetRemoteTabModel(tabID, name)
9253 }
9254 if a.ctx == nil {
9255 return nil
9256 }
9257 tab := a.tabByID(tabID)
9258 if tab == nil {
9259 return nil
9260 }
9261 pendingSequence := a.deferredRebuildSequence(tab.ID)
9262 a.mu.RLock()
9263 currentModel := tab.model
9264 a.mu.RUnlock()
9265 if name == currentModel {
9266 return nil
9267 }
9268 timing := modelSwitchTiming{}
9269 totalStarted := time.Now()
9270 defer a.recordModelSwitchTiming(tab.ID, &timing, totalStarted, &retErr)
9271 // Same build+swap shape as rebuildSetting; hold the same lock so a settings
9272 // rebuild (manual or from the deferred-rebuild retry loop) and a model
9273 // switch cannot interleave on one tab.
9274 stageStarted := time.Now()
9275 a.runtimeRebuildMu.Lock()
9276 timing.LockWait = time.Since(stageStarted)
9277 defer a.runtimeRebuildMu.Unlock()
9278 stageStarted = time.Now()
9279 tab.turnStartMu.Lock()
9280 defer tab.turnStartMu.Unlock()
9281 prevPath := a.sessionPathForSettingsRebuild(tab)
9282 if a.controllerForTab(tab) == nil && prevPath != "" {
9283 a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
9284 }
9285 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "model"); err != nil {
9286 return err
9287 }
9288 if err := a.ensureTabControllerWorkspace(tab); err != nil {
9289 return err
9290 }
9291 prevPath = a.sessionPathForSettingsRebuild(tab)
9292 if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
9293 prevPath = a.reconciledSessionPathForTab(tab)
9294 if prevPath == "" {
9295 prevPath = a.currentSessionPathFor(tab)
9296 }
9297 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "model"); err != nil {
9298 return err
9299 }
9300 }
9301 timing.Prepare = time.Since(stageStarted)
9302 // Snapshot the tab profile under a.mu: SetModeForTab/SetGoalForTab and the
9303 // event sink write these fields under the lock while this rebuild runs
9304 // off-lock.
9305 stageStarted = time.Now()
9306 snap := a.tabRuntimeSnapshot(tab)
9307 runtime := snap.normalizedRuntime()
9308 cfg, err := config.LoadForRoot(snap.workspaceRoot)
9309 if err != nil {
9310 return err
9311 }
9312 entry, ok := cfg.ResolveModel(name)
9313 pluginRef := false
9314 if !ok {
9315 // Plugin-namespaced refs belong to extension sidecars: validate them
9316 // against the tab controller's merged catalog instead of the config.
9317 if d, found := extensionModelDescriptor(a.providerCatalogForTab(tab), name); found {
9318 pluginRef = true
9319 ok = true
9320 name = d.Ref
9321 }
9322 }
9323 if !ok {
9324 return fmt.Errorf("unknown model %q", name)
9325 }
9326 if !pluginRef {
9327 if !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, entry.Name) {
9328 return fmt.Errorf("model %q is not available because provider %q is not added", name, entry.Name)
9329 }
9330 name = entry.Name + "/" + entry.Model
9331 }
9332 effortOverride := config.RebindSessionEffort(cfg, snap.model, name, snap.effort)
9333 timing.Config = time.Since(stageStarted)
9334
9335 stageStarted = time.Now()
9336 var carried []provider.Message
9337 oldCtrl := a.controllerForTab(tab)
9338 if oldCtrl != nil {
9339 _, _, exclusiveV3 := exclusiveSessionBinding(oldCtrl)
9340 if !exclusiveV3 {
9341 if prevPath == "" {
9342 prevPath = oldCtrl.SessionPath()
9343 }
9344 if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "model"); err != nil {
9345 return err
9346 }
9347 }
9348 if err := a.snapshotTabForAction(tab, "changing model"); err != nil {
9349 return err
9350 }
9351 if !exclusiveV3 {
9352 prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
9353 carried = oldCtrl.History()
9354 }
9355 }
9356 timing.Snapshot = time.Since(stageStarted)
9357
9358 // Preserve the shared plugin host across controller rebuilds — the tab
9359 // stays in the same workspace root, so MCP processes must not be restarted.
9360 sharedHost := a.lookupSharedHost(snap.sharedHostKey)
9361
9362 stageStarted = time.Now()
9363 newCtrl, rebuiltV3, err := buildDesktopControllerReplacement(a.bootContext(), oldCtrl, boot.Options{
9364 Model: name,
9365 RequireKey: false,
9366 StatsSource: "desktop",
9367 TaskStore: a.taskStore(),
9368 OnConfigLoadWarnings: a.configLoadWarningsHandler(),
9369 Sink: snap.sink,
9370 WorkspaceRoot: snap.workspaceRoot,
9371 SessionDir: sessionDirForSnapshot(snap),
9372 SessionService: a.desktopSessionService(sessionDirForSnapshot(snap)),
9373 EffortOverride: cloneStringPtr(effortOverride),
9374 SharedHost: sharedHost, BrowserExecutor: a.browserExecutorForRuntime(tab.ID, snap.sink),
9375 SharedSkillWatchService: a.sharedSkillWatchService(),
9376 MCPHostProfile: plugin.HostProfileDesktopApps,
9377 CleanupPendingReconciler: reconcileDesktopCleanupPending,
9378 SubagentParentLive: a.subagentParentProbeForBuild(tab),
9379 SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
9380 PinnedContextLoader: pinnedContextLoader(snap.workspaceRoot),
9381 OnSessionRecovered: a.handleTabSessionRecovered(tab),
9382 OnSessionTransition: a.handleTabSessionTransition(tab),
9383 BeforeInboxDispatch: a.beforeInboxDispatch,
9384 OnSessionTitleChanged: a.onSessionTitleChanged,
9385 // Keep the private temporary directory across model switches (#7575).
9386 SessionTemp: sessionTempFromController(oldCtrl),
9387 })
9388 if err != nil {
9389 return err
9390 }
9391 timing.Build = time.Since(stageStarted)
9392 a.bindControllerDisplayRecorder(newCtrl)
9393 configureControllerRuntime(newCtrl, oldCtrl, runtime)
9394
9395 stageStarted = time.Now()
9396 path := ""
9397 var restoredRuntime normalizedTabRuntime
9398 if rebuiltV3 {
9399 restoredRuntime, err = normalizeRestoredControllerRuntime(newCtrl, runtime)
9400 } else {
9401 path = agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
9402 if err = a.ensureTabSessionLeaseForRebuild(tab, path, "model"); err == nil {
9403 restoredRuntime, err = resumeControllerRuntimeWithMessages(newCtrl, carried, path, runtime)
9404 }
9405 }
9406 if err != nil {
9407 discardReplacementController(newCtrl, oldCtrl)
9408 return err
9409 }
9410 timing.LeaseAndResume = time.Since(stageStarted)
9411 stageStarted = time.Now()
9412 a.mu.Lock()
9413 if err := a.authorizeTabReplacementLocked(tab, newCtrl, "switching model", "model-switch"); err != nil {
9414 // The tab was closed/replaced while we built the new controller off-lock;
9415 // adopting it now would leak the runtime onto an orphaned tab and pin the
9416 // session lease forever.
9417 a.mu.Unlock()
9418 discardReplacementController(newCtrl, oldCtrl)
9419 tab.releaseSessionLease()
9420 return err
9421 }
9422 if err := activateReplacementController(oldCtrl, newCtrl); err != nil {
9423 a.mu.Unlock()
9424 discardReplacementController(newCtrl, oldCtrl)
9425 return fmt.Errorf("switching model: activate replacement runtime: %w", err)
9426 }
9427 tab.Ctrl = newCtrl
9428 tab.model = name
9429 tab.effort = cloneStringPtr(effortOverride)
9430 tab.Label = newCtrl.Label()
9431 applyNormalizedRuntimeToTabLocked(tab, restoredRuntime)
9432 // Supersede any in-flight startup build: it would otherwise finish later,
9433 // overwrite this controller, and release/steal the tab's session lease.
9434 a.supersedeTabBuildLocked(tab)
9435 a.saveTabsLocked()
9436 a.mu.Unlock()
9437 if oldCtrl != nil {
9438 retireReplacedController(oldCtrl, newCtrl)
9439 }
9440 // A refresh queued during this build still owns its newer sequence.
9441 a.clearDeferredRebuildVersion(tab.ID, pendingSequence)
9442 a.persistTabSessionPath(tab, path)
9443 // Keep the provider identity in the session sidecar inside the same
9444 // runtimeRebuildMu transaction as the controller swap. Empty sessions do
9445 // not autosave a turn, so without this write a later startup can prefer the
9446 // outgoing provider from stale metadata. Serializing it here also preserves
9447 // last-click-wins when a new-session default switch overlaps an explicit
9448 // model selection.
9449 if path != "" {
9450 if err := agent.SetBranchModelPreserveUpdated(path, name); err != nil {
9451 return fmt.Errorf("persist selected model: %w", err)
9452 }
9453 }
9454 // A model switch changes the pricing context; discard the session-local
9455 // automatic wallet hint and let the next balance response rebind it.
9456 tab.clearRuntimeDisplayCurrency()
9457 a.notifyTabRuntimeRebuilt(tab)
9458 timing.SwapAndPersist = time.Since(stageStarted)
9459 return nil
9460 }
9461
9462 func (a *App) Effort() EffortInfo {
9463 return a.EffortForTab("")
9464 }
9465
9466 func (a *App) EffortForTab(tabID string) EffortInfo {
9467 entry, err := a.currentProviderEntryForTab(tabID)
9468 if err != nil {
9469 return EffortInfo{Current: "auto", Levels: []string{}}
9470 }
9471 cap := config.EffortCapabilityForEntry(entry)
9472 if !cap.Supported {
9473 return EffortInfo{Supported: false, Current: "auto", Default: cap.Default, Levels: []string{}}
9474 }
9475 levels := cap.Levels
9476 if levels == nil {
9477 levels = []string{}
9478 }
9479 return EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels, Options: config.ReasoningCapabilityForEntry(entry).Options}
9480 }
9481
9482 func (a *App) SetEffort(level string) error {
9483 return a.SetEffortForTab("", level)
9484 }
9485
9486 func (a *App) SetEffortForTab(tabID, level string) error {
9487 tab := a.tabByID(tabID)
9488 if tab == nil {
9489 if strings.TrimSpace(tabID) == "" {
9490 entry, err := a.currentProviderEntryForTab("")
9491 if err != nil {
9492 return err
9493 }
9494 effort, err := config.NormalizeEffort(entry, level)
9495 if err != nil {
9496 return err
9497 }
9498 return a.applyProviderEffortConfig(entry, effort)
9499 }
9500 return fmt.Errorf("tab %q not found", tabID)
9501 }
9502 // Build+swap path; serialize with the other rebuild paths (see
9503 // runtimeRebuildMu). The tab==nil branch above goes through
9504 // applyProviderEffortConfig → rebuildSetting, which takes the lock itself.
9505 pendingSequence := a.deferredRebuildSequence(tab.ID)
9506 a.runtimeRebuildMu.Lock()
9507 defer a.runtimeRebuildMu.Unlock()
9508 tab.turnStartMu.Lock()
9509 defer tab.turnStartMu.Unlock()
9510 prevPath := a.reconciledSessionPathForTab(tab)
9511 if prevPath == "" {
9512 prevPath = a.currentSessionPathFor(tab)
9513 }
9514 // Recomputing prevPath after this attach would be a dead store: it is
9515 // unconditionally derived again after ensureTabControllerWorkspace below.
9516 if a.controllerForTab(tab) == nil && prevPath != "" {
9517 a.attachExistingSessionRuntime(tab, prevPath, a.ctx)
9518 }
9519 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "effort"); err != nil {
9520 return err
9521 }
9522 if err := a.ensureTabControllerWorkspace(tab); err != nil {
9523 return err
9524 }
9525 prevPath = a.reconciledSessionPathForTab(tab)
9526 if prevPath == "" {
9527 prevPath = a.currentSessionPathFor(tab)
9528 }
9529 if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) {
9530 prevPath = a.reconciledSessionPathForTab(tab)
9531 if prevPath == "" {
9532 prevPath = a.currentSessionPathFor(tab)
9533 }
9534 if err := rebuildControllerActiveWorkErrorFor(a.controllerForTab(tab), "effort"); err != nil {
9535 return err
9536 }
9537 }
9538 snap := a.tabRuntimeSnapshot(tab)
9539 runtime := snap.normalizedRuntime()
9540 entry, err := a.currentProviderEntryForTab(tabID)
9541 if err != nil {
9542 return err
9543 }
9544 modelRef := entry.Name + "/" + entry.Model
9545 effort, err := config.NormalizeEffort(entry, level)
9546 if err != nil {
9547 return err
9548 }
9549 var carried []provider.Message
9550 oldCtrl := a.controllerForTab(tab)
9551 if oldCtrl != nil {
9552 _, _, exclusiveV3 := exclusiveSessionBinding(oldCtrl)
9553 if !exclusiveV3 {
9554 if prevPath == "" {
9555 prevPath = oldCtrl.SessionPath()
9556 }
9557 if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "effort"); err != nil {
9558 return err
9559 }
9560 }
9561 if err := a.snapshotTabForAction(tab, "changing effort"); err != nil {
9562 return err
9563 }
9564 if !exclusiveV3 {
9565 prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath)
9566 carried = oldCtrl.History()
9567 }
9568 }
9569 sharedHost := a.lookupSharedHost(snap.sharedHostKey)
9570 newCtrl, rebuiltV3, err := buildDesktopControllerReplacement(a.bootContext(), oldCtrl, boot.Options{
9571 Model: modelRef,
9572 RequireKey: false,
9573 StatsSource: "desktop",
9574 TaskStore: a.taskStore(),
9575 OnConfigLoadWarnings: a.configLoadWarningsHandler(),
9576 Sink: snap.sink,
9577 WorkspaceRoot: snap.workspaceRoot,
9578 SessionDir: sessionDirForSnapshot(snap),
9579 SessionService: a.desktopSessionService(sessionDirForSnapshot(snap)),
9580 EffortOverride: &effort,
9581 SharedHost: sharedHost, BrowserExecutor: a.browserExecutorForRuntime(tab.ID, snap.sink),
9582 SharedSkillWatchService: a.sharedSkillWatchService(),
9583 MCPHostProfile: plugin.HostProfileDesktopApps,
9584 CleanupPendingReconciler: reconcileDesktopCleanupPending,
9585 SubagentParentLive: a.subagentParentProbeForBuild(tab),
9586 SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab),
9587 PinnedContextLoader: pinnedContextLoader(snap.workspaceRoot),
9588 OnSessionRecovered: a.handleTabSessionRecovered(tab),
9589 OnSessionTransition: a.handleTabSessionTransition(tab),
9590 BeforeInboxDispatch: a.beforeInboxDispatch,
9591 OnSessionTitleChanged: a.onSessionTitleChanged,
9592 // Keep the private temporary directory across effort switches (#7575).
9593 SessionTemp: sessionTempFromController(oldCtrl),
9594 })
9595 if err != nil {
9596 return err
9597 }
9598 a.bindControllerDisplayRecorder(newCtrl)
9599 configureControllerRuntime(newCtrl, oldCtrl, runtime)
9600 path := ""
9601 var restoredRuntime normalizedTabRuntime
9602 if rebuiltV3 {
9603 restoredRuntime, err = normalizeRestoredControllerRuntime(newCtrl, runtime)
9604 } else {
9605 path = agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label())
9606 if err = a.ensureTabSessionLeaseForRebuild(tab, path, "effort"); err == nil {
9607 restoredRuntime, err = resumeControllerRuntimeWithMessages(newCtrl, carried, path, runtime)
9608 }
9609 }
9610 if err != nil {
9611 discardReplacementController(newCtrl, oldCtrl)
9612 return err
9613 }
9614 a.mu.Lock()
9615 if err := a.authorizeTabReplacementLocked(tab, newCtrl, "switching effort", "effort-switch"); err != nil {
9616 a.mu.Unlock()
9617 discardReplacementController(newCtrl, oldCtrl)
9618 tab.releaseSessionLease()
9619 return err
9620 }
9621 if err := activateReplacementController(oldCtrl, newCtrl); err != nil {
9622 a.mu.Unlock()
9623 discardReplacementController(newCtrl, oldCtrl)
9624 return fmt.Errorf("switching effort: activate replacement runtime: %w", err)
9625 }
9626 tab.Ctrl = newCtrl
9627 tab.model = modelRef
9628 tab.effort = &effort
9629 tab.Label = newCtrl.Label()
9630 applyNormalizedRuntimeToTabLocked(tab, restoredRuntime)
9631 clearTabStartupError(tab)
9632 tab.Ready = true
9633 a.supersedeTabBuildLocked(tab)
9634 a.saveTabsLocked()
9635 a.mu.Unlock()
9636 if oldCtrl != nil {
9637 retireReplacedController(oldCtrl, newCtrl)
9638 }
9639 a.clearDeferredRebuildVersion(tab.ID, pendingSequence)
9640 a.persistTabSessionPath(tab, path)
9641 a.notifyTabRuntimeRebuilt(tab)
9642 return nil
9643 }
9644
9645 // SetAgentPresetDeprecatedNotice is returned by the deprecated execution-mode
9646 // Wails methods. Reasonix runs one adaptive standard execution; these methods
9647 // remain bound for one compatibility version as no-op wrappers: they never
9648 // require an idle tab, never save a mode, and never rebuild an agent.
9649 const SetAgentPresetDeprecatedNotice = "Reasonix now uses one adaptive standard execution: planning, verification, and review strength follow task risk automatically. Execution modes are no longer switchable; this call is accepted for compatibility and ignored."
9650
9651 func (a *App) SetTokenMode(mode string) error {
9652 // Deprecated no-op compatibility wrapper.
9653 return a.SetAgentPreset(boot.NormalizeAgentPreset(mode))
9654 }
9655
9656 func (a *App) SetTokenModeForTab(tabID, mode string) error {
9657 // Deprecated no-op compatibility wrapper.
9658 return a.SetAgentPresetForTab(tabID, boot.NormalizeAgentPreset(mode))
9659 }
9660
9661 // SetAgentPreset is a deprecated no-op compatibility wrapper.
9662 func (a *App) SetAgentPreset(preset string) error {
9663 return a.SetAgentPresetForTab("", preset)
9664 }
9665
9666 // SetAgentPresetForTab is a deprecated no-op compatibility wrapper: it accepts
9667 // the legacy argument, does not require an idle tab, saves no mode, rebuilds
9668 // no agent, and always succeeds with the deprecation notice.
9669 func (a *App) SetAgentPresetForTab(tabID, preset string) error {
9670 normalized, err := boot.NormalizeAgentPresetErr(preset)
9671 if err != nil {
9672 return err
9673 }
9674 if tab := a.tabByID(tabID); tab == nil && strings.TrimSpace(tabID) != "" {
9675 return fmt.Errorf("tab %q not found", tabID)
9676 }
9677 return a.SetQualityFloorForTab(tabID, normalized)
9678 }
9679
9680 // persistTabTokenMode persists the deprecated dual-write compatibility values
9681 // (agentPreset=standard, tokenMode=full) so one-version-old clients keep
9682 // parsing tab state and session metas. The values are fixed; nothing reads
9683 // them to alter runtime behavior.
9684 func (a *App) persistTabTokenMode(tab *WorkspaceTab) {
9685 if a == nil || tab == nil {
9686 return
9687 }
9688 a.mu.Lock()
9689 a.saveTabsLocked()
9690 a.mu.Unlock()
9691 _ = a.saveTabSessionMetaForCurrentSession(tab)
9692 }
9693
9694 func (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error {
9695 return a.applyConfigChange(func(cfg *config.Config) error {
9696 if _, ok := cfg.Provider(entry.Name); !ok {
9697 if err := cfg.UpsertProvider(*entry); err != nil {
9698 return err
9699 }
9700 }
9701 if entry.Kind == "anthropic" && effort != "" && entry.Thinking == "" {
9702 if err := cfg.SetProviderThinking(entry.Name, "adaptive"); err != nil {
9703 return err
9704 }
9705 }
9706 for _, name := range providerEffortTargetNames(cfg, entry) {
9707 if err := cfg.SetProviderEffort(name, effort); err != nil {
9708 return err
9709 }
9710 }
9711 return nil
9712 })
9713 }
9714
9715 func providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string {
9716 if cfg == nil || entry == nil {
9717 return nil
9718 }
9719 out := []string{entry.Name}
9720 seen := map[string]bool{entry.Name: true}
9721 kind := officialProviderKindFromEntry(*entry)
9722 if kind == "" {
9723 return out
9724 }
9725 var family []string
9726 switch kind {
9727 case "deepseek":
9728 family = []string{"deepseek", "deepseek-flash", "deepseek-pro"}
9729 }
9730 for _, name := range family {
9731 if seen[name] {
9732 continue
9733 }
9734 p, ok := cfg.Provider(name)
9735 if !ok || officialProviderKindFromEntry(*p) != kind {
9736 continue
9737 }
9738 seen[name] = true
9739 out = append(out, name)
9740 }
9741 return out
9742 }
9743
9744 // DirEntry is one entry in the "@" file-reference menu.
9745 type DirEntry struct {
9746 Name string `json:"name"`
9747 Path string `json:"path,omitempty"`
9748 IsDir bool `json:"isDir"`
9749 DisplayName string `json:"displayName,omitempty"`
9750 DisplayPath string `json:"displayPath,omitempty"`
9751 }
9752
9753 // FilePreview is a bounded, read-only file payload for the workspace side panel.
9754 type FilePreview struct {
9755 Path string `json:"path"`
9756 Body string `json:"body"`
9757 Size int64 `json:"size"`
9758 Truncated bool `json:"truncated"`
9759 Binary bool `json:"binary"`
9760 Version string `json:"version,omitempty"`
9761 NextOffset int64 `json:"nextOffset,omitempty"`
9762 Kind string `json:"kind,omitempty"`
9763 Mime string `json:"mime,omitempty"`
9764 URL string `json:"url,omitempty"`
9765 Err string `json:"err,omitempty"`
9766 }
9767
9768 // PresentedTextPage is one version-fenced UTF-8 continuation for a declared
9769 // text deliverable. Pages append to a single document; callers must discard a
9770 // page when Version differs from the first preview.
9771 type PresentedTextPage struct {
9772 Path string `json:"path"`
9773 Body string `json:"body"`
9774 Offset int64 `json:"offset"`
9775 NextOffset int64 `json:"nextOffset"`
9776 Size int64 `json:"size"`
9777 HasMore bool `json:"hasMore"`
9778 Version string `json:"version"`
9779 }
9780
9781 type WorkspaceChangeView struct {
9782 Path string `json:"path"`
9783 OldPath string `json:"oldPath,omitempty"`
9784 Sources []string `json:"sources"`
9785 GitStatus string `json:"gitStatus,omitempty"`
9786 Turns []int `json:"turns,omitempty"`
9787 LatestPrompt string `json:"latestPrompt,omitempty"`
9788 LatestTime int64 `json:"latestTime,omitempty"`
9789 CanSessionRevert bool `json:"canSessionRevert,omitempty"`
9790 }
9791
9792 type WorkspaceChangesView struct {
9793 Files []WorkspaceChangeView `json:"files"`
9794 GitAvailable bool `json:"gitAvailable"`
9795 GitErr string `json:"gitErr,omitempty"`
9796 GitBranch string `json:"gitBranch,omitempty"`
9797 Added int `json:"added,omitempty"`
9798 Removed int `json:"removed,omitempty"`
9799 Incomplete bool `json:"incomplete,omitempty"`
9800 }
9801
9802 type WorkspaceChangeDetailView struct {
9803 Diff *string `json:"diff,omitempty"`
9804 Source string `json:"source,omitempty"`
9805 Added int `json:"added,omitempty"`
9806 Removed int `json:"removed,omitempty"`
9807 Binary bool `json:"binary,omitempty"`
9808 Truncated bool `json:"truncated,omitempty"`
9809 }
9810
9811 const filePreviewLimit = 2 * 1024 * 1024 // 2 MiB — full file preview for the workspace panel
9812 const presentedTextPageLimit = 512 * 1024
9813 const fileRefSearchLimit = 20
9814
9815 var previewMediaMIMEs = map[string]string{
9816 ".aac": "audio/aac",
9817 ".bmp": "image/bmp",
9818 ".flac": "audio/flac",
9819 ".gif": "image/gif",
9820 ".htm": "text/html; charset=utf-8",
9821 ".html": "text/html; charset=utf-8",
9822 ".jpeg": "image/jpeg",
9823 ".jpg": "image/jpeg",
9824 ".m4a": "audio/mp4",
9825 ".m4v": "video/mp4",
9826 ".mov": "video/quicktime",
9827 ".mp3": "audio/mpeg",
9828 ".mp4": "video/mp4",
9829 ".oga": "audio/ogg",
9830 ".ogg": "audio/ogg",
9831 ".ogv": "video/ogg",
9832 ".pdf": "application/pdf",
9833 ".png": "image/png",
9834 ".svg": "image/svg+xml",
9835 ".wav": "audio/wav",
9836 ".webm": "video/webm",
9837 ".webp": "image/webp",
9838 }
9839
9840 func trimUTF8PartialSuffix(data []byte) []byte {
9841 if utf8.Valid(data) {
9842 return data
9843 }
9844 for i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- {
9845 if !utf8.RuneStart(data[i]) {
9846 continue
9847 }
9848 if !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) {
9849 return data
9850 }
9851 return data[:i]
9852 }
9853 return data
9854 }
9855
9856 func workspaceFileVersion(info os.FileInfo) string {
9857 return fmt.Sprintf("%d:%d", info.Size(), info.ModTime().UnixNano())
9858 }
9859
9860 func previewMediaKind(path string) (kind string, mime string) {
9861 mime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))]
9862 if mime == "" {
9863 return "", ""
9864 }
9865 if strings.HasPrefix(mime, "image/") {
9866 return "image", mime
9867 }
9868 if strings.HasPrefix(mime, "audio/") {
9869 return "audio", mime
9870 }
9871 if strings.HasPrefix(mime, "video/") {
9872 return "video", mime
9873 }
9874 if mime == "application/pdf" {
9875 return "pdf", mime
9876 }
9877 if strings.HasPrefix(mime, "text/html") {
9878 return "html", mime
9879 }
9880 return "", ""
9881 }
9882
9883 func (a *App) workspaceTargetForTab(tabID string) (string, control.SessionAPI, bool) {
9884 tabID = strings.TrimSpace(tabID)
9885 a.mu.RLock()
9886 defer a.mu.RUnlock()
9887 tab := a.tabByIDLocked(tabID)
9888 if tab == nil {
9889 if tabID == "" {
9890 return ".", nil, true
9891 }
9892 return "", nil, false
9893 }
9894 return tab.WorkspaceRoot, tab.Ctrl, true
9895 }
9896
9897 func workspaceBaseFromRoot(root string) (string, error) {
9898 if strings.TrimSpace(root) == "" || root == "." {
9899 return os.Getwd()
9900 }
9901 if abs, err := filepath.Abs(root); err == nil {
9902 root = abs
9903 }
9904 return filepath.Clean(root), nil
9905 }
9906
9907 func workspacePathForBase(base, rel string) (string, bool, error) {
9908 base = filepath.Clean(base)
9909 if rel == "" {
9910 return "", false, os.ErrInvalid
9911 }
9912 path := rel
9913 if !filepath.IsAbs(path) {
9914 path = filepath.Join(base, rel)
9915 }
9916 path = filepath.Clean(path)
9917 r, err := filepath.Rel(base, path)
9918 if err != nil {
9919 return "", false, err
9920 }
9921 if r == ".." || strings.HasPrefix(r, ".."+string(os.PathSeparator)) {
9922 return "", false, os.ErrPermission
9923 }
9924 return path, true, nil
9925 }
9926
9927 // ListDir lists one directory level (directories first, then files, each
9928 // alphabetical) for the "@" file-reference menu. rel resolves against the active
9929 // tab workspace. The menu navigates one level at a time, never recursively —
9930 // bounded for huge trees.
9931 func (a *App) ListDir(rel string) []DirEntry {
9932 return a.ListDirForTab("", rel)
9933 }
9934
9935 // ListDirForTab is the tab-scoped variant used by multi-tab frontend surfaces.
9936 func (a *App) ListDirForTab(tabID, rel string) []DirEntry {
9937 root, ctrl, ok := a.workspaceTargetForTab(tabID)
9938 if !ok {
9939 return []DirEntry{}
9940 }
9941 base, err := workspaceBaseFromRoot(root)
9942 if err != nil {
9943 return []DirEntry{}
9944 }
9945 return listDirForWorkspaceTarget(base, ctrl, rel)
9946 }
9947
9948 func listDirForWorkspaceTarget(base string, ctrl control.SessionAPI, rel string) []DirEntry {
9949 if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
9950 if entries, handled := browser.ListExternalFolderRefDir(rel); handled {
9951 return externalFolderDirEntries(entries)
9952 }
9953 }
9954 dir := base
9955 if rel != "" {
9956 path, ok, err := workspacePathForBase(base, rel)
9957 if err != nil || !ok {
9958 return []DirEntry{}
9959 }
9960 dir = path
9961 }
9962 realDir, realBase, err := canonicalPathWithin(base, dir)
9963 if err != nil {
9964 return []DirEntry{}
9965 }
9966 es, err := os.ReadDir(dir)
9967 if err != nil {
9968 return []DirEntry{}
9969 }
9970 dirs, files := []DirEntry{}, []DirEntry{}
9971 for _, e := range es {
9972 name := e.Name()
9973 info, err := e.Info()
9974 if err == nil && info.Mode()&os.ModeSymlink != 0 {
9975 info, err = linkedEntryInTree(realBase, realDir, filepath.Join(dir, name))
9976 }
9977 if err != nil || fileref.SkipBrowseEntry(name, info.IsDir()) {
9978 continue
9979 }
9980 if info.IsDir() {
9981 dirs = append(dirs, DirEntry{Name: name, IsDir: true})
9982 continue
9983 }
9984 if !info.Mode().IsRegular() {
9985 continue
9986 }
9987 files = append(files, DirEntry{Name: name, IsDir: false})
9988 }
9989 sort.Slice(dirs, func(i, j int) bool { return fileref.NaturalLess(dirs[i].Name, dirs[j].Name) })
9990 sort.Slice(files, func(i, j int) bool { return fileref.NaturalLess(files[i].Name, files[j].Name) })
9991 return append(dirs, files...)
9992 }
9993
9994 // linkedEntryInTree stats a link's target when it resolves inside the
9995 // workspace and not onto the folder being listed or one of its ancestors; a
9996 // folder that contains itself would be expanded without end.
9997 func linkedEntryInTree(realBase, realDir, link string) (os.FileInfo, error) {
9998 target, _, err := canonicalPathWithin(realBase, link)
9999 if err != nil {
10000 return nil, err
10001 }
10002 if up, err := filepath.Rel(target, realDir); err == nil && filepath.IsLocal(up) {
10003 return nil, os.ErrPermission
10004 }
10005 return os.Stat(target)
10006 }
10007
10008 // SearchFileRefs finds workspace files by basename for bare "@token" completion.
10009 func (a *App) SearchFileRefs(query string) []DirEntry {
10010 return a.SearchFileRefsForTab("", query)
10011 }
10012
10013 // SearchFileRefsForTab is the tab-scoped variant used by multi-tab frontend surfaces.
10014 func (a *App) SearchFileRefsForTab(tabID, query string) []DirEntry {
10015 root, ctrl, ok := a.workspaceTargetForTab(tabID)
10016 if !ok {
10017 return []DirEntry{}
10018 }
10019 base, err := workspaceBaseFromRoot(root)
10020 if err != nil {
10021 return []DirEntry{}
10022 }
10023 return searchFileRefsForWorkspaceTarget(base, ctrl, query)
10024 }
10025
10026 func searchFileRefsForWorkspaceTarget(base string, ctrl control.SessionAPI, query string) []DirEntry {
10027 results := fileref.Search(base, query, fileRefSearchLimit)
10028 out := make([]DirEntry, 0, len(results))
10029 for _, r := range results {
10030 out = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir})
10031 }
10032 if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
10033 out = append(out, externalFolderDirEntries(browser.SearchExternalFolderRefs(query, fileRefSearchLimit))...)
10034 }
10035 return out
10036 }
10037
10038 type externalFolderRefBrowser interface {
10039 ListExternalFolderRefDir(tokenPath string) ([]control.ExternalFolderRefEntry, bool)
10040 SearchExternalFolderRefs(query string, limit int) []control.ExternalFolderRefEntry
10041 ExternalFolderRefLocalPath(tokenPath string) (path, displayPath string, ok bool)
10042 }
10043
10044 func externalFolderRefBrowserFromController(ctrl control.SessionAPI) externalFolderRefBrowser {
10045 if browser, ok := ctrl.(externalFolderRefBrowser); ok {
10046 return browser
10047 }
10048 return nil
10049 }
10050
10051 func externalFolderDirEntries(entries []control.ExternalFolderRefEntry) []DirEntry {
10052 out := make([]DirEntry, 0, len(entries))
10053 for _, e := range entries {
10054 out = append(out, DirEntry{
10055 Name: e.Name,
10056 Path: e.Path,
10057 IsDir: e.IsDir,
10058 DisplayName: e.DisplayName,
10059 DisplayPath: e.DisplayPath,
10060 })
10061 }
10062 return out
10063 }
10064
10065 func (a *App) workspaceOrExternalPathForTab(tabID, rel string) (string, bool, error) {
10066 path, _, ok, err := a.tabPathAndBase(tabID, rel)
10067 return path, ok, err
10068 }
10069
10070 // tabPathAndBase also returns the workspace base the path was joined under,
10071 // empty for a session-authorized external folder reference.
10072 func (a *App) tabPathAndBase(tabID, rel string) (string, string, bool, error) {
10073 root, ctrl, ok := a.workspaceTargetForTab(tabID)
10074 if !ok {
10075 return "", "", false, os.ErrNotExist
10076 }
10077 if browser := externalFolderRefBrowserFromController(ctrl); browser != nil {
10078 if path, _, ok := browser.ExternalFolderRefLocalPath(rel); ok {
10079 return path, "", true, nil
10080 }
10081 }
10082 base, err := workspaceBaseFromRoot(root)
10083 if err != nil {
10084 return "", "", false, err
10085 }
10086 path, ok, err := workspacePathForBase(base, rel)
10087 return path, base, ok, err
10088 }
10089
10090 // ReadFile returns a small text preview for a file under the current workspace
10091 // or a session-authorized external folder ref.
10092 func (a *App) ReadFile(rel string) FilePreview {
10093 return a.ReadFileForTab("", rel)
10094 }
10095
10096 // ReadFileForTab returns a preview resolved against the requested tab.
10097 func (a *App) ReadFileForTab(tabID, rel string) FilePreview {
10098 path, base, ok, err := a.tabPathAndBase(tabID, rel)
10099 if err != nil || !ok {
10100 return FilePreview{Path: rel, Err: "invalid path"}
10101 }
10102 // The lexical join is not containment: a linked component can leave the tree.
10103 if base != "" {
10104 if _, _, err := canonicalPathWithin(base, path); err != nil && !errors.Is(err, os.ErrNotExist) {
10105 return FilePreview{Path: rel, Err: "invalid path"}
10106 }
10107 }
10108 return a.readFilePathForTab(tabID, rel, path, false)
10109 }
10110
10111 func (a *App) readFilePathForTab(tabID, displayPath, path string, forceSource bool) FilePreview {
10112 out := FilePreview{Path: displayPath}
10113 info, err := os.Stat(path)
10114 if err != nil {
10115 out.Err = err.Error()
10116 return out
10117 }
10118 if info.IsDir() {
10119 out.Err = "path is a directory"
10120 return out
10121 }
10122 if !info.Mode().IsRegular() {
10123 out.Err = "path is not a regular file"
10124 return out
10125 }
10126 out.Size = info.Size()
10127 out.Version = workspaceFileVersion(info)
10128 if kind, mime := previewMediaKind(path); kind != "" && !forceSource {
10129 var token string
10130 store := a.ensureMediaTokenStore()
10131 if kind == "html" {
10132 allowedRoot := filepath.Dir(path)
10133 if root, _, found := a.workspaceTargetForTab(tabID); found {
10134 if base, baseErr := workspaceBaseFromRoot(root); baseErr == nil {
10135 if relative, relativeErr := filepath.Rel(base, path); relativeErr == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
10136 allowedRoot = base
10137 }
10138 }
10139 }
10140 token, err = store.createHTML(path, allowedRoot, info.Name(), mime, info)
10141 if err != nil {
10142 out.Err = err.Error()
10143 return out
10144 }
10145 } else {
10146 token = store.create(path, info.Name(), mime, kind, info.Size(), info.ModTime())
10147 }
10148 // Document tabs explicitly revoke their token on close. A two-hour
10149 // ceiling keeps long-running HTML/media previews alive without leaving
10150 // abandoned capabilities unbounded after a renderer crash.
10151 store.extend(token, 2*time.Hour)
10152 out.Kind = kind
10153 out.Mime = mime
10154 out.URL = "/__reasonix_workspace_media/" + token + "/" + url.PathEscape(info.Name())
10155 return out
10156 }
10157 f, err := os.Open(path)
10158 if err != nil {
10159 out.Err = err.Error()
10160 return out
10161 }
10162 defer f.Close()
10163
10164 buf := make([]byte, filePreviewLimit+1)
10165 n, err := f.Read(buf)
10166 if err != nil && !errors.Is(err, io.EOF) {
10167 out.Err = err.Error()
10168 return out
10169 }
10170 data := buf[:n]
10171 if len(data) > filePreviewLimit {
10172 data = data[:filePreviewLimit]
10173 out.Truncated = true
10174 }
10175
10176 // Check for BOM first (just the first 2-3 bytes — always complete
10177 // even at a truncation boundary). BOM-prefixed files skip the NUL
10178 // check since UTF-16 normally contains 0x00 for ASCII characters.
10179 bomKind := fileenc.DetectQuick(data)
10180 if bomKind != fileenc.UTF8 {
10181 enc, _ := fileenc.Detect(data)
10182 if enc == fileenc.LossyUTF8 {
10183 out.Binary = true
10184 return out
10185 }
10186 decoded := fileenc.Decode(data, enc)
10187 out.Body = string(decoded)
10188 return out
10189 }
10190
10191 // No BOM — NUL in raw bytes is a binary signal.
10192 if bytes.Contains(data, []byte{0}) {
10193 out.Binary = true
10194 return out
10195 }
10196
10197 // A truncated preview can end inside a character; the fragment keeps the
10198 // whole ones and the next page starts at the split one.
10199 detect := fileenc.Detect
10200 if out.Truncated {
10201 detect = fileenc.DetectFragment
10202 }
10203 enc, data := detect(data)
10204 if out.Truncated {
10205 out.NextOffset = int64(len(data))
10206 }
10207 if enc == fileenc.LossyUTF8 {
10208 out.Body = strings.ToValidUTF8(string(data), "\uFFFD")
10209 return out
10210 }
10211 out.Body = string(fileenc.Decode(data, enc))
10212 return out
10213 }
10214
10215 func (a *App) presentedPathForTab(tabID, toolCallID, requested string) (string, error) {
10216 result := a.ToolResultForTab(tabID, toolCallID)
10217 if !presentedFileDeclared(result, requested) {
10218 return "", os.ErrPermission
10219 }
10220 root, _, found := a.workspaceTargetForTab(tabID)
10221 if !found {
10222 return "", os.ErrPermission
10223 }
10224 declared := requested
10225 var resolved string
10226 if path, ok, err := a.workspaceOrExternalPathForTab(tabID, declared); err == nil && ok {
10227 resolved = path
10228 } else {
10229 if !filepath.IsAbs(declared) {
10230 return "", os.ErrPermission
10231 }
10232 resolved = filepath.Clean(declared)
10233 }
10234 resolved, err := validatePresentedReadPath(resolved)
10235 if err != nil {
10236 return "", err
10237 }
10238 if !readPolicyAllowsPath(root, resolved) {
10239 return "", os.ErrPermission
10240 }
10241 return resolved, nil
10242 }
10243
10244 func readPolicyAllowsPath(workspaceRoot, resolved string) bool {
10245 cfg, err := config.LoadForRootWithoutCredentialsReadOnly(workspaceRoot)
10246 if err != nil {
10247 return false
10248 }
10249 return !builtin.ReadPathForbidden(boot.RuntimeForbidReadRoots(cfg, workspaceRoot), resolved)
10250 }
10251
10252 func validatePresentedReadPath(path string) (string, error) {
10253 info, err := os.Lstat(path)
10254 if err != nil {
10255 return "", err
10256 }
10257 if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
10258 return "", os.ErrInvalid
10259 }
10260 return path, nil
10261 }
10262
10263 func presentedFileDeclared(result *control.ToolResultData, requested string) bool {
10264 if result == nil || result.Name != "present" || strings.TrimSpace(requested) == "" {
10265 return false
10266 }
10267 for _, file := range result.PresentedFiles {
10268 if file.Path == requested {
10269 return true
10270 }
10271 }
10272 return false
10273 }
10274
10275 // ReadPresentedFileForTab resolves a resource through the trusted metadata of
10276 // the built-in present call. This permits an explicitly declared absolute file
10277 // without turning the generic workspace reader into an arbitrary-path API.
10278 func (a *App) ReadPresentedFileForTab(tabID, toolCallID, path string) FilePreview {
10279 resolved, err := a.presentedPathForTab(tabID, toolCallID, path)
10280 if err != nil {
10281 return FilePreview{Path: path, Err: err.Error()}
10282 }
10283 return a.readFilePathForTab(tabID, path, resolved, false)
10284 }
10285
10286 func (a *App) ReadPresentedFileSourceForTab(tabID, toolCallID, path string) FilePreview {
10287 resolved, err := a.presentedPathForTab(tabID, toolCallID, path)
10288 if err != nil {
10289 return FilePreview{Path: path, Err: err.Error()}
10290 }
10291 return a.readFilePathForTab(tabID, path, resolved, true)
10292 }
10293
10294 // ReadPresentedTextPageForTab appends a bounded UTF-8 page to a trusted
10295 // present preview. The expected version prevents a reader from joining bytes
10296 // from two revisions when the file changes between requests.
10297 func (a *App) ReadPresentedTextPageForTab(tabID, toolCallID, path string, offset int64, expectedVersion string) (PresentedTextPage, error) {
10298 resolved, err := a.presentedPathForTab(tabID, toolCallID, path)
10299 if err != nil {
10300 return PresentedTextPage{}, err
10301 }
10302 return readPresentedTextPage(resolved, path, offset, expectedVersion)
10303 }
10304
10305 func readPresentedTextPage(resolved, displayPath string, offset int64, expectedVersion string) (PresentedTextPage, error) {
10306 f, err := os.Open(resolved)
10307 if err != nil {
10308 return PresentedTextPage{}, err
10309 }
10310 defer f.Close()
10311 info, err := f.Stat()
10312 if err != nil {
10313 return PresentedTextPage{}, err
10314 }
10315 current, err := os.Lstat(resolved)
10316 if err != nil || current.Mode()&os.ModeSymlink != 0 || !os.SameFile(info, current) {
10317 return PresentedTextPage{}, os.ErrPermission
10318 }
10319 version := workspaceFileVersion(info)
10320 if expectedVersion == "" || expectedVersion != version {
10321 return PresentedTextPage{}, fmt.Errorf("file changed; reload before loading more")
10322 }
10323 if offset < 0 || offset > info.Size() {
10324 return PresentedTextPage{}, os.ErrInvalid
10325 }
10326 if _, err := f.Seek(offset, io.SeekStart); err != nil {
10327 return PresentedTextPage{}, err
10328 }
10329 buf := make([]byte, presentedTextPageLimit+utf8.UTFMax)
10330 n, readErr := f.Read(buf)
10331 if readErr != nil && !errors.Is(readErr, io.EOF) {
10332 return PresentedTextPage{}, readErr
10333 }
10334 data := buf[:n]
10335 if len(data) > presentedTextPageLimit {
10336 data = trimUTF8PartialSuffix(data[:presentedTextPageLimit])
10337 }
10338 if bytes.Contains(data, []byte{0}) || !utf8.Valid(data) {
10339 return PresentedTextPage{}, fmt.Errorf("additional pages require UTF-8 text")
10340 }
10341 next := offset + int64(len(data))
10342 if next == offset && next < info.Size() {
10343 return PresentedTextPage{}, fmt.Errorf("could not advance text page")
10344 }
10345 after, err := f.Stat()
10346 if err != nil || workspaceFileVersion(after) != version {
10347 return PresentedTextPage{}, fmt.Errorf("file changed; reload before loading more")
10348 }
10349 return PresentedTextPage{
10350 Path: displayPath, Body: string(data), Offset: offset, NextOffset: next,
10351 Size: info.Size(), HasMore: next < info.Size(), Version: version,
10352 }, nil
10353 }
10354
10355 // CreateWorkspaceBrowserPreviewForTab returns a loopback-only URL for a
10356 // validated preview resource. The browser never receives file:// or the app's
10357 // privileged resource origin.
10358 func (a *App) CreateWorkspaceBrowserPreviewForTab(tabID, rel string) (string, error) {
10359 preview := a.ReadFileForTab(tabID, rel)
10360 if preview.Err != "" {
10361 return "", errors.New(preview.Err)
10362 }
10363 if preview.URL == "" {
10364 return "", errors.New("this file type cannot be opened in the built-in browser")
10365 }
10366 origin, err := a.ensureWorkspacePreviewOrigin()
10367 if err != nil {
10368 return "", err
10369 }
10370 a.extendWorkspaceBrowserPreviewToken(preview.URL)
10371 return origin + preview.URL, nil
10372 }
10373
10374 func (a *App) CreatePresentedBrowserPreviewForTab(tabID, toolCallID, path string) (string, error) {
10375 preview := a.ReadPresentedFileForTab(tabID, toolCallID, path)
10376 if preview.Err != "" {
10377 return "", errors.New(preview.Err)
10378 }
10379 if preview.URL == "" {
10380 return "", errors.New("this file type cannot be opened in the built-in browser")
10381 }
10382 origin, err := a.ensureWorkspacePreviewOrigin()
10383 if err != nil {
10384 return "", err
10385 }
10386 a.extendWorkspaceBrowserPreviewToken(preview.URL)
10387 return origin + preview.URL, nil
10388 }
10389
10390 func (a *App) extendWorkspaceBrowserPreviewToken(resourceURL string) {
10391 const prefix = "/__reasonix_workspace_media/"
10392 trimmed := strings.TrimPrefix(resourceURL, prefix)
10393 if trimmed == resourceURL {
10394 return
10395 }
10396 token := strings.SplitN(trimmed, "/", 2)[0]
10397 if token != "" {
10398 a.ensureMediaTokenStore().extend(token, 2*time.Hour)
10399 }
10400 }
10401
10402 // RevokeWorkspaceBrowserPreview drops the file binding and invalidates only
10403 // URLs minted by this app's unprivileged preview origin. Browser tab close
10404 // calls this best-effort.
10405 func (a *App) RevokeWorkspaceBrowserPreview(rawURL string) {
10406 a.releaseFileBrowserPreviewURL(rawURL)
10407 a.revokeWorkspaceBrowserPreview(rawURL)
10408 }
10409
10410 func (a *App) revokeWorkspaceBrowserPreview(rawURL string) {
10411 u, err := url.Parse(rawURL)
10412 if err != nil {
10413 return
10414 }
10415 a.mu.RLock()
10416 p := a.presentPreview
10417 a.mu.RUnlock()
10418 if p == nil {
10419 return
10420 }
10421 p.mu.Lock()
10422 origin := p.origin
10423 p.mu.Unlock()
10424 if origin == "" || u.Scheme+"://"+u.Host != origin {
10425 return
10426 }
10427 a.revokeWorkspaceMediaPath(u.Path)
10428 }
10429
10430 // RevokeWorkspaceMediaPreview releases a relative resource capability used by
10431 // the document workspace when its renderer unmounts or changes files.
10432 func (a *App) RevokeWorkspaceMediaPreview(resourceURL string) {
10433 u, err := url.Parse(resourceURL)
10434 if err != nil || u.Scheme != "" || u.Host != "" {
10435 return
10436 }
10437 a.revokeWorkspaceMediaPath(u.Path)
10438 }
10439
10440 func (a *App) revokeWorkspaceMediaPath(resourcePath string) {
10441 const prefix = "/__reasonix_workspace_media/"
10442 trimmed := strings.TrimPrefix(resourcePath, prefix)
10443 if trimmed == resourcePath {
10444 return
10445 }
10446 if token := strings.SplitN(trimmed, "/", 2)[0]; token != "" {
10447 a.ensureMediaTokenStore().revoke(token)
10448 }
10449 }
10450
10451 func (a *App) OpenPresentedPathForTab(tabID, toolCallID, path string) error {
10452 resolved, err := a.presentedPathForTab(tabID, toolCallID, path)
10453 if err != nil {
10454 return err
10455 }
10456 return openWorkspacePath(resolved)
10457 }
10458
10459 // ResolvePresentedPathForTab returns the source host's absolute path only
10460 // after revalidating the trusted present result and the current read policy.
10461 func (a *App) ResolvePresentedPathForTab(tabID, toolCallID, path string) (string, error) {
10462 return a.presentedPathForTab(tabID, toolCallID, path)
10463 }
10464
10465 func (a *App) RevealPresentedPathForTab(tabID, toolCallID, path string) error {
10466 resolved, err := a.presentedPathForTab(tabID, toolCallID, path)
10467 if err != nil {
10468 return err
10469 }
10470 return revealPath(resolved)
10471 }
10472
10473 func (a *App) SavePresentedPathAsForTab(tabID, toolCallID, path string) (string, error) {
10474 resolved, err := a.presentedPathForTab(tabID, toolCallID, path)
10475 if err != nil {
10476 return "", err
10477 }
10478 return a.SaveLocalPathAs(resolved)
10479 }
10480
10481 // OpenWorkspacePathForTab opens a path resolved against the requested tab.
10482 func (a *App) OpenWorkspacePathForTab(tabID, rel string) error {
10483 path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
10484 if err != nil || !ok {
10485 return os.ErrInvalid
10486 }
10487 return openWorkspacePath(path)
10488 }
10489
10490 // RevealWorkspacePathForTab reveals a path resolved against the requested tab.
10491 func (a *App) RevealWorkspacePathForTab(tabID, rel string) error {
10492 path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
10493 if err != nil || !ok {
10494 return os.ErrInvalid
10495 }
10496 return revealPath(path)
10497 }
10498
10499 // SaveWorkspacePathAsForTab copies a session-scoped workspace or authorized
10500 // external file to a destination chosen by the user.
10501 func (a *App) SaveWorkspacePathAsForTab(tabID, rel string) (string, error) {
10502 path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
10503 if err != nil || !ok {
10504 return "", os.ErrInvalid
10505 }
10506 return a.SaveLocalPathAs(path)
10507 }
10508
10509 // RevealPath shows an arbitrary absolute path in the native file manager.
10510 func (a *App) RevealPath(path string) error {
10511 path = strings.TrimSpace(path)
10512 if path == "" {
10513 return os.ErrInvalid
10514 }
10515 if abs, err := filepath.Abs(path); err == nil {
10516 path = abs
10517 }
10518 return revealPath(path)
10519 }
10520
10521 var revealPath = defaultRevealPath
10522
10523 func defaultRevealPath(path string) error {
10524 switch goruntime.GOOS {
10525 case "darwin":
10526 return proc.VisibleCommand("open", "-R", path).Start()
10527 case "windows":
10528 // explorer.exe lives in %SystemRoot%, which isn't always on PATH (the
10529 // launch environment can strip it), so resolve it directly rather than
10530 // relying on a PATH lookup.
10531 explorer := "explorer.exe"
10532 root := os.Getenv("SystemRoot")
10533 if root == "" {
10534 root = os.Getenv("windir")
10535 }
10536 if root != "" {
10537 explorer = filepath.Join(root, "explorer.exe")
10538 }
10539 return proc.VisibleCommand(explorer, "/select,", path).Start()
10540 default:
10541 dir := path
10542 if info, err := os.Stat(path); err == nil && !info.IsDir() {
10543 dir = filepath.Dir(path)
10544 }
10545 return proc.VisibleCommand("xdg-open", dir).Start()
10546 }
10547 }
10548
10549 func (a *App) noticeForTab(tabID, text string) {
10550 tab := a.tabByID(tabID)
10551 if tab != nil && tab.sink != nil {
10552 tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text})
10553 }
10554 }
10555
10556 func (a *App) warnForTab(tabID, text string) {
10557 tab := a.tabByID(tabID)
10558 if tab != nil && tab.sink != nil {
10559 tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text})
10560 }
10561 }
10562
10563 func (a *App) runEffortCommandForTab(tabID, input string) {
10564 entry, err := a.currentProviderEntryForTab(tabID)
10565 if err != nil {
10566 a.noticeForTab(tabID, "effort: "+err.Error())
10567 return
10568 }
10569 cap := config.EffortCapabilityForEntry(entry)
10570 args := strings.Fields(input)
10571 if !cap.Supported && !(len(args) == 2 && args[1] == "auto") {
10572 a.noticeForTab(tabID, fmt.Sprintf("effort is not configurable for %s", entry.Name))
10573 return
10574 }
10575 if len(args) < 2 {
10576 a.noticeForTab(tabID, fmt.Sprintf("effort for %s: %s (default: %s; options: %s)", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, "|")))
10577 return
10578 }
10579 if len(args) > 2 {
10580 a.noticeForTab(tabID, "usage: /effort "+strings.Join(cap.Levels, "|"))
10581 return
10582 }
10583 effort, err := config.NormalizeEffort(entry, args[1])
10584 if err != nil {
10585 a.noticeForTab(tabID, err.Error())
10586 return
10587 }
10588 if err := a.SetEffortForTab(tabID, args[1]); err != nil {
10589 a.noticeForTab(tabID, "effort: "+err.Error())
10590 return
10591 }
10592 display := effort
10593 if display == "" {
10594 display = "auto"
10595 }
10596 a.noticeForTab(tabID, fmt.Sprintf("effort for %s set to %s", entry.Name, display))
10597 }
10598
10599 func (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) {
10600 if tab := a.tabByID(tabID); tab != nil {
10601 a.reconcileTabWithPinnedSessionMeta(tab)
10602 }
10603 a.mu.RLock()
10604 ref := ""
10605 workspaceRoot := ""
10606 effortOverride := (*string)(nil)
10607 if tab := a.tabByIDLocked(tabID); tab != nil {
10608 ref = tab.model
10609 workspaceRoot = tab.WorkspaceRoot
10610 effortOverride = cloneStringPtr(tab.effort)
10611 }
10612 a.mu.RUnlock()
10613 cfg, err := config.LoadForRoot(workspaceRoot)
10614 if err != nil {
10615 return nil, err
10616 }
10617 if strings.TrimSpace(ref) == "" {
10618 ref = cfg.DefaultModel
10619 }
10620 config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref)
10621 resolved, _, ok := cfg.ResolveModelWithFallback(ref)
10622 if !ok {
10623 return nil, fmt.Errorf("unknown model %q", ref)
10624 }
10625 entry, ok := cfg.ResolveModel(resolved)
10626 if !ok {
10627 return nil, fmt.Errorf("unknown model %q", resolved)
10628 }
10629 if effortOverride != nil {
10630 entry.Effort = *effortOverride
10631 }
10632 return entry, nil
10633 }
10634
10635 // PickExportFile opens the native save dialog and returns the selected path. It
10636 // returns "" when the user cancels.
10637 func (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) {
10638 if a.ctx == nil {
10639 return "", nil
10640 }
10641 defaultFilename = safeExportFilename(defaultFilename)
10642 ext := strings.ToLower(filepath.Ext(defaultFilename))
10643 path, err := a.nativeHost().SaveFileDialog(a.ctx, nativeDialogOptions{
10644 Title: "Export session",
10645 DefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()),
10646 DefaultFilename: defaultFilename,
10647 CanCreateDirectories: true,
10648 Filters: exportFileFilters(mimeType, ext),
10649 })
10650 if err != nil || path == "" {
10651 return "", err
10652 }
10653 if ext != "" && filepath.Ext(path) == "" {
10654 path += ext
10655 }
10656 return path, nil
10657 }
10658
10659 // SaveExportFile writes an exported session payload to a path previously picked
10660 // by PickExportFile. An empty path is treated as a cancelled export.
10661 func (a *App) SaveExportFile(path, payload string, base64Encoded bool) error {
10662 if strings.TrimSpace(path) == "" {
10663 return nil
10664 }
10665 var data []byte
10666 var err error
10667 if base64Encoded {
10668 data, err = base64.StdEncoding.DecodeString(payload)
10669 if err != nil {
10670 return fmt.Errorf("decode export payload: %w", err)
10671 }
10672 } else {
10673 data = []byte(payload)
10674 }
10675 if err := os.WriteFile(path, data, 0o644); err != nil {
10676 return exportOperationError("save export file", path, err)
10677 }
10678 return nil
10679 }
10680
10681 // SaveExportImageFiles writes one or more base64-encoded image parts. A single
10682 // image keeps the native save dialog's normal overwrite semantics. Multi-part
10683 // exports use numbered sibling paths and never overwrite an existing sibling;
10684 // every payload is staged before any target is committed, and a failed commit
10685 // removes only files created by this call.
10686 func (a *App) SaveExportImageFiles(path string, payloads []string) error {
10687 if strings.TrimSpace(path) == "" {
10688 return nil
10689 }
10690 if len(payloads) == 0 {
10691 return errors.New("no image payloads to export")
10692 }
10693 if len(payloads) == 1 {
10694 return a.SaveExportFile(path, payloads[0], true)
10695 }
10696
10697 targets := make([]string, len(payloads))
10698 for i := range payloads {
10699 targets[i] = numberedExportPath(path, i, len(payloads))
10700 }
10701
10702 return saveExclusiveExportPayloads(targets, len(payloads), func(index int) ([]byte, error) {
10703 decoded, err := base64.StdEncoding.DecodeString(payloads[index])
10704 if err != nil {
10705 return nil, fmt.Errorf("decode export image part %d: %w", index+1, err)
10706 }
10707 return decoded, nil
10708 })
10709 }
10710
10711 type stagedExportFile struct {
10712 targetPath string
10713 tempPath string
10714 }
10715
10716 type committedExportFile struct {
10717 path string
10718 info os.FileInfo
10719 }
10720
10721 const exportTempCreateAttempts = 100
10722
10723 func saveExclusiveExportPayloads(targets []string, payloadCount int, payloadAt func(int) ([]byte, error)) error {
10724 if len(targets) == 0 || len(targets) != payloadCount || payloadAt == nil {
10725 return errors.New("invalid export image batch")
10726 }
10727 for _, target := range targets {
10728 if _, err := os.Lstat(target); err == nil {
10729 return fmt.Errorf("export file already exists: %s", filepath.Base(target))
10730 } else if !errors.Is(err, os.ErrNotExist) {
10731 return exportOperationError("inspect export target", target, err)
10732 }
10733 }
10734
10735 staged := make([]stagedExportFile, 0, len(targets))
10736 defer func() {
10737 for _, file := range staged {
10738 _ = os.Remove(file.tempPath)
10739 }
10740 }()
10741 for i, target := range targets {
10742 payload, err := payloadAt(i)
10743 if err != nil {
10744 return err
10745 }
10746 file, finalMode, err := createExportTempFile(filepath.Dir(target))
10747 if err != nil {
10748 return exportOperationError("stage export file", target, err)
10749 }
10750 tempPath := file.Name()
10751 staged = append(staged, stagedExportFile{targetPath: target, tempPath: tempPath})
10752 if _, err = file.Write(payload); err == nil {
10753 err = file.Sync()
10754 }
10755 // Keep staged payloads private while they are incomplete, then restore
10756 // the same umask-adjusted mode used by SaveExportFile before publishing.
10757 if err == nil {
10758 err = file.Chmod(finalMode)
10759 }
10760 if err == nil {
10761 err = file.Sync()
10762 }
10763 if closeErr := file.Close(); err == nil {
10764 err = closeErr
10765 }
10766 if err != nil {
10767 return exportOperationError("stage export file", target, err)
10768 }
10769 }
10770
10771 committed := make([]committedExportFile, 0, len(staged))
10772 for _, file := range staged {
10773 info, err := commitStagedExportFile(file.tempPath, file.targetPath)
10774 if err != nil {
10775 rollbackCommittedExportFiles(committed)
10776 return exportOperationError("save export file", file.targetPath, err)
10777 }
10778 committed = append(committed, committedExportFile{path: file.targetPath, info: info})
10779 }
10780 return nil
10781 }
10782
10783 // createExportTempFile reserves a cryptographically random sibling path with
10784 // the same requested mode as a normal export. It immediately narrows the mode
10785 // while bytes are staged; the caller restores finalMode only after the payload
10786 // has been completely written and synced.
10787 func createExportTempFile(dir string) (*os.File, os.FileMode, error) {
10788 for range exportTempCreateAttempts {
10789 var suffix [12]byte
10790 if _, err := rand.Read(suffix[:]); err != nil {
10791 return nil, 0, fmt.Errorf("generate export temp name: %w", err)
10792 }
10793 path := filepath.Join(dir, ".reasonix-export-"+hex.EncodeToString(suffix[:]))
10794 file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
10795 if errors.Is(err, os.ErrExist) {
10796 continue
10797 }
10798 if err != nil {
10799 return nil, 0, err
10800 }
10801 info, err := file.Stat()
10802 if err == nil {
10803 err = file.Chmod(0o600)
10804 }
10805 if err != nil {
10806 _ = file.Close()
10807 _ = os.Remove(path)
10808 return nil, 0, err
10809 }
10810 return file, info.Mode().Perm(), nil
10811 }
10812 return nil, 0, errors.New("could not reserve a unique export temp file")
10813 }
10814
10815 func commitStagedExportFile(tempPath, targetPath string) (os.FileInfo, error) {
10816 stagedInfo, err := os.Lstat(tempPath)
10817 if err != nil {
10818 return nil, err
10819 }
10820 // A hard link publishes a fully written staged file atomically and fails if
10821 // the target already exists. Some filesystems do not support hard links, so
10822 // fall back to an exclusive create while preserving the no-overwrite rule.
10823 if err := os.Link(tempPath, targetPath); err == nil {
10824 current, statErr := os.Lstat(targetPath)
10825 if statErr != nil {
10826 removeExportFileIfSame(targetPath, stagedInfo)
10827 return nil, statErr
10828 }
10829 if !os.SameFile(current, stagedInfo) {
10830 return nil, errors.New("export target changed while it was being saved")
10831 }
10832 return stagedInfo, nil
10833 }
10834
10835 source, err := os.Open(tempPath)
10836 if err != nil {
10837 return nil, err
10838 }
10839 defer source.Close()
10840 target, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
10841 if err != nil {
10842 return nil, err
10843 }
10844 info, statErr := target.Stat()
10845 if statErr == nil {
10846 _, err = io.Copy(target, source)
10847 }
10848 if err == nil && statErr == nil {
10849 err = target.Sync()
10850 }
10851 if closeErr := target.Close(); err == nil && statErr == nil {
10852 err = closeErr
10853 }
10854 if statErr != nil {
10855 err = statErr
10856 }
10857 if err != nil {
10858 removeExportFileIfSame(targetPath, info)
10859 return nil, err
10860 }
10861 return info, nil
10862 }
10863
10864 func rollbackCommittedExportFiles(files []committedExportFile) {
10865 for _, file := range files {
10866 removeExportFileIfSame(file.path, file.info)
10867 }
10868 }
10869
10870 func removeExportFileIfSame(path string, created os.FileInfo) {
10871 if created == nil {
10872 return
10873 }
10874 current, err := os.Lstat(path)
10875 if err == nil && os.SameFile(current, created) {
10876 _ = os.Remove(path)
10877 }
10878 }
10879
10880 func exportOperationError(operation, path string, err error) error {
10881 var pathErr *os.PathError
10882 if errors.As(err, &pathErr) {
10883 return fmt.Errorf("%s %s: %w", operation, filepath.Base(path), pathErr.Err)
10884 }
10885 return fmt.Errorf("%s %s: %w", operation, filepath.Base(path), err)
10886 }
10887
10888 func safeExportFilename(name string) string {
10889 name = strings.TrimSpace(name)
10890 if name == "" {
10891 return "reasonix-session.md"
10892 }
10893 return filepath.Base(name)
10894 }
10895
10896 func exportFileFilters(mimeType, ext string) []nativeFileFilter {
10897 switch mimeType {
10898 case "text/markdown":
10899 return []nativeFileFilter{{DisplayName: "Markdown (*.md)", Pattern: "*.md"}}
10900 case "application/json":
10901 return []nativeFileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}}
10902 case "application/pdf":
10903 return []nativeFileFilter{{DisplayName: "PDF (*.pdf)", Pattern: "*.pdf"}}
10904 case "image/png":
10905 return []nativeFileFilter{{DisplayName: "PNG image (*.png)", Pattern: "*.png"}}
10906 }
10907 if ext != "" {
10908 return []nativeFileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, ".")) + " files (*" + ext + ")", Pattern: "*" + ext}}
10909 }
10910 return []nativeFileFilter{{DisplayName: "All files (*.*)", Pattern: "*.*"}}
10911 }
10912
10913 // memory panel (frontend ⇄ controller)
10914
10915 type MemoryImport struct {
10916 Path string `json:"path"`
10917 SourcePath string `json:"sourcePath"`
10918 }
10919
10920 // MemoryDoc is one resolved instruction file with applicability metadata.
10921 type MemoryDoc struct {
10922 Path string `json:"path"`
10923 Scope string `json:"scope"`
10924 Directory string `json:"directory,omitempty"`
10925 Body string `json:"body"`
10926 Imports []MemoryImport `json:"imports"`
10927 Depth int `json:"depth"`
10928 Order int `json:"order"`
10929 Precedence int `json:"precedence"`
10930 }
10931
10932 type InstructionDiagnostic struct {
10933 Code string `json:"code"`
10934 Path string `json:"path"`
10935 SourcePath string `json:"sourcePath,omitempty"`
10936 Line int `json:"line,omitempty"`
10937 Message string `json:"message"`
10938 }
10939
10940 // MemoryFact is one saved auto-memory, surfaced read-only in the panel.
10941 type MemoryFact struct {
10942 ID string `json:"id,omitempty"`
10943 Revision int `json:"revision,omitempty"`
10944 CreatedAt string `json:"createdAt,omitempty"`
10945 UpdatedAt string `json:"updatedAt,omitempty"`
10946 Name string `json:"name"`
10947 Title string `json:"title,omitempty"`
10948 Description string `json:"description"`
10949 Type string `json:"type"`
10950 Scope string `json:"scope"`
10951 Body string `json:"body"`
10952 Freshness string `json:"freshness"`
10953 }
10954
10955 type MemoryConflict struct {
10956 Key string `json:"key"`
10957 ProjectID string `json:"projectId"`
10958 ProjectName string `json:"projectName"`
10959 GlobalID string `json:"globalId"`
10960 GlobalName string `json:"globalName"`
10961 Resolution string `json:"resolution"`
10962 }
10963
10964 type MemoryRecallHit struct {
10965 ID string `json:"id"`
10966 Revision int `json:"revision"`
10967 Name string `json:"name"`
10968 Title string `json:"title,omitempty"`
10969 Type string `json:"type"`
10970 Scope string `json:"scope"`
10971 Score float64 `json:"score"`
10972 Freshness string `json:"freshness"`
10973 Reason string `json:"reason"`
10974 Snippet string `json:"snippet"`
10975 }
10976
10977 type MemoryRecallTrace struct {
10978 Query string `json:"query"`
10979 Hits []MemoryRecallHit `json:"hits"`
10980 Omitted int `json:"omitted"`
10981 CharBudget int `json:"charBudget"`
10982 UsedChars int `json:"usedChars"`
10983 Suppressed string `json:"suppressed,omitempty"`
10984 }
10985
10986 // MemoryArchive is one archived auto-memory kept only for inspection.
10987 type MemoryArchive struct {
10988 ID string `json:"id,omitempty"`
10989 Revision int `json:"revision,omitempty"`
10990 CreatedAt string `json:"createdAt,omitempty"`
10991 UpdatedAt string `json:"updatedAt,omitempty"`
10992 Name string `json:"name"`
10993 Title string `json:"title,omitempty"`
10994 Description string `json:"description"`
10995 Type string `json:"type"`
10996 Scope string `json:"scope"`
10997 Body string `json:"body"`
10998 Freshness string `json:"freshness"`
10999 Path string `json:"path"`
11000 ArchivedAt string `json:"archivedAt,omitempty"`
11001 }
11002
11003 // MemoryScope is one writable quick-add target (scope id + the file it writes to).
11004 type MemoryScope struct {
11005 Scope string `json:"scope"`
11006 Path string `json:"path"`
11007 }
11008
11009 // MemoryView is the whole memory panel payload: hierarchical docs, active saved
11010 // facts, archived facts, and the writable scopes for the quick-add selector.
11011 type MemoryView struct {
11012 Docs []MemoryDoc `json:"docs"`
11013 Facts []MemoryFact `json:"facts"`
11014 Archives []MemoryArchive `json:"archives"`
11015 Scopes []MemoryScope `json:"scopes"`
11016 InstructionDiagnostics []InstructionDiagnostic `json:"instructionDiagnostics"`
11017 Conflicts []MemoryConflict `json:"conflicts"`
11018 LastRecall MemoryRecallTrace `json:"lastRecall"`
11019 StoreDir string `json:"storeDir"`
11020 StoreGlobalDir string `json:"storeGlobalDir,omitempty"`
11021 Available bool `json:"available"`
11022 }
11023
11024 // writableScopes are the quick-add targets the panel offers, broad → specific.
11025 var writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal}
11026
11027 // Memory returns the loaded memory for the panel: the REASONIX.md hierarchy,
11028 // active/archived auto-memories, and the writable scopes. Read-only; mutations
11029 // go through Remember / SaveDoc.
11030 func (a *App) Memory() MemoryView {
11031 return a.memoryForCtrl(nil, true)
11032 }
11033
11034 // MemoryForTab returns the loaded memory for a specific tab's controller,
11035 // so the panel can show memory for any open project, not just the active tab.
11036 // If the tab does not exist or has no controller, returns an empty view
11037 // instead of falling back to the active tab (which would show the wrong data).
11038 // An empty tabID is treated as "no tab specified" and falls back to the
11039 // active tab for backward compatibility.
11040 func (a *App) MemoryForTab(tabID string) MemoryView {
11041 if tabID == "" {
11042 return a.memoryForCtrl(nil, true)
11043 }
11044 return a.memoryForCtrl(a.ctrlByTabID(tabID), false)
11045 }
11046
11047 func (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView {
11048 view := emptyMemoryView()
11049 if ctrl == nil {
11050 if !fallback {
11051 return view
11052 }
11053 a.mu.RLock()
11054 ctrl = a.activeCtrlLocked()
11055 a.mu.RUnlock()
11056 if ctrl == nil {
11057 return view
11058 }
11059 }
11060 set := ctrl.Memory()
11061 if set == nil {
11062 return view
11063 }
11064 view.StoreDir = set.Store.Dir
11065 view.StoreGlobalDir = set.Store.GlobalDir
11066 view.Available = true
11067 for _, d := range set.Docs {
11068 imports := make([]MemoryImport, 0, len(d.Imports))
11069 for _, imported := range d.Imports {
11070 imports = append(imports, MemoryImport{Path: imported.Path, SourcePath: imported.SourcePath})
11071 }
11072 view.Docs = append(view.Docs, MemoryDoc{
11073 Path: d.Path, Scope: string(d.Scope), Directory: d.Directory, Body: d.Body,
11074 Imports: imports, Depth: d.Depth, Order: d.Order, Precedence: d.Order,
11075 })
11076 }
11077 for _, diagnostic := range set.InstructionDiagnostics {
11078 view.InstructionDiagnostics = append(view.InstructionDiagnostics, InstructionDiagnostic{
11079 Code: diagnostic.Code, Path: diagnostic.Path, SourcePath: diagnostic.SourcePath,
11080 Line: diagnostic.Line, Message: diagnostic.Message,
11081 })
11082 }
11083 allFacts := set.Store.ListAll()
11084 for _, f := range allFacts {
11085 view.Facts = append(view.Facts, memoryFactView(f))
11086 }
11087 for _, conflict := range memory.FindOverrides(allFacts) {
11088 view.Conflicts = append(view.Conflicts, MemoryConflict{
11089 Key: conflict.Key, ProjectID: conflict.Project.ID, ProjectName: conflict.Project.Name,
11090 GlobalID: conflict.Global.ID, GlobalName: conflict.Global.Name, Resolution: "project_over_global",
11091 })
11092 }
11093 view.LastRecall = memoryRecallTraceView(ctrl.LastMemoryRecall())
11094 for _, f := range set.Store.ListArchived() {
11095 archivedAt := ""
11096 if !f.ArchivedAt.IsZero() {
11097 archivedAt = f.ArchivedAt.Format(time.RFC3339)
11098 }
11099 view.Archives = append(view.Archives, MemoryArchive{
11100 ID: f.ID, Revision: f.Revision, CreatedAt: formatMemoryTime(f.CreatedAt), UpdatedAt: formatMemoryTime(f.UpdatedAt),
11101 Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Scope: string(f.Scope), Body: f.Body,
11102 Freshness: memory.FreshnessFor(f.Memory, time.Now().UTC()), Path: f.Path, ArchivedAt: archivedAt,
11103 })
11104 }
11105 for _, sc := range writableScopes {
11106 if p := set.DocPath(sc); p != "" {
11107 view.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p})
11108 }
11109 }
11110 return view
11111 }
11112
11113 func formatMemoryTime(value time.Time) string {
11114 if value.IsZero() {
11115 return ""
11116 }
11117 return value.UTC().Format(time.RFC3339Nano)
11118 }
11119
11120 func emptyMemoryView() MemoryView {
11121 return MemoryView{
11122 Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{},
11123 InstructionDiagnostics: []InstructionDiagnostic{}, Conflicts: []MemoryConflict{},
11124 LastRecall: MemoryRecallTrace{Hits: []MemoryRecallHit{}},
11125 }
11126 }
11127
11128 // Remember quick-adds a one-line note to the doc-memory file for scope — the
11129 // panel's explicit "remember" action, equivalent to typing "/remember <note>".
11130 // An unknown scope falls back to project. Returns the file written.
11131 func (a *App) Remember(scope, note string) (string, error) {
11132 return a.rememberForCtrl(nil, scope, note, true)
11133 }
11134
11135 func (a *App) RememberForTab(tabID, scope, note string) (string, error) {
11136 if tabID == "" {
11137 return a.rememberForCtrl(nil, scope, note, true)
11138 }
11139 return a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false)
11140 }
11141
11142 func (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) {
11143 if ctrl == nil {
11144 if !fallback {
11145 return "", nil
11146 }
11147 a.mu.RLock()
11148 ctrl = a.activeCtrlLocked()
11149 a.mu.RUnlock()
11150 if ctrl == nil {
11151 return "", nil
11152 }
11153 }
11154 return ctrl.QuickAdd(parseScope(scope), note)
11155 }
11156
11157 // Forget deletes a saved auto-memory by name — the panel's delete action for a
11158 // fact the model owns. A no-op when no controller is attached.
11159 func (a *App) Forget(name string) error {
11160 return a.forgetForCtrl(nil, name, true)
11161 }
11162
11163 func (a *App) ForgetForTab(tabID, name string) error {
11164 if tabID == "" {
11165 return a.forgetForCtrl(nil, name, true)
11166 }
11167 return a.forgetForCtrl(a.ctrlByTabID(tabID), name, false)
11168 }
11169
11170 func (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error {
11171 if ctrl == nil {
11172 if !fallback {
11173 return nil
11174 }
11175 a.mu.RLock()
11176 ctrl = a.activeCtrlLocked()
11177 a.mu.RUnlock()
11178 if ctrl == nil {
11179 return nil
11180 }
11181 }
11182 return ctrl.ForgetMemory(name)
11183 }
11184
11185 // RestoreArchivedMemory recovers one archived fact without replacing active
11186 // memory. The store preserves its identity and creates a new audited revision.
11187 func (a *App) RestoreArchivedMemory(archivePath string) (MemoryFact, error) {
11188 return a.restoreArchivedMemoryForCtrl(nil, archivePath, true)
11189 }
11190
11191 func (a *App) RestoreArchivedMemoryForTab(tabID, archivePath string) (MemoryFact, error) {
11192 if tabID == "" {
11193 return a.restoreArchivedMemoryForCtrl(nil, archivePath, true)
11194 }
11195 return a.restoreArchivedMemoryForCtrl(a.ctrlByTabID(tabID), archivePath, false)
11196 }
11197
11198 func (a *App) restoreArchivedMemoryForCtrl(ctrl control.SessionAPI, archivePath string, fallback bool) (MemoryFact, error) {
11199 if ctrl == nil {
11200 if !fallback {
11201 return MemoryFact{}, nil
11202 }
11203 a.mu.RLock()
11204 ctrl = a.activeCtrlLocked()
11205 a.mu.RUnlock()
11206 if ctrl == nil {
11207 return MemoryFact{}, nil
11208 }
11209 }
11210 restored, err := ctrl.RestoreArchivedMemory(archivePath)
11211 if err != nil {
11212 return MemoryFact{}, err
11213 }
11214 return memoryFactView(restored), nil
11215 }
11216
11217 func memoryFactView(f memory.Memory) MemoryFact {
11218 return MemoryFact{
11219 ID: f.ID, Revision: f.Revision, CreatedAt: formatMemoryTime(f.CreatedAt), UpdatedAt: formatMemoryTime(f.UpdatedAt),
11220 Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Scope: string(f.Scope), Body: f.Body,
11221 Freshness: memory.FreshnessFor(f, time.Now().UTC()),
11222 }
11223 }
11224
11225 func memoryRecallTraceView(trace memory.RecallResult) MemoryRecallTrace {
11226 view := MemoryRecallTrace{
11227 Query: trace.Query, Hits: []MemoryRecallHit{}, Omitted: trace.Omitted,
11228 CharBudget: trace.CharBudget, UsedChars: trace.UsedChars, Suppressed: trace.Suppressed,
11229 }
11230 for _, hit := range trace.Hits {
11231 view.Hits = append(view.Hits, MemoryRecallHit{
11232 ID: hit.Memory.ID, Revision: hit.Memory.Revision, Name: hit.Memory.Name, Title: hit.Memory.Title,
11233 Type: string(hit.Memory.Type), Scope: string(hit.Memory.Scope), Score: hit.Score,
11234 Freshness: hit.Freshness, Reason: hit.Reason, Snippet: hit.Snippet,
11235 })
11236 }
11237 return view
11238 }
11239
11240 func (a *App) MemoryRevisions(ref string) []MemoryFact {
11241 return a.memoryRevisionsForCtrl(nil, ref, true)
11242 }
11243
11244 func (a *App) MemoryRevisionsForTab(tabID, ref string) []MemoryFact {
11245 if tabID == "" {
11246 return a.memoryRevisionsForCtrl(nil, ref, true)
11247 }
11248 return a.memoryRevisionsForCtrl(a.ctrlByTabID(tabID), ref, false)
11249 }
11250
11251 func (a *App) memoryRevisionsForCtrl(ctrl control.SessionAPI, ref string, fallback bool) []MemoryFact {
11252 out := []MemoryFact{}
11253 if ctrl == nil {
11254 if !fallback {
11255 return out
11256 }
11257 a.mu.RLock()
11258 ctrl = a.activeCtrlLocked()
11259 a.mu.RUnlock()
11260 if ctrl == nil {
11261 return out
11262 }
11263 }
11264 for _, revision := range ctrl.MemoryRevisions(ref) {
11265 out = append(out, memoryFactView(revision))
11266 }
11267 return out
11268 }
11269
11270 func (a *App) RestoreMemoryRevision(ref string, revision int) (MemoryFact, error) {
11271 return a.restoreMemoryRevisionForCtrl(nil, ref, revision, true)
11272 }
11273
11274 func (a *App) RestoreMemoryRevisionForTab(tabID, ref string, revision int) (MemoryFact, error) {
11275 if tabID == "" {
11276 return a.restoreMemoryRevisionForCtrl(nil, ref, revision, true)
11277 }
11278 return a.restoreMemoryRevisionForCtrl(a.ctrlByTabID(tabID), ref, revision, false)
11279 }
11280
11281 func (a *App) restoreMemoryRevisionForCtrl(ctrl control.SessionAPI, ref string, revision int, fallback bool) (MemoryFact, error) {
11282 if ctrl == nil {
11283 if !fallback {
11284 return MemoryFact{}, nil
11285 }
11286 a.mu.RLock()
11287 ctrl = a.activeCtrlLocked()
11288 a.mu.RUnlock()
11289 if ctrl == nil {
11290 return MemoryFact{}, nil
11291 }
11292 }
11293 restored, err := ctrl.RestoreMemory(ref, revision)
11294 if err != nil {
11295 return MemoryFact{}, err
11296 }
11297 return memoryFactView(restored), nil
11298 }
11299
11300 // SaveDoc overwrites a memory doc with the panel editor's contents. The controller
11301 // validates path against the recognized memory files. Returns the file written.
11302 func (a *App) SaveDoc(path, body string) (string, error) {
11303 return a.saveDocForCtrl(nil, path, body, true)
11304 }
11305
11306 func (a *App) SaveDocForTab(tabID, path, body string) (string, error) {
11307 if tabID == "" {
11308 return a.saveDocForCtrl(nil, path, body, true)
11309 }
11310 return a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false)
11311 }
11312
11313 func (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) {
11314 if ctrl == nil {
11315 if !fallback {
11316 return "", nil
11317 }
11318 a.mu.RLock()
11319 ctrl = a.activeCtrlLocked()
11320 a.mu.RUnlock()
11321 if ctrl == nil {
11322 return "", nil
11323 }
11324 }
11325 return ctrl.SaveDoc(path, body)
11326 }
11327
11328 // parseScope maps a frontend scope id to a memory.Scope, defaulting to project.
11329 func parseScope(s string) memory.Scope {
11330 switch memory.Scope(s) {
11331 case memory.ScopeUser:
11332 return memory.ScopeUser
11333 case memory.ScopeLocal:
11334 return memory.ScopeLocal
11335 default:
11336 return memory.ScopeProject
11337 }
11338 }
11339
11340 // taskStore is the Store backing the task monitor panel.
11341 func (a *App) taskStore() taskmonitor.WriteStore {
11342 return taskcatalog.ObservedStore()
11343 }
11344
11345 // taskControl returns the process-wide ControlService backing the task
11346 // monitor panel. A single instance keeps control operations serialized within
11347 // this process (across processes the FileStore's per-task lock still
11348 // arbitrates), and avoids re-creating the service on every Wails call.
11349 func (a *App) taskControl() *taskmonitor.ControlService {
11350 a.taskCtrlOnce.Do(func() {
11351 a.taskCtrl = taskmonitor.NewControlService(a.taskStore())
11352 })
11353 return a.taskCtrl
11354 }
11355
11356 func (a *App) projectDir() string {
11357 return a.activeWorkspaceRoot()
11358 }
11359
11360 type taskMonitorTabTarget struct {
11361 projectDir string
11362 sessionDir string
11363 sessionPath string
11364 sessionID string
11365 }
11366
11367 // taskMonitorTargetForTab snapshots the workspace and session identity owned by
11368 // tabID. Wails dispatches bound calls concurrently, so resolving the active tab
11369 // inside a task operation would allow a later tab switch to retarget it.
11370 func (a *App) taskMonitorTargetForTab(tabID string) (taskMonitorTabTarget, error) {
11371 tabID = strings.TrimSpace(tabID)
11372 if tabID == "" {
11373 return taskMonitorTabTarget{}, fmt.Errorf("task monitor tab id is required")
11374 }
11375
11376 a.mu.RLock()
11377 tab := a.tabByIDLocked(tabID)
11378 if tab == nil {
11379 a.mu.RUnlock()
11380 return taskMonitorTabTarget{}, fmt.Errorf("task monitor tab %q is unavailable", tabID)
11381 }
11382 workspaceRoot := strings.TrimSpace(tab.WorkspaceRoot)
11383 tabSessionPath := strings.TrimSpace(tab.SessionPath)
11384 ctrl := tab.Ctrl
11385 leaseKey := tab.sessionLeaseRuntimeKey()
11386 a.mu.RUnlock()
11387
11388 projectDir := workspaceRoot
11389 if projectDir == "" {
11390 projectDir = "."
11391 }
11392 sessionDir := desktopSessionDir(workspaceRoot)
11393 sessionPath := tabSessionPath
11394 if ctrl != nil {
11395 if dir := strings.TrimSpace(ctrl.SessionDir()); dir != "" {
11396 sessionDir = dir
11397 }
11398 if path := strings.TrimSpace(ctrl.SessionPath()); path != "" {
11399 sessionPath = path
11400 }
11401 }
11402 // During a recovery handoff the lease-backed tab path is newer than the
11403 // controller path until the controller commits the handoff.
11404 if tabSessionPath != "" && sessionRuntimeKey(tabSessionPath) == leaseKey {
11405 sessionPath = tabSessionPath
11406 sessionDir = filepath.Dir(tabSessionPath)
11407 } else if ctrl == nil && tabSessionPath != "" {
11408 sessionDir = filepath.Dir(tabSessionPath)
11409 }
11410
11411 target := taskMonitorTabTarget{
11412 projectDir: projectDir,
11413 sessionDir: sessionDir,
11414 sessionPath: sessionPath,
11415 }
11416 if sessionPath != "" {
11417 target.sessionID = agent.BranchID(sessionPath)
11418 }
11419 if id := controllerTaskSessionID(ctrl); id != "" {
11420 target.sessionID = id
11421 }
11422 return target, nil
11423 }
11424
11425 func (a *App) ListTasks() ([]taskmonitor.TaskSnapshot, error) {
11426 return a.taskStore().ListTasks(a.ctx, a.projectDir())
11427 }
11428
11429 // CurrentTaskSessionID returns the stable branch ID for the active desktop
11430 // session. Task Monitor uses this as an optional view filter; an empty value
11431 // means that the active tab has no session controller yet.
11432 func (a *App) CurrentTaskSessionID() string {
11433 _, ctrl := a.activeTabAndCtrl()
11434 if ctrl == nil {
11435 return ""
11436 }
11437 return controllerTaskSessionID(ctrl)
11438 }
11439
11440 // ListTasksForSession limits the project task view to one desktop session.
11441 // The unfiltered ListTasks method remains for compatibility with existing
11442 // callers and project-wide diagnostics.
11443 func (a *App) ListTasksForSession(sessionID string) ([]taskmonitor.TaskSnapshot, error) {
11444 tasks, err := a.ListTasks()
11445 if err != nil || strings.TrimSpace(sessionID) == "" {
11446 return tasks, err
11447 }
11448 return filterTasksBySession(tasks, sessionID), nil
11449 }
11450
11451 // ListTasksForTab returns the task view owned by tabID and filters it to that
11452 // tab's session when one is available. It deliberately avoids active-tab state.
11453 func (a *App) ListTasksForTab(tabID string) ([]taskmonitor.TaskSnapshot, error) {
11454 target, err := a.taskMonitorTargetForTab(tabID)
11455 if err != nil {
11456 return nil, err
11457 }
11458 if target.sessionID == "" {
11459 return []taskmonitor.TaskSnapshot{}, nil
11460 }
11461 tasks, err := a.taskStore().ListTasks(a.ctx, target.projectDir)
11462 if err != nil {
11463 return tasks, err
11464 }
11465 return filterTasksBySession(tasks, target.sessionID), nil
11466 }
11467
11468 func filterTasksBySession(tasks []taskmonitor.TaskSnapshot, sessionID string) []taskmonitor.TaskSnapshot {
11469 filtered := make([]taskmonitor.TaskSnapshot, 0, len(tasks))
11470 for _, task := range tasks {
11471 if task.SessionID == sessionID {
11472 filtered = append(filtered, task)
11473 }
11474 }
11475 return filtered
11476 }
11477
11478 func (a *App) GetTask(taskID string) (*taskmonitor.TaskSnapshot, error) {
11479 return a.taskStore().GetTask(a.ctx, a.projectDir(), taskID)
11480 }
11481
11482 func (a *App) ListTaskEventsForTab(tabID, taskID string, afterSequence int) ([]taskmonitor.TaskEvent, error) {
11483 target, err := a.taskMonitorTargetForTab(tabID)
11484 if err != nil {
11485 return nil, err
11486 }
11487 return a.taskStore().ListEvents(a.ctx, target.projectDir, taskID, afterSequence)
11488 }
11489
11490 func (a *App) StopTask(taskID string, expectedVersion uint64, reason, idemKey string) (taskmonitor.ControlResult, error) {
11491 projectDir := a.projectDir()
11492 return a.taskControl().StopTaskWithKiller(
11493 a.ctx, projectDir, taskID, expectedVersion, reason, idemKey,
11494 desktopTaskJobKiller{app: a, projectDir: projectDir},
11495 )
11496 }
11497
11498 func (a *App) StopTaskForTab(tabID, taskID string, expectedVersion uint64, reason, idemKey string) (taskmonitor.ControlResult, error) {
11499 target, err := a.taskMonitorTargetForTab(tabID)
11500 if err != nil {
11501 return taskmonitor.ControlResult{}, err
11502 }
11503 return a.taskControl().StopTaskWithKiller(
11504 a.ctx, target.projectDir, taskID, expectedVersion, reason, idemKey,
11505 desktopTaskJobKiller{app: a, projectDir: target.projectDir},
11506 )
11507 }
11508
11509 func (a *App) CancelTask(taskID string, expectedVersion uint64, reason, idemKey string) (taskmonitor.ControlResult, error) {
11510 projectDir := a.projectDir()
11511 return a.taskControl().CancelTaskWithKiller(
11512 a.ctx, projectDir, taskID, expectedVersion, reason, idemKey,
11513 desktopTaskJobKiller{app: a, projectDir: projectDir},
11514 )
11515 }
11516
11517 func (a *App) CancelTaskForTab(tabID, taskID string, expectedVersion uint64, reason, idemKey string) (taskmonitor.ControlResult, error) {
11518 target, err := a.taskMonitorTargetForTab(tabID)
11519 if err != nil {
11520 return taskmonitor.ControlResult{}, err
11521 }
11522 return a.taskControl().CancelTaskWithKiller(
11523 a.ctx, target.projectDir, taskID, expectedVersion, reason, idemKey,
11524 desktopTaskJobKiller{app: a, projectDir: target.projectDir},
11525 )
11526 }
11527
11528 func (a *App) RequeueTaskForTab(tabID, taskID string, expectedVersion uint64, idemKey string) (taskmonitor.ControlResult, error) {
11529 target, err := a.taskMonitorTargetForTab(tabID)
11530 if err != nil {
11531 return taskmonitor.ControlResult{}, err
11532 }
11533 return a.taskControl().RequeueTask(a.ctx, target.projectDir, taskID, expectedVersion, idemKey)
11534 }
11535
11536 func (a *App) OpenTaskSessionForTab(tabID, taskID string) (taskmonitor.ControlResult, error) {
11537 target, err := a.taskMonitorTargetForTab(tabID)
11538 if err != nil {
11539 return taskmonitor.ControlResult{}, err
11540 }
11541 return a.taskControl().OpenTaskSession(a.ctx, target.projectDir, taskID)
11542 }
11543
11544 type desktopTaskJobKiller struct {
11545 app *App
11546 projectDir string
11547 }
11548
11549 func (k desktopTaskJobKiller) Kill(sessionID, taskID string) bool {
11550 return k.kill(sessionID, taskID, "", false)
11551 }
11552
11553 func (k desktopTaskJobKiller) KillOwned(sessionID, taskID, ownerID string) bool {
11554 if ownerID == "" {
11555 return false
11556 }
11557 return k.kill(sessionID, taskID, ownerID, true)
11558 }
11559
11560 func (k desktopTaskJobKiller) kill(sessionID, taskID, ownerID string, requireOwner bool) bool {
11561 // Legacy task records without a session ID cannot be routed safely because
11562 // jobs.Manager IDs restart at task-1 for each controller.
11563 if k.app == nil || sessionID == "" || strings.TrimSpace(k.projectDir) == "" {
11564 return false
11565 }
11566 unlockRuntime := k.app.lockRuntimeMutation("stop task")
11567 defer unlockRuntime()
11568
11569 k.app.mu.RLock()
11570 tabs := k.app.runtimeTabsLocked()
11571 controllers := make([]control.SessionAPI, 0, len(tabs))
11572 for _, tab := range tabs {
11573 if tab != nil && tab.Ctrl != nil && sameProjectRoot(tab.WorkspaceRoot, k.projectDir) {
11574 controllers = append(controllers, tab.Ctrl)
11575 }
11576 }
11577 k.app.mu.RUnlock()
11578
11579 for _, ctrl := range controllers {
11580 if controllerTaskSessionID(ctrl) != sessionID {
11581 continue
11582 }
11583 if requireOwner {
11584 owner, ok := ctrl.(interface{ TaskRuntimeOwnerID() string })
11585 if !ok || owner.TaskRuntimeOwnerID() != ownerID {
11586 continue
11587 }
11588 }
11589 if killer, ok := ctrl.(interface{ CancelJob(string) bool }); ok && killer.CancelJob(taskID) {
11590 return true
11591 }
11592 }
11593 return false
11594 }
11595
11596 // onboardingKeyEnv is the default provider (deepseek) key from config.Default().
11597 const onboardingKeyEnv = "DEEPSEEK_API_KEY"
11598
11599 // onboardingBalanceURL doubles as a zero-token connectivity + auth probe:
11600 // billing.FetchWithClient surfaces 401/403 for a bad key.
11601 const onboardingBalanceURL = "https://api.deepseek.com/user/balance"
11602
11603 var connectKeyBalanceFetch = billing.FetchWithClient
11604
11605 // NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation
11606 // dialog that replaces web-style confirm() for destructive or important actions.
11607 type NativeConfirmRequest struct {
11608 Title string `json:"title"`
11609 Message string `json:"message"`
11610 Detail string `json:"detail"`
11611 ConfirmLabel string `json:"confirmLabel"`
11612 CancelLabel string `json:"cancelLabel"`
11613 Destructive bool `json:"destructive"`
11614 }
11615
11616 // ConfirmAction shows a native confirmation dialog and returns true when the user
11617 // clicks the confirm button. For destructive actions the dialog type is Warning so
11618 // the platform can apply its danger styling (red tint on macOS, etc.).
11619 func (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) {
11620 if a.ctx == nil {
11621 return false, nil
11622 }
11623 dialogType := nativeDialogQuestion
11624 if req.Destructive {
11625 dialogType = nativeDialogWarning
11626 }
11627 confirm := req.ConfirmLabel
11628 if confirm == "" {
11629 confirm = "OK"
11630 }
11631 cancel := req.CancelLabel
11632 if cancel == "" {
11633 cancel = "Cancel"
11634 }
11635 title := req.Title
11636 if title == "" {
11637 title = req.Message
11638 }
11639 body := req.Message
11640 if req.Detail != "" {
11641 if body != "" {
11642 body += "\n\n" + req.Detail
11643 } else {
11644 body = req.Detail
11645 }
11646 }
11647 defaultBtn := confirm
11648 if req.Destructive {
11649 // On destructive actions, make cancel the default so Enter / Space
11650 // does NOT accidentally confirm. ESC always maps to CancelButton.
11651 defaultBtn = cancel
11652 }
11653 result, err := a.nativeHost().MessageDialog(a.ctx, nativeMessageOptions{
11654 Type: dialogType,
11655 Title: title,
11656 Message: body,
11657 Buttons: []string{confirm, cancel},
11658 DefaultButton: defaultBtn,
11659 CancelButton: cancel,
11660 })
11661 if err != nil {
11662 return false, err
11663 }
11664 return result == confirm, nil
11665 }
11666
11667 func (a *App) NeedsOnboarding() bool {
11668 cfg, err := config.LoadForRootReadOnly(a.activeWorkspaceRoot())
11669 if err != nil {
11670 // Configuration errors already surface through the startup error banner.
11671 // Do not cover their recovery path with an onboarding gate.
11672 return false
11673 }
11674 for i := range cfg.Providers {
11675 p := &cfg.Providers[i]
11676 if !modelProviderAccessAllowed(cfg.Desktop.ProviderAccess, p.Name) || !p.Configured() || len(p.ChatModelList()) == 0 {
11677 continue
11678 }
11679 return false
11680 }
11681 return true
11682 }
11683
11684 // ConnectKey validates apiKey against the balance endpoint, persists it to
11685 // Reasonix's global .env, and rebuilds the controller so the new key takes effect.
11686 func (a *App) ConnectKey(apiKey string) (string, error) {
11687 apiKey = strings.TrimSpace(apiKey)
11688 if apiKey == "" {
11689 return "", fmt.Errorf("key is required")
11690 }
11691 ctx, cancel := context.WithTimeout(a.reqCtx(), 8*time.Second)
11692 defer cancel()
11693 if _, err := connectKeyBalanceFetch(ctx, nil, onboardingBalanceURL, apiKey); err != nil {
11694 return "", fmt.Errorf("validate: %w", err)
11695 }
11696 return a.AddOfficialProviderAccess("deepseek", apiKey)
11697 }
11698
11698 lines GO