thinkpool-pair 0.7.202 → 0.7.204

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 CHANGED
@@ -1044,7 +1044,7 @@ const announce = () => {
1044
1044
  // turnActive). Authoritative — the bridge owns the turn — so the /code
1045
1045
  // dashboard reads "N agents working" off the roster instead of sniffing
1046
1046
  // the live code-event stream (which a passive dashboard channel misses).
1047
- ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, busy: s.session?.turnActive ?? false, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [] } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1047
+ ...[...sessions.entries()].map(([id, s]) => ({ id, cmd: s.cmd, kind: 'structured', runtime: s.runtime || 'claude', alive: true, busy: s.session?.turnActive ?? false, hasTranscript: s.log.length > 0, commands: s.commands, mode: s.mode || undefined, effort: s.effort || undefined, name: termNames[id] || undefined, model: s.model || undefined, ...(s.runtime === 'codex' ? { approvalPolicy: codexConfigForMode(s.mode).approvalPolicy, models: s.models || [] } : {}), ...(s.archiveOldestSeq != null ? { oldestSeq: s.archiveOldestSeq } : {}), ...(s.spawnedBy ? { spawned: true, spawnedBy: s.spawnedBy } : {}), ...(s.flowSessionId ? { flowId: s.flowSessionId, flowRole: s.flowTaskKey ? 'lane' : 'conductor' } : {}),
1048
1048
  // provider: the registered LLM-provider this lane runs on, NAME-ONLY {id,name}
1049
1049
  // (NEVER the key or baseUrl). Additive; older clients ignore it. Omitted for the
1050
1050
  // built-in/default Claude path (no badge). Makes the lane's provider badge +
@@ -2239,7 +2239,10 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2239
2239
  { const onBuiltin = !entry.provider || entry.provider === 'anthropic'
2240
2240
  const m = onBuiltin ? (evt.model || evt.ctx?.model) : null
2241
2241
  if (m && m !== entry.model) { entry.model = m; announce() }
2242
- if (evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { roomModels = evt.models; announce() } }
2242
+ // A custom Anthropic-compatible endpoint may expose its own catalog through the
2243
+ // same SDK method. Never publish that as the ROOM's Claude catalog: its models
2244
+ // already render in the provider group and otherwise leak under ANTHROPIC.
2245
+ if (onBuiltin && evt.kind === 'models' && Array.isArray(evt.models) && evt.models.length) { roomModels = evt.models; announce() } }
2243
2246
  // Cache the last usage/ctx meter on the entry (persisted + replayed — see
2244
2247
  // openStructured). Chrome events bypass pushLog/persist in emitTail below, so
2245
2248
  // usage needs an explicit persist() here to survive a bridge restart.
@@ -2730,8 +2733,18 @@ channel
2730
2733
  // (legacy/pre-C1 logs) ride along always; the client cid-dedups those and
2731
2734
  // additionally drops ≤ seqHi, so an over-send is harmless. A fresh joiner (no
2732
2735
  // cursor) still gets the full log.
2733
- for (const [id, s] of sessions) {
2734
- if (!s.log.length) continue
2736
+ // Hydrate the viewed lane first. Other terminals still warm in the background,
2737
+ // but cannot queue ahead of the transcript the person is waiting to see.
2738
+ const replaySessions = [...sessions.entries()].sort(([a], [b]) =>
2739
+ a === payload?.priority ? -1 : b === payload?.priority ? 1 : 0)
2740
+ for (const [id, s] of replaySessions) {
2741
+ // An explicit empty replay is an acknowledgement, not data. It lets mixed
2742
+ // clients clear their loading cover even if they missed the announce's
2743
+ // hasTranscript:false state.
2744
+ if (!s.log.length) {
2745
+ bcast('code-replay', { to: payload?.to ?? null, term: id, events: [], empty: true })
2746
+ continue
2747
+ }
2735
2748
  const from = Number(payload?.cursors?.[id]) || 0
2736
2749
  const mx = maxSeq(s.log)
2737
2750
  // Cursor-sanity (B-safety): if the client's cursor is AHEAD of our highest seq, the
@@ -0,0 +1,96 @@
1
+ /* Browser-safe structured-event bounding shared by the bridge broadcast path and
2
+ the web client's persisted reload snapshot. Large Codex/Claude tool payloads
3
+ must remain recognisable (tool name + a useful preview), but must never turn a
4
+ replay frame or tx: seed row into a multi-megabyte hydration dependency. */
5
+
6
+ export const STRUCTURED_EVENT_CAP = 80000
7
+
8
+ const sizeOf = (value) => {
9
+ try { return JSON.stringify(value).length } catch { return Infinity }
10
+ }
11
+
12
+ const clipString = (value, limit) => {
13
+ if (value.length <= limit) return value
14
+ return `${value.slice(0, limit)}\n…[truncated ${value.length - limit} bytes]`
15
+ }
16
+
17
+ function compactValue(value, budget, depth = 0) {
18
+ if (sizeOf(value) <= budget) return value
19
+ if (typeof value === 'string') return clipString(value, Math.max(80, budget - 80))
20
+ if (value == null || typeof value !== 'object') return value
21
+ if (depth >= 4) return '[nested value omitted — too large]'
22
+
23
+ if (Array.isArray(value)) {
24
+ const out = []
25
+ const limit = Math.min(value.length, 8)
26
+ const per = Math.max(240, Math.floor((budget - 180) / Math.max(1, limit)))
27
+ for (let i = 0; i < limit; i++) out.push(compactValue(value[i], per, depth + 1))
28
+ if (value.length > limit) out.push(`…[${value.length - limit} more items omitted]`)
29
+ return out
30
+ }
31
+
32
+ const entries = Object.entries(value)
33
+ const out = {}
34
+ const limit = Math.min(entries.length, 16)
35
+ const per = Math.max(240, Math.floor((budget - 180) / Math.max(1, limit)))
36
+ for (let i = 0; i < limit; i++) {
37
+ const [key, nested] = entries[i]
38
+ out[key] = compactValue(nested, per, depth + 1)
39
+ }
40
+ if (entries.length > limit) out._omitted = `${entries.length - limit} more fields`
41
+ return out
42
+ }
43
+
44
+ function compactBlock(block, budget) {
45
+ if (!block || typeof block !== 'object') return compactValue(block, budget)
46
+ if (sizeOf(block) <= budget) return block
47
+ if (block.type === 'image') {
48
+ return { type: 'text', text: '[image omitted from replay — open the live session to view]' }
49
+ }
50
+ const out = {}
51
+ for (const key of ['type', 'id', 'name', 'tool_use_id']) if (block[key] != null) out[key] = block[key]
52
+ // A replay needs the tool identity and a useful preview, not a second full copy
53
+ // of a giant patch/result that already lives in the durable archive.
54
+ const payloadBudget = Math.min(block.type === 'tool_use' ? 12000 : 20000, Math.max(500, budget - 600))
55
+ if (typeof block.text === 'string') out.text = clipString(block.text, payloadBudget)
56
+ if (block.input != null) out.input = compactValue(block.input, payloadBudget, 1)
57
+ if (block.content != null) out.content = compactValue(block.content, payloadBudget, 1)
58
+ if (!('text' in out) && !('input' in out) && !('content' in out)) out.text = `[${block.type || 'content'} omitted — too large]`
59
+ return out
60
+ }
61
+
62
+ export function boundStructuredEvent(event, cap = STRUCTURED_EVENT_CAP) {
63
+ if (!event || typeof event !== 'object' || sizeOf(event) <= cap) return event
64
+
65
+ const out = { ...event, _bounded: true }
66
+ const stringBudget = Math.min(12000, Math.max(1000, Math.floor(cap / 4)))
67
+ for (const key of ['text', 'output', 'stdout', 'stderr', 'input']) {
68
+ if (typeof out[key] === 'string') out[key] = clipString(out[key], stringBudget)
69
+ }
70
+ if (typeof out.content === 'string') out.content = clipString(out.content, stringBudget)
71
+ else if (Array.isArray(out.content)) {
72
+ const per = Math.max(900, Math.floor((cap - 2000) / Math.max(1, Math.min(out.content.length, 8))))
73
+ out.content = out.content.slice(0, 8).map(block => compactBlock(block, per))
74
+ if (event.content.length > 8) out.content.push({ type: 'text', text: `…[${event.content.length - 8} more blocks omitted]` })
75
+ }
76
+ if (Array.isArray(out.blocks)) {
77
+ const per = Math.max(900, Math.floor((cap - 2000) / Math.max(1, Math.min(out.blocks.length, 8))))
78
+ out.blocks = out.blocks.slice(0, 8).map(block => compactBlock(block, per))
79
+ if (event.blocks.length > 8) out.blocks.push({ type: 'text', text: `…[${event.blocks.length - 8} more blocks omitted]` })
80
+ }
81
+
82
+ if (sizeOf(out) <= cap) return out
83
+
84
+ // Hard fallback: retain ordering/dedup identity and recognisable tool blocks.
85
+ const identity = {}
86
+ for (const key of ['kind', 'seq', 'cid', 'ts', 'term', 'toolUseId', 'isError']) {
87
+ if (out[key] != null) identity[key] = out[key]
88
+ }
89
+ if (Array.isArray(out.blocks)) identity.blocks = out.blocks.slice(0, 4).map(block => compactBlock(block, 6000))
90
+ else if (Array.isArray(out.content)) identity.content = out.content.slice(0, 4).map(block => compactBlock(block, 6000))
91
+ else if (typeof out.content === 'string') identity.content = clipString(out.content, 12000)
92
+ else if (typeof out.text === 'string') identity.text = clipString(out.text, 12000)
93
+ identity._bounded = true
94
+ identity._notice = 'Large tool payload truncated for fast transcript hydration'
95
+ return identity
96
+ }
package/event-id.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto'
2
+ import { boundStructuredEvent, STRUCTURED_EVENT_CAP } from './event-bounds.mjs'
2
3
 
3
4
  /* event-id.mjs — stamp a structured event with a stable identity + timestamp
4
5
  BEFORE it is logged + broadcast.
@@ -180,29 +181,9 @@ export function firstSeq(events) {
180
181
  // so dedup, ordering, seqHi, and turn structure stay intact. Returns the event UNCHANGED
181
182
  // (no clone) when it's already small; clones before mutating so the persisted log is never
182
183
  // touched. (Proper fix for inline images = upload to storage + broadcast a URL — separate.)
183
- export const PER_EVENT_CAP = 180000
184
- const truncStr = (s) => s.slice(0, 20000) + `\n…[truncated ${s.length - 20000} bytes — too large to broadcast]`
184
+ export const PER_EVENT_CAP = STRUCTURED_EVENT_CAP
185
185
  export function boundEventForBroadcast(evt, cap = PER_EVENT_CAP) {
186
- if (!evt || typeof evt !== 'object') return evt
187
- let json
188
- try { json = JSON.stringify(evt) } catch { return evt }
189
- if (json.length <= cap) return evt
190
- const out = { ...evt }
191
- for (const k of ['text', 'output', 'stdout', 'stderr', 'input']) {
192
- if (typeof out[k] === 'string' && out[k].length > 20000) out[k] = truncStr(out[k])
193
- }
194
- if (typeof out.content === 'string' && out.content.length > 20000) out.content = truncStr(out.content)
195
- else if (Array.isArray(out.content)) {
196
- out.content = out.content.map((b) => {
197
- if (!b || typeof b !== 'object') return b
198
- if (JSON.stringify(b).length <= 20000) return b
199
- if (b.type === 'image') return { type: 'text', text: '[image omitted from replay — too large to broadcast; open the live session to view]' }
200
- if (typeof b.text === 'string') return { ...b, text: truncStr(b.text) }
201
- return { type: 'text', text: `[${b.type || 'content'} omitted — too large to broadcast]` }
202
- })
203
- }
204
- out._bounded = true
205
- return out
186
+ return boundStructuredEvent(evt, cap)
206
187
  }
207
188
 
208
189
  // Detect inline base64 IMAGE blocks on a tool_result event (a screenshot the agent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.202",
3
+ "version": "0.7.204",
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": {
@@ -22,6 +22,7 @@
22
22
  "agent-notify.mjs",
23
23
  "agent-detect.mjs",
24
24
  "event-id.mjs",
25
+ "event-bounds.mjs",
25
26
  "plan-meters.mjs",
26
27
  "recap.mjs",
27
28
  "transcript-sanitize.mjs",