thinkpool-pair 0.7.319 → 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/README.md CHANGED
@@ -31,14 +31,14 @@ The bridge self-heals at three levels — all cross-platform (macOS / Linux / Wi
31
31
  backoff). Zero-dependency. Combined with session restore (below), a crash is
32
32
  invisible — the room reconnects and the Claude session resumes where it left off.
33
33
  ```bash
34
- npx thinkpool-pair <ROOM> --supervise -- claude
34
+ npx thinkpool-pair@latest <ROOM> --supervise -- claude
35
35
  ```
36
36
  - **Boot-persistent service** — survive reboot/logout too. Installs the right
37
37
  native service for your OS (launchd on macOS, systemd `--user` on Linux, a
38
38
  Startup-folder script on Windows):
39
39
  ```bash
40
- npx thinkpool-pair install-service <ROOM> -- claude # set and forget
41
- npx thinkpool-pair uninstall-service <ROOM> # remove it
40
+ npx thinkpool-pair@latest install-service <ROOM> -- claude # set and forget
41
+ npx thinkpool-pair@latest uninstall-service <ROOM> # remove it
42
42
  ```
43
43
  The service runs `npx thinkpool-pair@latest`, so it **auto-updates** — new
44
44
  versions apply on the next restart, no re-install. (Linux: run
@@ -186,7 +186,7 @@ that format:
186
186
  > not OpenRouter's Anthropic endpoint.
187
187
 
188
188
  ```bash
189
- npx thinkpool-pair provider custom --base <url> --token <key> [--model <name>]
189
+ npx thinkpool-pair@latest provider custom --base <url> --token <key> [--model <name>]
190
190
  ```
191
191
 
192
192
  Common base urls (the agent appends `/v1/messages`):
@@ -203,11 +203,11 @@ Common base urls (the agent appends `/v1/messages`):
203
203
  > gateway is for. Avoid LiteLLM 1.82.7 / 1.82.8 (compromised releases); use a
204
204
  > current version.
205
205
 
206
- Show the current provider (`npx thinkpool-pair provider`), or reset back to your
206
+ Show the current provider (`npx thinkpool-pair@latest provider`), or reset back to your
207
207
  regular Claude login:
208
208
 
209
209
  ```bash
210
- npx thinkpool-pair provider anthropic
210
+ npx thinkpool-pair@latest provider anthropic
211
211
  ```
212
212
 
213
213
  Provider config is stored in `~/.thinkpool-pair/provider.json` (mode 0600) and
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'
@@ -24,6 +24,7 @@ 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
26
  import { clearSupervisorReady, supervisorPresenceEchoed, writeSupervisorReady } from './supervisor-ready.mjs'
27
+ import { PAIR_CLI, pairCli } from './command-guidance.mjs'
27
28
 
28
29
  const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
29
30
 
@@ -121,7 +122,7 @@ export async function runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) {
121
122
  process.stderr.write(`\n ◆ Link this device to your ThinkPool account.\n Open (signed in) and approve:\n\n ${url}\n\n code: ${user_code}\n\n Waiting…\n`)
122
123
  const started = Date.now()
123
124
  const poll = async () => {
124
- if (Date.now() - started > 5 * 60 * 1000) { process.stderr.write('\n ◇ link timed out — run `npx thinkpool-pair login` again.\n'); process.exit(1) }
125
+ if (Date.now() - started > 5 * 60 * 1000) { process.stderr.write(`\n ◇ link timed out — run \`${pairCli('login')}\` again.\n`); process.exit(1) }
125
126
  try {
126
127
  // A discarded `error` costs one tick — the loop retries in a second, and a
127
128
  // permanent failure still lands on the "link timed out" branch above rather than
@@ -130,10 +131,10 @@ export async function runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) {
130
131
  const { data: r } = await sb.rpc('claim_device_code', { p_device_code: device_code })
131
132
  if (r?.status === 'approved' && r.session?.refresh_token) {
132
133
  saveAuth(r.session)
133
- process.stderr.write(`\n ◆ linked as ${r.session.email || 'your account'}. You can close the browser tab.\n Now run: npx thinkpool-pair\n`)
134
+ process.stderr.write(`\n ◆ linked as ${r.session.email || 'your account'}. You can close the browser tab.\n Now run: ${PAIR_CLI}\n`)
134
135
  process.exit(0)
135
136
  }
136
- if (r?.status === 'expired' || r?.status === 'not_found') { process.stderr.write('\n ◇ link expired — run `npx thinkpool-pair login` again.\n'); process.exit(1) }
137
+ if (r?.status === 'expired' || r?.status === 'not_found') { process.stderr.write(`\n ◇ link expired — run \`${pairCli('login')}\` again.\n`); process.exit(1) }
137
138
  } catch { /* transient — keep polling */ }
138
139
  setTimeout(poll, 3000)
139
140
  }
@@ -397,18 +398,18 @@ export function scheduleTokenRefresh({ sb, session, onRefreshed, onReconnectRequ
397
398
 
398
399
  export function runBind(room, dir) {
399
400
  const r = (room || '').toUpperCase().trim()
400
- if (!r) { console.error('usage: npx thinkpool-pair bind <ROOM> <directory>'); process.exit(1) }
401
+ if (!r) { console.error(`usage: ${pairCli('bind', '<ROOM>', '<directory>')}`); process.exit(1) }
401
402
  const abs = path.resolve(dir || '.')
402
403
  if (!fs.existsSync(abs) || !fs.statSync(abs).isDirectory()) { console.error(`✗ not a directory: ${abs}`); process.exit(1) }
403
404
  bindDir(r, abs)
404
- console.error(`✓ bound ${r} → ${abs}\n Run \`npx thinkpool-pair\` (or restart it) to serve this session.`)
405
+ console.error(`✓ bound ${r} → ${abs}\n Run \`${PAIR_CLI}\` (or restart it) to serve this session.`)
405
406
  process.exit(0)
406
407
  }
407
408
 
408
409
  // ── account supervisor: a headless child bridge per bound room, in its dir ──
409
410
  export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
410
411
  if (!acquireSingleton()) {
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')
412
+ 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 \`${pairCli('<ROOM>')}\`.\n`)
412
413
  process.exit(0)
413
414
  }
414
415
  // We own account.lock now, so any previous readiness receipt is stale. A fresh
@@ -430,7 +431,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
430
431
  // Reached ONLY when there is no saved login at all (not linked). Stand down CLEANLY
431
432
  // with exit 0: launchd KeepAlive has SuccessfulExit=false, so exit 0 is NOT respawned
432
433
  // — no crash storm for the "never logged in" case. The user links with `login`.
433
- console.error('\n ◇ Not linked to a ThinkPool account on this machine.\n Link it: npx thinkpool-pair login\n')
434
+ console.error(`\n ◇ Not linked to a ThinkPool account on this machine.\n Link it: ${pairCli('login')}\n`)
434
435
  process.exit(0)
435
436
  }
436
437
  if (auth.reconnectRequired) {
@@ -470,9 +471,9 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
470
471
  ? ' (auto-updating background service — survives reboot, tracks @latest)\n'
471
472
  : isManagedService
472
473
  ? ' (background service — survives reboot, restarts on crash, pinned to this version)\n'
473
- : ' (run hands-off + auto-updating: npx thinkpool-pair install-service)\n'
474
+ : ` (run hands-off + auto-updating: ${pairCli('install-service')})\n`
474
475
  const _effDefault = loadDefaultDir() || claudeRecentProjectDir() || DEFAULT_DIR
475
- 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}`)
476
+ 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: ${pairCli('set-default-dir', '<dir>')} · one room: ${pairCli('bind', '<code>', '<dir>')})\n${svcHint}`)
476
477
 
