agello 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,7 @@
6
6
  - Live session status, tool activity, and prompts queued while the agent is busy (same order as the terminal)
7
7
  - Conversation saved per pane in the browser, kept across reloads
8
8
  - Optional live view of a `terminal-browser` screen, with an on/off switch to control it yourself
9
+ - Annotate: point at an element on that screen and send a request about it, with its CSS selector
9
10
  - Embeddable `<agent-bridge>` web component
10
11
 
11
12
  Currently supports **Claude Code** sessions.
@@ -59,6 +60,18 @@ Messages sent from the page arrive in the agent as:
59
60
 
60
61
  The approve / reject buttons send `action=approve` / `action=reject`.
61
62
 
63
+ ### Annotate
64
+
65
+ 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:
66
+
67
+ ```
68
+ [browser] action=annotate make this red / and bolder
69
+ 대상: #app > main > button:nth-of-type(2)
70
+ 요소: button.primary "Save" · 위치 120,340 크기 96x32 · http://localhost:3000/
71
+ ```
72
+
73
+ 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.
74
+
62
75
  ### Commands
63
76
 
64
77
  | Command | Description |
@@ -69,6 +82,8 @@ The approve / reject buttons send `action=approve` / `action=reject`.
69
82
  | `agello open [--port N \| --pane ID]` | Open the page (default: this pane's server) |
70
83
  | `agello present [x,y,w,h] [--port N \| --pane ID]` | Presentation mode (see below) |
71
84
  | `agello present stop` | End presentation mode |
85
+ | `agello script load <file> \| show` | Load / show a presentation script |
86
+ | `agello present resume \| pause \| goto <n[.m]>` | Play the script from where it stopped / stop it / move to a step |
72
87
 
73
88
  `start` options: `--pane <id>`, `--port <n>` (default: first free from 8765), `--session <id>`, `--browser <terminal-browser key>`, `--allow-origin <origin>` (repeatable), `--open`, `--foreground`.
74
89
 
@@ -87,6 +102,23 @@ State and logs: `~/.local/state/agello/<port>.json`, `<port>.log`.
87
102
 
88
103
  Run `present` again to move the crop. User control is off while presenting.
89
104
 
105
+ #### Scripts
106
+
107
+ Write the talk ahead so each line appears the moment its screen does:
108
+
109
+ ```json
110
+ { "rect": "28,157,688,477",
111
+ "steps": [
112
+ { "go": "#1", "say": ["First line", "Second line"] },
113
+ { "go": "#2", "say": ["..."], "hold": 6 } ] }
114
+ ```
115
+
116
+ - 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
117
+ - `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
118
+ - raising a hand pauses at once; the agent gets `action=hand-raise` with the position (`마지막 표시 2.1 · 다음 2.2`)
119
+ - `script load` again after editing keeps the position; `present goto 2.2` then `present resume` replays from a fixed line
120
+ - 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
121
+
90
122
  ## Embed
91
123
 
92
124
  ```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.2.0",
3
+ "version": "0.3.0",
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
@@ -5,17 +5,23 @@
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
7
  // POST /send {action, text} -> herdr agent prompt
8
+ // (action "annotate": + target {selector, label, rect, text?, url?} from inspect)
8
9
  // GET /screen SSE: terminal-browser screen frames (CDP screencast)
9
- // WS /input user control: CDP Input.* commands forwarded to the page
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
12
  // GET /present presentation state
11
13
  // POST /present {rect?: {x,y,w,h}} start (or move the crop), {stop: true} end
12
14
  // ({stop: true, from: "viewer"}: ended from the page, the agent is told
13
15
  // 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"}
14
19
 
15
20
  import { homedir } from "node:os";
16
21
  import { join } from "node:path";
17
22
  import { runJson } from "./run.ts";
18
- import { createScreencast, type Rect } from "./screencast.ts";
23
+ import { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
24
+ import { createScreencast, type Inspected, type Rect } from "./screencast.ts";
19
25
 
20
26
  export type ServerOptions = {
21
27
  port: number;
@@ -90,7 +96,22 @@ export function parseRect(r: any): Rect | null {
90
96
  return { x, y, w, h };
91
97
  }
92
98
 
93
- export type Present = { on: boolean; rect?: Rect; since?: string };
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 };
94
115
 
95
116
  type Status =
96
117
  | { alive: false; reason: string; pane: string }
@@ -311,10 +332,67 @@ export async function startServer(opts: ServerOptions) {
311
332
  // short-lived bubbles. rect: crop of the visible viewport (unset = whole screen).
312
333
 
313
334
  let present: Present = { on: false };
335
+ const presentView = (): Present => ({ ...present, player: player.info() });
314
336
  function setPresent(next: Present) {
315
337
  present = next;
316
338
  screen.setClip(next.on ? (next.rect ?? null) : null);
317
- broadcast("present", present);
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() });
318
396
  }
319
397
 
320
398
  async function presentRequest(req: Request): Promise<Response> {
@@ -326,10 +404,11 @@ export async function startServer(opts: ServerOptions) {
326
404
  // (The CLI stop comes from the agent itself and needs no notice.)
327
405
  let notified: string | undefined;
328
406
  if (was && data.from === "viewer") {
329
- const r = await promptAgent("present-stop", "사용자가 페이지에서 발표를 종료함");
407
+ const at = where();
408
+ const r = await promptAgent("present-stop", `사용자가 페이지에서 발표를 종료함${at ? ` (대본 정지: ${at})` : ""}`);
330
409
  notified = r.ok ? "ok" : r.error;
331
410
  }
332
- return Response.json({ ok: true, present, notified });
411
+ return Response.json({ ok: true, present: presentView(), notified });
333
412
  }
334
413
  let rect: Rect | undefined;
335
414
  if (data.rect != null) {
@@ -337,7 +416,7 @@ export async function startServer(opts: ServerOptions) {
337
416
  if (!rect) return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
338
417
  }
339
418
  setPresent({ on: true, rect, since: present.on ? present.since : new Date().toISOString() });
340
- return Response.json({ ok: true, present });
419
+ return Response.json({ ok: true, present: presentView() });
341
420
  }
342
421
 
343
422
  // ----- HTTP -----
@@ -378,7 +457,7 @@ export async function startServer(opts: ServerOptions) {
378
457
  tools: [...pendingTools.keys()],
379
458
  });
380
459
  if (lastStatus) send(c, "status", lastStatus);
381
- send(c, "present", present);
460
+ send(c, "present", presentView());
382
461
  for (const t of pendingTools.values()) send(c, "tool_start", t);
383
462
  ping = setInterval(() => send(c, "ping", {}), 15000);
384
463
  },
