agello 0.4.3 → 0.4.5

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
@@ -8,6 +8,7 @@
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
10
  - Annotate: point at an element on that screen and send a request about it, with its CSS selector
11
+ - Tab sound: hear that screen's audio on the page instead of the agent's machine
11
12
  - Embeddable `<agent-bridge>` web component
12
13
 
13
14
  Chat currently supports **Claude Code** sessions. The terminal view connects directly to the pane, including ordinary shells and other terminal applications.
@@ -73,6 +74,10 @@ The `주석` button on the screen panel turns on annotate mode: hovering tints t
73
74
 
74
75
  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
76
 
77
+ ### Tab sound
78
+
79
+ The `소리` button on the screen panel plays the relayed tab's audio on that page. While at least one page has it on, the tab is muted in the agent's terminal-browser; when the last one turns it off or closes, local playback returns within 4 seconds. agello reroutes the page's Web Audio output and `<audio>`/`<video>` elements into PCM chunks (`GET /screen/audio`, SSE), since CDP has no audio stream. Not captured: Web Audio connected before it was turned on (reload the tab), cross-origin media without CORS (keeps playing locally), cross-site iframes. Audio trails the screen by about 0.2s.
80
+
76
81
  ### Commands
77
82
 
78
83
  | Command | Description |
@@ -136,6 +141,30 @@ Write the talk ahead so each line appears the moment its screen does:
136
141
  - `script load` again after editing keeps the position; `present goto 2.2` then `present resume` replays from a fixed line
137
142
  - 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
143
 
144
+ #### Optional TTS and recording
145
+
146
+ The screen panel's **발표 설정** remains available during presentation. TTS and recording both start OFF and can be used independently:
147
+
148
+ | TTS | Recording | Result |
149
+ |---|---|---|
150
+ | OFF | OFF | Existing timed script captions |
151
+ | OFF | ON | Presentation images and captions in a silent video |
152
+ | ON | OFF | Spoken script, with captions synchronized to audio playback |
153
+ | ON | ON | Presentation images, captions, and TTS audio in the controlling viewer's video |
154
+
155
+ 1. Open **발표 설정**. Enter a Typecast API key and optionally change the voice ID, then click **키·목소리 적용**. The default voice is `tc_6a0e85a97f7750959b970d5d`; the provider uses Korean (`kor`) and model `ssfm-v30`.
156
+ 2. Alternatively, **로컬 키 불러오기** reads only `TYPECAST_API_KEY` from `../yt-outlier/.env`, relative to the server's working directory. It does not execute the file or import other settings. A server started with `TYPECAST_API_KEY` in its environment also has a key ready, with TTS still OFF.
157
+ 3. Click **TTS 켜기** in the viewer that should play sound. This click grants browser audio playback permission. Click **대본 재생** after loading a script; **일시정지** stops playback, and **다시 재생** starts a completed script again from its first step.
158
+ 4. Click **녹화 시작**, then **녹화 종료** and **영상 저장** to download the recording. Recording can start before presentation if a screen image is already available. Stopping recording leaves script and audio playback running.
159
+
160
+ With TTS enabled, each script caption appears after that viewer starts the actual audio, and closes when the audio finishes. The audio's duration replaces the script's `hold`; ordinary agent replies still use their existing caption timer. Raising a hand immediately stops audio locally, and resumed playback repeats the interrupted line. Pausing, changing steps, updating a script, or disconnecting cancels outdated speech. A TTS failure pauses the script; fix the key or connection, or turn TTS OFF, then resume.
161
+
162
+ Only the viewer that enabled TTS plays audio and acknowledges speech progress. Other viewers receive the same captions and can record a silent video. To include TTS audio, record in the viewer that enabled TTS. Audio settings are fixed when recording starts: stop recording before changing TTS. If another viewer changes the controlling audio role, a recording whose audio setting changes is finalized automatically.
163
+
164
+ Recording uses a 1280×720 canvas that fits the relayed browser image with letterboxing and draws the current captions below it. It records these images and plain caption text, rather than the page's entire DOM: chat, settings, question input, and control buttons are excluded; caption Markdown styling is flattened. Long captions are wrapped and truncated to the safe area. The canvas targets 30fps, while source screen updates can be slower (presentation screenshots are approximately 10fps). A background or minimized tab may render more slowly. Use a current Chromium browser for the verified recording path; other browsers depend on their `canvas.captureStream`, Web Audio, and `MediaRecorder` support. MP4 is preferred when supported, with WebM as a fallback. Streaming WebM duration metadata is finalized when its header supports the correction; otherwise the original browser recording is retained.
165
+
166
+ Recording stops at 15 minutes or 256MB, at presentation end, on server changes, or on connection loss. Keep the page open until **영상 저장** appears and download it before closing or reloading; recording data is held in browser memory. A later recording replaces the earlier download link.
167
+
139
168
  ## Embed
