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.
- package/dist/captureLog.d.ts +1 -1
- package/dist/captureSignals.d.ts +19 -0
- package/dist/captureSignals.js +119 -0
- package/dist/codexLimitScan.d.ts +62 -0
- package/dist/codexLimitScan.js +453 -0
- package/dist/codieLauncher.js +1 -0
- package/dist/hook.js +100 -1
- package/dist/transcriptModels.d.ts +18 -0
- package/dist/transcriptModels.js +207 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.js +7 -2
- package/package.json +1 -1
package/dist/captureLog.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/codieLauncher.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|