thinkpool-pair 0.7.115 → 0.7.116

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'
@@ -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.116",
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": {