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.
- package/account.mjs +57 -1
- package/bridge.mjs +19 -0
- 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.
|