agello 0.4.1 → 0.4.3

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
 
@@ -98,10 +113,29 @@ The xterm.js renderer and its styles are served locally; no terminal CDN is requ
98
113
  - agent replies appear over it as bubbles that fade after 10s
99
114
  - `agello present stop` returns to the normal layout; the replies are in the chat as usual
100
115
  - the viewer can ask a question with the raise-hand button (its bubble fades after 10s too) or end the presentation with the end button; the agent then receives `[browser] action=present-stop`
101
- - raising the hand tells the agent at once (`action=hand-raise`) so it can pause; closing the input without asking sends `action=hand-lower`
116
+ - raising the hand tells the agent at once (`action=hand-raise`) so it can pause; closing it with X (or Esc) before asking sends `action=hand-lower`
117
+ - the input stays open for the whole question: after the agent answers, follow-up questions go in the same input (no need to raise the hand again); X (or Esc) then sends `action=hand-done`, and the agent resumes
102
118
 
103
119
  Run `present` again to move the crop. User control is off while presenting.
104
120
 
121
+ #### Scripts
122
+
123
+ Write the talk ahead so each line appears the moment its screen does:
124
+
125
+ ```json
126
+ { "rect": "28,157,688,477",
127
+ "steps": [
128
+ { "go": "#1", "say": ["First line", "Second line"] },
129
+ { "go": "#2", "say": ["..."], "hold": 6 } ] }
130
+ ```
131
+
132
+ - 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
133
+ - `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
134
+ - raising a hand pauses at once and rewinds one line, so `present resume` replays the interrupted line; the agent gets `action=hand-raise` with the position (`마지막 표시 2.1 · 다음 2.1`)
135
+ - raising, cancelling (X before asking) and finishing a question (X after an answer) show a small light event bubble for 5s right away, apart from the conversation bubbles; sending a question shows none
136
+ - `script load` again after editing keeps the position; `present goto 2.2` then `present resume` replays from a fixed line
137
+ - 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
138
+
105
139
  ## Embed
106
140
 
107
141
  ```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.3",
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,244 @@
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
+ // rewind() (after a raised hand) moves back so the last shown line plays again.
13
+ // goto() moves the position and shows that step without playing.
14
+ //
15
+ // Positions are "step.line", 1-based, in everything that leaves this module.
16
+
17
+ import type { Rect } from "./screencast.ts";
18
+
19
+ export type Step = { go?: string; say: string[]; hold?: number };
20
+ export type Script = { rect?: Rect; steps: Step[] };
21
+ export type PlayerInfo = {
22
+ state: "empty" | "ready" | "playing" | "paused" | "done";
23
+ next?: string; // next line to show ("3.2")
24
+ last?: string; // last line shown
25
+ steps: number;
26
+ };
27
+
28
+ // Pacing: the audience looks at a new screen first, then reads and thinks.
29
+ // go -> 1s -> line (shown >= 10s) -> fades out -> 0.3s -> next line
30
+ // last line -> fades out -> 0.7s -> next step's go
31
+ const MIN_HOLD = 10;
32
+ const MAX_HOLD = 20;
33
+ export const PACE = { afterGo: 1000, fade: 500, betweenLines: 300, afterStep: 700 };
34
+
35
+ // How long one line stays on screen (s): at least 10s, longer text a bit more
36
+ // (about 8 chars/s past the first ~50 chars), at most 20s. `hold` can only
37
+ // lengthen it.
38
+ export function lineHold(text: string, hold?: number): number {
39
+ const auto = Math.min(MAX_HOLD, Math.max(MIN_HOLD, 4 + [...text].length / 8));
40
+ return hold != null && Number.isFinite(hold) && hold > 0 ? Math.max(MIN_HOLD, hold) : auto;
41
+ }
42
+
43
+ export function parseRectString(v: unknown): Rect | undefined {
44
+ if (v && typeof v === "object") {
45
+ const r = v as any;
46
+ return { x: r.x, y: r.y, w: r.w, h: r.h };
47
+ }
48
+ if (typeof v !== "string") return undefined;
49
+ const n = v.split(/[\s,]+/).filter(Boolean).map(Number);
50
+ return n.length === 4 ? { x: n[0], y: n[1], w: n[2], h: n[3] } : undefined;
51
+ }
52
+
53
+ // Validate a script from JSON. Returns the script or an error message.
54
+ export function parseScript(raw: any): Script | string {
55
+ if (!raw || typeof raw !== "object") return "script must be an object";
56
+ if (!Array.isArray(raw.steps) || !raw.steps.length) return "steps must be a non-empty array";
57
+ const steps: Step[] = [];
58
+ for (const [i, s] of raw.steps.entries()) {
59
+ const at = `steps[${i}]`;
60
+ if (!s || typeof s !== "object") return `${at} must be an object`;
61
+ if (s.go != null && typeof s.go !== "string") return `${at}.go must be a string`;
62
+ const say = s.say == null ? [] : Array.isArray(s.say) ? s.say : [s.say];
63
+ if (!say.every((l: unknown) => typeof l === "string" && l.trim())) return `${at}.say must be non-empty strings`;
64
+ if (s.hold != null && !(Number.isFinite(s.hold) && s.hold > 0)) return `${at}.hold must be a positive number`;
65
+ steps.push({ go: s.go || undefined, say: say.map((l: string) => l.trim()), hold: s.hold });
66
+ }
67
+ const rect = raw.rect == null ? undefined : parseRectString(raw.rect);
68
+ if (raw.rect != null && !rect) return "rect must be \"x,y,w,h\" or {x,y,w,h}";
69
+ return { rect, steps };
70
+ }
71
+
72
+ // "3" or "3.2" (1-based) -> 0-based position
73
+ export function parsePos(v: string): { step: number; line: number } | null {
74
+ const m = String(v).trim().match(/^(\d+)(?:\.(\d+))?$/);
75
+ if (!m) return null;
76
+ const step = Number(m[1]) - 1;
77
+ const line = m[2] ? Number(m[2]) - 1 : 0;
78
+ return step >= 0 && line >= 0 ? { step, line } : null;
79
+ }
80
+
81
+ export function createPlayer(deps: {
82
+ go: (expr: string) => Promise<void>;
83
+ say: (text: string, at: string, hold: number) => void;
84
+ done: () => void;
85
+ changed: () => void;
86
+ }) {
87
+ let script: Script | null = null;
88
+ let state: PlayerInfo["state"] = "empty";
89
+ let cur = { step: 0, line: 0 }; // next line to show
90
+ let last: { step: number; line: number } | null = null;
91
+ let entered = false; // `go` of cur.step already run in this play run
92
+ let run = 0; // bumps on every pause/goto/load, stale timers check it
93
+ let timer: Timer | null = null;
94
+
95
+ const fmt = (p: { step: number; line: number }) => `${p.step + 1}.${p.line + 1}`;
96
+ const set = (s: PlayerInfo["state"]) => {
97
+ state = s;
98
+ deps.changed();
99
+ };
100
+ const cancel = () => {
101
+ run++;
102
+ if (timer) clearTimeout(timer);
103
+ timer = null;
104
+ };
105
+ const wait = (ms: number, id: number, fn: () => void) => {
106
+ timer = setTimeout(() => id === run && fn(), ms);
107
+ };
108
+
109
+ function info(): PlayerInfo {
110
+ const steps = script?.steps.length ?? 0;
111
+ // next line as it will be played (a finished step points at the next step)
112
+ let p = cur;
113
+ while (script && p.step < steps && p.line >= Math.max(1, script.steps[p.step].say.length))
114
+ p = { step: p.step + 1, line: 0 };
115
+ return {
116
+ state,
117
+ steps,
118
+ next: script && p.step < steps ? fmt(p) : undefined,
119
+ last: last ? fmt(last) : undefined,
120
+ };
121
+ }
122
+
123
+ // Skip past finished steps (cur.line beyond the step's lines).
124
+ function normalize() {
125
+ if (!script) return;
126
+ while (cur.step < script.steps.length && cur.line >= Math.max(1, script.steps[cur.step].say.length)) {
127
+ cur = { step: cur.step + 1, line: 0 };
128
+ entered = false;
129
+ }
130
+ }
131
+
132
+ async function tick(id: number) {
133
+ if (id !== run || state !== "playing" || !script) return;
134
+ normalize();
135
+ if (cur.step >= script.steps.length) {
136
+ set("done");
137
+ deps.done();
138
+ return;
139
+ }
140
+ const step = script.steps[cur.step];
141
+ if (!entered) {
142
+ entered = true;
143
+ if (step.go) {
144
+ await deps.go(step.go);
145
+ if (id !== run) return;
146
+ wait(PACE.afterGo, id, () => tick(id));
147
+ return;
148
+ }
149
+ }
150
+ if (!step.say.length) {
151
+ // silent step: just show the screen for `hold` seconds
152
+ cur = { step: cur.step, line: 1 };
153
+ deps.changed();
154
+ wait(lineHold("", step.hold) * 1000 + PACE.afterStep, id, () => tick(id));
155
+ return;
156
+ }
157
+ const text = step.say[cur.line];
158
+ const hold = lineHold(text, step.hold);
159
+ const lastLine = cur.line === step.say.length - 1;
160
+ last = { ...cur };
161
+ deps.say(text, fmt(cur), hold);
162
+ cur = { step: cur.step, line: cur.line + 1 };
163
+ deps.changed();
164
+ const gap = lastLine ? PACE.afterStep : PACE.betweenLines;
165
+ wait(hold * 1000 + PACE.fade + gap, id, () => tick(id));
166
+ }
167
+
168
+ return {
169
+ info,
170
+ script: () => script,
171
+
172
+ // Replace the script, keeping the position (clamped) and the state.
173
+ load(next: Script) {
174
+ const playing = state === "playing";
175
+ cancel();
176
+ script = next;
177
+ if (cur.step > next.steps.length) cur = { step: next.steps.length, line: 0 };
178
+ normalize();
179
+ if (state === "empty") state = "ready";
180
+ if (state === "done" && cur.step < next.steps.length) state = "paused";
181
+ if (playing) {
182
+ entered = true; // screen is already on this step
183
+ tick(run);
184
+ }
185
+ deps.changed();
186
+ },
187
+
188
+ resume(): string | null {
189
+ if (!script) return "no_script";
190
+ if (state === "playing") return null;
191
+ if (state === "done") return "done";
192
+ cancel();
193
+ entered = false; // bring the screen back to the current step first
194
+ set("playing");
195
+ tick(run);
196
+ return null;
197
+ },
198
+
199
+ pause() {
200
+ if (state !== "playing") return;
201
+ cancel();
202
+ set("paused");
203
+ },
204
+
205
+ // Paused by an interruption (raised hand): the next resume replays the
206
+ // last shown line, since the viewer may have missed it.
207
+ rewind() {
208
+ if (state !== "paused" || !last) return;
209
+ cur = { ...last };
210
+ entered = false;
211
+ deps.changed();
212
+ },
213
+
214
+ // Move to a step (and line) and show its screen. Does not play; if it was
215
+ // playing, it keeps playing from there.
216
+ async goto(pos: { step: number; line: number }): Promise<string | null> {
217
+ if (!script) return "no_script";
218
+ const step = script.steps[pos.step];
219
+ if (!step) return "no_such_step";
220
+ if (pos.line > 0 && pos.line >= step.say.length) return "no_such_line";
221
+ const playing = state === "playing";
222
+ cancel();
223
+ cur = { ...pos };
224
+ entered = false;
225
+ if (playing) {
226
+ tick(run);
227
+ return null;
228
+ }
229
+ if (state !== "ready") state = "paused";
230
+ if (step.go) await deps.go(step.go);
231
+ entered = true;
232
+ deps.changed();
233
+ return null;
234
+ },
235
+
236
+ // Presentation ended: stop where it is.
237
+ stop() {
238
+ if (state === "playing") {
239
+ cancel();
240
+ set("paused");
241
+ }
242
+ },
243
+ };
244
+ }
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
+ });