@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
297 lines (296 loc) • 19.3 kB
JavaScript
import { WebSocket } from "ws";
import { z } from "zod";
import { logger } from "../logger.js";
import { queuedQuestion, threadHealth } from "./codex-threads.js";
import { SNAPSHOT_SHARE_MS, trustedDirectory, validateTarget } from "./herdr.js";
import { repositoryBranch } from "./projects.js";
import { BridgeError, MAX_FRAME, object, targetFromURL } from "./protocol.js";
import { TranscriptPreviewStream } from "./transcript-preview.js";
import { childAgent, childAgentTree, refreshTranscript, targetTranscriptPath, TranscriptReader } from "./transcripts.js";
import { countTick } from "./metrics.js";
import { approvalSummary } from "./approval-summary.js";
import { codexServers } from "./codex-servers.js";
import { settingsCapabilities } from "./settings-switch.js";
function send(socket, frame) {
if (socket.readyState !== WebSocket.OPEN)
return;
const data = JSON.stringify(frame);
if (Buffer.byteLength(data) > MAX_FRAME || socket.bufferedAmount > MAX_FRAME) {
socket.close(1009, "Reconnect to resume the conversation");
return;
}
socket.send(data);
}
/**
* Why a transcript or status stream closed. Only a real target change (the
* 409 validation) says the conversation changed; I/O, parse and git failures
* keep their own words, with absolute paths cut to their last component and
* the reason bounded to WebSocket's 123-byte close limit.
*/
export function streamCloseReason(error) {
if (error instanceof BridgeError && error.status === 409)
return "The conversation changed; refresh";
const code = error?.code;
const first = (error instanceof Error ? error.message : String(error)).split("\n")[0]
.replace(/[\x00-\x1f\x7f]/g, " ").replace(/(?:~|\/)[^\s'",:]*\/([^\s'",:/]+)/g, "$1").trim();
let reason = error instanceof BridgeError ? first
: `Stream failed: ${typeof code === "string" && !first.startsWith(code) ? `${code} ` : ""}${first || "unknown error"}`;
while (Buffer.byteLength(reason) > 123)
reason = reason.slice(0, -1);
return reason;
}
export function transcriptStreams(ctx) {
const { modules, agentHooks, codexQuestions, sideQuestions, settingsSwitcher, info, activeCapabilities } = ctx;
/** A conversation the agent has identified but not written yet (Claude
* Code creates its file on the first turn) is an empty transcript, not a
* missing one: `reader` stays undefined until the file appears. */
async function conversationReader(target) {
try {
const reader = new TranscriptReader(await targetTranscriptPath(target), target.source, undefined, modules.has("git") ? agentHooks.changes.view(`${target.source}:${target.session}`) : undefined);
return { reader, source: target.source, session: target.session };
}
catch (error) {
if (error instanceof BridgeError && error.status === 404)
return { source: target.source, session: target.session };
throw error;
}
}
const emptyPage = { entries: [], totalLines: 0, startLine: 0, hasMore: false, reset: true };
/** The transcript of an agent the conversation spawned. The child is a
* parent-scoped id from `/v1/subagents`; the file is only ever reached
* through the relation, never by a path or session the phone names. */
async function childConversationReader(target, child) {
const relation = childAgent(await childAgentTree(target.source, target.session), child);
if (!relation)
throw new BridgeError(404, "This child agent does not belong to the selected conversation.");
const reader = new TranscriptReader(relation.transcript, relation.provider, undefined, undefined, relation.provider === "claude", relation.cwd);
return { reader, source: relation.provider, session: relation.id };
}
async function stream(client, url) {
const abort = new AbortController();
let reader, timer;
let unwatch;
let busy = false, ready = false, first = true;
let awaitingTranscript = false, lastTranscriptLookup = 0;
let initialPane;
let busyAt = 0;
const pending = [];
const stop = () => { abort.abort(); clearInterval(timer); unwatch?.(); pending.length = 0; };
client.once("close", stop);
client.once("error", stop);
// Even rejected upgrades may already contain invalid WebSocket frames.
// Handle their errors before parsing any untrusted destination fields.
const target = targetFromURL(url);
const previews = new TranscriptPreviewStream(target);
// A child agent's transcript streams through the same socket, bound to
// the parent conversation: the parent target is what gets revalidated
// each tick, and the frames name the child by its parent-scoped id.
const child = url.pathname === "/v1/transcripts" ? url.searchParams.get("child") : null;
const cursor = url.pathname === "/v1/transcripts" ? url.searchParams.get("afterLine") : null;
// Side answers (Claude's `/btw`) ride the parent conversation's stream
// for a phone that asked for them; an older phone rejects unknown frames.
const sideAnswers = url.pathname === "/v1/transcripts" && child === null && url.searchParams.get("sideAnswers") === "1";
const sentSide = new Map();
// A phone message's outcome by its delivery id (`/v1/prompt/status`),
// pushed as it changes to a stream opened with `deliveries=1`.
const deliveryFrames = url.pathname === "/v1/transcripts" && child === null && url.searchParams.get("deliveries") === "1";
const sentDelivery = new Map();
// The first frame stays a backlog for protocol compatibility, but a
// reconnect only reads rows beyond the phone's retained raw-line cursor.
let resumeAfterLine = cursor === null ? undefined : z.coerce.number().int().nonnegative().max(4_294_967_295).parse(cursor);
let conversation = { source: target.source, session: target.session };
// The transcript file of a fresh conversation appears with its first
// turn; until then the socket carries an empty backlog and keeps looking.
const findTranscript = async () => {
if (Date.now() - lastTranscriptLookup < 2_000)
return;
lastTranscriptLookup = Date.now();
const opened = await conversationReader(target);
if (opened.reader) {
reader = opened.reader;
conversation = { source: opened.source, session: opened.session };
awaitingTranscript = false;
}
};
const tick = async () => {
if (busy || !ready || abort.signal.aborted)
return;
busy = true;
try {
if (first || pending.length === 0) {
const pane = first ? initialPane : await validateTarget(target, false, false, SNAPSHOT_SHARE_MS);
if (awaitingTranscript)
await findTranscript();
if (awaitingTranscript) {
if (first)
send(client, { ...emptyPage, type: "backlog", ...conversation });
}
else if (reader) {
await refreshTranscript(reader.file, conversation.source, conversation.session);
if (first && child === null && target.source === "claude" && resumeAfterLine !== undefined) {
// A reconnect cursor can omit the current user row entirely.
// Seed only ephemeral turn state from the bounded recent tail.
previews.observe((await new TranscriptReader(reader.file, conversation.source).read(undefined, abort.signal)).entries);
}
const page = resumeAfterLine === undefined
? await reader.read(undefined, abort.signal)
: await reader.readAfter(resumeAfterLine, abort.signal);
resumeAfterLine = undefined;
if (child === null)
previews.observe(page.entries, page.reset);
const preview = child === null ? await previews.update(pane.agent_status, reader.file) : undefined;
// `activityVerb` stays for phones that predate `activity`.
const activity = previews.activity ? { activityVerb: previews.activity.verb, activity: { ...previews.activity } }
: previews.verb ? { activityVerb: previews.verb } : {};
if (first || page.entries.length || page.reset)
send(client, { ...page, ...preview, ...activity, type: first || page.reset ? "backlog" : "append", ...conversation });
else if (preview)
send(client, { type: "preview", ...conversation, ...preview, ...activity });
}
else {
let pendingApproval = agentHooks.approval(target);
const pendingQuestions = target.source === "codex" ? await codexQuestions.pending(target).catch(() => undefined) : undefined;
const cwd = await trustedDirectory(pane).catch(() => undefined);
const branch = modules.has("git") && cwd ? await repositoryBranch(cwd) : undefined;
const waiting = !pendingApproval && ["blocked", "waiting"].includes(String(pane.agent_status));
// Claude Code's auto-mode fallback, opencode and Codex draw a
// numbered dialog in the pane with no PermissionRequest hook
// behind it: read the pane (at most once per three seconds) and
// publish the dialog as the same terminal choice shape the phone
// answers. Codex also resolves the real terminal choices for a held
// approval; a structured question keeps its own answer channel.
if (["claude", "opencode", "codex"].includes(target.source)) {
await agentHooks.syncTerminalDialog(target, waiting && !pendingQuestions?.length);
pendingApproval = agentHooks.approval(target);
}
// A served OpenCode pane's question comes from its own API, whatever
// status its terminal reports.
const servedQuestion = agentHooks.servedQuestion(target);
const hookPrompt = servedQuestion ?? (waiting ? agentHooks.terminalPrompt(target) : undefined);
// Codex 0.155's queued follow-up question never becomes a held
// PermissionRequest: it lives as a thread item the terminal shows
// under "Queued follow-up inputs". With nothing else to ask, read
// its text and options and publish them as the same choice shape
// the phone already answers with keys (alt+up, then the option).
const terminalPrompt = hookPrompt
?? (waiting && target.source === "codex" && !pendingQuestions?.length
? await queuedQuestion(target.session).then(queued => queued ? {
toolName: "Question", message: queued.title, request: approvalSummary({ tool: "Question", message: queued.title, question: true }).request, queued: true,
choice: { title: queued.title, options: queued.options },
} : undefined).catch(() => undefined)
: undefined);
// Claude's own state comes from its footer, which leads the transcript.
const codexServed = target.source === "codex" && !!codexServers.forTarget(target);
const { settings, settingsState, suggestion, permissionMode, permissionModes } = settingsSwitcher ? await settingsSwitcher.streamSettings(target, pane.terminal_id, codexServed)
: { settings: settingsCapabilities(target.source, codexServed), settingsState: undefined, suggestion: undefined, permissionMode: undefined, permissionModes: undefined };
// Claude's suggested next prompt, only between turns. The footer
// read can be a throttle old, so one from before a turn this
// stream saw start is stale: the turn cleared it.
const between = ["idle", "done"].includes(String(pane.agent_status)) && !pendingApproval && !servedQuestion;
if (!between)
busyAt = Date.now();
const nextPrompt = between && suggestion && suggestion.readAt > busyAt ? suggestion.text : undefined;
const historyHealth = target.source === "codex" ? await threadHealth(target.session, pane.agent_status) : { stalled: false };
send(client, { agentStatus: { source: target.source, session: target.session,
status: pendingApproval || servedQuestion ? "waiting" : pane.agent_status, pendingApproval, pendingQuestions, terminalPrompt,
...(waiting && agentHooks.passwordPrompt(target) ? { passwordPrompt: true } : {}),
compacting: agentHooks.compacting(target),
...(nextPrompt ? { suggestion: nextPrompt } : {}),
// The mode Claude's footer shows and the cycle its picker offers,
// only from a confident read (a Claude pane's readable footer).
...(permissionMode ? { permissionMode, permissionModes } : {}),
...(historyHealth.stalled ? { historyStalled: true, historyStalledSince: historyHealth.since } : {}),
modules: info.modules, store: info.store, profile: info.profile, generation: info.generation,
capabilities: { ...activeCapabilities, asyncQuestions: codexQuestions.availableFor(target),
// Answers to Codex questions may carry the phone's uploads.
...(codexQuestions.availableFor(target) ? { questionAttachments: true } : {}),
// Claude's AskUserQuestion is answered in its terminal dialog
// through /v1/questions/answer, whether or not a hold caught it.
...(target.source === "claude" || servedQuestion ? { questions: true } : {}),
// What the composer's permission, plan and fast chips can change here.
...(settings ? { settings } : {}) }, ...(settingsState ? { settingsState } : {}), branch } });
}
if (sideAnswers && sideQuestions) {
for (const { revision, ...side } of sideQuestions.list(target)) {
if (sentSide.get(side.id) === revision)
continue;
sentSide.set(side.id, revision);
send(client, { type: "side-answer", ...conversation, ...side });
}
}
if (deliveryFrames) {
// Every message typed into this pane, also those sent while an
// earlier conversation was showing there.
for (const { deliveryId, state, reason } of agentHooks.deliveries.forPane(target)) {
if (sentDelivery.get(deliveryId) === state)
continue;
sentDelivery.set(deliveryId, state);
send(client, { type: "delivery", ...conversation, deliveryId, state, ...(reason ? { reason } : {}) });
}
}
first = false;
}
while (pending.length && reader && !abort.signal.aborted) {
const before = pending.shift();
await validateTarget(target, false, false, SNAPSHOT_SHARE_MS);
const page = await reader.read(before, abort.signal);
send(client, { ...page, type: "older", ...conversation });
}
}
catch (error) {
const reason = streamCloseReason(error);
if (!(error instanceof BridgeError && error.status === 409))
logger.warn("stream", `${url.pathname} closed: ${reason}`);
stop();
client.close(1011, reason);
}
finally {
busy = false;
if (pending.length)
void tick();
}
};
client.on("message", bytes => {
try {
const message = object(JSON.parse(bytes.toString()));
if (message.type !== "older" || url.pathname !== "/v1/transcripts" || abort.signal.aborted)
return;
const before = z.number().int().positive().parse(message.beforeLine);
if (pending.length >= 8)
throw new Error("History queue is full");
if (!pending.includes(before))
pending.push(before);
void tick();
}
catch {
stop();
client.close(1008, "Invalid history request");
}
});
try {
initialPane = await validateTarget(target);
if (abort.signal.aborted)
return;
unwatch = agentHooks.watch(target);
if (url.pathname === "/v1/transcripts") {
const opened = child === null ? await conversationReader(target) : await childConversationReader(target, child);
reader = opened.reader;
conversation = { source: opened.source, session: opened.session };
awaitingTranscript = child === null && !reader;
lastTranscriptLookup = Date.now();
}
if (abort.signal.aborted) {
stop();
return;
}
ready = true;
timer = setInterval(() => { countTick(url.pathname === "/v1/transcripts" ? "stream-transcripts" : "stream-status"); void tick(); }, reader || awaitingTranscript ? 500 : 1500);
await tick();
}
catch (error) {
stop();
throw error;
}
}
return { conversationReader, childConversationReader, emptyPage, stream };
}