mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
feat(usage): plan usage limits header chip via statusLine telemetry
Surface Claude subscription plan usage limits (5-hour rolling + 7-day weekly: percent used + reset time) in the header, opt-in via App Settings → Display → "Plan Usage Limits" (default OFF, no behavior change when off). A Codeman-managed Claude statusLine exporter forwards the rate_limits JSON to a new auth-exempt POST /api/status-telemetry (same loopback + hook-secret gate as /api/hook-event); parsed telemetry broadcasts over SSE session:statusTelemetry to a header chip (amber >=80%, red >=95%, reset times on hover). The exporter prints the same summary back as the in-terminal footer (print-through). - src/usage-telemetry.ts: pure parser/formatter (epoch-sec -> ms, clamp, change signature) + test/usage-telemetry.test.ts - hooks-config.ts: generateStatusLineCommand + applyStatusLineConfig (add/remove; never clobbers a user's own statusLine) - session-routes.ts: inject gate (Claude-only, Codeman-managed cases), driven by create-payload statusLineTelemetry (session-ui.js) - schemas.ts: StatusTelemetrySchema + showPlanUsageLimits + payload field - frontend: header chip, applyHeaderVisibilitySettings toggle, renderIndexHtml strip, _onSessionStatusTelemetry handler Schema empirically confirmed against Claude Code 2.1.177 (Claude Max): only five_hour/seven_day windows exist (no Opus-weekly field); rate_limits is absent before the first API response and for non-subscriber auth. Design + verification method in docs/usage-limits-display-plan.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
# Plan Usage Limits Display — Design Plan
|
||||
|
||||
> **Status: IMPLEMENTED — disabled by default (2026-06-14, not yet committed/COM'd).** Surface the user's Claude subscription **plan usage limits** (5-hour rolling + 7-day weekly windows: percent used + reset time) in the Codeman web UI via a Codeman-managed `statusLine` callback. Opt-in via App Settings → Display → **Plan Usage Limits** (`showPlanUsageLimits`, default OFF). The `rate_limits` JSON schema below was **empirically confirmed** against Claude Code 2.1.177 on a Claude Max account (2026-06-14); see the Verification appendix to reproduce.
|
||||
>
|
||||
> **Shipped surface:** pure parser `src/usage-telemetry.ts` (+ `test/usage-telemetry.test.ts`); `applyStatusLineConfig`/`generateStatusLineCommand` in `hooks-config.ts`; `POST /api/status-telemetry` (`status-telemetry-routes.ts`, auth-exempt in `middleware/auth.ts`, schema in `schemas.ts`); SSE `session:statusTelemetry`; create-payload `statusLineTelemetry` gate in `session-routes.ts`; header chip + `applyHeaderVisibilitySettings` toggle + `renderIndexHtml` strip + `_onSessionStatusTelemetry` handler. Verified: typecheck, lint, full test suite (2866 pass), and live (endpoint/settings/render-strip on an isolated instance).
|
||||
|
||||
## Problem
|
||||
|
||||
Codeman has no proactive view of how much of the Claude subscription is left. Today it only learns about limits **reactively**: `usage-limit-patterns.ts` regex-scrapes ANSI-stripped terminal output for footer strings like `5-hour limit reached ∙ resets 8pm`, and extracts only the **reset time** — and only *after* Claude has already stalled. There is no "73% of your 5-hour limit used" anywhere.
|
||||
|
||||
We want a live gauge:
|
||||
|
||||
```
|
||||
5-hour limit ▕███▏ 15% resets 8:50am
|
||||
7-day limit ▕███████▏ 34% resets Fri
|
||||
```
|
||||
|
||||
So the operator can see a wall coming, pace overnight Ralph/autonomous runs, and (future) pre-arm auto-resume instead of waiting for the stall.
|
||||
|
||||
## Current state (verified in code)
|
||||
|
||||
| Piece | File | Behavior |
|
||||
|-------|------|----------|
|
||||
| Reactive scraper | `src/usage-limit-patterns.ts` | Pure regex over cleaned PTY output; detects limit footers, parses **reset time only**. Conservative (no reset time → ignored). |
|
||||
| Auto-resume | `src/session-auto-ops.ts` | Arms a timer at reset+2min, sends Esc + `continue`. Persists via `SessionState.autoResumeEnabled/autoResumeAt`. |
|
||||
| SSE events (existing) | `src/web/sse-events.ts` | `session:limitPauseScheduled`, `session:limitResume`, `session:limitResumeCancelled`. |
|
||||
| Settings injection | `src/hooks-config.ts` | Writes `hooks` (+ `env`/`model`) into each case's `.claude/settings.local.json`. **No `statusLine` is ever written** (confirmed via grep). |
|
||||
|
||||
Codeman therefore has **zero access to Claude Code's structured usage data** — it has never configured a statusLine, which is the channel that data flows through.
|
||||
|
||||
## Data source: the statusline `rate_limits` JSON
|
||||
|
||||
Claude Code (**v2.1.80+**; prod box runs **2.1.177**) pipes a JSON blob to a configured `statusLine.command` on stdin after each render. On Pro/Max subscriptions that blob includes `rate_limits`.
|
||||
|
||||
### Confirmed schema (real captured payload)
|
||||
|
||||
```jsonc
|
||||
"rate_limits": {
|
||||
"five_hour": { "used_percentage": 15, "resets_at": 1781409000 }, // → 2026-06-14T03:50:00Z
|
||||
"seven_day": { "used_percentage": 34, "resets_at": 1781827200 } // → 2026-06-19T00:00:00Z
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `rate_limits.five_hour.used_percentage` | `number` 0–100 | Integer-valued in practice; treat as `number`, don't assume decimals. |
|
||||
| `rate_limits.five_hour.resets_at` | `number` | **Epoch SECONDS** (10 digits). `×1000` for a JS `Date`. |
|
||||
| `rate_limits.seven_day.{used_percentage,resets_at}` | same | |
|
||||
|
||||
**Confirmed facts & gotchas:**
|
||||
|
||||
- **Only two windows exist: `five_hour` and `seven_day`.** There is **no separate Opus-weekly field**, even on a Max/Opus account. Do **not** promise an Opus gauge in the UI.
|
||||
- `rate_limits` is **absent on the first render**, **present after the first API response**. UI must degrade to "usage not yet available."
|
||||
- statusLine fires **only in interactive TUI mode**, never `--print`. Fine — Codeman sessions are interactive TUIs.
|
||||
- **Subscriber-gated.** Absent for API-key / non-subscriber auth.
|
||||
|
||||
### Bonus telemetry in the same payload (free to surface)
|
||||
|
||||
The same stdin object also carries (non-sensitive):
|
||||
|
||||
```jsonc
|
||||
"model": { "id": "claude-opus-4-8[1m]", "display_name": "Opus 4.8 (1M context)" },
|
||||
"context_window": { "context_window_size": 1000000, "used_percentage": 2, "remaining_percentage": 98,
|
||||
"current_usage": { "input_tokens": …, "output_tokens": …,
|
||||
"cache_creation_input_tokens": …, "cache_read_input_tokens": … } },
|
||||
"cost": { "total_cost_usd": 0.0415, "total_duration_ms": …, "total_api_duration_ms": …,
|
||||
"total_lines_added": 0, "total_lines_removed": 0 },
|
||||
"effort": { "level": "xhigh" },
|
||||
"fast_mode": false, "exceeds_200k_tokens": false,
|
||||
"session_name": "…", "session_id": "…", "transcript_path": "…", "version": "…"
|
||||
```
|
||||
|
||||
One statusLine callback thus unlocks live **context-window %**, **per-session cost**, **model**, **effort**, and **fast-mode** alongside the plan limits — a meaningful expansion of what the feature can show.
|
||||
|
||||
### Alternatives considered & rejected
|
||||
|
||||
| Source | Why not |
|
||||
|--------|---------|
|
||||
| OAuth endpoint `api.anthropic.com/api/oauth/usage` | Undocumented, aggressively rate-limited, needs extracting the **encrypted** OAuth token. Only worth it for *dollar spend* data the statusline lacks. |
|
||||
| `/usage` slash command | Interactive-only, no programmatic output. |
|
||||
| On-disk `~/.claude/` files | No usage state persisted (verified: only `daemon.status.json` = auto-updater supervisor). |
|
||||
| CLI flag (`claude usage` / `--check-usage`) | Does not exist (open upstream feature request). |
|
||||
| `StopFailure` hook | Carries only an `error_type` (`rate_limit`) on *failure* — no live percentages. Could complement, not replace. |
|
||||
|
||||
## Design
|
||||
|
||||
A Codeman-managed `statusLine.command` that mirrors the existing hook-callback pattern: it POSTs the full stdin payload to a new loopback endpoint and prints whatever the server returns as the visible status text.
|
||||
|
||||
```
|
||||
Claude TUI (managed case)
|
||||
│ renders statusline (~300ms debounce, after each assistant msg)
|
||||
▼
|
||||
statusLine.command ──reads stdin JSON──▶ curl POST $CODEMAN_API_URL/api/sessions/:id/statusline
|
||||
│ (X-Codeman-Hook-Secret: $(cat $CODEMAN_HOOK_SECRET_FILE))
|
||||
│ ◀── HTTP 200 body = formatted status string ──┘
|
||||
▼
|
||||
printf '%s' "$body" → in-terminal statusline stays useful ("Opus 4.8 · 5h 15% · 7d 34%")
|
||||
|
||||
server: parse payload → extract rate_limits/context_window/cost/model
|
||||
→ store on Session → broadcast SSE → format & return status string
|
||||
▼
|
||||
SSE session:statusTelemetry → app.js listener → header/Respawn-tab gauge
|
||||
```
|
||||
|
||||
### 1. The exporter (reuses hook plumbing)
|
||||
|
||||
Mirror `curlCmd()` in `hooks-config.ts`. The managed-session env already carries `$CODEMAN_SESSION_ID`, `$CODEMAN_API_URL`, and `$CODEMAN_HOOK_SECRET_FILE` (set by `tmux-manager.buildEnvExports()`), which the hooks already rely on — the statusLine command inherits the same env. No `jq` dependency: POST the **raw** stdin and let the server parse. The server's HTTP response body is the status string to print, so formatting lives server-side and the in-terminal statusline stays useful even when attached directly (`sc`):
|
||||
|
||||
```bash
|
||||
# conceptual — generated into settings.local.json statusLine.command
|
||||
PAYLOAD=$(cat 2>/dev/null || echo '{}')
|
||||
BODY=$(printf '%s' "$PAYLOAD" | curl -s -X POST "$CODEMAN_API_URL/api/sessions/$CODEMAN_SESSION_ID/statusline" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" \
|
||||
--data @- 2>/dev/null || true)
|
||||
printf '%s' "${BODY:-codeman}" # fallback keeps a sane statusline if the server is down
|
||||
```
|
||||
|
||||
### 2. Settings injection (opt-in, Claude-only)
|
||||
|
||||
Add a `statusLine` block to `generateHooksConfig()` (or a sibling) in `hooks-config.ts`, gated by a new opt-in app setting (e.g. `showPlanUsageLimits`) and only for Claude-mode cases (OpenCode/Codex emit no such JSON — gate exactly like the hooks). Merge into `settings.local.json` touching only the `statusLine` key, same as `writeHooksConfig()` merges `hooks`.
|
||||
|
||||
### 3. New endpoint
|
||||
|
||||
`POST /api/sessions/:id/statusline` in `src/web/routes/session-routes.ts`:
|
||||
- Zod schema in `schemas.ts` validates the subset Codeman cares about (`rate_limits`, `context_window`, `cost`, `model`, `effort`) — all `.optional()`/`.nullish()` since fields come and go (⚠️ recall: `.optional()` rejects `null`; the payload is machine-generated so unlikely, but use `.passthrough()`/`.nullish()` defensively).
|
||||
- Auth: exempt like `/api/hook-event` (localhost-only), gated by `X-Codeman-Hook-Secret` while a tunnel is up (reuse `config/hook-secret.ts`). Dedicated rate-limit bucket — **never** the login bucket (COD-55 lesson).
|
||||
- Returns the formatted status string in the response body.
|
||||
- **Debounce/dedup:** statusline renders ~every 300ms. Server should ignore payloads with no `rate_limits` change and avoid re-broadcasting unchanged telemetry, to keep SSE quiet.
|
||||
|
||||
### 4. SSE event
|
||||
|
||||
Add `session:statusTelemetry` (or `account:rateLimits`) to `src/web/sse-events.ts` **and** the `SSE_EVENTS` mirror in `constants.js` (both must stay in sync), emit via `broadcast()`, handle in `app.js` (`addListener(`).
|
||||
|
||||
### 5. State
|
||||
|
||||
Store last-seen telemetry on the `Session` (e.g. `_lastStatusTelemetry`), include in `session.toState()`, persist via `persistSessionState()` so a reload re-renders immediately. See *account-global* caveat below for whether to also keep a global singleton.
|
||||
|
||||
### 6. Frontend
|
||||
|
||||
A compact gauge component (two bars: 5h / 7d, percent + humanized reset). Natural home: the **header** (account-global) or beside the existing token-pause / auto-resume control at the top of the **Respawn tab** (`respawn-ui.js`). Reset time = `resets_at * 1000` → `new Date(...)`; render relative ("resets in 2h14m") with absolute on hover.
|
||||
|
||||
## Codeman-specific considerations
|
||||
|
||||
1. **The limits are account-global, not per-session.** The 5h/7d pools are shared across every session authenticated as the same Claude account. So all sessions report the **same** numbers → prefer a **single global widget** (freshest sample wins) over a per-tab bar showing identical values. Caveat: if different sessions ever use different Claude accounts (rare — one machine usually = one login), keep per-session storage and label the global widget with the account/most-recent source.
|
||||
|
||||
2. **Settings-override risk.** `settings.local.json.statusLine` **overrides** the user's own `~/.claude/settings.json` statusLine inside managed sessions. The print-through design (§1) mitigates the visible degradation; keeping the feature **opt-in** avoids surprising users who have a custom statusline. (Managed Ralph/autonomous cases are rarely viewed in-terminal anyway — the web UI is the real surface.)
|
||||
|
||||
3. **Subscriber + post-first-response gating.** Degrade gracefully when `rate_limits` is absent (API-key auth, free tier, or before the first response). Keep `usage-limit-patterns.ts` as the **fallback** for older CLIs / non-subscribers.
|
||||
|
||||
4. **Security stays in the existing envelope.** The exporter runs arbitrary shell every render, but that's the same trust model as the hook curls (localhost + `$CODEMAN_HOOK_SECRET_FILE`). No new exposure; reuse the hook secret and a separate rate-limit bucket.
|
||||
|
||||
5. **External CLI modes.** OpenCode/Codex render their own TUIs and emit no `rate_limits` JSON. Gate the statusLine injection to Claude mode only (`isExternalCliMode()` guard), exactly as Ralph/BashToolParser are gated.
|
||||
|
||||
6. **Synergy with auto-resume (future).** Live percentages let `SessionAutoOps` pre-arm *before* the wall (e.g. at 100% projected) rather than only reacting to the stall footer, and let the UI show "limit imminent." Out of scope for v1 but the data makes it trivial later.
|
||||
|
||||
## Implementation surface area (checklist)
|
||||
|
||||
- [ ] `src/hooks-config.ts` — generate a `statusLine` block (opt-in setting, Claude-only); merge into `settings.local.json`.
|
||||
- [ ] `src/web/routes/session-routes.ts` — `POST /api/sessions/:id/statusline` (auth-exempt + hook-secret + dedicated rate-limit bucket), returns formatted status string.
|
||||
- [ ] `src/web/schemas.ts` — Zod schema for the telemetry subset (`.nullish()`/passthrough).
|
||||
- [ ] `src/web/sse-events.ts` + `src/web/public/constants.js` — new SSE event (keep in sync).
|
||||
- [ ] `SessionState` + `session.toState()` + `persistSessionState()` — persist last telemetry.
|
||||
- [ ] Frontend gauge + `app.js` listener; placement in header or `respawn-ui.js`.
|
||||
- [ ] App Settings toggle `showPlanUsageLimits` (Display section), reload-on-toggle (statusLine is settings-injected at session create / via `writeHooksConfig`).
|
||||
- [ ] Tests: pure formatter (percent + reset humanizer) unit-tested like `usage-limit-patterns.test.ts`; route test via `app.inject()`.
|
||||
|
||||
## Open questions / risks
|
||||
|
||||
- **Schema stability.** `rate_limits` is an officially-shipped statusline field but undocumented in exact shape; a future CC version could add windows (e.g. an Opus field) or rename keys. The server parser should be tolerant (ignore unknown windows, render whatever windows exist) rather than hard-coding only `five_hour`/`seven_day`.
|
||||
- **Re-injection timing.** Toggling the setting must (re)write `statusLine` into existing cases or only new sessions — decide whether to push to live cases via `writeHooksConfig`-style update or require a respawn.
|
||||
- **Multi-account.** See consideration #1 — confirm whether any real deployment runs sessions under different Claude logins before committing to a single global widget.
|
||||
|
||||
## Verification appendix — how the schema was captured (reproducible)
|
||||
|
||||
Captured without touching global settings or any real session:
|
||||
|
||||
1. Throwaway dir `/tmp/sl-capture` with an exporter `dump.sh` that appends stdin to `payloads.jsonl` and prints `cap`; a `settings.json` pointing `statusLine.command` at it.
|
||||
2. `--print` mode does **not** render a statusline → no capture (confirms TUI-only). Must use interactive.
|
||||
3. Launch interactive Claude in an **isolated tmux socket** (`tmux -L slcap`, never `-L codeman`) inside the temp dir, `--settings /tmp/sl-capture/settings.json` (no global mutation). Confirm the workspace-trust dialog (appears even with `--dangerously-skip-permissions`), then send a one-line prompt (literal text + Enter separately, Ink-style).
|
||||
4. After the first response, `rate_limits` appears in the **second** captured record (absent in the first). Inspect with `jq '.rate_limits'`.
|
||||
5. Tear down: `tmux -L slcap kill-server` + `rm -rf /tmp/sl-capture`; verify the `codeman` socket is untouched.
|
||||
|
||||
Related: `docs/claude-code-hooks-reference.md` (hook callback pattern), `src/usage-limit-patterns.ts` (reactive fallback), `docs/respawn-state-machine.md` (auto-resume interplay).
|
||||
@@ -213,3 +213,59 @@ export async function writeHooksConfig(casePath: string): Promise<void> {
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/** Unique marker identifying Codeman's own statusLine command (vs a user's). */
|
||||
const STATUSLINE_MARKER = '/api/status-telemetry';
|
||||
|
||||
/**
|
||||
* The plan-usage statusLine exporter command. Mirrors the hook `curlCmd` pattern:
|
||||
* reads Claude Code's statusline stdin JSON, POSTs `{sessionId,data}` to Codeman,
|
||||
* and prints the response body (a compact "⟳ 5h 15% · 7d 34%" footer) back to
|
||||
* stdout so the in-terminal statusline stays useful. Env vars resolve at runtime
|
||||
* (present in every managed session via tmux setenv), so the config is static.
|
||||
*/
|
||||
export function generateStatusLineCommand(): string {
|
||||
return (
|
||||
`INPUT=$(cat 2>/dev/null || echo '{}'); ` +
|
||||
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
|
||||
`curl -s -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
|
||||
`-H 'Content-Type: application/json' ` +
|
||||
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
||||
`--data @- 2>/dev/null || true`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or remove Codeman's plan-usage statusLine exporter in
|
||||
* `.claude/settings.local.json`. Only ever touches a statusLine that is OURS
|
||||
* (command targets `/api/status-telemetry`), so a user's hand-authored
|
||||
* statusLine is never removed. Callers gate on Claude mode + a Codeman-managed
|
||||
* case path. Merges, preserving all other keys (hooks, env, model).
|
||||
*/
|
||||
export async function applyStatusLineConfig(casePath: string, enabled: boolean): Promise<void> {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
|
||||
let existing: Record<string, unknown> = {};
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
existing = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
return; // Malformed — don't rewrite it
|
||||
}
|
||||
}
|
||||
|
||||
const current = existing.statusLine as { command?: unknown } | undefined;
|
||||
const isOurs = !!current && typeof current.command === 'string' && current.command.includes(STATUSLINE_MARKER);
|
||||
|
||||
if (enabled) {
|
||||
if (isOurs) return; // already configured — avoid rewriting on every session create
|
||||
if (!existsSync(claudeDir)) await mkdir(claudeDir, { recursive: true });
|
||||
existing.statusLine = { type: 'command', command: generateStatusLineCommand() };
|
||||
} else {
|
||||
if (!isOurs) return; // nothing of ours to remove (leave a user's own statusLine alone)
|
||||
delete existing.statusLine;
|
||||
}
|
||||
|
||||
await writeFile(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @fileoverview Pure parsing + formatting of Claude Code statusline telemetry.
|
||||
*
|
||||
* Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command`
|
||||
* on each render. On Pro/Max subscriptions that blob carries a `rate_limits`
|
||||
* object with the 5-hour rolling and 7-day weekly plan windows. The
|
||||
* Codeman-managed statusLine exporter (see `hooks-config.generateStatusLineCommand`)
|
||||
* POSTs that blob to `/api/status-telemetry`; these helpers normalize the subset
|
||||
* Codeman displays and format the compact in-terminal footer string.
|
||||
*
|
||||
* Confirmed schema (empirically captured, CC 2.1.177, Claude Max — see
|
||||
* `docs/usage-limits-display-plan.md`):
|
||||
* rate_limits.{five_hour,seven_day}.{used_percentage: number 0-100,
|
||||
* resets_at: number EPOCH-SECONDS}
|
||||
* Only those two windows exist (no Opus-weekly field). `rate_limits` is absent
|
||||
* before the first API response and for non-subscriber auth — both yield null.
|
||||
*
|
||||
* All functions are pure for testability. See `test/usage-telemetry.test.ts`.
|
||||
*
|
||||
* @module usage-telemetry
|
||||
*/
|
||||
|
||||
/** A single normalized plan-usage window. */
|
||||
export interface UsageWindow {
|
||||
/** Percent of the window consumed, 0–100. */
|
||||
usedPercentage: number;
|
||||
/** Epoch MILLISECONDS when the window resets (statusline reports seconds). */
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
/** Normalized telemetry Codeman broadcasts to the UI. */
|
||||
export interface StatusTelemetry {
|
||||
fiveHour?: UsageWindow;
|
||||
sevenDay?: UsageWindow;
|
||||
/** Context-window percent used, 0–100 (bonus field from the same payload). */
|
||||
contextUsedPercentage?: number;
|
||||
/** Session cost in USD (bonus field). */
|
||||
costUsd?: number;
|
||||
/** Model display name, e.g. "Opus 4.8 (1M context)" (bonus field). */
|
||||
modelDisplayName?: string;
|
||||
}
|
||||
|
||||
/** Raw subset of the statusline stdin JSON (snake_case, as Claude emits it). */
|
||||
export interface RawStatuslinePayload {
|
||||
rate_limits?: {
|
||||
five_hour?: { used_percentage?: number; resets_at?: number };
|
||||
seven_day?: { used_percentage?: number; resets_at?: number };
|
||||
};
|
||||
context_window?: { used_percentage?: number };
|
||||
cost?: { total_cost_usd?: number };
|
||||
model?: { display_name?: string };
|
||||
}
|
||||
|
||||
function clampPct(n: number): number {
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.max(0, Math.min(100, n));
|
||||
}
|
||||
|
||||
function parseWindow(w?: { used_percentage?: number; resets_at?: number }): UsageWindow | undefined {
|
||||
if (!w || typeof w.used_percentage !== 'number' || typeof w.resets_at !== 'number') return undefined;
|
||||
if (!Number.isFinite(w.resets_at) || w.resets_at <= 0) return undefined;
|
||||
return { usedPercentage: clampPct(w.used_percentage), resetAt: Math.round(w.resets_at * 1000) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a raw statusline payload to the telemetry Codeman displays. Returns
|
||||
* null when there is no plan-limit data to show (pre-first-response or a
|
||||
* non-subscriber account) so the caller can skip broadcasting.
|
||||
*/
|
||||
export function parseStatusTelemetry(data: RawStatuslinePayload | undefined): StatusTelemetry | null {
|
||||
if (!data) return null;
|
||||
const fiveHour = parseWindow(data.rate_limits?.five_hour);
|
||||
const sevenDay = parseWindow(data.rate_limits?.seven_day);
|
||||
if (!fiveHour && !sevenDay) return null;
|
||||
|
||||
const t: StatusTelemetry = {};
|
||||
if (fiveHour) t.fiveHour = fiveHour;
|
||||
if (sevenDay) t.sevenDay = sevenDay;
|
||||
if (typeof data.context_window?.used_percentage === 'number') {
|
||||
t.contextUsedPercentage = clampPct(data.context_window.used_percentage);
|
||||
}
|
||||
if (typeof data.cost?.total_cost_usd === 'number' && Number.isFinite(data.cost.total_cost_usd)) {
|
||||
t.costUsd = data.cost.total_cost_usd;
|
||||
}
|
||||
if (typeof data.model?.display_name === 'string' && data.model.display_name) {
|
||||
t.modelDisplayName = data.model.display_name.slice(0, 60);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Compact in-terminal footer text (statusLine stdout / print-through). */
|
||||
export function formatStatusLineText(t: StatusTelemetry | null): string {
|
||||
if (!t) return 'codeman';
|
||||
const parts: string[] = [];
|
||||
if (t.fiveHour) parts.push(`5h ${Math.round(t.fiveHour.usedPercentage)}%`);
|
||||
if (t.sevenDay) parts.push(`7d ${Math.round(t.sevenDay.usedPercentage)}%`);
|
||||
return parts.length ? `⟳ ${parts.join(' · ')}` : 'codeman';
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature for change-detection — the statusline fires on every
|
||||
* assistant message, so the route only rebroadcasts when this value changes.
|
||||
*/
|
||||
export function telemetrySignature(t: StatusTelemetry): string {
|
||||
return JSON.stringify([
|
||||
t.fiveHour?.usedPercentage ?? null,
|
||||
t.fiveHour?.resetAt ?? null,
|
||||
t.sevenDay?.usedPercentage ?? null,
|
||||
t.sevenDay?.resetAt ?? null,
|
||||
t.contextUsedPercentage ?? null,
|
||||
]);
|
||||
}
|
||||
@@ -107,8 +107,9 @@ export function registerAuthMiddleware(
|
||||
}
|
||||
|
||||
app.addHook('onRequest', (req, reply, done) => {
|
||||
// Hook events come from local Claude Code hooks (curl from localhost) — no
|
||||
// Basic-Auth credentials available. Validated downstream by HookEventSchema.
|
||||
// Hook events + statusline telemetry come from local Claude Code (curl from
|
||||
// localhost) — no Basic-Auth credentials available. Validated downstream by
|
||||
// HookEventSchema / StatusTelemetrySchema. Same loopback+hook-secret gate.
|
||||
//
|
||||
// COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
|
||||
// `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
|
||||
@@ -119,7 +120,7 @@ export function registerAuthMiddleware(
|
||||
// - tunnel not running (loopback-only, the normal case) → keep the plain
|
||||
// localhost bypass so already-deployed (pre-secret) hooks + the loop's own
|
||||
// credential-less hook channel keep working.
|
||||
if (req.url === '/api/hook-event' && req.method === 'POST') {
|
||||
if ((req.url === '/api/hook-event' || req.url === '/api/status-telemetry') && req.method === 'POST') {
|
||||
const ip = req.ip;
|
||||
const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
||||
if (isLoopback) {
|
||||
|
||||
@@ -157,6 +157,7 @@ const _SSE_HANDLER_MAP = [
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME, '_onSessionLimitResume'],
|
||||
[SSE_EVENTS.SESSION_LIMIT_RESUME_CANCELLED, '_onSessionLimitResumeCancelled'],
|
||||
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
|
||||
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
|
||||
|
||||
// Scheduled runs
|
||||
[SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'],
|
||||
@@ -1812,6 +1813,35 @@ class CodemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Claude plan usage limits (5-hour + weekly) — account-global, so the latest
|
||||
// sample from any session drives the shared header chip.
|
||||
_onSessionStatusTelemetry(data) {
|
||||
this._latestPlanUsage = data;
|
||||
this.updatePlanUsageChip(data);
|
||||
}
|
||||
|
||||
updatePlanUsageChip(data) {
|
||||
const chip = document.getElementById('planUsageChip');
|
||||
if (!chip || !data) return;
|
||||
const pct = (w) => (w && typeof w.usedPercentage === 'number' ? Math.round(w.usedPercentage) : null);
|
||||
const five = pct(data.fiveHour);
|
||||
const seven = pct(data.sevenDay);
|
||||
if (five === null && seven === null) return;
|
||||
const parts = [];
|
||||
if (five !== null) parts.push(`5h ${five}%`);
|
||||
if (seven !== null) parts.push(`7d ${seven}%`);
|
||||
chip.textContent = parts.join(' · ');
|
||||
// Color hint as a window nears exhaustion.
|
||||
const max = Math.max(five ?? 0, seven ?? 0);
|
||||
chip.classList.toggle('header-plan-usage--warn', max >= 80 && max < 95);
|
||||
chip.classList.toggle('header-plan-usage--crit', max >= 95);
|
||||
const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—');
|
||||
chip.title =
|
||||
`Claude plan usage\n` +
|
||||
`5-hour: ${five ?? '—'}% (resets ${resetStr(data.fiveHour)})\n` +
|
||||
`Weekly: ${seven ?? '—'}% (resets ${resetStr(data.sevenDay)})`;
|
||||
}
|
||||
|
||||
// Scheduled runs
|
||||
_onScheduledCreated(data) {
|
||||
this.currentRun = data;
|
||||
|
||||
@@ -251,6 +251,7 @@ const SSE_EVENTS = {
|
||||
SESSION_MESSAGE: 'session:message',
|
||||
SESSION_INTERACTIVE: 'session:interactive',
|
||||
SESSION_RUNNING: 'session:running',
|
||||
SESSION_STATUS_TELEMETRY: 'session:statusTelemetry',
|
||||
|
||||
// Scheduled runs
|
||||
SCHEDULED_CREATED: 'scheduled:created',
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
</div>
|
||||
<button class="btn-icon-header btn-response-viewer-header btn-response-viewer-header--hidden" onclick="app.toggleResponseViewer()" title="View last response" aria-label="View last response"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
<button class="btn-icon-header btn-multimonitor btn-multimonitor--hidden" onclick="app.launchMultiMonitor()" title="Open Codeman across all displays" aria-label="Open Codeman across all displays"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="11" y="9" width="11" height="8" rx="1.5"/></svg></button>
|
||||
<div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude plan usage limits">—</div>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
@@ -1025,6 +1026,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-item" title="Show Claude plan usage limits (5-hour & weekly) in the header. Applies to newly created sessions.">
|
||||
<span class="settings-item-label">Plan Usage Limits</span>
|
||||
<label class="switch switch-sm">
|
||||
<input type="checkbox" id="appSettingsShowPlanUsageLimits">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar Section -->
|
||||
<div class="settings-section-header">Tab Bar</div>
|
||||
|
||||
@@ -370,6 +370,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
...(hasEnvOverrides ? { envOverrides } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(modelOverride !== undefined ? { modelOverride } : {}),
|
||||
// Plan-usage statusLine exporter (App Settings → Display). Always
|
||||
// sent so toggling the setting off removes our exporter on next create.
|
||||
statusLineTelemetry: globalSettings.showPlanUsageLimits === true,
|
||||
})
|
||||
}).then(r => r.json())
|
||||
);
|
||||
|
||||
@@ -315,6 +315,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? defaults.showFileBrowser ?? false;
|
||||
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? defaults.showSubagents ?? false;
|
||||
document.getElementById('appSettingsShowMultiMonitorButton').checked = settings.showMultiMonitorButton ?? defaults.showMultiMonitorButton ?? false;
|
||||
document.getElementById('appSettingsShowPlanUsageLimits').checked = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
// Gesture control lives in the Input section (alongside Local Echo / CJK Input)
|
||||
// but is only available when the instance runs with CODEMAN_GESTURE=1 (server sets
|
||||
// window.__codemanGestureAvailable). Hide just this item otherwise so the toggle
|
||||
@@ -1369,6 +1370,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
|
||||
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
|
||||
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
|
||||
showPlanUsageLimits: document.getElementById('appSettingsShowPlanUsageLimits').checked,
|
||||
gestureControlEnabled: document.getElementById('appSettingsGestureControl').checked,
|
||||
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
|
||||
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
|
||||
@@ -1658,6 +1660,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
showFileBrowser: false,
|
||||
showSubagents: false,
|
||||
showMultiMonitorButton: false,
|
||||
showPlanUsageLimits: false,
|
||||
// Input
|
||||
gestureControlEnabled: false,
|
||||
// Feature toggles - keep tracking on even on mobile
|
||||
@@ -1770,6 +1773,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
multiMonitorBtn.classList.toggle('btn-multimonitor--hidden', !showMultiMonitorButton);
|
||||
}
|
||||
|
||||
// Plan-usage chip — hidden by default (App Settings → Display → "Plan Usage
|
||||
// Limits"). Server renders the initial state on reload; this handles a live
|
||||
// toggle from a settings save. Marker class (base is display:inline-flex
|
||||
// !important), matching the response-viewer/multimonitor pattern.
|
||||
const showPlanUsageLimits = settings.showPlanUsageLimits ?? defaults.showPlanUsageLimits ?? false;
|
||||
const planUsageChip = document.getElementById('planUsageChip');
|
||||
if (planUsageChip) {
|
||||
planUsageChip.classList.toggle('header-plan-usage--hidden', !showPlanUsageLimits);
|
||||
}
|
||||
|
||||
// Notification bell is retired (notifications live in Settings → Notifications
|
||||
// + the drawer); keep it hidden regardless of the notification-enabled state.
|
||||
const notifBtn = document.querySelector('.btn-notifications');
|
||||
|
||||
@@ -1107,6 +1107,7 @@ body.solo-mode .header-system-stats,
|
||||
body.solo-mode .header-tokens,
|
||||
body.solo-mode .btn-notifications,
|
||||
body.solo-mode .btn-multimonitor,
|
||||
body.solo-mode .header-plan-usage,
|
||||
body.solo-mode .btn-lifecycle-log {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -8321,6 +8322,38 @@ kbd {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Plan-usage chip (App Settings → Display → "Plan Usage Limits"). Shows the
|
||||
live 5-hour + weekly plan limits parsed from the Claude statusline. Hidden by
|
||||
default via the marker class below; the server strips it at render when the
|
||||
setting is on and the client toggles it live on save. */
|
||||
.header-plan-usage {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 11px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
color: var(--text-dim);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
.header-plan-usage--warn {
|
||||
color: #e6b800;
|
||||
border-color: rgba(230, 184, 0, 0.4);
|
||||
}
|
||||
.header-plan-usage--crit {
|
||||
color: var(--danger, #ff5c5c);
|
||||
border-color: rgba(255, 92, 92, 0.5);
|
||||
}
|
||||
/* Marker class must out-specify the display:inline-flex !important above. */
|
||||
.header-plan-usage.header-plan-usage--hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.response-viewer {
|
||||
display: none;
|
||||
position: fixed;
|
||||
|
||||
@@ -9,6 +9,7 @@ export { registerFileRoutes } from './file-routes.js';
|
||||
export { registerScheduledRoutes } from './scheduled-routes.js';
|
||||
export { registerSystemRoutes } from './system-routes.js';
|
||||
export { registerHookEventRoutes } from './hook-event-routes.js';
|
||||
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
|
||||
export { registerCaseRoutes } from './case-routes.js';
|
||||
export { registerSessionRoutes } from './session-routes.js';
|
||||
export { registerRespawnRoutes } from './respawn-routes.js';
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
validatePathWithinBase,
|
||||
} from '../route-helpers.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys } from '../../hooks-config.js';
|
||||
import { writeHooksConfig, updateCaseModel, stripCaseEnvKeys, applyStatusLineConfig } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { imageWatcher } from '../../image-watcher.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
@@ -297,6 +297,14 @@ export function registerSessionRoutes(
|
||||
await updateCaseModel(workingDir, body.modelOverride || null);
|
||||
}
|
||||
|
||||
// Plan-usage statusLine exporter (App Settings → Display → "Plan Usage
|
||||
// Limits"). Claude-only, and only inside Codeman-managed cases (never
|
||||
// arbitrary user repos, which may carry a hand-authored statusLine). Adds or
|
||||
// removes our exporter so toggling the setting off cleans up on next create.
|
||||
if ((body.mode ?? 'claude') === 'claude' && workingDir.startsWith(CASES_DIR + '/')) {
|
||||
await applyStatusLineConfig(workingDir, body.statusLineTelemetry === true);
|
||||
}
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
if (body.mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @fileoverview Status-telemetry route.
|
||||
*
|
||||
* Receives Claude Code statusline payloads POSTed by the Codeman-managed
|
||||
* statusLine exporter (see `hooks-config.generateStatusLineCommand`) and
|
||||
* broadcasts the parsed plan-usage limits (5-hour + weekly) to SSE clients for
|
||||
* the header "Plan Usage Limits" chip. Auth-exempt like `/api/hook-event`
|
||||
* (localhost-only; hook-secret-gated while a tunnel runs — see middleware/auth).
|
||||
*
|
||||
* Returns a compact plain-text status string for the exporter to print as the
|
||||
* in-terminal footer (print-through), so injecting our statusLine doesn't leave
|
||||
* the terminal footer blank.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { StatusTelemetrySchema } from '../schemas.js';
|
||||
import { parseBody } from '../route-helpers.js';
|
||||
import {
|
||||
parseStatusTelemetry,
|
||||
formatStatusLineText,
|
||||
telemetrySignature,
|
||||
type RawStatuslinePayload,
|
||||
} from '../../usage-telemetry.js';
|
||||
import { SessionStatusTelemetry } from '../sse-events.js';
|
||||
import type { SessionPort, EventPort } from '../ports/index.js';
|
||||
|
||||
export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void {
|
||||
// Last broadcast telemetry signature per session — the statusline fires on
|
||||
// every assistant message, so we only rebroadcast when the value changes.
|
||||
const lastSig = new Map<string, string>();
|
||||
|
||||
app.post('/api/status-telemetry', async (req, reply) => {
|
||||
const { sessionId, data } = parseBody(StatusTelemetrySchema, req.body);
|
||||
|
||||
reply.type('text/plain; charset=utf-8');
|
||||
|
||||
// Unknown session — minimal footer, no broadcast.
|
||||
if (!ctx.sessions.has(sessionId)) {
|
||||
lastSig.delete(sessionId);
|
||||
return 'codeman';
|
||||
}
|
||||
|
||||
const telemetry = parseStatusTelemetry(data as RawStatuslinePayload | undefined);
|
||||
if (!telemetry) {
|
||||
// No plan-limit data yet (pre-first-response or non-subscriber auth).
|
||||
return 'codeman';
|
||||
}
|
||||
|
||||
const sig = telemetrySignature(telemetry);
|
||||
if (lastSig.get(sessionId) !== sig) {
|
||||
lastSig.set(sessionId, sig);
|
||||
// Bound the map across long multi-session runs: prune dead sessions.
|
||||
if (lastSig.size > 256) {
|
||||
for (const id of [...lastSig.keys()]) {
|
||||
if (!ctx.sessions.has(id)) lastSig.delete(id);
|
||||
}
|
||||
}
|
||||
ctx.broadcast(SessionStatusTelemetry, { sessionId, ...telemetry });
|
||||
}
|
||||
|
||||
return formatStatusLineText(telemetry);
|
||||
});
|
||||
}
|
||||
@@ -158,6 +158,8 @@ export const CreateSessionSchema = z.object({
|
||||
effort: effortLevelSchema,
|
||||
/** Model override to write to .claude/settings.local.json (e.g., "opus[1m]"). Empty string clears. */
|
||||
modelOverride: z.string().max(50).optional(),
|
||||
/** Inject the plan-usage statusLine exporter into the case (App Settings → Display → "Plan Usage Limits"). Claude-only. */
|
||||
statusLineTelemetry: z.boolean().optional(),
|
||||
openCodeConfig: OpenCodeConfigSchema,
|
||||
codexConfig: CodexConfigSchema,
|
||||
/** Resume a previous Claude conversation by its session ID (used for reboot recovery) */
|
||||
@@ -186,6 +188,37 @@ export const ResizeSchema = z.object({
|
||||
viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Schema for POST /api/status-telemetry
|
||||
* Claude Code statusline payload forwarded by the Codeman-managed statusLine
|
||||
* exporter (see hooks-config.generateStatusLineCommand). Validates only the
|
||||
* subset Codeman displays; unknown keys (session_id, transcript_path, cwd, …)
|
||||
* are stripped by z.object. Auth-exempt like /api/hook-event.
|
||||
*/
|
||||
const RateLimitWindowSchema = z
|
||||
.object({
|
||||
used_percentage: z.number().optional(),
|
||||
resets_at: z.number().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const StatusTelemetrySchema = z.object({
|
||||
sessionId: z.string().min(1).max(100),
|
||||
data: z
|
||||
.object({
|
||||
rate_limits: z
|
||||
.object({
|
||||
five_hour: RateLimitWindowSchema,
|
||||
seven_day: RateLimitWindowSchema,
|
||||
})
|
||||
.optional(),
|
||||
context_window: z.object({ used_percentage: z.number().optional() }).optional(),
|
||||
cost: z.object({ total_cost_usd: z.number().optional() }).optional(),
|
||||
model: z.object({ display_name: z.string().max(100).optional() }).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ========== Case Routes ==========
|
||||
|
||||
/**
|
||||
@@ -321,6 +354,7 @@ export const SettingsUpdateSchema = z
|
||||
showFileBrowser: z.boolean().optional(),
|
||||
showSubagents: z.boolean().optional(),
|
||||
showMultiMonitorButton: z.boolean().optional(),
|
||||
showPlanUsageLimits: z.boolean().optional(),
|
||||
// Input
|
||||
gestureControlEnabled: z.boolean().optional(),
|
||||
// Claude CLI settings
|
||||
|
||||
@@ -135,6 +135,7 @@ import {
|
||||
registerFileRoutes,
|
||||
registerScheduledRoutes,
|
||||
registerHookEventRoutes,
|
||||
registerStatusTelemetryRoutes,
|
||||
registerSystemRoutes,
|
||||
registerCaseRoutes,
|
||||
registerSessionRoutes,
|
||||
@@ -811,6 +812,7 @@ export class WebServer extends EventEmitter {
|
||||
registerFileRoutes(this.app, ctx);
|
||||
registerScheduledRoutes(this.app, ctx);
|
||||
registerHookEventRoutes(this.app, ctx);
|
||||
registerStatusTelemetryRoutes(this.app, ctx);
|
||||
registerSystemRoutes(this.app, ctx);
|
||||
registerCaseRoutes(this.app, ctx);
|
||||
registerSessionRoutes(this.app, ctx);
|
||||
@@ -1121,6 +1123,13 @@ export class WebServer extends EventEmitter {
|
||||
if (settings.showMultiMonitorButton === true) {
|
||||
html = html.replace(' btn-multimonitor--hidden', '');
|
||||
}
|
||||
// Plan-usage chip: carries `header-plan-usage--hidden` by default (App
|
||||
// Settings → Display → "Plan Usage Limits"); reveal by stripping the marker
|
||||
// class when the user enabled it, so the chip paints in the right state on
|
||||
// every normal reload (the client toggles the same class live on save).
|
||||
if (settings.showPlanUsageLimits === true) {
|
||||
html = html.replace(' header-plan-usage--hidden', '');
|
||||
}
|
||||
// Detached single-session ("solo") window: inject the target session id so
|
||||
// the client can enter solo mode even if a (network-first) service worker
|
||||
// later serves a cached shell. The client primarily detects solo mode from
|
||||
|
||||
@@ -87,6 +87,8 @@ export const SessionMessage = 'session:message' as const;
|
||||
export const SessionInteractive = 'session:interactive' as const;
|
||||
/** Prompt sent to session for execution. */
|
||||
export const SessionRunning = 'session:running' as const;
|
||||
/** Claude plan-usage telemetry (5-hour + weekly limits) parsed from the statusline. */
|
||||
export const SessionStatusTelemetry = 'session:statusTelemetry' as const;
|
||||
|
||||
// ─── Session: Ralph ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -376,6 +378,7 @@ export const SseEvent = {
|
||||
SessionMessage,
|
||||
SessionInteractive,
|
||||
SessionRunning,
|
||||
SessionStatusTelemetry,
|
||||
|
||||
// Session: Ralph
|
||||
SessionRalphLoopUpdate,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
parseStatusTelemetry,
|
||||
formatStatusLineText,
|
||||
telemetrySignature,
|
||||
type RawStatuslinePayload,
|
||||
} from '../src/usage-telemetry.js';
|
||||
|
||||
// Mirrors the real captured statusline payload (CC 2.1.177, Claude Max) — see
|
||||
// docs/usage-limits-display-plan.md. resets_at is epoch SECONDS.
|
||||
const REAL: RawStatuslinePayload = {
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 15, resets_at: 1781409000 },
|
||||
seven_day: { used_percentage: 34, resets_at: 1781827200 },
|
||||
},
|
||||
context_window: { used_percentage: 2 },
|
||||
cost: { total_cost_usd: 0.0415495 },
|
||||
model: { display_name: 'Opus 4.8 (1M context)' },
|
||||
};
|
||||
|
||||
describe('parseStatusTelemetry', () => {
|
||||
it('normalizes the real payload, converting resets_at seconds → ms', () => {
|
||||
const t = parseStatusTelemetry(REAL);
|
||||
expect(t).not.toBeNull();
|
||||
expect(t!.fiveHour).toEqual({ usedPercentage: 15, resetAt: 1781409000 * 1000 });
|
||||
expect(t!.sevenDay).toEqual({ usedPercentage: 34, resetAt: 1781827200 * 1000 });
|
||||
expect(t!.contextUsedPercentage).toBe(2);
|
||||
expect(t!.costUsd).toBeCloseTo(0.0415495);
|
||||
expect(t!.modelDisplayName).toBe('Opus 4.8 (1M context)');
|
||||
});
|
||||
|
||||
it('returns null when there is no rate_limits (pre-first-response / non-subscriber)', () => {
|
||||
expect(parseStatusTelemetry({})).toBeNull();
|
||||
expect(parseStatusTelemetry(undefined)).toBeNull();
|
||||
expect(parseStatusTelemetry({ context_window: { used_percentage: 5 } })).toBeNull();
|
||||
expect(parseStatusTelemetry({ rate_limits: {} })).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts a single window when only one is present', () => {
|
||||
const t = parseStatusTelemetry({ rate_limits: { five_hour: { used_percentage: 50, resets_at: 1781409000 } } });
|
||||
expect(t!.fiveHour?.usedPercentage).toBe(50);
|
||||
expect(t!.sevenDay).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops a window with a missing or non-numeric field', () => {
|
||||
const t = parseStatusTelemetry({
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 20 }, // no resets_at → dropped
|
||||
seven_day: { used_percentage: 40, resets_at: 1781827200 },
|
||||
},
|
||||
});
|
||||
expect(t!.fiveHour).toBeUndefined();
|
||||
expect(t!.sevenDay?.usedPercentage).toBe(40);
|
||||
});
|
||||
|
||||
it('clamps percentages to 0–100', () => {
|
||||
const t = parseStatusTelemetry({
|
||||
rate_limits: {
|
||||
five_hour: { used_percentage: 150, resets_at: 1781409000 },
|
||||
seven_day: { used_percentage: -5, resets_at: 1781827200 },
|
||||
},
|
||||
});
|
||||
expect(t!.fiveHour?.usedPercentage).toBe(100);
|
||||
expect(t!.sevenDay?.usedPercentage).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores a zero/negative reset timestamp', () => {
|
||||
expect(parseStatusTelemetry({ rate_limits: { five_hour: { used_percentage: 10, resets_at: 0 } } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatStatusLineText', () => {
|
||||
it('formats both windows compactly', () => {
|
||||
expect(formatStatusLineText(parseStatusTelemetry(REAL))).toBe('⟳ 5h 15% · 7d 34%');
|
||||
});
|
||||
|
||||
it('falls back to a brand string when there is no data', () => {
|
||||
expect(formatStatusLineText(null)).toBe('codeman');
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetrySignature', () => {
|
||||
it('is stable for equal telemetry and changes when a percentage moves', () => {
|
||||
const a = parseStatusTelemetry(REAL)!;
|
||||
const b = parseStatusTelemetry(REAL)!;
|
||||
expect(telemetrySignature(a)).toBe(telemetrySignature(b));
|
||||
|
||||
const moved = parseStatusTelemetry({
|
||||
...REAL,
|
||||
rate_limits: { ...REAL.rate_limits, five_hour: { used_percentage: 16, resets_at: 1781409000 } },
|
||||
})!;
|
||||
expect(telemetrySignature(moved)).not.toBe(telemetrySignature(a));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user