SPB Git

Repository Public

codex.ts 14.3 KB 311 lines typescript

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
Files main
Menu

Theme