hop2 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.github/workflows/ci.yml +25 -0
  2. package/.github/workflows/publish.yml +130 -0
  3. package/DESIGN-input-acks.md +349 -0
  4. package/MULTI-AGENT-CHECKLIST.md +5 -5
  5. package/README-MCP.md +84 -5
  6. package/README.md +55 -4
  7. package/demo/README.md +52 -0
  8. package/demo/capture/capture-env.mjs +108 -0
  9. package/demo/capture/capture.mjs +634 -0
  10. package/demo/capture/dispatch-task.mjs +44 -0
  11. package/demo/capture/mcp-client.mjs +61 -0
  12. package/demo/capture/native-css.mjs +49 -0
  13. package/demo/capture/setup-04.mjs +67 -0
  14. package/demo/capture/setup-sessions.mjs +40 -0
  15. package/demo/capture/spawn-aurora.mjs +53 -0
  16. package/demo/capture/tools/agent.sh +30 -0
  17. package/demo/capture/tools/build.sh +18 -0
  18. package/demo/capture/tools/metrics.sh +17 -0
  19. package/docs/hero-overview.svg +119 -0
  20. package/docs/hero.svg +118 -0
  21. package/docs/restore-reliability.md +139 -0
  22. package/docs/session-identity.md +76 -0
  23. package/docs/switcher-first-ux.md +103 -0
  24. package/hay/CHANGELOG.md +59 -1
  25. package/hay/apps/cli/package.json +3 -2
  26. package/hay/apps/cli/src/index.ts +705 -28
  27. package/hay/apps/server/src/pty.ts +36 -2
  28. package/hay/apps/server/src/rooms.ts +525 -34
  29. package/hay/apps/server/test/rooms.test.ts +411 -1
  30. package/hay/apps/web/index.html +2 -0
  31. package/hay/apps/web/package.json +14 -12
  32. package/hay/apps/web/scripts/run-e2e.mjs +42 -5
  33. package/hay/apps/web/src/App.tsx +2492 -281
  34. package/hay/apps/web/src/components/ContextMenu.tsx +109 -0
  35. package/hay/apps/web/src/components/MobileKeyboard.tsx +135 -14
  36. package/hay/apps/web/src/components/SecondaryPane.tsx +218 -0
  37. package/hay/apps/web/src/components/SessionSwitcher.tsx +2525 -0
  38. package/hay/apps/web/src/setupTests.ts +23 -0
  39. package/hay/apps/web/src/styles.css +2460 -180
  40. package/hay/apps/web/src/utils/fileDrop.ts +15 -0
  41. package/hay/apps/web/src/utils/keyboardProtocol.ts +22 -0
  42. package/hay/apps/web/src/utils/optimisticEcho.ts +83 -7
  43. package/hay/apps/web/src/utils/scrollFlywheel.ts +145 -0
  44. package/hay/apps/web/src/utils/switcherModel.ts +314 -0
  45. package/hay/apps/web/src/utils/terminalSearch.ts +54 -0
  46. package/hay/apps/web/src/vite-env.d.ts +3 -0
  47. package/hay/apps/web/test/SessionSwitcher.test.tsx +324 -0
  48. package/hay/apps/web/test/fileDrop.test.ts +22 -0
  49. package/hay/apps/web/test/optimisticEcho.test.ts +17 -0
  50. package/hay/apps/web/test/scrollFlywheel.test.ts +113 -0
  51. package/hay/apps/web/test/switcherModel.test.ts +377 -0
  52. package/hay/apps/web/test/terminalSearch.test.ts +37 -0
  53. package/hay/apps/web/vite.config.ts +20 -0
  54. package/hay/package-lock.json +1696 -1171
  55. package/hay/packages/shared/src/protocol.ts +25 -2
  56. package/hay-web/assets/index-BohAWpcL.css +1 -0
  57. package/hay-web/assets/index-DfGT3DuO.js +130 -0
  58. package/hay-web/assets/index-DfGT3DuO.js.map +1 -0
  59. package/hay-web/index.html +4 -2
  60. package/hop +4348 -302
  61. package/hopa +465 -0
  62. package/mcp/hop-mcp.js +2878 -122
  63. package/package.json +7 -4
  64. package/scripts/claude-session-hook.js +152 -0
  65. package/scripts/hay-host.js +227 -4
  66. package/scripts/hop-math.js +316 -0
  67. package/scripts/salvage-and-restart-host.js +217 -0
  68. package/skills/hop-manager/SKILL.md +62 -0
  69. package/skills/hop-session/SKILL.md +42 -0
  70. package/skills/hopa/SKILL.md +75 -0
  71. package/tests/agent-skill.unit.test.cjs +60 -0
  72. package/tests/composer-state.unit.test.cjs +102 -0
  73. package/tests/hop-mcp.integration.test.cjs +476 -94
  74. package/tests/hopa-parity.unit.test.cjs +188 -0
  75. package/tests/readable-echo.unit.test.cjs +94 -0
  76. package/tests/readable-flush.unit.test.cjs +93 -0
  77. package/tests/submit-verification.unit.test.cjs +331 -0
  78. package/tests/terminal-api.integration.test.cjs +301 -13
  79. package/hay-web/assets/index-BzJB9r_I.js +0 -64
  80. package/hay-web/assets/index-BzJB9r_I.js.map +0 -1
  81. package/hay-web/assets/index-DG8Ntznc.css +0 -32
  82. package/sessions.html +0 -726
