thinkpool-pair 0.7.66 → 0.7.68

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 +37 -3
  2. package/event-id.mjs +26 -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 } from './event-id.mjs'
42
+ import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents } 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 })
@@ -975,9 +1004,11 @@ channel
975
1004
  flushAll()
976
1005
  for (const [id, t] of terms) {
977
1006
  if (!t.scrollback) continue
1007
+ // Cap scrollback too — a huge PTY buffer would blow the same frame limit.
1008
+ const sb = t.scrollback.length > 100000 ? t.scrollback.slice(-100000) : t.scrollback
978
1009
  bcast('pty-replay', {
979
1010
  to: payload?.to ?? null, term: id,
980
- b64: Buffer.from(t.scrollback, 'utf8').toString('base64'),
1011
+ b64: Buffer.from(sb, 'utf8').toString('base64'),
981
1012
  })
982
1013
  }
983
1014
  // Structured sessions replay their event log (reader rebuilds from it).
@@ -990,7 +1021,10 @@ channel
990
1021
  for (const [id, s] of sessions) {
991
1022
  if (!s.log.length) continue
992
1023
  const from = Number(payload?.cursors?.[id]) || 0
993
- const events = from > 0 ? s.log.filter((e) => e.seq == null || e.seq > from) : s.log
1024
+ const tail = from > 0 ? s.log.filter((e) => e.seq == null || e.seq > from) : s.log
1025
+ // Cap to a frame-safe size — the full log on a fresh-reload (empty cursor) is
1026
+ // multi-MB and gets dropped by Realtime, freezing the terminal. See capReplayEvents.
1027
+ const events = capReplayEvents(tail)
994
1028
  bcast('code-replay', { to: payload?.to ?? null, term: id, events })
995
1029
  }
996
1030
  // Re-send any still-pending permission/question cards. They ride a one-shot
package/event-id.mjs CHANGED
@@ -67,3 +67,29 @@ export function maxSeq(events) {
67
67
  for (const e of events) if (e && typeof e.seq === 'number' && e.seq > m) m = e.seq
68
68
  return m
69
69
  }
70
+
71
+ // Cap a replay event list to a byte budget so the broadcast frame fits under the
72
+ // Realtime size limit. THE BUG (2026-06-26, room XE3IQ): on a fresh reload the client
73
+ // sends replay-request with an EMPTY cursor (seqHi=0), so the bridge replied with the
74
+ // ENTIRE per-term log — multi-thousand events / multiple MB for an established terminal.
75
+ // That frame exceeds Realtime's broadcast size limit and is silently DROPPED (and breaks
76
+ // the socket for the rest of the tick, taking small terminals' replays + the CHANNEL_ERROR
77
+ // churn down with it). The terminal then never refills and every live event is gap-dropped
78
+ // → "messages vanish, agent never responds, only brand-new terminals work".
79
+ // Keep the MOST RECENT events that fit: the client's DB transcript snapshot (code_messages
80
+ // tx:, last 200) already renders older history, and seqDedup.classifyReplay explicitly
81
+ // accepts the seq eviction-jump, so the client's live cursor (seqHi) still reaches current.
82
+ // Empirically a ~266KB frame delivered while multi-MB did not; 150KB is a safe margin.
83
+ // Always keeps at least the newest event so seqHi can advance even if it alone is oversized.
84
+ export const REPLAY_BYTE_BUDGET = 150000
85
+ export function capReplayEvents(events, budget = REPLAY_BYTE_BUDGET) {
86
+ if (!Array.isArray(events) || events.length === 0) return events
87
+ let total = 0, i = events.length
88
+ while (i > 0) {
89
+ const sz = JSON.stringify(events[i - 1]).length + 1
90
+ if (i < events.length && total + sz > budget) break
91
+ total += sz
92
+ i--
93
+ }
94
+ return i === 0 ? events : events.slice(i)
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.66",
3
+ "version": "0.7.68",
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": {