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.
- package/account.mjs +35 -6
- 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
|
-
:
|
|
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 (
|
|
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 (
|
|
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
|
|
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:
|
|
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 }
|