agello 0.4.1 → 0.4.2

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 CHANGED
@@ -7,6 +7,7 @@
7
7
  - Conversation saved per pane in the browser, kept across reloads
8
8
  - Interactive terminal view of the existing herdr pane: live cursor, keyboard input, and automatic resize
9
9
  - Optional live view of a `terminal-browser` screen, with an on/off switch to control it yourself
10
+ - Annotate: point at an element on that screen and send a request about it, with its CSS selector
10
11
  - Embeddable `<agent-bridge>` web component
11
12
 
12
13
  Chat currently supports **Claude Code** sessions. The terminal view connects directly to the pane, including ordinary shells and other terminal applications.
@@ -60,6 +61,18 @@ Messages sent from the page arrive in the agent as:
60
61
 
61
62
  The approve / reject buttons send `action=approve` / `action=reject`.
62
63
 
64
+ ### Annotate
65
+
66
+ The `주석` button on the screen panel turns on annotate mode: hovering tints the element under the pointer (with its tag and size), clicking outlines it and opens an input next to it. The wheel still scrolls the page, and the outline follows the element as the page changes. The request arrives as:
67
+
68
+ ```
69
+ [browser] action=annotate make this red / and bolder
70
+ 대상: #app > main > button:nth-of-type(2)
71
+ 요소: button.primary "Save" · 위치 120,340 크기 96x32 · http://localhost:3000/
72
+ ```
73
+
74
+ Selector: nearest unique `id` / `data-testid` ancestor, then `tag:nth-of-type` steps (light DOM only). Coordinates are CSS px of the viewport. Off while presenting and while "내 조작" is on.
75
+
63
76
  ### Commands
64
77
 
65
78
  | Command | Description |
