Repository Public
1/**2 * Codex CLI introspection (runtime-fs), used by the profile-aware routes of claude.ts, skills.ts3 * and mcp.ts when the instance runs with `CR_AGENT=codex`. Read-only; never starts a turn, never4 * returns account identifiers, keys or their masked forms.5 *6 * Observed with codex-cli 0.166.0 on macOS:7 * - `codex --version` → `codex-cli 0.166.0`8 * - `codex login status` prints on STDERR: `Logged in using ChatGPT` (exit 0), `Not logged in`9 * (exit 1); with an API key the line continues with a masked key, which is never read.10 * - sessions ("rollouts") live in `$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<local time>-<uuid>.jsonl`.11 * Line 1 is `{"type":"session_meta","payload":{"id":<uuid>,"cwd":<abs>,"timestamp":…,12 * "originator":"codex-tui",…,"base_instructions":…}}` (about 22 kB); user prompts are13 * `{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":14 * "input_text","text":…}]}}`, the first one being an injected `<environment_context>` block.15 * - skills: `$CODEX_HOME/skills/<name>/SKILL.md` (bundled ones under `skills/.system/`),16 * `~/.agents/skills/`, and `<project>/.agents/skills/` / `<project>/.codex/skills/`;17 * instructions: `$CODEX_HOME/AGENTS.md` and `<project>/AGENTS.md` (`AGENTS.override.md` wins).18 * This version has no `$CODEX_HOME/prompts` custom-prompt directory.19 */20import { promises as fsp } from 'node:fs';21import path from 'node:path';22import type { ClaudeConversation, SkillInfo } from '@cr/shared';23import { DISABLED_DIR, parseFrontmatter, scanKindDir, type ScanScope } from './fsx-skills-scan.js';2425const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;26const ROLLOUT_RE = /^rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i;27const SUMMARY_CHARS = 160;28const MAX_CONVERSATIONS = 50;29/** Bytes read from the start of a rollout to find its meta record (line 1). */30export const ROLLOUT_HEAD_BYTES = 256 * 1024;31/** Bytes read from a matching rollout to find the first prompt and count messages. */32export const ROLLOUT_READ_BYTES = 4 * 1024 * 1024;33/** Rollout files examined per request, newest first (each costs one bounded read). */34export const MAX_ROLLOUTS_SCANNED = 400;3536export interface CodexAuth {37 authenticated: boolean | null;38 /** Non-sensitive label: "chatgpt" or "api_key". */39 method: string | null;40}4142/** Map the output of `codex login status` (stdout + stderr) to a boolean and a label. */43export function parseCodexLoginStatus(output: string): CodexAuth {44 if (/\bnot logged in\b/i.test(output)) return { authenticated: false, method: null };45 const m = /\blogged in using\s+(an?\s+)?([A-Za-z][A-Za-z ]{0,30})/i.exec(output);46 if (m) {47 const how = (m[2] as string).trim().toLowerCase();48 const method = how.startsWith('chatgpt') ? 'chatgpt' : how.startsWith('api key') ? 'api_key' : how.startsWith('access token') ? 'access_token' : null;49 return { authenticated: true, method };50 }51 // codex-cli 0.167 with `codex login --with-api-key`: `API key configured - sk-…` (masked key never read).52 if (/\bapi key configured\b/i.test(output)) return { authenticated: true, method: 'api_key' };53 if (/\blogged in\b/i.test(output)) return { authenticated: true, method: null };54 return { authenticated: null, method: null };55}5657/** `codex-cli 0.166.0` → `0.166.0`. */58export function parseCodexVersion(stdout: string): string | null {59 const m = /\d+\.\d+\.\d+[\w.-]*/.exec(stdout);60 return m ? m[0] : null;61}6263// ------------------------------------------------------------------ sessions (rollouts)6465export interface RolloutMeta {66 id: string;67 cwd: string;68 /** Sessions the resume picker would not offer (sub-agent threads, `codex exec` runs). */69 interactive: boolean;70}7172/** Parse the first line of a rollout. A line cut by the bounded read is recovered field by field. */73export function parseRolloutMeta(head: string): RolloutMeta | null {74 const nl = head.indexOf('\n');75 const line = nl < 0 ? head : head.slice(0, nl);76 try {77 const rec = JSON.parse(line) as { type?: unknown; payload?: Record<string, unknown> };78 const p = rec.payload;79 if (rec.type !== 'session_meta' || typeof p !== 'object' || p === null) return null;80 const id = typeof p.id === 'string' ? p.id : typeof p.session_id === 'string' ? p.session_id : null;81 if (!id || !UUID_RE.test(id) || typeof p.cwd !== 'string') return null;82 const threadSource = typeof p.thread_source === 'string' ? p.thread_source : 'user';83 return { id, cwd: p.cwd, interactive: threadSource === 'user' && p.source !== 'exec' };84 } catch {85 // Truncated first line (very large instructions): the fields we need come first.86 if (!line.startsWith('{') || !line.includes('"session_meta"')) return null;87 const id = /"id":"([0-9a-f-]{36})"/i.exec(line)?.[1];88 const cwdRaw = /"cwd":("(?:[^"\\]|\\.)*")/.exec(line)?.[1];89 if (!id || !UUID_RE.test(id) || !cwdRaw) return null;90 let cwd: unknown;91 try {92 cwd = JSON.parse(cwdRaw);93 } catch {94 return null;95 }96 if (typeof cwd !== 'string') return null;97 const threadSource = /"thread_source":"([^"]*)"/.exec(line)?.[1] ?? 'user';98 return { id, cwd, interactive: threadSource === 'user' && !/"source":"exec"/.test(line) };99 }100}101102/** A prompt typed by the user — not an injected context block. */103function cleanPrompt(text: string): string | null {104 const t = text.trim();105 if (t === '' || /^<[a-zA-Z_][\w-]*[\s>]/.test(t)) return null; // <environment_context>, <user_instructions>, …106 const flat = t.replace(/\s+/g, ' ');107 return flat.length > SUMMARY_CHARS ? `${flat.slice(0, SUMMARY_CHARS - 1)}…` : flat;108}109110function messageRole(rec: unknown): { role: string; texts: string[] } | null {111 if (typeof rec !== 'object' || rec === null) return null;112 const r = rec as { type?: unknown; payload?: unknown };113 if (r.type !== 'response_item' || typeof r.payload !== 'object' || r.payload === null) return null;114 const p = r.payload as { type?: unknown; role?: unknown; content?: unknown };115 if (p.type !== 'message' || typeof p.role !== 'string') return null;116 const texts: string[] = [];117 if (Array.isArray(p.content)) {118 for (const block of p.content as unknown[]) {119 const b = block as { type?: unknown; text?: unknown } | null;120 if (b && typeof b.text === 'string') texts.push(b.text);121 }122 }123 return { role: p.role, texts };124}125126/** First real user prompt + number of user/assistant messages in the text read. */127export function summarizeRollout(text: string, complete: boolean): { summary: string; messageCount: number } {128 const lines = text.split('\n');129 if (!complete) lines.pop(); // the read stopped inside a line130 let summary = '';131 let count = 0;132 for (const line of lines) {133 // Cheap pre-filter: meta, tool and context records can be tens of kilobytes each.134 if (!line.includes('"message"') || !line.startsWith('{"')) continue;135 let rec: unknown;136 try {137 rec = JSON.parse(line);138 } catch {139 continue;140 }141 const msg = messageRole(rec);142 if (!msg || (msg.role !== 'user' && msg.role !== 'assistant')) continue;143 if (msg.role === 'assistant') {144 count++;145 continue;146 }147 const prompt = msg.texts.map(cleanPrompt).find((p): p is string => p !== null) ?? null;148 if (prompt === null) continue; // injected block: neither counted nor summarised149 count++;150 if (summary === '') summary = prompt;151 }152 return { summary, messageCount: count };153}154155async function readHead(file: string, cap: number): Promise<{ text: string; complete: boolean } | null> {156 let fh: fsp.FileHandle | null = null;157 try {158 fh = await fsp.open(file, 'r');159 const size = (await fh.stat()).size;160 const want = Math.min(size, cap);161 const buf = Buffer.alloc(want);162 let got = 0;163 while (got < want) {164 const { bytesRead } = await fh.read(buf, got, want - got, got);165 if (bytesRead === 0) break;166 got += bytesRead;167 }168 return { text: buf.subarray(0, got).toString('utf8'), complete: got >= size };169 } catch {170 return null;171 } finally {172 await fh?.close().catch(() => undefined);173 }174}175176/** Real sub-directories / files of `dir`, names sorted descending (newest date / time first). */177async function listDesc(dir: string, want: 'dir' | 'file', pattern: RegExp): Promise<string[]> {178 try {179 const dirents = await fsp.readdir(dir, { withFileTypes: true });180 return dirents181 .filter((d) => (want === 'dir' ? d.isDirectory() : d.isFile()) && pattern.test(d.name)) // symlinks are ignored182 .map((d) => d.name)183 .sort()184 .reverse();185 } catch {186 return [];187 }188}189190/**191 * Codex sessions recorded for one of `cwds`, newest first (by modification time), at most 50.192 * Walks `sessionsDir/YYYY/MM/DD` from the most recent day and stops after MAX_ROLLOUTS_SCANNED193 * files; every read is bounded.194 */195export async function listCodexSessions(sessionsDir: string, cwds: readonly string[], limits: { maxScanned?: number } = {}): Promise<ClaudeConversation[]> {196 const wanted = new Set(cwds);197 const maxScanned = limits.maxScanned ?? MAX_ROLLOUTS_SCANNED;198 const found = new Map<string, { file: string; mtimeMs: number }>();199 let scanned = 0;200 walk: for (const y of await listDesc(sessionsDir, 'dir', /^\d{4}$/)) {201 for (const m of await listDesc(path.join(sessionsDir, y), 'dir', /^\d{2}$/)) {202 for (const d of await listDesc(path.join(sessionsDir, y, m), 'dir', /^\d{2}$/)) {203 const dayDir = path.join(sessionsDir, y, m, d);204 for (const name of await listDesc(dayDir, 'file', ROLLOUT_RE)) {205 if (scanned >= maxScanned) break walk;206 scanned++;207 const file = path.join(dayDir, name);208 const head = await readHead(file, ROLLOUT_HEAD_BYTES);209 const meta = head ? parseRolloutMeta(head.text) : null;210 if (!meta || !meta.interactive || !wanted.has(meta.cwd)) continue;211 // The id in the file name and the one in the meta record must agree.212 if ((ROLLOUT_RE.exec(name)?.[1] ?? '').toLowerCase() !== meta.id.toLowerCase()) continue;213 const st = await fsp.stat(file).catch(() => null);214 if (!st) continue;215 const prev = found.get(meta.id);216 if (!prev || prev.mtimeMs < st.mtimeMs) found.set(meta.id, { file, mtimeMs: st.mtimeMs });217 }218 }219 }220 }221 const newest = [...found.entries()].sort((a, b) => b[1].mtimeMs - a[1].mtimeMs).slice(0, MAX_CONVERSATIONS);222 const out: ClaudeConversation[] = [];223 for (const [id, f] of newest) {224 const body = await readHead(f.file, ROLLOUT_READ_BYTES);225 if (!body) continue;226 const { summary, messageCount } = summarizeRollout(body.text, body.complete);227 out.push({ id, summary, updatedAt: new Date(f.mtimeMs).toISOString(), messageCount });228 }229 return out;230}231232// ------------------------------------------------------------------ skills / instructions233234const INSTRUCTION_FILES = ['AGENTS.override.md', 'AGENTS.md'] as const;235const INSTRUCTION_HEAD_BYTES = 16 * 1024;236237async function isRealDir(p: string, follow: boolean): Promise<boolean> {238 const st = await (follow ? fsp.stat(p) : fsp.lstat(p)).catch(() => null);239 return st?.isDirectory() ?? false;240}241242async function instructionFiles(dir: string, source: 'user' | 'project', label: (abs: string) => string): Promise<SkillInfo[]> {243 const out: SkillInfo[] = [];244 for (const name of INSTRUCTION_FILES) {245 const abs = path.join(dir, name);246 const st = await fsp.lstat(abs).catch(() => null);247 if (!st?.isFile()) continue; // a symlink could point anywhere: not read248 const head = await readHead(abs, INSTRUCTION_HEAD_BYTES);249 if (!head) continue;250 const first = parseFrontmatter(head.text)251 .body.split('\n')252 .map((l) => l.trim())253 .find((l) => l !== '' && !l.startsWith('#') && !l.startsWith('---'));254 out.push({255 name,256 description: (first ?? (source === 'user' ? 'Global Codex instructions' : 'Project Codex instructions')).slice(0, 500),257 source,258 kind: 'agent',259 version: null,260 location: label(abs),261 enabled: true,262 updateAvailable: null,263 });264 }265 return out;266}267268export interface CodexSkillsScan {269 codexHome: string;270 home: string;271 /** Project root (already validated, real path) or null. */272 root: string | null;273 label: (abs: string) => string;274}275276/**277 * What exists for Codex, mapped onto `SkillInfo`:278 * - kind `skill`, source `user`: `$CODEX_HOME/skills/<n>/SKILL.md`, `~/.agents/skills/<n>/SKILL.md`279 * (+ disabled ones parked by this app under `$CODEX_HOME/.disabled/skills/`)280 * - kind `skill`, source `plugin`: `$CODEX_HOME/skills/.system/<n>/SKILL.md` (bundled with the CLI)281 * - kind `skill`, source `project`: `<root>/.agents/skills/`, `<root>/.codex/skills/`282 * - kind `agent`: `AGENTS.md` / `AGENTS.override.md` instruction files283 * Codex has no slash-command files, so kind `command` never appears.284 */285export async function scanCodexSkills(s: CodexSkillsScan): Promise<SkillInfo[]> {286 const out: SkillInfo[] = [];287 const user: Omit<ScanScope, 'base'> = { source: 'user', followSymlinks: true, label: s.label };288 const skillsIn = async (dir: string, scope: Omit<ScanScope, 'base'>, enabled: boolean, source?: SkillInfo['source']): Promise<void> => {289 if (!(await isRealDir(dir, scope.followSymlinks))) return;290 const found: SkillInfo[] = [];291 await scanKindDir({ ...scope, base: path.dirname(dir) }, 'skill', dir, enabled, found);292 for (const f of found) out.push(source ? { ...f, source } : f);293 };294 await skillsIn(path.join(s.codexHome, 'skills'), user, true);295 await skillsIn(path.join(s.home, '.agents', 'skills'), user, true);296 await skillsIn(path.join(s.codexHome, 'skills', '.system'), user, true, 'plugin');297 await skillsIn(path.join(s.codexHome, DISABLED_DIR, 'skills'), user, false);298 out.push(...(await instructionFiles(s.codexHome, 'user', s.label)));299 if (s.root) {300 const project: Omit<ScanScope, 'base'> = { source: 'project', followSymlinks: false, label: s.label };301 for (const dot of ['.agents', '.codex']) {302 // A dot-directory that is a symlink is not scanned: it could point outside the project.303 if (!(await isRealDir(path.join(s.root, dot), false))) continue;304 await skillsIn(path.join(s.root, dot, 'skills'), project, true);305 if (dot === '.agents') await skillsIn(path.join(s.root, dot, DISABLED_DIR, 'skills'), project, false);306 }307 out.push(...(await instructionFiles(s.root, 'project', s.label)));308 }309 return out;310}311