@foldspace_npm/harness 0.1.30 → 0.1.32

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 (40) hide show
  1. package/CLAUDE.md +42 -11
  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/package.json +1 -1
  9. package/src/attach-daemon.mjs +18 -0
  10. package/src/badge-auto.mjs +38 -1
  11. package/src/cdp-client.mjs +7 -1
  12. package/src/chrome-binary.mjs +28 -0
  13. package/src/cli-help.mjs +1 -0
  14. package/src/cli-registry.mjs +53 -3
  15. package/src/init.mjs +170 -16
  16. package/src/panel-driver.mjs +283 -0
  17. package/src/session-events.mjs +1 -0
  18. package/src/upgrade.mjs +87 -1
  19. package/src/video/README.md +29 -0
  20. package/src/video/assets/foldspace-mark.svg +27 -0
  21. package/src/video/assets/logo-dark.svg +68 -0
  22. package/src/video/assets/logo-light.svg +56 -0
  23. package/src/video/brand.mjs +46 -0
  24. package/src/video/compose.mjs +476 -0
  25. package/src/video/composer/composer.mjs +554 -0
  26. package/src/video/composer/index.html +13 -0
  27. package/src/video/fonts/OFL.txt +93 -0
  28. package/src/video/fonts/Poppins-ExtraBold.woff2 +0 -0
  29. package/src/video/fonts/Poppins-Regular.woff2 +0 -0
  30. package/src/video/fonts/Poppins-SemiBold.woff2 +0 -0
  31. package/src/video/recorder.mjs +331 -0
  32. package/src/video/scenes.mjs +253 -0
  33. package/src/video/timeline.mjs +726 -0
  34. package/src/video/vendor/mp4-muxer.LICENSE +21 -0
  35. package/src/video/vendor/mp4-muxer.min.mjs +5 -0
  36. package/templates/agent-starter/AGENTS.md +24 -0
  37. package/templates/agent-starter/README.md +5 -0
  38. package/templates/agent-starter/claude-settings.json +1 -0
  39. package/templates/agent-starter/gitignore +3 -0
  40. package/templates/agent-starter/skills/share-a-video/SKILL.md +235 -0
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
+ }
@@ -13,6 +13,7 @@ export const SESSION_EVENTS = Object.freeze([
13
13
  "observe",
14
14
  "ask",
15
15
  "run",
16
+ "record",
16
17
  ]);
17
18
 
