From b831b1d0b2dcad56d03b49cecd6efa4427ad8f85 Mon Sep 17 00:00:00 2001 From: poweruserz <55040543+poweruserz@users.noreply.github.com> Date: Thu, 12 Nov 2026 18:38:27 +0300 Subject: [PATCH 1/8] Turn on only the providers a new install finds A new install had Claude and Codex on for everyone, so users without them saw two "Not logged in" cards. It now starts with every provider off, probes each once and turns on those whose own login lookup found something. Copilot needs a Copilot plan, since its login is the GitHub CLI's. Settings are saved once everything is decided, so a start cut short detects again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHc1HaoK4c5kUmgtCmKuwT --- CLAUDE.md | 5 + plugins/not-set-up.test.js | 29 ++++ src-tauri/src/local_http_api/cache.rs | 12 +- src/App.test.tsx | 28 +++- src/App.tsx | 10 ++ src/hooks/app/use-provider-detection.test.ts | 132 ++++++++++++++++++ src/hooks/app/use-provider-detection.ts | 73 ++++++++++ src/hooks/app/use-settings-bootstrap.test.ts | 18 +++ src/hooks/app/use-settings-bootstrap.ts | 11 +- .../app/use-settings-plugin-actions.test.ts | 27 ++++ src/hooks/app/use-settings-plugin-actions.ts | 7 +- src/lib/plugin-errors.test.ts | 17 ++- src/lib/plugin-errors.ts | 24 +++- src/lib/settings.test.ts | 17 ++- src/lib/settings.ts | 12 +- 15 files changed, 393 insertions(+), 29 deletions(-) create mode 100644 plugins/not-set-up.test.js create mode 100644 src/hooks/app/use-provider-detection.test.ts create mode 100644 src/hooks/app/use-provider-detection.ts diff --git a/CLAUDE.md b/CLAUDE.md index 9376b11c..fd607a4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,11 @@ This file holds the project facts that are easy to get wrong. - `plugins//plugin.js` runs in **a fresh QuickJS runtime per probe**: module-level variables do NOT survive between refreshes (vitest reuses one instance, so tests hide this). Persist anything cross-probe under `ctx.app.pluginDataDir` (see claude `live-plan.json`). +- A new install starts with every provider off and probes them all once (`use-provider-detection.ts`): a card + turns on unless its error is one `isNotSetUpError` knows. So a plugin with nothing set up must throw such an + error without a request (`plugins/not-set-up.test.js` checks every shipped plugin). Copilot needs a Copilot plan + in its answer (its login is the GitHub CLI's, which any GitHub user has). The settings are saved only when every + provider is decided, so a start cut short detects again. - Every line label a plugin can emit on the overview card must be declared `scope: "overview"` in `plugin.json`, or that account type gets a blank card (the card filters by manifest labels). - Response bodies are logged (first 500 chars) after `redact_body` in diff --git a/plugins/not-set-up.test.js b/plugins/not-set-up.test.js new file mode 100644 index 00000000..af0ed598 --- /dev/null +++ b/plugins/not-set-up.test.js @@ -0,0 +1,29 @@ +import { readdirSync } from "node:fs" +import { expect, it } from "vitest" +import { isNotSetUpError } from "../src/lib/plugin-errors" +import { makeCtx } from "./test-helpers.js" + +// A new install turns on the providers whose first probe finds something (use-provider-detection.ts). +// So with nothing set up, every shipped plugin (copy-bundled.cjs skips mock) must say so in words +// isNotSetUpError knows, without a request: anything else would turn its card on for everyone. +const ids = readdirSync("plugins", { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name !== "mock") + .map((entry) => entry.name) + +it("every plugin says 'not set up' when nothing of it is on the PC", async () => { + expect(ids.length).toBeGreaterThan(10) + for (const id of ids) { + await import(/* @vite-ignore */ `./${id}/plugin.js`) + const plugin = globalThis.__openusage_plugin + const ctx = makeCtx() + ctx.app.platform = "windows" + let message = "(no error)" + try { + await plugin.probe(ctx) + } catch (error) { + message = String(error?.message ?? error) + } + expect(isNotSetUpError(message), `${id}: ${message}`).toBe(true) + expect(ctx.host.http.request, id).not.toHaveBeenCalled() + } +}) diff --git a/src-tauri/src/local_http_api/cache.rs b/src-tauri/src/local_http_api/cache.rs index 1c9d772a..315e3235 100644 --- a/src-tauri/src/local_http_api/cache.rs +++ b/src-tauri/src/local_http_api/cache.rs @@ -7,7 +7,6 @@ use std::time::Duration; const CACHE_FILE_NAME: &str = "usage-api-cache.json"; const SETTINGS_FILE_NAME: &str = "settings.json"; -const DEFAULT_ENABLED_PLUGINS: &[&str] = &["claude", "codex"]; #[cfg(not(test))] const CACHE_WRITE_DEBOUNCE: Duration = Duration::from_millis(500); @@ -316,15 +315,8 @@ fn read_plugin_settings(app_data_dir: &Path) -> (Vec, HashSet, b pub(super) fn enabled_snapshots_ordered(state: &CacheState) -> Vec { let (settings_order, disabled, has_settings) = read_plugin_settings(&state.app_data_dir); - let default_enabled: HashSet<&str> = DEFAULT_ENABLED_PLUGINS.iter().copied().collect(); - - let is_enabled = |id: &str| -> bool { - if has_settings { - !disabled.contains(id) - } else { - default_enabled.contains(id) - } - }; + // Until the app first saves its plugin settings nothing is on: a new install turns on what it detects. + let is_enabled = |id: &str| has_settings && !disabled.contains(id); // Build ordered plugin ids: settings order first, then remaining known ids. let mut ordered: Vec = Vec::new(); diff --git a/src/App.test.tsx b/src/App.test.tsx index 35921704..b35e4800 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -474,6 +474,31 @@ describe("App", () => { await screen.findByText("Now") }) + it("on a new install shows only the providers this PC has", async () => { + state.loadPluginSettingsMock.mockResolvedValueOnce({ order: [], disabled: [] }) + render() + await waitFor(() => expect(state.startBatchMock).toHaveBeenCalledWith(["a", "b"])) + expect(state.savePluginSettingsMock).not.toHaveBeenCalled() + + state.probeHandlers?.onResult({ + providerId: "a", + displayName: "Alpha", + iconUrl: "icon-a", + lines: [{ type: "text", label: "Now", value: "Later" }], + }) + state.probeHandlers?.onResult({ + providerId: "b", + displayName: "Beta", + iconUrl: "icon-b", + lines: [{ type: "badge", label: "Error", text: "Not logged in. Run `beta` to authenticate." }], + }) + + await waitFor(() => expect(state.savePluginSettingsMock).toHaveBeenCalledWith({ order: ["a", "b"], disabled: ["b"] })) + expect(state.savePluginSettingsMock).toHaveBeenCalledTimes(1) + await screen.findByText("Now") + expect(screen.queryByText("Beta")).toBeNull() + }) + it("updates tray icon on probe results when plugin has a primary progress", async () => { state.invokeMock.mockImplementation(async (cmd: string) => { if (cmd === "list_plugins") { @@ -949,8 +974,7 @@ describe("App", () => { }) it("toggles plugins in settings", async () => { - // Use already-normalised settings so no init save fires (b is disabled - // because "b" is not in DEFAULT_ENABLED_PLUGINS = ["claude","codex"]) + // Use already-normalised settings so no init save fires state.loadPluginSettingsMock.mockResolvedValue({ order: ["a", "b"], disabled: ["b"] }) render() const settingsButtons = await screen.findAllByRole("button", { name: "Settings" }) diff --git a/src/App.tsx b/src/App.tsx index 8a1c0b11..c1a2a843 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { AppShell } from "@/components/app/app-shell" import { useAppPluginViews } from "@/hooks/app/use-app-plugin-views" import { useProbe } from "@/hooks/app/use-probe" import { useSettingsBootstrap } from "@/hooks/app/use-settings-bootstrap" +import { useProviderDetection } from "@/hooks/app/use-provider-detection" import { useSettingsDisplayActions } from "@/hooks/app/use-settings-display-actions" import { useSettingsPluginActions } from "@/hooks/app/use-settings-plugin-actions" import { useSettingsPluginList } from "@/hooks/app/use-settings-plugin-list" @@ -217,6 +218,14 @@ function App() { } }, [scheduleTrayIconUpdate]) + const { detectProviders } = useProviderDetection({ + pluginSettings, + pluginStates, + setPluginSettings, + setLoadingForPlugins, + startBatch, + }) + const { applyStartOnLogin } = useSettingsBootstrap({ setPluginSettings, setPluginsMeta, @@ -244,6 +253,7 @@ function App() { setLoadingForPlugins, setErrorForPlugins, startBatch, + detectProviders, }) useSettingsTheme(themeMode) diff --git a/src/hooks/app/use-provider-detection.test.ts b/src/hooks/app/use-provider-detection.test.ts new file mode 100644 index 00000000..11d688a3 --- /dev/null +++ b/src/hooks/app/use-provider-detection.test.ts @@ -0,0 +1,132 @@ +import { act, renderHook } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { PluginState } from "@/hooks/app/types" +import type { PluginOutput } from "@/lib/plugin-types" +import type { PluginSettings } from "@/lib/settings" +import { useAppPluginStore } from "@/stores/app-plugin-store" + +const { savePluginSettingsMock } = vi.hoisted(() => ({ savePluginSettingsMock: vi.fn() })) + +vi.mock("@/lib/settings", () => ({ savePluginSettings: savePluginSettingsMock })) + +import { useProviderDetection } from "@/hooks/app/use-provider-detection" + +const IDS = ["claude", "codex", "cursor", "zai"] + +const done = (patch: Partial): PluginState => ({ + data: null, + loading: false, + error: null, + lastManualRefreshAt: null, + lastUpdatedAt: null, + ...patch, +}) +const usage = (id: string, plan?: string): PluginOutput => ({ providerId: id, displayName: id, plan, lines: [], iconUrl: "" }) +const notSetUp = "Not logged in. Run `x` to authenticate." + +// As in App: the setter is the store's, and the hook reads the store. +const settings = () => useAppPluginStore.getState().pluginSettings +const setSettings = (value: PluginSettings | null) => useAppPluginStore.getState().setPluginSettings(value) + +function setup(ids = IDS, startBatch = vi.fn().mockResolvedValue(undefined)) { + setSettings({ order: ids, disabled: [...ids] }) + const setPluginSettings = vi.fn(setSettings) + const hook = renderHook( + ({ pluginStates }: { pluginStates: Record }) => + useProviderDetection({ + pluginSettings: useAppPluginStore((state) => state.pluginSettings), + pluginStates, + setPluginSettings, + setLoadingForPlugins: vi.fn(), + startBatch, + }), + { initialProps: { pluginStates: {} as Record } } + ) + return { hook, setPluginSettings, startBatch, detect: () => act(() => hook.result.current.detectProviders(ids)) } +} + +describe("useProviderDetection", () => { + beforeEach(() => { + useAppPluginStore.getState().resetState() + savePluginSettingsMock.mockReset() + savePluginSettingsMock.mockResolvedValue(undefined) + }) + + it("turns on what a probe found, and saves once every provider is decided", async () => { + const { hook, startBatch, detect } = setup() + await detect() + expect(startBatch).toHaveBeenCalledWith(IDS) + + const states = { + claude: done({ data: usage("claude") }), + codex: done({ error: notSetUp }), + // An expired login is still the user's: the card shows with its fix. + cursor: done({ error: "Token expired. Sign in via Cursor app or run `agent login`." }), + zai: done({ loading: true }), + } + hook.rerender({ pluginStates: states }) + expect(settings()).toEqual({ order: IDS, disabled: ["codex", "zai"] }) + // Not saved while zai is still probing: a start cut short detects again. + expect(savePluginSettingsMock).not.toHaveBeenCalled() + + hook.rerender({ pluginStates: { ...states, zai: done({ error: "No ZAI_API_KEY found. Set up environment variable first." }) } }) + expect(savePluginSettingsMock).toHaveBeenCalledTimes(1) + expect(savePluginSettingsMock).toHaveBeenCalledWith({ order: IDS, disabled: ["codex", "zai"] }) + }) + + it("saves the all-off settings when nothing is found", async () => { + const { hook, setPluginSettings, detect } = setup(["codex"]) + await detect() + hook.rerender({ pluginStates: { codex: done({ error: notSetUp }) } }) + expect(setPluginSettings).not.toHaveBeenCalled() + expect(savePluginSettingsMock).toHaveBeenCalledWith({ order: ["codex"], disabled: ["codex"] }) + }) + + it("takes Copilot only with a Copilot plan: its login is the GitHub CLI's", async () => { + const { hook, detect } = setup(["copilot"]) + await detect() + hook.rerender({ pluginStates: { copilot: done({ error: "Token invalid. Run `gh auth login` to re-authenticate." }) } }) + expect(settings()?.disabled).toEqual(["copilot"]) + + const found = setup(["copilot"]) + await found.detect() + found.hook.rerender({ pluginStates: { copilot: done({ data: usage("copilot", "Individual Pro") }) } }) + expect(settings()?.disabled).toEqual([]) + }) + + it("decides each provider once, so turning one off later sticks", async () => { + const { hook, detect } = setup(["claude"]) + await detect() + const states = { claude: done({ data: usage("claude") }) } + hook.rerender({ pluginStates: states }) + act(() => setSettings({ order: ["claude"], disabled: ["claude"] })) + hook.rerender({ pluginStates: { ...states } }) + expect(settings()?.disabled).toEqual(["claude"]) + }) + + it("leaves a provider the user turned on and off while it probed to the user", async () => { + const { hook, detect } = setup(["claude"]) + await detect() + act(() => setSettings({ order: ["claude"], disabled: [] })) + act(() => setSettings({ order: ["claude"], disabled: ["claude"] })) + hook.rerender({ pluginStates: { claude: done({ data: usage("claude") }) } }) + expect(settings()?.disabled).toEqual(["claude"]) + }) + + it("keeps a card added while it was detecting", async () => { + const { hook, detect } = setup(["claude"]) + await detect() + // A WSL card arrives (handlePluginsChanged) before the result. + act(() => setSettings({ order: ["claude", "claude-wsl-1a2b3c4d"], disabled: ["claude"] })) + hook.rerender({ pluginStates: { claude: done({ data: usage("claude") }) } }) + expect(settings()).toEqual({ order: ["claude", "claude-wsl-1a2b3c4d"], disabled: [] }) + }) + + it("turns nothing on and saves nothing when the probes can't start", async () => { + const { hook, setPluginSettings, detect } = setup(["claude"], vi.fn().mockRejectedValue(new Error("no host"))) + await detect() + hook.rerender({ pluginStates: { claude: done({ error: "Failed to start probe" }) } }) + expect(setPluginSettings).not.toHaveBeenCalled() + expect(savePluginSettingsMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/app/use-provider-detection.ts b/src/hooks/app/use-provider-detection.ts new file mode 100644 index 00000000..b8e92440 --- /dev/null +++ b/src/hooks/app/use-provider-detection.ts @@ -0,0 +1,73 @@ +import { useCallback, useEffect, useRef } from "react" +import type { PluginState } from "@/hooks/app/types" +import { isNotSetUpError } from "@/lib/plugin-errors" +import { savePluginSettings, type PluginSettings } from "@/lib/settings" +import { useAppPluginStore } from "@/stores/app-plugin-store" + +type UseProviderDetectionArgs = { + pluginSettings: PluginSettings | null + pluginStates: Record + setPluginSettings: (value: PluginSettings | null) => void + setLoadingForPlugins: (ids: string[]) => void + startBatch: (pluginIds?: string[]) => Promise +} + +/** Usage, or any error but "nothing set up": an expired login or a 429 still means the user has it. */ +function isFound(id: string, state: PluginState) { + // Copilot signs in with the GitHub CLI's login, which any GitHub user has: only a Copilot plan proves Copilot. + if (id === "copilot") return Boolean(state.data?.plan) + return Boolean(state.data) || !isNotSetUpError(state.error ?? "") +} + +/** + * A new install starts with every provider off and probes them all once: each plugin's own login + * lookup is the detector, and what it finds is turned on. The settings are saved once every + * provider is decided, so a start cut short (quit, crash, probes that can't start) detects again. + */ +export function useProviderDetection({ + pluginSettings, + pluginStates, + setPluginSettings, + setLoadingForPlugins, + startBatch, +}: UseProviderDetectionArgs) { + const pending = useRef(new Set()) + + const detectProviders = useCallback( + async (ids: string[]) => { + for (const id of ids) pending.current.add(id) + setLoadingForPlugins(ids) + try { + await startBatch(ids) + } catch (error) { + console.error("Failed to start provider detection:", error) + pending.current.clear() + } + }, + [setLoadingForPlugins, startBatch] + ) + + // Runs on new results and on settings changes. Reads the store, not the render's copy: a WSL card + // added meanwhile (`handlePluginsChanged`) must not be written over. + useEffect(() => { + const current = useAppPluginStore.getState().pluginSettings + if (!current || pending.current.size === 0) return + const found = new Set() + for (const id of [...pending.current]) { + const state = pluginStates[id] + // Turned on by the user meanwhile: theirs to decide. + if (!current.disabled.includes(id)) pending.current.delete(id) + else if (state && !state.loading) { + pending.current.delete(id) + if (isFound(id, state)) found.add(id) + } + } + const next = found.size > 0 ? { ...current, disabled: current.disabled.filter((id) => !found.has(id)) } : current + if (next !== current) setPluginSettings(next) + if (pending.current.size === 0) { + void savePluginSettings(next).catch((error) => console.error("Failed to save detected providers:", error)) + } + }, [pluginSettings, pluginStates, setPluginSettings]) + + return { detectProviders } +} diff --git a/src/hooks/app/use-settings-bootstrap.test.ts b/src/hooks/app/use-settings-bootstrap.test.ts index 6843b143..470c133a 100644 --- a/src/hooks/app/use-settings-bootstrap.test.ts +++ b/src/hooks/app/use-settings-bootstrap.test.ts @@ -131,6 +131,7 @@ function createArgs() { setLoadingForPlugins: vi.fn(), setErrorForPlugins: vi.fn(), startBatch: vi.fn().mockResolvedValue(undefined), + detectProviders: vi.fn().mockResolvedValue(undefined), } } @@ -310,5 +311,22 @@ describe("useSettingsBootstrap", () => { expect(args.setPluginSettings).toHaveBeenCalledWith(migratedSettings) expect(args.startBatch).toHaveBeenCalledWith(["devin"]) }) + expect(args.detectProviders).not.toHaveBeenCalled() + }) + + it("on a new install probes every provider to find the ones this PC has", async () => { + const args = createArgs() + const normalized = { order: ["claude", "codex"], disabled: ["claude", "codex"] } + loadPluginSettingsMock.mockResolvedValueOnce({ order: [], disabled: [] }) + normalizePluginSettingsMock.mockReturnValueOnce(normalized) + arePluginSettingsEqualMock.mockReturnValueOnce(false) + + renderHook(() => useSettingsBootstrap(args)) + + await waitFor(() => expect(args.detectProviders).toHaveBeenCalledWith(["claude", "codex"])) + expect(args.setPluginSettings).toHaveBeenCalledWith(normalized) + expect(args.startBatch).not.toHaveBeenCalled() + // Saved by detection once it is done, so a start cut short detects again. + expect(savePluginSettingsMock).not.toHaveBeenCalled() }) }) diff --git a/src/hooks/app/use-settings-bootstrap.ts b/src/hooks/app/use-settings-bootstrap.ts index e47dab44..e2ebae0c 100644 --- a/src/hooks/app/use-settings-bootstrap.ts +++ b/src/hooks/app/use-settings-bootstrap.ts @@ -89,6 +89,7 @@ type UseSettingsBootstrapArgs = { setLoadingForPlugins: (ids: string[]) => void setErrorForPlugins: (ids: string[], error: string) => void startBatch: (pluginIds?: string[]) => Promise + detectProviders: (ids: string[]) => Promise } export function useSettingsBootstrap({ @@ -118,6 +119,7 @@ export function useSettingsBootstrap({ setLoadingForPlugins, setErrorForPlugins, startBatch, + detectProviders, }: UseSettingsBootstrapArgs) { const applyStartOnLogin = useCallback(async (value: boolean) => { if (!isTauri()) return @@ -205,7 +207,9 @@ export function useSettingsBootstrap({ const storedSettings = await loadPluginSettings() const migratedSettings = migrateWindsurfToDevin(storedSettings) const normalized = normalizePluginSettings(migratedSettings, availablePlugins) - if (!arePluginSettingsEqual(storedSettings, normalized)) { + // A new install saves them once detection has decided every provider (use-provider-detection.ts). + const newInstall = storedSettings.order.length === 0 + if (!newInstall && !arePluginSettingsEqual(storedSettings, normalized)) { await savePluginSettings(normalized) } @@ -301,6 +305,10 @@ export function useSettingsBootstrap({ setTrayMetric(storedTrayMetric) setTrayPercentColor(storedTrayPercentColor) + if (newInstall) { + await detectProviders(normalized.order) + return + } const enabledIds = getEnabledPluginIds(normalized) setLoadingForPlugins(enabledIds) try { @@ -324,6 +332,7 @@ export function useSettingsBootstrap({ } }, [ applyStartOnLogin, + detectProviders, setAutoUpdateInterval, setDisplayMode, setErrorForPlugins, diff --git a/src/hooks/app/use-settings-plugin-actions.test.ts b/src/hooks/app/use-settings-plugin-actions.test.ts index 13ce39dc..f44bd377 100644 --- a/src/hooks/app/use-settings-plugin-actions.test.ts +++ b/src/hooks/app/use-settings-plugin-actions.test.ts @@ -14,6 +14,7 @@ const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() })) vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock })) import { useSettingsPluginActions } from "@/hooks/app/use-settings-plugin-actions" +import { useAppPluginStore } from "@/stores/app-plugin-store" describe("useSettingsPluginActions", () => { beforeEach(() => { @@ -335,6 +336,32 @@ describe("useSettingsPluginActions", () => { expect(startBatch).toHaveBeenCalledWith(["claude-wsl-0a1b2c3d"]) }) + it("keeps a provider turned on while the plugin list was being read", async () => { + let listed: (metas: unknown) => void = () => {} + invokeMock.mockReturnValue(new Promise((resolve) => { listed = resolve })) + const setPluginSettings = vi.fn() + const { result } = renderHook(() => + useSettingsPluginActions({ + pluginSettings: { order: ["claude", "codex"], disabled: ["claude", "codex"] }, + setPluginSettings, + setPluginsMeta: vi.fn(), + setLoadingForPlugins: vi.fn(), + setErrorForPlugins: vi.fn(), + startBatch: vi.fn(() => Promise.resolve([])), + scheduleTrayIconUpdate: vi.fn(), + }) + ) + + const change = result.current.handlePluginsChanged({ added: [{ id: "claude-wsl-0a1b2c3d", plugin: "claude" }], removed: [] }) + // A new install's detection turns Claude on meanwhile. + useAppPluginStore.getState().setPluginSettings({ order: ["claude", "codex"], disabled: ["codex"] }) + listed(["claude", "claude-wsl-0a1b2c3d", "codex"].map((id) => ({ id, name: id }))) + await act(() => change) + + expect(setPluginSettings).toHaveBeenCalledWith({ order: ["claude", "claude-wsl-0a1b2c3d", "codex"], disabled: ["codex"] }) + useAppPluginStore.getState().resetState() + }) + it("keeps a change that arrives before the settings load, and applies it once they do", async () => { invokeMock.mockResolvedValue(["claude", "claude-wsl-0a1b2c3d", "codex"].map((id) => ({ id, name: id }))) const setPluginSettings = vi.fn() diff --git a/src/hooks/app/use-settings-plugin-actions.ts b/src/hooks/app/use-settings-plugin-actions.ts index 5460b314..aab30013 100644 --- a/src/hooks/app/use-settings-plugin-actions.ts +++ b/src/hooks/app/use-settings-plugin-actions.ts @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core" import { insertAccount, type AccountsChange, type PluginsChange } from "@/lib/accounts" import type { PluginMeta } from "@/lib/plugin-types" import { normalizePluginSettings, savePluginSettings, type PluginSettings } from "@/lib/settings" +import { useAppPluginStore } from "@/stores/app-plugin-store" const TRAY_SETTINGS_DEBOUNCE_MS = 2000 @@ -110,9 +111,11 @@ export function useSettingsPluginActions({ try { const metas = await invoke("list_plugins") setPluginsMeta(metas) + // Read after the await: a provider a new install's detection turned on meanwhile must stay on. + const current = useAppPluginStore.getState().pluginSettings ?? pluginSettings const removed = new Set(change.removed) - let order = pluginSettings.order.filter((id) => !removed.has(id)) - let disabled = pluginSettings.disabled.filter((id) => !removed.has(id)) + let order = current.order.filter((id) => !removed.has(id)) + let disabled = current.disabled.filter((id) => !removed.has(id)) for (const added of change.added) { order = insertAccount(order, added.id, added.plugin) disabled = disabled.filter((id) => id !== added.id) diff --git a/src/lib/plugin-errors.test.ts b/src/lib/plugin-errors.test.ts index 5b528099..030d7026 100644 --- a/src/lib/plugin-errors.test.ts +++ b/src/lib/plugin-errors.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs" import { afterEach, describe, expect, it } from "vitest" import { LANGUAGES, useLocaleStore } from "@/lib/i18n" -import { getPluginErrorAction, PLUGIN_ERROR_PATTERNS, translatePluginError } from "@/lib/plugin-errors" +import { getPluginErrorAction, isNotSetUpError, PLUGIN_ERROR_PATTERNS, translatePluginError } from "@/lib/plugin-errors" import enUS, { type PluginErrorKey } from "@/locales/plugin-errors/en-US" import deDE from "@/locales/plugin-errors/de-DE" import es419 from "@/locales/plugin-errors/es-419" @@ -228,6 +228,21 @@ describe("plugin error dictionaries", () => { } }) +describe("isNotSetUpError", () => { + it("is true only when nothing of the provider is on this PC", () => { + expect(isNotSetUpError("Not logged in. Run `claude` to authenticate.")).toBe(true) + expect(isNotSetUpError("No ZAI_API_KEY found. Set up environment variable first.")).toBe(true) + expect(isNotSetUpError("Amp not installed. Install Amp Code to get started.")).toBe(true) + // The user has the provider: an old login, a bad key, a busy or unreachable server. + expect(isNotSetUpError("Token expired. Run `claude` to log in again.")).toBe(false) + expect(isNotSetUpError("API key invalid. Check your Z.ai API key.")).toBe(false) + expect(isNotSetUpError("Rate limited by Claude, retry in ~5m")).toBe(false) + expect(isNotSetUpError("Request failed. Check your connection.")).toBe(false) + expect(isNotSetUpError("quota exceeded for org")).toBe(false) + expect(isNotSetUpError("")).toBe(false) + }) +}) + describe("getPluginErrorAction", () => { it("offers the login command or the missing API-key variables", () => { expect(getPluginErrorAction("Start Antigravity or run `agy` and try again.")).toEqual({ kind: "run", command: "agy" }) diff --git a/src/lib/plugin-errors.ts b/src/lib/plugin-errors.ts index 611b23d7..2f7aab1d 100644 --- a/src/lib/plugin-errors.ts +++ b/src/lib/plugin-errors.ts @@ -131,7 +131,29 @@ export function translatePluginError(message: string): string { return MESSAGES[getLocale()][match.key].replace(/\{(\w+)\}/g, (text, name: string) => match.vars[name] ?? text) } -export type PluginErrorAction = { kind: "run"; command: string } | { kind: "env"; names: string[] } +/** What a provider says when nothing of it is on this PC: no login, no API key, not installed. */ +const NOT_SET_UP: ReadonlySet = new Set([ + "notLoggedInRun", + "notLoggedInRunFirst", + "notLoggedInSignInOrRun", + "startAppOrRun", + "runOrSignIn", + "productNotLoggedInRun", + "openAppSignIn", + "apiKeyMissingSetEnv", + "apiKeyNotFoundSetEnvOrFile", + "envKeyNotFound", + "notDetectedLogIn", + "notInstalled", + "jetbrainsNotDetected", +]) + +export function isNotSetUpError(message: string) { + const key = matchPluginError(message)?.key + return key !== undefined && NOT_SET_UP.has(key) +} + +export type PluginErrorAction ={ kind: "run"; command: string } | { kind: "env"; names: string[] } /** * One-click fix for a known error: a login command to run in a terminal, or API-key environment diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 90c0d56b..3d81365b 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -106,15 +106,24 @@ describe("settings", () => { expect(normalized).toEqual({ order: ["b", "a"], disabled: ["a"] }) }) - it("auto-disables new non-default plugins", () => { + it("starts a new install with every provider off, Claude and Codex too", () => { const plugins: PluginMeta[] = [ + { id: "codex", name: "Codex", iconUrl: "", lines: [], primaryCandidates: [] }, { id: "claude", name: "Claude", iconUrl: "", lines: [], primaryCandidates: [] }, - { id: "copilot", name: "Copilot", iconUrl: "", lines: [], primaryCandidates: [] }, { id: "devin", name: "Devin", iconUrl: "", lines: [], primaryCandidates: [] }, ] const result = normalizePluginSettings({ order: [], disabled: [] }, plugins) - expect(result.order).toEqual(["claude", "copilot", "devin"]) - expect(result.disabled).toEqual(["copilot", "devin"]) + expect(result.order).toEqual(["claude", "codex", "devin"]) + expect(result.disabled).toEqual(["claude", "codex", "devin"]) + }) + + it("turns off only the providers an update adds", () => { + const plugins: PluginMeta[] = [ + { id: "claude", name: "Claude", iconUrl: "", lines: [], primaryCandidates: [] }, + { id: "codex", name: "Codex", iconUrl: "", lines: [], primaryCandidates: [] }, + ] + const result = normalizePluginSettings({ order: ["claude"], disabled: [] }, plugins) + expect(result).toEqual({ order: ["claude", "codex"], disabled: ["codex"] }) }) it("migrates enabled windsurf settings to enabled devin settings", () => { diff --git a/src/lib/settings.ts b/src/lib/settings.ts index a18e1322..6e671bc0 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -7,7 +7,8 @@ import { normalizeCurrency, type CurrencySetting } from "@/lib/currency"; // Refresh cooldown duration in milliseconds (5 minutes) export const REFRESH_COOLDOWN_MS = 300_000; -// Spec: persist plugin order + disabled list; new plugins append, default disabled unless in DEFAULT_ENABLED_PLUGINS. +// Spec: persist plugin order + disabled list; new plugins append, disabled (a new install turns on +// what `use-provider-detection.ts` finds). export type PluginSettings = { order: string[]; disabled: string[]; @@ -145,8 +146,6 @@ export const TIME_FORMAT_OPTIONS: SettingOption[] = [ const store = new LazyStore(SETTINGS_STORE_PATH); -const DEFAULT_ENABLED_PLUGINS = new Set(["claude", "codex"]); - const PREFERRED_ORDER = ["claude", "codex", "gemini"]; export const DEFAULT_PLUGIN_SETTINGS: PluginSettings = { @@ -249,11 +248,8 @@ export function normalizePluginSettings( } const disabled = settings.disabled.filter((id) => knownSet.has(id)); - const defaultDisabledIds = hasSavedOrder ? newlyAdded : order; - for (const id of defaultDisabledIds) { - if (!DEFAULT_ENABLED_PLUGINS.has(id) && !disabled.includes(id)) { - disabled.push(id); - } + for (const id of hasSavedOrder ? newlyAdded : order) { + if (!disabled.includes(id)) disabled.push(id); } return { order, disabled }; } From 54239b97ef21c9bfdefa75873e12ec275c0ecbe3 Mon Sep 17 00:00:00 2001 From: poweruserz <55040543+poweruserz@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:02:39 +0300 Subject: [PATCH 2/8] Keep detection resumable and rejected logins visible Other settings saves during detection (a WSL card, a toggle) made the next start look finished, so a flag now marks detection as pending until every provider is decided. Devin, Cursor and Kimi said "not logged in" for a login the server rejected, which detection reads as nothing set up; they now say the session expired. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHc1HaoK4c5kUmgtCmKuwT --- CLAUDE.md | 5 +++-- plugins/cursor/plugin.js | 2 +- plugins/cursor/plugin.test.js | 3 ++- plugins/devin/plugin.js | 3 ++- plugins/devin/plugin.test.js | 9 +++++++++ plugins/kimi/plugin.js | 5 ++++- plugins/kimi/plugin.test.js | 5 +++-- src/App.test.tsx | 8 +++++--- src/hooks/app/use-provider-detection.test.ts | 18 +++++++++++++++--- src/hooks/app/use-provider-detection.ts | 11 +++++++---- src/hooks/app/use-settings-bootstrap.test.ts | 19 +++++++++++++++++-- src/hooks/app/use-settings-bootstrap.ts | 12 ++++++++---- src/lib/settings.ts | 12 ++++++++++++ 13 files changed, 88 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fd607a4e..386e5e4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,9 @@ This file holds the project facts that are easy to get wrong. - A new install starts with every provider off and probes them all once (`use-provider-detection.ts`): a card turns on unless its error is one `isNotSetUpError` knows. So a plugin with nothing set up must throw such an error without a request (`plugins/not-set-up.test.js` checks every shipped plugin). Copilot needs a Copilot plan - in its answer (its login is the GitHub CLI's, which any GitHub user has). The settings are saved only when every - provider is decided, so a start cut short detects again. + in its answer (its login is the GitHub CLI's, which any GitHub user has). The store key `providerDetectionPending` + is set before the first settings save and cleared once every provider is decided, so a start cut short detects + again. A plugin whose login is rejected must not throw a "not set up" message (use "Session expired …"). - Every line label a plugin can emit on the overview card must be declared `scope: "overview"` in `plugin.json`, or that account type gets a blank card (the card filters by manifest labels). - Response bodies are logged (first 500 chars) after `redact_body` in diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index db425425..04056aca 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -579,7 +579,7 @@ accessToken = refreshed } else if (!accessToken) { ctx.host.log.error("refresh failed and no access token available") - throw "Not logged in. " + LOGIN_HINT + throw "Session expired. " + LOGIN_HINT } } diff --git a/plugins/cursor/plugin.test.js b/plugins/cursor/plugin.test.js index 15952b7e..d0542782 100644 --- a/plugins/cursor/plugin.test.js +++ b/plugins/cursor/plugin.test.js @@ -1531,7 +1531,8 @@ describe("cursor plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Not logged in") + // A refresh token is a login the user has: not "Not logged in", which means nothing is set up. + expect(() => plugin.probe(ctx)).toThrow("Session expired. Sign in via Cursor app or run `agent login`.") }) it("throws token expired when usage remains unauthorized after refresh retry", async () => { diff --git a/plugins/devin/plugin.js b/plugins/devin/plugin.js index 708b5058..2386a6ef 100644 --- a/plugins/devin/plugin.js +++ b/plugins/devin/plugin.js @@ -325,7 +325,8 @@ if (appAttempt.authFailure) sawAuthFailure = true } - if (sawAuthFailure) throw LOGIN_HINT + // Not LOGIN_HINT: that says nothing is set up, and a new install would leave the card off. + if (sawAuthFailure) throw "Session expired. Sign in via Devin app or run `devin auth login`." if (sawApiKey) throw QUOTA_HINT throw LOGIN_HINT } diff --git a/plugins/devin/plugin.test.js b/plugins/devin/plugin.test.js index 732220a6..3a2a5613 100644 --- a/plugins/devin/plugin.test.js +++ b/plugins/devin/plugin.test.js @@ -337,6 +337,15 @@ describe("devin plugin", () => { ]) }) + it("says the session expired when every login is rejected, not that nothing is set up", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText(CREDENTIALS_PATH, makeCredentialsToml()) + ctx.host.http.request.mockReturnValue({ status: 401, bodyText: "{}" }) + const plugin = await loadPlugin() + + expect(() => plugin.probe(ctx)).toThrow("Session expired. Sign in via Devin app or run `devin auth login`.") + }) + it("throws the login hint when no auth source is available", async () => { const ctx = makeCtx() const plugin = await loadPlugin() diff --git a/plugins/kimi/plugin.js b/plugins/kimi/plugin.js index 45072e00..591631fa 100644 --- a/plugins/kimi/plugin.js +++ b/plugins/kimi/plugin.js @@ -253,7 +253,10 @@ if (refreshed) { accessToken = refreshed } else if (!accessToken) { - throw "Not logged in. Run `kimi login` to authenticate." + // A refresh token that no longer works is a login the user has (a new install shows its card). + throw creds.refresh_token + ? "Session expired. Run `kimi login` to authenticate." + : "Not logged in. Run `kimi login` to authenticate." } } diff --git a/plugins/kimi/plugin.test.js b/plugins/kimi/plugin.test.js index 0a638092..c28e254f 100644 --- a/plugins/kimi/plugin.test.js +++ b/plugins/kimi/plugin.test.js @@ -306,7 +306,7 @@ describe("kimi plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Usage request failed. Check your connection.") }) - it("throws not logged in when refresh response is missing access token", async () => { + it("says the session expired when the refresh token gives no access token", async () => { const ctx = makeCtx() ctx.host.fs.writeText( CRED_PATH, @@ -324,7 +324,8 @@ describe("kimi plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow("Not logged in") + // A refresh token is a login the user has: "Not logged in" would mean nothing is set up. + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `kimi login` to authenticate.") }) it("returns badge when no valid quota is available", async () => { diff --git a/src/App.test.tsx b/src/App.test.tsx index b35e4800..cb210293 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -231,6 +231,8 @@ vi.mock("@/lib/settings", async () => { ...actual, loadPluginSettings: state.loadPluginSettingsMock, savePluginSettings: state.savePluginSettingsMock, + loadProviderDetectionPending: async () => false, + saveProviderDetectionPending: async () => {}, loadAutoUpdateInterval: state.loadAutoUpdateIntervalMock, saveAutoUpdateInterval: state.saveAutoUpdateIntervalMock, loadThemeMode: state.loadThemeModeMock, @@ -478,7 +480,7 @@ describe("App", () => { state.loadPluginSettingsMock.mockResolvedValueOnce({ order: [], disabled: [] }) render() await waitFor(() => expect(state.startBatchMock).toHaveBeenCalledWith(["a", "b"])) - expect(state.savePluginSettingsMock).not.toHaveBeenCalled() + expect(state.savePluginSettingsMock).toHaveBeenCalledWith({ order: ["a", "b"], disabled: ["a", "b"] }) state.probeHandlers?.onResult({ providerId: "a", @@ -493,8 +495,8 @@ describe("App", () => { lines: [{ type: "badge", label: "Error", text: "Not logged in. Run `beta` to authenticate." }], }) - await waitFor(() => expect(state.savePluginSettingsMock).toHaveBeenCalledWith({ order: ["a", "b"], disabled: ["b"] })) - expect(state.savePluginSettingsMock).toHaveBeenCalledTimes(1) + await waitFor(() => expect(state.savePluginSettingsMock).toHaveBeenLastCalledWith({ order: ["a", "b"], disabled: ["b"] })) + expect(state.savePluginSettingsMock).toHaveBeenCalledTimes(2) await screen.findByText("Now") expect(screen.queryByText("Beta")).toBeNull() }) diff --git a/src/hooks/app/use-provider-detection.test.ts b/src/hooks/app/use-provider-detection.test.ts index 11d688a3..92bad3ca 100644 --- a/src/hooks/app/use-provider-detection.test.ts +++ b/src/hooks/app/use-provider-detection.test.ts @@ -1,13 +1,19 @@ -import { act, renderHook } from "@testing-library/react" +import { act, renderHook, waitFor } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import type { PluginState } from "@/hooks/app/types" import type { PluginOutput } from "@/lib/plugin-types" import type { PluginSettings } from "@/lib/settings" import { useAppPluginStore } from "@/stores/app-plugin-store" -const { savePluginSettingsMock } = vi.hoisted(() => ({ savePluginSettingsMock: vi.fn() })) +const { savePluginSettingsMock, saveDetectionPendingMock } = vi.hoisted(() => ({ + savePluginSettingsMock: vi.fn(), + saveDetectionPendingMock: vi.fn(), +})) -vi.mock("@/lib/settings", () => ({ savePluginSettings: savePluginSettingsMock })) +vi.mock("@/lib/settings", () => ({ + savePluginSettings: savePluginSettingsMock, + saveProviderDetectionPending: saveDetectionPendingMock, +})) import { useProviderDetection } from "@/hooks/app/use-provider-detection" @@ -50,6 +56,8 @@ describe("useProviderDetection", () => { useAppPluginStore.getState().resetState() savePluginSettingsMock.mockReset() savePluginSettingsMock.mockResolvedValue(undefined) + saveDetectionPendingMock.mockReset() + saveDetectionPendingMock.mockResolvedValue(undefined) }) it("turns on what a probe found, and saves once every provider is decided", async () => { @@ -72,6 +80,9 @@ describe("useProviderDetection", () => { hook.rerender({ pluginStates: { ...states, zai: done({ error: "No ZAI_API_KEY found. Set up environment variable first." }) } }) expect(savePluginSettingsMock).toHaveBeenCalledTimes(1) expect(savePluginSettingsMock).toHaveBeenCalledWith({ order: IDS, disabled: ["codex", "zai"] }) + // Cleared only after the settings are on disk. + await waitFor(() => expect(saveDetectionPendingMock).toHaveBeenCalledWith(false)) + expect(saveDetectionPendingMock.mock.invocationCallOrder[0]).toBeGreaterThan(savePluginSettingsMock.mock.invocationCallOrder[0]) }) it("saves the all-off settings when nothing is found", async () => { @@ -128,5 +139,6 @@ describe("useProviderDetection", () => { hook.rerender({ pluginStates: { claude: done({ error: "Failed to start probe" }) } }) expect(setPluginSettings).not.toHaveBeenCalled() expect(savePluginSettingsMock).not.toHaveBeenCalled() + expect(saveDetectionPendingMock).not.toHaveBeenCalled() }) }) diff --git a/src/hooks/app/use-provider-detection.ts b/src/hooks/app/use-provider-detection.ts index b8e92440..78ae3093 100644 --- a/src/hooks/app/use-provider-detection.ts +++ b/src/hooks/app/use-provider-detection.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from "react" import type { PluginState } from "@/hooks/app/types" import { isNotSetUpError } from "@/lib/plugin-errors" -import { savePluginSettings, type PluginSettings } from "@/lib/settings" +import { savePluginSettings, saveProviderDetectionPending, type PluginSettings } from "@/lib/settings" import { useAppPluginStore } from "@/stores/app-plugin-store" type UseProviderDetectionArgs = { @@ -21,8 +21,9 @@ function isFound(id: string, state: PluginState) { /** * A new install starts with every provider off and probes them all once: each plugin's own login - * lookup is the detector, and what it finds is turned on. The settings are saved once every - * provider is decided, so a start cut short (quit, crash, probes that can't start) detects again. + * lookup is the detector, and what it finds is turned on. Once every provider is decided the + * settings are saved and the bootstrap's `providerDetectionPending` flag cleared; a start cut short + * (quit, crash, probes that can't start) leaves it set and detects again. */ export function useProviderDetection({ pluginSettings, @@ -65,7 +66,9 @@ export function useProviderDetection({ const next = found.size > 0 ? { ...current, disabled: current.disabled.filter((id) => !found.has(id)) } : current if (next !== current) setPluginSettings(next) if (pending.current.size === 0) { - void savePluginSettings(next).catch((error) => console.error("Failed to save detected providers:", error)) + void savePluginSettings(next) + .then(() => saveProviderDetectionPending(false)) + .catch((error) => console.error("Failed to save detected providers:", error)) } }, [pluginSettings, pluginStates, setPluginSettings]) diff --git a/src/hooks/app/use-settings-bootstrap.test.ts b/src/hooks/app/use-settings-bootstrap.test.ts index 470c133a..ecf79bb1 100644 --- a/src/hooks/app/use-settings-bootstrap.test.ts +++ b/src/hooks/app/use-settings-bootstrap.test.ts @@ -66,6 +66,8 @@ vi.mock("@tauri-apps/plugin-autostart", () => ({ isEnabled: isAutostartEnabledMock, })) +const detectionMocks = vi.hoisted(() => ({ load: vi.fn(), save: vi.fn() })) + vi.mock("@/lib/settings", () => ({ arePluginSettingsEqual: arePluginSettingsEqualMock, DEFAULT_AUTO_UPDATE_INTERVAL: 15, @@ -102,6 +104,8 @@ vi.mock("@/lib/settings", () => ({ migrateWindsurfToDevin: migrateWindsurfToDevinMock, normalizePluginSettings: normalizePluginSettingsMock, savePluginSettings: savePluginSettingsMock, + loadProviderDetectionPending: detectionMocks.load, + saveProviderDetectionPending: detectionMocks.save, })) import { useSettingsBootstrap } from "@/hooks/app/use-settings-bootstrap" @@ -326,7 +330,18 @@ describe("useSettingsBootstrap", () => { await waitFor(() => expect(args.detectProviders).toHaveBeenCalledWith(["claude", "codex"])) expect(args.setPluginSettings).toHaveBeenCalledWith(normalized) expect(args.startBatch).not.toHaveBeenCalled() - // Saved by detection once it is done, so a start cut short detects again. - expect(savePluginSettingsMock).not.toHaveBeenCalled() + // The flag first: a settings save alone would make the next start look like an update. + expect(detectionMocks.save).toHaveBeenCalledWith(true) + expect(detectionMocks.save.mock.invocationCallOrder[0]).toBeLessThan(savePluginSettingsMock.mock.invocationCallOrder[0]) + }) + + it("detects again when an earlier detection was cut short", async () => { + const args = createArgs() + detectionMocks.load.mockResolvedValueOnce(true) + + renderHook(() => useSettingsBootstrap(args)) + + await waitFor(() => expect(args.detectProviders).toHaveBeenCalledWith(["codex"])) + expect(args.startBatch).not.toHaveBeenCalled() }) }) diff --git a/src/hooks/app/use-settings-bootstrap.ts b/src/hooks/app/use-settings-bootstrap.ts index e2ebae0c..4c7667b6 100644 --- a/src/hooks/app/use-settings-bootstrap.ts +++ b/src/hooks/app/use-settings-bootstrap.ts @@ -39,6 +39,8 @@ import { migrateLegacyTraySettings, migrateWindsurfToDevin, loadPluginSettings, + loadProviderDetectionPending, + saveProviderDetectionPending, loadResetTimerDisplayMode, loadStartOnLogin, loadThemeMode, @@ -207,9 +209,11 @@ export function useSettingsBootstrap({ const storedSettings = await loadPluginSettings() const migratedSettings = migrateWindsurfToDevin(storedSettings) const normalized = normalizePluginSettings(migratedSettings, availablePlugins) - // A new install saves them once detection has decided every provider (use-provider-detection.ts). - const newInstall = storedSettings.order.length === 0 - if (!newInstall && !arePluginSettingsEqual(storedSettings, normalized)) { + // A new install, or one whose detection was cut short (use-provider-detection.ts). The flag is saved + // before the settings, so no later save (a WSL card, a toggle) can hide an unfinished run. + const detect = storedSettings.order.length === 0 || (await loadProviderDetectionPending()) + if (detect) await saveProviderDetectionPending(true) + if (!arePluginSettingsEqual(storedSettings, normalized)) { await savePluginSettings(normalized) } @@ -305,7 +309,7 @@ export function useSettingsBootstrap({ setTrayMetric(storedTrayMetric) setTrayPercentColor(storedTrayPercentColor) - if (newInstall) { + if (detect) { await detectProviders(normalized.order) return } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 6e671bc0..a8884051 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -71,6 +71,7 @@ const GLOBAL_SHORTCUT_KEY = "globalShortcut"; const START_ON_LOGIN_KEY = "startOnLogin"; const LANGUAGE_KEY = "language"; const UPDATE_RELAUNCH_KEY = "updateRelaunchVersion"; +const PROVIDER_DETECTION_KEY = "providerDetectionPending"; const UPDATE_NOTIFIED_KEY = "updateNotifiedVersion"; const STAR_PROMPT_KEY = "starPrompt"; @@ -547,6 +548,17 @@ export async function saveUpdateRelaunchVersion(version: string | null): Promise await store.save(); } +/** Set while a new install's provider detection runs, so a start cut short detects again. */ +export async function loadProviderDetectionPending(): Promise { + return (await store.get(PROVIDER_DETECTION_KEY)) === true; +} + +export async function saveProviderDetectionPending(pending: boolean): Promise { + if (pending) await store.set(PROVIDER_DETECTION_KEY, true); + else await store.delete(PROVIDER_DETECTION_KEY); + await store.save(); +} + /** The last version whose "update ready" notification was shown: each version notifies once. */ export async function loadUpdateNotifiedVersion(): Promise { const stored = await store.get(UPDATE_NOTIFIED_KEY); From fb8b66470ded519e0a0eedf16ec604c1d892db4c Mon Sep 17 00:00:00 2001 From: poweruserz <55040543+poweruserz@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:18:58 +0300 Subject: [PATCH 3/8] Keep a refused Antigravity login visible to detection Antigravity said "Start Antigravity or run agy" for a saved login that was refused or couldn't be checked, which a new install reads as nothing set up. That message now means no login was found; a refused login says the session expired, and one that couldn't be checked says the quota data is unavailable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHc1HaoK4c5kUmgtCmKuwT --- docs/providers/antigravity.md | 2 +- docs/providers/devin.md | 2 +- plugins/antigravity/plugin.js | 6 +++++- plugins/antigravity/plugin.test.js | 12 ++++++------ src/lib/plugin-errors.test.ts | 2 ++ 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md index afcfb86d..a2ac9ece 100644 --- a/docs/providers/antigravity.md +++ b/docs/providers/antigravity.md @@ -280,4 +280,4 @@ The Cloud Code model set is a superset of the LS model set. The LS returns only 4. Try unexpired SQLite/cached access tokens: the plan and project from `loadCodeAssist`, then `retrieveUserQuotaSummary` (then `fetchAvailableModels` for builds without it). 5. Refresh SQLite refresh tokens only after auth failure or when no access token exists. 6. Read `agy` keychain token from service `gemini`, account `antigravity`, then call `loadCodeAssist` and `retrieveUserQuota`. -7. If all strategies fail: error "Start Antigravity or run `agy` and try again." +7. If all strategies fail: with no login found, error "Start Antigravity or run `agy` and try again."; with a login that was refused (or only a refresh token that no longer works), "Session expired. Run `agy` to log in again."; with a login that couldn't be checked (5xx, timeouts, no models), "Antigravity quota data unavailable. Try again later." Only the first counts as "nothing set up" for a new install's detection. diff --git a/docs/providers/devin.md b/docs/providers/devin.md index c4d78f63..4cc4bf83 100644 --- a/docs/providers/devin.md +++ b/docs/providers/devin.md @@ -120,7 +120,7 @@ Devin currently returns no `weeklyQuotaRemainingPercent` field in the observed p 2. Read Devin app SQLite auth if CLI credentials are missing or different. 3. POST `GetUserStatus` with `ideName: "devin"`. 4. Build weekly quota and extra balance lines. Show daily quota only if Devin does not mark it hidden. -5. If auth fails, show: `Run devin auth login or sign in to Devin and try again.` +5. With no login found, show: `Run devin auth login or sign in to Devin and try again.` When every login found is rejected (401/403), show: ``Session expired. Sign in via Devin app or run `devin auth login`.`` (a new install's detection reads only the first as "nothing set up"). ## WSL diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index 8a41215b..dbc9ee2a 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -1145,7 +1145,11 @@ if (ccResult && !ccResult._authFailed) return ccResult - throw LOGIN_MESSAGE + // LOGIN_MESSAGE says no login was found; a saved one that was refused or couldn't be checked is the + // user's, and a new install shows its card (use-provider-detection.ts). + if (tokens.length === 0 && refreshTokens.length === 0 && !agy) throw LOGIN_MESSAGE + if (sawAuthFailure || ccResult || tokens.length === 0) throw "Session expired. Run `agy` to log in again." + throw "Antigravity quota data unavailable. Try again later." } globalThis.__openusage_plugin = { id: "antigravity", probe: probe } diff --git a/plugins/antigravity/plugin.test.js b/plugins/antigravity/plugin.test.js index 025b18fe..62bcb786 100644 --- a/plugins/antigravity/plugin.test.js +++ b/plugins/antigravity/plugin.test.js @@ -899,7 +899,7 @@ describe("antigravity plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") }) it("Cloud Code tries multiple base URLs", async () => { @@ -1147,7 +1147,7 @@ describe("antigravity plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") }) it("tries DB token first, then cached on auth failure", async () => { @@ -1659,7 +1659,7 @@ describe("antigravity plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") }) it("handles refresh response missing access_token", async () => { @@ -1682,7 +1682,7 @@ describe("antigravity plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") expect(oauthCalls).toBe(1) }) @@ -1885,7 +1885,7 @@ describe("antigravity plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") expect(refreshCalls).toBe(0) }) @@ -1911,7 +1911,7 @@ describe("antigravity plugin", () => { }) const plugin = await loadPlugin() - expect(() => plugin.probe(ctx)).toThrow(LOGIN_MESSAGE) + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") expect(refreshCalls).toBe(0) }) diff --git a/src/lib/plugin-errors.test.ts b/src/lib/plugin-errors.test.ts index 030d7026..38a010a6 100644 --- a/src/lib/plugin-errors.test.ts +++ b/src/lib/plugin-errors.test.ts @@ -235,6 +235,8 @@ describe("isNotSetUpError", () => { expect(isNotSetUpError("Amp not installed. Install Amp Code to get started.")).toBe(true) // The user has the provider: an old login, a bad key, a busy or unreachable server. expect(isNotSetUpError("Token expired. Run `claude` to log in again.")).toBe(false) + expect(isNotSetUpError("Session expired. Run `agy` to log in again.")).toBe(false) + expect(isNotSetUpError("Antigravity quota data unavailable. Try again later.")).toBe(false) expect(isNotSetUpError("API key invalid. Check your Z.ai API key.")).toBe(false) expect(isNotSetUpError("Rate limited by Claude, retry in ~5m")).toBe(false) expect(isNotSetUpError("Request failed. Check your connection.")).toBe(false) From 824454fe25beac86a811a60649420eb7d5dc35f9 Mon Sep 17 00:00:00 2001 From: poweruserz <55040543+poweruserz@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:20:53 +0300 Subject: [PATCH 4/8] Keep network failures from asking for a new Antigravity sign-in "Session expired" makes the app ask the user to sign in again, so Antigravity now says it only when Cloud Code refused the login, or for an expired login with nothing to refresh it. A network error or 5xx, including one right after a refresh that worked, says "unavailable". An expired login still counts as found for a new install's detection. Restoring a settings backup's card list also ends a pending first-run detection, which would otherwise turn the backup's off cards on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHc1HaoK4c5kUmgtCmKuwT --- docs/providers/antigravity.md | 2 +- plugins/antigravity/plugin.js | 15 ++++++--- plugins/antigravity/plugin.test.js | 49 ++++++++++++++++++++++++++++++ src/lib/settings-backup.test.ts | 10 +++++- src/lib/settings-backup.ts | 4 ++- src/lib/settings.ts | 2 +- 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md index a2ac9ece..f22093e4 100644 --- a/docs/providers/antigravity.md +++ b/docs/providers/antigravity.md @@ -280,4 +280,4 @@ The Cloud Code model set is a superset of the LS model set. The LS returns only 4. Try unexpired SQLite/cached access tokens: the plan and project from `loadCodeAssist`, then `retrieveUserQuotaSummary` (then `fetchAvailableModels` for builds without it). 5. Refresh SQLite refresh tokens only after auth failure or when no access token exists. 6. Read `agy` keychain token from service `gemini`, account `antigravity`, then call `loadCodeAssist` and `retrieveUserQuota`. -7. If all strategies fail: with no login found, error "Start Antigravity or run `agy` and try again."; with a login that was refused (or only a refresh token that no longer works), "Session expired. Run `agy` to log in again."; with a login that couldn't be checked (5xx, timeouts, no models), "Antigravity quota data unavailable. Try again later." Only the first counts as "nothing set up" for a new install's detection. +7. If all strategies fail: with no login found, error "Start Antigravity or run `agy` and try again."; with a login that Cloud Code refused (and no refresh fixed), or an expired one with nothing to refresh it, "Session expired. Run `agy` to log in again."; otherwise (5xx, timeouts, no models, a refresh that failed or worked but Cloud Code then didn't answer), "Antigravity quota data unavailable. Try again later." Only the first counts as "nothing set up" for a new install's detection. diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index dbc9ee2a..30183126 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -1121,10 +1121,12 @@ // probeCloudCodeToken returns null for transient failures (5xx/timeouts); without this // guard a Cloud Code incident would trigger a Google OAuth refresh every probe cycle // instead of ~once per token lifetime — risking refresh-token throttling or rotation. + var refreshWorked = false if (!ccResult && (sawAuthFailure || tokens.length === 0)) { for (var k = 0; k < refreshTokens.length; k++) { var refreshed = refreshAccessToken(ctx, refreshTokens[k], DB_TOKEN_CACHE) if (!refreshed) continue + refreshWorked = true var refreshedResult = probeCloudCodeToken(ctx, refreshed) if (refreshedResult && !refreshedResult._authFailed) { ccResult = refreshedResult @@ -1145,10 +1147,15 @@ if (ccResult && !ccResult._authFailed) return ccResult - // LOGIN_MESSAGE says no login was found; a saved one that was refused or couldn't be checked is the - // user's, and a new install shows its card (use-provider-detection.ts). - if (tokens.length === 0 && refreshTokens.length === 0 && !agy) throw LOGIN_MESSAGE - if (sawAuthFailure || ccResult || tokens.length === 0) throw "Session expired. Run `agy` to log in again." + // LOGIN_MESSAGE says no login was found; a saved one, even expired, is the user's, and a new install + // shows its card (use-provider-detection.ts). + if (dbTokenCandidates.length === 0 && !agy) throw LOGIN_MESSAGE + // "Session expired" asks the user to sign in again (useLoginRecovery), so it needs a refusal or a login + // with nothing left to try; a network error or 5xx, even after a refresh, is "unavailable". + var refused = (ccResult && ccResult._authFailed) || (sawAuthFailure && !refreshWorked) + if (refused || (tokens.length === 0 && refreshTokens.length === 0 && !agy)) { + throw "Session expired. Run `agy` to log in again." + } throw "Antigravity quota data unavailable. Try again later." } diff --git a/plugins/antigravity/plugin.test.js b/plugins/antigravity/plugin.test.js index 62bcb786..eea6dc7d 100644 --- a/plugins/antigravity/plugin.test.js +++ b/plugins/antigravity/plugin.test.js @@ -1915,6 +1915,55 @@ describe("antigravity plugin", () => { expect(refreshCalls).toBe(0) }) + it("says unavailable, not expired, when Cloud Code fails after a refresh that worked", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.old", refreshToken: "1//refresh", expirySeconds: futureExpiry })) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.http.request.mockImplementation((opts) => { + const url = String(opts.url) + if (url.includes("oauth2.googleapis.com")) { + return { status: 200, bodyText: JSON.stringify({ access_token: "ya29.new", expires_in: 3600 }) } + } + if (url.includes("fetchAvailableModels") && opts.headers.Authorization === "Bearer ya29.old") { + return { status: 401, bodyText: "" } + } + return { status: 503, bodyText: "" } + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") + }) + + it("says unavailable, not expired, when the agy login's Cloud Code calls fail", async () => { + const ctx = makeCtx() + setupSqliteMock(ctx, null) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.keychain.readGenericPassword.mockImplementation((service, account) => { + if (service === "gemini" && account === "antigravity") { + return "go-keyring-base64:" + ctx.base64.encode(JSON.stringify({ + tokens: { access_token: "agy-keychain-token" }, + })) + } + throw new Error("unexpected keychain lookup") + }) + ctx.host.http.request.mockReturnValue({ status: 503, bodyText: "" }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") + }) + + it("says the session expired for an expired SQLite token with nothing to refresh it", async () => { + const ctx = makeCtx() + const pastExpiry = Math.floor(Date.now() / 1000) - 3600 + setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.expired", expirySeconds: pastExpiry })) + ctx.host.ls.discover.mockReturnValue(null) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") + expect(ctx.host.http.request).not.toHaveBeenCalled() + }) + // --- Regression tests: RetrieveUserQuotaSummary (merged Gemini pool + weekly limits) --- const SESSION_MS = 5 * 60 * 60 * 1000 diff --git a/src/lib/settings-backup.test.ts b/src/lib/settings-backup.test.ts index 3a36a9fa..79f829f0 100644 --- a/src/lib/settings-backup.test.ts +++ b/src/lib/settings-backup.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { buildSettingsBackup, restoreSettingsBackup } from "@/lib/settings-backup" -import { loadAlertSettings, loadThemeMode } from "@/lib/settings" +import { loadAlertSettings, loadProviderDetectionPending, loadThemeMode } from "@/lib/settings" const storeState = new Map() @@ -42,6 +42,14 @@ describe("settings backup", () => { expect(storeState.get("themeMode")).toBe("light") }) + it("ends a pending first-run detection when it restores the card list", async () => { + storeState.set("plugins", { order: ["claude", "codex"], disabled: ["codex"] }) + const text = await buildSettingsBackup() + storeState.set("providerDetectionPending", true) + expect(await restoreSettingsBackup(text)).toEqual({ ok: true }) + expect(await loadProviderDetectionPending()).toBe(false) + }) + it("puts a preference the backup left at its default back to the default", async () => { storeState.set("plugins", { order: ["claude"], disabled: [] }) const text = await buildSettingsBackup() diff --git a/src/lib/settings-backup.ts b/src/lib/settings-backup.ts index a804bd5f..8becd752 100644 --- a/src/lib/settings-backup.ts +++ b/src/lib/settings-backup.ts @@ -1,5 +1,5 @@ import { insertAccount, isAccountId, isWslId } from "@/lib/accounts" -import { backupStore, DEFAULT_ALERT_SETTINGS, DEFAULT_TASKBAR_STRIP_STYLE, normalizeAlertSettings } from "@/lib/settings" +import { backupStore, DEFAULT_ALERT_SETTINGS, DEFAULT_TASKBAR_STRIP_STYLE, normalizeAlertSettings, PROVIDER_DETECTION_KEY } from "@/lib/settings" /** * The preferences a settings backup carries (store keys). Left out: accounts and logins (never in @@ -159,6 +159,8 @@ export async function restoreSettingsBackup(text: string): Promise Date: Thu, 12 Nov 2026 21:30:21 +0300 Subject: [PATCH 5/8] Say expired for an agy login with nothing to refresh it Its keychain entry held only an expired access token, so the probe made no request and showed "unavailable" on every round instead of asking the user to sign in again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHc1HaoK4c5kUmgtCmKuwT --- plugins/antigravity/plugin.js | 2 ++ plugins/antigravity/plugin.test.js | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index 30183126..fe7acfd2 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -1064,6 +1064,8 @@ } var cached = loadCachedToken(ctx, agy.refreshToken ? [agy.refreshToken] : [], AGY_TOKEN_CACHE) if (cached && tokens.indexOf(cached) === -1) tokens.push(cached) + // An expired login with nothing to refresh it reads as refused: probe then says "Session expired". + if (tokens.length === 0 && !agy.refreshToken) return { _authFailed: true } var sawAuthFailure = false for (var i = 0; i < tokens.length; i++) { diff --git a/plugins/antigravity/plugin.test.js b/plugins/antigravity/plugin.test.js index eea6dc7d..6ea8039c 100644 --- a/plugins/antigravity/plugin.test.js +++ b/plugins/antigravity/plugin.test.js @@ -1953,6 +1953,19 @@ describe("antigravity plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") }) + it("says the session expired for an expired agy login with nothing to refresh it", async () => { + const ctx = makeCtx() + setupSqliteMock(ctx, null) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({ + token: { access_token: "ya29.expired", expiry: new Date(Date.now() - 3600000).toISOString() }, + })) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") + expect(ctx.host.http.request).not.toHaveBeenCalled() + }) + it("says the session expired for an expired SQLite token with nothing to refresh it", async () => { const ctx = makeCtx() const pastExpiry = Math.floor(Date.now() / 1000) - 3600 From c8de4ff292e6f5d26215cc2ca5c621d19811351c Mon Sep 17 00:00:00 2001 From: poweruserz <55040543+poweruserz@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:39:39 +0300 Subject: [PATCH 6/8] Say expired when Google refuses an Antigravity refresh token An expired access token is normal for an agy login, so a revoked refresh token often comes with no earlier 401. Google's 400/401/403 on the refresh now counts as a refusal; 429, 5xx and network errors still say "unavailable". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHc1HaoK4c5kUmgtCmKuwT --- docs/providers/antigravity.md | 2 +- plugins/antigravity/plugin.js | 10 ++++++-- plugins/antigravity/plugin.test.js | 39 ++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md index f22093e4..1c2db9a9 100644 --- a/docs/providers/antigravity.md +++ b/docs/providers/antigravity.md @@ -280,4 +280,4 @@ The Cloud Code model set is a superset of the LS model set. The LS returns only 4. Try unexpired SQLite/cached access tokens: the plan and project from `loadCodeAssist`, then `retrieveUserQuotaSummary` (then `fetchAvailableModels` for builds without it). 5. Refresh SQLite refresh tokens only after auth failure or when no access token exists. 6. Read `agy` keychain token from service `gemini`, account `antigravity`, then call `loadCodeAssist` and `retrieveUserQuota`. -7. If all strategies fail: with no login found, error "Start Antigravity or run `agy` and try again."; with a login that Cloud Code refused (and no refresh fixed), or an expired one with nothing to refresh it, "Session expired. Run `agy` to log in again."; otherwise (5xx, timeouts, no models, a refresh that failed or worked but Cloud Code then didn't answer), "Antigravity quota data unavailable. Try again later." Only the first counts as "nothing set up" for a new install's detection. +7. If all strategies fail: with no login found, error "Start Antigravity or run `agy` and try again."; with a login that Cloud Code or Google's token refresh refused (400/401/403, and no refresh worked), or an expired one with nothing to refresh it, "Session expired. Run `agy` to log in again."; otherwise (5xx, timeouts, no models, a refresh that failed or worked but Cloud Code then didn't answer), "Antigravity quota data unavailable. Try again later." Only the first counts as "nothing set up" for a new install's detection. diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index fe7acfd2..b09b3914 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -147,6 +147,8 @@ // --- Google OAuth token refresh --- + // The new access token; false when Google refused the refresh token (the login needs a new sign-in); + // null when it couldn't be checked (network, 5xx, 429, a malformed answer). function refreshAccessToken(ctx, refreshTokenValue, cacheFile) { if (!refreshTokenValue) { ctx.host.log.warn("refresh skipped: no refresh token") @@ -167,7 +169,8 @@ }) if (resp.status < 200 || resp.status >= 300) { ctx.host.log.warn("Google OAuth refresh returned status: " + resp.status) - return null + // 400 is invalid_grant: the refresh token was revoked or expired. + return resp.status === 400 || ctx.util.isAuthStatus(resp.status) ? false : null } var body = ctx.util.tryParseJson(resp.bodyText) if (!body || !body.access_token) { @@ -1075,6 +1078,7 @@ } if ((sawAuthFailure || tokens.length === 0) && agy.refreshToken) { var refreshed = refreshAccessToken(ctx, agy.refreshToken, AGY_TOKEN_CACHE) + if (refreshed === false) return { _authFailed: true } if (refreshed) return probeAgyCloudCode(ctx, refreshed) } return sawAuthFailure ? { _authFailed: true } : null @@ -1124,9 +1128,11 @@ // guard a Cloud Code incident would trigger a Google OAuth refresh every probe cycle // instead of ~once per token lifetime — risking refresh-token throttling or rotation. var refreshWorked = false + var refreshRefused = false if (!ccResult && (sawAuthFailure || tokens.length === 0)) { for (var k = 0; k < refreshTokens.length; k++) { var refreshed = refreshAccessToken(ctx, refreshTokens[k], DB_TOKEN_CACHE) + if (refreshed === false) refreshRefused = true if (!refreshed) continue refreshWorked = true var refreshedResult = probeCloudCodeToken(ctx, refreshed) @@ -1154,7 +1160,7 @@ if (dbTokenCandidates.length === 0 && !agy) throw LOGIN_MESSAGE // "Session expired" asks the user to sign in again (useLoginRecovery), so it needs a refusal or a login // with nothing left to try; a network error or 5xx, even after a refresh, is "unavailable". - var refused = (ccResult && ccResult._authFailed) || (sawAuthFailure && !refreshWorked) + var refused = (ccResult && ccResult._authFailed) || ((sawAuthFailure || refreshRefused) && !refreshWorked) if (refused || (tokens.length === 0 && refreshTokens.length === 0 && !agy)) { throw "Session expired. Run `agy` to log in again." } diff --git a/plugins/antigravity/plugin.test.js b/plugins/antigravity/plugin.test.js index 6ea8039c..279faee3 100644 --- a/plugins/antigravity/plugin.test.js +++ b/plugins/antigravity/plugin.test.js @@ -1966,6 +1966,45 @@ describe("antigravity plugin", () => { expect(ctx.host.http.request).not.toHaveBeenCalled() }) + it("says the session expired when Google refuses an expired agy login's refresh token", async () => { + const ctx = makeCtx() + setupSqliteMock(ctx, null) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({ + token: { + access_token: "ya29.expired", + refresh_token: "1//revoked", + expiry: new Date(Date.now() - 3600000).toISOString(), + }, + })) + ctx.host.http.request.mockReturnValue({ status: 400, bodyText: '{"error":"invalid_grant"}' }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") + }) + + it("says the session expired when Google refuses an expired SQLite token's refresh token", async () => { + const ctx = makeCtx() + const pastExpiry = Math.floor(Date.now() / 1000) - 3600 + setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.expired", refreshToken: "1//revoked", expirySeconds: pastExpiry })) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.http.request.mockReturnValue({ status: 400, bodyText: '{"error":"invalid_grant"}' }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") + }) + + it("says unavailable when the refresh of an expired SQLite token can't reach Google", async () => { + const ctx = makeCtx() + const pastExpiry = Math.floor(Date.now() / 1000) - 3600 + setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.expired", refreshToken: "1//refresh", expirySeconds: pastExpiry })) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.http.request.mockReturnValue({ status: 503, bodyText: "" }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") + }) + it("says the session expired for an expired SQLite token with nothing to refresh it", async () => { const ctx = makeCtx() const pastExpiry = Math.floor(Date.now() / 1000) - 3600 From a4848bc238763e4a9aa6fa9548ec533128b77c8b Mon Sep 17 00:00:00 2001 From: poweruserz <55040543+poweruserz@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:48:07 +0300 Subject: [PATCH 7/8] Count a refused Antigravity refresh only when no other is unknown With two databases, a stale one's revoked refresh token and a network failure on the current one's said "Session expired", though the current login may still work. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHc1HaoK4c5kUmgtCmKuwT --- plugins/antigravity/plugin.js | 6 +++++- plugins/antigravity/plugin.test.js | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index b09b3914..92e9f40b 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -1129,10 +1129,12 @@ // instead of ~once per token lifetime — risking refresh-token throttling or rotation. var refreshWorked = false var refreshRefused = false + var refreshUnchecked = false if (!ccResult && (sawAuthFailure || tokens.length === 0)) { for (var k = 0; k < refreshTokens.length; k++) { var refreshed = refreshAccessToken(ctx, refreshTokens[k], DB_TOKEN_CACHE) if (refreshed === false) refreshRefused = true + else if (!refreshed) refreshUnchecked = true if (!refreshed) continue refreshWorked = true var refreshedResult = probeCloudCodeToken(ctx, refreshed) @@ -1160,7 +1162,9 @@ if (dbTokenCandidates.length === 0 && !agy) throw LOGIN_MESSAGE // "Session expired" asks the user to sign in again (useLoginRecovery), so it needs a refusal or a login // with nothing left to try; a network error or 5xx, even after a refresh, is "unavailable". - var refused = (ccResult && ccResult._authFailed) || ((sawAuthFailure || refreshRefused) && !refreshWorked) + // A refused refresh counts only when no other login's refresh is still unknown (a stale second database). + var refused = (ccResult && ccResult._authFailed) || + (!refreshWorked && (sawAuthFailure || (refreshRefused && !refreshUnchecked))) if (refused || (tokens.length === 0 && refreshTokens.length === 0 && !agy)) { throw "Session expired. Run `agy` to log in again." } diff --git a/plugins/antigravity/plugin.test.js b/plugins/antigravity/plugin.test.js index 279faee3..26f85038 100644 --- a/plugins/antigravity/plugin.test.js +++ b/plugins/antigravity/plugin.test.js @@ -1994,6 +1994,25 @@ describe("antigravity plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") }) + it("says unavailable when one database's refresh is refused and the other's can't reach Google", async () => { + const ctx = makeCtx() + const pastExpiry = Math.floor(Date.now() / 1000) - 3600 + setupSqliteByPath(ctx, { + [STATE_DB_V2]: makeOAuthSentinelB64(ctx, { accessToken: "ya29.v2", refreshToken: "1//v2-current", expirySeconds: pastExpiry }), + [STATE_DB_V1]: makeOAuthSentinelB64(ctx, { accessToken: "ya29.v1", refreshToken: "1//v1-revoked", expirySeconds: pastExpiry }), + }) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.bodyText).includes(encodeURIComponent("1//v1-revoked"))) { + return { status: 400, bodyText: '{"error":"invalid_grant"}' } + } + return { status: 503, bodyText: "" } + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") + }) + it("says unavailable when the refresh of an expired SQLite token can't reach Google", async () => { const ctx = makeCtx() const pastExpiry = Math.floor(Date.now() / 1000) - 3600 From 821d050a7a4635089865ad0cedeb3a25e69db2d0 Mon Sep 17 00:00:00 2001 From: poweruserz <55040543+poweruserz@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:01:36 +0300 Subject: [PATCH 8/8] Say expired only when every Antigravity login found was refused A refused SQLite login next to an agy login that couldn't be checked (or the other way round) said "Session expired" during a Cloud Code outage. The probe now decides per login: expired only when each one found was refused, otherwise "unavailable". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KHc1HaoK4c5kUmgtCmKuwT --- docs/providers/antigravity.md | 2 +- plugins/antigravity/plugin.js | 18 +++++++++++------- plugins/antigravity/plugin.test.js | 29 +++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md index 1c2db9a9..f9335f63 100644 --- a/docs/providers/antigravity.md +++ b/docs/providers/antigravity.md @@ -280,4 +280,4 @@ The Cloud Code model set is a superset of the LS model set. The LS returns only 4. Try unexpired SQLite/cached access tokens: the plan and project from `loadCodeAssist`, then `retrieveUserQuotaSummary` (then `fetchAvailableModels` for builds without it). 5. Refresh SQLite refresh tokens only after auth failure or when no access token exists. 6. Read `agy` keychain token from service `gemini`, account `antigravity`, then call `loadCodeAssist` and `retrieveUserQuota`. -7. If all strategies fail: with no login found, error "Start Antigravity or run `agy` and try again."; with a login that Cloud Code or Google's token refresh refused (400/401/403, and no refresh worked), or an expired one with nothing to refresh it, "Session expired. Run `agy` to log in again."; otherwise (5xx, timeouts, no models, a refresh that failed or worked but Cloud Code then didn't answer), "Antigravity quota data unavailable. Try again later." Only the first counts as "nothing set up" for a new install's detection. +7. If all strategies fail: with no login found, error "Start Antigravity or run `agy` and try again."; when every login found (SQLite and agy) was refused: Cloud Code or Google's token refresh refused (400/401/403, and no refresh worked), or an expired one with nothing to refresh it, "Session expired. Run `agy` to log in again."; otherwise (5xx, timeouts, no models, a refresh that failed or worked but Cloud Code then didn't answer), "Antigravity quota data unavailable. Try again later." Only the first counts as "nothing set up" for a new install's detection. diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index 92e9f40b..1ba7ad80 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -1146,12 +1146,19 @@ } } + // Refused: Cloud Code turned a refreshed token down, or a token got a 401 or Google refused the refresh + // and no refresh worked, or an expired login has nothing to refresh it. A refused refresh next to one + // that couldn't be checked (a stale second database) isn't a refusal. + var sqliteRefused = (ccResult && ccResult._authFailed) || + (!refreshWorked && (sawAuthFailure || (refreshRefused && !refreshUnchecked) || (tokens.length === 0 && refreshTokens.length === 0))) + + var agyRefused = false if (!ccResult || ccResult._authFailed) { var agy = loadAgyKeychainToken(ctx) if (agy) { var agyResult = probeAgyKeychain(ctx, agy, nowSec) if (agyResult && !agyResult._authFailed) return agyResult - if (agyResult && agyResult._authFailed) ccResult = agyResult + agyRefused = Boolean(agyResult) } } @@ -1160,12 +1167,9 @@ // LOGIN_MESSAGE says no login was found; a saved one, even expired, is the user's, and a new install // shows its card (use-provider-detection.ts). if (dbTokenCandidates.length === 0 && !agy) throw LOGIN_MESSAGE - // "Session expired" asks the user to sign in again (useLoginRecovery), so it needs a refusal or a login - // with nothing left to try; a network error or 5xx, even after a refresh, is "unavailable". - // A refused refresh counts only when no other login's refresh is still unknown (a stale second database). - var refused = (ccResult && ccResult._authFailed) || - (!refreshWorked && (sawAuthFailure || (refreshRefused && !refreshUnchecked))) - if (refused || (tokens.length === 0 && refreshTokens.length === 0 && !agy)) { + // "Session expired" asks the user to sign in again (useLoginRecovery), so every login found must be + // refused; one that couldn't be checked (network, 5xx, even after a refresh) makes it "unavailable". + if ((dbTokenCandidates.length === 0 || sqliteRefused) && (!agy || agyRefused)) { throw "Session expired. Run `agy` to log in again." } throw "Antigravity quota data unavailable. Try again later." diff --git a/plugins/antigravity/plugin.test.js b/plugins/antigravity/plugin.test.js index 26f85038..56fe5b36 100644 --- a/plugins/antigravity/plugin.test.js +++ b/plugins/antigravity/plugin.test.js @@ -1994,6 +1994,35 @@ describe("antigravity plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Session expired. Run `agy` to log in again.") }) + it("says unavailable when the SQLite login is refused but the agy login can't be checked", async () => { + const ctx = makeCtx() + const pastExpiry = Math.floor(Date.now() / 1000) - 3600 + setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.expired", refreshToken: "1//revoked", expirySeconds: pastExpiry })) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({ token: { access_token: "agy-keychain-token" } })) + ctx.host.http.request.mockImplementation((opts) => { + if (String(opts.url).includes("oauth2.googleapis.com")) return { status: 400, bodyText: '{"error":"invalid_grant"}' } + return { status: 503, bodyText: "" } + }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") + }) + + it("says unavailable when the agy login is refused but the SQLite login can't be checked", async () => { + const ctx = makeCtx() + const futureExpiry = Math.floor(Date.now() / 1000) + 3600 + setupSqliteMock(ctx, makeOAuthSentinelB64(ctx, { accessToken: "ya29.valid", refreshToken: "1//refresh", expirySeconds: futureExpiry })) + ctx.host.ls.discover.mockReturnValue(null) + ctx.host.keychain.readGenericPassword.mockReturnValue(JSON.stringify({ + token: { access_token: "ya29.agy-expired", expiry: new Date(Date.now() - 3600000).toISOString() }, + })) + ctx.host.http.request.mockReturnValue({ status: 503, bodyText: "" }) + + const plugin = await loadPlugin() + expect(() => plugin.probe(ctx)).toThrow("Antigravity quota data unavailable. Try again later.") + }) + it("says unavailable when one database's refresh is refused and the other's can't reach Google", async () => { const ctx = makeCtx() const pastExpiry = Math.floor(Date.now() / 1000) - 3600