thinkpool-pair 0.7.204 → 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'
@@ -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
  }
@@ -2423,6 +2425,20 @@ function openStructured({ id, runtime = 'claude', model, effort, resume, log, co
2423
2425
  : `\n ${A.yel}● permission: ${req.toolName}${argStr(req.input)} — approve in the room.${A.rst}\n`)
2424
2426
  }),
2425
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
+ }
2426
2442
  // Brand-new session (no restored log): write its record now so a restart that lands
2427
2443
  // before the first debounced save still restores this id instead of dropping it (the
2428
2444
  // room would otherwise show a fresh empty terminal + strand the transcript).
@@ -2879,11 +2895,11 @@ channel
2879
2895
  pushLog(s, evt)
2880
2896
  bcast('code-event', { term: payload.term, evt })
2881
2897
  }
2882
- // Session controls (/model, /clear) are NOT agent turns — they change the
2898
+ // Session controls (/model, /clear, /usage, and Codex /compact) are NOT agent turns — they change the
2883
2899
  // shared session config. Emitting a `you` line for them strands the reader's
2884
2900
  // inferred "working…" state (no turn → no result to clear it; the old bug).
2885
- // Instead announce a ◆ control line both readers see. /compact + any other
2886
- // slash still flow through as a real, self-terminating turn.
2901
+ // Instead announce a ◆ control line both readers see. Claude /compact + any
2902
+ // other slash still flow through as a real, self-terminating turn.
2887
2903
  const ctlLine = (ctlText) => {
2888
2904
  const evt = { kind: 'control', text: ctlText, by: payload.by, cid: payload.cid }
2889
2905
  pushLog(s, evt)
@@ -2936,7 +2952,33 @@ channel
2936
2952
  // No persisted ctl line — the live indicator + the CompactionCard are the record.
2937
2953
  if (/^\/compact\b/.test(text)) {
2938
2954
  if (s.runtime === 'codex') {
2939
- ctlLine('Codex manages context compaction automatically')
2955
+ if (s.session.turnActive) {
2956
+ ctlLine('finish or stop the current Codex turn before compacting context')
2957
+ return
2958
+ }
2959
+ const recap = buildRecapFromLog(s.log, RECAP_CAP, { reason: 'compact' })
2960
+ if (!recap) {
2961
+ ctlLine('nothing to compact — context unchanged')
2962
+ return
2963
+ }
2964
+ if (s.session.clearContext?.() === false) {
2965
+ ctlLine('Codex context compaction unavailable right now')
2966
+ return
2967
+ }
2968
+ s.pendingRecap = recap
2969
+ const ce = { kind: 'compaction', trigger: 'manual', preTokens: s.lastUsage?.ctx?.used || null, by: payload.by }
2970
+ pushLog(s, ce)
2971
+ bcast('code-event', { term: payload.term, evt: ce })
2972
+ // The new native thread has only the bounded recap queued for the next
2973
+ // human turn. Reset the visible meter to that conservative text estimate.
2974
+ if (s.lastUsage?.ctx?.max) {
2975
+ const used = Math.ceil(recap.length / 4)
2976
+ const max = s.lastUsage.ctx.max
2977
+ const usage = { kind: 'usage', ctx: { ...s.lastUsage.ctx, used, max, pct: Math.min(100, Math.round((used / max) * 100)), over: used > max } }
2978
+ s.lastUsage = usage
2979
+ bcast('code-event', { term: payload.term, evt: usage })
2980
+ }
2981
+ s.flush?.()
2940
2982
  return
2941
2983
  }
2942
2984
  s.compacting = true
@@ -2945,18 +2987,20 @@ channel
2945
2987
  s.session.sendTurn(text)
2946
2988
  return
2947
2989
  }
2948
- // /usage → report this session's token + cost totals WITHOUT burning an agent turn.
2949
- // Computed by REPLAYING the session's own retained result events (each completed turn
2950
- // persists kind:'result' with usage + costUsd + model — see onEvent). No new accumulator
2951
- // 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 ◆
2952
2994
  // control lines (like /model) so both members AND late-joiners see it. Two lines: the
2953
2995
  // token/cost report (instant, from the retained log), then the plan meters (5h + weekly
2954
2996
  // utilization + server-authoritative resets) once the OAuth usage endpoint answers. The
2955
2997
  // meter line is fired-and-forgotten so a slow/unreachable endpoint never stalls /usage;
2956
2998
  // it degrades to "plan meters unavailable" rather than printing an inferred reset.
2957
2999
  if (/^\/usage\s*$/.test(text)) {
2958
- ctlLine(usageReportLine(s.model, s.log))
2959
- planMeterLine().then((l) => { if (l) ctlLine(l) }).catch(() => { /* meters are never load-bearing */ })
3000
+ ctlLine(s.runtime === 'codex'
3001
+ ? codexUsageReportLine(s.model, s.session?.usageSnapshot)
3002
+ : usageReportLine(s.model, s.log))
3003
+ if (s.runtime !== 'codex') planMeterLine().then((l) => { if (l) ctlLine(l) }).catch(() => { /* meters are never load-bearing */ })
2960
3004
  return
2961
3005
  }
2962
3006
  // Context-carry (2026-07-08) point 4: a real human turn arrived BEFORE the post-switch/
@@ -2964,7 +3008,7 @@ channel
2964
3008
  // — a separate recap turn would race + double-fire) and clear the flag so the init handler
2965
3009
  // no-ops. The visible `you` echo is unchanged; the recap is context for the agent only.
2966
3010
  let sendText = text
2967
- if (s.pendingRecap) { sendText = `${s.pendingRecap}\n\n--- the person's next message ---\n${text}`; s.pendingRecap = null }
3011
+ if (s.pendingRecap) { sendText = `${s.pendingRecap}\n\n--- the person's next message ---\n${text}`; s.pendingRecap = null; s.flush?.() }
2968
3012
  s.session.sendTurn(sendText)
2969
3013
  echoYou()
2970
3014
  // Pool-order fix (2026-07-02): emit the "dispatched to agent" marker HERE, right after
@@ -3193,7 +3237,7 @@ channel
3193
3237
  // FL-M6 — restore the flow context (id/role/cwd) so an in-flight flow survives a
3194
3238
  // bridge restart: the conductor keeps its subagent-block + plan interception, and
3195
3239
  // lanes keep their worktree cwd + the ability to mark done.
3196
- 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,
3240
+ 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,
3197
3241
  // Lazy-boot restored terminals that were IDLE + not part of a flow: their transcript
3198
3242
  // shows immediately; the query boots on first turn. Mid-turn + flow terminals boot now
3199
3243
  // (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, usageSnapshotForSession = () => null }) {
46
46
  this._onEvent = onEvent
47
47
  this._model = model || null
48
48
  this._sessionId = sessionId || null
@@ -50,6 +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._usageSnapshotForSession = usageSnapshotForSession
54
+ this._usageBaseline = null
53
55
  }
