thinkpool-pair 0.7.166 → 0.7.168
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 +33 -37
- package/bridge.mjs +61 -64
- package/package.json +1 -1
- package/serve-consent.mjs +34 -87
package/account.mjs
CHANGED
|
@@ -17,7 +17,7 @@ import { saveAuth, loadAuth, loadDirs, bindDir, loadServed, rememberServed, load
|
|
|
17
17
|
import { isSafeToRestart } from './update-gate.mjs'
|
|
18
18
|
import { makeThrottledTrack } from './presence.mjs'
|
|
19
19
|
import { publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal } from './providers.mjs'
|
|
20
|
-
import { supervisorServes
|
|
20
|
+
import { supervisorServes } from './serve-consent.mjs'
|
|
21
21
|
import { resolveServeDir } from './serve-dir.mjs'
|
|
22
22
|
import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
|
|
23
23
|
|
|
@@ -232,13 +232,6 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
232
232
|
process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n New sessions auto-serve from ${_effDefault}${loadDefaultDir() ? ' (your default)' : (claudeRecentProjectDir() ? ' (your most-recent Claude Code project)' : '')}\n (change it: npx thinkpool-pair set-default-dir <dir> · one room: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
|
|
233
233
|
|
|
234
234
|
const children = new Map() // room -> child process
|
|
235
|
-
// grantCooldown: room -> wall-clock ms deadline. A grantee child that yielded to the
|
|
236
|
-
// owner's returning bridge (exit 75) parks its room here so discovery does NOT re-spawn a
|
|
237
|
-
// child that would only yield again — the owner-serves/grantee-stands-by coexistence state
|
|
238
|
-
// must not thrash-churn every 15s tick (DEFECT 2, 2026-07-05). Expires after TP_GRANT_COOLDOWN_MS;
|
|
239
|
-
// after that discovery retries once — serving normally if the owner has since gone.
|
|
240
|
-
const grantCooldown = new Map()
|
|
241
|
-
const GRANT_COOLDOWN_MS = Math.max(15_000, parseInt(process.env.TP_GRANT_COOLDOWN_MS, 10) || 120_000)
|
|
242
235
|
const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
|
|
243
236
|
const childBetween = new Map() // room -> bool (between turns now — safe to restart per Contract #1)
|
|
244
237
|
const childPeer = new Map() // room -> bool (a web client is watching this room)
|
|
@@ -271,6 +264,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
271
264
|
// account without per-room probing. We TRACK this machine (name + served room
|
|
272
265
|
// codes + version) on a per-account channel; the dashboard subscribes read-only.
|
|
273
266
|
const machine = os.hostname().replace(/\.local$/, '')
|
|
267
|
+
|
|
268
|
+
// ── supervisor logging (cascade brg-instrument, supervisor-log lane) ─────────
|
|
269
|
+
const SUP_ID = (randomUUID?.() || '').slice(0, 8)
|
|
270
|
+
let lastClaimHeld = null // track state changes for claim_tick logging
|
|
271
|
+
console.log(`◇ supervisor boot sup=${SUP_ID} host=${machine} pid=${process.pid} npx=${VERSION || 'dev'}`)
|
|
272
|
+
|
|
274
273
|
const acct = sb.channel(`tpacct:${session.user.id}`, { config: { presence: { key: machine }, broadcast: { self: false } } })
|
|
275
274
|
let restarting = false // set by the "restart" broadcast → stop() exits non-zero so a supervisor respawns us
|
|
276
275
|
// Web "attach this session" card → bind an unbound/new room to a local dir (and
|
|
@@ -463,6 +462,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
463
462
|
else if (!held && standbyHost !== host) process.stderr.write(`\n ◇ another device (${host}) is serving this account — standing by.\n (this Mac takes over automatically if that device goes offline.)\n`)
|
|
464
463
|
claimHeld = held
|
|
465
464
|
standbyHost = held ? null : host
|
|
465
|
+
|
|
466
|
+
// ── supervisor claim_tick logging (cascade brg-instrument) ─────────────────
|
|
467
|
+
if (lastClaimHeld !== held) {
|
|
468
|
+
console.log(`claim_tick sup=${SUP_ID} held=${held} holder=${held ? machine : host}`)
|
|
469
|
+
lastClaimHeld = held
|
|
470
|
+
}
|
|
471
|
+
|
|
466
472
|
return held
|
|
467
473
|
} catch {
|
|
468
474
|
// Transient claim error: keep serving if we already hold; never START
|
|
@@ -589,17 +595,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
589
595
|
let rooms = []
|
|
590
596
|
const me = session.user.id
|
|
591
597
|
try {
|
|
592
|
-
// Discovery (Contract C-CODE-2,
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
|
|
598
|
-
// spawning your machine into another owner's session that already runs its own
|
|
599
|
-
// bridge; the .or keeps un-granted joins out while letting a consented grant IN.
|
|
600
|
-
// The web watches tpacct:<session-owner>; a granted bridge serves on the owner's
|
|
601
|
-
// behalf (the grant IS the consent), so it is wanted duplication, not stray.
|
|
602
|
-
const { data } = await sb.from('code_sessions').select('code,name,last_active_at,owner_id,serve_grant_uid,participants').or(`owner_id.eq.${me},serve_grant_uid.eq.${me}`).order('last_active_at', { ascending: false }).limit(20)
|
|
598
|
+
// Discovery (Contract C-CODE-2, owner-only since 2026-07-06): a host bridges ONLY
|
|
599
|
+
// the sessions it OWNS. RLS returns owned OR participant rooms; the owner filter
|
|
600
|
+
// keeps rooms you merely JOINED as a guest out of auto-serve — your machine never
|
|
601
|
+
// spawns into another owner's session (cross-person serving removed; pairing is
|
|
602
|
+
// read-only peer peek, not hosting). The web watches tpacct:<session-owner>.
|
|
603
|
+
const { data } = await sb.from('code_sessions').select('code,name,last_active_at,owner_id,participants').eq('owner_id', me).order('last_active_at', { ascending: false }).limit(20)
|
|
603
604
|
rooms = data || []
|
|
604
605
|
// Prune refused entries for rooms we can no longer serve — owned OR granted (only
|
|
605
606
|
// on a SUCCESSFUL fetch, so a transient query error never wipes the set).
|
|
@@ -611,15 +612,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
611
612
|
for (const r of rooms) {
|
|
612
613
|
const room = r.code
|
|
613
614
|
if (!room || children.has(room)) { if (room) refused.delete(room); continue } // served → not refused
|
|
614
|
-
//
|
|
615
|
-
|
|
616
|
-
// thrash-respawn a child that will only yield again. refused already reads 'owner-serves'
|
|
617
|
-
// (set on that exit below), so presence stays honest while we stand by.
|
|
618
|
-
const cdUntil = grantCooldown.get(room)
|
|
619
|
-
if (roomInCooldown(cdUntil, Date.now())) continue
|
|
620
|
-
if (cdUntil) grantCooldown.delete(room) // window elapsed — allow one fresh attempt (owner may have gone)
|
|
621
|
-
// Defense-in-depth behind the .or above: serve iff we OWN the room or hold its grant.
|
|
622
|
-
if (!supervisorServes({ ownerId: r.owner_id, grantUid: r.serve_grant_uid, myUid: me })) continue
|
|
615
|
+
// Defense-in-depth behind the owner-only query above: serve iff we OWN the room.
|
|
616
|
+
if (!supervisorServes({ ownerId: r.owner_id, myUid: me })) continue
|
|
623
617
|
// Resolve the serve dir: explicit bind > last-served memory > launch dir. REFUSE
|
|
624
618
|
// to auto-serve an unbound room from $HOME — that strips the agent's resume context
|
|
625
619
|
// (cwd-keyed SDK sessions) and shows your home folder as the repo (FVIHV1DE).
|
|
@@ -660,7 +654,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
660
654
|
delete env.THINKPOOL_PAIR_AUTOUPDATE
|
|
661
655
|
// Hand the child the owner JWT for authed web writes (code-mockup, L16).
|
|
662
656
|
if (currentAccessToken) env.TP_ACCESS_TOKEN = currentAccessToken
|
|
657
|
+
|
|
658
|
+
// ── supervisor child_spawn logging (cascade brg-instrument) ─────────────────
|
|
659
|
+
const alreadyHad = children.has(room)
|
|
663
660
|
const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
|
|
661
|
+
console.log(`child_spawn sup=${SUP_ID} room=${room} pid=${child.pid} dir=${dir} had_prior=${alreadyHad}`)
|
|
662
|
+
|
|
664
663
|
children.set(room, child)
|
|
665
664
|
childIdle.set(room, false) // unknown until it reports → never restart a fresh child
|
|
666
665
|
childBetween.set(room, false)
|
|
@@ -738,16 +737,10 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
738
737
|
}
|
|
739
738
|
})
|
|
740
739
|
const dropRoom = (code) => {
|
|
740
|
+
// ── supervisor child_exit logging (cascade brg-instrument) ─────────────────
|
|
741
|
+
console.log(`child_exit sup=${SUP_ID} room=${room} pid=${child?.pid || '?'} code=${code || '?'}`)
|
|
742
|
+
|
|
741
743
|
children.delete(room); childIdle.delete(room); childBetween.delete(room); childPeer.delete(room); roomNames.delete(room); roomPartner.delete(room) // re-served on the next tick
|
|
742
|
-
// Owner-reclaim SENTINEL (75): the grantee child stood down because the owner's bridge
|
|
743
|
-
// returned. Park the room on a cooldown so discovery doesn't respawn it into another
|
|
744
|
-
// immediate yield (DEFECT 2), and mark refused 'owner-serves' so presence is honest.
|
|
745
|
-
// A revoke-yield exits 0 (discovery drops a revoked room anyway); owner-owned rooms
|
|
746
|
-
// never yield, so neither cools down. `code` is undefined on the 'error' path — fine.
|
|
747
|
-
if (shouldCooldownOnExit(code)) {
|
|
748
|
-
grantCooldown.set(room, Date.now() + GRANT_COOLDOWN_MS)
|
|
749
|
-
refused.set(room, 'owner-serves')
|
|
750
|
-
}
|
|
751
744
|
// Fail any in-flight cross-room peeks/posts this dead room had ASKED (bus-origin
|
|
752
745
|
// entries have askerRoom null and just time out on their own).
|
|
753
746
|
for (const [reqId, w] of pairPending) { if (w?.askerRoom === room) pairPending.delete(reqId) }
|
|
@@ -790,6 +783,9 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
|
|
|
790
783
|
const stop = (sig) => {
|
|
791
784
|
if (stopping) return; stopping = true
|
|
792
785
|
const code = restarting ? 1 : 0
|
|
786
|
+
|
|
787
|
+
// ── supervisor stand-down logging (cascade brg-instrument) ─────────────────
|
|
788
|
+
console.log(`supervisor_stop sup=${SUP_ID} reason=${sig || 'unknown'} restarting=${restarting} children=${children.size}`)
|
|
793
789
|
clearInterval(iv)
|
|
794
790
|
for (const t of updateTimers) { try { clearInterval(t) } catch { /* noop */ } } // clearInterval clears Timeouts too
|
|
795
791
|
keepFresh.cancel()
|
package/bridge.mjs
CHANGED
|
@@ -37,7 +37,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
37
37
|
import { createRequire } from 'node:module'
|
|
38
38
|
import { randomUUID } from 'node:crypto'
|
|
39
39
|
import { createClient } from '@supabase/supabase-js'
|
|
40
|
-
import { serveDecision,
|
|
40
|
+
import { serveDecision, fetchServeRow, refusalMessage, gateFailAction } from './serve-consent.mjs'
|
|
41
41
|
import { reapTerminalRow as _reapTerminalRow } from './reap-terminal.mjs'
|
|
42
42
|
// resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
|
|
43
43
|
// registered custom provider, or null for the built-in/unknown (leave the default env intact).
|
|
@@ -397,7 +397,7 @@ if (_superOwn.includes('--supervise') || _superOwn.includes('--keep-alive')) {
|
|
|
397
397
|
child.on('error', () => { /* spawn failure → exit handler retries */ })
|
|
398
398
|
}
|
|
399
399
|
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stopping = true; try { child?.kill(sig) } catch { /* noop */ } setTimeout(() => process.exit(0), 400) })
|
|
400
|
-
process.stderr.write(`\n ◆ supervisor up — keeping the bridge for room ${room} alive (auto-restart on crash).\n`)
|
|
400
|
+
process.stderr.write(`\n [b:${BRIDGE_ID} r:${room}] ◆ supervisor up — keeping the bridge for room ${room} alive (auto-restart on crash).\n`)
|
|
401
401
|
spawnChild()
|
|
402
402
|
await new Promise(() => {}) // block forever; the child IS the bridge
|
|
403
403
|
}
|
|
@@ -505,14 +505,15 @@ if (process.stdin.isTTY && !headless && process.env.THINKPOOL_PAIR_AUTOUPDATE !=
|
|
|
505
505
|
const yes = await askYesNo(`\n Run as a background service instead? It survives reboot + auto-updates,\n but runs detached — you'd stop it with 'uninstall-service', not Ctrl-C.\n Install background service? [y/N] `, false)
|
|
506
506
|
if (yes) {
|
|
507
507
|
svc.installService(room, attachedCmd ? [attachedCmd, ...attachedArgs] : [])
|
|
508
|
-
process.stderr.write(`\n ◆ the background service is handling room ${room} now — you can close this terminal.\n ◆ STOP IT with: npx thinkpool-pair@latest uninstall-service ${room}\n (closing the terminal does NOT stop it — it's detached under launchd/systemd.)\n\n`)
|
|
508
|
+
process.stderr.write(`\n [b:${BRIDGE_ID} r:${room}] ◆ the background service is handling room ${room} now — you can close this terminal.\n ◆ STOP IT with: npx thinkpool-pair@latest uninstall-service ${room}\n (closing the terminal does NOT stop it — it's detached under launchd/systemd.)\n\n`)
|
|
509
509
|
process.exit(0) // hand off to the supervised service (no double bridge)
|
|
510
510
|
}
|
|
511
511
|
try { fs.mkdirSync(path.dirname(declineFile), { recursive: true }); fs.writeFileSync(declineFile, new Date().toISOString()) } catch { /* noop */ }
|
|
512
|
-
process.stderr.write(` ◆ running in this terminal. (Auto-update service anytime: npx thinkpool-pair@latest install-service ${room})\n`)
|
|
512
|
+
process.stderr.write(` [b:${BRIDGE_ID} r:${room}] ◆ running in this terminal. (Auto-update service anytime: npx thinkpool-pair@latest install-service ${room})\n`)
|
|
513
513
|
}
|
|
514
514
|
}
|
|
515
515
|
const name = process.env.TP_NAME || os.userInfo().username || 'host'
|
|
516
|
+
const BRIDGE_ID = (randomUUID?.().slice(0,8)) || 'bridgexx'
|
|
516
517
|
// host: this machine's short label — os.hostname() with any DNS/domain suffix
|
|
517
518
|
// stripped and capped ~24 chars. A /code room can be served by different bridges
|
|
518
519
|
// over time (Max's Mac vs Conrad's Linux box); the client uses this to show WHICH
|
|
@@ -528,6 +529,48 @@ const repoLabel = path.basename(cwd)
|
|
|
528
529
|
// thinkpool-pair's own version — surfaced in the room's welcome banner.
|
|
529
530
|
let VERSION = null
|
|
530
531
|
try { VERSION = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { /* unknown — banner omits it */ }
|
|
532
|
+
|
|
533
|
+
// Stale npx cache check — warn if the running bridge is older than the newest installed.
|
|
534
|
+
// Uses proper semver comparison (not string compare) to avoid false positives like "0.7.150" < "0.7.99".
|
|
535
|
+
try {
|
|
536
|
+
const npxCacheRoot = path.join(os.homedir(), '.npm', '_npx')
|
|
537
|
+
const npxEntries = fs.readdirSync(npxCacheRoot, { withFileTypes: true })
|
|
538
|
+
.filter(dirent => dirent.isDirectory())
|
|
539
|
+
.map(dirent => dirent.name)
|
|
540
|
+
.filter(name => name !== 'cache')
|
|
541
|
+
|
|
542
|
+
// Parse semver into [major, minor, patch] with missing segments as 0
|
|
543
|
+
const parseSemver = (v) => {
|
|
544
|
+
if (!v) return [0, 0, 0]
|
|
545
|
+
return v.split('.').map(p => parseInt(p, 10) || 0)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Compare two semver versions: return true if a < b
|
|
549
|
+
const semverLess = (a, b) => {
|
|
550
|
+
const pa = parseSemver(a)
|
|
551
|
+
const pb = parseSemver(b)
|
|
552
|
+
for (let i = 0; i < 3; i++) {
|
|
553
|
+
if (pa[i] < pb[i]) return true
|
|
554
|
+
if (pa[i] > pb[i]) return false
|
|
555
|
+
}
|
|
556
|
+
return false // equal
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
let newestVersion = null
|
|
560
|
+
for (const entry of npxEntries) {
|
|
561
|
+
const pkgPath = path.join(npxCacheRoot, entry, 'node_modules', 'thinkpool-pair', 'package.json')
|
|
562
|
+
try {
|
|
563
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
564
|
+
if (pkg.version) {
|
|
565
|
+
if (!newestVersion || semverLess(newestVersion, pkg.version)) newestVersion = pkg.version
|
|
566
|
+
}
|
|
567
|
+
} catch { /* skip unreadable entries */ }
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (newestVersion && VERSION && semverLess(VERSION, newestVersion)) {
|
|
571
|
+
process.stderr.write(` ⚠ stale-cache: running ${VERSION}, newest ${newestVersion}\n`)
|
|
572
|
+
}
|
|
573
|
+
} catch { /* skip on any error — this is a lint, not a hard gate */ }
|
|
531
574
|
// Read the CURRENT branch LIVE (not once at boot) so the room's mode row reflects a
|
|
532
575
|
// branch switch instead of showing whatever branch the dir was on when the bridge spawned
|
|
533
576
|
// (the stale-branch bug). Handles a .git dir (normal checkout) AND a .git FILE (linked
|
|
@@ -622,18 +665,16 @@ const isRoomParticipant = async (jwt) => {
|
|
|
622
665
|
return Array.isArray(rows) && rows.length > 0
|
|
623
666
|
} catch { return false }
|
|
624
667
|
}
|
|
625
|
-
// ── Room-serve owner consent (Contract C-CODE-3)
|
|
626
|
-
// A room is served by its OWNER's bridge
|
|
627
|
-
//
|
|
628
|
-
// realtime channel is even built — so a
|
|
629
|
-
//
|
|
630
|
-
//
|
|
631
|
-
//
|
|
632
|
-
// an
|
|
633
|
-
|
|
668
|
+
// ── Room-serve owner consent (Contract C-CODE-3, owner-only since 2026-07-06) ──
|
|
669
|
+
// A room is served by its OWNER's bridge ONLY — no cross-person grantee path
|
|
670
|
+
// (removed 2026-07-06; see docs/specs/2026-07-06-remove-cross-person-serve.md).
|
|
671
|
+
// Enforced HERE — before the realtime channel is even built — so a non-owner bridge
|
|
672
|
+
// never dials the room. Same-account second-machine coexistence is handled by the
|
|
673
|
+
// per-account claim_bridge, not here. LIMITATION: enforcement is in-bridge; an OLDER
|
|
674
|
+
// bridge may still honor a grant — but the serve_grant_uid column was dropped server-side
|
|
675
|
+
// in the same migration, so an old bridge can no longer READ a grant and refuses as a
|
|
676
|
+
// non-owner. That closes the bypass.
|
|
634
677
|
let myServeUid = null // this bridge's authed uid (null = anon)
|
|
635
|
-
let iServeAsGrantee = false // serving by grant, not as owner → the only bridge that yields
|
|
636
|
-
let yielding = false // set once when standing down, so reclaim + revoke don't double-fire
|
|
637
678
|
{
|
|
638
679
|
// Standalone room mode ran anon unless TP_ACCESS_TOKEN was in env; load the stored
|
|
639
680
|
// login so `npx thinkpool-pair <CODE>` authenticates AS the signed-in user (the
|
|
@@ -675,17 +716,14 @@ let yielding = false // set once when standing down, so reclaim + revoke
|
|
|
675
716
|
const { data } = await supabase.auth.getUser(codeAuthToken)
|
|
676
717
|
myServeUid = data?.user?.id || null
|
|
677
718
|
}
|
|
678
|
-
const { ownerId,
|
|
679
|
-
|
|
680
|
-
const decision = serveDecision({ ownerId, grantUid, myUid: myServeUid })
|
|
719
|
+
const { ownerId, name } = await fetchServeRow({ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, room })
|
|
720
|
+
const decision = serveDecision({ ownerId, myUid: myServeUid })
|
|
681
721
|
if (!decision.serve) {
|
|
682
722
|
process.stderr.write(refusalMessage({ room, name, reason: decision.reason }))
|
|
683
723
|
// exit 0 — an intentional stand-down, not a crash, so a supervisor does not
|
|
684
724
|
// thrash-restart it (matches the machine-lock stand-down at the top of boot).
|
|
685
725
|
process.exit(0)
|
|
686
726
|
}
|
|
687
|
-
iServeAsGrantee = amGrantee({ ownerId, grantUid, myUid: myServeUid })
|
|
688
|
-
if (iServeAsGrantee) process.stderr.write(`\n ◆ serving room ${room} by the owner's grant — will yield if the owner's bridge returns or the grant is revoked.\n`)
|
|
689
727
|
} catch (e) {
|
|
690
728
|
// A THROWN fetchServeRow error is either a definitively-bad auth (401/403) or an
|
|
691
729
|
// availability failure (5xx / transport reject). Only the latter fails OPEN — a stale
|
|
@@ -995,26 +1033,8 @@ const announce = () => {
|
|
|
995
1033
|
],
|
|
996
1034
|
}) }
|
|
997
1035
|
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
// bridge always wins. Reuses the existing `bridge` announce (its additive `uid`) — no
|
|
1001
|
-
// new topic (feedback_no_duplicate_realtime_channel). The owner's own bridge never
|
|
1002
|
-
// yields (standDownOnPeerAnnounce returns false for it). Clean shutdown(0): shuttingDown
|
|
1003
|
-
// suppresses the term-exit broadcast, so no structured terminal row is reaped
|
|
1004
|
-
// (docs/specs/2026-07-04-structured-terminal-durability.md — a handover must not delete rows).
|
|
1005
|
-
function onPeerBridgeAnnounce (payload) {
|
|
1006
|
-
if (yielding) return
|
|
1007
|
-
if (!standDownOnPeerAnnounce({ myUid: myServeUid, ownerId: roomOwnerId, peerUid: payload?.uid })) return
|
|
1008
|
-
yielding = true
|
|
1009
|
-
process.stderr.write(`\n ◆ owner's bridge reconnected — yielding room ${room}.\n`)
|
|
1010
|
-
// Exit with the grant-reclaim SENTINEL (not a clean 0). A supervisor that spawned this
|
|
1011
|
-
// grantee child reads the code and puts the room on a cooldown, so it does NOT respawn a
|
|
1012
|
-
// child that will only yield again — the owner-serves/grantee-stands-by coexistence state
|
|
1013
|
-
// must not thrash-churn every discovery tick (DEFECT 2, 2026-07-05). shutdown() still sets
|
|
1014
|
-
// shuttingDown FIRST, so the term-exit broadcast stays suppressed and structured terminal
|
|
1015
|
-
// rows survive the stand-down (#205/#208 durability — a handover must not delete rows).
|
|
1016
|
-
shutdown(GRANT_RECLAIM_EXIT)
|
|
1017
|
-
}
|
|
1036
|
+
// (cross-person grantee yield removed 2026-07-06 — owner-only serving now; the owner's
|
|
1037
|
+
// bridge never stands down. See docs/specs/2026-07-06-remove-cross-person-serve.md.)
|
|
1018
1038
|
|
|
1019
1039
|
// ── Thinkpool Ensemble — cross-ROOM reach (Tier 1: same-machine pair peek) ────
|
|
1020
1040
|
// The in-room Ensemble (read_terminal over sibling terminals) expanded ACROSS the
|
|
@@ -2693,9 +2713,6 @@ channel
|
|
|
2693
2713
|
announce()
|
|
2694
2714
|
})
|
|
2695
2715
|
.on('broadcast', { event: 'who' }, announce)
|
|
2696
|
-
// Peer bridge announces (owner reclaim) — a grantee yields when the owner's bridge
|
|
2697
|
-
// reappears. broadcast self:false, so this only fires for OTHER bridges on the topic.
|
|
2698
|
-
.on('broadcast', { event: 'bridge' }, ({ payload }) => onPeerBridgeAnnounce(payload))
|
|
2699
2716
|
// ── Provider registry — the multi-BYOK wire contract (room mode). ─────────────
|
|
2700
2717
|
// AUTH: room mode serves a room the user may not own (post-#208 grant). We verify
|
|
2701
2718
|
// the sender is a participant of THIS room before ANY operation. List is open to
|
|
@@ -2782,7 +2799,7 @@ channel
|
|
|
2782
2799
|
.subscribe(status => {
|
|
2783
2800
|
if (status === 'SUBSCRIBED') {
|
|
2784
2801
|
realtimeHealthy = true; brokenSince = 0
|
|
2785
|
-
trackPresence({ name, role: 'bridge' })
|
|
2802
|
+
trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID })
|
|
2786
2803
|
const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
|
|
2787
2804
|
if (startCmd && !terms.size && !sessions.size) {
|
|
2788
2805
|
// Claude + structured mode → Agent SDK session; everything else → PTY.
|
|
@@ -3145,26 +3162,6 @@ setInterval(() => {
|
|
|
3145
3162
|
}
|
|
3146
3163
|
}, 15000).unref()
|
|
3147
3164
|
|
|
3148
|
-
// Grant revocation mid-serve (Contract C-CODE-3 invariant 3): a grantee re-checks the
|
|
3149
|
-
// row on a slow cadence — a cheap poll on the EXISTING REST endpoint, no new realtime
|
|
3150
|
-
// subscription (the owner-reclaim announce path above is the fast one). If the grant no
|
|
3151
|
-
// longer names this bridge (cleared or reassigned), stand down. Owner bridges never poll
|
|
3152
|
-
// (iServeAsGrantee is false for them). A flaky fetch never yields — next tick re-checks.
|
|
3153
|
-
if (iServeAsGrantee) {
|
|
3154
|
-
const RECHECK_MS = Math.max(15000, parseInt(process.env.TP_SERVE_RECHECK_MS, 10) || 30000)
|
|
3155
|
-
setInterval(async () => {
|
|
3156
|
-
if (yielding) return
|
|
3157
|
-
try {
|
|
3158
|
-
const { grantUid } = await fetchServeRow({ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, token: codeAuthToken, room })
|
|
3159
|
-
if (standDownOnGrantChange({ myUid: myServeUid, ownerId: roomOwnerId, grantUid })) {
|
|
3160
|
-
yielding = true
|
|
3161
|
-
process.stderr.write(`\n ◆ serve grant revoked — yielding room ${room}.\n`)
|
|
3162
|
-
shutdown(0)
|
|
3163
|
-
}
|
|
3164
|
-
} catch { /* transient — never yield on a flaky fetch; next tick re-checks */ }
|
|
3165
|
-
}, RECHECK_MS).unref()
|
|
3166
|
-
}
|
|
3167
|
-
|
|
3168
3165
|
// ── auto-update (OS-service tier only) ─────────────────────────────────
|
|
3169
3166
|
// launchd (KeepAlive) and systemd (Restart=always) re-run
|
|
3170
3167
|
// `npx -y thinkpool-pair@latest` on every (re)start, so a clean exit here makes
|
package/package.json
CHANGED
package/serve-consent.mjs
CHANGED
|
@@ -1,81 +1,52 @@
|
|
|
1
1
|
// Room-serve owner consent — decision core (pure) + row fetch.
|
|
2
2
|
//
|
|
3
|
-
// Spec: docs/specs/2026-07-
|
|
4
|
-
// A room is served by its OWNER's bridge
|
|
5
|
-
// the
|
|
6
|
-
//
|
|
3
|
+
// Spec: docs/specs/2026-07-06-remove-cross-person-serve.md (Contract C-CODE-3, evolved).
|
|
4
|
+
// A room is served by its OWNER's bridge ONLY. No cross-person serving, no grantee
|
|
5
|
+
// path (removed 2026-07-06 — the cross-person grant was a recipe for the wrong-session
|
|
6
|
+
// fuckery). The owner's own bridge on a second machine is handled by the per-account
|
|
7
|
+
// claim_bridge, not here. Pairing stays read-only (peer peek across the pair).
|
|
7
8
|
//
|
|
8
9
|
// This module holds ONLY pure, order-independent decisions (unit-tested in
|
|
9
|
-
// bridge/test-serve-consent.mjs) plus a single network helper. The wiring —
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// bridge/test-serve-consent.mjs) plus a single network helper. The wiring — where the
|
|
11
|
+
// gate runs — lives in bridge.mjs. Keeping the logic pure is the
|
|
12
|
+
// feedback_code_seed_order_independent discipline: no decision depends on live
|
|
13
|
+
// channel/session state, so every branch is testable off-wire.
|
|
13
14
|
//
|
|
14
|
-
// LIMITATION (documented
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// LIMITATION (documented): enforcement is in-bridge. A bridge OLDER than this version
|
|
16
|
+
// may still honor a grant — there is no server-side wire-level lock. The set_serve_grant
|
|
17
|
+
// RPC + serve_grant_uid column were dropped server-side in the same 2026-07-06 migration,
|
|
18
|
+
// so even an old bridge can no longer READ a grant (the column is gone) and will refuse
|
|
19
|
+
// as a non-owner. That closes the bypass.
|
|
18
20
|
|
|
19
21
|
// serveDecision — may THIS bridge serve THIS room?
|
|
20
|
-
// ownerId
|
|
21
|
-
// grantUid : code_sessions.serve_grant_uid (uuid | null) — the one granted non-owner
|
|
22
|
+
// ownerId : code_sessions.owner_id (uuid | null)
|
|
22
23
|
// myUid : this bridge's authed user id (uuid | null) — null when the bridge is anon
|
|
23
24
|
// Returns { serve, reason }:
|
|
24
|
-
// reason 'owner' — myUid owns the room (
|
|
25
|
-
// reason '
|
|
26
|
-
// reason '
|
|
27
|
-
// grantee → refuse (the new model has no anon serve path)
|
|
28
|
-
// reason 'not-granted' — authed, but neither owner nor the current grantee → refuse
|
|
25
|
+
// reason 'owner' — myUid owns the room (the only serve path)
|
|
26
|
+
// reason 'no-identity' — bridge has no authed uid → refuse (no anon serve path)
|
|
27
|
+
// reason 'not-owner' — authed, but not the owner → refuse
|
|
29
28
|
// reason 'unknown-room'— no owner_id resolved (row missing / RLS-hidden) → refuse
|
|
30
|
-
export function serveDecision ({ ownerId,
|
|
29
|
+
export function serveDecision ({ ownerId, myUid }) {
|
|
31
30
|
if (!myUid) return { serve: false, reason: 'no-identity' }
|
|
32
31
|
if (!ownerId) return { serve: false, reason: 'unknown-room' }
|
|
33
32
|
if (myUid === ownerId) return { serve: true, reason: 'owner' }
|
|
34
|
-
|
|
35
|
-
return { serve: false, reason: 'not-granted' }
|
|
33
|
+
return { serve: false, reason: 'not-owner' }
|
|
36
34
|
}
|
|
37
35
|
|
|
38
36
|
// supervisorServes — the account supervisor's per-row discovery decision: may this
|
|
39
|
-
// login auto-serve THIS discovered room? True iff the login OWNS the room
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
// gate fetch (Contract C-CODE-2, evolved owner-only → owner-or-granted).
|
|
45
|
-
export function supervisorServes ({ ownerId, grantUid, myUid }) {
|
|
37
|
+
// login auto-serve THIS discovered room? True iff the login OWNS the room. This is the
|
|
38
|
+
// defense-in-depth per-row check behind the supervisor's owner-only `.eq(owner_id, me)`
|
|
39
|
+
// query — a broad RLS row (owned OR merely participant) must still resolve to OWNED
|
|
40
|
+
// before a child is spawned. Contract C-CODE-2 (owner-only).
|
|
41
|
+
export function supervisorServes ({ ownerId, myUid }) {
|
|
46
42
|
if (!myUid) return false
|
|
47
|
-
return ownerId === myUid
|
|
43
|
+
return ownerId === myUid
|
|
48
44
|
}
|
|
49
45
|
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// standDownOnPeerAnnounce — a grantee-served bridge saw ANOTHER bridge announce on
|
|
57
|
-
// the room channel. Yield iff that peer is the OWNER's bridge (peerUid === ownerId)
|
|
58
|
-
// and I am not the owner. The owner's own bridge (myUid === ownerId) never yields —
|
|
59
|
-
// serveDecision already refused any second non-owner, so a peer that isn't the owner
|
|
60
|
-
// is either a stale duplicate of me or nothing to cede to.
|
|
61
|
-
export function standDownOnPeerAnnounce ({ myUid, ownerId, peerUid }) {
|
|
62
|
-
if (!myUid || !ownerId) return false
|
|
63
|
-
if (myUid === ownerId) return false // owner never stands down
|
|
64
|
-
return !!peerUid && peerUid === ownerId // owner's bridge reclaimed → yield
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// standDownOnGrantChange — a grantee re-checked the row and the grant no longer
|
|
68
|
-
// names it (revoked, or reassigned to a third party). Yield. The owner never yields
|
|
69
|
-
// on a grant change (the grant does not govern the owner).
|
|
70
|
-
export function standDownOnGrantChange ({ myUid, ownerId, grantUid }) {
|
|
71
|
-
if (!myUid || myUid === ownerId) return false
|
|
72
|
-
return grantUid !== myUid
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// fetchServeRow — read the room's consent columns with whatever identity the bridge
|
|
76
|
-
// holds (authed token → RLS returns the row if the user is a member; anon → empty).
|
|
77
|
-
// THROWS on a non-OK response or a transport error. The thrown error carries `.authHard`
|
|
78
|
-
// so the caller can tell the two apart (Contract C-CODE-3 invariant 2, hardened 2026-07-05):
|
|
46
|
+
// fetchServeRow — read the room's owner with whatever identity the bridge holds
|
|
47
|
+
// (authed token → RLS returns the row if the user is a member; anon → empty). THROWS on
|
|
48
|
+
// a non-OK response or a transport error. The thrown error carries `.authHard` so the
|
|
49
|
+
// caller can tell the two apart (Contract C-CODE-3 invariant 2, hardened 2026-07-05):
|
|
79
50
|
// - 401/403 (authHard === true) → auth is DEFINITIVELY bad (dead/expired token). The
|
|
80
51
|
// caller REFUSES: a stale access token must never fail OPEN into serving, or the gate
|
|
81
52
|
// is bypassed in the common case (stored tokens expire ~1h). The caller refreshes the
|
|
@@ -88,7 +59,7 @@ export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
|
|
|
88
59
|
const headers = { apikey: anonKey }
|
|
89
60
|
if (token) headers.Authorization = `Bearer ${token}`
|
|
90
61
|
const r = await fetch(
|
|
91
|
-
`${supabaseUrl}/rest/v1/code_sessions?select=owner_id,
|
|
62
|
+
`${supabaseUrl}/rest/v1/code_sessions?select=owner_id,name&code=eq.${encodeURIComponent(room)}`,
|
|
92
63
|
{ headers },
|
|
93
64
|
)
|
|
94
65
|
if (!r.ok) {
|
|
@@ -101,7 +72,6 @@ export async function fetchServeRow ({ supabaseUrl, anonKey, token, room }) {
|
|
|
101
72
|
const row = Array.isArray(rows) ? rows[0] : null
|
|
102
73
|
return {
|
|
103
74
|
ownerId: row?.owner_id ?? null,
|
|
104
|
-
grantUid: row?.serve_grant_uid ?? null,
|
|
105
75
|
name: row?.name ?? null,
|
|
106
76
|
}
|
|
107
77
|
}
|
|
@@ -114,36 +84,13 @@ export function gateFailAction (err) {
|
|
|
114
84
|
return err?.authHard ? 'refuse' : 'fail-open'
|
|
115
85
|
}
|
|
116
86
|
|
|
117
|
-
// ── supervisor grant-reclaim cooldown (DEFECT 2, 2026-07-05) ──────────────────
|
|
118
|
-
// A grantee child that yields to the owner's returning bridge exits with this SENTINEL
|
|
119
|
-
// code (distinct from a clean 0 / a crash 1). The supervisor reads the exit code and puts
|
|
120
|
-
// the room on a cooldown so it does NOT thrash-respawn a child that will only yield again —
|
|
121
|
-
// the feature's INTENDED coexistence state (owner serves, grantee stands by) must not churn.
|
|
122
|
-
// A revoke-yield keeps exit 0 (discovery drops a revoked room anyway); owner-owned rooms
|
|
123
|
-
// never yield, so they never cool down.
|
|
124
|
-
export const GRANT_RECLAIM_EXIT = 75
|
|
125
|
-
|
|
126
|
-
// shouldCooldownOnExit — did this child exit BECAUSE the owner reclaimed the room (75)?
|
|
127
|
-
// Only that exit arms the cooldown; a clean stop (0), a crash (1), or a signal (null) do not.
|
|
128
|
-
export function shouldCooldownOnExit (code) {
|
|
129
|
-
return code === GRANT_RECLAIM_EXIT
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// roomInCooldown — is a room still inside its grant-reclaim cooldown window? The supervisor
|
|
133
|
-
// discovery tick skips re-serving while this is true. `until` is a wall-clock ms deadline
|
|
134
|
-
// (Date.now() + window); a null/expired deadline means serve again (owner may have gone).
|
|
135
|
-
export function roomInCooldown (until, now) {
|
|
136
|
-
return !!(until && now < until)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
87
|
// refusalMessage — the human-facing stderr line when a bridge refuses to serve.
|
|
140
88
|
export function refusalMessage ({ room, name, reason }) {
|
|
141
89
|
const label = name ? `"${name}"` : '(unnamed)'
|
|
142
90
|
if (reason === 'no-identity') {
|
|
143
91
|
return `\n ◇ Refusing to serve room ${room} — this bridge is not signed in.\n` +
|
|
144
|
-
` A room is served by its owner's bridge. Sign in first: npx thinkpool-pair login\n`
|
|
92
|
+
` A room is served only by its owner's bridge. Sign in first: npx thinkpool-pair login\n`
|
|
145
93
|
}
|
|
146
|
-
return `\n ◇ Refusing to serve room ${room} ${label} — it is owned by another person; their bridge serves it.\n` +
|
|
147
|
-
`
|
|
148
|
-
` then re-run. The grant is the only way in — there is no override flag.\n`
|
|
94
|
+
return `\n ◇ Refusing to serve room ${room} ${label} — it is owned by another person; only their bridge serves it.\n` +
|
|
95
|
+
` Pairing gives you a read-only view across the pair, not hosting rights. There is no override.\n`
|
|
149
96
|
}
|