@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/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