54
56
 
55
57
  /** Emit a room-event; never let a consumer throw into the stream. */
@@ -57,6 +59,8 @@ export class CodexEventMapper {
57
59
 
58
60
  setModel(model) { this._model = model || null }
59
61
 
62
+ setUsageBaseline(snapshot) { this._usageBaseline = snapshot || null }
63
+
60
64
  /** Process one parsed Codex JSONL event. */
61
65
  push(ev) {
62
66
  if (!ev || typeof ev !== 'object') return
@@ -141,9 +145,22 @@ export class CodexEventMapper {
141
145
 
142
146
  case 'turn.completed': {
143
147
  const u = ev.usage || {}
144
- // Map Codex usage → Claude-shape usage (what event-id.mjs folds into cost).
145
- // reasoning_output_tokens are billed as output → fold in (conservative).
146
- const out = (u.output_tokens || 0) + (u.reasoning_output_tokens || 0)
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) {
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 } })
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
147
164
  this._emit({
148
165
  kind: 'result',
149
166
  subtype: 'success',
@@ -151,8 +168,10 @@ export class CodexEventMapper {
151
168
  model: this._model,
152
169
  costUsd: null,
153
170
  usage: {
154
- input_tokens: u.input_tokens || 0,
155
- cache_read_input_tokens: u.cached_input_tokens || 0,
171
+ // Claude-shape fields are mutually exclusive buckets. Codex reports
172
+ // cached_input_tokens as a subset of input_tokens, so subtract it.
173
+ input_tokens: totalInput - cachedInput,
174
+ cache_read_input_tokens: cachedInput,
156
175
  cache_creation_input_tokens: 0,
157
176
  output_tokens: out,
158
177
  },
package/codex-session.mjs CHANGED
@@ -65,6 +65,70 @@ export function readCodexModels({ home = process.env.CODEX_HOME || path.join(os.
65
65
  } catch { return [] }
66
66
  }
67
67
 
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')
76
+ try {
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
+ }
127
+ } catch { return null }
128
+ finally { if (fd != null) { try { fsImpl.closeSync(fd) } catch { /* noop */ } } }
129
+ return null
130
+ }
131
+
68
132
  export function normalizeCodexSandbox(value) {
69
133
  return SAFE_SANDBOXES.has(value) ? value : DEFAULT_SANDBOX
70
134
  }
@@ -145,12 +209,21 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
145
209
  let turnActive = false
146
210
  let activeModel = model || null
147
211
  let activeEffort = EFFORT_LEVELS.has(initialEffort) ? initialEffort : 'high'
212
+ let latestUsageSnapshot = sessionId ? readCodexThreadUsage(sessionId) : null
148
213
  let mcpHttpPromise = null
149
214
  // serialize turns: one exec at a time
150
215
  let chain = Promise.resolve()
151
216
  const queue = []
152
217
 
153
- const mapper = new CodexEventMapper({ onEvent, model: model || null })
218
+ const mapper = new CodexEventMapper({
219
+ onEvent,
220
+ model: model || null,
221
+ usageSnapshotForSession: (id) => {
222
+ const snapshot = readCodexThreadUsage(id)
223
+ if (snapshot) latestUsageSnapshot = snapshot
224
+ return snapshot
225
+ },
226
+ })
154
227
 
155
228
  const note = (text) => { try { onEvent?.({ kind: 'note', text }) } catch { /* noop */ } }
156
229
 
@@ -180,6 +253,10 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
180
253
  prompt,
181
254
  })
182
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
+
183
260
  return new Promise((resolve) => {
184
261
  // stdin: /dev/null (spike gotcha — codex blocks on stdin otherwise)
185
262
  let stdinFd
@@ -250,6 +327,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
250
327
 
251
328
  return {
252
329
  get sessionId() { return sessionId },
330
+ get usageSnapshot() { return latestUsageSnapshot },
253
331
  get turnActive() { return turnActive },
254
332
  get started() { return turnNo > 0 || turnActive },
255
333
  sendTurn(text) {
@@ -288,6 +366,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
288
366
  // next turn so the selected mode is real, while ThinkPool keeps the lane log.
289
367
  sessionId = null
290
368
  turnNo = 0
369
+ latestUsageSnapshot = null
291
370
  return true
292
371
  },
293
372
  setEffort(nextEffort) {
@@ -300,6 +379,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
300
379
  if (turnActive) return false
301
380
  sessionId = null
302
381
  turnNo = 0
382
+ latestUsageSnapshot = null
303
383
  return true
304
384
  },
305
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.204",
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": {
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