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.
Files changed (3) hide show
  1. package/bridge.mjs +90 -25
  2. package/event-id.mjs +53 -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, 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
- 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
  }
@@ -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
- if (!chrome) pushLog(entry, evt)
866
- bcast('code-event', { term: id, evt })
867
- printLocal(evt)
868
- if (!chrome) persist()
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 tail = (from > 0 && !ahead) ? s.log.filter((e) => e.seq == null || e.seq > from) : s.log
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.70",
3
+ "version": "0.7.72",
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": {