thinkpool-pair 0.7.317 → 0.7.318

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 CHANGED
@@ -23,6 +23,7 @@ import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
23
23
  import { installedAgentCommands } from './agent-detect.mjs'
24
24
  import { hostMemoryAdmission } from './host-memory.mjs'
25
25
  import { createPairBusBroker, mergePairRoomRoster, pairBusStatusFromRealtime, PAIR_BUS_STATUS } from './pair-bus.mjs'
26
+ import { clearSupervisorReady, supervisorPresenceEchoed, writeSupervisorReady } from './supervisor-ready.mjs'
26
27
 
27
28
  const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
28
29
 
@@ -410,6 +411,10 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
410
411
  console.error('\n ◇ An account bridge is already running on this machine\n (~/.thinkpool-pair/account.lock). Not starting a second — two would race\n your saved login. Stop the other one first, or share a single room with\n `npx thinkpool-pair <ROOM>`.\n')
411
412
  process.exit(0)
412
413
  }
414
+ // We own account.lock now, so any previous readiness receipt is stale. A fresh
415
+ // receipt is written only after the first reconciliation tick AND a server-side
416
+ // presence self-echo prove that this exact supervisor finished bootstrapping.
417
+ clearSupervisorReady()
413
418
  const releaseStartupAnchor = createStartupAnchor()
414
419
  // F2: NEVER exit(1) on a refresh failure. The old code exited 1 here, and launchd
415
420
  // KeepAlive respawned us within seconds → refresh again → the 16× crash storm (and,
@@ -676,7 +681,17 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
676
681
  // channel, not an unknowable state, once the listener was attached before join.
677
682
  let presenceSyncSeenAt = 0
678
683
  let acctSubscribedAt = 0
679
- acct.on('presence', { event: 'sync' }, () => { presenceSyncSeenAt = Date.now() })
684
+ let initialTickDone = false
685
+ let readinessWritten = false
686
+ const publishReadiness = () => {
687
+ if (readinessWritten || !initialTickDone) return
688
+ let present = false
689
+ try { present = supervisorPresenceEchoed(acct.presenceState?.(), machine, BRIDGE_ID, VERSION) } catch { present = false }
690
+ if (!present) return
691
+ readinessWritten = writeSupervisorReady({ version: VERSION, bridgeId: BRIDGE_ID })
692
+ if (!readinessWritten) process.stderr.write('\n ◇ Could not record local supervisor readiness; the bridge remains running but installers will not claim it was confirmed.\n')
693
+ }
694
+ acct.on('presence', { event: 'sync' }, () => { presenceSyncSeenAt = Date.now(); publishReadiness() })
680
695
 
681
696
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
682
697
  // (network blip, or a token swap mid-flight) rejoins the channel and must
@@ -1120,6 +1135,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1120
1135
 
1121
1136
  await tick()
1122
1137
  const iv = setInterval(tick, 15000)
1138
+ initialTickDone = true
1139
+ publishReadiness()
1123
1140
  releaseStartupAnchor()
1124
1141
  const updateTimers = [] // auto-update poll/apply timers — cleared in stop() so none fire post-exit
1125
1142
  let stopping = false
@@ -1199,6 +1216,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1199
1216
  clearInterval(presenceWatch)
1200
1217
  for (const t of updateTimers) { try { clearInterval(t) } catch { /* noop */ } } // clearInterval clears Timeouts too
1201
1218
  keepFresh.cancel()
1219
+ clearSupervisorReady({ pid: process.pid })
1202
1220
  releaseSingleton()
1203
1221
  for (const c of children.values()) { try { c.kill(sig || 'SIGTERM') } catch { /* noop */ } }
1204
1222
  pairBus.close()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.317",
3
+ "version": "0.7.318",
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": {
@@ -89,6 +89,7 @@
89
89
  "terminal-row-reconcile.mjs",
90
90
  "switch-provider.mjs",
91
91
  "service.mjs",
92
+ "supervisor-ready.mjs",
92
93
  "presence.mjs",
93
94
  "account.mjs",
94
95
  "auth-store.mjs",
package/service.mjs CHANGED
@@ -23,6 +23,7 @@ import fs from 'node:fs'
23
23
  import path from 'node:path'
24
24
  import { execSync } from 'node:child_process'
25
25
  import { hostMemoryAdmission } from './host-memory.mjs'
26
+ import { readSupervisorReady, supervisorReadyMatches } from './supervisor-ready.mjs'
26
27
 
27
28
  // Service identity. Account mode has no room → a single stable id so there's
