thinkpool-pair 0.7.307 → 0.7.309

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
@@ -28,6 +28,22 @@ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('./pack
28
28
 
29
29
  const BRIDGE = fileURLToPath(new URL('./bridge.mjs', import.meta.url))
30
30
 
31
+ // Startup can spend several seconds waiting on the cross-process refresh lock or
32
+ // backing off after a transient auth failure. Those sleeps are intentionally unref'd
33
+ // once the supervisor is running, but before Realtime + the 15s tick exist they leave
34
+ // an unresolved top-level await as Node's only work; Node 22 exits 13 with
35
+ // "Detected unsettled top-level await". Hold one referenced timer only for bootstrap,
36
+ // then release it as soon as the supervisor's real lifetime interval exists.
37
+ export function createStartupAnchor({ set = setInterval, clear = clearInterval } = {}) {
38
+ const timer = set(() => {}, 60_000)
39
+ let released = false
40
+ return () => {
41
+ if (released) return
42
+ released = true
43
+ clear(timer)
44
+ }
45
+ }
46
+
31
47
  // ── single-instance lock ────────────────────────────────────────────────────
32
48
  // Two account supervisors on one machine share ONE ~/.thinkpool-pair/auth.json
33
49
  // refresh token and race its rotation — which on 2026-06-17 deleted a live Pro
@@ -360,6 +376,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
360
376
  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')
361
377
  process.exit(0)
362
378
  }
379
+ const releaseStartupAnchor = createStartupAnchor()
363
380
  // F2: NEVER exit(1) on a refresh failure. The old code exited 1 here, and launchd
364
381
  // KeepAlive respawned us within seconds → refresh again → the 16× crash storm (and,
365
382
  // worse, hammering the refresh path burns rotations and can revoke the token family).
@@ -1043,6 +1060,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1043
1060
 
1044
1061
  await tick()
1045
1062
  const iv = setInterval(tick, 15000)
1063
+ releaseStartupAnchor()
1046
1064
  const updateTimers = [] // auto-update poll/apply timers — cleared in stop() so none fire post-exit
1047
1065
  let stopping = false
1048
1066
 
package/bridge.mjs CHANGED
@@ -249,7 +249,12 @@ if (argv[0] === 'install-service' || argv[0] === 'uninstall-service') {
249
249
  // always-on service); --auto-update opts into @latest tracking. (2026-06-22)
250
250
  const autoUpdate = argv.includes('--auto-update')
251
251
  const svc = await import('./service.mjs')
252
- if (argv[0] === 'install-service') svc.installService(svcRoom, svcCmd, { autoUpdate })
252
+ if (argv[0] === 'install-service') {
253
+ const ok = autoUpdate
254
+ ? svc.installService(svcRoom, svcCmd, { autoUpdate })
255
+ : await svc.installAndConfirmService(svcRoom, svcCmd)
256
+ process.exit(ok === false ? 1 : 0)
257
+ }
253
258
  else svc.uninstallService(svcRoom)
254
259
  process.exit(0)
255
260
  }
@@ -350,10 +355,9 @@ if ((!argv[0] || argv[0] === 'setup') && process.stdin.isTTY) {
350
355
  child.on('exit', (code) => process.exit(code == null ? 0 : code))
351
356
  }),
352
357
  serveAccountForeground: async () => { const { runAccount } = await import('./account.mjs'); await runAccount(SUPABASE_URL, SUPABASE_ANON) },
353
- // install / uninstall are NOT terminal — they do their work, print their notes,
354
- // and return to the menu. A confirmed update closes the launcher instead: its
355
- // in-memory package version is stale while the new service runs independently.
356
- installService: ({ room = null, agentCmd } = {}) => { svc.installService(room, agentCmd ? [agentCmd] : []) },
358
+ // Confirmed installs/updates close the launcher: the service is independently
359
+ // running and another menu cycle can only race it. Failures return to the menu.
360
+ installService: ({ room = null, agentCmd } = {}) => svc.installAndConfirmService(room, agentCmd ? [agentCmd] : []),
357
361
  uninstallService: ({ room = null } = {}) => { svc.uninstallService(room) },
