thinkpool-pair 0.7.205 → 0.7.206
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 +22 -6
- package/codex-event-mapper.mjs +20 -13
- package/codex-session.mjs +73 -10
- package/event-id.mjs +18 -0
- package/package.json +1 -1
package/bridge.mjs
CHANGED
|
@@ -101,7 +101,7 @@ import { formatPeek, PEEK, siblingsOf, resolveSibling, crossPostDecision, CROSSP
|
|
|
101
101
|
import { armInterruptedResume, sendInterruptedContinue, supersedeInterruptedResume } from './interrupted-resume.mjs'
|
|
102
102
|
import { turnInFlight } from './update-gate.mjs'
|
|
103
103
|
import { saveSession, flushSession, deleteSession, loadAll, canResume, loadPtyId, savePtyId, loadNames, saveNames, appendDurableEvents, seedDurableEvents, readDurablePage, readDurableOldestSeq, hasDurableArchive, servesHistoryPage } from './session-store.mjs'
|
|
104
|
-
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
104
|
+
import { stampEvent, makeSeqCounter, maxSeq, seqable, capReplayEvents, chunkReplayEvents, boundEventForBroadcast, inlineImageBlocks, usageReportLine, codexUsageReportLine, buildRecapFromLog, RECAP_CAP, trimmedBeforeSeq, firstSeq } from './event-id.mjs'
|
|
105
105
|
import { planMeterLine } from './plan-meters.mjs'
|
|
106
106
|
import { makeThrottledTrack } from './presence.mjs'
|
|
107
107
|
import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
|
|
@@ -2425,6 +2425,20 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
|
|
|
2425
2425
|
: `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
|
|
2426
2426
|
}),
|
|
2427
2427
|
})
|
|
2428
|
+
// A restored Codex lane may have a stale pre-0.7.206 meter persisted from the
|
|
2429
|
+
// cumulative `turn.completed` bug. Replace it synchronously from the native
|
|
2430
|
+
// rollout before announce/replay, so a reload is truthful even before the next
|
|
2431
|
+
// human turn completes.
|
|
2432
|
+
if (runtime === 'codex' && entry.session?.usageSnapshot?.context) {
|
|
2433
|
+
const { used, max } = entry.session.usageSnapshot.context
|
|
2434
|
+
entry.lastUsage = { kind: 'usage', ctx: {
|
|
2435
|
+
used,
|
|
2436
|
+
max,
|
|
2437
|
+
pct: Math.min(100, Math.max(0, Math.round((used / max) * 100))),
|
|
2438
|
+
over: used > max,
|
|
2439
|
+
model: entry.model,
|
|
2440
|
+
} }
|
|
2441
|
+
}
|
|
2428
2442
|
// Brand-new session (no restored log): write its record now so a restart that lands
|
|
2429
2443
|
// before the first debounced save still restores this id instead of dropping it (the
|
|
2430
2444
|
// room would otherwise show a fresh empty terminal + strand the transcript).
|
|
@@ -2973,17 +2987,19 @@ channel
|
|
|
2973
2987
|
s.session.sendTurn(text)
|
|
2974
2988
|
return
|
|
2975
2989
|
}
|
|
2976
|
-
// /usage → report
|
|
2977
|
-
//
|
|
2978
|
-
//
|
|
2979
|
-
//
|
|
2990
|
+
// /usage → report token totals WITHOUT burning an agent turn. Claude folds
|
|
2991
|
+
// retained per-turn result events; Codex reads its native rollout snapshot
|
|
2992
|
+
// because the CLI's public completion counters are lifetime thread totals.
|
|
2993
|
+
// Reported as ◆
|
|
2980
2994
|
// control lines (like /model) so both members AND late-joiners see it. Two lines: the
|
|
2981
2995
|
// token/cost report (instant, from the retained log), then the plan meters (5h + weekly
|
|
2982
2996
|
// utilization + server-authoritative resets) once the OAuth usage endpoint answers. The
|
|
2983
2997
|
// meter line is fired-and-forgotten so a slow/unreachable endpoint never stalls /usage;
|
|
2984
2998
|
// it degrades to "plan meters unavailable" rather than printing an inferred reset.
|
|
2985
2999
|
if (/^\/usage\s*$/.test(text)) {
|
|
2986
|
-
ctlLine(
|
|
3000
|
+
ctlLine(s.runtime === 'codex'
|
|
3001
|
+
? codexUsageReportLine(s.model, s.session?.usageSnapshot)
|
|
3002
|
+
: usageReportLine(s.model, s.log))
|
|
2987
3003
|
if (s.runtime !== 'codex') planMeterLine().then((l) => { if (l) ctlLine(l) }).catch(() => { /* meters are never load-bearing */ })
|
|
2988
3004
|
return
|
|
2989
3005
|
}
|
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, usageSnapshotForSession = () => null }) {
|
|
46
46
|
this._onEvent = onEvent
|
|
47
47
|
this._model = model || null
|
|
48
48
|
this._sessionId = sessionId || null
|
|
@@ -50,7 +50,8 @@ 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.
|
|
53
|
+
this._usageSnapshotForSession = usageSnapshotForSession
|
|
54
|
+
this._usageBaseline = null
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/** Emit a room-event; never let a consumer throw into the stream. */
|
|
@@ -58,6 +59,8 @@ export class CodexEventMapper {
|
|
|
58
59
|
|
|
59
60
|
setModel(model) { this._model = model || null }
|
|
60
61
|
|
|
62
|
+
setUsageBaseline(snapshot) { this._usageBaseline = snapshot || null }
|
|
63
|
+
|
|
61
64
|
/** Process one parsed Codex JSONL event. */
|
|
62
65
|
push(ev) {
|
|
63
66
|
if (!ev || typeof ev !== 'object') return
|
|
@@ -142,17 +145,22 @@ export class CodexEventMapper {
|
|
|
142
145
|
|
|
143
146
|
case 'turn.completed': {
|
|
144
147
|
const u = ev.usage || {}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
const
|
|
149
|
-
|
|
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
|
|
148
|
+
let snapshot = null
|
|
149
|
+
try { snapshot = this._usageSnapshotForSession(this._sessionId) } catch { /* unreadable rollout → omit meter */ }
|
|
150
|
+
const used = Number(snapshot?.context?.used)
|
|
151
|
+
const max = Number(snapshot?.context?.max)
|
|
152
|
+
if (Number.isFinite(used) && used >= 0 && Number.isFinite(max) && max > 0) {
|
|
154
153
|
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
154
|
}
|
|
155
|
+
// Codex's turn.completed values are lifetime thread totals, not turn
|
|
156
|
+
// deltas. Prefer the native rollout totals and subtract the pre-turn
|
|
157
|
+
// baseline. A fresh thread has no baseline, so its totals equal turn 1.
|
|
158
|
+
const totals = snapshot?.total || u
|
|
159
|
+
const before = this._usageBaseline?.total || {}
|
|
160
|
+
const totalInput = Math.max(0, (Number(totals.input_tokens) || 0) - (Number(before.input_tokens) || 0))
|
|
161
|
+
const cachedInput = Math.min(totalInput, Math.max(0, (Number(totals.cached_input_tokens) || 0) - (Number(before.cached_input_tokens) || 0)))
|
|
162
|
+
const out = Math.max(0, (Number(totals.output_tokens) || 0) - (Number(before.output_tokens) || 0))
|
|
163
|
+
this._usageBaseline = snapshot || null
|
|
156
164
|
this._emit({
|
|
157
165
|
kind: 'result',
|
|
158
166
|
subtype: 'success',
|
|
@@ -161,8 +169,7 @@ export class CodexEventMapper {
|
|
|
161
169
|
costUsd: null,
|
|
162
170
|
usage: {
|
|
163
171
|
// Claude-shape fields are mutually exclusive buckets. Codex reports
|
|
164
|
-
// cached_input_tokens as a subset of input_tokens, so subtract it
|
|
165
|
-
// usageReportLine adds the buckets and must not double-count cache hits.
|
|
172
|
+
// cached_input_tokens as a subset of input_tokens, so subtract it.
|
|
166
173
|
input_tokens: totalInput - cachedInput,
|
|
167
174
|
cache_read_input_tokens: cachedInput,
|
|
168
175
|
cache_creation_input_tokens: 0,
|
package/codex-session.mjs
CHANGED
|
@@ -65,17 +65,68 @@ export function readCodexModels({ home = process.env.CODEX_HOME || path.join(os.
|
|
|
65
65
|
} catch { return [] }
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
const rolloutPathCache = new Map()
|
|
69
|
+
|
|
70
|
+
function codexRolloutPath(sessionId, { home, fsImpl }) {
|
|
71
|
+
if (!sessionId) return null
|
|
72
|
+
const cacheKey = `${home}\0${sessionId}`
|
|
73
|
+
const cached = rolloutPathCache.get(cacheKey)
|
|
74
|
+
if (cached) return cached
|
|
75
|
+
const root = path.join(home, 'sessions')
|
|
73
76
|
try {
|
|
74
|
-
const
|
|
75
|
-
const hit =
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
const suffix = `${sessionId}.jsonl`
|
|
78
|
+
const hit = fsImpl.readdirSync(root, { recursive: true, withFileTypes: true })
|
|
79
|
+
.find((entry) => entry.isFile() && entry.name.endsWith(suffix))
|
|
80
|
+
if (!hit) return null
|
|
81
|
+
const found = path.join(hit.parentPath || hit.path || root, hit.name)
|
|
82
|
+
rolloutPathCache.set(cacheKey, found)
|
|
83
|
+
return found
|
|
84
|
+
} catch { return null }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// `codex exec --json` exposes lifetime thread totals on `turn.completed`. Those
|
|
88
|
+
// totals are useful for billing, but they are NOT context occupancy: an agentic
|
|
89
|
+
// turn can make hundreds of cached model calls and process many context windows.
|
|
90
|
+
// The native rollout's final token_count record carries both the exact last
|
|
91
|
+
// request and Codex's usable window, so read only a bounded tail of that file.
|
|
92
|
+
export function readCodexThreadUsage(sessionId, {
|
|
93
|
+
home = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'),
|
|
94
|
+
fsImpl = fs,
|
|
95
|
+
tailBytes = 4 * 1024 * 1024,
|
|
96
|
+
} = {}) {
|
|
97
|
+
const file = codexRolloutPath(sessionId, { home, fsImpl })
|
|
98
|
+
if (!file) return null
|
|
99
|
+
let fd
|
|
100
|
+
try {
|
|
101
|
+
const size = Number(fsImpl.statSync(file).size) || 0
|
|
102
|
+
const length = Math.min(size, Math.max(64 * 1024, tailBytes))
|
|
103
|
+
const buf = Buffer.alloc(length)
|
|
104
|
+
fd = fsImpl.openSync(file, 'r')
|
|
105
|
+
fsImpl.readSync(fd, buf, 0, length, size - length)
|
|
106
|
+
const lines = buf.toString('utf8').split('\n')
|
|
107
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
108
|
+
let row
|
|
109
|
+
try { row = JSON.parse(lines[i]) } catch { continue }
|
|
110
|
+
if (row?.payload?.type !== 'token_count' || !row.payload.info) continue
|
|
111
|
+
const info = row.payload.info
|
|
112
|
+
const last = info.last_token_usage || {}
|
|
113
|
+
const total = info.total_token_usage || {}
|
|
114
|
+
const used = Number(last.total_tokens)
|
|
115
|
+
const max = Number(info.model_context_window)
|
|
116
|
+
if (!Number.isFinite(used) || used < 0 || !Number.isFinite(max) || max <= 0) return null
|
|
117
|
+
return {
|
|
118
|
+
context: { used: Math.floor(used), max: Math.floor(max) },
|
|
119
|
+
total: {
|
|
120
|
+
input_tokens: Math.max(0, Number(total.input_tokens) || 0),
|
|
121
|
+
cached_input_tokens: Math.max(0, Number(total.cached_input_tokens) || 0),
|
|
122
|
+
output_tokens: Math.max(0, Number(total.output_tokens) || 0),
|
|
123
|
+
},
|
|
124
|
+
rateLimits: row.payload.rate_limits || null,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
78
127
|
} catch { return null }
|
|
128
|
+
finally { if (fd != null) { try { fsImpl.closeSync(fd) } catch { /* noop */ } } }
|
|
129
|
+
return null
|
|
79
130
|
}
|
|
80
131
|
|
|
81
132
|
export function normalizeCodexSandbox(value) {
|
|
@@ -158,6 +209,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
158
209
|
let turnActive = false
|
|
159
210
|
let activeModel = model || null
|
|
160
211
|
let activeEffort = EFFORT_LEVELS.has(initialEffort) ? initialEffort : 'high'
|
|
212
|
+
let latestUsageSnapshot = sessionId ? readCodexThreadUsage(sessionId) : null
|
|
161
213
|
let mcpHttpPromise = null
|
|
162
214
|
// serialize turns: one exec at a time
|
|
163
215
|
let chain = Promise.resolve()
|
|
@@ -166,7 +218,11 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
166
218
|
const mapper = new CodexEventMapper({
|
|
167
219
|
onEvent,
|
|
168
220
|
model: model || null,
|
|
169
|
-
|
|
221
|
+
usageSnapshotForSession: (id) => {
|
|
222
|
+
const snapshot = readCodexThreadUsage(id)
|
|
223
|
+
if (snapshot) latestUsageSnapshot = snapshot
|
|
224
|
+
return snapshot
|
|
225
|
+
},
|
|
170
226
|
})
|
|
171
227
|
|
|
172
228
|
const note = (text) => { try { onEvent?.({ kind: 'note', text }) } catch { /* noop */ } }
|
|
@@ -197,6 +253,10 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
197
253
|
prompt,
|
|
198
254
|
})
|
|
199
255
|
|
|
256
|
+
// Snapshot lifetime totals before the turn. The mapper subtracts this from
|
|
257
|
+
// the post-turn totals so result/accounting events contain this turn only.
|
|
258
|
+
mapper.setUsageBaseline(sessionId ? readCodexThreadUsage(sessionId) : null)
|
|
259
|
+
|
|
200
260
|
return new Promise((resolve) => {
|
|
201
261
|
// stdin: /dev/null (spike gotcha — codex blocks on stdin otherwise)
|
|
202
262
|
let stdinFd
|
|
@@ -267,6 +327,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
267
327
|
|
|
268
328
|
return {
|
|
269
329
|
get sessionId() { return sessionId },
|
|
330
|
+
get usageSnapshot() { return latestUsageSnapshot },
|
|
270
331
|
get turnActive() { return turnActive },
|
|
271
332
|
get started() { return turnNo > 0 || turnActive },
|
|
272
333
|
sendTurn(text) {
|
|
@@ -305,6 +366,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
305
366
|
// next turn so the selected mode is real, while ThinkPool keeps the lane log.
|
|
306
367
|
sessionId = null
|
|
307
368
|
turnNo = 0
|
|
369
|
+
latestUsageSnapshot = null
|
|
308
370
|
return true
|
|
309
371
|
},
|
|
310
372
|
setEffort(nextEffort) {
|
|
@@ -317,6 +379,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
317
379
|
if (turnActive) return false
|
|
318
380
|
sessionId = null
|
|
319
381
|
turnNo = 0
|
|
382
|
+
latestUsageSnapshot = null
|
|
320
383
|
return true
|
|
321
384
|
},
|
|
322
385
|
}
|
package/event-id.mjs
CHANGED
|
@@ -284,6 +284,24 @@ export function usageReportLine (sessionModel, logEvents) {
|
|
|
284
284
|
return `usage · ${turns} · ${perStr}${total}${cost}`
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
// Codex exposes exact current-context and lifetime-thread counters in its native
|
|
288
|
+
// rollout telemetry. Keep this separate from usageReportLine: Claude result
|
|
289
|
+
// events are per turn, while Codex's public turn.completed counters are lifetime
|
|
290
|
+
// totals and cannot safely be folded from the retained log.
|
|
291
|
+
export function codexUsageReportLine (sessionModel, snapshot) {
|
|
292
|
+
const ctx = snapshot?.context
|
|
293
|
+
const total = snapshot?.total
|
|
294
|
+
if (!ctx || !total) return 'usage · no completed Codex turn yet'
|
|
295
|
+
const fmtTok = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(Math.round(n || 0))
|
|
296
|
+
const used = Math.max(0, Number(ctx.used) || 0)
|
|
297
|
+
const max = Math.max(0, Number(ctx.max) || 0)
|
|
298
|
+
const pct = max > 0 ? Math.min(100, Math.max(0, Math.round((used / max) * 100))) : 0
|
|
299
|
+
const input = Math.max(0, Number(total.input_tokens) || 0)
|
|
300
|
+
const cached = Math.min(input, Math.max(0, Number(total.cached_input_tokens) || 0))
|
|
301
|
+
const output = Math.max(0, Number(total.output_tokens) || 0)
|
|
302
|
+
return `usage · ${prettyModelId(sessionModel)} · context ${fmtTok(used)}/${fmtTok(max)} (${pct}%) · thread ${fmtTok(input)} processed (${fmtTok(cached)} cached) / ${fmtTok(output)} out`
|
|
303
|
+
}
|
|
304
|
+
|
|
287
305
|
/* The context-carry recap moved to ./recap.mjs (ADR-0010 / S4, 2026-07-09).
|
|
288
306
|
*
|
|
289
307
|
* Why: the WEB CLIENT needs buildRecapFromLog to carry a lane's bridgeless
|