codiedev 0.9.55 → 0.9.56

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" | "stderr";
5
+ stage: "capture-finish" | "upload" | "upload-deferred" | "stderr" | "limit";
6
6
  sessionId: string | undefined;
7
7
  outcome: string;
8
8
  detail?: string;
@@ -0,0 +1,64 @@
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
+ /** Stop starting new scans once this many ms have elapsed. */
49
+ deadlineMs?: number;
50
+ }
51
+ /**
52
+ * Report every Codex block this machine has not reported yet. Returns how many
53
+ * events were posted. Never throws — it runs inside a hook.
54
+ *
55
+ * State is kept PER FILE, not as one newest-wins timestamp. A single global
56
+ * watermark loses data: rollouts are capped per sweep, so a block in a file
57
+ * outside the cap can be older than one already reported and would then be
58
+ * suppressed forever. A per-file record can only ever cause a re-report, which
59
+ * the server deduplicates on (companyId, userId, at).
60
+ *
61
+ * A file is recorded only after its POST succeeds, so a failed send is retried
62
+ * on the next prompt.
63
+ */
64
+ export declare function sweepCodexLimits(args: SweepArgs): Promise<number>;
@@ -0,0 +1,466 @@
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
+ //
386
+ // The budget binds only on a COLD START, where nothing has been seen before,
387
+ // so it must be big enough to finish that first pass. A budget of 8 silently
388
+ // starved the older blocked rollouts on a real machine: 49 candidates in the
389
+ // window, the newest 8 all unblocked, nothing reported. A full pass over
390
+ // those 49 costs ~205ms — the backward scan stops at the marker rather than
391
+ // reading each file to its cap — so the budget can afford to cover them.
392
+ const DEFAULT_CANDIDATES = 200;
393
+ const DEFAULT_MAX_SCANS = 64;
394
+ // The budget bounds how MANY files are read; this bounds how LONG. A count is
395
+ // not enough on a slow or network-mounted disk, and this runs on the user's
396
+ // prompt clock. Whatever is not reached stays unrecorded, so the next prompt
397
+ // picks it up rather than skipping it.
398
+ const DEFAULT_DEADLINE_MS = 1_500;
399
+ /**
400
+ * Report every Codex block this machine has not reported yet. Returns how many
401
+ * events were posted. Never throws — it runs inside a hook.
402
+ *
403
+ * State is kept PER FILE, not as one newest-wins timestamp. A single global
404
+ * watermark loses data: rollouts are capped per sweep, so a block in a file
405
+ * outside the cap can be older than one already reported and would then be
406
+ * suppressed forever. A per-file record can only ever cause a re-report, which
407
+ * the server deduplicates on (companyId, userId, at).
408
+ *
409
+ * A file is recorded only after its POST succeeds, so a failed send is retried
410
+ * on the next prompt.
411
+ */
412
+ async function sweepCodexLimits(args) {
413
+ const statePath = args.statePath ?? exports.CODEX_LIMIT_STATE_PATH;
414
+ const state = readSweepState(statePath);
415
+ const files = findRecentCodexRollouts(args.sessionsDir, {
416
+ maxFiles: DEFAULT_CANDIDATES,
417
+ ...args.findOptions,
418
+ });
419
+ const now = Date.now();
420
+ const touched = {};
421
+ let budget = args.maxScans ?? DEFAULT_MAX_SCANS;
422
+ const deadline = now + (args.deadlineMs ?? DEFAULT_DEADLINE_MS);
423
+ const pending = [];
424
+ for (const file of files) {
425
+ const print = fingerprint(file);
426
+ if (!print)
427
+ continue;
428
+ const record = state.seen[file];
429
+ // Settled by the stat alone — it has not changed since the last scan, so
430
+ // it holds nothing new and does not spend the read budget.
431
+ if (record && record.size === print.size && record.mtime === print.mtime)
432
+ continue;
433
+ if (budget <= 0 || Date.now() >= deadline)
434
+ break;
435
+ budget -= 1;
436
+ const hit = scanCodexRolloutForLimit(file, args.scanOptions);
437
+ if (!hit) {
438
+ touched[file] = { ...print, at: null, t: now };
439
+ continue;
440
+ }
441
+ if (record && record.at === hit.event.at) {
442
+ // Same block, already reported; the file just grew.
443
+ touched[file] = { ...print, at: record.at, t: now };
444
+ continue;
445
+ }
446
+ pending.push({ file, print, hit });
447
+ }
448
+ // Posted concurrently: each file is independent, and this runs on the
449
+ // prompt path where a serial chain of network timeouts would be felt.
450
+ const results = await Promise.allSettled(pending.map(({ file, hit }) => args.post({
451
+ source: "codex",
452
+ ...hit.event,
453
+ sessionId: sessionIdFromRolloutPath(file),
454
+ transcriptTail: readTailEndingAt(file, hit.lineEnd),
455
+ })));
456
+ let posted = 0;
457
+ results.forEach((result, index) => {
458
+ if (result.status !== "fulfilled")
459
+ return;
460
+ const { file, print, hit } = pending[index];
461
+ touched[file] = { ...print, at: hit.event.at, t: now };
462
+ posted += 1;
463
+ });
464
+ commitSweepState(statePath, touched, now);
465
+ return posted;
466
+ }
package/dist/hook.js CHANGED
@@ -44,6 +44,7 @@ const http = __importStar(require("http"));
44
44
  const child_process_1 = require("child_process");
