thinkpool-pair 0.7.342 → 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 } },
@@ -1283,6 +1311,15 @@ const replayPump = createLatestReplayPump({
1283
1311
  // every announce carries them so a late-joining or second device sees them too.
1284
1312
  const termNames = loadNames(room)
1285
1313
  const manualNameTouched = new Set()
1314
+ const acknowledgeTermOpen = (id, kind, entry = null) => {
1315
+ bcast('term-opened', {
1316
+ id,
1317
+ kind,
1318
+ sideParent: entry?.sideParent || undefined,
1319
+ sideTask: entry?.sideTask || undefined,
1320
+ name: termNames[id] || undefined,
1321
+ })
1322
+ }
1286
1323
  const autoNameAttempts = new Set()
1287
1324
  const autoNames = new Map()
1288
1325
  // The SDK's supported-model LIST (value/displayName/description), captured from
@@ -1726,7 +1763,6 @@ function pumpDesign(term) {
1726
1763
  }
1727
1764
  const active = { ...next, resultOk: false, proof: null, timer: null }
1728
1765
  designActive.set(term, active)
1729
- designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'applying' })
1730
1766
  // Complete room-visible instructions, without host paths or locator packets.
1731
1767
  const visible = {
1732
1768
  kind: 'you',
@@ -1747,6 +1783,10 @@ function pumpDesign(term) {
1747
1783
  } catch { accepted = false }
1748
1784
  if (accepted) beginStructuredTurn(lane)
1749
1785
  stampEvent(visible); pushLog(lane, visible); bcast('code-event', { term, evt: visible })
1786
+ // Browser clients use this as the positive dispatch acknowledgment. Keep it
1787
+ // after the accepted turn and its visible prompt so "applying" never navigates
1788
+ // a submitter away from an actionable Design failure.
1789
+ if (accepted) designStatus({ previewId: next.record.previewId, requestId: next.request.cid, state: 'applying' })
1750
1790
  if (!accepted) {
1751
1791
  syncStructuredTurn(lane)
1752
1792
  const failed = { kind: 'error', message: 'The producing lane could not start the edit.', recoverable: true }
@@ -2117,12 +2157,11 @@ function pushLog(entry, evt) {
2117
2157
  // filters out (system/thinking_tokens/effort) ride the log seq-less so they don't
2118
2158
  // hole the client's applied sequence. See event-id.mjs NO_SEQ_KINDS.
2119
2159
  if (entry.seq && seqable(evt)) evt.seq = entry.seq.next()
2120
- entry.log.push(evt)
2121
2160
  // Durable, UNBOUNDED archive (powers "load earlier history"): append the transcript
2122
- // event before the in-memory cap evicts it. Only seqable kinds — chrome/thinking noise
2123
- // is skipped. entry.id/entry.room are stamped in openStructured; a PTY entry (no id)
2124
- // is a no-op. This is the single place every new event passes through, so nothing is
2125
- // 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.
2126
2165
  if (entry.id && seqable(evt)) {
2127
2166
  appendDurableEvents(entry.room, entry.id, [evt])
2128
2167
  // First archived event sets the floor for a fresh lane (no seed ran). Lets the
@@ -2130,7 +2169,17 @@ function pushLog(entry, evt) {
2130
2169
  // above this floor — a new lane's first turn at seq>1 is NOT older-history.
2131
2170
  if (entry.archiveOldestSeq == null) entry.archiveOldestSeq = evt.seq
2132
2171
  }
2133
- 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
+ }
2134
2183
  return evt
2135
2184
  }
2136
2185
 
@@ -2218,7 +2267,10 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2218
2267
  ? spawnDepth
2219
2268
  : (sideParent || (spawnedBy && !String(spawnedBy).startsWith('flow:')) ? 1 : 0)
2220
2269
  const initialHop = Number.isInteger(hop) && hop >= 0 ? hop : structuralDepth
2221
- 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,
2222
2274
  // model: truthful active-model label — now the SAME `laneModel` the SDK is given, so the
2223
2275
  // chip cannot disagree with the wire. When this lane runs on a custom (non-anthropic)
2224
2276
  // registered provider the SDK id is impersonated (see the onEvent guard below), so
@@ -2243,7 +2295,12 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
2243
2295
  // Stable creation order — persisted so a bridge restart restores tabs in the SAME
2244
2296
  // order (not readdir/filesystem order). Legacy recs (no openedAt) derive it from the
2245
2297
  // first transcript event ts, so even the first post-fix restart is ordered right.
2246
- 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) }
2247
2304
  entry._turnRev = entry.log.reduce((max, event) => Math.max(max, Number(event?.turnRev) || 0), 0)
