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.
- package/.github/workflows/ci.yml +25 -0
- package/.github/workflows/publish.yml +130 -0
- package/DESIGN-input-acks.md +349 -0
- package/MULTI-AGENT-CHECKLIST.md +5 -5
- package/README-MCP.md +84 -5
- package/README.md +55 -4
- package/demo/README.md +52 -0
- package/demo/capture/capture-env.mjs +108 -0
- package/demo/capture/capture.mjs +634 -0
- package/demo/capture/dispatch-task.mjs +44 -0
- package/demo/capture/mcp-client.mjs +61 -0
- package/demo/capture/native-css.mjs +49 -0
- package/demo/capture/setup-04.mjs +67 -0
- package/demo/capture/setup-sessions.mjs +40 -0
- package/demo/capture/spawn-aurora.mjs +53 -0
- package/demo/capture/tools/agent.sh +30 -0
- package/demo/capture/tools/build.sh +18 -0
- package/demo/capture/tools/metrics.sh +17 -0
- package/docs/hero-overview.svg +119 -0
- package/docs/hero.svg +118 -0
- package/docs/restore-reliability.md +139 -0
- package/docs/session-identity.md +76 -0
- package/docs/switcher-first-ux.md +103 -0
- package/hay/CHANGELOG.md +59 -1
- package/hay/apps/cli/package.json +3 -2
- package/hay/apps/cli/src/index.ts +705 -28
- package/hay/apps/server/src/pty.ts +36 -2
- package/hay/apps/server/src/rooms.ts +525 -34
- package/hay/apps/server/test/rooms.test.ts +411 -1
- package/hay/apps/web/index.html +2 -0
- package/hay/apps/web/package.json +14 -12
- package/hay/apps/web/scripts/run-e2e.mjs +42 -5
- package/hay/apps/web/src/App.tsx +2492 -281
- package/hay/apps/web/src/components/ContextMenu.tsx +109 -0
- package/hay/apps/web/src/components/MobileKeyboard.tsx +135 -14
- package/hay/apps/web/src/components/SecondaryPane.tsx +218 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +2525 -0
- package/hay/apps/web/src/setupTests.ts +23 -0
- package/hay/apps/web/src/styles.css +2460 -180
- package/hay/apps/web/src/utils/fileDrop.ts +15 -0
- package/hay/apps/web/src/utils/keyboardProtocol.ts +22 -0
- package/hay/apps/web/src/utils/optimisticEcho.ts +83 -7
- package/hay/apps/web/src/utils/scrollFlywheel.ts +145 -0
- package/hay/apps/web/src/utils/switcherModel.ts +314 -0
- package/hay/apps/web/src/utils/terminalSearch.ts +54 -0
- package/hay/apps/web/src/vite-env.d.ts +3 -0
- package/hay/apps/web/test/SessionSwitcher.test.tsx +324 -0
- package/hay/apps/web/test/fileDrop.test.ts +22 -0
- package/hay/apps/web/test/optimisticEcho.test.ts +17 -0
- package/hay/apps/web/test/scrollFlywheel.test.ts +113 -0
- package/hay/apps/web/test/switcherModel.test.ts +377 -0
- package/hay/apps/web/test/terminalSearch.test.ts +37 -0
- package/hay/apps/web/vite.config.ts +20 -0
- package/hay/package-lock.json +1696 -1171
- package/hay/packages/shared/src/protocol.ts +25 -2
- package/hay-web/assets/index-BohAWpcL.css +1 -0
- package/hay-web/assets/index-DfGT3DuO.js +130 -0
- package/hay-web/assets/index-DfGT3DuO.js.map +1 -0
- package/hay-web/index.html +4 -2
- package/hop +4348 -302
- package/hopa +465 -0
- package/mcp/hop-mcp.js +2878 -122
- package/package.json +7 -4
- package/scripts/claude-session-hook.js +152 -0
- package/scripts/hay-host.js +227 -4
- package/scripts/hop-math.js +316 -0
- package/scripts/salvage-and-restart-host.js +217 -0
- package/skills/hop-manager/SKILL.md +62 -0
- package/skills/hop-session/SKILL.md +42 -0
- package/skills/hopa/SKILL.md +75 -0
- package/tests/agent-skill.unit.test.cjs +60 -0
- package/tests/composer-state.unit.test.cjs +102 -0
- package/tests/hop-mcp.integration.test.cjs +476 -94
- package/tests/hopa-parity.unit.test.cjs +188 -0
- package/tests/readable-echo.unit.test.cjs +94 -0
- package/tests/readable-flush.unit.test.cjs +93 -0
- package/tests/submit-verification.unit.test.cjs +331 -0
- package/tests/terminal-api.integration.test.cjs +301 -13
- package/hay-web/assets/index-BzJB9r_I.js +0 -64
- package/hay-web/assets/index-BzJB9r_I.js.map +0 -1
- package/hay-web/assets/index-DG8Ntznc.css +0 -32
- 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
|
+
}
|