@@ -403,9 +482,22 @@ export async function startServer(opts: ServerOptions) {
403
482
  }
404
483
 
405
484
  async function sendPrompt(req: Request): Promise<Response> {
406
- const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string };
485
+ const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string; target?: Inspected };
407
486
  const action = /^[\w-]{1,32}$/.test(data.action ?? "") ? data.action! : "message";
408
- const r = await promptAgent(action, data.text ?? "");
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
+ }
500
+ const r = await promptAgent(action, text);
409
501
  return r.ok ? Response.json({ ok: true }) : Response.json({ ok: false, error: r.error }, { status: r.status });
410
502
  }
411
503
 
@@ -416,7 +508,7 @@ export async function startServer(opts: ServerOptions) {
416
508
  ...(await checkStatus()),
417
509
  browser: opts.browser,
418
510
  screen: await screen.describe(),
419
- present,
511
+ present: presentView(),
420
512
  });
421
513
  case "/events":
422
514
  return events();
@@ -430,7 +522,12 @@ export async function startServer(opts: ServerOptions) {
430
522
  if (req.method === "POST") return sendPrompt(req);
431
523
  break;
432
524
  case "/present":
433
- return req.method === "POST" ? presentRequest(req) : Response.json(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);
530
+ break;
434
531
  }
435
532
  return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
436
533
  }
@@ -440,8 +537,24 @@ export async function startServer(opts: ServerOptions) {
440
537
  port: opts.port,
441
538
  idleTimeout: 0, // keep SSE open
442
539
  websocket: {
443
- message(_ws, msg) {
444
- screen.input(String(msg));
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);
445
558
  },
446
559
  },
