@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
202 lines (201 loc) • 13.4 kB
JavaScript
import { ownerInboxSchema } from "./owner-inbox.js";
import { prsSchema } from "./return-contract.js";
import { parseArgs } from "node:util";
import { hookRequest } from "./client.js";
import { dispatchSchema } from "./dispatch.js";
import { handOff } from "./hand-off.js";
import { terminalPaneFromEnv } from "./terminal.js";
import { addGrant, grantSchema, listNamedGrants, removeGrant } from "./grants.js";
import { sessionId } from "./protocol.js";
export async function runDispatch(args) {
if (args[0] === "report") {
const { values } = parseArgs({ args: args.slice(1), options: { prs: { type: "string" } } });
const origin = await terminalPaneFromEnv();
if (!origin || !values.prs)
throw new Error("Usage inside a worker pane: phren dispatch report --prs <JSON array>");
console.log(JSON.stringify(await hookRequest("/v1/dispatch/report", { origin, prs: prsSchema.parse(JSON.parse(values.prs)) }), null, 2));
return 0;
}
if (args.length === 1 && args[0] === "status") {
console.log(JSON.stringify(await hookRequest("/v1/dispatch"), null, 2));
return 0;
}
if (args.length === 1 && args[0] === "returns") {
console.log(JSON.stringify(await hookRequest("/v1/dispatch/returns", {}), null, 2));
return 0;
}
if (args[0] === "usage" && args.slice(1).every(arg => arg === "--json")) {
const { readAccountUsage, formatAccountUsage } = await import("./account-usage.js");
const view = await readAccountUsage();
console.log(args.includes("--json") ? JSON.stringify(view, null, 2) : formatAccountUsage(view));
return 0;
}
if (args.length === 1 && args[0] === "sessions") {
const { listLiveSessions } = await import("./hand-off.js");
console.log(JSON.stringify(await listLiveSessions(), null, 2));
return 0;
}
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
"keep-open": { type: "boolean" },
harness: { type: "string", default: "codex" }, model: { type: "string" }, effort: { type: "string" }, account: { type: "string" }, "permission-mode": { type: "string" }, prompt: { type: "string" }, label: { type: "string" },
"parent-provider": { type: "string" }, "parent-session": { type: "string" }, "parent-computer": { type: "string" },
"parent-server": { type: "string" }, "parent-workspace": { type: "string" }, "parent-tab": { type: "string" }, "parent-pane": { type: "string" },
} });
if (positionals.length !== 2)
throw new Error("Usage: phren dispatch <computer|anywhere> <project> --label <label> --prompt <brief> [--harness codex|claude|opencode|copilot] [--model <model>] [--effort minimal|low|medium|high|xhigh|max] [--account <id>] [--permission-mode supervised|auto-edits|auto|full-access] [explicit parent flags]");
const parentValues = ["parent-provider", "parent-session", "parent-computer", "parent-server", "parent-workspace", "parent-tab", "parent-pane"];
const hasParent = parentValues.some(key => values[key] !== undefined);
const parent = hasParent ? {
provider: values["parent-provider"], session: values["parent-session"], computer: values["parent-computer"],
} : undefined;
const parentTarget = hasParent ? {
server: values["parent-server"], workspace: values["parent-workspace"], tab: values["parent-tab"], pane: values["parent-pane"],
source: values["parent-provider"], session: values["parent-session"],
} : undefined;
const { "permission-mode": permissionMode, "keep-open": keepOpen, ...rest } = values;
const ordinary = { ...Object.fromEntries(Object.entries(rest).filter(([key]) => !key.startsWith("parent-"))), ...(permissionMode !== undefined ? { permissionMode } : {}), ...(keepOpen ? { closeOnFinish: false } : {}) };
const input = dispatchSchema.parse({ computer: positionals[0], project: positionals[1], ...ordinary,
...(parent ? { parent, parentTarget } : {}) });
// Run inside an agent's pane, the dispatch remembers that pane for return notices.
const origin = await terminalPaneFromEnv();
const result = await hookRequest("/v1/dispatch", { ...input, ...(origin ? { origin } : {}) }, undefined, 180_000);
console.log(JSON.stringify(result, null, 2));
return result.ok === true ? 0 : 1;
}
export async function runHandOff(args) {
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
"delivery-id": { type: "string" }, status: { type: "boolean" }, session: { type: "string" }, text: { type: "string" }, project: { type: "string" }, account: { type: "string" },
} });
if (positionals.length !== 1 || !values.session || (!values.status && !values.text) || (values.status && !values["delivery-id"]))
throw new Error("Usage: phren hand-off <computer|local> --session <id> --text <prompt> [--project <slug>] [--account <id>]");
const computer = positionals[0] === "local" ? undefined : positionals[0];
const result = await handOff({ ...(computer ? { computer } : {}), project: values.project, ...(values.account ? { account: values.account } : {}), session: sessionId.parse(values.session), ...(values.status ? { status: true } : { text: values.text }), ...(values["delivery-id"] ? { deliveryId: values["delivery-id"] } : {}) });
console.log(JSON.stringify(result, null, 2));
return result.ok ? 0 : 1;
}
const CONDUCTOR_USAGE = "Usage: phren conductor status | make [--pane <id>] [--mux herdr:<name>|tmux:<name>] | stop [--pane <id>] | sets [--json] | sets name <name>|--clear | grants [list|add|remove]";
/** The Hook route for a pane's multiplexer: `--mux`, else the pane the command runs in. */
function muxQuery(mux, here) {
if (mux)
return `?mux=${encodeURIComponent(mux)}`;
return here ? `?server=${encodeURIComponent(here.server)}` : "";
}
/** One line per computer: name, reachability, link and conductor. */
export function formatSets(view) {
const lines = [];
const rows = (value) => Array.isArray(value) ? value.filter((item) => !!item && typeof item === "object") : [];
for (const set of rows(view.sets)) {
const conductors = typeof set.conductors === "number" ? set.conductors : 0;
lines.push(`${typeof set.name === "string" ? set.name : "Unnamed set"}${set.local ? " (this computer)" : ""}${conductors > 1 ? `: ${conductors} conductors, stop all but one` : ""}`);
for (const computer of rows(set.computers)) {
const state = computer.link === "self" ? "this computer" : computer.reachable === true ? "reachable" : computer.reachable === false ? "unreachable" : "not asked";
const link = computer.link === "self" ? "" : `, ${String(computer.link)} link`;
lines.push(` ${String(computer.name)}: ${state}${link}${computer.conductor ? ", conductor" : ""}${typeof computer.hint === "string" ? `. ${computer.hint}` : typeof computer.error === "string" ? `. ${computer.error}` : ""}`);
}
}
const unlinked = rows(view.unlinked);
if (unlinked.length) {
lines.push("Not linked");
for (const computer of unlinked)
lines.push(` ${String(computer.name)}. Link it with phren bridge link ${String(computer.name)}.`);
}
return lines.join("\n");
}
export async function runConductor(args) {
const [namespace = "status", action = "list", ...rest] = args;
if (namespace === "status") {
console.log(JSON.stringify(await hookRequest("/v1/conductor"), null, 2));
return 0;
}
if (namespace === "make" || namespace === "stop") {
const { values } = parseArgs({ args: args.slice(1), options: { pane: { type: "string" }, mux: { type: "string" } } });
const here = await terminalPaneFromEnv();
if (namespace === "make") {
const pane = values.pane ?? here?.pane;
if (!pane)
throw new Error("Run phren conductor make inside the agent's pane, or name it with --pane <id>.");
const place = values.pane ? { paneId: values.pane } : { workspaceId: here.workspace, tabId: here.tab, paneId: here.pane };
const result = await hookRequest(`/v1/conductor/make${muxQuery(values.mux, values.pane ? undefined : here)}`, place);
console.log(JSON.stringify(result, null, 2));
return result.ok === true ? 0 : 1;
}
const result = await hookRequest("/v1/conductor/stop", values.pane ? { paneId: values.pane } : {});
console.log(result.stopped ? "This computer has no conductor now." : "This computer had no conductor.");
return 0;
}
if (namespace === "integrator") {
const { values } = parseArgs({ args: args.slice(1), options: { session: { type: "string" }, computer: { type: "string" }, clear: { type: "boolean" } } });
if (values.clear) {
console.log(JSON.stringify(await hookRequest("/v1/conductor/integrator", { integrator: null }), null, 2));
return 0;
}
if (!values.session) {
console.log(JSON.stringify(await hookRequest("/v1/conductor/integrator"), null, 2));
return 0;
}
const { listLiveSessions } = await import("./hand-off.js");
const live = await listLiveSessions(), session = live.sessions.find(row => row.target?.session === values.session && (values.computer ? row.computer === values.computer : row.local));
if (!session?.target)
throw new Error("That integrator is not a live session on the selected computer.");
console.log(JSON.stringify(await hookRequest("/v1/conductor/integrator", { integrator: { target: session.target, ...(!session.local ? { computer: session.computer } : {}) } }), null, 2));
return 0;
}
if (namespace === "sets") {
if (action === "name") {
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { clear: { type: "boolean" } } });
const name = values.clear ? null : positionals.join(" ").trim();
if (name === "")
throw new Error("Usage: phren conductor sets name <name>|--clear");
console.log(JSON.stringify(await hookRequest("/v1/sets/name", { name }), null, 2));
return 0;
}
const view = await hookRequest("/v1/sets", undefined, undefined, 60_000);
console.log(args.includes("--json") ? JSON.stringify(view, null, 2) : formatSets(view));
return 0;
}
if (namespace !== "grants")
throw new Error(CONDUCTOR_USAGE);
if (action === "list") {
console.log(JSON.stringify(await listNamedGrants(), null, 2));
return 0;
}
if (action === "add") {
const { values } = parseArgs({ args: rest, allowPositionals: true, options: {
scope: { type: "string" }, actions: { type: "string" }, computers: { type: "string" }, until: { type: "string" },
"max-permission-mode": { type: "string" },
} });
const actions = (values.actions ?? "dispatch,hand_off").split(",").map(value => value.trim()).filter(Boolean);
const grant = grantSchema.parse({
scope: values.scope ?? "global",
actions,
...(values.computers ? { computers: values.computers.split(",").map(value => value.trim()).filter(Boolean) } : {}),
...(values.until ? { until: values.until } : {}),
...(values["max-permission-mode"] ? { maxPermissionMode: values["max-permission-mode"] } : {}),
});
console.log(JSON.stringify(await addGrant(grant), null, 2));
return 0;
}
if (action === "remove") {
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: {
scope: { type: "string" }, index: { type: "string" },
} });
const index = values.index !== undefined ? Number(values.index) : positionals[0] !== undefined ? Number(positionals[0]) : undefined;
if (index === undefined && !values.scope)
throw new Error("Usage: phren conductor grants remove <index>|--scope <scope>");
if (index !== undefined && !Number.isInteger(index))
throw new Error("Grant index must be an integer.");
console.log(JSON.stringify(await removeGrant(index !== undefined ? { index } : { scope: values.scope }), null, 2));
return 0;
}
throw new Error(CONDUCTOR_USAGE);
}
export async function runOwnerInbox(args) {
const [action = "list", ...rest] = args;
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { computer: { type: "string" }, project: { type: "string" }, id: { type: "string" }, resolution: { type: "string" }, all: { type: "boolean" } } });
const input = ownerInboxSchema.parse({ action, ...(values.computer ? { computer: values.computer === "local" ? undefined : values.computer } : {}),
...(action === "add" ? { title: positionals.join(" "), project: values.project, id: values.id } : {}),
...(action === "resolve" ? { id: positionals[0], resolution: values.resolution } : {}),
...(values.all ? { includeResolved: true } : {}) });
console.log(JSON.stringify(await hookRequest("/v1/owner-inbox", input), null, 2));
return 0;
}