UNPKG

@phren/cli

Version:

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

701 lines (700 loc) • 36 kB
import { randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; import { mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { z } from "zod"; import { AppServerRpcError, connectAppServer, spawnAppServer } from "./codex-app-server.js"; import { logger } from "../logger.js"; import { atomic, bridgeRoot, id, object, objects, serverName } from "./protocol.js"; /** * Codex panes the Hook launches run on a Phren-owned `codex app-server`, one * per pane: the Hook spawns `codex app-server --listen unix://<sock>`, starts * the thread itself (`thread/start`), and the pane runs `codex resume * <threadId> --remote unix://<sock>` as a second client of that thread. The * Hook's own client sends prompts (`turn/start`, acknowledged with a turn id), * interrupts (`turn/interrupt`, after declining parked requests) and answers * approvals (server requests, no hold timeout); whatever the owner does in the * pane reaches the same thread. The registry under * `<bridge>/codex-servers/<id>/server.json` lets a restarted Hook find each * running server again and reconnect to its thread. Under systemd each server * runs in its own scope (`phren-codex-<id>.scope`), outside the Hook's * service, so stopping or restarting the Hook does not end it. */ const REGISTRY = "codex-servers"; /** A Unix socket path is limited to about 104 bytes (macOS) / 108 (Linux). */ const MAX_SOCKET_PATH = 100; /** A pane that shows no Codex for this long no longer runs the worker. */ const AGENT_GONE_MS = 120_000; const CLIENT_NAME = "phren_hook"; /** How long a turn lost with its server is reported to the returns loop. */ const LOST_MS = 24 * 60 * 60 * 1000; const QUESTION_METHODS = new Set(["item/tool/requestUserInput", "mcpServer/elicitation/request"]); const entrySchema = z.object({ id: z.string().regex(/^[a-f0-9]{12}$/), server: serverName, workspace: id, tab: id, pane: id, socket: z.string().min(1).max(4096), pid: z.number().int().positive(), /** Unknown for a moment after a launch without a brief: the pane's TUI * starts the thread itself and the Hook learns it from `thread/started`. */ threadId: z.string().min(1).max(200).optional(), cwd: z.string().min(1).max(4096), startedAt: z.string(), dispatchId: z.string().optional(), activeTurn: z.string().optional(), lastTurn: z.object({ id: z.string(), status: z.string(), at: z.string() }).optional(), /** What the phone chose, sent with the Hook's next `turn/start` (Codex keeps * it for the thread's later turns too): model and effort, and the permission * and plan settings as T3 maps them. */ nextTurn: z.object({ model: z.string().min(1).max(200).optional(), effort: z.string().min(1).max(20).optional(), approvalPolicy: z.enum(["untrusted", "on-request", "never"]).optional(), approvalsReviewer: z.enum(["user", "auto_review"]).optional(), sandboxPolicy: z.object({ type: z.enum(["readOnly", "workspaceWrite", "dangerFullAccess"]) }).strict().optional(), collaborationMode: z.object({ mode: z.enum(["plan", "default"]) }).strict().optional(), }).strict().optional(), }).strict(); const defaultDeps = { spawn: spawnAppServer, connect: connectAppServer, alive: pid => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === "EPERM"; } }, kill: pid => { // The detached server leads its own process group: end the group. try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ } } }, scope: serverId => process.platform === "linux" && serviceCgroup() ? `phren-codex-${serverId}.scope` : undefined, }; /** The systemd service cgroup a process runs in (`/proc/<pid>/cgroup`, cgroup * v2), or undefined outside a service: a terminal, a scope, macOS. */ export function serviceCgroup(pid = "self") { try { const line = readFileSync(`/proc/${pid}/cgroup`, "utf8").split("\n").find(candidate => candidate.startsWith("0::")); const group = line?.slice(3).trim(); return group?.endsWith(".service") ? group : undefined; } catch { return undefined; } } /** `PHREN_CODEX_APP_SERVER=off` keeps every Codex launch on the typed path. */ export function codexAppServerEnabled(env = process.env, platform = process.platform) { // The server listens on a unix socket and the pane joins it with // `--remote unix://…`; Windows keeps the typed path. if (platform === "win32") return false; return !/^(?:0|off|false|no)$/i.test(env.PHREN_CODEX_APP_SERVER ?? ""); } /** A question on a Hook-run pane reaches the phone as a card either way, but * only the blocking `request_user_input` holds the turn until someone answers: * an async question (`request_user_input_async`) lets the worker carry on * without the answer, and a plain-text question never becomes a card at all. * Codex 0.157 offers the blocking tool outside plan mode only behind * `features.default_mode_request_user_input`, and a short developer * instruction steers the model to it (as T3 Code's CodexDeveloperInstructions * does). `PHREN_CODEX_BLOCKING_QUESTIONS=off` leaves both out. */ export const BLOCKING_QUESTION_INSTRUCTIONS = "When you need the user to decide or supply something you cannot find out yourself, " + "ask with the `request_user_input` tool, which waits for the answer; the user answers it from their phone or this terminal. " + "Do not use `request_user_input_async` for that, and never write a multiple-choice question as a plain message. " + "Ask only when a reasonable assumption would be risky."; export function serverConfig(env = process.env) { if (/^(?:0|off|false|no)$/i.test(env.PHREN_CODEX_BLOCKING_QUESTIONS ?? "")) return []; return ["features.default_mode_request_user_input=true", `developer_instructions=${JSON.stringify(BLOCKING_QUESTION_INSTRUCTIONS)}`]; } /** The variable that tells a hook it runs inside the Hook's own app-server * for one pane, so the pane variables it carries are that pane's. */ export const CODEX_SERVER_ENV = "PHREN_CODEX_SERVER"; export const codexServerId = z.string().regex(/^[a-f0-9]{12}$/); /** The server's environment: the Hook's own, minus the variables that name * the Hook's terminal, plus the pane's (so the hooks Codex runs inside the * server report this pane) and the caller's. */ export function serverEnvironment(base, extra) { const env = {}; for (const [key, value] of Object.entries(base)) { if (/^(?:HERDR_|TMUX$|TMUX_PANE$|PHREN_DISPATCH_ID$)/.test(key)) continue; env[key] = value; } return { ...env, ...extra }; } export function codexServersRoot() { return path.join(bridgeRoot(), REGISTRY); } export const LOST_TURN = "The worker's Codex server stopped before its turn finished (the Phren Hook restarted with it, or it crashed), so the turn will not finish. Resume the thread or dispatch the work again."; /** Nothing was sent: the server could not be reached. */ export class CodexServerUnavailable extends Error { constructor(message) { super(message); this.name = "CodexServerUnavailable"; } } export class CodexServers { deps; live = new Map(); /** Turns lost with their server, by `<server>\n<pane>`. */ lost = new Map(); sink; closed = false; constructor(deps = defaultDeps) { this.deps = deps; } setDeps(deps) { const previous = this.deps; this.deps = { ...this.deps, ...deps }; return () => { this.deps = previous; }; } saving = new Map(); setSink(sink) { this.sink = sink; } entries() { return [...this.live.values()].map(live => live.entry); } forPane(server, pane) { return [...this.live.values()].find(live => live.entry.server === server && live.entry.pane === pane)?.entry; } forThread(threadId) { return [...this.live.values()].find(live => live.entry.threadId !== undefined && live.entry.threadId === threadId)?.entry; } /** The server behind a Codex target, when the target is exactly its pane and thread. */ forTarget(target) { if (target.source !== "codex") return undefined; const entry = this.forPane(target.server, target.pane); return entry && entry.threadId !== undefined && entry.threadId === target.session && entry.workspace === target.workspace && entry.tab === target.tab ? entry : undefined; } /** A new server for a pane the Hook just created, and the arguments the * pane's `codex` joins it with. With `startThread` (a launch that carries a * brief) the Hook starts the thread and the pane resumes it: * `codex resume <threadId> --remote unix://<socket>`. Codex cannot resume a * thread with no turn yet, so without one the pane's TUI starts the thread * (`codex --remote unix://<socket>`, with the model and effort as its own * flags) and the Hook learns it from `thread/started` (`awaitThread`). The * caller `stop`s the server if the pane cannot start. */ async launch(place, options) { const serverId = randomBytes(6).toString("hex"); const directory = path.join(codexServersRoot(), serverId); const socket = path.join(directory, "app.sock"); if (Buffer.byteLength(socket) > MAX_SOCKET_PATH) throw new Error(`The Codex server socket path is too long: ${socket}`); await mkdir(codexServersRoot(), { recursive: true, mode: 0o700 }); await mkdir(directory, { recursive: true, mode: 0o700 }); const spawnOptions = { socketPath: socket, cwd: options.cwd, detached: true, logFile: path.join(directory, "server.log"), config: serverConfig(), replaceEnv: true, env: serverEnvironment(process.env, { ...(options.env ?? {}), [CODEX_SERVER_ENV]: serverId }) }; const scope = this.deps.scope?.(serverId); const handle = await (scope // No systemd-run, or no user manager to ask: the server runs in the // Hook's service as before, and a Hook restart ends it. ? this.deps.spawn({ ...spawnOptions, scope }).catch(error => { logger.warn("codex-servers", `Could not start the Codex server in its own scope ${scope}; it will stop with the Hook: ${error instanceof Error ? error.message : String(error)}`); return this.deps.spawn(spawnOptions); }) : this.deps.spawn(spawnOptions)).catch(async (error) => { await rm(directory, { recursive: true, force: true }).catch(() => undefined); throw error; }); const pid = handle.child.pid; let client; try { if (!pid) throw new Error("codex app-server started without a process id"); client = await this.deps.connect(socket, { clientName: CLIENT_NAME, clientTitle: "Phren Hook" }); const entry = entrySchema.parse({ id: serverId, ...place, socket, pid, cwd: options.cwd, startedAt: new Date().toISOString(), ...(options.dispatchId ? { dispatchId: options.dispatchId } : {}) }); const live = { entry, client }; // Listening before the thread exists, so its `thread/started` is not missed. this.listen(live, client); if (options.startThread) { const started = await client.threadStart({ cwd: options.cwd, ...(options.model ? { model: options.model } : {}), ...(options.effort ? { config: { model_reasoning_effort: options.effort } } : {}) }); const threadId = object(started.thread).id; if (typeof threadId !== "string" || !threadId) throw new Error("codex app-server thread/start returned no thread id"); entry.threadId = threadId; live.subscribed = true; if (typeof started.model === "string") live.model = started.model; } await this.save(entry); this.live.set(serverId, live); this.lost.delete(paneKey(place.server, place.pane)); const args = entry.threadId ? ["resume", entry.threadId, "--remote", `unix://${socket}`] : ["--remote", `unix://${socket}`, ...(options.model ? ["--model", options.model] : []), ...(options.effort ? ["-c", `model_reasoning_effort=${options.effort}`] : []), ...(options.remoteArgs ?? [])]; return { entry, args }; } catch (error) { client?.close(); await handle.stop().catch(() => undefined); await rm(directory, { recursive: true, force: true }).catch(() => undefined); throw error; } } /** The pane's thread once its TUI has started it; undefined after `timeoutMs`. */ async awaitThread(entry, timeoutMs = 10_000) { const deadline = Date.now() + timeoutMs; while (!entry.threadId && Date.now() < deadline && this.live.has(entry.id)) await new Promise(resolve => setTimeout(resolve, 100)); return entry.threadId; } /** On Hook start: every registered server still running is reconnected * and its thread followed again (its pending approvals are replayed to * the new client); the rest are forgotten. */ async adopt() { this.closed = false; const names = await readdir(codexServersRoot()).catch(() => []); for (const name of names.slice(0, 256)) { if (!codexServerId.safeParse(name).success || [...this.live.values()].some(live => live.entry.id === name)) continue; const entry = await readEntry(name); if (!entry || !this.deps.alive(entry.pid)) { if (entry) this.markLost(entry); await this.forget(name); continue; } const live = { entry }; this.live.set(name, live); await this.connect(live).catch(error => logger.warn("codex-servers", `Could not reconnect to Codex server ${name}: ${error instanceof Error ? error.message : String(error)}`)); } } /** `turn/start` on the pane's thread; resolves with the turn id the server * acknowledged. Nothing is typed into the pane. */ async prompt(entry, text) { if (!entry.threadId) throw new CodexServerUnavailable("The Codex pane has not started its thread yet."); const client = await this.client(entry); const live = this.live.get(entry.id)?.entry ?? entry, next = live.nextTurn; const modeModel = next?.model ?? this.live.get(entry.id)?.model; const collaborationMode = next?.collaborationMode && modeModel // The mode carries its own model and effort, which win over the turn's. ? { mode: next.collaborationMode.mode, settings: { model: modeModel, reasoning_effort: next.effort ?? null, developer_instructions: null } } : undefined; const started = await client.turnStart({ threadId: entry.threadId, input: [{ type: "text", text, text_elements: [] }], ...(next ? { ...(next.model ? { model: next.model } : {}), ...(next.effort ? { effort: next.effort } : {}), ...(next.approvalPolicy ? { approvalPolicy: next.approvalPolicy } : {}), ...(next.approvalsReviewer ? { approvalsReviewer: next.approvalsReviewer } : {}), ...(next.sandboxPolicy ? { sandboxPolicy: next.sandboxPolicy } : {}), ...(collaborationMode ? { collaborationMode } : {}) } : {}) }); // Codex took the override with the turn; the turn's own record shows it. if (next && live.nextTurn === next) { delete live.nextTurn; void this.save(live).catch(() => undefined); } return started; } /** `thread/name/set` on the pane's thread: Codex's own rename, which its TUI * shows and `session_index.jsonl` keeps. Allowed while a turn runs. */ async renameThread(entry, name) { if (!entry.threadId) throw new CodexServerUnavailable("The Codex pane has not started its thread yet."); await (await this.client(entry)).request("thread/name/set", { threadId: entry.threadId, name }); } /** Holds a model and effort for the pane's next Hook-sent turn. Nothing is * typed into the TUI and a running turn is not disturbed. */ setNextTurn(entry, model, effort) { this.hold(entry, { model, effort }); // A model chosen without an effort takes its own default, not the last one's. if (!effort) { const live = this.live.get(entry.id)?.entry; if (live?.nextTurn) delete live.nextTurn.effort; } } /** Holds permission and plan settings the same way, next to a pending model. */ holdSettings(entry, settings) { const live = this.live.get(entry.id); if (settings.collaborationMode && !(live?.entry.nextTurn?.model ?? live?.model)) { throw new CodexServerUnavailable("Codex has not reported this thread's model yet. Send a message first, then change plan mode."); } this.hold(entry, settings); } /** Merges into what is pending: a later choice replaces its own fields only. */ hold(entry, patch) { const live = this.live.get(entry.id)?.entry; if (!live) throw new CodexServerUnavailable("This Codex server is no longer registered."); live.nextTurn = { ...live.nextTurn, ...Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined)) }; void this.save(live).catch(() => undefined); } /** Input for the thread's running turn (`turn/steer`, as the TUI sends an * async question's answer), or a new turn when none is running or the one * it knew has ended. A refused steer sent nothing, so starting is safe. */ async steer(entry, text, extra = []) { if (!entry.threadId) throw new CodexServerUnavailable("The Codex pane has not started its thread yet."); const client = await this.client(entry); const input = [{ type: "text", text, text_elements: [] }, ...extra]; const running = this.live.get(entry.id)?.entry.activeTurn; if (running) { try { return await client.turnSteer({ threadId: entry.threadId, expectedTurnId: running, input }); } catch (error) { if (!(error instanceof AppServerRpcError)) throw error; } } return client.turnStart({ threadId: entry.threadId, input }); } /** The thread's parked questions: `item/tool/requestUserInput` and MCP * elicitations, which wait for an answer from any client. */ questions(entry) { const client = this.live.get(entry.id)?.client; if (!client || !entry.threadId) return []; return [...client.pending.values()].filter(request => QUESTION_METHODS.has(request.method) && (request.threadId === undefined || request.threadId === entry.threadId)); } /** Answers one parked question; false when no client still waits on it. */ answerQuestion(entry, requestId, result) { const client = this.live.get(entry.id)?.client; if (!client?.pending.has(requestId)) return false; client.respond(requestId, result); return true; } /** One proactive refresh of the shared Codex sign-in (codex-auth-refresh.ts) * through Codex's own flow: on a running server's connection, or on a * short-lived server when none runs. */ async refreshAuth() { for (const live of this.live.values()) { if (!this.deps.alive(live.entry.pid)) continue; const client = await this.client(live.entry).catch(() => undefined); if (!client) continue; await client.request("account/read", { refreshToken: true }); return; } const directory = await mkdtemp(path.join(tmpdir(), "phren-codex-auth-")); const socket = path.join(directory, "app.sock"); try { const handle = await this.deps.spawn({ socketPath: socket, cwd: directory, replaceEnv: true, env: serverEnvironment(process.env, {}) }); try { const client = await this.deps.connect(socket, { clientName: CLIENT_NAME, clientTitle: "Phren Hook" }); try { await client.request("account/read", { refreshToken: true }); } finally { client.close(); } } finally { await handle.stop().catch(() => undefined); } } finally { await rm(directory, { recursive: true, force: true }).catch(() => undefined); } } /** Interrupt the running turn, declining the thread's parked server * requests first. False when no turn is known to be running. */ async interrupt(entry) { const live = this.live.get(entry.id); const turn = live?.entry.activeTurn, threadId = live?.entry.threadId; if (!live || !turn || !threadId) return false; const client = await this.client(entry); const parked = [...client.pending.values()].filter(request => request.threadId === undefined || request.threadId === threadId); await client.interruptTurn(threadId, turn); // Declined by this client, so no `serverRequest/resolved` comes back for them. for (const request of parked) this.sink?.resolved(this.target(live.entry), request.requestId); return true; } /** The turn the pane's worker lost with its server, while that pane still * shows `threadId` (any thread when not given). */ lostTurn(server, pane, threadId, now = Date.now()) { const key = paneKey(server, pane), lost = this.lost.get(key); if (!lost) return undefined; if (now - Date.parse(lost.at) >= LOST_MS) { this.lost.delete(key); return undefined; } return threadId === undefined || threadId === lost.threadId ? lost : undefined; } /** The last finished turn of a registered thread and the running one, for * the returns loop. */ turnState(threadId) { const entry = this.forThread(threadId); return entry ? { ...(entry.activeTurn ? { activeTurn: entry.activeTurn } : {}), ...(entry.lastTurn ? { lastTurn: entry.lastTurn } : {}) } : undefined; } /** One Hook tick for `server`: forget servers whose process ended, stop * servers whose pane closed (or has shown no Codex for two minutes), and * reconnect a client that dropped. */ async reap(server, snapshot, now = Date.now()) { const panes = objects(snapshot.panes); for (const live of [...this.live.values()]) { const entry = live.entry; if (entry.server !== server) continue; if (!this.deps.alive(entry.pid)) { this.markLost(entry); await this.drop(live); continue; } const pane = panes.find(candidate => candidate.pane_id === entry.pane); if (!pane) { await this.stop(entry); continue; } if (pane.agent === "codex") live.agentGoneSince = undefined; else { live.agentGoneSince ??= now; if (now - live.agentGoneSince >= AGENT_GONE_MS) { await this.stop(entry); continue; } } if (!live.client && !live.connecting) await this.connect(live).catch(() => undefined); else if (live.client && !live.subscribed && entry.threadId) await this.subscribe(live, live.client).catch(() => undefined); } } /** Servers whose terminal server is no longer running at all (a stopped * Herdr session takes its panes with it): forgotten once dead, stopped * after the same two minutes a pane without Codex gets. */ async sweep(running, now = Date.now()) { for (const live of [...this.live.values()]) { if (running.includes(live.entry.server)) continue; if (!this.deps.alive(live.entry.pid)) { this.markLost(live.entry); await this.drop(live); continue; } live.agentGoneSince ??= now; if (now - live.agentGoneSince >= AGENT_GONE_MS) await this.stop(live.entry); } } /** The pane's TUI moved to `threadId` (`/new`, `/resume`), as its * SessionStart hook reports: follow it, so prompts, approvals and Escape * go where the TUI is. False when the pane has no registered server. */ follow(server, pane, threadId) { const live = [...this.live.values()].find(candidate => candidate.entry.server === server && candidate.entry.pane === pane); if (!live) return false; this.rebind(live, threadId); return true; } rebind(live, threadId) { const entry = live.entry; if (entry.threadId === threadId) return; const previous = entry.threadId; // The old thread's cards cannot be answered from this pane any more. if (previous && live.client) { const target = this.target(entry); for (const request of live.client.pending.values()) if (request.threadId === undefined || request.threadId === previous) this.sink?.resolved(target, request.requestId); } entry.threadId = threadId; delete entry.activeTurn; delete entry.lastTurn; live.subscribed = false; void this.save(entry).catch(() => undefined); // A thread with no turn yet cannot be resumed; the tick tries again. if (live.client) void this.subscribe(live, live.client).catch(() => undefined); } /** Ends the server and forgets it. */ async stop(entry) { const live = this.live.get(entry.id); if (this.deps.alive(entry.pid)) this.deps.kill(entry.pid); if (live) await this.drop(live); else await this.forget(entry.id); } /** Close every client; the servers keep running for the next Hook. */ close() { this.closed = true; for (const live of this.live.values()) { live.off?.(); live.client?.close(); live.client = undefined; } this.live.clear(); } /** For tests: forget everything without touching processes. */ reset() { this.close(); this.lost.clear(); this.closed = false; } /** Resolves once every registry write started so far has finished. */ async saved() { await Promise.all([...this.saving.values()].map(write => write.catch(() => undefined))); } target(entry) { return { server: entry.server, workspace: entry.workspace, tab: entry.tab, pane: entry.pane, source: "codex", session: entry.threadId ?? "" }; } /** Rejoin the thread: its history is skipped, its pending server requests * come again as requests. */ async subscribe(live, client) { const threadId = live.entry.threadId; if (!threadId) return; const resumed = await client.threadResume({ threadId }); // The pane moved on while this was in flight: that thread's join decides. if (live.entry.threadId !== threadId) return; live.subscribed = true; if (typeof resumed.model === "string") live.model = resumed.model; // A turn that ended while no client listened: the recorded one is stale. if (object(object(resumed.thread).status).type === "idle") delete live.entry.activeTurn; } async client(entry) { const live = this.live.get(entry.id); if (!live) throw new CodexServerUnavailable("This Codex server is no longer registered."); if (!live.client) await this.connect(live).catch(error => { throw new CodexServerUnavailable(`The Codex server is not reachable: ${error instanceof Error ? error.message : String(error)}`); }); if (!live.client) throw new CodexServerUnavailable("The Codex server is not reachable."); return live.client; } connect(live) { if (live.connecting) return live.connecting; live.connecting = (async () => { const client = await this.deps.connect(live.entry.socket, { clientName: CLIENT_NAME, clientTitle: "Phren Hook" }); live.subscribed = false; try { this.listen(live, client); // A thread without a turn cannot be resumed yet; the tick retries. await this.subscribe(live, client).catch(error => { if (!/no rollout/i.test(error instanceof Error ? error.message : "")) throw error; }); } catch (error) { live.off?.(); live.off = undefined; client.close(); throw error; } if (this.closed || !this.live.has(live.entry.id)) { client.close(); return; } live.client = client; })().finally(() => { live.connecting = undefined; }); return live.connecting; } listen(live, client) { live.off?.(); const off = client.on(event => { const entry = live.entry; if (event.kind === "notification" && event.method === "thread/started") { // The pane's TUI started a thread: its first one, or a later /new. // Only the TUI and this Hook use the server, and the Hook starts a // thread only at launch, so a top-level thread in the pane's folder // is the one the pane now shows (helper threads, such as the one // naming the thread, have no environment; subagents have a parent). const thread = object(event.params.thread); const here = objects(thread.environments).some(environment => typeof environment.cwd === "string" && path.resolve(environment.cwd) === path.resolve(entry.cwd)); if (typeof thread.id === "string" && thread.id && !thread.parentThreadId && here) this.rebind(live, thread.id); return; } if (!entry.threadId) return; if (event.kind === "request") { if (event.threadId !== undefined && event.threadId !== entry.threadId) return; const changes = event.method === "item/fileChange/requestApproval" ? live.fileChanges?.get(String(event.params.itemId)) : undefined; const request = changes ? { ...event, params: { ...event.params, changes } } : event; this.sink?.request(this.target(entry), request, result => client.respond(event.requestId, result)); return; } if (event.kind === "resolved") { if (event.threadId === undefined || event.threadId === entry.threadId) this.sink?.resolved(this.target(entry), event.requestId); return; } if (event.params.threadId !== entry.threadId) return; const item = object(event.params.item); if (event.method === "item/started" && item.type === "fileChange" && typeof item.id === "string") { live.fileChanges ??= new Map(); live.fileChanges.set(item.id, objects(item.changes).slice(0, 64)); while (live.fileChanges.size > 32) live.fileChanges.delete(live.fileChanges.keys().next().value); } const turn = object(event.params.turn); if (event.method === "turn/started" && typeof turn.id === "string") { entry.activeTurn = turn.id; void this.save(entry).catch(() => undefined); } else if (event.method === "turn/completed" && typeof turn.id === "string") { if (entry.activeTurn === turn.id) delete entry.activeTurn; entry.lastTurn = { id: turn.id, status: typeof turn.status === "string" ? turn.status : "completed", at: new Date().toISOString() }; void this.save(entry).catch(() => undefined); } }); // A dropped connection is reconnected on the next tick. const socketClosed = () => { if (live.client === client) live.client = undefined; }; const offClose = client.onClose?.(socketClosed); live.off = () => { off(); offClose?.(); }; } /** A server that ended on its own mid-turn: the returns loop reports the turn failed. */ markLost(entry) { if (!entry.activeTurn || !entry.threadId) return; logger.warn("codex-servers", `The Codex server for ${entry.server} ${entry.pane} ended during turn ${entry.activeTurn}`); this.lost.set(paneKey(entry.server, entry.pane), { threadId: entry.threadId, turn: entry.activeTurn, ...(entry.dispatchId ? { dispatchId: entry.dispatchId } : {}), at: new Date().toISOString() }); } async drop(live) { live.off?.(); live.client?.close(); live.client = undefined; this.live.delete(live.entry.id); await this.forget(live.entry.id); } async forget(serverId) { await rm(path.join(codexServersRoot(), codexServerId.parse(serverId)), { recursive: true, force: true }).catch(() => undefined); } /** One server's registry writes run in order, each with the entry as it is * then, so an earlier write that finishes late never lands over a newer one. */ save(entry) { const file = path.join(codexServersRoot(), entry.id, "server.json"); const next = (this.saving.get(entry.id) ?? Promise.resolve()).catch(() => undefined) .then(() => atomic(file, entrySchema.parse(entry))); this.saving.set(entry.id, next); void next.catch(() => undefined).finally(() => { if (this.saving.get(entry.id) === next) this.saving.delete(entry.id); }); return next; } } const paneKey = (server, pane) => `${server}\n${pane}`; async function readEntry(serverId) { try { const parsed = entrySchema.safeParse(JSON.parse(await readFile(path.join(codexServersRoot(), serverId, "server.json"), "utf8"))); return parsed.success && parsed.data.id === serverId ? parsed.data : undefined; } catch { return undefined; } } /** Registered servers still running inside the systemd service `unit` * (`phren-hook.service`): stopping it ends them and cuts off their turns. * Servers a Hook with scopes started run outside it. */ export async function serversInService(unit, cgroup = serviceCgroup) { const names = await readdir(codexServersRoot()).catch(() => []); const entries = await Promise.all(names.filter(name => codexServerId.safeParse(name).success).slice(0, 256).map(readEntry)); return entries.filter((entry) => !!entry && cgroup(entry.pid)?.endsWith(`/${unit}`) === true); } /** The Hook's servers: launch, prompt and keys routes, identity and the * approval store all see the same registry. */ export const codexServers = new CodexServers();