447
560
  async fetch(req, server) {
@@ -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
@@ -36,9 +39,12 @@
36
39
  // closing it without asking sends action=hand-lower so it can go on.
37
40
  // The viewer's questions show as bubbles too, with the same 10s lifetime.
38
41
  // An end button above it stops the presentation for every viewer.
42
+ // Script lines (`agello script` + `present resume`) arrive as agent messages
43
+ // with `script: "step.line"`. While the script is not playing and the agent
44
+ // is working (e.g. answering a question), a small loader is shown.
39
45
  //
40
46
  // Methods
41
- // el.send(text, action = 'message') -> Promise<{ok, error?}>
47
+ // el.send(text, action = 'message', extra) -> Promise<{ok, error?}> (extra: e.g. {target} for annotate)
42
48
  // el.clearHistory() clear saved chat for the current pane
43
49
  // el.stopPresent() end presentation mode, tell the agent -> Promise<{ok, error?, notified?}>
44
50
 
@@ -69,7 +75,7 @@ const SCREEN_REASONS = {
69
75
  };
70
76
  const ACTIONS = {
71
77
  message: "메시지", approve: "승인", reject: "거절",
72
- "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림",
78
+ "present-stop": "발표 종료", "hand-raise": "손들기", "hand-lower": "손 내림", annotate: "주석",
73
79
  };
74
80
 
75
81
  const CSS = `
@@ -172,6 +178,30 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
172
178
  @media (prefers-reduced-motion: reduce) { .control-btn .track, .control-btn .knob { transition: none; } }
173
179
  .placeholder { font-size: 13px; color: var(--_muted); text-align: center; }
174
180
 
181
+ /* annotate: hover tint, selected outline, request input next to the element */
182
+ .annot-btn { flex: none; font-size: 12px; padding: 2px 10px; border-radius: 999px; }
183
+ .annot-btn[aria-pressed="true"] { background: var(--_accent); border-color: var(--_accent); color: #fff; }
184
+ .viewport.annot img { cursor: crosshair; }
185
+ .hl { position: absolute; z-index: 3; pointer-events: none; display: none; border-radius: 2px; }
186
+ .hl.on { display: block; }
187
+ .hl.hover { background: color-mix(in srgb, var(--_accent) 22%, transparent);
188
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--_accent) 55%, transparent); }
189
+ .hl.sel { box-shadow: 0 0 0 2px var(--_accent), 0 0 0 5px color-mix(in srgb, var(--_accent) 25%, transparent); }
190
+ .hl-label { position: absolute; z-index: 4; pointer-events: none; display: none; max-width: 60%;
191
+ padding: 2px 7px; border-radius: 5px; font: 11px/1.5 ui-monospace, Menlo, monospace; color: #fff;
192
+ background: rgba(20, 20, 22, .85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
193
+ .hl-label.on { display: block; }
194
+ .note { position: absolute; z-index: 5; width: min(340px, calc(100% - 16px)); padding: 10px; border-radius: 12px;
195
+ background: var(--_panel); border: 1px solid var(--_line); box-shadow: 0 8px 28px rgba(0,0,0,.22);
196
+ animation: capIn .18s ease; }
197
+ .note[hidden] { display: none; }
198
+ .note .note-target { font: 11px/1.4 ui-monospace, Menlo, monospace; color: var(--_muted); margin-bottom: 6px;
199
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
200
+ .note textarea { min-height: 52px; font-size: 14px; }
201
+ .note .note-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
202
+ .note .note-hint { flex: 1; font-size: 12px; color: var(--_muted); }
203
+ @media (prefers-reduced-motion: reduce) { .note { animation: none; } }
204
+
175
205
  /* presentation mode: the screen panel covers the window, chat stays hidden underneath.
176
206
  container-type would make the host the containing block of the fixed layer, so drop it. */
177
207
  :host([presenting]) { container-type: normal; }
@@ -199,6 +229,14 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
199
229
  .caption a { color: inherit; }
200
230
  @keyframes capIn { from { opacity: 0; translate: 0 10px; } }
201
231
 
232
+ /* agent busy while presenting (answering, fixing the script): minimal loader */
233
+ .busy { display: none; }
234
+ .body.present .busy.on { position: absolute; left: 22px; bottom: 28px; z-index: 5; display: flex; align-items: center;
235
+ gap: 9px; padding: 9px 14px; border-radius: 999px; font-size: 13px; color: #fff; background: rgba(20, 20, 22, .72);
236
+ -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
237
+ animation: capIn .25s ease; }
238
+ .busy .spinner { border-color: rgba(255,255,255,.25); border-top-color: #fff; }
239
+
202
240
  /* raise hand: ask the agent mid-presentation */
203
241
  .hand-wrap { display: none; }
204
242
  .body.present .hand-wrap { position: absolute; right: 22px; bottom: 28px; z-index: 5; display: flex;
@@ -325,9 +363,13 @@ const TEMPLATE = `
325
363
  </section>
326
364
  <aside class="screen" part="screen">
327
365
  <div class="screen-bar"><span class="live" hidden>에이전트 조작 중</span><span class="url"></span>
366
+ <button class="annot-btn" type="button" aria-pressed="false" title="화면의 요소를 골라 에이전트에게 요청 (선택자와 함께 전달)">주석</button>
328
367
  <button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
329
368
  <span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
330
- <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>
369
+ <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>
370
+ <form class="note" part="note" hidden><div class="note-target"></div><textarea aria-label="선택한 요소에 대한 요청" placeholder="이 요소에 대한 요청 (Enter 전송, Esc 취소)"></textarea>
371
+ <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>
372
+ <div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
331
373
  <div class="hand-wrap">
332
374
  <form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
333
375
  <div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
@@ -346,7 +388,14 @@ class AgentBridge extends HTMLElement {
346
388
  #root = this.attachShadow({ mode: "open" });
347
389
  #es = null;
348
390
  #screenEs = null;
349
- #controlWs = null;
391
+ #ws = null; // /input: control input and annotate inspect queries
392
+ #control = false;
393
+ #annot = false;
394
+ #hover = null; // inspected element under the pointer (annotate)
395
+ #hoverPt = null;
396
+ #sel = null; // selected element (annotate)
397
+ #inspectId = 0;
398
+ #inspectWait = new Map();
350
399
  #frame = { w: 0, h: 0 };
351
400
  #moveQueued = null;
352
401
  #status = null;
@@ -370,7 +419,9 @@ class AgentBridge extends HTMLElement {
370
419
  live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
371
420
  controlBtn: q(".control-btn"), kbd: q(".kbd"), clearBtn: q(".clear-btn"),
372
421
  body: q(".body"), screen: q(".screen"), captions: q(".captions"),
373
- hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
422
+ busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
423
+ annotBtn: q(".annot-btn"), hlHover: q(".hl.hover"), hlSel: q(".hl.sel"), hlLabel: q(".hl-label"),
424
+ note: q(".note"), noteBox: q(".note textarea"), noteHint: q(".note-hint"), noteTarget: q(".note-target"),
374
425
  };
375
426
  this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
376
427
  this.#bindGrip();
@@ -382,6 +433,7 @@ class AgentBridge extends HTMLElement {
382
433
  });
383
434
  loadMarkdown().then((md) => (this.#md = md));
384
435
  this.#bindControl();
436
+ this.#bindAnnotate();
385
437
  this.#bindHand();
386
438
  this.$.clearBtn.addEventListener("click", () => this.clearHistory());
387
439
  }
@@ -401,6 +453,7 @@ class AgentBridge extends HTMLElement {
401
453
  this.#screenEs?.close();
402
454
  this.#screenEs = null;
403
455
  this.#setControl(false);
456
+ this.#setAnnotate(false);
404
457
  }
405
458
  attributeChangedCallback(name) {
406
459
  if (name === "heading") this.$.heading.textContent = this.getAttribute("heading") || "agello";
@@ -408,12 +461,12 @@ class AgentBridge extends HTMLElement {
408
461
  if (name === "screen" && this.isConnected) this.#syncScreen();
409
462
  }
410
463
 
411
- async send(text, action = "message") {
464
+ async send(text, action = "message", extra = {}) {
412
465
  try {
413
466
  const res = await fetch(`${this.server}/send`, {
414
467
  method: "POST",
415
468
  headers: { "Content-Type": "application/json" },
416
- body: JSON.stringify({ action, text }),
469
+ body: JSON.stringify({ ...extra, action, text }),
417
470
  });
418
471
  return await res.json();
419
472
  } catch {
@@ -452,7 +505,8 @@ class AgentBridge extends HTMLElement {
452
505
  on("message", (m) => {
453
506
  this.#addMessage(m);
454
507
  this.#saveMessage(m);
455
- if (this.#present.on && m.role === "assistant") this.#caption(m.text);
508
+ // script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
509
+ if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
456
510
  if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
457
511
  this.#emit("agent-message", m);
458
512
  });
@@ -478,7 +532,7 @@ class AgentBridge extends HTMLElement {
478
532
 
479
533
  #syncScreen() {
480
534
  const want = this.hasAttribute("screen") || this.#present.on;
481
- if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); return; }
535
+ if (!want) { this.#screenEs?.close(); this.#screenEs = null; this.#setControl(false); this.#setAnnotate(false); return; }
482
536
  if (this.#screenEs) return;
483
537
  const es = (this.#screenEs = new EventSource(`${this.server}/screen`));
484
538
  es.addEventListener("meta", (e) => this.#renderScreenMeta(JSON.parse(e.data)));
@@ -487,6 +541,7 @@ class AgentBridge extends HTMLElement {
487
541
  this.#frame = { w: f.w, h: f.h };
488
542
  this.$.img.src = `data:image/jpeg;base64,${f.data}`;
489
543
  this.$.viewport.classList.add("has");
544
+ if (this.#annot) this.#annotFrame();
490
545
  });
491
546
  es.onerror = () => this.#renderScreenMeta({ connected: false });
492
547
  }
@@ -498,8 +553,9 @@ class AgentBridge extends HTMLElement {
498
553
  const was = this.#present.on;
499
554
  this.#present = p ?? { on: false };
500
555
  this.#emit("agent-present", this.#present);
556
+ this.#renderBusy();
501
557
  if (on === was) return;
502
- if (on) this.#setControl(false);
558
+ if (on) { this.#setControl(false); this.#setAnnotate(false); }
503
559
  else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
504
560
  this.#syncScreen();
505
561
  // FLIP: grow from (or shrink back to) the side panel
@@ -515,6 +571,12 @@ class AgentBridge extends HTMLElement {
515
571
  { duration: on ? 420 : 360, easing: "cubic-bezier(.2, .7, .2, 1)" });
516
572
  }
517
573
 
574
+ #renderBusy() {
575
+ const s = this.#status;
576
+ const busy = this.#present.on && s?.alive && s.status === "working" && this.#present.player?.state !== "playing";
577
+ this.$.busy.classList.toggle("on", !!busy);
578
+ }
579
+
518
580
  // Raise hand: open a small input over the presentation and send it as a message.
519
581
  #bindHand() {
520
582
  const { hand, ask, askBox, askHint } = this.$;
@@ -574,7 +636,7 @@ class AgentBridge extends HTMLElement {
574
636
 
575
637
  // Agent reply as a short-lived bubble over the presented screen.
576
638
  // kind "user": the viewer's own question (plain text, accent colour).
577
- #caption(text, kind = "agent") {
639
+ #caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
578
640
  if (!text?.trim()) return;
579
641
  const el = document.createElement("div");
580
642
  el.className = `caption ${kind}`;
@@ -589,7 +651,7 @@ class AgentBridge extends HTMLElement {
589
651
  el.classList.add("out");
590
652
  setTimeout(() => el.remove(), 500);
591
653
  };
592
- setTimeout(leave, AgentBridge.CAPTION_MS);
654
+ setTimeout(leave, ms);
593
655
  const live = [...box.children].filter((c) => !c.classList.contains("out"));
594
656
  live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
595
657
  el.__leave = leave;
@@ -632,24 +694,43 @@ class AgentBridge extends HTMLElement {
632
694
 
633
695
  // ---------- user control ----------
634
696
 
635
- #setControl(on) {
636
- if (on && !this.#controlWs) {
697
+ // One socket for both modes; open while control or annotate is on.
698
+ #syncWs() {
699
+ const want = this.#control || this.#annot;
700
+ if (want && !this.#ws) {
637
701
  const ws = new WebSocket(`${this.server.replace(/^http/, "ws")}/input`);
638
- ws.onclose = () => { if (this.#controlWs === ws) this.#setControl(false); };
639
- this.#controlWs = ws;
640
- } else if (!on && this.#controlWs) {
641
- const ws = this.#controlWs;
642
- this.#controlWs = null;
702
+ ws.onmessage = (e) => {
703
+ let m;
704
+ try { m = JSON.parse(e.data); } catch { return; }
705
+ this.#inspectWait.get(m.id)?.(m.result ?? null);
706
+ };
707
+ ws.onclose = () => {
708
+ if (this.#ws !== ws) return;
709
+ this.#ws = null;
710
+ this.#setControl(false);
711
+ this.#setAnnotate(false);
712
+ };
713
+ this.#ws = ws;
714
+ } else if (!want && this.#ws) {
715
+ const ws = this.#ws;
716
+ this.#ws = null;
643
717
  ws.close();
644
718
  }
645
- this.$.controlBtn.setAttribute("aria-checked", String(!!this.#controlWs));
646
- this.$.controlBtn.querySelector(".state-label").textContent = this.#controlWs ? "ON" : "OFF";
647
- this.$.viewport.classList.toggle("control", !!this.#controlWs);
648
- if (this.#controlWs) this.$.kbd.focus();
719
+ if (!this.#ws) { this.#inspectWait.forEach((r) => r(null)); this.#inspectWait.clear(); }
720
+ }
721
+
722
+ #setControl(on) {
723
+ this.#control = !!on && !this.#present.on;
724
+ if (this.#control) this.#setAnnotate(false);
725
+ this.#syncWs();
726
+ this.$.controlBtn.setAttribute("aria-checked", String(this.#control));
727
+ this.$.controlBtn.querySelector(".state-label").textContent = this.#control ? "ON" : "OFF";
728
+ this.$.viewport.classList.toggle("control", this.#control);
729
+ if (this.#control) this.$.kbd.focus();
649
730
  }
650
731
 
651
732
  #cdp(method, params) {
652
- const ws = this.#controlWs;
733
+ const ws = this.#ws;
653
734
  if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ method, params }));
654
735
  }
655
736
 
@@ -663,11 +744,11 @@ class AgentBridge extends HTMLElement {
663
744
 
664
745
  #bindControl() {
665
746
  const { img, kbd, controlBtn } = this.$;
666
- const on = () => !!this.#controlWs && this.#frame.w > 0;
747
+ const on = () => this.#control && this.#frame.w > 0;
667
748
  const mods = (e) => (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
668
749
  const BUTTONS = ["left", "middle", "right"];
669
750
 
670
- controlBtn.addEventListener("click", () => { if (!this.#present.on) this.#setControl(!this.#controlWs); });
751
+ controlBtn.addEventListener("click", () => this.#setControl(!this.#control));
671
752
 
672
753
  img.addEventListener("mousedown", (e) => {
673
754
  if (!on()) return;
@@ -695,7 +776,7 @@ class AgentBridge extends HTMLElement {
695
776
  });
696
777
  });
697
778
  img.addEventListener("wheel", (e) => {
698
- if (!on()) return;
779
+ if (!on() && !(this.#annot && this.#frame.w > 0)) return; // annotate: scroll to reach the element
699
780
  e.preventDefault();
700
781
  this.#cdp("Input.dispatchMouseEvent", { type: "mouseWheel", ...this.#point(e), deltaX: e.deltaX, deltaY: e.deltaY,
701
782
  modifiers: mods(e) });
@@ -733,6 +814,163 @@ class AgentBridge extends HTMLElement {
733
814
  });
734
815
  }
735
816
 
817
+ // ---------- annotate ----------
818
+
819
+ #setAnnotate(on) {
820
+ on = !!on && !this.#present.on;
821
+ if (on === this.#annot) return;
822
+ this.#annot = on;
823
+ if (on) this.#setControl(false);
824
+ else { this.#hover = null; this.#hoverPt = null; this.#select(null); }
825
+ this.#syncWs();
826
+ this.$.annotBtn.setAttribute("aria-pressed", String(on));
827
+ this.$.viewport.classList.toggle("annot", on);
828
+ this.#drawHl();
829
+ }
830
+
831
+ // Element at a frame point ({x, y}) or by selector; null if unavailable.
832
+ #inspect(q, full = false) {
833
+ const ws = this.#ws;
834
+ if (ws?.readyState !== WebSocket.OPEN) return Promise.resolve(null);
835
+ const id = ++this.#inspectId;
836
+ return new Promise((resolve) => {
837
+ const done = (r) => { clearTimeout(t); this.#inspectWait.delete(id); resolve(r); };
838
+ const t = setTimeout(() => done(null), 3000);
839
+ this.#inspectWait.set(id, done);
840
+ ws.send(JSON.stringify({ id, inspect: { ...q, full } }));
841
+ });
842
+ }
843
+
844
+ // Latest pointer position wins; at most one hover query in flight.
845
+ #probing = false;
846
+ #probeAgain = false;
847
+ async #probeHover() {
848
+ if (this.#probing) { this.#probeAgain = true; return; }
849
+ this.#probing = true;
850
+ try {
851
+ do {
852
+ this.#probeAgain = false;
853
+ const p = this.#hoverPt;
854
+ if (!p || !this.#annot) break;
855
+ const r = await this.#inspect(p);
856
+ if (this.#hoverPt && this.#annot) { this.#hover = r; this.#drawHl(); }
857
+ } while (this.#probeAgain);
858
+ } finally {
859
+ this.#probing = false;
860
+ }
861
+ }
862
+
863
+ // The page changed (scroll, layout, HMR): follow the selected element and
864
+ // re-check what is under the pointer.
865
+ #tracking = false;
866
+ async #annotFrame() {
867
+ if (this.#hoverPt) this.#probeHover();
868
+ const sel = this.#sel;
869
+ if (!sel || this.#tracking) { this.#drawHl(); return; }
870
+ this.#tracking = true;
871
+ try {
872
+ const r = await this.#inspect({ selector: sel.selector });
873
+ if (r && this.#sel === sel) sel.rect = r.rect; // gone from the page: keep the last box
874
+ } finally {
875
+ this.#tracking = false;
876
+ }
877
+ this.#drawHl();
878
+ }
879
+
880
+ #select(t) {
881
+ const { note, noteBox, noteHint, noteTarget } = this.$;
882
+ this.#sel = t;
883
+ note.hidden = !t;
884
+ noteHint.textContent = "";
885
+ if (t) {
886
+ noteTarget.textContent = t.selector;
887
+ noteTarget.title = t.selector;
888
+ noteBox.focus();
889
+ }
890
+ this.#drawHl();
891
+ }
892
+
893
+ // Frame (CSS px of the page viewport) -> position over the displayed image.
894
+ #drawHl() {
895
+ const { viewport, img, hlHover, hlSel, hlLabel, note } = this.$;
896
+ const show = this.#annot && viewport.classList.contains("has") && this.#frame.w > 0;
897
+ const vr = viewport.getBoundingClientRect();
898
+ const ir = img.getBoundingClientRect();
899
+ const sx = ir.width / this.#frame.w, sy = ir.height / this.#frame.h;
900
+ const box = (r) => {
901
+ if (!show || !r) return null;
902
+ const x0 = Math.max(r.x, 0), y0 = Math.max(r.y, 0);
903
+ const x1 = Math.min(r.x + r.w, this.#frame.w), y1 = Math.min(r.y + r.h, this.#frame.h);
904
+ if (x1 <= x0 || y1 <= y0) return null;
905
+ return { left: ir.left - vr.left + x0 * sx, top: ir.top - vr.top + y0 * sy, width: (x1 - x0) * sx, height: (y1 - y0) * sy };
906
+ };
907
+ const place = (el, b) => {
908
+ el.classList.toggle("on", !!b);
909
+ if (b) Object.assign(el.style, { left: `${b.left}px`, top: `${b.top}px`, width: `${b.width}px`, height: `${b.height}px` });
910
+ return b;
911
+ };
912
+ const hb = place(hlHover, box(this.#hover?.rect));
913
+ const sb = place(hlSel, box(this.#sel?.rect));
914
+
915
+ hlLabel.classList.toggle("on", !!hb);
916
+ if (hb) {
917
+ const r = this.#hover.rect;
918
+ hlLabel.textContent = `${this.#hover.label} ${Math.round(r.w)}×${Math.round(r.h)}`;
919
+ // above the box, else below it, else inside its top edge
920
+ const top = hb.top >= 22 ? hb.top - 22 : hb.top + hb.height + 26 <= vr.height ? hb.top + hb.height + 4 : hb.top + 4;
921
+ Object.assign(hlLabel.style, { left: `${hb.left + (top === hb.top + 4 ? 4 : 0)}px`, top: `${top}px` });
922
+ }
923
+
924
+ // input below the selected element, above it if there is no room
925
+ if (!note.hidden) {
926
+ const W = note.offsetWidth, H = note.offsetHeight, pad = 8;
927
+ const b = sb ?? { left: pad, top: pad, width: 0, height: 0 };
928
+ let top = b.top + b.height + pad;
929
+ if (top + H > vr.height - pad) top = b.top - H - pad;
930
+ top = Math.max(pad, Math.min(top, vr.height - H - pad));
931
+ const left = Math.max(pad, Math.min(b.left, vr.width - W - pad));
932
+ Object.assign(note.style, { left: `${left}px`, top: `${top}px` });
933
+ }
934
+ }
935
+
936
+ #bindAnnotate() {
937
+ const { img, annotBtn, note, noteBox, noteHint } = this.$;
938
+ annotBtn.addEventListener("click", () => this.#setAnnotate(!this.#annot));
939
+ img.addEventListener("mousemove", (e) => {
940
+ if (!this.#annot || !this.#frame.w) return;
941
+ this.#hoverPt = this.#point(e);
942
+ this.#probeHover();
943
+ });
944
+ img.addEventListener("mouseleave", () => {
945
+ if (!this.#annot) return;
946
+ this.#hoverPt = null;
947
+ this.#hover = null;
948
+ this.#drawHl();
949
+ });
950
+ img.addEventListener("click", async (e) => {
951
+ if (!this.#annot || !this.#frame.w) return;
952
+ e.preventDefault();
953
+ const t = await this.#inspect(this.#point(e), true);
954
+ if (t && this.#annot) this.#select(t);
955
+ });
956
+ new ResizeObserver(() => this.#drawHl()).observe(img);
957
+ note.addEventListener("submit", async (e) => {
958
+ e.preventDefault();
959
+ const text = noteBox.value.trim();
960
+ const target = this.#sel;
961
+ if (!text || !target) return;
962
+ noteHint.textContent = "보내는 중…";
963
+ const res = await this.send(text, "annotate", { target });
964
+ if (!res.ok) { noteHint.textContent = `전송 실패: ${res.error}`; return; }
965
+ noteBox.value = "";
966
+ if (this.#sel === target) this.#select(null);
967
+ });
968
+ noteBox.addEventListener("keydown", (e) => {
969
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); note.requestSubmit(); }
970
+ if (e.key === "Escape") { e.preventDefault(); this.#select(null); }
971
+ });
972
+ }
973
+
736
974
  #renderScreenMeta(m) {
737
975
  const { live, url, viewport } = this.$;
738
976
  if (!m.connected) {
@@ -748,6 +986,7 @@ class AgentBridge extends HTMLElement {
748
986
  url.textContent = m.title ? `${m.title} · ${m.url}` : m.url || "";
749
987
  url.title = m.url || "";
750
988
  }
989
+ this.#drawHl();
751
990
  this.#emit("agent-screen", m);
752
991
  }
753
992
 
@@ -884,7 +1123,7 @@ class AgentBridge extends HTMLElement {
884
1123
  return;
885
1124
  } else if (m.role === "assistant") {
886
1125
  wrap.className = "msg assistant";
887
- meta.textContent = `에이전트 · ${time}`;
1126
+ meta.textContent = `에이전트${m.script ? ` · 대본 ${m.script}` : ""} · ${time}`;
888
1127
  if (this.#md) bubble.innerHTML = this.#md(m.text);
889
1128
  else { bubble.textContent = m.text; bubble.classList.add("plain"); }
890
1129
  } else if (m.role === "browser") {
@@ -946,6 +1185,7 @@ class AgentBridge extends HTMLElement {
946
1185
  this.$.buttons.forEach((b) => (b.disabled = !canSend));
947
1186
  if (!s || !s.alive || s.status === "idle") this.#tools.clear();
948
1187
  this.#renderActivity();
1188
+ this.#renderBusy();
949
1189
  this.#emit("agent-status", s);
950
1190
  }
951
1191