2248
2305
  const restoredBoundary = [...entry.log].reverse().find((event) => event?.kind === 'result' && Number(event?.turnRev) > 0)
2249
2306
  entry._settledTurnRev = Number(restoredBoundary?.turnRev) || null
@@ -4111,6 +4168,13 @@ channel
4111
4168
  // Multi-bridge rooms: a targeted open is for ONE machine. Untargeted
4112
4169
  // opens (older web) are taken by whoever hears them — the solo case.
4113
4170
  if (payload.host && payload.host !== name) return
4171
+ // A client retries an open when the first broadcast or this acknowledgement is
4172
+ // lost. PTY creation was already idempotent, but acknowledge the existing lane
4173
+ // explicitly so the watchdog can settle without waiting for a full roster.
4174
+ if (terms.has(payload.id)) {
4175
+ acknowledgeTermOpen(payload.id, 'pty', terms.get(payload.id))
4176
+ return
4177
+ }
4114
4178
  // resume is a FLAG, never argv: the channel must not pass arbitrary
4115
4179
  // args even though the room is shell-trust by design. The args come
4116
4180
  // from our own KNOWN_AGENTS table, probe-gated so `--continue` with
@@ -4121,6 +4185,15 @@ channel
4121
4185
  if (agent?.resume) { try { if (agent.resume.probe()) args = [...agent.resume.args] } catch { /* fresh */ } }
4122
4186
  }
4123
4187
  if (wantStructured(payload.cmd)) {
4188
+ // The open id is the idempotency key for the ENTIRE operation, including a
4189
+ // side lane's initial task. openStructured() already no-ops for an existing
4190
+ // runtime, but the old handler continued below and published side-started +
4191
+ // dispatched the task again. A retry must only replay the acceptance ack.
4192
+ const existing = sessions.get(payload.id)
4193
+ if (existing) {
4194
+ acknowledgeTermOpen(payload.id, 'structured', existing)
4195
+ return
4196
+ }
4124
4197
  // model: the web passes the model to open on — a NEW terminal inherits the
4125
4198
  // previous terminal's model (and the first uses the provider default). Falls
4126
4199
  // through to the SDK default when absent. Stamped on the entry so the announce
@@ -4179,13 +4252,17 @@ channel
4179
4252
  return
4180
4253
  }
4181
4254
  child.flush?.(); parent.flush?.(); announce()
4255
+ acknowledgeTermOpen(payload.id, 'structured', child)
4182
4256
  process.stderr.write(`\n ◆ ${by} opened side lane ${String(payload.id).slice(0, 8)} under ${String(sideParent).slice(0, 8)}.\n`)
4183
4257
  return
4184
4258
  }
4259
+ const opened = sessions.get(payload.id)
4260
+ if (opened) acknowledgeTermOpen(payload.id, 'structured', opened)
4185
4261
  process.stderr.write(`\n ◆ web opened a structured "${payload.cmd}" session (${payload.mode || 'default'}${payload.model ? `, model ${payload.model}` : ''}).\n`)
4186
4262
  return
4187
4263
  }
4188
4264
  openTerm({ id: payload.id, cmd: payload.cmd, args })
4265
+ if (terms.has(payload.id)) acknowledgeTermOpen(payload.id, 'pty', terms.get(payload.id))
4189
4266
  process.stderr.write(`\n ◆ web opened a "${payload.cmd}"${args.length ? ' (continue)' : ''} terminal (headless).\n`)
4190
4267
  })
