package main import ( "bytes" "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log/slog" "mime" "net/http" "net/url" "os" "os/exec" "path/filepath" goruntime "runtime" "sort" "strconv" "strings" "sync" "sync/atomic" "time" "unicode/utf8" "github.com/wailsapp/wails/v2/pkg/runtime" "reasonix/internal/agent" "reasonix/internal/autoresearch" "reasonix/internal/billing" "reasonix/internal/boot" "reasonix/internal/botruntime" "reasonix/internal/config" "reasonix/internal/control" "reasonix/internal/event" "reasonix/internal/evidence" "reasonix/internal/fileref" fileenc "reasonix/internal/fileutil/encoding" "reasonix/internal/i18n" "reasonix/internal/mcpdiag" "reasonix/internal/memory" "reasonix/internal/notify" "reasonix/internal/plugin" "reasonix/internal/pluginpkg" "reasonix/internal/provider" "reasonix/internal/skill" "reasonix/internal/store" "reasonix/internal/tool" ) // eventChannel is the Wails runtime event name the frontend subscribes to for the // agent's typed event stream. One channel carries every event kind; the payload's // `kind` field discriminates — the desktop analogue of the serve transport's SSE // `data:` frames. const eventChannel = "agent:event" const singleInstanceIDPrefix = "com.reasonix.desktop" // singleInstanceID is used by Wails to route a second desktop launch back to the // running instance. It is stable for a given binary path, while allowing a dev // build and an installed release at different paths to run side by side. func singleInstanceID() string { abs, err := os.Executable() if err != nil { return singleInstanceIDPrefix } if resolved, err := filepath.EvalSymlinks(abs); err == nil { abs = resolved } else if fallback, err := filepath.Abs(abs); err == nil { abs = fallback } sum := sha256.Sum256([]byte(abs)) return singleInstanceIDPrefix + "." + hex.EncodeToString(sum[:8]) } // PromptHistoryEntry is one user prompt extracted from a session JSONL file. // The frontend uses these for ↑/↓ prompt-history navigation. type PromptHistoryEntry struct { Text string `json:"text"` At int64 `json:"at"` // unix ms SessionPath string `json:"sessionPath"` Turn int `json:"turn"` } // PromptHistoryResult is returned as one Wails value. It carries one loaded tape // segment plus the cursor needed to keep walking toward older prompts. type PromptHistoryResult struct { Entries []PromptHistoryEntry `json:"entries"` Nonce string `json:"nonce"` OlderCursor string `json:"olderCursor,omitempty"` HasOlder bool `json:"hasOlder"` } // App is the Wails-bound application object: the desktop frontend's command // surface. Its exported methods (Submit/Cancel/Approve/…) are generated into JS // bindings. The app manages multiple WorkspaceTabs — each with its own controller // scoped to a project workspace — and routes commands to the active tab. Events // flow the other way: each tab's controller emits to a tabEventSink that // forwards events tagged with tabId to the webview via runtime.EventsEmit. type App struct { ctx context.Context // mu protects the tab map, tabOrder, activeTabID, and per-tab fields that are read // from bound methods. All bound methods that touch a controller use activeCtrl(). mu sync.RWMutex tabs map[string]*WorkspaceTab tabOrder []string activeTabID string readyHook func() // tabsRestored is closed when restoreOrBuildTabs has finished populating // a.tabs from desktop-tabs.json (or built the first-launch tab). Startup // work that inspects "which sessions are open" or persists the tab list // (recovery GC's DeleteSession does both) must wait on it: running against // the pre-restore empty tab map would treat every saved tab's session as // closed and could overwrite desktop-tabs.json with an empty snapshot. tabsRestored chan struct{} // projectTreeChangedHook is test-only: set once before any concurrency // starts, then read lock-free from emitProjectTreeChanged (whose callers // may or may not hold a.mu, so it cannot re-lock). Never write it after // startup. projectTreeChangedHook func() // singleSurfaceMu serializes open/reuse plus visible-tab pruning for the // one-conversation layout so overlapping navigation cannot remove the tab // another navigation is still activating. singleSurfaceMu sync.Mutex // sessionRemovalMu serializes operations that remove visible or detached // session bindings. Those operations may snapshot controllers before // deletion; keep that snapshot outside a.mu, but do not let DeleteSession or // topic/workspace removal trash the same files while it is in flight. sessionRemovalMu sync.Mutex // runtimeRebuildMu serializes controller rebuilds (build + swap) across // rebuildSetting, SetModelForTab, and the deferred-rebuild retry loop. Two // concurrent rebuilds of the same tab both pass the tab-identity check at // swap time (the tab pointer is unchanged), so the loser's controller // would replace the winner's and leak it without Close. runtimeRebuildMu sync.Mutex // tryRunMu guards tryRunCancel — the cancel handle for the single // in-flight settings-page subagent try run (TrySubagentProfile / // CancelTrySubagentProfile). tryRunMu sync.Mutex tryRunCancel context.CancelFunc // deferredRebuild tracks tabs whose settings were saved but whose runtime // could not refresh because the session lease was held by another process. deferredRebuild deferredRebuildState // detachedSessions keeps live session runtimes whose visible tab was closed. // It is process-local by design: shutdown closes every detached controller. detachedSessions map[string]*WorkspaceTab // sharedHosts holds one *plugin.Host per workspace root, shared by all // controllers/tabs in that root so MCP subprocesses (CodeGraph, etc.) are // spawned once instead of N times. Lifecycle: first Acquire creates the // host, last Release closes it. sharedHosts map[string]*sharedPluginHost sharedHostsMu sync.Mutex // tabsSaveMu serializes writes to desktop-tabs.json and its fixed .tmp path. tabsSaveMu sync.Mutex tabsSaveVersion uint64 // protected by mu; assigned when collecting a snapshot tabsLastWrittenVersion uint64 // protected by tabsSaveMu forceQuit atomic.Bool backgroundMaximised atomic.Bool trayReady bool tray *desktopTray hangWatchdogMu sync.Mutex hangWatchdogCancel context.CancelFunc mediaTokens *mediaTokenStore botInstalls map[string]*botInstallSession botRuntime *desktopBotRuntime // botBridge gives the embedded bot gateway a god view over desktop // sessions (/desktop commands). Set once in NewApp before any tab exists, // read-only afterwards, so tabEventSink.Emit reads it without a lock. botBridge *botBridgeHub metrics atomic.Pointer[metricsAggregator] // non-nil only when desktop.metrics is opted in; swapped live by SetDesktopMetrics notificationSenderOnce sync.Once notificationSender notify.Sender runtimeEvents asyncRuntimeEmitter // promptHistoryTape is a lazy, cursor-addressed view of prompt history. It // stores session order and per-session parsed entries only after that session is // reached by ↑ navigation. See ScanPromptHistory. promptHistoryMu sync.Mutex promptHistoryTape *promptHistoryTape skillRootsMu sync.Mutex skillRootsCache skillRootsCache heartbeat *HeartbeatEngine // scheduled heartbeat tasks; nil until startup } type skillRootsCache struct { key string at time.Time roots []SkillRootView } // mediaTokenEntry holds metadata for a workspace media file served via temporary URL. type mediaTokenEntry struct { absPath string filename string mime string kind string size int64 modTime time.Time createdAt time.Time expiresAt time.Time } // mediaTokenStore manages temporary tokens that grant access to workspace files // through the AssetServer middleware. Tokens expire after a fixed TTL and are // capped at a maximum count; creating a new token evicts the oldest entry when // the store is full. type mediaTokenStore struct { mu sync.Mutex byTok map[string]*mediaTokenEntry order []string // oldest first maxN int ttl time.Duration } const mediaTokenMax = 256 func newMediaTokenStore() *mediaTokenStore { return &mediaTokenStore{ byTok: map[string]*mediaTokenEntry{}, maxN: mediaTokenMax, ttl: 10 * time.Minute, } } func (s *mediaTokenStore) cleanupLocked() { now := time.Now() for len(s.order) > 0 { tok := s.order[0] e := s.byTok[tok] if e == nil { s.order = s.order[1:] continue } if !now.Before(e.expiresAt) { delete(s.byTok, tok) s.order = s.order[1:] continue } break } for len(s.order) > s.maxN { oldest := s.order[0] delete(s.byTok, oldest) s.order = s.order[1:] } } func (s *mediaTokenStore) create(absPath, filename, mime, kind string, size int64, modTime time.Time) string { s.mu.Lock() defer s.mu.Unlock() s.cleanupLocked() tok := make([]byte, 16) if _, err := rand.Read(tok); err != nil { panic("crypto/rand.Read failed: " + err.Error()) } token := hex.EncodeToString(tok) now := time.Now() s.byTok[token] = &mediaTokenEntry{ absPath: absPath, filename: filename, mime: mime, kind: kind, size: size, modTime: modTime, createdAt: now, expiresAt: now.Add(s.ttl), } s.order = append(s.order, token) // Trim oldest if the new token pushed us over the limit. for len(s.order) > s.maxN { oldest := s.order[0] delete(s.byTok, oldest) s.order = s.order[1:] } return token } func (s *mediaTokenStore) get(token string) *mediaTokenEntry { s.mu.Lock() defer s.mu.Unlock() e := s.byTok[token] if e == nil { return nil } if time.Now().After(e.expiresAt) { delete(s.byTok, token) return nil } return e } func (a *App) ensureMediaTokenStore() *mediaTokenStore { a.mu.Lock() defer a.mu.Unlock() if a.mediaTokens == nil { a.mediaTokens = newMediaTokenStore() } return a.mediaTokens } // jsProfilingMiddleware opts every asset response into the JS Self-Profiling // document policy so the frontend performance monitor can attach sampled stacks // to long-task reports. Chromium WebViews (WebView2) honor it; WebKit ignores // both the header and the API, so the frontend degrades to unattributed reports. func (a *App) jsProfilingMiddleware() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Document-Policy", "js-profiling") next.ServeHTTP(w, r) }) } } // workspaceMediaMiddleware returns an HTTP middleware that intercepts // /__reasonix_workspace_media/{token}/{filename} requests and serves the // corresponding workspace file. All other paths pass through to the Wails // default asset handler unchanged. func (a *App) workspaceMediaMiddleware() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { prefix := "/__reasonix_workspace_media/" if !strings.HasPrefix(r.URL.Path, prefix) { next.ServeHTTP(w, r) return } if r.Method != http.MethodGet && r.Method != http.MethodHead { w.WriteHeader(http.StatusMethodNotAllowed) return } rest := strings.TrimPrefix(r.URL.Path, prefix) parts := strings.SplitN(rest, "/", 2) if len(parts) == 0 || parts[0] == "" { http.NotFound(w, r) return } token := parts[0] entry := a.ensureMediaTokenStore().get(token) if entry == nil { http.NotFound(w, r) return } f, err := os.Open(entry.absPath) if err != nil { http.NotFound(w, r) return } defer f.Close() w.Header().Set("Content-Type", entry.mime) w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": entry.filename})) w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Cache-Control", "private, max-age=600") http.ServeContent(w, r, entry.filename, entry.modTime, f) }) } } // NewApp constructs the bound object. Tabs are restored in startup from the // last session's desktop-tabs.json. func NewApp() *App { a := &App{ tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}, mediaTokens: newMediaTokenStore(), botInstalls: map[string]*botInstallSession{}, botRuntime: newDesktopBotRuntime(), } a.botBridge = a.newBotBridge() return a } func (a *App) bootContext() context.Context { if a.ctx != nil { return a.ctx } return context.Background() } // Platform exposes the native OS to the frontend so chrome/layout affordances can // stay platform-scoped instead of relying on browser user-agent guesses. func (a *App) Platform() string { return goruntime.GOOS } // startup runs once the webview process is up, before the frontend can issue any // bound call. It captures the Wails context (needed for EventsEmit), then kicks // off the initialization in a background goroutine so the webview loads immediately. func (a *App) startup(ctx context.Context) { a.ctx = ctx installSystemQuitHook() a.startTray() a.enableDeferredRebuildRetry() if cfg, err := config.Load(); err == nil && cfg.DesktopMetrics() && version != "dev" { a.metrics.Store(newMetricsAggregator(config.MemoryUserDir())) a.recordSettingsMetricsSnapshot(cfg) } a.startMainThreadWatchdog() a.heartbeat = newHeartbeatEngine(a) a.heartbeat.Start() a.mu.Lock() a.tabsRestored = make(chan struct{}) a.mu.Unlock() go a.restoreOrBuildTabs() a.goSafe("refreshBotRuntime", a.refreshBotRuntime) a.goSafe("sendStartupPing", a.sendStartupPing) a.goSafe("flushMetrics", a.flushMetrics) a.goSafe("flushPendingCrash", a.flushPendingCrash) // After restoreOrBuildTabs is launched: the GC's first sweep waits on // tabsRestored so it never observes the pre-restore empty tab map. a.startRecoveryGC() } func (a *App) beforeClose(ctx context.Context) bool { if a.forceQuit.Swap(false) || consumeSystemQuitRequested() { return false } cfg, _, err := a.loadDesktopUserConfigForView() if err != nil { cfg = config.LoadForEdit(config.UserConfigPath()) } if cfg.DesktopCloseBehavior() == "background" { if !a.backgroundCloseHasRestorePath() { return false } a.backgroundMaximised.Store(runtime.WindowIsMaximised(ctx)) a.saveWindowStateSync() a.snapshotAllTabs() hideForBackground(ctx) return true } return false } const backgroundCloseTrayReadyTimeout = 500 * time.Millisecond func (a *App) backgroundCloseHasRestorePath() bool { if backgroundCloseUsesApplicationHide(goruntime.GOOS) { return backgroundCloseHasRestorePathFor(goruntime.GOOS, false, false) } if !a.startTray() { return false } return backgroundCloseHasRestorePathFor(goruntime.GOOS, true, a.waitForTrayReady(backgroundCloseTrayReadyTimeout)) } func (a *App) waitForTrayReady(timeout time.Duration) bool { if a.isTrayReady() { return true } ready := a.trayReadySignal() if ready == nil { return false } if timeout <= 0 { select { case <-ready: return a.isTrayReady() default: return false } } timer := time.NewTimer(timeout) defer timer.Stop() select { case <-ready: return a.isTrayReady() case <-timer.C: return a.isTrayReady() } } func (a *App) isTrayReady() bool { a.mu.RLock() defer a.mu.RUnlock() return a.trayReady } func (a *App) trayReadySignal() <-chan struct{} { a.mu.RLock() defer a.mu.RUnlock() if a.tray == nil { return nil } return a.tray.ready } // markTabsRestored closes the tabsRestored gate exactly once. Safe when the // channel was never created (tests that drive App without startup). func (a *App) markTabsRestored() { a.mu.Lock() defer a.mu.Unlock() if a.tabsRestored == nil { return } select { case <-a.tabsRestored: default: close(a.tabsRestored) } } // tabsRestoredSignal returns a channel closed once tab restore has completed. // When startup never armed the gate (tests), it reports already-restored. func (a *App) tabsRestoredSignal() <-chan struct{} { a.mu.RLock() defer a.mu.RUnlock() if a.tabsRestored == nil { closed := make(chan struct{}) close(closed) return closed } return a.tabsRestored } func (a *App) showMainWindow() { if a.ctx != nil { showFromBackground(a.ctx, a.backgroundMaximised.Swap(false)) a.kickDeferredRebuildRetry() } } func (a *App) secondInstanceLaunch() { a.showMainWindow() } func (a *App) quitApp() { if a.ctx == nil { return } a.forceQuit.Store(true) runtime.Quit(a.ctx) } func hideForBackground(ctx context.Context) { if backgroundCloseUsesApplicationHide(goruntime.GOOS) { runtime.Hide(ctx) return } runtime.WindowHide(ctx) } func showFromBackground(ctx context.Context, wasMaximised bool) { if backgroundCloseUsesApplicationHide(goruntime.GOOS) { runtime.Show(ctx) } plan := backgroundRestorePlanFor(goruntime.GOOS, wasMaximised) if plan.maximiseBeforeShow { runtime.WindowMaximise(ctx) } runtime.WindowShow(ctx) if plan.unminimiseAfterShow { runtime.WindowUnminimise(ctx) } } func backgroundCloseUsesApplicationHide(goos string) bool { return goos == "darwin" } func backgroundCloseHasRestorePathFor(goos string, trayStarted, trayReady bool) bool { return backgroundCloseUsesApplicationHide(goos) || (trayStarted && trayReady) } type backgroundRestorePlan struct { maximiseBeforeShow bool unminimiseAfterShow bool } func backgroundRestorePlanFor(goos string, wasMaximised bool) backgroundRestorePlan { if backgroundRestoreShouldMaximise(goos, wasMaximised) { return backgroundRestorePlan{maximiseBeforeShow: true} } return backgroundRestorePlan{unminimiseAfterShow: true} } func backgroundRestoreShouldMaximise(goos string, wasMaximised bool) bool { return wasMaximised && !backgroundCloseUsesApplicationHide(goos) } // restoreOrBuildTabs restores the tabs from the last session, or creates a // default Global tab on first launch. func (a *App) restoreOrBuildTabs() { defer a.recoverToPending("restoreOrBuildTabs") // Unblock startup work gated on the restore (recovery GC) no matter how // this returns — including the recover path above. defer a.markTabsRestored() // Reap any orphaned codegraph processes from a previous crash or older // version that leaked them, so they don't accumulate across restarts. a.reapOrphanCodeGraph() ctx := a.ctx ensureWorkspace() // Run legacy config migration before the first config load so the // freshly written config (including the user's default_model) is // picked up by Load instead of falling back to built-in defaults. _, _ = config.MigrateLegacyIfNeeded() f := loadTabsFile() _, _ = recoverLegacyProjectSidebarRoots(f) _, _ = config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath()) _, _ = config.MigrateMCPToUserConfigOnUpgrade(desktopMCPMigrationRoots(f)) // Load i18n from the first available config. // Prefer DesktopLanguage (desktop UI setting) over Language (CLI setting), // so the user's language choice in desktop settings takes effect. startupCfg, cfgErr := config.Load() if cfgErr == nil { cfg := startupCfg lang := cfg.DesktopLanguage() if lang == "" { lang = cfg.Language } i18n.DetectLanguage(lang) } if cfgErr != nil || singleSurfaceLayoutStyle(startupCfg.DesktopLayoutStyle()) { f = singleSurfaceTabsFile(f) } if len(f.Tabs) > 0 { toBuild := make([]*WorkspaceTab, 0, len(f.Tabs)) for _, entry := range f.Tabs { a.mu.Lock() id := a.restoredTabIDLocked(entry.ID) a.mu.Unlock() var tab *WorkspaceTab if entry.Scope == "project" { tab = a.createTabEntryWithID(entry.Scope, entry.WorkspaceRoot, entry.TopicID, id) } else { tab = a.createTabEntryWithID("global", globalTabWorkspaceRoot(), entry.TopicID, id) } tab.model = entry.Model tab.effort = cloneStringPtr(entry.Effort) tab.tokenMode = boot.NormalizeTokenMode(entry.TokenMode) tab.mode = persistedTabMode(entry.Mode) // Validate the persisted goal against the session's goal-state // sidecar: a typed /new or /clear rotates the session through the // controller without passing App.NewSession/ClearSession, so // entry.Goal can be stale. Session rotation writes a stopped // goal-state onto the fresh path; reading it here stops a restart // from re-seeding the cleared goal into the rotated session. A // session without a sidecar keeps the persisted goal (legacy). tab.goal = runningTabSessionGoal(strings.TrimSpace(entry.SessionPath), strings.TrimSpace(entry.Goal)) tab.toolApprovalMode = normalizeToolApprovalMode(entry.ToolApprovalMode) if tab.toolApprovalMode == control.ToolApprovalAsk && tabModeHasAutoApproveTools(entry.Mode) { tab.toolApprovalMode = control.ToolApprovalYolo } tab.SessionPath = strings.TrimSpace(entry.SessionPath) tab.ReadOnly = entry.ReadOnly tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx} a.mu.Lock() a.tabs[tab.ID] = tab a.tabOrder = append(a.tabOrder, tab.ID) a.mu.Unlock() toBuild = append(toBuild, tab) } a.mu.Lock() if _, ok := a.tabs[f.ActiveTab]; ok { a.activeTabID = f.ActiveTab } else { ordered := a.orderedTabIDsLocked() if len(ordered) > 0 { a.activeTabID = ordered[0] } } a.saveTabsLocked() a.mu.Unlock() for _, tab := range toBuild { a.startTabControllerBuild(tab) } return } // First launch: create a default Global tab. tab := a.createTabEntry("global", globalTabWorkspaceRoot(), "") tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: ctx} tab.TopicTitle = "Global" a.mu.Lock() a.tabs[tab.ID] = tab a.tabOrder = append(a.tabOrder, tab.ID) a.activeTabID = tab.ID a.mu.Unlock() a.startTabControllerBuild(tab) } func (a *App) createTabEntry(scope, workspaceRoot, topicID string) *WorkspaceTab { return a.createTabEntryWithID(scope, workspaceRoot, topicID, newTabID()) } func desktopNewSessionDefaults() (string, string) { cfg := config.LoadForEdit(config.UserConfigPath()) return strings.TrimSpace(cfg.DefaultModel), normalizeToolApprovalMode(cfg.DesktopDefaultToolApprovalMode()) } func (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *WorkspaceTab { model, toolApprovalMode := desktopNewSessionDefaults() return &WorkspaceTab{ ID: id, Scope: scope, WorkspaceRoot: workspaceRoot, TopicID: topicID, TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID), model: model, tokenMode: boot.TokenModeFull, mode: tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo), toolApprovalMode: toolApprovalMode, disabledMCP: map[string]ServerView{}, } } func (a *App) snapshotAllTabs() { a.mu.RLock() tabs := a.runtimeTabsLocked() a.mu.RUnlock() for _, t := range tabs { if err := a.snapshotTab(t); err != nil { slog.Warn("desktop: snapshot all tabs failed", "tab", t.ID, "err", err) } } } // shutdown snapshots all tabs, saves the final window geometry, and closes tabs. func (a *App) shutdown(context.Context) { a.stopDeferredRebuildRetry() a.stopMainThreadWatchdog() if a.heartbeat != nil { a.heartbeat.Stop() } a.stopBotRuntime() a.stopTray() // Save window geometry synchronously from Go so it's persisted even if the // frontend's beforeunload promise hasn't resolved yet. a.saveWindowStateSync() // Close every shared plugin host on exit, even if a tab cleanup panics. defer a.closeAllSharedHosts() a.mu.RLock() tabs := a.runtimeTabsLocked() type shutdownItem struct { tab *WorkspaceTab ctrl control.SessionAPI } items := make([]shutdownItem, 0, len(tabs)) for _, t := range tabs { if t.Ctrl != nil { items = append(items, shutdownItem{tab: t, ctrl: t.Ctrl}) } } a.mu.RUnlock() for _, it := range items { if err := a.snapshotTab(it.tab); err != nil { slog.Warn("desktop: shutdown snapshot failed", "tab", it.tab.ID, "err", err) } it.ctrl.Close() it.tab.releaseSessionLease() } } // domReady is called (via OnDomReady) after the webview finishes loading its DOM // but before the window is shown (StartHidden). It restores the saved window // position and size, then calls WindowShow so the user never sees the default // size/position flash. func (a *App) domReady(_ context.Context) { state, ok := loadWindowState() if ok { // Validate saved position against current screens. Wails v2 doesn't // expose per-screen origin (x,y offsets) so we can only do a basic // sanity check: ensure the window origin falls within a generous // estimate of the screen area. If the user unplugged an external // display, negative or out-of-bounds coordinates are caught here. valid := state.X >= 0 && state.Y >= 0 if valid { screens, err := runtime.ScreenGetAll(a.ctx) if err == nil && len(screens) > 0 { maxW, maxH := 0, 0 for _, sc := range screens { if sc.Size.Width > maxW { maxW = sc.Size.Width } if sc.Size.Height > maxH { maxH = sc.Size.Height } } if state.X > maxW*2 || state.Y > maxH*2 { valid = false } } } if valid { runtime.WindowSetPosition(a.ctx, state.X, state.Y) } else { runtime.WindowCenter(a.ctx) } } else { runtime.WindowCenter(a.ctx) } if ok && state.Maximised { runtime.WindowMaximise(a.ctx) } runtime.WindowShow(a.ctx) } // --- bound command surface (frontend → controller) --- // Each method guards on a nil controller so a pre-startup or failed-build call is // a no-op, never a panic. // Submit runs raw user input as a turn; slash commands and @-references are // resolved by the controller. Output arrives asynchronously on eventChannel. func (a *App) Submit(input string) error { return a.SubmitToTab("", input) } func (a *App) SubmitToTab(tabID, input string) error { return a.submitToTab(tabID, input, false) } // beginTabTurn locks the tab's foreground-turn admission gate and reserves the // event sink until TurnDone has completed all of its fan-out. The caller must // call finishTabTurnStart exactly once after invoking the controller. func (a *App) beginTabTurn(tabID string, reclaim bool) (*WorkspaceTab, control.SessionAPI, error) { tab, ctrl := a.tabAndCtrlByID(tabID) if a.tabIsReadOnly(tab) { return nil, nil, readOnlyChannelErr() } if tab == nil || ctrl == nil { return nil, nil, a.workspaceNotReadyErr(tab) } tab.turnStartMu.Lock() if a.tabIsReadOnly(tab) { tab.turnStartMu.Unlock() return nil, nil, readOnlyChannelErr() } if reclaim && a.botBridge != nil { a.botBridge.reclaimFromDesktop(tab.ID) } ctrl = a.controllerForTab(tab) if ctrl == nil { tab.turnStartMu.Unlock() return nil, nil, a.workspaceNotReadyErr(tab) } if err := a.ensureTabControllerWorkspace(tab); err != nil { tab.turnStartMu.Unlock() return nil, nil, err } ctrl = a.controllerForTab(tab) if ctrl == nil { tab.turnStartMu.Unlock() return nil, nil, a.workspaceNotReadyErr(tab) } if ctrl.RuntimeStatus().Running || (tab.sink != nil && !tab.sink.tryBeginTurn()) { tab.turnStartMu.Unlock() return nil, nil, control.ErrTurnRunning } return tab, ctrl, nil } func (a *App) finishTabTurnStart(tab *WorkspaceTab, ctrl control.SessionAPI) bool { started := ctrl != nil && ctrl.RuntimeStatus().Running if !started && tab != nil && tab.sink != nil { tab.sink.cancelTurnStart() } if tab != nil { tab.turnStartMu.Unlock() } return started } // submitToTab is the shared submit body. fromBridge marks submissions driven // by the IM takeover bridge; local (frontend) submissions on a taken-over tab // reclaim remote control first — typing locally is the grab-back gesture. func (a *App) submitToTab(tabID, input string, fromBridge bool) error { trimmed := strings.TrimSpace(input) if trimmed == "/effort" || strings.HasPrefix(trimmed, "/effort ") { tab, _ := a.tabAndCtrlByID(tabID) if a.tabIsReadOnly(tab) { return readOnlyChannelErr() } if tab == nil { return a.workspaceNotReadyErr(tab) } tab.turnStartMu.Lock() defer tab.turnStartMu.Unlock() if !fromBridge && a.botBridge != nil { a.botBridge.reclaimFromDesktop(tab.ID) } a.runEffortCommandForTab(tabID, trimmed) return nil } tab, ctrl, err := a.beginTabTurn(tabID, !fromBridge) if err != nil { return err } a.ensureTabTopicIndexedForUserTurn(tab) ctrl.SubmitDisplay(input, input) a.finishTabTurnStart(tab, ctrl) return nil } func (a *App) submitUserTurnToTabWithSink(tabID, input string, forwarder event.Sink) bool { tab, ctrl, err := a.beginTabTurn(tabID, false) if err != nil { return false } var generation uint64 if forwarder != nil { generation = tab.sink.SetBotSink(forwarder) } a.ensureTabTopicIndexedForUserTurn(tab) ctrl.SubmitUserTurn(input, input) started := a.finishTabTurnStart(tab, ctrl) if !started && forwarder != nil { tab.sink.clearBotSink(generation) } return started } // RunShell executes a shell command directly (bypassing the model) and streams // output as events on eventChannel. func (a *App) RunShell(command string) error { return a.RunShellForTab("", command) } func (a *App) RunShellForTab(tabID, command string) error { tab, ctrl, err := a.beginTabTurn(tabID, true) if err != nil { return err } a.ensureTabTopicIndexedForUserTurn(tab) ctrl.RunShell(command) a.finishTabTurnStart(tab, ctrl) return nil } // SubmitDisplay runs input as a turn while recording a shorter UI-only display // string for the saved desktop transcript. The model still receives input. func (a *App) SubmitDisplay(display, input string) error { return a.SubmitDisplayToTab("", display, input) } func (a *App) SubmitDisplayToTab(tabID, display, input string) error { tab, ctrl, err := a.beginTabTurn(tabID, true) if err != nil { return err } a.ensureTabTopicIndexedForUserTurn(tab) ctrl.SubmitDisplay(display, input) a.finishTabTurnStart(tab, ctrl) return nil } // InvocationRequest is the Wails-bound form of a composer invocation entity. type InvocationRequest struct { Name string `json:"name"` Kind string `json:"kind"` Offset int `json:"offset"` } func (a *App) SubmitInvocationsToTab(tabID, display, input string, invocations []InvocationRequest) error { tab, ctrl, err := a.beginTabTurn(tabID, true) if err != nil { return err } a.ensureTabTopicIndexedForUserTurn(tab) requests := make([]control.InvocationRequest, 0, len(invocations)) for _, invocation := range invocations { requests = append(requests, control.InvocationRequest{ Name: invocation.Name, Kind: invocation.Kind, Offset: invocation.Offset, }) } ctrl.SubmitInvocationDisplay(display, input, requests) a.finishTabTurnStart(tab, ctrl) return nil } func (a *App) SubmitEditedDisplayToTab(tabID, display, input, original string) error { tab, ctrl, err := a.beginTabTurn(tabID, true) if err != nil { return err } a.ensureTabTopicIndexedForUserTurn(tab) ctrl.SubmitEditedDisplay(display, input, original) a.finishTabTurnStart(tab, ctrl) return nil } func (a *App) bindControllerDisplayRecorder(ctrl control.SessionAPI) { if ctrl == nil { return } ctrl.SetDisplayRecorder(func(content, display string) { dir := ctrl.SessionDir() if dir == "" { dir = config.SessionDir() } _ = recordSessionDisplay(dir, ctrl.SessionPath(), content, display) }) } // Cancel aborts the in-flight turn. func (a *App) Cancel() { a.CancelTab("") } func (a *App) CancelTab(tabID string) { if ctrl := a.ctrlByTabID(tabID); ctrl != nil { ctrl.Cancel() } } // Steer sends mid-turn guidance to the agent without interrupting the in-flight request. func (a *App) Steer(text string) error { return a.SteerForTab("", text) } // SteerForTab sends mid-turn guidance to a specific tab's agent. func (a *App) SteerForTab(tabID, text string) error { tab, ctrl := a.tabAndCtrlByID(tabID) if a.tabIsReadOnly(tab) { return readOnlyChannelErr() } if ctrl == nil { return a.workspaceNotReadyErr(tab) } if err := a.ensureTabControllerWorkspace(tab); err != nil { return err } ctrl = a.controllerForTab(tab) if ctrl == nil { return a.workspaceNotReadyErr(tab) } ctrl.Steer(text) return nil } func (a *App) tabAndCtrlByID(tabID string) (*WorkspaceTab, control.SessionAPI) { a.mu.RLock() tab := a.tabByIDLocked(tabID) if tab == nil { a.mu.RUnlock() return nil, nil } ctrl := tab.Ctrl retryStartup := ctrl == nil && tab.StartupErrLeaseHeld a.mu.RUnlock() if retryStartup && a.tryRecoverStartupLeaseHeldTab(tab) { a.mu.RLock() defer a.mu.RUnlock() if a.tabs[tab.ID] != tab { return nil, nil } return tab, tab.Ctrl } return tab, ctrl } // activeTabAndCtrl snapshots the active tab and its controller in one locked // read, so callers never do a check-then-use on tab.Ctrl after the lock is // released (a rebuild can swap the controller in between). func (a *App) activeTabAndCtrl() (*WorkspaceTab, control.SessionAPI) { a.mu.RLock() defer a.mu.RUnlock() tab := a.activeTabLocked() if tab == nil { return nil, nil } return tab, tab.Ctrl } func (a *App) controllerForTab(tab *WorkspaceTab) control.SessionAPI { if tab == nil { return nil } a.mu.RLock() defer a.mu.RUnlock() if tab.ID != "" && a.tabs[tab.ID] != tab { return nil } return tab.Ctrl } // currentSessionPathFor is the locked form of tab.currentSessionPath: it // snapshots Ctrl/SessionPath under a.mu, then queries the controller off-lock. // Use it on paths that do not otherwise hold a.mu. func (a *App) currentSessionPathFor(tab *WorkspaceTab) string { if tab == nil { return "" } a.mu.RLock() ctrl := tab.Ctrl fallback := strings.TrimSpace(tab.SessionPath) a.mu.RUnlock() if ctrl != nil { if path := strings.TrimSpace(ctrl.SessionPath()); path != "" { return path } } return fallback } // sessionDirForSnapshot mirrors tabSessionDir for callers that hold a // tabRuntimeSnapshot instead of reading the live tab. func sessionDirForSnapshot(s tabRuntimeSnapshot) string { if s.workspaceRoot != "" { return desktopSessionDir(s.workspaceRoot) } if s.ctrl != nil { if dir := s.ctrl.SessionDir(); dir != "" { return dir } } return desktopSessionDir("") } func readOnlyChannelErr() error { return fmt.Errorf("channel session is read-only") } func (a *App) snapshotTab(tab *WorkspaceTab) error { if tab == nil { return nil } a.mu.RLock() readOnly := tab.ReadOnly ctrl := tab.Ctrl a.mu.RUnlock() if readOnly || ctrl == nil { return nil } return ctrl.Snapshot() } func (a *App) snapshotTabForAction(tab *WorkspaceTab, action string) error { if err := a.snapshotTab(tab); err != nil { a.reportTabSnapshotError(tab, action, err) if strings.TrimSpace(action) == "" { return fmt.Errorf("save current session: %w", err) } return fmt.Errorf("save current session before %s: %w", action, err) } return nil } func (a *App) reportTabSnapshotError(tab *WorkspaceTab, action string, err error) { if err == nil { return } tabID := "" if tab != nil { tabID = tab.ID } slog.Warn("desktop: session snapshot failed", "tab", tabID, "action", action, "err", err) if tab == nil || tab.sink == nil { return } // Autosave fires once per turn; on a persistently failing disk that would // stream a chat warning after every turn. Rate-limit the user-facing // notice per tab (the slog line above always records every failure). Saves // triggered by an explicit action are one-shot and always surface. if action == "autosave" { tab.saveMu.Lock() now := time.Now() if !tab.lastAutosaveWarnAt.IsZero() && now.Sub(tab.lastAutosaveWarnAt) < autosaveWarnInterval { tab.saveMu.Unlock() return } tab.lastAutosaveWarnAt = now tab.saveMu.Unlock() } prefix := "Session autosave failed" if strings.TrimSpace(action) != "" && action != "autosave" { prefix = "Session save failed before " + action } tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: prefix + ": " + err.Error()}) } func (a *App) reconciledSessionPathForTab(tab *WorkspaceTab) string { if tab == nil { return "" } path, _ := a.reconcileTabWithPinnedSessionMeta(tab) if ctrl := a.controllerForTab(tab); path == "" && ctrl != nil { path = ctrl.SessionPath() } return path } func (a *App) ensureTabControllerWorkspace(tab *WorkspaceTab) error { if tab == nil { return nil } tab.reconcileMu.Lock() defer tab.reconcileMu.Unlock() a.mu.RLock() current := a.tabs[tab.ID] ctrl := tab.Ctrl readOnly := tab.ReadOnly a.mu.RUnlock() if current != tab || ctrl == nil || readOnly { return nil } if controllerHasActiveRuntimeWork(ctrl) { return nil } path, hasBinding := a.reconcileTabWithPinnedSessionMeta(tab) desiredRoot := strings.TrimSpace(tab.WorkspaceRoot) ctrlRoot, rootOK := safeControllerWorkspaceRoot(ctrl) ctrlDir, dirOK := safeControllerSessionDir(ctrl) if !rootOK || !dirOK { return nil } if !hasBinding { if desiredRoot == "" || strings.TrimSpace(ctrlRoot) == "" || sameDesktopPath(ctrlRoot, desiredRoot) { return nil } } desiredDir := tabSessionDir(tab) rootMatches := desiredRoot == "" || sameDesktopPath(ctrlRoot, desiredRoot) dirMatches := desiredDir == "" || sameDesktopPath(ctrlDir, desiredDir) if !dirMatches && path != "" { if validPath, _, err := validateSessionPath(ctrlDir, path); err == nil && sessionRuntimeKey(validPath) == sessionRuntimeKey(path) { dirMatches = true } } if strings.TrimSpace(ctrlRoot) == "" && dirMatches { rootMatches = true } if tab.Scope == "global" { if strings.TrimSpace(ctrlRoot) == "" { rootMatches = true } if sameDesktopPath(ctrlDir, config.SessionDir()) || sameDesktopPath(ctrlDir, desktopSessionDir(globalWorkspaceRoot())) { dirMatches = true } } sessionMatches := path == "" || sessionRuntimeKey(ctrl.SessionPath()) == sessionRuntimeKey(path) if rootMatches && dirMatches && sessionMatches { return nil } if err := ctrl.Snapshot(); err != nil { return err } ctrl.Close() a.mu.Lock() var hostKey string if current := a.tabs[tab.ID]; current == tab { tab.Ctrl = nil tab.Ready = false clearTabStartupError(tab) tab.ActivityStatus = "" if tab.sink == nil { tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx} } hostKey = takeTabSharedHostKey(tab) a.saveTabsLocked() } a.mu.Unlock() if hostKey != "" { a.releaseSharedHost(hostKey) } a.buildTabController(tab) if tab.Ctrl == nil { if tab.StartupErr != "" { return fmt.Errorf("workspace failed to restart with corrected root: %s", tab.StartupErr) } return fmt.Errorf("workspace failed to restart with corrected root") } return nil } func safeControllerWorkspaceRoot(ctrl control.SessionAPI) (root string, ok bool) { if ctrl == nil { return "", false } defer func() { if recover() != nil { root = "" ok = false } }() return ctrl.WorkspaceRoot(), true } func safeControllerSessionDir(ctrl control.SessionAPI) (dir string, ok bool) { if ctrl == nil { return "", false } defer func() { if recover() != nil { dir = "" ok = false } }() return ctrl.SessionDir(), true } // Approve answers a pending approval_request by ID: allow runs the call, session // also remembers the grant for the rest of the session. func (a *App) Approve(id string, allow, session, persist bool) { ctrl := a.ctrlByTabID("") if ctrl != nil { ctrl.Approve(id, allow, session, persist) } } // ApproveTab is like Approve but scoped to a specific tab. func (a *App) ApproveTab(tabID, id string, allow, session, persist bool) { ctrl := a.ctrlByTabID(tabID) if ctrl != nil { ctrl.Approve(id, allow, session, persist) } } // ReplayPendingPrompts asks every tab's controller to re-emit any approval/ask // prompt that is currently blocking its run loop. The frontend calls this once // its event subscription is live (on load/reconnect) so a session that was // already awaiting confirmation rebuilds its modal instead of showing a // "waiting" status with no way to answer — and no way to stop. func (a *App) ReplayPendingPrompts() { a.mu.RLock() tabs := a.runtimeTabsLocked() ctrls := make([]control.SessionAPI, 0, len(tabs)) for _, t := range tabs { if t.Ctrl != nil { ctrls = append(ctrls, t.Ctrl) } } a.mu.RUnlock() for _, ctrl := range ctrls { ctrl.ReplayPendingPrompts() } } // SetPlanMode toggles the read-only plan axis while preserving the current // tool-auto-approval axis. func (a *App) SetPlanMode(on bool) { a.setPlanModeForTab("", on) } func (a *App) setPlanModeForTab(tabID string, on bool) { current := a.currentModeForTab(tabID) a.SetModeForTab(tabID, tabModeFromAxes(on, tabModeHasAutoApproveTools(current))) } // SetMode applies a composer gating mode ("plan" | "yolo" | "plan-yolo" | // anything else = // normal) in one call, so a turn submitted right after the switch can't race a // half-applied plan/tool-auto-approval pair. func (a *App) SetMode(mode string) { a.SetModeForTab("", mode) } func (a *App) SetModeForTab(tabID, mode string) { normalized := normalizeTabMode(mode) a.mu.Lock() tab := a.tabByIDLocked(tabID) if tab == nil { a.mu.Unlock() return } tab.mode = normalized tab.toolApprovalMode = normalizeToolApprovalMode(tab.toolApprovalMode) if tabModeHasAutoApproveTools(normalized) { tab.toolApprovalMode = control.ToolApprovalYolo } else if tab.toolApprovalMode == control.ToolApprovalYolo { tab.toolApprovalMode = control.ToolApprovalAsk } ctrl := tab.Ctrl approvalMode := tab.toolApprovalMode tabIDForSave := tab.ID a.mu.Unlock() applyTabModeToController(ctrl, normalized) applyTabToolApprovalModeToController(ctrl, approvalMode) a.mu.Lock() if a.tabs[tabIDForSave] == tab { a.saveTabsLocked() } a.mu.Unlock() } func applyTabModeToController(ctrl control.SessionAPI, mode string) { if ctrl == nil { return } switch normalizeTabMode(mode) { case "plan": ctrl.SetMode(true, false) case "yolo": ctrl.SetMode(false, true) case "plan-yolo": ctrl.SetMode(true, true) default: ctrl.SetMode(false, false) } } func applyTabToolApprovalModeToController(ctrl control.SessionAPI, mode string) { if ctrl == nil { return } ctrl.SetToolApprovalMode(normalizeToolApprovalMode(mode)) } func (a *App) currentModeForTab(tabID string) string { a.mu.RLock() tab := a.tabByIDLocked(tabID) mode := "normal" if tab != nil { mode = currentTabMode(tab) } a.mu.RUnlock() return mode } func normalizeCollaborationMode(mode string) string { switch strings.ToLower(strings.TrimSpace(mode)) { case "plan": return "plan" case "goal": return "goal" default: return "normal" } } func (a *App) SetCollaborationMode(mode string) { a.SetCollaborationModeForTab("", mode) } func (a *App) SetCollaborationModeForTab(tabID, mode string) { mode = normalizeCollaborationMode(mode) a.mu.Lock() tab := a.tabByIDLocked(tabID) if tab == nil { a.mu.Unlock() return } approvalMode := currentTabToolApprovalMode(tab) switch mode { case "plan": tab.mode = tabModeFromAxes(true, approvalMode == control.ToolApprovalYolo) tab.goal = "" case "goal": tab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo) default: tab.mode = tabModeFromAxes(false, approvalMode == control.ToolApprovalYolo) tab.goal = "" } ctrl := tab.Ctrl goal := tab.goal plan := tabModeHasPlan(tab.mode) tabIDForSave := tab.ID a.mu.Unlock() if ctrl != nil { ctrl.SetPlanMode(plan) ctrl.SetGoal(goal) } a.mu.Lock() if a.tabs[tabIDForSave] == tab { a.saveTabsLocked() } a.mu.Unlock() } // QuestionAnswer is the frontend's reply to one question in an ask_request. type QuestionAnswer struct { QuestionID string `json:"questionId"` Selected []string `json:"selected"` } // AnswerQuestion resolves a pending ask_request (the `ask` tool) by ID with the // user's selections per question. func (a *App) AnswerQuestion(id string, answers []QuestionAnswer) { a.AnswerQuestionForTab("", id, answers) } func (a *App) AnswerQuestionForTab(tabID, id string, answers []QuestionAnswer) { ctrl := a.ctrlByTabID(tabID) if ctrl == nil { return } out := make([]event.AskAnswer, len(answers)) for i, an := range answers { out[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected} } ctrl.AnswerQuestion(id, out) } // Compact runs a plain compaction pass (the "compact now" button). Focus-guided // compaction goes through Submit("/compact ") instead. func (a *App) Compact() error { return a.CompactForTab("") } // CompactForTab compacts the requested tab without depending on which tab is // focused when the asynchronous frontend call reaches the backend. func (a *App) CompactForTab(tabID string) error { tab, ctrl := a.tabAndCtrlByID(tabID) if a.tabIsReadOnly(tab) { return readOnlyChannelErr() } if ctrl == nil { return nil } if err := a.ensureTabControllerWorkspace(tab); err != nil { return err } ctrl = a.controllerForTab(tab) if ctrl == nil { return nil } return ctrl.Compact(a.ctx, "") } // workspaceNotReadyErr names why a session action arrived before the tab's // controller existed: still starting, or failed to start. Silently returning // nil here swallowed the click with no feedback (#3938). // // This is the bound-method form: StartupErr is written under a.mu by the // build goroutine while Submit-family calls race it, so read it under the // lock. Callers must not hold a.mu. func (a *App) workspaceNotReadyErr(tab *WorkspaceTab) error { startupErr := "" if tab != nil { a.mu.RLock() startupErr = tab.StartupErr a.mu.RUnlock() } if strings.TrimSpace(startupErr) != "" { return fmt.Errorf("workspace failed to start: %s", startupErr) } return fmt.Errorf("workspace is still starting") } // tabIsReadOnly reads tab.ReadOnly under a.mu; setTabReadOnly can flip it // concurrently with Submit-family bound calls. Callers must not hold a.mu. func (a *App) tabIsReadOnly(tab *WorkspaceTab) bool { if tab == nil { return false } a.mu.RLock() defer a.mu.RUnlock() return tab.ReadOnly } // NewSession snapshots the current conversation and rotates to a fresh one. func (a *App) NewSession() error { return a.NewSessionForTab("") } // NewSessionForTab snapshots and rotates the requested tab regardless of which // tab becomes active while the Wails call is in flight. func (a *App) NewSessionForTab(tabID string) error { tab, ctrl := a.tabAndCtrlByID(tabID) if a.tabIsReadOnly(tab) { return readOnlyChannelErr() } if ctrl == nil { return a.workspaceNotReadyErr(tab) } if err := a.ensureTabControllerWorkspace(tab); err != nil { return err } ctrl = a.controllerForTab(tab) if ctrl == nil { return a.workspaceNotReadyErr(tab) } // Tab is already blank — just persist and skip the new-session dance. if !controllerHasActiveRuntimeWork(ctrl) && !messagesHaveConversationContent(ctrl.History()) { a.persistTabSessionPath(tab, ctrl.SessionPath()) return nil } if err := ctrl.NewSession(); err != nil { return err } // The rotated session starts with zero spend: without this reset the tab // telemetry keeps the previous session's totals and the status bar 会话费用 // silently turns into an all-sessions running total (#5850). tab.resetTelemetry(ctrl.SessionPath()) // Mirror the controller: NewSession cleared the active goal, and the tab's // persisted copy must follow — otherwise the next rebuild/restart would // re-seed the old goal into the fresh session via SetGoal(tab.goal). a.clearTabGoal(tab) a.assignFreshSessionTopic(tab) a.persistTabSessionPath(tab, ctrl.SessionPath()) a.invalidatePromptHistoryCache() a.emitProjectTreeChanged() return nil } func (a *App) assignFreshSessionTopic(tab *WorkspaceTab) { if tab == nil { return } topicID := newTopicID() a.mu.Lock() scope := tab.Scope workspaceRoot := tab.WorkspaceRoot tab.TopicID = topicID tab.TopicTitle = defaultTopicTitle if current := a.tabs[tab.ID]; current == tab { a.saveTabsLocked() } a.mu.Unlock() if strings.TrimSpace(scope) == "global" { workspaceRoot = "" } else { workspaceRoot = normalizeProjectRoot(workspaceRoot) } // NewSession already rotated the runtime to a fresh session. If the sidebar // topic index repair fails here, keep the session usable and let persisted // session metadata repair the topic index later instead of surfacing a false // "new session failed" error to the frontend. _ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto) _ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli()) } func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) { if tab == nil { return } topicID := newTopicID() a.mu.Lock() if strings.TrimSpace(tab.TopicID) != "" { a.mu.Unlock() return } scope := tab.Scope workspaceRoot := tab.WorkspaceRoot tab.TopicID = topicID tab.TopicTitle = defaultTopicTitle if current := a.tabs[tab.ID]; current == tab { a.saveTabsLocked() } a.mu.Unlock() if strings.TrimSpace(scope) == "global" { scope = "global" workspaceRoot = "" } else { scope = "project" workspaceRoot = normalizeProjectRoot(workspaceRoot) } _ = ensureTopicIndexed(scope, workspaceRoot, topicID, defaultTopicTitle, topicTitleSourceAuto) _ = setTopicCreatedAt(topicTitleRoot(scope, workspaceRoot), topicID, time.Now().UnixMilli()) a.persistTabSessionPath(tab, a.currentSessionPathFor(tab)) a.emitProjectTreeChanged() } func messagesHaveConversationContent(messages []provider.Message) bool { for _, msg := range messages { if msg.Role != provider.RoleSystem { return true } } return false } // ClearSession discards the current conversation and rotates to a fresh unsaved one. func (a *App) ClearSession() error { return a.ClearSessionForTab("") } // ClearSessionForTab clears the requested tab regardless of later focus changes. func (a *App) ClearSessionForTab(tabID string) error { tab, ctrl := a.tabAndCtrlByID(tabID) if a.tabIsReadOnly(tab) { return readOnlyChannelErr() } if ctrl == nil { return a.workspaceNotReadyErr(tab) } if err := a.ensureTabControllerWorkspace(tab); err != nil { return err } ctrl = a.controllerForTab(tab) if ctrl == nil { return a.workspaceNotReadyErr(tab) } if controllerHasActiveRuntimeWork(ctrl) { return a.clearActiveSessionRuntime(tab, ctrl) } if err := ctrl.ClearSession(); err != nil { return err } if err := tab.ensureSessionLease(ctrl.SessionPath()); err != nil { // Wails bridge return: a raw lease error would carry the session path // and holder id across to the frontend. return userFacingSessionLeaseError("", err) } tab.resetTelemetry(ctrl.SessionPath()) // Mirror the controller: ClearSession cleared the active goal. a.clearTabGoal(tab) a.persistTabSessionPath(tab, ctrl.SessionPath()) a.invalidatePromptHistoryCache() return nil } // clearTabGoal drops the tab's persisted goal copy so rebuilds and restarts // cannot re-seed a goal the controller has already cleared on session rotation. func (a *App) clearTabGoal(tab *WorkspaceTab) { if tab == nil { return } a.mu.Lock() tab.goal = "" if current := a.tabs[tab.ID]; current == tab { a.saveTabsLocked() } a.mu.Unlock() } func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.SessionAPI) error { if tab == nil || oldCtrl == nil { return fmt.Errorf("workspace is still starting") } // This is a build+swap of the tab's controller; serialize with the other // rebuild paths (see runtimeRebuildMu) so a concurrent model/effort/settings // rebuild cannot interleave a second swap. Lock order: // runtimeRebuildMu → sessionRemovalMu (no path acquires them in reverse). a.runtimeRebuildMu.Lock() defer a.runtimeRebuildMu.Unlock() // This path destroys the old session's files (removeDesktopSessionArtifacts); // serialize with DeleteSession/TrashTopic/workspace removal so they never // trash or restore the same files mid-clear. a.sessionRemovalMu.Lock() defer a.sessionRemovalMu.Unlock() a.reconciledSessionPathForTab(tab) oldPath := oldCtrl.SessionPath() // Snapshot the tab profile under a.mu: bound methods write these fields // under the lock while this rebuild runs off-lock. snap := a.tabRuntimeSnapshot(tab) oldSink := snap.sink if oldSink != nil { // Rebind under the runtime key, matching the id cloneDetachedRuntimeTab // derives — a raw path here would hash to a different detached id on // Windows where keys are case-folded. oldSink.setBinding(detachedRuntimeTabID(sessionRuntimeKey(oldPath)), nil) oldSink.clearContext() } if oldCtrl.RuntimeStatus().Cancellable { oldCtrl.Cancel() if err := waitControllerStopped(oldCtrl); err != nil { return err } } destroy := oldCtrl.BeginDestroySession(oldPath) destroys := []control.SessionDestroyHandle{destroy} teardownTimedOut := waitDestroyHandles(destroys) if teardownTimedOut { if err := agent.MarkCleanupPending(oldPath, "clear"); err != nil { return err } } newSink := &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx} sharedHost := a.lookupSharedHost(snap.sharedHostKey) newCtrl, err := boot.Build(a.bootContext(), boot.Options{ Model: snap.model, RequireKey: false, Sink: newSink, WorkspaceRoot: snap.workspaceRoot, SessionDir: sessionDirForSnapshot(snap), EffortOverride: cloneStringPtr(snap.effort), TokenMode: snap.currentTokenMode(), SharedHost: sharedHost, CleanupPendingReconciler: reconcileDesktopCleanupPending, SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab), OnSessionRecovered: a.handleTabSessionRecovered(tab), }) if err != nil { if teardownTimedOut { // The old session was already marked cleanup-pending, so finish the // destroy cleanup instead of re-exposing a runtime in teardown. go delayedDesktopSessionCleanup(oldPath, destroys) } else { finishDestroyHandles(destroys) } if oldSink != nil { oldSink.setBinding(tab.ID, nil) oldSink.setContext(a.ctx) } return err } if teardownTimedOut { go delayedDesktopSessionCleanup(oldPath, destroys) } else { if err := removeDesktopSessionArtifacts(oldPath); err != nil { finishDestroyHandles(destroys) newCtrl.Close() return err } finishDestroyHandles(destroys) } a.bindControllerDisplayRecorder(newCtrl) newCtrl.EnableInteractiveApproval() applyTabModeToController(newCtrl, snap.mode) applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode) // Clearing the session clears the active goal too (same contract as // Controller.ClearSession): the snapshot's goal belongs to the destroyed // conversation and must not seed the replacement. path := agent.NewSessionPath(newCtrl.SessionDir(), newCtrl.Label()) if err := tab.ensureSessionLease(path); err != nil { newCtrl.Close() // Surfaces through ClearSession's Wails return; keep the holder's // path/pid/writer id out of it. return userFacingSessionLeaseError("", err) } newCtrl.SetSessionPath(path) a.mu.Lock() if current := a.tabs[tab.ID]; current != tab { a.mu.Unlock() // The old session is already destroyed either way; release what this // clear acquired for the replaced tab (fresh controller and its // lease) so neither leaks, and still finish the old runtime teardown. newCtrl.Close() tab.releaseSessionLease() oldCtrl.CloseAfterDestroy() a.emitProjectTreeChanged() return fmt.Errorf("tab %q changed while clearing the session", tab.ID) } tab.Ctrl = newCtrl tab.sink = newSink tab.SessionPath = path tab.Label = newCtrl.Label() tab.Ready = true clearTabStartupError(tab) tab.goal = "" // Supersede any in-flight startup build: the session it was resuming // was just destroyed, and finishing later would pass the generation // check and overwrite this controller. a.supersedeTabBuildLocked(tab) a.saveTabsLocked() a.mu.Unlock() // Same contract as ClearSession's non-running path: the replacement // session starts with zero spend. tab.resetTelemetry(path) oldCtrl.CloseAfterDestroy() a.emitProjectTreeChanged() a.notifyTabRuntimeRebuilt(tab) return nil } func removeDesktopSessionArtifacts(path string) error { if strings.TrimSpace(path) == "" { return nil } guard, err := acquireSessionRemovalGuard(path) if err != nil { return err } defer guard.Release() if err := invalidateTopicDirMarkers(filepath.Dir(path)); err != nil { return err } defer invalidateTopicSessionIndexForPath(path) for _, p := range sessionOwnedArtifactPaths(path) { if strings.TrimSpace(p) == "" { continue } if err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) { return err } } if err := guard.RemoveSidecarsAndRelease(); err != nil { return err } if err := removeSessionDisplay(filepath.Dir(path), path); err != nil { return err } if err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil { return err } return agent.ClearCleanupPending(path) } // CheckpointMeta summarises one rewind point (a user turn) for the desktop. type CheckpointMeta struct { Turn int `json:"turn"` Prompt string `json:"prompt"` Files []string `json:"files"` // stable preview of cumulative files RestoreCode would affect from this turn FileCount int `json:"fileCount"` // full cumulative file count, including entries omitted from Files FilesTruncated bool `json:"filesTruncated,omitempty"` TurnFileCount int `json:"turnFileCount"` // files changed during this turn only Time int64 `json:"time"` // unix milliseconds CanCode bool `json:"canCode"` CanConversation bool `json:"canConversation"` } const checkpointFilePreviewLimit = 60 // Checkpoints lists the session's rewind points, oldest first, for the rewind UI. func (a *App) Checkpoints() []CheckpointMeta { return a.CheckpointsForTab("") } func (a *App) CheckpointsForTab(tabID string) []CheckpointMeta { a.mu.RLock() var ctrl control.SessionAPI if tab := a.tabByIDLocked(tabID); tab != nil { ctrl = tab.Ctrl } a.mu.RUnlock() if ctrl == nil { return []CheckpointMeta{} } metas := ctrl.Checkpoints() out := make([]CheckpointMeta, 0, len(metas)) for _, m := range metas { out = append(out, CheckpointMeta{ Turn: m.Turn, Prompt: m.Prompt, Files: m.Paths, TurnFileCount: len(m.Paths), Time: m.Time.UnixMilli(), CanCode: len(m.Paths) > 0, CanConversation: ctrl.CheckpointHasBoundary(m.Turn), }) } // RestoreCode(turn) reverts every file touched in this turn or any later one, so // a turn can rewind code even when it changed no files itself — as long as a // later turn did. Propagate CanCode backwards over the oldest-first list. // Also propagate the cumulative unique file count so the UI shows how many // files RestoreCode would actually affect from this turn. hasCodeAfter := false codeFileSet := make(map[string]bool, len(metas)*2) codeFilePreview := []string{} for i := len(out) - 1; i >= 0; i-- { if len(out[i].Files) > 0 { hasCodeAfter = true } for _, f := range out[i].Files { if codeFileSet[f] { continue } codeFileSet[f] = true codeFilePreview = insertCheckpointFilePreview(codeFilePreview, f, checkpointFilePreviewLimit) } out[i].CanCode = hasCodeAfter out[i].FileCount = len(codeFileSet) out[i].Files = append([]string{}, codeFilePreview...) out[i].FilesTruncated = out[i].FileCount > len(out[i].Files) } return out } func insertCheckpointFilePreview(preview []string, path string, limit int) []string { if limit <= 0 || path == "" { return preview } idx := sort.SearchStrings(preview, path) if idx < len(preview) && preview[idx] == path { return preview } if len(preview) < limit { preview = append(preview, "") copy(preview[idx+1:], preview[idx:]) preview[idx] = path return preview } if idx >= limit { return preview } copy(preview[idx+1:], preview[idx:limit-1]) preview[idx] = path return preview } // ToolResultForTab returns the full arguments and output for one tool call that // were elided from the frontend's in-memory items[] for memory efficiency. The // caller (frontend ToolCard) loads this on demand when the user expands a // collapsed tool card. Returns nil when the tool ID is not found. func (a *App) ToolResultForTab(tabID, toolID string) *control.ToolResultData { a.mu.RLock() var ctrl control.SessionAPI if tab := a.tabByIDLocked(tabID); tab != nil { ctrl = tab.Ctrl } a.mu.RUnlock() if ctrl == nil { return nil } return ctrl.ToolResult(toolID) } // Rewind restores the session to the start of turn. scope is "code", // "conversation", or "both" (anything else is treated as "both"). The frontend // re-reads History after this resolves. func (a *App) Rewind(turn int, scope string) error { return a.RewindForTab("", turn, scope) } // RewindForTab rewinds the requested tab instead of resolving the active tab at // execution time, which may have changed after frontend confirmation. func (a *App) RewindForTab(tabID string, turn int, scope string) error { tab, ctrl := a.tabAndCtrlByID(tabID) if a.tabIsReadOnly(tab) { return readOnlyChannelErr() } if ctrl == nil { return nil } s := control.RewindBoth switch scope { case "code": s = control.RewindCode case "conversation": s = control.RewindConversation } return ctrl.Rewind(turn, s) } // Fork branches the conversation at the start of turn into a new session tab // (preserving the current tab), keeping code intact, and switches to the new tab. func (a *App) Fork(turn int) (TabMeta, error) { return a.ForkForTab("", turn) } // ForkForTab forks the requested source tab even if focus changes before the // backend begins processing the request. The fork becomes active only while the // source tab still owns focus, so a later tab selection remains authoritative. func (a *App) ForkForTab(tabID string, turn int) (TabMeta, error) { sourceTab, ctrl := a.tabAndCtrlByID(tabID) if sourceTab == nil || ctrl == nil { return TabMeta{}, nil } if a.tabIsReadOnly(sourceTab) { return TabMeta{}, readOnlyChannelErr() } if err := a.ensureTabControllerWorkspace(sourceTab); err != nil { return TabMeta{}, err } a.mu.RLock() if a.tabs[sourceTab.ID] != sourceTab || sourceTab.Ctrl == nil { a.mu.RUnlock() return TabMeta{}, nil } ctrl = sourceTab.Ctrl scope := sourceTab.Scope workspaceRoot := sourceTab.WorkspaceRoot sourceTitle := sourceTab.TopicTitle model := sourceTab.model effort := cloneStringPtr(sourceTab.effort) mode := currentTabMode(sourceTab) toolApprovalMode := currentTabToolApprovalMode(sourceTab) disabledMCP := cloneServerViewMap(sourceTab.disabledMCP) mcpOrder := append([]string(nil), sourceTab.mcpOrder...) a.mu.RUnlock() newPath, err := ctrl.ForkSession(turn, "") if err != nil { return TabMeta{}, err } topicID := newTopicID() topicTitle := forkTopicTitle(sourceTitle) titleRoot := workspaceRoot if scope == "global" { titleRoot = "" } if err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil { return TabMeta{}, err } m, _ := agent.EnsureBranchMeta(newPath) m.Scope = scope m.WorkspaceRoot = workspaceRoot m.TopicID = topicID m.TopicTitle = topicTitle if err := agent.SaveBranchMeta(newPath, m); err != nil { return TabMeta{}, err } invalidateTopicSessionIndexForPath(newPath) a.mu.Lock() newTabID := a.newUniqueTabIDLocked() tab := &WorkspaceTab{ ID: newTabID, Scope: scope, WorkspaceRoot: workspaceRoot, TopicID: topicID, TopicTitle: topicTitle, SessionPath: newPath, model: model, effort: effort, mode: mode, toolApprovalMode: toolApprovalMode, disabledMCP: disabledMCP, mcpOrder: mcpOrder, } tab.sink = &tabEventSink{tabID: newTabID, app: a} a.tabs[newTabID] = tab a.tabOrder = append(a.tabOrder, newTabID) activateFork := a.activeTabID == sourceTab.ID if activateFork { a.activeTabID = newTabID } a.saveTabsLocked() meta := a.tabMeta(tab, activateFork) a.mu.Unlock() a.emitProjectTreeChanged() a.startTabControllerBuild(tab) return meta, nil } // SummarizeFrom / SummarizeUpTo compress the conversation from / up to the start // of turn into one summary (Claude Code's "summarize from/up to here"), keeping // code intact. The frontend re-reads History after this resolves. func (a *App) SummarizeFrom(turn int) error { return a.SummarizeFromForTab("", turn) } func (a *App) SummarizeFromForTab(tabID string, turn int) error { tab, ctrl := a.tabAndCtrlByID(tabID) if a.tabIsReadOnly(tab) { return readOnlyChannelErr() } if ctrl == nil { return nil } return ctrl.SummarizeFrom(a.ctx, turn) } func (a *App) SummarizeUpTo(turn int) error { return a.SummarizeUpToForTab("", turn) } func (a *App) SummarizeUpToForTab(tabID string, turn int) error { tab, ctrl := a.tabAndCtrlByID(tabID) if a.tabIsReadOnly(tab) { return readOnlyChannelErr() } if ctrl == nil { return nil } return ctrl.SummarizeUpTo(a.ctx, turn) } // SessionMeta summarises one saved session for the history panel. type SessionMeta struct { Path string `json:"path"` Preview string `json:"preview"` // first user message Title string `json:"title,omitempty"` // user-chosen name, when set (overrides preview) Turns int `json:"turns"` CreatedAt int64 `json:"createdAt"` // unix milliseconds LastActivityAt int64 `json:"lastActivityAt"` // unix milliseconds ModTime int64 `json:"modTime"` // compatibility alias for lastActivityAt DeletedAt int64 `json:"deletedAt,omitempty"` Current bool `json:"current"` Open bool `json:"open"` Scope string `json:"scope,omitempty"` WorkspaceRoot string `json:"workspaceRoot,omitempty"` TopicID string `json:"topicId,omitempty"` TopicTitle string `json:"topicTitle,omitempty"` Kind string `json:"kind,omitempty"` // "channel" for external IM transcripts Channel string `json:"channel,omitempty"` ChannelLabel string `json:"channelLabel,omitempty"` RemoteID string `json:"remoteId,omitempty"` ChatType string `json:"chatType,omitempty"` UserID string `json:"userId,omitempty"` ThreadID string `json:"threadId,omitempty"` SessionSource string `json:"sessionSource,omitempty"` Recovered bool `json:"recovered,omitempty"` // created by conflict recovery, including an adopted/continued branch RecoveryCopy bool `json:"recoveryCopy,omitempty"` // actual branch content is unchanged and covered by its parent } type channelSessionRoute struct { channel string channelLabel string remoteID string chatType string userID string threadID string sessionSource string } type WorkspaceMeta struct { Path string `json:"path"` Name string `json:"name"` Current bool `json:"current"` } func controllerSessionDir(ctrl control.SessionAPI) string { if ctrl != nil { if dir := ctrl.SessionDir(); dir != "" { return dir } } return desktopSessionDir("") } func tabSessionDir(tab *WorkspaceTab) string { if tab != nil { if tab.WorkspaceRoot != "" { return desktopSessionDir(tab.WorkspaceRoot) } if tab.Ctrl != nil { if dir := tab.Ctrl.SessionDir(); dir != "" { return dir } } } return desktopSessionDir("") } func tabRuntimeSessionDir(tab *WorkspaceTab) string { if tab != nil && tab.Ctrl != nil { if dir, ok := safeControllerSessionDir(tab.Ctrl); ok && strings.TrimSpace(dir) != "" { if path := strings.TrimSpace(tab.currentSessionPath()); path != "" { if _, _, err := validateSessionPath(dir, path); err == nil { return dir } } else { return dir } } } return tabSessionDir(tab) } func (a *App) activeSessionDir() string { tab := a.activeTab() if path, ok := a.reconcileTabWithPinnedSessionMeta(tab); ok && strings.TrimSpace(path) != "" { return filepath.Dir(path) } if tab != nil && tab.Ctrl != nil { return tabRuntimeSessionDir(tab) } return tabSessionDir(tab) } // ListSessions returns the saved sessions newest-first for the history panel, // marking the one the current conversation is writing to and attaching any // user-chosen titles. func (a *App) ListSessions() []SessionMeta { dir := a.activeSessionDir() infos, err := agent.ListSessions(dir) if err != nil { return []SessionMeta{} } open := a.openSessionPaths(dir) protectedDisplays := make(map[string]struct{}, len(open)) for path := range open { if key := filepath.Base(path); store.IsSessionTranscriptName(key) { protectedDisplays[key] = struct{}{} } } _ = pruneSessionDisplays(dir, protectedDisplays) titles := loadSessionTitles(dir) channelRoutes := channelSessionRoutesForDir(dir) active := a.activeSessionPath(dir) out := make([]SessionMeta, 0, len(infos)) for _, s := range infos { _, isOpen := open[s.Path] title := strings.TrimSpace(s.CustomTitle) if title == "" { title = titles[filepath.Base(s.Path)] } meta := sessionMetaFromInfo(s, title, s.Path == active, isOpen, 0, dir) if route, ok := channelRoutes[sessionRuntimeKey(s.Path)]; ok { applyChannelSessionRoute(&meta, route) } out = append(out, meta) } return out } // ListTrashedSessions returns sessions that were moved to the local trash, // newest-deleted first. These can be previewed, restored, or permanently purged. func (a *App) ListTrashedSessions() []SessionMeta { out := []SessionMeta{} for _, dir := range a.knownSessionDirs() { paths, err := listTrashedSessionFiles(dir) if err != nil { continue } titles := loadSessionTitles(dir) for _, path := range paths { infos, err := agent.ListSessions(filepath.Dir(path)) if err != nil || len(infos) == 0 { continue } deletedAt := trashedSessionDeletedAt(path) title := strings.TrimSpace(infos[0].CustomTitle) if title == "" { title = titles[filepath.Base(path)] } out = append(out, sessionMetaFromInfo(infos[0], title, false, false, deletedAt, dir)) } } sort.Slice(out, func(i, j int) bool { if out[i].DeletedAt == out[j].DeletedAt { return out[i].LastActivityAt > out[j].LastActivityAt } return out[i].DeletedAt > out[j].DeletedAt }) return out } func (a *App) trashedSessionDir(path string) (string, error) { for _, dir := range a.knownSessionDirs() { if _, _, _, err := validateTrashedSessionPath(dir, path); err == nil { return dir, nil } } return "", fmt.Errorf("trashed session path outside known session dirs: %s", path) } func (a *App) sessionDirForPath(path string) (string, string, error) { for _, dir := range a.knownSessionDirs() { sessionPath, _, err := validateSessionPath(dir, path) if err == nil { return dir, sessionPath, nil } } return "", "", fmt.Errorf("session path outside known session dirs: %s", path) } func sessionMetaFromInfo(s agent.SessionInfo, title string, current, open bool, deletedAt int64, parentDir string) SessionMeta { return SessionMeta{ Path: s.Path, Preview: s.Preview, Title: title, Turns: s.Turns, CreatedAt: s.CreatedAt.UnixMilli(), LastActivityAt: s.LastActivityAt.UnixMilli(), ModTime: s.LastActivityAt.UnixMilli(), DeletedAt: deletedAt, Current: current, Open: open, Scope: s.Scope, WorkspaceRoot: s.WorkspaceRoot, TopicID: s.TopicID, TopicTitle: s.TopicTitle, Recovered: sessionInfoIsAutomaticRecovery(s), RecoveryCopy: sessionInfoIsUnmodifiedRecoveryCopy(s, parentDir), } } func applyChannelSessionRoute(meta *SessionMeta, route channelSessionRoute) { if meta == nil { return } meta.Kind = "channel" meta.Channel = route.channel meta.ChannelLabel = route.channelLabel meta.RemoteID = route.remoteID meta.ChatType = route.chatType meta.UserID = route.userID meta.ThreadID = route.threadID meta.SessionSource = route.sessionSource } func channelSessionRoutesForDir(dir string) map[string]channelSessionRoute { userPath := config.UserConfigPath() if strings.TrimSpace(userPath) == "" { return nil } cfg := config.LoadForEdit(userPath) out := map[string]channelSessionRoute{} for _, conn := range cfg.Bot.Connections { channel := strings.TrimSpace(conn.Provider) if channel == "" { continue } channelLabel := strings.TrimSpace(conn.Label) if channelLabel == "" { channelLabel = channelDisplayName(channel, conn.Domain) } for _, mapping := range conn.SessionMappings { if strings.TrimSpace(mapping.SessionSource) != "auto" { continue } sessionPath := botSessionPathTarget(mapping.SessionID) if sessionPath == "" { continue } validPath, _, err := validateSessionPath(dir, sessionPath) if err != nil { continue } key := sessionRuntimeKey(validPath) if key == "" { continue } out[key] = channelSessionRoute{ channel: channel, channelLabel: channelLabel, remoteID: strings.TrimSpace(mapping.RemoteID), chatType: strings.TrimSpace(mapping.ChatType), userID: strings.TrimSpace(mapping.UserID), threadID: strings.TrimSpace(mapping.ThreadID), sessionSource: strings.TrimSpace(mapping.SessionSource), } } } if len(out) == 0 { return nil } return out } func botSessionPathTarget(sessionID string) string { sessionID = strings.TrimSpace(sessionID) if sessionID == "" { return "" } if strings.HasPrefix(strings.ToLower(sessionID), "path:") { return strings.TrimSpace(sessionID[5:]) } if strings.HasSuffix(sessionID, ".jsonl") || strings.Contains(sessionID, "/") || strings.Contains(sessionID, `\`) || strings.HasPrefix(sessionID, "~") { return sessionID } return "" } func channelDisplayName(provider, domain string) string { provider = strings.TrimSpace(provider) domain = strings.TrimSpace(domain) switch provider { case "feishu": if strings.EqualFold(domain, "lark") { return "Lark" } return "Feishu" case "weixin": return "WeChat" case "qq": return "QQ" default: return provider } } // DeleteSession moves a saved session to the local trash. If the session still // has an in-process runtime, the runtime is cancelled and removed first so // autosave cannot recreate or append to the deleted file later. func (a *App) DeleteSession(path string) error { return friendlySessionFileError(a.deleteSession(path, false)) } // DeleteRecoveryCopy is the guarded bulk-cleanup path. The frontend's copy // marker is only a hint; the backend re-reads the branch and parent immediately // before changing runtime state or moving any files. func (a *App) DeleteRecoveryCopy(path string) error { return friendlySessionFileError(a.deleteSession(path, true)) } var errRecoveryCopyNotRedundant = errors.New("recovery session contains content not preserved by its parent") func (a *App) deleteSession(path string, requireRedundantRecovery bool) error { dir := a.activeSessionDir() sessionPath, key, err := validateSessionPath(dir, path) if err != nil { var foundErr error if dir, sessionPath, foundErr = a.sessionDirForPath(path); foundErr != nil { return err } key = filepath.Base(sessionPath) } if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil { return err } var fallback fallbackRuntimeTarget if err := func() error { a.sessionRemovalMu.Lock() defer a.sessionRemovalMu.Unlock() if requireRedundantRecovery && !agent.RecoveryBranchCoveredByParent(sessionPath, dir) { return errRecoveryCopyNotRedundant } removed, nextFallback := a.removeSessionRuntimeBindings(dir, sessionPath) fallback = nextFallback if err := a.prepareRemovedSessionRuntimes(removed); err != nil { a.closeRemovedSessionRuntimes(removed) return err } closedRemoved := map[control.SessionAPI]bool{} destroys := a.destroyHandlesForSession(dir, sessionPath, removed) teardownTimedOut := waitDestroyHandles(destroys) a.closeRemovedSessionRuntimesForSessionAfterDestroy(removed, dir, sessionPath, closedRemoved) if teardownTimedOut { if err := agent.MarkCleanupPending(sessionPath, "delete"); err != nil { a.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved) return err } go delayedDesktopSessionTrash(dir, sessionPath, key, destroys) } else { err = trashSessionArtifacts(dir, sessionPath, key) finishDestroyHandles(destroys) if err != nil { a.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved) return err } } a.closeRemainingRemovedSessionRuntimesAfterDestroy(removed, closedRemoved) return nil }(); err != nil { return err } if err := botruntime.ForgetAutoSessionMappingsForPath(sessionPath); err != nil { slog.Warn("desktop: failed to clear auto bot session mapping", "err", err) } if fallback.needs { fallback = a.sessionDeleteFallbackTarget(fallback) if err := a.openFallbackRuntime(fallback); err != nil { return err } } a.emitProjectTreeChanged() a.invalidatePromptHistoryCache() return nil } type removedSessionRuntime struct { tab *WorkspaceTab ctrl control.SessionAPI sink *tabEventSink sessionDir string sessionPath string scope string workspaceRoot string topicID string readOnly bool } type fallbackRuntimeTarget struct { needs bool scope string workspaceRoot string topicID string } func (a *App) removeSessionRuntimeBindings(dir, sessionPath string) ([]removedSessionRuntime, fallbackRuntimeTarget) { var removed []removedSessionRuntime var fallback fallbackRuntimeTarget a.mu.Lock() for id, tab := range a.tabs { if !tabMatchesSession(tab, dir, sessionPath) { continue } if len(removed) == 0 { fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID} } removed = append(removed, removedRuntimeFromTab(tab, dir, sessionPath)) a.markTabRemovedLocked(tab) delete(a.tabs, id) a.removeTabOrderLocked(id) if a.activeTabID == id { a.activeTabID = "" } } for key, tab := range a.detachedSessions { if !tabMatchesSession(tab, dir, sessionPath) { continue } if len(removed) == 0 { fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID} } removed = append(removed, removedRuntimeFromTab(tab, dir, sessionPath)) a.markTabRemovedLocked(tab) delete(a.detachedSessions, key) } if a.activeTabID == "" && len(a.tabOrder) > 0 { a.activeTabID = a.tabOrder[0] } fallback.needs = len(removed) > 0 && len(a.tabs) == 0 dir, entries, activeID, version := a.saveTabsCollectLocked() a.mu.Unlock() a.saveTabsWrite(dir, entries, activeID, version) return removed, fallback } func (a *App) sessionDeleteFallbackTarget(target fallbackRuntimeTarget) fallbackRuntimeTarget { topicID := strings.TrimSpace(target.topicID) if topicID == "" { return target } if path, _ := a.findTopicContentSessionForTarget(target.scope, target.workspaceRoot, topicID); path != "" { return target } target.topicID = "" return target } func (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) { var removed []removedSessionRuntime var fallback fallbackRuntimeTarget a.mu.Lock() for id, tab := range a.tabs { if tab == nil || tab.TopicID != topicID { continue } sessionDir := tabRuntimeSessionDir(tab) sessionPath := canonicalTabSessionPath(tab.currentSessionPath()) if len(removed) == 0 { fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot} } removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath)) a.markTabRemovedLocked(tab) delete(a.tabs, id) a.removeTabOrderLocked(id) if a.activeTabID == id { a.activeTabID = "" } } for key, tab := range a.detachedSessions { if tab == nil || tab.TopicID != topicID { continue } sessionDir := tabRuntimeSessionDir(tab) sessionPath := canonicalTabSessionPath(tab.currentSessionPath()) if len(removed) == 0 { fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot} } removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath)) a.markTabRemovedLocked(tab) delete(a.detachedSessions, key) } if a.activeTabID == "" && len(a.tabOrder) > 0 { a.activeTabID = a.tabOrder[0] } fallback.needs = len(removed) > 0 && len(a.tabs) == 0 dir, entries, activeID, version := a.saveTabsCollectLocked() a.mu.Unlock() a.saveTabsWrite(dir, entries, activeID, version) return removed, fallback } func removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime { return removedSessionRuntime{ tab: tab, ctrl: tab.Ctrl, sink: tab.sink, sessionDir: dir, sessionPath: sessionPath, scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID, readOnly: tab.ReadOnly, } } func tabMatchesSession(tab *WorkspaceTab, dir, sessionPath string) bool { if tab == nil { return false } currentPath, _, err := validateSessionPath(dir, tab.currentSessionPath()) if err == nil && currentPath == sessionPath { return true } if tabRuntimeSessionDir(tab) != dir { return false } currentPath, _, err = validateSessionPath(dir, tab.currentSessionPath()) return err == nil && currentPath == sessionPath } func (a *App) prepareRemovedSessionRuntimes(removed []removedSessionRuntime) error { for _, item := range removed { if item.sink != nil { item.sink.clearContext() } if item.ctrl == nil { continue } if item.ctrl.Running() { item.ctrl.Cancel() if err := waitControllerStopped(item.ctrl); err != nil { return err } } if item.readOnly { continue } if err := item.ctrl.Snapshot(); err != nil { if !errors.Is(err, agent.ErrSessionSnapshotConflict) { return err } slog.Warn("desktop: skipping stale runtime snapshot before removing session", "session", item.sessionPath, "err", err) } item.ctrl.SetSessionPath("") a.quiesceTabAutosave(item.tab) } return nil } func waitControllerStopped(ctrl control.SessionAPI) error { deadline := time.Now().Add(5 * time.Second) for ctrl.Running() { if time.Now().After(deadline) { return fmt.Errorf("timed out waiting for cancelled session work to stop") } time.Sleep(10 * time.Millisecond) } return nil } func (a *App) destroyHandlesForSession(dir, sessionPath string, removed []removedSessionRuntime) []control.SessionDestroyHandle { destroys := a.beginDestroySessionJobs(dir, sessionPath) for _, item := range removed { if item.ctrl == nil || item.sessionDir != dir || item.sessionPath != sessionPath { continue } destroys = append(destroys, item.ctrl.BeginDestroySession(sessionPath)) } return destroys } func waitDestroyHandles(destroys []control.SessionDestroyHandle) bool { timedOut := false for _, destroy := range destroys { if destroy.Wait != nil { if destroy.Wait().HasTimedOut() { timedOut = true } } } return timedOut } func waitAllDestroyHandles(destroys []control.SessionDestroyHandle) { for _, destroy := range destroys { if destroy.WaitAll != nil { destroy.WaitAll() } } } func finishDestroyHandles(destroys []control.SessionDestroyHandle) { for _, destroy := range destroys { if destroy.Finish != nil { destroy.Finish() } } } func delayedDesktopSessionCleanup(path string, destroys []control.SessionDestroyHandle) { waitAllDestroyHandles(destroys) if err := removeDesktopSessionArtifacts(path); err != nil { slog.Warn("desktop: delayed session cleanup failed", "path", path, "err", err) } finishDestroyHandles(destroys) } func delayedDesktopSessionTrash(dir, sessionPath, key string, destroys []control.SessionDestroyHandle) { waitAllDestroyHandles(destroys) if err := trashSessionArtifacts(dir, sessionPath, key); err != nil { slog.Warn("desktop: delayed session trash failed", "path", sessionPath, "err", err) } finishDestroyHandles(destroys) } func (a *App) closeRemovedSessionRuntimes(removed []removedSessionRuntime) { a.closeRemainingRemovedSessionRuntimes(removed, map[control.SessionAPI]bool{}) } func (a *App) closeRemovedSessionRuntimesForSessionAfterDestroy(removed []removedSessionRuntime, dir, sessionPath string, closed map[control.SessionAPI]bool) { releasedTabs := map[*WorkspaceTab]bool{} for _, item := range removed { if item.sessionDir != dir || item.sessionPath != sessionPath { continue } a.closeRemovedSessionRuntime(item, closed, releasedTabs, true) } } func (a *App) closeRemainingRemovedSessionRuntimes(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) { releasedTabs := map[*WorkspaceTab]bool{} for _, item := range removed { a.closeRemovedSessionRuntime(item, closed, releasedTabs, false) } } func (a *App) closeRemainingRemovedSessionRuntimesAfterDestroy(removed []removedSessionRuntime, closed map[control.SessionAPI]bool) { releasedTabs := map[*WorkspaceTab]bool{} for _, item := range removed { a.closeRemovedSessionRuntime(item, closed, releasedTabs, true) } } func (a *App) closeRemovedSessionRuntime(item removedSessionRuntime, closed map[control.SessionAPI]bool, releasedTabs map[*WorkspaceTab]bool, afterDestroy bool) { if item.tab != nil { if releasedTabs == nil || !releasedTabs[item.tab] { if releasedTabs != nil { releasedTabs[item.tab] = true } a.releaseTabSharedHost(item.tab) item.tab.releaseSessionLease() } } if item.ctrl == nil { return } if closed == nil { closed = map[control.SessionAPI]bool{} } if closed[item.ctrl] { return } closed[item.ctrl] = true if afterDestroy { item.ctrl.CloseAfterDestroy() return } item.ctrl.Close() } func (a *App) openFallbackRuntime(target fallbackRuntimeTarget) error { scope := target.scope root := target.workspaceRoot topicID := strings.TrimSpace(target.topicID) if scope == "global" { root = "" } if topicID == "" { return a.openTransientBlankRuntime(scope, root) } var err error if a.singleSurfaceLayoutEnabled() { _, err = a.ActivateTopic(scope, root, topicID, "") } else if scope == "global" { _, err = a.OpenGlobalTab(topicID) } else { _, err = a.OpenProjectTab(root, topicID) } return err } func (a *App) openTransientBlankRuntime(scope, workspaceRoot string) error { scope = strings.TrimSpace(scope) if scope != "project" { scope = "global" } actualRoot := "" if scope == "project" { workspaceRoot = normalizeProjectRoot(workspaceRoot) if workspaceRoot == "" { return fmt.Errorf("workspaceRoot is required") } saveWorkspace(workspaceRoot) a.registerProjectRoot(workspaceRoot) actualRoot = workspaceRoot } else { actualRoot = globalWorkspaceRoot() if err := os.MkdirAll(actualRoot, 0o755); err != nil { return fmt.Errorf("create global workspace: %w", err) } } model, toolApprovalMode := desktopNewSessionDefaults() sessionPath, err := createEmptySessionFile(desktopSessionDir(actualRoot), model) if err != nil { return err } tab := &WorkspaceTab{ Scope: scope, WorkspaceRoot: actualRoot, TopicTitle: defaultTopicTitle, SessionPath: sessionPath, model: model, tokenMode: boot.TokenModeFull, mode: tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo), toolApprovalMode: toolApprovalMode, disabledMCP: map[string]ServerView{}, } a.mu.Lock() tab.ID = a.newUniqueTabIDLocked() tab.sink = &tabEventSink{tabID: tab.ID, app: a} a.tabs[tab.ID] = tab a.tabOrder = append(a.tabOrder, tab.ID) a.activeTabID = tab.ID a.saveTabsLocked() a.mu.Unlock() a.startTabControllerBuild(tab) return nil } func (a *App) beginDestroySessionJobs(dir, sessionPath string) []control.SessionDestroyHandle { a.mu.RLock() defer a.mu.RUnlock() var destroys []control.SessionDestroyHandle for _, tab := range a.runtimeTabsLocked() { if tab == nil || tab.Ctrl == nil || tabRuntimeSessionDir(tab) != dir { continue } destroys = append(destroys, tab.Ctrl.BeginDestroySession(sessionPath)) } return destroys } func (a *App) openSessionPaths(dir string) map[string]struct{} { a.mu.RLock() paths := make([]string, 0, len(a.tabs)+len(a.detachedSessions)) for _, tab := range a.runtimeTabsLocked() { if tab != nil { paths = append(paths, tab.currentSessionPath()) } } a.mu.RUnlock() out := make(map[string]struct{}, len(paths)) for _, path := range paths { currentPath, _, err := validateSessionPath(dir, path) if err == nil { out[currentPath] = struct{}{} } } return out } func (a *App) activeSessionPath(dir string) string { a.mu.RLock() var path string if tab := a.tabs[a.activeTabID]; tab != nil { path = tab.currentSessionPath() } a.mu.RUnlock() currentPath, _, err := validateSessionPath(dir, path) if err != nil { return "" } return currentPath } // RestoreSession moves a trashed session back into the saved-session list. func (a *App) RestoreSession(path string) error { return friendlySessionFileError(a.restoreSession(path)) } func (a *App) restoreSession(path string) error { dir, err := a.trashedSessionDir(path) if err != nil { return err } _, key, _, err := validateTrashedSessionPath(dir, path) if err != nil { return err } // The destroying/open checks and the trash-entry move must not interleave // with DeleteSession/TrashTopic trashing the same entry. a.sessionRemovalMu.Lock() defer a.sessionRemovalMu.Unlock() target := filepath.Join(dir, key) if a.sessionDestroying(dir, target) { return fmt.Errorf("session cleanup is still in progress: %s", key) } if a.sessionOpen(dir, target) { return fmt.Errorf("session is open: %s", key) } if err := restoreTrashedSessionFile(dir, path); err != nil { return err } if err := restoreSessionTopicIndex(dir, target); err != nil { return err } a.emitProjectTreeChanged() a.invalidatePromptHistoryCache() return nil } func (a *App) sessionDestroying(dir, sessionPath string) bool { a.mu.RLock() defer a.mu.RUnlock() for _, tab := range a.runtimeTabsLocked() { if tab == nil || tab.Ctrl == nil || tabRuntimeSessionDir(tab) != dir { continue } if tab.Ctrl.IsDestroyingSession(sessionPath) { return true } } return false } func (a *App) sessionOpen(dir, sessionPath string) bool { a.mu.RLock() defer a.mu.RUnlock() for _, tab := range a.runtimeTabsLocked() { if tabMatchesSession(tab, dir, sessionPath) { return true } } return false } // PurgeTrashedSession permanently removes a trashed session and its title/display // sidecars. func (a *App) PurgeTrashedSession(path string) error { return friendlySessionFileError(a.purgeTrashedSession(path, false)) } // PurgeRecoveryCopy is the guarded permanent-cleanup path. A trashed branch is // rechecked against its live parent; missing, stale, or divergent data is kept. func (a *App) PurgeRecoveryCopy(path string) error { return friendlySessionFileError(a.purgeTrashedSession(path, true)) } func (a *App) purgeTrashedSession(path string, requireRedundantRecovery bool) error { dir, err := a.trashedSessionDir(path) if err != nil { return err } a.sessionRemovalMu.Lock() defer a.sessionRemovalMu.Unlock() var parentGuard *agent.SessionRemovalGuard if requireRedundantRecovery { parentGuard, err = agent.TryAcquireRecoveryParentGuard(path, dir) if err != nil { switch { case errors.Is(err, agent.ErrRecoveryBranchNotCovered): return errRecoveryCopyNotRedundant case errors.Is(err, agent.ErrSessionLeaseHeld): return errSessionBusyElsewhere default: return err } } defer parentGuard.Release() } if err := purgeTrashedSessionFile(dir, path); err != nil { return err } a.invalidatePromptHistoryCache() return nil } // RenameSession sets a custom display name for a session (empty clears it back to // the preview). The transcript file is unchanged; the canonical name lives in // the branch meta sidecar, with the legacy .titles.json map kept as a // compatibility write-through for older desktop data paths. func (a *App) RenameSession(path, title string) error { dir := a.activeSessionDir() sessionPath, _, err := validateSessionPath(dir, path) if err != nil { return err } if err := agent.RenameSession(sessionPath, title); err != nil { return err } if err := setSessionTitle(dir, sessionPath, title); err != nil { return err } a.invalidatePromptHistoryCache() a.emitProjectTreeChanged() return nil } // ResumeSession snapshots the current conversation, then loads the session at // path and continues it on the active tab. The model and working folder are // unchanged; only the transcript is swapped. Returns the resumed messages for // the frontend to render. func (a *App) ResumeSession(path string) ([]HistoryMessage, error) { return a.ResumeSessionForTab("", path) } func (a *App) ResumeSessionPage(path string, limit int) (HistoryPage, error) { return a.ResumeSessionPageForTab("", path, limit) } func (a *App) ResumeSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) { tab, ctrl := a.tabAndCtrlByID(tabID) if tab == nil || ctrl == nil { return HistoryPage{}, fmt.Errorf("tab is not ready") } sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path) if err != nil { return HistoryPage{}, err } loaded, err := loadResumableSession(sessionPath) if err != nil { return HistoryPage{}, err } if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) { if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil { return HistoryPage{}, err } } a.setTabReadOnly(tab.ID, false) return a.HistoryPageForTab(tab.ID, 0, limit), nil } // ResumeSessionForTab is the tab-scoped form of ResumeSession. A saved session // path is a runtime identity, so changing to a different path must replace the // tab's controller binding rather than mutating the current controller in place. func (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error) { tab, ctrl := a.tabAndCtrlByID(tabID) if tab == nil || ctrl == nil { return []HistoryMessage{}, fmt.Errorf("tab is not ready") } sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path) if err != nil { return nil, err } loaded, err := loadResumableSession(sessionPath) if err != nil { return nil, err } if sessionRuntimeKey(tab.currentSessionPath()) == sessionRuntimeKey(sessionPath) { a.setTabReadOnly(tab.ID, false) return a.HistoryForTab(tabID), nil } if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil { return nil, err } a.setTabReadOnly(tab.ID, false) return a.HistoryForTab(tab.ID), nil } func (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) { tab, ctrl := a.tabAndCtrlByID(tabID) if tab == nil || ctrl == nil { return []HistoryMessage{}, fmt.Errorf("tab is not ready") } sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path) if err != nil { return nil, err } loaded, err := loadResumableSession(sessionPath) if err != nil { return nil, err } if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) { if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil { return nil, err } } a.setTabReadOnly(tab.ID, true) return a.HistoryForTab(tab.ID), nil } func (a *App) OpenChannelSessionPageForTab(tabID, path string, limit int) (HistoryPage, error) { tab, ctrl := a.tabAndCtrlByID(tabID) if tab == nil || ctrl == nil { return HistoryPage{}, fmt.Errorf("tab is not ready") } sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path) if err != nil { return HistoryPage{}, err } loaded, err := loadResumableSession(sessionPath) if err != nil { return HistoryPage{}, err } if sessionRuntimeKey(tab.currentSessionPath()) != sessionRuntimeKey(sessionPath) { if err := a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded); err != nil { return HistoryPage{}, err } } a.setTabReadOnly(tab.ID, true) return a.HistoryPageForTab(tab.ID, 0, limit), nil } func (a *App) setTabReadOnly(tabID string, readOnly bool) { a.mu.Lock() if tab := a.tabs[tabID]; tab != nil && tab.ReadOnly != readOnly { tab.ReadOnly = readOnly a.saveTabsLocked() } a.mu.Unlock() } func (a *App) rebindTabToSessionPath(tab *WorkspaceTab, sessionPath string) error { sessionPath = canonicalTabSessionPath(sessionPath) if sessionPath == "" { return fmt.Errorf("session path is required") } loaded, err := loadResumableSession(sessionPath) if err != nil { return err } return a.rebindTabToLoadedSessionPath(tab, sessionPath, loaded) } func (a *App) rebindTabToLoadedSessionPath(tab *WorkspaceTab, sessionPath string, loaded *agent.Session) error { if tab == nil { return fmt.Errorf("tab is not ready") } sessionPath = canonicalTabSessionPath(sessionPath) if sessionPath == "" { return fmt.Errorf("session path is required") } if agent.IsCleanupPending(sessionPath) { return fmt.Errorf("session is pending cleanup") } if loaded == nil { var err error loaded, err = loadResumableSession(sessionPath) if err != nil { return err } } // Session rebinding is a full detach/close/build/swap of the tab's // controller — the same shape as rebuildSetting/SetModelForTab. Hold the // shared rebuild mutex so a concurrent model/effort/settings rebuild of the // same tab cannot interleave: without it both builds pass the swap-time // identity check, the loser's controller leaks un-closed, and the old // controller can be double-closed. a.runtimeRebuildMu.Lock() defer a.runtimeRebuildMu.Unlock() // Validate the tab, compare session keys, invalidate any in-flight async // build, and snapshot the controller in ONE a.mu critical section. // runtimeRebuildMu does not cover startTabControllerBuild's goroutine, so // observing ctrl == nil and bumping the generation later would leave a // window where the async build passes its swap-time generation check, // installs its controller after the observation, and the loaded build // below silently overwrites it — leaking the runtime and its shared-host // reference. Bump-before-snapshot makes the snapshot authoritative: after // the bump the async build can only fall into its superseded branches, // which release exactly what it acquired (abandonSupersededBuild). a.mu.Lock() if tab.removed || a.tabs[tab.ID] != tab { a.mu.Unlock() return fmt.Errorf("tab is not ready") } currentPath := "" if tab.Ctrl != nil { currentPath = strings.TrimSpace(tab.Ctrl.SessionPath()) } if currentPath == "" { currentPath = strings.TrimSpace(tab.SessionPath) } if sessionRuntimeKey(currentPath) == sessionRuntimeKey(sessionPath) { // Same session: leave any in-flight build alone — resuming the // session a build is already binding must stay a no-op. a.mu.Unlock() return nil } tab.buildGeneration++ if tab.buildCancel != nil { tab.buildCancel() tab.buildCancel = nil } ctrl := tab.Ctrl a.mu.Unlock() profile := loadTabSessionProfile(sessionPath) if ctrl == nil { a.mu.Lock() tab.SessionPath = sessionPath applyTabSessionProfile(tab, profile) tab.Ready = false clearTabStartupError(tab) tab.ActivityStatus = "" tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx} a.saveTabsLocked() a.mu.Unlock() a.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded}) a.mu.RLock() builtCtrl, startupErr := tab.Ctrl, tab.StartupErr a.mu.RUnlock() if builtCtrl == nil { if startupErr != "" { return fmt.Errorf("resume session: %s", startupErr) } return fmt.Errorf("resume session: controller was not built") } return nil } if err := a.snapshotTabForAction(tab, "switching sessions"); err != nil { return err } if oldPath := a.reconciledSessionPathForTab(tab); oldPath != "" { if err := a.saveTabSessionMeta(tab, oldPath); err != nil { return fmt.Errorf("save current session metadata before switching sessions: %w", err) } } if controllerHasActiveRuntimeWork(ctrl) { if !a.detachRuntimeForReplacement(tab) { return fmt.Errorf("current session runtime cannot be detached") } } else { ctrl.Close() tab.releaseSessionLease() } a.mu.Lock() // The generation was already bumped (and any async build cancelled) in // the validation section above; only retarget the tab here. tab.Ctrl = nil tab.SessionPath = sessionPath applyTabSessionProfile(tab, profile) tab.Ready = false clearTabStartupError(tab) tab.ActivityStatus = "" tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx} a.saveTabsLocked() a.mu.Unlock() a.buildTabControllerWithLoadedSession(tab, loadedTabSession{Path: sessionPath, Session: loaded}) a.mu.RLock() builtCtrl, startupErr := tab.Ctrl, tab.StartupErr a.mu.RUnlock() if builtCtrl == nil { if startupErr != "" { return fmt.Errorf("resume session: %s", startupErr) } return fmt.Errorf("resume session: controller was not built") } return nil } func loadResumableSession(sessionPath string) (*agent.Session, error) { if agent.IsCleanupPending(sessionPath) { return nil, fmt.Errorf("session is pending cleanup") } return agent.LoadSession(sessionPath) } // PreviewSession reads a saved session for display only. It does not snapshot or // swap the active controller, so the history drawer can call it while a turn runs. func (a *App) PreviewSession(path string) ([]HistoryMessage, error) { sessionDir, sessionPath, err := a.sessionDirForPath(path) if err != nil { return nil, err } return previewSessionMessages(sessionDir, sessionPath) } // invalidatePromptHistoryCache resets the lazy prompt-history tape so the next // ScanPromptHistory call rebuilds session order and reloads sessions on demand. // Called from every session-mutating path: NewSession, ClearSession, // DeleteSession, RestoreSession, PurgeTrashedSession, RenameSession. func (a *App) invalidatePromptHistoryCache() { a.promptHistoryMu.Lock() a.promptHistoryTape = nil a.promptHistoryMu.Unlock() } const ( promptHistoryPageLimit = 50 promptHistoryMaxPageLimit = 200 ) type promptHistoryRequest struct { Nonce string `json:"nonce,omitempty"` Cursor string `json:"cursor,omitempty"` Limit int `json:"limit,omitempty"` legacy bool } type promptHistoryCursor struct { Nonce string `json:"n"` Session int `json:"s"` Offset int `json:"o"` } type promptHistoryTape struct { nonce string dir string currentPath string displays sessionDisplayMap sessions []promptHistorySessionFile loaded map[string][]PromptHistoryEntry } // ScanPromptHistory returns the next prompt-history tape segment. The request is // a JSON string so the Wails binding stays one-argument while the protocol can // carry a cursor and page limit. Older clients may still pass a bare nonce; that // path keeps the old cache-hit behavior. func (a *App) ScanPromptHistory(rawRequest string) (PromptHistoryResult, error) { req := parsePromptHistoryRequest(rawRequest) dir := a.activeSessionDir() sessionPath := a.activeSessionPath(dir) a.promptHistoryMu.Lock() tape, err := a.promptHistoryTapeForLocked(dir, sessionPath) if err != nil { a.promptHistoryMu.Unlock() return PromptHistoryResult{}, err } if req.legacy && req.Nonce != "" && req.Nonce == tape.nonce { a.promptHistoryMu.Unlock() return PromptHistoryResult{Entries: nil, Nonce: req.Nonce}, nil } result := tape.readOlder(req.Cursor, promptHistoryLimit(req.Limit)) a.promptHistoryMu.Unlock() return result, nil } func parsePromptHistoryRequest(raw string) promptHistoryRequest { raw = strings.TrimSpace(raw) if raw == "" { return promptHistoryRequest{} } if strings.HasPrefix(raw, "{") { var req promptHistoryRequest if err := json.Unmarshal([]byte(raw), &req); err == nil { return req } } return promptHistoryRequest{Nonce: raw, legacy: true} } func promptHistoryLimit(limit int) int { if limit <= 0 { return promptHistoryPageLimit } if limit > promptHistoryMaxPageLimit { return promptHistoryMaxPageLimit } return limit } func (a *App) promptHistoryTapeForLocked(dir, sessionPath string) (*promptHistoryTape, error) { currentPath := "" if path, _, err := validateSessionPath(dir, sessionPath); err == nil { currentPath = path } if a.promptHistoryTape != nil && a.promptHistoryTape.dir == dir && a.promptHistoryTape.currentPath == currentPath { return a.promptHistoryTape, nil } tape, err := newPromptHistoryTape(dir, currentPath) if err != nil { return nil, err } a.promptHistoryTape = tape return tape, nil } func (a *App) scanPromptHistoryFromDir(dir string) ([]PromptHistoryEntry, error) { tape, err := newPromptHistoryTape(dir, "") if err != nil { return nil, err } return tape.readAll(), nil } func newPromptHistoryTape(dir, currentPath string) (*promptHistoryTape, error) { tape := &promptHistoryTape{ nonce: fmt.Sprintf("%d", time.Now().UnixNano()), dir: dir, currentPath: currentPath, displays: loadSessionDisplays(dir), loaded: map[string][]PromptHistoryEntry{}, } sessions, err := promptHistorySessionFiles(dir) if err != nil { return nil, err } if currentPath != "" { currentPath = filepath.Clean(currentPath) currentSession := promptHistorySessionFile{} currentIndex := -1 for i, session := range sessions { if filepath.Clean(session.path) == currentPath { currentSession = session currentIndex = i break } } if currentIndex >= 0 { sessions = append([]promptHistorySessionFile{currentSession}, append(sessions[:currentIndex], sessions[currentIndex+1:]...)...) } else if info, err := os.Stat(currentPath); err == nil && !info.IsDir() { sessions = append([]promptHistorySessionFile{{ path: currentPath, }}, sessions...) } } tape.sessions = sessions return tape, nil } func (t *promptHistoryTape) readOlder(cursor string, limit int) PromptHistoryResult { c := promptHistoryCursor{Nonce: t.nonce} if decoded, ok := decodePromptHistoryCursor(cursor); ok && decoded.Nonce == t.nonce { c = decoded } if c.Session < 0 { c.Session = 0 } if c.Offset < 0 { c.Offset = 0 } out := make([]PromptHistoryEntry, 0, limit) sessionIndex := c.Session offset := c.Offset for sessionIndex < len(t.sessions) && len(out) < limit { entries, err := t.entriesForSession(sessionIndex) if err != nil || offset >= len(entries) { sessionIndex++ offset = 0 continue } end := min(len(entries), offset+limit-len(out)) out = append(out, entries[offset:end]...) offset = end if offset >= len(entries) && len(out) < limit { sessionIndex++ offset = 0 } } if sessionIndex < len(t.sessions) { if entries, ok := t.loaded[t.sessions[sessionIndex].path]; ok && offset >= len(entries) { sessionIndex++ offset = 0 } } hasOlder := sessionIndex < len(t.sessions) olderCursor := "" if hasOlder { olderCursor = encodePromptHistoryCursor(promptHistoryCursor{Nonce: t.nonce, Session: sessionIndex, Offset: offset}) } return PromptHistoryResult{Entries: out, Nonce: t.nonce, OlderCursor: olderCursor, HasOlder: hasOlder} } func (t *promptHistoryTape) readAll() []PromptHistoryEntry { out := []PromptHistoryEntry{} cursor := "" for { page := t.readOlder(cursor, promptHistoryMaxPageLimit) out = append(out, page.Entries...) if !page.HasOlder || page.OlderCursor == "" { return out } cursor = page.OlderCursor } } func (t *promptHistoryTape) entriesForSession(index int) ([]PromptHistoryEntry, error) { if index < 0 || index >= len(t.sessions) { return nil, nil } path := t.sessions[index].path if entries, ok := t.loaded[path]; ok { return entries, nil } info, err := os.Stat(path) if err != nil { t.loaded[path] = nil if os.IsNotExist(err) { return nil, nil } return nil, err } entries, err := scanPromptHistoryFile(path, info, sessionDisplayResolverFromMap(t.displays, path)) if err != nil { t.loaded[path] = nil return nil, err } t.loaded[path] = entries return entries, nil } func encodePromptHistoryCursor(cursor promptHistoryCursor) string { b, err := json.Marshal(cursor) if err != nil { return "" } return base64.RawURLEncoding.EncodeToString(b) } func decodePromptHistoryCursor(value string) (promptHistoryCursor, bool) { if strings.TrimSpace(value) == "" { return promptHistoryCursor{}, false } b, err := base64.RawURLEncoding.DecodeString(value) if err != nil { return promptHistoryCursor{}, false } var cursor promptHistoryCursor if err := json.Unmarshal(b, &cursor); err != nil { return promptHistoryCursor{}, false } return cursor, true } func scanPromptHistoryFile(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) { entries, err := collectPromptHistoryEntries(path, info, resolveUserContent) if err != nil { return nil, err } sortPromptHistoryNewestFirst(entries) return entries, nil } type promptHistorySessionFile struct { path string } func promptHistorySessionFiles(dir string) ([]promptHistorySessionFile, error) { infos, err := agent.ListSessionOrder(dir) if err != nil { return nil, err } sessions := make([]promptHistorySessionFile, 0, len(infos)) for _, info := range infos { sessions = append(sessions, promptHistorySessionFile{path: info.Path}) } return sessions, nil } func promptHistoryEntryNewer(a, b PromptHistoryEntry) bool { if a.At != b.At { return a.At > b.At } if a.SessionPath != b.SessionPath { return a.SessionPath > b.SessionPath } return a.Turn > b.Turn } func sortPromptHistoryNewestFirst(entries []PromptHistoryEntry) { sort.Slice(entries, func(i, j int) bool { return promptHistoryEntryNewer(entries[i], entries[j]) }) } func collectPromptHistoryEntries(path string, info os.FileInfo, resolveUserContent func(string) string) ([]PromptHistoryEntry, error) { var out []PromptHistoryEntry emit := func(entry PromptHistoryEntry) { out = append(out, entry) } // Sessions with an event log must replay it: the .jsonl checkpoint stops // gaining turns between checkpoints, so scanning it directly would freeze // ↑-recall at each session's last checkpoint. if handled, err := collectEventLogUserPrompts(path, info, resolveUserContent, emit); handled { return out, err } err := collectJSONLUserPrompts(path, info, resolveUserContent, emit) return out, err } func collectEventLogUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) (bool, error) { logPath := store.SessionEventLog(path) if logPath == "" { return false, nil } if logInfo, err := os.Stat(logPath); err != nil || logInfo.IsDir() || logInfo.Size() == 0 { return false, nil } users, err := agent.LoadSessionUserMessages(path) if err != nil { return true, err } fallbackAt := promptHistoryFallbackMillis(path, info) turn := 0 for _, user := range users { text := strings.TrimSpace(resolveUserContent(strings.TrimSpace(user.Text))) if text == "" || control.IsSyntheticUserMessage(text) { continue } at := fallbackAt if !user.At.IsZero() { at = user.At.UnixMilli() } emit(PromptHistoryEntry{ Text: text, At: at, SessionPath: path, Turn: turn, }) turn++ } return true, nil } func collectJSONLUserPrompts(path string, info os.FileInfo, resolveUserContent func(string) string, emit func(PromptHistoryEntry)) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() fallbackAt := promptHistoryFallbackMillis(path, info) dec := json.NewDecoder(f) turn := 0 for { var rec previewEventRecord if err := dec.Decode(&rec); err != nil { if errors.Is(err, io.EOF) { break } return nil // partial results are better than none } // Format compatibility: // 1) Legacy event format: {"kind":"user.message","text":"..."} // 2) Early event format: {"type":"user.message","text":"..."} // 3) Current provider.Message format: {"role":"user","content":"..."} text := "" kindOrType := strings.TrimSpace(rec.Kind) if kindOrType == "" { kindOrType = strings.TrimSpace(rec.Type) } if kindOrType == "user.message" { text = strings.TrimSpace(rec.Text) } else if strings.TrimSpace(rec.Role) == "user" { text = strings.TrimSpace(rec.Content) } if text != "" { text = resolveUserContent(text) text = strings.TrimSpace(text) if text == "" { continue } if control.IsSyntheticUserMessage(text) { continue } at := fallbackAt if eventAt, ok := promptHistoryEventMillis(rec); ok { at = eventAt } entry := PromptHistoryEntry{ Text: text, At: at, SessionPath: path, Turn: turn, } emit(entry) turn++ } } return nil } func promptHistoryFallbackMillis(path string, info os.FileInfo) int64 { if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok && !meta.UpdatedAt.IsZero() { return meta.UpdatedAt.UnixMilli() } if info != nil { return info.ModTime().UnixMilli() } return 0 } func promptHistoryEventMillis(rec previewEventRecord) (int64, bool) { for _, raw := range []json.RawMessage{ rec.Time, rec.Timestamp, rec.CreatedAt, rec.CreatedAtSnake, rec.UpdatedAt, rec.UpdatedAtSnake, } { if at, ok := parseJSONTimestampMillis(raw); ok { return at, true } } return 0, false } func parseJSONTimestampMillis(raw json.RawMessage) (int64, bool) { if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { return 0, false } var s string if err := json.Unmarshal(raw, &s); err == nil { s = strings.TrimSpace(s) if s == "" { return 0, false } if n, err := strconv.ParseInt(s, 10, 64); err == nil { return normalizeTimestampMillis(n) } if f, err := strconv.ParseFloat(s, 64); err == nil { return normalizeTimestampMillisFloat(f) } if t, err := time.Parse(time.RFC3339Nano, s); err == nil { return t.UnixMilli(), true } return 0, false } dec := json.NewDecoder(bytes.NewReader(raw)) dec.UseNumber() var n json.Number if err := dec.Decode(&n); err != nil { return 0, false } if i, err := strconv.ParseInt(n.String(), 10, 64); err == nil { return normalizeTimestampMillis(i) } if f, err := strconv.ParseFloat(n.String(), 64); err == nil { return normalizeTimestampMillisFloat(f) } return 0, false } func normalizeTimestampMillis(v int64) (int64, bool) { if v <= 0 { return 0, false } switch { case v >= 1_000_000_000_000_000_000: return v / 1_000_000, true // nanoseconds case v >= 1_000_000_000_000_000: return v / 1_000, true // microseconds case v >= 100_000_000_000: return v, true // milliseconds case v >= 1_000_000_000: return v * 1_000, true // seconds default: return 0, false } } func normalizeTimestampMillisFloat(v float64) (int64, bool) { if v <= 0 { return 0, false } switch { case v >= 1_000_000_000_000_000_000: return int64(v / 1_000_000), true case v >= 1_000_000_000_000_000: return int64(v / 1_000), true case v >= 100_000_000_000: return int64(v), true case v >= 1_000_000_000: return int64(v * 1_000), true default: return 0, false } } // PickWorkspace opens a folder chooser and, on a pick, opens a new project tab // scoped to that folder. Returns the chosen path ("" if cancelled). func (a *App) PickWorkspace() (string, error) { if a.ctx == nil { return "", nil } cur, _ := os.Getwd() a.mu.RLock() if tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != "" { cur = tab.WorkspaceRoot } a.mu.RUnlock() dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{ Title: "Choose working folder", DefaultDirectory: dialogDefaultDirectory(cur), }) if err != nil || dir == "" { return "", err } return a.SwitchWorkspace(dir) } func dialogDefaultDirectory(preferred string) string { if dir := nearestExistingDirectory(preferred); dir != "" { return dir } if cwd, err := os.Getwd(); err == nil { if dir := nearestExistingDirectory(cwd); dir != "" { return dir } } if home, err := os.UserHomeDir(); err == nil { if dir := nearestExistingDirectory(home); dir != "" { return dir } } return "" } func nearestExistingDirectory(path string) string { path = strings.TrimSpace(path) if path == "" { return "" } if abs, err := filepath.Abs(path); err == nil { path = abs } for { info, err := os.Stat(path) if err == nil { if info.IsDir() { return path } path = filepath.Dir(path) continue } parent := filepath.Dir(path) if parent == path { return "" } path = parent } } func (a *App) ListWorkspaces() []WorkspaceMeta { migrateLegacyWorkspacesIntoProjects() activeRoot := "" cur, _ := os.Getwd() a.mu.RLock() if tab := a.activeTabLocked(); tab != nil && tab.WorkspaceRoot != "" { activeRoot = normalizeProjectRoot(tab.WorkspaceRoot) } a.mu.RUnlock() if activeRoot == "" { activeRoot = normalizeProjectRoot(cur) } projects := loadProjectsFile().Projects out := make([]WorkspaceMeta, 0, len(projects)) for _, project := range projects { out = append(out, WorkspaceMeta{ Path: project.Root, Name: projectDisplayName(project), Current: activeRoot != "" && sameProjectRoot(project.Root, activeRoot), }) } return out } func (a *App) RemoveWorkspace(dir string) error { if dir == "" { return fmt.Errorf("workspace path is required") } dir = normalizeProjectRoot(dir) var fallback *WorkspaceTab // sessionRemovalMu covers every step that can still touch this workspace's // session files: snapshotting, unlinking the tab/runtime bindings, and // closing the unlinked runtimes (quiescing autosave). Once a runtime is // unlinked from a.tabs/detachedSessions it is invisible to // DeleteSession/TrashTopic/RestoreSession, so it must stop writing before // the lock is released. Project bookkeeping, the fallback controller build, // and notifications run after release. if err := func() error { a.sessionRemovalMu.Lock() defer a.sessionRemovalMu.Unlock() type workspaceTabCandidate struct { id string tab *WorkspaceTab } var closeTabs []*WorkspaceTab var closeDetached []*WorkspaceTab a.mu.Lock() for _, tab := range a.tabs { if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() { a.mu.Unlock() return fmt.Errorf("workspace has running sessions; stop them before removing") } } for _, tab := range a.detachedSessions { if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() { a.mu.Unlock() return fmt.Errorf("workspace has running sessions; stop them before removing") } } candidates := make([]workspaceTabCandidate, 0) for id, tab := range a.tabs { if !tabInWorkspace(tab, dir) { continue } candidates = append(candidates, workspaceTabCandidate{id: id, tab: tab}) } a.mu.Unlock() snapshotted := make(map[string]*WorkspaceTab, len(candidates)) for _, candidate := range candidates { id, tab := candidate.id, candidate.tab snapshotted[id] = tab if err := a.snapshotTab(tab); err != nil { slog.Warn("desktop: snapshot before removing workspace failed", "tab", id, "workspace", dir, "err", err) return fmt.Errorf("save current session before removing workspace: %w", err) } } a.mu.Lock() for _, tab := range a.tabs { if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() { a.mu.Unlock() return fmt.Errorf("workspace has running sessions; stop them before removing") } } for _, tab := range a.detachedSessions { if tabInWorkspace(tab, dir) && tab.hasActiveRuntimeWork() { a.mu.Unlock() return fmt.Errorf("workspace has running sessions; stop them before removing") } } for id, tab := range a.tabs { if tabInWorkspace(tab, dir) && snapshotted[id] != tab { a.mu.Unlock() return fmt.Errorf("workspace tabs changed while removing; retry") } } for _, candidate := range candidates { id, tab := candidate.id, candidate.tab if tab == nil || a.tabs[id] != tab || !tabInWorkspace(tab, dir) { continue } a.markTabRemovedLocked(tab) closeTabs = append(closeTabs, tab) delete(a.tabs, id) a.removeTabOrderLocked(id) if a.activeTabID == id { a.activeTabID = "" } } for key, tab := range a.detachedSessions { if !tabInWorkspace(tab, dir) { continue } closeDetached = append(closeDetached, tab) delete(a.detachedSessions, key) } if len(a.tabs) == 0 { fallback = a.createTabEntry("global", globalTabWorkspaceRoot(), "") fallback.TopicTitle = "Global" fallback.sink = &tabEventSink{tabID: fallback.ID, app: a, ctx: a.ctx} a.tabs[fallback.ID] = fallback a.tabOrder = append(a.tabOrder, fallback.ID) a.activeTabID = fallback.ID } else if a.activeTabID == "" { if ordered := a.orderedTabIDsLocked(); len(ordered) > 0 { a.activeTabID = ordered[0] } } a.saveTabsLocked() a.mu.Unlock() for _, tab := range closeTabs { a.closeTabRuntime(tab) } for _, tab := range closeDetached { a.closeTabRuntime(tab) } return nil }(); err != nil { return err } // The fallback tab is already linked into a.tabs; its controller build is // asynchronous and does not touch removed session files, so it does not // need the removal lock. if fallback != nil { a.startTabControllerBuild(fallback) } forgetWorkspace(dir) if err := removeProject(dir); err != nil { return err } // If the removed workspace was the active one, clear the pointer // so we don't leave a stale reference to a deleted project. if loadWorkspace() == dir { if remaining := loadProjectsFile(); len(remaining.Projects) > 0 { // Fall back to the first remaining project saveWorkspace(remaining.Projects[0].Root) } else { // No projects left; clear the active pointer entirely clearWorkspace() } } a.emitProjectTreeChanged() return nil } func migrateLegacyWorkspacesIntoProjects() { legacy := loadWorkspaces() if len(legacy) == 0 { return } _ = updateProjectsFile(func(f *desktopProjectFile) (bool, error) { seen := make(map[string]bool, len(f.Projects)+len(legacy)) for _, p := range f.Projects { seen[p.Root] = true } changed := false for _, path := range legacy { root := normalizeProjectRoot(path) if root == "" || seen[root] { continue } f.Projects = append(f.Projects, desktopProject{Root: root}) seen[root] = true changed = true } return changed, nil }) } func workspaceName(path string) string { name := filepath.Base(path) if name == "." || name == string(filepath.Separator) || name == "" { return path } return name } // tabWorkspaceNameForScope resolves the display name for a tab's workspace. // Callers pass tab.Scope copied under a.mu instead of re-reading the tab. func tabWorkspaceNameForScope(scope, cwd string) string { if scope == "global" { return globalProjectTitle() } return workspaceName(cwd) } func (a *App) SwitchWorkspace(dir string) (string, error) { if dir == "" { home, err := os.UserHomeDir() if err != nil { return "", err } dir = home } if abs, err := filepath.Abs(dir); err == nil { dir = abs } info, err := os.Stat(dir) if err != nil { return "", err } if !info.IsDir() { return "", fmt.Errorf("%s is not a directory", dir) } saveWorkspace(dir) // Open a registered topic so the new workspace appears in the project tree // immediately instead of only existing as an in-memory tab. topic, err := a.CreateTopic("project", dir, "") if err != nil { return "", err } var meta TabMeta if a.singleSurfaceLayoutEnabled() { meta, err = a.ActivateTopic("project", dir, topic.ID, "") } else { meta, err = a.OpenProjectTab(dir, topic.ID) } if err != nil { return "", err } return meta.WorkspaceRoot, nil } func (a *App) singleSurfaceLayoutEnabled() bool { cfg, _, err := a.loadDesktopUserConfigForView() if err != nil { return true } return singleSurfaceLayoutStyle(cfg.DesktopLayoutStyle()) } // HistoryMessage is one prior turn, for the frontend to repopulate its transcript // after a reload. type HistoryMessage struct { Role string `json:"role"` Content string `json:"content"` Detail string `json:"detail,omitempty"` Code string `json:"code,omitempty"` SubmitText string `json:"submitText,omitempty"` CheckpointTurn *int `json:"checkpointTurn,omitempty"` Reasoning string `json:"reasoning,omitempty"` MemoryCitations []provider.MemoryCitation `json:"memoryCitations,omitempty"` WorkDurationMs int64 `json:"workDurationMs,omitempty"` Level string `json:"level,omitempty"` ToolCalls []HistoryToolCall `json:"toolCalls,omitempty"` ToolCallID string `json:"toolCallId,omitempty"` ToolName string `json:"toolName,omitempty"` ToolResultArchived bool `json:"toolResultArchived,omitempty"` ToolResultError string `json:"toolResultError,omitempty"` Pending bool `json:"pending,omitempty"` Trigger string `json:"trigger,omitempty"` Messages int `json:"messages,omitempty"` Summary string `json:"summary,omitempty"` Archive string `json:"archive,omitempty"` } type HistoryToolCall struct { ID string `json:"id"` Name string `json:"name"` Arguments string `json:"arguments"` Subject string `json:"subject,omitempty"` Summary string `json:"summary,omitempty"` Diff string `json:"diff,omitempty"` Added int `json:"added,omitempty"` Removed int `json:"removed,omitempty"` ArgumentsArchived bool `json:"argumentsArchived,omitempty"` } const ( defaultHistoryPageTurns = 60 maxHistoryPageTurns = 200 ) type HistoryPage struct { Messages []HistoryMessage `json:"messages"` StartTurn int `json:"startTurn"` EndTurn int `json:"endTurn"` TotalTurns int `json:"totalTurns"` HasOlder bool `json:"hasOlder"` } // History returns the session's message log. func (a *App) History() []HistoryMessage { return a.HistoryForTab("") } func (a *App) HistoryPage(beforeTurn, limit int) HistoryPage { return a.HistoryPageForTab("", beforeTurn, limit) } func (a *App) HistoryPageForTab(tabID string, beforeTurn, limit int) HistoryPage { a.mu.RLock() tab := a.tabByIDLocked(tabID) var ctrl control.SessionAPI var sessionDir, sessionPath string if tab != nil { ctrl = tab.Ctrl sessionDir = tabSessionDir(tab) sessionPath = tab.currentSessionPath() } a.mu.RUnlock() if ctrl == nil { if strings.TrimSpace(sessionPath) == "" { return HistoryPage{Messages: []HistoryMessage{}} } page, err := previewSessionPage(sessionDir, sessionPath, beforeTurn, limit) if err != nil { return HistoryPage{Messages: []HistoryMessage{}} } return page } msgs := ctrl.History() dir := controllerSessionDir(ctrl) path := ctrl.SessionPath() return historyPageFromProviderMessages( msgs, sessionDisplayResolver(dir, path), sessionPlannerDisplayTurns(dir, path), ctrl.CheckpointTurnsByMessageIndex(), beforeTurn, limit, ) } func normalizeHistoryPageLimit(limit int) int { if limit <= 0 { return defaultHistoryPageTurns } if limit > maxHistoryPageTurns { return maxHistoryPageTurns } return limit } func historyPageFromMessages(messages []HistoryMessage, beforeTurn, limit int) HistoryPage { limit = normalizeHistoryPageLimit(limit) totalTurns := 0 for _, msg := range messages { if msg.Role == "user" { totalTurns++ } } if beforeTurn <= 0 || beforeTurn > totalTurns { beforeTurn = totalTurns } startTurn := beforeTurn - limit if startTurn < 0 { startTurn = 0 } page := HistoryPage{ StartTurn: startTurn, EndTurn: beforeTurn, TotalTurns: totalTurns, HasOlder: startTurn > 0, } if len(messages) == 0 || startTurn >= beforeTurn { page.Messages = []HistoryMessage{} return page } page.Messages = historyMessagesForTurnRange(messages, startTurn, beforeTurn) return page } func historyMessagesForTurnRange(messages []HistoryMessage, startTurn, endTurn int) []HistoryMessage { out := make([]HistoryMessage, 0, len(messages)) turn := -1 for _, msg := range messages { if msg.Role == "user" { turn++ } if turn < 0 { if startTurn == 0 { out = append(out, msg) } continue } if turn >= startTurn && turn < endTurn { out = append(out, msg) } } return out } func (a *App) HistoryForTab(tabID string) []HistoryMessage { a.mu.RLock() tab := a.tabByIDLocked(tabID) var ctrl control.SessionAPI var sessionDir, sessionPath string if tab != nil { ctrl = tab.Ctrl sessionDir = tabSessionDir(tab) sessionPath = tab.currentSessionPath() } a.mu.RUnlock() if ctrl == nil { if strings.TrimSpace(sessionPath) == "" { return []HistoryMessage{} } messages, err := previewSessionMessages(sessionDir, sessionPath) if err != nil { return []HistoryMessage{} } return messages } msgs := ctrl.History() dir := controllerSessionDir(ctrl) path := ctrl.SessionPath() return historyMessagesWithPlannerDisplays( msgs, sessionDisplayResolver(dir, path), sessionPlannerDisplayTurns(dir, path), ctrl.CheckpointTurnsByMessageIndex(), ) } func (a *App) HistoryCheckpointTurnsForTab(tabID string) []int { a.mu.RLock() tab := a.tabByIDLocked(tabID) var ctrl control.SessionAPI if tab != nil { ctrl = tab.Ctrl } a.mu.RUnlock() if ctrl == nil { return []int{} } return historyCheckpointTurns( ctrl.History(), sessionDisplayResolver(controllerSessionDir(ctrl), ctrl.SessionPath()), ctrl.CheckpointTurnsByMessageIndex(), ) } func historyCheckpointTurns(msgs []provider.Message, resolveUserContent func(string) string, checkpointTurns map[int]int) []int { out := make([]int, 0) for index, msg := range msgs { if msg.Role != provider.RoleUser { continue } if _, isSteer := agent.SteerText(msg.Content); isSteer { continue } if control.IsSyntheticUserMessage(resolveUserContent(msg.Content)) { continue } turn, ok := checkpointTurns[index] if !ok { turn = -1 } out = append(out, turn) } return out } func historyMessages(msgs []provider.Message, resolveUserContent func(string) string) []HistoryMessage { return historyMessagesWithPlannerDisplays(msgs, resolveUserContent, nil, nil) } func historyMessagesWithPlannerDisplays(msgs []provider.Message, resolveUserContent func(string) string, plannerTurns []plannerDisplayTurn, checkpointTurns map[int]int) []HistoryMessage { replayedTodoArgs := historyTodoArgsWithCompleteSteps(msgs) toolResults := historyToolResultsByID(msgs) return historyMessagesWithPlannerDisplaysAndLookups(msgs, resolveUserContent, plannerTurns, checkpointTurns, replayedTodoArgs, toolResults) } func historyMessagesWithPlannerDisplaysAndLookups( msgs []provider.Message, resolveUserContent func(string) string, plannerTurns []plannerDisplayTurn, checkpointTurns map[int]int, replayedTodoArgs map[string]string, toolResults map[string]provider.Message, ) []HistoryMessage { out := make([]HistoryMessage, 0, len(msgs)) plannerByUserHash := plannerTurnsByUserHash(plannerTurns) for index, m := range msgs { content := m.Content var checkpointTurn *int if m.Role == provider.RoleUser { // Mid-turn steer messages are persisted in the session so they // survive tab switches. They are surfaced as a notice (↪ text) // — matching the live Steer event look — rather than as a // regular user bubble or being filtered as synthetic (#4044). // Check against the raw m.Content: resolveUserContent applies // StripComposePrefixes which trims trailing whitespace. if steerText, isSteer := agent.SteerText(m.Content); isSteer { out = append(out, HistoryMessage{Role: "notice", Content: "↪ " + steerText}) continue } content = resolveUserContent(m.Content) if control.IsSyntheticUserMessage(content) { continue } if turn, ok := checkpointTurns[index]; ok { turnCopy := turn checkpointTurn = &turnCopy } } reasoning := "" if m.Role == provider.RoleAssistant { reasoning = m.ReasoningContent } hm := HistoryMessage{Role: string(m.Role), Content: content, CheckpointTurn: checkpointTurn, Reasoning: reasoning, WorkDurationMs: m.WorkDurationMs} if m.Role == provider.RoleAssistant && len(m.MemoryCitations) > 0 { hm.MemoryCitations = append([]provider.MemoryCitation(nil), m.MemoryCitations...) } if m.Role == provider.RoleUser && content != m.Content { if agent.ContainsMemoryCompilerExecution(m.Content) { // Never expose the compiler contract itself. A safely unwrapped // slash invocation is useful display metadata, though: it lets the // frontend restore the selected skill/subagent in history and trash. if replay := control.StripComposePrefixes(m.Content); strings.HasPrefix(strings.TrimSpace(replay), "/") && replay != content { hm.SubmitText = replay } } else { hm.SubmitText = m.Content } } if m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 { hm.ToolCalls = make([]HistoryToolCall, len(m.ToolCalls)) for i, tc := range m.ToolCalls { args := tc.Arguments if tc.Name == "todo_write" { if replayed, ok := replayedTodoArgs[tc.ID]; ok { args = replayed } } hm.ToolCalls[i] = historyToolCall(tc, args, toolResults[tc.ID]) } } if m.Role == provider.RoleTool { hm.ToolCallID = m.ToolCallID hm.ToolName = m.Name hm.Content, hm.ToolResultArchived, hm.ToolResultError = historyToolResultContent(m.Content, m.ToolCallID != "") } out = append(out, hm) if m.Role == provider.RoleUser { if turns := plannerByUserHash[messageDisplayKey(m.Content)]; len(turns) > 0 { out = append(out, cloneHistoryMessages(turns[0].Messages)...) plannerByUserHash[messageDisplayKey(m.Content)] = turns[1:] } } } return out } func historyPageFromProviderMessages( msgs []provider.Message, resolveUserContent func(string) string, plannerTurns []plannerDisplayTurn, checkpointTurns map[int]int, beforeTurn, limit int, ) HistoryPage { limit = normalizeHistoryPageLimit(limit) totalTurns := visibleHistoryUserTurns(msgs, resolveUserContent) if beforeTurn <= 0 || beforeTurn > totalTurns { beforeTurn = totalTurns } startTurn := beforeTurn - limit if startTurn < 0 { startTurn = 0 } page := HistoryPage{ StartTurn: startTurn, EndTurn: beforeTurn, TotalTurns: totalTurns, HasOlder: startTurn > 0, } if len(msgs) == 0 || startTurn >= beforeTurn { page.Messages = []HistoryMessage{} return page } pageMessages, originalIndexes := providerMessagesForVisibleTurnRange(msgs, resolveUserContent, startTurn, beforeTurn) page.Messages = historyMessagesWithPlannerDisplaysAndLookups( pageMessages, resolveUserContent, plannerTurns, checkpointTurnsForProviderWindow(checkpointTurns, originalIndexes), historyTodoArgsWithCompleteSteps(msgs), historyToolResultsByID(msgs), ) return page } func visibleHistoryUserTurns(msgs []provider.Message, resolveUserContent func(string) string) int { total := 0 for _, msg := range msgs { if isVisibleHistoryUser(msg, resolveUserContent) { total++ } } return total } func isVisibleHistoryUser(msg provider.Message, resolveUserContent func(string) string) bool { if msg.Role != provider.RoleUser { return false } if _, isSteer := agent.SteerText(msg.Content); isSteer { return false } return !control.IsSyntheticUserMessage(resolveUserContent(msg.Content)) } func providerMessagesForVisibleTurnRange(msgs []provider.Message, resolveUserContent func(string) string, startTurn, endTurn int) ([]provider.Message, []int) { out := make([]provider.Message, 0, len(msgs)) indexes := make([]int, 0, len(msgs)) turn := -1 for index, msg := range msgs { if isVisibleHistoryUser(msg, resolveUserContent) { turn++ } if turn < 0 { if startTurn == 0 { out = append(out, msg) indexes = append(indexes, index) } continue } if turn >= startTurn && turn < endTurn { out = append(out, msg) indexes = append(indexes, index) } } return out, indexes } func checkpointTurnsForProviderWindow(checkpointTurns map[int]int, originalIndexes []int) map[int]int { if len(checkpointTurns) == 0 || len(originalIndexes) == 0 { return nil } out := map[int]int{} for pageIndex, originalIndex := range originalIndexes { if turn, ok := checkpointTurns[originalIndex]; ok { out[pageIndex] = turn } } return out } func plannerTurnsByUserHash(turns []plannerDisplayTurn) map[string][]plannerDisplayTurn { out := map[string][]plannerDisplayTurn{} for _, turn := range turns { if strings.TrimSpace(turn.UserHash) == "" || len(turn.Messages) == 0 { continue } out[turn.UserHash] = append(out[turn.UserHash], turn) } return out } func cloneHistoryMessages(in []HistoryMessage) []HistoryMessage { if len(in) == 0 { return nil } out := make([]HistoryMessage, len(in)) copy(out, in) for i := range out { if len(in[i].MemoryCitations) > 0 { out[i].MemoryCitations = append([]provider.MemoryCitation(nil), in[i].MemoryCitations...) } if len(in[i].ToolCalls) > 0 { out[i].ToolCalls = append([]HistoryToolCall(nil), in[i].ToolCalls...) } } return out } const historyToolPreviewLimit = 2_000 func historyToolCall(tc provider.ToolCall, args string, result provider.Message) HistoryToolCall { call := HistoryToolCall{ ID: tc.ID, Name: tc.Name, Subject: historyToolSubject(tc.Name, args), Summary: historyToolSummary(tc.Name, args, result.Content), Diff: tc.Diff, Added: tc.Added, Removed: tc.Removed, } if tc.Name == "todo_write" { call.Arguments = args return call } if tc.ID == "" { call.Arguments = args return call } if args != "" { call.ArgumentsArchived = true } return call } func historyToolResultsByID(msgs []provider.Message) map[string]provider.Message { out := map[string]provider.Message{} for _, msg := range msgs { if msg.Role != provider.RoleTool || msg.ToolCallID == "" { continue } out[msg.ToolCallID] = msg } return out } func historyToolResultContent(content string, canArchive bool) (display string, archived bool, errPreview string) { if content == "" { return "", false, "" } if !canArchive { if historyToolResultFailed(content) { return content, false, content } return content, false, "" } if historyToolResultFailed(content) { display = clipHistoryToolPreview(strings.TrimSpace(content)) return display, display != content, display } return "", true, "" } func clipHistoryToolPreview(s string) string { if len(s) <= historyToolPreviewLimit { return s } return strings.TrimSpace(clipStringBytes(s, historyToolPreviewLimit)) + "\n..." } func historyToolSubject(name, args string) string { a := parseHistoryToolArgs(args) var subject string switch name { case "bash": subject = historyArgString(a, "command") case "grep", "glob": subject = firstNonEmpty(historyArgString(a, "pattern"), historyArgString(a, "path")) case "web_fetch": subject = historyArgString(a, "url") case "task": subject = firstNonEmpty(historyArgString(a, "description"), historyArgString(a, "prompt")) case "run_skill": subject = historyArgString(a, "name") case "move_file": src := historyArgString(a, "source_path") dst := historyArgString(a, "destination_path") if src != "" && dst != "" { subject = src + " -> " + dst } else { subject = firstNonEmpty(src, dst) } case "remember": subject = firstNonEmpty(historyArgString(a, "name"), historyArgString(a, "description")) case "todo_write", "exit_plan_mode": subject = "" default: subject = firstNonEmpty(historyArgString(a, "path"), historyArgString(a, "file_path")) } return clipSingleLine(subject, 240) } func historyToolSummary(name, args, output string) string { if historyToolResultFailed(output) { return "" } a := parseHistoryToolArgs(args) switch name { case "write_file": if content := historyArgString(a, "content"); content != "" { return fmt.Sprintf("%d lines", historyLineCount(content)) } case "edit_file": oldText := historyArgString(a, "old_string") newText := historyArgString(a, "new_string") if oldText != "" || newText != "" { return fmt.Sprintf("%d -> %d lines", historyLineCount(oldText), historyLineCount(newText)) } case "multi_edit": if edits, ok := a["edits"].([]any); ok && len(edits) > 0 { return fmt.Sprintf("%d edits", len(edits)) } } if output == "" { return "" } switch name { case "read_file": if strings.HasPrefix(output, "(empty file)") { return "empty file" } if arrows := strings.Count(output, "→"); arrows > 0 { return fmt.Sprintf("%d lines", arrows) } return fmt.Sprintf("%d lines", historyLineCount(output)) case "grep": return fmt.Sprintf("%d matches", historyNonEmptyLineCount(output)) case "glob": return fmt.Sprintf("%d files", historyNonEmptyLineCount(output)) case "ls": return fmt.Sprintf("%d entries", historyNonEmptyLineCount(output)) case "web_fetch": return clipSingleLine(strings.SplitN(output, "\n", 2)[0], 80) case "bash": if strings.TrimSpace(output) == "" { return "no output" } return fmt.Sprintf("%d lines", historyLineCount(output)) default: return "" } } func parseHistoryToolArgs(args string) map[string]any { if args == "" { return map[string]any{} } var out map[string]any if err := json.Unmarshal([]byte(args), &out); err != nil { return map[string]any{} } return out } func historyArgString(args map[string]any, key string) string { if v, ok := args[key].(string); ok { return v } return "" } func historyLineCount(s string) int { if s == "" { return 0 } s = strings.TrimSuffix(s, "\n") if s == "" { return 0 } return strings.Count(s, "\n") + 1 } func historyNonEmptyLineCount(s string) int { count := 0 for _, line := range strings.Split(s, "\n") { if strings.TrimSpace(line) != "" { count++ } } return count } func clipSingleLine(s string, max int) string { s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ") if len(s) <= max { return s } if max <= 3 { return clipStringBytes(s, max) } return clipStringBytes(s, max-3) + "..." } func clipStringBytes(s string, max int) string { if max <= 0 { return "" } if len(s) <= max { return s } for max > 0 && !utf8.RuneStart(s[max]) { max-- } return s[:max] } func historyTodoArgsWithCompleteSteps(msgs []provider.Message) map[string]string { successful := successfulHistoryToolCallIDs(msgs) out := map[string]string{} var todos []evidence.TodoItem latestTodoID := "" for _, m := range msgs { for _, tc := range m.ToolCalls { if tc.ID == "" || !successful[tc.ID] { continue } switch tc.Name { case "todo_write": rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true) if len(rec.Todos) == 0 { continue } todos = append([]evidence.TodoItem(nil), rec.Todos...) latestTodoID = tc.ID if args, ok := todoArgsJSON(todos); ok { out[latestTodoID] = args } case "complete_step": if latestTodoID == "" || len(todos) == 0 { continue } rec := evidence.ReceiptFromToolCall(tc.Name, json.RawMessage(tc.Arguments), true, true) match, ok := evidence.MatchStep(rec.Step, todos) if !ok || match.Index < 1 || match.Index > len(todos) || todoStatusForHistory(todos[match.Index-1].Status) == "completed" { continue } todos[match.Index-1].Status = "completed" promoteNextHistoryTodo(todos) if args, ok := todoArgsJSON(todos); ok { out[latestTodoID] = args } } } } return out } func successfulHistoryToolCallIDs(msgs []provider.Message) map[string]bool { successful := map[string]bool{} for _, msg := range msgs { if msg.Role != provider.RoleTool || msg.ToolCallID == "" { continue } if !historyToolResultFailed(msg.Content) { successful[msg.ToolCallID] = true } } return successful } func historyToolResultFailed(content string) bool { content = strings.TrimSpace(content) return strings.HasPrefix(content, "error:") || strings.HasPrefix(content, "blocked:") || strings.HasPrefix(content, "Error:") || strings.HasPrefix(content, "[error") } func todoArgsJSON(todos []evidence.TodoItem) (string, bool) { b, err := json.Marshal(map[string]any{"todos": todos}) if err != nil { return "", false } return string(b), true } func promoteNextHistoryTodo(todos []evidence.TodoItem) { for _, todo := range todos { if todoStatusForHistory(todo.Status) == "in_progress" { return } } for i := range todos { if todoStatusForHistory(todos[i].Status) == "pending" { todos[i].Status = "in_progress" return } } } func todoStatusForHistory(status string) string { status = strings.TrimSpace(status) if status == "" { return "pending" } return status } func previewSessionMessages(sessionDir, path string) ([]HistoryMessage, error) { sessionPath, _, err := validateSessionPath(sessionDir, path) if err != nil { return nil, err } if out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil { return out, err } loaded, err := agent.LoadSession(sessionPath) if err != nil { return nil, err } return historyMessagesWithPlannerDisplays( loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath), sessionPlannerDisplayTurns(sessionDir, sessionPath), nil, ), nil } func previewSessionPage(sessionDir, path string, beforeTurn, limit int) (HistoryPage, error) { sessionPath, _, err := validateSessionPath(sessionDir, path) if err != nil { return HistoryPage{}, err } if out, ok, err := previewEventSessionMessages(sessionPath); ok || err != nil { if err != nil { return HistoryPage{}, err } return historyPageFromMessages(out, beforeTurn, limit), nil } loaded, err := agent.LoadSession(sessionPath) if err != nil { return HistoryPage{}, err } return historyPageFromProviderMessages( loaded.Snapshot(), sessionDisplayResolver(sessionDir, sessionPath), sessionPlannerDisplayTurns(sessionDir, sessionPath), nil, beforeTurn, limit, ), nil } type previewEventRecord struct { Kind string `json:"kind"` Type string `json:"type"` Role string `json:"role"` Time json.RawMessage `json:"time"` Timestamp json.RawMessage `json:"timestamp"` CreatedAt json.RawMessage `json:"createdAt"` CreatedAtSnake json.RawMessage `json:"created_at"` UpdatedAt json.RawMessage `json:"updatedAt"` UpdatedAtSnake json.RawMessage `json:"updated_at"` Text string `json:"text"` Detail string `json:"detail"` Code string `json:"code"` Content string `json:"content"` Reasoning string `json:"reasoning"` ReasoningContent string `json:"reasoningContent"` MemoryCitations []provider.MemoryCitation `json:"memoryCitations"` Level string `json:"level"` ToolCalls []previewToolCall `json:"toolCalls"` CallID string `json:"callId"` ToolCallID string `json:"toolCallId"` ToolName string `json:"toolName"` Name string `json:"name"` Output string `json:"output"` Compaction *previewCompaction `json:"compaction"` Trigger string `json:"trigger"` Messages int `json:"messages"` Summary string `json:"summary"` Archive string `json:"archive"` } type previewToolCall struct { ID string `json:"id"` Name string `json:"name"` Arguments string `json:"arguments"` Function struct { Name string `json:"name"` Arguments string `json:"arguments"` } `json:"function"` } type previewCompaction struct { Trigger string `json:"trigger"` Messages int `json:"messages"` Summary string `json:"summary"` Archive string `json:"archive"` } func previewEventSessionMessages(path string) ([]HistoryMessage, bool, error) { f, err := os.Open(path) if err != nil { return nil, false, err } defer f.Close() dec := json.NewDecoder(f) out := []HistoryMessage{} toolName := map[string]string{} sawEvent := false for { var rec previewEventRecord if err := dec.Decode(&rec); err != nil { if errors.Is(err, io.EOF) { break } if sawEvent { return out, true, nil } return nil, false, nil } eventName := strings.TrimSpace(rec.Kind) if eventName == "" { eventName = strings.TrimSpace(rec.Type) } if eventName == "" { continue } sawEvent = true switch eventName { case "user.message": if rec.Text != "" { out = append(out, HistoryMessage{Role: "user", Content: rec.Text}) } case "model.final": hm := HistoryMessage{Role: "assistant", Content: rec.Content, Reasoning: firstNonEmpty(rec.Reasoning, rec.ReasoningContent)} if len(rec.MemoryCitations) > 0 { hm.MemoryCitations = append([]provider.MemoryCitation(nil), rec.MemoryCitations...) } for _, tc := range rec.ToolCalls { id := tc.ID name := firstNonEmpty(tc.Name, tc.Function.Name) args := firstNonEmpty(tc.Arguments, tc.Function.Arguments) hm.ToolCalls = append(hm.ToolCalls, historyToolCall(provider.ToolCall{ID: id, Name: name, Arguments: args}, args, provider.Message{})) if id != "" { toolName[id] = name } } out = append(out, hm) case "tool.result": callID := firstNonEmpty(rec.CallID, rec.ToolCallID) content := firstNonEmpty(rec.Output, rec.Content) display, archived, errPreview := historyToolResultContent(content, callID != "") if len(out) > 0 && callID != "" { updateHistoryToolCallSummary(out, callID, content) } out = append(out, HistoryMessage{ Role: "tool", ToolCallID: callID, ToolName: firstNonEmpty(rec.ToolName, rec.Name, toolName[callID]), Content: display, ToolResultArchived: archived, ToolResultError: errPreview, }) case "phase": out = append(out, HistoryMessage{Role: "phase", Content: firstNonEmpty(rec.Text, rec.Content)}) case "notice": level := rec.Level if level != "warn" { level = "info" } out = append(out, HistoryMessage{Role: "notice", Level: level, Content: firstNonEmpty(rec.Text, rec.Content), Detail: rec.Detail, Code: rec.Code}) case "compaction_started": c := rec.compactionPayload() out = append(out, HistoryMessage{Role: "compaction", Pending: true, Trigger: c.Trigger}) case "compaction_done": c := rec.compactionPayload() out = append(out, HistoryMessage{ Role: "compaction", Trigger: c.Trigger, Messages: c.Messages, Summary: c.Summary, Archive: c.Archive, }) } } return out, sawEvent, nil } func (r previewEventRecord) compactionPayload() previewCompaction { if r.Compaction != nil { return *r.Compaction } return previewCompaction{Trigger: r.Trigger, Messages: r.Messages, Summary: r.Summary, Archive: r.Archive} } func updateHistoryToolCallSummary(out []HistoryMessage, callID, output string) { if callID == "" { return } for i := len(out) - 1; i >= 0; i-- { for j := range out[i].ToolCalls { call := &out[i].ToolCalls[j] if call.ID != callID { continue } if call.Summary == "" { call.Summary = historyToolSummary(call.Name, call.Arguments, output) } return } } } func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { return value } } return "" } // ContextInfo is the prompt-vs-window gauge payload plus session totals. Used // and Window both zero means no context-window data yet. type ContextInfo struct { Used int `json:"used"` Window int `json:"window"` SessionTokens int `json:"sessionTokens"` CompactRatio float64 `json:"compactRatio,omitempty"` SessionCost float64 `json:"sessionCost,omitempty"` SessionCurrency string `json:"sessionCurrency,omitempty"` CacheHitTokens int `json:"cacheHitTokens,omitempty"` CacheMissTokens int `json:"cacheMissTokens,omitempty"` Sources map[string]usageSourceStats `json:"sources,omitempty"` } // ContextUsage returns the latest context-window gauge numbers. func (a *App) ContextUsage() ContextInfo { return a.ContextUsageForTab("") } func (a *App) ContextUsageForTab(tabID string) ContextInfo { a.mu.RLock() tab := a.tabByIDLocked(tabID) var ctrl control.SessionAPI if tab != nil { ctrl = tab.Ctrl } a.mu.RUnlock() var info ContextInfo if tab != nil { // Re-key first: a controller-side rotation (typed /new) may have // swapped sessions without the App noticing, and the stale totals // would otherwise be reported — and then persisted — under the new // session (#5850). if ctrl != nil { if sp := ctrl.SessionPath(); sp != "" { tab.syncTelemetryToSession(sp) } } snap := tab.telemetrySnapshot() info.SessionTokens = snap.Usage.TotalTokens info.SessionCost = snap.Usage.SessionCost info.SessionCurrency = snap.Usage.SessionCurrency info.CacheHitTokens = snap.Usage.CacheHitTokens info.CacheMissTokens = snap.Usage.CacheMissTokens info.Sources = snap.Usage.Sources } if ctrl == nil { return info } used, window := ctrl.ContextSnapshot() info.Used = used info.Window = window info.CompactRatio = ctrl.CompactRatio() return info } // BalanceInfo is the wallet-balance readout for the status bar. Available is true // only when a balance was fetched; Display is the formatted amount (e.g. "¥110.00") // and is "" when the active provider declares no balance_url — the frontend then // omits the readout. Err carries a fetch failure for an optional tooltip. type BalanceInfo struct { Available bool `json:"available"` Display string `json:"display"` Err string `json:"err,omitempty"` } // Balance queries the active provider's wallet balance (a network call). It // returns an empty (unavailable) readout when no provider balance_url is set, the // controller is down, or the fetch fails — so the status bar simply shows nothing // rather than an error. func (a *App) Balance() BalanceInfo { return a.BalanceForTab("") } func (a *App) BalanceForTab(tabID string) BalanceInfo { ctrl := a.ctrlByTabID(tabID) if ctrl == nil { return BalanceInfo{} } b, err := ctrl.Balance(a.ctx) if err != nil { return BalanceInfo{Err: err.Error()} } if b == nil { return BalanceInfo{} // provider declares no balance endpoint } return BalanceInfo{Available: true, Display: b.Display()} } // JobView is one running background job (bash/task started with // run_in_background) for the status-bar indicator. type JobView struct { ID string `json:"id"` Kind string `json:"kind"` Label string `json:"label"` Status string `json:"status"` StartedAt int64 `json:"startedAt"` } // Jobs returns the still-running background jobs for the status bar. It refreshes // on demand (mount, turn end, and on each notice the frontend receives). func (a *App) Jobs() []JobView { out := []JobView{} ctrl := a.ctrlByTabID("") return a.jobsForCtrl(ctrl, out) } func (a *App) JobsForTab(tabID string) []JobView { out := []JobView{} ctrl := a.ctrlByTabID(tabID) return a.jobsForCtrl(ctrl, out) } func (a *App) jobsForCtrl(ctrl control.SessionAPI, out []JobView) []JobView { if ctrl == nil { return out } for _, v := range ctrl.Jobs() { out = append(out, JobView{ID: v.ID, Kind: v.Kind, Label: v.Label, Status: v.Status, StartedAt: v.StartedAt}) } return out } // Meta describes the session for the frontend's header and status line. type Meta struct { Label string `json:"label"` Ready bool `json:"ready"` StartupErr string `json:"startupErr,omitempty"` EventChannel string `json:"eventChannel"` Cwd string `json:"cwd"` WorkspaceRoot string `json:"workspaceRoot,omitempty"` WorkspaceName string `json:"workspaceName,omitempty"` WorkspacePath string `json:"workspacePath,omitempty"` GitBranch string `json:"gitBranch,omitempty"` ImageInputEnabled bool `json:"imageInputEnabled"` AutoApproveTools bool `json:"autoApproveTools"` Bypass bool `json:"bypass"` // legacy JSON key for YOLO/full-access tool auto-approval CollaborationMode string `json:"collaborationMode"` ToolApprovalMode string `json:"toolApprovalMode"` TokenMode string `json:"tokenMode"` Goal string `json:"goal,omitempty"` GoalStatus string `json:"goalStatus,omitempty"` AutoResearch *AutoResearchCompactView `json:"autoResearch,omitempty"` } type AutoResearchCompactView struct { TaskID string `json:"taskId"` Status string `json:"status"` Iteration int `json:"iteration"` PivotRequired bool `json:"pivotRequired"` StaleCount int `json:"staleCount"` } type AutoResearchCriterionView struct { ID string `json:"id"` Description string `json:"description"` Required bool `json:"required"` EvidenceCount int `json:"evidenceCount"` Status string `json:"status"` } type AutoResearchStatusView struct { TaskID string `json:"taskId"` Goal string `json:"goal"` Status string `json:"status"` Iteration int `json:"iteration"` CurrentDirection string `json:"currentDirection"` StaleCount int `json:"staleCount"` PivotCount int `json:"pivotCount"` PivotRequired bool `json:"pivotRequired"` LastHeartbeatAt string `json:"lastHeartbeatAt"` FindingCount int `json:"findingCount"` OpenCriteria []AutoResearchCriterionView `json:"openCriteria"` Blocker string `json:"blocker"` TaskPath string `json:"taskPath"` NextRequiredAction string `json:"nextRequiredAction"` } type AutoResearchFindingView struct { ID string `json:"id"` Kind string `json:"kind"` Summary string `json:"summary"` Source string `json:"source"` Command string `json:"command,omitempty"` Paths []string `json:"paths,omitempty"` Accepted bool `json:"accepted"` CreatedAt string `json:"createdAt"` } type AutoResearchEvidenceView struct { ID string `json:"id"` Kind string `json:"kind"` Summary string `json:"summary"` Source string `json:"source"` Command string `json:"command,omitempty"` Paths []string `json:"paths,omitempty"` Accepted bool `json:"accepted"` } // Meta reports the model label, readiness, any startup error, the working // directory (for the status line), and the runtime event channel the frontend // subscribes to. func (a *App) Meta() Meta { return a.MetaForTab("") } func (a *App) imageInputEnabledForTab(tabID string) bool { a.mu.RLock() tab := a.tabByIDLocked(tabID) var ref, root string if tab != nil { ref = tab.model root = tab.WorkspaceRoot } a.mu.RUnlock() if tab == nil { return false } cfg, err := config.LoadForRoot(root) if err == nil && ref == "" { ref = cfg.DefaultModel } if err != nil || ref == "" { return false } entry, ok := cfg.ResolveModel(ref) return ok && config.EffectiveVision(entry) } func (a *App) MetaForTab(tabID string) Meta { a.mu.RLock() tab := a.tabByIDLocked(tabID) snap := snapshotTabRuntimeLocked(tab) a.mu.RUnlock() if tab == nil { return Meta{EventChannel: eventChannel} } cwd := snap.workspaceRoot if cwd == "" { cwd, _ = os.Getwd() } autoApproveTools := snap.ctrl != nil && snap.ctrl.AutoApproveTools() collaborationMode := snap.collaborationMode() toolApprovalMode := snap.currentToolApprovalMode() tokenMode := snap.currentTokenMode() goal := snap.currentGoal() goalStatus := snap.currentGoalStatus() return Meta{ Label: snap.label, Ready: snap.ready, StartupErr: snap.startupErr, EventChannel: eventChannel, Cwd: cwd, WorkspaceRoot: cwd, WorkspaceName: tabWorkspaceNameForScope(snap.scope, cwd), WorkspacePath: cwd, GitBranch: workspaceGitBranchForMeta(cwd), ImageInputEnabled: a.imageInputEnabledForTab(tabID), AutoApproveTools: autoApproveTools, Bypass: autoApproveTools, CollaborationMode: collaborationMode, ToolApprovalMode: toolApprovalMode, TokenMode: tokenMode, Goal: goal, GoalStatus: goalStatus, AutoResearch: compactAutoResearchFromController(snap.ctrl), } } func compactAutoResearchFromController(ctrl control.SessionAPI) *AutoResearchCompactView { if ctrl == nil { return nil } summary, ok := ctrl.AutoResearchSummary() if !ok || summary == nil || summary.TaskID == "" { return nil } return &AutoResearchCompactView{ TaskID: summary.TaskID, Status: summary.Status, Iteration: summary.Iteration, PivotRequired: summary.PivotRequired, StaleCount: summary.StaleCount, } } func compactAutoResearch(tab *WorkspaceTab) *AutoResearchCompactView { if tab == nil || tab.Ctrl == nil { return nil } summary, ok := tab.Ctrl.AutoResearchSummary() if !ok || summary == nil || summary.TaskID == "" { return nil } return &AutoResearchCompactView{ TaskID: summary.TaskID, Status: summary.Status, Iteration: summary.Iteration, PivotRequired: summary.PivotRequired, StaleCount: summary.StaleCount, } } func autoResearchStatusView(summary *autoresearch.Summary) AutoResearchStatusView { if summary == nil { return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}} } open := make([]AutoResearchCriterionView, 0, len(summary.OpenCriteria)) for _, criterion := range summary.OpenCriteria { open = append(open, AutoResearchCriterionView{ ID: criterion.ID, Description: criterion.Description, Required: criterion.Required, EvidenceCount: criterion.EvidenceCount, Status: criterion.Status, }) } return AutoResearchStatusView{ TaskID: summary.TaskID, Goal: summary.Goal, Status: summary.Status, Iteration: summary.Iteration, CurrentDirection: summary.CurrentDirection, StaleCount: summary.StaleCount, PivotCount: summary.PivotCount, PivotRequired: summary.PivotRequired, LastHeartbeatAt: summary.LastHeartbeatAt.Format(time.RFC3339), FindingCount: summary.FindingCount, OpenCriteria: open, Blocker: summary.Blocker, TaskPath: summary.TaskPath, NextRequiredAction: summary.NextRequiredAction, } } func (a *App) AutoResearchCurrent() AutoResearchStatusView { return a.AutoResearchStatus("") } func (a *App) AutoResearchStatus(tabID string) AutoResearchStatusView { ctrl := a.ctrlByTabID(tabID) if ctrl == nil { return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}} } summary, ok := ctrl.AutoResearchSummary() if !ok { return AutoResearchStatusView{OpenCriteria: []AutoResearchCriterionView{}} } return autoResearchStatusView(summary) } func (a *App) AutoResearchList(tabID string) []AutoResearchStatusView { ctrl := a.ctrlByTabID(tabID) if ctrl == nil { return []AutoResearchStatusView{} } summaries, ok := ctrl.AutoResearchList() if !ok { return []AutoResearchStatusView{} } out := make([]AutoResearchStatusView, 0, len(summaries)) for i := range summaries { out = append(out, autoResearchStatusView(&summaries[i])) } return out } func (a *App) AutoResearchFindings(tabID string, limit int) []AutoResearchFindingView { ctrl := a.ctrlByTabID(tabID) if ctrl == nil { return []AutoResearchFindingView{} } findings, ok := ctrl.AutoResearchFindings(limit) if !ok { return []AutoResearchFindingView{} } out := make([]AutoResearchFindingView, 0, len(findings)) for _, finding := range findings { out = append(out, AutoResearchFindingView{ ID: finding.ID, Kind: finding.Kind, Summary: finding.Summary, Source: finding.Source, Command: finding.Command, Paths: append([]string(nil), finding.Paths...), Accepted: finding.Accepted, CreatedAt: finding.CreatedAt.Format(time.RFC3339), }) } return out } func (a *App) AutoResearchOpenTask(tabID string) error { status := a.AutoResearchStatus(tabID) if strings.TrimSpace(status.TaskPath) == "" { return os.ErrInvalid } return a.RevealPath(status.TaskPath) } func (a *App) AutoResearchRecordEvidence(tabID, criterionID string, input AutoResearchEvidenceView) error { ctrl := a.ctrlByTabID(tabID) if ctrl == nil { return os.ErrInvalid } return ctrl.RecordAutoResearchEvidence(criterionID, control.AutoResearchEvidenceInput{ ID: input.ID, Kind: input.Kind, Summary: input.Summary, Source: input.Source, Command: input.Command, Paths: append([]string(nil), input.Paths...), Accepted: input.Accepted, }) } func (a *App) SetGoal(goal string) { a.SetGoalForTab("", goal) } func (a *App) SetGoalForTab(tabID, goal string) { goal = strings.TrimSpace(goal) a.mu.Lock() tab := a.tabByIDLocked(tabID) if tab == nil { a.mu.Unlock() return } tab.goal = goal if goal != "" { tab.mode = tabModeFromAxes(false, currentTabToolApprovalMode(tab) == control.ToolApprovalYolo) } ctrl := tab.Ctrl plan := tabModeHasPlan(tab.mode) tabIDForSave := tab.ID a.mu.Unlock() if ctrl != nil { ctrl.SetPlanMode(plan) ctrl.SetGoal(goal) } a.mu.Lock() if a.tabs[tabIDForSave] == tab { a.saveTabsLocked() } a.mu.Unlock() } func (a *App) ClearGoal() { a.SetGoal("") } func (a *App) ClearGoalForTab(tabID string) { a.SetGoalForTab(tabID, "") } // SetAutoApproveTools toggles YOLO/full-access tool auto-approval: // approval-gated tool calls run without asking, while ask questions and plan // approvals still wait for the user. Runtime-only — not written to config. func (a *App) SetAutoApproveTools(on bool) { if on { a.SetToolApprovalModeForTab("", control.ToolApprovalYolo) return } a.SetToolApprovalModeForTab("", control.ToolApprovalAsk) } // SetBypass is the legacy Wails binding for SetAutoApproveTools. func (a *App) SetBypass(on bool) { a.SetAutoApproveTools(on) } func (a *App) SetToolApprovalMode(mode string) { a.SetToolApprovalModeForTab("", mode) } func (a *App) SetToolApprovalModeForTab(tabID, mode string) { mode = normalizeToolApprovalMode(mode) a.mu.Lock() tab := a.tabByIDLocked(tabID) if tab == nil { a.mu.Unlock() return } tab.toolApprovalMode = mode tab.mode = tabModeFromAxes(tabModeHasPlan(currentTabMode(tab)), mode == control.ToolApprovalYolo) ctrl := tab.Ctrl tabIDForSave := tab.ID a.mu.Unlock() applyTabToolApprovalModeToController(ctrl, mode) a.mu.Lock() if a.tabs[tabIDForSave] == tab { a.saveTabsLocked() } a.mu.Unlock() } // CommandInfo describes one available slash command for the composer's "/" menu. type CommandInfo struct { Name string `json:"name"` // without the leading slash Description string `json:"description"` Hint string `json:"hint,omitempty"` // argument hint, if any Kind string `json:"kind"` // "builtin" | "custom" | "mcp" | "skill" | "subagent" Group string `json:"group,omitempty"` // menu group; older frontends can ignore it Plugin string `json:"plugin,omitempty"` Color string `json:"color,omitempty"` } // Commands lists the slash commands available this session — built-in actions, // custom commands (.reasonix/commands), and MCP prompts — for the composer's "/" // autocomplete menu. func (a *App) Commands() []CommandInfo { out := []CommandInfo{ {Name: "new", Description: i18n.M.CmdNew, Kind: "builtin", Group: "actions"}, {Name: "clear", Description: i18n.M.CmdClear, Kind: "builtin", Group: "actions"}, {Name: "compact", Description: i18n.M.CmdCompact, Kind: "builtin", Group: "actions"}, {Name: "model", Description: i18n.M.CmdModel, Kind: "builtin", Group: "actions"}, {Name: "provider", Description: i18n.M.CmdProvider, Kind: "builtin", Group: "management"}, {Name: "effort", Description: i18n.M.CmdEffort, Kind: "builtin", Group: "actions"}, {Name: "memory", Description: i18n.M.CmdMemory, Kind: "builtin", Group: "management"}, {Name: "migrate", Description: i18n.M.CmdMigrate, Kind: "builtin", Group: "management"}, {Name: "goal", Description: i18n.M.CmdGoal, Kind: "builtin", Group: "actions"}, {Name: "remember", Description: i18n.M.CmdRemember, Kind: "builtin", Group: "management"}, {Name: "mcp", Description: i18n.M.CmdMcp, Kind: "builtin", Group: "integrations"}, {Name: "hooks", Description: i18n.M.CmdHooks, Kind: "builtin", Group: "management"}, {Name: "plugins", Description: i18n.M.CmdPlugins, Kind: "builtin", Group: "integrations"}, {Name: "theme", Description: i18n.M.CmdTheme, Kind: "builtin", Group: "management"}, {Name: "skill", Description: i18n.M.CmdSkill, Kind: "builtin", Group: "skills"}, {Name: "reload-cmd", Description: i18n.M.CmdReloadCmd, Kind: "builtin", Group: "management"}, } a.mu.RLock() ctrl := a.activeCtrlLocked() a.mu.RUnlock() if ctrl == nil { return out } // Skills are invocable as slash commands (the model runs inline ones; subagent ones // run isolated). Listing them here is what surfaces /init, /explore, … in the // composer's slash menu; selecting one submits its displayed slash name, which the controller // resolves via RunSkill. for _, s := range ctrl.SlashSkills() { kind := "skill" if s.RunAs == skill.RunSubagent { kind = "subagent" } group := "skills" if kind == "subagent" { group = "subagents" } out = append(out, CommandInfo{Name: s.SlashName(), Description: s.Description, Kind: kind, Group: group, Plugin: s.Plugin, Color: s.Color}) } for _, c := range ctrl.Commands() { if c.Hidden { continue } out = append(out, CommandInfo{Name: c.Name, Description: c.Description, Hint: c.ArgHint, Kind: "custom", Group: "skills", Plugin: c.Plugin}) } if h := ctrl.Host(); h != nil { for _, p := range h.Prompts() { out = append(out, CommandInfo{Name: p.Name, Description: p.Description, Kind: "mcp", Group: "integrations"}) } } return out } // SlashArgItem is one sub-command / argument suggestion for the composer's slash // menu (the part after the command word). Mirrors the CLI's arg completion via // the shared control.SlashArgItems, so desktop and CLI offer the same hints. type SlashArgItem struct { Label string `json:"label"` Insert string `json:"insert"` Hint string `json:"hint"` Descend bool `json:"descend"` } // SlashArgsResult carries the suggestions plus the byte offset in the input where // the current token begins, so the composer replaces just that token. type SlashArgsResult struct { Items []SlashArgItem `json:"items"` From int `json:"from"` } // SlashArgs completes the arguments of a management slash command (/mcp, /model, // /skill, /hooks) for the composer — the same logic the chat TUI uses. Empty // Items means the input has no structured arguments to complete. func (a *App) SlashArgs(input string) SlashArgsResult { a.mu.RLock() ctrl := a.activeCtrlLocked() model := "" if tab := a.activeTabLocked(); tab != nil { model = tab.model } a.mu.RUnlock() if ctrl == nil { return SlashArgsResult{Items: []SlashArgItem{}} } data := control.ArgData{ Skills: ctrl.Skills(), DisabledSkills: ctrl.DisabledSkills(), ConfiguredMCP: ctrl.ConfiguredMCPNames(), DisconnectedMCP: ctrl.DisconnectedMCPNames(), CurrentModel: model, } if names, err := pluginpkg.InstalledNames(config.ReasonixHomeDir()); err == nil { data.PluginNames = names } seen := map[string]bool{} for _, m := range a.Models() { data.ModelRefs = append(data.ModelRefs, m.Ref) if m.Provider != "" && !seen[m.Provider] { seen[m.Provider] = true data.ProviderNames = append(data.ProviderNames, m.Provider) } if m.Current { data.CurrentProvider = m.Provider } } if h := ctrl.Host(); h != nil { data.ServerNames = h.ServerNames() } items, from := control.SlashArgItems(input, data) // Non-nil so it serializes as a JSON array, never null — the frontend filters // over it directly. out := SlashArgsResult{Items: []SlashArgItem{}, From: from} for _, it := range items { out.Items = append(out.Items, SlashArgItem{Label: it.Label, Insert: it.Insert, Hint: it.Hint, Descend: it.Descend}) } return out } // CapabilitiesView is the MCP & Skills drawer's data: connected/failed MCP // servers and the discoverable skills, the GUI counterpart to `/mcp` + `/skill`. type CapabilitiesView struct { Servers []ServerView `json:"servers"` Skills []SkillView `json:"skills"` SkillRoots []SkillRootView `json:"skillRoots"` Plugins []PluginView `json:"plugins"` } // SkillsSettingsView is the skills management page's data, split from MCP // status so opening MCP settings does not scan skill roots. type SkillsSettingsView struct { Skills []SkillView `json:"skills"` SkillRoots []SkillRootView `json:"skillRoots"` } // ServerView is one MCP server for the drawer. Status is "connected" (with // tool/prompt/resource counts), "deferred" (enabled but idle), "failed" (with // the connection error), "initializing" (background startup in progress), or // "disabled". type ServerView struct { Name string `json:"name"` Transport string `json:"transport"` Status string `json:"status"` StartIntent string `json:"startIntent,omitempty"` RuntimeState string `json:"runtimeState,omitempty"` BuiltIn bool `json:"builtIn,omitempty"` Configured bool `json:"configured,omitempty"` AutoStart bool `json:"autoStart"` Tier string `json:"tier,omitempty"` Command string `json:"command,omitempty"` Args []string `json:"args,omitempty"` URL string `json:"url,omitempty"` EnvKeys []string `json:"envKeys,omitempty"` HeaderKeys []string `json:"headerKeys,omitempty"` Tools int `json:"tools"` Prompts int `json:"prompts"` Resources int `json:"resources"` HasTools bool `json:"hasTools,omitempty"` Error string `json:"error,omitempty"` ToolList []ToolView `json:"toolList,omitempty"` TrustedReadOnlyTools []string `json:"trustedReadOnlyTools,omitempty"` AuthStatus string `json:"authStatus,omitempty"` AuthURL string `json:"authUrl,omitempty"` AuthConfigured bool `json:"authConfigured,omitempty"` ManagedByPlugin string `json:"managedByPlugin,omitempty"` } type ToolView struct { Name string `json:"name"` Description string `json:"description"` ReadOnlyHint bool `json:"readOnlyHint,omitempty"` } // SkillView is one discoverable skill for the drawer. Also backs the // Subagents settings surface: the frontend filters this same list to // RunAs=="subagent" rather than calling a second, redundant endpoint. type SkillView struct { Name string `json:"name"` Description string `json:"description"` Scope string `json:"scope"` RunAs string `json:"runAs"` Enabled bool `json:"enabled"` Plugin string `json:"plugin,omitempty"` Model string `json:"model,omitempty"` Effort string `json:"effort,omitempty"` AllowedTools []string `json:"allowedTools,omitempty"` Color string `json:"color,omitempty"` // Invocation is the user-facing slash name; InvocationMode preserves the // frontmatter policy used by the subagent profile editor. Invocation string `json:"invocation,omitempty"` InvocationMode string `json:"invocationMode,omitempty"` // Body is the skill's full markdown body (post-frontmatter) — the // subagent profile editor pre-fills its system-prompt field from this. Body string `json:"body,omitempty"` // ConfiguredModel/ConfiguredEffort are the per-name overrides from // cfg.Agent.SubagentModels/SubagentEfforts (internal/boot's // subagentModelRef/subagentEffortRef read the same map at dispatch time). // This is the only lever for a built-in subagent's model/effort, since // built-ins have no editable frontmatter file to carry Model/Effort. ConfiguredModel string `json:"configuredModel,omitempty"` ConfiguredEffort string `json:"configuredEffort,omitempty"` } type SkillRootSkillView struct { Name string `json:"name"` Description string `json:"description"` Scope string `json:"scope"` RunAs string `json:"runAs"` Plugin string `json:"plugin,omitempty"` Model string `json:"model,omitempty"` Effort string `json:"effort,omitempty"` AllowedTools []string `json:"allowedTools,omitempty"` Color string `json:"color,omitempty"` Invocation string `json:"invocation,omitempty"` } // SkillRootView is one skill discovery root for the drawer's Sources section. type SkillRootView struct { Dir string `json:"dir"` Scope string `json:"scope"` Priority int `json:"priority"` Status string `json:"status"` Configured bool `json:"configured"` Removable bool `json:"removable"` Skills int `json:"skills"` SkillItems []SkillRootSkillView `json:"skillItems,omitempty"` Warning string `json:"warning,omitempty"` } // Capabilities projects the session's MCP servers (connected + failed) and skills // for the MCP & Skills drawer. Non-nil slices so the frontend can map over them. func (a *App) Capabilities() CapabilitiesView { skills := a.SkillsSettings() return CapabilitiesView{ Servers: a.MCPServers(), Skills: skills.Skills, SkillRoots: skills.SkillRoots, Plugins: a.Plugins(), } } // MCPServers returns only MCP server status for settings pages that do not need // skill discovery. func (a *App) MCPServers() []ServerView { return a.mcpServersView() } // SkillsSettings returns the skills management snapshot without MCP status. func (a *App) SkillsSettings() SkillsSettingsView { out := SkillsSettingsView{Skills: []SkillView{}, SkillRoots: []SkillRootView{}} a.mu.RLock() tab := a.activeTabLocked() var ctrl control.SessionAPI if tab != nil { ctrl = tab.Ctrl } a.mu.RUnlock() if ctrl == nil { return out } disabled := map[string]bool{} var configuredModels, configuredEfforts map[string]string if cfg, err := config.Load(); err == nil { for _, name := range cfg.Skills.DisabledSkills { if key := config.SkillNameKey(name); key != "" { disabled[key] = true } } configuredModels = cfg.Agent.SubagentModels configuredEfforts = cfg.Agent.SubagentEfforts } for _, s := range ctrl.AllSkills() { view := SkillView{ Name: s.Name, Description: s.Description, Scope: string(s.Scope), RunAs: string(s.RunAs), Enabled: !disabled[config.SkillNameKey(s.Name)], Plugin: s.Plugin, Model: s.Model, Effort: s.Effort, AllowedTools: append([]string{}, s.AllowedTools...), Color: s.Color, Invocation: "/" + s.SlashName(), InvocationMode: s.Invocation, ConfiguredModel: subagentOverrideFor(configuredModels, s.Name), ConfiguredEffort: subagentOverrideFor(configuredEfforts, s.Name), } // Body feeds only the Subagents editor's prompt prefill. Inline skills // fold references/ into Body at load time (hundreds of KB for a rich // skill library), and every Capabilities/Settings fetch would ship all // of it across the JSON bridge for nothing. if s.RunAs == skill.RunSubagent { view.Body = s.Body } out.Skills = append(out.Skills, view) } out.SkillRoots = a.cachedSkillRootsView() return out } // subagentOverrideFor resolves a per-name subagent override with the same // underscore/hyphen alias fallback the runtime dispatch uses // (boot.SubagentModelKeys) — an exact-key read would show a legacy // `security_review` config entry as "inherit default" while it still won at // dispatch time. func subagentOverrideFor(overrides map[string]string, name string) string { for _, key := range boot.SubagentModelKeys(name) { if v := strings.TrimSpace(overrides[key]); v != "" { return v } } return "" } // AvailableSubagentTools lists the tool names a subagent profile's // "available tools" picker may offer. Scoped to compile-time builtins for // v1 — MCP/plugin tools are per-session/per-connection and would need a new // live-registry accessor on control.Capabilities to enumerate safely; a // profile's allowed-tools already degrades gracefully (FilterRegistry drops // unknown names silently) if extended to MCP names by hand later. Tools that // are always excluded from every subagent regardless of an explicit // allowlist (agent.AlwaysHiddenSubagentTools) are left out entirely — they'd // be a selectable no-op otherwise. func (a *App) AvailableSubagentTools() []ToolView { hidden := map[string]bool{} for _, name := range agent.AlwaysHiddenSubagentTools() { hidden[name] = true } entries := tool.BuiltinContractEntries() out := make([]ToolView, 0, len(entries)) for _, e := range entries { if hidden[e.Name] { continue } out = append(out, ToolView{Name: e.Name, Description: e.Description, ReadOnlyHint: e.ReadOnly}) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } func (a *App) mcpServersView() []ServerView { out := []ServerView{} a.mu.RLock() tab := a.activeTabLocked() if tab == nil { a.mu.RUnlock() return out } ctrl := tab.Ctrl disabled := make(map[string]ServerView, len(tab.disabledMCP)) for name, s := range tab.disabledMCP { disabled[name] = s } order := append([]string(nil), tab.mcpOrder...) workspaceRoot := tab.WorkspaceRoot tabID := tab.ID a.mu.RUnlock() if ctrl == nil { return out } seen := map[string]bool{} connected := map[string]bool{} retainedDisabled := map[string]ServerView{} configured := map[string]config.PluginEntry{} managedByPlugin := map[string]string{} var configuredEntries []config.PluginEntry if cfg, err := config.LoadForRoot(workspaceRoot); err == nil { configuredEntries = append(configuredEntries, cfg.Plugins...) for _, p := range configuredEntries { configured[p.Name] = p if owner, ok := cfg.PluginPackageOwner(p.Name); ok { managedByPlugin[p.Name] = owner } } } if h := ctrl.Host(); h != nil { for _, s := range h.Servers() { if disabledView, ok := disabled[s.Name]; ok { disabledView.Status = "disabled" disabledView.RuntimeState = "idle" disabledView.StartIntent = "off" disabledView.Error = "" if p, ok := configured[s.Name]; ok { disabledView = withPluginConfig(disabledView, p) } out = append(out, disabledView) retainedDisabled[s.Name] = disabledView seen[s.Name] = true delete(disabled, s.Name) continue } seen[s.Name] = true connected[s.Name] = true view := ServerView{ Name: s.Name, Transport: s.Transport, Status: "connected", RuntimeState: "ready", Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources, HasTools: s.HasTools, ToolList: pluginToolsToView(s.ToolList), } if p, ok := configured[s.Name]; ok { view = withPluginConfig(view, p) } out = append(out, view) } for _, f := range h.Failures() { seen[f.Name] = true view := ServerView{ Name: f.Name, Transport: f.Transport, Status: "failed", RuntimeState: "issue", Error: f.Error, } if p, ok := configured[f.Name]; ok { view = withPluginConfig(view, p) } out = append(out, view) } for _, name := range h.ConnectingServers() { if seen[name] { continue } seen[name] = true view := ServerView{Name: name, Status: "initializing", RuntimeState: "connecting"} if p, ok := configured[name]; ok { view = withPluginConfig(view, p) } out = append(out, view) } } // Configured servers that are neither connected, connecting, nor failed are // idle: disabled/off or automatic background startup waiting for its next kick. if len(configuredEntries) > 0 { for _, p := range configuredEntries { if seen[p.Name] { continue } if s, ok := disabled[p.Name]; ok { s.Status = "disabled" s.RuntimeState = "idle" s.StartIntent = "off" s = withPluginConfig(s, p) s.Error = "" out = append(out, s) retainedDisabled[p.Name] = s seen[p.Name] = true delete(disabled, p.Name) continue } status := "disabled" startIntent := "off" if p.ShouldAutoStart() { status = "deferred" startIntent = mcpStartIntent(p) } out = append(out, withPluginConfig(ServerView{Name: p.Name, Status: status, StartIntent: startIntent, RuntimeState: "idle"}, p)) seen[p.Name] = true } } out = orderServerViews(out, order) for i := range out { out[i].ManagedByPlugin = managedByPlugin[out[i].Name] } a.mu.Lock() if tab, ok := a.tabs[tabID]; ok { for name := range connected { delete(retainedDisabled, name) } tab.disabledMCP = retainedDisabled tab.mcpOrder = mergeServerOrder(tab.mcpOrder, out) } a.mu.Unlock() return out } func mcpStartIntent(p config.PluginEntry) string { if !p.ShouldAutoStart() { return "off" } return "automatic" } func mcpRuntimeState(status string) string { switch status { case "connected": return "ready" case "initializing": return "connecting" case "failed": return "issue" default: return "idle" } } func withPluginConfig(v ServerView, p config.PluginEntry) ServerView { tt := p.Type if tt == "" { tt = "stdio" } v.Transport = tt v.Configured = true v.AutoStart = p.ShouldAutoStart() v.Tier = p.ResolvedTier() if v.StartIntent == "" { v.StartIntent = mcpStartIntent(p) } if v.Status == "disabled" { v.StartIntent = "off" } if v.RuntimeState == "" { v.RuntimeState = mcpRuntimeState(v.Status) } v.Command = p.Command v.Args = append([]string(nil), p.Args...) v.URL = p.URL v.TrustedReadOnlyTools = uniqueStrings(p.TrustedReadOnlyTools) v.AuthConfigured = mcpdiag.HasAuthConfig(p.Headers, p.Env, p.URL) v.EnvKeys = nil v.HeaderKeys = nil if len(p.Env) > 0 { v.EnvKeys = make([]string, 0, len(p.Env)) for k := range p.Env { v.EnvKeys = append(v.EnvKeys, k) } sort.Strings(v.EnvKeys) } if len(p.Headers) > 0 { v.HeaderKeys = make([]string, 0, len(p.Headers)) for k := range p.Headers { v.HeaderKeys = append(v.HeaderKeys, k) } sort.Strings(v.HeaderKeys) } auth := mcpdiag.DiagnoseAuth(v.Transport, v.Status, v.Error, v.URL, v.AuthConfigured) v.AuthStatus = auth.Status v.AuthURL = auth.URL return v } const skillRootsCacheTTL = 10 * time.Second func (a *App) cachedSkillRootsView() []SkillRootView { cwd, _ := os.Getwd() cfg, _ := config.Load() userCfg := config.LoadForEdit(config.UserConfigPath()) key := skillRootsCacheKey(cwd, cfg, userCfg) now := time.Now() a.skillRootsMu.Lock() if a.skillRootsCache.key == key && now.Sub(a.skillRootsCache.at) < skillRootsCacheTTL { roots := cloneSkillRootViews(a.skillRootsCache.roots) a.skillRootsMu.Unlock() return roots } a.skillRootsMu.Unlock() roots := skillRootsViewFrom(cwd, cfg, userCfg) a.skillRootsMu.Lock() a.skillRootsCache = skillRootsCache{ key: key, at: now, roots: cloneSkillRootViews(roots), } a.skillRootsMu.Unlock() return roots } func (a *App) invalidateSkillRootsCache() { a.skillRootsMu.Lock() a.skillRootsCache = skillRootsCache{} a.skillRootsMu.Unlock() } func skillRootsView() []SkillRootView { cwd, _ := os.Getwd() cfg, _ := config.Load() userCfg := config.LoadForEdit(config.UserConfigPath()) return skillRootsViewFrom(cwd, cfg, userCfg) } func skillRootsViewFrom(cwd string, cfg, userCfg *config.Config) []SkillRootView { var custom []string var excluded []string maxDepth := 3 if cfg != nil { custom = cfg.SkillCustomPaths() excluded = cfg.SkillExcludedPaths() maxDepth = cfg.SkillMaxDepth() } var pluginPaths map[string][]string if cfg != nil { pluginPaths = cfg.PluginPackageSkillOwners() } st := skill.New(skill.Options{ProjectRoot: cwd, CustomPaths: custom, PluginPaths: pluginPaths, ExcludedPaths: excluded, MaxDepth: maxDepth, DisableBuiltins: true, Stderr: io.Discard}) counts := map[string]int{} skillItems := map[string][]SkillRootSkillView{} roots := st.Roots() for _, sk := range st.SlashList() { root := skillDisplayRoot(sk, roots) counts[root]++ skillItems[root] = append(skillItems[root], SkillRootSkillView{ Name: sk.Name, Description: sk.Description, Scope: string(sk.Scope), RunAs: string(sk.RunAs), Plugin: sk.Plugin, Model: sk.Model, Effort: sk.Effort, AllowedTools: append([]string{}, sk.AllowedTools...), Color: sk.Color, Invocation: "/" + sk.SlashName(), }) } for root := range skillItems { sort.Slice(skillItems[root], func(i, j int) bool { return skillItems[root][i].Invocation < skillItems[root][j].Invocation }) } userConfigured := map[string]bool{} if userCfg != nil { for _, p := range userCfg.Skills.Paths { userConfigured[config.CanonicalSkillPath(p)] = true } } out := []SkillRootView{} seenRoots := map[string]int{} for _, r := range roots { dir := config.CanonicalSkillPath(r.Dir) view := SkillRootView{ Dir: r.Dir, Scope: string(r.Scope), Priority: r.Priority + 1, Status: string(r.Status), Configured: r.Scope == skill.ScopeCustom && userConfigured[dir], Removable: true, Skills: counts[dir], SkillItems: skillItems[dir], } if idx, ok := seenRoots[dir]; ok { out[idx] = mergeDuplicateSkillRootView(out[idx], view) continue } seenRoots[dir] = len(out) out = append(out, view) } if userCfg != nil { for _, p := range userCfg.Skills.Paths { if rootActive(out, p) { continue } out = append(out, SkillRootView{ Dir: p, Scope: string(skill.ScopeCustom), Status: "inactive", Configured: true, Removable: true, Warning: "configured in user config but not active in this workspace; project [skills].paths may override it", }) } } return out } func mergeDuplicateSkillRootView(existing, duplicate SkillRootView) SkillRootView { existing.Configured = existing.Configured || duplicate.Configured existing.Removable = existing.Removable || duplicate.Removable if existing.Status != "ok" && duplicate.Status == "ok" { existing.Status = duplicate.Status } if existing.Skills == 0 && duplicate.Skills > 0 { existing.Skills = duplicate.Skills existing.SkillItems = duplicate.SkillItems } if existing.Warning == "" { existing.Warning = duplicate.Warning } return existing } func skillRootsCacheKey(cwd string, cfg, userCfg *config.Config) string { type cacheKey struct { CWD string `json:"cwd"` Custom []string `json:"custom"` Plugins []string `json:"plugins"` Excluded []string `json:"excluded"` MaxDepth int `json:"maxDepth"` UserPaths []string `json:"userPaths"` } key := cacheKey{CWD: config.CanonicalSkillPath(cwd), MaxDepth: 3} if cfg != nil { key.Custom = canonicalSkillPaths(cfg.SkillCustomPaths()) for path, owners := range cfg.PluginPackageSkillOwners() { for _, owner := range owners { key.Plugins = append(key.Plugins, config.CanonicalSkillPath(path)+"\x00"+owner) } } sort.Strings(key.Plugins) key.Excluded = canonicalSkillPaths(cfg.SkillExcludedPaths()) key.MaxDepth = cfg.SkillMaxDepth() } if userCfg != nil { key.UserPaths = canonicalSkillPaths(userCfg.Skills.Paths) } b, err := json.Marshal(key) if err != nil { return fmt.Sprintf("%s|%v|%v|%v|%d|%v", key.CWD, key.Custom, key.Plugins, key.Excluded, key.MaxDepth, key.UserPaths) } return string(b) } func canonicalSkillPaths(paths []string) []string { out := make([]string, 0, len(paths)) for _, p := range paths { out = append(out, config.CanonicalSkillPath(p)) } sort.Strings(out) return out } func cloneSkillRootViews(in []SkillRootView) []SkillRootView { out := make([]SkillRootView, len(in)) for i, r := range in { out[i] = r out[i].SkillItems = append([]SkillRootSkillView(nil), r.SkillItems...) } return out } func rootActive(roots []SkillRootView, path string) bool { want := config.CanonicalSkillPath(path) for _, r := range roots { if config.CanonicalSkillPath(r.Dir) == want { return true } } return false } // PickSkillFolder opens a directory picker for adding custom skill roots. It only // returns a path; AddSkillPath performs normalization and writes config. func (a *App) PickSkillFolder() (string, error) { if a.ctx == nil { return "", nil } cur, _ := os.Getwd() dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{ Title: "Choose skills folder", DefaultDirectory: dialogDefaultDirectory(cur), }) if err != nil || dir == "" { return "", err } return normalizeSkillPath(dir), nil } // PickPluginFolder opens a directory picker for choosing a local plugin package // source. It returns the selected directory path; plugin install/plan performs // manifest validation and decides whether to copy or link the package. func (a *App) PickPluginFolder() (string, error) { if a.ctx == nil { return "", nil } cur := a.activeWorkspaceRoot() if strings.TrimSpace(cur) == "" { cur, _ = os.Getwd() } dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{ Title: "Choose plugin folder", DefaultDirectory: dialogDefaultDirectory(cur), }) if err != nil || dir == "" { return "", err } return filepath.Clean(dir), nil } // AddSkillPath adds a custom skill root to the user config and rebuilds the // controller so the skills index and slash menu reflect it immediately. func (a *App) AddSkillPath(path string) error { path = normalizeSkillPath(path) workspaceRoot := a.activeWorkspaceRoot() err := a.applyConfigChange(func(c *config.Config) error { if isConventionSkillRoot(path, workspaceRoot) { return c.RestoreSkillPath(path) } return c.AddSkillPath(path) }) if err == nil { a.invalidateSkillRootsCache() } return err } // RemoveSkillPath removes a skill source from the user config and rebuilds. For // convention roots, it records a pseudo-delete in excluded_paths. func (a *App) RemoveSkillPath(path string) error { path = normalizeSkillPath(path) err := a.applyConfigChange(func(c *config.Config) error { removed, err := c.RemoveSkillPath(path) if err != nil || removed { return err } return c.ExcludeSkillPath(path) }) if err == nil { a.invalidateSkillRootsCache() } return err } // RefreshSkills rebuilds the controller without changing config, reloading skill // discovery, the system prompt index, and slash completions. func (a *App) RefreshSkills() error { a.invalidateSkillRootsCache() if err := a.rebuild(); err != nil { // The skill cache is already invalidated; refresh the runtime once the // other window releases the session lease. if _, ok := a.deferredRebuildWarning("skills", err); ok { return nil } return err } return nil } // ReloadCommands rescans command directories and hot-swaps without restarting // the controller — no MCP disconnect, no hook rerun. func (a *App) ReloadCommands() error { if a.ctx == nil { return nil } _, ctrl := a.activeTabAndCtrl() if ctrl == nil { return fmt.Errorf("no active session") } if ctrl.Running() { return fmt.Errorf("wait for the current turn to finish, then retry") } return ctrl.ReloadCommands(a.ctx) } // SetSkillEnabled persists a skill toggle and rebuilds the controller so the // prompt index, slash menu, and skill tools reflect it immediately. func (a *App) SetSkillEnabled(name string, enabled bool) error { err := a.applyConfigChange(func(c *config.Config) error { return c.SetSkillEnabled(name, enabled) }) if err == nil { a.invalidateSkillRootsCache() } return err } func normalizeSkillPath(path string) string { path = strings.TrimSpace(path) if path == "" { return "" } if path == "~" || strings.HasPrefix(path, "~/") || strings.HasPrefix(path, `~\`) { if home, err := os.UserHomeDir(); err == nil { if path == "~" { path = home } else { path = filepath.Join(home, path[2:]) } } } if abs, err := filepath.Abs(path); err == nil { path = abs } info, err := os.Stat(path) if err != nil { return filepath.Clean(path) } if info.Mode().IsRegular() { if filepath.Base(path) == skill.SkillFile { return filepath.Clean(filepath.Dir(filepath.Dir(path))) } return filepath.Clean(filepath.Dir(path)) } if info.IsDir() { if _, err := os.Stat(filepath.Join(path, skill.SkillFile)); err == nil { return filepath.Clean(filepath.Dir(path)) } } return filepath.Clean(path) } func isConventionSkillRoot(path, workspaceRoot string) bool { want := config.CanonicalSkillPath(path) if want == "" { return false } bases := []string{workspaceRoot} if home, err := os.UserHomeDir(); err == nil { bases = append(bases, home) } for _, base := range bases { base = strings.TrimSpace(base) if base == "" { continue } for _, dir := range config.ConventionDirs { if want == config.CanonicalSkillPath(filepath.Join(base, dir, skill.SkillsDirname)) { return true } } } return false } func skillRootPath(path string) string { if filepath.Base(path) == skill.SkillFile { return filepath.Dir(path) } return path } func skillDisplayRoot(sk skill.Skill, roots []skill.Root) string { cleanPath := filepath.Clean(sk.Path) for _, r := range roots { if r.Scope != sk.Scope { continue } cleanRoot := filepath.Clean(r.Dir) prefix := cleanRoot + string(filepath.Separator) if cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix) { return config.CanonicalSkillPath(r.Dir) } } return config.CanonicalSkillPath(filepath.Dir(skillRootPath(sk.Path))) } // MCPServerInput is the drawer's "add server" form. Transport is "stdio" (Command // + Args + Env) or "http"/"sse" (URL). Mirrors config.PluginEntry's writable shape. type MCPServerInput struct { Name string `json:"name"` Transport string `json:"transport"` Command string `json:"command"` Args []string `json:"args"` URL string `json:"url"` Env map[string]string `json:"env"` Headers map[string]string `json:"headers"` TrustedReadOnlyTools []string `json:"trustedReadOnlyTools"` } // AddMCPServer connects a server live and persists it to config (Customize → MCP → // Add). Returns the number of tools it exposed. func (a *App) AddMCPServer(in MCPServerInput) (int, error) { ctrl := a.activeCtrl() if ctrl == nil { return 0, fmt.Errorf("no active session") } if controllerHasActiveRuntimeWork(ctrl) { return 0, rebuildControllerActiveWorkError("MCP server") } entry := config.PluginEntry{ Name: in.Name, Type: normalizeMCPTransport(in.Transport), Command: in.Command, Args: in.Args, URL: in.URL, Env: in.Env, Headers: in.Headers, TrustedReadOnlyTools: uniqueStrings(in.TrustedReadOnlyTools), } entry, _ = config.NormalizePluginCommandLine(entry) if err := a.saveDesktopMCPServer(entry); err != nil { return 0, err } return ctrl.ConnectMCPServer(entry) } // UpdateMCPServer edits a persisted external MCP server. The name is the stable // identity; callers must remove + add if they want to rename a server. func (a *App) UpdateMCPServer(name string, in MCPServerInput) error { ctrl := a.activeCtrl() if ctrl == nil { return fmt.Errorf("no active session") } if controllerHasActiveRuntimeWork(ctrl) { return rebuildControllerActiveWorkError("MCP server") } if strings.TrimSpace(in.Name) != "" && strings.TrimSpace(in.Name) != name { return fmt.Errorf("renaming MCP servers is not supported; remove and add a new server") } updated, found, err := a.desktopMCPServerForEdit(name) if err != nil { return err } if !found { return fmt.Errorf("no configured MCP server named %q", name) } updated.Type = normalizeMCPTransport(in.Transport) updated.Command = strings.TrimSpace(in.Command) updated.Args = append([]string(nil), in.Args...) updated.URL = strings.TrimSpace(in.URL) updated.Tier = "" if in.Env != nil { updated.Env = in.Env } if in.Headers != nil { updated.Headers = in.Headers } if in.TrustedReadOnlyTools != nil { updated.TrustedReadOnlyTools = uniqueStrings(in.TrustedReadOnlyTools) } updated, _ = config.NormalizePluginCommandLine(updated) if updated.Type == "stdio" { updated.URL = "" } else { updated.Command = "" updated.Args = nil } if err := a.saveDesktopMCPServer(updated); err != nil { return err } a.mu.RLock() tab := a.activeTabLocked() sessionDisabled := false if tab != nil { _, sessionDisabled = tab.disabledMCP[name] } a.mu.RUnlock() wasConnected := mcpConnected(ctrl, name) if wasConnected { ctrl.DisconnectMCPServer(name) } if !sessionDisabled { if _, err := ctrl.ConnectMCPServer(updated); err != nil { recordMCPFailure(ctrl, updated, err) return nil } } return nil } // RemoveMCPServer disconnects a live server and drops it from config (the row's ✕). func (a *App) RemoveMCPServer(name string) error { tab, ctrl := a.activeTabAndCtrl() if tab == nil || ctrl == nil { return fmt.Errorf("no active session") } if controllerHasActiveRuntimeWork(ctrl) { return rebuildControllerActiveWorkError("MCP server") } if err := ensureMCPServerDirectlyWritable(tab.WorkspaceRoot, name); err != nil { return err } removed, err := a.removeDesktopMCPServer(tab.WorkspaceRoot, name) if err != nil { return err } if !removed { return fmt.Errorf("no removable MCP server named %q", name) } ctrl.DisconnectMCPServer(name) if h := ctrl.Host(); h != nil { h.ClearFailure(name) } a.mu.Lock() delete(tab.disabledMCP, name) tab.mcpOrder = removeServerOrder(tab.mcpOrder, name) a.mu.Unlock() return nil } // ReconnectMCPServer disconnects the server if it is already connected (to force // a fresh handshake and tool re-registration), then reconnects. Failures are // recorded on the Host so the UI can render them. func (a *App) ReconnectMCPServer(name string) error { tab, ctrl := a.activeTabAndCtrl() if tab == nil || ctrl == nil { return fmt.Errorf("no active session") } if controllerHasActiveRuntimeWork(ctrl) { return rebuildControllerActiveWorkError("MCP server") } ctrl.DisconnectMCPServer(name) if h := ctrl.Host(); h != nil { h.ClearFailure(name) } _, err := a.connectConfiguredMCPServerForTab(tab, name) if err != nil { if plugin.IsServerAlreadyConnected(err) { a.mu.Lock() delete(tab.disabledMCP, name) a.mu.Unlock() return nil } entry := config.PluginEntry{Name: name} if p, found, cfgErr := a.desktopMCPServerForEdit(name); cfgErr == nil && found { entry = p } recordMCPFailure(ctrl, entry, err) return err } a.mu.Lock() delete(tab.disabledMCP, name) a.mu.Unlock() return nil } // ClearMCPServerAuthentication removes local auth-like config for one MCP and // clears the current session's cached connection failure. It does not remove the // server itself or try to sign the user out of the third-party browser session. func (a *App) ClearMCPServerAuthentication(name string) error { tab, ctrl := a.activeTabAndCtrl() if tab == nil || ctrl == nil { return fmt.Errorf("no active session") } if controllerHasActiveRuntimeWork(ctrl) { return rebuildControllerActiveWorkError("MCP server") } if err := ensureMCPServerDirectlyWritable(tab.WorkspaceRoot, name); err != nil { return err } if _, _, _, err := config.ClearPluginAuthenticationInSource(name); err != nil { return err } ctrl.DisconnectMCPServer(name) if h := ctrl.Host(); h != nil { h.ClearFailure(name) } return nil } func (a *App) updateMCPServerTrustedReadOnlyTools(name string, update func([]string) []string) error { ctrl := a.activeCtrl() if ctrl == nil { return fmt.Errorf("no active session") } if controllerHasActiveRuntimeWork(ctrl) { return rebuildControllerActiveWorkError("MCP server") } name = strings.TrimSpace(name) if name == "" { return fmt.Errorf("MCP server name is required") } updated, found, err := a.desktopMCPServerForEdit(name) if err != nil { return err } if !found { return fmt.Errorf("no configured MCP server named %q", name) } trusted := uniqueStrings(updated.TrustedReadOnlyTools) next := uniqueStrings(update(trusted)) if sameStringList(trusted, next) { updated.TrustedReadOnlyTools = trusted return nil } updated.TrustedReadOnlyTools = next if err := a.saveDesktopMCPServer(updated); err != nil { return err } a.mu.RLock() tab := a.activeTabLocked() sessionDisabled := false if tab != nil { _, sessionDisabled = tab.disabledMCP[name] } a.mu.RUnlock() if !mcpConnected(ctrl, name) { return nil } ctrl.DisconnectMCPServer(name) if h := ctrl.Host(); h != nil { h.ClearFailure(name) } if !sessionDisabled { if _, err := ctrl.ConnectMCPServer(updated); err != nil { recordMCPFailure(ctrl, updated, err) return nil } } return nil } // TrustMCPServerTool marks one raw MCP tool name as trusted read-only and // refreshes the live connection so plan mode can use the updated trust boundary. func (a *App) TrustMCPServerTool(name, toolName string) error { toolName = strings.TrimSpace(toolName) if toolName == "" { return fmt.Errorf("MCP tool name is required") } return a.updateMCPServerTrustedReadOnlyTools(name, func(trusted []string) []string { return append(trusted, toolName) }) } // TrustMCPServerTools marks multiple raw MCP tool names as trusted read-only in // one config write and one live reconnect. func (a *App) TrustMCPServerTools(name string, toolNames []string) error { if len(uniqueStrings(toolNames)) == 0 { return fmt.Errorf("at least one MCP tool name is required") } return a.updateMCPServerTrustedReadOnlyTools(name, func(trusted []string) []string { return append(trusted, toolNames...) }) } // UntrustMCPServerTool removes one raw MCP tool name from the trusted read-only // list and refreshes the live connection. func (a *App) UntrustMCPServerTool(name, toolName string) error { toolName = strings.TrimSpace(toolName) if toolName == "" { return fmt.Errorf("MCP tool name is required") } return a.updateMCPServerTrustedReadOnlyTools(name, func(trusted []string) []string { return removeString(trusted, toolName) }) } // SetMCPServerEnabled is the connector toggle: on reconnects a configured server // for this session, off disconnects it (config untouched either way — like Claude // Code's per-conversation enable/disable, it resets on the next session start). func (a *App) SetMCPServerEnabled(name string, enabled bool) error { a.mu.RLock() tab := a.activeTabLocked() var ctrl control.SessionAPI hostKey := "" if tab != nil { ctrl = tab.Ctrl hostKey = tab.SharedHostKey } a.mu.RUnlock() if tab == nil || ctrl == nil { return fmt.Errorf("no active session") } if controllerHasActiveRuntimeWork(ctrl) { return rebuildControllerActiveWorkError("MCP server") } configuredEntry, hasConfiguredEntry, err := a.desktopMCPServerForEdit(name) if err != nil { return err } if enabled { _, err := a.connectConfiguredMCPServerForTab(tab, name) if err == nil { a.mu.Lock() delete(tab.disabledMCP, name) a.mu.Unlock() } return err } if s, ok := findMCPServerView(ctrl, name); ok { s.Status = "disabled" s.Error = "" a.mu.Lock() if tab.disabledMCP == nil { tab.disabledMCP = map[string]ServerView{} } tab.disabledMCP[name] = s tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s}) a.mu.Unlock() } else if hasConfiguredEntry { s := withPluginConfig(ServerView{Name: name, Status: "disabled"}, configuredEntry) a.mu.Lock() if tab.disabledMCP == nil { tab.disabledMCP = map[string]ServerView{} } tab.disabledMCP[name] = s tab.mcpOrder = mergeServerOrder(tab.mcpOrder, []ServerView{s}) a.mu.Unlock() } if hostKey != "" { ctrl.UnregisterMCPServerTools(name) } else { ctrl.DisconnectMCPServer(name) } return nil } func (a *App) connectConfiguredMCPServerForTab(tab *WorkspaceTab, name string) (int, error) { a.mu.RLock() var ctrl control.SessionAPI root := "" if tab != nil { ctrl = tab.Ctrl root = tab.WorkspaceRoot } a.mu.RUnlock() if ctrl == nil { return 0, fmt.Errorf("no active session") } cfg, err := config.LoadForRoot(root) if err != nil { return 0, err } for _, p := range cfg.Plugins { if p.Name == name { return ctrl.ConnectMCPServer(p) } } return 0, fmt.Errorf("no configured MCP server named %q", name) } // SetMCPServerTier is kept for old desktop bindings. New config writes drop the // retired tier field. func (a *App) SetMCPServerTier(name, tier string) error { tier = normalizeMCPTier(tier) tab, ctrl := a.activeTabAndCtrl() if tab != nil && controllerHasActiveRuntimeWork(ctrl) { return rebuildControllerActiveWorkError("MCP server") } updated, found, err := a.desktopMCPServerForEdit(name) if err != nil { return err } if !found { return fmt.Errorf("no configured MCP server named %q", name) } updated.Tier = tier if !updated.ShouldAutoStart() { on := true updated.AutoStart = &on } if err := a.saveDesktopMCPServer(updated); err != nil { return err } if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) { if _, err := ctrl.ConnectMCPServer(updated); err != nil { recordMCPFailure(ctrl, updated, err) return nil } a.mu.Lock() delete(tab.disabledMCP, name) a.mu.Unlock() } return nil } func (a *App) desktopMCPServerForEdit(name string) (config.PluginEntry, bool, error) { // Read-only lookup of the entry to edit; loads credentials because callers // hand the entry to ConnectMCPServer, which resolves env-based secrets. // The actual config write goes through saveDesktopMCPServer under the // config edit lock. cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials() if err != nil { return config.PluginEntry{}, false, err } if p, ok := findPluginEntry(cfg.Plugins, name); ok { return p, true, nil } if merged, err := config.LoadForRoot(a.activeWorkspaceRoot()); err == nil { if p, ok := findPluginEntry(merged.Plugins, name); ok { return p, true, nil } } return config.PluginEntry{}, false, nil } func (a *App) saveDesktopMCPServer(entry config.PluginEntry) error { root := a.activeWorkspaceRoot() if err := ensureMCPServerDirectlyWritable(root, entry.Name); err != nil { return err } if a.desktopMCPServerOwnedByProjectMCPJSON(root, entry.Name) { _, err := config.UpsertMCPJSONPlugin(projectMCPJSONPathForRoot(root), entry) return err } // Lock only the user-config load-modify-save; the project-override cleanup // below writes the project config, which this lock does not cover. if err := func() error { unlock := config.LockUserConfigEdits() defer unlock() cfg, path, err := a.loadDesktopUserConfigForEdit() if err != nil { return err } if err := cfg.UpsertPlugin(entry); err != nil { return err } return cfg.SaveTo(path) }(); err != nil { return err } _, err := a.removeProjectMCPOverride(root, entry.Name) return err } func ensureMCPServerDirectlyWritable(root, name string) error { cfg, err := config.LoadForRoot(root) if err != nil { return err } if owner, ok := cfg.PluginPackageOwner(name); ok { return fmt.Errorf("MCP server %q is managed by plugin %q; disable or remove the plugin instead", name, owner) } return nil } func (a *App) removeDesktopMCPServer(root, name string) (bool, error) { return config.RemovePluginFromSourcesForRoot(root, name) } func (a *App) removeProjectMCPOverride(root, name string) (bool, error) { path := projectConfigPathForRoot(root) userPath := config.UserConfigPath() if path == "" || sameConfigPath(path, userPath) { return false, nil } if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { return false, nil } return false, err } cfg := config.LoadForEdit(path) if !cfg.RemovePlugin(name) { return false, nil } if err := cfg.SaveTo(path); err != nil { return false, err } return true, nil } func (a *App) desktopMCPServerOwnedByProjectMCPJSON(root, name string) bool { if strings.TrimSpace(name) == "" { return false } // Read-only ownership check: only looks for the name in the user config's // plugin list, so no credentials and no config edit lock are needed. cfg, _, err := a.loadDesktopUserConfigForView() if err == nil { if _, ok := findPluginEntry(cfg.Plugins, name); ok { return false } } projectCfg := config.LoadForEdit(projectConfigPathForRoot(root)) if _, ok := findPluginEntry(projectCfg.Plugins, name); ok { return false } _, ok, err := config.LoadMCPJSONPlugin(projectMCPJSONPathForRoot(root), name) return err == nil && ok } func projectMCPJSONPathForRoot(root string) string { if strings.TrimSpace(root) == "" || root == "." { return ".mcp.json" } return filepath.Join(root, ".mcp.json") } func findPluginEntry(entries []config.PluginEntry, name string) (config.PluginEntry, bool) { for _, p := range entries { if p.Name == name { return p, true } } return config.PluginEntry{}, false } func normalizeMCPTier(tier string) string { switch strings.ToLower(strings.TrimSpace(tier)) { case "eager": return "eager" case "background", "lazy": return "background" case "": return "background" default: return "background" } } func normalizeMCPTransport(transport string) string { switch strings.ToLower(strings.TrimSpace(transport)) { case "http", "streamable-http": return "http" case "sse": return "sse" default: return "stdio" } } func mcpConnected(ctrl control.SessionAPI, name string) bool { if ctrl == nil || ctrl.Host() == nil { return false } for _, s := range ctrl.Host().Servers() { if s.Name == name { return true } } return false } func mcpFailed(ctrl control.SessionAPI, name string) bool { if ctrl == nil || ctrl.Host() == nil { return false } for _, f := range ctrl.Host().Failures() { if f.Name == name { return true } } return false } func recordMCPFailure(ctrl control.SessionAPI, e config.PluginEntry, err error) { if ctrl == nil || ctrl.Host() == nil || err == nil { return } exp := e.ExpandedPlugin() ctrl.Host().RecordFailure(plugin.Spec{ Name: exp.Name, Type: exp.Type, Command: exp.Command, Args: exp.Args, Env: exp.Env, URL: exp.URL, Headers: exp.Headers, }, err) } func findMCPServerView(ctrl control.SessionAPI, name string) (ServerView, bool) { if ctrl == nil || ctrl.Host() == nil { return ServerView{}, false } for _, s := range ctrl.Host().Servers() { if s.Name == name { return ServerView{ Name: s.Name, Transport: s.Transport, Status: "connected", Tools: s.Tools, Prompts: s.Prompts, Resources: s.Resources, HasTools: s.HasTools, ToolList: pluginToolsToView(s.ToolList), }, true } } for _, f := range ctrl.Host().Failures() { if f.Name == name { return ServerView{Name: f.Name, Transport: f.Transport, Status: "failed", Error: f.Error}, true } } return ServerView{}, false } func pluginToolsToView(tools []plugin.ToolInfo) []ToolView { if len(tools) == 0 { return nil } out := make([]ToolView, 0, len(tools)) for _, t := range tools { out = append(out, ToolView{Name: t.Name, Description: t.Description, ReadOnlyHint: t.ReadOnlyHint}) } return out } func sameStringList(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } func orderServerViews(servers []ServerView, order []string) []ServerView { pos := make(map[string]int, len(order)) for i, name := range order { pos[name] = i } sort.SliceStable(servers, func(i, j int) bool { pi, iok := pos[servers[i].Name] pj, jok := pos[servers[j].Name] switch { case iok && jok: return pi < pj case iok: return true case jok: return false default: return false } }) return servers } func mergeServerOrder(order []string, servers []ServerView) []string { seen := make(map[string]bool, len(order)+len(servers)) next := make([]string, 0, len(order)+len(servers)) for _, name := range order { if name == "" || seen[name] { continue } seen[name] = true next = append(next, name) } for _, s := range servers { if s.Name == "" || seen[s.Name] { continue } seen[s.Name] = true next = append(next, s.Name) } return next } func removeServerOrder(order []string, name string) []string { if name == "" || len(order) == 0 { return order } next := order[:0] for _, n := range order { if n != name { next = append(next, n) } } return next } // ModelInfo is one (provider, model) the bottom switcher can pick. Ref ("provider/ // model") is what SetModel takes; Provider/Model are for display. type ModelInfo struct { Ref string `json:"ref"` Provider string `json:"provider"` Model string `json:"model"` Current bool `json:"current"` } type EffortInfo struct { Supported bool `json:"supported"` Current string `json:"current"` Default string `json:"default"` Levels []string `json:"levels"` } // Models flattens the configured providers into their (provider, model) pairs — // the switcher's options — marking the active one. A vendor with a `models` list // yields one entry per model, all sharing the same endpoint/key. Unconfigured // providers are skipped. Result is non-nil: the frontend reads .length, so a nil // slice (JSON null) would crash the switcher on an empty list. func (a *App) Models() []ModelInfo { return a.ModelsForTab("") } func (a *App) ModelsForTab(tabID string) []ModelInfo { a.mu.RLock() curModel := "" workspaceRoot := "" if tab := a.tabByIDLocked(tabID); tab != nil { curModel = tab.model workspaceRoot = tab.WorkspaceRoot } a.mu.RUnlock() cfg, err := config.LoadForRoot(workspaceRoot) if err != nil { return []ModelInfo{} } if entry, ok := cfg.ResolveModel(curModel); ok { curModel = entry.Name + "/" + entry.Model } access := providerAccessSet(cfg.Desktop.ProviderAccess) out := []ModelInfo{} for i := range cfg.Providers { p := &cfg.Providers[i] if !modelProviderAccessAllowed(access, p.Name) || !p.Configured() { continue } for _, m := range p.ChatModelList() { ref := p.Name + "/" + m out = append(out, ModelInfo{Ref: ref, Provider: p.Name, Model: m, Current: ref == curModel}) } } return out } func modelProviderAccessAllowed(access map[string]bool, name string) bool { if len(access) == 0 { return true } return access[strings.TrimSpace(name)] } func controllerHasActiveRuntimeWork(ctrl control.SessionAPI) bool { if ctrl == nil { return false } status := ctrl.RuntimeStatus() return status.Running || status.PendingPrompt || status.BackgroundJobs > 0 } // rebuildBusyError reports a rebuild rejected because the controller still has // a running turn, pending prompt, or background jobs. Typed so the // deferred-rebuild retry loop can keep waiting instead of giving up. type rebuildBusyError struct{ setting string } func (e *rebuildBusyError) Error() string { return fmt.Sprintf("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing %s", e.setting) } func rebuildControllerActiveWorkError(setting string) error { return &rebuildBusyError{setting: setting} } type sessionLeaseBusyError struct { setting string err error } func (e *sessionLeaseBusyError) Error() string { // The raw SessionLeaseError text carries the session path and the // holder's host-pid-writer id; every user-facing surface must render // this wrapper instead. An empty setting means the failure gated opening // the session itself (startup bind), not changing a setting on it. setting := strings.TrimSpace(e.setting) if setting == "" { return "this session is already open in another Reasonix window or still running in the background; close the other window or open a copy" } 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) } func (e *sessionLeaseBusyError) Unwrap() error { if e == nil { return nil } return e.err } func userFacingSessionLeaseError(setting string, err error) error { if err == nil { return nil } if errors.Is(err, agent.ErrSessionLeaseHeld) { return &sessionLeaseBusyError{setting: setting, err: err} } return err } // sessionPathAfterSnapshot returns where a controller rebuild should keep // persisting after the old controller was snapshotted. Snapshotting is not // path-neutral: a snapshot conflict can recover by retargeting the controller // (and the tab's session lease, via handleTabSessionRecovered) to a recovery // branch, so a prevPath captured before the snapshot may be stale. Reusing the // stale path would bind the rebuilt controller — carrying the just-recovered // transcript — back to the original file, turning every later save into a new // conflict that derives yet another recovery branch. Falls back to fallback // when the controller is gone or persistence is disabled (empty SessionPath). func sessionPathAfterSnapshot(ctrl control.SessionAPI, fallback string) string { if ctrl == nil { return fallback } if path := strings.TrimSpace(ctrl.SessionPath()); path != "" { return path } return fallback } func (a *App) ensureTabSessionLeaseForRebuild(tab *WorkspaceTab, path, setting string) error { if err := tab.ensureSessionLease(path); err != nil { if a.canReclaimCurrentProcessSessionLease(tab, path, err) { if lease, reclaimErr := agent.TryReclaimCurrentProcessSessionLease(path); reclaimErr == nil { tab.adoptSessionLease(lease) return nil } else { err = reclaimErr } } return userFacingSessionLeaseError(setting, err) } return nil } func (a *App) canReclaimCurrentProcessSessionLease(tab *WorkspaceTab, path string, err error) bool { key := sessionRuntimeKey(path) if tab == nil || key == "" || !errors.Is(err, agent.ErrSessionLeaseHeld) { return false } var leaseErr *agent.SessionLeaseError if !errors.As(err, &leaseErr) || leaseErr == nil { return false } // A readable info naming a foreign runtime is respected here; reclaim // would refuse it anyway. A nil Info (lease.json deleted by the user, // quarantined by AV, or torn by a crash) must still attempt the reclaim: // the OS lock is the arbiter there, and refusing on missing metadata // wedges a session nobody actually holds as permanently busy. if leaseErr.Info != nil && (leaseErr.Info.PID != os.Getpid() || leaseErr.Info.WriterID != agent.SessionWriterID()) { return false } a.mu.RLock() defer a.mu.RUnlock() for _, candidate := range a.runtimeTabsLocked() { if candidate == nil || candidate == tab { continue } if candidate.sessionLeaseRuntimeKey() == key { return false } if candidate.Ctrl != nil && sessionRuntimeKey(candidate.currentSessionPath()) == key { return false } } return true } // SetModel switches the active model and carries the current conversation into the // new model's session, so the chat continues seamlessly and subsequent turns use // the new model. No-op if name is already active or the controller is down. func (a *App) SetModel(name string) error { return a.SetModelForTab("", name) } func (a *App) SetModelForTab(tabID, name string) error { if a.ctx == nil || name == "" { return nil } tab := a.tabByID(tabID) if tab == nil { return nil } a.mu.RLock() currentModel := tab.model a.mu.RUnlock() if name == currentModel { return nil } // Same build+swap shape as rebuildSetting; hold the same lock so a settings // rebuild (manual or from the deferred-rebuild retry loop) and a model // switch cannot interleave on one tab. a.runtimeRebuildMu.Lock() defer a.runtimeRebuildMu.Unlock() prevPath := a.reconciledSessionPathForTab(tab) if prevPath == "" { prevPath = a.currentSessionPathFor(tab) } if a.controllerForTab(tab) == nil && prevPath != "" { a.attachExistingSessionRuntime(tab, prevPath, a.ctx) } if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) { return rebuildControllerActiveWorkError("model") } if err := a.ensureTabControllerWorkspace(tab); err != nil { return err } prevPath = a.reconciledSessionPathForTab(tab) if prevPath == "" { prevPath = a.currentSessionPathFor(tab) } if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) { prevPath = a.reconciledSessionPathForTab(tab) if prevPath == "" { prevPath = a.currentSessionPathFor(tab) } if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) { return rebuildControllerActiveWorkError("model") } } // Snapshot the tab profile under a.mu: SetModeForTab/SetGoalForTab and the // event sink write these fields under the lock while this rebuild runs // off-lock. snap := a.tabRuntimeSnapshot(tab) cfg, err := config.LoadForRoot(snap.workspaceRoot) if err != nil { return err } entry, ok := cfg.ResolveModel(name) if !ok { return fmt.Errorf("unknown model %q", name) } if !modelProviderAccessAllowed(providerAccessSet(cfg.Desktop.ProviderAccess), entry.Name) { return fmt.Errorf("model %q is not available because provider %q is not added", name, entry.Name) } name = entry.Name + "/" + entry.Model effortOverride := cloneStringPtr(snap.effort) if effortOverride != nil { normalized, err := config.NormalizeEffort(entry, config.EffortDisplay(&config.ProviderEntry{Effort: *effortOverride})) if err != nil { effortOverride = nil } else { effortOverride = &normalized } } var carried []provider.Message oldCtrl := a.controllerForTab(tab) if oldCtrl != nil { if prevPath == "" { prevPath = oldCtrl.SessionPath() } if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "model"); err != nil { return err } if err := a.snapshotTabForAction(tab, "changing model"); err != nil { return err } prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath) carried = oldCtrl.History() } // Preserve the shared plugin host across controller rebuilds — the tab // stays in the same workspace root, so MCP processes must not be restarted. sharedHost := a.lookupSharedHost(snap.sharedHostKey) newCtrl, err := boot.Build(a.bootContext(), boot.Options{ Model: name, RequireKey: false, Sink: snap.sink, WorkspaceRoot: snap.workspaceRoot, SessionDir: sessionDirForSnapshot(snap), EffortOverride: cloneStringPtr(effortOverride), TokenMode: snap.currentTokenMode(), SharedHost: sharedHost, CleanupPendingReconciler: reconcileDesktopCleanupPending, SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab), OnSessionRecovered: a.handleTabSessionRecovered(tab), }) if err != nil { return err } a.bindControllerDisplayRecorder(newCtrl) newCtrl.EnableInteractiveApproval() applyTabModeToController(newCtrl, snap.mode) applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode) newCtrl.SetGoal(snap.goal) path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label()) if err := a.ensureTabSessionLeaseForRebuild(tab, path, "model"); err != nil { newCtrl.Close() return err } resumeWithFreshSystemPrompt(newCtrl, carried, path) a.mu.Lock() if current := a.tabs[tab.ID]; current != tab { // The tab was closed/replaced while we built the new controller off-lock; // adopting it now would leak the runtime onto an orphaned tab and pin the // session lease forever. a.mu.Unlock() newCtrl.Close() tab.releaseSessionLease() return fmt.Errorf("tab %q changed while switching model; retry", tab.ID) } tab.Ctrl = newCtrl tab.model = name tab.effort = cloneStringPtr(effortOverride) tab.Label = newCtrl.Label() // Supersede any in-flight startup build: it would otherwise finish later, // overwrite this controller, and release/steal the tab's session lease. a.supersedeTabBuildLocked(tab) a.saveTabsLocked() a.mu.Unlock() if oldCtrl != nil { oldCtrl.Close() } // The runtime now reflects the on-disk config; drop any deferred refresh. a.clearDeferredRebuild(tab.ID) a.persistTabSessionPath(tab, path) a.notifyTabRuntimeRebuilt(tab) return nil } func (a *App) Effort() EffortInfo { return a.EffortForTab("") } func (a *App) EffortForTab(tabID string) EffortInfo { entry, err := a.currentProviderEntryForTab(tabID) if err != nil { return EffortInfo{Current: "auto", Levels: []string{}} } cap := config.EffortCapabilityForEntry(entry) if !cap.Supported { return EffortInfo{Supported: false, Current: "auto", Default: cap.Default, Levels: []string{}} } levels := cap.Levels if levels == nil { levels = []string{} } return EffortInfo{Supported: true, Current: config.EffortDisplay(entry), Default: cap.Default, Levels: levels} } func (a *App) SetEffort(level string) error { return a.SetEffortForTab("", level) } func (a *App) SetEffortForTab(tabID, level string) error { tab := a.tabByID(tabID) if tab == nil { if strings.TrimSpace(tabID) == "" { entry, err := a.currentProviderEntryForTab("") if err != nil { return err } effort, err := config.NormalizeEffort(entry, level) if err != nil { return err } return a.applyProviderEffortConfig(entry, effort) } return fmt.Errorf("tab %q not found", tabID) } // Build+swap path; serialize with the other rebuild paths (see // runtimeRebuildMu). The tab==nil branch above goes through // applyProviderEffortConfig → rebuildSetting, which takes the lock itself. a.runtimeRebuildMu.Lock() defer a.runtimeRebuildMu.Unlock() prevPath := a.reconciledSessionPathForTab(tab) if prevPath == "" { prevPath = a.currentSessionPathFor(tab) } // Recomputing prevPath after this attach would be a dead store: it is // unconditionally derived again after ensureTabControllerWorkspace below. if a.controllerForTab(tab) == nil && prevPath != "" { a.attachExistingSessionRuntime(tab, prevPath, a.ctx) } if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) { return rebuildControllerActiveWorkError("effort") } if err := a.ensureTabControllerWorkspace(tab); err != nil { return err } prevPath = a.reconciledSessionPathForTab(tab) if prevPath == "" { prevPath = a.currentSessionPathFor(tab) } if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) { prevPath = a.reconciledSessionPathForTab(tab) if prevPath == "" { prevPath = a.currentSessionPathFor(tab) } if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) { return rebuildControllerActiveWorkError("effort") } } snap := a.tabRuntimeSnapshot(tab) entry, err := a.currentProviderEntryForTab(tabID) if err != nil { return err } modelRef := entry.Name + "/" + entry.Model effort, err := config.NormalizeEffort(entry, level) if err != nil { return err } var carried []provider.Message oldCtrl := a.controllerForTab(tab) if oldCtrl != nil { if prevPath == "" { prevPath = oldCtrl.SessionPath() } if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "effort"); err != nil { return err } if err := a.snapshotTabForAction(tab, "changing effort"); err != nil { return err } prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath) carried = oldCtrl.History() } sharedHost := a.lookupSharedHost(snap.sharedHostKey) newCtrl, err := boot.Build(a.bootContext(), boot.Options{ Model: modelRef, RequireKey: false, Sink: snap.sink, WorkspaceRoot: snap.workspaceRoot, SessionDir: sessionDirForSnapshot(snap), EffortOverride: &effort, TokenMode: snap.currentTokenMode(), SharedHost: sharedHost, CleanupPendingReconciler: reconcileDesktopCleanupPending, SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab), OnSessionRecovered: a.handleTabSessionRecovered(tab), }) if err != nil { return err } a.bindControllerDisplayRecorder(newCtrl) newCtrl.EnableInteractiveApproval() applyTabModeToController(newCtrl, snap.mode) applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode) newCtrl.SetGoal(snap.goal) path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label()) if err := a.ensureTabSessionLeaseForRebuild(tab, path, "effort"); err != nil { newCtrl.Close() return err } resumeWithFreshSystemPrompt(newCtrl, carried, path) a.mu.Lock() if current := a.tabs[tab.ID]; current != tab { a.mu.Unlock() newCtrl.Close() tab.releaseSessionLease() return fmt.Errorf("tab %q changed while switching effort; retry", tab.ID) } tab.Ctrl = newCtrl tab.model = modelRef tab.effort = &effort tab.Label = newCtrl.Label() clearTabStartupError(tab) tab.Ready = true a.supersedeTabBuildLocked(tab) a.saveTabsLocked() a.mu.Unlock() if oldCtrl != nil { oldCtrl.Close() } // The rebuilt runtime reflects the on-disk config; drop any deferred refresh. a.clearDeferredRebuild(tab.ID) a.persistTabSessionPath(tab, path) a.notifyTabRuntimeRebuilt(tab) return nil } func (a *App) SetTokenMode(mode string) error { return a.SetTokenModeForTab("", mode) } func (a *App) SetTokenModeForTab(tabID, mode string) error { mode = boot.NormalizeTokenMode(mode) tab := a.tabByID(tabID) if tab == nil { if strings.TrimSpace(tabID) == "" { return nil } return fmt.Errorf("tab %q not found", tabID) } a.mu.RLock() currentMode := boot.NormalizeTokenMode(tab.tokenMode) a.mu.RUnlock() if mode == currentMode { return nil } // Build+swap path; serialize with the other rebuild paths (see runtimeRebuildMu). a.runtimeRebuildMu.Lock() defer a.runtimeRebuildMu.Unlock() prevPath := a.reconciledSessionPathForTab(tab) if prevPath == "" { prevPath = a.currentSessionPathFor(tab) } // Recomputing prevPath after this attach would be a dead store: it is // unconditionally derived again after ensureTabControllerWorkspace below. if a.controllerForTab(tab) == nil && prevPath != "" { a.attachExistingSessionRuntime(tab, prevPath, a.ctx) } if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) { return rebuildControllerActiveWorkError("token mode") } if err := a.ensureTabControllerWorkspace(tab); err != nil { return err } prevPath = a.reconciledSessionPathForTab(tab) if prevPath == "" { prevPath = a.currentSessionPathFor(tab) } if a.controllerForTab(tab) == nil && prevPath != "" && a.attachExistingSessionRuntime(tab, prevPath, a.ctx) { prevPath = a.reconciledSessionPathForTab(tab) if prevPath == "" { prevPath = a.currentSessionPathFor(tab) } if controllerHasActiveRuntimeWork(a.controllerForTab(tab)) { return rebuildControllerActiveWorkError("token mode") } } modelRef, fallback, err := a.resolvedModelForTab(tab) if err != nil { return err } snap := a.tabRuntimeSnapshot(tab) if fallback && strings.TrimSpace(snap.model) != "" { a.noticeForTab(tab.ID, fmt.Sprintf("model %q is no longer available; switched to %s", snap.model, modelRef)) } var carried []provider.Message oldCtrl := a.controllerForTab(tab) if oldCtrl != nil { if prevPath == "" { prevPath = oldCtrl.SessionPath() } if err := a.ensureTabSessionLeaseForRebuild(tab, prevPath, "token mode"); err != nil { return err } if err := a.snapshotTabForAction(tab, "changing token mode"); err != nil { return err } prevPath = sessionPathAfterSnapshot(oldCtrl, prevPath) carried = oldCtrl.History() } sharedHost := a.lookupSharedHost(snap.sharedHostKey) newCtrl, err := boot.Build(a.bootContext(), boot.Options{ Model: modelRef, RequireKey: false, Sink: snap.sink, WorkspaceRoot: snap.workspaceRoot, SessionDir: sessionDirForSnapshot(snap), EffortOverride: cloneStringPtr(snap.effort), TokenMode: mode, SharedHost: sharedHost, CleanupPendingReconciler: reconcileDesktopCleanupPending, SessionRecoveryMeta: a.tabSessionRecoveryMeta(tab), OnSessionRecovered: a.handleTabSessionRecovered(tab), }) if err != nil { return err } a.bindControllerDisplayRecorder(newCtrl) newCtrl.EnableInteractiveApproval() applyTabModeToController(newCtrl, snap.mode) applyTabToolApprovalModeToController(newCtrl, snap.toolApprovalMode) newCtrl.SetGoal(snap.goal) path := agent.ContinueSessionPath(prevPath, newCtrl.SessionDir(), newCtrl.Label()) if err := a.ensureTabSessionLeaseForRebuild(tab, path, "token mode"); err != nil { newCtrl.Close() return err } resumeWithFreshSystemPrompt(newCtrl, carried, path) a.mu.Lock() if current := a.tabs[tab.ID]; current != tab { a.mu.Unlock() newCtrl.Close() tab.releaseSessionLease() return fmt.Errorf("tab %q changed while switching token mode; retry", tab.ID) } tab.Ctrl = newCtrl tab.model = modelRef tab.tokenMode = mode tab.Label = newCtrl.Label() clearTabStartupError(tab) tab.Ready = true a.supersedeTabBuildLocked(tab) a.saveTabsLocked() a.mu.Unlock() if oldCtrl != nil { oldCtrl.Close() } // The rebuilt runtime reflects the on-disk config; drop any deferred refresh. a.clearDeferredRebuild(tab.ID) a.persistTabSessionPath(tab, path) a.notifyTabRuntimeRebuilt(tab) return nil } func (a *App) applyProviderEffortConfig(entry *config.ProviderEntry, effort string) error { return a.applyConfigChange(func(cfg *config.Config) error { if _, ok := cfg.Provider(entry.Name); !ok { if err := cfg.UpsertProvider(*entry); err != nil { return err } } if entry.Kind == "anthropic" && effort != "" && entry.Thinking == "" { if err := cfg.SetProviderThinking(entry.Name, "adaptive"); err != nil { return err } } for _, name := range providerEffortTargetNames(cfg, entry) { if err := cfg.SetProviderEffort(name, effort); err != nil { return err } } return nil }) } func providerEffortTargetNames(cfg *config.Config, entry *config.ProviderEntry) []string { if cfg == nil || entry == nil { return nil } out := []string{entry.Name} seen := map[string]bool{entry.Name: true} kind := officialProviderKindFromEntry(*entry) if kind == "" { return out } var family []string switch kind { case "deepseek": family = []string{"deepseek", "deepseek-flash", "deepseek-pro"} } for _, name := range family { if seen[name] { continue } p, ok := cfg.Provider(name) if !ok || officialProviderKindFromEntry(*p) != kind { continue } seen[name] = true out = append(out, name) } return out } // DirEntry is one entry in the "@" file-reference menu. type DirEntry struct { Name string `json:"name"` Path string `json:"path,omitempty"` IsDir bool `json:"isDir"` DisplayName string `json:"displayName,omitempty"` DisplayPath string `json:"displayPath,omitempty"` } // FilePreview is a bounded, read-only file payload for the workspace side panel. type FilePreview struct { Path string `json:"path"` Body string `json:"body"` Size int64 `json:"size"` Truncated bool `json:"truncated"` Binary bool `json:"binary"` Kind string `json:"kind,omitempty"` Mime string `json:"mime,omitempty"` URL string `json:"url,omitempty"` Err string `json:"err,omitempty"` } type WorkspaceChangeView struct { Path string `json:"path"` OldPath string `json:"oldPath,omitempty"` Sources []string `json:"sources"` GitStatus string `json:"gitStatus,omitempty"` Turns []int `json:"turns,omitempty"` LatestPrompt string `json:"latestPrompt,omitempty"` LatestTime int64 `json:"latestTime,omitempty"` } type WorkspaceChangesView struct { Files []WorkspaceChangeView `json:"files"` GitAvailable bool `json:"gitAvailable"` GitErr string `json:"gitErr,omitempty"` GitBranch string `json:"gitBranch,omitempty"` } // workspaceNoiseNames are local cache/vendor entries hidden from the file tree // and "@" menu regardless of where they appear. var workspaceNoiseNames = map[string]bool{ ".codex": true, ".DS_Store": true, ".git": true, ".npm": true, ".pnpm-store": true, "node_modules": true, "Thumbs.db": true, } var workspaceNoiseDirs = map[string]bool{ "bin": true, "desktop/build": true, "desktop/frontend/dist": true, "desktop/frontend/wailsjs": true, "dist": true, "npm/.stage": true, "site/.astro": true, "site/dist": true, "stage": true, "tmp": true, } const filePreviewLimit = 256 * 1024 const fileRefSearchLimit = 20 var previewMediaMIMEs = map[string]string{ ".bmp": "image/bmp", ".gif": "image/gif", ".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".pdf": "application/pdf", ".png": "image/png", ".svg": "image/svg+xml", ".webp": "image/webp", } func trimUTF8PartialSuffix(data []byte) []byte { if utf8.Valid(data) { return data } for i := len(data) - 1; i >= 0 && len(data)-i <= utf8.UTFMax; i-- { if !utf8.RuneStart(data[i]) { continue } if !utf8.Valid(data[:i]) || utf8.FullRune(data[i:]) { return data } return data[:i] } return data } func previewMediaKind(path string) (kind string, mime string) { mime = previewMediaMIMEs[strings.ToLower(filepath.Ext(path))] if mime == "" { return "", "" } if strings.HasPrefix(mime, "image/") { return "image", mime } if mime == "application/pdf" { return "pdf", mime } return "", "" } func workspaceEntryRel(rel, name string) string { rel = strings.Trim(filepath.ToSlash(rel), "/") if rel == "" || rel == "." { return name } return rel + "/" + name } func skipWorkspaceEntry(rel, name string, isDir bool) bool { if workspaceNoiseNames[name] { return true } return isDir && workspaceNoiseDirs[workspaceEntryRel(rel, name)] } func (a *App) activeWorkspaceBase() (string, error) { return workspaceBaseFromRoot(a.activeWorkspaceRoot()) } func (a *App) workspaceTargetForTab(tabID string) (string, control.SessionAPI, bool) { tabID = strings.TrimSpace(tabID) a.mu.RLock() defer a.mu.RUnlock() tab := a.tabByIDLocked(tabID) if tab == nil { if tabID == "" { return ".", nil, true } return "", nil, false } return tab.WorkspaceRoot, tab.Ctrl, true } func workspaceBaseFromRoot(root string) (string, error) { if strings.TrimSpace(root) == "" || root == "." { return os.Getwd() } if abs, err := filepath.Abs(root); err == nil { root = abs } return filepath.Clean(root), nil } func workspacePathForBase(base, rel string) (string, bool, error) { base = filepath.Clean(base) if rel == "" { return "", false, os.ErrInvalid } path := rel if !filepath.IsAbs(path) { path = filepath.Join(base, rel) } path = filepath.Clean(path) r, err := filepath.Rel(base, path) if err != nil { return "", false, err } if r == ".." || strings.HasPrefix(r, ".."+string(os.PathSeparator)) { return "", false, os.ErrPermission } return path, true, nil } // ListDir lists one directory level (directories first, then files, each // alphabetical) for the "@" file-reference menu. rel resolves against the active // tab workspace. The menu navigates one level at a time, never recursively — // bounded for huge trees. func (a *App) ListDir(rel string) []DirEntry { return a.ListDirForTab("", rel) } // ListDirForTab is the tab-scoped variant used by multi-tab frontend surfaces. func (a *App) ListDirForTab(tabID, rel string) []DirEntry { root, ctrl, ok := a.workspaceTargetForTab(tabID) if !ok { return []DirEntry{} } if browser := externalFolderRefBrowserFromController(ctrl); browser != nil { if entries, handled := browser.ListExternalFolderRefDir(rel); handled { return externalFolderDirEntries(entries) } } base, err := workspaceBaseFromRoot(root) if err != nil { return []DirEntry{} } dir := base if rel != "" { path, ok, err := workspacePathForBase(base, rel) if err != nil || !ok { return []DirEntry{} } dir = path } es, err := os.ReadDir(dir) if err != nil { return []DirEntry{} } dirs, files := []DirEntry{}, []DirEntry{} for _, e := range es { name := e.Name() if skipWorkspaceEntry(rel, name, e.IsDir()) { continue } if e.IsDir() { dirs = append(dirs, DirEntry{Name: name, IsDir: true}) continue } info, err := e.Info() if err != nil || !info.Mode().IsRegular() { continue } files = append(files, DirEntry{Name: name, IsDir: false}) } sort.Slice(dirs, func(i, j int) bool { return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name) }) sort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name) }) return append(dirs, files...) } // SearchFileRefs finds workspace files by basename for bare "@token" completion. func (a *App) SearchFileRefs(query string) []DirEntry { return a.SearchFileRefsForTab("", query) } // SearchFileRefsForTab is the tab-scoped variant used by multi-tab frontend surfaces. func (a *App) SearchFileRefsForTab(tabID, query string) []DirEntry { root, ctrl, ok := a.workspaceTargetForTab(tabID) if !ok { return []DirEntry{} } base, err := workspaceBaseFromRoot(root) if err != nil { return []DirEntry{} } results := fileref.Search(base, query, fileRefSearchLimit) out := make([]DirEntry, 0, len(results)) for _, r := range results { out = append(out, DirEntry{Name: r.Path, IsDir: r.IsDir}) } if browser := externalFolderRefBrowserFromController(ctrl); browser != nil { out = append(out, externalFolderDirEntries(browser.SearchExternalFolderRefs(query, fileRefSearchLimit))...) } return out } type externalFolderRefBrowser interface { ListExternalFolderRefDir(tokenPath string) ([]control.ExternalFolderRefEntry, bool) SearchExternalFolderRefs(query string, limit int) []control.ExternalFolderRefEntry ExternalFolderRefLocalPath(tokenPath string) (path, displayPath string, ok bool) } func externalFolderRefBrowserFromController(ctrl control.SessionAPI) externalFolderRefBrowser { if browser, ok := ctrl.(externalFolderRefBrowser); ok { return browser } return nil } func externalFolderDirEntries(entries []control.ExternalFolderRefEntry) []DirEntry { out := make([]DirEntry, 0, len(entries)) for _, e := range entries { out = append(out, DirEntry{ Name: e.Name, Path: e.Path, IsDir: e.IsDir, DisplayName: e.DisplayName, DisplayPath: e.DisplayPath, }) } return out } func (a *App) workspaceOrExternalPathForTab(tabID, rel string) (string, bool, error) { root, ctrl, ok := a.workspaceTargetForTab(tabID) if !ok { return "", false, os.ErrNotExist } if browser := externalFolderRefBrowserFromController(ctrl); browser != nil { if path, _, ok := browser.ExternalFolderRefLocalPath(rel); ok { return path, true, nil } } base, err := workspaceBaseFromRoot(root) if err != nil { return "", false, err } return workspacePathForBase(base, rel) } // ReadFile returns a small text preview for a file under the current workspace // or a session-authorized external folder ref. func (a *App) ReadFile(rel string) FilePreview { return a.ReadFileForTab("", rel) } // ReadFileForTab returns a preview resolved against the requested tab. func (a *App) ReadFileForTab(tabID, rel string) FilePreview { out := FilePreview{Path: rel} path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel) if err != nil || !ok { out.Err = "invalid path" return out } info, err := os.Stat(path) if err != nil { out.Err = err.Error() return out } if info.IsDir() { out.Err = "path is a directory" return out } if !info.Mode().IsRegular() { out.Err = "path is not a regular file" return out } out.Size = info.Size() if kind, mime := previewMediaKind(path); kind != "" { token := a.ensureMediaTokenStore().create(path, info.Name(), mime, kind, info.Size(), info.ModTime()) out.Kind = kind out.Mime = mime out.URL = "/__reasonix_workspace_media/" + token + "/" + url.PathEscape(info.Name()) return out } f, err := os.Open(path) if err != nil { out.Err = err.Error() return out } defer f.Close() buf := make([]byte, filePreviewLimit+1) n, err := f.Read(buf) if err != nil && err != io.EOF { out.Err = err.Error() return out } data := buf[:n] if len(data) > filePreviewLimit { data = data[:filePreviewLimit] out.Truncated = true } // Check for BOM first (just the first 2-3 bytes — always complete // even at a truncation boundary). BOM-prefixed files skip the NUL // check since UTF-16 normally contains 0x00 for ASCII characters. bomKind := fileenc.DetectQuick(data) if bomKind != fileenc.UTF8 { enc, _ := fileenc.Detect(data) if enc == fileenc.LossyUTF8 { out.Binary = true return out } decoded := fileenc.Decode(data, enc) out.Body = string(decoded) return out } // No BOM — NUL in raw bytes is a binary signal. if bytes.Contains(data, []byte{0}) { out.Binary = true return out } // Trim any partial multi-byte rune at the truncation boundary BEFORE // encoding detection. Without this, a large UTF-8 file truncated // mid-character would fail utf8.Valid and be misdetected as GB18030 // or LossyUTF8, producing mojibake or a false binary classification. if out.Truncated { data = trimUTF8PartialSuffix(data) } enc, _ := fileenc.Detect(data) if enc == fileenc.LossyUTF8 { out.Binary = true return out } out.Body = string(fileenc.Decode(data, enc)) return out } // OpenWorkspacePath opens a workspace or authorized external-ref file/folder in // the OS default app. func (a *App) OpenWorkspacePath(rel string) error { return a.OpenWorkspacePathForTab("", rel) } // OpenWorkspacePathForTab opens a path resolved against the requested tab. func (a *App) OpenWorkspacePathForTab(tabID, rel string) error { path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel) if err != nil || !ok { return os.ErrInvalid } return openWorkspacePath(path) } // RevealWorkspacePath shows a workspace or authorized external-ref file in the // native file manager. func (a *App) RevealWorkspacePath(rel string) error { return a.RevealWorkspacePathForTab("", rel) } // RevealWorkspacePathForTab reveals a path resolved against the requested tab. func (a *App) RevealWorkspacePathForTab(tabID, rel string) error { path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel) if err != nil || !ok { return os.ErrInvalid } return revealPath(path) } // RevealPath shows an arbitrary absolute path in the native file manager. func (a *App) RevealPath(path string) error { path = strings.TrimSpace(path) if path == "" { return os.ErrInvalid } if abs, err := filepath.Abs(path); err == nil { path = abs } return revealPath(path) } var revealPath = defaultRevealPath func defaultRevealPath(path string) error { switch goruntime.GOOS { case "darwin": return exec.Command("open", "-R", path).Start() case "windows": // explorer.exe lives in %SystemRoot%, which isn't always on PATH (the // launch environment can strip it), so resolve it directly rather than // relying on a PATH lookup. explorer := "explorer.exe" root := os.Getenv("SystemRoot") if root == "" { root = os.Getenv("windir") } if root != "" { explorer = filepath.Join(root, "explorer.exe") } return exec.Command(explorer, "/select,", path).Start() default: dir := path if info, err := os.Stat(path); err == nil && !info.IsDir() { dir = filepath.Dir(path) } return exec.Command("xdg-open", dir).Start() } } func (a *App) noticeForTab(tabID, text string) { tab := a.tabByID(tabID) if tab != nil && tab.sink != nil { tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: text}) } } func (a *App) warnForTab(tabID, text string) { tab := a.tabByID(tabID) if tab != nil && tab.sink != nil { tab.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text}) } } func (a *App) runEffortCommandForTab(tabID, input string) { entry, err := a.currentProviderEntryForTab(tabID) if err != nil { a.noticeForTab(tabID, "effort: "+err.Error()) return } cap := config.EffortCapabilityForEntry(entry) if !cap.Supported { a.noticeForTab(tabID, fmt.Sprintf("effort is not configurable for %s", entry.Name)) return } args := strings.Fields(input) if len(args) < 2 { a.noticeForTab(tabID, fmt.Sprintf("effort for %s: %s (default: %s; options: %s)", entry.Name, config.EffortDisplay(entry), cap.Default, strings.Join(cap.Levels, "|"))) return } if len(args) > 2 { a.noticeForTab(tabID, "usage: /effort "+strings.Join(cap.Levels, "|")) return } effort, err := config.NormalizeEffort(entry, args[1]) if err != nil { a.noticeForTab(tabID, err.Error()) return } if err := a.SetEffortForTab(tabID, args[1]); err != nil { a.noticeForTab(tabID, "effort: "+err.Error()) return } display := effort if display == "" { display = "auto" } a.noticeForTab(tabID, fmt.Sprintf("effort for %s set to %s", entry.Name, display)) } func (a *App) currentProviderEntryForTab(tabID string) (*config.ProviderEntry, error) { if tab := a.tabByID(tabID); tab != nil { a.reconcileTabWithPinnedSessionMeta(tab) } a.mu.RLock() ref := "" workspaceRoot := "" effortOverride := (*string)(nil) if tab := a.tabByIDLocked(tabID); tab != nil { ref = tab.model workspaceRoot = tab.WorkspaceRoot effortOverride = cloneStringPtr(tab.effort) } a.mu.RUnlock() cfg, err := config.LoadForRoot(workspaceRoot) if err != nil { return nil, err } if strings.TrimSpace(ref) == "" { ref = cfg.DefaultModel } config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref) resolved, _, ok := cfg.ResolveModelWithFallback(ref) if !ok { return nil, fmt.Errorf("unknown model %q", ref) } entry, ok := cfg.ResolveModel(resolved) if !ok { return nil, fmt.Errorf("unknown model %q", resolved) } if effortOverride != nil { entry.Effort = *effortOverride } return entry, nil } func (a *App) resolvedModelForTab(tab *WorkspaceTab) (string, bool, error) { if tab == nil { return "", false, fmt.Errorf("no active tab") } cfg, err := config.LoadForRoot(tab.WorkspaceRoot) if err != nil { return "", false, err } ref := strings.TrimSpace(tab.model) if ref == "" { ref = cfg.DefaultModel } config.NormalizeLegacyMimoCustomProvidersForRefs(cfg, ref) resolved, fallback, ok := cfg.ResolveModelWithFallback(ref) if !ok { return "", false, fmt.Errorf("unknown model %q", ref) } return resolved, fallback, nil } func (a *App) withActiveWorkspace(fn func() (string, error)) (string, error) { var result string err := a.withActiveWorkspaceDo(func() error { var err error result, err = fn() return err }) return result, err } func (a *App) withActiveWorkspaceDo(fn func() error) error { root := a.activeWorkspaceRoot() if root != "" && root != "." { prev, err := os.Getwd() if err != nil { return err } if err := os.Chdir(root); err != nil { return err } defer func() { _ = os.Chdir(prev) }() } return fn() } // SavePastedImage stores a browser clipboard image data URL under the active // tab's workspace .reasonix/attachments and returns the relative @-reference path. func (a *App) SavePastedImage(dataURL string) (string, error) { return a.withActiveWorkspace(func() (string, error) { return control.SaveImageDataURL(dataURL) }) } // SaveClipboardImage reads the native OS clipboard image under the active tab's // workspace .reasonix/attachments and returns the relative @-reference path. func (a *App) SaveClipboardImage() (string, error) { return a.withActiveWorkspace(control.SaveClipboardImage) } // SavePastedFile stores a dropped non-image file (the browser exposes its bytes // as a data URL but not a real path) under the active tab's workspace // .reasonix/attachments and returns the relative @-reference path. func (a *App) SavePastedFile(name, dataURL string) (string, error) { return a.withActiveWorkspace(func() (string, error) { return control.SaveAttachmentDataURL(name, dataURL) }) } // PickExportFile opens the native save dialog and returns the selected path. It // returns "" when the user cancels. func (a *App) PickExportFile(defaultFilename, mimeType string) (string, error) { if a.ctx == nil { return "", nil } defaultFilename = safeExportFilename(defaultFilename) ext := strings.ToLower(filepath.Ext(defaultFilename)) path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ Title: "Export session", DefaultDirectory: dialogDefaultDirectory(a.activeWorkspaceRoot()), DefaultFilename: defaultFilename, CanCreateDirectories: true, Filters: exportFileFilters(mimeType, ext), }) if err != nil || path == "" { return "", err } if ext != "" && filepath.Ext(path) == "" { path += ext } return path, nil } // SaveExportFile writes an exported session payload to a path previously picked // by PickExportFile. An empty path is treated as a cancelled export. func (a *App) SaveExportFile(path, payload string, base64Encoded bool) error { if strings.TrimSpace(path) == "" { return nil } var data []byte var err error if base64Encoded { data, err = base64.StdEncoding.DecodeString(payload) if err != nil { return fmt.Errorf("decode export payload: %w", err) } } else { data = []byte(payload) } if err := os.WriteFile(path, data, 0o644); err != nil { return err } return nil } func safeExportFilename(name string) string { name = strings.TrimSpace(name) if name == "" { return "reasonix-session.md" } return filepath.Base(name) } func exportFileFilters(mimeType, ext string) []runtime.FileFilter { switch mimeType { case "text/markdown": return []runtime.FileFilter{{DisplayName: "Markdown (*.md)", Pattern: "*.md"}} case "application/json": return []runtime.FileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}} case "application/pdf": return []runtime.FileFilter{{DisplayName: "PDF (*.pdf)", Pattern: "*.pdf"}} case "image/png": return []runtime.FileFilter{{DisplayName: "PNG image (*.png)", Pattern: "*.png"}} } if ext != "" { return []runtime.FileFilter{{DisplayName: strings.ToUpper(strings.TrimPrefix(ext, ".")) + " files (*" + ext + ")", Pattern: "*" + ext}} } return []runtime.FileFilter{{DisplayName: "All files (*.*)", Pattern: "*.*"}} } // AttachmentDataURL returns a safe data URL for a stored image attachment. func (a *App) AttachmentDataURL(path string) (string, error) { return a.withActiveWorkspace(func() (string, error) { return control.ImageDataURL(path) }) } // DroppedItem is one OS-dropped file resolved into a composer context entry: an // in-tree file becomes a workspace @reference (read in place, no copy), while an // outside directory becomes a session-scoped workspace @reference; an image or // out-of-tree file is copied into .reasonix/attachments. type DroppedItem struct { Kind string `json:"kind"` // "workspace" | "attachment" Path string `json:"path"` IsDir bool `json:"isDir,omitempty"` DisplayPath string `json:"displayPath,omitempty"` PreviewURL string `json:"previewUrl,omitempty"` } // AttachDropped turns an absolute path from the native file-drop bridge into a // composer context entry. Images are stored as attachments so the chip shows a // thumbnail; in-workspace files are referenced relatively (no copy); directories // outside the workspace are registered as current-session folder references; // files outside the workspace are copied into .reasonix/attachments. func (a *App) AttachDropped(path string) (DroppedItem, error) { var item DroppedItem err := a.withActiveWorkspaceDo(func() error { info, err := os.Lstat(path) if err != nil { return err } if isImageExt(path) { if rel, err := control.SaveImageFile(path); err == nil { preview, _ := control.ImageDataURL(rel) item = DroppedItem{Kind: "attachment", Path: rel, PreviewURL: preview} return nil } } if rel, ok := workspaceRelativeIn(path, a.activeWorkspaceRoot()); ok { item = DroppedItem{Kind: "workspace", Path: rel, IsDir: info.IsDir()} return nil } if info.IsDir() { tab, ctrl := a.tabAndCtrlByID("") if err := a.ensureTabControllerWorkspace(tab); err != nil { return err } if tab != nil { ctrl = a.controllerForTab(tab) } if ctrl == nil { return fmt.Errorf("workspace is not ready") } token, displayPath, err := ctrl.RegisterExternalFolderRef(path) if err != nil { return err } item = DroppedItem{Kind: "workspace", Path: token, IsDir: true, DisplayPath: displayPath} return nil } rel, err := control.SaveAttachmentFile(path) if err != nil { return err } item = DroppedItem{Kind: "attachment", Path: rel} return nil }) if err != nil { return DroppedItem{}, err } return item, nil } func isImageExt(path string) bool { switch strings.ToLower(filepath.Ext(path)) { case ".png", ".jpg", ".jpeg", ".gif", ".webp": return true } return false } func workspaceRelativeIn(path, workspaceRoot string) (string, bool) { root := workspaceRoot if !filepath.IsAbs(root) { abs, err := filepath.Abs(root) if err != nil { return "", false } root = abs } rel, err := filepath.Rel(root, path) if err != nil { return "", false } if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { return "", false } return filepath.ToSlash(rel), true } // --- memory panel (frontend ⇄ controller) --- // MemoryDoc is one loaded doc-memory file for the panel: path, scope, and body. type MemoryDoc struct { Path string `json:"path"` Scope string `json:"scope"` Body string `json:"body"` } // MemoryFact is one saved auto-memory, surfaced read-only in the panel. type MemoryFact struct { Name string `json:"name"` Title string `json:"title,omitempty"` Description string `json:"description"` Type string `json:"type"` Body string `json:"body"` } // MemoryArchive is one archived auto-memory kept only for inspection. type MemoryArchive struct { Name string `json:"name"` Title string `json:"title,omitempty"` Description string `json:"description"` Type string `json:"type"` Body string `json:"body"` Path string `json:"path"` ArchivedAt string `json:"archivedAt,omitempty"` } // MemoryScope is one writable quick-add target (scope id + the file it writes to). type MemoryScope struct { Scope string `json:"scope"` Path string `json:"path"` } // MemoryView is the whole memory panel payload: hierarchical docs, active saved // facts, archived facts, and the writable scopes for the quick-add selector. type MemoryView struct { Docs []MemoryDoc `json:"docs"` Facts []MemoryFact `json:"facts"` Archives []MemoryArchive `json:"archives"` Scopes []MemoryScope `json:"scopes"` StoreDir string `json:"storeDir"` StoreGlobalDir string `json:"storeGlobalDir,omitempty"` Available bool `json:"available"` } // writableScopes are the quick-add targets the panel offers, broad → specific. var writableScopes = []memory.Scope{memory.ScopeUser, memory.ScopeProject, memory.ScopeLocal} // Memory returns the loaded memory for the panel: the REASONIX.md hierarchy, // active/archived auto-memories, and the writable scopes. Read-only; mutations // go through Remember / SaveDoc. func (a *App) Memory() MemoryView { return a.memoryForCtrl(nil, true) } // MemoryForTab returns the loaded memory for a specific tab's controller, // so the panel can show memory for any open project, not just the active tab. // If the tab does not exist or has no controller, returns an empty view // instead of falling back to the active tab (which would show the wrong data). // An empty tabID is treated as "no tab specified" and falls back to the // active tab for backward compatibility. func (a *App) MemoryForTab(tabID string) MemoryView { if tabID == "" { return a.memoryForCtrl(nil, true) } return a.memoryForCtrl(a.ctrlByTabID(tabID), false) } func (a *App) memoryForCtrl(ctrl control.SessionAPI, fallback bool) MemoryView { view := MemoryView{Docs: []MemoryDoc{}, Facts: []MemoryFact{}, Archives: []MemoryArchive{}, Scopes: []MemoryScope{}} if ctrl == nil { if !fallback { return view } a.mu.RLock() ctrl = a.activeCtrlLocked() a.mu.RUnlock() if ctrl == nil { return view } } set := ctrl.Memory() if set == nil { return view } view.StoreDir = set.Store.Dir view.StoreGlobalDir = set.Store.GlobalDir view.Available = true for _, d := range set.Docs { view.Docs = append(view.Docs, MemoryDoc{Path: d.Path, Scope: string(d.Scope), Body: d.Body}) } for _, f := range set.Store.List() { view.Facts = append(view.Facts, MemoryFact{ Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body, }) } for _, f := range set.Store.ListArchived() { archivedAt := "" if !f.ArchivedAt.IsZero() { archivedAt = f.ArchivedAt.Format(time.RFC3339) } view.Archives = append(view.Archives, MemoryArchive{ Name: f.Name, Title: f.Title, Description: f.Description, Type: string(f.Type), Body: f.Body, Path: f.Path, ArchivedAt: archivedAt, }) } for _, sc := range writableScopes { if p := set.DocPath(sc); p != "" { view.Scopes = append(view.Scopes, MemoryScope{Scope: string(sc), Path: p}) } } return view } // Remember quick-adds a one-line note to the doc-memory file for scope — the // panel's explicit "remember" action, equivalent to typing "/remember ". // An unknown scope falls back to project. Returns the file written. func (a *App) Remember(scope, note string) (string, error) { return a.rememberForCtrl(nil, scope, note, true) } func (a *App) RememberForTab(tabID, scope, note string) (string, error) { if tabID == "" { return a.rememberForCtrl(nil, scope, note, true) } return a.rememberForCtrl(a.ctrlByTabID(tabID), scope, note, false) } func (a *App) rememberForCtrl(ctrl control.SessionAPI, scope, note string, fallback bool) (string, error) { if ctrl == nil { if !fallback { return "", nil } a.mu.RLock() ctrl = a.activeCtrlLocked() a.mu.RUnlock() if ctrl == nil { return "", nil } } return ctrl.QuickAdd(parseScope(scope), note) } // Forget deletes a saved auto-memory by name — the panel's delete action for a // fact the model owns. A no-op when no controller is attached. func (a *App) Forget(name string) error { return a.forgetForCtrl(nil, name, true) } func (a *App) ForgetForTab(tabID, name string) error { if tabID == "" { return a.forgetForCtrl(nil, name, true) } return a.forgetForCtrl(a.ctrlByTabID(tabID), name, false) } func (a *App) forgetForCtrl(ctrl control.SessionAPI, name string, fallback bool) error { if ctrl == nil { if !fallback { return nil } a.mu.RLock() ctrl = a.activeCtrlLocked() a.mu.RUnlock() if ctrl == nil { return nil } } return ctrl.ForgetMemory(name) } // SaveDoc overwrites a memory doc with the panel editor's contents. The controller // validates path against the recognized memory files. Returns the file written. func (a *App) SaveDoc(path, body string) (string, error) { return a.saveDocForCtrl(nil, path, body, true) } func (a *App) SaveDocForTab(tabID, path, body string) (string, error) { if tabID == "" { return a.saveDocForCtrl(nil, path, body, true) } return a.saveDocForCtrl(a.ctrlByTabID(tabID), path, body, false) } func (a *App) saveDocForCtrl(ctrl control.SessionAPI, path, body string, fallback bool) (string, error) { if ctrl == nil { if !fallback { return "", nil } a.mu.RLock() ctrl = a.activeCtrlLocked() a.mu.RUnlock() if ctrl == nil { return "", nil } } return ctrl.SaveDoc(path, body) } // parseScope maps a frontend scope id to a memory.Scope, defaulting to project. func parseScope(s string) memory.Scope { switch memory.Scope(s) { case memory.ScopeUser: return memory.ScopeUser case memory.ScopeLocal: return memory.ScopeLocal default: return memory.ScopeProject } } // onboardingKeyEnv is the default provider (deepseek) key from config.Default(). const onboardingKeyEnv = "DEEPSEEK_API_KEY" // onboardingBalanceURL doubles as a zero-token connectivity + auth probe: // billing.FetchWithClient surfaces 401/403 for a bad key. const onboardingBalanceURL = "https://api.deepseek.com/user/balance" var connectKeyBalanceFetch = billing.FetchWithClient // NativeConfirmRequest is the payload for ConfirmAction — a native OS confirmation // dialog that replaces web-style confirm() for destructive or important actions. type NativeConfirmRequest struct { Title string `json:"title"` Message string `json:"message"` Detail string `json:"detail"` ConfirmLabel string `json:"confirmLabel"` CancelLabel string `json:"cancelLabel"` Destructive bool `json:"destructive"` } // ConfirmAction shows a native confirmation dialog and returns true when the user // clicks the confirm button. For destructive actions the dialog type is Warning so // the platform can apply its danger styling (red tint on macOS, etc.). func (a *App) ConfirmAction(req NativeConfirmRequest) (bool, error) { if a.ctx == nil { return false, nil } dialogType := runtime.QuestionDialog if req.Destructive { dialogType = runtime.WarningDialog } confirm := req.ConfirmLabel if confirm == "" { confirm = "OK" } cancel := req.CancelLabel if cancel == "" { cancel = "Cancel" } title := req.Title if title == "" { title = req.Message } body := req.Message if req.Detail != "" { if body != "" { body += "\n\n" + req.Detail } else { body = req.Detail } } defaultBtn := confirm if req.Destructive { // On destructive actions, make cancel the default so Enter / Space // does NOT accidentally confirm. ESC always maps to CancelButton. defaultBtn = cancel } result, err := runtime.MessageDialog(a.ctx, runtime.MessageDialogOptions{ Type: dialogType, Title: title, Message: body, Buttons: []string{confirm, cancel}, DefaultButton: defaultBtn, CancelButton: cancel, }) if err != nil { return false, err } return result == confirm, nil } func (a *App) NeedsOnboarding() bool { return !config.CredentialStored(onboardingKeyEnv) } // ConnectKey validates apiKey against the balance endpoint, persists it to // Reasonix's global .env, and rebuilds the controller so the new key takes effect. func (a *App) ConnectKey(apiKey string) (string, error) { apiKey = strings.TrimSpace(apiKey) if apiKey == "" { return "", fmt.Errorf("key is required") } if tab := a.activeTab(); tab != nil && controllerHasActiveRuntimeWork(tab.Ctrl) { return "", rebuildControllerActiveWorkError("provider key") } ctx, cancel := context.WithTimeout(a.ctx, 8*time.Second) defer cancel() if _, err := connectKeyBalanceFetch(ctx, nil, onboardingBalanceURL, apiKey); err != nil { return "", fmt.Errorf("validate: %w", err) } warning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey) if err != nil { return "", fmt.Errorf("save: %w", err) } if err := a.rebuildSetting("provider key"); err != nil { if rebuildWarning, ok := a.deferredRebuildWarning("provider key", err); ok { warning = appendSettingsWarning(warning, rebuildWarning) } else { return "", err } } return warning, nil }