@foldspace_npm/harness 0.1.33 → 0.1.34
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 +169 -49
- package/README.md +30 -2
- package/bin/inject.mjs +2 -18
- package/bin/observe.mjs +18 -0
- package/bin/record.mjs +2 -0
- package/bin/sync.mjs +48 -0
- package/package.json +1 -1
- package/recipes/INDEX.md +1 -1
- package/recipes/bottom-bar/README.md +24 -11
- package/recipes/bottom-bar/agent/bottomBar.ts +76 -38
- package/recipes/bottom-bar/fixtures/configuration.sent.json +13 -12
- package/recipes/swap-the-login-method/README.md +1 -1
- package/recipes/swap-the-login-method/agent/utils.ts +8 -7
- package/recipes/swap-the-login-method/variants/utils.cookies.ts +8 -7
- package/src/chrome-binary.mjs +33 -0
- package/src/cli-registry.mjs +52 -5
- package/src/customer-subdomain.mjs +149 -0
- package/src/lint/index.mjs +23 -3
- package/src/lint/rules.mjs +46 -2
- package/src/lint/safety.mjs +785 -0
- package/src/observe-core.mjs +1 -1
- package/src/runtime/VERSION +1 -1
- package/src/runtime/config.ts +15 -2
- package/src/runtime/http.ts +40 -3
- package/src/runtime/index.ts +1 -0
- package/src/sync-core.mjs +374 -0
- package/src/upgrade.mjs +36 -7
- package/src/video/compose.mjs +12 -3
- package/src/video/recorder.mjs +15 -7
- package/templates/agent-starter/agent/constants.ts +15 -1
- package/templates/agent-starter/agent/utils.ts +3 -0
- package/templates/agent-starter/claude-settings.json +3 -1
- package/templates/agent-starter/skills/review-experience/SKILL.md +135 -0
- package/templates/agent-starter/skills/share-a-video/SKILL.md +9 -2
package/src/observe-core.mjs
CHANGED
|
@@ -20,7 +20,7 @@ export function hostMatchesAny(hostname, hosts = []) {
|
|
|
20
20
|
// on, never to decide where anything is sent.
|
|
21
21
|
const SECOND_LEVEL_SUFFIXES = new Set(["co", "com", "org", "net", "gov", "ac", "edu"]);
|
|
22
22
|
|
|
23
|
-
function siteOf(hostname) {
|
|
23
|
+
export function siteOf(hostname) {
|
|
24
24
|
const host = String(hostname || "").toLowerCase();
|
|
25
25
|
if (!host.includes(".") || /^[\d.]+$/.test(host) || host.includes(":")) return host;
|
|
26
26
|
const labels = host.split(".");
|
package/src/runtime/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.34
|
package/src/runtime/config.ts
CHANGED
|
@@ -29,8 +29,21 @@ export interface RuntimeConfig {
|
|
|
29
29
|
/** Foldspace agent apiName, e.g. `acme-agent`. */
|
|
30
30
|
agentApiName: string;
|
|
31
31
|
/**
|
|
32
|
-
* Prefix for `apiFetch` / `apiFetchBinary
|
|
33
|
-
*
|
|
32
|
+
* Prefix for `apiFetch` / `apiFetchBinary`, in one of three forms:
|
|
33
|
+
*
|
|
34
|
+
* - a full address, `https://api.example.com`, when every customer's app
|
|
35
|
+
* calls the same backend. Include an explicit port if the app uses one —
|
|
36
|
+
* omitting `:443` can fail CORS.
|
|
37
|
+
* - a path, `/api`, when the backend is on the page's own host. Each user's
|
|
38
|
+
* page calls its own host.
|
|
39
|
+
* - an address with `{subdomain}` in it, `https://{subdomain}.api.example.com`,
|
|
40
|
+
* when each customer has their own subdomain (`acme.example.com`) and the
|
|
41
|
+
* backend carries it too. `{subdomain}` becomes the first part of the page's
|
|
42
|
+
* address at run time, so each customer's agent calls their own backend.
|
|
43
|
+
*
|
|
44
|
+
* A product with a subdomain per customer never gets a fixed address here:
|
|
45
|
+
* that is the one customer the builder was signed in to, for every user.
|
|
46
|
+
* Read it through `apiBaseUrl()`, never `getConfig().apiBase` directly.
|
|
34
47
|
*/
|
|
35
48
|
apiBase?: string;
|
|
36
49
|
/**
|
package/src/runtime/http.ts
CHANGED
|
@@ -156,6 +156,34 @@ export type FailureReason =
|
|
|
156
156
|
/** Discriminated result of a customer-API call. Branch on `ok`. */
|
|
157
157
|
export type ApiResult<T> = ApiSuccess<T> | ApiFailure;
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* The backend address for this page: `apiBase` as configured, with a path
|
|
161
|
+
* (`/api`) put on the page's own origin and `{subdomain}` replaced by the
|
|
162
|
+
* first part of the page's address (`acme` on `acme.example.com`; a leading
|
|
163
|
+
* `www.` is skipped). Empty when `apiBase` is empty, or when it asks for a
|
|
164
|
+
* subdomain and the page's address has none.
|
|
165
|
+
*
|
|
166
|
+
* A tenant's own `apiFetch` (recipes/swap-the-login-method) builds its URLs
|
|
167
|
+
* from this, so a product with a subdomain per customer calls each
|
|
168
|
+
* customer's own backend.
|
|
169
|
+
*
|
|
170
|
+
* @returns The address to put in front of a path, or `""`
|
|
171
|
+
*/
|
|
172
|
+
export function apiBaseUrl(): string {
|
|
173
|
+
const base = getConfig().apiBase ?? "";
|
|
174
|
+
if (!base) return "";
|
|
175
|
+
const page = typeof location === "undefined" ? null : location;
|
|
176
|
+
if (base.startsWith("/")) return page?.origin ? `${page.origin}${base}` : base;
|
|
177
|
+
if (!base.includes("{subdomain}")) return base;
|
|
178
|
+
const labels = (page?.hostname ?? "").toLowerCase().split(".").filter(Boolean);
|
|
179
|
+
if (labels[0] === "www") labels.shift();
|
|
180
|
+
// A customer subdomain is a part in front of the product's own domain:
|
|
181
|
+
// acme.example.com has one, example.com and example.co.uk do not.
|
|
182
|
+
const domainParts = ["co", "com", "org", "net", "gov", "ac", "edu"].includes(labels[labels.length - 2] ?? "") ? 3 : 2;
|
|
183
|
+
if (labels.length <= domainParts || !/^[a-z0-9-]+$/.test(labels[0])) return "";
|
|
184
|
+
return base.split("{subdomain}").join(labels[0]);
|
|
185
|
+
}
|
|
186
|
+
|
|
159
187
|
// What reaches the agent and the card when the build is not configured yet:
|
|
160
188
|
// a state, never a developer instruction. The developer hint is in `detail`,
|
|
161
189
|
// for the console only: nothing shows it unless the handler logs it.
|
|
@@ -172,6 +200,15 @@ function missingConfigResult(): ApiResult<never> | null {
|
|
|
172
200
|
detail: "API_BASE is not set - capture the app's XHR first.",
|
|
173
201
|
};
|
|
174
202
|
}
|
|
203
|
+
if (!apiBaseUrl()) {
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
status: 0,
|
|
207
|
+
reason: "config",
|
|
208
|
+
error: NOT_SET_UP,
|
|
209
|
+
detail: "API_BASE uses {subdomain}, but this page's address has no customer subdomain to put in.",
|
|
210
|
+
};
|
|
211
|
+
}
|
|
175
212
|
if (!config.authSource) {
|
|
176
213
|
return {
|
|
177
214
|
ok: false,
|
|
@@ -251,7 +288,7 @@ const SIGNED_OUT: ApiFailure = {
|
|
|
251
288
|
};
|
|
252
289
|
|
|
253
290
|
/**
|
|
254
|
-
* Authenticated JSON request against `
|
|
291
|
+
* Authenticated JSON request against `apiBaseUrl()` with `Authorization: Bearer`.
|
|
255
292
|
*
|
|
256
293
|
* For an app whose own requests carry `Authorization: Bearer`. If the live
|
|
257
294
|
* page sends something else (a custom header, or cookies with no token),
|
|
@@ -283,7 +320,7 @@ export async function apiFetch<T = unknown>(
|
|
|
283
320
|
);
|
|
284
321
|
|
|
285
322
|
try {
|
|
286
|
-
const res = await fetch(`${
|
|
323
|
+
const res = await fetch(`${apiBaseUrl()}${path}`, {
|
|
287
324
|
...init,
|
|
288
325
|
signal: controller.signal,
|
|
289
326
|
headers: {
|
|
@@ -370,7 +407,7 @@ export async function apiFetchBinary(
|
|
|
370
407
|
);
|
|
371
408
|
|
|
372
409
|
try {
|
|
373
|
-
const res = await fetch(`${
|
|
410
|
+
const res = await fetch(`${apiBaseUrl()}${path}`, {
|
|
374
411
|
...init,
|
|
375
412
|
signal: controller.signal,
|
|
376
413
|
headers: { Authorization: `Bearer ${token}`, ...(init.headers ?? {}) },
|
package/src/runtime/index.ts
CHANGED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// Keeping this folder's agent/ up to date with the agent's saved code.
|
|
2
|
+
//
|
|
3
|
+
// Several builders - people or machines - can work on one agent. The saved
|
|
4
|
+
// code lives in a code project this harness never reaches itself: the coding
|
|
5
|
+
// agent reads it with the Foldspace tools (repo_read) and writes what it read
|
|
6
|
+
// into a folder here. This module does the rest, so the manual stays short:
|
|
7
|
+
//
|
|
8
|
+
// check compares the heads the coding agent just read - develop, and the
|
|
9
|
+
// line of work saves go to (foldspace/<Agent Key>, once it exists) -
|
|
10
|
+
// with the ones this folder last pulled, and says which to pull and
|
|
11
|
+
// where to put them
|
|
12
|
+
// apply merges what was pulled into agent/, file by file, keeping a copy
|
|
13
|
+
// of agent/ aside first, and remembers what it pulled
|
|
14
|
+
// saved remembers a save this folder made, so its own save is not taken
|
|
15
|
+
// for someone else's
|
|
16
|
+
//
|
|
17
|
+
// Saving and publishing follow the code project's own instructions
|
|
18
|
+
// (repo_read command "instructions"); this only keeps agent/ current.
|
|
19
|
+
//
|
|
20
|
+
// Everything lives in .foldspace-dev/saved-code/ (never committed):
|
|
21
|
+
// state.json { develop: {sha}, line: {ref, sha}, pending }
|
|
22
|
+
// base/<ref>/agent/... the files as this folder last pulled or saved them
|
|
23
|
+
// incoming/<ref>/... where the coding agent writes what it reads
|
|
24
|
+
// and the copy of agent/ goes to .foldspace-dev/agent-kept/<time>/.
|
|
25
|
+
//
|
|
26
|
+
// The merge is three-way per file, against what this folder last had from
|
|
27
|
+
// the same ref: a file only one side changed takes that side; a file both
|
|
28
|
+
// changed differently is a conflict - agent/ gets the saved version (as a
|
|
29
|
+
// save refused for an older base would) and this folder's version stays in
|
|
30
|
+
// the kept copy. With nothing remembered for a ref, every difference is a
|
|
31
|
+
// conflict, which is the session-start rule: the saved code wins, and files
|
|
32
|
+
// it lacks are put back.
|
|
33
|
+
|
|
34
|
+
import fs from "node:fs";
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
|
|
37
|
+
export const DEVELOP = "develop";
|
|
38
|
+
// The line of work the code project's instructions save to.
|
|
39
|
+
export const LINE_PREFIX = "foldspace/";
|
|
40
|
+
const SHA = /^[0-9a-f]{7,40}$/i;
|
|
41
|
+
const KEEP_COPIES = 10;
|
|
42
|
+
// The build owns agent/runtime/: it puts this harness's copy back, so a
|
|
43
|
+
// difference there is never someone's work to re-apply.
|
|
44
|
+
const BUILD_OWNED = "agent/runtime/";
|
|
45
|
+
const IGNORED = new Set([".DS_Store", "Thumbs.db"]);
|
|
46
|
+
// On a first pull, the only files the saved code lacks that go back.
|
|
47
|
+
const PUT_BACK = ["agent/actions/", "agent/api/", "agent/views/"];
|
|
48
|
+
|
|
49
|
+
export function syncPaths(projectDir) {
|
|
50
|
+
const root = path.join(projectDir, ".foldspace-dev", "saved-code");
|
|
51
|
+
return {
|
|
52
|
+
root,
|
|
53
|
+
state: path.join(root, "state.json"),
|
|
54
|
+
base: (ref) => path.join(root, "base", ...ref.split("/")),
|
|
55
|
+
incoming: (ref) => path.join(root, "incoming", ...ref.split("/")),
|
|
56
|
+
kept: path.join(projectDir, ".foldspace-dev", "agent-kept"),
|
|
57
|
+
agent: path.join(projectDir, "agent"),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** foldspace/<Agent Key>, from this project's foldspace.dev.json. */
|
|
62
|
+
export function saveLine(projectDir) {
|
|
63
|
+
let config;
|
|
64
|
+
try {
|
|
65
|
+
config = JSON.parse(fs.readFileSync(path.join(projectDir, "foldspace.dev.json"), "utf8"));
|
|
66
|
+
} catch {
|
|
67
|
+
throw new Error("foldspace.dev.json not found or not valid JSON: run this from the project folder");
|
|
68
|
+
}
|
|
69
|
+
const key = config?.targets?.[config?.defaultTarget]?.agentApiName;
|
|
70
|
+
if (typeof key !== "string" || !key) {
|
|
71
|
+
throw new Error("foldspace.dev.json names no agentApiName for its defaultTarget");
|
|
72
|
+
}
|
|
73
|
+
return `${LINE_PREFIX}${key}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function requireSha(value, flag) {
|
|
77
|
+
if (typeof value !== "string" || !SHA.test(value)) {
|
|
78
|
+
throw new Error(`${flag} needs a commit id (7 to 40 hex characters), got '${value ?? ""}'`);
|
|
79
|
+
}
|
|
80
|
+
return value.toLowerCase();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Two ids name the same commit when one is a prefix of the other (short and full forms). */
|
|
84
|
+
export function sameCommit(a, b) {
|
|
85
|
+
if (!a || !b) return false;
|
|
86
|
+
const x = a.toLowerCase();
|
|
87
|
+
const y = b.toLowerCase();
|
|
88
|
+
return x.startsWith(y) || y.startsWith(x);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readSyncState(projectDir) {
|
|
92
|
+
try {
|
|
93
|
+
const state = JSON.parse(fs.readFileSync(syncPaths(projectDir).state, "utf8"));
|
|
94
|
+
return { develop: null, line: null, pending: null, ...state };
|
|
95
|
+
} catch {
|
|
96
|
+
return { develop: null, line: null, pending: null };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writeSyncState(projectDir, state) {
|
|
101
|
+
const file = syncPaths(projectDir).state;
|
|
102
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
103
|
+
fs.writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** { "agent/x.ts": Buffer } for every file under dir/agent, or {} when there is none. */
|
|
107
|
+
export function readTree(dir) {
|
|
108
|
+
const files = {};
|
|
109
|
+
const agentDir = path.join(dir, "agent");
|
|
110
|
+
if (!fs.existsSync(agentDir)) return files;
|
|
111
|
+
const walk = (relative) => {
|
|
112
|
+
for (const entry of fs.readdirSync(path.join(dir, relative), { withFileTypes: true })) {
|
|
113
|
+
if (IGNORED.has(entry.name)) continue;
|
|
114
|
+
const rel = path.posix.join(relative, entry.name);
|
|
115
|
+
if (entry.isDirectory()) walk(rel);
|
|
116
|
+
else if (entry.isFile()) files[rel] = fs.readFileSync(path.join(dir, rel));
|
|
117
|
+
// apply rewrites agent/ from what it read: a link would be lost.
|
|
118
|
+
else throw new Error(`${rel} is not a plain file or folder; sync handles only those`);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
walk("agent");
|
|
122
|
+
return files;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function writeTree(dir, files) {
|
|
126
|
+
fs.rmSync(path.join(dir, "agent"), { recursive: true, force: true });
|
|
127
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
128
|
+
const target = path.join(dir, ...rel.split("/"));
|
|
129
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
130
|
+
fs.writeFileSync(target, content);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const same = (a, b) => (a === undefined && b === undefined) || (a !== undefined && b !== undefined && a.equals(b));
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Three-way merge of one ref into the local files. base is what this folder
|
|
138
|
+
* last had from that ref ({} when nothing is remembered).
|
|
139
|
+
* Returns { files, fromSaved, conflicts, setAside }.
|
|
140
|
+
*/
|
|
141
|
+
export function mergeTrees(local, remote, base) {
|
|
142
|
+
const files = { ...local };
|
|
143
|
+
const fromSaved = [];
|
|
144
|
+
const conflicts = [];
|
|
145
|
+
const setAside = [];
|
|
146
|
+
const nothingRemembered = Object.keys(base).length === 0;
|
|
147
|
+
const paths = new Set([...Object.keys(local), ...Object.keys(remote), ...Object.keys(base)]);
|
|
148
|
+
for (const rel of [...paths].sort()) {
|
|
149
|
+
const l = local[rel];
|
|
150
|
+
const r = remote[rel];
|
|
151
|
+
const b = base[rel];
|
|
152
|
+
if (same(l, r)) continue; // nothing to do
|
|
153
|
+
if (nothingRemembered && r === undefined) {
|
|
154
|
+
// A file the saved code lacks, on a first pull: work never saved goes
|
|
155
|
+
// back (actions, their API helpers and cards); anything else - a
|
|
156
|
+
// scaffold's utils.ts beside the project's utils/ - would win an import.
|
|
157
|
+
if (!PUT_BACK.some((prefix) => rel.startsWith(prefix))) {
|
|
158
|
+
delete files[rel];
|
|
159
|
+
setAside.push(rel);
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (same(l, b)) {
|
|
164
|
+
// Only the saved code changed it (or added or deleted it).
|
|
165
|
+
if (r === undefined) delete files[rel];
|
|
166
|
+
else files[rel] = r;
|
|
167
|
+
fromSaved.push(rel);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (same(r, b)) continue; // only this folder changed it: keep it
|
|
171
|
+
// Both changed it, differently.
|
|
172
|
+
if (r === undefined) {
|
|
173
|
+
// Deleted in the saved code, changed here: keep this folder's, and say so.
|
|
174
|
+
conflicts.push(rel);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
files[rel] = r;
|
|
178
|
+
if (rel.startsWith(BUILD_OWNED)) {
|
|
179
|
+
fromSaved.push(rel);
|
|
180
|
+
} else {
|
|
181
|
+
conflicts.push(rel);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { files, fromSaved, conflicts, setAside };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function requireProject(projectDir) {
|
|
188
|
+
if (!fs.existsSync(path.join(projectDir, "foldspace.dev.json"))) {
|
|
189
|
+
throw new Error("foldspace.dev.json not found: run this from the project folder");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* check: the coding agent passes the heads it just read with repo_read -
|
|
195
|
+
* develop_head from `status`, and the head of the line of work saves go to
|
|
196
|
+
* (foldspace/<Agent Key>: the sha `tree` prints for that ref), once that line
|
|
197
|
+
* exists. Returns what to pull, and the base_sha for the next save.
|
|
198
|
+
*/
|
|
199
|
+
export function syncCheck(projectDir, { develop, line } = {}) {
|
|
200
|
+
requireProject(projectDir);
|
|
201
|
+
const developSha = requireSha(develop, "--develop");
|
|
202
|
+
const lineSha = line !== undefined ? requireSha(line, "--line") : null;
|
|
203
|
+
const lineRef = saveLine(projectDir);
|
|
204
|
+
|
|
205
|
+
const paths = syncPaths(projectDir);
|
|
206
|
+
const state = readSyncState(projectDir);
|
|
207
|
+
const pull = [];
|
|
208
|
+
if (!sameCommit(state.develop?.sha, developSha)) {
|
|
209
|
+
pull.push({ ref: DEVELOP, sha: developSha });
|
|
210
|
+
}
|
|
211
|
+
if (lineSha && !sameCommit(state.line?.sha, lineSha)) {
|
|
212
|
+
pull.push({ ref: lineRef, sha: lineSha });
|
|
213
|
+
}
|
|
214
|
+
for (const item of pull) {
|
|
215
|
+
const into = paths.incoming(item.ref);
|
|
216
|
+
fs.rmSync(into, { recursive: true, force: true });
|
|
217
|
+
fs.mkdirSync(into, { recursive: true });
|
|
218
|
+
item.into = path.relative(projectDir, into);
|
|
219
|
+
}
|
|
220
|
+
writeSyncState(projectDir, { ...state, pending: pull.length ? { pull, at: Date.now() } : null });
|
|
221
|
+
|
|
222
|
+
// As the code project's instructions say: the head of the ref you read -
|
|
223
|
+
// the line of work once it exists, develop before (a first save starts the
|
|
224
|
+
// line from develop's head, and is checked against it).
|
|
225
|
+
const baseSha = lineSha || developSha;
|
|
226
|
+
return {
|
|
227
|
+
kind: "foldspace.sync.check",
|
|
228
|
+
upToDate: pull.length === 0,
|
|
229
|
+
pull,
|
|
230
|
+
line: lineRef,
|
|
231
|
+
baseSha,
|
|
232
|
+
next: pull.length
|
|
233
|
+
? "For each entry in pull, read agent/ at that ref (repo_read tree and file, ref = the entry's ref) and write each file under the entry's into folder at its project path (into/agent/...). Then run npx foldspace sync apply."
|
|
234
|
+
: `Up to date. The next save's base_sha is ${baseSha}.`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function keepCopy(paths, local) {
|
|
239
|
+
if (!Object.keys(local).length) return null;
|
|
240
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
241
|
+
const dir = path.join(paths.kept, stamp);
|
|
242
|
+
writeTree(dir, local);
|
|
243
|
+
// Only the latest few: each is a whole agent/.
|
|
244
|
+
try {
|
|
245
|
+
const all = fs.readdirSync(paths.kept).filter((name) => /^\d{4}-/.test(name)).sort();
|
|
246
|
+
for (const old of all.slice(0, Math.max(0, all.length - KEEP_COPIES))) {
|
|
247
|
+
fs.rmSync(path.join(paths.kept, old), { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
// keeping fewer copies is not worth failing over
|
|
251
|
+
}
|
|
252
|
+
return dir;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** apply: merge what check asked for, which the coding agent wrote under incoming/. */
|
|
256
|
+
export function syncApply(projectDir) {
|
|
257
|
+
requireProject(projectDir);
|
|
258
|
+
const paths = syncPaths(projectDir);
|
|
259
|
+
const state = readSyncState(projectDir);
|
|
260
|
+
const pull = state.pending?.pull;
|
|
261
|
+
if (!pull?.length) {
|
|
262
|
+
throw new Error("nothing to apply: run npx foldspace sync check first");
|
|
263
|
+
}
|
|
264
|
+
const incoming = pull.map((item) => {
|
|
265
|
+
const files = readTree(paths.incoming(item.ref));
|
|
266
|
+
if (!Object.keys(files).length) {
|
|
267
|
+
throw new Error(
|
|
268
|
+
`nothing was read into ${item.into}/agent/: read agent/ at ${item.ref} there first (repo_read tree and file)`,
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return { ...item, files };
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const local = readTree(projectDir);
|
|
275
|
+
const keptDir = keepCopy(paths, local);
|
|
276
|
+
|
|
277
|
+
let files = local;
|
|
278
|
+
const fromSaved = new Set();
|
|
279
|
+
const conflicts = new Set();
|
|
280
|
+
const setAside = new Set();
|
|
281
|
+
let firstPull = false;
|
|
282
|
+
let developFiles = readTree(paths.base(DEVELOP));
|
|
283
|
+
for (const item of incoming) {
|
|
284
|
+
let base = readTree(paths.base(item.ref));
|
|
285
|
+
// A line of work this folder never pulled or saved started from develop:
|
|
286
|
+
// compare against that. Known limit: if develop moved after the line
|
|
287
|
+
// started, the line's older copies of develop's files look like its own
|
|
288
|
+
// changes here; publishing merges on the server against the true start.
|
|
289
|
+
if (!Object.keys(base).length && item.ref !== DEVELOP) base = developFiles;
|
|
290
|
+
if (!Object.keys(base).length) firstPull = true;
|
|
291
|
+
const merged = mergeTrees(files, item.files, base);
|
|
292
|
+
files = merged.files;
|
|
293
|
+
merged.fromSaved.forEach((rel) => fromSaved.add(rel));
|
|
294
|
+
merged.conflicts.forEach((rel) => conflicts.add(rel));
|
|
295
|
+
merged.setAside.forEach((rel) => setAside.add(rel));
|
|
296
|
+
if (item.ref === DEVELOP) developFiles = item.files;
|
|
297
|
+
}
|
|
298
|
+
writeTree(projectDir, files);
|
|
299
|
+
|
|
300
|
+
// Remember what each ref held, as the base for the next merge.
|
|
301
|
+
const nextState = { ...state, pending: null };
|
|
302
|
+
for (const item of incoming) {
|
|
303
|
+
writeTree(paths.base(item.ref), item.files);
|
|
304
|
+
fs.rmSync(paths.incoming(item.ref), { recursive: true, force: true });
|
|
305
|
+
const entry = { sha: item.sha, at: Date.now() };
|
|
306
|
+
if (item.ref === DEVELOP) nextState.develop = entry;
|
|
307
|
+
else nextState.line = { ref: item.ref, ...entry };
|
|
308
|
+
}
|
|
309
|
+
writeSyncState(projectDir, nextState);
|
|
310
|
+
|
|
311
|
+
const keptCopy = keptDir ? path.relative(projectDir, keptDir) : null;
|
|
312
|
+
const conflictList = [...conflicts].sort();
|
|
313
|
+
// Conflicts on a ref this folder never pulled are the session-start case:
|
|
314
|
+
// work on this computer that was never saved.
|
|
315
|
+
const neverSaved = firstPull;
|
|
316
|
+
const steps = [];
|
|
317
|
+
if (conflictList.length) {
|
|
318
|
+
steps.push(
|
|
319
|
+
neverSaved
|
|
320
|
+
? `These files differed from the saved code, which wins: tell the human in one line which actions had changes on this computer that were never saved. This folder's versions are in ${keptCopy}.`
|
|
321
|
+
: `These files changed both here and in the saved code since this folder last pulled. agent/ now has the saved version; this folder's is in ${keptCopy}. Put this session's change back on top of each.`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (fromSaved.size && !neverSaved) {
|
|
325
|
+
steps.push("Someone saved changes since this folder last pulled: tell the human in one plain line that you brought them in.");
|
|
326
|
+
}
|
|
327
|
+
steps.push("Then npm run build; if attach is running, npx foldspace attach --stop and attach again with the same flags.");
|
|
328
|
+
return {
|
|
329
|
+
kind: "foldspace.sync.apply",
|
|
330
|
+
pulled: incoming.map(({ ref, sha }) => ({ ref, sha })),
|
|
331
|
+
fromSaved: [...fromSaved].sort(),
|
|
332
|
+
conflicts: conflictList,
|
|
333
|
+
setAside: [...setAside].sort(),
|
|
334
|
+
keptCopy,
|
|
335
|
+
next: steps.join(" "),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* saved: after repo_write commit, remember the new head and what was saved,
|
|
341
|
+
* so the next check does not take this folder's own save for someone else's.
|
|
342
|
+
*/
|
|
343
|
+
export function syncSaved(projectDir, { head, paths: savedPaths } = {}) {
|
|
344
|
+
requireProject(projectDir);
|
|
345
|
+
const lineRef = saveLine(projectDir);
|
|
346
|
+
const sha = requireSha(head, "--head");
|
|
347
|
+
const list = String(savedPaths || "")
|
|
348
|
+
.split(",")
|
|
349
|
+
.map((item) => item.trim())
|
|
350
|
+
.filter(Boolean);
|
|
351
|
+
if (!list.length) throw new Error("--paths needs the committed_paths the save returned, comma-separated");
|
|
352
|
+
for (const rel of list) {
|
|
353
|
+
if (!rel.startsWith("agent/") || rel.includes("..")) throw new Error(`'${rel}' is not a path under agent/`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const paths = syncPaths(projectDir);
|
|
357
|
+
const state = readSyncState(projectDir);
|
|
358
|
+
// The line's first save starts it from develop's head: what this folder
|
|
359
|
+
// last pulled from develop is what the line held before this save.
|
|
360
|
+
const base = state.line ? readTree(paths.base(lineRef)) : readTree(paths.base(DEVELOP));
|
|
361
|
+
const local = readTree(projectDir);
|
|
362
|
+
for (const rel of list) {
|
|
363
|
+
if (local[rel] === undefined) delete base[rel];
|
|
364
|
+
else base[rel] = local[rel];
|
|
365
|
+
}
|
|
366
|
+
writeTree(paths.base(lineRef), base);
|
|
367
|
+
writeSyncState(projectDir, { ...state, line: { ref: lineRef, sha, at: Date.now() } });
|
|
368
|
+
return {
|
|
369
|
+
kind: "foldspace.sync.saved",
|
|
370
|
+
line: lineRef,
|
|
371
|
+
head: sha,
|
|
372
|
+
next: `The next save's base_sha is ${sha}.`,
|
|
373
|
+
};
|
|
374
|
+
}
|
package/src/upgrade.mjs
CHANGED
|
@@ -10,6 +10,11 @@ export const INSTRUCTION_IMPORT = `@node_modules/${HARNESS_PACKAGE}/CLAUDE.md`;
|
|
|
10
10
|
export const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
|
11
11
|
export const NPM_VIEW_TIMEOUT_MS = 2000;
|
|
12
12
|
export const UPGRADE_NEXT = "Ask the human, then run npx foldspace upgrade --yes";
|
|
13
|
+
// Within one line (0.1.x to a newer 0.1.y) the newest is always right: several
|
|
14
|
+
// builders can work on one agent, and each session should run the same
|
|
15
|
+
// harness. Crossing a line (0.1 to 0.2, or a major) still asks.
|
|
16
|
+
export const AUTO_UPGRADE_NEXT =
|
|
17
|
+
"Same line: run npx foldspace upgrade --yes now, without asking, and tell the human in one plain line that you updated the Foldspace tools";
|
|
13
18
|
|
|
14
19
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
20
|
const instructionTemplatePath = path.join(
|
|
@@ -85,6 +90,14 @@ export function isBehind(current, latest) {
|
|
|
85
90
|
return compareVersions(current, latest) < 0;
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
/** Same major and minor: 0.1.20 and 0.1.34 are one line, 0.1.34 and 0.2.0 are not. */
|
|
94
|
+
export function isSameLine(left, right) {
|
|
95
|
+
const a = parseSemver(left);
|
|
96
|
+
const b = parseSemver(right);
|
|
97
|
+
if (!a || !b) return false;
|
|
98
|
+
return a[0] === b[0] && a[1] === b[1];
|
|
99
|
+
}
|
|
100
|
+
|
|
88
101
|
function readJson(filePath) {
|
|
89
102
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
90
103
|
}
|
|
@@ -343,9 +356,14 @@ export function fetchDeprecationFromNpm(version, options = {}) {
|
|
|
343
356
|
|
|
344
357
|
function nextAction(report) {
|
|
345
358
|
if (report.broken) {
|
|
346
|
-
return `This harness version was withdrawn: ${report.deprecated}. ${UPGRADE_NEXT}`;
|
|
359
|
+
return `This harness version was withdrawn: ${report.deprecated}. ${report.autoUpgrade ? AUTO_UPGRADE_NEXT : UPGRADE_NEXT}`;
|
|
347
360
|
}
|
|
348
361
|
if (report.status === "outdated") {
|
|
362
|
+
if (report.autoUpgrade) {
|
|
363
|
+
return report.instructionsImport === false
|
|
364
|
+
? `${AUTO_UPGRADE_NEXT}. CLAUDE.md does not import the package instructions: ask the human before npx foldspace upgrade --refresh-instructions (the old CLAUDE.md is kept as CLAUDE.md.bak).`
|
|
365
|
+
: AUTO_UPGRADE_NEXT;
|
|
366
|
+
}
|
|
349
367
|
return report.instructionsImport === false
|
|
350
368
|
? "CLAUDE.md does not import the package instructions. Ask the human, then run npx foldspace upgrade --yes --refresh-instructions (the old CLAUDE.md is kept as CLAUDE.md.bak)."
|
|
351
369
|
: UPGRADE_NEXT;
|
|
@@ -455,10 +473,18 @@ export function checkForUpdate(projectDir, options = {}) {
|
|
|
455
473
|
outdated: status === "outdated",
|
|
456
474
|
deprecated,
|
|
457
475
|
broken: Boolean(deprecated),
|
|
476
|
+
// A newer version in the same line: upgrade without asking (manual, step 1).
|
|
477
|
+
autoUpgrade: false,
|
|
458
478
|
status,
|
|
459
479
|
instructionsImport,
|
|
460
480
|
next: null,
|
|
461
481
|
};
|
|
482
|
+
report.autoUpgrade = Boolean(
|
|
483
|
+
(report.outdated || report.broken) &&
|
|
484
|
+
latest &&
|
|
485
|
+
isBehind(running, latest) &&
|
|
486
|
+
isSameLine(running, latest),
|
|
487
|
+
);
|
|
462
488
|
report.next = nextAction(report);
|
|
463
489
|
return report;
|
|
464
490
|
}
|
|
@@ -466,7 +492,8 @@ export function checkForUpdate(projectDir, options = {}) {
|
|
|
466
492
|
/**
|
|
467
493
|
* What `build` and `attach` do with an update report before they run:
|
|
468
494
|
* `block: true` stops the command, `message` (if any) is printed to stderr.
|
|
469
|
-
* Never upgrades
|
|
495
|
+
* Never upgrades itself: the coding agent runs upgrade (CLAUDE.md, step 1) -
|
|
496
|
+
* without asking within a line, after asking across one.
|
|
470
497
|
* The escape hatch is FOLDSPACE_SKIP_UPDATE_CHECK=1: the report comes back
|
|
471
498
|
* check_skipped, so nothing here blocks.
|
|
472
499
|
*/
|
|
@@ -476,15 +503,16 @@ export function gateForCommand(report) {
|
|
|
476
503
|
if (report.broken) {
|
|
477
504
|
return {
|
|
478
505
|
block: true,
|
|
479
|
-
message:
|
|
480
|
-
`${HARNESS_PACKAGE} ${report.installed || report.pinned} was withdrawn: ${report.deprecated}.
|
|
481
|
-
`${
|
|
506
|
+
message: report.autoUpgrade
|
|
507
|
+
? `${HARNESS_PACKAGE} ${report.installed || report.pinned} was withdrawn: ${report.deprecated}. ${AUTO_UPGRADE_NEXT}, then run this again.`
|
|
508
|
+
: `${HARNESS_PACKAGE} ${report.installed || report.pinned} was withdrawn: ${report.deprecated}. ` +
|
|
509
|
+
`${UPGRADE_NEXT}. If the human declines, rerun with FOLDSPACE_SKIP_UPDATE_CHECK=1 to run this version anyway.`,
|
|
482
510
|
};
|
|
483
511
|
}
|
|
484
512
|
if (report.outdated) {
|
|
485
513
|
return {
|
|
486
514
|
block: false,
|
|
487
|
-
message: `${HARNESS_PACKAGE} ${report.installed || report.pinned} is behind ${report.latest}. ${UPGRADE_NEXT}.`,
|
|
515
|
+
message: `${HARNESS_PACKAGE} ${report.installed || report.pinned} is behind ${report.latest}. ${report.autoUpgrade ? AUTO_UPGRADE_NEXT : UPGRADE_NEXT}.`,
|
|
488
516
|
};
|
|
489
517
|
}
|
|
490
518
|
return { block: false, message: null };
|
|
@@ -632,6 +660,7 @@ export async function runUpgrade(argv, options = {}) {
|
|
|
632
660
|
pinned: shouldInstall ? report.latest : report.pinned,
|
|
633
661
|
installed: shouldInstall ? report.latest : report.installed,
|
|
634
662
|
outdated: false,
|
|
663
|
+
autoUpgrade: false,
|
|
635
664
|
status: shouldInstall ? "upgraded" : refreshed.written ? "instructions_refreshed" : report.status,
|
|
636
665
|
instructionsImport: shouldRefresh ? true : report.instructionsImport,
|
|
637
666
|
refreshed: refreshed.written,
|
|
@@ -645,6 +674,6 @@ export async function runUpgrade(argv, options = {}) {
|
|
|
645
674
|
export function nagIfOutdated(report, write = (message) => console.error(message)) {
|
|
646
675
|
if (!report?.outdated) return;
|
|
647
676
|
write(
|
|
648
|
-
`foldspace: ${HARNESS_PACKAGE} ${report.pinned || report.installed} is behind ${report.latest}. ${UPGRADE_NEXT}.`,
|
|
677
|
+
`foldspace: ${HARNESS_PACKAGE} ${report.pinned || report.installed} is behind ${report.latest}. ${report.autoUpgrade ? AUTO_UPGRADE_NEXT : UPGRADE_NEXT}.`,
|
|
649
678
|
);
|
|
650
679
|
}
|
package/src/video/compose.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import os from "node:os";
|
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
|
|
18
|
-
import { findChrome } from "../chrome-binary.mjs";
|
|
18
|
+
import { KEEP_RUNNING_ARGS, findChrome } from "../chrome-binary.mjs";
|
|
19
19
|
import { scenePlace } from "./scenes.mjs";
|
|
20
20
|
import { TIMING, fitToLength, layoutClips, sceneTimeMap } from "./timeline.mjs";
|
|
21
21
|
|
|
@@ -180,8 +180,7 @@ const HEADLESS_ARGS = [
|
|
|
180
180
|
"--disable-default-apps",
|
|
181
181
|
"--disable-background-networking",
|
|
182
182
|
"--disable-component-update",
|
|
183
|
-
|
|
184
|
-
"--disable-renderer-backgrounding",
|
|
183
|
+
...KEEP_RUNNING_ARGS,
|
|
185
184
|
"--mute-audio",
|
|
186
185
|
"--hide-scrollbars",
|
|
187
186
|
];
|
|
@@ -474,3 +473,13 @@ export function needsChrome(chromePath) {
|
|
|
474
473
|
? `The video needs Google Chrome: this Chrome (${chromePath}) cannot encode H.264 video, as some Chromium builds cannot. Install Google Chrome, or set CHROME_PATH to it.`
|
|
475
474
|
: "The video needs Google Chrome, and none was found. Install Google Chrome, or set CHROME_PATH to it.";
|
|
476
475
|
}
|
|
476
|
+
|
|
477
|
+
/** Wider than this many page pixels, the product shrinks to fit a 1920-wide video and its small text gets hard to read on a phone. */
|
|
478
|
+
export const WIDE_WINDOW_PX = 1920;
|
|
479
|
+
|
|
480
|
+
/** A note when the test window was wider than WIDE_WINDOW_PX while it recorded; null otherwise. */
|
|
481
|
+
export function wideWindowNote(recording) {
|
|
482
|
+
const widest = Math.max(0, ...recording.scenes.map((scene) => scene.events?.viewport?.width || 0));
|
|
483
|
+
if (widest <= WIDE_WINDOW_PX) return null;
|
|
484
|
+
return `The test window was ${widest} px wide, so the product is shrunk to fit the video and small text may be hard to read on a phone. To fix it, make the test window narrower (about 1440-1600 px) and record again - the human decides.`;
|
|
485
|
+
}
|
package/src/video/recorder.mjs
CHANGED
|
@@ -39,6 +39,20 @@ export const RECORD_DEFAULTS = Object.freeze({
|
|
|
39
39
|
typingGapMaxMs: 1000,
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
// The pictures Chrome sends. It captures at the window's own pixel density -
|
|
43
|
+
// 2x on a Retina screen - and shrinks a picture to fit maxWidth x maxHeight.
|
|
44
|
+
// At maxWidth 1920 a Retina window 2336 CSS px wide came out 1920 px wide:
|
|
45
|
+
// below its own size, so the panel's 12-13px text was soft in the video. Twice
|
|
46
|
+
// the 1920x1080 video is all the composer can use; it downscales from there.
|
|
47
|
+
// The page, its layout and its size are untouched: nothing is zoomed.
|
|
48
|
+
export const SCREENCAST = Object.freeze({
|
|
49
|
+
format: "jpeg",
|
|
50
|
+
quality: 92,
|
|
51
|
+
maxWidth: 3840,
|
|
52
|
+
maxHeight: 2400,
|
|
53
|
+
everyNthFrame: 1,
|
|
54
|
+
});
|
|
55
|
+
|
|
42
56
|
/** Pause between keys: a question types in about 1.6 s, never faster than a quick typist. */
|
|
43
57
|
export function keyDelayMs(question) {
|
|
44
58
|
const length = [...String(question || "")].length || 1;
|
|
@@ -161,13 +175,7 @@ function screencastSink(page, sceneDir, { minGapMs }) {
|
|
|
161
175
|
async start() {
|
|
162
176
|
page.on("Page.screencastFrame", onFrame);
|
|
163
177
|
page.on("Page.screencastVisibilityChanged", onVisibility);
|
|
164
|
-
await page.send("Page.startScreencast", {
|
|
165
|
-
format: "jpeg",
|
|
166
|
-
quality: 85,
|
|
167
|
-
maxWidth: 1920,
|
|
168
|
-
maxHeight: 1200,
|
|
169
|
-
everyNthFrame: 1,
|
|
170
|
-
});
|
|
178
|
+
await page.send("Page.startScreencast", { ...SCREENCAST });
|
|
171
179
|
},
|
|
172
180
|
async stop() {
|
|
173
181
|
await page.send("Page.stopScreencast", {}, { timeoutMs: 5000 }).catch(() => {});
|