thinkpool-pair 0.7.203 → 0.7.205

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 +
@@ -1759,7 +1759,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
1759
1759
  // restart. Without this, sessionData omitted it → on restart the resumed session
1760
1760
  // re-launched on the host default (Opus) regardless of the last switch, and the
1761
1761
  // switch looked like it "never changed the model" (Max 2026-07-02). Restored below.
1762
- const sessionData = () => ({ sessionId: entry.session?.sessionId || resume || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null })
1762
+ const sessionData = () => ({ sessionId: entry.session?.sessionId || null, runtime: entry.runtime, log: entry.log, commands: entry.commands, mode: entry.mode, effort: entry.effort, model: entry.model || null, provider: entry.provider || null, spawnedBy: entry.spawnedBy, flowSessionId: entry.flowSessionId, flowTaskKey: entry.flowTaskKey, cwd: entry.cwd, managedWorktree: entry.managedWorktree, rolePrompt: entry.rolePrompt, flowReviewTarget: entry.flowReviewTarget || null, revertTarget: entry.revertTarget || null, reviewSliceRoots: entry.reviewSliceRoots || [], openedAt: entry.openedAt || null, lastUsage: entry.lastUsage || null, carryRecap: entry.pendingRecap || null })
1763
1763
  const persist = () => saveSession(room, id, sessionData())
1764
1764
  // Synchronous flush of this session's record. Used on open (so a brand-new session
1765
1765
  // has a file under its id BEFORE its first event — surviving a restart inside the
@@ -2098,7 +2098,8 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2098
2098
  // Context-carry (2026-07-08): a non-resumable re-open (provider switch / non-resumable
2099
2099
  // restart) hands the FRESH session a plain-text recap of the visible log as its first
2100
2100
  // input, so the agent picks up the work instead of starting blank mid-conversation. Held
2101
- // here (transient — NOT in sessionData, a re-restart re-derives it from the log) and fired
2101
+ // here and persist it until consumed, so a restart between /compact and the next
2102
+ // human turn cannot strand a fresh Codex thread without its recap. It is fired
2102
2103
  // on the init `system` event (see onEvent) — a sendTurn before the input stream is consumed
2103
2104
  // is silently lost (the 2026-07-02 auto-resume bug). Empty string → nothing to carry.
2104
2105
  entry.pendingRecap = (typeof carryRecap === 'string' && carryRecap.trim()) ? carryRecap : null
@@ -2286,6 +2287,7 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2286
2287
  if (evt.kind === 'system' && entry.pendingRecap) {
2287
2288
  const recap = entry.pendingRecap
2288
2289
  entry.pendingRecap = null
2290
+ entry.flush?.()
2289
2291
  try { entry.session?.sendTurn(recap) } catch { /* not live — stays idle, user can continue manually */ }
2290
2292
  process.stderr.write(`\n ◆ carried ${recap.length}c context recap into fresh session (${id.slice(0, 8)}) after provider/restore switch.\n`)
2291
2293
  }
@@ -2733,8 +2735,18 @@ channel
2733
2735
  // (legacy/pre-C1 logs) ride along always; the client cid-dedups those and
2734
2736
  // additionally drops ≤ seqHi, so an over-send is harmless. A fresh joiner (no
2735
2737
  // cursor) still gets the full log.