477
478
  const children = new Map() // room -> child process
478
479
  const childIdle = new Map() // room -> bool (last idle report: in the quiet window)
@@ -564,27 +565,43 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
564
565
  // just stops — restart is meaningful for the always-on bridge. Old bridges (pre-0.7.60)
565
566
  // don't subscribe to this event, so the dashboard button is a harmless no-op there.
566
567
  let restartPreparationRunning = false
567
- 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
+ }
568
577
  // A dashboard restart is an EXPLICIT apply request, but it is not permission
569
578
  // to interrupt a live turn or a pending human decision. Stage the immutable
570
579
  // runtime while work continues; applyIfIdle() owns the only destructive edge.
571
- applyRequested = true
572
- if (restartPreparationRunning || applyingUpdate) return
580
+ if (restartPreparationRunning || applyingUpdate) {
581
+ await reply(false, 'a bridge update is already being prepared')
582
+ return
583
+ }
573
584
  restartPreparationRunning = true
574
- ;(async () => {
575
- try {
576
- const svc = await import('./service.mjs')
577
- const staged = svc.serviceActive(null) ? svc.stageServiceUpdate(null) : null
578
- // The dashboard's advertised version is only a fallback marker. The host
579
- // independently resolves and verifies npm above before applying a service.
580
- pendingUpdate = staged || (typeof payload?.version === 'string' ? payload.version : VERSION) || 'restart'
581
- } catch {
582
- pendingUpdate = (typeof payload?.version === 'string' ? payload.version : VERSION) || 'restart'
583
- } finally {
584
- 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
585
592
  }
586
- void applyIfIdle()
587
- })()
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()
588
605
  })
