thinkpool-pair 0.7.62 → 0.7.63

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 +57 -1
  2. package/bridge.mjs +19 -0
  3. package/package.json +1 -1
package/account.mjs CHANGED
@@ -61,6 +61,25 @@ export function releaseSingleton(lockPath = LOCK) {
61
61
  try { if ((parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10) || 0) === process.pid) fs.rmSync(lockPath, { force: true }) } catch { /* noop */ }
62
62
  }
63
63
 
64
+ // ── machine-wide single-bridge lock ─────────────────────────────────────────
65
+ // The SAME pidfile is now the machine-wide "one live bridge" lock, not just the
66
+ // account-supervisor guard: a standalone `npx thinkpool-pair <ROOM>` takes it too
67
+ // (see bridge.mjs, post-`--supervise`). Two bridges serving one room race its
68
+ // terminal reconcile, which reaps + CASCADE-deletes terminal history (the XE3IQ
69
+ // "flat search" loss, 2026-06-25). Account mode and standalone room mode share
70
+ // this lock; an account-spawned room child skips it because its parent
71
+ // supervisor already holds it. Wrappers so bridge.mjs need not know the path.
72
+ export function acquireMachineLock(lockPath = LOCK) { return acquireSingleton(lockPath) }
73
+ export function releaseMachineLock(lockPath = LOCK) { return releaseSingleton(lockPath) }
74
+
75
+ // A served bridge process must hold the machine lock UNLESS it's a room child the
76
+ // account supervisor spawned (THINKPOOL_PAIR_ACCOUNT_CHILD=1) — the parent holds
77
+ // it, and a child fighting its parent for the lock would refuse itself and break
78
+ // account mode. Pure (env in) so the policy is unit-testable.
79
+ export function shouldHoldMachineLock(env = process.env) {
80
+ return env.THINKPOOL_PAIR_ACCOUNT_CHILD !== '1'
81
+ }
82
+
64
83
  // ── login: OAuth-style DEVICE-CODE flow (hardened) ──────────────────────────
65
84
  // The bridge asks Postgres (start_device_code) for a secret device_code + a short
66
85
  // human user_code, prints the user_code + URL, then POLLS claim_device_code with
@@ -287,7 +306,44 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
287
306
  forceMs: parseInt(process.env.TP_TOKEN_REFRESH_FORCE_MS, 10) || 0,
288
307
  })
289
308
 
309
+ // ── cross-machine single active bridge (INV-2) ──────────────────────────────
310
+ // account.lock guards THIS Mac; this claim guards the ACCOUNT across Macs. We
311
+ // serve rooms ONLY while we hold the claim. A non-holder stands by (stays alive
312
+ // for launchd KeepAlive, serves nothing) and takes over only when the holder's
313
+ // heartbeat goes stale — so two launchd bridges never flap respawning. Two Macs
314
+ // both serving one account is the cross-machine half of the terminal-reconcile
315
+ // reap that lost room XE3IQ's history.
316
+ // Spec: docs/specs/2026-06-25-single-bridge-durable-history.md (INV-2)
317
+ const BRIDGE_ID = randomUUID()
318
+ let claimHeld = false
319
+ let standbyHost = null
320
+ const refreshClaim = async () => {
321
+ try {
322
+ const { data } = await sb.rpc('claim_bridge', { p_bridge_id: BRIDGE_ID, p_hostname: machine })
323
+ const c = Array.isArray(data) ? data[0] : data
324
+ const held = !!c?.held
325
+ const host = c?.holder_host || '?'
326
+ if (held && !claimHeld) process.stderr.write(`\n ◆ holding this account — serving your sessions (single active bridge).\n`)
327
+ else if (!held && claimHeld) process.stderr.write(`\n ◇ another device (${host}) took over this account — standing by.\n`)
328
+ 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`)
329
+ claimHeld = held
330
+ standbyHost = held ? null : host
331
+ return held
332
+ } catch {
333
+ // Transient claim error: keep serving if we already hold; never START
334
+ // serving on an unknown claim (that's the double-serve we're preventing).
335
+ return claimHeld
336
+ }
337
+ }
338
+
290
339
  const tick = async () => {
340
+ // Hold the account claim before serving anything (cross-machine guard). When
341
+ // standing by, make sure we're serving nothing and wait for the next tick.
342
+ if (!(await refreshClaim())) {
343
+ if (children.size) for (const [, ch] of children) { try { ch.kill('SIGTERM') } catch { /* noop */ } }
344
+ pushPresence()
345
+ return
346
+ }
291
347
  let rooms = []
292
348
  try {
293
349
  // Owner-only discovery (Contract C-CODE-2): a host bridges ONLY the sessions
@@ -351,7 +407,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
351
407
  for (const c of children.values()) { try { c.kill(sig || 'SIGTERM') } catch { /* noop */ } }
352
408
  // Flush the presence LEAVE before exiting so the dashboard flips to
353
409
  // "not connected" in realtime (don't fire-and-forget the untrack).
354
- ;(async () => { try { await acct.untrack() } catch { /* noop */ } try { await sb.removeChannel(acct) } catch { /* noop */ } process.exit(code) })()
410
+ ;(async () => { try { if (claimHeld) await sb.rpc('release_bridge', { p_bridge_id: BRIDGE_ID }) } catch { /* noop */ } try { await acct.untrack() } catch { /* noop */ } try { await sb.removeChannel(acct) } catch { /* noop */ } process.exit(code) })()
355
411
  setTimeout(() => process.exit(code), 1500) // hard backstop if the flush hangs
356
412
  }
357
413
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => stop(sig))
package/bridge.mjs CHANGED
@@ -269,6 +269,25 @@ if (_superOwn.includes('--supervise') || _superOwn.includes('--keep-alive')) {
269
269
  await new Promise(() => {}) // block forever; the child IS the bridge
270
270
  }
271
271
 
272
+ // ── machine-wide single-bridge lock ─────────────────────────────────────────
273
+ // Reached only by the ACTUAL serving process: a direct run, the supervised child
274
+ // (the `--supervise` wrapper above blocks forever at line ~269 and never gets
275
+ // here), or an account-spawned room child. Only one live bridge per machine —
276
+ // a second standalone bridge serving the same rooms races their terminal
277
+ // reconcile, which reaps + CASCADE-deletes terminal history (the XE3IQ "flat
278
+ // search" loss, 2026-06-25). Account children skip the lock (parent holds it).
279
+ // Acquiring HERE — not in the supervisor wrapper — keeps a respawn able to
280
+ // re-acquire and stops the wrapper fighting its own child.
281
+ // Spec: docs/specs/2026-06-25-single-bridge-durable-history.md
282
+ {
283
+ const { acquireMachineLock, releaseMachineLock, shouldHoldMachineLock } = await import('./account.mjs')
284
+ if (shouldHoldMachineLock(process.env) && !acquireMachineLock()) {
285
+ console.error('\n ◇ A thinkpool-pair bridge is already running on this machine\n (~/.thinkpool-pair/account.lock). Only one can be active, and the running\n one already serves every room you are in — use it, or stop it first.\n (If you are certain none is running, delete that stale lock file.)\n')
286
+ process.exit(1)
287
+ }
288
+ if (shouldHoldMachineLock(process.env)) process.on('exit', () => { try { releaseMachineLock() } catch { /* noop */ } })
289
+ }
290
+
272
291
  const headless = argv.includes('--headless')
273
292
  // Account-mode children pass --auto=<agent> so a served session opens a live
274
293
  // terminal automatically (headless, no TTY/picker) instead of an empty room.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.62",
3
+ "version": "0.7.63",
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": {