@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
106 lines (105 loc) • 4.92 kB
JavaScript
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"];