leglas 0.4.1 → 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 {
@@ -1384,7 +1465,8 @@ async function addLocalPreview(cwd, input, shared) {
1384
1465
  ...input.tags === void 0 ? {} : { tags: input.tags },
1385
1466
  ...input.branch === void 0 ? {} : { branch: input.branch },
1386
1467
  ...input.file === void 0 ? {} : { file: input.file },
1387
- ...input.basedOn === void 0 ? {} : { basedOn: input.basedOn }
1468
+ ...input.basedOn === void 0 ? {} : { basedOn: input.basedOn },
1469
+ ...input.askedFor === void 0 ? {} : { askedFor: input.askedFor }
1388
1470
  };
1389
1471
  const check = normalizeConfig({ previews: [candidate] }, { requireDevCommand: false });
1390
1472
  if (check.config === null) {
@@ -1616,17 +1698,220 @@ async function startAppProcess(options) {
1616
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.`);
1617
1699
  }
1618
1700
 
1619
- // ../server/dist/requests.js
1620
- 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
1621
1769
  import { randomBytes } from "crypto";
1770
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1622
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";
1623
1908
  var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
1624
- function targetFor(url) {
1909
+ function variantSlot(url) {
1625
1910
  if (!url.startsWith("/"))
1626
1911
  return null;
1627
- const query = url.slice(url.indexOf("?") + 1);
1628
1912
  if (!url.includes("?"))
1629
1913
  return null;
1914
+ const query = url.slice(url.indexOf("?") + 1);
1630
1915
  for (const pair of query.split("&")) {
1631
1916
  const [rawKey, rawValue] = pair.split("=");
1632
1917
  if (rawKey === void 0 || rawValue === void 0)
@@ -1637,37 +1922,125 @@ function targetFor(url) {
1637
1922
  const option = decodeURIComponent(rawValue);
1638
1923
  if (!SAFE_SEGMENT.test(surface) || !SAFE_SEGMENT.test(option))
1639
1924
  return null;
1640
- return `.leglas/variants/${surface}/${option}.tsx`;
1925
+ return { surface, option };
1641
1926
  }
1642
1927
  return null;
1643
1928
  }
1644
- 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") {
1645
1934
  const target = preview.file ?? targetFor(preview.url);
1646
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) {
1647
1964
  const where = target === null ? `The direction is titled "${preview.title}" and renders at ${preview.url}. Find what produces it.` : `It lives at ${target}.`;
1648
1965
  const pace = target === null ? `Once found, make the change and finish. ` : `Make the change in that file and finish. `;
1649
- 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.
1650
1989
 
1651
- What to change: ${cleaned}
1990
+ ${source} ${make}
1652
1991
 
1653
- ${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}
1654
1993
 
1655
- 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.`;
1656
- return { prompt, target };
1994
+ Then register it, which is what puts it on the rail:
1995
+
1996
+ ${register}
1997
+
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}`;
1657
2001
  }
1658
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
+ }
1659
2028
  async function readRequests(cwd) {
1660
2029
  try {
1661
- const raw = await readFile4(join5(cwd, REQUESTS_PATH), "utf8");
2030
+ const raw = await readFile5(join6(cwd, REQUESTS_PATH), "utf8");
1662
2031
  const parsed = JSON.parse(raw);
1663
2032
  if (!Array.isArray(parsed.requests))
1664
2033
  return [];
1665
2034
  return parsed.requests.map((request, index) => {
1666
- 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;
1667
2038
  return {
1668
2039
  ...entry,
1669
2040
  id: typeof entry.id === "string" ? entry.id : String(index),
1670
- status: entry.status === "picked-up" ? "picked-up" : "queued"
2041
+ status,
2042
+ mode: entry.mode === "variant" ? "variant" : "replace",
2043
+ ...failure === null ? {} : { failure }
1671
2044
  };
1672
2045
  });
1673
2046
  } catch {
@@ -1675,23 +2048,23 @@ async function readRequests(cwd) {
1675
2048
  }
1676
2049
  }
1677
2050
  async function writeQueue(cwd, requests) {
1678
- const path = join5(cwd, REQUESTS_PATH);
1679
- await mkdir3(dirname4(path), { recursive: true });
1680
- 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)}
1681
2054
  `, "utf8");
1682
2055
  }
1683
2056
  async function appendRequest(cwd, request) {
1684
2057
  await writeQueue(cwd, [
1685
2058
  ...await readRequests(cwd),
1686
- { ...request, id: randomBytes(6).toString("base64url"), status: "queued" }
2059
+ { ...request, id: randomBytes2(6).toString("base64url"), status: "queued" }
1687
2060
  ]);
1688
2061
  }
