thinkpool-pair 0.7.317 → 0.7.319

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/account.mjs CHANGED
@@ -15,7 +15,7 @@ import { createClient } from '@supabase/supabase-js'
15
15
  import os from 'node:os'
16
16
  import { saveAuth, loadAuth, markAuthReconnectRequired, loadDirs, bindDir, loadServed, rememberServed, loadDefaultDir, saveDefaultDir, claudeRecentProjectDir } from './auth-store.mjs'
17
17
  import { isSafeToRestart } from './update-gate.mjs'
18
- import { makeThrottledTrack, presenceSelfEchoObservation } from './presence.mjs'
18
+ import { makeThrottledTrack, presenceRecoveryEligible, presenceSelfEchoObservation, reducePresenceChannelEvidence } from './presence.mjs'
19
19
  import { publicKeyB64, announceProviders, listProviders, addProvider, addProviderModel, removeProvider, unseal } from './providers.mjs'
20
20
  import { supervisorServes, supervisorRoomsToStop } from './serve-consent.mjs'
21
21
  import { resolveServeDir } from './serve-dir.mjs'
@@ -23,6 +23,7 @@ import { pairKeyFor, pairTopic, CROSSROOM_BUS } from './cross-terminal.mjs'
23
23
  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
+ import { clearSupervisorReady, supervisorPresenceEchoed, writeSupervisorReady } from './supervisor-ready.mjs'
26
27
 
27
28
  const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version } catch { return null } })()
28
29
 
@@ -410,6 +411,10 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
410
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')
411
412
  process.exit(0)
412
413
  }
414
+ // We own account.lock now, so any previous readiness receipt is stale. A fresh
415
+ // receipt is written only after the first reconciliation tick AND a server-side
416
+ // presence self-echo prove that this exact supervisor finished bootstrapping.
417
+ clearSupervisorReady()
413
418
  const releaseStartupAnchor = createStartupAnchor()
414
419
  // F2: NEVER exit(1) on a refresh failure. The old code exited 1 here, and launchd
415
420
  // KeepAlive respawned us within seconds → refresh again → the 16× crash storm (and,
@@ -674,16 +679,35 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
674
679
  // and stamps presenceSyncSeenAt. acctSubscribedAt bounds the startup case where
675
680
  // SUBSCRIBED fires but the first sync never arrives: that is a broken Realtime
676
681
  // channel, not an unknowable state, once the listener was attached before join.
677
- let presenceSyncSeenAt = 0
678
- let acctSubscribedAt = 0
679
- acct.on('presence', { event: 'sync' }, () => { presenceSyncSeenAt = Date.now() })
682
+ let presenceEvidence = reducePresenceChannelEvidence()
683
+ let initialTickDone = false
684
+ let readinessWritten = false
685
+ const publishReadiness = () => {
686
+ if (readinessWritten || !initialTickDone) return
687
+ let present = false
688
+ try { present = supervisorPresenceEchoed(acct.presenceState?.(), machine, BRIDGE_ID, VERSION) } catch { present = false }
689
+ if (!present) return
690
+ readinessWritten = writeSupervisorReady({ version: VERSION, bridgeId: BRIDGE_ID })
691
+ if (!readinessWritten) process.stderr.write('\n ◇ Could not record local supervisor readiness; the bridge remains running but installers will not claim it was confirmed.\n')
692
+ }
693
+ acct.on('presence', { event: 'sync' }, () => {
694
+ presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, 'sync')
695
+ publishReadiness()
696
+ })
680
697
 
681
698
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
682
699
  // (network blip, or a token swap mid-flight) rejoins the channel and must
683
700
  // re-announce, or the dashboard would read "no bridge" until the next restart.
