leglas 0.4.0 → 0.5.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
@@ -63,6 +63,7 @@ function parseAdd(rest) {
63
63
  let branch;
64
64
  let file;
65
65
  let basedOn;
66
+ let askedFor;
66
67
  const tags = [];
67
68
  let json = false;
68
69
  for (let index = 0; index < rest.length; index += 1) {
@@ -81,7 +82,9 @@ function parseAdd(rest) {
81
82
  } else {
82
83
  value = argument.slice(equals + 1);
83
84
  }
84
- if (!["--title", "--url", "--note", "--tag", "--branch", "--file", "--based-on"].includes(flag)) {
85
+ if (!["--title", "--url", "--note", "--tag", "--branch", "--file", "--based-on", "--asked-for"].includes(
86
+ flag
87
+ )) {
85
88
  return { kind: "error", message: `leglas add does not take ${flag}.` };
86
89
  }
87
90
  if (value === void 0 || value === "") {
@@ -93,6 +96,7 @@ function parseAdd(rest) {
93
96
  else if (flag === "--branch") branch = value;
94
97
  else if (flag === "--file") file = value;
95
98
  else if (flag === "--based-on") basedOn = value;
99
+ else if (flag === "--asked-for") askedFor = value;
96
100
  else tags.push(value);
97
101
  }
98
102
  if (title === void 0) {
@@ -106,7 +110,16 @@ function parseAdd(rest) {
106
110
  }
107
111
  return {
108
112
  kind: "add",
109
- preview: { title, url, note, tags: tags.length > 0 ? tags : void 0, branch, file, basedOn },
113
+ preview: {
114
+ title,
115
+ url,
116
+ note,
117
+ tags: tags.length > 0 ? tags : void 0,
118
+ branch,
119
+ file,
120
+ basedOn,
121
+ askedFor
122
+ },
110
123
  json
111
124
  };
112
125
  }
@@ -353,7 +366,7 @@ function parseArgs(argv) {
353
366
 
354
367
  // src/run-classify.ts
355
368
  import { stat } from "fs/promises";
356
- import { join as join8 } from "path";
369
+ import { join as join10 } from "path";
357
370
 
358
371
  // ../server/dist/config.js
359
372
  var DEFAULT_DEV_SERVER = "http://localhost:3000";
@@ -448,6 +461,10 @@ function normalizeConfig(raw, options = {}) {
448
461
  if (basedOn !== void 0 && (typeof basedOn !== "string" || basedOn.trim() === "")) {
449
462
  errors.push(`${at} has a basedOn that is not a direction title.`);
450
463
  }
464
+ const askedFor = entry["askedFor"];
465
+ if (askedFor !== void 0 && (typeof askedFor !== "string" || askedFor.trim() === "")) {
466
+ errors.push(`${at} has an askedFor that is not a change request.`);
467
+ }
451
468
  const tags = entry["tags"];
452
469
  previews.push({
453
470
  title: typeof title === "string" ? title : "",
@@ -456,7 +473,8 @@ function normalizeConfig(raw, options = {}) {
456
473
  tags: Array.isArray(tags) ? tags.filter((tag) => typeof tag === "string") : [],
457
474
  ...typeof branch === "string" ? { branch } : {},
458
475
  ...typeof file === "string" ? { file } : {},
459
- ...typeof basedOn === "string" && basedOn.trim() !== "" ? { basedOn } : {}
476
+ ...typeof basedOn === "string" && basedOn.trim() !== "" ? { basedOn } : {},
477
+ ...typeof askedFor === "string" && askedFor.trim() !== "" ? { askedFor } : {}
460
478
  });
461
479
  });
462
480
  const devCommand = source["devCommand"];
@@ -572,6 +590,10 @@ import { spawn } from "child_process";
572
590
  import { constants } from "fs";
573
591
  import { access, mkdir, readFile, writeFile } from "fs/promises";
574
592
  import { delimiter, dirname, isAbsolute, join, relative } from "path";
593
+ var CODEX_WORKSPACE_CONFIG = [
594
+ "-c",
595
+ "sandbox_workspace_write.network_access=true"
596
+ ];
575
597
  var KNOWN_AGENTS = {
576
598
  claude: {
577
599
  name: "Claude",
@@ -602,6 +624,12 @@ var KNOWN_AGENTS = {
602
624
  "--permission-mode",
603
625
  "acceptEdits"
604
626
  ],
627
+ // Non-interactive Claude cannot approve a Bash call: acceptEdits covers
628
+ // files, so a command the prompt requires is refused every time with
629
+ // nobody there to say yes. This allows exactly that command and nothing
630
+ // wider. Codex needs no equivalent, because workspace-write already lets
631
+ // it run commands.
632
+ allowArgs: (command) => ["--allowedTools", `Bash(${command} *)`],
605
633
  // Every stream-json event names its session.
606
634
  sessionFrom: (event) => typeof event.session_id === "string" && event.session_id !== "" ? event.session_id : null,
607
635
  authArgs: ["auth", "status"],
@@ -622,15 +650,41 @@ var KNOWN_AGENTS = {
622
650
  codex: {
623
651
  name: "Codex",
624
652
  binary: "codex",
625
- args: (prompt) => ["exec", "--json", "-s", "workspace-write", prompt],
626
- terminalArgs: (prompt) => ["exec", "-s", "workspace-write", prompt],
653
+ // `--skip-git-repo-check` is what lets Codex run at all in a project the
654
+ // user never put under version control: without it codex-cli refuses
655
+ // before it reaches a model, with "Not inside a trusted directory and
656
+ // --skip-git-repo-check was not specified", and every Codex request in a
657
+ // non-git project fails for a reason nothing in Leglas explained. The flag
658
+ // moves that precondition and only that: `-s workspace-write` still
659
+ // confines writes to the project, so the sandbox boundary is unchanged,
660
+ // and in a git repository the flag does nothing at all.
661
+ args: (prompt) => [
662
+ "exec",
663
+ "--json",
664
+ ...CODEX_WORKSPACE_CONFIG,
665
+ "-s",
666
+ "workspace-write",
667
+ "--skip-git-repo-check",
668
+ prompt
669
+ ],
670
+ terminalArgs: (prompt) => [
671
+ "exec",
672
+ ...CODEX_WORKSPACE_CONFIG,
673
+ "-s",
674
+ "workspace-write",
675
+ "--skip-git-repo-check",
676
+ prompt
677
+ ],
627
678
  // No sandbox flag here: `codex exec resume` refuses it and inherits the
628
- // session's own sandbox, which the first turn set to workspace-write.
679
+ // session's own sandbox, which the first turn set to workspace-write. The
680
+ // repository check is per invocation, so resume needs the flag of its own.
629
681
  resumeArgs: (sessionId, prompt) => [
630
682
  "exec",
631
683
  "resume",
632
684
  sessionId,
633
685
  "--json",
686
+ ...CODEX_WORKSPACE_CONFIG,
687
+ "--skip-git-repo-check",
634
688
  prompt
635
689
  ],
636
690
  sessionFrom: (event) => event.type === "thread.started" && typeof event.thread_id === "string" ? event.thread_id : null,
@@ -656,7 +710,7 @@ var KNOWN_AGENTS = {
656
710
  }
657
711
  };
658
712
  var PROBE_TIMEOUT_MS = 3e3;
659
- function execProbe(binary, args) {
713
+ function execProbe(binary, args, timeoutMs = PROBE_TIMEOUT_MS) {
660
714
  return new Promise((resolve) => {
661
715
  let child;
662
716
  try {
@@ -669,7 +723,10 @@ function execProbe(binary, args) {
669
723
  if (stdout.length < 4096)
670
724
  stdout += chunk.toString();
671
725
  });
672
- const deadline = setTimeout(() => child.kill("SIGKILL"), PROBE_TIMEOUT_MS);
726
+ const deadline = setTimeout(() => {
727
+ child.kill("SIGKILL");
728
+ resolve(null);
729
+ }, timeoutMs);
673
730
  child.once("error", () => {
674
731
  clearTimeout(deadline);
675
732
  resolve(null);
@@ -738,10 +795,10 @@ function shownCommand(value) {
738
795
  function claudeActivity(event, cwd) {
739
796
  if (event.type !== "assistant")
740
797
  return null;
741
- const message = record(event.message);
742
- if (message === null || !Array.isArray(message.content))
798
+ const message2 = record(event.message);
799
+ if (message2 === null || !Array.isArray(message2.content))
743
800
  return null;
744
- for (const rawBlock of message.content) {
801
+ for (const rawBlock of message2.content) {
745
802
  const block = record(rawBlock);
746
803
  if (block?.type !== "tool_use" || typeof block.name !== "string")
747
804
  continue;
@@ -810,6 +867,25 @@ function sessionFrom(agent, line) {
810
867
  return null;
811
868
  return KNOWN_AGENTS[agent].sessionFrom(event);
812
869
  }
870
+ function retryFrom(agent, line) {
871
+ if (agent !== "claude" && agent !== "cursor")
872
+ return null;
873
+ let event;
874
+ try {
875
+ event = record(JSON.parse(line));
876
+ } catch {
877
+ return null;
878
+ }
879
+ if (event === null || event.type !== "system" || event.subtype !== "api_retry")
880
+ return null;
881
+ const attempt = typeof event.attempt === "number" ? event.attempt : 1;
882
+ return {
883
+ attempt,
884
+ max: typeof event.max_retries === "number" ? event.max_retries : null,
885
+ status: typeof event.error_status === "number" ? event.error_status : null,
886
+ reason: typeof event.error === "string" && event.error !== "" ? event.error.toLowerCase() : null
887
+ };
888
+ }
813
889
  function isAgentChoice(value) {
814
890
  return value === "custom" || typeof value === "string" && Object.hasOwn(KNOWN_AGENTS, value);
815
891
  }
@@ -953,8 +1029,8 @@ async function loadConfig(cwd) {
953
1029
  exported = module.default;
954
1030
  }
955
1031
  } catch (error) {
956
- const message = error instanceof Error ? error.message : String(error);
957
- return { config: null, errors: [`${label} could not be loaded: ${message}`], path };
1032
+ const message2 = error instanceof Error ? error.message : String(error);
1033
+ return { config: null, errors: [`${label} could not be loaded: ${message2}`], path };
958
1034
  }
959
1035
  const result2 = normalizeConfig(exported);
960
1036
  return {
@@ -973,8 +1049,17 @@ async function readLocalPreviews(cwd) {
973
1049
  let raw;
974
1050
  try {
975
1051
  raw = await readFile3(path, "utf8");
976
- } catch {
977
- return { previews: [], errors: [] };
1052
+ } catch (error) {
1053
+ const code = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : null;
1054
+ if (code === "ENOENT") {
1055
+ return { previews: [], errors: [] };
1056
+ }
1057
+ return {
1058
+ previews: [],
1059
+ errors: [
1060
+ `${LOCAL_PREVIEWS_PATH} could not be read (${code ?? "unknown error"}). Check its permissions and file type; nothing shared is lost.`
1061
+ ]
1062
+ };
978
1063
  }
979
1064
  let parsed2;
980
1065
  try {
@@ -1015,7 +1100,8 @@ async function addLocalPreview(cwd, input, shared) {
1015
1100
  ...input.tags === void 0 ? {} : { tags: input.tags },
1016
1101
  ...input.branch === void 0 ? {} : { branch: input.branch },
1017
1102
  ...input.file === void 0 ? {} : { file: input.file },
1018
- ...input.basedOn === void 0 ? {} : { basedOn: input.basedOn }
1103
+ ...input.basedOn === void 0 ? {} : { basedOn: input.basedOn },
1104
+ ...input.askedFor === void 0 ? {} : { askedFor: input.askedFor }
1019
1105
  };
1020
1106
  const check = normalizeConfig({ previews: [candidate] }, { requireDevCommand: false });
1021
1107
  if (check.config === null) {
@@ -1247,17 +1333,220 @@ async function startAppProcess(options) {
1247
1333
  throw new Error(`${options.label} did not start within ${Math.round(readyTimeoutMs / 1e3)}s. Check that its dev command serves the port it is given.`);
1248
1334
  }
1249
1335
 
1250
- // ../server/dist/requests.js
1251
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1336
+ // ../server/dist/failure.js
1337
+ var NEEDS_TRUST = /not inside a trusted directory|--skip-git-repo-check/i;
1338
+ var MISSING_BINARY = /\b(ENOENT|EACCES|ENOTDIR)\b/;
1339
+ var NOT_SIGNED_IN = /not logged in|not signed in|please (?:re-?)?(?:run|sign|log)\s*in|\/login\b|invalid api key|unauthorized|authentication_failed|\b401\b/i;
1340
+ var LIMIT = /\b429\b|rate limit|usage limit|quota exceeded|too many requests/i;
1341
+ var OVERLOADED = /\b(?:503|529)\b|overloaded|service unavailable/i;
1342
+ function fromStatus(status, reason) {
1343
+ if (status === 401 || status === 403 || reason === "authentication_failed")
1344
+ return "not-signed-in";
1345
+ if (status === 429 || reason === "rate_limit")
1346
+ return "provider-limit";
1347
+ if (status === 529 || status === 503 || reason === "overloaded")
1348
+ return "provider-overloaded";
1349
+ return null;
1350
+ }
1351
+ function fromLines(lines) {
1352
+ for (const line of [...lines].reverse()) {
1353
+ if (NEEDS_TRUST.test(line))
1354
+ return "needs-trust";
1355
+ if (NOT_SIGNED_IN.test(line))
1356
+ return "not-signed-in";
1357
+ if (LIMIT.test(line))
1358
+ return "provider-limit";
1359
+ if (OVERLOADED.test(line))
1360
+ return "provider-overloaded";
1361
+ }
1362
+ return null;
1363
+ }
1364
+ function attempts(retry) {
1365
+ if (retry === null || retry === void 0)
1366
+ return "";
1367
+ const total = retry.max === null ? retry.attempt : Math.max(retry.attempt, retry.max);
1368
+ return ` It retried ${total} times first.`;
1369
+ }
1370
+ function message(code, input) {
1371
+ const agent = input.agent;
1372
+ switch (code) {
1373
+ case "cancelled":
1374
+ return "You stopped this run.";
1375
+ case "stopped":
1376
+ return "Leglas shut down while this was running.";
1377
+ case "missing-agent":
1378
+ return `${agent} could not be started. Its command is not on this machine's PATH any more.`;
1379
+ case "not-signed-in":
1380
+ return `${agent} is not signed in. Sign in to it in a terminal, then run this again.`;
1381
+ case "provider-overloaded":
1382
+ return `${agent}'s provider was overloaded and gave up.${attempts(input.retry)}`;
1383
+ case "provider-limit":
1384
+ return `${agent} reported a rate or usage limit, so nothing ran.`;
1385
+ case "needs-trust":
1386
+ return `Codex refused this project: it is not a git repository and Codex has no trust on record for it.`;
1387
+ case "not-registered":
1388
+ return `${agent} finished without registering the new direction, so nothing reached the rail. Its last output is in the Leglas terminal.`;
1389
+ case "agent-error":
1390
+ return input.exitCode === null || input.exitCode === void 0 ? `${agent} stopped without finishing. Its last output is in the Leglas terminal.` : `${agent} exited with code ${input.exitCode}. Its last output is in the Leglas terminal.`;
1391
+ }
1392
+ }
1393
+ function classifyFailure(input) {
1394
+ const lines = input.lines ?? [];
1395
+ const error = input.error ?? null;
1396
+ const code = error === "cancelled" ? "cancelled" : error === "not-registered" ? "not-registered" : error !== null && /^stopped by /.test(error) ? "stopped" : error !== null && MISSING_BINARY.test(error) ? "missing-agent" : (error !== null ? fromLines([error]) : null) ?? fromStatus(input.retry?.status ?? null, input.retry?.reason ?? null) ?? fromLines(lines) ?? "agent-error";
1397
+ return { code, message: message(code, input) };
1398
+ }
1399
+ function sessionShaped(code) {
1400
+ return code === "agent-error";
1401
+ }
1402
+
1403
+ // ../server/dist/annotations.js
1252
1404
  import { randomBytes } from "crypto";
1405
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1253
1406
  import { dirname as dirname4, join as join5 } from "path";
1407
+ var ANNOTATIONS_PATH = ".leglas/annotations.json";
1408
+ var NOTE_CAP = 500;
1409
+ var SELECTOR_CAP = 300;
1410
+ var TEXT_CAP = 120;
1411
+ var TAG_CAP = 40;
1412
+ var CLASS_CAP = 8;
1413
+ var CLASS_LENGTH_CAP = 60;
1414
+ var COVERS_CAP = 8;
1415
+ function isRecord2(value) {
1416
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1417
+ }
1418
+ function text(value, cap) {
1419
+ return typeof value === "string" ? value.trim().slice(0, cap) : "";
1420
+ }
1421
+ function fraction(value) {
1422
+ if (typeof value !== "number" || !Number.isFinite(value))
1423
+ return 0.5;
1424
+ return Math.min(1, Math.max(0, value));
1425
+ }
1426
+ function size(value) {
1427
+ return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0;
1428
+ }
1429
+ function anchorFrom(value) {
1430
+ if (!isRecord2(value))
1431
+ return null;
1432
+ const selector = text(value["selector"], SELECTOR_CAP);
1433
+ if (selector === "")
1434
+ return null;
1435
+ const rect = isRecord2(value["rect"]) ? value["rect"] : {};
1436
+ const classes = Array.isArray(value["classes"]) ? value["classes"].filter((entry) => typeof entry === "string").slice(0, CLASS_CAP).map((entry) => entry.slice(0, CLASS_LENGTH_CAP)) : [];
1437
+ const rawRegion = isRecord2(value["region"]) ? value["region"] : null;
1438
+ const region = rawRegion === null ? null : {
1439
+ height: fraction(rawRegion["height"]),
1440
+ width: fraction(rawRegion["width"]),
1441
+ x: fraction(rawRegion["x"]),
1442
+ y: fraction(rawRegion["y"])
1443
+ };
1444
+ const covers = Array.isArray(value["covers"]) ? value["covers"].filter(isRecord2).slice(0, COVERS_CAP).map((entry) => ({
1445
+ tag: text(entry["tag"], TAG_CAP) || "element",
1446
+ text: text(entry["text"], TEXT_CAP)
1447
+ })) : [];
1448
+ return {
1449
+ classes,
1450
+ ...covers.length === 0 ? {} : { covers },
1451
+ ...region === null ? {} : { region },
1452
+ rect: {
1453
+ height: size(rect["height"]),
1454
+ width: size(rect["width"]),
1455
+ x: size(rect["x"]),
1456
+ y: size(rect["y"])
1457
+ },
1458
+ selector,
1459
+ spot: {
1460
+ x: fraction(isRecord2(value["spot"]) ? value["spot"]["x"] : void 0),
1461
+ y: fraction(isRecord2(value["spot"]) ? value["spot"]["y"] : void 0)
1462
+ },
1463
+ tag: text(value["tag"], TAG_CAP) || "element",
1464
+ text: text(value["text"], TEXT_CAP),
1465
+ viewport: size(value["viewport"])
1466
+ };
1467
+ }
1468
+ async function readAnnotations(cwd) {
1469
+ try {
1470
+ const raw = await readFile4(join5(cwd, ANNOTATIONS_PATH), "utf8");
1471
+ const parsed2 = JSON.parse(raw);
1472
+ if (!Array.isArray(parsed2.annotations))
1473
+ return [];
1474
+ return parsed2.annotations.flatMap((entry, index) => {
1475
+ if (!isRecord2(entry))
1476
+ return [];
1477
+ const anchor = anchorFrom(entry["anchor"]);
1478
+ const title = text(entry["title"], TAG_CAP * 4);
1479
+ if (anchor === null || title === "")
1480
+ return [];
1481
+ return [
1482
+ {
1483
+ anchor,
1484
+ id: typeof entry["id"] === "string" ? entry["id"] : String(index),
1485
+ note: text(entry["note"], NOTE_CAP),
1486
+ title
1487
+ }
1488
+ ];
1489
+ });
1490
+ } catch {
1491
+ return [];
1492
+ }
1493
+ }
1494
+ async function write(cwd, annotations) {
1495
+ const path = join5(cwd, ANNOTATIONS_PATH);
1496
+ await mkdir3(dirname4(path), { recursive: true });
1497
+ await writeFile3(path, `${JSON.stringify({ annotations }, null, 2)}
1498
+ `, "utf8");
1499
+ }
1500
+ async function addAnnotation(cwd, input) {
1501
+ const annotation = { ...input, id: randomBytes(6).toString("base64url") };
1502
+ await write(cwd, [...await readAnnotations(cwd), annotation]);
1503
+ return annotation;
1504
+ }
1505
+ async function removeAnnotations(cwd, ids) {
1506
+ const wanted = new Set(ids);
1507
+ const annotations = await readAnnotations(cwd);
1508
+ const remaining = annotations.filter((entry) => !wanted.has(entry.id));
1509
+ const dropped = annotations.length - remaining.length;
1510
+ if (dropped > 0)
1511
+ await write(cwd, remaining);
1512
+ return dropped;
1513
+ }
1514
+ function annotationsFor(annotations, title) {
1515
+ return annotations.filter((entry) => entry.title === title);
1516
+ }
1517
+ function describeAnchor(anchor) {
1518
+ const where = `about ${anchor.rect.width}\xD7${anchor.rect.height} at (${anchor.rect.x}, ${anchor.rect.y}) in a ${anchor.viewport}px-wide viewport`;
1519
+ if (anchor.region !== void 0) {
1520
+ const covered = (anchor.covers ?? []).map((entry) => entry.text === "" ? `<${entry.tag}>` : `<${entry.tag}> \u201C${entry.text}\u201D`).join(", ");
1521
+ const inside = covered === "" ? "" : ` covering ${covered};`;
1522
+ return `an area inside <${anchor.tag}>;${inside} path ${anchor.selector}; ${where}`;
1523
+ }
1524
+ const parts = [`<${anchor.tag}>`];
1525
+ if (anchor.classes.length > 0)
1526
+ parts.push(`class "${anchor.classes.join(" ")}"`);
1527
+ if (anchor.text !== "")
1528
+ parts.push(`reading \u201C${anchor.text}\u201D`);
1529
+ return `${parts.join(", ")}; path ${anchor.selector}; ${where}`;
1530
+ }
1531
+ function describeAnnotations(annotations) {
1532
+ return annotations.map((annotation, index) => {
1533
+ const said = annotation.note === "" ? "Look at this." : annotation.note;
1534
+ return `${index + 1}. ${said}
1535
+ The element: ${describeAnchor(annotation.anchor)}`;
1536
+ }).join("\n\n");
1537
+ }
1538
+
1539
+ // ../server/dist/requests.js
1540
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1541
+ import { randomBytes as randomBytes2 } from "crypto";
1542
+ import { dirname as dirname5, join as join6 } from "path";
1254
1543
  var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
1255
- function targetFor(url) {
1544
+ function variantSlot(url) {
1256
1545
  if (!url.startsWith("/"))
1257
1546
  return null;
1258
- const query = url.slice(url.indexOf("?") + 1);
1259
1547
  if (!url.includes("?"))
1260
1548
  return null;
1549
+ const query = url.slice(url.indexOf("?") + 1);
1261
1550
  for (const pair of query.split("&")) {
1262
1551
  const [rawKey, rawValue] = pair.split("=");
1263
1552
  if (rawKey === void 0 || rawValue === void 0)
@@ -1268,37 +1557,125 @@ function targetFor(url) {
1268
1557
  const option = decodeURIComponent(rawValue);
1269
1558
  if (!SAFE_SEGMENT.test(surface) || !SAFE_SEGMENT.test(option))
1270
1559
  return null;
1271
- return `.leglas/variants/${surface}/${option}.tsx`;
1560
+ return { surface, option };
1272
1561
  }
1273
1562
  return null;
1274
1563
  }
1275
- function composeRequest(preview, intent) {
1564
+ function targetFor(url) {
1565
+ const slot = variantSlot(url);
1566
+ return slot === null ? null : `.leglas/variants/${slot.surface}/${slot.option}.tsx`;
1567
+ }
1568
+ function composeRequest(preview, intent, mode, notes = [], leglasCommand = "npx -y leglas") {
1276
1569
  const target = preview.file ?? targetFor(preview.url);
1277
1570
  const cleaned = intent.trim();
1571
+ const asked = changeBlock(cleaned, notes);
1572
+ const recorded = cleaned === "" ? notes.map((entry) => entry.note).filter((entry) => entry !== "").join("; ") : cleaned;
1573
+ const prompt = mode === "variant" ? variantPrompt(preview, recorded, asked, target, leglasCommand) : replacePrompt(preview, asked, target);
1574
+ return { prompt, target, mode };
1575
+ }
1576
+ var ANCHORS = `Each path and rectangle was recorded when the note was left, against the design as it looked then. Trust the element's own words first, then its tag and classes, then the path, and treat the rectangle as a hint about where on the page to look rather than a fact.`;
1577
+ function changeBlock(cleaned, notes) {
1578
+ if (notes.length === 0)
1579
+ return `What to change: ${cleaned}`;
1580
+ const many = notes.length === 1 ? "a note" : `${notes.length} notes`;
1581
+ const lead = cleaned === "" ? `What to change, left as ${many} on the design itself:` : `What to change: ${cleaned}
1582
+
1583
+ And ${many} left on the design itself:`;
1584
+ return `${lead}
1585
+
1586
+ ${describeAnnotations(notes)}
1587
+
1588
+ ${ANCHORS}`;
1589
+ }
1590
+ var SCOPE = `This request came from the running Leglas interface. Request collection, direction discovery and the live-server check are already complete. Do not run Leglas explore, requests, list, show, help or version commands, do not inspect package caches, and do not start or restart the app or Leglas. Use the existing live preview if visual inspection is useful.
1591
+
1592
+ This is a scoped design change: no test run, no build, and no survey of the rest of the project is needed. The result is checked visually in a live preview, not by tooling.
1593
+
1594
+ Leave every other direction exactly as it is; they are alternatives being compared side by side, so changing a sibling destroys the comparison. Keep the change additive: do not rewrite shared components that other directions rely on.`;
1595
+ function registrationCommand(leglasCommand) {
1596
+ return `${leglasCommand} add`;
1597
+ }
1598
+ function replacePrompt(preview, asked, target) {
1278
1599
  const where = target === null ? `The direction is titled "${preview.title}" and renders at ${preview.url}. Find what produces it.` : `It lives at ${target}.`;
1279
1600
  const pace = target === null ? `Once found, make the change and finish. ` : `Make the change in that file and finish. `;
1280
- const prompt = `In this project, change only the "${preview.title}" design direction. ${where}
1601
+ return `In this project, change only the "${preview.title}" design direction. ${where}
1602
+
1603
+ ${asked}
1281
1604
 
1282
- What to change: ${cleaned}
1605
+ ${pace}${SCOPE} The direction is already registered, so nothing needs re-registering.`;
1606
+ }
1607
+ function variantPrompt(preview, recorded, asked, target, leglasCommand) {
1608
+ const slot = variantSlot(preview.url);
1609
+ const parent = JSON.stringify(preview.title);
1610
+ const askedFor = JSON.stringify(recorded);
1611
+ const add = registrationCommand(leglasCommand);
1612
+ const source = target === null ? `Find what renders it first.` : `Its source is ${target}.`;
1613
+ const [make, register] = preview.file !== void 0 ? [
1614
+ `Copy that file to a new file beside it and make the change in the copy.`,
1615
+ ` ${add} --title "<name>" --file "<the new file>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
1616
+ ] : slot !== null ? [
1617
+ `Copy that file to a new one in the same folder and make the change in the copy. The new file's name without its extension is its key, and that key has to be listed in the DIRECTIONS map in .leglas/variants/${slot.surface}/switch.tsx or its URL will not resolve.`,
1618
+ ` ${add} --title "<name>" --url "/?v-${slot.surface}=<key>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
1619
+ ] : [
1620
+ `Copy its source rather than editing it, and make the change in the copy. Add the new direction the way this project already switches between them; if it has a Leglas branch point, that is the DIRECTIONS map in .leglas/variants/<surface>/switch.tsx.`,
1621
+ ` ${add} --title "<name>" --url "<the URL that shows it>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
1622
+ ];
1623
+ return `In this project, add a new design direction based on the "${preview.title}" direction. Leave "${preview.title}" itself exactly as it is: it is the thing the new one will be compared against.
1283
1624
 
1284
- ${pace}This is a scoped design change: no test run, no build, and no survey of the rest of the project is needed. The result is checked visually in a live preview, not by tooling.
1625
+ ${source} ${make}
1285
1626
 
1286
- Leave every other direction exactly as it is; they are alternatives being compared side by side, so changing a sibling destroys the comparison. The direction is already registered, so nothing needs re-registering. Keep the change additive: do not rewrite shared components that other directions rely on.`;
1287
- return { prompt, target };
1627
+ ${asked}
1628
+
1629
+ Then register it, which is what puts it on the rail:
1630
+
1631
+ ${register}
1632
+
1633
+ Name it for its idea rather than numbering it, and keep the name short enough to read in a narrow rail. Pass --asked-for exactly as given above; it is the user's own words and the interface shows them. Registering it is the last step; finish there.
1634
+
1635
+ ${SCOPE}`;
1288
1636
  }
1289
1637
  var REQUESTS_PATH = ".leglas/requests.json";
1638
+ var TERMINAL = ["failed", "cancelled"];
1639
+ function isTerminal(status) {
1640
+ return TERMINAL.includes(status);
1641
+ }
1642
+ var FAILURE_CODES = [
1643
+ "cancelled",
1644
+ "stopped",
1645
+ "missing-agent",
1646
+ "not-signed-in",
1647
+ "provider-overloaded",
1648
+ "provider-limit",
1649
+ "needs-trust",
1650
+ "not-registered",
1651
+ "agent-error"
1652
+ ];
1653
+ function failureOf(value) {
1654
+ if (typeof value !== "object" || value === null)
1655
+ return null;
1656
+ const entry = value;
1657
+ if (typeof entry.message !== "string" || entry.message === "")
1658
+ return null;
1659
+ if (entry.code === void 0 || !FAILURE_CODES.includes(entry.code))
1660
+ return null;
1661
+ return { code: entry.code, message: entry.message };
1662
+ }
1290
1663
  async function readRequests(cwd) {
1291
1664
  try {
1292
- const raw = await readFile4(join5(cwd, REQUESTS_PATH), "utf8");
1665
+ const raw = await readFile5(join6(cwd, REQUESTS_PATH), "utf8");
1293
1666
  const parsed2 = JSON.parse(raw);
1294
1667
  if (!Array.isArray(parsed2.requests))
1295
1668
  return [];
1296
1669
  return parsed2.requests.map((request, index) => {
1297
- const entry = request;
1670
+ const { failure: rawFailure, ...entry } = request;
1671
+ const status = entry.status === "picked-up" || entry.status === "failed" || entry.status === "cancelled" ? entry.status : "queued";
1672
+ const failure = isTerminal(status) ? failureOf(rawFailure) : null;
1298
1673
  return {
1299
1674
  ...entry,
1300
1675
  id: typeof entry.id === "string" ? entry.id : String(index),
1301
- status: entry.status === "picked-up" ? "picked-up" : "queued"
1676
+ status,
1677
+ mode: entry.mode === "variant" ? "variant" : "replace",
1678
+ ...failure === null ? {} : { failure }
1302
1679
  };
1303
1680
  });
1304
1681
  } catch {
@@ -1306,23 +1683,23 @@ async function readRequests(cwd) {
1306
1683
  }
1307
1684
  }
1308
1685
  async function writeQueue(cwd, requests) {
1309
- const path = join5(cwd, REQUESTS_PATH);
1310
- await mkdir3(dirname4(path), { recursive: true });
1311
- await writeFile3(path, `${JSON.stringify({ requests }, null, 2)}
1686
+ const path = join6(cwd, REQUESTS_PATH);
1687
+ await mkdir4(dirname5(path), { recursive: true });
1688
+ await writeFile4(path, `${JSON.stringify({ requests }, null, 2)}
1312
1689
  `, "utf8");
1313
1690
  }
1314
1691
  async function appendRequest(cwd, request) {
1315
1692
  await writeQueue(cwd, [
1316
1693
  ...await readRequests(cwd),
1317
- { ...request, id: randomBytes(6).toString("base64url"), status: "queued" }
1694
+ { ...request, id: randomBytes2(6).toString("base64url"), status: "queued" }
1318
1695
  ]);
1319
1696
  }
1320
1697
  async function collectRequests(cwd) {
1321
1698
  const requests = await readRequests(cwd);
1322
- const collected = requests.map((request) => ({ ...request, status: "picked-up" }));
1323
- if (requests.some((request) => request.status !== "picked-up"))
1699
+ const collected = requests.map((request) => isTerminal(request.status) ? request : { ...request, status: "picked-up" });
1700
+ if (requests.some((request) => request.status === "queued"))
1324
1701
  await writeQueue(cwd, collected);
1325
- return collected;
1702
+ return collected.filter((request) => !isTerminal(request.status));
1326
1703
  }
1327
1704
  async function markPickedUp(cwd, id) {
1328
1705
  const requests = await readRequests(cwd);
@@ -1331,6 +1708,17 @@ async function markPickedUp(cwd, id) {
1331
1708
  await writeQueue(cwd, requests.map((request) => request.id === id ? { ...request, status: "picked-up" } : request));
1332
1709
  return true;
1333
1710
  }
1711
+ async function markFailed(cwd, id, failure) {
1712
+ const requests = await readRequests(cwd);
1713
+ if (!requests.some((request) => request.id === id))
1714
+ return false;
1715
+ await writeQueue(cwd, requests.map((request) => request.id === id ? {
1716
+ ...request,
1717
+ status: failure.code === "cancelled" ? "cancelled" : "failed",
1718
+ failure
1719
+ } : request));
1720
+ return true;
1721
+ }
1334
1722
  async function removeRequest(cwd, id) {
1335
1723
  const requests = await readRequests(cwd);
1336
1724
  const remaining = requests.filter((request) => request.id !== id);
@@ -1341,7 +1729,7 @@ async function removeRequest(cwd, id) {
1341
1729
  }
1342
1730
  async function clearRequests(cwd) {
1343
1731
  const requests = await readRequests(cwd);
1344
- const pending = requests.filter((request) => request.status !== "picked-up");
1732
+ const pending = requests.filter((request) => request.status === "queued");
1345
1733
  const cleared = requests.length - pending.length;
1346
1734
  if (cleared > 0)
1347
1735
  await writeQueue(cwd, pending);
@@ -1350,10 +1738,13 @@ async function clearRequests(cwd) {
1350
1738
 
1351
1739
  // ../server/dist/runner.js
1352
1740
  import { spawn as nodeSpawn } from "child_process";
1741
+ import { readFile as readFile6 } from "fs/promises";
1742
+ import { join as join7 } from "path";
1353
1743
  var POLL_MS = 2e3;
1354
1744
  var OUTPUT_LINES = 20;
1745
+ var CANCEL_GRACE_MS = 5e3;
1355
1746
  var SESSION_TURNS_CAP = 8;
1356
- function resolveCommand(choice, prompt, sessionId = null) {
1747
+ function resolveCommand(choice, prompt, sessionId = null, registration = null) {
1357
1748
  if (choice.agent === null)
1358
1749
  return null;
1359
1750
  if (choice.agent === "custom") {
@@ -1365,12 +1756,13 @@ function resolveCommand(choice, prompt, sessionId = null) {
1365
1756
  return { agent: "custom", name: "Custom", ...commandFor(parsed2.template, prompt), resumed: false };
1366
1757
  }
1367
1758
  const adapter = KNOWN_AGENTS[choice.agent];
1759
+ const allow = registration !== null && "allowArgs" in adapter ? adapter.allowArgs(registration) : [];
1368
1760
  if (sessionId !== null && "resumeArgs" in adapter) {
1369
1761
  return {
1370
1762
  agent: choice.agent,
1371
1763
  name: adapter.name,
1372
1764
  command: adapter.binary,
1373
- args: adapter.resumeArgs(sessionId, prompt),
1765
+ args: [...adapter.resumeArgs(sessionId, prompt), ...allow],
1374
1766
  resumed: true
1375
1767
  };
1376
1768
  }
@@ -1378,7 +1770,7 @@ function resolveCommand(choice, prompt, sessionId = null) {
1378
1770
  agent: choice.agent,
1379
1771
  name: adapter.name,
1380
1772
  command: adapter.binary,
1381
- args: adapter.args(prompt),
1773
+ args: [...adapter.args(prompt), ...allow],
1382
1774
  resumed: false
1383
1775
  };
1384
1776
  }
@@ -1408,12 +1800,17 @@ function startRunner(options) {
1408
1800
  const setEvery = options.setInterval ?? ((callback, milliseconds) => setInterval(callback, milliseconds));
1409
1801
  const clearEvery = options.clearInterval ?? ((handle2) => clearInterval(handle2));
1410
1802
  const failed = /* @__PURE__ */ new Set();
1803
+ const setLater = options.setTimeout ?? ((callback, milliseconds) => {
1804
+ setTimeout(callback, milliseconds).unref?.();
1805
+ });
1411
1806
  let state = {
1412
1807
  running: false,
1413
1808
  requestId: null,
1414
1809
  agent: null,
1415
1810
  activity: null,
1416
- startedAt: null
1811
+ startedAt: null,
1812
+ stopping: false,
1813
+ waiting: null
1417
1814
  };
1418
1815
  let stopped = false;
1419
1816
  let ticking = null;
@@ -1421,15 +1818,30 @@ function startRunner(options) {
1421
1818
  let active = null;
1422
1819
  const sessions = /* @__PURE__ */ new Map();
1423
1820
  const idle = () => {
1424
- state = { running: false, requestId: null, agent: null, activity: null, startedAt: null };
1821
+ state = {
1822
+ running: false,
1823
+ requestId: null,
1824
+ agent: null,
1825
+ activity: null,
1826
+ startedAt: null,
1827
+ stopping: false,
1828
+ waiting: null
1829
+ };
1425
1830
  };
1426
1831
  const rememberLine = (lines, line) => {
1427
1832
  lines.push(line);
1428
1833
  if (lines.length > OUTPUT_LINES)
1429
1834
  lines.splice(0, lines.length - OUTPUT_LINES);
1430
1835
  };
1431
- const reportFailure = (request, error, lines) => {
1432
- console.error(`Leglas agent failed for ${request.title}: ${error}`);
1836
+ const reportFailure = async (request, failure, lines) => {
1837
+ await markFailed(options.cwd, request.id, failure).catch(() => {
1838
+ });
1839
+ failed.add(request.id);
1840
+ if (failure.code === "cancelled") {
1841
+ console.error(`Leglas stopped the run for ${request.title}.`);
1842
+ return;
1843
+ }
1844
+ console.error(`Leglas agent failed for ${request.title}: ${failure.message}`);
1433
1845
  for (const line of lines)
1434
1846
  console.error(` ${line}`);
1435
1847
  };
@@ -1447,19 +1859,31 @@ function startRunner(options) {
1447
1859
  error: error instanceof Error ? error.message : String(error)
1448
1860
  });
1449
1861
  }
1450
- const current = { child, requestId: request.id, cancelled: false };
1862
+ const current = {
1863
+ child,
1864
+ requestId: request.id,
1865
+ cancelled: false,
1866
+ abandon: () => {
1867
+ }
1868
+ };
1451
1869
  active = current;
1452
1870
  const stdoutFlush = lineReader(child.stdout, (line) => {
1453
1871
  rememberLine(lines, line);
1454
1872
  const sessionId = sessionFrom(resolved.agent, line);
1455
1873
  if (sessionId !== null)
1456
1874
  observed.sessionId = sessionId;
1875
+ const retry = retryFrom(resolved.agent, line);
1876
+ if (retry !== null) {
1877
+ observed.retry = retry;
1878
+ if (active === current)
1879
+ state = { ...state, waiting: retry };
1880
+ }
1457
1881
  const activity = activityFrom(resolved.agent, line, options.cwd);
1458
1882
  if (activity !== null) {
1459
1883
  if (activity.startsWith("editing"))
1460
1884
  observed.edited = true;
1461
1885
  if (active === current)
1462
- state = { ...state, activity };
1886
+ state = { ...state, activity, waiting: null };
1463
1887
  }
1464
1888
  });
1465
1889
  const stderrFlush = lineReader(child.stderr, (line) => rememberLine(lines, line));
@@ -1473,6 +1897,7 @@ function startRunner(options) {
1473
1897
  stderrFlush();
1474
1898
  resolve(outcome);
1475
1899
  };
1900
+ current.abandon = () => settle({ ok: false, error: "cancelled" });
1476
1901
  child.once("error", (error) => settle({ ok: false, error: error.message }));
1477
1902
  child.once("close", (code, signal) => {
1478
1903
  if (current.cancelled)
@@ -1486,10 +1911,12 @@ function startRunner(options) {
1486
1911
  active = null;
1487
1912
  });
1488
1913
  };
1914
+ const registered = () => readFile6(join7(options.cwd, LOCAL_PREVIEWS_PATH), "utf8").catch(() => null);
1489
1915
  const handle = async (request, choice) => {
1490
1916
  const session = choice.agent !== null ? sessions.get(choice.agent) ?? null : null;
1491
1917
  const continuable = session !== null && session.turns < SESSION_TURNS_CAP;
1492
- let resolved = resolveCommand(choice, request.prompt, continuable ? session.id : null);
1918
+ const registration = request.mode === "variant" && options.leglasCommand !== void 0 ? registrationCommand(options.leglasCommand) : null;
1919
+ let resolved = resolveCommand(choice, request.prompt, continuable ? session.id : null, registration);
1493
1920
  if (resolved === null)
1494
1921
  return;
1495
1922
  const lines = [];
@@ -1497,7 +1924,7 @@ function startRunner(options) {
1497
1924
  if (!await markPickedUp(options.cwd, request.id))
1498
1925
  return;
1499
1926
  if (stopped) {
1500
- failed.add(request.id);
1927
+ await reportFailure(request, classifyFailure({ agent: resolved.name, error: "stopped by shutdown" }), []);
1501
1928
  return;
1502
1929
  }
1503
1930
  state = {
@@ -1505,25 +1932,47 @@ function startRunner(options) {
1505
1932
  requestId: request.id,
1506
1933
  agent: resolved.name,
1507
1934
  activity: null,
1508
- startedAt: Date.now()
1935
+ startedAt: Date.now(),
1936
+ stopping: false,
1937
+ waiting: null
1938
+ };
1939
+ const observed = {
1940
+ sessionId: null,
1941
+ edited: false,
1942
+ retry: null
1509
1943
  };
1510
- const observed = { sessionId: null, edited: false };
1944
+ const before = request.mode === "variant" ? await registered() : null;
1945
+ const agent = resolved.name;
1511
1946
  let outcome = await runChild(request, resolved, lines, observed);
1512
- const cancelled = !outcome.ok && outcome.error === "cancelled";
1513
- if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && !cancelled && // Not redundant with the line above: a stop that lands between the
1514
- // first child settling and the retry starting finds no child to
1515
- // cancel, so nothing says "cancelled". Stopped still means stopped.
1947
+ const verdict = () => classifyFailure({
1948
+ agent,
1949
+ error: outcome.ok ? null : stopped && outcome.error === "cancelled" ? "stopped by shutdown" : outcome.error,
1950
+ exitCode: outcome.ok ? outcome.code : null,
1951
+ lines,
1952
+ retry: observed.retry
1953
+ });
1954
+ let failure = verdict();
1955
+ if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && sessionShaped(failure.code) && // Not redundant with the verdict: a stop that lands between the first
1956
+ // child settling and the retry starting finds no child to cancel, so
1957
+ // nothing says "cancelled". Stopped still means stopped.
1516
1958
  !stopped) {
1517
1959
  sessions.delete(resolved.agent);
1518
- const cold = resolveCommand(choice, request.prompt);
1960
+ const cold = resolveCommand(choice, request.prompt, null, registration);
1519
1961
  if (cold !== null) {
1520
1962
  resolved = cold;
1521
1963
  observed.sessionId = null;
1522
- state = { ...state, activity: null };
1964
+ observed.retry = null;
1965
+ state = { ...state, activity: null, waiting: null };
1523
1966
  outcome = await runChild(request, resolved, lines, observed);
1967
+ failure = verdict();
1524
1968
  }
1525
1969
  }
1526
1970
  if (outcome.ok && outcome.code === 0) {
1971
+ if (request.mode === "variant" && await registered() === before) {
1972
+ sessions.delete(resolved.agent);
1973
+ await reportFailure(request, classifyFailure({ agent, error: "not-registered" }), lines);
1974
+ return;
1975
+ }
1527
1976
  if (observed.sessionId !== null) {
1528
1977
  const previous = sessions.get(resolved.agent);
1529
1978
  sessions.set(resolved.agent, {
@@ -1531,12 +1980,14 @@ function startRunner(options) {
1531
1980
  turns: resolved.resumed && previous?.id === observed.sessionId ? previous.turns + 1 : 1
1532
1981
  });
1533
1982
  }
1983
+ if (request.mode === "replace" && request.notes !== void 0) {
1984
+ await removeAnnotations(options.cwd, request.notes).catch(() => 0);
1985
+ }
1534
1986
  await removeRequest(options.cwd, request.id);
1535
1987
  return;
1536
1988
  }
1537
1989
  sessions.delete(resolved.agent);
1538
- failed.add(request.id);
1539
- reportFailure(request, outcome.ok ? `${resolved.command} exited ${outcome.code}` : outcome.error, lines);
1990
+ await reportFailure(request, failure, lines);
1540
1991
  } finally {
1541
1992
  idle();
1542
1993
  }
@@ -1570,12 +2021,23 @@ function startRunner(options) {
1570
2021
  return false;
1571
2022
  if (id !== void 0 && active.requestId !== id)
1572
2023
  return false;
1573
- active.cancelled = true;
1574
- failed.add(active.requestId);
2024
+ const current = active;
2025
+ current.cancelled = true;
2026
+ failed.add(current.requestId);
2027
+ state = { ...state, stopping: true, waiting: null };
1575
2028
  try {
1576
- active.child.kill("SIGTERM");
2029
+ current.child.kill("SIGTERM");
1577
2030
  } catch {
1578
2031
  }
2032
+ setLater(() => {
2033
+ if (active !== current)
2034
+ return;
2035
+ try {
2036
+ current.child.kill("SIGKILL");
2037
+ } catch {
2038
+ }
2039
+ current.abandon();
2040
+ }, CANCEL_GRACE_MS);
1579
2041
  return true;
1580
2042
  };
1581
2043
  const stop = () => {
@@ -1600,12 +2062,12 @@ function startRunner(options) {
1600
2062
  }
1601
2063
 
1602
2064
  // ../server/dist/renames.js
1603
- import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1604
- import { dirname as dirname5, join as join6 } from "path";
2065
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile5 } from "fs/promises";
2066
+ import { dirname as dirname6, join as join8 } from "path";
1605
2067
  var RENAMES_PATH = ".leglas/renames.json";
1606
2068
  async function readRenames(cwd) {
1607
2069
  try {
1608
- const raw = await readFile5(join6(cwd, RENAMES_PATH), "utf8");
2070
+ const raw = await readFile7(join8(cwd, RENAMES_PATH), "utf8");
1609
2071
  const parsed2 = JSON.parse(raw);
1610
2072
  if (parsed2.renames === null || typeof parsed2.renames !== "object")
1611
2073
  return {};
@@ -1615,9 +2077,9 @@ async function readRenames(cwd) {
1615
2077
  }
1616
2078
  }
1617
2079
  async function writeRenames(cwd, renames) {
1618
- const path = join6(cwd, RENAMES_PATH);
1619
- await mkdir4(dirname5(path), { recursive: true });
1620
- await writeFile4(path, `${JSON.stringify({ renames }, null, 2)}
2080
+ const path = join8(cwd, RENAMES_PATH);
2081
+ await mkdir5(dirname6(path), { recursive: true });
2082
+ await writeFile5(path, `${JSON.stringify({ renames }, null, 2)}
1621
2083
  `, "utf8");
1622
2084
  }
1623
2085
  function resolveTitle(input, titles, renames) {
@@ -1635,7 +2097,7 @@ function resolveTitle(input, titles, renames) {
1635
2097
  import { createReadStream, existsSync as existsSync2, statSync } from "fs";
1636
2098
  import http2 from "http";
1637
2099
  import net3 from "net";
1638
- import { extname, join as join7, normalize, relative as relative3 } from "path";
2100
+ import { extname, join as join9, normalize, relative as relative3 } from "path";
1639
2101
  var LEGLAS_PREFIX = "/leglas";
1640
2102
  var DEFAULT_PORT = 4100;
1641
2103
  var PORT_ATTEMPTS = 20;
@@ -1708,6 +2170,9 @@ function isTrustedMutation(req) {
1708
2170
  return false;
1709
2171
  }
1710
2172
  }
2173
+ function isEnded(request, failedIds) {
2174
+ return isTerminal(request.status) || failedIds.includes(request.id);
2175
+ }
1711
2176
  function hasJsonBody(req) {
1712
2177
  const contentType = req.headers["content-type"];
1713
2178
  return typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
@@ -1734,7 +2199,7 @@ function probe(target, timeoutMs = 1e3) {
1734
2199
  }
1735
2200
  function serveFrom(res, dir, relativePath) {
1736
2201
  const relative5 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
1737
- const candidate = join7(dir, relative5);
2202
+ const candidate = join9(dir, relative5);
1738
2203
  if (!candidate.startsWith(dir))
1739
2204
  return false;
1740
2205
  if (!existsSync2(candidate) || !statSync(candidate).isFile())
@@ -1819,7 +2284,7 @@ async function bind(server, requested) {
1819
2284
  throw new Error(`No free port between ${requested} and ${requested + PORT_ATTEMPTS - 1}.`);
1820
2285
  }
1821
2286
  async function startServer(options) {
1822
- const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
2287
+ const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), leglasCommand = "npx -y leglas", fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
1823
2288
  const target = config?.devServer ?? "http://localhost:3000";
1824
2289
  const proxy = createProxyHandler({ target });
1825
2290
  const bootConfigSnapshot = snapshotConfig(cwd);
@@ -1859,13 +2324,23 @@ async function startServer(options) {
1859
2324
  const notice = configStalenessNotice(cwd, bootConfigSnapshot, snapshotConfig(cwd));
1860
2325
  if (notice !== null)
1861
2326
  errors.push(notice);
1862
- return void readLocalPreviews(cwd).then(({ previews: local }) => {
1863
- const known = new Set(boot.map((preview) => preview.title));
2327
+ return void readLocalPreviews(cwd).then(({ previews: local, errors: localErrors }) => {
2328
+ if (localErrors.length > 0) {
2329
+ return sendJson(res, 200, {
2330
+ project,
2331
+ devServer: target,
2332
+ previews: boot,
2333
+ errors
2334
+ });
2335
+ }
2336
+ const localTitles = new Set(local.map((preview) => preview.title));
2337
+ const currentBoot = boot.filter((preview) => preview.local !== true || localTitles.has(preview.title));
2338
+ const known = new Set(currentBoot.map((preview) => preview.title));
1864
2339
  const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
1865
2340
  sendJson(res, 200, {
1866
2341
  project,
1867
2342
  devServer: target,
1868
- previews: [...boot, ...fresh],
2343
+ previews: [...currentBoot, ...fresh],
1869
2344
  errors
1870
2345
  });
1871
2346
  }).catch(() => sendJson(res, 200, {
@@ -1875,6 +2350,47 @@ async function startServer(options) {
1875
2350
  errors
1876
2351
  }));
1877
2352
  }
2353
+ if (path === `${LEGLAS_PREFIX}/api/previews/delete` && req.method === "POST") {
2354
+ let body = "";
2355
+ req.on("data", (chunk) => body += chunk);
2356
+ return void req.on("end", async () => {
2357
+ let parsed2;
2358
+ try {
2359
+ parsed2 = JSON.parse(body || "{}");
2360
+ } catch {
2361
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2362
+ }
2363
+ const titles = parsed2.titles;
2364
+ if (!Array.isArray(titles) || titles.length === 0 || titles.some((title) => typeof title !== "string" || title.trim() === "")) {
2365
+ return sendJson(res, 400, {
2366
+ ok: false,
2367
+ error: "Body needs a non-empty array of direction titles."
2368
+ });
2369
+ }
2370
+ const unique = [...new Set(titles)];
2371
+ try {
2372
+ const local = await readLocalPreviews(cwd);
2373
+ if (local.errors.length > 0) {
2374
+ return sendJson(res, 409, { ok: false, error: local.errors.join(" ") });
2375
+ }
2376
+ const localTitles = new Set(local.previews.map((preview) => preview.title));
2377
+ const unknown = unique.filter((title) => !localTitles.has(title));
2378
+ if (unknown.length > 0) {
2379
+ return sendJson(res, 400, {
2380
+ ok: false,
2381
+ error: "Only machine-local directions can be deleted from the registry."
2382
+ });
2383
+ }
2384
+ const deleted = await dropLocalPreviews(cwd, unique);
2385
+ return sendJson(res, 200, { ok: true, deleted });
2386
+ } catch {
2387
+ return sendJson(res, 500, {
2388
+ ok: false,
2389
+ error: "The directions could not be deleted from Leglas."
2390
+ });
2391
+ }
2392
+ });
2393
+ }
1878
2394
  if (path === `${LEGLAS_PREFIX}/api/request` && req.method === "POST") {
1879
2395
  let body = "";
1880
2396
  req.on("data", (chunk) => body += chunk);
@@ -1885,16 +2401,51 @@ async function startServer(options) {
1885
2401
  } catch {
1886
2402
  return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
1887
2403
  }
1888
- const local = await readLocalPreviews(cwd).then((read) => read.previews, () => []);
1889
- const preview = [...config?.previews ?? [], ...local].find((entry) => entry.title === parsed2.title);
1890
- if (!preview || !parsed2.intent?.trim()) {
2404
+ if (parsed2.mode !== void 0 && parsed2.mode !== "variant" && parsed2.mode !== "replace") {
2405
+ return sendJson(res, 400, {
2406
+ ok: false,
2407
+ error: 'mode must be "variant" or "replace".'
2408
+ });
2409
+ }
2410
+ const mode = parsed2.mode === "replace" ? "replace" : "variant";
2411
+ const localRead = await readLocalPreviews(cwd).catch(() => null);
2412
+ const local = localRead?.errors.length === 0 ? localRead.previews : [];
2413
+ const localTitles = new Set(local.map((entry) => entry.title));
2414
+ const bootConfig = config?.previews ?? [];
2415
+ const boot = localRead === null || localRead.errors.length > 0 ? bootConfig : bootConfig.filter((entry) => entry.local !== true || localTitles.has(entry.title));
2416
+ const preview = [...boot, ...local].find((entry) => entry.title === parsed2.title);
2417
+ if (!preview) {
2418
+ return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
2419
+ }
2420
+ const notes = annotationsFor(await readAnnotations(cwd).catch(() => []), preview.title);
2421
+ if (!parsed2.intent?.trim() && notes.length === 0) {
1891
2422
  return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
1892
2423
  }
1893
- const composed = composeRequest(preview, parsed2.intent);
2424
+ const intent = (parsed2.intent ?? "").trim();
2425
+ const live = (await readRequests(cwd).catch(() => [])).filter((entry) => entry.status === "queued" || entry.status === "picked-up");
2426
+ const sameNotes = (entry) => {
2427
+ const before = [...entry.notes ?? []].sort().join(",");
2428
+ return before === notes.map((note) => note.id).sort().join(",");
2429
+ };
2430
+ if (live.some((entry) => entry.title === preview.title && entry.intent === intent && // The same words in the other mode are not the same request:
2431
+ // one forks the direction and the other rewrites it. Only a
2432
+ // genuine repeat is refused.
2433
+ (entry.mode ?? "replace") === mode && sameNotes(entry))) {
2434
+ return sendJson(res, 409, {
2435
+ ok: false,
2436
+ duplicate: true,
2437
+ error: `That exact change to ${preview.title} is already waiting.`
2438
+ });
2439
+ }
2440
+ const composed = composeRequest(preview, intent, mode, notes, leglasCommand);
1894
2441
  void appendRequest(cwd, {
1895
2442
  title: preview.title,
1896
2443
  url: preview.url,
1897
- intent: parsed2.intent.trim(),
2444
+ intent,
2445
+ // The ids travel with the request so a change made in place can
2446
+ // forget the notes it answered. A fork leaves them where they are:
2447
+ // the direction they point at was not touched.
2448
+ ...notes.length === 0 ? {} : { notes: notes.map((entry) => entry.id) },
1898
2449
  ...composed
1899
2450
  }).then(() => {
1900
2451
  runner?.nudge();
@@ -1970,21 +2521,33 @@ async function startServer(options) {
1970
2521
  agent: null,
1971
2522
  activity: null,
1972
2523
  startedAt: null,
2524
+ stopping: false,
2525
+ waiting: null,
1973
2526
  failedIds: []
1974
2527
  };
1975
2528
  return void readRequests(cwd).then((requests) => sendJson(res, 200, {
1976
- requests: requests.map(({ id, title, intent, status }) => ({
2529
+ requests: requests.map(({ id, title, intent, status, failure }) => ({
1977
2530
  id,
1978
2531
  title,
1979
2532
  intent,
1980
- status: snapshot.running && snapshot.requestId === id ? "running" : snapshot.failedIds.includes(id) ? "failed" : status
2533
+ // The run in flight is the one thing the file cannot know. After
2534
+ // that the file is the record, including across a restart, and the
2535
+ // process-local failed set only covers a request whose verdict
2536
+ // could not be written.
2537
+ status: snapshot.running && snapshot.requestId === id ? "running" : status === "queued" && snapshot.failedIds.includes(id) ? "failed" : status,
2538
+ failure: failure ?? null
1981
2539
  })),
1982
2540
  agent: {
1983
2541
  attached: externallyAttached(),
1984
2542
  running: snapshot.running,
1985
2543
  name: snapshot.running ? snapshot.agent : null,
1986
2544
  activity: snapshot.running ? snapshot.activity : null,
1987
- startedAt: snapshot.running ? snapshot.startedAt : null
2545
+ startedAt: snapshot.running ? snapshot.startedAt : null,
2546
+ // A stop that has been asked for but not yet obeyed. The card
2547
+ // says so rather than going on describing a live run.
2548
+ stopping: snapshot.running && snapshot.stopping,
2549
+ // Why a run that looks stalled is stalled, while it is stalled.
2550
+ waiting: snapshot.running ? snapshot.waiting : null
1988
2551
  }
1989
2552
  }));
1990
2553
  }
@@ -2027,8 +2590,8 @@ async function startServer(options) {
2027
2590
  if (request === void 0) {
2028
2591
  return sendJson(res, 404, { ok: false, error: "No such request." });
2029
2592
  }
2030
- if (!(runner?.snapshot().failedIds.includes(request.id) ?? false)) {
2031
- return sendJson(res, 400, { ok: false, error: "Only a failed request can be retried." });
2593
+ if (!isEnded(request, runner?.snapshot().failedIds ?? [])) {
2594
+ return sendJson(res, 400, { ok: false, error: "Only an ended request can be run again." });
2032
2595
  }
2033
2596
  try {
2034
2597
  if (!await removeRequest(cwd, request.id)) {
@@ -2039,7 +2602,11 @@ async function startServer(options) {
2039
2602
  url: request.url,
2040
2603
  intent: request.intent,
2041
2604
  target: request.target,
2042
- prompt: request.prompt
2605
+ prompt: request.prompt,
2606
+ // The stored prompt already carries the mode's instructions; the
2607
+ // field travels with it so the queue keeps saying which kind of
2608
+ // change this is.
2609
+ ...request.mode === void 0 ? {} : { mode: request.mode }
2043
2610
  });
2044
2611
  runner?.nudge();
2045
2612
  return sendJson(res, 200, { ok: true });
@@ -2048,6 +2615,65 @@ async function startServer(options) {
2048
2615
  }
2049
2616
  });
2050
2617
  }
2618
+ if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "GET") {
2619
+ return void readAnnotations(cwd).then((annotations) => sendJson(res, 200, { annotations }));
2620
+ }
2621
+ if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "POST") {
2622
+ if (!hasJsonBody(req)) {
2623
+ return sendJson(res, 400, { ok: false, error: "A note must be JSON." });
2624
+ }
2625
+ let body = "";
2626
+ req.on("data", (chunk) => body += chunk);
2627
+ return void req.on("end", async () => {
2628
+ let parsed2;
2629
+ try {
2630
+ parsed2 = JSON.parse(body || "{}");
2631
+ } catch {
2632
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2633
+ }
2634
+ if (typeof parsed2.title !== "string" || parsed2.title.trim() === "") {
2635
+ return sendJson(res, 400, { ok: false, error: "A note needs a direction." });
2636
+ }
2637
+ const anchor = anchorFrom(parsed2.anchor);
2638
+ if (anchor === null) {
2639
+ return sendJson(res, 400, { ok: false, error: "A note needs something to point at." });
2640
+ }
2641
+ try {
2642
+ const annotation = await addAnnotation(cwd, {
2643
+ anchor,
2644
+ note: typeof parsed2.note === "string" ? parsed2.note.trim() : "",
2645
+ title: parsed2.title
2646
+ });
2647
+ return sendJson(res, 200, { ok: true, annotation });
2648
+ } catch {
2649
+ return sendJson(res, 500, { ok: false, error: "The note could not be kept." });
2650
+ }
2651
+ });
2652
+ }
2653
+ if (path === `${LEGLAS_PREFIX}/api/annotations/delete` && req.method === "POST") {
2654
+ if (!hasJsonBody(req)) {
2655
+ return sendJson(res, 400, { ok: false, error: "Delete must be JSON." });
2656
+ }
2657
+ let body = "";
2658
+ req.on("data", (chunk) => body += chunk);
2659
+ return void req.on("end", async () => {
2660
+ let parsed2;
2661
+ try {
2662
+ parsed2 = JSON.parse(body || "{}");
2663
+ } catch {
2664
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2665
+ }
2666
+ const ids = Array.isArray(parsed2.ids) ? parsed2.ids.filter((entry) => typeof entry === "string") : [];
2667
+ if (ids.length === 0) {
2668
+ return sendJson(res, 400, { ok: false, error: "Body needs the notes to forget." });
2669
+ }
2670
+ try {
2671
+ return sendJson(res, 200, { ok: true, deleted: await removeAnnotations(cwd, ids) });
2672
+ } catch {
2673
+ return sendJson(res, 500, { ok: false, error: "The notes could not be forgotten." });
2674
+ }
2675
+ });
2676
+ }
2051
2677
  if (path === `${LEGLAS_PREFIX}/api/requests/dismiss` && req.method === "POST") {
2052
2678
  if (!hasJsonBody(req)) {
2053
2679
  return sendJson(res, 400, { ok: false, error: "Dismiss must be JSON." });
@@ -2064,8 +2690,9 @@ async function startServer(options) {
2064
2690
  if (typeof parsed2.id !== "string") {
2065
2691
  return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
2066
2692
  }
2067
- if (!(runner?.snapshot().failedIds.includes(parsed2.id) ?? false)) {
2068
- return sendJson(res, 400, { ok: false, error: "Only a failed request can be dismissed." });
2693
+ const target2 = (await readRequests(cwd)).find((entry) => entry.id === parsed2.id);
2694
+ if (target2 === void 0 || !isEnded(target2, runner?.snapshot().failedIds ?? [])) {
2695
+ return sendJson(res, 400, { ok: false, error: "Only an ended request can be dismissed." });
2069
2696
  }
2070
2697
  try {
2071
2698
  if (!await removeRequest(cwd, parsed2.id)) {
@@ -2140,7 +2767,7 @@ async function startServer(options) {
2140
2767
  proxy.upgrade(req, socket, head);
2141
2768
  });
2142
2769
  const port = await bind(server, options.port ?? DEFAULT_PORT);
2143
- runner = startRunner({ cwd, externallyAttached });
2770
+ runner = startRunner({ cwd, externallyAttached, leglasCommand });
2144
2771
  let closePromise = null;
2145
2772
  return {
2146
2773
  port,
@@ -2165,7 +2792,7 @@ async function runClassify(options, deps) {
2165
2792
  const declared = await Promise.all(
2166
2793
  options.changes.map(async (change) => ({
2167
2794
  ...change,
2168
- exists: await stat(join8(options.cwd, change.path)).then(
2795
+ exists: await stat(join10(options.cwd, change.path)).then(
2169
2796
  () => true,
2170
2797
  () => false
2171
2798
  )
@@ -2430,8 +3057,8 @@ function runExplore(options, deps) {
2430
3057
  }
2431
3058
 
2432
3059
  // src/run-init.ts
2433
- import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
2434
- import { join as join9 } from "path";
3060
+ import { readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
3061
+ import { join as join11 } from "path";
2435
3062
 
2436
3063
  // src/init.ts
2437
3064
  var AGENTS_MARKER_START = "<!-- leglas:start -->";
@@ -2449,6 +3076,11 @@ direction. Build it directly. A planning or approval step before implementing
2449
3076
  costs more than the work itself, and the directions on screen are the thing
2450
3077
  being asked for.
2451
3078
 
3079
+ A request that says it came from the running Leglas interface has already
3080
+ completed exploration, request collection and the live-server check. Follow
3081
+ the exact source and registration command in that request directly. Do not
3082
+ repeat \`explore\`, \`requests\`, \`list\`, CLI help/version checks or server startup.
3083
+
2452
3084
  When asked for design variations, alternatives, or "a few options":
2453
3085
 
2454
3086
  1. **Add beside what exists. Never replace it.** Every direction has to render
@@ -2564,7 +3196,7 @@ ${AGENTS_SECTION}`
2564
3196
  // src/run-init.ts
2565
3197
  async function readIfPresent(path) {
2566
3198
  try {
2567
- return await readFile6(path, "utf8");
3199
+ return await readFile8(path, "utf8");
2568
3200
  } catch {
2569
3201
  return null;
2570
3202
  }
@@ -2572,18 +3204,18 @@ async function readIfPresent(path) {
2572
3204
  async function runInit(options, deps) {
2573
3205
  const existingConfig = findConfigFile(options.cwd);
2574
3206
  const plan = planInit({
2575
- agents: await readIfPresent(join9(options.cwd, "AGENTS.md")),
3207
+ agents: await readIfPresent(join11(options.cwd, "AGENTS.md")),
2576
3208
  config: existingConfig === null ? null : "present",
2577
- gitignore: await readIfPresent(join9(options.cwd, ".gitignore")),
3209
+ gitignore: await readIfPresent(join11(options.cwd, ".gitignore")),
2578
3210
  force: options.force
2579
3211
  });
2580
3212
  const touched = [];
2581
- for (const write of plan.writes) {
2582
- await writeFile5(join9(options.cwd, write.path), write.contents, "utf8");
2583
- touched.push(write.path);
3213
+ for (const write2 of plan.writes) {
3214
+ await writeFile6(join11(options.cwd, write2.path), write2.contents, "utf8");
3215
+ touched.push(write2.path);
2584
3216
  }
2585
3217
  if (plan.gitignore !== null) {
2586
- await writeFile5(join9(options.cwd, ".gitignore"), plan.gitignore, "utf8");
3218
+ await writeFile6(join11(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2587
3219
  touched.push(".gitignore");
2588
3220
  }
2589
3221
  if (options.json) {
@@ -2603,8 +3235,8 @@ async function runInit(options, deps) {
2603
3235
 
2604
3236
  // src/run-keep.ts
2605
3237
  import { existsSync as existsSync3 } from "fs";
2606
- import { mkdir as mkdir5, readFile as readFile7, rm as rm2, writeFile as writeFile6 } from "fs/promises";
2607
- import { dirname as dirname6, join as join10 } from "path";
3238
+ import { mkdir as mkdir6, readFile as readFile9, rm as rm2, writeFile as writeFile7 } from "fs/promises";
3239
+ import { dirname as dirname7, join as join12 } from "path";
2608
3240
 
2609
3241
  // src/keep.ts
2610
3242
  import { basename as basename2, extname as extname2, normalize as normalize2 } from "path";
@@ -2703,18 +3335,18 @@ async function runKeep(options, deps) {
2703
3335
  if (!resolved.ok) return fail(resolved.error);
2704
3336
  const plan = planKeep({ title: resolved.title, previews, to: options.to });
2705
3337
  if (!plan.ok) return fail(plan.error);
2706
- const from = join10(options.cwd, plan.move.from);
2707
- const to = join10(options.cwd, plan.move.to);
3338
+ const from = join12(options.cwd, plan.move.from);
3339
+ const to = join12(options.cwd, plan.move.to);
2708
3340
  if (!existsSync3(from)) {
2709
3341
  return fail(`${plan.move.from} does not exist. Nothing to keep.`);
2710
3342
  }
2711
3343
  if (existsSync3(to)) {
2712
3344
  return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
2713
3345
  }
2714
- const source = await readFile7(from, "utf8");
2715
- await mkdir5(dirname6(to), { recursive: true });
2716
- await writeFile6(to, renameExport(source, plan.exportName), "utf8");
2717
- await rm2(join10(options.cwd, plan.removeDir), { recursive: true, force: true });
3346
+ const source = await readFile9(from, "utf8");
3347
+ await mkdir6(dirname7(to), { recursive: true });
3348
+ await writeFile7(to, renameExport(source, plan.exportName), "utf8");
3349
+ await rm2(join12(options.cwd, plan.removeDir), { recursive: true, force: true });
2718
3350
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
2719
3351
  if (options.json) {
2720
3352
  deps.log(
@@ -2749,11 +3381,11 @@ async function runKeep(options, deps) {
2749
3381
 
2750
3382
  // src/run-new.ts
2751
3383
  import { existsSync as existsSync4 } from "fs";
2752
- import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
2753
- import { dirname as dirname7, join as join11 } from "path";
3384
+ import { mkdir as mkdir7, readFile as readFile10, writeFile as writeFile8 } from "fs/promises";
3385
+ import { dirname as dirname8, join as join13 } from "path";
2754
3386
  async function readIfPresent2(path) {
2755
3387
  try {
2756
- return await readFile8(path, "utf8");
3388
+ return await readFile10(path, "utf8");
2757
3389
  } catch {
2758
3390
  return null;
2759
3391
  }
@@ -2761,19 +3393,19 @@ async function readIfPresent2(path) {
2761
3393
  async function runNew(options, deps) {
2762
3394
  let from;
2763
3395
  if (options.from !== void 0) {
2764
- const contents = await readIfPresent2(join11(options.cwd, options.from));
3396
+ const contents = await readIfPresent2(join13(options.cwd, options.from));
2765
3397
  if (contents === null) {
2766
- const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
2767
- if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
2768
- else deps.log(message);
3398
+ const message2 = `${options.from} does not exist, so there is nothing to use as the baseline.`;
3399
+ if (options.json) deps.log(JSON.stringify({ ok: false, error: message2 }));
3400
+ else deps.log(message2);
2769
3401
  return { exitCode: 1, written: [] };
2770
3402
  }
2771
3403
  from = { path: options.from, contents };
2772
3404
  }
2773
3405
  const plan = planNew({
2774
3406
  surface: options.surface,
2775
- packageJson: await readIfPresent2(join11(options.cwd, "package.json")),
2776
- gitignore: await readIfPresent2(join11(options.cwd, ".gitignore")),
3407
+ packageJson: await readIfPresent2(join13(options.cwd, "package.json")),
3408
+ gitignore: await readIfPresent2(join13(options.cwd, ".gitignore")),
2777
3409
  from
2778
3410
  });
2779
3411
  const fail = (error) => {
@@ -2789,26 +3421,26 @@ async function runNew(options, deps) {
2789
3421
  deps.log(JSON.stringify({ ok: true, files: plan.writes, instructions: plan.instructions, previews: plan.previews }));
2790
3422
  return { exitCode: 0, written: [] };
2791
3423
  }
2792
- for (const write of plan.writes) {
2793
- deps.log(`--- ${write.path}`);
2794
- deps.log(write.contents);
3424
+ for (const write2 of plan.writes) {
3425
+ deps.log(`--- ${write2.path}`);
3426
+ deps.log(write2.contents);
2795
3427
  }
2796
3428
  deps.log(plan.instructions);
2797
3429
  return { exitCode: 0, written: [] };
2798
3430
  }
2799
- const existing = plan.writes.filter((write) => existsSync4(join11(options.cwd, write.path)));
3431
+ const existing = plan.writes.filter((write2) => existsSync4(join13(options.cwd, write2.path)));
2800
3432
  if (existing.length > 0) {
2801
3433
  return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
2802
3434
  }
2803
3435
  const written = [];
2804
- for (const write of plan.writes) {
2805
- const target = join11(options.cwd, write.path);
2806
- await mkdir6(dirname7(target), { recursive: true });
2807
- await writeFile7(target, write.contents, "utf8");
2808
- written.push(write.path);
3436
+ for (const write2 of plan.writes) {
3437
+ const target = join13(options.cwd, write2.path);
3438
+ await mkdir7(dirname8(target), { recursive: true });
3439
+ await writeFile8(target, write2.contents, "utf8");
3440
+ written.push(write2.path);
2809
3441
  }
2810
3442
  if (plan.gitignore !== null) {
2811
- await writeFile7(join11(options.cwd, ".gitignore"), plan.gitignore, "utf8");
3443
+ await writeFile8(join13(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2812
3444
  written.push(".gitignore");
2813
3445
  }
2814
3446
  if (options.json) {
@@ -2829,21 +3461,21 @@ async function runNew(options, deps) {
2829
3461
  }
2830
3462
 
2831
3463
  // src/run-previews.ts
2832
- import { readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
2833
- import { join as join12 } from "path";
3464
+ import { readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
3465
+ import { join as join14 } from "path";
2834
3466
  function envelope(deps, ok, body) {
2835
3467
  deps.log(JSON.stringify({ ok, ...body }));
2836
3468
  }
2837
3469
  async function ensureIgnored(cwd) {
2838
- const path = join12(cwd, ".gitignore");
3470
+ const path = join14(cwd, ".gitignore");
2839
3471
  let current = null;
2840
3472
  try {
2841
- current = await readFile9(path, "utf8");
3473
+ current = await readFile11(path, "utf8");
2842
3474
  } catch {
2843
3475
  current = null;
2844
3476
  }
2845
3477
  const next = ignoreEntry(current);
2846
- if (next !== null) await writeFile8(path, next, "utf8");
3478
+ if (next !== null) await writeFile9(path, next, "utf8");
2847
3479
  }
2848
3480
  async function runAdd(options, deps) {
2849
3481
  const loaded = await loadConfig(options.cwd);
@@ -2867,7 +3499,8 @@ async function runAdd(options, deps) {
2867
3499
  tags: options.preview.tags,
2868
3500
  branch: options.preview.branch,
2869
3501
  file: options.preview.file,
2870
- basedOn: options.preview.basedOn
3502
+ basedOn: options.preview.basedOn,
3503
+ askedFor: options.preview.askedFor
2871
3504
  },
2872
3505
  shared
2873
3506
  );
@@ -2927,6 +3560,7 @@ async function runList(options, deps) {
2927
3560
  note: preview.note ?? null,
2928
3561
  tags: preview.tags,
2929
3562
  basedOn: preview.basedOn ?? null,
3563
+ askedFor: preview.askedFor ?? null,
2930
3564
  local: preview.local,
2931
3565
  branch: preview.branch ?? null,
2932
3566
  file: preview.file ?? null
@@ -3089,23 +3723,23 @@ async function runShow(options, deps) {
3089
3723
 
3090
3724
  // src/run-watch.ts
3091
3725
  import { spawn as spawn3 } from "child_process";
3092
- import { mkdir as mkdir7, readFile as readFile10, writeFile as writeFile9 } from "fs/promises";
3093
- import { dirname as dirname8, join as join13 } from "path";
3726
+ import { mkdir as mkdir8, readFile as readFile12, writeFile as writeFile10 } from "fs/promises";
3727
+ import { dirname as dirname9, join as join15 } from "path";
3094
3728
  var POLL_MS2 = 2e3;
3095
3729
  var HEARTBEAT_TIMEOUT_MS = 1e3;
3096
3730
  async function saveTemplate(cwd, run3) {
3097
- const path = join13(cwd, WATCH_PATH);
3731
+ const path = join15(cwd, WATCH_PATH);
3098
3732
  let config = {};
3099
3733
  try {
3100
- const parsed2 = JSON.parse(await readFile10(path, "utf8"));
3734
+ const parsed2 = JSON.parse(await readFile12(path, "utf8"));
3101
3735
  if (typeof parsed2 === "object" && parsed2 !== null && !Array.isArray(parsed2)) {
3102
3736
  config = parsed2;
3103
3737
  }
3104
3738
  } catch {
3105
3739
  }
3106
3740
  config.run = run3;
3107
- await mkdir7(dirname8(path), { recursive: true });
3108
- await writeFile9(path, `${JSON.stringify(config, null, 2)}
3741
+ await mkdir8(dirname9(path), { recursive: true });
3742
+ await writeFile10(path, `${JSON.stringify(config, null, 2)}
3109
3743
  `, "utf8");
3110
3744
  }
3111
3745
  function spawnAgent(command, args, cwd) {
@@ -3191,9 +3825,13 @@ async function runWatch(options, deps) {
3191
3825
  return;
3192
3826
  }
3193
3827
  failed.add(request.id);
3194
- deps.error(
3195
- ` failed ${request.title}: ${outcome.ok ? `${command} exited ${outcome.code}` : outcome.error}`
3196
- );
3828
+ const failure = classifyFailure({
3829
+ agent: (shownCommand2.split(/\s+/)[0] ?? command).split("/").pop() ?? command,
3830
+ error: outcome.ok ? null : outcome.error,
3831
+ exitCode: outcome.ok ? outcome.code : null
3832
+ });
3833
+ await markFailed(options.cwd, request.id, failure);
3834
+ deps.error(` failed ${request.title}: ${failure.message}`);
3197
3835
  deps.error(" Left in the queue and not retried.");
3198
3836
  };
3199
3837
  const tick = async () => {
@@ -3240,18 +3878,28 @@ async function runWatch(options, deps) {
3240
3878
  // src/run.ts
3241
3879
  import { existsSync as existsSync5 } from "fs";
3242
3880
  import { createRequire } from "module";
3243
- import { basename as basename3, dirname as dirname9, join as join14, relative as relative4 } from "path";
3881
+ import { basename as basename3, dirname as dirname10, join as join16, relative as relative4 } from "path";
3244
3882
  import { fileURLToPath } from "url";
3245
3883
  function findShellDir() {
3246
- const bundled = join14(dirname9(fileURLToPath(import.meta.url)), "shell");
3247
- if (existsSync5(join14(bundled, "index.html"))) return bundled;
3884
+ const bundled = join16(dirname10(fileURLToPath(import.meta.url)), "shell");
3885
+ if (existsSync5(join16(bundled, "index.html"))) return bundled;
3248
3886
  try {
3249
3887
  const require2 = createRequire(import.meta.url);
3250
- return dirname9(require2.resolve("@leglas/shell/dist/index.html"));
3888
+ return dirname10(require2.resolve("@leglas/shell/dist/index.html"));
3251
3889
  } catch {
3252
3890
  return null;
3253
3891
  }
3254
3892
  }
3893
+ function shellWord(value) {
3894
+ if (/^[A-Za-z0-9_./:=+\\-]+$/.test(value)) return value;
3895
+ if (process.platform === "win32") return `"${value.replaceAll('"', '""')}"`;
3896
+ return `'${value.replaceAll("'", `'\\''`)}'`;
3897
+ }
3898
+ function embeddedLeglasCommand() {
3899
+ const entry = join16(dirname10(fileURLToPath(import.meta.url)), "bin.js");
3900
+ if (!existsSync5(entry)) return "npx -y leglas";
3901
+ return [process.execPath, entry].map(shellWord).join(" ");
3902
+ }
3255
3903
  async function run2(options, deps) {
3256
3904
  const loaded = await loadConfig(options.cwd);
3257
3905
  const local = await readLocalPreviews(options.cwd);
@@ -3281,7 +3929,7 @@ async function run2(options, deps) {
3281
3929
  const fileMounts = /* @__PURE__ */ new Map();
3282
3930
  for (const preview of merged?.previews ?? []) {
3283
3931
  if (preview.file !== void 0) {
3284
- const absolute = join14(options.cwd, preview.file);
3932
+ const absolute = join16(options.cwd, preview.file);
3285
3933
  if (!existsSync5(absolute)) {
3286
3934
  worktreeErrors.push(
3287
3935
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -3292,7 +3940,7 @@ async function run2(options, deps) {
3292
3940
  for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
3293
3941
  slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
3294
3942
  }
3295
- fileMounts.set(slug, dirname9(absolute));
3943
+ fileMounts.set(slug, dirname10(absolute));
3296
3944
  previews.push({
3297
3945
  ...preview,
3298
3946
  url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
@@ -3333,6 +3981,7 @@ async function run2(options, deps) {
3333
3981
  // directory does. Either way saved layout survives a port change.
3334
3982
  project: loaded.path ?? options.cwd,
3335
3983
  cwd: options.cwd,
3984
+ leglasCommand: embeddedLeglasCommand(),
3336
3985
  ...options.port === void 0 ? {} : { port: options.port }
3337
3986
  });
3338
3987
  const url = `${server.url}${LEGLAS_PREFIX}`;
@@ -3438,6 +4087,7 @@ Options for add
3438
4087
  --branch <name> Back the preview with a checkout of this git branch
3439
4088
  --file <path> Preview a plain HTML file served by Leglas itself
3440
4089
  --based-on <title> The direction this is a variant of; groups the family
4090
+ --asked-for <text> The change that was asked for, in the words that were typed
3441
4091
  `;
3442
4092
  function version() {
3443
4093
  const require2 = createRequire2(import.meta.url);