Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The app is only a bridge: herdr owns every pty, scrollback and agent state.
- NEVER give xterm scrollback (keep `scrollback: 0`), and never rebuild an attached terminal from `pane.read`. The attach byte stream is the source of truth; only a mirrored pane (below) is drawn from `pane.read`.
- NEVER edit `shared/herdr-api.generated.ts`. Run `bun run generate:types`.
- NEVER pool herdr RPC connections: herdr closes the socket after each response. Use one connection per call (10 s timeout). Only `events.subscribe` stays open, and a second subscribe on an open connection is silently ignored, so reopen it with the full set. `server/conventions.test.ts` keeps herdr's socket in `server/herdr/client.ts`.
- `pane.process_info` takes `pane_id`, not `target`. Given `target`, it silently answers for the focused pane.
- `pane.process_info` takes `pane_id`, not `target` (given `target`, herdr answers for the focused pane): call `paneProcessInfo`. Server code reaches herdr through the typed wrappers in `server/herdr/client.ts`, never `herdrRpc` (`HerdrMethods` in `server/herdr/methods.ts`; `server/conventions.test.ts` enforces it, not the compiler: `herdrRpc<T>(…)` with an explicit result type still takes any params; the files in `HERDR_RPC_ALLOWED` and `HERDR_RPC_PENDING` still call it while their open PRs drain).
- `server/collector.ts` is the only status subscription source. Do not subscribe to status anywhere else (`server/conventions.test.ts` enforces it).
- Nothing the user typed is ever queued or sent automatically while offline. Keystrokes go to a draft the user sends or discards. An explicit chat Send while an agent works can enter the bridge's pending list on `pending-input` bridges: the next turn and its explicit Send now action claim the same server ID. Automatic delivery requires the original live connection and pane lease; disconnect/reload never resumes it. Existing browser-held messages still require "Send now".
- Prompt answers are `send_keys` navigation, never digits, and go through `POST /api/pane/prompt/answer`: the key semantics per agent live on the server.
Expand Down
5 changes: 5 additions & 0 deletions changelog.d/herdr-rpc-seam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Maintenance
- herdr RPCs are typed per method (`HerdrMethods`): server code calls `paneProcessInfo`,
`agentGet` and the other wrappers in `server/herdr/client.ts`, and `server/conventions.test.ts`
keeps it off the untyped `herdrRpc`, so asking `pane.process_info` by `target` no longer compiles
without an explicit result type.
4 changes: 3 additions & 1 deletion scripts/generate-protocol-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ const OUTPUT_PATH = join(ROOT, "shared", "herdr-api.generated.ts");

