thinkpool-pair 0.7.67 → 0.7.69

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/bridge.mjs +46 -6
  2. package/event-id.mjs +31 -0
  3. package/package.json +1 -1
package/bridge.mjs CHANGED
@@ -39,7 +39,7 @@ import { createClient } from '@supabase/supabase-js'
39
39
  import { startClaudeSession } from './claude-session.mjs'
40
40
  import { turnInFlight } from './update-gate.mjs'
41
41
  import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames } from './session-store.mjs'
42
- import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents } from './event-id.mjs'
42
+ import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents } from './event-id.mjs'
43
43
  import { makeThrottledTrack } from './presence.mjs'
44
44
 
45
45
  // Public client creds (the same anon values the web app ships — safe to embed).
@@ -454,8 +454,37 @@ const webPeerPresent = () => {
454
454
  return false
455
455
  }
456
456
 
457
+ // Realtime broadcast silently DROPS frames over its size limit AND can break the
458
+ // socket for the rest of the tick — the 2026-06-26 reload-freeze (a 5.4MB replay).
459
+ // This is the universal backstop: ANY oversized frame (replay OR a giant live
460
+ // tool_result/assistant blob) is (a) LOGGED loudly so it can never be a silent black
461
+ // hole again, and (b) defanged by truncating the largest string field so the frame
462
+ // still delivers instead of taking the channel down. capReplayEvents bounds bulk
463
+ // replays before they reach here; this catches the single-event case + is the alarm.
464
+ const FRAME_SOFT_LIMIT = 200000
465
+ const defendFrame = (event, payload) => {
466
+ let json
467
+ try { json = JSON.stringify(payload) } catch { return payload }
468
+ if (!json || json.length <= FRAME_SOFT_LIMIT) return payload
469
+ process.stderr.write(`\n ⚠ oversized ${event} frame: ${json.length}B (term ${String(payload?.term || '?').slice(0, 8)}) — truncating to fit so delivery isn't dropped\n`)
470
+ try {
471
+ const p = JSON.parse(json)
472
+ const evt = p.evt
473
+ if (evt && typeof evt === 'object') {
474
+ for (const k of ['text', 'content', 'output', 'stdout', 'stderr']) {
475
+ if (typeof evt[k] === 'string' && evt[k].length > 20000) {
476
+ const orig = evt[k].length
477
+ evt[k] = evt[k].slice(0, 20000) + `\n…[truncated ${orig - 20000} bytes — too large to broadcast]`
478
+ }
479
+ }
480
+ }
481
+ return p
482
+ } catch { return payload }
483
+ }
484
+
457
485
  const bcast = (event, payload) => {
458
486
  if (event === 'pty-out' || event === 'code-event') lastActivity = Date.now()
487
+ payload = defendFrame(event, payload)
459
488
  try {
460
489
  if (channel.channelAdapter?.canPush?.() ?? true) {
461
490
  channel.send({ type: 'broadcast', event, payload })
@@ -992,11 +1021,22 @@ channel
992
1021
  for (const [id, s] of sessions) {
993
1022
  if (!s.log.length) continue
994
1023
  const from = Number(payload?.cursors?.[id]) || 0
995
- const tail = from > 0 ? s.log.filter((e) => e.seq == null || e.seq > from) : s.log
996
- // Cap to a frame-safe size — the full log on a fresh-reload (empty cursor) is
997
- // multi-MB and gets dropped by Realtime, freezing the terminal. See capReplayEvents.
998
- const events = capReplayEvents(tail)
999
- bcast('code-replay', { to: payload?.to ?? null, term: id, events })
1024
+ const mx = maxSeq(s.log)
1025
+ // Cursor-sanity (B-safety): if the client's cursor is AHEAD of our highest seq, the
1026
+ // bridge restarted BEHIND the client (lost unflushed events) or a /clear reset the
1027
+ // counter — our live seqs would all read as "already seen" and be dropped forever
1028
+ // (the seq-divergence freeze). Replay the full recent tail and flag `reset` so the
1029
+ // client rebuilds its cursor from our authoritative log instead of trusting its own.
1030
+ const ahead = from > mx
1031
+ const tail = (from > 0 && !ahead) ? s.log.filter((e) => e.seq == null || e.seq > from) : s.log
1032
+ // Chunk the tail into ORDERED under-budget frames (A): the full log on a fresh reload
1033
+ // (empty cursor) is multi-MB and a single frame would be dropped by Realtime, freezing
1034
+ // the terminal. chunkReplayEvents splits it so nothing in the recent window is lost;
1035
+ // classifyReplay applies the chunks in arrival order. Older history beyond the chunk
1036
+ // budget is covered by the client's DB transcript snapshot.
1037
+ for (const events of chunkReplayEvents(tail)) {
1038
+ bcast('code-replay', { to: payload?.to ?? null, term: id, events, ...(ahead ? { reset: true } : {}) })
1039
+ }
1000
1040
  }
1001
1041
  // Re-send any still-pending permission/question cards. They ride a one-shot
1002
1042
  // code-perm-req broadcast (NOT the replayed event log), so a reconnect/refresh
package/event-id.mjs CHANGED
@@ -93,3 +93,34 @@ export function capReplayEvents(events, budget = REPLAY_BYTE_BUDGET) {
93
93
  }
94
94
  return i === 0 ? events : events.slice(i)
95
95
  }
96
+
97
+ // A (2026-06-26 follow-up): chunk the replay tail into ORDERED, under-budget frames so
98
+ // no event in the recent window is lost (capReplayEvents truncates; this doesn't). Each
99
+ // returned chunk is broadcast as its own `code-replay` — seqDedup.classifyReplay applies
100
+ // them in order (each chunk is contiguous and seq > seqHi), so the client rebuilds the
101
+ // whole window across frames. We keep the MOST RECENT `maxChunks * budget` bytes (older
102
+ // history is the DB transcript snapshot's job) so a giant log doesn't fan out 30+ frames.
103
+ // Returns an array of event-arrays, oldest chunk first; [] when there's nothing to send.
104
+ export const REPLAY_MAX_CHUNKS = 6
105
+ export function chunkReplayEvents(events, budget = REPLAY_BYTE_BUDGET, maxChunks = REPLAY_MAX_CHUNKS) {
106
+ if (!Array.isArray(events) || events.length === 0) return []
107
+ // Walk newest→oldest accumulating into the current chunk; start a new chunk when the
108
+ // next event would overflow the budget. Stop once we have maxChunks. A lone event
109
+ // larger than the whole budget still gets its own chunk (so seqHi can advance).
110
+ const chunksRev = [] // newest chunk first while building
111
+ let cur = [], curBytes = 0
112
+ for (let i = events.length - 1; i >= 0; i--) {
113
+ const sz = JSON.stringify(events[i]).length + 1
114
+ if (cur.length && curBytes + sz > budget) {
115
+ chunksRev.push(cur)
116
+ if (chunksRev.length >= maxChunks) { cur = null; break }
117
+ cur = [], curBytes = 0
118
+ }
119
+ cur.push(events[i]) // reversed within the chunk for now
120
+ curBytes += sz
121
+ }
122
+ if (cur && cur.length) chunksRev.push(cur)
123
+ // chunksRev is newest-chunk-first, each chunk newest-event-first → reverse both axes
124
+ // to oldest-first so classifyReplay applies them contiguously in arrival order.
125
+ return chunksRev.reverse().map((c) => c.reverse())
126
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.67",
3
+ "version": "0.7.69",
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": {