diff --git a/AGENTS.md b/AGENTS.md index 899695ceb1..f60a8312b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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(…)` 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. diff --git a/changelog.d/herdr-rpc-seam.md b/changelog.d/herdr-rpc-seam.md new file mode 100644 index 0000000000..41e60d6163 --- /dev/null +++ b/changelog.d/herdr-rpc-seam.md @@ -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. diff --git a/scripts/generate-protocol-types.ts b/scripts/generate-protocol-types.ts index 21cdac7c37..abf2d00768 100644 --- a/scripts/generate-protocol-types.ts +++ b/scripts/generate-protocol-types.ts @@ -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 = { - success_response: ["SessionSnapshot", "PaneReadResult", "AgentManifestInfo", "PaneMoveResult"], + success_response: ["SessionSnapshot", "PaneReadResult", "AgentManifestInfo", "PaneMoveResult", "PaneProcessInfo"], request: [ "AgentStartParams", + "AgentTarget", "PaneMoveParams", + "PaneProcessInfoParams", "PaneReadParams", "PaneSendTextParams", "PaneSendKeysParams", diff --git a/server/agy-store.ts b/server/agy-store.ts index 205689f005..b31a347a33 100644 --- a/server/agy-store.ts +++ b/server/agy-store.ts @@ -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; @@ -71,8 +71,8 @@ async function heldPaths(pid: number): Promise { export async function resolveAgyStore(pane: HerdrPane): Promise { 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); diff --git a/server/codex.ts b/server/codex.ts index 84c8dd7ec7..b6553e4e14 100644 --- a/server/codex.ts +++ b/server/codex.ts @@ -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; const record = (value: unknown): RecordValue => value !== null && typeof value === "object" && !Array.isArray(value) ? value as RecordValue : {}; @@ -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 @@ -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 { - 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]!; @@ -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); diff --git a/server/conventions.test.ts b/server/conventions.test.ts index 716e3f6fe2..4a2a2be857 100644 --- a/server/conventions.test.ts +++ b/server/conventions.test.ts @@ -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", () => { @@ -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 = { + "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 = { + "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."); }); }); diff --git a/server/conversation.test.ts b/server/conversation.test.ts index 9354bfc716..41391ecd7a 100644 --- a/server/conversation.test.ts +++ b/server/conversation.test.ts @@ -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; }); diff --git a/server/grok-store.ts b/server/grok-store.ts index e6442eef46..c083b1fdbd 100644 --- a/server/grok-store.ts +++ b/server/grok-store.ts @@ -2,7 +2,7 @@ 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}$/; @@ -10,7 +10,7 @@ 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)); @@ -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; diff --git a/server/herdr/client.ts b/server/herdr/client.ts index 3afa7cf4ba..9e665ee687 100644 --- a/server/herdr/client.ts +++ b/server/herdr/client.ts @@ -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; @@ -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( - method: string, +export function herdrRpc( + method: SingleHerdrMethod, + params: HerdrMethods[M]["params"], + socketPath?: string, + timeoutMs?: number, + guard?: () => boolean, +): Promise; +/** + * 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("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( + method: UntypedHerdrMethod, params: Record, + socketPath?: string, + timeoutMs?: number, + guard?: () => boolean, +): Promise; +export async function herdrRpc( + method: string, + params: object, socketPath: string = herdrSocketPath(), timeoutMs: number = DEFAULT_TIMEOUT_MS, guard?: () => boolean, -): Promise { +): Promise { const id = nextId(); - return await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { let settled = false; let socket: { end: () => void } | null = null; let timer: ReturnType | null = null; @@ -124,7 +148,7 @@ export async function herdrRpc( finish(() => reject(new HerdrError(code, message))); return; } - finish(() => resolve(frame.result as T)); + finish(() => resolve(frame.result)); }; const onData = makeLineReader(handleLine); @@ -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 { + 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 { + return (await herdrRpc("agent.get", { target: paneId }, socketPath)).agent; +} + export async function paneSendText(paneId: string, text: string, socketPath?: string, guard?: () => boolean): Promise { await herdrRpc("pane.send_text", { pane_id: paneId, text }, socketPath, undefined, guard); } diff --git a/server/herdr/methods.ts b/server/herdr/methods.ts new file mode 100644 index 0000000000..b201cee476 --- /dev/null +++ b/server/herdr/methods.ts @@ -0,0 +1,38 @@ +import type { AgentInfo, AgentTarget, PaneProcessInfo, PaneProcessInfoParams, PaneSendKeysParams, PaneSendTextParams } from "../../shared/herdr-api.generated.ts"; + +/** + * The herdr methods `herdrRpc` knows the shape of: each method's params and its raw answer + * (the envelope; the wrappers in client.ts unwrap it). Add a method here, with a wrapper in + * client.ts, before calling it from outside server/herdr/. + * + * Hand-written on purpose: herdr's schema maps a method to its params (`request.oneOf`), but its + * answers are one `ResponseResult.oneOf` told apart by a `type` tag (`pane_process_info`, + * `agent_info`, `ok`) with no link back to the method, so a generated method -> result table + * would be a hand-kept table anyway. The params and result bodies come from the generated types + * (scripts/generate-protocol-types.ts ROOTS). + */ +export interface HerdrMethods { + /** `pane_id`, never `target`: given `target`, herdr silently answers for the focused pane. */ + "pane.process_info": { params: PaneProcessInfoParams & { pane_id: string }; result: { type?: "pane_process_info"; process_info?: PaneProcessInfo } }; + "agent.get": { params: AgentTarget; result: { type?: "agent_info"; agent: AgentInfo } }; + "pane.send_text": { params: PaneSendTextParams; result: { type?: "ok" } }; + "pane.send_keys": { params: PaneSendKeysParams; result: { type?: "ok" } }; +} + +export type HerdrMethod = keyof HerdrMethods; + +/** + * One typed method, never a union of them: given `"pane.process_info" | "agent.get"`, the params + * would be the union of both methods' params, and `{ target }` would pass for pane.process_info. + */ +export type SingleHerdrMethod = IsUnion extends true ? never : M; +type IsUnion = T extends unknown ? ([U] extends [T] ? false : true) : never; + +/** + * A method `HerdrMethods` does not type yet. A typed method given here is `never`, so a call + * like `herdrRpc("pane.process_info", { target })` fails to compile instead of falling back. + * That holds only while `M` is inferred from the literal: with an explicit result type + * (`herdrRpc("pane.process_info", …)`) or a method typed as plain `string`, `M` is `string` + * and the fallback takes any params (see the herdrRpc overloads in client.ts). + */ +export type UntypedHerdrMethod = M extends HerdrMethod ? never : M; diff --git a/server/herdr/seam.test.ts b/server/herdr/seam.test.ts new file mode 100644 index 0000000000..374081be37 --- /dev/null +++ b/server/herdr/seam.test.ts @@ -0,0 +1,75 @@ +import { afterEach, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { agentGet, herdrRpc, paneProcessInfo } from "./client.ts"; + +// The typed herdr seam (server/herdr/methods.ts). `bun run typecheck` checks the lines marked +// with ts-expect-error below: each must stay a compile error. They hold for a call without an explicit +// result type; `herdrRpc(…)` still reaches the untyped fallback, which the files in +// HERDR_RPC_ALLOWED (server/conventions.test.ts) use until a follow-up PR moves them to the +// wrappers once their open PRs land, so outside server/herdr/ that guard, not the compiler, +// keeps server code off herdrRpc. +declare const either: boolean; +async function typeOnly(): Promise { + // @ts-expect-error pane.process_info takes pane_id: given target, herdr answers for the focused pane + await herdrRpc("pane.process_info", { target: "pane-1" }); + // @ts-expect-error agent.get takes target + await herdrRpc("agent.get", { pane_id: "pane-1" }); + // @ts-expect-error pane.send_text needs its text + await herdrRpc("pane.send_text", { pane_id: "pane-1" }); + // @ts-expect-error a union of typed methods would take either method's params + await herdrRpc(either ? ("pane.process_info" as const) : ("agent.get" as const), { target: "pane-1" }); + // Known holes: these still compile, because an explicit result type or a plain-string method + // leaves the fallback's `M` at `string`. Splitting the fallback out under its own name (the + // follow-up above) turns both into ts-expect-error lines. + await herdrRpc("pane.process_info", { target: "pane-1" }); + const widened: string = "pane.process_info"; + await herdrRpc(widened, { target: "pane-1" }); + const info = await herdrRpc("pane.process_info", { pane_id: "pane-1" }); + const shell: number | null | undefined = info.process_info?.shell_pid; + const agent = await agentGet("pane-1"); + const kind: string | undefined = agent.agent_session?.kind; + void shell; void kind; +} +void typeOnly; + +// A stand-in herdr on a Unix socket: it records each request and answers with `result`. +let dir = ""; +let listener: ReturnType | null = null; +afterEach(() => { + listener?.stop(true); + listener = null; + if (dir) rmSync(dir, { recursive: true, force: true }); +}); + +function standIn(result: unknown): { path: string; written: { method: string; params: unknown }[] } { + dir = mkdtempSync(join(tmpdir(), "herdr-seam-")); + const path = join(dir, "herdr.sock"); + const written: { method: string; params: unknown }[] = []; + listener = Bun.listen({ + unix: path, + socket: { + data(sock, chunk) { + const request = JSON.parse(chunk.toString()) as { id: string; method: string; params: unknown }; + written.push({ method: request.method, params: request.params }); + sock.write(`${JSON.stringify({ id: request.id, result })}\n`); + }, + }, + }); + return { path, written }; +} + +it("asks pane.process_info by pane_id and answers its process_info", async () => { + const process_info = { pane_id: "pane-1", shell_pid: 41, foreground_processes: [{ pid: 42, name: "codex", argv: ["codex"] }] }; + const { path, written } = standIn({ type: "pane_process_info", process_info }); + expect(await paneProcessInfo("pane-1", path)).toEqual(process_info); + expect(written).toEqual([{ method: "pane.process_info", params: { pane_id: "pane-1" } }]); +}); + +it("asks agent.get by target and answers its agent", async () => { + const agent = { pane_id: "pane-1", agent_session: { agent: "pi", kind: "path", source: "herdr:pi", value: "/s.jsonl" } }; + const { path, written } = standIn({ type: "agent_info", agent }); + expect(await agentGet("pane-1", path)).toEqual(agent as never); + expect(written).toEqual([{ method: "agent.get", params: { target: "pane-1" } }]); +}); diff --git a/server/omp.test.ts b/server/omp.test.ts index e18a9b8383..4471923691 100644 --- a/server/omp.test.ts +++ b/server/omp.test.ts @@ -82,7 +82,7 @@ describe("an omp pane's chat", () => { mkdirSync(cwd); pane = { pane_id: "pane-omp", cwd, foreground_cwd: cwd, agent: "omp" } as HerdrPane; const snapshot = spyOn(herdr, "sessionSnapshot").mockImplementation(async () => ({ panes: [pane] } as SessionSnapshot)); - const rpc = spyOn(herdr, "herdrRpc").mockImplementation(async (method) => { + const rpc = spyOn(herdr, "herdrRpc").mockImplementation(async (method: string) => { if (method === "agent.get") return { agent: { agent_session: agentSession } } as never; if (method === "pane.process_info") return { process_info: { foreground_processes: processes } } as never; throw new Error(`unexpected RPC: ${method}`); diff --git a/server/omp.ts b/server/omp.ts index 2f4d01cc01..6923f08b63 100644 --- a/server/omp.ts +++ b/server/omp.ts @@ -6,7 +6,7 @@ */ import { join } from "node:path"; -import { herdrRpc } from "./herdr/client.ts"; +import { paneProcessInfo } from "./herdr/client.ts"; import { heldTranscript, runsAgent } from "./gjc-runtime.ts"; import { processEnviron } from "./omo.ts"; @@ -36,12 +36,10 @@ export function ompAgentDir(argv: readonly string[], environ: readonly string[] /** The transcript the pane's omp process holds open or last pointed its terminal breadcrumb at, or null. */ export async function ompHeldTranscript(paneId: string, cwd: string, home = process.env["HOME"] ?? ""): Promise { - const info = await herdrRpc<{ process_info?: { foreground_processes?: { pid?: unknown; argv?: unknown }[] } }>( - "pane.process_info", { pane_id: paneId }, - ).catch(() => null); + const info = await paneProcessInfo(paneId).catch(() => null); let agentDir: string | null = null; const pids: number[] = []; - for (const process of info?.process_info?.foreground_processes ?? []) { + for (const process of info?.foreground_processes ?? []) { const argv = Array.isArray(process.argv) ? process.argv.map(String) : []; if (typeof process.pid !== "number" || !isOmpProcess(argv)) continue; const dir = ompAgentDir(argv, processEnviron(process.pid), home); diff --git a/server/pi.ts b/server/pi.ts index 1eeacf18df..cafffbf0f6 100644 --- a/server/pi.ts +++ b/server/pi.ts @@ -1,7 +1,7 @@ import { constants, closeSync, fstatSync, lstatSync, openSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; -import { herdrRpc } from "./herdr/client.ts"; +import { agentGet } from "./herdr/client.ts"; import { piAgentDir } from "./pi-models.ts"; /** @@ -76,11 +76,8 @@ export function unwrittenSession(path: string, sessionDir: string): { id: string * `/clone` need no inference here: the next read names the file that replaced the old one. */ export async function piTranscriptPath(paneId: string, sessionDir = defaultPiSessionDir()): Promise<{ path: string } | { unwritten: { id: string; path: string } } | null> { - const info = await herdrRpc<{ agent: { agent_session?: { agent?: unknown; kind?: unknown; value?: unknown } } }>( - "agent.get", - { target: paneId }, - ).catch(() => null); - const session = info?.agent?.agent_session; + const agent = await agentGet(paneId).catch(() => null); + const session = agent?.agent_session; // pi reports an absolute path; the id-only form belongs to agents with no store of ours. if (session?.kind !== "path" || typeof session.value !== "string") return null; const path = piTranscriptInStore(session.value, sessionDir); diff --git a/shared/herdr-api.generated.ts b/shared/herdr-api.generated.ts index d0a8da6738..9674487c8f 100644 --- a/shared/herdr-api.generated.ts +++ b/shared/herdr-api.generated.ts @@ -64,6 +64,10 @@ export interface AgentStartParams { export type AgentStatus = "blocked" | "done" | "idle" | "unknown" | "working" | (string & {}); +export interface AgentTarget { + target: string; +} + export interface EventsSubscribeParams { subscriptions: Subscription[]; } @@ -166,6 +170,27 @@ export interface PaneMoveResult { target_layout: PaneLayoutSnapshot; } +export interface PaneProcessInfo { + foreground_process_group_id?: number | null; + foreground_processes?: PaneProcessInfoProcess[]; + pane_id: string; + shell_pid?: number | null; + tty?: string | null; +} + +export interface PaneProcessInfoParams { + pane_id?: string | null; +} + +export interface PaneProcessInfoProcess { + argv?: string[] | null; + argv0?: string | null; + cmdline?: string | null; + cwd?: string | null; + name: string; + pid: number; +} + export interface PaneReadParams { format?: ReadFormat; lines?: number | null;