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:
arkon
2026-06-14 05:00:28 +02:00
co-authored by Claude Opus 4.8
parent 0809f59f0f
commit c82f6c802e
17 changed files with 657 additions and 4 deletions
+30
View File
@@ -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;
+1
View File
@@ -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',
+8
View File
@@ -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>
+3
View File
@@ -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())
);
+13
View File
@@ -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');
+33
View File
@@ -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;