agello 0.1.0 → 0.1.4

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
@@ -38,16 +38,19 @@ Run inside the herdr pane where the agent is running (`$HERDR_PANE_ID` is picked
38
38
 
39
39
  ```sh
40
40
  agello start --open # start in the background and open the page
41
- agello status # list running servers
42
- agello stop # stop
41
+ agello status # list running servers (pane, agent state, screen browser)
42
+ agello stop # stop this pane's server
43
43
  ```
44
44
 
45
45
  Or target another pane:
46
46
 
47
47
  ```sh
48
- agello start --pane w1:p2 --port 8766
48
+ agello start --pane w1:p2
49
+ agello stop --pane w1:p2
49
50
  ```
50
51
 
52
+ One server per pane. `start` reuses the pane's server if it is already running, and otherwise picks the first free port from 8765 (so servers for different panes never collide). The screen panel only uses the terminal-browser given with `--browser` or the one in the agent's own herdr tab, never another tab's.
53
+
51
54
  Messages sent from the page arrive in the agent as:
52
55
 
53
56
  ```
@@ -61,11 +64,11 @@ The approve / reject buttons send `action=approve` / `action=reject`.
61
64
  | Command | Description |
62
65
  |---|---|
63
66
  | `agello start [options]` | Start a server in the background |
64
- | `agello stop [--port N \| --all]` | Stop a server |
65
- | `agello status [--json]` | List running servers and agent state |
66
- | `agello open [--port N]` | Open the page in the default browser |
67
+ | `agello stop [--port N \| --pane ID \| --all]` | Stop a server (default: this pane's) |
68
+ | `agello status [--json]` | List running servers, agent state and screen browser |
69
+ | `agello open [--port N \| --pane ID]` | Open the page (default: this pane's server) |
67
70
 
68
- `start` options: `--pane <id>`, `--port <n>` (default 8765), `--session <id>`, `--browser <terminal-browser key>`, `--allow-origin <origin>` (repeatable), `--open`, `--foreground`.
71
+ `start` options: `--pane <id>`, `--port <n>` (default: first free from 8765), `--session <id>`, `--browser <terminal-browser key>`, `--allow-origin <origin>` (repeatable), `--open`, `--foreground`.
69
72
 
70
73
  State and logs: `~/.local/state/agello/<port>.json`, `<port>.log`.
71
74
 
package/bin/agello.ts CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { spawn } from "node:child_process";
5
5
  import { existsSync, mkdirSync, openSync, readdirSync, rmSync } from "node:fs";
6
+ import { createServer } from "node:net";
6
7
  import { homedir } from "node:os";
7
8
  import { join } from "node:path";
8
9
  import { parseArgs } from "node:util";
@@ -11,22 +12,23 @@ import { startServer } from "../src/server.ts";
11
12
 
12
13
  const NAME = "agello";
13
14
  const DEFAULT_PORT = 8765;
15
+ const PORT_RANGE_END = 8799; // auto-picked ports: DEFAULT_PORT..PORT_RANGE_END
14
16
  const STATE_DIR = join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), NAME);
15
17
 
16
18
  const HELP = `${NAME} ${pkg.version}: talk to a coding agent in a herdr pane from the browser
17
19
 
18
20
  Usage:
19
- ${NAME} start [options] Start a bridge server in the background
20
- ${NAME} stop [--port N|--all] Stop a running server
21
+ ${NAME} start [options] Start a bridge server for a pane in the background
22
+ ${NAME} stop [--port N|--pane ID|--all] Stop a server (default: this pane's)
21
23
  ${NAME} status [--json] List running servers
22
- ${NAME} open [--port N] Open the bridge page in the default browser
24
+ ${NAME} open [--port N|--pane ID] Open the page (default: this pane's server)
23
25
  ${NAME} help | --version
24
26
 
25
27
  start options:
26
28
  --pane <id> herdr pane of the agent (default: $HERDR_PANE_ID)
27
- --port <n> port (default: ${DEFAULT_PORT})
29
+ --port <n> port (default: first free port from ${DEFAULT_PORT})
28
30
  --session <id> expected agent session id (default: session seen at start)
29
- --browser <key> terminal-browser key for the screen panel (default: same herdr tab)
31
+ --browser <key> terminal-browser key for the screen panel (default: the one in the same herdr tab)
30
32
  --allow-origin <o> extra allowed origin for embedding, repeatable ("null" for file://)
31
33
  --open open the page after starting
32
34
  --foreground run in this process instead of the background
@@ -40,6 +42,7 @@ type Instance = {
40
42
  port: number;
41
43
  pane: string;
42
44
  session?: string;
45
+ browser?: string; // --browser given at start (unset: same herdr tab)
43
46
  url: string;
44
47
  log: string;
45
48
  startedAt: string;
@@ -89,6 +92,33 @@ async function fetchStatus(url: string): Promise<any | null> {
89
92
  }
90
93
  }
91
94
 
95
+ function portFree(port: number): Promise<boolean> {
96
+ return new Promise((resolve) => {
97
+ const s = createServer();
98
+ s.once("error", () => resolve(false));
99
+ s.listen(port, "127.0.0.1", () => s.close(() => resolve(true)));
100
+ });
101
+ }
102
+
103
+ // Which server a stop/open without --all targets: --port, else --pane,
104
+ // else this pane's ($HERDR_PANE_ID), else the only running one.
105
+ async function resolveTarget(o: { port?: string; pane?: string }): Promise<Instance> {
106
+ const all = await listInstances();
107
+ if (o.port) {
108
+ const i = all.find((x) => x.port === Number(o.port));
109
+ return i ?? fail(`nothing running on port ${o.port}`);
110
+ }
111
+ const pane = o.pane ?? process.env.HERDR_PANE_ID;
112
+ if (pane) {
113
+ const i = all.find((x) => x.pane === pane);
114
+ if (i) return i;
115
+ if (o.pane) fail(`nothing running for pane ${pane}`);
116
+ }
117
+ if (all.length === 1) return all[0];
118
+ if (!all.length) fail(`not running; run \`${NAME} start\``);
119
+ return fail(`several servers running; pass --port or --pane:\n${all.map((i) => ` ${i.url} pane=${i.pane}`).join("\n")}`);
120
+ }
121
+
92
122
  function openUrl(url: string) {
93
123
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
94
124
  Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" });
@@ -106,7 +136,7 @@ async function cmdStart(argv: string[]) {
106
136
  args: argv,
107
137
  options: {
108
138
  pane: { type: "string", default: process.env.HERDR_PANE_ID },
109
- port: { type: "string", default: String(DEFAULT_PORT) },
139
+ port: { type: "string" },
110
140
  session: { type: "string" },
111
141
  browser: { type: "string" },
112
142
  "allow-origin": { type: "string", multiple: true, default: [] },
@@ -114,18 +144,33 @@ async function cmdStart(argv: string[]) {
114
144
  foreground: { type: "boolean", default: false },
115
145
  },
116
146
  });
117
- const port = Number(o.port);
118
147
  if (!o.pane) fail("--pane is required (HERDR_PANE_ID is not set; run inside herdr or pass --pane)");
119
148
  if (!Bun.which("herdr")) fail("herdr not found on PATH");
120
149
  mkdirSync(STATE_DIR, { recursive: true });
150
+ const pane = o.pane!;
121
151
 
122
- const existing = await readInstance(port);
123
- if (existing) {
124
- console.log(`already running: ${existing.url} (pane ${existing.pane}, pid ${existing.pid})`);
125
- if (o.open) openUrl(existing.url);
152
+ // One server per pane: reuse this pane's server, never another pane's.
153
+ const all = await listInstances();
154
+ const mine = all.find((i) => i.pane === pane);
155
+ if (mine) {
156
+ console.log(`already running: ${mine.url} (pane ${mine.pane}, pid ${mine.pid})`);
157
+ if (o.open) openUrl(mine.url);
126
158
  return;
127
159
  }
128
160
 
161
+ let port: number;
162
+ if (o.port) {
163
+ port = Number(o.port);
164
+ const other = all.find((i) => i.port === port);
165
+ if (other) fail(`port ${port} is used by the server for pane ${other.pane} (${other.url}); omit --port to pick a free one`);
166
+ if (!(await portFree(port))) fail(`port ${port} is in use by another program`);
167
+ } else {
168
+ let found = 0;
169
+ for (let p = DEFAULT_PORT; p <= PORT_RANGE_END && !found; p++)
170
+ if (!all.some((i) => i.port === p) && (await portFree(p))) found = p;
171
+ port = found || fail(`no free port in ${DEFAULT_PORT}-${PORT_RANGE_END}; pass --port`);
172
+ }
173
+
129
174
  if (o.foreground) {
130
175
  const srv = await startServer({
131
176
  port,
@@ -139,6 +184,7 @@ async function cmdStart(argv: string[]) {
139
184
  port,
140
185
  pane: srv.pane,
141
186
  session: srv.session,
187
+ browser: o.browser,
142
188
  url: srv.url,
143
189
  log: logPath(port),
144
190
  startedAt: new Date().toISOString(),
@@ -157,7 +203,10 @@ async function cmdStart(argv: string[]) {
157
203
  }
158
204
 
159
205
  // Background: re-run this CLI with --foreground, detached, logging to a file.
160
- const args = argv.filter((a) => a !== "--open");
206
+ const args = ["--pane", pane, "--port", String(port)];
207
+ if (o.session) args.push("--session", o.session);
208
+ if (o.browser) args.push("--browser", o.browser);
209
+ for (const origin of o["allow-origin"] as string[]) args.push("--allow-origin", origin);
161
210
  const log = openSync(logPath(port), "a");
162
211
  const child = spawn(process.execPath, [import.meta.path, "start", "--foreground", ...args], {
163
212
  detached: true,
@@ -171,7 +220,7 @@ async function cmdStart(argv: string[]) {
171
220
  while (Date.now() < deadline) {
172
221
  if (child.exitCode !== null || !isAlive(child.pid!)) fail(`server exited; see ${logPath(port)}`);
173
222
  if ((await readInstance(port)) && (await fetchStatus(url))) {
174
- console.log(`started: ${url} (pane ${o.pane}, pid ${child.pid})`);
223
+ console.log(`started: ${url} (pane ${pane}, pid ${child.pid})`);
175
224
  console.log(`log: ${logPath(port)}`);
176
225
  if (o.open) openUrl(url);
177
226
  return;
@@ -184,11 +233,9 @@ async function cmdStart(argv: string[]) {
184
233
  async function cmdStop(argv: string[]) {
185
234
  const { values: o } = parseArgs({
186
235
  args: argv,
187
- options: { port: { type: "string" }, all: { type: "boolean", default: false } },
236
+ options: { port: { type: "string" }, pane: { type: "string" }, all: { type: "boolean", default: false } },
188
237
  });
189
- const targets = o.all
190
- ? await listInstances()
191
- : [await readInstance(Number(o.port ?? DEFAULT_PORT))].filter((i): i is Instance => !!i);
238
+ const targets = o.all ? await listInstances() : [await resolveTarget(o)];
192
239
  if (!targets.length) {
193
240
  console.log("not running");
194
241
  return;
@@ -219,16 +266,17 @@ async function cmdStatus(argv: string[]) {
219
266
  const a = r.agent;
220
267
  const state = !a ? "unreachable" : a.alive ? a.status : `dead (${a.reason})`;
221
268
  const name = a?.label ? `${a.label} (${r.pane})` : r.pane;
222
- console.log(`${r.url} pane=${name} agent=${state} pid=${r.pid}`);
269
+ const scr = a?.screen;
270
+ const browser = scr?.browser ?? (scr?.reason ? `none (${scr.reason})` : "unknown");
271
+ console.log(`${r.url} pane=${name} agent=${state} browser=${browser} pid=${r.pid}`);
223
272
  }
224
273
  }
225
274
 
226
275
  async function cmdOpen(argv: string[]) {
227
- const { values: o } = parseArgs({ args: argv, options: { port: { type: "string" } } });
228
- const inst = await readInstance(Number(o.port ?? DEFAULT_PORT));
229
- if (!inst) fail(`not running on port ${o.port ?? DEFAULT_PORT}; run \`${NAME} start\``);
230
- openUrl(inst!.url);
231
- console.log(inst!.url);
276
+ const { values: o } = parseArgs({ args: argv, options: { port: { type: "string" }, pane: { type: "string" } } });
277
+ const inst = await resolveTarget(o);
278
+ openUrl(inst.url);
279
+ console.log(inst.url);
232
280
  }
233
281
 
234
282
  // ---------- main ----------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agello",
3
- "version": "0.1.0",
3
+ "version": "0.1.4",
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",
package/src/screencast.ts CHANGED
@@ -16,6 +16,7 @@ type Browser = {
16
16
 
17
17
  export type ScreenMeta = {
18
18
  connected: boolean;
19
+ reason?: "browser_not_found" | "no_browser_in_tab" | "tab_unknown" | "no_active_tab";
19
20
  browser?: string;
20
21
  url?: string;
21
22
  title?: string;
@@ -53,11 +54,20 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
53
54
  return (await runJson(["terminal-browser", "ls", "--all", "--json"], 3000))?.browsers ?? [];
54
55
  }
55
56
 
56
- async function pickBrowser(): Promise<Browser | undefined> {
57
+ // Only ever relay (and forward input to) the intended browser: the one given
58
+ // with --browser, or one in the agent's own herdr tab. Never fall back to
59
+ // another tab's browser: that would expose another agent's screen and let
60
+ // "my control" type into it.
61
+ async function pickBrowser(): Promise<{ browser?: Browser; reason?: ScreenMeta["reason"] }> {
57
62
  const all = await listBrowsers();
58
- if (opts.browserKey) return all.find((b) => b.key === opts.browserKey);
63
+ if (opts.browserKey) {
64
+ const browser = all.find((b) => b.key === opts.browserKey);
65
+ return browser ? { browser } : { reason: "browser_not_found" };
66
+ }
59
67
  const tab = await opts.herdrTab?.();
60
- return all.find((b) => b.pane?.tab === tab) ?? all[0];
68
+ if (!tab) return { reason: "tab_unknown" };
69
+ const browser = all.find((b) => b.pane?.tab === tab);
70
+ return browser ? { browser } : { reason: "no_browser_in_tab" };
61
71
  }
62
72
 
63
73
  function disconnect() {
@@ -107,12 +117,12 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
107
117
  }
108
118
 
109
119
  async function syncOnce() {
110
- const b = await pickBrowser();
120
+ const { browser: b, reason } = await pickBrowser();
111
121
  const tab = b?.tabs.find((t) => t.active);
112
122
  if (!b || !tab) {
113
123
  disconnect();
114
124
  lastFrame = null;
115
- setMeta({ connected: false });
125
+ setMeta({ connected: false, reason: reason ?? "no_active_tab" });
116
126
  return;
117
127
  }
118
128
  const url = `ws://127.0.0.1:${b.cdpPort}/devtools/page/${tab.targetId}`;
@@ -175,5 +185,12 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
175
185
  ws.send(JSON.stringify({ id: ++msgId, method: m.method, params: m.params ?? {} }));
176
186
  }
177
187
 
178
- return { handle, input };
188
+ // Which browser the relay uses (or would use), for status reporting.
189
+ async function describe(): Promise<ScreenMeta> {
190
+ if (meta.connected) return meta;
191
+ const { browser, reason } = await pickBrowser();
192
+ return browser ? { connected: false, browser: browser.key } : { connected: false, reason };
193
+ }
194
+
195
+ return { handle, input, describe };
179
196
  }
package/src/server.ts CHANGED
@@ -58,6 +58,20 @@ function toolSummary(input: any): string {
58
58
  return text.length > 80 ? text.slice(0, 80) + "…" : text;
59
59
  }
60
60
 
61
+ // Some models emit user-facing narration as a `thinking` block whose signature
62
+ // carries a "narration" marker. Claude Code renders these like normal replies,
63
+ // so treat them as assistant text. Plain (unmarked) thinking stays hidden.
64
+ // The marker is an undocumented detail of the signature; if it changes, these
65
+ // blocks are simply not shown (same as before).
66
+ export function isNarration(b: any): boolean {
67
+ if (b?.type !== "thinking" || typeof b.thinking !== "string" || !b.thinking.trim()) return false;
68
+ try {
69
+ return Buffer.from(String(b.signature ?? "").slice(0, 200), "base64").includes("narration");
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
61
75
  // Every herdr call is killed after `timeout` ms so a hung herdr cannot pile up processes.
62
76
  async function herdr(timeout: number, ...cmd: string[]): Promise<any> {
63
77
  return (await runJson(["herdr", ...cmd], timeout)) ?? { error: { code: "herdr_failed" } };
@@ -229,6 +243,8 @@ export async function startServer(opts: ServerOptions) {
229
243
  for (const b of content) {
230
244
  if (b.type === "text" && b.text.trim()) {
231
245
  broadcast("message", { role: "assistant", text: b.text, ts: d.timestamp });
246
+ } else if (isNarration(b)) {
247
+ broadcast("message", { role: "assistant", text: b.thinking, ts: d.timestamp, narration: true });
232
248
  } else if (b.type === "tool_use") {
233
249
  const t = { id: b.id, name: b.name, summary: toolSummary(b.input), ts: d.timestamp };
234
250
  pendingTools.set(b.id, t);
@@ -341,7 +357,7 @@ export async function startServer(opts: ServerOptions) {
341
357
  async function route(req: Request, url: URL): Promise<Response> {
342
358
  switch (url.pathname) {
343
359
  case "/status":
344
- return Response.json(await checkStatus());
360
+ return Response.json({ ...(await checkStatus()), browser: opts.browser, screen: await screen.describe() });
345
361
  case "/events":
346
362
  return events();
347
363
  case "/screen":
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // Attributes
8
8
  // server bridge server origin (default: origin this script was loaded from)
9
- // heading header text (default: "Agent Bridge")
9
+ // heading header text (default: "agello")
10
10
  // no-header hide header
11
11
  // no-actions hide approve/reject buttons
12
12
  // no-history do not persist chat in localStorage (default: persisted per server + pane)
@@ -47,6 +47,12 @@ const REASONS = {
47
47
  session_changed: "다른 세션으로 교체됨",
48
48
  };
49
49
  const STATES = { idle: "대기 중", working: "작업 중", blocked: "입력 대기", done: "완료", unknown: "상태 불명" };
50
+ const SCREEN_REASONS = {
51
+ browser_not_found: "지정한 terminal-browser(--browser)를 찾을 수 없음",
52
+ no_browser_in_tab: "이 herdr 탭에 열린 terminal-browser 없음",
53
+ tab_unknown: "에이전트의 herdr 탭을 확인할 수 없음",
54
+ no_active_tab: "브라우저에 활성 탭 없음",
55
+ };
50
56
  const ACTIONS = { message: "메시지", approve: "승인", reject: "거절" };
51
57
 
52
58
  const CSS = `
@@ -298,7 +304,7 @@ class AgentBridge extends HTMLElement {
298
304
  }
299
305
 
300
306
  connectedCallback() {
301
- this.$.heading.textContent = this.getAttribute("heading") || "Agent Bridge";
307
+ this.$.heading.textContent = this.getAttribute("heading") || "agello";
302
308
  this.#connect();
303
309
  this.#syncScreen();
304
310
  }
@@ -310,7 +316,7 @@ class AgentBridge extends HTMLElement {
310
316
  this.#setControl(false);
311
317
  }
312
318
  attributeChangedCallback(name) {
313
- if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "Agent Bridge";
319
+ if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
314
320
  if (name === "server" && this.isConnected) { this.#connect(); this.#screenEs?.close(); this.#screenEs = null; this.#syncScreen(); }
315
321
  if (name === "screen" && this.isConnected) this.#syncScreen();
316
322
  }
@@ -519,6 +525,7 @@ class AgentBridge extends HTMLElement {
519
525
  #renderScreenMeta(m) {
520
526
  const { live, url, viewport } = this.$;
521
527
  if (!m.connected) {
528
+ this.$.viewport.querySelector(".placeholder").textContent = SCREEN_REASONS[m.reason] || "열린 terminal-browser 없음";
522
529
  live.hidden = true;
523
530
  url.textContent = "";
524
531
  viewport.classList.remove("has", "agent");
@@ -2,7 +2,7 @@
2
2
  <html lang="ko">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
- <title>Agent Bridge 임베드 예시</title>
5
+ <title>agello 임베드 예시</title>
6
6
  <script type="module" src="http://127.0.0.1:8765/agent-bridge.js"></script>
7
7
  <style>
8
8
  body { font-family: system-ui, sans-serif; margin: 0; display: grid; grid-template-columns: 1fr 420px;
package/web/index.html CHANGED
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Agent Bridge</title>
6
+ <title>agello</title>
7
7
  <script type="module" src="/agent-bridge.js"></script>
8
8
  <style>
9
9
  html, body { height: 100%; margin: 0; }