thinkpool-pair 0.7.320 → 0.7.321

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, presenceRecoveryEligible, presenceSelfEchoObservation, reducePresenceChannelEvidence } from './presence.mjs'
18
+ import { makeThrottledTrack, presenceRecoveryEligible, presenceSelfEchoObservation, recoveryBackoffMs, reducePresenceChannelEvidence, subscribeWithoutStartupDeadlock } 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'
@@ -565,27 +565,43 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
565
565
  // just stops — restart is meaningful for the always-on bridge. Old bridges (pre-0.7.60)
566
566
  // don't subscribe to this event, so the dashboard button is a harmless no-op there.
567
567
  let restartPreparationRunning = false
568
- acct.on('broadcast', { event: 'restart' }, ({ payload } = {}) => {
568
+ acct.on('broadcast', { event: 'restart' }, async ({ payload } = {}) => {
569
+ const nonce = payload?.nonce
570
+ const reply = async (ok, error) => {
571
+ try { await acct.send({ type: 'broadcast', event: 'restart-status', payload: { nonce, ok, error } }) } catch { /* channel down */ }
572
+ }
573
+ if (!(await isOwner(payload?.jwt))) {
574
+ await reply(false, 'unauthorized')
575
+ return
576
+ }
569
577
  // A dashboard restart is an EXPLICIT apply request, but it is not permission
570
578
  // to interrupt a live turn or a pending human decision. Stage the immutable
571
579
  // runtime while work continues; applyIfIdle() owns the only destructive edge.
572
- applyRequested = true
573
- if (restartPreparationRunning || applyingUpdate) return
580
+ if (restartPreparationRunning || applyingUpdate) {
581
+ await reply(false, 'a bridge update is already being prepared')
582
+ return
583
+ }
574
584
  restartPreparationRunning = true
575
- ;(async () => {
576
- try {
577
- const svc = await import('./service.mjs')
578
- const staged = svc.serviceActive(null) ? svc.stageServiceUpdate(null) : null
579
- // The dashboard's advertised version is only a fallback marker. The host
580
- // independently resolves and verifies npm above before applying a service.
581
- pendingUpdate = staged || (typeof payload?.version === 'string' ? payload.version : VERSION) || 'restart'
582
- } catch {
583
- pendingUpdate = (typeof payload?.version === 'string' ? payload.version : VERSION) || 'restart'
584
- } finally {
585
- restartPreparationRunning = false
585
+ try {
586
+ const svc = await import('./service.mjs')
587
+ const managed = svc.serviceActive(null)
588
+ const staged = managed ? svc.stageServiceUpdate(null) : null
589
+ if (managed && !staged) {
590
+ await reply(false, 'the published runtime could not be staged; the existing bridge was left running')
591
+ return
586
592
  }
587
- void applyIfIdle()
588
- })()
593
+ // The dashboard's advertised version is only a fallback marker for a
594
+ // foreground supervisor. Managed hosts independently resolve and verify npm.
595
+ pendingUpdate = staged || (typeof payload?.version === 'string' ? payload.version : VERSION) || 'restart'
596
+ applyRequested = true
597
+ await reply(true)
598
+ } catch (error) {
599
+ await reply(false, `restart preparation failed: ${error?.message || error}`)
600
+ return
601
+ } finally {
602
+ restartPreparationRunning = false
603
+ }
604
+ void applyIfIdle()
589
605
  })
590
606
  // Throttled so a realtime reconnect storm can't machine-gun track() into the
591
607
  // per-client presence rate limit (ClientPresenceRateLimitReached → channel closed
@@ -699,10 +715,10 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
699
715
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
700
716
  // (network blip, or a token swap mid-flight) rejoins the channel and must
701
717
  // re-announce, or the dashboard would read "no bridge" until the next restart.
702
- await new Promise((res) => acct.subscribe((st) => {
718
+ const initialAccountRealtime = await subscribeWithoutStartupDeadlock(acct, (st) => {
703
719
  if (st === 'SUBSCRIBED') {
704
720
  presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, st)
705
- pushPresence(); res()
721
+ pushPresence()
706
722
  }
707
723
  else if (st === 'CLOSED' || st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
708
724
  // Invalidate the old generation before inspecting presenceState again. The
@@ -715,7 +731,10 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
715
731
  // The callback stays wired for the channel's life, so later rejoins re-fire it.
716
732
  try { sb.realtime?.connect?.() } catch { /* noop */ }
717
733
  }
718
- }))
734
+ })
735
+ if (!initialAccountRealtime.connected) {
736
+ process.stderr.write(`\n ◇ account realtime not ready at startup (${initialAccountRealtime.status}) — staying alive and recovering in process.\n`)
737
+ }
719
738
 
720
739
  // Keep the account JWT fresh so the presence socket never gets deauthed at
721
740
  // expiry (the 2026-06-17 "No bridge connected while sessions run" bug). On each
@@ -1159,13 +1178,26 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1159
1178
  // wedged tick can't starve it: when the claim has been quiet ≥2 ticks it nudges realtime
1160
1179
  // to reconnect, force-clears the re-entrancy guard, and re-issues the heartbeat directly
1161
1180
  // (claim_bridge is a plain HTTP RPC — it recovers even while the socket is still flapping).
1181
+ let claimRecoveryAttempt = 0
1182
+ let claimRecoveryNextAt = 0
1183
+ let claimRecoveryInFlight = false
1162
1184
  const heartbeatWatch = setInterval(async () => {
1163
1185
  if (stopping) return
1164
- if (!claimLoopWedged({ lastClaimOkAt, now: Date.now() })) return
1165
- console.log(`claim_watchdog sup=${SUP_ID} quiet_ms=${Date.now() - lastClaimOkAt} — reconnecting realtime + re-claiming`)
1186
+ const now = Date.now()
1187
+ if (!claimLoopWedged({ lastClaimOkAt, now })) {
1188
+ claimRecoveryAttempt = 0; claimRecoveryNextAt = 0
1189
+ return
1190
+ }
1191
+ if (claimRecoveryInFlight || now < claimRecoveryNextAt) return
1192
+ claimRecoveryInFlight = true
1193
+ const waitMs = recoveryBackoffMs(claimRecoveryAttempt)
1194
+ claimRecoveryNextAt = now + waitMs
1195
+ claimRecoveryAttempt += 1
1196
+ console.log(`claim_watchdog sup=${SUP_ID} quiet_ms=${now - lastClaimOkAt} attempt=${claimRecoveryAttempt} next_ms=${waitMs} — reconnecting realtime + re-claiming`)
1166
1197
  try { sb.realtime?.connect?.() } catch { /* noop */ }
1167
1198
  clearTickGuard() // unwedge the guard so the next scheduled tick can run
1168
- try { await refreshClaim(); pushPresence() } catch { /* the next tick retries */ }
1199
+ try { await refreshClaim(); pushPresence() } catch { /* the backed-off attempt retries */ }
1200
+ finally { claimRecoveryInFlight = false }
1169
1201
  }, 20_000)
1170
1202
  heartbeatWatch.unref?.()
1171
1203
 
package/bridge.mjs CHANGED
@@ -122,7 +122,7 @@ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSP
122
122
  import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
123
123
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
124
124
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
125
- import { turnInFlight } from './update-gate.mjs'
125
+ import { realtimeRecoveryDecision, turnInFlight } from './update-gate.mjs'
126
126
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
127
127
  import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, ImageEventQueue, imageQueueConfig, uploadCodeImage as uploadCodeImageRequest, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
128
128
  import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
@@ -131,7 +131,7 @@ import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
131
131
  import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, dispatchSideContexts, sideContextBlock, sideSnapshot } from './side-lane.mjs'
132
132
  import { planMeterLine } from './plan-meters.mjs'
133
133
  import { priceForModel } from './model-prices.mjs'
134
- import { makeThrottledTrack } from './presence.mjs'
134
+ import { makeThrottledTrack, recoveryBackoffMs } from './presence.mjs'
135
135
  import { MockupDeliveryQueue, completeMockupManifest, isMockupDeliveryBoundary } from './mockup-delivery.mjs'
136
136
  import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
137
137
  import { buildTerminalRolePrompt, HERMES_VISIBLE_WORKER_FALLBACK_RULE, THINKPOOL_PROMPT_BUNDLE } from './thinkpool-room-prompt.mjs'
@@ -359,8 +359,9 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
359
359
  child.on('exit', (code) => process.exit(code == null ? 0 : code))
360
360
  }),
