thinkpool-pair 0.7.169 → 0.7.171

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
@@ -116,17 +116,170 @@ export async function runLogin(SUPABASE_URL, SUPABASE_ANON, WEB_BASE) {
116
116
  await new Promise(() => {})
117
117
  }
118
118
 
119
- // Exchange the stored refresh token for a live session + authed client.
119
+ // A promise that resolves after ms, never keeping the loop alive on its own.
120
+ const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.() })
121
+
122
+ // Reject a promise if it doesn't settle within ms — so a wedged realtime socket can't
123
+ // freeze a Supabase call (and the tick that awaits it) forever (the 2026-07-08 wedge:
124
+ // a tick hung on a half-open socket, the re-entrancy guard stuck true, the claim
125
+ // heartbeat stopped, and the bridge stood by against its own 564s-stale row).
126
+ export function withTimeout(promise, ms, label = 'op') {
127
+ return new Promise((resolve, reject) => {
128
+ const t = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); t.unref?.()
129
+ Promise.resolve(promise).then((v) => { clearTimeout(t); resolve(v) }, (e) => { clearTimeout(t); reject(e) })
130
+ })
131
+ }
132
+
133
+ // Decode a JWT's `exp` (unix seconds) WITHOUT verifying — used only to decide whether the
134
+ // saved access token is fresh enough to skip a refresh. 0 on any parse failure (→ refresh).
135
+ function jwtExpSeconds(token) {
136
+ try {
137
+ const seg = String(token).split('.')[1]
138
+ const json = Buffer.from(seg.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
139
+ const exp = JSON.parse(json)?.exp
140
+ return typeof exp === 'number' ? exp : 0
141
+ } catch { return 0 }
142
+ }
143
+
144
+ // F1 predicate (pure, testable): is the saved access token still worth adopting WITHOUT a
145
+ // refresh? True iff we have an access token whose expiry (from the persisted expires_at, or
146
+ // decoded from the JWT) is more than marginSec ahead of now. Skipping the refresh here is
147
+ // one fewer rotation → one fewer race window / mid-rotation brick.
148
+ export function savedTokenStillValid(saved, nowSec = Math.floor(Date.now() / 1000), marginSec = 300) {
149
+ if (!saved?.access_token) return false
150
+ const expSec = saved.expires_at || jwtExpSeconds(saved.access_token)
151
+ return !!expSec && (expSec - nowSec) > marginSec
152
+ }
153
+
154
+ // ── cross-process refresh serialization (F3) ────────────────────────────────
155
+ // Two bridges refreshing ONE refresh token outside Supabase's short reuse window makes
156
+ // Supabase revoke the entire token FAMILY — every future refresh then fails permanently
157
+ // until a fresh login. A short-lived lock file serializes the actual network refresh
158
+ // across processes; a lock older than staleMs is stale (holder crashed mid-refresh) and
159
+ // is taken over. fs errors fail-OPEN (return true) — a lock we can't manage must never
160
+ // block a genuine refresh. Exported for the test harness.
161
+ const REFRESH_LOCK = path.join(os.homedir(), '.thinkpool-pair', 'refresh.lock')
162
+ export function acquireRefreshLock(lockPath = REFRESH_LOCK, staleMs = 30_000) {
163
+ try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }) } catch { /* noop */ }
164
+ for (let i = 0; i < 2; i++) {
165
+ try {
166
+ const fd = fs.openSync(lockPath, 'wx') // atomic create-exclusive
167
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() })); fs.closeSync(fd)
168
+ return true
169
+ } catch (e) {
170
+ if (e.code !== 'EEXIST') return true // unmanageable → fail open
171
+ let age = Infinity
172
+ try { age = Date.now() - (JSON.parse(fs.readFileSync(lockPath, 'utf8'))?.at || 0) } catch { age = Infinity }
173
+ if (age < staleMs) return false // a live refresh holds it → back off
174
+ try { fs.rmSync(lockPath, { force: true }) } catch { return true } // stale → drop + retry
175
+ }
176
+ }
177
+ return false
178
+ }
179
+ export function releaseRefreshLock(lockPath = REFRESH_LOCK) { try { fs.rmSync(lockPath, { force: true }) } catch { /* noop */ } }
180
+
181
+ // Build an authed session from a saved {access_token, refresh_token} WITHOUT rotating the
182
+ // refresh token. setSession validates the access token (getUser — no rotation) when it is
183
+ // still valid; callers only ever adopt a FRESH token, so it never falls through to a refresh.
184
+ async function adoptSavedSession(sb, saved) {
185
+ if (!saved?.access_token || !saved?.refresh_token) return null
186
+ try {
187
+ const { data, error } = await sb.auth.setSession({ access_token: saved.access_token, refresh_token: saved.refresh_token })
188
+ if (error || !data?.session) return null
189
+ return data.session
190
+ } catch { return null }
191
+ }
192
+
193
+ // One refresh, serialized across processes + adopting a sibling's rotation (F3). Steps:
194
+ // • auth.json already holds a DIFFERENT refresh token than the one we carry → a sibling
195
+ // (or a re-mint) rotated: ADOPT the disk pair instead of refreshing again.
196
+ // • else take the refresh lock; if a sibling holds it, wait briefly and adopt THEIR
197
+ // rotated result rather than racing a second rotation (which revokes the family).
198
+ // • under the lock, re-read disk once more, then refreshSession → saveAuth.
199
+ // Deps are injectable for the test harness. Returns { session, adopted } or null.
200
+ export async function refreshAccountSession(sb, holdRefreshToken, deps = {}) {
201
+ const { load = loadAuth, save = saveAuth, acquire = acquireRefreshLock, release = releaseRefreshLock, wait = sleep } = deps
202
+ const adoptIfRotated = async (rt) => {
203
+ const d = load()
204
+ if (d?.refresh_token && rt && d.refresh_token !== rt) {
205
+ const s = await adoptSavedSession(sb, d)
206
+ if (s) return { session: s, adopted: true }
207
+ }
208
+ return null
209
+ }
210
+ let adopted = await adoptIfRotated(holdRefreshToken)
211
+ if (adopted) return adopted
212
+ const rt0 = load()?.refresh_token || holdRefreshToken
213
+ if (!rt0) return null
214
+ let locked = acquire()
215
+ if (!locked) {
216
+ // A sibling is mid-refresh. Wait for it to finish and adopt its rotated pair rather
217
+ // than firing a competing rotation (the family-revoke race).
218
+ for (let i = 0; i < 20; i++) {
219
+ await wait(250)
220
+ adopted = await adoptIfRotated(rt0)
221
+ if (adopted) return adopted
222
+ if (acquire()) { locked = true; break }
223
+ }
224
+ if (!locked) return null // couldn't get the lock or adopt — caller retries later
225
+ }
226
+ try {
227
+ adopted = await adoptIfRotated(rt0) // re-check under the lock (sibling may have just finished)
228
+ if (adopted) return adopted
229
+ const rt = load()?.refresh_token || rt0
230
+ const { data, error } = await sb.auth.refreshSession({ refresh_token: rt })
231
+ if (error || !data?.session) return null
232
+ save(data.session) // atomic (auth-store) — never a torn token file
233
+ return { session: data.session, adopted: false }
234
+ } finally { if (locked) release() }
235
+ }
236
+
237
+ // Exchange the stored credentials for a live session + authed client.
120
238
  export async function authedClient(SUPABASE_URL, SUPABASE_ANON) {
121
239
  const a = loadAuth()
122
240
  if (!a?.refresh_token) return null
123
241
  const sb = createClient(SUPABASE_URL, SUPABASE_ANON, { auth: { persistSession: false, autoRefreshToken: false } })
124
- try {
125
- const { data, error } = await sb.auth.refreshSession({ refresh_token: a.refresh_token })
126
- if (error || !data?.session) return null
127
- saveAuth(data.session) // rotate the refresh token
128
- return { sb, session: data.session }
129
- } catch { return null }
242
+ // F1: skip the refresh entirely when the saved access token is still comfortably valid
243
+ // (>5 min to expiry). Every gratuitous startup rotation is a race window — and, killed
244
+ // mid-rotation, a login-brick. Adopt the saved pair; scheduleTokenRefresh rotates near
245
+ // expiry as it already does.
246
+ if (savedTokenStillValid(a)) {
247
+ const s = await adoptSavedSession(sb, a)
248
+ if (s) return { sb, session: s }
249
+ }
250
+ // Near-expiry / missing / invalid access token → do a serialized rotation (F3).
251
+ const r = await refreshAccountSession(sb, a.refresh_token)
252
+ if (!r?.session) return null
253
+ return { sb, session: r.session }
254
+ }
255
+
256
+ // F2: startup refresh can fail transiently (offline, a sibling mid-rotation, a wedged
257
+ // socket). Retry IN-PROCESS with backoff — NEVER exit(1), because launchd KeepAlive would
258
+ // respawn us within seconds and each respawn hammers the refresh path (the 16× crash
259
+ // storm). authedClient re-reads auth.json every attempt, so a re-mint or a sibling's
260
+ // rotation is picked up on the next try. Returns null ONLY when there is no saved login
261
+ // at all (genuinely not linked) — the caller then stands down CLEANLY (exit 0, no respawn).
262
+ // Deps injectable for the test harness.
263
+ export async function authedClientWithRetry(SUPABASE_URL, SUPABASE_ANON, opts = {}) {
264
+ const { attempt = authedClient, load = loadAuth, wait = sleep, onRetry = null, maxAttempts = Infinity, baseMs = 15_000, capMs = 300_000 } = opts
265
+ let delay = baseMs
266
+ for (let n = 1; n <= maxAttempts; n++) {
267
+ const a = await attempt(SUPABASE_URL, SUPABASE_ANON)
268
+ if (a) return a
269
+ if (!load()?.refresh_token) return null // not linked — caller says `login`, exits 0
270
+ if (n >= maxAttempts) break
271
+ onRetry?.(delay, n)
272
+ await wait(delay)
273
+ delay = Math.min(delay * 2, capMs)
274
+ }
275
+ return null
276
+ }
277
+
278
+ // Pure predicate (2026-07-08): the claim/heartbeat loop is "wedged" when no successful
279
+ // claim has landed in ≥ quietMsThreshold (default 40s = ~2 missed 15s ticks). Extracted so
280
+ // the watchdog's trigger is unit-testable without a live socket.
281
+ export function claimLoopWedged({ lastClaimOkAt, now, quietMsThreshold = 40_000 }) {
282
+ return (now - lastClaimOkAt) >= quietMsThreshold
130
283
  }
