UNPKG

@phren/cli

Version:

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

106 lines (105 loc) • 4.92 kB
import { randomUUID } from "node:crypto"; import { mkdir, rename, unlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import { z } from "zod"; import { isRecord } from "../phren-core.js"; export const PROTOCOL = 1; export const MAX_FRAME = 8 * 1024 * 1024; export const id = z.string().regex(/^[A-Za-z0-9_%:.-]{1,200}$/); export const serverName = z.string().regex(/^(?!\.\.?$)[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}$/); export const computerName = serverName.refine(name => name !== "anywhere", "anywhere is reserved for scheduling"); export const provider = z.enum(["codex", "claude", "copilot", "phren", "opencode"]); export const sessionId = z.union([ z.string().uuid(), z.string().regex(/^ses_[0-9A-Za-z]{1,64}$/), ]); export const targetSchema = z.object({ server: serverName, workspace: id, tab: id, pane: id, source: provider, session: sessionId, }); // Only /v1/prompt accepts a transcript-less target. All transcript, image, // approval and diff routes retain the session-bound schema above. export const startingTargetSchema = targetSchema.omit({ session: true }).extend({ starting: z.literal(true), startingToken: z.string().regex(/^[a-f0-9]{64}$/), }); /** The decisions an approval answer may carry. An OpenCode permission ask * accepts both grant scopes; every other provider approves or denies only. */ export const approvalDecisions = ["approve", "deny", "allow-project", "allow-everywhere"]; /** `value` when it is a plain object, else an empty one. */ export function object(value) { return isRecord(value) ? value : {}; } export function objects(value) { return Array.isArray(value) ? value.map(object) : []; } export function bridgeRoot() { return process.env.PHREN_BRIDGE_HOME || path.join(homedir(), ".local/share/phren/bridge"); } export function socketPath() { return path.join(bridgeRoot(), "hook.sock"); } export class BridgeError extends Error { status; details; constructor(status, message, details) { super(message); this.status = status; this.details = details; } } const ERROR_CODE = /^[a-z][a-z0-9-]{0,39}$/; /** The machine-readable `code` an error carries, if any. */ export function errorCode(error) { const code = error instanceof BridgeError ? error.details?.code : undefined; return typeof code === "string" && ERROR_CODE.test(code) ? code : undefined; } /** A BridgeError that keeps `error`'s status and text and adds `code` when it has none yet. */ export function withErrorCode(error, code) { return errorCode(error) ? error : new BridgeError(error.status, error.message, { ...(error.details ?? {}), code }); } export const requestID = () => randomUUID(); /** * Write a file atomically: serialize to a fresh temp name, then rename it over * the target so a reader never observes a partial file. `value` may be * pre-serialized text or any JSON-serializable value. The default mode is 0600 * because every caller writes per-user state. */ export async function atomic(file, value, mode = 0o600) { const temporary = `${file}.${randomUUID()}.tmp`; const text = typeof value === "string" ? value : JSON.stringify(value); try { await writeFile(temporary, text, { mode, flag: "wx" }); await renameOver(temporary, file); } finally { await unlink(temporary).catch(() => { }); } } /** * Windows refuses to replace a file another handle holds open (a concurrent * reader, or another writer's rename landing at the same moment) with EPERM, * EACCES or EBUSY, where POSIX replaces it atomically. Those holds last * milliseconds, so retry briefly before giving up. */ async function renameOver(from, to) { for (let attempt = 0;; attempt++) { try { return await rename(from, to); } catch (error) { const code = error.code ?? ""; if (process.platform !== "win32" || attempt >= 20 || !["EPERM", "EACCES", "EBUSY"].includes(code)) throw error; await new Promise(resolve => setTimeout(resolve, 5 * (attempt + 1))); } } } /** {@link atomic}, first creating the file's directory (0700 where it is new). */ export async function atomicInPrivateDir(file, value, mode) { await mkdir(path.dirname(file), { recursive: true, mode: 0o700 }); await atomic(file, value, mode); } export function targetFromURL(url) { return targetSchema.parse(Object.fromEntries(["server", "workspace", "tab", "pane", "source", "session"].map(k => [k, url.searchParams.get(k)]))); } /** The reasoning efforts a launch or a dispatch may ask a harness for. */ export const launchEfforts = ["minimal", "low", "medium", "high", "xhigh", "max"]; /** The permission modes a worker may start in, in the phone's words (T3's). */ export const PERMISSION_MODES = ["supervised", "auto-edits", "auto", "full-access"];