diff --git a/docs/plugins.md b/docs/plugins.md index 401e33b94b..387f24f46b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -488,9 +488,9 @@ SVG or URL. ## Usage sources -Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons. +Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage screen falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons. -The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version. +The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. ## Contribute sidebar items diff --git a/packages/app/e2e/browser/context-window-meter.spec.ts b/packages/app/e2e/browser/context-window-meter.spec.ts index 9bd5193173..80a43d6aa2 100644 --- a/packages/app/e2e/browser/context-window-meter.spec.ts +++ b/packages/app/e2e/browser/context-window-meter.spec.ts @@ -273,7 +273,7 @@ for (const theme of ["light", "dark"] as const) { const card = contextWindowDetails(page); await refreshUsageCard(card, "Claude"); await expect(card.getByText(LOGIN_EXPIRED)).toBeVisible(); - expect(usage.refreshedReports()).toEqual([["claude:work"]]); + expect(usage.refreshedAgents()).toEqual([agent.agentId]); }); await test.step("after a resume under another login, only that login shows", async () => { @@ -321,7 +321,7 @@ for (const theme of ["light", "dark"] as const) { await test.step("a host without usage reports shows only the context window", async () => { await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot); // One request per open that had usage; this one sends none. - expect(usage.agentRequests()).toHaveLength(5); + expect(usage.agentRequests()).toHaveLength(6); }); }); @@ -351,7 +351,7 @@ for (const theme of ["light", "dark"] as const) { const sheet = contextWindowDetails(page); await refreshUsageCard(sheet, "Claude"); await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible(); - expect(usage.refreshedReports()).toEqual([["claude:work"]]); + expect(usage.refreshedAgents()).toEqual([agent.agentId]); await shot("problem"); }); @@ -365,7 +365,7 @@ for (const theme of ["light", "dark"] as const) { await test.step("a host without usage reports shows only the context window", async () => { await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot); - expect(usage.agentRequests()).toHaveLength(2); + expect(usage.agentRequests()).toHaveLength(3); }); }); } diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts index e457c43d22..1dfe89ae19 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -322,14 +322,14 @@ test("expired login refreshes to windows with visible pin toggles", async ({ pag await gotoAppShell(page); await openUsage(page); await expect( - page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }), + page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }), ).toBeVisible(); await expect(page.getByText("Unavailable", { exact: true })).toBeVisible(); await qaScreenshot(page, "usage-expired-login"); await fixture.setReport(loginWindows); await refreshLoginUsage(page); await expect( - page.getByText("Login expired 1h ago. Run claude to refresh it.", { exact: true }), + page.getByText("Claude: Login expired 1h ago. Run claude to refresh it.", { exact: true }), ).toHaveCount(0); await expectPinnedUsage(page, ["31% 5h", "54% wk"]); const row = page.getByRole("checkbox", { name: /^Pin Claude Weekly, / }); @@ -474,3 +474,64 @@ async function captureSettingsState( await qaScreenshot(page, name); await testInfo.attach(name, { body: await page.screenshot(), contentType: "image/png" }); } + +test("an account with all logins failing shows each harness and remedy, then only usage when one succeeds", async ({ + page, +}) => { + const fixture = await installLoginUsage([ + { harness: "Codex", report: { status: "error", error: "Usage API returned 500. Try again." } }, + { + harness: "OpenCode", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "opencode" }, + }, + }, + { + harness: "Pi", + report: { status: "unavailable", problem: { kind: "no_quota", detail: "No active plan" } }, + }, + { + harness: "OMP", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 403, refreshedBy: "omp" }, + }, + }, + ]); + try { + await gotoAppShell(page); + await openUsage(page); + await expectLoginErrors(page, [ + "Codex: Usage API returned 500. Try again.", + "OpenCode: Login rejected (HTTP 401). Run opencode to refresh it.", + "Pi: No active plan", + "OMP: Login rejected (HTTP 403). Run omp to refresh it.", + ]); + await fixture.setReport([ + { + harness: "Codex", + report: { status: "error", error: "Usage API returned 500. Try again." }, + }, + { + harness: "OpenCode", + report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }, + }, + ]); + await refreshLoginUsage(page); + await expectLoginUsageOnly(page, "42%"); + } finally { + await fixture.cleanup(); + } +}); + +async function expectLoginErrors(page: Page, lines: string[]) { + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByTestId("usage-login-error")).toHaveText(lines.join("\n")); +} + +async function expectLoginUsageOnly(page: Page, percentage: string) { + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByText(percentage, { exact: true })).toBeVisible(); + await expect(card.getByTestId("usage-login-error")).toHaveCount(0); +} diff --git a/packages/app/e2e/support/helpers/context-window.ts b/packages/app/e2e/support/helpers/context-window.ts index 82f2be6da5..21341c7766 100644 --- a/packages/app/e2e/support/helpers/context-window.ts +++ b/packages/app/e2e/support/helpers/context-window.ts @@ -134,8 +134,8 @@ export interface AgentUsageScript { stopReportingUsage(): void; /** The agent of each agent usage request, in order. */ agentRequests(): string[]; - /** Each report Refresh, by report ID. */ - refreshedReports(): string[][]; + /** Each scoped agent Refresh, by agent ID. */ + refreshedAgents(): string[]; } /** @@ -162,8 +162,10 @@ export async function scriptAgentUsage(page: Page): Promise { supported = false; }, agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []), - refreshedReports: () => - usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])), + refreshedAgents: () => + usage + .listRequests() + .flatMap((request) => (request.forceRefresh && request.agentId ? [request.agentId] : [])), }; } diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts index 234ec2bf1b..985e7bd7ac 100644 --- a/packages/app/e2e/support/helpers/usage-login.ts +++ b/packages/app/e2e/support/helpers/usage-login.ts @@ -8,12 +8,15 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { pluginRequirements } from "./plugin-fixture"; /** A real store-backed plugin on the worker's isolated daemon. */ -export async function installLoginUsage(initialReport: UsageReport) { +type LoginReports = Array<{ harness: string; report: UsageReport }>; + +export async function installLoginUsage(initialReport: UsageReport | LoginReports) { const directory = await mkdtemp(path.join(tmpdir(), "usage-login-journey-")); const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); const previous = await client.getDaemonConfig(); const store = path.join(directory, "report.json"); - const setReport = (report: UsageReport) => writeFile(store, JSON.stringify(report)); + const setReport = (report: UsageReport | LoginReports) => + writeFile(store, JSON.stringify(report)); const cleanup = async () => { try { await client.removePlugin("login-journey"); @@ -35,9 +38,13 @@ export async function installLoginUsage(initialReport: UsageReport) { import {readFile} from "node:fs/promises"; import {z} from "zod"; export default function contribute(server) { - server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({}), - discover: async () => [{key: "account", input: {}}], - fetch: async () => JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")), + const read = async () => { + const value = JSON.parse(await readFile(${JSON.stringify(store)}, "utf8")); + return Array.isArray(value) ? value : [{harness: "Claude", report: value}]; + }; + server.registerUsageSource({id: "login-journey", label: "Claude", input: z.object({index: z.number()}), + discover: async () => (await read()).map((login, index) => ({key: "account", harness: login.harness, input: {index}})), + fetch: async ({index}) => (await read())[index].report, }); return () => {}; }`, diff --git a/packages/app/src/usage/agent-usage.tsx b/packages/app/src/usage/agent-usage.tsx index a31ae89478..0199c08ff2 100644 --- a/packages/app/src/usage/agent-usage.tsx +++ b/packages/app/src/usage/agent-usage.tsx @@ -27,7 +27,12 @@ export function AgentUsage({ <> {view.kind === "ready" ? ( - + ) : ( {view.kind === "loading" ? usageCopy.loading : view.message} @@ -39,10 +44,12 @@ export function AgentUsage({ function AgentUsageCards({ serverId, + agentId, reports, refreshable, }: { serverId: string; + agentId: string; reports: readonly UsageReportEntry[]; refreshable: boolean; }) { @@ -51,6 +58,7 @@ function AgentUsageCards({ `${login.harness}: ${reportContent(login.report).message}`, + ); + // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0. + return [ + entry.report.status === "error" ? entry.report.error : usageCopy.problem(entry.report.problem), + ]; +} + const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); export function UsageCard({ serverId, + agentId, entry, display, pinnable, @@ -52,6 +65,7 @@ export function UsageCard({ compact = false, }: { serverId: string; + agentId?: string; entry: UsageReportEntry; display: UsageDisplay; /** Whether each window row pins the window to the sidebar. */ @@ -61,13 +75,14 @@ export function UsageCard({ compact?: boolean; }) { const isCompact = useIsCompactFormFactor(); - const { refresh, refreshState } = useReportRefresh(serverId, entry.id); + const { refresh, refreshState } = useReportRefresh(serverId, entry.id, agentId); // Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip. const showsFreshnessInline = isNative || isCompact || !refreshable; const usage = entry.report; const status = statusText(usage); const footer = entry.account.label ?? null; - const { windows, balances, details, message } = reportContent(usage); + const { windows, balances, details } = reportContent(usage); + const messages = reportMessages(entry); const containerStyle = useMemo( () => [styles.container, compact ? styles.containerCompact : styles.containerPadded], @@ -110,9 +125,9 @@ export function UsageCard({ ) : null} - {message ? ( - - {message} + {messages.length > 0 ? ( + + {messages.join("\n")} ) : null} diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index f054d6d082..7b6f210543 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -98,10 +98,14 @@ async function getReport( serverId: string, reportId: string, forceRefresh = false, + agentId?: string, ): Promise { return ( - (await requireClient(serverId).listUsageReports({ reportIds: [reportId], forceRefresh })) - .reports[0] ?? null + ( + await requireClient(serverId).listUsageReports( + agentId === undefined ? { reportIds: [reportId], forceRefresh } : { agentId, forceRefresh }, + ) + ).reports.find((report) => report.id === reportId) ?? null ); } @@ -223,20 +227,22 @@ export function useUsageHosts(): UsageHost[] { } /** - * Forces the source to fetch one report, and only that report. The result replaces the report - * in every list of its host that holds it, the host's and each agent's, so every surface showing - * it moves together; until then the previous report stays on screen. + * Refreshes a host report, or the agent's session reports. The result replaces the card + * in the requesting view. Host and agent views can use different logins of the same account. */ export function useReportRefresh( serverId: string, reportId: string, + agentId?: string, ): { refresh: () => void; refreshState: UsageRefresh } { const queryClient = useQueryClient(); const mutation = useMutation({ - mutationFn: () => getReport(serverId, reportId, true), + mutationFn: () => getReport(serverId, reportId, true, agentId), onSuccess: (report) => { - queryClient.setQueriesData( - { queryKey: hostUsageQueryKey(serverId) }, + queryClient.setQueryData( + agentId === undefined + ? usageReportsQueryKey(serverId) + : agentUsageQueryKey(serverId, agentId), (reports) => (reports ? replaceReport(reports, reportId, report) : reports), ); }, diff --git a/packages/plugin/src/server/usage.ts b/packages/plugin/src/server/usage.ts index 4ad2661d67..fa23ca9c91 100644 --- a/packages/plugin/src/server/usage.ts +++ b/packages/plugin/src/server/usage.ts @@ -58,6 +58,8 @@ export interface UsageAccount { /** Stable across token rotation; [A-Za-z0-9._-]{1,128}. Never a credential or raw email. */ key: string; label?: string; + /** Harness owning this login, e.g. Codex, OpenCode, Pi or OMP. */ + harness?: string; /** Store locator, opaque to the daemon. */ input: JsonValue; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 0214502263..41b9ca3f74 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -6289,6 +6289,17 @@ export const UsageReportEntrySchema = z.object({ sourceLabel: z.string(), icon: z.string().optional(), report: UsageReportSchema, + loginErrors: z + .array( + z.object({ + harness: z.string(), + report: z.discriminatedUnion("status", [ + z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }), + z.object({ status: z.literal("error"), error: z.string() }), + ]), + }), + ) + .optional(), }); export const UsageListReportsUpdateMessageSchema = z.object({ type: z.literal("usage.list_reports.update"), diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index fe2a79916f..2298d680a4 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -389,3 +389,50 @@ test("blocked setup preserves the legacy failed shape and optional provenance", legacySnapshot.parse(failed), ); }); + +test("usage login errors are additive and older reports still parse", () => { + const entry = { + id: "codex:account", + account: {}, + fetchedAt: "2026-10-05T00:00:00.000Z", + sourceId: "codex", + sourceLabel: "Codex", + report: { status: "error", error: "Usage API returned 500" }, + }; + const legacy = z.object({ + type: z.literal("usage.list_reports.update"), + payload: z.object({ + requestId: z.string(), + report: z.object({ + id: z.string(), + account: z.object({ label: z.string().optional() }), + fetchedAt: z.string(), + sourceId: z.string(), + sourceLabel: z.string(), + icon: z.string().optional(), + report: z.discriminatedUnion("status", [ + z.object({ status: z.literal("available"), windows: z.array(z.unknown()) }), + z.object({ status: z.literal("unavailable"), problem: z.unknown() }), + z.object({ status: z.literal("error"), error: z.string() }), + ]), + }), + }), + }); + const oldMessage = { + type: "usage.list_reports.update", + payload: { requestId: "usage", report: entry }, + }; + const newMessage = { + ...oldMessage, + payload: { + ...oldMessage.payload, + report: { + ...entry, + loginErrors: [{ harness: "Codex", report: entry.report }], + }, + }, + }; + expect(SessionOutboundMessageSchema.parse(oldMessage)).toEqual(oldMessage); + expect(SessionOutboundMessageSchema.parse(newMessage)).toEqual(newMessage); + expect(legacy.parse(newMessage)).toEqual(oldMessage); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index 37ab021831..eeea242788 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -190,7 +190,7 @@ test("legacy listing distinguishes labeled accounts and preserves unlabeled name ).toEqual(["claude (work)", "claude (personal)", "claude"]); }); -test("duplicate logins follow discovery order and fall back on unavailable, error and throws", async () => { +test("duplicate logins prefer discovery order across unavailable, error and thrown fetches", async () => { for (const failure of ["unavailable", "error", "throw"]) { const registry = new UsageSourceRegistry(); const calls: number[] = []; @@ -216,7 +216,7 @@ test("duplicate logins follow discovery order and fall back on unavailable, erro const reports = await registry.listReports(); expect(reports).toHaveLength(1); expect(reports[0]?.report.status).toBe("available"); - expect(calls).toEqual([0, 1]); + expect(calls).toEqual([0, 1, 2]); } }); @@ -338,7 +338,7 @@ test("session discovery reuses accounts, separates launches, and retains live ag await registry.listReports({ agentId: "one" }); await registry.listReports({ agentId: "two" }); expect(discoveries).toEqual(["/one", "/two"]); - expect(fetches).toBe(1); + expect(fetches).toBe(2); expect((await registry.listReports()).map((r) => r.id)).toEqual([ "claude:default", "claude:same", @@ -395,3 +395,164 @@ test("reports arrive independently and a hung fetch settles as an error before c entries[0]?.report, ); }); + +test("#6155: agent reports use only their session login despite host and sibling discoveries", async () => { + const sessions = new Map([ + ["codex-agent", { provider: "codex", env: {}, sessionKey: "codex-launch" }], + ["opencode-agent", { provider: "opencode", env: {}, sessionKey: "opencode-launch" }], + ]); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + const codexError = { status: "error", error: "Codex usage API returned 500" }; + const expired = { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-07-07T00:00:00.000Z", refreshedBy: "opencode" }, + }; + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => + (scope.kind === "global" ? ["codex", "opencode"] : [scope.provider]).map((harness) => ({ + key: "same", + harness, + input: { harness }, + })), + fetch: async (input) => + (input as { harness: string }).harness === "codex" ? codexError : expired, + }); + await registry.listReports(); + await registry.listReports({ agentId: "opencode-agent" }); + expect((await registry.listReports({ agentId: "codex-agent" }))[0]?.report).toEqual(codexError); +}); + +test("all failed host logins retain each source-supplied harness and remedy", async () => { + const registry = new UsageSourceRegistry(); + const failures = [ + { status: "error", error: "Codex usage API returned 500" }, + { + status: "unavailable", + problem: { kind: "expired", expiresAt: "2026-07-07T00:00:00.000Z", refreshedBy: "opencode" }, + }, + { status: "unavailable", problem: { kind: "rejected", status: 401, refreshedBy: "pi" } }, + { status: "unavailable", problem: { kind: "rejected", status: 403, refreshedBy: "omp" } }, + ]; + const harnesses = ["Codex", "OpenCode", "Pi", "OMP"]; + registry.register( + source({ + id: "codex", + discover: async () => harnesses.map((harness, input) => ({ key: "same", harness, input })), + fetch: async (input) => failures[Number(input)], + }), + ); + const [entry] = await registry.listReports(); + expect(entry?.loginErrors).toEqual( + harnesses.map((harness, index) => ({ harness, report: failures[index] })), + ); +}); + +test("a working agent login never uses another harness's failure or host fallback cache", async () => { + const sessions = new Map([ + ["working", { provider: "codex", env: {}, sessionKey: "one" }], + ["broken", { provider: "opencode", env: {}, sessionKey: "two" }], + ]); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + const available = { + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 31 }], + }; + const error = { status: "error", error: "OpenCode login rejected. Run opencode to refresh it." }; + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => + (scope.kind === "global" ? ["opencode", "codex"] : [scope.provider]).map((harness) => ({ + key: "same", + harness, + input: harness, + })), + fetch: async (input) => (input === "codex" ? available : error), + }); + const [host] = await registry.listReports(); + expect(host?.report).toEqual(available); + expect(host?.loginErrors).toBeUndefined(); + expect((await registry.listReports({ agentId: "working" }))[0]?.report).toEqual(available); + expect((await registry.listReports({ agentId: "broken" }))[0]?.report).toEqual(error); + expect( + (await registry.listReports({ agentId: "broken", forceRefresh: true }))[0]?.report, + ).toEqual(error); + expect( + (await registry.listReports({ reportIds: ["codex:same"], forceRefresh: true }))[0]?.report, + ).toEqual(available); +}); + +test("concurrent host and agent requests never share a different login's pending fetch", async () => { + let finish!: (report: unknown) => void; + const slow = new Promise((resolve) => { + finish = resolve; + }); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: () => true, + usageSession: () => ({ provider: "codex", env: {}, sessionKey: "launch" }), + }); + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => [{ key: "same", harness: "Codex", input: scope.kind }], + fetch: async (input) => + input === "global" ? slow : { status: "error", error: "Session login failed" }, + }); + const host = registry.listReports(); + const [agent] = await registry.listReports({ agentId: "agent" }); + expect(agent?.report).toEqual({ status: "error", error: "Session login failed" }); + finish({ status: "available", windows: [] }); + expect((await host)[0]?.report.status).toBe("available"); +}); + +test("duplicate logins across scopes appear only once in the host's errors", async () => { + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: () => true, + usageSession: () => ({ provider: "codex", env: {}, sessionKey: "launch" }), + }); + registry.register({ + id: "codex", + label: "Codex", + discover: async (scope) => [ + { + key: "same", + harness: "Codex", + input: + scope.kind === "global" + ? { path: "/auth", kind: "file" } + : { kind: "file", path: "/auth" }, + }, + ], + fetch: async () => ({ status: "error", error: "Login failed" }), + }); + await registry.listReports({ agentId: "agent" }); + expect((await registry.listReports())[0]?.loginErrors).toEqual([ + { harness: "Codex", report: { status: "error", error: "Login failed" } }, + ]); +}); + +test("a hung login records its own timeout and still tries the next login", async () => { + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, undefined, 25); + registry.register({ + id: "codex", + label: "Codex", + discover: async () => [ + { key: "same", harness: "Codex", input: 0 }, + { key: "same", harness: "OpenCode", input: 1 }, + ], + fetch: async (input) => + input === 0 ? new Promise(() => {}) : { status: "error", error: "Login rejected" }, + }); + expect((await registry.listReports())[0]?.loginErrors).toEqual([ + { harness: "Codex", report: { status: "error", error: "Usage fetch timed out" } }, + { harness: "OpenCode", report: { status: "error", error: "Login rejected" } }, + ]); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index e0fa300dcf..c0c9d781e5 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { AgentUsageSession } from "../../agent/agent-sdk-types.js"; import type { UsageScope } from "@getpaseo/plugin/server/usage"; import { z } from "zod"; @@ -18,9 +19,14 @@ export interface UsageSource { fetch(input: unknown): Promise; } +interface Login { + input: unknown; + harness: string; +} + interface KnownReport { source: UsageSource; - inputs: [unknown, ...unknown[]]; + logins: [Login, ...Login[]]; label?: string; } @@ -75,6 +81,7 @@ export class UsageSourceRegistry { } for (const key of this.known.keys()) if (key.startsWith(`${id}:`)) this.known.delete(key); for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key); + for (const key of this.pending.keys()) if (key.startsWith(`${id}:`)) this.pending.delete(key); } async listReports(options: ListUsageReportsOptions = {}): Promise { @@ -82,8 +89,10 @@ export class UsageSourceRegistry { throw new Error("agentId and reportIds cannot be combined"); this.pruneAgents(); let ids: string[]; + let reports = this.known; if (options.agentId !== undefined) { ids = await this.discoverAgent(options.agentId); + reports = this.byAgent.get(options.agentId)?.reports ?? new Map(); } else if (options.reportIds !== undefined) { ids = options.reportIds; } else { @@ -93,7 +102,7 @@ export class UsageSourceRegistry { } return Promise.all( [...new Set(ids)].flatMap((id) => { - const known = this.known.get(id); + const known = reports.get(id); if (!known) return []; return [ this.fetchId(id, known, options.forceRefresh).then((entry) => { @@ -149,8 +158,14 @@ export class UsageSourceRegistry { for (const [id, report] of reports) { if (this.sources.get(report.source.id) !== report.source) continue; const known = this.known.get(id); - if (known) known.inputs.push(...report.inputs); - else this.known.set(id, { ...report, inputs: [...report.inputs] }); + if (!known) { + this.known.set(id, { ...report, logins: [...report.logins] }); + continue; + } + for (const login of report.logins) { + if (!known.logins.some((existing) => loginKey(existing) === loginKey(login))) + known.logins.push(login); + } } } } @@ -165,6 +180,7 @@ export class UsageSourceRegistry { z.object({ key: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/), label: z.string().optional(), + harness: z.string().min(1).optional(), input: z.json(), }), ) @@ -172,8 +188,12 @@ export class UsageSourceRegistry { for (const account of accounts) { const id = `${source.id}:${account.key}`; const known = reports.get(id); - if (known) known.inputs.push(account.input); - else reports.set(id, { source, inputs: [account.input], label: account.label }); + // COMPAT(usageLoginHarness): added in v0.11.0, remove after 2027-04-05 once plugin floor >= v0.11.0. + const login = { input: account.input, harness: account.harness ?? source.label }; + if (known) { + if (!known.logins.some((existing) => loginKey(existing) === loginKey(login))) + known.logins.push(login); + } else reports.set(id, { source, logins: [login], label: account.label }); } } catch (error) { this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed"); @@ -214,10 +234,14 @@ export class UsageSourceRegistry { } private fetchId(id: string, known: KnownReport, forceRefresh = false): Promise { - const cached = this.cache.get(id); + // Account identity groups cards; the ordered login set identifies a fetch result. + const cacheKey = `${id}:${createHash("sha256") + .update(JSON.stringify([known.label, known.logins.map(loginKey)])) + .digest("hex")}`; + const cached = this.cache.get(cacheKey); if (!forceRefresh && cached && this.now() - cached.at < this.ttlMs) return Promise.resolve(cached.entry); - const pending = this.pending.get(id); + const pending = this.pending.get(cacheKey); if (pending) return pending; const request = (async () => { const entry: UsageReportEntry = { @@ -227,14 +251,14 @@ export class UsageSourceRegistry { icon: known.source.icon, account: { label: known.label }, fetchedAt: new Date(this.now()).toISOString(), - report: await this.fetchBeforeDeadline(known), + ...(await this.fetchWithFallback(known)), }; - this.writeCache(id, entry); + if (this.sources.get(known.source.id) === known.source) this.writeCache(cacheKey, entry); return entry; })(); - this.pending.set(id, request); + this.pending.set(cacheKey, request); void request.finally(() => { - if (this.pending.get(id) === request) this.pending.delete(id); + if (this.pending.get(cacheKey) === request) this.pending.delete(cacheKey); }); return request; } @@ -247,7 +271,7 @@ export class UsageSourceRegistry { this.cache.set(id, { at, entry }); } - private async fetchBeforeDeadline(known: KnownReport): Promise { + private async fetchBeforeDeadline(source: UsageSource, input: unknown): Promise { let timer: ReturnType | undefined; const deadline = new Promise((resolve) => { timer = setTimeout( @@ -256,19 +280,29 @@ export class UsageSourceRegistry { ); }); try { - return await Promise.race([this.fetchWithFallback(known), deadline]); + return await Promise.race([this.fetchLogin(source, input), deadline]); } finally { clearTimeout(timer); } } - private async fetchWithFallback(known: KnownReport): Promise { - let report = await this.fetchLogin(known.source, known.inputs[0]); - for (const input of known.inputs.slice(1)) { - if (report.status === "available") break; - report = await this.fetchLogin(known.source, input); + private async fetchWithFallback( + known: KnownReport, + ): Promise> { + // Start each login within the same deadline window, then prefer discovery order. + // Sequential deadlines can exceed the client's RPC timeout before all errors arrive. + const attempts = known.logins.map((login) => + this.fetchBeforeDeadline(known.source, login.input), + ); + const loginErrors: NonNullable = []; + for (const [index, login] of known.logins.entries()) { + const report = await attempts[index]!; + if (report.status === "available") return { report }; + loginErrors.push({ harness: login.harness, report }); } - return report; + // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once app floor >= v0.11.0. + // Keep the final single report so older apps still parse and display failed accounts. + return { report: loginErrors[loginErrors.length - 1]!.report, loginErrors }; } private async fetchLogin(source: UsageSource, input: unknown): Promise { @@ -303,3 +337,12 @@ function legacyError(report: UsageReport, now: number): string | null { if (report.status === "unavailable") return legacyProblem(report.problem, now); return null; } + +// Inputs are opaque JSON locators. Canonical equality deduplicates discovery across scopes. +function loginKey(login: Login): string { + return JSON.stringify(login, (_key, value) => + value !== null && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) + : value, + ); +} diff --git a/plugins/claude-usage-source/server/keychain.test.ts b/plugins/claude-usage-source/server/keychain.test.ts index d85401037a..dd6c58f77a 100644 --- a/plugins/claude-usage-source/server/keychain.test.ts +++ b/plugins/claude-usage-source/server/keychain.test.ts @@ -141,6 +141,7 @@ describe("Claude credential routes", () => { expect(accounts).toEqual([ { key: "fixture-account.fixture-org", + harness: "Claude", input: { route: { store: "claude", path: join(dir, ".credentials.json") } }, }, ]); diff --git a/plugins/claude-usage-source/server/stores.test.ts b/plugins/claude-usage-source/server/stores.test.ts index 75248116cc..c830fca9cc 100644 --- a/plugins/claude-usage-source/server/stores.test.ts +++ b/plugins/claude-usage-source/server/stores.test.ts @@ -237,6 +237,7 @@ test("keychain identity belongs to its token even when Claude Code metadata name ); expect(identity).toEqual({ key: "pi-account.pi-org", + harness: "Claude", input: { route: { store: "keychain", service: "Claude Code-credentials", account: "fixture-user" }, }, diff --git a/plugins/claude-usage-source/server/usage.test.ts b/plugins/claude-usage-source/server/usage.test.ts index be2fd45b7f..29e78a54e9 100644 --- a/plugins/claude-usage-source/server/usage.test.ts +++ b/plugins/claude-usage-source/server/usage.test.ts @@ -915,6 +915,7 @@ it("keeps an expired Keychain login without calling the network and prefers Keyc expect(accounts).toEqual([ { key: expect.stringMatching(/^[a-f0-9]{64}$/), + harness: "Claude", input: { route: { store: "keychain", service: "Claude Code-credentials", account: "fixture" }, }, @@ -960,7 +961,11 @@ it.each([401, 403])("a rejected profile HTTP %i keeps a locator-keyed account", const api: typeof fetch = async () => new Response(null, { status }); const accounts = await discover({ kind: "global" }, lookup, api); expect(accounts).toEqual([ - { key: expect.stringMatching(/^[a-f0-9]{64}$/), input: credentialInput(lookup.claudeHome) }, + { + key: expect.stringMatching(/^[a-f0-9]{64}$/), + harness: "Claude", + input: credentialInput(lookup.claudeHome), + }, ]); let fetches = 0; expect( @@ -992,7 +997,9 @@ it.each([ return new Response(null, { status: 500 }); }; const accounts = await discover({ kind: "global" }, lookup, api); - expect(accounts).toEqual([{ key: hashAccountKey(JSON.stringify(input.route)), input }]); + expect(accounts).toEqual([ + { key: hashAccountKey(JSON.stringify(input.route)), harness: "Claude", input }, + ]); await expect(fetchUsage(accounts[0]!.input as UsageInput, api, lookup)).rejects.toThrow(error); }, ); @@ -1071,7 +1078,12 @@ it("session discovery reads only its Claude home and excludes foreign sessions", const lookup = { platform: "linux" as const, home: "/unused-default-home", env: {} }; const accounts = await discover(scope, lookup, api); expect(accounts).toEqual([ - { key: "session-account.org", label: "session@example.test", input: credentialInput(home) }, + { + key: "session-account.org", + label: "session@example.test", + harness: "Claude", + input: credentialInput(home), + }, ]); await discover(scope, lookup, api); expect(calls).toEqual(["https://api.anthropic.com/api/oauth/profile"]); diff --git a/plugins/claude-usage-source/server/usage.ts b/plugins/claude-usage-source/server/usage.ts index 72a82c1c8b..9efade2062 100644 --- a/plugins/claude-usage-source/server/usage.ts +++ b/plugins/claude-usage-source/server/usage.ts @@ -380,7 +380,10 @@ export async function discover( for (const input of candidates) { const credentials = await resolveClaudeCredentials(input, lookup); if (!credentials) continue; - const fallback = { key: hashAccountKey(JSON.stringify(input.route)), input }; + const harness = { pi: "Pi", omp: "OMP", claude: "Claude", keychain: "Claude" }[ + input.route.store + ]; + const fallback = { key: hashAccountKey(JSON.stringify(input.route)), harness, input }; if (credentials.expires !== undefined && credentials.expires <= (lookup.now ?? Date.now)()) { accounts.push(fallback); continue; @@ -391,7 +394,7 @@ export async function discover( fetchApi, lookup.now ?? Date.now, ); - accounts.push("status" in profile ? fallback : { ...profile, input }); + accounts.push("status" in profile ? fallback : { ...profile, harness, input }); } catch { // Keep the login visible; fetching usage reports the vendor failure. accounts.push(fallback); diff --git a/plugins/codex-usage-source/server/usage.test.ts b/plugins/codex-usage-source/server/usage.test.ts index 8222aae315..4cdefa2bcd 100644 --- a/plugins/codex-usage-source/server/usage.test.ts +++ b/plugins/codex-usage-source/server/usage.test.ts @@ -1,3 +1,4 @@ +import { UsageSourceRegistry } from "../../../packages/server/src/server/plugins/usage-sources/index.js"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -412,3 +413,55 @@ test("session discovery isolates CODEX_HOME and excludes foreign routes", async await rm(home, { recursive: true, force: true }); } }); + +test("#6155: real Codex and OpenCode stores share an account but keep agent failures separate", async () => { + await writeAuth("fixture-codex", "same-account"); + const data = join(fixtureHome, "data"); + await mkdir(join(data, "opencode"), { recursive: true }); + await writeFile( + join(data, "opencode", "auth.json"), + JSON.stringify({ + openai: { type: "oauth", access: "fixture-opencode", accountId: "same-account", expires: 0 }, + }), + ); + const env = { HOME: fixtureHome, CODEX_HOME: fixtureHome, XDG_DATA_HOME: data }; + const lookup = { home: fixtureHome, env, platform: "linux" as const, now: () => 1_000 }; + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => id === "agent", + usageSession: () => ({ provider: "codex", env, sessionKey: "launch" }), + }); + registry.register({ + id: "codex", + label: "Codex", + discover: (scope) => discover(scope, lookup), + fetch: (input) => + fetchUsage(inputSchema.parse(input), async () => new Response(null, { status: 401 }), lookup), + }); + const [host] = await registry.listReports(); + expect(host?.loginErrors).toEqual([ + { + harness: "Codex", + report: { + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "codex" }, + }, + }, + { + harness: "OpenCode", + report: { + status: "unavailable", + problem: { + kind: "expired", + expiresAt: "1970-01-01T00:00:00.000Z", + refreshedBy: "opencode", + }, + }, + }, + ]); + const [agent] = await registry.listReports({ agentId: "agent" }); + expect(agent?.report).toEqual({ + status: "unavailable", + problem: { kind: "rejected", status: 401, refreshedBy: "codex" }, + }); + expect(agent?.loginErrors).toEqual([host!.loginErrors![0]]); +}); diff --git a/plugins/codex-usage-source/server/usage.ts b/plugins/codex-usage-source/server/usage.ts index cf5207ff22..0b211726c6 100644 --- a/plugins/codex-usage-source/server/usage.ts +++ b/plugins/codex-usage-source/server/usage.ts @@ -72,7 +72,8 @@ export async function discover( const present: UsageAccount[] = []; for (const input of candidates) { const auth = await readAuth(input, lookup); - if (auth) present.push({ ...accountIdentity(auth, input), input }); + if (auth) + present.push({ ...accountIdentity(auth, input), harness: harnessLabel(input), input }); } return present; } @@ -294,3 +295,8 @@ function accountIdentity(auth: Auth, input: CodexUsageInput): { key: string; lab claimString(id?.["email"]); return { key: key ?? hashAccountKey(JSON.stringify(input.route)), ...(label ? { label } : {}) }; } + +function harnessLabel(input: CodexUsageInput): string { + const labels = { codex: "Codex", opencode: "OpenCode", pi: "Pi", omp: "OMP" }; + return labels[input.route.store]; +} diff --git a/plugins/muse-provider/server/usage.ts b/plugins/muse-provider/server/usage.ts index a8c77428d6..e3915c6ddd 100644 --- a/plugins/muse-provider/server/usage.ts +++ b/plugins/muse-provider/server/usage.ts @@ -16,6 +16,13 @@ import { usageSchema } from "./wire.js"; const inputSchema = z.object({ account: z.string() }).strict(); type Observation = z.infer; + +function accountKey(env: ProviderLaunch["env"]): string { + // MSP 1.4.1 has no stable account ID; swapping accounts in one config directory shares a report ID. + const configHome = env.XDG_CONFIG_HOME ?? path.join(env.HOME ?? homedir(), ".config"); + return hashAccountKey(path.resolve(configHome, "muse")); +} + export class Usage { private readonly launches = new Map(); private readonly sessions = new Map< @@ -23,10 +30,7 @@ export class Usage { { account: string; read: () => Promise } >(); remember(launch: ProviderLaunch): { account: string } { - // MSP 1.4.1 has no stable account ID; swapping accounts in one config directory shares a report ID. - const configHome = - launch.env.XDG_CONFIG_HOME ?? path.join(launch.env.HOME ?? homedir(), ".config"); - const key = hashAccountKey(path.resolve(configHome, "muse")); + const key = accountKey(launch.env); this.launches.set(key, launch); return { account: key }; } @@ -60,12 +64,21 @@ export class Usage { label: "Muse Code", icon: "icon.svg", input: inputSchema, - discover: async () => - [...this.launches.keys()].map((account) => ({ + discover: async (scope) => { + let accounts: string[]; + if (scope.kind === "global") { + accounts = [...this.launches.keys()]; + } else { + if (scope.provider !== "muse") return []; + const account = accountKey(scope.env); + accounts = this.launches.has(account) ? [account] : []; + } + return accounts.map((account) => ({ key: account, label: "Muse Code", input: { account }, - })), + })); + }, fetch: async (input) => { const { account } = inputSchema.parse(input); const launch = this.launches.get(account); diff --git a/plugins/muse-provider/test/provider.test.ts b/plugins/muse-provider/test/provider.test.ts index 447988e31b..598579d04e 100644 --- a/plugins/muse-provider/test/provider.test.ts +++ b/plugins/muse-provider/test/provider.test.ts @@ -1,4 +1,6 @@ import { execFile } from "node:child_process"; +import { UsageSourceRegistry } from "../../../packages/server/src/server/plugins/usage-sources/index.js"; +import { Usage as MuseUsage } from "../server/usage.js"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { setTimeout as delay } from "node:timers/promises"; import os from "node:os"; @@ -1415,7 +1417,7 @@ for (const usage of [ test(`usage source presents ${"usage" in usage ? "subscription windows" : "real route absence"} from discovered accounts`, async () => { const h = await harness("catalog-controls", { MUSE_TEST_USAGE: JSON.stringify(usage) }); await h.open(); - const inputs = await h.usageSource.discover(); + const inputs = await h.usageSource.discover({ kind: "global" }); expect(inputs).toHaveLength(1); const account = inputs[0]!; expect(account).toEqual({ @@ -1566,7 +1568,7 @@ test("usage identity follows the resolved config directory across credential and META_BASE_URL: route!, }); await h.open(); - const inputs = await h.usageSource.discover(); + const inputs = await h.usageSource.discover({ kind: "global" }); expect(inputs).toHaveLength(1); identities.push(inputs[0]!.key); } @@ -1576,7 +1578,7 @@ test("usage identity follows the resolved config directory across credential and test.each(["no sessions", "unrelated environment"])("Muse discovery is empty with %s", async () => { const { Usage } = await import("../server/usage.js"); - expect(await new Usage().registration().discover()).toEqual([]); + expect(await new Usage().registration().discover({ kind: "global" })).toEqual([]); }); test("Muse window identity follows the reported duration instead of assuming five hours", async () => { @@ -1591,7 +1593,7 @@ test("Muse window identity follows the reported duration instead of assuming fiv }), }); await h.open(); - const [account] = await h.usageSource.discover(); + const [account] = await h.usageSource.discover({ kind: "global" }); expect(await h.usageSource.fetch(account!.input)).toMatchObject({ status: "available", windows: [ @@ -1687,3 +1689,44 @@ async function cleanupStubbornHosts(h: Awaited>) { } } } + +test("Muse reports belong only to the session's account and remain available globally", async () => { + const usage = new MuseUsage(); + const homeEnv = { HOME: "/usage/home" }; + const configEnv = { HOME: "/usage/home", XDG_CONFIG_HOME: "/usage/work" }; + const accountIds = [homeEnv, configEnv].map((env, index) => { + const launch = { command: "muse", args: [], env }; + usage.attach(`session-${index}`, launch, async () => ({ + usage: { + observedAtMs: 1700000000000, + tier: "Pro", + window: { usedPercent: 11, resetsAtMs: 1700018000000, windowDurationMins: 120 }, + weekly: { usedPercent: 22, resetsAtMs: 1700604800000 }, + }, + })); + return `muse:${usage.remember(launch).account}`; + }); + const sessions = new Map([ + ["claude", { provider: "claude", env: homeEnv, sessionKey: "claude" }], + ["codex", { provider: "codex", env: configEnv, sessionKey: "codex" }], + ["muse-home", { provider: "muse", env: homeEnv, sessionKey: "muse-home" }], + ["muse-work", { provider: "muse", env: configEnv, sessionKey: "muse-work" }], + ["muse-unknown", { provider: "muse", env: { HOME: "/usage/unknown" }, sessionKey: "unknown" }], + ]); + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + registry.register(usage.registration()); + + expect((await registry.listReports()).map((report) => report.id)).toEqual(accountIds); + expect(await registry.listReports({ agentId: "claude" })).toEqual([]); + expect(await registry.listReports({ agentId: "codex" })).toEqual([]); + for (const [index, agentId] of ["muse-home", "muse-work"].entries()) { + const reports = await registry.listReports({ agentId }); + expect(reports.map((report) => report.id)).toEqual([accountIds[index]]); + expect(reports[0]!.report.status).toBe("available"); + } + expect(await registry.listReports({ agentId: "muse-unknown" })).toEqual([]); + expect((await registry.listReports()).map((report) => report.id)).toEqual(accountIds); +}); diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 760cb1857d..086f910599 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -342,6 +342,7 @@ A source implements two calls: interface UsageAccount { key: string; label?: string; + harness?: string; input: JsonValue; } @@ -375,25 +376,29 @@ type UsageProblem = | { kind: "no_quota"; detail: string }; ``` -`discover({ kind: "global" })` queries machine login stores, including expired logins. Session -scope queries only the login stores selected by that harness's resolved launch environment. -Return `[]` when no login exists or the session does not use your source. Never scan default stores -from session discovery or scan agents from global discovery. Discovery is a query, with no agent +Session discovery returns only the login that agent runs on. The agent popover uses only those +inputs; it never tries logins from global discovery or other agents. For any other provider, +return `[]`. Global discovery lists every account, including expired logins. + +Resolve the session account from that session's launch environment. Return `[]` when no login exists. +Never scan default stores from session discovery or scan agents from global discovery. Discovery is a query, with no agent lifecycle hooks. Closed agents have no session scope until resumed. Inputs name credential stores; never put credentials in inputs or reports. Paseo validates each input against your schema before calling `fetch()`. The same key in any scope is the same report; -agents sharing an account share the fetch cache. Fetches have a 20-second deadline. +fetches share a five-minute cache only when their ordered login inputs match. Each login fetch +has a 20-second deadline. Built-in session routes: -| Source | Session | Login store | -| ------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| Claude | `claude` | `CLAUDE_CONFIG_DIR`, or the default; on macOS, the directory's Keychain entry takes precedence | -| Claude | `pi`, `omp` with `anthropic/…` model | That harness's Anthropic login store | -| Codex | `codex` | `CODEX_HOME/auth.json`, or the default | -| Codex | `pi`, `opencode`, `omp` with `openai/…` model | That harness's OpenAI login store | -| Other sources | Any | No session discovery | +| Source | Session | Login store | +| ---------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| Claude | `claude` | `CLAUDE_CONFIG_DIR`, or the default; on macOS, the directory's Keychain entry takes precedence | +| Claude | `pi`, `omp` with `anthropic/…` model | That harness's Anthropic login store | +| Codex | `codex` | `CODEX_HOME/auth.json`, or the default | +| Codex | `pi`, `opencode`, `omp` with `openai/…` model | That harness's OpenAI login store | +| Muse | `muse` | That session's own Muse launch/config | +| Other built-in sources | Any | No session discovery | Claude excludes Bedrock, Vertex, and foreign `ANTHROPIC_BASE_URL` sessions. Codex excludes sessions with `OPENAI_BASE_URL` set. @@ -403,9 +408,13 @@ organization whose quota is metered and survives token rotation. Never use a cre email as the key. Use `hashAccountKey()` for sensitive stable identities or a store locator when account metadata is unavailable. Labels can name accounts without becoming their identity. -Return logins in preference order. Several entries with the same key become one card, with their -inputs tried in order until a report is `available`. An unavailable report, error report, or thrown -fetch falls through to the next input. If none succeeds, the card carries the last report. +Return logins in preference order and set `harness` to the owning harness's display name, such as +Codex, OpenCode, Pi, or OMP. The daemon treats inputs as opaque and never derives labels from them. + +The host-wide Usage screen groups logins with the same key into one account card and tries them +concurrently, preferring the discovery order when selecting a result. If any login returns +`available`, it shows usage with no errors. If all fail, it shows +every login's error on a separate line with its harness label and its own remedy. Discovery failures are logged by the daemon and produce no card. Window names must come from provider data: an explicit duration, a named API field such as @@ -485,7 +494,8 @@ metadata or JWT claims. Pi and OMP logins remain discoverable when expired. OMP `usage.list_reports` discovers accounts when called without IDs. With IDs, it refreshes known accounts without rediscovering identity. If a store switches accounts, the existing card shows the new login's quota until the next discovery. Reports are cached for five minutes; `forceRefresh` -bypasses that cache. Entries carry `id`, `account.label`, and `fetchedAt`. Clients gate the feature +bypasses that cache. Entries carry `id`, `account.label`, and `fetchedAt`. Failed accounts also carry optional +`loginErrors` entries with `harness` and each login's failed `report`. Clients gate the feature on `server_info.features.usageSources`. The older `provider.usage.list` RPC maps the same reports for 0.10 clients and renders problems into its `error` string.