361
361
  serveAccountForeground: async () => { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) },
362
- // Confirmed installs/updates close the launcher: the service is independently
363
- // running and another menu cycle can only race it. Failures return to the menu.
362
+ // Confirmed installs/updates continue in the exact runtime just proven live. This
363
+ // keeps the person inside the npx menu without letting the stale installer claim
364
+ // its in-memory VERSION matches the newly installed managed service.
364
365
  installService: ({ room = null, agentCmd } = {}) => svc.installAndConfirmService(room, agentCmd ? [agentCmd] : []),
365
366
  uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
366
367
  restartService: ({ room = null } = {}) => { svc.restartService(room) },
@@ -370,6 +371,27 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
370
371
  restartUpdateService: async ({ room = null } = {}) => process.platform === 'win32'
371
372
  ? svc.updateService(room)
372
373
  : svc.updateAndConfirmService(room),
374
+ relaunchLauncher: async () => {
375
+ const installedVersion = svc.serviceRuntimeVersion(null)
376
+ if (!installedVersion) {
377
+ io.print('\n ⚠ the managed runtime was confirmed earlier but could not be resolved for launcher handoff; staying in this launcher.')
378
+ return false
379
+ }
380
+ let entry = ''
381
+ try { entry = svc.provisionRuntime(installedVersion) }
382
+ catch (error) {
383
+ io.print(`\n ⚠ could not open the verified v${installedVersion} launcher: ${error?.message || error}`)
384
+ return false
385
+ }
386
+ return new Promise((resolve) => {
387
+ const child = spawn(process.execPath, [entry], { stdio: 'inherit', cwd: process.cwd(), env: process.env })
388
+ child.once('error', (error) => {
389
+ io.print(`\n ⚠ launcher handoff failed: ${error?.message || error}`)
390
+ resolve(false)
391
+ })
392
+ child.once('exit', () => resolve(true))
393
+ })
394
+ },
373
395
  login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
