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.
- package/account.mjs +10 -2
- package/bridge.mjs +68 -1
- 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
|
})
|