@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
236 lines (235 loc) • 11.7 kB
JavaScript
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;
}