UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

236 lines (235 loc) • 11.7 kB
import { execFile } from "node:child_process"; import { open, readdir, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { codexHome } from "../home-paths.js"; import { countIdentity } from "./metrics.js"; import { object, sessionId } from "./protocol.js"; /** Codex 0.157 runs its conversations in a shared background daemon * (`codex app-server --listen … --managed-daemon`, reparented to launchd or * init), not in the pane's own `codex` process. The pane's TUI holds no * rollout, and Codex's lifecycle hooks run inside the daemon with the * environment of whichever pane first started it. This module is what the * Hook can still prove about such a conversation: which rollouts the daemon * holds, the folder and time each began, and when each pane's TUI started. */ const exec = promisify(execFile); const DAEMON_COMMAND = /(?:^|\/)codex\s+app-server(?:\s|$)/; const ROLLOUT = /rollout-[^/]*-([a-f0-9-]{36})\.jsonl$/i; const CACHE_MS = 2_000, META_BYTES = 65_536, MAX_ROLLOUTS = 32, MAX_DAEMONS = 4; /** A conversation begins as its TUI starts; allow for clock rounding. */ export const START_SLACK_MS = 5_000; /** `ps` etime: `[[dd-]hh:]mm:ss`, in milliseconds. */ export function parseElapsed(text) { const match = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(text.trim()); if (!match) return undefined; const [, days = "0", hours = "0", minutes, seconds] = match; return (((Number(days) * 24 + Number(hours)) * 60 + Number(minutes)) * 60 + Number(seconds)) * 1000; } let table; /** Every process with its parent, start time and command line, from one `ps`. */ export function processTable(now = Date.now()) { if (table && now - table.at < CACHE_MS) return table.rows; countIdentity("ps"); const rows = exec("ps", ["-Aww", "-o", "pid=,ppid=,etime=,command="], { timeout: 3000, maxBuffer: 8_388_608 }).then(({ stdout }) => { const out = []; for (const line of String(stdout).split("\n")) { const match = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/.exec(line); const elapsed = match ? parseElapsed(match[3]) : undefined; if (match && elapsed !== undefined) out.push({ pid: Number(match[1]), ppid: Number(match[2]), startedAt: now - elapsed, command: match[4].slice(0, 4096) }); } return out; }, () => []); table = { at: now, rows }; return rows; } export function isCodexDaemon(command) { return DAEMON_COMMAND.test(command); } /** When a pane's agent started: its oldest foreground process. */ export function startedAt(rows, pids) { const times = rows.filter(row => pids.includes(row.pid)).map(row => row.startedAt); return times.length ? Math.min(...times) : undefined; } /** A direct `codex resume <id>` launch, including its shell/npm wrapper. * This is only a hint: the command line survives later /new and /resume. */ export function resumedConversation(rows, pids) { const ids = new Set(rows.filter(row => pids.includes(row.pid)).flatMap(row => { const match = /^(?:(?:\S*\/)?(?:sh|bash|zsh|node)\s+)?(?:\S*\/)?codex\s+resume\s+([a-f0-9-]{36})(?:\s|$)/i.exec(row.command); return match && sessionId.safeParse(match[1]).success ? [match[1]] : []; })); return ids.size === 1 ? [...ids][0] : undefined; } /** True when this process runs under a Codex app-server daemon: a hook the * daemon ran carries the environment of the pane that started the daemon, * which is not the pane of the conversation it reports. */ export async function underCodexDaemon(env = process.env, pid = process.pid) { // Set only in the daemon's environment by Codex 0.157; a hint, not a contract. if (env.CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED !== undefined) return true; if (process.platform === "win32") return false; const rows = await processTable(), byPid = new Map(rows.map(row => [row.pid, row])); let current = byPid.get(pid)?.ppid ?? process.ppid; for (let depth = 0; depth < 8 && current > 1; depth++) { const row = byPid.get(current); if (!row) return false; if (isCodexDaemon(row.command)) return true; current = row.ppid; } return false; } /** The session_meta fields identity needs, from a rollout's first line read * at most META_BYTES deep. Codex 0.157 writes its whole base instructions into * that line, so a cut line is read field by field instead of parsed. */ export async function rolloutMeta(file) { const handle = await open(file, "r").catch(() => undefined); if (!handle) return undefined; let text; try { const { buffer, bytesRead } = await handle.read(Buffer.alloc(META_BYTES), 0, META_BYTES, 0); text = buffer.subarray(0, bytesRead).toString("utf8"); } catch { return undefined; } finally { await handle.close(); } const newline = text.indexOf("\n"); let payload; if (newline >= 0) { try { const raw = object(JSON.parse(text.slice(0, newline))); if (raw.type !== "session_meta") return undefined; payload = object(raw.payload); } catch { /* fall through to field reads */ } } if (!payload) { // The row's own type comes before its payload. if (!text.slice(0, 256).includes('"type":"session_meta"')) return undefined; payload = {}; for (const key of ["id", "session_id", "timestamp", "cwd", "thread_source"]) { // An unescaped `"key":"` is a JSON key: inside a string its quotes are escaped. const match = new RegExp(`"${key}":("(?:[^"\\\\]|\\\\.)*")`).exec(text); if (match) try { payload[key] = JSON.parse(match[1]); } catch { /* ignore */ } } if (/"source":\{"subagent"/.test(text)) payload.source = { subagent: true }; } const id = String(payload.id ?? payload.session_id ?? ""), fromName = ROLLOUT.exec(file)?.[1]; const at = Date.parse(String(payload.timestamp ?? "")); if (!sessionId.safeParse(id).success || (fromName && fromName.toLowerCase() !== id.toLowerCase()) || typeof payload.cwd !== "string" || !path.isAbsolute(payload.cwd) || !Number.isFinite(at)) return undefined; const subagent = !!object(payload.source).subagent || (typeof payload.thread_source === "string" && /sub.?agent/i.test(payload.thread_source)); return { id, cwd: payload.cwd, startedAt: at, subagent }; } /** Today's and yesterday's rollouts, newest first by name, capped. */ async function recentRollouts(now) { const files = []; for (const offset of [0, 1]) { const day = new Date(now - offset * 86_400_000); const folder = path.join(codexHome(), "sessions", String(day.getFullYear()), String(day.getMonth() + 1).padStart(2, "0"), String(day.getDate()).padStart(2, "0")); const names = (await readdir(folder).catch(() => [])).filter(name => ROLLOUT.test(name)).sort().reverse(); files.push(...names.slice(0, MAX_ROLLOUTS).map(name => path.join(folder, name))); } return files.slice(0, MAX_ROLLOUTS); } let rollouts; /** Launcher wrappers may have the same command as their native server child. * Count the server once, and keep the shared managed daemon ahead of private * per-pane servers when the bounded probe is full. */ function daemonPids(rows) { const byPid = new Map(rows.map(row => [row.pid, row])); const servers = rows.filter(row => isCodexDaemon(row.command)); const serverIds = new Set(servers.map(row => row.pid)); const wrappers = new Set(); for (const server of servers) { let parent = server.ppid; for (let depth = 0; depth < 8 && parent > 1; depth++) { if (serverIds.has(parent)) wrappers.add(parent); const ancestor = byPid.get(parent); if (!ancestor || ancestor.ppid === parent) break; parent = ancestor.ppid; } } const managed = (row) => /(?:^|\s)--managed-daemon(?:\s|$)/.test(row.command) ? 1 : 0; return servers.filter(row => !wrappers.has(row.pid)) .sort((a, b) => managed(b) - managed(a) || a.startedAt - b.startedAt || a.pid - b.pid) .slice(0, MAX_DAEMONS).map(row => row.pid); } /** * The conversations a running Codex daemon holds open (by `openFiles`, the * caller's lsof or /proc read), else, when it holds none, the recent rollouts * in Codex's sessions folder. Empty when no daemon runs: a TUI that runs its * own conversation holds its rollout itself. */ export function daemonRollouts(openFiles, now = Date.now()) { if (rollouts && now - rollouts.at < CACHE_MS) return rollouts.value; const value = (async () => { const daemons = daemonPids(await processTable(now)); if (!daemons.length) return []; countIdentity("codex-daemon"); const held = (await openFiles(daemons).catch(() => [])).filter(file => ROLLOUT.test(file)).slice(0, MAX_ROLLOUTS); const files = held.length ? held : await recentRollouts(now); const out = []; for (const file of files) { const meta = await rolloutMeta(file); if (!meta || meta.subagent) continue; const info = await stat(file).catch(() => undefined); out.push({ id: meta.id, cwd: meta.cwd, startedAt: meta.startedAt, activeAt: info?.mtimeMs ?? meta.startedAt, held: held.length > 0 }); } return out; })().catch(() => []); rollouts = { at: now, value }; return value; } export function resetCodexDaemonCache() { table = undefined; rollouts = undefined; } const real = async (dir) => realpath(dir).catch(() => path.resolve(dir)); export async function sameDirectory(a, b) { if (typeof a !== "string" || typeof b !== "string" || !path.isAbsolute(a) || !path.isAbsolute(b)) return false; return path.resolve(a) === path.resolve(b) || await real(a) === await real(b); } export function assignDaemonConversation(here, self, claimed, rivals, now = Date.now()) { const open = here.filter(r => !claimed.has(r.id) && r.startedAt <= now + 60_000); const after = (from) => open.filter(r => r.startedAt >= from - START_SLACK_MS); if (!rivals.length) { const pool = after(self.start); const held = pool.filter(r => r.held); const current = (held.length ? held : pool).sort((a, b) => b.activeAt - a.activeAt)[0]?.id; if (current) return current; // An explicit resume may name a conversation older than this TUI. Only // use it when the daemon holds exactly one unclaimed conversation that // has advanced since launch, and no other pane in this folder competes. // A later /new above takes precedence over the stale launch arguments. const resumed = open.filter(r => r.held && r.activeAt >= self.start && r.activeAt <= now + 60_000); return resumed.length === 1 && resumed[0].id === self.resumed ? self.resumed : undefined; } const order = [...rivals, self].sort((a, b) => b.start - a.start || (a.key < b.key ? 1 : a.key > b.key ? -1 : 0)); const taken = new Set(); for (const pane of order) { const pick = after(pane.start).filter(r => !taken.has(r.id)).sort((a, b) => a.startedAt - b.startedAt)[0]; if (pane === self) return pick?.id; if (pick) taken.add(pick.id); } return undefined; }