@foldspace_npm/harness 0.1.31 → 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 +27 -8
- 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/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/bin/record.mjs
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// foldspace record - a short video of the agent working in the product, for
|
|
3
|
+
// the human to share: an opening card ("Meet the <product> Product Agent"),
|
|
4
|
+
// then one scene for each of the agent's key use cases, recorded live in the
|
|
5
|
+
// test window (the question typed key by key in a fresh conversation, the
|
|
6
|
+
// agent thinking, the answer arriving), a caption once each answer settles -
|
|
7
|
+
// the capabilities it shows, the question, what the user gets - an optional
|
|
8
|
+
// closing card, a small Foldspace logo over the scenes - in Foldspace's
|
|
9
|
+
// colours, not the app's.
|
|
10
|
+
//
|
|
11
|
+
// npx foldspace record [--scenes video/scenes.json] [--preset landscape|square]
|
|
12
|
+
// [--no-end-card] [--from <recordDir>]
|
|
13
|
+
//
|
|
14
|
+
// The video shows real data from the signed-in account: nothing is masked
|
|
15
|
+
// and nothing is uploaded. Whether to share it is the human's call.
|
|
16
|
+
// No test mode: a recording never arms it, and refuses to run while attach
|
|
17
|
+
// has it on (the panel would show its banner) - read from the daemon's log,
|
|
18
|
+
// or from the page, which attach marks either way.
|
|
19
|
+
//
|
|
20
|
+
// --from makes the video again from an earlier recording, with the
|
|
21
|
+
// capabilities and lines in the scenes file now - nothing is recorded,
|
|
22
|
+
// the window is not touched.
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
|
|
27
|
+
import { ATTACH_DAEMON_LOG_FILE, attachTestModeOn, liveDaemon, pageTestModeOn } from "../src/attach-daemon.mjs";
|
|
28
|
+
import { withBadgeOff } from "../src/badge-auto.mjs";
|
|
29
|
+
import { CdpPage, parseArgs, printJson } from "../src/cdp-client.mjs";
|
|
30
|
+
import { findChrome } from "../src/chrome-binary.mjs";
|
|
31
|
+
import { recordEvent } from "../src/session-events.mjs";
|
|
32
|
+
import { resolveProjectDir } from "../src/upgrade.mjs";
|
|
33
|
+
import {
|
|
34
|
+
PRESETS,
|
|
35
|
+
VideoError,
|
|
36
|
+
buildPlan,
|
|
37
|
+
checkRecordingMatches,
|
|
38
|
+
closeAllHeadless,
|
|
39
|
+
loadRecording,
|
|
40
|
+
needsChrome,
|
|
41
|
+
probeEncoder,
|
|
42
|
+
renderInChrome,
|
|
43
|
+
} from "../src/video/compose.mjs";
|
|
44
|
+
import { pruneRecordings, recordScene } from "../src/video/recorder.mjs";
|
|
45
|
+
import { ensureVideoIgnored, validateScenes, videoBaseName } from "../src/video/scenes.mjs";
|
|
46
|
+
import { TIMING } from "../src/video/timeline.mjs";
|
|
47
|
+
|
|
48
|
+
const { flags } = parseArgs(process.argv.slice(2), ["scenes", "preset", "from"]);
|
|
49
|
+
const projectDir = path.resolve(resolveProjectDir());
|
|
50
|
+
const scenesFile = path.resolve(projectDir, String(flags.scenes || path.join("video", "scenes.json")));
|
|
51
|
+
const preset = String(flags.preset || "landscape");
|
|
52
|
+
const recordRoot = path.join(projectDir, ".foldspace-dev", "record");
|
|
53
|
+
const startedAt = Date.now();
|
|
54
|
+
|
|
55
|
+
let page = null;
|
|
56
|
+
let finished = false;
|
|
57
|
+
// Where the run is, for a stop half-way: what to put back, what is kept.
|
|
58
|
+
const run = { phase: "checking", recordDir: null, recordingComplete: false };
|
|
59
|
+
// What a stopped run must undo, newest last: the badge, the screencast.
|
|
60
|
+
const undo = new Set();
|
|
61
|
+
|
|
62
|
+
function finish(result, code) {
|
|
63
|
+
if (finished) return;
|
|
64
|
+
finished = true;
|
|
65
|
+
recordEvent(projectDir, "record", {
|
|
66
|
+
ok: Boolean(result.ok),
|
|
67
|
+
scenes: result.scenes?.length ?? null,
|
|
68
|
+
...(result.ok ? {} : { code: result.code || "failed" }),
|
|
69
|
+
});
|
|
70
|
+
printJson(result);
|
|
71
|
+
page?.close();
|
|
72
|
+
process.exit(code);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The main flow's way out, awaited so nothing after it runs. While a stop is
|
|
76
|
+
// being handled, the stop's own report is the one that counts: whatever fails
|
|
77
|
+
// here then (the hidden Chrome closing, say) fails because of the stop.
|
|
78
|
+
function end(result, code) {
|
|
79
|
+
if (!run.stopping) finish(result, code);
|
|
80
|
+
return new Promise(() => {});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const progress = (line) => process.stderr.write(`record: ${line}\n`);
|
|
84
|
+
const rel = (file) => path.relative(projectDir, file) || file;
|
|
85
|
+
const fromAgain = (dir) =>
|
|
86
|
+
`The recording is kept: \`npx foldspace record --from ${rel(dir)}\` makes the video again without asking anything.`;
|
|
87
|
+
|
|
88
|
+
// Stopped half-way - Ctrl-C, or the shell's timeout. A `finally` never runs
|
|
89
|
+
// when the process is killed, so without this the pill and corner tag would
|
|
90
|
+
// stay hidden for the rest of the session, and a hidden Chrome would keep
|
|
91
|
+
// running. Put things back (briefly: each step gives up after 2 s), then say
|
|
92
|
+
// what happened in the same JSON as always.
|
|
93
|
+
function stop(signal) {
|
|
94
|
+
if (finished || run.stopping) return;
|
|
95
|
+
run.stopping = true;
|
|
96
|
+
const exitCode = signal === "SIGINT" ? 130 : signal === "SIGHUP" ? 129 : 143;
|
|
97
|
+
progress(`stopped (${signal}): putting the window back`);
|
|
98
|
+
const hardStop = setTimeout(() => process.exit(exitCode), 6000);
|
|
99
|
+
Promise.allSettled([...undo].reverse().map((step) => step()))
|
|
100
|
+
.then(() => closeAllHeadless())
|
|
101
|
+
.finally(() => {
|
|
102
|
+
clearTimeout(hardStop);
|
|
103
|
+
const kept = run.recordingComplete && run.recordDir;
|
|
104
|
+
finish(
|
|
105
|
+
{
|
|
106
|
+
ok: false,
|
|
107
|
+
code: "interrupted",
|
|
108
|
+
reason:
|
|
109
|
+
run.phase === "recording"
|
|
110
|
+
? `Stopped (${signal}) while recording; the pill and corner tag are back. A recording takes a few minutes: run it with a 10-minute timeout.`
|
|
111
|
+
: run.phase === "composing" && kept
|
|
112
|
+
? `Stopped (${signal}) while making the video. ${fromAgain(run.recordDir)}`
|
|
113
|
+
: `Stopped (${signal}) before anything was recorded.`,
|
|
114
|
+
...(run.recordDir ? { recordDir: run.recordDir } : {}),
|
|
115
|
+
},
|
|
116
|
+
exitCode,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => stop(signal));
|
|
121
|
+
|
|
122
|
+
function readScenes() {
|
|
123
|
+
let raw;
|
|
124
|
+
try {
|
|
125
|
+
raw = fs.readFileSync(scenesFile, "utf8");
|
|
126
|
+
} catch {
|
|
127
|
+
finish(
|
|
128
|
+
{
|
|
129
|
+
ok: false,
|
|
130
|
+
code: "no_scenes",
|
|
131
|
+
reason: `No scenes file at ${path.relative(projectDir, scenesFile) || scenesFile}. Agree 4-5 scenes with the human and write it first (the share-a-video skill shows the shape).`,
|
|
132
|
+
},
|
|
133
|
+
1,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
let parsed;
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(raw);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
finish({ ok: false, code: "bad_scenes", reason: `${path.basename(scenesFile)} is not valid JSON: ${error.message}` }, 1);
|
|
141
|
+
}
|
|
142
|
+
const checked = validateScenes(parsed);
|
|
143
|
+
if (!checked.ok) {
|
|
144
|
+
finish({ ok: false, code: "bad_scenes", reason: `Fix ${path.basename(scenesFile)} first.`, errors: checked.errors }, 1);
|
|
145
|
+
}
|
|
146
|
+
return checked;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const askAgain = (scene) => `Prove the question with \`npx foldspace ask "${scene.question}"\`, then record again.`;
|
|
150
|
+
|
|
151
|
+
const SCENE_REASONS = {
|
|
152
|
+
// As `ask` says it: while attach runs, its log knows why the panel is missing.
|
|
153
|
+
no_panel: () =>
|
|
154
|
+
liveDaemon(projectDir)
|
|
155
|
+
? "No agent panel on this page while attach runs. Read the lines under the last [lifecycle] line in .foldspace-dev/attach-daemon.log, then record again."
|
|
156
|
+
: "No agent panel on this page. Run `npx foldspace attach --daemon`, follow what it says about the page, then record again.",
|
|
157
|
+
no_composer: () =>
|
|
158
|
+
"The agent panel is on the page but never showed a composer. Run `npx foldspace observe screenshot` and look at the page.",
|
|
159
|
+
not_sent: (scene) => `The question could not be typed into the panel's composer. ${askAgain(scene)}`,
|
|
160
|
+
no_reply: (scene) => `No reply within the 90 s a scene may take. ${askAgain(scene)}`,
|
|
161
|
+
not_settled: (scene) => `The answer did not finish within the 90 s a scene may take. ${askAgain(scene)}`,
|
|
162
|
+
no_frames: () =>
|
|
163
|
+
"Chrome sent almost no pictures of the page: the test window must stay open and in view - not minimized, not covered. Ask the human to leave it that way, then record again.",
|
|
164
|
+
hidden: () =>
|
|
165
|
+
"The test window was minimized or covered while recording, so part of the scene is missing. Ask the human to keep it where they can see it, then record again.",
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const TEST_MODE_REASON =
|
|
169
|
+
"attach runs with --test-mode, so the panel shows its test-mode banner, and a video is recorded without test mode. " +
|
|
170
|
+
"The recording's conversations then appear in their Foldspace dashboard, next to their users' conversations: say so, and on their yes run `npx foldspace attach --stop`, " +
|
|
171
|
+
"then `npx foldspace attach --daemon` with the flags it ran with minus --test-mode, then record again. After the recording, attach again with --test-mode.";
|
|
172
|
+
|
|
173
|
+
/** attach's daemon log says "Test: on"; a foreground attach marks the page itself. */
|
|
174
|
+
async function testModeOn(page) {
|
|
175
|
+
const daemon = liveDaemon(projectDir);
|
|
176
|
+
if (daemon) {
|
|
177
|
+
try {
|
|
178
|
+
const log = fs.readFileSync(daemon.logPath || path.join(projectDir, ".foldspace-dev", ATTACH_DAEMON_LOG_FILE), "utf8");
|
|
179
|
+
if (attachTestModeOn(log)) return true;
|
|
180
|
+
} catch {
|
|
181
|
+
// no log to read: the page still says
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return pageTestModeOn(page);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function writeManifest(recordDir, manifest) {
|
|
188
|
+
fs.writeFileSync(path.join(recordDir, "record.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function record(config) {
|
|
192
|
+
page = await CdpPage.open({ projectDir, command: "record" });
|
|
193
|
+
if (await testModeOn(page)) await end({ ok: false, code: "test_mode", reason: TEST_MODE_REASON }, 1);
|
|
194
|
+
const agentApiName = page.info.target?.agentApiName;
|
|
195
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
196
|
+
const recordDir = path.join(recordRoot, stamp);
|
|
197
|
+
fs.mkdirSync(recordDir, { recursive: true });
|
|
198
|
+
run.recordDir = recordDir;
|
|
199
|
+
run.phase = "recording";
|
|
200
|
+
// `complete` only once every scene's answer settled: --from refuses the rest.
|
|
201
|
+
const manifest = {
|
|
202
|
+
version: 2,
|
|
203
|
+
createdAt: new Date().toISOString(),
|
|
204
|
+
complete: false,
|
|
205
|
+
scenes: config.scenes.map((scene, index) => ({ n: index + 1, dir: `scene-${index + 1}`, question: scene.question })),
|
|
206
|
+
};
|
|
207
|
+
writeManifest(recordDir, manifest);
|
|
208
|
+
|
|
209
|
+
const reports = [];
|
|
210
|
+
let failed = null;
|
|
211
|
+
// Nothing of the harness in the video: no pill, no corner tag. Focus
|
|
212
|
+
// emulation keeps the page painting and taking keys as if it were in front,
|
|
213
|
+
// without ever bringing the window forward.
|
|
214
|
+
const unfocus = () => page?.send("Emulation.setFocusEmulationEnabled", { enabled: false }, { timeoutMs: 2000 }).catch(() => {});
|
|
215
|
+
const stopCapture = () => page?.send("Page.stopScreencast", {}, { timeoutMs: 2000 }).catch(() => {});
|
|
216
|
+
await withBadgeOff(
|
|
217
|
+
page,
|
|
218
|
+
async () => {
|
|
219
|
+
await page.send("Emulation.setFocusEmulationEnabled", { enabled: true }).catch(() => {});
|
|
220
|
+
undo.add(unfocus);
|
|
221
|
+
undo.add(stopCapture);
|
|
222
|
+
try {
|
|
223
|
+
for (const [index, scene] of config.scenes.entries()) {
|
|
224
|
+
progress(`scene ${index + 1} of ${config.scenes.length}: "${scene.question}"`);
|
|
225
|
+
const report = await recordScene(page, {
|
|
226
|
+
question: scene.question,
|
|
227
|
+
dir: path.join(recordDir, `scene-${index + 1}`),
|
|
228
|
+
agentApiName,
|
|
229
|
+
});
|
|
230
|
+
reports.push({ n: index + 1, ...report });
|
|
231
|
+
Object.assign(manifest.scenes[index], { ok: Boolean(report.ok), ...(report.ok ? {} : { reason: report.reason }) });
|
|
232
|
+
writeManifest(recordDir, manifest);
|
|
233
|
+
if (!report.ok) {
|
|
234
|
+
failed = { n: index + 1, scene, report };
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} finally {
|
|
239
|
+
await unfocus();
|
|
240
|
+
undo.delete(unfocus);
|
|
241
|
+
undo.delete(stopCapture);
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
// The badge's own restore, for a run stopped half-way.
|
|
245
|
+
{ register: (restore) => undo.add(() => restore({ timeoutMs: 2000 })) },
|
|
246
|
+
);
|
|
247
|
+
undo.clear();
|
|
248
|
+
page.close();
|
|
249
|
+
page = null;
|
|
250
|
+
if (!failed) {
|
|
251
|
+
manifest.complete = true;
|
|
252
|
+
writeManifest(recordDir, manifest);
|
|
253
|
+
run.recordingComplete = true;
|
|
254
|
+
}
|
|
255
|
+
pruneRecordings(recordRoot, 3);
|
|
256
|
+
|
|
257
|
+
if (failed) {
|
|
258
|
+
const explain = SCENE_REASONS[failed.report.reason] || (() => failed.report.reason);
|
|
259
|
+
await end(
|
|
260
|
+
{
|
|
261
|
+
ok: false,
|
|
262
|
+
code: failed.report.reason,
|
|
263
|
+
reason: `Scene ${failed.n}: ${explain(failed.scene)}`,
|
|
264
|
+
question: failed.scene.question,
|
|
265
|
+
recordDir,
|
|
266
|
+
scenes: reports.map(sceneSummary),
|
|
267
|
+
},
|
|
268
|
+
1,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return { recordDir, reports };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function sceneSummary(report) {
|
|
275
|
+
return {
|
|
276
|
+
n: report.n,
|
|
277
|
+
question: report.question,
|
|
278
|
+
ok: Boolean(report.ok),
|
|
279
|
+
...(report.reason ? { reason: report.reason } : {}),
|
|
280
|
+
newChat: report.newChat,
|
|
281
|
+
typedBy: report.typedBy,
|
|
282
|
+
recordedS: report.end != null ? Math.round(report.end / 100) / 10 : null,
|
|
283
|
+
frames: report.frames,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The last recorded picture of each scene: the answer as it ended. */
|
|
288
|
+
function lastFrames(recording) {
|
|
289
|
+
return recording.scenes.map((scene) => {
|
|
290
|
+
const last = scene.frames[scene.frames.length - 1];
|
|
291
|
+
return last ? rel(path.join(recording.dir, scene.dir, last.file)) : null;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function compose(config, recordDir, chromePath) {
|
|
296
|
+
const recording = loadRecording(recordDir);
|
|
297
|
+
const mismatch = checkRecordingMatches(recording, config);
|
|
298
|
+
if (mismatch) throw new VideoError("scenes_changed", `${mismatch} Run \`npx foldspace record\` without --from.`);
|
|
299
|
+
run.recordingComplete = true;
|
|
300
|
+
const endCard = config.endCard && !flags["no-end-card"];
|
|
301
|
+
const plan = buildPlan({ recording, config, preset, endCard });
|
|
302
|
+
let shown = -1;
|
|
303
|
+
const rendered = await renderInChrome({
|
|
304
|
+
plan,
|
|
305
|
+
recordDir,
|
|
306
|
+
chromePath,
|
|
307
|
+
onProgress: ({ frame, of }) => {
|
|
308
|
+
const percent = Math.floor((frame / of) * 10) * 10;
|
|
309
|
+
if (percent > shown) {
|
|
310
|
+
shown = percent;
|
|
311
|
+
progress(`making the video: ${percent}%`);
|
|
312
|
+
}
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
const outDir = path.join(projectDir, "video");
|
|
316
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
317
|
+
// The video holds real data: never saved with the code, in any project.
|
|
318
|
+
ensureVideoIgnored(outDir);
|
|
319
|
+
const base = `${videoBaseName(config.product)}${preset === "square" ? "-square" : ""}`;
|
|
320
|
+
const video = path.join(outDir, `${base}.mp4`);
|
|
321
|
+
const poster = path.join(outDir, `${base}.jpg`);
|
|
322
|
+
fs.writeFileSync(video, rendered.mp4);
|
|
323
|
+
if (rendered.poster) fs.writeFileSync(poster, rendered.poster);
|
|
324
|
+
return {
|
|
325
|
+
video,
|
|
326
|
+
poster: rendered.poster ? poster : null,
|
|
327
|
+
durationS: Math.round(plan.layout.totalS * 10) / 10,
|
|
328
|
+
sizeMB: Math.round((rendered.mp4.length / 1024 / 1024) * 10) / 10,
|
|
329
|
+
width: plan.width,
|
|
330
|
+
height: plan.height,
|
|
331
|
+
preset,
|
|
332
|
+
codec: rendered.done?.codec || null,
|
|
333
|
+
endCard,
|
|
334
|
+
logoSpot: rendered.done?.logo || null,
|
|
335
|
+
...(plan.fit.overS > 0
|
|
336
|
+
? {
|
|
337
|
+
note: `${Math.round(plan.fit.totalS)} s, longer than the ${TIMING.maxTotalS} s it should be at most, with the waits already shortened: drop a scene, or pick questions with shorter answers.`,
|
|
338
|
+
}
|
|
339
|
+
: {}),
|
|
340
|
+
sceneSeconds: plan.clips.filter((clip) => clip.kind === "scene").map((clip) => Math.round(clip.durationS * 10) / 10),
|
|
341
|
+
lastFrames: lastFrames(recording),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function main() {
|
|
346
|
+
if (!PRESETS[preset]) {
|
|
347
|
+
await end({ ok: false, code: "bad_preset", reason: `Unknown --preset '${preset}': landscape or square.` }, 1);
|
|
348
|
+
}
|
|
349
|
+
const checked = readScenes();
|
|
350
|
+
const config = checked.value;
|
|
351
|
+
for (const warning of checked.warnings) progress(warning);
|
|
352
|
+
|
|
353
|
+
// Before minutes of recording: can this machine's Chrome make the video?
|
|
354
|
+
const chromePath = findChrome();
|
|
355
|
+
const probe = await probeEncoder({ chromePath });
|
|
356
|
+
if (!probe.ok) {
|
|
357
|
+
await end({ ok: false, code: probe.code, reason: probe.message || needsChrome(chromePath) }, 1);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let recordDir;
|
|
361
|
+
let reports = null;
|
|
362
|
+
if (flags.from) {
|
|
363
|
+
// As printed in the summary (from the project), or from where this runs.
|
|
364
|
+
const fromProject = path.resolve(projectDir, String(flags.from));
|
|
365
|
+
const fromHere = path.resolve(String(flags.from));
|
|
366
|
+
recordDir = fs.existsSync(fromProject) || !fs.existsSync(fromHere) ? fromProject : fromHere;
|
|
367
|
+
run.recordDir = recordDir;
|
|
368
|
+
} else {
|
|
369
|
+
({ recordDir, reports } = await record(config));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
run.phase = "composing";
|
|
373
|
+
progress("making the video in a hidden Chrome");
|
|
374
|
+
let made;
|
|
375
|
+
try {
|
|
376
|
+
made = await compose(config, recordDir, chromePath);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
const code = error instanceof VideoError ? error.code : "compose_failed";
|
|
379
|
+
// A finished recording is never lost to a failure here: say how to remake it.
|
|
380
|
+
const said = code === "no_h264" ? needsChrome(chromePath) : String(error?.message || error);
|
|
381
|
+
await end(
|
|
382
|
+
{
|
|
383
|
+
ok: false,
|
|
384
|
+
code,
|
|
385
|
+
reason: run.recordingComplete ? `${said} ${fromAgain(recordDir)}` : said,
|
|
386
|
+
recordDir,
|
|
387
|
+
},
|
|
388
|
+
1,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
const scenes = (reports ? reports.map(sceneSummary) : config.scenes.map((scene, index) => ({ n: index + 1, question: scene.question }))).map(
|
|
392
|
+
(scene, index) => ({ ...scene, inVideoS: made.sceneSeconds[index], lastFrame: made.lastFrames[index] }),
|
|
393
|
+
);
|
|
394
|
+
const { sceneSeconds: _sceneSeconds, lastFrames: _lastFrames, ...summary } = made;
|
|
395
|
+
await end(
|
|
396
|
+
{
|
|
397
|
+
ok: true,
|
|
398
|
+
...summary,
|
|
399
|
+
scenes,
|
|
400
|
+
recordDir,
|
|
401
|
+
ms: Date.now() - startedAt,
|
|
402
|
+
next: [
|
|
403
|
+
"Read the poster and each scene's lastFrame: every answer complete, none an error or \"that action isn't available\". The human watches the video",
|
|
404
|
+
"Hand over the MP4 and the poster. Say plainly: it shows real data from their account, and whether to share it is their call",
|
|
405
|
+
`To change a scene's capabilities or line, or the end card: edit ${rel(scenesFile)}, then \`npx foldspace record --from ${rel(recordDir)}\``,
|
|
406
|
+
],
|
|
407
|
+
},
|
|
408
|
+
0,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
main().catch((error) => end({ ok: false, code: "failed", reason: String(error?.message || error) }, 1));
|
package/package.json
CHANGED
package/src/attach-daemon.mjs
CHANGED
|
@@ -151,6 +151,24 @@ export async function waitForRegistration({
|
|
|
151
151
|
);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
/** attach prints "Test: on" when it starts with --test-mode (and "Test: off - ..." without). */
|
|
155
|
+
export function attachTestModeOn(logText) {
|
|
156
|
+
return /^Test:\s+on\b/m.test(String(logText ?? ""));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// attach marks the page with the test mode it set, so a command can see it
|
|
160
|
+
// even when attach runs in the foreground and keeps no daemon log.
|
|
161
|
+
export const PAGE_TEST_MODE_KEY = "__FOLDSPACE_TEST_MODE__";
|
|
162
|
+
|
|
163
|
+
/** Whether attach set test mode on this page; false when it cannot tell. */
|
|
164
|
+
export async function pageTestModeOn(page) {
|
|
165
|
+
try {
|
|
166
|
+
return (await page.evaluate(`return window.${PAGE_TEST_MODE_KEY} === true;`, { timeoutMs: 5000 })) === true;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
154
172
|
export function liveDaemon(root, isAlive = isProcessAlive) {
|
|
155
173
|
const state = readDaemonState(root);
|
|
156
174
|
if (!state?.pid || !isAlive(state.pid)) return null;
|
package/src/badge-auto.mjs
CHANGED
|
@@ -17,9 +17,10 @@ export async function readBadge(page) {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export async function writeBadge(page, payload) {
|
|
20
|
+
export async function writeBadge(page, payload, { timeoutMs } = {}) {
|
|
21
21
|
await page.evaluate(
|
|
22
22
|
`sessionStorage.setItem(${JSON.stringify(BADGE_KEY)}, ${JSON.stringify(JSON.stringify(payload))}); return true;`,
|
|
23
|
+
timeoutMs ? { timeoutMs } : undefined,
|
|
23
24
|
);
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -40,3 +41,39 @@ export async function withWorkingBadge(page, action) {
|
|
|
40
41
|
if (raised) await writeBadge(page, before ?? { state: "label" }).catch(() => {});
|
|
41
42
|
}
|
|
42
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run `action` with nothing drawn - no pill, no corner tag - then restore what
|
|
47
|
+
* was there. For a recording: the video shows the product, not the harness.
|
|
48
|
+
* attach's badge script redraws within a second, so the wait lets it go.
|
|
49
|
+
* `register` receives the restore function as soon as the badge is down, so a
|
|
50
|
+
* run that is stopped half-way (Ctrl-C, a timeout) can put it back too: a
|
|
51
|
+
* `finally` never runs when the process is killed.
|
|
52
|
+
*/
|
|
53
|
+
export async function withBadgeOff(
|
|
54
|
+
page,
|
|
55
|
+
action,
|
|
56
|
+
{ settleMs = 1200, wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), register } = {},
|
|
57
|
+
) {
|
|
58
|
+
const before = await readBadge(page);
|
|
59
|
+
let restored = false;
|
|
60
|
+
const restore = async ({ timeoutMs } = {}) => {
|
|
61
|
+
if (restored) return;
|
|
62
|
+
restored = true;
|
|
63
|
+
await writeBadge(page, before ?? { state: "label" }, { timeoutMs }).catch(() => {});
|
|
64
|
+
};
|
|
65
|
+
let lowered = false;
|
|
66
|
+
try {
|
|
67
|
+
await writeBadge(page, { state: "off" });
|
|
68
|
+
lowered = true;
|
|
69
|
+
register?.(restore);
|
|
70
|
+
await wait(settleMs);
|
|
71
|
+
} catch {
|
|
72
|
+
// no page to write to: nothing is drawn either
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return await action();
|
|
76
|
+
} finally {
|
|
77
|
+
if (lowered) await restore();
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/cdp-client.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// A small CDP page client for `observe`, `ask`, `badge` and
|
|
1
|
+
// A small CDP page client for `observe`, `ask`, `badge`, `published` and
|
|
2
|
+
// `record`.
|
|
2
3
|
//
|
|
3
4
|
// It is a second client on the test window and is safe to use while
|
|
4
5
|
// `foldspace attach` is running: attach's claim on the debug port is about
|
|
@@ -178,6 +179,11 @@ export class CdpPage {
|
|
|
178
179
|
this.#listeners.get(method).push(listener);
|
|
179
180
|
}
|
|
180
181
|
|
|
182
|
+
off(method, listener) {
|
|
183
|
+
const listeners = this.#listeners.get(method) || [];
|
|
184
|
+
this.#listeners.set(method, listeners.filter((item) => item !== listener));
|
|
185
|
+
}
|
|
186
|
+
|
|
181
187
|
/** Evaluate the body of an async function in the page; returns its JSON value. */
|
|
182
188
|
async evaluate(body, { timeoutMs = 30000 } = {}) {
|
|
183
189
|
const result = await this.send(
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Which Chrome to launch: CHROME_PATH, else the usual install places. Shared
|
|
2
|
+
// by `inject` (the test window) and `record` (the hidden Chrome that makes the
|
|
3
|
+
// video), so both use the same browser.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
export function chromeCandidates(platform = os.platform(), env = process.env) {
|
|
10
|
+
if (platform === "darwin") {
|
|
11
|
+
return [
|
|
12
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
13
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
14
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
if (platform === "win32") {
|
|
18
|
+
// Installed for everyone (Program Files), or for this user only (LocalAppData).
|
|
19
|
+
return [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA]
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.map((root) => path.win32.join(root, "Google", "Chrome", "Application", "chrome.exe"));
|
|
22
|
+
}
|
|
23
|
+
return ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium"];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function findChrome({ env = process.env, platform = os.platform(), exists = fs.existsSync } = {}) {
|
|
27
|
+
return env.CHROME_PATH || chromeCandidates(platform, env).find((candidate) => exists(candidate)) || null;
|
|
28
|
+
}
|
package/src/cli-help.mjs
CHANGED
package/src/cli-registry.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
32
32
|
{
|
|
33
33
|
name: "directory",
|
|
34
34
|
required: "non-interactive",
|
|
35
|
-
description: "
|
|
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
|
|
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
|
|
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,54 @@ 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
|
+
}),
|
|
443
493
|
Object.freeze({
|
|
444
494
|
name: "deploy",
|
|
445
495
|
// Foldspace-internal: it uploads to Foldspace's own storage, which no
|