@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
115 lines (114 loc) • 7.03 kB
JavaScript
import { request } from "node:http";
import { defaultPhrenPath } from "../shared.js";
import { activateModules as moduleSnapshot } from "../modules/runtime.js";
import { object, provider, targetSchema } from "./protocol.js";
import { hookOutput, TOOL_HOOK_BUDGET_MS } from "./hook-fast.js";
import { underCodexDaemon } from "./codex-daemon.js";
import { codexAutoReview } from "./codex-review-mode.js";
import { claudeHeadlessThread, codexForeignThread } from "./thread-origin.js";
import { terminalPaneFromEnv } from "./terminal.js";
import { briefId, DISPATCH_ID_ENV } from "./launch-brief.js";
import { CODEX_SERVER_ENV, codexServerId } from "./codex-servers.js";
import { localSocket } from "./agent-hook-stores.js";
/** The agent side of the Hook's callbacks: `hook <source>` reads the agent's
* payload and posts it to the daemon's local socket (agent-hooks.ts). */
/** What a Stop payload says about the turn it ends: the harness's last
* assistant message (Claude, Codex) and, from Claude Code, the background
* tasks still in flight, which will wake the conversation again when they
* finish. Absent fields are left out, so the Hook falls back to the transcript. */
export function stopFacts(value) {
const tasks = Array.isArray(value.background_tasks)
? value.background_tasks.filter(task => !["completed", "failed", "killed", "stopped"].includes(String(object(task).status))) : undefined;
return { ...(tasks ? { background: tasks.length } : {}),
...(typeof value.last_assistant_message === "string" && value.last_assistant_message.trim() ? { reply: value.last_assistant_message.slice(0, 16_384) } : {}) };
}
/** One agent callback, run as `hook <source>`. Resolves "abandoned" when a
* tool call's callback ran out of its budget: the caller then exits at once,
* since a socket or a process read still pending would hold the agent. */
export async function agentHook(source, elapsed = () => performance.now()) {
provider.parse(source);
// A missing helper must never prevent the coding agent from running.
// Inside Herdr only Herdr's pane counts; elsewhere a tmux pane does.
const place = await terminalPaneFromEnv();
if (!place)
return;
let input = "";
for await (const chunk of process.stdin) {
input += chunk.toString();
if (input.length > 1_048_576)
return;
}
const value = object(JSON.parse(input));
if (value.agent_id || value.agentId || value.isSidechain || value.is_sidechain)
return;
// A `codex exec` or a subagent inside a Codex pane is not the pane's conversation.
if (source === "codex" && await codexForeignThread(value.transcript_path).catch(() => false))
return;
// So is a headless `claude -p` run inside a Claude pane.
if (source === "claude" && await claudeHeadlessThread(value.transcript_path).catch(() => false))
return;
const target = targetSchema.parse({ server: place.server, workspace: place.workspace, tab: place.tab,
pane: place.pane, source, session: value.session_id || value.sessionId });
const event = String(value.hook_event_name || "SessionStart");
return withinToolBudget(event, left => forwardHook(source, target, event, value, left), elapsed);
}
/**
* Runs a callback's work. A PreToolUse or PostToolUse callback gets what is
* left of TOOL_HOOK_BUDGET_MS since its process started (hook-fast.ts says
* why): "abandoned" once that runs out, and the caller exits without an
* answer so the tool runs. Every other event runs to its own timeouts.
*/
export async function withinToolBudget(event, work, elapsed = () => performance.now(), budgetMs = TOOL_HOOK_BUDGET_MS) {
if (!event.endsWith("ToolUse"))
return work(() => Infinity);
const left = () => Math.floor(budgetMs - elapsed());
if (left() <= 0)
return "abandoned";
let timer;
const expired = new Promise(resolve => { timer = setTimeout(() => resolve("abandoned"), left()); });
try {
return await Promise.race([work(left), expired]);
}
finally {
clearTimeout(timer);
}
}
async function forwardHook(source, target, event, value, left) {
const modules = moduleSnapshot(defaultPhrenPath(), undefined, true);
if (!modules.has("hook") || (event.endsWith("ToolUse") && !modules.has("git")))
return;
// Codex 0.157 runs hooks inside its shared app-server daemon, whose pane
// variables belong to whichever pane first started it.
// The Hook's own per-pane app-server (codex-servers.ts) runs its hooks
// with that pane's variables, so they are trusted like a pane's own.
const ownServer = source === "codex" && codexServerId.safeParse(process.env[CODEX_SERVER_ENV]).success;
const daemon = source === "codex" && !ownServer && await underCodexDaemon().catch(() => false);
// Codex asks this hook before its automatic reviewer: say when that
// reviewer, not the owner, will decide.
const autoReview = source === "codex" && event === "PermissionRequest" && await codexAutoReview(value.transcript_path);
// A worker the Hook launched with its brief carries that dispatch's id. A
// daemon's variables belong to another pane, so it sends none.
const dispatchId = !daemon && (event === "SessionStart" || event === "UserPromptSubmit") && briefId.safeParse(process.env[DISPATCH_ID_ENV]).success
? process.env[DISPATCH_ID_ENV] : undefined;
const data = JSON.stringify({ target, event, ...(daemon ? { daemon: true } : {}), ...(autoReview ? { autoReview: true } : {}), ...(dispatchId ? { dispatchId } : {}),
...(source === "claude" && typeof value.transcript_path === "string" ? { transcript: value.transcript_path.slice(0, 4096) } : {}),
tool: value.tool_name, input: value.tool_input, toolUseId: value.tool_use_id, cwd: value.cwd,
...(event === "UserPromptSubmit" && typeof value.prompt === "string" ? { prompt: value.prompt.slice(0, 65_536) } : {}),
...(event === "Stop" ? stopFacts(value) : {}) });
await new Promise(resolve => {
const req = request({ socketPath: localSocket(), path: "/hook", method: "POST", timeout: event === "PermissionRequest" ? 58_000 : event.endsWith("ToolUse") ? Math.max(1, left()) : 12_000,
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } }, res => {
let result = "";
res.on("data", chunk => { result += chunk.toString(); if (result.length > 16_384)
req.destroy(); });
// Forward approval/refusal decisions and validated turn context.
// Tool callbacks stay silent, and an empty reply says nothing.
res.on("end", () => { if (res.statusCode === 200)
process.stdout.write(hookOutput(event, result)); resolve(); });
res.on("error", () => resolve());
});
req.on("error", () => resolve());
req.on("timeout", () => { req.destroy(); resolve(); });
req.end(data);
});
}