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/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
+ }