Skip to content
Open
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
45 changes: 21 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 <url> --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.
Expand Down
2 changes: 1 addition & 1 deletion herdr-plugin.toml
Original file line number Diff line number Diff line change
@@ -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"]

Expand Down
207 changes: 124 additions & 83 deletions src/browser-pane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,125 +18,166 @@ 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(
"/bin/herdr",
{ 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();
});
});
Loading