leglas 0.4.1 → 0.6.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"];
@@ -929,29 +952,39 @@ function nextRequest(requests, failed) {
929
952
 
930
953
  // ../server/dist/agents.js
931
954
  import { spawn } from "child_process";
932
- import { constants } from "fs";
955
+ import { constants, readdirSync } from "fs";
933
956
  import { access, mkdir, readFile, writeFile } from "fs/promises";
934
957
  import { delimiter, dirname, isAbsolute, join, relative } from "path";
958
+ var AGENT_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
959
+ var effortFlag = (effort) => effort === null ? [] : ["--effort", effort];
960
+ var codexEffortConfig = (effort) => effort === null ? [] : ["-c", `model_reasoning_effort=${effort}`];
961
+ var CODEX_WORKSPACE_CONFIG = [
962
+ "-c",
963
+ "sandbox_workspace_write.network_access=true"
964
+ ];
935
965
  var KNOWN_AGENTS = {
936
966
  claude: {
937
967
  name: "Claude",
938
968
  binary: "claude",
939
- args: (prompt) => [
969
+ efforts: AGENT_EFFORTS,
970
+ args: (prompt, effort = null) => [
940
971
  "-p",
941
972
  prompt,
942
973
  "--output-format",
943
974
  "stream-json",
944
975
  "--verbose",
945
976
  "--permission-mode",
946
- "acceptEdits"
977
+ "acceptEdits",
978
+ ...effortFlag(effort)
947
979
  ],
948
- terminalArgs: (prompt) => [
980
+ terminalArgs: (prompt, effort = null) => [
949
981
  "-p",
950
982
  prompt,
951
983
  "--permission-mode",
952
- "acceptEdits"
984
+ "acceptEdits",
985
+ ...effortFlag(effort)
953
986
  ],
954
- resumeArgs: (sessionId, prompt) => [
987
+ resumeArgs: (sessionId, prompt, effort = null) => [
955
988
  "-p",
956
989
  "--resume",
957
990
  sessionId,
@@ -960,8 +993,15 @@ var KNOWN_AGENTS = {
960
993
  "stream-json",
961
994
  "--verbose",
962
995
  "--permission-mode",
963
- "acceptEdits"
996
+ "acceptEdits",
997
+ ...effortFlag(effort)
964
998
  ],
999
+ // Non-interactive Claude cannot approve a Bash call: acceptEdits covers
1000
+ // files, so a command the prompt requires is refused every time with
1001
+ // nobody there to say yes. This allows exactly that command and nothing
1002
+ // wider. Codex needs no equivalent, because workspace-write already lets
1003
+ // it run commands.
1004
+ allowArgs: (command) => ["--allowedTools", `Bash(${command} *)`],
965
1005
  // Every stream-json event names its session.
966
1006
  sessionFrom: (event) => typeof event.session_id === "string" && event.session_id !== "" ? event.session_id : null,
967
1007
  authArgs: ["auth", "status"],
@@ -982,15 +1022,45 @@ var KNOWN_AGENTS = {
982
1022
  codex: {
983
1023
  name: "Codex",
984
1024
  binary: "codex",
985
- args: (prompt) => ["exec", "--json", "-s", "workspace-write", prompt],
986
- terminalArgs: (prompt) => ["exec", "-s", "workspace-write", prompt],
1025
+ efforts: AGENT_EFFORTS,
1026
+ // `--skip-git-repo-check` is what lets Codex run at all in a project the
1027
+ // user never put under version control: without it codex-cli refuses
1028
+ // before it reaches a model, with "Not inside a trusted directory and
1029
+ // --skip-git-repo-check was not specified", and every Codex request in a
1030
+ // non-git project fails for a reason nothing in Leglas explained. The flag
1031
+ // moves that precondition and only that: `-s workspace-write` still
1032
+ // confines writes to the project, so the sandbox boundary is unchanged,
1033
+ // and in a git repository the flag does nothing at all.
1034
+ args: (prompt, effort = null) => [
1035
+ "exec",
1036
+ "--json",
1037
+ ...CODEX_WORKSPACE_CONFIG,
1038
+ ...codexEffortConfig(effort),
1039
+ "-s",
1040
+ "workspace-write",
1041
+ "--skip-git-repo-check",
1042
+ prompt
1043
+ ],
1044
+ terminalArgs: (prompt, effort = null) => [
1045
+ "exec",
1046
+ ...CODEX_WORKSPACE_CONFIG,
1047
+ ...codexEffortConfig(effort),
1048
+ "-s",
1049
+ "workspace-write",
1050
+ "--skip-git-repo-check",
1051
+ prompt
1052
+ ],
987
1053
  // 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.
989
- resumeArgs: (sessionId, prompt) => [
1054
+ // session's own sandbox, which the first turn set to workspace-write. The
1055
+ // repository check is per invocation, so resume needs the flag of its own.
1056
+ resumeArgs: (sessionId, prompt, effort = null) => [
990
1057
  "exec",
991
1058
  "resume",
992
1059
  sessionId,
993
1060
  "--json",
1061
+ ...CODEX_WORKSPACE_CONFIG,
1062
+ ...codexEffortConfig(effort),
1063
+ "--skip-git-repo-check",
994
1064
  prompt
995
1065
  ],
996
1066
  sessionFrom: (event) => event.type === "thread.started" && typeof event.thread_id === "string" ? event.thread_id : null,
@@ -1001,8 +1071,14 @@ var KNOWN_AGENTS = {
1001
1071
  cursor: {
1002
1072
  name: "Cursor",
1003
1073
  binary: "cursor-agent",
1004
- args: (prompt) => ["-p", prompt, "--output-format", "stream-json"],
1005
- terminalArgs: (prompt) => ["-p", prompt],
1074
+ efforts: [],
1075
+ args: (prompt, _effort = null) => [
1076
+ "-p",
1077
+ prompt,
1078
+ "--output-format",
1079
+ "stream-json"
1080
+ ],
1081
+ terminalArgs: (prompt, _effort = null) => ["-p", prompt],
1006
1082
  authArgs: ["status"],
1007
1083
  // UNVERIFIED: cursor-agent was not available on the build machine. The
1008
1084
  // reading is deliberately loose, and anything ambiguous stays unknown.
@@ -1016,11 +1092,15 @@ var KNOWN_AGENTS = {
1016
1092
  }
1017
1093
  };
1018
1094
  var PROBE_TIMEOUT_MS = 3e3;
1019
- function execProbe(binary, args) {
1095
+ function execProbe(binary, args, timeoutMs = PROBE_TIMEOUT_MS) {
1020
1096
  return new Promise((resolve) => {
1021
1097
  let child;
1022
1098
  try {
1023
- child = spawn(binary, [...args], { shell: false, stdio: ["ignore", "pipe", "ignore"] });
1099
+ child = spawn(binary, [...args], {
1100
+ env: agentEnvironment(),
1101
+ shell: false,
1102
+ stdio: ["ignore", "pipe", "ignore"]
1103
+ });
1024
1104
  } catch {
1025
1105
  return resolve(null);
1026
1106
  }
@@ -1029,7 +1109,10 @@ function execProbe(binary, args) {
1029
1109
  if (stdout.length < 4096)
1030
1110
  stdout += chunk.toString();
1031
1111
  });
1032
- const deadline = setTimeout(() => child.kill("SIGKILL"), PROBE_TIMEOUT_MS);
1112
+ const deadline = setTimeout(() => {
1113
+ child.kill("SIGKILL");
1114
+ resolve(null);
1115
+ }, timeoutMs);
1033
1116
  child.once("error", () => {
1034
1117
  clearTimeout(deadline);
1035
1118
  resolve(null);
@@ -1040,8 +1123,50 @@ function execProbe(binary, args) {
1040
1123
  });
1041
1124
  });
1042
1125
  }
1126
+ function agentSearchPath(env = process.env, platform = process.platform) {
1127
+ const home = env.HOME ?? env.USERPROFILE ?? "";
1128
+ const npmPrefix = env.NPM_CONFIG_PREFIX;
1129
+ const versionBins = (root, suffix) => {
1130
+ try {
1131
+ return readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(root, entry.name, ...suffix));
1132
+ } catch {
1133
+ return [];
1134
+ }
1135
+ };
1136
+ const candidates = [
1137
+ ...(env.PATH ?? "").split(delimiter),
1138
+ env.PNPM_HOME,
1139
+ env.NVM_BIN,
1140
+ env.BUN_INSTALL === void 0 ? void 0 : join(env.BUN_INSTALL, "bin"),
1141
+ env.CARGO_HOME === void 0 ? void 0 : join(env.CARGO_HOME, "bin"),
1142
+ npmPrefix === void 0 ? void 0 : platform === "win32" ? npmPrefix : join(npmPrefix, "bin"),
1143
+ home === "" ? void 0 : join(home, ".local", "bin"),
1144
+ home === "" ? void 0 : join(home, ".npm-global", "bin"),
1145
+ home === "" ? void 0 : join(home, ".bun", "bin"),
1146
+ home === "" ? void 0 : join(home, ".cargo", "bin"),
1147
+ home === "" ? void 0 : join(home, ".volta", "bin"),
1148
+ home === "" ? void 0 : join(home, ".asdf", "shims"),
1149
+ home === "" ? void 0 : join(home, ".local", "share", "mise", "shims"),
1150
+ home === "" ? void 0 : join(home, ".local", "share", "pnpm"),
1151
+ home === "" ? void 0 : join(home, "Library", "pnpm"),
1152
+ ...home === "" ? [] : versionBins(join(home, ".nvm", "versions", "node"), ["bin"]),
1153
+ ...home === "" ? [] : versionBins(join(home, ".local", "share", "fnm", "node-versions"), [
1154
+ "installation",
1155
+ "bin"
1156
+ ]),
1157
+ platform === "win32" ? env.APPDATA : void 0,
1158
+ platform === "darwin" ? "/opt/homebrew/bin" : void 0,
1159
+ platform === "darwin" ? "/usr/local/bin" : void 0,
1160
+ platform === "darwin" ? "/Applications/Codex.app/Contents/Resources" : void 0,
1161
+ platform === "darwin" ? "/Applications/Codex++.app/Contents/Resources" : void 0
1162
+ ].filter((entry) => typeof entry === "string" && entry !== "");
1163
+ return [...new Set(candidates)].join(delimiter);
1164
+ }
1165
+ function agentEnvironment(env = process.env) {
1166
+ return { ...env, PATH: agentSearchPath(env) };
1167
+ }
1043
1168
  async function pathLookup(binary) {
1044
- const entries = (process.env.PATH ?? "").split(delimiter).filter((entry) => entry !== "");
1169
+ const entries = agentSearchPath().split(delimiter).filter((entry) => entry !== "");
1045
1170
  const extensions = process.platform === "win32" ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((entry) => entry !== "") : [""];
1046
1171
  for (const entry of entries) {
1047
1172
  for (const extension of extensions) {
@@ -1058,14 +1183,22 @@ async function detectAgents(lookup = pathLookup, probe2 = execProbe) {
1058
1183
  const entries = Object.entries(KNOWN_AGENTS);
1059
1184
  return Promise.all(entries.map(async ([id, adapter]) => {
1060
1185
  const available = await lookup(adapter.binary).catch(() => false);
1061
- if (!available)
1062
- return { id, name: adapter.name, available, auth: "unknown" };
1186
+ if (!available) {
1187
+ return {
1188
+ id,
1189
+ name: adapter.name,
1190
+ available,
1191
+ auth: "unknown",
1192
+ efforts: adapter.efforts
1193
+ };
1194
+ }
1063
1195
  const result = await probe2(adapter.binary, adapter.authArgs).catch(() => null);
1064
1196
  return {
1065
1197
  id,
1066
1198
  name: adapter.name,
1067
1199
  available,
1068
- auth: result === null ? "unknown" : adapter.authVerdict(result)
1200
+ auth: result === null ? "unknown" : adapter.authVerdict(result),
1201
+ efforts: adapter.efforts
1069
1202
  };
1070
1203
  }));
1071
1204
  }
@@ -1098,10 +1231,10 @@ function shownCommand(value) {
1098
1231
  function claudeActivity(event, cwd) {
1099
1232
  if (event.type !== "assistant")
1100
1233
  return null;
1101
- const message = record(event.message);
1102
- if (message === null || !Array.isArray(message.content))
1234
+ const message2 = record(event.message);
1235
+ if (message2 === null || !Array.isArray(message2.content))
1103
1236
  return null;
1104
- for (const rawBlock of message.content) {
1237
+ for (const rawBlock of message2.content) {
1105
1238
  const block = record(rawBlock);
1106
1239
  if (block?.type !== "tool_use" || typeof block.name !== "string")
1107
1240
  continue;
@@ -1170,9 +1303,31 @@ function sessionFrom(agent, line) {
1170
1303
  return null;
1171
1304
  return KNOWN_AGENTS[agent].sessionFrom(event);
1172
1305
  }
1306
+ function retryFrom(agent, line) {
1307
+ if (agent !== "claude" && agent !== "cursor")
1308
+ return null;
1309
+ let event;
1310
+ try {
1311
+ event = record(JSON.parse(line));
1312
+ } catch {
1313
+ return null;
1314
+ }
1315
+ if (event === null || event.type !== "system" || event.subtype !== "api_retry")
1316
+ return null;
1317
+ const attempt = typeof event.attempt === "number" ? event.attempt : 1;
1318
+ return {
1319
+ attempt,
1320
+ max: typeof event.max_retries === "number" ? event.max_retries : null,
1321
+ status: typeof event.error_status === "number" ? event.error_status : null,
1322
+ reason: typeof event.error === "string" && event.error !== "" ? event.error.toLowerCase() : null
1323
+ };
1324
+ }
1173
1325
  function isAgentChoice(value) {
1174
1326
  return value === "custom" || typeof value === "string" && Object.hasOwn(KNOWN_AGENTS, value);
1175
1327
  }
1328
+ function isAgentEffort(value) {
1329
+ return typeof value === "string" && AGENT_EFFORTS.includes(value);
1330
+ }
1176
1331
  async function readWatchConfig(cwd) {
1177
1332
  try {
1178
1333
  const parsed = JSON.parse(await readFile(join(cwd, WATCH_PATH), "utf8"));
@@ -1183,14 +1338,28 @@ async function readWatchConfig(cwd) {
1183
1338
  }
1184
1339
  async function readAgentChoice(cwd) {
1185
1340
  const config = await readWatchConfig(cwd);
1341
+ const agent = isAgentChoice(config.agent) ? config.agent : null;
1342
+ const efforts = record(config.efforts);
1186
1343
  return {
1187
- agent: isAgentChoice(config.agent) ? config.agent : null,
1344
+ agent,
1345
+ effort: agent !== null && agent !== "custom" && isAgentEffort(efforts?.[agent]) ? efforts[agent] : null,
1188
1346
  run: typeof config.run === "string" && config.run !== "" ? config.run : null
1189
1347
  };
1190
1348
  }
1191
1349
  async function saveAgentChoice(cwd, choice) {
1192
1350
  const config = await readWatchConfig(cwd);
1193
1351
  config.agent = choice.agent;
1352
+ if (choice.agent !== "custom" && choice.effort !== void 0) {
1353
+ const efforts = record(config.efforts) ?? {};
1354
+ if (choice.effort === null)
1355
+ delete efforts[choice.agent];
1356
+ else
1357
+ efforts[choice.agent] = choice.effort;
1358
+ if (Object.keys(efforts).length === 0)
1359
+ delete config.efforts;
1360
+ else
1361
+ config.efforts = efforts;
1362
+ }
1194
1363
  if (choice.run !== void 0)
1195
1364
  config.run = choice.run;
1196
1365
  const path = join(cwd, WATCH_PATH);
@@ -1313,8 +1482,8 @@ async function loadConfig(cwd) {
1313
1482
  exported = module.default;
1314
1483
  }
1315
1484
  } catch (error) {
1316
- const message = error instanceof Error ? error.message : String(error);
1317
- return { config: null, errors: [`${label} could not be loaded: ${message}`], path };
1485
+ const message2 = error instanceof Error ? error.message : String(error);
1486
+ return { config: null, errors: [`${label} could not be loaded: ${message2}`], path };
1318
1487
  }
1319
1488
  const result = normalizeConfig(exported);
1320
1489
  return {
@@ -1384,7 +1553,8 @@ async function addLocalPreview(cwd, input, shared) {
1384
1553
  ...input.tags === void 0 ? {} : { tags: input.tags },
1385
1554
  ...input.branch === void 0 ? {} : { branch: input.branch },
1386
1555
  ...input.file === void 0 ? {} : { file: input.file },
1387
- ...input.basedOn === void 0 ? {} : { basedOn: input.basedOn }
1556
+ ...input.basedOn === void 0 ? {} : { basedOn: input.basedOn },
1557
+ ...input.askedFor === void 0 ? {} : { askedFor: input.askedFor }
1388
1558
  };
1389
1559
  const check = normalizeConfig({ previews: [candidate] }, { requireDevCommand: false });
1390
1560
  if (check.config === null) {
@@ -1616,17 +1786,220 @@ async function startAppProcess(options) {
1616
1786
  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
1787
  }
1618
1788
 
1619
- // ../server/dist/requests.js
1620
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1789
+ // ../server/dist/failure.js
1790
+ var NEEDS_TRUST = /not inside a trusted directory|--skip-git-repo-check/i;
1791
+ var MISSING_BINARY = /\b(ENOENT|EACCES|ENOTDIR)\b/;
1792
+ 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;
1793
+ var LIMIT = /\b429\b|rate limit|usage limit|quota exceeded|too many requests/i;
1794
+ var OVERLOADED = /\b(?:503|529)\b|overloaded|service unavailable/i;
1795
+ function fromStatus(status, reason) {
1796
+ if (status === 401 || status === 403 || reason === "authentication_failed")
1797
+ return "not-signed-in";
1798
+ if (status === 429 || reason === "rate_limit")
1799
+ return "provider-limit";
1800
+ if (status === 529 || status === 503 || reason === "overloaded")
1801
+ return "provider-overloaded";
1802
+ return null;
1803
+ }
1804
+ function fromLines(lines) {
1805
+ for (const line of [...lines].reverse()) {
1806
+ if (NEEDS_TRUST.test(line))
1807
+ return "needs-trust";
1808
+ if (NOT_SIGNED_IN.test(line))
1809
+ return "not-signed-in";
1810
+ if (LIMIT.test(line))
1811
+ return "provider-limit";
1812
+ if (OVERLOADED.test(line))
1813
+ return "provider-overloaded";
1814
+ }
1815
+ return null;
1816
+ }
1817
+ function attempts(retry) {
1818
+ if (retry === null || retry === void 0)
1819
+ return "";
1820
+ const total = retry.max === null ? retry.attempt : Math.max(retry.attempt, retry.max);
1821
+ return ` It retried ${total} times first.`;
1822
+ }
1823
+ function message(code, input) {
1824
+ const agent = input.agent;
1825
+ switch (code) {
1826
+ case "cancelled":
1827
+ return "You stopped this run.";
1828
+ case "stopped":
1829
+ return "Leglas shut down while this was running.";
1830
+ case "missing-agent":
1831
+ return `${agent} could not be started. Its command is not on this machine's PATH any more.`;
1832
+ case "not-signed-in":
1833
+ return `${agent} is not signed in. Sign in to it in a terminal, then run this again.`;
1834
+ case "provider-overloaded":
1835
+ return `${agent}'s provider was overloaded and gave up.${attempts(input.retry)}`;
1836
+ case "provider-limit":
1837
+ return `${agent} reported a rate or usage limit, so nothing ran.`;
1838
+ case "needs-trust":
1839
+ return `Codex refused this project: it is not a git repository and Codex has no trust on record for it.`;
1840
+ case "not-registered":
1841
+ return `${agent} finished without registering the new direction, so nothing reached the rail. Its last output is in the Leglas terminal.`;
1842
+ case "agent-error":
1843
+ 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.`;
1844
+ }
1845
+ }
1846
+ function classifyFailure(input) {
1847
+ const lines = input.lines ?? [];
1848
+ const error = input.error ?? null;
1849
+ 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";
1850
+ return { code, message: message(code, input) };
1851
+ }
1852
+ function sessionShaped(code) {
1853
+ return code === "agent-error";
1854
+ }
1855
+
1856
+ // ../server/dist/annotations.js
1621
1857
  import { randomBytes } from "crypto";
1858
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1622
1859
  import { dirname as dirname4, join as join5 } from "path";
1860
+ var ANNOTATIONS_PATH = ".leglas/annotations.json";
1861
+ var NOTE_CAP = 500;
1862
+ var SELECTOR_CAP = 300;
1863
+ var TEXT_CAP = 120;
1864
+ var TAG_CAP = 40;
1865
+ var CLASS_CAP = 8;
1866
+ var CLASS_LENGTH_CAP = 60;
1867
+ var COVERS_CAP = 8;
1868
+ function isRecord2(value) {
1869
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1870
+ }
1871
+ function text(value, cap) {
1872
+ return typeof value === "string" ? value.trim().slice(0, cap) : "";
1873
+ }
1874
+ function fraction(value) {
1875
+ if (typeof value !== "number" || !Number.isFinite(value))
1876
+ return 0.5;
1877
+ return Math.min(1, Math.max(0, value));
1878
+ }
1879
+ function size(value) {
1880
+ return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0;
1881
+ }
1882
+ function anchorFrom(value) {
1883
+ if (!isRecord2(value))
1884
+ return null;
1885
+ const selector = text(value["selector"], SELECTOR_CAP);
1886
+ if (selector === "")
1887
+ return null;
1888
+ const rect = isRecord2(value["rect"]) ? value["rect"] : {};
1889
+ 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)) : [];
1890
+ const rawRegion = isRecord2(value["region"]) ? value["region"] : null;
1891
+ const region = rawRegion === null ? null : {
1892
+ height: fraction(rawRegion["height"]),
1893
+ width: fraction(rawRegion["width"]),
1894
+ x: fraction(rawRegion["x"]),
1895
+ y: fraction(rawRegion["y"])
1896
+ };
1897
+ const covers = Array.isArray(value["covers"]) ? value["covers"].filter(isRecord2).slice(0, COVERS_CAP).map((entry) => ({
1898
+ tag: text(entry["tag"], TAG_CAP) || "element",
1899
+ text: text(entry["text"], TEXT_CAP)
1900
+ })) : [];
1901
+ return {
1902
+ classes,
1903
+ ...covers.length === 0 ? {} : { covers },
1904
+ ...region === null ? {} : { region },
1905
+ rect: {
1906
+ height: size(rect["height"]),
1907
+ width: size(rect["width"]),
1908
+ x: size(rect["x"]),
1909
+ y: size(rect["y"])
1910
+ },
1911
+ selector,
1912
+ spot: {
1913
+ x: fraction(isRecord2(value["spot"]) ? value["spot"]["x"] : void 0),
1914
+ y: fraction(isRecord2(value["spot"]) ? value["spot"]["y"] : void 0)
1915
+ },
1916
+ tag: text(value["tag"], TAG_CAP) || "element",
1917
+ text: text(value["text"], TEXT_CAP),
1918
+ viewport: size(value["viewport"])
1919
+ };
1920
+ }
1921
+ async function readAnnotations(cwd) {
1922
+ try {
1923
+ const raw = await readFile4(join5(cwd, ANNOTATIONS_PATH), "utf8");
1924
+ const parsed = JSON.parse(raw);
1925
+ if (!Array.isArray(parsed.annotations))
1926
+ return [];
1927
+ return parsed.annotations.flatMap((entry, index) => {
1928
+ if (!isRecord2(entry))
1929
+ return [];
1930
+ const anchor = anchorFrom(entry["anchor"]);
1931
+ const title = text(entry["title"], TAG_CAP * 4);
1932
+ if (anchor === null || title === "")
1933
+ return [];
1934
+ return [
1935
+ {
1936
+ anchor,
1937
+ id: typeof entry["id"] === "string" ? entry["id"] : String(index),
1938
+ note: text(entry["note"], NOTE_CAP),
1939
+ title
1940
+ }
1941
+ ];
1942
+ });
1943
+ } catch {
1944
+ return [];
1945
+ }
1946
+ }
1947
+ async function write(cwd, annotations) {
1948
+ const path = join5(cwd, ANNOTATIONS_PATH);
1949
+ await mkdir3(dirname4(path), { recursive: true });
1950
+ await writeFile3(path, `${JSON.stringify({ annotations }, null, 2)}
1951
+ `, "utf8");
1952
+ }
1953
+ async function addAnnotation(cwd, input) {
1954
+ const annotation = { ...input, id: randomBytes(6).toString("base64url") };
1955
+ await write(cwd, [...await readAnnotations(cwd), annotation]);
1956
+ return annotation;
1957
+ }
1958
+ async function removeAnnotations(cwd, ids) {
1959
+ const wanted = new Set(ids);
1960
+ const annotations = await readAnnotations(cwd);
1961
+ const remaining = annotations.filter((entry) => !wanted.has(entry.id));
1962
+ const dropped = annotations.length - remaining.length;
1963
+ if (dropped > 0)
1964
+ await write(cwd, remaining);
1965
+ return dropped;
1966
+ }
1967
+ function annotationsFor(annotations, title) {
1968
+ return annotations.filter((entry) => entry.title === title);
1969
+ }
1970
+ function describeAnchor(anchor) {
1971
+ const where = `about ${anchor.rect.width}\xD7${anchor.rect.height} at (${anchor.rect.x}, ${anchor.rect.y}) in a ${anchor.viewport}px-wide viewport`;
1972
+ if (anchor.region !== void 0) {
1973
+ const covered = (anchor.covers ?? []).map((entry) => entry.text === "" ? `<${entry.tag}>` : `<${entry.tag}> \u201C${entry.text}\u201D`).join(", ");
1974
+ const inside = covered === "" ? "" : ` covering ${covered};`;
1975
+ return `an area inside <${anchor.tag}>;${inside} path ${anchor.selector}; ${where}`;
1976
+ }
1977
+ const parts = [`<${anchor.tag}>`];
1978
+ if (anchor.classes.length > 0)
1979
+ parts.push(`class "${anchor.classes.join(" ")}"`);
1980
+ if (anchor.text !== "")
1981
+ parts.push(`reading \u201C${anchor.text}\u201D`);
1982
+ return `${parts.join(", ")}; path ${anchor.selector}; ${where}`;
1983
+ }
1984
+ function describeAnnotations(annotations) {
1985
+ return annotations.map((annotation, index) => {
1986
+ const said = annotation.note === "" ? "Look at this." : annotation.note;
1987
+ return `${index + 1}. ${said}
1988
+ The element: ${describeAnchor(annotation.anchor)}`;
1989
+ }).join("\n\n");
1990
+ }
1991
+
1992
+ // ../server/dist/requests.js
1993
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1994
+ import { randomBytes as randomBytes2 } from "crypto";
1995
+ import { dirname as dirname5, join as join6 } from "path";
1623
1996
  var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
1624
- function targetFor(url) {
1997
+ function variantSlot(url) {
1625
1998
  if (!url.startsWith("/"))
1626
1999
  return null;
1627
- const query = url.slice(url.indexOf("?") + 1);
1628
2000
  if (!url.includes("?"))
1629
2001
  return null;
2002
+ const query = url.slice(url.indexOf("?") + 1);
1630
2003
  for (const pair of query.split("&")) {
1631
2004
  const [rawKey, rawValue] = pair.split("=");
1632
2005
  if (rawKey === void 0 || rawValue === void 0)
@@ -1637,37 +2010,125 @@ function targetFor(url) {
1637
2010
  const option = decodeURIComponent(rawValue);
1638
2011
  if (!SAFE_SEGMENT.test(surface) || !SAFE_SEGMENT.test(option))
1639
2012
  return null;
1640
- return `.leglas/variants/${surface}/${option}.tsx`;
2013
+ return { surface, option };
1641
2014
  }
1642
2015
  return null;
1643
2016
  }
1644
- function composeRequest(preview, intent) {
2017
+ function targetFor(url) {
2018
+ const slot = variantSlot(url);
2019
+ return slot === null ? null : `.leglas/variants/${slot.surface}/${slot.option}.tsx`;
2020
+ }
2021
+ function composeRequest(preview, intent, mode, notes = [], leglasCommand = "npx -y leglas") {
1645
2022
  const target = preview.file ?? targetFor(preview.url);
1646
2023
  const cleaned = intent.trim();
2024
+ const asked = changeBlock(cleaned, notes);
2025
+ const recorded = cleaned === "" ? notes.map((entry) => entry.note).filter((entry) => entry !== "").join("; ") : cleaned;
2026
+ const prompt = mode === "variant" ? variantPrompt(preview, recorded, asked, target, leglasCommand) : replacePrompt(preview, asked, target);
2027
+ return { prompt, target, mode };
2028
+ }
2029
+ 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.`;
2030
+ function changeBlock(cleaned, notes) {
2031
+ if (notes.length === 0)
2032
+ return `What to change: ${cleaned}`;
2033
+ const many = notes.length === 1 ? "a note" : `${notes.length} notes`;
2034
+ const lead = cleaned === "" ? `What to change, left as ${many} on the design itself:` : `What to change: ${cleaned}
2035
+
2036
+ And ${many} left on the design itself:`;
2037
+ return `${lead}
2038
+
2039
+ ${describeAnnotations(notes)}
2040
+
2041
+ ${ANCHORS}`;
2042
+ }
2043
+ 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.
2044
+
2045
+ 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.
2046
+
2047
+ 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.`;
2048
+ function registrationCommand(leglasCommand) {
2049
+ return `${leglasCommand} add`;
2050
+ }
2051
+ function replacePrompt(preview, asked, target) {
1647
2052
  const where = target === null ? `The direction is titled "${preview.title}" and renders at ${preview.url}. Find what produces it.` : `It lives at ${target}.`;
1648
2053
  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}
2054
+ return `In this project, change only the "${preview.title}" design direction. ${where}
2055
+
2056
+ ${asked}
2057
+
2058
+ ${pace}${SCOPE} The direction is already registered, so nothing needs re-registering.`;
2059
+ }
2060
+ function variantPrompt(preview, recorded, asked, target, leglasCommand) {
2061
+ const slot = variantSlot(preview.url);
2062
+ const parent = JSON.stringify(preview.title);
2063
+ const askedFor = JSON.stringify(recorded);
2064
+ const add = registrationCommand(leglasCommand);
2065
+ const source = target === null ? `Find what renders it first.` : `Its source is ${target}.`;
2066
+ const [make, register] = preview.file !== void 0 ? [
2067
+ `Copy that file to a new file beside it and make the change in the copy.`,
2068
+ ` ${add} --title "<name>" --file "<the new file>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
2069
+ ] : slot !== null ? [
2070
+ `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.`,
2071
+ ` ${add} --title "<name>" --url "/?v-${slot.surface}=<key>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
2072
+ ] : [
2073
+ `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.`,
2074
+ ` ${add} --title "<name>" --url "<the URL that shows it>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
2075
+ ];
2076
+ 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
2077
 
1651
- What to change: ${cleaned}
2078
+ ${source} ${make}
1652
2079
 
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.
2080
+ ${asked}
1654
2081
 
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 };
2082
+ Then register it, which is what puts it on the rail:
2083
+
2084
+ ${register}
2085
+
2086
+ 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.
2087
+
2088
+ ${SCOPE}`;
1657
2089
  }
1658
2090
  var REQUESTS_PATH = ".leglas/requests.json";
2091
+ var TERMINAL = ["failed", "cancelled"];
2092
+ function isTerminal(status) {
2093
+ return TERMINAL.includes(status);
2094
+ }
2095
+ var FAILURE_CODES = [
2096
+ "cancelled",
2097
+ "stopped",
2098
+ "missing-agent",
2099
+ "not-signed-in",
2100
+ "provider-overloaded",
2101
+ "provider-limit",
2102
+ "needs-trust",
2103
+ "not-registered",
2104
+ "agent-error"
2105
+ ];
2106
+ function failureOf(value) {
2107
+ if (typeof value !== "object" || value === null)
2108
+ return null;
2109
+ const entry = value;
2110
+ if (typeof entry.message !== "string" || entry.message === "")
2111
+ return null;
2112
+ if (entry.code === void 0 || !FAILURE_CODES.includes(entry.code))
2113
+ return null;
2114
+ return { code: entry.code, message: entry.message };
2115
+ }
1659
2116
  async function readRequests(cwd) {
1660
2117
  try {
1661
- const raw = await readFile4(join5(cwd, REQUESTS_PATH), "utf8");
2118
+ const raw = await readFile5(join6(cwd, REQUESTS_PATH), "utf8");
1662
2119
  const parsed = JSON.parse(raw);
1663
2120
  if (!Array.isArray(parsed.requests))
1664
2121
  return [];
1665
2122
  return parsed.requests.map((request, index) => {
1666
- const entry = request;
2123
+ const { failure: rawFailure, ...entry } = request;
2124
+ const status = entry.status === "picked-up" || entry.status === "failed" || entry.status === "cancelled" ? entry.status : "queued";
2125
+ const failure = isTerminal(status) ? failureOf(rawFailure) : null;
1667
2126
  return {
1668
2127
  ...entry,
1669
2128
  id: typeof entry.id === "string" ? entry.id : String(index),
1670
- status: entry.status === "picked-up" ? "picked-up" : "queued"
2129
+ status,
2130
+ mode: entry.mode === "variant" ? "variant" : "replace",
2131
+ ...failure === null ? {} : { failure }
1671
2132
  };
1672
2133
  });
1673
2134
  } catch {
@@ -1675,23 +2136,23 @@ async function readRequests(cwd) {
1675
2136
  }
1676
2137
  }
1677
2138
  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)}
2139
+ const path = join6(cwd, REQUESTS_PATH);
2140
+ await mkdir4(dirname5(path), { recursive: true });
2141
+ await writeFile4(path, `${JSON.stringify({ requests }, null, 2)}
1681
2142
  `, "utf8");
1682
2143
  }
1683
2144
  async function appendRequest(cwd, request) {
1684
2145
  await writeQueue(cwd, [
1685
2146
  ...await readRequests(cwd),
1686
- { ...request, id: randomBytes(6).toString("base64url"), status: "queued" }
2147
+ { ...request, id: randomBytes2(6).toString("base64url"), status: "queued" }
1687
2148
  ]);
1688
2149
  }
1689
2150
  async function collectRequests(cwd) {
1690
2151
  const requests = await readRequests(cwd);
1691
- const collected = requests.map((request) => ({ ...request, status: "picked-up" }));
1692
- if (requests.some((request) => request.status !== "picked-up"))
2152
+ const collected = requests.map((request) => isTerminal(request.status) ? request : { ...request, status: "picked-up" });
2153
+ if (requests.some((request) => request.status === "queued"))
1693
2154
  await writeQueue(cwd, collected);
1694
- return collected;
2155
+ return collected.filter((request) => !isTerminal(request.status));
1695
2156
  }
1696
2157
  async function markPickedUp(cwd, id) {
1697
2158
  const requests = await readRequests(cwd);
@@ -1700,6 +2161,17 @@ async function markPickedUp(cwd, id) {
1700
2161
  await writeQueue(cwd, requests.map((request) => request.id === id ? { ...request, status: "picked-up" } : request));
1701
2162
  return true;
1702
2163
  }
2164
+ async function markFailed(cwd, id, failure) {
2165
+ const requests = await readRequests(cwd);
2166
+ if (!requests.some((request) => request.id === id))
2167
+ return false;
2168
+ await writeQueue(cwd, requests.map((request) => request.id === id ? {
2169
+ ...request,
2170
+ status: failure.code === "cancelled" ? "cancelled" : "failed",
2171
+ failure
2172
+ } : request));
2173
+ return true;
2174
+ }
1703
2175
  async function removeRequest(cwd, id) {
1704
2176
  const requests = await readRequests(cwd);
1705
2177
  const remaining = requests.filter((request) => request.id !== id);
@@ -1710,7 +2182,7 @@ async function removeRequest(cwd, id) {
1710
2182
  }
1711
2183
  async function clearRequests(cwd) {
1712
2184
  const requests = await readRequests(cwd);
1713
- const pending = requests.filter((request) => request.status !== "picked-up");
2185
+ const pending = requests.filter((request) => request.status === "queued");
1714
2186
  const cleared = requests.length - pending.length;
1715
2187
  if (cleared > 0)
1716
2188
  await writeQueue(cwd, pending);
@@ -1719,10 +2191,13 @@ async function clearRequests(cwd) {
1719
2191
 
1720
2192
  // ../server/dist/runner.js
1721
2193
  import { spawn as nodeSpawn } from "child_process";
2194
+ import { readFile as readFile6 } from "fs/promises";
2195
+ import { join as join7 } from "path";
1722
2196
  var POLL_MS = 2e3;
1723
2197
  var OUTPUT_LINES = 20;
2198
+ var CANCEL_GRACE_MS = 5e3;
1724
2199
  var SESSION_TURNS_CAP = 8;
1725
- function resolveCommand(choice, prompt, sessionId = null) {
2200
+ function resolveCommand(choice, prompt, sessionId = null, registration = null) {
1726
2201
  if (choice.agent === null)
1727
2202
  return null;
1728
2203
  if (choice.agent === "custom") {
@@ -1734,12 +2209,13 @@ function resolveCommand(choice, prompt, sessionId = null) {
1734
2209
  return { agent: "custom", name: "Custom", ...commandFor(parsed.template, prompt), resumed: false };
1735
2210
  }
1736
2211
  const adapter = KNOWN_AGENTS[choice.agent];
2212
+ const allow = registration !== null && "allowArgs" in adapter ? adapter.allowArgs(registration) : [];
1737
2213
  if (sessionId !== null && "resumeArgs" in adapter) {
1738
2214
  return {
1739
2215
  agent: choice.agent,
1740
2216
  name: adapter.name,
1741
2217
  command: adapter.binary,
1742
- args: adapter.resumeArgs(sessionId, prompt),
2218
+ args: [...adapter.resumeArgs(sessionId, prompt, choice.effort), ...allow],
1743
2219
  resumed: true
1744
2220
  };
1745
2221
  }
@@ -1747,7 +2223,7 @@ function resolveCommand(choice, prompt, sessionId = null) {
1747
2223
  agent: choice.agent,
1748
2224
  name: adapter.name,
1749
2225
  command: adapter.binary,
1750
- args: adapter.args(prompt),
2226
+ args: [...adapter.args(prompt, choice.effort), ...allow],
1751
2227
  resumed: false
1752
2228
  };
1753
2229
  }
@@ -1770,19 +2246,24 @@ function lineReader(stream, onLine) {
1770
2246
  return flush;
1771
2247
  }
1772
2248
  function defaultSpawn(command, args, options) {
1773
- return nodeSpawn(command, args, options);
2249
+ return nodeSpawn(command, args, { ...options, env: agentEnvironment() });
1774
2250
  }
1775
2251
  function startRunner(options) {
1776
2252
  const spawn4 = options.spawn ?? defaultSpawn;
1777
2253
  const setEvery = options.setInterval ?? ((callback, milliseconds) => setInterval(callback, milliseconds));
1778
2254
  const clearEvery = options.clearInterval ?? ((handle2) => clearInterval(handle2));
1779
2255
  const failed = /* @__PURE__ */ new Set();
2256
+ const setLater = options.setTimeout ?? ((callback, milliseconds) => {
2257
+ setTimeout(callback, milliseconds).unref?.();
2258
+ });
1780
2259
  let state = {
1781
2260
  running: false,
1782
2261
  requestId: null,
1783
2262
  agent: null,
1784
2263
  activity: null,
1785
- startedAt: null
2264
+ startedAt: null,
2265
+ stopping: false,
2266
+ waiting: null
1786
2267
  };
1787
2268
  let stopped = false;
1788
2269
  let ticking = null;
@@ -1790,15 +2271,30 @@ function startRunner(options) {
1790
2271
  let active = null;
1791
2272
  const sessions = /* @__PURE__ */ new Map();
1792
2273
  const idle = () => {
1793
- state = { running: false, requestId: null, agent: null, activity: null, startedAt: null };
2274
+ state = {
2275
+ running: false,
2276
+ requestId: null,
2277
+ agent: null,
2278
+ activity: null,
2279
+ startedAt: null,
2280
+ stopping: false,
2281
+ waiting: null
2282
+ };
1794
2283
  };
1795
2284
  const rememberLine = (lines, line) => {
1796
2285
  lines.push(line);
1797
2286
  if (lines.length > OUTPUT_LINES)
1798
2287
  lines.splice(0, lines.length - OUTPUT_LINES);
1799
2288
  };
1800
- const reportFailure = (request, error, lines) => {
1801
- console.error(`Leglas agent failed for ${request.title}: ${error}`);
2289
+ const reportFailure = async (request, failure, lines) => {
2290
+ await markFailed(options.cwd, request.id, failure).catch(() => {
2291
+ });
2292
+ failed.add(request.id);
2293
+ if (failure.code === "cancelled") {
2294
+ console.error(`Leglas stopped the run for ${request.title}.`);
2295
+ return;
2296
+ }
2297
+ console.error(`Leglas agent failed for ${request.title}: ${failure.message}`);
1802
2298
  for (const line of lines)
1803
2299
  console.error(` ${line}`);
1804
2300
  };
@@ -1816,19 +2312,31 @@ function startRunner(options) {
1816
2312
  error: error instanceof Error ? error.message : String(error)
1817
2313
  });
1818
2314
  }
1819
- const current = { child, requestId: request.id, cancelled: false };
2315
+ const current = {
2316
+ child,
2317
+ requestId: request.id,
2318
+ cancelled: false,
2319
+ abandon: () => {
2320
+ }
2321
+ };
1820
2322
  active = current;
1821
2323
  const stdoutFlush = lineReader(child.stdout, (line) => {
1822
2324
  rememberLine(lines, line);
1823
2325
  const sessionId = sessionFrom(resolved.agent, line);
1824
2326
  if (sessionId !== null)
1825
2327
  observed.sessionId = sessionId;
2328
+ const retry = retryFrom(resolved.agent, line);
2329
+ if (retry !== null) {
2330
+ observed.retry = retry;
2331
+ if (active === current)
2332
+ state = { ...state, waiting: retry };
2333
+ }
1826
2334
  const activity = activityFrom(resolved.agent, line, options.cwd);
1827
2335
  if (activity !== null) {
1828
2336
  if (activity.startsWith("editing"))
1829
2337
  observed.edited = true;
1830
2338
  if (active === current)
1831
- state = { ...state, activity };
2339
+ state = { ...state, activity, waiting: null };
1832
2340
  }
1833
2341
  });
1834
2342
  const stderrFlush = lineReader(child.stderr, (line) => rememberLine(lines, line));
@@ -1842,6 +2350,7 @@ function startRunner(options) {
1842
2350
  stderrFlush();
1843
2351
  resolve(outcome);
1844
2352
  };
2353
+ current.abandon = () => settle({ ok: false, error: "cancelled" });
1845
2354
  child.once("error", (error) => settle({ ok: false, error: error.message }));
1846
2355
  child.once("close", (code, signal) => {
1847
2356
  if (current.cancelled)
@@ -1855,10 +2364,12 @@ function startRunner(options) {
1855
2364
  active = null;
1856
2365
  });
1857
2366
  };
2367
+ const registered = () => readFile6(join7(options.cwd, LOCAL_PREVIEWS_PATH), "utf8").catch(() => null);
1858
2368
  const handle = async (request, choice) => {
1859
2369
  const session = choice.agent !== null ? sessions.get(choice.agent) ?? null : null;
1860
2370
  const continuable = session !== null && session.turns < SESSION_TURNS_CAP;
1861
- let resolved = resolveCommand(choice, request.prompt, continuable ? session.id : null);
2371
+ const registration = request.mode === "variant" && options.leglasCommand !== void 0 ? registrationCommand(options.leglasCommand) : null;
2372
+ let resolved = resolveCommand(choice, request.prompt, continuable ? session.id : null, registration);
1862
2373
  if (resolved === null)
1863
2374
  return;
1864
2375
  const lines = [];
@@ -1866,7 +2377,7 @@ function startRunner(options) {
1866
2377
  if (!await markPickedUp(options.cwd, request.id))
1867
2378
  return;
1868
2379
  if (stopped) {
1869
- failed.add(request.id);
2380
+ await reportFailure(request, classifyFailure({ agent: resolved.name, error: "stopped by shutdown" }), []);
1870
2381
  return;
1871
2382
  }
1872
2383
  state = {
@@ -1874,25 +2385,47 @@ function startRunner(options) {
1874
2385
  requestId: request.id,
1875
2386
  agent: resolved.name,
1876
2387
  activity: null,
1877
- startedAt: Date.now()
2388
+ startedAt: Date.now(),
2389
+ stopping: false,
2390
+ waiting: null
2391
+ };
2392
+ const observed = {
2393
+ sessionId: null,
2394
+ edited: false,
2395
+ retry: null
1878
2396
  };
1879
- const observed = { sessionId: null, edited: false };
2397
+ const before = request.mode === "variant" ? await registered() : null;
2398
+ const agent = resolved.name;
1880
2399
  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.
2400
+ const verdict = () => classifyFailure({
2401
+ agent,
2402
+ error: outcome.ok ? null : stopped && outcome.error === "cancelled" ? "stopped by shutdown" : outcome.error,
2403
+ exitCode: outcome.ok ? outcome.code : null,
2404
+ lines,
2405
+ retry: observed.retry
2406
+ });
2407
+ let failure = verdict();
2408
+ 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
2409
+ // child settling and the retry starting finds no child to cancel, so
2410
+ // nothing says "cancelled". Stopped still means stopped.
1885
2411
  !stopped) {
1886
2412
  sessions.delete(resolved.agent);
1887
- const cold = resolveCommand(choice, request.prompt);
2413
+ const cold = resolveCommand(choice, request.prompt, null, registration);
1888
2414
  if (cold !== null) {
1889
2415
  resolved = cold;
1890
2416
  observed.sessionId = null;
1891
- state = { ...state, activity: null };
2417
+ observed.retry = null;
2418
+ state = { ...state, activity: null, waiting: null };
1892
2419
  outcome = await runChild(request, resolved, lines, observed);
2420
+ failure = verdict();
1893
2421
  }
1894
2422
  }
1895
2423
  if (outcome.ok && outcome.code === 0) {
2424
+ if (request.mode === "variant" && await registered() === before) {
2425
+ sessions.delete(resolved.agent);
2426
+ await reportFailure(request, classifyFailure({ agent, error: "not-registered" }), lines);
2427
+ return;
2428
+ }
1896
2429
  if (observed.sessionId !== null) {
1897
2430
  const previous = sessions.get(resolved.agent);
1898
2431
  sessions.set(resolved.agent, {
@@ -1900,12 +2433,14 @@ function startRunner(options) {
1900
2433
  turns: resolved.resumed && previous?.id === observed.sessionId ? previous.turns + 1 : 1
1901
2434
  });
1902
2435
  }
2436
+ if (request.mode === "replace" && request.notes !== void 0) {
2437
+ await removeAnnotations(options.cwd, request.notes).catch(() => 0);
2438
+ }
1903
2439
  await removeRequest(options.cwd, request.id);
1904
2440
  return;
1905
2441
  }
1906
2442
  sessions.delete(resolved.agent);
1907
- failed.add(request.id);
1908
- reportFailure(request, outcome.ok ? `${resolved.command} exited ${outcome.code}` : outcome.error, lines);
2443
+ await reportFailure(request, failure, lines);
1909
2444
  } finally {
1910
2445
  idle();
1911
2446
  }
@@ -1939,12 +2474,23 @@ function startRunner(options) {
1939
2474
  return false;
1940
2475
  if (id !== void 0 && active.requestId !== id)
1941
2476
  return false;
1942
- active.cancelled = true;
1943
- failed.add(active.requestId);
2477
+ const current = active;
2478
+ current.cancelled = true;
2479
+ failed.add(current.requestId);
2480
+ state = { ...state, stopping: true, waiting: null };
1944
2481
  try {
1945
- active.child.kill("SIGTERM");
2482
+ current.child.kill("SIGTERM");
1946
2483
  } catch {
1947
2484
  }
2485
+ setLater(() => {
2486
+ if (active !== current)
2487
+ return;
2488
+ try {
2489
+ current.child.kill("SIGKILL");
2490
+ } catch {
2491
+ }
2492
+ current.abandon();
2493
+ }, CANCEL_GRACE_MS);
1948
2494
  return true;
1949
2495
  };
1950
2496
  const stop = () => {
@@ -1969,12 +2515,12 @@ function startRunner(options) {
1969
2515
  }
1970
2516
 
1971
2517
  // ../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";
2518
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile5 } from "fs/promises";
2519
+ import { dirname as dirname6, join as join8 } from "path";
1974
2520
  var RENAMES_PATH = ".leglas/renames.json";
1975
2521
  async function readRenames(cwd) {
1976
2522
  try {
1977
- const raw = await readFile5(join6(cwd, RENAMES_PATH), "utf8");
2523
+ const raw = await readFile7(join8(cwd, RENAMES_PATH), "utf8");
1978
2524
  const parsed = JSON.parse(raw);
1979
2525
  if (parsed.renames === null || typeof parsed.renames !== "object")
1980
2526
  return {};
@@ -1984,9 +2530,9 @@ async function readRenames(cwd) {
1984
2530
  }
1985
2531
  }
1986
2532
  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)}
2533
+ const path = join8(cwd, RENAMES_PATH);
2534
+ await mkdir5(dirname6(path), { recursive: true });
2535
+ await writeFile5(path, `${JSON.stringify({ renames }, null, 2)}
1990
2536
  `, "utf8");
1991
2537
  }
1992
2538
  function resolveTitle(input, titles, renames) {
@@ -2004,7 +2550,7 @@ function resolveTitle(input, titles, renames) {
2004
2550
  import { createReadStream, existsSync as existsSync2, statSync } from "fs";
2005
2551
  import http2 from "http";
2006
2552
  import net3 from "net";
2007
- import { extname, join as join7, normalize, relative as relative3 } from "path";
2553
+ import { extname, join as join9, normalize, relative as relative3 } from "path";
2008
2554
  var LEGLAS_PREFIX = "/leglas";
2009
2555
  var DEFAULT_PORT = 4100;
2010
2556
  var PORT_ATTEMPTS = 20;
@@ -2077,6 +2623,9 @@ function isTrustedMutation(req) {
2077
2623
  return false;
2078
2624
  }
2079
2625
  }
2626
+ function isEnded(request, failedIds) {
2627
+ return isTerminal(request.status) || failedIds.includes(request.id);
2628
+ }
2080
2629
  function hasJsonBody(req) {
2081
2630
  const contentType = req.headers["content-type"];
2082
2631
  return typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
@@ -2103,7 +2652,7 @@ function probe(target, timeoutMs = 1e3) {
2103
2652
  }
2104
2653
  function serveFrom(res, dir, relativePath) {
2105
2654
  const relative5 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
2106
- const candidate = join7(dir, relative5);
2655
+ const candidate = join9(dir, relative5);
2107
2656
  if (!candidate.startsWith(dir))
2108
2657
  return false;
2109
2658
  if (!existsSync2(candidate) || !statSync(candidate).isFile())
@@ -2188,7 +2737,7 @@ async function bind(server, requested) {
2188
2737
  throw new Error(`No free port between ${requested} and ${requested + PORT_ATTEMPTS - 1}.`);
2189
2738
  }
2190
2739
  async function startServer(options) {
2191
- const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
2740
+ const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), leglasCommand = "npx -y leglas", fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
2192
2741
  const target = config?.devServer ?? "http://localhost:3000";
2193
2742
  const proxy = createProxyHandler({ target });
2194
2743
  const bootConfigSnapshot = snapshotConfig(cwd);
@@ -2207,18 +2756,16 @@ async function startServer(options) {
2207
2756
  });
2208
2757
  return agentsInflight;
2209
2758
  };
2210
- const currentAgents = () => {
2211
- if (agentsCache === null)
2759
+ const currentAgents = (refresh = false) => {
2760
+ if (refresh || agentsCache === null || Date.now() - agentsCache.at > AGENTS_FRESH_MS) {
2212
2761
  return probeAgents();
2213
- if (Date.now() - agentsCache.at > AGENTS_FRESH_MS) {
2214
- void probeAgents().catch(() => {
2215
- });
2216
2762
  }
2217
2763
  return Promise.resolve(agentsCache.agents);
2218
2764
  };
2219
2765
  const server = http2.createServer((req, res) => {
2220
2766
  const url = req.url ?? "/";
2221
2767
  const path = url.split("?")[0] ?? "/";
2768
+ const query = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
2222
2769
  if (req.method === "POST" && path.startsWith(`${LEGLAS_PREFIX}/api/`) && !isTrustedMutation(req)) {
2223
2770
  return sendJson(res, 403, { ok: false, error: "Cross-origin API mutations are refused." });
2224
2771
  }
@@ -2305,20 +2852,51 @@ async function startServer(options) {
2305
2852
  } catch {
2306
2853
  return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2307
2854
  }
2855
+ if (parsed.mode !== void 0 && parsed.mode !== "variant" && parsed.mode !== "replace") {
2856
+ return sendJson(res, 400, {
2857
+ ok: false,
2858
+ error: 'mode must be "variant" or "replace".'
2859
+ });
2860
+ }
2861
+ const mode = parsed.mode === "replace" ? "replace" : "variant";
2308
2862
  const localRead = await readLocalPreviews(cwd).catch(() => null);
2309
2863
  const local = localRead?.errors.length === 0 ? localRead.previews : [];
2310
2864
  const localTitles = new Set(local.map((entry) => entry.title));
2311
2865
  const bootConfig = config?.previews ?? [];
2312
2866
  const boot = localRead === null || localRead.errors.length > 0 ? bootConfig : bootConfig.filter((entry) => entry.local !== true || localTitles.has(entry.title));
2313
2867
  const preview = [...boot, ...local].find((entry) => entry.title === parsed.title);
2314
- if (!preview || !parsed.intent?.trim()) {
2868
+ if (!preview) {
2315
2869
  return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
2316
2870
  }
2317
- const composed = composeRequest(preview, parsed.intent);
2871
+ const notes = annotationsFor(await readAnnotations(cwd).catch(() => []), preview.title);
2872
+ if (!parsed.intent?.trim() && notes.length === 0) {
2873
+ return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
2874
+ }
2875
+ const intent = (parsed.intent ?? "").trim();
2876
+ const live = (await readRequests(cwd).catch(() => [])).filter((entry) => entry.status === "queued" || entry.status === "picked-up");
2877
+ const sameNotes = (entry) => {
2878
+ const before = [...entry.notes ?? []].sort().join(",");
2879
+ return before === notes.map((note) => note.id).sort().join(",");
2880
+ };
2881
+ if (live.some((entry) => entry.title === preview.title && entry.intent === intent && // The same words in the other mode are not the same request:
2882
+ // one forks the direction and the other rewrites it. Only a
2883
+ // genuine repeat is refused.
2884
+ (entry.mode ?? "replace") === mode && sameNotes(entry))) {
2885
+ return sendJson(res, 409, {
2886
+ ok: false,
2887
+ duplicate: true,
2888
+ error: `That exact change to ${preview.title} is already waiting.`
2889
+ });
2890
+ }
2891
+ const composed = composeRequest(preview, intent, mode, notes, leglasCommand);
2318
2892
  void appendRequest(cwd, {
2319
2893
  title: preview.title,
2320
2894
  url: preview.url,
2321
- intent: parsed.intent.trim(),
2895
+ intent,
2896
+ // The ids travel with the request so a change made in place can
2897
+ // forget the notes it answered. A fork leaves them where they are:
2898
+ // the direction they point at was not touched.
2899
+ ...notes.length === 0 ? {} : { notes: notes.map((entry) => entry.id) },
2322
2900
  ...composed
2323
2901
  }).then(() => {
2324
2902
  runner?.nudge();
@@ -2327,10 +2905,14 @@ async function startServer(options) {
2327
2905
  });
2328
2906
  }
2329
2907
  if (path === `${LEGLAS_PREFIX}/api/agents` && req.method === "GET") {
2330
- return void Promise.all([currentAgents(), readAgentChoice(cwd)]).then(([agents, choice]) => sendJson(res, 200, {
2908
+ return void Promise.all([
2909
+ currentAgents(query.get("refresh") === "1"),
2910
+ readAgentChoice(cwd)
2911
+ ]).then(([agents, choice]) => sendJson(res, 200, {
2331
2912
  agents,
2332
2913
  choice: choice.agent,
2333
- customRun: choice.run
2914
+ customRun: choice.run,
2915
+ effort: choice.effort
2334
2916
  }));
2335
2917
  }
2336
2918
  if (path === `${LEGLAS_PREFIX}/api/agent` && req.method === "POST") {
@@ -2358,7 +2940,17 @@ async function startServer(options) {
2358
2940
  if (parsed.run !== void 0 && typeof parsed.run !== "string") {
2359
2941
  return sendJson(res, 400, { ok: false, error: "The custom run command must be a string." });
2360
2942
  }
2943
+ const effort = parsed.effort === null || isAgentEffort(parsed.effort) ? parsed.effort : void 0;
2944
+ if (parsed.effort !== void 0 && effort === void 0) {
2945
+ return sendJson(res, 400, { ok: false, error: "Effort must be a supported level or null." });
2946
+ }
2361
2947
  if (parsed.agent === "custom") {
2948
+ if (effort !== void 0) {
2949
+ return sendJson(res, 400, {
2950
+ ok: false,
2951
+ error: "Custom agents manage effort in their own command."
2952
+ });
2953
+ }
2362
2954
  if (typeof parsed.run !== "string") {
2363
2955
  return sendJson(res, 400, { ok: false, error: "A custom agent needs a run command." });
2364
2956
  }
@@ -2367,7 +2959,16 @@ async function startServer(options) {
2367
2959
  return sendJson(res, 400, { ok: false, error: template.error });
2368
2960
  return void saveAgentChoice(cwd, { agent: "custom", run: parsed.run }).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 500, { ok: false, error: "Agent choice could not be saved." }));
2369
2961
  }
2370
- return void saveAgentChoice(cwd, { agent: parsed.agent }).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 500, { ok: false, error: "Agent choice could not be saved." }));
2962
+ if (effort !== void 0 && effort !== null && !KNOWN_AGENTS[parsed.agent].efforts.includes(effort)) {
2963
+ return sendJson(res, 400, {
2964
+ ok: false,
2965
+ error: `${KNOWN_AGENTS[parsed.agent].name} does not expose an effort override.`
2966
+ });
2967
+ }
2968
+ return void saveAgentChoice(cwd, {
2969
+ agent: parsed.agent,
2970
+ ...effort === void 0 ? {} : { effort }
2971
+ }).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 500, { ok: false, error: "Agent choice could not be saved." }));
2371
2972
  });
2372
2973
  }
2373
2974
  if (path === `${LEGLAS_PREFIX}/api/watch` && req.method === "POST") {
@@ -2394,21 +2995,33 @@ async function startServer(options) {
2394
2995
  agent: null,
2395
2996
  activity: null,
2396
2997
  startedAt: null,
2998
+ stopping: false,
2999
+ waiting: null,
2397
3000
  failedIds: []
2398
3001
  };
2399
3002
  return void readRequests(cwd).then((requests) => sendJson(res, 200, {
2400
- requests: requests.map(({ id, title, intent, status }) => ({
3003
+ requests: requests.map(({ id, title, intent, status, failure }) => ({
2401
3004
  id,
2402
3005
  title,
2403
3006
  intent,
2404
- status: snapshot.running && snapshot.requestId === id ? "running" : snapshot.failedIds.includes(id) ? "failed" : status
3007
+ // The run in flight is the one thing the file cannot know. After
3008
+ // that the file is the record, including across a restart, and the
3009
+ // process-local failed set only covers a request whose verdict
3010
+ // could not be written.
3011
+ status: snapshot.running && snapshot.requestId === id ? "running" : status === "queued" && snapshot.failedIds.includes(id) ? "failed" : status,
3012
+ failure: failure ?? null
2405
3013
  })),
2406
3014
  agent: {
2407
3015
  attached: externallyAttached(),
2408
3016
  running: snapshot.running,
2409
3017
  name: snapshot.running ? snapshot.agent : null,
2410
3018
  activity: snapshot.running ? snapshot.activity : null,
2411
- startedAt: snapshot.running ? snapshot.startedAt : null
3019
+ startedAt: snapshot.running ? snapshot.startedAt : null,
3020
+ // A stop that has been asked for but not yet obeyed. The card
3021
+ // says so rather than going on describing a live run.
3022
+ stopping: snapshot.running && snapshot.stopping,
3023
+ // Why a run that looks stalled is stalled, while it is stalled.
3024
+ waiting: snapshot.running ? snapshot.waiting : null
2412
3025
  }
2413
3026
  }));
2414
3027
  }
@@ -2451,8 +3064,8 @@ async function startServer(options) {
2451
3064
  if (request === void 0) {
2452
3065
  return sendJson(res, 404, { ok: false, error: "No such request." });
2453
3066
  }
2454
- if (!(runner?.snapshot().failedIds.includes(request.id) ?? false)) {
2455
- return sendJson(res, 400, { ok: false, error: "Only a failed request can be retried." });
3067
+ if (!isEnded(request, runner?.snapshot().failedIds ?? [])) {
3068
+ return sendJson(res, 400, { ok: false, error: "Only an ended request can be run again." });
2456
3069
  }
2457
3070
  try {
2458
3071
  if (!await removeRequest(cwd, request.id)) {
@@ -2463,7 +3076,11 @@ async function startServer(options) {
2463
3076
  url: request.url,
2464
3077
  intent: request.intent,
2465
3078
  target: request.target,
2466
- prompt: request.prompt
3079
+ prompt: request.prompt,
3080
+ // The stored prompt already carries the mode's instructions; the
3081
+ // field travels with it so the queue keeps saying which kind of
3082
+ // change this is.
3083
+ ...request.mode === void 0 ? {} : { mode: request.mode }
2467
3084
  });
2468
3085
  runner?.nudge();
2469
3086
  return sendJson(res, 200, { ok: true });
@@ -2472,6 +3089,65 @@ async function startServer(options) {
2472
3089
  }
2473
3090
  });
2474
3091
  }
3092
+ if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "GET") {
3093
+ return void readAnnotations(cwd).then((annotations) => sendJson(res, 200, { annotations }));
3094
+ }
3095
+ if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "POST") {
3096
+ if (!hasJsonBody(req)) {
3097
+ return sendJson(res, 400, { ok: false, error: "A note must be JSON." });
3098
+ }
3099
+ let body = "";
3100
+ req.on("data", (chunk) => body += chunk);
3101
+ return void req.on("end", async () => {
3102
+ let parsed;
3103
+ try {
3104
+ parsed = JSON.parse(body || "{}");
3105
+ } catch {
3106
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
3107
+ }
3108
+ if (typeof parsed.title !== "string" || parsed.title.trim() === "") {
3109
+ return sendJson(res, 400, { ok: false, error: "A note needs a direction." });
3110
+ }
3111
+ const anchor = anchorFrom(parsed.anchor);
3112
+ if (anchor === null) {
3113
+ return sendJson(res, 400, { ok: false, error: "A note needs something to point at." });
3114
+ }
3115
+ try {
3116
+ const annotation = await addAnnotation(cwd, {
3117
+ anchor,
3118
+ note: typeof parsed.note === "string" ? parsed.note.trim() : "",
3119
+ title: parsed.title
3120
+ });
3121
+ return sendJson(res, 200, { ok: true, annotation });
3122
+ } catch {
3123
+ return sendJson(res, 500, { ok: false, error: "The note could not be kept." });
3124
+ }
3125
+ });
3126
+ }
3127
+ if (path === `${LEGLAS_PREFIX}/api/annotations/delete` && req.method === "POST") {
3128
+ if (!hasJsonBody(req)) {
3129
+ return sendJson(res, 400, { ok: false, error: "Delete must be JSON." });
3130
+ }
3131
+ let body = "";
3132
+ req.on("data", (chunk) => body += chunk);
3133
+ return void req.on("end", async () => {
3134
+ let parsed;
3135
+ try {
3136
+ parsed = JSON.parse(body || "{}");
3137
+ } catch {
3138
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
3139
+ }
3140
+ const ids = Array.isArray(parsed.ids) ? parsed.ids.filter((entry) => typeof entry === "string") : [];
3141
+ if (ids.length === 0) {
3142
+ return sendJson(res, 400, { ok: false, error: "Body needs the notes to forget." });
3143
+ }
3144
+ try {
3145
+ return sendJson(res, 200, { ok: true, deleted: await removeAnnotations(cwd, ids) });
3146
+ } catch {
3147
+ return sendJson(res, 500, { ok: false, error: "The notes could not be forgotten." });
3148
+ }
3149
+ });
3150
+ }
2475
3151
  if (path === `${LEGLAS_PREFIX}/api/requests/dismiss` && req.method === "POST") {
2476
3152
  if (!hasJsonBody(req)) {
2477
3153
  return sendJson(res, 400, { ok: false, error: "Dismiss must be JSON." });
@@ -2488,8 +3164,9 @@ async function startServer(options) {
2488
3164
  if (typeof parsed.id !== "string") {
2489
3165
  return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
2490
3166
  }
2491
- if (!(runner?.snapshot().failedIds.includes(parsed.id) ?? false)) {
2492
- return sendJson(res, 400, { ok: false, error: "Only a failed request can be dismissed." });
3167
+ const target2 = (await readRequests(cwd)).find((entry) => entry.id === parsed.id);
3168
+ if (target2 === void 0 || !isEnded(target2, runner?.snapshot().failedIds ?? [])) {
3169
+ return sendJson(res, 400, { ok: false, error: "Only an ended request can be dismissed." });
2493
3170
  }
2494
3171
  try {
2495
3172
  if (!await removeRequest(cwd, parsed.id)) {
@@ -2564,7 +3241,7 @@ async function startServer(options) {
2564
3241
  proxy.upgrade(req, socket, head);
2565
3242
  });
2566
3243
  const port = await bind(server, options.port ?? DEFAULT_PORT);
2567
- runner = startRunner({ cwd, externallyAttached });
3244
+ runner = startRunner({ cwd, externallyAttached, leglasCommand });
2568
3245
  let closePromise = null;
2569
3246
  return {
2570
3247
  port,
@@ -2639,11 +3316,11 @@ function planKeep(options) {
2639
3316
  }
2640
3317
 
2641
3318
  // src/run-init.ts
2642
- import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
2643
- import { join as join8 } from "path";
3319
+ import { readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
3320
+ import { join as join10 } from "path";
2644
3321
  async function readIfPresent(path) {
2645
3322
  try {
2646
- return await readFile6(path, "utf8");
3323
+ return await readFile8(path, "utf8");
2647
3324
  } catch {
2648
3325
  return null;
2649
3326
  }
@@ -2651,18 +3328,18 @@ async function readIfPresent(path) {
2651
3328
  async function runInit(options, deps) {
2652
3329
  const existingConfig = findConfigFile(options.cwd);
2653
3330
  const plan = planInit({
2654
- agents: await readIfPresent(join8(options.cwd, "AGENTS.md")),
3331
+ agents: await readIfPresent(join10(options.cwd, "AGENTS.md")),
2655
3332
  config: existingConfig === null ? null : "present",
2656
- gitignore: await readIfPresent(join8(options.cwd, ".gitignore")),
3333
+ gitignore: await readIfPresent(join10(options.cwd, ".gitignore")),
2657
3334
  force: options.force
2658
3335
  });
2659
3336
  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);
3337
+ for (const write2 of plan.writes) {
3338
+ await writeFile6(join10(options.cwd, write2.path), write2.contents, "utf8");
3339
+ touched.push(write2.path);
2663
3340
  }
2664
3341
  if (plan.gitignore !== null) {
2665
- await writeFile5(join8(options.cwd, ".gitignore"), plan.gitignore, "utf8");
3342
+ await writeFile6(join10(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2666
3343
  touched.push(".gitignore");
2667
3344
  }
2668
3345
  if (options.json) {
@@ -2682,8 +3359,8 @@ async function runInit(options, deps) {
2682
3359
 
2683
3360
  // src/run-keep.ts
2684
3361
  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";
3362
+ import { mkdir as mkdir6, readFile as readFile9, rm as rm2, writeFile as writeFile7 } from "fs/promises";
3363
+ import { dirname as dirname7, join as join11 } from "path";
2687
3364
 
2688
3365
  // src/resolve-title.ts
2689
3366
  function resolveOrExplain(input, titles, renames) {
@@ -2727,18 +3404,18 @@ async function runKeep(options, deps) {
2727
3404
  if (!resolved.ok) return fail(resolved.error);
2728
3405
  const plan = planKeep({ title: resolved.title, previews, to: options.to });
2729
3406
  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);
3407
+ const from = join11(options.cwd, plan.move.from);
3408
+ const to = join11(options.cwd, plan.move.to);
2732
3409
  if (!existsSync3(from)) {
2733
3410
  return fail(`${plan.move.from} does not exist. Nothing to keep.`);
2734
3411
  }
2735
3412
  if (existsSync3(to)) {
2736
3413
  return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
2737
3414
  }
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 });
3415
+ const source = await readFile9(from, "utf8");
3416
+ await mkdir6(dirname7(to), { recursive: true });
3417
+ await writeFile7(to, renameExport(source, plan.exportName), "utf8");
3418
+ await rm2(join11(options.cwd, plan.removeDir), { recursive: true, force: true });
2742
3419
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
2743
3420
  if (options.json) {
2744
3421
  deps.log(
@@ -2773,11 +3450,11 @@ async function runKeep(options, deps) {
2773
3450
 
2774
3451
  // src/run-new.ts
2775
3452
  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";
3453
+ import { mkdir as mkdir7, readFile as readFile10, writeFile as writeFile8 } from "fs/promises";
3454
+ import { dirname as dirname8, join as join12 } from "path";
2778
3455
  async function readIfPresent2(path) {
2779
3456
  try {
2780
- return await readFile8(path, "utf8");
3457
+ return await readFile10(path, "utf8");
2781
3458
  } catch {
2782
3459
  return null;
2783
3460
  }
@@ -2785,19 +3462,19 @@ async function readIfPresent2(path) {
2785
3462
  async function runNew(options, deps) {
2786
3463
  let from;
2787
3464
  if (options.from !== void 0) {
2788
- const contents = await readIfPresent2(join10(options.cwd, options.from));
3465
+ const contents = await readIfPresent2(join12(options.cwd, options.from));
2789
3466
  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);
3467
+ const message2 = `${options.from} does not exist, so there is nothing to use as the baseline.`;
3468
+ if (options.json) deps.log(JSON.stringify({ ok: false, error: message2 }));
3469
+ else deps.log(message2);
2793
3470
  return { exitCode: 1, written: [] };
2794
3471
  }
2795
3472
  from = { path: options.from, contents };
2796
3473
  }
2797
3474
  const plan = planNew({
2798
3475
  surface: options.surface,
2799
- packageJson: await readIfPresent2(join10(options.cwd, "package.json")),
2800
- gitignore: await readIfPresent2(join10(options.cwd, ".gitignore")),
3476
+ packageJson: await readIfPresent2(join12(options.cwd, "package.json")),
3477
+ gitignore: await readIfPresent2(join12(options.cwd, ".gitignore")),
2801
3478
  from
2802
3479
  });
2803
3480
  const fail = (error) => {
@@ -2813,26 +3490,26 @@ async function runNew(options, deps) {
2813
3490
  deps.log(JSON.stringify({ ok: true, files: plan.writes, instructions: plan.instructions, previews: plan.previews }));
2814
3491
  return { exitCode: 0, written: [] };
2815
3492
  }
2816
- for (const write of plan.writes) {
2817
- deps.log(`--- ${write.path}`);
2818
- deps.log(write.contents);
3493
+ for (const write2 of plan.writes) {
3494
+ deps.log(`--- ${write2.path}`);
3495
+ deps.log(write2.contents);
2819
3496
  }
2820
3497
  deps.log(plan.instructions);
2821
3498
  return { exitCode: 0, written: [] };
2822
3499
  }
2823
- const existing = plan.writes.filter((write) => existsSync4(join10(options.cwd, write.path)));
3500
+ const existing = plan.writes.filter((write2) => existsSync4(join12(options.cwd, write2.path)));
2824
3501
  if (existing.length > 0) {
2825
3502
  return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
2826
3503
  }
2827
3504
  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);
3505
+ for (const write2 of plan.writes) {
3506
+ const target = join12(options.cwd, write2.path);
3507
+ await mkdir7(dirname8(target), { recursive: true });
3508
+ await writeFile8(target, write2.contents, "utf8");
3509
+ written.push(write2.path);
2833
3510
  }
2834
3511
  if (plan.gitignore !== null) {
2835
- await writeFile7(join10(options.cwd, ".gitignore"), plan.gitignore, "utf8");
3512
+ await writeFile8(join12(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2836
3513
  written.push(".gitignore");
2837
3514
  }
2838
3515
  if (options.json) {
@@ -2853,21 +3530,21 @@ async function runNew(options, deps) {
2853
3530
  }
2854
3531
 
2855
3532
  // src/run-previews.ts
2856
- import { readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
2857
- import { join as join11 } from "path";
3533
+ import { readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
3534
+ import { join as join13 } from "path";
2858
3535
  function envelope(deps, ok, body) {
2859
3536
  deps.log(JSON.stringify({ ok, ...body }));
2860
3537
  }
2861
3538
  async function ensureIgnored(cwd) {
2862
- const path = join11(cwd, ".gitignore");
3539
+ const path = join13(cwd, ".gitignore");
2863
3540
  let current = null;
2864
3541
  try {
2865
- current = await readFile9(path, "utf8");
3542
+ current = await readFile11(path, "utf8");
2866
3543
  } catch {
2867
3544
  current = null;
2868
3545
  }
2869
3546
  const next = ignoreEntry(current);
2870
- if (next !== null) await writeFile8(path, next, "utf8");
3547
+ if (next !== null) await writeFile9(path, next, "utf8");
2871
3548
  }
2872
3549
  async function runAdd(options, deps) {
2873
3550
  const loaded = await loadConfig(options.cwd);
@@ -2891,7 +3568,8 @@ async function runAdd(options, deps) {
2891
3568
  tags: options.preview.tags,
2892
3569
  branch: options.preview.branch,
2893
3570
  file: options.preview.file,
2894
- basedOn: options.preview.basedOn
3571
+ basedOn: options.preview.basedOn,
3572
+ askedFor: options.preview.askedFor
2895
3573
  },
2896
3574
  shared
2897
3575
  );
@@ -2951,6 +3629,7 @@ async function runList(options, deps) {
2951
3629
  note: preview.note ?? null,
2952
3630
  tags: preview.tags,
2953
3631
  basedOn: preview.basedOn ?? null,
3632
+ askedFor: preview.askedFor ?? null,
2954
3633
  local: preview.local,
2955
3634
  branch: preview.branch ?? null,
2956
3635
  file: preview.file ?? null
@@ -3113,23 +3792,23 @@ async function runShow(options, deps) {
3113
3792
 
3114
3793
  // src/run-watch.ts
3115
3794
  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";
3795
+ import { mkdir as mkdir8, readFile as readFile12, writeFile as writeFile10 } from "fs/promises";
3796
+ import { dirname as dirname9, join as join14 } from "path";
3118
3797
  var POLL_MS2 = 2e3;
3119
3798
  var HEARTBEAT_TIMEOUT_MS = 1e3;
3120
3799
  async function saveTemplate(cwd, run3) {
3121
- const path = join12(cwd, WATCH_PATH);
3800
+ const path = join14(cwd, WATCH_PATH);
3122
3801
  let config = {};
3123
3802
  try {
3124
- const parsed = JSON.parse(await readFile10(path, "utf8"));
3803
+ const parsed = JSON.parse(await readFile12(path, "utf8"));
3125
3804
  if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
3126
3805
  config = parsed;
3127
3806
  }
3128
3807
  } catch {
3129
3808
  }
3130
3809
  config.run = run3;
3131
- await mkdir7(dirname8(path), { recursive: true });
3132
- await writeFile9(path, `${JSON.stringify(config, null, 2)}
3810
+ await mkdir8(dirname9(path), { recursive: true });
3811
+ await writeFile10(path, `${JSON.stringify(config, null, 2)}
3133
3812
  `, "utf8");
3134
3813
  }
3135
3814
  function spawnAgent(command, args, cwd) {
@@ -3168,7 +3847,7 @@ async function runWatch(options, deps) {
3168
3847
  const adapter = KNOWN_AGENTS[saved.agent];
3169
3848
  template = {
3170
3849
  command: adapter.binary,
3171
- args: adapter.terminalArgs(PROMPT_TOKEN)
3850
+ args: adapter.terminalArgs(PROMPT_TOKEN, saved.effort)
3172
3851
  };
3173
3852
  shownCommand2 = [template.command, ...template.args].join(" ");
3174
3853
  synthesizedAgent = adapter.name;
@@ -3215,9 +3894,13 @@ async function runWatch(options, deps) {
3215
3894
  return;
3216
3895
  }
3217
3896
  failed.add(request.id);
3218
- deps.error(
3219
- ` failed ${request.title}: ${outcome.ok ? `${command} exited ${outcome.code}` : outcome.error}`
3220
- );
3897
+ const failure = classifyFailure({
3898
+ agent: (shownCommand2.split(/\s+/)[0] ?? command).split("/").pop() ?? command,
3899
+ error: outcome.ok ? null : outcome.error,
3900
+ exitCode: outcome.ok ? outcome.code : null
3901
+ });
3902
+ await markFailed(options.cwd, request.id, failure);
3903
+ deps.error(` failed ${request.title}: ${failure.message}`);
3221
3904
  deps.error(" Left in the queue and not retried.");
3222
3905
  };
3223
3906
  const tick = async () => {
@@ -3263,12 +3946,12 @@ async function runWatch(options, deps) {
3263
3946
 
3264
3947
  // src/run-classify.ts
3265
3948
  import { stat } from "fs/promises";
3266
- import { join as join13 } from "path";
3949
+ import { join as join15 } from "path";
3267
3950
  async function runClassify(options, deps) {
3268
3951
  const declared = await Promise.all(
3269
3952
  options.changes.map(async (change) => ({
3270
3953
  ...change,
3271
- exists: await stat(join13(options.cwd, change.path)).then(
3954
+ exists: await stat(join15(options.cwd, change.path)).then(
3272
3955
  () => true,
3273
3956
  () => false
3274
3957
  )
@@ -3296,18 +3979,28 @@ async function runClassify(options, deps) {
3296
3979
  // src/run.ts
3297
3980
  import { existsSync as existsSync5 } from "fs";
3298
3981
  import { createRequire } from "module";
3299
- import { basename as basename3, dirname as dirname9, join as join14, relative as relative4 } from "path";
3982
+ import { basename as basename3, dirname as dirname10, join as join16, relative as relative4 } from "path";
3300
3983
  import { fileURLToPath } from "url";
3301
3984
  function findShellDir() {
3302
- const bundled = join14(dirname9(fileURLToPath(import.meta.url)), "shell");
3303
- if (existsSync5(join14(bundled, "index.html"))) return bundled;
3985
+ const bundled = join16(dirname10(fileURLToPath(import.meta.url)), "shell");
3986
+ if (existsSync5(join16(bundled, "index.html"))) return bundled;
3304
3987
  try {
3305
3988
  const require2 = createRequire(import.meta.url);
3306
- return dirname9(require2.resolve("@leglas/shell/dist/index.html"));
3989
+ return dirname10(require2.resolve("@leglas/shell/dist/index.html"));
3307
3990
  } catch {
3308
3991
  return null;
3309
3992
  }
3310
3993
  }
3994
+ function shellWord(value) {
3995
+ if (/^[A-Za-z0-9_./:=+\\-]+$/.test(value)) return value;
3996
+ if (process.platform === "win32") return `"${value.replaceAll('"', '""')}"`;
3997
+ return `'${value.replaceAll("'", `'\\''`)}'`;
3998
+ }
3999
+ function embeddedLeglasCommand() {
4000
+ const entry = join16(dirname10(fileURLToPath(import.meta.url)), "bin.js");
4001
+ if (!existsSync5(entry)) return "npx -y leglas";
4002
+ return [process.execPath, entry].map(shellWord).join(" ");
4003
+ }
3311
4004
  async function run2(options, deps) {
3312
4005
  const loaded = await loadConfig(options.cwd);
3313
4006
  const local = await readLocalPreviews(options.cwd);
@@ -3337,7 +4030,7 @@ async function run2(options, deps) {
3337
4030
  const fileMounts = /* @__PURE__ */ new Map();
3338
4031
  for (const preview of merged?.previews ?? []) {
3339
4032
  if (preview.file !== void 0) {
3340
- const absolute = join14(options.cwd, preview.file);
4033
+ const absolute = join16(options.cwd, preview.file);
3341
4034
  if (!existsSync5(absolute)) {
3342
4035
  worktreeErrors.push(
3343
4036
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -3348,7 +4041,7 @@ async function run2(options, deps) {
3348
4041
  for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
3349
4042
  slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
3350
4043
  }
3351
- fileMounts.set(slug, dirname9(absolute));
4044
+ fileMounts.set(slug, dirname10(absolute));
3352
4045
  previews.push({
3353
4046
  ...preview,
3354
4047
  url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
@@ -3389,6 +4082,7 @@ async function run2(options, deps) {
3389
4082
  // directory does. Either way saved layout survives a port change.
3390
4083
  project: loaded.path ?? options.cwd,
3391
4084
  cwd: options.cwd,
4085
+ leglasCommand: embeddedLeglasCommand(),
3392
4086
  ...options.port === void 0 ? {} : { port: options.port }
3393
4087
  });
3394
4088
  const url = `${server.url}${LEGLAS_PREFIX}`;