agello 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,6 +69,8 @@ The approve / reject buttons send `action=approve` / `action=reject`.
69
69
  | `agello open [--port N \| --pane ID]` | Open the page (default: this pane's server) |
70
70
  | `agello present [x,y,w,h] [--port N \| --pane ID]` | Presentation mode (see below) |
71
71
  | `agello present stop` | End presentation mode |
72
+ | `agello script load <file> \| show` | Load / show a presentation script |
73
+ | `agello present resume \| pause \| goto <n[.m]>` | Play the script from where it stopped / stop it / move to a step |
72
74
 
73
75
  `start` options: `--pane <id>`, `--port <n>` (default: first free from 8765), `--session <id>`, `--browser <terminal-browser key>`, `--allow-origin <origin>` (repeatable), `--open`, `--foreground`.
74
76
 
@@ -87,6 +89,23 @@ State and logs: `~/.local/state/agello/<port>.json`, `<port>.log`.
87
89
 
88
90
  Run `present` again to move the crop. User control is off while presenting.
89
91
 
92
+ #### Scripts
93
+
94
+ Write the talk ahead so each line appears the moment its screen does:
95
+
96
+ ```json
97
+ { "rect": "28,157,688,477",
98
+ "steps": [
99
+ { "go": "#1", "say": ["First line", "Second line"] },
100
+ { "go": "#2", "say": ["..."], "hold": 6 } ] }
101
+ ```
102
+
103
+ - 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
104
+ - `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
105
+ - raising a hand pauses at once; the agent gets `action=hand-raise` with the position (`마지막 표시 2.1 · 다음 2.2`)
106
+ - `script load` again after editing keeps the position; `present goto 2.2` then `present resume` replays from a fixed line
107
+ - 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
108
+
90
109
  ## Embed
91
110
 
92
111
  ```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.2.1",
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,12 @@ 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
+ return { handle, input, describe, setClip, evaluate };
272
279
  }
package/src/server.ts CHANGED
@@ -11,10 +11,14 @@
11
11
  // POST /present {rect?: {x,y,w,h}} start (or move the crop), {stop: true} end
12
12
  // ({stop: true, from: "viewer"}: ended from the page, the agent is told
13
13
  // with "[browser] action=present-stop")
14
+ // GET /script presentation script + player position
15
+ // POST /script {script: {rect?, steps: [{go?, say: string[], hold?}]}} load (keeps position)
16
+ // POST /player {cmd: "resume" | "pause" | "goto", at?: "3" | "3.2"}
14
17
 
15
18
  import { homedir } from "node:os";
16
19
  import { join } from "node:path";
17
20
  import { runJson } from "./run.ts";
