@botbuddy/cli 1.34.0 → 1.34.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.34.0",
3
+ "version": "1.34.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -28,13 +28,16 @@ import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation
28
28
  import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
29
29
  import { doctorAgentAuth, doctorHelp, parseDoctorArgs } from "./agent-doctor.mjs";
30
30
  import { clearAgentState, selfHealAgentSession } from "./agent-session.mjs";
31
+ import { runUsage, usageStatus } from "./usage/sync.mjs";
32
+ import { installUsageAgent, usageAgentStatus } from "./usage/launchd.mjs";
31
33
 
32
34
  // BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
33
35
  // invocation carrying `--json` — those are hot paths whose receipts (BOT-1229)
34
- // must not pay for a registry round-trip.
36
+ // must not pay for a registry round-trip. BOT-1023: nor may `usage`, which
37
+ // runs inside every `git push` under a 5 s budget.
35
38
  export function shouldCheckForUpdates(argv) {
36
39
  const [command] = argv;
37
- if (command === "wait" || command === "telemetry") return false;
40
+ if (command === "wait" || command === "telemetry" || command === "usage") return false;
38
41
  if (argv.includes("--json")) return false;
39
42
  return true;
40
43
  }
@@ -77,6 +80,16 @@ export async function run(argv, {
77
80
  case "docker": return cmdDocker(args);
78
81
  case "run": return cmdRun(args);
79
82
  case "telemetry": return cmdTelemetry(args);
83
+ // BOT-803: local Claude/Codex token usage → usage-ingest (fail-open).
84
+ case "usage": {
85
+ const code = await runUsage(args, { deps: { cliVersion: VERSION } });
86
+ // The run is bounded by --max-seconds, but a credential or socket call it
87
+ // abandoned at the deadline can keep the event loop alive for minutes
88
+ // (a pre-push lane would stall). An unref'd timer only fires when
89
+ // something is still lingering; a clean run exits on its own first.
90
+ setTimeout(() => process.exit(code), 250).unref();
91
+ return code;
92
+ }
80
93
  case "test": return cmdTest(args);
81
94
  case "wait": return runWait(args);
82
95
  case "pw": return runPw(args);
@@ -198,6 +211,11 @@ ${bold("DURABLE WORKLOADS")}
198
211
  telemetry <status|replay|doctor>
199
212
  Inspect, replay, or gate durable lifecycle delivery
200
213
 
214
+ ${bold("USAGE INGEST")}
215
+ usage sync [--since 2d] [--provider claude|codex|all] [--file <transcript>] [--dry-run] [--quiet]
216
+ Send local Claude/Codex token usage (counters only, no prompts)
217
+ usage status Last sync runs and tracked transcripts
218
+
201
219
  ${bold("AGENT WAITS")}
202
220
  wait [--any] <condition>... [options]
203
221
  Wait once for a pushed BotBuddy signal
@@ -216,13 +234,18 @@ ${bold("OTHER")}
216
234
  ${SETUP_BLOCK}`);
217
235
  }
218
236
 
219
- export async function cmdDoctor(args, { log = (line) => console.log(line), errorLog = (line) => console.error(line) } = {}) {
237
+ export async function cmdDoctor(args, {
238
+ log = (line) => console.log(line), errorLog = (line) => console.error(line),
239
+ doctor = doctorAgentAuth, usageReport = doctorUsageSync,
240
+ } = {}) {
220
241
  const parsed = parseDoctorArgs(args);
221
242
  if (parsed.help) { log(doctorHelp()); return 0; }
222
243
  if (parsed.errors.length) { errorLog(`bb doctor: ${parsed.errors.join("; ")}`); return 4; }
223
- const result = await doctorAgentAuth({ fix: parsed.fix, printExports: parsed.printExports });
244
+ const result = await doctor({ fix: parsed.fix, printExports: parsed.printExports });
245
+ // BOT-1021: informational only — never changes doctor's exit code.
246
+ const usage = await usageReport().catch(() => null);
224
247
  if (parsed.json) {
225
- log(JSON.stringify(result.report));
248
+ log(JSON.stringify(usage ? { ...result.report, usage_sync: usage } : result.report));
226
249
  } else {
227
250
  log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
228
251
  log(`client key: ${result.report.client_key.status}`);
@@ -233,11 +256,33 @@ export async function cmdDoctor(args, { log = (line) => console.log(line), error
233
256
  log(`stack lease: ${sl.capability} (host ${sl.host}, machine id ${sl.machine_id_readable ? "readable" : "unavailable"})${sl.reason ? ` — ${sl.reason}` : ""}`);
234
257
  }
235
258
  if (result.report.recovery) log(`recovery: ${result.report.recovery}`);
259
+ if (usage) log(formatUsageSyncLine(usage));
236
260
  }
237
261
  if (result.exports) log(result.exports);
238
262
  return result.exitCode;
239
263
  }
240
264
 
265
+ /** BOT-1021: is the daily sweep installed, and how did the last sync runs go? */
266
+ export async function doctorUsageSync({ agentStatus = usageAgentStatus, status = usageStatus } = {}) {
267
+ const agent = await agentStatus();
268
+ const { last_runs: runs = [] } = await status({ limit: 20 });
269
+ const pick = (run) => (run ? { at: run.at, outcome: run.outcome, ...(run.error ? { error: run.error } : {}) } : null);
270
+ return {
271
+ daily_agent: agent.supported ? (agent.installed ? "installed" : "not_installed") : "unsupported",
272
+ last_daily_run: pick(runs.find((run) => run.trigger === "daily")),
273
+ last_run: pick(runs[0]),
274
+ };
275
+ }
276
+
277
+ export function formatUsageSyncLine(usage) {
278
+ const last = usage.last_daily_run ?? usage.last_run;
279
+ const lastText = last ? `last ${usage.last_daily_run ? "daily " : ""}run ${last.outcome}${last.error ? ` (${last.error})` : ""} at ${last.at}` : "no runs yet";
280
+ const agentText = usage.daily_agent === "installed" ? "daily agent installed"
281
+ : usage.daily_agent === "unsupported" ? "daily agent n/a (launchd is macOS-only)"
282
+ : "daily agent NOT installed — run bb setup";
283
+ return `usage sync: ${agentText}; ${lastText}`;
284
+ }
285
+
241
286
  // ─── BOT-876: generic tool access ───────────────────────────────
242
287
 
243
288
  async function cmdCall(args) {
@@ -425,6 +470,7 @@ export async function cmdSetup(args, {
425
470
  clearState = clearAgentState,
426
471
  selfHeal = selfHealAgentSession,
427
472
  callerResolver = resolveCaller,
473
+ usageAgent = installUsageAgent,
428
474
  cwd = process.cwd(),
429
475
  env = process.env,
430
476
  log = (line) => console.log(line),
@@ -485,6 +531,7 @@ export async function cmdSetup(args, {
485
531
  return 5;
486
532
  }
487
533
  log(`bb setup: ${tenant} is ready (${session.source} worktree session). Run bb wait '<condition>'.`);
534
+ await reportUsageAgent(usageAgent, { cwd, log });
488
535
  return 0;
489
536
  } catch (error) {
490
537
  errorLog(`bb setup: could not prepare this worktree (${error?.code ?? "setup_failed"}). Retry bb setup after completing sign-in.`);
@@ -492,6 +539,17 @@ export async function cmdSetup(args, {
492
539
  }
493
540
  }
494
541
 
542
+ // BOT-1021: setup also (re)installs the daily usage sweep. Never fails setup —
543
+ // the worktree session is ready either way; the line says what happened.
544
+ async function reportUsageAgent(usageAgent, { cwd, log }) {
545
+ let result;
546
+ try { result = await usageAgent({ cwd }); } catch (error) { result = { status: "failed", error: error?.code ?? "install_failed" }; }
547
+ if (result?.status === "installed") log("bb setup: daily usage sync installed (ai.botbuddy.usage-sync, 03:10 local).");
548
+ else if (result?.status === "unchanged") log("bb setup: daily usage sync already installed (ai.botbuddy.usage-sync).");
549
+ else if (result?.status === "unsupported") log(`bb setup: daily usage sync skipped — ${result.notice}.`);
550
+ else log(`bb setup: daily usage sync could not be installed (${result?.error ?? "unknown"}); run bb setup again to retry.`);
551
+ }
552
+
495
553
  export function loginHelp(log = console.log) {
496
554
  log(`${bold("botbuddy login")} — authenticate via OAuth (browser + loopback callback)
497
555
 
@@ -0,0 +1,157 @@
1
+ // BOT-803 — Claude Code transcript adapter (`~/.claude/projects/**/*.jsonl`).
2
+ //
3
+ // Verified on real transcripts (PoC report, 2026-09-28):
4
+ // * one API message fans out into several JSONL records that share
5
+ // `message.id` + `requestId` with identical usage. The server keys tokens on
6
+ // (provider, harness_session_id, message_id), first record wins — this
7
+ // matches the harness's own `cost-state` counters exactly;
8
+ // * `tool_use` blocks are split across those records, so the adapter emits ONE
9
+ // envelope per message whose tool_call_count is summed over the message's
10
+ // DISTINCT record uuids (the server stores, never sums, it — BOT-1019);
11
+ // * subagent transcripts live in `<session>/subagents/agent-*.jsonl`, reuse
12
+ // the parent sessionId, carry `isSidechain: true` and `stop_reason: null`
13
+ // with a stream-start output_tokens placeholder → usage_final false, partial;
14
+ // * `gitBranch` can be the literal "HEAD" and `cwd` can change mid-session, so
15
+ // the ticket key is inferred per message;
16
+ // * `cost-state` carries per-model cumulative `modelUsage` and no timestamp.
17
+ // BOT-1019's table has no row kind to hold it, and as a plain row it would
18
+ // double-count, so those records are counted and WITHHELD (never sent) until
19
+ // the server grows a reconciliation surface.
20
+ //
21
+ // Because the first record to reach the server wins, a message whose records
22
+ // are still being written must not be sent early: the trailing message of a
23
+ // live file is held back (the cursor stops at its first record) until it gains
24
+ // a final record, a later message starts, or the file goes quiet.
25
+ import {
26
+ finalizeEnvelope, inferTicketKey, modelLabel, safeLabel, str, tok, usageQuality, uuid, withRoute,
27
+ } from "./envelope.mjs";
28
+
29
+ export const CLAUDE_PROVIDER = "claude";
30
+ export const CLAUDE_PARSER_VERSION = "claude-1";
31
+
32
+ function countToolUses(content) {
33
+ if (!Array.isArray(content)) return 0;
34
+ let n = 0;
35
+ for (const block of content) if (block && block.type === "tool_use") n += 1;
36
+ return n;
37
+ }
38
+
39
+ const classesOf = (usage) => ({
40
+ input_tokens: tok(usage?.input_tokens),
41
+ output_tokens: tok(usage?.output_tokens),
42
+ cache_creation_input_tokens: tok(usage?.cache_creation_input_tokens),
43
+ cache_read_input_tokens: tok(usage?.cache_read_input_tokens),
44
+ });
45
+
46
+ /**
47
+ * @param {object} opts
48
+ * @param {(value: string) => string} opts.hmac keyed HMAC of a local path
49
+ * @param {string} opts.environment 'local' | 'cloud'
50
+ * @param {(cwd: string) => string|null} [opts.repoFor] cwd → GitHub owner/name
51
+ * @param {(cwd: string|null) => string|null} [opts.tenantFor] cwd → tenant bound to its checkout
52
+ */
53
+ export function createClaudeAdapter({ hmac, environment, repoFor = () => null, tenantFor = () => null }) {
54
+ return {
55
+ provider: CLAUDE_PROVIDER,
56
+ parserVersion: CLAUDE_PARSER_VERSION,
57
+ /**
58
+ * One file's parser. `ctx` is per-file state persisted in the cursor (only
59
+ * derived, privacy-safe values).
60
+ */
61
+ beginFile({ sourceHmac, ctx }) {
62
+ const groups = new Map();
63
+ let lastKey = null;
64
+ const stats = { refused: 0, withheld: 0 };
65
+ return {
66
+ stats,
67
+ parseRecord(record, offset) {
68
+ if (!record || typeof record !== "object") return;
69
+ if (typeof record.timestamp === "string") ctx.last_ts = record.timestamp;
70
+ if (uuid(record.sessionId)) ctx.session_id = record.sessionId;
71
+
72
+ if (record.type === "cost-state" && record.modelUsage && typeof record.modelUsage === "object") {
73
+ stats.withheld += Object.keys(record.modelUsage).length;
74
+ return;
75
+ }
76
+ const msg = record.message;
77
+ if (record.type !== "assistant" || !msg || typeof msg !== "object" || !msg.usage || typeof msg.usage !== "object") return;
78
+ const messageId = str(msg.id);
79
+ // A record without a sessionId inherits the file's; a malformed one is refused.
80
+ const sessionId = record.sessionId === undefined ? ctx.session_id ?? null : uuid(record.sessionId);
81
+ // NULLs never conflict in a unique index — refuse rather than duplicate.
82
+ if (!messageId || !sessionId) { stats.refused += 1; return; }
83
+
84
+ const key = `${sessionId}\u0000${messageId}`;
85
+ let group = groups.get(key);
86
+ if (!group) {
87
+ if (lastKey && groups.has(lastKey)) groups.get(lastKey).closed = true; // a new API response began
88
+ const cwd = str(record.cwd);
89
+ const gitBranch = safeLabel(record.gitBranch);
90
+ group = {
91
+ offset, closed: false, final: false, toolCalls: 0, uuids: new Set(),
92
+ sessionId, messageId,
93
+ requestId: str(record.requestId), recordUuid: uuid(record.uuid), occurredAt: str(record.timestamp),
94
+ model: modelLabel(msg.model), version: safeLabel(record.version), isSidechain: record.isSidechain === true,
95
+ gitBranch, cwd, usage: classesOf(msg.usage),
96
+ };
97
+ groups.set(key, group);
98
+ }
99
+ const recordId = str(record.uuid);
100
+ if (!recordId || !group.uuids.has(recordId)) {
101
+ if (recordId) group.uuids.add(recordId);
102
+ group.toolCalls += countToolUses(msg.content);
103
+ }
104
+ if (msg.stop_reason !== null && msg.stop_reason !== undefined) {
105
+ group.final = true;
106
+ group.closed = true;
107
+ group.usage = classesOf(msg.usage); // the final record's usage is authoritative
108
+ }
109
+ lastKey = key;
110
+ },
111
+ /** @returns {{ envelopes: object[], holdOffset: number|null }} */
112
+ finish({ quiescent }) {
113
+ const envelopes = [];
114
+ let holdOffset = null;
115
+ for (const group of groups.values()) {
116
+ if (!group.closed && !quiescent) {
117
+ holdOffset = holdOffset === null ? group.offset : Math.min(holdOffset, group.offset);
118
+ continue;
119
+ }
120
+ envelopes.push(envelopeFor(group));
121
+ }
122
+ // Anything after a held message is re-read with it next time.
123
+ return { envelopes: holdOffset === null ? envelopes : envelopes.filter((e) => e.source_offset < holdOffset), holdOffset };
124
+ },
125
+ };
126
+
127
+ function envelopeFor(group) {
128
+ const ticket = inferTicketKey(group.gitBranch, group.cwd);
129
+ const usage = group.usage;
130
+ return withRoute(finalizeEnvelope({
131
+ provider: CLAUDE_PROVIDER,
132
+ provider_schema_version: group.version,
133
+ harness_session_id: group.sessionId,
134
+ request_id: group.requestId,
135
+ message_id: group.messageId,
136
+ record_uuid: group.recordUuid,
137
+ occurred_at: group.occurredAt,
138
+ model: group.model,
139
+ ...usage,
140
+ tool_call_count: group.toolCalls,
141
+ is_sidechain: group.isSidechain,
142
+ usage_final: group.final,
143
+ usage_quality: usageQuality({ usageFinal: group.final, classes: Object.values(usage) }),
144
+ git_branch: group.gitBranch,
145
+ repo: group.cwd ? repoFor(group.cwd) : null,
146
+ ticket_key_inferred: ticket,
147
+ attribution_confidence: ticket ? "inferred" : null,
148
+ environment,
149
+ cwd_hmac: group.cwd ? hmac(group.cwd) : null,
150
+ source_hmac: sourceHmac,
151
+ source_offset: group.offset,
152
+ parser_version: CLAUDE_PARSER_VERSION,
153
+ }), group.cwd ? tenantFor(group.cwd) : null);
154
+ }
155
+ },
156
+ };
157
+ }
@@ -0,0 +1,236 @@
1
+ // BOT-803 — Codex rollout adapter (`~/.codex/sessions/**/*.jsonl`, or
2
+ // `$CODEX_HOME/sessions`).
3
+ //
4
+ // VERIFIED SHAPE (parser_version 'codex-2'): checked on 2026-09-28 against real
5
+ // Codex Desktop rollouts (0.117 → 0.155.0-alpha.9.2, ~/.codex/sessions). The
6
+ // fixtures in cli/test-fixtures/usage/ are synthetic copies of that shape.
7
+ // Every record is {"timestamp","type","ordinal"?,"payload"}:
8
+ // session_meta {id, session_id, cli_version, thread_source, parent_thread_id?, cwd,
9
+ // git:{commit_hash, branch, repository_url}, …}
10
+ // A subagent rollout is its own file: id = the subagent thread,
11
+ // session_id = the ROOT session, thread_source 'subagent'.
12
+ // turn_context {model, cwd, turn_id, …}
13
+ // response_item {type:'function_call'|'custom_tool_call'|'local_shell_call'|…} —
14
+ // written BEFORE the usage record of the response that made it
15
+ // token_usage_record (≥0.151) {response_id, session_id, thread_id, turn_id,
16
+ // usage:{input_tokens, cached_input_tokens, cache_write_input_tokens,
17
+ // output_tokens, reasoning_output_tokens, total_tokens},
18
+ // turn_token_usage, thread_token_usage} — ONE per model response,
19
+ // including a compaction's own response (which has no token_count)
20
+ // event_msg/token_count {info:{total_token_usage, last_token_usage, model_context_window}|null,
21
+ // rate_limits} — follows each usage record with the same numbers,
22
+ // is re-emitted with unchanged totals, and is null-info for a
23
+ // rate-limit-only update
24
+ // Identity: a usage record keys on its provider `response_id`. Rollouts written
25
+ // before token_usage_record existed (and the pre-upgrade head of a session
26
+ // resumed on a newer Codex) fall back to token_count keyed on the cumulative
27
+ // total; once a file shows a usage record, token_count is ignored (it would
28
+ // double count). `cache_write_input_tokens` has no BOT-1019 column and was 0 in
29
+ // every real rollout checked; it is not sent.
30
+ // Codex classes stay provider-faithful (codex_* columns); Claude's four classes
31
+ // are null on Codex rows. Codex `input_tokens` INCLUDES `cached_input_tokens`.
32
+ import {
33
+ commitSha, finalizeEnvelope, inferTicketKey, modelLabel, normalizeRepo, safeLabel, str, tok, usageQuality, uuid, withRoute,
34
+ } from "./envelope.mjs";
35
+
36
+ export const CODEX_PROVIDER = "codex";
37
+ export const CODEX_PARSER_VERSION = "codex-2";
38
+
39
+ // Mirrors the server's PROVIDER_ID_RE (usageEnvelope.ts): resp_… ids, no '/' or '@'.
40
+ const RESPONSE_ID_RE = /^[A-Za-z0-9_.:-]{1,200}$/;
41
+ const responseId = (value) => (typeof value === "string" && RESPONSE_ID_RE.test(value) ? value : null);
42
+
43
+ function totalOf(usage) {
44
+ if (!usage || typeof usage !== "object") return null;
45
+ const explicit = tok(usage.total_tokens);
46
+ if (explicit !== null) return explicit;
47
+ const input = tok(usage.input_tokens);
48
+ const output = tok(usage.output_tokens);
49
+ return input === null && output === null ? null : (input ?? 0) + (output ?? 0);
50
+ }
51
+
52
+ function deltaUsage(total, previous) {
53
+ const keys = ["input_tokens", "cached_input_tokens", "output_tokens", "reasoning_output_tokens"];
54
+ const out = {};
55
+ for (const key of keys) {
56
+ const now = tok(total?.[key]);
57
+ const before = tok(previous?.[key]) ?? 0;
58
+ out[key] = now === null ? null : Math.max(0, now - before);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ // Only derived values are kept in ctx (it is persisted in the cursor file):
64
+ // the cwd's HMAC and the ticket key its last segment names — never the path.
65
+ function setCwd(ctx, cwd, hmac, tenantFor, repoFor = () => null) {
66
+ const value = str(cwd);
67
+ if (!value) return;
68
+ const cwdHmac = hmac(value);
69
+ // A later turn in ANOTHER checkout: the session_meta git metadata described
70
+ // the old one. Drop it (the ticket then falls back to the new cwd) and
71
+ // re-derive the repo from the new checkout.
72
+ if (ctx.cwd_hmac && ctx.cwd_hmac !== cwdHmac) {
73
+ ctx.git_branch = null;
74
+ ctx.commit_sha = null;
75
+ ctx.repo = repoFor(value) ?? null;
76
+ }
77
+ ctx.cwd_hmac = cwdHmac;
78
+ ctx.cwd_ticket = inferTicketKey(null, value);
79
+ ctx.cwd_tenant = tenantFor(value); // a tenant slug (or null), never the path
80
+ }
81
+
82
+ const NONE = Object.freeze({ envelopes: [], refused: 0 });
83
+
84
+ /**
85
+ * @param {object} opts
86
+ * @param {(value: string) => string} opts.hmac
87
+ * @param {string} opts.environment
88
+ */
89
+ export function createCodexAdapter({ hmac, environment, tenantFor = () => null, repoFor = () => null }) {
90
+ function parseCodexRecord(record, { offset, sourceHmac, ctx }) {
91
+ if (!record || typeof record !== "object") return NONE;
92
+ const payload = record.payload && typeof record.payload === "object" ? record.payload : null;
93
+ if (!payload) return NONE;
94
+
95
+ if (record.type === "session_meta") {
96
+ // The harness session is the ROOT thread (`session_id`); a subagent's own
97
+ // thread id (`id`) only keeps its legacy token_count keys apart.
98
+ const root = uuid(payload.session_id) ?? uuid(payload.id);
99
+ if (root) ctx.session_id = root;
100
+ const thread = uuid(payload.id);
101
+ ctx.subagent_thread = thread && root && thread !== root ? thread
102
+ : payload.thread_source === "subagent" && thread ? thread : null;
103
+ if (safeLabel(payload.cli_version)) ctx.cli_version = safeLabel(payload.cli_version);
104
+ setCwd(ctx, payload.cwd, hmac, tenantFor, repoFor);
105
+ const git = payload.git && typeof payload.git === "object" ? payload.git : {};
106
+ ctx.git_branch = safeLabel(git.branch) ?? ctx.git_branch ?? null;
107
+ ctx.commit_sha = commitSha(git.commit_hash) ?? ctx.commit_sha ?? null;
108
+ ctx.repo = normalizeRepo(git.repository_url) ?? ctx.repo ?? null;
109
+ return NONE;
110
+ }
111
+ if (record.type === "turn_context") {
112
+ if (modelLabel(payload.model)) ctx.model = modelLabel(payload.model);
113
+ setCwd(ctx, payload.cwd, hmac, tenantFor, repoFor);
114
+ return NONE;
115
+ }
116
+ if (record.type === "response_item") {
117
+ if (["function_call", "local_shell_call", "custom_tool_call"].includes(payload.type)) {
118
+ ctx.pending_tool_calls = (ctx.pending_tool_calls ?? 0) + 1;
119
+ }
120
+ return NONE;
121
+ }
122
+ if (record.type === "token_usage_record") {
123
+ ctx.has_usage_records = true;
124
+ const id = responseId(payload.response_id);
125
+ const session = uuid(payload.session_id) ?? ctx.session_id ?? null;
126
+ const usage = payload.usage && typeof payload.usage === "object" ? payload.usage : null;
127
+ if (!id || !session || !usage) return { envelopes: [], refused: 1 };
128
+ const toolCalls = ctx.pending_tool_calls ?? 0;
129
+ ctx.pending_tool_calls = 0;
130
+ return { envelopes: [envelopeFor({ record, usage, usageFinal: true, messageId: id, session, toolCalls, offset, sourceHmac, ctx })], refused: 0 };
131
+ }
132
+ if (record.type !== "event_msg" || payload.type !== "token_count") return NONE;
133
+ // Each usage record is followed by a token_count carrying the same numbers.
134
+ if (ctx.has_usage_records) return NONE;
135
+
136
+ const info = payload.info && typeof payload.info === "object" ? payload.info : null;
137
+ const total = info?.total_token_usage;
138
+ const cumulative = totalOf(total);
139
+ // A token_count without cumulative usage (e.g. a rate-limit-only update)
140
+ // has no stable identity: refuse it rather than invent one.
141
+ if (!info || cumulative === null || !ctx.session_id) return info ? { envelopes: [], refused: 1 } : NONE;
142
+ // Codex re-emits token_count with unchanged totals; within one accounting
143
+ // epoch the cumulative total strictly increases per real response, so it is
144
+ // the message identity. Older rollouts reset the total (after compaction):
145
+ // a drop starts a new epoch so a later equal total is never read as a repeat.
146
+ if (ctx.last_total_tokens === cumulative) return NONE;
147
+ if (typeof ctx.last_total_tokens === "number" && cumulative < ctx.last_total_tokens) {
148
+ ctx.total_epoch = (ctx.total_epoch ?? 0) + 1;
149
+ ctx.last_total_usage = null; // a delta across the reset would be meaningless
150
+ }
151
+
152
+ let usage = info.last_token_usage && typeof info.last_token_usage === "object" ? info.last_token_usage : null;
153
+ let usageFinal = true;
154
+ if (!usage) {
155
+ // No per-response usage: derive it from the previous cumulative total;
156
+ // without one the row cannot be split from earlier turns → not final.
157
+ usage = deltaUsage(total, ctx.last_total_usage);
158
+ usageFinal = Boolean(ctx.last_total_usage);
159
+ }
160
+ ctx.last_total_tokens = cumulative;
161
+ ctx.last_total_usage = {
162
+ input_tokens: tok(total.input_tokens),
163
+ cached_input_tokens: tok(total.cached_input_tokens),
164
+ output_tokens: tok(total.output_tokens),
165
+ reasoning_output_tokens: tok(total.reasoning_output_tokens),
166
+ };
167
+ const toolCalls = ctx.pending_tool_calls ?? 0;
168
+ ctx.pending_tool_calls = 0;
169
+
170
+ // A legacy subagent's cumulative totals restart at zero: keep its keys
171
+ // apart from the parent's under the shared root session.
172
+ const epoch = ctx.total_epoch ? `r${ctx.total_epoch}:` : "";
173
+ const messageId = ctx.subagent_thread
174
+ ? `token_count:${ctx.subagent_thread}:${epoch}${cumulative}`
175
+ : `token_count:${epoch}${cumulative}`;
176
+ return {
177
+ envelopes: [envelopeFor({ record, usage, usageFinal, messageId, session: ctx.session_id, toolCalls, offset, sourceHmac, ctx })],
178
+ refused: 0,
179
+ };
180
+ }
181
+
182
+ function envelopeFor({ record, usage, usageFinal, messageId, session, toolCalls, offset, sourceHmac, ctx }) {
183
+ const classes = {
184
+ codex_input_tokens: tok(usage.input_tokens),
185
+ codex_cached_input_tokens: tok(usage.cached_input_tokens),
186
+ codex_output_tokens: tok(usage.output_tokens),
187
+ codex_reasoning_output_tokens: tok(usage.reasoning_output_tokens),
188
+ };
189
+ const ticket = inferTicketKey(ctx.git_branch, null) ?? ctx.cwd_ticket ?? null;
190
+ return withRoute(finalizeEnvelope({
191
+ provider: CODEX_PROVIDER,
192
+ provider_schema_version: ctx.cli_version ?? null,
193
+ harness_session_id: session,
194
+ message_id: messageId,
195
+ occurred_at: str(record.timestamp),
196
+ model: ctx.model ?? null,
197
+ ...classes,
198
+ tool_call_count: toolCalls,
199
+ is_sidechain: Boolean(ctx.subagent_thread),
200
+ usage_final: usageFinal,
201
+ // The server's rule: Codex is 'measured' when input/cached/output are present.
202
+ usage_quality: usageQuality({
203
+ usageFinal, classes: [classes.codex_input_tokens, classes.codex_cached_input_tokens, classes.codex_output_tokens],
204
+ }),
205
+ git_branch: ctx.git_branch ?? null,
206
+ repo: ctx.repo ?? null,
207
+ commit_sha: ctx.commit_sha ?? null,
208
+ ticket_key_inferred: ticket,
209
+ attribution_confidence: ticket ? "inferred" : null,
210
+ environment,
211
+ cwd_hmac: ctx.cwd_hmac ?? null,
212
+ source_hmac: sourceHmac,
213
+ source_offset: offset,
214
+ parser_version: CODEX_PARSER_VERSION,
215
+ }), ctx.cwd_tenant ?? null);
216
+ }
217
+
218
+ return {
219
+ provider: CODEX_PROVIDER,
220
+ parserVersion: CODEX_PARSER_VERSION,
221
+ /** One file's parser; token_count rows are complete when written, so nothing is ever held back. */
222
+ beginFile({ sourceHmac, ctx }) {
223
+ const envelopes = [];
224
+ const stats = { refused: 0, withheld: 0 };
225
+ return {
226
+ stats,
227
+ parseRecord(record, offset) {
228
+ const result = parseCodexRecord(record, { offset, sourceHmac, ctx });
229
+ stats.refused += result.refused;
230
+ envelopes.push(...result.envelopes);
231
+ },
232
+ finish() { return { envelopes, holdOffset: null }; },
233
+ };
234
+ },
235
+ };
236
+ }