@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
145 lines (144 loc) • 7.84 kB
JavaScript
import { stripTerminal } from "../terminal-text.js";
import { validateTarget } from "./herdr.js";
import { BridgeError } from "./protocol.js";
import { terminalProvider } from "./terminal.js";
import { visibleTerminalChoice } from "./terminal-choice.js";
/** An agent's permission mode as its own footer names it, and the route that
* changes it by pressing Shift+Tab through the harness's cycle. The phone's
* mode picker reads `agentStatus.permissionMode` / `permissionModes` off the
* chat status stream and posts one back here. Only Claude Code draws the mode
* in its footer; Codex and OpenCode keep theirs in config, so they offer none. */
/** Claude Code's `permissionMode` values in Shift+Tab cycle order. "default" is
* the footer's "manual mode on"; "auto" is "auto mode on". */
export const PERMISSION_CYCLE = ["default", "acceptEdits", "plan", "auto"];
/** `bypassPermissions` is in the cycle only for a session launched allowing
* bypass, so it is a valid mode but not part of the standard list. */
export const PERMISSION_MODE_VALUES = [...PERMISSION_CYCLE, "bypassPermissions"];
/** The modes a Claude pane offers, in Shift+Tab cycle order. `bypass` adds the
* one only a session launched with --allow-dangerously-skip-permissions has. */
export function permissionModes(bypass) {
return [...PERMISSION_CYCLE, ...(bypass ? ["bypassPermissions"] : [])];
}
/** Claude's permission mode from the footer line its TUI draws under the
* composer. The transcript lags it: a `permission-mode` row is written only
* when a prompt is submitted, and a fresh session has no file yet. The line may
* trail hints ("(shift+tab to cycle) · 1 agent"), so only its start counts. A
* mode the footer does not mark is `undefined`: "manual mode on" is default. */
export function claudeFooterMode(screen) {
for (const line of stripTerminal(screen).split(/\r?\n/).map(line => line.trim()).filter(Boolean).slice(-8).reverse()) {
const text = line.replace(/^[⏵⏸]+\s*/, "");
if (text === line)
continue;
if (/bypass permissions on/i.test(text))
return "bypassPermissions";
if (/^manual mode on\b/i.test(text))
return "default";
if (/^accept edits on\b/i.test(text))
return "acceptEdits";
if (/^plan mode on\b/i.test(text))
return "plan";
if (/^auto mode on\b/i.test(text))
return "auto";
}
return undefined;
}
/** Only the owner changes a session's permission mode, from the phone. An
* agent's call names its own pane (`origin`), and none is accepted: an agent
* could otherwise lift itself or another pane past the per-grant
* `maxPermissionMode` ceiling, up to bypassPermissions. Same rule as
* `/v1/sudo/answer`. */
export function refuseAgentOrigin(actor) {
if (actor !== undefined)
throw new BridgeError(403, "Only the owner changes a session's permission mode, from the phone.");
}
/** Who chose the mode, for the reply: always the owner (see `refuseAgentOrigin`). */
function attribution() {
return { setBy: "owner" };
}
const STEP_WAIT_MS = 2_000, POLL_MS = 100;
/** One Shift+Tab transaction per pane owns its terminal input until it ends,
* like the settings switcher, so a mode change and a settings change never
* press into the same composer at once. */
export class PermissionModeSwitcher {
hooks;
wait;
active = new Set();
constructor(hooks, wait = STEP_WAIT_MS) {
this.hooks = hooks;
this.wait = wait;
}
key(target) { return `${target.server}:${target.pane}`; }
assertAvailable(target) {
if (this.active.has(this.key(target)))
throw new BridgeError(409, "A permission-mode change is already in progress. Wait for it to finish.");
}
/** Steps the pane to `mode`, or refuses. Only Claude offers the cycle; the
* machine's authority policy never caps an owner's own choice. */
async set(target, mode, actor) {
refuseAgentOrigin(actor);
this.assertAvailable(target);
if (target.source !== "claude")
throw new BridgeError(422, "Changing the permission mode is not supported for this harness. Open terminal to change it.");
this.active.add(this.key(target));
try {
return await this.cycle(target, mode, actor);
}
finally {
this.active.delete(this.key(target));
}
}
async cycle(target, mode, actor) {
const first = await validateTarget(target, false, true);
const terminal = first.terminal_id, status = String(first.agent_status);
// Shift+Tab is the composer's key: a turn or a dialog would take it first.
if (status === "working")
throw new BridgeError(409, "This agent is mid-turn. Permission changes happen between turns; choose the mode again when the turn ends.");
const screen = await this.hooks.paneAnsi(target).catch(() => undefined);
if (screen === undefined)
throw new BridgeError(409, "Could not read Claude's permission mode from its screen. Open terminal to check.");
if (status === "blocked" || status === "waiting" || this.hooks.approval(target) || this.hooks.terminalPrompt(target)
|| visibleTerminalChoice(stripTerminal(screen))) {
throw new BridgeError(409, "A permission prompt or dialog is open. Answer it before changing the permission mode.");
}
let current = claudeFooterMode(screen);
if (!current)
throw new BridgeError(409, "Could not read Claude's permission mode from its screen. Open terminal to check.");
if (current === mode)
return { ok: true, permissionMode: current, ...attribution() };
// Shift+Tab steps through the modes this session offers. A full lap back to
// the start (one more press than the cycle holds) means it does not offer
// the one asked for. The bound grows if the cycle turns out to include one
// more mode than the standard list (bypass).
const modes = new Set(PERMISSION_CYCLE);
for (let presses = 0;; presses++) {
if (presses >= modes.size + 1)
throw new BridgeError(422, `Claude doesn't offer ${mode} in this session.`, { permissionMode: current });
await this.assertLive(target, terminal);
await terminalProvider().sendKeys(target.server, target.pane, ["shift+tab"]);
current = await this.changed(target, terminal, current);
modes.add(current);
if (current === mode)
return { ok: true, permissionMode: current, ...attribution() };
}
}
/** The mode after a press: polled from the screen until it differs from
* `from`, because the TUI redraws the footer asynchronously. */
async changed(target, terminal, from) {
const deadline = Date.now() + this.wait;
do {
await new Promise(resolve => setTimeout(resolve, POLL_MS));
await this.assertLive(target, terminal);
const mode = claudeFooterMode(await this.hooks.paneAnsi(target).catch(() => ""));
if (mode && mode !== from)
return mode;
} while (Date.now() < deadline);
throw new BridgeError(409, "Could not verify Claude's permission mode. Open terminal to check it.");
}
async assertLive(target, terminal) {
const pane = await validateTarget(target, false, true);
if (pane.terminal_id !== terminal)
throw new BridgeError(409, "The terminal changed during the permission-mode change.");
if (pane.agent_status === "working")
throw new BridgeError(409, "The agent started working during the permission-mode change. The result is unconfirmed; check the terminal.");
}
}