@@ -70,6 +83,8 @@ The approve / reject buttons send `action=approve` / `action=reject`.
70
83
  | `agello open [--port N \| --pane ID]` | Open the page (default: this pane's server) |
71
84
  | `agello present [x,y,w,h] [--port N \| --pane ID]` | Presentation mode (see below) |
72
85
  | `agello present stop` | End presentation mode |
86
+ | `agello script load <file> \| show` | Load / show a presentation script |
87
+ | `agello present resume \| pause \| goto <n[.m]>` | Play the script from where it stopped / stop it / move to a step |
73
88
 
74
89
  `start` options: `--pane <id>`, `--port <n>` (default: first free from 8765), `--session <id>`, `--browser <terminal-browser key>`, `--allow-origin <origin>` (repeatable), `--open`, `--foreground`.
75
90
 
@@ -102,6 +117,23 @@ The xterm.js renderer and its styles are served locally; no terminal CDN is requ
102
117
 
103
118
  Run `present` again to move the crop. User control is off while presenting.
104
119
 
120
+ #### Scripts
121
+
122
+ Write the talk ahead so each line appears the moment its screen does:
123
+
124
+ ```json
125
+ { "rect": "28,157,688,477",
126
+ "steps": [
127
+ { "go": "#1", "say": ["First line", "Second line"] },
128
+ { "go": "#2", "say": ["..."], "hold": 6 } ] }
129
+ ```
130
+
131
+ - step: `go` brings the screen there (`#n` sets `location.hash`, anything else is JS run in the page; agello knows nothing about the deck), `say` lines are one bubble each, `hold` seconds a line stays on screen (default from length, 10 to 20s; never below 10s). Pacing: screen change, 1s, line, fade out, 0.3s, next line; after a step's last line 0.7s before the next screen
132
+ - `present resume` plays from where it stopped (starts presentation mode with the script's `rect`); it first re-runs the current step's `go`, so the agent may move the screen freely while answering
133
+ - raising a hand pauses at once; the agent gets `action=hand-raise` with the position (`마지막 표시 2.1 · 다음 2.2`)
134
+ - `script load` again after editing keeps the position; `present goto 2.2` then `present resume` replays from a fixed line
135
+ - at the end the agent gets `action=present-done`; while the script is paused and the agent is working, the page shows a small loader
136
+
105
137
  ## Embed
106
138
 
107
139
  ```html
package/bin/agello.ts CHANGED
@@ -27,6 +27,13 @@ Usage:
27
27
  whole screen), with agent replies as bubbles that fade after 10s.
28
28
  Run again to move the crop.
29
29
  ${NAME} present stop [--port N|--pane ID] End presentation mode (bubbles stay in the chat)
30
+ ${NAME} script load <file.json> Load a presentation script (keeps the current position):
31
+ {"rect": "x,y,w,h", "steps": [{"go": "#1", "say": ["line", ...], "hold": 6}]}
32
+ go: "#n" sets location.hash, otherwise JS run in the page; one line = one bubble
33
+ ${NAME} script show Script with positions (step.line) and player state
34
+ ${NAME} present resume Play the script from where it stopped (starts presentation mode)
35
+ ${NAME} present pause Stop the script (raising a hand on the page also pauses)
36
+ ${NAME} present goto <n[.m]> Move to step n (line m) and show its screen
30
37
  ${NAME} help | --version
31
38
 
32
39
  start options:
@@ -284,6 +291,56 @@ async function cmdOpen(argv: string[]) {
284
291
  console.log(inst.url);
285
292
  }
286
293
 
294
+ async function api(url: string, path: string, body?: object): Promise<any> {
295
+ try {
296
+ const r = await fetch(`${url}${path}`, {
297
+ method: body ? "POST" : "GET",
298
+ headers: { "Content-Type": "application/json" },
299
+ body: body ? JSON.stringify(body) : undefined,
300
+ signal: AbortSignal.timeout(8000),
301
+ });
302
+ return await r.json();
303
+ } catch {
304
+ return fail(`server unreachable: ${url}`);
305
+ }
306
+ }
307
+
308
+ const playerLine = (p: any) =>
309
+ `script: ${p.state}${p.last ? ` last=${p.last}` : ""}${p.next ? ` next=${p.next}` : ""} steps=${p.steps}`;
310
+
311
+ async function cmdScript(argv: string[]) {
312
+ const { values: o, positionals } = parseArgs({
313
+ args: argv,
314
+ options: { port: { type: "string" }, pane: { type: "string" } },
315
+ allowPositionals: true,
316
+ });
317
+ const [sub, file] = positionals;
318
+ const inst = await resolveTarget(o);
319
+ if (sub === "load") {
320
+ if (!file) fail("usage: script load <file.json>");
321
+ let script: unknown;
322
+ try {
323
+ script = await Bun.file(file!).json();
324
+ } catch (e) {
325
+ fail(`cannot read ${file}: ${(e as Error).message}`);
326
+ }
327
+ const res = await api(inst.url, "/script", { script });
328
+ if (!res.ok) fail(res.error);
329
+ console.log(playerLine(res.player));
330
+ } else if (sub === "show" || !sub) {
331
+ const res = await api(inst.url, "/script");
332
+ console.log(playerLine(res.player));
333
+ for (const [i, st] of (res.script?.steps ?? []).entries()) {
334
+ console.log(`${i + 1}${st.go ? ` go=${st.go}` : ""}${st.hold ? ` hold=${st.hold}s` : ""}`);
335
+ st.say.forEach((l: string, j: number) => {
336
+ const at = `${i + 1}.${j + 1}`;
337
+ const mark = at === res.player.next ? ">" : at === res.player.last ? "*" : " ";
338
+ console.log(` ${mark} ${at} ${l}`);
339
+ });
340
+ }
341
+ } else fail(`unknown script command: ${sub}`);
342
+ }
343
+
287
344
  async function cmdPresent(argv: string[]) {
288
345
  const { values: o, positionals } = parseArgs({
289
346
  args: argv,
@@ -291,6 +348,14 @@ async function cmdPresent(argv: string[]) {
291
348
  allowPositionals: true,
292
349
  });
293
350
  const inst = await resolveTarget(o);
351
+ const [sub, at] = positionals;
352
+ if (sub === "resume" || sub === "pause" || sub === "goto") {
353
+ if (sub === "goto" && !at) fail("usage: present goto <n[.m]>");
354
+ const res = await api(inst.url, "/player", { cmd: sub, at });
355
+ if (!res.ok) fail(`${res.error}${res.player ? ` (${playerLine(res.player)})` : ""}`);
356
+ console.log(playerLine(res.player));
357
+ return;
358
+ }
294
359
  const arg = positionals.join(",");
295
360
  let body: object;
296
361
  if (arg === "stop") body = { stop: true };
@@ -337,6 +402,9 @@ switch (cmd) {
337
402
  case "present":
338
403
  await cmdPresent(rest);
339
404
  break;
405
+ case "script":
406
+ await cmdScript(rest);
407
+ break;
340
408
  case "--version":
341
409
  case "-v":
342
410
  console.log(pkg.version);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agello",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Talk to a coding agent in a herdr pane from the browser: chat, live status, and a shared browser screen.",
5
5
  "keywords": [
6
6
  "herdr",
package/src/player.ts ADDED
@@ -0,0 +1,234 @@
1
+ // Presentation script player.
2
+ //
3
+ // A script is a list of steps. Each step has an optional `go` (how to bring the
4
+ // screen to that step: "#3" sets location.hash, anything else is evaluated as
5
+ // JS in the page; written by the agent, agello knows nothing about the deck)
6
+ // and lines (`say`), each shown as one bubble.
7
+ //
8
+ // The player owns order and timing: entering a step runs its `go`, then every
9
+ // line is sent and held for its reading time (see PACE). pause() stops at once and keeps
10
+ // the position, resume() re-runs the current step's `go` (the agent may have
11
+ // moved the screen meanwhile) and continues from the next unshown line.
12
+ // goto() moves the position and shows that step without playing.
13
+ //
14
+ // Positions are "step.line", 1-based, in everything that leaves this module.
15
+
16
+ import type { Rect } from "./screencast.ts";
17
+
18
+ export type Step = { go?: string; say: string[]; hold?: number };
19
+ export type Script = { rect?: Rect; steps: Step[] };
20
+ export type PlayerInfo = {
21
+ state: "empty" | "ready" | "playing" | "paused" | "done";
22
+ next?: string; // next line to show ("3.2")
23
+ last?: string; // last line shown
24
+ steps: number;
25
+ };
26
+
27
+ // Pacing: the audience looks at a new screen first, then reads and thinks.
28
+ // go -> 1s -> line (shown >= 10s) -> fades out -> 0.3s -> next line
29
+ // last line -> fades out -> 0.7s -> next step's go
30
+ const MIN_HOLD = 10;
31
+ const MAX_HOLD = 20;
32
+ export const PACE = { afterGo: 1000, fade: 500, betweenLines: 300, afterStep: 700 };
33
+
34
+ // How long one line stays on screen (s): at least 10s, longer text a bit more
35
+ // (about 8 chars/s past the first ~50 chars), at most 20s. `hold` can only
36
+ // lengthen it.
37
+ export function lineHold(text: string, hold?: number): number {
38
+ const auto = Math.min(MAX_HOLD, Math.max(MIN_HOLD, 4 + [...text].length / 8));
39
+ return hold != null && Number.isFinite(hold) && hold > 0 ? Math.max(MIN_HOLD, hold) : auto;
40
+ }
41
+
42
+ export function parseRectString(v: unknown): Rect | undefined {
43
+ if (v && typeof v === "object") {
44
+ const r = v as any;
45
+ return { x: r.x, y: r.y, w: r.w, h: r.h };
46
+ }
47
+ if (typeof v !== "string") return undefined;
48
+ const n = v.split(/[\s,]+/).filter(Boolean).map(Number);
49
+ return n.length === 4 ? { x: n[0], y: n[1], w: n[2], h: n[3] } : undefined;
50
+ }
51
+
52
+ // Validate a script from JSON. Returns the script or an error message.
53
+ export function parseScript(raw: any): Script | string {
54
+ if (!raw || typeof raw !== "object") return "script must be an object";
55
+ if (!Array.isArray(raw.steps) || !raw.steps.length) return "steps must be a non-empty array";
56
+ const steps: Step[] = [];
57
+ for (const [i, s] of raw.steps.entries()) {
58
+ const at = `steps[${i}]`;
59
+ if (!s || typeof s !== "object") return `${at} must be an object`;
60
+ if (s.go != null && typeof s.go !== "string") return `${at}.go must be a string`;
61
+ const say = s.say == null ? [] : Array.isArray(s.say) ? s.say : [s.say];
62
+ if (!say.every((l: unknown) => typeof l === "string" && l.trim())) return `${at}.say must be non-empty strings`;
63
+ if (s.hold != null && !(Number.isFinite(s.hold) && s.hold > 0)) return `${at}.hold must be a positive number`;
64
+ steps.push({ go: s.go || undefined, say: say.map((l: string) => l.trim()), hold: s.hold });
65
+ }
66
+ const rect = raw.rect == null ? undefined : parseRectString(raw.rect);
67
+ if (raw.rect != null && !rect) return "rect must be \"x,y,w,h\" or {x,y,w,h}";
68
+ return { rect, steps };
69
+ }
70
+
71
+ // "3" or "3.2" (1-based) -> 0-based position
72
+ export function parsePos(v: string): { step: number; line: number } | null {
73
+ const m = String(v).trim().match(/^(\d+)(?:\.(\d+))?$/);
74
+ if (!m) return null;
75
+ const step = Number(m[1]) - 1;
76
+ const line = m[2] ? Number(m[2]) - 1 : 0;
77
+ return step >= 0 && line >= 0 ? { step, line } : null;
78
+ }
79
+
80
+ export function createPlayer(deps: {
81
+ go: (expr: string) => Promise<void>;
82
+ say: (text: string, at: string, hold: number) => void;
83
+ done: () => void;
84
+ changed: () => void;
85
+ }) {
86
+ let script: Script | null = null;
87
+ let state: PlayerInfo["state"] = "empty";
88
+ let cur = { step: 0, line: 0 }; // next line to show
89
+ let last: { step: number; line: number } | null = null;
90
+ let entered = false; // `go` of cur.step already run in this play run
91
+ let run = 0; // bumps on every pause/goto/load, stale timers check it
92
+ let timer: Timer | null = null;
93
+
94
+ const fmt = (p: { step: number; line: number }) => `${p.step + 1}.${p.line + 1}`;
95
+ const set = (s: PlayerInfo["state"]) => {
96
+ state = s;
97
+ deps.changed();
98
+ };
99
+ const cancel = () => {
100
+ run++;
101
+ if (timer) clearTimeout(timer);
102
+ timer = null;
103
+ };
104
+ const wait = (ms: number, id: number, fn: () => void) => {
105
+ timer = setTimeout(() => id === run && fn(), ms);
106
+ };
107
+
108
+ function info(): PlayerInfo {
109
+ const steps = script?.steps.length ?? 0;
110
+ // next line as it will be played (a finished step points at the next step)
111
+ let p = cur;
112
+ while (script && p.step < steps && p.line >= Math.max(1, script.steps[p.step].say.length))
113
+ p = { step: p.step + 1, line: 0 };
114
+ return {
115
+ state,
116
+ steps,
117
+ next: script && p.step < steps ? fmt(p) : undefined,
118
+ last: last ? fmt(last) : undefined,
119
+ };
120
+ }
121
+
122
+ // Skip past finished steps (cur.line beyond the step's lines).
123
+ function normalize() {
124
+ if (!script) return;
125
+ while (cur.step < script.steps.length && cur.line >= Math.max(1, script.steps[cur.step].say.length)) {
126
+ cur = { step: cur.step + 1, line: 0 };
127
+ entered = false;
128
+ }
129
+ }
130
+
131
+ async function tick(id: number) {
132
+ if (id !== run || state !== "playing" || !script) return;
133
+ normalize();
134
+ if (cur.step >= script.steps.length) {
135
+ set("done");
136
+ deps.done();
137
+ return;
138
+ }
139
+ const step = script.steps[cur.step];
140
+ if (!entered) {
141
+ entered = true;
142
+ if (step.go) {
143
+ await deps.go(step.go);
144
+ if (id !== run) return;
145
+ wait(PACE.afterGo, id, () => tick(id));
146
+ return;
147
+ }
148
+ }
149
+ if (!step.say.length) {
150
+ // silent step: just show the screen for `hold` seconds
151
+ cur = { step: cur.step, line: 1 };
152
+ deps.changed();
153
+ wait(lineHold("", step.hold) * 1000 + PACE.afterStep, id, () => tick(id));
154
+ return;
155
+ }
156
+ const text = step.say[cur.line];
157
+ const hold = lineHold(text, step.hold);
158
+ const lastLine = cur.line === step.say.length - 1;
159
+ last = { ...cur };
160
+ deps.say(text, fmt(cur), hold);
161
+ cur = { step: cur.step, line: cur.line + 1 };
162
+ deps.changed();
163
+ const gap = lastLine ? PACE.afterStep : PACE.betweenLines;
164
+ wait(hold * 1000 + PACE.fade + gap, id, () => tick(id));
165
+ }
166
+
167
+ return {
168
+ info,
169
+ script: () => script,
170
+
171
+ // Replace the script, keeping the position (clamped) and the state.
172
+ load(next: Script) {
173
+ const playing = state === "playing";
174
+ cancel();
175
+ script = next;
176
+ if (cur.step > next.steps.length) cur = { step: next.steps.length, line: 0 };
177
+ normalize();
178
+ if (state === "empty") state = "ready";
179
+ if (state === "done" && cur.step < next.steps.length) state = "paused";
180
+ if (playing) {
181
+ entered = true; // screen is already on this step
182
+ tick(run);
183
+ }
184
+ deps.changed();
185
+ },
186
+
187
+ resume(): string | null {
188
+ if (!script) return "no_script";
189
+ if (state === "playing") return null;
190
+ if (state === "done") return "done";
191
+ cancel();
192
+ entered = false; // bring the screen back to the current step first
193
+ set("playing");
194
+ tick(run);
195
+ return null;
196
+ },
197
+
198
+ pause() {
199
+ if (state !== "playing") return;
200
+ cancel();
201
+ set("paused");
202
+ },
203
+
204
+ // Move to a step (and line) and show its screen. Does not play; if it was
205
+ // playing, it keeps playing from there.
206
+ async goto(pos: { step: number; line: number }): Promise<string | null> {
207
+ if (!script) return "no_script";
208
+ const step = script.steps[pos.step];
209
+ if (!step) return "no_such_step";
210
+ if (pos.line > 0 && pos.line >= step.say.length) return "no_such_line";
211
+ const playing = state === "playing";
212
+ cancel();
213
+ cur = { ...pos };
214
+ entered = false;
215
+ if (playing) {
216
+ tick(run);
217
+ return null;
218
+ }
219
+ if (state !== "ready") state = "paused";
220
+ if (step.go) await deps.go(step.go);
221
+ entered = true;
222
+ deps.changed();
223
+ return null;
224
+ },
225
+
226
+ // Presentation ended: stop where it is.
227
+ stop() {
228
+ if (state === "playing") {
229
+ cancel();
230
+ set("paused");
231
+ }
232
+ },
233
+ };
234
+ }
package/src/screencast.ts CHANGED
@@ -268,5 +268,75 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
268
268
  return browser ? { connected: false, browser: browser.key } : { connected: false, reason };
269
269
  }
270
270
 
271
- return { handle, input, describe, setClip };
271
+ // Run JS in the relayed tab (presentation script `go`). Needs the relay to be
272
+ // connected, i.e. at least one viewer. Returns false if it could not run.
273
+ async function evaluate(expression: string): Promise<boolean> {
274
+ const r = await request("Runtime.evaluate", { expression, awaitPromise: true });
275
+ return !!r && !r.exceptionDetails;
276
+ }
277
+
278
+ // Annotation: which element is at a point (hover / click) or where a
279
+ // selected element is now (by selector). Coordinates are CSS px of the
280
+ // visible viewport, same as the full screencast frame. Not while cropped.
281
+ async function inspect(q: InspectQuery): Promise<Inspected | null> {
282
+ if (clip) return null;
283
+ const r = await request("Runtime.evaluate", {
284
+ expression: `(${INSPECT_JS})(${JSON.stringify(q)})`,
285
+ returnByValue: true,
286
+ });
287
+ return r && !r.exceptionDetails ? (r.result?.value ?? null) : null;
288
+ }
289
+
290
+ return { handle, input, describe, setClip, evaluate, inspect };
272
291
  }
292
+
293
+ export type InspectQuery = { x?: number; y?: number; selector?: string; full?: boolean };
294
+ export type Inspected = {
295
+ selector: string;
296
+ label: string; // tag#id.class, for the hover tooltip
297
+ rect: { x: number; y: number; w: number; h: number };
298
+ text?: string; // full only
299
+ url?: string; // full only
300
+ };
301
+
302
+ // Runs in the page. Selector: nearest unique id / data-testid ancestor, then
303
+ // tag:nth-of-type steps down to the element (light DOM only).
304
+ const INSPECT_JS = String((q: InspectQuery) => {
305
+ let el: Element | null = null;
306
+ if (q.selector) {
307
+ try {
308
+ el = document.querySelector(q.selector);
309
+ } catch {}
310
+ } else el = document.elementFromPoint(q.x ?? 0, q.y ?? 0);
311
+ if (!el || el === document.documentElement) return null;
312
+ const esc = CSS.escape;
313
+ const unique = (s: string) => document.querySelectorAll(s).length === 1;
314
+ const step = (e: Element): [string, boolean] => {
315
+ if (e.id && unique(`#${esc(e.id)}`)) return [`#${esc(e.id)}`, true];
316
+ for (const a of ["data-testid", "data-test", "data-cy"]) {
317
+ const v = e.getAttribute(a);
318
+ if (v && unique(`[${a}="${esc(v)}"]`)) return [`[${a}="${esc(v)}"]`, true];
319
+ }
320
+ const same = e.parentElement ? [...e.parentElement.children].filter((c) => c.localName === e.localName) : [];
321
+ return [same.length > 1 ? `${e.localName}:nth-of-type(${same.indexOf(e) + 1})` : e.localName, false];
322
+ };
323
+ const parts: string[] = [];
324
+ for (let e: Element | null = el; e && e !== document.documentElement; e = e.parentElement) {
325
+ const [s, anchor] = step(e);
326
+ parts.unshift(s);
327
+ if (anchor || e === document.body) break;
328
+ }
329
+ const cls = [...el.classList].slice(0, 2).map((c) => `.${c}`).join("");
330
+ const r = el.getBoundingClientRect();
331
+ const out: any = {
332
+ selector: parts.join(" > "),
333
+ label: `${el.localName}${el.id ? `#${el.id}` : ""}${cls}`,
334
+ rect: { x: r.x, y: r.y, w: r.width, h: r.height },
335
+ };
336
+ if (q.full) {
337
+ const t = (el as HTMLInputElement).value || el.getAttribute("aria-label") || el.getAttribute("alt") || el.textContent || "";
338
+ out.text = t.replace(/\s+/g, " ").trim().slice(0, 80);
339
+ out.url = location.href;
340
+ }
341
+ return out;
342
+ });
package/src/server.ts CHANGED
@@ -6,17 +6,22 @@
6
6
  // GET /events SSE: status, chat messages, tool start/end (from transcript JSONL)