18
19
  export function eventsPath(projectDir) {
package/src/upgrade.mjs CHANGED
@@ -18,6 +18,8 @@ const instructionTemplatePath = path.join(
18
18
  "agent-starter",
19
19
  "CLAUDE.md",
20
20
  );
21
+ // Flat in the template (as claude-settings.json is); .claude/skills/ in a project.
22
+ const skillsTemplateRoot = path.join(packageRoot, "templates", "agent-starter", "skills");
21
23
  const tokenPattern = /\{\{([A-Z0-9_]+)\}\}/g;
22
24
 
23
25
  export function resolveProjectDir(env = process.env, cwd = process.cwd()) {
@@ -208,6 +210,84 @@ export function refreshInstructionStub(projectDir, options = {}) {
208
210
  return { written: true, backedUp, importPresent: true };
209
211
  }
210
212
 
213
+ /** Every file of the harness's skills: [{ rel, source }], rel under .claude/skills/. */
214
+ function shippedSkillFiles(skillsRoot) {
215
+ const files = [];
216
+ if (!fs.existsSync(skillsRoot)) return files;
217
+ const walk = (relative) => {
218
+ for (const entry of fs.readdirSync(path.join(skillsRoot, relative), { withFileTypes: true })) {
219
+ const rel = path.join(relative, entry.name);
220
+ if (entry.isDirectory()) walk(rel);
221
+ else if (entry.isFile()) files.push({ rel, source: path.join(skillsRoot, rel) });
222
+ }
223
+ };
224
+ walk("");
225
+ return files;
226
+ }
227
+
228
+ /**
229
+ * The skills this harness ships, written into the project's .claude/skills/
230
+ * from the installed package: `init` writes them with the rest of the
231
+ * template, and an upgrade brings them up to date the same way. Only the
232
+ * harness's own skill folders are written; any other skill there is left
233
+ * alone. They carry no template tokens, so they are copied as they are. A
234
+ * copy that differs is kept beside it as <file>.bak first - it may hold the
235
+ * human's own edits. Returns { written, backedUp }, paths relative to the
236
+ * project.
237
+ */
238
+ export function refreshProjectSkills(projectDir, { skillsRoot = skillsTemplateRoot } = {}) {
239
+ const written = [];
240
+ const backedUp = [];
241
+ for (const { rel, source } of shippedSkillFiles(skillsRoot)) {
242
+ const target = path.join(projectDir, ".claude", "skills", rel);
243
+ const content = fs.readFileSync(source);
244
+ let current = null;
245
+ try {
246
+ current = fs.readFileSync(target);
247
+ } catch {
248
+ current = null;
249
+ }
250
+ if (current && current.equals(content)) continue;
251
+ fs.mkdirSync(path.dirname(target), { recursive: true });
252
+ if (current) {
253
+ fs.writeFileSync(`${target}.bak`, current);
254
+ backedUp.push(path.join(".claude", "skills", `${rel}.bak`));
255
+ }
256
+ fs.writeFileSync(target, content);
257
+ written.push(path.join(".claude", "skills", rel));
258
+ }
259
+ return { written, backedUp };
260
+ }
261
+
262
+ /**
263
+ * Write any of the harness's skill files the project does not have yet, and
264
+ * touch nothing else. Every command runs it: `upgrade --yes` runs the harness
265
+ * version that was installed before, which may come from before the skills
266
+ * shipped, so the first upgrade to this one cannot write them - the next
267
+ * command does. Only in a Foldspace project (foldspace.dev.json). Returns the
268
+ * paths written; never throws.
269
+ */
270
+ export function ensureProjectSkills(projectDir, { skillsRoot = skillsTemplateRoot } = {}) {
271
+ const written = [];
272
+ try {
273
+ if (!fs.existsSync(path.join(projectDir, "foldspace.dev.json"))) return written;
274
+ for (const { rel, source } of shippedSkillFiles(skillsRoot)) {
275
+ const target = path.join(projectDir, ".claude", "skills", rel);
276
+ if (fs.existsSync(target)) continue;
277
+ fs.mkdirSync(path.dirname(target), { recursive: true });
278
+ fs.copyFileSync(source, target);
279
+ written.push(path.join(".claude", "skills", rel));
280
+ }
281
+ } catch {
282
+ // a read-only or odd project folder: the manual names the shipped copy too
283
+ }
284
+ return written;
285
+ }
286
+
287
+ function skillsReport(result) {
288
+ return result.backedUp.length ? { skills: result.written, skillsBackedUp: result.backedUp } : { skills: result.written };
289
+ }
290
+
211
291
  function cachePath(projectDir) {
212
292
  return path.join(projectDir, ".foldspace-dev", "harness-update.json");
213
293
  }
@@ -483,6 +563,7 @@ export async function runUpgrade(argv, options = {}) {
483
563
  instructionsImport: true,
484
564
  refreshed: refreshed.written,
485
565
  backedUp: refreshed.backedUp,
566
+ ...skillsReport(refreshProjectSkills(projectDir, options)),
486
567
  next: refreshed.written
487
568
  ? "Imported platform instructions from the installed harness."
488
569
  : report.next,
@@ -525,7 +606,9 @@ export async function runUpgrade(argv, options = {}) {
525
606
  }
526
607
 
527
608
  if (!shouldInstall && !shouldRefresh) {
528
- return report;
609
+ // Already on the latest: --yes still brings the skills up to date, for a
610
+ // project whose last upgrade ran a harness from before they shipped.
611
+ return parsed.yes ? { ...report, ...skillsReport(refreshProjectSkills(projectDir, options)) } : report;
529
612
  }
530
613
 
531
614
  if (shouldInstall) {
@@ -541,6 +624,8 @@ export async function runUpgrade(argv, options = {}) {
541
624
  if (shouldRefresh) {
542
625
  refreshed = refreshInstructionStub(projectDir, options);
543
626
  }
627
+ // After the install, this package's folder holds the new version's files.
628
+ const skills = skillsReport(refreshProjectSkills(projectDir, options));
544
629
 
545
630
  const nextReport = {
546
631
  ...report,
@@ -551,6 +636,7 @@ export async function runUpgrade(argv, options = {}) {
551
636
  instructionsImport: shouldRefresh ? true : report.instructionsImport,
552
637
  refreshed: refreshed.written,
553
638
  backedUp: refreshed.backedUp,
639
+ ...skills,
554
640
  };
555
641
  nextReport.next = nextAction(nextReport);
556
642
  return nextReport;