Skip to content

fix(plugin): reject expired Claude usage windows - #161

Closed
jcarberator wants to merge 1 commit into
puritysb:masterfrom
jcarberator:fix/streamdeck-usage-freshness
Closed

fix(plugin): reject expired Claude usage windows#161
jcarberator wants to merge 1 commit into
puritysb:masterfrom
jcarberator:fix/streamdeck-usage-freshness

Conversation

@jcarberator

Copy link
Copy Markdown
Contributor

Summary

  • Treat a usage window whose reset time has passed as stale, even when an older daemon still marks the payload live.
  • Hide expired Claude usage tiles on Stream Deck keys and encoders.
  • Add explicit Claude and Codex provider labels so low percentages cannot be confused across providers.
  • Generalize the freshness helper while preserving its existing Codex-compatible export.

This portable change intentionally excludes any machine-local relay or fallback endpoint.

Verification

  • Focused usage and renderer tests: 162 passed.
  • Full workspace suite: 2,760 passed, 1 skipped.
  • Workspace build passed.

@puritysb puritysb left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.tsadjustUsagePercent():

  • 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:15formatResetTime(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

  1. Split the header change out — the CLAUDE/CODEX labels stand on their own and I'll merge that immediately.
  2. If the >1h branch of adjustUsagePercent is 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.
  3. Whatever the grace ends up being, it needs to stop being three inline literals. 3_600_000 in format-utils.ts:152 and graceSeconds: Int = 3600 in FormatUtils.swift:15 are a hand-mirrored cross-platform constant with no SSOT and no drift gate, which our own convention forbids. Hoisting it to USAGE_WINDOW_GRACE_MS and emitting it through the existing pnpm generate-codex-freshness-rules pipeline 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.

puritysb added a commit that referenced this pull request Aug 9, 2026
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>
@jcarberator

Copy link
Copy Markdown
Contributor Author

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.

@jcarberator jcarberator closed this Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants