thinkpool-pair 0.7.184 → 0.7.186
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bridge.mjs +46 -12
- package/event-id.mjs +37 -1
- package/package.json +2 -1
- package/plan-meters.mjs +144 -0
package/bridge.mjs
CHANGED
|
@@ -95,7 +95,8 @@ const flowBudgets = new Map()
|
|
|
95
95
|
import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow } from './cross-terminal.mjs'
|
|
96
96
|
import { turnInFlight } from './update-gate.mjs'
|
|
97
97
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq } from './session-store.mjs'
|
|
98
|
-
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, buildRecapFromLog, RECAP_CAP } from './event-id.mjs'
|
|
98
|
+
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
99
|
+
import { planMeterLine } from './plan-meters.mjs'
|
|
99
100
|
import { makeThrottledTrack } from './presence.mjs'
|
|
100
101
|
|
|
101
102
|
// Public client creds (the same anon values the web app ships — safe to embed).
|
|
@@ -928,10 +929,23 @@ const defendFrame = (event, payload) => {
|
|
|
928
929
|
if (Array.isArray(p.events)) {
|
|
929
930
|
p.events = p.events.map((e) => boundEventForBroadcast(e, 100000))
|
|
930
931
|
// …and if a frame is STILL over the limit, drop the OLDEST events until it fits —
|
|
931
|
-
// keep the newest so the client's seqHi still advances to current.
|
|
932
|
-
//
|
|
933
|
-
|
|
934
|
-
|
|
932
|
+
// keep the newest so the client's seqHi still advances to current.
|
|
933
|
+
//
|
|
934
|
+
// C-RG1 (2026-07-10): STAMP what we dropped. This used to end with "classifyReplay
|
|
935
|
+
// accepts the resulting seq jump; older events are covered by the DB transcript
|
|
936
|
+
// snapshot" — both halves were wrong. classifyReplay accepting the jump IS the bug
|
|
937
|
+
// (the client leapt over a hole it couldn't see, and its "load earlier" gate only
|
|
938
|
+
// detects a LEADING gap, never a middle one). And the tx: snapshot never covered
|
|
939
|
+
// this; the durable JSONL archive does. So say what we trimmed and let the client
|
|
940
|
+
// fetch it back. Events dropped here were about to be broadcast, so by construction
|
|
941
|
+
// they're in the archive — the stamp is always recoverable. Raise, never lower: the
|
|
942
|
+
// send site may have already stamped a chunk-cap trim below this one.
|
|
943
|
+
let guard = 0, dropped = 0
|
|
944
|
+
while (p.events.length > 1 && JSON.stringify(p).length > FRAME_SOFT_LIMIT && guard++ < 10000) { p.events.shift(); dropped++ }
|
|
945
|
+
if (dropped) {
|
|
946
|
+
const lo = firstSeq(p.events)
|
|
947
|
+
if (typeof lo === 'number') p.trimmedBefore = Math.max(p.trimmedBefore ?? 0, lo)
|
|
948
|
+
}
|
|
935
949
|
}
|
|
936
950
|
return p
|
|
937
951
|
}
|
|
@@ -2701,17 +2715,30 @@ channel
|
|
|
2701
2715
|
// Chunk the tail into ORDERED under-budget frames (A): the full log on a fresh reload
|
|
2702
2716
|
// (empty cursor) is multi-MB and a single frame would be dropped by Realtime, freezing
|
|
2703
2717
|
// the terminal. chunkReplayEvents splits it so nothing in the recent window is lost;
|
|
2704
|
-
// classifyReplay applies the chunks in arrival order.
|
|
2705
|
-
//
|
|
2718
|
+
// classifyReplay applies the chunks in arrival order.
|
|
2719
|
+
//
|
|
2720
|
+
// C-RG1 (2026-07-10): chunkReplayEvents keeps only the newest REPLAY_MAX_CHUNKS —
|
|
2721
|
+
// a fat lane (measured: 1.7MB / 12 chunks) drops its OLDEST chunks. That hole used
|
|
2722
|
+
// to travel unannounced; the client absorbed the seq jump and could never fetch the
|
|
2723
|
+
// missing stretch back (its "load earlier" gate only sees a LEADING gap). Tell it.
|
|
2724
|
+
// `cursor` is what the client holds — an `ahead` reset replay is a full rebuild, so
|
|
2725
|
+
// it holds nothing (0). The floor keeps a fresh lane whose first turn lands at seq>1
|
|
2726
|
+
// from being stamped (the 2026-07-02 phantom-pill regression).
|
|
2727
|
+
const chunks = chunkReplayEvents(tail)
|
|
2728
|
+
const cursor = ahead ? 0 : from
|
|
2729
|
+
const trimmedBefore = trimmedBeforeSeq(firstSeq(chunks[0] || []), cursor, s.archiveOldestSeq)
|
|
2706
2730
|
// Attach the terminal's last usage/ctx meter to the FIRST replay chunk. usage is
|
|
2707
2731
|
// chrome (filtered out of `events` by both bridge + client), so without this a
|
|
2708
2732
|
// (re)joiner's ctx meter is empty ("—") until the terminal's next turn. The client
|
|
2709
2733
|
// applies `payload.lastUsage` in its code-replay handler (which is `to`-targeted, so
|
|
2710
2734
|
// only the joiner gets it). Gated on `.ctx` — a suppressed meter (correctContext
|
|
2711
2735
|
// nulled it for an unknown non-Claude model) replays nothing, never a misleading %.
|
|
2736
|
+
// trimmedBefore rides the FIRST (oldest) chunk only — the later chunks are contiguous
|
|
2737
|
+
// with it, so a stamp there would read as a second, phantom gap. Same rule as
|
|
2738
|
+
// lastUsage above and hasMore on history-page below.
|
|
2712
2739
|
let firstChunk = true
|
|
2713
|
-
for (const events of
|
|
2714
|
-
bcast('code-replay', { to: payload?.to ?? null, term: id, events, ...(ahead ? { reset: true } : {}), ...(firstChunk && s.lastUsage?.ctx ? { lastUsage: s.lastUsage } : {}) })
|
|
2740
|
+
for (const events of chunks) {
|
|
2741
|
+
bcast('code-replay', { to: payload?.to ?? null, term: id, events, ...(ahead ? { reset: true } : {}), ...(firstChunk && trimmedBefore != null ? { trimmedBefore } : {}), ...(firstChunk && s.lastUsage?.ctx ? { lastUsage: s.lastUsage } : {}) })
|
|
2715
2742
|
firstChunk = false
|
|
2716
2743
|
}
|
|
2717
2744
|
}
|
|
@@ -2848,9 +2875,16 @@ channel
|
|
|
2848
2875
|
// Computed by REPLAYING the session's own retained result events (each completed turn
|
|
2849
2876
|
// persists kind:'result' with usage + costUsd + model — see onEvent). No new accumulator
|
|
2850
2877
|
// state: the log is durable, so this survives a bridge restart for free. Reported as ◆
|
|
2851
|
-
// control lines (like /model) so both members AND late-joiners see it.
|
|
2852
|
-
//
|
|
2853
|
-
|
|
2878
|
+
// control lines (like /model) so both members AND late-joiners see it. Two lines: the
|
|
2879
|
+
// token/cost report (instant, from the retained log), then the plan meters (5h + weekly
|
|
2880
|
+
// utilization + server-authoritative resets) once the OAuth usage endpoint answers. The
|
|
2881
|
+
// meter line is fired-and-forgotten so a slow/unreachable endpoint never stalls /usage;
|
|
2882
|
+
// it degrades to "plan meters unavailable" rather than printing an inferred reset.
|
|
2883
|
+
if (/^\/usage\s*$/.test(text)) {
|
|
2884
|
+
ctlLine(usageReportLine(s.model, s.log))
|
|
2885
|
+
planMeterLine().then((l) => { if (l) ctlLine(l) }).catch(() => { /* meters are never load-bearing */ })
|
|
2886
|
+
return
|
|
2887
|
+
}
|
|
2854
2888
|
// Context-carry (2026-07-08) point 4: a real human turn arrived BEFORE the post-switch/
|
|
2855
2889
|
// restore recap fired on init-`system`. Prepend the recap to THIS turn (one turn, not two
|
|
2856
2890
|
// — a separate recap turn would race + double-fire) and clear the flag so the init handler
|
package/event-id.mjs
CHANGED
|
@@ -139,6 +139,39 @@ export function chunkReplayEvents(events, budget = REPLAY_BYTE_BUDGET, maxChunks
|
|
|
139
139
|
return chunksRev.reverse().map((c) => c.reverse())
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// ── C-RG1/C-RG4 (2026-07-10): the bridge stamps what it trimmed. ──────────────
|
|
143
|
+
// chunkReplayEvents keeps only the newest maxChunks — on a fat lane the OLDEST chunks
|
|
144
|
+
// are dropped. Until now that hole was unannounced: classifyReplay accepted the seq
|
|
145
|
+
// jump, seqHi leapt past it, and because the client's "load earlier" gate compares its
|
|
146
|
+
// OLDEST loaded seq against the archive floor, a hole in the MIDDLE (client holds
|
|
147
|
+
// 1..50 ∪ 900..2000) never surfaced a pill and was unrecoverable — silently, forever.
|
|
148
|
+
//
|
|
149
|
+
// The bridge is the only party that knows. Return the lowest seq we are ACTUALLY
|
|
150
|
+
// sending when events below it were withheld AND are recoverable from the durable
|
|
151
|
+
// JSONL archive; null when nothing was withheld.
|
|
152
|
+
//
|
|
153
|
+
// Floor-aware on purpose. The seq counter is per-SESSION, not per-terminal, so a fresh
|
|
154
|
+
// lane's first turn legitimately lands at seq > 1 — stamping that would resurrect the
|
|
155
|
+
// 2026-07-02 phantom "load earlier" pill (a button that fetches nothing). `cursor` is
|
|
156
|
+
// what the client already holds (0 = nothing, and an `ahead` cursor-reset replay must
|
|
157
|
+
// pass 0). `archiveOldestSeq` is the durable floor (bridge.mjs entry.archiveOldestSeq).
|
|
158
|
+
// Anything at or below max(cursor, floor-1) is not missing: either the client has it,
|
|
159
|
+
// or the archive never had it and no affordance could ever fetch it.
|
|
160
|
+
export function trimmedBeforeSeq(sentFromSeq, cursor = 0, archiveOldestSeq = null) {
|
|
161
|
+
if (typeof sentFromSeq !== 'number') return null
|
|
162
|
+
const floor = typeof archiveOldestSeq === 'number' ? archiveOldestSeq : 1
|
|
163
|
+
const known = Math.max(cursor || 0, floor - 1)
|
|
164
|
+
return sentFromSeq > known + 1 ? sentFromSeq : null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The lowest transcript seq in an event list (nulls sort out). Chrome events are
|
|
168
|
+
// seq-less (NO_SEQ_KINDS) and must not anchor the stamp.
|
|
169
|
+
export function firstSeq(events) {
|
|
170
|
+
if (!Array.isArray(events)) return null
|
|
171
|
+
for (const e of events) if (e && typeof e.seq === 'number') return e.seq
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
|
|
142
175
|
// Bound a SINGLE event so it can never overflow a broadcast frame on its own. The worst
|
|
143
176
|
// offenders are tool_results carrying inline base64 IMAGES (a 592KB screenshot block) —
|
|
144
177
|
// these can't ride a size-capped Realtime broadcast at all (live OR replay). Truncate
|
|
@@ -264,7 +297,10 @@ export function usageReportLine (sessionModel, logEvents) {
|
|
|
264
297
|
const total = rows.length > 1 ? ` · total ${fmtTok(sum.totalIn)} in / ${fmtTok(sum.totalOut)} out` : ''
|
|
265
298
|
const cost = sum.haveCost ? ` · ~$${sum.totalCost.toFixed(2)} est` : ''
|
|
266
299
|
const turns = `${sum.turns} turn${sum.turns === 1 ? '' : 's'}`
|
|
267
|
-
|
|
300
|
+
// No "(no plan/rate-limit meters)" tail any more — the handler appends a second control
|
|
301
|
+
// line from bridge/plan-meters.mjs carrying the real 5h/weekly utilization + resets, or
|
|
302
|
+
// an explicit "plan meters unavailable". Neither claim belongs on this token/cost line.
|
|
303
|
+
return `usage · ${turns} · ${perStr}${total}${cost}`
|
|
268
304
|
}
|
|
269
305
|
|
|
270
306
|
/* The context-carry recap moved to ./recap.mjs (ADR-0010 / S4, 2026-07-09).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.186",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"update-gate.mjs",
|
|
18
18
|
"agent-notify.mjs",
|
|
19
19
|
"event-id.mjs",
|
|
20
|
+
"plan-meters.mjs",
|
|
20
21
|
"recap.mjs",
|
|
21
22
|
"transcript-sanitize.mjs",
|
|
22
23
|
"session-store.mjs",
|
package/plan-meters.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/* Plan / rate-limit meters for the /usage report.
|
|
2
|
+
*
|
|
3
|
+
* WHERE THE NUMBERS COME FROM. `GET https://api.anthropic.com/api/oauth/usage`
|
|
4
|
+
* with the Claude Code OAuth access token — the SAME endpoint the CLI's own /usage
|
|
5
|
+
* screen reads. So the percentages and, critically, the RESET TIMES are the
|
|
6
|
+
* server's, not ours. We never infer a 5-hour block boundary from transcript
|
|
7
|
+
* timestamps (the ccusage approach): an inferred reset is a number that looks
|
|
8
|
+
* authoritative and is wrong, which is exactly the bug this module exists to avoid.
|
|
9
|
+
*
|
|
10
|
+
* READ-ONLY, AND DELIBERATELY SO. We read the access token and we never refresh it.
|
|
11
|
+
* Refreshing rotates the refresh token; two processes rotating against one credential
|
|
12
|
+
* store is how the bridge's login died before (memory: thinkpool_bridge_login_headless_recovery).
|
|
13
|
+
* An expired token therefore degrades to "plan meters unavailable" rather than
|
|
14
|
+
* quietly re-authenticating behind the user's back.
|
|
15
|
+
*
|
|
16
|
+
* DEGRADE HONESTLY. Every failure — no token, non-Claude provider, network error,
|
|
17
|
+
* 401, shape change, a reset timestamp in the past — returns null, and the caller
|
|
18
|
+
* prints "plan meters unavailable". A stale or invented meter is worse than no meter.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from 'node:child_process'
|
|
22
|
+
import { readFileSync } from 'node:fs'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
|
|
26
|
+
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
|
|
27
|
+
const CACHE_MS = 45_000
|
|
28
|
+
const FETCH_TIMEOUT_MS = 4_000
|
|
29
|
+
|
|
30
|
+
// ── token ────────────────────────────────────────────────────────────────────
|
|
31
|
+
// macOS keeps Claude Code's credentials in the login keychain; Linux writes
|
|
32
|
+
// ~/.claude/.credentials.json. Same JSON shape either way: { claudeAiOauth: { accessToken } }.
|
|
33
|
+
// Any failure here is a normal, expected state (BYOK lane, API-key auth, logged out).
|
|
34
|
+
export function readOAuthToken (deps = {}) {
|
|
35
|
+
const {
|
|
36
|
+
platform = process.platform,
|
|
37
|
+
env = process.env,
|
|
38
|
+
exec = execFileSync,
|
|
39
|
+
read = readFileSync,
|
|
40
|
+
home = homedir()
|
|
41
|
+
} = deps
|
|
42
|
+
if (env.THINKPOOL_OAUTH_TOKEN) return env.THINKPOOL_OAUTH_TOKEN // test/override seam
|
|
43
|
+
let raw = null
|
|
44
|
+
try {
|
|
45
|
+
if (platform === 'darwin') {
|
|
46
|
+
raw = exec('security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
|
|
47
|
+
{ encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] })
|
|
48
|
+
} else {
|
|
49
|
+
raw = read(join(home, '.claude', '.credentials.json'), 'utf8')
|
|
50
|
+
}
|
|
51
|
+
const tok = JSON.parse(raw)?.claudeAiOauth?.accessToken
|
|
52
|
+
return (typeof tok === 'string' && tok.startsWith('sk-ant-oat')) ? tok : null
|
|
53
|
+
} catch { return null }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── fetch ────────────────────────────────────────────────────────────────────
|
|
57
|
+
export async function fetchPlanUsage (token, fetchImpl = globalThis.fetch) {
|
|
58
|
+
if (!token) return null
|
|
59
|
+
const ac = new AbortController()
|
|
60
|
+
const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS)
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetchImpl(USAGE_URL, {
|
|
63
|
+
method: 'GET',
|
|
64
|
+
headers: { authorization: `Bearer ${token}`, 'anthropic-beta': 'oauth-2025-04-20' },
|
|
65
|
+
signal: ac.signal
|
|
66
|
+
})
|
|
67
|
+
if (!res.ok) return null // 401 (expired) / 403 / 5xx → unavailable, never a guess
|
|
68
|
+
return await res.json()
|
|
69
|
+
} catch { return null } finally { clearTimeout(timer) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── format (pure) ────────────────────────────────────────────────────────────
|
|
73
|
+
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
74
|
+
|
|
75
|
+
// A reset renders in LOCAL time. Beyond ~20h out (the weekly meter) a bare HH:MM is
|
|
76
|
+
// ambiguous, so it carries a weekday. Returns null for an unparseable timestamp OR one
|
|
77
|
+
// already in the past — a past reset means the payload is stale, and printing "resets
|
|
78
|
+
// 10:10" when 10:10 has been and gone is precisely the lie we refuse to tell.
|
|
79
|
+
export function fmtReset (iso, now) {
|
|
80
|
+
const t = new Date(iso).getTime()
|
|
81
|
+
if (!Number.isFinite(t) || t <= now.getTime()) return null
|
|
82
|
+
// ROUND to the nearest minute, don't floor. The endpoint stamps `resets_at` with
|
|
83
|
+
// sub-second jitter around the true boundary — two calls seconds apart returned
|
|
84
|
+
// 10:10:00.355Z and 10:09:59.873Z for the same reset. Flooring renders those as
|
|
85
|
+
// 12:10 and 12:09, so the meter visibly flickers a minute back and forth on a
|
|
86
|
+
// surface whose whole job is to be the trustworthy number. Observed 2026-07-10.
|
|
87
|
+
const d = new Date(Math.round(t / 60000) * 60000)
|
|
88
|
+
const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
89
|
+
return (t - now.getTime()) > 20 * 3600 * 1000 ? `${DAYS[d.getDay()]} ${hhmm}` : hhmm
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fmtIn = (ms) => {
|
|
93
|
+
const m = Math.max(0, Math.round(ms / 60000))
|
|
94
|
+
return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m` : `${m}m`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Render one block ("5h" / "week") or null if it can't be stated truthfully.
|
|
98
|
+
function block (label, node, now, withRelative) {
|
|
99
|
+
const pct = node && typeof node.utilization === 'number' ? Math.round(node.utilization) : null
|
|
100
|
+
if (pct == null) return null
|
|
101
|
+
const at = fmtReset(node.resets_at, now)
|
|
102
|
+
if (!at) return `${label}: ${pct}%` // percent is still true even if the reset isn't usable
|
|
103
|
+
const rel = withRelative ? ` (${fmtIn(new Date(node.resets_at).getTime() - now.getTime())})` : ''
|
|
104
|
+
return `${label}: ${pct}% · resets ${at}${rel}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// json → "plan · 5h: 10% · resets 12:10 (1h04m) · week: 26% · resets Tue 11:00"
|
|
108
|
+
// Returns null when nothing truthful can be said, so the caller prints the honest
|
|
109
|
+
// "plan meters unavailable" instead of a half-line.
|
|
110
|
+
export function formatPlanMeters (json, now = new Date()) {
|
|
111
|
+
if (!json || typeof json !== 'object') return null
|
|
112
|
+
const parts = [block('5h', json.five_hour, now, true), block('week', json.seven_day, now, false)]
|
|
113
|
+
// A scoped weekly cap (e.g. a per-model ceiling) only matters while it's the binding
|
|
114
|
+
// one — surface it, named, so a lane that's about to hit a model-specific wall sees it.
|
|
115
|
+
const scoped = Array.isArray(json.limits)
|
|
116
|
+
? json.limits.find((l) => l && l.is_active && l.kind === 'weekly_scoped' && typeof l.percent === 'number')
|
|
117
|
+
: null
|
|
118
|
+
if (scoped) {
|
|
119
|
+
const name = scoped.scope?.model?.display_name
|
|
120
|
+
parts.push(`${name ? `week ${name}` : 'week scoped'}: ${Math.round(scoped.percent)}%`)
|
|
121
|
+
}
|
|
122
|
+
const live = parts.filter(Boolean)
|
|
123
|
+
return live.length ? `plan · ${live.join(' · ')}` : null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── cached entry point ───────────────────────────────────────────────────────
|
|
127
|
+
let cache = { at: 0, line: null }
|
|
128
|
+
|
|
129
|
+
// The ONE line bridge.mjs appends under the token/cost line. Cached ~45s so a user
|
|
130
|
+
// spamming /usage doesn't hammer the endpoint. Never throws.
|
|
131
|
+
export async function planMeterLine (deps = {}) {
|
|
132
|
+
const { now = () => Date.now(), clock = () => new Date(), token = readOAuthToken, fetchUsage = fetchPlanUsage } = deps
|
|
133
|
+
const t = now()
|
|
134
|
+
if (cache.line !== null && (t - cache.at) < CACHE_MS) return cache.line
|
|
135
|
+
let line = 'plan meters unavailable'
|
|
136
|
+
try {
|
|
137
|
+
const json = await fetchUsage(token())
|
|
138
|
+
line = formatPlanMeters(json, clock()) || 'plan meters unavailable'
|
|
139
|
+
} catch { /* stays unavailable */ }
|
|
140
|
+
cache = { at: t, line }
|
|
141
|
+
return line
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function _resetCache () { cache = { at: 0, line: null } } // test seam
|