leglas 0.2.0 → 0.4.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
@@ -45,7 +45,7 @@ function parseNew(rest) {
45
45
  if (surface === void 0) {
46
46
  return {
47
47
  kind: "error",
48
- message: "leglas new needs a surface name, for example: leglas new hero"
48
+ message: "leglas new needs a surface name, for example: npx leglas new hero"
49
49
  };
50
50
  }
51
51
  return { kind: "new", surface, print, json, from };
@@ -128,7 +128,7 @@ function parseClassify(rest) {
128
128
  if (changes.length === 0) {
129
129
  return {
130
130
  kind: "error",
131
- message: "leglas classify needs what the direction will touch, for example: leglas classify --change package.json --rewrite src/theme.css"
131
+ message: "leglas classify needs what the direction will touch, for example: npx leglas classify --change package.json --rewrite src/theme.css"
132
132
  };
133
133
  }
134
134
  return { kind: "classify", changes, json };
@@ -203,7 +203,7 @@ function parseArgs(argv) {
203
203
  if (title === void 0) {
204
204
  return {
205
205
  kind: "error",
206
- message: 'leglas keep needs a direction title, for example: leglas keep "Aurora" --to src/components/hero.tsx'
206
+ message: 'leglas keep needs a direction title, for example: npx leglas keep "Aurora" --to src/components/hero.tsx'
207
207
  };
208
208
  }
209
209
  if (to === void 0) {
@@ -253,7 +253,7 @@ function parseArgs(argv) {
253
253
  if (surface === void 0) {
254
254
  return {
255
255
  kind: "error",
256
- message: "leglas explore needs a surface name, for example: leglas explore hero --count 6"
256
+ message: "leglas explore needs a surface name, for example: npx leglas explore hero --count 6"
257
257
  };
258
258
  }
259
259
  return { kind: "explore", surface, count, basedOn, json };
@@ -294,7 +294,7 @@ function parseArgs(argv) {
294
294
  if (title === void 0) {
295
295
  return {
296
296
  kind: "error",
297
- message: 'leglas show needs a direction title, for example: leglas show "Aurora" --json'
297
+ message: 'leglas show needs a direction title, for example: npx leglas show "Aurora" --json'
298
298
  };
299
299
  }
300
300
  return { kind: "show", title, json };
@@ -556,8 +556,8 @@ The set exists to be chosen from, and the choice only means something if the dir
556
556
  One trap, seen every time this goes wrong: a set collapses toward whichever direction is built first. Decide all ${count} before building any, and if two would read as the same direction at a glance, replace one of them.` : `Build ${count} variations of the "${basedOn}" direction for "${surface}".
557
557
 
558
558
  The set exists to pick a variant of a direction already chosen, so every variation must stay recognisably that direction. The trap here is drift: change enough and the comparison stops being about the variant. Vary each one deliberately and hold everything else still; if a variation grows into a new direction, it belongs in its own exploration instead.`;
559
- const register = basedOn === null ? ` leglas add --title "<name>" --url "/?v-${slug}=<key>" --note "<the idea, one line>"` : ` leglas add --title "<name>" --url "/?v-${slug}=<key>" --based-on ${JSON.stringify(basedOn)} --note "<the idea, one line>"`;
560
- const mechanics = `Each one is its own file under .leglas/variants/${slug}/, listed in the DIRECTIONS map in that folder's switch file. If there is no switch file yet, run \`leglas new ${slug}\` first. Register each one the moment it renders, not the set at the end. The interface picks a registration up within seconds, so whoever asked watches the set fill in:
559
+ const register = basedOn === null ? ` npx leglas add --title "<name>" --url "/?v-${slug}=<key>" --note "<the idea, one line>"` : ` npx leglas add --title "<name>" --url "/?v-${slug}=<key>" --based-on ${JSON.stringify(basedOn)} --note "<the idea, one line>"`;
560
+ const mechanics = `Each one is its own file under .leglas/variants/${slug}/, listed in the DIRECTIONS map in that folder's switch file. If there is no switch file yet, run \`npx leglas new ${slug}\` first. Register each one the moment it renders, not the set at the end. The interface picks a registration up within seconds, so whoever asked watches the set fill in:
561
561
 
562
562
  ${register}
563
563
 
@@ -609,46 +609,48 @@ When asked for design variations, alternatives, or "a few options":
609
609
  If a direction cannot be additive, because it must change dependencies,
610
610
  change build configuration, or rewrite an existing file's behaviour, ask
611
611
  where it should live before writing it:
612
- \`leglas classify --change package.json --rewrite src/theme.css --json\`.
612
+ \`npx leglas classify --change package.json --rewrite src/theme.css --json\`.
613
613
  When the answer is \`checkout\`, build the direction on its own git branch
614
- and register it with \`leglas add --title "\u2026" --url "/" --branch <branch>\`
614
+ and register it with \`npx leglas add --title "\u2026" --url "/" --branch <branch>\`
615
615
  (the config needs \`devCommand\` with \`{port}\`). Everything below is the
616
616
  ordinary, in-app path.
617
- 2. Run \`leglas explore <surface> --count <n>\` first, adding
617
+ 2. Run \`npx leglas explore <surface> --count <n>\` first, adding
618
618
  \`--based-on "<title>"\` when the user wants variations of a direction they
619
619
  already like. It prints what the set needs and how to register it. In
620
620
  short: new directions must genuinely disagree with each other, variants of
621
621
  one must not, and either way decide the whole set before building any of
622
622
  it. The designs themselves are yours.
623
623
  3. If the surface has no switcher yet, run
624
- \`leglas new <surface> --from <the component that renders it today>\`. It
624
+ \`npx leglas new <surface> --from <the component that renders it today>\`. It
625
625
  writes one under \`.leglas/variants/<surface>/\` and prints the single line
626
626
  to add where that surface renders. \`--from\` makes the baseline re-export
627
627
  the real component rather than copying it, so it stays live.
628
- 4. Before building, make sure the interface is up. If \`leglas\` is not
629
- already running, tell the user to run it, and hand them the URL now
630
- rather than when the set is done: the rail picks up each registration
631
- within seconds, so they get to watch the exploration fill in.
628
+ 4. Before building, make sure the interface is up. If Leglas is not
629
+ already running, tell the user to run \`npx leglas\`, and hand them the
630
+ URL now rather than when the set is done: the rail picks up each
631
+ registration within seconds, so they get to watch the exploration fill in.
632
632
  5. Build one direction at a time: its own file beside the others in
633
633
  \`.leglas/variants/<surface>/\`, listed in the \`DIRECTIONS\` map in that
634
634
  folder's \`switch\` file, then registered the moment it renders:
635
- \`leglas add --title "Aurora" --url "/?v-<surface>=aurora" --note "One line on the idea."\`
635
+ \`npx leglas add --title "Aurora" --url "/?v-<surface>=aurora" --note "One line on the idea."\`
636
636
  Register each direction as it lands, never the whole set at the end. To
637
637
  the user watching the rail, a batch at the end is minutes of nothing and
638
638
  then everything at once.
639
639
 
640
- When the user asks to change one direction, check \`leglas requests --json\`
640
+ When the user asks to change one direction, check \`npx leglas requests --json\`
641
641
  first: they may have described it from the interface, and the request names the
642
- exact file. Clear the queue with \`leglas requests --clear\` once done.
642
+ exact file. Acknowledge them with \`npx leglas requests --clear\` once done: it
643
+ drops what you collected and reports anything the user typed while you worked,
644
+ which is yours to collect and do next.
643
645
 
644
646
  If the user wants requests handled the moment they are typed, without relaying
645
- each one, tell them about \`leglas watch --run "claude -p {prompt}"\` (any
647
+ each one, tell them about \`npx leglas watch --run "claude -p {prompt}"\` (any
646
648
  agent command works; {prompt} receives the request). It runs in their
647
649
  terminal, hands each request to that command as it arrives, and the interface
648
650
  shows the request's progress.
649
651
 
650
652
  When the user picks a winner, run
651
- \`leglas keep "<title>" --to <path in real source>\`. It moves that direction
653
+ \`npx leglas keep "<title>" --to <path in real source>\`. It moves that direction
652
654
  out of the ignored directory, deletes the rest of the exploration, and drops
653
655
  them from the rail. Then change their component to use the kept component
654
656
  instead of the switcher.
@@ -659,14 +661,14 @@ Useful to know:
659
661
  ships. Move a direction into real source only when it wins.
660
662
  - If the project has no running app yet, a direction can be a plain HTML
661
663
  file: write it under \`.leglas/pages/\` and register it with
662
- \`leglas add --title "Aurora" --file .leglas/pages/aurora.html\`. Leglas
664
+ \`npx leglas add --title "Aurora" --file .leglas/pages/aurora.html\`. Leglas
663
665
  serves the file itself, so no dev server is needed. Sibling assets in the
664
666
  same directory resolve normally.
665
667
  - Titles identify previews and must be unique. The user may rename one in the
666
668
  rail, which renames it on their machine only; the commands answer to either
667
669
  name, so use whichever they said.
668
- - \`leglas list\` shows every direction, shared and local.
669
- - \`leglas show "<title>" --json\` answers for one of them: the file behind it,
670
+ - \`npx leglas list\` shows every direction, shared and local.
671
+ - \`npx leglas show "<title>" --json\` answers for one of them: the file behind it,
670
672
  the variants based on it, what it is being compared against, and anything
671
673
  they have asked for that is not done yet. Run it when handed a direction you
672
674
  did not register yourself.
@@ -676,7 +678,7 @@ Useful to know:
676
678
  ${AGENTS_MARKER_END}
677
679
  `;
678
680
  var STARTER_CONFIG = `// Previews are URLs of your own app. Add one per direction you want to
679
- // compare, then run \`leglas\`.
681
+ // compare, then run \`npx leglas\`.
680
682
  export default {
681
683
  // Where your dev server is. Override at the command line with --user-port.
682
684
  devServer: "http://localhost:3000",
@@ -843,6 +845,360 @@ function normalizeConfig(raw, options = {}) {
843
845
  };
844
846
  }
845
847
 
848
+ // ../server/dist/agent-command.js
849
+ var WATCH_PATH = ".leglas/watch.json";
850
+ var PROMPT_TOKEN = "{prompt}";
851
+ var EXAMPLE = `npx leglas watch --run "claude -p ${PROMPT_TOKEN}"`;
852
+ function tokenize(template) {
853
+ const tokens = [];
854
+ let current = "";
855
+ let started = false;
856
+ let quote = null;
857
+ for (const character of template) {
858
+ if (quote !== null) {
859
+ if (character === quote)
860
+ quote = null;
861
+ else
862
+ current += character;
863
+ continue;
864
+ }
865
+ if (character === '"' || character === "'") {
866
+ quote = character;
867
+ started = true;
868
+ continue;
869
+ }
870
+ if (/\s/.test(character)) {
871
+ if (started)
872
+ tokens.push(current);
873
+ current = "";
874
+ started = false;
875
+ continue;
876
+ }
877
+ current += character;
878
+ started = true;
879
+ }
880
+ if (quote !== null) {
881
+ return { ok: false, error: `The agent command has an unclosed ${quote} quote.` };
882
+ }
883
+ if (started)
884
+ tokens.push(current);
885
+ return { ok: true, tokens };
886
+ }
887
+ function parseTemplate(raw) {
888
+ const tokenized = tokenize(raw);
889
+ if (!tokenized.ok)
890
+ return tokenized;
891
+ const { tokens } = tokenized;
892
+ const [command, ...args] = tokens;
893
+ if (command === void 0) {
894
+ return { ok: false, error: `Watch needs an agent command, for example: ${EXAMPLE}` };
895
+ }
896
+ if (tokens.some((token) => token !== PROMPT_TOKEN && token.includes(PROMPT_TOKEN))) {
897
+ return {
898
+ ok: false,
899
+ error: `${PROMPT_TOKEN} must stand as a word of its own, for example: ${EXAMPLE}`
900
+ };
901
+ }
902
+ const placeholders = tokens.filter((token) => token === PROMPT_TOKEN).length;
903
+ if (placeholders > 1) {
904
+ return {
905
+ ok: false,
906
+ error: `The agent command takes ${PROMPT_TOKEN} once, for example: ${EXAMPLE}`
907
+ };
908
+ }
909
+ if (command === PROMPT_TOKEN) {
910
+ return {
911
+ ok: false,
912
+ error: `The agent command must name a program before ${PROMPT_TOKEN}, for example: ${EXAMPLE}`
913
+ };
914
+ }
915
+ return { ok: true, template: { command, args } };
916
+ }
917
+ function commandFor(template, prompt) {
918
+ if (!template.args.includes(PROMPT_TOKEN)) {
919
+ return { command: template.command, args: [...template.args, prompt] };
920
+ }
921
+ return {
922
+ command: template.command,
923
+ args: template.args.map((argument) => argument === PROMPT_TOKEN ? prompt : argument)
924
+ };
925
+ }
926
+ function nextRequest(requests, failed) {
927
+ return requests.find((request) => request.status === "queued" && !failed.has(request.id)) ?? null;
928
+ }
929
+
930
+ // ../server/dist/agents.js
931
+ import { spawn } from "child_process";
932
+ import { constants } from "fs";
933
+ import { access, mkdir, readFile, writeFile } from "fs/promises";
934
+ import { delimiter, dirname, isAbsolute, join, relative } from "path";
935
+ var KNOWN_AGENTS = {
936
+ claude: {
937
+ name: "Claude",
938
+ binary: "claude",
939
+ args: (prompt) => [
940
+ "-p",
941
+ prompt,
942
+ "--output-format",
943
+ "stream-json",
944
+ "--verbose",
945
+ "--permission-mode",
946
+ "acceptEdits"
947
+ ],
948
+ terminalArgs: (prompt) => [
949
+ "-p",
950
+ prompt,
951
+ "--permission-mode",
952
+ "acceptEdits"
953
+ ],
954
+ resumeArgs: (sessionId, prompt) => [
955
+ "-p",
956
+ "--resume",
957
+ sessionId,
958
+ prompt,
959
+ "--output-format",
960
+ "stream-json",
961
+ "--verbose",
962
+ "--permission-mode",
963
+ "acceptEdits"
964
+ ],
965
+ // Every stream-json event names its session.
966
+ sessionFrom: (event) => typeof event.session_id === "string" && event.session_id !== "" ? event.session_id : null,
967
+ authArgs: ["auth", "status"],
968
+ // `claude auth status` prints JSON with a loggedIn boolean. Only that
969
+ // field decides; any other shape stays unknown.
970
+ authVerdict: (result) => {
971
+ try {
972
+ const parsed = record(JSON.parse(result.stdout));
973
+ if (parsed?.loggedIn === true)
974
+ return "ok";
975
+ if (parsed?.loggedIn === false)
976
+ return "signed-out";
977
+ } catch {
978
+ }
979
+ return "unknown";
980
+ }
981
+ },
982
+ codex: {
983
+ name: "Codex",
984
+ binary: "codex",
985
+ args: (prompt) => ["exec", "--json", "-s", "workspace-write", prompt],
986
+ terminalArgs: (prompt) => ["exec", "-s", "workspace-write", prompt],
987
+ // 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) => [
990
+ "exec",
991
+ "resume",
992
+ sessionId,
993
+ "--json",
994
+ prompt
995
+ ],
996
+ sessionFrom: (event) => event.type === "thread.started" && typeof event.thread_id === "string" ? event.thread_id : null,
997
+ authArgs: ["login", "status"],
998
+ // `codex login status` exits 0 when logged in and nonzero when not.
999
+ authVerdict: (result) => result.code === 0 ? "ok" : "signed-out"
1000
+ },
1001
+ cursor: {
1002
+ name: "Cursor",
1003
+ binary: "cursor-agent",
1004
+ args: (prompt) => ["-p", prompt, "--output-format", "stream-json"],
1005
+ terminalArgs: (prompt) => ["-p", prompt],
1006
+ authArgs: ["status"],
1007
+ // UNVERIFIED: cursor-agent was not available on the build machine. The
1008
+ // reading is deliberately loose, and anything ambiguous stays unknown.
1009
+ authVerdict: (result) => {
1010
+ if (/logged in|signed in/i.test(result.stdout))
1011
+ return "ok";
1012
+ if (result.code !== 0 || /not logged in|log in|sign in/i.test(result.stdout))
1013
+ return "signed-out";
1014
+ return "unknown";
1015
+ }
1016
+ }
1017
+ };
1018
+ var PROBE_TIMEOUT_MS = 3e3;
1019
+ function execProbe(binary, args) {
1020
+ return new Promise((resolve) => {
1021
+ let child;
1022
+ try {
1023
+ child = spawn(binary, [...args], { shell: false, stdio: ["ignore", "pipe", "ignore"] });
1024
+ } catch {
1025
+ return resolve(null);
1026
+ }
1027
+ let stdout = "";
1028
+ child.stdout?.on("data", (chunk) => {
1029
+ if (stdout.length < 4096)
1030
+ stdout += chunk.toString();
1031
+ });
1032
+ const deadline = setTimeout(() => child.kill("SIGKILL"), PROBE_TIMEOUT_MS);
1033
+ child.once("error", () => {
1034
+ clearTimeout(deadline);
1035
+ resolve(null);
1036
+ });
1037
+ child.once("close", (code, signal) => {
1038
+ clearTimeout(deadline);
1039
+ resolve(signal !== null ? null : { code: code ?? 0, stdout });
1040
+ });
1041
+ });
1042
+ }
1043
+ async function pathLookup(binary) {
1044
+ const entries = (process.env.PATH ?? "").split(delimiter).filter((entry) => entry !== "");
1045
+ const extensions = process.platform === "win32" ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((entry) => entry !== "") : [""];
1046
+ for (const entry of entries) {
1047
+ for (const extension of extensions) {
1048
+ try {
1049
+ await access(join(entry, `${binary}${extension}`), constants.X_OK);
1050
+ return true;
1051
+ } catch {
1052
+ }
1053
+ }
1054
+ }
1055
+ return false;
1056
+ }
1057
+ async function detectAgents(lookup = pathLookup, probe2 = execProbe) {
1058
+ const entries = Object.entries(KNOWN_AGENTS);
1059
+ return Promise.all(entries.map(async ([id, adapter]) => {
1060
+ const available = await lookup(adapter.binary).catch(() => false);
1061
+ if (!available)
1062
+ return { id, name: adapter.name, available, auth: "unknown" };
1063
+ const result = await probe2(adapter.binary, adapter.authArgs).catch(() => null);
1064
+ return {
1065
+ id,
1066
+ name: adapter.name,
1067
+ available,
1068
+ auth: result === null ? "unknown" : adapter.authVerdict(result)
1069
+ };
1070
+ }));
1071
+ }
1072
+ function record(value) {
1073
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
1074
+ }
1075
+ function shownPath(value, cwd) {
1076
+ if (typeof value !== "string" || value === "")
1077
+ return null;
1078
+ if (!isAbsolute(value))
1079
+ return value;
1080
+ return relative(cwd, value) || ".";
1081
+ }
1082
+ function shownCommand(value) {
1083
+ let command = Array.isArray(value) ? value.filter((part) => typeof part === "string").join(" ") : typeof value === "string" ? value : "";
1084
+ command = command.trim();
1085
+ const wrapped = /^(?:\S*\/)?(?:bash|sh|zsh)\s+-l?c\s+([\s\S]*)$/.exec(command);
1086
+ if (wrapped?.[1] !== void 0) {
1087
+ command = wrapped[1].trim();
1088
+ const quote = command[0];
1089
+ if ((quote === "'" || quote === '"') && command.endsWith(quote) && command.length > 1) {
1090
+ command = command.slice(1, -1);
1091
+ }
1092
+ }
1093
+ command = (command.split("\n")[0] ?? "").replace(/\s+/g, " ").trim();
1094
+ if (command === "")
1095
+ return null;
1096
+ return command.length > 48 ? `${command.slice(0, 47)}\u2026` : command;
1097
+ }
1098
+ function claudeActivity(event, cwd) {
1099
+ if (event.type !== "assistant")
1100
+ return null;
1101
+ const message = record(event.message);
1102
+ if (message === null || !Array.isArray(message.content))
1103
+ return null;
1104
+ for (const rawBlock of message.content) {
1105
+ const block = record(rawBlock);
1106
+ if (block?.type !== "tool_use" || typeof block.name !== "string")
1107
+ continue;
1108
+ const input = record(block.input);
1109
+ if (["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(block.name)) {
1110
+ const path = shownPath(input?.file_path ?? input?.notebook_path, cwd);
1111
+ return path === null ? `using ${block.name}` : `editing ${path}`;
1112
+ }
1113
+ if (block.name === "Read") {
1114
+ const path = shownPath(input?.file_path ?? input?.path, cwd);
1115
+ return path === null ? "using Read" : `reading ${path}`;
1116
+ }
1117
+ if (block.name === "Bash") {
1118
+ const command = shownCommand(input?.command);
1119
+ return command === null ? "running a command" : `running ${command}`;
1120
+ }
1121
+ if (block.name === "Grep" || block.name === "Glob")
1122
+ return "searching the project";
1123
+ return `using ${block.name}`;
1124
+ }
1125
+ return null;
1126
+ }
1127
+ function codexActivity(event, cwd) {
1128
+ if (event.type !== "item.started" && event.type !== "item.completed")
1129
+ return null;
1130
+ const item = record(event.item);
1131
+ if (item === null)
1132
+ return null;
1133
+ if (item.type === "command_execution") {
1134
+ const command = shownCommand(item.command);
1135
+ return command === null ? "running a command" : `running ${command}`;
1136
+ }
1137
+ if (item.type !== "file_change")
1138
+ return null;
1139
+ const first = Array.isArray(item.changes) ? record(item.changes[0]) : null;
1140
+ const path = shownPath(first?.path ?? item.path, cwd);
1141
+ return path === null ? null : `editing ${path}`;
1142
+ }
1143
+ function activityFrom(agent, line, cwd = process.cwd()) {
1144
+ let event;
1145
+ try {
1146
+ event = record(JSON.parse(line));
1147
+ } catch {
1148
+ return null;
1149
+ }
1150
+ if (event === null)
1151
+ return null;
1152
+ if (agent === "claude")
1153
+ return claudeActivity(event, cwd);
1154
+ if (agent === "codex")
1155
+ return codexActivity(event, cwd);
1156
+ if (agent === "cursor")
1157
+ return claudeActivity(event, cwd);
1158
+ return null;
1159
+ }
1160
+ function sessionFrom(agent, line) {
1161
+ if (agent !== "claude" && agent !== "codex")
1162
+ return null;
1163
+ let event;
1164
+ try {
1165
+ event = record(JSON.parse(line));
1166
+ } catch {
1167
+ return null;
1168
+ }
1169
+ if (event === null)
1170
+ return null;
1171
+ return KNOWN_AGENTS[agent].sessionFrom(event);
1172
+ }
1173
+ function isAgentChoice(value) {
1174
+ return value === "custom" || typeof value === "string" && Object.hasOwn(KNOWN_AGENTS, value);
1175
+ }
1176
+ async function readWatchConfig(cwd) {
1177
+ try {
1178
+ const parsed = JSON.parse(await readFile(join(cwd, WATCH_PATH), "utf8"));
1179
+ return record(parsed) ?? {};
1180
+ } catch {
1181
+ return {};
1182
+ }
1183
+ }
1184
+ async function readAgentChoice(cwd) {
1185
+ const config = await readWatchConfig(cwd);
1186
+ return {
1187
+ agent: isAgentChoice(config.agent) ? config.agent : null,
1188
+ run: typeof config.run === "string" && config.run !== "" ? config.run : null
1189
+ };
1190
+ }
1191
+ async function saveAgentChoice(cwd, choice) {
1192
+ const config = await readWatchConfig(cwd);
1193
+ config.agent = choice.agent;
1194
+ if (choice.run !== void 0)
1195
+ config.run = choice.run;
1196
+ const path = join(cwd, WATCH_PATH);
1197
+ await mkdir(dirname(path), { recursive: true });
1198
+ await writeFile(path, `${JSON.stringify(config, null, 2)}
1199
+ `, "utf8");
1200
+ }
1201
+
846
1202
  // ../server/dist/classify.js
847
1203
  var MANIFESTS = /* @__PURE__ */ new Set([
848
1204
  "package.json",
@@ -880,12 +1236,12 @@ function isExplorationFile(path) {
880
1236
  }
881
1237
  var CHECKOUT_STEPS = [
882
1238
  "Build the direction on its own branch: git switch -c <branch>, commit it there, switch back.",
883
- 'Register it: leglas add --title "<title>" --url "/" --branch <branch>.',
1239
+ 'Register it: npx leglas add --title "<title>" --url "/" --branch <branch>.',
884
1240
  "Make sure the config sets devCommand (with {port}), so Leglas can start the checkout."
885
1241
  ];
886
1242
  var IN_APP_STEPS = [
887
1243
  "Author it additively under .leglas/variants/<surface>/, beside the existing directions.",
888
- 'Register it: leglas add --title "<title>" --url "/?v-<surface>=<direction>".'
1244
+ 'Register it: npx leglas add --title "<title>" --url "/?v-<surface>=<direction>".'
889
1245
  ];
890
1246
  function classifyDirection(input) {
891
1247
  const checkout = (reason) => ({ level: "checkout", reason, steps: CHECKOUT_STEPS });
@@ -910,7 +1266,7 @@ function classifyDirection(input) {
910
1266
 
911
1267
  // ../server/dist/find-config.js
912
1268
  import { existsSync } from "fs";
913
- import { dirname, join, parse } from "path";
1269
+ import { dirname as dirname2, join as join2, parse } from "path";
914
1270
  var CONFIG_BASENAMES = [
915
1271
  "leglas.config.ts",
916
1272
  "leglas.config.mjs",
@@ -922,13 +1278,13 @@ function findConfigFile(startDir) {
922
1278
  let dir = startDir;
923
1279
  for (; ; ) {
924
1280
  for (const basename4 of CONFIG_BASENAMES) {
925
- const candidate = join(dir, basename4);
1281
+ const candidate = join2(dir, basename4);
926
1282
  if (existsSync(candidate))
927
1283
  return candidate;
928
1284
  }
929
1285
  if (dir === root)
930
1286
  return null;
931
- const parent = dirname(dir);
1287
+ const parent = dirname2(dir);
932
1288
  if (parent === dir)
933
1289
  return null;
934
1290
  dir = parent;
@@ -936,19 +1292,19 @@ function findConfigFile(startDir) {
936
1292
  }
937
1293
 
938
1294
  // ../server/dist/load-config.js
939
- import { readFile } from "fs/promises";
940
- import { relative } from "path";
1295
+ import { readFile as readFile2 } from "fs/promises";
1296
+ import { relative as relative2 } from "path";
941
1297
  import { pathToFileURL } from "url";
942
1298
  async function loadConfig(cwd) {
943
1299
  const path = findConfigFile(cwd);
944
1300
  if (path === null) {
945
1301
  return { ...normalizeConfig(void 0), path: null };
946
1302
  }
947
- const label = relative(cwd, path) || path;
1303
+ const label = relative2(cwd, path) || path;
948
1304
  let exported;
949
1305
  try {
950
1306
  if (path.endsWith(".json")) {
951
- exported = JSON.parse(await readFile(path, "utf8"));
1307
+ exported = JSON.parse(await readFile2(path, "utf8"));
952
1308
  } else {
953
1309
  const module = await import(pathToFileURL(path).href);
954
1310
  if (!("default" in module)) {
@@ -969,14 +1325,14 @@ async function loadConfig(cwd) {
969
1325
  }
970
1326
 
971
1327
  // ../server/dist/local-previews.js
972
- import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
973
- import { dirname as dirname2, join as join2 } from "path";
1328
+ import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1329
+ import { dirname as dirname3, join as join3 } from "path";
974
1330
  var LOCAL_PREVIEWS_PATH = ".leglas/previews.json";
975
1331
  async function readLocalPreviews(cwd) {
976
- const path = join2(cwd, LOCAL_PREVIEWS_PATH);
1332
+ const path = join3(cwd, LOCAL_PREVIEWS_PATH);
977
1333
  let raw;
978
1334
  try {
979
- raw = await readFile2(path, "utf8");
1335
+ raw = await readFile3(path, "utf8");
980
1336
  } catch {
981
1337
  return { previews: [], errors: [] };
982
1338
  }
@@ -1025,9 +1381,9 @@ async function addLocalPreview(cwd, input, shared) {
1025
1381
  if (check.config === null) {
1026
1382
  return { ok: false, error: check.errors.join(" ") };
1027
1383
  }
1028
- const path = join2(cwd, LOCAL_PREVIEWS_PATH);
1029
- await mkdir(dirname2(path), { recursive: true });
1030
- await writeFile(path, `${JSON.stringify({ previews: [...existing.previews.map(toStored), candidate] }, null, 2)}
1384
+ const path = join3(cwd, LOCAL_PREVIEWS_PATH);
1385
+ await mkdir2(dirname3(path), { recursive: true });
1386
+ await writeFile2(path, `${JSON.stringify({ previews: [...existing.previews.map(toStored), candidate] }, null, 2)}
1031
1387
  `, "utf8");
1032
1388
  return { ok: true };
1033
1389
  }
@@ -1040,9 +1396,9 @@ async function dropLocalPreviews(cwd, titles) {
1040
1396
  const keep = existing.previews.filter((preview) => !titles.includes(preview.title));
1041
1397
  if (keep.length === existing.previews.length)
1042
1398
  return 0;
1043
- const path = join2(cwd, LOCAL_PREVIEWS_PATH);
1044
- await mkdir(dirname2(path), { recursive: true });
1045
- await writeFile(path, `${JSON.stringify({ previews: keep.map(toStored) }, null, 2)}
1399
+ const path = join3(cwd, LOCAL_PREVIEWS_PATH);
1400
+ await mkdir2(dirname3(path), { recursive: true });
1401
+ await writeFile2(path, `${JSON.stringify({ previews: keep.map(toStored) }, null, 2)}
1046
1402
  `, "utf8");
1047
1403
  return existing.previews.length - keep.length;
1048
1404
  }
@@ -1111,10 +1467,10 @@ ${headers}\r
1111
1467
  }
1112
1468
 
1113
1469
  // ../server/dist/worktree.js
1114
- import { execFile, spawn } from "child_process";
1470
+ import { execFile, spawn as spawn2 } from "child_process";
1115
1471
  import { rm } from "fs/promises";
1116
1472
  import net2 from "net";
1117
- import { join as join3 } from "path";
1473
+ import { join as join4 } from "path";
1118
1474
  import { promisify } from "util";
1119
1475
  var run = promisify(execFile);
1120
1476
  var WORKTREES_DIR = ".leglas/worktrees";
@@ -1151,7 +1507,7 @@ function answers(port) {
1151
1507
  }
1152
1508
  async function startWorktree(options) {
1153
1509
  const readyTimeoutMs = options.readyTimeoutMs ?? 9e4;
1154
- const path = join3(options.cwd, WORKTREES_DIR, worktreeSlug(options.branch));
1510
+ const path = join4(options.cwd, WORKTREES_DIR, worktreeSlug(options.branch));
1155
1511
  const log = options.onLog ?? (() => {
1156
1512
  });
1157
1513
  await rm(path, { recursive: true, force: true });
@@ -1212,7 +1568,7 @@ async function startAppProcess(options) {
1212
1568
  const port = await freePort();
1213
1569
  let child;
1214
1570
  try {
1215
- child = spawn(substitutePort(options.devCommand, port), {
1571
+ child = spawn2(substitutePort(options.devCommand, port), {
1216
1572
  cwd: options.cwd,
1217
1573
  shell: true,
1218
1574
  // Own process group, so stopping kills the shell and whatever it spawned
@@ -1252,9 +1608,9 @@ async function startAppProcess(options) {
1252
1608
  }
1253
1609
 
1254
1610
  // ../server/dist/requests.js
1255
- import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1611
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1256
1612
  import { randomBytes } from "crypto";
1257
- import { dirname as dirname3, join as join4 } from "path";
1613
+ import { dirname as dirname4, join as join5 } from "path";
1258
1614
  var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
1259
1615
  function targetFor(url) {
1260
1616
  if (!url.startsWith("/"))
@@ -1280,17 +1636,20 @@ function composeRequest(preview, intent) {
1280
1636
  const target = preview.file ?? targetFor(preview.url);
1281
1637
  const cleaned = intent.trim();
1282
1638
  const where = target === null ? `The direction is titled "${preview.title}" and renders at ${preview.url}. Find what produces it.` : `It lives at ${target}.`;
1639
+ const pace = target === null ? `Once found, make the change and finish. ` : `Make the change in that file and finish. `;
1283
1640
  const prompt = `In this project, change only the "${preview.title}" design direction. ${where}
1284
1641
 
1285
1642
  What to change: ${cleaned}
1286
1643
 
1644
+ ${pace}This is a scoped design change: no test run, no build, and no survey of the rest of the project is needed. The result is checked visually in a live preview, not by tooling.
1645
+
1287
1646
  Leave every other direction exactly as it is; they are alternatives being compared side by side, so changing a sibling destroys the comparison. The direction is already registered, so nothing needs re-registering. Keep the change additive: do not rewrite shared components that other directions rely on.`;
1288
1647
  return { prompt, target };
1289
1648
  }
1290
1649
  var REQUESTS_PATH = ".leglas/requests.json";
1291
1650
  async function readRequests(cwd) {
1292
1651
  try {
1293
- const raw = await readFile3(join4(cwd, REQUESTS_PATH), "utf8");
1652
+ const raw = await readFile4(join5(cwd, REQUESTS_PATH), "utf8");
1294
1653
  const parsed = JSON.parse(raw);
1295
1654
  if (!Array.isArray(parsed.requests))
1296
1655
  return [];
@@ -1307,9 +1666,9 @@ async function readRequests(cwd) {
1307
1666
  }
1308
1667
  }
1309
1668
  async function writeQueue(cwd, requests) {
1310
- const path = join4(cwd, REQUESTS_PATH);
1311
- await mkdir2(dirname3(path), { recursive: true });
1312
- await writeFile2(path, `${JSON.stringify({ requests }, null, 2)}
1669
+ const path = join5(cwd, REQUESTS_PATH);
1670
+ await mkdir3(dirname4(path), { recursive: true });
1671
+ await writeFile3(path, `${JSON.stringify({ requests }, null, 2)}
1313
1672
  `, "utf8");
1314
1673
  }
1315
1674
  async function appendRequest(cwd, request) {
@@ -1341,16 +1700,272 @@ async function removeRequest(cwd, id) {
1341
1700
  return true;
1342
1701
  }
1343
1702
  async function clearRequests(cwd) {
1344
- await writeQueue(cwd, []);
1703
+ const requests = await readRequests(cwd);
1704
+ const pending = requests.filter((request) => request.status !== "picked-up");
1705
+ const cleared = requests.length - pending.length;
1706
+ if (cleared > 0)
1707
+ await writeQueue(cwd, pending);
1708
+ return { cleared, pending: pending.length };
1709
+ }
1710
+
1711
+ // ../server/dist/runner.js
1712
+ import { spawn as nodeSpawn } from "child_process";
1713
+ var POLL_MS = 2e3;
1714
+ var OUTPUT_LINES = 20;
1715
+ var SESSION_TURNS_CAP = 8;
1716
+ function resolveCommand(choice, prompt, sessionId = null) {
1717
+ if (choice.agent === null)
1718
+ return null;
1719
+ if (choice.agent === "custom") {
1720
+ if (choice.run === null)
1721
+ return null;
1722
+ const parsed = parseTemplate(choice.run);
1723
+ if (!parsed.ok)
1724
+ return null;
1725
+ return { agent: "custom", name: "Custom", ...commandFor(parsed.template, prompt), resumed: false };
1726
+ }
1727
+ const adapter = KNOWN_AGENTS[choice.agent];
1728
+ if (sessionId !== null && "resumeArgs" in adapter) {
1729
+ return {
1730
+ agent: choice.agent,
1731
+ name: adapter.name,
1732
+ command: adapter.binary,
1733
+ args: adapter.resumeArgs(sessionId, prompt),
1734
+ resumed: true
1735
+ };
1736
+ }
1737
+ return {
1738
+ agent: choice.agent,
1739
+ name: adapter.name,
1740
+ command: adapter.binary,
1741
+ args: adapter.args(prompt),
1742
+ resumed: false
1743
+ };
1744
+ }
1745
+ function lineReader(stream, onLine) {
1746
+ let buffered = "";
1747
+ const flush = () => {
1748
+ if (buffered === "")
1749
+ return;
1750
+ onLine(buffered.replace(/\r$/, ""));
1751
+ buffered = "";
1752
+ };
1753
+ stream.on("data", (chunk) => {
1754
+ buffered += chunk.toString();
1755
+ const lines = buffered.split("\n");
1756
+ buffered = lines.pop() ?? "";
1757
+ for (const line of lines)
1758
+ onLine(line.replace(/\r$/, ""));
1759
+ });
1760
+ stream.on("end", flush);
1761
+ return flush;
1762
+ }
1763
+ function defaultSpawn(command, args, options) {
1764
+ return nodeSpawn(command, args, options);
1765
+ }
1766
+ function startRunner(options) {
1767
+ const spawn4 = options.spawn ?? defaultSpawn;
1768
+ const setEvery = options.setInterval ?? ((callback, milliseconds) => setInterval(callback, milliseconds));
1769
+ const clearEvery = options.clearInterval ?? ((handle2) => clearInterval(handle2));
1770
+ const failed = /* @__PURE__ */ new Set();
1771
+ let state = {
1772
+ running: false,
1773
+ requestId: null,
1774
+ agent: null,
1775
+ activity: null,
1776
+ startedAt: null
1777
+ };
1778
+ let stopped = false;
1779
+ let ticking = null;
1780
+ let stopPromise = null;
1781
+ let active = null;
1782
+ const sessions = /* @__PURE__ */ new Map();
1783
+ const idle = () => {
1784
+ state = { running: false, requestId: null, agent: null, activity: null, startedAt: null };
1785
+ };
1786
+ const rememberLine = (lines, line) => {
1787
+ lines.push(line);
1788
+ if (lines.length > OUTPUT_LINES)
1789
+ lines.splice(0, lines.length - OUTPUT_LINES);
1790
+ };
1791
+ const reportFailure = (request, error, lines) => {
1792
+ console.error(`Leglas agent failed for ${request.title}: ${error}`);
1793
+ for (const line of lines)
1794
+ console.error(` ${line}`);
1795
+ };
1796
+ const runChild = (request, resolved, lines, observed) => {
1797
+ let child;
1798
+ try {
1799
+ child = spawn4(resolved.command, resolved.args, {
1800
+ cwd: options.cwd,
1801
+ shell: false,
1802
+ stdio: ["ignore", "pipe", "pipe"]
1803
+ });
1804
+ } catch (error) {
1805
+ return Promise.resolve({
1806
+ ok: false,
1807
+ error: error instanceof Error ? error.message : String(error)
1808
+ });
1809
+ }
1810
+ const current = { child, requestId: request.id, cancelled: false };
1811
+ active = current;
1812
+ const stdoutFlush = lineReader(child.stdout, (line) => {
1813
+ rememberLine(lines, line);
1814
+ const sessionId = sessionFrom(resolved.agent, line);
1815
+ if (sessionId !== null)
1816
+ observed.sessionId = sessionId;
1817
+ const activity = activityFrom(resolved.agent, line, options.cwd);
1818
+ if (activity !== null) {
1819
+ if (activity.startsWith("editing"))
1820
+ observed.edited = true;
1821
+ if (active === current)
1822
+ state = { ...state, activity };
1823
+ }
1824
+ });
1825
+ const stderrFlush = lineReader(child.stderr, (line) => rememberLine(lines, line));
1826
+ return new Promise((resolve) => {
1827
+ let settled = false;
1828
+ const settle = (outcome) => {
1829
+ if (settled)
1830
+ return;
1831
+ settled = true;
1832
+ stdoutFlush();
1833
+ stderrFlush();
1834
+ resolve(outcome);
1835
+ };
1836
+ child.once("error", (error) => settle({ ok: false, error: error.message }));
1837
+ child.once("close", (code, signal) => {
1838
+ if (current.cancelled)
1839
+ return settle({ ok: false, error: "cancelled" });
1840
+ if (signal !== null)
1841
+ return settle({ ok: false, error: `stopped by ${signal}` });
1842
+ settle({ ok: true, code: code ?? 0 });
1843
+ });
1844
+ }).finally(() => {
1845
+ if (active === current)
1846
+ active = null;
1847
+ });
1848
+ };
1849
+ const handle = async (request, choice) => {
1850
+ const session = choice.agent !== null ? sessions.get(choice.agent) ?? null : null;
1851
+ const continuable = session !== null && session.turns < SESSION_TURNS_CAP;
1852
+ let resolved = resolveCommand(choice, request.prompt, continuable ? session.id : null);
1853
+ if (resolved === null)
1854
+ return;
1855
+ const lines = [];
1856
+ try {
1857
+ if (!await markPickedUp(options.cwd, request.id))
1858
+ return;
1859
+ if (stopped) {
1860
+ failed.add(request.id);
1861
+ return;
1862
+ }
1863
+ state = {
1864
+ running: true,
1865
+ requestId: request.id,
1866
+ agent: resolved.name,
1867
+ activity: null,
1868
+ startedAt: Date.now()
1869
+ };
1870
+ const observed = { sessionId: null, edited: false };
1871
+ let outcome = await runChild(request, resolved, lines, observed);
1872
+ const cancelled = !outcome.ok && outcome.error === "cancelled";
1873
+ if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && !cancelled && // Not redundant with the line above: a stop that lands between the
1874
+ // first child settling and the retry starting finds no child to
1875
+ // cancel, so nothing says "cancelled". Stopped still means stopped.
1876
+ !stopped) {
1877
+ sessions.delete(resolved.agent);
1878
+ const cold = resolveCommand(choice, request.prompt);
1879
+ if (cold !== null) {
1880
+ resolved = cold;
1881
+ observed.sessionId = null;
1882
+ state = { ...state, activity: null };
1883
+ outcome = await runChild(request, resolved, lines, observed);
1884
+ }
1885
+ }
1886
+ if (outcome.ok && outcome.code === 0) {
1887
+ if (observed.sessionId !== null) {
1888
+ const previous = sessions.get(resolved.agent);
1889
+ sessions.set(resolved.agent, {
1890
+ id: observed.sessionId,
1891
+ turns: resolved.resumed && previous?.id === observed.sessionId ? previous.turns + 1 : 1
1892
+ });
1893
+ }
1894
+ await removeRequest(options.cwd, request.id);
1895
+ return;
1896
+ }
1897
+ sessions.delete(resolved.agent);
1898
+ failed.add(request.id);
1899
+ reportFailure(request, outcome.ok ? `${resolved.command} exited ${outcome.code}` : outcome.error, lines);
1900
+ } finally {
1901
+ idle();
1902
+ }
1903
+ };
1904
+ const tick = async () => {
1905
+ if (stopped)
1906
+ return;
1907
+ const choice = await readAgentChoice(options.cwd);
1908
+ if (choice.agent === null || stopped)
1909
+ return;
1910
+ if (options.externallyAttached())
1911
+ return;
1912
+ const request = nextRequest(await readRequests(options.cwd), failed);
1913
+ if (request !== null && !stopped)
1914
+ await handle(request, choice);
1915
+ };
1916
+ const schedule = () => {
1917
+ if (stopped || ticking !== null)
1918
+ return;
1919
+ const task = tick();
1920
+ ticking = task;
1921
+ void task.catch((error) => console.error(`Leglas runner: ${error instanceof Error ? error.message : String(error)}`)).finally(() => {
1922
+ if (ticking === task)
1923
+ ticking = null;
1924
+ });
1925
+ };
1926
+ const timer = setEvery(schedule, POLL_MS);
1927
+ schedule();
1928
+ const cancel = (id) => {
1929
+ if (active === null || active.cancelled)
1930
+ return false;
1931
+ if (id !== void 0 && active.requestId !== id)
1932
+ return false;
1933
+ active.cancelled = true;
1934
+ failed.add(active.requestId);
1935
+ try {
1936
+ active.child.kill("SIGTERM");
1937
+ } catch {
1938
+ }
1939
+ return true;
1940
+ };
1941
+ const stop = () => {
1942
+ if (stopPromise !== null)
1943
+ return stopPromise;
1944
+ stopped = true;
1945
+ clearEvery(timer);
1946
+ cancel();
1947
+ stopPromise = Promise.resolve(ticking).catch(() => {
1948
+ }).then(() => {
1949
+ });
1950
+ return stopPromise;
1951
+ };
1952
+ return {
1953
+ stop,
1954
+ snapshot: () => ({ ...state, failedIds: [...failed] }),
1955
+ cancel,
1956
+ // schedule already refuses to overlap a tick in flight, so a nudge during
1957
+ // a run costs nothing and a nudge between runs starts the next one now.
1958
+ nudge: schedule
1959
+ };
1345
1960
  }
1346
1961
 
1347
1962
  // ../server/dist/renames.js
1348
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1349
- import { dirname as dirname4, join as join5 } from "path";
1963
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1964
+ import { dirname as dirname5, join as join6 } from "path";
1350
1965
  var RENAMES_PATH = ".leglas/renames.json";
1351
1966
  async function readRenames(cwd) {
1352
1967
  try {
1353
- const raw = await readFile4(join5(cwd, RENAMES_PATH), "utf8");
1968
+ const raw = await readFile5(join6(cwd, RENAMES_PATH), "utf8");
1354
1969
  const parsed = JSON.parse(raw);
1355
1970
  if (parsed.renames === null || typeof parsed.renames !== "object")
1356
1971
  return {};
@@ -1360,9 +1975,9 @@ async function readRenames(cwd) {
1360
1975
  }
1361
1976
  }
1362
1977
  async function writeRenames(cwd, renames) {
1363
- const path = join5(cwd, RENAMES_PATH);
1364
- await mkdir3(dirname4(path), { recursive: true });
1365
- await writeFile3(path, `${JSON.stringify({ renames }, null, 2)}
1978
+ const path = join6(cwd, RENAMES_PATH);
1979
+ await mkdir4(dirname5(path), { recursive: true });
1980
+ await writeFile4(path, `${JSON.stringify({ renames }, null, 2)}
1366
1981
  `, "utf8");
1367
1982
  }
1368
1983
  function resolveTitle(input, titles, renames) {
@@ -1380,7 +1995,7 @@ function resolveTitle(input, titles, renames) {
1380
1995
  import { createReadStream, existsSync as existsSync2, statSync } from "fs";
1381
1996
  import http2 from "http";
1382
1997
  import net3 from "net";
1383
- import { extname, join as join6, normalize } from "path";
1998
+ import { extname, join as join7, normalize, relative as relative3 } from "path";
1384
1999
  var LEGLAS_PREFIX = "/leglas";
1385
2000
  var DEFAULT_PORT = 4100;
1386
2001
  var PORT_ATTEMPTS = 20;
@@ -1411,6 +2026,52 @@ function sendJson(res, status, body) {
1411
2026
  });
1412
2027
  res.end(payload);
1413
2028
  }
2029
+ function isKnownAgent(value) {
2030
+ return typeof value === "string" && Object.hasOwn(KNOWN_AGENTS, value);
2031
+ }
2032
+ function isAllowedMutationHost(hostname) {
2033
+ const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
2034
+ if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1")
2035
+ return true;
2036
+ if (bare.endsWith(".local"))
2037
+ return true;
2038
+ if (!net3.isIPv4(bare))
2039
+ return false;
2040
+ const [first, second] = bare.split(".").map(Number);
2041
+ return first === 10 || first === 172 && second !== void 0 && second >= 16 && second <= 31 || first === 192 && second === 168;
2042
+ }
2043
+ function isLoopbackAddress(address) {
2044
+ if (address === void 0)
2045
+ return false;
2046
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1" || address.startsWith("127.");
2047
+ }
2048
+ function isTrustedMutation(req) {
2049
+ if (!isLoopbackAddress(req.socket.remoteAddress))
2050
+ return false;
2051
+ if (typeof req.headers.host !== "string")
2052
+ return false;
2053
+ let host;
2054
+ try {
2055
+ host = new URL(`http://${req.headers.host}`);
2056
+ } catch {
2057
+ return false;
2058
+ }
2059
+ if (!isAllowedMutationHost(host.hostname))
2060
+ return false;
2061
+ const rawOrigin = req.headers.origin;
2062
+ if (rawOrigin === void 0)
2063
+ return true;
2064
+ try {
2065
+ const origin = new URL(rawOrigin);
2066
+ return origin.protocol === "http:" && origin.host === host.host;
2067
+ } catch {
2068
+ return false;
2069
+ }
2070
+ }
2071
+ function hasJsonBody(req) {
2072
+ const contentType = req.headers["content-type"];
2073
+ return typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
2074
+ }
1414
2075
  function probe(target, timeoutMs = 1e3) {
1415
2076
  return new Promise((resolve) => {
1416
2077
  let url;
@@ -1432,8 +2093,8 @@ function probe(target, timeoutMs = 1e3) {
1432
2093
  });
1433
2094
  }
1434
2095
  function serveFrom(res, dir, relativePath) {
1435
- const relative3 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
1436
- const candidate = join6(dir, relative3);
2096
+ const relative5 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
2097
+ const candidate = join7(dir, relative5);
1437
2098
  if (!candidate.startsWith(dir))
1438
2099
  return false;
1439
2100
  if (!existsSync2(candidate) || !statSync(candidate).isFile())
@@ -1446,9 +2107,36 @@ function serveFrom(res, dir, relativePath) {
1446
2107
  return true;
1447
2108
  }
1448
2109
  function serveShellFile(res, shellDir, urlPath) {
1449
- const relative3 = normalize(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
1450
- const isRoot = relative3 === "" || relative3 === "." || relative3 === "/";
1451
- return serveFrom(res, shellDir, isRoot ? "index.html" : relative3);
2110
+ const relative5 = normalize(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
2111
+ const isRoot = relative5 === "" || relative5 === "." || relative5 === "/";
2112
+ return serveFrom(res, shellDir, isRoot ? "index.html" : relative5);
2113
+ }
2114
+ function snapshotConfig(cwd) {
2115
+ const path = findConfigFile(cwd);
2116
+ if (path === null)
2117
+ return null;
2118
+ try {
2119
+ return { path, mtimeMs: statSync(path).mtimeMs };
2120
+ } catch {
2121
+ return null;
2122
+ }
2123
+ }
2124
+ function configStalenessNotice(cwd, boot, current) {
2125
+ if (boot === null && current === null)
2126
+ return null;
2127
+ if (boot === null && current !== null) {
2128
+ const label = relative3(cwd, current.path) || current.path;
2129
+ return `${label} appeared after Leglas started. Restart leglas to pick it up.`;
2130
+ }
2131
+ if (boot !== null && current === null) {
2132
+ const label = relative3(cwd, boot.path) || boot.path;
2133
+ return `${label} was removed after Leglas started. Restart leglas to run without it.`;
2134
+ }
2135
+ if (boot !== null && current !== null && (boot.path !== current.path || boot.mtimeMs !== current.mtimeMs)) {
2136
+ const label = relative3(cwd, current.path) || current.path;
2137
+ return `${label} changed after Leglas started. Restart leglas to pick it up.`;
2138
+ }
2139
+ return null;
1452
2140
  }
1453
2141
  var PLACEHOLDER = `<!doctype html>
1454
2142
  <meta charset="utf-8">
@@ -1491,15 +2179,46 @@ async function bind(server, requested) {
1491
2179
  throw new Error(`No free port between ${requested} and ${requested + PORT_ATTEMPTS - 1}.`);
1492
2180
  }
1493
2181
  async function startServer(options) {
1494
- const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map() } = options;
2182
+ const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
1495
2183
  const target = config?.devServer ?? "http://localhost:3000";
1496
2184
  const proxy = createProxyHandler({ target });
2185
+ const bootConfigSnapshot = snapshotConfig(cwd);
1497
2186
  let lastSeen = null;
2187
+ const externallyAttached = () => lastSeen !== null && Date.now() - lastSeen < ATTACHED_WINDOW_MS;
2188
+ let runner = null;
2189
+ let agentsCache = null;
2190
+ let agentsInflight = null;
2191
+ const AGENTS_FRESH_MS = 3e4;
2192
+ const probeAgents = () => {
2193
+ agentsInflight ??= detect().then((agents) => {
2194
+ agentsCache = { at: Date.now(), agents };
2195
+ return agents;
2196
+ }).finally(() => {
2197
+ agentsInflight = null;
2198
+ });
2199
+ return agentsInflight;
2200
+ };
2201
+ const currentAgents = () => {
2202
+ if (agentsCache === null)
2203
+ return probeAgents();
2204
+ if (Date.now() - agentsCache.at > AGENTS_FRESH_MS) {
2205
+ void probeAgents().catch(() => {
2206
+ });
2207
+ }
2208
+ return Promise.resolve(agentsCache.agents);
2209
+ };
1498
2210
  const server = http2.createServer((req, res) => {
1499
2211
  const url = req.url ?? "/";
1500
2212
  const path = url.split("?")[0] ?? "/";
2213
+ if (req.method === "POST" && path.startsWith(`${LEGLAS_PREFIX}/api/`) && !isTrustedMutation(req)) {
2214
+ return sendJson(res, 403, { ok: false, error: "Cross-origin API mutations are refused." });
2215
+ }
1501
2216
  if (path === `${LEGLAS_PREFIX}/api/config`) {
1502
2217
  const boot = config?.previews ?? [];
2218
+ const errors = [...configErrors];
2219
+ const notice = configStalenessNotice(cwd, bootConfigSnapshot, snapshotConfig(cwd));
2220
+ if (notice !== null)
2221
+ errors.push(notice);
1503
2222
  return void readLocalPreviews(cwd).then(({ previews: local }) => {
1504
2223
  const known = new Set(boot.map((preview) => preview.title));
1505
2224
  const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
@@ -1507,13 +2226,13 @@ async function startServer(options) {
1507
2226
  project,
1508
2227
  devServer: target,
1509
2228
  previews: [...boot, ...fresh],
1510
- errors: configErrors
2229
+ errors
1511
2230
  });
1512
2231
  }).catch(() => sendJson(res, 200, {
1513
2232
  project,
1514
2233
  devServer: target,
1515
2234
  previews: boot,
1516
- errors: configErrors
2235
+ errors
1517
2236
  }));
1518
2237
  }
1519
2238
  if (path === `${LEGLAS_PREFIX}/api/request` && req.method === "POST") {
@@ -1537,7 +2256,54 @@ async function startServer(options) {
1537
2256
  url: preview.url,
1538
2257
  intent: parsed.intent.trim(),
1539
2258
  ...composed
1540
- }).then(() => sendJson(res, 200, { ok: true, ...composed })).catch(() => sendJson(res, 200, { ok: true, ...composed, queued: false }));
2259
+ }).then(() => {
2260
+ runner?.nudge();
2261
+ sendJson(res, 200, { ok: true, ...composed });
2262
+ }).catch(() => sendJson(res, 200, { ok: true, ...composed, queued: false }));
2263
+ });
2264
+ }
2265
+ if (path === `${LEGLAS_PREFIX}/api/agents` && req.method === "GET") {
2266
+ return void Promise.all([currentAgents(), readAgentChoice(cwd)]).then(([agents, choice]) => sendJson(res, 200, {
2267
+ agents,
2268
+ choice: choice.agent,
2269
+ customRun: choice.run
2270
+ }));
2271
+ }
2272
+ if (path === `${LEGLAS_PREFIX}/api/agent` && req.method === "POST") {
2273
+ if (!isLoopbackAddress(req.socket.remoteAddress)) {
2274
+ return sendJson(res, 403, {
2275
+ ok: false,
2276
+ error: "The agent choice can only be made from the machine running Leglas."
2277
+ });
2278
+ }
2279
+ if (!hasJsonBody(req)) {
2280
+ return sendJson(res, 400, { ok: false, error: "Agent choice must be JSON." });
2281
+ }
2282
+ let body = "";
2283
+ req.on("data", (chunk) => body += chunk);
2284
+ return void req.on("end", () => {
2285
+ let parsed;
2286
+ try {
2287
+ parsed = JSON.parse(body || "{}");
2288
+ } catch {
2289
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2290
+ }
2291
+ if (!isKnownAgent(parsed.agent) && parsed.agent !== "custom") {
2292
+ return sendJson(res, 400, { ok: false, error: "Body needs a known agent." });
2293
+ }
2294
+ if (parsed.run !== void 0 && typeof parsed.run !== "string") {
2295
+ return sendJson(res, 400, { ok: false, error: "The custom run command must be a string." });
2296
+ }
2297
+ if (parsed.agent === "custom") {
2298
+ if (typeof parsed.run !== "string") {
2299
+ return sendJson(res, 400, { ok: false, error: "A custom agent needs a run command." });
2300
+ }
2301
+ const template = parseTemplate(parsed.run);
2302
+ if (!template.ok)
2303
+ return sendJson(res, 400, { ok: false, error: template.error });
2304
+ 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." }));
2305
+ }
2306
+ 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." }));
1541
2307
  });
1542
2308
  }
1543
2309
  if (path === `${LEGLAS_PREFIX}/api/watch` && req.method === "POST") {
@@ -1558,11 +2324,119 @@ async function startServer(options) {
1558
2324
  });
1559
2325
  }
1560
2326
  if (path === `${LEGLAS_PREFIX}/api/requests` && req.method === "GET") {
2327
+ const snapshot = runner?.snapshot() ?? {
2328
+ running: false,
2329
+ requestId: null,
2330
+ agent: null,
2331
+ activity: null,
2332
+ startedAt: null,
2333
+ failedIds: []
2334
+ };
1561
2335
  return void readRequests(cwd).then((requests) => sendJson(res, 200, {
1562
- requests: requests.map(({ id, title, intent, status }) => ({ id, title, intent, status })),
1563
- agent: { attached: lastSeen !== null && Date.now() - lastSeen < ATTACHED_WINDOW_MS }
2336
+ requests: requests.map(({ id, title, intent, status }) => ({
2337
+ id,
2338
+ title,
2339
+ intent,
2340
+ status: snapshot.running && snapshot.requestId === id ? "running" : snapshot.failedIds.includes(id) ? "failed" : status
2341
+ })),
2342
+ agent: {
2343
+ attached: externallyAttached(),
2344
+ running: snapshot.running,
2345
+ name: snapshot.running ? snapshot.agent : null,
2346
+ activity: snapshot.running ? snapshot.activity : null,
2347
+ startedAt: snapshot.running ? snapshot.startedAt : null
2348
+ }
1564
2349
  }));
1565
2350
  }
2351
+ if (path === `${LEGLAS_PREFIX}/api/requests/cancel` && req.method === "POST") {
2352
+ if (!hasJsonBody(req)) {
2353
+ return sendJson(res, 200, { ok: true, cancelled: runner?.cancel() ?? false });
2354
+ }
2355
+ let body = "";
2356
+ req.on("data", (chunk) => body += chunk);
2357
+ return void req.on("end", () => {
2358
+ let parsed;
2359
+ try {
2360
+ parsed = JSON.parse(body || "{}");
2361
+ } catch {
2362
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2363
+ }
2364
+ if (parsed.id !== void 0 && typeof parsed.id !== "string") {
2365
+ return sendJson(res, 400, { ok: false, error: "The request id must be a string." });
2366
+ }
2367
+ return sendJson(res, 200, { ok: true, cancelled: runner?.cancel(parsed.id) ?? false });
2368
+ });
2369
+ }
2370
+ if (path === `${LEGLAS_PREFIX}/api/requests/retry` && req.method === "POST") {
2371
+ if (!hasJsonBody(req)) {
2372
+ return sendJson(res, 400, { ok: false, error: "Retry must be JSON." });
2373
+ }
2374
+ let body = "";
2375
+ req.on("data", (chunk) => body += chunk);
2376
+ return void req.on("end", async () => {
2377
+ let parsed;
2378
+ try {
2379
+ parsed = JSON.parse(body || "{}");
2380
+ } catch {
2381
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2382
+ }
2383
+ if (typeof parsed.id !== "string") {
2384
+ return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
2385
+ }
2386
+ const request = (await readRequests(cwd)).find((entry) => entry.id === parsed.id);
2387
+ if (request === void 0) {
2388
+ return sendJson(res, 404, { ok: false, error: "No such request." });
2389
+ }
2390
+ if (!(runner?.snapshot().failedIds.includes(request.id) ?? false)) {
2391
+ return sendJson(res, 400, { ok: false, error: "Only a failed request can be retried." });
2392
+ }
2393
+ try {
2394
+ if (!await removeRequest(cwd, request.id)) {
2395
+ return sendJson(res, 404, { ok: false, error: "No such request." });
2396
+ }
2397
+ await appendRequest(cwd, {
2398
+ title: request.title,
2399
+ url: request.url,
2400
+ intent: request.intent,
2401
+ target: request.target,
2402
+ prompt: request.prompt
2403
+ });
2404
+ runner?.nudge();
2405
+ return sendJson(res, 200, { ok: true });
2406
+ } catch {
2407
+ return sendJson(res, 500, { ok: false, error: "The request could not be retried." });
2408
+ }
2409
+ });
2410
+ }
2411
+ if (path === `${LEGLAS_PREFIX}/api/requests/dismiss` && req.method === "POST") {
2412
+ if (!hasJsonBody(req)) {
2413
+ return sendJson(res, 400, { ok: false, error: "Dismiss must be JSON." });
2414
+ }
2415
+ let body = "";
2416
+ req.on("data", (chunk) => body += chunk);
2417
+ return void req.on("end", async () => {
2418
+ let parsed;
2419
+ try {
2420
+ parsed = JSON.parse(body || "{}");
2421
+ } catch {
2422
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2423
+ }
2424
+ if (typeof parsed.id !== "string") {
2425
+ return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
2426
+ }
2427
+ if (!(runner?.snapshot().failedIds.includes(parsed.id) ?? false)) {
2428
+ return sendJson(res, 400, { ok: false, error: "Only a failed request can be dismissed." });
2429
+ }
2430
+ try {
2431
+ if (!await removeRequest(cwd, parsed.id)) {
2432
+ return sendJson(res, 404, { ok: false, error: "No such request." });
2433
+ }
2434
+ return sendJson(res, 200, { ok: true });
2435
+ } catch {
2436
+ return sendJson(res, 500, { ok: false, error: "The request could not be dismissed." });
2437
+ }
2438
+ });
2439
+ }
1566
2440
  if (path === `${LEGLAS_PREFIX}/api/renames` && req.method === "POST") {
1567
2441
  let body = "";
1568
2442
  req.on("data", (chunk) => body += chunk);
@@ -1587,21 +2461,28 @@ async function startServer(options) {
1587
2461
  const rest = path.slice(FILES_PREFIX.length + 1);
1588
2462
  const slash = rest.indexOf("/");
1589
2463
  const slug = slash === -1 ? rest : rest.slice(0, slash);
1590
- let relative3 = slash === -1 ? "" : rest.slice(slash + 1);
2464
+ let relative5 = slash === -1 ? "" : rest.slice(slash + 1);
1591
2465
  try {
1592
- relative3 = decodeURIComponent(relative3);
2466
+ relative5 = decodeURIComponent(relative5);
1593
2467
  } catch {
1594
- relative3 = "";
2468
+ relative5 = "";
1595
2469
  }
1596
2470
  const dir = fileMounts.get(slug);
1597
- if (dir !== void 0 && relative3 !== "" && serveFrom(res, dir, relative3))
2471
+ if (dir !== void 0 && relative5 !== "" && serveFrom(res, dir, relative5))
1598
2472
  return;
1599
2473
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
1600
2474
  return res.end("Leglas: no such preview file.");
1601
2475
  }
2476
+ if (path.startsWith(`${LEGLAS_PREFIX}/api/`)) {
2477
+ return sendJson(res, 404, { error: "No such Leglas API path." });
2478
+ }
1602
2479
  if (path === LEGLAS_PREFIX || path.startsWith(`${LEGLAS_PREFIX}/`)) {
1603
2480
  if (shellDir !== null && serveShellFile(res, shellDir, path))
1604
2481
  return;
2482
+ if (shellDir !== null) {
2483
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
2484
+ return res.end("Leglas: no such path.");
2485
+ }
1605
2486
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1606
2487
  return res.end(PLACEHOLDER);
1607
2488
  }
@@ -1619,16 +2500,23 @@ async function startServer(options) {
1619
2500
  proxy.upgrade(req, socket, head);
1620
2501
  });
1621
2502
  const port = await bind(server, options.port ?? DEFAULT_PORT);
2503
+ runner = startRunner({ cwd, externallyAttached });
2504
+ let closePromise = null;
1622
2505
  return {
1623
2506
  port,
1624
2507
  url: `http://localhost:${port}`,
1625
- close: () => new Promise((done) => {
1626
- for (const socket of sockets)
1627
- socket.destroy();
1628
- sockets.clear();
1629
- server.closeAllConnections();
1630
- server.close(() => done());
1631
- })
2508
+ close: () => {
2509
+ if (closePromise !== null)
2510
+ return closePromise;
2511
+ closePromise = runner.stop().then(() => new Promise((done) => {
2512
+ for (const socket of sockets)
2513
+ socket.destroy();
2514
+ sockets.clear();
2515
+ server.closeAllConnections();
2516
+ server.close(() => done());
2517
+ }));
2518
+ return closePromise;
2519
+ }
1632
2520
  };
1633
2521
  }
1634
2522
 
@@ -1650,7 +2538,7 @@ function planKeep(options) {
1650
2538
  if (!winner) {
1651
2539
  return {
1652
2540
  ok: false,
1653
- error: `No direction called ${JSON.stringify(options.title)}. Run leglas list to see them.`
2541
+ error: `No direction called ${JSON.stringify(options.title)}. Run npx leglas list to see them.`
1654
2542
  };
1655
2543
  }
1656
2544
  const from = targetFor(winner.url);
@@ -1687,11 +2575,11 @@ function planKeep(options) {
1687
2575
  }
1688
2576
 
1689
2577
  // src/run-init.ts
1690
- import { readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1691
- import { join as join7 } from "path";
2578
+ import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
2579
+ import { join as join8 } from "path";
1692
2580
  async function readIfPresent(path) {
1693
2581
  try {
1694
- return await readFile5(path, "utf8");
2582
+ return await readFile6(path, "utf8");
1695
2583
  } catch {
1696
2584
  return null;
1697
2585
  }
@@ -1699,18 +2587,18 @@ async function readIfPresent(path) {
1699
2587
  async function runInit(options, deps) {
1700
2588
  const existingConfig = findConfigFile(options.cwd);
1701
2589
  const plan = planInit({
1702
- agents: await readIfPresent(join7(options.cwd, "AGENTS.md")),
2590
+ agents: await readIfPresent(join8(options.cwd, "AGENTS.md")),
1703
2591
  config: existingConfig === null ? null : "present",
1704
- gitignore: await readIfPresent(join7(options.cwd, ".gitignore")),
2592
+ gitignore: await readIfPresent(join8(options.cwd, ".gitignore")),
1705
2593
  force: options.force
1706
2594
  });
1707
2595
  const touched = [];
1708
2596
  for (const write of plan.writes) {
1709
- await writeFile4(join7(options.cwd, write.path), write.contents, "utf8");
2597
+ await writeFile5(join8(options.cwd, write.path), write.contents, "utf8");
1710
2598
  touched.push(write.path);
1711
2599
  }
1712
2600
  if (plan.gitignore !== null) {
1713
- await writeFile4(join7(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2601
+ await writeFile5(join8(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1714
2602
  touched.push(".gitignore");
1715
2603
  }
1716
2604
  if (options.json) {
@@ -1730,8 +2618,8 @@ async function runInit(options, deps) {
1730
2618
 
1731
2619
  // src/run-keep.ts
1732
2620
  import { existsSync as existsSync3 } from "fs";
1733
- import { mkdir as mkdir4, readFile as readFile6, rm as rm2, writeFile as writeFile5 } from "fs/promises";
1734
- import { dirname as dirname5, join as join8 } from "path";
2621
+ import { mkdir as mkdir5, readFile as readFile7, rm as rm2, writeFile as writeFile6 } from "fs/promises";
2622
+ import { dirname as dirname6, join as join9 } from "path";
1735
2623
 
1736
2624
  // src/resolve-title.ts
1737
2625
  function resolveOrExplain(input, titles, renames) {
@@ -1745,7 +2633,7 @@ function resolveOrExplain(input, titles, renames) {
1745
2633
  }
1746
2634
  return {
1747
2635
  ok: false,
1748
- error: `No direction called ${JSON.stringify(input)}. Renaming one in the rail only renames it here, and it still answers to its title in the config, which its reference block quotes. leglas list shows every title.`
2636
+ error: `No direction called ${JSON.stringify(input)}. Renaming one in the rail only renames it here, and it still answers to its title in the config, which its reference block quotes. npx leglas list shows every title.`
1749
2637
  };
1750
2638
  }
1751
2639
 
@@ -1775,18 +2663,18 @@ async function runKeep(options, deps) {
1775
2663
  if (!resolved.ok) return fail(resolved.error);
1776
2664
  const plan = planKeep({ title: resolved.title, previews, to: options.to });
1777
2665
  if (!plan.ok) return fail(plan.error);
1778
- const from = join8(options.cwd, plan.move.from);
1779
- const to = join8(options.cwd, plan.move.to);
2666
+ const from = join9(options.cwd, plan.move.from);
2667
+ const to = join9(options.cwd, plan.move.to);
1780
2668
  if (!existsSync3(from)) {
1781
2669
  return fail(`${plan.move.from} does not exist. Nothing to keep.`);
1782
2670
  }
1783
2671
  if (existsSync3(to)) {
1784
2672
  return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
1785
2673
  }
1786
- const source = await readFile6(from, "utf8");
1787
- await mkdir4(dirname5(to), { recursive: true });
1788
- await writeFile5(to, renameExport(source, plan.exportName), "utf8");
1789
- await rm2(join8(options.cwd, plan.removeDir), { recursive: true, force: true });
2674
+ const source = await readFile7(from, "utf8");
2675
+ await mkdir5(dirname6(to), { recursive: true });
2676
+ await writeFile6(to, renameExport(source, plan.exportName), "utf8");
2677
+ await rm2(join9(options.cwd, plan.removeDir), { recursive: true, force: true });
1790
2678
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
1791
2679
  if (options.json) {
1792
2680
  deps.log(
@@ -1821,11 +2709,11 @@ async function runKeep(options, deps) {
1821
2709
 
1822
2710
  // src/run-new.ts
1823
2711
  import { existsSync as existsSync4 } from "fs";
1824
- import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
1825
- import { dirname as dirname6, join as join9 } from "path";
2712
+ import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
2713
+ import { dirname as dirname7, join as join10 } from "path";
1826
2714
  async function readIfPresent2(path) {
1827
2715
  try {
1828
- return await readFile7(path, "utf8");
2716
+ return await readFile8(path, "utf8");
1829
2717
  } catch {
1830
2718
  return null;
1831
2719
  }
@@ -1833,7 +2721,7 @@ async function readIfPresent2(path) {
1833
2721
  async function runNew(options, deps) {
1834
2722
  let from;
1835
2723
  if (options.from !== void 0) {
1836
- const contents = await readIfPresent2(join9(options.cwd, options.from));
2724
+ const contents = await readIfPresent2(join10(options.cwd, options.from));
1837
2725
  if (contents === null) {
1838
2726
  const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
1839
2727
  if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
@@ -1844,8 +2732,8 @@ async function runNew(options, deps) {
1844
2732
  }
1845
2733
  const plan = planNew({
1846
2734
  surface: options.surface,
1847
- packageJson: await readIfPresent2(join9(options.cwd, "package.json")),
1848
- gitignore: await readIfPresent2(join9(options.cwd, ".gitignore")),
2735
+ packageJson: await readIfPresent2(join10(options.cwd, "package.json")),
2736
+ gitignore: await readIfPresent2(join10(options.cwd, ".gitignore")),
1849
2737
  from
1850
2738
  });
1851
2739
  const fail = (error) => {
@@ -1868,19 +2756,19 @@ async function runNew(options, deps) {
1868
2756
  deps.log(plan.instructions);
1869
2757
  return { exitCode: 0, written: [] };
1870
2758
  }
1871
- const existing = plan.writes.filter((write) => existsSync4(join9(options.cwd, write.path)));
2759
+ const existing = plan.writes.filter((write) => existsSync4(join10(options.cwd, write.path)));
1872
2760
  if (existing.length > 0) {
1873
2761
  return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
1874
2762
  }
1875
2763
  const written = [];
1876
2764
  for (const write of plan.writes) {
1877
- const target = join9(options.cwd, write.path);
1878
- await mkdir5(dirname6(target), { recursive: true });
1879
- await writeFile6(target, write.contents, "utf8");
2765
+ const target = join10(options.cwd, write.path);
2766
+ await mkdir6(dirname7(target), { recursive: true });
2767
+ await writeFile7(target, write.contents, "utf8");
1880
2768
  written.push(write.path);
1881
2769
  }
1882
2770
  if (plan.gitignore !== null) {
1883
- await writeFile6(join9(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2771
+ await writeFile7(join10(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1884
2772
  written.push(".gitignore");
1885
2773
  }
1886
2774
  if (options.json) {
@@ -1895,27 +2783,27 @@ async function runNew(options, deps) {
1895
2783
  deps.log("Then register them so they appear in the interface:");
1896
2784
  deps.log("");
1897
2785
  for (const preview of plan.previews) {
1898
- deps.log(` leglas add --title ${JSON.stringify(preview.title)} --url ${JSON.stringify(preview.url)}`);
2786
+ deps.log(` npx leglas add --title ${JSON.stringify(preview.title)} --url ${JSON.stringify(preview.url)}`);
1899
2787
  }
1900
2788
  return { exitCode: 0, written };
1901
2789
  }
1902
2790
 
1903
2791
  // src/run-previews.ts
1904
- import { readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
1905
- import { join as join10 } from "path";
2792
+ import { readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
2793
+ import { join as join11 } from "path";
1906
2794
  function envelope(deps, ok, body) {
1907
2795
  deps.log(JSON.stringify({ ok, ...body }));
1908
2796
  }
1909
2797
  async function ensureIgnored(cwd) {
1910
- const path = join10(cwd, ".gitignore");
2798
+ const path = join11(cwd, ".gitignore");
1911
2799
  let current = null;
1912
2800
  try {
1913
- current = await readFile8(path, "utf8");
2801
+ current = await readFile9(path, "utf8");
1914
2802
  } catch {
1915
2803
  current = null;
1916
2804
  }
1917
2805
  const next = ignoreEntry(current);
1918
- if (next !== null) await writeFile7(path, next, "utf8");
2806
+ if (next !== null) await writeFile8(path, next, "utf8");
1919
2807
  }
1920
2808
  async function runAdd(options, deps) {
1921
2809
  const loaded = await loadConfig(options.cwd);
@@ -1924,7 +2812,7 @@ async function runAdd(options, deps) {
1924
2812
  const local = await readLocalPreviews(options.cwd);
1925
2813
  const titles = new Set([...shared, ...local.previews].map((preview) => preview.title));
1926
2814
  if (!titles.has(options.preview.basedOn)) {
1927
- const error = `--based-on names ${JSON.stringify(options.preview.basedOn)}, which is not a registered direction. leglas list shows what exists.`;
2815
+ const error = `--based-on names ${JSON.stringify(options.preview.basedOn)}, which is not a registered direction. npx leglas list shows what exists.`;
1928
2816
  if (options.json) envelope(deps, false, { error });
1929
2817
  else deps.error(error);
1930
2818
  return { exitCode: 1 };
@@ -1957,6 +2845,7 @@ async function runAdd(options, deps) {
1957
2845
  local: true,
1958
2846
  ...options.preview.branch === void 0 ? {} : { branch: options.preview.branch },
1959
2847
  ...options.preview.file === void 0 ? {} : { file: options.preview.file },
2848
+ note: options.preview.branch === void 0 && options.preview.file === void 0 ? "A running interface picks this up within seconds." : "Restart Leglas to see this preview: branch checkouts and file mounts are built when Leglas starts.",
1960
2849
  ...needsDevCommand ? { warning: "The config sets no devCommand, so Leglas cannot start this branch yet. Add devCommand (with {port}) to the config." } : {}
1961
2850
  });
1962
2851
  } else {
@@ -1972,7 +2861,7 @@ async function runAdd(options, deps) {
1972
2861
  if (options.preview.branch === void 0 && options.preview.file === void 0) {
1973
2862
  deps.log("Local to this machine. A running interface picks it up within seconds.");
1974
2863
  } else {
1975
- deps.log("Local to this machine. Restart Leglas to see it, or run leglas list.");
2864
+ deps.log("Local to this machine. Restart Leglas to see it, or run npx leglas list.");
1976
2865
  }
1977
2866
  }
1978
2867
  return { exitCode: 0 };
@@ -2007,7 +2896,7 @@ async function runList(options, deps) {
2007
2896
  return { exitCode: errors.length === 0 ? 0 : 1 };
2008
2897
  }
2009
2898
  if (previews.length === 0) {
2010
- deps.log("No previews yet. Add one with leglas add, or list them in leglas.config.ts.");
2899
+ deps.log("No previews yet. Add one with npx leglas add, or list them in leglas.config.ts.");
2011
2900
  } else {
2012
2901
  const width = Math.max(...previews.map((preview) => preview.title.length));
2013
2902
  for (const preview of previews) {
@@ -2022,9 +2911,16 @@ async function runList(options, deps) {
2022
2911
  }
2023
2912
  async function runRequests(options, deps) {
2024
2913
  if (options.clear) {
2025
- await clearRequests(options.cwd);
2026
- if (options.json) envelope(deps, true, { cleared: true });
2027
- else deps.log("Queue cleared.");
2914
+ const { cleared, pending } = await clearRequests(options.cwd);
2915
+ if (options.json) envelope(deps, true, { cleared, pending });
2916
+ else {
2917
+ deps.log(cleared === 1 ? "Cleared 1 request." : `Cleared ${cleared} requests.`);
2918
+ if (pending > 0) {
2919
+ deps.log(
2920
+ `${pending} arrived while you worked. Run npx leglas requests to collect ${pending === 1 ? "it" : "them"}.`
2921
+ );
2922
+ }
2923
+ }
2028
2924
  return { exitCode: 0 };
2029
2925
  }
2030
2926
  const requests = await collectRequests(options.cwd);
@@ -2041,7 +2937,7 @@ async function runRequests(options, deps) {
2041
2937
  if (request.target !== null) deps.log(` ${request.target}`);
2042
2938
  }
2043
2939
  deps.log("");
2044
- deps.log("Run leglas requests --json to get the full prompts, then --clear when done.");
2940
+ deps.log("Run npx leglas requests --json to get the full prompts, then --clear when done.");
2045
2941
  return { exitCode: 0 };
2046
2942
  }
2047
2943
 
@@ -2067,7 +2963,7 @@ function planShow({ title, previews, requests }) {
2067
2963
  if (!found) {
2068
2964
  return {
2069
2965
  ok: false,
2070
- error: `No direction called ${JSON.stringify(title)}. Run leglas list to see them.`
2966
+ error: `No direction called ${JSON.stringify(title)}. Run npx leglas list to see them.`
2071
2967
  };
2072
2968
  }
2073
2969
  const variants = previews.filter((preview) => preview.basedOn === title).map(describe);
@@ -2146,104 +3042,30 @@ async function runShow(options, deps) {
2146
3042
  deps.log(` Pending, not yet done (${plan.requests.length}):`);
2147
3043
  for (const request of plan.requests) deps.log(` ${request.status} ${request.intent}`);
2148
3044
  deps.log("");
2149
- deps.log(" Run leglas requests --json for the full prompts.");
3045
+ deps.log(" Run npx leglas requests --json for the full prompts.");
2150
3046
  }
2151
3047
  return { exitCode: 0 };
2152
3048
  }
2153
3049
 
2154
- // src/watch.ts
2155
- var WATCH_PATH = ".leglas/watch.json";
2156
- var PROMPT_TOKEN = "{prompt}";
2157
- var EXAMPLE = `leglas watch --run "claude -p ${PROMPT_TOKEN}"`;
2158
- function tokenize(template) {
2159
- const tokens = [];
2160
- let current = "";
2161
- let started = false;
2162
- let quote = null;
2163
- for (const character of template) {
2164
- if (quote !== null) {
2165
- if (character === quote) quote = null;
2166
- else current += character;
2167
- continue;
2168
- }
2169
- if (character === '"' || character === "'") {
2170
- quote = character;
2171
- started = true;
2172
- continue;
2173
- }
2174
- if (/\s/.test(character)) {
2175
- if (started) tokens.push(current);
2176
- current = "";
2177
- started = false;
2178
- continue;
2179
- }
2180
- current += character;
2181
- started = true;
2182
- }
2183
- if (quote !== null) {
2184
- return { ok: false, error: `The agent command has an unclosed ${quote} quote.` };
2185
- }
2186
- if (started) tokens.push(current);
2187
- return { ok: true, tokens };
2188
- }
2189
- function parseTemplate(raw) {
2190
- const tokenized = tokenize(raw);
2191
- if (!tokenized.ok) return tokenized;
2192
- const { tokens } = tokenized;
2193
- const [command, ...args] = tokens;
2194
- if (command === void 0) {
2195
- return { ok: false, error: `Watch needs an agent command, for example: ${EXAMPLE}` };
2196
- }
2197
- const placeholders = tokens.filter((token) => token === PROMPT_TOKEN).length;
2198
- if (placeholders === 0) {
2199
- return {
2200
- ok: false,
2201
- error: `The agent command needs ${PROMPT_TOKEN} as a word of its own, for example: ${EXAMPLE}`
2202
- };
2203
- }
2204
- if (placeholders > 1) {
2205
- return {
2206
- ok: false,
2207
- error: `The agent command takes ${PROMPT_TOKEN} once, for example: ${EXAMPLE}`
2208
- };
2209
- }
2210
- if (command === PROMPT_TOKEN) {
2211
- return {
2212
- ok: false,
2213
- error: `The agent command must name a program before ${PROMPT_TOKEN}, for example: ${EXAMPLE}`
2214
- };
2215
- }
2216
- return { ok: true, template: { command, args } };
2217
- }
2218
- function commandFor(template, prompt) {
2219
- return {
2220
- command: template.command,
2221
- args: template.args.map((argument) => argument === PROMPT_TOKEN ? prompt : argument)
2222
- };
2223
- }
2224
- function nextRequest(requests, failed) {
2225
- return requests.find((request) => request.status === "queued" && !failed.has(request.id)) ?? null;
2226
- }
2227
-
2228
3050
  // src/run-watch.ts
2229
- import { spawn as spawn2 } from "child_process";
2230
- import { mkdir as mkdir6, readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
2231
- import { dirname as dirname7, join as join11 } from "path";
2232
- var POLL_MS = 2e3;
3051
+ import { spawn as spawn3 } from "child_process";
3052
+ import { mkdir as mkdir7, readFile as readFile10, writeFile as writeFile9 } from "fs/promises";
3053
+ import { dirname as dirname8, join as join12 } from "path";
3054
+ var POLL_MS2 = 2e3;
2233
3055
  var HEARTBEAT_TIMEOUT_MS = 1e3;
2234
- async function readSavedTemplate(cwd) {
3056
+ async function saveTemplate(cwd, run3) {
3057
+ const path = join12(cwd, WATCH_PATH);
3058
+ let config = {};
2235
3059
  try {
2236
- const raw = await readFile9(join11(cwd, WATCH_PATH), "utf8");
2237
- const parsed = JSON.parse(raw);
2238
- return typeof parsed.run === "string" && parsed.run !== "" ? parsed.run : null;
3060
+ const parsed = JSON.parse(await readFile10(path, "utf8"));
3061
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
3062
+ config = parsed;
3063
+ }
2239
3064
  } catch {
2240
- return null;
2241
3065
  }
2242
- }
2243
- async function saveTemplate(cwd, run3) {
2244
- const path = join11(cwd, WATCH_PATH);
2245
- await mkdir6(dirname7(path), { recursive: true });
2246
- await writeFile8(path, `${JSON.stringify({ run: run3 }, null, 2)}
3066
+ config.run = run3;
3067
+ await mkdir7(dirname8(path), { recursive: true });
3068
+ await writeFile9(path, `${JSON.stringify(config, null, 2)}
2247
3069
  `, "utf8");
2248
3070
  }
2249
3071
  function spawnAgent(command, args, cwd) {
@@ -2254,7 +3076,7 @@ function spawnAgent(command, args, cwd) {
2254
3076
  settled = true;
2255
3077
  resolve(outcome);
2256
3078
  };
2257
- const child = spawn2(command, args, { cwd, stdio: "inherit" });
3079
+ const child = spawn3(command, args, { cwd, stdio: "inherit" });
2258
3080
  child.on("error", (error) => settle({ ok: false, error: error.message }));
2259
3081
  child.on(
2260
3082
  "close",
@@ -2265,21 +3087,34 @@ function spawnAgent(command, args, cwd) {
2265
3087
  });
2266
3088
  }
2267
3089
  async function runWatch(options, deps) {
2268
- const saved = options.run === void 0 ? await readSavedTemplate(options.cwd) : null;
2269
- const raw = options.run ?? saved;
2270
- if (raw === null) {
3090
+ const saved = options.run === void 0 ? await readAgentChoice(options.cwd) : { agent: null, run: null };
3091
+ const raw = options.run ?? saved.run;
3092
+ let template;
3093
+ let shownCommand2;
3094
+ let synthesizedAgent = null;
3095
+ if (raw !== null) {
3096
+ const parsed = parseTemplate(raw);
3097
+ if (!parsed.ok) {
3098
+ deps.error(parsed.error);
3099
+ return { exitCode: 1 };
3100
+ }
3101
+ template = parsed.template;
3102
+ shownCommand2 = raw;
3103
+ } else if (saved.agent !== null && saved.agent !== "custom") {
3104
+ const adapter = KNOWN_AGENTS[saved.agent];
3105
+ template = {
3106
+ command: adapter.binary,
3107
+ args: adapter.terminalArgs(PROMPT_TOKEN)
3108
+ };
3109
+ shownCommand2 = [template.command, ...template.args].join(" ");
3110
+ synthesizedAgent = adapter.name;
3111
+ } else {
2271
3112
  deps.error(
2272
- 'Watch needs an agent command the first time: leglas watch --run "claude -p {prompt}"'
3113
+ 'Watch needs an agent command the first time: pick an agent in the interface, or pass --run "claude -p {prompt}".'
2273
3114
  );
2274
3115
  return { exitCode: 1 };
2275
3116
  }
2276
- const parsed = parseTemplate(raw);
2277
- if (!parsed.ok) {
2278
- deps.error(parsed.error);
2279
- return { exitCode: 1 };
2280
- }
2281
- const template = parsed.template;
2282
- if (options.run !== void 0) await saveTemplate(options.cwd, raw).catch(() => {
3117
+ if (options.run !== void 0) await saveTemplate(options.cwd, options.run).catch(() => {
2283
3118
  });
2284
3119
  const base = `http://localhost:${options.port ?? DEFAULT_PORT}`;
2285
3120
  const heartbeat = async (watching) => {
@@ -2293,11 +3128,15 @@ async function runWatch(options, deps) {
2293
3128
  } catch {
2294
3129
  }
2295
3130
  };
2296
- deps.log(`Watching for change requests. Each one runs: ${raw}`);
3131
+ if (synthesizedAgent !== null) {
3132
+ deps.log(`Using ${synthesizedAgent}, chosen in the interface.`);
3133
+ }
3134
+ deps.log(`Watching for change requests. Each one runs: ${shownCommand2}`);
2297
3135
  deps.log("Stop with Ctrl-C.");
2298
3136
  const failed = /* @__PURE__ */ new Set();
2299
3137
  let stopped = false;
2300
3138
  let busy = false;
3139
+ let announced = false;
2301
3140
  let inflight = null;
2302
3141
  const handle = async (request) => {
2303
3142
  deps.log("");
@@ -2319,7 +3158,12 @@ async function runWatch(options, deps) {
2319
3158
  };
2320
3159
  const tick = async () => {
2321
3160
  if (stopped) return;
2322
- void heartbeat(true);
3161
+ if (announced) {
3162
+ void heartbeat(true);
3163
+ } else {
3164
+ await heartbeat(true);
3165
+ announced = true;
3166
+ }
2323
3167
  if (busy) return;
2324
3168
  busy = true;
2325
3169
  try {
@@ -2336,7 +3180,7 @@ async function runWatch(options, deps) {
2336
3180
  }
2337
3181
  };
2338
3182
  return new Promise((resolve) => {
2339
- const timer = setInterval(() => void tick(), POLL_MS);
3183
+ const timer = setInterval(() => void tick(), POLL_MS2);
2340
3184
  const stop = () => {
2341
3185
  if (stopped) return;
2342
3186
  stopped = true;
@@ -2355,12 +3199,12 @@ async function runWatch(options, deps) {
2355
3199
 
2356
3200
  // src/run-classify.ts
2357
3201
  import { stat } from "fs/promises";
2358
- import { join as join12 } from "path";
3202
+ import { join as join13 } from "path";
2359
3203
  async function runClassify(options, deps) {
2360
3204
  const declared = await Promise.all(
2361
3205
  options.changes.map(async (change) => ({
2362
3206
  ...change,
2363
- exists: await stat(join12(options.cwd, change.path)).then(
3207
+ exists: await stat(join13(options.cwd, change.path)).then(
2364
3208
  () => true,
2365
3209
  () => false
2366
3210
  )
@@ -2388,14 +3232,14 @@ async function runClassify(options, deps) {
2388
3232
  // src/run.ts
2389
3233
  import { existsSync as existsSync5 } from "fs";
2390
3234
  import { createRequire } from "module";
2391
- import { basename as basename3, dirname as dirname8, join as join13, relative as relative2 } from "path";
3235
+ import { basename as basename3, dirname as dirname9, join as join14, relative as relative4 } from "path";
2392
3236
  import { fileURLToPath } from "url";
2393
3237
  function findShellDir() {
2394
- const bundled = join13(dirname8(fileURLToPath(import.meta.url)), "shell");
2395
- if (existsSync5(join13(bundled, "index.html"))) return bundled;
3238
+ const bundled = join14(dirname9(fileURLToPath(import.meta.url)), "shell");
3239
+ if (existsSync5(join14(bundled, "index.html"))) return bundled;
2396
3240
  try {
2397
3241
  const require2 = createRequire(import.meta.url);
2398
- return dirname8(require2.resolve("@leglas/shell/dist/index.html"));
3242
+ return dirname9(require2.resolve("@leglas/shell/dist/index.html"));
2399
3243
  } catch {
2400
3244
  return null;
2401
3245
  }
@@ -2429,7 +3273,7 @@ async function run2(options, deps) {
2429
3273
  const fileMounts = /* @__PURE__ */ new Map();
2430
3274
  for (const preview of merged?.previews ?? []) {
2431
3275
  if (preview.file !== void 0) {
2432
- const absolute = join13(options.cwd, preview.file);
3276
+ const absolute = join14(options.cwd, preview.file);
2433
3277
  if (!existsSync5(absolute)) {
2434
3278
  worktreeErrors.push(
2435
3279
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -2440,7 +3284,7 @@ async function run2(options, deps) {
2440
3284
  for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
2441
3285
  slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
2442
3286
  }
2443
- fileMounts.set(slug, dirname8(absolute));
3287
+ fileMounts.set(slug, dirname9(absolute));
2444
3288
  previews.push({
2445
3289
  ...preview,
2446
3290
  url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
@@ -2501,7 +3345,7 @@ async function run2(options, deps) {
2501
3345
  })
2502
3346
  );
2503
3347
  } else {
2504
- const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative2(options.cwd, loaded.path) || loaded.path;
3348
+ const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative4(options.cwd, loaded.path) || loaded.path;
2505
3349
  deps.log(`Leglas ${url}`);
2506
3350
  deps.log(
2507
3351
  `app ${devServer}${app !== null ? " (started by Leglas)" : health.reachable ? "" : " (not reachable)"}`
@@ -2540,6 +3384,8 @@ async function run2(options, deps) {
2540
3384
  export {
2541
3385
  AGENTS_MARKER_END,
2542
3386
  AGENTS_MARKER_START,
3387
+ DEFAULT_PORT,
3388
+ LEGLAS_PREFIX,
2543
3389
  PROMPT_TOKEN,
2544
3390
  WATCH_PATH,
2545
3391
  baselineFrom,