@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
443 lines (442 loc) • 22.8 kB
JavaScript
import { createHash } from "node:crypto";
import { mkdir, open, readFile, stat } from "node:fs/promises";
import { codexHome } from "../home-paths.js";
import path from "node:path";
import { atomic, bridgeRoot, object, objects, sessionId } from "./protocol.js";
/** Codex 0.155 keeps a thread's history in `thread_history_1.sqlite` and no
* longer writes the rollout file its `state_5.sqlite` still names. The Hook
* materializes such a thread into a rollout-shaped JSONL of its own, append
* only, so every reader of Codex transcripts keeps working unchanged. */
const MAX_ITEMS = 20_000, OUTPUT_TAIL = 4_000;
const STALLED_AFTER_MS = 10 * 60 * 1_000;
export function materializedRoot() { return path.join(bridgeRoot(), "codex-threads"); }
export function materializedPath(session) { return path.join(materializedRoot(), `${session}.jsonl`); }
async function openReadOnly(file) {
try {
await stat(file);
const sqlite = await import("node:sqlite");
return new sqlite.DatabaseSync(file, { readOnly: true });
}
catch {
return undefined;
}
}
/** A working pane whose projection cursor stopped at an unfinished turn can
* keep accepting input even though none of it will be readable again. */
export async function threadHealth(session, agentStatus) {
if (!sessionId.safeParse(session).success || !["working", "blocked", "waiting"].includes(String(agentStatus)))
return { stalled: false };
const stateFile = materializedPath(session) + ".state.json";
let cursor, cursorMtime;
try {
cursor = { lastOrdinal: -1, maxUpdated: -1, count: 0, done: {}, ...object(JSON.parse(await readFile(stateFile, "utf8"))) };
cursorMtime = (await stat(stateFile)).mtimeMs;
}
catch {
return { stalled: false };
}
if (Date.now() - cursorMtime < STALLED_AFTER_MS)
return { stalled: false };
const history = await openReadOnly(path.join(codexHome(), "thread_history_1.sqlite"));
if (!history)
return { stalled: false };
try {
const summary = object(history.prepare("select count(*) as n, max(rollout_ordinal) as last, max(updated_at_ordinal) as updated from thread_items where thread_id = ?").get(session));
// The store may have advanced just before the materializer catches up.
if (cursor.count !== Number(summary.n) || cursor.lastOrdinal !== Number(summary.last) || cursor.maxUpdated !== Number(summary.updated))
return { stalled: false };
const turn = object(history.prepare("select status, rollout_end_ordinal from thread_turns where thread_id = ? order by rollout_ordinal desc limit 1").get(session));
if (turn.status !== "inProgress" || turn.rollout_end_ordinal !== null)
return { stalled: false };
return { stalled: true, since: new Date(cursorMtime).toISOString() };
}
catch {
return { stalled: false };
}
finally {
history.close();
}
}
const materializations = new Map();
/** The thread store accumulates agent-message deltas in place. Only text
* from the newest unfinished message is public; reasoning is never read out. */
function pendingMessage(history, session) {
const turn = object(history.prepare("select turn_id, status, started_at from thread_turns where thread_id = ? order by rollout_ordinal desc limit 1").get(session));
if (turn.status !== "inProgress")
return undefined;
// Queued steering can be appended while the current reply is still growing.
// It does not finish that reply or make its partial text safe to persist.
const row = object(history.prepare(`select item_json from thread_items where thread_id = ? and turn_id = ?
and not (item_type = 'userMessage' and case when json_valid(item_json)
then coalesce(json_extract(item_json, '$.status'), '') else '' end = 'queued')
order by rollout_ordinal desc limit 1`).get(session, turn.turn_id));
let item;
try {
item = object(JSON.parse(String(row.item_json)));
}
catch {
return undefined;
}
if (item.type !== "agentMessage" || finished(item) || item.delivery === "async")
return undefined;
const started = Number(turn.started_at);
if (!Number.isFinite(started) || started <= 0)
return undefined;
return { id: String(item.id), turnStartedAt: new Date(started > 1e12 ? started : started * 1000).toISOString(),
text: typeof item.text === "string" ? item.text.slice(0, 32_768) : "" };
}
export async function codexThreadPreview(session) {
if (!sessionId.safeParse(session).success)
return undefined;
const history = await openReadOnly(path.join(codexHome(), "thread_history_1.sqlite"));
if (!history)
return undefined;
try {
const pending = pendingMessage(history, session);
return pending?.text ? { turnStartedAt: pending.turnStartedAt, text: pending.text } : undefined;
}
catch {
return undefined;
}
finally {
history.close();
}
}
/** Bring the materialized file up to date with the store. Returns the file
* when the thread exists there, undefined otherwise. Safe to call often:
* an unchanged thread costs one aggregate query. */
export async function materializeCodexThread(session) {
if (!sessionId.safeParse(session).success)
return undefined;
const key = materializedPath(session);
const pending = materializations.get(key);
if (pending)
return pending;
const work = materializeThread(session);
materializations.set(key, work);
try {
return await work;
}
finally {
materializations.delete(key);
}
}
async function materializeThread(session) {
if (!sessionId.safeParse(session).success)
return undefined;
const history = await openReadOnly(path.join(codexHome(), "thread_history_1.sqlite"));
if (!history)
return undefined;
try {
const summary = object(history.prepare("select count(*) as n, max(rollout_ordinal) as last, max(updated_at_ordinal) as updated from thread_items where thread_id = ?").get(session));
if (!Number(summary.n))
return undefined;
const file = materializedPath(session), stateFile = file + ".state.json";
await mkdir(materializedRoot(), { recursive: true, mode: 0o700 });
let state = { lastOrdinal: -1, maxUpdated: -1, count: 0, done: {} };
try {
state = { ...state, ...object(JSON.parse(await readFile(stateFile, "utf8"))) };
}
catch { /* first time */ }
const fresh = state.count === 0;
const turn = object(history.prepare("select turn_id, status from thread_turns where thread_id = ? order by rollout_ordinal desc limit 1").get(session));
const turnSignature = JSON.stringify(turn);
if (!fresh && state.count === Number(summary.n) && state.lastOrdinal === Number(summary.last) && state.maxUpdated === Number(summary.updated)
&& state.turnSignature === turnSignature)
return file;
const pending = pendingMessage(history, session);
const rows = objects(history.prepare("select rollout_ordinal, item_type, item_json, updated_at_ordinal from thread_items where thread_id = ? order by rollout_ordinal limit ?").all(session, MAX_ITEMS));
const lines = [];
if (fresh) {
const meta = await threadMeta(session);
// A child thread's row names its parent inside `source`; the child
// agent tree verifies that link from the first line, so it rides along.
lines.push(JSON.stringify({ type: "session_meta", payload: { id: session, ...(meta.cwd ? { cwd: meta.cwd } : {}),
source: meta.spawn ? { subagent: { thread_spawn: meta.spawn } } : "codex-thread-store" } }));
if (meta.model)
lines.push(JSON.stringify({ type: "turn_context", payload: { model: meta.model } }));
}
for (const row of rows) {
let item;
try {
item = object(JSON.parse(String(row.item_json)));
}
catch {
continue;
}
const id = String(item.id ?? `ordinal-${row.rollout_ordinal}`), emitted = state.done[id];
if (item.type === "agentMessage" && id === pending?.id)
continue;
const calls = callRows(item), output = outputRow(item);
// Preserve only queue state actually recorded by the harness. The
// append-only transcript pairs the queued user row with a content-free
// consumption marker when that same item leaves the queue.
if (emitted === "queued" && item.type === "userMessage" && item.status !== "queued") {
lines.push(JSON.stringify({ type: "phren_queue_consumed", key: queueKey(session, id) }));
state.done[id] = "done";
continue;
}
if (!emitted) {
if (calls) {
for (const call of calls)
lines.push(JSON.stringify(call));
state.done[id] = "call";
}
else if (subAgentRow(item)) {
// A child Codex agent (spawn, interaction, completion) rides along
// as the event the rollout carried before 0.155, so the child
// agent tree keeps finding native subagents.
lines.push(JSON.stringify(subAgentRow(item)));
state.done[id] = "done";
continue;
}
else if (messageRow(item)) {
const message = messageRow(item);
const queued = item.type === "userMessage" && item.status === "queued";
lines.push(JSON.stringify(queued ? { ...message, phrenQueued: true, phrenQueueKey: queueKey(session, id) } : message));
// A question the agent asked (delivered async) rides along as the
// same event the rollout would carry, so the pending scan sees it.
const asked = questionEvent(item);
if (asked)
lines.push(JSON.stringify(asked));
state.done[id] = queued ? "queued" : "done";
continue;
}
else if (isQueuedQuestionItem(item)) {
// The queued follow-up question reads in the transcript as the
// asking sentence; the Hook answers it from the store, not by
// quoting a reply, so no async event rides along here.
const title = questionTitle(objects(item.questions));
if (title)
lines.push(JSON.stringify({ type: "response_item", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: title }] } }));
state.done[id] = "done";
continue;
}
else
continue;
}
if (state.done[id] === "call" && output) {
lines.push(JSON.stringify(output));
state.done[id] = "done";
}
}
if (lines.length) {
const handle = await open(file, "a", 0o600);
try {
await handle.appendFile(lines.join("\n") + "\n");
}
finally {
await handle.close();
}
}
state.count = Number(summary.n);
state.lastOrdinal = Number(summary.last);
state.maxUpdated = Number(summary.updated);
state.turnSignature = turnSignature;
await atomic(stateFile, JSON.stringify(state));
return file;
}
catch {
return undefined;
}
finally {
history.close();
}
}
/** Codex 0.155 stores a child agent's lifecycle as `subAgentActivity` items
* with camelCase fields; the rollout shape the child tree parses is the
* `SubAgentActivity` event with snake_case fields. */
function subAgentRow(item) {
if (item.type !== "subAgentActivity")
return undefined;
const kind = String(item.kind ?? ""), child = String(item.agentThreadId ?? item.agent_thread_id ?? "");
if (!child)
return undefined;
return { type: "event_msg", payload: { type: "item_completed", item: {
type: "SubAgentActivity", id: String(item.id ?? ""), kind,
agent_thread_id: child, agent_path: String(item.agentPath ?? item.agent_path ?? ""),
} } };
}
async function threadMeta(session) {
const db = await openReadOnly(path.join(codexHome(), "state_5.sqlite"));
if (!db)
return {};
try {
const row = object(db.prepare("select model, cwd from threads where id = ?").get(session));
let spawn;
try {
// Older stores have no `source` column; a child thread's names its parent there.
const source = object(db.prepare("select source from threads where id = ?").get(session)).source;
if (typeof source === "string" && source.startsWith("{")) {
const parsed = object(object(JSON.parse(source)).subagent).thread_spawn;
if (parsed && typeof parsed === "object" && Object.keys(parsed).length)
spawn = parsed;
}
}
catch { /* no source column or plain text */ }
return { ...(typeof row.model === "string" && row.model ? { model: row.model } : {}), ...(typeof row.cwd === "string" && row.cwd ? { cwd: row.cwd } : {}), ...(spawn ? { spawn } : {}) };
}
catch {
return {};
}
finally {
db.close();
}
}
function finished(item) { return ["completed", "failed", "declined", "error"].includes(String(item.status)) || item.exitCode !== undefined && item.exitCode !== null; }
function queueKey(session, id) {
return createHash("sha256").update(`${session}\0${id}`).digest("hex");
}
/** A message row, for the two message kinds. */
function messageRow(item) {
if (item.type === "userMessage") {
const text = objects(item.content).filter(b => b.type === "text" && typeof b.text === "string").map(b => String(b.text)).join("\n");
return text ? { type: "response_item", payload: { type: "message", role: "user", content: [{ type: "input_text", text }] } } : undefined;
}
if (item.type === "agentMessage" && typeof item.text === "string" && item.text) {
return { type: "response_item", payload: { type: "message", role: "assistant", content: [{ type: "output_text", text: item.text }] } };
}
return undefined;
}
/** Codex 0.155 asks through an agent message with `questions` attached. */
function questionEvent(item) {
if (item.type !== "agentMessage" || item.delivery !== "async" || !Array.isArray(item.questions) || !item.questions.length)
return undefined;
return { type: "event_msg", payload: { type: "item_completed", item: { type: "AgentMessage", id: String(item.id ?? ""),
content: [{ type: "Text", text: String(item.text ?? "") }], delivery: "async", questions: objects(item.questions).slice(0, 8) } } };
}
/** Codex 0.155 records a queued follow-up question as its own thread item
* (`question` or `requestUserInput`), not as a delivered async agent message.
* It sits under the terminal's "Queued follow-up inputs" until alt+up opens
* it, so the Hook reads its text and options straight from the store. */
const QUEUED_QUESTION_TYPES = new Set(["question", "requestUserInput"]);
function isQueuedQuestionItem(item) { return QUEUED_QUESTION_TYPES.has(String(item.type)); }
function questionAnswered(item) {
if (item.answers !== undefined && item.answers !== null)
return true;
return ["answered", "completed", "resolved", "cancelled", "canceled", "error", "declined"].includes(String(item.status ?? ""));
}
function questionTitle(questions) {
return questions.map(raw => {
const q = object(raw);
const text = [q.question, q.title].find(v => typeof v === "string" && v.trim());
return typeof text === "string" ? text.trim() : "";
}).filter(Boolean).join("\n\n").slice(0, 4_000);
}
function questionOptionLabels(questions) {
const first = object(questions[0]);
const raw = first.options;
if (!Array.isArray(raw))
return [];
return raw.map(option => {
if (typeof option === "string")
return option.trim();
const label = object(option).label;
return typeof label === "string" ? label.trim() : "";
}).filter(Boolean).slice(0, 12);
}
/** The pending queued question as the pane's choice shape: the asking
* sentence and one numbered option per choice (Codex answers with number
* keys once alt+up has opened the queue). Undefined when nothing answerable
* remains. */
export async function queuedQuestion(session) {
if (!sessionId.safeParse(session).success)
return undefined;
const db = await openReadOnly(path.join(codexHome(), "thread_history_1.sqlite"));
if (!db)
return undefined;
try {
const rows = objects(db.prepare("select item_json from thread_items where thread_id = ? and item_type in ('question', 'requestUserInput') order by rollout_ordinal desc limit 32").all(session));
for (const row of rows) {
let item;
try {
item = object(JSON.parse(String(row.item_json)));
}
catch {
continue;
}
if (questionAnswered(item))
continue;
const questions = objects(item.questions);
const title = questionTitle(questions);
if (!title)
continue;
const labels = questionOptionLabels(questions);
return { title, options: labels.map((label, index) => ({ label, key: String(index + 1) })) };
}
return undefined;
}
catch {
return undefined;
}
finally {
db.close();
}
}
/** Codex 0.155 code mode stores a generic tool call whose input is JavaScript;
* whichever key holds it, the Hook projects the source in the rollout reader. */
function codeModeInput(item) {
for (const key of ["input", "code", "script"]) {
const value = item[key];
if (typeof value === "string")
return value;
if (value && typeof value === "object" && ["code", "source", "script"].some(k => typeof object(value)[k] === "string"))
return value;
}
return undefined;
}
/** The call row(s) an item starts with; tool inputs, not reasoning. */
function callRows(item) {
const id = String(item.id ?? "");
switch (item.type) {
case "commandExecution":
return [{ type: "response_item", payload: { type: "function_call", name: "shell", call_id: id, arguments: JSON.stringify({ command: String(item.command ?? "").slice(0, 4000), ...(typeof item.cwd === "string" ? { workdir: item.cwd } : {}) }) } }];
case "fileChange":
return [{ type: "response_item", payload: { type: "function_call", name: "apply_patch", call_id: id, arguments: JSON.stringify({ files: objects(item.changes).slice(0, 50).map(c => ({ path: String(c.path ?? ""), kind: String(object(c.kind).type ?? c.kind ?? "") })) }) } }];
case "mcpToolCall":
return [{ type: "response_item", payload: { type: "function_call", name: `mcp__${String(item.server ?? "mcp")}__${String(item.tool ?? "tool")}`, call_id: id, arguments: JSON.stringify(object(item.arguments)) } }];
case "webSearch":
return [{ type: "response_item", payload: { type: "function_call", name: "web_search", call_id: id, arguments: JSON.stringify({ query: String(item.query ?? "").slice(0, 2000) }) } }];
default: {
const input = codeModeInput(item);
if (input === undefined)
return undefined;
return [{ type: "response_item", payload: { type: "custom_tool_call", name: String(item.name ?? item.tool ?? "exec"), call_id: id, input } }];
}
}
}
/** The output row once the item finished. */
function outputRow(item) {
if (!finished(item))
return undefined;
const id = String(item.id ?? "");
switch (item.type) {
case "commandExecution": {
const text = typeof item.aggregatedOutput === "string" ? item.aggregatedOutput.slice(-OUTPUT_TAIL) : "";
const status = typeof item.exitCode === "number" ? `[exit ${item.exitCode}]` : `[${String(item.status)}]`;
return { type: "response_item", payload: { type: "function_call_output", call_id: id, output: `${text}${text && !text.endsWith("\n") ? "\n" : ""}${status}` } };
}
case "fileChange": {
const changes = objects(item.changes).slice(0, 50);
const kind = (c) => { const t = String(object(c.kind).type ?? c.kind ?? "update"); return t === "add" ? "A" : t === "delete" ? "D" : "M"; };
const files = changes.map(c => ({ path: String(c.path ?? ""), status: kind(c), ...(typeof c.diff === "string" ? { patch: c.diff.slice(0, 200_000) } : {}) }));
const output = changes.length ? `${changes.length} file(s) changed\n${files.map(f => f.path).join("\n")}` : "0 file(s) changed";
return { type: "response_item", payload: { type: "function_call_output", call_id: id, output }, ...(files.length ? { phren_changes: { [id]: files } } : {}) };
}
case "mcpToolCall": {
const parts = objects(object(item.result).content).filter(b => b.type === "text" && typeof b.text === "string").map(b => String(b.text));
const output = parts.length ? parts.join("\n").slice(-OUTPUT_TAIL) : typeof item.error === "string" ? item.error.slice(0, 2000) : String(item.status);
return { type: "response_item", payload: { type: "function_call_output", call_id: id, output } };
}
case "webSearch": {
const results = objects(item.results).slice(0, 10).map(r => [r.title, r.snippet].filter(v => typeof v === "string" && v).join(": ")).join("\n");
return { type: "response_item", payload: { type: "function_call_output", call_id: id, output: results || "searched" } };
}
default: {
if (codeModeInput(item) === undefined)
return undefined;
const text = [item.aggregatedOutput, item.output, item.result].find(v => typeof v === "string" && v);
return { type: "response_item", payload: { type: "custom_tool_call_output", call_id: id, output: (text ?? String(item.status ?? "completed")).slice(-OUTPUT_TAIL) } };
}
}
}