From 8c4d70a0e3689d4ab18132b55fe2d90dfad3a53b Mon Sep 17 00:00:00 2001 From: PunkDevRobot Date: Wed, 26 Aug 2026 04:43:05 +0200 Subject: [PATCH] Port from herdr-browser to terminal-browser herdr-browser (official.browser) was deprecated in favor of the terminal-browser CLI, which ships its own thin Herdr plugin. Present now runs 'terminal-browser open --split right' against the requesting pane, maps the opened browser to its Herdr pane via 'ls --json', and zooms it; dismiss closes the pane. The presenter stdin protocol is unchanged. Runtime discovery resolves the terminal-browser executable instead of inspecting a plugin root; the integration config schema is now 3 and records it. A 13-second overall deadline keeps present inside Plannotator's 15-second budget, and doctor probes the resolved binary with --version. Requires Herdr 0.8.2, matching terminal-browser's own floor. --- README.md | 45 ++++---- herdr-plugin.toml | 2 +- src/browser-pane.test.ts | 207 ++++++++++++++++++++-------------- src/browser-pane.ts | 226 ++++++++++++++++++++------------------ src/configuration.ts | 23 +++- src/constants.ts | 23 ++-- src/herdr.test.ts | 86 ++++----------- src/herdr.ts | 126 +++++++++------------ src/integration-config.ts | 2 + src/presenter.ts | 11 +- 10 files changed, 384 insertions(+), 367 deletions(-) diff --git a/README.md b/README.md index e874dd7..cfc9e7f 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,36 @@ # Herdr Plannotator -Herdr Plannotator opens Plannotator's existing review page in a Herdr Browser -pane. Plannotator still owns approvals, feedback, and agent behavior. This -plugin only changes where the page appears. +Herdr Plannotator opens Plannotator's existing review page in a +[terminal-browser](https://github.com/zenbu-labs/terminal-browser) split pane +next to your agent. Plannotator still owns approvals, feedback, and agent +behavior. This plugin only changes where the page appears. ## Requirements -- Herdr 0.7.5 or newer +- Herdr 0.8.2 or newer - A Plannotator release with external presenter support -- [Herdr Browser](https://github.com/ogulcancelik/herdr-browser), installed and enabled +- [terminal-browser](https://github.com/zenbu-labs/terminal-browser) - Bun -- Google Chrome or Chromium -- Herdr's experimental Kitty graphics support -Install Browser first: +Install terminal-browser first, either directly: ```bash -herdr plugin install ogulcancelik/herdr-browser --yes +curl -fsSL https://terminal-browser.sh/install | bash ``` -Run the same command again if your installed copy predates Browser's initial -URL support. +or as a Herdr plugin: -Enable graphics in Herdr's configuration: - -```toml -[experimental] -kitty_graphics = true +```bash +herdr plugin install zenbu-labs/terminal-browser/herdr-plugin --yes ``` -Then reload Herdr: +terminal-browser enables Herdr's experimental Kitty graphics support itself +the first time it opens a pane. If the browser pane stays blank, enable it +manually: set `kitty_graphics = true` under `[experimental]` in Herdr's +configuration and run `herdr server reload-config`. -```bash -herdr server reload-config -``` +Upgrading from a release that used Herdr Browser? Re-run the `configure` +action once after updating. ## Install @@ -75,10 +72,10 @@ or use an absolute or `~/...` path. ## How it works When Plannotator has a page ready, it runs the plugin's presenter helper with -one JSON request. The helper asks Herdr to open a focused, zoomed -`official.browser` pane at Plannotator's local URL. Herdr returns the pane ID. -When the review finishes, the helper closes that pane. Browser closes its own -view when the pane exits. +one JSON request. The helper runs `terminal-browser open --split right` +against the requesting agent's pane, maps the opened browser to its Herdr +pane ID, and zooms the pane. When the review finishes, the helper closes that +pane, which also closes the browser view. The helper never interprets approval or feedback. The existing Plannotator server receives those choices directly from its web page. diff --git a/herdr-plugin.toml b/herdr-plugin.toml index d0a107b..3ae04b7 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,7 +1,7 @@ id = "official.plannotator" name = "Plannotator" version = "0.1.0" -min_herdr_version = "0.7.5" +min_herdr_version = "0.8.2" description = "Review Plannotator plans and changes inside Herdr." platforms = ["linux", "macos"] diff --git a/src/browser-pane.test.ts b/src/browser-pane.test.ts index 7747ee2..5359e76 100644 --- a/src/browser-pane.test.ts +++ b/src/browser-pane.test.ts @@ -18,115 +18,142 @@ const success = ( aborted: false, }); +// A pid that cannot exist, so cleanup kill in failure tests is a no-op. +const OPEN_OUTPUT = JSON.stringify({ key: "71054-1", pid: 2147483646 }); +const LS_OUTPUT = JSON.stringify({ + self: { tab: "w1:t1", pane: "w1:p1" }, + browsers: [{ + key: "71054-1", + pane: { tab: "w1:t1", pane: "w1:p2" }, + }], +}); + describe("Browser pane", () => { - test("opens the supplied URL beside the source pane", async () => { - const calls: string[][] = []; + test("opens the supplied URL beside the source pane and zooms it", async () => { + const calls: Array<{ command: string; args: string[] }> = []; const runner: CommandRunner = async (command, args, options) => { - expect(command).toBe("/bin/herdr"); - calls.push(args); + calls.push({ command, args }); expect(options?.signal).toBeDefined(); - if (args[0] === "pane") { - return success(); + if (command === "/bin/herdr") { + return success('{"result":{"type":"ok"}}'); } - return success(JSON.stringify({ - result: { - type: "plugin_pane_opened", - plugin_pane: { - pane: { pane_id: "w1:p2" }, - }, - }, - })); + expect(options?.env?.HERDR_PANE_ID).toBe("w1:p1"); + return success(args[0] === "open" ? OPEN_OUTPUT : LS_OUTPUT); }; await expect(openBrowserPane( + "/bin/terminal-browser", "/bin/herdr", "http://127.0.0.1:43127", "w1:p1", { runner, + env: {}, signal: new AbortController().signal, }, )).resolves.toEqual({ paneId: "w1:p2" }); - expect(calls).toEqual([ - [ - "plugin", - "pane", - "open", - "--plugin", - "official.browser", - "--entrypoint", - "browser", - "--placement", - "zoomed", - "--target-pane", - "w1:p1", - "--env", - "HERDR_BROWSER_INITIAL_URL=http://127.0.0.1:43127", - "--focus", - ], - [ - "pane", - "wait-output", - "w1:p2", - "--match", - "http://127.0.0.1:43127", - "--source", - "visible", - "--timeout", - "5000", - ], + expect(calls.map((call) => call.args)).toEqual([ + ["open", "http://127.0.0.1:43127", "--split", "right"], + ["ls", "--json"], + ["pane", "zoom", "w1:p2", "--on"], ]); }); - test("closes the pane when Browser does not start", async () => { - const calls: string[][] = []; + test("fails when terminal-browser cannot open", async () => { + const runner: CommandRunner = async () => ({ + ...success(), + exitCode: 1, + stderr: "terminal-browser: Command failed: herdr pane split", + }); + + await expect(openBrowserPane( + "/bin/terminal-browser", + "/bin/herdr", + "http://127.0.0.1:43127", + "w1:p1", + { runner, env: {} }, + )).rejects.toThrow( + "Failed to open terminal-browser: terminal-browser: Command failed: herdr pane split", + ); + }); + + test("retries pane lookup before failing", async () => { + let listings = 0; const runner: CommandRunner = async (_command, args) => { - calls.push(args); - if (args[0] === "plugin" && args[2] === "open") { - return success(JSON.stringify({ - result: { - type: "plugin_pane_opened", - plugin_pane: { - pane: { pane_id: "w1:p2" }, - }, - }, - })); + if (args[0] === "open") { + return success(OPEN_OUTPUT); } - if (args[0] === "pane" && args[1] === "wait-output") { - return { ...success(), exitCode: 1, stderr: "Browser exited" }; + if (args[0] !== "ls") { + return success('{"result":{"type":"ok"}}'); } - return success(); + listings += 1; + return success(listings < 3 + ? JSON.stringify({ browsers: [] }) + : LS_OUTPUT); }; await expect(openBrowserPane( + "/bin/terminal-browser", "/bin/herdr", "http://127.0.0.1:43127", "w1:p1", - { runner }, - )).rejects.toThrow("Failed to start Browser: Browser exited"); - expect(calls.at(-1)).toEqual([ - "plugin", - "pane", - "close", - "w1:p2", - ]); + { runner, env: {} }, + )).resolves.toEqual({ paneId: "w1:p2" }); + expect(listings).toBe(3); }); - test("closing an already-removed plugin pane is idempotent", async () => { - const calls: string[][] = []; + test("kills the browser when it never gets a Herdr pane", async () => { + const killed: number[] = []; + const runner: CommandRunner = async (_command, args) => success( + args[0] === "open" + ? OPEN_OUTPUT + : JSON.stringify({ browsers: [] }), + ); + + await expect(openBrowserPane( + "/bin/terminal-browser", + "/bin/herdr", + "http://127.0.0.1:43127", + "w1:p1", + { runner, env: {}, kill: (pid) => killed.push(pid) }, + )).rejects.toThrow("browser 71054-1 never appeared in terminal-browser ls"); + expect(killed).toEqual([2147483646]); + }); + + test("fails when open output is not the browser record", async () => { + const runner: CommandRunner = async () => success("starting browser..."); + + await expect(openBrowserPane( + "/bin/terminal-browser", + "/bin/herdr", + "http://127.0.0.1:43127", + "w1:p1", + { runner, env: {} }, + )).rejects.toThrow("terminal-browser did not report the opened browser"); + }); + + test("zoom failure does not fail the presentation", async () => { const runner: CommandRunner = async (_command, args) => { - calls.push(args); - return { - ...success(), - exitCode: 1, - stderr: JSON.stringify({ - error: { - code: args[0] === "plugin" - ? "plugin_pane_not_found" - : "pane_not_found", - }, - }), - }; + if (args[0] === "pane" && args[1] === "zoom") { + throw new Error("zoom exploded"); + } + return success(args[0] === "open" ? OPEN_OUTPUT : LS_OUTPUT); + }; + + await expect(openBrowserPane( + "/bin/terminal-browser", + "/bin/herdr", + "http://127.0.0.1:43127", + "w1:p1", + { runner, env: {} }, + )).resolves.toEqual({ paneId: "w1:p2" }); + }); + + test("closes the pane through Herdr", async () => { + const calls: Array<{ command: string; args: string[] }> = []; + const runner: CommandRunner = async (command, args) => { + calls.push({ command, args }); + return success('{"id":"cli:pane:close","result":{"type":"ok"}}'); }; await expect(closeBrowserPane( @@ -134,9 +161,23 @@ describe("Browser pane", () => { { paneId: "w1:p2" }, { runner }, )).resolves.toBeUndefined(); - expect(calls).toEqual([ - ["plugin", "pane", "close", "w1:p2"], - ["pane", "close", "w1:p2"], - ]); + expect(calls).toEqual([{ + command: "/bin/herdr", + args: ["pane", "close", "w1:p2"], + }]); + }); + + test("closing an already-removed pane is idempotent", async () => { + const runner: CommandRunner = async () => ({ + ...success(), + exitCode: 1, + stderr: JSON.stringify({ error: { code: "pane_not_found" } }), + }); + + await expect(closeBrowserPane( + "/bin/herdr", + { paneId: "w1:p2" }, + { runner }, + )).resolves.toBeUndefined(); }); }); diff --git a/src/browser-pane.ts b/src/browser-pane.ts index 790d66f..07c654e 100644 --- a/src/browser-pane.ts +++ b/src/browser-pane.ts @@ -1,9 +1,7 @@ import { - BROWSER_INITIAL_URL_ENV, + BROWSER_LS_TIMEOUT_MS, + BROWSER_OPEN_TIMEOUT_MS, BROWSER_PANE_COMMAND_TIMEOUT_MS, - BROWSER_PLUGIN_ID, - BROWSER_READY_MATCH_TIMEOUT_MS, - BROWSER_READY_TIMEOUT_MS, } from "./constants.ts"; import type { CommandResult, CommandRunner } from "./command.ts"; import { commandFailureReason, runCommand } from "./command.ts"; @@ -16,87 +14,96 @@ type BrowserPaneOptions = { runner?: CommandRunner; env?: NodeJS.ProcessEnv; signal?: AbortSignal; + /** Test seam for the best-effort browser-process cleanup. */ + kill?: (pid: number) => void; }; export async function openBrowserPane( + terminalBrowserBin: string, herdrBin: string, url: string, targetPaneId: string, options: BrowserPaneOptions = {}, ): Promise { const runner = options.runner ?? runCommand; - const result = await runner( - herdrBin, - [ - "plugin", - "pane", - "open", - "--plugin", - BROWSER_PLUGIN_ID, - "--entrypoint", - "browser", - "--placement", - "zoomed", - "--target-pane", - targetPaneId, - "--env", - `${BROWSER_INITIAL_URL_ENV}=${url}`, - "--focus", - ], + // terminal-browser splits the pane named by HERDR_PANE_ID; pin it to the + // requesting pane instead of trusting whatever this process inherited. + const env = { ...(options.env ?? process.env), HERDR_PANE_ID: targetPaneId }; + const opened = await runner( + terminalBrowserBin, + ["open", url, "--split", "right"], { - env: options.env, - timeoutMs: BROWSER_PANE_COMMAND_TIMEOUT_MS, + env, + timeoutMs: BROWSER_OPEN_TIMEOUT_MS, signal: options.signal, }, ); - if (result.exitCode !== 0) { - throw commandError( - result, - "browser_pane_open_failed", - "open Browser pane", - ); + if (opened.exitCode !== 0) { + throw commandError(opened, "browser_pane_open_failed", "open terminal-browser"); } - - const paneId = openedPaneId(result.stdout); - if (!paneId) { + const browser = openedBrowser(opened.stdout); + if (!browser) { throw new BrowserPaneError( "browser_pane_open_failed", - "Herdr did not return the opened Browser pane ID", + `terminal-browser did not report the opened browser: ${opened.stdout.trim()}`, ); } - const handle = { paneId }; - const ready = await runner( - herdrBin, - [ - "pane", - "wait-output", - paneId, - "--match", - new URL(url).origin, - "--source", - "visible", - "--timeout", - String(BROWSER_READY_MATCH_TIMEOUT_MS), - ], - { - env: options.env, - timeoutMs: BROWSER_READY_TIMEOUT_MS, - signal: options.signal, - }, - ); - if (ready.exitCode === 0) { - return handle; + + // Map the browser key to the Herdr pane hosting it; the pane is the handle + // that dismiss closes later. Registration is observed to be complete when + // open returns, but retry briefly in case it is not guaranteed. + let listed: CommandResult | null = null; + for (let attempt = 0; attempt < 3; attempt += 1) { + if (attempt > 0) { + await sleep(250, options.signal); + } + listed = await runner( + terminalBrowserBin, + ["ls", "--json"], + { + env, + timeoutMs: BROWSER_LS_TIMEOUT_MS, + signal: options.signal, + }, + ); + const paneId = listed.exitCode === 0 + ? browserPaneId(listed.stdout, browser.key) + : null; + if (paneId) { + // Match the previous plugin's zoomed presentation. Fire and forget: a + // split that fails to zoom is still usable, and the handle must not + // wait on it. + void runner( + herdrBin, + ["pane", "zoom", paneId, "--on"], + { + env, + timeoutMs: BROWSER_PANE_COMMAND_TIMEOUT_MS, + signal: options.signal, + }, + ).catch(() => {}); + return { paneId }; + } } - await closeBrowserPane(herdrBin, handle, { - runner, - env: options.env, - }).catch(() => {}); - throw commandError( - ready, - "browser_pane_start_failed", - "start Browser", - ); + // Without a pane ID the only cleanup is killing the browser process; the + // emptied Herdr pane may linger on this rare path. + const kill = options.kill ?? process.kill.bind(process); + try { + kill(browser.pid); + } catch { + // Already gone. + } + throw listed && listed.exitCode === 0 + ? new BrowserPaneError( + "browser_pane_start_failed", + `browser ${browser.key} never appeared in terminal-browser ls`, + ) + : commandError( + listed!, + "browser_pane_start_failed", + `find the Herdr pane for browser ${browser.key}`, + ); } export async function closeBrowserPane( @@ -105,40 +112,19 @@ export async function closeBrowserPane( options: BrowserPaneOptions = {}, ): Promise { const runner = options.runner ?? runCommand; - const commandOptions = { - env: options.env, - timeoutMs: BROWSER_PANE_COMMAND_TIMEOUT_MS, - signal: options.signal, - }; - const pluginClose = await runner( - herdrBin, - ["plugin", "pane", "close", handle.paneId], - commandOptions, - ); - if (pluginClose.exitCode === 0) { - return; - } - if (!isMissingPane(pluginClose)) { - throw commandError( - pluginClose, - "browser_pane_close_failed", - "close Browser pane", - ); - } - - const paneClose = await runner( + const result = await runner( herdrBin, ["pane", "close", handle.paneId], - commandOptions, + { + env: options.env, + timeoutMs: BROWSER_PANE_COMMAND_TIMEOUT_MS, + signal: options.signal, + }, ); - if (paneClose.exitCode === 0 || isMissingPane(paneClose)) { + if (result.exitCode === 0 || isMissingPane(result)) { return; } - throw commandError( - paneClose, - "browser_pane_close_failed", - "close Browser pane", - ); + throw commandError(result, "browser_pane_close_failed", "close browser pane"); } export class BrowserPaneError extends Error { @@ -162,25 +148,37 @@ function commandError( ); } -function openedPaneId(output: string): string | null { - const response = parseRecord(output); - const result = isJsonObject(response?.result) ? response.result : null; - const pluginPane = isJsonObject(result?.plugin_pane) - ? result.plugin_pane +function openedBrowser(output: string): { key: string; pid: number } | null { + const record = parseRecord(output); + return typeof record?.key === "string" + && record.key.trim() + && typeof record.pid === "number" + && Number.isInteger(record.pid) + && record.pid > 0 + ? { key: record.key.trim(), pid: record.pid } + : null; +} + +function browserPaneId(output: string, key: string): string | null { + const record = parseRecord(output); + if (!record || !Array.isArray(record.browsers)) { + return null; + } + const browser = record.browsers.find((candidate) => ( + isJsonObject(candidate) && candidate.key === key + )); + const pane = isJsonObject(browser) && isJsonObject(browser.pane) + ? browser.pane : null; - const pane = isJsonObject(pluginPane?.pane) ? pluginPane.pane : null; - return result?.type === "plugin_pane_opened" - && typeof pane?.pane_id === "string" - && pane.pane_id.trim() - ? pane.pane_id.trim() + return typeof pane?.pane === "string" && pane.pane.trim() + ? pane.pane.trim() : null; } function isMissingPane(result: CommandResult): boolean { const response = parseRecord(result.stderr) ?? parseRecord(result.stdout); const error = isJsonObject(response?.error) ? response.error : null; - return error?.code === "plugin_pane_not_found" - || error?.code === "pane_not_found"; + return error?.code === "pane_not_found"; } function parseRecord(output: string): Record | null { @@ -191,3 +189,19 @@ function parseRecord(output: string): Record | null { return null; } } + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolvePromise) => { + if (signal?.aborted) { + resolvePromise(); + return; + } + const timer = setTimeout(done, ms); + signal?.addEventListener("abort", done, { once: true }); + function done() { + clearTimeout(timer); + signal?.removeEventListener("abort", done); + resolvePromise(); + } + }); +} diff --git a/src/configuration.ts b/src/configuration.ts index 88cdd58..d00541f 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -10,6 +10,7 @@ import { } from "./integration-config.ts"; import { herdrVersionSupported, + readTerminalBrowserVersion, resolveRuntime, type ResolvedRuntime, } from "./herdr.ts"; @@ -56,6 +57,7 @@ export async function configureIntegration(): Promise<{ schema: INTEGRATION_CONFIG_SCHEMA, owner: PLUGIN_ID, herdrBin: runtime.herdrBin, + terminalBrowserBin: runtime.terminalBrowserBin, configuredAt, }; const integrationPath = integrationConfigPath(); @@ -83,11 +85,22 @@ export async function runDoctorChecks(): Promise { ? `${runtime.herdrVersion} at ${runtime.herdrBin}` : `${runtime.herdrVersion}; ${MIN_HERDR_VERSION} or newer is required`, }); - checks.push({ - name: "Browser plugin", - ok: true, - detail: runtime.browserRoot, - }); + try { + const browserVersion = await readTerminalBrowserVersion( + runtime.terminalBrowserBin, + ); + checks.push({ + name: "terminal-browser", + ok: true, + detail: `${browserVersion} at ${runtime.terminalBrowserBin}`, + }); + } catch (error) { + checks.push({ + name: "terminal-browser", + ok: false, + detail: error instanceof Error ? error.message : String(error), + }); + } } catch (error) { checks.push({ name: "Runtime discovery", diff --git a/src/constants.ts b/src/constants.ts index 861e5dc..cb91e73 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,15 +1,20 @@ export const PLUGIN_ID = "official.plannotator"; -export const BROWSER_PLUGIN_ID = "official.browser"; -export const BROWSER_INITIAL_URL_ENV = "HERDR_BROWSER_INITIAL_URL"; +export const TERMINAL_BROWSER_BIN = "terminal-browser"; +export const TERMINAL_BROWSER_INSTALL_HINT = + "curl -fsSL https://terminal-browser.sh/install | bash"; export const PRESENTER_PROTOCOL = 1; -export const MIN_HERDR_VERSION = "0.7.5"; -export const INTEGRATION_CONFIG_SCHEMA = 2; +// terminal-browser drives `herdr pane split` internally; match the floor its +// own Herdr plugin declares. +export const MIN_HERDR_VERSION = "0.8.2"; +export const INTEGRATION_CONFIG_SCHEMA = 3; export const OWNERSHIP_KEY = "_herdrPlannotator"; export const MAX_PROTOCOL_BYTES = 64 * 1024; -// Discovery, pane startup, readiness, and failed-startup cleanup must fit -// inside Plannotator's 15-second presentation deadline. +// Discovery, browser startup, pane lookup, and failed-startup cleanup must +// fit inside Plannotator's 15-second presentation deadline. Worst case: +// version 2s + open 7s + 3 x (ls 1s + 250ms) < the 13s overall deadline, +// which aborts the present flow outright if the arithmetic ever lies. +export const PRESENT_DEADLINE_MS = 13_000; export const HERDR_VERSION_TIMEOUT_MS = 2_000; -export const BROWSER_DISCOVERY_TIMEOUT_MS = 2_000; +export const BROWSER_OPEN_TIMEOUT_MS = 7_000; +export const BROWSER_LS_TIMEOUT_MS = 1_000; export const BROWSER_PANE_COMMAND_TIMEOUT_MS = 3_000; -export const BROWSER_READY_TIMEOUT_MS = 6_000; -export const BROWSER_READY_MATCH_TIMEOUT_MS = 5_000; diff --git a/src/herdr.test.ts b/src/herdr.test.ts index 477bb01..bc78cf7 100644 --- a/src/herdr.test.ts +++ b/src/herdr.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { chmodSync, - mkdirSync, mkdtempSync, realpathSync, rmSync, @@ -11,9 +10,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { - discoverBrowserPlugin, herdrVersionSupported, resolveRuntime, + resolveTerminalBrowserExecutable, type ResolveRuntimeOptions, } from "./herdr.ts"; import type { CommandRunner } from "./command.ts"; @@ -28,67 +27,33 @@ afterEach(() => { describe("Herdr discovery", () => { test("compares semantic versions numerically", () => { - expect(herdrVersionSupported("0.7.5")).toBe(true); - expect(herdrVersionSupported("0.8.0")).toBe(true); - expect(herdrVersionSupported("0.7.4")).toBe(false); + expect(herdrVersionSupported("0.8.2")).toBe(true); + expect(herdrVersionSupported("0.9.0")).toBe(true); + expect(herdrVersionSupported("0.8.1")).toBe(false); expect(herdrVersionSupported("0.10.0")).toBe(true); }); - test("requires an enabled official Browser plugin", async () => { - const runner: CommandRunner = async () => ({ - exitCode: 0, - signal: null, - stdout: JSON.stringify({ - id: "cli:plugin", - result: { - type: "plugin_list", - plugins: [{ - plugin_id: "official.browser", - plugin_root: "/plugins/browser", - enabled: true, - }], - }, - }), - stderr: "", - timedOut: false, - outputExceeded: false, - aborted: false, - }); - - await expect(discoverBrowserPlugin("/bin/herdr", runner)).resolves.toBe( - "/plugins/browser", - ); + test("requires an installed terminal-browser executable", async () => { + await expect(resolveTerminalBrowserExecutable({ + env: { PATH: "/nonexistent", HOME: "/nonexistent" }, + cached: null, + })).rejects.toThrow("terminal-browser was not found"); }); - test("requires Browser support for an initial pane URL", async () => { - const root = mkdtempSync(join(tmpdir(), "herdr-plannotator-browser-")); + test("resolves both executables and the Herdr version", async () => { + const root = mkdtempSync(join(tmpdir(), "herdr-plannotator-runtime-")); temporaryPaths.push(root); - const browserRoot = join(root, "browser"); - const browserSource = join(browserRoot, "src"); const herdrBin = join(root, "herdr"); - mkdirSync(browserSource, { recursive: true }); - writeFileSync( - join(browserRoot, "herdr-plugin.toml"), - 'id = "official.browser"\n', - ); - writeFileSync(join(browserSource, "viewer.ts"), "export {};\n"); + const terminalBrowserBin = join(root, "terminal-browser"); writeFileSync(herdrBin, "#!/bin/sh\n"); + writeFileSync(terminalBrowserBin, "#!/bin/sh\n"); chmodSync(herdrBin, 0o755); + chmodSync(terminalBrowserBin, 0o755); - const runner: CommandRunner = async (_command, args) => ({ + const runner: CommandRunner = async () => ({ exitCode: 0, signal: null, - stdout: args[0] === "--version" - ? "herdr 0.7.5\n" - : JSON.stringify({ - result: { - plugins: [{ - plugin_id: "official.browser", - plugin_root: browserRoot, - enabled: true, - }], - }, - }), + stdout: "herdr 0.8.2\n", stderr: "", timedOut: false, outputExceeded: false, @@ -98,24 +63,15 @@ describe("Herdr discovery", () => { const options = { env: { HERDR_PLANNOTATOR_HERDR_BIN: herdrBin, + HERDR_PLANNOTATOR_TERMINAL_BROWSER_BIN: terminalBrowserBin, }, runner, - cached: { - schema: 2, - owner: "official.plannotator", - herdrBin, - configuredAt: "2026-07-27T00:00:00.000Z", - }, + cached: null, } satisfies ResolveRuntimeOptions; - await expect(resolveRuntime(options)).rejects.toThrow( - "reinstall it to add HERDR_BROWSER_INITIAL_URL", - ); - writeFileSync( - join(browserSource, "viewer.ts"), - 'const initialUrl = process.env.HERDR_BROWSER_INITIAL_URL;\n', - ); const runtime = await resolveRuntime(options); - expect(runtime.browserRoot).toBe(realpathSync(browserRoot)); + expect(runtime.herdrBin).toBe(realpathSync(herdrBin)); + expect(runtime.terminalBrowserBin).toBe(realpathSync(terminalBrowserBin)); + expect(runtime.herdrVersion).toBe("0.8.2"); }); }); diff --git a/src/herdr.ts b/src/herdr.ts index a4f8126..21f35ca 100644 --- a/src/herdr.ts +++ b/src/herdr.ts @@ -1,12 +1,11 @@ -import { readFile, realpath } from "node:fs/promises"; +import { homedir } from "node:os"; import { delimiter, isAbsolute, join, resolve } from "node:path"; import { - BROWSER_DISCOVERY_TIMEOUT_MS, - BROWSER_INITIAL_URL_ENV, - BROWSER_PLUGIN_ID, HERDR_VERSION_TIMEOUT_MS, MIN_HERDR_VERSION, + TERMINAL_BROWSER_BIN, + TERMINAL_BROWSER_INSTALL_HINT, } from "./constants.ts"; import type { CommandRunner } from "./command.ts"; import { commandFailureReason, runCommand } from "./command.ts"; @@ -15,7 +14,6 @@ import { readIntegrationConfig, usableExecutable, } from "./integration-config.ts"; -import { isJsonObject } from "./json-file.ts"; import { integrationConfigPath } from "./paths.ts"; export type ResolvedHerdrRuntime = { @@ -24,7 +22,7 @@ export type ResolvedHerdrRuntime = { }; export type ResolvedRuntime = ResolvedHerdrRuntime & { - browserRoot: string; + terminalBrowserBin: string; }; export type ResolveRuntimeOptions = { @@ -39,17 +37,17 @@ export async function resolveRuntime( ): Promise { const env = options.env ?? process.env; const runner = options.runner ?? runCommand; - const herdrBin = await resolveHerdrExecutable(options); - const [herdrVersion, browserRoot] = await Promise.all([ - readHerdrVersion(herdrBin, runner, env, options.signal), - discoverBrowserPlugin(herdrBin, runner, env, options.signal) - .then(validateBrowserRoot), + const cached = await cachedConfig(options); + const [herdrBin, terminalBrowserBin] = await Promise.all([ + resolveHerdrExecutable({ ...options, cached }), + resolveTerminalBrowserExecutable({ ...options, cached }), ]); + const herdrVersion = await readHerdrVersion(herdrBin, runner, env, options.signal); return { herdrBin, herdrVersion, - browserRoot, + terminalBrowserBin, }; } @@ -57,9 +55,7 @@ export async function resolveHerdrExecutable( options: ResolveRuntimeOptions = {}, ): Promise { const env = options.env ?? process.env; - const cached = options.cached === undefined - ? await readIntegrationConfig(integrationConfigPath(env)).catch(() => null) - : options.cached; + const cached = await cachedConfig(options); const herdrBin = await resolveExecutableCandidates( [ env.HERDR_PLANNOTATOR_HERDR_BIN, @@ -75,6 +71,30 @@ export async function resolveHerdrExecutable( return herdrBin; } +export async function resolveTerminalBrowserExecutable( + options: ResolveRuntimeOptions = {}, +): Promise { + const env = options.env ?? process.env; + const cached = await cachedConfig(options); + const bin = await resolveExecutableCandidates( + [ + env.HERDR_PLANNOTATOR_TERMINAL_BROWSER_BIN, + cached?.terminalBrowserBin, + TERMINAL_BROWSER_BIN, + // The installer's default location, for agent environments whose PATH + // does not include ~/.local/bin. + join(env.HOME ?? homedir(), ".local", "bin", TERMINAL_BROWSER_BIN), + ], + env, + ); + if (!bin) { + throw new Error( + `${TERMINAL_BROWSER_BIN} was not found; install it: ${TERMINAL_BROWSER_INSTALL_HINT}`, + ); + } + return bin; +} + export async function readHerdrVersion( herdrBin: string, runner: CommandRunner = runCommand, @@ -118,21 +138,18 @@ export function herdrVersionSupported( return true; } -export async function discoverBrowserPlugin( - herdrBin: string, +/** Probe the resolved terminal-browser binary; throws when it cannot run. */ +export async function readTerminalBrowserVersion( + terminalBrowserBin: string, runner: CommandRunner = runCommand, env: NodeJS.ProcessEnv = process.env, signal?: AbortSignal, ): Promise { - const result = await runner( - herdrBin, - ["plugin", "list", "--plugin", BROWSER_PLUGIN_ID, "--json"], - { - env, - timeoutMs: BROWSER_DISCOVERY_TIMEOUT_MS, - signal, - }, - ); + const result = await runner(terminalBrowserBin, ["--version"], { + env, + timeoutMs: HERDR_VERSION_TIMEOUT_MS, + signal, + }); if ( result.exitCode !== 0 || result.timedOut @@ -140,36 +157,10 @@ export async function discoverBrowserPlugin( || result.aborted ) { throw new Error( - `failed to inspect ${BROWSER_PLUGIN_ID}: ${commandFailureReason(result)}`, - ); - } - - let parsed: unknown; - try { - parsed = JSON.parse(result.stdout); - } catch { - throw new Error(`Herdr returned malformed plugin JSON: ${result.stdout.trim()}`); - } - const plugins = isJsonObject(parsed) - && isJsonObject(parsed.result) - && Array.isArray(parsed.result.plugins) - ? parsed.result.plugins - : null; - const plugin = plugins?.find((candidate) => ( - isJsonObject(candidate) && candidate.plugin_id === BROWSER_PLUGIN_ID - )); - if (!isJsonObject(plugin)) { - throw new Error( - `${BROWSER_PLUGIN_ID} is not installed; run: herdr plugin install ogulcancelik/herdr-browser --yes`, + `failed to read terminal-browser version: ${commandFailureReason(result)}`, ); } - if (plugin.enabled !== true) { - throw new Error(`${BROWSER_PLUGIN_ID} is installed but disabled`); - } - if (typeof plugin.plugin_root !== "string" || !isAbsolute(plugin.plugin_root)) { - throw new Error(`${BROWSER_PLUGIN_ID} returned an invalid plugin root`); - } - return plugin.plugin_root; + return result.stdout.trim(); } export async function resolveExecutableCandidates( @@ -199,6 +190,15 @@ export async function resolveExecutableCandidates( return null; } +async function cachedConfig( + options: ResolveRuntimeOptions, +): Promise { + const env = options.env ?? process.env; + return options.cached === undefined + ? await readIntegrationConfig(integrationConfigPath(env)).catch(() => null) + : options.cached; +} + async function findOnPath( name: string, env: NodeJS.ProcessEnv, @@ -215,24 +215,6 @@ async function findOnPath( return null; } -async function validateBrowserRoot(rawRoot: string): Promise { - const root = await realpath(rawRoot); - const manifest = join(root, "herdr-plugin.toml"); - const manifestText = await readFile(manifest, "utf8"); - if (!/^\s*id\s*=\s*["']official\.browser["']\s*$/m.test(manifestText)) { - throw new Error(`${rawRoot} is not the ${BROWSER_PLUGIN_ID} plugin root`); - } - const viewer = await readFile(join(root, "src", "viewer.ts"), "utf8"); - // Browser kept version 0.1.0 when initial URL support was added, so inspect - // the installed viewer for the capability instead of comparing versions. - if (!viewer.includes(BROWSER_INITIAL_URL_ENV)) { - throw new Error( - `${BROWSER_PLUGIN_ID} is too old; reinstall it to add ${BROWSER_INITIAL_URL_ENV}`, - ); - } - return root; -} - function parseSemver(version: string): [number, number, number] | null { const match = version.match(/^(\d+)\.(\d+)\.(\d+)/); if (!match) { diff --git a/src/integration-config.ts b/src/integration-config.ts index 0f480cc..2748ec2 100644 --- a/src/integration-config.ts +++ b/src/integration-config.ts @@ -16,6 +16,7 @@ export type IntegrationConfig = { schema: typeof INTEGRATION_CONFIG_SCHEMA; owner: typeof PLUGIN_ID; herdrBin: string; + terminalBrowserBin: string; configuredAt: string; }; @@ -54,6 +55,7 @@ function isIntegrationConfig(value: unknown): value is IntegrationConfig { value.schema === INTEGRATION_CONFIG_SCHEMA && value.owner === PLUGIN_ID && typeof value.herdrBin === "string" + && typeof value.terminalBrowserBin === "string" && typeof value.configuredAt === "string" ); } diff --git a/src/presenter.ts b/src/presenter.ts index 6e5301d..d8aa463 100644 --- a/src/presenter.ts +++ b/src/presenter.ts @@ -1,5 +1,6 @@ import { MAX_PROTOCOL_BYTES, + PRESENT_DEADLINE_MS, MIN_HERDR_VERSION, PRESENTER_PROTOCOL, } from "./constants.ts"; @@ -30,7 +31,12 @@ export async function runPresenter( const request = parsePresenterRequest(await inputReader()); requireHerdrContext(request.action); if (request.action === "present") { - const runtime = await resolveRuntime({ signal: options.signal }); + // A presentation that outlives Plannotator's deadline is worse than a + // failed one: Plannotator would never dismiss the orphaned pane. + const signal = options.signal + ? AbortSignal.any([options.signal, AbortSignal.timeout(PRESENT_DEADLINE_MS)]) + : AbortSignal.timeout(PRESENT_DEADLINE_MS); + const runtime = await resolveRuntime({ signal }); if (!herdrVersionSupported(runtime.herdrVersion)) { throw new PresenterProtocolError( "unsupported_herdr", @@ -38,12 +44,13 @@ export async function runPresenter( ); } const handle = await openBrowserPane( + runtime.terminalBrowserBin, runtime.herdrBin, request.url, process.env.HERDR_PANE_ID!.trim(), { env: process.env, - signal: options.signal, + signal, }, ); outputWriter({