374
396
  // Provider config is NOT terminal — it writes provider.json and returns to the
375
397
  // menu so you can pick a model, switch back, or do something else. (It used to
@@ -5182,15 +5204,46 @@ designChannel
5182
5204
  if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') process.stderr.write(`\n ⚠ design realtime ${status} (tpdesign:${room}).\n`)
5183
5205
  })
5184
5206
 
5185
- // Watchdog — if the realtime channel is stuck (never connected, or dropped and
5186
- // not recovered) for >60s, exit non-zero so a supervisor/launchd restarts a
5187
- // clean process. Healthy long-lived connections never trip this (brokenSince=0).
5188
- setInterval(() => {
5189
- if (!realtimeHealthy && brokenSince && Date.now() - brokenSince > 60000) {
5190
- process.stderr.write('\n ⚠ realtime wedged >60s — exiting for a clean restart.\n')
5207
+ // Watchdog — restart only for a Realtime-only wedge. A total network/Supabase
5208
+ // outage cannot be repaired by killing the room process; doing so destroys local
5209
+ // agent runtimes and leaves the supervisor unable to rediscover the room until HTTP
5210
+ // returns. Probe the public health endpoint with capped backoff, preserve agents while
5211
+ // offline or mid-turn, and cross the destructive edge only when HTTP proves healthy.
5212
+ let realtimeRecoveryInFlight = false
5213
+ let realtimeRecoveryAttempt = 0
5214
+ let realtimeRecoveryNextAt = 0
5215
+ const probeSupabaseHttp = async () => {
5216
+ const controller = new AbortController()
5217
+ const timer = setTimeout(() => controller.abort(), 5000)
5218
+ try {
5219
+ const response = await fetch(`${SUPABASE_URL}/auth/v1/health`, { headers: { apikey: SUPABASE_ANON }, signal: controller.signal })
5220
+ return response.ok
5221
+ } catch { return false }
5222
+ finally { clearTimeout(timer) }
5223
+ }
5224
+ setInterval(async () => {
5225
+ if (realtimeHealthy || !brokenSince) {
5226
+ realtimeRecoveryAttempt = 0; realtimeRecoveryNextAt = 0
5227
+ return
5228
+ }
5229
+ const now = Date.now()
5230
+ if (now - brokenSince < 60_000 || realtimeRecoveryInFlight || now < realtimeRecoveryNextAt) return
5231
+ realtimeRecoveryInFlight = true
5232
+ const httpHealthy = await probeSupabaseHttp()
5233
+ const decision = realtimeRecoveryDecision({ brokenSince, now: Date.now(), httpHealthy, activeTurn: turnInFlight(sessions) })
5234
+ if (decision === 'restart') {
5235
+ process.stderr.write('\n ⚠ realtime wedged while HTTP is healthy — exiting for a clean restart.\n')
5191
5236
  shutdown(1) // reap PTYs + attempt presence-leave (was a raw exit → orphaned children)
5237
+ return
5192
5238
  }
5193
- }, 15000).unref()
5239
+ const waitMs = recoveryBackoffMs(realtimeRecoveryAttempt)
5240
+ realtimeRecoveryAttempt += 1
5241
+ realtimeRecoveryNextAt = Date.now() + waitMs
5242
+ process.stderr.write(`\n ◇ realtime unavailable (${decision}) — preserving local agents; retrying in ${Math.round(waitMs / 1000)}s.\n`)
5243
+ try { supabase.realtime?.disconnect?.() } catch { /* noop */ }
5244
+ try { supabase.realtime?.connect?.() } catch { /* noop */ }
5245
+ realtimeRecoveryInFlight = false
5246
+ }, 15_000).unref()
5194
5247
 
5195
5248
  // ── auto-update (OS-service tier only) ─────────────────────────────────
5196
5249
  // launchd (KeepAlive) and systemd (Restart=always) re-run
package/launcher.mjs CHANGED
@@ -285,18 +285,20 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
285
285
  else if (pick.key === 'service') {
286
286
  if (await ensureLoggedIn()) {
287
287
  const installed = await actions.installService({ room: null })
288
- if (installed === true) return
288
+ if (installed === true) {
289
+ if (!actions.relaunchLauncher || await actions.relaunchLauncher()) return
290
+ }
289
291
  resync()
290
292
  }
291
293
  }
292
294
  else if (pick.key === 'restart') {
293
295
  const updated = await actions.restartUpdateService({ room: null })
294
- // The launcher process cannot update its own in-memory VERSION. Returning to
295
- // the menu after a successful service update therefore showed the old launcher
296
- // version beside the new managed-service version and ended on a misleading
297
- // "press Enter" pause. The service is already independently running, so close
298
- // this stale installer process once the OS-level update is confirmed.
299
- if (updated === true) return
296
+ // Keep the person inside the launcher. A confirmed service update may have
297
+ // installed a newer package than this process has in memory, so hand the same
298
+ // TTY to the exact verified runtime instead of dumping back to the shell.
299
+ if (updated === true) {
300
+ if (!actions.relaunchLauncher || await actions.relaunchLauncher()) return
301
+ }
300
302
  resync()
301
303
  }
302
304
  else if (pick.key === 'uninstall') { await actions.uninstallService({ room: null }); resync() }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.320",
3
+ "version": "0.7.321",
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,37 @@ export function makeThrottledTrack(channel, { minMs = 5000, onStatus = null } =
57
57
  }
58
58
  }
