thinkpool-pair 0.7.69 → 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 +22 -16
  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).
@@ -466,20 +466,22 @@ const defendFrame = (event, payload) => {
466
466
  let json
467
467
  try { json = JSON.stringify(payload) } catch { return payload }
468
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 }
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`)
470
+ let p
471
+ try { p = JSON.parse(json) } catch { return payload }
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…
476
+ if (Array.isArray(p.events)) {
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.
481
+ let guard = 0
482
+ while (p.events.length > 1 && JSON.stringify(p).length > FRAME_SOFT_LIMIT && guard++ < 10000) p.events.shift()
483
+ }
484
+ return p
483
485
  }
484
486
 
485
487
  const bcast = (event, payload) => {
@@ -1028,7 +1030,11 @@ channel
1028
1030
  // (the seq-divergence freeze). Replay the full recent tail and flag `reset` so the
1029
1031
  // client rebuilds its cursor from our authoritative log instead of trusting its own.
1030
1032
  const ahead = from > mx
1031
- 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))
1032
1038
  // Chunk the tail into ORDERED under-budget frames (A): the full log on a fresh reload
1033
1039
  // (empty cursor) is multi-MB and a single frame would be dropped by Realtime, freezing
1034
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.69",
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": {