@@ -0,0 +1,634 @@
1
+ // Sanitized live-footage capture driver for the hop product video (dark theme).
2
+ // Usage: node demo/capture/capture.mjs <clip> where clip in:
3
+ // 01-sessions | 02-agent-live | 02b-desktop-terminal | 03-phone-live |
4
+ // 04-phone-switcher | 05-presence | 06-math | 07-theme
5
+ //
6
+ // Run demo/capture/setup-sessions.mjs and demo/capture/spawn-aurora.mjs first
7
+ // (setup-04.mjs for the 04 clip). Paths and identity strings come from
8
+ // capture-env.mjs and its HOP_CAPTURE_* env overrides.
9
+ //
10
+ // Sanitization layers (both installed BEFORE first paint):
11
+ // 1. Network: /api/sessions GET responses are filtered to the demo session
12
+ // set only (sessions, active, starting, aliases, order, folders).
13
+ // 2. DOM: a document-start MutationObserver rewrites the internal demo names
14
+ // (Aurora2/Nebula2/Polaris2) to their display names (Aurora/Nebula/Polaris)
15
+ // in every text node and title/aria-label/placeholder attribute.
16
+ //
17
+ // Recording discipline:
18
+ // - DARK THEME everywhere: localStorage hay_theme=dark injected pre-load in
19
+ // every context + colorScheme:'dark' (sessions.html follows
20
+ // prefers-color-scheme). assertDark() verifies a real frame via ffmpeg
21
+ // signalstats before the kept portion of each recording.
22
+ // - Mobile on-screen keyboard is hidden (switcher header "Toggle keyboard")
23
+ // before the kept portion of 03/04, and asserted hidden.
24
+ // - Head-trimming: recording starts at page open, so each clip records a
25
+ // marker timestamp and the head (page load, keyboard hiding, connect
26
+ // handshakes) is trimmed off with ffmpeg on save. No idle/light/keyboard/
27
+ // "Connecting" frames survive into the delivered clips.
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import { spawnSync } from "node:child_process";
31
+ import { getHopState, sleep } from "../hop-demo-lib.mjs";
32
+ import { chromium, devices } from "../../hay/node_modules/playwright/index.mjs";
33
+ import { NATIVE_CSS_INIT } from "./native-css.mjs";
34
+ import {
35
+ CHROME, FFMPEG, OUT, VID_TMP, ALLOWED, SCREEN_FORBIDDEN, TERMINALS_PATH, loadTerminals
36
+ } from "./capture-env.mjs";
37
+
38
+ const state = getHopState();
39
+ const terminals = loadTerminals();
40
+ fs.mkdirSync(VID_TMP, { recursive: true });
41
+
42
+ function requireTerminalId(name) {
43
+ const t = terminals[name];
44
+ if (!t) {
45
+ throw new Error(
46
+ `session "${name}" not in ${TERMINALS_PATH} — run setup-sessions.mjs / spawn-aurora.mjs first`
47
+ );
48
+ }
49
+ return t.terminalId;
50
+ }
51
+
52
+ const REWRITER = `(() => {
53
+ const MAP = [["Aurora2","Aurora"],["Lyra2","Lyra"],["Nebula2","Nebula"],["Polaris2","Polaris"]];
54
+ const fix = (s) => { let o = s; for (const [a,b] of MAP) o = o.split(a).join(b); return o; };
55
+ const ATTRS = ["title","aria-label","placeholder"];
56
+ const SKIP = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEMPLATE: 1 };
57
+ const fixText = (n) => {
58
+ const p = n.parentNode;
59
+ if (p && SKIP[p.nodeName]) return; // never touch executable/style content
60
+ const v = fix(n.nodeValue); if (v !== n.nodeValue) n.nodeValue = v;
61
+ };
62
+ const fixEl = (el) => {
63
+ if (!el.getAttribute) return;
64
+ for (const a of ATTRS) { const v = el.getAttribute(a); if (v) { const f = fix(v); if (f !== v) el.setAttribute(a, f); } }
65
+ if (el.tagName === "INPUT" && el.value) { const f = fix(el.value); if (f !== el.value) el.value = f; }
66
+ };
67
+ const walk = (root) => {
68
+ if (!root) return;
69
+ if (root.nodeType === 3) return fixText(root);
70
+ if (root.nodeType !== 1 && root.nodeType !== 9 && root.nodeType !== 11) return;
71
+ if (root.nodeType === 1) fixEl(root);
72
+ const tw = document.createTreeWalker(root, 5 /* ELEMENT|TEXT */);
73
+ let n; while ((n = tw.nextNode())) { n.nodeType === 3 ? fixText(n) : fixEl(n); }
74
+ };
75
+ const mo = new MutationObserver((muts) => {
76
+ for (const m of muts) {
77
+ if (m.type === "characterData") fixText(m.target);
78
+ else if (m.type === "attributes") fixEl(m.target);
79
+ else m.addedNodes.forEach(walk);
80
+ }
81
+ });
82
+ mo.observe(document, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: ATTRS });
83
+ if (document.documentElement) walk(document.documentElement);
84
+ // Hide daemon-side API clients from the presence strip: every terminal
85
+ // created/attached over /api/terminals keeps a persistent room client
86
+ // (labelled "user"/"agent") that is rig plumbing, not demo cast.
87
+ const GHOSTS = { user: 1, agent: 1 };
88
+ setInterval(() => {
89
+ document.querySelectorAll(".presence-chip:not(.self)").forEach((chip) => {
90
+ const nm = chip.querySelector(".presence-name");
91
+ if (nm && GHOSTS[nm.textContent.trim().toLowerCase()]) chip.style.display = "none";
92
+ });
93
+ }, 300);
94
+ })();`;
95
+
96
+ // Force dark theme before any app script runs. The terminal app reads
97
+ // localStorage hay_theme; sessions.html follows prefers-color-scheme (handled
98
+ // by context colorScheme below).
99
+ const DARK_INIT = `(() => { try {
100
+ localStorage.setItem("hay_theme", "dark");
101
+ localStorage.setItem("hay-syskb-seen", "1"); // suppress first-run KB hint overlay (it eats taps)
102
+ } catch (e) {} })();`;
103
+
104
+ async function sanitizeContext(context) {
105
+ await context.addInitScript(DARK_INIT);
106
+ await context.addInitScript(REWRITER);
107
+ await context.route(
108
+ (url) => url.pathname === "/api/sessions",
109
+ async (route) => {
110
+ try {
111
+ if (route.request().method() !== "GET") return await route.continue();
112
+ const resp = await route.fetch();
113
+ let json;
114
+ try { json = await resp.json(); } catch { return await route.fulfill({ response: resp }); }
115
+ json.sessions = (json.sessions || []).filter((s) => ALLOWED.includes(s.name));
116
+ json.active = (json.active || []).filter((n) => ALLOWED.includes(n));
117
+ json.starting = (json.starting || []).filter((n) => ALLOWED.includes(n));
118
+ json.aliases = {};
119
+ json.folders = [];
120
+ if (json.order) json.order = { folders: [], sessions: { root: [] } };
121
+ return await route.fulfill({ response: resp, json });
122
+ } catch {
123
+ try { await route.abort(); } catch {}
124
+ }
125
+ }
126
+ );
127
+ }
128
+
129
+ function cookie() {
130
+ return {
131
+ name: "tunnel_session", value: state.sessionSecret, domain: "127.0.0.1",
132
+ path: "/", httpOnly: true, secure: false, sameSite: "Lax"
133
+ };
134
+ }
135
+
136
+ // Returns { ctx, page, t0 } — t0 approximates the recording start (first
137
+ // video frame is captured when the page opens).
138
+ async function newRecordedPage(browser, { mobile = false, record = true, viewport = null, css = null } = {}) {
139
+ const desktopVp = viewport || { width: 1920, height: 1080 };
140
+ const opts = mobile
141
+ ? { ...devices["iPhone 14"], colorScheme: "dark" }
142
+ : { viewport: desktopVp, colorScheme: "dark" };
143
+ if (record) {
144
+ // Mobile size MUST match the device viewport (390x664) or the video gets
145
+ // letterboxed with a gray band.
146
+ opts.recordVideo = { dir: VID_TMP, size: mobile ? { width: 390, height: 664 } : desktopVp };
147
+ }
148
+ const ctx = await browser.newContext(opts);
149
+ await ctx.addCookies([cookie()]);
150
+ await sanitizeContext(ctx);
151
+ if (css) await ctx.addInitScript(css); // e.g. NATIVE_CSS_INIT, installed pre-paint
152
+ const page = await ctx.newPage();
153
+ const t0 = Date.now();
154
+ return { ctx, page, t0 };
155
+ }
156
+
157
+ // Verify an actual rendered frame is dark (mean luma) before recording the
158
+ // kept portion of a clip. Light theme frames average YAVG ~180-230; dark ~15-60.
159
+ async function assertDark(page, label) {
160
+ const shot = path.join(VID_TMP, "dark-check.png");
161
+ await page.screenshot({ path: shot });
162
+ const r = spawnSync(FFMPEG, [
163
+ "-i", shot, "-vf", "signalstats,metadata=print:file=-", "-f", "null", "-"
164
+ ], { encoding: "utf8" });
165
+ const m = (r.stdout + "\n" + r.stderr).match(/signalstats\.YAVG=([\d.]+)/);
166
+ if (!m) throw new Error(`assertDark(${label}): could not measure YAVG`);
167
+ const yavg = Number(m[1]);
168
+ console.log(`assertDark(${label}): YAVG=${yavg.toFixed(1)}`);
169
+ if (yavg > 100) throw new Error(`ABORT ${label}: frame is NOT dark (YAVG=${yavg.toFixed(1)})`);
170
+ }
171
+
172
+ // Save the context's recording, trimming trimSec off the head (page load,
173
+ // keyboard hiding, connect handshakes) via a VP9 re-encode.
174
+ async function saveVideo(page, clipName, trimSec = 0) {
175
+ const video = page.video();
176
+ try { await page.context().unrouteAll({ behavior: "ignoreErrors" }); } catch {}
177
+ await page.context().close();
178
+ const src = await video.path();
179
+ const dest = path.join(OUT, `${clipName}.webm`);
180
+ fs.mkdirSync(OUT, { recursive: true });
181
+ if (trimSec > 0.05) {
182
+ const r = spawnSync(FFMPEG, [
183
+ "-y", "-ss", trimSec.toFixed(2), "-i", src,
184
+ "-c:v", "libvpx-vp9", "-crf", "20", "-b:v", "0",
185
+ "-cpu-used", "4", "-row-mt", "1", "-an", dest
186
+ ], { encoding: "utf8" });
187
+ if (r.status !== 0) throw new Error(`ffmpeg trim failed for ${clipName}: ${(r.stderr || "").slice(-500)}`);
188
+ } else {
189
+ fs.copyFileSync(src, dest);
190
+ }
191
+ fs.rmSync(src, { force: true });
192
+ console.log(`saved ${dest} (head trimmed ${trimSec.toFixed(2)}s)`);
193
+ }
194
+
195
+ const trimFor = (t0, markerMs, padSec = 0.4) => Math.max(0, (markerMs - t0) / 1000 + padSec);
196
+
197
+ async function gotoSession(page, name, extraQuery = "") {
198
+ await page.goto(`${state.localUrl}/s/${encodeURIComponent(name)}${extraQuery}`, {
199
+ waitUntil: "networkidle", timeout: 30000
200
+ });
201
+ // Hard safety check: the page must be attached to the intended demo session.
202
+ const room = await page.evaluate(() => window.__HOP_SESSION__ && window.__HOP_SESSION__.room);
203
+ if (room !== name) {
204
+ throw new Error(`SAFETY ABORT: page attached to room "${room}", expected "${name}"`);
205
+ }
206
+ }
207
+
208
+ // Raw terminal write over the hop API (used to type into Lyra for the math clip).
209
+ async function apiWrite(terminalId, data) {
210
+ await fetch(`${state.localUrl}/api/terminals/${encodeURIComponent(terminalId)}/write`, {
211
+ method: "POST",
212
+ headers: { Authorization: `Bearer ${state.sessionSecret}`, "Content-Type": "application/json" },
213
+ body: JSON.stringify({ data })
214
+ });
215
+ }
216
+ async function typeSlow(terminalId, text, delayMs = 55) {
217
+ for (const ch of text) { await apiWrite(terminalId, ch); await sleep(delayMs); }
218
+ }
219
+
220
+ async function apiResize(terminalId, cols, rows) {
221
+ await fetch(`${state.localUrl}/api/terminals/${encodeURIComponent(terminalId)}/resize`, {
222
+ method: "POST",
223
+ headers: { Authorization: `Bearer ${state.sessionSecret}`, "Content-Type": "application/json" },
224
+ body: JSON.stringify({ cols, rows })
225
+ });
226
+ }
227
+
228
+ async function previewText(name) {
229
+ const r = await fetch(`${state.localUrl}/api/sessions/preview?name=${encodeURIComponent(name)}`, {
230
+ headers: { Authorization: `Bearer ${state.sessionSecret}` }
231
+ });
232
+ try { return (await r.json()).text || ""; } catch { return ""; }
233
+ }
234
+
235
+ // Whitespace-insensitive matching: terminal wrapping inserts arbitrary line
236
+ // breaks (mid-word at narrow widths), so collapse all whitespace first.
237
+ const normalize = (s) => s.replace(/\s+/g, "");
238
+
239
+ async function sendTask(taskText) {
240
+ const aurora = requireTerminalId("Aurora2");
241
+ await apiWrite(aurora, taskText);
242
+ await sleep(500);
243
+ await apiWrite(aurora, "\r");
244
+ }
245
+
246
+ // Wait until visible screen content has grown by deltaChars over the given
247
+ // baseline — i.e. the agent is actually producing output.
248
+ async function waitOutputGrowth(baselineLen, deltaChars = 250, timeoutMs = 60000) {
249
+ const t = Date.now();
250
+ while (Date.now() - t < timeoutMs) {
251
+ const len = normalize(await previewText("Aurora2")).length;
252
+ if (len > baselineLen + deltaChars) return true;
253
+ await sleep(1000);
254
+ }
255
+ console.error("WARN: output growth gate timed out");
256
+ return false;
257
+ }
258
+
259
+ // Natural-sounding tasks: the prompt echo is briefly on screen in 02, so the
260
+ // text must read like a real dev request, not staging directions. Each task
261
+ // asks for a long narration so the turn comfortably outlasts the streaming
262
+ // gates plus the kept dwell.
263
+ const TASK_02 =
264
+ "Take a look around this workspace and give me the full tour: read README.md, index.html, and everything under scripts/ " +
265
+ "and server/, then lay out what this project is and a concrete 6-step roadmap to make it production-ready. Narrate as you " +
266
+ "go, one short paragraph per file, and keep the commentary flowing while you read — at least 1200 words before you wrap up.";
267
+
268
+ // 02b is dispatched as a FOLLOW-UP turn: the fresh-session welcome banner
269
+ // contains the real account email (canvas-rendered — the DOM rewriter cannot
270
+ // touch it), so the identity gates below require it to have scrolled off
271
+ // before anything is kept. Run a first narration turn (dispatch-task.mjs)
272
+ // if the agent session is fresh.
273
+ const TASK_02B =
274
+ "Love it. Now the director's cut: narrate the full story of a day of real work inside hop — morning attach, " +
275
+ "agents running in their rooms, a teammate dropping in from a phone, a restore after a laptop reboot — all in " +
276
+ "the same flowing prose, at least 1100 words, no lists, don't stop to summarize, just keep the narration rolling.";
277
+
278
+ const TASK_03 =
279
+ "Give the phone-reader's tour: re-read README.md, index.html, and each file under scripts/ and server/ one at a time, and " +
280
+ "between reads narrate in short flowing paragraphs what each piece does and how they fit together — then close with the " +
281
+ "three fixes you would ship first. At least 1300 words of narration, keep it flowing the whole way.";
282
+
283
+ // Two consecutive polls must each show the screen TAIL changing: a full
284
+ // screen scrolls at constant length, so compare content, not length. Detects
285
+ // steady streaming (not an API-retry stall, not a finished static screen).
286
+ async function waitSteadyStream(timeoutMs = 120000) {
287
+ const t = Date.now();
288
+ let prevTail = normalize(await previewText("Aurora2")).slice(-400);
289
+ let hits = 0;
290
+ while (Date.now() - t < timeoutMs) {
291
+ await sleep(1500);
292
+ const txt = await previewText("Aurora2");
293
+ const tail = normalize(txt).slice(-400);
294
+ if (txt.includes("Waiting for API response")) { hits = 0; }
295
+ else if (tail !== prevTail) { hits += 1; if (hits >= 2) return true; }
296
+ else hits = 0;
297
+ prevTail = tail;
298
+ }
299
+ throw new Error("ABORT 03: agent never streamed steadily (stall or early finish)");
300
+ }
301
+
302
+ // CDP touch swipe for genuine mobile touch scrolling.
303
+ async function swipe(page, x, y0, y1, steps = 14, stepMs = 16) {
304
+ const cdp = await page.context().newCDPSession(page);
305
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y: y0 }] });
306
+ for (let i = 1; i <= steps; i++) {
307
+ const y = y0 + ((y1 - y0) * i) / steps;
308
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: [{ x, y }] });
309
+ await sleep(stepMs);
310
+ }
311
+ await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
312
+ await cdp.detach();
313
+ }
314
+
315
+ // Swallow the synthetic click that follows a FAB touch tap so it cannot hit
316
+ // whatever renders beneath the FAB once the switcher overlay opens.
317
+ async function swallowNextClick(page) {
318
+ await page.evaluate(() => {
319
+ const swallow = (e) => {
320
+ e.stopPropagation();
321
+ e.preventDefault();
322
+ document.removeEventListener("click", swallow, true);
323
+ };
324
+ document.addEventListener("click", swallow, true);
325
+ });
326
+ }
327
+
328
+ async function tapFab(page) {
329
+ const fab = await page.locator(".drawer-toggle").boundingBox();
330
+ await swallowNextClick(page);
331
+ await page.touchscreen.tap(fab.x + fab.width / 2, fab.y + fab.height / 2);
332
+ }
333
+
334
+ // Hide the mobile on-screen keyboard: FAB -> full-screen switcher -> header
335
+ // "Toggle keyboard" action (which hides the keyboard and closes the switcher).
336
+ // Asserts the keyboard is really gone afterwards.
337
+ async function hideKeyboard(page) {
338
+ await tapFab(page);
339
+ const toggle = page.locator('button[aria-label="Toggle keyboard"]');
340
+ await toggle.waitFor({ state: "visible", timeout: 8000 });
341
+ await sleep(700);
342
+ await toggle.tap(); // plain tap: the button acts on its click event
343
+ await sleep(1400);
344
+ const hasKb = await page.evaluate(() =>
345
+ !!document.querySelector(".session.has-keyboard") ||
346
+ !!document.querySelector(".switcher-overlay"));
347
+ if (hasKb) throw new Error("ABORT: keyboard (or switcher) still visible after toggle");
348
+ console.log("keyboard hidden");
349
+ }
350
+
351
+ const clip = process.argv[2];
352
+ const browser = await chromium.launch({ headless: true, executablePath: CHROME });
353
+
354
+ try {
355
+ if (clip === "02-agent-live") {
356
+ // Recording rolls from page-open; the task is dispatched AFTER the desktop
357
+ // client attaches, and the kept portion starts ~3s after fresh output is
358
+ // visibly streaming (echo scrolled off). Head is trimmed away.
359
+ const { page, t0 } = await newRecordedPage(browser, {});
360
+ await gotoSession(page, "Aurora2");
361
+ await sleep(1500);
362
+ const baseline = normalize(await previewText("Aurora2")).length;
363
+ await sendTask(TASK_02);
364
+ await waitOutputGrowth(baseline, 250); // agent visibly producing output
365
+ await sleep(3000); // ~3s of live streaming before the kept portion starts
366
+ await assertDark(page, "02-agent-live");
367
+ const marker = Date.now();
368
+ await sleep(18000); // kept content; short enough to precede any usage banner
369
+ await saveVideo(page, "02-agent-live", trimFor(t0, marker));
370
+
371
+ } else if (clip === "02b-desktop-terminal") {
372
+ // "Native local terminal" clip: 1600x1000 (16:10), ALL web chrome hidden
373
+ // via document-start CSS (topbar/controls/footer/FAB), terminal frame
374
+ // full-bleed. PTY pre-sized to the client's fit (185x46), and re-claimed
375
+ // via REST right after the task write (API client is then the latest
376
+ // typer, so the resize wins the election). Kept portion starts once fresh
377
+ // output is visibly streaming; ~12s dwell, stops before any usage banner.
378
+ const { page, t0 } = await newRecordedPage(browser, {
379
+ viewport: { width: 1600, height: 1000 }, css: NATIVE_CSS_INIT
380
+ });
381
+ await gotoSession(page, "Aurora2");
382
+ await sleep(1800);
383
+ // Hard gate: no web chrome may be visible in the frame.
384
+ const chromeVisible = await page.evaluate(() =>
385
+ [".topbar", ".controls", ".terminal-footer", ".drawer-toggle", ".connection-banner"]
386
+ .filter((sel) => {
387
+ const el = document.querySelector(sel);
388
+ return el && getComputedStyle(el).display !== "none";
389
+ }));
390
+ if (chromeVisible.length) throw new Error(`ABORT 02b: chrome visible: ${chromeVisible.join(", ")}`);
391
+ const frameBox = await page.evaluate(() => {
392
+ const r = document.querySelector(".terminal-frame").getBoundingClientRect();
393
+ return { x: r.x, y: r.y, w: r.width, h: r.height };
394
+ });
395
+ if (frameBox.x !== 0 || frameBox.y !== 0 || frameBox.w !== 1600 || frameBox.h !== 1000) {
396
+ throw new Error(`ABORT 02b: terminal not full-bleed: ${JSON.stringify(frameBox)}`);
397
+ }
398
+ // Pre-dispatch identity gate: the welcome banner (real email) must already
399
+ // be scrolled off the visible screen before we even send the task.
400
+ const preScreen = await previewText("Aurora2");
401
+ for (const bad of SCREEN_FORBIDDEN) {
402
+ if (preScreen.includes(bad)) throw new Error(`ABORT 02b: "${bad}" visible on screen pre-dispatch`);
403
+ }
404
+ await sendTask(TASK_02B);
405
+ await apiResize(requireTerminalId("Aurora2"), 185, 46); // re-claim size election post-write
406
+ // Streaming gate: the agent TUI footer shows "esc to interrupt" for the
407
+ // whole active turn, and the screen tail must be visibly moving. (Length-
408
+ // based growth gates are useless on a full screen — it scrolls at constant
409
+ // length.) Marker fires as soon as prose is flowing so the 12.5s dwell
410
+ // finishes well before the turn does.
411
+ {
412
+ const tGate = Date.now();
413
+ let prevTail = normalize(await previewText("Aurora2")).slice(-600);
414
+ let hits = 0, streaming = false;
415
+ while (Date.now() - tGate < 90000) {
416
+ await sleep(900);
417
+ const txt = await previewText("Aurora2");
418
+ const tail = normalize(txt).slice(-600);
419
+ const active = txt.includes("esc to interrupt");
420
+ if (active && tail !== prevTail) { hits += 1; } else { hits = 0; }
421
+ prevTail = tail;
422
+ if (hits >= 2) { streaming = true; break; }
423
+ }
424
+ if (!streaming) throw new Error("ABORT 02b: turn never streamed (stall or instant finish)");
425
+ }
426
+ // Final identity gate right before the kept portion.
427
+ const liveScreen = await previewText("Aurora2");
428
+ for (const bad of SCREEN_FORBIDDEN) {
429
+ if (liveScreen.includes(bad)) throw new Error(`ABORT 02b: "${bad}" visible on screen pre-marker`);
430
+ }
431
+ await assertDark(page, "02b-desktop-terminal");
432
+ const marker = Date.now();
433
+ await sleep(12500); // kept content (~12s)
434
+ // Still-streaming + identity check BEFORE saving: if the turn ended or a
435
+ // forbidden string surfaced during the dwell, the take is bad — abort.
436
+ const postScreen = await previewText("Aurora2");
437
+ for (const bad of SCREEN_FORBIDDEN) {
438
+ if (postScreen.includes(bad)) throw new Error(`RESHOOT 02b: "${bad}" appeared during kept portion`);
439
+ }
440
+ if (!postScreen.includes("esc to interrupt")) {
441
+ throw new Error("RESHOOT 02b: turn ended during the kept portion (static tail frames)");
442
+ }
443
+ await saveVideo(page, "02b-desktop-terminal", trimFor(t0, marker));
444
+
445
+ } else if (clip === "03-phone-live") {
446
+ // The phone client's own fit never claims the PTY size in headless
447
+ // capture, so force phone dimensions through the terminal API instead:
448
+ // a REST resize issued right after an API write always wins the size
449
+ // election (the API client is then the most recent typer). With the pty
450
+ // at 46x40 the echo AND all narration render at clean phone width.
451
+ const { page, t0 } = await newRecordedPage(browser, { mobile: true });
452
+ await gotoSession(page, "Aurora2");
453
+ await sleep(2000);
454
+ await hideKeyboard(page); // must be hidden before the kept portion
455
+ await sleep(1500);
456
+ await sendTask(TASK_03);
457
+ await apiResize(requireTerminalId("Aurora2"), 46, 40);
458
+ // Gate only on steady live streaming and start the kept portion early:
459
+ // waiting for the echo to scroll off a 40-row screen can eat the whole
460
+ // turn with a fast model, leaving a finished static screen. The echo is a
461
+ // real user message in natural language — fine if briefly visible.
462
+ await waitSteadyStream();
463
+ await sleep(3000);
464
+ await assertDark(page, "03-phone-live");
465
+ const marker = Date.now();
466
+ await sleep(8000); // streaming dwell at phone width
467
+ // Bounded touch scroll (keyboard hidden, so full height is usable):
468
+ try {
469
+ await swipe(page, 195, 250, 480); // scroll up ~230px (older output)
470
+ await sleep(1500);
471
+ await swipe(page, 195, 480, 250); // back toward bottom
472
+ await sleep(900);
473
+ } catch (e) {
474
+ console.error("touch swipe failed, wheel fallback: " + e.message);
475
+ await page.mouse.move(195, 400);
476
+ await page.mouse.wheel(0, -300); await sleep(1800); await page.mouse.wheel(0, 300);
477
+ }
478
+ await sleep(3500);
479
+ await saveVideo(page, "03-phone-live", trimFor(t0, marker));
480
+
481
+ } else if (clip === "04-phone-switcher") {
482
+ const { page, t0 } = await newRecordedPage(browser, { mobile: true });
483
+ await gotoSession(page, "Lyra2");
484
+ await sleep(2000);
485
+ await hideKeyboard(page); // stays hidden through FAB -> switcher -> switch
486
+ await sleep(800);
487
+ // Phone-fit EVERY demo session's pty AFTER the recorded client attached
488
+ // (the REST client is then the latest typer, so its size wins the
489
+ // election — same trick as clip 03). Prevents the mobile view panning
490
+ // and cropping the left text edge on a wide pty.
491
+ for (const k of Object.keys(terminals)) {
492
+ await apiResize(terminals[k].terminalId, 46, 40);
493
+ }
494
+ await sleep(2200);
495
+ // Hard gate: every demo pty must actually report 46 cols.
496
+ const sess = await (await fetch(`${state.localUrl}/api/sessions`, {
497
+ headers: { Authorization: `Bearer ${state.sessionSecret}` }
498
+ })).json();
499
+ for (const k of Object.keys(terminals)) {
500
+ const s = (sess.sessions || []).find((x) => x.name === k);
501
+ if (!s || s.cols !== 46) {
502
+ throw new Error(`ABORT 04: ${k} pty is ${s ? s.cols + "x" + s.rows : "missing"}, expected 46 cols`);
503
+ }
504
+ }
505
+ await assertDark(page, "04-phone-switcher");
506
+ const marker = Date.now();
507
+ await sleep(2500); // settle on Lyra before the flow starts
508
+ await tapFab(page);
509
+ await sleep(3800); // hero previews load + refresh
510
+ // Dismiss a stray long-press action sheet if one opened.
511
+ if (await page.locator(".switcher-sheet-backdrop").isVisible().catch(() => false)) {
512
+ const bd = await page.locator(".switcher-sheet-backdrop").boundingBox();
513
+ await page.touchscreen.tap(bd.x + bd.width / 2, bd.y + 60);
514
+ await sleep(800);
515
+ }
516
+ const card = page.locator(".switcher-card, .switcher-row", { hasText: "Aurora" }).first();
517
+ const box = await card.boundingBox();
518
+ await page.touchscreen.tap(box.x + box.width / 2, box.y + Math.min(60, box.height / 2));
519
+ await sleep(4500); // instant switch: agent TUI now visible
520
+ const kbAfter = await page.evaluate(() => !!document.querySelector(".session.has-keyboard"));
521
+ if (kbAfter) throw new Error("ABORT 04: keyboard reappeared after switch");
522
+ await saveVideo(page, "04-phone-switcher", trimFor(t0, marker));
523
+
524
+ } else if (clip === "05-presence") {
525
+ const { page, t0 } = await newRecordedPage(browser, {});
526
+ await gotoSession(page, "Lyra2", "?name=you");
527
+ const { page: pageB, ctx: ctxB } = await newRecordedPage(browser, { record: false });
528
+ await gotoSession(pageB, "Lyra2", "?name=Vega");
529
+ // Gate: BOTH clients connected, EXACTLY the demo cast chips (you + Vega)
530
+ // visible on the recorded page (ghost API chips hidden by the sanitizer),
531
+ // and no Connecting/Reconnecting text anywhere in the recorded frame.
532
+ const connected = async (p, needChips) => p.evaluate((need) => {
533
+ const txt = document.body.innerText || "";
534
+ if (txt.includes("Connecting") || txt.includes("Reconnecting")) return false;
535
+ if (!need) return true;
536
+ const chips = [...document.querySelectorAll(".presence-chip")]
537
+ .filter((c) => getComputedStyle(c).display !== "none")
538
+ .map((c) => (c.querySelector(".presence-name")?.textContent || "").trim());
539
+ return chips.length === 2 && chips.includes("you") && chips.includes("Vega");
540
+ }, needChips);
541
+ const tGate = Date.now();
542
+ while (Date.now() - tGate < 30000) {
543
+ if ((await connected(page, true)) && (await connected(pageB, false))) break;
544
+ await sleep(400);
545
+ }
546
+ if (!(await connected(page, true)) || !(await connected(pageB, false))) {
547
+ throw new Error("ABORT 05: clients not both connected with chips within 30s");
548
+ }
549
+ await sleep(1200); // stability margin
550
+ await assertDark(page, "05-presence");
551
+ const marker = Date.now();
552
+ await sleep(1500);
553
+ await pageB.mouse.click(960, 500); // focus terminal on Vega's client
554
+ await pageB.keyboard.type("echo hi from the train — Vega", { delay: 110 });
555
+ await sleep(500);
556
+ await pageB.keyboard.press("Enter");
557
+ await sleep(1200);
558
+ await pageB.keyboard.type("ls -1", { delay: 130 });
559
+ await sleep(400);
560
+ await pageB.keyboard.press("Enter");
561
+ await sleep(3200);
562
+ try { await ctxB.unrouteAll({ behavior: "ignoreErrors" }); } catch {}
563
+ await ctxB.close();
564
+ await saveVideo(page, "05-presence", trimFor(t0, marker));
565
+
566
+ } else if (clip === "06-math") {
567
+ const lyra = requireTerminalId("Lyra2");
568
+ const { page, t0 } = await newRecordedPage(browser, {});
569
+ await gotoSession(page, "Lyra2");
570
+ await sleep(2200);
571
+ await assertDark(page, "06-math");
572
+ const marker = Date.now();
573
+ await sleep(1200);
574
+ await typeSlow(lyra, "./hop math 'e^{i\\pi} + 1 = 0'");
575
+ await sleep(300); await apiWrite(lyra, "\r");
576
+ await sleep(2200);
577
+ await typeSlow(lyra, "./hop math '\\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}'");
578
+ await sleep(300); await apiWrite(lyra, "\r");
579
+ await sleep(3500);
580
+ await saveVideo(page, "06-math", trimFor(t0, marker));
581
+
582
+ } else if (clip === "07-theme") {
583
+ const { page, t0 } = await newRecordedPage(browser, {});
584
+ await gotoSession(page, "Aurora2");
585
+ await sleep(2500);
586
+ await assertDark(page, "07-theme"); // starts dark
587
+ const themeRow = page.locator(".drawer-row", { hasText: "Theme" });
588
+ if (!(await themeRow.first().isVisible().catch(() => false))) {
589
+ await page.click(".drawer-toggle");
590
+ await sleep(1800);
591
+ }
592
+ const marker = Date.now();
593
+ await sleep(1200);
594
+ // Dark -> Light -> Dark: shows the flip both ways, starts AND ends dark.
595
+ await themeRow.getByRole("button", { name: "Light" }).click();
596
+ await sleep(2200);
597
+ await themeRow.getByRole("button", { name: "Dark" }).click();
598
+ await sleep(2400);
599
+ await saveVideo(page, "07-theme", trimFor(t0, marker));
600
+
601
+ } else if (clip === "01-sessions") {
602
+ const { page, t0 } = await newRecordedPage(browser, {});
603
+ await page.goto(`${state.localUrl}/sessions`, { waitUntil: "networkidle", timeout: 30000 });
604
+ await sleep(2800);
605
+ await assertDark(page, "01-sessions"); // sessions.html via colorScheme dark
606
+ const marker = Date.now();
607
+ await sleep(1000);
608
+ // The rename dialog prefills the INPUT's .value with the internal name
609
+ // ("Lyra2"); property writes bypass the MutationObserver rewriter, so shim
610
+ // promptModal to show the display name. The rename POST still uses the
611
+ // real oldName ("Lyra2") from renameSession's own argument.
612
+ await page.evaluate(() => {
613
+ const orig = window.promptModal;
614
+ window.promptModal = (opts) =>
615
+ orig({ ...opts, value: opts.value === "Lyra2" ? "Lyra" : opts.value });
616
+ });
617
+ const row = page.locator(".session-item-wrapper", { hasText: "Lyra" });
618
+ await row.hover();
619
+ await sleep(700);
620
+ await row.locator('.row-action[title="Rename"]').click();
621
+ await sleep(1200);
622
+ await page.keyboard.type("Vega", { delay: 140 });
623
+ await sleep(700);
624
+ await page.click("#modalConfirmBtn");
625
+ await sleep(3200);
626
+ await saveVideo(page, "01-sessions", trimFor(t0, marker));
627
+
628
+ } else {
629
+ console.error("unknown clip: " + clip);
630
+ process.exit(1);
631
+ }
632
+ } finally {
633
+ await browser.close();
634
+ }
@@ -0,0 +1,44 @@
1
+ // Dispatch a narration task to the Aurora2 agent via hopx_agent_turn (async),
2
+ // then wait until the identity banner has scrolled off the visible screen AND
3
+ // enough output has streamed that modest scrollback is safe.
4
+ // Usage: node dispatch-task.mjs "<task text>" [minCharsStreamed]
5
+ import { getHopState } from "../hop-demo-lib.mjs";
6
+ import { startMcp } from "./mcp-client.mjs";
7
+ import { SCREEN_FORBIDDEN, loadTerminals } from "./capture-env.mjs";
8
+
9
+ const task = process.argv[2];
10
+ const minChars = Number(process.argv[3] || 2500);
11
+ if (!task) { console.error("usage: dispatch-task.mjs <task>"); process.exit(1); }
12
+ const { Aurora2 } = loadTerminals();
13
+ if (!Aurora2) { console.error("Aurora2 not registered — run spawn-aurora.mjs first"); process.exit(1); }
14
+
15
+ const state = getHopState();
16
+ const preview = async () => {
17
+ const r = await fetch(`${state.localUrl}/api/sessions/preview?name=Aurora2`, {
18
+ headers: { Authorization: `Bearer ${state.sessionSecret}` }
19
+ });
20
+ return (await r.json()).text || "";
21
+ };
22
+
23
+ const mcp = startMcp("video-dispatch");
24
+ try {
25
+ await mcp.init();
26
+
27
+ const turn = await mcp.callTool("hopx_agent_turn", { terminal_id: Aurora2.terminalId, data: task, async: true }, 60000);
28
+ console.log("dispatched wait_id=" + (turn.parsed?.wait_id || "none") + (turn.isError ? " ERROR " + turn.text.slice(0, 200) : ""));
29
+
30
+ // Wait until banner is off the visible screen and output volume is healthy.
31
+ let streamed = 0;
32
+ const t0 = Date.now();
33
+ while (Date.now() - t0 < 120000) {
34
+ const text = await preview();
35
+ const bannerVisible = SCREEN_FORBIDDEN.some((bad) => text.includes(bad));
36
+ streamed = text.length;
37
+ if (!bannerVisible && text.length > minChars) break;
38
+ await new Promise((r) => setTimeout(r, 1500));
39
+ }
40
+ console.log(`screen ready, visible chars=${streamed}`);
41
+ } finally {
42
+ mcp.kill();
43
+ process.exit(0);
44
+ }