4191
4268
  .on('broadcast', { event: 'term-close' }, ({ payload }) => {
@@ -4324,9 +4401,7 @@ channel
4324
4401
  try {
4325
4402
  const fp = canonicalRoomFilePath(payload, { updir: UPDIR })
4326
4403
  if (!fp) throw new Error('invalid attachment metadata')
4327
- const r = await fetch(payload.url)
4328
- if (!r.ok) throw new Error(`HTTP ${r.status}`)
4329
- const buf = Buffer.from(await r.arrayBuffer())
4404
+ const buf = await fetchRoomAttachment(payload)
4330
4405
  if (buf.length > FILE_MAX_BYTES) throw new Error('file too large')
4331
4406
  fs.mkdirSync(UPDIR, { recursive: true })
4332
4407
  fs.writeFileSync(fp, buf)
@@ -4501,7 +4576,7 @@ channel
4501
4576
  // two conversations. Drop the cached floor with it: the next pushLog re-seeds
4502
4577
  // archiveOldestSeq from the FIRST post-clear event (the new epoch's floor), instead
4503
4578
  // of leaving a stale pre-clear floor to mis-aim trimmedBeforeSeq's phantom-pill guard.
4504
- s.log = []; s.seq = makeSeqCounter(0); s.archiveOldestSeq = null
4579
+ s.log = []; s.logBytes = 0; s.seq = makeSeqCounter(0); s.archiveOldestSeq = null
4505
4580
  bcast('code-event', { term: payload.term, evt: { kind: 'clear' } })
4506
4581
  flushSession(room, payload.term, { sessionId: s.session?.sessionId || null, log: [] })
4507
4582
  ctlLine('context cleared. You can continue with these answers in mind.')
@@ -4766,6 +4841,7 @@ channel
4766
4841
  realtimeHealthy = true; brokenSince = 0
4767
4842
  trackPresence({ name, role: 'bridge', bridge_id: BRIDGE_ID, started_at: BRIDGE_STARTED_AT })
4768
4843
  const startCmd = attachedCmd || autoAgent // autoAgent: account-mode auto-open (headless)
4844
+ const interruptedRecoveries = []
4769
4845
  if (startCmd && !terms.size && !sessions.size) {
4770
4846
  // Claude + structured mode → Agent SDK session; everything else → PTY.
4771
4847
  // On restart, restore EVERY saved structured session for this room (not
@@ -4819,9 +4895,8 @@ channel
4819
4895
  // Every interrupted role resumes exactly once. Flow lanes do not have a
4820
4896
  // startup redispatch path; excluding them here stranded conductors/builders.
4821
4897
  if (re && wasInterrupted) {
4822
- const recovery = recoverInterruptedTurn(re, { resumable, recap: recoveryRecap })
4823
- if (recovery === 'sent') process.stderr.write(`\n ◆ auto-resumed interrupted Codex turn (${rec.id.slice(0, 8)}) — sent continue.\n`)
4824
- 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)
4825
4900
  }
4826
4901
  }
4827
4902
  // Fresh open: ONLY for an explicit `-- <claude>` share. In account mode
@@ -4856,6 +4931,31 @@ channel
4856
4931
  if (process.send && process.env.THINKPOOL_PAIR_ACCOUNT_CHILD === '1') {
4857
4932
  try { process.send({ t: 'room-ready', room, bridgeId: BRIDGE_ID }) } catch { /* supervisor exited */ }
4858
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
+ })
4859
4959
  process.stderr.write(headless
4860
4960
  ? `\n ◆ thinkpool — relaying room ${room} (headless). Open terminals from the web UI.\n\n`
4861
4961
  : `\n ◆ thinkpool — sharing "${attachedCmd}"${continuing ? ' (continuing your latest session)' : ''} into room ${room}. Open the web UI and you're both in.\n\n`)
@@ -5379,6 +5479,9 @@ if (process.env.THINKPOOL_PAIR_AUTOUPDATE === '1' && VERSION) {
5379
5479
  if (!m) return
5380
5480
  if (m.t === 'update-available') surfaceUpdate(m.version)
5381
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
+ }
5382
5485
  // Supervisor rotated the owner JWT (account.mjs scheduleTokenRefresh) — adopt
5383
5486
  // it so our authed writes (code-mockup) never go stale on a long session (L16).
5384
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.342",
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) {