leglas 1.3.0 → 1.4.0

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/dist/index.js CHANGED
@@ -1,17 +1,82 @@
1
+ // src/rules.ts
2
+ var MIN_PORT = 1;
3
+ var MAX_PORT = 65535;
4
+ var MIN_SHOW_WIDTH = 320;
5
+ var MAX_SHOW_WIDTH = 3840;
6
+ var DEFAULT_EXPLORE_COUNT = 3;
7
+ var DEFAULT_SHARE_REACH = "open";
8
+ function portRefusal(flag, port) {
9
+ return port < MIN_PORT || port > MAX_PORT ? `${flag} must be between ${MIN_PORT} and ${MAX_PORT}, received ${port}.` : null;
10
+ }
11
+ function valueRefusal(flag) {
12
+ return `${flag} needs a value.`;
13
+ }
14
+ function addRefusal(preview) {
15
+ const texts = [
16
+ ["--title", preview.title],
17
+ ["--url", preview.url],
18
+ ["--note", preview.note],
19
+ ["--branch", preview.branch],
20
+ ["--file", preview.file],
21
+ ["--based-on", preview.basedOn],
22
+ ["--asked-for", preview.askedFor],
23
+ ...(preview.tags ?? []).map((tag) => ["--tag", tag])
24
+ ];
25
+ const empty = texts.find(([, value]) => value === "");
26
+ if (empty !== void 0) return valueRefusal(empty[0]);
27
+ if (preview.url === void 0 && preview.file === void 0) {
28
+ return "leglas add needs --url (for example --url '/?v-hero=aurora') or --file for a page Leglas serves itself.";
29
+ }
30
+ return null;
31
+ }
32
+ function shareRefusal(options) {
33
+ const acts = [
34
+ options.stop && "--stop ends the share",
35
+ options.rotate === true && "--rotate replaces every link",
36
+ options.revoke !== void 0 && options.revoke !== null && "--revoke ends one link"
37
+ ].filter((act2) => act2 !== false);
38
+ if (acts.length > 1) {
39
+ return "leglas share takes one of --stop, --rotate or --revoke at a time.";
40
+ }
41
+ const [act] = acts;
42
+ if (act !== void 0 && (options.titles.length > 0 || options.reach !== void 0 || options.tunnel !== null)) {
43
+ return `leglas share ${act}; it takes no directions, reach or tunnel.`;
44
+ }
45
+ if (options.titles.length > 2) {
46
+ return "leglas share takes one direction to share alone, or two to compare. Name none to share the rail.";
47
+ }
48
+ return null;
49
+ }
50
+ function linkRefusal(titles) {
51
+ return titles.length > 2 ? "leglas link takes one direction, or two to put side by side. Name none for the rail." : null;
52
+ }
53
+ function widthRefusal(width) {
54
+ return width < MIN_SHOW_WIDTH || width > MAX_SHOW_WIDTH ? `--width must be between ${MIN_SHOW_WIDTH} and ${MAX_SHOW_WIDTH}, received ${width}.` : null;
55
+ }
56
+ function showRefusal(options) {
57
+ if (options.width !== null && !options.screenshot) {
58
+ return "leglas show --width needs --screenshot.";
59
+ }
60
+ if (options.port !== null && !options.screenshot) {
61
+ return "leglas show --port needs --screenshot.";
62
+ }
63
+ return null;
64
+ }
65
+ function fromRefusal(from) {
66
+ return from === "" ? "--from needs a path, for example --from src/Hero.tsx" : null;
67
+ }
68
+
1
69
  // src/args.ts
2
70
  var SHARE_TUNNELS = ["cloudflared", "ngrok", "none"];
3
71
  var VALUE_FLAGS = /* @__PURE__ */ new Set(["--port", "--user-port", "--config"]);
4
72
  var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["--no-open", "--json"]);
5
- var MIN_SHOW_WIDTH = 320;
6
- var MAX_SHOW_WIDTH = 3840;
7
73
  function parsePort(flag, raw) {
8
74
  if (!/^\d+$/.test(raw)) {
9
75
  return { error: `${flag} needs a number, received ${JSON.stringify(raw)}.` };
10
76
  }
11
77
  const port = Number(raw);
12
- if (port < 1 || port > 65535) {
13
- return { error: `${flag} must be between 1 and 65535, received ${port}.` };
14
- }
78
+ const refusal = portRefusal(flag, port);
79
+ if (refusal !== null) return { error: refusal };
15
80
  return { port };
16
81
  }
