UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

356 lines (355 loc) • 21.3 kB
import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdir, open, stat, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { z } from "zod"; import { BridgeError, bridgeRoot, object, objects } from "./protocol.js"; import { paneIdentity, validateTarget } from "./herdr.js"; import { transcriptPath } from "./transcripts.js"; import { codexExecutable } from "./codex-binary.js"; import { withTranscriptIndex } from "./transcript-index.js"; import { formatQuestionReply, parseQuestionReply } from "./codex-question-reply.js"; import { IMAGE_NAME, sessionUpload } from "./uploads.js"; const exec = promisify(execFile); const text = z.string().trim().min(1).max(4000).refine(t => !/[\x00-\x08\x0b-\x1f\x7f]/.test(t)); const question = z.object({ title: text, options: z.array(text).max(12).nullable().optional() }); const questionSet = z.array(question).min(1).max(8); /** Codex's asynchronous questions return {accepted:true} immediately. Their * answers are ordinary user messages quoting the original title, not replies * to item/tool/requestUserInput (that RPC is the synchronous tool). */ export function asyncQuestion(raw, id) { const p = object(raw.payload); // Codex 0.155 asks through an agent message delivered async, with the // questions on the item itself; older versions called a tool. const asked = deliveredQuestion(raw); if (asked) return asked.id === id ? asked.questions : undefined; if (raw.type !== "response_item" || p.type !== "function_call" || p.call_id !== id || !["request_user_input_async", "functions.request_user_input_async"].includes(String(p.name))) return; try { return questionSet.parse(object(JSON.parse(String(p.arguments))).questions); } catch { return; } } /** An `item_completed` event for an AgentMessage delivered async with * questions attached: the newer shape, already acknowledged by nature. */ export function deliveredQuestion(raw) { const p = object(raw.payload), item = object(p.item); if (raw.type !== "event_msg" || p.type !== "item_completed" || !["AgentMessage", "agentMessage"].includes(String(item.type)) || item.delivery !== "async" || !Array.isArray(item.questions) || typeof item.id !== "string" || !item.id) return; try { const questions = questionSet.parse(objects(item.questions).map(q => ({ ...q, options: Array.isArray(q.options) ? q.options : undefined }))); return { id: item.id.slice(0, 512), questions }; } catch { return; } } /** The phone's answer to each question: one option it chose or its typed text. */ function answerValues(questions, answers) { const parsed = z.array(z.object({ optionIndexes: z.array(z.number().int().nonnegative()).max(1), text: z.string().max(4000).optional() })).parse(answers); if (parsed.length !== questions.length) throw new BridgeError(400, "Answer every question."); return questions.map((q, index) => { const answer = parsed[index], typed = answer.text?.trim() ?? "", options = q.options ?? []; if (answer.optionIndexes.length + (typed ? 1 : 0) !== 1) throw new BridgeError(400, "Choose one answer for each question."); const value = typed || options[answer.optionIndexes[0]]; if (!value || !text.safeParse(value).success) throw new BridgeError(400, "Choose an available answer."); return value; }); } export function questionReply(questions, answers) { const values = answerValues(questions, answers); return questions.map((q, index) => q.title.split("\n").map(line => `> ${line}`).join("\n") + "\n\n" + values[index]).join("\n\n"); } /** The same answers as Codex's own TUI sends them into the running turn. */ export function asyncQuestionReply(id, questions, answers) { const values = answerValues(questions, answers); return formatQuestionReply(id, questions.map((q, index) => ({ question: q.title, answer: values[index] }))); } const answersQuestions = (reply, questions) => questions.every(q => { const quote = q.title.split("\n").map(line => `> ${line}`).join("\n") + "\n\n"; const index = reply.indexOf(quote); return index >= 0 && reply.slice(index + quote.length).trim().length > 0; }); export async function pendingAsyncQuestions(file, targetID) { return withTranscriptIndex(file, async (handle, index) => { const replies = [], replied = new Set(), acknowledged = new Set(), resolved = new Set(), seen = new Set(), pending = []; let bytes = 0; for await (const row of index.rows(handle, index.lines, Math.max(0, index.lines - 10_000))) { // An incomplete scan is not evidence that no questions remain. Status // omits this field on failure, preserving the phone's known prompts. if (!row.bytes || (bytes += row.bytes.length) > 8_388_608) throw new BridgeError(413, "The pending question history is too large to verify."); let raw; try { raw = object(JSON.parse(row.bytes.toString())); } catch { continue; } const delivered = deliveredQuestion(raw); if (delivered) acknowledged.add(delivered.id); if (raw.type !== "response_item" && !delivered) continue; const p = object(raw.payload), id = delivered ? delivered.id : typeof p.call_id === "string" ? p.call_id : ""; if (p.type === "message" && p.role === "user") { const reply = objects(p.content).map(b => typeof b.text === "string" ? b.text : "").join("\n"); replies.push(reply); // Codex's own answer (its TUI, or the Hook for a pane on its app-server) names the question. for (const entry of parseQuestionReply(reply) ?? []) if (entry.id) replied.add(entry.id); } if (id && ["function_call_output", "custom_tool_call_output"].includes(String(p.type)) && !resolved.has(id) && !acknowledged.has(id)) { try { if (object(JSON.parse(String(p.output))).accepted === true) acknowledged.add(id); else resolved.add(id); } catch { resolved.add(id); } } if (targetID && resolved.has(targetID)) return []; if (!id || !acknowledged.has(id)) continue; const questions = asyncQuestion(raw, id); // Codex 0.155 records one question twice under the same id: the // request_user_input_async call and the AgentMessage delivered async. if (questions && !seen.has(id) && !replied.has(id) && !replies.some(reply => answersQuestions(reply, questions))) { pending.push({ id, questions }); if (pending.length >= 64) throw new BridgeError(413, "Too many pending questions to verify."); } if (questions) seen.add(id); if (targetID && questions && id === targetID) return pending.filter(p => p.id === targetID); } if (index.lines > 10_000) throw new BridgeError(413, "The pending question history is too large to verify."); return pending.reverse(); }); } export async function pendingAsyncQuestion(file, id) { const pending = (await pendingAsyncQuestions(file, id)).find(p => p.id === id); if (!pending) throw new BridgeError(409, "This question is no longer pending. Refresh the conversation."); return pending.questions; } /** Files the phone attached to an answer, named the way the phone names them * under a chat message it sends with attachments. */ export const attachedFiles = (paths) => paths.length ? `Attached files on this computer:\n${paths.join("\n")}` : ""; const withAttachments = (answer, paths) => paths.length ? `${answer}\n\n${attachedFiles(paths)}` : answer; /** The phone's attachments for an answer: at most eight of this conversation's uploads. */ export async function answerAttachments(session, data) { const requested = z.array(z.string().max(4096)).max(8).optional().parse(data.attachments) ?? []; return Promise.all(requested.map(file => sessionUpload(session, file))); } const requestKey = (requestId) => `request:${JSON.stringify(requestId)}`; const choose = (label, options, typed) => { if (!typed && !options.includes(label)) throw new BridgeError(400, "Choose one of the offered answers."); return label; }; /** `item/tool/requestUserInput` (the synchronous tool) and a form MCP * elicitation whose fields are all single values. Secret inputs, URL * elicitations and multi-select fields stay in the pane. */ export function serverQuestion(request) { const params = request.params; if (request.method === "item/tool/requestUserInput") { const asked = objects(params.questions); if (!asked.length || asked.some(q => q.isSecret === true || typeof q.id !== "string" || typeof q.question !== "string")) return undefined; const parsed = questionSet.safeParse(asked.map(q => ({ title: q.question, options: objects(q.options).map(option => option.label).filter((label) => typeof label === "string") }))); if (!parsed.success) return undefined; const questions = parsed.data.map(q => ({ ...q, options: q.options?.length ? q.options : undefined })); return { questions, attachments: true, result: values => ({ answers: Object.fromEntries(asked.map((q, index) => [String(q.id), { answers: [choose(values[index], questions[index].options ?? [], q.isOther === true || !questions[index].options)] }])) }) }; } if (request.method !== "mcpServer/elicitation/request" || !["form", "openai/form", "openaiForm"].includes(String(params.mode))) return undefined; const message = typeof params.message === "string" ? params.message.trim() : ""; const fields = Object.entries(object(object(params.requestedSchema).properties)); if (!message || !fields.length || fields.length > 8) return undefined; const shaped = []; for (const [key, raw] of fields) { const field = object(raw), label = [field.title, field.description].find((value) => typeof value === "string" && !!value.trim())?.trim() ?? key; if (field.type === "boolean") { shaped.push({ key, title: label, options: ["Yes", "No"], value: answer => choose(answer, ["Yes", "No"], false) === "Yes" }); continue; } if (field.type === "number" || field.type === "integer") { shaped.push({ key, title: label, value: answer => { const number = Number(answer.trim()); if (!answer.trim() || !Number.isFinite(number) || (field.type === "integer" && !Number.isInteger(number))) throw new BridgeError(400, `Answer "${label}" with a number.`); return number; } }); continue; } if (field.type !== "string") return undefined; const titled = objects(field.oneOf).filter(option => typeof option.const === "string" && typeof option.title === "string"); const plain = Array.isArray(field.enum) ? field.enum.filter((value) => typeof value === "string") : []; const names = Array.isArray(field.enumNames) ? field.enumNames : []; const pairs = titled.length ? titled.map(option => [String(option.title), String(option.const)]) : plain.map((value, index) => [typeof names[index] === "string" ? String(names[index]) : value, value]); if (pairs.length) { const options = pairs.map(([title]) => title); shaped.push({ key, title: label, options, value: answer => pairs[options.indexOf(choose(answer, options, false))][1] }); } else shaped.push({ key, title: label, value: answer => answer }); } // The server's message heads the first question; one field reads as the message itself. const titles = shaped.map((field, index) => shaped.length === 1 ? (field.title === field.key ? message : `${message}\n\n${field.title}`) : index === 0 ? `${message}\n\n${field.title}` : field.title); const parsed = questionSet.safeParse(shaped.map((field, index) => ({ title: titles[index], ...(field.options ? { options: field.options } : {}) }))); if (!parsed.success) return undefined; return { questions: parsed.data, attachments: false, result: values => ({ action: "accept", content: Object.fromEntries(shaped.map((field, index) => [field.key, field.value(values[index])])) }) }; } export class CodexQuestions { configured; served; snapshots = new Map(); failedSnapshots = new Map(); inboxAvailable = false; /** Feature discovery must never delay permission/status frames. */ get available() { void this.supported(); return this.inboxAvailable; } probe; /** No executable: the real `codex` on PATH, past phren's session wrapper. * `served` answers panes on the Hook's own app-server over that server. */ constructor(configured, served) { this.configured = configured; this.served = served; } /** Whether the phone can answer this pane's questions: a pane on the Hook's * own app-server always can; any other needs `codex queue`. */ availableFor(target) { return target.source === "codex" && (!!this.served?.forTarget(target) || this.available); } get executable() { return this.configured ?? codexExecutable(); } supported() { if (!this.probe || Date.now() - this.probe.at > 300_000) this.probe = { at: Date.now(), result: exec(this.executable, ["queue", "--help"], { timeout: 5000, maxBuffer: 65_536 }) .then(({ stdout }) => stdout.includes("--thread") && stdout.includes("--message")).catch(() => false) .then(available => { this.inboxAvailable = available; return available; }) }; return this.probe.result; } async pending(target) { if (target.source !== "codex") return []; const entry = this.served?.forTarget(target); const parked = entry ? this.served.questions(entry).flatMap(request => { const shown = serverQuestion(request); return shown ? [{ toolUseId: requestKey(request.requestId), isAsync: true, submitted: false, questions: asPrompt(shown.questions) }] : []; }) : []; return [...parked, ...await this.transcriptPending(target)]; } async transcriptPending(target) { if (Date.now() - (this.failedSnapshots.get(target.session) ?? 0) < 5000) throw new BridgeError(503, "Pending question history is unavailable."); const file = await transcriptPath("codex", target.session); const cached = this.snapshots.get(target.session); let pending = cached?.pending; if (!cached || Date.now() - cached.at > 1000) { const metadata = await stat(file), stamp = `${metadata.ino}:${metadata.size}:${metadata.mtimeMs}`; pending = cached?.stamp === stamp ? cached.pending : await pendingAsyncQuestions(file).catch(error => { if (this.failedSnapshots.size >= 64) this.failedSnapshots.delete(this.failedSnapshots.keys().next().value); this.failedSnapshots.set(target.session, Date.now()); throw error; }); this.failedSnapshots.delete(target.session); if (this.snapshots.size >= 64) this.snapshots.delete(this.snapshots.keys().next().value); this.snapshots.set(target.session, { at: Date.now(), stamp, pending }); } return Promise.all((pending ?? []).map(async (p) => { const key = createHash("sha256").update(JSON.stringify([target.source, target.session, p.id])).digest("hex"); const submitted = await readFile(path.join(bridgeRoot(), "question-replies", key), "utf8").then(value => value === "submitted").catch(() => false); return { toolUseId: p.id, isAsync: true, submitted, questions: asPrompt(p.questions) }; })).then(prompts => prompts.filter(p => !p.submitted)); } async answer(target, data) { const id = z.string().min(1).max(512).parse(data.toolUseId); const entry = target.source === "codex" ? this.served?.forTarget(target) : undefined; if (entry && id.startsWith("request:")) { // A question parked on the Hook's own app-server: the answer is the // reply to that request, exactly once, from whichever client is first. const request = this.served.questions(entry).find(candidate => requestKey(candidate.requestId) === id); const shown = request && serverQuestion(request); if (!request || !shown) throw new BridgeError(409, "This question is no longer pending. Refresh the conversation."); const files = await answerAttachments(target.session, data); if (files.length && !shown.attachments) throw new BridgeError(400, "This question takes no attachments."); const result = shown.result(answerValues(shown.questions, data.answers)); // Files ride on the last answer, the way a chat message carries them. const answers = object(result.answers), last = Object.keys(answers).at(-1); if (files.length && last) { const values = object(answers[last]).answers; answers[last] = { answers: [...values.slice(0, -1), withAttachments(values.at(-1) ?? "", files)] }; } if (!this.served.answerQuestion(entry, request.requestId, result)) throw new BridgeError(409, "This question is no longer pending. Refresh the conversation."); return; } if (target.source !== "codex" || (!entry && !await this.supported())) throw new BridgeError(409, "This connection needs the question answered in the terminal."); z.string().uuid().parse(target.session); const questions = await pendingAsyncQuestion(await transcriptPath("codex", target.session), id); const files = await answerAttachments(target.session, data); // A pane on the Hook's app-server gets the answer in its running turn, as // Codex's TUI sends it; `codex queue` would hold it until the turn ends. // Attached pictures go in as images, and every file is named below. const reply = entry ? asyncQuestionReply(id, questions, data.answers) : withAttachments(questionReply(questions, data.answers), files); const extra = entry && files.length ? [{ type: "text", text: attachedFiles(files), text_elements: [] }, ...files.filter(file => IMAGE_NAME.test(file)).map(file => ({ type: "localImage", path: file }))] : []; const pane = await validateTarget(target); if (await paneIdentity(target.server, pane, true) !== target.session) throw new BridgeError(409, "This pane's conversation changed. Reopen the chat."); // Record before invoking the provider: a timeout can mean it accepted the // message. Concurrent taps, reconnects, and helper restarts must not resend. const directory = path.join(bridgeRoot(), "question-replies"); await mkdir(directory, { recursive: true, mode: 0o700 }); const key = createHash("sha256").update(JSON.stringify([target.source, target.session, id])).digest("hex"); const receiptPath = path.join(directory, key); const receipt = await open(receiptPath, "wx", 0o600).catch(error => { if (error.code === "EEXIST") throw new BridgeError(409, "An answer was already submitted. Check the conversation before answering again."); throw new BridgeError(503, "Phren could not record this answer. Nothing was sent."); }); await receipt.close(); try { // Explicit UUID, no session-name lookup, no shell, no terminal keystrokes. if (entry) await this.served.steer(entry, reply, extra); else await exec(this.executable, ["queue", "--thread", target.session, "--message", reply], { timeout: 8000, maxBuffer: 65_536 }); await writeFile(receiptPath, "submitted", { mode: 0o600 }); } catch { throw new BridgeError(409, "Codex did not confirm the answer. Check the conversation; Phren has not retried it."); } } } /** Questions in the phone's pending-question shape: labelled options, or a * typed answer when there are none. */ function asPrompt(questions) { return questions.map(q => ({ question: q.title, options: (q.options ?? []).map(label => ({ label })), ...(!q.options?.length ? { kind: "text" } : {}) })); }