140
169
 
141
170
  ```html
@@ -167,9 +196,11 @@ See `web/embed-example.html`.
167
196
 
168
197
  The server listens on `127.0.0.1` only. Data and input endpoints accept requests from the same origin and `localhost` pages only; other origins get `403` unless added with `--allow-origin`. Allowed origins can send chat prompts and connect to `/terminal` to type directly into the pane, so keep the allow list short.
169
198
 
199
+ TTS keys remain in server memory until replaced, cleared with **키 삭제**, or the server stops. Password inputs are cleared after applying; keys and speech-driver tokens are never put in browser storage, chat history, scripts, or SSE broadcasts. Keys are sent only to the local server and Typecast. Script text is sent to Typecast when TTS is ON, and generated audio is cached in server memory for reuse. Allowed origins can also configure TTS and read generated audio, so allow only trusted pages.
200
+
170
201
  ## Development
171
202
 
172
- Run `bun install` and `bun test`. Tests cover the pane tree and creation (against a mock herdr), local terminal assets, origin rejection, ANSI frames, keyboard bytes, resize, exclusive control, and reconnect.
203
+ Run `bun install` and `bun test`. Tests cover the pane tree and creation (against a mock herdr), local terminal assets, origin rejection, ANSI frames, keyboard bytes, resize, exclusive control, reconnect, TTS coordination, stale speech cancellation, and recording lifecycle and metadata.
173
204
 
174
205
  ## License
175
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agello",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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",
@@ -0,0 +1,159 @@
1
+ // Page audio relay, page side. Injected into the relayed terminal-browser tab
2
+ // through CDP while at least one viewer listens (GET /screen/audio).
3
+ //
4
+ // CDP has no audio stream and terminal-browser (Electron) denies tab capture,
5
+ // so the page's own audio graph is rerouted:
6
+ // - AudioNode.connect(ctx.destination) goes to a per-context tap instead
7
+ // - <audio>/<video> playing while remote are moved into a WebAudio graph
8
+ // (createMediaElementSource), which also takes them off the speakers
9
+ // tap -> out (gain 1 local, 0 remote) -> real destination
10
+ // tap -> ScriptProcessor -> 16-bit stereo PCM -> binding -> agello server
11
+ //
12
+ // Remote mode lasts while the server keeps calling ping() (every ~1.5s); after
13
+ // 4s without a ping (server gone, tab no longer relayed) local playback returns.
14
+ //
15
+ // Not captured: connections made before injection (reload the page),
16
+ // cross-origin media without CORS (routing them would play silence, so they
17
+ // keep playing locally), cross-site iframes (separate CDP targets).
18
+
19
+ export const AUDIO_BINDING = "__agelloAudioPcm";
20
+
21
+ export const PAGE_AUDIO_JS = `(() => {
22
+ if (window.__agelloAudio) return;
23
+ const AC = window.AudioContext || window.webkitAudioContext;
24
+ if (!AC || !window.AudioNode) return;
25
+ const BINDING = ${JSON.stringify(AUDIO_BINDING)};
26
+ const QUIET_CHUNKS = 24; // ~2s of silence before chunks stop being sent
27
+ const origConnect = AudioNode.prototype.connect;
28
+ const origDisconnect = AudioNode.prototype.disconnect;
29
+ const origCreateSource = AC.prototype.createMediaElementSource;
30
+ const origPlay = HTMLMediaElement.prototype.play;
31
+ const taps = new WeakMap(); // context -> tap record
32
+ const records = new Set();
33
+ const owned = new WeakSet(); // media elements moved into our graph
34
+ let remote = false, lastPing = 0, nextId = 1, mediaCtx = null;
35
+
36
+ function send(t, buf) {
37
+ const n = buf.length;
38
+ const L = buf.getChannelData(0), R = buf.numberOfChannels > 1 ? buf.getChannelData(1) : L;
39
+ const pcm = new Int16Array(n * 2);
40
+ let loud = false;
41
+ for (let i = 0; i < n; i++) {
42
+ const l = L[i], r = R[i];
43
+ if (l || r) loud = true;
44
+ pcm[2 * i] = (l < -1 ? -1 : l > 1 ? 1 : l) * 32767;
45
+ pcm[2 * i + 1] = (r < -1 ? -1 : r > 1 ? 1 : r) * 32767;
46
+ }
47
+ t.quiet = loud ? 0 : t.quiet + 1;
48
+ if (t.quiet > QUIET_CHUNKS) return;
49
+ const bytes = new Uint8Array(pcm.buffer);
50
+ let s = "";
51
+ for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
52
+ try { window[BINDING](JSON.stringify({ c: t.id, r: t.ctx.sampleRate, d: btoa(s) })); } catch {}
53
+ }
54
+
55
+ function tapOf(ctx) {
56
+ let t = taps.get(ctx);
57
+ if (t) return t;
58
+ const tap = ctx.createGain(), out = ctx.createGain();
59
+ out.gain.value = remote ? 0 : 1;
60
+ origConnect.call(tap, out);
61
+ origConnect.call(out, ctx.destination);
62
+ t = { id: nextId++, ctx, tap, out, quiet: QUIET_CHUNKS + 1 };
63
+ if (ctx.createScriptProcessor) {
64
+ const proc = ctx.createScriptProcessor(4096, 2, 2), sink = ctx.createGain();
65
+ sink.gain.value = 0;
66
+ origConnect.call(tap, proc);
67
+ origConnect.call(proc, sink);
68
+ origConnect.call(sink, ctx.destination);
69
+ proc.onaudioprocess = (e) => { if (remote) send(t, e.inputBuffer); };
70
+ t.proc = proc;
71
+ }
72
+ taps.set(ctx, t);
73
+ records.add(t);
74
+ return t;
75
+ }
76
+
77
+ AudioNode.prototype.connect = function (dest, output) {
78
+ const ctx = this.context;
79
+ if (dest && ctx instanceof AC && dest === ctx.destination) {
80
+ origConnect.call(this, tapOf(ctx).tap, output || 0);
81
+ return dest;
82
+ }
83
+ return origConnect.apply(this, arguments);
84
+ };
85
+ AudioNode.prototype.disconnect = function (dest) {
86
+ const ctx = this.context, t = ctx && taps.get(ctx);
87
+ if (t && dest && dest === ctx.destination) {
88
+ const args = [...arguments];
89
+ args[0] = t.tap;
90
+ if (args.length > 2) args.length = 2; // the tap has one input
91
+ return origDisconnect.apply(this, args);
92
+ }
93
+ return origDisconnect.apply(this, arguments);
94
+ };
95
+
96
+ function capturable(el) {
97
+ if (el.srcObject) return true;
98
+ const src = el.currentSrc || el.src;
99
+ if (!src) return false;
100
+ if (el.crossOrigin != null) return true;
101
+ try {
102
+ const u = new URL(src, location.href);
103
+ return u.protocol === "data:" || u.origin === location.origin;
104
+ } catch { return false; }
105
+ }
106
+ function own(el) {
107
+ if (!remote || !(el instanceof HTMLMediaElement) || owned.has(el) || !capturable(el)) return;
108
+ try {
109
+ mediaCtx = mediaCtx || new AC();
110
+ const src = origCreateSource.call(mediaCtx, el);
111
+ owned.add(el);
112
+ origConnect.call(src, tapOf(mediaCtx).tap);
113
+ if (mediaCtx.state !== "running") mediaCtx.resume().catch(() => {});
114
+ } catch {}
115
+ }
116
+ HTMLMediaElement.prototype.play = function () {
117
+ own(this);
118
+ return origPlay.apply(this, arguments);
119
+ };
120
+ for (const type of ["play", "playing"]) document.addEventListener(type, (e) => own(e.target), true);
121
+
122
+ function setRemote(on) {
123
+ if (on) lastPing = Date.now();
124
+ if (remote === on) return;
125
+ remote = on;
126
+ for (const t of records) {
127
+ t.out.gain.value = on ? 0 : 1;
128
+ t.quiet = QUIET_CHUNKS + 1;
129
+ }
130
+ if (on) {
131
+ document.querySelectorAll("audio,video").forEach((el) => { if (!el.paused) own(el); });
132
+ if (mediaCtx && mediaCtx.state !== "running") mediaCtx.resume().catch(() => {});
133
+ }
134
+ }
135
+ setInterval(() => { if (remote && Date.now() - lastPing > 4000) setRemote(false); }, 1000);
136
+
137
+ window.__agelloAudio = {
138
+ ping: () => setRemote(true),
139
+ off: () => setRemote(false),
140
+ state: () => ({ remote, contexts: records.size, media: !!mediaCtx }),
141
+ };
142
+ })()`;
143
+
144
+ // Viewer-bound chunk, validated (the binding is callable by the page itself).
145
+ export type PcmChunk = { c: number; r: number; d: string };
146
+ export function parsePcm(payload: string): PcmChunk | null {
147
+ if (payload.length > 512 * 1024) return null;
148
+ let m: any;
149
+ try {
150
+ m = JSON.parse(payload);
151
+ } catch {
152
+ return null;
153
+ }
154
+ const { c, r, d } = m ?? {};
155
+ if (!Number.isInteger(c) || c < 1 || c > 1e6) return null;
156
+ if (!Number.isFinite(r) || r < 8000 || r > 192000) return null;
157
+ if (typeof d !== "string" || !d || d.length % 4 || !/^[A-Za-z0-9+/]+={0,2}$/.test(d)) return null;
158
+ return { c, r, d };
159
+ }
package/src/player.ts CHANGED
@@ -23,6 +23,7 @@ export type PlayerInfo = {
23
23
  next?: string; // next line to show ("3.2")
24
24
  last?: string; // last line shown
25
25
  steps: number;
26
+ error?: string;
26
27
  };
27
28
 
28
29
  // Pacing: the audience looks at a new screen first, then reads and thinks.
@@ -80,7 +81,10 @@ export function parsePos(v: string): { step: number; line: number } | null {
80
81
 
81
82
  export function createPlayer(deps: {
82
83
  go: (expr: string) => Promise<void>;
83
- say: (text: string, at: string, hold: number) => void;
84
+ say: (text: string, at: string, hold: number, narration?: string) => void;
85
+ // null selects text pacing; a promise resolves only after actual playback ends.
86
+ narrate?: (text: string, at: string, signal: AbortSignal,
87
+ started: (duration: number, id: string) => void) => Promise<void> | null;
84
88
  done: () => void;
85
89
  changed: () => void;
86
90
  }) {
@@ -91,6 +95,8 @@ export function createPlayer(deps: {
91
95
  let entered = false; // `go` of cur.step already run in this play run
92
96
  let run = 0; // bumps on every pause/goto/load, stale timers check it
93
97
  let timer: Timer | null = null;
98
+ let speech: AbortController | null = null;
99
+ let error: string | undefined;
94
100
 
95
101
  const fmt = (p: { step: number; line: number }) => `${p.step + 1}.${p.line + 1}`;
96
102
  const set = (s: PlayerInfo["state"]) => {
@@ -101,6 +107,8 @@ export function createPlayer(deps: {
101
107
  run++;
102
108
  if (timer) clearTimeout(timer);
103
109
  timer = null;
110
+ speech?.abort();
111
+ speech = null;
104
112
  };
105
113
  const wait = (ms: number, id: number, fn: () => void) => {
106
114
  timer = setTimeout(() => id === run && fn(), ms);
@@ -117,6 +125,7 @@ export function createPlayer(deps: {
117
125
  steps,
118
126
  next: script && p.step < steps ? fmt(p) : undefined,
119
127
  last: last ? fmt(last) : undefined,
128
+ ...(error ? { error } : {}),
120
129
  };
121
130
  }
122
131
 
@@ -155,13 +164,38 @@ export function createPlayer(deps: {
155
164
  return;
156
165
  }
157
166
  const text = step.say[cur.line];
158
- const hold = lineHold(text, step.hold);
167
+ const position = { ...cur };
159
168
  const lastLine = cur.line === step.say.length - 1;
169
+ const gap = lastLine ? PACE.afterStep : PACE.betweenLines;
170
+ speech = new AbortController();
171
+ const narration = deps.narrate?.(text, fmt(cur), speech.signal, (duration, narrationId) => {
172
+ if (id !== run || state !== "playing") return;
173
+ last = position;
174
+ deps.say(text, fmt(position), duration, narrationId);
175
+ deps.changed();
176
+ });
177
+ if (narration) {
178
+ try {
179
+ await narration;
180
+ if (id !== run || state !== "playing") return;
181
+ speech = null;
182
+ cur = { step: position.step, line: position.line + 1 };
183
+ deps.changed();
184
+ wait(PACE.fade + gap, id, () => tick(id));
185
+ } catch {
186
+ if (id !== run) return;
187
+ cancel();
188
+ error = "narration_failed";
189
+ set("paused");
190
+ }
191
+ return;
192
+ }
193
+ speech = null;
194
+ const hold = lineHold(text, step.hold);
160
195
  last = { ...cur };
161
196
  deps.say(text, fmt(cur), hold);
162
197
  cur = { step: cur.step, line: cur.line + 1 };
163
198
  deps.changed();
164
- const gap = lastLine ? PACE.afterStep : PACE.betweenLines;
165
199
  wait(hold * 1000 + PACE.fade + gap, id, () => tick(id));
166
200
  }
167
201
 
@@ -173,6 +207,7 @@ export function createPlayer(deps: {
173
207
  load(next: Script) {
174
208
  const playing = state === "playing";
175
209
  cancel();
210
+ error = undefined;
176
211
  script = next;
177
212
  if (cur.step > next.steps.length) cur = { step: next.steps.length, line: 0 };
178
213
  normalize();
@@ -190,6 +225,7 @@ export function createPlayer(deps: {
190
225
  if (state === "playing") return null;
191
226
  if (state === "done") return "done";
192
227
  cancel();
228
+ error = undefined;
193
229
  entered = false; // bring the screen back to the current step first
194
230
  set("playing");
195
231
  tick(run);
@@ -220,6 +256,7 @@ export function createPlayer(deps: {
220
256
  if (pos.line > 0 && pos.line >= step.say.length) return "no_such_line";
221
257
  const playing = state === "playing";
222
258
  cancel();
259
+ error = undefined;
223
260
  cur = { ...pos };
224
261
  entered = false;
225
262
  if (playing) {
@@ -235,8 +272,8 @@ export function createPlayer(deps: {
235
272
 
236
273
  // Presentation ended: stop where it is.
237
274
  stop() {
275
+ cancel();
238
276
  if (state === "playing") {
239
- cancel();
240
277
  set("paused");
241
278
  }
242
279
  },
package/src/screencast.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { runJson } from "./run.ts";
2
+ import { AUDIO_BINDING, PAGE_AUDIO_JS, parsePcm } from "./page-audio.ts";
2
3
 
3
4
  // terminal-browser screen relay: CDP Page.startScreencast -> SSE (/screen).
4
5
  //
@@ -6,6 +7,10 @@ import { runJson } from "./run.ts";
6
7
  // follows its active tab, and streams JPEG frames only while at least one
7
8
  // viewer is connected. input() forwards whitelisted CDP Input.* commands
8
9
  // from the viewer to the page (user control).
10
+ //
11
+ // Page audio (GET /screen/audio, SSE): while at least one viewer listens, the
12
+ // tab's audio is rerouted into PCM chunks for those viewers and muted locally
13
+ // (see page-audio.ts). The last listener leaving restores local playback.
9
14
 
10
15
  type Browser = {
11
16
  key: string;
@@ -24,10 +29,12 @@ export type ScreenMeta = {
24
29
  url?: string;
25
30
  title?: string;
26
31
  agentControlled?: boolean;
32
+ audioListeners?: number; // status only
27
33
  };
28
34
 
29
35
  export function createScreencast(opts: { browserKey?: string; herdrTab?: () => Promise<string | undefined> }) {
30
36
  const viewers = new Set<ReadableStreamDefaultController>();
37
+ const listeners = new Set<ReadableStreamDefaultController>(); // page audio
31
38
  const enc = new TextEncoder();
32
39
 
33
40
  let meta: ScreenMeta = { connected: false };
@@ -39,6 +46,7 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
39
46
  let wsTarget = "";
40
47
  let msgId = 0;
41
48
  let timer: Timer | null = null;
49
+ let audioOn = false; // audio hook installed on the current CDP session
42
50
  const pending = new Map<number, (result: any) => void>();
43
51
 
44
52
  const emit = (c: ReadableStreamDefaultController, event: string, data: unknown) => {
@@ -46,9 +54,12 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
46
54
  c.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
47
55
  } catch {
48
56
  viewers.delete(c);
57
+ listeners.delete(c);
49
58
  }
50
59
  };
51
60
  const broadcast = (event: string, data: unknown) => viewers.forEach((c) => emit(c, event, data));
61
+ const AUDIO_OFF = "window.__agelloAudio?.off()";
62
+ const AUDIO_PING = "window.__agelloAudio?.ping()";
52
63
 
53
64
  function setMeta(next: ScreenMeta) {
54
65
  if (JSON.stringify(next) === JSON.stringify(meta)) return;
@@ -78,6 +89,10 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
78
89
  }
79
90
 
80
91
  function disconnect() {
92
+ // the old tab plays locally again (its own 4s timeout covers a lost message)
93
+ if (audioOn && ws?.readyState === WebSocket.OPEN)
94
+ ws.send(JSON.stringify({ id: ++msgId, method: "Runtime.evaluate", params: { expression: AUDIO_OFF } }));
95
+ audioOn = false;
81
96
  ws?.close();
82
97
  ws = null;
83
98
  wsTarget = "";
@@ -153,6 +168,7 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
153
168
  call("Page.enable");
154
169
  call("Page.startScreencast", { format: "jpeg", quality: 70, maxWidth: 1600, maxHeight: 1600 });
155
170
  if (clip) capture();
171
+ if (listeners.size) audioStart();
156
172
  };
157
173
  sock.onmessage = (ev) => {
158
174
  const msg = JSON.parse(String(ev.data));
@@ -162,6 +178,11 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
162
178
  resolve(msg.result ?? null);
163
179
  return;
164
180
  }
181
+ if (msg.method === "Runtime.bindingCalled" && msg.params?.name === AUDIO_BINDING) {
182
+ const chunk = listeners.size ? parsePcm(String(msg.params.payload ?? "")) : null;
183
+ if (chunk) listeners.forEach((c) => emit(c, "pcm", chunk));
184
+ return;
185
+ }
165
186
  if (msg.method !== "Page.screencastFrame") return;
166
187
  const { data, metadata, sessionId } = msg.params;
167
188
  call("Page.screencastFrameAck", { sessionId });
@@ -177,6 +198,25 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
177
198
  };
178
199
  }
179
200
 
201
+ // Install the page hook (new documents too) and switch the tab to remote.
202
+ function audioStart() {
203
+ const sock = ws;
204
+ if (audioOn || !sock || sock.readyState !== WebSocket.OPEN) return;
205
+ audioOn = true;
206
+ const call = (method: string, params: object = {}) =>
207
+ sock.send(JSON.stringify({ id: ++msgId, method, params }));
208
+ call("Runtime.enable");
209
+ call("Runtime.addBinding", { name: AUDIO_BINDING });
210
+ call("Page.addScriptToEvaluateOnNewDocument", { source: PAGE_AUDIO_JS });
211
+ call("Runtime.evaluate", { expression: PAGE_AUDIO_JS });
212
+ call("Runtime.evaluate", { expression: AUDIO_PING });
213
+ }
214
+ function audioPing() {
215
+ if (!listeners.size || !ws || ws.readyState !== WebSocket.OPEN) return;
216
+ if (!audioOn) return audioStart();
217
+ ws.send(JSON.stringify({ id: ++msgId, method: "Runtime.evaluate", params: { expression: AUDIO_PING } }));
218
+ }
219
+
180
220
  let syncing = false; // skip a tick while the previous sync is still running
181
221
  async function sync() {
182
222
  if (syncing) return;
@@ -200,6 +240,7 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
200
240
  }
201
241
  const url = `ws://127.0.0.1:${b.cdpPort}/devtools/page/${tab.targetId}`;
