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