/** Roots per schema section; their transitive closure is what gets emitted. */
const ROOTS: Record<string, string[]> = {
success_response: ["SessionSnapshot", "PaneReadResult", "AgentManifestInfo", "PaneMoveResult"],
success_response: ["SessionSnapshot", "PaneReadResult", "AgentManifestInfo", "PaneMoveResult", "PaneProcessInfo"],
request: [
"AgentStartParams",
"AgentTarget",
"PaneMoveParams",
"PaneProcessInfoParams",
"PaneReadParams",
"PaneSendTextParams",
"PaneSendKeysParams",
Expand Down
6 changes: 3 additions & 3 deletions server/agy-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { readFileSync, opendirSync, readlinkSync, realpathSync, statSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import type { HerdrPane } from "../shared/protocol.ts";
import { herdrRpc } from "./herdr/client.ts";
import { paneProcessInfo } from "./herdr/client.ts";

export interface AgyStore { root: string; session: string; path: string; pid: number; started: string }
const LOCK = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.lock$/i;
Expand Down Expand Up @@ -71,8 +71,8 @@ async function heldPaths(pid: number): Promise<string[]> {
export async function resolveAgyStore(pane: HerdrPane): Promise<AgyStore | null> {
if (pane.agent !== "agy") return null;
try {
const info = await herdrRpc<{ process_info?: { foreground_processes?: { pid: number; name?: string; argv?: string[] }[] } }>("pane.process_info", { pane_id: pane.pane_id });
const owners = (info.process_info?.foreground_processes ?? []).filter((p) => p.name === "agy" && Number.isSafeInteger(p.pid) && p.pid > 1);
const info = await paneProcessInfo(pane.pane_id);
const owners = (info?.foreground_processes ?? []).filter((p) => p.name === "agy" && Number.isSafeInteger(p.pid) && p.pid > 1);
if (owners.length !== 1) return null;
const pid = owners[0]!.pid;
const before = await processIdentity(pid);
Expand Down
16 changes: 7 additions & 9 deletions server/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { processStartedAt } from "./process-start.ts";
import { codexImageParts } from "./codex-images.ts";
import { codexReadCall, codexReadSkills, selectedSkill } from "./skill-activity.ts";
import { trimOutput } from "./tool-output.ts";
import { herdrRpc, paneRead, sessionSnapshot } from "./herdr/client.ts";
import { agentGet, paneProcessInfo, paneRead, sessionSnapshot } from "./herdr/client.ts";

type RecordValue = Record<string, unknown>;
const record = (value: unknown): RecordValue => value !== null && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {};
Expand Down Expand Up @@ -1089,10 +1089,10 @@ const VALUELESS_OPTIONS = new Set([

/** A pane's Codex executables (or interpreter scripts), and their pids as one key. */
async function codexProcessesOf(paneId: string): Promise<{ list: { pid: number; argv?: string[] }[]; key: string }> {
const processInfo = await herdrRpc<{ process_info?: { foreground_processes?: { pid: number; argv?: string[] }[] } }>(
"pane.process_info", { pane_id: paneId },
);
const list = (processInfo.process_info?.foreground_processes ?? [])
const processInfo = await paneProcessInfo(paneId);
// herdr's schema allows `argv: null`; this reader has always read argv as absent or a list
// (the cast is the inline result type herdrRpc was given before the typed seam)
const list = ((processInfo?.foreground_processes ?? []) as { pid: number; argv?: string[] }[])
.map((foreground) => {
if (foreground.argv?.length || process.platform !== "linux") return foreground;
// herdr can report only pid/name. Read this process, never infer argv from
Expand Down Expand Up @@ -1326,8 +1326,7 @@ async function openRollouts(paneId: string, home: string): Promise<{ codexProces
* matches the pane again.
*/
export async function codexOpenRollout(paneId: string, cwd: string | null | undefined, home = defaultCodexHome()): Promise<string | null> {
const info = await herdrRpc<{ agent: { agent_session?: { kind?: string; value?: string } } }>("agent.get", { target: paneId });
const session = info.agent.agent_session;
const session = (await agentGet(paneId)).agent_session;
if (session?.kind === "path" && session.value) return codexRolloutPath(session.value, home);
const { processes, open } = await openRollouts(paneId, home);
if (open.size === 1) return [...open][0]!;
Expand Down Expand Up @@ -1360,8 +1359,7 @@ export async function codexTranscriptPath(paneId: string, cwd: string, home = de
const open = new Set(panes.map((pane) => pane.pane_id));
for (const pane of [...boundRollouts.keys()]) if (!open.has(pane)) boundRollouts.delete(pane);
}
const info = await herdrRpc<{ agent: { agent_session?: { kind?: string; value?: string } } }>("agent.get", { target: paneId });
const session = info.agent.agent_session;
const session = (await agentGet(paneId)).agent_session;
if (session?.kind === "path" && session.value) return codexRolloutPath(session.value, home);

const { codexProcesses, processes, open } = await openRollouts(paneId, home);
Expand Down
34 changes: 32 additions & 2 deletions server/conventions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ describe("herdr socket", () => {
const uses = serverCode().flatMap((file) => unixConnects(file, stripComments(read(file))));
expect(uses.map((use) => use.file)).toContain("server/herdr/client.ts");
fail(uses.filter((use) => !(use.file in (use.bun ? UNIX_CONNECT_ALLOWED : NET_CONNECT_ALLOWED))).map((use) => use.at),
"Call herdr through the helpers in server/herdr/client.ts (herdrRpc and the named wrappers): one connection per call, never a pooled or second client. A unix socket that is not herdr's gets an entry with its reason in UNIX_CONNECT_ALLOWED (Bun.connect) or NET_CONNECT_ALLOWED (node:net) in server/conventions.test.ts. A TCP connection (`{ host, port }`) is not flagged.");
"Call herdr through the typed wrappers in server/herdr/client.ts: one connection per call, never a pooled or second client. A unix socket that is not herdr's gets an entry with its reason in UNIX_CONNECT_ALLOWED (Bun.connect) or NET_CONNECT_ALLOWED (node:net) in server/conventions.test.ts. A TCP connection (`{ host, port }`) is not flagged.");
});

it("tells a unix socket from a TCP one", () => {
Expand All @@ -182,10 +182,40 @@ describe("herdr socket", () => {
expect(found("import { connect as tcpConnect, createServer } from \"node:net\";\ntcpConnect({ host, port });\ntcpConnect(port, host);")).toBe(0);
});

// Server code calls herdr through the typed wrappers in server/herdr/client.ts (paneProcessInfo,
// agentGet, paneSendText, paneSendKeys, …), never through herdrRpc itself: the wrapper is where a
// method's params are typed (server/herdr/methods.ts), so `{ target }` to pane.process_info does
// not compile there. herdrRpc's untyped fallback still takes any params given an explicit result
// type, so this guard is what keeps server code off it. These files still call herdrRpc because
// open PRs change them; a follow-up PR moves them to the wrappers once those PRs land.
const HERDR_RPC_ALLOWED: Record<string, string> = {
"server/conversation.ts": "open PRs #869 #912 #923 change it",
"server/gjc-runtime.ts": "open PR #912 changes it",
"server/hermes.ts": "open PR #910 changes it",
"server/omo.ts": "open PR #923 changes it",
"server/shell-agent.ts": "open PRs #885 #923 change it",
};
// Files that do not call herdrRpc on main yet but whose open PR adds a `pane.process_info` call.
// They are exempt from the "no longer calls" check below. Remove an entry when its PR switches to
// paneProcessInfo(paneId) (the better outcome), merges, or closes.
const HERDR_RPC_PENDING: Record<string, string> = {
"server/gjc-status.ts": "open PR #928 adds a call",
"server/index.ts": "open PR #859 adds a call",
};

it("calls herdrRpc only inside server/herdr/", () => {
const uses = matches(serverCode().filter((file) => !file.startsWith("server/herdr/")), /\bherdrRpc\b/);
fail(uses.filter((use) => !(use.file in HERDR_RPC_ALLOWED) && !(use.file in HERDR_RPC_PENDING)).map((use) => use.at),
"Do not call herdrRpc outside server/herdr/: add a typed wrapper in server/herdr/client.ts (its method in HerdrMethods, server/herdr/methods.ts) and call that; for pane status use the server/collector.ts listeners. Files waiting on open PRs are listed in HERDR_RPC_ALLOWED in server/conventions.test.ts; do not add new ones.");
const users = new Set(uses.map((use) => use.file));
fail(Object.keys(HERDR_RPC_ALLOWED).filter((file) => !users.has(file)),
"These files no longer call herdrRpc: remove them from HERDR_RPC_ALLOWED in server/conventions.test.ts.");
});

it("subscribes to status only in server/collector.ts", () => {
const subscribers = matches(serverCode(), /\bsubscribeEvents\b/).filter((match) => match.file !== "server/herdr/client.ts" && match.file !== "server/collector.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");
fail([...subscribers, ...raw].map((match) => match.at),
"server/collector.ts is the only status subscription (startStatusCollector): take pane status from its callbacks (onStatus, onPaneEnded, onBaseline) in server/index.ts instead of subscribing again.");
"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.");
});
});
2 changes: 1 addition & 1 deletion server/conversation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ let mockedProcesses: { argv?: unknown }[] = [];
describe("Devin pane identity", () => {
const restores: Array<() => void> = [];
beforeEach(() => {
const rpc = spyOn(herdr, "herdrRpc").mockImplementation(async (method) => {
const rpc = spyOn(herdr, "herdrRpc").mockImplementation(async (method: string) => {
if (method !== "pane.process_info") throw new Error(`unexpected conversation fixture RPC: ${method}`);
return { process_info: { foreground_processes: mockedProcesses } } as never;
});
Expand Down
7 changes: 3 additions & 4 deletions server/grok-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
import { opendirSync, readFileSync, readlinkSync, realpathSync, statSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
import type { HerdrPane } from "../shared/protocol.ts";
import { herdrRpc } from "./herdr/client.ts";
import { paneProcessInfo } from "./herdr/client.ts";
import { runsAgent } from "./gjc-runtime.ts";

const SESSION = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
const MAX_GROUPS = 4096;
/** The one native build whose held-file evidence was observed. Others have no reader yet. */
const GROK_NATIVE_BUILD = "grok-1.0.50-linux-x86_64";

export interface GrokProcessInfo { foreground_processes?: { pid: number; argv?: string[] }[] }
export interface GrokProcessInfo { foreground_processes?: { pid: number; argv?: string[] | null }[] }
const GROK_EXECUTABLE = /(^|[\\/])grok(?:-\d[^\\/]*)?(?:\.exe|\.[cm]?js)?$/i;
export function grokPaneProcesses(info: GrokProcessInfo): { pid: number }[] {
return (info.foreground_processes ?? []).filter((p) => runsAgent(p.argv ?? [], GROK_EXECUTABLE));
Expand Down Expand Up @@ -91,8 +91,7 @@ export async function resolveGrokStore(pane: HerdrPane, configuredHome?: string)
// Every other build and PC gets the terminal view; argv alone does not attest the version.
if (process.platform !== "linux") return null;
try {
const info = await herdrRpc<{ process_info?: GrokProcessInfo }>("pane.process_info", { pane_id: pane.pane_id });
const processes = grokPaneProcesses(info.process_info ?? {});
const processes = grokPaneProcesses((await paneProcessInfo(pane.pane_id)) ?? {});
if (processes.length !== 1) return null;
const owner = processes[0]!;
if (basename(readlinkSync(`/proc/${owner.pid}/exe`)) !== GROK_NATIVE_BUILD) return null;
Expand Down
52 changes: 46 additions & 6 deletions server/herdr/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import type {
PaneFindResponse,
PaneFindMatch,
} from "../../shared/protocol.ts";
import type { AgentInfo, AgentManifestInfo, AgentStartParams, PaneInfo, PaneLayoutSnapshot, PaneMoveParams, PaneMoveResult, PaneScrollInfo, TabInfo, WorkspaceInfo } from "../../shared/herdr-api.generated.ts";
import type { AgentInfo, AgentManifestInfo, AgentStartParams, PaneInfo, PaneLayoutSnapshot, PaneMoveParams, PaneMoveResult, PaneProcessInfo, PaneScrollInfo, TabInfo, WorkspaceInfo } from "../../shared/herdr-api.generated.ts";
import type { HerdrMethod, HerdrMethods, SingleHerdrMethod, UntypedHerdrMethod } from "./methods.ts";
import type { HerdrIdentity } from "../../shared/machines.ts";

const DEFAULT_TIMEOUT_MS = 10_000;
Expand Down Expand Up @@ -81,15 +82,38 @@ function makeLineReader(onLine: (line: string) => void): (chunk: Uint8Array) =>
* connect is awaited, and a caller's right to send can lapse meanwhile. A guard that
* answers false sends nothing and rejects with `cancelled`.
*/
export async function herdrRpc<T = unknown>(
method: string,
export function herdrRpc<M extends HerdrMethod>(
method: SingleHerdrMethod<M>,
params: HerdrMethods[M]["params"],
socketPath?: string,
timeoutMs?: number,
guard?: () => boolean,
): Promise<HerdrMethods[M]["result"]>;
/**
* Untyped fallback for a method `HerdrMethods` does not list yet. Outside server/herdr/ only
* tests, scripts and the files in HERDR_RPC_ALLOWED (server/conventions.test.ts) may call it;
* server code adds a typed wrapper here instead. It rejects a typed method only when `M` is
* inferred from a literal: with an explicit result type (`herdrRpc<T>("pane.process_info", …)`)
* or a method typed as plain `string`, `M` is `string` and any params compile. Closing that
* needs this fallback under its own name, left to the follow-up PR that moves the
* HERDR_RPC_ALLOWED files to the wrappers once their open PRs land.
*/
export function herdrRpc<T = unknown, M extends string = string>(
method: UntypedHerdrMethod<M>,
params: Record<string, unknown>,
socketPath?: string,
timeoutMs?: number,
guard?: () => boolean,
): Promise<T>;
export async function herdrRpc(
method: string,
params: object,
socketPath: string = herdrSocketPath(),
timeoutMs: number = DEFAULT_TIMEOUT_MS,
guard?: () => boolean,
): Promise<T> {
): Promise<unknown> {
const id = nextId();
return await new Promise<T>((resolve, reject) => {
return await new Promise<unknown>((resolve, reject) => {
let settled = false;
let socket: { end: () => void } | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
Expand Down Expand Up @@ -124,7 +148,7 @@ export async function herdrRpc<T = unknown>(
finish(() => reject(new HerdrError(code, message)));
return;
}
finish(() => resolve(frame.result as T));
finish(() => resolve(frame.result));
};

const onData = makeLineReader(handleLine);
Expand Down Expand Up @@ -542,6 +566,22 @@ export async function paneSelectionRead(paneId: string, anchor: PaneTextPoint, c
return result.text;
}

/** What herdr knows of the processes in a pane's terminal (`pane.process_info`). */
export type ProcessInfo = PaneProcessInfo;

/**
* The pane's shell and foreground processes, or undefined when herdr names none. Always by
* `pane_id`: given `target`, herdr silently answers for the focused pane. Rejects as herdrRpc does.
*/
export async function paneProcessInfo(paneId: string, socketPath?: string): Promise<ProcessInfo | undefined> {
return (await herdrRpc("pane.process_info", { pane_id: paneId }, socketPath)).process_info;
}

/** The agent herdr tracks in a pane (`agent.get`), including the session it reported. */
export async function agentGet(paneId: string, socketPath?: string): Promise<AgentInfo> {
return (await herdrRpc("agent.get", { target: paneId }, socketPath)).agent;
}

export async function paneSendText(paneId: string, text: string, socketPath?: string, guard?: () => boolean): Promise<void> {
await herdrRpc("pane.send_text", { pane_id: paneId, text }, socketPath, undefined, guard);
}
Expand Down
Loading
Loading