7
7
  // POST /send {action, text, images?: [{type, data(base64)}]} -> herdr agent prompt
8
8
  // (images are saved to $TMPDIR/agello-uploads and sent as [image: <path>])
9
+ // (action "annotate": + target {selector, label, rect, text?, url?} from inspect)
9
10
  // GET /screen SSE: terminal-browser screen frames (CDP screencast)
10
11
  // POST /upload {images: [{type, data(base64)}]} -> {paths}: terminal image paste
11
12
  // WS /terminal interactive herdr terminal frames, keyboard input, resize
12
13
  // GET /panes herdr workspace -> tab -> pane tree
13
14
  // POST /panes/connect {pane} -> url of that pane's server (started if needed)
14
15
  // POST /panes/create {kind: workspace|tab|pane, cwd?, label?, workspace?, pane?, direction?}
15
- // WS /input user control: CDP Input.* commands forwarded to the page
16
+ // WS /input user control: CDP Input.* commands forwarded to the page;
17
+ // {id, inspect: {x, y} | {selector}, full?} -> {id, result}: element at a point (annotation)
16
18
  // GET /present presentation state
17
19
  // POST /present {rect?: {x,y,w,h}} start (or move the crop), {stop: true} end
18
20
  // ({stop: true, from: "viewer"}: ended from the page, the agent is told
