thinkpool-pair 0.7.115 → 0.7.117

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
@@ -529,6 +529,22 @@ function recordRoomSpend (tokens) {
529
529
  if (BUDGET_OFF || !codeAuthToken || !room || !tokens) return
530
530
  budgetRpc('record_spend', { p_room: room, p_tokens: tokens }).catch(() => { /* best-effort — next turn's check reconciles */ })
531
531
  }
532
+ // Fire-and-forget: log a completed Code turn's BYOK token usage per-MODEL into
533
+ // chat_usage (via record_code_usage → attributes to the room owner) so the
534
+ // Settings "AI usage" card counts ThinkPool Code, not just chat. Independent of
535
+ // TP_BUDGET_OFF (that only governs the cap ledger; usage accounting should still
536
+ // run). input = full input incl. cache; cached = cache-read slice; both mapped to
537
+ // pricing.js's rowCostEur formula. No model → skip (can't price/label it).
538
+ function recordCodeUsage (model, usage) {
539
+ if (!codeAuthToken || !room || !model) return
540
+ const u = usage || {}
541
+ const input = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0)
542
+ const output = u.output_tokens || 0
543
+ const cached = u.cache_read_input_tokens || 0
544
+ if (!input && !output) return
545
+ budgetRpc('record_code_usage', { p_room: room, p_model: model, p_input: input, p_output: output, p_cached: cached })
546
+ .catch(() => { /* best-effort — usage accounting is not load-bearing for the turn */ })
547
+ }
532
548
  // Pre-flight gate. Returns true if the turn must be REFUSED (room cap reached). Refusal is
533
549
  // legible (a ◆ control line both readers + late-joiners see), never a silent stall.
534
550
  async function budgetBlocked (s, term) {
@@ -1613,7 +1629,7 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1613
1629
  }
1614
1630
  // Budget caps — fold EVERY completed turn's output tokens into the room's durable
1615
1631
  // monthly ledger (lanes + normal turns), so check_budget can pre-flight the next one.
1616
- if (evt.kind === 'result' && evt.usage) recordRoomSpend(evt.usage.output_tokens || 0)
1632
+ if (evt.kind === 'result' && evt.usage) { recordRoomSpend(evt.usage.output_tokens || 0); recordCodeUsage(evt.model, evt.usage) }
1617
1633
  // FL-B1 (lane) — a flow lane that ENDS ITS TURN is done: a bypass lane runs its slice
1618
1634
  // to completion in one turn, then narrates "done" and stops. Models often skip the
1619
1635
  // explicit signal (FLOW_DONE / mark_flow_done), which left the slice stuck 'dispatched'
@@ -2393,7 +2409,19 @@ flowChannel
2393
2409
  cleanupFlowLanes(payload.flowId, 600)
2394
2410
  process.stderr.write(`\n ${A.yel}◆ flow cancelled — closing its lanes (${String(payload.flowId).replace(/-/g, '').slice(0, 8)}).${A.rst}\n`)
2395
2411
  })
2396
- .subscribe()
2412
+ .subscribe(status => {
2413
+ // BRG-2 (audit 2026-07-02): tpflow had a bare subscribe with no status handler, so a
2414
+ // channel-specific CHANNEL_ERROR (socket still healthy → the main tpcode watchdog
2415
+ // never trips) dropped flow dispatch/plan/done silently until the next bridge
2416
+ // restart. Surface it. supabase-js auto-rejoins every channel on a socket reconnect,
2417
+ // so a transient drop self-heals; an active per-channel re-subscribe is deliberately
2418
+ // NOT added here — re-subscribing a live supabase channel is a known lifecycle
2419
+ // footgun (the 2026-06-30 channel-collision family) and can't be two-device verified
2420
+ // from a single host.
2421
+ if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {
2422
+ process.stderr.write(`\n ⚠ flow realtime ${status} (tpflow:${room}).\n`)
2423
+ }
2424
+ })
2397
2425
 
2398
2426
  // Watchdog — if the realtime channel is stuck (never connected, or dropped and
2399
2427
  // not recovered) for >60s, exit non-zero so a supervisor/launchd restarts a
@@ -2549,6 +2577,10 @@ async function shutdown(code = 0, farewell = true) {
2549
2577
  // socket is alive; on a wedge it won't flush and the hard backstop above wins).
2550
2578
  try { await channel.untrack() } catch { /* noop */ }
2551
2579
  try { await supabase.removeChannel(channel) } catch { /* noop */ }
2580
+ // BRG-2 (audit 2026-07-02): shutdown removed only the room channel, leaking the flow
2581
+ // topic (no presence to untrack, but the subscription + socket join outlived the
2582
+ // process teardown). Close it too so shutdown is symmetric.
2583
+ try { await supabase.removeChannel(flowChannel) } catch { /* noop */ }
2552
2584
  setTimeout(() => process.exit(code), 250) // grace for the leave/close frames to flush
2553
2585
  }
2554
2586
  process.on('SIGINT', () => shutdown(0))
@@ -171,6 +171,9 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
171
171
  // per turn on `result`.
172
172
  let turnBaseOut = 0 // output tokens from completed messages this turn
173
173
  let curMsgOut = 0 // latest output_tokens for the in-flight message
