Skip to content
5 changes: 5 additions & 0 deletions changelog.d/hc-machines-as-pcs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Added
- On herdr-core, every other machine the core reaches (`hc machine add`, or added in the Herdr
app) shows as a PC of its own, through this PC's core, with nothing installed there: its
workspaces as the Herdr app lays them out, its live terminals, status and alerts, and new
workspaces and tabs started there.
14 changes: 13 additions & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -915,7 +915,19 @@ macOS). See [INSTALL.md](../INSTALL.md#2-choose-the-install-method).
pi); without the extension a pi pane uses Terminal. Other agents whose chat reads only that
report, OpenCode for example, use Terminal on herdr-core.
- **Not on herdr-core:** search in a pane, scrolling or selecting the history through the
scroll buttons beside it, plugins and worktrees. Remote PCs keep running herdr's TUI server.
scroll buttons beside it, plugins and worktrees.
- **Other machines:** each machine the core reaches (`hc machine add vps`, or added in the Herdr
app) is a PC of its own in the sidebar, beside this one: its workspaces and tabs as its own
`herdr-app` document lays them out, its live terminals, input, status and alerts, and new
workspaces and tabs started there. Ovis reaches it through this PC's core: nothing of Ovis is
installed on it, and it is added, renamed or removed with `hc machine` or in the Herdr app.
Ovis starts one more server of its own for each (on loopback, with an empty home folder of its
own), so its agents' chat history is not read here: their transcripts are on that machine.
Its files are not reachable from Ovis yet: file previews, the Files view, diffs, pasted images
and the folder picker are refused for it. A folder typed for a new workspace there is passed to
that machine as written (empty: its home). Pins and "seen" are not shared with the Herdr app
there. Temporary machines (`hc machine add --temp`) are not shown, and
PCs added over SSH or through the relay keep working beside them.

## Configuration

