agello 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,16 +5,16 @@
5
5
  - Chat with the agent from a web page; terminal input, browser input and agent replies are shown as separate bubbles
6
6
  - Live session status, tool activity, and prompts queued while the agent is busy (same order as the terminal)
7
7
  - Conversation saved per pane in the browser, kept across reloads
8
+ - Interactive terminal view of the existing herdr pane: live cursor, keyboard input, and automatic resize
8
9
  - Optional live view of a `terminal-browser` screen, with an on/off switch to control it yourself
9
- - Annotate: point at an element on that screen and send a request about it, with its CSS selector
10
10
  - Embeddable `<agent-bridge>` web component
11
11
 
12
- Currently supports **Claude Code** sessions.
12
+ Chat currently supports **Claude Code** sessions. The terminal view connects directly to the pane, including ordinary shells and other terminal applications.
13
13
 
14
14
  ## Requirements
15
15
 
16
16
  - [Bun](https://bun.sh) >= 1.1
17
- - [herdr](https://herdr.dev) (the agent must run inside a herdr pane)
17
+ - [herdr](https://herdr.dev) (the agent must run inside a herdr pane); interactive terminal view requires `herdr terminal session control`
18
18
  - Optional: `terminal-browser` for the screen panel
19
19
 
20
20
  ## Install
@@ -60,18 +60,6 @@ Messages sent from the page arrive in the agent as:
60
60
 
61
61
  The approve / reject buttons send `action=approve` / `action=reject`.
62
62
 
63
- ### Annotate
64
-
65
- The `주석` button on the screen panel turns on annotate mode: hovering tints the element under the pointer (with its tag and size), clicking outlines it and opens an input next to it. The wheel still scrolls the page, and the outline follows the element as the page changes. The request arrives as:
66
-
67
- ```
68
- [browser] action=annotate make this red / and bolder
69
- 대상: #app > main > button:nth-of-type(2)
70
- 요소: button.primary "Save" · 위치 120,340 크기 96x32 · http://localhost:3000/
71
- ```
72
-
73
- Selector: nearest unique `id` / `data-testid` ancestor, then `tag:nth-of-type` steps (light DOM only). Coordinates are CSS px of the viewport. Off while presenting and while "내 조작" is on.
74
-
75
63
  ### Commands
76
64
 
77
65
  | Command | Description |
@@ -82,13 +70,25 @@ Selector: nearest unique `id` / `data-testid` ancestor, then `tag:nth-of-type` s
82
70
  | `agello open [--port N \| --pane ID]` | Open the page (default: this pane's server) |
83
71
  | `agello present [x,y,w,h] [--port N \| --pane ID]` | Presentation mode (see below) |
84
72
  | `agello present stop` | End presentation mode |
85
- | `agello script load <file> \| show` | Load / show a presentation script |
86
- | `agello present resume \| pause \| goto <n[.m]>` | Play the script from where it stopped / stop it / move to a step |
87
73
 
88
74
  `start` options: `--pane <id>`, `--port <n>` (default: first free from 8765), `--session <id>`, `--browser <terminal-browser key>`, `--allow-origin <origin>` (repeatable), `--open`, `--foreground`.
89
75
 
90
76
  State and logs: `~/.local/state/agello/<port>.json`, `<port>.log`.
91
77
 
78
+ ### Pane picker
79
+
80
+ Click the status in the header to open a drawer with herdr's workspace -> tab -> pane tree (the current pane's workspace is expanded; a workspace with a single tab lists its panes directly). Search filters by workspace, title, folder, or agent. Picking a pane switches the page to that pane's server, starting it if needed (`?server=` keeps the choice across reloads). Panes without Claude Code open as a terminal, since chat supports Claude Code only.
81
+
82
+ `+` adds a workspace (top), a tab (workspace row), or splits a pane (pane row); new panes open as a terminal. Nothing can be closed from the page: closing a herdr pane ends its processes.
83
+
84
+ ### Interactive terminal
85
+
86
+ Click the terminal icon in the header to replace the chat with the pane's live terminal (the screen panel stays). Keyboard input (including Korean text, arrow keys, and Ctrl+C) goes directly to that terminal. The viewport follows the browser size, and the mouse wheel scrolls through Herdr. Click the icon again or close the page to release control; the pane and its process keep running. If disconnected, use **다시 연결** to restore the current screen.
87
+
88
+ Only one browser connection can control a pane through this server at a time. Agello never forcibly takes over another Herdr controller. While connected, resizing the browser also changes the source terminal size. Terminal input is raw input, without the chat's `[browser]` prefix.
89
+
90
+ The xterm.js renderer and its styles are served locally; no terminal CDN is required. Herdr sends an initial ANSI screen and subsequent frame updates over the WebSocket, including cursor state. Terminal frames are not stored in browser chat history.
91
+
92
92
  ### Presentation mode
93
93
 
94
94
  `agello present x,y,w,h` (CSS px of the browser's visible viewport; omit for the whole screen):
@@ -102,23 +102,6 @@ State and logs: `~/.local/state/agello/<port>.json`, `<port>.log`.
102
102
 
103
103
  Run `present` again to move the crop. User control is off while presenting.
104
104
 
105
- #### Scripts
106
-
107
- Write the talk ahead so each line appears the moment its screen does:
108
-
109
- ```json
110
- { "rect": "28,157,688,477",
111
- "steps": [
112
- { "go": "#1", "say": ["First line", "Second line"] },
113
- { "go": "#2", "say": ["..."], "hold": 6 } ] }
114
- ```
115
-
116
- - step: `go` brings the screen there (`#n` sets `location.hash`, anything else is JS run in the page; agello knows nothing about the deck), `say` lines are one bubble each, `hold` seconds a line stays on screen (default from length, 10 to 20s; never below 10s). Pacing: screen change, 1s, line, fade out, 0.3s, next line; after a step's last line 0.7s before the next screen
117
- - `present resume` plays from where it stopped (starts presentation mode with the script's `rect`); it first re-runs the current step's `go`, so the agent may move the screen freely while answering
118
- - raising a hand pauses at once; the agent gets `action=hand-raise` with the position (`마지막 표시 2.1 · 다음 2.2`)
119
- - `script load` again after editing keeps the position; `present goto 2.2` then `present resume` replays from a fixed line
120
- - at the end the agent gets `action=present-done`; while the script is paused and the agent is working, the page shows a small loader
121
-
122
105
  ## Embed
123
106
 
124
107
  ```html
@@ -142,11 +125,17 @@ See `web/embed-example.html`.
142
125
  - **Send**: `herdr agent prompt <pane> "<text>"` (kept to 3 lines so Claude Code does not treat it as a paste)
143
126
  - **Status**: `herdr agent get` / `herdr pane get`, polled every 1.5s
144
127
  - **Chat**: tails the Claude Code transcript (`$CLAUDE_CONFIG_DIR/projects/*/<session>.jsonl`) and streams it over SSE
128
+ - **Panes**: `GET /panes` (`herdr workspace/tab/pane list`), `POST /panes/connect` (`agello start --pane`), `POST /panes/create` (`herdr workspace create`, `tab create`, `pane split`, always `--no-focus`)
129
+ - **Terminal**: `/terminal` WebSocket relays `herdr terminal session control <pane>` ANSI frames and validated input, resize, and scroll commands to xterm.js
145
130
  - **Screen**: finds the terminal-browser CDP port via `terminal-browser ls --json` and relays `Page.startScreencast` frames; user control forwards `Input.*` events only
146
131
 
147
132
  ## Security
148
133
 
149
- The server listens on `127.0.0.1` only. Data and input endpoints accept requests from the same origin and `localhost` pages only; other origins get `403` unless added with `--allow-origin`. Anything allowed to post to `/send` can type into your agent session, so keep the allow list short.
134
+ The server listens on `127.0.0.1` only. Data and input endpoints accept requests from the same origin and `localhost` pages only; other origins get `403` unless added with `--allow-origin`. Allowed origins can send chat prompts and connect to `/terminal` to type directly into the pane, so keep the allow list short.
135
+
136
+ ## Development
137
+
138
+ Run `bun install` and `bun test`. Tests cover the pane tree and creation (against a mock herdr), local terminal assets, origin rejection, ANSI frames, keyboard bytes, resize, exclusive control, and reconnect.
150
139
 
151
140
  ## License
152
141
 
package/bin/agello.ts CHANGED
@@ -27,13 +27,6 @@ Usage:
27
27
  whole screen), with agent replies as bubbles that fade after 10s.
28
28
  Run again to move the crop.
29
29
  ${NAME} present stop [--port N|--pane ID] End presentation mode (bubbles stay in the chat)
30
- ${NAME} script load <file.json> Load a presentation script (keeps the current position):
31
- {"rect": "x,y,w,h", "steps": [{"go": "#1", "say": ["line", ...], "hold": 6}]}
32
- go: "#n" sets location.hash, otherwise JS run in the page; one line = one bubble
33
- ${NAME} script show Script with positions (step.line) and player state
34
- ${NAME} present resume Play the script from where it stopped (starts presentation mode)
35
- ${NAME} present pause Stop the script (raising a hand on the page also pauses)
36
- ${NAME} present goto <n[.m]> Move to step n (line m) and show its screen
37
30
  ${NAME} help | --version
38
31
 
39
32
  start options:
@@ -291,56 +284,6 @@ async function cmdOpen(argv: string[]) {
291
284
  console.log(inst.url);
292
285
  }
293
286
 
294
- async function api(url: string, path: string, body?: object): Promise<any> {
295
- try {
296
- const r = await fetch(`${url}${path}`, {
297
- method: body ? "POST" : "GET",
298
- headers: { "Content-Type": "application/json" },
299
- body: body ? JSON.stringify(body) : undefined,
300
- signal: AbortSignal.timeout(8000),
301
- });
302
- return await r.json();
303
- } catch {
304
- return fail(`server unreachable: ${url}`);
305
- }
306
- }
307
-
308
- const playerLine = (p: any) =>
309
- `script: ${p.state}${p.last ? ` last=${p.last}` : ""}${p.next ? ` next=${p.next}` : ""} steps=${p.steps}`;
310
-
311
- async function cmdScript(argv: string[]) {
312
- const { values: o, positionals } = parseArgs({
313
- args: argv,
314
- options: { port: { type: "string" }, pane: { type: "string" } },
315
- allowPositionals: true,
316
- });
317
- const [sub, file] = positionals;
318
- const inst = await resolveTarget(o);
319
- if (sub === "load") {
320
- if (!file) fail("usage: script load <file.json>");
321
- let script: unknown;
322
- try {
323
- script = await Bun.file(file!).json();
324
- } catch (e) {
325
- fail(`cannot read ${file}: ${(e as Error).message}`);
326
- }
327
- const res = await api(inst.url, "/script", { script });
328
- if (!res.ok) fail(res.error);
329
- console.log(playerLine(res.player));
330
- } else if (sub === "show" || !sub) {
331
- const res = await api(inst.url, "/script");
332
- console.log(playerLine(res.player));
333
- for (const [i, st] of (res.script?.steps ?? []).entries()) {
334
- console.log(`${i + 1}${st.go ? ` go=${st.go}` : ""}${st.hold ? ` hold=${st.hold}s` : ""}`);
335
- st.say.forEach((l: string, j: number) => {
336
- const at = `${i + 1}.${j + 1}`;
337
- const mark = at === res.player.next ? ">" : at === res.player.last ? "*" : " ";
338
- console.log(` ${mark} ${at} ${l}`);
339
- });
340
- }
341
- } else fail(`unknown script command: ${sub}`);
342
- }
343
-
344
287
  async function cmdPresent(argv: string[]) {
345
288
  const { values: o, positionals } = parseArgs({
346
289
  args: argv,
@@ -348,14 +291,6 @@ async function cmdPresent(argv: string[]) {
348
291
  allowPositionals: true,
349
292
  });
350
293
  const inst = await resolveTarget(o);
351
- const [sub, at] = positionals;
352
- if (sub === "resume" || sub === "pause" || sub === "goto") {
353
- if (sub === "goto" && !at) fail("usage: present goto <n[.m]>");
354
- const res = await api(inst.url, "/player", { cmd: sub, at });
355
- if (!res.ok) fail(`${res.error}${res.player ? ` (${playerLine(res.player)})` : ""}`);
356
- console.log(playerLine(res.player));
357
- return;
358
- }
359
294
  const arg = positionals.join(",");
360
295
  let body: object;
361
296
  if (arg === "stop") body = { stop: true };
@@ -402,9 +337,6 @@ switch (cmd) {
402
337
  case "present":
403
338
  await cmdPresent(rest);
404
339
  break;
405
- case "script":
406
- await cmdScript(rest);
407
- break;
408
340
  case "--version":
409
341
  case "-v":
410
342
  console.log(pkg.version);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agello",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Talk to a coding agent in a herdr pane from the browser: chat, live status, and a shared browser screen.",
5
5
  "keywords": [
6
6
  "herdr",
@@ -31,5 +31,9 @@
31
31
  "engines": {
32
32
  "bun": ">=1.1"
33
33
  },
34
- "license": "MIT"
34
+ "license": "MIT",
35
+ "dependencies": {
36
+ "@xterm/addon-fit": "0.11.0",
37
+ "@xterm/xterm": "6.0.0"
38
+ }
35
39
  }
package/src/panes.ts ADDED
@@ -0,0 +1,86 @@
1
+ // herdr workspace -> tab -> pane tree, creation, and switching to a pane's own
2
+ // agello server (one server per pane; started on demand through the CLI).
3
+ // Deliberately no close/delete: closing a herdr pane kills its processes.
4
+
5
+ import { join } from "node:path";
6
+ import { run, runJson } from "./run.ts";
7
+
8
+ const CLI = join(import.meta.dir, "..", "bin", "agello.ts");
9
+ const ID = /^[A-Za-z0-9]+:[A-Za-z0-9]+$/; // herdr ids: w23, w23:t1, w23:p3
10
+ const WS_ID = /^[A-Za-z0-9]+$/;
11
+
12
+ // Resolve herdr on the current PATH (Bun.spawn uses the PATH from process start).
13
+ const bin = () => Bun.which("herdr", { PATH: process.env.PATH }) ?? "herdr";
14
+ const herdr = async (...cmd: string[]) => (await runJson([bin(), ...cmd], 5000)) ?? { error: { code: "herdr_failed" } };
15
+
16
+ export type TreePane = { id: string; agent?: string; status: string; title?: string; cwd?: string; focused: boolean };
17
+ export type TreeTab = { id: string; label: string; number: number; panes: TreePane[] };
18
+ export type TreeWorkspace = { id: string; label: string; number: number; tabs: TreeTab[] };
19
+
20
+ export async function paneTree(): Promise<TreeWorkspace[] | null> {
21
+ const [w, t, p] = await Promise.all([herdr("workspace", "list"), herdr("tab", "list"), herdr("pane", "list")]);
22
+ if (!w.result || !t.result || !p.result) return null;
23
+ const tabs = new Map<string, TreeTab>();
24
+ for (const tab of t.result.tabs)
25
+ tabs.set(tab.tab_id, { id: tab.tab_id, label: tab.label, number: tab.number, panes: [] });
26
+ for (const pane of p.result.panes)
27
+ tabs.get(pane.tab_id)?.panes.push({
28
+ id: pane.pane_id,
29
+ agent: pane.agent,
30
+ status: pane.agent_status,
31
+ title: pane.terminal_title_stripped || undefined,
32
+ cwd: pane.foreground_cwd || pane.cwd,
33
+ focused: pane.focused,
34
+ });
35
+ return w.result.workspaces.map((ws: any) => ({
36
+ id: ws.workspace_id,
37
+ label: ws.label,
38
+ number: ws.number,
39
+ tabs: t.result.tabs
40
+ .filter((tab: any) => tab.workspace_id === ws.workspace_id)
41
+ .map((tab: any) => tabs.get(tab.tab_id)!),
42
+ }));
43
+ }
44
+
45
+ const text = (v: unknown, max = 200) => (typeof v === "string" && v.trim() && v.length <= max ? v.trim() : undefined);
46
+ const fail = (error: string, status = 400) => Response.json({ ok: false, error }, { status });
47
+
48
+ // POST /panes/connect {pane} -> {ok, url}: the pane's server, started if needed.
49
+ async function connect(pane: unknown): Promise<Response> {
50
+ if (typeof pane !== "string" || !ID.test(pane)) return fail("invalid_pane");
51
+ if (!(await herdr("pane", "get", pane)).result) return fail("pane_not_found", 404);
52
+ const out = await run([process.execPath, CLI, "start", "--pane", pane], 20000);
53
+ const url = out?.match(/https?:\/\/127\.0\.0\.1:\d+/)?.[0];
54
+ return url ? Response.json({ ok: true, pane, url }) : fail("server_start_failed", 502);
55
+ }
56
+
57
+ // POST /panes/create {kind: workspace|tab|pane, ...} -> {ok, pane}: the new root pane.
58
+ async function create(d: any): Promise<Response> {
59
+ const cwd = text(d.cwd, 1024);
60
+ if (cwd && !cwd.startsWith("/")) return fail("invalid_cwd");
61
+ const label = text(d.label, 80);
62
+ const opt = [...(cwd ? ["--cwd", cwd] : []), ...(label ? ["--label", label] : []), "--no-focus"];
63
+ let res: any;
64
+ if (d.kind === "workspace") res = await herdr("workspace", "create", ...opt);
65
+ else if (d.kind === "tab") {
66
+ if (typeof d.workspace !== "string" || !WS_ID.test(d.workspace)) return fail("invalid_workspace");
67
+ res = await herdr("tab", "create", "--workspace", d.workspace, ...opt);
68
+ } else if (d.kind === "pane") {
69
+ if (typeof d.pane !== "string" || !ID.test(d.pane)) return fail("invalid_pane");
70
+ if (d.direction !== "right" && d.direction !== "down") return fail("invalid_direction");
71
+ res = await herdr("pane", "split", d.pane, "--direction", d.direction, ...(cwd ? ["--cwd", cwd] : []), "--no-focus");
72
+ } else return fail("invalid_kind");
73
+ const pane = res.result?.root_pane?.pane_id ?? res.result?.pane?.pane_id;
74
+ return pane ? Response.json({ ok: true, pane }) : fail(res.error?.code ?? "herdr_failed", 502);
75
+ }
76
+
77
+ export async function panesRoute(req: Request, path: string, current: string): Promise<Response | null> {
78
+ if (path === "/panes" && req.method === "GET") {
79
+ const tree = await paneTree();
80
+ return tree ? Response.json({ ok: true, current, workspaces: tree }) : fail("herdr_failed", 502);
81
+ }
82
+ if (req.method !== "POST" || (path !== "/panes/connect" && path !== "/panes/create")) return null;
83
+ const d = (await req.json().catch(() => null)) as any;
84
+ if (!d || typeof d !== "object") return fail("invalid_body");
85
+ return path === "/panes/connect" ? connect(d.pane) : create(d);
86
+ }
package/src/screencast.ts CHANGED
@@ -268,75 +268,5 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
268
268
  return browser ? { connected: false, browser: browser.key } : { connected: false, reason };
269
269
  }
270
270
 
271
- // Run JS in the relayed tab (presentation script `go`). Needs the relay to be
272
- // connected, i.e. at least one viewer. Returns false if it could not run.
273
- async function evaluate(expression: string): Promise<boolean> {
274
- const r = await request("Runtime.evaluate", { expression, awaitPromise: true });
275
- return !!r && !r.exceptionDetails;
276
- }
277
-
278
- // Annotation: which element is at a point (hover / click) or where a
279
- // selected element is now (by selector). Coordinates are CSS px of the
280
- // visible viewport, same as the full screencast frame. Not while cropped.
281
- async function inspect(q: InspectQuery): Promise<Inspected | null> {
282
- if (clip) return null;
283
- const r = await request("Runtime.evaluate", {
284
- expression: `(${INSPECT_JS})(${JSON.stringify(q)})`,
285
- returnByValue: true,
286
- });
287
- return r && !r.exceptionDetails ? (r.result?.value ?? null) : null;
288
- }
289
-
290
- return { handle, input, describe, setClip, evaluate, inspect };
271
+ return { handle, input, describe, setClip };
291
272
  }
292
-
293
- export type InspectQuery = { x?: number; y?: number; selector?: string; full?: boolean };
294
- export type Inspected = {
295
- selector: string;
296
- label: string; // tag#id.class, for the hover tooltip
297
- rect: { x: number; y: number; w: number; h: number };
298
- text?: string; // full only
299
- url?: string; // full only
300
- };
301
-
302
- // Runs in the page. Selector: nearest unique id / data-testid ancestor, then
303
- // tag:nth-of-type steps down to the element (light DOM only).
304
- const INSPECT_JS = String((q: InspectQuery) => {
305
- let el: Element | null = null;
306
- if (q.selector) {
307
- try {
308
- el = document.querySelector(q.selector);
309
- } catch {}
310
- } else el = document.elementFromPoint(q.x ?? 0, q.y ?? 0);
311
- if (!el || el === document.documentElement) return null;
312
- const esc = CSS.escape;
313
- const unique = (s: string) => document.querySelectorAll(s).length === 1;
314
- const step = (e: Element): [string, boolean] => {
315
- if (e.id && unique(`#${esc(e.id)}`)) return [`#${esc(e.id)}`, true];
316
- for (const a of ["data-testid", "data-test", "data-cy"]) {
317
- const v = e.getAttribute(a);
318
- if (v && unique(`[${a}="${esc(v)}"]`)) return [`[${a}="${esc(v)}"]`, true];
319
- }
320
- const same = e.parentElement ? [...e.parentElement.children].filter((c) => c.localName === e.localName) : [];
321
- return [same.length > 1 ? `${e.localName}:nth-of-type(${same.indexOf(e) + 1})` : e.localName, false];
322
- };
323
- const parts: string[] = [];
324
- for (let e: Element | null = el; e && e !== document.documentElement; e = e.parentElement) {
325
- const [s, anchor] = step(e);
326
- parts.unshift(s);
327
- if (anchor || e === document.body) break;
328
- }
329
- const cls = [...el.classList].slice(0, 2).map((c) => `.${c}`).join("");
330
- const r = el.getBoundingClientRect();
331
- const out: any = {
332
- selector: parts.join(" > "),
333
- label: `${el.localName}${el.id ? `#${el.id}` : ""}${cls}`,
334
- rect: { x: r.x, y: r.y, w: r.width, h: r.height },
335
- };
336
- if (q.full) {
337
- const t = (el as HTMLInputElement).value || el.getAttribute("aria-label") || el.getAttribute("alt") || el.textContent || "";
338
- out.text = t.replace(/\s+/g, " ").trim().slice(0, 80);
339
- out.url = location.href;
340
- }
341
- return out;
342
- });