thinkpool-pair 0.7.70 → 0.7.72
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 +90 -25
- package/event-id.mjs +53 -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, chunkReplayEvents } from './event-id.mjs'
|
|
42
|
+
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks } 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
|
-
|
|
485
|
-
|
|
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.
|
|
488
|
-
// …and if a frame is STILL over the limit
|
|
489
|
-
//
|
|
490
|
-
//
|
|
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
|
}
|
|
@@ -640,6 +629,64 @@ const ownerOutbox = (kind, id) => path.join(MOCKUP_OUTBOX, kind, id)
|
|
|
640
629
|
// back to the old "first live session/terminal" heuristic for it alone.
|
|
641
630
|
watchOutbox(MOCKUP_OUTBOX, () => [...sessions.keys()][0] || attachedId || [...terms.keys()][0])
|
|
642
631
|
|
|
632
|
+
// ── agent image hand-off ───────────────────────────────────────────
|
|
633
|
+
// A screenshot the agent produces arrives as a tool_result whose content carries
|
|
634
|
+
// an inline base64 image (100-600KB+). That overflows Realtime's broadcast frame —
|
|
635
|
+
// it can ride neither a live `code-event` nor a replay (boundEventForBroadcast
|
|
636
|
+
// strips it to a text placeholder so delivery survives). So BEFORE the event is
|
|
637
|
+
// logged/broadcast we lift each image OUT: upload the bytes to Storage (mirrors the
|
|
638
|
+
// mockup path → /api/code-image, service-key write) and rewrite the block to a tiny
|
|
639
|
+
// { type:'image', path } ref. The persisted log + every replay then carry the path,
|
|
640
|
+
// never base64; the web client mints a signed URL on render (structured.jsx, same
|
|
641
|
+
// as the mockup thumbs). Spec: docs/specs/2026-06-26-code-agent-images.md
|
|
642
|
+
async function uploadCodeImage(term, cid, im) {
|
|
643
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
644
|
+
if (codeAuthToken) headers.Authorization = `Bearer ${codeAuthToken}`
|
|
645
|
+
const res = await fetch(`${WEB_BASE}/api/code-image`, {
|
|
646
|
+
method: 'POST',
|
|
647
|
+
headers,
|
|
648
|
+
body: JSON.stringify({ code: room, term, cid, idx: im.idx, mediaType: im.mediaType, b64: im.b64 }),
|
|
649
|
+
})
|
|
650
|
+
if (!res.ok) throw new Error(`code-image ${res.status}`)
|
|
651
|
+
const { path } = await res.json()
|
|
652
|
+
if (!path) throw new Error('code-image: no path')
|
|
653
|
+
return path
|
|
654
|
+
}
|
|
655
|
+
// Defer an image-bearing tool_result behind a per-session upload chain (preserves
|
|
656
|
+
// arrival order among images), then log + broadcast the URL-only event through the
|
|
657
|
+
// normal path. pushLog is the single seq-assignment point, so the deferred event
|
|
658
|
+
// still gets a contiguous seq at chain-resolution time; its ts was stamped at
|
|
659
|
+
// arrival, so it sorts into place. On upload failure the block degrades to the same
|
|
660
|
+
// render-safe text placeholder — base64 is NEVER logged, broadcast, or persisted.
|
|
661
|
+
function deferImageEvent(entry, id, evt, imgs, emitTail) {
|
|
662
|
+
entry.imageChain = (entry.imageChain || Promise.resolve()).then(async () => {
|
|
663
|
+
for (const im of imgs) {
|
|
664
|
+
try {
|
|
665
|
+
const path = await uploadCodeImage(id, evt.cid, im)
|
|
666
|
+
evt.content[im.idx] = { type: 'image', path, mediaType: im.mediaType }
|
|
667
|
+
} catch (e) {
|
|
668
|
+
process.stderr.write(`\n ◇ screenshot upload failed (${e?.message || e}).\n`)
|
|
669
|
+
evt.content[im.idx] = { type: 'text', text: '[screenshot omitted — upload failed]' }
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
emitTail(evt)
|
|
673
|
+
}).catch(() => {})
|
|
674
|
+
}
|
|
675
|
+
// Fire-and-forget: drop a closed term's screenshots from Storage so they don't
|
|
676
|
+
// outlive the room (COGS). Per-term scope — never touches a sibling term's images.
|
|
677
|
+
function fireImageCleanup(term) {
|
|
678
|
+
if (!term) return
|
|
679
|
+
try {
|
|
680
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
681
|
+
if (codeAuthToken) headers.Authorization = `Bearer ${codeAuthToken}`
|
|
682
|
+
fetch(`${WEB_BASE}/api/code-image-cleanup`, {
|
|
683
|
+
method: 'POST',
|
|
684
|
+
headers,
|
|
685
|
+
body: JSON.stringify({ code: room, term }),
|
|
686
|
+
}).catch(() => { /* best-effort — the TTL sweep mops up orphans */ })
|
|
687
|
+
} catch { /* noop */ }
|
|
688
|
+
}
|
|
689
|
+
|
|
643
690
|
// ── shared-PTY geometry ───────────────────────────────────────────
|
|
644
691
|
// The attached PTY is CAPPED at 120×32, never pinned (2026-06-11).
|
|
645
692
|
// Mangling physics: a PTY *wider/taller* than the host's window forces
|
|
@@ -862,10 +909,23 @@ function openStructured({ id, model, resume, log, commands, mode }) {
|
|
|
862
909
|
entry.compacting = false
|
|
863
910
|
bcast('code-event', { term: id, evt: { kind: 'compact', status: 'done', ts: Date.now() } })
|
|
864
911
|
}
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
912
|
+
// Single tail: stamp into the log (transcript kinds only), broadcast live,
|
|
913
|
+
// print locally, persist. Shared so a deferred image event re-enters it once
|
|
914
|
+
// its base64 has been lifted to a Storage path (see deferImageEvent).
|
|
915
|
+
const emitTail = (e) => {
|
|
916
|
+
if (!chrome) pushLog(entry, e)
|
|
917
|
+
bcast('code-event', { term: id, evt: e })
|
|
918
|
+
printLocal(e)
|
|
919
|
+
if (!chrome) persist()
|
|
920
|
+
}
|
|
921
|
+
// A tool_result carrying an inline base64 image can't ride a broadcast frame
|
|
922
|
+
// (live OR replay). Lift it to Storage FIRST, then emit the URL-only event —
|
|
923
|
+
// so the persisted log + replay carry a path, never base64. Order/seq stay
|
|
924
|
+
// intact: pushLog (inside emitTail) is the single seq point, called when the
|
|
925
|
+
// upload resolves. Other kinds emit synchronously, unchanged.
|
|
926
|
+
const imgs = inlineImageBlocks(evt)
|
|
927
|
+
if (imgs.length) { deferImageEvent(entry, id, evt, imgs, emitTail); return }
|
|
928
|
+
emitTail(evt)
|
|
869
929
|
},
|
|
870
930
|
requestPermission: (req) => new Promise((resolve) => {
|
|
871
931
|
// Keep the broadcast payload with the resolver so a reconnect can re-send it
|
|
@@ -913,6 +973,7 @@ function endStructured(id) {
|
|
|
913
973
|
try { s.mockupWatcher?.close() } catch { /* noop */ }
|
|
914
974
|
sessions.delete(id)
|
|
915
975
|
bcast('term-exit', { id })
|
|
976
|
+
fireImageCleanup(id) // drop this term's screenshots from Storage (COGS)
|
|
916
977
|
}
|
|
917
978
|
deleteSession(room, id) // remove <id>.json so loadAll() won't bring it back
|
|
918
979
|
if (s) announce()
|
|
@@ -1041,7 +1102,11 @@ channel
|
|
|
1041
1102
|
// (the seq-divergence freeze). Replay the full recent tail and flag `reset` so the
|
|
1042
1103
|
// client rebuilds its cursor from our authoritative log instead of trusting its own.
|
|
1043
1104
|
const ahead = from > mx
|
|
1044
|
-
const
|
|
1105
|
+
const rawTail = (from > 0 && !ahead) ? s.log.filter((e) => e.seq == null || e.seq > from) : s.log
|
|
1106
|
+
// Bound each event so no single one (e.g. a 592KB inline-image tool_result) can
|
|
1107
|
+
// overflow a frame on its own — chunkReplayEvents only bounds the SUM, not a lone
|
|
1108
|
+
// giant event. boundEventForBroadcast clones+defangs only the oversized ones.
|
|
1109
|
+
const tail = rawTail.map((e) => boundEventForBroadcast(e))
|
|
1045
1110
|
// Chunk the tail into ORDERED under-budget frames (A): the full log on a fresh reload
|
|
1046
1111
|
// (empty cursor) is multi-MB and a single frame would be dropped by Realtime, freezing
|
|
1047
1112
|
// the terminal. chunkReplayEvents splits it so nothing in the recent window is lost;
|
package/event-id.mjs
CHANGED
|
@@ -124,3 +124,56 @@ 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
|
+
}
|
|
160
|
+
|
|
161
|
+
// Detect inline base64 IMAGE blocks on a tool_result event (a screenshot the agent
|
|
162
|
+
// produced — content:[{type:'image', source:{type:'base64', media_type, data}}]).
|
|
163
|
+
// Returns [{ idx, b64, mediaType }] per base64 image block, or [] when none. Pure +
|
|
164
|
+
// import-safe so bridge/test-seq.mjs can exercise it. The bridge uses this to lift
|
|
165
|
+
// images OUT of the event (upload to Storage, swap to a {type:'image', path} ref)
|
|
166
|
+
// BEFORE the base64 ever enters the persisted log or a broadcast frame — so a
|
|
167
|
+
// screenshot renders live AND in replay instead of being stripped to a placeholder
|
|
168
|
+
// by boundEventForBroadcast (which stays the fallback for any block we don't lift).
|
|
169
|
+
export function inlineImageBlocks(evt) {
|
|
170
|
+
if (!evt || evt.kind !== 'tool_result' || !Array.isArray(evt.content)) return []
|
|
171
|
+
const out = []
|
|
172
|
+
evt.content.forEach((b, idx) => {
|
|
173
|
+
if (b && b.type === 'image' && b.source && b.source.type === 'base64' &&
|
|
174
|
+
typeof b.source.data === 'string' && b.source.data.length) {
|
|
175
|
+
out.push({ idx, b64: b.source.data, mediaType: b.source.media_type || 'image/png' })
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
return out
|
|
179
|
+
}
|