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/bin.js CHANGED
@@ -63,6 +63,7 @@ function parseAdd(rest) {
63
63
  let branch;
64
64
  let file;
65
65
  let basedOn;
66
+ let askedFor;
66
67
  const tags = [];
67
68
  let json = false;
68
69
  for (let index = 0; index < rest.length; index += 1) {
@@ -81,7 +82,9 @@ function parseAdd(rest) {
81
82
  } else {
82
83
  value = argument.slice(equals + 1);
83
84
  }
84
- if (!["--title", "--url", "--note", "--tag", "--branch", "--file", "--based-on"].includes(flag)) {
85
+ if (!["--title", "--url", "--note", "--tag", "--branch", "--file", "--based-on", "--asked-for"].includes(
86
+ flag
87
+ )) {
85
88
  return { kind: "error", message: `leglas add does not take ${flag}.` };
86
89
  }
87
90
  if (value === void 0 || value === "") {
@@ -93,6 +96,7 @@ function parseAdd(rest) {
93
96
  else if (flag === "--branch") branch = value;
94
97
  else if (flag === "--file") file = value;
95
98
  else if (flag === "--based-on") basedOn = value;
99
+ else if (flag === "--asked-for") askedFor = value;
96
100
  else tags.push(value);
97
101
  }
98
102
  if (title === void 0) {
@@ -106,7 +110,16 @@ function parseAdd(rest) {
106
110
  }
107
111
  return {
108
112
  kind: "add",
109
- preview: { title, url, note, tags: tags.length > 0 ? tags : void 0, branch, file, basedOn },
113
+ preview: {
114
+ title,
115
+ url,
116
+ note,
117
+ tags: tags.length > 0 ? tags : void 0,
118
+ branch,
119
+ file,
120
+ basedOn,
121
+ askedFor
122
+ },
110
123
  json
111
124
  };
112
125
  }
@@ -353,7 +366,7 @@ function parseArgs(argv) {
353
366
 
354
367
  // src/run-classify.ts
355
368
  import { stat } from "fs/promises";
356
- import { join as join8 } from "path";
369
+ import { join as join10 } from "path";
357
370
 
358
371
  // ../server/dist/config.js
359
372
  var DEFAULT_DEV_SERVER = "http://localhost:3000";
@@ -448,6 +461,10 @@ function normalizeConfig(raw, options = {}) {
448
461
  if (basedOn !== void 0 && (typeof basedOn !== "string" || basedOn.trim() === "")) {
449
462
  errors.push(`${at} has a basedOn that is not a direction title.`);
450
463
  }
464
+ const askedFor = entry["askedFor"];
465
+ if (askedFor !== void 0 && (typeof askedFor !== "string" || askedFor.trim() === "")) {
466
+ errors.push(`${at} has an askedFor that is not a change request.`);
467
+ }
451
468
  const tags = entry["tags"];
452
469
  previews.push({
453
470
  title: typeof title === "string" ? title : "",
@@ -456,7 +473,8 @@ function normalizeConfig(raw, options = {}) {
456
473
  tags: Array.isArray(tags) ? tags.filter((tag) => typeof tag === "string") : [],
457
474
  ...typeof branch === "string" ? { branch } : {},
458
475
  ...typeof file === "string" ? { file } : {},
459
- ...typeof basedOn === "string" && basedOn.trim() !== "" ? { basedOn } : {}
476
+ ...typeof basedOn === "string" && basedOn.trim() !== "" ? { basedOn } : {},
477
+ ...typeof askedFor === "string" && askedFor.trim() !== "" ? { askedFor } : {}
460
478
  });
461
479
  });
462
480
  const devCommand = source["devCommand"];
@@ -569,29 +587,39 @@ function nextRequest(requests, failed) {
569
587
 
570
588
  // ../server/dist/agents.js
571
589
  import { spawn } from "child_process";
572
- import { constants } from "fs";
590
+ import { constants, readdirSync } from "fs";
573
591
  import { access, mkdir, readFile, writeFile } from "fs/promises";
574
592
  import { delimiter, dirname, isAbsolute, join, relative } from "path";
593
+ var AGENT_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
594
+ var effortFlag = (effort) => effort === null ? [] : ["--effort", effort];
595
+ var codexEffortConfig = (effort) => effort === null ? [] : ["-c", `model_reasoning_effort=${effort}`];
596
+ var CODEX_WORKSPACE_CONFIG = [
597
+ "-c",
598
+ "sandbox_workspace_write.network_access=true"
599
+ ];
575
600
  var KNOWN_AGENTS = {
576
601
  claude: {
577
602
  name: "Claude",
578
603
  binary: "claude",
579
- args: (prompt) => [
604
+ efforts: AGENT_EFFORTS,
605
+ args: (prompt, effort = null) => [
580
606
  "-p",
581
607
  prompt,
582
608
  "--output-format",
583
609
  "stream-json",
584
610
  "--verbose",
585
611
  "--permission-mode",
586
- "acceptEdits"
612
+ "acceptEdits",
613
+ ...effortFlag(effort)
587
614
  ],
588
- terminalArgs: (prompt) => [
615
+ terminalArgs: (prompt, effort = null) => [
589
616
  "-p",
590
617
  prompt,
591
618
  "--permission-mode",
592
- "acceptEdits"
619
+ "acceptEdits",
620
+ ...effortFlag(effort)
593
621
  ],
594
- resumeArgs: (sessionId, prompt) => [
622
+ resumeArgs: (sessionId, prompt, effort = null) => [
595
623
  "-p",
596
624
  "--resume",
597
625
  sessionId,
@@ -600,8 +628,15 @@ var KNOWN_AGENTS = {
600
628
  "stream-json",
601
629
  "--verbose",
602
630
  "--permission-mode",
603
- "acceptEdits"
631
+ "acceptEdits",
632
+ ...effortFlag(effort)
604
633
  ],
634
+ // Non-interactive Claude cannot approve a Bash call: acceptEdits covers
635
+ // files, so a command the prompt requires is refused every time with
636
+ // nobody there to say yes. This allows exactly that command and nothing
637
+ // wider. Codex needs no equivalent, because workspace-write already lets
638
+ // it run commands.
639
+ allowArgs: (command) => ["--allowedTools", `Bash(${command} *)`],
605
640
  // Every stream-json event names its session.
606
641
  sessionFrom: (event) => typeof event.session_id === "string" && event.session_id !== "" ? event.session_id : null,
607
642
  authArgs: ["auth", "status"],
@@ -622,15 +657,45 @@ var KNOWN_AGENTS = {
622
657
  codex: {
623
658
  name: "Codex",
624
659
  binary: "codex",
625
- args: (prompt) => ["exec", "--json", "-s", "workspace-write", prompt],
626
- terminalArgs: (prompt) => ["exec", "-s", "workspace-write", prompt],
660
+ efforts: AGENT_EFFORTS,
661
+ // `--skip-git-repo-check` is what lets Codex run at all in a project the
662
+ // user never put under version control: without it codex-cli refuses
663
+ // before it reaches a model, with "Not inside a trusted directory and
664
+ // --skip-git-repo-check was not specified", and every Codex request in a
665
+ // non-git project fails for a reason nothing in Leglas explained. The flag
666
+ // moves that precondition and only that: `-s workspace-write` still
667
+ // confines writes to the project, so the sandbox boundary is unchanged,
668
+ // and in a git repository the flag does nothing at all.
669
+ args: (prompt, effort = null) => [
670
+ "exec",
671
+ "--json",
672
+ ...CODEX_WORKSPACE_CONFIG,
673
+ ...codexEffortConfig(effort),
674
+ "-s",
675
+ "workspace-write",
676
+ "--skip-git-repo-check",
677
+ prompt
678
+ ],
679
+ terminalArgs: (prompt, effort = null) => [
680
+ "exec",
681
+ ...CODEX_WORKSPACE_CONFIG,
682
+ ...codexEffortConfig(effort),
683
+ "-s",
684
+ "workspace-write",
685
+ "--skip-git-repo-check",
686
+ prompt
687
+ ],
627
688
  // No sandbox flag here: `codex exec resume` refuses it and inherits the
628
- // session's own sandbox, which the first turn set to workspace-write.
629
- resumeArgs: (sessionId, prompt) => [
689
+ // session's own sandbox, which the first turn set to workspace-write. The
690
+ // repository check is per invocation, so resume needs the flag of its own.
691
+ resumeArgs: (sessionId, prompt, effort = null) => [
630
692
  "exec",
631
693
  "resume",
632
694
  sessionId,
633
695
  "--json",
696
+ ...CODEX_WORKSPACE_CONFIG,
697
+ ...codexEffortConfig(effort),
698
+ "--skip-git-repo-check",
634
699
  prompt
635
700
  ],
636
701
  sessionFrom: (event) => event.type === "thread.started" && typeof event.thread_id === "string" ? event.thread_id : null,
@@ -641,8 +706,14 @@ var KNOWN_AGENTS = {
641
706
  cursor: {
642
707
  name: "Cursor",
643
708
  binary: "cursor-agent",
644
- args: (prompt) => ["-p", prompt, "--output-format", "stream-json"],
645
- terminalArgs: (prompt) => ["-p", prompt],
709
+ efforts: [],
710
+ args: (prompt, _effort = null) => [
711
+ "-p",
712
+ prompt,
713
+ "--output-format",
714
+ "stream-json"
715
+ ],
716
+ terminalArgs: (prompt, _effort = null) => ["-p", prompt],
646
717
  authArgs: ["status"],
647
718
  // UNVERIFIED: cursor-agent was not available on the build machine. The
648
719
  // reading is deliberately loose, and anything ambiguous stays unknown.
@@ -656,11 +727,15 @@ var KNOWN_AGENTS = {
656
727
  }
657
728
  };
658
729
  var PROBE_TIMEOUT_MS = 3e3;
659
- function execProbe(binary, args) {
730
+ function execProbe(binary, args, timeoutMs = PROBE_TIMEOUT_MS) {
660
731
  return new Promise((resolve) => {
661
732
  let child;
662
733
  try {
663
- child = spawn(binary, [...args], { shell: false, stdio: ["ignore", "pipe", "ignore"] });
734
+ child = spawn(binary, [...args], {
735
+ env: agentEnvironment(),
736
+ shell: false,
737
+ stdio: ["ignore", "pipe", "ignore"]
738
+ });
664
739
  } catch {
665
740
  return resolve(null);
666
741
  }
@@ -669,7 +744,10 @@ function execProbe(binary, args) {
669
744
  if (stdout.length < 4096)
670
745
  stdout += chunk.toString();
671
746
  });
672
- const deadline = setTimeout(() => child.kill("SIGKILL"), PROBE_TIMEOUT_MS);
747
+ const deadline = setTimeout(() => {
748
+ child.kill("SIGKILL");
749
+ resolve(null);
750
+ }, timeoutMs);
673
751
  child.once("error", () => {
674
752
  clearTimeout(deadline);
675
753
  resolve(null);
@@ -680,8 +758,50 @@ function execProbe(binary, args) {
680
758
  });
681
759
  });
682
760
  }
761
+ function agentSearchPath(env = process.env, platform = process.platform) {
762
+ const home = env.HOME ?? env.USERPROFILE ?? "";
763
+ const npmPrefix = env.NPM_CONFIG_PREFIX;
764
+ const versionBins = (root, suffix) => {
765
+ try {
766
+ return readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(root, entry.name, ...suffix));
767
+ } catch {
768
+ return [];
769
+ }
770
+ };
771
+ const candidates = [
772
+ ...(env.PATH ?? "").split(delimiter),
773
+ env.PNPM_HOME,
774
+ env.NVM_BIN,
775
+ env.BUN_INSTALL === void 0 ? void 0 : join(env.BUN_INSTALL, "bin"),
776
+ env.CARGO_HOME === void 0 ? void 0 : join(env.CARGO_HOME, "bin"),
777
+ npmPrefix === void 0 ? void 0 : platform === "win32" ? npmPrefix : join(npmPrefix, "bin"),
778
+ home === "" ? void 0 : join(home, ".local", "bin"),
779
+ home === "" ? void 0 : join(home, ".npm-global", "bin"),
780
+ home === "" ? void 0 : join(home, ".bun", "bin"),
781
+ home === "" ? void 0 : join(home, ".cargo", "bin"),
782
+ home === "" ? void 0 : join(home, ".volta", "bin"),
783
+ home === "" ? void 0 : join(home, ".asdf", "shims"),
784
+ home === "" ? void 0 : join(home, ".local", "share", "mise", "shims"),
785
+ home === "" ? void 0 : join(home, ".local", "share", "pnpm"),
786
+ home === "" ? void 0 : join(home, "Library", "pnpm"),
787
+ ...home === "" ? [] : versionBins(join(home, ".nvm", "versions", "node"), ["bin"]),
788
+ ...home === "" ? [] : versionBins(join(home, ".local", "share", "fnm", "node-versions"), [
789
+ "installation",
790
+ "bin"
791
+ ]),
792
+ platform === "win32" ? env.APPDATA : void 0,
793
+ platform === "darwin" ? "/opt/homebrew/bin" : void 0,
794
+ platform === "darwin" ? "/usr/local/bin" : void 0,
795
+ platform === "darwin" ? "/Applications/Codex.app/Contents/Resources" : void 0,
796
+ platform === "darwin" ? "/Applications/Codex++.app/Contents/Resources" : void 0
797
+ ].filter((entry) => typeof entry === "string" && entry !== "");
798
+ return [...new Set(candidates)].join(delimiter);
799
+ }
800
+ function agentEnvironment(env = process.env) {
801
+ return { ...env, PATH: agentSearchPath(env) };
802
+ }
683
803
  async function pathLookup(binary) {
684
- const entries = (process.env.PATH ?? "").split(delimiter).filter((entry) => entry !== "");
804
+ const entries = agentSearchPath().split(delimiter).filter((entry) => entry !== "");
685
805
  const extensions = process.platform === "win32" ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((entry) => entry !== "") : [""];
686
806
  for (const entry of entries) {
687
807
  for (const extension of extensions) {
@@ -698,14 +818,22 @@ async function detectAgents(lookup = pathLookup, probe2 = execProbe) {
698
818
  const entries = Object.entries(KNOWN_AGENTS);
699
819
  return Promise.all(entries.map(async ([id, adapter]) => {
700
820
  const available = await lookup(adapter.binary).catch(() => false);
701
- if (!available)
702
- return { id, name: adapter.name, available, auth: "unknown" };
821
+ if (!available) {
822
+ return {
823
+ id,
824
+ name: adapter.name,
825
+ available,
826
+ auth: "unknown",
827
+ efforts: adapter.efforts
828
+ };
829
+ }
703
830
  const result2 = await probe2(adapter.binary, adapter.authArgs).catch(() => null);
704
831
  return {
705
832
  id,
706
833
  name: adapter.name,
707
834
  available,
708
- auth: result2 === null ? "unknown" : adapter.authVerdict(result2)
835
+ auth: result2 === null ? "unknown" : adapter.authVerdict(result2),
836
+ efforts: adapter.efforts
709
837
  };
710
838
  }));
711
839
  }
@@ -738,10 +866,10 @@ function shownCommand(value) {
738
866
  function claudeActivity(event, cwd) {
739
867
  if (event.type !== "assistant")
740
868
  return null;
741
- const message = record(event.message);
742
- if (message === null || !Array.isArray(message.content))
869
+ const message2 = record(event.message);
870
+ if (message2 === null || !Array.isArray(message2.content))
743
871
  return null;
744
- for (const rawBlock of message.content) {
872
+ for (const rawBlock of message2.content) {
745
873
  const block = record(rawBlock);
746
874
  if (block?.type !== "tool_use" || typeof block.name !== "string")
747
875
  continue;
@@ -810,9 +938,31 @@ function sessionFrom(agent, line) {
810
938
  return null;
811
939
  return KNOWN_AGENTS[agent].sessionFrom(event);
812
940
  }
941
+ function retryFrom(agent, line) {
942
+ if (agent !== "claude" && agent !== "cursor")
943
+ return null;
944
+ let event;
945
+ try {
946
+ event = record(JSON.parse(line));
947
+ } catch {
948
+ return null;
949
+ }
950
+ if (event === null || event.type !== "system" || event.subtype !== "api_retry")
951
+ return null;
952
+ const attempt = typeof event.attempt === "number" ? event.attempt : 1;
953
+ return {
954
+ attempt,
955
+ max: typeof event.max_retries === "number" ? event.max_retries : null,
956
+ status: typeof event.error_status === "number" ? event.error_status : null,
957
+ reason: typeof event.error === "string" && event.error !== "" ? event.error.toLowerCase() : null
958
+ };
959
+ }
813
960
  function isAgentChoice(value) {
814
961
  return value === "custom" || typeof value === "string" && Object.hasOwn(KNOWN_AGENTS, value);
815
962
  }
963
+ function isAgentEffort(value) {
964
+ return typeof value === "string" && AGENT_EFFORTS.includes(value);
965
+ }
816
966
  async function readWatchConfig(cwd) {
817
967
  try {
818
968
  const parsed2 = JSON.parse(await readFile(join(cwd, WATCH_PATH), "utf8"));
@@ -823,14 +973,28 @@ async function readWatchConfig(cwd) {
823
973
  }
824
974
  async function readAgentChoice(cwd) {
825
975
  const config = await readWatchConfig(cwd);
976
+ const agent = isAgentChoice(config.agent) ? config.agent : null;
977
+ const efforts = record(config.efforts);
826
978
  return {
827
- agent: isAgentChoice(config.agent) ? config.agent : null,
979
+ agent,
980
+ effort: agent !== null && agent !== "custom" && isAgentEffort(efforts?.[agent]) ? efforts[agent] : null,
828
981
  run: typeof config.run === "string" && config.run !== "" ? config.run : null
829
982
  };
830
983
  }
831
984
  async function saveAgentChoice(cwd, choice) {
832
985
  const config = await readWatchConfig(cwd);
833
986
  config.agent = choice.agent;
987
+ if (choice.agent !== "custom" && choice.effort !== void 0) {
988
+ const efforts = record(config.efforts) ?? {};
989
+ if (choice.effort === null)
990
+ delete efforts[choice.agent];
991
+ else
992
+ efforts[choice.agent] = choice.effort;
993
+ if (Object.keys(efforts).length === 0)
994
+ delete config.efforts;
995
+ else
996
+ config.efforts = efforts;
997
+ }
834
998
  if (choice.run !== void 0)
835
999
  config.run = choice.run;
836
1000
  const path = join(cwd, WATCH_PATH);
@@ -953,8 +1117,8 @@ async function loadConfig(cwd) {
953
1117
  exported = module.default;
954
1118
  }
955
1119
  } catch (error) {
956
- const message = error instanceof Error ? error.message : String(error);
957
- return { config: null, errors: [`${label} could not be loaded: ${message}`], path };
1120
+ const message2 = error instanceof Error ? error.message : String(error);
1121
+ return { config: null, errors: [`${label} could not be loaded: ${message2}`], path };
958
1122
  }
959
1123
  const result2 = normalizeConfig(exported);
960
1124
  return {
@@ -1024,7 +1188,8 @@ async function addLocalPreview(cwd, input, shared) {
1024
1188
  ...input.tags === void 0 ? {} : { tags: input.tags },
1025
1189
  ...input.branch === void 0 ? {} : { branch: input.branch },
1026
1190
  ...input.file === void 0 ? {} : { file: input.file },
1027
- ...input.basedOn === void 0 ? {} : { basedOn: input.basedOn }
1191
+ ...input.basedOn === void 0 ? {} : { basedOn: input.basedOn },
1192
+ ...input.askedFor === void 0 ? {} : { askedFor: input.askedFor }
1028
1193
  };
1029
1194
  const check = normalizeConfig({ previews: [candidate] }, { requireDevCommand: false });
1030
1195
  if (check.config === null) {
@@ -1256,17 +1421,220 @@ async function startAppProcess(options) {
1256
1421
  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.`);
1257
1422
  }
1258
1423
 
1259
- // ../server/dist/requests.js
1260
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1424
+ // ../server/dist/failure.js
1425
+ var NEEDS_TRUST = /not inside a trusted directory|--skip-git-repo-check/i;
1426
+ var MISSING_BINARY = /\b(ENOENT|EACCES|ENOTDIR)\b/;
1427
+ 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;
1428
+ var LIMIT = /\b429\b|rate limit|usage limit|quota exceeded|too many requests/i;
1429
+ var OVERLOADED = /\b(?:503|529)\b|overloaded|service unavailable/i;
1430
+ function fromStatus(status, reason) {
1431
+ if (status === 401 || status === 403 || reason === "authentication_failed")
1432
+ return "not-signed-in";
1433
+ if (status === 429 || reason === "rate_limit")
1434
+ return "provider-limit";
1435
+ if (status === 529 || status === 503 || reason === "overloaded")
1436
+ return "provider-overloaded";
1437
+ return null;
1438
+ }
1439
+ function fromLines(lines) {
1440
+ for (const line of [...lines].reverse()) {
1441
+ if (NEEDS_TRUST.test(line))
1442
+ return "needs-trust";
1443
+ if (NOT_SIGNED_IN.test(line))
1444
+ return "not-signed-in";
1445
+ if (LIMIT.test(line))
1446
+ return "provider-limit";
1447
+ if (OVERLOADED.test(line))
1448
+ return "provider-overloaded";
1449
+ }
1450
+ return null;
1451
+ }
1452
+ function attempts(retry) {
1453
+ if (retry === null || retry === void 0)
1454
+ return "";
1455
+ const total = retry.max === null ? retry.attempt : Math.max(retry.attempt, retry.max);
1456
+ return ` It retried ${total} times first.`;
1457
+ }
1458
+ function message(code, input) {
1459
+ const agent = input.agent;
1460
+ switch (code) {
1461
+ case "cancelled":
1462
+ return "You stopped this run.";
1463
+ case "stopped":
1464
+ return "Leglas shut down while this was running.";
1465
+ case "missing-agent":
1466
+ return `${agent} could not be started. Its command is not on this machine's PATH any more.`;
1467
+ case "not-signed-in":
1468
+ return `${agent} is not signed in. Sign in to it in a terminal, then run this again.`;
1469
+ case "provider-overloaded":
1470
+ return `${agent}'s provider was overloaded and gave up.${attempts(input.retry)}`;
1471
+ case "provider-limit":
1472
+ return `${agent} reported a rate or usage limit, so nothing ran.`;
1473
+ case "needs-trust":
1474
+ return `Codex refused this project: it is not a git repository and Codex has no trust on record for it.`;
1475
+ case "not-registered":
1476
+ return `${agent} finished without registering the new direction, so nothing reached the rail. Its last output is in the Leglas terminal.`;
1477
+ case "agent-error":
1478
+ 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.`;
1479
+ }
1480
+ }
1481
+ function classifyFailure(input) {
1482
+ const lines = input.lines ?? [];
1483
+ const error = input.error ?? null;
1484
+ 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";
1485
+ return { code, message: message(code, input) };
1486
+ }
1487
+ function sessionShaped(code) {
1488
+ return code === "agent-error";
1489
+ }
1490
+
1491
+ // ../server/dist/annotations.js
1261
1492
  import { randomBytes } from "crypto";
1493
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1262
1494
  import { dirname as dirname4, join as join5 } from "path";
1495
+ var ANNOTATIONS_PATH = ".leglas/annotations.json";
1496
+ var NOTE_CAP = 500;
1497
+ var SELECTOR_CAP = 300;
1498
+ var TEXT_CAP = 120;
1499
+ var TAG_CAP = 40;
1500
+ var CLASS_CAP = 8;
1501
+ var CLASS_LENGTH_CAP = 60;
1502
+ var COVERS_CAP = 8;
1503
+ function isRecord2(value) {
1504
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1505
+ }
1506
+ function text(value, cap) {
1507
+ return typeof value === "string" ? value.trim().slice(0, cap) : "";
1508
+ }
1509
+ function fraction(value) {
1510
+ if (typeof value !== "number" || !Number.isFinite(value))
1511
+ return 0.5;
1512
+ return Math.min(1, Math.max(0, value));
1513
+ }
1514
+ function size(value) {
1515
+ return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0;
1516
+ }
1517
+ function anchorFrom(value) {
1518
+ if (!isRecord2(value))
1519
+ return null;
1520
+ const selector = text(value["selector"], SELECTOR_CAP);
1521
+ if (selector === "")
1522
+ return null;
1523
+ const rect = isRecord2(value["rect"]) ? value["rect"] : {};
1524
+ 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)) : [];
1525
+ const rawRegion = isRecord2(value["region"]) ? value["region"] : null;
1526
+ const region = rawRegion === null ? null : {
1527
+ height: fraction(rawRegion["height"]),
1528
+ width: fraction(rawRegion["width"]),
1529
+ x: fraction(rawRegion["x"]),
1530
+ y: fraction(rawRegion["y"])
1531
+ };
1532
+ const covers = Array.isArray(value["covers"]) ? value["covers"].filter(isRecord2).slice(0, COVERS_CAP).map((entry) => ({
1533
+ tag: text(entry["tag"], TAG_CAP) || "element",
1534
+ text: text(entry["text"], TEXT_CAP)
1535
+ })) : [];
1536
+ return {
1537
+ classes,
1538
+ ...covers.length === 0 ? {} : { covers },
1539
+ ...region === null ? {} : { region },
1540
+ rect: {
1541
+ height: size(rect["height"]),
1542
+ width: size(rect["width"]),
1543
+ x: size(rect["x"]),
1544
+ y: size(rect["y"])
1545
+ },
1546
+ selector,
1547
+ spot: {
1548
+ x: fraction(isRecord2(value["spot"]) ? value["spot"]["x"] : void 0),
1549
+ y: fraction(isRecord2(value["spot"]) ? value["spot"]["y"] : void 0)
1550
+ },
1551
+ tag: text(value["tag"], TAG_CAP) || "element",
1552
+ text: text(value["text"], TEXT_CAP),
1553
+ viewport: size(value["viewport"])
1554
+ };
1555
+ }
1556
+ async function readAnnotations(cwd) {
1557
+ try {
1558
+ const raw = await readFile4(join5(cwd, ANNOTATIONS_PATH), "utf8");
1559
+ const parsed2 = JSON.parse(raw);
1560
+ if (!Array.isArray(parsed2.annotations))
1561
+ return [];
1562
+ return parsed2.annotations.flatMap((entry, index) => {
1563
+ if (!isRecord2(entry))
1564
+ return [];
1565
+ const anchor = anchorFrom(entry["anchor"]);
1566
+ const title = text(entry["title"], TAG_CAP * 4);
1567
+ if (anchor === null || title === "")
1568
+ return [];
1569
+ return [
1570
+ {
1571
+ anchor,
1572
+ id: typeof entry["id"] === "string" ? entry["id"] : String(index),
1573
+ note: text(entry["note"], NOTE_CAP),
1574
+ title
1575
+ }
1576
+ ];
1577
+ });
1578
+ } catch {
1579
+ return [];
1580
+ }
1581
+ }
1582
+ async function write(cwd, annotations) {
1583
+ const path = join5(cwd, ANNOTATIONS_PATH);
1584
+ await mkdir3(dirname4(path), { recursive: true });
1585
+ await writeFile3(path, `${JSON.stringify({ annotations }, null, 2)}
1586
+ `, "utf8");
1587
+ }
1588
+ async function addAnnotation(cwd, input) {
1589
+ const annotation = { ...input, id: randomBytes(6).toString("base64url") };
1590
+ await write(cwd, [...await readAnnotations(cwd), annotation]);
1591
+ return annotation;
1592
+ }
1593
+ async function removeAnnotations(cwd, ids) {
1594
+ const wanted = new Set(ids);
1595
+ const annotations = await readAnnotations(cwd);
1596
+ const remaining = annotations.filter((entry) => !wanted.has(entry.id));
1597
+ const dropped = annotations.length - remaining.length;
1598
+ if (dropped > 0)
1599
+ await write(cwd, remaining);
1600
+ return dropped;
1601
+ }
1602
+ function annotationsFor(annotations, title) {
1603
+ return annotations.filter((entry) => entry.title === title);
1604
+ }
1605
+ function describeAnchor(anchor) {
1606
+ const where = `about ${anchor.rect.width}\xD7${anchor.rect.height} at (${anchor.rect.x}, ${anchor.rect.y}) in a ${anchor.viewport}px-wide viewport`;
1607
+ if (anchor.region !== void 0) {
1608
+ const covered = (anchor.covers ?? []).map((entry) => entry.text === "" ? `<${entry.tag}>` : `<${entry.tag}> \u201C${entry.text}\u201D`).join(", ");
1609
+ const inside = covered === "" ? "" : ` covering ${covered};`;
1610
+ return `an area inside <${anchor.tag}>;${inside} path ${anchor.selector}; ${where}`;
1611
+ }
1612
+ const parts = [`<${anchor.tag}>`];
1613
+ if (anchor.classes.length > 0)
1614
+ parts.push(`class "${anchor.classes.join(" ")}"`);
1615
+ if (anchor.text !== "")
1616
+ parts.push(`reading \u201C${anchor.text}\u201D`);
1617
+ return `${parts.join(", ")}; path ${anchor.selector}; ${where}`;
1618
+ }
1619
+ function describeAnnotations(annotations) {
1620
+ return annotations.map((annotation, index) => {
1621
+ const said = annotation.note === "" ? "Look at this." : annotation.note;
1622
+ return `${index + 1}. ${said}
1623
+ The element: ${describeAnchor(annotation.anchor)}`;
1624
+ }).join("\n\n");
1625
+ }
1626
+
1627
+ // ../server/dist/requests.js
1628
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1629
+ import { randomBytes as randomBytes2 } from "crypto";
1630
+ import { dirname as dirname5, join as join6 } from "path";
1263
1631
  var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
1264
- function targetFor(url) {
1632
+ function variantSlot(url) {
1265
1633
  if (!url.startsWith("/"))
1266
1634
  return null;
1267
- const query = url.slice(url.indexOf("?") + 1);
1268
1635
  if (!url.includes("?"))
1269
1636
  return null;
1637
+ const query = url.slice(url.indexOf("?") + 1);
1270
1638
  for (const pair of query.split("&")) {
1271
1639
  const [rawKey, rawValue] = pair.split("=");
1272
1640
  if (rawKey === void 0 || rawValue === void 0)
@@ -1277,37 +1645,125 @@ function targetFor(url) {
1277
1645
  const option = decodeURIComponent(rawValue);
1278
1646
  if (!SAFE_SEGMENT.test(surface) || !SAFE_SEGMENT.test(option))
1279
1647
  return null;
1280
- return `.leglas/variants/${surface}/${option}.tsx`;
1648
+ return { surface, option };
1281
1649
  }
1282
1650
  return null;
1283
1651
  }
1284
- function composeRequest(preview, intent) {
1652
+ function targetFor(url) {
1653
+ const slot = variantSlot(url);
1654
+ return slot === null ? null : `.leglas/variants/${slot.surface}/${slot.option}.tsx`;
1655
+ }
1656
+ function composeRequest(preview, intent, mode, notes = [], leglasCommand = "npx -y leglas") {
1285
1657
  const target = preview.file ?? targetFor(preview.url);
1286
1658
  const cleaned = intent.trim();
1659
+ const asked = changeBlock(cleaned, notes);
1660
+ const recorded = cleaned === "" ? notes.map((entry) => entry.note).filter((entry) => entry !== "").join("; ") : cleaned;
1661
+ const prompt = mode === "variant" ? variantPrompt(preview, recorded, asked, target, leglasCommand) : replacePrompt(preview, asked, target);
1662
+ return { prompt, target, mode };
1663
+ }
1664
+ 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.`;
1665
+ function changeBlock(cleaned, notes) {
1666
+ if (notes.length === 0)
1667
+ return `What to change: ${cleaned}`;
1668
+ const many = notes.length === 1 ? "a note" : `${notes.length} notes`;
1669
+ const lead = cleaned === "" ? `What to change, left as ${many} on the design itself:` : `What to change: ${cleaned}
1670
+
1671
+ And ${many} left on the design itself:`;
1672
+ return `${lead}
1673
+
1674
+ ${describeAnnotations(notes)}
1675
+
1676
+ ${ANCHORS}`;
1677
+ }
1678
+ 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.
1679
+
1680
+ 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.
1681
+
1682
+ 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.`;
1683
+ function registrationCommand(leglasCommand) {
1684
+ return `${leglasCommand} add`;
1685
+ }
1686
+ function replacePrompt(preview, asked, target) {
1287
1687
  const where = target === null ? `The direction is titled "${preview.title}" and renders at ${preview.url}. Find what produces it.` : `It lives at ${target}.`;
1288
1688
  const pace = target === null ? `Once found, make the change and finish. ` : `Make the change in that file and finish. `;
1289
- const prompt = `In this project, change only the "${preview.title}" design direction. ${where}
1689
+ return `In this project, change only the "${preview.title}" design direction. ${where}
1690
+
1691
+ ${asked}
1290
1692
 
1291
- What to change: ${cleaned}
1693
+ ${pace}${SCOPE} The direction is already registered, so nothing needs re-registering.`;
1694
+ }
1695
+ function variantPrompt(preview, recorded, asked, target, leglasCommand) {
1696
+ const slot = variantSlot(preview.url);
1697
+ const parent = JSON.stringify(preview.title);
1698
+ const askedFor = JSON.stringify(recorded);
1699
+ const add = registrationCommand(leglasCommand);
1700
+ const source = target === null ? `Find what renders it first.` : `Its source is ${target}.`;
1701
+ const [make, register] = preview.file !== void 0 ? [
1702
+ `Copy that file to a new file beside it and make the change in the copy.`,
1703
+ ` ${add} --title "<name>" --file "<the new file>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
1704
+ ] : slot !== null ? [
1705
+ `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.`,
1706
+ ` ${add} --title "<name>" --url "/?v-${slot.surface}=<key>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
1707
+ ] : [
1708
+ `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.`,
1709
+ ` ${add} --title "<name>" --url "<the URL that shows it>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
1710
+ ];
1711
+ 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.
1712
+
1713
+ ${source} ${make}
1714
+
1715
+ ${asked}
1716
+
1717
+ Then register it, which is what puts it on the rail:
1718
+
1719
+ ${register}
1292
1720
 
1293
- ${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.
1721
+ 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.
1294
1722
 
1295
- 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.`;
1296
- return { prompt, target };
1723
+ ${SCOPE}`;
1297
1724
  }
1298
1725
  var REQUESTS_PATH = ".leglas/requests.json";
1726
+ var TERMINAL = ["failed", "cancelled"];
1727
+ function isTerminal(status) {
1728
+ return TERMINAL.includes(status);
1729
+ }
1730
+ var FAILURE_CODES = [
1731
+ "cancelled",
1732
+ "stopped",
1733
+ "missing-agent",
1734
+ "not-signed-in",
1735
+ "provider-overloaded",
1736
+ "provider-limit",
1737
+ "needs-trust",
1738
+ "not-registered",
1739
+ "agent-error"
1740
+ ];
1741
+ function failureOf(value) {
1742
+ if (typeof value !== "object" || value === null)
1743
+ return null;
1744
+ const entry = value;
1745
+ if (typeof entry.message !== "string" || entry.message === "")
1746
+ return null;
1747
+ if (entry.code === void 0 || !FAILURE_CODES.includes(entry.code))
1748
+ return null;
1749
+ return { code: entry.code, message: entry.message };
1750
+ }
1299
1751
  async function readRequests(cwd) {
1300
1752
  try {
1301
- const raw = await readFile4(join5(cwd, REQUESTS_PATH), "utf8");
1753
+ const raw = await readFile5(join6(cwd, REQUESTS_PATH), "utf8");
1302
1754
  const parsed2 = JSON.parse(raw);
1303
1755
  if (!Array.isArray(parsed2.requests))
1304
1756
  return [];
1305
1757
  return parsed2.requests.map((request, index) => {
1306
- const entry = request;
1758
+ const { failure: rawFailure, ...entry } = request;
1759
+ const status = entry.status === "picked-up" || entry.status === "failed" || entry.status === "cancelled" ? entry.status : "queued";
1760
+ const failure = isTerminal(status) ? failureOf(rawFailure) : null;
1307
1761
  return {
1308
1762
  ...entry,
1309
1763
  id: typeof entry.id === "string" ? entry.id : String(index),
1310
- status: entry.status === "picked-up" ? "picked-up" : "queued"
1764
+ status,
1765
+ mode: entry.mode === "variant" ? "variant" : "replace",
1766
+ ...failure === null ? {} : { failure }
1311
1767
  };
1312
1768
  });
1313
1769
  } catch {
@@ -1315,23 +1771,23 @@ async function readRequests(cwd) {
1315
1771
  }
1316
1772
  }
1317
1773
  async function writeQueue(cwd, requests) {
1318
- const path = join5(cwd, REQUESTS_PATH);
1319
- await mkdir3(dirname4(path), { recursive: true });
1320
- await writeFile3(path, `${JSON.stringify({ requests }, null, 2)}
1774
+ const path = join6(cwd, REQUESTS_PATH);
1775
+ await mkdir4(dirname5(path), { recursive: true });
1776
+ await writeFile4(path, `${JSON.stringify({ requests }, null, 2)}
1321
1777
  `, "utf8");
1322
1778
  }
1323
1779
  async function appendRequest(cwd, request) {
1324
1780
  await writeQueue(cwd, [
1325
1781
  ...await readRequests(cwd),
1326
- { ...request, id: randomBytes(6).toString("base64url"), status: "queued" }
1782
+ { ...request, id: randomBytes2(6).toString("base64url"), status: "queued" }
1327
1783
  ]);
1328
1784
  }
1329
1785
  async function collectRequests(cwd) {
1330
1786
  const requests = await readRequests(cwd);
1331
- const collected = requests.map((request) => ({ ...request, status: "picked-up" }));
1332
- if (requests.some((request) => request.status !== "picked-up"))
1787
+ const collected = requests.map((request) => isTerminal(request.status) ? request : { ...request, status: "picked-up" });
1788
+ if (requests.some((request) => request.status === "queued"))
1333
1789
  await writeQueue(cwd, collected);
1334
- return collected;
1790
+ return collected.filter((request) => !isTerminal(request.status));
1335
1791
  }
1336
1792
  async function markPickedUp(cwd, id) {
1337
1793
  const requests = await readRequests(cwd);
@@ -1340,6 +1796,17 @@ async function markPickedUp(cwd, id) {
1340
1796
  await writeQueue(cwd, requests.map((request) => request.id === id ? { ...request, status: "picked-up" } : request));
1341
1797
  return true;
1342
1798
  }
1799
+ async function markFailed(cwd, id, failure) {
1800
+ const requests = await readRequests(cwd);
1801
+ if (!requests.some((request) => request.id === id))
1802
+ return false;
1803
+ await writeQueue(cwd, requests.map((request) => request.id === id ? {
1804
+ ...request,
1805
+ status: failure.code === "cancelled" ? "cancelled" : "failed",
1806
+ failure
1807
+ } : request));
1808
+ return true;
1809
+ }
1343
1810
  async function removeRequest(cwd, id) {
1344
1811
  const requests = await readRequests(cwd);
1345
1812
  const remaining = requests.filter((request) => request.id !== id);
@@ -1350,7 +1817,7 @@ async function removeRequest(cwd, id) {
1350
1817
  }
1351
1818
  async function clearRequests(cwd) {
1352
1819
  const requests = await readRequests(cwd);
1353
- const pending = requests.filter((request) => request.status !== "picked-up");
1820
+ const pending = requests.filter((request) => request.status === "queued");
1354
1821
  const cleared = requests.length - pending.length;
1355
1822
  if (cleared > 0)
1356
1823
  await writeQueue(cwd, pending);
@@ -1359,10 +1826,13 @@ async function clearRequests(cwd) {
1359
1826
 
1360
1827
  // ../server/dist/runner.js
1361
1828
  import { spawn as nodeSpawn } from "child_process";
1829
+ import { readFile as readFile6 } from "fs/promises";
1830
+ import { join as join7 } from "path";
1362
1831
  var POLL_MS = 2e3;
1363
1832
  var OUTPUT_LINES = 20;
1833
+ var CANCEL_GRACE_MS = 5e3;
1364
1834
  var SESSION_TURNS_CAP = 8;
1365
- function resolveCommand(choice, prompt, sessionId = null) {
1835
+ function resolveCommand(choice, prompt, sessionId = null, registration = null) {
1366
1836
  if (choice.agent === null)
1367
1837
  return null;
1368
1838
  if (choice.agent === "custom") {
@@ -1374,12 +1844,13 @@ function resolveCommand(choice, prompt, sessionId = null) {
1374
1844
  return { agent: "custom", name: "Custom", ...commandFor(parsed2.template, prompt), resumed: false };
1375
1845
  }
1376
1846
  const adapter = KNOWN_AGENTS[choice.agent];
1847
+ const allow = registration !== null && "allowArgs" in adapter ? adapter.allowArgs(registration) : [];
1377
1848
  if (sessionId !== null && "resumeArgs" in adapter) {
1378
1849
  return {
1379
1850
  agent: choice.agent,
1380
1851
  name: adapter.name,
1381
1852
  command: adapter.binary,
1382
- args: adapter.resumeArgs(sessionId, prompt),
1853
+ args: [...adapter.resumeArgs(sessionId, prompt, choice.effort), ...allow],
1383
1854
  resumed: true
1384
1855
  };
1385
1856
  }
@@ -1387,7 +1858,7 @@ function resolveCommand(choice, prompt, sessionId = null) {
1387
1858
  agent: choice.agent,
1388
1859
  name: adapter.name,
1389
1860
  command: adapter.binary,
1390
- args: adapter.args(prompt),
1861
+ args: [...adapter.args(prompt, choice.effort), ...allow],
1391
1862
  resumed: false
1392
1863
  };
1393
1864
  }
@@ -1410,19 +1881,24 @@ function lineReader(stream, onLine) {
1410
1881
  return flush;
1411
1882
  }
1412
1883
  function defaultSpawn(command, args, options) {
1413
- return nodeSpawn(command, args, options);
1884
+ return nodeSpawn(command, args, { ...options, env: agentEnvironment() });
1414
1885
  }
1415
1886
  function startRunner(options) {
1416
1887
  const spawn5 = options.spawn ?? defaultSpawn;
1417
1888
  const setEvery = options.setInterval ?? ((callback, milliseconds) => setInterval(callback, milliseconds));
1418
1889
  const clearEvery = options.clearInterval ?? ((handle2) => clearInterval(handle2));
1419
1890
  const failed = /* @__PURE__ */ new Set();
1891
+ const setLater = options.setTimeout ?? ((callback, milliseconds) => {
1892
+ setTimeout(callback, milliseconds).unref?.();
1893
+ });
1420
1894
  let state = {
1421
1895
  running: false,
1422
1896
  requestId: null,
1423
1897
  agent: null,
1424
1898
  activity: null,
1425
- startedAt: null
1899
+ startedAt: null,
1900
+ stopping: false,
1901
+ waiting: null
1426
1902
  };
1427
1903
  let stopped = false;
1428
1904
  let ticking = null;
@@ -1430,15 +1906,30 @@ function startRunner(options) {
1430
1906
  let active = null;
1431
1907
  const sessions = /* @__PURE__ */ new Map();
1432
1908
  const idle = () => {
1433
- state = { running: false, requestId: null, agent: null, activity: null, startedAt: null };
1909
+ state = {
1910
+ running: false,
1911
+ requestId: null,
1912
+ agent: null,
1913
+ activity: null,
1914
+ startedAt: null,
1915
+ stopping: false,
1916
+ waiting: null
1917
+ };
1434
1918
  };
1435
1919
  const rememberLine = (lines, line) => {
1436
1920
  lines.push(line);
1437
1921
  if (lines.length > OUTPUT_LINES)
1438
1922
  lines.splice(0, lines.length - OUTPUT_LINES);
1439
1923
  };
1440
- const reportFailure = (request, error, lines) => {
1441
- console.error(`Leglas agent failed for ${request.title}: ${error}`);
1924
+ const reportFailure = async (request, failure, lines) => {
1925
+ await markFailed(options.cwd, request.id, failure).catch(() => {
1926
+ });
1927
+ failed.add(request.id);
1928
+ if (failure.code === "cancelled") {
1929
+ console.error(`Leglas stopped the run for ${request.title}.`);
1930
+ return;
1931
+ }
1932
+ console.error(`Leglas agent failed for ${request.title}: ${failure.message}`);
1442
1933
  for (const line of lines)
1443
1934
  console.error(` ${line}`);
1444
1935
  };
@@ -1456,19 +1947,31 @@ function startRunner(options) {
1456
1947
  error: error instanceof Error ? error.message : String(error)
1457
1948
  });
1458
1949
  }
1459
- const current = { child, requestId: request.id, cancelled: false };
1950
+ const current = {
1951
+ child,
1952
+ requestId: request.id,
1953
+ cancelled: false,
1954
+ abandon: () => {
1955
+ }
1956
+ };
1460
1957
  active = current;
1461
1958
  const stdoutFlush = lineReader(child.stdout, (line) => {
1462
1959
  rememberLine(lines, line);
1463
1960
  const sessionId = sessionFrom(resolved.agent, line);
1464
1961
  if (sessionId !== null)
1465
1962
  observed.sessionId = sessionId;
1963
+ const retry = retryFrom(resolved.agent, line);
1964
+ if (retry !== null) {
1965
+ observed.retry = retry;
1966
+ if (active === current)
1967
+ state = { ...state, waiting: retry };
1968
+ }
1466
1969
  const activity = activityFrom(resolved.agent, line, options.cwd);
1467
1970
  if (activity !== null) {
1468
1971
  if (activity.startsWith("editing"))
1469
1972
  observed.edited = true;
1470
1973
  if (active === current)
1471
- state = { ...state, activity };
1974
+ state = { ...state, activity, waiting: null };
1472
1975
  }
1473
1976
  });
1474
1977
  const stderrFlush = lineReader(child.stderr, (line) => rememberLine(lines, line));
@@ -1482,6 +1985,7 @@ function startRunner(options) {
1482
1985
  stderrFlush();
1483
1986
  resolve(outcome);
1484
1987
  };
1988
+ current.abandon = () => settle({ ok: false, error: "cancelled" });
1485
1989
  child.once("error", (error) => settle({ ok: false, error: error.message }));
1486
1990
  child.once("close", (code, signal) => {
1487
1991
  if (current.cancelled)
@@ -1495,10 +1999,12 @@ function startRunner(options) {
1495
1999
  active = null;
1496
2000
  });
1497
2001
  };
2002
+ const registered = () => readFile6(join7(options.cwd, LOCAL_PREVIEWS_PATH), "utf8").catch(() => null);
1498
2003
  const handle = async (request, choice) => {
1499
2004
  const session = choice.agent !== null ? sessions.get(choice.agent) ?? null : null;
1500
2005
  const continuable = session !== null && session.turns < SESSION_TURNS_CAP;
1501
- let resolved = resolveCommand(choice, request.prompt, continuable ? session.id : null);
2006
+ const registration = request.mode === "variant" && options.leglasCommand !== void 0 ? registrationCommand(options.leglasCommand) : null;
2007
+ let resolved = resolveCommand(choice, request.prompt, continuable ? session.id : null, registration);
1502
2008
  if (resolved === null)
1503
2009
  return;
1504
2010
  const lines = [];
@@ -1506,7 +2012,7 @@ function startRunner(options) {
1506
2012
  if (!await markPickedUp(options.cwd, request.id))
1507
2013
  return;
1508
2014
  if (stopped) {
1509
- failed.add(request.id);
2015
+ await reportFailure(request, classifyFailure({ agent: resolved.name, error: "stopped by shutdown" }), []);
1510
2016
  return;
1511
2017
  }
1512
2018
  state = {
@@ -1514,25 +2020,47 @@ function startRunner(options) {
1514
2020
  requestId: request.id,
1515
2021
  agent: resolved.name,
1516
2022
  activity: null,
1517
- startedAt: Date.now()
2023
+ startedAt: Date.now(),
2024
+ stopping: false,
2025
+ waiting: null
2026
+ };
2027
+ const observed = {
2028
+ sessionId: null,
2029
+ edited: false,
2030
+ retry: null
1518
2031
  };
1519
- const observed = { sessionId: null, edited: false };
2032
+ const before = request.mode === "variant" ? await registered() : null;
2033
+ const agent = resolved.name;
1520
2034
  let outcome = await runChild(request, resolved, lines, observed);
1521
- const cancelled = !outcome.ok && outcome.error === "cancelled";
1522
- if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && !cancelled && // Not redundant with the line above: a stop that lands between the
1523
- // first child settling and the retry starting finds no child to
1524
- // cancel, so nothing says "cancelled". Stopped still means stopped.
2035
+ const verdict = () => classifyFailure({
2036
+ agent,
2037
+ error: outcome.ok ? null : stopped && outcome.error === "cancelled" ? "stopped by shutdown" : outcome.error,
2038
+ exitCode: outcome.ok ? outcome.code : null,
2039
+ lines,
2040
+ retry: observed.retry
2041
+ });
2042
+ let failure = verdict();
2043
+ 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
2044
+ // child settling and the retry starting finds no child to cancel, so
2045
+ // nothing says "cancelled". Stopped still means stopped.
1525
2046
  !stopped) {
1526
2047
  sessions.delete(resolved.agent);
1527
- const cold = resolveCommand(choice, request.prompt);
2048
+ const cold = resolveCommand(choice, request.prompt, null, registration);
1528
2049
  if (cold !== null) {
1529
2050
  resolved = cold;
1530
2051
  observed.sessionId = null;
1531
- state = { ...state, activity: null };
2052
+ observed.retry = null;
2053
+ state = { ...state, activity: null, waiting: null };
1532
2054
  outcome = await runChild(request, resolved, lines, observed);
2055
+ failure = verdict();
1533
2056
  }
1534
2057
  }
1535
2058
  if (outcome.ok && outcome.code === 0) {
2059
+ if (request.mode === "variant" && await registered() === before) {
2060
+ sessions.delete(resolved.agent);
2061
+ await reportFailure(request, classifyFailure({ agent, error: "not-registered" }), lines);
2062
+ return;
2063
+ }
1536
2064
  if (observed.sessionId !== null) {
1537
2065
  const previous = sessions.get(resolved.agent);
1538
2066
  sessions.set(resolved.agent, {
@@ -1540,12 +2068,14 @@ function startRunner(options) {
1540
2068
  turns: resolved.resumed && previous?.id === observed.sessionId ? previous.turns + 1 : 1
1541
2069
  });
1542
2070
  }
2071
+ if (request.mode === "replace" && request.notes !== void 0) {
2072
+ await removeAnnotations(options.cwd, request.notes).catch(() => 0);
2073
+ }
1543
2074
  await removeRequest(options.cwd, request.id);
1544
2075
  return;
1545
2076
  }
1546
2077
  sessions.delete(resolved.agent);
1547
- failed.add(request.id);
1548
- reportFailure(request, outcome.ok ? `${resolved.command} exited ${outcome.code}` : outcome.error, lines);
2078
+ await reportFailure(request, failure, lines);
1549
2079
  } finally {
1550
2080
  idle();
1551
2081
  }
@@ -1579,12 +2109,23 @@ function startRunner(options) {
1579
2109
  return false;
1580
2110
  if (id !== void 0 && active.requestId !== id)
1581
2111
  return false;
1582
- active.cancelled = true;
1583
- failed.add(active.requestId);
2112
+ const current = active;
2113
+ current.cancelled = true;
2114
+ failed.add(current.requestId);
2115
+ state = { ...state, stopping: true, waiting: null };
1584
2116
  try {
1585
- active.child.kill("SIGTERM");
2117
+ current.child.kill("SIGTERM");
1586
2118
  } catch {
1587
2119
  }
2120
+ setLater(() => {
2121
+ if (active !== current)
2122
+ return;
2123
+ try {
2124
+ current.child.kill("SIGKILL");
2125
+ } catch {
2126
+ }
2127
+ current.abandon();
2128
+ }, CANCEL_GRACE_MS);
1588
2129
  return true;
1589
2130
  };
1590
2131
  const stop = () => {
@@ -1609,12 +2150,12 @@ function startRunner(options) {
1609
2150
  }
1610
2151
 
1611
2152
  // ../server/dist/renames.js
1612
- import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1613
- import { dirname as dirname5, join as join6 } from "path";
2153
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile5 } from "fs/promises";
2154
+ import { dirname as dirname6, join as join8 } from "path";
1614
2155
  var RENAMES_PATH = ".leglas/renames.json";
1615
2156
  async function readRenames(cwd) {
1616
2157
  try {
1617
- const raw = await readFile5(join6(cwd, RENAMES_PATH), "utf8");
2158
+ const raw = await readFile7(join8(cwd, RENAMES_PATH), "utf8");
1618
2159
  const parsed2 = JSON.parse(raw);
1619
2160
  if (parsed2.renames === null || typeof parsed2.renames !== "object")
1620
2161
  return {};
@@ -1624,9 +2165,9 @@ async function readRenames(cwd) {
1624
2165
  }
1625
2166
  }
1626
2167
  async function writeRenames(cwd, renames) {
1627
- const path = join6(cwd, RENAMES_PATH);
1628
- await mkdir4(dirname5(path), { recursive: true });
1629
- await writeFile4(path, `${JSON.stringify({ renames }, null, 2)}
2168
+ const path = join8(cwd, RENAMES_PATH);
2169
+ await mkdir5(dirname6(path), { recursive: true });
2170
+ await writeFile5(path, `${JSON.stringify({ renames }, null, 2)}
1630
2171
  `, "utf8");
1631
2172
  }
1632
2173
  function resolveTitle(input, titles, renames) {
@@ -1644,7 +2185,7 @@ function resolveTitle(input, titles, renames) {
1644
2185
  import { createReadStream, existsSync as existsSync2, statSync } from "fs";
1645
2186
  import http2 from "http";
1646
2187
  import net3 from "net";
1647
- import { extname, join as join7, normalize, relative as relative3 } from "path";
2188
+ import { extname, join as join9, normalize, relative as relative3 } from "path";
1648
2189
  var LEGLAS_PREFIX = "/leglas";
1649
2190
  var DEFAULT_PORT = 4100;
1650
2191
  var PORT_ATTEMPTS = 20;
@@ -1717,6 +2258,9 @@ function isTrustedMutation(req) {
1717
2258
  return false;
1718
2259
  }
1719
2260
  }
2261
+ function isEnded(request, failedIds) {
2262
+ return isTerminal(request.status) || failedIds.includes(request.id);
2263
+ }
1720
2264
  function hasJsonBody(req) {
1721
2265
  const contentType = req.headers["content-type"];
1722
2266
  return typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
@@ -1743,7 +2287,7 @@ function probe(target, timeoutMs = 1e3) {
1743
2287
  }
1744
2288
  function serveFrom(res, dir, relativePath) {
1745
2289
  const relative5 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
1746
- const candidate = join7(dir, relative5);
2290
+ const candidate = join9(dir, relative5);
1747
2291
  if (!candidate.startsWith(dir))
1748
2292
  return false;
1749
2293
  if (!existsSync2(candidate) || !statSync(candidate).isFile())
@@ -1828,7 +2372,7 @@ async function bind(server, requested) {
1828
2372
  throw new Error(`No free port between ${requested} and ${requested + PORT_ATTEMPTS - 1}.`);
1829
2373
  }
1830
2374
  async function startServer(options) {
1831
- const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
2375
+ const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), leglasCommand = "npx -y leglas", fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
1832
2376
  const target = config?.devServer ?? "http://localhost:3000";
1833
2377
  const proxy = createProxyHandler({ target });
1834
2378
  const bootConfigSnapshot = snapshotConfig(cwd);
@@ -1847,18 +2391,16 @@ async function startServer(options) {
1847
2391
  });
1848
2392
  return agentsInflight;
1849
2393
  };
1850
- const currentAgents = () => {
1851
- if (agentsCache === null)
2394
+ const currentAgents = (refresh = false) => {
2395
+ if (refresh || agentsCache === null || Date.now() - agentsCache.at > AGENTS_FRESH_MS) {
1852
2396
  return probeAgents();
1853
- if (Date.now() - agentsCache.at > AGENTS_FRESH_MS) {
1854
- void probeAgents().catch(() => {
1855
- });
1856
2397
  }
1857
2398
  return Promise.resolve(agentsCache.agents);
1858
2399
  };
1859
2400
  const server = http2.createServer((req, res) => {
1860
2401
  const url = req.url ?? "/";
1861
2402
  const path = url.split("?")[0] ?? "/";
2403
+ const query = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
1862
2404
  if (req.method === "POST" && path.startsWith(`${LEGLAS_PREFIX}/api/`) && !isTrustedMutation(req)) {
1863
2405
  return sendJson(res, 403, { ok: false, error: "Cross-origin API mutations are refused." });
1864
2406
  }
@@ -1945,20 +2487,51 @@ async function startServer(options) {
1945
2487
  } catch {
1946
2488
  return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
1947
2489
  }
2490
+ if (parsed2.mode !== void 0 && parsed2.mode !== "variant" && parsed2.mode !== "replace") {
2491
+ return sendJson(res, 400, {
2492
+ ok: false,
2493
+ error: 'mode must be "variant" or "replace".'
2494
+ });
2495
+ }
2496
+ const mode = parsed2.mode === "replace" ? "replace" : "variant";
1948
2497
  const localRead = await readLocalPreviews(cwd).catch(() => null);
1949
2498
  const local = localRead?.errors.length === 0 ? localRead.previews : [];
1950
2499
  const localTitles = new Set(local.map((entry) => entry.title));
1951
2500
  const bootConfig = config?.previews ?? [];
1952
2501
  const boot = localRead === null || localRead.errors.length > 0 ? bootConfig : bootConfig.filter((entry) => entry.local !== true || localTitles.has(entry.title));
1953
2502
  const preview = [...boot, ...local].find((entry) => entry.title === parsed2.title);
1954
- if (!preview || !parsed2.intent?.trim()) {
2503
+ if (!preview) {
1955
2504
  return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
1956
2505
  }
1957
- const composed = composeRequest(preview, parsed2.intent);
2506
+ const notes = annotationsFor(await readAnnotations(cwd).catch(() => []), preview.title);
2507
+ if (!parsed2.intent?.trim() && notes.length === 0) {
2508
+ return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
2509
+ }
2510
+ const intent = (parsed2.intent ?? "").trim();
2511
+ const live = (await readRequests(cwd).catch(() => [])).filter((entry) => entry.status === "queued" || entry.status === "picked-up");
2512
+ const sameNotes = (entry) => {
2513
+ const before = [...entry.notes ?? []].sort().join(",");
2514
+ return before === notes.map((note) => note.id).sort().join(",");
2515
+ };
2516
+ if (live.some((entry) => entry.title === preview.title && entry.intent === intent && // The same words in the other mode are not the same request:
2517
+ // one forks the direction and the other rewrites it. Only a
2518
+ // genuine repeat is refused.
2519
+ (entry.mode ?? "replace") === mode && sameNotes(entry))) {
2520
+ return sendJson(res, 409, {
2521
+ ok: false,
2522
+ duplicate: true,
2523
+ error: `That exact change to ${preview.title} is already waiting.`
2524
+ });
2525
+ }
2526
+ const composed = composeRequest(preview, intent, mode, notes, leglasCommand);
1958
2527
  void appendRequest(cwd, {
1959
2528
  title: preview.title,
1960
2529
  url: preview.url,
1961
- intent: parsed2.intent.trim(),
2530
+ intent,
2531
+ // The ids travel with the request so a change made in place can
2532
+ // forget the notes it answered. A fork leaves them where they are:
2533
+ // the direction they point at was not touched.
2534
+ ...notes.length === 0 ? {} : { notes: notes.map((entry) => entry.id) },
1962
2535
  ...composed
1963
2536
  }).then(() => {
1964
2537
  runner?.nudge();
@@ -1967,10 +2540,14 @@ async function startServer(options) {
1967
2540
  });
1968
2541
  }
1969
2542
  if (path === `${LEGLAS_PREFIX}/api/agents` && req.method === "GET") {
1970
- return void Promise.all([currentAgents(), readAgentChoice(cwd)]).then(([agents, choice]) => sendJson(res, 200, {
2543
+ return void Promise.all([
2544
+ currentAgents(query.get("refresh") === "1"),
2545
+ readAgentChoice(cwd)
2546
+ ]).then(([agents, choice]) => sendJson(res, 200, {
1971
2547
  agents,
1972
2548
  choice: choice.agent,
1973
- customRun: choice.run
2549
+ customRun: choice.run,
2550
+ effort: choice.effort
1974
2551
  }));
1975
2552
  }
1976
2553
  if (path === `${LEGLAS_PREFIX}/api/agent` && req.method === "POST") {
@@ -1998,7 +2575,17 @@ async function startServer(options) {
1998
2575
  if (parsed2.run !== void 0 && typeof parsed2.run !== "string") {
1999
2576
  return sendJson(res, 400, { ok: false, error: "The custom run command must be a string." });
2000
2577
  }
2578
+ const effort = parsed2.effort === null || isAgentEffort(parsed2.effort) ? parsed2.effort : void 0;
2579
+ if (parsed2.effort !== void 0 && effort === void 0) {
2580
+ return sendJson(res, 400, { ok: false, error: "Effort must be a supported level or null." });
2581
+ }
2001
2582
  if (parsed2.agent === "custom") {
2583
+ if (effort !== void 0) {
2584
+ return sendJson(res, 400, {
2585
+ ok: false,
2586
+ error: "Custom agents manage effort in their own command."
2587
+ });
2588
+ }
2002
2589
  if (typeof parsed2.run !== "string") {
2003
2590
  return sendJson(res, 400, { ok: false, error: "A custom agent needs a run command." });
2004
2591
  }
@@ -2007,7 +2594,16 @@ async function startServer(options) {
2007
2594
  return sendJson(res, 400, { ok: false, error: template.error });
2008
2595
  return void saveAgentChoice(cwd, { agent: "custom", run: parsed2.run }).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 500, { ok: false, error: "Agent choice could not be saved." }));
2009
2596
  }
2010
- return void saveAgentChoice(cwd, { agent: parsed2.agent }).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 500, { ok: false, error: "Agent choice could not be saved." }));
2597
+ if (effort !== void 0 && effort !== null && !KNOWN_AGENTS[parsed2.agent].efforts.includes(effort)) {
2598
+ return sendJson(res, 400, {
2599
+ ok: false,
2600
+ error: `${KNOWN_AGENTS[parsed2.agent].name} does not expose an effort override.`
2601
+ });
2602
+ }
2603
+ return void saveAgentChoice(cwd, {
2604
+ agent: parsed2.agent,
2605
+ ...effort === void 0 ? {} : { effort }
2606
+ }).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 500, { ok: false, error: "Agent choice could not be saved." }));
2011
2607
  });
2012
2608
  }
2013
2609
  if (path === `${LEGLAS_PREFIX}/api/watch` && req.method === "POST") {
@@ -2034,21 +2630,33 @@ async function startServer(options) {
2034
2630
  agent: null,
2035
2631
  activity: null,
2036
2632
  startedAt: null,
2633
+ stopping: false,
2634
+ waiting: null,
2037
2635
  failedIds: []
2038
2636
  };
2039
2637
  return void readRequests(cwd).then((requests) => sendJson(res, 200, {
2040
- requests: requests.map(({ id, title, intent, status }) => ({
2638
+ requests: requests.map(({ id, title, intent, status, failure }) => ({
2041
2639
  id,
2042
2640
  title,
2043
2641
  intent,
2044
- status: snapshot.running && snapshot.requestId === id ? "running" : snapshot.failedIds.includes(id) ? "failed" : status
2642
+ // The run in flight is the one thing the file cannot know. After
2643
+ // that the file is the record, including across a restart, and the
2644
+ // process-local failed set only covers a request whose verdict
2645
+ // could not be written.
2646
+ status: snapshot.running && snapshot.requestId === id ? "running" : status === "queued" && snapshot.failedIds.includes(id) ? "failed" : status,
2647
+ failure: failure ?? null
2045
2648
  })),
2046
2649
  agent: {
2047
2650
  attached: externallyAttached(),
2048
2651
  running: snapshot.running,
2049
2652
  name: snapshot.running ? snapshot.agent : null,
2050
2653
  activity: snapshot.running ? snapshot.activity : null,
2051
- startedAt: snapshot.running ? snapshot.startedAt : null
2654
+ startedAt: snapshot.running ? snapshot.startedAt : null,
2655
+ // A stop that has been asked for but not yet obeyed. The card
2656
+ // says so rather than going on describing a live run.
2657
+ stopping: snapshot.running && snapshot.stopping,
2658
+ // Why a run that looks stalled is stalled, while it is stalled.
2659
+ waiting: snapshot.running ? snapshot.waiting : null
2052
2660
  }
2053
2661
  }));
2054
2662
  }
@@ -2091,8 +2699,8 @@ async function startServer(options) {
2091
2699
  if (request === void 0) {
2092
2700
  return sendJson(res, 404, { ok: false, error: "No such request." });
2093
2701
  }
2094
- if (!(runner?.snapshot().failedIds.includes(request.id) ?? false)) {
2095
- return sendJson(res, 400, { ok: false, error: "Only a failed request can be retried." });
2702
+ if (!isEnded(request, runner?.snapshot().failedIds ?? [])) {
2703
+ return sendJson(res, 400, { ok: false, error: "Only an ended request can be run again." });
2096
2704
  }
2097
2705
  try {
2098
2706
  if (!await removeRequest(cwd, request.id)) {
@@ -2103,7 +2711,11 @@ async function startServer(options) {
2103
2711
  url: request.url,
2104
2712
  intent: request.intent,
2105
2713
  target: request.target,
2106
- prompt: request.prompt
2714
+ prompt: request.prompt,
2715
+ // The stored prompt already carries the mode's instructions; the
2716
+ // field travels with it so the queue keeps saying which kind of
2717
+ // change this is.
2718
+ ...request.mode === void 0 ? {} : { mode: request.mode }
2107
2719
  });
2108
2720
  runner?.nudge();
2109
2721
  return sendJson(res, 200, { ok: true });
@@ -2112,6 +2724,65 @@ async function startServer(options) {
2112
2724
  }
2113
2725
  });
2114
2726
  }
2727
+ if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "GET") {
2728
+ return void readAnnotations(cwd).then((annotations) => sendJson(res, 200, { annotations }));
2729
+ }
2730
+ if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "POST") {
2731
+ if (!hasJsonBody(req)) {
2732
+ return sendJson(res, 400, { ok: false, error: "A note must be JSON." });
2733
+ }
2734
+ let body = "";
2735
+ req.on("data", (chunk) => body += chunk);
2736
+ return void req.on("end", async () => {
2737
+ let parsed2;
2738
+ try {
2739
+ parsed2 = JSON.parse(body || "{}");
2740
+ } catch {
2741
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2742
+ }
2743
+ if (typeof parsed2.title !== "string" || parsed2.title.trim() === "") {
2744
+ return sendJson(res, 400, { ok: false, error: "A note needs a direction." });
2745
+ }
2746
+ const anchor = anchorFrom(parsed2.anchor);
2747
+ if (anchor === null) {
2748
+ return sendJson(res, 400, { ok: false, error: "A note needs something to point at." });
2749
+ }
2750
+ try {
2751
+ const annotation = await addAnnotation(cwd, {
2752
+ anchor,
2753
+ note: typeof parsed2.note === "string" ? parsed2.note.trim() : "",
2754
+ title: parsed2.title
2755
+ });
2756
+ return sendJson(res, 200, { ok: true, annotation });
2757
+ } catch {
2758
+ return sendJson(res, 500, { ok: false, error: "The note could not be kept." });
2759
+ }
2760
+ });
2761
+ }
2762
+ if (path === `${LEGLAS_PREFIX}/api/annotations/delete` && req.method === "POST") {
2763
+ if (!hasJsonBody(req)) {
2764
+ return sendJson(res, 400, { ok: false, error: "Delete must be JSON." });
2765
+ }
2766
+ let body = "";
2767
+ req.on("data", (chunk) => body += chunk);
2768
+ return void req.on("end", async () => {
2769
+ let parsed2;
2770
+ try {
2771
+ parsed2 = JSON.parse(body || "{}");
2772
+ } catch {
2773
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2774
+ }
2775
+ const ids = Array.isArray(parsed2.ids) ? parsed2.ids.filter((entry) => typeof entry === "string") : [];
2776
+ if (ids.length === 0) {
2777
+ return sendJson(res, 400, { ok: false, error: "Body needs the notes to forget." });
2778
+ }
2779
+ try {
2780
+ return sendJson(res, 200, { ok: true, deleted: await removeAnnotations(cwd, ids) });
2781
+ } catch {
2782
+ return sendJson(res, 500, { ok: false, error: "The notes could not be forgotten." });
2783
+ }
2784
+ });
2785
+ }
2115
2786
  if (path === `${LEGLAS_PREFIX}/api/requests/dismiss` && req.method === "POST") {
2116
2787
  if (!hasJsonBody(req)) {
2117
2788
  return sendJson(res, 400, { ok: false, error: "Dismiss must be JSON." });
@@ -2128,8 +2799,9 @@ async function startServer(options) {
2128
2799
  if (typeof parsed2.id !== "string") {
2129
2800
  return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
2130
2801
  }
2131
- if (!(runner?.snapshot().failedIds.includes(parsed2.id) ?? false)) {
2132
- return sendJson(res, 400, { ok: false, error: "Only a failed request can be dismissed." });
2802
+ const target2 = (await readRequests(cwd)).find((entry) => entry.id === parsed2.id);
2803
+ if (target2 === void 0 || !isEnded(target2, runner?.snapshot().failedIds ?? [])) {
2804
+ return sendJson(res, 400, { ok: false, error: "Only an ended request can be dismissed." });
2133
2805
  }
2134
2806
  try {
2135
2807
  if (!await removeRequest(cwd, parsed2.id)) {
@@ -2204,7 +2876,7 @@ async function startServer(options) {
2204
2876
  proxy.upgrade(req, socket, head);
2205
2877
  });
2206
2878
  const port = await bind(server, options.port ?? DEFAULT_PORT);
2207
- runner = startRunner({ cwd, externallyAttached });
2879
+ runner = startRunner({ cwd, externallyAttached, leglasCommand });
2208
2880
  let closePromise = null;
2209
2881
  return {
2210
2882
  port,
@@ -2229,7 +2901,7 @@ async function runClassify(options, deps) {
2229
2901
  const declared = await Promise.all(
2230
2902
  options.changes.map(async (change) => ({
2231
2903
  ...change,
2232
- exists: await stat(join8(options.cwd, change.path)).then(
2904
+ exists: await stat(join10(options.cwd, change.path)).then(
2233
2905
  () => true,
2234
2906
  () => false
2235
2907
  )
@@ -2494,8 +3166,8 @@ function runExplore(options, deps) {
2494
3166
  }
2495
3167
 
2496
3168
  // src/run-init.ts
2497
- import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
2498
- import { join as join9 } from "path";
3169
+ import { readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
3170
+ import { join as join11 } from "path";
2499
3171
 
2500
3172
  // src/init.ts
2501
3173
  var AGENTS_MARKER_START = "<!-- leglas:start -->";
@@ -2513,6 +3185,11 @@ direction. Build it directly. A planning or approval step before implementing
2513
3185
  costs more than the work itself, and the directions on screen are the thing
2514
3186
  being asked for.
2515
3187
 
3188
+ A request that says it came from the running Leglas interface has already
3189
+ completed exploration, request collection and the live-server check. Follow
3190
+ the exact source and registration command in that request directly. Do not
3191
+ repeat \`explore\`, \`requests\`, \`list\`, CLI help/version checks or server startup.
3192
+
2516
3193
  When asked for design variations, alternatives, or "a few options":
2517
3194
 
2518
3195
  1. **Add beside what exists. Never replace it.** Every direction has to render
@@ -2628,7 +3305,7 @@ ${AGENTS_SECTION}`
2628
3305
  // src/run-init.ts
2629
3306
  async function readIfPresent(path) {
2630
3307
  try {
2631
- return await readFile6(path, "utf8");
3308
+ return await readFile8(path, "utf8");
2632
3309
  } catch {
2633
3310
  return null;
2634
3311
  }
@@ -2636,18 +3313,18 @@ async function readIfPresent(path) {
2636
3313
  async function runInit(options, deps) {
2637
3314
  const existingConfig = findConfigFile(options.cwd);
2638
3315
  const plan = planInit({
2639
- agents: await readIfPresent(join9(options.cwd, "AGENTS.md")),
3316
+ agents: await readIfPresent(join11(options.cwd, "AGENTS.md")),
2640
3317
  config: existingConfig === null ? null : "present",
2641
- gitignore: await readIfPresent(join9(options.cwd, ".gitignore")),
3318
+ gitignore: await readIfPresent(join11(options.cwd, ".gitignore")),
2642
3319
  force: options.force
2643
3320
  });
2644
3321
  const touched = [];
2645
- for (const write of plan.writes) {
2646
- await writeFile5(join9(options.cwd, write.path), write.contents, "utf8");
2647
- touched.push(write.path);
3322
+ for (const write2 of plan.writes) {
3323
+ await writeFile6(join11(options.cwd, write2.path), write2.contents, "utf8");
3324
+ touched.push(write2.path);
2648
3325
  }
2649
3326
  if (plan.gitignore !== null) {
2650
- await writeFile5(join9(options.cwd, ".gitignore"), plan.gitignore, "utf8");
3327
+ await writeFile6(join11(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2651
3328
  touched.push(".gitignore");
2652
3329
  }
2653
3330
  if (options.json) {
@@ -2667,8 +3344,8 @@ async function runInit(options, deps) {
2667
3344
 
2668
3345
  // src/run-keep.ts
2669
3346
  import { existsSync as existsSync3 } from "fs";
2670
- import { mkdir as mkdir5, readFile as readFile7, rm as rm2, writeFile as writeFile6 } from "fs/promises";
2671
- import { dirname as dirname6, join as join10 } from "path";
3347
+ import { mkdir as mkdir6, readFile as readFile9, rm as rm2, writeFile as writeFile7 } from "fs/promises";
3348
+ import { dirname as dirname7, join as join12 } from "path";
2672
3349
 
2673
3350
  // src/keep.ts
2674
3351
  import { basename as basename2, extname as extname2, normalize as normalize2 } from "path";
@@ -2767,18 +3444,18 @@ async function runKeep(options, deps) {
2767
3444
  if (!resolved.ok) return fail(resolved.error);
2768
3445
  const plan = planKeep({ title: resolved.title, previews, to: options.to });
2769
3446
  if (!plan.ok) return fail(plan.error);
2770
- const from = join10(options.cwd, plan.move.from);
2771
- const to = join10(options.cwd, plan.move.to);
3447
+ const from = join12(options.cwd, plan.move.from);
3448
+ const to = join12(options.cwd, plan.move.to);
2772
3449
  if (!existsSync3(from)) {
2773
3450
  return fail(`${plan.move.from} does not exist. Nothing to keep.`);
2774
3451
  }
2775
3452
  if (existsSync3(to)) {
2776
3453
  return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
2777
3454
  }
2778
- const source = await readFile7(from, "utf8");
2779
- await mkdir5(dirname6(to), { recursive: true });
2780
- await writeFile6(to, renameExport(source, plan.exportName), "utf8");
2781
- await rm2(join10(options.cwd, plan.removeDir), { recursive: true, force: true });
3455
+ const source = await readFile9(from, "utf8");
3456
+ await mkdir6(dirname7(to), { recursive: true });
3457
+ await writeFile7(to, renameExport(source, plan.exportName), "utf8");
3458
+ await rm2(join12(options.cwd, plan.removeDir), { recursive: true, force: true });
2782
3459
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
2783
3460
  if (options.json) {
2784
3461
  deps.log(
@@ -2813,11 +3490,11 @@ async function runKeep(options, deps) {
2813
3490
 
2814
3491
  // src/run-new.ts
2815
3492
  import { existsSync as existsSync4 } from "fs";
2816
- import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
2817
- import { dirname as dirname7, join as join11 } from "path";
3493
+ import { mkdir as mkdir7, readFile as readFile10, writeFile as writeFile8 } from "fs/promises";
3494
+ import { dirname as dirname8, join as join13 } from "path";
2818
3495
  async function readIfPresent2(path) {
2819
3496
  try {
2820
- return await readFile8(path, "utf8");
3497
+ return await readFile10(path, "utf8");
2821
3498
  } catch {
2822
3499
  return null;
2823
3500
  }
@@ -2825,19 +3502,19 @@ async function readIfPresent2(path) {
2825
3502
  async function runNew(options, deps) {
2826
3503
  let from;
2827
3504
  if (options.from !== void 0) {
2828
- const contents = await readIfPresent2(join11(options.cwd, options.from));
3505
+ const contents = await readIfPresent2(join13(options.cwd, options.from));
2829
3506
  if (contents === null) {
2830
- const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
2831
- if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
2832
- else deps.log(message);
3507
+ const message2 = `${options.from} does not exist, so there is nothing to use as the baseline.`;
3508
+ if (options.json) deps.log(JSON.stringify({ ok: false, error: message2 }));
3509
+ else deps.log(message2);
2833
3510
  return { exitCode: 1, written: [] };
2834
3511
  }
2835
3512
  from = { path: options.from, contents };
2836
3513
  }
2837
3514
  const plan = planNew({
2838
3515
  surface: options.surface,
2839
- packageJson: await readIfPresent2(join11(options.cwd, "package.json")),
2840
- gitignore: await readIfPresent2(join11(options.cwd, ".gitignore")),
3516
+ packageJson: await readIfPresent2(join13(options.cwd, "package.json")),
3517
+ gitignore: await readIfPresent2(join13(options.cwd, ".gitignore")),
2841
3518
  from
2842
3519
  });
2843
3520
  const fail = (error) => {
@@ -2853,26 +3530,26 @@ async function runNew(options, deps) {
2853
3530
  deps.log(JSON.stringify({ ok: true, files: plan.writes, instructions: plan.instructions, previews: plan.previews }));
2854
3531
  return { exitCode: 0, written: [] };
2855
3532
  }
2856
- for (const write of plan.writes) {
2857
- deps.log(`--- ${write.path}`);
2858
- deps.log(write.contents);
3533
+ for (const write2 of plan.writes) {
3534
+ deps.log(`--- ${write2.path}`);
3535
+ deps.log(write2.contents);
2859
3536
  }
2860
3537
  deps.log(plan.instructions);
2861
3538
  return { exitCode: 0, written: [] };
2862
3539
  }
2863
- const existing = plan.writes.filter((write) => existsSync4(join11(options.cwd, write.path)));
3540
+ const existing = plan.writes.filter((write2) => existsSync4(join13(options.cwd, write2.path)));
2864
3541
  if (existing.length > 0) {
2865
3542
  return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
2866
3543
  }
2867
3544
  const written = [];
2868
- for (const write of plan.writes) {
2869
- const target = join11(options.cwd, write.path);
2870
- await mkdir6(dirname7(target), { recursive: true });
2871
- await writeFile7(target, write.contents, "utf8");
2872
- written.push(write.path);
3545
+ for (const write2 of plan.writes) {
3546
+ const target = join13(options.cwd, write2.path);
3547
+ await mkdir7(dirname8(target), { recursive: true });
3548
+ await writeFile8(target, write2.contents, "utf8");
3549
+ written.push(write2.path);
2873
3550
  }
2874
3551
  if (plan.gitignore !== null) {
2875
- await writeFile7(join11(options.cwd, ".gitignore"), plan.gitignore, "utf8");
3552
+ await writeFile8(join13(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2876
3553
  written.push(".gitignore");
2877
3554
  }
2878
3555
  if (options.json) {
@@ -2893,21 +3570,21 @@ async function runNew(options, deps) {
2893
3570
  }
2894
3571
 
2895
3572
  // src/run-previews.ts
2896
- import { readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
2897
- import { join as join12 } from "path";
3573
+ import { readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
3574
+ import { join as join14 } from "path";
2898
3575
  function envelope(deps, ok, body) {
2899
3576
  deps.log(JSON.stringify({ ok, ...body }));
2900
3577
  }
2901
3578
  async function ensureIgnored(cwd) {
2902
- const path = join12(cwd, ".gitignore");
3579
+ const path = join14(cwd, ".gitignore");
2903
3580
  let current = null;
2904
3581
  try {
2905
- current = await readFile9(path, "utf8");
3582
+ current = await readFile11(path, "utf8");
2906
3583
  } catch {
2907
3584
  current = null;
2908
3585
  }
2909
3586
  const next = ignoreEntry(current);
2910
- if (next !== null) await writeFile8(path, next, "utf8");
3587
+ if (next !== null) await writeFile9(path, next, "utf8");
2911
3588
  }
2912
3589
  async function runAdd(options, deps) {
2913
3590
  const loaded = await loadConfig(options.cwd);
@@ -2931,7 +3608,8 @@ async function runAdd(options, deps) {
2931
3608
  tags: options.preview.tags,
2932
3609
  branch: options.preview.branch,
2933
3610
  file: options.preview.file,
2934
- basedOn: options.preview.basedOn
3611
+ basedOn: options.preview.basedOn,
3612
+ askedFor: options.preview.askedFor
2935
3613
  },
2936
3614
  shared
2937
3615
  );
@@ -2991,6 +3669,7 @@ async function runList(options, deps) {
2991
3669
  note: preview.note ?? null,
2992
3670
  tags: preview.tags,
2993
3671
  basedOn: preview.basedOn ?? null,
3672
+ askedFor: preview.askedFor ?? null,
2994
3673
  local: preview.local,
2995
3674
  branch: preview.branch ?? null,
2996
3675
  file: preview.file ?? null
@@ -3153,23 +3832,23 @@ async function runShow(options, deps) {
3153
3832
 
3154
3833
  // src/run-watch.ts
3155
3834
  import { spawn as spawn3 } from "child_process";
3156
- import { mkdir as mkdir7, readFile as readFile10, writeFile as writeFile9 } from "fs/promises";
3157
- import { dirname as dirname8, join as join13 } from "path";
3835
+ import { mkdir as mkdir8, readFile as readFile12, writeFile as writeFile10 } from "fs/promises";
3836
+ import { dirname as dirname9, join as join15 } from "path";
3158
3837
  var POLL_MS2 = 2e3;
3159
3838
  var HEARTBEAT_TIMEOUT_MS = 1e3;
3160
3839
  async function saveTemplate(cwd, run3) {
3161
- const path = join13(cwd, WATCH_PATH);
3840
+ const path = join15(cwd, WATCH_PATH);
3162
3841
  let config = {};
3163
3842
  try {
3164
- const parsed2 = JSON.parse(await readFile10(path, "utf8"));
3843
+ const parsed2 = JSON.parse(await readFile12(path, "utf8"));
3165
3844
  if (typeof parsed2 === "object" && parsed2 !== null && !Array.isArray(parsed2)) {
3166
3845
  config = parsed2;
3167
3846
  }
3168
3847
  } catch {
3169
3848
  }
3170
3849
  config.run = run3;
3171
- await mkdir7(dirname8(path), { recursive: true });
3172
- await writeFile9(path, `${JSON.stringify(config, null, 2)}
3850
+ await mkdir8(dirname9(path), { recursive: true });
3851
+ await writeFile10(path, `${JSON.stringify(config, null, 2)}
3173
3852
  `, "utf8");
3174
3853
  }
3175
3854
  function spawnAgent(command, args, cwd) {
@@ -3208,7 +3887,7 @@ async function runWatch(options, deps) {
3208
3887
  const adapter = KNOWN_AGENTS[saved.agent];
3209
3888
  template = {
3210
3889
  command: adapter.binary,
3211
- args: adapter.terminalArgs(PROMPT_TOKEN)
3890
+ args: adapter.terminalArgs(PROMPT_TOKEN, saved.effort)
3212
3891
  };
3213
3892
  shownCommand2 = [template.command, ...template.args].join(" ");
3214
3893
  synthesizedAgent = adapter.name;
@@ -3255,9 +3934,13 @@ async function runWatch(options, deps) {
3255
3934
  return;
3256
3935
  }
3257
3936
  failed.add(request.id);
3258
- deps.error(
3259
- ` failed ${request.title}: ${outcome.ok ? `${command} exited ${outcome.code}` : outcome.error}`
3260
- );
3937
+ const failure = classifyFailure({
3938
+ agent: (shownCommand2.split(/\s+/)[0] ?? command).split("/").pop() ?? command,
3939
+ error: outcome.ok ? null : outcome.error,
3940
+ exitCode: outcome.ok ? outcome.code : null
3941
+ });
3942
+ await markFailed(options.cwd, request.id, failure);
3943
+ deps.error(` failed ${request.title}: ${failure.message}`);
3261
3944
  deps.error(" Left in the queue and not retried.");
3262
3945
  };
3263
3946
  const tick = async () => {
@@ -3304,18 +3987,28 @@ async function runWatch(options, deps) {
3304
3987
  // src/run.ts
3305
3988
  import { existsSync as existsSync5 } from "fs";
3306
3989
  import { createRequire } from "module";
3307
- import { basename as basename3, dirname as dirname9, join as join14, relative as relative4 } from "path";
3990
+ import { basename as basename3, dirname as dirname10, join as join16, relative as relative4 } from "path";
3308
3991
  import { fileURLToPath } from "url";
3309
3992
  function findShellDir() {
3310
- const bundled = join14(dirname9(fileURLToPath(import.meta.url)), "shell");
3311
- if (existsSync5(join14(bundled, "index.html"))) return bundled;
3993
+ const bundled = join16(dirname10(fileURLToPath(import.meta.url)), "shell");
3994
+ if (existsSync5(join16(bundled, "index.html"))) return bundled;
3312
3995
  try {
3313
3996
  const require2 = createRequire(import.meta.url);
3314
- return dirname9(require2.resolve("@leglas/shell/dist/index.html"));
3997
+ return dirname10(require2.resolve("@leglas/shell/dist/index.html"));
3315
3998
  } catch {
3316
3999
  return null;
3317
4000
  }
3318
4001
  }
4002
+ function shellWord(value) {
4003
+ if (/^[A-Za-z0-9_./:=+\\-]+$/.test(value)) return value;
4004
+ if (process.platform === "win32") return `"${value.replaceAll('"', '""')}"`;
4005
+ return `'${value.replaceAll("'", `'\\''`)}'`;
4006
+ }
4007
+ function embeddedLeglasCommand() {
4008
+ const entry = join16(dirname10(fileURLToPath(import.meta.url)), "bin.js");
4009
+ if (!existsSync5(entry)) return "npx -y leglas";
4010
+ return [process.execPath, entry].map(shellWord).join(" ");
4011
+ }
3319
4012
  async function run2(options, deps) {
3320
4013
  const loaded = await loadConfig(options.cwd);
3321
4014
  const local = await readLocalPreviews(options.cwd);
@@ -3345,7 +4038,7 @@ async function run2(options, deps) {
3345
4038
  const fileMounts = /* @__PURE__ */ new Map();
3346
4039
  for (const preview of merged?.previews ?? []) {
3347
4040
  if (preview.file !== void 0) {
3348
- const absolute = join14(options.cwd, preview.file);
4041
+ const absolute = join16(options.cwd, preview.file);
3349
4042
  if (!existsSync5(absolute)) {
3350
4043
  worktreeErrors.push(
3351
4044
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -3356,7 +4049,7 @@ async function run2(options, deps) {
3356
4049
  for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
3357
4050
  slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
3358
4051
  }
3359
- fileMounts.set(slug, dirname9(absolute));
4052
+ fileMounts.set(slug, dirname10(absolute));
3360
4053
  previews.push({
3361
4054
  ...preview,
3362
4055
  url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
@@ -3397,6 +4090,7 @@ async function run2(options, deps) {
3397
4090
  // directory does. Either way saved layout survives a port change.
3398
4091
  project: loaded.path ?? options.cwd,
3399
4092
  cwd: options.cwd,
4093
+ leglasCommand: embeddedLeglasCommand(),
3400
4094
  ...options.port === void 0 ? {} : { port: options.port }
3401
4095
  });
3402
4096
  const url = `${server.url}${LEGLAS_PREFIX}`;
@@ -3502,6 +4196,7 @@ Options for add
3502
4196
  --branch <name> Back the preview with a checkout of this git branch
3503
4197
  --file <path> Preview a plain HTML file served by Leglas itself
3504
4198
  --based-on <title> The direction this is a variant of; groups the family
4199
+ --asked-for <text> The change that was asked for, in the words that were typed
3505
4200
  `;
3506
4201
  function version() {
3507
4202
  const require2 = createRequire2(import.meta.url);