2736
- for (const [id, s] of sessions) {
2737
- if (!s.log.length) continue
2738
+ // Hydrate the viewed lane first. Other terminals still warm in the background,
2739
+ // but cannot queue ahead of the transcript the person is waiting to see.
2740
+ const replaySessions = [...sessions.entries()].sort(([a], [b]) =>
2741
+ a === payload?.priority ? -1 : b === payload?.priority ? 1 : 0)
2742
+ for (const [id, s] of replaySessions) {
2743
+ // An explicit empty replay is an acknowledgement, not data. It lets mixed
2744
+ // clients clear their loading cover even if they missed the announce's
2745
+ // hasTranscript:false state.
2746
+ if (!s.log.length) {
2747
+ bcast('code-replay', { to: payload?.to ?? null, term: id, events: [], empty: true })
2748
+ continue
2749
+ }
2738
2750
  const from = Number(payload?.cursors?.[id]) || 0
2739
2751
  const mx = maxSeq(s.log)
2740
2752
  // Cursor-sanity (B-safety): if the client's cursor is AHEAD of our highest seq, the
@@ -2869,11 +2881,11 @@ channel
2869
2881
  pushLog(s, evt)
2870
2882
  bcast('code-event', { term: payload.term, evt })
2871
2883
  }
2872
- // Session controls (/model, /clear) are NOT agent turns — they change the
2884
+ // Session controls (/model, /clear, /usage, and Codex /compact) are NOT agent turns — they change the
2873
2885
  // shared session config. Emitting a `you` line for them strands the reader's
2874
2886
  // inferred "working…" state (no turn → no result to clear it; the old bug).
2875
- // Instead announce a ◆ control line both readers see. /compact + any other
2876
- // slash still flow through as a real, self-terminating turn.
2887
+ // Instead announce a ◆ control line both readers see. Claude /compact + any
2888
+ // other slash still flow through as a real, self-terminating turn.
2877
2889
  const ctlLine = (ctlText) => {
2878
2890
  const evt = { kind: 'control', text: ctlText, by: payload.by, cid: payload.cid }
2879
2891
  pushLog(s, evt)
@@ -2926,7 +2938,33 @@ channel
2926
2938
  // No persisted ctl line — the live indicator + the CompactionCard are the record.
2927
2939
  if (/^\/compact\b/.test(text)) {
2928
2940
  if (s.runtime === 'codex') {
2929
- ctlLine('Codex manages context compaction automatically')
2941
+ if (s.session.turnActive) {
2942
+ ctlLine('finish or stop the current Codex turn before compacting context')
2943
+ return
2944
+ }
2945
+ const recap = buildRecapFromLog(s.log, RECAP_CAP, { reason: 'compact' })
2946
+ if (!recap) {
2947
+ ctlLine('nothing to compact — context unchanged')
2948
+ return
2949
+ }
2950
+ if (s.session.clearContext?.() === false) {
2951
+ ctlLine('Codex context compaction unavailable right now')
2952
+ return
2953
+ }
2954
+ s.pendingRecap = recap
2955
+ const ce = { kind: 'compaction', trigger: 'manual', preTokens: s.lastUsage?.ctx?.used || null, by: payload.by }
2956
+ pushLog(s, ce)
2957
+ bcast('code-event', { term: payload.term, evt: ce })
2958
+ // The new native thread has only the bounded recap queued for the next
2959
+ // human turn. Reset the visible meter to that conservative text estimate.
2960
+ if (s.lastUsage?.ctx?.max) {
2961
+ const used = Math.ceil(recap.length / 4)
2962
+ const max = s.lastUsage.ctx.max
2963
+ const usage = { kind: 'usage', ctx: { ...s.lastUsage.ctx, used, max, pct: Math.min(100, Math.round((used / max) * 100)), over: used > max } }
2964
+ s.lastUsage = usage
2965
+ bcast('code-event', { term: payload.term, evt: usage })
2966
+ }
2967
+ s.flush?.()
2930
2968
  return
2931
2969
  }
2932
2970
  s.compacting = true
@@ -2946,7 +2984,7 @@ channel
2946
2984
  // it degrades to "plan meters unavailable" rather than printing an inferred reset.
2947
2985
  if (/^\/usage\s*$/.test(text)) {
2948
2986
  ctlLine(usageReportLine(s.model, s.log))
2949
- planMeterLine().then((l) => { if (l) ctlLine(l) }).catch(() => { /* meters are never load-bearing */ })
2987
+ if (s.runtime !== 'codex') planMeterLine().then((l) => { if (l) ctlLine(l) }).catch(() => { /* meters are never load-bearing */ })
2950
2988
  return
2951
2989
  }
2952
2990
  // Context-carry (2026-07-08) point 4: a real human turn arrived BEFORE the post-switch/
@@ -2954,7 +2992,7 @@ channel
2954
2992
  // — a separate recap turn would race + double-fire) and clear the flag so the init handler
2955
2993
  // no-ops. The visible `you` echo is unchanged; the recap is context for the agent only.
2956
2994
  let sendText = text
2957
- if (s.pendingRecap) { sendText = `${s.pendingRecap}\n\n--- the person's next message ---\n${text}`; s.pendingRecap = null }
2995
+ if (s.pendingRecap) { sendText = `${s.pendingRecap}\n\n--- the person's next message ---\n${text}`; s.pendingRecap = null; s.flush?.() }
2958
2996
  s.session.sendTurn(sendText)
2959
2997
  echoYou()
2960
2998
  // Pool-order fix (2026-07-02): emit the "dispatched to agent" marker HERE, right after
@@ -3183,7 +3221,7 @@ channel
3183
3221
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
3184
3222
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
3185
3223
  // lanes keep their worktree cwd + the ability to mark done.
3186
- openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, effort: rec.effort, provider: rec.runtime === 'codex' ? undefined : rec.provider || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage,
3224
+ openStructured({ id: rec.id, runtime: rec.runtime || 'claude', model: rec.model || undefined, effort: rec.effort, provider: rec.runtime === 'codex' ? undefined : rec.provider || undefined, resume: canResume(rec) ? rec.sessionId : undefined, log: rec.log, commands: rec.commands, mode: rec.mode, spawnedBy: rec.spawnedBy, flowSessionId: rec.flowSessionId, flowTaskKey: rec.flowTaskKey, cwd: rec.cwd, managedWorktree: rec.managedWorktree, rolePrompt: rec.rolePrompt, reviewSliceRoots: rec.reviewSliceRoots, openedAt: rec.openedAt, lastUsage: rec.lastUsage, carryRecap: rec.carryRecap,
3187
3225
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
3188
3226
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
3189
3227
  // (mid-turn needs auto-resume; flow needs its lane live).
@@ -42,7 +42,7 @@ export class CodexEventMapper {
42
42
  /**
43
43
  * @param {{onEvent:(evt)=>void, model?:string|null, sessionId?:string|null}} opts
44
44
  */
45
- constructor({ onEvent, model = null, sessionId = null, fsImpl = fs }) {
45
+ constructor({ onEvent, model = null, sessionId = null, fsImpl = fs, contextWindowForModel = () => null }) {
46
46
  this._onEvent = onEvent
47
47
  this._model = model || null
48
48
  this._sessionId = sessionId || null
@@ -50,6 +50,7 @@ export class CodexEventMapper {
50
50
  this._fileBefore = new Map() // item.id → [{ path, kind, content }]
51
51
  this._fs = fsImpl
52
52
  this._systemSent = false
53
+ this._contextWindowForModel = contextWindowForModel
53
54
  }
54
55
 
55
56
  /** Emit a room-event; never let a consumer throw into the stream. */
@@ -143,7 +144,15 @@ export class CodexEventMapper {
143
144
  const u = ev.usage || {}
144
145
  // Map Codex usage → Claude-shape usage (what event-id.mjs folds into cost).
145
146
  // reasoning_output_tokens are billed as output → fold in (conservative).
146
- const out = (u.output_tokens || 0) + (u.reasoning_output_tokens || 0)
147
+ const out = (Number(u.output_tokens) || 0) + (Number(u.reasoning_output_tokens) || 0)
148
+ const totalInput = Number(u.input_tokens) || 0
149
+ const cachedInput = Math.min(totalInput, Number(u.cached_input_tokens) || 0)
150
+ let max = null
151
+ try { max = Number(this._contextWindowForModel(this._model)) } catch { /* unknown catalog → omit meter */ }
152
+ if (Number.isFinite(max) && max > 0) {
153
+ const used = totalInput + out
154
+ this._emit({ kind: 'usage', ctx: { used, max, pct: Math.min(100, Math.max(0, Math.round((used / max) * 100))), over: used > max, model: this._model } })
155
+ }
147
156
  this._emit({
148
157
  kind: 'result',
149
158
  subtype: 'success',
@@ -151,8 +160,11 @@ export class CodexEventMapper {
151
160
  model: this._model,
152
161
  costUsd: null,
153
162
  usage: {
154
- input_tokens: u.input_tokens || 0,
155
- cache_read_input_tokens: u.cached_input_tokens || 0,
163
+ // Claude-shape fields are mutually exclusive buckets. Codex reports
164
+ // cached_input_tokens as a subset of input_tokens, so subtract it here;
165
+ // usageReportLine adds the buckets and must not double-count cache hits.
166
+ input_tokens: totalInput - cachedInput,
167
+ cache_read_input_tokens: cachedInput,
156
168
  cache_creation_input_tokens: 0,
157
169
  output_tokens: out,
158
170
  },
package/codex-session.mjs CHANGED
@@ -65,6 +65,19 @@ export function readCodexModels({ home = process.env.CODEX_HOME || path.join(os.
65
65
  } catch { return [] }
66
66
  }
67
67
 
68
+ // The CLI's current model catalog is the authority for the runtime's usable model
69
+ // window. Keep this separate from readCodexModels so the public announce shape stays
70
+ // stable while the driver can emit a truthful context meter for every Codex model.
71
+ export function readCodexContextWindow(model, { home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), fsImpl = fs } = {}) {
72
+ if (!model) return null
73
+ try {
74
+ const data = JSON.parse(fsImpl.readFileSync(path.join(home, 'models_cache.json'), 'utf8'))
75
+ const hit = (Array.isArray(data?.models) ? data.models : []).find((m) => m?.slug === model)
76
+ const max = Number(hit?.context_window)
77
+ return Number.isFinite(max) && max > 0 ? Math.floor(max) : null
78
+ } catch { return null }
79
+ }
80
+
68
81
  export function normalizeCodexSandbox(value) {
69
82
  return SAFE_SANDBOXES.has(value) ? value : DEFAULT_SANDBOX
70
83
  }
@@ -150,7 +163,11 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
150
163
  let chain = Promise.resolve()
151
164
  const queue = []
152
165
 
153
- const mapper = new CodexEventMapper({ onEvent, model: model || null })
166
+ const mapper = new CodexEventMapper({
167
+ onEvent,
168
+ model: model || null,
169
+ contextWindowForModel: (id) => readCodexContextWindow(id),
170
+ })
154
171
 
155
172
  const note = (text) => { try { onEvent?.({ kind: 'note', text }) } catch { /* noop */ } }
156
173
 
@@ -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.203",
3
+ "version": "0.7.205",
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",
package/recap.mjs CHANGED
@@ -42,6 +42,10 @@ const FRAMING = {
42
42
  header: '[CONTEXT CARRY — this lane kept talking while your machine was asleep]',
43
43
  body: "Your bridge was offline. The lane stayed open and the people in this room kept planning here with Pool — a chat-only assistant with no file access and no tools. Everything below already happened and they have all seen it. NOTHING WAS RUN ON YOUR BEHALF: no commands were executed, no files were changed, and no plan below has been carried out. Do NOT re-greet and do NOT re-answer what is already answered. Read the recap, then wait for the people to tell you what to do with it — the plan below is a plan, not an instruction you have already been given.",
44
44
  },
45
+ compact: {
46
+ header: '[CONTEXT COMPACTED — continue this Codex lane from its bounded recap]',
47
+ body: "The person intentionally compacted this lane. Your prior native thread was closed and the visible conversation below is its bounded continuation context. Do NOT re-greet, do NOT re-answer completed work, and do NOT repeat tool calls that already succeeded. Continue from the person's next message using this recap as memory.",
48
+ },
45
49
  }
46
50
 
47
51
  /* Context-carry recap for a lane whose SDK memory CAN'T be resumed — a cross-provider