diff --git a/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx b/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx index 94e6bb688b..7c8f0eae80 100644 --- a/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx +++ b/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx @@ -6,7 +6,8 @@ import { type SidebarBootstrapResponse, } from "@bb/server-contract"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { request } from "@/lib/api"; +import { HttpError, request } from "@/lib/api"; +import { shouldRetryTransientReadQuery } from "./query-helpers"; import { MAX_CACHED_SIDEBAR_THREADS_PER_PROJECT, SIDEBAR_BOOTSTRAP_CACHE_KEY, @@ -68,6 +69,62 @@ afterEach(() => { }); describe("useSidebarNavigation", () => { + it.each([ + new TypeError("Failed to fetch"), + new HttpError({ status: 503, message: "Server starting" }), + ])("recovers the startup project and thread list after a temporary outage: %s", async (error) => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const harness = createQueryClientTestHarness({ queries: { retry: shouldRetryTransientReadQuery } }); + try { + vi.mocked(request).mockReset(); + vi.mocked(request).mockRejectedValueOnce(error).mockRejectedValueOnce(error).mockRejectedValueOnce(error).mockRejectedValueOnce(error).mockResolvedValue(BOOTSTRAP); + const { result } = renderHook(() => useSidebarNavigation(), { wrapper: harness.wrapper }); + await act(async () => { await vi.advanceTimersByTimeAsync(35_000); }); + expect(result.current.data).toEqual(BOOTSTRAP); + expect(result.current.isError).toBe(false); + expect(request).toHaveBeenCalledTimes(5); + } finally { + harness.queryClient.clear(); + vi.useRealTimers(); + } + }); + + it("recovers after a startup outage outlasts the initial retry budget", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const harness = createQueryClientTestHarness(); + try { + vi.mocked(request).mockReset(); + for (let index = 0; index < 11; index += 1) { + vi.mocked(request).mockRejectedValueOnce(new HttpError({ status: 503, message: "Starting" })); + } + vi.mocked(request).mockResolvedValue(BOOTSTRAP); + const { result } = renderHook(() => useSidebarNavigation(), { wrapper: harness.wrapper }); + await act(async () => { await vi.advanceTimersByTimeAsync(40_000); }); + expect(result.current.data).toEqual(BOOTSTRAP); + expect(request).toHaveBeenCalledTimes(12); + await act(async () => { await vi.advanceTimersByTimeAsync(20_000); }); + expect(request).toHaveBeenCalledTimes(12); + } finally { + harness.queryClient.clear(); + vi.useRealTimers(); + } + }); + + it.each([401, 403, 404, 500])("does not repeatedly retry a permanent HTTP %s error", async (status) => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const harness = createQueryClientTestHarness(); + try { + vi.mocked(request).mockReset().mockRejectedValue(new HttpError({ status, message: "Permanent error" })); + const { result } = renderHook(() => useSidebarNavigation(), { wrapper: harness.wrapper }); + await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); + expect(result.current.isError).toBe(true); + expect(request).toHaveBeenCalledTimes(1); + } finally { + harness.queryClient.clear(); + vi.useRealTimers(); + } + }); + it("replays the last bootstrap while the live one loads", async () => { sidebarBootstrapResponseSchema.parse(BOOTSTRAP); diff --git a/apps/app/src/hooks/queries/sidebar-navigation-query.ts b/apps/app/src/hooks/queries/sidebar-navigation-query.ts index 0a62120f2e..e871fcc8ee 100644 --- a/apps/app/src/hooks/queries/sidebar-navigation-query.ts +++ b/apps/app/src/hooks/queries/sidebar-navigation-query.ts @@ -4,14 +4,14 @@ import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain"; import type { SidebarBootstrapResponse } from "@bb/server-contract"; import { listSidebarNavigationThreads } from "@/hooks/cache-owners/query-cache"; import { apiClient } from "@/lib/api-server"; -import { request, requestOptions } from "@/lib/api"; +import { HttpError, request, requestOptions } from "@/lib/api"; import { useEnvironmentListRealtimeSubscription, useHostListRealtimeSubscription, useProjectListRealtimeSubscription, useThreadListRealtimeSubscription, } from "@/hooks/useRealtimeSubscription"; -import type { QueryOptions } from "./query-helpers"; +import { isTransientReadError, type QueryOptions } from "./query-helpers"; import { sidebarNavigationQueryKey } from "./query-keys"; import { REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY } from "./query-policies"; import { @@ -19,6 +19,21 @@ import { writeCachedSidebarBootstrap, } from "@/lib/sidebar-bootstrap-cache"; +function isRecoverableSidebarError(error: unknown): boolean { + return isTransientReadError(error) || + (error instanceof HttpError && [502, 503, 504].includes(error.status)); +} + +const SIDEBAR_BOOTSTRAP_RECOVERY_POLICY = { + retry: (failureCount: number, error: Error) => + failureCount < 10 && isRecoverableSidebarError(error), + retryDelay: (attempt: number) => Math.min(250 * 2 ** attempt, 5_000), + refetchInterval: (query: { state: { status: string; error: Error | null } }) => + query.state.status === "error" && isRecoverableSidebarError(query.state.error) + ? 5_000 + : false, +} as const; + function fetchSidebarNavigation( signal?: AbortSignal, ): Promise { @@ -43,6 +58,7 @@ export function useSidebarNavigation(options?: QueryOptions) { }, enabled, ...REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY, + ...SIDEBAR_BOOTSTRAP_RECOVERY_POLICY, placeholderData: () => readCachedSidebarBootstrap() ?? undefined, }); } @@ -54,6 +70,7 @@ export function useProjectDisplayName( queryKey: sidebarNavigationQueryKey(), queryFn: ({ signal }) => fetchSidebarNavigation(signal), ...REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY, + ...SIDEBAR_BOOTSTRAP_RECOVERY_POLICY, enabled: Boolean(projectId), }); if (!data || !projectId) { @@ -82,6 +99,7 @@ export function useSidebarNavigationThreadSelection( queryKey: sidebarNavigationQueryKey(), queryFn: ({ signal }) => fetchSidebarNavigation(signal), ...REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY, + ...SIDEBAR_BOOTSTRAP_RECOVERY_POLICY, enabled: false, select: selectFromNavigation, }); diff --git a/apps/server/test/public/public-system-usage-limits.test.ts b/apps/server/test/public/public-system-usage-limits.test.ts index de62d7d7f0..23fdfbbcb2 100644 --- a/apps/server/test/public/public-system-usage-limits.test.ts +++ b/apps/server/test/public/public-system-usage-limits.test.ts @@ -113,7 +113,9 @@ describe("GET /api/v1/system/usage-limits", () => { ).toBe(false); expect( responder.requests.some( - (request) => request.command.type === "provider.health", + (request) => + request.command.type === "provider.health" && + request.command.providerId === "no-usage", ), ).toBe(false); }); @@ -143,10 +145,12 @@ describe("GET /api/v1/system/usage-limits", () => { ), ).toEqual(["codex"]); expect( - responder.requests.some( - (request) => request.command.type === "provider.health", + responder.requests.flatMap((request) => + request.command.type === "provider.health" + ? [request.command.providerId] + : [], ), - ).toBe(false); + ).toEqual(["acp-grok"]); }); }); diff --git a/apps/server/test/services/plugins/first-party-provider-plugins.test.ts b/apps/server/test/services/plugins/first-party-provider-plugins.test.ts index 334004e8a3..3e4139ba38 100644 --- a/apps/server/test/services/plugins/first-party-provider-plugins.test.ts +++ b/apps/server/test/services/plugins/first-party-provider-plugins.test.ts @@ -93,7 +93,7 @@ const FIRST_PARTY_PROVIDER_DECLARATIONS = [ supportsThreadRename: false, fork: "none", supportsManualCompaction: false, - supportsUsage: false, + supportsUsage: true, visibility: "installed", hasLogo: true, }, diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index b2b1952e95..87c7609e69 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -1,3 +1,3 @@ -export const PLUGIN_SDK_VERSION = "0.4.48"; +export const PLUGIN_SDK_VERSION = "0.4.49"; export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index d38d40eca3..f5b0caa3f4 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.48", + "version": "0.4.49", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" diff --git a/packages/provider-bridge-acp/src/bridge/bridge.test.ts b/packages/provider-bridge-acp/src/bridge/bridge.test.ts index 0ea2072880..78b6258732 100644 --- a/packages/provider-bridge-acp/src/bridge/bridge.test.ts +++ b/packages/provider-bridge-acp/src/bridge/bridge.test.ts @@ -2372,6 +2372,31 @@ describe("acp bridge", () => { expect(agentMessageTexts()).not.toContain("echo:/compact"); }); + it("reports Cursor's terminal PING timeout as a failed turn", async () => { + const { providerThreadId } = await startThread({ dialectId: "cursor", envVars: { FAKE_ACP_TEXT_FAILURE: "1" } }); + const id = sendTurnRequest("turn/start", providerThreadId, { input: [{ type: "text", text: "probe" }] }); + expect((await waitForResponse(id)).error).toBeUndefined(); + expect(await waitForTurnCompleted()).toMatchObject({ status: "failed" }); + expect(notifications("error")).toEqual(expect.arrayContaining([ + expect.objectContaining({ params: expect.objectContaining({ message: "RetriableError: [unavailable] PING timed out" }) }), + ])); + }); + + it.each([ + { dialectId: "cursor", parts: ["\n\nError: RetriableError: [unavailable] ", "PING timed out"], status: "failed" }, + { dialectId: "cursor", parts: ["The log says: Error: RetriableError: [unavailable] PING timed out"], status: "completed" }, + { dialectId: "cursor", parts: ["```\nError: RetriableError: [unavailable] PING timed out\n```"], status: "completed" }, + { dialectId: "cursor", parts: ["\n\nError: RetriableError: [unavailable] PING timed out", "\nI recovered and completed the task."], status: "completed" }, + { dialectId: "cursor", parts: ["Error: RetriableError: [unavailable] PING timed out"], status: "completed" }, + { dialectId: "cursor", parts: ["\n\nError: RetriableError: [unavailable] PING timed out" + " ".repeat(512) + "Recovered"], status: "completed" }, + { dialectId: "generic", parts: ["\n\nError: RetriableError: [unavailable] PING timed out"], status: "completed" }, + ])("normalizes only a terminal Cursor transport failure: $dialectId $parts", async ({ dialectId, parts, status }) => { + const { providerThreadId } = await startThread({ dialectId, envVars: { FAKE_ACP_TEXT_FAILURE: "1", FAKE_ACP_TEXT_PARTS: JSON.stringify(parts) } }); + const id = sendTurnRequest("turn/start", providerThreadId, { input: [{ type: "text", text: "probe" }] }); + expect((await waitForResponse(id)).error).toBeUndefined(); + expect(await waitForTurnCompleted()).toMatchObject({ status }); + }); + it("fails the compaction turn legibly when the agent rejects the request", async () => { const { providerThreadId } = await startThread({ envVars: { FAKE_ACP_PROMPT_ERROR: "1" }, diff --git a/packages/provider-bridge-acp/src/bridge/bridge.ts b/packages/provider-bridge-acp/src/bridge/bridge.ts index 66884bbed1..85f51105c8 100644 --- a/packages/provider-bridge-acp/src/bridge/bridge.ts +++ b/packages/provider-bridge-acp/src/bridge/bridge.ts @@ -172,6 +172,7 @@ interface AcpThreadSession { compactionAgentMessage: string; queuedInputs: AcpPendingTurnInput[]; promptRequestPending: boolean; + cursorTerminalErrorText: string; cancelRequested: boolean; loading: boolean; loadingSessionId: string | undefined; @@ -1715,6 +1716,7 @@ async function startAgentSession( compactionAgentMessage: "", queuedInputs: [], promptRequestPending: false, + cursorTerminalErrorText: "", cancelRequested: false, loading: false, loadingSessionId: undefined, @@ -2049,6 +2051,7 @@ function runTurn( let stopReason: z.infer; session.cancelRequested = false; try { + session.cursorTerminalErrorText = ""; session.promptRequestPending = true; const promptResult = session.connection.request({ method: "session/prompt", @@ -2064,6 +2067,15 @@ function runTurn( } const result = await promptResult; stopReason = result.stopReason; + if ( + stopReason === "end_turn" && + session.dialect.id === "cursor" && + session.cursorTerminalErrorText.startsWith("\n\nError:") && + session.cursorTerminalErrorText.trim() === + "Error: RetriableError: [unavailable] PING timed out" + ) { + throw new Error("RetriableError: [unavailable] PING timed out"); + } } catch (error) { session.promptRequestPending = false; dropTurnInput(pending, "ACP turn failed before the prompt was sent"); @@ -2251,6 +2263,26 @@ function handleAgentNotification( extractAcpContentText(chunk.data.content) ?? ""; } } + if (session.dialect.id === "cursor" && session.activePromptKind === "turn") { + const event = parsed.data.update; + const chunk = + event.sessionUpdate === "agent_message_chunk" + ? acpAgentMessageChunkUpdateSchema.safeParse(event) + : null; + const text = + chunk?.success === true + ? extractAcpContentText(chunk.data.content) + : undefined; + if (text !== undefined) { + if (text.startsWith("\n\nError:")) { + session.cursorTerminalErrorText = ""; + } + const combined = session.cursorTerminalErrorText + text; + session.cursorTerminalErrorText = combined.length <= 512 ? combined : ""; + } else if (event.sessionUpdate !== "usage_update") { + session.cursorTerminalErrorText = ""; + } + } emitForSession(session, ACP_UPDATE_METHOD, update); } diff --git a/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs b/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs index 270fce3176..9d212656a2 100755 --- a/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs +++ b/packages/provider-bridge-acp/src/bridge/fake-acp-agent.mjs @@ -422,6 +422,16 @@ async function handlePrompt(message) { ); } + if (process.env.FAKE_ACP_TEXT_FAILURE === "1") { + const parts = JSON.parse(process.env.FAKE_ACP_TEXT_PARTS ?? '["\\n\\nError: RetriableError: [unavailable] PING timed out"]'); + for (const text of parts) { + notifyUpdate({ sessionUpdate: "agent_message_chunk", content: { type: "text", text } }); + } + activePromptId = null; + send({ jsonrpc: "2.0", id: message.id, result: { stopReason: "end_turn" } }); + return; + } + if (process.env.FAKE_ACP_PROMPT_ERROR === "1") { activePromptId = null; send({ diff --git a/packages/provider-bridge-acp/src/bridge/provider-maintenance.test.ts b/packages/provider-bridge-acp/src/bridge/provider-maintenance.test.ts index a15169e735..cf686e9155 100644 --- a/packages/provider-bridge-acp/src/bridge/provider-maintenance.test.ts +++ b/packages/provider-bridge-acp/src/bridge/provider-maintenance.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { CURSOR_ACP_MAINTENANCE, __testing } from "./provider-maintenance.js"; +import { + CURSOR_ACP_MAINTENANCE, + GROK_ACP_MAINTENANCE, + OPENCODE_ACP_MAINTENANCE, + __testing, +} from "./provider-maintenance.js"; function cursorMissingInstallationStatus() { return { @@ -23,12 +28,82 @@ function cursorMissingInstallationStatus() { } describe("ACP provider maintenance", () => { - it("normalizes Cursor plan and spend limits without reading daemon state", () => { + it("normalizes the shared OpenCode Go allowance with the five-hour window first", () => { + expect( + __testing.normalizeOpenCodeUsage({ + usage: { + rolling: { percent: 23, resetsAt: "2026-09-01T01:00:00Z" }, + weekly: { percent: 41, resetsAt: "2026-09-07T00:00:00Z" }, + }, + }), + ).toEqual({ + status: "ok", + accountEmail: null, + planLabel: "Go · shared", + windows: [ + { + label: "Rolling (5h)", + usedPercent: 23, + resetsAt: "2026-09-01T01:00:00Z", + }, + { + label: "Weekly", + usedPercent: 41, + resetsAt: "2026-09-07T00:00:00Z", + }, + ], + }); + }); + + it("normalizes Grok quota for native and remote usage clients", () => { + expect( + __testing.normalizeGrokUsage( + { + config: { + creditUsagePercent: 12, + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + end: "2026-09-06T00:00:00Z", + }, + }, + }, + { subscription_tier_display: "X Premium+" }, + ), + ).toEqual({ + status: "ok", + accountEmail: null, + planLabel: "X Premium+", + windows: [ + { + label: "Weekly limit", + usedPercent: 12, + resetsAt: "2026-09-06T00:00:00Z", + }, + ], + }); + }); + + it("keeps usage-only dialects separate from installation support", () => { + expect(OPENCODE_ACP_MAINTENANCE.readUsage).toBeTypeOf("function"); + expect(GROK_ACP_MAINTENANCE.readUsage).toBeTypeOf("function"); + expect(OPENCODE_ACP_MAINTENANCE.installer).toBeUndefined(); + expect(GROK_ACP_MAINTENANCE.installer).toBeUndefined(); + }); + + it("shows Cursor included-model and other-model usage separately", () => { expect( __testing.normalizeUsage( { billingCycleEnd: "1767225600000", - planUsage: { totalPercentUsed: 72.2 }, + planUsage: { + autoPercentUsed: 40.8, + apiPercentUsed: 71.4, + totalPercentUsed: 72.2, + totalSpend: 17794, + includedSpend: 7000, + bonusSpend: 10794, + limit: 7000, + }, spendLimitUsage: { overallUsed: "1250", overallLimit: "5000", @@ -43,7 +118,17 @@ describe("ACP provider maintenance", () => { planLabel: "Pro", windows: [ { - label: "Plan usage", + label: "Included models", + usedPercent: 41, + resetsAt: "2026-01-01T00:00:00.000Z", + }, + { + label: "Other models", + usedPercent: 71, + resetsAt: "2026-01-01T00:00:00.000Z", + }, + { + label: "Total usage", usedPercent: 72, resetsAt: "2026-01-01T00:00:00.000Z", }, @@ -53,10 +138,63 @@ describe("ACP provider maintenance", () => { resetsAt: "2026-01-01T00:00:00.000Z", cost: { usedUsdCents: 1250, limitUsdCents: 5000 }, }, + { + label: "Bonus spend", + usedPercent: 61, + resetsAt: "2026-01-01T00:00:00.000Z", + cost: { usedUsdCents: 10794, limitUsdCents: 17794 }, + }, ], }); }); + it("omits Cursor total and bonus windows when the dashboard omits them", () => { + expect( + __testing.normalizeUsage( + { + planUsage: { + autoPercentUsed: 10, + apiPercentUsed: 20, + }, + }, + {}, + ).windows, + ).toEqual([ + { label: "Included models", usedPercent: 10, resetsAt: null }, + { label: "Other models", usedPercent: 20, resetsAt: null }, + ]); + }); + + it("accepts string-encoded Cursor percentages", () => { + expect( + __testing.normalizeUsage( + { + planUsage: { + autoPercentUsed: "10.2", + apiPercentUsed: "100", + totalPercentUsed: "14.2", + }, + }, + {}, + ).windows, + ).toEqual([ + { label: "Included models", usedPercent: 10, resetsAt: null }, + { label: "Other models", usedPercent: 100, resetsAt: null }, + { label: "Total usage", usedPercent: 14, resetsAt: null }, + ]); + }); + + it("keeps Cursor's aggregate plan usage as a legacy fallback", () => { + expect( + __testing.normalizeUsage( + { planUsage: { totalPercentUsed: 72.2 } }, + { planInfo: { planName: "Pro" } }, + ).windows, + ).toEqual([ + { label: "Plan usage", usedPercent: 72, resetsAt: null }, + ]); + }); + it("offers the installer only through a fresh matching action", () => { expect( __testing.buildProviderInstallationRun( diff --git a/packages/provider-bridge-acp/src/bridge/provider-maintenance.ts b/packages/provider-bridge-acp/src/bridge/provider-maintenance.ts index 5c02646fd4..f510f6f675 100644 --- a/packages/provider-bridge-acp/src/bridge/provider-maintenance.ts +++ b/packages/provider-bridge-acp/src/bridge/provider-maintenance.ts @@ -28,6 +28,10 @@ const CURSOR_DASHBOARD_URL = const CURSOR_KEYCHAIN_ACCOUNT = "cursor-user"; const CURSOR_ACCESS_TOKEN_SERVICE = "cursor-access-token"; const CURSOR_INSTALL_SCRIPT_URL = "https://cursor.com/install"; +const OPENCODE_GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage"; +const GROK_BILLING_URL = + "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; +const GROK_SETTINGS_URL = "https://cli-chat-proxy.grok.com/v1/settings"; function cursorAuthFilePath(): string { if (process.platform === "win32") { @@ -129,10 +133,10 @@ function readAccountEmail(): string | null { } export interface AcpMaintenanceDialect { - loginCommand: string; - installer(): { command: string; args: string[]; displayCommand: string }; - readAccount(): Promise<{ email: string | null } | null>; - readUsage(): Promise; + loginCommand?: string; + installer?(): { command: string; args: string[]; displayCommand: string }; + readAccount?(): Promise<{ email: string | null } | null>; + readUsage?(): Promise; } function healthResult(args: { @@ -142,7 +146,7 @@ function healthResult(args: { installedVersion?: string | null; statusMessage?: string | null; }): ProviderHealthResult { - const maintained = args.maintenance !== undefined; + const installable = args.maintenance?.installer !== undefined; return { supported: true, health: { @@ -152,8 +156,8 @@ function healthResult(args: { planLabel: null, installedVersion: args.installedVersion ?? null, minimumSupportedVersion: null, - canInstall: maintained, - canUpdate: maintained && args.status !== "not_installed", + canInstall: installable, + canUpdate: installable && args.status !== "not_installed", loginCommand: args.maintenance?.loginCommand ?? null, }, }; @@ -175,7 +179,7 @@ export async function getAcpProviderHealth(args: { return healthResult({ maintenance, status: "not_installed" }); } const version = await readCliVersion(args.command); - if (maintenance === undefined) { + if (maintenance?.readAccount === undefined) { return healthResult({ maintenance, status: "ready", @@ -213,7 +217,7 @@ export async function getAcpProviderInstallationStatus(args: { ? await readCliVersion(args.command) : null; const installAction = - args.maintenance !== undefined && !installed + args.maintenance?.installer !== undefined && !installed ? { kind: "install" as const, label: "Install" as const, @@ -255,7 +259,7 @@ function buildAcpProviderInstallationRun( ): ProviderInstallationRunResult { if ( status.installAction?.kind !== args.action || - args.maintenance === undefined + args.maintenance?.installer === undefined ) { return { available: false, @@ -276,11 +280,29 @@ const cursorNonNegativeIntegerSchema = z ]) .refine(Number.isSafeInteger); +const cursorPercentSchema = z + .union([ + z.number().nonnegative(), + z + .string() + .regex(/^\d+(\.\d+)?$/u) + .transform(Number), + ]) + .refine(Number.isFinite); + const cursorUsageResponseSchema = z .object({ billingCycleEnd: cursorNonNegativeIntegerSchema.nullish(), planUsage: z - .object({ totalPercentUsed: z.number().nonnegative().default(0) }) + .object({ + autoPercentUsed: cursorPercentSchema.nullish(), + apiPercentUsed: cursorPercentSchema.nullish(), + totalPercentUsed: cursorPercentSchema.nullish(), + totalSpend: cursorNonNegativeIntegerSchema.nullish(), + includedSpend: cursorNonNegativeIntegerSchema.nullish(), + bonusSpend: cursorNonNegativeIntegerSchema.nullish(), + limit: cursorNonNegativeIntegerSchema.nullish(), + }) .nullish(), spendLimitUsage: z .object({ @@ -321,10 +343,34 @@ function normalizeUsage( ? null : new Date(usage.data.billingCycleEnd).toISOString(); const windows: ProviderUsageWindow[] = []; - if (usage.data.planUsage?.totalPercentUsed != null) { + const planUsage = usage.data.planUsage; + const hasModelBuckets = + planUsage?.autoPercentUsed != null || planUsage?.apiPercentUsed != null; + if (planUsage?.autoPercentUsed != null) { + windows.push({ + label: "Included models", + usedPercent: clampPercent(planUsage.autoPercentUsed), + resetsAt, + }); + } + if (planUsage?.apiPercentUsed != null) { + windows.push({ + label: "Other models", + usedPercent: clampPercent(planUsage.apiPercentUsed), + resetsAt, + }); + } + if (!hasModelBuckets && planUsage != null) { windows.push({ label: "Plan usage", - usedPercent: clampPercent(usage.data.planUsage.totalPercentUsed), + usedPercent: clampPercent(planUsage.totalPercentUsed ?? 0), + resetsAt, + }); + } + if (hasModelBuckets && planUsage?.totalPercentUsed != null) { + windows.push({ + label: "Total usage", + usedPercent: clampPercent(planUsage.totalPercentUsed), resetsAt, }); } @@ -345,6 +391,16 @@ function normalizeUsage( cost: { usedUsdCents: pair.used, limitUsdCents: pair.limit }, }); } + const bonusSpend = planUsage?.bonusSpend ?? 0; + const totalSpend = planUsage?.totalSpend ?? 0; + if (bonusSpend > 0 && totalSpend > 0) { + windows.push({ + label: "Bonus spend", + usedPercent: clampPercent((bonusSpend / totalSpend) * 100), + resetsAt, + cost: { usedUsdCents: bonusSpend, limitUsdCents: totalSpend }, + }); + } return { status: "ok", accountEmail, @@ -376,7 +432,7 @@ export async function getAcpProviderUsage(args: { maintenance: AcpMaintenanceDialect | undefined; command: string | null; }): Promise { - if (args.maintenance === undefined) return { supported: false }; + if (args.maintenance?.readUsage === undefined) return { supported: false }; if ( args.command === null || (await resolveExecutablePath(args.command)) === null @@ -441,7 +497,285 @@ async function readCursorUsage(): Promise { } } +async function readOpenCodeUsageKey(): Promise { + try { + const text = await fs.readFile( + path.join(os.homedir(), ".bb", "opencode-usage.env"), + "utf8", + ); + const line = text + .split(/\r?\n/u) + .find((entry) => /^OPENCODE_API_KEY=/u.test(entry)); + if (line === undefined) return ""; + const raw = line.slice(line.indexOf("=") + 1).trim(); + if ( + raw.length >= 2 && + ((raw.startsWith('"') && raw.endsWith('"')) || + (raw.startsWith("'") && raw.endsWith("'"))) + ) { + return raw.slice(1, -1); + } + return raw; + } catch { + return ""; + } +} + +function normalizeOpenCodeUsage(data: unknown): ProviderUsage { + if ( + data === null || + typeof data !== "object" || + (data as { usage?: unknown }).usage === null || + typeof (data as { usage?: unknown }).usage !== "object" + ) { + return { + status: "error", + message: "OpenCode Go usage response was malformed.", + planLabel: "Go · shared", + accountEmail: null, + }; + } + const usage = (data as { usage: Record }).usage; + const labels = [ + ["rolling", "Rolling (5h)"], + ["weekly", "Weekly"], + ["monthly", "Monthly"], + ] as const; + const windows = labels.flatMap(([key, label]) => { + const value = usage[key]; + if (value === null || typeof value !== "object") return []; + const rawPercent = Number((value as { percent?: unknown }).percent); + return [ + { + label, + usedPercent: clampPercent(rawPercent), + resetsAt: + typeof (value as { resetsAt?: unknown }).resetsAt === "string" + ? (value as { resetsAt: string }).resetsAt + : null, + }, + ]; + }); + return { + status: "ok", + accountEmail: null, + planLabel: "Go · shared", + windows, + }; +} + +async function readOpenCodeUsage(): Promise { + const accessToken = await readOpenCodeUsageKey(); + if (accessToken === "") { + return { supported: true, usage: { status: "unauthenticated" } }; + } + try { + const response = await fetch(OPENCODE_GO_USAGE_URL, { + headers: { + Authorization: `Bearer ${accessToken}`, + "User-Agent": "bb-provider-acp/0.1.0", + }, + signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS), + }); + if (response.status === 401 || response.status === 403) { + return { supported: true, usage: { status: "expired" } }; + } + if (!response.ok) { + return { + supported: true, + usage: { + status: "error", + message: `OpenCode Go usage request failed (HTTP ${response.status}).`, + planLabel: "Go · shared", + accountEmail: null, + }, + }; + } + return { + supported: true, + usage: normalizeOpenCodeUsage(await response.json()), + }; + } catch (error) { + return { + supported: true, + usage: { + status: "error", + message: error instanceof Error ? error.message : String(error), + planLabel: "Go · shared", + accountEmail: null, + }, + }; + } +} + +type JsonRecord = Record; + +function jsonRecord(value: unknown): JsonRecord | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : null; +} + +function finiteNumber(value: unknown): number | null { + const number = typeof value === "number" ? value : Number(value); + return Number.isFinite(number) ? number : null; +} + +function grokUsagePercent(config: JsonRecord): number { + const direct = finiteNumber(config.creditUsagePercent); + if (direct !== null) return direct; + const history = Array.isArray(config.history) ? config.history : []; + for (let index = history.length - 1; index >= 0; index -= 1) { + const percentage = finiteNumber( + jsonRecord(history[index])?.creditUsagePercent, + ); + if (percentage !== null) return percentage; + } + const includedUsed = finiteNumber( + jsonRecord(config.includedUsed)?.val ?? config.includedUsed, + ); + const monthlyLimit = finiteNumber( + jsonRecord(config.monthlyLimit)?.val ?? config.monthlyLimit, + ); + return includedUsed !== null && monthlyLimit !== null && monthlyLimit > 0 + ? (includedUsed / monthlyLimit) * 100 + : 0; +} + +function grokPeriodLabel(type: unknown): string { + if (typeof type !== "string") return "Usage limit"; + if (type.includes("WEEKLY")) return "Weekly limit"; + if (type.includes("MONTHLY")) return "Monthly limit"; + if (type.includes("DAILY")) return "Daily limit"; + return "Usage limit"; +} + +function normalizeGrokUsage( + billingPayload: unknown, + settingsPayload: unknown, +): ProviderUsage { + const config = jsonRecord(jsonRecord(billingPayload)?.config); + if (config === null) { + return { + status: "error", + message: "Grok usage response was malformed.", + planLabel: null, + accountEmail: null, + }; + } + const period = jsonRecord(config.currentPeriod); + const plan = jsonRecord(settingsPayload)?.subscription_tier_display; + return { + status: "ok", + accountEmail: null, + planLabel: + typeof plan === "string" && plan.trim() !== "" ? plan : "Grok Build", + windows: [ + { + label: grokPeriodLabel(period?.type), + usedPercent: clampPercent(grokUsagePercent(config)), + resetsAt: + typeof period?.end === "string" + ? period.end + : typeof config.billingPeriodEnd === "string" + ? config.billingPeriodEnd + : null, + }, + ], + }; +} + +async function readGrokBearerKey(): Promise { + const grokHome = + process.env.GROK_HOME?.trim() || path.join(os.homedir(), ".grok"); + try { + const root = jsonRecord( + JSON.parse(await fs.readFile(path.join(grokHome, "auth.json"), "utf8")), + ); + if (root === null) return null; + for (const candidate of Object.values(root)) { + const key = jsonRecord(candidate)?.key; + if (typeof key === "string" && key.trim() !== "") return key; + } + } catch { + return null; + } + return null; +} + +async function readGrokUsage(): Promise { + const key = await readGrokBearerKey(); + if (key === null) { + return { supported: true, usage: { status: "unauthenticated" } }; + } + const headers = { + Authorization: `Bearer ${key}`, + "x-grok-client-mode": "billing", + "User-Agent": "bb-provider-acp/0.1.0", + }; + try { + const [billing, settings] = await Promise.all([ + fetch(GROK_BILLING_URL, { + headers, + signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS), + }), + fetch(GROK_SETTINGS_URL, { + headers, + signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS), + }), + ]); + if ( + billing.status === 401 || + billing.status === 403 || + settings.status === 401 || + settings.status === 403 + ) { + return { supported: true, usage: { status: "expired" } }; + } + if (!billing.ok) { + return { + supported: true, + usage: { + status: "error", + message: `Grok usage request failed (HTTP ${billing.status}).`, + planLabel: null, + accountEmail: null, + }, + }; + } + return { + supported: true, + usage: normalizeGrokUsage( + await billing.json(), + settings.ok ? await settings.json() : {}, + ), + }; + } catch (error) { + return { + supported: true, + usage: { + status: "error", + message: error instanceof Error ? error.message : String(error), + planLabel: null, + accountEmail: null, + }, + }; + } +} + +export const OPENCODE_ACP_MAINTENANCE: AcpMaintenanceDialect = { + loginCommand: "opencode auth login", + readUsage: readOpenCodeUsage, +}; + +export const GROK_ACP_MAINTENANCE: AcpMaintenanceDialect = { + loginCommand: "grok login", + readUsage: readGrokUsage, +}; + export const __testing = { buildProviderInstallationRun: buildAcpProviderInstallationRun, + normalizeGrokUsage, + normalizeOpenCodeUsage, normalizeUsage, }; diff --git a/packages/provider-bridge-acp/src/dialect.ts b/packages/provider-bridge-acp/src/dialect.ts index a4d8f658b2..97c0d33ce6 100644 --- a/packages/provider-bridge-acp/src/dialect.ts +++ b/packages/provider-bridge-acp/src/dialect.ts @@ -3,6 +3,8 @@ import { basename } from "node:path"; import { z } from "zod"; import { CURSOR_ACP_MAINTENANCE, + GROK_ACP_MAINTENANCE, + OPENCODE_ACP_MAINTENANCE, type AcpMaintenanceDialect, } from "./bridge/provider-maintenance.js"; import { delegationPresentation } from "./presentation.js"; @@ -123,6 +125,7 @@ export const GROK_ACP_DIALECT: AcpDialect = { id: "grok", toolIdentity: grokToolIdentity, classifyToolCall: grokClassifyToolCall, + maintenance: GROK_ACP_MAINTENANCE, }; const CURSOR_TASK_TOOL = "task"; @@ -374,6 +377,7 @@ function normalizeOpenCodeCommandEvent( export const OPENCODE_ACP_DIALECT: AcpDialect = { id: "opencode", normalizeCommandEvent: normalizeOpenCodeCommandEvent, + maintenance: OPENCODE_ACP_MAINTENANCE, }; const DIALECTS_BY_ID: ReadonlyMap = new Map([ diff --git a/plugins/provider-acp/package.json b/plugins/provider-acp/package.json index 456edeb456..3563f5d558 100644 --- a/plugins/provider-acp/package.json +++ b/plugins/provider-acp/package.json @@ -1,15 +1,15 @@ { "name": "bb-plugin-provider-acp", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", - "description": "Run bb threads with ACP agents (supports Cursor, opencode, omp and more).", + "description": "Current BB ACP providers with Cursor, Grok, and shared OpenCode usage for desktop and remote clients.", "engines": { "bb": ">=0.0" }, "bb": { - "name": "ACP providers", - "description": "Run bb threads with ACP agents (supports Cursor, opencode, omp and more).", + "name": "ACP providers with native usage", + "description": "Current BB ACP providers with Cursor, Grok, and shared OpenCode usage for desktop and remote clients.", "branding": { "icon": "./icons/acp.svg", "experimental_icons": { diff --git a/plugins/provider-acp/src/known-agents.ts b/plugins/provider-acp/src/known-agents.ts index 601dc228d3..fca304791f 100644 --- a/plugins/provider-acp/src/known-agents.ts +++ b/plugins/provider-acp/src/known-agents.ts @@ -152,6 +152,7 @@ export const KNOWN_ACP_AGENTS: readonly AcpAgentDefinition[] = [ installUrl: "https://docs.x.ai/docs/grok-build", visibility: "installed", dialect: "grok", + providerUsage: true, fork: "none", reasoningLevels: ["low", "medium", "high"], launch: {