agello 0.3.0 → 0.4.1
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 +24 -35
- package/bin/agello.ts +0 -68
- package/package.json +6 -2
- package/src/panes.ts +86 -0
- package/src/screencast.ts +1 -71
- package/src/server.ts +84 -133
- package/src/terminal.ts +122 -0
- package/web/agent-bridge.js +408 -277
- package/web/index.html +12 -0
- package/web/terminal.js +121 -0
- package/src/player.ts +0 -234
package/src/server.ts
CHANGED
|
@@ -4,24 +4,27 @@
|
|
|
4
4
|
// GET /agent-bridge.js <agent-bridge> web component
|
|
5
5
|
// GET /status session liveness (herdr agent get + pane get)
|
|
6
6
|
// GET /events SSE: status, chat messages, tool start/end (from transcript JSONL)
|
|
7
|
-
// POST /send {action, text} -> herdr agent prompt
|
|
8
|
-
// (
|
|
7
|
+
// POST /send {action, text, images?: [{type, data(base64)}]} -> herdr agent prompt
|
|
8
|
+
// (images are saved to $TMPDIR/agello-uploads and sent as [image: <path>])
|
|
9
9
|
// GET /screen SSE: terminal-browser screen frames (CDP screencast)
|
|
10
|
-
//
|
|
11
|
-
//
|
|
10
|
+
// POST /upload {images: [{type, data(base64)}]} -> {paths}: terminal image paste
|
|
11
|
+
// WS /terminal interactive herdr terminal frames, keyboard input, resize
|
|
12
|
+
// GET /panes herdr workspace -> tab -> pane tree
|
|
13
|
+
// POST /panes/connect {pane} -> url of that pane's server (started if needed)
|
|
14
|
+
// POST /panes/create {kind: workspace|tab|pane, cwd?, label?, workspace?, pane?, direction?}
|
|
15
|
+
// WS /input user control: CDP Input.* commands forwarded to the page
|
|
12
16
|
// GET /present presentation state
|
|
13
17
|
// POST /present {rect?: {x,y,w,h}} start (or move the crop), {stop: true} end
|
|
14
18
|
// ({stop: true, from: "viewer"}: ended from the page, the agent is told
|
|
15
19
|
// with "[browser] action=present-stop")
|
|
16
|
-
// GET /script presentation script + player position
|
|
17
|
-
// POST /script {script: {rect?, steps: [{go?, say: string[], hold?}]}} load (keeps position)
|
|
18
|
-
// POST /player {cmd: "resume" | "pause" | "goto", at?: "3" | "3.2"}
|
|
19
20
|
|
|
20
|
-
import {
|
|
21
|
+
import { createTerminal, type SocketData } from "./terminal.ts";
|
|
22
|
+
import { panesRoute } from "./panes.ts";
|
|
23
|
+
import { mkdir } from "node:fs/promises";
|
|
24
|
+
import { homedir, tmpdir } from "node:os";
|
|
21
25
|
import { join } from "node:path";
|
|
22
26
|
import { runJson } from "./run.ts";
|
|
23
|
-
import {
|
|
24
|
-
import { createScreencast, type Inspected, type Rect } from "./screencast.ts";
|
|
27
|
+
import { createScreencast, type Rect } from "./screencast.ts";
|
|
25
28
|
|
|
26
29
|
export type ServerOptions = {
|
|
27
30
|
port: number;
|
|
@@ -32,6 +35,30 @@ export type ServerOptions = {
|
|
|
32
35
|
};
|
|
33
36
|
|
|
34
37
|
export const BROWSER_PREFIX = "[browser]";
|
|
38
|
+
const UPLOAD_DIR = join(tmpdir(), "agello-uploads");
|
|
39
|
+
const IMAGE_EXT: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" };
|
|
40
|
+
const MAX_IMAGES = 5;
|
|
41
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
42
|
+
|
|
43
|
+
// Save pasted/dropped chat images so the agent can open them by path
|
|
44
|
+
// (herdr agent prompt carries text only). null: invalid input.
|
|
45
|
+
export async function saveImages(list: unknown): Promise<string[] | null> {
|
|
46
|
+
if (list === undefined) return [];
|
|
47
|
+
if (!Array.isArray(list) || list.length > MAX_IMAGES) return null;
|
|
48
|
+
const files = list.map((img: any) => {
|
|
49
|
+
const ext = IMAGE_EXT[img?.type];
|
|
50
|
+
if (!ext || typeof img.data !== "string") return null;
|
|
51
|
+
const bytes = Buffer.from(img.data, "base64");
|
|
52
|
+
return bytes.length && bytes.length <= MAX_IMAGE_BYTES ? { ext, bytes } : null;
|
|
53
|
+
});
|
|
54
|
+
if (files.includes(null)) return null;
|
|
55
|
+
await mkdir(UPLOAD_DIR, { recursive: true, mode: 0o700 });
|
|
56
|
+
return Promise.all(files.map(async (f) => {
|
|
57
|
+
const path = join(UPLOAD_DIR, `${crypto.randomUUID()}.${f!.ext}`);
|
|
58
|
+
await Bun.write(path, f!.bytes);
|
|
59
|
+
return path;
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
35
62
|
const WEB_DIR = join(import.meta.dir, "..", "web");
|
|
36
63
|
|
|
37
64
|
// ---------- pure helpers ----------
|
|
@@ -96,22 +123,7 @@ export function parseRect(r: any): Rect | null {
|
|
|
96
123
|
return { x, y, w, h };
|
|
97
124
|
}
|
|
98
125
|
|
|
99
|
-
|
|
100
|
-
// 3 lines (request / selector / element summary).
|
|
101
|
-
export function formatAnnotation(text: string, t: any): string | null {
|
|
102
|
-
if (!t || typeof t.selector !== "string" || !t.selector || t.selector.length > 1000) return null;
|
|
103
|
-
const clean = (v: unknown, n: number) => String(v ?? "").replace(/\s+/g, " ").trim().slice(0, n);
|
|
104
|
-
const request = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).join(" / ");
|
|
105
|
-
const r = t.rect ?? {};
|
|
106
|
-
const [x, y, w, h] = [r.x, r.y, r.w, r.h].map((v) => Math.round(Number(v) || 0));
|
|
107
|
-
const label = clean(t.label, 120);
|
|
108
|
-
const snippet = clean(t.text, 80);
|
|
109
|
-
const url = clean(t.url, 300);
|
|
110
|
-
const info = [`요소: ${label}${snippet ? ` "${snippet}"` : ""}`, `위치 ${x},${y} 크기 ${w}x${h}`, url].filter(Boolean);
|
|
111
|
-
return [request, `대상: ${clean(t.selector, 1000)}`, info.join(" · ")].join("\n");
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export type Present = { on: boolean; rect?: Rect; since?: string; player?: PlayerInfo };
|
|
126
|
+
export type Present = { on: boolean; rect?: Rect; since?: string };
|
|
115
127
|
|
|
116
128
|
type Status =
|
|
117
129
|
| { alive: false; reason: string; pane: string }
|
|
@@ -125,6 +137,12 @@ export async function startServer(opts: ServerOptions) {
|
|
|
125
137
|
const HTML = Bun.file(join(WEB_DIR, "index.html"));
|
|
126
138
|
const COMPONENT = Bun.file(join(WEB_DIR, "agent-bridge.js"));
|
|
127
139
|
|
|
140
|
+
const terminal = createTerminal(PANE);
|
|
141
|
+
const bundle = await Bun.build({ entrypoints: [join(WEB_DIR, "terminal.js")], target: "browser", minify: true });
|
|
142
|
+
if (!bundle.success) throw new Error(`Terminal bundle failed: ${bundle.logs.join("\n")}`);
|
|
143
|
+
const terminalJS = await bundle.outputs[0].text();
|
|
144
|
+
const terminalCSS = Bun.file(new URL(import.meta.resolve("@xterm/xterm/css/xterm.css")));
|
|
145
|
+
|
|
128
146
|
const agentInfo = async (): Promise<any | null> =>
|
|
129
147
|
(await herdr(3000, "agent", "get", PANE))?.result?.agent ?? null;
|
|
130
148
|
|
|
@@ -324,7 +342,8 @@ export async function startServer(opts: ServerOptions) {
|
|
|
324
342
|
|
|
325
343
|
const screen = createScreencast({
|
|
326
344
|
browserKey: opts.browser,
|
|
327
|
-
|
|
345
|
+
// shell panes have no agent: fall back to the pane's own tab
|
|
346
|
+
herdrTab: async () => (await agentInfo())?.tab_id ?? (await herdr(3000, "pane", "get", PANE))?.result?.pane?.tab_id,
|
|
328
347
|
});
|
|
329
348
|
|
|
330
349
|
// ----- presentation mode -----
|
|
@@ -332,67 +351,10 @@ export async function startServer(opts: ServerOptions) {
|
|
|
332
351
|
// short-lived bubbles. rect: crop of the visible viewport (unset = whole screen).
|
|
333
352
|
|
|
334
353
|
let present: Present = { on: false };
|
|
335
|
-
const presentView = (): Present => ({ ...present, player: player.info() });
|
|
336
354
|
function setPresent(next: Present) {
|
|
337
355
|
present = next;
|
|
338
356
|
screen.setClip(next.on ? (next.rect ?? null) : null);
|
|
339
|
-
|
|
340
|
-
broadcast("present", presentView());
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// Script player: agello owns order and timing, the agent owns the deck
|
|
344
|
-
// (each step's `go`) and decides when to resume after a question.
|
|
345
|
-
const player = createPlayer({
|
|
346
|
-
async go(expr) {
|
|
347
|
-
const js = expr.startsWith("#") ? `location.hash = ${JSON.stringify(expr)}` : expr;
|
|
348
|
-
if (!(await screen.evaluate(js))) console.error(`present: go failed: ${expr}`);
|
|
349
|
-
},
|
|
350
|
-
say(text, at, hold) {
|
|
351
|
-
broadcast("message", { role: "assistant", text, ts: new Date().toISOString(), script: at, hold });
|
|
352
|
-
},
|
|
353
|
-
done() {
|
|
354
|
-
const n = player.info().steps;
|
|
355
|
-
promptAgent("present-done", `대본 끝까지 재생함 (${n}장)`);
|
|
356
|
-
},
|
|
357
|
-
changed() {
|
|
358
|
-
broadcast("present", presentView());
|
|
359
|
-
},
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
// "마지막 표시 3.2 · 다음 3.3" for agent notices
|
|
363
|
-
const where = () => {
|
|
364
|
-
const i = player.info();
|
|
365
|
-
if (i.state === "empty") return "";
|
|
366
|
-
return [i.last && `마지막 표시 ${i.last}`, i.next && `다음 ${i.next}`].filter(Boolean).join(" · ");
|
|
367
|
-
};
|
|
368
|
-
|
|
369
|
-
async function scriptRequest(req: Request): Promise<Response> {
|
|
370
|
-
if (req.method !== "POST") return Response.json({ script: player.script(), player: player.info() });
|
|
371
|
-
const data = (await req.json().catch(() => ({}))) as { script?: unknown };
|
|
372
|
-
const script = parseScript(data.script);
|
|
373
|
-
if (typeof script === "string") return Response.json({ ok: false, error: script }, { status: 400 });
|
|
374
|
-
if (script.rect && !parseRect(script.rect))
|
|
375
|
-
return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
|
|
376
|
-
player.load(script);
|
|
377
|
-
return Response.json({ ok: true, player: player.info() });
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
async function playerRequest(req: Request): Promise<Response> {
|
|
381
|
-
const data = (await req.json().catch(() => ({}))) as { cmd?: string; at?: string };
|
|
382
|
-
let error: string | null = null;
|
|
383
|
-
if (data.cmd === "resume") {
|
|
384
|
-
if (!present.on) {
|
|
385
|
-
const rect = player.script()?.rect;
|
|
386
|
-
setPresent({ on: true, rect: rect ? parseRect(rect)! : undefined, since: new Date().toISOString() });
|
|
387
|
-
}
|
|
388
|
-
error = player.resume();
|
|
389
|
-
} else if (data.cmd === "pause") player.pause();
|
|
390
|
-
else if (data.cmd === "goto") {
|
|
391
|
-
const pos = parsePos(data.at ?? "");
|
|
392
|
-
error = pos ? await player.goto(pos) : "invalid_position";
|
|
393
|
-
} else error = "unknown_cmd";
|
|
394
|
-
if (error) return Response.json({ ok: false, error, player: player.info() }, { status: 400 });
|
|
395
|
-
return Response.json({ ok: true, player: player.info() });
|
|
357
|
+
broadcast("present", present);
|
|
396
358
|
}
|
|
397
359
|
|
|
398
360
|
async function presentRequest(req: Request): Promise<Response> {
|
|
@@ -404,11 +366,10 @@ export async function startServer(opts: ServerOptions) {
|
|
|
404
366
|
// (The CLI stop comes from the agent itself and needs no notice.)
|
|
405
367
|
let notified: string | undefined;
|
|
406
368
|
if (was && data.from === "viewer") {
|
|
407
|
-
const
|
|
408
|
-
const r = await promptAgent("present-stop", `사용자가 페이지에서 발표를 종료함${at ? ` (대본 정지: ${at})` : ""}`);
|
|
369
|
+
const r = await promptAgent("present-stop", "사용자가 페이지에서 발표를 종료함");
|
|
409
370
|
notified = r.ok ? "ok" : r.error;
|
|
410
371
|
}
|
|
411
|
-
return Response.json({ ok: true, present
|
|
372
|
+
return Response.json({ ok: true, present, notified });
|
|
412
373
|
}
|
|
413
374
|
let rect: Rect | undefined;
|
|
414
375
|
if (data.rect != null) {
|
|
@@ -416,7 +377,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
416
377
|
if (!rect) return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
|
|
417
378
|
}
|
|
418
379
|
setPresent({ on: true, rect, since: present.on ? present.since : new Date().toISOString() });
|
|
419
|
-
return Response.json({ ok: true, present
|
|
380
|
+
return Response.json({ ok: true, present });
|
|
420
381
|
}
|
|
421
382
|
|
|
422
383
|
// ----- HTTP -----
|
|
@@ -457,7 +418,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
457
418
|
tools: [...pendingTools.keys()],
|
|
458
419
|
});
|
|
459
420
|
if (lastStatus) send(c, "status", lastStatus);
|
|
460
|
-
send(c, "present",
|
|
421
|
+
send(c, "present", present);
|
|
461
422
|
for (const t of pendingTools.values()) send(c, "tool_start", t);
|
|
462
423
|
ping = setInterval(() => send(c, "ping", {}), 15000);
|
|
463
424
|
},
|
|
@@ -482,38 +443,36 @@ export async function startServer(opts: ServerOptions) {
|
|
|
482
443
|
}
|
|
483
444
|
|
|
484
445
|
async function sendPrompt(req: Request): Promise<Response> {
|
|
485
|
-
const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string;
|
|
446
|
+
const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string; images?: unknown };
|
|
486
447
|
const action = /^[\w-]{1,32}$/.test(data.action ?? "") ? data.action! : "message";
|
|
487
|
-
|
|
488
|
-
if (
|
|
489
|
-
|
|
490
|
-
if (!t || !text.trim()) return Response.json({ ok: false, error: "invalid_target" }, { status: 400 });
|
|
491
|
-
text = t;
|
|
492
|
-
}
|
|
493
|
-
// Raised hand: stop the script at once (no agent round trip) and tell the
|
|
494
|
-
// agent where it stopped.
|
|
495
|
-
if (action === "hand-raise") {
|
|
496
|
-
player.pause();
|
|
497
|
-
const at = where();
|
|
498
|
-
if (at) text += ` (대본 정지: ${at})`;
|
|
499
|
-
}
|
|
448
|
+
const images = await saveImages(data.images);
|
|
449
|
+
if (!images) return Response.json({ ok: false, error: "invalid_images" }, { status: 400 });
|
|
450
|
+
const text = [data.text ?? "", ...images.map((p) => `[image: ${p}]`)].join("\n");
|
|
500
451
|
const r = await promptAgent(action, text);
|
|
501
452
|
return r.ok ? Response.json({ ok: true }) : Response.json({ ok: false, error: r.error }, { status: r.status });
|
|
502
453
|
}
|
|
503
454
|
|
|
504
455
|
async function route(req: Request, url: URL): Promise<Response> {
|
|
456
|
+
if (url.pathname.startsWith("/panes")) {
|
|
457
|
+
const res = await panesRoute(req, url.pathname, PANE);
|
|
458
|
+
if (res) return res;
|
|
459
|
+
}
|
|
505
460
|
switch (url.pathname) {
|
|
506
461
|
case "/status":
|
|
507
462
|
return Response.json({
|
|
508
463
|
...(await checkStatus()),
|
|
509
464
|
browser: opts.browser,
|
|
510
465
|
screen: await screen.describe(),
|
|
511
|
-
present
|
|
466
|
+
present,
|
|
512
467
|
});
|
|
513
468
|
case "/events":
|
|
514
469
|
return events();
|
|
515
470
|
case "/screen":
|
|
516
471
|
return screen.handle();
|
|
472
|
+
case "/terminal.js":
|
|
473
|
+
return new Response(terminalJS, { headers: { "Content-Type": "text/javascript; charset=utf-8" } });
|
|
474
|
+
case "/terminal.css":
|
|
475
|
+
return new Response(terminalCSS, { headers: { "Content-Type": "text/css; charset=utf-8" } });
|
|
517
476
|
case "/agent-bridge.js":
|
|
518
477
|
return new Response(COMPONENT, {
|
|
519
478
|
headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-store" },
|
|
@@ -521,46 +480,36 @@ export async function startServer(opts: ServerOptions) {
|
|
|
521
480
|
case "/send":
|
|
522
481
|
if (req.method === "POST") return sendPrompt(req);
|
|
523
482
|
break;
|
|
524
|
-
case "/
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
483
|
+
case "/upload":
|
|
484
|
+
if (req.method === "POST") {
|
|
485
|
+
const data = (await req.json().catch(() => ({}))) as { images?: unknown };
|
|
486
|
+
const paths = data.images === undefined ? null : await saveImages(data.images);
|
|
487
|
+
return paths ? Response.json({ ok: true, paths }) : Response.json({ ok: false, error: "invalid_images" }, { status: 400 });
|
|
488
|
+
}
|
|
530
489
|
break;
|
|
490
|
+
case "/present":
|
|
491
|
+
return req.method === "POST" ? presentRequest(req) : Response.json(present);
|
|
531
492
|
}
|
|
532
493
|
return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
|
|
533
494
|
}
|
|
534
495
|
|
|
535
|
-
const server = Bun.serve({
|
|
496
|
+
const server = Bun.serve<SocketData>({
|
|
536
497
|
hostname: "127.0.0.1",
|
|
537
498
|
port: opts.port,
|
|
538
499
|
idleTimeout: 0, // keep SSE open
|
|
539
500
|
websocket: {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
} catch {
|
|
546
|
-
return;
|
|
547
|
-
}
|
|
548
|
-
if (m?.inspect) {
|
|
549
|
-
const q = m.inspect;
|
|
550
|
-
const query =
|
|
551
|
-
typeof q.selector === "string"
|
|
552
|
-
? { selector: q.selector.slice(0, 1000), full: !!q.full }
|
|
553
|
-
: { x: Number(q.x) || 0, y: Number(q.y) || 0, full: !!q.full };
|
|
554
|
-
ws.send(JSON.stringify({ id: m.id, result: await screen.inspect(query) }));
|
|
555
|
-
return;
|
|
556
|
-
}
|
|
557
|
-
screen.input(raw);
|
|
501
|
+
maxPayloadLength: 128 * 1024,
|
|
502
|
+
open(ws) { if (ws.data.kind === "terminal") terminal.open(ws); },
|
|
503
|
+
message(ws, msg) {
|
|
504
|
+
if (ws.data.kind === "terminal") terminal.message(ws, String(msg));
|
|
505
|
+
else screen.input(String(msg));
|
|
558
506
|
},
|
|
507
|
+
close(ws) { if (ws.data.kind === "terminal") terminal.close(ws); },
|
|
559
508
|
},
|
|
560
509
|
async fetch(req, server) {
|
|
561
510
|
const url = new URL(req.url);
|
|
562
511
|
const origin = req.headers.get("Origin");
|
|
563
|
-
const isScript =
|
|
512
|
+
const isScript = ["/agent-bridge.js", "/terminal.js", "/terminal.css"].includes(url.pathname);
|
|
564
513
|
|
|
565
514
|
// The component script itself is public; data and input endpoints are origin-checked.
|
|
566
515
|
if (!isScript && !originAllowed(origin, url.origin))
|
|
@@ -579,8 +528,9 @@ export async function startServer(opts: ServerOptions) {
|
|
|
579
528
|
origin,
|
|
580
529
|
);
|
|
581
530
|
|
|
582
|
-
if (url.pathname === "/input")
|
|
583
|
-
return server.upgrade(req
|
|
531
|
+
if (url.pathname === "/input" || url.pathname === "/terminal")
|
|
532
|
+
return server.upgrade(req, { data: { kind: url.pathname === "/terminal" ? "terminal" : "screen" } })
|
|
533
|
+
? undefined : new Response("websocket required", { status: 426 });
|
|
584
534
|
|
|
585
535
|
const res = await route(req, url);
|
|
586
536
|
return isScript ? (res.headers.set("Access-Control-Allow-Origin", "*"), res) : withCors(res, origin);
|
|
@@ -593,6 +543,7 @@ export async function startServer(opts: ServerOptions) {
|
|
|
593
543
|
session: SESSION,
|
|
594
544
|
transcript,
|
|
595
545
|
stop() {
|
|
546
|
+
terminal.stop();
|
|
596
547
|
timers.forEach(clearInterval);
|
|
597
548
|
server.stop(true);
|
|
598
549
|
},
|
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
|
|
3
|
+
export type SocketData = { kind: "screen" | "terminal" };
|
|
4
|
+
type Socket = ServerWebSocket<SocketData>;
|
|
5
|
+
const MAX_MESSAGE = 128 * 1024;
|
|
6
|
+
|
|
7
|
+
// The CLI owns Herdr's socket handshake and emits ordered ANSI frame envelopes.
|
|
8
|
+
// Never use --takeover: another controller must release its session first.
|
|
9
|
+
export function createTerminal(pane: string) {
|
|
10
|
+
let owner: Socket | null = null;
|
|
11
|
+
let childProcess: ReturnType<typeof Bun.spawn> | null = null;
|
|
12
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
13
|
+
|
|
14
|
+
function close(ws: Socket) {
|
|
15
|
+
if (owner !== ws) return;
|
|
16
|
+
owner = null;
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
const child = childProcess;
|
|
19
|
+
childProcess = null;
|
|
20
|
+
if (child) {
|
|
21
|
+
try { (child.stdin as any).end(); } catch {}
|
|
22
|
+
child.kill(); // Disconnect only; Herdr keeps the underlying pane alive.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function fail(ws: Socket, message: string) {
|
|
27
|
+
ws.send(JSON.stringify({ type: "terminal.error", message }));
|
|
28
|
+
close(ws);
|
|
29
|
+
ws.close(1011, "Terminal disconnected");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function send(ws: Socket, frame: unknown) {
|
|
33
|
+
if (owner !== ws) return;
|
|
34
|
+
if (ws.getBufferedAmount() > 4 * 1024 * 1024) {
|
|
35
|
+
fail(ws, "연결이 느려 터미널을 닫았습니다. 다시 연결해 주세요.");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
ws.send(JSON.stringify(frame));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function pump(ws: Socket, child: ReturnType<typeof Bun.spawn>) {
|
|
42
|
+
let pending = "";
|
|
43
|
+
let stderr = "";
|
|
44
|
+
let received = false;
|
|
45
|
+
const readError = (async () => {
|
|
46
|
+
for await (const chunk of child.stderr as ReadableStream<Uint8Array>) {
|
|
47
|
+
stderr = (stderr + new TextDecoder().decode(chunk)).slice(-2000);
|
|
48
|
+
}
|
|
49
|
+
})();
|
|
50
|
+
try {
|
|
51
|
+
const decoder = new TextDecoder();
|
|
52
|
+
for await (const chunk of child.stdout as ReadableStream<Uint8Array>) {
|
|
53
|
+
if (owner !== ws) break;
|
|
54
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
55
|
+
if (pending.length > 8 * 1024 * 1024) throw new Error("Terminal frame too large");
|
|
56
|
+
let end: number;
|
|
57
|
+
while ((end = pending.indexOf("\n")) >= 0) {
|
|
58
|
+
const line = pending.slice(0, end);
|
|
59
|
+
pending = pending.slice(end + 1);
|
|
60
|
+
if (!line.trim()) continue;
|
|
61
|
+
const frame = JSON.parse(line);
|
|
62
|
+
if (frame.type === "terminal.frame" && frame.encoding === "ansi") {
|
|
63
|
+
if (!received) { received = true; clearTimeout(timer); }
|
|
64
|
+
send(ws, frame);
|
|
65
|
+
} else if (frame.type === "terminal.closed") {
|
|
66
|
+
fail(ws, frame.reason || "터미널 연결이 종료되었습니다.");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
await child.exited;
|
|
71
|
+
await readError;
|
|
72
|
+
if (owner === ws) fail(ws, stderr.trim() || "터미널 연결이 종료되었습니다. 다시 연결해 주세요.");
|
|
73
|
+
} catch {
|
|
74
|
+
if (owner === ws) fail(ws, "터미널 스트림을 읽을 수 없습니다.");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
open(ws: Socket) {
|
|
80
|
+
if (owner) {
|
|
81
|
+
ws.send(JSON.stringify({ type: "terminal.error", message: "다른 브라우저가 터미널을 사용 중입니다." }));
|
|
82
|
+
ws.close(1008, "Terminal busy");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
owner = ws;
|
|
86
|
+
timer = setTimeout(() => fail(ws, "터미널 연결 시간이 초과되었습니다."), 10000);
|
|
87
|
+
},
|
|
88
|
+
message(ws: Socket, raw: string) {
|
|
89
|
+
if (owner !== ws) return;
|
|
90
|
+
try {
|
|
91
|
+
if (raw.length > MAX_MESSAGE) throw new Error("Message too large");
|
|
92
|
+
const msg = JSON.parse(raw);
|
|
93
|
+
const size = () => Number.isInteger(msg.cols) && Number.isInteger(msg.rows)
|
|
94
|
+
&& msg.cols >= 2 && msg.cols <= 500 && msg.rows >= 2 && msg.rows <= 300;
|
|
95
|
+
if (msg.type === "terminal.start" && !childProcess && size()) {
|
|
96
|
+
childProcess = Bun.spawn([Bun.which("herdr", { PATH: process.env.PATH }) ?? "herdr", "terminal", "session", "control", pane,
|
|
97
|
+
"--cols", String(msg.cols), "--rows", String(msg.rows)],
|
|
98
|
+
{ stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
99
|
+
void pump(ws, childProcess);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!childProcess) throw new Error("Terminal not started");
|
|
103
|
+
let command: object;
|
|
104
|
+
if (msg.type === "terminal.input" && typeof msg.text === "string" && msg.text.length <= 65536) {
|
|
105
|
+
command = { type: msg.type, text: msg.text };
|
|
106
|
+
} else if (msg.type === "terminal.resize" && size()) {
|
|
107
|
+
command = { type: msg.type, cols: msg.cols, rows: msg.rows };
|
|
108
|
+
} else if (msg.type === "terminal.scroll" && ["up", "down"].includes(msg.direction)
|
|
109
|
+
&& Number.isInteger(msg.lines) && msg.lines > 0 && msg.lines <= 100) {
|
|
110
|
+
command = { type: msg.type, direction: msg.direction, lines: msg.lines };
|
|
111
|
+
} else throw new Error("Invalid terminal message");
|
|
112
|
+
const input = childProcess.stdin as Bun.FileSink;
|
|
113
|
+
if (input.write(JSON.stringify(command) + "\n") > 1024 * 1024) throw new Error("Input overflow");
|
|
114
|
+
input.flush();
|
|
115
|
+
} catch {
|
|
116
|
+
fail(ws, "터미널 요청을 처리할 수 없습니다.");
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
close,
|
|
120
|
+
stop() { if (owner) close(owner); },
|
|
121
|
+
};
|
|
122
|
+
}
|