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.
- package/bridge.mjs +37 -3
- package/event-id.mjs +26 -0
- 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(
|
|
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
|
|
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
|
+
}
|