diff --git a/.changeset/copilot-plan-usage.md b/.changeset/copilot-plan-usage.md new file mode 100644 index 00000000..4b69efb3 --- /dev/null +++ b/.changeset/copilot-plan-usage.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +The plan-usage chip in the header gains a `Copilot` row: the calendar-month premium-request quota, with the request counts and the reset in the tooltip. The host reads it every 10 minutes from GitHub's `copilot_internal/user` endpoint with the Copilot CLI's own sign-in token. It is on by default while the CLI is installed and signed in (an absent Plan Usage Limits setting reads as on), and turning Plan Usage Limits off in App Settings stops the request. Any failure leaves the chip without a Copilot row. The FAQ, Installation and Security pages say so. diff --git a/CLAUDE.md b/CLAUDE.md index 4b2cf2eb..2a061c91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,7 +157,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) -**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` +**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). Copilot comes from a read-only `GET https://api.github.com/copilot_internal/user` (`refreshCopilotPlanUsage()` in server.ts, at startup and every 10 minutes; `parseCopilotQuotaResponse()` keeps only the metered `premium_interactions` quota, as a `monthly` window with `used`/`limit`) sent with Copilot's OWN sign-in token (`resolveCopilotToken()`: `COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN`, else the plain-text token in `/config.json`; a keychain sign-in shows no row). It is **on by default**, like the Codex poll: it rides `showPlanUsageLimits` (absent = ON), and Plan Usage Limits off stops the request. ⚠️ The gates live in `readCopilotPlanUsageIfEnabled()` (copilot-plan-usage.ts) in this order: the setting, then a token, then `isCopilotAvailable()`, whose resolver can run a login-shell probe that a host with no sign-in must never pay each tick. ⚠️ `redirect: 'error'`, the token is never logged, stored or broadcast, any failure is silently no row, and the row is admin-only on the wire like Codex's. The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` **Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in on the Respawn tab, app-wide default `promptCacheKeepWarm` + cap `promptCacheKeepWarmMaxHours` in App Settings → System → Automation, SYNCED, default OFF / 2 h; step 2 of Discussion #554): one keep-alive turn (`KEEP_WARM_NUDGE`, under 100 chars) five minutes before an idle claude session's cache would expire, so the next prompt reads the conversation from cache instead of re-writing it at 2x. Codeman owns the idle policy for these sessions: a launch with keep-warm on also carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON (`session-cli-registry-bridge.ts`), decided once per create/respawn by `decideIdleCompactionOptOut()` (tmux-manager.ts: keep-warm on via `keepWarmOn()`, exporter `injected`, claude's `idleCompactionSetting` gate at 2.1.290, fail-closed like `--name`) and recorded as `MuxSession.idleCompactionOff`; a keep-warm-off launch stays byte-identical (`test/cli-registry-spawn-golden.test.ts`). Switched on after launch it is best-effort and the readout says idle compaction may still run until the next launch. ⚠️ The deadline is `Session.promptCache.expiresAt` from the statusline, never idle detection or the plan tier; a `5m` TTL is a hard stop, and so is a launch whose `statusLineExporter` is not `injected`. ⚠️ Every guard lives in the pure `decideKeepWarm()` (effective opt-in, where an agent-spawned session (`parentSessionId`) does not inherit the app default; exporter injected; warm; no `compactedAt` this gap; tokens at stake; live pane (pid AND no `paneExit`); no limit pause (auto-resume's, or a limit message on screen); no respawn/Ralph/task/waiter; inside the cap since `lastRealPromptAt`; idle; no dialog; EMPTY composer via `composerIsEmpty()`, continuation lines included, read from a STYLED capture so Claude Code's faint prompt suggestion is not taken for a draft; no recent keystroke); add a guard there, never in the service. ⚠️ The nudge IS a submit and moves `lastSubmitAt` (the response viewer's conversation correlation needs it); the window `beginKeepAliveTurn()` opens keeps it off `lastRealPromptAt` (the cap's own persisted clock) and opens its `stop`/`idle_prompt` hooks pre-acknowledged (`preAcknowledged` on `notePrompt`), so nothing alerts, and leaves an idle item already pending exactly as found (only a human spends an idle alert; `session:working`/`session:idle` carry `keepAlive: true` so the page's stuck timer ignores the turn); the keep-alive's own `idle_prompt` ends that window (`endKeepAliveTurn()`; `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling, since a slow turn's idle prompt lands a minute after it ends), so does a nudge whose write failed (retried a minute later, never past the last-chance line), and so does any other submit: a `fromUser` write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook whose prompt is not the nudge. Read My Mind drops its exact text (`isCapturablePrompt`). → [architecture-invariants#prompt-cache-keep-warm](docs/architecture-invariants.md#prompt-cache-keep-warm) diff --git a/README.md b/README.md index bad01443..cc2f4096 100644 --- a/README.md +++ b/README.md @@ -479,7 +479,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt - **OSC 52 clipboard**: a program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard, in the main terminal and in every tile. Clipboard read requests are refused, and replaying a buffer never copies - **MCP server sync** _(opt-in)_: copies the user-level MCP servers each installed CLI has (Claude, Codex, Gemini, OpenCode, Antigravity, and GitHub Copilot CLI) into the others' own config files. Additive only, a server you switched off is never copied, **Preview** shows the changes first, and the previous file is kept as `.codeman-bak`. App Settings → Agents & CLIs; admin only in multi-user mode - **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI -- **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server; per device, on for desktops and off for phones +- **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server and the GitHub Copilot monthly premium-request quota (the server asks `api.github.com` for it every 10 minutes with Copilot's own sign-in token: on by default while Copilot CLI is installed and signed in, and turning off Plan Usage Limits stops it); per device, on for desktops and off for phones - **Prompt-cache readout**: each Claude session shows whether its prompt cache is warm (`cache:until HH:MM`) or cold, in the footer (when you have no statusline of your own) and in Session Options → Respawn, with the cost of a re-cache and a note after a compaction. Display only; it survives a restart - **Session list, your way** — the header strip, a left sidebar with a filter box, or a vertical rail whose detailed rows carry created and state stamps and sort by activity; the phone home screen and the desktop home rail use the same order. On the rail, **Search sessions** narrows the list to the tabs that match, across every group, and a tab with an alert stays visible even when its name does not. Named **tab groups** collapse, reorder and take drags; a group's menu has **New session**, and a worker an agent spawns lands right after its parent (**Spawned tabs follow their parent**, off by default, keeps it moving with the parent). **Add to Focus** pins a shortcut to any tab in a section at the top of the rail, and **CLI Logos on Tabs** can be switched off per device - **Terminal looks** — seven skins, four of them light, per-device font family and weight (the bundled JetBrains Mono covers weights 100 to 800), and opt-in animations for tabs, agent windows, the terminal pane, connection lines and [tile-grid tiles](#tile-grid), all in App Settings → Animations (with a lab to try them) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1bd5ac24..2d2e72bf 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -159,7 +159,7 @@ Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgent ### Plan-usage chip (statusLine telemetry) -**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). ⚠️ **Injected as an EPHEMERAL `claude --settings` CLI flag at spawn (2026-09-07), never written to disk** — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` in `hooks-config.ts` (`generateStatusLineCommand()`/`applyStatusLineConfig()` remain, but only as the legacy disk-write self-heal path: a workspace an older Codeman build touched gets its stale `.claude/settings.local.json` entry stripped the first time a session starts there again). The exporter WRAPS a user's own real statusline (`findEffectiveUserStatusLineCommand()`, walking Claude Code's own settings precedence) rather than replacing it, and POSTs the `rate_limits` blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (foreground POST in the no-wrap branch so its own stdout becomes the footer, printing NOTHING on failure, `curl -sfk` plus `|| true`, since a bare brand word on the statusline is what discussion #405 opened with; backgrounded — `>/dev/null 2>&1 /dev/null 2>&1 /config.json`); a sign-in kept in the OS keychain is not read, so it shows no row. ⚠️ The read is **on by default**, like the Codex poll: it rides the same `showPlanUsageLimits` collection switch (an absent key reads as ON, so it runs even for a phone-only user whose chip is hidden), and turning Plan Usage Limits off stops the request. ⚠️ `readCopilotPlanUsageIfEnabled()` (copilot-plan-usage.ts) holds the three gates in order: the setting, then a token, then `isCopilotAvailable()`, whose resolver ends in a login-shell probe that a host without a Copilot sign-in must never run on each tick; a null clears the row. ⚠️ The token is never logged, stored or broadcast, the request refuses redirects (`redirect: 'error'`, so a redirect cannot carry the token elsewhere), and the row is admin-only on the wire like Codex's (`deriveSseHint`, and the init snapshot's `planUsage`). `plan-usage-latest.ts` merges the three process-wide sources (a Claude statusline sample keeps the `codex` and `copilot` keys) and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox and chip visibility (DISPLAY only) — the SAME `showPlanUsageLimits` setting also doubles as the server-side telemetry COLLECTION switch, read FRESH from `settings.json` by `readPlanUsageTelemetryEnabled()` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), never cached, with no per-session field and no per-request wire field — applies uniformly across every claude-creation path (interactive Run, cron, Ralph Loop API, quick-start) and survives a Codeman restart by construction (nothing per-session to lose). ⚠️ An ABSENT key reads as ON, the same way an absent `workspaceHooksEnabled` does: the desktop chip already shows as on for an install that never touched the setting, and the exporter posts only to this Codeman over loopback. Resolving the default in the reader is what keeps `GET /api/settings` a plain read. It briefly reconciled the key on first read (persisting `true` when absent), but `readJsonConfig()` answers `{}` for ANY read failure, not only ENOENT, and every page load hits that route, so one unlucky read replaced the whole settings file with a one-key file; pinned by `test/routes/system-routes-settings-get-plan-usage-default.test.ts`. ⚠️ The client sends `showPlanUsageLimits` in a settings save ONLY when that save FLIPS the chip relative to what the device had (`planUsageCollectionFlip()` in settings-ui.js): the chip defaults OFF on handhelds, so sending it on every save let a phone saving its font size persist `false` and switch collection off for every desktop, whose chip then went stale with no error anywhere. An explicit toggle on any device still writes the switch. Injection covers LOCAL tmux-spawned claude sessions only: the non-tmux direct-PTY fallback (`Session.startInteractive` when tmux is unavailable) and the remote/docker pane builders do not carry the flag. Registry-gated on `getCli(mode)?.capabilities.statusLineTelemetry` rather than a hardcoded mode string. **Distinct from auto-resume** (which reacts to the Claude limit _message_; this proactively shows live percentages). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`, `test/codex-plan-usage.test.ts`, `test/copilot-plan-usage.test.ts`, `test/plan-usage-chip.test.ts`, `test/plan-usage-latest.test.ts`, `test/hooks-config.test.ts` (statusline exporter script + `readPlanUsageTelemetryEnabled`), `test/statusline-cli-flag.test.ts`. Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`. The same statusline payload carries a `prompt_cache` object since Claude Code v2.1.251 (`warm`, `ttl`, `expires_at` in epoch seconds, `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at`, `last_miss_cause`); `StatusTelemetrySchema` lists every documented scalar field null-tolerant and keeps `causes` as `unknown` (the object gains fields across CLI versions and several are documented as possibly null, so a stricter shape would 400 the whole post and blank the footer — the recurring `.optional()`/null trap), while `parsePromptCache()` (usage-telemetry.ts) normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. The footer shows it as `cache:until HH:MM` (server-local clock, never a countdown: the footer only re-renders on statusline triggers) while warm and `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes, so the footer flips to cold on its own. ⚠️ A user's OWN statusline wins the footer (the exporter then posts in the background and prints theirs), so the state ALSO lives on `Session.promptCache` (`setPromptCache()`, in `toState()` for the Session Options → Respawn readout `#promptCacheStatus`, worded by the pure `CodemanPromptCache.format` in constants.js, which also shows the hit rate, the last miss's `causes`, and — after a compaction (`/compact` or Claude Code's own idle compaction) or a tool-result clearing, the first warm report whose `recache_tokens_if_cold` is null after one that carried it — a `Context compacted at HH:MM` note that is NEVER an alert) and the route broadcasts `session:promptCache` (the STORED object, so the compaction stamp rides along) only when the report CHANGES (the statusline fires per assistant message). ⚠️ `setPromptCache()` returns a NEW object when it stamps `compactedAt`, never mutating the parser's output, which is what keeps broadcasting the stored object meaningful; the stamp needs a KNOWN prior `recache_tokens_if_cold` (a first report already inside the null window, such as a mid-session restart's, gets none), is carried while the window holds and dropped at the next request, and never reads `expected_rebuilds`: Claude Code's tracker clears the window and counts the expected rebuild in that same next request, so no single report carries both (2.1.294-2.1.296), and `adoptClaudeSessionId()` drops the whole report on a conversation switch so a post-`/clear` pane never shows the old prefix's warm-until. The last report is restored on a Codeman restart (the constructor's `promptCache`, set directly so it is not re-stamped, like `displayModel`) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose `expiresAt` has passed reads as cold rather than as a stale warm (and `parsePromptCache` validates the converted-to-ms expiry against the Date range, so a huge `expires_at` cannot render `NaN:NaN`). ⚠️ A launch without the exporter never reports, so `TmuxManager._resolveStatusLine()` records why on the mux record at every create and respawn (`MuxSession.statusLine`, published as `SessionState.statusLineExporter`: `injected`, `collection-off`, `workspace-statusline`, `remote`) and the readout names the cause instead of "not reported yet" forever. It is a spawn-time fact: flipping Plan Usage on later changes nothing until the next launch. ⚠️ This object, not the plan tier, is the source of truth for the TTL: Claude Code requests the hour only for a subscription within plan usage, and drops to five minutes on an API key, a cloud provider or extra-usage credits. diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 2bc1253e..72c28a2d 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -350,6 +350,6 @@ The rules the module keeps and the tests pin: it only ADDS (a name already defin ## GitHub Copilot CLI -`copilot` (npm `@github/copilot`) is a stock entry. A bare launch is `copilot`, in its own Manual Approval mode. The launch parameters follow the grok/omp shape: `model` (`--model`), `resumeId` (`--resume `, an id, id prefix or session name: a single word that starts with a letter or digit, so a flag-shaped value such as `--yolo` is dropped rather than read as its own flag) and `continueSession` (`--continue`, skipped when a resume id is set), plus `allowAll` (`--yolo`: allow every tool, path and URL). The legacy config object is `copilotConfig` (`{ model, allowAll, resumeSessionId, continueSession }`). The Run button sends `allowAll: true` like the other agent CLIs' bypass switches, and `allowAll` is declared in `privilegedParams`, so a non-granted multi-user owner has it forced back to `false` (an absent config is never materialized: a bare spawn is already safe). Session history resume (Past Sessions) sends only `copilotConfig.continueSession`, which continues the most recent conversation Copilot has for that directory; `resumeSessionId` is for API callers. The tab names the model it reads from the footer (`modelDetect`). Its `altScreen` is `strip-mux-and-mouse`, like opencode: Copilot turns mouse tracking on itself, and left in, a plain drag is reported to the TUI instead of selecting (Auto Copy and copy-on-select did nothing); stripped, a drag is a local selection, clicks go through the hand-encoded tap, and the wheel is forwarded as SGR reports while `cliMouseTracking` is on. It declares `mcpConfig` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`) and no `credStore`: its token sits in `config.json` in plain text on a host without a keyring, so a Docker case is not seeded and you sign in inside the container. `env.allowedKeys` is empty on purpose: a headless host sets `COPILOT_GITHUB_TOKEN`, which the `COPILOT_` prefix admits, and `GH_TOKEN`/`GITHUB_TOKEN` stay out of the one global allowlist. +`copilot` (npm `@github/copilot`) is a stock entry. A bare launch is `copilot`, in its own Manual Approval mode. The launch parameters follow the grok/omp shape: `model` (`--model`), `resumeId` (`--resume `, an id, id prefix or session name: a single word that starts with a letter or digit, so a flag-shaped value such as `--yolo` is dropped rather than read as its own flag) and `continueSession` (`--continue`, skipped when a resume id is set), plus `allowAll` (`--yolo`: allow every tool, path and URL). The legacy config object is `copilotConfig` (`{ model, allowAll, resumeSessionId, continueSession }`). The Run button sends `allowAll: true` like the other agent CLIs' bypass switches, and `allowAll` is declared in `privilegedParams`, so a non-granted multi-user owner has it forced back to `false` (an absent config is never materialized: a bare spawn is already safe). Session history resume (Past Sessions) sends only `copilotConfig.continueSession`, which continues the most recent conversation Copilot has for that directory; `resumeSessionId` is for API callers. The tab names the model it reads from the footer (`modelDetect`). Its `altScreen` is `strip-mux-and-mouse`, like opencode: Copilot turns mouse tracking on itself, and left in, a plain drag is reported to the TUI instead of selecting (Auto Copy and copy-on-select did nothing); stripped, a drag is a local selection, clicks go through the hand-encoded tap, and the wheel is forwarded as SGR reports while `cliMouseTracking` is on. It declares `mcpConfig` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`) and no `credStore`: its token sits in `config.json` in plain text on a host without a keyring, so a Docker case is not seeded and you sign in inside the container. `env.allowedKeys` is empty on purpose: a headless host sets `COPILOT_GITHUB_TOKEN`, which the `COPILOT_` prefix admits, and `GH_TOKEN`/`GITHUB_TOKEN` stay out of the one global allowlist. **Plan usage:** the header chip gains a `Copilot` row with one `mo` window, the calendar-month premium-request quota (`quota_snapshots.premium_interactions` from `GET api.github.com/copilot_internal/user`, an undocumented endpoint the editors use; the tooltip also shows the request counts and reset). It is polled every 10 minutes by the host, read-only, and only when the CLI is installed, a token resolves (`COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`, then `authTokens` in `/config.json`) and plan-usage display is on. Any failure leaves the chip without a Copilot row; an unlimited quota has nothing to meter and is omitted. Measured on 1.0.94 inside tmux: an alternate-screen TUI with mouse tracking on, whose composer is the `❯` between two rules. While a turn runs the footer reads `◉ Working esc edit prompt` (the dot alternates `◉`/`◎`), which is the `workDetect` working line. Keystrokes sent with `tmux send-keys` reach the composer, Shift+Enter inserts a newline, and a shell tool call completes inside tmux; github/copilot-cli#4180 (a driven PTY ignores input) and #4223 (a shell command never completes under tmux) report both failing on 1.0.70 to 1.0.74 and neither reproduces on 1.0.94. `COPILOT_HOME` and `COPILOT_ALLOW_ALL` are privileged env keys, so a non-granted multi-user owner cannot set them through `envOverrides`, and so are the other `COPILOT_*` variables (read from the 1.0.98 bundle, far more than `copilot help environment` lists) that switch approvals, run code with no approval row or send the sign-in token elsewhere. The `COPILOT_` prefix admits whatever a later release adds, so the list needs a look whenever the measured version moves. diff --git a/docs/copilot-integration.md b/docs/copilot-integration.md index 4a0c383b..66955e29 100644 --- a/docs/copilot-integration.md +++ b/docs/copilot-integration.md @@ -57,6 +57,7 @@ Codeman never logs Copilot in or stores its credentials. | Model detection | The right-hand field of the footer's last row, at rest and mid-turn. | | Status probe | `GET /api/copilot/status`, plus an entry in the CLI-installed probes and `codeman doctor`. | | MCP | The entry declares `mcpConfig` (`~/.copilot/mcp-config.json`, dialect `copilot-json`, relocated by `COPILOT_HOME`), so [MCP server sync](./cli-registry.md) treats it as a target and a source. `copilot mcp disable` is honoured (read from `settings.json`, never written). | +| Plan usage | The header chip's **Copilot** row: the monthly premium-request quota from `GET https://api.github.com/copilot_internal/user`, read at startup and every 10 minutes with Copilot's own sign-in token (`COPILOT_GITHUB_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN`, else the plain-text token in `/config.json`; a sign-in kept in the OS keychain shows no row). On by default, like the Codex usage poll: it rides Plan Usage Limits (`showPlanUsageLimits`), and turning that off stops the request. Read-only, redirects refused, the token never logged or stored; any failure leaves the row out. | | Docker | The agent image installs `@github/copilot`. No credential directory is seeded (see below). | ### Your own endpoint (BYOK) diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index 6ff5a8c0..414f5393 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -72,7 +72,7 @@ output. The other CLIs expose no equivalent. | Docker cases, remote SSH cases | Yes | Yes | | Precise idle detection | Yes | Codex, Pi, OpenCode, OMP, Copilot, Gemini and Antigravity: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Grok: generic cues only, unreliable | | Auto-resume when a usage limit resets | Yes | No | -| Plan usage chip | Yes | No | +| Plan usage chip | Yes | Codex (limits) and Copilot (monthly premium requests); the others no | | Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no | | Read My Mind | Yes | No | | Ralph loop and its task tracker | Yes | No | @@ -280,6 +280,15 @@ GitHub's `copilot`, installed with `npm install -g @github/copilot` (CLI managem that for you). Codeman recognises it only when `--version` answers `GitHub Copilot CLI`, and `GET /api/copilot/status` reports the path and version that resolved. +**Quota in the plan usage chip.** While Copilot CLI is installed and signed in, the chip gains +a **Copilot** row with the calendar-month premium-request quota (requests used and the reset in +the tooltip). This is **on by default**, like the Codex usage poll: every 10 minutes the server +asks `api.github.com` for the quota with Copilot's own sign-in token (read-only, redirects +refused, the token never logged or stored). The token is read from `COPILOT_GITHUB_TOKEN`, +`GH_TOKEN` or `GITHUB_TOKEN`, or from the plain-text `~/.copilot/config.json`, so a sign-in kept +in the OS keychain (a macOS or desktop Linux keyring) shows no row. Any failure just leaves the row out. Turn off +**Plan Usage Limits** in App Settings to stop the request. + **Signing in.** Codeman does not log it in: run `copilot` once and use `/login`, or sign in inside a Codeman tab. Copilot keeps the token in the OS keychain when there is one; a headless server has none, so it offers to save it as **plain text in `~/.copilot/config.json`**, which is diff --git a/docs/wiki/FAQ.md b/docs/wiki/FAQ.md index 2ec5b738..fc6fc960 100644 --- a/docs/wiki/FAQ.md +++ b/docs/wiki/FAQ.md @@ -29,8 +29,12 @@ are Claude-only; [Agent CLIs](Agent-CLIs) has the table. ### Does Codeman send my code or prompts anywhere? -No. There is no telemetry, no analytics, and no phone-home. The only network traffic -Codeman itself makes is between your browser and your server. +No. There is no telemetry, no analytics, and no phone-home. Beyond the traffic between +your browser and your server, the server makes a few requests of its own for features you use +(the Codex usage poll, web push, webhooks). One is on by default: while GitHub Copilot CLI is +installed and signed in, every 10 minutes the server asks `api.github.com` for your own +premium-request quota with Copilot's own token. Turn off Plan Usage Limits in App Settings to +stop it (see [Agent CLIs](Agent-CLIs)). Your agent CLI is a separate matter: Claude Code talks to Anthropic, Codex talks to OpenAI, and so on. That traffic is the CLI's, on your own account, exactly as it would be in a diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 051116be..ef0585ca 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -12,7 +12,10 @@ Getting Codeman onto a machine, verifying it works, updating it, and removing it | **An agent CLI** | At least one of [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi). Plain shell sessions need none. See [Agent CLIs](Agent-CLIs). | Codeman itself sends no telemetry and phones no home. The only network traffic is your -browser to your server, and whatever the agent CLI you chose does on its own. +browser to your server, and whatever the agent CLI you chose does on its own, plus the +server's own few requests (Codex usage, web push, webhooks) and, on by default while Copilot +CLI is installed and signed in, a read of your own GitHub Copilot quota every 10 minutes +(Plan Usage Limits in App Settings turns it off; see [Agent CLIs](Agent-CLIs)). ## Route A: the installer (recommended) diff --git a/docs/wiki/Keeping-Agents-Running.md b/docs/wiki/Keeping-Agents-Running.md index 35f81870..e484147b 100644 --- a/docs/wiki/Keeping-Agents-Running.md +++ b/docs/wiki/Keeping-Agents-Running.md @@ -119,8 +119,9 @@ Recent Claude Code builds can compact a large idle session on their own shortly ## The plan usage chip -**Claude only.** A header chip showing live subscription usage, on by default on desktop and -off on phones. +A header chip showing live subscription usage: Claude (the 5-hour and weekly windows), Codex +(its limits) and, while Copilot CLI is installed and signed in, Copilot's monthly premium-request +quota. On by default on desktop and off on phones. It works through a status line exporter that Codeman hands to `claude` as an ephemeral setting when it spawns the session, never written to disk, which posts Claude's own rate @@ -128,7 +129,8 @@ limit data back to Codeman. Your own status line (project-local, project, then `~/.claude/settings.json`) is wrapped and printed through, and a `claude` you run by hand outside Codeman sees nothing of it. Workspaces an older Codeman wrote the exporter into are cleaned up the first time a session starts there. Codex limits come from a read-only poll of -its own app-server. Known limit: sessions inside a Docker case do not feed the chip yet. +its own app-server, and the Copilot quota from a read-only request to `api.github.com` every +10 minutes with Copilot's own token (on by default; Plan Usage Limits off stops it). Known limit: sessions inside a Docker case do not feed the chip yet. The chip and the exporter are the same setting. Turning the chip on without the exporter would leave it showing a dash forever, so resolve it in one place: **App Settings**. A diff --git a/docs/wiki/Security.md b/docs/wiki/Security.md index 8dbaaa38..12f576d1 100644 --- a/docs/wiki/Security.md +++ b/docs/wiki/Security.md @@ -101,8 +101,15 @@ Stated plainly, because a security page that only lists strengths is not useful: ## Privacy -No telemetry, no analytics, no phone-home. Codeman's only network traffic is between your -browser and your server. Your agent CLI's traffic is its own, on your account. +No telemetry, no analytics, no phone-home. Your agent CLI's traffic is its own, on your +account. Beyond the traffic between your browser and your server, the server itself makes a few +requests for features you use: the Codex usage poll, web push, and webhooks if you set one up. + +The GitHub Copilot quota read behind the plan usage chip's **Copilot** row is **on by default +while Copilot CLI is installed and signed in** (an absent plan-usage setting reads as on, even +for a phone-only user whose chip is hidden). Every 10 minutes the server asks `api.github.com`, +and nothing else, for your premium-request quota, with Copilot's own sign-in token. The token is +never logged and redirects are refused. Turn off **Plan Usage Limits** in App Settings to stop it. Two features send data outward, both off by default and both stated where they appear: voice dictation through your Claude login, and the Read My Mind prediction call. diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 5583d3f6..b40ab49b 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -148,7 +148,7 @@ The right side of the header. Almost all of these are off until you enable them | CPU / MEM | On | Server resource use. Drawn as a compact pill by default; see Header Stats Style below. | | File Viewer | On | Toggles the file browser panel. | | Settings gear | Always on | App Settings. | -| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. | +| Plan usage chip | On, desktop only | Live subscription usage: Claude (needs its telemetry exporter, which the same setting installs), Codex limits and the Copilot monthly quota. | | Session Manager | Off | The full session list, live and historical. | | Approvals bell | Off | Cross-session queue of prompts waiting on a human. Appears only when the count is above zero. Never shown on phones. | | Read My Mind 🧠 | Off | Predicts your next prompt for this case. Claude-only. | diff --git a/src/usage-telemetry.ts b/src/usage-telemetry.ts index 6e4ead75..cdd16cfe 100644 --- a/src/usage-telemetry.ts +++ b/src/usage-telemetry.ts @@ -40,12 +40,20 @@ export interface UsageWindow { usedPercentage: number; /** Epoch MILLISECONDS when the window resets (statusline reports seconds). */ resetAt: number; + /** + * Absolute counts, for a window measured in requests rather than as a bare percent (Copilot's + * monthly premium-request quota). Absent for the Claude and Codex windows. + */ + used?: number; + limit?: number; } /** Normalized telemetry Codeman broadcasts to the UI. */ export interface StatusTelemetry { fiveHour?: UsageWindow; sevenDay?: UsageWindow; + /** A calendar-month quota (GitHub Copilot's premium requests); not a rolling 5h/7d window. */ + monthly?: UsageWindow; /** Context-window percent used, 0–100 (bonus field from the same payload). */ contextUsedPercentage?: number; /** Session cost in USD (bonus field). */ @@ -196,6 +204,51 @@ export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry | return telemetry.fiveHour || telemetry.sevenDay ? telemetry : null; } +/** + * Normalize GitHub Copilot's quota snapshot (`GET /copilot_internal/user`) into the chip's monthly + * window. Only `premium_interactions` is a metered quota: `chat` and `completions` report + * `unlimited: true` on every plan seen, and an unlimited premium quota has nothing to meter, so it + * yields null and the chip shows no Copilot row rather than a bar stuck at 0%. + */ +export function parseCopilotQuotaResponse(value: unknown): StatusTelemetry | null { + if (!value || typeof value !== 'object') return null; + const raw = value as { + quota_snapshots?: { + premium_interactions?: { + entitlement?: unknown; + remaining?: unknown; + percent_remaining?: unknown; + unlimited?: unknown; + }; + }; + quota_reset_date_utc?: unknown; + quota_reset_date?: unknown; + }; + const snap = raw.quota_snapshots?.premium_interactions; + if (!snap || typeof snap !== 'object' || snap.unlimited === true) return null; + // A zero (or negative) quota that is not 'unlimited' would show a stuck 0% or 100% row. + if (typeof snap.entitlement === 'number' && snap.entitlement <= 0) return null; + + const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); + const limit = finite(snap.entitlement) && snap.entitlement > 0 ? snap.entitlement : undefined; + let usedPct: number | null = null; + if (finite(snap.percent_remaining)) usedPct = 100 - snap.percent_remaining; + else if (limit !== undefined && finite(snap.remaining)) usedPct = (1 - snap.remaining / limit) * 100; + if (usedPct === null) return null; + + const resetRaw = typeof raw.quota_reset_date_utc === 'string' ? raw.quota_reset_date_utc : raw.quota_reset_date; + const parsedReset = typeof resetRaw === 'string' ? Date.parse(resetRaw) : NaN; + const monthly: UsageWindow = { + usedPercentage: clampPct(usedPct), + resetAt: Number.isFinite(parsedReset) && parsedReset > 0 ? parsedReset : 0, + }; + if (limit !== undefined && finite(snap.remaining)) { + monthly.limit = limit; + monthly.used = Math.max(0, Math.round(limit - snap.remaining)); + } + return { monthly }; +} + /** * Current-session status for the in-terminal statusline footer. This is the * "status of the current session" the user sees in Claude's footer — distinct @@ -286,10 +339,21 @@ export function formatSessionStatusText(s: SessionStatus | null, now: number = D * localStorage write + identical chip re-render each time. */ export function telemetrySignature(t: StatusTelemetry): string { - return JSON.stringify([ + const parts: Array = [ t.fiveHour ? Math.round(t.fiveHour.usedPercentage) : null, t.fiveHour?.resetAt ?? null, t.sevenDay ? Math.round(t.sevenDay.usedPercentage) : null, t.sevenDay?.resetAt ?? null, - ]); + ]; + // Appended only when present, so the Claude and Codex signatures are byte-identical to before. + // The limit is part of it: a plan change mid-month (300 to 1500 with 100 used) keeps `used` and the reset. + if (t.monthly) { + parts.push( + Math.round(t.monthly.usedPercentage), + t.monthly.used ?? null, + t.monthly.limit ?? null, + t.monthly.resetAt + ); + } + return JSON.stringify(parts); } diff --git a/src/utils/copilot-plan-usage.ts b/src/utils/copilot-plan-usage.ts new file mode 100644 index 00000000..9722283e --- /dev/null +++ b/src/utils/copilot-plan-usage.ts @@ -0,0 +1,117 @@ +/** + * @fileoverview GitHub Copilot's monthly premium-request quota, for the header plan-usage chip. + * + * The Copilot CLI has no scriptable usage command (`copilot billing` exists only inside the TUI), but + * the same quota snapshot the editors show is `GET https://api.github.com/copilot_internal/user`, + * readable with the CLI's own sign-in token. The endpoint is not part of GitHub's documented REST API, + * so this is read-only, best-effort and silent: any failure (offline, 401, a changed shape) yields + * `null` and the chip simply has no Copilot row. + * + * ⚠️ The token is never logged, never put in an error and only ever sent to api.github.com over HTTPS + * (`redirect: 'error'`, so a redirect cannot carry it elsewhere). + * + * @module utils/copilot-plan-usage + */ + +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { parseCopilotQuotaResponse, type StatusTelemetry } from '../usage-telemetry.js'; + +export const COPILOT_QUOTA_URL = 'https://api.github.com/copilot_internal/user'; +const REQUEST_TIMEOUT_MS = 10_000; + +/** Env names Copilot itself reads for a headless sign-in, in its own precedence order. */ +const TOKEN_ENV_KEYS = ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'] as const; + +/** The CLI writes `// ...` header comments above the JSON in config.json. */ +function parseCommentedJson(raw: string): unknown { + return JSON.parse( + raw + .split('\n') + .filter((line) => !line.trimStart().startsWith('//')) + .join('\n') + ); +} + +/** + * The sign-in token Copilot would use: a token in the environment first, then the plain-text + * `authTokens` entry the CLI keeps in `/config.json` when no OS keychain + * is available. Only github.com sign-ins are read from the file (the quota URL is github.com's). + */ +export async function resolveCopilotToken( + env: Record = process.env, + home: string = homedir() +): Promise { + for (const key of TOKEN_ENV_KEYS) { + const value = env[key]?.trim(); + if (value) return value; + } + const dir = env.COPILOT_HOME?.trim() || join(home, '.copilot'); + try { + const config = parseCommentedJson(await readFile(join(dir, 'config.json'), 'utf8')) as { + lastLoggedInUser?: { host?: string; login?: string }; + authTokens?: Record; + }; + const { host, login } = config.lastLoggedInUser ?? {}; + if (host !== 'https://github.com' || !login || !config.authTokens) return null; + for (const key of [`${host}:${login}`, `${host}:${login}:github`]) { + const token = config.authTokens[key]?.token; + if (typeof token === 'string' && token.trim()) return token.trim(); + } + } catch { + /* no config, unreadable, or not JSON: no token */ + } + return null; +} + +export interface ReadCopilotPlanUsageOptions { + /** Defaults to `resolveCopilotToken()`. */ + token?: string | null; + /** Injectable for tests. */ + fetchFn?: typeof fetch; +} + +/** The monthly premium-request window, or null when there is no token, no meter or any failure. */ +export async function readCopilotPlanUsage(opts: ReadCopilotPlanUsageOptions = {}): Promise { + const token = opts.token === undefined ? await resolveCopilotToken() : opts.token; + if (!token) return null; + const doFetch = opts.fetchFn ?? fetch; + try { + const response = await doFetch(COPILOT_QUOTA_URL, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'User-Agent': 'codeman', + }, + redirect: 'error', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return null; + return parseCopilotQuotaResponse(await response.json()); + } catch { + return null; + } +} + +export interface CopilotPlanUsageGates { + /** `showPlanUsageLimits` as the server reads it (`readPlanUsageTelemetryEnabled()`: absent = on). */ + planUsageEnabled: () => Promise; + /** `resolveCopilotToken()`: env vars and a file read, nothing spawned. */ + resolveToken: () => Promise; + /** `isCopilotAvailable()`: its resolver may run a synchronous login-shell probe. */ + copilotInstalled: () => boolean; + read: (token: string) => Promise; +} + +/** + * The server's poll: Copilot's quota, or null when Plan Usage Limits is off, there is no sign-in + * token or the CLI is not installed. Cheapest gate first, so a host with no Copilot sign-in never + * runs the CLI resolver on each tick, and nothing at all is read while Plan Usage Limits is off. + */ +export async function readCopilotPlanUsageIfEnabled(gates: CopilotPlanUsageGates): Promise { + if (!(await gates.planUsageEnabled())) return null; + const token = await gates.resolveToken(); + if (!token || !gates.copilotInstalled()) return null; + return gates.read(token); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 717cb1dc..93124e5a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -76,6 +76,7 @@ export { getCopilotNotFoundMessage, getCopilotCliVersion, } from './copilot-cli-resolver.js'; +export { readCopilotPlanUsage, resolveCopilotToken } from './copilot-plan-usage.js'; export { boundedPathExists, describeUnknownPath, diff --git a/src/web/plan-usage-latest.ts b/src/web/plan-usage-latest.ts index 66f92409..ce06156e 100644 --- a/src/web/plan-usage-latest.ts +++ b/src/web/plan-usage-latest.ts @@ -1,7 +1,7 @@ /** * @fileoverview Process-wide last-known plan-usage telemetry (account-global). * - * The Claude status-telemetry route and host Codex poll merge their latest + * The Claude status-telemetry route and the host Codex and Copilot polls merge their latest * values here. The SSE init snapshot (`getLightState`) replays the combined * value so the header "Plan Usage Limits" chip shows immediately on a fresh * page load / SSE reconnect — before either source emits another sample, and @@ -15,8 +15,14 @@ let latest: Record | null = null; export function setLatestPlanUsage(value: Record): Record { + // The Claude status route replaces the Claude part; the host-polled providers ride along. const codex = latest?.codex; - latest = { ...value, ...(codex !== undefined ? { codex } : {}) }; + const copilot = latest?.copilot; + latest = { + ...value, + ...(codex !== undefined ? { codex } : {}), + ...(copilot !== undefined ? { copilot } : {}), + }; return latest; } @@ -25,6 +31,11 @@ export function setLatestCodexPlanUsage(value: object | null): Record { + latest = { ...(latest ?? {}), copilot: value }; + return latest; +} + export function getLatestPlanUsage(): Record | null { return latest; } diff --git a/src/web/public/app.js b/src/web/public/app.js index f70fffb3..d2644c11 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3605,7 +3605,7 @@ class CodemanApp { } } - // Claude + Codex plan usage limits — account-global, so the latest sample + // Claude, Codex and Copilot plan usage limits — account-global, so the latest sample // drives the shared header chip. _onSessionStatusTelemetry(data) { this.updatePlanUsageChip(data); @@ -3665,19 +3665,27 @@ class CodemanApp { }; // The provider label only earns its space when there is more than one // provider to tell apart: a machine with Claude alone shows bare windows. - const hasWindows = (usage) => pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null; - const labelled = hasWindows(data) && hasWindows(data.codex); + const hasWindows = (usage) => + pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null || pct(usage?.monthly) !== null; + const labelled = [data, data.codex, data.copilot].filter(hasWindows).length > 1; const row = (provider, usage, idle) => { // hasWindows() gates the row, so a placeholder can only ever appear // ALONGSIDE a real reading — a provider reporting nothing still renders // nothing, never a row of em dashes. if (!hasWindows(usage)) return ''; - const windows = [seg('5h', pct(usage?.fiveHour), idle), seg('7d', pct(usage?.sevenDay), idle)].filter(Boolean); + // `mo` is Copilot's calendar-month premium-request quota; it is the only window that provider has. + const windows = [ + seg('5h', pct(usage?.fiveHour), idle), + seg('7d', pct(usage?.sevenDay), idle), + seg('mo', pct(usage?.monthly), false), + ].filter(Boolean); if (!windows.length) return ''; const label = labelled ? `${provider}` : ''; return `${label}${windows.join('·')}`; }; - const rows = [row('Claude', data, true), row('Codex', data.codex, false)].filter(Boolean); + const rows = [row('Claude', data, true), row('Codex', data.codex, false), row('Copilot', data.copilot, false)].filter( + Boolean + ); chip.innerHTML = rows.length ? rows.join('') : '—'; const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—'); const details = (provider, usage, idle) => { @@ -3687,10 +3695,20 @@ class CodemanApp { if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`); else if (idle && seven !== null) lines.push('5-hour limit: no active session window'); if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`); + const month = pct(usage?.monthly); + if (month !== null) { + // Counts are coerced finite numbers, so interpolating them stays XSS-safe like the rest. + const m = usage.monthly; + const counts = + Number.isFinite(m.used) && Number.isFinite(m.limit) ? `${Math.round(m.used)} of ${Math.round(m.limit)} requests, ` : ''; + lines.push(`Premium requests this month: ${month}% used (${counts}resets ${resetStr(m)})`); + } return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : ''; }; chip.title = - [details('Claude', data, true), details('Codex', data.codex, false)].filter(Boolean).join('\n\n') || + [details('Claude', data, true), details('Codex', data.codex, false), details('Copilot', data.copilot, false)] + .filter(Boolean) + .join('\n\n') || 'Plan usage limits'; } diff --git a/src/web/public/index.html b/src/web/public/index.html index 06ed2445..bcdec4d7 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -197,7 +197,7 @@ -
—
+
—