589
606
  // Throttled so a realtime reconnect storm can't machine-gun track() into the
590
607
  // per-client presence rate limit (ClientPresenceRateLimitReached → channel closed
@@ -698,10 +715,10 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
698
715
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
699
716
  // (network blip, or a token swap mid-flight) rejoins the channel and must
700
717
  // re-announce, or the dashboard would read "no bridge" until the next restart.
701
- await new Promise((res) => acct.subscribe((st) => {
718
+ const initialAccountRealtime = await subscribeWithoutStartupDeadlock(acct, (st) => {
702
719
  if (st === 'SUBSCRIBED') {
703
720
  presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, st)
704
- pushPresence(); res()
721
+ pushPresence()
705
722
  }
706
723
  else if (st === 'CLOSED' || st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
707
724
  // Invalidate the old generation before inspecting presenceState again. The
@@ -714,7 +731,10 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
714
731
  // The callback stays wired for the channel's life, so later rejoins re-fire it.
715
732
  try { sb.realtime?.connect?.() } catch { /* noop */ }
716
733
  }
717
- }))
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
+ }
718
738
 
719
739
  // Keep the account JWT fresh so the presence socket never gets deauthed at
720
740
  // expiry (the 2026-06-17 "No bridge connected while sessions run" bug). On each
@@ -955,7 +975,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
955
975
  const why = choice.reason === 'home'
956
976
  ? `no project directory set — refusing to serve it from your home folder (${choice.dir}); the agent would lose its session context and the room would show "${path.basename(choice.dir)}" as the repo.`
957
977
  : 'no directory to serve it from.'
958
- process.stderr.write(`\n ◇ ${room}: ${why}\n Bind it to its repo: npx thinkpool-pair bind ${room} <repo-dir>\n`)
978
+ process.stderr.write(`\n ◇ ${room}: ${why}\n Bind it to its repo: ${pairCli('bind', room, '<repo-dir>')}\n`)
959
979
  }
960
980
  continue
961
981
  }
@@ -1158,13 +1178,26 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1158
1178
  // wedged tick can't starve it: when the claim has been quiet ≥2 ticks it nudges realtime
1159
1179
  // to reconnect, force-clears the re-entrancy guard, and re-issues the heartbeat directly