202
242
  if (url !== wsTarget || !ws) connect(b.cdpPort, tab.targetId);
243
+ else audioPing();
203
244
  setMeta({
204
245
  connected: true,
205
246
  browser: b.key,
@@ -248,6 +289,31 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
248
289
  });
249
290
  }
250
291
 
292
+ // SSE of page audio for one listener. Only meaningful alongside /screen
293
+ // (the relay runs while screen viewers are connected).
294
+ function handleAudio(): Response {
295
+ let ctrl!: ReadableStreamDefaultController;
296
+ let ping: Timer;
297
+ const stream = new ReadableStream({
298
+ start(c) {
299
+ ctrl = c;
300
+ listeners.add(c);
301
+ emit(c, "ready", {});
302
+ ping = setInterval(() => emit(c, "ping", {}), 15000);
303
+ audioPing();
304
+ },
305
+ cancel() {
306
+ clearInterval(ping);
307
+ listeners.delete(ctrl);
308
+ if (listeners.size === 0 && audioOn && ws?.readyState === WebSocket.OPEN)
309
+ ws.send(JSON.stringify({ id: ++msgId, method: "Runtime.evaluate", params: { expression: AUDIO_OFF } }));
310
+ },
311
+ });
312
+ return new Response(stream, {
313
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
314
+ });
315
+ }
316
+
251
317
  const INPUT_METHODS = new Set(["Input.dispatchMouseEvent", "Input.dispatchKeyEvent", "Input.insertText"]);
