@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
291 lines (290 loc) • 16.8 kB
JavaScript
import { open, stat } from "node:fs/promises";
import { z } from "zod";
import { stripTerminal } from "../terminal-text.js";
import { claudeSuggestion } from "./claude-suggestion.js";
import { codexServers } from "./codex-servers.js";
import { validateTarget } from "./herdr.js";
import { intervalFromEnv } from "./limits.js";
import { assertComposerReady } from "./model-switch.js";
import { claudeFooterMode, permissionModes } from "./permission-mode.js";
import { BridgeError, object, PERMISSION_MODES } from "./protocol.js";
import { terminalProvider } from "./terminal.js";
import { transcriptPath } from "./transcripts.js";
export { claudeFooterMode } from "./permission-mode.js";
/** T3's CodexSessionRuntime mapping. The reviewer is always sent: leaving it
* out would keep `auto_review` from an earlier turn. */
export const CODEX_MODES = {
supervised: { approvalPolicy: "untrusted", approvalsReviewer: "user", sandboxPolicy: { type: "readOnly" } },
"auto-edits": { approvalPolicy: "on-request", approvalsReviewer: "user", sandboxPolicy: { type: "workspaceWrite" } },
auto: { approvalPolicy: "on-request", approvalsReviewer: "auto_review", sandboxPolicy: { type: "workspaceWrite" } },
"full-access": { approvalPolicy: "never", approvalsReviewer: "user", sandboxPolicy: { type: "dangerFullAccess" } },
};
/** Claude Code's `permissionMode` values, as the phone names them. */
const CLAUDE_MODES = { default: "supervised", acceptEdits: "auto-edits", auto: "auto", bypassPermissions: "full-access", plan: "plan" };
export const CLAUDE_NAMES = Object.fromEntries(Object.entries(CLAUDE_MODES).map(([raw, phone]) => [phone, raw]));
/** The same Codex mode as command-line flags, for a Codex started with no thread yet. */
export function codexModeFlags(mode) {
const { approvalPolicy, approvalsReviewer, sandboxPolicy } = CODEX_MODES[mode];
const sandbox = { readOnly: "read-only", workspaceWrite: "workspace-write", dangerFullAccess: "danger-full-access" }[String(sandboxPolicy?.type)];
return ["-a", String(approvalPolicy), "-s", String(sandbox), ...(approvalsReviewer === "auto_review" ? ["-c", 'approvals_reviewer="auto_review"'] : [])];
}
/**
* Copilot CLI's launch flags for a mode. Copilot asks before every tool by
* default and has no automatic reviewer, so `auto` approves every tool call
* while file access stays inside the working folder, and `full-access` also
* lifts the path and URL checks (`--allow-all`).
*/
export function copilotModeFlags(mode) {
if (mode === "auto-edits")
return ["--allow-tool=write"];
if (mode === "auto")
return ["--allow-all-tools"];
if (mode === "full-access")
return ["--allow-all"];
return [];
}
const MAX_PRESSES = 6, STEP_WAIT_MS = 2_000;
/** What this pane can change from the phone; undefined for harnesses that
* offer nothing. A hand-started Codex TUI is empty: its state is still read
* from its transcript, but switching is not offered. Claude's cycle holds
* `full-access` only when it was launched allowing bypass. */
export function settingsCapabilities(source, codexServed, claudeBypass = false) {
if (source === "codex")
return codexServed ? { permissionModes: [...PERMISSION_MODES], plan: true, fast: false } : { permissionModes: [], plan: false, fast: false };
if (source === "claude")
return { permissionModes: PERMISSION_MODES.filter(mode => mode !== "full-access" || claudeBypass), plan: true, fast: true };
return undefined;
}
const request = z.object({ permissionMode: z.enum(PERMISSION_MODES).optional(), plan: z.boolean().optional(), fast: z.boolean().optional() })
.strict().refine(value => value.permissionMode !== undefined || value.plan !== undefined || value.fast !== undefined, "Choose a setting to change.");
/** A Claude pane's settings as its footer shows them, read at most once per
* `PHREN_DIALOG_THROTTLE_MS` like the dialog check, and whether bypass was
* ever seen in it (which is when full access is offered). The same read, with
* its styles, carries the suggested next prompt in the input box. */
export class ClaudeSettingsReader {
hooks;
every;
cache = new Map();
constructor(hooks, every = intervalFromEnv("PHREN_DIALOG_THROTTLE_MS", 3_000)) {
this.hooks = hooks;
this.every = every;
}
async read(target, terminal) {
const key = `${target.server}:${target.pane}`;
let entry = this.cache.get(key);
if (!entry || entry.terminal !== terminal)
entry = { terminal, at: 0, bypass: false };
// Re-set on every use so the Map's order is recency.
this.cache.delete(key);
this.cache.set(key, entry);
if (Date.now() - entry.at >= this.every) {
entry.at = Date.now();
const screen = await this.hooks.paneAnsi(target).catch(() => undefined);
if (screen !== undefined)
entry.mode = claudeFooterMode(screen);
entry.suggestion = screen === undefined ? undefined : claudeSuggestion(screen);
if (entry.mode === "bypassPermissions")
entry.bypass = true;
while (this.cache.size > 64)
this.cache.delete(this.cache.keys().next().value);
}
const phone = entry.mode ? CLAUDE_MODES[entry.mode] : undefined;
return { bypass: entry.bypass, ...(phone ? { state: phone === "plan" ? { plan: true } : { permissionMode: phone, plan: false } } : {}),
...(entry.suggestion ? { suggestion: { text: entry.suggestion, readAt: entry.at } } : {}),
// The raw Claude mode the phone's mode picker shows, only when the footer
// was read: an unreadable one is not a confident "default".
...(entry.mode ? { permissionMode: entry.mode, permissionModes: permissionModes(entry.bypass) } : {}) };
}
}
/** Claude's answer to a slash command: the first `<local-command-stdout>` user
* row appended after `from`. */
async function commandOutput(file, from) {
const handle = await open(file, "r");
try {
const { size } = await handle.stat(), length = Math.min(Math.max(size - from, 0), 1_048_576), buffer = Buffer.alloc(length);
await handle.read(buffer, 0, length, from);
for (const line of buffer.subarray(0, buffer.lastIndexOf(10) + 1).toString("utf8").split("\n")) {
if (!line.includes("local-command-stdout"))
continue;
try {
const content = object(object(JSON.parse(line)).message).content;
const text = typeof content === "string" ? content : Array.isArray(content) ? content.map(block => String(object(block).text ?? "")).join("") : "";
const match = /<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/.exec(text);
if (match)
return stripTerminal(match[1]).trim().slice(0, 300);
}
catch { /* Unrelated row. */ }
}
return undefined;
}
finally {
await handle.close();
}
}
const fileSize = async (file) => file ? (await stat(file).catch(() => undefined))?.size ?? 0 : 0;
/** One settings transaction per pane owns its terminal input until it ends. */
export class SettingsSwitcher {
hooks;
wait;
active = new Set();
reader;
constructor(hooks, wait = STEP_WAIT_MS) {
this.hooks = hooks;
this.wait = wait;
this.reader = new ClaudeSettingsReader(hooks);
}
/** A Claude pane's footer-read settings and capabilities for the stream,
* with the suggested next prompt its input box shows and the raw permission
* mode (`agentStatus.permissionMode`) its picker offers. */
async streamSettings(target, terminal, codexServed) {
if (target.source !== "claude") {
const settings = settingsCapabilities(target.source, codexServed);
return settings ? { settings } : {};
}
const { state, bypass, suggestion, permissionMode, permissionModes } = await this.reader.read(target, terminal);
return { settings: settingsCapabilities("claude", false, bypass), ...(state ? { settingsState: state } : {}), ...(suggestion ? { suggestion } : {}),
...(permissionMode ? { permissionMode, permissionModes } : {}) };
}
key(target) { return `${target.server}:${target.pane}`; }
assertAvailable(target) {
if (this.active.has(this.key(target)))
throw new BridgeError(409, "A settings change is already in progress. Wait for it to finish.");
}
async switch(target, data) {
this.assertAvailable(target);
// The route's own envelope is not a setting; anything else unknown is refused.
const { target: _target, deliveryId: _delivery, ...settings } = data;
const change = request.parse(settings);
// A Codex pane on the Hook's own app-server takes the settings with its
// next turn/start, whatever it is doing now, as with the model.
const served = target.source === "codex" ? codexServers.forTarget(target) : undefined;
if (served)
return this.hold(served, change);
if (target.source !== "claude")
throw new BridgeError(422, "Changing these settings is not supported for this harness. Open terminal to change them.");
this.active.add(this.key(target));
try {
return await this.claude(target, change);
}
finally {
this.active.delete(this.key(target));
}
}
hold(entry, change) {
if (change.fast !== undefined)
throw new BridgeError(422, "Fast mode is Claude's. Codex has no fast setting here.");
try {
codexServers.holdSettings(entry, { ...(change.permissionMode ? CODEX_MODES[change.permissionMode] : {}),
...(change.plan !== undefined ? { collaborationMode: { mode: change.plan ? "plan" : "default" } } : {}) });
}
catch (error) {
throw new BridgeError(409, error instanceof Error ? error.message : "Codex could not take the setting.");
}
return { ok: true, ...(change.permissionMode ? { permissionMode: change.permissionMode } : {}), ...(change.plan !== undefined ? { plan: change.plan } : {}), applies: "next-turn" };
}
async claude(target, change) {
if (change.permissionMode && change.plan === true)
throw new BridgeError(422, "Claude's plan mode replaces the permission mode. Change one at a time.");
if (change.permissionMode === "full-access")
throw new BridgeError(422, "Claude only offers full access when it was launched allowing it. Open terminal to check.");
const first = await validateTarget(target, false, true);
if (!["idle", "done"].includes(String(first.agent_status))) {
throw new BridgeError(409, "This agent is working. Settings change between turns; choose it again when the turn ends.");
}
const terminal = first.terminal_id;
const validate = async () => {
const pane = await validateTarget(target, false, true);
if (pane.terminal_id !== terminal)
throw new BridgeError(409, "The terminal changed during the settings change.");
if (pane.agent_status === "working")
throw new BridgeError(409, "The agent started working during the settings change. The result is unconfirmed; check the terminal.");
};
const screenMode = async () => claudeFooterMode(await this.hooks.paneLines(target));
// The mode after a change: polled from the screen until it differs from `from`.
const changed = async (from, wait) => {
const deadline = Date.now() + wait;
do {
await new Promise(resolve => setTimeout(resolve, 150));
await validate();
const now = await screenMode();
if (now && now !== from)
return now;
} while (Date.now() < deadline);
throw new BridgeError(409, "Could not verify Claude's permission mode. Open terminal to check it.");
};
const slash = async (command) => {
// Typing into a draft would corrupt both it and the command.
const before = await this.hooks.paneLines(target, false);
assertComposerReady(before, "change settings");
await validate();
await terminalProvider().prompt(target.server, target.pane, command);
};
let verified;
if (change.fast !== undefined) {
const said = async () => [...stripTerminal(await this.hooks.paneLines(target, true)).matchAll(/fast mode (on|off)\b/gi)].map(match => match[1].toLowerCase());
// Older answers, in the transcript or the scrollback, must not count.
let file = await transcriptPath("claude", target.session).catch(() => undefined);
const from = await fileSize(file), earlier = (await said()).length;
await slash(`/fast ${change.fast ? "on" : "off"}`);
verified = false;
// Claude answers in the transcript, as a user row right after the command.
const deadline = Date.now() + Math.round(this.wait * 1.5);
let answer;
do {
await new Promise(resolve => setTimeout(resolve, 150));
await validate();
file ??= await transcriptPath("claude", target.session).catch(() => undefined);
answer = file ? await commandOutput(file, from).catch(() => undefined) : undefined;
} while (answer === undefined && Date.now() < deadline);
if (answer !== undefined) {
const state = /fast mode (?:is )?(on|off)\b/i.exec(answer)?.[1].toLowerCase();
if (/unavailable|not available/i.test(answer))
throw new BridgeError(422, `Claude: ${answer}`);
if (state) {
if ((state === "on") !== change.fast)
throw new BridgeError(409, `Claude answered Fast mode ${state}. Open terminal to check.`);
verified = true;
}
}
else {
// No row in time: the screen is the fallback.
const answers = await said(), last = answers.length > earlier ? answers.at(-1) : undefined;
if (last) {
if ((last === "on") !== change.fast)
throw new BridgeError(409, `Claude answered Fast mode ${last}. Open terminal to check.`);
verified = true;
}
}
}
let mode = await screenMode();
if ((change.plan !== undefined || change.permissionMode) && !mode)
throw new BridgeError(409, "Could not read Claude's permission mode from its screen. Open terminal to check.");
const started = mode ?? "default"; // Only used once a change needs the mode, and then the footer was read.
if (change.plan === true) {
if (started !== "plan") {
await slash("/plan");
mode = await changed(started, this.wait);
if (mode !== "plan")
throw new BridgeError(409, "Claude did not enter plan mode. Open terminal to check.");
}
}
else if (change.permissionMode || change.plan === false) {
const wanted = change.permissionMode ? CLAUDE_NAMES[change.permissionMode] : undefined;
const reached = (raw) => wanted ? raw === wanted : raw !== "plan";
// Shift+Tab steps through the modes Claude offers in this session; a
// lap back to the start means it does not offer the one asked for.
for (let presses = 0; !reached(mode); presses++) {
if (presses >= MAX_PRESSES || (presses > 0 && mode === started))
throw new BridgeError(422, "Claude doesn't offer that mode in this session.");
await validate();
// Herdr's name for the key (it sends ESC [ Z); shift-tab, backtab and btab are rejected.
await terminalProvider().sendKeys(target.server, target.pane, ["shift+tab"]);
mode = await changed(mode, this.wait);
}
}
const phone = mode ? CLAUDE_MODES[mode] : undefined;
// Only what the footer showed is reported; a fast-only change with an unreadable one says nothing of the mode.
return { ok: true, ...(phone && phone !== "plan" ? { permissionMode: phone } : {}), ...(phone ? { plan: phone === "plan" } : {}),
...(change.fast !== undefined ? { fast: change.fast, verified: verified === true } : {}) };
}
}