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 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 finished (or errored) → clear the pulsing indicator AND resolve the
2286
- // "◈ compacting context…" line so it doesn't sit there forever looking stuck (Max asked
2287
- // "is it compacted?" — 2026-07-02). On success post an explicit "◈ context compacted"
2288
- // control line, paired with the start line; on error the SDK's AbortError/too-small
2289
- // text is the signal, so no line. Same control-event shape as ctlLine (kind:'control').
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. Scope is honest —
2838
- // the Agent SDK exposes tokens + cost, not plan/rate-limit meters, and the card says so.
2839
- 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
+ }
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
@@ -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
- const emit = (evt) => { lastEvtTs = Date.now(); if (evt && evt.kind !== 'stalled') stalledSent = false; emitRaw(evt) }
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
- const replay = wasActive ? lastTurnText : null
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 mm = String(m).match(/(opus|sonnet|haiku|fable)-(\d+)(?:-(\d+))?/i)
231
- return mm ? `${mm[1][0].toUpperCase()}${mm[1].slice(1)} ${mm[2]}${mm[3] ? '.' + mm[3] : ''}` : String(m)
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
- const perStr = sum.per.map((r) => {
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 = sum.per.length > 1 ? ` · total ${fmtTok(sum.totalIn)} in / ${fmtTok(sum.totalOut)} out` : ''
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
- 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}`
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.183",
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
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
- export function stallDecision({ turnActive, awaitingUser, quietMs, stallMs, forceStopMs, stalledSent, stallRetried }) {
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.