agello 0.1.1 → 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 +10 -7
- package/bin/agello.ts +71 -23
- package/package.json +1 -1
- package/src/screencast.ts +23 -6
- package/src/server.ts +17 -1
- package/web/agent-bridge.js +7 -0
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
|
|
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
|
|
66
|
-
| `agello open [--port N]` | Open the page
|
|
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]
|
|
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]
|
|
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"
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 =
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
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
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
package/web/agent-bridge.js
CHANGED
|
@@ -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 = `
|
|
@@ -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");
|