thinkpool-pair 0.7.296 → 0.7.298

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
@@ -1038,7 +1038,14 @@ const warmComplete = (e) => {
1038
1038
  // output or a human driving). Read by the auto-update poll so an update restart
1039
1039
  // only happens when idle, never mid-turn.
1040
1040
  let lastActivity = Date.now()
1041
- 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
+ }
1042
1049
 
1043
1050
  // ── Slice 3: update-ready nudge state ──────────────────────────────
1044
1051
  // The newest published version once the update poll (service tier) or the account
@@ -1490,9 +1497,25 @@ function pumpDesign(term) {
1490
1497
  by: next.by,
1491
1498
  design: true,
1492
1499
  }
1500
+ // Design bypasses the ordinary `code-turn` handler, so it must perform the
1501
+ // same lifecycle handshake itself. Open the revision BEFORE logging the human
1502
+ // line: its stamped `turnRev` lets every room client reconcile stale
1503
+ // `busy:false` immediately, without waiting several seconds for first model
1504
+ // output. A rejected dispatch gets a real terminal boundary below.
1505
+ syncStructuredTurn(lane)
1506
+ let accepted = false
1507
+ try {
1508
+ accepted = lane.session.sendTurn(designPrompt({ record: next.record, request: next.request, by: next.by, restore: !!next.restoreRecord, priorRecord: next.restoreRecord })) !== false
1509
+ } catch { accepted = false }
1510
+ if (accepted) beginStructuredTurn(lane)
1493
1511
  stampEvent(visible); pushLog(lane, visible); bcast('code-event', { term, evt: visible })
1494
- try { lane.session.sendTurn(designPrompt({ record: next.record, request: next.request, by: next.by, restore: !!next.restoreRecord, priorRecord: next.restoreRecord })) }
1495
- catch { finishDesign(term, 'failed', { message: 'The producing lane could not start the edit.' }) }
1512
+ if (!accepted) {
1513
+ syncStructuredTurn(lane)
1514
+ const failed = { kind: 'error', message: 'The producing lane could not start the edit.', recoverable: true }
1515
+ pushLog(lane, failed); bcast('code-event', { term, evt: failed })
1516
+ finishDesign(term, 'failed', { message: 'The producing lane could not start the edit.' })
1517
+ }
1518
+ announce()
1496
1519
  }
1497
1520
  // Push one manifest file into the room, attributed to `term`. The owner is
1498
1521
  // resolved by the WATCHER, not guessed here — each session/terminal writes to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.296",
3
+ "version": "0.7.298",
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
@@ -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
  }
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
  }