thinkpool-pair 0.7.70 → 0.7.71

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 +14 -21
  2. package/event-id.mjs +33 -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, chunkReplayEvents } from './event-id.mjs'
42
+ import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast } 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).
@@ -462,17 +462,6 @@ const webPeerPresent = () => {
462
462
  // still delivers instead of taking the channel down. capReplayEvents bounds bulk
463
463
  // replays before they reach here; this catches the single-event case + is the alarm.
464
464
  const FRAME_SOFT_LIMIT = 200000
465
- // Truncate the bulky string fields of ONE event in place (a giant tool_result / cat of
466
- // a big file / mockup blob). Keeps a head + a marker so the transcript stays readable.
467
- const truncEventFields = (evt) => {
468
- if (!evt || typeof evt !== 'object') return
469
- for (const k of ['text', 'content', 'output', 'stdout', 'stderr', 'input']) {
470
- const v = evt[k]
471
- if (typeof v === 'string' && v.length > 20000) {
472
- evt[k] = v.slice(0, 20000) + `\n…[truncated ${v.length - 20000} bytes — too large to broadcast]`
473
- }
474
- }
475
- }
476
465
  const defendFrame = (event, payload) => {
477
466
  let json
478
467
  try { json = JSON.stringify(payload) } catch { return payload }
@@ -480,15 +469,15 @@ const defendFrame = (event, payload) => {
480
469
  process.stderr.write(`\n ⚠ oversized ${event} frame: ${json.length}B (term ${String(payload?.term || '?').slice(0, 8)}) — defanging so Realtime doesn't drop it\n`)
481
470
  let p
482
471
  try { p = JSON.parse(json) } catch { return payload }
483
- // single live event (code-event)
484
- if (p.evt && typeof p.evt === 'object') truncEventFields(p.evt)
485
- // replay batch (code-replay): truncate each event's bulky fields…
472
+ // single live event (code-event): bound it (truncates strings, strips oversized
473
+ // image/content blocks → render-safe placeholder).
474
+ if (p.evt && typeof p.evt === 'object') p.evt = boundEventForBroadcast(p.evt, 100000)
475
+ // replay batch (code-replay): bound every event…
486
476
  if (Array.isArray(p.events)) {
487
- p.events.forEach(truncEventFields)
488
- // …and if a frame is STILL over the limit (bulk hidden in fields we don't truncate),
489
- // drop the OLDEST events until it fits — keep the newest so the client's seqHi still
490
- // advances to current. classifyReplay accepts the resulting seq jump; older events are
491
- // covered by the DB transcript snapshot.
477
+ p.events = p.events.map((e) => boundEventForBroadcast(e, 100000))
478
+ // …and if a frame is STILL over the limit, drop the OLDEST events until it fits —
479
+ // keep the newest so the client's seqHi still advances to current. classifyReplay
480
+ // accepts the resulting seq jump; older events are covered by the DB transcript snapshot.
492
481
  let guard = 0
493
482
  while (p.events.length > 1 && JSON.stringify(p).length > FRAME_SOFT_LIMIT && guard++ < 10000) p.events.shift()
494
483
  }
@@ -1041,7 +1030,11 @@ channel
1041
1030
  // (the seq-divergence freeze). Replay the full recent tail and flag `reset` so the
1042
1031
  // client rebuilds its cursor from our authoritative log instead of trusting its own.
1043
1032
  const ahead = from > mx
1044
- const tail = (from > 0 && !ahead) ? s.log.filter((e) => e.seq == null || e.seq > from) : s.log
1033
+ const rawTail = (from > 0 && !ahead) ? s.log.filter((e) => e.seq == null || e.seq > from) : s.log
1034
+ // Bound each event so no single one (e.g. a 592KB inline-image tool_result) can
1035
+ // overflow a frame on its own — chunkReplayEvents only bounds the SUM, not a lone
1036
+ // giant event. boundEventForBroadcast clones+defangs only the oversized ones.
1037
+ const tail = rawTail.map((e) => boundEventForBroadcast(e))
1045
1038
  // Chunk the tail into ORDERED under-budget frames (A): the full log on a fresh reload
1046
1039
  // (empty cursor) is multi-MB and a single frame would be dropped by Realtime, freezing
1047
1040
  // the terminal. chunkReplayEvents splits it so nothing in the recent window is lost;
package/event-id.mjs CHANGED
@@ -124,3 +124,36 @@ export function chunkReplayEvents(events, budget = REPLAY_BYTE_BUDGET, maxChunks
124
124
  // to oldest-first so classifyReplay applies them contiguously in arrival order.
125
125
  return chunksRev.reverse().map((c) => c.reverse())
126
126
  }
127
+
128
+ // Bound a SINGLE event so it can never overflow a broadcast frame on its own. The worst
129
+ // offenders are tool_results carrying inline base64 IMAGES (a 592KB screenshot block) —
130
+ // these can't ride a size-capped Realtime broadcast at all (live OR replay). Truncate
131
+ // bulky string fields AND replace oversized content blocks (images, huge text) with a
132
+ // compact, render-SAFE text placeholder — preserving identity (kind/seq/cid/ts/toolUseId)
133
+ // so dedup, ordering, seqHi, and turn structure stay intact. Returns the event UNCHANGED
134
+ // (no clone) when it's already small; clones before mutating so the persisted log is never
135
+ // touched. (Proper fix for inline images = upload to storage + broadcast a URL — separate.)
136
+ export const PER_EVENT_CAP = 180000
137
+ const truncStr = (s) => s.slice(0, 20000) + `\n…[truncated ${s.length - 20000} bytes — too large to broadcast]`
138
+ export function boundEventForBroadcast(evt, cap = PER_EVENT_CAP) {
139
+ if (!evt || typeof evt !== 'object') return evt
140
+ let json
141
+ try { json = JSON.stringify(evt) } catch { return evt }
142
+ if (json.length <= cap) return evt
143
+ const out = { ...evt }
144
+ for (const k of ['text', 'output', 'stdout', 'stderr', 'input']) {
145
+ if (typeof out[k] === 'string' && out[k].length > 20000) out[k] = truncStr(out[k])
146
+ }
147
+ if (typeof out.content === 'string' && out.content.length > 20000) out.content = truncStr(out.content)
148
+ else if (Array.isArray(out.content)) {
149
+ out.content = out.content.map((b) => {
150
+ if (!b || typeof b !== 'object') return b
151
+ if (JSON.stringify(b).length <= 20000) return b
152
+ if (b.type === 'image') return { type: 'text', text: '[image omitted from replay — too large to broadcast; open the live session to view]' }
153
+ if (typeof b.text === 'string') return { ...b, text: truncStr(b.text) }
154
+ return { type: 'text', text: `[${b.type || 'content'} omitted — too large to broadcast]` }
155
+ })
156
+ }
157
+ out._bounded = true
158
+ return out
159
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.70",
3
+ "version": "0.7.71",
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": {