thinkpool-pair 0.7.183 → 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 +34 -12
- package/claude-session.mjs +23 -4
- package/event-id.mjs +19 -5
- package/package.json +2 -1
- package/plan-meters.mjs +144 -0
- package/turn-stall.mjs +19 -1
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).
|
|
@@ -2282,18 +2283,32 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
|
|
|
2282
2283
|
// 'suggestion' is Claude Code's predicted-next-prompt ghost text — ephemeral
|
|
2283
2284
|
// composer UI, broadcast to both clients but never logged/persisted/replayed.
|
|
2284
2285
|
const chrome = evt.kind === 'mode' || evt.kind === 'usage' || evt.kind === 'clear' || evt.kind === 'compact' || evt.kind === 'stalled' || evt.kind === 'suggestion'
|
|
2285
|
-
// compact turn
|
|
2286
|
-
//
|
|
2287
|
-
//
|
|
2288
|
-
//
|
|
2289
|
-
//
|
|
2286
|
+
// The compact turn settled with `entry.compacting` STILL TRUE — which, because the
|
|
2287
|
+
// `compaction` branch above clears the flag the moment the milestone lands, means
|
|
2288
|
+
// exactly one thing: NO compaction happened. Three ways to get here, all of which
|
|
2289
|
+
// used to end in silence (the indicator blinked out and the context was unchanged,
|
|
2290
|
+
// so "did that work?" was unanswerable — Max, 2026-07-10):
|
|
2291
|
+
// • aborted — Stop, a model switch, or (until turn-stall.mjs) the stall watchdog
|
|
2292
|
+
// killing the long silent compaction call outright.
|
|
2293
|
+
// • no-op — the SDK declines: "Not enough messages to compact."
|
|
2294
|
+
// • error — the compaction call itself failed.
|
|
2295
|
+
// `/compact` writes no `you` line (the code-turn handler returns before echoYou), so
|
|
2296
|
+
// without this the room has no record the command was ever run. Say what happened,
|
|
2297
|
+
// once, durably — same control-event shape as ctlLine.
|
|
2290
2298
|
if ((evt.kind === 'result' || evt.kind === 'error') && entry.compacting) {
|
|
2299
|
+
const by = entry.compactBy || null
|
|
2291
2300
|
entry.compacting = false
|
|
2292
|
-
// Clear the ephemeral "Compacting…" indicator. The persistent record is the
|
|
2293
|
-
// CompactionCard (kind:'compaction', now attributed) — no redundant control line
|
|
2294
|
-
// that lingers in scrollback reading as "compacted recently" (Max, 2026-07-02).
|
|
2295
|
-
bcast('code-event', { term: id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
2296
2301
|
entry.compactBy = null
|
|
2302
|
+
// Clear the ephemeral "Compacting…" indicator. On the SUCCESS path the persistent
|
|
2303
|
+
// record is the CompactionCard (kind:'compaction', attributed) — no redundant control
|
|
2304
|
+
// line that reads as "compacted recently" in scrollback (Max, 2026-07-02).
|
|
2305
|
+
bcast('code-event', { term: id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
2306
|
+
const why = evt.kind === 'error' ? 'compaction failed'
|
|
2307
|
+
: evt.subtype === 'aborted' ? 'compaction canceled'
|
|
2308
|
+
: 'nothing to compact'
|
|
2309
|
+
const ce = { kind: 'control', text: `${why} — context unchanged`, by }
|
|
2310
|
+
pushLog(entry, ce)
|
|
2311
|
+
bcast('code-event', { term: id, evt: ce })
|
|
2297
2312
|
}
|
|
2298
2313
|
// ── Slice 3: the turn settled. Two things happen, in this order.
|
|
2299
2314
|
// 1. Every outstanding permission card on this lane is now dead (the SDK
|
|
@@ -2834,9 +2849,16 @@ channel
|
|
|
2834
2849
|
// Computed by REPLAYING the session's own retained result events (each completed turn
|
|
2835
2850
|
// persists kind:'result' with usage + costUsd + model — see onEvent). No new accumulator
|
|
2836
2851
|
// state: the log is durable, so this survives a bridge restart for free. Reported as ◆
|
|
2837
|
-
// control lines (like /model) so both members AND late-joiners see it.
|
|
2838
|
-
//
|
|
2839
|
-
|
|
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
|
+
}
|
|
2840
2862
|
// Context-carry (2026-07-08) point 4: a real human turn arrived BEFORE the post-switch/
|
|
2841
2863
|
// restore recap fired on init-`system`. Prepend the recap to THIS turn (one turn, not two
|
|
2842
2864
|
// — a separate recap turn would race + double-fire) and clear the flag so the init handler
|
package/claude-session.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { sanitizeSession } from './transcript-sanitize.mjs'
|
|
|
21
21
|
import { reviewGatePreToolDecision } from './flow-review-gate.mjs'
|
|
22
22
|
import { crossPostNeedsCard } from './cross-terminal.mjs'
|
|
23
23
|
import { correctContext } from './context-windows.mjs'
|
|
24
|
-
import { stallDecision, stallEvent } from './turn-stall.mjs'
|
|
24
|
+
import { stallDecision, stallEvent, isCompactTurn } from './turn-stall.mjs'
|
|
25
25
|
|
|
26
26
|
// The caret-pulled SDK's real version (^0.3.x auto-upgrades on restart). Resolved
|
|
27
27
|
// once at import by walking up from the package entry to its own package.json.
|
|
@@ -312,6 +312,13 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
312
312
|
const FORCE_STOP_MS = Math.max(STALL_MS * 3, parseInt(process.env.TP_FORCE_STOP_MS, 10) || 300000)
|
|
313
313
|
let forceStopped = false
|
|
314
314
|
let stallRetried = false // one auto-retry per turn (the abort+re-run path below)
|
|
315
|
+
// A manual /compact is one long, LEGITIMATELY silent model call — not a wedge. The
|
|
316
|
+
// watchdog stands down for its whole duration (stallDecision's `compacting` branch);
|
|
317
|
+
// only a compaction quiet for COMPACT_FORCE_STOP_MS (20 min default) is force-stopped,
|
|
318
|
+
// and never by aborting the query. Set in sendTurn, cleared on compact_boundary (the
|
|
319
|
+
// compaction landed) and on any result/error (the turn settled, incl. a no-op compact).
|
|
320
|
+
const COMPACT_FORCE_STOP_MS = Math.max(FORCE_STOP_MS, parseInt(process.env.TP_COMPACT_FORCE_STOP_MS, 10) || 1200000)
|
|
321
|
+
let compacting = false
|
|
315
322
|
// Waiting on a HUMAN decision (permission card / plan gate / AskUserQuestion)
|
|
316
323
|
// is NOT a wedge — the turn is correctly idle until the person answers. Every
|
|
317
324
|
// interactive gate goes through requestPermission, so wrap it once to bump a
|
|
@@ -360,10 +367,13 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
360
367
|
// the last real SDK event). emit() is the normal path: it stamps liveness + clears the
|
|
361
368
|
// one-shot stall flag on any real event other than a `stalled` re-emit.
|
|
362
369
|
const emitRaw = (evt) => { try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
|
|
363
|
-
|
|
370
|
+
// The compaction window closes on its milestone (`compaction`) or on the turn settling
|
|
371
|
+
// (`result`/`error` — covers a no-op "Not enough messages to compact" and a Stop). After
|
|
372
|
+
// that the normal wedge timeline applies again.
|
|
373
|
+
const emit = (evt) => { lastEvtTs = Date.now(); if (evt && evt.kind !== 'stalled') stalledSent = false; if (evt && (evt.kind === 'compaction' || evt.kind === 'result' || evt.kind === 'error')) compacting = false; emitRaw(evt) }
|
|
364
374
|
const stallTimer = setInterval(() => {
|
|
365
375
|
const quiet = Date.now() - lastEvtTs
|
|
366
|
-
const action = stallDecision({ turnActive, awaitingUser, quietMs: quiet, stallMs: STALL_MS, forceStopMs: FORCE_STOP_MS, stalledSent, stallRetried })
|
|
376
|
+
const action = stallDecision({ turnActive, awaitingUser, quietMs: quiet, stallMs: STALL_MS, forceStopMs: FORCE_STOP_MS, stalledSent, stallRetried, compacting, compactForceStopMs: COMPACT_FORCE_STOP_MS })
|
|
367
377
|
if (action === 'none') return
|
|
368
378
|
// ALWAYS surface — a stall is never swallowed. emitRaw so this synthetic event
|
|
369
379
|
// doesn't reset the silence clock (see emitRaw above).
|
|
@@ -1110,7 +1120,11 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
1110
1120
|
if (closed) return
|
|
1111
1121
|
stallRetried = true
|
|
1112
1122
|
const wasActive = turnActive
|
|
1113
|
-
|
|
1123
|
+
// NEVER replay a `/compact`. The abort below is what cancels an in-flight compaction, and
|
|
1124
|
+
// re-pushing the command just feeds the next abort. stallDecision already stands down while
|
|
1125
|
+
// `compacting` is armed, so this is the belt for the window where the flag was cleared by a
|
|
1126
|
+
// mid-compaction error but lastTurnText is still the command.
|
|
1127
|
+
const replay = wasActive && !isCompactTurn(lastTurnText) ? lastTurnText : null
|
|
1114
1128
|
turnActive = false // unblock between-turns updates while we tear down
|
|
1115
1129
|
const dying = qDone
|
|
1116
1130
|
const oldInput = input
|
|
@@ -1136,6 +1150,11 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
|
|
|
1136
1150
|
// is queue-backed, so the pushed turn buffers and runs once the query is ready.
|
|
1137
1151
|
sendTurn(text) { if (!closed) { if (!started) runQuery(); turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; stallRetried = false; forceStopped = false;
|
|
1138
1152
|
const t = String(text)
|
|
1153
|
+
// Arm the compaction window BEFORE the push: from here until the `compaction` milestone
|
|
1154
|
+
// (or the turn's result) the SDK is allowed to be silent for minutes without the stall
|
|
1155
|
+
// watchdog aborting it. See turn-stall.mjs — aborting a compaction is what produced
|
|
1156
|
+
// `AbortError: Compaction canceled.` in the room.
|
|
1157
|
+
compacting = isCompactTurn(t)
|
|
1139
1158
|
// SDK 0.3.198 only recognizes a slash command (/compact, /clear, custom /cmds) when the
|
|
1140
1159
|
// user message is a SINGLE text block. Appending the per-turn ROOM NOW <system-reminder>
|
|
1141
1160
|
// as a 2nd block made 0.3.198 treat /compact as a plain turn → compaction silently
|
package/event-id.mjs
CHANGED
|
@@ -225,10 +225,16 @@ export const claudeish = (m) => /claude|anthropic|opus|sonnet|haiku|fable/i.test
|
|
|
225
225
|
|
|
226
226
|
// model id → friendly label for the /usage report: claude-opus-4-8 → "Opus 4.8".
|
|
227
227
|
// Bridge-local twin of the web's prettyModel (structured.jsx); falls back to the raw id.
|
|
228
|
+
// The `[1m]` long-context suffix is PRESERVED. `claude-opus-4-8` and `claude-opus-4-8[1m]`
|
|
229
|
+
// are different models on different rates and summarizeUsage keys them apart — collapsing
|
|
230
|
+
// both to "Opus 4.8" printed two identical, unattributable rows in one report (real log,
|
|
231
|
+
// room 1TV7B59A: `Opus 4.8: 51.8M in … · Opus 4.8: 46.0M in …`). 2026-07-10.
|
|
228
232
|
export function prettyModelId (m) {
|
|
229
233
|
if (!m) return 'unknown'
|
|
230
|
-
const
|
|
231
|
-
|
|
234
|
+
const raw = String(m)
|
|
235
|
+
const long = /\[1m\]/i.test(raw) ? ' [1m]' : ''
|
|
236
|
+
const mm = raw.match(/(opus|sonnet|haiku|fable)-(\d+)(?:-(\d+))?/i)
|
|
237
|
+
return mm ? `${mm[1][0].toUpperCase()}${mm[1].slice(1)} ${mm[2]}${mm[3] ? '.' + mm[3] : ''}${long}` : raw
|
|
232
238
|
}
|
|
233
239
|
|
|
234
240
|
// Build the ONE ◆ control line the /usage command emits, from the session's model +
|
|
@@ -246,14 +252,22 @@ export function usageReportLine (sessionModel, logEvents) {
|
|
|
246
252
|
if (!anyClaude && (sessionModel || sum.turns)) return 'usage reporting is available on Claude models only'
|
|
247
253
|
if (!sum.turns) return 'usage · no completed turns yet this session'
|
|
248
254
|
const fmtTok = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(n)
|
|
249
|
-
|
|
255
|
+
// Drop models that burned nothing. The SDK stamps a `result` with the resolved id even for
|
|
256
|
+
// turns that spent no tokens (an interrupt, a no-op /compact), so a real report carried dead
|
|
257
|
+
// rows like `opus[1m]: 0 in / 0 out` (room 1TV7B59A). If EVERY row is empty, print them all
|
|
258
|
+
// rather than an eerie modelless line. `total` keys off the printed rows, not the raw set.
|
|
259
|
+
const rows = sum.per.filter((r) => r.in || r.out).length ? sum.per.filter((r) => r.in || r.out) : sum.per
|
|
260
|
+
const perStr = rows.map((r) => {
|
|
250
261
|
const cch = r.cached ? ` (${fmtTok(r.cached)} cached)` : ''
|
|
251
262
|
return `${prettyModelId(r.model)}: ${fmtTok(r.in)} in${cch} / ${fmtTok(r.out)} out`
|
|
252
263
|
}).join(' · ')
|
|
253
|
-
const total =
|
|
264
|
+
const total = rows.length > 1 ? ` · total ${fmtTok(sum.totalIn)} in / ${fmtTok(sum.totalOut)} out` : ''
|
|
254
265
|
const cost = sum.haveCost ? ` · ~$${sum.totalCost.toFixed(2)} est` : ''
|
|
255
266
|
const turns = `${sum.turns} turn${sum.turns === 1 ? '' : 's'}`
|
|
256
|
-
|
|
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}`
|
|
257
271
|
}
|
|
258
272
|
|
|
259
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.
|
|
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",
|
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
|
package/turn-stall.mjs
CHANGED
|
@@ -22,13 +22,31 @@
|
|
|
22
22
|
// quietMs > stallMs (90s) → 'status' — surface a "still retrying (Ns)" banner, ONCE
|
|
23
23
|
// quietMs > forceStopMs (5min) → 'retry' — abort the hung call, re-run the turn ONCE
|
|
24
24
|
// quietMs > forceStopMs again → 'giveup' — the one retry also stalled: force-stop, resend
|
|
25
|
-
|
|
25
|
+
//
|
|
26
|
+
// COMPACTION is the third "silence is not a wedge" case, alongside idle + awaitingUser.
|
|
27
|
+
// A `/compact` is ONE model call that summarises the whole transcript and emits NOTHING until
|
|
28
|
+
// it lands: measured at 28.5s of dead air on a 54k-token context (bridge/probe-compact.mjs),
|
|
29
|
+
// and Max's real compactions carried 397k–618k pre-tokens — minutes of silence. The watchdog
|
|
30
|
+
// read that as a wedge, fired 'retry' at 300s, and retryStalledTurn's qAc.abort() KILLED the
|
|
31
|
+
// compaction: `AbortError: Compaction canceled.` landed in the shared transcript, the context
|
|
32
|
+
// was unchanged, and the retry re-pushed `/compact` to be aborted again. (Room 1TV7B59A, lane
|
|
33
|
+
// 9ad2e6bf, seq 1210-1215 on 2026-07-09 20:00 — and the same shape again at seq 2239-2241.)
|
|
34
|
+
// So while compacting: never 'status' (the "still retrying" copy is a lie, and the compact
|
|
35
|
+
// indicator already owns that UI) and never 'retry' (the abort IS the bug). A genuinely hung
|
|
36
|
+
// compaction still recovers at a much longer ceiling — 'giveup' force-stops without aborting.
|
|
37
|
+
export function stallDecision({ turnActive, awaitingUser, quietMs, stallMs, forceStopMs, stalledSent, stallRetried, compacting = false, compactForceStopMs = Infinity }) {
|
|
26
38
|
if (!turnActive || awaitingUser > 0) return 'none'
|
|
39
|
+
if (compacting) return quietMs > compactForceStopMs ? 'giveup' : 'none'
|
|
27
40
|
if (quietMs > forceStopMs) return stallRetried ? 'giveup' : 'retry'
|
|
28
41
|
if (!stalledSent && quietMs > stallMs) return 'status'
|
|
29
42
|
return 'none'
|
|
30
43
|
}
|
|
31
44
|
|
|
45
|
+
// Is this turn text a manual compaction request? The SDK recognises `/compact` (optionally
|
|
46
|
+
// with focus instructions) as a slash command when the user message is a single text block —
|
|
47
|
+
// see claude-session's sendTurn. Exported so the watchdog and the re-push guard agree.
|
|
48
|
+
export const isCompactTurn = (text) => /^\s*\/compact\b/.test(String(text || ''))
|
|
49
|
+
|
|
32
50
|
// The room-facing event for a watchdog action (pure → the exact wording is unit-tested).
|
|
33
51
|
// 'status' rides the transient `stalled` chrome event (broadcast, never persisted); 'retry'
|
|
34
52
|
// and 'giveup' ride `error` (recoverable) so the stall is ALWAYS surfaced, never swallowed.
|