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 +50 -12
- package/codex-event-mapper.mjs +16 -4
- package/codex-session.mjs +18 -1
- package/event-bounds.mjs +96 -0
- package/event-id.mjs +3 -22
- package/package.json +2 -1
- package/recap.mjs +4 -0
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 ||
|
|
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
|
|
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
|
-
|
|
2737
|
-
|
|
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
|
|
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
|
-
|
|
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).
|
package/codex-event-mapper.mjs
CHANGED
|
@@ -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
|
-
|
|
155
|
-
|
|
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({
|
|
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
|
|
package/event-bounds.mjs
ADDED
|
@@ -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 =
|
|
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
|
-
|
|
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.
|
|
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
|