@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
133 lines (132 loc) • 6.81 kB
JavaScript
import { WebSocket } from "ws";
import { logger } from "../logger.js";
import { sharedSnapshot } from "./herdr.js";
import { intervalFromEnv } from "./limits.js";
import { countTick } from "./metrics.js";
import { muxReplyForClient } from "./mux-wire.js";
import { MAX_FRAME } from "./protocol.js";
import { streamCloseReason } from "./server-stream.js";
/**
* The `/v1/overview` WebSocket: the same overview `GET /v1/workspaces`
* answers, pushed to the phone when it changes, so a phone keeps one socket
* per computer open instead of polling. Each tick reads the shared Herdr
* snapshot at most one tick old: the activity timer takes one every five
* seconds (and open chats every 2.5), so a connected phone adds no Herdr
* snapshots of its own, and a status change reaches it within one tick. The
* overview is rebuilt when that snapshot changed or `OVERVIEW_REFRESH_MS` has
* passed (branches, models and steps live outside the snapshot), and sent
* only when it differs from the last one sent. With nothing to send, a heartbeat every
* `OVERVIEW_HEARTBEAT_MS` carries the Hook's info and tells the phone the
* overview it holds is still current.
*
* Frames: `{ type: "overview", ...workspaces, phren }` and
* `{ type: "heartbeat", phren }`. A phone that asks with `resources=1` also
* gets `{ type: "resources", resources }` after the first overview, then at
* `OVERVIEW_RESOURCES_MS` intervals (an older phone never asks, so never meets it).
* One that asks with `sudo=1` gets `{ type: "sudo", requests }` after the first
* overview and whenever the pending sudo requests change.
*/
export const OVERVIEW_TICK_MS = intervalFromEnv("PHREN_OVERVIEW_TICK_MS", 5_000, 250, 60_000);
export const OVERVIEW_REFRESH_MS = intervalFromEnv("PHREN_OVERVIEW_REFRESH_MS", 10_000, 1_000, 120_000);
export const OVERVIEW_HEARTBEAT_MS = intervalFromEnv("PHREN_OVERVIEW_HEARTBEAT_MS", 20_000, 1_000, 60_000);
export const OVERVIEW_RESOURCES_MS = intervalFromEnv("PHREN_OVERVIEW_RESOURCES_MS", 12_000, 1_000, 120_000);
export function overviewStream(options) {
const snapshot = options.snapshot ?? sharedSnapshot;
const now = options.now ?? Date.now;
const tickMs = options.tickMs ?? OVERVIEW_TICK_MS;
const refreshMs = options.refreshMs ?? OVERVIEW_REFRESH_MS;
const heartbeatMs = options.heartbeatMs ?? OVERVIEW_HEARTBEAT_MS;
const resourcesMs = options.resourcesMs ?? OVERVIEW_RESOURCES_MS;
function send(client, frame) {
if (client.readyState !== WebSocket.OPEN)
return false;
const data = JSON.stringify(frame);
if (Buffer.byteLength(data) > MAX_FRAME || client.bufferedAmount > MAX_FRAME) {
client.close(1009, "Overview too large; refresh");
return false;
}
client.send(data);
return true;
}
/** Streams one server's overview until the client closes. Returns the
* tick function, for tests that drive time themselves. */
return function stream(client, server, watchApprovals, withResources = false, typedMux = false, withSudo = false) {
let closed = false, busy = false, resourcesPending = false, first = true, overviewSent = false;
// Subscribed at once, so the phone counts as able to answer while the first overview builds.
const unsubscribe = withSudo && options.sudo
? options.sudo.subscribe(requests => { if (!closed && overviewSent)
send(client, { type: "sudo", requests }); }) : undefined;
let snapshotKey = "", builtAt = 0, sentKey = "", sentAt = 0, resourcesAt = -Infinity;
const collectResources = () => {
const at = now();
if (!withResources || !options.resources || resourcesPending || at - resourcesAt < resourcesMs)
return;
resourcesPending = true;
resourcesAt = at;
void Promise.resolve().then(() => options.resources()).then(resources => {
if (!closed && resources)
send(client, { type: "resources", resources });
}).catch(error => {
logger.warn("stream", `/v1/overview resources read failed: ${streamCloseReason(error)}`);
}).finally(() => { resourcesPending = false; });
};
const tick = async () => {
if (busy || closed)
return;
busy = true;
try {
countTick("overview-stream");
const at = now();
// The first frame reads a fresh snapshot, as a poll would.
const held = await snapshot(server, first ? 0 : tickMs);
if (closed)
return;
const key = JSON.stringify(held);
if (first || key !== snapshotKey || at - builtAt >= refreshMs) {
snapshotKey = key;
builtAt = at;
const overview = muxReplyForClient(await options.read(server, held, watchApprovals), typedMux);
if (closed)
return;
const { phren: _phren, ...rows } = overview;
const rowsKey = JSON.stringify(rows);
if (first || rowsKey !== sentKey) {
if (send(client, { type: "overview", ...overview })) {
sentKey = rowsKey;
sentAt = at;
if (!overviewSent && unsubscribe)
send(client, { type: "sudo", requests: options.sudo.list() });
overviewSent = true;
}
first = false;
return;
}
}
else if (watchApprovals) {
options.renew(server);
}
first = false;
if (at - sentAt >= heartbeatMs && send(client, { type: "heartbeat", phren: options.info() }))
sentAt = at;
}
catch (error) {
const reason = streamCloseReason(error);
logger.warn("stream", `/v1/overview closed: ${reason}`);
stop();
client.close(1011, reason);
}
finally {
busy = false;
// Start this separately so ps and pane ownership cannot hold up the overview.
if (!closed)
collectResources();
}
};
const timer = setInterval(() => { void tick(); }, tickMs);
const stop = () => { closed = true; clearInterval(timer); unsubscribe?.(); };
client.once("close", stop);
client.once("error", stop);
void tick();
return { tick, stop };
};
}