thinkpool-pair 0.7.165 → 0.7.167

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 +24 -0
  2. package/bridge.mjs +57 -8
  3. package/package.json +2 -2
package/account.mjs CHANGED
@@ -271,6 +271,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
271
271
  // account without per-room probing. We TRACK this machine (name + served room
272
272
  // codes + version) on a per-account channel; the dashboard subscribes read-only.
273
273
  const machine = os.hostname().replace(/\.local$/, '')
274
+
275
+ // ── supervisor logging (cascade brg-instrument, supervisor-log lane) ─────────
276
+ const SUP_ID = (randomUUID?.() || '').slice(0, 8)
277
+ let lastClaimHeld = null // track state changes for claim_tick logging
278
+ console.log(`◇ supervisor boot sup=${SUP_ID} host=${machine} pid=${process.pid} npx=${VERSION || 'dev'}`)
279
+
274
280
  const acct = sb.channel(`tpacct:${session.user.id}`, { config: { presence: { key: machine }, broadcast: { self: false } } })
275
281
  let restarting = false // set by the "restart" broadcast → stop() exits non-zero so a supervisor respawns us
276
282
  // Web "attach this session" card → bind an unbound/new room to a local dir (and
@@ -463,6 +469,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
463
469
  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
470
  claimHeld = held
465
471
  standbyHost = held ? null : host
472
+
473
+ // ── supervisor claim_tick logging (cascade brg-instrument) ─────────────────
474
+ if (lastClaimHeld !== held) {
475
+ console.log(`claim_tick sup=${SUP_ID} held=${held} holder=${held ? machine : host}`)
476
+ lastClaimHeld = held
477
+ }
478
+
466
479
  return held
467
480
  } catch {
468
481
  // Transient claim error: keep serving if we already hold; never START
@@ -660,7 +673,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
660
673
  delete env.THINKPOOL_PAIR_AUTOUPDATE
661
674
  // Hand the child the owner JWT for authed web writes (code-mockup, L16).
662
675
  if (currentAccessToken) env.TP_ACCESS_TOKEN = currentAccessToken
676
+
677
+ // ── supervisor child_spawn logging (cascade brg-instrument) ─────────────────
678
+ const alreadyHad = children.has(room)
663
679
  const child = spawn(process.execPath, [BRIDGE, room, '--headless', '--auto=claude'], { cwd: dir, stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env })
680
+ console.log(`child_spawn sup=${SUP_ID} room=${room} pid=${child.pid} dir=${dir} had_prior=${alreadyHad}`)
681
+
664
682
  children.set(room, child)
665
683
  childIdle.set(room, false) // unknown until it reports → never restart a fresh child
666
684
  childBetween.set(room, false)
@@ -738,6 +756,9 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
738
756
  }
739
757
  })
740
758
  const dropRoom = (code) => {
759
+ // ── supervisor child_exit logging (cascade brg-instrument) ─────────────────
760
+ console.log(`child_exit sup=${SUP_ID} room=${room} pid=${child?.pid || '?'} code=${code || '?'}`)
761
+
741
762
  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
763
  // Owner-reclaim SENTINEL (75): the grantee child stood down because the owner's bridge
743
764
  // returned. Park the room on a cooldown so discovery doesn't respawn it into another
@@ -790,6 +811,9 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
790
811
  const stop = (sig) => {
791
812
  if (stopping) return; stopping = true
792
813
  const code = restarting ? 1 : 0
814
+
815
+ // ── supervisor stand-down logging (cascade brg-instrument) ─────────────────
816
+ console.log(`supervisor_stop sup=${SUP_ID} reason=${sig || 'unknown'} restarting=${restarting} children=${children.size}`)
793
817
  clearInterval(iv)
794
818
  for (const t of updateTimers) { try { clearInterval(t) } catch { /* noop */ } } // clearInterval clears Timeouts too
795
819
  keepFresh.cancel()
package/bridge.mjs CHANGED
@@ -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
@@ -612,8 +655,14 @@ const isRoomParticipant = async (jwt) => {
612
655
  const { data } = await supabase.auth.getUser(jwt)
613
656
  if (!data?.user) return false
614
657
  const uid = data.user.id
615
- const r = await fetch(`${SUPABASE_URL}/rest/v1/code_sessions?code=eq.${encodeURIComponent(room)}&participants=cs.${uid}`, { headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${codeAuthToken}` } })
616
- return r.ok
658
+ // Query with the CALLER's jwt (not the bridge host's codeAuthToken) so RLS scopes
659
+ // the read to what THIS user may see, and require an actual matching row — `r.ok`
660
+ // alone is true for an empty [] result, which let ANY authenticated user pass.
661
+ // participants is a uuid[]; PostgREST array-contains is cs.{uid} (braces required).
662
+ const r = await fetch(`${SUPABASE_URL}/rest/v1/code_sessions?code=eq.${encodeURIComponent(room)}&participants=cs.%7B${encodeURIComponent(uid)}%7D&select=code`, { headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${jwt}` } })
663
+ if (!r.ok) return false
664
+ const rows = await r.json().catch(() => [])
665
+ return Array.isArray(rows) && rows.length > 0
617
666
  } catch { return false }
618
667
  }
619
668
  // ── Room-serve owner consent (Contract C-CODE-3) ──────────────────────────────
@@ -679,7 +728,7 @@ let yielding = false // set once when standing down, so reclaim + revoke
679
728
  process.exit(0)
680
729
  }
681
730
  iServeAsGrantee = amGrantee({ ownerId, grantUid, myUid: myServeUid })
682
- 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`)
731
+ if (iServeAsGrantee) process.stderr.write(`\n [b:${BRIDGE_ID} r:${room}] ◆ serving room ${room} by the owner's grant — will yield if the owner's bridge returns or the grant is revoked.\n`)
683
732
  } catch (e) {
684
733
  // A THROWN fetchServeRow error is either a definitively-bad auth (401/403) or an
685
734
  // availability failure (5xx / transport reject). Only the latter fails OPEN — a stale
@@ -1000,7 +1049,7 @@ function onPeerBridgeAnnounce (payload) {
1000
1049
  if (yielding) return
1001
1050
  if (!standDownOnPeerAnnounce({ myUid: myServeUid, ownerId: roomOwnerId, peerUid: payload?.uid })) return
1002
1051
  yielding = true
1003
- process.stderr.write(`\n ◆ owner's bridge reconnected — yielding room ${room}.\n`)
1052
+ process.stderr.write(`\n [b:${BRIDGE_ID} r:${room}] ◆ owner's bridge reconnected — yielding room ${room}.\n`)
1004
1053
  // Exit with the grant-reclaim SENTINEL (not a clean 0). A supervisor that spawned this
1005
1054
  // grantee child reads the code and puts the room on a cooldown, so it does NOT respawn a
1006
1055
  // child that will only yield again — the owner-serves/grantee-stands-by coexistence state
@@ -2776,7 +2825,7 @@ channel
2776
2825
  .subscribe(status => {
2777
2826
  if (status === 'SUBSCRIBED') {
2778
2827
  realtimeHealthy = true; brokenSince = 0
2779
- trackPresence({ name, role: 'bridge' })
2828
+ trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID })
2780
2829
  const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
2781
2830
  if (startCmd && !terms.size && !sessions.size) {
2782
2831
  // Claude + structured mode → Agent SDK session; everything else → PTY.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.165",
4
- "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
3
+ "version": "0.7.167",
4
+ "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, \u2026) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "thinkpool-pair": "bridge.mjs"