thinkpool-pair 0.7.343 → 0.7.344

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
@@ -504,6 +504,7 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
504
504
  let applyRequested = false // a user clicked "apply" in some room (Slice 3)
505
505
  let pendingUpdate = null // newest published version once the poll sees it
506
506
  let applyingUpdate = false
507
+ const restartProbes = new Map() // nonce -> { pending rooms, ok, timer, resolve }
507
508
  const warned = new Set()
508
509
  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
509
510
 
@@ -1064,6 +1065,18 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1064
1065
  void applyIfIdle()
1065
1066
  }
1066
1067
  else if (m.t === 'busy') { childIdle.set(room, false); childBetween.set(room, false) }
1068
+ else if (m.t === 'restart-probe-result') {
1069
+ const probe = restartProbes.get(m.nonce)
1070
+ if (!probe || !probe.pending.has(room) || children.get(room) !== child) return
1071
+ probe.pending.delete(room)
1072
+ probe.ok = probe.ok && m.between === true
1073
+ childBetween.set(room, m.between === true)
1074
+ if (!probe.pending.size) {
1075
+ clearTimeout(probe.timer)
1076
+ restartProbes.delete(m.nonce)
1077
+ probe.resolve(probe.ok)
1078
+ }
1079
+ }
1067
1080
  else if (m.t === 'apply-update') { applyRequested = true; applyIfIdle() } // user clicked the chip
1068
1081
  // ── Thinkpool Ensemble cross-ROOM routing ──────────────────────────────
1069
1082
  // `room` here is the room that sent the message (this closure is per-child).