45
45
  const utils_1 = require("./utils");
46
46
  const limitEventReporter_1 = require("./limitEventReporter");
47
+ const codexLimitScan_1 = require("./codexLimitScan");
47
48
  const version_1 = require("./version");
48
49
  const captureLog_1 = require("./captureLog");
49
50
  const captureExclusions_1 = require("./captureExclusions");
@@ -143,10 +144,96 @@ async function runStopFailure(hookInput) {
143
144
  await readAndPostLimitEvent(hookInput, "claude-code", true);
144
145
  process.exit(0);
145
146
  }
147
+ // Codex path. Two things differ from Claude Code:
148
+ //
149
+ // 1. There is no event that fires when a credit/rate-limit block kills a turn.
150
+ // Codex skips its Stop hook for exactly that turn (measured: every normal
151
+ // task_complete pairs with a Stop-hook run, the blocked one never does),
152
+ // and its HookEventName enum has no StopFailure equivalent. So the block is
153
+ // reported from the developer's NEXT prompt instead — see runCodexLimitSweep.
154
+ // 2. Rollouts run to tens of megabytes and keep growing after the block, so a
155
+ // fixed tail read loses the marker (in one real 44 MB rollout it sat 1.13 MB
156
+ // from the end). Scan backwards for it instead.
157
+ async function reportCodexLimitFromFile(config, transcriptPath, sessionId) {
158
+ const hit = (0, codexLimitScan_1.scanCodexRolloutForLimit)(transcriptPath);
159
+ if (!hit)
160
+ return false;
161
+ await (0, limitEventReporter_1.postLimitEvent)(config, {
162
+ source: "codex",
163
+ ...hit.event,
164
+ sessionId: sessionId ?? (0, codexLimitScan_1.sessionIdFromRolloutPath)(transcriptPath),
165
+ transcriptTail: (0, codexLimitScan_1.readTailEndingAt)(transcriptPath, hit.lineEnd),
166
+ });
167
+ // Tell the prompt sweep this one is done, so it neither re-scans the
168
+ // rollout nor re-posts an event the server would only deduplicate.
169
+ (0, codexLimitScan_1.recordCodexLimitReported)(transcriptPath, hit.event.at);
170
+ return true;
171
+ }
146
172
  async function runCodexLimit(hookInput) {
147
- await readAndPostLimitEvent(hookInput, "codex", false);
173
+ const config = (0, utils_1.readConfig)();
174
+ const transcriptPath = hookInput.transcript_path;
175
+ if (config && transcriptPath) {
176
+ try {
177
+ const sent = await reportCodexLimitFromFile(config, transcriptPath, hookInput.session_id);
178
+ if (sent) {
179
+ (0, captureLog_1.appendCaptureLog)({
180
+ stage: "limit",
181
+ sessionId: hookInput.session_id,
182
+ outcome: "ok",
183
+ detail: "source=codex trigger=stop",
184
+ });
185
+ }
186
+ }
187
+ catch (err) {
188
+ (0, captureLog_1.appendCaptureLog)({
189
+ stage: "limit",
190
+ sessionId: hookInput.session_id,
191
+ outcome: "ERROR",
192
+ detail: `source=codex trigger=stop ${err.message}`,
193
+ });
194
+ }
195
+ }
148
196
  process.exit(0);
149
197
  }