174
+ let curModel = model || null // latest model id seen (init/system + ctx); stamped on
175
+ // the result event so the bridge can attribute BYOK Code
176
+ // spend per-model (record_code_usage → chat_usage).
174
177
  // ── stall watchdog (C2) — stream silence ≠ done; a turn can stall (deltas pause
175
178
  // 3+ min) or abort silently with no `result`. Track turn liveness + last event
176
179
  // time; if an active turn goes quiet past STALL_MS, emit one `stalled` chrome
@@ -197,6 +200,22 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
197
200
  // the catch self-heals.
198
201
  const FORCE_STOP_MS = Math.max(STALL_MS * 3, parseInt(process.env.TP_FORCE_STOP_MS, 10) || 300000)
199
202
  let forceStopped = false
203
+ // Waiting on a HUMAN decision (permission card / plan gate / AskUserQuestion)
204
+ // is NOT a wedge — the turn is correctly idle until the person answers. Every
205
+ // interactive gate goes through requestPermission, so wrap it once to bump a
206
+ // counter while a card is pending; the stall watchdog below stands down while
207
+ // awaitingUser > 0. Without this, a slow human answer (e.g. a late
208
+ // AskUserQuestion pick) trips FORCE_STOP_MS, the turn is force-stopped, and the
209
+ // answer lands on a dead turn ("agent went silent … send again to resume").
210
+ // On release, stamp lastEvtTs = now so the resumed turn isn't force-stopped on
211
+ // the very next tick by a now-stale (minutes-old) timestamp.
212
+ let awaitingUser = 0
213
+ const _requestPermission = requestPermission
214
+ requestPermission = async (req) => {
215
+ awaitingUser++
216
+ try { return await _requestPermission?.(req) }
217
+ finally { awaitingUser = Math.max(0, awaitingUser - 1); lastEvtTs = Date.now() }
218
+ }
200
219
  // Set while an interrupt is settling. q.interrupt() doesn't end the turn cleanly on this
201
220
  // SDK — it surfaces a REDUNDANT pair of terminal results (subtype `aborted` AND
202
221
  // `error_during_execution`) and re-inits the session. abort() emits the single canonical
@@ -225,7 +244,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
225
244
 
226
245
  const emit = (evt) => { lastEvtTs = Date.now(); if (evt && evt.kind !== 'stalled') stalledSent = false; try { onEvent?.(evt) } catch { /* never let a consumer throw into the loop */ } }
227
246
  const stallTimer = setInterval(() => {
228
- if (!turnActive) return
247
+ if (!turnActive || awaitingUser > 0) return // awaiting a human decision ≠ wedged
229
248
  const quiet = Date.now() - lastEvtTs
230
249
  if (quiet > FORCE_STOP_MS && !forceStopped) {
231
250
  // True wedge — no result, no error, just silence. Unblocks between-turns
@@ -650,6 +669,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
650
669
  // m.slash_commands (init message) — the commands this session really
651
670
  // supports: built-ins + the host's custom .claude/commands. Surfaced
652
671
  // so the room composer's autocomplete lists what ACTUALLY exists.
672
+ curModel = m.model || model || curModel
653
673
  emit({ kind: 'system', sessionId, model: m.model || model || null, commands: Array.isArray(m.slash_commands) ? m.slash_commands : undefined })
654
674
  // Dynamic model list — ask the SDK for its OWN supported models and surface
655
675
  // them to the room so the /model picker renders live options (incl. new
@@ -715,7 +735,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
715
735
  turnActive = false // turn settled → stall watchdog stands down
716
736
  restartCount = 0 // a successful turn refills the auto-restart budget
717
737
  forceStopped = false
718
- emit({ kind: 'result', subtype: m.subtype, sessionId, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns, durationMs: m.duration_ms ?? null, denials: Array.isArray(m.permission_denials) ? m.permission_denials.length : 0 })
738
+ emit({ kind: 'result', subtype: m.subtype, sessionId, model: curModel, costUsd: m.total_cost_usd, usage: m.usage, numTurns: m.num_turns, durationMs: m.duration_ms ?? null, denials: Array.isArray(m.permission_denials) ? m.permission_denials.length : 0 })
719
739
  // Arm the Haiku fallback: if Claude's own prompt_suggestion doesn't arrive within
720
740
  // the window, generate one from the last assistant reply. (Skip aborted turns.)
721
741
  if (sugTimer) clearTimeout(sugTimer)
@@ -726,7 +746,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
726
746
  let ctx = null
727
747
  try {
728
748
  const c = await q?.getContextUsage?.()
729
- if (c) ctx = { used: c.totalTokens, max: c.maxTokens, pct: Math.round(c.percentage), model: c.model }
749
+ if (c) { ctx = { used: c.totalTokens, max: c.maxTokens, pct: Math.round(c.percentage), model: c.model }; if (c.model) curModel = c.model }
730
750
  } catch { /* control req may be unavailable */ }
731
751
  const u = m.usage || {}
732
752
  emit({ kind: 'usage', costUsd: m.total_cost_usd ?? null, tokens: (u.input_tokens || 0) + (u.output_tokens || 0), ctx })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.115",
3
+ "version": "0.7.117",
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": {