thinkpool-pair 0.7.287 → 0.7.289

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.
Files changed (3) hide show
  1. package/account.mjs +10 -3
  2. package/bridge.mjs +70 -3
  3. package/package.json +1 -1
package/account.mjs CHANGED
@@ -437,6 +437,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
437
437
  const POST_BUCKET = { max: 6, windowMs: 60_000 } // ≤6 inbound cross-room posts / partner / minute
438
438
  let applyRequested = false // a user clicked "apply" in some room (Slice 3)
439
439
  let pendingUpdate = null // newest published version once the poll sees it
440
+ let applyingUpdate = false
440
441
  const warned = new Set()
441
442
  const refused = new Map() // room -> reason ('home'|'none'|'dir-gone'): owned but NOT served, announced in presence so the web shows a precise "attach this session" card instead of spinning
442
443
 
@@ -1126,10 +1127,16 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1126
1127
  // Restart the account bridge to apply a pending update ONLY when it's safe: every
1127
1128
  // child idle (between turns) AND either a user clicked apply OR nobody's watching
1128
1129
  // (unattended fallback). The predicate is unit-tested (tests/update-gate.test.mjs).
1129
- const applyIfIdle = () => {
1130
- if (!isSafeToRestart({ pendingUpdate, stopping, childIdle, childBetween, childPeer, requested: applyRequested })) return
1130
+ const applyIfIdle = async () => {
1131
+ if (applyingUpdate || !isSafeToRestart({ pendingUpdate, stopping, childIdle, childBetween, childPeer, requested: applyRequested })) return
1132
+ applyingUpdate = true
1131
1133
  process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
1132
- stop('SIGTERM') // graceful: children persist + leave presence; exit 0 → service reruns @latest
1134
+ try {
1135
+ const svc = await import('./service.mjs')
1136
+ if (svc.serviceActive(null) && svc.updateService(null) !== false) return
1137
+ } catch { /* fall back to the legacy supervised restart below */ }
1138
+ applyingUpdate = false
1139
+ stop('SIGTERM')
1133
1140
  }
1134
1141
 
1135
1142
  // ── auto-update (account-service tier only) ───────────────────────────────
package/bridge.mjs CHANGED
@@ -1009,6 +1009,7 @@ const markActivity = () => { lastActivity = Date.now() }
1009
1009
  // ready" chip; the restart is gated to between turns (never mid-turn, Contract #1).
1010
1010
  let pendingUpdate = null
1011
1011
  let applyRequested = false // a user clicked "apply" in the room
1012
+ let managedUpdateRunning = false
1012
1013
  // A web client (not another bridge) is present — checks presence metas for a
1013
1014
  // non-bridge role. Gates the unattended auto-restart: when someone's watching we
1014
1015
  // wait for their apply click instead of restarting under them.
@@ -3466,6 +3467,36 @@ function surfaceUpdate(version) {
3466
3467
  announce()
3467
3468
  }
3468
3469
 
3470
+ // A pinned managed room must stage the published runtime before it restarts.
3471
+ // Exiting and trusting launchd/systemd to "pick up latest" only reloads the same
3472
+ // immutable version. This is the room-scoped counterpart to account.mjs's
3473
+ // dashboard updater and is deliberately shared by the session and dashboard UI.
3474
+ const betweenUpdateTurns = () => {
3475
+ if (turnInFlight(sessions)) return false
3476
+ for (const t of terms.values()) if (t.buf) return false
3477
+ return true
3478
+ }
3479
+
3480
+ async function applyStandaloneManagedUpdate() {
3481
+ if (!applyRequested || !pendingUpdate || managedUpdateRunning) return false
3482
+ if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') return false
3483
+ if (!betweenUpdateTurns()) return false
3484
+ managedUpdateRunning = true
3485
+ try {
3486
+ const svc = await import('./service.mjs')
3487
+ if (!svc.serviceActive(room)) return false
3488
+ const ok = svc.updateService(room) !== false
3489
+ if (!ok) managedUpdateRunning = false
3490
+ return ok
3491
+ } catch (error) {
3492
+ managedUpdateRunning = false
3493
+ process.stderr.write(`\n ⚠ bridge update failed: ${error?.message || error}\n`)
3494
+ return false
3495
+ }
3496
+ }
3497
+
3498
+ setInterval(() => { if (applyRequested) void applyStandaloneManagedUpdate() }, 5000).unref()
3499
+
3469
3500
  // After the attached CLI exits, the host's stdin stops feeding a PTY —
3470
3501
  // restore the cooked terminal so Ctrl-C reaches the bridge itself.
3471
3502
  const detachLocal = () => {
@@ -3807,8 +3838,8 @@ channel
3807
3838
  // inferred "working…" state (no turn → no result to clear it; the old bug).
3808
3839
  // Instead announce a ◆ control line both readers see. Claude /compact + any
3809
3840
  // other slash still flow through as a real, self-terminating turn.
3810
- const ctlLine = (ctlText) => {
3811
- const evt = { kind: 'control', text: ctlText, by: payload.by, cid: payload.cid }
3841
+ const ctlLine = (ctlText, details = {}) => {
3842
+ const evt = { kind: 'control', text: ctlText, by: payload.by, cid: payload.cid, ...details }
3812
3843
  pushLog(s, evt)
3813
3844
  bcast('code-event', { term: payload.term, evt })
3814
3845
  }
@@ -3904,7 +3935,7 @@ channel
3904
3935
  if (/^\/compact\s*$/.test(text)) {
3905
3936
  if (s.runtime === 'codex') {
3906
3937
  if (s.session.turnActive) {
3907
- ctlLine('finish or stop the current Codex turn before compacting context')
3938
+ ctlLine('Compact after this turn ends.', { tone: 'caution' })
3908
3939
  return
3909
3940
  }
3910
3941
  const recap = buildRecapFromLog(s.log, RECAP_CAP, { reason: 'compact' })
@@ -4116,6 +4147,42 @@ channel
4116
4147
  try { process.send({ t: 'apply-update' }) } catch { /* parent gone */ }
4117
4148
  }
4118
4149
  })
4150
+ // Authenticated + acknowledged update contract used by both the room card and
4151
+ // the dashboard's room-scoped fallback. Unlike the legacy apply-update event,
4152
+ // this does not depend on the bridge having discovered npm first: the web names
4153
+ // the advertised target, the host updater independently resolves npm latest,
4154
+ // and only a managed service is allowed to accept the operation.
4155
+ .on('broadcast', { event: 'bridge-update' }, async ({ payload }) => {
4156
+ const nonce = payload?.nonce
4157
+ const reply = (ok, error, state) => channel.send({
4158
+ type: 'broadcast', event: 'bridge-update-res',
4159
+ payload: { nonce, ok, ...(error ? { error } : {}), ...(state ? { state } : {}) },
4160
+ })
4161
+ if (!nonce || !(await isRoomParticipant(payload?.jwt))) return reply(false, 'unauthorized')
4162
+ const target = typeof payload?.v === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(payload.v)
4163
+ ? payload.v : null
4164
+ if (!target) return reply(false, 'invalid bridge version')
4165
+
4166
+ surfaceUpdate(target)
4167
+ applyRequested = true
4168
+ if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
4169
+ try { process.send({ t: 'apply-update' }) } catch { return reply(false, 'account supervisor is unavailable') }
4170
+ return reply(true, null, 'queued')
4171
+ }
4172
+
4173
+ let managed = false
4174
+ try {
4175
+ const svc = await import('./service.mjs')
4176
+ managed = svc.serviceActive(room)
4177
+ } catch { /* reported as an explicit foreground refusal below */ }
4178
+ if (!managed) {
4179
+ applyRequested = false
4180
+ return reply(false, 'This bridge is running in a terminal. Restart it once with npx thinkpool-pair@latest, or install the background service to enable remote updates.')
4181
+ }
4182
+ if (!betweenUpdateTurns()) return reply(true, null, 'queued')
4183
+ await reply(true, null, 'applying')
4184
+ void applyStandaloneManagedUpdate()
4185
+ })
4119
4186
  // Persist + re-announce terminal renames. The web also echoes term-rename to
4120
4187
  // online peers directly; storing it here is what reaches a device that joins
4121
4188
  // LATER (or a second machine) — those only ever see the announce.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.287",
3
+ "version": "0.7.289",
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": {