thinkpool-pair 0.7.318 → 0.7.319

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
@@ -15,7 +15,7 @@ import { createClient } from '@supabase/supabase-js'
15
15
  import os from 'node:os'
16
16
  import { saveAuth, loadAuth, markAuthReconnectRequired, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
17
17
  import { isSafeToRestart } from './update-gate.mjs'
18
- import { makeThrottledTrack, presenceSelfEchoObservation } from './presence.mjs'
18
+ import { makeThrottledTrack, presenceRecoveryEligible, presenceSelfEchoObservation, reducePresenceChannelEvidence } from './presence.mjs'
19
19
  import { publicKeyB64, announceProviders, listProviders, addProvider, addProviderModel, removeProvider, unseal } from './providers.mjs'
20
20
  import { supervisorServes, supervisorRoomsToStop } from './serve-consent.mjs'
21
21
  import { resolveServeDir } from './serve-dir.mjs'
@@ -679,8 +679,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
679
679
  // and stamps presenceSyncSeenAt. acctSubscribedAt bounds the startup case where
680
680
  // SUBSCRIBED fires but the first sync never arrives: that is a broken Realtime
681
681
  // channel, not an unknowable state, once the listener was attached before join.
682
- let presenceSyncSeenAt = 0
683
- let acctSubscribedAt = 0
682
+ let presenceEvidence = reducePresenceChannelEvidence()
684
683
  let initialTickDone = false
685
684
  let readinessWritten = false
686
685
  const publishReadiness = () => {
@@ -691,14 +690,24 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
691
690
  readinessWritten = writeSupervisorReady({ version: VERSION, bridgeId: BRIDGE_ID })
692
691
  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
692
  }
694
- acct.on('presence', { event: 'sync' }, () => { presenceSyncSeenAt = Date.now(); publishReadiness() })
693
+ acct.on('presence', { event: 'sync' }, () => {
694
+ presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, 'sync')
695
+ publishReadiness()
696
+ })
695
697
 
696
698
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
697
699
  // (network blip, or a token swap mid-flight) rejoins the channel and must
698
700
  // re-announce, or the dashboard would read "no bridge" until the next restart.
699
701
  await new Promise((res) => acct.subscribe((st) => {
700
- if (st === 'SUBSCRIBED') { if (!acctSubscribedAt) acctSubscribedAt = Date.now(); pushPresence(); res() }
702
+ if (st === 'SUBSCRIBED') {
703
+ presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, st)
704
+ pushPresence(); res()
705
+ }
701
706
  else if (st === 'CLOSED' || st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
707
+ // Invalidate the old generation before inspecting presenceState again. The
708
+ // channel retains a local self key across failures; accepting that cache is
709
+ // what left the managed service zombified while a fresh foreground run worked.
710
+ presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, st)
702
711
  // Realtime dropped (the 2026-07-08 `realtime CLOSED` blip). The socket normally
703
712
  // auto-reconnects, but nudge it so presence — and the claim heartbeat that shares
704
713
  // this client — recover instead of the loop standing by against its own stale row.
@@ -1177,12 +1186,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1177
1186
  const presenceWatch = setInterval(() => {
1178
1187
  if (stopping) return
1179
1188
  const now = Date.now()
1189
+ const recoveryEligible = presenceRecoveryEligible({ claimHeld, lastClaimOkAt, now })
1180
1190
  let present = false
1181
1191
  try { present = !!acct.presenceState?.()[machine] } catch { present = false }
1182
1192
  const observation = presenceSelfEchoObservation({
1183
- tracking: claimHeld,
1184
- subscribedAt: acctSubscribedAt,
1185
- syncSeenAt: presenceSyncSeenAt,
1193
+ // A fresh HTTP claim distinguishes a Realtime-only zombie from a full
1194
+ // connectivity outage. In the latter case, wait in-process; a respawn cannot
1195
+ // restore the network and launchd would otherwise loop indefinitely.
1196
+ tracking: recoveryEligible,
1197
+ subscribedAt: Math.max(presenceEvidence.subscribedAt, lastClaimOkAt),
1198
+ syncSeenAt: presenceEvidence.syncSeenAt,
1186
1199
  present,
1187
1200
  missingSince: selfEchoMissingSince,
1188
1201
  now,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.318",
3
+ "version": "0.7.319",
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": {
package/presence.mjs CHANGED
@@ -57,6 +57,40 @@ export function makeThrottledTrack(channel, { minMs = 5000, onStatus = null } =
57
57
  }
58
58
  }
59
59
 
60
+ // A RealtimeChannel keeps its last presenceState() locally. After CHANNEL_ERROR /
61
+ // CLOSED that cache can still contain our own key even though the server (and every
62
+ // dashboard subscriber) has already removed it. Treat every subscribe/error edge as
63
+ // a new evidence generation: only a presence sync delivered in that generation may
64
+ // certify the cached self key.
65
+ export function reducePresenceChannelEvidence(state = {}, event, now = Date.now()) {
66
+ const current = {
67
+ subscribedAt: Number(state.subscribedAt) || 0,
68
+ syncSeenAt: Number(state.syncSeenAt) || 0,
69
+ }
70
+ if (event === 'sync') return { ...current, syncSeenAt: now }
71
+ if (event === 'SUBSCRIBED') {
72
+ // A reconnect loop may report SUBSCRIBED repeatedly without ever delivering
73
+ // presence sync. Preserve the first unsynced boundary so retries cannot keep
74
+ // pushing the watchdog deadline out forever.
75
+ if (current.subscribedAt && !current.syncSeenAt) return current
76
+ return { subscribedAt: now, syncSeenAt: 0 }
77
+ }
78
+ if (event === 'CLOSED' || event === 'CHANNEL_ERROR' || event === 'TIMED_OUT') {
79
+ // Likewise, repeated error callbacks are one failure episode until a fresh
80
+ // sync proves recovery.
81
+ if (current.subscribedAt && !current.syncSeenAt) return current
82
+ return { subscribedAt: now, syncSeenAt: 0 }
83
+ }
84
+ return current
85
+ }
86
+
87
+ // Recycling a websocket is useful only when plain HTTP is healthy enough to prove
88
+ // this is a Realtime-only wedge. During a full network/Supabase outage, respawning a
89
+ // launchd service cannot help and merely creates a restart storm.
90
+ export function presenceRecoveryEligible({ claimHeld, lastClaimOkAt, now, maxClaimSilenceMs = 40_000 }) {
91
+ return !!claimHeld && Number.isFinite(lastClaimOkAt) && (now - lastClaimOkAt) < maxClaimSilenceMs
92
+ }
93
+
60
94
  /* ─────────────────────────────────────────────────────────────
61
95
  presenceSelfEchoVerdict — the pure trigger for the account
62
96
  supervisor's presence self-echo watchdog (2026-07-08 recurrence).