1160
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
1161
1184
  const heartbeatWatch = setInterval(async () => {
1162
1185
  if (stopping) return
1163
- if (!claimLoopWedged({ lastClaimOkAt, now: Date.now() })) return
1164
- 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`)
1165
1197
  try { sb.realtime?.connect?.() } catch { /* noop */ }
1166
1198
  clearTickGuard() // unwedge the guard so the next scheduled tick can run
1167
- try { await refreshClaim(); pushPresence() } catch { /* the next tick retries */ }
1199
+ try { await refreshClaim(); pushPresence() } catch { /* the backed-off attempt retries */ }
1200
+ finally { claimRecoveryInFlight = false }
1168
1201
  }, 20_000)
1169
1202
  heartbeatWatch.unref?.()
1170
1203
 
package/bridge.mjs CHANGED
@@ -68,6 +68,7 @@ import { requiresSdkAdmission, sdkSmokePassed } from './sdk-admission.mjs'
68
68
  import { hermesUserInputResponse } from './question-response.mjs'
69
69
  import { hostMemoryAdmission } from './host-memory.mjs'
70
70
  import { queueAbortBarrier, waitForAbortBarrier } from './abort-turn-barrier.mjs'
71
+ import { PAIR_CLI, pairCli } from './command-guidance.mjs'
71
72
 
72
73
  const STRUCTURED_MODES = new Set(['default', 'acceptEdits', 'plan', 'review', 'bypassPermissions'])
73
74
  import { FLOW_CONDUCTOR_PROMPT, FLOW_LANE_PROMPT, FLOW_CODEX_CONDUCTOR_PROMPT, FLOW_CODEX_LANE_PROMPT, buildConductorEnv, assembleCrossWaveContext, buildLanePrompt } from './flow-conductor.mjs'
@@ -121,7 +122,7 @@ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSP
121
122
  import { createStandalonePairResponder, standalonePairIdentity } from './direct-pair-room.mjs'
122
123
  import { dispatchPendingRecap, recoverInterruptedTurn, recoverMissingResumeOnce, sendInterruptedContinue, shouldAttemptNativeResume, supersedeInterruptedResume } from './interrupted-resume.mjs'
123
124
  import { supersedeDispatchLease } from './dispatch-lease.mjs'
124
- import { turnInFlight } from './update-gate.mjs'
125
+ import { realtimeRecoveryDecision, turnInFlight } from './update-gate.mjs'
125
126
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
126
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'
127
128
  import { createLatestReplayPump, requestedReplayIds } from './replay-transport.mjs'
@@ -130,7 +131,7 @@ import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
130
131
  import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, dispatchSideContexts, sideContextBlock, sideSnapshot } from './side-lane.mjs'
131
132
  import { planMeterLine } from './plan-meters.mjs'
132
133
  import { priceForModel } from './model-prices.mjs'
133
- import { makeThrottledTrack } from './presence.mjs'
134
+ import { makeThrottledTrack, recoveryBackoffMs } from './presence.mjs'
134
135
  import { MockupDeliveryQueue, completeMockupManifest, isMockupDeliveryBoundary } from './mockup-delivery.mjs'
135
136
  import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
136
137
  import { buildTerminalRolePrompt, HERMES_VISIBLE_WORKER_FALLBACK_RULE, THINKPOOL_PROMPT_BUNDLE } from './thinkpool-room-prompt.mjs'
@@ -292,7 +293,7 @@ if (argv[0] === 'bind') { const { runBind } = await import('./account.mjs'); r
292
293
  if (argv[0] === 'set-default-dir') {
293
294
  const { saveDefaultDir } = await import('./auth-store.mjs')
294
295
  let d = (argv[1] || '').trim(); if (d.startsWith('~')) d = d.replace(/^~/, os.homedir())
295
- if (!d) { console.error(' usage: npx thinkpool-pair set-default-dir <dir>'); process.exit(1) }
296
+ if (!d) { console.error(` usage: ${pairCli('set-default-dir', '<dir>')}`); process.exit(1) }
296
297
  saveDefaultDir(d)
297
298
  console.log(` ◆ default project dir set → ${d}\n New/unbound sessions auto-serve from here — no per-session bind. Takes effect next serve tick.`)
298
299
  process.exit(0)
@@ -358,8 +359,9 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
358
359
  child.on('exit', (code) => process.exit(code == null ? 0 : code))
359
360
  }),
360
361
  serveAccountForeground: async () => { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) },
361
- // Confirmed installs/updates close the launcher: the service is independently
362
- // 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.
363
365
  installService: ({ room = null, agentCmd } = {}) => svc.installAndConfirmService(room, agentCmd ? [agentCmd] : []),
364
366
  uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
365
367
  restartService: ({ room = null } = {}) => { svc.restartService(room) },
@@ -369,6 +371,27 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
369
371
  restartUpdateService: async ({ room = null } = {}) => process.platform === 'win32'
370
372
  ? svc.updateService(room)
371
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
+ },
372
395
  login: async () => { const { runLogin } = await import('./account.mjs'); await runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) },
373
396
  // Provider config is NOT terminal — it writes provider.json and returns to the
374
397
  // menu so you can pick a model, switch back, or do something else. (It used to
@@ -437,7 +460,7 @@ function checkSdkCompat() {
437
460
  if (!argv[0] || argv[0].startsWith('-')) { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) }
438
461
 
439
462
  const room = (argv[0] || '').toUpperCase().trim()
440
- if (!room) { console.error('usage: npx thinkpool-pair <ROOM> [--headless] [--continue|--fresh] [-- <command…>] | npx thinkpool-pair (account mode)'); process.exit(1) }
463
+ if (!room) { console.error(`usage: ${pairCli('<ROOM>', '[--headless]', '[--continue|--fresh]', '[-- <command…>]')} | ${PAIR_CLI} (account mode)`); process.exit(1) }
441
464
  // Security (audit 2026-07-02): `room` becomes a filesystem path segment under
442
465
  // ~/.thinkpool-pair (session-store) AND is interpolated into launchctl/systemd
443
466
  // service labels. sessions.id is unconstrained TEXT, so refuse path-unsafe / shell-
@@ -549,7 +572,7 @@ if (dashIdx >= 0) {
549
572
  }
550
573
  } else if (!headless) {
551
574
  if (installedAgents.length === 0) {
552
- console.error(`\n No known coding-agent CLI found on your PATH.\n Install one (claude / codex / gemini / aider / cursor-agent / opencode …)\n or share a specific command: npx thinkpool-pair ${room} -- <your-command>\n`)
575
+ console.error(`\n No known coding-agent CLI found on your PATH.\n Install one (claude / codex / gemini / aider / cursor-agent / opencode …)\n or share a specific command: ${pairCli(room, '--', '<your-command>')}\n`)
553
576
  process.exit(1)
554
577
  }
555
578
  attachedCmd = await pickAgent(installedAgents)
@@ -831,7 +854,7 @@ let myServeUid = null // this bridge's authed uid (null = anon)
831
854
  // 401 must NEVER serve, or the gate is bypassed (DEFECT 1). gateFailAction classifies.
832
855
  if (gateFailAction(e) === 'refuse') {
833
856
  process.stderr.write(refusalMessage({ room, name: null, reason: 'no-identity' }))
834
- process.stderr.write(`\n ◇ (serve-consent: auth rejected at the gate — ${e.message}; the stored login is expired or revoked. Re-run: npx thinkpool-pair login)\n`)
857
+ process.stderr.write(`\n ◇ (serve-consent: auth rejected at the gate — ${e.message}; the stored login is expired or revoked. Re-run: ${pairCli('login')})\n`)
835
858
  process.exit(0)
836
859
  }
837
860
  // Availability failure → fail OPEN (spec: never strand a user on a flaky fetch).
@@ -1402,7 +1425,7 @@ let standalonePairChannel = null // standalone owner bridge's direct paired-sess
1402
1425
  // standalone bridge (no supervisor) or a disabled/timed-out request returns { error }.
1403
1426
  function pairRequest(type, payload = {}, timeoutMs = 4000) {
1404
1427
  if (process.env.TP_CROSSROOM_OFF === '1') return Promise.resolve({ error: 'Cross-session reach is turned off in this room.' })
1405
- if (!IS_ACCOUNT_CHILD) return Promise.resolve({ error: 'Cross-session reach needs the ThinkPool account bridge running your sessions (npx thinkpool-pair). This room is running standalone, so it can only see its own terminals — use read_terminal.' })
1428
+ if (!IS_ACCOUNT_CHILD) return Promise.resolve({ error: `Cross-session reach needs the ThinkPool account bridge running your sessions (${PAIR_CLI}). This room is running standalone, so it can only see its own terminals — use read_terminal.` })
1406
1429
  const reqId = randomUUID()
1407
1430
  return new Promise((resolve) => {
1408
1431
  const timer = setTimeout(() => { pairWaiters.delete(reqId); resolve({ error: 'The other session did not respond in time.' }) }, timeoutMs)
@@ -5181,15 +5204,46 @@ designChannel
5181
5204
  if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') process.stderr.write(`\n ⚠ design realtime ${status} (tpdesign:${room}).\n`)
5182
5205
  })
5183
5206
 
5184
- // Watchdog — if the realtime channel is stuck (never connected, or dropped and
5185
- // not recovered) for >60s, exit non-zero so a supervisor/launchd restarts a
5186
- // clean process. Healthy long-lived connections never trip this (brokenSince=0).
5187
- setInterval(() => {
5188
- if (!realtimeHealthy && brokenSince && Date.now() - brokenSince > 60000) {
5189
- 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')
5190
5236
  shutdown(1) // reap PTYs + attempt presence-leave (was a raw exit → orphaned children)
5237
+ return
5191
5238
  }
5192
- }, 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()
5193
5247
 
5194
5248
  // ── auto-update (OS-service tier only) ─────────────────────────────────
5195
5249
  // launchd (KeepAlive) and systemd (Restart=always) re-run
@@ -0,0 +1,8 @@
1
+ // Canonical, cache-busting invocation for every command printed to people.
2
+ export const PAIR_CLI = 'npx thinkpool-pair@latest'
3
+
4
+ export function pairCli(...args) {
5
+ return [PAIR_CLI, ...args.filter((arg) => arg !== undefined && arg !== null && String(arg).length > 0)]
6
+ .map(String)
7
+ .join(' ')
8
+ }
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.319",
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": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bridge.mjs",
11
+ "command-guidance.mjs",
11
12
  "abort-turn-barrier.mjs",
12
13
  "host-memory.mjs",
13
14
  "sdk-smoke.mjs",
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/provider.mjs CHANGED
@@ -31,6 +31,7 @@
31
31
  import os from 'node:os'
32
32
  import fs from 'node:fs'
33
33
  import path from 'node:path'
34
+ import { pairCli } from './command-guidance.mjs'
34
35
 
35
36
  const DIR = path.join(os.homedir(), '.thinkpool-pair')
36
37
  const FILE = path.join(DIR, 'provider.json')
@@ -68,7 +69,7 @@ export function applyProviderEnv() {
68
69
  const authToken = process.env.TP_ANTHROPIC_AUTH_TOKEN || cfg.authToken
69
70
  const model = process.env.TP_ANTHROPIC_MODEL || cfg.model
70
71
  if (!baseUrl || !authToken) {
71
- process.stderr.write(`\n ◇ provider "${provider}" is set but is missing baseUrl/authToken.\n Finish setup: npx thinkpool-pair provider custom --base <url> --token <key>\n Falling back to regular Claude for now.\n`)
72
+ process.stderr.write(`\n ◇ provider "${provider}" is set but is missing baseUrl/authToken.\n Finish setup: ${pairCli('provider', 'custom', '--base', '<url>', '--token', '<key>')}\n Falling back to regular Claude for now.\n`)
72
73
  return { provider: 'anthropic', source: 'incomplete' }
73
74
  }
74
75
  process.env.ANTHROPIC_BASE_URL = baseUrl
@@ -121,9 +122,9 @@ export async function runProvider(args) {
121
122
 
122
123
  console.error(
123
124
  '\n usage:\n' +
124
- ' npx thinkpool-pair provider # show current\n' +
125
- ' npx thinkpool-pair provider custom --base <url> --token <key> [--model <m>] # any Anthropic-compatible endpoint\n' +
126
- ' npx thinkpool-pair provider anthropic # reset to Claude\n\n' +
125
+ ` ${pairCli('provider')} # show current\n` +
126
+ ` ${pairCli('provider', 'custom', '--base', '<url>', '--token', '<key>', '[--model <m>]')} # any Anthropic-compatible endpoint\n` +
127
+ ` ${pairCli('provider', 'anthropic')} # reset to Claude\n\n` +
127
128
  ' common base urls (SDK appends /v1/messages):\n' +
128
129
  ' Z.ai GLM → https://api.z.ai/api/anthropic\n' +
129
130
  ' OpenRouter → https://openrouter.ai/api\n'
package/serve-consent.mjs CHANGED
@@ -18,6 +18,8 @@
18
18
  // so even an old bridge can no longer READ a grant (the column is gone) and will refuse
19
19
  // as a non-owner. That closes the bypass.
20
20
 
21
+ import { pairCli } from './command-guidance.mjs'
22
+
21
23
  // serveDecision — may THIS bridge serve THIS room?
22
24
  // ownerId : code_sessions.owner_id (uuid | null)
23
25
  // myUid : this bridge's authed user id (uuid | null) — null when the bridge is anon
@@ -109,7 +111,7 @@ export function refusalMessage ({ room, name, reason }) {
109
111
  const label = name ? `"${name}"` : '(unnamed)'
110
112
  if (reason === 'no-identity') {
111
113
  return `\n ◇ Refusing to serve room ${room} — this bridge is not signed in.\n` +
112
- ` A room is served only by its owner's bridge. Sign in first: npx thinkpool-pair login\n`
114
+ ` A room is served only by its owner's bridge. Sign in first: ${pairCli('login')}\n`
113
115
  }
114
116
  return `\n ◇ Refusing to serve room ${room} ${label} — it is owned by another person; only their bridge serves it.\n` +
115
117
  ` Pairing gives you a read-only view across the pair, not hosting rights. There is no override.\n`
package/service.mjs CHANGED
@@ -24,6 +24,7 @@ import path from 'node:path'
24
24
  import { execSync } from 'node:child_process'
25
25
  import { hostMemoryAdmission } from './host-memory.mjs'
26
26
  import { readSupervisorReady, supervisorReadyMatches } from './supervisor-ready.mjs'
27
+ import { pairCli } from './command-guidance.mjs'
27
28
 
28
29
  // Service identity. Account mode has no room → a single stable id so there's
29
30
  // exactly one account service per machine (a second install replaces it).
@@ -294,6 +295,8 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
294
295
  const backupFile = `${stagedFile}.previous`
295
296
  const statusFile = path.join(os.homedir(), '.thinkpool-pair', 'update-status.json')
296
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)
297
300
  const script = [
298
301
  'set -u',
299
302
  'dom="gui/$(id -u)"',
@@ -304,7 +307,10 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
304
307
  `backup_file=${shq(backupFile)}`,
305
308
  `helper_file=${shq(helperFile)}`,
306
309
  `expected=${shq(expectedRuntime)}`,
310
+ `version=${shq(version)}`,
307
311
  `status=${shq(statusFile)}`,
312
+ `ready=${shq(readyFile)}`,
313
+ `requires_ready=${requiresReady ? '1' : '0'}`,
308
314
  'tmp="$status.tmp.$$"',
309
315
  'ok=0',
310
316
  'live_pid=""',
@@ -313,11 +319,12 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
313
319
  'if [ -f "$target_file" ]; then cp "$target_file" "$backup_file" && had_previous=1; fi',
314
320
  'launchctl bootout "$dom/$target" 2>/dev/null || true',
315
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',
316
323
  'cp "$staged_file" "$target_file"',
317
324
  'launchctl enable "$dom/$target" 2>/dev/null || true',
318
325
  'for i in $(seq 1 30); do launchctl bootstrap "$dom" "$target_file" 2>/dev/null && break; sleep 0.3; 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',
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`,
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`,
321
328
  'mv "$tmp" "$status"',
322
329
  'rm -f "$staged_file" "$backup_file" "$helper_file"',
323
330
  'launchctl bootout "$dom/$helper" >/dev/null 2>&1 || true',
@@ -335,6 +342,53 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
335
342
  return { helperLabel, helperFile, backupFile, statusFile, logFile, script, content }
336
343
  }
337
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
+
338
392
  const accountLockFile = () => path.join(os.homedir(), '.thinkpool-pair', 'account.lock')
339
393
  const processAlive = (pid) => {
340
394
  try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
@@ -402,8 +456,25 @@ function startDarwinReloadHandoff(a, version) {
402
456
  }
403
457
  }
404
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
+
405
476
  function labelFromFile(file) {
406
- 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)$/)
407
478
  if (!match) throw new Error(`cannot derive service label from ${file}`)
408
479
  return match[1]
409
480
  }
