@foldspace_npm/harness 0.1.31 → 0.1.33

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 (41) hide show
  1. package/CLAUDE.md +27 -8
  2. package/README.md +25 -2
  3. package/bin/ask.mjs +16 -145
  4. package/bin/attach.mjs +2 -0
  5. package/bin/cli.mjs +22 -0
  6. package/bin/inject.mjs +2 -14
  7. package/bin/record.mjs +412 -0
  8. package/bin/share.mjs +63 -0
  9. package/package.json +1 -1
  10. package/src/attach-daemon.mjs +18 -0
  11. package/src/badge-auto.mjs +38 -1
  12. package/src/cdp-client.mjs +7 -1
  13. package/src/chrome-binary.mjs +28 -0
  14. package/src/cli-help.mjs +1 -0
  15. package/src/cli-registry.mjs +97 -3
  16. package/src/init.mjs +170 -16
  17. package/src/panel-driver.mjs +283 -0
  18. package/src/session-events.mjs +1 -0
  19. package/src/upgrade.mjs +87 -1
  20. package/src/video/README.md +29 -0
  21. package/src/video/assets/foldspace-mark.svg +27 -0
  22. package/src/video/assets/logo-dark.svg +68 -0
  23. package/src/video/assets/logo-light.svg +56 -0
  24. package/src/video/brand.mjs +46 -0
  25. package/src/video/compose.mjs +476 -0
  26. package/src/video/composer/composer.mjs +554 -0
  27. package/src/video/composer/index.html +13 -0
  28. package/src/video/fonts/OFL.txt +93 -0
  29. package/src/video/fonts/Poppins-ExtraBold.woff2 +0 -0
  30. package/src/video/fonts/Poppins-Regular.woff2 +0 -0
  31. package/src/video/fonts/Poppins-SemiBold.woff2 +0 -0
  32. package/src/video/recorder.mjs +331 -0
  33. package/src/video/scenes.mjs +253 -0
  34. package/src/video/share.mjs +284 -0
  35. package/src/video/timeline.mjs +726 -0
  36. package/src/video/vendor/mp4-muxer.LICENSE +21 -0
  37. package/src/video/vendor/mp4-muxer.min.mjs +5 -0
  38. package/templates/agent-starter/README.md +5 -0
  39. package/templates/agent-starter/claude-settings.json +1 -0
  40. package/templates/agent-starter/gitignore +3 -0
  41. package/templates/agent-starter/skills/share-a-video/SKILL.md +271 -0