131
284
 
132
285
  // ── keep the account JWT fresh (presence keepalive) ─────────────────────────
@@ -143,7 +296,7 @@ export async function authedClient(SUPABASE_URL, SUPABASE_ANON) {
143
296
  // skewMs — refresh this far ahead of expiry (default 90s)
144
297
  // forceMs — fixed interval override (test hook: TP_TOKEN_REFRESH_FORCE_MS)
145
298
  // retryMs — backoff after a transient failure (offline / rotation race)
146
- export function scheduleTokenRefresh({ sb, session, onRefreshed, skewMs = 90_000, forceMs = 0, retryMs = 30_000 }) {
299
+ export function scheduleTokenRefresh({ sb, session, onRefreshed, skewMs = 90_000, forceMs = 0, retryMs = 30_000, refresh = refreshAccountSession }) {
147
300
  let timer = null
148
301
  let cur = session
149
302
  let cancelled = false
@@ -162,9 +315,12 @@ export function scheduleTokenRefresh({ sb, session, onRefreshed, skewMs = 90_000
162
315
  async function tick() {
163
316
  if (cancelled) return
164
317
  try {
165
- const { data, error } = await sb.auth.refreshSession({ refresh_token: cur.refresh_token })
166
- if (error || !data?.session) throw error || new Error('no session')
167
- cur = data.session
318
+ // Serialized refresh (F3): re-reads auth.json and adopts a sibling's rotated pair
319
+ // instead of blindly refreshing with our in-memory `cur.refresh_token` — the old
320
+ // tick retried with a stale token it held and could lose the family to a race.
321
+ const r = await refresh(sb, cur.refresh_token)
322
+ if (!r?.session) throw new Error('no session')
323
+ cur = r.session
168
324
  // Hand the fresh token to the live realtime socket so the presence
169
325
  // connection stays authed across the swap (no drop, no rejoin needed).
170
326
  try { sb.realtime?.setAuth?.(cur.access_token) } catch { /* noop */ }
@@ -196,17 +352,22 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
196
352
  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')
197
353
  process.exit(0)
198
354
  }
199
- const auth = await authedClient(SUPABASE_URL, SUPABASE_ANON)
355
+ // F2: NEVER exit(1) on a refresh failure. The old code exited 1 here, and launchd
356
+ // KeepAlive respawned us within seconds → refresh again → the 16× crash storm (and,
357
+ // worse, hammering the refresh path burns rotations and can revoke the token family).
358
+ // Instead retry IN-PROCESS with backoff, re-reading auth.json each time so a headless
359
+ // re-mint or a sibling's rotation is picked up without a restart. Do NOT delete
360
+ // auth.json — a transient failure must never wipe a still-valid login (2026-06-17).
361
+ const auth = await authedClientWithRetry(SUPABASE_URL, SUPABASE_ANON, {
362
+ onRetry: (delay) => process.stderr.write(`\n ◇ Couldn't refresh your account session yet (offline, or another bridge is mid-rotation) — retrying in ${Math.round(delay / 1000)}s. Saved login left in place.\n`),
363
+ })
200
364
  if (!auth) {
201
365
  releaseSingleton()
202
- // Do NOT delete auth.json here. A refresh can fail transiently (offline) or
203
- // lose a refresh-token rotation race with another bridge sharing this login —
204
- // wiping the file on that destroys a still-valid login. Leave it: only an
205
- // explicit `login` should ever replace the token (saveAuth overwrites it).
206
- // (2026-06-17: a second account supervisor raced the token and the old
207
- // clearAuth() here deleted a live Pro login mid-session.)
208
- console.error('\n ◇ Couldn\'t refresh your account session (offline, or another bridge\n may be using this login). Your saved login was left in place.\n If this keeps happening, re-link: npx thinkpool-pair login\n')
209
- process.exit(1)
366
+ // Reached ONLY when there is no saved login at all (not linked). Stand down CLEANLY
367
+ // with exit 0: launchd KeepAlive has SuccessfulExit=false, so exit 0 is NOT respawned
368
+ // — no crash storm for the "never logged in" case. The user links with `login`.
369
+ console.error('\n ◇ Not linked to a ThinkPool account on this machine.\n Link it: npx thinkpool-pair login\n')
370
+ process.exit(0)
210
371
  }
211
372
  const { sb, session } = auth
212
373
  const email = session.user?.email || 'your account'
@@ -422,7 +583,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
422
583
  // Re-track on EVERY (re)subscribe, not just the first: a realtime reconnect
423
584
  // (network blip, or a token swap mid-flight) rejoins the channel and must
424
585
  // re-announce, or the dashboard would read "no bridge" until the next restart.
425
- await new Promise((res) => acct.subscribe((st) => { if (st === 'SUBSCRIBED') { pushPresence(); res() } }))
586
+ await new Promise((res) => acct.subscribe((st) => {
587
+ if (st === 'SUBSCRIBED') { pushPresence(); res() }
588
+ else if (st === 'CLOSED' || st === 'CHANNEL_ERROR' || st === 'TIMED_OUT') {
589
+ // Realtime dropped (the 2026-07-08 `realtime CLOSED` blip). The socket normally
590
+ // auto-reconnects, but nudge it so presence — and the claim heartbeat that shares
591
+ // this client — recover instead of the loop standing by against its own stale row.
592
+ // The callback stays wired for the channel's life, so later rejoins re-fire it.
593
+ try { sb.realtime?.connect?.() } catch { /* noop */ }
594
+ }
595
+ }))
426
596
 
427
597
  // Keep the account JWT fresh so the presence socket never gets deauthed at
428
598
  // expiry (the 2026-06-17 "No bridge connected while sessions run" bug). On each
@@ -451,9 +621,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
451
621
  const BRIDGE_ID = randomUUID()
452
622
  let claimHeld = false
453
623
  let standbyHost = null
624
+ // Wall-clock of the last SUCCESSFUL claim RPC. The heartbeat watchdog (below) uses it to
625
+ // detect a wedged loop and self-heal — the 2026-07-08 outage stood by against its OWN
626
+ // 564s-stale row because the tick had frozen and the heartbeat simply stopped.
627
+ let lastClaimOkAt = Date.now()
454
628
  const refreshClaim = async () => {
455
629
  try {
456
- const { data } = await sb.rpc('claim_bridge', { p_bridge_id: BRIDGE_ID, p_hostname: machine })
630
+ // Time-box the RPC: a half-open realtime socket must not freeze the heartbeat (and
631
+ // the tick that awaits it) forever. On timeout we throw → keep the prior held state.
632
+ const { data } = await withTimeout(sb.rpc('claim_bridge', { p_bridge_id: BRIDGE_ID, p_hostname: machine }), 10_000, 'claim_bridge')
633
+ lastClaimOkAt = Date.now()
457
634
  const c = Array.isArray(data) ? data[0] : data
458
635
  const held = !!c?.held
459
636
  const host = c?.holder_host || '?'
@@ -578,12 +755,20 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
578
755
  // awaits a code_sessions query before children.set(), so two overlapping runs can
579
756
  // both see children.has(room) false and DOUBLE-SPAWN a 2nd bridge for one room
580
757
  // (two bridges per room = the CASCADE-delete / ghost-terminal hazard). Serialize.
581
- let ticking = false
758
+ // SELF-HEALING guard (2026-07-08): a plain boolean stuck `true` was the wedge — a tick
759
+ // that hung on a half-open socket never ran its `finally`, so `ticking` stayed true and
760
+ // every later tick returned early forever (heartbeat dead, own row goes 564s stale). Use
761
+ // a start-timestamp instead: a prior run older than 60s is presumed wedged and we proceed
762
+ // anyway (the timed-out awaits inside now guarantee it eventually unwinds).
763
+ let tickingSince = 0
582
764
  const tick = async () => {
583
- if (ticking) return
584
- ticking = true
585
- try { await tickBody() } finally { ticking = false }
765
+ const now = Date.now()
766
+ if (tickingSince && now - tickingSince < 60_000) return // a genuine in-flight tick
767
+ tickingSince = now
768
+ try { await tickBody() } finally { if (tickingSince === now) tickingSince = 0 }
586
769
  }
770
+ // Expose for the watchdog to force-unwedge the guard after a reconnect.
771
+ const clearTickGuard = () => { tickingSince = 0 }
587
772
  const tickBody = async () => {
588
773
  // Hold the account claim before serving anything (cross-machine guard). When
589
774
  // standing by, make sure we're serving nothing and wait for the next tick.
@@ -778,6 +963,24 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
778
963
  const iv = setInterval(tick, 15000)
779
964
  const updateTimers = [] // auto-update poll/apply timers — cleared in stop() so none fire post-exit
780
965
  let stopping = false
966
+
967
+ // ── claim/heartbeat watchdog (2026-07-08) ──────────────────────────────────
968
+ // The 15s tick carries the claim heartbeat. If anything inside a tick wedged (a half-open
969
+ // realtime socket), the heartbeat stopped, our own claim row went stale, and — because a
970
+ // same-host holder is never displaced by itself — the bridge stood by FOREVER against its
971
+ // own ghost (the 564s-stale-own-hostname outage). This timer runs on its OWN interval so a
972
+ // wedged tick can't starve it: when the claim has been quiet ≥2 ticks it nudges realtime
973
+ // to reconnect, force-clears the re-entrancy guard, and re-issues the heartbeat directly
974
+ // (claim_bridge is a plain HTTP RPC — it recovers even while the socket is still flapping).
975
+ const heartbeatWatch = setInterval(async () => {
976
+ if (stopping) return
977
+ if (!claimLoopWedged({ lastClaimOkAt, now: Date.now() })) return
978
+ console.log(`claim_watchdog sup=${SUP_ID} quiet_ms=${Date.now() - lastClaimOkAt} — reconnecting realtime + re-claiming`)
979
+ try { sb.realtime?.connect?.() } catch { /* noop */ }
980
+ clearTickGuard() // unwedge the guard so the next scheduled tick can run
981
+ try { await refreshClaim(); pushPresence() } catch { /* the next tick retries */ }
982
+ }, 20_000)
983
+ heartbeatWatch.unref?.()
781
984
  // restart-vs-stop is the ONLY difference in exit code: a web "restart" sets this so
782
985
  // we exit non-zero (every supervisor respawns); a normal stop/shutdown exits 0.
783
986
  const stop = (sig) => {
@@ -787,6 +990,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
787
990
  // ── supervisor stand-down logging (cascade brg-instrument) ─────────────────
788
991
  console.log(`supervisor_stop sup=${SUP_ID} reason=${sig || 'unknown'} restarting=${restarting} children=${children.size}`)
789
992
  clearInterval(iv)
993
+ clearInterval(heartbeatWatch)
790
994
  for (const t of updateTimers) { try { clearInterval(t) } catch { /* noop */ } } // clearInterval clears Timeouts too
791
995
  keepFresh.cancel()
792
996
  releaseSingleton()
package/auth-store.mjs CHANGED
@@ -22,14 +22,32 @@ function ensureDir() { try { fs.mkdirSync(DIR, { recursive: true }) } catch { /*
22
22
  export function saveAuth(session) {
23
23
  if (!session?.refresh_token) return
24
24
  ensureDir()
25
+ const payload = JSON.stringify({
26
+ refresh_token: session.refresh_token,
27
+ access_token: session.access_token || null,
28
+ // Unix SECONDS. Lets the bridge adopt the saved access token WITHOUT a refresh while
29
+ // it's still comfortably valid (account.mjs F1) — every skipped rotation is one fewer
30
+ // race window (and one fewer chance to be killed mid-rotation and brick the login).
31
+ expires_at: session.expires_at || null,
32
+ email: session.user?.email || session.email || null,
33
+ savedAt: Date.now(),
34
+ })
35
+ // ATOMIC write (2026-07-08): a crash/kill/kickstart mid-write must never leave a
36
+ // truncated auth.json — an unparseable token file reads as "not linked" and bricks the
37
+ // saved login (three bridge outages in two days, one from a restart landing mid-rotation).
38
+ // Write a sibling temp file, fsync it to disk, then rename OVER auth.json — rename is
39
+ // atomic on one filesystem, so any concurrent reader sees the whole old file or the whole
40
+ // new one, never a half-written token. This does NOT undo Supabase's server-side rotation
41
+ // (that invalidates the old token the instant refreshSession returns) — the frequency of
42
+ // rotation is cut by F1/F3 in account.mjs; this guarantees the file itself is never torn.
43
+ const tmp = `${AUTH}.tmp.${process.pid}.${Date.now()}`
25
44
  try {
26
- fs.writeFileSync(AUTH, JSON.stringify({
27
- refresh_token: session.refresh_token,
28
- access_token: session.access_token || null,
29
- email: session.user?.email || session.email || null,
30
- savedAt: Date.now(),
31
- }), { mode: 0o600 })
32
- } catch { /* noop */ }
45
+ const fd = fs.openSync(tmp, 'w', 0o600)
46
+ try { fs.writeSync(fd, payload); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
47
+ fs.renameSync(tmp, AUTH)
48
+ } catch {
49
+ try { fs.rmSync(tmp, { force: true }) } catch { /* noop */ }
50
+ }
33
51
  }
34
52
  export function loadAuth() { try { return JSON.parse(fs.readFileSync(AUTH, 'utf8')) } catch { return null } }
35
53
  export function clearAuth() { try { fs.unlinkSync(AUTH) } catch { /* noop */ } }
package/bridge.mjs CHANGED
@@ -92,7 +92,7 @@ const flowBudgets = new Map()
92
92
  import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSPOST, spawnDecision, SPAWN, CROSSROOM, formatPairRoster, crossRoomPostDecision, formatRoomNow } from './cross-terminal.mjs'
93
93
  import { turnInFlight } from './update-gate.mjs'
94
94
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq } from './session-store.mjs'
95
- import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine } from './event-id.mjs'
95
+ import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, buildRecapFromLog, RECAP_CAP } from './event-id.mjs'
96
96
  import { makeThrottledTrack } from './presence.mjs'
97
97
 
98
98
  // Public client creds (the same anon values the web app ships — safe to embed).
@@ -987,6 +987,13 @@ const announce = () => {
987
987
  // host (e.g. openrouter.ai). Older web clients ignore unknown fields.
988
988
  provider: PROVIDER_INFO.provider,
989
989
  providerBaseUrl: PROVIDER_INFO.baseUrl || undefined,
990
+ // ctxCarry: this bridge re-feeds a plain-text recap of the visible chat into the
991
+ // FRESH session after a provider switch (the 2026-07-08 context-carry work —
992
+ // openStructured carryRecap → pendingRecap → sendTurn). The UI reads it as
993
+ // bridgeInfo.ctxCarry to word the switch warning honestly: WITH carry the agent
994
+ // "rejoins from a recap of this chat"; WITHOUT it (older bridge, flag absent) it
995
+ // "starts blank". Additive top-level boolean; older clients ignore the unknown key.
996
+ ctxCarry: true,
990
997
  // providerPubKey: the bridge's RSA-OAEP public key (SPKI base64), used by the
991
998
  // dashboard to seal a provider's API key so it reaches the bridge without ever
992
999
  // crossing the wire in the clear. Computed once (the keypair is stable).
@@ -1222,8 +1229,19 @@ const handleManifest = async (box, file, term) => {
1222
1229
  let m
1223
1230
  try { m = JSON.parse(fs.readFileSync(full, 'utf8')) } catch { return }
1224
1231
  if (!m?.slug) return
1225
- const readB64 = (p) => { try { return p && fs.readFileSync(p).toString('base64') } catch { return null } }
1226
- const readTxt = (p) => { try { return p && fs.readFileSync(p, 'utf8') } catch { return null } }
1232
+ // 2026-07-07: these used to swallow read errors silently — a transient
1233
+ // unreadable file (race with the render script, permissions, mid-write)
1234
+ // meant the manifest still POSTed with that field missing, and nothing
1235
+ // anywhere logged why. Surface it on stderr so a blank mockup pane in the
1236
+ // room has a breadcrumb on the host side instead of just vanishing.
1237
+ const readB64 = (p) => {
1238
+ try { return p && fs.readFileSync(p).toString('base64') }
1239
+ catch (e) { if (p) process.stderr.write(`\n ◇ mockup asset unreadable: ${p} (${e?.code || e?.message || e})\n`); return null }
1240
+ }
1241
+ const readTxt = (p) => {
1242
+ try { return p && fs.readFileSync(p, 'utf8') }
1243
+ catch (e) { if (p) process.stderr.write(`\n ◇ mockup asset unreadable: ${p} (${e?.code || e?.message || e})\n`); return null }
1244
+ }
1227
1245
  const cid = randomUUID()
1228
1246
  try {
1229
1247
  const mockupHeaders = { 'Content-Type': 'application/json' }
@@ -1505,7 +1523,7 @@ function worktreeSnapshot(cwd) {
1505
1523
  // relay STRUCTURED events. onEvent → broadcast `code-event` + print locally +
1506
1524
  // persist to the host file; tool calls round-trip through the perm card; the
1507
1525
  // rolling log replays to joiners and survives bridge restarts (session-store).
1508
- function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider }) {
1526
+ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rolePrompt, flowSessionId, flowTaskKey, cwd, reviewSliceRoots, openedAt, defer, provider, carryRecap }) {
1509
1527
  if (sessions.has(id)) return
1510
1528
  // spawnedBy: set when this lane was Dispatched (spawn_terminal). Restored from the
1511
1529
  // session store so the Ensemble flag survives a bridge restart (else a respin
@@ -2019,6 +2037,13 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
2019
2037
  }
2020
2038
  : null
2021
2039
  entry.deferred = !!defer // a lazily-booted idle terminal — the background warmer will boot it
2040
+ // Context-carry (2026-07-08): a non-resumable re-open (provider switch / non-resumable
2041
+ // restart) hands the FRESH session a plain-text recap of the visible log as its first
2042
+ // input, so the agent picks up the work instead of starting blank mid-conversation. Held
2043
+ // here (transient — NOT in sessionData, a re-restart re-derives it from the log) and fired
2044
+ // on the init `system` event (see onEvent) — a sendTurn before the input stream is consumed
2045
+ // is silently lost (the 2026-07-02 auto-resume bug). Empty string → nothing to carry.
2046
+ entry.pendingRecap = (typeof carryRecap === 'string' && carryRecap.trim()) ? carryRecap : null
2022
2047
  entry.session = startClaudeSession({
2023
2048
  cwd: cwd || process.cwd(), model, resume, mode, rolePrompt,
2024
2049
  // Live ROOM NOW tail appended to every turn's <system-reminder>: sibling lanes
@@ -2162,6 +2187,18 @@ function openStructured({ id, model, resume, log, commands, mode, spawnedBy, rol
2162
2187
  try { entry.session?.sendTurn('continue') } catch { /* not resumable — stays idle, user can continue manually */ }
2163
2188
  process.stderr.write(`\n ◆ auto-resumed interrupted turn (${id.slice(0, 8)}) — sent continue.\n`)
2164
2189
  }
2190
+ // Context-carry (2026-07-08): a lane re-opened WITHOUT an SDK resume (provider switch,
2191
+ // non-resumable restart) starts blank. Fire the text-layer recap as its first turn the
2192
+ // instant the session is live — same init-`system` gate as auto-resume above, for the
2193
+ // same reason (a sendTurn into an unconsumed input stream is lost). Fires once. If a
2194
+ // human turn arrived first, the code-turn handler already consumed + cleared it
2195
+ // (prepended to that turn), so this no-ops. Spec: docs/specs/2026-07-08-provider-switch-context-carry.md.
2196
+ if (evt.kind === 'system' && entry.pendingRecap) {
2197
+ const recap = entry.pendingRecap
2198
+ entry.pendingRecap = null
2199
+ try { entry.session?.sendTurn(recap) } catch { /* not live — stays idle, user can continue manually */ }
2200
+ process.stderr.write(`\n ◆ carried ${recap.length}c context recap into fresh session (${id.slice(0, 8)}) after provider/restore switch.\n`)
2201
+ }
2165
2202
  // A background-warmed terminal is now live — free its warmer slot so the next queued
2166
2203
  // idle terminal starts booting.
2167
2204
  if (evt.kind === 'system' && entry.warming) warmComplete(entry)
@@ -2331,6 +2368,13 @@ function respawnStructured(id, provider) {
2331
2368
  if (!s) return false
2332
2369
  // Capture the lane's identity for re-open (NOT resume — fresh SDK session).
2333
2370
  const { model, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt } = s
2371
+ // Context-carry (2026-07-08): a provider switch is not an SDK resume — the new backend
2372
+ // starts blank mid-conversation. Synthesize a plain-text recap from the VISIBLE log NOW
2373
+ // (before teardown) and hand it to the fresh session as its first turn so the agent
2374
+ // continues the work instead of re-greeting (the 109d22e1 "first message in my lane" bug).
2375
+ // Flow lanes are exempt: they re-dispatch from their own artifacts + role prompt, not a
2376
+ // chat recap, and their protocol (FLOW_DONE / FLOW_REVIEW sentinels) must not be perturbed.
2377
+ const carryRecap = flowSessionId ? null : buildRecapFromLog(log, RECAP_CAP)
2334
2378
  // Teardown WITHOUT the close side effects. drainPending denies any in-flight
2335
2379
  // permission cards (the switch aborts the current turn, same as /model mid-turn).
2336
2380
  drainPending(s)
@@ -2341,7 +2385,7 @@ function respawnStructured(id, provider) {
2341
2385
  // sessionData() (provider included) synchronously on open, so a bridge restart
2342
2386
  // restores the lane on its CURRENT provider, not the original — and its next
2343
2387
  // announce carries the new provider badge (additive {id,name} projection).
2344
- openStructured({ id, model, provider, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt })
2388
+ openStructured({ id, model, provider, log, commands, mode, spawnedBy, flowSessionId, flowTaskKey, cwd, rolePrompt, reviewSliceRoots, openedAt, carryRecap })
2345
2389
  return true
2346
2390
  }
2347
2391
 
@@ -2643,6 +2687,7 @@ channel
2643
2687
  return
2644
2688
  }
2645
2689
  if (/^\/clear\s*$/.test(text)) {
2690
+ s.pendingRecap = null // /clear means forget context — drop any un-fired carry recap
2646
2691
  s.session.sendTurn(text)
2647
2692
  s.log = []; s.seq = makeSeqCounter(0); bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
2648
2693
  flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
@@ -2670,7 +2715,13 @@ channel
2670
2715
  // its cap. The slash controls above (/model, /clear, /compact) are intentionally exempt:
2671
2716
  // they let a user manage or REDUCE spend even when capped. Legible refusal, no silent stall.
2672
2717
  if (await budgetBlocked(s, payload.term)) return
2673
- s.session.sendTurn(text)
2718
+ // Context-carry (2026-07-08) point 4: a real human turn arrived BEFORE the post-switch/
2719
+ // restore recap fired on init-`system`. Prepend the recap to THIS turn (one turn, not two
2720
+ // — a separate recap turn would race + double-fire) and clear the flag so the init handler
2721
+ // no-ops. The visible `you` echo is unchanged; the recap is context for the agent only.
2722
+ let sendText = text
2723
+ if (s.pendingRecap) { sendText = `${s.pendingRecap}\n\n--- the person's next message ---\n${text}`; s.pendingRecap = null }
2724
+ s.session.sendTurn(sendText)
2674
2725
  echoYou()
2675
2726
  // Pool-order fix (2026-07-02): emit the "dispatched to agent" marker HERE, right after
2676
2727
  // the @pool you-line, so it inherits the NEXT seq (seqable('pool') is true) and orders
@@ -2873,6 +2924,17 @@ channel
2873
2924
  if (re && !rec.flowSessionId && canResume(rec) && restoredTurnOpen(rec.log || [])) {
2874
2925
  re.pendingAutoResume = true
2875
2926
  }
2927
+ // Context-carry (2026-07-08): the SAME mid-turn case but the SDK context CANNOT
2928
+ // resume (canResume false — a stale/expired session). A bare "continue" would land
2929
+ // in a blank backend, so instead carry a plain-text recap of the visible log as the
2930
+ // first turn — the interrupted work resumes WITH its history. Mid-turn only
2931
+ // (restoredTurnOpen): we never arm an unprompted turn on an idle restored lane (its
2932
+ // transcript stays visible; the person starts their next request fresh). If the
2933
+ // person types before this recap fires, the code-turn handler prepends it instead.
2934
+ // Flow lanes re-dispatch separately (excluded above).
2935
+ else if (re && !rec.flowSessionId && !canResume(rec) && restoredTurnOpen(rec.log || [])) {
2936
+ re.pendingRecap = buildRecapFromLog(rec.log || [], RECAP_CAP)
2937
+ }
2876
2938
  }
2877
2939
  // Fresh open: ONLY for an explicit `-- <claude>` share. In account mode
2878
2940
  // (autoAgent set, no attachedCmd) the web's `?new=1` flow opens the first
@@ -660,15 +660,11 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
660
660
  // documented override. opts.env REPLACES the child env, so spread the inherited one.
661
661
  opts.env = { ...(env || process.env), CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION: '1' }
662
662
 
663
- // Before the SDK replays this transcript, heal any cross-provider tool blocks
664
- // it left behind (e.g. a room that ran on Z.ai GLM, now back on anthropic).
665
- // Anthropic 400s on a `server_tool_use` block carrying a foreign id/name, which
666
- // wedges the room on every send. Reclassifying those to plain `tool_use` clears
667
- // it; idempotent + no-op on clean transcripts. See transcript-sanitize.mjs.
668
- if (resume) {
669
- const healed = sanitizeSession(cwd || process.cwd(), resume)
670
- if (healed.blocks) process.stderr.write(`\n ◆ healed ${healed.blocks} malformed tool block(s) in the transcript before resume.\n`)
671
- }
663
+ // Transcript healing lives at the TRUE resume point — see runQuery, right before
664
+ // query() is (re)built. The old factory-time heal was gated on the closure constant
665
+ // `resume`, so it ran only on the first open; a `/model` switch or a restart re-points
666
+ // the live `sessionId` and re-enters runQuery, resuming a transcript that was never
667
+ // re-healed (finding FBL-1). Healing inside runQuery covers EVERY resume path.
672
668
 
673
669
  // One-shot Haiku fallback suggestion. Runs on the SAME subscription auth as the room
674
670
  // (opts.env carries the OAuth — no API key needed), but as a BARE model call: no
@@ -719,6 +715,18 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
719
715
  const onParentAbort = () => { try { myAc.abort() } catch { /* noop */ } }
720
716
  ac.signal.addEventListener('abort', onParentAbort, { once: true })
721
717
  try {
718
+ // Heal the transcript we're about to REPLAY — at the true resume point, keyed on the
719
+ // LIVE `sessionId` (not the factory-time `resume` constant). `sessionId` mutates:
720
+ // recreateForSwitch re-points it to persistedSessionId and the restart / bad-resume
721
+ // forks re-point it too, each re-entering runQuery. A `/model` switch (or restart) that
722
+ // resumed a corrupted transcript — a dangling tool_use / tool_result-in-assistant left
723
+ // by an aborted mid-tool turn — used to hit an Anthropic 400 (H42 class) and wedge the
724
+ // lane on every send (finding FBL-1). sanitizeSession is idempotent + a no-op on clean
725
+ // transcripts, so healing before EVERY resume is free. See transcript-sanitize.mjs.
726
+ if (sessionId) {
727
+ const healed = sanitizeSession(cwd || process.cwd(), sessionId)
728
+ if (healed.blocks) process.stderr.write(`\n ◆ healed ${healed.blocks} malformed tool block(s) in the transcript before resume.\n`)
729
+ }
722
730
  q = query({ prompt: input.stream, options: { ...opts, abortController: myAc, ...(sessionId ? { resume: sessionId } : {}) } })
723
731
  for await (const m of q) {
724
732
  if (closed) break
@@ -756,7 +764,7 @@ export function startClaudeSession({ cwd, model, resume, env, mode: initialMode
756
764
  // Cold-start timing — the init system message is when the terminal is
757
765
  // usable, so spawn→here IS the latency the user waits on. One line per
758
766
  // open. Compare a normal open vs one with TP_MCP_STRICT=1 to read off how
759
- // many ms the host MCP servers cost. (resume opens also pay sanitizeSession.)
767
+ // many ms the host MCP servers cost. (each resume also pays a sanitizeSession heal — see runQuery.)
760
768
  if (!readyLogged) {
761
769
  readyLogged = true
762
770
  process.stderr.write(`\n ◆ session ready in ${Date.now() - spawnT0}ms — MCP ${process.env.TP_MCP_STRICT === '1' ? 'OFF' : 'on'}${resume ? ', resume' : ', fresh'}.\n`)
package/event-id.mjs CHANGED
@@ -248,3 +248,68 @@ export function usageReportLine (sessionModel, logEvents) {
248
248
  const turns = `${sum.turns} turn${sum.turns === 1 ? '' : 's'}`
249
249
  return `usage · ${turns} · ${perStr}${total}${cost} · tokens + cost only (no plan/rate-limit meters)`
250
250
  }
251
+
252
+ // Default hard cap for the context-carry recap (chars of transcript body, framing extra).
253
+ export const RECAP_CAP = 20000
254
+
255
+ // Context-carry recap for a lane whose SDK memory CAN'T be resumed (a cross-provider
256
+ // switch, or a bridge-restart restore where canResume() is false). Such a lane re-opens
257
+ // under the SAME id with the visible transcript replayed but a FRESH, blank backend — so
258
+ // the agent starts amnesiac mid-conversation (the 2026-07-08 lane 109d22e1 "this is the
259
+ // first message in my lane" incident). Raw SDK resume can't cross a provider (signed
260
+ // thinking blocks + provider-specific tool_use records get rejected by a foreign backend),
261
+ // so we carry the conversation at the TEXT layer instead: synthesize a plain-text recap
262
+ // from the saved log — the people's turns (`you`) + the assistant's own text replies
263
+ // (`assistant` text blocks; thinking + tool_use are dropped, they don't survive a backend
264
+ // hop and are noise) — newest-weighted under a hard char cap, framed so the agent RESUMES
265
+ // rather than re-greets. Pure + import-safe (unit-tested in bridge/recap.test.mjs).
266
+ //
267
+ // capReplayEvents lesson (rooms died once on a multi-MB replay): the char cap keeps this
268
+ // bounded in prose form too — an unbounded recap would be the same bug wearing a text hat.
269
+ export function buildRecapFromLog(log, cap = RECAP_CAP) {
270
+ if (!Array.isArray(log) || !log.length) return ''
271
+ const budget = Math.max(0, Number.isFinite(cap) ? Math.floor(cap) : 0)
272
+ if (!budget) return ''
273
+ // Flatten the log to speaker-labelled turns, in order. `you` = a person in the room;
274
+ // `assistant` = this agent's own earlier reply (its text blocks only).
275
+ const turns = []
276
+ for (const e of log) {
277
+ if (!e || typeof e !== 'object') continue
278
+ if (e.kind === 'you') {
279
+ const t = String(e.text || '').trim()
280
+ if (t) turns.push({ who: 'PERSON', text: t })
281
+ } else if (e.kind === 'assistant') {
282
+ const t = (Array.isArray(e.blocks) ? e.blocks : [])
283
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.trim())
284
+ .map((b) => b.text.trim())
285
+ .join('\n')
286
+ if (t) turns.push({ who: 'YOU', text: t })
287
+ }
288
+ }
289
+ if (!turns.length) return ''
290
+ // Newest-weighted: walk from the end, keep whole turns until the char budget is spent,
291
+ // then restore chronological order. A single newest turn bigger than the whole budget is
292
+ // still kept (truncated) so the LIVE task is never dropped to the cap.
293
+ const kept = []
294
+ let used = 0
295
+ let omitted = false
296
+ for (let i = turns.length - 1; i >= 0; i--) {
297
+ let line = `${turns[i].who}: ${turns[i].text}`
298
+ if (used + line.length > budget) {
299
+ if (kept.length) { omitted = true; break } // older turns exist but won't fit → note + stop
300
+ line = line.slice(0, budget) // newest turn alone overflows → clip, still include
301
+ }
302
+ kept.push(line)
303
+ used += line.length
304
+ }
305
+ kept.reverse()
306
+ const body = (omitted ? ['[…earlier messages omitted for length…]'] : []).concat(kept).join('\n\n')
307
+ return [
308
+ '[CONTEXT CARRY — you are RESUMING a live conversation after a model/provider switch]',
309
+ "Your session memory was reset by the switch, but this lane's conversation continued and the people already saw everything below. Do NOT re-greet, do NOT re-introduce yourself, and do NOT re-answer anything you already completed. Read the recap, then continue the work exactly where it left off. The LAST PERSON message with no completed answer below is your live task — pick it up.",
310
+ '',
311
+ '--- conversation so far (oldest → newest) ---',
312
+ body,
313
+ '--- end recap ---',
314
+ ].join('\n')
315
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.169",
3
+ "version": "0.7.171",
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/service.mjs CHANGED
@@ -232,6 +232,61 @@ function verifyDarwinReload(lbl, file, version) {
232
232
  return false
233
233
  }
234
234
 
235
+ // F4 — every io.thinkpool.pair.* label currently LOADED in the launchd/systemd domain,
236
+ // plus any label with a plist/unit still on disk (loaded or not). Pure enumeration so the
237
+ // sweep below is testable in isolation. `keepLabel` is the label we're about to (re)install
238
+ // — its own bootout+bootstrap is the caller's job, so the sweep leaves it alone.
239
+ export function existingServiceLabels(platform = process.platform, { fsMod = fs } = {}) {
240
+ const labels = new Set()
241
+ try {
242
+ if (platform === 'darwin') {
243
+ try {
244
+ const out = execSync(`launchctl print gui/$(id -u) 2>/dev/null | grep -oE 'io\\.thinkpool\\.pair\\.[A-Za-z0-9_]+' | sort -u`, { encoding: 'utf8', shell: '/bin/bash' })
245
+ for (const l of out.split('\n')) { const t = l.trim(); if (t) labels.add(t) }
246
+ } catch { /* nothing loaded */ }
247
+ try {
248
+ const dir = path.join(os.homedir(), 'Library', 'LaunchAgents')
249
+ for (const f of fsMod.readdirSync(dir)) { const m = f.match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_]+)\.plist$/); if (m) labels.add(m[1]) }
250
+ } catch { /* no dir */ }
251
+ } else if (platform === 'linux') {
252
+ try {
253
+ const out = execSync(`systemctl --user list-unit-files 'io.thinkpool.pair.*' --no-legend 2>/dev/null | awk '{print $1}'`, { encoding: 'utf8', shell: '/bin/bash' })
254
+ for (const l of out.split('\n')) { const t = l.trim().replace(/\.service$/, ''); if (t) labels.add(t) }
255
+ } catch { /* none */ }
256
+ try {
257
+ const dir = path.join(os.homedir(), '.config', 'systemd', 'user')
258
+ for (const f of fsMod.readdirSync(dir)) { const m = f.match(/^(io\.thinkpool\.pair\.[A-Za-z0-9_]+)\.service$/); if (m) labels.add(m[1]) }
259
+ } catch { /* no dir */ }
260
+ }
261
+ } catch { /* best-effort */ }
262
+ return [...labels]
263
+ }
264
+
265
+ // F4 — before (re)installing, sweep EVERY pre-existing thinkpool-pair service EXCEPT the one
266
+ // we're about to install (which the install path bootout+bootstraps itself). Today's outage
267
+ // trigger was an uninstall/reinstall leaving TWO account services live, both heart-beating,
268
+ // racing the saved login. Idempotent + best-effort; never throws. Also reaps orphaned ROOM
269
+ // CHILD bridges (the proven verifyDarwinReload pattern — the uppercase-room guard never
270
+ // matches the no-arg menu or the supervisor, so the live account bridge is never touched).
271
+ export function sweepExistingServices(keepLabel, platform = process.platform) {
272
+ for (const lbl of existingServiceLabels(platform)) {
273
+ if (lbl === keepLabel) continue
274
+ try {
275
+ if (platform === 'darwin') {
276
+ execSync(`launchctl bootout gui/$(id -u)/${lbl} 2>/dev/null || true`, { stdio: 'ignore', shell: '/bin/bash' })
277
+ try { fs.rmSync(path.join(os.homedir(), 'Library', 'LaunchAgents', `${lbl}.plist`), { force: true }) } catch { /* noop */ }
278
+ } else if (platform === 'linux') {
279
+ execSync(`systemctl --user disable --now ${lbl}.service 2>/dev/null || true`, { stdio: 'ignore', shell: '/bin/bash' })
280
+ }
281
+ process.stderr.write(` ◆ swept stale service ${lbl}\n`)
282
+ } catch { /* best-effort */ }
283
+ }
284
+ // Reap orphaned ROOM child bridges only — safe (never the supervisor / interactive menu).
285
+ if (platform !== 'win32') {
286
+ try { execSync(`pkill -9 -f 'thinkpool-pair/bridge.mjs [A-Z0-9]' 2>/dev/null || true`, { stdio: 'ignore', shell: '/bin/bash' }) } catch { /* noop */ }
287
+ }
288
+ }
289
+
235
290
  // staleProof defaults ON for every real install: even a FIRST install can be launched from
236
291
  // a stale npx cache (the launcher itself is npx-resolved), so without the cache-wiping
237
292
  // wrapper a fresh `install-service` could pin a stale build just like the update path did.
@@ -240,6 +295,10 @@ function verifyDarwinReload(lbl, file, version) {
240
295
  // so its unit tests assert the pure pinned shape.
241
296
  export function installService(room, cmdArgs = [], { autoUpdate = false, version = VERSION, staleProof = true } = {}) {
242
297
  const a = buildArtifact(process.platform, { room, cmdArgs, autoUpdate, version, staleProof })
298
+ // F4: clear every OTHER pre-existing pair service first, so only the one we install remains
299
+ // (the "two account services live" storm trigger). Our own label is bootout+bootstrapped
300
+ // by a.post below, so keep it.
301
+ try { sweepExistingServices(label(room), process.platform) } catch { /* best-effort */ }
243
302
  fs.mkdirSync(a.logDir, { recursive: true })
244
303
  fs.mkdirSync(path.dirname(a.file), { recursive: true })
245
304
  fs.writeFileSync(a.file, a.content)