thinkpool-pair 0.7.173 → 0.7.175

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 (2) hide show
  1. package/account.mjs +35 -6
  2. package/package.json +1 -1
package/account.mjs CHANGED
@@ -386,9 +386,21 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
386
386
  // ThinkPool nothing; there's no cost reason to refuse a free host.
387
387
 
388
388
  const DEFAULT_DIR = process.cwd() // unbound sessions auto-serve here; `bind` overrides per session
389
+ // Ground truth for "am I the managed service": ask the service manager, not the
390
+ // THINKPOOL_PAIR_AUTOUPDATE env proxy. Since pinned installs became the default,
391
+ // the plist/unit sets AUTOUPDATE only when --auto-update was passed — the env var
392
+ // now means "track @latest", NOT "I'm the service". Testing the env alone made a
393
+ // pinned managed service (a) announce service:false → dashboard says "foreground",
394
+ // and (b) skip the uninstall on a dashboard Kill → launchd respawns the "killed"
395
+ // bridge ~2s later (the 2026-06-18 zombie-toggle, reintroduced by pinning).
396
+ let isManagedService = false
397
+ try { const svc = await import('./service.mjs'); isManagedService = svc.serviceActive(null) } catch { /* not installed */ }
398
+ const runsAsService = () => process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' || isManagedService
389
399
  const svcHint = process.env.THINKPOOL_PAIR_AUTOUPDATE === '1'
390
400
  ? ' (auto-updating background service — survives reboot, tracks @latest)\n'
391
- : ' (run hands-off + auto-updating: npx thinkpool-pair install-service)\n'
401
+ : isManagedService
402
+ ? ' (background service — survives reboot, restarts on crash, pinned to this version)\n'
403
+ : ' (run hands-off + auto-updating: npx thinkpool-pair install-service)\n'
392
404
  const _effDefault = loadDefaultDir() || claudeRecentProjectDir() || DEFAULT_DIR
393
405
  process.stderr.write(`\n ◆ thinkpool-pair — account mode · ${email}\n New sessions auto-serve from ${_effDefault}${loadDefaultDir() ? ' (your default)' : (claudeRecentProjectDir() ? ' (your most-recent Claude Code project)' : '')}\n (change it: npx thinkpool-pair set-default-dir <dir> · one room: npx thinkpool-pair bind <code> <dir>)\n${svcHint}`)
394
406
 
@@ -463,7 +475,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
463
475
  // restart), force the process down and release the lock so a fresh bridge
464
476
  // can take over. NOT unref'd: it must keep the loop alive until it fires.
465
477
  setTimeout(() => { try { releaseSingleton() } catch { /* noop */ } process.exit(0) }, 2500)
466
- if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1') {
478
+ if (runsAsService()) {
467
479
  try { const svc = await import('./service.mjs'); svc.uninstallService(null) } catch { /* noop */ }
468
480
  }
469
481
  process.kill(process.pid, 'SIGTERM')
@@ -493,7 +505,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
493
505
  // Unsupervised FOREGROUND bridge (no launchd/systemd) → re-spawn ourselves DETACHED
494
506
  // after releasing the lock, so "Restart" isn't a footgun that never comes back
495
507
  // (2026-06-25). A supervised bridge is respawned by its supervisor on exit(1).
496
- if (process.env.THINKPOOL_PAIR_AUTOUPDATE !== '1') {
508
+ if (!runsAsService()) {
497
509
  try { spawn(process.execPath, [process.argv[1]], { detached: true, stdio: 'ignore', env: process.env }).unref() } catch { /* noop */ }
498
510
  }
499
511
  process.exit(1)
@@ -531,8 +543,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
531
543
  const trackPresence = makeThrottledTrack(acct, { minMs: 5000, onStatus: (s) => { lastTrackStatus = s } })
532
544
  // `service` lets the dashboard show whether this bridge is the durable installed
533
545
  // service (always-on, survives reboot) vs a foreground `npx` run that dies with its
534
- // terminal. THINKPOOL_PAIR_AUTOUPDATE=1 is set ONLY by the install-service plist/unit
535
- // (service.mjs), so it's the reliable proxy for "running as the managed service".
546
+ // terminal — runsAsService() above (service-manager ground truth, not the env proxy).
536
547
  // providerPubKey: the bridge's RSA-OAEP public key (SPKI base64), generated +
537
548
  // persisted 0600 on first read. The dashboard imports it to SEAL a provider's API
538
549
  // key so it reaches the bridge without ever crossing the wire in the clear. Computed
@@ -541,7 +552,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
541
552
  // to spawn against — re-read each announce so an add/remove reflects immediately.
542
553
  // Both are additive; older clients ignore them (feature: multi-provider BYOK, slice 1).
543
554
  const PROVIDER_PUBKEY = publicKeyB64()
544
- const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: process.env.THINKPOOL_PAIR_AUTOUPDATE === '1', providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), ts: Date.now() })
555
+ const pushPresence = () => trackPresence({ name: machine, version: VERSION, rooms: [...children.keys()], refused: [...refused].map(([code, reason]) => ({ code, reason })), service: runsAsService(), providerPubKey: PROVIDER_PUBKEY, providers: announceProviders(), ts: Date.now() })
545
556
 
546
557
  // ── Provider registry — the multi-BYOK wire contract (slice 1). ─────────────
547
558
  // AUTH: the account channel `tpacct:<uid>` is created WITHOUT config.private:true,
@@ -583,6 +594,18 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
583
594
  provReply('providers-list-res', { nonce, ok: true, providers: listProviders() })
584
595
  })
585
596
 
597
+ // ENABLE presence delivery + the self-echo's ground truth. supabase-js only asks
598
+ // the server for presence state when a presence callback is bound at join time —
599
+ // with no listener, presenceState() stays {} FOREVER even while our own track()
600
+ // lands fine. That blind spot made the 0.7.174 self-echo watchdog false-positive
601
+ // respawn the supervisor every ~2.5 min ("the bridge disconnects and reconnects
602
+ // randomly", 2026-07-09 — nine respawns in an hour, track=ok on every one).
603
+ // Binding sync does two jobs: turns presence delivery ON (the echo becomes real)
604
+ // and stamps presenceSyncSeenAt — the watchdog's fail-safe (no proven delivery →
605
+ // no escalation, ever). Proof: /tmp echo-proof (no listener → {}, listener → key).
606
+ let presenceSyncSeenAt = 0
607
+ acct.on('presence', { event: 'sync' }, () => { presenceSyncSeenAt = Date.now() })
608
+
586
609
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
587
610
  // (network blip, or a token swap mid-flight) rejoins the channel and must
588
611
  // re-announce, or the dashboard would read "no bridge" until the next restart.
@@ -1009,6 +1032,12 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1009
1032
  const presenceWatch = setInterval(() => {
1010
1033
  if (stopping) return
1011
1034
  const now = Date.now()
1035
+ // FAIL-SAFE: escalation requires PROVEN presence delivery on this channel (≥1
1036
+ // sync ever received). An empty presenceState() without it means WE can't see —
1037
+ // not that the track failed; respawning on that is the 0.7.174 false-positive
1038
+ // loop. A real zombie (the bug this watchdog exists for) still escalates:
1039
+ // delivery was proven earlier, then our key vanished from the server's state.
1040
+ if (!presenceSyncSeenAt) { selfEchoMissingSince = 0; return }
1012
1041
  let present = false
1013
1042
  try { present = !!acct.presenceState?.()[machine] } catch { present = false }
1014
1043
  if (present) { selfEchoMissingSince = 0; presenceRecycledForMissing = 0; return }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.173",
3
+ "version": "0.7.175",
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": {