@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
616 lines (615 loc) • 35.6 kB
JavaScript
import { activateModules as moduleSnapshot } from "../modules/runtime.js";
import { defaultPhrenPath } from "../shared.js";
import { disabledHint } from "../modules/registry.js";
import { execFile } from "node:child_process";
import { usageStatusLine } from "./usage.js";
import { access, chmod, copyFile, mkdir, open, readFile, rename, symlink, unlink, lstat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { codexHome, claudeConfigDir } from "../home-paths.js";
import { claudeHomes } from "./claude-accounts.js";
import { syncAccountMcpServers } from "./claude-account-setup.js";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { bridgeRoot, object, objects, atomic, socketPath } from "./protocol.js";
import { herdrRoot } from "./herdr.js";
import { health } from "./transport.js";
import { FAST_HOOK_SOURCE, fastHookPath } from "./hook-fast.js";
import { installAskpass, removeAskpass } from "./sudo.js";
import { readStoredVoice, SPEECH_VOICE_ENV, writeSpeechVoice } from "./speech-voice.js";
import { carryCodexHookTrust } from "./codex-hook-trust.js";
import { serversInService } from "./codex-servers.js";
const exec = promisify(execFile);
const label = "com.phren.hook";
const unit = "phren-hook.service";
export const peerForcedCommand = 'command="sh ~/.local/share/phren/bridge/dispatch-scoped peer"';
export const forcedCommand = 'command="sh ~/.local/share/phren/bridge/dispatch"';
/** A scoped key's forced command. A separate script, so a key added before
* the Hook can serve its scope fails closed instead of reaching `dispatch`. */
export const scopedForcedCommand = (scope) => `command="sh ~/.local/share/phren/bridge/dispatch-scoped ${scope}"`;
function keyOptions(line) {
const options = [];
let quoted = false, start = 0;
for (let i = 0; i < line.length; i++) {
const character = line[i];
if (quoted && character === "\\" && line[i + 1] === '"') {
i++;
continue;
}
if (character === '"') {
quoted = !quoted;
continue;
}
if (!quoted && (character === "," || /\s/.test(character))) {
options.push(line.slice(start, i));
start = i + 1;
if (character !== ",")
return { options, rest: line.slice(i) };
}
}
return undefined;
}
export function upgradeKeys(text) {
let changed = 0;
const result = text.split(/(?<=\n)/).map(line => {
const parsed = keyOptions(line);
if (!parsed || parsed.options[0] !== "restrict" || !/^\s+ssh-ed25519 [A-Za-z0-9+/=]+ phren-(?:iphone|android)\s*$/.test(parsed.rest))
return line;
const old = /^command="(?:\/usr\/bin\/false|python3 ~\/\.local\/share\/phren\/chat-progress\.py|sh ~\/\.local\/share\/phren\/bridge\/dispatch)"$/;
if (!parsed.options.some(option => old.test(option)))
return line;
// permitopen restricts TCP destinations only. Removing generic forwarding
// also prevents access to private Unix sockets through this device key.
const options = parsed.options.filter(option => option.toLowerCase() !== "port-forwarding" && !option.toLowerCase().startsWith("permitopen="))
.map(option => old.test(option) ? forcedCommand : option);
if (!options.includes("pty"))
options.splice(1, 0, "pty");
const next = options.join(",") + parsed.rest;
if (next !== line)
changed++;
return next;
}).join("");
return { text: result, changed };
}
const quote = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
const xml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
/** The service environment install writes. Any other key in an existing
* LaunchAgent (a voice, a proxy, an API override someone added by hand) is
* kept: before, `phren bridge update` rewrote the plist and dropped it. */
const MANAGED_ENVIRONMENT = new Set(["PATH", "PHREN_BRIDGE_HOME", "PHREN_HERDR_HOME", "PHREN_PATH", "PHREN_PROFILE"]);
/** The existing LaunchAgent's own EnvironmentVariables beyond phren's, read
* with plutil. A missing or unreadable plist has none. */
export async function extraLaunchAgentEnvironment(file, read = async (f) => (await exec("plutil", ["-convert", "json", "-o", "-", f])).stdout) {
let parsed;
try {
parsed = JSON.parse(await read(file));
}
catch {
return {};
}
const extra = {};
for (const [key, value] of Object.entries(object(object(parsed).EnvironmentVariables))) {
if (!MANAGED_ENVIRONMENT.has(key) && /^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(key) && typeof value === "string")
extra[key] = value;
}
return extra;
}
/** The Hook's LaunchAgent: phren's own environment first, then every kept key. */
export function launchAgentXml(values, extra = {}) {
const environment = [["PATH", values.path], ["PHREN_BRIDGE_HOME", values.root], ["PHREN_HERDR_HOME", values.herdr],
["PHREN_PATH", values.store], ["PHREN_PROFILE", values.profile], ...Object.entries(extra).filter(([key]) => !MANAGED_ENVIRONMENT.has(key))];
const env = environment.map(([key, value]) => `<key>${xml(key)}</key><string>${xml(value)}</string>`).join("");
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${xml(values.label)}</string><key>ProgramArguments</key><array><string>${xml(values.node)}</string><string>${xml(values.program)}</string><string>serve</string></array><key>Umask</key><integer>63</integer><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>5</integer><key>Nice</key><integer>-5</integer><key>EnvironmentVariables</key><dict>${env}</dict><key>StandardErrorPath</key><string>${xml(path.join(values.root, "service.log"))}</string></dict></plist>\n`;
}
const systemdQuote = (s) => '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/%/g, "%%") + '"';
async function commandOutput(file, args, env) {
try {
const { stdout, stderr } = await exec(file, args, { env, timeout: 2000 });
return stdout + stderr || undefined;
}
catch (error) {
const value = error;
return `${value.stdout ?? ""}${value.stderr ?? ""}` || undefined;
}
}
/** Pick the cheapest reliable forwarder at install time: socat, then an nc that
* understands Unix sockets, then the node gateway that always works. */
export async function detectGateway(env = process.env) {
if ((await commandOutput("/bin/sh", ["-c", "command -v socat"], env))?.trim())
return "socat";
const help = await commandOutput("nc", ["-h"], env);
if (help !== undefined && /(^|\s)-U(\s|,|$)/m.test(help))
return "nc";
return "node";
}
/** The POSIX sh forced command: the phone's byte pipe goes straight to the Hook
* socket through a tiny forwarder, so a loaded machine never pays for a fresh
* node process; every other SSH command falls through to the node gateway. */
export function gatewayScript(gateway, environment) {
const { root, herdr, store, profile, node, bundle, socket, timing } = environment;
const forward = gateway === "socat"
? ` if command -v socat >/dev/null 2>&1; then rm -f ${quote(timing)}; exec socat - UNIX-CONNECT:${quote(socket)}; fi`
: gateway === "nc"
? ` if command -v nc >/dev/null 2>&1; then rm -f ${quote(timing)}; exec nc -U ${quote(socket)}; fi`
: "";
const pipe = forward ? `if [ "$SSH_ORIGINAL_COMMAND" = "phren-hook v1 pipe" ]; then\n${forward}\nfi\n` : "";
return `#!/bin/sh
# Phren Hook gateway. The phone's byte pipe goes straight to the Hook socket
# through a tiny forwarder so a loaded machine does not pay for a fresh node
# process; every other SSH command falls through to the node gateway.
export PHREN_BRIDGE_HOME=${quote(root)}
export PHREN_HERDR_HOME=${quote(herdr)}
export PHREN_PATH=${quote(store)}
export PHREN_PROFILE=${quote(profile)}
${pipe}exec ${quote(node)} ${quote(bundle)} ssh
`;
}
/** The enroll script, for an SSH session the user signed into with a password
* (the phone's "Password" sign-in): it adds one restricted phone key, then the
* phone connects with that key. It is never the forced command, so a phone key
* cannot use it to add more keys. */
export function enrollScript(environment) {
const { root, herdr, store, profile, node, bundle } = environment;
return `#!/bin/sh
# Phren Hook enroll: adds this phone's restricted key to authorized_keys.
export PHREN_BRIDGE_HOME=${quote(root)}
export PHREN_HERDR_HOME=${quote(herdr)}
export PHREN_PATH=${quote(store)}
export PHREN_PROFILE=${quote(profile)}
exec ${quote(node)} ${quote(bundle)} enroll-device "$@"
`;
}
/** The scoped keys' forced command: always the node gateway (never the raw
* socat/nc pipe), told which scope the key holds. An unknown scope or an older
* bundle without `ssh-scoped` refuses. */
export function scopedGatewayScript(environment) {
const { root, herdr, store, profile, node, bundle } = environment;
return `#!/bin/sh
# Phren Hook scoped gateway for keys limited to one route set (phren pair --scope).
export PHREN_BRIDGE_HOME=${quote(root)}
export PHREN_HERDR_HOME=${quote(herdr)}
export PHREN_PATH=${quote(store)}
export PHREN_PROFILE=${quote(profile)}
exec ${quote(node)} ${quote(bundle)} ssh-scoped "$1"
`;
}
export const scopedGatewayPath = (root = bridgeRoot()) => path.join(root, "dispatch-scoped");
async function activate(version) {
const root = bridgeRoot();
const next = path.join(root, `current-${process.pid}`);
await symlink(path.join("versions", version), next);
await rename(next, path.join(root, "current"));
}
/** The launchd domain for the LaunchAgent: the GUI session when someone is
* logged in at the screen, else the per-user background domain an SSH login
* has (gui/<uid> does not exist there, so a bootstrap into it fails). */
function launchDomain(uid, guiSession) {
return guiSession ? `gui/${uid}` : `user/${uid}`;
}
/** The commands that restart the Hook in `domain`, printed when launchd refuses. */
export function launchCommands(domain, plist) {
return [`launchctl bootout ${domain}/${label}`, `launchctl bootstrap ${domain} ${quote(plist)}`, `launchctl kickstart -k ${domain}/${label}`];
}
const launchAgentPlist = () => path.join(homedir(), "Library/LaunchAgents", `${label}.plist`);
async function guiSession(uid) {
return exec("launchctl", ["print", `gui/${uid}`]).then(() => true, () => false);
}
async function stopService() {
if (process.platform === "darwin") {
// Either domain may hold the job from an earlier install.
for (const gui of [true, false])
await exec("launchctl", ["bootout", `${launchDomain(process.getuid(), gui)}/${label}`]).catch(() => { });
}
else
await exec("systemctl", ["--user", "stop", unit]).catch(() => { });
}
async function startService() {
if (process.platform === "darwin") {
const uid = process.getuid(), gui = await guiSession(uid), domain = launchDomain(uid, gui);
// bootout returns before launchd finishes releasing the old job. A valid
// immediate bootstrap can transiently fail with EIO during an update.
for (let attempt = 0;; attempt++) {
try {
await exec("launchctl", ["bootstrap", domain, launchAgentPlist()]);
break;
}
catch (error) {
const stderr = String(error.stderr ?? "");
if (attempt < 5 && stderr.includes("Bootstrap failed: 5:")) {
await new Promise(resolve => setTimeout(resolve, 200 * (attempt + 1)));
continue;
}
throw new Error(`launchctl could not start the Phren Hook in ${domain}: ${stderr.trim() || error.message}\nTo start it by hand:\n ${launchCommands(domain, launchAgentPlist()).join("\n ")}`);
}
}
// RunAtLoad does not always fire for a job bootstrapped from an SSH login.
// A failed kickstart is only fatal if the version check below also fails.
const kickstartError = await exec("launchctl", ["kickstart", `${domain}/${label}`]).then(() => undefined, error => String(error.stderr || error.message).trim());
if (!gui)
console.log(`No one is logged in at this Mac's screen, so the Phren Hook runs in ${domain}. After a screen login, run phren bridge install again to move it into gui/${uid}.`);
return kickstartError;
}
else {
await exec("systemctl", ["--user", "daemon-reload"]);
await exec("systemctl", ["--user", "enable", "--now", unit]);
}
}
/** How long install waits for Codex turns that would stop with the Hook. */
export const CODEX_TURN_WAIT_MS = 10 * 60 * 1000;
/** Codex workers still running inside the Hook's service (started by a Hook
* from before each server got its own scope) stop with it, mid-turn or not.
* Say which, and wait up to `waitMs` for the running turns to finish. */
export async function waitForCodexWorkers(waitMs = CODEX_TURN_WAIT_MS, inService = () => serversInService(unit), log = console.log, pollMs = 5_000) {
if (process.platform !== "linux")
return;
const describe = (entry) => `${entry.pane}${entry.dispatchId ? ` (dispatch ${entry.dispatchId.slice(0, 8)})` : ""}${entry.activeTurn ? ", turn running" : ", idle"}`;
let workers = await inService();
if (!workers.length)
return;
log(`${workers.length} Codex worker${workers.length === 1 ? "" : "s"} run inside the Phren Hook's service and will stop when it restarts: ${workers.map(describe).join("; ")}.`
+ " Hooks from this version on start each Codex server in its own scope, so later restarts leave them running.");
const deadline = Date.now() + waitMs;
while (workers.some(entry => entry.activeTurn) && Date.now() < deadline) {
log(`Waiting up to ${Math.ceil((deadline - Date.now()) / 60_000)} min for ${workers.filter(entry => entry.activeTurn).length} running Codex turn(s) to finish. Pass --force to restart now.`);
await new Promise(resolve => setTimeout(resolve, pollMs));
workers = await inService();
}
const cut = workers.filter(entry => entry.activeTurn);
if (cut.length)
log(`Restarting anyway: ${cut.map(describe).join("; ")} will stop mid-turn and return failed.`);
}
async function serviceReady(version) {
for (let i = 0; i < 50; i++) {
try {
if ((await health()).version === version)
return true;
}
catch { /* Hook is still starting */ }
if (i < 49)
await new Promise(resolve => setTimeout(resolve, 200));
}
return false;
}
export async function install(version, noService = false, force = false) {
if (!["darwin", "linux"].includes(process.platform))
throw new Error("Phren Hook supports macOS and Linux.");
if (!/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(version))
throw new Error("Invalid helper version.");
const modules = moduleSnapshot(defaultPhrenPath(), undefined, true);
if (!modules.has("hook"))
throw new Error(disabledHint("hook"));
const root = bridgeRoot(), herdr = herdrRoot(), versions = path.join(root, "versions");
await mkdir(versions, { recursive: true, mode: 0o700 });
await chmod(root, 0o700);
const serviceLog = path.join(root, "service.log");
const log = await open(serviceLog, "a", 0o600);
try {
await log.chmod(0o600);
}
finally {
await log.close();
}
const hookEdits = await planAgentHooks(path.join(root, "current/bridge-hook.mjs"), false, modules);
const own = fileURLToPath(import.meta.url);
const bundle = own.endsWith("bridge-hook.mjs") ? own : path.join(path.dirname(own), "..", "bridge-hook.mjs");
const destination = path.join(versions, version);
await mkdir(destination, { recursive: true, mode: 0o700 });
const installedBundle = path.join(destination, "bridge-hook.mjs");
const previousBundle = await missingFile(readFile(installedBundle));
const stagedBundle = installedBundle + `.phren-${process.pid}`;
await copyFile(bundle, stagedBundle);
await rename(stagedBundle, installedBundle);
await atomic(fastHookPath(destination), FAST_HOOK_SOURCE);
const previous = await readFile(path.join(root, "installed.json"), "utf8").then(v => JSON.parse(v)).catch(() => null);
const gateway = await detectGateway();
await atomic(path.join(root, "dispatch"), gatewayScript(gateway, {
root, herdr, store: modules.store, profile: modules.profile, node: process.execPath,
bundle: path.join(root, "current/bridge-hook.mjs"), socket: socketPath(), timing: path.join(root, "gateway.json"),
}), 0o700);
await atomic(path.join(root, "enroll"), enrollScript({
root, herdr, store: modules.store, profile: modules.profile, node: process.execPath, bundle: path.join(root, "current/bridge-hook.mjs"),
}), 0o700);
await atomic(scopedGatewayPath(root), scopedGatewayScript({
root, herdr, store: modules.store, profile: modules.profile, node: process.execPath, bundle: path.join(root, "current/bridge-hook.mjs"),
}), 0o700);
await installAskpass(process.execPath, path.join(root, "current/bridge-hook.mjs"));
const environmentPath = [path.dirname(process.execPath), path.join(homedir(), ".local/bin"), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin"].join(":");
const program = path.join(root, "current/bridge-hook.mjs");
if (!noService) {
if (process.platform === "darwin") {
const folder = path.join(homedir(), "Library/LaunchAgents");
await mkdir(folder, { recursive: true });
const plist = path.join(folder, `${label}.plist`);
const extra = await extraLaunchAgentEnvironment(plist);
// The talk-mode voice used to live only here; it moves to the Hook's own setting.
if (extra[SPEECH_VOICE_ENV] && !(await readStoredVoice()))
await writeSpeechVoice(extra[SPEECH_VOICE_ENV]).catch(() => { });
await atomic(plist, launchAgentXml({ label, node: process.execPath, program, path: environmentPath, root, herdr, store: modules.store, profile: modules.profile }, extra));
}
else {
const folder = path.join(homedir(), ".config/systemd/user");
await mkdir(folder, { recursive: true });
await atomic(path.join(folder, unit), `[Unit]\nDescription=Phren Hook\n[Service]\nExecStart=${systemdQuote(process.execPath)} ${systemdQuote(program)} serve\nNice=-5\nEnvironment=${systemdQuote("PATH=" + environmentPath)} ${systemdQuote("PHREN_BRIDGE_HOME=" + root)} ${systemdQuote("PHREN_HERDR_HOME=" + herdr)} ${systemdQuote("PHREN_PATH=" + modules.store)} ${systemdQuote("PHREN_PROFILE=" + modules.profile)}\nRestart=always\nRestartSec=3\nUMask=0077\n[Install]\nWantedBy=default.target\n`);
}
if (!force)
await waitForCodexWorkers();
await stopService();
}
await activate(version);
try {
if (!noService) {
let kickstartError = await startService();
let ready = await serviceReady(version);
if (!ready && process.platform === "darwin") {
// launchd may accept bootstrap but leave a throttled or old job behind.
// Reload the plist and give the new job a second bounded start window.
await stopService();
kickstartError = await startService();
ready = await serviceReady(version);
}
if (!ready) {
const uid = process.getuid(), domain = process.platform === "darwin" ? launchDomain(uid, await guiSession(uid)) : undefined;
throw new Error(`The new Phren Hook ${version} did not become ready after ${domain ? "two launchd starts" : "starting the service"}.${kickstartError ? ` Last launchctl kickstart error: ${kickstartError}.` : ""} See ${serviceLog}. ` + (domain
? `To start it by hand:\n ${launchCommands(domain, launchAgentPlist()).join("\n ")}`
: `Check systemctl --user status ${unit}.`));
}
}
await applyAgentHooks(hookEdits);
const synced = await syncAccountMcpServers().catch(() => []);
if (synced.length)
console.log(`Claude accounts: refreshed MCP servers in ${synced.join(", ")}.`);
if (await applyOpencodePlugin()) {
console.log("opencode chat: restart any opencode session started before now so it loads the transcript plugin.");
}
// phren-agent calls the installed bundle itself, so it has no settings to edit.
if ((await import("../modules/agent-package.js")).agentInstalled()) {
console.log("phren-agent chat: found; it reports to this Hook itself, so restart any phren-agent session started before now.");
}
const keys = path.join(homedir(), ".ssh/authorized_keys");
const keyStat = await lstat(keys).catch(() => null);
if (keyStat && !keyStat.isSymbolicLink() && keyStat.isFile()) {
const before = await readFile(keys, "utf8"), after = upgradeKeys(before);
if (after.changed) {
await copyFile(keys, keys + `.phren-hook-${Date.now()}.bak`);
if (await readFile(keys, "utf8") !== before)
throw new Error("authorized_keys changed during installation. Run install again.");
await atomic(keys, after.text, keyStat.mode & 0o777);
}
console.log(`Updated ${after.changed} Phren iPhone key(s); other keys were preserved.`);
}
await atomic(path.join(root, "installed.json"), JSON.stringify({ version, previous: previous?.version === version ? previous.previous : previous?.version, node: process.execPath, gateway, store: modules.store }, null, 2) + "\n");
// Last, so a failed install restores hooks.json without leaving trust for entries it no longer has.
await carryCodexHookTrust(program, codexHooksBefore(hookEdits));
console.log("Agent hooks installed. In Codex, review the new Phren entries in /hooks. Existing agents may need to resume before new hooks load.");
console.log(`SSH gateway: ${gateway}${gateway === "node" ? " (no socat or nc -U found)" : ""}.`);
console.log(`Phren Hook ${version} installed${noService ? " (service not started)" : " and running"}. Run phren bridge doctor.`);
}
catch (error) {
await restoreAgentHooks(hookEdits);
if (!noService)
await stopService();
if (previous?.version) {
if (previous.version === version && previousBundle) {
await writeFile(stagedBundle, previousBundle);
await rename(stagedBundle, installedBundle);
}
await activate(previous.version);
if (!noService)
await startService();
}
throw error;
}
}
export async function uninstall() {
await stopService();
if (process.platform === "darwin")
await unlink(launchAgentPlist()).catch(() => { });
else {
await exec("systemctl", ["--user", "disable", unit]).catch(() => { });
await unlink(path.join(homedir(), ".config/systemd/user", unit)).catch(() => { });
await exec("systemctl", ["--user", "daemon-reload"]).catch(() => { });
}
await applyAgentHooks(await planAgentHooks(path.join(bridgeRoot(), "current/bridge-hook.mjs"), true));
await applyOpencodePlugin(true);
await removeAskpass();
// Preserve journal, settings, uploaded images, rollback version and SSH backups.
console.log("Phren Hook stopped and its background service removed. Remove phren-iphone, phren-android, phren-computer, phren-gitboy and phren-gitboy-write keys from authorized_keys to revoke device access. Local data remains in " + bridgeRoot());
}
async function missingFile(operation) {
try {
return await operation;
}
catch (error) {
if (error.code === "ENOENT")
return undefined;
throw error;
}
}
/** Whether the version `current/` points at ships the Claude forwarder; one
* from before it does not, and its Claude callbacks run the bundle. */
const currentHasFastHook = (root) => access(fastHookPath(path.join(root, "current"))).then(() => true, () => false);
export async function planAgentHooks(program, remove = false, modules, fastClaude = true) {
const edits = [];
// Extra Claude homes (one per account) get the same hooks; a symlinked settings.json shares another home's, so it is skipped.
const extraClaude = claudeHomes().slice(1).map(home => ["claude", path.join(home.dir, "settings.json")]);
for (const [source, file] of [
["codex", path.join(codexHome(), "hooks.json")],
["claude", path.join(claudeConfigDir(), "settings.json")],
...extraClaude,
["copilot", path.join(process.env.COPILOT_HOME || path.join(homedir(), ".copilot"), "hooks/phren.json")],
]) {
const metadata = await missingFile(lstat(file));
if (metadata && extraClaude.some(([, extra]) => extra === file) && (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 2_097_152))
continue;
if (metadata && (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size > 2_097_152)) {
throw new Error(`Agent settings require a manual update: ${file}`);
}
const before = await missingFile(readFile(file, "utf8"));
if ((remove || modules?.has("hook") === false) && before === undefined)
continue;
const parsed = before === undefined ? {} : JSON.parse(before);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
throw new Error(`Invalid agent settings: ${file}`);
const config = object(parsed);
if (config.hooks !== undefined && (!config.hooks || typeof config.hooks !== "object" || Array.isArray(config.hooks)))
throw new Error(`Invalid hook configuration: ${file}`);
const hooks = object(config.hooks);
// Claude's callbacks run the small forwarder beside the bundle (hook-fast.ts)
// instead of loading the whole bundle for every event.
const fast = fastHookPath(path.dirname(program));
const command = source === "claude" && fastClaude ? `${quote(process.execPath)} ${quote(fast)} claude` : `${quote(process.execPath)} ${quote(program)} hook ${source}`;
const ownHook = (entry) => typeof entry === "string" && (entry.endsWith(` ${quote(program)} hook ${source}`) || (source === "claude" && entry.endsWith(` ${quote(fast)} claude`)));
if (remove || modules?.has("hook") === false) {
const owned = Object.values(hooks).flatMap(objects).some(group => ownHook(group.command) || ownHook(group.bash) || objects(group.hooks).some(hook => ownHook(hook.command)));
const statusChanged = source === "claude" && JSON.stringify(usageStatusLine(config.statusLine, program, true)) !== JSON.stringify(config.statusLine);
if (!owned && !statusChanged)
continue;
}
for (const event of ["SessionStart", "UserPromptSubmit", "Stop", "PermissionRequest", "PreToolUse", "PostToolUse", ...(source === "claude" ? ["PreCompact"] : [])]) {
if (hooks[event] !== undefined && (!Array.isArray(hooks[event]) || hooks[event].some(v => !v || typeof v !== "object" || Array.isArray(v))))
throw new Error(`Invalid ${event} hooks: ${file}`);
if (source !== "copilot" && objects(hooks[event]).some(g => !Array.isArray(g.hooks)))
throw new Error(`Invalid ${event} hook group: ${file}`);
}
if (source === "copilot") {
config.version = 1;
for (const event of ["SessionStart", "UserPromptSubmit"]) {
const entries = objects(hooks[event]).filter(h => !ownHook(h.bash) && !ownHook(h.command));
hooks[event] = remove || modules?.has("hook") === false ? entries : [...entries, { type: "command", bash: command, timeoutSec: 15 }];
}
}
else {
for (const event of ["SessionStart", "UserPromptSubmit", "Stop", "PermissionRequest", "PreToolUse", "PostToolUse", ...(source === "claude" ? ["PreCompact"] : [])]) {
const groups = objects(hooks[event]).map(group => ({ ...group, hooks: objects(group.hooks).filter(h => !ownHook(h.command)) })).filter(group => group.hooks.length);
// Snapshot shell and file-edit calls so every card can show actual
// changed-file rows, including files outside the original cwd.
// Codex names tools differently across versions; filter its callbacks
// inside the Hook. Claude can narrow its registration here.
const group = event.endsWith("ToolUse") ? { ...(source === "claude" ? { matcher: "Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|str_replace_editor" } : {}), hooks: [{ type: "command", command, timeout: 10 }] }
: { hooks: [{ type: "command", command, timeout: event === "PermissionRequest" ? 60 : 15 }] };
const owner = event.endsWith("ToolUse") ? "git" : "hook";
hooks[event] = remove || modules?.has("hook") === false || modules?.has(owner) === false ? groups : [...groups, group];
}
}
config.hooks = hooks;
if (source === "claude") {
const statusLine = usageStatusLine(config.statusLine, program, remove || modules?.has("hook") === false);
if (statusLine === undefined)
delete config.statusLine;
else
config.statusLine = statusLine;
}
const after = JSON.stringify(config, null, 2) + "\n";
if (after === before)
continue;
edits.push({ file, before, after });
}
return edits;
}
const opencodePluginsDir = () => path.join(process.env.XDG_CONFIG_HOME || path.join(homedir(), ".config"), "opencode", "plugins");
/** The first line of every plugin copy phren wrote; a copy without it belongs to the user. */
export const OPENCODE_PLUGIN_MARKER = "// Installed by Phren Hook";
/** Whether to (re)write the installed plugin: a missing copy, or one phren wrote that is now out of date. */
export function opencodePluginNeedsWrite(existing, source) {
if (existing === undefined)
return true;
if (existing === source)
return false;
return existing.startsWith(OPENCODE_PLUGIN_MARKER);
}
async function opencodePluginSource() {
if (typeof OPENCODE_PLUGIN_SOURCE === "string")
return OPENCODE_PLUGIN_SOURCE;
const here = path.dirname(fileURLToPath(import.meta.url));
for (const candidate of [
path.join(here, "..", "..", "plugins", "opencode", "phren-transcript.js"),
path.join(here, "..", "plugins", "opencode", "phren-transcript.js"),
]) {
const source = await missingFile(readFile(candidate, "utf8"));
if (source !== undefined)
return source;
}
return undefined;
}
async function applyOpencodePlugin(remove = false) {
const dir = opencodePluginsDir(), file = path.join(dir, "phren-transcript.js");
const source = await opencodePluginSource();
const existing = await missingFile(readFile(file, "utf8"));
if (remove) {
if (existing !== undefined && (existing === source || existing.startsWith(OPENCODE_PLUGIN_MARKER)))
await unlink(file);
return false;
}
if (source === undefined || !opencodePluginNeedsWrite(existing, source))
return false;
if (!(await lstat(path.dirname(dir)).catch(() => null))?.isDirectory())
return false;
await mkdir(dir, { recursive: true });
await atomic(file, source, 0o644);
return true;
}
async function applyAgentHooks(edits) {
for (const { file, before, after } of edits) {
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
if (await missingFile(readFile(file, "utf8")) !== before)
throw new Error("Agent settings changed during installation. Run install again.");
if (before !== undefined)
await copyFile(file, file + `.phren-hook-${Date.now()}.bak`);
await atomic(file, after);
}
}
/** Codex's hooks.json as it stood before `edits` rewrote it, when they did. */
const codexHooksBefore = (edits) => edits.find(edit => edit.file === path.join(codexHome(), "hooks.json"))?.before;
async function restoreAgentHooks(edits) {
for (const { file, before, after } of edits) {
// A concurrent user edit always wins over rollback.
if (await missingFile(readFile(file, "utf8")) !== after)
continue;
if (before === undefined)
await unlink(file);
else
await atomic(file, before);
}
}
export async function rollback() {
const config = JSON.parse(await readFile(path.join(bridgeRoot(), "installed.json"), "utf8"));
if (!config.previous || !/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(config.previous))
throw new Error("No previous helper version is available.");
await stopService();
await activate(config.previous);
await startService();
// The version now in `current/` decides whether Claude's callbacks run its
// forwarder or, from before the forwarder, its bundle.
const root = bridgeRoot();
const program = path.join(root, "current/bridge-hook.mjs");
const edits = await planAgentHooks(program, false, moduleSnapshot(defaultPhrenPath(), undefined, true), await currentHasFastHook(root));
await applyAgentHooks(edits);
await carryCodexHookTrust(program, codexHooksBefore(edits));
await atomic(path.join(bridgeRoot(), "installed.json"), JSON.stringify({ version: config.previous, previous: config.version, ...(config.store ? { store: config.store } : {}) }) + "\n");
}
export async function reconcileModuleHooks(store, profile) {
const modules = moduleSnapshot(store, profile);
const root = bridgeRoot();
// Synced enablement alone never installs a host service or enrolls a key.
const installed = await missingFile(readFile(path.join(root, "installed.json"), "utf8"));
if (!installed)
return;
// The Hook serves the store it was installed with. `phren init` or `link`
// on another store (a scratch store, a test's) must not stop it or rewrite
// its agent hooks: a test run inside a Hook-launched worker did exactly that.
const served = (() => { try {
return object(JSON.parse(installed)).store;
}
catch {
return undefined;
} })();
if (typeof served === "string" && path.resolve(served) !== path.resolve(store))
return;
const program = path.join(root, "current/bridge-hook.mjs");
const edits = await planAgentHooks(program, false, modules, await currentHasFastHook(root));
await applyAgentHooks(edits);
await carryCodexHookTrust(program, codexHooksBefore(edits));
await applyOpencodePlugin(!modules.has("hook"));
if (!modules.has("hook"))
await stopService();
}