UNPKG

@phren/cli

Version:

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

387 lines (386 loc) • 26.3 kB
import { continuationPlacement } from "./continuation-launch.js"; import { ConductorSuccessionService, preflightConductorWorkers } from "./conductor-succession.js"; import { StorePulls } from "./store-pull.js"; import { GitboyWebhooks } from "./gitboy-webhooks.js"; import { setChoiceReaders } from "./account-choice.js"; import { AccountFailover } from "./account-failover.js"; import { ownerInboxSources } from "./owner-inbox-sources.js"; import { OwnerInbox } from "./owner-inbox.js"; import { closeFinishedWorker } from "./worker-close.js"; import { isLocalComputer } from "./dispatch-hosts.js"; import { hookPeers, peerRequest } from "./peers.js"; import { handOff } from "./hand-off.js"; import { sessionStalls } from "./stalls.js"; import { HandOffQueue } from "./hand-off-queue.js"; import { paneRouteOnce } from "./server-pane-routes.js"; import { FanoutMessages } from "./fanout-messages.js"; import { activateModules as moduleSnapshot } from "../modules/runtime.js"; import { disabledHint } from "../modules/registry.js"; import { randomUUID } from "node:crypto"; import { chmod, lstat, mkdir, readFile, rm, unlink, writeFile } from "node:fs/promises"; import { readFileSync } from "node:fs"; import { createServer } from "node:http"; import { cpus, hostname, loadavg } from "node:os"; import path from "node:path"; import { WebSocketServer } from "ws"; import { relayLiveSpeech } from "./speech-live.js"; import { failRelay, relayTranscription } from "./speech-transcribe.js"; import { ActivityJournal } from "./activity.js"; import { countTick } from "./metrics.js"; import { AgentHooks } from "./agent-hooks.js"; import { approvalPushCapability } from "./push.js"; import { startChangeRetention } from "./changes.js"; import { CodeReindexer, CodeRoutes } from "./code-routes.js"; import { WorkspaceContextUsage } from "./context.js"; import { DispatchService } from "./dispatch.js"; import { DispatchReturns, hookWorkers } from "./dispatch-returns.js"; import { findPane, paneChatState, paneIdentity, recentServers, sharedSnapshot, snapshot, validateTarget } from "./herdr.js"; import { LaunchLimiter } from "./limits.js"; import { locateProject } from "./locate.js"; import { BridgeError, bridgeRoot, objects, PROTOCOL, provider, socketPath, targetSchema } from "./protocol.js"; import { CodexQuestions } from "./questions.js"; import { TabActivityStore } from "./tab-activity.js"; import { childAgentTree } from "./transcripts.js"; import { ModelCatalog } from "./models.js"; import { ModelSwitcher } from "./model-switch.js"; import { SettingsSwitcher } from "./settings-switch.js"; import { PermissionModeSwitcher } from "./permission-mode.js"; import { SideQuestions } from "./side-questions.js"; import { AccountUsageReader } from "./usage.js"; import { ResourceMonitor } from "./resources.js"; import { createScheduleLauncher, Scheduler, scheduleRunsFile } from "./schedules.js"; import { dailyCanaryDue, runCanary } from "./canary.js"; import { defaultPhrenPath } from "../shared.js"; import { capabilitiesForModules, createRouteHandler, requireRoute, selectedServer, workspacesReader } from "./server-routes.js"; import { typedMuxRequest } from "./mux-wire.js"; import { overviewStream } from "./server-overview.js"; import { transcriptStreams } from "./server-stream.js"; import { launchSession } from "./server-launch.js"; import { SessionMover, transcriptTail } from "./session-move.js"; import { harnessInventoryWithin, launchCheckOff } from "./harnesses.js"; import { paneAccount, paneAccountKey } from "./pane-accounts.js"; import { readTurn } from "./turn-records.js"; import { readLaunchBrief } from "./launch-brief.js"; import { conductorPane, readRoleState } from "./conductor-role.js"; import { terminalKind, terminalProvider } from "./terminal.js"; import { targetTranscriptPath } from "./transcripts.js"; import { codexAppServerEnabled, codexServers } from "./codex-servers.js"; import { CodexAuthKeeper, codexAuthRefreshEnabled } from "./codex-auth-refresh.js"; import { localNames } from "./computer-names.js"; import { startStoreSyncLoop } from "../sync/store-sync.js"; import { SocketBudget, socketIdentity, socketKind } from "./socket-budget.js"; export { capabilities, capabilitiesForModules, requireRoute } from "./server-routes.js"; export { streamCloseReason } from "./server-stream.js"; export { herdrAgentName, launchSession } from "./server-launch.js"; export async function serve(version, options = {}) { process.umask(0o077); const modules = moduleSnapshot(defaultPhrenPath(), undefined, true); if (!modules.has("hook")) throw new BridgeError(404, disabledHint("hook")); const activeCapabilities = capabilitiesForModules(modules); const root = bridgeRoot(); await mkdir(root, { recursive: true, mode: 0o700 }); await chmod(root, 0o700); let computerID; const identityFile = path.join(root, "computer-id"); try { computerID = (await readFile(identityFile, "utf8")).trim(); } catch { computerID = randomUUID(); await writeFile(identityFile, computerID, { flag: "wx", mode: 0o600 }); } const launches = new LaunchLimiter(); const dispatches = modules.has("conductor") ? new DispatchService({ computerID, validateParentTarget: target => validateTarget(target, false, true), originAgent: async (origin) => { const state = (await readRoleState())?.succession; if (state?.side === "origin" && state.phase === "committed" && state.from.server === origin.server && state.from.pane === origin.pane && state.previous.terminal && state.previous.source) return { agent: state.previous.source, terminal: state.previous.terminal }; const pane = findPane(await snapshot(origin.server), origin); const agent = provider.safeParse(pane?.agent); return agent.success && typeof pane?.terminal_id === "string" ? { agent: agent.data, terminal: pane.terminal_id } : undefined; } }) : undefined; const locatedDirectories = new Set(); const journal = new ActivityJournal(); const agentHooks = new AgentHooks(undefined, modules, path.join(bridgeRoot(), "deliveries.json")); // Follows what dispatched workers do and tells the dispatching agent. let succession; const returns = dispatches ? new DispatchReturns({ localComputer: { id: computerID, name: hostname() }, forward: (origin, text, deliveryId) => succession?.forward(origin, text, deliveryId) ?? Promise.resolve(undefined), // Continue on another signed-in account when a Claude worker stops at its usage limit: opt-in, `phren config account-failover on`. failover: new AccountFailover({ rooms: () => dispatches.claudeRooms(), dispatch: (input, origin, continues) => succession ? succession.automatic("dispatch", { ...input, continues }, origin, () => dispatches.dispatch(input, origin, continues)) : dispatches.dispatch(input, origin, continues) }), localWorkers: hookWorkers(agentHooks), close: async (receipt, verifiedPeer) => { const data = { target: receipt.target, dispatch: receipt.moves?.slice().reverse().find(move => move.to.computer)?.id ?? receipt.id, turn: receipt.closePending.turn }; if (receipt.computerId === computerID) return closeFinishedWorker(data, handOffs); const peer = verifiedPeer ?? (await hookPeers().catch(() => [])).find(peer => peer.name === receipt.computer); if (peer) return peerRequest(peer, "/v1/dispatch/close", data); if (!receipt.moves?.some(move => move.to.computer) && isLocalComputer(receipt.computer)) return closeFinishedWorker(data, handOffs); throw new BridgeError(503, "The worker's verified computer is offline."); }, localAnswer: (target, actionId, decision) => agentHooks.answer(target, actionId, decision), // A worker's request the worker's own Hook did not push reaches this Hook's phone. onApproval: (receipt, approval) => { if (approval.pushed || !agentHooks.push.available) return; agentHooks.pushForwarded({ provider: receipt.harness, computer: receipt.computer, project: receipt.project, request: approval.request ?? approval.title ?? approval.tool, ...(approval.requestKind ? { requestKind: approval.requestKind } : {}) }, decision => returns.answerApproval(receipt.id, decision, approval.actionId)); return true; }, }) : undefined; // Push is offered only once an APNs sender loaded; phones may still register. if ("approvalPush" in activeCapabilities) { Object.defineProperty(activeCapabilities, "approvalPush", { enumerable: true, get: () => approvalPushCapability(agentHooks.push.status) }); } const inbox = dispatches ? new OwnerInbox(() => ownerInboxSources(agentHooks)) : undefined; const modelCatalog = options.modelCatalog ?? new ModelCatalog(); const modelSwitcher = new ModelSwitcher(agentHooks, modelCatalog); const settingsSwitcher = new SettingsSwitcher(agentHooks); const permissionModeSwitcher = new PermissionModeSwitcher(agentHooks); const sideQuestions = new SideQuestions(); const sendPrompt = async (target, text, deliveryId, typing) => await paneRouteOnce({ agentHooks, modelSwitcher, settingsSwitcher, permissionModeSwitcher, codexQuestions, sideQuestions }, new URL("http://phren.local/v1/prompt"), { target, text, deliveryId, hookQueued: true }, {}, typing); const handOffs = dispatches ? new HandOffQueue({ forward: (target, text, deliveryId) => succession?.forward(target, text, deliveryId) ?? Promise.resolve(undefined), validate: target => validateTarget(target, false, true), current: async (target) => { const pane = findPane(await snapshot(target.server), target); const session = pane ? await paneIdentity(target.server, pane, true) : undefined; return pane && session ? { session, terminal: pane.terminal_id } : undefined; }, notify: (target, text, deliveryId, computer) => handOff({ computer, target, text, deliveryId }, { notifySender: false, bypassConductor: true }), send: sendPrompt, }) : undefined; // Moving a session to another agent (session-move.ts): its hand-off request // takes the hand_off path, and the new agent starts through the launch path. succession = dispatches ? new ConductorSuccessionService({ root, computer: { id: computerID, name: hostname() }, snapshot, validate: target => validateTarget(target, false, true), isSuccessor: async (origin, id) => (await readTurn(origin.server, origin.pane))?.dispatch === id, deliver: (target, text, deliveryId) => handOffs.enqueue({ target, text, deliveryId }), checkWorkers: from => preflightConductorWorkers(from, computerID), }) : undefined; const mover = new SessionMover({ succession, snapshot: server => snapshot(server), identity: (server, pane) => paneIdentity(server, pane, true), terminal: terminalProvider(), deliver: (target, text, deliveryId) => handOffs ? handOffs.enqueue({ target, text, deliveryId }) : sendPrompt(target, text, deliveryId, async () => { }), // The new agent starts in the folder an agent already ran in, so it is trusted for the new harness too. launch: (server, data, options) => launches.run(() => launchSession(server, data, { trustFolder: true, ...options })), inventory: () => launchCheckOff() ? Promise.resolve(undefined) : harnessInventoryWithin(2_500), transcript: async (target) => transcriptTail(await targetTranscriptPath(target)), paneAccount: (server, pane) => paneAccount(paneAccountKey(server, pane.pane_id), typeof pane.terminal_id === "string" ? pane.terminal_id : undefined)?.id, paneDispatch: async (server, pane) => { const dispatch = (await readTurn(server, String(pane.pane_id)))?.dispatch; if (!dispatch) return undefined; const continuation = await continuationPlacement(dispatch); if (continuation) return continuation.worker ? dispatch : undefined; const localMove = await (await import("./session-move.js")).readMove(dispatch); return localMove && !localMove.dispatch ? undefined : dispatch; }, brief: readLaunchBrief, isConductor: async (server, pane) => (await conductorPane(server, await snapshot(server)))?.pane_id === pane.pane_id, envAtStart: server => terminalKind(server) === "tmux", afterExit: async (server, pane) => { const entry = codexServers.forPane(server, pane); if (entry) await codexServers.stop(entry); }, }); const contextUsage = new WorkspaceContextUsage(); const accountUsage = options.accountUsage ?? new AccountUsageReader(); // A Claude launch that names no account runs under the one with the most room (account-choice.ts). setChoiceReaders({ usage: () => accountUsage.limits(true) }); const resources = new ResourceMonitor(); const tabActivity = new TabActivityStore(); const codexQuestions = new CodexQuestions(undefined, codexServers); const scheduleStore = defaultPhrenPath(); const scheduler = modules.has("schedules") ? new Scheduler({ now: () => new Date(), store: scheduleStore, runsFile: scheduleRunsFile(), aliases: [computerID], // A schedule starts in its project's folder, which the Hook resolved itself. launch: createScheduleLauncher((server, data) => launchSession(server, data, { trustFolder: true }), scheduleStore, // `session: reuse` continues a session through the same queue a hand-off takes. (target, text, deliveryId) => handOffs ? handOffs.enqueue({ target, text, deliveryId }) : sendPrompt(target, text, deliveryId, async () => { })), push: { notify: value => agentHooks.push.notifySchedule(value) }, locateProject: async (project) => (await locateProject(project, await journal.recent()))[0]?.directory }) : undefined; const fanoutMessages = new FanoutMessages({ ...process.env, PHREN_PATH: scheduleStore }, { validate: target => validateTarget(target, false, true), tree: target => childAgentTree(target.source, target.session, 0, new Set(), computerID), }); fanoutMessages.start(); const codeRoutes = modules.has("code") ? new CodeRoutes(scheduleStore) : undefined; // The code module follows the git module's recorded file changes: an // incremental re-index after a save, a full one after a branch switch. const codeReindexer = codeRoutes ? new CodeReindexer({ store: scheduleStore }) : undefined; if (codeReindexer) agentHooks.changes.onRecord = files => codeReindexer.record(files); // The node gateway writes its own cost after the first response byte, so a // health read reflects the most recent node-path connection, if any. function gatewayTiming() { try { const parsed = JSON.parse(readFileSync(path.join(root, "gateway.json"), "utf8")); if (typeof parsed.ms === "number" && Number.isFinite(parsed.ms) && parsed.ms >= 0) return Math.round(parsed.ms); } catch { /* the node gateway has not answered yet */ } return undefined; } const info = { product: "phren-hook", protocol: PROTOCOL, version, computer: { id: computerID, name: hostname(), aliases: localNames() }, capabilities: activeCapabilities, modules: Object.fromEntries(modules.modules.map(module => [module.name, module.version])), store: modules.store, profile: modules.profile, generation: modules.generation, get load() { return { average: Number(loadavg()[0].toFixed(2)), cpus: cpus().length }; }, get gatewayMs() { return gatewayTiming(); } }; // The canary launches through the same serialized launch path as the phone. const canary = (trigger) => runCanary({ trigger, store: scheduleStore, launch: (server, data) => launches.run(() => launchSession(server, data, { canary: true })), scheduler }); const old = await lstat(socketPath()).catch(() => null); if (old) { if (!old.isSocket() || (process.getuid && old.uid !== process.getuid())) throw new Error("Refusing to replace an unexpected hook socket."); // The service manager owns process lifetime. Only remove a stale socket. const { connect } = await import("node:net"); const live = await new Promise(resolve => { const s = connect(socketPath()); s.on("connect", () => { s.destroy(); resolve(true); }); s.on("error", () => resolve(false)); }); if (live) throw new Error("Phren Hook is already running."); await unlink(socketPath()); } const storePulls = new StorePulls(scheduleStore); const gitboyWebhooks = new GitboyWebhooks(root, storeId => storePulls.request(storeId)); const stopRetention = modules.has("git") ? await startChangeRetention() : () => { }; if (modules.has("git")) await rm(path.join(root, "changes-scratch"), { recursive: true, force: true }); const streams = transcriptStreams({ modules, agentHooks, codexQuestions, sideQuestions, settingsSwitcher, info, activeCapabilities }); const { stream } = streams; // The phone's overview, pushed over a WebSocket when it changes. const overview = overviewStream({ read: workspacesReader({ modules, info, agentHooks, journal, tabActivity, contextUsage }), info: () => ({ ...info, capabilities: info.capabilities }), renew: server => agentHooks.overview.renew(server), resources: () => resources.read(), sudo: agentHooks.sudo, }); // 256 KiB: gitboy's /memory/files asks about up to 500 paths in one query string. const http = createServer({ maxHeaderSize: 256 * 1024 }, createRouteHandler({ version, modules, info, computerID, scheduleStore, scheduler, dispatches, agentHooks, journal, tabActivity, contextUsage, modelCatalog, modelSwitcher, settingsSwitcher, permissionModeSwitcher, sideQuestions, accountUsage, resources, codexQuestions, launches, locatedDirectories, fanoutMessages, canary, streams, returns, handOffs, inbox, mover, succession, storePulls, gitboyWebhooks })); http.requestTimeout = 20_000; http.headersTimeout = 10_000; http.maxHeadersCount = 32; const ws = new WebSocketServer({ noServer: true, maxPayload: 65_536, perMessageDeflate: false }); const socketPools = new SocketBudget(); http.on("upgrade", (request, socket, head) => { try { const url = new URL(request.url || "/", "http://phren.local"); if (!["/v1/transcripts", "/v1/status", "/v1/overview", "/v1/speech/transcribe", "/v1/speech/live"].includes(url.pathname) || url.origin !== "http://phren.local") { socket.destroy(); return; } requireRoute(modules, "WS", url.pathname); // Parsed before the upgrade: an invalid server name is refused as the other routes refuse it. const overviewServer = url.pathname === "/v1/overview" ? selectedServer(url) : undefined; // Sockets are budgeted per authenticated identity (socket-budget.ts); the // x-phren-client header only picks a sub-pool inside that budget. const identity = socketIdentity(request), kind = socketKind(request); ws.handleUpgrade(request, socket, head, client => { client.once("close", () => socketPools.remove(client)); for (const oldest of socketPools.add(client, identity, kind)) { oldest.close(1008, "Too many connections; reconnect"); oldest.terminate(); ws.clients.delete(oldest); } if (overviewServer !== undefined) { overview(client, overviewServer, url.searchParams.get("watchApprovals") === "1", url.searchParams.get("resources") === "1", typedMuxRequest(url), url.searchParams.get("sudo") === "1"); return; } if (url.pathname === "/v1/speech/transcribe") { void relayTranscription(client, url.searchParams).catch(() => failRelay(client)); return; } if (url.pathname === "/v1/speech/live") { void relayLiveSpeech(client, url.searchParams).catch(() => client.close(1011, "Spoken replies unavailable")); return; } void stream(client, url).catch(() => client.close(1011, "Conversation unavailable; refresh")); }); } catch { socket.destroy(); } }); await new Promise((resolve, reject) => { http.once("error", reject); http.listen(socketPath(), () => resolve()); }); await chmod(socketPath(), 0o600); await gitboyWebhooks.start(); await agentHooks.start(); // Codex panes on the Hook's own app-servers: their requests become // approval cards, and servers a previous Hook started are rejoined. codexServers.setSink({ request: (target, request, answer) => agentHooks.codexRequest(target, request, answer), resolved: (target, requestId) => agentHooks.codexResolved(target, requestId), threadChanged: previous => agentHooks.deliveries.threadChanged(previous) }); await codexServers.adopt().catch(() => { }); void scheduler?.tick().catch(() => { }); const scheduleTimer = scheduler ? setInterval(() => { countTick("schedules"); void scheduler.tick().catch(() => { }); }, 30_000) : undefined; // Off unless PHREN_CANARY_DAILY=1 or `phren canary --daily on`. // Refresh the shared Codex sign-in before its workers each try to at once. const codexAuth = codexAppServerEnabled() && codexAuthRefreshEnabled() ? new CodexAuthKeeper(codexServers) : undefined; const codexAuthTimer = codexAuth ? setInterval(() => { void codexAuth.tick().catch(() => { }); }, 60 * 60_000) : undefined; if (codexAuth) setTimeout(() => { void codexAuth.tick().catch(() => { }); }, 60_000).unref(); // Every store converges with its remote even when no agent session runs here (store-sync.ts). const storeSync = startStoreSyncLoop(scheduleStore); const canaryTimer = setInterval(() => { countTick("canary"); void dailyCanaryDue().then(due => due ? canary("daily") : undefined).catch(() => { }); }, 10 * 60_000); let recording = false; const activityTimer = setInterval(() => { countTick("activity"); // Served OpenCode panes: follow new ones, drop those whose process is gone. agentHooks.paneServers.tick(); if (recording) return; recording = true; void (async () => { // The server list is reused for SERVER_LIST_REUSE_MS instead of pinging // every Herdr directory each tick, and a snapshot an open chat or the // overview fetched in the last four seconds is reused; otherwise each // tick still takes its own, so activity keeps its 5 s resolution. const live = await recentServers(); await tabActivity.pruneServers(live.map(server => String(server.session))); for (const server of live) { try { const name = String(server.session), current = await sharedSnapshot(name, 4000); await tabActivity.observe(name, current); await journal.record(name, objects(current.panes)); // Phone messages a pane still holds: fail loudly when it closed, // restarted or finished without them. await agentHooks.deliveries.observe(name, objects(current.panes)).catch(() => { }); // A Codex app-server whose pane closed is stopped; a dead one forgotten. await codexServers.reap(name, current).catch(() => { }); // Approvals drawn in a terminal reach a phone with phren closed. await agentHooks.observeWaitingPanes(name, objects(current.panes), async (pane) => { const state = await paneChatState(name, pane, { tokenWhenIdentified: false }); const parsed = targetSchema.safeParse({ server: name, workspace: pane.workspace_id, tab: pane.tab_id, pane: pane.pane_id, source: pane.agent, session: state.sessionId }); if (parsed.success) await sessionStalls.observe(parsed.data, pane); return parsed.success ? parsed.data : undefined; }); } catch { /* A disconnected computer keeps its previous local activity. */ } } await codexServers.sweep(live.map(server => String(server.session))).catch(() => { }); // Dispatch returns ride this tick and its shared snapshots. The returns // loop throttles its own peer polls and never holds up activity. void handOffs?.tick().catch(() => { }); void mover.reconcile().catch(() => { }); void succession?.reconcile().catch(() => { }); void returns?.tick(); void inbox?.tick().catch(() => { }); })().finally(() => { recording = false; }).catch(() => { }); }, 5000); await new Promise(resolve => { const stop = () => { gitboyWebhooks.stop(); storePulls.stop(); storeSync.stop(); fanoutMessages.close(); stopRetention(); clearInterval(scheduleTimer); clearInterval(canaryTimer); clearInterval(codexAuthTimer); clearInterval(activityTimer); scheduler?.close(); codeReindexer?.close(); codexServers.close(); agentHooks.close(); ws.clients.forEach(c => c.terminate()); ws.close(); http.close(() => resolve()); http.closeAllConnections(); }; process.once("SIGTERM", stop); process.once("SIGINT", stop); }); await unlink(socketPath()).catch(() => { }); }