diff --git a/README.md b/README.md index 95aed60..47cd96a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Browse and install curated extensions from **Campus → Settings → Extensions* ## Available extensions - [Usage Limits](extensions/io.flutterflow.campus.usage-limits/) — view Claude - Code and Codex usage in one Campus dashboard. + Code and Codex usage in independent compact Campus cards. ## Contributing diff --git a/extensions/io.flutterflow.campus.usage-limits/CHANGELOG.md b/extensions/io.flutterflow.campus.usage-limits/CHANGELOG.md index 59cf3bc..ef96371 100644 --- a/extensions/io.flutterflow.campus.usage-limits/CHANGELOG.md +++ b/extensions/io.flutterflow.campus.usage-limits/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 2.0.0 + +- Replaced the combined dashboard with the locally developed independent + Claude and Codex usage cards. +- Replaced the Codex process and Claude external-action collectors with + origin-exact, prefix-bounded Anthropic and ChatGPT connector requests backed + by Campus-owned local credentials. +- Rendered update labels with the owner's current local offset instead of UTC; + reset labels remain relative so daylight-saving transitions stay correct. +- Added a visible in-flight refresh state that replaces stale metrics and bars + with fixed low-contrast skeleton rows. +- Bounded provider labels and usage rows and kept raw account/credential/error + material out of shared state. +- This release replaces the persisted `usage.dashboard` surface. Existing 1.0 + dashboard tiles must be removed and recreated as individual Claude and Codex + cards after updating. + ## 1.0.0 - Added a shared Usage Limits dashboard for normalized Claude Code and Codex diff --git a/extensions/io.flutterflow.campus.usage-limits/README.md b/extensions/io.flutterflow.campus.usage-limits/README.md index c2dac50..7b181ea 100644 --- a/extensions/io.flutterflow.campus.usage-limits/README.md +++ b/extensions/io.flutterflow.campus.usage-limits/README.md @@ -1,124 +1,119 @@ # Usage Limits -`io.flutterflow.campus.usage-limits` is a schema-v3 authoring example for one -shareable Campus dashboard containing normalized Claude Code and Codex usage -metrics. It deliberately has no HTTP connector, OAuth flow, bearer token, API -key, or undocumented network call. +`io.flutterflow.campus.usage-limits` is a schema-v3 Campus extension with the +same two-card experience as the locally developed Usage Limits package. It +contributes independent **Claude Usage Limits** and **Codex Usage Limits** +launchers, each rendered as a compact 420-pixel canvas object. -The package uses the current schema-v3 generic-resource and external-action -contracts. Keep `schemaVersion: 3`; do not replace the local host seams with a -web request. +The extension has no process, filesystem, native-helper, external-action, or +arbitrary-network authority. It declares two origin-exact, prefix-bounded +read-only HTTP connectors and publishes only bounded display values into shared +state. ## Data flow -### Codex +### Claude -The `codex` resource is a generic process grant scoped to the exact executable -name `codex`. Its coordinator: +The extension requests: -1. asks the host to spawn `codex app-server` without a shell; -2. performs the app-server `initialize` handshake; -3. sends `account/rateLimits/read` and `account/usage/read` JSON-RPC requests; -4. bounds stdout to 256 KiB and every JSON-RPC line/read to 64 KiB; -5. projects only displayable counters into shared state; and -6. terminates and waits for the child on every path. +```text +GET https://api.anthropic.com/api/oauth/usage +anthropic-beta: oauth-2025-04-20 +``` -The app-server reuses the owner's existing local Codex account session. The -extension never receives an OAuth token or calls an OpenAI HTTP endpoint. +Campus resolves the existing local Claude Code credential for that exact +origin and attaches it inside the host broker. Extension JavaScript never sees +the bearer value. The projection keeps at most twelve usage windows: a bounded +label, rounded used percentage, active state, and reset label. -### Claude +The manifest authorizes `GET` beneath the `/api/oauth/usage` path prefix, +including descendants and bounded query parameters; the shipped guest requests +only the path shown above with no query. -`externalActions.claudeStatus` is an owner-only, 256 KiB-bounded ingress mapped -to the ordinary `ingestClaudeStatus` action. Claude Code writes its status-line -JSON to the configured command's stdin, so the adapter is the generic Campus -invocation command itself: +### Codex -Do not replace a working status-line renderer with the quiet Campus ingestion -command: `--quiet` deliberately emits nothing on success, so using it as the -sole `statusLine` would leave the line blank. Instead, point Claude at a small -owner-managed wrapper which reads stdin once, replays the exact bytes to Campus -as a best-effort side effect, and then runs the existing renderer: +The extension requests: -```sh -#!/bin/sh -umask 077 -payload_file=$(mktemp "${TMPDIR:-/tmp}/campus-claude-status.XXXXXX") || exit 1 -trap 'rm -f "$payload_file"' EXIT HUP INT TERM -cat >"$payload_file" || exit 1 -campus extension invoke io.flutterflow.campus.usage-limits claudeStatus \ - --quiet <"$payload_file" >/dev/null 2>&1 || true -/absolute/path/to/existing-status-line-renderer <"$payload_file" +```text +GET https://chatgpt.com/backend-api/wham/usage ``` -Then preserve the existing `statusLine` object and change only its command to -the wrapper's absolute path. If there is no existing renderer, choose one -first; do not configure quiet ingestion alone. A Campus failure never replaces -or suppresses renderer output. Invocation requires exactly one active Usage -Limits runtime; zero or multiple instances fail closed. The action keeps only -the model display name, bounded context-window counters/percentages, and -bounded session cost/duration metrics. +Campus resolves the existing local Codex login for the exact `chatgpt.com` +origin. The projection keeps the bounded plan label, limit-reached flag, credit +summary, and at most twelve primary/secondary usage windows. + +The manifest authorizes `GET` beneath the `/backend-api/wham/usage` path prefix, +including descendants and bounded query parameters; the shipped guest requests +only the path shown above with no query. + +Origin matching is exact. A manifest cannot request or influence the local +credential source, and a different host receives no local-tool credential. -Campus integration owns invocation of the external action. This package does -not install or rewrite Claude Code configuration and does not read a transcript -or working directory. +## Updating from 1.0 + +Version 2.0 replaces the combined `usage.dashboard` surface with independent +`claude.card` and `codex.card` surfaces. Remove an existing 1.0 dashboard tile +and recreate the desired cards after approving the update. + +## Local time and refresh state + +Connector receipts carry the host-authored `receivedAt` instant and the +owner's east-positive `utcOffsetMinutes`. The guest uses those values to render +the update label in the owner's current local time. Reset labels stay relative, +which remains correct across daylight-saving transitions without pretending a +single receipt-time offset is a full time-zone database. It never uses the +sandbox's pinned `Date.now()` or claims a viewer-local timezone; an older host +that omits the offset shows `Updated just now` rather than falling back to UTC. + +Every refresh first publishes `status: "loading"`. While the network effect is +in flight, the renderer replaces stale percentages, progress fills, credits, +and warning copy with low-contrast skeleton rows and changes the action label +to **Refreshing…**. `scene.v1` intentionally has no guest timer or animation +lane, so the skeleton uses a fixed sheen rather than pretending to provide an +animated shimmer. ## Shared projection -Only the following normalized visible values enter `dashboard`, a 64 KiB -`shared.document` pool: +Only these normalized display values enter the 128 KiB `usage` shared-document +pool: | Source | Shared values | | --- | --- | -| Codex | plan label, limited flag, at most eight named usage windows, used percentage, reset time, window duration, credit summary, and bounded aggregate token/streak statistics | -| Claude | model display label, context used/remaining percentage, bounded context/input/output counters, session cost, and session/API duration | +| Claude | bounded window label, integer used percentage, active state, relative reset label, local update label | +| Codex | bounded plan label, limit-reached flag, bounded credit summary, integer used percentage, window label, relative reset label, local update label | -Account ids, email, auth state, tokens, raw RPC errors, arbitrary response -fields, Claude session ids, transcript paths, cwd/workspace data, and the raw -status-line payload are dropped before a state write. Teammates can view and -interact with the rendered tile, but both collection actions are owner-only. +Account IDs, email, credentials, tokens, raw connector bodies/errors, paths, +and arbitrary provider fields are not copied into state. Collection actions +are owner-only; room participants may see the normalized shared card. ## Source layout -- `src/read_codex_usage.js` is the only process-effect module. -- `src/codex_protocol.js` is pure bounded JSON-RPC framing. -- `src/codex_usage.js` and `src/claude_status.js` are allowlist-only parsers. -- `src/model.js` owns the canonical shared projection. -- `src/render.js` is a finite pure `scene.v1` renderer. -- `src/main.js` only coordinates resource binding, actions, state, and surface - activation. +- `src/main.js` coordinates state, actions, fixed connector requests, and both + surfaces. +- `src/model.js` bounds and normalizes provider receipts and formats host-owned + clock data. +- `src/card_ui.js` contains shared finite `scene.v1` card/skeleton builders. +- `src/render_claude.js` and `src/render_codex.js` are pure card renderers. ## Test -Run the dependency-free pure tests: +Run the dependency-free focused tests from the registry root: ```sh node --test \ extensions/io.flutterflow.campus.usage-limits/test/usage_limits.test.mjs ``` -The tests exercise hostile extra fields, bounds, JSON-RPC correlation, process -cleanup, unavailable-source behavior, shared-state redaction, coordinator -wiring, and finite rendering. +The suite covers the authority ceiling, exact origins and bounded path prefixes, local-time +formatting, provider projections, row/label bounds, redaction, failure states, +loading skeletons, finite unique scene keys, and coordinator wiring. It never +contacts a real account. ## Generate and validate -The source-version Campus tooling supports this schema-v3 manifest. Regenerate -the inert bindings after manifest identifier changes, then validate a clean -staging package. - -The registry source directory is an authoring tree: `README.md`, `CHANGELOG.md`, -and `test/` are not included in the production `.campusext`. The publisher -captures only: - -```text -campus.extension.json -src/*.js -src/*.mjs -src/campus.generated.d.mts -``` - -The registry's normative source, archive, digest, authority, and catalog check -is: +The registry source is an authoring tree: `README.md`, `CHANGELOG.md`, and +`test/` are excluded from the production archive. The registry's normative +source, archive, digest, authority, and catalog check is: ```sh python3 tool/registry.py validate --root . --json @@ -126,8 +121,5 @@ python3 tool/registry.py validate --root . --json When changing manifest identifiers, regenerate the inert bindings with the source-version Campus CLI in a `work_canvas` checkout before submitting the -source pull request. - -Validation does not grant the process resource, enable the package, install a -Claude status-line adapter, or execute extension code. Publication is a -separate generated catalog/artifact change. +source pull request. Validation does not grant connector access, install or +enable the package, execute code, or publish a release. diff --git a/extensions/io.flutterflow.campus.usage-limits/campus.extension.json b/extensions/io.flutterflow.campus.usage-limits/campus.extension.json index 2641ffe..2eb34ee 100644 --- a/extensions/io.flutterflow.campus.usage-limits/campus.extension.json +++ b/extensions/io.flutterflow.campus.usage-limits/campus.extension.json @@ -2,103 +2,130 @@ "schemaVersion": 3, "id": "io.flutterflow.campus.usage-limits", "name": "Usage Limits", - "version": "1.0.0", + "version": "2.0.0", "entry": "src/main.js", "capabilities": { "maximum": [ + "connector.http.request", "state.shared.write", "surface.scene.write" ], "required": [ + "connector.http.request", "state.shared.write", "surface.scene.write" ] }, - "resources": { - "codex": { - "kind": "process", - "operations": ["spawn", "write", "read", "wait", "signal"], - "request": { - "mode": "scoped", - "scopes": [ - {"type": "executable", "value": "codex"} - ] - } - } - }, - "externalActions": { - "claudeStatus": { - "action": "ingestClaudeStatus", - "resourceSlots": [], - "timeoutMs": 5000, - "maxInputBytes": 262144, - "maxOutputBytes": 65536 - } - }, + "resources": {}, "tasks": {}, "statePools": { - "dashboard": { + "usage": { "scope": "shared.document", - "maxBytes": 65536 + "maxBytes": 131072 + } + }, + "connectors": { + "anthropic": { + "kind": "http", + "baseUrl": "https://api.anthropic.com", + "pathPrefixes": ["/api/oauth/usage"], + "allowedMethods": ["GET"], + "headers": {"anthropic-beta": "oauth-2025-04-20"}, + "auth": {"type": "bearer"}, + "maxResponseBytes": 262144, + "timeoutMs": 20000 + }, + "openai": { + "kind": "http", + "baseUrl": "https://chatgpt.com", + "pathPrefixes": ["/backend-api/wham/usage"], + "allowedMethods": ["GET"], + "auth": {"type": "bearer"}, + "maxResponseBytes": 262144, + "timeoutMs": 20000 } }, "surfacePools": { - "dashboard": { + "claudeCard": { + "protocol": "scene.v1", + "statePool": "usage", + "renderer": "src/render_claude.js#render", + "layout": { + "width": {"strategy": "fixed", "value": 420}, + "height": {"strategy": "content"}, + "minSize": {"width": 420, "height": 120}, + "maxSize": {"width": 420, "height": 900} + }, + "maxNodes": 700, + "maxDepth": 24, + "maxBytes": 262144 + }, + "codexCard": { "protocol": "scene.v1", - "statePool": "dashboard", - "renderer": "src/render.js#render", + "statePool": "usage", + "renderer": "src/render_codex.js#render", "layout": { - "width": {"strategy": "fill"}, + "width": {"strategy": "fixed", "value": 420}, "height": {"strategy": "content"}, - "minSize": {"width": 560, "height": 360}, - "maxSize": {"width": 860, "height": 900} + "minSize": {"width": 420, "height": 120}, + "maxSize": {"width": 420, "height": 900} }, - "maxNodes": 420, - "maxDepth": 20, + "maxNodes": 700, + "maxDepth": 24, "maxBytes": 262144 } }, "actions": { - "refreshCodex": { + "refreshClaude": { "effect": "resourceReadAndPublish", "audience": "owner" }, - "ingestClaudeStatus": { - "effect": "sharedDocumentMutation", + "refreshCodex": { + "effect": "resourceReadAndPublish", "audience": "owner" } }, "contributes": { "launchers": [ { - "id": "usage-limits-dashboard", - "title": "Usage Limits", - "subtitle": "Share normalized Claude and Codex usage metrics on one dashboard", - "resourceSlots": ["codex"], + "id": "claude-usage", + "title": "Claude Usage Limits", + "subtitle": "Session and weekly rate-limit meters for your Claude account", "surfaces": [ { - "id": "usage.dashboard", - "surfacePool": "dashboard", - "title": "Usage Limits", + "id": "claude.card", + "surfacePool": "claudeCard", + "title": "Claude Usage", "role": "primary", + "frameMode": "canvasObject", "column": 0, "row": 0, "columnSpan": 1, "rowSpan": 1, - "defaultWidth": 720, - "defaultHeight": 520 + "defaultWidth": 420, + "defaultHeight": 300 } ] - } - ], - "commands": [ + }, { - "id": "refresh-codex", - "title": "Usage Limits: Refresh Codex", - "action": "refreshCodex", - "section": "Usage Limits", - "subtitle": "Read account usage from the locally granted Codex app-server", - "searchTerms": ["usage", "limits", "rate limit", "codex", "refresh"] + "id": "codex-usage", + "title": "Codex Usage Limits", + "subtitle": "Rate-limit meters for your ChatGPT Codex account", + "surfaces": [ + { + "id": "codex.card", + "surfacePool": "codexCard", + "title": "Codex Usage", + "role": "primary", + "frameMode": "canvasObject", + "column": 0, + "row": 0, + "columnSpan": 1, + "rowSpan": 1, + "defaultWidth": 420, + "defaultHeight": 300 + } + ] } ] } diff --git a/extensions/io.flutterflow.campus.usage-limits/src/campus.generated.d.mts b/extensions/io.flutterflow.campus.usage-limits/src/campus.generated.d.mts index cdd748a..ed446c8 100644 --- a/extensions/io.flutterflow.campus.usage-limits/src/campus.generated.d.mts +++ b/extensions/io.flutterflow.campus.usage-limits/src/campus.generated.d.mts @@ -2,14 +2,15 @@ // Literal types only; this declaration grants no host authority. export declare const actions: Readonly<{ - readonly "ingestClaudeStatus": "ingestClaudeStatus"; + readonly "refreshClaude": "refreshClaude"; readonly "refreshCodex": "refreshCodex"; }>; export declare const statePools: Readonly<{ - readonly "dashboard": "dashboard"; + readonly "usage": "usage"; }>; export declare const surfacePools: Readonly<{ - readonly "dashboard": "dashboard"; + readonly "claudeCard": "claudeCard"; + readonly "codexCard": "codexCard"; }>; export declare const sharedTexts: Readonly<{ }>; @@ -18,12 +19,14 @@ export declare const assetInputs: Readonly<{ export declare const settings: Readonly<{ }>; export declare const commands: Readonly<{ - readonly "refresh-codex": "refresh-codex"; }>; export declare const launchers: Readonly<{ - readonly "usage-limits-dashboard": "usage-limits-dashboard"; + readonly "claude-usage": "claude-usage"; + readonly "codex-usage": "codex-usage"; }>; export declare const layoutModes: Readonly<{ - readonly "dashboard": Readonly<{ + readonly "claudeCard": Readonly<{ + }>; + readonly "codexCard": Readonly<{ }>; }>; diff --git a/extensions/io.flutterflow.campus.usage-limits/src/campus.generated.mjs b/extensions/io.flutterflow.campus.usage-limits/src/campus.generated.mjs index aa9ff0e..fad7293 100644 --- a/extensions/io.flutterflow.campus.usage-limits/src/campus.generated.mjs +++ b/extensions/io.flutterflow.campus.usage-limits/src/campus.generated.mjs @@ -2,14 +2,15 @@ // This file contains inert IDs only and grants no host authority. export const actions = Object.freeze({ - "ingestClaudeStatus": "ingestClaudeStatus", + "refreshClaude": "refreshClaude", "refreshCodex": "refreshCodex", }); export const statePools = Object.freeze({ - "dashboard": "dashboard", + "usage": "usage", }); export const surfacePools = Object.freeze({ - "dashboard": "dashboard", + "claudeCard": "claudeCard", + "codexCard": "codexCard", }); export const sharedTexts = Object.freeze({ }); @@ -18,12 +19,14 @@ export const assetInputs = Object.freeze({ export const settings = Object.freeze({ }); export const commands = Object.freeze({ - "refresh-codex": "refresh-codex", }); export const launchers = Object.freeze({ - "usage-limits-dashboard": "usage-limits-dashboard", + "claude-usage": "claude-usage", + "codex-usage": "codex-usage", }); export const layoutModes = Object.freeze({ - "dashboard": Object.freeze({ + "claudeCard": Object.freeze({ + }), + "codexCard": Object.freeze({ }), }); diff --git a/extensions/io.flutterflow.campus.usage-limits/src/card_ui.js b/extensions/io.flutterflow.campus.usage-limits/src/card_ui.js new file mode 100644 index 0000000..0a3bbba --- /dev/null +++ b/extensions/io.flutterflow.campus.usage-limits/src/card_ui.js @@ -0,0 +1,314 @@ +// Shared scene builders for the usage cards. Pure functions: plain scene.v1 +// JSON nodes in the same dependency-free style as the maintained examples. + +export const cardWidth = 420; +const padding = 16; +export const contentWidth = cardWidth - padding * 2; + +export function boundedTextValue(value, maximum = 512) { + return Array.from( + String(value).replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ').trim(), + ).slice(0, maximum).join(''); +} + +export function text(key, value, props = {}) { + return {key, type: 'text', props: {value: boundedTextValue(value), ...props}}; +} + +export function usageRows(value, keyPrefix) { + if (!Array.isArray(value)) return []; + const rows = []; + const occurrences = new Map(); + for (const candidate of value) { + if (rows.length >= 12) break; + if (candidate === null || + typeof candidate !== 'object' || + typeof candidate.label !== 'string') { + continue; + } + const label = boundedTextValue(candidate.label, 72); + if (label === '') continue; + const rawPercent = candidate.percent; + const percent = typeof rawPercent === 'number' && Number.isFinite(rawPercent) + ? Math.max(0, Math.min(100, Math.round(rawPercent))) + : 0; + const identity = typeof candidate.id === 'string' && candidate.id !== '' + ? candidate.id + : label; + const baseId = `${keyPrefix}:limit-${stableKeyToken(identity)}`; + const occurrence = occurrences.get(baseId) ?? 0; + occurrences.set(baseId, occurrence + 1); + rows.push({ + id: occurrence === 0 ? baseId : `${baseId}-${occurrence}`, + label, + percent, + reset: typeof candidate.reset === 'string' + ? boundedTextValue(candidate.reset, 96) + : null, + active: candidate.active !== false, + }); + } + return rows; +} + +// Small deterministic key token for retained-scene identity. Provider/model +// ids can be long or contain control characters, so they are never copied into +// scene keys; hashing keeps normal rows stable across reorder/refresh while the +// occurrence suffix above makes even duplicate restored rows globally unique. +function stableKeyToken(value) { + const input = boundedTextValue(value, 160); + let hash = 2166136261; + for (const character of input) { + hash ^= character.codePointAt(0); + hash = Math.imul(hash, 16777619) >>> 0; + } + return hash.toString(36); +} + +function barColor(percent, theme) { + if (percent >= 85) return theme.danger; + if (percent >= 60) return theme.warning; + return theme.ok; +} + +export function limitRow(row, theme) { + const percent = typeof row.percent === 'number' ? row.percent : 0; + const dim = row.active === false; + const fillWidth = Math.max( + percent > 0 ? 3 : 0, + Math.round((percent / 100) * contentWidth), + ); + const children = [ + { + key: `${row.id}:head`, + type: 'row', + props: {gap: 8, crossAlign: 'center'}, + children: [ + text(`${row.id}:label`, row.label, { + color: dim ? theme.secondary : theme.text, + fontSize: 13, + fontWeight: 600, + flex: 1, + maxLines: 1, + overflow: 'ellipsis', + }), + // Spell out which direction the number runs: a bare "84%" reads just + // as easily as "84% remaining", which is the opposite of the truth. + text(`${row.id}:pct`, `${percent}% used`, { + color: dim ? theme.secondary : barColor(percent, theme), + fontSize: 13, + fontWeight: 700, + font: 'mono', + }), + ], + }, + { + key: `${row.id}:track`, + type: 'box', + props: { + width: contentWidth, + height: 8, + radius: 4, + background: theme.track, + // A fixed-size box without alignment gives its child tight constraints. + // Alignment lets the requested fill width remain authoritative. + align: 'centerLeft', + }, + children: fillWidth === 0 ? [] : [{ + key: `${row.id}:fill`, + type: 'box', + props: { + width: fillWidth, + height: 8, + radius: 4, + background: dim ? theme.secondary : barColor(percent, theme), + }, + }], + }, + ]; + if (typeof row.reset === 'string' && row.reset !== '') { + children.push(text(`${row.id}:reset`, row.reset, { + color: theme.secondary, + fontSize: 11, + })); + } + return { + key: row.id, + type: 'column', + props: {gap: 4, crossAlign: 'stretch'}, + children, + }; +} + +// scene.v1 deliberately has no guest-driven animation lane. This fixed, +// low-contrast skeleton still makes the in-flight state immediately visible +// without a timer, layout jump, or misleading stale percentage. +export function loadingLimitRow(rowId, label, theme, index = 0) { + const hasLabel = typeof label === 'string' && label !== ''; + return { + key: rowId, + type: 'column', + props: { + gap: 4, + crossAlign: 'stretch', + semanticsLabel: 'Refreshing usage limit', + }, + children: [ + { + key: `${rowId}:head`, + type: 'row', + props: {gap: 8, crossAlign: 'center'}, + children: [ + hasLabel + ? text(`${rowId}:label`, label, { + color: theme.secondary, + fontSize: 13, + fontWeight: 600, + flex: 1, + maxLines: 1, + overflow: 'ellipsis', + }) + : skeletonBox(`${rowId}:label`, 132, 12, theme), + skeletonBox(`${rowId}:pct`, 72, 12, theme), + ], + }, + { + key: `${rowId}:track`, + type: 'box', + props: { + width: contentWidth, + height: 8, + radius: 4, + background: theme.track, + align: index % 2 === 0 ? 'centerLeft' : 'centerRight', + }, + children: [{ + key: `${rowId}:fill`, + type: 'box', + props: { + width: 112, + height: 8, + radius: 4, + background: theme.skeletonHighlight, + }, + }], + }, + skeletonBox( + `${rowId}:reset`, + index % 2 === 0 ? 148 : 116, + 9, + theme, + ), + ], + }; +} + +function skeletonBox(key, width, height, theme) { + return { + key, + type: 'box', + props: { + width, + height, + radius: Math.ceil(height / 2), + background: theme.skeleton, + }, + children: [], + }; +} + +export function header( + keyPrefix, + brand, + badge, + refreshActionId, + theme, + refreshing = false, +) { + const children = [ + { + key: `${keyPrefix}:dot`, + type: 'box', + props: {width: 10, height: 10, radius: 5, background: theme.accent}, + }, + text(`${keyPrefix}:brand`, brand, { + color: theme.text, + fontSize: 12, + fontWeight: 700, + letterSpacing: 2, + flex: 1, + }), + ]; + if (typeof badge === 'string' && badge !== '') { + children.push({ + key: `${keyPrefix}:badge`, + type: 'badge', + props: {label: badge, tone: 'neutral'}, + }); + } + children.push({ + key: `${keyPrefix}:refresh`, + type: 'button', + props: { + label: refreshing ? 'Refreshing…' : 'Refresh', + actionId: refreshActionId, + semanticsLabel: refreshing ? 'Usage refresh in progress' : 'Refresh usage', + }, + }); + return { + key: keyPrefix, + type: 'row', + props: {gap: 8, crossAlign: 'center'}, + children, + }; +} + +export function statusBody(keyPrefix, doc, theme) { + const children = []; + if (doc.status === 'idle') { + children.push(text( + `${keyPrefix}:idle`, + 'Press Refresh to fetch usage for this account.', + {color: theme.secondary, fontSize: 12}, + )); + } + if (typeof doc.error === 'string' && doc.error !== '') { + children.push(text(`${keyPrefix}:error`, doc.error, { + color: doc.status === 'unconfigured' ? theme.secondary : theme.danger, + fontSize: 12, + maxLines: 6, + })); + } + return children; +} + +export function card(keyPrefix, theme, children) { + return { + key: keyPrefix, + type: 'box', + props: { + width: cardWidth, + background: theme.background, + radius: 12, + borderColor: theme.border, + borderWidth: 1, + padding: [padding, padding], + semanticsLabel: theme.semanticsLabel, + }, + children: [{ + key: `${keyPrefix}:layout`, + type: 'column', + props: {gap: 12, crossAlign: 'stretch'}, + children, + }], + }; +} + +export function footer(keyPrefix, doc, theme) { + return typeof doc.updated === 'string' && doc.updated !== '' + ? [text(`${keyPrefix}:updated`, doc.updated, { + color: theme.secondary, + fontSize: 10, + })] + : []; +} diff --git a/extensions/io.flutterflow.campus.usage-limits/src/claude_status.js b/extensions/io.flutterflow.campus.usage-limits/src/claude_status.js deleted file mode 100644 index 67f43a4..0000000 --- a/extensions/io.flutterflow.campus.usage-limits/src/claude_status.js +++ /dev/null @@ -1,185 +0,0 @@ -// Pure, allowlist-only projection of Claude Code's status-line payload. The -// payload can contain session ids, transcript paths, cwd, workspace metadata, -// and other owner-local details; none of those fields cross this boundary. - -const maximumTokenCount = 1_000_000_000_000_000; -const maximumDurationMs = 31_536_000_000; - -export function normalizeClaudeStatusline(value) { - const root = asObject(value); - const payload = asObject(root.input ?? root.payload ?? root); - const model = asObject(payload.model); - const context = asObject(payload.context_window ?? payload.contextWindow); - const currentUsage = asObject( - context.current_usage ?? context.currentUsage, - ); - const cost = asObject(payload.cost); - - const contextSize = safeCount( - context.context_window_size ?? context.contextWindowSize, - ); - const currentInputTokens = sumCounts([ - currentUsage.input_tokens, - currentUsage.cache_creation_input_tokens, - currentUsage.cache_read_input_tokens, - ]); - const totalInputTokens = safeCount( - context.total_input_tokens ?? context.totalInputTokens, - ); - const outputTokens = safeCount( - context.total_output_tokens ?? context.totalOutputTokens ?? - currentUsage.output_tokens, - ); - const usedTokens = currentInputTokens ?? totalInputTokens; - const usedPercent = safePercent( - context.used_percentage ?? context.usedPercentage, - usedTokens, - contextSize, - ); - const remainingPercent = safeRemainingPercent( - context.remaining_percentage ?? context.remainingPercentage, - usedPercent, - ); - const costUsd = safeDecimal( - cost.total_cost_usd ?? cost.totalCostUsd, - 0, - 1_000_000, - ); - const durationMs = safeCount( - cost.total_duration_ms ?? cost.totalDurationMs, - maximumDurationMs, - ); - const apiDurationMs = safeCount( - cost.total_api_duration_ms ?? cost.totalApiDurationMs, - maximumDurationMs, - ); - const modelName = plainLabel( - model.display_name ?? model.displayName ?? model.id, - 64, - ); - - const hasVisibleMetric = modelName !== '' || - contextSize !== null || - usedTokens !== null || - outputTokens !== null || - usedPercent !== null || - costUsd !== null || - durationMs !== null; - if (!hasVisibleMetric) return unavailableClaudeProjection(); - - return { - status: 'ready', - model: modelName, - context: { - usedPercent, - remainingPercent, - usedTokens, - outputTokens, - sizeTokens: contextSize, - }, - session: { - costUsd, - durationMs, - apiDurationMs, - }, - }; -} - -export function unavailableClaudeProjection() { - return { - status: 'unavailable', - model: '', - context: { - usedPercent: null, - remainingPercent: null, - usedTokens: null, - outputTokens: null, - sizeTokens: null, - }, - session: { - costUsd: null, - durationMs: null, - apiDurationMs: null, - }, - }; -} - -export function normalizePublishedClaude(value) { - const source = asObject(value); - if (source.status !== 'ready') return unavailableClaudeProjection(); - const context = asObject(source.context); - const session = asObject(source.session); - return { - status: 'ready', - model: plainLabel(source.model, 64), - context: { - usedPercent: safePercent(context.usedPercent), - remainingPercent: safeRemainingPercent( - context.remainingPercent, - safePercent(context.usedPercent), - ), - usedTokens: safeCount(context.usedTokens), - outputTokens: safeCount(context.outputTokens), - sizeTokens: safeCount(context.sizeTokens), - }, - session: { - costUsd: safeDecimal(session.costUsd, 0, 1_000_000), - durationMs: safeCount(session.durationMs, maximumDurationMs), - apiDurationMs: safeCount(session.apiDurationMs, maximumDurationMs), - }, - }; -} - -function safePercent(value, usedTokens = null, sizeTokens = null) { - const direct = safeDecimal(value, 0, 100); - if (direct !== null) return Math.round(direct * 10) / 10; - if (usedTokens === null || sizeTokens === null || sizeTokens <= 0) return null; - return Math.round(Math.min(100, usedTokens / sizeTokens * 100) * 10) / 10; -} - -function safeRemainingPercent(value, usedPercent) { - const direct = safeDecimal(value, 0, 100); - if (direct !== null) return Math.round(direct * 10) / 10; - return usedPercent === null ? null : Math.round((100 - usedPercent) * 10) / 10; -} - -function sumCounts(values) { - let total = 0; - let found = false; - for (const value of values) { - const count = safeCount(value); - if (count !== null) { - found = true; - total = Math.min(maximumTokenCount, total + count); - } - } - return found ? total : null; -} - -function safeCount(value, maximum = maximumTokenCount) { - return Number.isSafeInteger(value) && value >= 0 - ? Math.min(value, maximum) - : null; -} - -function safeDecimal(value, minimum, maximum) { - return typeof value === 'number' && - Number.isFinite(value) && - value >= minimum && - value <= maximum - ? value - : null; -} - -function plainLabel(value, maximum) { - if (typeof value !== 'string') return ''; - return Array.from( - value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ').trim(), - ).slice(0, maximum).join(''); -} - -function asObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? value - : {}; -} diff --git a/extensions/io.flutterflow.campus.usage-limits/src/codex_protocol.js b/extensions/io.flutterflow.campus.usage-limits/src/codex_protocol.js deleted file mode 100644 index 5a25a92..0000000 --- a/extensions/io.flutterflow.campus.usage-limits/src/codex_protocol.js +++ /dev/null @@ -1,124 +0,0 @@ -// Pure JSON-RPC framing for the local Codex app-server. The process adapter is -// kept in read_codex_usage.js; this module is deterministic and Node-testable. - -export const codexRpc = Object.freeze({ - initializeId: 1, - rateLimitsId: 2, - usageId: 3, - rateLimitsMethod: 'account/rateLimits/read', - usageMethod: 'account/usage/read', -}); - -export function initializeRequest() { - return request(codexRpc.initializeId, 'initialize', { - clientInfo: { - name: 'campus-usage-limits', - title: 'Campus Usage Limits', - version: '1.0.0', - }, - capabilities: {}, - }); -} - -export function initializedNotification() { - return {method: 'initialized'}; -} - -export function rateLimitsRequest() { - return request(codexRpc.rateLimitsId, codexRpc.rateLimitsMethod); -} - -export function usageRequest() { - return request(codexRpc.usageId, codexRpc.usageMethod); -} - -export function encodeJsonRpcLine(message) { - return JSON.stringify(message) + '\n'; -} - -export class BoundedJsonRpcDecoder { - constructor({maxTotalBytes = 262144, maxLineBytes = 65536} = {}) { - this.maxTotalBytes = maxTotalBytes; - this.maxLineBytes = maxLineBytes; - this.totalBytes = 0; - this.pending = ''; - this.messages = []; - } - - push(chunk) { - if (typeof chunk !== 'string') { - throw new TypeError('Codex app-server output must be UTF-8 text'); - } - this.totalBytes += encodedUtf8Bytes(chunk); - if (this.totalBytes > this.maxTotalBytes) { - throw new RangeError('Codex app-server output exceeded its task budget'); - } - this.pending += chunk; - let newline; - while ((newline = this.pending.indexOf('\n')) >= 0) { - const line = this.pending.slice(0, newline).replace(/\r$/u, ''); - this.pending = this.pending.slice(newline + 1); - if (line === '') continue; - this.#acceptLine(line); - } - if (encodedUtf8Bytes(this.pending) > this.maxLineBytes) { - throw new RangeError('Codex app-server JSON-RPC line is too large'); - } - } - - finish() { - const tail = this.pending.replace(/\r$/u, ''); - this.pending = ''; - if (tail !== '') this.#acceptLine(tail); - return this.messages.slice(); - } - - take(id) { - const index = this.messages.findIndex((message) => message.id === id); - return index < 0 ? null : this.messages.splice(index, 1)[0]; - } - - #acceptLine(line) { - if (encodedUtf8Bytes(line) > this.maxLineBytes) { - throw new RangeError('Codex app-server JSON-RPC line is too large'); - } - const value = JSON.parse(line); - const hasId = value !== null && Object.hasOwn(value, 'id'); - const isResponse = hasId && - (typeof value.id === 'number' || typeof value.id === 'string') && - (Object.hasOwn(value, 'result') !== Object.hasOwn(value, 'error')); - const isNotification = !hasId && typeof value?.method === 'string'; - if ( - value === null || - typeof value !== 'object' || - Array.isArray(value) || - Object.hasOwn(value, 'jsonrpc') || - (!isResponse && !isNotification) - ) { - throw new TypeError('Codex app-server emitted an invalid wire message'); - } - if (this.messages.length >= 64) { - throw new RangeError('Codex app-server emitted too many messages'); - } - this.messages.push(value); - } -} - -function request(id, method, params) { - return params === undefined ? {id, method} : {id, method, params}; -} - -function encodedUtf8Bytes(value) { - let count = 0; - for (const scalar of value) { - const point = scalar.codePointAt(0); - count += point <= 0x7f - ? 1 - : point <= 0x7ff - ? 2 - : point <= 0xffff - ? 3 - : 4; - } - return count; -} diff --git a/extensions/io.flutterflow.campus.usage-limits/src/codex_usage.js b/extensions/io.flutterflow.campus.usage-limits/src/codex_usage.js deleted file mode 100644 index 44bb002..0000000 --- a/extensions/io.flutterflow.campus.usage-limits/src/codex_usage.js +++ /dev/null @@ -1,322 +0,0 @@ -// Pure projection from the two Codex app-server account responses into the -// small set of metrics the dashboard is allowed to publish. Account ids, -// emails, auth state, raw errors, and unrecognized fields are never copied. - -const maximumWindows = 8; -const maximumTokenCount = 1_000_000_000_000_000; - -export function normalizeCodexAccount( - rateLimitsEnvelope, - usageEnvelope, - refreshedAt = '', -) { - const rateResponse = unwrapJsonRpcResult(rateLimitsEnvelope); - const usageResponse = unwrapJsonRpcResult(usageEnvelope); - if (rateResponse === null && usageResponse === null) { - return unavailableCodexProjection('response-unavailable', refreshedAt); - } - - const rateRoot = asObject(rateResponse); - const rateLimits = asObject( - rateRoot.rateLimits ?? rateRoot.codexRateLimits ?? rateRoot, - ); - const windows = collectRateLimitWindows(rateLimits, rateRoot); - const credits = normalizeCredits( - rateLimits.credits ?? rateRoot.credits ?? rateRoot, - rateRoot.rateLimitResetCredits, - ); - const usage = normalizeUsage(usageResponse); - - return { - status: 'ready', - refreshedAt: plainTimestamp(refreshedAt), - plan: plainLabel( - rateRoot.planType ?? rateLimits.planType ?? firstBucketPlan(rateRoot), - 48, - ), - limited: - (rateLimits.rateLimitReachedType ?? rateRoot.rateLimitReachedType) !== - null && - (rateLimits.rateLimitReachedType ?? rateRoot.rateLimitReachedType) !== - undefined && - (rateLimits.rateLimitReachedType ?? rateRoot.rateLimitReachedType) !== '', - windows, - credits, - usage, - }; -} - -export function unavailableCodexProjection( - reason = 'response-unavailable', - refreshedAt = '', -) { - const safeReason = reason === 'not-signed-in' - ? 'not-signed-in' - : reason === 'process-unavailable' - ? 'process-unavailable' - : 'response-unavailable'; - return { - status: 'unavailable', - reason: safeReason, - refreshedAt: plainTimestamp(refreshedAt), - plan: '', - limited: false, - windows: [], - credits: { - hasCredits: null, - unlimited: null, - balance: null, - resetCreditsAvailable: null, - }, - usage: emptyUsage(), - }; -} - -export function normalizePublishedCodex(value) { - const source = asObject(value); - if (source.status !== 'ready' && source.status !== 'loading') { - return unavailableCodexProjection(source.reason, source.refreshedAt); - } - const windows = []; - if (Array.isArray(source.windows)) { - for (const candidate of source.windows.slice(0, maximumWindows)) { - const window = normalizeProjectedWindow(candidate, windows.length); - if (window !== null) windows.push(window); - } - } - return { - status: source.status, - refreshedAt: plainTimestamp(source.refreshedAt), - plan: plainLabel(source.plan, 48), - limited: source.limited === true, - windows, - credits: normalizeCredits(source.credits, source.credits), - usage: normalizeUsage(source.usage), - }; -} - -function collectRateLimitWindows(rateLimits, rateRoot) { - const windows = []; - appendWindow(windows, 'primary', 'Primary window', rateLimits.primary); - appendWindow(windows, 'secondary', 'Secondary window', rateLimits.secondary); - - const individual = rateLimits.individualLimit; - if (individual !== null && individual !== undefined) { - appendWindow(windows, 'individual', 'Individual limit', individual); - } - - const byId = rateRoot.rateLimitsByLimitId ?? rateLimits.rateLimitsByLimitId; - const candidates = Array.isArray(byId) - ? byId.map((value, index) => [`bucket-${index + 1}`, value]) - : Object.entries(asObject(byId)); - for (const [bucketId, candidate] of candidates.slice(0, maximumWindows)) { - const source = asObject(candidate); - const label = plainLabel(source.limitName, 48) || - plainLabel(bucketId, 48) || `Additional limit ${windows.length + 1}`; - appendWindow( - windows, - `${plainIdentifier(bucketId) || 'additional'}-primary`, - label, - source.primary ?? source, - ); - appendWindow( - windows, - `${plainIdentifier(bucketId) || 'additional'}-secondary`, - `${label} secondary`, - source.secondary, - ); - if (windows.length >= maximumWindows) break; - } - return windows; -} - -function appendWindow(windows, id, fallbackLabel, value) { - if (windows.length >= maximumWindows) return; - const source = asObject(value); - const usedPercent = safePercent( - source.usedPercent, - source.remainingPercent, - ); - const durationMinutes = safeInteger(source.windowDurationMins, 1, 525_600); - const resetsAt = plainTimestamp(source.resetsAt); - if (usedPercent === null && durationMinutes === null && resetsAt === '') { - return; - } - windows.push({ - id, - label: plainLabel(source.limitName, 48) || fallbackLabel, - usedPercent, - durationMinutes, - resetsAt, - }); -} - -function normalizeProjectedWindow(value, index) { - const source = asObject(value); - const usedPercent = safePercent(source.usedPercent); - const durationMinutes = safeInteger(source.durationMinutes, 1, 525_600); - const resetsAt = plainTimestamp(source.resetsAt); - if (usedPercent === null && durationMinutes === null && resetsAt === '') { - return null; - } - return { - id: plainIdentifier(source.id) || `window-${index + 1}`, - label: plainLabel(source.label, 48) || `Usage window ${index + 1}`, - usedPercent, - durationMinutes, - resetsAt, - }; -} - -function normalizeCredits(value, resetCreditsValue = null) { - const source = asObject(value); - const resetCredits = asObject(resetCreditsValue); - return { - hasCredits: typeof source.hasCredits === 'boolean' - ? source.hasCredits - : null, - unlimited: typeof source.unlimited === 'boolean' - ? source.unlimited - : null, - balance: safeCreditBalance(source.balance), - resetCreditsAvailable: safeCount(resetCredits.availableCount, 1_000_000), - }; -} - -function normalizeUsage(value) { - const root = asObject(unwrapJsonRpcResult(value) ?? value); - const usage = asObject(root.summary ?? root.usage ?? root.tokenUsage ?? root); - const tokens = asObject(usage.tokens); - return { - startDate: plainDate(usage.startDate), - lifetimeTokens: safeCount( - usage.lifetimeTokens ?? tokens.lifetimeTokens ?? - (typeof usage.tokens === 'number' ? usage.tokens : null), - ), - peakDailyTokens: safeCount( - usage.peakDailyTokens ?? tokens.peakDailyTokens, - ), - longestRunningTurnSeconds: safeCount( - usage.longestRunningTurnSec ?? usage.longestRunningTurnSeconds, - 31_536_000, - ), - currentStreakDays: safeCount(usage.currentStreakDays, 100_000), - longestStreakDays: safeCount(usage.longestStreakDays, 100_000), - }; -} - -function firstBucketPlan(rateRoot) { - const byId = asObject(rateRoot.rateLimitsByLimitId); - for (const bucket of Object.values(byId)) { - const plan = asObject(bucket).planType; - if (typeof plan === 'string') return plan; - } - return ''; -} - -function emptyUsage() { - return { - startDate: '', - lifetimeTokens: null, - peakDailyTokens: null, - longestRunningTurnSeconds: null, - currentStreakDays: null, - longestStreakDays: null, - }; -} - -function unwrapJsonRpcResult(value) { - const source = asObject(value); - if (Object.hasOwn(source, 'error')) return null; - if (Object.hasOwn(source, 'result')) { - return isObject(source.result) ? source.result : null; - } - return isObject(value) ? value : null; -} - -function plainIdentifier(value) { - return typeof value === 'string' && /^[a-z0-9][a-z0-9-]{0,47}$/u.test(value) - ? value - : ''; -} - -function plainLabel(value, maximum) { - if (typeof value !== 'string') return ''; - return Array.from( - value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ').trim(), - ).slice(0, maximum).join(''); -} - -function plainDate(value) { - if (typeof value !== 'string') return ''; - const match = /^(\d{4}-\d{2}-\d{2})/u.exec(value); - return match === null ? '' : match[1]; -} - -function plainTimestamp(value) { - if (typeof value === 'number' && Number.isFinite(value)) { - const milliseconds = value < 10_000_000_000 ? value * 1000 : value; - if (milliseconds < 946_684_800_000 || milliseconds > 7_258_118_400_000) { - return ''; - } - return new Date(milliseconds).toISOString(); - } - if (typeof value !== 'string' || value.length > 64) return ''; - const milliseconds = Date.parse(value); - if (!Number.isFinite(milliseconds)) return ''; - return new Date(milliseconds).toISOString(); -} - -function safePercent(value, remainingValue = null) { - const direct = safeDecimal(value, 0, 100); - if (direct !== null) return Math.round(direct * 10) / 10; - const remaining = safeDecimal(remainingValue, 0, 100); - return remaining === null - ? null - : Math.round((100 - remaining) * 10) / 10; -} - -function safeCount(value, maximum = maximumTokenCount) { - return Number.isSafeInteger(value) && value >= 0 - ? Math.min(value, maximum) - : null; -} - -function safeInteger(value, minimum, maximum) { - return Number.isSafeInteger(value) && value >= minimum && value <= maximum - ? value - : null; -} - -function safeDecimal(value, minimum, maximum) { - return typeof value === 'number' && - Number.isFinite(value) && - value >= minimum && - value <= maximum - ? value - : null; -} - -function safeCreditBalance(value) { - const direct = safeDecimal(value, 0, 1_000_000_000); - if (direct !== null) return direct; - if ( - typeof value !== 'string' || - value.length > 24 || - !/^(?:0|[1-9]\d{0,9})(?:\.\d{1,6})?$/u.test(value) - ) { - return null; - } - const [whole, fraction = ''] = value.split('.'); - const significantDigits = whole.replace(/^0+/u, '').length + fraction.length; - if (significantDigits > 15) return null; - return safeDecimal(Number(value), 0, 1_000_000_000); -} - -function isObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function asObject(value) { - return isObject(value) ? value : {}; -} diff --git a/extensions/io.flutterflow.campus.usage-limits/src/main.js b/extensions/io.flutterflow.campus.usage-limits/src/main.js index 7289c58..a6e5f31 100644 --- a/extensions/io.flutterflow.campus.usage-limits/src/main.js +++ b/extensions/io.flutterflow.campus.usage-limits/src/main.js @@ -1,77 +1,106 @@ -// Coordinator only: bind the host-owned process resource, invoke it through -// the generic process facade, and accept the bounded owner-only Claude action. -// Parsing and rendering remain in pure modules. +// Coordinator for the usage-limit cards. Each launched tile runs a fresh +// instance of this activate; the coordinator is not told which launcher was +// used, and a launcher-declared surface that is never opened fails the tile, +// so every activation opens both surfaces. Each tile projects only its own. +import {actions, statePools, surfacePools} from './campus.generated.mjs'; import { - actions, - statePools, - surfacePools, -} from './campus.generated.mjs'; -import { - createInitialDashboard, - normalizeDashboard, - withClaudeStatusline, - withCodexLoading, - withCodexProjection, + emptyClaudeDoc, + emptyCodexDoc, + loadingDoc, + mapClaudeReceipt, + mapCodexReceipt, + receiptClock, } from './model.js'; -import {unavailableCodexProjection} from './codex_usage.js'; -import {readCodexUsage} from './read_codex_usage.js'; -const codexResourceSlot = 'codex'; -const surfaceId = 'usage.dashboard'; +const claudeSurface = 'claude.card'; +const codexSurface = 'codex.card'; + +function isUsageDoc(snapshot, provider) { + return snapshot !== null && + typeof snapshot === 'object' && + snapshot.provider === provider && + Array.isArray(snapshot.limits); +} export async function activate(ctx) { - const dashboard = await ctx.state.open(statePools.dashboard, 'main'); - const canonical = normalizeDashboard(dashboard.snapshot); - if (JSON.stringify(canonical) !== JSON.stringify(dashboard.snapshot)) { - await ctx.state.patch(dashboard, canonical); + const claude = await ctx.state.open(statePools.usage, 'claude'); + const codex = await ctx.state.open(statePools.usage, 'codex'); + + if (!isUsageDoc(claude.snapshot, 'claude')) { + await ctx.state.patch(claude, emptyClaudeDoc()); + } + if (!isUsageDoc(codex.snapshot, 'codex')) { + await ctx.state.patch(codex, emptyCodexDoc()); } - async function refreshCodex(effectCtx) { - await effectCtx.state.patch( - dashboard, - withCodexLoading(dashboard.snapshot), + async function refreshClaude(effectCtx, markLoading = true) { + if (markLoading) { + await effectCtx.state.patch( + claude, + loadingDoc(claude.snapshot, emptyClaudeDoc()), + ); + } + const receipt = await effectCtx.connectors.request('anthropic', { + method: 'GET', + path: '/api/oauth/usage', + }); + return effectCtx.state.patch( + claude, + mapClaudeReceipt(receipt, receiptClock(receipt)), ); - let next; - try { - const codex = await effectCtx.resources.bind(codexResourceSlot); - next = await readCodexUsage({ - process: effectCtx.process, - binding: codex, - current: normalizeDashboard(dashboard.snapshot), - }); - } catch (_) { - next = withCodexProjection( - dashboard.snapshot, - unavailableCodexProjection('process-unavailable'), + } + + async function refreshCodex(effectCtx, markLoading = true) { + if (markLoading) { + await effectCtx.state.patch( + codex, + loadingDoc(codex.snapshot, emptyCodexDoc()), ); } - return effectCtx.state.patch(dashboard, next); + const receipt = await effectCtx.connectors.request('openai', { + method: 'GET', + path: '/backend-api/wham/usage', + }); + return effectCtx.state.patch( + codex, + mapCodexReceipt(receipt, receiptClock(receipt)), + ); } + await ctx.actions.handle(actions.refreshClaude, (_, actionCtx) => + refreshClaude(actionCtx)); await ctx.actions.handle(actions.refreshCodex, (_, actionCtx) => refreshCodex(actionCtx)); - await ctx.actions.handle( - actions.ingestClaudeStatus, - (payload, actionCtx) => actionCtx.state.patch( - dashboard, - withClaudeStatusline(dashboard.snapshot, payload), - ), + + // Both cards start in the same visible in-flight state. openSurface renders + // immediately, before activate parks on the first connector request, so the + // initial scene must already be loading rather than the idle "Press Refresh" + // placeholder. Codex remains visibly pending while Claude resolves first. + await ctx.state.patch( + claude, + loadingDoc(claude.snapshot, emptyClaudeDoc()), + ); + await ctx.state.patch( + codex, + loadingDoc(codex.snapshot, emptyCodexDoc()), ); await ctx.ui.openSurface({ - pool: surfacePools.dashboard, - id: surfaceId, - inputs: {model: dashboard}, + pool: surfacePools.claudeCard, + id: claudeSurface, + inputs: {model: claude}, + }); + await ctx.ui.openSurface({ + pool: surfacePools.codexCard, + id: codexSurface, + inputs: {model: codex}, }); - // The process grant is exact and already user-approved at this point. The - // initial read and later refreshes use the same generic resource operations. - await refreshCodex(ctx); - return {surfaceId}; -} + // Self-populate on launch. auth-not-configured comes back immediately from + // the host broker, so an unconfigured card settles into its hint state. + await refreshClaude(ctx, false); + await refreshCodex(ctx, false); -export const usageLimitsIds = Object.freeze({ - codexResourceSlot, - surfaceId, -}); + return {surfaces: [claudeSurface, codexSurface]}; +} diff --git a/extensions/io.flutterflow.campus.usage-limits/src/model.js b/extensions/io.flutterflow.campus.usage-limits/src/model.js index 35db4d8..6a7d704 100644 --- a/extensions/io.flutterflow.campus.usage-limits/src/model.js +++ b/extensions/io.flutterflow.campus.usage-limits/src/model.js @@ -1,59 +1,438 @@ -import { - normalizePublishedCodex, - unavailableCodexProjection, -} from './codex_usage.js'; -import { - normalizeClaudeStatusline, - normalizePublishedClaude, - unavailableClaudeProjection, -} from './claude_status.js'; - -export function createInitialDashboard() { +// Pure mapping from connector receipts to bounded renderable usage documents. +// The guest has no ambient clock or locale. The host therefore stamps both +// the receipt instant and the owner's UTC offset onto every connector receipt. + +const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', +]; +const maximumRows = 12; +const maximumPlausibleResetMs = 60 * 24 * 3600 * 1000; +const minimumTimestampMs = 946684800000; // 2000-01-01T00:00:00Z +const maximumTimestampMs = 7258118400000; // 2200-01-01T00:00:00Z + +function boundedTimestamp(value) { + return typeof value === 'number' && + Number.isFinite(value) && + value >= minimumTimestampMs && + value <= maximumTimestampMs + ? value + : null; +} + +export function receiptClock(receipt) { + const receivedAt = receipt?.receivedAt; + const utcOffsetMinutes = receipt?.utcOffsetMinutes; return { - schemaVersion: 1, - codex: unavailableCodexProjection(), - claude: unavailableClaudeProjection(), + nowMs: boundedTimestamp(receivedAt), + utcOffsetMinutes: Number.isSafeInteger(utcOffsetMinutes) && + utcOffsetMinutes >= -14 * 60 && + utcOffsetMinutes <= 14 * 60 + ? utcOffsetMinutes + : null, }; } -export function normalizeDashboard(value) { - const source = asObject(value); +function normalizeClock(value) { return { - schemaVersion: 1, - codex: normalizePublishedCodex(source.codex), - claude: normalizePublishedClaude(source.claude), + nowMs: boundedTimestamp(value?.nowMs), + utcOffsetMinutes: Number.isSafeInteger(value?.utcOffsetMinutes) && + value.utcOffsetMinutes >= -14 * 60 && + value.utcOffsetMinutes <= 14 * 60 + ? value.utcOffsetMinutes + : null, }; } -export function withCodexProjection(current, projection) { - const dashboard = normalizeDashboard(current); +function clampPercent(value) { + const number = typeof value === 'number' && Number.isFinite(value) ? value : 0; + return Math.max(0, Math.min(100, Math.round(number))); +} + +// Howard Hinnant's civil-date algorithms let us format a host-authored instant +// with a host-authored offset without relying on the sandbox's pinned Date. +function civilFromDays(value) { + const z = value + 719468; + const era = Math.floor(z / 146097); + const dayOfEra = z - era * 146097; + const yearOfEra = Math.floor( + (dayOfEra - Math.floor(dayOfEra / 1460) + + Math.floor(dayOfEra / 36524) - Math.floor(dayOfEra / 146096)) / 365, + ); + const year = yearOfEra + era * 400; + const dayOfYear = dayOfEra - + (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100)); + const monthPart = Math.floor((5 * dayOfYear + 2) / 153); + const day = dayOfYear - Math.floor((153 * monthPart + 2) / 5) + 1; + const month = monthPart + (monthPart < 10 ? 3 : -9); + return [year + (month <= 2 ? 1 : 0), month, day]; +} + +function daysFromCivil(yearValue, month, day) { + const year = yearValue - (month <= 2 ? 1 : 0); + const era = Math.floor(year / 400); + const yearOfEra = year - era * 400; + const monthPart = (month + 9) % 12; + const dayOfYear = Math.floor((153 * monthPart + 2) / 5) + day - 1; + const dayOfEra = yearOfEra * 365 + + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100) + dayOfYear; + return era * 146097 + dayOfEra - 719468; +} + +function pad2(value) { + return value < 10 ? `0${value}` : String(value); +} + +export function formatLocalTime(milliseconds, utcOffsetMinutes) { + if (boundedTimestamp(milliseconds) === null) return null; + const hasOffset = Number.isSafeInteger(utcOffsetMinutes) && + utcOffsetMinutes >= -14 * 60 && + utcOffsetMinutes <= 14 * 60; + // Never substitute UTC for the owner's local time. Older hosts omit the + // offset; callers can then use a relative/no-clock fallback instead of + // presenting a confidently wrong zone. + if (!hasOffset) return null; + const shifted = milliseconds + utcOffsetMinutes * 60000; + const days = Math.floor(shifted / 86400000); + const civil = civilFromDays(days); + const remainder = shifted - days * 86400000; + const hours = Math.floor(remainder / 3600000) % 24; + const minutes = Math.floor(remainder / 60000) % 60; + return `${months[civil[1] - 1]} ${civil[2]}, ${pad2(hours)}:${pad2(minutes)}`; +} + +export function formatDuration(totalSeconds) { + if (typeof totalSeconds !== 'number' || !Number.isFinite(totalSeconds)) { + return null; + } + const seconds = Math.max(0, Math.round(totalSeconds)); + if (seconds < 60) return '<1m'; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +// resets_at arrives as ISO text, epoch seconds, epoch milliseconds, or a +// quoted epoch. A zone-less ISO value is interpreted as UTC, matching the two +// usage endpoints' wire contracts. +export function parseResetsAt(value) { + if (value === null || value === undefined) return null; + if (typeof value === 'number' && Number.isFinite(value)) { + return boundedTimestamp(value < 1e12 ? value * 1000 : value); + } + if (typeof value !== 'string' || value.length > 64) return null; + const trimmed = value.trim(); + if (trimmed === '') return null; + if (/^\d+(?:\.\d+)?$/u.test(trimmed)) { + const numeric = Number(trimmed); + if (!Number.isFinite(numeric)) return null; + return boundedTimestamp(numeric < 1e12 ? numeric * 1000 : numeric); + } + const match = /^(\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|z|[+-]\d{2}:?\d{2})?$/u.exec(trimmed); + if (match === null) return null; + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = match[6] === undefined ? 0 : Number(match[6]); + if (month < 1 || month > 12 || day < 1 || day > 31 || + hour > 23 || minute > 59 || second > 59) { + return null; + } + let seconds = daysFromCivil(Number(match[1]), month, day) * 86400 + + hour * 3600 + minute * 60 + second; + const zone = match[7]; + if (zone !== undefined && zone !== 'Z' && zone !== 'z') { + const sign = zone[0] === '-' ? -1 : 1; + const zoneHours = Number(zone.slice(1, 3)); + const zoneMinutes = Number(zone.slice(-2)); + if (zoneHours > 14 || zoneMinutes > 59) return null; + seconds -= sign * (zoneHours * 3600 + zoneMinutes * 60); + } + return boundedTimestamp(seconds * 1000); +} + +function resetLabel(resetsAtMs, clockValue) { + if (resetsAtMs === null) return null; + const clock = normalizeClock(clockValue); + const delta = clock.nowMs === null ? null : resetsAtMs - clock.nowMs; + if (delta !== null && delta > 0) { + return `resets in ${formatDuration(delta / 1000)}`; + } + // An offset captured at receipt completion is correct for "Updated", but it + // is not a time-zone database and can be wrong for an arbitrary reset instant + // across a DST transition. Keep reset labels relative and zone-free. + return delta === null ? null : 'reset elapsed'; +} + +function updatedLabel(clockValue) { + const clock = normalizeClock(clockValue); + if (clock.nowMs === null) return null; + const label = formatLocalTime(clock.nowMs, clock.utcOffsetMinutes); + return label === null ? 'Updated just now' : `Updated ${label}`; +} + +function plainLabel(value, fallback = '') { + if (typeof value !== 'string') return fallback; + const normalized = Array.from( + value.replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ').trim(), + ).slice(0, 72).join(''); + return normalized === '' ? fallback : normalized; +} + +function titleCase(value) { + return plainLabel(value) + .split(/[_\s]+/u) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +const claudeKindLabels = Object.freeze({ + five_hour: 'Session (5h)', + seven_day: 'Weekly · all models', + seven_day_opus: 'Weekly · Opus', + seven_day_sonnet: 'Weekly · Sonnet', + seven_day_oauth_apps: 'Weekly · apps', +}); + +function claudeKindLabel(kind, scopeName) { + const base = claudeKindLabels[kind] ?? (titleCase(kind) || 'Usage window'); + const scope = plainLabel(scopeName); + return scope !== '' && !base.includes(scope) + ? plainLabel(`${base} · ${scope}`, base) + : base; +} + +export function emptyClaudeDoc() { return { - ...dashboard, - codex: normalizePublishedCodex(projection), + provider: 'claude', + status: 'idle', + error: null, + updated: null, + limits: [], }; } -export function withClaudeStatusline(current, payload) { - const dashboard = normalizeDashboard(current); +export function emptyCodexDoc() { return { - ...dashboard, - claude: normalizeClaudeStatusline(payload), + provider: 'codex', + status: 'idle', + error: null, + updated: null, + plan: null, + limitReached: false, + credits: null, + limits: [], }; } -export function withCodexLoading(current) { - const dashboard = normalizeDashboard(current); +export function loadingDoc(previous, empty) { + const base = previous !== null && + typeof previous === 'object' && + Array.isArray(previous.limits) + ? previous + : empty; + return {...base, status: 'loading', error: null}; +} + +function receiptFailure(receipt, connectHint) { + if (receipt === null || typeof receipt !== 'object') { + return {status: 'error', error: 'The connector returned nothing.'}; + } + if (receipt.ok === false) { + const code = receipt.error?.code; + if (code === 'auth-not-configured') { + return {status: 'unconfigured', error: connectHint}; + } + if (code === 'timeout') { + return {status: 'error', error: 'The usage request timed out. Try again.'}; + } + return {status: 'error', error: 'The usage request failed. Try again.'}; + } + const status = receipt.status; + if (status === 401 || status === 403) { + return { + status: 'unconfigured', + error: `Token rejected (HTTP ${status}). ${connectHint}`, + }; + } + if (status === 429) { + return { + status: 'error', + error: 'The usage endpoint rate-limited this request. Try again in a minute.', + }; + } + if (typeof status === 'number' && status >= 400) { + return {status: 'error', error: `The usage endpoint returned HTTP ${status}.`}; + } + if (receipt.body === null || + typeof receipt.body !== 'object' || + Array.isArray(receipt.body)) { + return {status: 'error', error: 'The usage endpoint returned no JSON body.'}; + } + return null; +} + +const claudeHint = 'Sign in to Claude Code, then refresh.'; + +export function mapClaudeReceipt(receipt, clockValue = receiptClock(receipt)) { + const failure = receiptFailure(receipt, claudeHint); + if (failure !== null) { + return { + ...emptyClaudeDoc(), + status: failure.status, + error: failure.error, + updated: updatedLabel(clockValue), + }; + } + const body = receipt.body; + const rows = []; + if (Array.isArray(body.limits) && body.limits.length > 0) { + for (const limit of body.limits.slice(0, maximumRows)) { + if (limit === null || typeof limit !== 'object' || + typeof limit.kind !== 'string') { + continue; + } + const scope = limit.scope?.model?.display_name; + const safeKind = plainLabel(limit.kind, 'usage-window'); + rows.push({ + id: `claude:${safeKind}:${plainLabel(scope, 'all')}`, + label: claudeKindLabel(limit.kind, scope), + percent: clampPercent(limit.percent), + reset: resetLabel(parseResetsAt(limit.resets_at), clockValue), + active: limit.is_active !== false, + }); + } + } else { + for (const key of Object.keys(claudeKindLabels)) { + const window = body[key]; + if (window === null || typeof window !== 'object' || Array.isArray(window)) { + continue; + } + const used = typeof window.utilization === 'number' + ? window.utilization + : window.used_percentage; + if (typeof used !== 'number') continue; + rows.push({ + id: `claude:${key}`, + label: claudeKindLabel(key, null), + percent: clampPercent(used), + reset: resetLabel(parseResetsAt(window.resets_at), clockValue), + active: true, + }); + } + } return { - ...dashboard, - codex: { - ...dashboard.codex, - status: 'loading', - }, + ...emptyClaudeDoc(), + status: 'ready', + updated: updatedLabel(clockValue), + limits: rows, + error: rows.length === 0 ? 'The account reported no usage windows.' : null, }; } -function asObject(value) { - return value !== null && typeof value === 'object' && !Array.isArray(value) - ? value - : {}; +const codexHint = 'Run `codex login`, then refresh.'; + +function codexWindowLabel(seconds) { + if (seconds === 604800) return 'Weekly'; + if (seconds === 86400) return 'Daily'; + if (seconds === 18000) return '5-hour'; + if (seconds === 3600) return 'Hourly'; + const span = formatDuration(seconds); + return span === null ? 'Window' : `${span} window`; +} + +function pushCodexWindows(rows, rateLimit, name, clockValue) { + if (rows.length >= maximumRows || + rateLimit === null || + typeof rateLimit !== 'object' || + Array.isArray(rateLimit)) { + return; + } + const windows = [ + ['primary', rateLimit.primary_window], + ['secondary', rateLimit.secondary_window], + ]; + for (const [kind, window] of windows) { + if (rows.length >= maximumRows || + window === null || + typeof window !== 'object' || + Array.isArray(window)) { + continue; + } + const spanLabel = codexWindowLabel(window.limit_window_seconds); + let reset = resetLabel(parseResetsAt(window.reset_at), clockValue); + if (reset === null && + typeof window.reset_after_seconds === 'number' && + window.reset_after_seconds >= 0 && + window.reset_after_seconds * 1000 <= maximumPlausibleResetMs) { + const duration = formatDuration(window.reset_after_seconds); + if (duration !== null) reset = `resets in ${duration} (as of refresh)`; + } + const safeName = plainLabel(name); + rows.push({ + id: `codex:${safeName || 'main'}:${kind}:${spanLabel}`, + label: safeName === '' ? `${spanLabel} limit` : `${safeName} · ${spanLabel}`, + percent: clampPercent(window.used_percent), + reset, + active: true, + }); + } +} + +export function mapCodexReceipt(receipt, clockValue = receiptClock(receipt)) { + const failure = receiptFailure(receipt, codexHint); + if (failure !== null) { + return { + ...emptyCodexDoc(), + status: failure.status, + error: failure.error, + updated: updatedLabel(clockValue), + }; + } + const body = receipt.body; + const rows = []; + pushCodexWindows(rows, body.rate_limit, '', clockValue); + pushCodexWindows(rows, body.code_review_rate_limit, 'Code review', clockValue); + if (Array.isArray(body.additional_rate_limits)) { + for (const extra of body.additional_rate_limits.slice(0, maximumRows)) { + if (rows.length >= maximumRows || + extra === null || + typeof extra !== 'object' || + Array.isArray(extra)) { + continue; + } + pushCodexWindows( + rows, + extra.rate_limit, + plainLabel(extra.limit_name, 'Additional'), + clockValue, + ); + } + } + const credits = body.credits; + let creditLabel = null; + if (credits !== null && typeof credits === 'object' && !Array.isArray(credits)) { + if (credits.unlimited === true) { + creditLabel = 'Credits: unlimited'; + } else if (credits.has_credits === true && + typeof credits.balance === 'string' && + /^(?:0|[1-9]\d{0,9})(?:\.\d{1,6})?$/u.test(credits.balance)) { + creditLabel = `Credits: ${credits.balance}`; + } + } + return { + ...emptyCodexDoc(), + status: 'ready', + updated: updatedLabel(clockValue), + plan: titleCase(body.plan_type) || null, + limitReached: body.rate_limit?.limit_reached === true, + credits: creditLabel, + limits: rows, + error: rows.length === 0 ? 'The account reported no rate-limit windows.' : null, + }; } diff --git a/extensions/io.flutterflow.campus.usage-limits/src/read_codex_usage.js b/extensions/io.flutterflow.campus.usage-limits/src/read_codex_usage.js deleted file mode 100644 index 59354a0..0000000 --- a/extensions/io.flutterflow.campus.usage-limits/src/read_codex_usage.js +++ /dev/null @@ -1,159 +0,0 @@ -// Bounded client for the schema-v3 generic process resource. The host grants -// the exact `codex` executable; this module supplies only the -// literal `app-server` argument and speaks bounded JSON-RPC over its pipes. - -import { - BoundedJsonRpcDecoder, - codexRpc, - encodeJsonRpcLine, - initializedNotification, - initializeRequest, - rateLimitsRequest, - usageRequest, -} from './codex_protocol.js'; -import { - normalizeCodexAccount, - unavailableCodexProjection, -} from './codex_usage.js'; -import {withCodexProjection} from './model.js'; - -const processOutputBudget = 262144; -const readChunkBudget = 65536; -const maximumReads = 32; - -export async function readCodexUsage({ - process, - binding, - current, - refreshedAt = '', -}) { - let child = null; - let projection; - try { - child = await process.spawn(binding, { - executable: 'codex', - arguments: ['app-server'], - timeoutMs: 5000, - }); - const decoder = new BoundedJsonRpcDecoder({ - maxTotalBytes: processOutputBudget, - maxLineBytes: readChunkBudget, - }); - - await writeMessage(process, child, initializeRequest()); - const initialized = await readResponses( - process, - child, - decoder, - new Set([codexRpc.initializeId]), - ); - requireSuccessfulResponse(initialized.get(codexRpc.initializeId)); - - await writeMessage(process, child, initializedNotification()); - await writeMessage(process, child, rateLimitsRequest()); - await writeMessage(process, child, usageRequest()); - const responses = await readResponses( - process, - child, - decoder, - new Set([codexRpc.rateLimitsId, codexRpc.usageId]), - ); - const rateLimits = requireSuccessfulResponse( - responses.get(codexRpc.rateLimitsId), - ); - const usage = requireSuccessfulResponse(responses.get(codexRpc.usageId)); - projection = normalizeCodexAccount( - rateLimits, - usage, - refreshedAt, - ); - } catch (_) { - // Never publish process output or a raw account/auth error. The only shared - // failure fact is that the locally granted source is unavailable. - projection = unavailableCodexProjection('process-unavailable', refreshedAt); - } finally { - if (child !== null) await closeChildBestEffort(process, child); - } - - return withCodexProjection(current, projection); -} - -async function writeMessage(process, child, message) { - await process.write(child, { - text: encodeJsonRpcLine(message), - }); -} - -async function readResponses(process, child, decoder, expectedIds) { - const found = new Map(); - for (let attempt = 0; attempt < maximumReads; attempt += 1) { - takeExpected(decoder, expectedIds, found); - if (hasEveryExpected(expectedIds, found)) return found; - - const receipt = await process.read(child, { - stream: 'stdout', - maxBytes: readChunkBudget, - timeoutMs: 2000, - }); - const chunk = readText(receipt); - if (chunk !== '') decoder.push(chunk); - if (isEndOfStream(receipt)) decoder.finish(); - takeExpected(decoder, expectedIds, found); - if (hasEveryExpected(expectedIds, found)) return found; - if (isEndOfStream(receipt)) break; - } - throw new Error('Codex app-server did not return the required responses'); -} - -function takeExpected(decoder, expectedIds, found) { - for (const id of expectedIds) { - const buffered = decoder.take(id); - if (buffered !== null) found.set(id, buffered); - } -} - -function hasEveryExpected(expectedIds, found) { - return [...expectedIds].every((id) => found.has(id)); -} - -function requireSuccessfulResponse(message) { - if ( - message === null || - message === undefined || - Object.hasOwn(message, 'error') || - !Object.hasOwn(message, 'result') - ) { - throw new Error('Codex app-server request failed'); - } - return message; -} - -function readText(receipt) { - if (typeof receipt === 'string') return receipt; - if (receipt === null || typeof receipt !== 'object' || Array.isArray(receipt)) { - return ''; - } - if (typeof receipt.text === 'string') return receipt.text; - if (typeof receipt.data === 'string') return receipt.data; - return typeof receipt.stdout === 'string' ? receipt.stdout : ''; -} - -function isEndOfStream(receipt) { - return receipt !== null && - typeof receipt === 'object' && - !Array.isArray(receipt) && - (receipt.eof === true || receipt.done === true); -} - -async function closeChildBestEffort(process, child) { - try { - await process.signal(child, {signal: 'terminate'}); - } catch (_) { - // The app-server may already have exited. - } - try { - await process.wait(child, {timeoutMs: 1000}); - } catch (_) { - // Cleanup is bounded and cannot change the already normalized projection. - } -} diff --git a/extensions/io.flutterflow.campus.usage-limits/src/render.js b/extensions/io.flutterflow.campus.usage-limits/src/render.js deleted file mode 100644 index e9f6af3..0000000 --- a/extensions/io.flutterflow.campus.usage-limits/src/render.js +++ /dev/null @@ -1,466 +0,0 @@ -// Pure scene.v1 renderer. It sees only the normalized shared projection from -// model.js, never a process handle, JSON-RPC response, or Claude status payload. - -import {actions} from './campus.generated.mjs'; -import {normalizeDashboard} from './model.js'; - -const theme = Object.freeze({ - background: '#071018', - panel: '#0C1924', - panelAlt: '#102231', - border: '#203746', - text: '#EAF2F6', - secondary: '#8CA1AD', - codex: '#52D3A8', - claude: '#D9A27B', - warning: '#F4C66D', - danger: '#FF8278', -}); - -export function render(view) { - const dashboard = normalizeDashboard(view?.inputs?.model); - const width = surfaceWidth(view); - const contentWidth = width - 32; - const sourceWidth = width >= 720 - ? Math.floor((contentWidth - 12) / 2) - : contentWidth; - const sourceCards = [ - codexCard(dashboard.codex, sourceWidth), - claudeCard(dashboard.claude, sourceWidth), - ]; - - return { - key: 'usage-limits', - type: 'box', - props: { - width, - padding: [16, 16], - background: theme.background, - borderColor: theme.border, - borderWidth: 1, - radius: 14, - semanticsLabel: 'Claude and Codex usage limits dashboard', - }, - children: [{ - key: 'usage-layout', - type: 'column', - props: {gap: 14, crossAlign: 'stretch'}, - children: [ - header(), - { - key: 'source-cards', - type: width >= 720 ? 'row' : 'column', - props: {gap: 12, crossAlign: 'stretch'}, - children: sourceCards, - }, - privacyFooter(), - ], - }], - }; -} - -function header() { - return { - key: 'header', - type: 'row', - props: {gap: 10, crossAlign: 'center'}, - children: [ - { - key: 'title-block', - type: 'column', - props: {gap: 3, flex: 1, crossAlign: 'stretch'}, - children: [ - text('title', 'Usage limits', { - fontSize: 22, - fontWeight: 700, - color: theme.text, - }), - text( - 'subtitle', - 'One owner-collected, teammate-readable view', - {fontSize: 12, color: theme.secondary}, - ), - ], - }, - { - key: 'refresh-codex', - type: 'button', - props: { - label: 'Refresh Codex', - actionId: actions.refreshCodex, - semanticsLabel: 'Refresh Codex account usage', - }, - }, - ], - }; -} - -function codexCard(codex, width) { - const children = [sourceHeading( - 'codex-heading', - 'CODEX', - codex.status === 'loading' ? 'Refreshing…' : statusLabel(codex.status), - theme.codex, - )]; - if (codex.status === 'ready' || codex.status === 'loading') { - if (codex.plan) { - children.push(detail('codex-plan', 'Plan', codex.plan)); - } - if (codex.windows.length === 0) { - children.push(emptyText( - 'codex-no-windows', - 'No rate-limit windows were returned.', - )); - } else { - for (const window of codex.windows.slice(0, 4)) { - children.push(limitWindow(`codex-${window.id}`, window, theme.codex)); - } - if (codex.windows.length > 4) { - children.push(emptyText( - 'codex-more-windows', - `+${codex.windows.length - 4} additional windows`, - )); - } - } - children.push(codexUsageGrid(codex)); - } else { - children.push(emptyText( - 'codex-unavailable', - codex.reason === 'process-unavailable' - ? 'The granted local Codex app-server is unavailable.' - : 'Refresh to read locally authenticated Codex usage.', - )); - } - return sourceCard('codex-card', width, theme.codex, children); -} - -function claudeCard(claude, width) { - const children = [sourceHeading( - 'claude-heading', - 'CLAUDE', - statusLabel(claude.status), - theme.claude, - )]; - if (claude.status === 'ready') { - if (claude.model) { - children.push(detail('claude-model', 'Model', claude.model)); - } - children.push(limitWindow( - 'claude-context', - { - label: 'Context window', - usedPercent: claude.context.usedPercent, - durationMinutes: null, - resetsAt: '', - }, - theme.claude, - )); - children.push(metricGrid('claude-metrics', [ - metric('claude-used', 'Context tokens', formatCount(claude.context.usedTokens)), - metric('claude-size', 'Context size', formatCount(claude.context.sizeTokens)), - metric('claude-cost', 'Session cost', formatMoney(claude.session.costUsd)), - metric('claude-duration', 'Duration', formatDuration(claude.session.durationMs)), - ])); - } else { - children.push(emptyText( - 'claude-unavailable', - 'Waiting for the next bounded Claude Code status-line action.', - )); - } - return sourceCard('claude-card', width, theme.claude, children); -} - -function sourceCard(key, width, accent, children) { - return { - key, - type: 'box', - props: { - width, - padding: [14, 14], - background: theme.panel, - borderColor: accent, - borderWidth: 1, - radius: 12, - }, - children: [{ - key: `${key}:layout`, - type: 'column', - props: {gap: 11, crossAlign: 'stretch'}, - children, - }], - }; -} - -function sourceHeading(key, title, status, color) { - return { - key, - type: 'row', - props: {gap: 8, crossAlign: 'center'}, - children: [ - text(`${key}:title`, title, { - flex: 1, - fontSize: 12, - fontWeight: 700, - letterSpacing: 1.6, - color, - }), - badge(`${key}:status`, status, color), - ], - }; -} - -function limitWindow(key, window, accent) { - const usedPercent = window.usedPercent; - const footer = [ - window.durationMinutes === null - ? '' - : formatWindowDuration(window.durationMinutes), - window.resetsAt === '' ? '' : `resets ${formatTimestamp(window.resetsAt)}`, - ].filter(Boolean).join(' · '); - return { - key, - type: 'box', - props: { - padding: [10, 10], - background: theme.panelAlt, - radius: 9, - }, - children: [{ - key: `${key}:layout`, - type: 'column', - props: {gap: 6, crossAlign: 'stretch'}, - children: [ - { - key: `${key}:label-row`, - type: 'row', - props: {gap: 6, crossAlign: 'center'}, - children: [ - text(`${key}:label`, window.label, { - flex: 1, - fontSize: 12, - color: theme.text, - maxLines: 1, - }), - text( - `${key}:percent`, - usedPercent === null ? '—' : `${formatDecimal(usedPercent)}% used`, - {fontSize: 12, fontWeight: 700, color: percentageColor(usedPercent, accent)}, - ), - ], - }, - progressBar(`${key}:bar`, usedPercent, accent), - ...(footer === '' - ? [] - : [text(`${key}:footer`, footer, { - fontSize: 10, - color: theme.secondary, - maxLines: 1, - })]), - ], - }], - }; -} - -function progressBar(key, percent, accent) { - const safe = percent === null ? 0 : Math.min(100, Math.max(0, percent)); - return { - key, - type: 'box', - props: {height: 7, background: '#1B303D', radius: 4}, - children: [{ - key: `${key}:fill`, - type: 'box', - props: { - width: Math.max(2, Math.round(safe * 2.2)), - height: 7, - background: percentageColor(percent, accent), - radius: 4, - }, - children: [], - }], - }; -} - -function codexUsageGrid(codex) { - let balance = codex.credits.unlimited === true - ? 'Unlimited' - : codex.credits.balance === null - ? '—' - : formatDecimal(codex.credits.balance); - if (codex.credits.resetCreditsAvailable !== null) { - const resets = `${codex.credits.resetCreditsAvailable} reset${ - codex.credits.resetCreditsAvailable === 1 ? '' : 's' - }`; - balance = balance === '—' ? resets : `${balance} · ${resets}`; - } - return metricGrid('codex-metrics', [ - metric('codex-lifetime', 'Lifetime tokens', formatCount(codex.usage.lifetimeTokens)), - metric('codex-peak', 'Peak daily', formatCount(codex.usage.peakDailyTokens)), - metric('codex-streak', 'Current streak', formatDays(codex.usage.currentStreakDays)), - metric('codex-credits', 'Credits', balance), - ]); -} - -function metricGrid(key, metrics) { - return { - key, - type: 'column', - props: {gap: 7, crossAlign: 'stretch'}, - children: [ - { - key: `${key}:top`, - type: 'row', - props: {gap: 7, crossAlign: 'stretch'}, - children: metrics.slice(0, 2), - }, - { - key: `${key}:bottom`, - type: 'row', - props: {gap: 7, crossAlign: 'stretch'}, - children: metrics.slice(2, 4), - }, - ], - }; -} - -function metric(key, label, value) { - return { - key, - type: 'box', - props: {flex: 1, padding: [8, 8], background: '#0A1720', radius: 8}, - children: [{ - key: `${key}:layout`, - type: 'column', - props: {gap: 3, crossAlign: 'stretch'}, - children: [ - text(`${key}:label`, label, {fontSize: 10, color: theme.secondary}), - text(`${key}:value`, value, { - fontSize: 13, - fontWeight: 700, - font: 'mono', - color: theme.text, - maxLines: 1, - }), - ], - }], - }; -} - -function detail(key, label, value) { - return { - key, - type: 'row', - props: {gap: 8, crossAlign: 'center'}, - children: [ - text(`${key}:label`, label, {flex: 1, fontSize: 11, color: theme.secondary}), - text(`${key}:value`, value, { - fontSize: 11, - color: theme.text, - maxLines: 1, - }), - ], - }; -} - -function privacyFooter() { - return { - key: 'privacy', - type: 'box', - props: { - padding: [10, 12], - background: '#0A1720', - borderColor: theme.border, - borderWidth: 1, - radius: 9, - }, - children: [text( - 'privacy-copy', - 'Shared: normalized visible counters only · Withheld: account identity, auth, paths, session ids, transcripts, and raw responses', - {fontSize: 10, color: theme.secondary, maxLines: 3}, - )], - }; -} - -function emptyText(key, value) { - return text(key, value, { - fontSize: 12, - color: theme.secondary, - maxLines: 3, - }); -} - -function badge(key, value, color) { - return { - key, - type: 'badge', - props: {label: value, color}, - }; -} - -function text(key, value, props = {}) { - return {key, type: 'text', props: {value: String(value), ...props}}; -} - -function statusLabel(status) { - return status === 'ready' - ? 'Current' - : status === 'loading' - ? 'Refreshing' - : 'Waiting'; -} - -function percentageColor(percent, accent) { - return percent === null - ? theme.secondary - : percent >= 90 - ? theme.danger - : percent >= 75 - ? theme.warning - : accent; -} - -function surfaceWidth(view) { - const value = view?.inputs?.surface?.width; - return typeof value === 'number' && Number.isFinite(value) - ? Math.min(860, Math.max(560, Math.round(value))) - : 720; -} - -function formatCount(value) { - if (value === null) return '—'; - if (value >= 1_000_000_000) return `${formatDecimal(value / 1_000_000_000)}B`; - if (value >= 1_000_000) return `${formatDecimal(value / 1_000_000)}M`; - if (value >= 1_000) return `${formatDecimal(value / 1_000)}K`; - return String(value); -} - -function formatMoney(value) { - return value === null ? '—' : `$${value.toFixed(value < 10 ? 2 : 0)}`; -} - -function formatDuration(value) { - if (value === null) return '—'; - const minutes = Math.floor(value / 60000); - if (minutes < 1) return '<1 min'; - if (minutes < 60) return `${minutes} min`; - return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; -} - -function formatDays(value) { - return value === null ? '—' : `${value} day${value === 1 ? '' : 's'}`; -} - -function formatWindowDuration(value) { - if (value < 60) return `${value} min window`; - if (value % 1440 === 0) return `${value / 1440} day window`; - if (value % 60 === 0) return `${value / 60} hour window`; - return `${value} min window`; -} - -function formatTimestamp(value) { - return value.length >= 16 ? value.slice(0, 16).replace('T', ' ') + 'Z' : value; -} - -function formatDecimal(value) { - return Number.isInteger(value) ? String(value) : value.toFixed(1); -} diff --git a/extensions/io.flutterflow.campus.usage-limits/src/render_claude.js b/extensions/io.flutterflow.campus.usage-limits/src/render_claude.js new file mode 100644 index 0000000..546c856 --- /dev/null +++ b/extensions/io.flutterflow.campus.usage-limits/src/render_claude.js @@ -0,0 +1,68 @@ +// Pure renderer for the Claude usage card: model snapshot in, scene.v1 out. + +import { + card, + footer, + header, + limitRow, + loadingLimitRow, + statusBody, + usageRows, +} from './card_ui.js'; + +const theme = Object.freeze({ + background: '#1F1E1B', + border: '#3B382F', + track: '#33302A', + skeleton: '#454139', + skeletonHighlight: '#5A554B', + text: '#EDECE6', + secondary: '#A29E93', + accent: '#D97757', + ok: '#7BA05B', + warning: '#E5A458', + danger: '#EB5757', + semanticsLabel: 'Claude usage limits card', +}); + +export function render(view) { + const doc = view?.inputs?.model ?? {}; + const limits = usageRows(doc.limits, 'claude'); + const refreshing = doc.status === 'loading'; + const children = [header( + 'claude:header', + 'CLAUDE', + null, + 'refreshClaude', + theme, + refreshing, + )]; + + if (refreshing) { + appendLoadingRows(children, 'claude', limits, theme); + } else { + appendLimitRows(children, limits, theme); + } + children.push(...statusBody('claude:status', doc, theme)); + children.push(...footer('claude:footer', doc, theme)); + return card('claude-usage-card', theme, children); +} + +function appendLimitRows(children, limits, theme) { + for (const row of limits) { + children.push(limitRow(row, theme)); + } +} + +function appendLoadingRows(children, keyPrefix, limits, theme) { + const count = Math.max(2, limits.length); + for (let index = 0; index < count; index += 1) { + const row = limits[index]; + children.push(loadingLimitRow( + row?.id ?? `${keyPrefix}:placeholder-${index}`, + row?.label ?? '', + theme, + index, + )); + } +} diff --git a/extensions/io.flutterflow.campus.usage-limits/src/render_codex.js b/extensions/io.flutterflow.campus.usage-limits/src/render_codex.js new file mode 100644 index 0000000..9bc5429 --- /dev/null +++ b/extensions/io.flutterflow.campus.usage-limits/src/render_codex.js @@ -0,0 +1,87 @@ +// Pure renderer for the Codex usage card: model snapshot in, scene.v1 out. + +import { + boundedTextValue, + card, + footer, + header, + limitRow, + loadingLimitRow, + statusBody, + text, + usageRows, +} from './card_ui.js'; + +const theme = Object.freeze({ + background: '#15181C', + border: '#2C333B', + track: '#242B32', + skeleton: '#303840', + skeletonHighlight: '#45515B', + text: '#E9EDF1', + secondary: '#93A0AB', + accent: '#10A37F', + ok: '#4CAF7D', + warning: '#E5A458', + danger: '#EB5757', + semanticsLabel: 'Codex usage limits card', +}); + +export function render(view) { + const doc = view?.inputs?.model ?? {}; + const limits = usageRows(doc.limits, 'codex'); + const refreshing = doc.status === 'loading'; + const plan = typeof doc.plan === 'string' ? boundedTextValue(doc.plan, 48) : ''; + const badge = plan !== '' + ? plan + : null; + const children = [header( + 'codex:header', + 'CODEX', + badge, + 'refreshCodex', + theme, + refreshing, + )]; + + if (!refreshing && doc.limitReached === true) { + children.push(text('codex:limit-reached', 'Limit reached', { + color: theme.danger, + fontSize: 12, + fontWeight: 700, + })); + } + if (refreshing) { + appendLoadingRows(children, 'codex', limits, theme); + } else { + appendLimitRows(children, limits, theme); + } + if (!refreshing && typeof doc.credits === 'string' && doc.credits !== '') { + children.push(text('codex:credits', doc.credits, { + color: theme.secondary, + fontSize: 11, + })); + } + children.push(...statusBody('codex:status', doc, theme)); + children.push(...footer('codex:footer', doc, theme)); + return card('codex-usage-card', theme, children); +} + +function appendLimitRows(children, limits, theme) { + for (const row of limits) { + children.push(limitRow(row, theme)); + } +} + +function appendLoadingRows(children, keyPrefix, limits, theme) { + const count = Math.max(2, limits.length); + for (let index = 0; index < count; index += 1) { + const row = limits[index]; + children.push(loadingLimitRow( + row?.id ?? `${keyPrefix}:placeholder-${index}`, + row?.label ?? '', + theme, + index, + )); + } +} diff --git a/extensions/io.flutterflow.campus.usage-limits/test/usage_limits.test.mjs b/extensions/io.flutterflow.campus.usage-limits/test/usage_limits.test.mjs index 2f07185..19d4d4c 100644 --- a/extensions/io.flutterflow.campus.usage-limits/test/usage_limits.test.mjs +++ b/extensions/io.flutterflow.campus.usage-limits/test/usage_limits.test.mjs @@ -2,528 +2,400 @@ import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; import test from 'node:test'; -import { - BoundedJsonRpcDecoder, - codexRpc, - encodeJsonRpcLine, - initializeRequest, - initializedNotification, - rateLimitsRequest, - usageRequest, -} from '../src/codex_protocol.js'; -import { - normalizeCodexAccount, - unavailableCodexProjection, -} from '../src/codex_usage.js'; -import {normalizeClaudeStatusline} from '../src/claude_status.js'; import {activate} from '../src/main.js'; import { - createInitialDashboard, - normalizeDashboard, - withClaudeStatusline, + emptyClaudeDoc, + emptyCodexDoc, + formatLocalTime, + loadingDoc, + mapClaudeReceipt, + mapCodexReceipt, + parseResetsAt, + receiptClock, } from '../src/model.js'; -import {readCodexUsage} from '../src/read_codex_usage.js'; -import {render} from '../src/render.js'; +import {render as renderClaude} from '../src/render_claude.js'; +import {render as renderCodex} from '../src/render_codex.js'; const manifestUrl = new URL('../campus.extension.json', import.meta.url); +const receivedAt = Date.UTC(2026, 7, 6, 10, 30); +const localClock = Object.freeze({nowMs: receivedAt, utcOffsetMinutes: 120}); -test('manifest uses only the schema-v3 local process and external-action seams', async () => { +test('manifest publishes the local two-card connector design under the official ID', async () => { const source = await readFile(manifestUrl, 'utf8'); const manifest = JSON.parse(source); assert.equal(manifest.schemaVersion, 3); assert.equal(manifest.id, 'io.flutterflow.campus.usage-limits'); - assert.equal(manifest.version, '1.0.0'); + assert.equal(manifest.version, '2.0.0'); assert.deepEqual(manifest.capabilities.maximum, [ + 'connector.http.request', 'state.shared.write', 'surface.scene.write', ]); assert.deepEqual(manifest.capabilities.required, manifest.capabilities.maximum); - assert.deepEqual(manifest.resources.codex, { - kind: 'process', - operations: ['spawn', 'write', 'read', 'wait', 'signal'], - request: { - mode: 'scoped', - scopes: [{type: 'executable', value: 'codex'}], - }, - }); - assert.deepEqual(manifest.externalActions.claudeStatus, { - action: 'ingestClaudeStatus', - resourceSlots: [], - timeoutMs: 5000, - maxInputBytes: 262144, - maxOutputBytes: 65536, - }); + assert.deepEqual(manifest.resources, {}); assert.deepEqual(manifest.tasks, {}); - assert.equal(manifest.actions.refreshCodex.audience, 'owner'); - assert.equal(manifest.actions.ingestClaudeStatus.audience, 'owner'); - assert.equal(Object.hasOwn(manifest, 'connectors'), false); - assert.equal(Object.hasOwn(manifest, 'nativeHelpers'), false); - assert.doesNotMatch(source, /oauth|bearer|https?:\/\//iu); -}); - -test('Codex projection keeps visible metrics and drops account/auth material', () => { - const projection = normalizeCodexAccount( + assert.equal(Object.hasOwn(manifest, 'externalActions'), false); + assert.deepEqual( + Object.fromEntries(Object.entries(manifest.connectors).map(([id, value]) => [ + id, + {baseUrl: value.baseUrl, paths: value.pathPrefixes, methods: value.allowedMethods}, + ])), { - id: 2, - result: { - accountId: 'acct-secret', - email: 'owner@example.invalid', - authMode: 'chatgpt', - accessToken: 'secret-token', - planType: 'Plus', - rateLimitReachedType: null, - rateLimits: { - primary: { - usedPercent: 34.56, - windowDurationMins: 300, - resetsAt: 1_800_000_000, - }, - secondary: { - remainingPercent: 12, - windowDurationMins: 10080, - resetsAt: '2027-01-16T08:00:00Z', - }, - credits: {hasCredits: true, unlimited: false, balance: '19.5'}, - }, - rateLimitsByLimitId: { - reviews: { - limitId: 'secret-provider-id', - limitName: 'Code reviews', - usedPercent: 10, - windowDurationMins: 1440, - }, - }, + anthropic: { + baseUrl: 'https://api.anthropic.com', + paths: ['/api/oauth/usage'], + methods: ['GET'], }, - }, - { - id: 3, - result: { - startDate: '2026-01-01T00:00:00Z', - lifetimeTokens: 12_345_678, - peakDailyTokens: 456_789, - longestRunningTurnSec: 321, - currentStreakDays: 7, - longestStreakDays: 22, - transcriptPath: '/Users/owner/.codex/private.jsonl', + openai: { + baseUrl: 'https://chatgpt.com', + paths: ['/backend-api/wham/usage'], + methods: ['GET'], }, }, - '2026-08-04T12:00:00Z', ); - - assert.equal(projection.status, 'ready'); - assert.equal(projection.plan, 'Plus'); assert.deepEqual( - projection.windows.map((window) => [window.label, window.usedPercent]), + manifest.contributes.launchers.map((launcher) => ({ + id: launcher.id, + pool: launcher.surfaces[0].surfacePool, + frameMode: launcher.surfaces[0].frameMode, + })), [ - ['Primary window', 34.6], - ['Secondary window', 88], - ['Code reviews', 10], + {id: 'claude-usage', pool: 'claudeCard', frameMode: 'canvasObject'}, + {id: 'codex-usage', pool: 'codexCard', frameMode: 'canvasObject'}, ], ); - assert.deepEqual(projection.credits, { - hasCredits: true, - unlimited: false, - balance: 19.5, - resetCreditsAvailable: null, - }); - assert.equal(projection.usage.lifetimeTokens, 12_345_678); - assert.equal(projection.usage.startDate, '2026-01-01'); - const encoded = JSON.stringify(projection); - for (const secret of [ - 'acct-secret', - 'owner@example.invalid', - 'secret-token', - 'secret-provider-id', - '/Users/owner', - ]) { - assert.equal(encoded.includes(secret), false); - } + assert.doesNotMatch(source, /token|secret|oauthToken|accessToken/iu); }); -test('Codex credit strings admit only bounded plain decimals', () => { - function balance(value) { - return normalizeCodexAccount( - {result: {rateLimits: {credits: {balance: value}}}}, - {result: {}}, - ).credits.balance; - } +test('connector clock renders the owner-local time with an east-positive offset', () => { + const receipt = {receivedAt, utcOffsetMinutes: 120}; - assert.equal(balance('19.5'), 19.5); - assert.equal(balance('1000000000'), 1_000_000_000); - assert.equal(balance('19.5000001'), null); - assert.equal(balance('1e3'), null); - assert.equal(balance('00019.5'), null); + assert.deepEqual(receiptClock(receipt), localClock); + assert.equal(formatLocalTime(receivedAt, 120), 'Aug 6, 12:30'); + assert.equal(formatLocalTime(receivedAt, -420), 'Aug 6, 03:30'); + assert.equal(formatLocalTime(receivedAt, null), null); + assert.equal(formatLocalTime(receivedAt, 120).includes('UTC'), false); + assert.deepEqual(receiptClock({receivedAt, utcOffsetMinutes: 10000}), { + nowMs: receivedAt, + utcOffsetMinutes: null, + }); + assert.equal(mapClaudeReceipt({ + ok: true, + status: 200, + receivedAt, + body: {five_hour: {utilization: 20}}, + }).updated, 'Updated just now'); }); -test('Codex projection accepts the documented nested account response', () => { - const projection = normalizeCodexAccount( - { - id: 2, - result: { - rateLimits: { - limitId: 'codex', - planType: 'pro', - primary: {usedPercent: 25, windowDurationMins: 15}, - secondary: null, - rateLimitReachedType: null, - }, - rateLimitsByLimitId: { - codex_other: { - limitId: 'codex_other', - limitName: 'Other Codex work', - primary: {usedPercent: 42, windowDurationMins: 60}, - secondary: null, - }, - }, - rateLimitResetCredits: {availableCount: 2, credits: null}, - }, - }, +test('reset timestamps accept bounded provider wire forms', () => { + assert.equal(parseResetsAt('2026-08-06T10:30:00Z'), receivedAt); + assert.equal(parseResetsAt('2026-08-06T12:30:00+02:00'), receivedAt); + assert.equal(parseResetsAt(String(receivedAt / 1000)), receivedAt); + assert.equal(parseResetsAt(receivedAt), receivedAt); + assert.equal(parseResetsAt('not a timestamp'), null); + assert.equal(parseResetsAt('x'.repeat(100)), null); + assert.equal(parseResetsAt(Number.MAX_VALUE), null); + assert.equal(formatLocalTime(Number.MAX_VALUE, 120), null); +}); + +test('Claude projection keeps bounded visible rows and uses local update time', () => { + const limits = Array.from({length: 20}, (_, index) => ({ + kind: index === 0 + ? 'five_hour' + : index === 11 ? 'x'.repeat(10000) : `provider_kind_${index}`, + percent: 41.6 + index, + resets_at: index === 0 + ? new Date(receivedAt + 2 * 3600000).toISOString() + : new Date(receivedAt - 3600000).toISOString(), + is_active: index !== 1, + scope: {model: {display_name: `Model ${index}`}}, + account_id: 'must-not-share', + })); + const projection = mapClaudeReceipt( { - id: 3, - result: { - summary: { - lifetimeTokens: 1_234_567, - peakDailyTokens: 45_678, - longestRunningTurnSec: 540, - currentStreakDays: 8, - longestStreakDays: 14, - }, - dailyUsageBuckets: [{startDate: '2026-06-18', tokens: 12_345}], + ok: true, + status: 200, + receivedAt, + utcOffsetMinutes: 120, + body: { + limits, + email: 'owner@example.invalid', + access_token: 'secret-token', }, }, + localClock, ); - assert.equal(projection.plan, 'pro'); - assert.equal(projection.usage.lifetimeTokens, 1_234_567); - assert.equal(projection.usage.currentStreakDays, 8); - assert.equal(projection.credits.resetCreditsAvailable, 2); - assert.ok( - projection.windows.some( - (window) => window.label === 'Other Codex work' && window.usedPercent === 42, - ), - ); + assert.equal(projection.status, 'ready'); + assert.equal(projection.updated, 'Updated Aug 6, 12:30'); + assert.equal(projection.limits.length, 12); + assert.equal(projection.limits[0].label, 'Session (5h) · Model 0'); + assert.equal(projection.limits[0].percent, 42); + assert.equal(projection.limits[0].reset, 'resets in 2h 0m'); + assert.equal(projection.limits[1].active, false); + assert.equal(projection.limits[1].reset, 'reset elapsed'); + const encoded = JSON.stringify(projection); + assert.equal(encoded.includes('owner@example.invalid'), false); + assert.equal(encoded.includes('secret-token'), false); + assert.equal(encoded.includes('must-not-share'), false); + assert.equal(encoded.includes('x'.repeat(100)), false); + assert.equal(encoded.includes(' UTC'), false); }); -test('Claude status-line projection is bounded and allowlist-only', () => { - const projection = normalizeClaudeStatusline({ - session_id: 'claude-secret-session', - transcript_path: '/Users/owner/.claude/transcript.jsonl', - cwd: '/Users/owner/private-project', - workspace: {project_dir: '/Users/owner/private-project'}, - model: {id: 'claude-opus-private-id', display_name: 'Claude Opus 4.1'}, - context_window: { - context_window_size: 200_000, - used_percentage: 31.25, - remaining_percentage: 68.75, - total_output_tokens: 8_000, - current_usage: { - input_tokens: 40_000, - cache_creation_input_tokens: 10_000, - cache_read_input_tokens: 12_500, - output_tokens: 999_999, +test('Codex projection matches the local wham usage shape without raw fields', () => { + const projection = mapCodexReceipt({ + ok: true, + status: 200, + receivedAt, + utcOffsetMinutes: 120, + body: { + plan_type: 'pro', + account_id: 'private-account', + rate_limit: { + limit_reached: false, + primary_window: { + used_percent: 34.6, + limit_window_seconds: 18000, + reset_at: (receivedAt + 90 * 60000) / 1000, + }, + secondary_window: { + used_percent: 72, + limit_window_seconds: 604800, + reset_after_seconds: 7200, + }, }, + code_review_rate_limit: { + primary_window: { + used_percent: 9, + limit_window_seconds: 86400, + reset_after_seconds: 3600, + }, + }, + credits: {has_credits: true, unlimited: false, balance: '19.5'}, + token: 'must-not-share', }, - cost: { - total_cost_usd: 3.25, - total_duration_ms: 180_000, - total_api_duration_ms: 62_000, - }, - api_key: 'claude-secret-key', }); - assert.deepEqual(projection, { - status: 'ready', - model: 'Claude Opus 4.1', - context: { - usedPercent: 31.3, - remainingPercent: 68.8, - usedTokens: 62_500, - outputTokens: 8_000, - sizeTokens: 200_000, - }, - session: { - costUsd: 3.25, - durationMs: 180_000, - apiDurationMs: 62_000, - }, - }); + assert.equal(projection.status, 'ready'); + assert.equal(projection.plan, 'Pro'); + assert.equal(projection.updated, 'Updated Aug 6, 12:30'); + assert.equal(projection.credits, 'Credits: 19.5'); + assert.deepEqual( + projection.limits.map(({label, percent}) => [label, percent]), + [ + ['5-hour limit', 35], + ['Weekly limit', 72], + ['Code review · Daily', 9], + ], + ); + assert.equal(projection.limits[0].reset, 'resets in 1h 30m'); + assert.equal(projection.limits[1].reset, 'resets in 2h 0m (as of refresh)'); const encoded = JSON.stringify(projection); - assert.equal(encoded.includes('claude-secret'), false); - assert.equal(encoded.includes('/Users/owner'), false); - assert.equal(encoded.includes('private-project'), false); + assert.equal(encoded.includes('private-account'), false); + assert.equal(encoded.includes('must-not-share'), false); }); -test('Codex JSON-RPC framing names only the documented local account reads', () => { - assert.deepEqual(initializeRequest(), { - id: 1, - method: 'initialize', - params: { - clientInfo: { - name: 'campus-usage-limits', - title: 'Campus Usage Limits', - version: '1.0.0', - }, - capabilities: {}, - }, +test('operational failures publish only generic guidance', () => { + const claude = mapClaudeReceipt({ + ok: false, + receivedAt, + utcOffsetMinutes: 120, + error: {code: 'network', message: 'owner@example.invalid secret-token'}, + }); + const codex = mapCodexReceipt({ + ok: false, + receivedAt, + utcOffsetMinutes: 120, + error: {code: 'auth-not-configured', message: 'private keychain path'}, }); - assert.equal(initializedNotification().method, 'initialized'); - assert.equal(Object.hasOwn(initializedNotification(), 'params'), false); - assert.equal(rateLimitsRequest().method, 'account/rateLimits/read'); - assert.equal(Object.hasOwn(rateLimitsRequest(), 'params'), false); - assert.equal(usageRequest().method, 'account/usage/read'); - assert.equal(Object.hasOwn(usageRequest(), 'params'), false); - const decoder = new BoundedJsonRpcDecoder(); - const first = encodeJsonRpcLine({id: 2, result: {ok: true}}); - const second = encodeJsonRpcLine({id: 3, result: {ok: true}}); - decoder.push(first.slice(0, 13)); - decoder.push(first.slice(13) + second); - assert.equal(decoder.take(codexRpc.usageId).id, 3); - assert.equal(decoder.take(codexRpc.rateLimitsId).id, 2); + assert.equal(claude.status, 'error'); + assert.equal(claude.error, 'The usage request failed. Try again.'); + assert.equal(codex.status, 'unconfigured'); + assert.equal(codex.error, 'Run `codex login`, then refresh.'); + const encoded = JSON.stringify({claude, codex}); + assert.equal(encoded.includes('owner@example.invalid'), false); + assert.equal(encoded.includes('secret-token'), false); + assert.equal(encoded.includes('keychain'), false); }); -test('Codex client uses the scoped process and returns only normalized state', async () => { - const writes = []; - const calls = []; - const chunks = [ - encodeJsonRpcLine({id: 1, result: {}}), - encodeJsonRpcLine({ - id: 2, - result: { - email: 'task-owner@example.invalid', - planType: 'Team', - rateLimits: { - primary: {usedPercent: 45, windowDurationMins: 300}, - }, - }, - }) + encodeJsonRpcLine({ - id: 3, - result: { - lifetimeTokens: 99_000, - transcriptPath: '/private/task-transcript', - }, - }), - ]; - const codexBinding = Object.freeze({opaque: 'codex-binding'}); - const current = withClaudeStatusline(createInitialDashboard(), { - model: {display_name: 'Claude Sonnet'}, - context_window: {used_percentage: 20, context_window_size: 200_000}, - }); - const process = { - async spawn(binding, options) { - calls.push(['spawn', binding, options]); - return Object.freeze({opaque: 'child'}); - }, - async write(child, options) { - calls.push(['write', child]); - writes.push(JSON.parse(options.text)); - }, - async read(child, options) { - calls.push(['read', child, options]); - return {text: chunks.shift() ?? '', eof: chunks.length === 0}; - }, - async signal(child, options) { - calls.push(['signal', child, options]); - }, - async wait(child, options) { - calls.push(['wait', child, options]); - return {exitCode: 0}; - }, +test('loading keeps labels for layout continuity but renderers replace values with skeletons', () => { + const readyClaude = { + ...emptyClaudeDoc(), + status: 'ready', + updated: 'Updated Aug 6, 12:30', + limits: [{ + id: 'claude:0', + label: 'Session (5h)', + percent: 42, + reset: 'resets in 2h 0m', + active: true, + }], }; - - const published = await readCodexUsage({ - process, - binding: codexBinding, - current, - refreshedAt: '2026-08-04T12:00:00Z', + const readyScene = renderClaude({inputs: {model: readyClaude}}); + const loadingScene = renderClaude({ + inputs: {model: loadingDoc(readyClaude, emptyClaudeDoc())}, }); + const readyEncoded = JSON.stringify(readyScene); + const loadingEncoded = JSON.stringify(loadingScene); - assert.deepEqual(calls[0], [ - 'spawn', - codexBinding, - { - executable: 'codex', - arguments: ['app-server'], - timeoutMs: 5000, - }, - ]); - assert.deepEqual(writes.map((message) => message.method), [ - 'initialize', - 'initialized', - 'account/rateLimits/read', - 'account/usage/read', - ]); - assert.equal(calls.some(([kind]) => kind === 'signal'), true); - assert.equal(calls.some(([kind]) => kind === 'wait'), true); - assert.equal(published.codex.status, 'ready'); - assert.equal(published.codex.usage.lifetimeTokens, 99_000); - assert.equal(published.claude.model, 'Claude Sonnet'); - const encoded = JSON.stringify(published); - assert.equal(encoded.includes('task-owner@example.invalid'), false); - assert.equal(encoded.includes('/private/task-transcript'), false); - assert.ok(Buffer.byteLength(encoded, 'utf8') < 65536); + assert.match(readyEncoded, /42% used/u); + assert.match(loadingEncoded, /Refreshing…/u); + assert.match(loadingEncoded, /Refreshing usage limit/u); + assert.match(loadingEncoded, /Session \(5h\)/u); + assert.doesNotMatch(loadingEncoded, /42% used/u); + assert.equal( + loadingScene.children[0].children[1].key, + readyScene.children[0].children[1].key, + ); + assert.equal(uniqueKeys(loadingScene), countNodes(loadingScene)); + assert.ok(countNodes(loadingScene) <= 700); }); -test('process failures publish one generic unavailable fact', async () => { - const published = await readCodexUsage({ - refreshedAt: '2026-08-04T12:00:00Z', - current: createInitialDashboard(), - binding: {}, - process: { - spawn: async () => { - throw new Error('token owner@example.invalid /Users/owner/private'); +test('Codex loading skeleton hides stale credits and limit warnings', () => { + const ready = { + ...emptyCodexDoc(), + status: 'ready', + plan: 'Pro', + limitReached: true, + credits: 'Credits: 19.5', + limits: [{ + id: 'codex:0:primary', + label: '5-hour limit', + percent: 95, + reset: 'resets in 1h', + active: true, + }], + }; + const scene = renderCodex({inputs: {model: loadingDoc(ready, emptyCodexDoc())}}); + const encoded = JSON.stringify(scene); + + assert.match(encoded, /Refreshing…/u); + assert.match(encoded, /Refreshing usage limit/u); + assert.doesNotMatch(encoded, /95% used/u); + assert.doesNotMatch(encoded, /Limit reached/u); + assert.doesNotMatch(encoded, /Credits: 19\.5/u); + assert.equal(uniqueKeys(scene), countNodes(scene)); + assert.ok(countNodes(scene) <= 700); +}); + +test('renderers bound hostile restored rows, labels, values, and keys', () => { + const hostileRows = Array.from({length: 1000}, () => ({ + id: 'duplicate\nkey', + label: `private\u0000${'x'.repeat(10000)}`, + percent: Number.POSITIVE_INFINITY, + reset: 'r'.repeat(10000), + })); + const scene = renderClaude({ + inputs: { + model: { + ...emptyClaudeDoc(), + status: 'ready', + limits: hostileRows, }, }, }); - assert.deepEqual( - published.codex, - unavailableCodexProjection( - 'process-unavailable', - '2026-08-04T12:00:00Z', - ), - ); - assert.equal(JSON.stringify(published).includes('owner@example.invalid'), false); + const encoded = JSON.stringify(scene); + + assert.equal(uniqueKeys(scene), countNodes(scene)); + assert.ok(countNodes(scene) < 150); + assert.ok(Buffer.byteLength(encoded, 'utf8') < 262144); + assert.equal((encoded.match(/:limit-/gu) ?? []).length > 0, true); + assert.equal(encoded.includes('duplicate\\nkey'), false); + assert.equal(encoded.includes('x'.repeat(100)), false); + assert.match(encoded, /0% used/u); }); -test('coordinator binds one process resource and routes owner actions', async () => { +test('coordinator opens both local card surfaces and routes exact connector reads', async () => { const handlers = new Map(); const opened = []; - let spawnCount = 0; - const model = {snapshot: createInitialDashboard()}; - const codexBinding = Object.freeze({opaque: 'bound-codex'}); + const requests = []; + const patches = []; + const models = { + claude: {snapshot: emptyClaudeDoc()}, + codex: {snapshot: emptyCodexDoc()}, + }; const ctx = { - resources: { - async bind(slot) { - assert.equal(slot, 'codex'); - return codexBinding; - }, - }, state: { - async open(pool, key) { - assert.equal(pool, 'dashboard'); - assert.equal(key, 'main'); - return model; + async open(pool, document) { + assert.equal(pool, 'usage'); + return models[document]; }, - async patch(_, next) { - model.snapshot = normalizeDashboard(next); + async patch(model, next) { + model.snapshot = next; + patches.push({provider: next.provider, status: next.status}); }, }, actions: { - async handle(id, handler) { - handlers.set(id, handler); + async handle(id, handler) { handlers.set(id, handler); }, + }, + connectors: { + async request(id, request) { + requests.push({id, request}); + return id === 'anthropic' + ? { + ok: true, + status: 200, + receivedAt, + utcOffsetMinutes: 120, + body: {five_hour: {utilization: 20}}, + } + : { + ok: true, + status: 200, + receivedAt, + utcOffsetMinutes: 120, + body: { + rate_limit: { + primary_window: { + used_percent: 30, + limit_window_seconds: 18000, + reset_after_seconds: 3600, + }, + }, + }, + }; }, }, - process: { - async spawn(binding, options) { - assert.equal(binding, codexBinding); - assert.equal(options.executable, 'codex'); - spawnCount++; - throw new Error('not running Codex in a coordinator unit test'); + ui: { + async openSurface(surface) { + opened.push({...surface, statusAtOpen: surface.inputs.model.snapshot.status}); }, }, - ui: {async openSurface(surface) { opened.push(surface); }}, }; const result = await activate(ctx); - assert.deepEqual(result, {surfaceId: 'usage.dashboard'}); - assert.deepEqual(opened.map(({pool, id}) => ({pool, id})), [ - {pool: 'dashboard', id: 'usage.dashboard'}, - ]); - assert.equal(spawnCount, 1, 'activation performs one bounded initial read'); - await handlers.get('ingestClaudeStatus')( + assert.deepEqual(result, {surfaces: ['claude.card', 'codex.card']}); + assert.deepEqual(opened.map(({pool, id, statusAtOpen}) => ({ + pool, + id, + statusAtOpen, + })), [ + {pool: 'claudeCard', id: 'claude.card', statusAtOpen: 'loading'}, + {pool: 'codexCard', id: 'codex.card', statusAtOpen: 'loading'}, + ]); + assert.deepEqual(requests, [ { - session_id: 'must-not-share', - cwd: '/Users/owner/private', - model: {display_name: 'Claude Sonnet'}, - context_window: {used_percentage: 25, context_window_size: 200_000}, + id: 'anthropic', + request: {method: 'GET', path: '/api/oauth/usage'}, }, - ctx, - ); - assert.equal(model.snapshot.claude.model, 'Claude Sonnet'); - assert.equal(JSON.stringify(model.snapshot).includes('must-not-share'), false); - await handlers.get('refreshCodex')({}, ctx); - assert.equal(spawnCount, 2); -}); - -test('a missing Codex grant keeps the Claude action and surface alive', async () => { - const handlers = new Map(); - const opened = []; - const model = {snapshot: createInitialDashboard()}; - const ctx = { - resources: { - async bind() { - throw new Error('missing private process grant'); - }, - }, - state: { - async open() { return model; }, - async patch(_, next) { model.snapshot = normalizeDashboard(next); }, - }, - actions: { - async handle(id, handler) { handlers.set(id, handler); }, - }, - process: { - async spawn() { throw new Error('must not spawn without a binding'); }, + { + id: 'openai', + request: {method: 'GET', path: '/backend-api/wham/usage'}, }, - ui: {async openSurface(surface) { opened.push(surface); }}, - }; - - const result = await activate(ctx); - assert.deepEqual(result, {surfaceId: 'usage.dashboard'}); - assert.equal(opened.length, 1); - assert.equal(model.snapshot.codex.status, 'unavailable'); - assert.equal(model.snapshot.codex.reason, 'process-unavailable'); - assert.equal(handlers.has('ingestClaudeStatus'), true); - - await handlers.get('ingestClaudeStatus')( - {model: {display_name: 'Claude Sonnet'}}, - ctx, - ); - assert.equal(model.snapshot.claude.model, 'Claude Sonnet'); -}); - -test('renderer is finite and cannot reintroduce withheld source data', () => { - const dashboard = normalizeDashboard({ - codex: normalizeCodexAccount( - { - result: { - email: 'render-owner@example.invalid', - planType: 'Plus', - rateLimits: { - primary: {usedPercent: 82, windowDurationMins: 300}, - }, - }, - }, - {result: {lifetimeTokens: 1_234_567}}, - ), - claude: normalizeClaudeStatusline({ - session_id: 'render-secret-session', - model: {display_name: 'Claude Sonnet'}, - context_window: {used_percentage: 42, context_window_size: 200_000}, - cost: {total_cost_usd: 1.2, total_duration_ms: 60_000}, - }), - }); - const scene = render({inputs: {model: dashboard, surface: {width: 720}}}); - const encoded = JSON.stringify(scene); - assert.equal(scene.type, 'box'); - assert.ok(countNodes(scene) <= 420); - assert.match(encoded, /Usage limits/u); - assert.match(encoded, /CODEX/u); - assert.match(encoded, /CLAUDE/u); - assert.equal(encoded.includes('render-owner@example.invalid'), false); - assert.equal(encoded.includes('render-secret-session'), false); -}); + ]); + assert.deepEqual(patches, [ + {provider: 'claude', status: 'loading'}, + {provider: 'codex', status: 'loading'}, + {provider: 'claude', status: 'ready'}, + {provider: 'codex', status: 'ready'}, + ]); + assert.deepEqual([...handlers.keys()].sort(), ['refreshClaude', 'refreshCodex']); -test('JSON-RPC decoder rejects oversized and non-protocol output', () => { - const tooSmall = new BoundedJsonRpcDecoder({maxTotalBytes: 16, maxLineBytes: 16}); - assert.throws( - () => tooSmall.push('{"id":1,"result":{}}\n'), - /budget|large/u, - ); - const malformed = new BoundedJsonRpcDecoder(); - assert.throws(() => malformed.push('{"hello":"world"}\n'), /wire/u); + await handlers.get('refreshClaude')({}, ctx); + assert.equal(requests.at(-1).id, 'anthropic'); }); function countNodes(node) { @@ -532,3 +404,14 @@ function countNodes(node) { ? node.children.reduce((sum, child) => sum + countNodes(child), 0) : 0); } + +function uniqueKeys(root) { + const keys = new Set(); + function visit(node) { + if (node === null || typeof node !== 'object' || Array.isArray(node)) return; + keys.add(node.key); + if (Array.isArray(node.children)) node.children.forEach(visit); + } + visit(root); + return keys.size; +}