codiedev 0.9.55 → 0.9.57
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/assets/tui-brand-plugin.tsx +61 -0
- package/dist/captureLog.d.ts +1 -1
- package/dist/codexLimitScan.d.ts +64 -0
- package/dist/codexLimitScan.js +466 -0
- package/dist/codie.js +33 -27
- package/dist/codieBrandInstall.d.ts +11 -0
- package/dist/codieBrandInstall.js +98 -0
- package/dist/codieLauncher.d.ts +33 -0
- package/dist/codieLauncher.js +77 -9
- package/dist/codieUsage.d.ts +57 -0
- package/dist/codieUsage.js +184 -0
- package/dist/hook.js +93 -1
- package/dist/limitEvent.js +26 -1
- package/package.json +1 -1
|
@@ -4,11 +4,16 @@
|
|
|
4
4
|
// over the product name, with a "powered by opencode" credit line beneath.
|
|
5
5
|
// Installed and kept current by `codie`; deleting this file and its tui.json
|
|
6
6
|
// entry restores the stock logo.
|
|
7
|
+
// Also paints the governed status line beside the prompt: who is spending,
|
|
8
|
+
// how much of which budget, and what is in flight. The numbers and the
|
|
9
|
+
// wording come from the codiedev package's tested usage module, whose
|
|
10
|
+
// absolute path `codie` bakes in when it stages this file.
|
|
7
11
|
// Mark ported from the retired Grok-fork TUI (assets/logo/logo07.txt), itself
|
|
8
12
|
// derived from vm-demo/public/codiedev-icon.png — keep aligned to that
|
|
9
13
|
// intertwined c/d silhouette when refreshing brand assets.
|
|
10
14
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
11
15
|
import { TextAttributes } from "@opentui/core"
|
|
16
|
+
import { createSignal } from "solid-js"
|
|
12
17
|
|
|
13
18
|
const MARK = [
|
|
14
19
|
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿",
|
|
@@ -20,6 +25,56 @@ const MARK = [
|
|
|
20
25
|
"⠀⠈⠻⢿⣿⣿⣿⠿⠋⠀⠀⠙⠿⣿⣿⣿⡿⠟⠁",
|
|
21
26
|
]
|
|
22
27
|
|
|
28
|
+
// One poller per TUI process, shared by every slot that shows the line. A
|
|
29
|
+
// failure anywhere here costs the status line and nothing else: the home
|
|
30
|
+
// screen still brands, and the developer still codes.
|
|
31
|
+
const REFRESH_MS = 30_000
|
|
32
|
+
|
|
33
|
+
const [usageLine, setUsageLine] = createSignal("")
|
|
34
|
+
|
|
35
|
+
async function startUsagePolling() {
|
|
36
|
+
let usage: any
|
|
37
|
+
try {
|
|
38
|
+
// Imported here, not at the top of the file. This plugin is copied
|
|
39
|
+
// outside the package, so its resolution is the engine loader's business:
|
|
40
|
+
// a top-level import that fails takes the whole module with it, and the
|
|
41
|
+
// developer silently loses the brand mark that worked before. solid-js
|
|
42
|
+
// stays a static import because @opentui/solid is already this file's JSX
|
|
43
|
+
// runtime — if Solid were missing the logo would be gone regardless.
|
|
44
|
+
const { createRequire } = await import("module")
|
|
45
|
+
usage = createRequire(import.meta.url)(__CODIEDEV_USAGE_MODULE__)
|
|
46
|
+
} catch {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
const key = process.env.CODIEDEV_GATEWAY_KEY
|
|
50
|
+
if (!key || typeof usage?.fetchCodieUsage !== "function") return
|
|
51
|
+
const poll = async () => {
|
|
52
|
+
try {
|
|
53
|
+
const snapshot = await usage.fetchCodieUsage({
|
|
54
|
+
baseUrl: process.env.CODIEDEV_GATEWAY_URL,
|
|
55
|
+
key,
|
|
56
|
+
})
|
|
57
|
+
// A failed poll leaves the last good line up rather than blanking it:
|
|
58
|
+
// a number that stopped updating beats a number that vanished.
|
|
59
|
+
if (snapshot) setUsageLine(usage.formatUsageLine(snapshot))
|
|
60
|
+
} catch {}
|
|
61
|
+
}
|
|
62
|
+
void poll()
|
|
63
|
+
const timer = setInterval(poll, REFRESH_MS)
|
|
64
|
+
// Never hold the process open for a status poll.
|
|
65
|
+
;(timer as any).unref?.()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
void startUsagePolling().catch(() => {})
|
|
69
|
+
|
|
70
|
+
function StatusLine(props: { theme: any }) {
|
|
71
|
+
return (
|
|
72
|
+
<text fg={props.theme.textMuted} selectable={false}>
|
|
73
|
+
{usageLine()}
|
|
74
|
+
</text>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
23
78
|
const tui: TuiPlugin = async (api) => {
|
|
24
79
|
api.slots.register({
|
|
25
80
|
slots: {
|
|
@@ -51,6 +106,12 @@ const tui: TuiPlugin = async (api) => {
|
|
|
51
106
|
</box>
|
|
52
107
|
)
|
|
53
108
|
},
|
|
109
|
+
home_prompt_right(ctx) {
|
|
110
|
+
return <StatusLine theme={ctx.theme.current} />
|
|
111
|
+
},
|
|
112
|
+
session_prompt_right(ctx) {
|
|
113
|
+
return <StatusLine theme={ctx.theme.current} />
|
|
114
|
+
},
|
|
54
115
|
},
|
|
55
116
|
})
|
|
56
117
|
}
|
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" | "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
|
+
}
|