thinkpool-pair 0.7.108 → 0.7.110

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 (3) hide show
  1. package/account.mjs +10 -2
  2. package/bridge.mjs +68 -1
  3. package/package.json +1 -1
package/account.mjs CHANGED
@@ -255,6 +255,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
255
255
  let applyRequested = false // a user clicked "apply" in some room (Slice 3)
256
256
  let pendingUpdate = null // newest published version once the poll sees it
257
257
  const warned = new Set()
258
+ const refused = new Map() // room -> reason ('home'|'none'|'dir-gone'): owned but NOT served, announced in presence so the web shows a precise "attach this session" card instead of spinning
258
259
 
259
260
  // Account presence — so the web dashboard can show "● Bridge active" for this
260
261
  // account without per-room probing. We TRACK this machine (name + served room
@@ -324,7 +325,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
324
325
  // service (always-on, survives reboot) vs a foreground `npx` run that dies with its
325
326
  // terminal. THINKPOOL_PAIR_AUTOUPDATE=1 is set ONLY by the install-service plist/unit
326
327
  // (service.mjs), so it's the reliable proxy for "running as the managed service".
327
- const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], service: process.env.THINKPOOL_PAIR_AUTOUPDATE === '1', ts: Date.now() })
328
+ const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: process.env.THINKPOOL_PAIR_AUTOUPDATE === '1', ts: Date.now() })
328
329
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
329
330
  // (network blip, or a token swap mid-flight) rejoins the channel and must
330
331
  // re-announce, or the dashboard would read "no bridge" until the next restart.
@@ -491,18 +492,23 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
491
492
  // participant's bridge is duplication the UI never even looks at.
492
493
  const { data } = await sb.from('code_sessions').select('code,name,last_active_at,owner_id,participants').eq('owner_id', session.user.id).order('last_active_at', { ascending: false }).limit(20)
493
494
  rooms = data || []
495
+ // Prune refused entries for rooms we no longer own (only on a SUCCESSFUL fetch,
496
+ // so a transient query error never wipes the set).
497
+ const ownedCodes = new Set(rooms.map((r) => r.code))
498
+ for (const c of [...refused.keys()]) if (!ownedCodes.has(c)) refused.delete(c)
494
499
  } catch { /* transient — keep existing children, retry next tick */ }
495
500
  const dirs = loadDirs()
496
501
  const served = loadServed()
497
502
  for (const r of rooms) {
498
503
  const room = r.code
499
- if (!room || children.has(room)) continue
504
+ if (!room || children.has(room)) { if (room) refused.delete(room); continue } // served → not refused
500
505
  if (r.owner_id && r.owner_id !== session.user.id) continue // never serve a room we don't own (defense-in-depth behind the .eq above)
501
506
  // Resolve the serve dir: explicit bind > last-served memory > launch dir. REFUSE
502
507
  // to auto-serve an unbound room from $HOME — that strips the agent's resume context
503
508
  // (cwd-keyed SDK sessions) and shows your home folder as the repo (FVIHV1DE).
504
509
  const choice = resolveServeDir({ bound: dirs[room], remembered: served[room], defaultDir: DEFAULT_DIR, home: os.homedir() })
505
510
  if (choice.refuse) {
511
+ refused.set(room, choice.reason)
506
512
  if (!warned.has(room)) {
507
513
  warned.add(room)
508
514
  const why = choice.reason === 'home'
@@ -514,10 +520,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
514
520
  }
515
521
  const dir = choice.dir
516
522
  if (!fs.existsSync(dir)) {
523
+ refused.set(room, 'dir-gone')
517
524
  if (!warned.has(room)) { warned.add(room); process.stderr.write(`\n ◇ ${room}: bound directory is gone (${dir}) — re-bind it.\n`) }
518
525
  continue
519
526
  }
520
527
  warned.delete(room)
528
+ refused.delete(room) // about to serve → clear any prior refusal
521
529
  // Learn this room → dir so a later relaunch from $HOME resumes it HERE (the real
522
530
  // repo) instead of the home-dir fallback. Only the auto path is remembered; an
523
531
  // explicit bind already persists in dirs.json.
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.110",
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": {