28
29
  // exactly one account service per machine (a second install replaces it).
@@ -306,6 +307,8 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
306
307
  `status=${shq(statusFile)}`,
307
308
  'tmp="$status.tmp.$$"',
308
309
  'ok=0',
310
+ 'live_pid=""',
311
+ 'stable=0',
309
312
  'had_previous=0',
310
313
  'if [ -f "$target_file" ]; then cp "$target_file" "$backup_file" && had_previous=1; fi',
311
314
  'launchctl bootout "$dom/$target" 2>/dev/null || true',
@@ -313,7 +316,7 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
313
316
  'cp "$staged_file" "$target_file"',
314
317
  'launchctl enable "$dom/$target" 2>/dev/null || true',
315
318
  'for i in $(seq 1 30); do launchctl bootstrap "$dom" "$target_file" 2>/dev/null && break; sleep 0.3; done',
316
- 'for i in $(seq 1 60); do if launchctl print "$dom/$target" 2>/dev/null | grep -F -- "$expected" >/dev/null && launchctl print "$dom/$target" 2>/dev/null | grep -F "state = running" >/dev/null; then ok=1; break; fi; sleep 0.5; done',
319
+ 'for i in $(seq 1 60); do snapshot="$(launchctl print "$dom/$target" 2>/dev/null || true)"; pid="$(printf "%s\\n" "$snapshot" | sed -n "s/^[[:space:]]*pid = \\([0-9][0-9]*\\)[[:space:]]*$/\\1/p" | head -1)"; if [ -n "$pid" ] && printf "%s\\n" "$snapshot" | grep -F -- "$expected" >/dev/null && printf "%s\\n" "$snapshot" | grep -F "state = running" >/dev/null; then if [ "$pid" = "$live_pid" ]; then stable=$((stable+1)); else live_pid="$pid"; stable=1; fi; if [ "$stable" -ge 5 ]; then ok=1; break; fi; else live_pid=""; stable=0; fi; sleep 0.5; done',
317
320
  `if [ "$ok" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: true, version: String(version), target: String(targetLabel) })}' > "$tmp"; else rollback=0; if [ "$had_previous" = 1 ]; then launchctl bootout "$dom/$target" 2>/dev/null || true; for i in $(seq 1 40); do launchctl print "$dom/$target" >/dev/null 2>&1 || break; sleep 0.2; done; cp "$backup_file" "$target_file"; launchctl enable "$dom/$target" 2>/dev/null || true; for i in $(seq 1 30); do if launchctl bootstrap "$dom" "$target_file" 2>/dev/null; then rollback=1; break; fi; sleep 0.3; done; fi; if [ "$rollback" = 1 ]; then printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed; previous service restored', rolledBack: true })}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed and rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
318
321
  'mv "$tmp" "$status"',
319
322
  'rm -f "$staged_file" "$backup_file" "$helper_file"',
@@ -332,6 +335,53 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
332
335
  return { helperLabel, helperFile, backupFile, statusFile, logFile, script, content }
333
336
  }
334
337
 
338
+ const accountLockFile = () => path.join(os.homedir(), '.thinkpool-pair', 'account.lock')
339
+ const processAlive = (pid) => {
340
+ try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
341
+ }
342
+
343
+ // A first background install can race a foreground/orphaned ThinkPool bridge that
344
+ // still owns account.lock. The launchd child then exits 0, satisfying a momentary
345
+ // `state = running` probe while KeepAlive correctly declines to restart it. Retire
346
+ // only a PID whose command is demonstrably a packaged ThinkPool bridge; fail closed
347
+ // on PID reuse or an unrelated process.
348
+ export async function retireUnmanagedBridgeLock({
349
+ lockFile = accountLockFile(),
350
+ readLock = () => fs.readFileSync(lockFile, 'utf8'),
351
+ removeLock = () => fs.rmSync(lockFile, { force: true }),
352
+ commandForPid = (pid) => String(execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })),
353
+ alive = processAlive,
354
+ signal = (pid, sig) => process.kill(pid, sig),
355
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
356
+ waitMs = 2500,
357
+ pollMs = 100,
358
+ stderr = process.stderr,
359
+ } = {}) {
360
+ let pid = 0
361
+ try { pid = parseInt(String(readLock()).trim(), 10) || 0 } catch { return true }
362
+ const removeIfSameAndDead = () => {
363
+ if (alive(pid)) return false
364
+ try { if ((parseInt(String(readLock()).trim(), 10) || 0) === pid) removeLock() } catch { /* already gone */ }
365
+ return true
366
+ }
367
+ if (!pid || pid === process.pid || removeIfSameAndDead()) return true
368
+ let command = ''
369
+ try { command = commandForPid(pid) } catch { return removeIfSameAndDead() }
370
+ const packagedBridge = /(?:node_modules[\\/]thinkpool-pair|\.npm[\\/]_npx|\.thinkpool-pair[\\/]runtimes)[\\/][^\n]*bridge\.mjs(?:\s|$)/.test(command)
371
+ if (!packagedBridge) {
372
+ stderr.write(` ⚠ account.lock belongs to PID ${pid}, but it is not a verified thinkpool-pair bridge; refusing to signal an unrelated process.\n`)
373
+ return false
374
+ }
375
+ stderr.write(` ◆ stopping previous thinkpool-pair bridge (PID ${pid}) before installing the background service…\n`)
376
+ try { signal(pid, 'SIGTERM') } catch { if (removeIfSameAndDead()) return true }
377
+ const polls = Math.max(1, Math.ceil(Math.max(0, Number(waitMs) || 0) / Math.max(1, Number(pollMs) || 1)))
378
+ for (let i = 0; i < polls; i++) { if (removeIfSameAndDead()) return true; await sleep(Math.max(1, Number(pollMs) || 1)) }
379
+ try { signal(pid, 'SIGKILL') } catch { /* checked below */ }
380
+ for (let i = 0; i < 5; i++) { if (removeIfSameAndDead()) return true; await sleep(Math.max(1, Number(pollMs) || 1)) }
381
+ stderr.write(` ⚠ previous thinkpool-pair bridge PID ${pid} did not stop; background install aborted to avoid two competing bridges.\n`)
382
+ return false
383
+ }
384
+
335
385
  function startDarwinReloadHandoff(a, version) {
336
386
  const stagedFile = path.join(a.logDir, `service-update-${process.pid}-${Date.now()}.plist`)
337
387
  const h = buildDarwinReloadHandoff({ targetLabel: labelFromFile(a.file), targetFile: a.file, stagedFile, expectedRuntime: a.expectedRuntime, version })
@@ -467,6 +517,8 @@ export async function installAndConfirmService(room, cmdArgs = [], {
467
517
  install = installService,
468
518
  exec = execSync,
469
519
  snapshot = serviceRuntimeSnapshot,
520
+ prepareAccountHandoff = retireUnmanagedBridgeLock,
521
+ readReady = () => readSupervisorReady(),
470
522
  readStatus = () => {
471
523
  try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.thinkpool-pair', 'update-status.json'), 'utf8')) } catch { return null }
472
524
  },
@@ -476,11 +528,20 @@ export async function installAndConfirmService(room, cmdArgs = [], {
476
528
  pollMs = 250,
477
529
  stderr = process.stderr,
478
530
  } = {}) {
531
+ const before = snapshot(room, { platform, exec })
532
+ if (!room && platform !== 'win32' && !before) {
533
+ if (!(await prepareAccountHandoff())) {
534
+ stderr.write(' ⚠ background install aborted: the previous bridge could not be handed off safely.\n')
535
+ return false
536
+ }
537
+ }
538
+ const confirmationStartedAt = now()
479
539
  if (install(room, cmdArgs, { version, staleProof }) === false) return false
480
540
 
481
541
  const confirmation = () => {
482
542
  const live = snapshot(room, { platform, exec })
483
- if (live?.version === version) {
543
+ const ready = !!room || platform !== 'darwin' || supervisorReadyMatches(readReady(), live, version, { notBefore: confirmationStartedAt })
544
+ if (live?.version === version && ready) {
484
545
  stderr.write(` ✓ bridge install confirmed — running v${version}.\n`)
485
546
  return true
486
547
  }
@@ -508,7 +569,7 @@ export async function installAndConfirmService(room, cmdArgs = [], {
508
569
  if (status.ok === true && confirmation()) return true
509
570
  }
510
571
  if (now() >= deadline) {
511
- stderr.write(` ⚠ install v${version} timed out waiting for launchd confirmation; no running service was proven.\n`)
572
+ stderr.write(` ⚠ install v${version} timed out waiting for supervisor readiness; no connected account bridge was proven.\n`)
512
573
  return false
513
574
  }
514
575
  await sleep(Math.max(1, Number(pollMs) || 1))
@@ -609,6 +670,7 @@ export async function updateAndConfirmService(room, {
609
670
  install = installService,
610
671
  active = isServiceInstalled,
611
672
  snapshot = serviceRuntimeSnapshot,
673
+ readReady = () => readSupervisorReady(),
612
674
  readStatus = () => {
613
675
  try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.thinkpool-pair', 'update-status.json'), 'utf8')) } catch { return null }
614
676
  },
@@ -619,6 +681,7 @@ export async function updateAndConfirmService(room, {
619
681
  stderr = process.stderr,
620
682
  } = {}) {
621
683
  const before = snapshot(room, { platform, exec })
684
+ const confirmationStartedAt = now()
622
685
  let target = null
623
686
  const captureTarget = (...args) => {
624
687
  const result = exec(...args)
@@ -636,7 +699,8 @@ export async function updateAndConfirmService(room, {
636
699
  const live = snapshot(room, { platform, exec })
637
700
  const versionChanged = !!before && before.version !== target
638
701
  const processChanged = !!before?.pid && !!live?.pid && before.pid !== live.pid
639
- if (live?.version === target && (versionChanged || processChanged)) {
702
+ const ready = !!room || platform !== 'darwin' || supervisorReadyMatches(readReady(), live, target, { notBefore: confirmationStartedAt })
703
+ if (live?.version === target && (versionChanged || processChanged) && ready) {
640
704
  stderr.write(` ✓ bridge restart confirmed — running v${target}.\n`)
641
705
  return { ok: true, live }
642
706
  }
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ export const ACCOUNT_READY_FILE = path.join(os.homedir(), '.thinkpool-pair', 'account-ready.json')
6
+
7
+ export function readSupervisorReady(file = ACCOUNT_READY_FILE, { fsMod = fs } = {}) {
8
+ try {
9
+ const value = JSON.parse(fsMod.readFileSync(file, 'utf8'))
10
+ if (!value || typeof value !== 'object') return null
11
+ return value
12
+ } catch { return null }
13
+ }
14
+
15
+ export function supervisorReadyMatches(receipt, live, version, { notBefore = 0 } = {}) {
16
+ return !!receipt && !!live?.pid &&
17
+ String(receipt.pid) === String(live.pid) &&
18
+ receipt.version === version &&
19
+ typeof receipt.bridgeId === 'string' && receipt.bridgeId.length > 0 &&
20
+ Number.isFinite(receipt.readyAt) && receipt.readyAt >= notBefore
21
+ }
22
+
23
+ // Supabase presence is keyed by hostname, so the key can outlive a crashed
24
+ // process briefly. Require the unique process bridgeId inside the echoed metas;
25
+ // hostname existence alone can certify the previous supervisor's stale presence.
26
+ export function supervisorPresenceEchoed(state, machine, bridgeId, version) {
27
+ if (!state || typeof state !== 'object' || typeof machine !== 'string' || !machine || typeof bridgeId !== 'string' || !bridgeId) return false
28
+ const metas = Array.isArray(state[machine]) ? state[machine] : []
29
+ return metas.some((meta) => meta?.bridgeId === bridgeId && meta?.version === version)
30
+ }
31
+
32
+ export function writeSupervisorReady({ pid = process.pid, version, bridgeId, readyAt = Date.now() }, file = ACCOUNT_READY_FILE, { fsMod = fs } = {}) {
33
+ if (!Number.isInteger(Number(pid)) || Number(pid) <= 0 || typeof version !== 'string' || !version || typeof bridgeId !== 'string' || !bridgeId) return false
34
+ const tmp = `${file}.tmp.${process.pid}.${Date.now()}`
35
+ try {
36
+ fsMod.mkdirSync(path.dirname(file), { recursive: true })
37
+ fsMod.writeFileSync(tmp, `${JSON.stringify({ pid: Number(pid), version, bridgeId, readyAt })}\n`, { mode: 0o600 })
38
+ fsMod.renameSync(tmp, file)
39
+ return true
40
+ } catch {
41
+ try { fsMod.rmSync(tmp, { force: true }) } catch { /* noop */ }
42
+ return false
43
+ }
44
+ }
45
+
46
+ // Remove an owned receipt on shutdown. With no pid, clear stale startup evidence
47
+ // after this process has acquired the singleton lock and therefore owns account mode.
48
+ export function clearSupervisorReady({ pid = null, file = ACCOUNT_READY_FILE, fsMod = fs } = {}) {
49
+ try {
50
+ if (pid !== null) {
51
+ const receipt = readSupervisorReady(file, { fsMod })
52
+ if (!receipt || String(receipt.pid) !== String(pid)) return false
53
+ }
54
+ fsMod.rmSync(file, { force: true })
55
+ return true
56
+ } catch { return false }
57
+ }