19
21
  // with "[browser] action=present-stop")
22
+ // GET /script presentation script + player position
23
+ // POST /script {script: {rect?, steps: [{go?, say: string[], hold?}]}} load (keeps position)
24
+ // POST /player {cmd: "resume" | "pause" | "goto", at?: "3" | "3.2"}
20
25
 
21
26
  import { createTerminal, type SocketData } from "./terminal.ts";
22
27
  import { panesRoute } from "./panes.ts";
@@ -24,7 +29,8 @@ import { mkdir } from "node:fs/promises";
24
29
  import { homedir, tmpdir } from "node:os";
25
30
  import { join } from "node:path";
26
31
  import { runJson } from "./run.ts";
27
- import { createScreencast, type Rect } from "./screencast.ts";
32
+ import { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
33
+ import { createScreencast, type Inspected, type Rect } from "./screencast.ts";
28
34
 
29
35
  export type ServerOptions = {
30
36
  port: number;
@@ -123,7 +129,22 @@ export function parseRect(r: any): Rect | null {
123
129
  return { x, y, w, h };
124
130
  }
125
131
 
126
- export type Present = { on: boolean; rect?: Rect; since?: string };
132
+ // Annotation prompt: the viewer's request plus the element it points at, in
133
+ // 3 lines (request / selector / element summary).
134
+ export function formatAnnotation(text: string, t: any): string | null {
135
+ if (!t || typeof t.selector !== "string" || !t.selector || t.selector.length > 1000) return null;
136
+ const clean = (v: unknown, n: number) => String(v ?? "").replace(/\s+/g, " ").trim().slice(0, n);
137
+ const request = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).join(" / ");
138
+ const r = t.rect ?? {};
139
+ const [x, y, w, h] = [r.x, r.y, r.w, r.h].map((v) => Math.round(Number(v) || 0));
140
+ const label = clean(t.label, 120);
141
+ const snippet = clean(t.text, 80);
142
+ const url = clean(t.url, 300);
143
+ const info = [`요소: ${label}${snippet ? ` "${snippet}"` : ""}`, `위치 ${x},${y} 크기 ${w}x${h}`, url].filter(Boolean);
144
+ return [request, `대상: ${clean(t.selector, 1000)}`, info.join(" · ")].join("\n");
145
+ }
146
+
147
+ export type Present = { on: boolean; rect?: Rect; since?: string; player?: PlayerInfo };
127
148
 
128
149
  type Status =
129
150
  | { alive: false; reason: string; pane: string }
@@ -351,10 +372,67 @@ export async function startServer(opts: ServerOptions) {
351
372
  // short-lived bubbles. rect: crop of the visible viewport (unset = whole screen).
352
373
 
353
374
  let present: Present = { on: false };
375
+ const presentView = (): Present => ({ ...present, player: player.info() });
354
376
  function setPresent(next: Present) {
355
377
  present = next;
356
378
  screen.setClip(next.on ? (next.rect ?? null) : null);
357
- broadcast("present", present);
379
+ if (!next.on) player.stop();
380
+ broadcast("present", presentView());
381
+ }
382
+
383
+ // Script player: agello owns order and timing, the agent owns the deck
384
+ // (each step's `go`) and decides when to resume after a question.
385
+ const player = createPlayer({
386
+ async go(expr) {
387
+ const js = expr.startsWith("#") ? `location.hash = ${JSON.stringify(expr)}` : expr;
388
+ if (!(await screen.evaluate(js))) console.error(`present: go failed: ${expr}`);
389
+ },
390
+ say(text, at, hold) {
391
+ broadcast("message", { role: "assistant", text, ts: new Date().toISOString(), script: at, hold });
392
+ },
393
+ done() {
394
+ const n = player.info().steps;
395
+ promptAgent("present-done", `대본 끝까지 재생함 (${n}장)`);
396
+ },
397
+ changed() {
398
+ broadcast("present", presentView());
399
+ },
400
+ });
401
+
402
+ // "마지막 표시 3.2 · 다음 3.3" for agent notices
403
+ const where = () => {
404
+ const i = player.info();
405
+ if (i.state === "empty") return "";
406
+ return [i.last && `마지막 표시 ${i.last}`, i.next && `다음 ${i.next}`].filter(Boolean).join(" · ");
407
+ };
408
+
409
+ async function scriptRequest(req: Request): Promise<Response> {
410
+ if (req.method !== "POST") return Response.json({ script: player.script(), player: player.info() });
411
+ const data = (await req.json().catch(() => ({}))) as { script?: unknown };
412
+ const script = parseScript(data.script);
413
+ if (typeof script === "string") return Response.json({ ok: false, error: script }, { status: 400 });
414
+ if (script.rect && !parseRect(script.rect))
415
+ return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
416
+ player.load(script);
417
+ return Response.json({ ok: true, player: player.info() });
418
+ }
419
+
420
+ async function playerRequest(req: Request): Promise<Response> {
421
+ const data = (await req.json().catch(() => ({}))) as { cmd?: string; at?: string };
422
+ let error: string | null = null;
423
+ if (data.cmd === "resume") {
424
+ if (!present.on) {
425
+ const rect = player.script()?.rect;
426
+ setPresent({ on: true, rect: rect ? parseRect(rect)! : undefined, since: new Date().toISOString() });
427
+ }
428
+ error = player.resume();
429
+ } else if (data.cmd === "pause") player.pause();
430
+ else if (data.cmd === "goto") {
431
+ const pos = parsePos(data.at ?? "");
432
+ error = pos ? await player.goto(pos) : "invalid_position";
433
+ } else error = "unknown_cmd";
434
+ if (error) return Response.json({ ok: false, error, player: player.info() }, { status: 400 });
435
+ return Response.json({ ok: true, player: player.info() });
358
436
  }
359
437
 
360
438
  async function presentRequest(req: Request): Promise<Response> {
@@ -366,10 +444,11 @@ export async function startServer(opts: ServerOptions) {
366
444
  // (The CLI stop comes from the agent itself and needs no notice.)
367
445
  let notified: string | undefined;
368
446
  if (was && data.from === "viewer") {
369
- const r = await promptAgent("present-stop", "사용자가 페이지에서 발표를 종료함");
447
+ const at = where();
448
+ const r = await promptAgent("present-stop", `사용자가 페이지에서 발표를 종료함${at ? ` (대본 정지: ${at})` : ""}`);
370
449
  notified = r.ok ? "ok" : r.error;
371
450
  }
372
- return Response.json({ ok: true, present, notified });
451
+ return Response.json({ ok: true, present: presentView(), notified });
373
452
  }
374
453
  let rect: Rect | undefined;
375
454
  if (data.rect != null) {
@@ -377,7 +456,7 @@ export async function startServer(opts: ServerOptions) {
377
456
  if (!rect) return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
378
457
  }
379
458
  setPresent({ on: true, rect, since: present.on ? present.since : new Date().toISOString() });
380
- return Response.json({ ok: true, present });
459
+ return Response.json({ ok: true, present: presentView() });
381
460
  }
382
461
 
383
462
  // ----- HTTP -----
@@ -418,7 +497,7 @@ export async function startServer(opts: ServerOptions) {
418
497
  tools: [...pendingTools.keys()],
419
498
  });
420
499
  if (lastStatus) send(c, "status", lastStatus);
421
- send(c, "present", present);
500
+ send(c, "present", presentView());
422
501
  for (const t of pendingTools.values()) send(c, "tool_start", t);
423
502
  ping = setInterval(() => send(c, "ping", {}), 15000);
424
503
  },
@@ -443,11 +522,24 @@ export async function startServer(opts: ServerOptions) {
443
522
  }
444
523
 
445
524
  async function sendPrompt(req: Request): Promise<Response> {
446
- const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string; images?: unknown };
525
+ const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string; images?: unknown; target?: Inspected };
447
526
  const action = /^[\w-]{1,32}$/.test(data.action ?? "") ? data.action! : "message";