@@ -483,6 +554,9 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
483
554
  if (process.platform === 'darwin') {
484
555
  process.stderr.write(`\n ◆ staged replacement for ${a.file}\n`)
485
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
486
560
  } else {
487
561
  fs.writeFileSync(a.file, a.content)
488
562
  process.stderr.write(`\n ◆ wrote ${a.file}\n`)
@@ -499,10 +573,10 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
499
573
  ? (process.platform === 'win32'
500
574
  ? 'auto-update ON: a new thinkpool-pair@latest applies on the next login/restart (Windows has no in-process self-update).'
501
575
  : `auto-update ON: checks npm ~every 30 min and restarts to apply a new @latest once sessions are idle (≥${idleSecs}s quiet; they resume in place).`)
502
- : `PINNED to ${version} (stable local runtime; restarts do not depend on npx or the network). To update: use "Restart & update the bridge" (or re-run install-service from a newer thinkpool-pair). Pass --auto-update to track @latest instead.`
576
+ : `PINNED to ${version} (stable local runtime; restarts do not depend on npx or the network). To update: use "Restart & update the bridge" (or run ${pairCli('install-service', room || undefined)}). Pass --auto-update to track @latest instead.`
503
577
  const removeArg = room ? ` ${room}` : ''
504
578
  const what = room ? `room ${room}` : 'your account (auto-serves every session)'
505
- process.stderr.write(` ◆ ${what}\n ◆ ${process.platform === 'darwin' ? 'Launchd is completing and verifying the reload independently.' : a.note}\n ◆ ${updateNote}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: thinkpool-pair uninstall-service${removeArg}\n\n`)
579
+ process.stderr.write(` ◆ ${what}\n ◆ ${process.platform === 'darwin' ? 'Launchd is completing and verifying the reload independently.' : a.note}\n ◆ ${updateNote}\n ◆ logs: ${path.join(a.logDir, `${slug(room)}.log`)}\n ◆ remove with: ${pairCli('uninstall-service')}${removeArg}\n\n`)
506
580
  return true
507
581
  }
