@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.
- package/CLAUDE.md +42 -11
- package/README.md +25 -2
- package/bin/ask.mjs +16 -145
- package/bin/attach.mjs +2 -0
- package/bin/cli.mjs +22 -0
- package/bin/inject.mjs +2 -14
- package/bin/record.mjs +412 -0
- package/package.json +1 -1
- package/src/attach-daemon.mjs +18 -0
- package/src/badge-auto.mjs +38 -1
- package/src/cdp-client.mjs +7 -1
- package/src/chrome-binary.mjs +28 -0
- package/src/cli-help.mjs +1 -0
- package/src/cli-registry.mjs +53 -3
- package/src/init.mjs +170 -16
- package/src/panel-driver.mjs +283 -0
- package/src/session-events.mjs +1 -0
- package/src/upgrade.mjs +87 -1
- package/src/video/README.md +29 -0
- package/src/video/assets/foldspace-mark.svg +27 -0
- package/src/video/assets/logo-dark.svg +68 -0
- package/src/video/assets/logo-light.svg +56 -0
- package/src/video/brand.mjs +46 -0
- package/src/video/compose.mjs +476 -0
- package/src/video/composer/composer.mjs +554 -0
- package/src/video/composer/index.html +13 -0
- package/src/video/fonts/OFL.txt +93 -0
- package/src/video/fonts/Poppins-ExtraBold.woff2 +0 -0
- package/src/video/fonts/Poppins-Regular.woff2 +0 -0
- package/src/video/fonts/Poppins-SemiBold.woff2 +0 -0
- package/src/video/recorder.mjs +331 -0
- package/src/video/scenes.mjs +253 -0
- package/src/video/timeline.mjs +726 -0
- package/src/video/vendor/mp4-muxer.LICENSE +21 -0
- package/src/video/vendor/mp4-muxer.min.mjs +5 -0
- package/templates/agent-starter/AGENTS.md +24 -0
- package/templates/agent-starter/README.md +5 -0
- package/templates/agent-starter/claude-settings.json +1 -0
- package/templates/agent-starter/gitignore +3 -0
- 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
|
|
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
|
-
|
|
407
|
-
|
|
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(
|
|
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 (
|
|
425
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/session-events.mjs
CHANGED
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
|
-
|
|
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;
|