@@ -1312,9 +1325,44 @@ export async function runAccount(SUPABASE_URL, SUPABASE_ANON) {
1312
1325
  // Restart the account bridge to apply a pending update ONLY when it's safe: every
1313
1326
  // child idle (between turns) AND either a user clicked apply OR nobody's watching
1314
1327
  // (unattended fallback). The predicate is unit-tested (tests/update-gate.test.mjs).
1328
+ function confirmChildrenBetweenTurns(timeoutMs = 2500) {
1329
+ const targets = [...children.entries()]
1330
+ if (!targets.length) return Promise.resolve(true)
1331
+ const nonce = randomUUID()
1332
+ return new Promise((resolve) => {
1333
+ const probe = { pending: new Set(targets.map(([room]) => room)), ok: true, resolve, timer: null }
1334
+ probe.timer = setTimeout(() => {
1335
+ restartProbes.delete(nonce)
1336
+ resolve(false)
1337
+ }, timeoutMs)
1338
+ probe.timer.unref?.()
1339
+ restartProbes.set(nonce, probe)
1340
+ for (const [room, child] of targets) {
1341
+ try { child.send({ t: 'restart-probe', nonce }) }
1342
+ catch {
1343
+ probe.pending.delete(room)
1344
+ probe.ok = false
1345
+ }
1346
+ }
1347
+ if (!probe.pending.size) {
1348
+ clearTimeout(probe.timer)
1349
+ restartProbes.delete(nonce)
1350
+ resolve(probe.ok)
1351
+ }
1352
+ })
1353
+ }
1354
+
1315
1355
  async function applyIfIdle() {
1316
1356
  if (applyingUpdate || !isSafeToRestart({ pendingUpdate, stopping, childIdle, childBetween, childPeer, requested: applyRequested })) return
1317
1357
  applyingUpdate = true
1358
+ // The 5s heartbeat is a dashboard signal, not an atomic restart barrier. Re-read
1359
+ // every child at the destructive edge so a turn that began after the last sample
1360
+ // vetoes the update instead of becoming an interrupted recovery.
1361
+ if (!await confirmChildrenBetweenTurns()) {
1362
+ applyingUpdate = false
1363
+ process.stderr.write('\n ◆ bridge update still queued — an agent became active before the restart boundary.\n')
1364
+ return
1365
+ }
1318
1366
  process.stderr.write(`\n ◆ thinkpool-pair ${pendingUpdate} ready (running ${VERSION}) — restarting account bridge to update; sessions resume.\n`)
1319
1367
  try {
1320
1368
  const svc = await import('./service.mjs')
package/bridge.mjs CHANGED
@@ -108,6 +108,7 @@ import { canDispatch, FLOW_LIMITS, makeBudget, recordSpend, killSwitchEnv } from
108
108
  // Spec: docs/specs/2026-06-30-flow-build-s4-clean-redispatch.md
109
109
  import { prepareRedispatch, redispatchKey } from './flow-redispatch.mjs'
110
110
  import { sanitizeSession } from './transcript-sanitize.mjs'
111
+ import { boundStructuredLog, STRUCTURED_LOG_BYTE_CAP } from './event-bounds.mjs'
111
112
  import { executeFlowRevert } from './flow-host-revert.mjs'
112
113
  // ACCEPTED LIMITATION — this registry is in-memory only. A bridge restart in the window between
113
114
  // a flow-revert kill and the next dispatch wave loses the pending resume record, so the task
@@ -742,6 +743,33 @@ function readBranch() {
742
743
  const UPDIR = path.join(os.tmpdir(), 'thinkpool-pair', room)
743
744
  const FILE_MAX_BYTES = 25 * 1024 * 1024 // must match web FILE_MB + bucket file_size_limit (25 MB) — see src/pages/code/code-constants.js
744
745
  const safeName = (n) => String(n || 'file').replace(/[^a-zA-Z0-9._-]/g, '_').slice(-80)
746
+ const roomAttachmentStorageUrl = (storagePath) => {
747
+ const value = String(storagePath || '')
748
+ const prefix = `code/${room}/`
749
+ if (!value.startsWith(prefix) || value.includes('..') || value.split('/').some((part) => !part)) return null
750
+ return `${SUPABASE_URL}/storage/v1/object/authenticated/session-attachments/${value.split('/').map(encodeURIComponent).join('/')}`
751
+ }
752
+ const fetchRoomAttachment = async (payload) => {
753
+ const authenticated = codeAuthToken && roomAttachmentStorageUrl(payload?.path)
754
+ const candidates = [
755
+ { url: payload?.url, headers: undefined },
756
+ ...(authenticated ? [{ url: authenticated, headers: { apikey: SUPABASE_ANON, Authorization: `Bearer ${codeAuthToken}` } }] : []),
757
+ ].filter((candidate) => candidate.url)
758
+ let lastError = new Error('no attachment source')
759
+ for (let attempt = 0; attempt < 3; attempt++) {
760
+ for (const candidate of candidates) {
761
+ try {
762
+ const response = await fetch(candidate.url, { headers: candidate.headers })
763
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
764
+ return Buffer.from(await response.arrayBuffer())
765
+ } catch (error) {
766
+ lastError = error
767
+ }
768
+ }
769
+ if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1)))
770
+ }
771
+ throw lastError
772
+ }
745
773
 
746
774
  const supabase = createClient(SUPABASE_URL, SUPABASE_ANON, {
747
775
  realtime: { params: { eventsPerSecond: 60 } },
@@ -2129,12 +2157,11 @@ function pushLog(entry, evt) {
2129
2157
  // filters out (system/thinking_tokens/effort) ride the log seq-less so they don't
2130
2158
  // hole the client's applied sequence. See event-id.mjs NO_SEQ_KINDS.
2131
2159
  if (entry.seq && seqable(evt)) evt.seq = entry.seq.next()
2132
- entry.log.push(evt)
2133
2160
  // Durable, UNBOUNDED archive (powers "load earlier history"): append the transcript
2134
- // event before the in-memory cap evicts it. Only seqable kinds — chrome/thinking noise
2135
- // is skipped. entry.id/entry.room are stamped in openStructured; a PTY entry (no id)
2136
- // is a no-op. This is the single place every new event passes through, so nothing is
2137
- // missed and reloaded events (which don't ride pushLog) never double-append.
2161
+ // event in full before the bounded replay copy enters memory. Only seqable kinds —
2162
+ // chrome/thinking noise is skipped. entry.id/entry.room are stamped in
2163
+ // openStructured; a PTY entry (no id) is a no-op. This is the single place every new
2164
+ // event passes through, so nothing is missed and reloaded events never double-append.
2138
2165
  if (entry.id && seqable(evt)) {
2139
2166
  appendDurableEvents(entry.room, entry.id, [evt])
2140
2167
  // First archived event sets the floor for a fresh lane (no seed ran). Lets the
@@ -2142,7 +2169,17 @@ function pushLog(entry, evt) {
2142
2169
  // above this floor — a new lane's first turn at seq>1 is NOT older-history.
2143
2170
  if (entry.archiveOldestSeq == null) entry.archiveOldestSeq = evt.seq
2144
2171
  }
2145
- if (entry.log.length > STRUCTURED_LOG_MAX) entry.log.shift()
2172
+ // The rolling replay log is also the restart snapshot. Keep it browser-sized here,
2173
+ // not only at broadcast time: otherwise one giant tool result is copied into every
2174
+ // debounced snapshot and turns a bridge restart into tens of MB of synchronous JSON
2175
+ // parsing/writing. The complete event remains in the append-only JSONL archive above.
2176
+ const replayEvent = boundEventForBroadcast(evt)
2177
+ entry.log.push(replayEvent)
2178
+ entry.logBytes = (entry.logBytes || 0) + JSON.stringify(replayEvent).length + 1
2179
+ while (entry.log.length > STRUCTURED_LOG_MAX || (entry.log.length > 1 && entry.logBytes > STRUCTURED_LOG_BYTE_CAP)) {
2180
+ const removed = entry.log.shift()
2181
+ entry.logBytes -= JSON.stringify(removed).length + 1
2182
+ }
2146
2183
  return evt
2147
2184
  }
2148
2185
 
@@ -2230,7 +2267,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2230
2267
  ? spawnDepth
2231
2268
  : (sideParent || (spawnedBy && !String(spawnedBy).startsWith('flow:')) ? 1 : 0)
2232
2269
  const initialHop = Number.isInteger(hop) && hop >= 0 ? hop : structuralDepth
2233
- const entry = { cmd: runtime, runtime, kind: 'structured', log: Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : [], pending: new Map(), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
2270
+ const restoredRawLog = Array.isArray(log) ? log.slice(-STRUCTURED_LOG_MAX) : []
2271
+ const restoredLog = boundStructuredLog(restoredRawLog)
2272
+ const restoredOffset = restoredRawLog.length - restoredLog.length
2273
+ const entry = { cmd: runtime, runtime, kind: 'structured', log: restoredLog, pending: new Map(), session: null, recovered: false, commands: commandCatalogForRuntime(runtime, commands), mode, effort, models: runtime === 'codex' ? codexModels : runtime === 'hermes' ? (Array.isArray(models) && models.length ? models : hostHermesModels) : undefined,
2234
2274
  // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
2235
2275
  // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
2236
2276
  // registered provider the SDK id is impersonated (see the onEvent guard below), so
@@ -2255,7 +2295,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2255
2295
  // Stable creation order — persisted so a bridge restart restores tabs in the SAME
2256
2296
  // order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
2257
2297
  // first transcript event ts, so even the first post-fix restart is ordered right.
2258
- openedAt: openedAt || (Array.isArray(log) ? (log.find((e) => e?.ts)?.ts || 0) : 0) || Date.now() }
2298
+ openedAt: openedAt || (Array.isArray(log) ? (log.find((e) => e?.ts)?.ts || 0) : 0) || Date.now(),
2299
+ // A legacy bridge may have persisted raw multi-megabyte events. The restored
2300
+ // in-memory copy is already bounded; rewrite that compact form only after the
2301
+ // room has announced ready so migration cannot extend the loading screen.
2302
+ snapshotNeedsCompaction: restoredOffset > 0 || restoredLog.some((event, index) => event !== restoredRawLog[index + restoredOffset]),
2303
+ logBytes: restoredLog.reduce((bytes, event) => bytes + JSON.stringify(event).length + 1, 0) }
2259
2304
  entry._turnRev = entry.log.reduce((max, event) => Math.max(max, Number(event?.turnRev) || 0), 0)
2260
2305
  const restoredBoundary = [...entry.log].reverse().find((event) => event?.kind === 'result' && Number(event?.turnRev) > 0)
2261
2306
  entry._settledTurnRev = Number(restoredBoundary?.turnRev) || null
@@ -4356,9 +4401,7 @@ channel
4356
4401
  try {
4357
4402
  const fp = canonicalRoomFilePath(payload, { updir: UPDIR })
4358
4403
  if (!fp) throw new Error('invalid attachment metadata')
4359
- const r = await fetch(payload.url)
4360
- if (!r.ok) throw new Error(`HTTP ${r.status}`)
4361
- const buf = Buffer.from(await r.arrayBuffer())
4404
+ const buf = await fetchRoomAttachment(payload)
4362
4405
  if (buf.length > FILE_MAX_BYTES) throw new Error('file too large')
4363
4406
  fs.mkdirSync(UPDIR, { recursive: true })
4364
4407
  fs.writeFileSync(fp, buf)
@@ -4533,7 +4576,7 @@ channel
4533
4576
  // two conversations. Drop the cached floor with it: the next pushLog re-seeds
4534
4577
  // archiveOldestSeq from the FIRST post-clear event (the new epoch's floor), instead
4535
4578
  // of leaving a stale pre-clear floor to mis-aim trimmedBeforeSeq's phantom-pill guard.
4536
- s.log = []; s.seq = makeSeqCounter(0); s.archiveOldestSeq = null
4579
+ s.log = []; s.logBytes = 0; s.seq = makeSeqCounter(0); s.archiveOldestSeq = null
4537
4580
  bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
4538
4581
  flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
4539
4582
  ctlLine('context cleared. You can continue with these answers in mind.')
@@ -4798,6 +4841,7 @@ channel
4798
4841
  realtimeHealthy = true; brokenSince = 0
4799
4842
  trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT })
4800
4843
  const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
4844
+ const interruptedRecoveries = []
4801
4845
  if (startCmd && !terms.size && !sessions.size) {
4802
4846
  // Claude + structured mode → Agent SDK session; everything else → PTY.
4803
4847
  // On restart, restore EVERY saved structured session for this room (not
@@ -4851,9 +4895,8 @@ channel
4851
4895
  // Every interrupted role resumes exactly once. Flow lanes do not have a
4852
4896
  // startup redispatch path; excluding them here stranded conductors/builders.
4853
4897
  if (re && wasInterrupted) {
4854
- const recovery = recoverInterruptedTurn(re, { resumable, recap: recoveryRecap })
4855
- if (recovery === 'sent') process.stderr.write(`\n ◆ auto-resumed interrupted Codex turn (${rec.id.slice(0, 8)}) — sent continue.\n`)
4856
- else if (recovery === 'recap-sent') process.stderr.write(`\n ◆ resumed interrupted Codex turn (${rec.id.slice(0, 8)}) with a fresh context recap.\n`)
4898
+ re.pendingInterruptedRecovery = { resumable, recap: recoveryRecap }
4899
+ interruptedRecoveries.push(re)
4857
4900
  }
4858
4901
  }
4859
4902
  // Fresh open: ONLY for an explicit `-- <claude>` share. In account mode
@@ -4888,6 +4931,31 @@ channel
4888
4931
  if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
4889
4932
  try { process.send({ t: 'room-ready', room, bridgeId: BRIDGE_ID }) } catch { /* supervisor exited */ }
4890
4933
  }
4934
+ // The room is usable before any runtime processes cold-boot. Stagger interrupted
4935
+ // resumes so several Codex/Claude MCP handshakes cannot stampede the same host and
4936
+ // starve the loopback MCP server. A person speaking during the delay cancels that
4937
+ // lane's stale auto-continue via supersedeInterruptedResume().
4938
+ interruptedRecoveries.forEach((entry, index) => {
4939
+ const timer = setTimeout(() => {
4940
+ const pending = entry.pendingInterruptedRecovery
4941
+ entry.pendingInterruptedRecovery = null
4942
+ if (!pending || sessions.get(entry.id) !== entry) return
4943
+ const recovery = recoverInterruptedTurn(entry, pending)
4944
+ if (recovery === 'sent') process.stderr.write(`\n ◆ auto-resumed interrupted Codex turn (${entry.id.slice(0, 8)}) — sent continue.\n`)
4945
+ else if (recovery === 'recap-sent') process.stderr.write(`\n ◆ resumed interrupted Codex turn (${entry.id.slice(0, 8)}) with a fresh context recap.\n`)
4946
+ }, 1000 + index * 5000)
4947
+ timer.unref?.()
4948
+ })
4949
+ // Compact legacy oversized snapshots after readiness. Each entry already holds the
4950
+ // bounded form, so this writes the small replacement without keeping the dashboard
4951
+ // or room bootstrap behind fsyncs.
4952
+ setImmediate(() => {
4953
+ for (const entry of sessions.values()) {
4954
+ if (!entry.snapshotNeedsCompaction) continue
4955
+ entry.snapshotNeedsCompaction = false
4956
+ try { entry.flush?.() } catch { /* keep the prior recoverable snapshot */ }
4957
+ }
4958
+ })
4891
4959
  process.stderr.write(headless
4892
4960
  ? `\n ◆ thinkpool — relaying room ${room} (headless). Open terminals from the web UI.\n\n`
4893
4961
  : `\n ◆ thinkpool — sharing "${attachedCmd}"${continuing ? ' (continuing your latest session)' : ''} into room ${room}. Open the web UI and you're both in.\n\n`)
@@ -5411,6 +5479,9 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
5411
5479
  if (!m) return
5412
5480
  if (m.t === 'update-available') surfaceUpdate(m.version)
5413
5481
  else if (m.t === 'agent-visibility-changed') void announce()
5482
+ else if (m.t === 'restart-probe') {
5483
+ try { process.send({ t: 'restart-probe-result', nonce: m.nonce, between: betweenTurns() }) } catch { /* parent is leaving */ }
5484
+ }
5414
5485
  // Supervisor rotated the owner JWT (account.mjs scheduleTokenRefresh) — adopt
5415
5486
  // it so our authed writes (code-mockup) never go stale on a long session (L16).
5416
5487
  else if (m.t === 'token') {
package/event-bounds.mjs CHANGED
@@ -4,6 +4,9 @@
4
4
  replay frame or tx: seed row into a multi-megabyte hydration dependency. */
5
5
 
6
6
  export const STRUCTURED_EVENT_CAP = 80000
7
+ // 14 × the 150KB replay-frame budget: the largest complete rolling window the
8
+ // transport can deliver without trimming. Older events remain in the JSONL archive.
9
+ export const STRUCTURED_LOG_BYTE_CAP = 2100000
7
10
 
8
11
  const sizeOf = (value) => {
9
12
  try { return JSON.stringify(value).length } catch { return Infinity }
@@ -94,3 +97,21 @@ export function boundStructuredEvent(event, cap = STRUCTURED_EVENT_CAP) {
94
97
  identity._notice = 'Large tool payload truncated for fast transcript hydration'
95
98
  return identity
96
99
  }
100
+
101
+ export function boundStructuredLog(events, {
102
+ eventCap = STRUCTURED_EVENT_CAP,
103
+ byteCap = STRUCTURED_LOG_BYTE_CAP,
104
+ countCap = 2000,
105
+ } = {}) {
106
+ if (!Array.isArray(events) || !events.length) return []
107
+ const bounded = events.slice(-countCap).map((event) => boundStructuredEvent(event, eventCap))
108
+ let bytes = 0
109
+ let start = bounded.length
110
+ while (start > 0) {
111
+ const size = sizeOf(bounded[start - 1]) + 1
112
+ if (start < bounded.length && bytes + size > byteCap) break
113
+ bytes += size
114
+ start--
115
+ }
116
+ return start === 0 ? bounded : bounded.slice(start)
117
+ }
@@ -86,7 +86,10 @@ export function recoverMissingResumeOnce(entry, { message, recap = '', reopen }
86
86
  // cold Claude/Codex runtime can trigger its init event and enqueue a stale second
87
87
  // `continue` behind the person's actual request.
88
88
  export function supersedeInterruptedResume(entry) {
89
- if (!entry?.pendingAutoResume) return false
89
+ if (!entry) return false
90
+ const queued = !!entry.pendingInterruptedRecovery
91
+ entry.pendingInterruptedRecovery = null
92
+ if (!entry.pendingAutoResume) return queued
90
93
  entry.pendingAutoResume = false
91
94
  return true
92
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.343",
3
+ "version": "0.7.344",
4
4
  "description": "Connect Claude Code, Codex, or Hermes on your computer to a Thinkpool Code room.",
5
5
  "type": "module",
6
6
  "bin": {
package/session-store.mjs CHANGED
@@ -12,6 +12,7 @@ import os from 'node:os'
12
12
  import fs from 'node:fs'
13
13
  import path from 'node:path'
14
14
  import { SPAWN } from './cross-terminal.mjs'
15
+ import { boundStructuredLog } from './event-bounds.mjs'
15
16
 
16
17
  // TP_PAIR_ROOT override exists for tests + sandboxes so they never touch the real
17
18
  // ~/.thinkpool-pair store. Read LIVE per call (not captured at module load) so a test
@@ -265,7 +266,8 @@ function write(room, id, data) {
265
266
  const file = path.join(dir(room), `${id}.json`)
266
267
  try {
267
268
  ensureDir(room)
268
- atomicCommit(file, JSON.stringify({ ...data, id, savedAt: Date.now() }))
269
+ const log = Array.isArray(data?.log) ? boundStructuredLog(data.log) : data?.log
270
+ atomicCommit(file, JSON.stringify({ ...data, ...(log ? { log } : {}), id, savedAt: Date.now() }))
269
271
  prune(room)
270
272
  return true
271
273
  } catch (error) {