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 +59 -15
- package/codex-event-mapper.mjs +25 -6
- package/codex-session.mjs +81 -1
- package/event-id.mjs +18 -0
- package/package.json +1 -1
- package/recap.mjs +4 -0
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 ||
|
|
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
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
2949
|
-
//
|
|
2950
|
-
//
|
|
2951
|
-
//
|
|
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(
|
|
2959
|
-
|
|
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).
|
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,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
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
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
|
-
|
|
155
|
-
|
|
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({
|
|
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
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
|