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 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 this session's token + cost totals WITHOUT burning an agent turn.
2977
- // Computed by REPLAYING the session's own retained result events (each completed turn
2978
- // persists kind:'result' with usage + costUsd + model — see onEvent). No new accumulator
2979
- // state: the log is durable, so this survives a bridge restart for free. Reported as ◆
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(usageReportLine(s.model, s.log))
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
  }
@@ -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, contextWindowForModel = () => null }) {
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._contextWindowForModel = contextWindowForModel
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
- // Map Codex usage → Claude-shape usage (what event-id.mjs folds into cost).
146
- // reasoning_output_tokens are billed as output → fold in (conservative).
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
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 here;
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
- // 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
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 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
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
- contextWindowForModel: (id) => readCodexContextWindow(id),
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkpool-pair",
3
- "version": "0.7.205",
3
+ "version": "0.7.206",
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": {