@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
459 lines (458 loc) • 25.9 kB
JavaScript
import { readdir, realpath, lstat } from "node:fs/promises";
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { codexHome } from "../home-paths.js";
import { claudeAccountRef, claudeHomeOfPath, claudeHomes } from "./claude-accounts.js";
import { paneAccount, paneAccountKey } from "./pane-accounts.js";
import path from "node:path";
import { withTranscriptIndex } from "./transcript-index.js";
import { BridgeError, object, objects, sessionId } from "./protocol.js";
import { materializeCodexThread, materializedRoot } from "./codex-threads.js";
import { namedPaths, SHELL_TOOLS, outputCallIds } from "./changes.js";
import { fanoutChildren } from "./fanouts.js";
import { claudeChildAgents, visibleClaudeEvent } from "./transcript-claude.js";
import { childTranscriptBelongsTo, codeModeOutputCall, directChildAgents, findCodeModeCall, projectCodeModeOutput, projectCodexRow, rewriteProjectedOutput, visibleCodexEvent } from "./transcript-codex.js";
import { visibleCopilotEvent } from "./transcript-copilot.js";
import { visibleOpencodeEvent } from "./transcript-opencode.js";
export { isNarration, unwrapPastedContent } from "./transcript-claude.js";
export { codeToolCalls, projectCodexRow } from "./transcript-codex.js";
/** Provider-neutral child-agent discovery. Codex currently supplies explicit
* SubAgentActivity links; other providers return no children until their
* public transcript format exposes an equivalent relationship. */
export async function childAgentTree(source, session, depth = 0, seen = new Set(), computer = "local") {
const identity = `${computer}\0${source}\0${session}`;
if (depth >= 4 || seen.size >= 128 || seen.has(identity))
return [];
seen.add(identity);
const fanouts = (await fanoutChildren(source, session, process.env, computer === "local" ? undefined : computer)).map(child => ({
...child, session: child.session ?? child.id, cwd: child.cwd, children: [],
}));
if (!["codex", "claude"].includes(source))
return fanouts;
const file = await transcriptPath(source, session);
if (source === "claude")
return [...await claudeChildAgents(file, session), ...fanouts];
const relations = await directChildAgents(file), verified = [];
for (const relation of relations) {
const childFile = await transcriptPath(source, relation.session).catch(() => undefined);
if (!childFile || !await childTranscriptBelongsTo(childFile, session))
continue;
verified.push({ ...relation, transcript: childFile, provider: source,
id: createHash("sha256").update(`${source}\0${session}\0${relation.session}`).digest("hex").slice(0, 32),
children: await childAgentTree(source, relation.session, depth + 1, seen, computer).catch(() => []) });
}
return [...verified, ...fanouts];
}
/** Explicit wire projection prevents a provider's private transcript identity
* from being returned if relation internals grow later. */
export function publicChildAgents(tree) {
return tree.map(({ id, provider, path: agentPath, callId, state, reason, startedAt, finishedAt, model, worktreeName, branch, computer, remote, fanout, children }) =>
// A blocked worker is finished; the phone's relation contract has no failed
// state, so the reason carries what happened without breaking old clients.
({ id, provider, path: agentPath, callId, state: state === "failed" || state === "gone" ? "completed" : state,
...(state === "failed" || state === "gone" ? { failed: true } : {}),
...(startedAt ? { startedAt } : {}), ...(finishedAt ? { finishedAt } : {}),
...(reason !== undefined ? { reason } : {}), ...(model !== undefined ? { model } : {}),
...(worktreeName !== undefined ? { worktreeName } : {}), ...(branch !== undefined ? { branch } : {}),
...(computer !== undefined ? { computer: { id: computer.id, name: computer.name } } : {}),
...(remote !== undefined ? { remote: { target: remote.target, ...(remote.child !== undefined ? { child: remote.child } : {}) } } : {}),
...(fanout ? { fanout } : {}),
children: publicChildAgents(children) }));
}
export function childAgent(tree, id) {
for (const node of tree) {
if (node.remote !== undefined)
continue;
if (node.id === id && node.session !== undefined && node.transcript !== undefined)
return node;
const nested = childAgent(node.children, id);
if (nested)
return nested;
}
}
/** The running children of a child-agent tree, at any depth: the sub-agents,
* teammates, workflow agents and fan-out jobs a finished turn still waits on. */
export function runningChildAgents(tree) {
return tree.flatMap(function visit(child) {
return [child, ...child.children.flatMap(visit)];
}).filter(child => child.state === "running");
}
/** Preserve content positions and image types; original bytes stay in the
* transcript for the separate image route. Only provider content blocks are
* interpreted, never text strings or arbitrary tool arguments. */
function imageReferences(content, toolResults = false) {
if (!Array.isArray(content))
return content;
return content.map(value => {
const block = object(value);
if (["image", "input_image"].includes(String(block.type)))
return { type: block.type };
if (toolResults && block.type === "tool_result")
return { ...block, content: toolOutputReferences(block.content) };
return value;
});
}
function toolOutputReferences(output) {
if (Array.isArray(output))
return imageReferences(output);
const result = object(output);
return Array.isArray(result.content) ? { ...result, content: imageReferences(result.content) } : output;
}
/**
* The phren store whose `.sessions` holds phren-agent event logs (and
* `.runtime/sessions` OpenCode's plugin logs).
* `PHREN_PATH` or the shared `~/.phren` root — the two resolutions the CLI's
* `findPhrenPath` makes without a working directory, which a service has none
* of. Kept inline rather than importing phren-paths: that module drags in
* yaml and the data layer, and this bundle is budgeted for cold start.
*/
export function phrenStoreRoot(env = process.env) {
const configured = env.PHREN_PATH?.trim();
if (!configured)
return path.join(homedir(), ".phren");
const expanded = configured === "~" ? homedir() : configured.startsWith("~/") ? path.join(homedir(), configured.slice(2)) : configured;
return path.resolve(expanded);
}
function chatFrame(raw, source) {
if (source === "phren" || source === "opencode") {
const data = object(raw.data), message = object(data.message);
return Array.isArray(message.content)
? { ...raw, data: { ...data, message: { ...message, content: imageReferences(message.content, true) } } } : raw;
}
if (source === "copilot") {
const data = object(raw.data);
return { ...raw, data: { ...data,
...(Array.isArray(data.content) ? { content: imageReferences(data.content) } : {}),
...(data.result !== undefined ? { result: toolOutputReferences(data.result) } : {}),
} };
}
const key = source === "codex" ? "payload" : "message";
const message = object(raw[key]);
if (source === "codex" && ["function_call_output", "custom_tool_call_output"].includes(String(message.type))) {
return { ...raw, [key]: { ...message, output: toolOutputReferences(message.output) } };
}
return Array.isArray(message.content)
? { ...raw, [key]: { ...message, content: imageReferences(message.content, source === "claude") } } : raw;
}
/** Compile one path segment to a regexp: `*` and `?` are the only wildcards,
* neither crosses `/`. */
function segmentRegExp(segment) {
const source = segment.split(/([*?])/).map(part => {
if (part === "*")
return "[^/]*";
if (part === "?")
return "[^/]";
return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}).join("");
return new RegExp(`^${source}$`);
}
/** The fixed transcript layouts globbed for: `*` within a single segment, no
* `**`, dotfiles skipped, symlinked directories not followed. Kept native so
* the Hook bundle does not carry the `glob` package. */
async function findPatternMatches(root, pattern) {
const segments = pattern.split("/");
const expressions = segments.map(segmentRegExp);
const matches = [];
const walk = async (dir, index) => {
const segment = segments[index];
const last = index === segments.length - 1;
if (!segment.includes("*") && !segment.includes("?")) {
const next = path.join(dir, segment);
try {
const info = await lstat(next);
if (last) {
// glob returns a symlink to a file here too; the caller's realpath
// check is what rejects one pointing outside the provider folder.
if (info.isFile() || info.isSymbolicLink())
matches.push(next);
}
else if (info.isDirectory()) {
await walk(next, index + 1);
}
}
catch { /* no match */ }
return;
}
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
}
catch {
return;
}
for (const entry of entries) {
if (entry.name.startsWith(".") || !expressions[index].test(entry.name))
continue;
const next = path.join(dir, entry.name);
if (last) {
if (entry.isFile() || entry.isSymbolicLink())
matches.push(next);
}
else if (entry.isDirectory()) {
await walk(next, index + 1);
}
}
};
await walk(root, 0);
return matches;
}
/** The account a resolved Claude transcript belongs to, by the home holding it. */
export function transcriptAccount(file) {
const home = claudeHomeOfPath(file);
return home ? claudeAccountRef(home) : undefined;
}
/** A pane's transcript: the pane's known Claude account settles a session id found in more than one home. */
export function targetTranscriptPath(target) {
const account = target.source === "claude" ? paneAccount(paneAccountKey(target.server, target.pane))?.id : undefined;
return transcriptPath(target.source, target.session, account);
}
/** `account` (a Claude home id) settles a session id found in more than one home. */
export async function transcriptPath(source, session, account) {
if (!sessionId.safeParse(session).success)
throw new BridgeError(400, "Invalid conversation identity.");
const bases = source === "codex" ? [{ base: path.join(codexHome(), "sessions") }]
: source === "claude" ? claudeHomes().map(home => ({ id: home.id, base: path.join(home.dir, "projects") }))
// phren-agent keeps its event logs in the store's `.sessions` (the CLI's
// sessionsDir); OpenCode's plugin writes under `.runtime/sessions`.
: source === "phren" ? [{ base: path.join(phrenStoreRoot(), ".sessions") }, { base: path.join(phrenStoreRoot(), ".runtime", "sessions") }]
: source === "opencode" ? [{ base: path.join(phrenStoreRoot(), ".runtime", "sessions") }]
: [{ base: path.join(process.env.COPILOT_HOME || path.join(homedir(), ".copilot"), "session-state") }];
const pattern = source === "codex" ? `*/*/*/rollout-*-${session}.jsonl` : source === "claude" ? `*/${session}.jsonl`
: source === "phren" ? `session-${session}.events.jsonl` : source === "opencode" ? `opencode-${session}.events.jsonl` : `${session}/events.jsonl`;
const found = [];
for (const entry of bases) {
const root = await realpath(entry.base).catch(() => entry.base);
const matches = await findPatternMatches(root, pattern).catch(() => []);
for (const match of matches)
found.push({ id: entry.id, root, match });
}
let chosen = found;
if (source === "claude" && found.length > 1 && account) {
const preferred = found.filter(item => item.id === account);
if (preferred.length)
chosen = preferred;
}
if (chosen.length !== 1) {
// Codex 0.155 keeps new threads only in its sqlite store; the Hook
// materializes those into a rollout-shaped file of its own.
const materialized = source === "codex" ? await materializeCodexThread(session) : undefined;
if (materialized)
return materialized;
throw new BridgeError(404, "The transcript is not available for this conversation.", { code: "transcript-unavailable" });
}
const file = await realpath(chosen[0].match);
if (!file.startsWith(chosen[0].root + path.sep))
throw new BridgeError(403, "The transcript points outside its agent folder.");
return file;
}
/** Keeps a materialized Codex thread current before a read; a no-op for
* transcripts the agent writes itself. */
export async function refreshTranscript(file, source, session) {
if (source === "codex" && file.startsWith(materializedRoot() + path.sep))
await materializeCodexThread(session);
}
/** Public conversation/tool events and real usage only. Never export private reasoning. */
export function visibleEvent(raw, source, includeSidechain = false, cwd, queue) {
if (source === "phren" || source === "opencode")
return visibleOpencodeEvent(raw, source, cwd);
if (source === "codex")
return visibleCodexEvent(raw);
if (source === "claude")
return visibleClaudeEvent(raw, includeSidechain, queue);
return visibleCopilotEvent(raw);
}
/** Parse only the requested page; shared byte indexes make reopening and
* backward pagination independent of the amount of already-read history. */
export class TranscriptReader {
file;
source;
imageLine;
changes;
includeSidechain;
cwd;
revision;
nextLine = 0;
constructor(file, source, imageLine, changes, includeSidechain = false, cwd) {
this.file = file;
this.source = source;
this.imageLine = imageLine;
this.changes = changes;
this.includeSidechain = includeSidechain;
this.cwd = cwd;
}
async read(before, signal) {
return this.readPage(before, undefined, signal);
}
/** Resume a fresh live reader after the last raw line the client retained. */
async readAfter(afterLine, signal) {
if (!Number.isSafeInteger(afterLine) || afterLine < 0)
throw new BridgeError(400, "Invalid transcript cursor.");
return this.readPage(undefined, afterLine, signal);
}
async readPage(before, afterLine, signal) {
return withTranscriptIndex(this.file, async (handle, index) => {
const reset = this.revision !== index.revision;
const end = Math.min(before ?? index.lines, index.lines);
const resuming = before === undefined && afterLine !== undefined && this.revision === undefined;
// The phone's cursor can sit past the end after the file was replaced
// (compaction, a rewritten thread). A resume into that gap is a full
// snapshot of the new file, flagged as the replacement it is; an
// in-range resume is only a delta and never claims a replacement.
const pastEnd = resuming && afterLine >= index.lines;
const lower = this.imageLine ?? (pastEnd ? 0
: resuming ? Math.min(afterLine + 1, index.lines)
: reset || before !== undefined ? 0 : this.nextLine);
const entries = [];
// The first page of a conversation is what the phone parses and lays
// out before anything shows; keep it light and let scrolling fetch the
// rest in fuller pages. A live tail (nextLine known) stays small too.
const opening = before === undefined && (pastEnd || (!resuming && (reset || this.nextLine === 0)));
const entryBudget = opening ? 60 : 200;
const byteBudget = opening ? 1_048_576 : 4_194_304;
let bytes = 0, cursor = end, held;
// Claude's queue pairing: the row scan is newest-first, so a prompt turn
// is seen before the content-free dequeue that delivered it.
const queue = {};
for await (const row of index.rows(handle, end, lower, signal)) {
signal?.throwIfAborted();
let rows = [];
try {
let raw = row.bytes && visibleEvent(object(JSON.parse(row.bytes.toString())), this.source, this.includeSidechain, this.cwd, queue);
// A child agent's transcript is the sidechain. Its rows are that
// conversation's own turns, not something for the reader to skip.
if (raw && this.includeSidechain && raw.isSidechain === true) {
const { isSidechain: _sidechain, ...own } = raw;
raw = own;
}
// A code-mode script's result follows from the script it answers,
// a few rows earlier: hidden when its items carry the actions.
const codeModeCall = raw && this.source === "codex" ? codeModeOutputCall(raw) : undefined;
if (raw && codeModeCall)
raw = projectCodeModeOutput(raw, await findCodeModeCall((upper, lower) => index.rows(handle, upper, lower, signal), row.line, codeModeCall));
if (raw) {
if (this.changes && this.imageLine === undefined) {
// A shell call's output carries what it changed on disk. While
// that diff is still being computed on the live tail, the row
// (and the newer rows already collected) waits for the next read.
const ids = outputCallIds(raw, this.source);
if (before === undefined && ids.some(id => this.changes.pending(id))) {
held = row.line;
entries.length = 0;
bytes = 0;
cursor = row.line;
continue;
}
const attached = {};
for (const id of ids) {
const files = await this.changes.changes(id);
if (files)
attached[id] = files;
}
// A row that already carries a worker's own diff (OpenCode edit,
// write or patch) keeps it; the shell lookup only fills in the rest.
if (Object.keys(attached).length)
raw = { ...raw, phren_changes: { ...attached, ...object(raw.phren_changes) } };
}
const projected = this.source === "codex" ? projectCodexRow(raw) : { rows: [raw] };
rows = this.imageLine === row.line ? projected.rows : projected.rows.map(r => chatFrame(r, this.source));
if (projected.base)
rewriteProjectedOutput(entries, projected.base);
}
}
catch { /* A malformed old row cannot block the next readable page. */ }
if (rows.length) {
const size = Buffer.byteLength(JSON.stringify(rows));
if (this.imageLine !== undefined || size < 2_097_152) {
// Leave an entry that doesn't fit for the following history page.
if (this.imageLine === undefined && bytes + size > byteBudget)
break;
// Rows arrive newest first; reverse a row's blocks so the page
// lists several calls from one source in the order they were written.
entries.push(...rows.slice().reverse().map(raw => ({ line: row.line, raw })));
bytes += size;
}
}
cursor = row.line;
if (entries.length >= entryBudget)
break;
}
if (before === undefined) {
this.revision = index.revision;
this.nextLine = held ?? index.lines;
}
// Live reads run newest first. After a long disconnect or a burst of
// output, a bounded page can stop before the phone's cursor. Mark that
// page as a replacement so its start becomes a usable older-page cursor
// instead of silently merging two disjoint windows. A pending diff
// holds its row for a later poll and is not a completed page.
const skippedLiveLines = before === undefined && held === undefined && cursor > lower;
// The phone resumes after `totalLines - 1`, so a live page does not
// count a held row or what follows it: those are still owed. An older
// page is never a replacement. The history route reads with a fresh
// reader, and a reset there made the phone drop every recent row it
// held and keep only the older page.
return { entries: entries.reverse(), totalLines: held ?? index.lines,
startLine: cursor, hasMore: cursor > 0,
reset: before !== undefined ? false : (resuming && !pastEnd ? false : reset) || skippedLiveLines };
}, signal);
}
}
/** One embedded image: block `block` of the row's content — or, with
* `inner`, image `inner` inside that block's tool_result content (what a
* Read of a PNG returns). For Codex, an output row's array is the content. */
export async function historicalImage(file, line, block, source, inner) {
if (!Number.isSafeInteger(line) || line < 0 || !Number.isSafeInteger(block) || block < 0 || block > 2000)
throw new BridgeError(400, "Invalid image reference.");
if (inner !== undefined && (!Number.isSafeInteger(inner) || inner < 0 || inner > 2000))
throw new BridgeError(400, "Invalid image reference.");
const reader = new TranscriptReader(file, source, line);
const page = await reader.read(line + 1);
const row = page.entries.find(e => e.line === line)?.raw;
if (!row)
throw new BridgeError(404, "This image is no longer in the transcript.");
const payload = source === "codex" ? object(row.payload) : source === "phren" || source === "opencode" ? object(object(row.data).message) : object(row.message);
const content = objects(source === "codex" && Array.isArray(payload.output) ? payload.output : payload.content);
let image = content[block];
if (inner !== undefined)
image = objects(image?.content)[inner];
const encoded = image?.image_url || object(image?.source).data;
const base64 = typeof encoded === "string" ? encoded.replace(/^data:image\/[a-z0-9.+-]+;base64,/i, "") : "";
if (!base64 || !/^[A-Za-z0-9+/=\s]+$/.test(base64) || base64.length > 11_184_812)
throw new BridgeError(404, "This image is not embedded in the transcript.");
const bytes = Buffer.from(base64, "base64");
if (bytes.length > 8_388_608)
throw new BridgeError(413, "This image is too large.");
return bytes;
}
/** Derive optional diff scope from local tool-call rows, never phone commands. */
export async function conversationNamedPaths(file, source, cwd, signal) {
return withTranscriptIndex(file, async (handle, index) => {
const paths = new Set();
for await (const row of index.rows(handle, index.lines, 0, signal)) {
signal?.throwIfAborted();
try {
const raw = row.bytes && visibleEvent(object(JSON.parse(row.bytes.toString())), source);
if (!raw)
continue;
const payload = object(raw.payload), data = object(raw.data);
const calls = source === "codex" ? (["function_call", "custom_tool_call"].includes(String(payload.type)) ? [payload] : [])
: source === "copilot" ? (raw.type === "tool.execution_start" ? [data] : [])
: objects(object(source === "phren" || source === "opencode" ? data.message : raw.message).content).filter(b => b.type === "tool_use");
for (const call of calls) {
if (!SHELL_TOOLS.has(String(call.name ?? call.toolName)))
continue;
const args = call.arguments ?? call.input;
const input = typeof args === "string" ? object(JSON.parse(args)) : object(args);
const command = input.command ?? input.cmd;
if (typeof command !== "string")
continue;
const base = [input.workdir, input.cwd, raw.cwd, cwd].find(v => typeof v === "string" && path.isAbsolute(v));
for (const named of namedPaths(command))
paths.add(named.startsWith("~/") ? path.join(homedir(), named.slice(2)) : path.resolve(base, named));
}
}
catch { /* Malformed or private rows grant no additional scope. */ }
}
return [...paths];
}, signal);
}