codiedev 0.10.0-beta.1 → 0.10.0-beta.2

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.
@@ -2,7 +2,7 @@ import * as fs from "fs";
2
2
  export declare const CAPTURE_LOG_MAX_BYTES: number;
3
3
  export declare const CAPTURE_LOG_PATH: string;
4
4
  export interface CaptureLogEntry {
5
- stage: "capture-finish" | "upload" | "upload-deferred" | "security" | "stderr";
5
+ stage: "capture-finish" | "upload" | "upload-deferred" | "security" | "stderr" | "limit";
6
6
  sessionId: string | undefined;
7
7
  outcome: string;
8
8
  detail?: string;
@@ -0,0 +1,19 @@
1
+ export declare const CAPTURE_SIGNALS_VERSION = 1;
2
+ export type CaptureSignals = {
3
+ version: 1;
4
+ parsedRecords: number;
5
+ timestampCount: number;
6
+ firstAt?: number;
7
+ lastAt?: number;
8
+ activeMs: number;
9
+ idleThresholdMs: number;
10
+ skillInvocations: string[];
11
+ skillPulls: string[];
12
+ toolCalls: Array<{
13
+ name: string;
14
+ calls: number;
15
+ }>;
16
+ truncated: boolean;
17
+ };
18
+ /** Compact diagnostic inputs only: never persist prompts, commands or tool output. */
19
+ export declare function parseCaptureSignals(content: string, source?: string): CaptureSignals;
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CAPTURE_SIGNALS_VERSION = void 0;
4
+ exports.parseCaptureSignals = parseCaptureSignals;
5
+ const transcriptModels_1 = require("./transcriptModels");
6
+ exports.CAPTURE_SIGNALS_VERSION = 1;
7
+ const IDLE_MS = 5 * 60_000;
8
+ const LIMIT = 100;
9
+ const skillKey = (value) => typeof value === "string"
10
+ ? value
11
+ .replace(/^\//, "")
12
+ .replace(/\.md$/i, "")
13
+ .replace(/["'`]/g, "")
14
+ .trim()
15
+ .toLowerCase()
16
+ : "";
17
+ /** Compact diagnostic inputs only: never persist prompts, commands or tool output. */
18
+ function parseCaptureSignals(content, source) {
19
+ const timestamps = new Set();
20
+ const invocations = new Set(), pulls = new Set();
21
+ const tools = new Map(), calls = new Set();
22
+ let parsedRecords = 0, truncated = false;
23
+ const addSkill = (set, value) => {
24
+ const key = skillKey(value);
25
+ if (!key || key.length > 200 || !/^[a-z0-9][a-z0-9_:/.-]*$/i.test(key))
26
+ return;
27
+ if (set.size >= LIMIT && !set.has(key)) {
28
+ truncated = true;
29
+ return;
30
+ }
31
+ set.add(key);
32
+ };
33
+ const command = (value) => {
34
+ if (typeof value !== "string")
35
+ return;
36
+ for (const match of value.matchAll(/\bcodiedev\s+pull\s+([^\s"'`]+)/gi))
37
+ addSkill(pulls, match[1]);
38
+ };
39
+ const tool = (name, id, input) => {
40
+ if (typeof name !== "string" || !name || name.length > 100)
41
+ return;
42
+ const key = typeof id === "string" ? id : `${parsedRecords}:${calls.size}`;
43
+ if (calls.has(key))
44
+ return;
45
+ calls.add(key);
46
+ if (tools.size < LIMIT || tools.has(name))
47
+ tools.set(name, (tools.get(name) ?? 0) + 1);
48
+ else
49
+ truncated = true;
50
+ let args = input;
51
+ if (typeof args === "string") {
52
+ try {
53
+ args = JSON.parse(args);
54
+ }
55
+ catch {
56
+ command(args);
57
+ return;
58
+ }
59
+ }
60
+ if (name.toLowerCase() === "skill")
61
+ addSkill(invocations, args?.skill ?? args?.name);
62
+ command(args?.command ?? args?.cmd);
63
+ };
64
+ for (const record of (0, transcriptModels_1.transcriptRecords)(content, source)) {
65
+ if (!record || typeof record !== "object")
66
+ continue;
67
+ const e = record;
68
+ parsedRecords++;
69
+ const time = typeof e.timestamp === "string"
70
+ ? Date.parse(e.timestamp)
71
+ : e.info?.time?.created;
72
+ if (typeof time === "number" && Number.isFinite(time) && time > 0)
73
+ timestamps.add(time);
74
+ const blocks = e.message?.content;
75
+ if (Array.isArray(blocks))
76
+ for (const part of blocks) {
77
+ if (part?.type === "tool_use")
78
+ tool(part.name, part.id, part.input);
79
+ if (part?.type === "text" && e.type === "user") {
80
+ for (const match of String(part.text ?? "").matchAll(/<command-name>\/?([a-z0-9:_-]+)<\/command-name>/gi))
81
+ addSkill(invocations, match[1]);
82
+ }
83
+ }
84
+ if (typeof blocks === "string" && e.type === "user")
85
+ for (const match of blocks.matchAll(/<command-name>\/?([a-z0-9:_-]+)<\/command-name>/gi))
86
+ addSkill(invocations, match[1]);
87
+ if (e.type === "response_item" &&
88
+ ["function_call", "custom_tool_call"].includes(e.payload?.type))
89
+ tool(e.payload.name, e.payload.call_id, e.payload.arguments ?? e.payload.input);
90
+ if (e.info?.role === "assistant" && Array.isArray(e.parts))
91
+ for (const part of e.parts) {
92
+ if (part?.type === "tool")
93
+ tool(part.tool, part.callID ?? part.id, part.state?.input);
94
+ }
95
+ }
96
+ const sorted = [...timestamps].sort((a, b) => a - b);
97
+ let activeMs = 0;
98
+ for (let i = 1; i < sorted.length; i++) {
99
+ const gap = sorted[i] - sorted[i - 1];
100
+ if (gap <= IDLE_MS)
101
+ activeMs += gap;
102
+ }
103
+ return {
104
+ version: exports.CAPTURE_SIGNALS_VERSION,
105
+ parsedRecords,
106
+ timestampCount: sorted.length,
107
+ ...(sorted.length
108
+ ? { firstAt: sorted[0], lastAt: sorted[sorted.length - 1] }
109
+ : {}),
110
+ activeMs,
111
+ idleThresholdMs: IDLE_MS,
112
+ skillInvocations: [...invocations].sort(),
113
+ skillPulls: [...pulls].sort(),
114
+ toolCalls: [...tools]
115
+ .map(([name, calls]) => ({ name, calls }))
116
+ .sort((a, b) => a.name.localeCompare(b.name)),
117
+ truncated,
118
+ };
119
+ }
@@ -0,0 +1,62 @@
1
+ import { type LimitEvent } from "./limitEvent";
2
+ import type { LimitEventPayload } from "./limitEventReporter";
3
+ export interface CodexLimitHit {
4
+ event: LimitEvent;
5
+ /** Byte offset just past the matched line — the tail ends here, not at EOF. */
6
+ lineEnd: number;
7
+ }
8
+ export interface ScanOptions {
9
+ chunkSize?: number;
10
+ maxScanBytes?: number;
11
+ }
12
+ /**
13
+ * Find the LAST rate-limit/credit block recorded in a Codex rollout file.
14
+ * Returns null when the session was never blocked.
15
+ */
16
+ export declare function scanCodexRolloutForLimit(filePath: string, options?: ScanOptions): CodexLimitHit | null;
17
+ /**
18
+ * Read up to `maxBytes` of the rollout ENDING at `lineEnd` — the work the
19
+ * developer was doing when the block hit, not whatever the session did after
20
+ * the window reset. Resumes at a line boundary so every JSONL line parses.
21
+ */
22
+ export declare function readTailEndingAt(filePath: string, lineEnd: number, maxBytes?: number): string;
23
+ export interface FindRolloutOptions {
24
+ withinMs?: number;
25
+ maxFiles?: number;
26
+ now?: number;
27
+ }
28
+ /** Codex rollout files, newest first, limited by age and count. */
29
+ export declare function findRecentCodexRollouts(sessionsDir: string, options?: FindRolloutOptions): string[];
30
+ /** Codex names rollouts `rollout-<ISO local time>-<session id>.jsonl`. */
31
+ export declare function sessionIdFromRolloutPath(filePath: string): string | undefined;
32
+ /** Where Codex writes its rollout JSONL files. */
33
+ export declare function codexSessionsDir(): string;
34
+ export declare const CODEX_LIMIT_STATE_PATH: string;
35
+ /**
36
+ * Record a block reported outside the sweep (the Stop path), so the next
37
+ * prompt sweep neither re-scans the rollout nor re-posts the event.
38
+ */
39
+ export declare function recordCodexLimitReported(filePath: string, at: number, statePath?: string): void;
40
+ export interface SweepArgs {
41
+ sessionsDir: string;
42
+ statePath?: string;
43
+ post: (payload: LimitEventPayload) => Promise<void>;
44
+ findOptions?: FindRolloutOptions;
45
+ scanOptions?: ScanOptions;
46
+ /** How many rollouts may actually be READ in one sweep. */
47
+ maxScans?: number;
48
+ }
49
+ /**
50
+ * Report every Codex block this machine has not reported yet. Returns how many
51
+ * events were posted. Never throws — it runs inside a hook.
52
+ *
53
+ * State is kept PER FILE, not as one newest-wins timestamp. A single global
54
+ * watermark loses data: rollouts are capped per sweep, so a block in a file
55
+ * outside the cap can be older than one already reported and would then be
56
+ * suppressed forever. A per-file record can only ever cause a re-report, which
57
+ * the server deduplicates on (companyId, userId, at).
58
+ *
59
+ * A file is recorded only after its POST succeeds, so a failed send is retried
60
+ * on the next prompt.
61
+ */
62
+ export declare function sweepCodexLimits(args: SweepArgs): Promise<number>;
@@ -0,0 +1,453 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CODEX_LIMIT_STATE_PATH = void 0;
37
+ exports.scanCodexRolloutForLimit = scanCodexRolloutForLimit;
38
+ exports.readTailEndingAt = readTailEndingAt;
39
+ exports.findRecentCodexRollouts = findRecentCodexRollouts;
40
+ exports.sessionIdFromRolloutPath = sessionIdFromRolloutPath;
41
+ exports.codexSessionsDir = codexSessionsDir;
42
+ exports.recordCodexLimitReported = recordCodexLimitReported;
43
+ exports.sweepCodexLimits = sweepCodexLimits;
44
+ // Reporting a Codex credit/rate-limit block.
45
+ //
46
+ // WHY THIS EXISTS: the `limit-codex` reporter is a Codex `Stop` hook, and
47
+ // Codex does NOT run Stop hooks on the turn a block kills. Measured on real
48
+ // rollouts: every normal `task_complete` pairs with a Stop-hook run within
49
+ // ~200ms, and the blocked one never does (21 turns → 20 runs; 11 → 10, short
50
+ // by exactly the blocked turn each time). Claude Code has a dedicated
51
+ // StopFailure event with a `rate_limit` matcher, which is why it reports
52
+ // correctly; Codex's HookEventName enum has no equivalent — there is no event
53
+ // at all that fires at the block.
54
+ //
55
+ // So the block can only be reported LATER, from a hook that does run. This
56
+ // module scans the rollout files Codex has already written and reports any
57
+ // block that has not been reported yet. It is driven from UserPromptSubmit
58
+ // (`discover-codex`), which fires on the developer's next prompt — in the
59
+ // blocked session or in a new one — and from Stop, which still catches the
60
+ // (rarer) case of a block followed by more turns.
61
+ //
62
+ // The scan reads BACKWARDS from end of file. The previous reader took a fixed
63
+ // 512 KB tail, which silently lost the event whenever the session kept writing
64
+ // after the block: in one real 44 MB rollout the marker sat 1.13 MB from the
65
+ // end, outside the window, and the parse returned null.
66
+ const fs = __importStar(require("fs"));
67
+ const os = __importStar(require("os"));
68
+ const path = __importStar(require("path"));
69
+ const limitEvent_1 = require("./limitEvent");
70
+ const configDir_1 = require("./configDir");
71
+ const MARKER = Buffer.from("rate_limit_reached_type");
72
+ const SPEND_MARKER = Buffer.from("spend_control_reached");
73
+ const NEWLINE = 0x0a;
74
+ // Read backwards in 256 KB chunks, and give up after 16 MB. Codex rollouts run
75
+ // to tens of megabytes, but a block is always in the session's LAST turn — the
76
+ // bytes after it are whatever the session did once the window reset. 16 MB
77
+ // covers that comfortably without ever loading a whole rollout.
78
+ const DEFAULT_CHUNK_SIZE = 256 * 1024;
79
+ const DEFAULT_MAX_SCAN_BYTES = 16 * 1024 * 1024;
80
+ // Tail shipped to the server so it can summarize what the developer was doing
81
+ // when they got blocked. Matches the server-side cap in http.ts.
82
+ const DEFAULT_TAIL_BYTES = 64 * 1024;
83
+ /**
84
+ * Find the LAST rate-limit/credit block recorded in a Codex rollout file.
85
+ * Returns null when the session was never blocked.
86
+ */
87
+ function scanCodexRolloutForLimit(filePath, options = {}) {
88
+ const chunkSize = options.chunkSize ?? DEFAULT_CHUNK_SIZE;
89
+ const maxScanBytes = options.maxScanBytes ?? DEFAULT_MAX_SCAN_BYTES;
90
+ let size = 0;
91
+ try {
92
+ size = fs.statSync(filePath).size;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ if (size === 0)
98
+ return null;
99
+ let fd;
100
+ try {
101
+ fd = fs.openSync(filePath, "r");
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ try {
107
+ let pos = size;
108
+ // Bytes of the (earlier-read) chunk that belong to a line whose start we
109
+ // have not reached yet. Carried into the next, earlier chunk.
110
+ let carry = Buffer.alloc(0);
111
+ let scanned = 0;
112
+ while (pos > 0 && scanned < maxScanBytes) {
113
+ const length = Math.min(chunkSize, pos, maxScanBytes - scanned);
114
+ if (length <= 0)
115
+ break;
116
+ pos -= length;
117
+ const chunk = Buffer.allocUnsafe(length);
118
+ const read = fs.readSync(fd, chunk, 0, length, pos);
119
+ scanned += read;
120
+ const window = Buffer.concat([chunk.subarray(0, read), carry]);
121
+ // At pos > 0 the first line in the window is partial — its start lies in
122
+ // an earlier chunk — so hold it back rather than parse a torn line.
123
+ let completeStart = 0;
124
+ if (pos > 0) {
125
+ const firstNewline = window.indexOf(NEWLINE);
126
+ if (firstNewline < 0) {
127
+ // The whole window is one unterminated line. A token_count record is
128
+ // ~1 KB, so a line this long is never the one we want: drop it rather
129
+ // than grow the carry without bound.
130
+ carry = window.length > maxScanBytes ? Buffer.alloc(0) : window;
131
+ continue;
132
+ }
133
+ completeStart = firstNewline + 1;
134
+ carry = window.subarray(0, completeStart);
135
+ }
136
+ else {
137
+ carry = Buffer.alloc(0);
138
+ }
139
+ const region = window.subarray(completeStart);
140
+ if (region.indexOf(MARKER) < 0 && region.indexOf(SPEND_MARKER) < 0)
141
+ continue;
142
+ // Walk this window's complete lines from the end backwards; the first
143
+ // match going backwards is the latest one in the file.
144
+ const bounds = [];
145
+ let lineStart = 0;
146
+ for (let index = 0; index < region.length; index += 1) {
147
+ if (region[index] === NEWLINE) {
148
+ bounds.push([lineStart, index + 1]);
149
+ lineStart = index + 1;
150
+ }
151
+ }
152
+ if (lineStart < region.length)
153
+ bounds.push([lineStart, region.length]);
154
+ for (let index = bounds.length - 1; index >= 0; index -= 1) {
155
+ const [start, end] = bounds[index];
156
+ const event = (0, limitEvent_1.parseCodexLimitEventFromTranscript)(region.subarray(start, end).toString("utf8"));
157
+ if (event)
158
+ return { event, lineEnd: pos + completeStart + end };
159
+ }
160
+ }
161
+ return null;
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ finally {
167
+ try {
168
+ fs.closeSync(fd);
169
+ }
170
+ catch {
171
+ /* best-effort */
172
+ }
173
+ }
174
+ }
175
+ /**
176
+ * Read up to `maxBytes` of the rollout ENDING at `lineEnd` — the work the
177
+ * developer was doing when the block hit, not whatever the session did after
178
+ * the window reset. Resumes at a line boundary so every JSONL line parses.
179
+ */
180
+ function readTailEndingAt(filePath, lineEnd, maxBytes = DEFAULT_TAIL_BYTES) {
181
+ // Clamp to the file as it is NOW: the offset came from an earlier scan, and
182
+ // reading past EOF would return a short, garbled tail.
183
+ let end = lineEnd;
184
+ try {
185
+ end = Math.min(lineEnd, fs.statSync(filePath).size);
186
+ }
187
+ catch {
188
+ return "";
189
+ }
190
+ const start = Math.max(0, end - maxBytes);
191
+ const length = end - start;
192
+ if (length <= 0)
193
+ return "";
194
+ let fd;
195
+ try {
196
+ fd = fs.openSync(filePath, "r");
197
+ }
198
+ catch {
199
+ return "";
200
+ }
201
+ try {
202
+ const buffer = Buffer.allocUnsafe(length);
203
+ const read = fs.readSync(fd, buffer, 0, length, start);
204
+ const text = buffer.subarray(0, read).toString("utf8");
205
+ if (start === 0)
206
+ return text;
207
+ const newline = text.indexOf("\n");
208
+ return newline === -1 ? "" : text.slice(newline + 1);
209
+ }
210
+ catch {
211
+ return "";
212
+ }
213
+ finally {
214
+ try {
215
+ fs.closeSync(fd);
216
+ }
217
+ catch {
218
+ /* best-effort */
219
+ }
220
+ }
221
+ }
222
+ const DEFAULT_WITHIN_MS = 24 * 3600_000;
223
+ const DEFAULT_MAX_FILES = 12;
224
+ /** Codex rollout files, newest first, limited by age and count. */
225
+ function findRecentCodexRollouts(sessionsDir, options = {}) {
226
+ const withinMs = options.withinMs ?? DEFAULT_WITHIN_MS;
227
+ const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
228
+ const now = options.now ?? Date.now();
229
+ const found = [];
230
+ const walk = (directory, depth) => {
231
+ if (depth > 5)
232
+ return;
233
+ let entries;
234
+ try {
235
+ entries = fs.readdirSync(directory, { withFileTypes: true });
236
+ }
237
+ catch {
238
+ return;
239
+ }
240
+ for (const entry of entries) {
241
+ const full = path.join(directory, entry.name);
242
+ if (entry.isDirectory()) {
243
+ walk(full, depth + 1);
244
+ continue;
245
+ }
246
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
247
+ continue;
248
+ try {
249
+ const mtime = fs.statSync(full).mtimeMs;
250
+ if (now - mtime > withinMs)
251
+ continue;
252
+ found.push({ file: full, mtime });
253
+ }
254
+ catch {
255
+ /* unreadable — skip */
256
+ }
257
+ }
258
+ };
259
+ walk(sessionsDir, 0);
260
+ return found
261
+ .sort((left, right) => right.mtime - left.mtime)
262
+ .slice(0, maxFiles)
263
+ .map((entry) => entry.file);
264
+ }
265
+ /** Codex names rollouts `rollout-<ISO local time>-<session id>.jsonl`. */
266
+ function sessionIdFromRolloutPath(filePath) {
267
+ const match = path.basename(filePath).match(/^rollout-.*?T[\d-]+-(.+)\.jsonl$/);
268
+ return match ? match[1] : undefined;
269
+ }
270
+ /** Where Codex writes its rollout JSONL files. */
271
+ function codexSessionsDir() {
272
+ return path.join(os.homedir(), ".codex", "sessions");
273
+ }
274
+ exports.CODEX_LIMIT_STATE_PATH = path.join((0, configDir_1.codiedevStateDir)(), "codex-limit-state.json");
275
+ // Bounds the state file on a machine with a long Codex history.
276
+ const MAX_STATE_ENTRIES = 500;
277
+ function readSweepState(statePath) {
278
+ let parsed;
279
+ try {
280
+ parsed = JSON.parse(fs.readFileSync(statePath, "utf8"));
281
+ }
282
+ catch {
283
+ return { seen: {} };
284
+ }
285
+ const seen = {};
286
+ const raw = parsed.seen;
287
+ if (raw && typeof raw === "object") {
288
+ for (const [file, value] of Object.entries(raw)) {
289
+ const record = value;
290
+ if (typeof record?.size !== "number")
291
+ continue;
292
+ seen[file] = {
293
+ size: record.size,
294
+ mtime: typeof record.mtime === "number" ? record.mtime : -1,
295
+ at: typeof record.at === "number" ? record.at : null,
296
+ t: typeof record.t === "number" ? record.t : 0,
297
+ };
298
+ }
299
+ }
300
+ // Migrate the 0.10.0-beta shape ({ reportedThrough, clean }). The old global
301
+ // watermark is DELIBERATELY dropped rather than carried over: it is exactly
302
+ // what could suppress an older block forever. Re-reporting is safe — the
303
+ // server deduplicates on (companyId, userId, at).
304
+ const legacyClean = parsed.clean;
305
+ if (legacyClean && typeof legacyClean === "object") {
306
+ for (const [file, size] of Object.entries(legacyClean)) {
307
+ if (typeof size === "number" && !seen[file])
308
+ seen[file] = { size, mtime: -1, at: null, t: 0 };
309
+ }
310
+ }
311
+ return { seen };
312
+ }
313
+ // A record is dropped only once its file has been gone for a while. Pruning
314
+ // the moment a path is missing would re-report an already-sent block after any
315
+ // transient rename or move.
316
+ const PRUNE_AFTER_MS = 7 * 24 * 3600_000;
317
+ function pruneSweepState(state, now) {
318
+ for (const [file, record] of Object.entries(state.seen)) {
319
+ if (now - (record.t ?? 0) < PRUNE_AFTER_MS)
320
+ continue;
321
+ if (!fs.existsSync(file))
322
+ delete state.seen[file];
323
+ }
324
+ const files = Object.keys(state.seen);
325
+ if (files.length <= MAX_STATE_ENTRIES)
326
+ return;
327
+ files
328
+ .sort((left, right) => (state.seen[right].t ?? 0) - (state.seen[left].t ?? 0))
329
+ .slice(MAX_STATE_ENTRIES)
330
+ .forEach((file) => delete state.seen[file]);
331
+ }
332
+ /**
333
+ * Merge our records into whatever is on disk NOW and write the result. Two
334
+ * Codex sessions can submit a prompt at the same time; a plain
335
+ * read-modify-write would silently drop the other process's records.
336
+ */
337
+ function commitSweepState(statePath, ours, now) {
338
+ const current = readSweepState(statePath);
339
+ for (const [file, record] of Object.entries(ours))
340
+ current.seen[file] = record;
341
+ pruneSweepState(current, now);
342
+ writeSweepState(statePath, current);
343
+ }
344
+ function writeSweepState(statePath, state) {
345
+ // Written through a temp file and renamed: Codex kills a hook that overruns
346
+ // its timeout, and a half-written state file would read as "nothing seen"
347
+ // and re-report every block in the window.
348
+ const temp = `${statePath}.${process.pid}.tmp`;
349
+ try {
350
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
351
+ fs.writeFileSync(temp, JSON.stringify(state), "utf8");
352
+ fs.renameSync(temp, statePath);
353
+ }
354
+ catch {
355
+ try {
356
+ fs.unlinkSync(temp);
357
+ }
358
+ catch {
359
+ /* best-effort */
360
+ }
361
+ }
362
+ }
363
+ function fingerprint(filePath) {
364
+ try {
365
+ const stat = fs.statSync(filePath);
366
+ return { size: stat.size, mtime: stat.mtimeMs };
367
+ }
368
+ catch {
369
+ return null;
370
+ }
371
+ }
372
+ /**
373
+ * Record a block reported outside the sweep (the Stop path), so the next
374
+ * prompt sweep neither re-scans the rollout nor re-posts the event.
375
+ */
376
+ function recordCodexLimitReported(filePath, at, statePath = exports.CODEX_LIMIT_STATE_PATH) {
377
+ const print = fingerprint(filePath);
378
+ if (!print)
379
+ return;
380
+ commitSweepState(statePath, { [filePath]: { ...print, at, t: Date.now() } }, Date.now());
381
+ }
382
+ // Candidates are listed generously and settled by one statSync each; only
383
+ // files that actually changed cost a read. The budget therefore bounds work
384
+ // without letting idle sessions bury an unreported block.
385
+ const DEFAULT_CANDIDATES = 200;
386
+ const DEFAULT_MAX_SCANS = 8;
387
+ /**
388
+ * Report every Codex block this machine has not reported yet. Returns how many
389
+ * events were posted. Never throws — it runs inside a hook.
390
+ *
391
+ * State is kept PER FILE, not as one newest-wins timestamp. A single global
392
+ * watermark loses data: rollouts are capped per sweep, so a block in a file
393
+ * outside the cap can be older than one already reported and would then be
394
+ * suppressed forever. A per-file record can only ever cause a re-report, which
395
+ * the server deduplicates on (companyId, userId, at).
396
+ *
397
+ * A file is recorded only after its POST succeeds, so a failed send is retried
398
+ * on the next prompt.
399
+ */
400
+ async function sweepCodexLimits(args) {
401
+ const statePath = args.statePath ?? exports.CODEX_LIMIT_STATE_PATH;
402
+ const state = readSweepState(statePath);
403
+ const files = findRecentCodexRollouts(args.sessionsDir, {
404
+ maxFiles: DEFAULT_CANDIDATES,
405
+ ...args.findOptions,
406
+ });
407
+ const now = Date.now();
408
+ const touched = {};
409
+ let budget = args.maxScans ?? DEFAULT_MAX_SCANS;
410
+ const pending = [];
411
+ for (const file of files) {
412
+ const print = fingerprint(file);
413
+ if (!print)
414
+ continue;
415
+ const record = state.seen[file];
416
+ // Settled by the stat alone — it has not changed since the last scan, so
417
+ // it holds nothing new and does not spend the read budget.
418
+ if (record && record.size === print.size && record.mtime === print.mtime)
419
+ continue;
420
+ if (budget <= 0)
421
+ break;
422
+ budget -= 1;
423
+ const hit = scanCodexRolloutForLimit(file, args.scanOptions);
424
+ if (!hit) {
425
+ touched[file] = { ...print, at: null, t: now };
426
+ continue;
427
+ }
428
+ if (record && record.at === hit.event.at) {
429
+ // Same block, already reported; the file just grew.
430
+ touched[file] = { ...print, at: record.at, t: now };
431
+ continue;
432
+ }
433
+ pending.push({ file, print, hit });
434
+ }
435
+ // Posted concurrently: each file is independent, and this runs on the
436
+ // prompt path where a serial chain of network timeouts would be felt.
437
+ const results = await Promise.allSettled(pending.map(({ file, hit }) => args.post({
438
+ source: "codex",
439
+ ...hit.event,
440
+ sessionId: sessionIdFromRolloutPath(file),
441
+ transcriptTail: readTailEndingAt(file, hit.lineEnd),
442
+ })));
443
+ let posted = 0;
444
+ results.forEach((result, index) => {
445
+ if (result.status !== "fulfilled")
446
+ return;
447
+ const { file, print, hit } = pending[index];
448
+ touched[file] = { ...print, at: hit.event.at, t: now };
449
+ posted += 1;
450
+ });
451
+ commitSweepState(statePath, touched, now);
452
+ return posted;
453
+ }
@@ -56,6 +56,7 @@ exports.validateCodieArgs = validateCodieArgs;
56
56
  const path = __importStar(require("path"));
57
57
  const url_1 = require("url");
58
58
  const DEFAULT_NAMES = {
59
+ "openai/gpt-5.6-luna": "GPT-5.6 Luna",
59
60
  "openai/gpt-5.3-codex": "GPT-5.3 Codex",
60
61
  "openai/gpt-5.2-codex": "GPT-5.2 Codex",
61
62
  "openai/gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
package/dist/hook.js CHANGED
@@ -35,6 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.buildDiscoveryContext = buildDiscoveryContext;
38
+ const captureSignals_1 = require("./captureSignals");
39
+ const transcriptModels_1 = require("./transcriptModels");
38
40
  const fs = __importStar(require("fs"));
39
41
  const os = __importStar(require("os"));
40
42
  const https = __importStar(require("https"));
@@ -42,6 +44,7 @@ const http = __importStar(require("http"));
42
44
  const child_process_1 = require("child_process");
43
45
  const utils_1 = require("./utils");
44
46
  const limitEventReporter_1 = require("./limitEventReporter");
47
+ const codexLimitScan_1 = require("./codexLimitScan");
45
48
  const version_1 = require("./version");
46
49
  const captureLog_1 = require("./captureLog");
47
50
  const security_1 = require("./security");
@@ -148,10 +151,100 @@ async function runStopFailure(hookInput) {
148
151
  await readAndPostLimitEvent(hookInput, "claude-code", true);
149
152
  process.exit(0);
150
153
  }
154
+ // Codex path. Two things differ from Claude Code:
155
+ //
156
+ // 1. There is no event that fires when a credit/rate-limit block kills a turn.
157
+ // Codex skips its Stop hook for exactly that turn (measured: every normal
158
+ // task_complete pairs with a Stop-hook run, the blocked one never does),
159
+ // and its HookEventName enum has no StopFailure equivalent. So the block is
160
+ // reported from the developer's NEXT prompt instead — see runCodexLimitSweep.
161
+ // 2. Rollouts run to tens of megabytes and keep growing after the block, so a
162
+ // fixed tail read loses the marker (in one real 44 MB rollout it sat 1.13 MB
163
+ // from the end). Scan backwards for it instead.
164
+ async function reportCodexLimitFromFile(config, transcriptPath, sessionId) {
165
+ const hit = (0, codexLimitScan_1.scanCodexRolloutForLimit)(transcriptPath);
166
+ if (!hit)
167
+ return false;
168
+ await (0, limitEventReporter_1.postLimitEvent)(config, {
169
+ source: "codex",
170
+ ...hit.event,
171
+ sessionId: sessionId ?? (0, codexLimitScan_1.sessionIdFromRolloutPath)(transcriptPath),
172
+ transcriptTail: (0, codexLimitScan_1.readTailEndingAt)(transcriptPath, hit.lineEnd),
173
+ });
174
+ // Tell the prompt sweep this one is done, so it neither re-scans the
175
+ // rollout nor re-posts an event the server would only deduplicate.
176
+ (0, codexLimitScan_1.recordCodexLimitReported)(transcriptPath, hit.event.at);
177
+ return true;
178
+ }
151
179
  async function runCodexLimit(hookInput) {
152
- await readAndPostLimitEvent(hookInput, "codex", false);
180
+ const config = (0, utils_1.readConfig)();
181
+ const transcriptPath = hookInput.transcript_path;
182
+ if (config && transcriptPath) {
183
+ try {
184
+ const sent = await reportCodexLimitFromFile(config, transcriptPath, hookInput.session_id);
185
+ if (sent) {
186
+ (0, captureLog_1.appendCaptureLog)({
187
+ stage: "limit",
188
+ sessionId: hookInput.session_id,
189
+ outcome: "ok",
190
+ detail: "source=codex trigger=stop",
191
+ });
192
+ }
193
+ }
194
+ catch (err) {
195
+ (0, captureLog_1.appendCaptureLog)({
196
+ stage: "limit",
197
+ sessionId: hookInput.session_id,
198
+ outcome: "ERROR",
199
+ detail: `source=codex trigger=stop ${err.message}`,
200
+ });
201
+ }
202
+ }
153
203
  process.exit(0);
154
204
  }
205
+ const PROMPT_LIMIT_POST_TIMEOUT_MS = 2_500;
206
+ // UserPromptSubmit: the first Codiedev hook that runs after a block, because
207
+ // Codex runs no hook at the block itself. Scans the recent rollouts for any
208
+ // block not yet reported. Cheap in the normal case — a per-file size cache
209
+ // means an unchanged, unblocked rollout costs one statSync.
210
+ async function runCodexLimitSweep(sessionId) {
211
+ const config = (0, utils_1.readConfig)();
212
+ if (!config)
213
+ return;
214
+ try {
215
+ const posted = await (0, codexLimitScan_1.sweepCodexLimits)({
216
+ sessionsDir: (0, codexLimitScan_1.codexSessionsDir)(),
217
+ // A shorter timeout than the default: this is on the user's prompt
218
+ // clock, and the next prompt retries anything that does not land.
219
+ post: (payload) => (0, limitEventReporter_1.postLimitEvent)(config, payload, PROMPT_LIMIT_POST_TIMEOUT_MS),
220
+ // This runs on the prompt path, so keep it bounded. Unchanged rollouts
221
+ // cost one statSync and do not spend the read budget, so a busy machine
222
+ // cannot bury an unreported block behind newer idle sessions. When a
223
+ // block is still unreported it sits at the END of its rollout, so a
224
+ // small scan window is enough; the Stop path keeps the larger default
225
+ // for a session that kept going after the window reset.
226
+ findOptions: { withinMs: 24 * 3600_000 },
227
+ maxScans: 8,
228
+ scanOptions: { maxScanBytes: 4 * 1024 * 1024 },
229
+ });
230
+ if (posted > 0) {
231
+ (0, captureLog_1.appendCaptureLog)({
232
+ stage: "limit",
233
+ sessionId,
234
+ outcome: "ok",
235
+ detail: `source=codex trigger=prompt events=${posted}`,
236
+ });
237
+ }
238
+ }
239
+ catch (err) {
240
+ (0, captureLog_1.appendCaptureLog)({
241
+ stage: "limit",
242
+ sessionId,
243
+ outcome: "ERROR",
244
+ detail: `source=codex trigger=prompt ${err.message}`,
245
+ });
246
+ }
247
+ }
155
248
  // Skip-reason ping: this session is ending WITHOUT a capture, and here's why.
156
249
  // Turns silent coverage loss into per-reason buckets on the admin Capture
157
250
  // Health panel (patches the session's aiSessionStarts row server-side). Tiny
@@ -646,6 +739,10 @@ async function main() {
646
739
  if (captureIgnored)
647
740
  return;
648
741
  dispatchAllowedPromptSecurityEvent(hookInput, source);
742
+ // A Codex block leaves no hook of its own, so this prompt is the first
743
+ // chance to report it. Runs before runDiscover, which exits the process.
744
+ if (source === "codex")
745
+ await runCodexLimitSweep(hookInput.session_id);
649
746
  await runDiscover(hookInput.prompt);
650
747
  return;
651
748
  }
@@ -970,6 +1067,8 @@ async function runCaptureFinishInner(input, config, session_id, transcript_path,
970
1067
  cacheReadTokens: usage.cacheReadTokens,
971
1068
  cacheCreationTokens: usage.cacheCreationTokens,
972
1069
  model: usage.model,
1070
+ captureSignals: (0, captureSignals_1.parseCaptureSignals)(mode === "desktop" || mode === "opencode" ? rawTranscriptContent : transcriptContent, mode),
1071
+ modelUsage: (0, transcriptModels_1.parseTranscriptModels)(mode === "desktop" || mode === "opencode" ? rawTranscriptContent : transcriptContent, mode),
973
1072
  sessionStartedAt,
974
1073
  interruptCount: steering.interrupts,
975
1074
  rejectionCount: steering.rejections,
@@ -0,0 +1,18 @@
1
+ /** Compact per-model usage, derived from captured transcript records. */
2
+ export type TranscriptModelUsage = {
3
+ model: string;
4
+ /** Assistant message IDs; nonzero cumulative usage events for Codex. */
5
+ requests: number;
6
+ inputTokens: number;
7
+ outputTokens: number;
8
+ cacheReadTokens: number;
9
+ cacheCreationTokens: number;
10
+ costUsd?: number;
11
+ /** Reported provider figure, retained separately from trusted billing inputs. */
12
+ reportedCostUsd?: number;
13
+ /** Reported reasoning is included in outputTokens, never added again. */
14
+ reasoningTokens?: number;
15
+ };
16
+ export declare function transcriptRecords(content: string, source?: string): Generator<unknown>;
17
+ export declare function parseTranscriptModels(content: string, source?: string): TranscriptModelUsage[];
18
+ export declare function totalModelTokens(row: Omit<TranscriptModelUsage, "model" | "requests">): number;
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.transcriptRecords = transcriptRecords;
4
+ exports.parseTranscriptModels = parseTranscriptModels;
5
+ exports.totalModelTokens = totalModelTokens;
6
+ const positive = (n) => typeof n === "number" && Number.isFinite(n) ? Math.max(0, n) : 0;
7
+ const empty = (model) => ({
8
+ model,
9
+ requests: 0,
10
+ inputTokens: 0,
11
+ outputTokens: 0,
12
+ cacheReadTokens: 0,
13
+ cacheCreationTokens: 0,
14
+ });
15
+ function* transcriptRecords(content, source) {
16
+ if (content.trimStart().startsWith("[") ||
17
+ ((source === "opencode" || source === "desktop") &&
18
+ content.trimStart().startsWith("{"))) {
19
+ try {
20
+ const parsed = JSON.parse(content);
21
+ const list = Array.isArray(parsed) ? parsed : parsed?.data;
22
+ if (Array.isArray(list)) {
23
+ yield* list;
24
+ return;
25
+ }
26
+ }
27
+ catch {
28
+ /* Fall through to JSONL for partial captures. */
29
+ }
30
+ }
31
+ let start = 0;
32
+ while (start < content.length) {
33
+ const newline = content.indexOf("\n", start);
34
+ const end = newline < 0 ? content.length : newline;
35
+ try {
36
+ yield JSON.parse(content.slice(start, end));
37
+ }
38
+ catch {
39
+ /* partial or non-JSON line */
40
+ }
41
+ start = end + 1;
42
+ }
43
+ }
44
+ function parseTranscriptModels(content, source) {
45
+ const buckets = new Map();
46
+ const messages = new Map();
47
+ let model = "Unattributed";
48
+ let previous = {
49
+ inputTokens: 0,
50
+ outputTokens: 0,
51
+ cacheReadTokens: 0,
52
+ cacheCreationTokens: 0,
53
+ };
54
+ let previousReasoning = 0;
55
+ let index = 0;
56
+ for (const record of transcriptRecords(content, source)) {
57
+ const event = record;
58
+ index++;
59
+ if (!event || typeof event !== "object")
60
+ continue;
61
+ let usage = null;
62
+ let messageId = String(index);
63
+ if (source === "codex") {
64
+ if (event.type === "turn_context" &&
65
+ typeof event.payload?.model === "string")
66
+ model = event.payload.model;
67
+ if (event.type !== "event_msg" || event.payload?.type !== "token_count")
68
+ continue;
69
+ const raw = event.payload?.info?.total_token_usage;
70
+ if (!raw)
71
+ continue;
72
+ const total = {
73
+ inputTokens: Math.max(0, positive(raw.input_tokens) - positive(raw.cached_input_tokens)),
74
+ outputTokens: positive(raw.output_tokens),
75
+ cacheReadTokens: positive(raw.cached_input_tokens),
76
+ cacheCreationTokens: 0,
77
+ };
78
+ // Codex reasoning_output_tokens is a subset of output_tokens. Cumulative
79
+ // snapshots must be differenced, including across a model switch.
80
+ usage = { ...empty(model), requests: 1 };
81
+ const reset = positive(raw.input_tokens) <
82
+ previous.inputTokens + previous.cacheReadTokens ||
83
+ total.outputTokens < previous.outputTokens;
84
+ for (const key of [
85
+ "inputTokens",
86
+ "outputTokens",
87
+ "cacheReadTokens",
88
+ "cacheCreationTokens",
89
+ ])
90
+ usage[key] = reset
91
+ ? total[key]
92
+ : Math.max(0, total[key] - previous[key]);
93
+ const reasoning = typeof raw.reasoning_output_tokens === "number" &&
94
+ raw.reasoning_output_tokens >= 0 &&
95
+ raw.reasoning_output_tokens <= total.outputTokens
96
+ ? raw.reasoning_output_tokens
97
+ : undefined;
98
+ if (reasoning !== undefined &&
99
+ (reset || previousReasoning !== undefined)) {
100
+ const delta = reset
101
+ ? reasoning
102
+ : Math.max(0, reasoning - previousReasoning);
103
+ if (delta <= usage.outputTokens)
104
+ usage.reasoningTokens = delta;
105
+ }
106
+ previousReasoning = reasoning;
107
+ previous = total;
108
+ if (!totalModelTokens(usage))
109
+ continue;
110
+ }
111
+ else if (event.info?.role === "assistant") {
112
+ const info = event.info, tokens = info.tokens;
113
+ if (!tokens)
114
+ continue;
115
+ const id = typeof info.modelID === "string" ? info.modelID : "Unattributed";
116
+ model =
117
+ info.providerID && id !== "Unattributed"
118
+ ? `${info.providerID}/${id}`
119
+ : id;
120
+ usage = {
121
+ ...empty(model),
122
+ requests: 1,
123
+ inputTokens: positive(tokens.input),
124
+ outputTokens: positive(tokens.output) + positive(tokens.reasoning),
125
+ ...(typeof tokens.reasoning === "number"
126
+ ? { reasoningTokens: positive(tokens.reasoning) }
127
+ : {}),
128
+ cacheReadTokens: positive(tokens.cache?.read),
129
+ cacheCreationTokens: positive(tokens.cache?.write),
130
+ ...(typeof info.cost === "number"
131
+ ? {
132
+ reportedCostUsd: positive(info.cost),
133
+ ...(source === "desktop" ? { costUsd: positive(info.cost) } : {}),
134
+ }
135
+ : {}),
136
+ };
137
+ messageId = info.id ?? messageId;
138
+ }
139
+ else {
140
+ const message = event.message;
141
+ if (!message?.usage)
142
+ continue;
143
+ model =
144
+ typeof message.model === "string" ? message.model : "Unattributed";
145
+ const raw = message.usage;
146
+ usage = {
147
+ ...empty(model),
148
+ requests: 1,
149
+ inputTokens: positive(raw.input_tokens),
150
+ outputTokens: positive(raw.output_tokens),
151
+ cacheReadTokens: positive(raw.cache_read_input_tokens),
152
+ cacheCreationTokens: positive(raw.cache_creation_input_tokens),
153
+ };
154
+ messageId = message.id ?? event.uuid ?? messageId;
155
+ }
156
+ // Client placeholders are not model executions.
157
+ if (!usage.model.trim() || usage.model.startsWith("<"))
158
+ continue;
159
+ // Streaming snapshots repeat message ids; retain the latest snapshot once.
160
+ messages.set(messageId, usage);
161
+ }
162
+ const incompleteCosts = new Set();
163
+ const incompleteReasoning = new Set();
164
+ const incompleteReportedCosts = new Set();
165
+ for (const usage of messages.values()) {
166
+ const key = buckets.has(usage.model) || buckets.size < 63
167
+ ? usage.model
168
+ : "Unattributed";
169
+ const bucket = buckets.get(key) ?? empty(key);
170
+ for (const field of [
171
+ "requests",
172
+ "inputTokens",
173
+ "outputTokens",
174
+ "cacheReadTokens",
175
+ "cacheCreationTokens",
176
+ ])
177
+ bucket[field] += usage[field];
178
+ if (usage.reasoningTokens !== undefined)
179
+ bucket.reasoningTokens =
180
+ (bucket.reasoningTokens ?? 0) + usage.reasoningTokens;
181
+ else
182
+ incompleteReasoning.add(key);
183
+ if (usage.reportedCostUsd !== undefined)
184
+ bucket.reportedCostUsd =
185
+ (bucket.reportedCostUsd ?? 0) + usage.reportedCostUsd;
186
+ else
187
+ incompleteReportedCosts.add(key);
188
+ if (usage.costUsd !== undefined)
189
+ bucket.costUsd = (bucket.costUsd ?? 0) + usage.costUsd;
190
+ else
191
+ incompleteCosts.add(key);
192
+ buckets.set(key, bucket);
193
+ }
194
+ for (const key of incompleteReportedCosts)
195
+ delete buckets.get(key).reportedCostUsd;
196
+ for (const key of incompleteCosts)
197
+ delete buckets.get(key).costUsd;
198
+ for (const key of incompleteReasoning)
199
+ delete buckets.get(key).reasoningTokens;
200
+ return [...buckets.values()];
201
+ }
202
+ function totalModelTokens(row) {
203
+ return (row.inputTokens +
204
+ row.outputTokens +
205
+ row.cacheReadTokens +
206
+ row.cacheCreationTokens);
207
+ }
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import * as fs from "fs";
2
+ import type { CaptureSignals } from "./captureSignals";
3
+ import type { TranscriptModelUsage } from "./transcriptModels";
2
4
  import { spawn as nodeSpawn } from "child_process";
3
5
  import type { CaptureLogEntry } from "./captureLog";
4
6
  import { codiedevStateDir } from "./configDir";
@@ -59,6 +61,8 @@ export declare function parseAuthoredShas(transcriptContent: string, repoRoot: s
59
61
  export declare function getGitBranch(cwd: string): string | null;
60
62
  export declare function getGitHeadSha(cwd: string): string | null;
61
63
  export interface CapturePayloadInput {
64
+ modelUsage?: TranscriptModelUsage[];
65
+ captureSignals?: CaptureSignals;
62
66
  sessionId?: string;
63
67
  transcriptBase64: string;
64
68
  remoteUrl: string | null;
package/dist/utils.js CHANGED
@@ -854,6 +854,8 @@ function buildCapturePayload(input) {
854
854
  cacheReadTokens: input.cacheReadTokens,
855
855
  cacheCreationTokens: input.cacheCreationTokens,
856
856
  model: input.model ?? undefined,
857
+ modelUsage: input.modelUsage?.length ? input.modelUsage : undefined,
858
+ captureSignals: input.captureSignals,
857
859
  sessionStartedAt: input.sessionStartedAt ?? undefined,
858
860
  interruptCount: input.interruptCount,
859
861
  rejectionCount: input.rejectionCount,
@@ -2195,7 +2197,8 @@ function parseCodexUsage(content) {
2195
2197
  const input = last.input_tokens ?? 0;
2196
2198
  return {
2197
2199
  inputTokens: Math.max(0, input - cached),
2198
- outputTokens: (last.output_tokens ?? 0) + (last.reasoning_output_tokens ?? 0),
2200
+ // Reasoning is included in output_tokens, not additional output.
2201
+ outputTokens: last.output_tokens ?? 0,
2199
2202
  // Codex only reports cached reads; creation is structurally zero.
2200
2203
  cacheTokens: cached,
2201
2204
  cacheReadTokens: cached,
@@ -2399,6 +2402,8 @@ function normalizeOpenCodeTranscript(content) {
2399
2402
  // {type:"response_item", payload:{type:"message", role, content:[{type:"input_text",text}|{type:"output_text",text}]}}
2400
2403
  // {type:"response_item", payload:{type:"function_call", name, arguments, call_id}}
2401
2404
  // {type:"response_item", payload:{type:"function_call_output", call_id, output}}
2405
+ // {type:"response_item", payload:{type:"custom_tool_call", name, input, call_id}}
2406
+ // {type:"response_item", payload:{type:"custom_tool_call_output", call_id, output}}
2402
2407
  // {type:"event_msg", payload:{type:"token_count"|"agent_reasoning"|...}}
2403
2408
  // {type:"turn_context", payload:{cwd, model, ...}}
2404
2409
  function parseCodexStats(content) {
@@ -2416,7 +2421,7 @@ function parseCodexStats(content) {
2416
2421
  if (payloadType === "message") {
2417
2422
  messageCount++;
2418
2423
  }
2419
- else if (payloadType === "function_call") {
2424
+ else if (payloadType === "function_call" || payloadType === "custom_tool_call") {
2420
2425
  toolCallCount++;
2421
2426
  }
2422
2427
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.10.0-beta.1",
3
+ "version": "0.10.0-beta.2",
4
4
  "description": "Connect Claude Code, Codex, Cursor, OpenCode, or VS Code Copilot to Codiedev for team session capture and artifact collaboration — and run the governed Codie harness",
5
5
  "bin": {
6
6
  "codiedev": "dist/cli.js",