import { randomUUID } from "node:crypto"; import { createConnection, type Socket } from "node:net"; export type JsonRecord = Record; export type SupportedHerdrProtocol = 16 | 17; export interface HerdrPing { version: string; protocol: SupportedHerdrProtocol; } export interface AgentSessionReference { agent: string; kind: "id" | "path"; value: string; } export interface SidetrackLaunch { terminalId: string; workspaceId: string; tabId: string; paneId: string; agentSession?: AgentSessionReference; } export interface LaunchSidetrackInput { ping: HerdrPing; name: string; childSessionFile: string; prompt: string; cwd: string; workspaceId: string; tabId: string; parentPaneId: string; } export interface SocketHerdrOptions { socketPath: string; connectTimeoutMs?: number; responseTimeoutMs?: number; idFactory?: () => string; } export class HerdrError extends Error { readonly code?: string; constructor(message: string, options?: ErrorOptions & { code?: string }) { super(message, options); this.name = "HerdrError"; this.code = options?.code; } } export class HerdrTimeoutError extends HerdrError { constructor(phase: "connect" | "response", timeoutMs: number) { super(`Herdr ${phase} timed out after ${timeoutMs}ms`); this.name = "HerdrTimeoutError"; } } function object(value: unknown, label: string): JsonRecord { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new HerdrError(`${label} must be an object`); } return value as JsonRecord; } function string(value: unknown, label: string): string { if (typeof value !== "string" || value.length === 0) { throw new HerdrError(`${label} must be a non-empty string`); } return value; } function abortError(signal: AbortSignal): Error { return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError"); } function decodeAgentSession(value: unknown): AgentSessionReference | undefined { if (value === undefined || value === null) return undefined; const session = object(value, "agent.agent_session"); const kind = string(session.kind, "agent.agent_session.kind"); if (kind !== "id" && kind !== "path") throw new HerdrError("agent.agent_session.kind must be id or path"); return { agent: string(session.agent, "agent.agent_session.agent"), kind, value: string(session.value, "agent.agent_session.value"), }; } function decodeAgent(value: unknown): SidetrackLaunch { const agent = object(value, "agent"); const agentSession = decodeAgentSession(agent.agent_session); return { terminalId: string(agent.terminal_id, "agent.terminal_id"), workspaceId: string(agent.workspace_id, "agent.workspace_id"), tabId: string(agent.tab_id, "agent.tab_id"), paneId: string(agent.pane_id, "agent.pane_id"), ...(agentSession ? { agentSession } : {}), }; } function decodePaneId(value: unknown): string { return string(object(value, "pane").pane_id, "pane.pane_id"); } export class SocketHerdrClient { readonly socketPath: string; readonly connectTimeoutMs: number; readonly responseTimeoutMs: number; readonly #idFactory: () => string; constructor(options: SocketHerdrOptions) { if (!options.socketPath) throw new HerdrError("HERDR_SOCKET_PATH is required"); this.socketPath = options.socketPath; this.connectTimeoutMs = options.connectTimeoutMs ?? 2_000; this.responseTimeoutMs = options.responseTimeoutMs ?? 10_000; this.#idFactory = options.idFactory ?? (() => `pi-sidetrack-${randomUUID()}`); } async ping(signal?: AbortSignal): Promise { const result = await this.#request("ping", {}, "pong", signal); const version = string(result.version, "ping.version"); if (typeof result.protocol !== "number" || !Number.isInteger(result.protocol)) { throw new HerdrError("ping.protocol must be an integer"); } if (result.protocol !== 16 && result.protocol !== 17) { throw new HerdrError(`Unsupported Herdr version ${version} (protocol ${result.protocol}); supported protocols are 16 and 17.`); } return { version, protocol: result.protocol }; } async launchSidetrack(input: LaunchSidetrackInput, signal?: AbortSignal): Promise { if (input.ping.protocol !== 16 && input.ping.protocol !== 17) { throw new HerdrError( `Unsupported Herdr version ${input.ping.version} (protocol ${String(input.ping.protocol)}); supported protocols are 16 and 17.`, ); } const name = input.name.trim(); if (!name) throw new HerdrError("Sidetrack name must not be empty"); const prompt = `\n${input.prompt}`; if (input.ping.protocol === 16) { const result = await this.#request("agent.start", { name, argv: ["pi", "--session", input.childSessionFile, "--name", name, prompt], cwd: input.cwd, workspace_id: input.workspaceId, tab_id: input.tabId, split: "right", focus: true, env: {}, }, "agent_started", signal); return decodeAgent(result.agent); } const split = await this.#request("pane.split", { target_pane_id: input.parentPaneId, direction: "right", ratio: 0.5, cwd: input.cwd, focus: true, env: {}, }, "pane_info", signal); const paneId = decodePaneId(split.pane); const started = await this.#request("agent.start", { name, kind: "pi", pane_id: paneId, args: ["--session", input.childSessionFile, "--name", name, prompt], timeout_ms: 30_000, }, "agent_started", signal); return decodeAgent(started.agent); } async #request(method: string, params: JsonRecord, expectedType: string, signal?: AbortSignal): Promise { if (signal?.aborted) throw abortError(signal); const id = this.#idFactory(); const socket = await this.#connect(signal); return new Promise((resolve, reject) => { let buffer = ""; let settled = false; const timer = setTimeout( () => finish(new HerdrTimeoutError("response", this.responseTimeoutMs)), this.responseTimeoutMs, ); const onAbort = () => finish(abortError(signal!)); const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); socket.removeAllListeners(); socket.destroy(); }; const finish = (error?: unknown, result?: JsonRecord) => { if (settled) return; settled = true; cleanup(); if (error === undefined) resolve(result!); else reject(error); }; const decode = (line: string) => { let parsed: unknown; try { parsed = JSON.parse(line); } catch (error) { throw new HerdrError("Herdr returned malformed JSON", { cause: error }); } const envelope = object(parsed, "Herdr response"); if (string(envelope.id, "response.id") !== id) throw new HerdrError("Herdr response ID mismatch"); const hasResult = Object.hasOwn(envelope, "result"); const hasError = Object.hasOwn(envelope, "error"); if (hasResult === hasError) throw new HerdrError("Herdr response must contain exactly one result or error"); if (hasError) { const error = object(envelope.error, "response.error"); const code = string(error.code, "error.code"); throw new HerdrError(`Herdr ${code}: ${string(error.message, "error.message")}`, { code }); } const result = object(envelope.result, "response.result"); const actualType = string(result.type, "result.type"); if (actualType !== expectedType) { throw new HerdrError(`Expected Herdr result ${expectedType}, received ${actualType}`); } return result; }; signal?.addEventListener("abort", onAbort, { once: true }); if (signal?.aborted) { onAbort(); return; } socket.setEncoding("utf8"); socket.on("data", (chunk: string) => { buffer += chunk; const newline = buffer.indexOf("\n"); if (newline < 0) return; try { finish(undefined, decode(buffer.slice(0, newline))); } catch (error) { finish(error); } }); socket.once("error", (error) => finish(new HerdrError("Herdr socket request failed", { cause: error }))); socket.once("end", () => finish(new HerdrError(buffer ? "Herdr response was truncated" : "Herdr disconnected before responding"))); socket.write(`${JSON.stringify({ id, method, params })}\n`); }); } #connect(signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { const socket = createConnection(this.socketPath); let settled = false; const timer = setTimeout( () => finish(new HerdrTimeoutError("connect", this.connectTimeoutMs)), this.connectTimeoutMs, ); const onAbort = () => finish(abortError(signal!)); const onError = (error: Error) => finish(new HerdrError(`Unable to connect to Herdr at ${this.socketPath}`, { cause: error })); const finish = (error?: unknown) => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener("abort", onAbort); socket.removeListener("error", onError); if (error === undefined) resolve(socket); else { socket.destroy(); reject(error); } }; signal?.addEventListener("abort", onAbort, { once: true }); if (signal?.aborted) { onAbort(); return; } socket.once("error", onError); socket.once("connect", () => finish()); }); } }