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/bin.js CHANGED
@@ -5,20 +5,85 @@ import { spawn as spawn6 } from "child_process";
5
5
  import { createRequire as createRequire2 } from "module";
6
6
  import { fileURLToPath as fileURLToPath2 } from "url";
7
7
 
8
+ // src/rules.ts
9
+ var MIN_PORT = 1;
10
+ var MAX_PORT = 65535;
11
+ var MIN_SHOW_WIDTH = 320;
12
+ var MAX_SHOW_WIDTH = 3840;
13
+ var DEFAULT_EXPLORE_COUNT = 3;
14
+ var DEFAULT_SHARE_REACH = "open";
15
+ function portRefusal(flag, port) {
16
+ return port < MIN_PORT || port > MAX_PORT ? `${flag} must be between ${MIN_PORT} and ${MAX_PORT}, received ${port}.` : null;
17
+ }
18
+ function valueRefusal(flag) {
19
+ return `${flag} needs a value.`;
20
+ }
21
+ function addRefusal(preview) {
22
+ const texts = [
23
+ ["--title", preview.title],
24
+ ["--url", preview.url],
25
+ ["--note", preview.note],
26
+ ["--branch", preview.branch],
27
+ ["--file", preview.file],
28
+ ["--based-on", preview.basedOn],
29
+ ["--asked-for", preview.askedFor],
30
+ ...(preview.tags ?? []).map((tag) => ["--tag", tag])
31
+ ];
32
+ const empty = texts.find(([, value]) => value === "");
33
+ if (empty !== void 0) return valueRefusal(empty[0]);
34
+ if (preview.url === void 0 && preview.file === void 0) {
35
+ return "leglas add needs --url (for example --url '/?v-hero=aurora') or --file for a page Leglas serves itself.";
36
+ }
37
+ return null;
38
+ }
39
+ function shareRefusal(options) {
40
+ const acts = [
41
+ options.stop && "--stop ends the share",
42
+ options.rotate === true && "--rotate replaces every link",
43
+ options.revoke !== void 0 && options.revoke !== null && "--revoke ends one link"
44
+ ].filter((act2) => act2 !== false);
45
+ if (acts.length > 1) {
46
+ return "leglas share takes one of --stop, --rotate or --revoke at a time.";
47
+ }
48
+ const [act] = acts;
49
+ if (act !== void 0 && (options.titles.length > 0 || options.reach !== void 0 || options.tunnel !== null)) {
50
+ return `leglas share ${act}; it takes no directions, reach or tunnel.`;
51
+ }
52
+ if (options.titles.length > 2) {
53
+ return "leglas share takes one direction to share alone, or two to compare. Name none to share the rail.";
54
+ }
55
+ return null;
56
+ }
57
+ function linkRefusal(titles) {
58
+ return titles.length > 2 ? "leglas link takes one direction, or two to put side by side. Name none for the rail." : null;
59
+ }
60
+ function widthRefusal(width) {
61
+ return width < MIN_SHOW_WIDTH || width > MAX_SHOW_WIDTH ? `--width must be between ${MIN_SHOW_WIDTH} and ${MAX_SHOW_WIDTH}, received ${width}.` : null;
62
+ }
63
+ function showRefusal(options) {
64
+ if (options.width !== null && !options.screenshot) {
65
+ return "leglas show --width needs --screenshot.";
66
+ }
67
+ if (options.port !== null && !options.screenshot) {
68
+ return "leglas show --port needs --screenshot.";
69
+ }
70
+ return null;
71
+ }
72
+ function fromRefusal(from) {
73
+ return from === "" ? "--from needs a path, for example --from src/Hero.tsx" : null;
74
+ }
75
+
8
76
  // src/args.ts
9
77
  var SHARE_TUNNELS = ["cloudflared", "ngrok", "none"];
10
78
  var VALUE_FLAGS = /* @__PURE__ */ new Set(["--port", "--user-port", "--config"]);
11
79
  var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["--no-open", "--json"]);
12
- var MIN_SHOW_WIDTH = 320;
13
- var MAX_SHOW_WIDTH = 3840;
14
80
  function parsePort(flag, raw) {
15
81
  if (!/^\d+$/.test(raw)) {
16
82
  return { error: `${flag} needs a number, received ${JSON.stringify(raw)}.` };
17
83
  }
18
84
  const port = Number(raw);
19
- if (port < 1 || port > 65535) {
20
- return { error: `${flag} must be between 1 and 65535, received ${port}.` };
21
- }
85
+ const refusal = portRefusal(flag, port);
86
+ if (refusal !== null) return { error: refusal };
22
87
  return { port };
23
88
  }
