thinkpool-pair 0.7.62 → 0.7.64

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 +66 -14
  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
@@ -191,19 +210,15 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
191
210
  // (code-mockup, whip L16). Refreshed below; pushed to live children on rotation.
192
211
  let currentAccessToken = session.access_token || null
193
212
 
194
- // Owner-gate (Contract C-CODE-2): hosting a code session — spawning the agent and
195
- // burning compute on this machine — requires Pro or Enterprise. This is the layer
196
- // with teeth on cost; the start_code_session RPC gates the web create flow, this
197
- // stops a crafted-row bypass from ever serving. Invited partners don't run a bridge.
198
- let plan = 'free'
199
- try {
200
- const { data } = await sb.from('profiles').select('plan').eq('id', session.user.id).single()
201
- plan = data?.plan || 'free'
202
- } catch { /* transient read — fall through to the gate, fail closed */ }
203
- if (plan !== 'pro' && plan !== 'enterprise') {
204
- process.stderr.write(`\n ◇ ThinkPool Code requires a Pro or Enterprise plan to host a session.\n Your account (${email}) is on the ${plan} plan.\n Upgrade: https://thinkpool.io/pro\n\n (Anyone you invite can still join and steer a Pro host's session for free.)\n`)
205
- process.exit(1)
206
- }
213
+ // No plan-refusal here (Contract C-CODE-2′, spec 2026-06-25-code-free-launch).
214
+ // /code is now a metered free tier, not Pro-only: a free account can legitimately
215
+ // own sessions (within its monthly quota, or during the launch window). The cost
216
+ // brake moved to session CREATION — start_code_session enforces the quota/window
217
+ // server-side, so a row exists iff the owner was entitled to create it. The bridge
218
+ // therefore serves whatever sessions the account legitimately owns (owner-only
219
+ // discovery below, line ~349, still bounds it to the account's OWN rooms). Inference
220
+ // runs on this machine's own Claude login regardless of plan, so hosting costs
221
+ // ThinkPool nothing; there's no cost reason to refuse a free host.
207
222
 
208
223
  const DEFAULT_DIR = process.cwd() // unbound sessions auto-serve here; `bind` overrides per session
209
224
  const svcHint = process.env.THINKPOOL_PAIR_AUTOUPDATE === '1'
@@ -287,7 +302,44 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
287
302
  forceMs: parseInt(process.env.TP_TOKEN_REFRESH_FORCE_MS, 10) || 0,
288
303
  })
289
304
 
305
+ // ── cross-machine single active bridge (INV-2) ──────────────────────────────
306
+ // account.lock guards THIS Mac; this claim guards the ACCOUNT across Macs. We
307
+ // serve rooms ONLY while we hold the claim. A non-holder stands by (stays alive
308
+ // for launchd KeepAlive, serves nothing) and takes over only when the holder's
309
+ // heartbeat goes stale — so two launchd bridges never flap respawning. Two Macs
310
+ // both serving one account is the cross-machine half of the terminal-reconcile
311
+ // reap that lost room XE3IQ's history.
312
+ // Spec: docs/specs/2026-06-25-single-bridge-durable-history.md (INV-2)
313
+ const BRIDGE_ID = randomUUID()
314
+ let claimHeld = false
315
+ let standbyHost = null
316
+ const refreshClaim = async () => {
317
+ try {
318
+ const { data } = await sb.rpc('claim_bridge', { p_bridge_id: BRIDGE_ID, p_hostname: machine })
319
+ const c = Array.isArray(data) ? data[0] : data
320
+ const held = !!c?.held
321
+ const host = c?.holder_host || '?'
322
+ if (held && !claimHeld) process.stderr.write(`\n ◆ holding this account — serving your sessions (single active bridge).\n`)
323
+ else if (!held && claimHeld) process.stderr.write(`\n ◇ another device (${host}) took over this account — standing by.\n`)
324
+ 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`)
325
+ claimHeld = held
326
+ standbyHost = held ? null : host
327
+ return held
328
+ } catch {
329
+ // Transient claim error: keep serving if we already hold; never START
330
+ // serving on an unknown claim (that's the double-serve we're preventing).
331
+ return claimHeld
332
+ }
333
+ }
334
+
290
335
  const tick = async () => {
336
+ // Hold the account claim before serving anything (cross-machine guard). When
337
+ // standing by, make sure we're serving nothing and wait for the next tick.
338
+ if (!(await refreshClaim())) {
339
+ if (children.size) for (const [, ch] of children) { try { ch.kill('SIGTERM') } catch { /* noop */ } }
340
+ pushPresence()
341
+ return
342
+ }
291
343
  let rooms = []
292
344
  try {
293
345
  // Owner-only discovery (Contract C-CODE-2): a host bridges ONLY the sessions
@@ -351,7 +403,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
351
403
  for (const c of children.values()) { try { c.kill(sig || 'SIGTERM') } catch { /* noop */ } }
352
404
  // Flush the presence LEAVE before exiting so the dashboard flips to
353
405
  // "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) })()
406
+ ;(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
407
  setTimeout(() => process.exit(code), 1500) // hard backstop if the flush hangs
356
408
  }
357
409
  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.64",
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": {