From ec39199a6c50a178516741529b78056a3d80916d Mon Sep 17 00:00:00 2001 From: PB <5535435+pekth@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:00:18 +0000 Subject: [PATCH 1/2] feat: add opt-in Muse Code allowance tracking --- CHANGELOG.md | 7 + README.md | 4 +- docs/DESKTOP-CONNECTIONS.md | 26 +++ docs/KB.md | 13 ++ docs/PRIVACY.md | 5 + .../0033-muse-code-subscription-allowance.md | 46 +++++ docs/adr/README.md | 1 + docs/electron/README.md | 3 +- docs/plans/muse-connections.md | 31 ++++ src/domain/models.ts | 5 +- src/domain/report.ts | 3 +- src/main/composition.ts | 6 +- src/main/coordinator.ts | 7 +- src/main/demo.ts | 3 +- src/main/electron.ts | 4 + src/main/history.ts | 4 +- src/main/providers/muse-code.ts | 57 ++++++ src/renderer/app.tsx | 12 +- tests/composition.test.ts | 8 +- tests/electron-lifecycle.test.ts | 20 ++- tests/muse-code.test.ts | 170 ++++++++++++++++++ tests/provider-order-ui.test.ts | 4 +- tests/provider-order.test.ts | 2 +- 23 files changed, 418 insertions(+), 23 deletions(-) create mode 100644 docs/adr/0033-muse-code-subscription-allowance.md create mode 100644 docs/plans/muse-connections.md create mode 100644 src/main/providers/muse-code.ts create mode 100644 tests/muse-code.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c8c102a..54e643b 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. 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..c3755f3 100644 --- a/docs/DESKTOP-CONNECTIONS.md +++ b/docs/DESKTOP-CONNECTIONS.md @@ -86,6 +86,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..b920383 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -41,6 +41,19 @@ Release documentation refresh: 2026-10-16; build 54 source review is unchanged. ## Product and source facts +- 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..ba2cd6b 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 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/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/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/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