508
582
 
@@ -524,7 +598,7 @@ export async function installAndConfirmService(room, cmdArgs = [], {
524
598
  },
525
599
  now = Date.now,
526
600
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
527
- timeoutMs = 55000,
601
+ timeoutMs = 85000,
528
602
  pollMs = 250,
529
603
  stderr = process.stderr,
530
604
  } = {}) {
@@ -540,7 +614,7 @@ export async function installAndConfirmService(room, cmdArgs = [], {
540
614
 
541
615
  const confirmation = () => {
542
616
  const live = snapshot(room, { platform, exec })
543
- const ready = !!room || platform !== 'darwin' || supervisorReadyMatches(readReady(), live, version, { notBefore: confirmationStartedAt })
617
+ const ready = !!room || platform === 'win32' || supervisorReadyMatches(readReady(), live, version, { notBefore: confirmationStartedAt })
544
618
  if (live?.version === version && ready) {
545
619
  stderr.write(` ✓ bridge install confirmed — running v${version}.\n`)
546
620
  return true
@@ -548,14 +622,10 @@ export async function installAndConfirmService(room, cmdArgs = [], {
548
622
  return false
549
623
  }
550
624
 
551
- // systemd's restart is synchronous. Windows cannot prove a live Startup process,
552
- // so this exact-runtime confirmation is used only where a service manager exists.
553
- if (platform !== 'darwin') {
554
- if (platform === 'win32') return true
555
- const confirmed = confirmation()
556
- if (!confirmed) stderr.write(` ⚠ install v${version} was not confirmed: the service manager cannot prove that runtime is running.\n`)
557
- return confirmed
558
- }
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
559
629
 
560
630
  const deadline = now() + Math.max(0, Number(timeoutMs) || 0)
561
631
  const expectedTarget = label(room)
@@ -676,7 +746,7 @@ export async function updateAndConfirmService(room, {
676
746
  },
677
747
  now = Date.now,
678
748
  sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
679
- timeoutMs = 55000,
749
+ timeoutMs = 85000,
680
750
  pollMs = 250,
681
751
  stderr = process.stderr,
682
752
  } = {}) {
@@ -699,7 +769,7 @@ export async function updateAndConfirmService(room, {
699
769
  const live = snapshot(room, { platform, exec })
700
770
  const versionChanged = !!before && before.version !== target
701
771
  const processChanged = !!before?.pid && !!live?.pid && before.pid !== live.pid
702
- const ready = !!room || platform !== 'darwin' || supervisorReadyMatches(readReady(), live, target, { notBefore: confirmationStartedAt })
772
+ const ready = !!room || platform === 'win32' || supervisorReadyMatches(readReady(), live, target, { notBefore: confirmationStartedAt })
703
773
  if (live?.version === target && (versionChanged || processChanged) && ready) {
704
774
  stderr.write(` ✓ bridge restart confirmed — running v${target}.\n`)
705
775
  return { ok: true, live }
@@ -712,14 +782,10 @@ export async function updateAndConfirmService(room, {
712
782
  else stderr.write(` ⚠ update v${target} was not confirmed: the service manager cannot prove the new runtime is running.\n`)
713
783
  }
714
784
 
715
- // systemd's restart is synchronous. The Windows Startup-folder tier has no
716
- // authoritative live-service identity, so callers use updateService directly
717
- // and label it as a next-launch update rather than a completed restart.
718
- if (platform !== 'darwin') {
719
- const proof = confirmation()
720
- if (!proof.ok) printUnconfirmed(proof)
721
- return proof.ok
722
- }
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
723
789
 
724
790
  const deadline = now() + Math.max(0, Number(timeoutMs) || 0)
725
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
+ }