thinkpool-pair 0.7.108 → 0.7.109

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.
Files changed (2) hide show
  1. package/bridge.mjs +68 -1
  2. package/package.json +1 -1
package/bridge.mjs CHANGED
@@ -464,6 +464,66 @@ async function refreshOwnerPlan() {
464
464
  } catch { /* DNS-flaky / offline — keep the safe default; the next token refresh retries */ }
465
465
  }
466
466
  refreshOwnerPlan() // fire-and-forget on boot (codeAuthToken may be set from env)
467
+
468
+ // ── Pre-flight budget caps — spec docs/specs/2026-07-01-budget-caps.md (nexos steal #1).
469
+ // The bridge calls the durable ledger RPCs as the OWNER (codeAuthToken); is_session_member
470
+ // passes for this room, so the room-scope cap enforces. Member-scope attribution needs the
471
+ // SENDER's uid (not available here — a turn carries a display label + cid, not a uuid), so
472
+ // the baseline records/enforces ROOM scope only; per-member caps wait on the quota-fairness
473
+ // work. TP_BUDGET_OFF disables the whole path. `roomCapped` caches whether a cap exists so a
474
+ // transient ledger error fails CLOSED only for a room we KNOW is capped, and open otherwise.
475
+ const BUDGET_OFF = process.env.TP_BUDGET_OFF === '1'
476
+ let roomCapped = false
477
+ function monthResetLabel () {
478
+ const d = new Date()
479
+ return new Date(d.getFullYear(), d.getMonth() + 1, 1).toISOString().slice(0, 10)
480
+ }
481
+ async function budgetRpc (fn, args) {
482
+ const r = await fetch(`${SUPABASE_URL}/rest/v1/rpc/${fn}`, {
483
+ method: 'POST',
484
+ headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${codeAuthToken}`, 'Content-Type': 'application/json' },
485
+ body: JSON.stringify(args),
486
+ })
487
+ if (!r.ok) throw new Error(`${fn} ${r.status}`)
488
+ const t = await r.text()
489
+ return t ? JSON.parse(t) : null
490
+ }
491
+ // Fire-and-forget: fold a completed turn's output tokens into the room's durable monthly
492
+ // ledger. output_tokens = the billed reasoning+output spend the indicator already tracks.
493
+ function recordRoomSpend (tokens) {
494
+ if (BUDGET_OFF || !codeAuthToken || !room || !tokens) return
495
+ budgetRpc('record_spend', { p_room: room, p_tokens: tokens }).catch(() => { /* best-effort — next turn's check reconciles */ })
496
+ }
497
+ // Pre-flight gate. Returns true if the turn must be REFUSED (room cap reached). Refusal is
498
+ // legible (a ◆ control line both readers + late-joiners see), never a silent stall.
499
+ async function budgetBlocked (s, term) {
500
+ if (BUDGET_OFF || !codeAuthToken) return false
501
+ const refuse = (text) => {
502
+ const evt = { kind: 'control', text }
503
+ try { pushLog(s, evt) } catch { /* no session log */ }
504
+ bcast('code-event', { term, evt })
505
+ return true
506
+ }
507
+ try {
508
+ const rows = await budgetRpc('check_budget', { p_room: room })
509
+ const r = Array.isArray(rows) ? rows[0] : rows
510
+ roomCapped = !!(r && r.remaining_tokens != null)
511
+ if (r && r.ok === false) {
512
+ process.stderr.write(`\n ${A.yel}◆ turn refused — ${r.tightest_scope} budget cap reached (room ${room}).${A.rst}\n`)
513
+ return refuse(`◆ Turn blocked — this room hit its ${r.tightest_scope || 'budget'} cap. Resets ${monthResetLabel()}, or raise the cap.`)
514
+ }
515
+ return false
516
+ } catch (e) {
517
+ // Fail-closed ONLY where we know a cap exists — a transient ledger/DNS blip must never
518
+ // brick an UNCAPPED room (that would be a worse outage than the cap it's guarding).
519
+ if (roomCapped) {
520
+ process.stderr.write(`\n ${A.yel}◆ turn refused — budget check failed, room is capped (fail-closed).${A.rst}\n`)
521
+ return refuse('◆ Turn blocked — budget check is unavailable and this room has a cap set. Try again in a moment.')
522
+ }
523
+ process.stderr.write(`\n ${A.dim}◆ budget check skipped (${e.message}) — room uncapped, proceeding.${A.rst}\n`)
524
+ return false
525
+ }
526
+ }
467
527
  // Throttled presence track() (presence.mjs) — a reconnect storm re-fires SUBSCRIBED and
468
528
  // the handler re-track()s; without throttle that spirals into the per-client presence
469
529
  // rate limit (ClientPresenceRateLimitReached). Lazy so it binds `channel` (the chain
@@ -1456,6 +1516,9 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
1456
1516
  const b = flowBudgets.get(entry.flowSessionId)
1457
1517
  if (b) flowBudgets.set(entry.flowSessionId, recordSpend(b, evt.usage.output_tokens || 0))
1458
1518
  }
1519
+ // Budget caps — fold EVERY completed turn's output tokens into the room's durable
1520
+ // monthly ledger (lanes + normal turns), so check_budget can pre-flight the next one.
1521
+ if (evt.kind === 'result' && evt.usage) recordRoomSpend(evt.usage.output_tokens || 0)
1459
1522
  // FL-B1 (lane) — a flow lane that ENDS ITS TURN is done: a bypass lane runs its slice
1460
1523
  // to completion in one turn, then narrates "done" and stops. Models often skip the
1461
1524
  // explicit signal (FLOW_DONE / mark_flow_done), which left the slice stuck 'dispatched'
@@ -1797,7 +1860,7 @@ channel
1797
1860
  if (payload?.host && payload.host !== name) return
1798
1861
  openStructured({ id: payload?.id || randomUUID(), model: payload?.model, resume: payload?.resume, mode: payload?.mode })
1799
1862
  })
1800
- .on('broadcast', { event: 'code-turn' }, ({ payload }) => {
1863
+ .on('broadcast', { event: 'code-turn' }, async ({ payload }) => {
1801
1864
  const s = payload?.term && sessions.get(payload.term)
1802
1865
  if (!s || payload.text == null) return
1803
1866
  markActivity()
@@ -1857,6 +1920,10 @@ channel
1857
1920
  s.session.sendTurn(text)
1858
1921
  return
1859
1922
  }
1923
+ // Pre-flight budget gate (room scope) — refuse the billable agent turn if the room hit
1924
+ // its cap. The slash controls above (/model, /clear, /compact) are intentionally exempt:
1925
+ // they let a user manage or REDUCE spend even when capped. Legible refusal, no silent stall.
1926
+ if (await budgetBlocked(s, payload.term)) return
1860
1927
  s.session.sendTurn(text)
1861
1928
  echoYou()
1862
1929
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.108",
3
+ "version": "0.7.109",
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": {