@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
@@ -32,7 +32,7 @@ export const CLI_COMMANDS = Object.freeze([
32
32
  {
33
33
  name: "directory",
34
34
  required: "non-interactive",
35
- description: "New project directory",
35
+ description: "Project directory: a new one, or one that holds nothing but files the system or an editor put there",
36
36
  },
37
37
  ],
38
38
  options: [
@@ -59,10 +59,10 @@ export const CLI_COMMANDS = Object.freeze([
59
59
  ],
60
60
  prerequisites: [
61
61
  "Node 20 or newer",
62
- "A target directory that does not already exist",
62
+ "A target directory that does not exist yet, or holds nothing but .DS_Store, .localized, Icon\\r, ._*, Thumbs.db, desktop.ini, .mcp.json, .git/, .vscode/, .cursor/, .idea/ or .claude/ without settings.json, and is not the home directory, its Desktop, Documents or Downloads, the disk's root, a Claude Code Desktop no-folder session, or a Codex chat's folder (~/Documents/Codex and the folders directly in it)",
63
63
  "Non-interactive use requires directory, product ID, Agent Key, and app domain",
64
64
  ],
65
- effects: ["Creates a new local project; never initializes Git"],
65
+ effects: ["Creates a local project in a new folder, or in place in one that holds nothing but files the system or an editor put there; never overwrites a file and never initializes Git"],
66
66
  next: [
67
67
  "npm install --ignore-scripts",
68
68
  "Before the first build, CLAUDE.md step 5 (\"Every session starts the same way\"): with a code project, its agent/ replaces the scaffold's",
@@ -107,6 +107,8 @@ export const CLI_COMMANDS = Object.freeze([
107
107
  "With --yes, writes the latest version into package.json (exact) and runs npm install --ignore-scripts",
108
108
  "Does not rebuild dist/index.js or publish anything",
109
109
  "--refresh-instructions copies CLAUDE.md to CLAUDE.md.bak then writes the stub",
110
+ "With --yes or --refresh-instructions, writes the harness's skills (share-a-video) into .claude/skills/ from the installed version, keeping a copy that differs as <file>.bak; other skills there are left alone",
111
+ "Any foldspace command writes a harness skill the project is missing, so the first upgrade from a harness older than the skill still gets it",
110
112
  ],
111
113
  next: [
112
114
  "If outdated, ask the human, then run npx foldspace upgrade --yes",
@@ -440,6 +442,96 @@ export const CLI_COMMANDS = Object.freeze([
440
442
  "To go back to the local build: npx foldspace attach --daemon",
441
443
  ],
442
444
  }),
445
+ Object.freeze({
446
+ name: "record",
447
+ entry: "record.mjs",
448
+ group: "share",
449
+ summary: "Record a short video of the agent's key use cases, answered in the test window, for the human to share",
450
+ usage:
451
+ "foldspace record [--scenes <file>] [--preset landscape|square] [--no-end-card] [--from <record-dir>]",
452
+ risk: "browser-session",
453
+ environment: "local-chrome",
454
+ environmentVariables: ["FOLDSPACE_PROJECT_DIR", "CDP_PORT", "CHROME_PATH"],
455
+ capabilities: ["browser.cdp", "page.evaluate", "browser.launch"],
456
+ positionals: [],
457
+ options: [
458
+ value("--scenes", "file", "The scenes and their captions: { product, scenes: [{ question, capabilities, line }] }", {
459
+ default: "video/scenes.json",
460
+ }),
461
+ value("--preset", "landscape|square", "1920x1080 or 1080x1080; either way the whole product stays in the frame", {
462
+ default: "landscape",
463
+ choices: ["landscape", "square"],
464
+ }),
465
+ flag("--no-end-card", "Leave out the closing card"),
466
+ value(
467
+ "--from",
468
+ "record-dir",
469
+ "Make the video again from an earlier recording, with the capabilities and lines in the scenes file now; records nothing",
470
+ ),
471
+ ],
472
+ prerequisites: [
473
+ "The test window from `npm run inject`, signed in, with the agent on the page: `npx foldspace attach --daemon` running, or after `npx foldspace published`",
474
+ "attach without --test-mode",
475
+ "Each question already proven with `npx foldspace ask`",
476
+ "Google Chrome, which makes the video (some Chromium builds cannot encode H.264)",
477
+ ],
478
+ effects: [
479
+ "Asks each question in a new conversation, typed key by key: real conversations, in their Foldspace dashboard",
480
+ "The video shows real data from the signed-in account; nothing is masked. Whether to share it is the human's call",
481
+ "Hides the pill and the corner tag while it records, and puts them back after - also when it is stopped (Ctrl-C, a timeout)",
482
+ "Runs for a few minutes: give it a 10-minute timeout",
483
+ "Records with Chrome's own screen capture (no screen-recording permission) into .foldspace-dev/record/, keeping the 3 latest finished recordings",
484
+ "Makes the video in a separate hidden Chrome; writes video/<product>-<date>.mp4 and a .jpg poster, and video/.gitignore so neither is saved with the code",
485
+ "Uploads nothing and posts nothing",
486
+ ],
487
+ next: [
488
+ "Read the poster and each scene's lastFrame: every answer complete, none an error. The human watches the video",
489
+ "Hand over the MP4 and the poster: say plainly it shows real data from their account, and that sharing it is their call",
490
+ "To change a scene's capabilities or line: edit the scenes file, then `npx foldspace record --from <recordDir>`",
491
+ ],
492
+ }),
493
+ Object.freeze({
494
+ name: "share",
495
+ entry: "share.mjs",
496
+ group: "share",
497
+ summary: "Size and check a video for a Foldspace share link, then upload it with the targets create_video_share returned",
498
+ usage: "foldspace share <inspect|upload> [<video.mp4>] [--poster <file.jpg>] [--targets '<json>']",
499
+ risk: "remote-upload",
500
+ environment: "node",
501
+ environmentVariables: ["FOLDSPACE_PROJECT_DIR"],
502
+ capabilities: ["video.upload"],
503
+ positionals: [
504
+ Object.freeze({
505
+ name: "action",
506
+ required: true,
507
+ description:
508
+ "inspect: size and check the MP4 and its poster here, uploading nothing | upload: send both to Foldspace's video storage with the targets create_video_share returned",
509
+ }),
510
+ Object.freeze({
511
+ name: "video",
512
+ required: false,
513
+ description: "The MP4; default the newest video/*.mp4 that `record` made",
514
+ }),
515
+ ],
516
+ options: [
517
+ value("--poster", "file.jpg", "The JPEG poster", { default: "the MP4's name with .jpg" }),
518
+ value("--targets", "json", "upload only: the targets JSON exactly as the upload command from create_video_share has it"),
519
+ ],
520
+ prerequisites: [
521
+ "An H.264 MP4 with its moov box first (what `record` makes) and a JPEG poster",
522
+ "upload: the human said yes to a public link, and the Foldspace MCP tool create_video_share returned the upload command (its targets last 30 minutes)",
523
+ ],
524
+ effects: [
525
+ "inspect reads the two files and nothing else",
526
+ "upload sends the MP4 and the poster to Foldspace's video storage, and only there; it reads no Foldspace sign-in",
527
+ "Never prints the upload targets: each is a one-time credential for one file",
528
+ "Anyone with the link publish_video_share returns can watch the video, which shows real data from the account",
529
+ ],
530
+ next: [
531
+ "After inspect, with the human's yes: create_video_share with a title, video_bytes and poster_bytes, then run its upload command",
532
+ "After upload: publish_video_share with the share_id; give the human the link",
533
+ ],
534
+ }),
443
535
  Object.freeze({
444
536
  name: "deploy",
445
537
  // Foldspace-internal: it uploads to Foldspace's own storage, which no
@@ -492,12 +584,14 @@ export const CAPABILITY_CATALOGUE = Object.freeze([
492
584
  ["page.evaluate", "Inspect bounded SDK and registration state"],
493
585
  ["page.mutate", "Prepare and restore a matching development page"],
494
586
  ["artifact.publish", "Write the built artifact to remote storage"],
587
+ ["video.upload", "Upload a video and its poster for a Foldspace share link, with targets the Foldspace MCP issued"],
495
588
  ]);
496
589
 
497
590
  export const RISK_CATALOGUE = Object.freeze([
498
591
  ["local-write", "Writes only inside the consuming project"],
499
592
  ["browser-session", "Launches or temporarily mutates a local browser session"],
500
593
  ["remote-publish", "Writes the action artifact to remote storage"],
594
+ ["remote-upload", "Uploads files the human chose to share to Foldspace's video storage"],
501
595
  ]);
502
596
 
503
597
  export const ENVIRONMENT_CATALOGUE = Object.freeze([
package/src/init.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import readline from "node:readline/promises";
5
6
  import { stdin as input, stdout as output } from "node:process";
@@ -299,13 +300,11 @@ function createTemplateValues(config, harnessVersion) {
299
300
  throw new Error("Display name must be a non-empty single line and cannot contain '{{'.");
300
301
  }
301
302
 
302
- const packageName = toPackageName(displayName);
303
- if (!packageName) {
304
- throw new Error("Display name must contain at least one letter or number.");
305
- }
306
-
307
303
  const productId = validateProductId(config.productId);
308
304
  const agentApiName = validateIdentifier(config.agentApiName, "Agent Key");
305
+ // A name in another alphabet, such as a folder the human named, has no
306
+ // letters a package name can use; the Agent Key always has.
307
+ const packageName = toPackageName(displayName) || toPackageName(agentApiName);
309
308
  const region = parseRegion(config.region || DEFAULT_REGION);
310
309
  const target = normalizeTarget(config.domain);
311
310
  const hosts =
@@ -362,14 +361,17 @@ function copyTemplate(templateRoot, targetRoot, values) {
362
361
  const sourcePath = path.join(templateRoot, relativePath);
363
362
  // npm excludes .gitignore files from package tarballs, so the bundled
364
363
  // template uses a publish-safe name and restores it during generation.
365
- // The permission allowlist lives in .claude/, kept flat here for the
366
- // same reason.
364
+ // The permission allowlist and the skills live in .claude/, kept flat
365
+ // here for the same reason - and so a session working on the harness
366
+ // itself does not pick the skills up as its own.
367
367
  const outputPath =
368
368
  relativePath === "gitignore"
369
369
  ? ".gitignore"
370
370
  : relativePath === "claude-settings.json"
371
371
  ? path.join(".claude", "settings.json")
372
- : relativePath;
372
+ : relativePath === "skills" || relativePath.startsWith(`skills${path.sep}`)
373
+ ? path.join(".claude", relativePath)
374
+ : relativePath;
373
375
  const targetPath = path.join(targetRoot, outputPath);
374
376
  assertInside(targetRoot, targetPath);
375
377
 
@@ -401,10 +403,147 @@ function validateGeneratedProject(projectDir) {
401
403
  }
402
404
  }
403
405
 
406
+ // What a folder the human made for their agent may already hold: files the
407
+ // system, an editor or Claude Code put there. The template writes none of them
408
+ // except .claude/settings.json, and init leaves each one as it is.
409
+ const ignorableFiles = new Set([".ds_store", ".localized", "icon\r", "thumbs.db", "desktop.ini", ".mcp.json"]);
410
+ const ignorableDirectories = new Set([".claude", ".git", ".vscode", ".cursor", ".idea"]);
411
+
412
+ function isIgnorableEntry(entry) {
413
+ const name = entry.name.toLowerCase();
414
+ if (entry.isDirectory()) {
415
+ return ignorableDirectories.has(name);
416
+ }
417
+ // A .git file, not a folder, belongs to another repository's worktree or
418
+ // submodule, so it is not ignorable.
419
+ return entry.isFile() && (ignorableFiles.has(name) || name.startsWith("._"));
420
+ }
421
+
422
+ // What stops init from filling an existing folder; an empty list means nothing.
423
+ function existingTargetBlockers(targetDir) {
424
+ let entries;
425
+ try {
426
+ entries = fs.readdirSync(targetDir, { withFileTypes: true });
427
+ } catch (error) {
428
+ throw new Error(`Refusing to set up a project in ${targetDir}: it cannot be read (${error.code || error.message}).`);
429
+ }
430
+ const blockers = entries
431
+ .filter((entry) => !isIgnorableEntry(entry))
432
+ .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
433
+ .sort((a, b) => a.localeCompare(b));
434
+ // The one template file that lands inside an ignorable folder. lstat, so a
435
+ // link there counts even when what it points to is missing.
436
+ if (fs.lstatSync(path.join(targetDir, ".claude", "settings.json"), { throwIfNoEntry: false })) {
437
+ blockers.push(path.join(".claude", "settings.json"));
438
+ }
439
+ return blockers;
440
+ }
441
+
442
+ function realPath(target) {
443
+ try {
444
+ return fs.realpathSync(target);
445
+ } catch {
446
+ return path.resolve(target);
447
+ }
448
+ }
449
+
450
+ // Folders init never fills, even empty: the home folder and its own Desktop,
451
+ // Documents and Downloads, the disk's root, the folders Claude Code
452
+ // Desktop makes for a session started with no folder, which the human cannot
453
+ // find again, and Codex's folder for chats with no project
454
+ // (~/Documents/Codex) and each chat's own folder in it.
455
+ function personalFolderReason(targetDir) {
456
+ const target = realPath(targetDir);
457
+ const home = realPath(os.homedir());
458
+ if (path.dirname(target) === target) {
459
+ return "it is the disk's root";
460
+ }
461
+ if (target === home) {
462
+ return "it is the home folder";
463
+ }
464
+ if (path.dirname(target) === home && ["desktop", "documents", "downloads"].includes(path.basename(target).toLowerCase())) {
465
+ return `it is the home folder's ${path.basename(target)}`;
466
+ }
467
+ if (target.split(path.sep).includes("scratch-workspaces")) {
468
+ return "it is a temporary session folder";
469
+ }
470
+ const codexChats = path.join(home, "documents", "codex");
471
+ const lower = target.toLowerCase();
472
+ if (lower === codexChats.toLowerCase() || path.dirname(lower) === codexChats.toLowerCase()) {
473
+ return "it is Codex's folder for chats with no project; make the agent's folder next to the chat's, in ~/Documents/Codex";
474
+ }
475
+ return null;
476
+ }
477
+
478
+ function assertFillableTarget(targetDir) {
479
+ if (!fs.statSync(targetDir).isDirectory()) {
480
+ throw new Error(`Refusing to overwrite existing path: ${targetDir}`);
481
+ }
482
+ if (fs.existsSync(path.join(targetDir, "foldspace.dev.json"))) {
483
+ throw new Error(`${targetDir} is already a Foldspace project. Open it instead of running init.`);
484
+ }
485
+ const reason = personalFolderReason(targetDir);
486
+ if (reason) {
487
+ throw new Error(`Refusing to set up a project in ${targetDir}: ${reason}. Pass a new folder instead.`);
488
+ }
489
+ const blockers = existingTargetBlockers(targetDir);
490
+ if (blockers.length > 0) {
491
+ const shown = blockers.slice(0, 5).join(", ") + (blockers.length > 5 ? `, and ${blockers.length - 5} more` : "");
492
+ throw new Error(
493
+ `Refusing to set up a project in ${targetDir}: it already holds ${shown}. ` +
494
+ "init sets up a new folder, or one that holds nothing but files the system or an editor put there.",
495
+ );
496
+ }
497
+ }
498
+
499
+ // Fill an existing folder without replacing it, so a session working in it
500
+ // stays in the same folder. A file that is already there is never
501
+ // overwritten, and on any error everything this call created is removed.
502
+ // foldspace.dev.json goes in last: init and the playbook take it to mean a
503
+ // finished project, so a fill that is cut short never looks like one.
504
+ function copyIntoExisting(sourceRoot, targetRoot) {
505
+ const created = [];
506
+ const lastAtRoot = (entries, relativeDir) =>
507
+ relativeDir === ""
508
+ ? [...entries.filter((entry) => entry.name !== "foldspace.dev.json"), ...entries.filter((entry) => entry.name === "foldspace.dev.json")]
509
+ : entries;
510
+ const walk = (relativeDir) => {
511
+ for (const entry of lastAtRoot(fs.readdirSync(path.join(sourceRoot, relativeDir), { withFileTypes: true }), relativeDir)) {
512
+ const relativePath = path.join(relativeDir, entry.name);
513
+ const sourcePath = path.join(sourceRoot, relativePath);
514
+ const targetPath = path.join(targetRoot, relativePath);
515
+ if (entry.isDirectory()) {
516
+ try {
517
+ fs.mkdirSync(targetPath);
518
+ created.push(targetPath);
519
+ } catch (error) {
520
+ // .claude/ is the one template folder the human's folder may hold.
521
+ if (error?.code !== "EEXIST" || relativePath !== ".claude") {
522
+ throw error;
523
+ }
524
+ }
525
+ walk(relativePath);
526
+ continue;
527
+ }
528
+ fs.copyFileSync(sourcePath, targetPath, fs.constants.COPYFILE_EXCL);
529
+ created.push(targetPath);
530
+ }
531
+ };
532
+ try {
533
+ walk("");
534
+ } catch (error) {
535
+ for (const createdPath of created.reverse()) {
536
+ fs.rmSync(createdPath, { recursive: true, force: true });
537
+ }
538
+ throw error;
539
+ }
540
+ }
541
+
404
542
  export function scaffoldProject(config, options = {}) {
405
543
  const targetDir = config.targetDir;
406
- if (fs.existsSync(targetDir)) {
407
- throw new Error(`Refusing to overwrite existing path: ${targetDir}`);
544
+ const inPlace = fs.existsSync(targetDir);
545
+ if (inPlace) {
546
+ assertFillableTarget(targetDir);
408
547
  }
409
548
 
410
549
  const templateRoot = options.templateRoot || defaultTemplateRoot;
@@ -414,17 +553,29 @@ export function scaffoldProject(config, options = {}) {
414
553
 
415
554
  const harnessVersion = options.harnessVersion || readHarnessVersion(options.packageRoot || packageRoot);
416
555
  const values = createTemplateValues(config, harnessVersion);
556
+ // A new folder is rendered next to where it goes, so it can be renamed into
557
+ // place in one step. Filling a folder copies file by file, so it renders in
558
+ // the system's temporary folder and leaves nothing beside the human's folder.
417
559
  const parentDir = path.dirname(targetDir);
418
560
  fs.mkdirSync(parentDir, { recursive: true });
419
- const temporaryDir = fs.mkdtempSync(path.join(parentDir, `.${path.basename(targetDir)}.foldspace-init-`));
561
+ const temporaryDir = fs.mkdtempSync(
562
+ path.join(inPlace ? os.tmpdir() : parentDir, `.${path.basename(targetDir)}.foldspace-init-`),
563
+ );
420
564
 
421
565
  try {
422
566
  copyTemplate(templateRoot, temporaryDir, values);
423
567
  validateGeneratedProject(temporaryDir);
424
- if (fs.existsSync(targetDir)) {
425
- throw new Error(`Target path appeared while initializing: ${targetDir}`);
568
+ if (inPlace) {
569
+ // Checked again: something may have landed there while rendering.
570
+ assertFillableTarget(targetDir);
571
+ copyIntoExisting(temporaryDir, targetDir);
572
+ fs.rmSync(temporaryDir, { recursive: true, force: true });
573
+ } else {
574
+ if (fs.existsSync(targetDir)) {
575
+ throw new Error(`Target path appeared while initializing: ${targetDir}`);
576
+ }
577
+ fs.renameSync(temporaryDir, targetDir);
426
578
  }
427
- fs.renameSync(temporaryDir, targetDir);
428
579
  } catch (error) {
429
580
  fs.rmSync(temporaryDir, { recursive: true, force: true });
430
581
  throw error;
@@ -434,6 +585,7 @@ export function scaffoldProject(config, options = {}) {
434
585
  targetDir,
435
586
  packageName: values.PACKAGE_NAME,
436
587
  harnessVersion,
588
+ inPlace,
437
589
  };
438
590
  }
439
591
 
@@ -496,11 +648,13 @@ export async function runInit(argv, options = {}) {
496
648
  const log = options.log || console.log;
497
649
  const installed = await maybeInstallDependencies(result.targetDir, options);
498
650
 
499
- log(`Created Foldspace project at ${result.targetDir}`);
651
+ log(`${result.inPlace ? "Set up Foldspace project in" : "Created Foldspace project at"} ${result.targetDir}`);
500
652
  log(`Using @foldspace_npm/harness ${result.harnessVersion}`);
501
653
  log("");
502
654
  log("Next steps:");
503
- log(` cd ${result.targetDir}`);
655
+ if (path.resolve(process.cwd()) !== result.targetDir) {
656
+ log(` cd ${result.targetDir}`);
657
+ }
504
658
  if (!installed) {
505
659
  log(" npm install --ignore-scripts");
506
660
  }
@@ -0,0 +1,283 @@
1
+ // Driving the agent's panel the way a person does: open it, type into the
2
+ // composer, press Enter, read the screen. Shared by `foldspace ask` and
3
+ // `foldspace record`.
4
+ //
5
+ // Nothing here reaches into the SDK's internals, patches fetch or injects into
6
+ // the panel. The panel is the SDK's iframe#eucera-agent-iframe; its document is
7
+ // readable from the page, so the reply is read from what is on screen.
8
+
9
+ import { createSettleTracker, replyAfter, squash } from "./ask-core.mjs";
10
+ import { sleep } from "./cdp-client.mjs";
11
+
12
+ export const PANEL_ID = "eucera-agent-iframe";
13
+
14
+ // Shared by every page snippet: the panel document, or null while it loads.
15
+ export const PANEL_DOC = `
16
+ const frame = document.getElementById(${JSON.stringify(PANEL_ID)});
17
+ let doc = null;
18
+ try { doc = frame && frame.contentDocument; } catch {}
19
+ `;
20
+
21
+ // The agent handle: the one attach's bootstrap made, else the page's own SDK
22
+ // handle for this project's agent. Defines `sdk` and `agent` for the snippet
23
+ // that follows.
24
+ const AGENT_HANDLE = (agentApiName) => `
25
+ const wanted = ${JSON.stringify(agentApiName || "")};
26
+ const namespace = typeof window.__FOLD_SPACE__ === "string" && window.__FOLD_SPACE__
27
+ ? window.__FOLD_SPACE__ : "foldspace";
28
+ const sdk = window[namespace];
29
+ let agent = window.__FOLDSPACE_AGENT__ || null;
30
+ if (!agent && typeof sdk?.agentIds === "function") {
31
+ const ids = sdk.agentIds().filter((id) => typeof id === "string" && id.indexOf("-") > 0);
32
+ const id = ids.find((item) => item.slice(item.indexOf("-") + 1) === wanted) || ids[0];
33
+ if (id) {
34
+ const cut = id.indexOf("-");
35
+ agent = sdk.agent({ apiName: id.slice(cut + 1), mode: id.slice(0, cut).toUpperCase() });
36
+ }
37
+ }
38
+ `;
39
+
40
+ export async function panelState(page) {
41
+ return page.evaluate(`${PANEL_DOC}
42
+ if (!doc) return { frame: !!frame, composer: false };
43
+ const box = doc.querySelector("textarea");
44
+ const rect = frame.getBoundingClientRect();
45
+ return { frame: true, composer: !!box, visible: rect.width > 50 && rect.height > 50 };`);
46
+ }
47
+
48
+ // open() is the SDK's public "show the panel" call, the same thing the
49
+ // launcher button does. The handle comes from the bootstrap when attach made
50
+ // one, else from the page's own SDK.
51
+ export async function openPanel(page, agentApiName) {
52
+ return page.evaluate(`${AGENT_HANDLE(agentApiName)}
53
+ if (typeof agent?.open !== "function") return { sdk: !!sdk, opened: false };
54
+ try { agent.open(); return { sdk: true, opened: true }; }
55
+ catch (error) { return { sdk: true, opened: false, error: String(error?.message || error) }; }`);
56
+ }
57
+
58
+ /**
59
+ * Open the panel until its composer shows. The composer does not exist until
60
+ * the panel has opened, and one open() is not always enough, so it is retried
61
+ * inside the poll. Gives up early when there is no panel and no SDK at all.
62
+ */
63
+ export async function openComposer(page, agentApiName, { waitS = 30 } = {}) {
64
+ let state = await panelState(page);
65
+ let opened = { sdk: false, opened: false };
66
+ for (let tick = 0; tick < waitS && !(state.composer && state.visible); tick++) {
67
+ opened = await openPanel(page, agentApiName).catch(() => opened);
68
+ await sleep(1000);
69
+ state = await panelState(page).catch(() => state);
70
+ if (tick === 5 && !state.frame && !opened.sdk) break;
71
+ }
72
+ return { state, opened };
73
+ }
74
+
75
+ export async function readPanel(page, cardsBefore) {
76
+ return page.evaluate(`${PANEL_DOC}
77
+ if (!doc || !doc.body) return { panel: "", cards: [], cardFrames: 0, newCardFrames: 0 };
78
+ const squash = (text) => (text || "").replace(/\\s+/g, " ").trim();
79
+ const frames = Array.from(doc.querySelectorAll("iframe"));
80
+ const cards = [];
81
+ let painted = 0;
82
+ // Cards from earlier turns are still in the panel; only the new ones count.
83
+ for (const card of frames.slice(${Number(cardsBefore) || 0})) {
84
+ const rect = card.getBoundingClientRect();
85
+ if (rect.width > 20 && rect.height > 20) painted += 1;
86
+ try {
87
+ const body = card.contentDocument && card.contentDocument.body;
88
+ if (body) cards.push(squash(body.innerText || body.textContent));
89
+ } catch {}
90
+ }
91
+ return {
92
+ panel: squash(doc.body.innerText || doc.body.textContent),
93
+ cards: cards.filter(Boolean),
94
+ cardFrames: frames.length,
95
+ newCardFrames: painted,
96
+ };`);
97
+ }
98
+
99
+ /** Where the panel sits in the page, in CSS pixels, with the viewport size; null without one. */
100
+ export async function panelRect(page) {
101
+ return page.evaluate(`${PANEL_DOC}
102
+ if (!frame) return null;
103
+ const rect = frame.getBoundingClientRect();
104
+ return {
105
+ x: rect.left, y: rect.top, width: rect.width, height: rect.height,
106
+ viewportWidth: window.innerWidth, viewportHeight: window.innerHeight,
107
+ };`);
108
+ }
109
+
110
+ async function focusComposer(page) {
111
+ return page.evaluate(`${PANEL_DOC}
112
+ const box = doc && doc.querySelector("textarea");
113
+ if (!box) return false;
114
+ window.focus(); frame.contentWindow.focus(); box.focus();
115
+ return doc.activeElement === box;`);
116
+ }
117
+
118
+ export async function composerValue(page) {
119
+ return page
120
+ .evaluate(`${PANEL_DOC}
121
+ const box = doc && doc.querySelector("textarea");
122
+ return box ? box.value : "";`)
123
+ .catch(() => "");
124
+ }
125
+
126
+ /** Enter as real key events through the browser's input pipeline. */
127
+ export async function pressEnter(page) {
128
+ const enter = {
129
+ key: "Enter",
130
+ code: "Enter",
131
+ windowsVirtualKeyCode: 13,
132
+ nativeVirtualKeyCode: 13,
133
+ };
134
+ await page.send("Input.dispatchKeyEvent", { type: "rawKeyDown", ...enter });
135
+ await page.send("Input.dispatchKeyEvent", { type: "char", ...enter, text: "\r" });
136
+ await page.send("Input.dispatchKeyEvent", { type: "keyUp", ...enter });
137
+ }
138
+
139
+ // Real key events through the browser's input pipeline, like a person at the
140
+ // keyboard. Frameworks that ignore synthetic DOM events still hear these.
141
+ export async function typeLikeAPerson(page, text) {
142
+ const focused = await focusComposer(page);
143
+ if (!focused) return false;
144
+ await page.send("Input.insertText", { text });
145
+ const typed = await composerValue(page);
146
+ if (squash(typed) !== text) return false;
147
+ await pressEnter(page);
148
+ return true;
149
+ }
150
+
151
+ // The same gesture delivered as DOM events on the composer; still only typing,
152
+ // still nothing injected. `submit` false fills the box and presses nothing.
153
+ async function setComposerWithDomEvents(page, text, { submit = true } = {}) {
154
+ return page.evaluate(`${PANEL_DOC}
155
+ const box = doc && doc.querySelector("textarea");
156
+ if (!box) return false;
157
+ const view = frame.contentWindow;
158
+ const setValue = Object.getOwnPropertyDescriptor(view.HTMLTextAreaElement.prototype, "value").set;
159
+ box.focus();
160
+ setValue.call(box, ${JSON.stringify(text)});
161
+ box.dispatchEvent(new view.Event("input", { bubbles: true }));
162
+ if (${submit ? "true" : "false"}) {
163
+ for (const type of ["keydown", "keypress", "keyup"]) {
164
+ box.dispatchEvent(new view.KeyboardEvent(type, {
165
+ key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true, cancelable: true,
166
+ }));
167
+ }
168
+ }
169
+ return true;`);
170
+ }
171
+
172
+ // A background or unfocused tab can swallow CDP input. Same gesture, delivered
173
+ // as DOM events on the composer.
174
+ export async function typeWithDomEvents(page, text) {
175
+ return setComposerWithDomEvents(page, text, { submit: true });
176
+ }
177
+
178
+ /**
179
+ * Type the question and send it, the way `ask` always has: the keyboard
180
+ * first; if the question is still sitting in the composer afterwards, Enter
181
+ * never landed, so the same thing again as DOM events.
182
+ */
183
+ export async function sendQuestion(page, question) {
184
+ let typedBy = "keyboard";
185
+ let sent = await typeLikeAPerson(page, question).catch(() => false);
186
+ await sleep(700);
187
+ // Still sitting in the composer means Enter never landed.
188
+ if (!sent || squash(await composerValue(page)) === question) {
189
+ typedBy = "dom-events";
190
+ sent = await typeWithDomEvents(page, question).catch(() => false);
191
+ }
192
+ return { sent, typedBy };
193
+ }
194
+
195
+ /**
196
+ * Type the question one character at a time, so a recording shows it being
197
+ * typed, then press Enter. Each character goes through the keyboard pipeline;
198
+ * if the window swallows CDP input, each one is set as a DOM input event
199
+ * instead - it still appears key by key. `delayMs` is the pause between keys.
200
+ * Returns { sent, typedBy, enteredAt }: enteredAt is when the Enter that sent
201
+ * it was pressed (ms), for a recording's timeline.
202
+ */
203
+ export async function typeKeyByKey(page, question, { delayMs = 45 } = {}) {
204
+ const characters = [...question];
205
+ let typedBy = "keyboard";
206
+ if (!(await focusComposer(page).catch(() => false))) return { sent: false, typedBy };
207
+ let typed = "";
208
+ for (const character of characters) {
209
+ typed += character;
210
+ if (typedBy === "keyboard") {
211
+ await page.send("Input.insertText", { text: character }).catch(() => {});
212
+ // Checked on the first key only: the composer either hears CDP or not.
213
+ if (typed.length === 1 && (await composerValue(page)) !== typed) typedBy = "dom-events";
214
+ }
215
+ if (typedBy === "dom-events") {
216
+ await setComposerWithDomEvents(page, typed, { submit: false }).catch(() => false);
217
+ }
218
+ await sleep(delayMs);
219
+ }
220
+ if (squash(await composerValue(page)) !== squash(question)) return { sent: false, typedBy, enteredAt: null };
221
+ await sleep(250);
222
+ let enteredAt = Date.now();
223
+ await pressEnter(page).catch(() => {});
224
+ await sleep(500);
225
+ if (squash(await composerValue(page)) === squash(question)) {
226
+ // Enter never landed: the same key as DOM events.
227
+ enteredAt = Date.now();
228
+ await typeWithDomEvents(page, question).catch(() => false);
229
+ await sleep(400);
230
+ typedBy = "dom-events";
231
+ }
232
+ return { sent: squash(await composerValue(page)) !== squash(question), typedBy, enteredAt };
233
+ }
234
+
235
+ /**
236
+ * A fresh conversation, the way a person starts one: the New Chat button in
237
+ * the panel's header (its class is in both header layouts of the panel).
238
+ * Returns false when this panel shows no such button.
239
+ */
240
+ export async function clickNewChat(page) {
241
+ return page.evaluate(`${PANEL_DOC}
242
+ const button = doc && doc.querySelector(".eucera-copilot-header-new-chat-icon");
243
+ if (!button) return false;
244
+ button.click();
245
+ return true;`);
246
+ }
247
+
248
+ /**
249
+ * The SDK's own way to put a question to the agent in a new conversation:
250
+ * agent.message(text, { newChat: true, autoSend: true }). The question is
251
+ * sent at once - nothing is typed - so it is only the fallback for a panel
252
+ * with no New Chat button.
253
+ */
254
+ export async function messageInNewChat(page, agentApiName, question) {
255
+ return page.evaluate(`${AGENT_HANDLE(agentApiName)}
256
+ if (typeof agent?.message !== "function") return false;
257
+ agent.message(${JSON.stringify(question)}, { newChat: true, autoSend: true });
258
+ return true;`);
259
+ }
260
+
261
+ /**
262
+ * Poll the panel until the reply to `question` settles or `timeoutS` passes.
263
+ * `onPoll` sees each observation (with its time), for a caller that wants to
264
+ * know when the reply first appeared and when it last changed.
265
+ */
266
+ export async function waitForReply(
267
+ page,
268
+ question,
269
+ { cardsBefore = 0, timeoutS = 90, pollMs = 1500, tracker = createSettleTracker(), onPoll } = {},
270
+ ) {
271
+ const deadline = Date.now() + timeoutS * 1000;
272
+ let seen = { panel: "", cards: [], newCardFrames: 0 };
273
+ let reply = "";
274
+ let settled = false;
275
+ while (Date.now() < deadline && !settled) {
276
+ await sleep(pollMs);
277
+ seen = await readPanel(page, cardsBefore).catch(() => seen);
278
+ reply = replyAfter(seen.panel, question);
279
+ settled = tracker.observe({ reply, cards: seen.cards }).settled;
280
+ if (onPoll) onPoll({ at: Date.now(), reply, seen, settled });
281
+ }
282
+ return { settled, reply, seen };
283
+ }