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/index.js CHANGED
@@ -57,6 +57,7 @@ function parseAdd(rest) {
57
57
  let branch;
58
58
  let file;
59
59
  let basedOn;
60
+ let askedFor;
60
61
  const tags = [];
61
62
  let json = false;
62
63
  for (let index = 0; index < rest.length; index += 1) {
@@ -75,7 +76,9 @@ function parseAdd(rest) {
75
76
  } else {
76
77
  value = argument.slice(equals + 1);
77
78
  }
78
- if (!["--title", "--url", "--note", "--tag", "--branch", "--file", "--based-on"].includes(flag)) {
79
+ if (!["--title", "--url", "--note", "--tag", "--branch", "--file", "--based-on", "--asked-for"].includes(
80
+ flag
81
+ )) {
79
82
  return { kind: "error", message: `leglas add does not take ${flag}.` };
80
83
  }
81
84
  if (value === void 0 || value === "") {
@@ -87,6 +90,7 @@ function parseAdd(rest) {
87
90
  else if (flag === "--branch") branch = value;
88
91
  else if (flag === "--file") file = value;
89
92
  else if (flag === "--based-on") basedOn = value;
93
+ else if (flag === "--asked-for") askedFor = value;
90
94
  else tags.push(value);
91
95
  }
92
96
  if (title === void 0) {
@@ -100,7 +104,16 @@ function parseAdd(rest) {
100
104
  }
101
105
  return {
102
106
  kind: "add",
103
- preview: { title, url, note, tags: tags.length > 0 ? tags : void 0, branch, file, basedOn },
107
+ preview: {
108
+ title,
109
+ url,
110
+ note,
111
+ tags: tags.length > 0 ? tags : void 0,
112
+ branch,
113
+ file,
114
+ basedOn,
115
+ askedFor
116
+ },
104
117
  json
105
118
  };
106
119
  }
@@ -600,6 +613,11 @@ direction. Build it directly. A planning or approval step before implementing
600
613
  costs more than the work itself, and the directions on screen are the thing
601
614
  being asked for.
602
615
 
616
+ A request that says it came from the running Leglas interface has already
617
+ completed exploration, request collection and the live-server check. Follow
618
+ the exact source and registration command in that request directly. Do not
619
+ repeat \`explore\`, \`requests\`, \`list\`, CLI help/version checks or server startup.
620
+
603
621
  When asked for design variations, alternatives, or "a few options":
604
622
 
605
623
  1. **Add beside what exists. Never replace it.** Every direction has to render
@@ -808,6 +826,10 @@ function normalizeConfig(raw, options = {}) {
808
826
  if (basedOn !== void 0 && (typeof basedOn !== "string" || basedOn.trim() === "")) {
809
827
  errors.push(`${at} has a basedOn that is not a direction title.`);
810
828
  }
829
+ const askedFor = entry["askedFor"];
830
+ if (askedFor !== void 0 && (typeof askedFor !== "string" || askedFor.trim() === "")) {
831
+ errors.push(`${at} has an askedFor that is not a change request.`);
832
+ }
811
833
  const tags = entry["tags"];
812
834
  previews.push({
813
835
  title: typeof title === "string" ? title : "",
@@ -816,7 +838,8 @@ function normalizeConfig(raw, options = {}) {
816
838
  tags: Array.isArray(tags) ? tags.filter((tag) => typeof tag === "string") : [],
817
839
  ...typeof branch === "string" ? { branch } : {},
818
840
  ...typeof file === "string" ? { file } : {},
819
- ...typeof basedOn === "string" && basedOn.trim() !== "" ? { basedOn } : {}
841
+ ...typeof basedOn === "string" && basedOn.trim() !== "" ? { basedOn } : {},
842
+ ...typeof askedFor === "string" && askedFor.trim() !== "" ? { askedFor } : {}
820
843
  });
821
844
  });
822
845
  const devCommand = source["devCommand"];
@@ -932,6 +955,10 @@ import { spawn } from "child_process";
932
955
  import { constants } from "fs";
933
956
  import { access, mkdir, readFile, writeFile } from "fs/promises";
934
957
  import { delimiter, dirname, isAbsolute, join, relative } from "path";
958
+ var CODEX_WORKSPACE_CONFIG = [
959
+ "-c",
960
+ "sandbox_workspace_write.network_access=true"
961
+ ];
935
962
  var KNOWN_AGENTS = {
936
963
  claude: {
937
964
  name: "Claude",
@@ -962,6 +989,12 @@ var KNOWN_AGENTS = {
962
989
  "--permission-mode",
963
990
  "acceptEdits"
964
991
  ],
992
+ // Non-interactive Claude cannot approve a Bash call: acceptEdits covers
993
+ // files, so a command the prompt requires is refused every time with
994
+ // nobody there to say yes. This allows exactly that command and nothing
995
+ // wider. Codex needs no equivalent, because workspace-write already lets
996
+ // it run commands.
997
+ allowArgs: (command) => ["--allowedTools", `Bash(${command} *)`],
965
998
  // Every stream-json event names its session.
966
999
  sessionFrom: (event) => typeof event.session_id === "string" && event.session_id !== "" ? event.session_id : null,
967
1000
  authArgs: ["auth", "status"],
@@ -982,15 +1015,41 @@ var KNOWN_AGENTS = {
982
1015
  codex: {
983
1016
  name: "Codex",
984
1017
  binary: "codex",
985
- args: (prompt) => ["exec", "--json", "-s", "workspace-write", prompt],
986
- terminalArgs: (prompt) => ["exec", "-s", "workspace-write", prompt],
1018
+ // `--skip-git-repo-check` is what lets Codex run at all in a project the
1019
+ // user never put under version control: without it codex-cli refuses
1020
+ // before it reaches a model, with "Not inside a trusted directory and
1021
+ // --skip-git-repo-check was not specified", and every Codex request in a
1022
+ // non-git project fails for a reason nothing in Leglas explained. The flag
1023
+ // moves that precondition and only that: `-s workspace-write` still
1024
+ // confines writes to the project, so the sandbox boundary is unchanged,
1025
+ // and in a git repository the flag does nothing at all.
1026
+ args: (prompt) => [
1027
+ "exec",
1028
+ "--json",
1029
+ ...CODEX_WORKSPACE_CONFIG,
1030
+ "-s",
1031
+ "workspace-write",
1032
+ "--skip-git-repo-check",
1033
+ prompt
1034
+ ],
1035
+ terminalArgs: (prompt) => [
1036
+ "exec",
1037
+ ...CODEX_WORKSPACE_CONFIG,
1038
+ "-s",
1039
+ "workspace-write",
1040
+ "--skip-git-repo-check",
1041
+ prompt
1042
+ ],
987
1043
  // No sandbox flag here: `codex exec resume` refuses it and inherits the
988
- // session's own sandbox, which the first turn set to workspace-write.
1044
+ // session's own sandbox, which the first turn set to workspace-write. The
1045
+ // repository check is per invocation, so resume needs the flag of its own.
989
1046
  resumeArgs: (sessionId, prompt) => [
990
1047
  "exec",
991
1048
  "resume",
992
1049
  sessionId,
993
1050
  "--json",
1051
+ ...CODEX_WORKSPACE_CONFIG,
1052
+ "--skip-git-repo-check",
994
1053
  prompt
995
1054
  ],
996
1055
  sessionFrom: (event) => event.type === "thread.started" && typeof event.thread_id === "string" ? event.thread_id : null,
@@ -1016,7 +1075,7 @@ var KNOWN_AGENTS = {
1016
1075
  }
1017
1076
  };
1018
1077
  var PROBE_TIMEOUT_MS = 3e3;
1019
- function execProbe(binary, args) {
1078
+ function execProbe(binary, args, timeoutMs = PROBE_TIMEOUT_MS) {
1020
1079
  return new Promise((resolve) => {
1021
1080
  let child;
1022
1081
  try {
@@ -1029,7 +1088,10 @@ function execProbe(binary, args) {
1029
1088
  if (stdout.length < 4096)
1030
1089
  stdout += chunk.toString();
1031
1090
  });
1032
- const deadline = setTimeout(() => child.kill("SIGKILL"), PROBE_TIMEOUT_MS);
1091
+ const deadline = setTimeout(() => {
1092
+ child.kill("SIGKILL");
1093
+ resolve(null);
1094
+ }, timeoutMs);
1033
1095
  child.once("error", () => {
1034
1096
  clearTimeout(deadline);
1035
1097
  resolve(null);
@@ -1098,10 +1160,10 @@ function shownCommand(value) {
1098
1160
  function claudeActivity(event, cwd) {
1099
1161
  if (event.type !== "assistant")
1100
1162
  return null;
1101
- const message = record(event.message);
1102
- if (message === null || !Array.isArray(message.content))
1163
+ const message2 = record(event.message);
1164
+ if (message2 === null || !Array.isArray(message2.content))
1103
1165
  return null;
1104
- for (const rawBlock of message.content) {
1166
+ for (const rawBlock of message2.content) {
1105
1167
  const block = record(rawBlock);
1106
1168
  if (block?.type !== "tool_use" || typeof block.name !== "string")
1107
1169
  continue;
@@ -1170,6 +1232,25 @@ function sessionFrom(agent, line) {
1170
1232
  return null;
1171
1233
  return KNOWN_AGENTS[agent].sessionFrom(event);
1172
1234
  }
1235
+ function retryFrom(agent, line) {
1236
+ if (agent !== "claude" && agent !== "cursor")
1237
+ return null;
1238
+ let event;
1239
+ try {
1240
+ event = record(JSON.parse(line));
1241
+ } catch {
1242
+ return null;
1243
+ }
1244
+ if (event === null || event.type !== "system" || event.subtype !== "api_retry")
1245
+ return null;
1246
+ const attempt = typeof event.attempt === "number" ? event.attempt : 1;
1247
+ return {
1248
+ attempt,
1249
+ max: typeof event.max_retries === "number" ? event.max_retries : null,
1250
+ status: typeof event.error_status === "number" ? event.error_status : null,
1251
+ reason: typeof event.error === "string" && event.error !== "" ? event.error.toLowerCase() : null
1252
+ };
1253
+ }
1173
1254
  function isAgentChoice(value) {
1174
1255
  return value === "custom" || typeof value === "string" && Object.hasOwn(KNOWN_AGENTS, value);
1175
1256
  }
@@ -1313,8 +1394,8 @@ async function loadConfig(cwd) {
1313
1394
  exported = module.default;
1314
1395
  }
1315
1396
  } catch (error) {
1316
- const message = error instanceof Error ? error.message : String(error);
1317
- return { config: null, errors: [`${label} could not be loaded: ${message}`], path };
1397
+ const message2 = error instanceof Error ? error.message : String(error);
1398
+ return { config: null, errors: [`${label} could not be loaded: ${message2}`], path };
1318
1399
  }
1319
1400
  const result = normalizeConfig(exported);
1320
1401
  return {
@@ -1333,8 +1414,17 @@ async function readLocalPreviews(cwd) {
1333
1414
  let raw;
1334
1415
  try {
1335
1416
  raw = await readFile3(path, "utf8");
1336
- } catch {
1337
- return { previews: [], errors: [] };
1417
+ } catch (error) {
1418
+ const code = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : null;
1419
+ if (code === "ENOENT") {
1420
+ return { previews: [], errors: [] };
1421
+ }
1422
+ return {
1423
+ previews: [],
1424
+ errors: [
1425
+ `${LOCAL_PREVIEWS_PATH} could not be read (${code ?? "unknown error"}). Check its permissions and file type; nothing shared is lost.`
1426
+ ]
1427
+ };
1338
1428
  }
1339
1429
  let parsed;
1340
1430
  try {
@@ -1375,7 +1465,8 @@ async function addLocalPreview(cwd, input, shared) {
1375
1465
  ...input.tags === void 0 ? {} : { tags: input.tags },
1376
1466
  ...input.branch === void 0 ? {} : { branch: input.branch },
1377
1467
  ...input.file === void 0 ? {} : { file: input.file },
1378
- ...input.basedOn === void 0 ? {} : { basedOn: input.basedOn }
1468
+ ...input.basedOn === void 0 ? {} : { basedOn: input.basedOn },
1469
+ ...input.askedFor === void 0 ? {} : { askedFor: input.askedFor }
1379
1470
  };
1380
1471
  const check = normalizeConfig({ previews: [candidate] }, { requireDevCommand: false });
1381
1472
  if (check.config === null) {
@@ -1607,17 +1698,220 @@ async function startAppProcess(options) {
1607
1698
  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.`);
1608
1699
  }
1609
1700
 
1610
- // ../server/dist/requests.js
1611
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1701
+ // ../server/dist/failure.js
1702
+ var NEEDS_TRUST = /not inside a trusted directory|--skip-git-repo-check/i;
1703
+ var MISSING_BINARY = /\b(ENOENT|EACCES|ENOTDIR)\b/;
1704
+ 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;
1705
+ var LIMIT = /\b429\b|rate limit|usage limit|quota exceeded|too many requests/i;
1706
+ var OVERLOADED = /\b(?:503|529)\b|overloaded|service unavailable/i;
1707
+ function fromStatus(status, reason) {
1708
+ if (status === 401 || status === 403 || reason === "authentication_failed")
1709
+ return "not-signed-in";
1710
+ if (status === 429 || reason === "rate_limit")
1711
+ return "provider-limit";
1712
+ if (status === 529 || status === 503 || reason === "overloaded")
1713
+ return "provider-overloaded";
1714
+ return null;
1715
+ }
1716
+ function fromLines(lines) {
1717
+ for (const line of [...lines].reverse()) {
1718
+ if (NEEDS_TRUST.test(line))
1719
+ return "needs-trust";
1720
+ if (NOT_SIGNED_IN.test(line))
1721
+ return "not-signed-in";
1722
+ if (LIMIT.test(line))
1723
+ return "provider-limit";
1724
+ if (OVERLOADED.test(line))
1725
+ return "provider-overloaded";
1726
+ }
1727
+ return null;
1728
+ }
1729
+ function attempts(retry) {
1730
+ if (retry === null || retry === void 0)
1731
+ return "";
1732
+ const total = retry.max === null ? retry.attempt : Math.max(retry.attempt, retry.max);
1733
+ return ` It retried ${total} times first.`;
1734
+ }
1735
+ function message(code, input) {
1736
+ const agent = input.agent;
1737
+ switch (code) {
1738
+ case "cancelled":
1739
+ return "You stopped this run.";
1740
+ case "stopped":
1741
+ return "Leglas shut down while this was running.";
1742
+ case "missing-agent":
1743
+ return `${agent} could not be started. Its command is not on this machine's PATH any more.`;
1744
+ case "not-signed-in":
1745
+ return `${agent} is not signed in. Sign in to it in a terminal, then run this again.`;
1746
+ case "provider-overloaded":
1747
+ return `${agent}'s provider was overloaded and gave up.${attempts(input.retry)}`;
1748
+ case "provider-limit":
1749
+ return `${agent} reported a rate or usage limit, so nothing ran.`;
1750
+ case "needs-trust":
1751
+ return `Codex refused this project: it is not a git repository and Codex has no trust on record for it.`;
1752
+ case "not-registered":
1753
+ return `${agent} finished without registering the new direction, so nothing reached the rail. Its last output is in the Leglas terminal.`;
1754
+ case "agent-error":
1755
+ 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.`;
1756
+ }
1757
+ }
1758
+ function classifyFailure(input) {
1759
+ const lines = input.lines ?? [];
1760
+ const error = input.error ?? null;
1761
+ 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";
1762
+ return { code, message: message(code, input) };
1763
+ }
1764
+ function sessionShaped(code) {
1765
+ return code === "agent-error";
1766
+ }
1767
+
1768
+ // ../server/dist/annotations.js
1612
1769
  import { randomBytes } from "crypto";
1770
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1613
1771
  import { dirname as dirname4, join as join5 } from "path";
1772
+ var ANNOTATIONS_PATH = ".leglas/annotations.json";
1773
+ var NOTE_CAP = 500;
1774
+ var SELECTOR_CAP = 300;
1775
+ var TEXT_CAP = 120;
1776
+ var TAG_CAP = 40;
1777
+ var CLASS_CAP = 8;
1778
+ var CLASS_LENGTH_CAP = 60;
1779
+ var COVERS_CAP = 8;
1780
+ function isRecord2(value) {
1781
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1782
+ }
1783
+ function text(value, cap) {
1784
+ return typeof value === "string" ? value.trim().slice(0, cap) : "";
1785
+ }
1786
+ function fraction(value) {
1787
+ if (typeof value !== "number" || !Number.isFinite(value))
1788
+ return 0.5;
1789
+ return Math.min(1, Math.max(0, value));
1790
+ }
1791
+ function size(value) {
1792
+ return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0;
1793
+ }
1794
+ function anchorFrom(value) {
1795
+ if (!isRecord2(value))
1796
+ return null;
1797
+ const selector = text(value["selector"], SELECTOR_CAP);
1798
+ if (selector === "")
1799
+ return null;
1800
+ const rect = isRecord2(value["rect"]) ? value["rect"] : {};
1801
+ 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)) : [];
1802
+ const rawRegion = isRecord2(value["region"]) ? value["region"] : null;
1803
+ const region = rawRegion === null ? null : {
1804
+ height: fraction(rawRegion["height"]),
1805
+ width: fraction(rawRegion["width"]),
1806
+ x: fraction(rawRegion["x"]),
1807
+ y: fraction(rawRegion["y"])
1808
+ };
1809
+ const covers = Array.isArray(value["covers"]) ? value["covers"].filter(isRecord2).slice(0, COVERS_CAP).map((entry) => ({
1810
+ tag: text(entry["tag"], TAG_CAP) || "element",
1811
+ text: text(entry["text"], TEXT_CAP)
1812
+ })) : [];
1813
+ return {
1814
+ classes,
1815
+ ...covers.length === 0 ? {} : { covers },
1816
+ ...region === null ? {} : { region },
1817
+ rect: {
1818
+ height: size(rect["height"]),
1819
+ width: size(rect["width"]),
1820
+ x: size(rect["x"]),
1821
+ y: size(rect["y"])
1822
+ },
1823
+ selector,
1824
+ spot: {
1825
+ x: fraction(isRecord2(value["spot"]) ? value["spot"]["x"] : void 0),
1826
+ y: fraction(isRecord2(value["spot"]) ? value["spot"]["y"] : void 0)
1827
+ },
1828
+ tag: text(value["tag"], TAG_CAP) || "element",
1829
+ text: text(value["text"], TEXT_CAP),
1830
+ viewport: size(value["viewport"])
1831
+ };
1832
+ }
1833
+ async function readAnnotations(cwd) {
1834
+ try {
1835
+ const raw = await readFile4(join5(cwd, ANNOTATIONS_PATH), "utf8");
1836
+ const parsed = JSON.parse(raw);
1837
+ if (!Array.isArray(parsed.annotations))
1838
+ return [];
1839
+ return parsed.annotations.flatMap((entry, index) => {
1840
+ if (!isRecord2(entry))
1841
+ return [];
1842
+ const anchor = anchorFrom(entry["anchor"]);
1843
+ const title = text(entry["title"], TAG_CAP * 4);
1844
+ if (anchor === null || title === "")
1845
+ return [];
1846
+ return [
1847
+ {
1848
+ anchor,
1849
+ id: typeof entry["id"] === "string" ? entry["id"] : String(index),
1850
+ note: text(entry["note"], NOTE_CAP),
1851
+ title
1852
+ }
1853
+ ];
1854
+ });
1855
+ } catch {
1856
+ return [];
1857
+ }
1858
+ }
1859
+ async function write(cwd, annotations) {
1860
+ const path = join5(cwd, ANNOTATIONS_PATH);
1861
+ await mkdir3(dirname4(path), { recursive: true });
1862
+ await writeFile3(path, `${JSON.stringify({ annotations }, null, 2)}
1863
+ `, "utf8");
1864
+ }
1865
+ async function addAnnotation(cwd, input) {
1866
+ const annotation = { ...input, id: randomBytes(6).toString("base64url") };
1867
+ await write(cwd, [...await readAnnotations(cwd), annotation]);
1868
+ return annotation;
1869
+ }
1870
+ async function removeAnnotations(cwd, ids) {
1871
+ const wanted = new Set(ids);
1872
+ const annotations = await readAnnotations(cwd);
1873
+ const remaining = annotations.filter((entry) => !wanted.has(entry.id));
1874
+ const dropped = annotations.length - remaining.length;
1875
+ if (dropped > 0)
1876
+ await write(cwd, remaining);
1877
+ return dropped;
1878
+ }
1879
+ function annotationsFor(annotations, title) {
1880
+ return annotations.filter((entry) => entry.title === title);
1881
+ }
1882
+ function describeAnchor(anchor) {
1883
+ const where = `about ${anchor.rect.width}\xD7${anchor.rect.height} at (${anchor.rect.x}, ${anchor.rect.y}) in a ${anchor.viewport}px-wide viewport`;
1884
+ if (anchor.region !== void 0) {
1885
+ const covered = (anchor.covers ?? []).map((entry) => entry.text === "" ? `<${entry.tag}>` : `<${entry.tag}> \u201C${entry.text}\u201D`).join(", ");
1886
+ const inside = covered === "" ? "" : ` covering ${covered};`;
1887
+ return `an area inside <${anchor.tag}>;${inside} path ${anchor.selector}; ${where}`;
1888
+ }
1889
+ const parts = [`<${anchor.tag}>`];
1890
+ if (anchor.classes.length > 0)
1891
+ parts.push(`class "${anchor.classes.join(" ")}"`);
1892
+ if (anchor.text !== "")
1893
+ parts.push(`reading \u201C${anchor.text}\u201D`);
1894
+ return `${parts.join(", ")}; path ${anchor.selector}; ${where}`;
1895
+ }
1896
+ function describeAnnotations(annotations) {
1897
+ return annotations.map((annotation, index) => {
1898
+ const said = annotation.note === "" ? "Look at this." : annotation.note;
1899
+ return `${index + 1}. ${said}
1900
+ The element: ${describeAnchor(annotation.anchor)}`;
1901
+ }).join("\n\n");
1902
+ }
1903
+
1904
+ // ../server/dist/requests.js
1905
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1906
+ import { randomBytes as randomBytes2 } from "crypto";
1907
+ import { dirname as dirname5, join as join6 } from "path";
1614
1908
  var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
1615
- function targetFor(url) {
1909
+ function variantSlot(url) {
1616
1910
  if (!url.startsWith("/"))
1617
1911
  return null;
1618
- const query = url.slice(url.indexOf("?") + 1);
1619
1912
  if (!url.includes("?"))
1620
1913
  return null;
1914
+ const query = url.slice(url.indexOf("?") + 1);
1621
1915
  for (const pair of query.split("&")) {
1622
1916
  const [rawKey, rawValue] = pair.split("=");
1623
1917
  if (rawKey === void 0 || rawValue === void 0)
@@ -1628,37 +1922,125 @@ function targetFor(url) {
1628
1922
  const option = decodeURIComponent(rawValue);
1629
1923
  if (!SAFE_SEGMENT.test(surface) || !SAFE_SEGMENT.test(option))
1630
1924
  return null;
1631
- return `.leglas/variants/${surface}/${option}.tsx`;
1925
+ return { surface, option };
1632
1926
  }
1633
1927
  return null;
1634
1928
  }
1635
- function composeRequest(preview, intent) {
1929
+ function targetFor(url) {
1930
+ const slot = variantSlot(url);
1931
+ return slot === null ? null : `.leglas/variants/${slot.surface}/${slot.option}.tsx`;
1932
+ }
1933
+ function composeRequest(preview, intent, mode, notes = [], leglasCommand = "npx -y leglas") {
1636
1934
  const target = preview.file ?? targetFor(preview.url);
1637
1935
  const cleaned = intent.trim();
1936
+ const asked = changeBlock(cleaned, notes);
1937
+ const recorded = cleaned === "" ? notes.map((entry) => entry.note).filter((entry) => entry !== "").join("; ") : cleaned;
1938
+ const prompt = mode === "variant" ? variantPrompt(preview, recorded, asked, target, leglasCommand) : replacePrompt(preview, asked, target);
1939
+ return { prompt, target, mode };
1940
+ }
1941
+ 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.`;
1942
+ function changeBlock(cleaned, notes) {
1943
+ if (notes.length === 0)
1944
+ return `What to change: ${cleaned}`;
1945
+ const many = notes.length === 1 ? "a note" : `${notes.length} notes`;
1946
+ const lead = cleaned === "" ? `What to change, left as ${many} on the design itself:` : `What to change: ${cleaned}
1947
+
1948
+ And ${many} left on the design itself:`;
1949
+ return `${lead}
1950
+
1951
+ ${describeAnnotations(notes)}
1952
+
1953
+ ${ANCHORS}`;
1954
+ }
1955
+ 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.
1956
+
1957
+ 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.
1958
+
1959
+ 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.`;
1960
+ function registrationCommand(leglasCommand) {
1961
+ return `${leglasCommand} add`;
1962
+ }
1963
+ function replacePrompt(preview, asked, target) {
1638
1964
  const where = target === null ? `The direction is titled "${preview.title}" and renders at ${preview.url}. Find what produces it.` : `It lives at ${target}.`;
1639
1965
  const pace = target === null ? `Once found, make the change and finish. ` : `Make the change in that file and finish. `;
1640
- const prompt = `In this project, change only the "${preview.title}" design direction. ${where}
1966
+ return `In this project, change only the "${preview.title}" design direction. ${where}
1967
+
1968
+ ${asked}
1969
+
1970
+ ${pace}${SCOPE} The direction is already registered, so nothing needs re-registering.`;
1971
+ }
1972
+ function variantPrompt(preview, recorded, asked, target, leglasCommand) {
1973
+ const slot = variantSlot(preview.url);
1974
+ const parent = JSON.stringify(preview.title);
1975
+ const askedFor = JSON.stringify(recorded);
1976
+ const add = registrationCommand(leglasCommand);
1977
+ const source = target === null ? `Find what renders it first.` : `Its source is ${target}.`;
1978
+ const [make, register] = preview.file !== void 0 ? [
1979
+ `Copy that file to a new file beside it and make the change in the copy.`,
1980
+ ` ${add} --title "<name>" --file "<the new file>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
1981
+ ] : slot !== null ? [
1982
+ `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.`,
1983
+ ` ${add} --title "<name>" --url "/?v-${slot.surface}=<key>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
1984
+ ] : [
1985
+ `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.`,
1986
+ ` ${add} --title "<name>" --url "<the URL that shows it>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
1987
+ ];
1988
+ 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.
1641
1989
 
1642
- What to change: ${cleaned}
1990
+ ${source} ${make}
1643
1991
 
1644
- ${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.
1992
+ ${asked}
1993
+
1994
+ Then register it, which is what puts it on the rail:
1995
+
1996
+ ${register}
1645
1997
 
1646
- 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.`;
1647
- return { prompt, target };
1998
+ 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.
1999
+
2000
+ ${SCOPE}`;
1648
2001
  }
1649
2002
  var REQUESTS_PATH = ".leglas/requests.json";
2003
+ var TERMINAL = ["failed", "cancelled"];
2004
+ function isTerminal(status) {
2005
+ return TERMINAL.includes(status);
2006
+ }
2007
+ var FAILURE_CODES = [
2008
+ "cancelled",
2009
+ "stopped",
2010
+ "missing-agent",
2011
+ "not-signed-in",
2012
+ "provider-overloaded",
2013
+ "provider-limit",
2014
+ "needs-trust",
2015
+ "not-registered",
2016
+ "agent-error"
2017
+ ];
2018
+ function failureOf(value) {
2019
+ if (typeof value !== "object" || value === null)
2020
+ return null;
2021
+ const entry = value;
2022
+ if (typeof entry.message !== "string" || entry.message === "")
2023
+ return null;
2024
+ if (entry.code === void 0 || !FAILURE_CODES.includes(entry.code))
2025
+ return null;
2026
+ return { code: entry.code, message: entry.message };
2027
+ }
1650
2028
  async function readRequests(cwd) {
1651
2029
  try {
1652
- const raw = await readFile4(join5(cwd, REQUESTS_PATH), "utf8");
2030
+ const raw = await readFile5(join6(cwd, REQUESTS_PATH), "utf8");
1653
2031
  const parsed = JSON.parse(raw);
1654
2032
  if (!Array.isArray(parsed.requests))
1655
2033
  return [];
1656
2034
  return parsed.requests.map((request, index) => {
1657
- const entry = request;
2035
+ const { failure: rawFailure, ...entry } = request;
2036
+ const status = entry.status === "picked-up" || entry.status === "failed" || entry.status === "cancelled" ? entry.status : "queued";
2037
+ const failure = isTerminal(status) ? failureOf(rawFailure) : null;
1658
2038
  return {
1659
2039
  ...entry,
1660
2040
  id: typeof entry.id === "string" ? entry.id : String(index),
1661
- status: entry.status === "picked-up" ? "picked-up" : "queued"
2041
+ status,
2042
+ mode: entry.mode === "variant" ? "variant" : "replace",
2043
+ ...failure === null ? {} : { failure }
1662
2044
  };
1663
2045
  });
1664
2046
  } catch {
@@ -1666,23 +2048,23 @@ async function readRequests(cwd) {
1666
2048
  }
1667
2049
  }
1668
2050
  async function writeQueue(cwd, requests) {
1669
- const path = join5(cwd, REQUESTS_PATH);
1670
- await mkdir3(dirname4(path), { recursive: true });
1671
- await writeFile3(path, `${JSON.stringify({ requests }, null, 2)}
2051
+ const path = join6(cwd, REQUESTS_PATH);
2052
+ await mkdir4(dirname5(path), { recursive: true });
2053
+ await writeFile4(path, `${JSON.stringify({ requests }, null, 2)}
1672
2054
  `, "utf8");
1673
2055
  }
1674
2056
  async function appendRequest(cwd, request) {
1675
2057
  await writeQueue(cwd, [
1676
2058
  ...await readRequests(cwd),
1677
- { ...request, id: randomBytes(6).toString("base64url"), status: "queued" }
2059
+ { ...request, id: randomBytes2(6).toString("base64url"), status: "queued" }
1678
2060
  ]);
1679
2061
  }
1680
2062
  async function collectRequests(cwd) {
1681
2063
  const requests = await readRequests(cwd);
1682
- const collected = requests.map((request) => ({ ...request, status: "picked-up" }));
1683
- if (requests.some((request) => request.status !== "picked-up"))
2064
+ const collected = requests.map((request) => isTerminal(request.status) ? request : { ...request, status: "picked-up" });
2065
+ if (requests.some((request) => request.status === "queued"))
1684
2066
  await writeQueue(cwd, collected);
1685
- return collected;
2067
+ return collected.filter((request) => !isTerminal(request.status));
1686
2068
  }
1687
2069
  async function markPickedUp(cwd, id) {
1688
2070
  const requests = await readRequests(cwd);
@@ -1691,6 +2073,17 @@ async function markPickedUp(cwd, id) {
1691
2073
  await writeQueue(cwd, requests.map((request) => request.id === id ? { ...request, status: "picked-up" } : request));
1692
2074
  return true;
1693
2075
  }
2076
+ async function markFailed(cwd, id, failure) {
2077
+ const requests = await readRequests(cwd);
2078
+ if (!requests.some((request) => request.id === id))
2079
+ return false;
2080
+ await writeQueue(cwd, requests.map((request) => request.id === id ? {
2081
+ ...request,
2082
+ status: failure.code === "cancelled" ? "cancelled" : "failed",
2083
+ failure
2084
+ } : request));
2085
+ return true;
2086
+ }
1694
2087
  async function removeRequest(cwd, id) {
1695
2088
  const requests = await readRequests(cwd);
1696
2089
  const remaining = requests.filter((request) => request.id !== id);
@@ -1701,7 +2094,7 @@ async function removeRequest(cwd, id) {
1701
2094
  }
1702
2095
  async function clearRequests(cwd) {
1703
2096
  const requests = await readRequests(cwd);
1704
- const pending = requests.filter((request) => request.status !== "picked-up");
2097
+ const pending = requests.filter((request) => request.status === "queued");
1705
2098
  const cleared = requests.length - pending.length;
1706
2099
  if (cleared > 0)
1707
2100
  await writeQueue(cwd, pending);
@@ -1710,10 +2103,13 @@ async function clearRequests(cwd) {
1710
2103
 
1711
2104
  // ../server/dist/runner.js
1712
2105
  import { spawn as nodeSpawn } from "child_process";
2106
+ import { readFile as readFile6 } from "fs/promises";
2107
+ import { join as join7 } from "path";
1713
2108
  var POLL_MS = 2e3;
1714
2109
  var OUTPUT_LINES = 20;
2110
+ var CANCEL_GRACE_MS = 5e3;
1715
2111
  var SESSION_TURNS_CAP = 8;
1716
- function resolveCommand(choice, prompt, sessionId = null) {
2112
+ function resolveCommand(choice, prompt, sessionId = null, registration = null) {
1717
2113
  if (choice.agent === null)
1718
2114
  return null;
1719
2115
  if (choice.agent === "custom") {
@@ -1725,12 +2121,13 @@ function resolveCommand(choice, prompt, sessionId = null) {
1725
2121
  return { agent: "custom", name: "Custom", ...commandFor(parsed.template, prompt), resumed: false };
1726
2122
  }
1727
2123
  const adapter = KNOWN_AGENTS[choice.agent];
2124
+ const allow = registration !== null && "allowArgs" in adapter ? adapter.allowArgs(registration) : [];
1728
2125
  if (sessionId !== null && "resumeArgs" in adapter) {
1729
2126
  return {
1730
2127
  agent: choice.agent,
1731
2128
  name: adapter.name,
1732
2129
  command: adapter.binary,
1733
- args: adapter.resumeArgs(sessionId, prompt),
2130
+ args: [...adapter.resumeArgs(sessionId, prompt), ...allow],
1734
2131
  resumed: true
1735
2132
  };
1736
2133
  }
@@ -1738,7 +2135,7 @@ function resolveCommand(choice, prompt, sessionId = null) {
1738
2135
  agent: choice.agent,
1739
2136
  name: adapter.name,
1740
2137
  command: adapter.binary,
1741
- args: adapter.args(prompt),
2138
+ args: [...adapter.args(prompt), ...allow],
1742
2139
  resumed: false
1743
2140
  };
1744
2141
  }
@@ -1768,12 +2165,17 @@ function startRunner(options) {
1768
2165
  const setEvery = options.setInterval ?? ((callback, milliseconds) => setInterval(callback, milliseconds));
1769
2166
  const clearEvery = options.clearInterval ?? ((handle2) => clearInterval(handle2));
1770
2167
  const failed = /* @__PURE__ */ new Set();
2168
+ const setLater = options.setTimeout ?? ((callback, milliseconds) => {
2169
+ setTimeout(callback, milliseconds).unref?.();
2170
+ });
1771
2171
  let state = {
1772
2172
  running: false,
1773
2173
  requestId: null,
1774
2174
  agent: null,
1775
2175
  activity: null,
1776
- startedAt: null
2176
+ startedAt: null,
2177
+ stopping: false,
2178
+ waiting: null
1777
2179
  };
1778
2180
  let stopped = false;
1779
2181
  let ticking = null;
@@ -1781,15 +2183,30 @@ function startRunner(options) {
1781
2183
  let active = null;
1782
2184
  const sessions = /* @__PURE__ */ new Map();
1783
2185
  const idle = () => {
1784
- state = { running: false, requestId: null, agent: null, activity: null, startedAt: null };
2186
+ state = {
2187
+ running: false,
2188
+ requestId: null,
2189
+ agent: null,
2190
+ activity: null,
2191
+ startedAt: null,
2192
+ stopping: false,
2193
+ waiting: null
2194
+ };
1785
2195
  };
1786
2196
  const rememberLine = (lines, line) => {
1787
2197
  lines.push(line);
1788
2198
  if (lines.length > OUTPUT_LINES)
1789
2199
  lines.splice(0, lines.length - OUTPUT_LINES);
1790
2200
  };
1791
- const reportFailure = (request, error, lines) => {
1792
- console.error(`Leglas agent failed for ${request.title}: ${error}`);
2201
+ const reportFailure = async (request, failure, lines) => {
2202
+ await markFailed(options.cwd, request.id, failure).catch(() => {
2203
+ });
2204
+ failed.add(request.id);
2205
+ if (failure.code === "cancelled") {
2206
+ console.error(`Leglas stopped the run for ${request.title}.`);
2207
+ return;
2208
+ }
2209
+ console.error(`Leglas agent failed for ${request.title}: ${failure.message}`);
1793
2210
  for (const line of lines)
1794
2211
  console.error(` ${line}`);
1795
2212
  };
@@ -1807,19 +2224,31 @@ function startRunner(options) {
1807
2224
  error: error instanceof Error ? error.message : String(error)
1808
2225
  });
1809
2226
  }
1810
- const current = { child, requestId: request.id, cancelled: false };
2227
+ const current = {
2228
+ child,
2229
+ requestId: request.id,
2230
+ cancelled: false,
2231
+ abandon: () => {
2232
+ }
2233
+ };
1811
2234
  active = current;
1812
2235
  const stdoutFlush = lineReader(child.stdout, (line) => {
1813
2236
  rememberLine(lines, line);
1814
2237
  const sessionId = sessionFrom(resolved.agent, line);
1815
2238
  if (sessionId !== null)
1816
2239
  observed.sessionId = sessionId;
2240
+ const retry = retryFrom(resolved.agent, line);
2241
+ if (retry !== null) {
2242
+ observed.retry = retry;
2243
+ if (active === current)
2244
+ state = { ...state, waiting: retry };
2245
+ }
1817
2246
  const activity = activityFrom(resolved.agent, line, options.cwd);
1818
2247
  if (activity !== null) {
1819
2248
  if (activity.startsWith("editing"))
1820
2249
  observed.edited = true;
1821
2250
  if (active === current)
1822
- state = { ...state, activity };
2251
+ state = { ...state, activity, waiting: null };
1823
2252
  }
1824
2253
  });
1825
2254
  const stderrFlush = lineReader(child.stderr, (line) => rememberLine(lines, line));
@@ -1833,6 +2262,7 @@ function startRunner(options) {
1833
2262
  stderrFlush();
1834
2263
  resolve(outcome);
1835
2264
  };
2265
+ current.abandon = () => settle({ ok: false, error: "cancelled" });
1836
2266
  child.once("error", (error) => settle({ ok: false, error: error.message }));
1837
2267
  child.once("close", (code, signal) => {
1838
2268
  if (current.cancelled)
@@ -1846,10 +2276,12 @@ function startRunner(options) {
1846
2276
  active = null;
1847
2277
  });
1848
2278
  };
2279
+ const registered = () => readFile6(join7(options.cwd, LOCAL_PREVIEWS_PATH), "utf8").catch(() => null);
1849
2280
  const handle = async (request, choice) => {
1850
2281
  const session = choice.agent !== null ? sessions.get(choice.agent) ?? null : null;
1851
2282
  const continuable = session !== null && session.turns < SESSION_TURNS_CAP;
1852
- let resolved = resolveCommand(choice, request.prompt, continuable ? session.id : null);
2283
+ const registration = request.mode === "variant" && options.leglasCommand !== void 0 ? registrationCommand(options.leglasCommand) : null;
2284
+ let resolved = resolveCommand(choice, request.prompt, continuable ? session.id : null, registration);
1853
2285
  if (resolved === null)
1854
2286
  return;
1855
2287
  const lines = [];
@@ -1857,7 +2289,7 @@ function startRunner(options) {
1857
2289
  if (!await markPickedUp(options.cwd, request.id))
1858
2290
  return;
1859
2291
  if (stopped) {
1860
- failed.add(request.id);
2292
+ await reportFailure(request, classifyFailure({ agent: resolved.name, error: "stopped by shutdown" }), []);
1861
2293
  return;
1862
2294
  }
1863
2295
  state = {
@@ -1865,25 +2297,47 @@ function startRunner(options) {
1865
2297
  requestId: request.id,
1866
2298
  agent: resolved.name,
1867
2299
  activity: null,
1868
- startedAt: Date.now()
2300
+ startedAt: Date.now(),
2301
+ stopping: false,
2302
+ waiting: null
1869
2303
  };
1870
- const observed = { sessionId: null, edited: false };
2304
+ const observed = {
2305
+ sessionId: null,
2306
+ edited: false,
2307
+ retry: null
2308
+ };
2309
+ const before = request.mode === "variant" ? await registered() : null;
2310
+ const agent = resolved.name;
1871
2311
  let outcome = await runChild(request, resolved, lines, observed);
1872
- const cancelled = !outcome.ok && outcome.error === "cancelled";
1873
- if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && !cancelled && // Not redundant with the line above: a stop that lands between the
1874
- // first child settling and the retry starting finds no child to
1875
- // cancel, so nothing says "cancelled". Stopped still means stopped.
2312
+ const verdict = () => classifyFailure({
2313
+ agent,
2314
+ error: outcome.ok ? null : stopped && outcome.error === "cancelled" ? "stopped by shutdown" : outcome.error,
2315
+ exitCode: outcome.ok ? outcome.code : null,
2316
+ lines,
2317
+ retry: observed.retry
2318
+ });
2319
+ let failure = verdict();
2320
+ 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
2321
+ // child settling and the retry starting finds no child to cancel, so
2322
+ // nothing says "cancelled". Stopped still means stopped.
1876
2323
  !stopped) {
1877
2324
  sessions.delete(resolved.agent);
1878
- const cold = resolveCommand(choice, request.prompt);
2325
+ const cold = resolveCommand(choice, request.prompt, null, registration);
1879
2326
  if (cold !== null) {
1880
2327
  resolved = cold;
1881
2328
  observed.sessionId = null;
1882
- state = { ...state, activity: null };
2329
+ observed.retry = null;
2330
+ state = { ...state, activity: null, waiting: null };
1883
2331
  outcome = await runChild(request, resolved, lines, observed);
2332
+ failure = verdict();
1884
2333
  }
1885
2334
  }
1886
2335
  if (outcome.ok && outcome.code === 0) {
2336
+ if (request.mode === "variant" && await registered() === before) {
2337
+ sessions.delete(resolved.agent);
2338
+ await reportFailure(request, classifyFailure({ agent, error: "not-registered" }), lines);
2339
+ return;
2340
+ }
1887
2341
  if (observed.sessionId !== null) {
1888
2342
  const previous = sessions.get(resolved.agent);
1889
2343
  sessions.set(resolved.agent, {
@@ -1891,12 +2345,14 @@ function startRunner(options) {
1891
2345
  turns: resolved.resumed && previous?.id === observed.sessionId ? previous.turns + 1 : 1
1892
2346
  });
1893
2347
  }
2348
+ if (request.mode === "replace" && request.notes !== void 0) {
2349
+ await removeAnnotations(options.cwd, request.notes).catch(() => 0);
2350
+ }
1894
2351
  await removeRequest(options.cwd, request.id);
1895
2352
  return;
1896
2353
  }
1897
2354
  sessions.delete(resolved.agent);
1898
- failed.add(request.id);
1899
- reportFailure(request, outcome.ok ? `${resolved.command} exited ${outcome.code}` : outcome.error, lines);
2355
+ await reportFailure(request, failure, lines);
1900
2356
  } finally {
1901
2357
  idle();
1902
2358
  }
@@ -1930,12 +2386,23 @@ function startRunner(options) {
1930
2386
  return false;
1931
2387
  if (id !== void 0 && active.requestId !== id)
1932
2388
  return false;
1933
- active.cancelled = true;
1934
- failed.add(active.requestId);
2389
+ const current = active;
2390
+ current.cancelled = true;
2391
+ failed.add(current.requestId);
2392
+ state = { ...state, stopping: true, waiting: null };
1935
2393
  try {
1936
- active.child.kill("SIGTERM");
2394
+ current.child.kill("SIGTERM");
1937
2395
  } catch {
1938
2396
  }
2397
+ setLater(() => {
2398
+ if (active !== current)
2399
+ return;
2400
+ try {
2401
+ current.child.kill("SIGKILL");
2402
+ } catch {
2403
+ }
2404
+ current.abandon();
2405
+ }, CANCEL_GRACE_MS);
1939
2406
  return true;
1940
2407
  };
1941
2408
  const stop = () => {
@@ -1960,12 +2427,12 @@ function startRunner(options) {
1960
2427
  }
1961
2428
 
1962
2429
  // ../server/dist/renames.js
1963
- import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1964
- import { dirname as dirname5, join as join6 } from "path";
2430
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile5 } from "fs/promises";
2431
+ import { dirname as dirname6, join as join8 } from "path";
1965
2432
  var RENAMES_PATH = ".leglas/renames.json";
1966
2433
  async function readRenames(cwd) {
1967
2434
  try {
1968
- const raw = await readFile5(join6(cwd, RENAMES_PATH), "utf8");
2435
+ const raw = await readFile7(join8(cwd, RENAMES_PATH), "utf8");
1969
2436
  const parsed = JSON.parse(raw);
1970
2437
  if (parsed.renames === null || typeof parsed.renames !== "object")
1971
2438
  return {};
@@ -1975,9 +2442,9 @@ async function readRenames(cwd) {
1975
2442
  }
1976
2443
  }
1977
2444
  async function writeRenames(cwd, renames) {
1978
- const path = join6(cwd, RENAMES_PATH);
1979
- await mkdir4(dirname5(path), { recursive: true });
1980
- await writeFile4(path, `${JSON.stringify({ renames }, null, 2)}
2445
+ const path = join8(cwd, RENAMES_PATH);
2446
+ await mkdir5(dirname6(path), { recursive: true });
2447
+ await writeFile5(path, `${JSON.stringify({ renames }, null, 2)}
1981
2448
  `, "utf8");
1982
2449
  }
1983
2450
  function resolveTitle(input, titles, renames) {
@@ -1995,7 +2462,7 @@ function resolveTitle(input, titles, renames) {
1995
2462
  import { createReadStream, existsSync as existsSync2, statSync } from "fs";
1996
2463
  import http2 from "http";
1997
2464
  import net3 from "net";
1998
- import { extname, join as join7, normalize, relative as relative3 } from "path";
2465
+ import { extname, join as join9, normalize, relative as relative3 } from "path";
1999
2466
  var LEGLAS_PREFIX = "/leglas";
2000
2467
  var DEFAULT_PORT = 4100;
2001
2468
  var PORT_ATTEMPTS = 20;
@@ -2068,6 +2535,9 @@ function isTrustedMutation(req) {
2068
2535
  return false;
2069
2536
  }
2070
2537
  }
2538
+ function isEnded(request, failedIds) {
2539
+ return isTerminal(request.status) || failedIds.includes(request.id);
2540
+ }
2071
2541
  function hasJsonBody(req) {
2072
2542
  const contentType = req.headers["content-type"];
2073
2543
  return typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
@@ -2094,7 +2564,7 @@ function probe(target, timeoutMs = 1e3) {
2094
2564
  }
2095
2565
  function serveFrom(res, dir, relativePath) {
2096
2566
  const relative5 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
2097
- const candidate = join7(dir, relative5);
2567
+ const candidate = join9(dir, relative5);
2098
2568
  if (!candidate.startsWith(dir))
2099
2569
  return false;
2100
2570
  if (!existsSync2(candidate) || !statSync(candidate).isFile())
@@ -2179,7 +2649,7 @@ async function bind(server, requested) {
2179
2649
  throw new Error(`No free port between ${requested} and ${requested + PORT_ATTEMPTS - 1}.`);
2180
2650
  }
2181
2651
  async function startServer(options) {
2182
- const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
2652
+ const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), leglasCommand = "npx -y leglas", fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
2183
2653
  const target = config?.devServer ?? "http://localhost:3000";
2184
2654
  const proxy = createProxyHandler({ target });
2185
2655
  const bootConfigSnapshot = snapshotConfig(cwd);
@@ -2219,13 +2689,23 @@ async function startServer(options) {
2219
2689
  const notice = configStalenessNotice(cwd, bootConfigSnapshot, snapshotConfig(cwd));
2220
2690
  if (notice !== null)
2221
2691
  errors.push(notice);
2222
- return void readLocalPreviews(cwd).then(({ previews: local }) => {
2223
- const known = new Set(boot.map((preview) => preview.title));
2692
+ return void readLocalPreviews(cwd).then(({ previews: local, errors: localErrors }) => {
2693
+ if (localErrors.length > 0) {
2694
+ return sendJson(res, 200, {
2695
+ project,
2696
+ devServer: target,
2697
+ previews: boot,
2698
+ errors
2699
+ });
2700
+ }
2701
+ const localTitles = new Set(local.map((preview) => preview.title));
2702
+ const currentBoot = boot.filter((preview) => preview.local !== true || localTitles.has(preview.title));
2703
+ const known = new Set(currentBoot.map((preview) => preview.title));
2224
2704
  const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
2225
2705
  sendJson(res, 200, {
2226
2706
  project,
2227
2707
  devServer: target,
2228
- previews: [...boot, ...fresh],
2708
+ previews: [...currentBoot, ...fresh],
2229
2709
  errors
2230
2710
  });
2231
2711
  }).catch(() => sendJson(res, 200, {
@@ -2235,6 +2715,47 @@ async function startServer(options) {
2235
2715
  errors
2236
2716
  }));
2237
2717
  }
2718
+ if (path === `${LEGLAS_PREFIX}/api/previews/delete` && req.method === "POST") {
2719
+ let body = "";
2720
+ req.on("data", (chunk) => body += chunk);
2721
+ return void req.on("end", async () => {
2722
+ let parsed;
2723
+ try {
2724
+ parsed = JSON.parse(body || "{}");
2725
+ } catch {
2726
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2727
+ }
2728
+ const titles = parsed.titles;
2729
+ if (!Array.isArray(titles) || titles.length === 0 || titles.some((title) => typeof title !== "string" || title.trim() === "")) {
2730
+ return sendJson(res, 400, {
2731
+ ok: false,
2732
+ error: "Body needs a non-empty array of direction titles."
2733
+ });
2734
+ }
2735
+ const unique = [...new Set(titles)];
2736
+ try {
2737
+ const local = await readLocalPreviews(cwd);
2738
+ if (local.errors.length > 0) {
2739
+ return sendJson(res, 409, { ok: false, error: local.errors.join(" ") });
2740
+ }
2741
+ const localTitles = new Set(local.previews.map((preview) => preview.title));
2742
+ const unknown = unique.filter((title) => !localTitles.has(title));
2743
+ if (unknown.length > 0) {
2744
+ return sendJson(res, 400, {
2745
+ ok: false,
2746
+ error: "Only machine-local directions can be deleted from the registry."
2747
+ });
2748
+ }
2749
+ const deleted = await dropLocalPreviews(cwd, unique);
2750
+ return sendJson(res, 200, { ok: true, deleted });
2751
+ } catch {
2752
+ return sendJson(res, 500, {
2753
+ ok: false,
2754
+ error: "The directions could not be deleted from Leglas."
2755
+ });
2756
+ }
2757
+ });
2758
+ }
2238
2759
  if (path === `${LEGLAS_PREFIX}/api/request` && req.method === "POST") {
2239
2760
  let body = "";
2240
2761
  req.on("data", (chunk) => body += chunk);
@@ -2245,16 +2766,51 @@ async function startServer(options) {
2245
2766
  } catch {
2246
2767
  return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2247
2768
  }
2248
- const local = await readLocalPreviews(cwd).then((read) => read.previews, () => []);
2249
- const preview = [...config?.previews ?? [], ...local].find((entry) => entry.title === parsed.title);
2250
- if (!preview || !parsed.intent?.trim()) {
2769
+ if (parsed.mode !== void 0 && parsed.mode !== "variant" && parsed.mode !== "replace") {
2770
+ return sendJson(res, 400, {
2771
+ ok: false,
2772
+ error: 'mode must be "variant" or "replace".'
2773
+ });
2774
+ }
2775
+ const mode = parsed.mode === "replace" ? "replace" : "variant";
2776
+ const localRead = await readLocalPreviews(cwd).catch(() => null);
2777
+ const local = localRead?.errors.length === 0 ? localRead.previews : [];
2778
+ const localTitles = new Set(local.map((entry) => entry.title));
2779
+ const bootConfig = config?.previews ?? [];
2780
+ const boot = localRead === null || localRead.errors.length > 0 ? bootConfig : bootConfig.filter((entry) => entry.local !== true || localTitles.has(entry.title));
2781
+ const preview = [...boot, ...local].find((entry) => entry.title === parsed.title);
2782
+ if (!preview) {
2783
+ return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
2784
+ }
2785
+ const notes = annotationsFor(await readAnnotations(cwd).catch(() => []), preview.title);
2786
+ if (!parsed.intent?.trim() && notes.length === 0) {
2251
2787
  return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
2252
2788
  }
2253
- const composed = composeRequest(preview, parsed.intent);
2789
+ const intent = (parsed.intent ?? "").trim();
2790
+ const live = (await readRequests(cwd).catch(() => [])).filter((entry) => entry.status === "queued" || entry.status === "picked-up");
2791
+ const sameNotes = (entry) => {
2792
+ const before = [...entry.notes ?? []].sort().join(",");
2793
+ return before === notes.map((note) => note.id).sort().join(",");
2794
+ };
2795
+ if (live.some((entry) => entry.title === preview.title && entry.intent === intent && // The same words in the other mode are not the same request:
2796
+ // one forks the direction and the other rewrites it. Only a
2797
+ // genuine repeat is refused.
2798
+ (entry.mode ?? "replace") === mode && sameNotes(entry))) {
2799
+ return sendJson(res, 409, {
2800
+ ok: false,
2801
+ duplicate: true,
2802
+ error: `That exact change to ${preview.title} is already waiting.`
2803
+ });
2804
+ }
2805
+ const composed = composeRequest(preview, intent, mode, notes, leglasCommand);
2254
2806
  void appendRequest(cwd, {
2255
2807
  title: preview.title,
2256
2808
  url: preview.url,
2257
- intent: parsed.intent.trim(),
2809
+ intent,
2810
+ // The ids travel with the request so a change made in place can
2811
+ // forget the notes it answered. A fork leaves them where they are:
2812
+ // the direction they point at was not touched.
2813
+ ...notes.length === 0 ? {} : { notes: notes.map((entry) => entry.id) },
2258
2814
  ...composed
2259
2815
  }).then(() => {
2260
2816
  runner?.nudge();
@@ -2330,21 +2886,33 @@ async function startServer(options) {
2330
2886
  agent: null,
2331
2887
  activity: null,
2332
2888
  startedAt: null,
2889
+ stopping: false,
2890
+ waiting: null,
2333
2891
  failedIds: []
2334
2892
  };
2335
2893
  return void readRequests(cwd).then((requests) => sendJson(res, 200, {
2336
- requests: requests.map(({ id, title, intent, status }) => ({
2894
+ requests: requests.map(({ id, title, intent, status, failure }) => ({
2337
2895
  id,
2338
2896
  title,
2339
2897
  intent,
2340
- status: snapshot.running && snapshot.requestId === id ? "running" : snapshot.failedIds.includes(id) ? "failed" : status
2898
+ // The run in flight is the one thing the file cannot know. After
2899
+ // that the file is the record, including across a restart, and the
2900
+ // process-local failed set only covers a request whose verdict
2901
+ // could not be written.
2902
+ status: snapshot.running && snapshot.requestId === id ? "running" : status === "queued" && snapshot.failedIds.includes(id) ? "failed" : status,
2903
+ failure: failure ?? null
2341
2904
  })),
2342
2905
  agent: {
2343
2906
  attached: externallyAttached(),
2344
2907
  running: snapshot.running,
2345
2908
  name: snapshot.running ? snapshot.agent : null,
2346
2909
  activity: snapshot.running ? snapshot.activity : null,
2347
- startedAt: snapshot.running ? snapshot.startedAt : null
2910
+ startedAt: snapshot.running ? snapshot.startedAt : null,
2911
+ // A stop that has been asked for but not yet obeyed. The card
2912
+ // says so rather than going on describing a live run.
2913
+ stopping: snapshot.running && snapshot.stopping,
2914
+ // Why a run that looks stalled is stalled, while it is stalled.
2915
+ waiting: snapshot.running ? snapshot.waiting : null
2348
2916
  }
2349
2917
  }));
2350
2918
  }
@@ -2387,8 +2955,8 @@ async function startServer(options) {
2387
2955
  if (request === void 0) {
2388
2956
  return sendJson(res, 404, { ok: false, error: "No such request." });
2389
2957
  }
2390
- if (!(runner?.snapshot().failedIds.includes(request.id) ?? false)) {
2391
- return sendJson(res, 400, { ok: false, error: "Only a failed request can be retried." });
2958
+ if (!isEnded(request, runner?.snapshot().failedIds ?? [])) {
2959
+ return sendJson(res, 400, { ok: false, error: "Only an ended request can be run again." });
2392
2960
  }
2393
2961
  try {
2394
2962
  if (!await removeRequest(cwd, request.id)) {
@@ -2399,7 +2967,11 @@ async function startServer(options) {
2399
2967
  url: request.url,
2400
2968
  intent: request.intent,
2401
2969
  target: request.target,
2402
- prompt: request.prompt
2970
+ prompt: request.prompt,
2971
+ // The stored prompt already carries the mode's instructions; the
2972
+ // field travels with it so the queue keeps saying which kind of
2973
+ // change this is.
2974
+ ...request.mode === void 0 ? {} : { mode: request.mode }
2403
2975
  });
2404
2976
  runner?.nudge();
2405
2977
  return sendJson(res, 200, { ok: true });
@@ -2408,6 +2980,65 @@ async function startServer(options) {
2408
2980
  }
2409
2981
  });
2410
2982
  }
2983
+ if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "GET") {
2984
+ return void readAnnotations(cwd).then((annotations) => sendJson(res, 200, { annotations }));
2985
+ }
2986
+ if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "POST") {
2987
+ if (!hasJsonBody(req)) {
2988
+ return sendJson(res, 400, { ok: false, error: "A note must be JSON." });
2989
+ }
2990
+ let body = "";
2991
+ req.on("data", (chunk) => body += chunk);
2992
+ return void req.on("end", async () => {
2993
+ let parsed;
2994
+ try {
2995
+ parsed = JSON.parse(body || "{}");
2996
+ } catch {
2997
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2998
+ }
2999
+ if (typeof parsed.title !== "string" || parsed.title.trim() === "") {
3000
+ return sendJson(res, 400, { ok: false, error: "A note needs a direction." });
3001
+ }
3002
+ const anchor = anchorFrom(parsed.anchor);
3003
+ if (anchor === null) {
3004
+ return sendJson(res, 400, { ok: false, error: "A note needs something to point at." });
3005
+ }
3006
+ try {
3007
+ const annotation = await addAnnotation(cwd, {
3008
+ anchor,
3009
+ note: typeof parsed.note === "string" ? parsed.note.trim() : "",
3010
+ title: parsed.title
3011
+ });
3012
+ return sendJson(res, 200, { ok: true, annotation });
3013
+ } catch {
3014
+ return sendJson(res, 500, { ok: false, error: "The note could not be kept." });
3015
+ }
3016
+ });
3017
+ }
3018
+ if (path === `${LEGLAS_PREFIX}/api/annotations/delete` && req.method === "POST") {
3019
+ if (!hasJsonBody(req)) {
3020
+ return sendJson(res, 400, { ok: false, error: "Delete must be JSON." });
3021
+ }
3022
+ let body = "";
3023
+ req.on("data", (chunk) => body += chunk);
3024
+ return void req.on("end", async () => {
3025
+ let parsed;
3026
+ try {
3027
+ parsed = JSON.parse(body || "{}");
3028
+ } catch {
3029
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
3030
+ }
3031
+ const ids = Array.isArray(parsed.ids) ? parsed.ids.filter((entry) => typeof entry === "string") : [];
3032
+ if (ids.length === 0) {
3033
+ return sendJson(res, 400, { ok: false, error: "Body needs the notes to forget." });
3034
+ }
3035
+ try {
3036
+ return sendJson(res, 200, { ok: true, deleted: await removeAnnotations(cwd, ids) });
3037
+ } catch {
3038
+ return sendJson(res, 500, { ok: false, error: "The notes could not be forgotten." });
3039
+ }
3040
+ });
3041
+ }
2411
3042
  if (path === `${LEGLAS_PREFIX}/api/requests/dismiss` && req.method === "POST") {
2412
3043
  if (!hasJsonBody(req)) {
2413
3044
  return sendJson(res, 400, { ok: false, error: "Dismiss must be JSON." });
@@ -2424,8 +3055,9 @@ async function startServer(options) {
2424
3055
  if (typeof parsed.id !== "string") {
2425
3056
  return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
2426
3057
  }
2427
- if (!(runner?.snapshot().failedIds.includes(parsed.id) ?? false)) {
2428
- return sendJson(res, 400, { ok: false, error: "Only a failed request can be dismissed." });
3058
+ const target2 = (await readRequests(cwd)).find((entry) => entry.id === parsed.id);
3059
+ if (target2 === void 0 || !isEnded(target2, runner?.snapshot().failedIds ?? [])) {
3060
+ return sendJson(res, 400, { ok: false, error: "Only an ended request can be dismissed." });
2429
3061
  }
2430
3062
  try {
2431
3063
  if (!await removeRequest(cwd, parsed.id)) {
@@ -2500,7 +3132,7 @@ async function startServer(options) {
2500
3132
  proxy.upgrade(req, socket, head);
2501
3133
  });
2502
3134
  const port = await bind(server, options.port ?? DEFAULT_PORT);
2503
- runner = startRunner({ cwd, externallyAttached });
3135
+ runner = startRunner({ cwd, externallyAttached, leglasCommand });
2504
3136
  let closePromise = null;
2505
3137
  return {
2506
3138
  port,
@@ -2575,11 +3207,11 @@ function planKeep(options) {
2575
3207
  }
2576
3208
 
2577
3209
  // src/run-init.ts
2578
- import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
2579
- import { join as join8 } from "path";
3210
+ import { readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
3211
+ import { join as join10 } from "path";
2580
3212
  async function readIfPresent(path) {
2581
3213
  try {
2582
- return await readFile6(path, "utf8");
3214
+ return await readFile8(path, "utf8");
2583
3215
  } catch {
2584
3216
  return null;
2585
3217
  }
@@ -2587,18 +3219,18 @@ async function readIfPresent(path) {
2587
3219
  async function runInit(options, deps) {
2588
3220
  const existingConfig = findConfigFile(options.cwd);
2589
3221
  const plan = planInit({
2590
- agents: await readIfPresent(join8(options.cwd, "AGENTS.md")),
3222
+ agents: await readIfPresent(join10(options.cwd, "AGENTS.md")),
2591
3223
  config: existingConfig === null ? null : "present",
2592
- gitignore: await readIfPresent(join8(options.cwd, ".gitignore")),
3224
+ gitignore: await readIfPresent(join10(options.cwd, ".gitignore")),
2593
3225
  force: options.force
2594
3226
  });
2595
3227
  const touched = [];
2596
- for (const write of plan.writes) {
2597
- await writeFile5(join8(options.cwd, write.path), write.contents, "utf8");
2598
- touched.push(write.path);
3228
+ for (const write2 of plan.writes) {
3229
+ await writeFile6(join10(options.cwd, write2.path), write2.contents, "utf8");
3230
+ touched.push(write2.path);
2599
3231
  }
2600
3232
  if (plan.gitignore !== null) {
2601
- await writeFile5(join8(options.cwd, ".gitignore"), plan.gitignore, "utf8");
3233
+ await writeFile6(join10(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2602
3234
  touched.push(".gitignore");
2603
3235
  }
2604
3236
  if (options.json) {
@@ -2618,8 +3250,8 @@ async function runInit(options, deps) {
2618
3250
 
2619
3251
  // src/run-keep.ts
2620
3252
  import { existsSync as existsSync3 } from "fs";
2621
- import { mkdir as mkdir5, readFile as readFile7, rm as rm2, writeFile as writeFile6 } from "fs/promises";
2622
- import { dirname as dirname6, join as join9 } from "path";
3253
+ import { mkdir as mkdir6, readFile as readFile9, rm as rm2, writeFile as writeFile7 } from "fs/promises";
3254
+ import { dirname as dirname7, join as join11 } from "path";
2623
3255
 
2624
3256
  // src/resolve-title.ts
2625
3257
  function resolveOrExplain(input, titles, renames) {
@@ -2663,18 +3295,18 @@ async function runKeep(options, deps) {
2663
3295
  if (!resolved.ok) return fail(resolved.error);
2664
3296
  const plan = planKeep({ title: resolved.title, previews, to: options.to });
2665
3297
  if (!plan.ok) return fail(plan.error);
2666
- const from = join9(options.cwd, plan.move.from);
2667
- const to = join9(options.cwd, plan.move.to);
3298
+ const from = join11(options.cwd, plan.move.from);
3299
+ const to = join11(options.cwd, plan.move.to);
2668
3300
  if (!existsSync3(from)) {
2669
3301
  return fail(`${plan.move.from} does not exist. Nothing to keep.`);
2670
3302
  }
2671
3303
  if (existsSync3(to)) {
2672
3304
  return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
2673
3305
  }
2674
- const source = await readFile7(from, "utf8");
2675
- await mkdir5(dirname6(to), { recursive: true });
2676
- await writeFile6(to, renameExport(source, plan.exportName), "utf8");
2677
- await rm2(join9(options.cwd, plan.removeDir), { recursive: true, force: true });
3306
+ const source = await readFile9(from, "utf8");
3307
+ await mkdir6(dirname7(to), { recursive: true });
3308
+ await writeFile7(to, renameExport(source, plan.exportName), "utf8");
3309
+ await rm2(join11(options.cwd, plan.removeDir), { recursive: true, force: true });
2678
3310
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
2679
3311
  if (options.json) {
2680
3312
  deps.log(
@@ -2709,11 +3341,11 @@ async function runKeep(options, deps) {
2709
3341
 
2710
3342
  // src/run-new.ts
2711
3343
  import { existsSync as existsSync4 } from "fs";
2712
- import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
2713
- import { dirname as dirname7, join as join10 } from "path";
3344
+ import { mkdir as mkdir7, readFile as readFile10, writeFile as writeFile8 } from "fs/promises";
3345
+ import { dirname as dirname8, join as join12 } from "path";
2714
3346
  async function readIfPresent2(path) {
2715
3347
  try {
2716
- return await readFile8(path, "utf8");
3348
+ return await readFile10(path, "utf8");
2717
3349
  } catch {
2718
3350
  return null;
2719
3351
  }
@@ -2721,19 +3353,19 @@ async function readIfPresent2(path) {
2721
3353
  async function runNew(options, deps) {
2722
3354
  let from;
2723
3355
  if (options.from !== void 0) {
2724
- const contents = await readIfPresent2(join10(options.cwd, options.from));
3356
+ const contents = await readIfPresent2(join12(options.cwd, options.from));
2725
3357
  if (contents === null) {
2726
- const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
2727
- if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
2728
- else deps.log(message);
3358
+ const message2 = `${options.from} does not exist, so there is nothing to use as the baseline.`;
3359
+ if (options.json) deps.log(JSON.stringify({ ok: false, error: message2 }));
3360
+ else deps.log(message2);
2729
3361
  return { exitCode: 1, written: [] };
2730
3362
  }
2731
3363
  from = { path: options.from, contents };
2732
3364
  }
2733
3365
  const plan = planNew({
2734
3366
  surface: options.surface,
2735
- packageJson: await readIfPresent2(join10(options.cwd, "package.json")),
2736
- gitignore: await readIfPresent2(join10(options.cwd, ".gitignore")),
3367
+ packageJson: await readIfPresent2(join12(options.cwd, "package.json")),
3368
+ gitignore: await readIfPresent2(join12(options.cwd, ".gitignore")),
2737
3369
  from
2738
3370
  });
2739
3371
  const fail = (error) => {
@@ -2749,26 +3381,26 @@ async function runNew(options, deps) {
2749
3381
  deps.log(JSON.stringify({ ok: true, files: plan.writes, instructions: plan.instructions, previews: plan.previews }));
2750
3382
  return { exitCode: 0, written: [] };
2751
3383
  }
2752
- for (const write of plan.writes) {
2753
- deps.log(`--- ${write.path}`);
2754
- deps.log(write.contents);
3384
+ for (const write2 of plan.writes) {
3385
+ deps.log(`--- ${write2.path}`);
3386
+ deps.log(write2.contents);
2755
3387
  }
2756
3388
  deps.log(plan.instructions);
2757
3389
  return { exitCode: 0, written: [] };
2758
3390
  }
2759
- const existing = plan.writes.filter((write) => existsSync4(join10(options.cwd, write.path)));
3391
+ const existing = plan.writes.filter((write2) => existsSync4(join12(options.cwd, write2.path)));
2760
3392
  if (existing.length > 0) {
2761
3393
  return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
2762
3394
  }
2763
3395
  const written = [];
2764
- for (const write of plan.writes) {
2765
- const target = join10(options.cwd, write.path);
2766
- await mkdir6(dirname7(target), { recursive: true });
2767
- await writeFile7(target, write.contents, "utf8");
2768
- written.push(write.path);
3396
+ for (const write2 of plan.writes) {
3397
+ const target = join12(options.cwd, write2.path);
3398
+ await mkdir7(dirname8(target), { recursive: true });
3399
+ await writeFile8(target, write2.contents, "utf8");
3400
+ written.push(write2.path);
2769
3401
  }
2770
3402
  if (plan.gitignore !== null) {
2771
- await writeFile7(join10(options.cwd, ".gitignore"), plan.gitignore, "utf8");
3403
+ await writeFile8(join12(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2772
3404
  written.push(".gitignore");
2773
3405
  }
2774
3406
  if (options.json) {
@@ -2789,21 +3421,21 @@ async function runNew(options, deps) {
2789
3421
  }
2790
3422
 
2791
3423
  // src/run-previews.ts
2792
- import { readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
2793
- import { join as join11 } from "path";
3424
+ import { readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
3425
+ import { join as join13 } from "path";
2794
3426
  function envelope(deps, ok, body) {
2795
3427
  deps.log(JSON.stringify({ ok, ...body }));
2796
3428
  }
2797
3429
  async function ensureIgnored(cwd) {
2798
- const path = join11(cwd, ".gitignore");
3430
+ const path = join13(cwd, ".gitignore");
2799
3431
  let current = null;
2800
3432
  try {
2801
- current = await readFile9(path, "utf8");
3433
+ current = await readFile11(path, "utf8");
2802
3434
  } catch {
2803
3435
  current = null;
2804
3436
  }
2805
3437
  const next = ignoreEntry(current);
2806
- if (next !== null) await writeFile8(path, next, "utf8");
3438
+ if (next !== null) await writeFile9(path, next, "utf8");
2807
3439
  }
2808
3440
  async function runAdd(options, deps) {
2809
3441
  const loaded = await loadConfig(options.cwd);
@@ -2827,7 +3459,8 @@ async function runAdd(options, deps) {
2827
3459
  tags: options.preview.tags,
2828
3460
  branch: options.preview.branch,
2829
3461
  file: options.preview.file,
2830
- basedOn: options.preview.basedOn
3462
+ basedOn: options.preview.basedOn,
3463
+ askedFor: options.preview.askedFor
2831
3464
  },
2832
3465
  shared
2833
3466
  );
@@ -2887,6 +3520,7 @@ async function runList(options, deps) {
2887
3520
  note: preview.note ?? null,
2888
3521
  tags: preview.tags,
2889
3522
  basedOn: preview.basedOn ?? null,
3523
+ askedFor: preview.askedFor ?? null,
2890
3524
  local: preview.local,
2891
3525
  branch: preview.branch ?? null,
2892
3526
  file: preview.file ?? null
@@ -3049,23 +3683,23 @@ async function runShow(options, deps) {
3049
3683
 
3050
3684
  // src/run-watch.ts
3051
3685
  import { spawn as spawn3 } from "child_process";
3052
- import { mkdir as mkdir7, readFile as readFile10, writeFile as writeFile9 } from "fs/promises";
3053
- import { dirname as dirname8, join as join12 } from "path";
3686
+ import { mkdir as mkdir8, readFile as readFile12, writeFile as writeFile10 } from "fs/promises";
3687
+ import { dirname as dirname9, join as join14 } from "path";
3054
3688
  var POLL_MS2 = 2e3;
3055
3689
  var HEARTBEAT_TIMEOUT_MS = 1e3;
3056
3690
  async function saveTemplate(cwd, run3) {
3057
- const path = join12(cwd, WATCH_PATH);
3691
+ const path = join14(cwd, WATCH_PATH);
3058
3692
  let config = {};
3059
3693
  try {
3060
- const parsed = JSON.parse(await readFile10(path, "utf8"));
3694
+ const parsed = JSON.parse(await readFile12(path, "utf8"));
3061
3695
  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
3062
3696
  config = parsed;
3063
3697
  }
3064
3698
  } catch {
3065
3699
  }
3066
3700
  config.run = run3;
3067
- await mkdir7(dirname8(path), { recursive: true });
3068
- await writeFile9(path, `${JSON.stringify(config, null, 2)}
3701
+ await mkdir8(dirname9(path), { recursive: true });
3702
+ await writeFile10(path, `${JSON.stringify(config, null, 2)}
3069
3703
  `, "utf8");
3070
3704
  }
3071
3705
  function spawnAgent(command, args, cwd) {
@@ -3151,9 +3785,13 @@ async function runWatch(options, deps) {
3151
3785
  return;
3152
3786
  }
3153
3787
  failed.add(request.id);
3154
- deps.error(
3155
- ` failed ${request.title}: ${outcome.ok ? `${command} exited ${outcome.code}` : outcome.error}`
3156
- );
3788
+ const failure = classifyFailure({
3789
+ agent: (shownCommand2.split(/\s+/)[0] ?? command).split("/").pop() ?? command,
3790
+ error: outcome.ok ? null : outcome.error,
3791
+ exitCode: outcome.ok ? outcome.code : null
3792
+ });
3793
+ await markFailed(options.cwd, request.id, failure);
3794
+ deps.error(` failed ${request.title}: ${failure.message}`);
3157
3795
  deps.error(" Left in the queue and not retried.");
3158
3796
  };
3159
3797
  const tick = async () => {
@@ -3199,12 +3837,12 @@ async function runWatch(options, deps) {
3199
3837
 
3200
3838
  // src/run-classify.ts
3201
3839
  import { stat } from "fs/promises";
3202
- import { join as join13 } from "path";
3840
+ import { join as join15 } from "path";
3203
3841
  async function runClassify(options, deps) {
3204
3842
  const declared = await Promise.all(
3205
3843
  options.changes.map(async (change) => ({
3206
3844
  ...change,
3207
- exists: await stat(join13(options.cwd, change.path)).then(
3845
+ exists: await stat(join15(options.cwd, change.path)).then(
3208
3846
  () => true,
3209
3847
  () => false
3210
3848
  )
@@ -3232,18 +3870,28 @@ async function runClassify(options, deps) {
3232
3870
  // src/run.ts
3233
3871
  import { existsSync as existsSync5 } from "fs";
3234
3872
  import { createRequire } from "module";
3235
- import { basename as basename3, dirname as dirname9, join as join14, relative as relative4 } from "path";
3873
+ import { basename as basename3, dirname as dirname10, join as join16, relative as relative4 } from "path";
3236
3874
  import { fileURLToPath } from "url";
3237
3875
  function findShellDir() {
3238
- const bundled = join14(dirname9(fileURLToPath(import.meta.url)), "shell");
3239
- if (existsSync5(join14(bundled, "index.html"))) return bundled;
3876
+ const bundled = join16(dirname10(fileURLToPath(import.meta.url)), "shell");
3877
+ if (existsSync5(join16(bundled, "index.html"))) return bundled;
3240
3878
  try {
3241
3879
  const require2 = createRequire(import.meta.url);
3242
- return dirname9(require2.resolve("@leglas/shell/dist/index.html"));
3880
+ return dirname10(require2.resolve("@leglas/shell/dist/index.html"));
3243
3881
  } catch {
3244
3882
  return null;
3245
3883
  }
3246
3884
  }
3885
+ function shellWord(value) {
3886
+ if (/^[A-Za-z0-9_./:=+\\-]+$/.test(value)) return value;
3887
+ if (process.platform === "win32") return `"${value.replaceAll('"', '""')}"`;
3888
+ return `'${value.replaceAll("'", `'\\''`)}'`;
3889
+ }
3890
+ function embeddedLeglasCommand() {
3891
+ const entry = join16(dirname10(fileURLToPath(import.meta.url)), "bin.js");
3892
+ if (!existsSync5(entry)) return "npx -y leglas";
3893
+ return [process.execPath, entry].map(shellWord).join(" ");
3894
+ }
3247
3895
  async function run2(options, deps) {
3248
3896
  const loaded = await loadConfig(options.cwd);
3249
3897
  const local = await readLocalPreviews(options.cwd);
@@ -3273,7 +3921,7 @@ async function run2(options, deps) {
3273
3921
  const fileMounts = /* @__PURE__ */ new Map();
3274
3922
  for (const preview of merged?.previews ?? []) {
3275
3923
  if (preview.file !== void 0) {
3276
- const absolute = join14(options.cwd, preview.file);
3924
+ const absolute = join16(options.cwd, preview.file);
3277
3925
  if (!existsSync5(absolute)) {
3278
3926
  worktreeErrors.push(
3279
3927
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -3284,7 +3932,7 @@ async function run2(options, deps) {
3284
3932
  for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
3285
3933
  slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
3286
3934
  }
3287
- fileMounts.set(slug, dirname9(absolute));
3935
+ fileMounts.set(slug, dirname10(absolute));
3288
3936
  previews.push({
3289
3937
  ...preview,
3290
3938
  url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
@@ -3325,6 +3973,7 @@ async function run2(options, deps) {
3325
3973
  // directory does. Either way saved layout survives a port change.
3326
3974
  project: loaded.path ?? options.cwd,
3327
3975
  cwd: options.cwd,
3976
+ leglasCommand: embeddedLeglasCommand(),
3328
3977
  ...options.port === void 0 ? {} : { port: options.port }
3329
3978
  });
3330
3979
  const url = `${server.url}${LEGLAS_PREFIX}`;