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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # agello
2
+
3
+ **agent + hello.** Talk to a coding agent running in a [herdr](https://herdr.dev) pane from your browser.
4
+
5
+ - Chat with the agent from a web page; terminal input, browser input and agent replies are shown as separate bubbles
6
+ - Live session status, tool activity, and prompts queued while the agent is busy (same order as the terminal)
7
+ - Conversation saved per pane in the browser, kept across reloads
8
+ - Optional live view of a `terminal-browser` screen, with an on/off switch to control it yourself
9
+ - Embeddable `<agent-bridge>` web component
10
+
11
+ Currently supports **Claude Code** sessions.
12
+
13
+ ## Requirements
14
+
15
+ - [Bun](https://bun.sh) >= 1.1
16
+ - [herdr](https://herdr.dev) (the agent must run inside a herdr pane)
17
+ - Optional: `terminal-browser` for the screen panel
18
+
19
+ ## Install
20
+
21
+ ```sh
22
+ bun add -g agello # or: npm i -g agello (Bun is still required to run it)
23
+ ```
24
+
25
+ Or run without installing: `bunx agello start --open`
26
+
27
+ From source:
28
+
29
+ ```sh
30
+ git clone https://github.com/obov/agello
31
+ cd agello
32
+ bun link # puts `agello` on your PATH
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ Run inside the herdr pane where the agent is running (`$HERDR_PANE_ID` is picked up automatically):
38
+
39
+ ```sh
40
+ agello start --open # start in the background and open the page
41
+ agello status # list running servers
42
+ agello stop # stop
43
+ ```
44
+
45
+ Or target another pane:
46
+
47
+ ```sh
48
+ agello start --pane w1:p2 --port 8766
49
+ ```
50
+
51
+ Messages sent from the page arrive in the agent as:
52
+
53
+ ```
54
+ [browser] action=message your text
55
+ ```
56
+
57
+ The approve / reject buttons send `action=approve` / `action=reject`.
58
+
59
+ ### Commands
60
+
61
+ | Command | Description |
62
+ |---|---|
63
+ | `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
+
68
+ `start` options: `--pane <id>`, `--port <n>` (default 8765), `--session <id>`, `--browser <terminal-browser key>`, `--allow-origin <origin>` (repeatable), `--open`, `--foreground`.
69
+
70
+ State and logs: `~/.local/state/agello/<port>.json`, `<port>.log`.
71
+
72
+ ## Embed
73
+
74
+ ```html
75
+ <script type="module" src="http://127.0.0.1:8765/agent-bridge.js"></script>
76
+ <agent-bridge screen style="height: 600px"></agent-bridge>
77
+ ```
78
+
79
+ | Attribute | |
80
+ |---|---|
81
+ | `server` | server origin (default: where the script was loaded from) |
82
+ | `heading` | header text |
83
+ | `screen` | show the terminal-browser screen panel |
84
+ | `no-header`, `no-actions`, `no-history` | hide header / hide approve-reject / don't persist chat |
85
+
86
+ Theme with CSS variables: `--ab-accent`, `--ab-bg`, `--ab-panel`, `--ab-text`, `--ab-muted`, `--ab-line`.
87
+ Events: `agent-status`, `agent-message`, `agent-tool`, `agent-queue`, `agent-screen`. Method: `el.send(text, action)`.
88
+ See `web/embed-example.html`.
89
+
90
+ ## How it works
91
+
92
+ - **Send**: `herdr agent prompt <pane> "<text>"` (kept to 3 lines so Claude Code does not treat it as a paste)
93
+ - **Status**: `herdr agent get` / `herdr pane get`, polled every 1.5s
94
+ - **Chat**: tails the Claude Code transcript (`$CLAUDE_CONFIG_DIR/projects/*/<session>.jsonl`) and streams it over SSE
95
+ - **Screen**: finds the terminal-browser CDP port via `terminal-browser ls --json` and relays `Page.startScreencast` frames; user control forwards `Input.*` events only
96
+
97
+ ## Security
98
+
99
+ 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.
100
+
101
+ ## License
102
+
103
+ MIT
package/bin/agello.ts ADDED
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env bun
2
+ // agello: talk to a coding agent in a herdr pane from the browser.
3
+
4
+ import { spawn } from "node:child_process";
5
+ import { existsSync, mkdirSync, openSync, readdirSync, rmSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { parseArgs } from "node:util";
9
+ import pkg from "../package.json";
10
+ import { startServer } from "../src/server.ts";
11
+
12
+ const NAME = "agello";
13
+ const DEFAULT_PORT = 8765;
14
+ const STATE_DIR = join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), NAME);
15
+
16
+ const HELP = `${NAME} ${pkg.version}: talk to a coding agent in a herdr pane from the browser
17
+
18
+ Usage:
19
+ ${NAME} start [options] Start a bridge server in the background
20
+ ${NAME} stop [--port N|--all] Stop a running server
21
+ ${NAME} status [--json] List running servers
22
+ ${NAME} open [--port N] Open the bridge page in the default browser
23
+ ${NAME} help | --version
24
+
25
+ start options:
26
+ --pane <id> herdr pane of the agent (default: $HERDR_PANE_ID)
27
+ --port <n> port (default: ${DEFAULT_PORT})
28
+ --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)
30
+ --allow-origin <o> extra allowed origin for embedding, repeatable ("null" for file://)
31
+ --open open the page after starting
32
+ --foreground run in this process instead of the background
33
+
34
+ Requires: bun, herdr. Optional: terminal-browser (screen panel).
35
+ State and logs: ${STATE_DIR}
36
+ `;
37
+
38
+ type Instance = {
39
+ pid: number;
40
+ port: number;
41
+ pane: string;
42
+ session?: string;
43
+ url: string;
44
+ log: string;
45
+ startedAt: string;
46
+ };
47
+
48
+ // ---------- state files ----------
49
+
50
+ const statePath = (port: number) => join(STATE_DIR, `${port}.json`);
51
+ const logPath = (port: number) => join(STATE_DIR, `${port}.log`);
52
+
53
+ function isAlive(pid: number): boolean {
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ async function readInstance(port: number): Promise<Instance | null> {
63
+ const f = Bun.file(statePath(port));
64
+ if (!(await f.exists())) return null;
65
+ try {
66
+ const inst = (await f.json()) as Instance;
67
+ if (isAlive(inst.pid)) return inst;
68
+ } catch {}
69
+ rmSync(statePath(port), { force: true }); // stale
70
+ return null;
71
+ }
72
+
73
+ async function listInstances(): Promise<Instance[]> {
74
+ if (!existsSync(STATE_DIR)) return [];
75
+ const ports = readdirSync(STATE_DIR)
76
+ .map((f) => f.match(/^(\d+)\.json$/)?.[1])
77
+ .filter(Boolean)
78
+ .map(Number);
79
+ const all = await Promise.all(ports.map(readInstance));
80
+ return all.filter((i): i is Instance => !!i).sort((a, b) => a.port - b.port);
81
+ }
82
+
83
+ async function fetchStatus(url: string): Promise<any | null> {
84
+ try {
85
+ const res = await fetch(`${url}/status`, { signal: AbortSignal.timeout(4000) });
86
+ return res.ok ? await res.json() : null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function openUrl(url: string) {
93
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
94
+ Bun.spawn([cmd, url], { stdout: "ignore", stderr: "ignore" });
95
+ }
96
+
97
+ const fail = (msg: string): never => {
98
+ console.error(`${NAME}: ${msg}`);
99
+ process.exit(1);
100
+ };
101
+
102
+ // ---------- commands ----------
103
+
104
+ async function cmdStart(argv: string[]) {
105
+ const { values: o } = parseArgs({
106
+ args: argv,
107
+ options: {
108
+ pane: { type: "string", default: process.env.HERDR_PANE_ID },
109
+ port: { type: "string", default: String(DEFAULT_PORT) },
110
+ session: { type: "string" },
111
+ browser: { type: "string" },
112
+ "allow-origin": { type: "string", multiple: true, default: [] },
113
+ open: { type: "boolean", default: false },
114
+ foreground: { type: "boolean", default: false },
115
+ },
116
+ });
117
+ const port = Number(o.port);
118
+ if (!o.pane) fail("--pane is required (HERDR_PANE_ID is not set; run inside herdr or pass --pane)");
119
+ if (!Bun.which("herdr")) fail("herdr not found on PATH");
120
+ mkdirSync(STATE_DIR, { recursive: true });
121
+
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);
126
+ return;
127
+ }
128
+
129
+ if (o.foreground) {
130
+ const srv = await startServer({
131
+ port,
132
+ pane: o.pane!,
133
+ session: o.session,
134
+ browser: o.browser,
135
+ allowOrigins: o["allow-origin"] as string[],
136
+ });
137
+ const inst: Instance = {
138
+ pid: process.pid,
139
+ port,
140
+ pane: srv.pane,
141
+ session: srv.session,
142
+ url: srv.url,
143
+ log: logPath(port),
144
+ startedAt: new Date().toISOString(),
145
+ };
146
+ await Bun.write(statePath(port), JSON.stringify(inst, null, 2));
147
+ const shutdown = () => {
148
+ srv.stop();
149
+ rmSync(statePath(port), { force: true });
150
+ process.exit(0);
151
+ };
152
+ process.on("SIGINT", shutdown);
153
+ process.on("SIGTERM", shutdown);
154
+ console.log(`listening ${srv.url} pane=${srv.pane} session=${srv.session} transcript=${srv.transcript}`);
155
+ if (o.open) openUrl(srv.url);
156
+ return;
157
+ }
158
+
159
+ // Background: re-run this CLI with --foreground, detached, logging to a file.
160
+ const args = argv.filter((a) => a !== "--open");
161
+ const log = openSync(logPath(port), "a");
162
+ const child = spawn(process.execPath, [import.meta.path, "start", "--foreground", ...args], {
163
+ detached: true,
164
+ stdio: ["ignore", log, log],
165
+ env: { ...process.env, HERDR_PANE_ID: o.pane },
166
+ });
167
+ child.unref();
168
+
169
+ const url = `http://127.0.0.1:${port}`;
170
+ const deadline = Date.now() + 15000;
171
+ while (Date.now() < deadline) {
172
+ if (child.exitCode !== null || !isAlive(child.pid!)) fail(`server exited; see ${logPath(port)}`);
173
+ if ((await readInstance(port)) && (await fetchStatus(url))) {
174
+ console.log(`started: ${url} (pane ${o.pane}, pid ${child.pid})`);
175
+ console.log(`log: ${logPath(port)}`);
176
+ if (o.open) openUrl(url);
177
+ return;
178
+ }
179
+ await Bun.sleep(200);
180
+ }
181
+ fail(`server did not become ready in 15s; see ${logPath(port)}`);
182
+ }
183
+
184
+ async function cmdStop(argv: string[]) {
185
+ const { values: o } = parseArgs({
186
+ args: argv,
187
+ options: { port: { type: "string" }, all: { type: "boolean", default: false } },
188
+ });
189
+ const targets = o.all
190
+ ? await listInstances()
191
+ : [await readInstance(Number(o.port ?? DEFAULT_PORT))].filter((i): i is Instance => !!i);
192
+ if (!targets.length) {
193
+ console.log("not running");
194
+ return;
195
+ }
196
+ for (const inst of targets) {
197
+ process.kill(inst.pid, "SIGTERM");
198
+ const deadline = Date.now() + 3000;
199
+ while (isAlive(inst.pid) && Date.now() < deadline) await Bun.sleep(100);
200
+ if (isAlive(inst.pid)) process.kill(inst.pid, "SIGKILL");
201
+ rmSync(statePath(inst.port), { force: true });
202
+ console.log(`stopped: ${inst.url} (pid ${inst.pid})`);
203
+ }
204
+ }
205
+
206
+ async function cmdStatus(argv: string[]) {
207
+ const { values: o } = parseArgs({ args: argv, options: { json: { type: "boolean", default: false } } });
208
+ const list = await listInstances();
209
+ const rows = await Promise.all(list.map(async (i) => ({ ...i, agent: await fetchStatus(i.url) })));
210
+ if (o.json) {
211
+ console.log(JSON.stringify(rows, null, 2));
212
+ return;
213
+ }
214
+ if (!rows.length) {
215
+ console.log("no running servers");
216
+ return;
217
+ }
218
+ for (const r of rows) {
219
+ const a = r.agent;
220
+ const state = !a ? "unreachable" : a.alive ? a.status : `dead (${a.reason})`;
221
+ const name = a?.label ? `${a.label} (${r.pane})` : r.pane;
222
+ console.log(`${r.url} pane=${name} agent=${state} pid=${r.pid}`);
223
+ }
224
+ }
225
+
226
+ 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);
232
+ }
233
+
234
+ // ---------- main ----------
235
+
236
+ const [cmd, ...rest] = Bun.argv.slice(2);
237
+ switch (cmd) {
238
+ case "start":
239
+ await cmdStart(rest);
240
+ break;
241
+ case "stop":
242
+ await cmdStop(rest);
243
+ break;
244
+ case "status":
245
+ await cmdStatus(rest);
246
+ break;
247
+ case "open":
248
+ await cmdOpen(rest);
249
+ break;
250
+ case "--version":
251
+ case "-v":
252
+ console.log(pkg.version);
253
+ break;
254
+ case undefined:
255
+ case "help":
256
+ case "--help":
257
+ case "-h":
258
+ console.log(HELP);
259
+ break;
260
+ default:
261
+ fail(`unknown command: ${cmd}\n\n${HELP}`);
262
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "agello",
3
+ "version": "0.1.0",
4
+ "description": "Talk to a coding agent in a herdr pane from the browser: chat, live status, and a shared browser screen.",
5
+ "keywords": [
6
+ "herdr",
7
+ "claude-code",
8
+ "coding-agent",
9
+ "browser",
10
+ "bridge",
11
+ "cli",
12
+ "bun"
13
+ ],
14
+ "homepage": "https://github.com/obov/agello#readme",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/obov/agello.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/obov/agello/issues"
21
+ },
22
+ "type": "module",
23
+ "bin": {
24
+ "agello": "bin/agello.ts"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "src",
29
+ "web"
30
+ ],
31
+ "engines": {
32
+ "bun": ">=1.1"
33
+ },
34
+ "license": "MIT"
35
+ }
package/src/run.ts ADDED
@@ -0,0 +1,28 @@
1
+ // Run a CLI and return stdout, or null on failure/timeout.
2
+ //
3
+ // - The child is killed after `timeout` ms.
4
+ // - Completion is decided by the process exit, not by stdout EOF: a killed
5
+ // child's own subprocess can keep the pipe open, and waiting for EOF would
6
+ // stall the caller until that orphan exits.
7
+ // - stderr is ignored (an unread pipe can fill up and block the child).
8
+ export async function run(cmd: string[], timeout = 3000): Promise<string | null> {
9
+ try {
10
+ const p = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore", timeout });
11
+ const out = new Response(p.stdout).text();
12
+ const code = await p.exited;
13
+ if (code !== 0) return null;
14
+ return await out;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ export async function runJson(cmd: string[], timeout = 3000): Promise<any | null> {
21
+ const out = await run(cmd, timeout);
22
+ if (out === null) return null;
23
+ try {
24
+ return JSON.parse(out);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
@@ -0,0 +1,179 @@
1
+ import { runJson } from "./run.ts";
2
+
3
+ // terminal-browser screen relay: CDP Page.startScreencast -> SSE (/screen).
4
+ //
5
+ // Discovers the terminal-browser instance with `terminal-browser ls --all --json`,
6
+ // follows its active tab, and streams JPEG frames only while at least one
7
+ // viewer is connected. input() forwards whitelisted CDP Input.* commands
8
+ // from the viewer to the page (user control).
9
+
10
+ type Browser = {
11
+ key: string;
12
+ cdpPort: number;
13
+ pane?: { tab: string; pane: string };
14
+ tabs: { id: number; url: string; title: string; active: boolean; targetId: string; agentControlled: boolean }[];
15
+ };
16
+
17
+ export type ScreenMeta = {
18
+ connected: boolean;
19
+ browser?: string;
20
+ url?: string;
21
+ title?: string;
22
+ agentControlled?: boolean;
23
+ };
24
+
25
+ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => Promise<string | undefined> }) {
26
+ const viewers = new Set<ReadableStreamDefaultController>();
27
+ const enc = new TextEncoder();
28
+
29
+ let meta: ScreenMeta = { connected: false };
30
+ let lastFrame: { data: string; w: number; h: number } | null = null;
31
+ let ws: WebSocket | null = null;
32
+ let wsTarget = "";
33
+ let msgId = 0;
34
+ let timer: Timer | null = null;
35
+
36
+ const emit = (c: ReadableStreamDefaultController, event: string, data: unknown) => {
37
+ try {
38
+ c.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
39
+ } catch {
40
+ viewers.delete(c);
41
+ }
42
+ };
43
+ const broadcast = (event: string, data: unknown) => viewers.forEach((c) => emit(c, event, data));
44
+
45
+ function setMeta(next: ScreenMeta) {
46
+ if (JSON.stringify(next) === JSON.stringify(meta)) return;
47
+ meta = next;
48
+ broadcast("meta", meta);
49
+ }
50
+
51
+ async function listBrowsers(): Promise<Browser[]> {
52
+ // timeout: a hung terminal-browser must not pile up processes or stall sync
53
+ return (await runJson(["terminal-browser", "ls", "--all", "--json"], 3000))?.browsers ?? [];
54
+ }
55
+
56
+ async function pickBrowser(): Promise<Browser | undefined> {
57
+ const all = await listBrowsers();
58
+ if (opts.browserKey) return all.find((b) => b.key === opts.browserKey);
59
+ const tab = await opts.herdrTab?.();
60
+ return all.find((b) => b.pane?.tab === tab) ?? all[0];
61
+ }
62
+
63
+ function disconnect() {
64
+ ws?.close();
65
+ ws = null;
66
+ wsTarget = "";
67
+ }
68
+
69
+ function connect(port: number, targetId: string) {
70
+ disconnect();
71
+ const url = `ws://127.0.0.1:${port}/devtools/page/${targetId}`;
72
+ wsTarget = url;
73
+ const sock = new WebSocket(url);
74
+ ws = sock;
75
+ const call = (method: string, params: object = {}) =>
76
+ sock.send(JSON.stringify({ id: ++msgId, method, params }));
77
+
78
+ sock.onopen = () => {
79
+ call("Page.enable");
80
+ call("Page.startScreencast", { format: "jpeg", quality: 70, maxWidth: 1600, maxHeight: 1600 });
81
+ };
82
+ sock.onmessage = (ev) => {
83
+ const msg = JSON.parse(String(ev.data));
84
+ if (msg.method !== "Page.screencastFrame") return;
85
+ const { data, metadata, sessionId } = msg.params;
86
+ call("Page.screencastFrameAck", { sessionId });
87
+ lastFrame = { data, w: metadata.deviceWidth, h: metadata.deviceHeight };
88
+ broadcast("frame", lastFrame);
89
+ };
90
+ sock.onclose = () => {
91
+ if (ws === sock) {
92
+ ws = null;
93
+ wsTarget = "";
94
+ }
95
+ };
96
+ }
97
+
98
+ let syncing = false; // skip a tick while the previous sync is still running
99
+ async function sync() {
100
+ if (syncing) return;
101
+ syncing = true;
102
+ try {
103
+ await syncOnce();
104
+ } finally {
105
+ syncing = false;
106
+ }
107
+ }
108
+
109
+ async function syncOnce() {
110
+ const b = await pickBrowser();
111
+ const tab = b?.tabs.find((t) => t.active);
112
+ if (!b || !tab) {
113
+ disconnect();
114
+ lastFrame = null;
115
+ setMeta({ connected: false });
116
+ return;
117
+ }
118
+ const url = `ws://127.0.0.1:${b.cdpPort}/devtools/page/${tab.targetId}`;
119
+ if (url !== wsTarget || !ws) connect(b.cdpPort, tab.targetId);
120
+ setMeta({
121
+ connected: true,
122
+ browser: b.key,
123
+ url: tab.url,
124
+ title: tab.title,
125
+ agentControlled: tab.agentControlled,
126
+ });
127
+ }
128
+
129
+ function start() {
130
+ if (timer) return;
131
+ sync();
132
+ timer = setInterval(sync, 1500);
133
+ }
134
+ function stop() {
135
+ if (timer) clearInterval(timer);
136
+ timer = null;
137
+ disconnect();
138
+ lastFrame = null;
139
+ meta = { connected: false };
140
+ }
141
+
142
+ // SSE response for one viewer.
143
+ function handle(): Response {
144
+ let ctrl!: ReadableStreamDefaultController;
145
+ let ping: Timer;
146
+ const stream = new ReadableStream({
147
+ start(c) {
148
+ ctrl = c;
149
+ viewers.add(c);
150
+ emit(c, "meta", meta);
151
+ if (lastFrame) emit(c, "frame", lastFrame);
152
+ ping = setInterval(() => emit(c, "ping", {}), 15000);
153
+ start();
154
+ },
155
+ cancel() {
156
+ clearInterval(ping);
157
+ viewers.delete(ctrl);
158
+ if (viewers.size === 0) stop();
159
+ },
160
+ });
161
+ return new Response(stream, {
162
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
163
+ });
164
+ }
165
+
166
+ const INPUT_METHODS = new Set(["Input.dispatchMouseEvent", "Input.dispatchKeyEvent", "Input.insertText"]);
167
+ function input(raw: string) {
168
+ let m: any;
169
+ try {
170
+ m = JSON.parse(raw);
171
+ } catch {
172
+ return;
173
+ }
174
+ if (!INPUT_METHODS.has(m?.method) || !ws || ws.readyState !== WebSocket.OPEN) return;
175
+ ws.send(JSON.stringify({ id: ++msgId, method: m.method, params: m.params ?? {} }));
176
+ }
177
+
178
+ return { handle, input };
179
+ }