agello 0.1.0
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/LICENSE +21 -0
- package/README.md +103 -0
- package/bin/agello.ts +262 -0
- package/package.json +35 -0
- package/src/run.ts +28 -0
- package/src/screencast.ts +179 -0
- package/src/server.ts +408 -0
- package/web/agent-bridge.js +746 -0
- package/web/embed-example.html +36 -0
- package/web/index.html +16 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// ahello server: browser <-> coding agent bridge via herdr (Bun runtime).
|
|
2
|
+
//
|
|
3
|
+
// GET / index.html
|
|
4
|
+
// GET /agent-bridge.js <agent-bridge> web component
|
|
5
|
+
// GET /status session liveness (herdr agent get + pane get)
|
|
6
|
+
// GET /events SSE: status, chat messages, tool start/end (from transcript JSONL)
|
|
7
|
+
// POST /send {action, text} -> herdr agent prompt
|
|
8
|
+
// GET /screen SSE: terminal-browser screen frames (CDP screencast)
|
|
9
|
+
// WS /input user control: CDP Input.* commands forwarded to the page
|
|
10
|
+
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { runJson } from "./run.ts";
|
|
14
|
+
import { createScreencast } from "./screencast.ts";
|
|
15
|
+
|
|
16
|
+
export type ServerOptions = {
|
|
17
|
+
port: number;
|
|
18
|
+
pane: string;
|
|
19
|
+
session?: string;
|
|
20
|
+
allowOrigins?: string[];
|
|
21
|
+
browser?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const BROWSER_PREFIX = "[browser]";
|
|
25
|
+
const WEB_DIR = join(import.meta.dir, "..", "web");
|
|
26
|
+
|
|
27
|
+
// ---------- pure helpers ----------
|
|
28
|
+
|
|
29
|
+
// Keep prompts at 3 lines or fewer so Claude Code does not treat them as a
|
|
30
|
+
// paste: drop blank lines, put the first body line on the header line, and
|
|
31
|
+
// join everything past the 3rd line with " / ".
|
|
32
|
+
export function formatPrompt(action: string, text: string): string {
|
|
33
|
+
const head = `${BROWSER_PREFIX} action=${action}`;
|
|
34
|
+
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
35
|
+
if (!lines.length) return head;
|
|
36
|
+
const rest = lines.slice(1);
|
|
37
|
+
const tail = rest.length > 2 ? [rest[0], rest.slice(1).join(" / ")] : rest;
|
|
38
|
+
return [`${head} ${lines[0]}`, ...tail].join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Claude Code wraps multi-line pasted input (4+ lines) in pasted_content tags.
|
|
42
|
+
// Strip them for display.
|
|
43
|
+
const PASTE_TAG = /<\/?pasted_content id="[^"]*">\n?/g;
|
|
44
|
+
const unwrapPaste = (s: string) => s.replace(PASTE_TAG, "").trim();
|
|
45
|
+
|
|
46
|
+
function toolSummary(input: any): string {
|
|
47
|
+
const s =
|
|
48
|
+
input?.description ??
|
|
49
|
+
input?.file_path ??
|
|
50
|
+
input?.pattern ??
|
|
51
|
+
input?.query ??
|
|
52
|
+
input?.url ??
|
|
53
|
+
input?.skill ??
|
|
54
|
+
input?.prompt ??
|
|
55
|
+
input?.command ??
|
|
56
|
+
"";
|
|
57
|
+
const text = String(s).replace(/\s+/g, " ").trim();
|
|
58
|
+
return text.length > 80 ? text.slice(0, 80) + "…" : text;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Every herdr call is killed after `timeout` ms so a hung herdr cannot pile up processes.
|
|
62
|
+
async function herdr(timeout: number, ...cmd: string[]): Promise<any> {
|
|
63
|
+
return (await runJson(["herdr", ...cmd], timeout)) ?? { error: { code: "herdr_failed" } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Status =
|
|
67
|
+
| { alive: false; reason: string; pane: string }
|
|
68
|
+
| { alive: true; status: string; pane: string; label?: string; session: string; title?: string };
|
|
69
|
+
|
|
70
|
+
// ---------- server ----------
|
|
71
|
+
|
|
72
|
+
export async function startServer(opts: ServerOptions) {
|
|
73
|
+
const PANE = opts.pane;
|
|
74
|
+
const ALLOW_ORIGINS = opts.allowOrigins ?? [];
|
|
75
|
+
const HTML = Bun.file(join(WEB_DIR, "index.html"));
|
|
76
|
+
const COMPONENT = Bun.file(join(WEB_DIR, "agent-bridge.js"));
|
|
77
|
+
|
|
78
|
+
const agentInfo = async (): Promise<any | null> =>
|
|
79
|
+
(await herdr(3000, "agent", "get", PANE))?.result?.agent ?? null;
|
|
80
|
+
|
|
81
|
+
const SESSION: string | undefined = opts.session ?? (await agentInfo())?.agent_session?.value;
|
|
82
|
+
|
|
83
|
+
async function checkStatus(): Promise<Status> {
|
|
84
|
+
// label (set with `herdr pane rename`) is only on pane get, not agent get
|
|
85
|
+
const [a, p] = await Promise.all([agentInfo(), herdr(3000, "pane", "get", PANE)]);
|
|
86
|
+
if (!a) return { alive: false, reason: "agent_not_found", pane: PANE };
|
|
87
|
+
if (a.agent !== "claude") return { alive: false, reason: "not_claude", pane: PANE };
|
|
88
|
+
const current = a.agent_session?.value;
|
|
89
|
+
if (SESSION && current !== SESSION) return { alive: false, reason: "session_changed", pane: PANE };
|
|
90
|
+
return {
|
|
91
|
+
alive: true,
|
|
92
|
+
status: a.agent_status,
|
|
93
|
+
pane: PANE,
|
|
94
|
+
label: p?.result?.pane?.label || undefined,
|
|
95
|
+
session: current,
|
|
96
|
+
title: a.terminal_title_stripped,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ----- SSE clients -----
|
|
101
|
+
|
|
102
|
+
const clients = new Set<ReadableStreamDefaultController>();
|
|
103
|
+
const enc = new TextEncoder();
|
|
104
|
+
const send = (ctrl: ReadableStreamDefaultController, event: string, data: unknown) => {
|
|
105
|
+
try {
|
|
106
|
+
ctrl.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
|
107
|
+
} catch {
|
|
108
|
+
clients.delete(ctrl);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const broadcast = (event: string, data: unknown) => clients.forEach((c) => send(c, event, data));
|
|
112
|
+
|
|
113
|
+
// ----- status polling -----
|
|
114
|
+
|
|
115
|
+
let lastStatus: Status | null = null;
|
|
116
|
+
let polling = false; // skip a tick while the previous poll is still running
|
|
117
|
+
async function pollStatus() {
|
|
118
|
+
if (polling) return;
|
|
119
|
+
polling = true;
|
|
120
|
+
try {
|
|
121
|
+
const s = await checkStatus();
|
|
122
|
+
if (JSON.stringify(s) !== JSON.stringify(lastStatus)) {
|
|
123
|
+
lastStatus = s;
|
|
124
|
+
broadcast("status", s);
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
polling = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
await pollStatus();
|
|
131
|
+
const timers: Timer[] = [setInterval(pollStatus, 1500)];
|
|
132
|
+
|
|
133
|
+
// ----- transcript tail -----
|
|
134
|
+
|
|
135
|
+
async function findTranscript(): Promise<string | null> {
|
|
136
|
+
if (!SESSION) return null;
|
|
137
|
+
const base = join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects");
|
|
138
|
+
for await (const p of new Bun.Glob(`*/${SESSION}.jsonl`).scan({ cwd: base, absolute: true })) return p;
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Pending tools, so a page that connects mid-tool still shows the loader.
|
|
143
|
+
const pendingTools = new Map<string, { id: string; name: string; summary: string; ts?: string }>();
|
|
144
|
+
|
|
145
|
+
// Build a chat message from a user prompt string ([browser] prefix -> browser bubble).
|
|
146
|
+
function userMessage(raw: string, ts: string, extra: object = {}) {
|
|
147
|
+
const text = unwrapPaste(raw);
|
|
148
|
+
if (text.startsWith(BROWSER_PREFIX)) {
|
|
149
|
+
const [head, ...rest] = text.split("\n");
|
|
150
|
+
const m = head.match(/action=(\S+) ?(.*)$/);
|
|
151
|
+
const body = [m?.[2] ?? "", ...rest].filter(Boolean).join("\n");
|
|
152
|
+
return { role: "browser", action: m?.[1] ?? "message", text: body, ts, ...extra };
|
|
153
|
+
}
|
|
154
|
+
return { role: "terminal", text, ts, ...extra };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Prompts typed while the agent is busy are queued by Claude Code:
|
|
158
|
+
// queue-operation enqueue {content} -> show as a "queued" bubble
|
|
159
|
+
// queue-operation remove {content, reason} -> delivered mid-turn (absorbed_mid_turn)
|
|
160
|
+
// attachment queued_command {prompt} -> delivered mid-turn (same message)
|
|
161
|
+
// queue-operation dequeue -> delivered at turn end (FIFO, no content);
|
|
162
|
+
// followed by a user entry (promptSource "queued")
|
|
163
|
+
// queue-operation popAll -> queue pulled back into the input box
|
|
164
|
+
// System prompts (task notifications, "<tag>..." content) share the queue but are not shown.
|
|
165
|
+
type Queued = { id: string; text: string; human: boolean };
|
|
166
|
+
const queue: Queued[] = [];
|
|
167
|
+
const suppress: string[] = []; // delivered queue texts whose follow-up user entry must not duplicate
|
|
168
|
+
const isHumanPrompt = (t: string) => !/^\s*<[a-z][\w-]*>/i.test(t);
|
|
169
|
+
|
|
170
|
+
function deliver(q: Queued) {
|
|
171
|
+
if (q.human) broadcast("delivered", { id: q.id });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function handleQueue(d: any) {
|
|
175
|
+
const op = d.operation;
|
|
176
|
+
if (op === "enqueue" && typeof d.content === "string") {
|
|
177
|
+
const q = { id: `q-${d.timestamp}-${queue.length}`, text: d.content, human: isHumanPrompt(d.content) };
|
|
178
|
+
queue.push(q);
|
|
179
|
+
if (q.human) broadcast("message", userMessage(d.content, d.timestamp, { id: q.id, queued: true }));
|
|
180
|
+
} else if (op === "remove" && typeof d.content === "string") {
|
|
181
|
+
const i = queue.findIndex((q) => q.text === d.content);
|
|
182
|
+
if (i >= 0) deliver(queue.splice(i, 1)[0]);
|
|
183
|
+
} else if (op === "dequeue") {
|
|
184
|
+
const q = queue.shift();
|
|
185
|
+
if (q) {
|
|
186
|
+
deliver(q);
|
|
187
|
+
suppress.push(q.text);
|
|
188
|
+
}
|
|
189
|
+
} else if (op === "popAll") {
|
|
190
|
+
for (const q of queue.splice(0)) if (q.human) broadcast("unqueued", { id: q.id });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function handleEntry(d: any) {
|
|
195
|
+
if (d.isSidechain) return;
|
|
196
|
+
const content = d.message?.content;
|
|
197
|
+
|
|
198
|
+
if (d.type === "queue-operation") return handleQueue(d);
|
|
199
|
+
|
|
200
|
+
if (d.type === "attachment" && d.attachment?.type === "queued_command") {
|
|
201
|
+
const i = queue.findIndex((q) => q.text === d.attachment.prompt);
|
|
202
|
+
if (i >= 0) deliver(queue.splice(i, 1)[0]);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (d.type === "user") {
|
|
207
|
+
const src = d.promptSource;
|
|
208
|
+
if (typeof content === "string" && (src === "typed" || src === "queued") && !d.isMeta) {
|
|
209
|
+
const s = suppress.indexOf(content);
|
|
210
|
+
if (s >= 0) {
|
|
211
|
+
suppress.splice(s, 1); // already shown as a queued bubble, now delivered
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
broadcast("message", userMessage(content, d.timestamp));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (Array.isArray(content)) {
|
|
218
|
+
for (const b of content) {
|
|
219
|
+
if (b.type === "tool_result") {
|
|
220
|
+
pendingTools.delete(b.tool_use_id);
|
|
221
|
+
broadcast("tool_end", { id: b.tool_use_id, isError: !!b.is_error });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (d.type === "assistant" && Array.isArray(content)) {
|
|
229
|
+
for (const b of content) {
|
|
230
|
+
if (b.type === "text" && b.text.trim()) {
|
|
231
|
+
broadcast("message", { role: "assistant", text: b.text, ts: d.timestamp });
|
|
232
|
+
} else if (b.type === "tool_use") {
|
|
233
|
+
const t = { id: b.id, name: b.name, summary: toolSummary(b.input), ts: d.timestamp };
|
|
234
|
+
pendingTools.set(b.id, t);
|
|
235
|
+
broadcast("tool_start", t);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const transcript = await findTranscript();
|
|
242
|
+
if (transcript) {
|
|
243
|
+
// Start from current end: only messages after the server starts are streamed.
|
|
244
|
+
let offset = Bun.file(transcript).size;
|
|
245
|
+
let reading = false;
|
|
246
|
+
timers.push(
|
|
247
|
+
setInterval(async () => {
|
|
248
|
+
if (reading) return;
|
|
249
|
+
reading = true;
|
|
250
|
+
try {
|
|
251
|
+
const f = Bun.file(transcript);
|
|
252
|
+
if (f.size < offset) offset = 0; // truncated/rewritten
|
|
253
|
+
if (f.size === offset) return;
|
|
254
|
+
const buf = new Uint8Array(await f.slice(offset, f.size).arrayBuffer());
|
|
255
|
+
const lastNl = buf.lastIndexOf(0x0a);
|
|
256
|
+
if (lastNl < 0) return; // wait for full line
|
|
257
|
+
offset += lastNl + 1;
|
|
258
|
+
for (const line of new TextDecoder().decode(buf.subarray(0, lastNl)).split("\n")) {
|
|
259
|
+
if (!line.trim()) continue;
|
|
260
|
+
try {
|
|
261
|
+
handleEntry(JSON.parse(line));
|
|
262
|
+
} catch {}
|
|
263
|
+
}
|
|
264
|
+
} finally {
|
|
265
|
+
reading = false;
|
|
266
|
+
}
|
|
267
|
+
}, 300),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ----- screen relay -----
|
|
272
|
+
|
|
273
|
+
const screen = createScreencast({
|
|
274
|
+
browserKey: opts.browser,
|
|
275
|
+
herdrTab: async () => (await agentInfo())?.tab_id,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ----- HTTP -----
|
|
279
|
+
|
|
280
|
+
// Same-origin and localhost pages are allowed by default; add more with
|
|
281
|
+
// allowOrigins (e.g. "null" for file:// pages).
|
|
282
|
+
function originAllowed(origin: string | null, self: string): boolean {
|
|
283
|
+
if (!origin || origin === self) return true;
|
|
284
|
+
if (ALLOW_ORIGINS.includes("*") || ALLOW_ORIGINS.includes(origin)) return true;
|
|
285
|
+
try {
|
|
286
|
+
const u = new URL(origin);
|
|
287
|
+
return u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1");
|
|
288
|
+
} catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function withCors(res: Response, origin: string | null): Response {
|
|
294
|
+
if (origin) {
|
|
295
|
+
res.headers.set("Access-Control-Allow-Origin", origin);
|
|
296
|
+
res.headers.set("Vary", "Origin");
|
|
297
|
+
}
|
|
298
|
+
return res;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function events(): Response {
|
|
302
|
+
let ctrl!: ReadableStreamDefaultController;
|
|
303
|
+
let ping: Timer;
|
|
304
|
+
const stream = new ReadableStream({
|
|
305
|
+
start(c) {
|
|
306
|
+
ctrl = c;
|
|
307
|
+
clients.add(c);
|
|
308
|
+
send(c, "hello", {
|
|
309
|
+
transcript: !!transcript,
|
|
310
|
+
pane: PANE,
|
|
311
|
+
session: SESSION,
|
|
312
|
+
queue: queue.filter((q) => q.human).map((q) => q.id),
|
|
313
|
+
tools: [...pendingTools.keys()],
|
|
314
|
+
});
|
|
315
|
+
if (lastStatus) send(c, "status", lastStatus);
|
|
316
|
+
for (const t of pendingTools.values()) send(c, "tool_start", t);
|
|
317
|
+
ping = setInterval(() => send(c, "ping", {}), 15000);
|
|
318
|
+
},
|
|
319
|
+
cancel() {
|
|
320
|
+
clearInterval(ping);
|
|
321
|
+
clients.delete(ctrl);
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
return new Response(stream, {
|
|
325
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function sendPrompt(req: Request): Promise<Response> {
|
|
330
|
+
const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string };
|
|
331
|
+
const st = await checkStatus();
|
|
332
|
+
if (!st.alive) return Response.json({ ok: false, error: st.reason }, { status: 409 });
|
|
333
|
+
if (st.status === "blocked") return Response.json({ ok: false, error: "agent_blocked" }, { status: 409 });
|
|
334
|
+
|
|
335
|
+
const action = /^[\w-]{1,32}$/.test(data.action ?? "") ? data.action! : "message";
|
|
336
|
+
const res = await herdr(10000, "agent", "prompt", PANE, formatPrompt(action, (data.text ?? "").trim()));
|
|
337
|
+
if (res?.error) return Response.json({ ok: false, error: res.error.code }, { status: 502 });
|
|
338
|
+
return Response.json({ ok: true });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function route(req: Request, url: URL): Promise<Response> {
|
|
342
|
+
switch (url.pathname) {
|
|
343
|
+
case "/status":
|
|
344
|
+
return Response.json(await checkStatus());
|
|
345
|
+
case "/events":
|
|
346
|
+
return events();
|
|
347
|
+
case "/screen":
|
|
348
|
+
return screen.handle();
|
|
349
|
+
case "/agent-bridge.js":
|
|
350
|
+
return new Response(COMPONENT, {
|
|
351
|
+
headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-store" },
|
|
352
|
+
});
|
|
353
|
+
case "/send":
|
|
354
|
+
if (req.method === "POST") return sendPrompt(req);
|
|
355
|
+
}
|
|
356
|
+
return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const server = Bun.serve({
|
|
360
|
+
hostname: "127.0.0.1",
|
|
361
|
+
port: opts.port,
|
|
362
|
+
idleTimeout: 0, // keep SSE open
|
|
363
|
+
websocket: {
|
|
364
|
+
message(_ws, msg) {
|
|
365
|
+
screen.input(String(msg));
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
async fetch(req, server) {
|
|
369
|
+
const url = new URL(req.url);
|
|
370
|
+
const origin = req.headers.get("Origin");
|
|
371
|
+
const isScript = url.pathname === "/agent-bridge.js";
|
|
372
|
+
|
|
373
|
+
// The component script itself is public; data and input endpoints are origin-checked.
|
|
374
|
+
if (!isScript && !originAllowed(origin, url.origin))
|
|
375
|
+
return Response.json({ ok: false, error: "origin_not_allowed" }, { status: 403 });
|
|
376
|
+
|
|
377
|
+
if (req.method === "OPTIONS")
|
|
378
|
+
return withCors(
|
|
379
|
+
new Response(null, {
|
|
380
|
+
status: 204,
|
|
381
|
+
headers: {
|
|
382
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
383
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
384
|
+
"Access-Control-Max-Age": "600",
|
|
385
|
+
},
|
|
386
|
+
}),
|
|
387
|
+
origin,
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
if (url.pathname === "/input")
|
|
391
|
+
return server.upgrade(req) ? undefined : new Response("websocket required", { status: 426 });
|
|
392
|
+
|
|
393
|
+
const res = await route(req, url);
|
|
394
|
+
return isScript ? (res.headers.set("Access-Control-Allow-Origin", "*"), res) : withCors(res, origin);
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
url: `http://127.0.0.1:${server.port}`,
|
|
400
|
+
pane: PANE,
|
|
401
|
+
session: SESSION,
|
|
402
|
+
transcript,
|
|
403
|
+
stop() {
|
|
404
|
+
timers.forEach(clearInterval);
|
|
405
|
+
server.stop(true);
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|