thinkpool-pair 0.7.68 → 0.7.70
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 +44 -20
- package/event-id.mjs +31 -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, 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).
|
|
@@ -462,24 +462,37 @@ 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
|
+
}
|
|
465
476
|
const defendFrame = (event, payload) => {
|
|
466
477
|
let json
|
|
467
478
|
try { json = JSON.stringify(payload) } catch { return payload }
|
|
468
479
|
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)}) —
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
480
|
+
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
|
+
let p
|
|
482
|
+
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…
|
|
486
|
+
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.
|
|
492
|
+
let guard = 0
|
|
493
|
+
while (p.events.length > 1 && JSON.stringify(p).length > FRAME_SOFT_LIMIT && guard++ < 10000) p.events.shift()
|
|
494
|
+
}
|
|
495
|
+
return p
|
|
483
496
|
}
|
|
484
497
|
|
|
485
498
|
const bcast = (event, payload) => {
|
|
@@ -1021,11 +1034,22 @@ channel
|
|
|
1021
1034
|
for (const [id, s] of sessions) {
|
|
1022
1035
|
if (!s.log.length) continue
|
|
1023
1036
|
const from = Number(payload?.cursors?.[id]) || 0
|
|
1024
|
-
const
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
|
|
1028
|
-
|
|
1037
|
+
const mx = maxSeq(s.log)
|
|
1038
|
+
// Cursor-sanity (B-safety): if the client's cursor is AHEAD of our highest seq, the
|
|
1039
|
+
// bridge restarted BEHIND the client (lost unflushed events) or a /clear reset the
|
|
1040
|
+
// counter — our live seqs would all read as "already seen" and be dropped forever
|
|
1041
|
+
// (the seq-divergence freeze). Replay the full recent tail and flag `reset` so the
|
|
1042
|
+
// client rebuilds its cursor from our authoritative log instead of trusting its own.
|
|
1043
|
+
const ahead = from > mx
|
|
1044
|
+
const tail = (from > 0 && !ahead) ? s.log.filter((e) => e.seq == null || e.seq > from) : s.log
|
|
1045
|
+
// Chunk the tail into ORDERED under-budget frames (A): the full log on a fresh reload
|
|
1046
|
+
// (empty cursor) is multi-MB and a single frame would be dropped by Realtime, freezing
|
|
1047
|
+
// the terminal. chunkReplayEvents splits it so nothing in the recent window is lost;
|
|
1048
|
+
// classifyReplay applies the chunks in arrival order. Older history beyond the chunk
|
|
1049
|
+
// budget is covered by the client's DB transcript snapshot.
|
|
1050
|
+
for (const events of chunkReplayEvents(tail)) {
|
|
1051
|
+
bcast('code-replay', { to: payload?.to ?? null, term: id, events, ...(ahead ? { reset: true } : {}) })
|
|
1052
|
+
}
|
|
1029
1053
|
}
|
|
1030
1054
|
// Re-send any still-pending permission/question cards. They ride a one-shot
|
|
1031
1055
|
// 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
|
+
}
|