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 +34 -2
- package/claude-session.mjs +23 -3
- package/package.json +1 -1
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))
|
package/claude-session.mjs
CHANGED
|
@@ -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 })
|