fix(plugin): reject expired Claude usage windows - #161
Conversation
puritysb
left a comment
There was a problem hiding this comment.
Thanks — the provider labels on the encoder header are a genuine improvement, and the "an older daemon can mark a frozen payload live" scenario is real. But the expired-window guard conflicts with a policy that already exists in three places, so I can't take it as written.
The repo already has an expired-window policy, with a 1-hour grace
shared/src/format-utils.ts — adjustUsagePercent():
- reset 0–1h in the past → returns 0 (the window rolled over; 0% used is the correct reading)
- reset >1h in the past → returns the percent unchanged
apple/AgentDeck/UI/Common/FormatUtils.swift:15 — formatResetTime(graceSeconds: 3600) hides the countdown chip past the same 1h boundary, and its doc comment explicitly says it mirrors "the adjustUsagePercent stale-window policy (bug_usage_stale_window_zeroed)".
Both daemons run adjustUsagePercent before the value reaches a client — Node at bridge/src/usage-event.ts:193, Swift at DaemonServer.swift:7546. So by the time the plugin sees a Claude percent with an expired resetsAt, the producer has already decided what it means.
This PR adds a third rule, at a fourth layer, with a 5-minute grace and a stronger action (hide the gauge entirely rather than zero it / hide the chip). The 5m–1h band is the damaging one: the producer computed 0 because the user genuinely has a fresh window with nothing spent, and the plugin now suppresses that tile as "no data".
It also folds a freshness signal into a hard signal
Per CLAUDE.md: "A freshness signal must never be folded into a hard signal." _fiveHourKnown = false doesn't dim the tile — it reserves no key at all. Claude's resetsAt is forward-looking, so any interruption in the usage push (session ends, daemon idles, plugin keeps its last payload) walks it into the past on its own, and five minutes later every Claude usage surface disappears. That's the same failure mode the Codex gauges hit on the unflashable boards.
The established shape for "this reading may be old" is dim + show the age, which codexSnapshotAgeMs / codexUsageFootnote already implement.
What I'd take instead
- Split the header change out — the
CLAUDE/CODEXlabels stand on their own and I'll merge that immediately. - If the >1h branch of
adjustUsagePercentis wrong (arguable — "trust a percent whose window ended a day ago" is a weak assumption), fix it at the producer, where the policy already lives, so every surface moves together instead of the Stream Deck diverging from the app, the D200H and the boards. - Whatever the grace ends up being, it needs to stop being three inline literals.
3_600_000informat-utils.ts:152andgraceSeconds: Int = 3600inFormatUtils.swift:15are a hand-mirrored cross-platform constant with no SSOT and no drift gate, which our own convention forbids. Hoisting it toUSAGE_WINDOW_GRACE_MSand emitting it through the existingpnpm generate-codex-freshness-rulespipeline would be a welcome PR on its own.
Minor
export const isCodexWindowStale = isUsageWindowStale is safe for the generator (it only reads CODEX_SNAPSHOT_STALE_MS and codexUsageFootnote), but the rename carries the Codex "the WINDOW has ENDED ⇒ drop the gauge" semantic onto Claude, which is where the behavior change above comes from. If a provider-neutral helper is wanted, please keep the Claude call sites on the dim-and-age path rather than the drop path.
Heads up: shared/src/format-utils.ts and plugin/src/session-slot-manager.ts both have in-flight work locally (Codex plan-mismatch voiding — a third axis where a snapshot minted under a retired plan is void rather than stale). Worth rebasing against that before the next revision so the axes get reconciled in one place.
Three changes this week broke, or nearly broke, rules that were never written down. #163 appended a new kind of row to `sessions_list` — every field-level rule followed, generated mirrors regenerated, Swift guard mirrored — and it would still have corrupted the deck on every shipped 1.0.x client, because nothing says that a new row kind is not an additive change. #161 added a fourth freshness rule at a fourth layer with a different grace constant. And closing #145 needed a probe from a second host, which nothing recorded either. Add docs/wire-compatibility.md as the contract for the daemon↔client surface: who is downstream and which of them we cannot update, what the X.Y version rule does and does not promise, the change classes (safe / breaking-though-additive), the retain-on-absent merge rule and its optional-boolean corollary, the three freshness axes, and the two supported routes for a genuinely breaking change (capability negotiation via `client_register`, or a new event type — verified that unknown `type` values are dropped by shipped parsers). Auth is deliberately NOT restated. docs/daemon.md's LAN security model already covers it and is already cataloged; the contract links to it and carries only the three invariants that constrain message shape. What daemon.md was missing is how to verify the boundary: you cannot do it from the daemon's own machine, because `isLocalConnection` trusts every address on this host's interfaces, so curling your own LAN IP returns the full token-bearing payload and that is correct. Record the second-host procedure, and the two traps in reading it (a 101 upgrade is not a failure — the close code is 4001; several e-ink Android images have no curl). Gate it: docs-wire-contract.test.ts asserts the documented public `/health` body equals `buildPublicHealth`, that it carries no credential/inventory/state field, and that `/health` is the only route an unauthorized peer reaches. Verified the gate fails in both drift directions — reintroducing `pairingToken` into the doc sample fails two cases, opening a second unauthenticated route fails a third. Cataloged as spec.wire-compatibility. docs:check, design-system:check (28 documents) and the security suite all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Superseded by #170, which contains only the Claude/Codex encoder-label change requested in review. The expired-window behavior and unrelated usage/session changes were intentionally not carried forward. |
Summary
This portable change intentionally excludes any machine-local relay or fallback endpoint.
Verification