thinkpool-pair 0.7.295 → 0.7.297

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
@@ -501,47 +501,27 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
501
501
  // we do NOT uninstall the service. A plain foreground bridge has no supervisor, so it
502
502
  // just stops — restart is meaningful for the always-on bridge. Old bridges (pre-0.7.60)
503
503
  // don't subscribe to this event, so the dashboard button is a harmless no-op there.
504
- acct.on('broadcast', { event: 'restart' }, () => {
505
- restarting = true
506
- // The dashboard "Restart bridge" button ALSO updates to the newest published version
507
- // (Max, 2026-07-03). Use service.mjs's authoritative registry → immutable runtime →
508
- // one-shot reload transaction directly. Spawning `npx install-service` here used a
509
- // disposable cache that legacy services could delete underneath the updater. The
510
- // service primitive arms an independent launchd handoff before this process is booted
511
- // out, so the reload survives us without a second package execution tree.
512
- // The reload IS the bounce; sessions resume from disk on the new version. Best-effort:
513
- // if there's no service or npm is unreachable, we fall through to the plain bounce, so
514
- // the button is never dead — and even a failed update degrades to today's behaviour.
515
- const plainBounce = (delay) => setTimeout(() => {
516
- try { releaseSingleton() } catch { /* noop */ }
517
- // Unsupervised FOREGROUND bridge (no launchd/systemd) → re-spawn ourselves DETACHED
518
- // after releasing the lock, so "Restart" isn't a footgun that never comes back
519
- // (2026-06-25). A supervised bridge is respawned by its supervisor on exit(1).
520
- if (!runsAsService()) {
521
- try { spawn(process.execPath, [process.argv[1]], { detached: true, stdio: 'ignore', env: process.env }).unref() } catch { /* noop */ }
522
- }
523
- process.exit(1)
524
- }, delay)
504
+ let restartPreparationRunning = false
505
+ acct.on('broadcast', { event: 'restart' }, ({ payload } = {}) => {
506
+ // A dashboard restart is an EXPLICIT apply request, but it is not permission
507
+ // to interrupt a live turn or a pending human decision. Stage the immutable
508
+ // runtime while work continues; applyIfIdle() owns the only destructive edge.
509
+ applyRequested = true
510
+ if (restartPreparationRunning || applyingUpdate) return
511
+ restartPreparationRunning = true
525
512
  ;(async () => {
526
- let updating = false
527
513
  try {
528
514
  const svc = await import('./service.mjs')
529
- if (svc.serviceActive(null)) {
530
- updating = svc.updateService(null) !== false
531
- }
532
- } catch { /* no service / spawn failed → plain bounce below */ }
533
- process.stderr.write(updating
534
- ? '\n ◇ update + restart requested from the dashboard — pulling the newest bridge and reloading; sessions resume.\n'
535
- : '\n ◇ restart requested from the dashboard — bouncing bridge (sessions resume).\n')
536
- if (updating) {
537
- // The updater's bootout replaces us (single clean bounce). Long backstop: if the
538
- // update never lands (npm down / install failed), bounce the current version so the
539
- // button isn't dead — the updater normally boots us out well before this fires.
540
- plainBounce(60000)
541
- return
515
+ const staged = svc.serviceActive(null) ? svc.stageServiceUpdate(null) : null
516
+ // The dashboard's advertised version is only a fallback marker. The host
517
+ // independently resolves and verifies npm above before applying a service.
518
+ pendingUpdate = staged || (typeof payload?.version === 'string' ? payload.version : VERSION) || 'restart'
519
+ } catch {
520
+ pendingUpdate = (typeof payload?.version === 'string' ? payload.version : VERSION) || 'restart'
521
+ } finally {
522
+ restartPreparationRunning = false
542
523
  }
543
- plainBounce(2500)
544
- process.kill(process.pid, 'SIGTERM')
524
+ void applyIfIdle()
545
525
  })()
546
526
  })
547
527
  // Throttled so a realtime reconnect storm can't machine-gun track() into the
@@ -907,7 +887,13 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
907
887
  console.log(`child_ready sup=${SUP_ID} room=${room} pid=${child.pid}`)
908
888
  pushPresence()
909
889
  }
910
- else if (m.t === 'idle') { childIdle.set(room, !!m.idle); childBetween.set(room, m.between != null ? !!m.between : !!m.idle); childPeer.set(room, !!m.webPeer) }
890
+ else if (m.t === 'idle') {
891
+ childIdle.set(room, !!m.idle)
892
+ childBetween.set(room, m.between != null ? !!m.between : !!m.idle)
893
+ childPeer.set(room, !!m.webPeer)
894
+ void applyIfIdle()
895
+ }
896
+ else if (m.t === 'busy') { childIdle.set(room, false); childBetween.set(room, false) }
911
897
  else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
912
898
  // ── Thinkpool Ensemble cross-ROOM routing ──────────────────────────────
913
899
  // `room` here is the room that sent the message (this closure is per-child).
@@ -1134,15 +1120,19 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1134
1120
  // Restart the account bridge to apply a pending update ONLY when it's safe: every
1135
1121
  // child idle (between turns) AND either a user clicked apply OR nobody's watching
1136
1122
  // (unattended fallback). The predicate is unit-tested (tests/update-gate.test.mjs).
1137
- const applyIfIdle = async () => {
1123
+ async function applyIfIdle() {
1138
1124
  if (applyingUpdate || !isSafeToRestart({ pendingUpdate, stopping, childIdle, childBetween, childPeer, requested: applyRequested })) return
1139
1125
  applyingUpdate = true
1140
1126
  process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
1141
1127
  try {
1142
1128
  const svc = await import('./service.mjs')
1143
- if (svc.serviceActive(null) && svc.updateService(null) !== false) return
1129
+ if (svc.serviceActive(null) && svc.applyStagedServiceUpdate(null, pendingUpdate) !== false) return
1144
1130
  } catch { /* fall back to the legacy supervised restart below */ }
1145
1131
  applyingUpdate = false
1132
+ restarting = true
1133
+ if (!runsAsService()) {
1134
+ try { spawn(process.execPath, [process.argv[1]], { detached: true, stdio: 'ignore', env: process.env }).unref() } catch { /* noop */ }
1135
+ }
1146
1136
  stop('SIGTERM')
1147
1137
  }
1148
1138
 
@@ -1180,6 +1170,15 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1180
1170
  const latest = await fetchLatest()
1181
1171
  if (latest && isNewer(latest, VERSION)) {
1182
1172
  if (!pendingUpdate) process.stderr.write(`\n ◆ thinkpool-pair ${latest} published — "update ready" surfaced to each room; applies between turns.\n`)
1173
+ // Provision while children continue running. applyIfIdle performs the
1174
+ // final safety check only after this slow network/install work is done.
1175
+ try {
1176
+ const svc = await import('./service.mjs')
1177
+ svc.provisionRuntime(latest)
1178
+ } catch (error) {
1179
+ process.stderr.write(`\n ⚠ bridge ${latest} could not be staged yet: ${error?.message || error}\n`)
1180
+ return
1181
+ }
1183
1182
  pendingUpdate = latest
1184
1183
  // Tell every served child so its room shows the chip (children never self-update;
1185
1184
  // only this supervisor restart re-resolves @latest). Slice 3 nudge.
package/bridge.mjs CHANGED
@@ -39,6 +39,7 @@ import { randomUUID } from 'node:crypto'
39
39
  import { createClient } from '@supabase/supabase-js'
40
40
  import { serveDecision, fetchServeRow, refusalMessage, gateFailAction } from './serve-consent.mjs'
41
41
  import { reapTerminalRow as _reapTerminalRow } from './reap-terminal.mjs'
42
+ import { reconcileTerminalRows as _reconcileTerminalRows } from './terminal-row-reconcile.mjs'
42
43
  import { cancelDurableDispatchPermissions, cancelDurablePermissions } from './dispatch-permission-cleanup.mjs'
43
44
  // Slice 3 — the pure decision layer for agent push events (turn-done / needs-input).
44
45
  // Unit-tested in bridge/agent-notify.test.mjs; everything with I/O stays here.
@@ -46,7 +47,7 @@ import { createPermNotifier, shouldNotifyTurnDone, permissionSummary, clipSummar
46
47
  // resolveProviderEnv(id) → {ANTHROPIC_BASE_URL,ANTHROPIC_AUTH_TOKEN,ANTHROPIC_MODEL} for a
47
48
  // registered custom provider, or null for the built-in/unknown (leave the default env intact).
48
49
  // Multi-provider BYOK slice 1: a lane spawned with a `provider` id runs on that endpoint.
49
- import { resolveProviderEnv, resolveProviderRef, providerNameMap, publicKeyB64, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
50
+ import { resolveProviderEnv, resolveProviderRef, providerNameMap, publicKeyB64, bridgeHostId, announceProviders, listProviders, addProvider, removeProvider, unseal, effectiveLaneModel, sameProviderEnv, providerModel } from './providers.mjs'
50
51
  import { validateProviderSwitch, providerSwitchPlan, BUILTIN_PROVIDER } from './switch-provider.mjs'
51
52
  import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
52
53
  import { z } from 'zod'
@@ -595,6 +596,7 @@ const BRIDGE_STARTED_AT = Date.now()
595
596
  // box. Distinct from `name` (the driver's username). Announced additively —
596
597
  // top-level only (one bridge per announce); older clients ignore the unknown key.
597
598
  const host = (os.hostname() || 'host').split('.')[0].slice(0, 24)
599
+ const hostId = bridgeHostId()
598
600
 
599
601
  // Repo awareness — the room shows which project this machine is sharing.
600
602
  // Cheap reads, no subprocess: directory name + .git/HEAD.
@@ -718,6 +720,20 @@ function reapTerminalRow(id) {
718
720
  onFailure: ({ error, status }) => process.stderr.write(`\n ⚠ terminal row reap failed (${String(id).slice(0, 8)}${status ? `, HTTP ${status}` : ''}): ${error || 'unknown error'}\n`),
719
721
  })
720
722
  }
723
+
724
+ // Crash/abort counterpart to the clean-close reap above. Called only after the
725
+ // subscribed bridge has restored every on-disk session into `terms`/`sessions`.
726
+ // The stable host filter is load-bearing: another machine's dormant lanes are
727
+ // durable product state, while this machine's missing rows are stale ghosts.
728
+ function reconcileTerminalRows() {
729
+ return _reconcileTerminalRows({
730
+ room, hostId, liveIds: [...terms.keys(), ...sessions.keys()], token: codeAuthToken,
731
+ supabaseUrl: SUPABASE_URL, anonKey: SUPABASE_ANON, shuttingDown,
732
+ onFailure: ({ error, status }) => process.stderr.write(`\n ⚠ terminal row reconcile failed${status ? ` (HTTP ${status})` : ''}: ${error || 'unknown error'}\n`),
733
+ }).then(({ reaped }) => {
734
+ if (reaped.length) process.stderr.write(`\n ◆ removed ${reaped.length} stale terminal row${reaped.length === 1 ? '' : 's'} after restore.\n`)
735
+ })
736
+ }
721
737
  // Auth: is the sender a PARTICIPANT of THIS room (owner OR granted OR joined)? Room mode
722
738
  // serves the room, so RLS returns participants; we confirm the jwt's user is one of them.
723
739
  // Pattern (per #218/#219): getUser + uid → REST existence check on code_sessions
@@ -1022,7 +1038,14 @@ const warmComplete = (e) => {
1022
1038
  // output or a human driving). Read by the auto-update poll so an update restart
1023
1039
  // only happens when idle, never mid-turn.
1024
1040
  let lastActivity = Date.now()
1025
- const markActivity = () => { lastActivity = Date.now() }
1041
+ const markActivity = () => {
1042
+ lastActivity = Date.now()
1043
+ // Do not wait for the 5s idle heartbeat to retract a stale between-turn
1044
+ // report. A dashboard update may be staged concurrently with this input.
1045
+ if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
1046
+ try { process.send({ t: 'busy' }) } catch { /* parent is already leaving */ }
1047
+ }
1048
+ }
1026
1049
 
1027
1050
  // ── Slice 3: update-ready nudge state ──────────────────────────────
1028
1051
  // The newest published version once the update poll (service tier) or the account
@@ -1130,9 +1153,9 @@ const announce = () => {
1130
1153
  cwd, version: VERSION, promptBundle: THINKPOOL_PROMPT_BUNDLE,
1131
1154
  // host: short machine label (see const `host`) so the room shows which box
1132
1155
  // currently serves it + attributes dormant terminals to their home machine.
1133
- // Additive top-level field; consumed by src/pages/code/room.jsx onAnnounce in
1134
- // a later lane. Older clients ignore it.
1135
- host,
1156
+ // Additive top-level fields consumed by src/pages/code/room.jsx onAnnounce.
1157
+ // Older clients ignore them.
1158
+ host, hostId,
1136
1159
  // uid: the authed user id whose bridge is serving this room (owner or grantee).
1137
1160
  // Additive field (older clients ignore it). A grantee bridge watches peer `bridge`
1138
1161
  // announces for uid === owner_id to know the owner's bridge reclaimed the room and
@@ -4460,6 +4483,7 @@ channel
4460
4483
  }
4461
4484
  }
4462
4485
  await announce()
4486
+ void reconcileTerminalRows()
4463
4487
  // Account presence must distinguish a spawned child from a usable room.
4464
4488
  // Signal only after realtime subscribed, durable sessions restored, and the
4465
4489
  // first authoritative roster was announced. The dashboard keeps the bridge
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.295",
3
+ "version": "0.7.297",
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": {
@@ -83,6 +83,7 @@
83
83
  "serve-dir.mjs",
84
84
  "serve-consent.mjs",
85
85
  "reap-terminal.mjs",
86
+ "terminal-row-reconcile.mjs",
86
87
  "switch-provider.mjs",
87
88
  "service.mjs",
88
89
  "presence.mjs",
package/providers.mjs CHANGED
@@ -100,6 +100,14 @@ export function ensureKeypair() {
100
100
  /** SPKI base64 public key for the announce (`providerPubKey`). */
101
101
  export function publicKeyB64() { return ensureKeypair().publicKeyB64 }
102
102
 
103
+ // Stable, public machine identity for terminal ownership. `host` is a display
104
+ // label and can be "localhost" on multiple computers; this fingerprint is tied
105
+ // to the bridge's existing keypair without exposing private material. Accepting
106
+ // an explicit public key keeps the identity primitive directly testable.
107
+ export function bridgeHostId(pubKey = publicKeyB64()) {
108
+ return crypto.createHash('sha256').update(pubKey).digest('hex').slice(0, 32)
109
+ }
110
+
103
111
  // ── seal / unseal (hybrid RSA-OAEP + AES-256-GCM) ───────────────────────
104
112
  // seal() is here so the UNIT can drive both ends node-side; the real seal
105
113
  // happens in the browser dashboard via WebCrypto against the same envelope.
package/service.mjs CHANGED
@@ -487,16 +487,47 @@ export function restartService(room) {
487
487
  // Resolve the registry version first, then rewrite + reload the service pinned to that
488
488
  // exact build. If npm is unavailable, fail without pretending an old-version restart
489
489
  // was an update.
490
+ function publishedServiceVersion(exec = execSync) {
491
+ let target = ''
492
+ try {
493
+ target = String(exec('npm view thinkpool-pair version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })).trim()
494
+ } catch { /* reported by caller */ }
495
+ return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(target) ? target : null
496
+ }
497
+
498
+ // Download and verify the immutable runtime WITHOUT replacing the live service.
499
+ // The account supervisor can do this while rooms are busy, then re-check its
500
+ // between-turn contract immediately before the now-fast service-manager reload.
501
+ export function stageServiceUpdate(room, { exec = execSync, active = isServiceInstalled, provision = provisionRuntime } = {}) {
502
+ if (!active(room)) {
503
+ process.stderr.write(' ⚠ no background service installed to update — install one first.\n')
504
+ return null
505
+ }
506
+ const target = publishedServiceVersion(exec)
507
+ if (!target) {
508
+ process.stderr.write(" ⚠ couldn't reach npm or resolve the published version — bridge left unchanged; NO update or restart was applied.\n")
509
+ return null
510
+ }
511
+ process.stderr.write(`\n ◆ preparing bridge v${target} in the background; active sessions keep running…\n`)
512
+ try { provision(target); return target } catch (error) {
513
+ process.stderr.write(` ⚠ couldn't stage bridge v${target}: ${error?.message || error}\n`)
514
+ return null
515
+ }
516
+ }
517
+
518
+ export function applyStagedServiceUpdate(room, target, { install = installService, active = isServiceInstalled } = {}) {
519
+ if (!active(room) || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(String(target || ''))) return false
520
+ process.stderr.write(`\n ◆ updating the background service to v${target} and restarting…\n`)
521
+ return install(room, [], { version: target, staleProof: true }) !== false
522
+ }
523
+
490
524
  export function updateService(room, { exec = execSync, install = installService, active = isServiceInstalled } = {}) {
491
525
  if (!active(room)) {
492
526
  process.stderr.write(' ⚠ no background service installed to update — install one first.\n')
493
527
  return false
494
528
  }
495
- let target = ''
496
- try {
497
- target = String(exec('npm view thinkpool-pair version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })).trim()
498
- } catch { /* reported below */ }
499
- if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(target)) {
529
+ const target = publishedServiceVersion(exec)
530
+ if (!target) {
500
531
  process.stderr.write(" ⚠ couldn't reach npm or resolve the published version — bridge left unchanged; NO update or restart was applied.\n")
501
532
  return false
502
533
  }
@@ -0,0 +1,65 @@
1
+ /* Reconcile durable terminal rows after a bridge has restored its on-disk roster.
2
+
3
+ A clean close is reaped by reap-terminal.mjs. This module closes the other
4
+ lifecycle boundary: a crash/abort can remove the live session file without a
5
+ clean-close event, leaving a permanent dormant DB row. A stable public host id
6
+ makes the bridge authoritative only for its own rows; foreign/null-host rows are
7
+ deliberately preserved for handover and mixed-version compatibility. */
8
+
9
+ const outcome = (extra = {}) => ({ reaped: [], ...extra })
10
+
11
+ export async function reconcileTerminalRows ({
12
+ room,
13
+ hostId,
14
+ liveIds = [],
15
+ token,
16
+ supabaseUrl,
17
+ anonKey,
18
+ shuttingDown = false,
19
+ fetchImpl = fetch,
20
+ onFailure,
21
+ } = {}) {
22
+ if (shuttingDown) return outcome({ reason: 'shutdown' })
23
+ if (!token) return outcome({ reason: 'anon' })
24
+ if (!room) return outcome({ reason: 'no-room' })
25
+ if (!hostId) return outcome({ reason: 'no-host-id' })
26
+
27
+ const fail = (result) => {
28
+ try { onFailure?.(result) } catch { /* observability cannot break restore */ }
29
+ return outcome(result)
30
+ }
31
+
32
+ try {
33
+ const query = `${supabaseUrl}/rest/v1/code_terminals?select=id,host_id` +
34
+ `&session_code=eq.${encodeURIComponent(room)}` +
35
+ `&host_id=eq.${encodeURIComponent(hostId)}`
36
+ const response = await fetchImpl(query, {
37
+ headers: { apikey: anonKey, Authorization: `Bearer ${token}` },
38
+ })
39
+ if (!response?.ok) {
40
+ return fail({ reason: 'error', error: `list HTTP ${response?.status ?? 'unknown'}`, status: response?.status })
41
+ }
42
+
43
+ const rows = await response.json()
44
+ const live = new Set(liveIds || [])
45
+ const stale = (Array.isArray(rows) ? rows : [])
46
+ .filter((row) => row?.id && row.host_id === hostId && !live.has(row.id))
47
+ .map((row) => row.id)
48
+
49
+ const reaped = []
50
+ 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 })
58
+ }
59
+ reaped.push(id)
60
+ }
61
+ return { reason: 'ok', reaped }
62
+ } catch (error) {
63
+ return fail({ reason: 'error', error: error?.message || String(error) })
64
+ }
65
+ }
package/update-gate.mjs CHANGED
@@ -33,7 +33,10 @@ export function isSafeToRestart({ pendingUpdate, stopping = false, childIdle, ch
33
33
  export function turnInFlight(sessions) {
34
34
  for (const s of (sessions?.values?.() || [])) {
35
35
  const ta = s && (typeof s.session?.turnActive === 'boolean' ? s.session.turnActive : s.turnActive)
36
- if (ta) return true
36
+ // A permission / question resolver is process-local. Restarting while one is
37
+ // pending destroys the only promise the eventual durable decision can wake,
38
+ // even when the runtime has temporarily reported turnActive=false.
39
+ if (ta || Number(s?.pending?.size || 0) > 0) return true
37
40
  }
38
41
  return false
39
42
  }