527
+ let text = data.text ?? "";
528
+ if (action === "annotate") {
529
+ const t = formatAnnotation(text, data.target);
530
+ if (!t || !text.trim()) return Response.json({ ok: false, error: "invalid_target" }, { status: 400 });
531
+ text = t;
532
+ }
533
+ // Raised hand: stop the script at once (no agent round trip) and tell the
534
+ // agent where it stopped.
535
+ if (action === "hand-raise") {
536
+ player.pause();
537
+ const at = where();
538
+ if (at) text += ` (대본 정지: ${at})`;
539
+ }
448
540
  const images = await saveImages(data.images);
449
541
  if (!images) return Response.json({ ok: false, error: "invalid_images" }, { status: 400 });
450
- const text = [data.text ?? "", ...images.map((p) => `[image: ${p}]`)].join("\n");
542
+ text = [text, ...images.map((p) => `[image: ${p}]`)].join("\n");
451
543
  const r = await promptAgent(action, text);
452
544
  return r.ok ? Response.json({ ok: true }) : Response.json({ ok: false, error: r.error }, { status: r.status });
453
545
  }
@@ -463,7 +555,7 @@ export async function startServer(opts: ServerOptions) {
463
555
  ...(await checkStatus()),
464
556
  browser: opts.browser,
465
557
  screen: await screen.describe(),
466
- present,
558
+ present: presentView(),
467
559
  });
468
560
  case "/events":
469
561
  return events();
@@ -488,7 +580,12 @@ export async function startServer(opts: ServerOptions) {
488
580
  }
489
581
  break;
490
582
  case "/present":
491
- return req.method === "POST" ? presentRequest(req) : Response.json(present);
583
+ return req.method === "POST" ? presentRequest(req) : Response.json(presentView());
584
+ case "/script":
585
+ return scriptRequest(req);
586
+ case "/player":
587
+ if (req.method === "POST") return playerRequest(req);
588
+ break;
492
589
  }
493
590
  return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
494
591
  }
@@ -500,9 +597,25 @@ export async function startServer(opts: ServerOptions) {
500
597
  websocket: {
501
598
  maxPayloadLength: 128 * 1024,
502
599
  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));
600
+ async message(ws, msg) {
601
+ const raw = String(msg);
602
+ if (ws.data.kind === "terminal") return terminal.message(ws, raw);
603
+ let m: any;
604
+ try {
605
+ m = JSON.parse(raw);
606
+ } catch {
607
+ return;
608
+ }
609
+ if (m?.inspect) {
610
+ const q = m.inspect;
611
+ const query =
612
+ typeof q.selector === "string"
613
+ ? { selector: q.selector.slice(0, 1000), full: !!q.full }
614
+ : { x: Number(q.x) || 0, y: Number(q.y) || 0, full: !!q.full };
615
+ ws.send(JSON.stringify({ id: m.id, result: await screen.inspect(query) }));
616
+ return;
617
+ }
618
+ screen.input(raw);
506
619
  },
507
620
  close(ws) { if (ws.data.kind === "terminal") terminal.close(ws); },
508
621
  },
@@ -12,6 +12,9 @@
12
12
  // no-history do not persist chat in localStorage (default: persisted per server + pane)
13
13
  // screen show live terminal-browser screen panel (side by side, stacked when narrow)
14
14
  // with a toggle that lets the viewer control the browser (mouse, wheel, keyboard)
15
+ // and an annotate toggle: hovering tints the element under the pointer, clicking
16
+ // outlines it and opens an input; the request goes to the agent with the element's
17
+ // CSS selector (action=annotate). The wheel still scrolls the page.
15
18
  //
16
19
  // Theming (CSS custom properties on the element or an ancestor)
17
20
  // --ab-bg --ab-panel --ab-text --ab-muted --ab-line --ab-accent --ab-user --ab-font
@@ -42,9 +45,12 @@
42
45
  // closing it without asking sends action=hand-lower so it can go on.
43
46
  // The viewer's questions show as bubbles too, with the same 10s lifetime.
44
47
  // An end button above it stops the presentation for every viewer.
48
+ // Script lines (`agello script` + `present resume`) arrive as agent messages
49
+ // with `script: "step.line"`. While the script is not playing and the agent
50
+ // is working (e.g. answering a question), a small loader is shown.
45
51
  //
46
52
  // Methods
47
- // el.send(text, action = 'message', images = [{type, data (base64)}]) -> Promise<{ok, error?}>
53
+ // el.send(text, action = 'message', extra) -> Promise<{ok, error?}> (extra: {images: [{type, data (base64)}]}, {target} for annotate)
48
54
  // el.clearHistory() clear saved chat for the current pane
49
55
  // el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
50
56
 
@@ -75,7 +81,7 @@ const SCREEN_REASONS = {
75
81
  };
76
82
  const ACTIONS = {
77
83
  message: "메시지", approve: "승인", reject: "거절",
78
- "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림",
84
+ "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", annotate: "주석",
79
85
  };
80
86
 
81
87
  const CSS = `
@@ -228,6 +234,30 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
228
234
  @media (prefers-reduced-motion: reduce) { .control-btn .track, .control-btn .knob { transition: none; } }
229
235
  .placeholder { font-size: 13px; color: var(--_muted); text-align: center; }
230
236
 
237
+ /* annotate: hover tint, selected outline, request input next to the element */
238
+ .annot-btn { flex: none; font-size: 12px; padding: 2px 10px; border-radius: 999px; }
239
+ .annot-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: #fff; }
240
+ .viewport.annot img { cursor: crosshair; }
241
+ .hl { position: absolute; z-index: 3; pointer-events: none; display: none; border-radius: 2px; }
242
+ .hl.on { display: block; }
243
+ .hl.hover { background: color-mix(in srgb, var(--_accent) 22%, transparent);
244
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--_accent) 55%, transparent); }
245
+ .hl.sel { box-shadow: 0 0 0 2px var(--_accent), 0 0 0 5px color-mix(in srgb, var(--_accent) 25%, transparent); }
246
+ .hl-label { position: absolute; z-index: 4; pointer-events: none; display: none; max-width: 60%;
247
+ padding: 2px 7px; border-radius: 5px; font: 11px/1.5 ui-monospace, Menlo, monospace; color: #fff;
248
+ background: rgba(20, 20, 22, .85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
249
+ .hl-label.on { display: block; }
250
+ .note { position: absolute; z-index: 5; width: min(340px, calc(100% - 16px)); padding: 10px; border-radius: 12px;
251
+ background: var(--_panel); border: 1px solid var(--_line); box-shadow: 0 8px 28px rgba(0,0,0,.22);
252
+ animation: capIn .18s ease; }
253
+ .note[hidden] { display: none; }
254
+ .note .note-target { font: 11px/1.4 ui-monospace, Menlo, monospace; color: var(--_muted); margin-bottom: 6px;
255
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
256
+ .note textarea { min-height: 52px; font-size: 14px; }
257
+ .note .note-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
258
+ .note .note-hint { flex: 1; font-size: 12px; color: var(--_muted); }
259
+ @media (prefers-reduced-motion: reduce) { .note { animation: none; } }
260
+
231
261
  /* presentation mode: the screen panel covers the window, chat stays hidden underneath.
232
262
  container-type would make the host the containing block of the fixed layer, so drop it. */
233
263
  :host([presenting]) { container-type: normal; }
@@ -255,6 +285,14 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
255
285
  .caption a { color: inherit; }
256
286
  @keyframes capIn { from { opacity: 0; translate: 0 10px; } }
257
287
 
288
+ /* agent busy while presenting (answering, fixing the script): minimal loader */
289
+ .busy { display: none; }
290
+ .body.present .busy.on { position: absolute; left: 22px; bottom: 28px; z-index: 5; display: flex; align-items: center;
291
+ gap: 9px; padding: 9px 14px; border-radius: 999px; font-size: 13px; color: #fff; background: rgba(20, 20, 22, .72);
292
+ -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
293
+ animation: capIn .25s ease; }
294
+ .busy .spinner { border-color: rgba(255,255,255,.25); border-top-color: #fff; }
295
+
258
296
  /* raise hand: ask the agent mid-presentation */
259
297
  .hand-wrap { display: none; }
260
298
  .body.present .hand-wrap { position: absolute; right: 22px; bottom: 28px; z-index: 5; display: flex;
@@ -401,9 +439,13 @@ const TEMPLATE = `
401
439
  </section>