17
82
  function parseNew(rest) {
@@ -23,9 +88,8 @@ function parseNew(rest) {
23
88
  const argument = rest[index];
24
89
  if (argument === "--from" || argument.startsWith("--from=")) {
25
90
  from = argument.includes("=") ? argument.split("=").slice(1).join("=") : rest[index += 1];
26
- if (from === void 0 || from === "") {
27
- return { kind: "error", message: "--from needs a path, for example --from src/Hero.tsx" };
28
- }
91
+ const refusal = fromRefusal(from ?? "");
92
+ if (refusal !== null) return { kind: "error", message: refusal };
29
93
  continue;
30
94
  }
31
95
  if (argument === "--print") {
@@ -93,7 +157,7 @@ function parseAdd(rest) {
93
157
  return { kind: "error", message: `leglas add does not take ${flag}.` };
94
158
  }
95
159
  if (value === void 0 || value === "") {
96
- return { kind: "error", message: `${flag} needs a value.` };
160
+ return { kind: "error", message: valueRefusal(flag) };
97
161
  }
98
162
  if (flag === "--title") title = value;
99
163
  else if (flag === "--url") url = value;
@@ -110,26 +174,19 @@ function parseAdd(rest) {
110
174
  message: "leglas add needs --title, which is how the preview is identified."
111
175
  };
112
176
  }
113
- if (url === void 0 && file === void 0) {
114
- return {
115
- kind: "error",
116
- message: "leglas add needs --url (for example --url '/?v-hero=aurora') or --file for a page Leglas serves itself."
117
- };
118
- }
119
- return {
120
- kind: "add",
121
- preview: {
122
- title,
123
- url,
124
- note,
125
- tags: tags.length > 0 ? tags : void 0,
126
- branch,
127
- file,
128
- basedOn,
129
- askedFor
130
- },
131
- json
177
+ const preview = {
178
+ title,
179
+ url,
180
+ note,
181
+ tags: tags.length > 0 ? tags : void 0,
182
+ branch,
183
+ file,
184
+ basedOn,
185
+ askedFor
132
186
  };
187
+ const refusal = addRefusal(preview);
188
+ if (refusal !== null) return { kind: "error", message: refusal };
189
+ return { kind: "add", preview, json };
133
190
  }
134
191
  function parseClassify(rest) {
135
192
  const changes = [];
@@ -178,7 +235,7 @@ function parseWatch(rest) {
178
235
  if (value === void 0 || value === "") {
179
236
  return {
180
237
  kind: "error",
181
- message: flag === "--run" ? '--run needs an agent command, for example --run "claude -p {prompt}"' : "--port needs a value."
238
+ message: flag === "--run" ? '--run needs an agent command, for example --run "claude -p {prompt}"' : valueRefusal(flag)
182
239
  };
183
240
  }
184
241
  if (flag === "--run") {
@@ -193,10 +250,12 @@ function parseWatch(rest) {
193
250
  }
194
251
  function parseShare(rest) {
195
252
  const titles = [];
196
- let reach = "open";
253
+ let reach = DEFAULT_SHARE_REACH;
197
254
  let reachGiven = false;
198
255
  let tunnel = null;
199
256
  let stop = false;
257
+ let rotate = false;
258
+ let revoke = null;
200
259
  let port = null;
201
260
  let json = false;
202
261
  for (let index = 0; index < rest.length; index += 1) {
@@ -209,12 +268,22 @@ function parseShare(rest) {
209
268
  stop = true;
210
269
  continue;
211
270
  }
271
+ if (argument === "--rotate") {
272
+ rotate = true;
273
+ continue;
274
+ }
212
275
  const equals = argument.indexOf("=");
213
276
  const flag = equals === -1 ? argument : argument.slice(0, equals);
277
+ if (flag === "--revoke") {
278
+ const raw = equals === -1 ? rest[index += 1] : argument.slice(equals + 1);
279
+ if (raw === void 0 || raw === "") return { kind: "error", message: valueRefusal(flag) };
280
+ revoke = raw;
281
+ continue;
282
+ }
214
283
  if (flag === "--reach" || flag === "--tunnel" || flag === "--port") {
215
284
  const raw = equals === -1 ? rest[index += 1] : argument.slice(equals + 1);
216
285
  if (raw === void 0 || raw === "") {
217
- return { kind: "error", message: `${flag} needs a value.` };
286
+ return { kind: "error", message: valueRefusal(flag) };
218
287
  }
219
288
  if (flag === "--port") {
220
289
  const parsed = parsePort(flag, raw);
@@ -248,19 +317,45 @@ function parseShare(rest) {
248
317
  }
249
318
  titles.push(argument);
250
319
  }
251
- if (stop && (titles.length > 0 || reachGiven || tunnel !== null)) {
252
- return {
253
- kind: "error",
254
- message: "leglas share --stop ends the share; it takes no directions, reach or tunnel."
255
- };
256
- }
257
- if (titles.length > 2) {
258
- return {
259
- kind: "error",
260
- message: "leglas share takes one direction to share alone, or two to compare. Name none to share the rail."
261
- };
320
+ const refusal = shareRefusal({
321
+ titles,
322
+ reach: reachGiven ? reach : void 0,
323
+ tunnel,
324
+ stop,
325
+ rotate,
326
+ revoke
327
+ });
328
+ if (refusal !== null) return { kind: "error", message: refusal };
329
+ return { kind: "share", titles, reach, tunnel, stop, rotate, revoke, port, json };
330
+ }
331
+ function parseLink(rest) {
332
+ const titles = [];
333
+ let port = null;
334
+ let json = false;
335
+ for (let index = 0; index < rest.length; index += 1) {
336
+ const argument = rest[index];
337
+ if (argument === "--json") {
338
+ json = true;
339
+ continue;
340
+ }
341
+ const equals = argument.indexOf("=");
342
+ const flag = equals === -1 ? argument : argument.slice(0, equals);
343
+ if (flag === "--port") {
344
+ const raw = equals === -1 ? rest[index += 1] : argument.slice(equals + 1);
345
+ if (raw === void 0 || raw === "") return { kind: "error", message: valueRefusal(flag) };
346
+ const parsed = parsePort(flag, raw);
347
+ if ("error" in parsed) return { kind: "error", message: parsed.error };
348
+ port = parsed.port;
349
+ continue;
350
+ }
351
+ if (argument.startsWith("-")) {
352
+ return { kind: "error", message: `leglas link does not take ${argument}.` };
353
+ }
354
+ titles.push(argument);
262
355
  }
263
- return { kind: "share", titles, reach, tunnel, stop, port, json };
356
+ const refusal = linkRefusal(titles);
357
+ if (refusal !== null) return { kind: "error", message: refusal };
358
+ return { kind: "link", titles, port, json };
264
359
  }
265
360
  function parseArgs(argv) {
266
361
  const [command] = argv;
@@ -325,7 +420,7 @@ function parseArgs(argv) {
325
420
  if (argv[0] === "explore") {
326
421
  const rest = argv.slice(1);
327
422
  let surface;
328
- let count = 3;
423
+ let count = DEFAULT_EXPLORE_COUNT;
329
424
  let basedOn = null;
330
425
  let json = false;
331
426
  let build = false;
@@ -458,7 +553,7 @@ function parseArgs(argv) {
458
553
  const flag = equals === -1 ? argument : argument.slice(0, equals);
459
554
  const raw = equals === -1 ? rest[index += 1] : argument.slice(equals + 1);
460
555
  if (raw === void 0 || raw === "") {
461
- return { kind: "error", message: `${flag} needs a value.` };
556
+ return { kind: "error", message: valueRefusal(flag) };
462
557
  }
463
558
  if (flag === "--port") {
464
559
  const parsed = parsePort(flag, raw);
@@ -473,12 +568,8 @@ function parseArgs(argv) {
473
568
  };
474
569
  }
475
570
  width = Number(raw);
476
- if (width < MIN_SHOW_WIDTH || width > MAX_SHOW_WIDTH) {
477
- return {
478
- kind: "error",
479
- message: `--width must be between ${MIN_SHOW_WIDTH} and ${MAX_SHOW_WIDTH}, received ${width}.`
480
- };
481
- }
571
+ const refusal2 = widthRefusal(width);
572
+ if (refusal2 !== null) return { kind: "error", message: refusal2 };
482
573
  continue;
483
574
  }
484
575
  if (argument.startsWith("-")) {
@@ -495,15 +586,27 @@ function parseArgs(argv) {
495
586
  message: 'leglas show needs a direction title, for example: npx leglas show "Aurora" --json'
496
587
  };
497
588
  }
498
- if (width !== null && !screenshot) {
499
- return { kind: "error", message: "leglas show --width needs --screenshot." };
500
- }
501
- if (port !== null && !screenshot) {
502
- return { kind: "error", message: "leglas show --port needs --screenshot." };
503
- }
589
+ const refusal = showRefusal({ screenshot, width, port });
590
+ if (refusal !== null) return { kind: "error", message: refusal };
504
591
  return { kind: "show", title, json, screenshot, width, port };
505
592
  }
506
593
  if (argv[0] === "share") return parseShare(argv.slice(1));
594
+ if (argv[0] === "link") return parseLink(argv.slice(1));
595
+ if (argv[0] === "remove") {
596
+ const rest = argv.slice(1);
597
+ const unknown = rest.find((argument) => argument.startsWith("-") && argument !== "--json");
598
+ if (unknown !== void 0) {
599
+ return { kind: "error", message: `leglas remove does not take ${unknown}.` };
600
+ }
601
+ const titles = rest.filter((argument) => argument !== "--json");
602
+ if (titles.length === 0) {
603
+ return {
604
+ kind: "error",
605
+ message: 'leglas remove needs a direction title, for example: npx leglas remove "Aurora"'
606
+ };
607
+ }
608
+ return { kind: "remove", titles, json: rest.includes("--json") };
609
+ }
507
610
  const options = {
508
611
  port: void 0,
509
612
  userPort: void 0,
@@ -536,7 +639,7 @@ function parseArgs(argv) {
536
639
  value = argument.slice(equals + 1);
537
640
  }
538
641
  if (value === void 0 || value === "" || value.startsWith("--")) {
539
- return { kind: "error", message: `${flag} needs a value.` };
642
+ return { kind: "error", message: valueRefusal(flag) };
540
643
  }
541
644
  if (flag === "--config") {
542
645
  options.configPath = value;
@@ -909,6 +1012,12 @@ Useful to know:
909
1012
  - Titles identify previews and must be unique. The user may rename one in the
910
1013
  rail, which renames it on their machine only; the commands answer to either
911
1014
  name, so use whichever they said.
1015
+ - \`npx leglas link "<title>"\` prints a link that opens the interface on that
1016
+ direction, and \`npx leglas link "<new>" "<old>"\` one that puts two side by
1017
+ side. Give it to the user when you finish a direction they asked for, rather
1018
+ than telling them where to click.
1019
+ - \`npx leglas remove "<title>"\` takes a direction registered on this machine
1020
+ off the rail when the user asks. One the config lists is theirs to edit.
912
1021
  - \`npx leglas list\` shows every direction, shared and local.
913
1022
  - \`npx leglas show "<title>" --json\` answers for one of them: the file behind it,
914
1023
  the variants based on it, what it is being compared against and anything
@@ -956,7 +1065,7 @@ ${AGENTS_SECTION}`
956
1065
  }
957
1066
 
958
1067
  // src/keep.ts
959
- import { basename as basename4, extname as extname4, isAbsolute as isAbsolute2, normalize as normalize3 } from "path";
1068
+ import { basename as basename4, extname as extname4, isAbsolute as isAbsolute2, normalize as normalize5 } from "path";
960
1069
 
961
1070
  // ../server/dist/log.js
962
1071
  var DEFAULT_LOG_DIR = "design-log";
@@ -1185,13 +1294,18 @@ function normalizeConfig(raw, options = {}) {
1185
1294
  if (!isBoolean(scanPreviews)) {
1186
1295
  errors.push("scanPreviews must be a boolean.");
1187
1296
  }
1188
- if (errors.length > 0 || !isString(devServer) || !isBoolean(scanPreviews) || !isString(installCommand) || !isString(logDir))
1297
+ const recordSets = source["recordSets"] ?? true;
1298
+ if (!isBoolean(recordSets)) {
1299
+ errors.push("recordSets must be a boolean.");
1300
+ }
1301
+ if (errors.length > 0 || !isString(devServer) || !isBoolean(scanPreviews) || !isBoolean(recordSets) || !isString(installCommand) || !isString(logDir))
1189
1302
  return { config: null, errors };
1190
1303
  return {
1191
1304
  config: {
1192
1305
  devServer,
1193
1306
  previews,
1194
1307
  scanPreviews,
1308
+ recordSets,
1195
1309
  devCommand: isString(devCommand) ? devCommand : void 0,
1196
1310
  installCommand,
1197
1311
  logDir
@@ -1284,9 +1398,9 @@ function nextRequest(requests, failed) {
1284
1398
 
1285
1399
  // ../server/dist/agents/agents.js
1286
1400
  import { spawn } from "child_process";
1287
- import { constants, readdirSync } from "fs";
1401
+ import { constants, readdirSync, realpathSync } from "fs";
1288
1402
  import { access, mkdir, readFile, writeFile } from "fs/promises";
1289
- import { delimiter, dirname, isAbsolute, join, relative } from "path";
1403
+ import { delimiter, dirname, isAbsolute, join, normalize, relative } from "path";
1290
1404
  var AGENT_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
1291
1405
  var effortFlag = (effort) => effort === null ? [] : ["--effort", effort];
1292
1406
  var codexEffortConfig = (effort) => effort === null ? [] : ["-c", `model_reasoning_effort=${effort}`];
@@ -1437,13 +1551,15 @@ var KNOWN_AGENTS = {
1437
1551
  // the earlier turn.
1438
1552
  activityVerified: true,
1439
1553
  authArgs: ["status"],
1440
- // The signed-in form was read from the CLI: "✓ Logged in as <email>",
1441
- // exit 0. The signed-out form has not been, so that side of the reading
1442
- // stays loose, and anything ambiguous stays unknown.
1554
+ // Both answers read from cursor-agent 2026.09.02, and both exit 0: "✓ Logged
1555
+ // in as <email>" and "Not logged in". The second contains the first's
1556
+ // words, so a "not logged in" or "not signed in" is read first.
1443
1557
  authVerdict: (result) => {
1558
+ if (/not (logged|signed) in/i.test(result.stdout))
1559
+ return "signed-out";
1444
1560
  if (/logged in|signed in/i.test(result.stdout))
1445
1561
  return "ok";
1446
- if (result.code !== 0 || /not logged in|log in|sign in/i.test(result.stdout))
1562
+ if (result.code !== 0 || /log in|sign in/i.test(result.stdout))
1447
1563
  return "signed-out";
1448
1564
  return "unknown";
1449
1565
  }
@@ -1663,6 +1779,45 @@ function cursorActivity(event, cwd) {
1663
1779
  const path = shownPath(args?.path, cwd);
1664
1780
  return path === null ? `using ${tool}` : `using ${tool} on ${path}`;
1665
1781
  }
1782
+ function editedFiles(agent, line, cwd) {
1783
+ let event;
1784
+ try {
1785
+ event = record(parseJson(line));
1786
+ } catch {
1787
+ return [];
1788
+ }
1789
+ const paths = [];
1790
+ if (agent === "claude" && event?.type === "assistant") {
1791
+ const content = record(event.message)?.content;
1792
+ for (const rawBlock of Array.isArray(content) ? content : []) {
1793
+ const block = record(rawBlock);
1794
+ const input = record(block?.input);
1795
+ if (block?.type === "tool_use" && isString(block.name) && ["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(block.name))
1796
+ paths.push(input?.file_path ?? input?.notebook_path ?? null);
1797
+ }
1798
+ }
1799
+ if (agent === "codex" && (event?.type === "item.started" || event?.type === "item.completed") && record(event.item)?.type === "file_change") {
1800
+ const item = record(event.item);
1801
+ for (const change of Array.isArray(item?.changes) ? item.changes : [])
1802
+ paths.push(record(change)?.path ?? null);
1803
+ paths.push(item?.path ?? null);
1804
+ }
1805
+ if (paths.length === 0)
1806
+ return [];
1807
+ let root = cwd;
1808
+ try {
1809
+ root = realpathSync(cwd);
1810
+ } catch {
1811
+ }
1812
+ return paths.flatMap((path) => {
1813
+ if (!isString(path) || path === "")
1814
+ return [];
1815
+ if (!isAbsolute(path))
1816
+ return [normalize(path)];
1817
+ const shown = [relative(cwd, path), relative(root, path)].find((form) => !form.startsWith(".."));
1818
+ return [normalize(shown ?? path)];
1819
+ });
1820
+ }
1666
1821
  function activityFrom(agent, line, cwd = process.cwd()) {
1667
1822
  let event;
1668
1823
  try {
@@ -1888,9 +2043,21 @@ async function loadConfig(cwd) {
1888
2043
  }
1889
2044
 
1890
2045
  // ../server/dist/config/local-previews.js
1891
- import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
2046
+ import { randomUUID } from "crypto";
2047
+ import { mkdir as mkdir2, readFile as readFile3, rename, rm, writeFile as writeFile2 } from "fs/promises";
1892
2048
  import { dirname as dirname3, join as join3 } from "path";
1893
2049
  var LOCAL_PREVIEWS_PATH = ".leglas/previews.json";
2050
+ async function writeRegistry(path, stored) {
2051
+ await mkdir2(dirname3(path), { recursive: true });
2052
+ const temporary = `${path}.${randomUUID()}.tmp`;
2053
+ await writeFile2(temporary, `${JSON.stringify({ previews: stored }, null, 2)}
2054
+ `, "utf8");
2055
+ await rename(temporary, path).catch(async (cause) => {
2056
+ await rm(temporary, { force: true }).catch(() => {
2057
+ });
2058
+ throw cause;
2059
+ });
2060
+ }
1894
2061
  async function readLocalPreviews(cwd) {
1895
2062
  const path = join3(cwd, LOCAL_PREVIEWS_PATH);
1896
2063
  let raw;
@@ -1962,10 +2129,10 @@ async function addLocalPreview(cwd, input, shared) {
1962
2129
  if (check.config === null) {
1963
2130
  return { ok: false, error: check.errors.join(" ") };
1964
2131
  }
1965
- const path = join3(cwd, LOCAL_PREVIEWS_PATH);
1966
- await mkdir2(dirname3(path), { recursive: true });
1967
- await writeFile2(path, `${JSON.stringify({ previews: [...existing.previews.map(toStored), candidate] }, null, 2)}
1968
- `, "utf8");
2132
+ await writeRegistry(join3(cwd, LOCAL_PREVIEWS_PATH), [
2133
+ ...existing.previews.map(toStored),
2134
+ candidate
2135
+ ]);
1969
2136
  return { ok: true };
1970
2137
  }
1971
2138
  function toStored(preview) {
@@ -1977,13 +2144,127 @@ async function dropLocalPreviews(cwd, titles) {
1977
2144
  const keep = existing.previews.filter((preview) => !titles.includes(preview.title));
1978
2145
  if (keep.length === existing.previews.length)
1979
2146
  return 0;
1980
- const path = join3(cwd, LOCAL_PREVIEWS_PATH);
1981
- await mkdir2(dirname3(path), { recursive: true });
1982
- await writeFile2(path, `${JSON.stringify({ previews: keep.map(toStored) }, null, 2)}
1983
- `, "utf8");
2147
+ await writeRegistry(join3(cwd, LOCAL_PREVIEWS_PATH), keep.map(toStored));
1984
2148
  return existing.previews.length - keep.length;
1985
2149
  }
1986
2150
 
2151
+ // ../server/dist/generation/record.js
2152
+ import { randomUUID as randomUUID2 } from "crypto";
2153
+ import { appendFile, mkdir as mkdir3, readdir, readFile as readFile4, rename as rename2, rm as rm2, writeFile as writeFile3 } from "fs/promises";
2154
+ import { join as join4 } from "path";
2155
+ var RECORDS_DIR = ".leglas/generations";
2156
+ var KEPT_RECORDS = 100;
2157
+ async function recordIds(cwd) {
2158
+ const names = await readdir(join4(cwd, RECORDS_DIR)).catch(() => []);
2159
+ const started = (id) => Number.parseInt(id.slice(4), 36);
2160
+ return names.filter((name) => /^gen-[a-z0-9]+$/.test(name)).sort((a, b) => started(b) - started(a));
2161
+ }
2162
+ async function directionsOf(cwd, id) {
2163
+ let parsed;
2164
+ try {
2165
+ parsed = parseJson(await readFile4(join4(cwd, RECORDS_DIR, id, "set.json"), "utf8"));
2166
+ } catch {
2167
+ return [];
2168
+ }
2169
+ if (!isJsonRecord(parsed) || !Array.isArray(parsed.directions))
2170
+ return [];
2171
+ return parsed.directions.flatMap((direction) => {
2172
+ if (!isJsonRecord(direction) || !isString(direction.key))
2173
+ return [];
2174
+ const attempts2 = Array.isArray(direction.attempts) ? direction.attempts : [];
2175
+ const last = attempts2.at(-1);
2176
+ return [
2177
+ {
2178
+ key: direction.key,
2179
+ title: isJsonRecord(last) && isString(last.title) ? last.title : null
2180
+ }
2181
+ ];
2182
+ });
2183
+ }
2184
+ async function appendEvent(cwd, id, event) {
2185
+ const dir = join4(cwd, RECORDS_DIR, id);
2186
+ await mkdir3(dir, { recursive: true });
2187
+ await appendFile(join4(dir, "events.jsonl"), `${JSON.stringify(event)}
2188
+ `, "utf8");
2189
+ }
2190
+ async function setsOfTitles(cwd, titles) {
2191
+ const found = /* @__PURE__ */ new Map();
2192
+ for (const id of await recordIds(cwd)) {
2193
+ if (found.size === titles.length)
2194
+ break;
2195
+ for (const direction of await directionsOf(cwd, id)) {
2196
+ const title = direction.title;
2197
+ if (title !== null && titles.includes(title) && !found.has(title))
2198
+ found.set(title, { id, key: direction.key });
2199
+ }
2200
+ }
2201
+ return found;
2202
+ }
2203
+ async function setOfKey(cwd, key) {
2204
+ for (const id of await recordIds(cwd)) {
2205
+ if ((await directionsOf(cwd, id)).some((each) => each.key === key))
2206
+ return id;
2207
+ }
2208
+ return null;
2209
+ }
2210
+ async function noteDirections(cwd, titles, kind, detail) {
2211
+ const unique = [...new Set(titles)];
2212
+ const found = await setsOfTitles(cwd, unique).catch(() => /* @__PURE__ */ new Map());
2213
+ for (const title of unique) {
2214
+ const set = found.get(title);
2215
+ if (set === void 0)
2216
+ continue;
2217
+ const event = { at: Date.now(), kind, direction: set.key };
2218
+ if (detail !== void 0)
2219
+ event.detail = detail;
2220
+ await appendEvent(cwd, set.id, event).catch(() => {
2221
+ });
2222
+ }
2223
+ }
2224
+ function createRecords(cwd) {
2225
+ let queue = Promise.resolve();
2226
+ const enqueue = (task) => {
2227
+ queue = queue.then(task).catch(() => {
2228
+ });
2229
+ };
2230
+ return {
2231
+ async prune() {
2232
+ const ids = await recordIds(cwd);
2233
+ for (const id of ids.slice(KEPT_RECORDS - 1)) {
2234
+ await rm2(join4(cwd, RECORDS_DIR, id), { recursive: true, force: true }).catch(() => {
2235
+ });
2236
+ }
2237
+ },
2238
+ save(record3) {
2239
+ const body = `${JSON.stringify(record3, null, 2)}
2240
+ `;
2241
+ enqueue(async () => {
2242
+ const dir = join4(cwd, RECORDS_DIR, record3.id);
2243
+ const path = join4(dir, "set.json");
2244
+ const temporary = `${path}.${randomUUID2()}.tmp`;
2245
+ await mkdir3(dir, { recursive: true });
2246
+ await writeFile3(temporary, body, "utf8");
2247
+ await rename2(temporary, path).catch(async (cause) => {
2248
+ await rm2(temporary, { force: true }).catch(() => {
2249
+ });
2250
+ throw cause;
2251
+ });
2252
+ });
2253
+ },
2254
+ picture(id, name, image) {
2255
+ enqueue(async () => {
2256
+ const dir = join4(cwd, RECORDS_DIR, id);
2257
+ await mkdir3(dir, { recursive: true });
2258
+ await writeFile3(join4(dir, name), image);
2259
+ });
2260
+ },
2261
+ event(id, event) {
2262
+ enqueue(() => appendEvent(cwd, id, event));
2263
+ },
2264
+ flush: () => queue
2265
+ };
2266
+ }
2267
+
1987
2268
  // ../server/dist/proxy.js
1988
2269
  import http, {} from "http";
1989
2270
  import net from "net";
@@ -2152,9 +2433,9 @@ import { randomBytes } from "crypto";
2152
2433
  import nodeProcess from "process";
2153
2434
  import { spawn as nodeSpawn } from "child_process";
2154
2435
  import { accessSync, constants as constants2, existsSync as existsSync2, mkdirSync, readdirSync as readdirSync2, writeFileSync } from "fs";
2155
- import { readFile as readFile4, readdir, rm, stat } from "fs/promises";
2436
+ import { readFile as readFile5, readdir as readdir2, rm as rm3, stat } from "fs/promises";
2156
2437
  import { homedir, tmpdir as osTmpdir } from "os";
2157
- import { delimiter as delimiter2, join as join4 } from "path";
2438
+ import { delimiter as delimiter2, join as join5 } from "path";
2158
2439
  var NO_BROWSER = "No Chrome, Chromium, Brave or Edge was found on this machine, so Leglas could not take a screenshot. On a machine with no browser at all, `npx playwright install chromium` puts one where Leglas will find it. Set LEGLAS_BROWSER to point at a specific binary.";
2159
2440
  var DARWIN_APPS = [
2160
2441
  "Google Chrome.app/Contents/MacOS/Google Chrome",
@@ -2187,7 +2468,7 @@ function firstOnPath(name, env) {
2187
2468
  for (const directory of (env.PATH ?? "").split(delimiter2)) {
2188
2469
  if (directory === "")
2189
2470
  continue;
2190
- const candidate = join4(directory, name);
2471
+ const candidate = join5(directory, name);
2191
2472
  try {
2192
2473
  accessSync(candidate, constants2.X_OK);
2193
2474
  return candidate;
@@ -2225,17 +2506,17 @@ function buildNumber(entry) {
2225
2506
  const digits = /(\d+)\s*$/.exec(entry)?.[1];
2226
2507
  return digits === void 0 ? 0 : Number(digits);
2227
2508
  }
2228
- function cacheRoots(platform, home, readdir6 = readableDirectories) {
2229
- const playwright = platform === "darwin" ? join4(home, "Library", "Caches", "ms-playwright") : join4(home, ".cache", "ms-playwright");
2230
- const puppeteer = join4(home, ".cache", "puppeteer");
2509
+ function cacheRoots(platform, home, readdir7 = readableDirectories) {
2510
+ const playwright = platform === "darwin" ? join5(home, "Library", "Caches", "ms-playwright") : join5(home, ".cache", "ms-playwright");
2511
+ const puppeteer = join5(home, ".cache", "puppeteer");
2231
2512
  const newestFirst = (dir, keep = () => true) => ({
2232
2513
  root: dir,
2233
- entries: readdir6(dir).filter(keep).sort((left, right) => buildNumber(right) - buildNumber(left))
2514
+ entries: readdir7(dir).filter(keep).sort((left, right) => buildNumber(right) - buildNumber(left))
2234
2515
  });
2235
2516
  return [
2236
2517
  newestFirst(playwright, (entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")),
2237
- newestFirst(join4(puppeteer, "chrome-headless-shell")),
2238
- newestFirst(join4(puppeteer, "chrome"))
2518
+ newestFirst(join5(puppeteer, "chrome-headless-shell")),
2519
+ newestFirst(join5(puppeteer, "chrome"))
2239
2520
  ];
2240
2521
  }
2241
2522
  function readableDirectories(dir) {
@@ -2251,18 +2532,18 @@ function findBrowser(search = {}) {
2251
2532
  const home = search.home ?? homedir();
2252
2533
  const exists = search.exists ?? existsSync2;
2253
2534
  const onPath = search.onPath ?? ((name) => firstOnPath(name, env));
2254
- const readdir6 = search.readdir ?? readableDirectories;
2535
+ const readdir7 = search.readdir ?? readableDirectories;
2255
2536
  const firstExisting = (paths) => paths.find((path) => exists(path)) ?? null;
2256
2537
  for (const candidate of [env.LEGLAS_BROWSER, env.CHROME_PATH, env.PUPPETEER_EXECUTABLE_PATH]) {
2257
2538
  if (isString(candidate) && candidate !== "" && exists(candidate))
2258
2539
  return candidate;
2259
2540
  }
2260
- const caches = cacheRoots(platform, home, readdir6);
2261
- const shell = firstExisting(caches.flatMap(({ root, entries }) => entries.flatMap((entry) => HEADLESS_SHELL.map((rest) => join4(root, entry, ...rest)))));
2541
+ const caches = cacheRoots(platform, home, readdir7);
2542
+ const shell = firstExisting(caches.flatMap(({ root, entries }) => entries.flatMap((entry) => HEADLESS_SHELL.map((rest) => join5(root, entry, ...rest)))));
2262
2543
  if (shell !== null)
2263
2544
  return shell;
2264
2545
  if (platform === "darwin") {
2265
- const installed = firstExisting(["/Applications", join4(home, "Applications")].flatMap((root) => DARWIN_APPS.map((app) => join4(root, app))));
2546
+ const installed = firstExisting(["/Applications", join5(home, "Applications")].flatMap((root) => DARWIN_APPS.map((app) => join5(root, app))));
2266
2547
  if (installed !== null)
2267
2548
  return installed;
2268
2549
  }
@@ -2273,7 +2554,7 @@ function findBrowser(search = {}) {
2273
2554
  return found;
2274
2555
  }
2275
2556
  const installed = firstExisting([
2276
- ...LINUX_NAMES.flatMap((name) => [join4("/usr/bin", name), join4("/snap/bin", name)]),
2557
+ ...LINUX_NAMES.flatMap((name) => [join5("/usr/bin", name), join5("/snap/bin", name)]),
2277
2558
  "/opt/google/chrome/chrome"
2278
2559
  ]);
2279
2560
  if (installed !== null)
@@ -2281,32 +2562,32 @@ function findBrowser(search = {}) {
2281
2562
  }
2282
2563
  if (platform === "win32") {
2283
2564
  const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter((entry) => isString(entry) && entry !== "");
2284
- const installed = firstExisting(roots.flatMap((root) => WINDOWS_BROWSERS.map((browser) => join4(root, browser))));
2565
+ const installed = firstExisting(roots.flatMap((root) => WINDOWS_BROWSERS.map((browser) => join5(root, browser))));
2285
2566
  if (installed !== null)
2286
2567
  return installed;
2287
2568
  }
2288
2569
  if (platform === "darwin" || platform === "linux") {
2289
- const playwrightRoot = platform === "darwin" ? join4(home, "Library", "Caches", "ms-playwright") : join4(home, ".cache", "ms-playwright");
2290
- const playwright = readdir6(playwrightRoot).filter((entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
2291
- const root = join4(playwrightRoot, entry);
2570
+ const playwrightRoot = platform === "darwin" ? join5(home, "Library", "Caches", "ms-playwright") : join5(home, ".cache", "ms-playwright");
2571
+ const playwright = readdir7(playwrightRoot).filter((entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
2572
+ const root = join5(playwrightRoot, entry);
2292
2573
  return [
2293
- ...FOR_TESTING.map((rest) => join4(root, ...rest)),
2294
- ...HEADLESS_SHELL.map((rest) => join4(root, ...rest)),
2574
+ ...FOR_TESTING.map((rest) => join5(root, ...rest)),
2575
+ ...HEADLESS_SHELL.map((rest) => join5(root, ...rest)),
2295
2576
  // Older builds shipped a plain Chromium app.
2296
- join4(root, "chrome-mac-arm64", "Chromium.app", "Contents", "MacOS", "Chromium"),
2297
- join4(root, "chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"),
2298
- join4(root, "chrome-linux", "chrome")
2577
+ join5(root, "chrome-mac-arm64", "Chromium.app", "Contents", "MacOS", "Chromium"),
2578
+ join5(root, "chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"),
2579
+ join5(root, "chrome-linux", "chrome")
2299
2580
  ];
2300
2581
  });
2301
2582
  const playwrightBrowser = firstExisting(playwright);
2302
2583
  if (playwrightBrowser !== null)
2303
2584
  return playwrightBrowser;
2304
- const puppeteerCache = join4(home, ".cache", "puppeteer");
2585
+ const puppeteerCache = join5(home, ".cache", "puppeteer");
2305
2586
  for (const kind of ["chrome", "chrome-headless-shell"]) {
2306
- const kindRoot = join4(puppeteerCache, kind);
2307
- const found = firstExisting(readdir6(kindRoot).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
2308
- const root = join4(kindRoot, entry);
2309
- return [...FOR_TESTING, ...HEADLESS_SHELL].map((rest) => join4(root, ...rest));
2587
+ const kindRoot = join5(puppeteerCache, kind);
2588
+ const found = firstExisting(readdir7(kindRoot).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
2589
+ const root = join5(kindRoot, entry);
2590
+ return [...FOR_TESTING, ...HEADLESS_SHELL].map((rest) => join5(root, ...rest));
2310
2591
  }));
2311
2592
  if (found !== null)
2312
2593
  return found;
@@ -2431,8 +2712,8 @@ async function closeOrphan(url, connect) {
2431
2712
  async function reapOrphanedBrowsers(deps = {}) {
2432
2713
  const root = deps.tmpdir ?? osTmpdir();
2433
2714
  const clock2 = deps.now ?? (() => Date.now());
2434
- const list = deps.list ?? (async (path) => (await readdir(path, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name));
2435
- const read = deps.read ?? ((path) => readFile4(path, "utf8"));
2715
+ const list = deps.list ?? (async (path) => (await readdir2(path, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name));
2716
+ const read = deps.read ?? ((path) => readFile5(path, "utf8"));
2436
2717
  const alive = deps.alive ?? livePid;
2437
2718
  const profileOf = deps.profile ?? (async (path) => {
2438
2719
  const entry = await stat(path);
@@ -2441,7 +2722,7 @@ async function reapOrphanedBrowsers(deps = {}) {
2441
2722
  const currentUid = deps.uid ?? (() => nodeProcess.getuid?.() ?? null);
2442
2723
  const connect = deps.connect ?? connectWebSocket;
2443
2724
  const remove = deps.remove ?? (async (path) => {
2444
- await rm(path, { recursive: true, force: true });
2725
+ await rm3(path, { recursive: true, force: true });
2445
2726
  });
2446
2727
  let names;
2447
2728
  try {
@@ -2454,7 +2735,7 @@ async function reapOrphanedBrowsers(deps = {}) {
2454
2735
  for (const name of names) {
2455
2736
  if (!name.startsWith(PROFILE_PREFIX))
2456
2737
  continue;
2457
- const directory = join4(root, name);
2738
+ const directory = join5(root, name);
2458
2739
  const details = await profileOf(directory).catch(() => null);
2459
2740
  if (details === null)
2460
2741
  continue;
@@ -2462,7 +2743,7 @@ async function reapOrphanedBrowsers(deps = {}) {
2462
2743
  continue;
2463
2744
  let record3 = null;
2464
2745
  try {
2465
- const raw = await read(join4(directory, OWNER_FILE));
2746
+ const raw = await read(join5(directory, OWNER_FILE));
2466
2747
  const parsed = raw === null ? null : parseJson(raw);
2467
2748
  record3 = isJsonRecord(parsed) ? parsed : null;
2468
2749
  } catch {
@@ -2484,11 +2765,11 @@ async function launchBrowser(executable, options = {}) {
2484
2765
  const spawn5 = options.spawn ?? nodeSpawn;
2485
2766
  const connect = options.connect ?? connectWebSocket;
2486
2767
  const commandTimeoutMs = options.commandTimeoutMs ?? 3e4;
2487
- const userDataDir = join4(options.tmpdir ?? osTmpdir(), `leglas-browser-${randomBytes(8).toString("hex")}`);
2768
+ const userDataDir = join5(options.tmpdir ?? osTmpdir(), `leglas-browser-${randomBytes(8).toString("hex")}`);
2488
2769
  const owned = (fields) => {
2489
2770
  try {
2490
2771
  mkdirSync(userDataDir, { recursive: true, mode: 448 });
2491
- writeFileSync(join4(userDataDir, OWNER_FILE), `${JSON.stringify({ owner: nodeProcess.pid, ...fields })}
2772
+ writeFileSync(join5(userDataDir, OWNER_FILE), `${JSON.stringify({ owner: nodeProcess.pid, ...fields })}
2492
2773
  `, { encoding: "utf8", mode: 384 });
2493
2774
  } catch {
2494
2775
  }
@@ -2508,11 +2789,10 @@ async function launchBrowser(executable, options = {}) {
2508
2789
  "--mute-audio",
2509
2790
  "--force-color-profile=srgb",
2510
2791
  "--window-size=1440,900",
2511
- // Everything a desktop browser does on the way up that a screenshot
2512
- // does not need. Measured on this project: about 300MB and half a
2513
- // second off a Chrome launch, and most of the wait off a headless
2514
- // shell. `--use-mock-keychain` matters most on macOS, where Chrome
2515
- // otherwise reaches for the login keychain before it will start.
2792
+ // Desktop startup work a screenshot doesn't need. Measured here: about
2793
+ // 300MB and half a second off a Chrome launch, most of the wait off a
2794
+ // headless shell. `--use-mock-keychain` matters most on macOS, where
2795
+ // Chrome otherwise waits on the login keychain.
2516
2796
  "--disable-dev-shm-usage",
2517
2797
  "--disable-breakpad",
2518
2798
  "--disable-client-side-phishing-detection",
@@ -2529,7 +2809,7 @@ async function launchBrowser(executable, options = {}) {
2529
2809
  try {
2530
2810
  websocketUrl = await endpoint(process2, options.startTimeoutMs ?? START_TIMEOUT_MS);
2531
2811
  } catch (error) {
2532
- await rm(userDataDir, { recursive: true, force: true }).catch(() => {
2812
+ await rm3(userDataDir, { recursive: true, force: true }).catch(() => {
2533
2813
  });
2534
2814
  throw error;
2535
2815
  }
@@ -2539,7 +2819,7 @@ async function launchBrowser(executable, options = {}) {
2539
2819
  socket = await connect(websocketUrl);
2540
2820
  } catch (error) {
2541
2821
  process2.kill("SIGKILL");
2542
- await rm(userDataDir, { recursive: true, force: true }).catch(() => {
2822
+ await rm3(userDataDir, { recursive: true, force: true }).catch(() => {
2543
2823
  });
2544
2824
  throw error;
2545
2825
  }
@@ -2692,7 +2972,7 @@ async function launchBrowser(executable, options = {}) {
2692
2972
  ]);
2693
2973
  }
2694
2974
  socket.close();
2695
- await rm(userDataDir, { recursive: true, force: true }).catch(() => {
2975
+ await rm3(userDataDir, { recursive: true, force: true }).catch(() => {
2696
2976
  });
2697
2977
  })();
2698
2978
  return closing;
@@ -2700,8 +2980,8 @@ async function launchBrowser(executable, options = {}) {
2700
2980
  return {
2701
2981
  withPage,
2702
2982
  close,
2703
- // A socket that went with the process still alive is the same thing to
2704
- // a caller: nothing can be sent, so the pool must not hand it out again.
2983
+ // A socket gone with the process alive is the same to a caller: the pool
2984
+ // must not hand it out.
2705
2985
  get closed() {
2706
2986
  return processClosed || socketClosed;
2707
2987
  }
@@ -2845,6 +3125,367 @@ function hydrationEvidence(messages) {
2845
3125
  return null;
2846
3126
  }
2847
3127
 
3128
+ // ../server/dist/capture/layout.js
3129
+ function isEdge(value) {
3130
+ return value === "left" || value === "right" || value === "top" || value === "bottom";
3131
+ }
3132
+ var LAYOUT_PROBE = `(() => {
3133
+ const OVERLAP_SHARE = 0.05;
3134
+ const OVERLAP_MIN = 1;
3135
+ const CUT_MIN = 3;
3136
+ const CUT_SHARE = 0.08;
3137
+ const LAYERED = 4;
3138
+ const TILT = 6;
3139
+ // Only a page gone wrong everywhere reaches this. The app's own findings are
3140
+ // subtracted after, so a low cap could crowd out a direction's.
3141
+ const LIMIT = 100;
3142
+ const width = window.innerWidth;
3143
+ const states = new Map();
3144
+ const inks = new Map();
3145
+ const context = document.createElement("canvas").getContext("2d");
3146
+
3147
+ // rgba(r, g, b, a), or a / before the alpha in the newer functions
3148
+ // (oklab, oklch, color), which computed colours keep.
3149
+ const alpha = (colour) => {
3150
+ if (colour === "transparent") return 0;
3151
+ const slash = /\\/\\s*([\\d.]+)(%?)\\s*\\)$/.exec(colour || "");
3152
+ if (slash) return parseFloat(slash[1]) / (slash[2] ? 100 : 1);
3153
+ const match = /rgba\\(([^)]+)\\)/.exec(colour || "");
3154
+ const parts = match ? match[1].split(/[\\s,]+/).filter(Boolean) : [];
3155
+ return parts.length > 3 ? parseFloat(parts[3]) : 1;
3156
+ };
3157
+
3158
+ // Degrees this element turns its content by, or Infinity for a skew or a
3159
+ // 3D transform, whose boxes say nothing useful.
3160
+ const angleOf = (a, b, c, d) =>
3161
+ Math.abs(a * c + b * d) > 0.01 ? Infinity : (Math.atan2(b, a) * 180) / Math.PI;
3162
+
3163
+ const turnOf = (element, style) => {
3164
+ let turn = 0;
3165
+ if (style.rotate && style.rotate !== "none") turn += parseFloat(style.rotate) || 0;
3166
+ const transform = style.transform;
3167
+ if (transform && transform !== "none") {
3168
+ if (transform.startsWith("matrix3d")) return Infinity;
3169
+ const [a, b, c, d] = transform.slice(7, -1).split(",").map(Number);
3170
+ turn += angleOf(a, b, c, d);
3171
+ }
3172
+ return turn;
3173
+ };
3174
+
3175
+ const looping = new Set();
3176
+ try {
3177
+ for (const animation of document.getAnimations()) {
3178
+ const timing = animation.effect && animation.effect.getComputedTiming();
3179
+ const target = animation.effect && animation.effect.target;
3180
+ if (timing && timing.iterations === Infinity && target) looping.add(target);
3181
+ }
3182
+ } catch (error) {
3183
+ // An engine without getAnimations; nothing is excluded for it.
3184
+ }
3185
+
3186
+ const stateOf = (element) => {
3187
+ if (states.has(element)) return states.get(element);
3188
+ const style = getComputedStyle(element);
3189
+ const parent = element.parentElement ? stateOf(element.parentElement) : null;
3190
+ const clipsText = /text/.test(style.backgroundClip || "") || /text/.test(style.webkitBackgroundClip || "");
3191
+ const state = {
3192
+ style,
3193
+ opacity: (parent ? parent.opacity : 1) * parseFloat(style.opacity || "1"),
3194
+ turn: element instanceof SVGGraphicsElement && element.getCTM
3195
+ ? (() => {
3196
+ const matrix = element.getCTM();
3197
+ return matrix ? angleOf(matrix.a, matrix.b, matrix.c, matrix.d) + (parent ? parent.turnOutsideSvg : 0) : 0;
3198
+ })()
3199
+ : (parent ? parent.turn : 0) + turnOf(element, style),
3200
+ // An <svg>'s own CSS transform is outside every getCTM beneath it.
3201
+ turnOutsideSvg: element.namespaceURI === "http://www.w3.org/2000/svg" && element.ownerSVGElement
3202
+ ? (parent ? parent.turnOutsideSvg : 0)
3203
+ : (parent ? parent.turn : 0) + turnOf(element, style),
3204
+ masked:
3205
+ (parent ? parent.masked : false) ||
3206
+ (style.clipPath && style.clipPath !== "none") ||
3207
+ (style.maskImage && style.maskImage !== "none") ||
3208
+ (style.webkitMaskImage && style.webkitMaskImage !== "none"),
3209
+ truncates:
3210
+ (parent ? parent.truncates : false) ||
3211
+ style.textOverflow === "ellipsis" ||
3212
+ (style.webkitLineClamp && style.webkitLineClamp !== "none"),
3213
+ moving: (parent ? parent.moving : false) || looping.has(element),
3214
+ paintsText: (parent ? parent.paintsText : false) || clipsText,
3215
+ };
3216
+ states.set(element, state);
3217
+ return state;
3218
+ };
3219
+
3220
+ const blockOf = (element) => {
3221
+ if (element.namespaceURI === "http://www.w3.org/2000/svg") return element.closest("text") || element;
3222
+ for (let node = element; node; node = node.parentElement) {
3223
+ const display = stateOf(node).style.display;
3224
+ if (display !== "inline" && display !== "contents") return node;
3225
+ }
3226
+ return document.body;
3227
+ };
3228
+
3229
+ const set = (value) => Boolean(value) && value !== "none" && value !== "normal";
3230
+
3231
+ const establishes = (style) =>
3232
+ set(style.transform) ||
3233
+ set(style.scale) ||
3234
+ set(style.rotate) ||
3235
+ set(style.translate) ||
3236
+ set(style.perspective) ||
3237
+ set(style.filter) ||
3238
+ set(style.backdropFilter) ||
3239
+ set(style.containerType) ||
3240
+ style.contentVisibility === "auto" ||
3241
+ /transform|perspective|filter|scale|rotate|translate/.test(style.willChange || "") ||
3242
+ /paint|layout|strict|content/.test(style.contain || "");
3243
+
3244
+ const clipRect = (element, style) => {
3245
+ const match = /rect\\(([^)]+)\\)/.exec(style.clip || "");
3246
+ if (!match || (style.position !== "absolute" && style.position !== "fixed")) return null;
3247
+ const outer = element.getBoundingClientRect();
3248
+ const [top, right, bottom, left] = match[1].split(/[\\s,]+/).filter(Boolean);
3249
+ const at = (value, fallback, origin) => (value === "auto" ? fallback : origin + parseFloat(value));
3250
+ return {
3251
+ left: at(left, outer.left, outer.left),
3252
+ right: at(right, outer.right, outer.left),
3253
+ top: at(top, outer.top, outer.top),
3254
+ bottom: at(bottom, outer.bottom, outer.top),
3255
+ page: false,
3256
+ cutsX: true,
3257
+ cutsY: true,
3258
+ };
3259
+ };
3260
+
3261
+ // With the root's overflow visible, the body's overflow is the viewport's,
3262
+ // which the page clip already is, and the body's own box clips nothing.
3263
+ const rootStyle = getComputedStyle(document.documentElement);
3264
+ const bodyIsViewport = rootStyle.overflowX === "visible" && rootStyle.overflowY === "visible";
3265
+
3266
+ // Every box that clips the text: its own element first. A positioned box
3267
+ // escapes every clipping box between it and its containing block.
3268
+ const clippers = (element) => {
3269
+ const found = [{ left: 0, right: width, top: 0, bottom: Infinity, page: true, cutsX: true, cutsY: true }];
3270
+ let escaping = null;
3271
+ for (let node = element; node && node !== document.documentElement; node = node.parentElement) {
3272
+ const style = stateOf(node).style;
3273
+ if (node !== element) {
3274
+ if (escaping === "absolute" && (style.position !== "static" || establishes(style))) escaping = null;
3275
+ if (escaping === "fixed" && establishes(style)) escaping = null;
3276
+ }
3277
+ if (escaping === null) {
3278
+ // A scrolling box hides what is scrolled away but cuts nothing off: the
3279
+ // rest is a scroll away. The document's own scroller hides nothing.
3280
+ const root = node === document.body;
3281
+ const hides = (value) => !(root && bodyIsViewport) && (value === "hidden" || value === "clip");
3282
+ const scrolls = (value) => !root && (value === "auto" || value === "scroll");
3283
+ const x = hides(style.overflowX) || scrolls(style.overflowX);
3284
+ const y = hides(style.overflowY) || scrolls(style.overflowY);
3285
+ if (x || y) {
3286
+ // The client box is measured before transforms, the bounding box after.
3287
+ const outer = node.getBoundingClientRect();
3288
+ const wide = typeof node.offsetWidth === "number" ? node.offsetWidth : node.clientWidth + 2 * node.clientLeft;
3289
+ const tall = typeof node.offsetHeight === "number" ? node.offsetHeight : node.clientHeight + 2 * node.clientTop;
3290
+ const across = wide > 0 ? outer.width / wide : 1;
3291
+ const down = tall > 0 ? outer.height / tall : 1;
3292
+ const left = outer.left + node.clientLeft * across;
3293
+ const top = outer.top + node.clientTop * down;
3294
+ found.push({
3295
+ left: x ? left : -Infinity,
3296
+ right: x ? left + node.clientWidth * across : Infinity,
3297
+ top: y ? top : -Infinity,
3298
+ bottom: y ? top + node.clientHeight * down : Infinity,
3299
+ page: false,
3300
+ cutsX: hides(style.overflowX),
3301
+ cutsY: hides(style.overflowY),
3302
+ });
3303
+ }
3304
+ const clipped = clipRect(node, style);
3305
+ if (clipped) found.push(clipped);
3306
+ }
3307
+ if (style.position === "absolute" || style.position === "fixed") escaping = style.position;
3308
+ }
3309
+ return found;
3310
+ };
3311
+
3312
+ // The box around the letters themselves: a font's line box reaches past its
3313
+ // ink, and display type set tight overlaps line to line without touching.
3314
+ const inkOf = (words, style) => {
3315
+ const shown = style.textTransform === "uppercase" ? words.toUpperCase() : words;
3316
+ const font = [style.fontStyle, style.fontWeight, style.fontSize, style.fontFamily].join(" ");
3317
+ const key = font + "\\u0000" + style.letterSpacing + "\\u0000" + shown;
3318
+ if (inks.has(key)) return inks.get(key);
3319
+ let ink = null;
3320
+ if (context) {
3321
+ context.font = font;
3322
+ if ("letterSpacing" in context) context.letterSpacing = style.letterSpacing === "normal" ? "0px" : style.letterSpacing;
3323
+ const measured = context.measureText(shown);
3324
+ if (typeof measured.fontBoundingBoxAscent === "number") {
3325
+ ink = {
3326
+ top: Math.max(0, measured.fontBoundingBoxAscent - measured.actualBoundingBoxAscent),
3327
+ bottom: Math.max(0, measured.fontBoundingBoxDescent - measured.actualBoundingBoxDescent),
3328
+ left: -measured.actualBoundingBoxLeft,
3329
+ right: measured.actualBoundingBoxRight,
3330
+ height: measured.fontBoundingBoxAscent + measured.fontBoundingBoxDescent,
3331
+ advance: measured.width,
3332
+ };
3333
+ }
3334
+ }
3335
+ inks.set(key, ink);
3336
+ return ink;
3337
+ };
3338
+
3339
+ const area = (box) => Math.max(0, box.right - box.left) * Math.max(0, box.bottom - box.top);
3340
+
3341
+ const texts = [];
3342
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
3343
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
3344
+ const words = node.nodeValue.replace(/\\s+/g, " ").trim();
3345
+ const element = node.parentElement;
3346
+ if (words === "" || !element || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(element.tagName)) continue;
3347
+ const state = stateOf(element);
3348
+ // A slight tilt keeps its box close to its letters; more and the box lies.
3349
+ const tilt = Math.abs((((state.turn % 360) + 540) % 360) - 180);
3350
+ if (state.style.visibility !== "visible" || state.opacity < 0.05 || state.masked || !(tilt <= TILT)) continue;
3351
+ if (alpha(state.style.color) < 0.05 && alpha(state.style.webkitTextFillColor) < 0.05 && !state.paintsText) continue;
3352
+ const range = document.createRange();
3353
+ range.selectNodeContents(node);
3354
+ const ink = inkOf(words, state.style);
3355
+ const clips = clippers(element);
3356
+ const boxes = [];
3357
+ const lines = [...range.getClientRects()].filter((raw) => raw.width >= 2 && raw.height >= 2);
3358
+ const rtl = state.style.direction === "rtl";
3359
+ for (const raw of lines) {
3360
+ // Scaled type (an SVG viewBox, a scale transform) draws at another size
3361
+ // than its computed font-size; the box's own height says by how much.
3362
+ const scale = ink && ink.height > 0 ? raw.height / ink.height : 1;
3363
+ const top = ink ? ink.top * scale : 0;
3364
+ const bottom = ink ? ink.bottom * scale : 0;
3365
+ const box = raw.height - top - bottom >= 1
3366
+ ? { left: raw.left, right: raw.right, top: raw.top + top, bottom: raw.bottom - bottom }
3367
+ : { left: raw.left, right: raw.right, top: raw.top, bottom: raw.bottom };
3368
+ // Across, the ink is known only when the whole string is on this one line,
3369
+ // and a line can be stretched one way only (scaleX).
3370
+ const wide = ink && ink.advance > 0 ? raw.width / ink.advance : scale;
3371
+ if (ink && lines.length === 1 && !rtl && (ink.right - ink.left) * wide >= 1) {
3372
+ box.left = Math.max(box.left, raw.left + ink.left * wide);
3373
+ box.right = Math.min(box.right, raw.left + ink.right * wide);
3374
+ }
3375
+ const seen = { ...box };
3376
+ for (const clip of clips) {
3377
+ seen.left = Math.max(seen.left, clip.left);
3378
+ seen.right = Math.min(seen.right, clip.right);
3379
+ seen.top = Math.max(seen.top, clip.top);
3380
+ seen.bottom = Math.min(seen.bottom, clip.bottom);
3381
+ }
3382
+ const shows = seen.right - seen.left >= 2 && seen.bottom - seen.top >= 2;
3383
+ boxes.push({ box, seen, shows, share: area(box) === 0 ? 0 : area(seen) / area(box), clips });
3384
+ }
3385
+ if (boxes.length === 0) continue;
3386
+ const drawn = ink && ink.height > 0 ? lines[0].height / ink.height : 1;
3387
+ const size = (parseFloat(state.style.fontSize) || 16) * drawn;
3388
+ texts.push({ words: words.slice(0, 80), boxes, block: blockOf(element), state, size });
3389
+ }
3390
+
3391
+ const findings = [];
3392
+ const seen = new Set();
3393
+
3394
+ // Collisions between what shows of each box, swept by top edge.
3395
+ const shown = texts.flatMap((text) =>
3396
+ text.boxes.filter((entry) => entry.share >= 0.5).map((entry) => ({ box: entry.seen, text })),
3397
+ );
3398
+ shown.sort((a, b) => a.box.top - b.box.top);
3399
+ for (let i = 0; i < shown.length && findings.length < LIMIT; i += 1) {
3400
+ const a = shown[i];
3401
+ for (let j = i + 1; j < shown.length && shown[j].box.top < a.box.bottom; j += 1) {
3402
+ const b = shown[j];
3403
+ if (a.text.block === b.text.block || a.text === b.text) continue;
3404
+ if (a.text.state.moving || b.text.state.moving) continue;
3405
+ const across = Math.min(a.box.right, b.box.right) - Math.max(a.box.left, b.box.left);
3406
+ const down = Math.min(a.box.bottom, b.box.bottom) - Math.max(a.box.top, b.box.top);
3407
+ if (across < OVERLAP_MIN || down < OVERLAP_MIN) continue;
3408
+ if ((across * down) / Math.min(area(a.box), area(b.box)) < OVERLAP_SHARE) continue;
3409
+ // Words laid over giant display type are a composition, not a collision,
3410
+ // and a variable font's width is past what the canvas can measure.
3411
+ const larger = Math.max(a.text.size, b.text.size);
3412
+ const smaller = Math.min(a.text.size, b.text.size);
3413
+ if (larger >= LAYERED * smaller) continue;
3414
+ const pair = [a.text.words, b.text.words].sort().join("\\u0000");
3415
+ if (seen.has(pair)) continue;
3416
+ seen.add(pair);
3417
+ findings.push({ kind: "overlap", text: a.text.words, other: b.text.words });
3418
+ if (findings.length >= LIMIT) break;
3419
+ }
3420
+ }
3421
+
3422
+ // Cut off: partly shown. Text hidden whole is a choice (screen reader
3423
+ // text, a rolling digit, a carousel), not a mistake.
3424
+ for (const text of texts) {
3425
+ if (findings.length >= LIMIT) break;
3426
+ if (text.state.truncates || text.state.moving) continue;
3427
+ let cut = null;
3428
+ for (const entry of text.boxes) {
3429
+ if (!entry.shows || entry.share > 0.97) continue;
3430
+ const { box } = entry;
3431
+ // Nearest clip first, the page last. Past a scroll box, a clip further
3432
+ // out sees only what the scroll box shows.
3433
+ const inner = { ...box };
3434
+ for (const clip of [...entry.clips.slice(1), entry.clips[0]]) {
3435
+ const sides = [
3436
+ ...(clip.cutsX ? [["left", clip.left - inner.left, box.right - box.left], ["right", inner.right - clip.right, box.right - box.left]] : []),
3437
+ ...(clip.cutsY ? [["top", clip.top - inner.top, box.bottom - box.top], ["bottom", inner.bottom - clip.bottom, box.bottom - box.top]] : []),
3438
+ ];
3439
+ for (const [edge, by, extent] of sides) {
3440
+ // A box flush with the page's edge cuts where the page does; say the page.
3441
+ const deeper = cut === null || by > cut.by || (clip.page && by === cut.by);
3442
+ if (by > Math.max(CUT_MIN, extent * CUT_SHARE) && deeper) {
3443
+ cut = { edge, by, within: clip.page ? "page" : "container" };
3444
+ }
3445
+ }
3446
+ if (!clip.cutsX) {
3447
+ inner.left = Math.max(inner.left, clip.left);
3448
+ inner.right = Math.min(inner.right, clip.right);
3449
+ }
3450
+ if (!clip.cutsY) {
3451
+ inner.top = Math.max(inner.top, clip.top);
3452
+ inner.bottom = Math.min(inner.bottom, clip.bottom);
3453
+ }
3454
+ }
3455
+ }
3456
+ if (cut) findings.push({ kind: "cut-off", text: text.words, edge: cut.edge, by: Math.round(cut.by), within: cut.within });
3457
+ }
3458
+
3459
+ return findings;
3460
+ })()`;
3461
+ function layoutFindings(value) {
3462
+ if (!Array.isArray(value))
3463
+ return [];
3464
+ const findings = [];
3465
+ for (const entry of value) {
3466
+ if (!isJsonRecord(entry) || !isString(entry.text))
3467
+ continue;
3468
+ if (entry.kind === "overlap" && isString(entry.other)) {
3469
+ findings.push({ kind: "overlap", text: entry.text, other: entry.other });
3470
+ } else if (entry.kind === "cut-off" && isString(entry.edge) && isEdge(entry.edge) && isNumber(entry.by) && (entry.within === "page" || entry.within === "container")) {
3471
+ findings.push({
3472
+ kind: "cut-off",
3473
+ text: entry.text,
3474
+ edge: entry.edge,
3475
+ by: entry.by,
3476
+ within: entry.within
3477
+ });
3478
+ }
3479
+ }
3480
+ return findings;
3481
+ }
3482
+ function describeFinding(finding) {
3483
+ if (finding.kind === "overlap")
3484
+ return `"${finding.text}" runs into "${finding.other}"`;
3485
+ const where = finding.within === "page" ? "the page" : "the box around it";
3486
+ return `"${finding.text}" is cut off ${finding.by}px past the ${finding.edge} edge of ${where}`;
3487
+ }
3488
+
2848
3489
  // ../server/dist/capture/capture.js
2849
3490
  var FRAME_MAX_HEIGHT = 4e3;
2850
3491
  var MIN_WIDTH = 320;
@@ -2950,6 +3591,13 @@ function resourced(text3, url) {
2950
3591
  function locatorExpression(focus) {
2951
3592
  return `${LOCATOR}(${JSON.stringify(focus.selector)}, ${JSON.stringify(focus.text)}, ${JSON.stringify(focus.tag)})`;
2952
3593
  }
3594
+ async function inspectLayout(page) {
3595
+ const response = await bounded(page.send("Runtime.evaluate", {
3596
+ expression: LAYOUT_PROBE,
3597
+ returnByValue: true
3598
+ }), 2e3, null).catch(() => null);
3599
+ return layoutFindings(resultValue(response));
3600
+ }
2953
3601
  async function render(page, input) {
2954
3602
  const width = clamp(Math.round(input.width), MIN_WIDTH, MAX_WIDTH);
2955
3603
  const errors = [];
@@ -3109,22 +3757,30 @@ async function render(page, input) {
3109
3757
  returnByValue: true
3110
3758
  }), 2e3, void 0).catch(() => {
3111
3759
  });
3760
+ const layout = input.inspect === true ? await inspectLayout(page) : [];
3112
3761
  const metrics = await page.send("Page.getLayoutMetrics");
3113
3762
  const size2 = metrics.cssContentSize ?? metrics.contentSize ?? { width, height: 900 };
3114
3763
  const pageWidth = Math.max(width, Math.ceil(size2.width));
3115
3764
  const contentHeight = Math.max(1, Math.ceil(size2.height));
3116
3765
  const pageHeight = Math.min(FRAME_MAX_HEIGHT, contentHeight);
3117
3766
  const cut = size2.height > FRAME_MAX_HEIGHT;
3767
+ const clip2 = { x: 0, y: 0, width, height: pageHeight, scale: 1 };
3118
3768
  const frameResponse = await page.send("Page.captureScreenshot", {
3119
3769
  format: "png",
3120
3770
  captureBeyondViewport: true,
3121
- clip: { x: 0, y: 0, width, height: pageHeight, scale: 1 }
3771
+ clip: clip2
3122
3772
  });
3123
3773
  const frame = {
3124
3774
  png: Buffer.from(frameResponse.data, "base64"),
3125
3775
  width,
3126
3776
  height: pageHeight
3127
3777
  };
3778
+ const jpeg = input.jpeg === true ? await page.send("Page.captureScreenshot", {
3779
+ format: "jpeg",
3780
+ quality: 80,
3781
+ captureBeyondViewport: true,
3782
+ clip: clip2
3783
+ }).then((shot) => Buffer.from(shot.data, "base64"), () => null) : null;
3128
3784
  const crops = [];
3129
3785
  for (const focus of input.focuses ?? []) {
3130
3786
  const located = await page.send("Runtime.evaluate", {
@@ -3160,7 +3816,7 @@ async function render(page, input) {
3160
3816
  resolved
3161
3817
  });
3162
3818
  }
3163
- return { frame, crops, errors, hydration, cut };
3819
+ return { frame, crops, errors, hydration, cut, layout, jpeg };
3164
3820
  } finally {
3165
3821
  for (const stop of unlisten)
3166
3822
  stop();
@@ -3172,8 +3828,8 @@ async function capturePage(browser, input) {
3172
3828
  }
3173
3829
 
3174
3830
  // ../server/dist/requests/attachments.js
3175
- import { copyFile, lstat, mkdir as mkdir3, readdir as readdir2, readFile as readFile5, realpath, rename, rm as rm2, stat as stat2, unlink, writeFile as writeFile3 } from "fs/promises";
3176
- import { basename as basename2, extname, join as join5, resolve, sep } from "path";
3831
+ import { copyFile, lstat, mkdir as mkdir4, readdir as readdir3, readFile as readFile6, realpath, rename as rename3, rm as rm4, stat as stat2, unlink, writeFile as writeFile4 } from "fs/promises";
3832
+ import { basename as basename2, extname, join as join6, resolve, sep } from "path";
3177
3833
  var CAPTURES_DIR = ".leglas/captures";
3178
3834
  var REFERENCES_DIR = ".leglas/references";
3179
3835
  var LOAD_SHARE = 0.6;
@@ -3264,7 +3920,7 @@ function sniffImage(bytes) {
3264
3920
  async function moveReference(cwd, id, destinationDir, index) {
3265
3921
  let names;
3266
3922
  try {
3267
- names = await readdir2(join5(cwd, REFERENCES_DIR));
3923
+ names = await readdir3(join6(cwd, REFERENCES_DIR));
3268
3924
  } catch {
3269
3925
  return null;
3270
3926
  }
@@ -3273,17 +3929,17 @@ async function moveReference(cwd, id, destinationDir, index) {
3273
3929
  return null;
3274
3930
  const extension = extname(sourceName).toLowerCase();
3275
3931
  const destinationName = `reference-${index}${extension}`;
3276
- const source = join5(cwd, REFERENCES_DIR, sourceName);
3277
- const destination = join5(destinationDir, destinationName);
3278
- await mkdir3(destinationDir, { recursive: true });
3932
+ const source = join6(cwd, REFERENCES_DIR, sourceName);
3933
+ const destination = join6(destinationDir, destinationName);
3934
+ await mkdir4(destinationDir, { recursive: true });
3279
3935
  try {
3280
- await rename(source, destination);
3936
+ await rename3(source, destination);
3281
3937
  } catch {
3282
3938
  await copyFile(source, destination);
3283
3939
  await unlink(source).catch(() => {
3284
3940
  });
3285
3941
  }
3286
- const sniffed = sniffImage(await readFile5(destination));
3942
+ const sniffed = sniffImage(await readFile6(destination));
3287
3943
  return {
3288
3944
  kind: "reference",
3289
3945
  file: `${CAPTURES_DIR}/${basename2(destinationDir)}/${destinationName}`,
@@ -3305,7 +3961,7 @@ function focusOf(note) {
3305
3961
  async function attachRequest(cwd, requestId, input, deps) {
3306
3962
  const capture = deps.capture ?? capturePage;
3307
3963
  const deadlineMs = deps.deadlineMs ?? 12e3;
3308
- const destination = join5(cwd, CAPTURES_DIR, requestId);
3964
+ const destination = join6(cwd, CAPTURES_DIR, requestId);
3309
3965
  const captured = {
3310
3966
  attachments: [],
3311
3967
  errors: [],
@@ -3351,8 +4007,8 @@ async function attachRequest(cwd, requestId, input, deps) {
3351
4007
  const direction = await capture(browser, directionInput);
3352
4008
  if (expired2)
3353
4009
  return;
3354
- await mkdir3(destination, { recursive: true });
3355
- await writeFile3(join5(destination, "frame.png"), direction.frame.png);
4010
+ await mkdir4(destination, { recursive: true });
4011
+ await writeFile4(join6(destination, "frame.png"), direction.frame.png);
3356
4012
  captured.attachments.push({
3357
4013
  kind: "frame",
3358
4014
  file: `${CAPTURES_DIR}/${requestId}/frame.png`,
@@ -3370,7 +4026,7 @@ async function attachRequest(cwd, requestId, input, deps) {
3370
4026
  if (crop === null || crop === void 0 || note === void 0 || expired2)
3371
4027
  continue;
3372
4028
  const name = `note-${index + 1}.png`;
3373
- await writeFile3(join5(destination, name), crop.shot.png);
4029
+ await writeFile4(join6(destination, name), crop.shot.png);
3374
4030
  captured.attachments.push({
3375
4031
  kind: "note",
3376
4032
  file: `${CAPTURES_DIR}/${requestId}/${name}`,
@@ -3391,7 +4047,7 @@ async function attachRequest(cwd, requestId, input, deps) {
3391
4047
  const comparison = await capture(browser, compareInput);
3392
4048
  if (expired2)
3393
4049
  return;
3394
- await writeFile3(join5(destination, "compare.png"), comparison.frame.png);
4050
+ await writeFile4(join6(destination, "compare.png"), comparison.frame.png);
3395
4051
  captured.attachments.push({
3396
4052
  kind: "compare",
3397
4053
  file: `${CAPTURES_DIR}/${requestId}/compare.png`,
@@ -3415,13 +4071,13 @@ async function attachRequest(cwd, requestId, input, deps) {
3415
4071
  return captured;
3416
4072
  }
3417
4073
  function captureDirectory(cwd, requestId) {
3418
- return /^[A-Za-z0-9_-]{1,32}$/.test(requestId) ? join5(cwd, CAPTURES_DIR, requestId) : null;
4074
+ return /^[A-Za-z0-9_-]{1,32}$/.test(requestId) ? join6(cwd, CAPTURES_DIR, requestId) : null;
3419
4075
  }
3420
4076
  async function removeCaptures(cwd, requestId) {
3421
4077
  const directory = captureDirectory(cwd, requestId);
3422
4078
  if (directory === null)
3423
4079
  return;
3424
- await rm2(directory, { recursive: true, force: true });
4080
+ await rm4(directory, { recursive: true, force: true });
3425
4081
  }
3426
4082
  async function rehomeCaptures(cwd, from, to, attachments) {
3427
4083
  if (attachments.length === 0)
@@ -3430,8 +4086,8 @@ async function rehomeCaptures(cwd, from, to, attachments) {
3430
4086
  const destination = captureDirectory(cwd, to);
3431
4087
  if (source === null || destination === null)
3432
4088
  return [...attachments];
3433
- await mkdir3(join5(cwd, CAPTURES_DIR), { recursive: true });
3434
- await rename(source, destination);
4089
+ await mkdir4(join6(cwd, CAPTURES_DIR), { recursive: true });
4090
+ await rename3(source, destination);
3435
4091
  const prefix = `${CAPTURES_DIR}/${from}/`;
3436
4092
  return attachments.map((attachment) => ({
3437
4093
  ...attachment,
@@ -3442,7 +4098,7 @@ function rehomeText(text3, from, to) {
3442
4098
  return text3.split(`${CAPTURES_DIR}/${from}/`).join(`${CAPTURES_DIR}/${to}/`);
3443
4099
  }
3444
4100
  async function ownCapturesRoot(cwd) {
3445
- const root = join5(cwd, CAPTURES_DIR);
4101
+ const root = join6(cwd, CAPTURES_DIR);
3446
4102
  try {
3447
4103
  const entry = await lstat(root);
3448
4104
  return entry.isDirectory() ? root : null;
@@ -3451,7 +4107,7 @@ async function ownCapturesRoot(cwd) {
3451
4107
  }
3452
4108
  }
3453
4109
  async function isOwnCapture(cwd, file) {
3454
- const root = await realpath(join5(cwd, CAPTURES_DIR)).catch(() => null);
4110
+ const root = await realpath(join6(cwd, CAPTURES_DIR)).catch(() => null);
3455
4111
  if (root === null)
3456
4112
  return false;
3457
4113
  const real = await realpath(resolve(cwd, file)).catch(() => null);
@@ -3465,21 +4121,21 @@ async function pruneCaptures(cwd, keep) {
3465
4121
  if (root === null)
3466
4122
  return void await pruneReferences(cwd);
3467
4123
  try {
3468
- const entries = await readdir2(root, { withFileTypes: true });
3469
- await Promise.all(entries.filter((entry) => entry.isDirectory() && !kept.has(entry.name)).map((entry) => rm2(join5(root, entry.name), { recursive: true, force: true })));
4124
+ const entries = await readdir3(root, { withFileTypes: true });
4125
+ await Promise.all(entries.filter((entry) => entry.isDirectory() && !kept.has(entry.name)).map((entry) => rm4(join6(root, entry.name), { recursive: true, force: true })));
3470
4126
  } catch {
3471
4127
  }
3472
4128
  await pruneReferences(cwd);
3473
4129
  }
3474
4130
  async function pruneReferences(cwd) {
3475
- const references = join5(cwd, REFERENCES_DIR);
4131
+ const references = join6(cwd, REFERENCES_DIR);
3476
4132
  try {
3477
4133
  if (!(await lstat(references)).isDirectory())
3478
4134
  return;
3479
- const entries = await readdir2(references, { withFileTypes: true });
4135
+ const entries = await readdir3(references, { withFileTypes: true });
3480
4136
  const old = Date.now() - 60 * 60 * 1e3;
3481
4137
  await Promise.all(entries.filter((entry) => entry.isFile()).map(async (entry) => {
3482
- const file = join5(references, entry.name);
4138
+ const file = join6(references, entry.name);
3483
4139
  if ((await stat2(file)).mtimeMs < old)
3484
4140
  await unlink(file).catch(() => {
3485
4141
  });
@@ -3490,9 +4146,9 @@ async function pruneReferences(cwd) {
3490
4146
 
3491
4147
  // ../server/dist/branches/worktree.js
3492
4148
  import { execFile, spawn as spawn2 } from "child_process";
3493
- import { rm as rm3 } from "fs/promises";
4149
+ import { rm as rm5 } from "fs/promises";
3494
4150
  import net2 from "net";
3495
- import { join as join6 } from "path";
4151
+ import { join as join7 } from "path";
3496
4152
  import { promisify } from "util";
3497
4153
  var run = promisify(execFile);
3498
4154
  var WORKTREES_DIR = ".leglas/worktrees";
@@ -3534,10 +4190,10 @@ async function answeringHost(port) {
3534
4190
  }
3535
4191
  async function startWorktree(options) {
3536
4192
  const readyTimeoutMs = options.readyTimeoutMs ?? 9e4;
3537
- const path = join6(options.cwd, WORKTREES_DIR, worktreeSlug(options.branch));
4193
+ const path = join7(options.cwd, WORKTREES_DIR, worktreeSlug(options.branch));
3538
4194
  const log = options.onLog ?? (() => {
3539
4195
  });
3540
- await rm3(path, { recursive: true, force: true });
4196
+ await rm5(path, { recursive: true, force: true });
3541
4197
  await run("git", ["worktree", "prune"], { cwd: options.cwd }).catch(() => {
3542
4198
  });
3543
4199
  try {
@@ -3549,7 +4205,7 @@ async function startWorktree(options) {
3549
4205
  const cleanup = async () => {
3550
4206
  await run("git", ["worktree", "remove", "--force", path], { cwd: options.cwd }).catch(() => {
3551
4207
  });
3552
- await rm3(path, { recursive: true, force: true }).catch(() => {
4208
+ await rm5(path, { recursive: true, force: true }).catch(() => {
3553
4209
  });
3554
4210
  };
3555
4211
  try {
@@ -3598,8 +4254,8 @@ async function startAppProcess(options) {
3598
4254
  child = spawn2(substitutePort(options.devCommand, port), {
3599
4255
  cwd: options.cwd,
3600
4256
  shell: true,
3601
- // Own process group, so stopping kills the shell and whatever it spawned
3602
- // rather than orphaning a dev server holding the port.
4257
+ // Own process group, so stopping kills the shell and everything it
4258
+ // spawned instead of orphaning a dev server on the port.
3603
4259
  detached: true,
3604
4260
  stdio: ["ignore", "pipe", "pipe"]
3605
4261
  });
@@ -3845,8 +4501,8 @@ function conversationFailure(code) {
3845
4501
 
3846
4502
  // ../server/dist/requests/annotations.js
3847
4503
  import { randomBytes as randomBytes2 } from "crypto";
3848
- import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
3849
- import { dirname as dirname4, join as join7 } from "path";
4504
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile5 } from "fs/promises";
4505
+ import { dirname as dirname4, join as join8 } from "path";
3850
4506
  var ANNOTATIONS_PATH = ".leglas/annotations.json";
3851
4507
  var NOTE_CAP = 500;
3852
4508
  var SELECTOR_CAP = 300;
@@ -3910,7 +4566,7 @@ function anchorFrom(value) {
3910
4566
  }
3911
4567
  async function readAnnotations(cwd) {
3912
4568
  try {
3913
- const raw = await readFile6(join7(cwd, ANNOTATIONS_PATH), "utf8");
4569
+ const raw = await readFile7(join8(cwd, ANNOTATIONS_PATH), "utf8");
3914
4570
  const parsed = parseJson(raw);
3915
4571
  if (!isJsonRecord(parsed) || !Array.isArray(parsed.annotations))
3916
4572
  return [];
@@ -3935,9 +4591,9 @@ async function readAnnotations(cwd) {
3935
4591
  }
3936
4592
  }
3937
4593
  async function write(cwd, annotations) {
3938
- const path = join7(cwd, ANNOTATIONS_PATH);
3939
- await mkdir4(dirname4(path), { recursive: true });
3940
- await writeFile4(path, `${JSON.stringify({ annotations }, null, 2)}
4594
+ const path = join8(cwd, ANNOTATIONS_PATH);
4595
+ await mkdir5(dirname4(path), { recursive: true });
4596
+ await writeFile5(path, `${JSON.stringify({ annotations }, null, 2)}
3941
4597
  `, "utf8");
3942
4598
  }
3943
4599
  var writing = Promise.resolve();
@@ -4008,9 +4664,9 @@ function describeAnnotations(annotations) {
4008
4664
  }
4009
4665
 
4010
4666
  // ../server/dist/requests/requests.js
4011
- import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile5 } from "fs/promises";
4667
+ import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
4012
4668
  import { randomBytes as randomBytes3 } from "crypto";
4013
- import { dirname as dirname5, join as join8 } from "path";
4669
+ import { dirname as dirname5, join as join9 } from "path";
4014
4670
  var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
4015
4671
  function variantSlot(url) {
4016
4672
  if (!url.startsWith("/"))
@@ -4189,7 +4845,7 @@ function failureOf(entry) {
4189
4845
  }
4190
4846
  async function readRequests(cwd) {
4191
4847
  try {
4192
- const raw = await readFile7(join8(cwd, REQUESTS_PATH), "utf8");
4848
+ const raw = await readFile8(join9(cwd, REQUESTS_PATH), "utf8");
4193
4849
  const parsed = parseJson(raw);
4194
4850
  if (!isJsonRecord(parsed) || !Array.isArray(parsed.requests))
4195
4851
  return [];
@@ -4225,9 +4881,9 @@ async function readRequests(cwd) {
4225
4881
  }
4226
4882
  }
4227
4883
  async function writeQueue(cwd, requests) {
4228
- const path = join8(cwd, REQUESTS_PATH);
4229
- await mkdir5(dirname5(path), { recursive: true });
4230
- await writeFile5(path, `${JSON.stringify({ requests }, null, 2)}
4884
+ const path = join9(cwd, REQUESTS_PATH);
4885
+ await mkdir6(dirname5(path), { recursive: true });
4886
+ await writeFile6(path, `${JSON.stringify({ requests }, null, 2)}
4231
4887
  `, "utf8");
4232
4888
  }
4233
4889
  async function appendRequest(cwd, request, id = newRequestId()) {
@@ -4285,12 +4941,12 @@ async function clearRequests(cwd) {
4285
4941
 
4286
4942
  // ../server/dist/agents/runner.js
4287
4943
  import { spawn as nodeSpawn3 } from "child_process";
4288
- import { readFile as readFile9 } from "fs/promises";
4289
- import { join as join9, resolve as resolve2 } from "path";
4944
+ import { readFile as readFile10 } from "fs/promises";
4945
+ import { join as join10, resolve as resolve2 } from "path";
4290
4946
 
4291
4947
  // ../server/dist/agents/claude-agent-session.js
4292
4948
  import { EventEmitter } from "events";
4293
- import { readFile as readFile8, stat as stat3 } from "fs/promises";
4949
+ import { readFile as readFile9, stat as stat3 } from "fs/promises";
4294
4950
  import { extname as extname2 } from "path";
4295
4951
  import { PassThrough } from "stream";
4296
4952
  var INITIALIZE_TIMEOUT_MS = 3e4;
@@ -4320,7 +4976,7 @@ async function claudeContent(prompt, images2) {
4320
4976
  try {
4321
4977
  if ((await stat3(path)).size > IMAGE_MAX_BYTES)
4322
4978
  continue;
4323
- const data = await readFile8(path);
4979
+ const data = await readFile9(path);
4324
4980
  blocks.push({
4325
4981
  type: "image",
4326
4982
  source: { type: "base64", media_type: mediaType, data: data.toString("base64") }
@@ -4453,11 +5109,10 @@ var PersistentClaudeSession = class {
4453
5109
  warmedFor = null;
4454
5110
  closed = false;
4455
5111
  /**
4456
- * Counts every ask for a warm process. The runner fires warm() and
4457
- * release() without awaiting either, so the two race in both directions:
4458
- * a release must outlast a warm that started while it was settling, and
4459
- * must stand down for a warm asked for after it began. The generation a
4460
- * release captured at entry says which side of that it is on.
5112
+ * Counts every ask for a warm process. warm() and release() aren't awaited
5113
+ * and race both ways: a release must outlast a warm that started while it
5114
+ * settled, and stand down for a warm asked for after it began. The generation
5115
+ * it captured says which.
4461
5116
  */
4462
5117
  generation = 0;
4463
5118
  constructor(cwd, allowedCommands, startup) {
@@ -4567,13 +5222,10 @@ var PersistentClaudeSession = class {
4567
5222
  }
4568
5223
  }
4569
5224
  /**
4570
- * Let the native process go without ending the transport.
4571
- *
4572
- * The process is the cost, not this object: a warm session is Claude Code
4573
- * plus every MCP server the user has configured, held for a request that
4574
- * may never come. The runner calls this once nothing has asked for Claude
4575
- * in a while, and the next warm() starts it again. A session id the runner
4576
- * still holds is served by the `claude --resume` fallback in the meantime.
5225
+ * Ends the native process but keeps the transport. A warm session is Claude
5226
+ * Code plus every MCP server the user configured, held for a request that may
5227
+ * not come. Called after an idle spell; the next warm() starts it again, and
5228
+ * a held session id goes through `claude --resume` meanwhile.
4577
5229
  */
4578
5230
  async release() {
4579
5231
  const asOf = this.generation;
@@ -4777,12 +5429,10 @@ var PersistentCodexAppServer = class {
4777
5429
  this.requestTimeoutMs = requestTimeoutMs;
4778
5430
  }
4779
5431
  /**
4780
- * Counts every ask for a warm process. The runner fires warm() and
4781
- * release() without awaiting either, so the two race in both directions:
4782
- * a release must outlast a warm that was queued behind its reset, and must
4783
- * stand down for a warm asked for after it began. A warm queued behind a
4784
- * reset restarts through start() rather than warm(), so waiting its turn
4785
- * does not read as a fresh ask.
5432
+ * Counts every ask for a warm process. warm() and release() aren't awaited
5433
+ * and race both ways: a release must outlast a warm queued behind its reset,
5434
+ * and stand down for a warm asked for after it began. A queued warm restarts
5435
+ * through start(), so waiting doesn't count as a new ask.
4786
5436
  */
4787
5437
  generation = 0;
4788
5438
  warm(_sessionId = null) {
@@ -4903,11 +5553,9 @@ var PersistentCodexAppServer = class {
4903
5553
  }
4904
5554
  }
4905
5555
  /**
4906
- * Let the app-server process go without ending the transport.
4907
- *
4908
- * The runner calls this once nothing has asked for Codex in a while; the
4909
- * next warm() spawns and handshakes again. A thread id the runner still
4910
- * holds is resumed by the new process through `thread/resume`.
5556
+ * Ends the app-server process but keeps the transport. Called after an idle
5557
+ * spell; the next warm() spawns again and resumes a held thread through
5558
+ * `thread/resume`.
4911
5559
  */
4912
5560
  async release() {
4913
5561
  const asOf = this.generation;
@@ -5540,7 +6188,7 @@ function startRunner(options) {
5540
6188
  active = null;
5541
6189
  }
5542
6190
  };
5543
- const registered = () => readFile9(join9(options.cwd, LOCAL_PREVIEWS_PATH), "utf8").catch(() => null);
6191
+ const registered = () => readFile10(join10(options.cwd, LOCAL_PREVIEWS_PATH), "utf8").catch(() => null);
5544
6192
  const handle = async (request, choice) => {
5545
6193
  const allowedCommands = options.leglasCommand === void 0 ? [] : [
5546
6194
  `${options.leglasCommand} show`,
@@ -5584,13 +6232,11 @@ function startRunner(options) {
5584
6232
  retry: observed.retry
5585
6233
  });
5586
6234
  let failure = verdict();
5587
- if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && // The edited flag is only evidence where Leglas has read the vendor's
5588
- // real output. Where it has not, "did not edit" means "was not seen
5589
- // to edit", and rerunning on that is how a half-applied change gets
5590
- // applied twice. Such a vendor keeps the failure card and its Retry.
5591
- activityVerified(resolved.agent) && conversationFailure(failure.code) && // Not redundant with the verdict: a stop that lands between the first
5592
- // child settling and the retry starting finds no child to cancel, so
5593
- // nothing says "cancelled". Stopped still means stopped.
6235
+ if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && // The edited flag is evidence only where Leglas has read the vendor's
6236
+ // real output; elsewhere "didn't edit" means "wasn't seen to edit".
6237
+ // Those vendors keep the failure card and its Retry.
6238
+ activityVerified(resolved.agent) && conversationFailure(failure.code) && // Not redundant: a stop between the first child settling and the retry
6239
+ // starting finds no child to cancel.
5594
6240
  !stopped) {
5595
6241
  sessions.delete(resolved.agent);
5596
6242
  const cold = resolveCommand(choice, request.prompt, null, allowedCommands, images2);
@@ -5735,9 +6381,8 @@ function startRunner(options) {
5735
6381
  snapshot: () => ({ ...state, failedIds: [...failed] }),
5736
6382
  cancel,
5737
6383
  prepare,
5738
- // A nudge during a tick is latched and checked as soon as that tick
5739
- // settles; a nudge between ticks starts immediately. Neither can overlap
5740
- // the active agent.
6384
+ // A nudge during a tick is latched until the tick settles; between ticks it
6385
+ // starts one at once. Neither overlaps the active agent.
5741
6386
  nudge: () => schedule(true)
5742
6387
  };
5743
6388
  }
@@ -5946,7 +6591,7 @@ Sec-WebSocket-Accept: ${accept}\r
5946
6591
  }
5947
6592
 
5948
6593
  // ../server/dist/share/share.js
5949
- import { randomBytes as randomBytes4, randomUUID, timingSafeEqual } from "crypto";
6594
+ import { randomBytes as randomBytes4, randomUUID as randomUUID3, timingSafeEqual } from "crypto";
5950
6595
  import http3 from "http";
5951
6596
  import { posix } from "path";
5952
6597
 
@@ -6267,10 +6912,10 @@ var DEV_CONTROL_ROUTES = [
6267
6912
  /** read, browser-sync 3.0.4: its client surface and server metadata. */
6268
6913
  "/__browser_sync__",
6269
6914
  /**
6270
- * read, webpack-dev-server 6.0.0, which mounts its own surface here: the
6271
- * file listing, `/webpack-dev-server/invalidate`, which forces a rebuild,
6272
- * and `/webpack-dev-server/open-editor`, which calls the same launch-editor
6273
- * package Vite does. The subtree match below takes all three.
6915
+ * read, webpack-dev-server 6.0.0: the file listing,
6916
+ * `/webpack-dev-server/invalidate` (forces a rebuild) and
6917
+ * `/webpack-dev-server/open-editor` (launch-editor again). The subtree match
6918
+ * covers all three.
6274
6919
  */
6275
6920
  "/webpack-dev-server",
6276
6921
  /** reported: Rails Web Console, an interactive server-side REPL. */
@@ -6295,17 +6940,12 @@ var DEV_CONTROL_ROUTES = [
6295
6940
  var DEV_CONTROL_PREFIXES = [
6296
6941
  /** read, Next 16.3.1. Its app assets sit at `/_next/` and stay allowed. */
6297
6942
  "/__nextjs_",
6298
- /**
6299
- * read, Nuxt DevTools 4.0.0-alpha.16. Nuxt's own bundle is at `/_nuxt/`,
6300
- * one underscore and a different prefix, so the app is untouched.
6301
- */
6943
+ /** read, Nuxt DevTools 4.0.0-alpha.16. Nuxt's bundle is at `/_nuxt/`, a different prefix. */
6302
6944
  "/__nuxt_devtools__",
6303
6945
  /**
6304
- * read, Parcel 2.16.4: `__parcel_launch_editor` reads a `file` parameter
6305
- * and calls the same launch-editor code Vite does. Beside it sit
6306
- * `__parcel_source_map`, `__parcel_source_root` and `__parcel_code_frame`,
6307
- * which serve source, and the HMR and health routes, which a viewer has no
6308
- * use for: their live-reload socket is already refused.
6946
+ * read, Parcel 2.16.4: `__parcel_launch_editor` takes a `file` and calls
6947
+ * launch-editor. Its source-map, source-root, code-frame, HMR and health
6948
+ * routes serve source or are useless to a viewer.
6309
6949
  */
6310
6950
  "/__parcel_"
6311
6951
  ];
@@ -6681,7 +7321,7 @@ function createShareManager(options) {
6681
7321
  token = randomBytes4(24).toString("base64url");
6682
7322
  const at = now();
6683
7323
  const grant = {
6684
- id: randomUUID(),
7324
+ id: randomUUID3(),
6685
7325
  name,
6686
7326
  token,
6687
7327
  createdAt: at,
@@ -6996,7 +7636,7 @@ function createShareManager(options) {
6996
7636
  }
6997
7637
  const share = {
6998
7638
  ...parsed.manifest,
6999
- id: randomUUID(),
7639
+ id: randomUUID3(),
7000
7640
  grants: /* @__PURE__ */ new Map(),
7001
7641
  tombstones: [],
7002
7642
  port,
@@ -7027,8 +7667,8 @@ function createShareManager(options) {
7027
7667
  share.runningTunnel = runTunnel({
7028
7668
  provider,
7029
7669
  port,
7030
- // Whichever link exists when the tunnel starts: the probe only
7031
- // needs a path the listener answers, and a share always has one.
7670
+ // Whichever link exists when the tunnel starts; a share always has
7671
+ // one, and the probe only needs a path the listener answers.
7032
7672
  entryPath: `${ENTRY_PREFIX}${Array.from(share.grants.values())[0]?.token ?? ""}`,
7033
7673
  onState: (next) => {
7034
7674
  if (active !== share || JSON.stringify(share.tunnel) === JSON.stringify(next))
@@ -7181,11 +7821,9 @@ function createShareManager(options) {
7181
7821
  return null;
7182
7822
  return {
7183
7823
  ...options.viewerConfig,
7184
- // The project id is the config's absolute path, which keys the sharer's
7185
- // saved layout and names their machine's directories, and the dev
7186
- // server's address names their network. A viewer keeps no layout and
7187
- // never dials the dev server, so the share's own id does the job and
7188
- // the address is left blank.
7824
+ // The project id is the config's absolute path and the dev server address
7825
+ // names the sharer's network. Viewers keep no layout and never dial the
7826
+ // dev server, so they get the share's id and a blank address.
7189
7827
  project: `share:${share.id}`,
7190
7828
  devServer: "",
7191
7829
  previews: options.previewsForConfig(previews),
@@ -7253,12 +7891,12 @@ function createShareManager(options) {
7253
7891
  }
7254
7892
 
7255
7893
  // ../server/dist/config/renames.js
7256
- import { mkdir as mkdir6, readFile as readFile10, writeFile as writeFile6 } from "fs/promises";
7257
- import { dirname as dirname6, join as join10 } from "path";
7894
+ import { mkdir as mkdir7, readFile as readFile11, writeFile as writeFile7 } from "fs/promises";
7895
+ import { dirname as dirname6, join as join11 } from "path";
7258
7896
  var RENAMES_PATH = ".leglas/renames.json";
7259
7897
  async function readRenames(cwd) {
7260
7898
  try {
7261
- const raw = await readFile10(join10(cwd, RENAMES_PATH), "utf8");
7899
+ const raw = await readFile11(join11(cwd, RENAMES_PATH), "utf8");
7262
7900
  const parsed = parseJson(raw);
7263
7901
  if (!isJsonRecord(parsed) || !isJsonRecord(parsed.renames) && !Array.isArray(parsed.renames))
7264
7902
  return {};
@@ -7268,9 +7906,9 @@ async function readRenames(cwd) {
7268
7906
  }
7269
7907
  }
7270
7908
  async function writeRenames(cwd, renames) {
7271
- const path = join10(cwd, RENAMES_PATH);
7272
- await mkdir6(dirname6(path), { recursive: true });
7273
- await writeFile6(path, `${JSON.stringify({ renames }, null, 2)}
7909
+ const path = join11(cwd, RENAMES_PATH);
7910
+ await mkdir7(dirname6(path), { recursive: true });
7911
+ await writeFile7(path, `${JSON.stringify({ renames }, null, 2)}
7274
7912
  `, "utf8");
7275
7913
  }
7276
7914
  function resolveTitle(input, titles, renames) {
@@ -7287,10 +7925,10 @@ function resolveTitle(input, titles, renames) {
7287
7925
  // ../server/dist/server.js
7288
7926
  import { createReadStream, existsSync as existsSync3, statSync, unwatchFile, watch as watchFs, watchFile } from "fs";
7289
7927
  import { createHash as createHash2 } from "crypto";
7290
- import { mkdir as mkdir8, readdir as readdir5, writeFile as writeFile9 } from "fs/promises";
7928
+ import { mkdir as mkdir9, readdir as readdir6, writeFile as writeFile10 } from "fs/promises";
7291
7929
  import http4 from "http";
7292
7930
  import net3 from "net";
7293
- import { basename as basename3, dirname as dirname10, extname as extname3, join as join16, normalize as normalize2, relative as relative4 } from "path";
7931
+ import { basename as basename3, dirname as dirname10, extname as extname3, join as join17, normalize as normalize4, relative as relative4 } from "path";
7294
7932
 
7295
7933
  // ../server/dist/frame-policy.js
7296
7934
  var FRAMING_TIMEOUT_MS = 4e3;
@@ -7403,8 +8041,8 @@ async function checkFraming(target, embedder, fetcher = fetch) {
7403
8041
 
7404
8042
  // ../server/dist/generation/generation.js
7405
8043
  import { spawn as nodeSpawn4 } from "child_process";
7406
- import { readFile as readFile14, writeFile as writeFile7 } from "fs/promises";
7407
- import { dirname as dirname8, join as join14 } from "path";
8044
+ import { readFile as readFile15, writeFile as writeFile8 } from "fs/promises";
8045
+ import { dirname as dirname8, join as join15, normalize as normalize3 } from "path";
7408
8046
 
7409
8047
  // ../server/dist/generation/claude.js
7410
8048
  var GENERATION_EFFORT = "medium";
@@ -7447,9 +8085,9 @@ function resultText(lines2) {
7447
8085
  }
7448
8086
 
7449
8087
  // ../server/dist/generation/codex.js
7450
- import { readFile as readFile11 } from "fs/promises";
8088
+ import { readFile as readFile12 } from "fs/promises";
7451
8089
  import { homedir as homedir2 } from "os";
7452
- import { join as join11 } from "path";
8090
+ import { join as join12 } from "path";
7453
8091
  var RESTRICTED2 = [
7454
8092
  "exec",
7455
8093
  "--json",
@@ -7463,8 +8101,8 @@ var RESTRICTED2 = [
7463
8101
  "skills.include_instructions=false",
7464
8102
  "-c",
7465
8103
  "skills.bundled.enabled=false",
7466
- // Through the config, not `--disable`: a Codex that lacks one of these
7467
- // ignores it here, where `--disable` refuses to start at all.
8104
+ // Through the config, not `--disable`: an unknown feature is ignored here,
8105
+ // while `--disable` refuses to start.
7468
8106
  ...[
7469
8107
  "multi_agent",
7470
8108
  "apps",
@@ -7480,7 +8118,7 @@ var BARE = /^[A-Za-z0-9_-]+$/;
7480
8118
  async function codexServers(home = defaultCodexHome()) {
7481
8119
  let config;
7482
8120
  try {
7483
- config = await readFile11(join11(home, "config.toml"), "utf8");
8121
+ config = await readFile12(join12(home, "config.toml"), "utf8");
7484
8122
  } catch {
7485
8123
  return [];
7486
8124
  }
@@ -7493,7 +8131,7 @@ async function codexServers(home = defaultCodexHome()) {
7493
8131
  return [...names];
7494
8132
  }
7495
8133
  function defaultCodexHome(env = process.env) {
7496
- return env.CODEX_HOME !== void 0 && env.CODEX_HOME !== "" ? env.CODEX_HOME : join11(homedir2(), ".codex");
8134
+ return env.CODEX_HOME !== void 0 && env.CODEX_HOME !== "" ? env.CODEX_HOME : join12(homedir2(), ".codex");
7497
8135
  }
7498
8136
  var serversOff = (servers) => servers.flatMap((name) => ["-c", `mcp_servers.${name}.enabled=false`]);
7499
8137
  function codexPlanArgs(prompt, servers) {
@@ -7519,12 +8157,12 @@ function codexResultText(lines2) {
7519
8157
  }
7520
8158
 
7521
8159
  // ../server/dist/generation/facts.js
7522
- import { readFile as readFile13, readdir as readdir4 } from "fs/promises";
7523
- import { dirname as dirname7, join as join13, normalize } from "path";
8160
+ import { readFile as readFile14, readdir as readdir5 } from "fs/promises";
8161
+ import { dirname as dirname7, join as join14, normalize as normalize2 } from "path";
7524
8162
 
7525
8163
  // ../server/dist/generation/switch-file.js
7526
- import { readFile as readFile12, readdir as readdir3, stat as stat4 } from "fs/promises";
7527
- import { join as join12, relative as relative3 } from "path";
8164
+ import { readFile as readFile13, readdir as readdir4, stat as stat4 } from "fs/promises";
8165
+ import { join as join13, relative as relative3 } from "path";
7528
8166
  var MAP = /const DIRECTIONS = \{([\s\S]*?)\} as const;/;
7529
8167
  var SOURCE = /\.(tsx|jsx|ts|js)$/;
7530
8168
  var SCAN_LIMIT = 3e3;
@@ -7537,8 +8175,8 @@ async function isFile(path) {
7537
8175
  }
7538
8176
  async function findSwitch(cwd, slug) {
7539
8177
  for (const name of ["switch.tsx", "switch.jsx"]) {
7540
- const path = join12(".leglas", "variants", slug, name);
7541
- if (await isFile(join12(cwd, path)))
8178
+ const path = join13(".leglas", "variants", slug, name);
8179
+ if (await isFile(join13(cwd, path)))
7542
8180
  return path;
7543
8181
  }
7544
8182
  const wanted = `"v-${slug}"`;
@@ -7546,14 +8184,14 @@ async function findSwitch(cwd, slug) {
7546
8184
  const walk = async (folder) => {
7547
8185
  let items;
7548
8186
  try {
7549
- items = await readdir3(join12(cwd, folder), { withFileTypes: true });
8187
+ items = await readdir4(join13(cwd, folder), { withFileTypes: true });
7550
8188
  } catch {
7551
8189
  return null;
7552
8190
  }
7553
8191
  for (const item of items) {
7554
8192
  if (seen >= SCAN_LIMIT || item.name.startsWith(".") || item.name === "node_modules")
7555
8193
  continue;
7556
- const path = join12(folder, item.name);
8194
+ const path = join13(folder, item.name);
7557
8195
  if (item.isDirectory()) {
7558
8196
  const found = await walk(path);
7559
8197
  if (found !== null)
@@ -7563,7 +8201,7 @@ async function findSwitch(cwd, slug) {
7563
8201
  if (!SOURCE.test(item.name))
7564
8202
  continue;
7565
8203
  seen += 1;
7566
- const source = await readFile12(join12(cwd, path), "utf8").catch(() => "");
8204
+ const source = await readFile13(join13(cwd, path), "utf8").catch(() => "");
7567
8205
  if (source.includes(wanted) && MAP.test(source))
7568
8206
  return path;
7569
8207
  }
@@ -7572,7 +8210,7 @@ async function findSwitch(cwd, slug) {
7572
8210
  for (const root of ["src", "app"]) {
7573
8211
  const found = await walk(root);
7574
8212
  if (found !== null)
7575
- return relative3(cwd, join12(cwd, found));
8213
+ return relative3(cwd, join13(cwd, found));
7576
8214
  }
7577
8215
  return null;
7578
8216
  }
@@ -7640,21 +8278,21 @@ function clip(source, limit) {
7640
8278
  var BASELINE_LINES = 15;
7641
8279
  var STUB = /Re-export what your|A first direction for/;
7642
8280
  async function text2(path) {
7643
- return readFile13(path, "utf8").catch(() => null);
8281
+ return readFile14(path, "utf8").catch(() => null);
7644
8282
  }
7645
8283
  async function firstFile(cwd, candidates) {
7646
8284
  for (const candidate of candidates) {
7647
- if (await text2(join13(cwd, candidate)) !== null)
8285
+ if (await text2(join14(cwd, candidate)) !== null)
7648
8286
  return candidate;
7649
8287
  }
7650
8288
  return null;
7651
8289
  }
7652
8290
  function cssImports(source, from) {
7653
- return [...source.matchAll(/import\s+["']([^"']+\.css)["']/g)].map((match) => normalize(join13(dirname7(from), match[1] ?? "")));
8291
+ return [...source.matchAll(/import\s+["']([^"']+\.css)["']/g)].map((match) => normalize2(join14(dirname7(from), match[1] ?? "")));
7654
8292
  }
7655
8293
  async function globalStylesheet(cwd) {
7656
8294
  const entries = [];
7657
- const html = await text2(join13(cwd, "index.html"));
8295
+ const html = await text2(join14(cwd, "index.html"));
7658
8296
  const script = html === null ? null : /<script[^>]+type="module"[^>]+src="\/?([^"]+)"/.exec(html);
7659
8297
  if (script?.[1] !== void 0)
7660
8298
  entries.push(script[1]);
@@ -7664,12 +8302,12 @@ async function globalStylesheet(cwd) {
7664
8302
  }
7665
8303
  const sheets = [];
7666
8304
  for (const entry of entries) {
7667
- const source = await text2(join13(cwd, entry));
8305
+ const source = await text2(join14(cwd, entry));
7668
8306
  if (source !== null)
7669
8307
  sheets.push(...cssImports(source, entry));
7670
8308
  }
7671
8309
  for (const sheet of sheets) {
7672
- const source = await text2(join13(cwd, sheet));
8310
+ const source = await text2(join14(cwd, sheet));
7673
8311
  if (source !== null && /:root\s*\{|@font-face/.test(source))
7674
8312
  return sheet;
7675
8313
  }
@@ -7680,7 +8318,7 @@ async function images(cwd) {
7680
8318
  const walk = async (folder) => {
7681
8319
  let items;
7682
8320
  try {
7683
- items = await readdir4(join13(cwd, "public", folder), { withFileTypes: true });
8321
+ items = await readdir5(join14(cwd, "public", folder), { withFileTypes: true });
7684
8322
  } catch {
7685
8323
  return;
7686
8324
  }
@@ -7698,14 +8336,14 @@ async function images(cwd) {
7698
8336
  return found;
7699
8337
  }
7700
8338
  async function componentFile(cwd, from, specifier) {
7701
- const base = normalize(join13(dirname7(from), specifier));
8339
+ const base = normalize2(join14(dirname7(from), specifier));
7702
8340
  return firstFile(cwd, [
7703
8341
  base,
7704
8342
  ...["tsx", "jsx", "ts", "js"].map((extension) => `${base}.${extension}`)
7705
8343
  ]);
7706
8344
  }
7707
8345
  async function example(cwd, switchPath, prefer) {
7708
- const source = await text2(join13(cwd, switchPath));
8346
+ const source = await text2(join14(cwd, switchPath));
7709
8347
  if (source === null)
7710
8348
  return null;
7711
8349
  const directions = readDirections(source);
@@ -7714,13 +8352,13 @@ async function example(cwd, switchPath, prefer) {
7714
8352
  for (const direction of ordered) {
7715
8353
  const limit = direction.key === prefer ? VARIED_LINES : EXAMPLE_LINES;
7716
8354
  const path = await componentFile(cwd, switchPath, direction.from);
7717
- const body = path === null ? null : await text2(join13(cwd, path));
8355
+ const body = path === null ? null : await text2(join14(cwd, path));
7718
8356
  if (path === null || body === null || body === placeholderSource(direction.name) || STUB.test(body))
7719
8357
  continue;
7720
8358
  if (body.split("\n").length < BASELINE_LINES) {
7721
8359
  const target = /from\s+["'](\.[^"']+)["']/.exec(body)?.[1];
7722
8360
  const followed = target === void 0 ? null : await componentFile(cwd, path, target);
7723
- const real = followed === null ? null : await text2(join13(cwd, followed));
8361
+ const real = followed === null ? null : await text2(join14(cwd, followed));
7724
8362
  if (followed !== null && real !== null) {
7725
8363
  return {
7726
8364
  key: direction.key,
@@ -7736,7 +8374,7 @@ async function example(cwd, switchPath, prefer) {
7736
8374
  }
7737
8375
  async function dependencyNames(cwd) {
7738
8376
  try {
7739
- const pkg = parseJson(await text2(join13(cwd, "package.json")) ?? "");
8377
+ const pkg = parseJson(await text2(join14(cwd, "package.json")) ?? "");
7740
8378
  return isJsonRecord(pkg) && isJsonRecord(pkg.dependencies) ? Object.keys(pkg.dependencies) : [];
7741
8379
  } catch {
7742
8380
  return [];
@@ -7745,7 +8383,7 @@ async function dependencyNames(cwd) {
7745
8383
  async function readProjectFacts(cwd, switchPath, prefer = null) {
7746
8384
  const dependencies = await dependencyNames(cwd);
7747
8385
  const sheetPath = await globalStylesheet(cwd);
7748
- const css = sheetPath === null ? null : await text2(join13(cwd, sheetPath));
8386
+ const css = sheetPath === null ? null : await text2(join14(cwd, sheetPath));
7749
8387
  return {
7750
8388
  dependencies,
7751
8389
  stylesheet: sheetPath === null || css === null ? null : {
@@ -7822,7 +8460,13 @@ ${input.facts}
7822
8460
  This is a first draft for someone comparing directions side by side, not a finished page. Aim for about 250 lines and write it in one pass: no second drafts, and no review, lint, tests or screenshots. Leglas renders and captures it for you. Your task ends when the file is written.`;
7823
8461
  }
7824
8462
  function fixPrompt(input) {
7825
- return `Leglas rendered ${input.file} and the page reported: ${input.errors.slice(0, 3).join(" | ")}. Fix what your file causes, in as few edits as you can, and change nothing else. If nothing in your file causes it, change nothing. Do not look around the project.`;
8463
+ const layout = input.layout ?? [];
8464
+ const reported = `Leglas rendered ${input.file} and the page reported: ${input.errors.slice(0, 3).join(" | ")}.`;
8465
+ if (layout.length === 0) {
8466
+ return `${reported} Fix what your file causes, in as few edits as you can, and change nothing else. If nothing in your file causes it, change nothing. Do not look around the project.`;
8467
+ }
8468
+ const opening = input.errors.length === 0 ? `Leglas rendered ${input.file}.` : reported;
8469
+ return `${opening} At 1440 by 900, text a person would see runs into other text or is cut off: ${layout.slice(0, 5).join("; ")}. Fix it where it happens: move, wrap or re-set that text, or nudge what crowds it. Keep the design's scale and composition, and never shrink the design or its type to make room. A crop or an overlap of shapes and pictures can stay. If nothing in your file causes it, change nothing. Do not look around the project.`;
7826
8470
  }
7827
8471
  function kebab(value) {
7828
8472
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -7873,6 +8517,16 @@ var OWN_FAILURES = /* @__PURE__ */ new Set([
7873
8517
  ]);
7874
8518
  var BUSY = "A set of directions is already being built. Wait for it, or stop it first.";
7875
8519
  var KEPT_JOBS = 5;
8520
+ var guardKey = (slot, attempt) => `${slot.key}#${attempt}`;
8521
+ function listed(items) {
8522
+ return items.length <= 1 ? items[0] ?? "" : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
8523
+ }
8524
+ function strayMessage(paths, restored, named) {
8525
+ const which = paths.length === 1 ? "which is not its own file" : "which are not its own files";
8526
+ const back = restored.length === 0 ? "" : restored.length === paths.length ? ` and put ${paths.length === 1 ? "the file" : "them"} back` : ` and put ${listed(restored)} back`;
8527
+ const check = named.length === 0 ? "." : `. Check ${listed(named)} before trying again.`;
8528
+ return `The build edited ${listed(paths)}, ${which}, so Leglas stopped it${back}${check}`;
8529
+ }
7876
8530
  var CLAUDE = {
7877
8531
  id: "claude",
7878
8532
  name: "Claude",
@@ -7889,6 +8543,10 @@ function codex(servers) {
7889
8543
  result: codexResultText
7890
8544
  };
7891
8545
  }
8546
+ function argsOf(agent) {
8547
+ const mark = "\0prompt";
8548
+ return agent.build(mark).map((arg) => arg === mark ? "<prompt>" : arg);
8549
+ }
7892
8550
  function wait(milliseconds) {
7893
8551
  return new Promise((resolve5) => {
7894
8552
  setTimeout(resolve5, milliseconds).unref?.();
@@ -7909,7 +8567,7 @@ function baseOf(preview, surface) {
7909
8567
  async function stackOf(cwd, switchPath) {
7910
8568
  let deps = {};
7911
8569
  try {
7912
- const pkg = parseJson(await readFile14(join14(cwd, "package.json"), "utf8"));
8570
+ const pkg = parseJson(await readFile15(join15(cwd, "package.json"), "utf8"));
7913
8571
  if (isJsonRecord(pkg)) {
7914
8572
  for (const group of [pkg.devDependencies, pkg.dependencies]) {
7915
8573
  if (!isJsonRecord(group))
@@ -7957,6 +8615,81 @@ function createGenerations(deps) {
7957
8615
  let closed = false;
7958
8616
  const busy = () => lives.some((live) => live.job.state === "planning" || live.job.state === "building");
7959
8617
  const changed = () => deps.onChange();
8618
+ const records = createRecords(deps.cwd);
8619
+ const runRecord = (prompt) => ({
8620
+ prompt,
8621
+ startedAt: now(),
8622
+ endedAt: null,
8623
+ exitCode: null,
8624
+ timedOut: false,
8625
+ error: null,
8626
+ result: null,
8627
+ tail: []
8628
+ });
8629
+ const ended = (agent, record3, outcome) => {
8630
+ const failed = outcome.timedOut || outcome.error !== null || outcome.code !== 0;
8631
+ record3.endedAt = now();
8632
+ record3.exitCode = outcome.code;
8633
+ record3.timedOut = outcome.timedOut;
8634
+ record3.error = outcome.error;
8635
+ record3.result = agent.result(outcome.lines);
8636
+ record3.tail = failed ? outcome.lines.slice(-40) : [];
8637
+ };
8638
+ const attemptRecord = (live, key, attempt) => live.record?.directions.find((direction) => direction.key === key)?.attempts.find((each) => each.attempt === attempt);
8639
+ const opened = (live, slot, attempt, cause) => {
8640
+ live.record?.directions.find((direction) => direction.key === slot.key)?.attempts.push({
8641
+ attempt,
8642
+ cause,
8643
+ title: slot.title,
8644
+ idea: slot.idea,
8645
+ ideaRun: null,
8646
+ build: null,
8647
+ renders: [],
8648
+ fix: null,
8649
+ state: slot.state,
8650
+ failure: null,
8651
+ fixed: false,
8652
+ layout: [],
8653
+ picture: null
8654
+ });
8655
+ };
8656
+ const saveRecord = (live) => {
8657
+ const { job, record: record3 } = live;
8658
+ if (record3 === null)
8659
+ return;
8660
+ record3.state = job.state;
8661
+ record3.error = job.error;
8662
+ record3.plannedAt = job.plannedAt;
8663
+ record3.endedAt = job.endedAt;
8664
+ for (const slot of job.slots) {
8665
+ const latest = record3.directions.find((each) => each.key === slot.key)?.attempts.at(-1);
8666
+ if (latest === void 0)
8667
+ continue;
8668
+ latest.title = slot.title;
8669
+ latest.idea = slot.idea;
8670
+ latest.state = slot.state;
8671
+ latest.failure = slot.failure === null ? null : { ...slot.failure };
8672
+ latest.fixed = slot.fixed;
8673
+ latest.layout = [...slot.layout];
8674
+ }
8675
+ records.save(record3);
8676
+ };
8677
+ const noted = (live, kind, direction) => {
8678
+ if (live.record === null)
8679
+ return;
8680
+ records.event(live.record.id, { at: now(), kind, direction });
8681
+ saveRecord(live);
8682
+ };
8683
+ const rendered = (live, slot, attempt, report) => {
8684
+ const record3 = attemptRecord(live, slot.key, attempt);
8685
+ if (live.record === null || record3 === void 0)
8686
+ return;
8687
+ record3.renders.push({ at: now(), errors: [...report.errors], layout: [...report.layout] });
8688
+ if (report.frame === null)
8689
+ return;
8690
+ record3.picture = `${slot.key}-${attempt}.jpg`;
8691
+ records.picture(live.record.id, record3.picture, report.frame);
8692
+ };
7960
8693
  let registry = Promise.resolve();
7961
8694
  const serially = (task) => {
7962
8695
  const next = registry.then(task, task);
@@ -8017,8 +8750,10 @@ function createGenerations(deps) {
8017
8750
  });
8018
8751
  child.once("close", (code) => {
8019
8752
  clearTimeout(deadline);
8020
- if (buffered !== "")
8753
+ if (buffered !== "") {
8021
8754
  lines2.push(buffered);
8755
+ onLine?.(buffered);
8756
+ }
8022
8757
  resolve5({ code, lines: lines2, error: null, timedOut });
8023
8758
  });
8024
8759
  });
@@ -8061,7 +8796,7 @@ function createGenerations(deps) {
8061
8796
  job.endedAt = now();
8062
8797
  };
8063
8798
  const restore = async (slot) => {
8064
- await writeFile7(join14(deps.cwd, slot.file), placeholderSource(pascal(slot.key)), "utf8").catch(() => {
8799
+ await writeFile8(join15(deps.cwd, slot.file), placeholderSource(pascal(slot.key)), "utf8").catch(() => {
8065
8800
  });
8066
8801
  };
8067
8802
  const begin = (live, slot) => {
@@ -8118,8 +8853,85 @@ function createGenerations(deps) {
8118
8853
  fail(live, slot, { code: "unexpected", message: message2 });
8119
8854
  settle(live);
8120
8855
  changed();
8856
+ saveRecord(live);
8857
+ };
8858
+ const strayed = async (live, slot, attempt) => {
8859
+ const guard = live.guards.get(guardKey(slot, attempt));
8860
+ if (guard === void 0 || guard.strays.size === 0)
8861
+ return null;
8862
+ const paths = [...guard.strays];
8863
+ const restored = [];
8864
+ const named = [];
8865
+ const building = new Set(lives.flatMap((other) => other.job.slots.flatMap((each) => each.state === "building" || each.state === "checking" ? [normalize3(each.file)] : [])));
8866
+ for (const path of paths) {
8867
+ const kept = guard.kept.get(path);
8868
+ if (kept === void 0 || building.has(path)) {
8869
+ named.push(path);
8870
+ continue;
8871
+ }
8872
+ const back = await writeFile8(join15(deps.cwd, path), kept, "utf8").then(() => true, () => false);
8873
+ (back ? restored : named).push(path);
8874
+ }
8875
+ guard.strays.clear();
8876
+ return {
8877
+ failure: { code: "outside-file", message: strayMessage(paths, restored, named) },
8878
+ putBack: named.length === 0
8879
+ };
8880
+ };
8881
+ const pendingStrays = (except) => {
8882
+ const pending = /* @__PURE__ */ new Map();
8883
+ for (const [key, guard] of lives.flatMap((other) => [...other.guards.entries()])) {
8884
+ if (except !== void 0 && key.startsWith(`${except.key}#`))
8885
+ continue;
8886
+ for (const path of guard.strays) {
8887
+ const kept = guard.kept.get(path);
8888
+ if (kept !== void 0)
8889
+ pending.set(path, kept);
8890
+ }
8891
+ }
8892
+ return pending;
8893
+ };
8894
+ const keptFiles = async (live, own = new Set(live.job.slots.map((slot) => normalize3(slot.file)))) => {
8895
+ const switchPath = live.switchPath;
8896
+ const pending = pendingStrays();
8897
+ const read = async (path) => pending.get(normalize3(path)) ?? await readFile15(join15(deps.cwd, path), "utf8").catch(() => null);
8898
+ const source = await read(switchPath);
8899
+ const kept = /* @__PURE__ */ new Map();
8900
+ if (source === null)
8901
+ return kept;
8902
+ kept.set(normalize3(switchPath), source);
8903
+ const keep = async (path) => {
8904
+ if (path === null || own.has(normalize3(path)) || kept.has(normalize3(path)))
8905
+ return null;
8906
+ const body = await read(path);
8907
+ if (body !== null)
8908
+ kept.set(normalize3(path), body);
8909
+ return body;
8910
+ };
8911
+ for (const direction of readDirections(source)) {
8912
+ const path = await componentFile(deps.cwd, switchPath, direction.from);
8913
+ const body = await keep(path);
8914
+ if (path === null || body === null)
8915
+ continue;
8916
+ for (const match of body.matchAll(/(?:from\s+|import\s*\(?\s*)["'](\.{1,2}\/[^"']+)["']/g)) {
8917
+ await keep(await componentFile(deps.cwd, path, match[1] ?? ""));
8918
+ }
8919
+ }
8920
+ return kept;
8121
8921
  };
8122
8922
  const follow = (live, slot, attempt) => (line) => {
8923
+ const guard = live.guards.get(guardKey(slot, attempt));
8924
+ for (const path of editedFiles(live.agent.id, line, deps.cwd)) {
8925
+ if (guard === void 0 || path === normalize3(slot.file) || guard.strays.has(path))
8926
+ continue;
8927
+ guard.strays.add(path);
8928
+ if (guard.strays.size > 1 || !owns(live, slot, attempt))
8929
+ continue;
8930
+ const running = live.runs.get(slot.key);
8931
+ if (running !== void 0)
8932
+ detach(live, running.stop(), () => {
8933
+ });
8934
+ }
8123
8935
  const activity = activityFrom(live.agent.id, line, deps.cwd);
8124
8936
  if (activity === null || activity === slot.activity || !owns(live, slot, attempt))
8125
8937
  return;
@@ -8136,8 +8948,21 @@ function createGenerations(deps) {
8136
8948
  slot.endedAt = null;
8137
8949
  slot.failure = null;
8138
8950
  slot.fixed = false;
8951
+ slot.layout = [];
8139
8952
  slot.activity = null;
8140
8953
  changed();
8954
+ const key = guardKey(slot, attempt);
8955
+ if (!live.guards.has(key))
8956
+ live.guards.set(key, { kept: await keptFiles(live), strays: /* @__PURE__ */ new Set() });
8957
+ try {
8958
+ if (!owns(live, slot, attempt))
8959
+ return;
8960
+ await buildOwned(live, slot, attempt, concept);
8961
+ } finally {
8962
+ live.guards.delete(key);
8963
+ }
8964
+ };
8965
+ const buildOwned = async (live, slot, attempt, concept) => {
8141
8966
  const others = [...live.concepts.values()].filter((other) => other !== concept);
8142
8967
  const prompt = buildPrompt({
8143
8968
  surface: live.job.surface,
@@ -8150,17 +8975,26 @@ function createGenerations(deps) {
8150
8975
  facts: live.facts ?? "",
8151
8976
  base: live.base
8152
8977
  });
8978
+ const buildRun = runRecord(prompt);
8979
+ const own = attemptRecord(live, slot.key, attempt);
8980
+ if (own !== void 0)
8981
+ own.build = buildRun;
8153
8982
  const building = run4(live.agent, live.agent.build(prompt), BUILD_DEADLINE_MS, follow(live, slot, attempt));
8154
8983
  live.runs.set(slot.key, building);
8155
8984
  const outcome = await building.done;
8985
+ ended(live.agent, buildRun, outcome);
8986
+ saveRecord(live);
8987
+ const outside = await strayed(live, slot, attempt);
8156
8988
  if (!owns(live, slot, attempt))
8157
8989
  return;
8158
8990
  if (live.runs.get(slot.key) === building)
8159
8991
  live.runs.delete(slot.key);
8160
- const written = await readFile14(join14(deps.cwd, slot.file), "utf8").catch(() => "");
8992
+ const written = await readFile15(join15(deps.cwd, slot.file), "utf8").catch(() => "");
8161
8993
  if (!owns(live, slot, attempt))
8162
8994
  return;
8163
- if (outcome.timedOut) {
8995
+ if (outside !== null) {
8996
+ fail(live, slot, outside.failure);
8997
+ } else if (outcome.timedOut) {
8164
8998
  fail(live, slot, {
8165
8999
  code: "too-slow",
8166
9000
  message: `The build took longer than ${BUILD_DEADLINE_MS / 6e4} minutes, so Leglas stopped it.`
@@ -8180,19 +9014,25 @@ function createGenerations(deps) {
8180
9014
  }
8181
9015
  settle(live);
8182
9016
  changed();
9017
+ saveRecord(live);
8183
9018
  };
8184
- const ownErrors = async (live, slot, attempt) => {
9019
+ const ownReport = async (live, slot, attempt) => {
8185
9020
  const others = live.job.slots.filter((other) => other !== slot);
8186
9021
  const until = now() + BUILD_DEADLINE_MS + FIX_DEADLINE_MS;
8187
9022
  for (; ; ) {
8188
- const report = await deps.render(slot.title);
9023
+ const report = await deps.render(slot.title, { picture: live.record !== null });
8189
9024
  if (!owns(live, slot, attempt))
8190
9025
  return null;
8191
9026
  const errors = report?.errors ?? [];
8192
9027
  const names = (file) => errors.some((error) => error.includes(file));
8193
- const waiting = !names(slot.file) && others.some((other) => (other.state === "building" || other.state === "checking") && names(other.file));
9028
+ const pending = [...pendingStrays(slot).keys()];
9029
+ const waiting = !names(slot.file) && (pending.some(names) || others.some((other) => (other.state === "building" || other.state === "checking") && names(other.file)));
8194
9030
  if (!waiting || now() >= until) {
8195
- return errors.filter((error) => !others.some((other) => error.includes(other.file)));
9031
+ return {
9032
+ errors: errors.filter((error) => !others.some((other) => error.includes(other.file))),
9033
+ layout: [...report?.layout ?? []],
9034
+ frame: report?.frame ?? null
9035
+ };
8196
9036
  }
8197
9037
  await wait(RECHECK_MS);
8198
9038
  if (!owns(live, slot, attempt))
@@ -8200,35 +9040,67 @@ function createGenerations(deps) {
8200
9040
  }
8201
9041
  };
8202
9042
  const check = async (live, slot, attempt) => {
8203
- let errors = await ownErrors(live, slot, attempt);
8204
- if (errors === null)
9043
+ let report = await ownReport(live, slot, attempt);
9044
+ if (report === null)
8205
9045
  return;
8206
- if (errors.length > 0) {
8207
- const fixing = run4(live.agent, live.agent.build(fixPrompt({ file: slot.file, errors })), FIX_DEADLINE_MS, follow(live, slot, attempt));
9046
+ rendered(live, slot, attempt, report);
9047
+ if (report.errors.length > 0 || report.layout.length > 0) {
9048
+ const repairs = report.errors.length > 0;
9049
+ const found = report.layout;
9050
+ const working = repairs ? null : await readFile15(join15(deps.cwd, slot.file), "utf8").catch(() => null);
9051
+ if (!owns(live, slot, attempt))
9052
+ return;
9053
+ const prompt = fixPrompt({ file: slot.file, errors: report.errors, layout: report.layout });
9054
+ const fixRun = runRecord(prompt);
9055
+ const own = attemptRecord(live, slot.key, attempt);
9056
+ if (own !== void 0)
9057
+ own.fix = fixRun;
9058
+ const fixing = run4(live.agent, live.agent.build(prompt), FIX_DEADLINE_MS, follow(live, slot, attempt));
8208
9059
  live.runs.set(slot.key, fixing);
8209
9060
  const outcome = await fixing.done;
9061
+ ended(live.agent, fixRun, outcome);
9062
+ const outside = await strayed(live, slot, attempt);
8210
9063
  if (!owns(live, slot, attempt))
8211
9064
  return;
8212
9065
  slot.activity = null;
8213
9066
  changed();
8214
9067
  if (live.runs.get(slot.key) === fixing)
8215
9068
  live.runs.delete(slot.key);
8216
- if (outcome.timedOut || outcome.error !== null || outcome.code !== 0) {
8217
- fail(live, slot, outcome.timedOut ? { code: "too-slow", message: "The fix took too long, so Leglas stopped it." } : failureOf2(live.agent, outcome));
8218
- return;
9069
+ let failure = outside?.failure ?? (outcome.timedOut ? { code: "too-slow", message: "The fix took too long, so Leglas stopped it." } : outcome.error !== null || outcome.code !== 0 ? failureOf2(live.agent, outcome) : null);
9070
+ if (failure === null) {
9071
+ slot.fixed = repairs;
9072
+ report = await ownReport(live, slot, attempt);
9073
+ if (report === null)
9074
+ return;
9075
+ rendered(live, slot, attempt, report);
9076
+ if (report.errors.length > 0) {
9077
+ failure = {
9078
+ code: "broken",
9079
+ message: `The page still reports: ${report.errors[0] ?? ""}`
9080
+ };
9081
+ }
8219
9082
  }
8220
- slot.fixed = true;
8221
- errors = await ownErrors(live, slot, attempt);
8222
- if (errors === null)
8223
- return;
8224
- if (errors.length > 0) {
8225
- fail(live, slot, {
8226
- code: "broken",
8227
- message: `The page still reports: ${errors[0] ?? ""}`
8228
- });
8229
- return;
9083
+ const worse = failure === null && !repairs && report.layout.length > found.length;
9084
+ if (failure !== null || worse) {
9085
+ if (working !== null && outside?.putBack !== false) {
9086
+ const restored = await writeFile8(join15(deps.cwd, slot.file), working, "utf8").then(() => true, () => false);
9087
+ if (!owns(live, slot, attempt))
9088
+ return;
9089
+ if (restored) {
9090
+ slot.fixed = false;
9091
+ slot.layout = found;
9092
+ slot.state = "ready";
9093
+ slot.endedAt = now();
9094
+ return;
9095
+ }
9096
+ }
9097
+ if (failure !== null) {
9098
+ fail(live, slot, failure);
9099
+ return;
9100
+ }
8230
9101
  }
8231
9102
  }
9103
+ slot.layout = report.layout;
8232
9104
  slot.state = "ready";
8233
9105
  slot.endedAt = now();
8234
9106
  };
@@ -8241,16 +9113,22 @@ function createGenerations(deps) {
8241
9113
  };
8242
9114
  const plan = async (live, existing) => {
8243
9115
  const { job } = live;
8244
- const planning = run4(live.agent, live.agent.plan(planPrompt({
9116
+ const prompt = planPrompt({
8245
9117
  surface: job.surface,
8246
9118
  brief: job.brief,
8247
9119
  count: job.count,
8248
9120
  existing,
8249
9121
  base: live.base
8250
- })), PLAN_DEADLINE_MS);
9122
+ });
9123
+ const planRun = { ...runRecord(prompt), concepts: [] };
9124
+ if (live.record !== null)
9125
+ live.record.plan = planRun;
9126
+ const planning = run4(live.agent, live.agent.plan(prompt), PLAN_DEADLINE_MS);
8251
9127
  live.plan = planning;
8252
9128
  const outcome = await planning.done;
8253
9129
  live.plan = null;
9130
+ ended(live.agent, planRun, outcome);
9131
+ saveRecord(live);
8254
9132
  if (job.state !== "planning")
8255
9133
  return;
8256
9134
  if (outcome.timedOut || outcome.error !== null || outcome.code !== 0) {
@@ -8258,6 +9136,7 @@ function createGenerations(deps) {
8258
9136
  job.error = outcome.timedOut ? "Planning took too long, so Leglas stopped it." : failureOf2(live.agent, outcome).message;
8259
9137
  job.endedAt = now();
8260
9138
  changed();
9139
+ saveRecord(live);
8261
9140
  return;
8262
9141
  }
8263
9142
  let concepts;
@@ -8268,8 +9147,10 @@ function createGenerations(deps) {
8268
9147
  job.error = error instanceof Error ? error.message : String(error);
8269
9148
  job.endedAt = now();
8270
9149
  changed();
9150
+ saveRecord(live);
8271
9151
  return;
8272
9152
  }
9153
+ planRun.concepts = concepts;
8273
9154
  live.facts = factsBlock(await readProjectFacts(deps.cwd, live.switchPath, live.base?.key ?? null), job.surface, live.base);
8274
9155
  const slug = surfaceSlug2(job.surface);
8275
9156
  const keys = new Set(existing);
@@ -8277,7 +9158,7 @@ function createGenerations(deps) {
8277
9158
  const folder = dirname8(live.switchPath);
8278
9159
  const slots = [];
8279
9160
  const extension = live.switchPath.endsWith(".jsx") ? "jsx" : "tsx";
8280
- const taken = async (key) => keys.has(key) || await isFile(join14(deps.cwd, folder, `${key}.${extension}`));
9161
+ const taken = async (key) => keys.has(key) || await isFile(join15(deps.cwd, folder, `${key}.${extension}`));
8281
9162
  for (const concept of concepts) {
8282
9163
  let key = `${slug}-${concept.key}`;
8283
9164
  for (let suffix = 2; await taken(key); suffix += 1)
@@ -8289,23 +9170,25 @@ function createGenerations(deps) {
8289
9170
  key,
8290
9171
  title,
8291
9172
  idea: concept.idea,
8292
- file: join14(folder, `${key}.${extension}`),
9173
+ file: join15(folder, `${key}.${extension}`),
8293
9174
  state: "building",
8294
9175
  startedAt: null,
8295
9176
  endedAt: null,
8296
9177
  failure: null,
8297
9178
  fixed: false,
9179
+ layout: [],
8298
9180
  activity: null
8299
9181
  });
8300
9182
  }
8301
- const source = await readFile14(join14(deps.cwd, live.switchPath), "utf8");
9183
+ const source = await readFile15(join15(deps.cwd, live.switchPath), "utf8");
8302
9184
  for (const slot of slots) {
8303
- await writeFile7(join14(deps.cwd, slot.file), placeholderSource(pascal(slot.key)), {
9185
+ await writeFile8(join15(deps.cwd, slot.file), placeholderSource(pascal(slot.key)), {
8304
9186
  encoding: "utf8",
8305
9187
  flag: "wx"
8306
9188
  });
8307
9189
  }
8308
- await writeFile7(join14(deps.cwd, live.switchPath), addSlots(source, slots.map((slot) => ({ key: slot.key, name: pascal(slot.key) }))), "utf8");
9190
+ await writeFile8(join15(deps.cwd, live.switchPath), addSlots(source, slots.map((slot) => ({ key: slot.key, name: pascal(slot.key) }))), "utf8");
9191
+ const kept = await keptFiles(live, new Set(slots.map((slot) => normalize3(slot.file))));
8309
9192
  for (const slot of slots) {
8310
9193
  await serially(() => deps.register({
8311
9194
  title: slot.title,
@@ -8315,21 +9198,33 @@ function createGenerations(deps) {
8315
9198
  }));
8316
9199
  }
8317
9200
  job.slots = slots;
9201
+ if (live.record !== null) {
9202
+ live.record.directions = slots.map((slot) => ({
9203
+ key: slot.key,
9204
+ file: slot.file,
9205
+ attempts: []
9206
+ }));
9207
+ }
8318
9208
  if (job.state !== "planning") {
8319
9209
  for (const slot of slots) {
8320
9210
  slot.state = "stopped";
8321
9211
  slot.endedAt = now();
8322
9212
  }
8323
9213
  changed();
9214
+ saveRecord(live);
8324
9215
  return;
8325
9216
  }
8326
9217
  job.state = "building";
8327
9218
  job.plannedAt = now();
8328
9219
  changed();
8329
- await Promise.all(job.slots.map((slot) => {
9220
+ const builds = job.slots.map((slot) => {
8330
9221
  const attempt = begin(live, slot);
9222
+ live.guards.set(guardKey(slot, attempt), { kept, strays: /* @__PURE__ */ new Set() });
9223
+ opened(live, slot, attempt, "plan");
8331
9224
  return guarded(build(live, slot, attempt), (message2) => lost(live, slot, attempt, message2));
8332
- }));
9225
+ });
9226
+ saveRecord(live);
9227
+ await Promise.all(builds);
8333
9228
  };
8334
9229
  const find = (id) => lives.find((live) => live.job.id === id);
8335
9230
  const stop = async (id, key) => {
@@ -8341,6 +9236,7 @@ function createGenerations(deps) {
8341
9236
  job.state = "stopped";
8342
9237
  job.endedAt = now();
8343
9238
  changed();
9239
+ noted(live, "stop", null);
8344
9240
  await live.plan?.stop();
8345
9241
  await drain(live);
8346
9242
  return true;
@@ -8353,6 +9249,8 @@ function createGenerations(deps) {
8353
9249
  }
8354
9250
  settle(live);
8355
9251
  changed();
9252
+ if (targets.length > 0)
9253
+ noted(live, "stop", key ?? null);
8356
9254
  await Promise.all(targets.map((slot) => release(live, slot)));
8357
9255
  return targets.length > 0;
8358
9256
  };
@@ -8387,7 +9285,7 @@ function createGenerations(deps) {
8387
9285
  error: `The ${slug} has no switch file yet. Run \`leglas new ${slug} --from <your component>\` and render ${pascal(slug)}Switch where the ${slug} is, then build directions into it.`
8388
9286
  };
8389
9287
  }
8390
- const existing = directionKeys(await readFile14(join14(deps.cwd, switchPath), "utf8"));
9288
+ const existing = directionKeys(await readFile15(join15(deps.cwd, switchPath), "utf8"));
8391
9289
  if (base !== null && !existing.includes(base.key)) {
8392
9290
  return {
8393
9291
  ok: false,
@@ -8426,19 +9324,54 @@ function createGenerations(deps) {
8426
9324
  attempts: /* @__PURE__ */ new Map(),
8427
9325
  base,
8428
9326
  agent: agent === "codex" ? codex(await codexServers(deps.codexHome)) : CLAUDE,
8429
- pending: /* @__PURE__ */ new Set()
9327
+ guards: /* @__PURE__ */ new Map(),
9328
+ pending: /* @__PURE__ */ new Set(),
9329
+ record: null
8430
9330
  };
9331
+ const recording = deps.record?.() !== false;
9332
+ if (recording)
9333
+ await records.prune();
9334
+ const baseSet = base === null || !recording ? null : await setOfKey(deps.cwd, base.key).catch(() => null);
8431
9335
  if (busy())
8432
9336
  return { ok: false, error: BUSY };
8433
9337
  lives.push(live);
8434
9338
  while (lives.length > KEPT_JOBS)
8435
9339
  lives.shift();
8436
9340
  changed();
9341
+ if (recording) {
9342
+ live.record = {
9343
+ version: 1,
9344
+ id: job.id,
9345
+ surface: slug,
9346
+ brief,
9347
+ count,
9348
+ agent,
9349
+ args: argsOf(live.agent),
9350
+ basedOn: base === null ? null : { title: base.title, key: base.key, set: baseSet },
9351
+ state: job.state,
9352
+ error: null,
9353
+ startedAt: job.startedAt,
9354
+ plannedAt: null,
9355
+ endedAt: null,
9356
+ plan: null,
9357
+ directions: []
9358
+ };
9359
+ saveRecord(live);
9360
+ if (base !== null && baseSet !== null) {
9361
+ records.event(baseSet, {
9362
+ at: now(),
9363
+ kind: "more-like",
9364
+ direction: base.key,
9365
+ detail: job.id
9366
+ });
9367
+ }
9368
+ }
8437
9369
  detach(live, plan(live, existing), (message2) => {
8438
9370
  job.state = "failed";
8439
9371
  job.error = message2;
8440
9372
  job.endedAt = now();
8441
9373
  changed();
9374
+ saveRecord(live);
8442
9375
  });
8443
9376
  return { ok: true, job: copy(job) };
8444
9377
  },
@@ -8453,10 +9386,12 @@ function createGenerations(deps) {
8453
9386
  slot.state = "building";
8454
9387
  slot.startedAt = now();
8455
9388
  slot.failure = null;
9389
+ opened(live, slot, attempt, "retry");
8456
9390
  detach(live, restore(slot).then(() => build(live, slot, attempt)), (message2) => lost(live, slot, attempt, message2));
8457
9391
  live.job.state = "building";
8458
9392
  live.job.endedAt = null;
8459
9393
  changed();
9394
+ noted(live, "retry", slot.key);
8460
9395
  return true;
8461
9396
  },
8462
9397
  replace(id, key) {
@@ -8468,18 +9403,27 @@ function createGenerations(deps) {
8468
9403
  slot.state = "building";
8469
9404
  slot.startedAt = now();
8470
9405
  slot.failure = null;
9406
+ opened(live, slot, attempt, "replace");
8471
9407
  live.job.state = "building";
8472
9408
  live.job.endedAt = null;
8473
9409
  changed();
9410
+ noted(live, "replace", slot.key);
8474
9411
  const replacing = async () => {
8475
- const asking = run4(live.agent, live.agent.plan(replacePrompt2({
9412
+ const prompt = replacePrompt2({
8476
9413
  surface: live.job.surface,
8477
9414
  brief: live.job.brief,
8478
9415
  avoid: [...live.concepts.values()],
8479
9416
  base: live.base
8480
- })), PLAN_DEADLINE_MS);
9417
+ });
9418
+ const ideaRun = runRecord(prompt);
9419
+ const own = attemptRecord(live, slot.key, attempt);
9420
+ if (own !== void 0)
9421
+ own.ideaRun = ideaRun;
9422
+ const asking = run4(live.agent, live.agent.plan(prompt), PLAN_DEADLINE_MS);
8481
9423
  live.runs.set(slot.key, asking);
8482
9424
  const outcome = await asking.done;
9425
+ ended(live.agent, ideaRun, outcome);
9426
+ saveRecord(live);
8483
9427
  if (!owns(live, slot, attempt))
8484
9428
  return;
8485
9429
  if (live.runs.get(slot.key) === asking)
@@ -8496,6 +9440,7 @@ function createGenerations(deps) {
8496
9440
  });
8497
9441
  settle(live);
8498
9442
  changed();
9443
+ saveRecord(live);
8499
9444
  return;
8500
9445
  }
8501
9446
  const titles = new Set(await deps.titles());
@@ -8515,6 +9460,7 @@ function createGenerations(deps) {
8515
9460
  live.concepts.set(slot.key, { ...concept, title });
8516
9461
  slot.title = title;
8517
9462
  slot.idea = concept.idea;
9463
+ saveRecord(live);
8518
9464
  if (!owns(live, slot, attempt))
8519
9465
  return;
8520
9466
  await restore(slot);
@@ -8532,35 +9478,36 @@ function createGenerations(deps) {
8532
9478
  })),
8533
9479
  wait(KILL_GRACE_MS * 2 + 1e3)
8534
9480
  ]);
9481
+ await Promise.race([records.flush(), wait(KILL_GRACE_MS)]);
8535
9482
  }
8536
9483
  };
8537
9484
  }
8538
9485
 
8539
9486
  // ../server/dist/server-info.js
8540
- import { lstat as lstat2, mkdir as mkdir7, readFile as readFile15, rename as rename2, rm as rm4, writeFile as writeFile8 } from "fs/promises";
8541
- import { dirname as dirname9, join as join15 } from "path";
9487
+ import { lstat as lstat2, mkdir as mkdir8, readFile as readFile16, rename as rename4, rm as rm6, writeFile as writeFile9 } from "fs/promises";
9488
+ import { dirname as dirname9, join as join16 } from "path";
8542
9489
  var SERVER_INFO_PATH = ".leglas/server.json";
8543
9490
  async function writeServerInfo(cwd, info) {
8544
- const path = join15(cwd, SERVER_INFO_PATH);
9491
+ const path = join16(cwd, SERVER_INFO_PATH);
8545
9492
  const directory = dirname9(path);
8546
- await mkdir7(directory, { recursive: true });
9493
+ await mkdir8(directory, { recursive: true });
8547
9494
  if (!(await lstat2(directory)).isDirectory())
8548
9495
  return;
8549
9496
  const existing = await lstat2(path).catch(() => null);
8550
9497
  if (existing !== null && !existing.isFile())
8551
9498
  return;
8552
9499
  const temporary = `${path}.${process.pid}.tmp`;
8553
- await writeFile8(temporary, `${JSON.stringify({ ...info, startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)}
9500
+ await writeFile9(temporary, `${JSON.stringify({ ...info, startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)}
8554
9501
  `, "utf8");
8555
- await rename2(temporary, path).catch(async (cause) => {
8556
- await rm4(temporary, { force: true }).catch(() => {
9502
+ await rename4(temporary, path).catch(async (cause) => {
9503
+ await rm6(temporary, { force: true }).catch(() => {
8557
9504
  });
8558
9505
  throw cause;
8559
9506
  });
8560
9507
  }
8561
9508
  async function readServerInfo(cwd) {
8562
9509
  try {
8563
- const value = parseJson(await readFile15(join15(cwd, SERVER_INFO_PATH), "utf8"));
9510
+ const value = parseJson(await readFile16(join16(cwd, SERVER_INFO_PATH), "utf8"));
8564
9511
  if (!isJsonRecord(value) || !isNumber(value.port) || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535 || !isString(value.url) || value.url === "" || !isNumber(value.pid) || !Number.isInteger(value.pid))
8565
9512
  return null;
8566
9513
  return { port: value.port, url: value.url, pid: value.pid };
@@ -8574,7 +9521,7 @@ async function removeServerInfo(cwd, expected) {
8574
9521
  if (current !== null && (current.port !== expected.port || current.pid !== expected.pid))
8575
9522
  return;
8576
9523
  }
8577
- await rm4(join15(cwd, SERVER_INFO_PATH), { force: true });
9524
+ await rm6(join16(cwd, SERVER_INFO_PATH), { force: true });
8578
9525
  }
8579
9526
 
8580
9527
  // ../server/dist/server.js
@@ -8723,7 +9670,7 @@ function probe(target, timeoutMs = 1e3) {
8723
9670
  return resolve5(false);
8724
9671
  }
8725
9672
  const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
8726
- const socket = net3.connect(port, url.hostname);
9673
+ const socket = net3.connect(port, url.hostname.replace(/^\[|\]$/g, ""));
8727
9674
  const settle = (reachable) => {
8728
9675
  socket.destroy();
8729
9676
  resolve5(reachable);
@@ -8735,8 +9682,8 @@ function probe(target, timeoutMs = 1e3) {
8735
9682
  });
8736
9683
  }
8737
9684
  function serveFrom(res, dir, relativePath) {
8738
- const relative8 = normalize2(relativePath).replace(/^(\.\.[/\\])+/, "");
8739
- const candidate = join16(dir, relative8);
9685
+ const relative9 = normalize4(relativePath).replace(/^(\.\.[/\\])+/, "");
9686
+ const candidate = join17(dir, relative9);
8740
9687
  if (!candidate.startsWith(dir))
8741
9688
  return false;
8742
9689
  if (!existsSync3(candidate) || !statSync(candidate).isFile())
@@ -8750,9 +9697,9 @@ function serveFrom(res, dir, relativePath) {
8750
9697
  return true;
8751
9698
  }
8752
9699
  function serveShellFile(res, shellDir, urlPath) {
8753
- const relative8 = normalize2(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
8754
- const isRoot = relative8 === "" || relative8 === "." || relative8 === "/";
8755
- return serveFrom(res, shellDir, isRoot ? "index.html" : relative8);
9700
+ const relative9 = normalize4(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
9701
+ const isRoot = relative9 === "" || relative9 === "." || relative9 === "/";
9702
+ return serveFrom(res, shellDir, isRoot ? "index.html" : relative9);
8756
9703
  }
8757
9704
  var HEALTH_PROBE_MS = 3e3;
8758
9705
  function fileStamp(path) {
@@ -8764,11 +9711,11 @@ function fileStamp(path) {
8764
9711
  }
8765
9712
  }
8766
9713
  function watchLiveFiles(cwd, configPath, live) {
8767
- const leglasDir = join16(cwd, ".leglas");
9714
+ const leglasDir = join17(cwd, ".leglas");
8768
9715
  const targets = [
8769
- { path: join16(cwd, LOCAL_PREVIEWS_PATH), change: "config" },
8770
- { path: join16(cwd, REQUESTS_PATH), change: "requests" },
8771
- { path: join16(cwd, ANNOTATIONS_PATH), change: "requests" }
9716
+ { path: join17(cwd, LOCAL_PREVIEWS_PATH), change: "config" },
9717
+ { path: join17(cwd, REQUESTS_PATH), change: "requests" },
9718
+ { path: join17(cwd, ANNOTATIONS_PATH), change: "requests" }
8772
9719
  ];
8773
9720
  const byName = new Map(targets.map((target) => [basename3(target.path), target]));
8774
9721
  const known = new Map(targets.map((target) => [target.path, fileStamp(target.path)]));
@@ -9421,6 +10368,7 @@ async function startServer(options) {
9421
10368
  });
9422
10369
  }
9423
10370
  const deleted = await dropLocalPreviews(cwd, unique);
10371
+ await noteDirections(cwd, unique, "remove");
9424
10372
  return sendJson2(res, 200, { ok: true, deleted });
9425
10373
  } catch {
9426
10374
  return sendJson2(res, 500, {
@@ -9469,8 +10417,8 @@ async function startServer(options) {
9469
10417
  const id = newRequestId();
9470
10418
  const file = `${REFERENCES_DIR}/${id}.${image.kind}`;
9471
10419
  try {
9472
- await mkdir8(join16(cwd, REFERENCES_DIR), { recursive: true });
9473
- await writeFile9(join16(cwd, file), body);
10420
+ await mkdir9(join17(cwd, REFERENCES_DIR), { recursive: true });
10421
+ await writeFile10(join17(cwd, file), body);
9474
10422
  void pruneReferences(cwd).catch(() => {
9475
10423
  });
9476
10424
  return sendJson2(res, 200, {
@@ -9512,7 +10460,7 @@ async function startServer(options) {
9512
10460
  }
9513
10461
  const references = parsed.references ?? [];
9514
10462
  if (references.length > 0) {
9515
- const present = new Set((await readdir5(join16(cwd, REFERENCES_DIR)).catch(() => [])).map((name) => name.slice(0, name.indexOf(".") === -1 ? name.length : name.indexOf("."))));
10463
+ const present = new Set((await readdir6(join17(cwd, REFERENCES_DIR)).catch(() => [])).map((name) => name.slice(0, name.indexOf(".") === -1 ? name.length : name.indexOf("."))));
9516
10464
  const gone = references.filter((id2) => !present.has(id2));
9517
10465
  if (gone.length > 0) {
9518
10466
  return sendJson2(res, 410, {
@@ -9542,9 +10490,8 @@ async function startServer(options) {
9542
10490
  };
9543
10491
  const compare = isString(parsed.compare) && parsed.compare !== preview.title ? previews.find((entry) => entry.title === parsed.compare) ?? null : null;
9544
10492
  const sameContext = (entry) => (entry.compare ?? null) === (compare?.title ?? null) && [...entry.references ?? []].sort().join(",") === [...references].sort().join(",");
9545
- if (live2.some((entry) => entry.title === preview.title && entry.intent === intent && // The same words in the other mode are not the same request:
9546
- // one forks the direction and the other rewrites it. Only a
9547
- // genuine repeat is refused.
10493
+ if (live2.some((entry) => entry.title === preview.title && entry.intent === intent && // The same words in the other mode aren't the same request: one
10494
+ // forks, one rewrites.
9548
10495
  (entry.mode ?? "replace") === mode && sameNotes(entry) && sameContext(entry))) {
9549
10496
  return sendJson2(res, 409, {
9550
10497
  ok: false,
@@ -9569,9 +10516,8 @@ async function startServer(options) {
9569
10516
  title: preview.title,
9570
10517
  url: preview.url,
9571
10518
  intent,
9572
- // The ids travel with the request so a change made in place can
9573
- // forget the notes it answered. A fork leaves them where they are:
9574
- // the direction they point at was not touched.
10519
+ // The ids travel with the request so a change in place can forget
10520
+ // the notes it answered. A fork leaves them.
9575
10521
  ...composed
9576
10522
  };
9577
10523
  if (notes.length > 0)
@@ -9675,8 +10621,8 @@ async function startServer(options) {
9675
10621
  const noteSuffix = isString(parsed.note) ? `-${parsed.note.replace(/[^A-Za-z0-9_-]+/g, "-")}` : "";
9676
10622
  const name = `${captureSlug(preview.title)}-${result.frame.width}${noteSuffix}.png`;
9677
10623
  const relativeFile = `${CAPTURES_DIR}/show/${name}`;
9678
- await mkdir8(join16(cwd, CAPTURES_DIR, "show"), { recursive: true });
9679
- await writeFile9(join16(cwd, relativeFile), shot.png);
10624
+ await mkdir9(join17(cwd, CAPTURES_DIR, "show"), { recursive: true });
10625
+ await writeFile10(join17(cwd, relativeFile), shot.png);
9680
10626
  return sendJson2(res, 200, {
9681
10627
  ok: true,
9682
10628
  file: relativeFile,
@@ -9811,16 +10757,14 @@ async function startServer(options) {
9811
10757
  title,
9812
10758
  intent,
9813
10759
  // A fork leaves its parent's document alone; the interface keeps
9814
- // the parent's duplicate verdict on the strength of this.
10760
+ // the parent's duplicate verdict because of this.
9815
10761
  mode,
9816
- // Which pins this change speaks for. The interface marks them, so
9817
- // a note already sitting in a prompt an agent holds does not look
9818
- // like one nobody has read.
10762
+ // Which pins this change speaks for, so the interface can show a
10763
+ // note already in an agent's prompt as read.
9819
10764
  notes: notes ?? [],
9820
- // The run in flight is the one thing the file cannot know. After
9821
- // that the file is the record, including across a restart, and the
9822
- // process-local failed set only covers a request whose verdict
9823
- // could not be written.
10765
+ // The file can't know the run in flight. Otherwise it's the record,
10766
+ // across restarts; the in-memory failed set only covers a verdict
10767
+ // that couldn't be written.
9824
10768
  status: snapshot.running && snapshot.requestId === id ? "running" : status === "queued" && snapshot.failedIds.includes(id) ? "failed" : status,
9825
10769
  failure: failure ?? null
9826
10770
  })),
@@ -9830,13 +10774,13 @@ async function startServer(options) {
9830
10774
  name: snapshot.running ? snapshot.agent : null,
9831
10775
  activity: snapshot.running ? snapshot.activity : null,
9832
10776
  startedAt: snapshot.running ? snapshot.startedAt : null,
9833
- // A stop that has been asked for but not yet obeyed. The card
9834
- // says so rather than going on describing a live run.
10777
+ // A stop asked for but not yet obeyed, so the card stops describing
10778
+ // a live run.
9835
10779
  stopping: snapshot.running && snapshot.stopping,
9836
10780
  // Why a run that looks stalled is stalled, while it is stalled.
9837
10781
  waiting: snapshot.running ? snapshot.waiting : null,
9838
- // When a run that has gone quiet last said anything. A run on its
9839
- // way out is not quiet, it is stopping.
10782
+ // When a quiet run last said anything. A run on its way out is
10783
+ // stopping, not quiet.
9840
10784
  quietSince: snapshot.running && !snapshot.stopping ? snapshot.quietSince : null
9841
10785
  }
9842
10786
  }));
@@ -9893,12 +10837,11 @@ async function startServer(options) {
9893
10837
  url: request.url,
9894
10838
  intent: request.intent,
9895
10839
  target: request.target,
9896
- // The prompt names the captures by path, and the embedded pipes
9897
- // are not its only readers: watch, a custom command and
9898
- // `requests --json` all hand the text over as it stands.
10840
+ // The prompt names captures by path, and watch, a custom command
10841
+ // and `requests --json` hand the text over as is.
9899
10842
  prompt: attachments.length === 0 ? request.prompt : rehomeText(request.prompt, request.id, retryId)
9900
- // The stored prompt already carries the mode's instructions;
9901
- // its notes and visual context travel with the retry too.
10843
+ // The stored prompt already carries the mode's instructions; its
10844
+ // notes and visual context travel with the retry.
9902
10845
  };
9903
10846
  if (request.mode !== void 0)
9904
10847
  retry.mode = request.mode;
@@ -10056,15 +10999,15 @@ async function startServer(options) {
10056
10999
  const rest = path.slice(FILES_PREFIX.length + 1);
10057
11000
  const slash = rest.indexOf("/");
10058
11001
  const slug = slash === -1 ? rest : rest.slice(0, slash);
10059
- let relative8 = slash === -1 ? "" : rest.slice(slash + 1);
11002
+ let relative9 = slash === -1 ? "" : rest.slice(slash + 1);
10060
11003
  try {
10061
- relative8 = decodeURIComponent(relative8);
11004
+ relative9 = decodeURIComponent(relative9);
10062
11005
  } catch {
10063
- relative8 = "";
11006
+ relative9 = "";
10064
11007
  }
10065
11008
  const dir = fileMounts.get(slug);
10066
11009
  const serveMount = () => {
10067
- if (dir !== void 0 && relative8 !== "" && serveFrom(res, dir, relative8))
11010
+ if (dir !== void 0 && relative9 !== "" && serveFrom(res, dir, relative9))
10068
11011
  return;
10069
11012
  res.writeHead(404, {
10070
11013
  "content-type": "text/plain; charset=utf-8",
@@ -10074,7 +11017,7 @@ async function startServer(options) {
10074
11017
  };
10075
11018
  if (!context.remote)
10076
11019
  return serveMount();
10077
- if (relative8.split("/").some((segment) => segment.startsWith("."))) {
11020
+ if (relative9.split("/").some((segment) => segment.startsWith("."))) {
10078
11021
  return sendJson2(res, 403, { ok: false, error: "Not available to viewers." });
10079
11022
  }
10080
11023
  return void (shares?.fileSlugAllowed(slug, context.grantId ?? "") ?? Promise.resolve(false)).then((allowed) => {
@@ -10175,7 +11118,7 @@ async function startServer(options) {
10175
11118
  if (options.claudeAgentSession !== void 0)
10176
11119
  runnerOptions.claudeAgentSession = options.claudeAgentSession;
10177
11120
  runner = startRunner(runnerOptions);
10178
- const renderDirection = async (title) => {
11121
+ const renderDirection = async (title, { picture = false } = {}) => {
10179
11122
  const preview = (await livePreviews()).find((entry) => entry.title === title);
10180
11123
  if (preview === void 0)
10181
11124
  return null;
@@ -10184,15 +11127,29 @@ async function startServer(options) {
10184
11127
  return null;
10185
11128
  const address = server.address();
10186
11129
  const renderPort = address !== null && !isString(address) ? address.port : options.port ?? DEFAULT_PORT;
11130
+ const url = previewUrl(`http://127.0.0.1:${renderPort}`, preview);
11131
+ const settings = { width: 1440, timeoutMs: CAPTURE_LOAD_MS, inspect: true };
10187
11132
  try {
10188
11133
  const captured = await capturePage(browser, {
10189
- url: previewUrl(`http://127.0.0.1:${renderPort}`, preview),
10190
- width: 1440,
10191
- timeoutMs: CAPTURE_LOAD_MS
11134
+ ...settings,
11135
+ url,
11136
+ jpeg: picture
10192
11137
  });
10193
- return { errors: captured.errors };
11138
+ const layout = captured.layout.map(describeFinding);
11139
+ const frame = captured.jpeg;
11140
+ if (layout.length === 0)
11141
+ return { errors: captured.errors, layout, frame };
11142
+ const plain = new URL(url);
11143
+ plain.search = new URLSearchParams([...plain.searchParams].filter(([key]) => !key.startsWith("v-"))).toString();
11144
+ const before = await capturePage(browser, { ...settings, url: plain.href }).catch(() => null);
11145
+ const known = new Set(before?.layout.map(describeFinding) ?? []);
11146
+ return {
11147
+ errors: captured.errors,
11148
+ layout: layout.filter((line) => !known.has(line)),
11149
+ frame
11150
+ };
10194
11151
  } catch (error) {
10195
- return { errors: [error instanceof Error ? error.message : String(error)] };
11152
+ return { errors: [error instanceof Error ? error.message : String(error)], layout: [] };
10196
11153
  }
10197
11154
  };
10198
11155
  const generationDeps = {
@@ -10206,7 +11163,8 @@ async function startServer(options) {
10206
11163
  const local = await readLocalPreviews(cwd).catch(() => null);
10207
11164
  return new Set([...config?.previews ?? [], ...local?.previews ?? []].map((entry) => entry.title));
10208
11165
  },
10209
- onChange: () => live.nudge("generation")
11166
+ onChange: () => live.nudge("generation"),
11167
+ record: () => config?.recordSets !== false
10210
11168
  };
10211
11169
  if (options.generationSpawn !== void 0)
10212
11170
  generationDeps.spawn = options.generationSpawn;
@@ -10249,10 +11207,10 @@ async function startServer(options) {
10249
11207
 
10250
11208
  // ../server/dist/update.js
10251
11209
  import { spawn as spawnChild2 } from "child_process";
10252
- import { existsSync as existsSync4, readFileSync, realpathSync } from "fs";
10253
- import { lstat as lstat3, mkdir as mkdir9, mkdtemp, rename as rename3, rm as rm5, writeFile as writeFile10 } from "fs/promises";
11210
+ import { existsSync as existsSync4, readFileSync, realpathSync as realpathSync2 } from "fs";
11211
+ import { lstat as lstat3, mkdir as mkdir10, mkdtemp, rename as rename5, rm as rm7, writeFile as writeFile11 } from "fs/promises";
10254
11212
  import { homedir as homedir3 } from "os";
10255
- import { dirname as dirname11, join as join17, posix as posix2, win32 } from "path";
11213
+ import { dirname as dirname11, join as join18, posix as posix2, win32 } from "path";
10256
11214
  var DAY_MS = 24 * 60 * 60 * 1e3;
10257
11215
  var INSTALL_DEADLINE_MS = 5 * 60 * 1e3;
10258
11216
 
@@ -10285,7 +11243,7 @@ function planKeep(options) {
10285
11243
  error: `Cannot tell which file produces ${JSON.stringify(options.title)}. Its URL (${winner.url}) was not generated by leglas new, so there is nothing safe to move.`
10286
11244
  };
10287
11245
  }
10288
- const to = normalize3(options.to);
11246
+ const to = normalize5(options.to);
10289
11247
  if (to.startsWith("..") || isAbsolute2(to)) {
10290
11248
  return { ok: false, error: "The destination has to be inside the project." };
10291
11249
  }
@@ -10311,11 +11269,11 @@ function planKeep(options) {
10311
11269
  }
10312
11270
 
10313
11271
  // src/run-init.ts
10314
- import { readFile as readFile16, writeFile as writeFile11 } from "fs/promises";
10315
- import { join as join18 } from "path";
11272
+ import { readFile as readFile17, writeFile as writeFile12 } from "fs/promises";
11273
+ import { join as join19 } from "path";
10316
11274
  async function readIfPresent(path) {
10317
11275
  try {
10318
- return await readFile16(path, "utf8");
11276
+ return await readFile17(path, "utf8");
10319
11277
  } catch {
10320
11278
  return null;
10321
11279
  }
@@ -10323,18 +11281,18 @@ async function readIfPresent(path) {
10323
11281
  async function runInit(options, deps) {
10324
11282
  const existingConfig = findConfigFile(options.cwd);
10325
11283
  const plan = planInit({
10326
- agents: await readIfPresent(join18(options.cwd, "AGENTS.md")),
11284
+ agents: await readIfPresent(join19(options.cwd, "AGENTS.md")),
10327
11285
  config: existingConfig === null ? null : "present",
10328
- gitignore: await readIfPresent(join18(options.cwd, ".gitignore")),
11286
+ gitignore: await readIfPresent(join19(options.cwd, ".gitignore")),
10329
11287
  force: options.force
10330
11288
  });
10331
11289
  const touched = [];
10332
11290
  for (const write2 of plan.writes) {
10333
- await writeFile11(join18(options.cwd, write2.path), write2.contents, "utf8");
11291
+ await writeFile12(join19(options.cwd, write2.path), write2.contents, "utf8");
10334
11292
  touched.push(write2.path);
10335
11293
  }
10336
11294
  if (plan.gitignore !== null) {
10337
- await writeFile11(join18(options.cwd, ".gitignore"), plan.gitignore, "utf8");
11295
+ await writeFile12(join19(options.cwd, ".gitignore"), plan.gitignore, "utf8");
10338
11296
  touched.push(".gitignore");
10339
11297
  }
10340
11298
  if (options.json) {
@@ -10354,8 +11312,8 @@ async function runInit(options, deps) {
10354
11312
 
10355
11313
  // src/run-keep.ts
10356
11314
  import { existsSync as existsSync5 } from "fs";
10357
- import { copyFile as copyFile2, mkdir as mkdir10, readFile as readFile17, rm as rm6, writeFile as writeFile12 } from "fs/promises";
10358
- import { dirname as dirname12, isAbsolute as isAbsolute3, join as join19, relative as relative5 } from "path";
11315
+ import { copyFile as copyFile2, mkdir as mkdir11, readFile as readFile18, rm as rm8, writeFile as writeFile13 } from "fs/promises";
11316
+ import { dirname as dirname12, isAbsolute as isAbsolute3, join as join20, relative as relative5 } from "path";
10359
11317
 
10360
11318
  // src/resolve-title.ts
10361
11319
  function resolveOrExplain(input, titles, renames) {
@@ -10388,15 +11346,15 @@ async function writeLogEntry(options) {
10388
11346
  annotations: await readAnnotations(options.cwd),
10389
11347
  date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
10390
11348
  });
10391
- const dir = join19(options.cwd, options.logDir);
10392
- await mkdir10(dir, { recursive: true });
10393
- const file = join19(dir, `${entry.slug}.md`);
10394
- await writeFile12(file, entry.markdown, "utf8");
11349
+ const dir = join20(options.cwd, options.logDir);
11350
+ await mkdir11(dir, { recursive: true });
11351
+ const file = join20(dir, `${entry.slug}.md`);
11352
+ await writeFile13(file, entry.markdown, "utf8");
10395
11353
  if (entry.pictures.length > 0) {
10396
- const pictureDir = join19(dir, entry.slug);
10397
- await mkdir10(pictureDir, { recursive: true });
11354
+ const pictureDir = join20(dir, entry.slug);
11355
+ await mkdir11(pictureDir, { recursive: true });
10398
11356
  for (const picture of entry.pictures) {
10399
- await copyFile2(join19(options.cwd, picture.from), join19(pictureDir, picture.to)).catch(() => {
11357
+ await copyFile2(join20(options.cwd, picture.from), join20(pictureDir, picture.to)).catch(() => {
10400
11358
  });
10401
11359
  }
10402
11360
  }
@@ -10420,17 +11378,17 @@ async function runKeep(options, deps) {
10420
11378
  const destination = isAbsolute3(options.to) ? relative5(options.cwd, options.to) : options.to;
10421
11379
  const plan = planKeep({ title: resolved.title, previews, to: destination });
10422
11380
  if (!plan.ok) return fail(plan.error);
10423
- const from = join19(options.cwd, plan.move.from);
10424
- const to = join19(options.cwd, plan.move.to);
11381
+ const from = join20(options.cwd, plan.move.from);
11382
+ const to = join20(options.cwd, plan.move.to);
10425
11383
  if (!existsSync5(from)) {
10426
11384
  return fail(`${plan.move.from} does not exist. Nothing to keep.`);
10427
11385
  }
10428
11386
  if (existsSync5(to)) {
10429
11387
  return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
10430
11388
  }
10431
- const source = await readFile17(from, "utf8");
10432
- await mkdir10(dirname12(to), { recursive: true });
10433
- await writeFile12(to, renameExport(source, plan.exportName), "utf8");
11389
+ const source = await readFile18(from, "utf8");
11390
+ await mkdir11(dirname12(to), { recursive: true });
11391
+ await writeFile13(to, renameExport(source, plan.exportName), "utf8");
10434
11392
  const surface = plan.removeDir.slice(plan.removeDir.lastIndexOf("/") + 1);
10435
11393
  let logged = null;
10436
11394
  let logError = null;
@@ -10445,8 +11403,9 @@ async function runKeep(options, deps) {
10445
11403
  } catch (error) {
10446
11404
  logError = error instanceof Error ? error.message : String(error);
10447
11405
  }
10448
- await rm6(join19(options.cwd, plan.removeDir), { recursive: true, force: true });
11406
+ await rm8(join20(options.cwd, plan.removeDir), { recursive: true, force: true });
10449
11407
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
11408
+ await noteDirections(options.cwd, [resolved.title], "keep", plan.move.to);
10450
11409
  if (options.json) {
10451
11410
  deps.log(
10452
11411
  JSON.stringify({
@@ -10484,11 +11443,11 @@ async function runKeep(options, deps) {
10484
11443
 
10485
11444
  // src/run-new.ts
10486
11445
  import { existsSync as existsSync6 } from "fs";
10487
- import { mkdir as mkdir11, readFile as readFile18, writeFile as writeFile13 } from "fs/promises";
10488
- import { dirname as dirname13, join as join20 } from "path";
11446
+ import { mkdir as mkdir12, readFile as readFile19, writeFile as writeFile14 } from "fs/promises";
11447
+ import { dirname as dirname13, join as join21 } from "path";
10489
11448
  async function readIfPresent2(path) {
10490
11449
  try {
10491
- return await readFile18(path, "utf8");
11450
+ return await readFile19(path, "utf8");
10492
11451
  } catch {
10493
11452
  return null;
10494
11453
  }
@@ -10496,7 +11455,7 @@ async function readIfPresent2(path) {
10496
11455
  async function runNew(options, deps) {
10497
11456
  let from;
10498
11457
  if (options.from !== void 0) {
10499
- const contents = await readIfPresent2(join20(options.cwd, options.from));
11458
+ const contents = await readIfPresent2(join21(options.cwd, options.from));
10500
11459
  if (contents === null) {
10501
11460
  const message2 = `${options.from} does not exist, so there is nothing to use as the baseline.`;
10502
11461
  if (options.json) deps.log(JSON.stringify({ ok: false, error: message2 }));
@@ -10507,8 +11466,8 @@ async function runNew(options, deps) {
10507
11466
  }
10508
11467
  const plan = planNew({
10509
11468
  surface: options.surface,
10510
- packageJson: await readIfPresent2(join20(options.cwd, "package.json")),
10511
- gitignore: await readIfPresent2(join20(options.cwd, ".gitignore")),
11469
+ packageJson: await readIfPresent2(join21(options.cwd, "package.json")),
11470
+ gitignore: await readIfPresent2(join21(options.cwd, ".gitignore")),
10512
11471
  from
10513
11472
  });
10514
11473
  const fail = (error) => {
@@ -10538,7 +11497,7 @@ async function runNew(options, deps) {
10538
11497
  deps.log(plan.instructions);
10539
11498
  return { exitCode: 0, written: [] };
10540
11499
  }
10541
- const existing = plan.writes.filter((write2) => existsSync6(join20(options.cwd, write2.path)));
11500
+ const existing = plan.writes.filter((write2) => existsSync6(join21(options.cwd, write2.path)));
10542
11501
  if (existing.length > 0) {
10543
11502
  return fail(
10544
11503
  `${existing[0]?.path} already exists. Delete it first, or pick another surface name.`
@@ -10546,13 +11505,13 @@ async function runNew(options, deps) {
10546
11505
  }
10547
11506
  const written = [];
10548
11507
  for (const write2 of plan.writes) {
10549
- const target = join20(options.cwd, write2.path);
10550
- await mkdir11(dirname13(target), { recursive: true });
10551
- await writeFile13(target, write2.contents, "utf8");
11508
+ const target = join21(options.cwd, write2.path);
11509
+ await mkdir12(dirname13(target), { recursive: true });
11510
+ await writeFile14(target, write2.contents, "utf8");
10552
11511
  written.push(write2.path);
10553
11512
  }
10554
11513
  if (plan.gitignore !== null) {
10555
- await writeFile13(join20(options.cwd, ".gitignore"), plan.gitignore, "utf8");
11514
+ await writeFile14(join21(options.cwd, ".gitignore"), plan.gitignore, "utf8");
10556
11515
  written.push(".gitignore");
10557
11516
  }
10558
11517
  if (options.json) {
@@ -10580,21 +11539,107 @@ async function runNew(options, deps) {
10580
11539
  }
10581
11540
 
10582
11541
  // src/run-previews.ts
10583
- import { readFile as readFile19, writeFile as writeFile14 } from "fs/promises";
10584
- import { join as join21 } from "path";
11542
+ import { readFile as readFile20, writeFile as writeFile15 } from "fs/promises";
11543
+ import { join as join22 } from "path";
11544
+
11545
+ // src/running.ts
11546
+ import { realpath as realpath2 } from "fs/promises";
11547
+
11548
+ // src/json.ts
11549
+ function isJsonObject(value) {
11550
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11551
+ }
11552
+ function isString2(value) {
11553
+ return typeof value === "string";
11554
+ }
11555
+ function isNumber2(value) {
11556
+ return typeof value === "number";
11557
+ }
11558
+ async function bodyOf(response) {
11559
+ try {
11560
+ return JSON.parse(await response.text());
11561
+ } catch {
11562
+ return null;
11563
+ }
11564
+ }
11565
+
11566
+ // src/running.ts
11567
+ var NOT_RUNNING = "Leglas is not running here. Start it with npx leglas, then try again.";
11568
+ async function sameDirectory(left, right) {
11569
+ const [a, b] = await Promise.all([
11570
+ realpath2(left).catch(() => left),
11571
+ realpath2(right).catch(() => right)
11572
+ ]);
11573
+ return a === b;
11574
+ }
11575
+ async function findLeglas(cwd, port, request) {
11576
+ const server = port === null ? await readServerInfo(cwd) : null;
11577
+ const chosen = port ?? server?.port ?? DEFAULT_PORT;
11578
+ try {
11579
+ const health = await request(`http://127.0.0.1:${chosen}/leglas/api/health`, {
11580
+ signal: AbortSignal.timeout(2e3)
11581
+ });
11582
+ if (health.status !== 200) return { ok: false, error: NOT_RUNNING };
11583
+ const answered = await health.text().then((text3) => JSON.parse(text3)).catch(() => ({}));
11584
+ if (answered === null) return { ok: false, error: NOT_RUNNING };
11585
+ if (isJsonObject(answered) && isString2(answered.cwd) && !await sameDirectory(answered.cwd, cwd)) {
11586
+ return {
11587
+ ok: false,
11588
+ error: `The Leglas on port ${chosen} serves another project. Start one here with npx leglas, or name the right one with --port.`
11589
+ };
11590
+ }
11591
+ return { ok: true, port: chosen, base: `http://127.0.0.1:${chosen}` };
11592
+ } catch {
11593
+ return { ok: false, error: NOT_RUNNING };
11594
+ }
11595
+ }
11596
+ function interfaceUrl(port, titles) {
11597
+ const url = new URL(`http://localhost:${port}${LEGLAS_PREFIX}`);
11598
+ const [direction, compare] = titles;
11599
+ if (direction !== void 0) url.searchParams.set("direction", direction);
11600
+ if (compare !== void 0) url.searchParams.set("compare", compare);
11601
+ return url.href;
11602
+ }
11603
+ async function railTitles(port, request) {
11604
+ try {
11605
+ const response = await request(`http://127.0.0.1:${port}${LEGLAS_PREFIX}/api/config`, {
11606
+ signal: AbortSignal.timeout(2e3)
11607
+ });
11608
+ if (!response.ok) return null;
11609
+ const payload = await bodyOf(response);
11610
+ if (!isJsonObject(payload) || !Array.isArray(payload.previews)) return null;
11611
+ return new Set(
11612
+ payload.previews.flatMap(
11613
+ (preview) => isJsonObject(preview) && isString2(preview.title) ? [preview.title] : []
11614
+ )
11615
+ );
11616
+ } catch {
11617
+ return null;
11618
+ }
11619
+ }
11620
+ async function recordedRail(cwd, request) {
11621
+ const record3 = await readServerInfo(cwd);
11622
+ if (record3 === null) return null;
11623
+ const found = await findLeglas(cwd, record3.port, request);
11624
+ if (!found.ok) return null;
11625
+ const titles = await railTitles(found.port, request);
11626
+ return titles === null ? null : { port: found.port, titles };
11627
+ }
11628
+
11629
+ // src/run-previews.ts
10585
11630
  function envelope(deps, ok, body) {
10586
11631
  deps.log(JSON.stringify({ ok, ...body }));
10587
11632
  }
10588
11633
  async function ensureIgnored(cwd) {
10589
- const path = join21(cwd, ".gitignore");
11634
+ const path = join22(cwd, ".gitignore");
10590
11635
  let current = null;
10591
11636
  try {
10592
- current = await readFile19(path, "utf8");
11637
+ current = await readFile20(path, "utf8");
10593
11638
  } catch {
10594
11639
  current = null;
10595
11640
  }
10596
11641
  const next = ignoreEntry(current);
10597
- if (next !== null) await writeFile14(path, next, "utf8");
11642
+ if (next !== null) await writeFile15(path, next, "utf8");
10598
11643
  }
10599
11644
  async function runAdd(options, deps) {
10600
11645
  const loaded = await loadConfig(options.cwd);
@@ -10630,6 +11675,8 @@ async function runAdd(options, deps) {
10630
11675
  }
10631
11676
  await ensureIgnored(options.cwd);
10632
11677
  const needsDevCommand = options.preview.branch !== void 0 && loaded.config?.devCommand === void 0;
11678
+ const rail = await recordedRail(options.cwd, deps.fetch ?? fetch);
11679
+ const opens = rail !== null && rail.titles.has(options.preview.title) ? interfaceUrl(rail.port, [options.preview.title]) : null;
10633
11680
  if (options.json) {
10634
11681
  const added = { added: options.preview.title };
10635
11682
  if (options.preview.url !== void 0) added.url = options.preview.url;
@@ -10640,11 +11687,13 @@ async function runAdd(options, deps) {
10640
11687
  if (needsDevCommand) {
10641
11688
  added.warning = "The config sets no devCommand, so Leglas cannot start this branch yet. Add devCommand (with {port}) to the config.";
10642
11689
  }
11690
+ if (opens !== null) added.interfaceUrl = opens;
10643
11691
  envelope(deps, true, added);
10644
11692
  } else {
10645
11693
  deps.log(
10646
11694
  ` added ${options.preview.title} ${options.preview.url ?? options.preview.file ?? ""}`
10647
11695
  );
11696
+ if (opens !== null) deps.log(` open ${opens}`);
10648
11697
  deps.log("");
10649
11698
  if (needsDevCommand) {
10650
11699
  deps.log(" ! The config sets no devCommand, so Leglas cannot start this branch yet.");
@@ -10668,12 +11717,10 @@ async function runList(options, deps) {
10668
11717
  ...local.previews
10669
11718
  ];
10670
11719
  if (options.json) {
11720
+ const rail = await recordedRail(options.cwd, deps.fetch ?? fetch);
10671
11721
  envelope(deps, errors.length === 0, {
10672
- // The whole record, not a summary of it. What the config holds about a
10673
- // preview — its note, its tags, the direction it is a variant of — is
10674
- // exactly what tells an agent why these are being compared, and leaving
10675
- // it out made the listing thinner than the reference block that points
10676
- // at it.
11722
+ // The whole record: a preview's note, tags and base direction are what
11723
+ // tell an agent why these are being compared.
10677
11724
  previews: previews.map((preview) => ({
10678
11725
  title: preview.title,
10679
11726
  url: preview.url,
@@ -10683,7 +11730,8 @@ async function runList(options, deps) {
10683
11730
  askedFor: preview.askedFor ?? null,
10684
11731
  local: preview.local,
10685
11732
  branch: preview.branch ?? null,
10686
- file: preview.file ?? null
11733
+ file: preview.file ?? null,
11734
+ interfaceUrl: rail !== null && rail.titles.has(preview.title) ? interfaceUrl(rail.port, [preview.title]) : null
10687
11735
  })),
10688
11736
  errors
10689
11737
  });
@@ -10746,9 +11794,9 @@ function describe(preview) {
10746
11794
  branch: preview.branch ?? null,
10747
11795
  file: preview.file ?? null,
10748
11796
  local: preview.local === true,
10749
- // A file preview names its own source. Everything else is decoded from the
11797
+ // A file preview names its own source; anything else is decoded from the
10750
11798
  // URL, and a URL outside the convention yields nothing rather than a path
10751
- // that looks authoritative and is not there.
11799
+ // that isn't there.
10752
11800
  target: preview.file ?? targetFor(preview.url)
10753
11801
  };
10754
11802
  }
@@ -10766,8 +11814,7 @@ function planShow({ title, previews, requests }) {
10766
11814
  ok: true,
10767
11815
  direction: describe(found),
10768
11816
  variants,
10769
- // Its own variants are already listed in full, so they are not repeated
10770
- // here; this is the rest of the comparison.
11817
+ // Its variants are listed above, so this is the rest of the comparison.
10771
11818
  comparedWith: previews.map((preview) => preview.title).filter((other) => other !== title && !variantTitles.has(other)),
10772
11819
  requests: requests.filter((request) => request.title === title).map((request) => ({
10773
11820
  id: request.id,
@@ -10779,58 +11826,6 @@ function planShow({ title, previews, requests }) {
10779
11826
  };
10780
11827
  }
10781
11828
 
10782
- // src/running.ts
10783
- import { realpath as realpath2 } from "fs/promises";
10784
-
10785
- // src/json.ts
10786
- function isJsonObject(value) {
10787
- return typeof value === "object" && value !== null && !Array.isArray(value);
10788
- }
10789
- function isString2(value) {
10790
- return typeof value === "string";
10791
- }
10792
- function isNumber2(value) {
10793
- return typeof value === "number";
10794
- }
10795
- async function bodyOf(response) {
10796
- try {
10797
- return JSON.parse(await response.text());
10798
- } catch {
10799
- return null;
10800
- }
10801
- }
10802
-
10803
- // src/running.ts
10804
- var NOT_RUNNING = "Leglas is not running here. Start it with npx leglas, then try again.";
10805
- async function sameDirectory(left, right) {
10806
- const [a, b] = await Promise.all([
10807
- realpath2(left).catch(() => left),
10808
- realpath2(right).catch(() => right)
10809
- ]);
10810
- return a === b;
10811
- }
10812
- async function findLeglas(cwd, port, request) {
10813
- const server = port === null ? await readServerInfo(cwd) : null;
10814
- const chosen = port ?? server?.port ?? DEFAULT_PORT;
10815
- try {
10816
- const health = await request(`http://127.0.0.1:${chosen}/leglas/api/health`, {
10817
- signal: AbortSignal.timeout(2e3)
10818
- });
10819
- if (health.status !== 200) return { ok: false, error: NOT_RUNNING };
10820
- const answered = await health.text().then((text3) => JSON.parse(text3)).catch(() => ({}));
10821
- if (answered === null) return { ok: false, error: NOT_RUNNING };
10822
- if (isJsonObject(answered) && isString2(answered.cwd) && !await sameDirectory(answered.cwd, cwd)) {
10823
- return {
10824
- ok: false,
10825
- error: `The Leglas on port ${chosen} serves another project. Start one here with npx leglas, or name the right one with --port.`
10826
- };
10827
- }
10828
- return { ok: true, port: chosen, base: `http://127.0.0.1:${chosen}` };
10829
- } catch {
10830
- return { ok: false, error: NOT_RUNNING };
10831
- }
10832
- }
10833
-
10834
11829
  // src/run-show.ts
10835
11830
  function isString3(value) {
10836
11831
  return typeof value === "string";
@@ -10871,21 +11866,23 @@ async function runShow(options, deps) {
10871
11866
  }
10872
11867
  const envelope2 = {
10873
11868
  ok: true,
10874
- direction: plan.direction,
11869
+ direction: { ...plan.direction, interfaceUrl: null },
10875
11870
  variants: plan.variants,
10876
11871
  comparedWith: plan.comparedWith,
10877
11872
  requests: plan.requests
10878
11873
  };
11874
+ const request = deps.fetch ?? fetch;
11875
+ let running = null;
10879
11876
  if (options.screenshot) {
10880
11877
  const fail = (error) => {
10881
11878
  if (options.json) deps.log(JSON.stringify({ ok: false, error }));
10882
11879
  else deps.error(error);
10883
11880
  return { exitCode: 1 };
10884
11881
  };
10885
- const request = deps.fetch ?? fetch;
10886
11882
  const found = await findLeglas(options.cwd, options.port, request);
10887
11883
  if (!found.ok) return fail(found.error);
10888
11884
  const port = found.port;
11885
+ running = port;
10889
11886
  let response;
10890
11887
  try {
10891
11888
  response = await request(`http://127.0.0.1:${port}/leglas/api/capture`, {
@@ -10919,17 +11916,22 @@ async function runShow(options, deps) {
10919
11916
  cut: captured.cut === true
10920
11917
  };
10921
11918
  }
11919
+ const rail = running === null ? await recordedRail(options.cwd, request) : { port: running, titles: await railTitles(running, request) ?? /* @__PURE__ */ new Set() };
11920
+ if (rail !== null && rail.titles.has(plan.direction.title)) {
11921
+ envelope2.direction.interfaceUrl = interfaceUrl(rail.port, [plan.direction.title]);
11922
+ }
10922
11923
  if (options.json) {
10923
11924
  deps.log(JSON.stringify(envelope2));
10924
11925
  return { exitCode: 0 };
10925
11926
  }
10926
- const { direction } = plan;
11927
+ const { direction } = envelope2;
10927
11928
  deps.log(` ${direction.title}${direction.local ? " (local)" : ""}`);
10928
11929
  if (direction.note !== null) deps.log(` ${direction.note}`);
10929
11930
  deps.log("");
10930
11931
  if (direction.target !== null) deps.log(` file ${direction.target}`);
10931
11932
  if (direction.branch !== null) deps.log(` branch ${direction.branch}`);
10932
11933
  deps.log(` url ${direction.url}`);
11934
+ if (direction.interfaceUrl !== null) deps.log(` open ${direction.interfaceUrl}`);
10933
11935
  if (direction.tags.length > 0) deps.log(` tags ${direction.tags.join(", ")}`);
10934
11936
  if (direction.basedOn !== null) deps.log(` variant of ${direction.basedOn}`);
10935
11937
  if (plan.variants.length > 0) {
@@ -10958,7 +11960,7 @@ async function runShow(options, deps) {
10958
11960
  if (plan.requests.length > 0) {
10959
11961
  deps.log("");
10960
11962
  deps.log(` Pending, not yet done (${plan.requests.length}):`);
10961
- for (const request of plan.requests) deps.log(` ${request.status} ${request.intent}`);
11963
+ for (const request2 of plan.requests) deps.log(` ${request2.status} ${request2.intent}`);
10962
11964
  deps.log("");
10963
11965
  deps.log(" Run npx leglas requests --json for the full prompts.");
10964
11966
  }
@@ -10992,8 +11994,11 @@ function tunnelFrom(value) {
10992
11994
  }
10993
11995
  }
10994
11996
  function linkFrom(value) {
10995
- if (!isJsonObject(value) || !isString2(value.localUrl) || !isNumber2(value.expiresAt)) return null;
11997
+ if (!isJsonObject(value) || !isString2(value.id) || !isString2(value.localUrl) || !isNumber2(value.expiresAt)) {
11998
+ return null;
11999
+ }
10996
12000
  return {
12001
+ id: value.id,
10997
12002
  name: isString2(value.name) ? value.name : "",
10998
12003
  url: isString2(value.url) ? value.url : null,
10999
12004
  localUrl: value.localUrl,
@@ -11114,11 +12119,12 @@ async function runShare(options, deps) {
11114
12119
  const running = await readShare(found.base, request);
11115
12120
  if (!running.ok) return fail(running.error);
11116
12121
  const { installed } = running.value;
11117
- const report = (share2, already, leftOut2) => {
12122
+ const report = (share2, already, leftOut2, done = null) => {
11118
12123
  if (options.json) {
11119
12124
  deps.log(
11120
12125
  JSON.stringify({
11121
12126
  ok: true,
12127
+ ...done?.json,
11122
12128
  alreadySharing: already,
11123
12129
  share: {
11124
12130
  scope: share2.scope,
@@ -11132,6 +12138,7 @@ async function runShare(options, deps) {
11132
12138
  );
11133
12139
  return { exitCode: 0 };
11134
12140
  }
12141
+ if (done !== null) deps.log(done.line);
11135
12142
  deps.log(` sharing ${describe2(share2)}${already ? " (already running)" : ""}`);
11136
12143
  if (share2.reach === "listed") {
11137
12144
  deps.log(" reach only the paths the share lists; allow more from the share panel");
@@ -11141,6 +12148,9 @@ async function runShare(options, deps) {
11141
12148
  if (link.url !== null) deps.log(` link ${label}${link.url}`);
11142
12149
  else deps.log(` local ${label}${link.localUrl}`);
11143
12150
  }
12151
+ if (share2.links.length === 0) {
12152
+ deps.log(" links no link is live; npx leglas share --rotate starts one");
12153
+ }
11144
12154
  const tunnel = tunnelLine(share2.tunnel, installed);
11145
12155
  if (tunnel !== null) deps.log(` tunnel ${tunnel}`);
11146
12156
  const ends = share2.links.map((link) => link.expiresAt);
@@ -11151,6 +12161,66 @@ async function runShare(options, deps) {
11151
12161
  deps.log(" stop npx leglas share --stop");
11152
12162
  return { exitCode: 0 };
11153
12163
  };
12164
+ const settle = async (share2) => {
12165
+ let settled2 = share2;
12166
+ for (let waited = 0; settled2.tunnel.status === "starting" && waited < TUNNEL_WAIT_MS; waited += TUNNEL_POLL_MS) {
12167
+ await sleep(TUNNEL_POLL_MS);
12168
+ const read = await readShare(found.base, request);
12169
+ if (!read.ok) return read;
12170
+ if (read.value.share === null) return { ok: true, value: null };
12171
+ settled2 = read.value.share;
12172
+ }
12173
+ return { ok: true, value: settled2 };
12174
+ };
12175
+ const revoke = options.revoke ?? null;
12176
+ if (options.rotate === true || revoke !== null) {
12177
+ const current2 = running.value.share;
12178
+ if (current2 === null) {
12179
+ return fail("Nothing is being shared. Start a share with npx leglas share.");
12180
+ }
12181
+ if (revoke !== null) {
12182
+ const named = revoke;
12183
+ const link = current2.links.find(
12184
+ (candidate) => candidate.id === named || candidate.url === named || candidate.localUrl === named
12185
+ );
12186
+ if (link === void 0) {
12187
+ return fail(`No live link is ${JSON.stringify(named)}. npx leglas share lists them.`);
12188
+ }
12189
+ const revoked = await post(
12190
+ found.base,
12191
+ "/leglas/api/share/grants/revoke",
12192
+ { id: link.id },
12193
+ request,
12194
+ "Leglas could not end that link."
12195
+ );
12196
+ if (!revoked.ok) return fail(revoked.error);
12197
+ const after = isJsonObject(revoked.value) ? shareFrom(revoked.value.share) : null;
12198
+ if (after === null) return fail("Leglas ended the link but did not say what is left.");
12199
+ return report(after, true, [], {
12200
+ json: { revoked: link },
12201
+ line: ` revoked ${link.name === "" ? "" : `${link.name} `}${link.url ?? link.localUrl}`
12202
+ });
12203
+ }
12204
+ const rotated = await post(
12205
+ found.base,
12206
+ "/leglas/api/share/rotate",
12207
+ {},
12208
+ request,
12209
+ "Leglas could not replace the links."
12210
+ );
12211
+ if (!rotated.ok) return fail(rotated.error);
12212
+ const replaced = isJsonObject(rotated.value) ? shareFrom(rotated.value.share) : null;
12213
+ if (replaced === null) return fail("Leglas replaced the links but did not say with what.");
12214
+ const settled2 = await settle(replaced);
12215
+ if (!settled2.ok) return fail(settled2.error);
12216
+ if (settled2.value === null) {
12217
+ return fail("The share was stopped while its new tunnel was starting.");
12218
+ }
12219
+ return report(settled2.value, true, [], {
12220
+ json: { rotated: true },
12221
+ line: " rotated every earlier link has ended"
12222
+ });
12223
+ }
11154
12224
  const loaded = await loadConfig(options.cwd);
11155
12225
  const local = await readLocalPreviews(options.cwd);
11156
12226
  const renames = await readRenames(options.cwd);
@@ -11219,20 +12289,102 @@ async function runShare(options, deps) {
11219
12289
  if (!created.ok) {
11220
12290
  return created.answered ? fail(created.error) : giveUp("Leglas stopped answering while the share was starting.", false);
11221
12291
  }
11222
- let share = isJsonObject(created.value) ? shareFrom(created.value.share) : null;
12292
+ const share = isJsonObject(created.value) ? shareFrom(created.value.share) : null;
11223
12293
  if (share === null) {
11224
12294
  return giveUp("Leglas started a share this version of the command cannot read.");
11225
12295
  }
11226
- for (let waited = 0; share.tunnel.status === "starting" && waited < TUNNEL_WAIT_MS; waited += TUNNEL_POLL_MS) {
11227
- await sleep(TUNNEL_POLL_MS);
11228
- const read = await readShare(found.base, request);
11229
- if (!read.ok) return giveUp(read.error);
11230
- if (read.value.share === null) {
11231
- return fail("The share was stopped while its tunnel was starting.");
11232
- }
11233
- share = read.value.share;
12296
+ const settled = await settle(share);
12297
+ if (!settled.ok) return giveUp(settled.error);
12298
+ if (settled.value === null) {
12299
+ return fail("The share was stopped while its tunnel was starting.");
12300
+ }
12301
+ return report(settled.value, false, leftOut);
12302
+ }
12303
+
12304
+ // src/run-link.ts
12305
+ async function runLink(options, deps) {
12306
+ const fail = (error) => {
12307
+ if (options.json) deps.log(JSON.stringify({ ok: false, error }));
12308
+ else deps.error(error);
12309
+ return { exitCode: 1 };
12310
+ };
12311
+ const loaded = await loadConfig(options.cwd);
12312
+ const local = await readLocalPreviews(options.cwd);
12313
+ const renames = await readRenames(options.cwd);
12314
+ const known = [
12315
+ ...new Set(
12316
+ [...loaded.config?.previews ?? [], ...local.previews].map((preview) => preview.title)
12317
+ )
12318
+ ];
12319
+ const titles = [];
12320
+ for (const asked of options.titles) {
12321
+ const resolved = resolveOrExplain(asked, known, renames);
12322
+ if (!resolved.ok) return fail(resolved.error);
12323
+ titles.push(resolved.title);
12324
+ }
12325
+ const [first, second] = titles;
12326
+ if (first !== void 0 && first === second) {
12327
+ return fail(`Both names are ${first}. Name two different directions to put side by side.`);
12328
+ }
12329
+ const request = deps.fetch ?? fetch;
12330
+ const found = await findLeglas(options.cwd, options.port, request);
12331
+ if (!found.ok) return fail(found.error);
12332
+ const rail = await railTitles(found.port, request);
12333
+ if (rail === null) return fail("Leglas did not say which directions are on its rail.");
12334
+ const absent = titles.find((title) => !rail.has(title));
12335
+ if (absent !== void 0) {
12336
+ return fail(
12337
+ `${absent} isn't on the running rail yet. A branch or file direction joins it when Leglas restarts.`
12338
+ );
12339
+ }
12340
+ const url = interfaceUrl(found.port, titles);
12341
+ if (options.json) deps.log(JSON.stringify({ ok: true, url }));
12342
+ else deps.log(url);
12343
+ return { exitCode: 0 };
12344
+ }
12345
+
12346
+ // src/run-remove.ts
12347
+ import { relative as relative6 } from "path";
12348
+ async function runRemove(options, deps) {
12349
+ const fail = (error) => {
12350
+ if (options.json) deps.log(JSON.stringify({ ok: false, error }));
12351
+ else deps.error(error);
12352
+ return { exitCode: 1 };
12353
+ };
12354
+ const loaded = await loadConfig(options.cwd);
12355
+ const local = await readLocalPreviews(options.cwd);
12356
+ if (local.errors.length > 0) {
12357
+ return fail(`${LOCAL_PREVIEWS_PATH} has entries Leglas can't read: ${local.errors.join(" ")}`);
11234
12358
  }
11235
- return report(share, false, leftOut);
12359
+ const known = [
12360
+ ...new Set(
12361
+ [...loaded.config?.previews ?? [], ...local.previews].map((preview) => preview.title)
12362
+ )
12363
+ ];
12364
+ const renames = await readRenames(options.cwd);
12365
+ const titles = [];
12366
+ for (const asked of options.titles) {
12367
+ const resolved = resolveOrExplain(asked, known, renames);
12368
+ if (!resolved.ok) return fail(resolved.error);
12369
+ if (!titles.includes(resolved.title)) titles.push(resolved.title);
12370
+ }
12371
+ const registered = new Set(local.previews.map((preview) => preview.title));
12372
+ const listed2 = new Set((loaded.config?.previews ?? []).map((preview) => preview.title));
12373
+ const shared = titles.find((title) => listed2.has(title) || !registered.has(title));
12374
+ if (shared !== void 0) {
12375
+ const where = loaded.path === null ? "the config" : relative6(options.cwd, loaded.path);
12376
+ return fail(`${shared} is in ${where}, which the project shares. Remove it there.`);
12377
+ }
12378
+ await dropLocalPreviews(options.cwd, titles);
12379
+ await noteDirections(options.cwd, titles, "remove");
12380
+ if (options.json) {
12381
+ deps.log(JSON.stringify({ ok: true, removed: titles }));
12382
+ } else {
12383
+ for (const title of titles) deps.log(` removed ${title}`);
12384
+ deps.log("");
12385
+ deps.log(`The files behind ${titles.length === 1 ? "it" : "them"} stay where they are.`);
12386
+ }
12387
+ return { exitCode: 0 };
11236
12388
  }
11237
12389
 
11238
12390
  // src/shutdown.ts
@@ -11249,21 +12401,21 @@ function installShutdown(stop, target = process) {
11249
12401
 
11250
12402
  // src/run-watch.ts
11251
12403
  import { spawn as spawn4 } from "child_process";
11252
- import { mkdir as mkdir12, readFile as readFile20, writeFile as writeFile15 } from "fs/promises";
11253
- import { dirname as dirname14, join as join22 } from "path";
12404
+ import { mkdir as mkdir13, readFile as readFile21, writeFile as writeFile16 } from "fs/promises";
12405
+ import { dirname as dirname14, join as join23 } from "path";
11254
12406
  var POLL_MS2 = 2e3;
11255
12407
  var HEARTBEAT_TIMEOUT_MS = 1e3;
11256
12408
  async function saveTemplate(cwd, run4) {
11257
- const path = join22(cwd, WATCH_PATH);
12409
+ const path = join23(cwd, WATCH_PATH);
11258
12410
  let config = {};
11259
12411
  try {
11260
- const parsed = JSON.parse(await readFile20(path, "utf8"));
12412
+ const parsed = JSON.parse(await readFile21(path, "utf8"));
11261
12413
  if (isJsonObject(parsed)) config = parsed;
11262
12414
  } catch {
11263
12415
  }
11264
12416
  config.run = run4;
11265
- await mkdir12(dirname14(path), { recursive: true });
11266
- await writeFile15(path, `${JSON.stringify(config, null, 2)}
12417
+ await mkdir13(dirname14(path), { recursive: true });
12418
+ await writeFile16(path, `${JSON.stringify(config, null, 2)}
11267
12419
  `, "utf8");
11268
12420
  }
11269
12421
  function spawnAgent(command, args, cwd, json) {
@@ -11427,12 +12579,12 @@ async function runWatch(options, deps) {
11427
12579
 
11428
12580
  // src/run-classify.ts
11429
12581
  import { stat as stat5 } from "fs/promises";
11430
- import { join as join23 } from "path";
12582
+ import { join as join24 } from "path";
11431
12583
  async function runClassify(options, deps) {
11432
12584
  const declared = await Promise.all(
11433
12585
  options.changes.map(async (change) => ({
11434
12586
  ...change,
11435
- exists: await stat5(join23(options.cwd, change.path)).then(
12587
+ exists: await stat5(join24(options.cwd, change.path)).then(
11436
12588
  () => true,
11437
12589
  () => false
11438
12590
  )
@@ -11461,13 +12613,13 @@ async function runClassify(options, deps) {
11461
12613
  import { existsSync as existsSync7 } from "fs";
11462
12614
  import { realpath as realpath4 } from "fs/promises";
11463
12615
  import { createRequire } from "module";
11464
- import { basename as basename6, dirname as dirname15, join as join24, relative as relative7, resolve as resolve4 } from "path";
12616
+ import { basename as basename6, dirname as dirname15, join as join25, relative as relative8, resolve as resolve4 } from "path";
11465
12617
  import { fileURLToPath } from "url";
11466
12618
 
11467
12619
  // src/dev-server-owner.ts
11468
12620
  import { execFile as execFile2 } from "child_process";
11469
12621
  import { realpath as realpath3 } from "fs/promises";
11470
- import { basename as basename5, isAbsolute as isAbsolute4, relative as relative6, resolve as resolve3 } from "path";
12622
+ import { basename as basename5, isAbsolute as isAbsolute4, relative as relative7, resolve as resolve3 } from "path";
11471
12623
  import { promisify as promisify2 } from "util";
11472
12624
  var run2 = promisify2(execFile2);
11473
12625
  var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
@@ -11536,7 +12688,7 @@ async function inspectLocalDevServer(origin) {
11536
12688
  }
11537
12689
  }
11538
12690
  function isInside(parent, child) {
11539
- const path = relative6(resolve3(parent), resolve3(child));
12691
+ const path = relative7(resolve3(parent), resolve3(child));
11540
12692
  return path === "" || !path.startsWith("..") && !isAbsolute4(path);
11541
12693
  }
11542
12694
  function devServerOwnerWarning(origin, projectRoot, owners) {
@@ -11550,8 +12702,8 @@ function devServerOwnerWarning(origin, projectRoot, owners) {
11550
12702
 
11551
12703
  // src/run.ts
11552
12704
  function findShellDir() {
11553
- const bundled = join24(dirname15(fileURLToPath(import.meta.url)), "shell");
11554
- if (existsSync7(join24(bundled, "index.html"))) return bundled;
12705
+ const bundled = join25(dirname15(fileURLToPath(import.meta.url)), "shell");
12706
+ if (existsSync7(join25(bundled, "index.html"))) return bundled;
11555
12707
  try {
11556
12708
  const require2 = createRequire(import.meta.url);
11557
12709
  return dirname15(require2.resolve("@leglas/shell/dist/index.html"));
@@ -11565,7 +12717,7 @@ function shellWord(value) {
11565
12717
  return `'${value.replaceAll("'", `'\\''`)}'`;
11566
12718
  }
11567
12719
  function embeddedLeglasCommand() {
11568
- const entry = join24(dirname15(fileURLToPath(import.meta.url)), "bin.js");
12720
+ const entry = join25(dirname15(fileURLToPath(import.meta.url)), "bin.js");
11569
12721
  if (!existsSync7(entry)) return "npx -y leglas";
11570
12722
  return [process.execPath, entry].map(shellWord).join(" ");
11571
12723
  }
@@ -11604,7 +12756,7 @@ async function runWithServices(options, deps, services = {}) {
11604
12756
  const fileMounts = /* @__PURE__ */ new Map();
11605
12757
  for (const preview of merged?.previews ?? []) {
11606
12758
  if (preview.file !== void 0) {
11607
- const absolute = join24(options.cwd, preview.file);
12759
+ const absolute = join25(options.cwd, preview.file);
11608
12760
  if (!existsSync7(absolute)) {
11609
12761
  previewErrors.push(
11610
12762
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -11636,8 +12788,8 @@ async function runWithServices(options, deps, services = {}) {
11636
12788
  configWarnings,
11637
12789
  fileMounts,
11638
12790
  shellDir: findShellDir(),
11639
- // The config file identifies the project when there is one; otherwise the
11640
- // directory does. Either way saved layout survives a port change.
12791
+ // The config file identifies the project, else the directory, so saved
12792
+ // layout survives a port change.
11641
12793
  project: loaded.path ?? options.cwd,
11642
12794
  cwd: options.cwd,
11643
12795
  leglasCommand: embeddedLeglasCommand()
@@ -11666,7 +12818,7 @@ async function runWithServices(options, deps, services = {}) {
11666
12818
  })
11667
12819
  );
11668
12820
  } else {
11669
- const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative7(options.cwd, loaded.path) || loaded.path;
12821
+ const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative8(options.cwd, loaded.path) || loaded.path;
11670
12822
  deps.log(`Leglas ${url}`);
11671
12823
  deps.log(
11672
12824
  `app ${devServer}${app !== null ? " (started by Leglas)" : health.reachable ? "" : " (not reachable)"}`
@@ -11722,17 +12874,24 @@ export {
11722
12874
  AGENTS_MARKER_END,
11723
12875
  AGENTS_MARKER_START,
11724
12876
  CAPTURES_DIR,
12877
+ DEFAULT_EXPLORE_COUNT,
11725
12878
  DEFAULT_PORT,
12879
+ DEFAULT_SHARE_REACH,
11726
12880
  LEGLAS_PREFIX,
12881
+ MAX_PORT,
12882
+ MAX_SHOW_WIDTH,
12883
+ MIN_SHOW_WIDTH,
11727
12884
  NO_BROWSER,
11728
12885
  PROMPT_TOKEN,
11729
12886
  REFERENCES_DIR,
11730
12887
  SERVER_INFO_PATH,
11731
12888
  WATCH_PATH,
12889
+ addRefusal,
11732
12890
  baselineFrom,
11733
12891
  commandFor,
11734
12892
  detectFramework,
11735
12893
  findBrowser,
12894
+ fromRefusal,
11736
12895
  installShutdown,
11737
12896
  isOwnCapture,
11738
12897
  nextRequest,
@@ -11751,11 +12910,15 @@ export {
11751
12910
  runExplore,
11752
12911
  runInit,
11753
12912
  runKeep,
12913
+ runLink,
11754
12914
  runList,
11755
12915
  runNew,
12916
+ runRemove,
11756
12917
  runRequests,
11757
12918
  runShare,
11758
12919
  runShow,
11759
12920
  runWatch,
12921
+ shareRefusal,
12922
+ showRefusal,
11760
12923
  surfaceSlug
11761
12924
  };