59
59
 
60
+ // A service process must not wedge its entire startup behind the first Realtime
61
+ // SUBSCRIBED callback. Settle the startup gate on success, a terminal channel state,
62
+ // or a bounded timeout while leaving the callback attached for later auto-rejoins.
63
+ export function subscribeWithoutStartupDeadlock(channel, onStatus, {
64
+ timeoutMs = 15_000,
65
+ setTimer = setTimeout,
66
+ clearTimer = clearTimeout,
67
+ } = {}) {
68
+ if (!channel?.subscribe || typeof onStatus !== 'function') return Promise.resolve({ connected: false, status: 'unavailable' })
69
+ return new Promise((resolve) => {
70
+ let settled = false
71
+ const finish = (connected, status) => {
72
+ if (settled) return
73
+ settled = true
74
+ clearTimer(timer)
75
+ resolve({ connected, status })
76
+ }
77
+ const timer = setTimer(() => finish(false, 'STARTUP_TIMEOUT'), Math.max(1, Number(timeoutMs) || 1))
78
+ channel.subscribe((status) => {
79
+ onStatus(status)
80
+ if (status === 'SUBSCRIBED') finish(true, status)
81
+ else if (status === 'CLOSED' || status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') finish(false, status)
82
+ })
83
+ })
84
+ }
85
+
86
+ export function recoveryBackoffMs(attempt, { baseMs = 20_000, capMs = 300_000 } = {}) {
87
+ const n = Math.max(0, Math.floor(Number(attempt) || 0))
88
+ return Math.min(Math.max(1, Number(capMs) || 1), Math.max(1, Number(baseMs) || 1) * (2 ** Math.min(n, 10)))
89
+ }
90
+
60
91
  // A RealtimeChannel keeps its last presenceState() locally. After CHANNEL_ERROR /
61
92
  // CLOSED that cache can still contain our own key even though the server (and every
62
93
  // dashboard subscriber) has already removed it. Treat every subscribe/error edge as
package/service.mjs CHANGED
@@ -295,6 +295,8 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
295
295
  const backupFile = `${stagedFile}.previous`
296
296
  const statusFile = path.join(os.homedir(), '.thinkpool-pair', 'update-status.json')
297
297
  const logFile = path.join(os.homedir(), '.thinkpool-pair', 'update.log')
298
+ const readyFile = path.join(os.homedir(), '.thinkpool-pair', 'account-ready.json')
299
+ const requiresReady = targetLabel === label(null)
298
300
  const script = [
299
301
  'set -u',
300
302
  'dom="gui/$(id -u)"',
@@ -305,7 +307,10 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
305
307
  `backup_file=${shq(backupFile)}`,
306
308
  `helper_file=${shq(helperFile)}`,
307
309
  `expected=${shq(expectedRuntime)}`,
310
+ `version=${shq(version)}`,
308
311
  `status=${shq(statusFile)}`,
312
+ `ready=${shq(readyFile)}`,
313
+ `requires_ready=${requiresReady ? '1' : '0'}`,
309
314
  'tmp="$status.tmp.$$"',
310
315
  'ok=0',
311
316
  'live_pid=""',
@@ -314,11 +319,12 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
314
319
  'if [ -f "$target_file" ]; then cp "$target_file" "$backup_file" && had_previous=1; fi',
315
320
  'launchctl bootout "$dom/$target" 2>/dev/null || true',
316
321
  'for i in $(seq 1 40); do launchctl print "$dom/$target" >/dev/null 2>&1 || break; sleep 0.2; done',
322
+ 'if [ "$requires_ready" = 1 ]; then rm -f "$ready"; fi',
317
323
  'cp "$staged_file" "$target_file"',
318
324
  'launchctl enable "$dom/$target" 2>/dev/null || true',
319
325
  'for i in $(seq 1 30); do launchctl bootstrap "$dom" "$target_file" 2>/dev/null && break; sleep 0.3; done',
320
- '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',
321
- `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`,
326
+ '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)"; ready_ok=1; if [ "$requires_ready" = 1 ]; then ready_ok=0; [ -f "$ready" ] && grep -F "\\"pid\\":$pid" "$ready" >/dev/null 2>&1 && grep -F "\\"version\\":\\"$version\\"" "$ready" >/dev/null 2>&1 && ready_ok=1; fi; if [ -n "$pid" ] && [ "$ready_ok" = 1 ] && 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',
327
+ `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; if [ "$requires_ready" = 1 ]; then rm -f "$ready"; fi; cp "$backup_file" "$target_file"; launchctl enable "$dom/$target" 2>/dev/null || true; bootstrapped=0; for i in $(seq 1 30); do if launchctl bootstrap "$dom" "$target_file" 2>/dev/null; then bootstrapped=1; break; fi; sleep 0.3; done; if [ "$bootstrapped" = 1 ]; then rollback_pid=""; rollback_stable=0; 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)"; ready_ok=1; if [ "$requires_ready" = 1 ]; then ready_ok=0; [ -f "$ready" ] && grep -F "\\"pid\\":$pid" "$ready" >/dev/null 2>&1 && ready_ok=1; fi; if [ -n "$pid" ] && [ "$ready_ok" = 1 ] && printf "%s\\n" "$snapshot" | grep -F "state = running" >/dev/null; then if [ "$pid" = "$rollback_pid" ]; then rollback_stable=$((rollback_stable+1)); else rollback_pid="$pid"; rollback_stable=1; fi; if [ "$rollback_stable" -ge 5 ]; then rollback=1; break; fi; else rollback_pid=""; rollback_stable=0; fi; sleep 0.5; done; fi; else launchctl bootout "$dom/$target" 2>/dev/null || true; rm -f "$target_file"; 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 and ready', rolledBack: true })}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed and ready rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
322
328
  'mv "$tmp" "$status"',
323
329
  'rm -f "$staged_file" "$backup_file" "$helper_file"',
324
330
  'launchctl bootout "$dom/$helper" >/dev/null 2>&1 || true',
@@ -336,6 +342,53 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
336
342
  return { helperLabel, helperFile, backupFile, statusFile, logFile, script, content }
337
343
  }
338
344
 
345
+ // Linux has the same self-replacement hazard as launchd: `systemctl restart` issued
346
+ // from inside the account service can terminate the caller before it observes the new
347
+ // process. Run the destructive transaction in a separate transient user unit, and use
348
+ // the same process-bound account readiness receipt as macOS before committing success.
349
+ export function buildLinuxReloadHandoff({ targetLabel, targetFile, stagedFile, expectedRuntime, version, nonce = `${process.pid}-${Date.now()}` }) {
350
+ if (!/^io\.thinkpool\.pair\.[A-Za-z0-9_.-]+$/.test(String(targetLabel))) throw new Error(`invalid service label: ${targetLabel}`)
351
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(version))) throw new Error(`invalid runtime version: ${version}`)
352
+ const safeNonce = String(nonce).replace(/[^A-Za-z0-9-]/g, '') || String(process.pid)
353
+ const helperLabel = `io.thinkpool.pair.update.${safeNonce}`
354
+ const backupFile = `${stagedFile}.previous`
355
+ const statusFile = path.join(os.homedir(), '.thinkpool-pair', 'update-status.json')
356
+ const logFile = path.join(os.homedir(), '.thinkpool-pair', 'update.log')
357
+ const readyFile = path.join(os.homedir(), '.thinkpool-pair', 'account-ready.json')
358
+ const requiresReady = targetLabel === label(null)
359
+ const script = [
360
+ 'set -u',
361
+ `target=${shq(targetLabel)}`,
362
+ 'unit="$target.service"',
363
+ `target_file=${shq(targetFile)}`,
364
+ `staged_file=${shq(stagedFile)}`,
365
+ `backup_file=${shq(backupFile)}`,
366
+ `expected=${shq(expectedRuntime)}`,
367
+ `version=${shq(version)}`,
368
+ `status=${shq(statusFile)}`,
369
+ `ready=${shq(readyFile)}`,
370
+ `requires_ready=${requiresReady ? '1' : '0'}`,
371
+ 'tmp="$status.tmp.$$"',
372
+ 'ok=0',
373
+ 'had_previous=0',
374
+ 'if [ -f "$target_file" ]; then cp "$target_file" "$backup_file" && had_previous=1; fi',
375
+ 'systemctl --user stop "$unit" 2>/dev/null || true',
376
+ 'if [ "$requires_ready" = 1 ]; then rm -f "$ready"; fi',
377
+ 'cp "$staged_file" "$target_file"',
378
+ 'systemctl --user daemon-reload',
379
+ 'systemctl --user enable "$unit" >/dev/null 2>&1 || true',
380
+ 'systemctl --user restart "$unit" >/dev/null 2>&1 || true',
381
+ 'live_pid=""',
382
+ 'stable=0',
383
+ 'for i in $(seq 1 60); do snapshot="$(systemctl --user show "$unit" --property=ActiveState --property=MainPID --property=ExecStart 2>/dev/null || true)"; pid="$(printf "%s\\n" "$snapshot" | sed -n "s/^MainPID=\\([1-9][0-9]*\\)$/\\1/p" | head -1)"; ready_ok=1; if [ "$requires_ready" = 1 ]; then ready_ok=0; [ -f "$ready" ] && grep -F "\\"pid\\":$pid" "$ready" >/dev/null 2>&1 && grep -F "\\"version\\":\\"$version\\"" "$ready" >/dev/null 2>&1 && ready_ok=1; fi; if [ -n "$pid" ] && [ "$ready_ok" = 1 ] && printf "%s\\n" "$snapshot" | grep -F "ActiveState=active" >/dev/null && printf "%s\\n" "$snapshot" | grep -F -- "$expected" >/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',
384
+ `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 systemctl --user stop "$unit" 2>/dev/null || true; if [ "$requires_ready" = 1 ]; then rm -f "$ready"; fi; cp "$backup_file" "$target_file"; systemctl --user daemon-reload; systemctl --user enable "$unit" >/dev/null 2>&1 || true; systemctl --user restart "$unit" >/dev/null 2>&1 || true; rollback_pid=""; rollback_stable=0; for i in $(seq 1 60); do snapshot="$(systemctl --user show "$unit" --property=ActiveState --property=MainPID 2>/dev/null || true)"; pid="$(printf "%s\\n" "$snapshot" | sed -n "s/^MainPID=\\([1-9][0-9]*\\)$/\\1/p" | head -1)"; ready_ok=1; if [ "$requires_ready" = 1 ]; then ready_ok=0; [ -f "$ready" ] && grep -F "\\"pid\\":$pid" "$ready" >/dev/null 2>&1 && ready_ok=1; fi; if [ -n "$pid" ] && [ "$ready_ok" = 1 ] && printf "%s\\n" "$snapshot" | grep -F "ActiveState=active" >/dev/null; then if [ "$pid" = "$rollback_pid" ]; then rollback_stable=$((rollback_stable+1)); else rollback_pid="$pid"; rollback_stable=1; fi; if [ "$rollback_stable" -ge 5 ]; then rollback=1; break; fi; else rollback_pid=""; rollback_stable=0; fi; sleep 0.5; done; else systemctl --user stop "$unit" 2>/dev/null || true; rm -f "$target_file"; systemctl --user daemon-reload; 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 and ready', rolledBack: true })}' > "$tmp"; else printf '%s\\n' '${JSON.stringify({ ok: false, version: String(version), target: String(targetLabel), error: 'new runtime not confirmed and ready rollback failed', rolledBack: false })}' > "$tmp"; fi; fi`,
385
+ 'mv "$tmp" "$status"',
386
+ 'rm -f "$staged_file" "$backup_file"',
387
+ 'exit "$((1-ok))"',
388
+ ].join('; ')
389
+ return { helperLabel, backupFile, statusFile, logFile, script }
390
+ }
391
+
339
392
  const accountLockFile = () => path.join(os.homedir(), '.thinkpool-pair', 'account.lock')
340
393
  const processAlive = (pid) => {
341
394
  try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
@@ -403,8 +456,25 @@ function startDarwinReloadHandoff(a, version) {
403
456
  }
404
457
  }
405
458
 
459
+ function startLinuxReloadHandoff(a, version) {
460
+ const stagedFile = path.join(a.logDir, `service-update-${process.pid}-${Date.now()}.service`)
461
+ const h = buildLinuxReloadHandoff({ targetLabel: labelFromFile(a.file), targetFile: a.file, stagedFile, expectedRuntime: a.expectedRuntime, version })
462
+ fs.mkdirSync(path.dirname(h.statusFile), { recursive: true })
463
+ fs.rmSync(h.statusFile, { force: true })
464
+ fs.writeFileSync(stagedFile, a.content)
465
+ try {
466
+ execSync(`systemd-run --user --unit=${shq(h.helperLabel)} --collect --property=StandardOutput=append:${shq(h.logFile)} --property=StandardError=append:${shq(h.logFile)} /bin/bash -lc ${shq(h.script)}`, { stdio: 'inherit', shell: '/bin/bash' })
467
+ process.stderr.write(` ◆ update handoff armed outside the bridge service cgroup — completion is recorded in ${h.statusFile}.\n`)
468
+ return true
469
+ } catch (e) {
470
+ fs.rmSync(stagedFile, { force: true })
471
+ process.stderr.write(` ⚠ could not arm the systemd update handoff; existing service left running: ${e.message}\n`)
472
+ return false
473
+ }
474
+ }
475
+
406
476
  function labelFromFile(file) {
407
- const match = path.basename(file).match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_.-]+)\.plist$/)
477
+ const match = path.basename(file).match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_.-]+)\.(?:plist|service)$/)
408
478
  if (!match) throw new Error(`cannot derive service label from ${file}`)
409
479
  return match[1]
410
480
  }
@@ -484,6 +554,9 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
484
554
  if (process.platform === 'darwin') {
485
555
  process.stderr.write(`\n ◆ staged replacement for ${a.file}\n`)
486
556
  if (!startDarwinReloadHandoff(a, version)) return false
557
+ } else if (process.platform === 'linux') {
558
+ process.stderr.write(`\n ◆ staged replacement for ${a.file}\n`)
559
+ if (!startLinuxReloadHandoff(a, version)) return false
487
560
  } else {
488
561
  fs.writeFileSync(a.file, a.content)
489
562
  process.stderr.write(`\n ◆ wrote ${a.file}\n`)
@@ -525,7 +598,7 @@ export async function installAndConfirmService(room, cmdArgs = [], {
525
598
  },
526
599
  now = Date.now,
527
600
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
528
- timeoutMs = 55000,
601
+ timeoutMs = 85000,
529
602
  pollMs = 250,
530
603
  stderr = process.stderr,
531
604
  } = {}) {
@@ -541,7 +614,7 @@ export async function installAndConfirmService(room, cmdArgs = [], {
541
614
 
542
615
  const confirmation = () => {
543
616
  const live = snapshot(room, { platform, exec })
544
- const ready = !!room || platform !== 'darwin' || supervisorReadyMatches(readReady(), live, version, { notBefore: confirmationStartedAt })
617
+ const ready = !!room || platform === 'win32' || supervisorReadyMatches(readReady(), live, version, { notBefore: confirmationStartedAt })
545
618
  if (live?.version === version && ready) {
546
619
  stderr.write(` ✓ bridge install confirmed — running v${version}.\n`)
547
620
  return true
@@ -549,14 +622,10 @@ export async function installAndConfirmService(room, cmdArgs = [], {
549
622
  return false
550
623
  }
551
624
 
552
- // systemd's restart is synchronous. Windows cannot prove a live Startup process,
553
- // so this exact-runtime confirmation is used only where a service manager exists.
554
- if (platform !== 'darwin') {
555
- if (platform === 'win32') return true
556
- const confirmed = confirmation()
557
- if (!confirmed) stderr.write(` ⚠ install v${version} was not confirmed: the service manager cannot prove that runtime is running.\n`)
558
- return confirmed
559
- }
625
+ // Windows has no service-manager process identity. macOS and Linux both cross an
626
+ // asynchronous external-helper boundary and must wait for its receipt plus account
627
+ // readiness; a live PID alone is not a connected bridge.
628
+ if (platform === 'win32') return true
560
629
 
561
630
  const deadline = now() + Math.max(0, Number(timeoutMs) || 0)
562
631
  const expectedTarget = label(room)
@@ -677,7 +746,7 @@ export async function updateAndConfirmService(room, {
677
746
  },
678
747
  now = Date.now,
679
748
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
680
- timeoutMs = 55000,
749
+ timeoutMs = 85000,
681
750
  pollMs = 250,
682
751
  stderr = process.stderr,
683
752
  } = {}) {
@@ -700,7 +769,7 @@ export async function updateAndConfirmService(room, {
700
769
  const live = snapshot(room, { platform, exec })
701
770
  const versionChanged = !!before && before.version !== target
702
771
  const processChanged = !!before?.pid && !!live?.pid && before.pid !== live.pid
703
- const ready = !!room || platform !== 'darwin' || supervisorReadyMatches(readReady(), live, target, { notBefore: confirmationStartedAt })
772
+ const ready = !!room || platform === 'win32' || supervisorReadyMatches(readReady(), live, target, { notBefore: confirmationStartedAt })
704
773
  if (live?.version === target && (versionChanged || processChanged) && ready) {
705
774
  stderr.write(` ✓ bridge restart confirmed — running v${target}.\n`)
706
775
  return { ok: true, live }
@@ -713,14 +782,10 @@ export async function updateAndConfirmService(room, {
713
782
  else stderr.write(` ⚠ update v${target} was not confirmed: the service manager cannot prove the new runtime is running.\n`)
714
783
  }
715
784
 
716
- // systemd's restart is synchronous. The Windows Startup-folder tier has no
717
- // authoritative live-service identity, so callers use updateService directly
718
- // and label it as a next-launch update rather than a completed restart.
719
- if (platform !== 'darwin') {
720
- const proof = confirmation()
721
- if (!proof.ok) printUnconfirmed(proof)
722
- return proof.ok
723
- }
785
+ // The Windows Startup-folder tier has no authoritative live-service identity, so
786
+ // callers use updateService directly and label it as a next-launch update. Both
787
+ // managed Unix tiers are asynchronous external handoffs and share the receipt loop.
788
+ if (platform === 'win32') return true
724
789
 
725
790
  const deadline = now() + Math.max(0, Number(timeoutMs) || 0)
726
791
  const expectedTarget = label(room)
package/update-gate.mjs CHANGED
@@ -40,3 +40,14 @@ export function turnInFlight(sessions) {
40
40
  }
41
41
  return false
42
42
  }
43
+
44
+ // Restarting a room process helps only when the HTTP control plane is reachable and
45
+ // Realtime alone is wedged. During a total network outage it destroys healthy local
46
+ // agent processes and cannot improve connectivity; a live turn or permission decision
47
+ // is likewise a hard hold even when HTTP is healthy.
48
+ export function realtimeRecoveryDecision({ brokenSince, now, httpHealthy, activeTurn, graceMs = 60_000 }) {
49
+ if (!brokenSince || now - brokenSince < graceMs) return 'wait'
50
+ if (!httpHealthy) return 'hold-offline'
51
+ if (activeTurn) return 'hold-active'
52
+ return 'restart'
53
+ }