You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A Codex coding session is completely invisible — unmetered like a tmux drive, but unlike a tmux drive the absence is never recorded, and classifyEngineMetering has zero production callers #556
Its whole all-time footprint in usage_summary is 20 calls, $0.000475 — the cloud-side Pilot decisions. The Codex process itself has produced zero ai_usage rows and zero usage.unmetered trace events. On the Usage page that agent's engine reads as costless, in the same way a tmux-driven Claude Code session read as costless before #348.
A zero you did not measure is a lie; an absence you did measure is data.
It applied that rule to the terminal driver and left the headless raw-engine case unrecorded — even though the classifier it shipped covers both.
Verified
The classifier knows.workers/api/src/lib/engine-metering.ts:100-121, classifyEngineMetering("headless", "codex"):
if(STRUCTURED_ENGINES.has(name)){return{metered: true,reason: "Claude Code reports each turn's tokens and cost, and that figure is recorded as measured."};}return{metered: false,reason: name
? `${name} ends a turn with plain stdout and reports no token counts, so its spend cannot be measured.`
: "This engine reports no token counts, so its spend cannot be measured.",};
with STRUCTURED_ENGINES = new Set(["claude", "claude-code"]) (:70), and the module docstring saying so outright: "Codex and Grok are spawned raw and their turns are stdout text, which is why they are unmetered under BOTH drivers rather than only under the terminal one."
Nothing calls it.
$ grep -rn "classifyEngineMetering" --include="*.ts" workers packages store | grep -v node_modules
workers/api/src/lib/engine-metering.ts:107:export function classifyEngineMetering(…)
workers/api/src/lib/engine-metering.test.ts: (10 assertions, including `("headless","codex").metered === false`)
One definition, ten test assertions, zero production call sites. The function that describes this gap is dead code.
And the absence recorder is wired only to the terminal driver.
Six call sites, all driver: "terminal". EngineDriver is declared as "headless" | "terminal" (engine-metering.ts:40) and "headless" is never passed by any caller.
So the runner-side path confirms it.packages/browser-runner/src/coding/headless.ts sets this.mode = config.clientType === "claude" ? "stream-json" : "raw" (:255); only the stream-json path parses a result event carrying tokens. A raw engine's turn produces stdout and nothing else, so takeUsage() returns [] and recordEngineUsage no-ops on !records?.length (usage.ts:191).
noteUnmeteredDrive was wired where the drives were happening, in the two terminal connectors, which is where the reported problem was.
Composed: the classifier covers a 2x2, the recorder was wired to one row of it, and the remaining cell — headless + raw engine — is now the default for any subscriber who picks Codex or Grok from the engines panel. The panel offers all three as equals (coding-engines.ts:253, DEFAULT_ENGINES) with nothing saying that one of them is measurable and two are not.
reserve() has exactly two callers; openBudget has six. So four paths open a pool that nothing ever draws against.
One latent hole worth naming, not currently costing anything: the key-proxy meters only OpenAI /audio/speech and /audio/transcriptions (routes/keys.ts:555). api.anthropic.com is on its allowlist (keys.ts:58), so a proxied /v1/messages would be entirely unmetered. Grepped: the only in-repo consumers are packages/sdk/src/voice/stt.ts:635 and tts.ts:333, both audio. Nothing exploits it today.
What to do, cheapest first
Call the classifier from the headless drain. In routes/coding.ts (capture, :237) and workflows/coding-session.ts (:402), when classifyEngineMetering("headless", session.clientType).metered === false, call noteUnmeteredDrive(env, ctx, { driver: "headless", target: session.engineLabel, activeCommand: session.clientType }). Everything needed is already in hand at both sites, the row id is already coarse enough not to flood the trace (unmeteredRowId keys on instance+target+day), and unmeteredUsageSummary — which the Usage page already renders — picks it up with no change.
Say it in the engines panel.classifyEngineMetering(...).reason is a finished sentence written for exactly this: "codex ends a turn with plain stdout and reports no token counts, so its spend cannot be measured." Show it under any preset whose engine is not Claude Code. This is the cheapest half and it prevents the choice being made blind.
Consider --json for Codex. Out of scope here; note only that if any raw engine grows a structured turn-end event, the fix is to add it to STRUCTURED_ENGINES and teach headless.ts to parse it — the classifier needs no change.
Alternatives considered and rejected
Estimate raw-engine spend from stdout length. Rejected — this is precisely the "a guess wearing a measurement's clothes" that engine-metering.ts:20-27 refuses for pane text, and the same refusal applies to stdout.
Block or hide non-Claude engines. Rejected outright. Codex is a legitimate choice and DEFAULT_ENGINES ships it deliberately; the fix is to record the consequence, not to remove the option.
Acceptance criteria
Driving a Codex session writes usage.unmetered trace events with driver: "headless", visible via GET /v1/instances/:id/trace and MCP agent_trace.
unmeteredUsageSummary counts them, so the Usage page's "what this leaves out" figure covers headless raw engines.
classifyEngineMetering has at least one production caller, asserted by the same style of source test that guards CHARGED_SQL.
The engines panel shows the metering verdict per preset.
A test drives the ("headless", "codex") cell end to end — today all four cells are unit-tested and only two are reachable.
Regression risk
noteUnmeteredDrive is best-effort and never throws (engine-metering.ts:230-247), so adding it to the capture drain cannot break a session. The one thing to watch is volume: the capture poll runs every 3s per open session, and the coarse unmeteredRowId (instance + target + day + aiCli) is what keeps that from becoming thousands of trace rows — do not make the id finer to include the session, or a day of Codex work becomes a day of identical trace rows. unmeteredUsageSummary's own comment already calls the resulting count "drives on distinct target-days", and that stays true.
Measured on production (engine presets, usage_summary, the instance's trace) and read from source.
Related: #348 (the terminal half of the same rule), #267 (engine metering), #449 (raw engines are one-shot), #516, #543, #551.
The gap
AIPA coder(a1d3522f…) runs Codex. Measured on production 2026-08-13,GET /v1/instances/a1d3522f…/coding/engines:Its whole all-time footprint in
usage_summaryis 20 calls, $0.000475 — the cloud-side Pilot decisions. The Codex process itself has produced zeroai_usagerows and zerousage.unmeteredtrace events. On the Usage page that agent's engine reads as costless, in the same way a tmux-driven Claude Code session read as costless before #348.#348's own header states the rule this violates:
It applied that rule to the terminal driver and left the headless raw-engine case unrecorded — even though the classifier it shipped covers both.
Verified
The classifier knows.
workers/api/src/lib/engine-metering.ts:100-121,classifyEngineMetering("headless", "codex"):with
STRUCTURED_ENGINES = new Set(["claude", "claude-code"])(:70), and the module docstring saying so outright: "Codex and Grok are spawned raw and their turns are stdout text, which is why they are unmetered under BOTH drivers rather than only under the terminal one."Nothing calls it.
One definition, ten test assertions, zero production call sites. The function that describes this gap is dead code.
And the absence recorder is wired only to the terminal driver.
Six call sites, all
driver: "terminal".EngineDriveris declared as"headless" | "terminal"(engine-metering.ts:40) and"headless"is never passed by any caller.So the runner-side path confirms it.
packages/browser-runner/src/coding/headless.tssetsthis.mode = config.clientType === "claude" ? "stream-json" : "raw"(:255); only the stream-json path parses aresultevent carrying tokens. A raw engine's turn produces stdout and nothing else, sotakeUsage()returns[]andrecordEngineUsageno-ops on!records?.length(usage.ts:191).Why it composed into a bug
Two correct decisions:
noteUnmeteredDrivewas wired where the drives were happening, in the two terminal connectors, which is where the reported problem was.Composed: the classifier covers a 2x2, the recorder was wired to one row of it, and the remaining cell — headless + raw engine — is now the default for any subscriber who picks Codex or Grok from the engines panel. The panel offers all three as equals (
coding-engines.ts:253,DEFAULT_ENGINES) with nothing saying that one of them is measurable and two are not.The full metering picture, for the record
ai_generateuser-ai.ts,recordUsageat:272/:462recordPlatformUsage(vectors.ts:313,summaries.ts:141,instances-translation.ts:289)recordVoiceUsage(routes/keys.ts:561,564)recordEngineUsage, 4 sitesagent-loop.ts:88)coding-session.ts:459)JOB_APPLYjob-apply.ts:155,kind:"apply")reserve()has exactly two callers;openBudgethas six. So four paths open a pool that nothing ever draws against.One latent hole worth naming, not currently costing anything: the key-proxy meters only OpenAI
/audio/speechand/audio/transcriptions(routes/keys.ts:555).api.anthropic.comis on its allowlist (keys.ts:58), so a proxied/v1/messageswould be entirely unmetered. Grepped: the only in-repo consumers arepackages/sdk/src/voice/stt.ts:635andtts.ts:333, both audio. Nothing exploits it today.What to do, cheapest first
routes/coding.ts(capture,:237) andworkflows/coding-session.ts(:402), whenclassifyEngineMetering("headless", session.clientType).metered === false, callnoteUnmeteredDrive(env, ctx, { driver: "headless", target: session.engineLabel, activeCommand: session.clientType }). Everything needed is already in hand at both sites, the row id is already coarse enough not to flood the trace (unmeteredRowIdkeys on instance+target+day), andunmeteredUsageSummary— which the Usage page already renders — picks it up with no change.classifyEngineMetering(...).reasonis a finished sentence written for exactly this: "codex ends a turn with plain stdout and reports no token counts, so its spend cannot be measured." Show it under any preset whose engine is not Claude Code. This is the cheapest half and it prevents the choice being made blind.--jsonfor Codex. Out of scope here; note only that if any raw engine grows a structured turn-end event, the fix is to add it toSTRUCTURED_ENGINESand teachheadless.tsto parse it — the classifier needs no change.Alternatives considered and rejected
engine-metering.ts:20-27refuses for pane text, and the same refusal applies to stdout.DEFAULT_ENGINESships it deliberately; the fix is to record the consequence, not to remove the option.Acceptance criteria
usage.unmeteredtrace events withdriver: "headless", visible viaGET /v1/instances/:id/traceand MCPagent_trace.unmeteredUsageSummarycounts them, so the Usage page's "what this leaves out" figure covers headless raw engines.classifyEngineMeteringhas at least one production caller, asserted by the same style of source test that guardsCHARGED_SQL.("headless", "codex")cell end to end — today all four cells are unit-tested and only two are reachable.Regression risk
noteUnmeteredDriveis best-effort and never throws (engine-metering.ts:230-247), so adding it to the capture drain cannot break a session. The one thing to watch is volume: the capture poll runs every 3s per open session, and the coarseunmeteredRowId(instance + target + day + aiCli) is what keeps that from becoming thousands of trace rows — do not make the id finer to include the session, or a day of Codex work becomes a day of identical trace rows.unmeteredUsageSummary's own comment already calls the resulting count "drives on distinct target-days", and that stays true.Measured on production (engine presets,
usage_summary, the instance's trace) and read from source.Related: #348 (the terminal half of the same rule), #267 (engine metering), #449 (raw engines are one-shot), #516, #543, #551.