358
362
  restartService: ({ room = null } = {}) => { svc.restartService(room) },
359
363
  // The menu waits for OS-level proof before claiming success. In-service web
package/launcher.mjs CHANGED
@@ -277,7 +277,13 @@ export async function runLauncher({ actions, io, state = detectState(), refresh
277
277
  const pick = items[await askChoice('choose', items)]
278
278
  if (pick.key === 'quit') return
279
279
  if (pick.key === 'serve') { if (await ensureLoggedIn()) await actions.serveAccountForeground() }
280
- else if (pick.key === 'service') { if (await ensureLoggedIn()) { await actions.installService({ room: null }); resync() } }
280
+ else if (pick.key === 'service') {
281
+ if (await ensureLoggedIn()) {
282
+ const installed = await actions.installService({ room: null })
283
+ if (installed === true) return
284
+ resync()
285
+ }
286
+ }
281
287
  else if (pick.key === 'restart') {
282
288
  const updated = await actions.restartUpdateService({ room: null })
283
289
  // The launcher process cannot update its own in-memory VERSION. Returning to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.307",
3
+ "version": "0.7.309",
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/reap-terminal.mjs CHANGED
@@ -36,21 +36,28 @@ export async function reapTerminalRow ({ id, token, supabaseUrl, anonKey, shutti
36
36
  if (!token) return { reaped: false, reason: 'anon' }
37
37
  if (!id) return { reaped: false, reason: 'no-id' }
38
38
  try {
39
- // Mirror of the client reap: DELETE code_terminals WHERE id = <terminal id>. The
40
- // terminal id IS the code_terminals PK (client/bridge-minted uuid). RLS scopes the
41
- // delete to this bridge's sessions, so id alone is sufficient + idempotent (a fast
42
- // client tab may have reaped first — 204 / 0 rows is fine).
43
- const response = await fetchImpl(`${supabaseUrl}/rest/v1/code_terminals?id=eq.${encodeURIComponent(id)}`, {
44
- method: 'DELETE',
45
- headers: { apikey: anonKey, Authorization: `Bearer ${token}` },
39
+ // A terminal at the 50k transcript cap cannot archive + cascade-delete inside
40
+ // the authenticated role's ordinary 8s statement timeout. The member-authorized
41
+ // RPC scopes a longer timeout to this one cleanup operation; a raw REST DELETE
42
+ // timed out and left the row as a cross-device dormant ghost (8TMX546Q).
43
+ const response = await fetchImpl(`${supabaseUrl}/rest/v1/rpc/delete_code_terminal`, {
44
+ method: 'POST',
45
+ headers: {
46
+ apikey: anonKey,
47
+ Authorization: `Bearer ${token}`,
48
+ 'Content-Type': 'application/json',
49
+ },
50
+ body: JSON.stringify({ p_terminal: id }),
46
51
  })
47
52
  // fetch resolves for HTTP failures. A 401/500 did not reap the row and must
48
53
  // stay visible to the fire-and-forget caller as a failed best-effort cleanup.
49
54
  if (!response?.ok) {
55
+ let detail = ''
56
+ try { detail = String(await response.text()).slice(0, 240) } catch { /* no body */ }
50
57
  return fail({
51
58
  reaped: false,
52
59
  reason: 'error',
53
- error: `HTTP ${response?.status ?? 'unknown'}${response?.statusText ? ` ${response.statusText}` : ''}`,
60
+ error: `HTTP ${response?.status ?? 'unknown'}${response?.statusText ? ` ${response.statusText}` : ''}${detail ? `: ${detail}` : ''}`,
54
61
  status: response?.status,
55
62
  })
56
63
  }
package/service.mjs CHANGED
@@ -456,6 +456,65 @@ export function installService(room, cmdArgs = [], { autoUpdate = false, version
456
456
  return true
457
457
  }
458
458
 
459
+ // Interactive installs must not present an armed launchd helper as a completed
460
+ // background service. installService() intentionally returns after staging because
461
+ // an in-service update may tear down its caller; this menu/CLI primitive waits for the
462
+ // helper receipt AND the exact live runtime before returning success.
463
+ export async function installAndConfirmService(room, cmdArgs = [], {
464
+ platform = process.platform,
465
+ version = VERSION,
466
+ staleProof = true,
467
+ install = installService,
468
+ exec = execSync,
469
+ snapshot = serviceRuntimeSnapshot,
470
+ readStatus = () => {
471
+ try { return JSON.parse(fs.readFileSync(path.join(os.homedir(), '.thinkpool-pair', 'update-status.json'), 'utf8')) } catch { return null }
472
+ },
473
+ now = Date.now,
474
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
475
+ timeoutMs = 55000,
476
+ pollMs = 250,
477
+ stderr = process.stderr,
478
+ } = {}) {
479
+ if (install(room, cmdArgs, { version, staleProof }) === false) return false
480
+
481
+ const confirmation = () => {
482
+ const live = snapshot(room, { platform, exec })
483
+ if (live?.version === version) {
484
+ stderr.write(` ✓ bridge install confirmed — running v${version}.\n`)
485
+ return true
486
+ }
487
+ return false
488
+ }
489
+
490
+ // systemd's restart is synchronous. Windows cannot prove a live Startup process,
491
+ // so this exact-runtime confirmation is used only where a service manager exists.
492
+ if (platform !== 'darwin') {
493
+ if (platform === 'win32') return true
494
+ const confirmed = confirmation()
495
+ if (!confirmed) stderr.write(` ⚠ install v${version} was not confirmed: the service manager cannot prove that runtime is running.\n`)
496
+ return confirmed
497
+ }
498
+
499
+ const deadline = now() + Math.max(0, Number(timeoutMs) || 0)
500
+ const expectedTarget = label(room)
501
+ for (;;) {
502
+ const status = readStatus()
503
+ if (status && typeof status === 'object' && status.target === expectedTarget && status.version === version) {
504
+ if (status.ok === false) {
505
+ stderr.write(` ⚠ install v${version} was not confirmed: ${status.error || 'the launchd handoff failed'}.\n`)
506
+ return false
507
+ }
508
+ if (status.ok === true && confirmation()) return true
509
+ }
510
+ if (now() >= deadline) {
511
+ stderr.write(` ⚠ install v${version} timed out waiting for launchd confirmation; no running service was proven.\n`)
512
+ return false
513
+ }
514
+ await sleep(Math.max(1, Number(pollMs) || 1))
515
+ }
516
+ }
517
+
459
518
  // Restart an already-installed boot-persistent service. launchd/systemd relaunch
460
519
  // the stable runtime (or legacy auto-update npx command), so active sessions resume
461
520
  // from disk. Use it to recover a wedged bridge or re-read provider.json.
@@ -1,3 +1,5 @@
1
+ import { reapTerminalRow } from './reap-terminal.mjs'
2
+
1
3
  /* Reconcile durable terminal rows after a bridge has restored its on-disk roster.
2
4
 
3
5
  A clean close is reaped by reap-terminal.mjs. This module closes the other
@@ -48,13 +50,16 @@ export async function reconcileTerminalRows ({
48
50
 
49
51
  const reaped = []
50
52
  for (const id of stale) {
51
- const del = await fetchImpl(
52
- `${supabaseUrl}/rest/v1/code_terminals?id=eq.${encodeURIComponent(id)}` +
53
- `&host_id=eq.${encodeURIComponent(hostId)}`,
54
- { method: 'DELETE', headers: { apikey: anonKey, Authorization: `Bearer ${token}` } },
55
- )
56
- if (!del?.ok) {
57
- return fail({ reason: 'error', error: `delete HTTP ${del?.status ?? 'unknown'}`, status: del?.status, reaped })
53
+ const deletion = await reapTerminalRow({
54
+ id, token, supabaseUrl, anonKey, fetchImpl,
55
+ })
56
+ if (!deletion.reaped) {
57
+ return fail({
58
+ reason: 'error',
59
+ error: `delete ${deletion.error || deletion.reason}`,
60
+ status: deletion.status,
61
+ reaped,
62
+ })
58
63
  }
59
64
  reaped.push(id)
60
65
  }