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/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
- // (action "annotate": + target {selector, label, rect, text?, url?} from inspect)
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
- // WS /input user control: CDP Input.* commands forwarded to the page;
11
- // {id, inspect: {x, y} | {selector}, full?} -> {id, result}: element at a point (annotation)
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 { homedir } from "node:os";
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 { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
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
- // Annotation prompt: the viewer's request plus the element it points at, in
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
- herdrTab: async () => (await agentInfo())?.tab_id,
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
- if (!next.on) player.stop();
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 at = where();
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: presentView(), notified });
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: presentView() });
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", presentView());
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; target?: Inspected };
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
- let text = data.text ?? "";
488
- if (action === "annotate") {
489
- const t = formatAnnotation(text, data.target);
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: presentView(),
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 "/present":
525
- return req.method === "POST" ? presentRequest(req) : Response.json(presentView());
526
- case "/script":
527
- return scriptRequest(req);
528
- case "/player":
529
- if (req.method === "POST") return playerRequest(req);
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
- async message(ws, msg) {
541
- const raw = String(msg);
542
- let m: any;
543
- try {
544
- m = JSON.parse(raw);
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 = url.pathname === "/agent-bridge.js";
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) ? undefined : new Response("websocket required", { status: 426 });
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
  },
@@ -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
+ }