684
701
  await new Promise((res) => acct.subscribe((st) => {
685
- if (st === 'SUBSCRIBED') { if (!acctSubscribedAt) acctSubscribedAt = Date.now(); pushPresence(); res() }
702
+ if (st === 'SUBSCRIBED') {
703
+ presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, st)
704
+ pushPresence(); res()
705
+ }
686
706
  else if (st === 'CLOSED' || st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
707
+ // Invalidate the old generation before inspecting presenceState again. The
708
+ // channel retains a local self key across failures; accepting that cache is
709
+ // what left the managed service zombified while a fresh foreground run worked.
710
+ presenceEvidence = reducePresenceChannelEvidence(presenceEvidence, st)
687
711
  // Realtime dropped (the 2026-07-08 `realtime CLOSED` blip). The socket normally
688
712
  // auto-reconnects, but nudge it so presence — and the claim heartbeat that shares
689
713
  // this client — recover instead of the loop standing by against its own stale row.
@@ -1120,6 +1144,8 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1120
1144
 
1121
1145
  await tick()
1122
1146
  const iv = setInterval(tick, 15000)
1147
+ initialTickDone = true
1148
+ publishReadiness()
1123
1149
  releaseStartupAnchor()
1124
1150
  const updateTimers = [] // auto-update poll/apply timers — cleared in stop() so none fire post-exit
1125
1151
  let stopping = false
@@ -1160,12 +1186,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1160
1186
  const presenceWatch = setInterval(() => {
1161
1187
  if (stopping) return
1162
1188
  const now = Date.now()
1189
+ const recoveryEligible = presenceRecoveryEligible({ claimHeld, lastClaimOkAt, now })
1163
1190
  let present = false
1164
1191
  try { present = !!acct.presenceState?.()[machine] } catch { present = false }
1165
1192
  const observation = presenceSelfEchoObservation({
1166
- tracking: claimHeld,
1167
- subscribedAt: acctSubscribedAt,
1168
- syncSeenAt: presenceSyncSeenAt,
1193
+ // A fresh HTTP claim distinguishes a Realtime-only zombie from a full
1194
+ // connectivity outage. In the latter case, wait in-process; a respawn cannot
1195
+ // restore the network and launchd would otherwise loop indefinitely.
1196
+ tracking: recoveryEligible,
1197
+ subscribedAt: Math.max(presenceEvidence.subscribedAt, lastClaimOkAt),
1198
+ syncSeenAt: presenceEvidence.syncSeenAt,
1169
1199
  present,
1170
1200
  missingSince: selfEchoMissingSince,
1171
1201
  now,
@@ -1199,6 +1229,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1199
1229
  clearInterval(presenceWatch)
1200
1230
  for (const t of updateTimers) { try { clearInterval(t) } catch { /* noop */ } } // clearInterval clears Timeouts too
1201
1231
  keepFresh.cancel()
1232
+ clearSupervisorReady({ pid: process.pid })
1202
1233
  releaseSingleton()
1203
1234
  for (const c of children.values()) { try { c.kill(sig || 'SIGTERM') } catch { /* noop */ } }
1204
1235
  pairBus.close()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.317",
3
+ "version": "0.7.319",
4
4
  "description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -89,6 +89,7 @@
89
89
  "terminal-row-reconcile.mjs",
90
90
  "switch-provider.mjs",
91
91
  "service.mjs",
92
+ "supervisor-ready.mjs",
92
93
  "presence.mjs",
93
94
  "account.mjs",
94
95
  "auth-store.mjs",
package/presence.mjs CHANGED
@@ -57,6 +57,40 @@ export function makeThrottledTrack(channel, { minMs = 5000, onStatus = null } =
57
57
  }
58
58
  }
59
59
 
60
+ // A RealtimeChannel keeps its last presenceState() locally. After CHANNEL_ERROR /
61
+ // CLOSED that cache can still contain our own key even though the server (and every
62
+ // dashboard subscriber) has already removed it. Treat every subscribe/error edge as
63
+ // a new evidence generation: only a presence sync delivered in that generation may
64
+ // certify the cached self key.
65
+ export function reducePresenceChannelEvidence(state = {}, event, now = Date.now()) {
66
+ const current = {
67
+ subscribedAt: Number(state.subscribedAt) || 0,
68
+ syncSeenAt: Number(state.syncSeenAt) || 0,
69
+ }
70
+ if (event === 'sync') return { ...current, syncSeenAt: now }
71
+ if (event === 'SUBSCRIBED') {
72
+ // A reconnect loop may report SUBSCRIBED repeatedly without ever delivering
73
+ // presence sync. Preserve the first unsynced boundary so retries cannot keep
74
+ // pushing the watchdog deadline out forever.
75
+ if (current.subscribedAt && !current.syncSeenAt) return current
76
+ return { subscribedAt: now, syncSeenAt: 0 }
77
+ }
78
+ if (event === 'CLOSED' || event === 'CHANNEL_ERROR' || event === 'TIMED_OUT') {
79
+ // Likewise, repeated error callbacks are one failure episode until a fresh
80
+ // sync proves recovery.
81
+ if (current.subscribedAt && !current.syncSeenAt) return current
82
+ return { subscribedAt: now, syncSeenAt: 0 }
83
+ }
84
+ return current
85
+ }
86
+
87
+ // Recycling a websocket is useful only when plain HTTP is healthy enough to prove
88
+ // this is a Realtime-only wedge. During a full network/Supabase outage, respawning a
89
+ // launchd service cannot help and merely creates a restart storm.
90
+ export function presenceRecoveryEligible({ claimHeld, lastClaimOkAt, now, maxClaimSilenceMs = 40_000 }) {
91
+ return !!claimHeld && Number.isFinite(lastClaimOkAt) && (now - lastClaimOkAt) < maxClaimSilenceMs
92
+ }
93
+
60
94
  /* ─────────────────────────────────────────────────────────────
61
95
  presenceSelfEchoVerdict — the pure trigger for the account
62
96
  supervisor's presence self-echo watchdog (2026-07-08 recurrence).
package/service.mjs CHANGED
@@ -23,6 +23,7 @@ import fs from 'node:fs'
23
23
  import path from 'node:path'
24
24
  import { execSync } from 'node:child_process'
25
25
  import { hostMemoryAdmission } from './host-memory.mjs'
26
+ import { readSupervisorReady, supervisorReadyMatches } from './supervisor-ready.mjs'
26
27
 
27
28
  // Service identity. Account mode has no room → a single stable id so there's
28
29
  // exactly one account service per machine (a second install replaces it).
@@ -306,6 +307,8 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
306
307
  `status=${shq(statusFile)}`,
307
308
  'tmp="$status.tmp.$$"',
308
309
  'ok=0',
310
+ 'live_pid=""',
311
+ 'stable=0',
309
312
  'had_previous=0',
310
313
  'if [ -f "$target_file" ]; then cp "$target_file" "$backup_file" && had_previous=1; fi',
311
314
  'launchctl bootout "$dom/$target" 2>/dev/null || true',
@@ -313,7 +316,7 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
313
316
  'cp "$staged_file" "$target_file"',
314
317
  'launchctl enable "$dom/$target" 2>/dev/null || true',
315
318
  'for i in $(seq 1 30); do launchctl bootstrap "$dom" "$target_file" 2>/dev/null && break; sleep 0.3; done',
316
- 'for i in $(seq 1 60); do if launchctl print "$dom/$target" 2>/dev/null | grep -F -- "$expected" >/dev/null && launchctl print "$dom/$target" 2>/dev/null | grep -F "state = running" >/dev/null; then ok=1; break; fi; sleep 0.5; 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',
317
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`,
318
321
  'mv "$tmp" "$status"',
319
322
  'rm -f "$staged_file" "$backup_file" "$helper_file"',
@@ -332,6 +335,53 @@ export function buildDarwinReloadHandoff({ targetLabel, targetFile, stagedFile,
332
335
  return { helperLabel, helperFile, backupFile, statusFile, logFile, script, content }
333
336
  }
334
337
 
338
+ const accountLockFile = () => path.join(os.homedir(), '.thinkpool-pair', 'account.lock')
339
+ const processAlive = (pid) => {
340
+ try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
341
+ }
342
+
343
+ // A first background install can race a foreground/orphaned ThinkPool bridge that
344
+ // still owns account.lock. The launchd child then exits 0, satisfying a momentary
345
+ // `state = running` probe while KeepAlive correctly declines to restart it. Retire
346
+ // only a PID whose command is demonstrably a packaged ThinkPool bridge; fail closed
347
+ // on PID reuse or an unrelated process.
348
+ export async function retireUnmanagedBridgeLock({
349
+ lockFile = accountLockFile(),
350
+ readLock = () => fs.readFileSync(lockFile, 'utf8'),
351
+ removeLock = () => fs.rmSync(lockFile, { force: true }),
352
+ commandForPid = (pid) => String(execSync(`ps -p ${pid} -o command=`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })),
353
+ alive = processAlive,
354
+ signal = (pid, sig) => process.kill(pid, sig),
355
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
356
+ waitMs = 2500,
357
+ pollMs = 100,
358
+ stderr = process.stderr,
359
+ } = {}) {
360
+ let pid = 0
361
+ try { pid = parseInt(String(readLock()).trim(), 10) || 0 } catch { return true }
362
+ const removeIfSameAndDead = () => {
363
+ if (alive(pid)) return false
364
+ try { if ((parseInt(String(readLock()).trim(), 10) || 0) === pid) removeLock() } catch { /* already gone */ }
365
+ return true
366
+ }
367
+ if (!pid || pid === process.pid || removeIfSameAndDead()) return true
368
+ let command = ''
369
+ try { command = commandForPid(pid) } catch { return removeIfSameAndDead() }
370
+ const packagedBridge = /(?:node_modules[\\/]thinkpool-pair|\.npm[\\/]_npx|\.thinkpool-pair[\\/]runtimes)[\\/][^\n]*bridge\.mjs(?:\s|$)/.test(command)
371
+ if (!packagedBridge) {
372
+ stderr.write(` ⚠ account.lock belongs to PID ${pid}, but it is not a verified thinkpool-pair bridge; refusing to signal an unrelated process.\n`)
373
+ return false
374
+ }
375
+ stderr.write(` ◆ stopping previous thinkpool-pair bridge (PID ${pid}) before installing the background service…\n`)
376
+ try { signal(pid, 'SIGTERM') } catch { if (removeIfSameAndDead()) return true }
377
+ const polls = Math.max(1, Math.ceil(Math.max(0, Number(waitMs) || 0) / Math.max(1, Number(pollMs) || 1)))
378
+ for (let i = 0; i < polls; i++) { if (removeIfSameAndDead()) return true; await sleep(Math.max(1, Number(pollMs) || 1)) }
379
+ try { signal(pid, 'SIGKILL') } catch { /* checked below */ }
380
+ for (let i = 0; i < 5; i++) { if (removeIfSameAndDead()) return true; await sleep(Math.max(1, Number(pollMs) || 1)) }
381
+ stderr.write(` ⚠ previous thinkpool-pair bridge PID ${pid} did not stop; background install aborted to avoid two competing bridges.\n`)
382
+ return false
383
+ }
384
+
335
385
  function startDarwinReloadHandoff(a, version) {
336
386
  const stagedFile = path.join(a.logDir, `service-update-${process.pid}-${Date.now()}.plist`)
337
387
  const h = buildDarwinReloadHandoff({ targetLabel: labelFromFile(a.file), targetFile: a.file, stagedFile, expectedRuntime: a.expectedRuntime, version })
@@ -467,6 +517,8 @@ export async function installAndConfirmService(room, cmdArgs = [], {
467
517
  install = installService,
468
518
  exec = execSync,
469
519
  snapshot = serviceRuntimeSnapshot,
520
+ prepareAccountHandoff = retireUnmanagedBridgeLock,
521
+ readReady = () => readSupervisorReady(),
470
522
  readStatus = () => {
471
523
  try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.thinkpool-pair', 'update-status.json'), 'utf8')) } catch { return null }
472
524
  },
@@ -476,11 +528,20 @@ export async function installAndConfirmService(room, cmdArgs = [], {
476
528
  pollMs = 250,
477
529
  stderr = process.stderr,
478
530
  } = {}) {
531
+ const before = snapshot(room, { platform, exec })
532
+ if (!room && platform !== 'win32' && !before) {
533
+ if (!(await prepareAccountHandoff())) {
534
+ stderr.write(' ⚠ background install aborted: the previous bridge could not be handed off safely.\n')
535
+ return false
536
+ }
537
+ }
538
+ const confirmationStartedAt = now()
479
539
  if (install(room, cmdArgs, { version, staleProof }) === false) return false
480
540
 
481
541
  const confirmation = () => {
482
542
  const live = snapshot(room, { platform, exec })
483
- if (live?.version === version) {
543
+ const ready = !!room || platform !== 'darwin' || supervisorReadyMatches(readReady(), live, version, { notBefore: confirmationStartedAt })
544
+ if (live?.version === version && ready) {
484
545
  stderr.write(` ✓ bridge install confirmed — running v${version}.\n`)
485
546
  return true
486
547
  }
@@ -508,7 +569,7 @@ export async function installAndConfirmService(room, cmdArgs = [], {
508
569
  if (status.ok === true && confirmation()) return true
509
570
  }
510
571
  if (now() >= deadline) {
511
- stderr.write(` ⚠ install v${version} timed out waiting for launchd confirmation; no running service was proven.\n`)
572
+ stderr.write(` ⚠ install v${version} timed out waiting for supervisor readiness; no connected account bridge was proven.\n`)
512
573
  return false
513
574
  }
514
575
  await sleep(Math.max(1, Number(pollMs) || 1))
@@ -609,6 +670,7 @@ export async function updateAndConfirmService(room, {
609
670
  install = installService,
610
671
  active = isServiceInstalled,
611
672
  snapshot = serviceRuntimeSnapshot,
673
+ readReady = () => readSupervisorReady(),
612
674
  readStatus = () => {
613
675
  try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.thinkpool-pair', 'update-status.json'), 'utf8')) } catch { return null }
614
676
  },
@@ -619,6 +681,7 @@ export async function updateAndConfirmService(room, {
619
681
  stderr = process.stderr,
620
682
  } = {}) {
621
683
  const before = snapshot(room, { platform, exec })
684
+ const confirmationStartedAt = now()
622
685
  let target = null
623
686
  const captureTarget = (...args) => {
624
687
  const result = exec(...args)
@@ -636,7 +699,8 @@ export async function updateAndConfirmService(room, {
636
699
  const live = snapshot(room, { platform, exec })
637
700
  const versionChanged = !!before && before.version !== target
638
701
  const processChanged = !!before?.pid && !!live?.pid && before.pid !== live.pid
639
- if (live?.version === target && (versionChanged || processChanged)) {
702
+ const ready = !!room || platform !== 'darwin' || supervisorReadyMatches(readReady(), live, target, { notBefore: confirmationStartedAt })
703
+ if (live?.version === target && (versionChanged || processChanged) && ready) {
640
704
  stderr.write(` ✓ bridge restart confirmed — running v${target}.\n`)
641
705
  return { ok: true, live }
642
706
  }
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ export const ACCOUNT_READY_FILE = path.join(os.homedir(), '.thinkpool-pair', 'account-ready.json')
6
+
7
+ export function readSupervisorReady(file = ACCOUNT_READY_FILE, { fsMod = fs } = {}) {
8
+ try {
9
+ const value = JSON.parse(fsMod.readFileSync(file, 'utf8'))
10
+ if (!value || typeof value !== 'object') return null
11
+ return value
12
+ } catch { return null }
13
+ }
14
+
15
+ export function supervisorReadyMatches(receipt, live, version, { notBefore = 0 } = {}) {
16
+ return !!receipt && !!live?.pid &&
17
+ String(receipt.pid) === String(live.pid) &&
18
+ receipt.version === version &&
19
+ typeof receipt.bridgeId === 'string' && receipt.bridgeId.length > 0 &&
20
+ Number.isFinite(receipt.readyAt) && receipt.readyAt >= notBefore
21
+ }
22
+
23
+ // Supabase presence is keyed by hostname, so the key can outlive a crashed
24
+ // process briefly. Require the unique process bridgeId inside the echoed metas;
25
+ // hostname existence alone can certify the previous supervisor's stale presence.
26
+ export function supervisorPresenceEchoed(state, machine, bridgeId, version) {
27
+ if (!state || typeof state !== 'object' || typeof machine !== 'string' || !machine || typeof bridgeId !== 'string' || !bridgeId) return false
28
+ const metas = Array.isArray(state[machine]) ? state[machine] : []
29
+ return metas.some((meta) => meta?.bridgeId === bridgeId && meta?.version === version)
30
+ }
31
+
32
+ export function writeSupervisorReady({ pid = process.pid, version, bridgeId, readyAt = Date.now() }, file = ACCOUNT_READY_FILE, { fsMod = fs } = {}) {
33
+ if (!Number.isInteger(Number(pid)) || Number(pid) <= 0 || typeof version !== 'string' || !version || typeof bridgeId !== 'string' || !bridgeId) return false
34
+ const tmp = `${file}.tmp.${process.pid}.${Date.now()}`
35
+ try {
36
+ fsMod.mkdirSync(path.dirname(file), { recursive: true })
37
+ fsMod.writeFileSync(tmp, `${JSON.stringify({ pid: Number(pid), version, bridgeId, readyAt })}\n`, { mode: 0o600 })
38
+ fsMod.renameSync(tmp, file)
39
+ return true
40
+ } catch {
41
+ try { fsMod.rmSync(tmp, { force: true }) } catch { /* noop */ }
42
+ return false
43
+ }
44
+ }
45
+
46
+ // Remove an owned receipt on shutdown. With no pid, clear stale startup evidence
47
+ // after this process has acquired the singleton lock and therefore owns account mode.
48
+ export function clearSupervisorReady({ pid = null, file = ACCOUNT_READY_FILE, fsMod = fs } = {}) {
49
+ try {
50
+ if (pid !== null) {
51
+ const receipt = readSupervisorReady(file, { fsMod })
52
+ if (!receipt || String(receipt.pid) !== String(pid)) return false
53
+ }
54
+ fsMod.rmSync(file, { force: true })
55
+ return true
56
+ } catch { return false }
57
+ }