@made-by-moonlight/athene-plugin-agent-codex 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/dist/app-server-client.d.ts +139 -0
- package/dist/app-server-client.d.ts.map +1 -0
- package/dist/app-server-client.js +360 -0
- package/dist/app-server-client.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +807 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
import { DEFAULT_READY_THRESHOLD_MS, DEFAULT_ACTIVE_WINDOW_MS, shellEscape, readLastJsonlEntry, normalizeAgentPermissionMode, readLastActivityEntry, checkActivityLogState, getActivityFallbackState, recordTerminalActivity, isWindows, PROCESS_PROBE_INDETERMINATE, } from "@made-by-moonlight/athene-core";
|
|
2
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { readdir, stat, lstat, open } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
import { StringDecoder } from "node:string_decoder";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Plugin Manifest
|
|
13
|
+
// =============================================================================
|
|
14
|
+
export const manifest = {
|
|
15
|
+
name: "codex",
|
|
16
|
+
slot: "agent",
|
|
17
|
+
description: "Agent plugin: OpenAI Codex CLI",
|
|
18
|
+
version: "0.1.1",
|
|
19
|
+
displayName: "OpenAI Codex",
|
|
20
|
+
};
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Workspace Setup (delegates to shared PATH-wrapper hooks from @made-by-moonlight/athene-core)
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Codex Session JSONL Parsing (for getSessionInfo)
|
|
26
|
+
// =============================================================================
|
|
27
|
+
/** Codex session directory: ~/.codex/sessions/ */
|
|
28
|
+
const CODEX_SESSIONS_DIR = join(homedir(), ".codex", "sessions");
|
|
29
|
+
const SESSION_MATCH_SCAN_CHUNK_BYTES = 8192;
|
|
30
|
+
const SESSION_MATCH_SCAN_LINE_LIMIT = 10;
|
|
31
|
+
function getCodexPayload(entry) {
|
|
32
|
+
return entry.payload ?? entry;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Collect all JSONL files under a directory, recursively.
|
|
36
|
+
* Codex stores sessions in date-sharded directories:
|
|
37
|
+
* ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
|
|
38
|
+
*
|
|
39
|
+
* Uses lstat (not stat) so symlinks to directories are never followed,
|
|
40
|
+
* preventing infinite loops from symlink cycles. Max depth is capped at 4
|
|
41
|
+
* (YYYY/MM/DD + 1 buffer) as an additional safety guard.
|
|
42
|
+
*/
|
|
43
|
+
const MAX_SESSION_SCAN_DEPTH = 4;
|
|
44
|
+
async function collectJsonlFiles(dir, depth = 0) {
|
|
45
|
+
if (depth > MAX_SESSION_SCAN_DEPTH)
|
|
46
|
+
return [];
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = await readdir(dir);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const results = [];
|
|
55
|
+
for (const entry of entries) {
|
|
56
|
+
const fullPath = join(dir, entry);
|
|
57
|
+
if (entry.endsWith(".jsonl")) {
|
|
58
|
+
results.push(fullPath);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// Recurse into subdirectories (YYYY/MM/DD structure).
|
|
62
|
+
// Use lstat to avoid following symlinks that could create cycles.
|
|
63
|
+
try {
|
|
64
|
+
const s = await lstat(fullPath);
|
|
65
|
+
if (s.isDirectory()) {
|
|
66
|
+
const nested = await collectJsonlFiles(fullPath, depth + 1);
|
|
67
|
+
results.push(...nested);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Skip inaccessible entries
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return results;
|
|
76
|
+
}
|
|
77
|
+
async function readJsonlPrefixLines(filePath, maxLines) {
|
|
78
|
+
const handle = await open(filePath, "r");
|
|
79
|
+
const lines = [];
|
|
80
|
+
let partialLine = "";
|
|
81
|
+
// Reuse a single decoder across reads so multi-byte UTF-8 sequences that
|
|
82
|
+
// straddle a chunk boundary (e.g. CJK characters in base_instructions) get
|
|
83
|
+
// buffered correctly instead of producing U+FFFD replacement characters.
|
|
84
|
+
const decoder = new StringDecoder("utf8");
|
|
85
|
+
try {
|
|
86
|
+
while (lines.length < maxLines) {
|
|
87
|
+
const buffer = Buffer.allocUnsafe(SESSION_MATCH_SCAN_CHUNK_BYTES);
|
|
88
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
|
89
|
+
if (bytesRead === 0) {
|
|
90
|
+
partialLine += decoder.end();
|
|
91
|
+
const finalLine = partialLine.trim();
|
|
92
|
+
if (finalLine)
|
|
93
|
+
lines.push(finalLine);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
partialLine += decoder.write(buffer.subarray(0, bytesRead));
|
|
97
|
+
let newlineIndex = partialLine.indexOf("\n");
|
|
98
|
+
while (newlineIndex !== -1 && lines.length < maxLines) {
|
|
99
|
+
const line = partialLine.slice(0, newlineIndex).trim();
|
|
100
|
+
if (line)
|
|
101
|
+
lines.push(line);
|
|
102
|
+
partialLine = partialLine.slice(newlineIndex + 1);
|
|
103
|
+
newlineIndex = partialLine.indexOf("\n");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await handle.close();
|
|
109
|
+
}
|
|
110
|
+
return lines;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Normalize a path for cross-platform comparison. Codex's JSONL may emit
|
|
114
|
+
* forward-slash paths or vary drive-letter case on Windows; AO constructs
|
|
115
|
+
* workspace paths via path.join which yields backslashes on Windows. Compare
|
|
116
|
+
* via a canonical form: forward slashes throughout, lowercased drive letter.
|
|
117
|
+
*/
|
|
118
|
+
function toComparablePath(p) {
|
|
119
|
+
const slash = p.replace(/\\/g, "/");
|
|
120
|
+
return slash.replace(/^([a-zA-Z]):/, (_, d) => d.toLowerCase() + ":");
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Check if the first few complete JSONL records of a session file contain a
|
|
124
|
+
* session_meta entry matching the given workspace path. This avoids parsing a
|
|
125
|
+
* truncated session_meta line when Codex embeds large base_instructions.
|
|
126
|
+
*/
|
|
127
|
+
async function sessionFileMatchesCwd(filePath, workspacePath) {
|
|
128
|
+
const wantedCwd = toComparablePath(workspacePath);
|
|
129
|
+
try {
|
|
130
|
+
const lines = await readJsonlPrefixLines(filePath, SESSION_MATCH_SCAN_LINE_LIMIT);
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(line);
|
|
134
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
135
|
+
const entry = parsed;
|
|
136
|
+
const payload = getCodexPayload(entry);
|
|
137
|
+
if (entry.type === "session_meta" &&
|
|
138
|
+
typeof payload.cwd === "string" &&
|
|
139
|
+
toComparablePath(payload.cwd) === wantedCwd) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// Skip malformed lines
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// Unreadable file
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Find Codex session files whose `session_meta` cwd matches the given workspace path.
|
|
156
|
+
* Recursively scans ~/.codex/sessions/ (date-sharded: YYYY/MM/DD/rollout-*.jsonl).
|
|
157
|
+
* Returns the path to the most recently modified matching file, or null.
|
|
158
|
+
*/
|
|
159
|
+
async function findCodexSessionFile(workspacePath, jsonlFiles) {
|
|
160
|
+
jsonlFiles ??= await collectJsonlFiles(CODEX_SESSIONS_DIR);
|
|
161
|
+
if (jsonlFiles.length === 0)
|
|
162
|
+
return null;
|
|
163
|
+
let bestMatch = null;
|
|
164
|
+
for (const filePath of jsonlFiles) {
|
|
165
|
+
const matches = await sessionFileMatchesCwd(filePath, workspacePath);
|
|
166
|
+
if (matches) {
|
|
167
|
+
try {
|
|
168
|
+
const s = await stat(filePath);
|
|
169
|
+
if (!bestMatch || s.mtimeMs > bestMatch.mtime) {
|
|
170
|
+
bestMatch = { path: filePath, mtime: s.mtimeMs };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Skip if stat fails
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return bestMatch?.path ?? null;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Find a Codex session file by persisted native thread id. Codex rollout
|
|
182
|
+
* filenames include the thread id, so this path only inspects filenames and
|
|
183
|
+
* avoids opening historical JSONL files to match session_meta.cwd.
|
|
184
|
+
*/
|
|
185
|
+
async function findCodexSessionFileByThreadId(threadId, jsonlFiles) {
|
|
186
|
+
jsonlFiles ??= await collectJsonlFiles(CODEX_SESSIONS_DIR);
|
|
187
|
+
const matches = jsonlFiles.filter((filePath) => basename(filePath).endsWith(`-${threadId}.jsonl`));
|
|
188
|
+
if (matches.length === 0)
|
|
189
|
+
return null;
|
|
190
|
+
if (matches.length === 1)
|
|
191
|
+
return matches[0] ?? null;
|
|
192
|
+
let bestMatch = null;
|
|
193
|
+
let fallback = null;
|
|
194
|
+
for (const filePath of matches) {
|
|
195
|
+
fallback ??= filePath;
|
|
196
|
+
try {
|
|
197
|
+
const s = await stat(filePath);
|
|
198
|
+
if (!bestMatch || s.mtimeMs > bestMatch.mtime) {
|
|
199
|
+
bestMatch = { path: filePath, mtime: s.mtimeMs };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Keep a filename match as fallback; thread id in the filename is enough.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return bestMatch?.path ?? fallback;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Stream a Codex JSONL session file line-by-line and aggregate the data
|
|
210
|
+
* we need (model, threadId, token counts) without loading the entire file
|
|
211
|
+
* into memory. This is critical because Codex rollout files can be 100 MB+.
|
|
212
|
+
*/
|
|
213
|
+
async function streamCodexSessionData(filePath) {
|
|
214
|
+
let stream = null;
|
|
215
|
+
let rl = null;
|
|
216
|
+
try {
|
|
217
|
+
const data = {
|
|
218
|
+
model: null,
|
|
219
|
+
threadId: null,
|
|
220
|
+
inputTokens: 0,
|
|
221
|
+
outputTokens: 0,
|
|
222
|
+
cachedTokens: 0,
|
|
223
|
+
reasoningTokens: 0,
|
|
224
|
+
};
|
|
225
|
+
stream = createReadStream(filePath, { encoding: "utf-8" });
|
|
226
|
+
rl = createInterface({
|
|
227
|
+
input: stream,
|
|
228
|
+
crlfDelay: Infinity,
|
|
229
|
+
});
|
|
230
|
+
for await (const line of rl) {
|
|
231
|
+
const trimmed = line.trim();
|
|
232
|
+
if (!trimmed)
|
|
233
|
+
continue;
|
|
234
|
+
try {
|
|
235
|
+
const parsed = JSON.parse(trimmed);
|
|
236
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
237
|
+
continue;
|
|
238
|
+
const entry = parsed;
|
|
239
|
+
const payload = getCodexPayload(entry);
|
|
240
|
+
if (entry.type === "session_meta") {
|
|
241
|
+
if (typeof payload.id === "string" && payload.id) {
|
|
242
|
+
data.threadId = payload.id;
|
|
243
|
+
}
|
|
244
|
+
else if (typeof payload.threadId === "string" && payload.threadId) {
|
|
245
|
+
data.threadId = payload.threadId;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (!data.threadId) {
|
|
249
|
+
if (typeof payload.threadId === "string" && payload.threadId) {
|
|
250
|
+
data.threadId = payload.threadId;
|
|
251
|
+
}
|
|
252
|
+
else if (typeof entry.threadId === "string" && entry.threadId) {
|
|
253
|
+
data.threadId = entry.threadId;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (entry.type === "turn_context" && typeof payload.model === "string" && payload.model) {
|
|
257
|
+
data.model = payload.model;
|
|
258
|
+
}
|
|
259
|
+
else if (!data.model && typeof payload.model === "string" && payload.model) {
|
|
260
|
+
data.model = payload.model;
|
|
261
|
+
}
|
|
262
|
+
// Token sources are precedence-ordered: total → last → flat → legacy.
|
|
263
|
+
// `continue` ensures only one source is counted per entry.
|
|
264
|
+
// `total_token_usage` is a cumulative snapshot (last-write-wins, so `=`);
|
|
265
|
+
// the rest are per-turn deltas (accumulate with `+=`). Do not "fix" this.
|
|
266
|
+
const totalUsage = payload.info?.total_token_usage;
|
|
267
|
+
if (typeof totalUsage?.input_tokens === "number") {
|
|
268
|
+
data.inputTokens = totalUsage.input_tokens;
|
|
269
|
+
data.outputTokens = totalUsage.output_tokens ?? 0;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const lastUsage = payload.info?.last_token_usage;
|
|
273
|
+
if (typeof lastUsage?.input_tokens === "number") {
|
|
274
|
+
data.inputTokens += lastUsage.input_tokens;
|
|
275
|
+
data.outputTokens += lastUsage.output_tokens ?? 0;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (typeof payload.input_tokens === "number") {
|
|
279
|
+
data.inputTokens += payload.input_tokens;
|
|
280
|
+
data.outputTokens += payload.output_tokens ?? 0;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (entry.type === "event_msg" && entry.msg?.type === "token_count") {
|
|
284
|
+
data.inputTokens += entry.msg.input_tokens ?? 0;
|
|
285
|
+
data.outputTokens += entry.msg.output_tokens ?? 0;
|
|
286
|
+
data.cachedTokens += entry.msg.cached_tokens ?? 0;
|
|
287
|
+
data.reasoningTokens += entry.msg.reasoning_tokens ?? 0;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Skip malformed lines
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return data;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
rl?.close();
|
|
301
|
+
stream?.destroy();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// =============================================================================
|
|
305
|
+
// Binary Resolution
|
|
306
|
+
// =============================================================================
|
|
307
|
+
/**
|
|
308
|
+
* Resolve the Codex CLI binary path.
|
|
309
|
+
* Checks (in order): which, common fallback locations.
|
|
310
|
+
* Returns "codex" as final fallback (let the shell resolve it at runtime).
|
|
311
|
+
*/
|
|
312
|
+
export async function resolveCodexBinary() {
|
|
313
|
+
if (isWindows()) {
|
|
314
|
+
return resolveCodexBinaryWindows();
|
|
315
|
+
}
|
|
316
|
+
// 1. Try `which codex`
|
|
317
|
+
try {
|
|
318
|
+
const { stdout } = await execFileAsync("which", ["codex"], { timeout: 10_000 });
|
|
319
|
+
const resolved = stdout.trim();
|
|
320
|
+
if (resolved)
|
|
321
|
+
return resolved;
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// Not found via which
|
|
325
|
+
}
|
|
326
|
+
// 2. Check common locations (npm global, Homebrew, Cargo — Codex is now Rust-based)
|
|
327
|
+
const home = homedir();
|
|
328
|
+
const candidates = [
|
|
329
|
+
"/usr/local/bin/codex",
|
|
330
|
+
"/opt/homebrew/bin/codex",
|
|
331
|
+
join(home, ".cargo", "bin", "codex"),
|
|
332
|
+
join(home, ".npm", "bin", "codex"),
|
|
333
|
+
];
|
|
334
|
+
for (const candidate of candidates) {
|
|
335
|
+
try {
|
|
336
|
+
await stat(candidate);
|
|
337
|
+
return candidate;
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// Not found at this location
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// 3. Fallback: let the shell resolve it
|
|
344
|
+
return "codex";
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Windows-specific binary lookup. `which` does not exist on Windows; the
|
|
348
|
+
* equivalent is `where.exe`, which can return multiple lines (PATHEXT
|
|
349
|
+
* variants). npm-installed CLIs land as `<name>.cmd` shims, while
|
|
350
|
+
* Rust/Cargo installs produce `<name>.exe`. We prefer the .cmd shim because
|
|
351
|
+
* it forwards to the right node binary, then fall back to .exe.
|
|
352
|
+
*/
|
|
353
|
+
async function resolveCodexBinaryWindows() {
|
|
354
|
+
for (const target of ["codex.cmd", "codex.exe"]) {
|
|
355
|
+
try {
|
|
356
|
+
const { stdout } = await execFileAsync("where.exe", [target], {
|
|
357
|
+
timeout: 10_000,
|
|
358
|
+
windowsHide: true,
|
|
359
|
+
});
|
|
360
|
+
const first = stdout.split(/\r?\n/).find((line) => line.trim().length > 0);
|
|
361
|
+
if (first)
|
|
362
|
+
return first.trim();
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
// Not on PATH — try next target
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
// Fall back to common npm/Cargo install locations so AO works even when
|
|
369
|
+
// the user installed Codex into a directory not currently on PATH.
|
|
370
|
+
const appData = process.env["APPDATA"];
|
|
371
|
+
const home = homedir();
|
|
372
|
+
const candidates = [
|
|
373
|
+
appData ? join(appData, "npm", "codex.cmd") : null,
|
|
374
|
+
appData ? join(appData, "npm", "codex.exe") : null,
|
|
375
|
+
join(home, ".cargo", "bin", "codex.exe"),
|
|
376
|
+
].filter((p) => p !== null);
|
|
377
|
+
for (const candidate of candidates) {
|
|
378
|
+
try {
|
|
379
|
+
await stat(candidate);
|
|
380
|
+
return candidate;
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// Not at this location
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// Last resort: bare name. PowerShell will hit PATHEXT to find codex.cmd.
|
|
387
|
+
// Combined with the `& ` prefix from formatLaunchCommand this still works.
|
|
388
|
+
return "codex";
|
|
389
|
+
}
|
|
390
|
+
// =============================================================================
|
|
391
|
+
// Agent Implementation
|
|
392
|
+
// =============================================================================
|
|
393
|
+
/** Append approval-policy flags to a command parts array */
|
|
394
|
+
function appendApprovalFlags(parts, permissions, allowDangerousBypass = true) {
|
|
395
|
+
const mode = normalizeAgentPermissionMode(permissions);
|
|
396
|
+
if (mode === "permissionless") {
|
|
397
|
+
if (allowDangerousBypass) {
|
|
398
|
+
parts.push("--dangerously-bypass-approvals-and-sandbox");
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
parts.push("--ask-for-approval", "never");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
else if (mode === "auto-edit") {
|
|
405
|
+
parts.push("--ask-for-approval", "never");
|
|
406
|
+
}
|
|
407
|
+
else if (mode === "suggest") {
|
|
408
|
+
parts.push("--ask-for-approval", "untrusted");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/** Append model and reasoning flags to a command parts array */
|
|
412
|
+
function appendModelFlags(parts, model) {
|
|
413
|
+
if (!model)
|
|
414
|
+
return;
|
|
415
|
+
parts.push("--model", shellEscape(model));
|
|
416
|
+
// Auto-detect o-series models and enable reasoning via config override.
|
|
417
|
+
// Codex does not have a --reasoning flag; reasoning is controlled via
|
|
418
|
+
// the model_reasoning_effort config key.
|
|
419
|
+
if (/^o[34]/i.test(model)) {
|
|
420
|
+
parts.push("-c", "model_reasoning_effort=high");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/** Disable Codex startup update checks/prompts in non-interactive sessions */
|
|
424
|
+
function appendNoUpdateCheckFlag(parts) {
|
|
425
|
+
parts.push("-c", "check_for_update_on_startup=false");
|
|
426
|
+
}
|
|
427
|
+
/** TTL for session file path cache (ms). Prevents redundant filesystem scans
|
|
428
|
+
* when getActivityState and getSessionInfo are called in the same refresh cycle. */
|
|
429
|
+
const SESSION_FILE_CACHE_TTL_MS = 30_000;
|
|
430
|
+
/** Module-level session file cache shared across the agent instance lifetime.
|
|
431
|
+
* Keyed by Codex thread id when available, otherwise workspace path. */
|
|
432
|
+
const sessionFileCache = new Map();
|
|
433
|
+
function getSessionMetadataString(session, key) {
|
|
434
|
+
const value = session.metadata?.[key];
|
|
435
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
436
|
+
}
|
|
437
|
+
async function getCachedSessionFile(cacheKey, resolve) {
|
|
438
|
+
const cached = sessionFileCache.get(cacheKey);
|
|
439
|
+
if (cached && Date.now() < cached.expiry) {
|
|
440
|
+
return cached.path;
|
|
441
|
+
}
|
|
442
|
+
const result = await resolve();
|
|
443
|
+
sessionFileCache.set(cacheKey, {
|
|
444
|
+
path: result,
|
|
445
|
+
expiry: Date.now() + SESSION_FILE_CACHE_TTL_MS,
|
|
446
|
+
});
|
|
447
|
+
return result;
|
|
448
|
+
}
|
|
449
|
+
/** Find session file with caching to avoid double scans per refresh cycle */
|
|
450
|
+
async function findCodexSessionFileCached(session) {
|
|
451
|
+
let jsonlFiles = null;
|
|
452
|
+
const getJsonlFiles = async () => {
|
|
453
|
+
jsonlFiles ??= await collectJsonlFiles(CODEX_SESSIONS_DIR);
|
|
454
|
+
return jsonlFiles;
|
|
455
|
+
};
|
|
456
|
+
const threadId = getSessionMetadataString(session, "codexThreadId");
|
|
457
|
+
if (threadId) {
|
|
458
|
+
const byThreadId = await getCachedSessionFile(`thread:${threadId}`, async () => findCodexSessionFileByThreadId(threadId, await getJsonlFiles()));
|
|
459
|
+
if (byThreadId)
|
|
460
|
+
return byThreadId;
|
|
461
|
+
}
|
|
462
|
+
if (!session.workspacePath)
|
|
463
|
+
return null;
|
|
464
|
+
return getCachedSessionFile(`cwd:${toComparablePath(session.workspacePath)}`, async () => findCodexSessionFile(session.workspacePath, await getJsonlFiles()));
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Format a launch command for the host shell. On Windows the resolved binary
|
|
468
|
+
* path is single-quoted by shellEscape (e.g. `'C:\Users\...\codex.cmd'`), and
|
|
469
|
+
* PowerShell parses a leading quoted string as an expression — `'codex' -c …`
|
|
470
|
+
* fails with "Unexpected token '-c' in expression or statement". Prepending
|
|
471
|
+
* the call operator `& ` tells PowerShell to *invoke* the string as a command.
|
|
472
|
+
* On Unix the prefix is unnecessary; bash treats `'codex' -c …` as a command.
|
|
473
|
+
*/
|
|
474
|
+
function formatLaunchCommand(parts) {
|
|
475
|
+
const cmd = parts.join(" ");
|
|
476
|
+
return isWindows() ? `& ${cmd}` : cmd;
|
|
477
|
+
}
|
|
478
|
+
function createCodexAgent() {
|
|
479
|
+
/** Cached resolved binary path (populated by init or first getLaunchCommand) */
|
|
480
|
+
let resolvedBinary = null;
|
|
481
|
+
/** Guard against concurrent resolveCodexBinary() calls */
|
|
482
|
+
let resolvingBinary = null;
|
|
483
|
+
return {
|
|
484
|
+
name: "codex",
|
|
485
|
+
processName: "codex",
|
|
486
|
+
getLaunchCommand(config) {
|
|
487
|
+
const binary = resolvedBinary ?? "codex";
|
|
488
|
+
const parts = [shellEscape(binary)];
|
|
489
|
+
appendNoUpdateCheckFlag(parts);
|
|
490
|
+
appendApprovalFlags(parts, config.permissions);
|
|
491
|
+
appendModelFlags(parts, config.model);
|
|
492
|
+
if (config.systemPromptFile) {
|
|
493
|
+
// Codex reads developer instructions from a file via config override
|
|
494
|
+
parts.push("-c", `model_instructions_file=${shellEscape(config.systemPromptFile)}`);
|
|
495
|
+
}
|
|
496
|
+
else if (config.systemPrompt) {
|
|
497
|
+
// Codex accepts inline developer instructions via config override
|
|
498
|
+
parts.push("-c", `developer_instructions=${shellEscape(config.systemPrompt)}`);
|
|
499
|
+
}
|
|
500
|
+
if (config.prompt) {
|
|
501
|
+
// Use `--` to end option parsing so prompts starting with `-` aren't
|
|
502
|
+
// misinterpreted as flags.
|
|
503
|
+
parts.push("--", shellEscape(config.prompt));
|
|
504
|
+
}
|
|
505
|
+
return formatLaunchCommand(parts);
|
|
506
|
+
},
|
|
507
|
+
getEnvironment(config) {
|
|
508
|
+
const env = {};
|
|
509
|
+
env["AO_SESSION_ID"] = config.sessionId;
|
|
510
|
+
// NOTE: AO_PROJECT_ID is the caller's responsibility (spawn.ts sets it)
|
|
511
|
+
if (config.issueId) {
|
|
512
|
+
env["AO_ISSUE_ID"] = config.issueId;
|
|
513
|
+
}
|
|
514
|
+
// PATH and GH_PATH are injected by session-manager for all agents.
|
|
515
|
+
// Disable Codex's version check/update prompt for non-interactive AO sessions.
|
|
516
|
+
env["CODEX_DISABLE_UPDATE_CHECK"] = "1";
|
|
517
|
+
return env;
|
|
518
|
+
},
|
|
519
|
+
detectActivity(terminalOutput) {
|
|
520
|
+
if (!terminalOutput.trim())
|
|
521
|
+
return "idle";
|
|
522
|
+
const lines = terminalOutput.trim().split("\n");
|
|
523
|
+
const lastLine = lines[lines.length - 1]?.trim() ?? "";
|
|
524
|
+
// If Codex is showing its input prompt, it's idle
|
|
525
|
+
if (/^[>$#]\s*$/.test(lastLine))
|
|
526
|
+
return "idle";
|
|
527
|
+
// Check last few lines for approval prompts
|
|
528
|
+
const tail = lines.slice(-5).join("\n");
|
|
529
|
+
if (/approval required/i.test(tail))
|
|
530
|
+
return "waiting_input";
|
|
531
|
+
if (/\(y\)es.*\(n\)o/i.test(tail))
|
|
532
|
+
return "waiting_input";
|
|
533
|
+
// Default to active — specific patterns (esc to interrupt, spinner
|
|
534
|
+
// symbols) all map to "active" so no need to check them individually.
|
|
535
|
+
return "active";
|
|
536
|
+
},
|
|
537
|
+
async getActivityState(session, readyThresholdMs) {
|
|
538
|
+
const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS;
|
|
539
|
+
// Check if process is running first
|
|
540
|
+
const exitedAt = new Date();
|
|
541
|
+
if (!session.runtimeHandle)
|
|
542
|
+
return { state: "exited", timestamp: exitedAt };
|
|
543
|
+
const running = await this.isProcessRunning(session.runtimeHandle);
|
|
544
|
+
if (running === PROCESS_PROBE_INDETERMINATE)
|
|
545
|
+
return null;
|
|
546
|
+
if (!running)
|
|
547
|
+
return { state: "exited", timestamp: exitedAt };
|
|
548
|
+
if (!session.workspacePath && !getSessionMetadataString(session, "codexThreadId")) {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
// 1. Try Codex's native JSONL first — it has richer 6-state detection
|
|
552
|
+
// (approval_request, error, tool_call, etc.) that terminal parsing can't match.
|
|
553
|
+
const sessionFile = await findCodexSessionFileCached(session);
|
|
554
|
+
if (sessionFile) {
|
|
555
|
+
const entry = await readLastJsonlEntry(sessionFile);
|
|
556
|
+
if (entry) {
|
|
557
|
+
const ageMs = Date.now() - entry.modifiedAt.getTime();
|
|
558
|
+
const timestamp = entry.modifiedAt;
|
|
559
|
+
// Real Codex wraps the semantic type in `payload.type` on event_msg
|
|
560
|
+
// records (e.g. `{"type":"event_msg","payload":{"type":"error",...}}`).
|
|
561
|
+
// Prefer payloadType when present so approval_request/error surface
|
|
562
|
+
// correctly instead of decaying to ready/idle via the event_msg case.
|
|
563
|
+
const effectiveType = entry.payloadType ?? entry.lastType;
|
|
564
|
+
// Map Codex JSONL entry types to activity states.
|
|
565
|
+
// Confirmed types: session_meta, event_msg. Others are best-effort.
|
|
566
|
+
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
|
567
|
+
switch (effectiveType) {
|
|
568
|
+
case "approval_request":
|
|
569
|
+
case "exec_approval_request":
|
|
570
|
+
case "apply_patch_approval_request":
|
|
571
|
+
return { state: "waiting_input", timestamp };
|
|
572
|
+
case "error":
|
|
573
|
+
case "stream_error":
|
|
574
|
+
return { state: "blocked", timestamp };
|
|
575
|
+
case "task_started":
|
|
576
|
+
case "agent_reasoning":
|
|
577
|
+
case "response_item":
|
|
578
|
+
case "turn_context":
|
|
579
|
+
case "user_input":
|
|
580
|
+
case "tool_call":
|
|
581
|
+
case "exec_command":
|
|
582
|
+
case "exec_command_begin":
|
|
583
|
+
case "exec_command_end":
|
|
584
|
+
if (ageMs <= activeWindowMs)
|
|
585
|
+
return { state: "active", timestamp };
|
|
586
|
+
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
|
|
587
|
+
case "task_complete":
|
|
588
|
+
case "turn_aborted":
|
|
589
|
+
case "agent_message":
|
|
590
|
+
case "assistant_message":
|
|
591
|
+
case "session_meta":
|
|
592
|
+
case "event_msg":
|
|
593
|
+
case "compacted":
|
|
594
|
+
case "token_count":
|
|
595
|
+
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
|
|
596
|
+
default:
|
|
597
|
+
if (ageMs <= activeWindowMs)
|
|
598
|
+
return { state: "active", timestamp };
|
|
599
|
+
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// Session file exists but no parseable entry — fall through to AO JSONL
|
|
603
|
+
// checks below instead of returning early, so waiting_input/blocked
|
|
604
|
+
// from terminal parsing can still be detected.
|
|
605
|
+
}
|
|
606
|
+
// 2. Fallback: check AO activity JSONL (terminal-derived) for waiting_input/blocked
|
|
607
|
+
// that the native JSONL may not have captured.
|
|
608
|
+
const activityResult = session.workspacePath
|
|
609
|
+
? await readLastActivityEntry(session.workspacePath)
|
|
610
|
+
: null;
|
|
611
|
+
const activityState = checkActivityLogState(activityResult);
|
|
612
|
+
if (activityState)
|
|
613
|
+
return activityState;
|
|
614
|
+
// 3. Fallback: use JSONL entry with age-based decay when native session file
|
|
615
|
+
// is missing or unparseable.
|
|
616
|
+
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
|
617
|
+
const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold);
|
|
618
|
+
if (fallback)
|
|
619
|
+
return fallback;
|
|
620
|
+
// 4. Last resort: native session file exists but nothing else — use its mtime
|
|
621
|
+
if (sessionFile) {
|
|
622
|
+
try {
|
|
623
|
+
const s = await stat(sessionFile);
|
|
624
|
+
const ageMs = Date.now() - s.mtimeMs;
|
|
625
|
+
const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold);
|
|
626
|
+
if (ageMs <= activeWindowMs)
|
|
627
|
+
return { state: "active", timestamp: s.mtime };
|
|
628
|
+
if (ageMs <= threshold)
|
|
629
|
+
return { state: "ready", timestamp: s.mtime };
|
|
630
|
+
return { state: "idle", timestamp: s.mtime };
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// stat failed — no signal available
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return null;
|
|
637
|
+
},
|
|
638
|
+
async recordActivity(session, terminalOutput) {
|
|
639
|
+
if (!session.workspacePath)
|
|
640
|
+
return;
|
|
641
|
+
await recordTerminalActivity(session.workspacePath, terminalOutput, (output) => this.detectActivity(output));
|
|
642
|
+
},
|
|
643
|
+
async isProcessRunning(handle) {
|
|
644
|
+
try {
|
|
645
|
+
if (handle.runtimeName === "tmux" && handle.id) {
|
|
646
|
+
// ps -eo is Unix-only; guard against stale tmux handles on Windows
|
|
647
|
+
if (isWindows())
|
|
648
|
+
return false;
|
|
649
|
+
const { stdout: ttyOut } = await execFileAsync("tmux", ["list-panes", "-t", handle.id, "-F", "#{pane_tty}"], { timeout: 30_000 });
|
|
650
|
+
const ttys = ttyOut
|
|
651
|
+
.trim()
|
|
652
|
+
.split("\n")
|
|
653
|
+
.map((t) => t.trim())
|
|
654
|
+
.filter(Boolean);
|
|
655
|
+
if (ttys.length === 0)
|
|
656
|
+
return false;
|
|
657
|
+
const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], {
|
|
658
|
+
timeout: 30_000,
|
|
659
|
+
});
|
|
660
|
+
if (!psOut)
|
|
661
|
+
return PROCESS_PROBE_INDETERMINATE;
|
|
662
|
+
const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, "")));
|
|
663
|
+
const processRe = /(?:^|\/)codex(?:\s|$)/;
|
|
664
|
+
for (const line of psOut.split("\n")) {
|
|
665
|
+
const cols = line.trimStart().split(/\s+/);
|
|
666
|
+
if (cols.length < 3 || !ttySet.has(cols[1] ?? ""))
|
|
667
|
+
continue;
|
|
668
|
+
const args = cols.slice(2).join(" ");
|
|
669
|
+
if (processRe.test(args)) {
|
|
670
|
+
return true;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
const rawPid = handle.data["pid"];
|
|
676
|
+
const pid = typeof rawPid === "number" ? rawPid : Number(rawPid);
|
|
677
|
+
if (Number.isFinite(pid) && pid > 0) {
|
|
678
|
+
try {
|
|
679
|
+
process.kill(pid, 0);
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
catch (err) {
|
|
683
|
+
if (err instanceof Error && "code" in err && err.code === "EPERM") {
|
|
684
|
+
return true;
|
|
685
|
+
}
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
return PROCESS_PROBE_INDETERMINATE;
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
async getSessionInfo(session) {
|
|
696
|
+
const sessionFile = await findCodexSessionFileCached(session);
|
|
697
|
+
if (!sessionFile)
|
|
698
|
+
return null;
|
|
699
|
+
// Stream the file line-by-line to avoid loading potentially huge
|
|
700
|
+
// rollout files (100 MB+) entirely into memory.
|
|
701
|
+
const data = await streamCodexSessionData(sessionFile);
|
|
702
|
+
if (!data)
|
|
703
|
+
return null;
|
|
704
|
+
const agentSessionId = basename(sessionFile, ".jsonl");
|
|
705
|
+
let cost;
|
|
706
|
+
const totalInputTokens = data.inputTokens + data.cachedTokens;
|
|
707
|
+
if (totalInputTokens > 0 || data.outputTokens > 0 || data.reasoningTokens > 0) {
|
|
708
|
+
const estimatedCostUsd = (data.inputTokens / 1_000_000) * 2.5 +
|
|
709
|
+
(data.cachedTokens / 1_000_000) * 0.625 +
|
|
710
|
+
((data.outputTokens + data.reasoningTokens) / 1_000_000) * 10.0;
|
|
711
|
+
cost = {
|
|
712
|
+
inputTokens: totalInputTokens,
|
|
713
|
+
outputTokens: data.outputTokens,
|
|
714
|
+
estimatedCostUsd,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
summary: data.model ? `Codex session (${data.model})` : null,
|
|
719
|
+
summaryIsFallback: true,
|
|
720
|
+
agentSessionId,
|
|
721
|
+
metadata: data.threadId
|
|
722
|
+
? {
|
|
723
|
+
codexThreadId: data.threadId,
|
|
724
|
+
...(data.model ? { codexModel: data.model } : {}),
|
|
725
|
+
}
|
|
726
|
+
: undefined,
|
|
727
|
+
cost,
|
|
728
|
+
};
|
|
729
|
+
},
|
|
730
|
+
async getRestoreCommand(session, project) {
|
|
731
|
+
let threadId = getSessionMetadataString(session, "codexThreadId");
|
|
732
|
+
let model = getSessionMetadataString(session, "codexModel");
|
|
733
|
+
if (!threadId) {
|
|
734
|
+
if (!session.workspacePath)
|
|
735
|
+
return null;
|
|
736
|
+
// Find the Codex session file for this workspace
|
|
737
|
+
const sessionFile = await findCodexSessionFileCached(session);
|
|
738
|
+
if (!sessionFile)
|
|
739
|
+
return null;
|
|
740
|
+
// Stream the file line-by-line to avoid loading potentially huge
|
|
741
|
+
// rollout files (100 MB+) entirely into memory.
|
|
742
|
+
const data = await streamCodexSessionData(sessionFile);
|
|
743
|
+
if (!data?.threadId)
|
|
744
|
+
return null;
|
|
745
|
+
threadId = data.threadId;
|
|
746
|
+
model = data.model;
|
|
747
|
+
}
|
|
748
|
+
// Use Codex's native `resume` subcommand for proper conversation resume.
|
|
749
|
+
// This restores the full thread state, not just a text prompt re-injection.
|
|
750
|
+
// Flags are placed before the positional threadId for CLI parser compatibility.
|
|
751
|
+
const binary = resolvedBinary ?? "codex";
|
|
752
|
+
const parts = [shellEscape(binary), "resume"];
|
|
753
|
+
appendNoUpdateCheckFlag(parts);
|
|
754
|
+
appendApprovalFlags(parts, project.agentConfig?.permissions);
|
|
755
|
+
const effectiveModel = (project.agentConfig?.model ?? model);
|
|
756
|
+
appendModelFlags(parts, effectiveModel ?? undefined);
|
|
757
|
+
// Positional threadId goes last, after all flags
|
|
758
|
+
parts.push(shellEscape(threadId));
|
|
759
|
+
return formatLaunchCommand(parts);
|
|
760
|
+
},
|
|
761
|
+
async setupWorkspaceHooks(_workspacePath, _config) {
|
|
762
|
+
// PATH wrappers are installed by session-manager for all agents.
|
|
763
|
+
},
|
|
764
|
+
async postLaunchSetup(_session) {
|
|
765
|
+
// Resolve binary path on first launch (cached for subsequent calls).
|
|
766
|
+
// Uses a promise guard to prevent concurrent calls from racing.
|
|
767
|
+
if (!resolvedBinary) {
|
|
768
|
+
if (!resolvingBinary) {
|
|
769
|
+
resolvingBinary = resolveCodexBinary();
|
|
770
|
+
}
|
|
771
|
+
try {
|
|
772
|
+
resolvedBinary = await resolvingBinary;
|
|
773
|
+
}
|
|
774
|
+
finally {
|
|
775
|
+
resolvingBinary = null;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// PATH wrappers are re-ensured by session-manager.
|
|
779
|
+
},
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
// =============================================================================
|
|
783
|
+
// Plugin Export
|
|
784
|
+
// =============================================================================
|
|
785
|
+
export function create() {
|
|
786
|
+
return createCodexAgent();
|
|
787
|
+
}
|
|
788
|
+
/** @internal Clear the session file cache. Exported for testing only. */
|
|
789
|
+
export function _resetSessionFileCache() {
|
|
790
|
+
sessionFileCache.clear();
|
|
791
|
+
}
|
|
792
|
+
export { CodexAppServerClient } from "./app-server-client.js";
|
|
793
|
+
export function detect() {
|
|
794
|
+
try {
|
|
795
|
+
execFileSync("codex", ["--version"], {
|
|
796
|
+
stdio: "ignore",
|
|
797
|
+
shell: isWindows(),
|
|
798
|
+
windowsHide: true,
|
|
799
|
+
});
|
|
800
|
+
return true;
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
return false;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
export default { manifest, create, detect };
|
|
807
|
+
//# sourceMappingURL=index.js.map
|