@@ -0,0 +1,476 @@
1
+ // Making the MP4 from a recording, in a hidden Chrome.
2
+ //
3
+ // No ffmpeg and nothing to install: a separate headless instance of the same
4
+ // Chrome `inject` uses loads the composer page from a short-lived server on
5
+ // 127.0.0.1, draws the video on a canvas, encodes H.264 with WebCodecs and
6
+ // posts the MP4 back. The headless Chrome gets a throwaway profile, and
7
+ // --use-mock-keychain --password-store=basic: without them macOS asks for the
8
+ // keychain password the moment Chrome starts.
9
+
10
+ import { spawn } from "node:child_process";
11
+ import crypto from "node:crypto";
12
+ import fs from "node:fs";
13
+ import http from "node:http";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ import { findChrome } from "../chrome-binary.mjs";
19
+ import { scenePlace } from "./scenes.mjs";
20
+ import { TIMING, fitToLength, layoutClips, sceneTimeMap } from "./timeline.mjs";
21
+
22
+ const videoRoot = path.dirname(fileURLToPath(import.meta.url));
23
+
24
+ export const PRESETS = Object.freeze({
25
+ landscape: { width: 1920, height: 1080, bitrate: 8_000_000 },
26
+ square: { width: 1080, height: 1080, bitrate: 6_000_000 },
27
+ });
28
+
29
+ export class VideoError extends Error {
30
+ constructor(code, message) {
31
+ super(message);
32
+ this.code = code;
33
+ }
34
+ }
35
+
36
+ // ------------------------------------------------------------ recording on disk
37
+
38
+ const RECORD_AGAIN = "Record again: `npx foldspace record`.";
39
+
40
+ /**
41
+ * A recording folder: record.json plus scene-<n>/{events.json, frames.json,
42
+ * frames/}. Only a finished one makes a video: record.json says `complete`
43
+ * once every scene's answer settled. A scene that failed or was cut off would
44
+ * put a caption over an unanswered "Thinking...".
45
+ */
46
+ export function loadRecording(recordDir) {
47
+ const manifestPath = path.join(recordDir, "record.json");
48
+ let manifest;
49
+ try {
50
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
51
+ } catch {
52
+ throw new VideoError("no_recording", `No recording in ${recordDir} (record.json is missing).`);
53
+ }
54
+ if (manifest.complete !== true) {
55
+ const stopped = (manifest.scenes || []).find((scene) => scene.ok !== true);
56
+ throw new VideoError(
57
+ "recording_incomplete",
58
+ stopped
59
+ ? `Scene ${stopped.n} of this recording never finished${stopped.reason ? ` (${stopped.reason})` : ""}, so it makes no video. ${RECORD_AGAIN}`
60
+ : `This recording stopped before it finished, so it makes no video. ${RECORD_AGAIN}`,
61
+ );
62
+ }
63
+ const scenes = (manifest.scenes || []).map((scene) => {
64
+ const dir = path.join(recordDir, scene.dir);
65
+ try {
66
+ const events = JSON.parse(fs.readFileSync(path.join(dir, "events.json"), "utf8"));
67
+ const frames = JSON.parse(fs.readFileSync(path.join(dir, "frames.json"), "utf8"));
68
+ return { ...scene, events, frames };
69
+ } catch {
70
+ throw new VideoError("recording_incomplete", `Scene ${scene.n} of this recording is missing its files. ${RECORD_AGAIN}`);
71
+ }
72
+ });
73
+ return { dir: recordDir, manifest, scenes };
74
+ }
75
+
76
+ /** A scene's events and picture times in seconds, for the timeline. */
77
+ export function sceneTimes(scene) {
78
+ const seconds = (ms) => (ms == null ? null : ms / 1000);
79
+ const { events } = scene;
80
+ return {
81
+ events: {
82
+ typingStart: seconds(events.typingStart),
83
+ sent: seconds(events.sent),
84
+ firstReply: seconds(events.firstReply),
85
+ settled: seconds(events.settledAt),
86
+ },
87
+ frameTimes: scene.frames.map((frame) => frame.t / 1000),
88
+ };
89
+ }
90
+
91
+ const CAPTIONS_ONLY =
92
+ "Only the product name, the capabilities, the lines and the end card can change without recording again.";
93
+
94
+ /** The recording must be of the questions in the scenes file now; the rest of each caption may change. */
95
+ export function checkRecordingMatches(recording, config) {
96
+ const recorded = recording.scenes.map((scene) => scene.question);
97
+ const wanted = config.scenes.map((scene) => scene.question);
98
+ if (recorded.length !== wanted.length) {
99
+ return `The recording has ${recorded.length} scene(s) and the scenes file ${wanted.length}. ${CAPTIONS_ONLY}`;
100
+ }
101
+ const changed = wanted.findIndex((question, index) => question !== recorded[index]);
102
+ if (changed >= 0) {
103
+ return `Scene ${changed + 1}'s question is not the one recorded. ${CAPTIONS_ONLY}`;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ // ------------------------------------------------------------ plan
109
+
110
+ /**
111
+ * Everything the composer page needs, as JSON: the frame size, the clips in
112
+ * order with their time maps and captions, and where the poster is taken.
113
+ * The opening card meets the product's agent; each caption shows the scene's
114
+ * capabilities, where the video is ("2 of 4"), the question and its line.
115
+ * The colours are Foldspace's own (brand.mjs), never the app's.
116
+ */
117
+ export function buildPlan({ recording, config, preset = "landscape", endCard = true, timing = TIMING }) {
118
+ const size = PRESETS[preset];
119
+ if (!size) throw new VideoError("bad_preset", `Unknown preset '${preset}': landscape or square.`);
120
+ const timed = recording.scenes.map(sceneTimes);
121
+ const fit = fitToLength(timed, { titleCard: true, endCard, timing });
122
+ const product = config.product;
123
+ const total = config.scenes.length;
124
+ const clips = [{ kind: "opening", product, fadeInS: 0.5, durationS: timing.openingS }];
125
+ recording.scenes.forEach((scene, index) => {
126
+ const map = sceneTimeMap(timed[index].events, timed[index].frameTimes, { k: fit.k, timing });
127
+ const { question, capabilities, line } = config.scenes[index];
128
+ clips.push({
129
+ kind: "scene",
130
+ n: index + 1,
131
+ dir: scene.dir,
132
+ frames: scene.frames.map((frame) => frame.file),
133
+ frameTimes: timed[index].frameTimes,
134
+ segments: map.segments,
135
+ caption: map.caption,
136
+ captionText: { capabilities, place: scenePlace(index + 1, total), question, line },
137
+ viewport: scene.events.viewport || null,
138
+ panel: (scene.events.panel || []).map(({ x, y, width, height }) => ({ x, y, width, height })),
139
+ durationS: map.durationS,
140
+ });
141
+ });
142
+ if (endCard) {
143
+ clips.push({
144
+ kind: "end",
145
+ eyebrow: `${product} × Foldspace`,
146
+ title: "An agent that does the work.",
147
+ sub: "Built with Foldspace. foldspace.ai",
148
+ mark: true,
149
+ durationS: timing.endS,
150
+ });
151
+ }
152
+ const layout = layoutClips(
153
+ clips.map((clip) => clip.durationS),
154
+ timing.dissolveS,
155
+ );
156
+ // The poster: the first scene's answer with its caption fully in.
157
+ const first = clips.findIndex((clip) => clip.kind === "scene");
158
+ const posterS = first >= 0 ? layout.starts[first] + (clips[first].caption.inEnd + clips[first].caption.outStart) / 2 : 1;
159
+ return {
160
+ ...size,
161
+ preset,
162
+ product,
163
+ clips,
164
+ layout,
165
+ posterS,
166
+ fit,
167
+ };
168
+ }
169
+
170
+ // ------------------------------------------------------------ hidden Chrome
171
+
172
+ const HEADLESS_ARGS = [
173
+ "--headless=new",
174
+ "--use-mock-keychain",
175
+ "--password-store=basic",
176
+ "--no-first-run",
177
+ "--no-default-browser-check",
178
+ "--disable-extensions",
179
+ "--disable-sync",
180
+ "--disable-default-apps",
181
+ "--disable-background-networking",
182
+ "--disable-component-update",
183
+ "--disable-background-timer-throttling",
184
+ "--disable-renderer-backgrounding",
185
+ "--mute-audio",
186
+ "--hide-scrollbars",
187
+ ];
188
+
189
+ // Every hidden Chrome this process has started and not yet closed. A run that
190
+ // is stopped (Ctrl-C, a timeout) closes them through closeAllHeadless(); one
191
+ // that exits any other way kills them on the way out. Left behind, each would
192
+ // keep running with a profile in the temp folder.
193
+ const launched = new Set();
194
+ let exitHookSet = false;
195
+
196
+ function killLeftovers() {
197
+ for (const chrome of launched) chrome.killNow();
198
+ }
199
+
200
+ /** Close every hidden Chrome this process started (for an interrupted run). */
201
+ export async function closeAllHeadless() {
202
+ await Promise.allSettled([...launched].map((chrome) => chrome.close()));
203
+ }
204
+
205
+ /**
206
+ * Start a hidden Chrome on `url` with a throwaway profile. `extraArgs` lets a
207
+ * test add a debugging port. Returns { child, profileDir, close() }.
208
+ */
209
+ export function launchHeadless(chromePath, url, { extraArgs = [] } = {}) {
210
+ const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), "foldspace-video-chrome-"));
211
+ const child = spawn(chromePath, [...HEADLESS_ARGS, `--user-data-dir=${profileDir}`, ...extraArgs, url], {
212
+ stdio: "ignore",
213
+ });
214
+ let exited = false;
215
+ child.on("exit", () => {
216
+ exited = true;
217
+ });
218
+ child.on("error", () => {
219
+ exited = true;
220
+ });
221
+ const chrome = {
222
+ child,
223
+ profileDir,
224
+ get exited() {
225
+ return exited;
226
+ },
227
+ // Synchronous, for process exit: no time to wait for a clean close.
228
+ killNow() {
229
+ launched.delete(chrome);
230
+ if (!exited) {
231
+ try {
232
+ child.kill("SIGKILL");
233
+ } catch {
234
+ // already gone
235
+ }
236
+ }
237
+ try {
238
+ fs.rmSync(profileDir, { recursive: true, force: true });
239
+ } catch {
240
+ // Chrome may still hold it for a moment; the temp folder is the OS's to clear
241
+ }
242
+ },
243
+ async close() {
244
+ launched.delete(chrome);
245
+ if (!exited) {
246
+ child.kill("SIGTERM");
247
+ for (let tick = 0; tick < 30 && !exited; tick++) await new Promise((resolve) => setTimeout(resolve, 100));
248
+ if (!exited) child.kill("SIGKILL");
249
+ }
250
+ // Chrome can still be letting go of its profile for a moment.
251
+ for (let attempt = 0; attempt < 5; attempt++) {
252
+ try {
253
+ fs.rmSync(profileDir, { recursive: true, force: true });
254
+ break;
255
+ } catch {
256
+ await new Promise((resolve) => setTimeout(resolve, 200));
257
+ }
258
+ }
259
+ },
260
+ };
261
+ launched.add(chrome);
262
+ if (!exitHookSet) {
263
+ exitHookSet = true;
264
+ process.on("exit", killLeftovers);
265
+ }
266
+ return chrome;
267
+ }
268
+
269
+ const TYPES = {
270
+ ".html": "text/html; charset=utf-8",
271
+ ".mjs": "text/javascript; charset=utf-8",
272
+ ".js": "text/javascript; charset=utf-8",
273
+ ".json": "application/json",
274
+ ".woff2": "font/woff2",
275
+ ".svg": "image/svg+xml",
276
+ ".jpg": "image/jpeg",
277
+ };
278
+
279
+ // What the composer page may read from this package.
280
+ const STATIC_ROOTS = ["composer", "vendor", "fonts", "assets"];
281
+ const STATIC_FILES = ["timeline.mjs", "brand.mjs"];
282
+
283
+ function inside(root, relative) {
284
+ const full = path.resolve(root, relative);
285
+ return full === root || full.startsWith(`${root}${path.sep}`) ? full : null;
286
+ }
287
+
288
+ /**
289
+ * The page's server: 127.0.0.1 only, every path under a random token, so no
290
+ * other page or process on the machine can guess its way to the recorded
291
+ * pictures (they show the customer's real data). Resolves `done` when the
292
+ * page has posted the video, or fails with what the page reported.
293
+ */
294
+ function startServer({ plan, recordDir: givenDir, onProgress }) {
295
+ const recordDir = path.resolve(givenDir);
296
+ const token = crypto.randomBytes(16).toString("hex");
297
+ const posted = {};
298
+ let settle;
299
+ const finished = new Promise((resolve, reject) => {
300
+ settle = { resolve, reject };
301
+ });
302
+ const server = http.createServer((request, response) => {
303
+ const url = new URL(request.url, "http://127.0.0.1");
304
+ const prefix = `/${token}/`;
305
+ if (!url.pathname.startsWith(prefix)) {
306
+ response.writeHead(404).end();
307
+ return;
308
+ }
309
+ const rest = decodeURIComponent(url.pathname.slice(prefix.length));
310
+ if (request.method === "POST") {
311
+ const chunks = [];
312
+ request.on("data", (chunk) => chunks.push(chunk));
313
+ request.on("end", () => {
314
+ const body = Buffer.concat(chunks);
315
+ response.writeHead(204).end();
316
+ if (rest === "progress") {
317
+ try {
318
+ onProgress?.(JSON.parse(body.toString("utf8")));
319
+ } catch {
320
+ // progress is only for the log
321
+ }
322
+ } else if (rest === "fail") {
323
+ let report = {};
324
+ try {
325
+ report = JSON.parse(body.toString("utf8"));
326
+ } catch {
327
+ // keep the generic message
328
+ }
329
+ settle.reject(new VideoError(report.code || "page_error", report.message || "The composer page failed."));
330
+ } else if (rest === "done") {
331
+ try {
332
+ settle.resolve({ ...posted, done: JSON.parse(body.toString("utf8")) });
333
+ } catch {
334
+ settle.resolve({ ...posted, done: {} });
335
+ }
336
+ } else {
337
+ posted[rest] = body;
338
+ }
339
+ });
340
+ return;
341
+ }
342
+ let file = null;
343
+ if (rest === "plan.json") {
344
+ response.writeHead(200, { "content-type": TYPES[".json"], "cache-control": "no-store" });
345
+ response.end(JSON.stringify(plan));
346
+ return;
347
+ }
348
+ if (rest.startsWith("rec/")) file = inside(recordDir, rest.slice(4));
349
+ else if (STATIC_FILES.includes(rest) || STATIC_ROOTS.includes(rest.split("/")[0])) file = inside(videoRoot, rest);
350
+ if (!file || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
351
+ response.writeHead(404).end();
352
+ return;
353
+ }
354
+ response.writeHead(200, {
355
+ "content-type": TYPES[path.extname(file)] || "application/octet-stream",
356
+ "cache-control": "no-store",
357
+ });
358
+ fs.createReadStream(file).pipe(response);
359
+ });
360
+ return new Promise((resolve) => {
361
+ server.listen(0, "127.0.0.1", () => {
362
+ const { port } = server.address();
363
+ resolve({
364
+ url: `http://127.0.0.1:${port}/${token}/composer/index.html`,
365
+ finished,
366
+ close: () => new Promise((done) => server.close(() => done())),
367
+ fail: (error) => settle.reject(error),
368
+ });
369
+ });
370
+ });
371
+ }
372
+
373
+ /** Run the composer page in a hidden Chrome; returns { mp4, poster, done }. */
374
+ export async function renderInChrome({ plan, recordDir, chromePath, onProgress, timeoutMs = 15 * 60 * 1000 }) {
375
+ const server = await startServer({ plan, recordDir, onProgress });
376
+ const chrome = launchHeadless(chromePath, server.url);
377
+ chrome.child.on("exit", (code, signal) =>
378
+ server.fail(
379
+ new VideoError(
380
+ "chrome_exited",
381
+ `The hidden Chrome closed before the video was made (${signal ? `stopped by ${signal}` : `exit code ${code}`}).`,
382
+ ),
383
+ ),
384
+ );
385
+ chrome.child.on("error", (error) =>
386
+ server.fail(new VideoError("chrome_failed", `Could not start Chrome at ${chromePath}: ${error.message}`)),
387
+ );
388
+ const timer = setTimeout(
389
+ () => server.fail(new VideoError("timeout", `The video was not made within ${Math.round(timeoutMs / 60000)} minutes.`)),
390
+ timeoutMs,
391
+ );
392
+ try {
393
+ const result = await server.finished;
394
+ if (!result.out) throw new VideoError("page_error", "The composer page finished without a video.");
395
+ return { mp4: result.out, poster: result.poster || null, done: result.done };
396
+ } finally {
397
+ clearTimeout(timer);
398
+ await chrome.close();
399
+ await server.close();
400
+ }
401
+ }
402
+
403
+ const PROBE_PAGE = `<!doctype html><meta charset="utf-8"><script type="module">
404
+ const configs = ["avc1.640028", "avc1.4d0028", "avc1.42e028"].map((codec) => ({
405
+ codec, width: 1920, height: 1080, bitrate: 8000000, framerate: 30, avc: { format: "avc" } }));
406
+ let codec = null;
407
+ if (typeof VideoEncoder === "function") {
408
+ for (const config of configs) {
409
+ try { if ((await VideoEncoder.isConfigSupported(config)).supported) { codec = config.codec; break; } } catch {}
410
+ }
411
+ }
412
+ await fetch("result", { method: "POST", body: JSON.stringify({ codec }) });
413
+ </script>`;
414
+
415
+ /**
416
+ * Whether this Chrome can encode H.264, asked in a hidden Chrome the same way
417
+ * the composer starts one: { ok, codec } or { ok: false, code, message }.
418
+ * Run before recording, so a Chrome that cannot make the video says so before
419
+ * minutes of recording rather than after.
420
+ */
421
+ export async function probeEncoder({ chromePath = findChrome(), timeoutMs = 60_000 } = {}) {
422
+ if (!chromePath) return { ok: false, code: "no_chrome", message: needsChrome(null) };
423
+ const token = crypto.randomBytes(16).toString("hex");
424
+ let answer;
425
+ const answered = new Promise((resolve) => {
426
+ answer = resolve;
427
+ });
428
+ const server = http.createServer((request, response) => {
429
+ if (request.url === `/${token}/` && request.method === "GET") {
430
+ response.writeHead(200, { "content-type": TYPES[".html"] }).end(PROBE_PAGE);
431
+ return;
432
+ }
433
+ if (request.url === `/${token}/result` && request.method === "POST") {
434
+ const chunks = [];
435
+ request.on("data", (chunk) => chunks.push(chunk));
436
+ request.on("end", () => {
437
+ response.writeHead(204).end();
438
+ try {
439
+ answer(JSON.parse(Buffer.concat(chunks).toString("utf8")));
440
+ } catch {
441
+ answer({ codec: null });
442
+ }
443
+ });
444
+ return;
445
+ }
446
+ response.writeHead(404).end();
447
+ });
448
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
449
+ const chrome = launchHeadless(chromePath, `http://127.0.0.1:${server.address().port}/${token}/`);
450
+ chrome.child.on("error", () => answer({ failed: true }));
451
+ chrome.child.on("exit", () => answer({ failed: true }));
452
+ const timer = setTimeout(() => answer({ failed: true, timeout: true }), timeoutMs);
453
+ try {
454
+ const result = await answered;
455
+ if (result.failed) {
456
+ return {
457
+ ok: false,
458
+ code: "chrome_failed",
459
+ message: `Chrome at ${chromePath} did not start in the background${result.timeout ? " in time" : ""}, so the video cannot be made.`,
460
+ };
461
+ }
462
+ if (!result.codec) return { ok: false, code: "no_h264", message: needsChrome(chromePath) };
463
+ return { ok: true, codec: result.codec, chromePath };
464
+ } finally {
465
+ clearTimeout(timer);
466
+ await chrome.close();
467
+ await new Promise((resolve) => server.close(() => resolve()));
468
+ }
469
+ }
470
+
471
+ /** The message for a Chrome that cannot make the video. */
472
+ export function needsChrome(chromePath) {
473
+ return chromePath
474
+ ? `The video needs Google Chrome: this Chrome (${chromePath}) cannot encode H.264 video, as some Chromium builds cannot. Install Google Chrome, or set CHROME_PATH to it.`
475
+ : "The video needs Google Chrome, and none was found. Install Google Chrome, or set CHROME_PATH to it.";
476
+ }