diff --git a/CHANGELOG.md b/CHANGELOG.md index c8c102a..3a240c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ follows [Semantic Versioning](https://semver.org/). ### Added +- Add opt-in Muse Code subscription allowance through the official CLI's + read-only `usage/read` protocol. Show session and weekly windows with the + original observation time. Do not send prompts or read CLI credentials. + Honor saved opt-in in headless reports, cancel reads when stopped, retain + observation time in JSON and keep allowance out of the shared archive for + compatibility with older app versions on rollback. + Muse.ai Desktop import and native/live validation remain pending. - Show Grok Bot Desktop quota burn in percentage points per hour from recent same-account readings. Collect for at least a minute, discard old/reset observations and show an estimated run-out only when measured. @@ -45,6 +52,11 @@ follows [Semantic Versioning](https://semver.org/). ### Fixed +- Read known Claude plan labels from the available tier fields without showing + internal identifiers or guessing a plan from an unrelated substring. +- Accept duplicate Claude Desktop cache entries when their eligible access + tokens are identical. Distinct matching tokens still require a clear login; + an ambiguous-token failure preserves the selected CLI source. - Preload the tray usage window hidden after completed onboarding, before connection recovery or provider collection. The first click reuses the loaded renderer instead of starting its initial load. diff --git a/README.md b/README.md index daa2543..e53c0d3 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ through Settings → API connections with a saved legacy Admin key, a one-time Admin key, or the bundled environment-only helper. Existing Keychain entries are preserved; pasted keys are not saved. OpenRouter keeps its existing account balance and aggregate activity integration. +Unreleased source adds opt-in [Muse Code CLI allowance](docs/DESKTOP-CONNECTIONS.md#muse-code-cli). +Muse.ai cloud/Desktop connection is separate and remains pending. Build 54's Settings footer incorrectly says `0.2.41`; the packaged version is `0.3.0-beta.1`. See [known beta limits](docs/BETA.md#known-limits). @@ -72,7 +74,7 @@ fall back to compilation. See the [Swift installation guide](docs/swift/README.m ## Setup and data coverage -All nine provider adapters remain in the retained Swift and candidate source: +The original nine provider adapters remain in the retained Swift and candidate source: Codex, Claude Code, Antigravity, OpenRouter, Grok, OpenCode Go, Cursor, Copilot CLI and Gemini CLI. Their inputs and available measurements differ. The [Swift provider matrix](docs/swift/README.md#provider-matrix) describes existing diff --git a/docs/DESKTOP-CONNECTIONS.md b/docs/DESKTOP-CONNECTIONS.md index fba4417..7bc96d0 100644 --- a/docs/DESKTOP-CONNECTIONS.md +++ b/docs/DESKTOP-CONNECTIONS.md @@ -38,6 +38,11 @@ below it to apply the choice. Existing profiles keep their current source. Keychain prompt. The candidate requests account allowance using Desktop's current access token. This interface can change and is not a documented third-party consumer API contract. + Duplicate eligible cache entries with the same access token count as one + credential. Different matching tokens remain ambiguous; MeterUsage does not + choose the first token or the one with the latest expiry. An ambiguous-token + failure leaves the previously selected CLI source available. This handling has + synthetic fixture coverage; live cache compatibility remains unverified. Alternatively, choose **Claude Code CLI** and **Use CLI** to read local Claude Code activity. CLI allowance requires a companion usage file; this option does not provide Claude website token history. @@ -86,6 +91,32 @@ identify the cause of a live connection failure or prove that Claude Desktop connection works. Packaged banner display, Keychain access, Desktop compatibility and live allowance still require native acceptance. +## Muse Code CLI + +Unreleased Electron source adds a separate **Muse Code** row in Settings. +Sign in with the [official CLI](https://dev.meta.ai/products/muse-code), then +choose **Use Muse Code CLI**. Collection starts disabled and the choice survives +relaunch. Headless JSON collection also honors this choice. **Stop Muse Code** +cancels active reads and clears the in-memory allowance without signing out the CLI. +Demo mode provides invented allowance and blocks the setup action. Isolated +Codex and Claude test profiles exclude this provider. + +MeterUsage starts `muse serve`, initializes the official experimental MSP +surface and calls [`usage/read`](https://meta-models.github.io/muse-code-sdk/next/generated/msp/methods/usage-read/). +It never starts a model turn or opens CLI credentials. The method returns the +host's last observation, which can be absent. Missing data is unavailable, +never zero. An unsupported CLI must be upgraded separately. Cards show the +original observation time, session and weekly percentages, and reported resets. +They provide no local token history or cost estimate. +The JSON quota report includes `observed_at` for Muse Code, separate from the +report's `generated_at`. Muse allowance stays in memory and is excluded from the +shared quota archive, so older versions can still load other providers on rollback. + +Muse.ai's cloud agent and Desktop app use a separate subscription and session. +The Muse Code connection does not provide Muse.ai allowance. Native Muse.ai +session import remains pending verification of its store and encryption schema. +Source tests do not prove native or live-provider behavior. + ## Data coverage Account allowance comes from the provider account service. It can reflect use diff --git a/docs/KB.md b/docs/KB.md index 2348e37..87b8422 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -41,6 +41,27 @@ Release documentation refresh: 2026-10-16; build 54 source review is unchanged. ## Product and source facts +- Electron Claude plan labels recognize known organization, seat and user tiers + in that order. An unknown higher-priority value permits a known fallback; + unknown-only metadata stays unavailable instead of exposing internal labels. + Claude Desktop treats identical eligible access tokens as one credential, + while distinct tokens remain ambiguous. Ambiguous-token Desktop connection + failures preserve selected CLI collection. These changes have synthetic fixture proof; live + Desktop compatibility remains separate. + +- Unreleased Electron source adds opt-in Muse Code allowance with `muse serve` + and the official experimental MSP `usage/read` method. The source starts + disabled, reads no credential files and sends no model turn. It preserves + observation age and percentages, including provider values above 100, while + rejecting absent, expired or malformed observations. Settings, demo cards, + menu-bar/notch provider ordering and JSON quota reporting use the separate + `muse` provider. Saved opt-in also gates headless collection; stopping cancels + active reads and invalidates allowance. JSON includes `observed_at`, and + allowance stays in memory, outside the shared archive, to preserve rollback + compatibility. Muse.ai native session import remains unimplemented; no + verified native store path or schema is available. Native/live validation + remains separate. See [ADR 0033](adr/0033-muse-code-subscription-allowance.md). + - Unreleased Antigravity Desktop setup reuses the existing connection controls, preferences, IPC and account-bound source. It requires one running same-user desktop language server with a CSRF token and PID-owned IPv4 loopback port. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 7944170..5263e76 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -16,6 +16,7 @@ cutover acceptance remain pending. See [ADR 0010](adr/0010-macos-typescript-elec | Provider | Read or request | Retained data | | --- | --- | --- | | Codex | `codex app-server --listen stdio://` initializes the CLI and calls `account/rateLimits/read`. The CLI uses its own existing authentication. Local rollout headers, model context and cumulative token-ledger events are selected from `sessions/**/*.jsonl`. | Quota windows/groups, plan, balances, reset credits, timestamps, numeric tokens and model. Session paths become opaque IDs; workspace directories become basenames. | +| Muse Code, unreleased Electron source | Opt-in `muse serve` subprocess using existing official CLI authentication. MSP initialization enables experimental methods, then `usage/read` reads the host's last observation. No model session, prompt, login or logout is started. MeterUsage does not open Muse credential files. | Session and weekly allowance percentages, reset timestamps, session duration, a restricted tier identifier and the provider observation timestamp. Missing or expired observations remain unavailable. No token ledger, cost or Muse.ai allowance is inferred. | | Claude | Local transcript usage fields under `projects/`, three tier keys from `.claude.json`, and optional companion quota JSON. | Numeric tokens/cost estimates, model, dates, project basename, plan tier and quota windows. Transcript message content and identifying tier metadata are skipped. | | OpenRouter | Existing `OPENROUTER_API_KEY` or supported local key file for `/api/v1/key` and `/api/v1/credits`. An existing management key, with configured API-key fallback, is used for `/api/v1/activity`. | Aggregate usage, balances, limits, dates, request counts and token totals. | | OpenCode Go | Existing `opencode-go` key in the local auth store for `https://opencode.ai/zen/go/v1/usage`. Read-only session SQL, with `opencode db --format json` fallback, selects tokens, cost, message counts, dates and directory. | Quota windows, numeric usage and project basename. No message bodies, prompts or tool arguments are selected. | @@ -23,6 +24,10 @@ cutover acceptance remain pending. See [ADR 0010](adr/0010-macos-typescript-elec | Antigravity | Existing agy container CLI `/usage`; numeric fields from conversation SQLite/protobuf stores, native first and container-volume fallback. Older stores use only history conversation ID/timestamp fields. | Model-group quota, numeric token totals, turn times and aggregate counts. Older history has unknown tokens. No prompt/tool payload or workspace text is decoded. | | Cursor, Copilot, Gemini | Existing local quota/usage JSON candidates and local presence checks. | Snapshot quota windows when present. These readers do not fetch cloud quota or provide a token ledger. Presence alone never becomes a zero-usage reading. | +Muse Code allowance stays in memory and is not written to the shared quota +archive. This preserves archive compatibility with older app versions. Its +collection preference remains saved; allowance is read again after relaunch. + Codex and Claude additional accounts use separate user-selected configuration directories. Enabled existing directories get distinct sources; an absent or disabled directory is not polled. An additional Claude source never falls back @@ -103,7 +108,9 @@ transport; do not run the helper interactively or record its output. The main process decrypts `oauth:tokenCacheV2` from Claude Desktop's `config.json` and reads only the active organization cookie from a read-only cookie database. It selects account-scoped current access-token/expiry fields, skips refresh -tokens, and rejects ambiguous, legacy or expired entries. It never writes to +tokens, and rejects ambiguous, legacy or expired entries. Identical tokens in +eligible entries count as one credential; distinct tokens remain ambiguous. +It never writes to Claude stores or rotates their tokens. The access token authenticates a GET to diff --git a/docs/adr/0033-muse-code-subscription-allowance.md b/docs/adr/0033-muse-code-subscription-allowance.md new file mode 100644 index 0000000..51be72b --- /dev/null +++ b/docs/adr/0033-muse-code-subscription-allowance.md @@ -0,0 +1,46 @@ +# ADR 0033: Read Muse Code allowance through the official CLI + +- Status: Source implementation; native and live-provider acceptance pending +- Date: 2026-10-17 + +Add an opt-in Electron provider named Muse Code with the internal key `muse`. +Keep it distinct from Muse.ai's cloud agent and Desktop subscription. Both +products have usage limits, but their credentials and allowances do not establish +a shared account or combined budget. + +Reuse the bounded subprocess transport. Start the installed official +`muse serve`, initialize MSP with experimental methods enabled, then call +`usage/read`. Do not start a session or model turn, change authentication, read +credential files or add an SDK dependency. The official method supplies the +host's last observation without a model call. + +Retain session and weekly percentages, reset times, session duration, a bounded +tier identifier and `observedAtMs`. Preserve percentages above 100 as the wire +contract permits them. Clamp display geometry independently. Reject missing, +expired and invalid values. Show the observation time so polling cannot make +an old observation appear newly measured. No token history or cost is inferred. + +Settings exposes explicit start/stop actions. Existing preference validation, +provider ordering, cards, quota reporting and demo composition serve the new +provider. It remains excluded from isolated Codex/Claude live profiles. Stopping +collection does not sign the user out of Muse Code. +The saved choice also gates headless collection. Source removal cancels active +reads, and a later start uses a new source to reject results from the old one. +Clear in-memory allowance on each change. The schema-1 JSON report adds Muse Code's +`observed_at` without changing other providers. Keep Muse allowance in memory and +exclude it from the shared quota archive. Legacy Swift and Electron decoders +reject an archive containing an unknown provider, so writing Muse there would +discard all cached quota on rollback. Other providers keep their existing format +and reset-pacing state. Muse allowance is collected again after relaunch. + +Muse.ai Desktop import needs independent evidence for the native session store, +active-account selection and encryption. Do not infer those from a bundle ID or +use Muse Code authentication as a substitute. + +Verify with synthetic MSP subprocesses, numeric/projection tests, composition +and renderer checks, TypeScript checking and production bundling. Native app and +live-provider validation remain distinct gates. + +References: [official product](https://dev.meta.ai/products/muse-code), +[`usage/read`](https://meta-models.github.io/muse-code-sdk/next/generated/msp/methods/usage-read/), +[usage window schema](https://meta-models.github.io/muse-code-sdk/next/generated/msp/types/subscriptionusagewindow/). diff --git a/docs/adr/README.md b/docs/adr/README.md index 061ddac..34ee187 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -44,3 +44,4 @@ compatibility and acceptance requirements remain in force. The | [0031](0031-grok-quota-burn.md) | Measure Grok Bot quota burn from recent same-account percentages without inventing token counts or cycle duration. | Source implementation; native and live rate acceptance separate | | [0032](0032-one-time-api-setup.md) | Separate API setup from account connections and accept a one-time Admin key for explicit billing fetches, without saving credentials. | Source implementation; real billing acceptance pending | +| [0033](0033-muse-code-subscription-allowance.md) | Read opt-in Muse Code allowance through official MSP without a model turn; preserve observation age and keep Muse.ai separate. | Source implementation; native/live acceptance pending | diff --git a/docs/electron/README.md b/docs/electron/README.md index bace21e..15d139c 100644 --- a/docs/electron/README.md +++ b/docs/electron/README.md @@ -29,6 +29,7 @@ Usage. See [ADR 0023](../adr/0023-onboarding-source-choice.md). | Grok Bot Desktop (Beta), unreleased source | Explicit consent reads the active account-bound encrypted Bot credential and requests allowance. | Native Keychain/store and endpoint compatibility remain unverified. Existing saved Cursor connections retain their source. | | OpenCode Go local | **Use local OpenCode** enables local Go sessions without a sign-in or key. Existing Go authentication also supplies allowance. | Go sessions only. Other providers used through OpenCode are excluded. | | Grok CLI (Beta) | Existing CLI authentication and local session summaries. | Consumer website/mobile sign-in is unsupported. Session/message counts do not establish token totals. | +| Muse Code CLI, unreleased source | Explicit **Use Muse Code CLI** enables read-only subscription observations through an existing official CLI. | Last-observed session/weekly allowance only. No local activity, token totals or Muse.ai cloud allowance. Native/live validation remains pending. | **Use CLI** ends only MeterUsage-owned account state and waits for successful cleanup before enabling local collection. Failed cleanup blocks the switch. @@ -39,7 +40,7 @@ to local collection. See [desktop connections](../DESKTOP-CONNECTIONS.md) and ### Existing provider inputs -All nine adapters remain wired in [composition](../../src/main/composition.ts). +The original nine adapters and the unreleased Muse Code reader are wired in [composition](../../src/main/composition.ts). The current source adds an OpenCode Go local setup action and Antigravity Desktop connection. These changes are separate from the published build 54. diff --git a/docs/plans/cliproxy-hub.md b/docs/plans/cliproxy-hub.md new file mode 100644 index 0000000..38b1c88 --- /dev/null +++ b/docs/plans/cliproxy-hub.md @@ -0,0 +1,52 @@ +# CLIProxyAPI Hub connection + +Status: Research complete; implementation needs an existing saved-history source. + +## Goal and source contract + +Connect MeterUsage to an existing CLIProxyAPI history source and show retained +token activity grouped by provider and upstream account. Keep proxy activity +separate from local transcripts and subscription allowance to avoid double +counting or assigning activity to an unrelated account. + +CLIProxyAPI 8.0.35 exposes a usage queue that removes records when read. It does +not expose the legacy historical snapshot endpoints. This integration must not +consume that queue, change proxy configuration, or install a new collector. +A saved export or a documented history service must be identified first. + +Sources: [v8 usage contract](https://help.router-for.me/management/apiv8#usage), +[v8.0.35 routes](https://github.com/router-for-me/CLIProxyAPI/blob/ae3ee3be303fb07fb8173279923413005bf32177/internal/api/server_management_v8.go), +[legacy snapshot format](https://github.com/router-for-me/CLIProxyAPI/blob/db63f9b5d60e643ca060901cfe67ffc5ce63ac63/internal/api/handlers/management/usage.go). + +## Proposed minimum + +1. Verify the saved source's format, retention and non-destructive read contract. + Do not advertise complete history where coverage is unknown. +2. Add one explicit Hub connection in Settings. For an authenticated HTTP source, + keep its management key in main-process memory and OS-backed encrypted + storage; fail closed without secure storage. Reject redirects and credentials + embedded in URLs. Source details must precede transport implementation. +3. Read one bounded snapshot per refresh. Join reported `auth_index` values to + projected provider metadata where the source supplies that mapping. Keep + unknown or ambiguous identities in an unattributed group; model aliases are + not evidence of a subscription account. +4. Publish only numeric usage, dates and reduced display labels. Never publish + client API keys, credential files, email addresses, raw errors or headers. + Request counts must remain requests rather than sessions or messages. +5. Reuse the account heatmap's unavailable-day handling. Use reported total + tokens when component counters can overlap. Replace snapshots rather than + adding repeated cumulative totals. Do not merge Hub and local token totals. + +## Verification and limits + +Synthetic checks must cover authentication failures, cancellation, source +replacement, payload limits, identity ambiguity, missing dates, snapshot reset, +overlapping token categories and repeated polling. Renderer checks must verify +provider/account labels, unavailable history and separate local/Hub scope. +Native secure storage and the actual history source need independent execution +proof before runtime support is claimed. + +No Hub adapter or credential store is implemented by this plan. The existing +local Claude heatmap and Desktop allowance remain separate data sources. No +server deployment, queue consumer, provider login or app installation follows +from this plan. diff --git a/docs/plans/muse-connections.md b/docs/plans/muse-connections.md new file mode 100644 index 0000000..42ce689 --- /dev/null +++ b/docs/plans/muse-connections.md @@ -0,0 +1,31 @@ +# Muse connections + +Add separate opt-in Electron providers for Muse.ai account allowance and Muse +Code subscription allowance. Preserve existing providers, account sources and +the retained Swift app. Success requires synthetic connection regression tests, +TypeScript checking and production bundling. Native sign-in and macOS execution +need separate proof. + +Muse Code uses its existing signed-in `muse serve` process over stdio. Initialize +MSP, then call `usage/read` without starting a model turn or reading credentials. +Keep the provider's observation timestamp. An absent observation remains +unavailable. See the [official protocol](https://meta-models.github.io/muse-code-sdk/next/generated/msp/methods/usage-read/). + +Muse.ai has a separate allowance and authentication boundary. Verify its +desktop session source and subscription response before implementing its reader. +Do not infer a native cookie location from an application identifier. + +1. Add the Muse Code reader under `src/main/providers/`, register its provider + and source, and add an explicit Settings action. Test the protocol exchange, + missing data, invalid numeric fields, timeout and cancellation. +2. Add Muse.ai only against an evidenced session and allowance contract. Reuse + connection consent, cancellation, disconnect and source invalidation. Test + account changes, expired sessions, failed saves and late responses. +3. Add synthetic demo readings and update `docs/PRIVACY.md`, `docs/KB.md`, + connection instructions and `CHANGELOG.md`. Review the complete diff and run + `pnpm typecheck`, `pnpm test` and `pnpm build` with the declared Node version. + +Both providers start disabled. They never share quota windows or imply a token +ledger from a percentage. No CLI installation, provider logout, paid action, +inference, app installation or release is part of this change. Disabling the +providers is the rollback path. diff --git a/src/domain/models.ts b/src/domain/models.ts index 82a8fe0..98cf3ae 100644 --- a/src/domain/models.ts +++ b/src/domain/models.ts @@ -1,8 +1,8 @@ -export const providers = ["codex", "antigravity", "grok", "openCodeGo", "openRouter", "claude", "cursor", "copilot", "gemini"] as const; +export const providers = ["codex", "antigravity", "grok", "openCodeGo", "openRouter", "claude", "cursor", "copilot", "gemini", "muse"] as const; export type Provider = typeof providers[number]; export const providerNames: Record = { codex: "Codex", antigravity: "Antigravity", grok: "Grok", openCodeGo: "OpenCode Go", - openRouter: "OpenRouter", claude: "Claude", cursor: "Cursor", copilot: "Copilot CLI", gemini: "Gemini CLI", + openRouter: "OpenRouter", claude: "Claude", cursor: "Cursor", copilot: "Copilot CLI", gemini: "Gemini CLI", muse: "Muse Code", }; export interface Slot { provider: Provider; slotID: string; label: string } export interface ManagedAccount { id: string; provider: "codex" | "claude"; label: string; path: string; enabled: boolean } @@ -33,6 +33,7 @@ export const isSessionWindow = (w: QuotaWindow) => w.label.toLowerCase() === "5- export function headlineWindow(provider: Provider, windows: QuotaWindow[]): QuotaWindow | undefined { if (provider === "codex") return windows.find(isSessionWindow) ?? (windows.length === 1 ? windows[0] : undefined); if (provider === "claude") return windows.find(isSessionWindow) ?? windows.find(w => w.label === "7-day" || w.label === "Weekly · All models"); + if (provider === "muse") return windows.find(isSessionWindow); if (provider === "openCodeGo") return windows.find(w => w.label.toLowerCase() === "rolling"); if (["cursor", "copilot", "gemini"].includes(provider)) { const session = windows.find(isSessionWindow); if (session) return session; } return windows.reduce((max, w) => !max || w.usedPercent > max.usedPercent ? w : max, undefined); diff --git a/src/domain/report.ts b/src/domain/report.ts index c05d667..9482e25 100644 --- a/src/domain/report.ts +++ b/src/domain/report.ts @@ -3,7 +3,7 @@ import { pace, effectivePace, etaSeconds, formatETA, paceText } from "./pacing"; export const iso = (at: number) => new Date(at).toISOString().replace(/\.\d{3}Z$/, "Z"); export interface WindowReport { label: string; used_percent: number; remaining_percent: number; resets_at?: string; eta_seconds?: number; eta_text?: string; pacing?: string; burn_rate?: number } -export interface ProviderReport { provider: Provider; account?: string; status: "ok" | "unavailable"; reason?: string; plan?: string; windows: WindowReport[]; credits?: { balance: number; unit: "credits" | "dollars"; dollar_balance?: number } } +export interface ProviderReport { provider: Provider; account?: string; status: "ok" | "unavailable"; reason?: string; plan?: string; observed_at?: string; windows: WindowReport[]; credits?: { balance: number; unit: "credits" | "dollars"; dollar_balance?: number } } export interface LimitsReport { schema: 1; generated_at: string; providers: ProviderReport[] } export function limitsReport(order: Slot[], quotas: Record>, burns: Record = {}, now = Date.now()): LimitsReport { return { @@ -16,6 +16,7 @@ export function limitsReport(order: Slot[], quotas: Record const q = state.value; return { provider: s.provider, ...account, status: "ok", ...(q.planType === undefined ? {} : { plan: q.planType }), + ...(s.provider === "muse" ? { observed_at: iso(q.capturedAt) } : {}), windows: q.windows.map(w => { const raw = pace(w, now), p = raw && effectivePace(raw, burns[slotKey(s)], now); const eta = p && etaSeconds(p, w.resetsAt, now); diff --git a/src/main/composition.ts b/src/main/composition.ts index 4f87a32..b4568f2 100644 --- a/src/main/composition.ts +++ b/src/main/composition.ts @@ -10,6 +10,7 @@ import { cliPath, cliEnvironment, codexRPC, runCommand, httpTransport, endpoints import { AntigravityRuntime, runtimeCandidates } from "./providers/antigravity"; import { scanGrok, openRouterUsage, openCodeUsage, parseOpenCodeRows, openCodeQuery } from "./providers/usage"; import { demoSources } from "./demo"; +import { museCodeQuota, museCodeRPC } from "./providers/muse-code"; import type { DesktopConnections } from "./connections"; export interface Source { accountBound?: boolean; @@ -92,7 +93,10 @@ export function compose(launch: Launch, prefs: Preferences, options: { command?: quota: signal => options.connections ? options.connections.quota("antigravity", signal, antigravityIdentity) : Promise.reject(new Unavailable("notSignedIn", "Antigravity Desktop")), } : { slot: primary("antigravity"), quota: signal => runtime.quota(now(), signal), usage: signal => runtime.usage(join(launch.home, ".gemini/antigravity-cli"), now(), signal) }; const grokIdentity = String(prefs.values.desktopGrokIdentity); - const sources: Source[] = [codex, { + const muse: Source[] = prefs.enabled("muse") ? [{ + slot: primary("muse"), accountBound: true, quota: async signal => museCodeQuota(await museCodeRPC(cliPath("muse", launch.home, env), { env, signal }), now()), + }] : []; + const sources: Source[] = [codex, ...muse, { slot: primary("openRouter"), quota: async signal => { const key = openRouterKey(launch); if (!key) throw new Unavailable("dataNotFound", "OpenRouter API key"); // Start the optional deadline with the required request, inside its 30s budget. diff --git a/src/main/coordinator.ts b/src/main/coordinator.ts index ec8b828..a5f049d 100644 --- a/src/main/coordinator.ts +++ b/src/main/coordinator.ts @@ -41,7 +41,7 @@ export class Coordinator { private evaluator = new AlertEvaluator(); private timer?: ReturnType; private stopped = false; - private controllers = new Set(); + private controllers = new Map(); private confirmations = new Map(); private resets = new Map(); private currentSources: readonly Source[]; @@ -54,6 +54,7 @@ export class Coordinator { set sources(next: readonly Source[]) { for (const source of this.currentSources) if (!next.includes(source)) { const key = slotKey(source.slot); + for (const [controller, owner] of this.controllers) if (owner === source) controller.abort(); this.resets.get(key)?.abort(); for (const readings of [this.accountActivities, this.quotas, this.activities, this.usages, this.plans]) delete readings[key]; for (const kind of ["accountActivity", "quota", "activity", "usage", "plan"]) this.backoffs.delete(`${kind}-${key}`); @@ -87,7 +88,7 @@ export class Coordinator { publish() { const state = this.snapshot(); for (const observe of this.observers) observe(state); } start() { this.stopped = false; this.restartTimer(); return this.refresh(); } restartTimer() { if (this.timer) clearInterval(this.timer); this.timer = setInterval(() => { void this.refresh(true); }, this.preferences.refreshInterval * 1000); } - stop() { this.stopped = true; if (this.timer) clearInterval(this.timer); this.timer = undefined; this.pendingForced = false; for (const c of this.controllers) c.abort(); for (const c of this.resets.values()) c.abort(); this.confirmations.clear(); } + stop() { this.stopped = true; if (this.timer) clearInterval(this.timer); this.timer = undefined; this.pendingForced = false; for (const c of this.controllers.keys()) c.abort(); for (const c of this.resets.values()) c.abort(); this.confirmations.clear(); } refreshIfStale() { return this.lastRefreshedAt === undefined || this.now() - this.lastRefreshedAt > 20000 ? this.refresh() : Promise.resolve(); } refresh(scheduled = false, onlyKey?: string): Promise { if (this.stopped) return Promise.resolve(); @@ -155,7 +156,7 @@ export class Coordinator { const key = kind === "status" ? source.slot.provider : slotKey(source.slot), backoffKey = `${kind}-${key}`, prior = this.backoffs.get(backoffKey); if (!force && prior && this.now() < prior.next) return; const resetAtStart = this.archived[key]?.resetPacingSince; - const controller = new AbortController(); this.controllers.add(controller); + const controller = new AbortController(); this.controllers.set(controller, source); const timeout = setTimeout(() => controller.abort(), 30000); const aborted = new Promise((_resolve, reject) => controller.signal.addEventListener("abort", () => reject(new Error("Source cancelled")), { once: true })); let result: Loaded; diff --git a/src/main/demo.ts b/src/main/demo.ts index f19538a..89daebf 100644 --- a/src/main/demo.ts +++ b/src/main/demo.ts @@ -15,6 +15,7 @@ function demoQuota(p: Provider, now: number): Quota { if (p === "openRouter") return { ...quota(p, [w("Monthly", 25.5)], now), credits: { balance: 74.5, hasCredits: true, unlimited: false, unit: "dollars", usedDollars: 25.5, limitDollars: 100 } }; if (p === "openCodeGo") return { ...quota(p, [w("Rolling", 87, 1.9 * 3600), w("Weekly", 77, 26 * 3600), w("Monthly", 44, 22.4 * 86400)], now), planType: "go" }; if (p === "grok") return { ...quota(p, [w("Weekly", 31, 2.2 * 86400)], now), planType: "X Premium" }; + if (p === "muse") return { ...quota(p, [w("Session", 24, 3 * 3600, 300), w("Weekly", 46, 3 * 86400, 10080)], now - 60000), planType: "power" }; if (p === "antigravity") { const q = quota(p, [w("Gemini Weekly", 89, 22 * 3600 + 24 * 60, 10080), w("Gemini 5-hour", 9, 4 * 3600 + 26 * 60, 300), w("Claude/GPT Weekly", 1, 22 * 3600 + 19 * 60, 10080), w("Claude/GPT 5-hour", 0, 4 * 3600 + 59 * 60, 300)], now); q.groups = ["Gemini Models", "Claude and GPT models"].map((title, i) => ({ title, id: title.toLowerCase(), windows: q.windows.slice(i * 2, i * 2 + 2).map((w, index) => ({ ...w, label: index ? "5-hour limit" : "Weekly limit" })) })); return q; @@ -27,7 +28,7 @@ function demoActivity(p: "codex" | "claude", now: number): Activity { return { provider: p, sessions, daily, scannedAt: now, telemetry: telemetry(sessions.map(s => ({ startedAt: s.startedAt, tokens: totalTokens(s.tokens), messageCount: s.messageCount })), daily, now) }; } export function demoSources(now: () => number, accounts: ManagedAccount[] = []): Source[] { - const slots = [primary("claude"), second("claude"), primary("codex"), second("codex"), ...(["openRouter", "openCodeGo", "grok", "antigravity"] as const).map(primary), ...accounts.filter(a => a.enabled).map(accountSlot)]; + const slots = [primary("claude"), second("claude"), primary("codex"), second("codex"), ...(["openRouter", "openCodeGo", "grok", "antigravity", "muse"] as const).map(primary), ...accounts.filter(a => a.enabled).map(accountSlot)]; return slots.map(slot => { const consumed = new Set(); const source: Source = { slot, quota: async () => { const q = demoQuota(slot.provider, now()); if (slot.provider === "codex") { q.resetCredits = q.resetCredits.filter(c => !consumed.has(c.id)); q.resetCreditCount = q.resetCredits.length; if (consumed.size) { q.windows = q.windows.map(w => ({ ...w, usedPercent: 0 })); q.groups[0].windows = q.windows; } } return q; } }; diff --git a/src/main/electron.ts b/src/main/electron.ts index cf3850c..4616158 100644 --- a/src/main/electron.ts +++ b/src/main/electron.ts @@ -274,6 +274,10 @@ async function start(launch: Launch) { if (app.getLoginItemSettings().openAtLogin !== r.value) throw new Error("Could not change login item"); } await prefs.set(r.key, r.value); + if (r.key === "showProviderMuse") { + coordinator.forgetQuota("muse"); + coordinator.sources = [...coordinator.sources.filter(s => s.slot.provider !== "muse"), ...sources().filter(s => s.slot.provider === "muse")]; + } if (r.key === "onboardingCompleted" && r.value === true && surface === "settings" && !quitting && !source.isDestroyed()) { source.hide(); showFlyout(); } if (r.key === "panelSize") sizeFlyout(); if (r.key === "refreshIntervalSeconds") coordinator.restartTimer(); diff --git a/src/main/history.ts b/src/main/history.ts index 176d88c..a8110c1 100644 --- a/src/main/history.ts +++ b/src/main/history.ts @@ -115,5 +115,7 @@ export function readArchive(path: string): Record { } catch { return {}; } } export function saveArchive(path: string, readings: { slot: Slot; quota: Quota }[]) { - atomicJSON(path, readings.map(({ slot: s, quota: q }) => ({ provider: s.provider, slotID: s.slotID || undefined, label: s.label || undefined, capturedAt: toDate(q.capturedAt), windows: q.windows.map(encodeWindow), groups: q.groups.length ? q.groups.map(g => ({ ...g, windows: g.windows.map(encodeWindow) })) : undefined, credits: q.credits && { ...q.credits, unit: { [q.credits.unit]: {} } }, resetCreditCount: q.resetCreditCount, resetCredits: q.resetCredits.length ? q.resetCredits.map(c => ({ ...c, expiresAt: toDate(c.expiresAt) })) : undefined, planType: q.planType, resetPacingSince: toDate(q.resetPacingSince) }))); + // Legacy Swift/Electron readers reject the entire file for an unknown provider. + // Muse allowance stays in memory so rollback retains every other quota. + atomicJSON(path, readings.filter(({ slot }) => slot.provider !== "muse").map(({ slot: s, quota: q }) => ({ provider: s.provider, slotID: s.slotID || undefined, label: s.label || undefined, capturedAt: toDate(q.capturedAt), windows: q.windows.map(encodeWindow), groups: q.groups.length ? q.groups.map(g => ({ ...g, windows: g.windows.map(encodeWindow) })) : undefined, credits: q.credits && { ...q.credits, unit: { [q.credits.unit]: {} } }, resetCreditCount: q.resetCreditCount, resetCredits: q.resetCredits.length ? q.resetCredits.map(c => ({ ...c, expiresAt: toDate(c.expiresAt) })) : undefined, planType: q.planType, resetPacingSince: toDate(q.resetPacingSince) }))); } diff --git a/src/main/providers/claude-desktop.ts b/src/main/providers/claude-desktop.ts index 0ec4e6a..e5f9be1 100644 --- a/src/main/providers/claude-desktop.ts +++ b/src/main/providers/claude-desktop.ts @@ -43,12 +43,13 @@ export function desktopCredential(config: string, organization: string, password const entry = object(value), scopes = match?.[4].split(/\s+/); return match && match[1].toLowerCase() === account && uuid.test(match[2]) && match[3].toLowerCase() === organization.toLowerCase() && scopes?.includes("user:profile") && scopes.includes("user:inference") && typeof entry.expiresAt === "number" && Number.isFinite(entry.expiresAt) && entry.expiresAt > now + 120000 && !!text(entry.token); }); - // Fail closed for ambiguous caches instead of guessing which login to use. - if (candidates.length !== 1) { - if (candidates.length > 1) reading?.("ambiguous"); + // Identical eligible tokens are one credential; distinct tokens stay ambiguous. + const tokens = [...new Set(candidates.map(([, value]) => object(value).token))]; + if (tokens.length !== 1) { + if (tokens.length > 1) reading?.("ambiguous"); throw unavailable(); } - return { token: text(object(candidates[0][1]).token)!, identity: createHash("sha256").update(account + ":" + organization.toLowerCase()).digest("hex") }; + return { token: text(tokens[0])!, identity: createHash("sha256").update(account + ":" + organization.toLowerCase()).digest("hex") }; } finally { plaintext.fill(0); } } catch { throw unavailable(); } } diff --git a/src/main/providers/muse-code.ts b/src/main/providers/muse-code.ts new file mode 100644 index 0000000..af77b73 --- /dev/null +++ b/src/main/providers/muse-code.ts @@ -0,0 +1,57 @@ +import { Unavailable, quota, type QuotaWindow } from "../../domain/models"; +import { selectJSON } from "../select-json"; +import { object } from "./parsers"; +import { boundedChild, type CommandOptions } from "./transport"; + +const unavailable = () => new Unavailable("noData", "Muse Code"); +const usageShape = { observedAtMs: true, tier: true, window: { usedPercent: true, resetsAtMs: true, windowDurationMins: true }, weekly: { usedPercent: true, resetsAtMs: true } } as const; +const integer = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; + +// MSP's percentages can exceed 100. Preserve the reported value; display rings +// clamp their geometry independently of the numeric reading. +export function museCodeQuota(raw: string, now: number) { + try { + const response = object(selectJSON(raw, { result: { usage: usageShape }, error: { code: true } }, undefined, true)); + if (response.error) throw unavailable(); + const usage = object(object(response.result).usage); + if (!integer(usage.observedAtMs) || usage.observedAtMs === 0 || usage.observedAtMs > now) throw unavailable(); + const readWindow = (raw: unknown, label: string, duration?: number): QuotaWindow => { + const value = object(raw), mins = duration ?? value.windowDurationMins; + if (!integer(value.usedPercent) || !integer(value.resetsAtMs) || value.resetsAtMs <= now || value.resetsAtMs > 8640000000000000 || !integer(mins) || mins === 0) throw unavailable(); + return { label, usedPercent: value.usedPercent, resetsAt: value.resetsAtMs, windowDurationMins: mins }; + }; + const current = readWindow(usage.window, "Session"), weekly = readWindow(usage.weekly, "Weekly", 10080); + const result = quota("muse", [current, weekly], usage.observedAtMs); + if (typeof usage.tier === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(usage.tier)) result.planType = usage.tier; + return result; + } catch { throw unavailable(); } +} + +// Read only the host's observation. No session, model turn, login or logout is +// started, and authentication remains inside the installed official CLI. +export function museCodeRPC(binary: string, options: CommandOptions): Promise { + return boundedChild(binary, ["serve"], { ...options, timeoutMs: 20000, maxBytes: 1048576 }, (child, finish) => { + const write = (message: unknown) => child.stdin.write(JSON.stringify({ jsonrpc: "2.0", ...message as object }) + "\n"); + let pending = "", initialized = false; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + pending += chunk; + let newline: number; + while ((newline = pending.indexOf("\n")) >= 0) { + const line = pending.slice(0, newline); pending = pending.slice(newline + 1); + if (!line.trim()) continue; + try { + const message = object(selectJSON(line, { jsonrpc: true, id: true, error: { code: true }, result: { experimentalApi: true } }, undefined, true)); + if (message.jsonrpc !== "2.0") throw unavailable(); + if (message.id === 1 && !initialized) { + if (message.error || object(message.result).experimentalApi !== true) throw unavailable(); + initialized = true; + write({ method: "initialized", params: {} }); + write({ id: 2, method: "usage/read", params: {} }); + } else if (message.id === 2 && initialized) { finish(line); return; } + } catch { finish(unavailable()); return; } + } + }); + write({ id: 1, method: "initialize", params: { clientInfo: { name: "meterusage", version: "0.2.41" }, capabilities: { experimentalApi: true, userInputDialogs: false } } }); + }); +} diff --git a/src/main/providers/parsers.ts b/src/main/providers/parsers.ts index d2f77bb..66e1254 100644 --- a/src/main/providers/parsers.ts +++ b/src/main/providers/parsers.ts @@ -79,10 +79,12 @@ export function parseClaudeQuota(s: string, now = Date.now()): Quota { export const claudeTierSelection = { oauthAccount: { organizationRateLimitTier: true, seatTier: true, userRateLimitTier: true } } as const; export function parseClaudePlan(s: string): string { const r = object(selectJSON(s, claudeTierSelection)), a = object(r.oauthAccount); - const tier = text(a.organizationRateLimitTier) ?? text(a.seatTier) ?? text(a.userRateLimitTier); - if (!tier) throw new Unavailable("noData"); - const v = tier.toLowerCase(); - return /max_?20x/.test(v) ? "Max 20×" : /max_?5x/.test(v) ? "Max 5×" : v.includes("enterprise") ? "Enterprise" : v.includes("team") ? "Team" : v.includes("pro") ? "Pro" : v.includes("free") ? "Free" : displayText(tier) ?? "Unknown"; + const labels: Record = { max_20x: "Max 20×", max20x: "Max 20×", max_5x: "Max 5×", max5x: "Max 5×", pro: "Pro", free: "Free", team: "Team", enterprise: "Enterprise" }; + for (const tier of [a.organizationRateLimitTier, a.seatTier, a.userRateLimitTier].map(text)) { + const key = tier?.toLowerCase().replace(/^default_claude_/, ""), label = key && Object.hasOwn(labels, key) ? labels[key] : undefined; + if (label) return label; + } + throw new Unavailable("noData"); } export function parseOpenRouter(s: string, creditsData?: string, now = Date.now()): Quota { const r = object(json(s).data), usedRaw = number(r.usage), used = usedRaw === undefined ? undefined : Math.max(0, usedRaw), limit = number(r.limit); diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 90cd45e..9c5b00f 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -11,7 +11,7 @@ import { effectiveWindows, notchEntries } from "../domain/notch"; import { burnBreakdown } from "../domain/telemetry"; import { pricingSnapshot } from "../domain/pricing"; const accents: Record = { blue: "#0a84ff", violet: "#a78bfa", teal: "#2dd4bf", amber: "#fbbf24", rose: "#fb7185", graphite: "#aeaeb2" }; -const colours: Record = { codex: "var(--accent)", claude: "var(--claude)", antigravity: "var(--antigravity)", grok: "var(--grok)", openCodeGo: "var(--opencode)", openRouter: "var(--openrouter)", cursor: "#1ab3e6", copilot: "#6699ff", gemini: "#4d80f2" }; +const colours: Record = { codex: "var(--accent)", claude: "var(--claude)", antigravity: "var(--antigravity)", grok: "var(--grok)", openCodeGo: "var(--opencode)", openRouter: "var(--openrouter)", cursor: "#1ab3e6", copilot: "#6699ff", gemini: "#4d80f2", muse: "#588cf5" }; const logos: Partial> = { codex: "codex-logo.png", grok: "grok-logo.png", antigravity: "antigravity-logo.png", openCodeGo: "opencode-logo.png" }; const n = (x: number) => new Intl.NumberFormat("en", { notation: x >= 1000 ? "compact" : "standard", maximumFractionDigits: 1 }).format(x); const money = (x: number) => new Intl.NumberFormat("en", { style: "currency", currency: "USD", maximumFractionDigits: 2 }).format(x); @@ -23,6 +23,7 @@ function Mark({ slot, state }: { slot: Slot; state: ViewState }) { const status = extract(state.snapshot.statuses[slot.provider])?.severity; const tint = status === undefined || status === "operational" ? colours[slot.provider] : status === "degraded" ? "var(--warn)" : status === "unknown" ? "var(--muted)" : "var(--alert)"; if (logos[slot.provider]) return