402
440
  <aside class="screen" part="screen">
403
441
  <div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
442
+ <button class="annot-btn" type="button" aria-pressed="false" title="화면의 요소를 골라 에이전트에게 요청 (선택자와 함께 전달)">주석</button>
404
443
  <button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
405
444
  <span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
406
- <div class="viewport"><div class="ring"></div><div class="glow"></div><div class="agent-badge">에이전트 조작 중</div><img alt="에이전트 브라우저 화면" draggable="false"><textarea class="kbd" aria-label="브라우저 키보드 입력"></textarea><div class="placeholder">열린 terminal-browser 없음</div><div class="captions" part="captions" aria-live="polite"></div>
445
+ <div class="viewport"><div class="ring"></div><div class="glow"></div><div class="agent-badge">에이전트 조작 중</div><img alt="에이전트 브라우저 화면" draggable="false"><textarea class="kbd" aria-label="브라우저 키보드 입력"></textarea><div class="placeholder">열린 terminal-browser 없음</div><div class="hl hover"></div><div class="hl sel"></div><div class="hl-label"></div>
446
+ <form class="note" part="note" hidden><div class="note-target"></div><textarea aria-label="선택한 요소에 대한 요청" placeholder="이 요소에 대한 요청 (Enter 전송, Esc 취소)"></textarea>
447
+ <div class="note-row"><span class="note-hint"></span><button type="submit" class="primary">보내기</button></div></form><div class="captions" part="captions" aria-live="polite"></div>
448
+ <div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
407
449
  <div class="hand-wrap">
408
450
  <form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
409
451
  <div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