198
+ const PROMPT_LIMIT_POST_TIMEOUT_MS = 2_500;
199
+ // UserPromptSubmit: the first Codiedev hook that runs after a block, because
200
+ // Codex runs no hook at the block itself. Scans the recent rollouts for any
201
+ // block not yet reported. Cheap in the normal case — a per-file size cache
202
+ // means an unchanged, unblocked rollout costs one statSync.
203
+ async function runCodexLimitSweep(sessionId) {
204
+ const config = (0, utils_1.readConfig)();
205
+ if (!config)
206
+ return;
207
+ try {
208
+ const posted = await (0, codexLimitScan_1.sweepCodexLimits)({
209
+ sessionsDir: (0, codexLimitScan_1.codexSessionsDir)(),
210
+ // A shorter timeout than the default: this is on the user's prompt
211
+ // clock, and the next prompt retries anything that does not land.
212
+ post: (payload) => (0, limitEventReporter_1.postLimitEvent)(config, payload, PROMPT_LIMIT_POST_TIMEOUT_MS),
213
+ // Unchanged rollouts cost one statSync and do not spend the read
214
+ // budget, so in steady state this is nearly free. The module default
215
+ // covers a cold start; see DEFAULT_MAX_SCANS for why it is not small.
216
+ findOptions: { withinMs: 24 * 3600_000 },
217
+ scanOptions: { maxScanBytes: 4 * 1024 * 1024 },
218
+ });
219
+ if (posted > 0) {
220
+ (0, captureLog_1.appendCaptureLog)({
221
+ stage: "limit",
222
+ sessionId,
223
+ outcome: "ok",
224
+ detail: `source=codex trigger=prompt events=${posted}`,
225
+ });
226
+ }
227
+ }
228
+ catch (err) {
229
+ (0, captureLog_1.appendCaptureLog)({
230
+ stage: "limit",
231
+ sessionId,
232
+ outcome: "ERROR",
233
+ detail: `source=codex trigger=prompt ${err.message}`,
234
+ });
235
+ }
236
+ }
150
237
  // Skip-reason ping: this session is ending WITHOUT a capture, and here's why.
151
238
  // Turns silent coverage loss into per-reason buckets on the admin Capture
152
239
  // Health panel (patches the session's aiSessionStarts row server-side). Tiny
@@ -454,6 +541,11 @@ async function main() {
454
541
  if (process.argv[2] === "discover" ||
455
542
  process.argv[2] === "discover-codex" ||
456
543
  process.argv[2] === "discover-codie") {
544
+ // A Codex block leaves no hook of its own, so this prompt is the first
545
+ // chance to report it. Runs before runDiscover, which exits the process.
546
+ if (process.argv[2] === "discover-codex") {
547
+ await runCodexLimitSweep(hookInput.session_id);
548
+ }
457
549
  await runDiscover(hookInput.prompt);
458
550
  return;
459
551
  }
@@ -51,11 +51,36 @@ function parseLimitEventFromTranscript(transcript) {
51
51
  }
52
52
  return found;
53
53
  }
54
+ /**
55
+ * Codex's `rate_limit_reached_type` is exactly five values (read off the 0.154
56
+ * binary): `rate_limit_reached`, `workspace_{owner,member}_credits_depleted`
57
+ * and `workspace_{owner,member}_usage_limit_reached`.
58
+ *
59
+ * The split that matters to a reader of the dashboard is "someone must act on
60
+ * money" vs "this dev is throttled and it will reset" — so map on what the
61
+ * snapshot actually asserts, not on the word "credits":
62
+ *
63
+ * - A member out of their OWN allowance is a rate limit. Real snapshots carry
64
+ * `spend_control_reached: null` and `individual_limit: null` — nobody
65
+ * configured a budget, the window is simply used up. Calling that personal
66
+ * spend tells a manager the dev overspent, which is not what happened.
67
+ *
68
+ * Deliberately NOT gated on `individual_limit`: it has only ever been
69
+ * observed null, so its populated shape and meaning are unknown, and a
70
+ * wrong guess reinstates the mislabel this exists to remove. The reason
71
+ * string is the causal signal; `individual_limit` is at best correlative.
72
+ * - A member whose ADMIN capped them (`usage_limit_reached`) is personal spend:
73
+ * there really is a cap, and someone can raise it.
74
+ * - Anything owner-scoped is org spend — the org's allowance or credits are
75
+ * gone and somebody has to act.
76
+ */
54
77
  function codexLimitKind(reason, spendControlReached) {
55
78
  if (spendControlReached || reason.startsWith("workspace_owner_"))
56
79
  return "org_spend";
57
- if (reason.startsWith("workspace_member_"))
80
+ if (reason === "workspace_member_usage_limit_reached")
58
81
  return "user_spend";
82
+ if (reason === "workspace_member_credits_depleted")
83
+ return "session";
59
84
  return reason === "rate_limit_reached" ? "session" : "other";
60
85
  }
61
86
  function codexResetText(rateLimits) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.9.55",
3
+ "version": "0.9.56",
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",