Expand Down
1 change: 1 addition & 0 deletions scripts/hc-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const HC_METHODS = [
"doc.set",
"events.subscribe",
"hello",
"machine.list",
"server.status",
"terminal.attach",
"terminal.get",
Expand Down
5 changes: 3 additions & 2 deletions server/conventions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,9 @@ describe("herdr socket", () => {
// server/herdr-core/adapter.ts is the other end: it answers herdr's events.subscribe (the
// collector's own, reaching it through HERDR_SOCKET) from hc's event stream of the same name;
// hc's generated types, their generator and the hc connection only name that hc method as a type
// server/herdr-core/pin-label.ts reads hc's own `labels` events for pins, never a pane's status
const HC_STREAM_TYPES = ["server/herdr-core/adapter.ts", "server/herdr-core/hc-api.generated.ts", "scripts/hc-types.ts", "server/herdr-core/connection.ts", "server/herdr-core/pin-label.ts"];
// server/herdr-core/pin-label.ts reads hc's own `labels` events for pins, never a pane's status;
// server/herdr-core/machine-scope.ts only narrows hc's stream to one machine's events for that adapter
const HC_STREAM_TYPES = ["server/herdr-core/adapter.ts", "server/herdr-core/hc-api.generated.ts", "scripts/hc-types.ts", "server/herdr-core/connection.ts", "server/herdr-core/pin-label.ts", "server/herdr-core/machine-scope.ts"];
const raw = matches(files("{server,src,shared,scripts}/**/*.{ts,tsx,mjs,js}").filter((file) => !file.endsWith(".test.ts")), /["']events\.subscribe["']/).filter((match) => match.file !== "server/herdr/client.ts" && !HC_STREAM_TYPES.includes(match.file));
fail([...subscribers, ...raw].map((match) => match.at),
"server/collector.ts is the only status subscription (startStatusCollector), and `events.subscribe` is sent only by subscribeEvents in server/herdr/client.ts: take pane status from the collector's callbacks (onStatus, onPaneEnded, onBaseline) in server/index.ts instead of subscribing again.");
Expand Down
5 changes: 4 additions & 1 deletion server/core/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { ClaudeSubagentStatus } from "../claude-subagents.ts";
import { codexOpenRollout, paneCodexHome } from "../codex.ts";
import { startStatusCollector } from "../collector.ts";
import { CompletionTracker, type OmoRest } from "../completion.ts";
import { servingCore } from "../herdr-core/runtime.ts";
import { listHcMachines, startMachineBridge } from "../herdr-core/machine-bridge.ts";
import { coreConnection, servingCore } from "../herdr-core/runtime.ts";
import { Finishes, herdrLifetime } from "../finishes.ts";
import { claudePanePid, claudePaneSession, forgetPaneTranscriptState } from "../conversation.ts";
import { openCodeNativeState, openCodeVerificationCheckpoint, releaseOpenCodePane, trackVerifiedOpenCodePanes } from "../opencode-native.ts";
Expand Down Expand Up @@ -350,6 +351,8 @@ export function createStatusHub<Owner, Lease>({ options, devices, preferences, c
({ type: "pane-status", pane_id: paneId, agent_status: status, ...finishes.status(paneId, status), ...about, ...(waits.waiting(paneId) ? { background_wait: true as const } : {}) });
const machines = options.machines === false ? null : new MachineManager(options.stateDir ?? defaultStateDir(), push, completions, clientSnapshot, { bootId: options.orderSequence?.epoch, localFinishEpoch: () => finishes.epoch, relaySocket: options.relayPcSocket, serverId: options.serverId });
if (machines && options.herdrProfiles) machines.watchHerdrProfiles(options.herdrProfiles);
// on herdr-core, the core's other machines are PCs too, each reached through this PC's core
if (machines && servingCore()) machines.watchHcMachines(() => listHcMachines(coreConnection()), (machine) => startMachineBridge(machine, options.stateDir ?? defaultStateDir()));

const desktopActivity = options.desktopActivity === true ? trackDesktopActivity({
path: join(options.stateDir ?? defaultStateDir(), DESKTOP_ACTIVITY_FILE),
Expand Down
11 changes: 10 additions & 1 deletion server/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ export interface ServerContext {
readonly relayBodyBudget?: EventStreamBudget;
}

/** The routes that read or write files and folders on this disk (or run git and gh in them) for a pane, a workspace or a picker. */
const OTHER_MACHINE_FILES = /^\/api\/(?:fs\/|pane\/(?:files|image)$|workspace\/(?:diff\/|directories$|pr(?:$|\/)))/;

export async function handleRequest(request: Request, ctx: ServerContext): Promise<Response | undefined> {
const { options, hostname, token, bridgeToken, bootId, devices, preferences, herdrSidebar, identityOf, serveOnly, portalGuards, shellAgents, agentDetector, agentOffered, agentInstaller, openCodeState, cliProxyRoutes, usageLogin, usage, voice, browserSnapshot, core, seen, piReader, orderSequence, clientConnectionLog, requestLog, portalStartupResult, portalStartup } = ctx.services;
const { sidecar, serialize, push, alertWebhook, logPushError, omo, claudeAgents, opencodeAgents, plans, clientSnapshot, machines, broadcastAll, collector, queueFirstMessage } = core;
Expand Down Expand Up @@ -290,6 +293,12 @@ export async function handleRequest(request: Request, ctx: ServerContext): Promi
// so an unauthenticated client cannot hold idle sockets for a minute.
if (pathname.startsWith("/api/") && authenticated) ctx.timeout(request, 60);

// the panes' files are on another machine: this disk's would be shown, listed, diffed or written as theirs
if (options.localFiles === false && OTHER_MACHINE_FILES.test(pathname)) {
setRequestRoute(pathname);
return jsonResponse({ error: { code: "unsupported", message: "Files on another machine of herdr-core are not reachable from Ovis yet" } }, 501);
}

if (pathname === "/api/bridge") {
setRequestRoute(pathname);
if (token === "" && !bridgeAuthorized) return unauthorizedJson();
Expand Down Expand Up @@ -580,7 +589,7 @@ export async function handleRequest(request: Request, ctx: ServerContext): Promi
const receipt = orderSequence.moved();
void machines?.refreshLocal();
return receipt;
}, shellAgents);
}, shellAgents, options.localFiles !== false);
if (workspaceResponse) return workspaceResponse;

const pluginResponse = await handlePluginRequest(request, url, pathname, options);
Expand Down
85 changes: 85 additions & 0 deletions server/hc-machines.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it, spyOn } from "bun:test";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createServer } from "./index.ts";
import { MachineManager } from "./machines.ts";
import type { CompletionTracker } from "./completion.ts";
import type { HcMachine, MachineBridge } from "./herdr-core/machine-bridge.ts";
import type { PushService } from "./push.ts";

const cleanups: (() => void)[] = [];
afterEach(() => { for (const cleanup of cleanups.splice(0).reverse()) cleanup(); });

const settled = () => new Promise((resolve) => setImmediate(resolve));

/** A manager reading herdr-core's machines, whose servers are stand-ins: each start and stop is recorded. */
async function fixture() {
const dir = mkdtempSync(join(tmpdir(), "ovis-hc-machines-"));
const manager = new MachineManager(dir, {} as PushService, {} as CompletionTracker, async () => { throw new Error("isolated local offline"); });
// what a connection reads through the PC's server is not this test's business: it connects
const observe = spyOn(manager as unknown as { observe(runtime: { machine: { state: string } }): Promise<void> }, "observe").mockImplementation(async (runtime) => { runtime.machine.state = "connected"; });
const events: string[] = [];
const start = async (machine: Pick<HcMachine, "id" | "name">): Promise<MachineBridge> => {
events.push(`start ${machine.name}`);
return { endpoint: { url: "http://127.0.0.1:1", token: "t" }, exited: new Promise(() => {}), stop: () => events.push(`stop ${machine.name}`) };
};
// the watch's own first read (none yet) is in before the test tells the core's list
manager.watchHcMachines(async () => [], start, 3_600_000);
await settled();
cleanups.push(() => { manager.stop(); observe.mockRestore(); rmSync(dir, { recursive: true, force: true }); });
return { manager, events, dir };
}

const lab: HcMachine = { id: "a32c7763ba582b75", name: "lab", connected: true, why: null };

describe("herdr-core's machines as PCs (#2497)", () => {
it("shows each machine of the core as a PC of its own, which starts its server and is never saved here", async () => {
const { manager, events, dir } = await fixture();
manager.syncHcMachines([lab]);
await settled();
expect(manager.list()[1]).toMatchObject({ id: "hc-a32c7763ba582b75", name: "lab", kind: "hc", state: "connected" });
expect(events).toEqual(["start lab"]);
expect(() => manager.patch("hc-a32c7763ba582b75", { name: "x" })).toThrow("hc machine");
expect(() => manager.remove("hc-a32c7763ba582b75")).toThrow("hc machine");
(manager as unknown as { persist(): void }).persist();
const saved = join(dir, "machines.json");
expect(existsSync(saved) ? JSON.parse(readFileSync(saved, "utf8")) : []).toEqual([]);
});

it("follows the machine: away, back, renamed (its terminals' addresses change) and removed", async () => {
const { manager, events } = await fixture();
manager.syncHcMachines([lab]);
await settled();
manager.syncHcMachines([{ ...lab, connected: false, why: "unreachable" }]);
expect(manager.list()[1]).toMatchObject({ state: "disconnected", error: "herdr-core cannot reach this machine (unreachable)" });
manager.syncHcMachines([lab]);
await settled();
manager.syncHcMachines([{ ...lab, name: "lab2" }]);
await settled();
expect(manager.list()[1]).toMatchObject({ id: "hc-a32c7763ba582b75", name: "lab2", state: "connected" });
manager.syncHcMachines([]);
expect(manager.list()).toHaveLength(1);
expect(events).toEqual(["start lab", "stop lab", "start lab", "stop lab", "start lab2", "stop lab2"]);
});
});

describe("a machine's own server (#2497)", () => {
it("shows, lists, diffs and makes no file of this disk as that machine's", async () => {
const dir = mkdtempSync(join(tmpdir(), "ovis-hc-machine-files-"));
const file = join(dir, "only-on-this-pc.txt");
writeFileSync(file, "this PC");
const token = "t".repeat(64);
const served = (localFiles: boolean) => createServer({ port: 0, hostname: "127.0.0.1", token, machines: false, bridgeOnly: true, localFiles, stateDir: join(dir, `state-${localFiles}`) });
const machine = served(false);
const own = served(true);
cleanups.push(() => { machine.stop(); own.stop(); rmSync(dir, { recursive: true, force: true }); });
const get = (server: ReturnType<typeof served>, path: string) => fetch(`http://127.0.0.1:${server.port}${path}`, { headers: { authorization: `Bearer ${token}` } });
const ask = `/api/fs/file?path=${encodeURIComponent(file)}`;
expect((await get(own, ask)).status).toBe(200);
for (const path of [ask, `/api/fs/stat?path=${encodeURIComponent(file)}`, `/api/workspace/directories?path=${encodeURIComponent(dir)}`, "/api/workspace/diff/files?pane_id=t1", "/api/workspace/pr?pane_id=t1", "/api/workspace/pr/log?pane_id=t1", "/api/pane/files?pane_id=t1"]) {
const answer = await get(machine, path);
expect({ path, status: answer.status, code: ((await answer.json()) as { error?: { code?: string } }).error?.code }).toEqual({ path, status: 501, code: "unsupported" });
}
});
});
Loading
Loading