24
89
  function parseNew(rest) {
@@ -30,9 +95,8 @@ function parseNew(rest) {
30
95
  const argument = rest[index];
31
96
  if (argument === "--from" || argument.startsWith("--from=")) {
32
97
  from = argument.includes("=") ? argument.split("=").slice(1).join("=") : rest[index += 1];
33
- if (from === void 0 || from === "") {
34
- return { kind: "error", message: "--from needs a path, for example --from src/Hero.tsx" };
35
- }
98
+ const refusal = fromRefusal(from ?? "");
99
+ if (refusal !== null) return { kind: "error", message: refusal };
36
100
  continue;
37
101
  }
38
102
  if (argument === "--print") {
@@ -100,7 +164,7 @@ function parseAdd(rest) {
100
164
  return { kind: "error", message: `leglas add does not take ${flag}.` };
101
165
  }
102
166
  if (value === void 0 || value === "") {
103
- return { kind: "error", message: `${flag} needs a value.` };
167
+ return { kind: "error", message: valueRefusal(flag) };
104
168
  }
105
169
  if (flag === "--title") title = value;
106
170
  else if (flag === "--url") url = value;
@@ -117,26 +181,19 @@ function parseAdd(rest) {
117
181
  message: "leglas add needs --title, which is how the preview is identified."
118
182
  };
119
183
  }
120
- if (url === void 0 && file === void 0) {
121
- return {
122
- kind: "error",
123
- message: "leglas add needs --url (for example --url '/?v-hero=aurora') or --file for a page Leglas serves itself."
124
- };
125
- }
126
- return {
127
- kind: "add",
128
- preview: {
129
- title,
130
- url,
131
- note,
132
- tags: tags.length > 0 ? tags : void 0,
133
- branch,
134
- file,
135
- basedOn,
136
- askedFor
137
- },
138
- json
184
+ const preview = {
185
+ title,
186
+ url,
187
+ note,
188
+ tags: tags.length > 0 ? tags : void 0,
189
+ branch,
190
+ file,
191
+ basedOn,
192
+ askedFor
139
193
  };
194
+ const refusal = addRefusal(preview);
195
+ if (refusal !== null) return { kind: "error", message: refusal };
196
+ return { kind: "add", preview, json };
140
197
  }
141
198
  function parseClassify(rest) {
142
199
  const changes = [];
@@ -185,7 +242,7 @@ function parseWatch(rest) {
185
242
  if (value === void 0 || value === "") {
186
243
  return {
187
244
  kind: "error",
188
- message: flag === "--run" ? '--run needs an agent command, for example --run "claude -p {prompt}"' : "--port needs a value."
245
+ message: flag === "--run" ? '--run needs an agent command, for example --run "claude -p {prompt}"' : valueRefusal(flag)
189
246
  };
190
247
  }
191
248
  if (flag === "--run") {
@@ -200,10 +257,12 @@ function parseWatch(rest) {
200
257
  }
201
258
  function parseShare(rest) {
202
259
  const titles = [];
203
- let reach = "open";
260
+ let reach = DEFAULT_SHARE_REACH;
204
261
  let reachGiven = false;
205
262
  let tunnel = null;
206
263
  let stop = false;
264
+ let rotate = false;
265
+ let revoke = null;
207
266
  let port = null;
208
267
  let json = false;
209
268
  for (let index = 0; index < rest.length; index += 1) {
@@ -216,12 +275,22 @@ function parseShare(rest) {
216
275
  stop = true;
217
276
  continue;
218
277
  }
278
+ if (argument === "--rotate") {
279
+ rotate = true;
280
+ continue;
281
+ }
219
282
  const equals = argument.indexOf("=");
220
283
  const flag = equals === -1 ? argument : argument.slice(0, equals);
284
+ if (flag === "--revoke") {
285
+ const raw = equals === -1 ? rest[index += 1] : argument.slice(equals + 1);
286
+ if (raw === void 0 || raw === "") return { kind: "error", message: valueRefusal(flag) };
287
+ revoke = raw;
288
+ continue;
289
+ }
221
290
  if (flag === "--reach" || flag === "--tunnel" || flag === "--port") {
222
291
  const raw = equals === -1 ? rest[index += 1] : argument.slice(equals + 1);
223
292
  if (raw === void 0 || raw === "") {
224
- return { kind: "error", message: `${flag} needs a value.` };
293
+ return { kind: "error", message: valueRefusal(flag) };
225
294
  }
226
295
  if (flag === "--port") {
227
296
  const parsed2 = parsePort(flag, raw);
@@ -255,19 +324,45 @@ function parseShare(rest) {
255
324
  }
256
325
  titles.push(argument);
257
326
  }
258
- if (stop && (titles.length > 0 || reachGiven || tunnel !== null)) {
259
- return {
260
- kind: "error",
261
- message: "leglas share --stop ends the share; it takes no directions, reach or tunnel."
262
- };
263
- }
264
- if (titles.length > 2) {
265
- return {
266
- kind: "error",
267
- message: "leglas share takes one direction to share alone, or two to compare. Name none to share the rail."
268
- };
327
+ const refusal = shareRefusal({
328
+ titles,
329
+ reach: reachGiven ? reach : void 0,
330
+ tunnel,
331
+ stop,
332
+ rotate,
333
+ revoke
334
+ });
335
+ if (refusal !== null) return { kind: "error", message: refusal };
336
+ return { kind: "share", titles, reach, tunnel, stop, rotate, revoke, port, json };
337
+ }
338
+ function parseLink(rest) {
339
+ const titles = [];
340
+ let port = null;
341
+ let json = false;
342
+ for (let index = 0; index < rest.length; index += 1) {
343
+ const argument = rest[index];
344
+ if (argument === "--json") {
345
+ json = true;
346
+ continue;
347
+ }
348
+ const equals = argument.indexOf("=");
349
+ const flag = equals === -1 ? argument : argument.slice(0, equals);
350
+ if (flag === "--port") {
351
+ const raw = equals === -1 ? rest[index += 1] : argument.slice(equals + 1);
352
+ if (raw === void 0 || raw === "") return { kind: "error", message: valueRefusal(flag) };
353
+ const parsed2 = parsePort(flag, raw);
354
+ if ("error" in parsed2) return { kind: "error", message: parsed2.error };
355
+ port = parsed2.port;
356
+ continue;
357
+ }
358
+ if (argument.startsWith("-")) {
359
+ return { kind: "error", message: `leglas link does not take ${argument}.` };
360
+ }
361
+ titles.push(argument);
269
362
  }
270
- return { kind: "share", titles, reach, tunnel, stop, port, json };
363
+ const refusal = linkRefusal(titles);
364
+ if (refusal !== null) return { kind: "error", message: refusal };
365
+ return { kind: "link", titles, port, json };
271
366
  }
272
367
  function parseArgs(argv) {
273
368
  const [command] = argv;
@@ -332,7 +427,7 @@ function parseArgs(argv) {
332
427
  if (argv[0] === "explore") {
333
428
  const rest = argv.slice(1);
334
429
  let surface;
335
- let count = 3;
430
+ let count = DEFAULT_EXPLORE_COUNT;
336
431
  let basedOn = null;
337
432
  let json = false;
338
433
  let build = false;
@@ -465,7 +560,7 @@ function parseArgs(argv) {
465
560
  const flag = equals === -1 ? argument : argument.slice(0, equals);
466
561
  const raw = equals === -1 ? rest[index += 1] : argument.slice(equals + 1);
467
562
  if (raw === void 0 || raw === "") {
468
- return { kind: "error", message: `${flag} needs a value.` };
563
+ return { kind: "error", message: valueRefusal(flag) };
469
564
  }
470
565
  if (flag === "--port") {
471
566
  const parsed2 = parsePort(flag, raw);
@@ -480,12 +575,8 @@ function parseArgs(argv) {
480
575
  };
481
576
  }
482
577
  width = Number(raw);
483
- if (width < MIN_SHOW_WIDTH || width > MAX_SHOW_WIDTH) {
484
- return {
485
- kind: "error",
486
- message: `--width must be between ${MIN_SHOW_WIDTH} and ${MAX_SHOW_WIDTH}, received ${width}.`
487
- };
488
- }
578
+ const refusal2 = widthRefusal(width);
579
+ if (refusal2 !== null) return { kind: "error", message: refusal2 };
489
580
  continue;
490
581
  }
491
582
  if (argument.startsWith("-")) {
@@ -502,15 +593,27 @@ function parseArgs(argv) {
502
593
  message: 'leglas show needs a direction title, for example: npx leglas show "Aurora" --json'
503
594
  };
504
595
  }
505
- if (width !== null && !screenshot) {
506
- return { kind: "error", message: "leglas show --width needs --screenshot." };
507
- }
508
- if (port !== null && !screenshot) {
509
- return { kind: "error", message: "leglas show --port needs --screenshot." };
510
- }
596
+ const refusal = showRefusal({ screenshot, width, port });
597
+ if (refusal !== null) return { kind: "error", message: refusal };
511
598
  return { kind: "show", title, json, screenshot, width, port };
512
599
  }
513
600
  if (argv[0] === "share") return parseShare(argv.slice(1));
601
+ if (argv[0] === "link") return parseLink(argv.slice(1));
602
+ if (argv[0] === "remove") {
603
+ const rest = argv.slice(1);
604
+ const unknown = rest.find((argument) => argument.startsWith("-") && argument !== "--json");
605
+ if (unknown !== void 0) {
606
+ return { kind: "error", message: `leglas remove does not take ${unknown}.` };
607
+ }
608
+ const titles = rest.filter((argument) => argument !== "--json");
609
+ if (titles.length === 0) {
610
+ return {
611
+ kind: "error",
612
+ message: 'leglas remove needs a direction title, for example: npx leglas remove "Aurora"'
613
+ };
614
+ }
615
+ return { kind: "remove", titles, json: rest.includes("--json") };
616
+ }
514
617
  const options = {
515
618
  port: void 0,
516
619
  userPort: void 0,
@@ -543,7 +646,7 @@ function parseArgs(argv) {
543
646
  value = argument.slice(equals + 1);
544
647
  }
545
648
  if (value === void 0 || value === "" || value.startsWith("--")) {
546
- return { kind: "error", message: `${flag} needs a value.` };
649
+ return { kind: "error", message: valueRefusal(flag) };
547
650
  }
548
651
  if (flag === "--config") {
549
652
  options.configPath = value;
@@ -559,7 +662,7 @@ function parseArgs(argv) {
559
662
 
560
663
  // src/run-classify.ts
561
664
  import { stat as stat5 } from "fs/promises";
562
- import { join as join18 } from "path";
665
+ import { join as join19 } from "path";
563
666
 
564
667
  // ../server/dist/log.js
565
668
  var DEFAULT_LOG_DIR = "design-log";
@@ -788,13 +891,18 @@ function normalizeConfig(raw, options = {}) {
788
891
  if (!isBoolean(scanPreviews)) {
789
892
  errors.push("scanPreviews must be a boolean.");
790
893
  }
791
- if (errors.length > 0 || !isString(devServer) || !isBoolean(scanPreviews) || !isString(installCommand) || !isString(logDir))
894
+ const recordSets = source["recordSets"] ?? true;
895
+ if (!isBoolean(recordSets)) {
896
+ errors.push("recordSets must be a boolean.");
897
+ }
898
+ if (errors.length > 0 || !isString(devServer) || !isBoolean(scanPreviews) || !isBoolean(recordSets) || !isString(installCommand) || !isString(logDir))
792
899
  return { config: null, errors };
793
900
  return {
794
901
  config: {
795
902
  devServer,
796
903
  previews,
797
904
  scanPreviews,
905
+ recordSets,
798
906
  devCommand: isString(devCommand) ? devCommand : void 0,
799
907
  installCommand,
800
908
  logDir
@@ -887,9 +995,9 @@ function nextRequest(requests, failed) {
887
995
 
888
996
  // ../server/dist/agents/agents.js
889
997
  import { spawn } from "child_process";
890
- import { constants, readdirSync } from "fs";
998
+ import { constants, readdirSync, realpathSync } from "fs";
891
999
  import { access, mkdir, readFile, writeFile } from "fs/promises";
892
- import { delimiter, dirname, isAbsolute, join, relative } from "path";
1000
+ import { delimiter, dirname, isAbsolute, join, normalize, relative } from "path";
893
1001
  var AGENT_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
894
1002
  var effortFlag = (effort) => effort === null ? [] : ["--effort", effort];
895
1003
  var codexEffortConfig = (effort) => effort === null ? [] : ["-c", `model_reasoning_effort=${effort}`];
@@ -1040,13 +1148,15 @@ var KNOWN_AGENTS = {
1040
1148
  // the earlier turn.
1041
1149
  activityVerified: true,
1042
1150
  authArgs: ["status"],
1043
- // The signed-in form was read from the CLI: "✓ Logged in as <email>",
1044
- // exit 0. The signed-out form has not been, so that side of the reading
1045
- // stays loose, and anything ambiguous stays unknown.
1151
+ // Both answers read from cursor-agent 2026.09.02, and both exit 0: "✓ Logged
1152
+ // in as <email>" and "Not logged in". The second contains the first's
1153
+ // words, so a "not logged in" or "not signed in" is read first.
1046
1154
  authVerdict: (result) => {
1155
+ if (/not (logged|signed) in/i.test(result.stdout))
1156
+ return "signed-out";
1047
1157
  if (/logged in|signed in/i.test(result.stdout))
1048
1158
  return "ok";
1049
- if (result.code !== 0 || /not logged in|log in|sign in/i.test(result.stdout))
1159
+ if (result.code !== 0 || /log in|sign in/i.test(result.stdout))
1050
1160
  return "signed-out";
1051
1161
  return "unknown";
1052
1162
  }
@@ -1266,6 +1376,45 @@ function cursorActivity(event, cwd) {
1266
1376
  const path = shownPath(args?.path, cwd);
1267
1377
  return path === null ? `using ${tool}` : `using ${tool} on ${path}`;
1268
1378
  }
1379
+ function editedFiles(agent, line, cwd) {
1380
+ let event;
1381
+ try {
1382
+ event = record(parseJson(line));
1383
+ } catch {
1384
+ return [];
1385
+ }
1386
+ const paths = [];
1387
+ if (agent === "claude" && event?.type === "assistant") {
1388
+ const content = record(event.message)?.content;
1389
+ for (const rawBlock of Array.isArray(content) ? content : []) {
1390
+ const block = record(rawBlock);
1391
+ const input = record(block?.input);
1392
+ if (block?.type === "tool_use" && isString(block.name) && ["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(block.name))
1393
+ paths.push(input?.file_path ?? input?.notebook_path ?? null);
1394
+ }
1395
+ }
1396
+ if (agent === "codex" && (event?.type === "item.started" || event?.type === "item.completed") && record(event.item)?.type === "file_change") {
1397
+ const item = record(event.item);
1398
+ for (const change of Array.isArray(item?.changes) ? item.changes : [])
1399
+ paths.push(record(change)?.path ?? null);
1400
+ paths.push(item?.path ?? null);
1401
+ }
1402
+ if (paths.length === 0)
1403
+ return [];
1404
+ let root = cwd;
1405
+ try {
1406
+ root = realpathSync(cwd);
1407
+ } catch {
1408
+ }
1409
+ return paths.flatMap((path) => {
1410
+ if (!isString(path) || path === "")
1411
+ return [];
1412
+ if (!isAbsolute(path))
1413
+ return [normalize(path)];
1414
+ const shown = [relative(cwd, path), relative(root, path)].find((form) => !form.startsWith(".."));
1415
+ return [normalize(shown ?? path)];
1416
+ });
1417
+ }
1269
1418
  function activityFrom(agent, line, cwd = process.cwd()) {
1270
1419
  let event;
1271
1420
  try {
@@ -1491,9 +1640,21 @@ async function loadConfig(cwd) {
1491
1640
  }
1492
1641
 
1493
1642
  // ../server/dist/config/local-previews.js
1494
- import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1643
+ import { randomUUID } from "crypto";
1644
+ import { mkdir as mkdir2, readFile as readFile3, rename, rm, writeFile as writeFile2 } from "fs/promises";
1495
1645
  import { dirname as dirname3, join as join3 } from "path";
1496
1646
  var LOCAL_PREVIEWS_PATH = ".leglas/previews.json";
1647
+ async function writeRegistry(path, stored) {
1648
+ await mkdir2(dirname3(path), { recursive: true });
1649
+ const temporary = `${path}.${randomUUID()}.tmp`;
1650
+ await writeFile2(temporary, `${JSON.stringify({ previews: stored }, null, 2)}
1651
+ `, "utf8");
1652
+ await rename(temporary, path).catch(async (cause) => {
1653
+ await rm(temporary, { force: true }).catch(() => {
1654
+ });
1655
+ throw cause;
1656
+ });
1657
+ }
1497
1658
  async function readLocalPreviews(cwd) {
1498
1659
  const path = join3(cwd, LOCAL_PREVIEWS_PATH);
1499
1660
  let raw;
@@ -1565,10 +1726,10 @@ async function addLocalPreview(cwd, input, shared) {
1565
1726
  if (check.config === null) {
1566
1727
  return { ok: false, error: check.errors.join(" ") };
1567
1728
  }
1568
- const path = join3(cwd, LOCAL_PREVIEWS_PATH);
1569
- await mkdir2(dirname3(path), { recursive: true });
1570
- await writeFile2(path, `${JSON.stringify({ previews: [...existing.previews.map(toStored), candidate] }, null, 2)}
1571
- `, "utf8");
1729
+ await writeRegistry(join3(cwd, LOCAL_PREVIEWS_PATH), [
1730
+ ...existing.previews.map(toStored),
1731
+ candidate
1732
+ ]);
1572
1733
  return { ok: true };
1573
1734
  }
1574
1735
  function toStored(preview) {
@@ -1580,13 +1741,127 @@ async function dropLocalPreviews(cwd, titles) {
1580
1741
  const keep = existing.previews.filter((preview) => !titles.includes(preview.title));
1581
1742
  if (keep.length === existing.previews.length)
1582
1743
  return 0;
1583
- const path = join3(cwd, LOCAL_PREVIEWS_PATH);
1584
- await mkdir2(dirname3(path), { recursive: true });
1585
- await writeFile2(path, `${JSON.stringify({ previews: keep.map(toStored) }, null, 2)}
1586
- `, "utf8");
1744
+ await writeRegistry(join3(cwd, LOCAL_PREVIEWS_PATH), keep.map(toStored));
1587
1745
  return existing.previews.length - keep.length;
1588
1746
  }
1589
1747
 
1748
+ // ../server/dist/generation/record.js
1749
+ import { randomUUID as randomUUID2 } from "crypto";
1750
+ import { appendFile, mkdir as mkdir3, readdir, readFile as readFile4, rename as rename2, rm as rm2, writeFile as writeFile3 } from "fs/promises";
1751
+ import { join as join4 } from "path";
1752
+ var RECORDS_DIR = ".leglas/generations";
1753
+ var KEPT_RECORDS = 100;
1754
+ async function recordIds(cwd) {
1755
+ const names = await readdir(join4(cwd, RECORDS_DIR)).catch(() => []);
1756
+ const started = (id) => Number.parseInt(id.slice(4), 36);
1757
+ return names.filter((name) => /^gen-[a-z0-9]+$/.test(name)).sort((a, b) => started(b) - started(a));
1758
+ }
1759
+ async function directionsOf(cwd, id) {
1760
+ let parsed2;
1761
+ try {
1762
+ parsed2 = parseJson(await readFile4(join4(cwd, RECORDS_DIR, id, "set.json"), "utf8"));
1763
+ } catch {
1764
+ return [];
1765
+ }
1766
+ if (!isJsonRecord(parsed2) || !Array.isArray(parsed2.directions))
1767
+ return [];
1768
+ return parsed2.directions.flatMap((direction) => {
1769
+ if (!isJsonRecord(direction) || !isString(direction.key))
1770
+ return [];
1771
+ const attempts2 = Array.isArray(direction.attempts) ? direction.attempts : [];
1772
+ const last = attempts2.at(-1);
1773
+ return [
1774
+ {
1775
+ key: direction.key,
1776
+ title: isJsonRecord(last) && isString(last.title) ? last.title : null
1777
+ }
1778
+ ];
1779
+ });
1780
+ }
1781
+ async function appendEvent(cwd, id, event) {
1782
+ const dir = join4(cwd, RECORDS_DIR, id);
1783
+ await mkdir3(dir, { recursive: true });
1784
+ await appendFile(join4(dir, "events.jsonl"), `${JSON.stringify(event)}
1785
+ `, "utf8");
1786
+ }
1787
+ async function setsOfTitles(cwd, titles) {
1788
+ const found = /* @__PURE__ */ new Map();
1789
+ for (const id of await recordIds(cwd)) {
1790
+ if (found.size === titles.length)
1791
+ break;
1792
+ for (const direction of await directionsOf(cwd, id)) {
1793
+ const title = direction.title;
1794
+ if (title !== null && titles.includes(title) && !found.has(title))
1795
+ found.set(title, { id, key: direction.key });
1796
+ }
1797
+ }
1798
+ return found;
1799
+ }
1800
+ async function setOfKey(cwd, key) {
1801
+ for (const id of await recordIds(cwd)) {
1802
+ if ((await directionsOf(cwd, id)).some((each) => each.key === key))
1803
+ return id;
1804
+ }
1805
+ return null;
1806
+ }
1807
+ async function noteDirections(cwd, titles, kind, detail) {
1808
+ const unique = [...new Set(titles)];
1809
+ const found = await setsOfTitles(cwd, unique).catch(() => /* @__PURE__ */ new Map());
1810
+ for (const title of unique) {
1811
+ const set = found.get(title);
1812
+ if (set === void 0)
1813
+ continue;
1814
+ const event = { at: Date.now(), kind, direction: set.key };
1815
+ if (detail !== void 0)
1816
+ event.detail = detail;
1817
+ await appendEvent(cwd, set.id, event).catch(() => {
1818
+ });
1819
+ }
1820
+ }
1821
+ function createRecords(cwd) {
1822
+ let queue = Promise.resolve();
1823
+ const enqueue = (task) => {
1824
+ queue = queue.then(task).catch(() => {
1825
+ });
1826
+ };
1827
+ return {
1828
+ async prune() {
1829
+ const ids = await recordIds(cwd);
1830
+ for (const id of ids.slice(KEPT_RECORDS - 1)) {
1831
+ await rm2(join4(cwd, RECORDS_DIR, id), { recursive: true, force: true }).catch(() => {
1832
+ });
1833
+ }
1834
+ },
1835
+ save(record3) {
1836
+ const body = `${JSON.stringify(record3, null, 2)}
1837
+ `;
1838
+ enqueue(async () => {
1839
+ const dir = join4(cwd, RECORDS_DIR, record3.id);
1840
+ const path = join4(dir, "set.json");
1841
+ const temporary = `${path}.${randomUUID2()}.tmp`;
1842
+ await mkdir3(dir, { recursive: true });
1843
+ await writeFile3(temporary, body, "utf8");
1844
+ await rename2(temporary, path).catch(async (cause) => {
1845
+ await rm2(temporary, { force: true }).catch(() => {
1846
+ });
1847
+ throw cause;
1848
+ });
1849
+ });
1850
+ },
1851
+ picture(id, name, image) {
1852
+ enqueue(async () => {
1853
+ const dir = join4(cwd, RECORDS_DIR, id);
1854
+ await mkdir3(dir, { recursive: true });
1855
+ await writeFile3(join4(dir, name), image);
1856
+ });
1857
+ },
1858
+ event(id, event) {
1859
+ enqueue(() => appendEvent(cwd, id, event));
1860
+ },
1861
+ flush: () => queue
1862
+ };
1863
+ }
1864
+
1590
1865
  // ../server/dist/proxy.js
1591
1866
  import http, {} from "http";
1592
1867
  import net from "net";
@@ -1755,9 +2030,9 @@ import { randomBytes } from "crypto";
1755
2030
  import nodeProcess from "process";
1756
2031
  import { spawn as nodeSpawn } from "child_process";
1757
2032
  import { accessSync, constants as constants2, existsSync as existsSync2, mkdirSync, readdirSync as readdirSync2, writeFileSync } from "fs";
1758
- import { readFile as readFile4, readdir, rm, stat } from "fs/promises";
2033
+ import { readFile as readFile5, readdir as readdir2, rm as rm3, stat } from "fs/promises";
1759
2034
  import { homedir, tmpdir as osTmpdir } from "os";
1760
- import { delimiter as delimiter2, join as join4 } from "path";
2035
+ import { delimiter as delimiter2, join as join5 } from "path";
1761
2036
  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.";
1762
2037
  var DARWIN_APPS = [
1763
2038
  "Google Chrome.app/Contents/MacOS/Google Chrome",
@@ -1790,7 +2065,7 @@ function firstOnPath(name, env) {
1790
2065
  for (const directory of (env.PATH ?? "").split(delimiter2)) {
1791
2066
  if (directory === "")
1792
2067
  continue;
1793
- const candidate = join4(directory, name);
2068
+ const candidate = join5(directory, name);
1794
2069
  try {
1795
2070
  accessSync(candidate, constants2.X_OK);
1796
2071
  return candidate;
@@ -1828,17 +2103,17 @@ function buildNumber(entry) {
1828
2103
  const digits = /(\d+)\s*$/.exec(entry)?.[1];
1829
2104
  return digits === void 0 ? 0 : Number(digits);
1830
2105
  }
1831
- function cacheRoots(platform, home, readdir7 = readableDirectories) {
1832
- const playwright = platform === "darwin" ? join4(home, "Library", "Caches", "ms-playwright") : join4(home, ".cache", "ms-playwright");
1833
- const puppeteer = join4(home, ".cache", "puppeteer");
2106
+ function cacheRoots(platform, home, readdir8 = readableDirectories) {
2107
+ const playwright = platform === "darwin" ? join5(home, "Library", "Caches", "ms-playwright") : join5(home, ".cache", "ms-playwright");
2108
+ const puppeteer = join5(home, ".cache", "puppeteer");
1834
2109
  const newestFirst = (dir, keep = () => true) => ({
1835
2110
  root: dir,
1836
- entries: readdir7(dir).filter(keep).sort((left, right) => buildNumber(right) - buildNumber(left))
2111
+ entries: readdir8(dir).filter(keep).sort((left, right) => buildNumber(right) - buildNumber(left))
1837
2112
  });
1838
2113
  return [
1839
2114
  newestFirst(playwright, (entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")),
1840
- newestFirst(join4(puppeteer, "chrome-headless-shell")),
1841
- newestFirst(join4(puppeteer, "chrome"))
2115
+ newestFirst(join5(puppeteer, "chrome-headless-shell")),
2116
+ newestFirst(join5(puppeteer, "chrome"))
1842
2117
  ];
1843
2118
  }
1844
2119
  function readableDirectories(dir) {
@@ -1854,18 +2129,18 @@ function findBrowser(search = {}) {
1854
2129
  const home = search.home ?? homedir();
1855
2130
  const exists = search.exists ?? existsSync2;
1856
2131
  const onPath = search.onPath ?? ((name) => firstOnPath(name, env));
1857
- const readdir7 = search.readdir ?? readableDirectories;
2132
+ const readdir8 = search.readdir ?? readableDirectories;
1858
2133
  const firstExisting = (paths) => paths.find((path) => exists(path)) ?? null;
1859
2134
  for (const candidate of [env.LEGLAS_BROWSER, env.CHROME_PATH, env.PUPPETEER_EXECUTABLE_PATH]) {
1860
2135
  if (isString(candidate) && candidate !== "" && exists(candidate))
1861
2136
  return candidate;
1862
2137
  }
1863
- const caches = cacheRoots(platform, home, readdir7);
1864
- const shell = firstExisting(caches.flatMap(({ root, entries }) => entries.flatMap((entry) => HEADLESS_SHELL.map((rest) => join4(root, entry, ...rest)))));
2138
+ const caches = cacheRoots(platform, home, readdir8);
2139
+ const shell = firstExisting(caches.flatMap(({ root, entries }) => entries.flatMap((entry) => HEADLESS_SHELL.map((rest) => join5(root, entry, ...rest)))));
1865
2140
  if (shell !== null)
1866
2141
  return shell;
1867
2142
  if (platform === "darwin") {
1868
- const installed = firstExisting(["/Applications", join4(home, "Applications")].flatMap((root) => DARWIN_APPS.map((app) => join4(root, app))));
2143
+ const installed = firstExisting(["/Applications", join5(home, "Applications")].flatMap((root) => DARWIN_APPS.map((app) => join5(root, app))));
1869
2144
  if (installed !== null)
1870
2145
  return installed;
1871
2146
  }
@@ -1876,7 +2151,7 @@ function findBrowser(search = {}) {
1876
2151
  return found;
1877
2152
  }
1878
2153
  const installed = firstExisting([
1879
- ...LINUX_NAMES.flatMap((name) => [join4("/usr/bin", name), join4("/snap/bin", name)]),
2154
+ ...LINUX_NAMES.flatMap((name) => [join5("/usr/bin", name), join5("/snap/bin", name)]),
1880
2155
  "/opt/google/chrome/chrome"
1881
2156
  ]);
1882
2157
  if (installed !== null)
@@ -1884,32 +2159,32 @@ function findBrowser(search = {}) {
1884
2159
  }
1885
2160
  if (platform === "win32") {
1886
2161
  const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter((entry) => isString(entry) && entry !== "");
1887
- const installed = firstExisting(roots.flatMap((root) => WINDOWS_BROWSERS.map((browser) => join4(root, browser))));
2162
+ const installed = firstExisting(roots.flatMap((root) => WINDOWS_BROWSERS.map((browser) => join5(root, browser))));
1888
2163
  if (installed !== null)
1889
2164
  return installed;
1890
2165
  }
1891
2166
  if (platform === "darwin" || platform === "linux") {
1892
- const playwrightRoot = platform === "darwin" ? join4(home, "Library", "Caches", "ms-playwright") : join4(home, ".cache", "ms-playwright");
1893
- const playwright = readdir7(playwrightRoot).filter((entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
1894
- const root = join4(playwrightRoot, entry);
2167
+ const playwrightRoot = platform === "darwin" ? join5(home, "Library", "Caches", "ms-playwright") : join5(home, ".cache", "ms-playwright");
2168
+ const playwright = readdir8(playwrightRoot).filter((entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
2169
+ const root = join5(playwrightRoot, entry);
1895
2170
  return [
1896
- ...FOR_TESTING.map((rest) => join4(root, ...rest)),
1897
- ...HEADLESS_SHELL.map((rest) => join4(root, ...rest)),
2171
+ ...FOR_TESTING.map((rest) => join5(root, ...rest)),
2172
+ ...HEADLESS_SHELL.map((rest) => join5(root, ...rest)),
1898
2173
  // Older builds shipped a plain Chromium app.
1899
- join4(root, "chrome-mac-arm64", "Chromium.app", "Contents", "MacOS", "Chromium"),
1900
- join4(root, "chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"),
1901
- join4(root, "chrome-linux", "chrome")
2174
+ join5(root, "chrome-mac-arm64", "Chromium.app", "Contents", "MacOS", "Chromium"),
2175
+ join5(root, "chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"),
2176
+ join5(root, "chrome-linux", "chrome")
1902
2177
  ];
1903
2178
  });
1904
2179
  const playwrightBrowser = firstExisting(playwright);
1905
2180
  if (playwrightBrowser !== null)
1906
2181
  return playwrightBrowser;
1907
- const puppeteerCache = join4(home, ".cache", "puppeteer");
2182
+ const puppeteerCache = join5(home, ".cache", "puppeteer");
1908
2183
  for (const kind of ["chrome", "chrome-headless-shell"]) {
1909
- const kindRoot = join4(puppeteerCache, kind);
1910
- const found = firstExisting(readdir7(kindRoot).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
1911
- const root = join4(kindRoot, entry);
1912
- return [...FOR_TESTING, ...HEADLESS_SHELL].map((rest) => join4(root, ...rest));
2184
+ const kindRoot = join5(puppeteerCache, kind);
2185
+ const found = firstExisting(readdir8(kindRoot).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
2186
+ const root = join5(kindRoot, entry);
2187
+ return [...FOR_TESTING, ...HEADLESS_SHELL].map((rest) => join5(root, ...rest));
1913
2188
  }));
1914
2189
  if (found !== null)
1915
2190
  return found;
@@ -2034,8 +2309,8 @@ async function closeOrphan(url, connect) {
2034
2309
  async function reapOrphanedBrowsers(deps = {}) {
2035
2310
  const root = deps.tmpdir ?? osTmpdir();
2036
2311
  const clock2 = deps.now ?? (() => Date.now());
2037
- const list = deps.list ?? (async (path) => (await readdir(path, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name));
2038
- const read = deps.read ?? ((path) => readFile4(path, "utf8"));
2312
+ const list = deps.list ?? (async (path) => (await readdir2(path, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name));
2313
+ const read = deps.read ?? ((path) => readFile5(path, "utf8"));
2039
2314
  const alive = deps.alive ?? livePid;
2040
2315
  const profileOf = deps.profile ?? (async (path) => {
2041
2316
  const entry = await stat(path);
@@ -2044,7 +2319,7 @@ async function reapOrphanedBrowsers(deps = {}) {
2044
2319
  const currentUid = deps.uid ?? (() => nodeProcess.getuid?.() ?? null);
2045
2320
  const connect = deps.connect ?? connectWebSocket;
2046
2321
  const remove = deps.remove ?? (async (path) => {
2047
- await rm(path, { recursive: true, force: true });
2322
+ await rm3(path, { recursive: true, force: true });
2048
2323
  });
2049
2324
  let names;
2050
2325
  try {
@@ -2057,7 +2332,7 @@ async function reapOrphanedBrowsers(deps = {}) {
2057
2332
  for (const name of names) {
2058
2333
  if (!name.startsWith(PROFILE_PREFIX))
2059
2334
  continue;
2060
- const directory = join4(root, name);
2335
+ const directory = join5(root, name);
2061
2336
  const details = await profileOf(directory).catch(() => null);
2062
2337
  if (details === null)
2063
2338
  continue;
@@ -2065,7 +2340,7 @@ async function reapOrphanedBrowsers(deps = {}) {
2065
2340
  continue;
2066
2341
  let record3 = null;
2067
2342
  try {
2068
- const raw = await read(join4(directory, OWNER_FILE));
2343
+ const raw = await read(join5(directory, OWNER_FILE));
2069
2344
  const parsed2 = raw === null ? null : parseJson(raw);
2070
2345
  record3 = isJsonRecord(parsed2) ? parsed2 : null;
2071
2346
  } catch {
@@ -2087,11 +2362,11 @@ async function launchBrowser(executable, options = {}) {
2087
2362
  const spawn7 = options.spawn ?? nodeSpawn;
2088
2363
  const connect = options.connect ?? connectWebSocket;
2089
2364
  const commandTimeoutMs = options.commandTimeoutMs ?? 3e4;
2090
- const userDataDir = join4(options.tmpdir ?? osTmpdir(), `leglas-browser-${randomBytes(8).toString("hex")}`);
2365
+ const userDataDir = join5(options.tmpdir ?? osTmpdir(), `leglas-browser-${randomBytes(8).toString("hex")}`);
2091
2366
  const owned = (fields) => {
2092
2367
  try {
2093
2368
  mkdirSync(userDataDir, { recursive: true, mode: 448 });
2094
- writeFileSync(join4(userDataDir, OWNER_FILE), `${JSON.stringify({ owner: nodeProcess.pid, ...fields })}
2369
+ writeFileSync(join5(userDataDir, OWNER_FILE), `${JSON.stringify({ owner: nodeProcess.pid, ...fields })}
2095
2370
  `, { encoding: "utf8", mode: 384 });
2096
2371
  } catch {
2097
2372
  }
@@ -2111,11 +2386,10 @@ async function launchBrowser(executable, options = {}) {
2111
2386
  "--mute-audio",
2112
2387
  "--force-color-profile=srgb",
2113
2388
  "--window-size=1440,900",
2114
- // Everything a desktop browser does on the way up that a screenshot
2115
- // does not need. Measured on this project: about 300MB and half a
2116
- // second off a Chrome launch, and most of the wait off a headless
2117
- // shell. `--use-mock-keychain` matters most on macOS, where Chrome
2118
- // otherwise reaches for the login keychain before it will start.
2389
+ // Desktop startup work a screenshot doesn't need. Measured here: about
2390
+ // 300MB and half a second off a Chrome launch, most of the wait off a
2391
+ // headless shell. `--use-mock-keychain` matters most on macOS, where
2392
+ // Chrome otherwise waits on the login keychain.
2119
2393
  "--disable-dev-shm-usage",
2120
2394
  "--disable-breakpad",
2121
2395
  "--disable-client-side-phishing-detection",
@@ -2132,7 +2406,7 @@ async function launchBrowser(executable, options = {}) {
2132
2406
  try {
2133
2407
  websocketUrl = await endpoint(process2, options.startTimeoutMs ?? START_TIMEOUT_MS);
2134
2408
  } catch (error) {
2135
- await rm(userDataDir, { recursive: true, force: true }).catch(() => {
2409
+ await rm3(userDataDir, { recursive: true, force: true }).catch(() => {
2136
2410
  });
2137
2411
  throw error;
2138
2412
  }
@@ -2142,7 +2416,7 @@ async function launchBrowser(executable, options = {}) {
2142
2416
  socket = await connect(websocketUrl);
2143
2417
  } catch (error) {
2144
2418
  process2.kill("SIGKILL");
2145
- await rm(userDataDir, { recursive: true, force: true }).catch(() => {
2419
+ await rm3(userDataDir, { recursive: true, force: true }).catch(() => {
2146
2420
  });
2147
2421
  throw error;
2148
2422
  }
@@ -2295,7 +2569,7 @@ async function launchBrowser(executable, options = {}) {
2295
2569
  ]);
2296
2570
  }
2297
2571
  socket.close();
2298
- await rm(userDataDir, { recursive: true, force: true }).catch(() => {
2572
+ await rm3(userDataDir, { recursive: true, force: true }).catch(() => {
2299
2573
  });
2300
2574
  })();
2301
2575
  return closing;
@@ -2303,8 +2577,8 @@ async function launchBrowser(executable, options = {}) {
2303
2577
  return {
2304
2578
  withPage,
2305
2579
  close,
2306
- // A socket that went with the process still alive is the same thing to
2307
- // a caller: nothing can be sent, so the pool must not hand it out again.
2580
+ // A socket gone with the process alive is the same to a caller: the pool
2581
+ // must not hand it out.
2308
2582
  get closed() {
2309
2583
  return processClosed || socketClosed;
2310
2584
  }
@@ -2448,6 +2722,367 @@ function hydrationEvidence(messages) {
2448
2722
  return null;
2449
2723
  }
2450
2724
 
2725
+ // ../server/dist/capture/layout.js
2726
+ function isEdge(value) {
2727
+ return value === "left" || value === "right" || value === "top" || value === "bottom";
2728
+ }
2729
+ var LAYOUT_PROBE = `(() => {
2730
+ const OVERLAP_SHARE = 0.05;
2731
+ const OVERLAP_MIN = 1;
2732
+ const CUT_MIN = 3;
2733
+ const CUT_SHARE = 0.08;
2734
+ const LAYERED = 4;
2735
+ const TILT = 6;
2736
+ // Only a page gone wrong everywhere reaches this. The app's own findings are
2737
+ // subtracted after, so a low cap could crowd out a direction's.
2738
+ const LIMIT = 100;
2739
+ const width = window.innerWidth;
2740
+ const states = new Map();
2741
+ const inks = new Map();
2742
+ const context = document.createElement("canvas").getContext("2d");
2743
+
2744
+ // rgba(r, g, b, a), or a / before the alpha in the newer functions
2745
+ // (oklab, oklch, color), which computed colours keep.
2746
+ const alpha = (colour) => {
2747
+ if (colour === "transparent") return 0;
2748
+ const slash = /\\/\\s*([\\d.]+)(%?)\\s*\\)$/.exec(colour || "");
2749
+ if (slash) return parseFloat(slash[1]) / (slash[2] ? 100 : 1);
2750
+ const match = /rgba\\(([^)]+)\\)/.exec(colour || "");
2751
+ const parts = match ? match[1].split(/[\\s,]+/).filter(Boolean) : [];
2752
+ return parts.length > 3 ? parseFloat(parts[3]) : 1;
2753
+ };
2754
+
2755
+ // Degrees this element turns its content by, or Infinity for a skew or a
2756
+ // 3D transform, whose boxes say nothing useful.
2757
+ const angleOf = (a, b, c, d) =>
2758
+ Math.abs(a * c + b * d) > 0.01 ? Infinity : (Math.atan2(b, a) * 180) / Math.PI;
2759
+
2760
+ const turnOf = (element, style) => {
2761
+ let turn = 0;
2762
+ if (style.rotate && style.rotate !== "none") turn += parseFloat(style.rotate) || 0;
2763
+ const transform = style.transform;
2764
+ if (transform && transform !== "none") {
2765
+ if (transform.startsWith("matrix3d")) return Infinity;
2766
+ const [a, b, c, d] = transform.slice(7, -1).split(",").map(Number);
2767
+ turn += angleOf(a, b, c, d);
2768
+ }
2769
+ return turn;
2770
+ };
2771
+
2772
+ const looping = new Set();
2773
+ try {
2774
+ for (const animation of document.getAnimations()) {
2775
+ const timing = animation.effect && animation.effect.getComputedTiming();
2776
+ const target = animation.effect && animation.effect.target;
2777
+ if (timing && timing.iterations === Infinity && target) looping.add(target);
2778
+ }
2779
+ } catch (error) {
2780
+ // An engine without getAnimations; nothing is excluded for it.
2781
+ }
2782
+
2783
+ const stateOf = (element) => {
2784
+ if (states.has(element)) return states.get(element);
2785
+ const style = getComputedStyle(element);
2786
+ const parent = element.parentElement ? stateOf(element.parentElement) : null;
2787
+ const clipsText = /text/.test(style.backgroundClip || "") || /text/.test(style.webkitBackgroundClip || "");
2788
+ const state = {
2789
+ style,
2790
+ opacity: (parent ? parent.opacity : 1) * parseFloat(style.opacity || "1"),
2791
+ turn: element instanceof SVGGraphicsElement && element.getCTM
2792
+ ? (() => {
2793
+ const matrix = element.getCTM();
2794
+ return matrix ? angleOf(matrix.a, matrix.b, matrix.c, matrix.d) + (parent ? parent.turnOutsideSvg : 0) : 0;
2795
+ })()
2796
+ : (parent ? parent.turn : 0) + turnOf(element, style),
2797
+ // An <svg>'s own CSS transform is outside every getCTM beneath it.
2798
+ turnOutsideSvg: element.namespaceURI === "http://www.w3.org/2000/svg" && element.ownerSVGElement
2799
+ ? (parent ? parent.turnOutsideSvg : 0)
2800
+ : (parent ? parent.turn : 0) + turnOf(element, style),
2801
+ masked:
2802
+ (parent ? parent.masked : false) ||
2803
+ (style.clipPath && style.clipPath !== "none") ||
2804
+ (style.maskImage && style.maskImage !== "none") ||
2805
+ (style.webkitMaskImage && style.webkitMaskImage !== "none"),
2806
+ truncates:
2807
+ (parent ? parent.truncates : false) ||
2808
+ style.textOverflow === "ellipsis" ||
2809
+ (style.webkitLineClamp && style.webkitLineClamp !== "none"),
2810
+ moving: (parent ? parent.moving : false) || looping.has(element),
2811
+ paintsText: (parent ? parent.paintsText : false) || clipsText,
2812
+ };
2813
+ states.set(element, state);
2814
+ return state;
2815
+ };
2816
+
2817
+ const blockOf = (element) => {
2818
+ if (element.namespaceURI === "http://www.w3.org/2000/svg") return element.closest("text") || element;
2819
+ for (let node = element; node; node = node.parentElement) {
2820
+ const display = stateOf(node).style.display;
2821
+ if (display !== "inline" && display !== "contents") return node;
2822
+ }
2823
+ return document.body;
2824
+ };
2825
+
2826
+ const set = (value) => Boolean(value) && value !== "none" && value !== "normal";
2827
+
2828
+ const establishes = (style) =>
2829
+ set(style.transform) ||
2830
+ set(style.scale) ||
2831
+ set(style.rotate) ||
2832
+ set(style.translate) ||
2833
+ set(style.perspective) ||
2834
+ set(style.filter) ||
2835
+ set(style.backdropFilter) ||
2836
+ set(style.containerType) ||
2837
+ style.contentVisibility === "auto" ||
2838
+ /transform|perspective|filter|scale|rotate|translate/.test(style.willChange || "") ||
2839
+ /paint|layout|strict|content/.test(style.contain || "");
2840
+
2841
+ const clipRect = (element, style) => {
2842
+ const match = /rect\\(([^)]+)\\)/.exec(style.clip || "");
2843
+ if (!match || (style.position !== "absolute" && style.position !== "fixed")) return null;
2844
+ const outer = element.getBoundingClientRect();
2845
+ const [top, right, bottom, left] = match[1].split(/[\\s,]+/).filter(Boolean);
2846
+ const at = (value, fallback, origin) => (value === "auto" ? fallback : origin + parseFloat(value));
2847
+ return {
2848
+ left: at(left, outer.left, outer.left),
2849
+ right: at(right, outer.right, outer.left),
2850
+ top: at(top, outer.top, outer.top),
2851
+ bottom: at(bottom, outer.bottom, outer.top),
2852
+ page: false,
2853
+ cutsX: true,
2854
+ cutsY: true,
2855
+ };
2856
+ };
2857
+
2858
+ // With the root's overflow visible, the body's overflow is the viewport's,
2859
+ // which the page clip already is, and the body's own box clips nothing.
2860
+ const rootStyle = getComputedStyle(document.documentElement);
2861
+ const bodyIsViewport = rootStyle.overflowX === "visible" && rootStyle.overflowY === "visible";
2862
+
2863
+ // Every box that clips the text: its own element first. A positioned box
2864
+ // escapes every clipping box between it and its containing block.
2865
+ const clippers = (element) => {
2866
+ const found = [{ left: 0, right: width, top: 0, bottom: Infinity, page: true, cutsX: true, cutsY: true }];
2867
+ let escaping = null;
2868
+ for (let node = element; node && node !== document.documentElement; node = node.parentElement) {
2869
+ const style = stateOf(node).style;
2870
+ if (node !== element) {
2871
+ if (escaping === "absolute" && (style.position !== "static" || establishes(style))) escaping = null;
2872
+ if (escaping === "fixed" && establishes(style)) escaping = null;
2873
+ }
2874
+ if (escaping === null) {
2875
+ // A scrolling box hides what is scrolled away but cuts nothing off: the
2876
+ // rest is a scroll away. The document's own scroller hides nothing.
2877
+ const root = node === document.body;
2878
+ const hides = (value) => !(root && bodyIsViewport) && (value === "hidden" || value === "clip");
2879
+ const scrolls = (value) => !root && (value === "auto" || value === "scroll");
2880
+ const x = hides(style.overflowX) || scrolls(style.overflowX);
2881
+ const y = hides(style.overflowY) || scrolls(style.overflowY);
2882
+ if (x || y) {
2883
+ // The client box is measured before transforms, the bounding box after.
2884
+ const outer = node.getBoundingClientRect();
2885
+ const wide = typeof node.offsetWidth === "number" ? node.offsetWidth : node.clientWidth + 2 * node.clientLeft;
2886
+ const tall = typeof node.offsetHeight === "number" ? node.offsetHeight : node.clientHeight + 2 * node.clientTop;
2887
+ const across = wide > 0 ? outer.width / wide : 1;
2888
+ const down = tall > 0 ? outer.height / tall : 1;
2889
+ const left = outer.left + node.clientLeft * across;
2890
+ const top = outer.top + node.clientTop * down;
2891
+ found.push({
2892
+ left: x ? left : -Infinity,
2893
+ right: x ? left + node.clientWidth * across : Infinity,
2894
+ top: y ? top : -Infinity,
2895
+ bottom: y ? top + node.clientHeight * down : Infinity,
2896
+ page: false,
2897
+ cutsX: hides(style.overflowX),
2898
+ cutsY: hides(style.overflowY),
2899
+ });
2900
+ }
2901
+ const clipped = clipRect(node, style);
2902
+ if (clipped) found.push(clipped);
2903
+ }
2904
+ if (style.position === "absolute" || style.position === "fixed") escaping = style.position;
2905
+ }
2906
+ return found;
2907
+ };
2908
+
2909
+ // The box around the letters themselves: a font's line box reaches past its
2910
+ // ink, and display type set tight overlaps line to line without touching.
2911
+ const inkOf = (words, style) => {
2912
+ const shown = style.textTransform === "uppercase" ? words.toUpperCase() : words;
2913
+ const font = [style.fontStyle, style.fontWeight, style.fontSize, style.fontFamily].join(" ");
2914
+ const key = font + "\\u0000" + style.letterSpacing + "\\u0000" + shown;
2915
+ if (inks.has(key)) return inks.get(key);
2916
+ let ink = null;
2917
+ if (context) {
2918
+ context.font = font;
2919
+ if ("letterSpacing" in context) context.letterSpacing = style.letterSpacing === "normal" ? "0px" : style.letterSpacing;
2920
+ const measured = context.measureText(shown);
2921
+ if (typeof measured.fontBoundingBoxAscent === "number") {
2922
+ ink = {
2923
+ top: Math.max(0, measured.fontBoundingBoxAscent - measured.actualBoundingBoxAscent),
2924
+ bottom: Math.max(0, measured.fontBoundingBoxDescent - measured.actualBoundingBoxDescent),
2925
+ left: -measured.actualBoundingBoxLeft,
2926
+ right: measured.actualBoundingBoxRight,
2927
+ height: measured.fontBoundingBoxAscent + measured.fontBoundingBoxDescent,
2928
+ advance: measured.width,
2929
+ };
2930
+ }
2931
+ }
2932
+ inks.set(key, ink);
2933
+ return ink;
2934
+ };
2935
+
2936
+ const area = (box) => Math.max(0, box.right - box.left) * Math.max(0, box.bottom - box.top);
2937
+
2938
+ const texts = [];
2939
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
2940
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
2941
+ const words = node.nodeValue.replace(/\\s+/g, " ").trim();
2942
+ const element = node.parentElement;
2943
+ if (words === "" || !element || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE)$/.test(element.tagName)) continue;
2944
+ const state = stateOf(element);
2945
+ // A slight tilt keeps its box close to its letters; more and the box lies.
2946
+ const tilt = Math.abs((((state.turn % 360) + 540) % 360) - 180);
2947
+ if (state.style.visibility !== "visible" || state.opacity < 0.05 || state.masked || !(tilt <= TILT)) continue;
2948
+ if (alpha(state.style.color) < 0.05 && alpha(state.style.webkitTextFillColor) < 0.05 && !state.paintsText) continue;
2949
+ const range = document.createRange();
2950
+ range.selectNodeContents(node);
2951
+ const ink = inkOf(words, state.style);
2952
+ const clips = clippers(element);
2953
+ const boxes = [];
2954
+ const lines = [...range.getClientRects()].filter((raw) => raw.width >= 2 && raw.height >= 2);
2955
+ const rtl = state.style.direction === "rtl";
2956
+ for (const raw of lines) {
2957
+ // Scaled type (an SVG viewBox, a scale transform) draws at another size
2958
+ // than its computed font-size; the box's own height says by how much.
2959
+ const scale = ink && ink.height > 0 ? raw.height / ink.height : 1;
2960
+ const top = ink ? ink.top * scale : 0;
2961
+ const bottom = ink ? ink.bottom * scale : 0;
2962
+ const box = raw.height - top - bottom >= 1
2963
+ ? { left: raw.left, right: raw.right, top: raw.top + top, bottom: raw.bottom - bottom }
2964
+ : { left: raw.left, right: raw.right, top: raw.top, bottom: raw.bottom };
2965
+ // Across, the ink is known only when the whole string is on this one line,
2966
+ // and a line can be stretched one way only (scaleX).
2967
+ const wide = ink && ink.advance > 0 ? raw.width / ink.advance : scale;
2968
+ if (ink && lines.length === 1 && !rtl && (ink.right - ink.left) * wide >= 1) {
2969
+ box.left = Math.max(box.left, raw.left + ink.left * wide);
2970
+ box.right = Math.min(box.right, raw.left + ink.right * wide);
2971
+ }
2972
+ const seen = { ...box };
2973
+ for (const clip of clips) {
2974
+ seen.left = Math.max(seen.left, clip.left);
2975
+ seen.right = Math.min(seen.right, clip.right);
2976
+ seen.top = Math.max(seen.top, clip.top);
2977
+ seen.bottom = Math.min(seen.bottom, clip.bottom);
2978
+ }
2979
+ const shows = seen.right - seen.left >= 2 && seen.bottom - seen.top >= 2;
2980
+ boxes.push({ box, seen, shows, share: area(box) === 0 ? 0 : area(seen) / area(box), clips });
2981
+ }
2982
+ if (boxes.length === 0) continue;
2983
+ const drawn = ink && ink.height > 0 ? lines[0].height / ink.height : 1;
2984
+ const size = (parseFloat(state.style.fontSize) || 16) * drawn;
2985
+ texts.push({ words: words.slice(0, 80), boxes, block: blockOf(element), state, size });
2986
+ }
2987
+
2988
+ const findings = [];
2989
+ const seen = new Set();
2990
+
2991
+ // Collisions between what shows of each box, swept by top edge.
2992
+ const shown = texts.flatMap((text) =>
2993
+ text.boxes.filter((entry) => entry.share >= 0.5).map((entry) => ({ box: entry.seen, text })),
2994
+ );
2995
+ shown.sort((a, b) => a.box.top - b.box.top);
2996
+ for (let i = 0; i < shown.length && findings.length < LIMIT; i += 1) {
2997
+ const a = shown[i];
2998
+ for (let j = i + 1; j < shown.length && shown[j].box.top < a.box.bottom; j += 1) {
2999
+ const b = shown[j];
3000
+ if (a.text.block === b.text.block || a.text === b.text) continue;
3001
+ if (a.text.state.moving || b.text.state.moving) continue;
3002
+ const across = Math.min(a.box.right, b.box.right) - Math.max(a.box.left, b.box.left);
3003
+ const down = Math.min(a.box.bottom, b.box.bottom) - Math.max(a.box.top, b.box.top);
3004
+ if (across < OVERLAP_MIN || down < OVERLAP_MIN) continue;
3005
+ if ((across * down) / Math.min(area(a.box), area(b.box)) < OVERLAP_SHARE) continue;
3006
+ // Words laid over giant display type are a composition, not a collision,
3007
+ // and a variable font's width is past what the canvas can measure.
3008
+ const larger = Math.max(a.text.size, b.text.size);
3009
+ const smaller = Math.min(a.text.size, b.text.size);
3010
+ if (larger >= LAYERED * smaller) continue;
3011
+ const pair = [a.text.words, b.text.words].sort().join("\\u0000");
3012
+ if (seen.has(pair)) continue;
3013
+ seen.add(pair);
3014
+ findings.push({ kind: "overlap", text: a.text.words, other: b.text.words });
3015
+ if (findings.length >= LIMIT) break;
3016
+ }
3017
+ }
3018
+
3019
+ // Cut off: partly shown. Text hidden whole is a choice (screen reader
3020
+ // text, a rolling digit, a carousel), not a mistake.
3021
+ for (const text of texts) {
3022
+ if (findings.length >= LIMIT) break;
3023
+ if (text.state.truncates || text.state.moving) continue;
3024
+ let cut = null;
3025
+ for (const entry of text.boxes) {
3026
+ if (!entry.shows || entry.share > 0.97) continue;
3027
+ const { box } = entry;
3028
+ // Nearest clip first, the page last. Past a scroll box, a clip further
3029
+ // out sees only what the scroll box shows.
3030
+ const inner = { ...box };
3031
+ for (const clip of [...entry.clips.slice(1), entry.clips[0]]) {
3032
+ const sides = [
3033
+ ...(clip.cutsX ? [["left", clip.left - inner.left, box.right - box.left], ["right", inner.right - clip.right, box.right - box.left]] : []),
3034
+ ...(clip.cutsY ? [["top", clip.top - inner.top, box.bottom - box.top], ["bottom", inner.bottom - clip.bottom, box.bottom - box.top]] : []),
3035
+ ];
3036
+ for (const [edge, by, extent] of sides) {
3037
+ // A box flush with the page's edge cuts where the page does; say the page.
3038
+ const deeper = cut === null || by > cut.by || (clip.page && by === cut.by);
3039
+ if (by > Math.max(CUT_MIN, extent * CUT_SHARE) && deeper) {
3040
+ cut = { edge, by, within: clip.page ? "page" : "container" };
3041
+ }
3042
+ }
3043
+ if (!clip.cutsX) {
3044
+ inner.left = Math.max(inner.left, clip.left);
3045
+ inner.right = Math.min(inner.right, clip.right);
3046
+ }
3047
+ if (!clip.cutsY) {
3048
+ inner.top = Math.max(inner.top, clip.top);
3049
+ inner.bottom = Math.min(inner.bottom, clip.bottom);
3050
+ }
3051
+ }
3052
+ }
3053
+ if (cut) findings.push({ kind: "cut-off", text: text.words, edge: cut.edge, by: Math.round(cut.by), within: cut.within });
3054
+ }
3055
+
3056
+ return findings;
3057
+ })()`;
3058
+ function layoutFindings(value) {
3059
+ if (!Array.isArray(value))
3060
+ return [];
3061
+ const findings = [];
3062
+ for (const entry of value) {
3063
+ if (!isJsonRecord(entry) || !isString(entry.text))
3064
+ continue;
3065
+ if (entry.kind === "overlap" && isString(entry.other)) {
3066
+ findings.push({ kind: "overlap", text: entry.text, other: entry.other });
3067
+ } else if (entry.kind === "cut-off" && isString(entry.edge) && isEdge(entry.edge) && isNumber(entry.by) && (entry.within === "page" || entry.within === "container")) {
3068
+ findings.push({
3069
+ kind: "cut-off",
3070
+ text: entry.text,
3071
+ edge: entry.edge,
3072
+ by: entry.by,
3073
+ within: entry.within
3074
+ });
3075
+ }
3076
+ }
3077
+ return findings;
3078
+ }
3079
+ function describeFinding(finding) {
3080
+ if (finding.kind === "overlap")
3081
+ return `"${finding.text}" runs into "${finding.other}"`;
3082
+ const where = finding.within === "page" ? "the page" : "the box around it";
3083
+ return `"${finding.text}" is cut off ${finding.by}px past the ${finding.edge} edge of ${where}`;
3084
+ }
3085
+
2451
3086
  // ../server/dist/capture/capture.js
2452
3087
  var FRAME_MAX_HEIGHT = 4e3;
2453
3088
  var MIN_WIDTH = 320;
@@ -2553,6 +3188,13 @@ function resourced(text3, url) {
2553
3188
  function locatorExpression(focus) {
2554
3189
  return `${LOCATOR}(${JSON.stringify(focus.selector)}, ${JSON.stringify(focus.text)}, ${JSON.stringify(focus.tag)})`;
2555
3190
  }
3191
+ async function inspectLayout(page) {
3192
+ const response = await bounded(page.send("Runtime.evaluate", {
3193
+ expression: LAYOUT_PROBE,
3194
+ returnByValue: true
3195
+ }), 2e3, null).catch(() => null);
3196
+ return layoutFindings(resultValue(response));
3197
+ }
2556
3198
  async function render(page, input) {
2557
3199
  const width = clamp(Math.round(input.width), MIN_WIDTH, MAX_WIDTH);
2558
3200
  const errors = [];
@@ -2712,22 +3354,30 @@ async function render(page, input) {
2712
3354
  returnByValue: true
2713
3355
  }), 2e3, void 0).catch(() => {
2714
3356
  });
3357
+ const layout = input.inspect === true ? await inspectLayout(page) : [];
2715
3358
  const metrics = await page.send("Page.getLayoutMetrics");
2716
3359
  const size2 = metrics.cssContentSize ?? metrics.contentSize ?? { width, height: 900 };
2717
3360
  const pageWidth = Math.max(width, Math.ceil(size2.width));
2718
3361
  const contentHeight = Math.max(1, Math.ceil(size2.height));
2719
3362
  const pageHeight = Math.min(FRAME_MAX_HEIGHT, contentHeight);
2720
3363
  const cut = size2.height > FRAME_MAX_HEIGHT;
3364
+ const clip2 = { x: 0, y: 0, width, height: pageHeight, scale: 1 };
2721
3365
  const frameResponse = await page.send("Page.captureScreenshot", {
2722
3366
  format: "png",
2723
3367
  captureBeyondViewport: true,
2724
- clip: { x: 0, y: 0, width, height: pageHeight, scale: 1 }
3368
+ clip: clip2
2725
3369
  });
2726
3370
  const frame = {
2727
3371
  png: Buffer.from(frameResponse.data, "base64"),
2728
3372
  width,
2729
3373
  height: pageHeight
2730
3374
  };
3375
+ const jpeg = input.jpeg === true ? await page.send("Page.captureScreenshot", {
3376
+ format: "jpeg",
3377
+ quality: 80,
3378
+ captureBeyondViewport: true,
3379
+ clip: clip2
3380
+ }).then((shot) => Buffer.from(shot.data, "base64"), () => null) : null;
2731
3381
  const crops = [];
2732
3382
  for (const focus of input.focuses ?? []) {
2733
3383
  const located = await page.send("Runtime.evaluate", {
@@ -2763,7 +3413,7 @@ async function render(page, input) {
2763
3413
  resolved
2764
3414
  });
2765
3415
  }
2766
- return { frame, crops, errors, hydration, cut };
3416
+ return { frame, crops, errors, hydration, cut, layout, jpeg };
2767
3417
  } finally {
2768
3418
  for (const stop of unlisten)
2769
3419
  stop();
@@ -2775,8 +3425,8 @@ async function capturePage(browser, input) {
2775
3425
  }
2776
3426
 
2777
3427
  // ../server/dist/requests/attachments.js
2778
- 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";
2779
- import { basename as basename2, extname, join as join5, resolve, sep } from "path";
3428
+ 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";
3429
+ import { basename as basename2, extname, join as join6, resolve, sep } from "path";
2780
3430
  var CAPTURES_DIR = ".leglas/captures";
2781
3431
  var REFERENCES_DIR = ".leglas/references";
2782
3432
  var LOAD_SHARE = 0.6;
@@ -2867,7 +3517,7 @@ function sniffImage(bytes) {
2867
3517
  async function moveReference(cwd, id, destinationDir, index) {
2868
3518
  let names;
2869
3519
  try {
2870
- names = await readdir2(join5(cwd, REFERENCES_DIR));
3520
+ names = await readdir3(join6(cwd, REFERENCES_DIR));
2871
3521
  } catch {
2872
3522
  return null;
2873
3523
  }
@@ -2876,17 +3526,17 @@ async function moveReference(cwd, id, destinationDir, index) {
2876
3526
  return null;
2877
3527
  const extension = extname(sourceName).toLowerCase();
2878
3528
  const destinationName = `reference-${index}${extension}`;
2879
- const source = join5(cwd, REFERENCES_DIR, sourceName);
2880
- const destination = join5(destinationDir, destinationName);
2881
- await mkdir3(destinationDir, { recursive: true });
3529
+ const source = join6(cwd, REFERENCES_DIR, sourceName);
3530
+ const destination = join6(destinationDir, destinationName);
3531
+ await mkdir4(destinationDir, { recursive: true });
2882
3532
  try {
2883
- await rename(source, destination);
3533
+ await rename3(source, destination);
2884
3534
  } catch {
2885
3535
  await copyFile(source, destination);
2886
3536
  await unlink(source).catch(() => {
2887
3537
  });
2888
3538
  }
2889
- const sniffed = sniffImage(await readFile5(destination));
3539
+ const sniffed = sniffImage(await readFile6(destination));
2890
3540
  return {
2891
3541
  kind: "reference",
2892
3542
  file: `${CAPTURES_DIR}/${basename2(destinationDir)}/${destinationName}`,
@@ -2908,7 +3558,7 @@ function focusOf(note) {
2908
3558
  async function attachRequest(cwd, requestId, input, deps) {
2909
3559
  const capture = deps.capture ?? capturePage;
2910
3560
  const deadlineMs = deps.deadlineMs ?? 12e3;
2911
- const destination = join5(cwd, CAPTURES_DIR, requestId);
3561
+ const destination = join6(cwd, CAPTURES_DIR, requestId);
2912
3562
  const captured = {
2913
3563
  attachments: [],
2914
3564
  errors: [],
@@ -2954,8 +3604,8 @@ async function attachRequest(cwd, requestId, input, deps) {
2954
3604
  const direction = await capture(browser, directionInput);
2955
3605
  if (expired2)
2956
3606
  return;
2957
- await mkdir3(destination, { recursive: true });
2958
- await writeFile3(join5(destination, "frame.png"), direction.frame.png);
3607
+ await mkdir4(destination, { recursive: true });
3608
+ await writeFile4(join6(destination, "frame.png"), direction.frame.png);
2959
3609
  captured.attachments.push({
2960
3610
  kind: "frame",
2961
3611
  file: `${CAPTURES_DIR}/${requestId}/frame.png`,
@@ -2973,7 +3623,7 @@ async function attachRequest(cwd, requestId, input, deps) {
2973
3623
  if (crop === null || crop === void 0 || note === void 0 || expired2)
2974
3624
  continue;
2975
3625
  const name = `note-${index + 1}.png`;
2976
- await writeFile3(join5(destination, name), crop.shot.png);
3626
+ await writeFile4(join6(destination, name), crop.shot.png);
2977
3627
  captured.attachments.push({
2978
3628
  kind: "note",
2979
3629
  file: `${CAPTURES_DIR}/${requestId}/${name}`,
@@ -2994,7 +3644,7 @@ async function attachRequest(cwd, requestId, input, deps) {
2994
3644
  const comparison = await capture(browser, compareInput);
2995
3645
  if (expired2)
2996
3646
  return;
2997
- await writeFile3(join5(destination, "compare.png"), comparison.frame.png);
3647
+ await writeFile4(join6(destination, "compare.png"), comparison.frame.png);
2998
3648
  captured.attachments.push({
2999
3649
  kind: "compare",
3000
3650
  file: `${CAPTURES_DIR}/${requestId}/compare.png`,
@@ -3018,13 +3668,13 @@ async function attachRequest(cwd, requestId, input, deps) {
3018
3668
  return captured;
3019
3669
  }
3020
3670
  function captureDirectory(cwd, requestId) {
3021
- return /^[A-Za-z0-9_-]{1,32}$/.test(requestId) ? join5(cwd, CAPTURES_DIR, requestId) : null;
3671
+ return /^[A-Za-z0-9_-]{1,32}$/.test(requestId) ? join6(cwd, CAPTURES_DIR, requestId) : null;
3022
3672
  }
3023
3673
  async function removeCaptures(cwd, requestId) {
3024
3674
  const directory = captureDirectory(cwd, requestId);
3025
3675
  if (directory === null)
3026
3676
  return;
3027
- await rm2(directory, { recursive: true, force: true });
3677
+ await rm4(directory, { recursive: true, force: true });
3028
3678
  }
3029
3679
  async function rehomeCaptures(cwd, from, to, attachments) {
3030
3680
  if (attachments.length === 0)
@@ -3033,8 +3683,8 @@ async function rehomeCaptures(cwd, from, to, attachments) {
3033
3683
  const destination = captureDirectory(cwd, to);
3034
3684
  if (source === null || destination === null)
3035
3685
  return [...attachments];
3036
- await mkdir3(join5(cwd, CAPTURES_DIR), { recursive: true });
3037
- await rename(source, destination);
3686
+ await mkdir4(join6(cwd, CAPTURES_DIR), { recursive: true });
3687
+ await rename3(source, destination);
3038
3688
  const prefix = `${CAPTURES_DIR}/${from}/`;
3039
3689
  return attachments.map((attachment) => ({
3040
3690
  ...attachment,
@@ -3045,7 +3695,7 @@ function rehomeText(text3, from, to) {
3045
3695
  return text3.split(`${CAPTURES_DIR}/${from}/`).join(`${CAPTURES_DIR}/${to}/`);
3046
3696
  }
3047
3697
  async function ownCapturesRoot(cwd) {
3048
- const root = join5(cwd, CAPTURES_DIR);
3698
+ const root = join6(cwd, CAPTURES_DIR);
3049
3699
  try {
3050
3700
  const entry = await lstat(root);
3051
3701
  return entry.isDirectory() ? root : null;
@@ -3054,7 +3704,7 @@ async function ownCapturesRoot(cwd) {
3054
3704
  }
3055
3705
  }
3056
3706
  async function isOwnCapture(cwd, file) {
3057
- const root = await realpath(join5(cwd, CAPTURES_DIR)).catch(() => null);
3707
+ const root = await realpath(join6(cwd, CAPTURES_DIR)).catch(() => null);
3058
3708
  if (root === null)
3059
3709
  return false;
3060
3710
  const real = await realpath(resolve(cwd, file)).catch(() => null);
@@ -3068,21 +3718,21 @@ async function pruneCaptures(cwd, keep) {
3068
3718
  if (root === null)
3069
3719
  return void await pruneReferences(cwd);
3070
3720
  try {
3071
- const entries = await readdir2(root, { withFileTypes: true });
3072
- await Promise.all(entries.filter((entry) => entry.isDirectory() && !kept.has(entry.name)).map((entry) => rm2(join5(root, entry.name), { recursive: true, force: true })));
3721
+ const entries = await readdir3(root, { withFileTypes: true });
3722
+ await Promise.all(entries.filter((entry) => entry.isDirectory() && !kept.has(entry.name)).map((entry) => rm4(join6(root, entry.name), { recursive: true, force: true })));
3073
3723
  } catch {
3074
3724
  }
3075
3725
  await pruneReferences(cwd);
3076
3726
  }
3077
3727
  async function pruneReferences(cwd) {
3078
- const references = join5(cwd, REFERENCES_DIR);
3728
+ const references = join6(cwd, REFERENCES_DIR);
3079
3729
  try {
3080
3730
  if (!(await lstat(references)).isDirectory())
3081
3731
  return;
3082
- const entries = await readdir2(references, { withFileTypes: true });
3732
+ const entries = await readdir3(references, { withFileTypes: true });
3083
3733
  const old = Date.now() - 60 * 60 * 1e3;
3084
3734
  await Promise.all(entries.filter((entry) => entry.isFile()).map(async (entry) => {
3085
- const file = join5(references, entry.name);
3735
+ const file = join6(references, entry.name);
3086
3736
  if ((await stat2(file)).mtimeMs < old)
3087
3737
  await unlink(file).catch(() => {
3088
3738
  });
@@ -3093,9 +3743,9 @@ async function pruneReferences(cwd) {
3093
3743
 
3094
3744
  // ../server/dist/branches/worktree.js
3095
3745
  import { execFile, spawn as spawn2 } from "child_process";
3096
- import { rm as rm3 } from "fs/promises";
3746
+ import { rm as rm5 } from "fs/promises";
3097
3747
  import net2 from "net";
3098
- import { join as join6 } from "path";
3748
+ import { join as join7 } from "path";
3099
3749
  import { promisify } from "util";
3100
3750
  var run = promisify(execFile);
3101
3751
  var WORKTREES_DIR = ".leglas/worktrees";
@@ -3137,10 +3787,10 @@ async function answeringHost(port) {
3137
3787
  }
3138
3788
  async function startWorktree(options) {
3139
3789
  const readyTimeoutMs = options.readyTimeoutMs ?? 9e4;
3140
- const path = join6(options.cwd, WORKTREES_DIR, worktreeSlug(options.branch));
3790
+ const path = join7(options.cwd, WORKTREES_DIR, worktreeSlug(options.branch));
3141
3791
  const log = options.onLog ?? (() => {
3142
3792
  });
3143
- await rm3(path, { recursive: true, force: true });
3793
+ await rm5(path, { recursive: true, force: true });
3144
3794
  await run("git", ["worktree", "prune"], { cwd: options.cwd }).catch(() => {
3145
3795
  });
3146
3796
  try {
@@ -3152,7 +3802,7 @@ async function startWorktree(options) {
3152
3802
  const cleanup = async () => {
3153
3803
  await run("git", ["worktree", "remove", "--force", path], { cwd: options.cwd }).catch(() => {
3154
3804
  });
3155
- await rm3(path, { recursive: true, force: true }).catch(() => {
3805
+ await rm5(path, { recursive: true, force: true }).catch(() => {
3156
3806
  });
3157
3807
  };
3158
3808
  try {
@@ -3201,8 +3851,8 @@ async function startAppProcess(options) {
3201
3851
  child = spawn2(substitutePort(options.devCommand, port), {
3202
3852
  cwd: options.cwd,
3203
3853
  shell: true,
3204
- // Own process group, so stopping kills the shell and whatever it spawned
3205
- // rather than orphaning a dev server holding the port.
3854
+ // Own process group, so stopping kills the shell and everything it
3855
+ // spawned instead of orphaning a dev server on the port.
3206
3856
  detached: true,
3207
3857
  stdio: ["ignore", "pipe", "pipe"]
3208
3858
  });
@@ -3448,8 +4098,8 @@ function conversationFailure(code) {
3448
4098
 
3449
4099
  // ../server/dist/requests/annotations.js
3450
4100
  import { randomBytes as randomBytes2 } from "crypto";
3451
- import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile4 } from "fs/promises";
3452
- import { dirname as dirname4, join as join7 } from "path";
4101
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile5 } from "fs/promises";
4102
+ import { dirname as dirname4, join as join8 } from "path";
3453
4103
  var ANNOTATIONS_PATH = ".leglas/annotations.json";
3454
4104
  var NOTE_CAP = 500;
3455
4105
  var SELECTOR_CAP = 300;
@@ -3513,7 +4163,7 @@ function anchorFrom(value) {
3513
4163
  }
3514
4164
  async function readAnnotations(cwd) {
3515
4165
  try {
3516
- const raw = await readFile6(join7(cwd, ANNOTATIONS_PATH), "utf8");
4166
+ const raw = await readFile7(join8(cwd, ANNOTATIONS_PATH), "utf8");
3517
4167
  const parsed2 = parseJson(raw);
3518
4168
  if (!isJsonRecord(parsed2) || !Array.isArray(parsed2.annotations))
3519
4169
  return [];
@@ -3538,9 +4188,9 @@ async function readAnnotations(cwd) {
3538
4188
  }
3539
4189
  }
3540
4190
  async function write(cwd, annotations) {
3541
- const path = join7(cwd, ANNOTATIONS_PATH);
3542
- await mkdir4(dirname4(path), { recursive: true });
3543
- await writeFile4(path, `${JSON.stringify({ annotations }, null, 2)}
4191
+ const path = join8(cwd, ANNOTATIONS_PATH);
4192
+ await mkdir5(dirname4(path), { recursive: true });
4193
+ await writeFile5(path, `${JSON.stringify({ annotations }, null, 2)}
3544
4194
  `, "utf8");
3545
4195
  }
3546
4196
  var writing = Promise.resolve();
@@ -3611,9 +4261,9 @@ function describeAnnotations(annotations) {
3611
4261
  }
3612
4262
 
3613
4263
  // ../server/dist/requests/requests.js
3614
- import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile5 } from "fs/promises";
4264
+ import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
3615
4265
  import { randomBytes as randomBytes3 } from "crypto";
3616
- import { dirname as dirname5, join as join8 } from "path";
4266
+ import { dirname as dirname5, join as join9 } from "path";
3617
4267
  var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
3618
4268
  function variantSlot(url) {
3619
4269
  if (!url.startsWith("/"))
@@ -3792,7 +4442,7 @@ function failureOf(entry) {
3792
4442
  }
3793
4443
  async function readRequests(cwd) {
3794
4444
  try {
3795
- const raw = await readFile7(join8(cwd, REQUESTS_PATH), "utf8");
4445
+ const raw = await readFile8(join9(cwd, REQUESTS_PATH), "utf8");
3796
4446
  const parsed2 = parseJson(raw);
3797
4447
  if (!isJsonRecord(parsed2) || !Array.isArray(parsed2.requests))
3798
4448
  return [];
@@ -3828,9 +4478,9 @@ async function readRequests(cwd) {
3828
4478
  }
3829
4479
  }
3830
4480
  async function writeQueue(cwd, requests) {
3831
- const path = join8(cwd, REQUESTS_PATH);
3832
- await mkdir5(dirname5(path), { recursive: true });
3833
- await writeFile5(path, `${JSON.stringify({ requests }, null, 2)}
4481
+ const path = join9(cwd, REQUESTS_PATH);
4482
+ await mkdir6(dirname5(path), { recursive: true });
4483
+ await writeFile6(path, `${JSON.stringify({ requests }, null, 2)}
3834
4484
  `, "utf8");
3835
4485
  }
3836
4486
  async function appendRequest(cwd, request, id = newRequestId()) {
@@ -3888,12 +4538,12 @@ async function clearRequests(cwd) {
3888
4538
 
3889
4539
  // ../server/dist/agents/runner.js
3890
4540
  import { spawn as nodeSpawn3 } from "child_process";
3891
- import { readFile as readFile9 } from "fs/promises";
3892
- import { join as join9, resolve as resolve2 } from "path";
4541
+ import { readFile as readFile10 } from "fs/promises";
4542
+ import { join as join10, resolve as resolve2 } from "path";
3893
4543
 
3894
4544
  // ../server/dist/agents/claude-agent-session.js
3895
4545
  import { EventEmitter } from "events";
3896
- import { readFile as readFile8, stat as stat3 } from "fs/promises";
4546
+ import { readFile as readFile9, stat as stat3 } from "fs/promises";
3897
4547
  import { extname as extname2 } from "path";
3898
4548
  import { PassThrough } from "stream";
3899
4549
  var INITIALIZE_TIMEOUT_MS = 3e4;
@@ -3923,7 +4573,7 @@ async function claudeContent(prompt, images2) {
3923
4573
  try {
3924
4574
  if ((await stat3(path)).size > IMAGE_MAX_BYTES)
3925
4575
  continue;
3926
- const data = await readFile8(path);
4576
+ const data = await readFile9(path);
3927
4577
  blocks.push({
3928
4578
  type: "image",
3929
4579
  source: { type: "base64", media_type: mediaType, data: data.toString("base64") }
@@ -4056,11 +4706,10 @@ var PersistentClaudeSession = class {
4056
4706
  warmedFor = null;
4057
4707
  closed = false;
4058
4708
  /**
4059
- * Counts every ask for a warm process. The runner fires warm() and
4060
- * release() without awaiting either, so the two race in both directions:
4061
- * a release must outlast a warm that started while it was settling, and
4062
- * must stand down for a warm asked for after it began. The generation a
4063
- * release captured at entry says which side of that it is on.
4709
+ * Counts every ask for a warm process. warm() and release() aren't awaited
4710
+ * and race both ways: a release must outlast a warm that started while it
4711
+ * settled, and stand down for a warm asked for after it began. The generation
4712
+ * it captured says which.
4064
4713
  */
4065
4714
  generation = 0;
4066
4715
  constructor(cwd, allowedCommands, startup) {
@@ -4170,13 +4819,10 @@ var PersistentClaudeSession = class {
4170
4819
  }
4171
4820
  }
4172
4821
  /**
4173
- * Let the native process go without ending the transport.
4174
- *
4175
- * The process is the cost, not this object: a warm session is Claude Code
4176
- * plus every MCP server the user has configured, held for a request that
4177
- * may never come. The runner calls this once nothing has asked for Claude
4178
- * in a while, and the next warm() starts it again. A session id the runner
4179
- * still holds is served by the `claude --resume` fallback in the meantime.
4822
+ * Ends the native process but keeps the transport. A warm session is Claude
4823
+ * Code plus every MCP server the user configured, held for a request that may
4824
+ * not come. Called after an idle spell; the next warm() starts it again, and
4825
+ * a held session id goes through `claude --resume` meanwhile.
4180
4826
  */
4181
4827
  async release() {
4182
4828
  const asOf = this.generation;
@@ -4380,12 +5026,10 @@ var PersistentCodexAppServer = class {
4380
5026
  this.requestTimeoutMs = requestTimeoutMs;
4381
5027
  }
4382
5028
  /**
4383
- * Counts every ask for a warm process. The runner fires warm() and
4384
- * release() without awaiting either, so the two race in both directions:
4385
- * a release must outlast a warm that was queued behind its reset, and must
4386
- * stand down for a warm asked for after it began. A warm queued behind a
4387
- * reset restarts through start() rather than warm(), so waiting its turn
4388
- * does not read as a fresh ask.
5029
+ * Counts every ask for a warm process. warm() and release() aren't awaited
5030
+ * and race both ways: a release must outlast a warm queued behind its reset,
5031
+ * and stand down for a warm asked for after it began. A queued warm restarts
5032
+ * through start(), so waiting doesn't count as a new ask.
4389
5033
  */
4390
5034
  generation = 0;
4391
5035
  warm(_sessionId = null) {
@@ -4506,11 +5150,9 @@ var PersistentCodexAppServer = class {
4506
5150
  }
4507
5151
  }
4508
5152
  /**
4509
- * Let the app-server process go without ending the transport.
4510
- *
4511
- * The runner calls this once nothing has asked for Codex in a while; the
4512
- * next warm() spawns and handshakes again. A thread id the runner still
4513
- * holds is resumed by the new process through `thread/resume`.
5153
+ * Ends the app-server process but keeps the transport. Called after an idle
5154
+ * spell; the next warm() spawns again and resumes a held thread through
5155
+ * `thread/resume`.
4514
5156
  */
4515
5157
  async release() {
4516
5158
  const asOf = this.generation;
@@ -5143,7 +5785,7 @@ function startRunner(options) {
5143
5785
  active = null;
5144
5786
  }
5145
5787
  };
5146
- const registered = () => readFile9(join9(options.cwd, LOCAL_PREVIEWS_PATH), "utf8").catch(() => null);
5788
+ const registered = () => readFile10(join10(options.cwd, LOCAL_PREVIEWS_PATH), "utf8").catch(() => null);
5147
5789
  const handle = async (request, choice) => {
5148
5790
  const allowedCommands = options.leglasCommand === void 0 ? [] : [
5149
5791
  `${options.leglasCommand} show`,
@@ -5187,13 +5829,11 @@ function startRunner(options) {
5187
5829
  retry: observed.retry
5188
5830
  });
5189
5831
  let failure = verdict();
5190
- if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && // The edited flag is only evidence where Leglas has read the vendor's
5191
- // real output. Where it has not, "did not edit" means "was not seen
5192
- // to edit", and rerunning on that is how a half-applied change gets
5193
- // applied twice. Such a vendor keeps the failure card and its Retry.
5194
- activityVerified(resolved.agent) && conversationFailure(failure.code) && // Not redundant with the verdict: a stop that lands between the first
5195
- // child settling and the retry starting finds no child to cancel, so
5196
- // nothing says "cancelled". Stopped still means stopped.
5832
+ if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && // The edited flag is evidence only where Leglas has read the vendor's
5833
+ // real output; elsewhere "didn't edit" means "wasn't seen to edit".
5834
+ // Those vendors keep the failure card and its Retry.
5835
+ activityVerified(resolved.agent) && conversationFailure(failure.code) && // Not redundant: a stop between the first child settling and the retry
5836
+ // starting finds no child to cancel.
5197
5837
  !stopped) {
5198
5838
  sessions.delete(resolved.agent);
5199
5839
  const cold = resolveCommand(choice, request.prompt, null, allowedCommands, images2);
@@ -5338,9 +5978,8 @@ function startRunner(options) {
5338
5978
  snapshot: () => ({ ...state, failedIds: [...failed] }),
5339
5979
  cancel,
5340
5980
  prepare,
5341
- // A nudge during a tick is latched and checked as soon as that tick
5342
- // settles; a nudge between ticks starts immediately. Neither can overlap
5343
- // the active agent.
5981
+ // A nudge during a tick is latched until the tick settles; between ticks it
5982
+ // starts one at once. Neither overlaps the active agent.
5344
5983
  nudge: () => schedule(true)
5345
5984
  };
5346
5985
  }
@@ -5549,7 +6188,7 @@ Sec-WebSocket-Accept: ${accept}\r
5549
6188
  }
5550
6189
 
5551
6190
  // ../server/dist/share/share.js
5552
- import { randomBytes as randomBytes4, randomUUID, timingSafeEqual } from "crypto";
6191
+ import { randomBytes as randomBytes4, randomUUID as randomUUID3, timingSafeEqual } from "crypto";
5553
6192
  import http3 from "http";
5554
6193
  import { posix } from "path";
5555
6194
 
@@ -5870,10 +6509,10 @@ var DEV_CONTROL_ROUTES = [
5870
6509
  /** read, browser-sync 3.0.4: its client surface and server metadata. */
5871
6510
  "/__browser_sync__",
5872
6511
  /**
5873
- * read, webpack-dev-server 6.0.0, which mounts its own surface here: the
5874
- * file listing, `/webpack-dev-server/invalidate`, which forces a rebuild,
5875
- * and `/webpack-dev-server/open-editor`, which calls the same launch-editor
5876
- * package Vite does. The subtree match below takes all three.
6512
+ * read, webpack-dev-server 6.0.0: the file listing,
6513
+ * `/webpack-dev-server/invalidate` (forces a rebuild) and
6514
+ * `/webpack-dev-server/open-editor` (launch-editor again). The subtree match
6515
+ * covers all three.
5877
6516
  */
5878
6517
  "/webpack-dev-server",
5879
6518
  /** reported: Rails Web Console, an interactive server-side REPL. */
@@ -5898,17 +6537,12 @@ var DEV_CONTROL_ROUTES = [
5898
6537
  var DEV_CONTROL_PREFIXES = [
5899
6538
  /** read, Next 16.3.1. Its app assets sit at `/_next/` and stay allowed. */
5900
6539
  "/__nextjs_",
5901
- /**
5902
- * read, Nuxt DevTools 4.0.0-alpha.16. Nuxt's own bundle is at `/_nuxt/`,
5903
- * one underscore and a different prefix, so the app is untouched.
5904
- */
6540
+ /** read, Nuxt DevTools 4.0.0-alpha.16. Nuxt's bundle is at `/_nuxt/`, a different prefix. */
5905
6541
  "/__nuxt_devtools__",
5906
6542
  /**
5907
- * read, Parcel 2.16.4: `__parcel_launch_editor` reads a `file` parameter
5908
- * and calls the same launch-editor code Vite does. Beside it sit
5909
- * `__parcel_source_map`, `__parcel_source_root` and `__parcel_code_frame`,
5910
- * which serve source, and the HMR and health routes, which a viewer has no
5911
- * use for: their live-reload socket is already refused.
6543
+ * read, Parcel 2.16.4: `__parcel_launch_editor` takes a `file` and calls
6544
+ * launch-editor. Its source-map, source-root, code-frame, HMR and health
6545
+ * routes serve source or are useless to a viewer.
5912
6546
  */
5913
6547
  "/__parcel_"
5914
6548
  ];
@@ -6284,7 +6918,7 @@ function createShareManager(options) {
6284
6918
  token = randomBytes4(24).toString("base64url");
6285
6919
  const at = now();
6286
6920
  const grant = {
6287
- id: randomUUID(),
6921
+ id: randomUUID3(),
6288
6922
  name,
6289
6923
  token,
6290
6924
  createdAt: at,
@@ -6599,7 +7233,7 @@ function createShareManager(options) {
6599
7233
  }
6600
7234
  const share = {
6601
7235
  ...parsed2.manifest,
6602
- id: randomUUID(),
7236
+ id: randomUUID3(),
6603
7237
  grants: /* @__PURE__ */ new Map(),
6604
7238
  tombstones: [],
6605
7239
  port,
@@ -6630,8 +7264,8 @@ function createShareManager(options) {
6630
7264
  share.runningTunnel = runTunnel({
6631
7265
  provider,
6632
7266
  port,
6633
- // Whichever link exists when the tunnel starts: the probe only
6634
- // needs a path the listener answers, and a share always has one.
7267
+ // Whichever link exists when the tunnel starts; a share always has
7268
+ // one, and the probe only needs a path the listener answers.
6635
7269
  entryPath: `${ENTRY_PREFIX}${Array.from(share.grants.values())[0]?.token ?? ""}`,
6636
7270
  onState: (next) => {
6637
7271
  if (active !== share || JSON.stringify(share.tunnel) === JSON.stringify(next))
@@ -6784,11 +7418,9 @@ function createShareManager(options) {
6784
7418
  return null;
6785
7419
  return {
6786
7420
  ...options.viewerConfig,
6787
- // The project id is the config's absolute path, which keys the sharer's
6788
- // saved layout and names their machine's directories, and the dev
6789
- // server's address names their network. A viewer keeps no layout and
6790
- // never dials the dev server, so the share's own id does the job and
6791
- // the address is left blank.
7421
+ // The project id is the config's absolute path and the dev server address
7422
+ // names the sharer's network. Viewers keep no layout and never dial the
7423
+ // dev server, so they get the share's id and a blank address.
6792
7424
  project: `share:${share.id}`,
6793
7425
  devServer: "",
6794
7426
  previews: options.previewsForConfig(previews),
@@ -6856,12 +7488,12 @@ function createShareManager(options) {
6856
7488
  }
6857
7489
 
6858
7490
  // ../server/dist/config/renames.js
6859
- import { mkdir as mkdir6, readFile as readFile10, writeFile as writeFile6 } from "fs/promises";
6860
- import { dirname as dirname6, join as join10 } from "path";
7491
+ import { mkdir as mkdir7, readFile as readFile11, writeFile as writeFile7 } from "fs/promises";
7492
+ import { dirname as dirname6, join as join11 } from "path";
6861
7493
  var RENAMES_PATH = ".leglas/renames.json";
6862
7494
  async function readRenames(cwd) {
6863
7495
  try {
6864
- const raw = await readFile10(join10(cwd, RENAMES_PATH), "utf8");
7496
+ const raw = await readFile11(join11(cwd, RENAMES_PATH), "utf8");
6865
7497
  const parsed2 = parseJson(raw);
6866
7498
  if (!isJsonRecord(parsed2) || !isJsonRecord(parsed2.renames) && !Array.isArray(parsed2.renames))
6867
7499
  return {};
@@ -6871,9 +7503,9 @@ async function readRenames(cwd) {
6871
7503
  }
6872
7504
  }
6873
7505
  async function writeRenames(cwd, renames) {
6874
- const path = join10(cwd, RENAMES_PATH);
6875
- await mkdir6(dirname6(path), { recursive: true });
6876
- await writeFile6(path, `${JSON.stringify({ renames }, null, 2)}
7506
+ const path = join11(cwd, RENAMES_PATH);
7507
+ await mkdir7(dirname6(path), { recursive: true });
7508
+ await writeFile7(path, `${JSON.stringify({ renames }, null, 2)}
6877
7509
  `, "utf8");
6878
7510
  }
6879
7511
  function resolveTitle(input, titles, renames) {
@@ -6890,10 +7522,10 @@ function resolveTitle(input, titles, renames) {
6890
7522
  // ../server/dist/server.js
6891
7523
  import { createReadStream, existsSync as existsSync3, statSync, unwatchFile, watch as watchFs, watchFile } from "fs";
6892
7524
  import { createHash as createHash2 } from "crypto";
6893
- import { mkdir as mkdir8, readdir as readdir5, writeFile as writeFile9 } from "fs/promises";
7525
+ import { mkdir as mkdir9, readdir as readdir6, writeFile as writeFile10 } from "fs/promises";
6894
7526
  import http4 from "http";
6895
7527
  import net3 from "net";
6896
- import { basename as basename3, dirname as dirname10, extname as extname3, join as join16, normalize as normalize2, relative as relative4 } from "path";
7528
+ import { basename as basename3, dirname as dirname10, extname as extname3, join as join17, normalize as normalize4, relative as relative4 } from "path";
6897
7529
 
6898
7530
  // ../server/dist/frame-policy.js
6899
7531
  var FRAMING_TIMEOUT_MS = 4e3;
@@ -7006,8 +7638,8 @@ async function checkFraming(target, embedder, fetcher = fetch) {
7006
7638
 
7007
7639
  // ../server/dist/generation/generation.js
7008
7640
  import { spawn as nodeSpawn4 } from "child_process";
7009
- import { readFile as readFile14, writeFile as writeFile7 } from "fs/promises";
7010
- import { dirname as dirname8, join as join14 } from "path";
7641
+ import { readFile as readFile15, writeFile as writeFile8 } from "fs/promises";
7642
+ import { dirname as dirname8, join as join15, normalize as normalize3 } from "path";
7011
7643
 
7012
7644
  // ../server/dist/generation/claude.js
7013
7645
  var GENERATION_EFFORT = "medium";
@@ -7050,9 +7682,9 @@ function resultText(lines2) {
7050
7682
  }
7051
7683
 
7052
7684
  // ../server/dist/generation/codex.js
7053
- import { readFile as readFile11 } from "fs/promises";
7685
+ import { readFile as readFile12 } from "fs/promises";
7054
7686
  import { homedir as homedir2 } from "os";
7055
- import { join as join11 } from "path";
7687
+ import { join as join12 } from "path";
7056
7688
  var RESTRICTED2 = [
7057
7689
  "exec",
7058
7690
  "--json",
@@ -7066,8 +7698,8 @@ var RESTRICTED2 = [
7066
7698
  "skills.include_instructions=false",
7067
7699
  "-c",
7068
7700
  "skills.bundled.enabled=false",
7069
- // Through the config, not `--disable`: a Codex that lacks one of these
7070
- // ignores it here, where `--disable` refuses to start at all.
7701
+ // Through the config, not `--disable`: an unknown feature is ignored here,
7702
+ // while `--disable` refuses to start.
7071
7703
  ...[
7072
7704
  "multi_agent",
7073
7705
  "apps",
@@ -7083,7 +7715,7 @@ var BARE = /^[A-Za-z0-9_-]+$/;
7083
7715
  async function codexServers(home = defaultCodexHome()) {
7084
7716
  let config;
7085
7717
  try {
7086
- config = await readFile11(join11(home, "config.toml"), "utf8");
7718
+ config = await readFile12(join12(home, "config.toml"), "utf8");
7087
7719
  } catch {
7088
7720
  return [];
7089
7721
  }
@@ -7096,7 +7728,7 @@ async function codexServers(home = defaultCodexHome()) {
7096
7728
  return [...names];
7097
7729
  }
7098
7730
  function defaultCodexHome(env = process.env) {
7099
- return env.CODEX_HOME !== void 0 && env.CODEX_HOME !== "" ? env.CODEX_HOME : join11(homedir2(), ".codex");
7731
+ return env.CODEX_HOME !== void 0 && env.CODEX_HOME !== "" ? env.CODEX_HOME : join12(homedir2(), ".codex");
7100
7732
  }
7101
7733
  var serversOff = (servers) => servers.flatMap((name) => ["-c", `mcp_servers.${name}.enabled=false`]);
7102
7734
  function codexPlanArgs(prompt, servers) {
@@ -7122,12 +7754,12 @@ function codexResultText(lines2) {
7122
7754
  }
7123
7755
 
7124
7756
  // ../server/dist/generation/facts.js
7125
- import { readFile as readFile13, readdir as readdir4 } from "fs/promises";
7126
- import { dirname as dirname7, join as join13, normalize } from "path";
7757
+ import { readFile as readFile14, readdir as readdir5 } from "fs/promises";
7758
+ import { dirname as dirname7, join as join14, normalize as normalize2 } from "path";
7127
7759
 
7128
7760
  // ../server/dist/generation/switch-file.js
7129
- import { readFile as readFile12, readdir as readdir3, stat as stat4 } from "fs/promises";
7130
- import { join as join12, relative as relative3 } from "path";
7761
+ import { readFile as readFile13, readdir as readdir4, stat as stat4 } from "fs/promises";
7762
+ import { join as join13, relative as relative3 } from "path";
7131
7763
  var MAP = /const DIRECTIONS = \{([\s\S]*?)\} as const;/;
7132
7764
  var SOURCE = /\.(tsx|jsx|ts|js)$/;
7133
7765
  var SCAN_LIMIT = 3e3;
@@ -7140,8 +7772,8 @@ async function isFile(path) {
7140
7772
  }
7141
7773
  async function findSwitch(cwd, slug) {
7142
7774
  for (const name of ["switch.tsx", "switch.jsx"]) {
7143
- const path = join12(".leglas", "variants", slug, name);
7144
- if (await isFile(join12(cwd, path)))
7775
+ const path = join13(".leglas", "variants", slug, name);
7776
+ if (await isFile(join13(cwd, path)))
7145
7777
  return path;
7146
7778
  }
7147
7779
  const wanted = `"v-${slug}"`;
@@ -7149,14 +7781,14 @@ async function findSwitch(cwd, slug) {
7149
7781
  const walk = async (folder) => {
7150
7782
  let items;
7151
7783
  try {
7152
- items = await readdir3(join12(cwd, folder), { withFileTypes: true });
7784
+ items = await readdir4(join13(cwd, folder), { withFileTypes: true });
7153
7785
  } catch {
7154
7786
  return null;
7155
7787
  }
7156
7788
  for (const item of items) {
7157
7789
  if (seen >= SCAN_LIMIT || item.name.startsWith(".") || item.name === "node_modules")
7158
7790
  continue;
7159
- const path = join12(folder, item.name);
7791
+ const path = join13(folder, item.name);
7160
7792
  if (item.isDirectory()) {
7161
7793
  const found = await walk(path);
7162
7794
  if (found !== null)
@@ -7166,7 +7798,7 @@ async function findSwitch(cwd, slug) {
7166
7798
  if (!SOURCE.test(item.name))
7167
7799
  continue;
7168
7800
  seen += 1;
7169
- const source = await readFile12(join12(cwd, path), "utf8").catch(() => "");
7801
+ const source = await readFile13(join13(cwd, path), "utf8").catch(() => "");
7170
7802
  if (source.includes(wanted) && MAP.test(source))
7171
7803
  return path;
7172
7804
  }
@@ -7175,7 +7807,7 @@ async function findSwitch(cwd, slug) {
7175
7807
  for (const root of ["src", "app"]) {
7176
7808
  const found = await walk(root);
7177
7809
  if (found !== null)
7178
- return relative3(cwd, join12(cwd, found));
7810
+ return relative3(cwd, join13(cwd, found));
7179
7811
  }
7180
7812
  return null;
7181
7813
  }
@@ -7243,21 +7875,21 @@ function clip(source, limit) {
7243
7875
  var BASELINE_LINES = 15;
7244
7876
  var STUB = /Re-export what your|A first direction for/;
7245
7877
  async function text2(path) {
7246
- return readFile13(path, "utf8").catch(() => null);
7878
+ return readFile14(path, "utf8").catch(() => null);
7247
7879
  }
7248
7880
  async function firstFile(cwd, candidates) {
7249
7881
  for (const candidate of candidates) {
7250
- if (await text2(join13(cwd, candidate)) !== null)
7882
+ if (await text2(join14(cwd, candidate)) !== null)
7251
7883
  return candidate;
7252
7884
  }
7253
7885
  return null;
7254
7886
  }
7255
7887
  function cssImports(source, from) {
7256
- return [...source.matchAll(/import\s+["']([^"']+\.css)["']/g)].map((match) => normalize(join13(dirname7(from), match[1] ?? "")));
7888
+ return [...source.matchAll(/import\s+["']([^"']+\.css)["']/g)].map((match) => normalize2(join14(dirname7(from), match[1] ?? "")));
7257
7889
  }
7258
7890
  async function globalStylesheet(cwd) {
7259
7891
  const entries = [];
7260
- const html = await text2(join13(cwd, "index.html"));
7892
+ const html = await text2(join14(cwd, "index.html"));
7261
7893
  const script = html === null ? null : /<script[^>]+type="module"[^>]+src="\/?([^"]+)"/.exec(html);
7262
7894
  if (script?.[1] !== void 0)
7263
7895
  entries.push(script[1]);
@@ -7267,12 +7899,12 @@ async function globalStylesheet(cwd) {
7267
7899
  }
7268
7900
  const sheets = [];
7269
7901
  for (const entry of entries) {
7270
- const source = await text2(join13(cwd, entry));
7902
+ const source = await text2(join14(cwd, entry));
7271
7903
  if (source !== null)
7272
7904
  sheets.push(...cssImports(source, entry));
7273
7905
  }
7274
7906
  for (const sheet of sheets) {
7275
- const source = await text2(join13(cwd, sheet));
7907
+ const source = await text2(join14(cwd, sheet));
7276
7908
  if (source !== null && /:root\s*\{|@font-face/.test(source))
7277
7909
  return sheet;
7278
7910
  }
@@ -7283,7 +7915,7 @@ async function images(cwd) {
7283
7915
  const walk = async (folder) => {
7284
7916
  let items;
7285
7917
  try {
7286
- items = await readdir4(join13(cwd, "public", folder), { withFileTypes: true });
7918
+ items = await readdir5(join14(cwd, "public", folder), { withFileTypes: true });
7287
7919
  } catch {
7288
7920
  return;
7289
7921
  }
@@ -7301,14 +7933,14 @@ async function images(cwd) {
7301
7933
  return found;
7302
7934
  }
7303
7935
  async function componentFile(cwd, from, specifier) {
7304
- const base = normalize(join13(dirname7(from), specifier));
7936
+ const base = normalize2(join14(dirname7(from), specifier));
7305
7937
  return firstFile(cwd, [
7306
7938
  base,
7307
7939
  ...["tsx", "jsx", "ts", "js"].map((extension) => `${base}.${extension}`)
7308
7940
  ]);
7309
7941
  }
7310
7942
  async function example(cwd, switchPath, prefer) {
7311
- const source = await text2(join13(cwd, switchPath));
7943
+ const source = await text2(join14(cwd, switchPath));
7312
7944
  if (source === null)
7313
7945
  return null;
7314
7946
  const directions = readDirections(source);
@@ -7317,13 +7949,13 @@ async function example(cwd, switchPath, prefer) {
7317
7949
  for (const direction of ordered) {
7318
7950
  const limit = direction.key === prefer ? VARIED_LINES : EXAMPLE_LINES;
7319
7951
  const path = await componentFile(cwd, switchPath, direction.from);
7320
- const body = path === null ? null : await text2(join13(cwd, path));
7952
+ const body = path === null ? null : await text2(join14(cwd, path));
7321
7953
  if (path === null || body === null || body === placeholderSource(direction.name) || STUB.test(body))
7322
7954
  continue;
7323
7955
  if (body.split("\n").length < BASELINE_LINES) {
7324
7956
  const target = /from\s+["'](\.[^"']+)["']/.exec(body)?.[1];
7325
7957
  const followed = target === void 0 ? null : await componentFile(cwd, path, target);
7326
- const real = followed === null ? null : await text2(join13(cwd, followed));
7958
+ const real = followed === null ? null : await text2(join14(cwd, followed));
7327
7959
  if (followed !== null && real !== null) {
7328
7960
  return {
7329
7961
  key: direction.key,
@@ -7339,7 +7971,7 @@ async function example(cwd, switchPath, prefer) {
7339
7971
  }
7340
7972
  async function dependencyNames(cwd) {
7341
7973
  try {
7342
- const pkg = parseJson(await text2(join13(cwd, "package.json")) ?? "");
7974
+ const pkg = parseJson(await text2(join14(cwd, "package.json")) ?? "");
7343
7975
  return isJsonRecord(pkg) && isJsonRecord(pkg.dependencies) ? Object.keys(pkg.dependencies) : [];
7344
7976
  } catch {
7345
7977
  return [];
@@ -7348,7 +7980,7 @@ async function dependencyNames(cwd) {
7348
7980
  async function readProjectFacts(cwd, switchPath, prefer = null) {
7349
7981
  const dependencies = await dependencyNames(cwd);
7350
7982
  const sheetPath = await globalStylesheet(cwd);
7351
- const css = sheetPath === null ? null : await text2(join13(cwd, sheetPath));
7983
+ const css = sheetPath === null ? null : await text2(join14(cwd, sheetPath));
7352
7984
  return {
7353
7985
  dependencies,
7354
7986
  stylesheet: sheetPath === null || css === null ? null : {
@@ -7425,7 +8057,13 @@ ${input.facts}
7425
8057
  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.`;
7426
8058
  }
7427
8059
  function fixPrompt(input) {
7428
- 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.`;
8060
+ const layout = input.layout ?? [];
8061
+ const reported = `Leglas rendered ${input.file} and the page reported: ${input.errors.slice(0, 3).join(" | ")}.`;
8062
+ if (layout.length === 0) {
8063
+ 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.`;
8064
+ }
8065
+ const opening = input.errors.length === 0 ? `Leglas rendered ${input.file}.` : reported;
8066
+ 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.`;
7429
8067
  }
7430
8068
  function kebab(value) {
7431
8069
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -7476,6 +8114,16 @@ var OWN_FAILURES = /* @__PURE__ */ new Set([
7476
8114
  ]);
7477
8115
  var BUSY = "A set of directions is already being built. Wait for it, or stop it first.";
7478
8116
  var KEPT_JOBS = 5;
8117
+ var guardKey = (slot, attempt) => `${slot.key}#${attempt}`;
8118
+ function listed(items) {
8119
+ return items.length <= 1 ? items[0] ?? "" : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
8120
+ }
8121
+ function strayMessage(paths, restored, named) {
8122
+ const which = paths.length === 1 ? "which is not its own file" : "which are not its own files";
8123
+ const back = restored.length === 0 ? "" : restored.length === paths.length ? ` and put ${paths.length === 1 ? "the file" : "them"} back` : ` and put ${listed(restored)} back`;
8124
+ const check = named.length === 0 ? "." : `. Check ${listed(named)} before trying again.`;
8125
+ return `The build edited ${listed(paths)}, ${which}, so Leglas stopped it${back}${check}`;
8126
+ }
7479
8127
  var CLAUDE = {
7480
8128
  id: "claude",
7481
8129
  name: "Claude",
@@ -7492,6 +8140,10 @@ function codex(servers) {
7492
8140
  result: codexResultText
7493
8141
  };
7494
8142
  }
8143
+ function argsOf(agent) {
8144
+ const mark = "\0prompt";
8145
+ return agent.build(mark).map((arg) => arg === mark ? "<prompt>" : arg);
8146
+ }
7495
8147
  function wait(milliseconds) {
7496
8148
  return new Promise((resolve5) => {
7497
8149
  setTimeout(resolve5, milliseconds).unref?.();
@@ -7512,7 +8164,7 @@ function baseOf(preview, surface) {
7512
8164
  async function stackOf(cwd, switchPath) {
7513
8165
  let deps = {};
7514
8166
  try {
7515
- const pkg = parseJson(await readFile14(join14(cwd, "package.json"), "utf8"));
8167
+ const pkg = parseJson(await readFile15(join15(cwd, "package.json"), "utf8"));
7516
8168
  if (isJsonRecord(pkg)) {
7517
8169
  for (const group of [pkg.devDependencies, pkg.dependencies]) {
7518
8170
  if (!isJsonRecord(group))
@@ -7560,6 +8212,81 @@ function createGenerations(deps) {
7560
8212
  let closed = false;
7561
8213
  const busy = () => lives.some((live) => live.job.state === "planning" || live.job.state === "building");
7562
8214
  const changed = () => deps.onChange();
8215
+ const records = createRecords(deps.cwd);
8216
+ const runRecord = (prompt) => ({
8217
+ prompt,
8218
+ startedAt: now(),
8219
+ endedAt: null,
8220
+ exitCode: null,
8221
+ timedOut: false,
8222
+ error: null,
8223
+ result: null,
8224
+ tail: []
8225
+ });
8226
+ const ended = (agent, record3, outcome) => {
8227
+ const failed = outcome.timedOut || outcome.error !== null || outcome.code !== 0;
8228
+ record3.endedAt = now();
8229
+ record3.exitCode = outcome.code;
8230
+ record3.timedOut = outcome.timedOut;
8231
+ record3.error = outcome.error;
8232
+ record3.result = agent.result(outcome.lines);
8233
+ record3.tail = failed ? outcome.lines.slice(-40) : [];
8234
+ };
8235
+ const attemptRecord = (live, key, attempt) => live.record?.directions.find((direction) => direction.key === key)?.attempts.find((each) => each.attempt === attempt);
8236
+ const opened = (live, slot, attempt, cause) => {
8237
+ live.record?.directions.find((direction) => direction.key === slot.key)?.attempts.push({
8238
+ attempt,
8239
+ cause,
8240
+ title: slot.title,
8241
+ idea: slot.idea,
8242
+ ideaRun: null,
8243
+ build: null,
8244
+ renders: [],
8245
+ fix: null,
8246
+ state: slot.state,
8247
+ failure: null,
8248
+ fixed: false,
8249
+ layout: [],
8250
+ picture: null
8251
+ });
8252
+ };
8253
+ const saveRecord = (live) => {
8254
+ const { job, record: record3 } = live;
8255
+ if (record3 === null)
8256
+ return;
8257
+ record3.state = job.state;
8258
+ record3.error = job.error;
8259
+ record3.plannedAt = job.plannedAt;
8260
+ record3.endedAt = job.endedAt;
8261
+ for (const slot of job.slots) {
8262
+ const latest = record3.directions.find((each) => each.key === slot.key)?.attempts.at(-1);
8263
+ if (latest === void 0)
8264
+ continue;
8265
+ latest.title = slot.title;
8266
+ latest.idea = slot.idea;
8267
+ latest.state = slot.state;
8268
+ latest.failure = slot.failure === null ? null : { ...slot.failure };
8269
+ latest.fixed = slot.fixed;
8270
+ latest.layout = [...slot.layout];
8271
+ }
8272
+ records.save(record3);
8273
+ };
8274
+ const noted = (live, kind, direction) => {
8275
+ if (live.record === null)
8276
+ return;
8277
+ records.event(live.record.id, { at: now(), kind, direction });
8278
+ saveRecord(live);
8279
+ };
8280
+ const rendered = (live, slot, attempt, report) => {
8281
+ const record3 = attemptRecord(live, slot.key, attempt);
8282
+ if (live.record === null || record3 === void 0)
8283
+ return;
8284
+ record3.renders.push({ at: now(), errors: [...report.errors], layout: [...report.layout] });
8285
+ if (report.frame === null)
8286
+ return;
8287
+ record3.picture = `${slot.key}-${attempt}.jpg`;
8288
+ records.picture(live.record.id, record3.picture, report.frame);
8289
+ };
7563
8290
  let registry = Promise.resolve();
7564
8291
  const serially = (task) => {
7565
8292
  const next = registry.then(task, task);
@@ -7620,8 +8347,10 @@ function createGenerations(deps) {
7620
8347
  });
7621
8348
  child.once("close", (code) => {
7622
8349
  clearTimeout(deadline);
7623
- if (buffered !== "")
8350
+ if (buffered !== "") {
7624
8351
  lines2.push(buffered);
8352
+ onLine?.(buffered);
8353
+ }
7625
8354
  resolve5({ code, lines: lines2, error: null, timedOut: timedOut2 });
7626
8355
  });
7627
8356
  });
@@ -7664,7 +8393,7 @@ function createGenerations(deps) {
7664
8393
  job.endedAt = now();
7665
8394
  };
7666
8395
  const restore = async (slot) => {
7667
- await writeFile7(join14(deps.cwd, slot.file), placeholderSource(pascal(slot.key)), "utf8").catch(() => {
8396
+ await writeFile8(join15(deps.cwd, slot.file), placeholderSource(pascal(slot.key)), "utf8").catch(() => {
7668
8397
  });
7669
8398
  };
7670
8399
  const begin = (live, slot) => {
@@ -7721,8 +8450,85 @@ function createGenerations(deps) {
7721
8450
  fail(live, slot, { code: "unexpected", message: message2 });
7722
8451
  settle(live);
7723
8452
  changed();
8453
+ saveRecord(live);
8454
+ };
8455
+ const strayed = async (live, slot, attempt) => {
8456
+ const guard = live.guards.get(guardKey(slot, attempt));
8457
+ if (guard === void 0 || guard.strays.size === 0)
8458
+ return null;
8459
+ const paths = [...guard.strays];
8460
+ const restored = [];
8461
+ const named = [];
8462
+ const building = new Set(lives.flatMap((other) => other.job.slots.flatMap((each) => each.state === "building" || each.state === "checking" ? [normalize3(each.file)] : [])));
8463
+ for (const path of paths) {
8464
+ const kept = guard.kept.get(path);
8465
+ if (kept === void 0 || building.has(path)) {
8466
+ named.push(path);
8467
+ continue;
8468
+ }
8469
+ const back = await writeFile8(join15(deps.cwd, path), kept, "utf8").then(() => true, () => false);
8470
+ (back ? restored : named).push(path);
8471
+ }
8472
+ guard.strays.clear();
8473
+ return {
8474
+ failure: { code: "outside-file", message: strayMessage(paths, restored, named) },
8475
+ putBack: named.length === 0
8476
+ };
8477
+ };
8478
+ const pendingStrays = (except) => {
8479
+ const pending = /* @__PURE__ */ new Map();
8480
+ for (const [key, guard] of lives.flatMap((other) => [...other.guards.entries()])) {
8481
+ if (except !== void 0 && key.startsWith(`${except.key}#`))
8482
+ continue;
8483
+ for (const path of guard.strays) {
8484
+ const kept = guard.kept.get(path);
8485
+ if (kept !== void 0)
8486
+ pending.set(path, kept);
8487
+ }
8488
+ }
8489
+ return pending;
8490
+ };
8491
+ const keptFiles = async (live, own = new Set(live.job.slots.map((slot) => normalize3(slot.file)))) => {
8492
+ const switchPath = live.switchPath;
8493
+ const pending = pendingStrays();
8494
+ const read = async (path) => pending.get(normalize3(path)) ?? await readFile15(join15(deps.cwd, path), "utf8").catch(() => null);
8495
+ const source = await read(switchPath);
8496
+ const kept = /* @__PURE__ */ new Map();
8497
+ if (source === null)
8498
+ return kept;
8499
+ kept.set(normalize3(switchPath), source);
8500
+ const keep = async (path) => {
8501
+ if (path === null || own.has(normalize3(path)) || kept.has(normalize3(path)))
8502
+ return null;
8503
+ const body = await read(path);
8504
+ if (body !== null)
8505
+ kept.set(normalize3(path), body);
8506
+ return body;
8507
+ };
8508
+ for (const direction of readDirections(source)) {
8509
+ const path = await componentFile(deps.cwd, switchPath, direction.from);
8510
+ const body = await keep(path);
8511
+ if (path === null || body === null)
8512
+ continue;
8513
+ for (const match of body.matchAll(/(?:from\s+|import\s*\(?\s*)["'](\.{1,2}\/[^"']+)["']/g)) {
8514
+ await keep(await componentFile(deps.cwd, path, match[1] ?? ""));
8515
+ }
8516
+ }
8517
+ return kept;
7724
8518
  };
7725
8519
  const follow = (live, slot, attempt) => (line) => {
8520
+ const guard = live.guards.get(guardKey(slot, attempt));
8521
+ for (const path of editedFiles(live.agent.id, line, deps.cwd)) {
8522
+ if (guard === void 0 || path === normalize3(slot.file) || guard.strays.has(path))
8523
+ continue;
8524
+ guard.strays.add(path);
8525
+ if (guard.strays.size > 1 || !owns(live, slot, attempt))
8526
+ continue;
8527
+ const running = live.runs.get(slot.key);
8528
+ if (running !== void 0)
8529
+ detach(live, running.stop(), () => {
8530
+ });
8531
+ }
7726
8532
  const activity = activityFrom(live.agent.id, line, deps.cwd);
7727
8533
  if (activity === null || activity === slot.activity || !owns(live, slot, attempt))
7728
8534
  return;
@@ -7739,8 +8545,21 @@ function createGenerations(deps) {
7739
8545
  slot.endedAt = null;
7740
8546
  slot.failure = null;
7741
8547
  slot.fixed = false;
8548
+ slot.layout = [];
7742
8549
  slot.activity = null;
7743
8550
  changed();
8551
+ const key = guardKey(slot, attempt);
8552
+ if (!live.guards.has(key))
8553
+ live.guards.set(key, { kept: await keptFiles(live), strays: /* @__PURE__ */ new Set() });
8554
+ try {
8555
+ if (!owns(live, slot, attempt))
8556
+ return;
8557
+ await buildOwned(live, slot, attempt, concept);
8558
+ } finally {
8559
+ live.guards.delete(key);
8560
+ }
8561
+ };
8562
+ const buildOwned = async (live, slot, attempt, concept) => {
7744
8563
  const others = [...live.concepts.values()].filter((other) => other !== concept);
7745
8564
  const prompt = buildPrompt({
7746
8565
  surface: live.job.surface,
@@ -7753,17 +8572,26 @@ function createGenerations(deps) {
7753
8572
  facts: live.facts ?? "",
7754
8573
  base: live.base
7755
8574
  });
8575
+ const buildRun = runRecord(prompt);
8576
+ const own = attemptRecord(live, slot.key, attempt);
8577
+ if (own !== void 0)
8578
+ own.build = buildRun;
7756
8579
  const building = run4(live.agent, live.agent.build(prompt), BUILD_DEADLINE_MS, follow(live, slot, attempt));
7757
8580
  live.runs.set(slot.key, building);
7758
8581
  const outcome = await building.done;
8582
+ ended(live.agent, buildRun, outcome);
8583
+ saveRecord(live);
8584
+ const outside = await strayed(live, slot, attempt);
7759
8585
  if (!owns(live, slot, attempt))
7760
8586
  return;
7761
8587
  if (live.runs.get(slot.key) === building)
7762
8588
  live.runs.delete(slot.key);
7763
- const written = await readFile14(join14(deps.cwd, slot.file), "utf8").catch(() => "");
8589
+ const written = await readFile15(join15(deps.cwd, slot.file), "utf8").catch(() => "");
7764
8590
  if (!owns(live, slot, attempt))
7765
8591
  return;
7766
- if (outcome.timedOut) {
8592
+ if (outside !== null) {
8593
+ fail(live, slot, outside.failure);
8594
+ } else if (outcome.timedOut) {
7767
8595
  fail(live, slot, {
7768
8596
  code: "too-slow",
7769
8597
  message: `The build took longer than ${BUILD_DEADLINE_MS / 6e4} minutes, so Leglas stopped it.`
@@ -7783,19 +8611,25 @@ function createGenerations(deps) {
7783
8611
  }
7784
8612
  settle(live);
7785
8613
  changed();
8614
+ saveRecord(live);
7786
8615
  };
7787
- const ownErrors = async (live, slot, attempt) => {
8616
+ const ownReport = async (live, slot, attempt) => {
7788
8617
  const others = live.job.slots.filter((other) => other !== slot);
7789
8618
  const until = now() + BUILD_DEADLINE_MS + FIX_DEADLINE_MS;
7790
8619
  for (; ; ) {
7791
- const report = await deps.render(slot.title);
8620
+ const report = await deps.render(slot.title, { picture: live.record !== null });
7792
8621
  if (!owns(live, slot, attempt))
7793
8622
  return null;
7794
8623
  const errors = report?.errors ?? [];
7795
8624
  const names = (file) => errors.some((error) => error.includes(file));
7796
- const waiting = !names(slot.file) && others.some((other) => (other.state === "building" || other.state === "checking") && names(other.file));
8625
+ const pending = [...pendingStrays(slot).keys()];
8626
+ const waiting = !names(slot.file) && (pending.some(names) || others.some((other) => (other.state === "building" || other.state === "checking") && names(other.file)));
7797
8627
  if (!waiting || now() >= until) {
7798
- return errors.filter((error) => !others.some((other) => error.includes(other.file)));
8628
+ return {
8629
+ errors: errors.filter((error) => !others.some((other) => error.includes(other.file))),
8630
+ layout: [...report?.layout ?? []],
8631
+ frame: report?.frame ?? null
8632
+ };
7799
8633
  }
7800
8634
  await wait(RECHECK_MS);
7801
8635
  if (!owns(live, slot, attempt))
@@ -7803,35 +8637,67 @@ function createGenerations(deps) {
7803
8637
  }
7804
8638
  };
7805
8639
  const check = async (live, slot, attempt) => {
7806
- let errors = await ownErrors(live, slot, attempt);
7807
- if (errors === null)
8640
+ let report = await ownReport(live, slot, attempt);
8641
+ if (report === null)
7808
8642
  return;
7809
- if (errors.length > 0) {
7810
- const fixing = run4(live.agent, live.agent.build(fixPrompt({ file: slot.file, errors })), FIX_DEADLINE_MS, follow(live, slot, attempt));
8643
+ rendered(live, slot, attempt, report);
8644
+ if (report.errors.length > 0 || report.layout.length > 0) {
8645
+ const repairs = report.errors.length > 0;
8646
+ const found = report.layout;
8647
+ const working = repairs ? null : await readFile15(join15(deps.cwd, slot.file), "utf8").catch(() => null);
8648
+ if (!owns(live, slot, attempt))
8649
+ return;
8650
+ const prompt = fixPrompt({ file: slot.file, errors: report.errors, layout: report.layout });
8651
+ const fixRun = runRecord(prompt);
8652
+ const own = attemptRecord(live, slot.key, attempt);
8653
+ if (own !== void 0)
8654
+ own.fix = fixRun;
8655
+ const fixing = run4(live.agent, live.agent.build(prompt), FIX_DEADLINE_MS, follow(live, slot, attempt));
7811
8656
  live.runs.set(slot.key, fixing);
7812
8657
  const outcome = await fixing.done;
8658
+ ended(live.agent, fixRun, outcome);
8659
+ const outside = await strayed(live, slot, attempt);
7813
8660
  if (!owns(live, slot, attempt))
7814
8661
  return;
7815
8662
  slot.activity = null;
7816
8663
  changed();
7817
8664
  if (live.runs.get(slot.key) === fixing)
7818
8665
  live.runs.delete(slot.key);
7819
- if (outcome.timedOut || outcome.error !== null || outcome.code !== 0) {
7820
- fail(live, slot, outcome.timedOut ? { code: "too-slow", message: "The fix took too long, so Leglas stopped it." } : failureOf2(live.agent, outcome));
7821
- return;
8666
+ 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);
8667
+ if (failure === null) {
8668
+ slot.fixed = repairs;
8669
+ report = await ownReport(live, slot, attempt);
8670
+ if (report === null)
8671
+ return;
8672
+ rendered(live, slot, attempt, report);
8673
+ if (report.errors.length > 0) {
8674
+ failure = {
8675
+ code: "broken",
8676
+ message: `The page still reports: ${report.errors[0] ?? ""}`
8677
+ };
8678
+ }
7822
8679
  }
7823
- slot.fixed = true;
7824
- errors = await ownErrors(live, slot, attempt);
7825
- if (errors === null)
7826
- return;
7827
- if (errors.length > 0) {
7828
- fail(live, slot, {
7829
- code: "broken",
7830
- message: `The page still reports: ${errors[0] ?? ""}`
7831
- });
7832
- return;
8680
+ const worse = failure === null && !repairs && report.layout.length > found.length;
8681
+ if (failure !== null || worse) {
8682
+ if (working !== null && outside?.putBack !== false) {
8683
+ const restored = await writeFile8(join15(deps.cwd, slot.file), working, "utf8").then(() => true, () => false);
8684
+ if (!owns(live, slot, attempt))
8685
+ return;
8686
+ if (restored) {
8687
+ slot.fixed = false;
8688
+ slot.layout = found;
8689
+ slot.state = "ready";
8690
+ slot.endedAt = now();
8691
+ return;
8692
+ }
8693
+ }
8694
+ if (failure !== null) {
8695
+ fail(live, slot, failure);
8696
+ return;
8697
+ }
7833
8698
  }
7834
8699
  }
8700
+ slot.layout = report.layout;
7835
8701
  slot.state = "ready";
7836
8702
  slot.endedAt = now();
7837
8703
  };
@@ -7844,16 +8710,22 @@ function createGenerations(deps) {
7844
8710
  };
7845
8711
  const plan = async (live, existing) => {
7846
8712
  const { job } = live;
7847
- const planning = run4(live.agent, live.agent.plan(planPrompt({
8713
+ const prompt = planPrompt({
7848
8714
  surface: job.surface,
7849
8715
  brief: job.brief,
7850
8716
  count: job.count,
7851
8717
  existing,
7852
8718
  base: live.base
7853
- })), PLAN_DEADLINE_MS);
8719
+ });
8720
+ const planRun = { ...runRecord(prompt), concepts: [] };
8721
+ if (live.record !== null)
8722
+ live.record.plan = planRun;
8723
+ const planning = run4(live.agent, live.agent.plan(prompt), PLAN_DEADLINE_MS);
7854
8724
  live.plan = planning;
7855
8725
  const outcome = await planning.done;
7856
8726
  live.plan = null;
8727
+ ended(live.agent, planRun, outcome);
8728
+ saveRecord(live);
7857
8729
  if (job.state !== "planning")
7858
8730
  return;
7859
8731
  if (outcome.timedOut || outcome.error !== null || outcome.code !== 0) {
@@ -7861,6 +8733,7 @@ function createGenerations(deps) {
7861
8733
  job.error = outcome.timedOut ? "Planning took too long, so Leglas stopped it." : failureOf2(live.agent, outcome).message;
7862
8734
  job.endedAt = now();
7863
8735
  changed();
8736
+ saveRecord(live);
7864
8737
  return;
7865
8738
  }
7866
8739
  let concepts;
@@ -7871,8 +8744,10 @@ function createGenerations(deps) {
7871
8744
  job.error = error instanceof Error ? error.message : String(error);
7872
8745
  job.endedAt = now();
7873
8746
  changed();
8747
+ saveRecord(live);
7874
8748
  return;
7875
8749
  }
8750
+ planRun.concepts = concepts;
7876
8751
  live.facts = factsBlock(await readProjectFacts(deps.cwd, live.switchPath, live.base?.key ?? null), job.surface, live.base);
7877
8752
  const slug = surfaceSlug(job.surface);
7878
8753
  const keys = new Set(existing);
@@ -7880,7 +8755,7 @@ function createGenerations(deps) {
7880
8755
  const folder = dirname8(live.switchPath);
7881
8756
  const slots = [];
7882
8757
  const extension = live.switchPath.endsWith(".jsx") ? "jsx" : "tsx";
7883
- const taken = async (key) => keys.has(key) || await isFile(join14(deps.cwd, folder, `${key}.${extension}`));
8758
+ const taken = async (key) => keys.has(key) || await isFile(join15(deps.cwd, folder, `${key}.${extension}`));
7884
8759
  for (const concept of concepts) {
7885
8760
  let key = `${slug}-${concept.key}`;
7886
8761
  for (let suffix = 2; await taken(key); suffix += 1)
@@ -7892,23 +8767,25 @@ function createGenerations(deps) {
7892
8767
  key,
7893
8768
  title,
7894
8769
  idea: concept.idea,
7895
- file: join14(folder, `${key}.${extension}`),
8770
+ file: join15(folder, `${key}.${extension}`),
7896
8771
  state: "building",
7897
8772
  startedAt: null,
7898
8773
  endedAt: null,
7899
8774
  failure: null,
7900
8775
  fixed: false,
8776
+ layout: [],
7901
8777
  activity: null
7902
8778
  });
7903
8779
  }
7904
- const source = await readFile14(join14(deps.cwd, live.switchPath), "utf8");
8780
+ const source = await readFile15(join15(deps.cwd, live.switchPath), "utf8");
7905
8781
  for (const slot of slots) {
7906
- await writeFile7(join14(deps.cwd, slot.file), placeholderSource(pascal(slot.key)), {
8782
+ await writeFile8(join15(deps.cwd, slot.file), placeholderSource(pascal(slot.key)), {
7907
8783
  encoding: "utf8",
7908
8784
  flag: "wx"
7909
8785
  });
7910
8786
  }
7911
- await writeFile7(join14(deps.cwd, live.switchPath), addSlots(source, slots.map((slot) => ({ key: slot.key, name: pascal(slot.key) }))), "utf8");
8787
+ await writeFile8(join15(deps.cwd, live.switchPath), addSlots(source, slots.map((slot) => ({ key: slot.key, name: pascal(slot.key) }))), "utf8");
8788
+ const kept = await keptFiles(live, new Set(slots.map((slot) => normalize3(slot.file))));
7912
8789
  for (const slot of slots) {
7913
8790
  await serially(() => deps.register({
7914
8791
  title: slot.title,
@@ -7918,21 +8795,33 @@ function createGenerations(deps) {
7918
8795
  }));
7919
8796
  }
7920
8797
  job.slots = slots;
8798
+ if (live.record !== null) {
8799
+ live.record.directions = slots.map((slot) => ({
8800
+ key: slot.key,
8801
+ file: slot.file,
8802
+ attempts: []
8803
+ }));
8804
+ }
7921
8805
  if (job.state !== "planning") {
7922
8806
  for (const slot of slots) {
7923
8807
  slot.state = "stopped";
7924
8808
  slot.endedAt = now();
7925
8809
  }
7926
8810
  changed();
8811
+ saveRecord(live);
7927
8812
  return;
7928
8813
  }
7929
8814
  job.state = "building";
7930
8815
  job.plannedAt = now();
7931
8816
  changed();
7932
- await Promise.all(job.slots.map((slot) => {
8817
+ const builds = job.slots.map((slot) => {
7933
8818
  const attempt = begin(live, slot);
8819
+ live.guards.set(guardKey(slot, attempt), { kept, strays: /* @__PURE__ */ new Set() });
8820
+ opened(live, slot, attempt, "plan");
7934
8821
  return guarded(build(live, slot, attempt), (message2) => lost(live, slot, attempt, message2));
7935
- }));
8822
+ });
8823
+ saveRecord(live);
8824
+ await Promise.all(builds);
7936
8825
  };
7937
8826
  const find = (id) => lives.find((live) => live.job.id === id);
7938
8827
  const stop = async (id, key) => {
@@ -7944,6 +8833,7 @@ function createGenerations(deps) {
7944
8833
  job.state = "stopped";
7945
8834
  job.endedAt = now();
7946
8835
  changed();
8836
+ noted(live, "stop", null);
7947
8837
  await live.plan?.stop();
7948
8838
  await drain(live);
7949
8839
  return true;
@@ -7956,6 +8846,8 @@ function createGenerations(deps) {
7956
8846
  }
7957
8847
  settle(live);
7958
8848
  changed();
8849
+ if (targets.length > 0)
8850
+ noted(live, "stop", key ?? null);
7959
8851
  await Promise.all(targets.map((slot) => release(live, slot)));
7960
8852
  return targets.length > 0;
7961
8853
  };
@@ -7990,7 +8882,7 @@ function createGenerations(deps) {
7990
8882
  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.`
7991
8883
  };
7992
8884
  }
7993
- const existing = directionKeys(await readFile14(join14(deps.cwd, switchPath), "utf8"));
8885
+ const existing = directionKeys(await readFile15(join15(deps.cwd, switchPath), "utf8"));
7994
8886
  if (base !== null && !existing.includes(base.key)) {
7995
8887
  return {
7996
8888
  ok: false,
@@ -8029,19 +8921,54 @@ function createGenerations(deps) {
8029
8921
  attempts: /* @__PURE__ */ new Map(),
8030
8922
  base,
8031
8923
  agent: agent === "codex" ? codex(await codexServers(deps.codexHome)) : CLAUDE,
8032
- pending: /* @__PURE__ */ new Set()
8924
+ guards: /* @__PURE__ */ new Map(),
8925
+ pending: /* @__PURE__ */ new Set(),
8926
+ record: null
8033
8927
  };
8928
+ const recording = deps.record?.() !== false;
8929
+ if (recording)
8930
+ await records.prune();
8931
+ const baseSet = base === null || !recording ? null : await setOfKey(deps.cwd, base.key).catch(() => null);
8034
8932
  if (busy())
8035
8933
  return { ok: false, error: BUSY };
8036
8934
  lives.push(live);
8037
8935
  while (lives.length > KEPT_JOBS)
8038
8936
  lives.shift();
8039
8937
  changed();
8938
+ if (recording) {
8939
+ live.record = {
8940
+ version: 1,
8941
+ id: job.id,
8942
+ surface: slug,
8943
+ brief,
8944
+ count,
8945
+ agent,
8946
+ args: argsOf(live.agent),
8947
+ basedOn: base === null ? null : { title: base.title, key: base.key, set: baseSet },
8948
+ state: job.state,
8949
+ error: null,
8950
+ startedAt: job.startedAt,
8951
+ plannedAt: null,
8952
+ endedAt: null,
8953
+ plan: null,
8954
+ directions: []
8955
+ };
8956
+ saveRecord(live);
8957
+ if (base !== null && baseSet !== null) {
8958
+ records.event(baseSet, {
8959
+ at: now(),
8960
+ kind: "more-like",
8961
+ direction: base.key,
8962
+ detail: job.id
8963
+ });
8964
+ }
8965
+ }
8040
8966
  detach(live, plan(live, existing), (message2) => {
8041
8967
  job.state = "failed";
8042
8968
  job.error = message2;
8043
8969
  job.endedAt = now();
8044
8970
  changed();
8971
+ saveRecord(live);
8045
8972
  });
8046
8973
  return { ok: true, job: copy(job) };
8047
8974
  },
@@ -8056,10 +8983,12 @@ function createGenerations(deps) {
8056
8983
  slot.state = "building";
8057
8984
  slot.startedAt = now();
8058
8985
  slot.failure = null;
8986
+ opened(live, slot, attempt, "retry");
8059
8987
  detach(live, restore(slot).then(() => build(live, slot, attempt)), (message2) => lost(live, slot, attempt, message2));
8060
8988
  live.job.state = "building";
8061
8989
  live.job.endedAt = null;
8062
8990
  changed();
8991
+ noted(live, "retry", slot.key);
8063
8992
  return true;
8064
8993
  },
8065
8994
  replace(id, key) {
@@ -8071,18 +9000,27 @@ function createGenerations(deps) {
8071
9000
  slot.state = "building";
8072
9001
  slot.startedAt = now();
8073
9002
  slot.failure = null;
9003
+ opened(live, slot, attempt, "replace");
8074
9004
  live.job.state = "building";
8075
9005
  live.job.endedAt = null;
8076
9006
  changed();
9007
+ noted(live, "replace", slot.key);
8077
9008
  const replacing = async () => {
8078
- const asking = run4(live.agent, live.agent.plan(replacePrompt2({
9009
+ const prompt = replacePrompt2({
8079
9010
  surface: live.job.surface,
8080
9011
  brief: live.job.brief,
8081
9012
  avoid: [...live.concepts.values()],
8082
9013
  base: live.base
8083
- })), PLAN_DEADLINE_MS);
9014
+ });
9015
+ const ideaRun = runRecord(prompt);
9016
+ const own = attemptRecord(live, slot.key, attempt);
9017
+ if (own !== void 0)
9018
+ own.ideaRun = ideaRun;
9019
+ const asking = run4(live.agent, live.agent.plan(prompt), PLAN_DEADLINE_MS);
8084
9020
  live.runs.set(slot.key, asking);
8085
9021
  const outcome = await asking.done;
9022
+ ended(live.agent, ideaRun, outcome);
9023
+ saveRecord(live);
8086
9024
  if (!owns(live, slot, attempt))
8087
9025
  return;
8088
9026
  if (live.runs.get(slot.key) === asking)
@@ -8099,6 +9037,7 @@ function createGenerations(deps) {
8099
9037
  });
8100
9038
  settle(live);
8101
9039
  changed();
9040
+ saveRecord(live);
8102
9041
  return;
8103
9042
  }
8104
9043
  const titles = new Set(await deps.titles());
@@ -8118,6 +9057,7 @@ function createGenerations(deps) {
8118
9057
  live.concepts.set(slot.key, { ...concept, title });
8119
9058
  slot.title = title;
8120
9059
  slot.idea = concept.idea;
9060
+ saveRecord(live);
8121
9061
  if (!owns(live, slot, attempt))
8122
9062
  return;
8123
9063
  await restore(slot);
@@ -8135,35 +9075,36 @@ function createGenerations(deps) {
8135
9075
  })),
8136
9076
  wait(KILL_GRACE_MS * 2 + 1e3)
8137
9077
  ]);
9078
+ await Promise.race([records.flush(), wait(KILL_GRACE_MS)]);
8138
9079
  }
8139
9080
  };
8140
9081
  }
8141
9082
 
8142
9083
  // ../server/dist/server-info.js
8143
- import { lstat as lstat2, mkdir as mkdir7, readFile as readFile15, rename as rename2, rm as rm4, writeFile as writeFile8 } from "fs/promises";
8144
- import { dirname as dirname9, join as join15 } from "path";
9084
+ import { lstat as lstat2, mkdir as mkdir8, readFile as readFile16, rename as rename4, rm as rm6, writeFile as writeFile9 } from "fs/promises";
9085
+ import { dirname as dirname9, join as join16 } from "path";
8145
9086
  var SERVER_INFO_PATH = ".leglas/server.json";
8146
9087
  async function writeServerInfo(cwd, info) {
8147
- const path = join15(cwd, SERVER_INFO_PATH);
9088
+ const path = join16(cwd, SERVER_INFO_PATH);
8148
9089
  const directory = dirname9(path);
8149
- await mkdir7(directory, { recursive: true });
9090
+ await mkdir8(directory, { recursive: true });
8150
9091
  if (!(await lstat2(directory)).isDirectory())
8151
9092
  return;
8152
9093
  const existing = await lstat2(path).catch(() => null);
8153
9094
  if (existing !== null && !existing.isFile())
8154
9095
  return;
8155
9096
  const temporary = `${path}.${process.pid}.tmp`;
8156
- await writeFile8(temporary, `${JSON.stringify({ ...info, startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)}
9097
+ await writeFile9(temporary, `${JSON.stringify({ ...info, startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)}
8157
9098
  `, "utf8");
8158
- await rename2(temporary, path).catch(async (cause) => {
8159
- await rm4(temporary, { force: true }).catch(() => {
9099
+ await rename4(temporary, path).catch(async (cause) => {
9100
+ await rm6(temporary, { force: true }).catch(() => {
8160
9101
  });
8161
9102
  throw cause;
8162
9103
  });
8163
9104
  }
8164
9105
  async function readServerInfo(cwd) {
8165
9106
  try {
8166
- const value = parseJson(await readFile15(join15(cwd, SERVER_INFO_PATH), "utf8"));
9107
+ const value = parseJson(await readFile16(join16(cwd, SERVER_INFO_PATH), "utf8"));
8167
9108
  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))
8168
9109
  return null;
8169
9110
  return { port: value.port, url: value.url, pid: value.pid };
@@ -8177,7 +9118,7 @@ async function removeServerInfo(cwd, expected) {
8177
9118
  if (current !== null && (current.port !== expected.port || current.pid !== expected.pid))
8178
9119
  return;
8179
9120
  }
8180
- await rm4(join15(cwd, SERVER_INFO_PATH), { force: true });
9121
+ await rm6(join16(cwd, SERVER_INFO_PATH), { force: true });
8181
9122
  }
8182
9123
 
8183
9124
  // ../server/dist/server.js
@@ -8326,7 +9267,7 @@ function probe(target, timeoutMs = 1e3) {
8326
9267
  return resolve5(false);
8327
9268
  }
8328
9269
  const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
8329
- const socket = net3.connect(port, url.hostname);
9270
+ const socket = net3.connect(port, url.hostname.replace(/^\[|\]$/g, ""));
8330
9271
  const settle = (reachable) => {
8331
9272
  socket.destroy();
8332
9273
  resolve5(reachable);
@@ -8338,8 +9279,8 @@ function probe(target, timeoutMs = 1e3) {
8338
9279
  });
8339
9280
  }
8340
9281
  function serveFrom(res, dir, relativePath) {
8341
- const relative8 = normalize2(relativePath).replace(/^(\.\.[/\\])+/, "");
8342
- const candidate = join16(dir, relative8);
9282
+ const relative9 = normalize4(relativePath).replace(/^(\.\.[/\\])+/, "");
9283
+ const candidate = join17(dir, relative9);
8343
9284
  if (!candidate.startsWith(dir))
8344
9285
  return false;
8345
9286
  if (!existsSync3(candidate) || !statSync(candidate).isFile())
@@ -8353,9 +9294,9 @@ function serveFrom(res, dir, relativePath) {
8353
9294
  return true;
8354
9295
  }
8355
9296
  function serveShellFile(res, shellDir, urlPath) {
8356
- const relative8 = normalize2(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
8357
- const isRoot = relative8 === "" || relative8 === "." || relative8 === "/";
8358
- return serveFrom(res, shellDir, isRoot ? "index.html" : relative8);
9297
+ const relative9 = normalize4(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
9298
+ const isRoot = relative9 === "" || relative9 === "." || relative9 === "/";
9299
+ return serveFrom(res, shellDir, isRoot ? "index.html" : relative9);
8359
9300
  }
8360
9301
  var HEALTH_PROBE_MS = 3e3;
8361
9302
  function fileStamp(path) {
@@ -8367,11 +9308,11 @@ function fileStamp(path) {
8367
9308
  }
8368
9309
  }
8369
9310
  function watchLiveFiles(cwd, configPath, live) {
8370
- const leglasDir = join16(cwd, ".leglas");
9311
+ const leglasDir = join17(cwd, ".leglas");
8371
9312
  const targets = [
8372
- { path: join16(cwd, LOCAL_PREVIEWS_PATH), change: "config" },
8373
- { path: join16(cwd, REQUESTS_PATH), change: "requests" },
8374
- { path: join16(cwd, ANNOTATIONS_PATH), change: "requests" }
9313
+ { path: join17(cwd, LOCAL_PREVIEWS_PATH), change: "config" },
9314
+ { path: join17(cwd, REQUESTS_PATH), change: "requests" },
9315
+ { path: join17(cwd, ANNOTATIONS_PATH), change: "requests" }
8375
9316
  ];
8376
9317
  const byName = new Map(targets.map((target) => [basename3(target.path), target]));
8377
9318
  const known = new Map(targets.map((target) => [target.path, fileStamp(target.path)]));
@@ -9024,6 +9965,7 @@ async function startServer(options) {
9024
9965
  });
9025
9966
  }
9026
9967
  const deleted = await dropLocalPreviews(cwd, unique);
9968
+ await noteDirections(cwd, unique, "remove");
9027
9969
  return sendJson2(res, 200, { ok: true, deleted });
9028
9970
  } catch {
9029
9971
  return sendJson2(res, 500, {
@@ -9072,8 +10014,8 @@ async function startServer(options) {
9072
10014
  const id = newRequestId();
9073
10015
  const file = `${REFERENCES_DIR}/${id}.${image.kind}`;
9074
10016
  try {
9075
- await mkdir8(join16(cwd, REFERENCES_DIR), { recursive: true });
9076
- await writeFile9(join16(cwd, file), body);
10017
+ await mkdir9(join17(cwd, REFERENCES_DIR), { recursive: true });
10018
+ await writeFile10(join17(cwd, file), body);
9077
10019
  void pruneReferences(cwd).catch(() => {
9078
10020
  });
9079
10021
  return sendJson2(res, 200, {
@@ -9115,7 +10057,7 @@ async function startServer(options) {
9115
10057
  }
9116
10058
  const references = parsed2.references ?? [];
9117
10059
  if (references.length > 0) {
9118
- const present = new Set((await readdir5(join16(cwd, REFERENCES_DIR)).catch(() => [])).map((name) => name.slice(0, name.indexOf(".") === -1 ? name.length : name.indexOf("."))));
10060
+ const present = new Set((await readdir6(join17(cwd, REFERENCES_DIR)).catch(() => [])).map((name) => name.slice(0, name.indexOf(".") === -1 ? name.length : name.indexOf("."))));
9119
10061
  const gone = references.filter((id2) => !present.has(id2));
9120
10062
  if (gone.length > 0) {
9121
10063
  return sendJson2(res, 410, {
@@ -9145,9 +10087,8 @@ async function startServer(options) {
9145
10087
  };
9146
10088
  const compare = isString(parsed2.compare) && parsed2.compare !== preview.title ? previews.find((entry) => entry.title === parsed2.compare) ?? null : null;
9147
10089
  const sameContext = (entry) => (entry.compare ?? null) === (compare?.title ?? null) && [...entry.references ?? []].sort().join(",") === [...references].sort().join(",");
9148
- if (live2.some((entry) => entry.title === preview.title && entry.intent === intent && // The same words in the other mode are not the same request:
9149
- // one forks the direction and the other rewrites it. Only a
9150
- // genuine repeat is refused.
10090
+ if (live2.some((entry) => entry.title === preview.title && entry.intent === intent && // The same words in the other mode aren't the same request: one
10091
+ // forks, one rewrites.
9151
10092
  (entry.mode ?? "replace") === mode && sameNotes(entry) && sameContext(entry))) {
9152
10093
  return sendJson2(res, 409, {
9153
10094
  ok: false,
@@ -9172,9 +10113,8 @@ async function startServer(options) {
9172
10113
  title: preview.title,
9173
10114
  url: preview.url,
9174
10115
  intent,
9175
- // The ids travel with the request so a change made in place can
9176
- // forget the notes it answered. A fork leaves them where they are:
9177
- // the direction they point at was not touched.
10116
+ // The ids travel with the request so a change in place can forget
10117
+ // the notes it answered. A fork leaves them.
9178
10118
  ...composed
9179
10119
  };
9180
10120
  if (notes.length > 0)
@@ -9278,8 +10218,8 @@ async function startServer(options) {
9278
10218
  const noteSuffix = isString(parsed2.note) ? `-${parsed2.note.replace(/[^A-Za-z0-9_-]+/g, "-")}` : "";
9279
10219
  const name = `${captureSlug(preview.title)}-${result.frame.width}${noteSuffix}.png`;
9280
10220
  const relativeFile = `${CAPTURES_DIR}/show/${name}`;
9281
- await mkdir8(join16(cwd, CAPTURES_DIR, "show"), { recursive: true });
9282
- await writeFile9(join16(cwd, relativeFile), shot.png);
10221
+ await mkdir9(join17(cwd, CAPTURES_DIR, "show"), { recursive: true });
10222
+ await writeFile10(join17(cwd, relativeFile), shot.png);
9283
10223
  return sendJson2(res, 200, {
9284
10224
  ok: true,
9285
10225
  file: relativeFile,
@@ -9414,16 +10354,14 @@ async function startServer(options) {
9414
10354
  title,
9415
10355
  intent,
9416
10356
  // A fork leaves its parent's document alone; the interface keeps
9417
- // the parent's duplicate verdict on the strength of this.
10357
+ // the parent's duplicate verdict because of this.
9418
10358
  mode,
9419
- // Which pins this change speaks for. The interface marks them, so
9420
- // a note already sitting in a prompt an agent holds does not look
9421
- // like one nobody has read.
10359
+ // Which pins this change speaks for, so the interface can show a
10360
+ // note already in an agent's prompt as read.
9422
10361
  notes: notes ?? [],
9423
- // The run in flight is the one thing the file cannot know. After
9424
- // that the file is the record, including across a restart, and the
9425
- // process-local failed set only covers a request whose verdict
9426
- // could not be written.
10362
+ // The file can't know the run in flight. Otherwise it's the record,
10363
+ // across restarts; the in-memory failed set only covers a verdict
10364
+ // that couldn't be written.
9427
10365
  status: snapshot.running && snapshot.requestId === id ? "running" : status === "queued" && snapshot.failedIds.includes(id) ? "failed" : status,
9428
10366
  failure: failure ?? null
9429
10367
  })),
@@ -9433,13 +10371,13 @@ async function startServer(options) {
9433
10371
  name: snapshot.running ? snapshot.agent : null,
9434
10372
  activity: snapshot.running ? snapshot.activity : null,
9435
10373
  startedAt: snapshot.running ? snapshot.startedAt : null,
9436
- // A stop that has been asked for but not yet obeyed. The card
9437
- // says so rather than going on describing a live run.
10374
+ // A stop asked for but not yet obeyed, so the card stops describing
10375
+ // a live run.
9438
10376
  stopping: snapshot.running && snapshot.stopping,
9439
10377
  // Why a run that looks stalled is stalled, while it is stalled.
9440
10378
  waiting: snapshot.running ? snapshot.waiting : null,
9441
- // When a run that has gone quiet last said anything. A run on its
9442
- // way out is not quiet, it is stopping.
10379
+ // When a quiet run last said anything. A run on its way out is
10380
+ // stopping, not quiet.
9443
10381
  quietSince: snapshot.running && !snapshot.stopping ? snapshot.quietSince : null
9444
10382
  }
9445
10383
  }));
@@ -9496,12 +10434,11 @@ async function startServer(options) {
9496
10434
  url: request.url,
9497
10435
  intent: request.intent,
9498
10436
  target: request.target,
9499
- // The prompt names the captures by path, and the embedded pipes
9500
- // are not its only readers: watch, a custom command and
9501
- // `requests --json` all hand the text over as it stands.
10437
+ // The prompt names captures by path, and watch, a custom command
10438
+ // and `requests --json` hand the text over as is.
9502
10439
  prompt: attachments.length === 0 ? request.prompt : rehomeText(request.prompt, request.id, retryId)
9503
- // The stored prompt already carries the mode's instructions;
9504
- // its notes and visual context travel with the retry too.
10440
+ // The stored prompt already carries the mode's instructions; its
10441
+ // notes and visual context travel with the retry.
9505
10442
  };
9506
10443
  if (request.mode !== void 0)
9507
10444
  retry.mode = request.mode;
@@ -9659,15 +10596,15 @@ async function startServer(options) {
9659
10596
  const rest = path.slice(FILES_PREFIX.length + 1);
9660
10597
  const slash = rest.indexOf("/");
9661
10598
  const slug = slash === -1 ? rest : rest.slice(0, slash);
9662
- let relative8 = slash === -1 ? "" : rest.slice(slash + 1);
10599
+ let relative9 = slash === -1 ? "" : rest.slice(slash + 1);
9663
10600
  try {
9664
- relative8 = decodeURIComponent(relative8);
10601
+ relative9 = decodeURIComponent(relative9);
9665
10602
  } catch {
9666
- relative8 = "";
10603
+ relative9 = "";
9667
10604
  }
9668
10605
  const dir = fileMounts.get(slug);
9669
10606
  const serveMount = () => {
9670
- if (dir !== void 0 && relative8 !== "" && serveFrom(res, dir, relative8))
10607
+ if (dir !== void 0 && relative9 !== "" && serveFrom(res, dir, relative9))
9671
10608
  return;
9672
10609
  res.writeHead(404, {
9673
10610
  "content-type": "text/plain; charset=utf-8",
@@ -9677,7 +10614,7 @@ async function startServer(options) {
9677
10614
  };
9678
10615
  if (!context.remote)
9679
10616
  return serveMount();
9680
- if (relative8.split("/").some((segment) => segment.startsWith("."))) {
10617
+ if (relative9.split("/").some((segment) => segment.startsWith("."))) {
9681
10618
  return sendJson2(res, 403, { ok: false, error: "Not available to viewers." });
9682
10619
  }
9683
10620
  return void (shares?.fileSlugAllowed(slug, context.grantId ?? "") ?? Promise.resolve(false)).then((allowed) => {
@@ -9778,7 +10715,7 @@ async function startServer(options) {
9778
10715
  if (options.claudeAgentSession !== void 0)
9779
10716
  runnerOptions.claudeAgentSession = options.claudeAgentSession;
9780
10717
  runner = startRunner(runnerOptions);
9781
- const renderDirection = async (title) => {
10718
+ const renderDirection = async (title, { picture = false } = {}) => {
9782
10719
  const preview = (await livePreviews()).find((entry) => entry.title === title);
9783
10720
  if (preview === void 0)
9784
10721
  return null;
@@ -9787,15 +10724,29 @@ async function startServer(options) {
9787
10724
  return null;
9788
10725
  const address = server.address();
9789
10726
  const renderPort = address !== null && !isString(address) ? address.port : options.port ?? DEFAULT_PORT;
10727
+ const url = previewUrl(`http://127.0.0.1:${renderPort}`, preview);
10728
+ const settings = { width: 1440, timeoutMs: CAPTURE_LOAD_MS, inspect: true };
9790
10729
  try {
9791
10730
  const captured = await capturePage(browser, {
9792
- url: previewUrl(`http://127.0.0.1:${renderPort}`, preview),
9793
- width: 1440,
9794
- timeoutMs: CAPTURE_LOAD_MS
10731
+ ...settings,
10732
+ url,
10733
+ jpeg: picture
9795
10734
  });
9796
- return { errors: captured.errors };
10735
+ const layout = captured.layout.map(describeFinding);
10736
+ const frame = captured.jpeg;
10737
+ if (layout.length === 0)
10738
+ return { errors: captured.errors, layout, frame };
10739
+ const plain = new URL(url);
10740
+ plain.search = new URLSearchParams([...plain.searchParams].filter(([key]) => !key.startsWith("v-"))).toString();
10741
+ const before = await capturePage(browser, { ...settings, url: plain.href }).catch(() => null);
10742
+ const known = new Set(before?.layout.map(describeFinding) ?? []);
10743
+ return {
10744
+ errors: captured.errors,
10745
+ layout: layout.filter((line) => !known.has(line)),
10746
+ frame
10747
+ };
9797
10748
  } catch (error) {
9798
- return { errors: [error instanceof Error ? error.message : String(error)] };
10749
+ return { errors: [error instanceof Error ? error.message : String(error)], layout: [] };
9799
10750
  }
9800
10751
  };
9801
10752
  const generationDeps = {
@@ -9809,7 +10760,8 @@ async function startServer(options) {
9809
10760
  const local = await readLocalPreviews(cwd).catch(() => null);
9810
10761
  return new Set([...config?.previews ?? [], ...local?.previews ?? []].map((entry) => entry.title));
9811
10762
  },
9812
- onChange: () => live.nudge("generation")
10763
+ onChange: () => live.nudge("generation"),
10764
+ record: () => config?.recordSets !== false
9813
10765
  };
9814
10766
  if (options.generationSpawn !== void 0)
9815
10767
  generationDeps.spawn = options.generationSpawn;
@@ -9852,10 +10804,10 @@ async function startServer(options) {
9852
10804
 
9853
10805
  // ../server/dist/update.js
9854
10806
  import { spawn as spawnChild2 } from "child_process";
9855
- import { existsSync as existsSync4, readFileSync, realpathSync } from "fs";
9856
- import { lstat as lstat3, mkdir as mkdir9, mkdtemp, rename as rename3, rm as rm5, writeFile as writeFile10 } from "fs/promises";
10807
+ import { existsSync as existsSync4, readFileSync, realpathSync as realpathSync2 } from "fs";
10808
+ import { lstat as lstat3, mkdir as mkdir10, mkdtemp, rename as rename5, rm as rm7, writeFile as writeFile11 } from "fs/promises";
9857
10809
  import { homedir as homedir3 } from "os";
9858
- import { dirname as dirname11, join as join17, posix as posix2, win32 } from "path";
10810
+ import { dirname as dirname11, join as join18, posix as posix2, win32 } from "path";
9859
10811
  var DAY_MS = 24 * 60 * 60 * 1e3;
9860
10812
  var INSTALL_DEADLINE_MS = 5 * 60 * 1e3;
9861
10813
  var REGISTRY = "https://registry.npmjs.org";
@@ -9867,7 +10819,7 @@ function normalized(path) {
9867
10819
  }
9868
10820
  function realPath(path) {
9869
10821
  try {
9870
- return realpathSync(path);
10822
+ return realpathSync2(path);
9871
10823
  } catch {
9872
10824
  return null;
9873
10825
  }
@@ -10055,7 +11007,7 @@ function restartCommand(install, argv, latest, port, options) {
10055
11007
  return spawnCommand(["leglas", ...rest], options.platform);
10056
11008
  }
10057
11009
  if (install.kind === "project" && install.root !== void 0) {
10058
- const shim = (windows ? win32.join : join17)(install.root, "node_modules", ".bin", windows ? "leglas.cmd" : "leglas");
11010
+ const shim = (windows ? win32.join : join18)(install.root, "node_modules", ".bin", windows ? "leglas.cmd" : "leglas");
10059
11011
  return spawnCommand(options.exists(shim) ? [shim, ...rest] : ["yarn", "leglas", ...rest], options.platform);
10060
11012
  }
10061
11013
  throw new Error(CHECKOUT_NOTICE);
@@ -10086,7 +11038,7 @@ function readState(path) {
10086
11038
  }
10087
11039
  function defaultStatePath(home) {
10088
11040
  try {
10089
- return join17(home(), ".leglas", "update.json");
11041
+ return join18(home(), ".leglas", "update.json");
10090
11042
  } catch {
10091
11043
  return null;
10092
11044
  }
@@ -10105,21 +11057,21 @@ async function writeState(path, state) {
10105
11057
  let temporary = null;
10106
11058
  try {
10107
11059
  const directory = dirname11(path);
10108
- await mkdir9(directory, { recursive: true });
11060
+ await mkdir10(directory, { recursive: true });
10109
11061
  if (!(await lstat3(directory)).isDirectory())
10110
11062
  return;
10111
11063
  const existing = await lstat3(path).catch(() => null);
10112
11064
  if (existing !== null && !existing.isFile())
10113
11065
  return;
10114
- temporary = await mkdtemp(join17(directory, ".update-"));
10115
- const file = join17(temporary, "update.json");
10116
- await writeFile10(file, `${JSON.stringify(state, null, 2)}
11066
+ temporary = await mkdtemp(join18(directory, ".update-"));
11067
+ const file = join18(temporary, "update.json");
11068
+ await writeFile11(file, `${JSON.stringify(state, null, 2)}
10117
11069
  `, "utf8");
10118
- await rename3(file, path);
11070
+ await rename5(file, path);
10119
11071
  } catch {
10120
11072
  } finally {
10121
11073
  if (temporary !== null)
10122
- await rm5(temporary, { recursive: true, force: true }).catch(() => {
11074
+ await rm7(temporary, { recursive: true, force: true }).catch(() => {
10123
11075
  });
10124
11076
  }
10125
11077
  }
@@ -10497,7 +11449,7 @@ async function runClassify(options, deps) {
10497
11449
  const declared = await Promise.all(
10498
11450
  options.changes.map(async (change) => ({
10499
11451
  ...change,
10500
- exists: await stat5(join18(options.cwd, change.path)).then(
11452
+ exists: await stat5(join19(options.cwd, change.path)).then(
10501
11453
  () => true,
10502
11454
  () => false
10503
11455
  )
@@ -10819,6 +11771,38 @@ async function findLeglas(cwd, port, request) {
10819
11771
  return { ok: false, error: NOT_RUNNING };
10820
11772
  }
10821
11773
  }
11774
+ function interfaceUrl(port, titles) {
11775
+ const url = new URL(`http://localhost:${port}${LEGLAS_PREFIX}`);
11776
+ const [direction, compare] = titles;
11777
+ if (direction !== void 0) url.searchParams.set("direction", direction);
11778
+ if (compare !== void 0) url.searchParams.set("compare", compare);
11779
+ return url.href;
11780
+ }
11781
+ async function railTitles(port, request) {
11782
+ try {
11783
+ const response = await request(`http://127.0.0.1:${port}${LEGLAS_PREFIX}/api/config`, {
11784
+ signal: AbortSignal.timeout(2e3)
11785
+ });
11786
+ if (!response.ok) return null;
11787
+ const payload = await bodyOf(response);
11788
+ if (!isJsonObject(payload) || !Array.isArray(payload.previews)) return null;
11789
+ return new Set(
11790
+ payload.previews.flatMap(
11791
+ (preview) => isJsonObject(preview) && isString2(preview.title) ? [preview.title] : []
11792
+ )
11793
+ );
11794
+ } catch {
11795
+ return null;
11796
+ }
11797
+ }
11798
+ async function recordedRail(cwd, request) {
11799
+ const record3 = await readServerInfo(cwd);
11800
+ if (record3 === null) return null;
11801
+ const found = await findLeglas(cwd, record3.port, request);
11802
+ if (!found.ok) return null;
11803
+ const titles = await railTitles(found.port, request);
11804
+ return titles === null ? null : { port: found.port, titles };
11805
+ }
10822
11806
 
10823
11807
  // src/run-explore-build.ts
10824
11808
  var POLL_MS2 = 1e3;
@@ -10894,13 +11878,13 @@ async function runExploreBuild(options, deps) {
10894
11878
  const deadline = Date.now() + WAIT_MS;
10895
11879
  while (Date.now() < deadline) {
10896
11880
  await sleep(POLL_MS2);
10897
- let listed;
11881
+ let listed2;
10898
11882
  try {
10899
- listed = await bodyOf(await request(base));
11883
+ listed2 = await bodyOf(await request(base));
10900
11884
  } catch {
10901
11885
  return fail("Leglas stopped answering while the directions were being built.");
10902
11886
  }
10903
- const jobs = isJsonObject(listed) ? listed.jobs : void 0;
11887
+ const jobs = isJsonObject(listed2) ? listed2.jobs : void 0;
10904
11888
  const latest = Array.isArray(jobs) ? jobs.map(readJob).find((candidate) => candidate?.id === first.id) : void 0;
10905
11889
  if (latest === void 0 || latest === null) continue;
10906
11890
  job = latest;
@@ -10938,8 +11922,8 @@ async function runExploreBuild(options, deps) {
10938
11922
  }
10939
11923
 
10940
11924
  // src/run-init.ts
10941
- import { readFile as readFile16, writeFile as writeFile11 } from "fs/promises";
10942
- import { join as join19 } from "path";
11925
+ import { readFile as readFile17, writeFile as writeFile12 } from "fs/promises";
11926
+ import { join as join20 } from "path";
10943
11927
 
10944
11928
  // src/init.ts
10945
11929
  var AGENTS_MARKER_START = "<!-- leglas:start -->";
@@ -11052,6 +12036,12 @@ Useful to know:
11052
12036
  - Titles identify previews and must be unique. The user may rename one in the
11053
12037
  rail, which renames it on their machine only; the commands answer to either
11054
12038
  name, so use whichever they said.
12039
+ - \`npx leglas link "<title>"\` prints a link that opens the interface on that
12040
+ direction, and \`npx leglas link "<new>" "<old>"\` one that puts two side by
12041
+ side. Give it to the user when you finish a direction they asked for, rather
12042
+ than telling them where to click.
12043
+ - \`npx leglas remove "<title>"\` takes a direction registered on this machine
12044
+ off the rail when the user asks. One the config lists is theirs to edit.
11055
12045
  - \`npx leglas list\` shows every direction, shared and local.
11056
12046
  - \`npx leglas show "<title>" --json\` answers for one of them: the file behind it,
11057
12047
  the variants based on it, what it is being compared against and anything
@@ -11101,7 +12091,7 @@ ${AGENTS_SECTION}`
11101
12091
  // src/run-init.ts
11102
12092
  async function readIfPresent(path) {
11103
12093
  try {
11104
- return await readFile16(path, "utf8");
12094
+ return await readFile17(path, "utf8");
11105
12095
  } catch {
11106
12096
  return null;
11107
12097
  }
@@ -11109,18 +12099,18 @@ async function readIfPresent(path) {
11109
12099
  async function runInit(options, deps) {
11110
12100
  const existingConfig = findConfigFile(options.cwd);
11111
12101
  const plan = planInit({
11112
- agents: await readIfPresent(join19(options.cwd, "AGENTS.md")),
12102
+ agents: await readIfPresent(join20(options.cwd, "AGENTS.md")),
11113
12103
  config: existingConfig === null ? null : "present",
11114
- gitignore: await readIfPresent(join19(options.cwd, ".gitignore")),
12104
+ gitignore: await readIfPresent(join20(options.cwd, ".gitignore")),
11115
12105
  force: options.force
11116
12106
  });
11117
12107
  const touched = [];
11118
12108
  for (const write2 of plan.writes) {
11119
- await writeFile11(join19(options.cwd, write2.path), write2.contents, "utf8");
12109
+ await writeFile12(join20(options.cwd, write2.path), write2.contents, "utf8");
11120
12110
  touched.push(write2.path);
11121
12111
  }
11122
12112
  if (plan.gitignore !== null) {
11123
- await writeFile11(join19(options.cwd, ".gitignore"), plan.gitignore, "utf8");
12113
+ await writeFile12(join20(options.cwd, ".gitignore"), plan.gitignore, "utf8");
11124
12114
  touched.push(".gitignore");
11125
12115
  }
11126
12116
  if (options.json) {
@@ -11140,11 +12130,11 @@ async function runInit(options, deps) {
11140
12130
 
11141
12131
  // src/run-keep.ts
11142
12132
  import { existsSync as existsSync5 } from "fs";
11143
- import { copyFile as copyFile2, mkdir as mkdir10, readFile as readFile17, rm as rm6, writeFile as writeFile12 } from "fs/promises";
11144
- import { dirname as dirname12, isAbsolute as isAbsolute3, join as join20, relative as relative5 } from "path";
12133
+ import { copyFile as copyFile2, mkdir as mkdir11, readFile as readFile18, rm as rm8, writeFile as writeFile13 } from "fs/promises";
12134
+ import { dirname as dirname12, isAbsolute as isAbsolute3, join as join21, relative as relative5 } from "path";
11145
12135
 
11146
12136
  // src/keep.ts
11147
- import { basename as basename4, extname as extname4, isAbsolute as isAbsolute2, normalize as normalize3 } from "path";
12137
+ import { basename as basename4, extname as extname4, isAbsolute as isAbsolute2, normalize as normalize5 } from "path";
11148
12138
  function surfaceOf(url) {
11149
12139
  if (!url.startsWith("/") || !url.includes("?")) return null;
11150
12140
  for (const pair of url.slice(url.indexOf("?") + 1).split("&")) {
@@ -11173,7 +12163,7 @@ function planKeep(options) {
11173
12163
  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.`
11174
12164
  };
11175
12165
  }
11176
- const to = normalize3(options.to);
12166
+ const to = normalize5(options.to);
11177
12167
  if (to.startsWith("..") || isAbsolute2(to)) {
11178
12168
  return { ok: false, error: "The destination has to be inside the project." };
11179
12169
  }
@@ -11229,15 +12219,15 @@ async function writeLogEntry(options) {
11229
12219
  annotations: await readAnnotations(options.cwd),
11230
12220
  date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
11231
12221
  });
11232
- const dir = join20(options.cwd, options.logDir);
11233
- await mkdir10(dir, { recursive: true });
11234
- const file = join20(dir, `${entry.slug}.md`);
11235
- await writeFile12(file, entry.markdown, "utf8");
12222
+ const dir = join21(options.cwd, options.logDir);
12223
+ await mkdir11(dir, { recursive: true });
12224
+ const file = join21(dir, `${entry.slug}.md`);
12225
+ await writeFile13(file, entry.markdown, "utf8");
11236
12226
  if (entry.pictures.length > 0) {
11237
- const pictureDir = join20(dir, entry.slug);
11238
- await mkdir10(pictureDir, { recursive: true });
12227
+ const pictureDir = join21(dir, entry.slug);
12228
+ await mkdir11(pictureDir, { recursive: true });
11239
12229
  for (const picture of entry.pictures) {
11240
- await copyFile2(join20(options.cwd, picture.from), join20(pictureDir, picture.to)).catch(() => {
12230
+ await copyFile2(join21(options.cwd, picture.from), join21(pictureDir, picture.to)).catch(() => {
11241
12231
  });
11242
12232
  }
11243
12233
  }
@@ -11261,17 +12251,17 @@ async function runKeep(options, deps) {
11261
12251
  const destination = isAbsolute3(options.to) ? relative5(options.cwd, options.to) : options.to;
11262
12252
  const plan = planKeep({ title: resolved.title, previews, to: destination });
11263
12253
  if (!plan.ok) return fail(plan.error);
11264
- const from = join20(options.cwd, plan.move.from);
11265
- const to = join20(options.cwd, plan.move.to);
12254
+ const from = join21(options.cwd, plan.move.from);
12255
+ const to = join21(options.cwd, plan.move.to);
11266
12256
  if (!existsSync5(from)) {
11267
12257
  return fail(`${plan.move.from} does not exist. Nothing to keep.`);
11268
12258
  }
11269
12259
  if (existsSync5(to)) {
11270
12260
  return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
11271
12261
  }
11272
- const source = await readFile17(from, "utf8");
11273
- await mkdir10(dirname12(to), { recursive: true });
11274
- await writeFile12(to, renameExport(source, plan.exportName), "utf8");
12262
+ const source = await readFile18(from, "utf8");
12263
+ await mkdir11(dirname12(to), { recursive: true });
12264
+ await writeFile13(to, renameExport(source, plan.exportName), "utf8");
11275
12265
  const surface = plan.removeDir.slice(plan.removeDir.lastIndexOf("/") + 1);
11276
12266
  let logged = null;
11277
12267
  let logError = null;
@@ -11286,8 +12276,9 @@ async function runKeep(options, deps) {
11286
12276
  } catch (error) {
11287
12277
  logError = error instanceof Error ? error.message : String(error);
11288
12278
  }
11289
- await rm6(join20(options.cwd, plan.removeDir), { recursive: true, force: true });
12279
+ await rm8(join21(options.cwd, plan.removeDir), { recursive: true, force: true });
11290
12280
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
12281
+ await noteDirections(options.cwd, [resolved.title], "keep", plan.move.to);
11291
12282
  if (options.json) {
11292
12283
  deps.log(
11293
12284
  JSON.stringify({
@@ -11323,13 +12314,99 @@ async function runKeep(options, deps) {
11323
12314
  return { exitCode: 0 };
11324
12315
  }
11325
12316
 
12317
+ // src/run-link.ts
12318
+ async function runLink(options, deps) {
12319
+ const fail = (error) => {
12320
+ if (options.json) deps.log(JSON.stringify({ ok: false, error }));
12321
+ else deps.error(error);
12322
+ return { exitCode: 1 };
12323
+ };
12324
+ const loaded = await loadConfig(options.cwd);
12325
+ const local = await readLocalPreviews(options.cwd);
12326
+ const renames = await readRenames(options.cwd);
12327
+ const known = [
12328
+ ...new Set(
12329
+ [...loaded.config?.previews ?? [], ...local.previews].map((preview) => preview.title)
12330
+ )
12331
+ ];
12332
+ const titles = [];
12333
+ for (const asked of options.titles) {
12334
+ const resolved = resolveOrExplain(asked, known, renames);
12335
+ if (!resolved.ok) return fail(resolved.error);
12336
+ titles.push(resolved.title);
12337
+ }
12338
+ const [first, second] = titles;
12339
+ if (first !== void 0 && first === second) {
12340
+ return fail(`Both names are ${first}. Name two different directions to put side by side.`);
12341
+ }
12342
+ const request = deps.fetch ?? fetch;
12343
+ const found = await findLeglas(options.cwd, options.port, request);
12344
+ if (!found.ok) return fail(found.error);
12345
+ const rail = await railTitles(found.port, request);
12346
+ if (rail === null) return fail("Leglas did not say which directions are on its rail.");
12347
+ const absent = titles.find((title) => !rail.has(title));
12348
+ if (absent !== void 0) {
12349
+ return fail(
12350
+ `${absent} isn't on the running rail yet. A branch or file direction joins it when Leglas restarts.`
12351
+ );
12352
+ }
12353
+ const url = interfaceUrl(found.port, titles);
12354
+ if (options.json) deps.log(JSON.stringify({ ok: true, url }));
12355
+ else deps.log(url);
12356
+ return { exitCode: 0 };
12357
+ }
12358
+
12359
+ // src/run-remove.ts
12360
+ import { relative as relative6 } from "path";
12361
+ async function runRemove(options, deps) {
12362
+ const fail = (error) => {
12363
+ if (options.json) deps.log(JSON.stringify({ ok: false, error }));
12364
+ else deps.error(error);
12365
+ return { exitCode: 1 };
12366
+ };
12367
+ const loaded = await loadConfig(options.cwd);
12368
+ const local = await readLocalPreviews(options.cwd);
12369
+ if (local.errors.length > 0) {
12370
+ return fail(`${LOCAL_PREVIEWS_PATH} has entries Leglas can't read: ${local.errors.join(" ")}`);
12371
+ }
12372
+ const known = [
12373
+ ...new Set(
12374
+ [...loaded.config?.previews ?? [], ...local.previews].map((preview) => preview.title)
12375
+ )
12376
+ ];
12377
+ const renames = await readRenames(options.cwd);
12378
+ const titles = [];
12379
+ for (const asked of options.titles) {
12380
+ const resolved = resolveOrExplain(asked, known, renames);
12381
+ if (!resolved.ok) return fail(resolved.error);
12382
+ if (!titles.includes(resolved.title)) titles.push(resolved.title);
12383
+ }
12384
+ const registered = new Set(local.previews.map((preview) => preview.title));
12385
+ const listed2 = new Set((loaded.config?.previews ?? []).map((preview) => preview.title));
12386
+ const shared = titles.find((title) => listed2.has(title) || !registered.has(title));
12387
+ if (shared !== void 0) {
12388
+ const where = loaded.path === null ? "the config" : relative6(options.cwd, loaded.path);
12389
+ return fail(`${shared} is in ${where}, which the project shares. Remove it there.`);
12390
+ }
12391
+ await dropLocalPreviews(options.cwd, titles);
12392
+ await noteDirections(options.cwd, titles, "remove");
12393
+ if (options.json) {
12394
+ deps.log(JSON.stringify({ ok: true, removed: titles }));
12395
+ } else {
12396
+ for (const title of titles) deps.log(` removed ${title}`);
12397
+ deps.log("");
12398
+ deps.log(`The files behind ${titles.length === 1 ? "it" : "them"} stay where they are.`);
12399
+ }
12400
+ return { exitCode: 0 };
12401
+ }
12402
+
11326
12403
  // src/run-new.ts
11327
12404
  import { existsSync as existsSync6 } from "fs";
11328
- import { mkdir as mkdir11, readFile as readFile18, writeFile as writeFile13 } from "fs/promises";
11329
- import { dirname as dirname13, join as join21 } from "path";
12405
+ import { mkdir as mkdir12, readFile as readFile19, writeFile as writeFile14 } from "fs/promises";
12406
+ import { dirname as dirname13, join as join22 } from "path";
11330
12407
  async function readIfPresent2(path) {
11331
12408
  try {
11332
- return await readFile18(path, "utf8");
12409
+ return await readFile19(path, "utf8");
11333
12410
  } catch {
11334
12411
  return null;
11335
12412
  }
@@ -11337,7 +12414,7 @@ async function readIfPresent2(path) {
11337
12414
  async function runNew(options, deps) {
11338
12415
  let from;
11339
12416
  if (options.from !== void 0) {
11340
- const contents = await readIfPresent2(join21(options.cwd, options.from));
12417
+ const contents = await readIfPresent2(join22(options.cwd, options.from));
11341
12418
  if (contents === null) {
11342
12419
  const message2 = `${options.from} does not exist, so there is nothing to use as the baseline.`;
11343
12420
  if (options.json) deps.log(JSON.stringify({ ok: false, error: message2 }));
@@ -11348,8 +12425,8 @@ async function runNew(options, deps) {
11348
12425
  }
11349
12426
  const plan = planNew({
11350
12427
  surface: options.surface,
11351
- packageJson: await readIfPresent2(join21(options.cwd, "package.json")),
11352
- gitignore: await readIfPresent2(join21(options.cwd, ".gitignore")),
12428
+ packageJson: await readIfPresent2(join22(options.cwd, "package.json")),
12429
+ gitignore: await readIfPresent2(join22(options.cwd, ".gitignore")),
11353
12430
  from
11354
12431
  });
11355
12432
  const fail = (error) => {
@@ -11379,7 +12456,7 @@ async function runNew(options, deps) {
11379
12456
  deps.log(plan.instructions);
11380
12457
  return { exitCode: 0, written: [] };
11381
12458
  }
11382
- const existing = plan.writes.filter((write2) => existsSync6(join21(options.cwd, write2.path)));
12459
+ const existing = plan.writes.filter((write2) => existsSync6(join22(options.cwd, write2.path)));
11383
12460
  if (existing.length > 0) {
11384
12461
  return fail(
11385
12462
  `${existing[0]?.path} already exists. Delete it first, or pick another surface name.`
@@ -11387,13 +12464,13 @@ async function runNew(options, deps) {
11387
12464
  }
11388
12465
  const written = [];
11389
12466
  for (const write2 of plan.writes) {
11390
- const target = join21(options.cwd, write2.path);
11391
- await mkdir11(dirname13(target), { recursive: true });
11392
- await writeFile13(target, write2.contents, "utf8");
12467
+ const target = join22(options.cwd, write2.path);
12468
+ await mkdir12(dirname13(target), { recursive: true });
12469
+ await writeFile14(target, write2.contents, "utf8");
11393
12470
  written.push(write2.path);
11394
12471
  }
11395
12472
  if (plan.gitignore !== null) {
11396
- await writeFile13(join21(options.cwd, ".gitignore"), plan.gitignore, "utf8");
12473
+ await writeFile14(join22(options.cwd, ".gitignore"), plan.gitignore, "utf8");
11397
12474
  written.push(".gitignore");
11398
12475
  }
11399
12476
  if (options.json) {
@@ -11421,8 +12498,8 @@ async function runNew(options, deps) {
11421
12498
  }
11422
12499
 
11423
12500
  // src/run-log.ts
11424
- import { readFile as readFile19, readdir as readdir6 } from "fs/promises";
11425
- import { join as join22 } from "path";
12501
+ import { readFile as readFile20, readdir as readdir7 } from "fs/promises";
12502
+ import { join as join23 } from "path";
11426
12503
  function headline(markdown) {
11427
12504
  const first = markdown.split("\n", 1)[0] ?? "";
11428
12505
  return first.replace(/^#\s*/, "").trim();
@@ -11432,7 +12509,7 @@ async function runLog(options, deps) {
11432
12509
  const dir = loaded.config?.logDir ?? DEFAULT_LOG_DIR;
11433
12510
  let names;
11434
12511
  try {
11435
- names = (await readdir6(join22(options.cwd, dir))).filter((name) => name.endsWith(".md")).sort().reverse();
12512
+ names = (await readdir7(join23(options.cwd, dir))).filter((name) => name.endsWith(".md")).sort().reverse();
11436
12513
  } catch {
11437
12514
  names = [];
11438
12515
  }
@@ -11445,7 +12522,7 @@ async function runLog(options, deps) {
11445
12522
  else deps.error(error);
11446
12523
  return { exitCode: 1 };
11447
12524
  }
11448
- const markdown = await readFile19(join22(options.cwd, dir, found), "utf8");
12525
+ const markdown = await readFile20(join23(options.cwd, dir, found), "utf8");
11449
12526
  if (options.json) deps.log(JSON.stringify({ ok: true, entry: wanted, markdown }));
11450
12527
  else deps.log(markdown.trimEnd());
11451
12528
  return { exitCode: 0 };
@@ -11453,7 +12530,7 @@ async function runLog(options, deps) {
11453
12530
  const entries = await Promise.all(
11454
12531
  names.map(async (name) => ({
11455
12532
  entry: name.replace(/\.md$/, ""),
11456
- title: headline(await readFile19(join22(options.cwd, dir, name), "utf8")),
12533
+ title: headline(await readFile20(join23(options.cwd, dir, name), "utf8")),
11457
12534
  file: `${dir}/${name}`
11458
12535
  }))
11459
12536
  );
@@ -11471,21 +12548,21 @@ async function runLog(options, deps) {
11471
12548
  }
11472
12549
 
11473
12550
  // src/run-previews.ts
11474
- import { readFile as readFile20, writeFile as writeFile14 } from "fs/promises";
11475
- import { join as join23 } from "path";
12551
+ import { readFile as readFile21, writeFile as writeFile15 } from "fs/promises";
12552
+ import { join as join24 } from "path";
11476
12553
  function envelope(deps, ok, body) {
11477
12554
  deps.log(JSON.stringify({ ok, ...body }));
11478
12555
  }
11479
12556
  async function ensureIgnored(cwd) {
11480
- const path = join23(cwd, ".gitignore");
12557
+ const path = join24(cwd, ".gitignore");
11481
12558
  let current = null;
11482
12559
  try {
11483
- current = await readFile20(path, "utf8");
12560
+ current = await readFile21(path, "utf8");
11484
12561
  } catch {
11485
12562
  current = null;
11486
12563
  }
11487
12564
  const next = ignoreEntry(current);
11488
- if (next !== null) await writeFile14(path, next, "utf8");
12565
+ if (next !== null) await writeFile15(path, next, "utf8");
11489
12566
  }
11490
12567
  async function runAdd(options, deps) {
11491
12568
  const loaded = await loadConfig(options.cwd);
@@ -11521,6 +12598,8 @@ async function runAdd(options, deps) {
11521
12598
  }
11522
12599
  await ensureIgnored(options.cwd);
11523
12600
  const needsDevCommand = options.preview.branch !== void 0 && loaded.config?.devCommand === void 0;
12601
+ const rail = await recordedRail(options.cwd, deps.fetch ?? fetch);
12602
+ const opens = rail !== null && rail.titles.has(options.preview.title) ? interfaceUrl(rail.port, [options.preview.title]) : null;
11524
12603
  if (options.json) {
11525
12604
  const added = { added: options.preview.title };
11526
12605
  if (options.preview.url !== void 0) added.url = options.preview.url;
@@ -11531,11 +12610,13 @@ async function runAdd(options, deps) {
11531
12610
  if (needsDevCommand) {
11532
12611
  added.warning = "The config sets no devCommand, so Leglas cannot start this branch yet. Add devCommand (with {port}) to the config.";
11533
12612
  }
12613
+ if (opens !== null) added.interfaceUrl = opens;
11534
12614
  envelope(deps, true, added);
11535
12615
  } else {
11536
12616
  deps.log(
11537
12617
  ` added ${options.preview.title} ${options.preview.url ?? options.preview.file ?? ""}`
11538
12618
  );
12619
+ if (opens !== null) deps.log(` open ${opens}`);
11539
12620
  deps.log("");
11540
12621
  if (needsDevCommand) {
11541
12622
  deps.log(" ! The config sets no devCommand, so Leglas cannot start this branch yet.");
@@ -11559,12 +12640,10 @@ async function runList(options, deps) {
11559
12640
  ...local.previews
11560
12641
  ];
11561
12642
  if (options.json) {
12643
+ const rail = await recordedRail(options.cwd, deps.fetch ?? fetch);
11562
12644
  envelope(deps, errors.length === 0, {
11563
- // The whole record, not a summary of it. What the config holds about a
11564
- // preview — its note, its tags, the direction it is a variant of — is
11565
- // exactly what tells an agent why these are being compared, and leaving
11566
- // it out made the listing thinner than the reference block that points
11567
- // at it.
12645
+ // The whole record: a preview's note, tags and base direction are what
12646
+ // tell an agent why these are being compared.
11568
12647
  previews: previews.map((preview) => ({
11569
12648
  title: preview.title,
11570
12649
  url: preview.url,
@@ -11574,7 +12653,8 @@ async function runList(options, deps) {
11574
12653
  askedFor: preview.askedFor ?? null,
11575
12654
  local: preview.local,
11576
12655
  branch: preview.branch ?? null,
11577
- file: preview.file ?? null
12656
+ file: preview.file ?? null,
12657
+ interfaceUrl: rail !== null && rail.titles.has(preview.title) ? interfaceUrl(rail.port, [preview.title]) : null
11578
12658
  })),
11579
12659
  errors
11580
12660
  });
@@ -11653,8 +12733,11 @@ function tunnelFrom(value) {
11653
12733
  }
11654
12734
  }
11655
12735
  function linkFrom(value) {
11656
- if (!isJsonObject(value) || !isString2(value.localUrl) || !isNumber2(value.expiresAt)) return null;
12736
+ if (!isJsonObject(value) || !isString2(value.id) || !isString2(value.localUrl) || !isNumber2(value.expiresAt)) {
12737
+ return null;
12738
+ }
11657
12739
  return {
12740
+ id: value.id,
11658
12741
  name: isString2(value.name) ? value.name : "",
11659
12742
  url: isString2(value.url) ? value.url : null,
11660
12743
  localUrl: value.localUrl,
@@ -11775,11 +12858,12 @@ async function runShare(options, deps) {
11775
12858
  const running = await readShare(found.base, request);
11776
12859
  if (!running.ok) return fail(running.error);
11777
12860
  const { installed } = running.value;
11778
- const report = (share2, already, leftOut2) => {
12861
+ const report = (share2, already, leftOut2, done = null) => {
11779
12862
  if (options.json) {
11780
12863
  deps.log(
11781
12864
  JSON.stringify({
11782
12865
  ok: true,
12866
+ ...done?.json,
11783
12867
  alreadySharing: already,
11784
12868
  share: {
11785
12869
  scope: share2.scope,
@@ -11793,6 +12877,7 @@ async function runShare(options, deps) {
11793
12877
  );
11794
12878
  return { exitCode: 0 };
11795
12879
  }
12880
+ if (done !== null) deps.log(done.line);
11796
12881
  deps.log(` sharing ${describe(share2)}${already ? " (already running)" : ""}`);
11797
12882
  if (share2.reach === "listed") {
11798
12883
  deps.log(" reach only the paths the share lists; allow more from the share panel");
@@ -11802,6 +12887,9 @@ async function runShare(options, deps) {
11802
12887
  if (link.url !== null) deps.log(` link ${label}${link.url}`);
11803
12888
  else deps.log(` local ${label}${link.localUrl}`);
11804
12889
  }
12890
+ if (share2.links.length === 0) {
12891
+ deps.log(" links no link is live; npx leglas share --rotate starts one");
12892
+ }
11805
12893
  const tunnel = tunnelLine(share2.tunnel, installed);
11806
12894
  if (tunnel !== null) deps.log(` tunnel ${tunnel}`);
11807
12895
  const ends = share2.links.map((link) => link.expiresAt);
@@ -11812,6 +12900,66 @@ async function runShare(options, deps) {
11812
12900
  deps.log(" stop npx leglas share --stop");
11813
12901
  return { exitCode: 0 };
11814
12902
  };
12903
+ const settle = async (share2) => {
12904
+ let settled2 = share2;
12905
+ for (let waited = 0; settled2.tunnel.status === "starting" && waited < TUNNEL_WAIT_MS; waited += TUNNEL_POLL_MS) {
12906
+ await sleep(TUNNEL_POLL_MS);
12907
+ const read = await readShare(found.base, request);
12908
+ if (!read.ok) return read;
12909
+ if (read.value.share === null) return { ok: true, value: null };
12910
+ settled2 = read.value.share;
12911
+ }
12912
+ return { ok: true, value: settled2 };
12913
+ };
12914
+ const revoke = options.revoke ?? null;
12915
+ if (options.rotate === true || revoke !== null) {
12916
+ const current2 = running.value.share;
12917
+ if (current2 === null) {
12918
+ return fail("Nothing is being shared. Start a share with npx leglas share.");
12919
+ }
12920
+ if (revoke !== null) {
12921
+ const named = revoke;
12922
+ const link = current2.links.find(
12923
+ (candidate) => candidate.id === named || candidate.url === named || candidate.localUrl === named
12924
+ );
12925
+ if (link === void 0) {
12926
+ return fail(`No live link is ${JSON.stringify(named)}. npx leglas share lists them.`);
12927
+ }
12928
+ const revoked = await post(
12929
+ found.base,
12930
+ "/leglas/api/share/grants/revoke",
12931
+ { id: link.id },
12932
+ request,
12933
+ "Leglas could not end that link."
12934
+ );
12935
+ if (!revoked.ok) return fail(revoked.error);
12936
+ const after = isJsonObject(revoked.value) ? shareFrom(revoked.value.share) : null;
12937
+ if (after === null) return fail("Leglas ended the link but did not say what is left.");
12938
+ return report(after, true, [], {
12939
+ json: { revoked: link },
12940
+ line: ` revoked ${link.name === "" ? "" : `${link.name} `}${link.url ?? link.localUrl}`
12941
+ });
12942
+ }
12943
+ const rotated = await post(
12944
+ found.base,
12945
+ "/leglas/api/share/rotate",
12946
+ {},
12947
+ request,
12948
+ "Leglas could not replace the links."
12949
+ );
12950
+ if (!rotated.ok) return fail(rotated.error);
12951
+ const replaced = isJsonObject(rotated.value) ? shareFrom(rotated.value.share) : null;
12952
+ if (replaced === null) return fail("Leglas replaced the links but did not say with what.");
12953
+ const settled2 = await settle(replaced);
12954
+ if (!settled2.ok) return fail(settled2.error);
12955
+ if (settled2.value === null) {
12956
+ return fail("The share was stopped while its new tunnel was starting.");
12957
+ }
12958
+ return report(settled2.value, true, [], {
12959
+ json: { rotated: true },
12960
+ line: " rotated every earlier link has ended"
12961
+ });
12962
+ }
11815
12963
  const loaded = await loadConfig(options.cwd);
11816
12964
  const local = await readLocalPreviews(options.cwd);
11817
12965
  const renames = await readRenames(options.cwd);
@@ -11880,20 +13028,16 @@ async function runShare(options, deps) {
11880
13028
  if (!created.ok) {
11881
13029
  return created.answered ? fail(created.error) : giveUp("Leglas stopped answering while the share was starting.", false);
11882
13030
  }
11883
- let share = isJsonObject(created.value) ? shareFrom(created.value.share) : null;
13031
+ const share = isJsonObject(created.value) ? shareFrom(created.value.share) : null;
11884
13032
  if (share === null) {
11885
13033
  return giveUp("Leglas started a share this version of the command cannot read.");
11886
13034
  }
11887
- for (let waited = 0; share.tunnel.status === "starting" && waited < TUNNEL_WAIT_MS; waited += TUNNEL_POLL_MS) {
11888
- await sleep(TUNNEL_POLL_MS);
11889
- const read = await readShare(found.base, request);
11890
- if (!read.ok) return giveUp(read.error);
11891
- if (read.value.share === null) {
11892
- return fail("The share was stopped while its tunnel was starting.");
11893
- }
11894
- share = read.value.share;
13035
+ const settled = await settle(share);
13036
+ if (!settled.ok) return giveUp(settled.error);
13037
+ if (settled.value === null) {
13038
+ return fail("The share was stopped while its tunnel was starting.");
11895
13039
  }
11896
- return report(share, false, leftOut);
13040
+ return report(settled.value, false, leftOut);
11897
13041
  }
11898
13042
 
11899
13043
  // src/show.ts
@@ -11907,9 +13051,9 @@ function describe2(preview) {
11907
13051
  branch: preview.branch ?? null,
11908
13052
  file: preview.file ?? null,
11909
13053
  local: preview.local === true,
11910
- // A file preview names its own source. Everything else is decoded from the
13054
+ // A file preview names its own source; anything else is decoded from the
11911
13055
  // URL, and a URL outside the convention yields nothing rather than a path
11912
- // that looks authoritative and is not there.
13056
+ // that isn't there.
11913
13057
  target: preview.file ?? targetFor(preview.url)
11914
13058
  };
11915
13059
  }
@@ -11927,8 +13071,7 @@ function planShow({ title, previews, requests }) {
11927
13071
  ok: true,
11928
13072
  direction: describe2(found),
11929
13073
  variants,
11930
- // Its own variants are already listed in full, so they are not repeated
11931
- // here; this is the rest of the comparison.
13074
+ // Its variants are listed above, so this is the rest of the comparison.
11932
13075
  comparedWith: previews.map((preview) => preview.title).filter((other) => other !== title && !variantTitles.has(other)),
11933
13076
  requests: requests.filter((request) => request.title === title).map((request) => ({
11934
13077
  id: request.id,
@@ -11980,21 +13123,23 @@ async function runShow(options, deps) {
11980
13123
  }
11981
13124
  const envelope2 = {
11982
13125
  ok: true,
11983
- direction: plan.direction,
13126
+ direction: { ...plan.direction, interfaceUrl: null },
11984
13127
  variants: plan.variants,
11985
13128
  comparedWith: plan.comparedWith,
11986
13129
  requests: plan.requests
11987
13130
  };
13131
+ const request = deps.fetch ?? fetch;
13132
+ let running = null;
11988
13133
  if (options.screenshot) {
11989
13134
  const fail = (error) => {
11990
13135
  if (options.json) deps.log(JSON.stringify({ ok: false, error }));
11991
13136
  else deps.error(error);
11992
13137
  return { exitCode: 1 };
11993
13138
  };
11994
- const request = deps.fetch ?? fetch;
11995
13139
  const found = await findLeglas(options.cwd, options.port, request);
11996
13140
  if (!found.ok) return fail(found.error);
11997
13141
  const port = found.port;
13142
+ running = port;
11998
13143
  let response;
11999
13144
  try {
12000
13145
  response = await request(`http://127.0.0.1:${port}/leglas/api/capture`, {
@@ -12028,17 +13173,22 @@ async function runShow(options, deps) {
12028
13173
  cut: captured.cut === true
12029
13174
  };
12030
13175
  }
13176
+ const rail = running === null ? await recordedRail(options.cwd, request) : { port: running, titles: await railTitles(running, request) ?? /* @__PURE__ */ new Set() };
13177
+ if (rail !== null && rail.titles.has(plan.direction.title)) {
13178
+ envelope2.direction.interfaceUrl = interfaceUrl(rail.port, [plan.direction.title]);
13179
+ }
12031
13180
  if (options.json) {
12032
13181
  deps.log(JSON.stringify(envelope2));
12033
13182
  return { exitCode: 0 };
12034
13183
  }
12035
- const { direction } = plan;
13184
+ const { direction } = envelope2;
12036
13185
  deps.log(` ${direction.title}${direction.local ? " (local)" : ""}`);
12037
13186
  if (direction.note !== null) deps.log(` ${direction.note}`);
12038
13187
  deps.log("");
12039
13188
  if (direction.target !== null) deps.log(` file ${direction.target}`);
12040
13189
  if (direction.branch !== null) deps.log(` branch ${direction.branch}`);
12041
13190
  deps.log(` url ${direction.url}`);
13191
+ if (direction.interfaceUrl !== null) deps.log(` open ${direction.interfaceUrl}`);
12042
13192
  if (direction.tags.length > 0) deps.log(` tags ${direction.tags.join(", ")}`);
12043
13193
  if (direction.basedOn !== null) deps.log(` variant of ${direction.basedOn}`);
12044
13194
  if (plan.variants.length > 0) {
@@ -12067,7 +13217,7 @@ async function runShow(options, deps) {
12067
13217
  if (plan.requests.length > 0) {
12068
13218
  deps.log("");
12069
13219
  deps.log(` Pending, not yet done (${plan.requests.length}):`);
12070
- for (const request of plan.requests) deps.log(` ${request.status} ${request.intent}`);
13220
+ for (const request2 of plan.requests) deps.log(` ${request2.status} ${request2.intent}`);
12071
13221
  deps.log("");
12072
13222
  deps.log(" Run npx leglas requests --json for the full prompts.");
12073
13223
  }
@@ -12076,21 +13226,21 @@ async function runShow(options, deps) {
12076
13226
 
12077
13227
  // src/run-watch.ts
12078
13228
  import { spawn as spawn4 } from "child_process";
12079
- import { mkdir as mkdir12, readFile as readFile21, writeFile as writeFile15 } from "fs/promises";
12080
- import { dirname as dirname14, join as join24 } from "path";
13229
+ import { mkdir as mkdir13, readFile as readFile22, writeFile as writeFile16 } from "fs/promises";
13230
+ import { dirname as dirname14, join as join25 } from "path";
12081
13231
  var POLL_MS3 = 2e3;
12082
13232
  var HEARTBEAT_TIMEOUT_MS = 1e3;
12083
13233
  async function saveTemplate(cwd, run4) {
12084
- const path = join24(cwd, WATCH_PATH);
13234
+ const path = join25(cwd, WATCH_PATH);
12085
13235
  let config = {};
12086
13236
  try {
12087
- const parsed2 = JSON.parse(await readFile21(path, "utf8"));
13237
+ const parsed2 = JSON.parse(await readFile22(path, "utf8"));
12088
13238
  if (isJsonObject(parsed2)) config = parsed2;
12089
13239
  } catch {
12090
13240
  }
12091
13241
  config.run = run4;
12092
- await mkdir12(dirname14(path), { recursive: true });
12093
- await writeFile15(path, `${JSON.stringify(config, null, 2)}
13242
+ await mkdir13(dirname14(path), { recursive: true });
13243
+ await writeFile16(path, `${JSON.stringify(config, null, 2)}
12094
13244
  `, "utf8");
12095
13245
  }
12096
13246
  function spawnAgent(command, args, cwd, json) {
@@ -12254,7 +13404,7 @@ async function runWatch(options, deps) {
12254
13404
 
12255
13405
  // src/bin-start.ts
12256
13406
  import { spawn as spawn5 } from "child_process";
12257
- import { realpathSync as realpathSync2 } from "fs";
13407
+ import { realpathSync as realpathSync3 } from "fs";
12258
13408
 
12259
13409
  // src/shutdown.ts
12260
13410
  var SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
@@ -12332,13 +13482,13 @@ function createHandoff() {
12332
13482
  import { existsSync as existsSync7 } from "fs";
12333
13483
  import { realpath as realpath4 } from "fs/promises";
12334
13484
  import { createRequire } from "module";
12335
- import { basename as basename6, dirname as dirname15, join as join25, relative as relative7, resolve as resolve4 } from "path";
13485
+ import { basename as basename6, dirname as dirname15, join as join26, relative as relative8, resolve as resolve4 } from "path";
12336
13486
  import { fileURLToPath } from "url";
12337
13487
 
12338
13488
  // src/dev-server-owner.ts
12339
13489
  import { execFile as execFile2 } from "child_process";
12340
13490
  import { realpath as realpath3 } from "fs/promises";
12341
- import { basename as basename5, isAbsolute as isAbsolute4, relative as relative6, resolve as resolve3 } from "path";
13491
+ import { basename as basename5, isAbsolute as isAbsolute4, relative as relative7, resolve as resolve3 } from "path";
12342
13492
  import { promisify as promisify2 } from "util";
12343
13493
  var run2 = promisify2(execFile2);
12344
13494
  var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
@@ -12407,7 +13557,7 @@ async function inspectLocalDevServer(origin) {
12407
13557
  }
12408
13558
  }
12409
13559
  function isInside(parent, child) {
12410
- const path = relative6(resolve3(parent), resolve3(child));
13560
+ const path = relative7(resolve3(parent), resolve3(child));
12411
13561
  return path === "" || !path.startsWith("..") && !isAbsolute4(path);
12412
13562
  }
12413
13563
  function devServerOwnerWarning(origin, projectRoot, owners) {
@@ -12421,8 +13571,8 @@ function devServerOwnerWarning(origin, projectRoot, owners) {
12421
13571
 
12422
13572
  // src/run.ts
12423
13573
  function findShellDir() {
12424
- const bundled = join25(dirname15(fileURLToPath(import.meta.url)), "shell");
12425
- if (existsSync7(join25(bundled, "index.html"))) return bundled;
13574
+ const bundled = join26(dirname15(fileURLToPath(import.meta.url)), "shell");
13575
+ if (existsSync7(join26(bundled, "index.html"))) return bundled;
12426
13576
  try {
12427
13577
  const require2 = createRequire(import.meta.url);
12428
13578
  return dirname15(require2.resolve("@leglas/shell/dist/index.html"));
@@ -12436,7 +13586,7 @@ function shellWord(value) {
12436
13586
  return `'${value.replaceAll("'", `'\\''`)}'`;
12437
13587
  }
12438
13588
  function embeddedLeglasCommand() {
12439
- const entry = join25(dirname15(fileURLToPath(import.meta.url)), "bin.js");
13589
+ const entry = join26(dirname15(fileURLToPath(import.meta.url)), "bin.js");
12440
13590
  if (!existsSync7(entry)) return "npx -y leglas";
12441
13591
  return [process.execPath, entry].map(shellWord).join(" ");
12442
13592
  }
@@ -12475,7 +13625,7 @@ async function runWithServices(options, deps, services = {}) {
12475
13625
  const fileMounts = /* @__PURE__ */ new Map();
12476
13626
  for (const preview of merged?.previews ?? []) {
12477
13627
  if (preview.file !== void 0) {
12478
- const absolute = join25(options.cwd, preview.file);
13628
+ const absolute = join26(options.cwd, preview.file);
12479
13629
  if (!existsSync7(absolute)) {
12480
13630
  previewErrors.push(
12481
13631
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -12507,8 +13657,8 @@ async function runWithServices(options, deps, services = {}) {
12507
13657
  configWarnings,
12508
13658
  fileMounts,
12509
13659
  shellDir: findShellDir(),
12510
- // The config file identifies the project when there is one; otherwise the
12511
- // directory does. Either way saved layout survives a port change.
13660
+ // The config file identifies the project, else the directory, so saved
13661
+ // layout survives a port change.
12512
13662
  project: loaded.path ?? options.cwd,
12513
13663
  cwd: options.cwd,
12514
13664
  leglasCommand: embeddedLeglasCommand()
@@ -12537,7 +13687,7 @@ async function runWithServices(options, deps, services = {}) {
12537
13687
  })
12538
13688
  );
12539
13689
  } else {
12540
- const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative7(options.cwd, loaded.path) || loaded.path;
13690
+ const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative8(options.cwd, loaded.path) || loaded.path;
12541
13691
  deps.log(`Leglas ${url}`);
12542
13692
  deps.log(
12543
13693
  `app ${devServer}${app !== null ? " (started by Leglas)" : health.reachable ? "" : " (not reachable)"}`
@@ -12594,7 +13744,7 @@ async function runWithServices(options, deps, services = {}) {
12594
13744
  async function startViewer(options, deps) {
12595
13745
  let entry = deps.entry;
12596
13746
  try {
12597
- entry = (deps.realpath ?? realpathSync2)(entry);
13747
+ entry = (deps.realpath ?? realpathSync3)(entry);
12598
13748
  } catch {
12599
13749
  }
12600
13750
  const updates = (deps.createUpdateService ?? createUpdateService)({
@@ -12627,7 +13777,7 @@ async function startViewer(options, deps) {
12627
13777
  }, handoff.target);
12628
13778
  }
12629
13779
 
12630
- // src/bin.ts
13780
+ // src/help.ts
12631
13781
  var HELP = `leglas - compare design directions inside your own running app
12632
13782
 
12633
13783
  Usage
@@ -12641,25 +13791,30 @@ Usage
12641
13791
  leglas log [entry] What past explorations decided
12642
13792
  leglas show <title> Everything Leglas knows about one direction
12643
13793
  leglas share [title] [title] Share the rail, one direction or a pair
13794
+ leglas link [title] [title] A link that opens the interface on them
13795
+ leglas remove <title>... Take directions this machine registered off the rail
12644
13796
  leglas requests Show change requests made from the interface
12645
13797
  leglas watch --run "<cmd>" Hand each request to your agent as it arrives
12646
13798
  leglas keep <title> --to <path> Keep a winner and end the exploration
12647
13799
 
12648
13800
  Options
12649
13801
  --user-port <port> Port your dev server is on (default: from config, or 3000)
12650
- --port <port> Port for Leglas itself (default: 4100, next free if taken)
13802
+ --port <port> Port for Leglas itself (default: ${DEFAULT_PORT}, next free if taken)
12651
13803
  --config <path> Config file to use instead of searching upward
12652
13804
  --no-open Do not open the browser
12653
13805
  --json Print a single machine-readable envelope
12654
13806
  -h, --help Show this
12655
13807
  -v, --version Show the version
12656
13808
 
13809
+ Options for init
13810
+ --force Rewrite the AGENTS.md section if it is already there
13811
+
12657
13812
  Options for new
12658
13813
  --print Print the scaffold instead of writing it
12659
13814
  --from <path> Use an existing component as the baseline
12660
13815
 
12661
13816
  Options for explore
12662
- --count <n> How many directions (default 3; up to 6 with --build)
13817
+ --count <n> How many directions (default ${DEFAULT_EXPLORE_COUNT}; up to ${MAX_DIRECTIONS} with --build)
12663
13818
  --based-on <title> Variants of an existing direction instead of new ones
12664
13819
  --build Build the set with your agent (Claude or Codex) in the
12665
13820
  running Leglas, instead of printing a brief for an agent
@@ -12667,10 +13822,13 @@ Options for explore
12667
13822
  with --based-on)
12668
13823
  --port <port> Running Leglas port (needs --build)
12669
13824
 
13825
+ Options for requests
13826
+ --clear Acknowledge the requests once they are done
13827
+
12670
13828
  Options for watch
12671
13829
  --run <command> Your agent, with {prompt} where the request goes, for
12672
13830
  example "claude -p {prompt}". Remembered after first use
12673
- --port <port> Port Leglas itself is on (default: 4100)
13831
+ --port <port> Port Leglas itself is on (default: ${DEFAULT_PORT})
12674
13832
  --json One JSON line per event; the agent's output goes to stderr
12675
13833
 
12676
13834
  Options for classify
@@ -12687,15 +13845,22 @@ Options for add
12687
13845
 
12688
13846
  Options for show
12689
13847
  --screenshot Render the direction and write a PNG
12690
- --width <n> Capture width from 320 to 3840 (needs --screenshot)
13848
+ --width <n> Capture width from ${MIN_SHOW_WIDTH} to ${MAX_SHOW_WIDTH} (needs --screenshot)
12691
13849
  --port <port> Running Leglas port (needs --screenshot)
12692
13850
 
12693
13851
  Options for share
12694
- --reach <open|listed> How far viewers reach into the app (default open)
13852
+ --reach <open|listed> How far viewers reach into the app (default ${DEFAULT_SHARE_REACH})
12695
13853
  --tunnel <name> cloudflared, ngrok or none (default: the first found)
12696
13854
  --stop End the share, and every link to it
13855
+ --rotate End every link and start a new one, through a new tunnel
13856
+ --revoke <link> End one link, named by its address or its id
13857
+ --port <port> Running Leglas port
13858
+
13859
+ Options for link
12697
13860
  --port <port> Running Leglas port
12698
13861
  `;
13862
+
13863
+ // src/bin.ts
12699
13864
  function version() {
12700
13865
  const require2 = createRequire2(import.meta.url);
12701
13866
  const pkg = require2("../package.json");
@@ -12827,6 +13992,8 @@ if (parsed.kind === "share") {
12827
13992
  reach: parsed.reach,
12828
13993
  tunnel: parsed.tunnel,
12829
13994
  stop: parsed.stop,
13995
+ rotate: parsed.rotate,
13996
+ revoke: parsed.revoke,
12830
13997
  port: parsed.port,
12831
13998
  json: parsed.json,
12832
13999
  cwd: process.cwd()
@@ -12835,6 +14002,20 @@ if (parsed.kind === "share") {
12835
14002
  );
12836
14003
  process.exit(outcome.exitCode);
12837
14004
  }
14005
+ if (parsed.kind === "remove") {
14006
+ const outcome = await runRemove(
14007
+ { titles: parsed.titles, json: parsed.json, cwd: process.cwd() },
14008
+ previewDeps
14009
+ );
14010
+ process.exit(outcome.exitCode);
14011
+ }
14012
+ if (parsed.kind === "link") {
14013
+ const outcome = await runLink(
14014
+ { titles: parsed.titles, port: parsed.port, json: parsed.json, cwd: process.cwd() },
14015
+ previewDeps
14016
+ );
14017
+ process.exit(outcome.exitCode);
14018
+ }
12838
14019
  if (parsed.kind === "show") {
12839
14020
  const outcome = await runShow(
12840
14021
  {