@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.
@@ -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(".");
@@ -1 +1 @@
1
- 0.1.30
1
+ 0.1.34
@@ -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`. Include an explicit port if the
33
- * app uses one — omitting `:443` can fail CORS.
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
  /**
@@ -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 `apiBase` with `Authorization: Bearer`.
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(`${getConfig().apiBase}${path}`, {
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(`${getConfig().apiBase}${path}`, {
410
+ const res = await fetch(`${apiBaseUrl()}${path}`, {
374
411
  ...init,
375
412
  signal: controller.signal,
376
413
  headers: { Authorization: `Bearer ${token}`, ...(init.headers ?? {}) },
@@ -17,6 +17,7 @@ export {
17
17
  redact,
18
18
  getAuthToken,
19
19
  parseJwt,
20
+ apiBaseUrl,
20
21
  apiFetch,
21
22
  apiFetchBinary,
22
23
  publicFetch,
@@ -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 - that stays the human's decision (CLAUDE.md, step 1).
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
- `${UPGRADE_NEXT}. If the human declines, rerun with FOLDSPACE_SKIP_UPDATE_CHECK=1 to run this version anyway.`,
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
  }
@@ -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
- "--disable-background-timer-throttling",
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
+ }
@@ -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(() => {});