252
318
  function input(raw: string) {
253
319
  let m: any;
@@ -263,7 +329,7 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
263
329
 
264
330
  // Which browser the relay uses (or would use), for status reporting.
265
331
  async function describe(): Promise<ScreenMeta> {
266
- if (meta.connected) return meta;
332
+ if (meta.connected) return { ...meta, audioListeners: listeners.size };
267
333
  const { browser, reason } = await pickBrowser();
268
334
  return browser ? { connected: false, browser: browser.key } : { connected: false, reason };
269
335
  }
@@ -287,7 +353,7 @@ export function createScreencast(opts: { browserKey?: string; herdrTab?: () => P
287
353
  return r && !r.exceptionDetails ? (r.result?.value ?? null) : null;
288
354
  }
289
355
 
290
- return { handle, input, describe, setClip, evaluate, inspect };
356
+ return { handle, handleAudio, input, describe, setClip, evaluate, inspect };
291
357
  }
292
358
 
293
359
  export type InspectQuery = { x?: number; y?: number; selector?: string; full?: boolean };
package/src/server.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // (images are saved to $TMPDIR/agello-uploads and sent as [image: <path>])
9
9
  // (action "annotate": + target {selector, label, rect, text?, url?} from inspect)
10
10
  // GET /screen SSE: terminal-browser screen frames (CDP screencast)
11
+ // GET /screen/audio SSE: tab audio as PCM chunks; muted in the browser while listened
11
12
  // POST /upload {images: [{type, data(base64)}]} -> {paths}: terminal image paste
12
13
  // WS /terminal interactive herdr terminal frames, keyboard input, resize
13
14
  // GET /panes herdr workspace -> tab -> pane tree
@@ -31,6 +32,7 @@ import { join } from "node:path";
31
32
  import { runJson } from "./run.ts";
32
33
  import { createPlayer, parsePos, parseScript, type PlayerInfo } from "./player.ts";
33
34
  import { createScreencast, type Inspected, type Rect } from "./screencast.ts";
35
+ import { createTts } from "./tts.ts";
34
36
 
35
37
  export type ServerOptions = {
36
38
  port: number;
@@ -189,12 +191,21 @@ export async function startServer(opts: ServerOptions) {
189
191
  // ----- SSE clients -----
190
192
 
191
193
  const clients = new Set<ReadableStreamDefaultController>();
194
+ const viewers = new Map<ReadableStreamDefaultController, string | null>();
195
+ const clientPings = new Map<ReadableStreamDefaultController, Timer>();
196
+ const dropClient = (ctrl: ReadableStreamDefaultController) => {
197
+ if (!clients.delete(ctrl)) return;
198
+ clearInterval(clientPings.get(ctrl));
199
+ clientPings.delete(ctrl);
200
+ tts.disconnect(viewers.get(ctrl) ?? null);
201
+ viewers.delete(ctrl);
202
+ };
192
203
  const enc = new TextEncoder();
193
204
  const send = (ctrl: ReadableStreamDefaultController, event: string, data: unknown) => {
194
205
  try {
195
206
  ctrl.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
196
207
  } catch {
197
- clients.delete(ctrl);
208
+ dropClient(ctrl);
198
209
  }
199
210
  };
200
211
  const broadcast = (event: string, data: unknown) => clients.forEach((c) => send(c, event, data));
@@ -382,13 +393,16 @@ export async function startServer(opts: ServerOptions) {
382
393
 
383
394
  // Script player: agello owns order and timing, the agent owns the deck
384
395
  // (each step's `go`) and decides when to resume after a question.
396
+ const tts = createTts({ emit: broadcast, pause: () => player.pause() });
385
397
  const player = createPlayer({
386
398
  async go(expr) {
387
399
  const js = expr.startsWith("#") ? `location.hash = ${JSON.stringify(expr)}` : expr;
388
400
  if (!(await screen.evaluate(js))) console.error(`present: go failed: ${expr}`);
389
401
  },
390
- say(text, at, hold) {
391
- broadcast("message", { role: "assistant", text, ts: new Date().toISOString(), script: at, hold });
402
+ narrate: tts.narrate,
403
+ say(text, at, hold, narration) {
404
+ broadcast("message", { role: "assistant", text, ts: new Date().toISOString(), script: at, hold,
405
+ ...(narration ? { narration } : {}) });
392
406
  },
393
407
  done() {
394
408
  const n = player.info().steps;
@@ -482,13 +496,15 @@ export async function startServer(opts: ServerOptions) {
482
496
  return res;
483
497
  }
484
498
 
485
- function events(): Response {
499
+ function events(viewer: string | null): Response {
486
500
  let ctrl!: ReadableStreamDefaultController;
487
501
  let ping: Timer;
488
502
  const stream = new ReadableStream({
489
503
  start(c) {
490
504
  ctrl = c;
491
505
  clients.add(c);
506
+ viewers.set(c, viewer);
507
+ tts.connect(viewer);
492
508
  send(c, "hello", {
493
509
  transcript: !!transcript,
494
510
  pane: PANE,
@@ -498,12 +514,16 @@ export async function startServer(opts: ServerOptions) {
498
514
  });
499
515
  if (lastStatus) send(c, "status", lastStatus);
500
516
  send(c, "present", presentView());
517
+ send(c, "tts", tts.status());
518
+ const narration = tts.currentNarration();
519
+ if (narration) send(c, "message", { role: "assistant", ts: new Date().toISOString(), ...narration });
501
520
  for (const t of pendingTools.values()) send(c, "tool_start", t);
502
521
  ping = setInterval(() => send(c, "ping", {}), 15000);
522
+ clientPings.set(c, ping);
503
523
  },
504
524
  cancel() {
505
525
  clearInterval(ping);
506
- clients.delete(ctrl);
526
+ dropClient(ctrl);
507
527
  },
508
528
  });
509
529
  return new Response(stream, {
@@ -546,6 +566,8 @@ export async function startServer(opts: ServerOptions) {
546
566
  }
547
567
 
548
568
  async function route(req: Request, url: URL): Promise<Response> {
569
+ const ttsResponse = await tts.handle(req, url.pathname);
570
+ if (ttsResponse) return ttsResponse;
549
571
  if (url.pathname.startsWith("/panes")) {
550
572
  const res = await panesRoute(req, url.pathname, PANE);
551
573
  if (res) return res;
@@ -559,9 +581,11 @@ export async function startServer(opts: ServerOptions) {
559
581
  present: presentView(),
560
582
  });
561
583
  case "/events":
562
- return events();
584
+ return events(url.searchParams.get("viewer"));
563
585
  case "/screen":
564
586
  return screen.handle();
587
+ case "/screen/audio":
588
+ return screen.handleAudio();
565
589
  case "/terminal.js":
566
590
  return new Response(terminalJS, { headers: { "Content-Type": "text/javascript; charset=utf-8" } });
567
591
  case "/terminal.css":
@@ -570,6 +594,10 @@ export async function startServer(opts: ServerOptions) {
570
594
  return new Response(COMPONENT, {
571
595
  headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-store" },
572
596
  });
597
+ case "/presentation-media.js":
598
+ return new Response(Bun.file(join(WEB_DIR, "presentation-media.js")), {
599
+ headers: { "Content-Type": "text/javascript; charset=utf-8", "Cache-Control": "no-store" },
600
+ });
573
601
  case "/send":
574
602
  if (req.method === "POST") return sendPrompt(req);
575
603
  break;
@@ -623,7 +651,7 @@ export async function startServer(opts: ServerOptions) {
623
651
  async fetch(req, server) {
624
652
  const url = new URL(req.url);
625
653
  const origin = req.headers.get("Origin");
626
- const isScript = ["/agent-bridge.js", "/terminal.js", "/terminal.css"].includes(url.pathname);
654
+ const isScript = ["/agent-bridge.js", "/presentation-media.js", "/terminal.js", "/terminal.css"].includes(url.pathname);
627
655
 
628
656
  // The component script itself is public; data and input endpoints are origin-checked.
629
657
  if (!isScript && !originAllowed(origin, url.origin))
@@ -657,6 +685,15 @@ export async function startServer(opts: ServerOptions) {
657
685
  session: SESSION,
658
686
  transcript,
659
687
  stop() {
688
+ player.stop();
689
+ tts.stop();
690
+ for (const client of clients) {
691
+ clearInterval(clientPings.get(client));
692
+ try { client.close(); } catch {}
693
+ }
694
+ clients.clear();
695
+ viewers.clear();
696
+ clientPings.clear();
660
697
  terminal.stop();
661
698
  timers.forEach(clearInterval);
662
699
  server.stop(true);