1689
2062
  async function collectRequests(cwd) {
1690
2063
  const requests = await readRequests(cwd);
1691
- const collected = requests.map((request) => ({ ...request, status: "picked-up" }));
1692
- 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"))
1693
2066
  await writeQueue(cwd, collected);
1694
- return collected;
2067
+ return collected.filter((request) => !isTerminal(request.status));
1695
2068
  }
1696
2069
  async function markPickedUp(cwd, id) {
1697
2070
  const requests = await readRequests(cwd);
@@ -1700,6 +2073,17 @@ async function markPickedUp(cwd, id) {
1700
2073
  await writeQueue(cwd, requests.map((request) => request.id === id ? { ...request, status: "picked-up" } : request));
1701
2074
  return true;
1702
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
+ }
1703
2087
  async function removeRequest(cwd, id) {
1704
2088
  const requests = await readRequests(cwd);
1705
2089
  const remaining = requests.filter((request) => request.id !== id);
@@ -1710,7 +2094,7 @@ async function removeRequest(cwd, id) {
1710
2094
  }
1711
2095
  async function clearRequests(cwd) {
1712
2096
  const requests = await readRequests(cwd);
1713
- const pending = requests.filter((request) => request.status !== "picked-up");
2097
+ const pending = requests.filter((request) => request.status === "queued");
1714
2098
  const cleared = requests.length - pending.length;
1715
2099
  if (cleared > 0)
1716
2100
  await writeQueue(cwd, pending);
@@ -1719,10 +2103,13 @@ async function clearRequests(cwd) {
1719
2103
 
1720
2104
  // ../server/dist/runner.js
1721
2105
  import { spawn as nodeSpawn } from "child_process";
2106
+ import { readFile as readFile6 } from "fs/promises";
2107
+ import { join as join7 } from "path";
1722
2108
  var POLL_MS = 2e3;
1723
2109
  var OUTPUT_LINES = 20;
2110
+ var CANCEL_GRACE_MS = 5e3;
1724
2111
  var SESSION_TURNS_CAP = 8;
1725
- function resolveCommand(choice, prompt, sessionId = null) {
2112
+ function resolveCommand(choice, prompt, sessionId = null, registration = null) {
1726
2113
  if (choice.agent === null)
1727
2114
  return null;
1728
2115
  if (choice.agent === "custom") {
@@ -1734,12 +2121,13 @@ function resolveCommand(choice, prompt, sessionId = null) {
1734
2121
  return { agent: "custom", name: "Custom", ...commandFor(parsed.template, prompt), resumed: false };
1735
2122
  }
1736
2123
  const adapter = KNOWN_AGENTS[choice.agent];
2124
+ const allow = registration !== null && "allowArgs" in adapter ? adapter.allowArgs(registration) : [];
1737
2125
  if (sessionId !== null && "resumeArgs" in adapter) {
1738
2126
  return {
1739
2127
  agent: choice.agent,
1740
2128
  name: adapter.name,
1741
2129
  command: adapter.binary,
1742
- args: adapter.resumeArgs(sessionId, prompt),
2130
+ args: [...adapter.resumeArgs(sessionId, prompt), ...allow],
1743
2131
  resumed: true
1744
2132
  };
1745
2133
  }
@@ -1747,7 +2135,7 @@ function resolveCommand(choice, prompt, sessionId = null) {
1747
2135
  agent: choice.agent,
1748
2136
  name: adapter.name,
1749
2137
  command: adapter.binary,
1750
- args: adapter.args(prompt),
2138
+ args: [...adapter.args(prompt), ...allow],
1751
2139
  resumed: false
1752
2140
  };
1753
2141
  }
@@ -1777,12 +2165,17 @@ function startRunner(options) {
1777
2165
  const setEvery = options.setInterval ?? ((callback, milliseconds) => setInterval(callback, milliseconds));
1778
2166
  const clearEvery = options.clearInterval ?? ((handle2) => clearInterval(handle2));
1779
2167
  const failed = /* @__PURE__ */ new Set();
2168
+ const setLater = options.setTimeout ?? ((callback, milliseconds) => {
2169
+ setTimeout(callback, milliseconds).unref?.();
2170
+ });
1780
2171
  let state = {
1781
2172
  running: false,
1782
2173
  requestId: null,
1783
2174
  agent: null,
1784
2175
  activity: null,
1785
- startedAt: null
2176
+ startedAt: null,
2177
+ stopping: false,
2178
+ waiting: null
1786
2179
  };
1787
2180
  let stopped = false;
1788
2181
  let ticking = null;
@@ -1790,15 +2183,30 @@ function startRunner(options) {
1790
2183
  let active = null;
1791
2184
  const sessions = /* @__PURE__ */ new Map();
1792
2185
  const idle = () => {
1793
- 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
+ };
1794
2195
  };
1795
2196
  const rememberLine = (lines, line) => {
1796
2197
  lines.push(line);
1797
2198
  if (lines.length > OUTPUT_LINES)
1798
2199
  lines.splice(0, lines.length - OUTPUT_LINES);
1799
2200
  };
1800
- const reportFailure = (request, error, lines) => {
1801
- 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}`);
1802
2210
  for (const line of lines)
1803
2211
  console.error(` ${line}`);
1804
2212
  };
@@ -1816,19 +2224,31 @@ function startRunner(options) {
1816
2224
  error: error instanceof Error ? error.message : String(error)
1817
2225
  });
1818
2226
  }
1819
- const current = { child, requestId: request.id, cancelled: false };
2227
+ const current = {
2228
+ child,
2229
+ requestId: request.id,
2230
+ cancelled: false,
2231
+ abandon: () => {
2232
+ }
2233
+ };
1820
2234
  active = current;
1821
2235
  const stdoutFlush = lineReader(child.stdout, (line) => {
1822
2236
  rememberLine(lines, line);
1823
2237
  const sessionId = sessionFrom(resolved.agent, line);
1824
2238
  if (sessionId !== null)
1825
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
+ }
1826
2246
  const activity = activityFrom(resolved.agent, line, options.cwd);
1827
2247
  if (activity !== null) {
1828
2248
  if (activity.startsWith("editing"))
1829
2249
  observed.edited = true;
1830
2250
  if (active === current)
1831
- state = { ...state, activity };
2251
+ state = { ...state, activity, waiting: null };
1832
2252
  }
1833
2253
  });
1834
2254
  const stderrFlush = lineReader(child.stderr, (line) => rememberLine(lines, line));
@@ -1842,6 +2262,7 @@ function startRunner(options) {
1842
2262
  stderrFlush();
1843
2263
  resolve(outcome);
1844
2264
  };
2265
+ current.abandon = () => settle({ ok: false, error: "cancelled" });
1845
2266
  child.once("error", (error) => settle({ ok: false, error: error.message }));
1846
2267
  child.once("close", (code, signal) => {
1847
2268
  if (current.cancelled)
@@ -1855,10 +2276,12 @@ function startRunner(options) {
1855
2276
  active = null;
1856
2277
  });
1857
2278
  };
2279
+ const registered = () => readFile6(join7(options.cwd, LOCAL_PREVIEWS_PATH), "utf8").catch(() => null);
1858
2280
  const handle = async (request, choice) => {
1859
2281
  const session = choice.agent !== null ? sessions.get(choice.agent) ?? null : null;
1860
2282
  const continuable = session !== null && session.turns < SESSION_TURNS_CAP;
1861
- 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);
1862
2285
  if (resolved === null)
1863
2286
  return;
1864
2287
  const lines = [];
@@ -1866,7 +2289,7 @@ function startRunner(options) {
1866
2289
  if (!await markPickedUp(options.cwd, request.id))
1867
2290
  return;
1868
2291
  if (stopped) {
1869
- failed.add(request.id);
2292
+ await reportFailure(request, classifyFailure({ agent: resolved.name, error: "stopped by shutdown" }), []);
1870
2293
  return;
1871
2294
  }
1872
2295
  state = {
@@ -1874,25 +2297,47 @@ function startRunner(options) {
1874
2297
  requestId: request.id,
1875
2298
  agent: resolved.name,
1876
2299
  activity: null,
1877
- startedAt: Date.now()
2300
+ startedAt: Date.now(),
2301
+ stopping: false,
2302
+ waiting: null
2303
+ };
2304
+ const observed = {
2305
+ sessionId: null,
2306
+ edited: false,
2307
+ retry: null
1878
2308
  };
1879
- const observed = { sessionId: null, edited: false };
2309
+ const before = request.mode === "variant" ? await registered() : null;
2310
+ const agent = resolved.name;
1880
2311
  let outcome = await runChild(request, resolved, lines, observed);
1881
- const cancelled = !outcome.ok && outcome.error === "cancelled";
1882
- if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && !cancelled && // Not redundant with the line above: a stop that lands between the
1883
- // first child settling and the retry starting finds no child to
1884
- // 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.
1885
2323
  !stopped) {
1886
2324
  sessions.delete(resolved.agent);
1887
- const cold = resolveCommand(choice, request.prompt);
2325
+ const cold = resolveCommand(choice, request.prompt, null, registration);
1888
2326
  if (cold !== null) {
1889
2327
  resolved = cold;
1890
2328
  observed.sessionId = null;
1891
- state = { ...state, activity: null };
2329
+ observed.retry = null;
2330
+ state = { ...state, activity: null, waiting: null };
1892
2331
  outcome = await runChild(request, resolved, lines, observed);
2332
+ failure = verdict();
1893
2333
  }
1894
2334
  }
1895
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
+ }
1896
2341
  if (observed.sessionId !== null) {
1897
2342
  const previous = sessions.get(resolved.agent);
1898
2343
  sessions.set(resolved.agent, {
@@ -1900,12 +2345,14 @@ function startRunner(options) {
1900
2345
  turns: resolved.resumed && previous?.id === observed.sessionId ? previous.turns + 1 : 1
1901
2346
  });
1902
2347
  }
2348
+ if (request.mode === "replace" && request.notes !== void 0) {
2349
+ await removeAnnotations(options.cwd, request.notes).catch(() => 0);
2350
+ }
1903
2351
  await removeRequest(options.cwd, request.id);
1904
2352
  return;
1905
2353
  }
1906
2354
  sessions.delete(resolved.agent);
1907
- failed.add(request.id);
1908
- reportFailure(request, outcome.ok ? `${resolved.command} exited ${outcome.code}` : outcome.error, lines);
2355
+ await reportFailure(request, failure, lines);
1909
2356
  } finally {
1910
2357
  idle();
1911
2358
  }
@@ -1939,12 +2386,23 @@ function startRunner(options) {
1939
2386
  return false;
1940
2387
  if (id !== void 0 && active.requestId !== id)
1941
2388
  return false;
1942
- active.cancelled = true;
1943
- failed.add(active.requestId);
2389
+ const current = active;
2390
+ current.cancelled = true;
2391
+ failed.add(current.requestId);
2392
+ state = { ...state, stopping: true, waiting: null };
1944
2393
  try {
1945
- active.child.kill("SIGTERM");
2394
+ current.child.kill("SIGTERM");
1946
2395
  } catch {
1947
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);
1948
2406
  return true;
1949
2407
  };
1950
2408
  const stop = () => {
@@ -1969,12 +2427,12 @@ function startRunner(options) {
1969
2427
  }
1970
2428
 
1971
2429
  // ../server/dist/renames.js
1972
- import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1973
- 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";
1974
2432
  var RENAMES_PATH = ".leglas/renames.json";
1975
2433
  async function readRenames(cwd) {
1976
2434
  try {
1977
- const raw = await readFile5(join6(cwd, RENAMES_PATH), "utf8");
2435
+ const raw = await readFile7(join8(cwd, RENAMES_PATH), "utf8");
1978
2436
  const parsed = JSON.parse(raw);
1979
2437
  if (parsed.renames === null || typeof parsed.renames !== "object")
1980
2438
  return {};
@@ -1984,9 +2442,9 @@ async function readRenames(cwd) {
1984
2442
  }
1985
2443
  }
1986
2444
  async function writeRenames(cwd, renames) {
1987
- const path = join6(cwd, RENAMES_PATH);
1988
- await mkdir4(dirname5(path), { recursive: true });
1989
- 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)}
1990
2448
  `, "utf8");
1991
2449
  }
1992
2450
  function resolveTitle(input, titles, renames) {
@@ -2004,7 +2462,7 @@ function resolveTitle(input, titles, renames) {
2004
2462
  import { createReadStream, existsSync as existsSync2, statSync } from "fs";
2005
2463
  import http2 from "http";
2006
2464
  import net3 from "net";
2007
- import { extname, join as join7, normalize, relative as relative3 } from "path";
2465
+ import { extname, join as join9, normalize, relative as relative3 } from "path";
2008
2466
  var LEGLAS_PREFIX = "/leglas";
2009
2467
  var DEFAULT_PORT = 4100;
2010
2468
  var PORT_ATTEMPTS = 20;
@@ -2077,6 +2535,9 @@ function isTrustedMutation(req) {
2077
2535
  return false;
2078
2536
  }
2079
2537
  }
2538
+ function isEnded(request, failedIds) {
2539
+ return isTerminal(request.status) || failedIds.includes(request.id);
2540
+ }
2080
2541
  function hasJsonBody(req) {
2081
2542
  const contentType = req.headers["content-type"];
2082
2543
  return typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
@@ -2103,7 +2564,7 @@ function probe(target, timeoutMs = 1e3) {
2103
2564
  }
2104
2565
  function serveFrom(res, dir, relativePath) {
2105
2566
  const relative5 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
2106
- const candidate = join7(dir, relative5);
2567
+ const candidate = join9(dir, relative5);
2107
2568
  if (!candidate.startsWith(dir))
2108
2569
  return false;
2109
2570
  if (!existsSync2(candidate) || !statSync(candidate).isFile())
@@ -2188,7 +2649,7 @@ async function bind(server, requested) {
2188
2649
  throw new Error(`No free port between ${requested} and ${requested + PORT_ATTEMPTS - 1}.`);
2189
2650
  }
2190
2651
  async function startServer(options) {
2191
- 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;
2192
2653
  const target = config?.devServer ?? "http://localhost:3000";
2193
2654
  const proxy = createProxyHandler({ target });
2194
2655
  const bootConfigSnapshot = snapshotConfig(cwd);
@@ -2305,20 +2766,51 @@ async function startServer(options) {
2305
2766
  } catch {
2306
2767
  return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2307
2768
  }
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";
2308
2776
  const localRead = await readLocalPreviews(cwd).catch(() => null);
2309
2777
  const local = localRead?.errors.length === 0 ? localRead.previews : [];
2310
2778
  const localTitles = new Set(local.map((entry) => entry.title));
2311
2779
  const bootConfig = config?.previews ?? [];
2312
2780
  const boot = localRead === null || localRead.errors.length > 0 ? bootConfig : bootConfig.filter((entry) => entry.local !== true || localTitles.has(entry.title));
2313
2781
  const preview = [...boot, ...local].find((entry) => entry.title === parsed.title);
2314
- if (!preview || !parsed.intent?.trim()) {
2782
+ if (!preview) {
2315
2783
  return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
2316
2784
  }
2317
- const composed = composeRequest(preview, parsed.intent);
2785
+ const notes = annotationsFor(await readAnnotations(cwd).catch(() => []), preview.title);
2786
+ if (!parsed.intent?.trim() && notes.length === 0) {
2787
+ return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
2788
+ }
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);
2318
2806
  void appendRequest(cwd, {
2319
2807
  title: preview.title,
2320
2808
  url: preview.url,
2321
- 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) },
2322
2814
  ...composed
2323
2815
  }).then(() => {
2324
2816
  runner?.nudge();
@@ -2394,21 +2886,33 @@ async function startServer(options) {
2394
2886
  agent: null,
2395
2887
  activity: null,
2396
2888
  startedAt: null,
2889
+ stopping: false,
2890
+ waiting: null,
2397
2891
  failedIds: []
2398
2892
  };
2399
2893
  return void readRequests(cwd).then((requests) => sendJson(res, 200, {
2400
- requests: requests.map(({ id, title, intent, status }) => ({
2894
+ requests: requests.map(({ id, title, intent, status, failure }) => ({
2401
2895
  id,
2402
2896
  title,
2403
2897
  intent,
2404
- 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
2405
2904
  })),
2406
2905
  agent: {
2407
2906
  attached: externallyAttached(),
2408
2907
  running: snapshot.running,
2409
2908
  name: snapshot.running ? snapshot.agent : null,
2410
2909
  activity: snapshot.running ? snapshot.activity : null,
2411
- 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
2412
2916
  }
2413
2917
  }));
2414
2918
  }
@@ -2451,8 +2955,8 @@ async function startServer(options) {
2451
2955
  if (request === void 0) {
2452
2956
  return sendJson(res, 404, { ok: false, error: "No such request." });
2453
2957
  }
2454
- if (!(runner?.snapshot().failedIds.includes(request.id) ?? false)) {
2455
- 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." });
2456
2960
  }
2457
2961
  try {
2458
2962
  if (!await removeRequest(cwd, request.id)) {
@@ -2463,7 +2967,11 @@ async function startServer(options) {
2463
2967
  url: request.url,
2464
2968
  intent: request.intent,
2465
2969
  target: request.target,
2466
- 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 }
2467
2975
  });
2468
2976
  runner?.nudge();
2469
2977
  return sendJson(res, 200, { ok: true });
@@ -2472,6 +2980,65 @@ async function startServer(options) {
2472
2980
  }
2473
2981
  });
2474
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
+ }
2475
3042
  if (path === `${LEGLAS_PREFIX}/api/requests/dismiss` && req.method === "POST") {
2476
3043
  if (!hasJsonBody(req)) {
2477
3044
  return sendJson(res, 400, { ok: false, error: "Dismiss must be JSON." });
@@ -2488,8 +3055,9 @@ async function startServer(options) {
2488
3055
  if (typeof parsed.id !== "string") {
2489
3056
  return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
2490
3057
  }
2491
- if (!(runner?.snapshot().failedIds.includes(parsed.id) ?? false)) {
2492
- 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." });
2493
3061
  }
2494
3062
  try {
2495
3063
  if (!await removeRequest(cwd, parsed.id)) {
@@ -2564,7 +3132,7 @@ async function startServer(options) {
2564
3132
  proxy.upgrade(req, socket, head);
2565
3133
  });
2566
3134
  const port = await bind(server, options.port ?? DEFAULT_PORT);
2567
- runner = startRunner({ cwd, externallyAttached });
3135
+ runner = startRunner({ cwd, externallyAttached, leglasCommand });
2568
3136
  let closePromise = null;
2569
3137
  return {
2570
3138
  port,
@@ -2639,11 +3207,11 @@ function planKeep(options) {
2639
3207
  }
2640
3208
 
2641
3209
  // src/run-init.ts
2642
- import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
2643
- import { join as join8 } from "path";
3210
+ import { readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
3211
+ import { join as join10 } from "path";
2644
3212
  async function readIfPresent(path) {
2645
3213
  try {
2646
- return await readFile6(path, "utf8");
3214
+ return await readFile8(path, "utf8");
2647
3215
  } catch {
2648
3216
  return null;
2649
3217
  }
@@ -2651,18 +3219,18 @@ async function readIfPresent(path) {
2651
3219
  async function runInit(options, deps) {
2652
3220
  const existingConfig = findConfigFile(options.cwd);
2653
3221
  const plan = planInit({
2654
- agents: await readIfPresent(join8(options.cwd, "AGENTS.md")),
3222
+ agents: await readIfPresent(join10(options.cwd, "AGENTS.md")),
2655
3223
  config: existingConfig === null ? null : "present",
2656
- gitignore: await readIfPresent(join8(options.cwd, ".gitignore")),
3224
+ gitignore: await readIfPresent(join10(options.cwd, ".gitignore")),
2657
3225
  force: options.force
2658
3226
  });
2659
3227
  const touched = [];
2660
- for (const write of plan.writes) {
2661
- await writeFile5(join8(options.cwd, write.path), write.contents, "utf8");
2662
- 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);
2663
3231
  }
2664
3232
  if (plan.gitignore !== null) {
2665
- await writeFile5(join8(options.cwd, ".gitignore"), plan.gitignore, "utf8");
3233
+ await writeFile6(join10(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2666
3234
  touched.push(".gitignore");
2667
3235
  }
2668
3236
  if (options.json) {
@@ -2682,8 +3250,8 @@ async function runInit(options, deps) {
2682
3250
 
2683
3251
  // src/run-keep.ts
2684
3252
  import { existsSync as existsSync3 } from "fs";
2685
- import { mkdir as mkdir5, readFile as readFile7, rm as rm2, writeFile as writeFile6 } from "fs/promises";
2686
- 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";
2687
3255
 
2688
3256
  // src/resolve-title.ts
2689
3257
  function resolveOrExplain(input, titles, renames) {
@@ -2727,18 +3295,18 @@ async function runKeep(options, deps) {
2727
3295
  if (!resolved.ok) return fail(resolved.error);
2728
3296
  const plan = planKeep({ title: resolved.title, previews, to: options.to });
2729
3297
  if (!plan.ok) return fail(plan.error);
2730
- const from = join9(options.cwd, plan.move.from);
2731
- 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);
2732
3300
  if (!existsSync3(from)) {
2733
3301
  return fail(`${plan.move.from} does not exist. Nothing to keep.`);
2734
3302
  }
2735
3303
  if (existsSync3(to)) {
2736
3304
  return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
2737
3305
  }
2738
- const source = await readFile7(from, "utf8");
2739
- await mkdir5(dirname6(to), { recursive: true });
2740
- await writeFile6(to, renameExport(source, plan.exportName), "utf8");
2741
- 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 });
2742
3310
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
2743
3311
  if (options.json) {
2744
3312
  deps.log(
@@ -2773,11 +3341,11 @@ async function runKeep(options, deps) {
2773
3341
 
2774
3342
  // src/run-new.ts
2775
3343
  import { existsSync as existsSync4 } from "fs";
2776
- import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
2777
- 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";
2778
3346
  async function readIfPresent2(path) {
2779
3347
  try {
2780
- return await readFile8(path, "utf8");
3348
+ return await readFile10(path, "utf8");
2781
3349
  } catch {
2782
3350
  return null;
2783
3351
  }
@@ -2785,19 +3353,19 @@ async function readIfPresent2(path) {
2785
3353
  async function runNew(options, deps) {
2786
3354
  let from;
2787
3355
  if (options.from !== void 0) {
2788
- const contents = await readIfPresent2(join10(options.cwd, options.from));
3356
+ const contents = await readIfPresent2(join12(options.cwd, options.from));
2789
3357
  if (contents === null) {
2790
- const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
2791
- if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
2792
- 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);
2793
3361
  return { exitCode: 1, written: [] };
2794
3362
  }
2795
3363
  from = { path: options.from, contents };
2796
3364
  }
2797
3365
  const plan = planNew({
2798
3366
  surface: options.surface,
2799
- packageJson: await readIfPresent2(join10(options.cwd, "package.json")),
2800
- gitignore: await readIfPresent2(join10(options.cwd, ".gitignore")),
3367
+ packageJson: await readIfPresent2(join12(options.cwd, "package.json")),
3368
+ gitignore: await readIfPresent2(join12(options.cwd, ".gitignore")),
2801
3369
  from
2802
3370
  });
2803
3371
  const fail = (error) => {
@@ -2813,26 +3381,26 @@ async function runNew(options, deps) {
2813
3381
  deps.log(JSON.stringify({ ok: true, files: plan.writes, instructions: plan.instructions, previews: plan.previews }));
2814
3382
  return { exitCode: 0, written: [] };
2815
3383
  }
2816
- for (const write of plan.writes) {
2817
- deps.log(`--- ${write.path}`);
2818
- deps.log(write.contents);
3384
+ for (const write2 of plan.writes) {
3385
+ deps.log(`--- ${write2.path}`);
3386
+ deps.log(write2.contents);
2819
3387
  }
2820
3388
  deps.log(plan.instructions);
2821
3389
  return { exitCode: 0, written: [] };
2822
3390
  }
2823
- 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)));
2824
3392
  if (existing.length > 0) {
2825
3393
  return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
2826
3394
  }
2827
3395
  const written = [];
2828
- for (const write of plan.writes) {
2829
- const target = join10(options.cwd, write.path);
2830
- await mkdir6(dirname7(target), { recursive: true });
2831
- await writeFile7(target, write.contents, "utf8");
2832
- 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);
2833
3401
  }
2834
3402
  if (plan.gitignore !== null) {
2835
- await writeFile7(join10(options.cwd, ".gitignore"), plan.gitignore, "utf8");
3403
+ await writeFile8(join12(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2836
3404
  written.push(".gitignore");
2837
3405
  }
2838
3406
  if (options.json) {
@@ -2853,21 +3421,21 @@ async function runNew(options, deps) {
2853
3421
  }
2854
3422
 
2855
3423
  // src/run-previews.ts
2856
- import { readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
2857
- import { join as join11 } from "path";
3424
+ import { readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
3425
+ import { join as join13 } from "path";
2858
3426
  function envelope(deps, ok, body) {
2859
3427
  deps.log(JSON.stringify({ ok, ...body }));
2860
3428
  }
2861
3429
  async function ensureIgnored(cwd) {
2862
- const path = join11(cwd, ".gitignore");
3430
+ const path = join13(cwd, ".gitignore");
2863
3431
  let current = null;
2864
3432
  try {
2865
- current = await readFile9(path, "utf8");
3433
+ current = await readFile11(path, "utf8");
2866
3434
  } catch {
2867
3435
  current = null;
2868
3436
  }
2869
3437
  const next = ignoreEntry(current);
2870
- if (next !== null) await writeFile8(path, next, "utf8");
3438
+ if (next !== null) await writeFile9(path, next, "utf8");
2871
3439
  }
2872
3440
  async function runAdd(options, deps) {
2873
3441
  const loaded = await loadConfig(options.cwd);
@@ -2891,7 +3459,8 @@ async function runAdd(options, deps) {
2891
3459
  tags: options.preview.tags,
2892
3460
  branch: options.preview.branch,
2893
3461
  file: options.preview.file,
2894
- basedOn: options.preview.basedOn
3462
+ basedOn: options.preview.basedOn,
3463
+ askedFor: options.preview.askedFor
2895
3464
  },
2896
3465
  shared
2897
3466
  );
@@ -2951,6 +3520,7 @@ async function runList(options, deps) {
2951
3520
  note: preview.note ?? null,
2952
3521
  tags: preview.tags,
2953
3522
  basedOn: preview.basedOn ?? null,
3523
+ askedFor: preview.askedFor ?? null,
2954
3524
  local: preview.local,
2955
3525
  branch: preview.branch ?? null,
2956
3526
  file: preview.file ?? null
@@ -3113,23 +3683,23 @@ async function runShow(options, deps) {
3113
3683
 
3114
3684
  // src/run-watch.ts
3115
3685
  import { spawn as spawn3 } from "child_process";
3116
- import { mkdir as mkdir7, readFile as readFile10, writeFile as writeFile9 } from "fs/promises";
3117
- 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";
3118
3688
  var POLL_MS2 = 2e3;
3119
3689
  var HEARTBEAT_TIMEOUT_MS = 1e3;
3120
3690
  async function saveTemplate(cwd, run3) {
3121
- const path = join12(cwd, WATCH_PATH);
3691
+ const path = join14(cwd, WATCH_PATH);
3122
3692
  let config = {};
3123
3693
  try {
3124
- const parsed = JSON.parse(await readFile10(path, "utf8"));
3694
+ const parsed = JSON.parse(await readFile12(path, "utf8"));
3125
3695
  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
3126
3696
  config = parsed;
3127
3697
  }
3128
3698
  } catch {
3129
3699
  }
3130
3700
  config.run = run3;
3131
- await mkdir7(dirname8(path), { recursive: true });
3132
- await writeFile9(path, `${JSON.stringify(config, null, 2)}
3701
+ await mkdir8(dirname9(path), { recursive: true });
3702
+ await writeFile10(path, `${JSON.stringify(config, null, 2)}
3133
3703
  `, "utf8");
3134
3704
  }
3135
3705
  function spawnAgent(command, args, cwd) {
@@ -3215,9 +3785,13 @@ async function runWatch(options, deps) {
3215
3785
  return;
3216
3786
  }
3217
3787
  failed.add(request.id);
3218
- deps.error(
3219
- ` failed ${request.title}: ${outcome.ok ? `${command} exited ${outcome.code}` : outcome.error}`
3220
- );
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}`);
3221
3795
  deps.error(" Left in the queue and not retried.");
3222
3796
  };
3223
3797
  const tick = async () => {
@@ -3263,12 +3837,12 @@ async function runWatch(options, deps) {
3263
3837
 
3264
3838
  // src/run-classify.ts
3265
3839
  import { stat } from "fs/promises";
3266
- import { join as join13 } from "path";
3840
+ import { join as join15 } from "path";
3267
3841
  async function runClassify(options, deps) {
3268
3842
  const declared = await Promise.all(
3269
3843
  options.changes.map(async (change) => ({
3270
3844
  ...change,
3271
- exists: await stat(join13(options.cwd, change.path)).then(
3845
+ exists: await stat(join15(options.cwd, change.path)).then(
3272
3846
  () => true,
3273
3847
  () => false
3274
3848
  )
@@ -3296,18 +3870,28 @@ async function runClassify(options, deps) {
3296
3870
  // src/run.ts
3297
3871
  import { existsSync as existsSync5 } from "fs";
3298
3872
  import { createRequire } from "module";
3299
- 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";
3300
3874
  import { fileURLToPath } from "url";
3301
3875
  function findShellDir() {
3302
- const bundled = join14(dirname9(fileURLToPath(import.meta.url)), "shell");
3303
- 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;
3304
3878
  try {
3305
3879
  const require2 = createRequire(import.meta.url);
3306
- return dirname9(require2.resolve("@leglas/shell/dist/index.html"));
3880
+ return dirname10(require2.resolve("@leglas/shell/dist/index.html"));
3307
3881
  } catch {
3308
3882
  return null;
3309
3883
  }
3310
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
+ }
3311
3895
  async function run2(options, deps) {
3312
3896
  const loaded = await loadConfig(options.cwd);
3313
3897
  const local = await readLocalPreviews(options.cwd);
@@ -3337,7 +3921,7 @@ async function run2(options, deps) {
3337
3921
  const fileMounts = /* @__PURE__ */ new Map();
3338
3922
  for (const preview of merged?.previews ?? []) {
3339
3923
  if (preview.file !== void 0) {
3340
- const absolute = join14(options.cwd, preview.file);
3924
+ const absolute = join16(options.cwd, preview.file);
3341
3925
  if (!existsSync5(absolute)) {
3342
3926
  worktreeErrors.push(
3343
3927
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -3348,7 +3932,7 @@ async function run2(options, deps) {
3348
3932
  for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
3349
3933
  slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
3350
3934
  }
3351
- fileMounts.set(slug, dirname9(absolute));
3935
+ fileMounts.set(slug, dirname10(absolute));
3352
3936
  previews.push({
3353
3937
  ...preview,
3354
3938
  url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
@@ -3389,6 +3973,7 @@ async function run2(options, deps) {
3389
3973
  // directory does. Either way saved layout survives a port change.
3390
3974
  project: loaded.path ?? options.cwd,
3391
3975
  cwd: options.cwd,
3976
+ leglasCommand: embeddedLeglasCommand(),
3392
3977
  ...options.port === void 0 ? {} : { port: options.port }
3393
3978
  });
3394
3979
  const url = `${server.url}${LEGLAS_PREFIX}`;