UNPKG

@phren/cli

Version:

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

153 lines (152 loc) • 9.65 kB
// Herdr as the Hook's terminal provider: each method is one call on Herdr's // socket API. It calls herdr.ts's exported `rpc`, the seam the bridge tests // fake, so a test that records Herdr requests sees exactly what it did before // the provider existed. import { herdrSocketPath, rpc, snapshot } from "./herdr.js"; import { BridgeError, object, objects } from "./protocol.js"; import { agentFromCommand } from "./terminal-tmux.js"; const text = (value) => typeof value === "string" && value ? value : undefined; /** Who reports phren-agent's lifecycle to Herdr, which does not detect it itself. */ export const PHREN_REPORT_SOURCE = "phren-hook"; /** A word for a POSIX shell, quoted only when it needs it. */ export function shellWord(word) { return /^[A-Za-z0-9_./:=@%+,-]+$/.test(word) ? word : `'${word.replace(/'/g, `'\\''`)}'`; } /** What the Hook types into a Herdr pane's shell to start phren-agent: * the agent, then Herdr's report of it released once it exits. */ export function phrenCommandLine(args) { return [["phren", ...args].map(shellWord).join(" "), `herdr pane release-agent "$HERDR_PANE_ID" --source ${PHREN_REPORT_SOURCE} --agent phren >/dev/null 2>&1`].join("; "); } /** Whether `pane` runs phren-agent as Herdr reports it, for the prompt and key fallbacks below. */ async function reportedPhren(server, pane) { const info = object((await rpc(server, "pane.get", { pane_id: pane }).catch(() => ({}))).pane); return info.agent === "phren"; } /** Herdr's `agent.prompt` and `agent.send_keys` take only agents it started; * a phren-agent pane it knows by report gets the pane calls instead. */ function unnamedAgent(error) { return error instanceof BridgeError && error.details?.herdrCode === "agent_not_ready" && /not an active named agent/.test(error.message); } /** Herdr's `agent.start` knows a fixed list of harnesses and phren-agent is * not on it. The Hook types `phren agent ...` at the pane's login shell * instead, waits for it to be the foreground program, then reports it to * Herdr as agent "phren" under the launch name, so the pane reads as an agent * like the others. Its own lifecycle hooks keep the status (agent-hooks.ts). */ async function startPhren(server, pane, { name, args, timeoutMs }) { const before = object((await rpc(server, "pane.process_info", { pane_id: pane })).process_info); // Same refusal as Herdr's own start, so the caller waits for the shell. if (!Number.isSafeInteger(before.shell_pid) || before.foreground_process_group_id !== before.shell_pid) throw new BridgeError(409, `Herdr: agent target pane ${pane} is not an available shell`, { herdrCode: "agent_pane_busy" }); await rpc(server, "pane.send_text", { pane_id: pane, text: phrenCommandLine(args) }); await rpc(server, "pane.send_keys", { pane_id: pane, keys: ["Enter"] }); const deadline = Date.now() + Math.min(timeoutMs, 30_000); for (;;) { const info = object((await rpc(server, "pane.process_info", { pane_id: pane })).process_info); if (objects(info.foreground_processes).some(p => typeof p.cmdline === "string" && agentFromCommand(p.cmdline) === "phren")) break; if (Date.now() >= deadline) throw new BridgeError(504, "phren agent did not start in the Herdr pane. Check that phren and @phren/agent are installed on the login shell's PATH."); await new Promise(resolve => setTimeout(resolve, 250)); } await rpc(server, "pane.report_agent", { pane_id: pane, source: PHREN_REPORT_SOURCE, agent: "phren", state: "idle" }); await rpc(server, "agent.rename", { target: pane, name }).catch(() => undefined); } /** A Herdr snapshot's panes in the provider's shape; Herdr's agent report becomes hints. */ export function herdrPanes(server, s) { return objects(s.panes).filter(p => typeof p.pane_id === "string" && typeof p.tab_id === "string" && typeof p.workspace_id === "string").map(p => { const reported = object(p.agent_session); const session = reported.kind === "id" && reported.agent === p.agent ? text(reported.value) : undefined; const hints = { agent: text(p.agent), status: text(p.agent_status), session }; return { server, workspace: String(p.workspace_id), tab: String(p.tab_id), pane: String(p.pane_id), cwd: text(p.foreground_cwd) ?? text(p.cwd), title: text(p.title) ?? text(p.terminal_title_stripped), label: text(p.label), ...(hints.agent || hints.status || hints.session ? { hints } : {}) }; }); } export const herdrTerminal = { kind: "herdr", async ping(server) { await rpc(server, "ping"); }, async snapshot(server) { return object((await rpc(server, "session.snapshot")).snapshot); }, async listPanes(server) { return herdrPanes(server, await snapshot(server)); }, async processes(server, pane) { const info = object((await rpc(server, "pane.process_info", { pane_id: pane })).process_info); return { ...(Number.isSafeInteger(info.shell_pid) ? { shellPid: Number(info.shell_pid) } : {}), foregroundPids: objects(info.foreground_processes).map(p => p.pid).filter((p) => Number.isSafeInteger(p)) }; }, async readScreen(server, pane, read) { const params = { ...(read.scope === "agent" ? { target: pane } : { pane_id: pane }), source: read.source, lines: read.lines, strip_ansi: read.stripAnsi ?? true, // Herdr 0.9 answers plain text whatever `strip_ansi` says; "ansi" keeps the styles. ...(read.format ? { format: read.format } : {}) }; // `pane.read` and `agent.read` both answer with a `read` object carrying the text. const result = await rpc(server, read.scope === "agent" ? "agent.read" : "pane.read", params, undefined, read.timeoutMs); const answer = object(result.read ?? result); return typeof answer.text === "string" ? answer.text : ""; }, async sendKeys(server, pane, keys) { try { await rpc(server, "agent.send_keys", { target: pane, keys }); } catch (error) { if (!unnamedAgent(error) || !await reportedPhren(server, pane)) throw error; await rpc(server, "pane.send_keys", { pane_id: pane, keys }); } }, async shellCommand(server, pane, command) { const deadline = Date.now() + 15_000; for (;;) { const info = object((await rpc(server, "pane.process_info", { pane_id: pane })).process_info); if (Number.isSafeInteger(info.shell_pid) && info.foreground_process_group_id === info.shell_pid) break; if (Date.now() >= deadline) throw new BridgeError(409, "The new pane did not become ready at its shell prompt."); await new Promise(resolve => setTimeout(resolve, 200)); } // An empty shell is not a named agent: agent.prompt cannot address it. await rpc(server, "pane.send_text", { pane_id: pane, text: command }); await rpc(server, "pane.send_keys", { pane_id: pane, keys: ["Enter"] }); }, async prompt(server, pane, text, signal) { try { if (signal) await rpc(server, "agent.prompt", { target: pane, text }, signal); else await rpc(server, "agent.prompt", { target: pane, text }); } catch (error) { if (!unnamedAgent(error) || !await reportedPhren(server, pane)) throw error; // One bracketed paste keeps the newlines in phren-agent's composer; Enter submits it. await rpc(server, "pane.send_text", { pane_id: pane, text: `\x1b[200~${text}\x1b[201~` }, signal); await new Promise(resolve => setTimeout(resolve, 150)); await rpc(server, "pane.send_keys", { pane_id: pane, keys: ["Enter"] }, signal); } }, async create(server, { workspace, label, cwd, env }) { await rpc(server, workspace ? "tab.create" : "workspace.create", { workspace_id: workspace, label, cwd, focus: false, env: env ?? {} }); }, // `agent.start` takes no environment; the pane's shell got it at `create`. async startAgent(server, pane, agent) { const { name, kind, args, timeoutMs } = agent; if (kind === "phren") { await startPhren(server, pane, agent); return; } // Herdr waits up to `timeout_ms` for the agent to become ready; the socket waits a little longer. await rpc(server, "agent.start", { name, kind, pane_id: pane, timeout_ms: timeoutMs, ...(args.length ? { args } : {}) }, undefined, timeoutMs + 5_000); }, // What Herdr sets in its own panes (herdrPaneFromEnv reads them back). paneEnv(server, { workspace, tab, pane }) { return { HERDR_ENV: "1", HERDR_SOCKET_PATH: herdrSocketPath(server), HERDR_WORKSPACE_ID: workspace, HERDR_TAB_ID: tab, HERDR_PANE_ID: pane }; }, async reportAgent(server, pane, agent, state) { await rpc(server, "pane.report_agent", { pane_id: pane, source: PHREN_REPORT_SOURCE, agent, state }); }, async focusPane(server, pane) { await rpc(server, "pane.focus", { pane_id: pane }); }, async closePane(server, pane) { await rpc(server, "pane.close", { pane_id: pane }); }, async renamePane(server, pane, label) { await rpc(server, "pane.rename", { pane_id: pane, label }); }, async groupAction(server, operation, { workspace, tab }, label) { await rpc(server, `${tab ? "tab" : "workspace"}.${operation}`, { ...(tab ? { tab_id: tab } : { workspace_id: workspace }), ...(label ? { label } : {}) }); }, };