thinkpool-pair 0.7.184 → 0.7.185

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 CHANGED
@@ -96,6 +96,7 @@ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSP
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
98
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, buildRecapFromLog, RECAP_CAP } 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).
@@ -2848,9 +2849,16 @@ channel
2848
2849
  // Computed by REPLAYING the session's own retained result events (each completed turn
2849
2850
  // persists kind:'result' with usage + costUsd + model — see onEvent). No new accumulator
2850
2851
  // 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. Scope is honest —
2852
- // the Agent SDK exposes tokens + cost, not plan/rate-limit meters, and the card says so.
2853
- if (/^\/usage\s*$/.test(text)) { ctlLine(usageReportLine(s.model, s.log)); return }
2852
+ // control lines (like /model) so both members AND late-joiners see it. Two lines: the
2853
+ // token/cost report (instant, from the retained log), then the plan meters (5h + weekly
2854
+ // utilization + server-authoritative resets) once the OAuth usage endpoint answers. The
2855
+ // meter line is fired-and-forgotten so a slow/unreachable endpoint never stalls /usage;
2856
+ // it degrades to "plan meters unavailable" rather than printing an inferred reset.
2857
+ if (/^\/usage\s*$/.test(text)) {
2858
+ ctlLine(usageReportLine(s.model, s.log))
2859
+ planMeterLine().then((l) => { if (l) ctlLine(l) }).catch(() => { /* meters are never load-bearing */ })
2860
+ return
2861
+ }
2854
2862
  // Context-carry (2026-07-08) point 4: a real human turn arrived BEFORE the post-switch/
2855
2863
  // restore recap fired on init-`system`. Prepend the recap to THIS turn (one turn, not two
2856
2864
  // — a separate recap turn would race + double-fire) and clear the flag so the init handler
package/event-id.mjs CHANGED
@@ -264,7 +264,10 @@ export function usageReportLine (sessionModel, logEvents) {
264
264
  const total = rows.length > 1 ? ` · total ${fmtTok(sum.totalIn)} in / ${fmtTok(sum.totalOut)} out` : ''
265
265
  const cost = sum.haveCost ? ` · ~$${sum.totalCost.toFixed(2)} est` : ''
266
266
  const turns = `${sum.turns} turn${sum.turns === 1 ? '' : 's'}`
267
- return `usage · ${turns} · ${perStr}${total}${cost} · tokens + cost only (no plan/rate-limit meters)`
267
+ // No "(no plan/rate-limit meters)" tail any more — the handler appends a second control
268
+ // line from bridge/plan-meters.mjs carrying the real 5h/weekly utilization + resets, or
269
+ // an explicit "plan meters unavailable". Neither claim belongs on this token/cost line.
270
+ return `usage · ${turns} · ${perStr}${total}${cost}`
268
271
  }
269
272
 
270
273
  /* 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.184",
3
+ "version": "0.7.185",
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",
@@ -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