21
+ import { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
18
22
  import { createScreencast, type Rect } from "./screencast.ts";
19
23
 
20
24
  export type ServerOptions = {
@@ -90,7 +94,7 @@ export function parseRect(r: any): Rect | null {
90
94
  return { x, y, w, h };
91
95
  }
92
96
 
93
- export type Present = { on: boolean; rect?: Rect; since?: string };
97
+ export type Present = { on: boolean; rect?: Rect; since?: string; player?: PlayerInfo };
94
98
 
95
99
  type Status =
96
100
  | { alive: false; reason: string; pane: string }
@@ -311,10 +315,67 @@ export async function startServer(opts: ServerOptions) {
311
315
  // short-lived bubbles. rect: crop of the visible viewport (unset = whole screen).
312
316
 
313
317
  let present: Present = { on: false };
318
+ const presentView = (): Present => ({ ...present, player: player.info() });
314
319
  function setPresent(next: Present) {
315
320
  present = next;
316
321
  screen.setClip(next.on ? (next.rect ?? null) : null);
317
- broadcast("present", present);
322
+ if (!next.on) player.stop();
323
+ broadcast("present", presentView());
324
+ }
325
+
326
+ // Script player: agello owns order and timing, the agent owns the deck
327
+ // (each step's `go`) and decides when to resume after a question.
328
+ const player = createPlayer({
329
+ async go(expr) {
330
+ const js = expr.startsWith("#") ? `location.hash = ${JSON.stringify(expr)}` : expr;
331
+ if (!(await screen.evaluate(js))) console.error(`present: go failed: ${expr}`);
332
+ },
333
+ say(text, at, hold) {
334
+ broadcast("message", { role: "assistant", text, ts: new Date().toISOString(), script: at, hold });
335
+ },
336
+ done() {
337
+ const n = player.info().steps;
338
+ promptAgent("present-done", `대본 끝까지 재생함 (${n}장)`);
339
+ },
340
+ changed() {
341
+ broadcast("present", presentView());
342
+ },
343
+ });
344
+
345
+ // "마지막 표시 3.2 · 다음 3.3" for agent notices
346
+ const where = () => {
347
+ const i = player.info();
348
+ if (i.state === "empty") return "";
349
+ return [i.last && `마지막 표시 ${i.last}`, i.next && `다음 ${i.next}`].filter(Boolean).join(" · ");
350
+ };
351
+
352
+ async function scriptRequest(req: Request): Promise<Response> {
353
+ if (req.method !== "POST") return Response.json({ script: player.script(), player: player.info() });
354
+ const data = (await req.json().catch(() => ({}))) as { script?: unknown };
355
+ const script = parseScript(data.script);
356
+ if (typeof script === "string") return Response.json({ ok: false, error: script }, { status: 400 });
357
+ if (script.rect && !parseRect(script.rect))
358
+ return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
359
+ player.load(script);
360
+ return Response.json({ ok: true, player: player.info() });
361
+ }
362
+
363
+ async function playerRequest(req: Request): Promise<Response> {
364
+ const data = (await req.json().catch(() => ({}))) as { cmd?: string; at?: string };
365
+ let error: string | null = null;
366
+ if (data.cmd === "resume") {
367
+ if (!present.on) {
368
+ const rect = player.script()?.rect;
369
+ setPresent({ on: true, rect: rect ? parseRect(rect)! : undefined, since: new Date().toISOString() });
370
+ }
371
+ error = player.resume();
372
+ } else if (data.cmd === "pause") player.pause();
373
+ else if (data.cmd === "goto") {
374
+ const pos = parsePos(data.at ?? "");
375
+ error = pos ? await player.goto(pos) : "invalid_position";
376
+ } else error = "unknown_cmd";
377
+ if (error) return Response.json({ ok: false, error, player: player.info() }, { status: 400 });
378
+ return Response.json({ ok: true, player: player.info() });
318
379
  }
319
380
 
320
381
  async function presentRequest(req: Request): Promise<Response> {
@@ -326,10 +387,11 @@ export async function startServer(opts: ServerOptions) {
326
387
  // (The CLI stop comes from the agent itself and needs no notice.)
327
388
  let notified: string | undefined;
328
389
  if (was && data.from === "viewer") {
329
- const r = await promptAgent("present-stop", "사용자가 페이지에서 발표를 종료함");
390
+ const at = where();
391
+ const r = await promptAgent("present-stop", `사용자가 페이지에서 발표를 종료함${at ? ` (대본 정지: ${at})` : ""}`);
330
392
  notified = r.ok ? "ok" : r.error;
331
393
  }
332
- return Response.json({ ok: true, present, notified });
394
+ return Response.json({ ok: true, present: presentView(), notified });
333
395
  }
334
396
  let rect: Rect | undefined;
335
397
  if (data.rect != null) {
@@ -337,7 +399,7 @@ export async function startServer(opts: ServerOptions) {
337
399
  if (!rect) return Response.json({ ok: false, error: "invalid_rect" }, { status: 400 });
338
400
  }
339
401
  setPresent({ on: true, rect, since: present.on ? present.since : new Date().toISOString() });
340
- return Response.json({ ok: true, present });
402
+ return Response.json({ ok: true, present: presentView() });
341
403
  }
342
404
 
343
405
  // ----- HTTP -----
@@ -378,7 +440,7 @@ export async function startServer(opts: ServerOptions) {
378
440
  tools: [...pendingTools.keys()],
379
441
  });
380
442
  if (lastStatus) send(c, "status", lastStatus);
381
- send(c, "present", present);
443
+ send(c, "present", presentView());
382
444
  for (const t of pendingTools.values()) send(c, "tool_start", t);
383
445
  ping = setInterval(() => send(c, "ping", {}), 15000);
384
446
  },
@@ -405,7 +467,15 @@ export async function startServer(opts: ServerOptions) {
405
467
  async function sendPrompt(req: Request): Promise<Response> {
406
468
  const data = (await req.json().catch(() => ({}))) as { action?: string; text?: string };
407
469
  const action = /^[\w-]{1,32}$/.test(data.action ?? "") ? data.action! : "message";
408
- const r = await promptAgent(action, data.text ?? "");
470
+ let text = data.text ?? "";
471
+ // Raised hand: stop the script at once (no agent round trip) and tell the
472
+ // agent where it stopped.
473
+ if (action === "hand-raise") {
474
+ player.pause();
475
+ const at = where();
476
+ if (at) text += ` (대본 정지: ${at})`;
477
+ }
478
+ const r = await promptAgent(action, text);
409
479
  return r.ok ? Response.json({ ok: true }) : Response.json({ ok: false, error: r.error }, { status: r.status });
410
480
  }
411
481
 
@@ -416,7 +486,7 @@ export async function startServer(opts: ServerOptions) {
416
486
  ...(await checkStatus()),
417
487
  browser: opts.browser,
418
488
  screen: await screen.describe(),
419
- present,
489
+ present: presentView(),
420
490
  });
421
491
  case "/events":
422
492
  return events();
@@ -430,7 +500,12 @@ export async function startServer(opts: ServerOptions) {
430
500
  if (req.method === "POST") return sendPrompt(req);
431
501
  break;
432
502
  case "/present":
433
- return req.method === "POST" ? presentRequest(req) : Response.json(present);
503
+ return req.method === "POST" ? presentRequest(req) : Response.json(presentView());
504
+ case "/script":
505
+ return scriptRequest(req);
506
+ case "/player":
507
+ if (req.method === "POST") return playerRequest(req);
508
+ break;
434
509
  }
435
510
  return new Response(HTML, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
436
511
  }
@@ -36,6 +36,9 @@
36
36
  // closing it without asking sends action=hand-lower so it can go on.
37
37
  // The viewer's questions show as bubbles too, with the same 10s lifetime.
38
38
  // An end button above it stops the presentation for every viewer.
39
+ // Script lines (`agello script` + `present resume`) arrive as agent messages
40
+ // with `script: "step.line"`. While the script is not playing and the agent
41
+ // is working (e.g. answering a question), a small loader is shown.
39
42
  //
40
43
  // Methods
41
44
  // el.send(text, action = 'message') -> Promise<{ok, error?}>
@@ -199,6 +202,14 @@ h1 { font-size: 14px; margin: 0; font-weight: 600; }
199
202
  .caption a { color: inherit; }
200
203
  @keyframes capIn { from { opacity: 0; translate: 0 10px; } }
201
204
 
205
+ /* agent busy while presenting (answering, fixing the script): minimal loader */
206
+ .busy { display: none; }
207
+ .body.present .busy.on { position: absolute; left: 22px; bottom: 28px; z-index: 5; display: flex; align-items: center;
208
+ gap: 9px; padding: 9px 14px; border-radius: 999px; font-size: 13px; color: #fff; background: rgba(20, 20, 22, .72);
209
+ -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); box-shadow: 0 8px 30px rgba(0,0,0,.35);
210
+ animation: capIn .25s ease; }
211
+ .busy .spinner { border-color: rgba(255,255,255,.25); border-top-color: #fff; }
212
+
202
213
  /* raise hand: ask the agent mid-presentation */
203
214
  .hand-wrap { display: none; }
204
215
  .body.present .hand-wrap { position: absolute; right: 22px; bottom: 28px; z-index: 5; display: flex;
@@ -328,6 +339,7 @@ const TEMPLATE = `
328
339
  <button class="control-btn" role="switch" aria-checked="false" title="켜면 마우스와 키보드 입력이 브라우저로 전달돼요 (끄기: Shift+Esc)">
329
340
  <span>내 조작</span><span class="track"><span class="knob"></span></span><span class="state-label">OFF</span></button></div>
330
341
  <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>
342
+ <div class="busy" part="busy" role="status"><span class="spinner"></span><span>답변 준비 중</span></div>
331
343
  <div class="hand-wrap">
332
344
  <form class="ask" part="ask" hidden><textarea aria-label="에이전트에게 질문" placeholder="에이전트에게 질문 (Enter 전송, Esc 닫기)"></textarea>
333
345
  <div class="ask-row"><span class="ask-hint"></span><button type="submit" class="primary">보내기</button></div></form>
@@ -370,7 +382,7 @@ class AgentBridge extends HTMLElement {
370
382
  live: q(".live"), url: q(".screen-bar .url"), viewport: q(".viewport"), img: q(".viewport img"),
371
383
  controlBtn: q(".control-btn"), kbd: q(".kbd"), clearBtn: q(".clear-btn"),
372
384
  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"),
385
+ busy: q(".busy"), hand: q(".hand"), end: q(".end"), ask: q(".ask"), askBox: q(".ask textarea"), askHint: q(".ask-hint"),
374
386
  };
375
387
  this.$.buttons.forEach((b) => b.addEventListener("click", () => this.#submit(b.dataset.action)));
376
388
  this.#bindGrip();
@@ -452,7 +464,8 @@ class AgentBridge extends HTMLElement {
452
464
  on("message", (m) => {
453
465
  this.#addMessage(m);
454
466
  this.#saveMessage(m);
455
- if (this.#present.on && m.role === "assistant") this.#caption(m.text);
467
+ // script lines carry their own on-screen time (>= 10s), others use CAPTION_MS
468
+ if (this.#present.on && m.role === "assistant") this.#caption(m.text, "agent", m.hold * 1000 || undefined);
456
469
  if (this.#present.on && m.role === "browser" && m.action === "message") this.#caption(m.text, "user");
457
470
  this.#emit("agent-message", m);
458
471
  });
@@ -498,6 +511,7 @@ class AgentBridge extends HTMLElement {
498
511
  const was = this.#present.on;
499
512
  this.#present = p ?? { on: false };
500
513
  this.#emit("agent-present", this.#present);
514
+ this.#renderBusy();
501
515
  if (on === was) return;
502
516
  if (on) this.#setControl(false);
503
517
  else { this.$.captions.replaceChildren(); this.#openAsk(false); this.#handUp = false; }
@@ -515,6 +529,12 @@ class AgentBridge extends HTMLElement {
515
529
  { duration: on ? 420 : 360, easing: "cubic-bezier(.2, .7, .2, 1)" });
516
530
  }
517
531
 
532
+ #renderBusy() {
533
+ const s = this.#status;
534
+ const busy = this.#present.on && s?.alive && s.status === "working" && this.#present.player?.state !== "playing";
535
+ this.$.busy.classList.toggle("on", !!busy);
536
+ }
537
+
518
538
  // Raise hand: open a small input over the presentation and send it as a message.
519
539
  #bindHand() {
520
540
  const { hand, ask, askBox, askHint } = this.$;
@@ -574,7 +594,7 @@ class AgentBridge extends HTMLElement {
574
594
 
575
595
  // Agent reply as a short-lived bubble over the presented screen.
576
596
  // kind "user": the viewer's own question (plain text, accent colour).
577
- #caption(text, kind = "agent") {
597
+ #caption(text, kind = "agent", ms = AgentBridge.CAPTION_MS) {
578
598
  if (!text?.trim()) return;
579
599
  const el = document.createElement("div");
580
600
  el.className = `caption ${kind}`;
@@ -589,7 +609,7 @@ class AgentBridge extends HTMLElement {
589
609
  el.classList.add("out");
590
610
  setTimeout(() => el.remove(), 500);
591
611
  };
592
- setTimeout(leave, AgentBridge.CAPTION_MS);
612
+ setTimeout(leave, ms);
593
613
  const live = [...box.children].filter((c) => !c.classList.contains("out"));
594
614
  live.slice(0, Math.max(0, live.length - AgentBridge.CAPTION_MAX)).forEach((c) => c.__leave?.());
595
615
  el.__leave = leave;
@@ -884,7 +904,7 @@ class AgentBridge extends HTMLElement {
884
904
  return;
885
905
  } else if (m.role === "assistant") {
886
906
  wrap.className = "msg assistant";
887
- meta.textContent = `에이전트 · ${time}`;
907
+ meta.textContent = `에이전트${m.script ? ` · 대본 ${m.script}` : ""} · ${time}`;
888
908
  if (this.#md) bubble.innerHTML = this.#md(m.text);
889
909
  else { bubble.textContent = m.text; bubble.classList.add("plain"); }
890
910
  } else if (m.role === "browser") {
@@ -946,6 +966,7 @@ class AgentBridge extends HTMLElement {
946
966
  this.$.buttons.forEach((b) => (b.disabled = !canSend));
947
967
  if (!s || !s.alive || s.status === "idle") this.#tools.clear();
948
968
  this.#renderActivity();
969
+ this.#renderBusy();
949
970
  this.#emit("agent-status", s);
950
971
  }
951
972