@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
488 lines (487 loc) • 25.7 kB
JavaScript
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { open } from "node:fs/promises";
import { z } from "zod";
import { atomicInPrivateDir, object, objects, provider } from "./protocol.js";
import { transcriptPath, unwrapPastedContent } from "./transcripts.js";
/** A message is kept this long, by its id, so a late status or a retry still
* finds it: a busy Codex turn can run far past the old ten minutes. */
export const DELIVERY_RETENTION_MS = 24 * 3_600_000;
/** An agent that ended its turn after the message was typed and then stayed
* idle this long without taking it has dropped it. */
export const DELIVERY_GIVE_UP_MS = 30_000;
const MAX_RECORDS = 512;
/** How often a message still held by a working agent is looked for in its transcript. */
const LANDED_CHECK_MS = 3_000;
/** The transcript scan starts this long before a message was typed (clock skew), so entries up to this far back may belong to an earlier message. */
const ENTRY_SLACK_MS = 5_000;
/** What the agent hands its UserPromptSubmit hook is the terminal's pasted
* form of what Phren typed; compare the words, not the wrapping. Claude Code
* also takes each attached picture's path line out of the text and puts an
* "[Image #N]" label at the front, so neither side keeps picture paths or
* labels. */
export function promptKey(text) {
return unwrapPastedContent(text).split("\n").filter(line => !PICTURE_PATH_LINE.test(line)).join("\n")
.replace(/\[Image #\d+\]/g, " ").replace(/\s+/g, " ").trim();
}
const PICTURE_PATH_LINE = /^\s*\/.*\.(?:png|jpe?g|gif|webp)\s*$/i;
export const promptHash = (text) => createHash("sha256").update(promptKey(text)).digest("hex");
const recordSchema = z.object({
id: z.string().regex(/^[A-Za-z0-9_-]{8,64}$/).optional(), hash: z.string().regex(/^[0-9a-f]{64}$/),
server: z.string().min(1).max(200), pane: z.string().min(1).max(200), source: provider, session: z.string().min(1).max(200),
terminal: z.string().max(200).optional(), state: z.enum(["typed", "queued", "delivered", "failed"]),
reason: z.string().max(500).optional(), at: z.number(), settledAt: z.number().optional(), stoppedAt: z.number().optional(),
/** The transcript entry (byte offset) that settled it, so no other message claims the same one. */
entry: z.number().optional(),
}).strict();
/** One message per record, bound to the pane it was typed into (server, pane,
* agent kind), not to the conversation: a Codex thread or a Claude /clear can
* replace the conversation while the agent still holds the typed text, and
* the conversation that submits it is the one it reached. Records hold a hash
* of the words, never the text, and those with a phone id are written to
* `file` so a Hook restart keeps their state. */
export class PromptDeliveries {
file;
now;
landed;
records = [];
waiters = new Map();
writing = Promise.resolve();
/** Panes (server, pane, agent) that sent a UserPromptSubmit or Stop since the Hook started. */
hookPanes = new Set();
paneKey(target) { return [target.server, target.pane, target.source].join("|"); }
static open(record) { return record.state === "typed" || record.state === "queued"; }
/** Open, or failed with its own hook's claim still waiting for its row (a late delivery). */
static claimable(record) { return PromptDeliveries.open(record) || (record.state === "failed" && !!record.hookClaim); }
constructor(file, now = Date.now, landed = transcriptEntries) {
this.file = file;
this.now = now;
this.landed = landed;
if (!file)
return;
try {
for (const row of objects(object(JSON.parse(readFileSync(file, "utf8"))).records)) {
const parsed = recordSchema.safeParse(row);
// A message the Hook was typing when it stopped is in the pane or
// nowhere: wait for the agent, and give up as for any other.
if (parsed.success)
this.records.push({ ...parsed.data, state: parsed.data.state === "typed" ? "queued" : parsed.data.state });
}
}
catch { /* No file yet, or an unreadable one: start empty. */ }
this.prune();
}
samePane(record, target) {
return record.server === target.server && record.pane === target.pane && record.source === target.source;
}
byId(id) { return this.records.find(record => record.id === id); }
prune() {
const cutoff = this.now() - DELIVERY_RETENTION_MS;
this.records = this.records.filter(record => record.at >= cutoff || this.waiters.has(record));
while (this.records.length > MAX_RECORDS)
this.records.shift();
}
save() {
if (!this.file)
return;
// A record without a phone id is kept once it claimed a transcript row, so after a restart that row is not offered to another message.
const rows = this.records.filter(record => record.id || record.entry !== undefined).map(({ idleSince: _, checkedAt: __, hookClaim: ___, seenRow: ____, ...row }) => row);
const file = this.file;
this.writing = this.writing.then(() => atomicInPrivateDir(file, { records: rows })).catch(() => undefined);
}
/** Settled writes, for tests and an orderly shutdown. */
flush() { return this.writing; }
/** Register a message about to be typed into `target`'s pane. Settles
* "delivered" once a conversation in that pane submits the words, or
* "pending" after `waitMs`; the record outlives the wait. A retry of a
* failed id replaces its record. Aborting (the terminal refused before
* writing) drops it. */
expect(target, text, waitMs, signal, id, terminal) {
if (!promptKey(text) || signal?.aborted)
return Promise.resolve("pending");
this.prune();
if (id)
this.records = this.records.filter(record => record.id !== id);
const record = { ...(id ? { id } : {}), hash: promptHash(text), server: target.server, pane: target.pane, source: target.source,
session: target.session, ...(typeof terminal === "string" ? { terminal } : {}), state: "typed", at: this.now() };
this.records.push(record);
this.save();
return new Promise(resolve => {
const timer = setTimeout(() => this.release(record, "pending"), waitMs);
timer.unref?.();
this.waiters.set(record, { resolve, timer });
signal?.addEventListener("abort", () => {
this.release(record, "pending");
if (record.state === "typed") {
this.records = this.records.filter(entry => entry !== record);
this.save();
}
}, { once: true });
});
}
release(record, outcome) {
const waiter = this.waiters.get(record);
if (!waiter)
return;
clearTimeout(waiter.timer);
waiter.resolve?.(outcome);
waiter.resolve = undefined;
if (outcome !== "pending")
waiter.late?.(outcome);
if (outcome !== "pending" || !waiter.late)
this.waiters.delete(record);
}
open(target, text) {
const hash = promptHash(text);
return this.records.find(record => record.hash === hash && this.samePane(record, target) && (record.state === "typed" || record.state === "queued"));
}
/** A message Phren typed into `target`'s pane that no conversation there has submitted yet. */
pending(target, text) { return !!this.open(target, text); }
/** Wait again for a message `expect` reported pending, after a second Enter. */
awaitLate(target, text, waitMs = 2_500) {
const record = this.open(target, text);
if (!record)
return Promise.resolve("pending");
return new Promise(resolve => {
const waiter = this.waiters.get(record) ?? {};
const timer = setTimeout(() => { waiter.late = undefined; if (!waiter.resolve)
this.waiters.delete(record); resolve("pending"); }, waitMs);
timer.unref?.();
waiter.late = outcome => { clearTimeout(timer); waiter.late = undefined; resolve(outcome); };
this.waiters.set(record, waiter);
});
}
find(id, target) {
const record = this.byId(id);
return record && this.samePane(record, target) && this.now() - record.at <= DELIVERY_RETENTION_MS ? record : undefined;
}
/** The Hook answered `id` as typed and held by the agent. */
queue(id, target) {
const record = this.find(id, target);
if (record?.state === "typed") {
record.state = "queued";
this.save();
}
}
/** Delivered without a hook: an app-server turn or a served OpenCode prompt. */
delivered(id, target, text) {
if (!id)
return;
this.records = this.records.filter(record => record.id !== id);
this.records.push({ id, hash: promptHash(text), server: target.server, pane: target.pane, source: target.source, session: target.session,
state: "delivered", at: this.now(), settledAt: this.now() });
this.save();
}
/** The message did not reach the agent, and why. It stays matchable, so a
* late submission still turns it delivered. */
fail(id, target, reason) {
const record = id ? this.find(id, target) : undefined;
if (!record || record.state === "delivered")
return;
record.state = "failed";
record.reason = reason.slice(0, 500);
record.settledAt = this.now();
this.save();
}
/** The pane's Codex server moved to another thread (/new, /resume):
* input still pending on the old one stays there, out of the pane's view.
* Fails loudly; a late UserPromptSubmit from the old thread still settles it. */
threadChanged(previous) {
let changed = false;
for (const record of this.records)
if ((record.state === "typed" || record.state === "queued") && this.samePane(record, previous) && record.session === previous.session) {
record.state = "failed";
record.settledAt = this.now();
changed = true;
record.reason = "The pane moved to another Codex conversation before the agent took the message.";
}
if (changed)
this.save();
}
/** False when `id` names another message: other words or another pane. */
sameMessage(id, target, text) {
const record = this.byId(id);
return !record || (this.samePane(record, target) && record.hash === promptHash(text));
}
/** What became of phone message `id` in `target`'s pane. `unknown` while the
* Hook has not answered it, for another pane, or past retention. */
status(id, target) {
const record = this.find(id, target);
if (!record || record.state === "typed")
return { state: "unknown" };
return { state: record.state, ...(record.reason && record.state === "failed" ? { reason: record.reason } : {}), session: record.session };
}
/** Every known phone message in `target`'s pane, for its conversation's stream. */
forPane(target) {
return this.records.filter(record => record.id && this.samePane(record, target)).map(record => ({ deliveryId: record.id, ...this.status(record.id, target) }))
.filter(item => item.state !== "unknown");
}
/** Conversation `target` just submitted `prompt`. Its own record wins, then
* the oldest open one in the same pane: the agent kept the text across a
* conversation change, and the conversation now in the pane is where it
* went. A prompt nothing matches was typed locally. Never refused.
* For Claude the transcript is consulted first and the submission claims
* the earliest unclaimed user row with these words, through the same
* `settle` and the same table of claimed entries the idle check uses: a
* callback for a row already claimed (the idle check settled it, the hook
* came late) settles nothing more. */
async submitted(target, prompt) {
const hash = promptHash(prompt);
this.hookPanes.add(this.paneKey(target));
const pick = () => {
const open = this.records.filter(record => record.hash === hash && this.samePane(record, target) && record.state !== "delivered");
return open.find(entry => entry.session === target.session) ?? open[0];
};
let record = pick();
if (!record)
return;
let entry;
if (target.source === "claude") {
// A hook settles a message only by claiming a row for it: rows all claimed by earlier messages is a
// callback for a turn already accounted for. No row yet (not written or not readable, or a transcript
// that cannot be read) settles nothing now: the claim waits on the record for its row to appear.
let rows;
try {
rows = (await this.landed({ source: target.source, session: target.session, hash, at: record.at })).filter(row => row.hook);
}
catch {
rows = undefined;
}
record = pick();
if (!record)
return;
// Claims are per conversation: another conversation's rows can sit at the same byte offset.
const claimed = new Set(this.records.filter(other => other.hash === hash && this.samePane(other, target) && other.session === target.session && other.entry !== undefined).map(other => other.entry));
// A transcript that cannot be read settles on the hook's word, unless a message with these words already
// claimed a row in this conversation: the callback may be that message's, late, so it waits for a row.
if (!rows && !claimed.size) {
this.settle(record, undefined, target.session);
this.save();
return;
}
// The claim is looked for in the conversation that made it (it may have replaced the one the message was typed for).
if (!rows?.length) {
record.hookClaim = true;
record.session = target.session;
return;
}
const free = rows.filter(row => !claimed.has(row.offset)).sort((a, b) => a.offset - b.offset)[0];
if (!free)
return;
entry = free.offset;
}
this.settle(record, entry, target.session);
this.save();
}
/** Marks `record` delivered, with the transcript entry that settled it when known. */
settle(record, entry, session) {
record.state = "delivered";
record.settledAt = this.now();
delete record.reason;
delete record.hookClaim;
delete record.seenRow;
if (session)
record.session = session;
if (entry !== undefined)
record.entry = entry;
this.release(record, "delivered");
}
/** Whether the transcript shows the agent took `record`'s message, claiming the entry it settled with.
* An entry settles one message: the table of records' `entry` offsets (per conversation) is the only
* state, so neither this path nor the hook's can settle two messages with one turn. A user row is a
* turn start whose UserPromptSubmit hook settles the message itself, so while the agent works only a
* mid-turn queue removal (no hook fires for it) settles from the transcript; once the agent is idle
* (`working` false) a user row settles too, for a hook that never came. Of several identical open
* messages the oldest take the entries. `pass` carries the entries still unclaimed within one observe. */
async taken(record, pass, working = false) {
// Only Claude's transcript says which entry a message settled with; Codex keeps the any-match check.
if (record.source !== "claude")
return (await this.landed(record).catch(() => [])).length > 0;
const same = (other) => other.hash === record.hash && other.server === record.server && other.pane === record.pane && other.session === record.session;
const open = this.records.filter(other => same(other) && PromptDeliveries.claimable(other)).sort((a, b) => a.at - b.at);
if (open[0] !== record)
return false;
const key = [record.hash, record.server, record.pane, record.session, working].join("|");
let free = pass.get(key);
if (!free) {
const found = (await this.landed(record).catch(() => [])).sort((a, b) => a.offset - b.offset);
const used = new Set(this.records.filter(other => same(other) && other.entry !== undefined).map(other => other.entry));
// A message the agent's hook settled took a user row without recording it (a transcript it could not read):
// the oldest user rows are theirs, whether the agent works or not.
let hooked = this.records.filter(other => same(other) && other.state === "delivered" && other.entry === undefined && (other.settledAt ?? 0) >= record.at - ENTRY_SLACK_MS).length;
const unclaimed = found.filter(entry => !used.has(entry.offset)).filter(entry => !(entry.hook && hooked-- > 0));
if (working) {
// A user row is its hook's to settle, unless that hook already came (before the row could be read), or
// the pane sends no callbacks at all: then a row two checks in a row saw unclaimed settles it.
const row = unclaimed.find(entry => entry.hook);
const hookless = !this.hookPanes.has(this.paneKey(record));
const own = row && (record.hookClaim || (hookless && record.seenRow === row.offset));
if (row && hookless && !own)
record.seenRow = row.offset;
free = unclaimed.filter(entry => !entry.hook || (own && entry === row));
}
else
free = unclaimed;
}
// The record may have settled (its hook claimed a row) while the transcript was read.
if (!PromptDeliveries.claimable(record))
return false;
const entry = free.shift();
pass.set(key, free);
if (entry === undefined)
return false;
record.entry = entry.offset;
return true;
}
/** A turn in `target`'s pane ended: the agent now takes held input or never will. */
stopped(target) {
const at = this.now();
this.hookPanes.add(this.paneKey(target));
let marked = false;
for (const record of this.records)
if (this.samePane(record, target) && (record.state === "typed" || record.state === "queued") && record.at < at) {
record.stoppedAt = at;
marked = true;
}
// Persisted: after a restart an idle pane's message is still failed or settled by its Stop.
if (marked)
this.save();
}
/** Called on the Hook's activity tick with a server's panes. A message
* still held fails loudly when its pane closed, its agent restarted, or the
* agent ended a turn and stayed idle without taking it (unless its
* transcript shows it after all). */
async observe(server, panes) {
const now = this.now();
let changed = false;
const pass = new Map();
for (const record of this.records) {
if (record.server !== server)
continue;
// A failed message whose own hook came late, before its row: settled when the row appears.
if (record.state === "failed" && record.hookClaim) {
if (record.source === "claude" && (record.checkedAt === undefined || now - record.checkedAt >= LANDED_CHECK_MS)) {
record.checkedAt = now;
if (await this.taken(record, pass, true)) {
this.settle(record);
changed = true;
}
}
continue;
}
if (record.state !== "typed" && record.state !== "queued")
continue;
const pane = panes.find(entry => entry.pane_id === record.pane);
if (!pane || (record.terminal && pane.terminal_id !== record.terminal) || (pane.agent && pane.agent !== record.source)) {
if (await this.taken(record, pass)) {
this.settle(record);
changed = true;
continue;
}
if (!PromptDeliveries.open(record))
continue;
record.state = "failed";
record.settledAt = now;
changed = true;
record.reason = pane ? "The agent in this pane restarted before it took the message." : "The agent's pane closed before it took the message.";
continue;
}
if (!["idle", "done"].includes(String(pane.agent_status))) {
record.idleSince = undefined;
// A message the agent took into its running turn (Claude's mid-turn steer) never reaches
// its UserPromptSubmit hook; its transcript is the only place that says so.
if (record.source === "claude" && (record.checkedAt === undefined || now - record.checkedAt >= LANDED_CHECK_MS)) {
record.checkedAt = now;
if (await this.taken(record, pass, true)) {
this.settle(record);
changed = true;
}
}
continue;
}
record.idleSince ??= now;
if (record.stoppedAt === undefined || now - Math.max(record.idleSince, record.stoppedAt) < DELIVERY_GIVE_UP_MS)
continue;
if (await this.taken(record, pass)) {
this.settle(record);
changed = true;
continue;
}
// Settled delivered by its hook while the transcript was read: never downgraded.
if (!PromptDeliveries.open(record))
continue;
record.state = "failed";
record.settledAt = now;
changed = true;
record.reason = "The agent finished its turn without taking the message.";
}
if (changed)
this.save();
}
}
/** The byte offsets of the user rows with these words that reached the conversation's transcript
* after the message was typed: the agent took it without its hook saying so
* (a mid-turn steer). Reads at most the last 512 KiB. */
/** The byte offsets (in the file, `start` being the buffer's own offset) of the rows in `buffer`
* whose user text has `record`'s words, newest first. One offset per row. */
export function matchingOffsets(buffer, start, record) {
const entries = [];
const lines = buffer.toString("utf8").split("\n");
let offset = start + buffer.length;
for (let index = lines.length - 1; index >= 0; index--) {
const line = lines[index];
offset -= Buffer.byteLength(line) + (index < lines.length - 1 ? 1 : 0);
let row;
try {
row = object(JSON.parse(line));
}
catch {
continue;
}
const stamp = Date.parse(String(row.timestamp ?? ""));
if (Number.isFinite(stamp) && stamp < record.at - 5_000)
break;
// One entry per row, even when its content repeats the words.
if (userTexts(row).some(text => promptHash(text) === record.hash))
entries.push({ offset, hook: row.type === "user" });
}
return entries;
}
async function transcriptEntries(record) {
const file = await open(await transcriptPath(record.source, record.session), "r");
try {
const size = (await file.stat()).size, start = Math.max(0, size - 524_288), buffer = Buffer.alloc(size - start);
await file.read(buffer, 0, buffer.length, start);
return matchingOffsets(buffer, start, record);
}
finally {
await file.close();
}
}
/** A transcript row's user-typed text: Claude's user rows, Codex's
* user_message events and user response items. */
export function userTexts(row) {
const texts = [];
const blocks = (content) => {
if (typeof content === "string")
texts.push(content);
else
for (const block of objects(content))
if (typeof block.text === "string")
texts.push(block.text);
};
if (row.type === "user" && !row.isMeta)
blocks(object(row.message).content);
// Claude Code takes a message typed mid-turn out of its queue into the running turn:
// `enqueue` when typed, then `remove` (not `dequeue`, which starts a turn of its own) with the same words.
// A `remove` also drops input without the agent seeing it (reason "cleared_on_cancel"): only these reasons mean it was taken.
if (row.type === "queue-operation" && row.operation === "remove" && typeof row.content === "string"
&& (row.reason === "absorbed_mid_turn" || row.reason === "delivered_to_agent"))
texts.push(row.content);
const payload = object(row.payload);
if (payload.type === "user_message" && typeof payload.message === "string")
texts.push(payload.message);
if (payload.type === "message" && payload.role === "user")
blocks(payload.content);
return texts;
}