@@ -422,7 +464,14 @@ class AgentBridge extends HTMLElement {
422
464
  #root = this.attachShadow({ mode: "open" });
423
465
  #es = null;
424
466
  #screenEs = null;
425
- #controlWs = null;
467
+ #ws = null; // /input: control input and annotate inspect queries
468
+ #control = false;
469
+ #annot = false;
470
+ #hover = null; // inspected element under the pointer (annotate)
471
+ #hoverPt = null;
472
+ #sel = null; // selected element (annotate)
473
+ #inspectId = 0;
474
+ #inspectWait = new Map();
426
475
  #terminalCleanup = null;
427
476
  #terminalVersion = 0;
428
477
  #terminalStyles = null;
@@ -455,7 +504,9 @@ class AgentBridge extends HTMLElement {
455
504
  live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
456
505
  controlBtn: q(".control-btn"), kbd: q(".kbd"),
457
506
  body: q(".body"), screen: q(".screen"), captions: q(".captions"),
458
- hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
507
+ busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
508
+ annotBtn: q(".annot-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
509
+ note: q(".note"), noteBox: q(".note textarea"), noteHint: q(".note-hint"), noteTarget: q(".note-target"),
459
510
  };
460
511
  this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
461
512
  this.#bindGrip();
@@ -467,6 +518,7 @@ class AgentBridge extends HTMLElement {
467
518
  });
468
519
  loadMarkdown().then((md) => (this.#md = md));
469
520
  this.#bindControl();
521
+ this.#bindAnnotate();
470
522
  this.#bindHand();
471
523
  q(".term-btn").addEventListener("click", () => this.#showTerminal(!this.hasAttribute("terminal-open")));
472
524
  q(".terminal-retry").addEventListener("click", () => this.#showTerminal(true));
@@ -490,6 +542,7 @@ class AgentBridge extends HTMLElement {
490
542
  this.#screenEs?.close();
491
543
  this.#screenEs = null;
492
544
  this.#setControl(false);
545
+ this.#setAnnotate(false);
493
546
  }
494
547
  attributeChangedCallback(name) {
495
548
  if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
@@ -506,6 +559,7 @@ class AgentBridge extends HTMLElement {
506
559
  q(".term-btn").setAttribute("aria-pressed", String(on));
507
560
  if (!on) return;
508
561
  this.#setControl(false);
562
+ this.#setAnnotate(false);
509
563
  q(".terminal-status").textContent = "터미널 로딩 중…";
510
564
  try {
511
565
  if (!this.#terminalStyles) {
@@ -772,12 +826,12 @@ class AgentBridge extends HTMLElement {
772
826
  }));
773
827
  }
774
828
 
775
- async send(text, action = "message", images = []) {
829
+ async send(text, action = "message", extra = {}) {
776
830
  try {
777
831
  const res = await fetch(`${this.server}/send`, {
778
832
  method: "POST",
779
833
  headers: { "Content-Type": "application/json" },
780
- body: JSON.stringify({ action, text, images: images.length ? images : undefined }),
834
+ body: JSON.stringify({ ...extra, action, text }),
781
835
  });
782
836
  return await res.json();
783
837
  } catch {
@@ -816,7 +870,8 @@ class AgentBridge extends HTMLElement {
816
870
  on("message", (m) => {
817
871
  this.#addMessage(m);
818
872
  this.#saveMessage(m);
819
- if (this.#present.on && m.role === "assistant") this.#caption(m.text);
873
+ // script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
874
+ if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
820
875
  if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
821
876
  this.#emit("agent-message", m);
822
877
  });
@@ -842,7 +897,7 @@ class AgentBridge extends HTMLElement {
842
897
 
843
898
  #syncScreen() {
844
899
  const want = this.hasAttribute("screen") || this.#present.on;
845
- if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); return; }
900
+ if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); this.#setAnnotate(false); return; }
846
901
  if (this.#screenEs) return;
847
902
  const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
848
903
  es.addEventListener("meta", (e) => this.#renderScreenMeta(JSON.parse(e.data)));
@@ -851,6 +906,7 @@ class AgentBridge extends HTMLElement {
851
906
  this.#frame = { w: f.w, h: f.h };
852
907
  this.$.img.src = `data:image/jpeg;base64,${f.data}`;
853
908
  this.$.viewport.classList.add("has");
909
+ if (this.#annot) this.#annotFrame();
854
910
  });
855
911
  es.onerror = () => this.#renderScreenMeta({ connected: false });
856
912
  }
@@ -862,8 +918,9 @@ class AgentBridge extends HTMLElement {
862
918
  const was = this.#present.on;
863
919
  this.#present = p ?? { on: false };
864
920
  this.#emit("agent-present", this.#present);
921
+ this.#renderBusy();
865
922
  if (on === was) return;
866
- if (on) { this.#showTerminal(false); this.#setControl(false); }
923
+ if (on) { this.#showTerminal(false); this.#setControl(false); this.#setAnnotate(false); }
867
924
  else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
868
925
  this.#syncScreen();
869
926
  // FLIP: grow from (or shrink back to) the side panel
@@ -879,6 +936,12 @@ class AgentBridge extends HTMLElement {
879
936
  { duration: on ? 420 : 360, easing: "cubic-bezier(.2, .7, .2, 1)" });
880
937
  }
881
938
 
939
+ #renderBusy() {
940
+ const s = this.#status;
941
+ const busy = this.#present.on && s?.alive && s.status === "working" && this.#present.player?.state !== "playing";
942
+ this.$.busy.classList.toggle("on", !!busy);
943
+ }
944
+
882
945
  // Raise hand: open a small input over the presentation and send it as a message.
883
946
  #bindHand() {
884
947
  const { hand, ask, askBox, askHint } = this.$;
@@ -938,7 +1001,7 @@ class AgentBridge extends HTMLElement {
938
1001
 
939
1002
  // Agent reply as a short-lived bubble over the presented screen.
940
1003
  // kind "user": the viewer's own question (plain text, accent colour).
941
- #caption(text, kind = "agent") {
1004
+ #caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
942
1005
  if (!text?.trim()) return;
943
1006
  const el = document.createElement("div");
944
1007
  el.className = `caption ${kind}`;
@@ -953,7 +1016,7 @@ class AgentBridge extends HTMLElement {
953
1016
  el.classList.add("out");
954
1017
  setTimeout(() => el.remove(), 500);
955
1018
  };
956
- setTimeout(leave, AgentBridge.CAPTION_MS);
1019
+ setTimeout(leave, ms);
957
1020
  const live = [...box.children].filter((c) => !c.classList.contains("out"));
958
1021
  live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
959
1022
  el.__leave = leave;
@@ -996,24 +1059,43 @@ class AgentBridge extends HTMLElement {
996
1059
 
997
1060
  // ---------- user control ----------
998
1061
 
999
- #setControl(on) {
1000
- if (on && !this.#controlWs) {
1062
+ // One socket for both modes; open while control or annotate is on.
1063
+ #syncWs() {
1064
+ const want = this.#control || this.#annot;
1065
+ if (want && !this.#ws) {
1001
1066
  const ws = new WebSocket(`${this.server.replace(/^http/, "ws")}/input`);
1002
- ws.onclose = () => { if (this.#controlWs === ws) this.#setControl(false); };
1003
- this.#controlWs = ws;
1004
- } else if (!on && this.#controlWs) {
1005
- const ws = this.#controlWs;
1006
- this.#controlWs = null;
1067
+ ws.onmessage = (e) => {
1068
+ let m;
1069
+ try { m = JSON.parse(e.data); } catch { return; }
1070
+ this.#inspectWait.get(m.id)?.(m.result ?? null);
1071
+ };
1072
+ ws.onclose = () => {
1073
+ if (this.#ws !== ws) return;
1074
+ this.#ws = null;
1075
+ this.#setControl(false);
1076
+ this.#setAnnotate(false);
1077
+ };
1078
+ this.#ws = ws;
1079
+ } else if (!want && this.#ws) {
1080
+ const ws = this.#ws;
1081
+ this.#ws = null;
1007
1082
  ws.close();
1008
1083
  }
1009
- this.$.controlBtn.setAttribute("aria-checked", String(!!this.#controlWs));
1010
- this.$.controlBtn.querySelector(".state-label").textContent = this.#controlWs ? "ON" : "OFF";
1011
- this.$.viewport.classList.toggle("control", !!this.#controlWs);
1012
- if (this.#controlWs) this.$.kbd.focus();
1084
+ if (!this.#ws) { this.#inspectWait.forEach((r) => r(null)); this.#inspectWait.clear(); }
1085
+ }
1086
+
1087
+ #setControl(on) {
1088
+ this.#control = !!on && !this.#present.on;
1089
+ if (this.#control) this.#setAnnotate(false);
1090
+ this.#syncWs();
1091
+ this.$.controlBtn.setAttribute("aria-checked", String(this.#control));
1092
+ this.$.controlBtn.querySelector(".state-label").textContent = this.#control ? "ON" : "OFF";
1093
+ this.$.viewport.classList.toggle("control", this.#control);
1094
+ if (this.#control) this.$.kbd.focus();
1013
1095
  }
1014
1096
 
1015
1097
  #cdp(method, params) {
1016
- const ws = this.#controlWs;
1098
+ const ws = this.#ws;
1017
1099
  if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ method, params }));
1018
1100
  }
1019
1101
 
@@ -1027,11 +1109,11 @@ class AgentBridge extends HTMLElement {
1027
1109
 
1028
1110
  #bindControl() {
1029
1111
  const { img, kbd, controlBtn } = this.$;
1030
- const on = () => !!this.#controlWs && this.#frame.w > 0;
1112
+ const on = () => this.#control && this.#frame.w > 0;
1031
1113
  const mods = (e) => (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
1032
1114
  const BUTTONS = ["left", "middle", "right"];
1033
1115
 
1034
- controlBtn.addEventListener("click", () => { if (!this.#present.on) this.#setControl(!this.#controlWs); });
1116
+ controlBtn.addEventListener("click", () => this.#setControl(!this.#control));
1035
1117
 
1036
1118
  img.addEventListener("mousedown", (e) => {
1037
1119
  if (!on()) return;
@@ -1059,7 +1141,7 @@ class AgentBridge extends HTMLElement {
1059
1141
  });
1060
1142
  });
1061
1143
  img.addEventListener("wheel", (e) => {
1062
- if (!on()) return;
1144
+ if (!on() && !(this.#annot && this.#frame.w > 0)) return; // annotate: scroll to reach the element
1063
1145
  e.preventDefault();
1064
1146
  this.#cdp("Input.dispatchMouseEvent", { type: "mouseWheel", ...this.#point(e), deltaX: e.deltaX, deltaY: e.deltaY,
1065
1147
  modifiers: mods(e) });
@@ -1097,6 +1179,163 @@ class AgentBridge extends HTMLElement {
1097
1179
  });
1098
1180
  }
1099
1181
 
1182
+ // ---------- annotate ----------
1183
+
1184
+ #setAnnotate(on) {
1185
+ on = !!on && !this.#present.on;
1186
+ if (on === this.#annot) return;
1187
+ this.#annot = on;
1188
+ if (on) this.#setControl(false);
1189
+ else { this.#hover = null; this.#hoverPt = null; this.#select(null); }
1190
+ this.#syncWs();
1191
+ this.$.annotBtn.setAttribute("aria-pressed", String(on));
1192
+ this.$.viewport.classList.toggle("annot", on);
1193
+ this.#drawHl();
1194
+ }
1195
+
1196
+ // Element at a frame point ({x, y}) or by selector; null if unavailable.
1197
+ #inspect(q, full = false) {
1198
+ const ws = this.#ws;
1199
+ if (ws?.readyState !== WebSocket.OPEN) return Promise.resolve(null);
1200
+ const id = ++this.#inspectId;
1201
+ return new Promise((resolve) => {
1202
+ const done = (r) => { clearTimeout(t); this.#inspectWait.delete(id); resolve(r); };
1203
+ const t = setTimeout(() => done(null), 3000);
1204
+ this.#inspectWait.set(id, done);
1205
+ ws.send(JSON.stringify({ id, inspect: { ...q, full } }));
1206
+ });
1207
+ }
1208
+
1209
+ // Latest pointer position wins; at most one hover query in flight.
1210
+ #probing = false;
1211
+ #probeAgain = false;
1212
+ async #probeHover() {
1213
+ if (this.#probing) { this.#probeAgain = true; return; }
1214
+ this.#probing = true;
1215
+ try {
1216
+ do {
1217
+ this.#probeAgain = false;
1218
+ const p = this.#hoverPt;
1219
+ if (!p || !this.#annot) break;
1220
+ const r = await this.#inspect(p);
1221
+ if (this.#hoverPt && this.#annot) { this.#hover = r; this.#drawHl(); }
1222
+ } while (this.#probeAgain);
1223
+ } finally {
1224
+ this.#probing = false;
1225
+ }
1226
+ }
1227
+
1228
+ // The page changed (scroll, layout, HMR): follow the selected element and
1229
+ // re-check what is under the pointer.
1230
+ #tracking = false;
1231
+ async #annotFrame() {
1232
+ if (this.#hoverPt) this.#probeHover();
1233
+ const sel = this.#sel;
1234
+ if (!sel || this.#tracking) { this.#drawHl(); return; }
1235
+ this.#tracking = true;
1236
+ try {
1237
+ const r = await this.#inspect({ selector: sel.selector });
1238
+ if (r && this.#sel === sel) sel.rect = r.rect; // gone from the page: keep the last box
1239
+ } finally {
1240
+ this.#tracking = false;
1241
+ }
1242
+ this.#drawHl();
1243
+ }
1244
+
1245
+ #select(t) {
1246
+ const { note, noteBox, noteHint, noteTarget } = this.$;
1247
+ this.#sel = t;
1248
+ note.hidden = !t;
1249
+ noteHint.textContent = "";
1250
+ if (t) {
1251
+ noteTarget.textContent = t.selector;
1252
+ noteTarget.title = t.selector;
1253
+ noteBox.focus();
1254
+ }
1255
+ this.#drawHl();
1256
+ }
1257
+
1258
+ // Frame (CSS px of the page viewport) -> position over the displayed image.
1259
+ #drawHl() {
1260
+ const { viewport, img, hlHover, hlSel, hlLabel, note } = this.$;
1261
+ const show = this.#annot && viewport.classList.contains("has") && this.#frame.w > 0;
1262
+ const vr = viewport.getBoundingClientRect();
1263
+ const ir = img.getBoundingClientRect();
1264
+ const sx = ir.width / this.#frame.w, sy = ir.height / this.#frame.h;
1265
+ const box = (r) => {
1266
+ if (!show || !r) return null;
1267
+ const x0 = Math.max(r.x, 0), y0 = Math.max(r.y, 0);
1268
+ const x1 = Math.min(r.x + r.w, this.#frame.w), y1 = Math.min(r.y + r.h, this.#frame.h);
1269
+ if (x1 <= x0 || y1 <= y0) return null;
1270
+ return { left: ir.left - vr.left + x0 * sx, top: ir.top - vr.top + y0 * sy, width: (x1 - x0) * sx, height: (y1 - y0) * sy };
1271
+ };
1272
+ const place = (el, b) => {
1273
+ el.classList.toggle("on", !!b);
1274
+ if (b) Object.assign(el.style, { left: `${b.left}px`, top: `${b.top}px`, width: `${b.width}px`, height: `${b.height}px` });
1275
+ return b;
1276
+ };
1277
+ const hb = place(hlHover, box(this.#hover?.rect));
1278
+ const sb = place(hlSel, box(this.#sel?.rect));
1279
+
1280
+ hlLabel.classList.toggle("on", !!hb);
1281
+ if (hb) {
1282
+ const r = this.#hover.rect;
1283
+ hlLabel.textContent = `${this.#hover.label} ${Math.round(r.w)}×${Math.round(r.h)}`;
1284
+ // above the box, else below it, else inside its top edge
1285
+ const top = hb.top >= 22 ? hb.top - 22 : hb.top + hb.height + 26 <= vr.height ? hb.top + hb.height + 4 : hb.top + 4;
1286
+ Object.assign(hlLabel.style, { left: `${hb.left + (top === hb.top + 4 ? 4 : 0)}px`, top: `${top}px` });
1287
+ }
1288
+
1289
+ // input below the selected element, above it if there is no room
1290
+ if (!note.hidden) {
1291
+ const W = note.offsetWidth, H = note.offsetHeight, pad = 8;
1292
+ const b = sb ?? { left: pad, top: pad, width: 0, height: 0 };
1293
+ let top = b.top + b.height + pad;
1294
+ if (top + H > vr.height - pad) top = b.top - H - pad;
1295
+ top = Math.max(pad, Math.min(top, vr.height - H - pad));
1296
+ const left = Math.max(pad, Math.min(b.left, vr.width - W - pad));
1297
+ Object.assign(note.style, { left: `${left}px`, top: `${top}px` });
1298
+ }
1299
+ }
1300
+
1301
+ #bindAnnotate() {
1302
+ const { img, annotBtn, note, noteBox, noteHint } = this.$;
1303
+ annotBtn.addEventListener("click", () => this.#setAnnotate(!this.#annot));
1304
+ img.addEventListener("mousemove", (e) => {
1305
+ if (!this.#annot || !this.#frame.w) return;
1306
+ this.#hoverPt = this.#point(e);
1307
+ this.#probeHover();
1308
+ });
1309
+ img.addEventListener("mouseleave", () => {
1310
+ if (!this.#annot) return;
1311
+ this.#hoverPt = null;
1312
+ this.#hover = null;
1313
+ this.#drawHl();
1314
+ });
1315
+ img.addEventListener("click", async (e) => {
1316
+ if (!this.#annot || !this.#frame.w) return;
1317
+ e.preventDefault();
1318
+ const t = await this.#inspect(this.#point(e), true);
1319
+ if (t && this.#annot) this.#select(t);
1320
+ });
1321
+ new ResizeObserver(() => this.#drawHl()).observe(img);
1322
+ note.addEventListener("submit", async (e) => {
1323
+ e.preventDefault();
1324
+ const text = noteBox.value.trim();
1325
+ const target = this.#sel;
1326
+ if (!text || !target) return;
1327
+ noteHint.textContent = "보내는 중…";
1328
+ const res = await this.send(text, "annotate", { target });
1329
+ if (!res.ok) { noteHint.textContent = `전송 실패: ${res.error}`; return; }
1330
+ noteBox.value = "";
1331
+ if (this.#sel === target) this.#select(null);
1332
+ });
1333
+ noteBox.addEventListener("keydown", (e) => {
1334
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); note.requestSubmit(); }
1335
+ if (e.key === "Escape") { e.preventDefault(); this.#select(null); }
1336
+ });
1337
+ }
1338
+
1100
1339
  #renderScreenMeta(m) {
1101
1340
  const { live, url, viewport } = this.$;
1102
1341
  if (!m.connected) {
@@ -1112,6 +1351,7 @@ class AgentBridge extends HTMLElement {
1112
1351
  url.textContent = m.title ? `${m.title} · ${m.url}` : m.url || "";
1113
1352
  url.title = m.url || "";
1114
1353
  }
1354
+ this.#drawHl();
1115
1355
  this.#emit("agent-screen", m);
1116
1356
  }
1117
1357
 
@@ -1248,7 +1488,7 @@ class AgentBridge extends HTMLElement {
1248
1488
  return;
1249
1489
  } else if (m.role === "assistant") {
1250
1490
  wrap.className = "msg assistant";
1251
- meta.textContent = `에이전트 · ${time}`;
1491
+ meta.textContent = `에이전트${m.script ? ` · 대본 ${m.script}` : ""} · ${time}`;
1252
1492
  if (this.#md) bubble.innerHTML = this.#md(m.text);
1253
1493
  else { bubble.textContent = m.text; bubble.classList.add("plain"); }
1254
1494
  } else if (m.role === "browser") {
@@ -1310,6 +1550,7 @@ class AgentBridge extends HTMLElement {
1310
1550
  this.$.buttons.forEach((b) => (b.disabled = !canSend));
1311
1551
  if (!s || !s.alive || s.status === "idle") this.#tools.clear();
1312
1552
  this.#renderActivity();
1553
+ this.#renderBusy();
1313
1554
  this.#emit("agent-status", s);
1314
1555
  }
1315
1556
 
@@ -1318,7 +1559,7 @@ class AgentBridge extends HTMLElement {
1318
1559
  const images = this.#images.map(({ type, data }) => ({ type, data }));
1319
1560
  if (action === "message" && !text && !images.length) return;
1320
1561
  this.$.buttons.forEach((b) => (b.disabled = true));
1321
- const res = await this.send(text, action, images);
1562
+ const res = await this.send(text, action, { images: images.length ? images : undefined });
1322
1563
  if (res.ok) {
1323
1564
  this.$.box.value = "";
1324
1565
  this.$.hint.textContent = "";