leglas 1.2.1 → 1.3.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
@@ -169,8 +169,13 @@ function parseClassify(rest) {
169
169
  function parseWatch(rest) {
170
170
  let run4;
171
171
  let port;
172
+ let json = false;
172
173
  for (let index = 0; index < rest.length; index += 1) {
173
174
  const argument = rest[index];
175
+ if (argument === "--json") {
176
+ json = true;
177
+ continue;
178
+ }
174
179
  const equals = argument.indexOf("=");
175
180
  const flag = equals === -1 ? argument : argument.slice(0, equals);
176
181
  if (flag !== "--run" && flag !== "--port") {
@@ -191,7 +196,7 @@ function parseWatch(rest) {
191
196
  if ("error" in parsed2) return { kind: "error", message: parsed2.error };
192
197
  port = parsed2.port;
193
198
  }
194
- return { kind: "watch", run: run4, port };
199
+ return { kind: "watch", run: run4, port, json };
195
200
  }
196
201
  function parseShare(rest) {
197
202
  const titles = [];
@@ -330,12 +335,38 @@ function parseArgs(argv) {
330
335
  let count = 3;
331
336
  let basedOn = null;
332
337
  let json = false;
338
+ let build = false;
339
+ let brief = null;
340
+ let port = null;
333
341
  for (let index = 0; index < rest.length; index += 1) {
334
342
  const argument = rest[index];
335
343
  if (argument === "--json") {
336
344
  json = true;
337
345
  continue;
338
346
  }
347
+ if (argument === "--build") {
348
+ build = true;
349
+ continue;
350
+ }
351
+ if (argument === "--brief" || argument.startsWith("--brief=")) {
352
+ const raw = argument.includes("=") ? argument.slice(argument.indexOf("=") + 1) : rest[index += 1];
353
+ if (raw === void 0 || raw.trim() === "") {
354
+ return {
355
+ kind: "error",
356
+ message: '--brief needs a description, for example --brief "A hero for a cooking app".'
357
+ };
358
+ }
359
+ brief = raw;
360
+ continue;
361
+ }
362
+ if (argument === "--port" || argument.startsWith("--port=")) {
363
+ const raw = argument.includes("=") ? argument.split("=")[1] : rest[index += 1];
364
+ if (raw === void 0 || !/^\d+$/.test(raw)) {
365
+ return { kind: "error", message: "--port needs a number, for example --port 4000." };
366
+ }
367
+ port = Number(raw);
368
+ continue;
369
+ }
339
370
  if (argument === "--count" || argument.startsWith("--count=")) {
340
371
  const raw = argument.includes("=") ? argument.split("=")[1] : rest[index += 1];
341
372
  if (raw === void 0 || !/^\d+$/.test(raw)) {
@@ -369,7 +400,19 @@ function parseArgs(argv) {
369
400
  message: "leglas explore needs a surface name, for example: npx leglas explore hero --count 6"
370
401
  };
371
402
  }
372
- return { kind: "explore", surface, count, basedOn, json };
403
+ if (build && brief === null && basedOn === null) {
404
+ return {
405
+ kind: "error",
406
+ message: 'leglas explore --build needs a brief, for example: npx leglas explore hero --build --brief "A hero for a cooking app". To vary a direction instead, name it with --based-on "<title>".'
407
+ };
408
+ }
409
+ if (!build && brief !== null) {
410
+ return {
411
+ kind: "error",
412
+ message: "--brief goes with --build: add --build to have Leglas build the set."
413
+ };
414
+ }
415
+ return { kind: "explore", surface, count, basedOn, json, build, brief, port };
373
416
  }
374
417
  if (argv[0] === "requests") {
375
418
  const rest = argv.slice(1);
@@ -515,8 +558,8 @@ function parseArgs(argv) {
515
558
  }
516
559
 
517
560
  // src/run-classify.ts
518
- import { stat as stat4 } from "fs/promises";
519
- import { join as join14 } from "path";
561
+ import { stat as stat5 } from "fs/promises";
562
+ import { join as join18 } from "path";
520
563
 
521
564
  // ../server/dist/log.js
522
565
  var DEFAULT_LOG_DIR = "design-log";
@@ -597,8 +640,8 @@ function composeEntry(input) {
597
640
  }
598
641
 
599
642
  // ../server/dist/json.js
600
- function parseJson(text2) {
601
- return JSON.parse(text2);
643
+ function parseJson(text3) {
644
+ return JSON.parse(text3);
602
645
  }
603
646
  function isJsonRecord(value) {
604
647
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -918,7 +961,7 @@ var KNOWN_AGENTS = {
918
961
  // moves that precondition and only that: `-s workspace-write` still
919
962
  // confines writes to the project, so the sandbox boundary is unchanged,
920
963
  // and in a git repository the flag does nothing at all.
921
- args: (prompt, effort = null, images = []) => [
964
+ args: (prompt, effort = null, images2 = []) => [
922
965
  "exec",
923
966
  "--json",
924
967
  ...CODEX_WORKSPACE_CONFIG,
@@ -926,23 +969,23 @@ var KNOWN_AGENTS = {
926
969
  "-s",
927
970
  "workspace-write",
928
971
  "--skip-git-repo-check",
929
- ...images.flatMap((image) => ["-i", image]),
972
+ ...images2.flatMap((image) => ["-i", image]),
930
973
  prompt
931
974
  ],
932
- terminalArgs: (prompt, effort = null, images = []) => [
975
+ terminalArgs: (prompt, effort = null, images2 = []) => [
933
976
  "exec",
934
977
  ...CODEX_WORKSPACE_CONFIG,
935
978
  ...codexEffortConfig(effort),
936
979
  "-s",
937
980
  "workspace-write",
938
981
  "--skip-git-repo-check",
939
- ...images.flatMap((image) => ["-i", image]),
982
+ ...images2.flatMap((image) => ["-i", image]),
940
983
  prompt
941
984
  ],
942
985
  // No sandbox flag here: `codex exec resume` refuses it and inherits the
943
986
  // session's own sandbox, which the first turn set to workspace-write. The
944
987
  // repository check is per invocation, so resume needs the flag of its own.
945
- resumeArgs: (sessionId, prompt, effort = null, images = []) => [
988
+ resumeArgs: (sessionId, prompt, effort = null, images2 = []) => [
946
989
  "exec",
947
990
  "resume",
948
991
  sessionId,
@@ -950,7 +993,7 @@ var KNOWN_AGENTS = {
950
993
  ...CODEX_WORKSPACE_CONFIG,
951
994
  ...codexEffortConfig(effort),
952
995
  "--skip-git-repo-check",
953
- ...images.flatMap((image) => ["-i", image]),
996
+ ...images2.flatMap((image) => ["-i", image]),
954
997
  prompt
955
998
  ],
956
999
  sessionFrom: (event) => event.type === "thread.started" && isString(event.thread_id) ? event.thread_id : null,
@@ -1785,12 +1828,12 @@ function buildNumber(entry) {
1785
1828
  const digits = /(\d+)\s*$/.exec(entry)?.[1];
1786
1829
  return digits === void 0 ? 0 : Number(digits);
1787
1830
  }
1788
- function cacheRoots(platform, home, readdir5 = readableDirectories) {
1831
+ function cacheRoots(platform, home, readdir7 = readableDirectories) {
1789
1832
  const playwright = platform === "darwin" ? join4(home, "Library", "Caches", "ms-playwright") : join4(home, ".cache", "ms-playwright");
1790
1833
  const puppeteer = join4(home, ".cache", "puppeteer");
1791
1834
  const newestFirst = (dir, keep = () => true) => ({
1792
1835
  root: dir,
1793
- entries: readdir5(dir).filter(keep).sort((left, right) => buildNumber(right) - buildNumber(left))
1836
+ entries: readdir7(dir).filter(keep).sort((left, right) => buildNumber(right) - buildNumber(left))
1794
1837
  });
1795
1838
  return [
1796
1839
  newestFirst(playwright, (entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")),
@@ -1811,13 +1854,13 @@ function findBrowser(search = {}) {
1811
1854
  const home = search.home ?? homedir();
1812
1855
  const exists = search.exists ?? existsSync2;
1813
1856
  const onPath = search.onPath ?? ((name) => firstOnPath(name, env));
1814
- const readdir5 = search.readdir ?? readableDirectories;
1857
+ const readdir7 = search.readdir ?? readableDirectories;
1815
1858
  const firstExisting = (paths) => paths.find((path) => exists(path)) ?? null;
1816
1859
  for (const candidate of [env.LEGLAS_BROWSER, env.CHROME_PATH, env.PUPPETEER_EXECUTABLE_PATH]) {
1817
1860
  if (isString(candidate) && candidate !== "" && exists(candidate))
1818
1861
  return candidate;
1819
1862
  }
1820
- const caches = cacheRoots(platform, home, readdir5);
1863
+ const caches = cacheRoots(platform, home, readdir7);
1821
1864
  const shell = firstExisting(caches.flatMap(({ root, entries }) => entries.flatMap((entry) => HEADLESS_SHELL.map((rest) => join4(root, entry, ...rest)))));
1822
1865
  if (shell !== null)
1823
1866
  return shell;
@@ -1847,7 +1890,7 @@ function findBrowser(search = {}) {
1847
1890
  }
1848
1891
  if (platform === "darwin" || platform === "linux") {
1849
1892
  const playwrightRoot = platform === "darwin" ? join4(home, "Library", "Caches", "ms-playwright") : join4(home, ".cache", "ms-playwright");
1850
- const playwright = readdir5(playwrightRoot).filter((entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
1893
+ const playwright = readdir7(playwrightRoot).filter((entry) => entry.startsWith("chromium-") || entry.startsWith("chromium_headless_shell-")).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
1851
1894
  const root = join4(playwrightRoot, entry);
1852
1895
  return [
1853
1896
  ...FOR_TESTING.map((rest) => join4(root, ...rest)),
@@ -1864,7 +1907,7 @@ function findBrowser(search = {}) {
1864
1907
  const puppeteerCache = join4(home, ".cache", "puppeteer");
1865
1908
  for (const kind of ["chrome", "chrome-headless-shell"]) {
1866
1909
  const kindRoot = join4(puppeteerCache, kind);
1867
- const found = firstExisting(readdir5(kindRoot).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
1910
+ const found = firstExisting(readdir7(kindRoot).sort((left, right) => buildNumber(right) - buildNumber(left)).flatMap((entry) => {
1868
1911
  const root = join4(kindRoot, entry);
1869
1912
  return [...FOR_TESTING, ...HEADLESS_SHELL].map((rest) => join4(root, ...rest));
1870
1913
  }));
@@ -1904,7 +1947,7 @@ async function connectWebSocket(url) {
1904
1947
  socket.addEventListener("error", failed, { once: true });
1905
1948
  });
1906
1949
  return {
1907
- send: (text2) => socket.send(text2),
1950
+ send: (text3) => socket.send(text3),
1908
1951
  onMessage: (listener) => socket.addEventListener("message", (event) => listener(String(event.data))),
1909
1952
  onClose: (listener) => socket.addEventListener("close", listener),
1910
1953
  close: () => socket.close()
@@ -2129,10 +2172,10 @@ async function launchBrowser(executable, options = {}) {
2129
2172
  socketClosed = true;
2130
2173
  rejectPending();
2131
2174
  });
2132
- socket.onMessage((text2) => {
2175
+ socket.onMessage((text3) => {
2133
2176
  let message2;
2134
2177
  try {
2135
- const parsed2 = parseJson(text2);
2178
+ const parsed2 = parseJson(text3);
2136
2179
  if (!isJsonRecord(parsed2))
2137
2180
  return;
2138
2181
  message2 = parsed2;
@@ -2497,6 +2540,16 @@ function validBox(value) {
2497
2540
  return false;
2498
2541
  return [value.x, value.y, value.width, value.height].every((entry) => isNumber(entry) && Number.isFinite(entry));
2499
2542
  }
2543
+ function resourced(text3, url) {
2544
+ const line = String(text3 ?? "");
2545
+ if (!isString(url) || url === "")
2546
+ return line;
2547
+ try {
2548
+ return `${line} (${new URL(url).pathname})`;
2549
+ } catch {
2550
+ return line;
2551
+ }
2552
+ }
2500
2553
  function locatorExpression(focus) {
2501
2554
  return `${LOCATOR}(${JSON.stringify(focus.selector)}, ${JSON.stringify(focus.text)}, ${JSON.stringify(focus.tag)})`;
2502
2555
  }
@@ -2565,7 +2618,7 @@ async function render(page, input) {
2565
2618
  }),
2566
2619
  page.on("Log.entryAdded", (params) => {
2567
2620
  if (params?.entry?.level === "error")
2568
- remember(params.entry.text);
2621
+ remember(resourced(params.entry.text, params.entry.url));
2569
2622
  })
2570
2623
  ];
2571
2624
  try {
@@ -2988,8 +3041,8 @@ async function rehomeCaptures(cwd, from, to, attachments) {
2988
3041
  file: attachment.file.startsWith(prefix) ? `${CAPTURES_DIR}/${to}/${attachment.file.slice(prefix.length)}` : attachment.file
2989
3042
  }));
2990
3043
  }
2991
- function rehomeText(text2, from, to) {
2992
- return text2.split(`${CAPTURES_DIR}/${from}/`).join(`${CAPTURES_DIR}/${to}/`);
3044
+ function rehomeText(text3, from, to) {
3045
+ return text3.split(`${CAPTURES_DIR}/${from}/`).join(`${CAPTURES_DIR}/${to}/`);
2993
3046
  }
2994
3047
  async function ownCapturesRoot(cwd) {
2995
3048
  const root = join5(cwd, CAPTURES_DIR);
@@ -3857,13 +3910,13 @@ function imageMediaType(path) {
3857
3910
  return "image/gif";
3858
3911
  return null;
3859
3912
  }
3860
- async function claudeContent(prompt, images) {
3861
- if (images.length === 0)
3913
+ async function claudeContent(prompt, images2) {
3914
+ if (images2.length === 0)
3862
3915
  return prompt;
3863
3916
  const blocks = [
3864
3917
  { type: "text", text: prompt }
3865
3918
  ];
3866
- for (const path of images) {
3919
+ for (const path of images2) {
3867
3920
  const mediaType = imageMediaType(path);
3868
3921
  if (mediaType === null)
3869
3922
  continue;
@@ -4766,7 +4819,7 @@ function isStateUpdater(value) {
4766
4819
  return typeof value === "function";
4767
4820
  }
4768
4821
  var SESSION_TURNS_CAP = 8;
4769
- function resolveCommand(choice, prompt, sessionId = null, allowedCommands = [], images = []) {
4822
+ function resolveCommand(choice, prompt, sessionId = null, allowedCommands = [], images2 = []) {
4770
4823
  if (choice.agent === null)
4771
4824
  return null;
4772
4825
  if (choice.agent === "custom") {
@@ -4793,24 +4846,24 @@ function resolveCommand(choice, prompt, sessionId = null, allowedCommands = [],
4793
4846
  agent: choice.agent,
4794
4847
  name: adapter.name,
4795
4848
  command: adapter.binary,
4796
- args: [...adapter.resumeArgs(sessionId, prompt, choice.effort, images), ...allow],
4849
+ args: [...adapter.resumeArgs(sessionId, prompt, choice.effort, images2), ...allow],
4797
4850
  prompt,
4798
4851
  effort: choice.effort,
4799
4852
  sessionId,
4800
4853
  resumed: true,
4801
- images
4854
+ images: images2
4802
4855
  };
4803
4856
  }
4804
4857
  return {
4805
4858
  agent: choice.agent,
4806
4859
  name: adapter.name,
4807
4860
  command: adapter.binary,
4808
- args: [...adapter.args(prompt, choice.effort, images), ...allow],
4861
+ args: [...adapter.args(prompt, choice.effort, images2), ...allow],
4809
4862
  prompt,
4810
4863
  effort: choice.effort,
4811
4864
  sessionId: null,
4812
4865
  resumed: false,
4813
- images
4866
+ images: images2
4814
4867
  };
4815
4868
  }
4816
4869
  function lineReader(stream, onLine) {
@@ -5096,8 +5149,8 @@ function startRunner(options) {
5096
5149
  `${options.leglasCommand} show`,
5097
5150
  ...request.mode === "variant" ? [registrationCommand(options.leglasCommand)] : []
5098
5151
  ];
5099
- const images = (await Promise.all((request.attachments ?? []).map(async (attachment) => await isOwnCapture(options.cwd, attachment.file) ? resolve2(options.cwd, attachment.file) : null))).filter((image) => image !== null);
5100
- let resolved = resolveCommand(choice, request.prompt, choice.agent === null ? null : resumable(choice.agent), allowedCommands, images);
5152
+ const images2 = (await Promise.all((request.attachments ?? []).map(async (attachment) => await isOwnCapture(options.cwd, attachment.file) ? resolve2(options.cwd, attachment.file) : null))).filter((image) => image !== null);
5153
+ let resolved = resolveCommand(choice, request.prompt, choice.agent === null ? null : resumable(choice.agent), allowedCommands, images2);
5101
5154
  if (resolved === null)
5102
5155
  return;
5103
5156
  const lines2 = [];
@@ -5143,7 +5196,7 @@ function startRunner(options) {
5143
5196
  // nothing says "cancelled". Stopped still means stopped.
5144
5197
  !stopped) {
5145
5198
  sessions.delete(resolved.agent);
5146
- const cold = resolveCommand(choice, request.prompt, null, allowedCommands, images);
5199
+ const cold = resolveCommand(choice, request.prompt, null, allowedCommands, images2);
5147
5200
  if (cold !== null) {
5148
5201
  resolved = cold;
5149
5202
  observed.sessionId = null;
@@ -6837,10 +6890,10 @@ function resolveTitle(input, titles, renames) {
6837
6890
  // ../server/dist/server.js
6838
6891
  import { createReadStream, existsSync as existsSync3, statSync, unwatchFile, watch as watchFs, watchFile } from "fs";
6839
6892
  import { createHash as createHash2 } from "crypto";
6840
- import { mkdir as mkdir8, readdir as readdir3, writeFile as writeFile8 } from "fs/promises";
6893
+ import { mkdir as mkdir8, readdir as readdir5, writeFile as writeFile9 } from "fs/promises";
6841
6894
  import http4 from "http";
6842
6895
  import net3 from "net";
6843
- import { basename as basename3, dirname as dirname8, extname as extname3, join as join12, normalize, relative as relative3 } from "path";
6896
+ import { basename as basename3, dirname as dirname10, extname as extname3, join as join16, normalize as normalize2, relative as relative4 } from "path";
6844
6897
 
6845
6898
  // ../server/dist/frame-policy.js
6846
6899
  var FRAMING_TIMEOUT_MS = 4e3;
@@ -6951,176 +7004,1311 @@ async function checkFraming(target, embedder, fetcher = fetch) {
6951
7004
  }
6952
7005
  }
6953
7006
 
6954
- // ../server/dist/server-info.js
6955
- import { lstat as lstat2, mkdir as mkdir7, readFile as readFile11, rename as rename2, rm as rm4, writeFile as writeFile7 } from "fs/promises";
6956
- import { dirname as dirname7, join as join11 } from "path";
6957
- var SERVER_INFO_PATH = ".leglas/server.json";
6958
- async function writeServerInfo(cwd, info) {
6959
- const path = join11(cwd, SERVER_INFO_PATH);
6960
- const directory = dirname7(path);
6961
- await mkdir7(directory, { recursive: true });
6962
- if (!(await lstat2(directory)).isDirectory())
6963
- return;
6964
- const existing = await lstat2(path).catch(() => null);
6965
- if (existing !== null && !existing.isFile())
6966
- return;
6967
- const temporary = `${path}.${process.pid}.tmp`;
6968
- await writeFile7(temporary, `${JSON.stringify({ ...info, startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)}
6969
- `, "utf8");
6970
- await rename2(temporary, path).catch(async (cause) => {
6971
- await rm4(temporary, { force: true }).catch(() => {
6972
- });
6973
- throw cause;
6974
- });
7007
+ // ../server/dist/generation/generation.js
7008
+ import { spawn as nodeSpawn4 } from "child_process";
7009
+ import { readFile as readFile14, writeFile as writeFile7 } from "fs/promises";
7010
+ import { dirname as dirname8, join as join14 } from "path";
7011
+
7012
+ // ../server/dist/generation/claude.js
7013
+ var GENERATION_EFFORT = "medium";
7014
+ var RESTRICTED = [
7015
+ "--output-format",
7016
+ "stream-json",
7017
+ "--verbose",
7018
+ "--restricted",
7019
+ "--strict-mcp-config"
7020
+ ];
7021
+ function planArgs(prompt, effort = GENERATION_EFFORT) {
7022
+ return ["-p", prompt, ...RESTRICTED, "--tools", "", "--effort", effort];
6975
7023
  }
6976
- async function readServerInfo(cwd) {
7024
+ function buildArgs(prompt, effort = GENERATION_EFFORT) {
7025
+ return [
7026
+ "-p",
7027
+ prompt,
7028
+ ...RESTRICTED,
7029
+ "--tools",
7030
+ "Read,Write,Edit",
7031
+ "--permission-mode",
7032
+ "acceptEdits",
7033
+ "--effort",
7034
+ effort
7035
+ ];
7036
+ }
7037
+ function resultText(lines2) {
7038
+ for (let index = lines2.length - 1; index >= 0; index -= 1) {
7039
+ const line = lines2[index]?.trim() ?? "";
7040
+ if (!line.startsWith("{"))
7041
+ continue;
7042
+ try {
7043
+ const event = parseJson(line);
7044
+ if (isJsonRecord(event) && event.type === "result" && isString(event.result))
7045
+ return event.result;
7046
+ } catch {
7047
+ }
7048
+ }
7049
+ return null;
7050
+ }
7051
+
7052
+ // ../server/dist/generation/codex.js
7053
+ import { readFile as readFile11 } from "fs/promises";
7054
+ import { homedir as homedir2 } from "os";
7055
+ import { join as join11 } from "path";
7056
+ var RESTRICTED2 = [
7057
+ "exec",
7058
+ "--json",
7059
+ "--ephemeral",
7060
+ "--skip-git-repo-check",
7061
+ "-c",
7062
+ `model_reasoning_effort=${GENERATION_EFFORT}`,
7063
+ "-c",
7064
+ "project_doc_max_bytes=0",
7065
+ "-c",
7066
+ "skills.include_instructions=false",
7067
+ "-c",
7068
+ "skills.bundled.enabled=false",
7069
+ // Through the config, not `--disable`: a Codex that lacks one of these
7070
+ // ignores it here, where `--disable` refuses to start at all.
7071
+ ...[
7072
+ "multi_agent",
7073
+ "apps",
7074
+ "plugins",
7075
+ "memories",
7076
+ "goals",
7077
+ "browser_use",
7078
+ "computer_use",
7079
+ "image_generation"
7080
+ ].flatMap((feature) => ["-c", `features.${feature}=false`])
7081
+ ];
7082
+ var BARE = /^[A-Za-z0-9_-]+$/;
7083
+ async function codexServers(home = defaultCodexHome()) {
7084
+ let config;
6977
7085
  try {
6978
- const value = parseJson(await readFile11(join11(cwd, SERVER_INFO_PATH), "utf8"));
6979
- if (!isJsonRecord(value) || !isNumber(value.port) || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535 || !isString(value.url) || value.url === "" || !isNumber(value.pid) || !Number.isInteger(value.pid))
6980
- return null;
6981
- return { port: value.port, url: value.url, pid: value.pid };
7086
+ config = await readFile11(join11(home, "config.toml"), "utf8");
6982
7087
  } catch {
6983
- return null;
7088
+ return [];
6984
7089
  }
6985
- }
6986
- async function removeServerInfo(cwd, expected) {
6987
- if (expected !== void 0) {
6988
- const current = await readServerInfo(cwd);
6989
- if (current !== null && (current.port !== expected.port || current.pid !== expected.pid))
6990
- return;
7090
+ const names = /* @__PURE__ */ new Set();
7091
+ for (const match of config.matchAll(/^\s*\[\s*mcp_servers\s*\.\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\s*\]\s*(?:#.*)?$/gm)) {
7092
+ const name = match[1] ?? match[2] ?? match[3] ?? "";
7093
+ if (BARE.test(name))
7094
+ names.add(name);
6991
7095
  }
6992
- await rm4(join11(cwd, SERVER_INFO_PATH), { force: true });
7096
+ return [...names];
6993
7097
  }
6994
-
6995
- // ../server/dist/server.js
6996
- var LEGLAS_PREFIX = "/leglas";
6997
- var DEFAULT_PORT = 4100;
6998
- var PORT_ATTEMPTS = 20;
6999
- var REFERENCE_MAX_BYTES = 1e7;
7000
- var ATTACHED_WINDOW_MS = 6e3;
7001
- var CONTENT_TYPES = {
7002
- ".css": "text/css; charset=utf-8",
7003
- ".gif": "image/gif",
7004
- ".html": "text/html; charset=utf-8",
7005
- ".ico": "image/x-icon",
7006
- ".jpeg": "image/jpeg",
7007
- ".jpg": "image/jpeg",
7008
- ".js": "text/javascript; charset=utf-8",
7009
- ".json": "application/json; charset=utf-8",
7010
- ".mjs": "text/javascript; charset=utf-8",
7011
- ".png": "image/png",
7012
- ".svg": "image/svg+xml",
7013
- ".txt": "text/plain; charset=utf-8",
7014
- ".webp": "image/webp",
7015
- ".woff": "font/woff",
7016
- ".woff2": "font/woff2"
7017
- };
7018
- function isContentExtension(extension) {
7019
- return Object.hasOwn(CONTENT_TYPES, extension);
7098
+ function defaultCodexHome(env = process.env) {
7099
+ return env.CODEX_HOME !== void 0 && env.CODEX_HOME !== "" ? env.CODEX_HOME : join11(homedir2(), ".codex");
7020
7100
  }
7021
- var FILES_PREFIX = `${LEGLAS_PREFIX}/files`;
7022
- function sendJson2(res, status, body) {
7023
- const payload = JSON.stringify(body);
7024
- res.writeHead(status, {
7025
- "content-type": "application/json; charset=utf-8",
7026
- "cache-control": "no-store"
7027
- });
7028
- res.end(payload);
7101
+ var serversOff = (servers) => servers.flatMap((name) => ["-c", `mcp_servers.${name}.enabled=false`]);
7102
+ function codexPlanArgs(prompt, servers) {
7103
+ return [...RESTRICTED2, ...serversOff(servers), "-s", "read-only", prompt];
7029
7104
  }
7030
- function etagMatches(value, etag) {
7031
- if (value === void 0)
7105
+ function codexBuildArgs(prompt, servers) {
7106
+ return [...RESTRICTED2, ...serversOff(servers), "-s", "workspace-write", prompt];
7107
+ }
7108
+ function codexResultText(lines2) {
7109
+ for (let index = lines2.length - 1; index >= 0; index -= 1) {
7110
+ const line = lines2[index]?.trim() ?? "";
7111
+ if (!line.startsWith("{"))
7112
+ continue;
7113
+ try {
7114
+ const event = parseJson(line);
7115
+ const item = isJsonRecord(event) ? event.item : void 0;
7116
+ if (isJsonRecord(event) && event.type === "item.completed" && isJsonRecord(item) && item.type === "agent_message" && isString(item.text))
7117
+ return item.text;
7118
+ } catch {
7119
+ }
7120
+ }
7121
+ return null;
7122
+ }
7123
+
7124
+ // ../server/dist/generation/facts.js
7125
+ import { readFile as readFile13, readdir as readdir4 } from "fs/promises";
7126
+ import { dirname as dirname7, join as join13, normalize } from "path";
7127
+
7128
+ // ../server/dist/generation/switch-file.js
7129
+ import { readFile as readFile12, readdir as readdir3, stat as stat4 } from "fs/promises";
7130
+ import { join as join12, relative as relative3 } from "path";
7131
+ var MAP = /const DIRECTIONS = \{([\s\S]*?)\} as const;/;
7132
+ var SOURCE = /\.(tsx|jsx|ts|js)$/;
7133
+ var SCAN_LIMIT = 3e3;
7134
+ async function isFile(path) {
7135
+ try {
7136
+ return (await stat4(path)).isFile();
7137
+ } catch {
7032
7138
  return false;
7033
- const values = Array.isArray(value) ? value : [value];
7034
- return values.some((header) => header.split(",").some((candidate) => {
7035
- const tag = candidate.trim();
7036
- return tag === "*" || tag === etag || tag === `W/${etag}`;
7037
- }));
7139
+ }
7038
7140
  }
7039
- function sendConditionalJson(req, res, body) {
7040
- const payload = JSON.stringify(body);
7041
- const etag = `"${createHash2("sha256").update(payload).digest("base64url")}"`;
7042
- if (etagMatches(req.headers["if-none-match"], etag)) {
7043
- res.writeHead(304, { etag, "cache-control": "private, no-cache" });
7044
- res.end();
7045
- return;
7141
+ async function findSwitch(cwd, slug) {
7142
+ for (const name of ["switch.tsx", "switch.jsx"]) {
7143
+ const path = join12(".leglas", "variants", slug, name);
7144
+ if (await isFile(join12(cwd, path)))
7145
+ return path;
7046
7146
  }
7047
- res.writeHead(200, {
7048
- "content-type": "application/json; charset=utf-8",
7049
- "cache-control": "private, no-cache",
7050
- etag
7147
+ const wanted = `"v-${slug}"`;
7148
+ let seen = 0;
7149
+ const walk = async (folder) => {
7150
+ let items;
7151
+ try {
7152
+ items = await readdir3(join12(cwd, folder), { withFileTypes: true });
7153
+ } catch {
7154
+ return null;
7155
+ }
7156
+ for (const item of items) {
7157
+ if (seen >= SCAN_LIMIT || item.name.startsWith(".") || item.name === "node_modules")
7158
+ continue;
7159
+ const path = join12(folder, item.name);
7160
+ if (item.isDirectory()) {
7161
+ const found = await walk(path);
7162
+ if (found !== null)
7163
+ return found;
7164
+ continue;
7165
+ }
7166
+ if (!SOURCE.test(item.name))
7167
+ continue;
7168
+ seen += 1;
7169
+ const source = await readFile12(join12(cwd, path), "utf8").catch(() => "");
7170
+ if (source.includes(wanted) && MAP.test(source))
7171
+ return path;
7172
+ }
7173
+ return null;
7174
+ };
7175
+ for (const root of ["src", "app"]) {
7176
+ const found = await walk(root);
7177
+ if (found !== null)
7178
+ return relative3(cwd, join12(cwd, found));
7179
+ }
7180
+ return null;
7181
+ }
7182
+ function readDirections(source) {
7183
+ const imports = /* @__PURE__ */ new Map();
7184
+ for (const match of source.matchAll(/^import \{ (\w+) \} from "([^"]+)";$/gm)) {
7185
+ imports.set(match[1] ?? "", match[2] ?? "");
7186
+ }
7187
+ const body = MAP.exec(source)?.[1] ?? "";
7188
+ const directions = [];
7189
+ for (const match of body.matchAll(/"?([\w-]+)"?\s*:\s*(\w+)/g)) {
7190
+ const key = match[1] ?? "";
7191
+ const name = match[2] ?? "";
7192
+ const from = imports.get(name);
7193
+ if (from !== void 0)
7194
+ directions.push({ key, name, from });
7195
+ }
7196
+ return directions;
7197
+ }
7198
+ function directionKeys(source) {
7199
+ const body = MAP.exec(source)?.[1] ?? "";
7200
+ return [...body.matchAll(/"?([\w-]+)"?\s*:/g)].flatMap((match) => match[1] === void 0 ? [] : [match[1]]);
7201
+ }
7202
+ function fallbackKey(source) {
7203
+ return /const FALLBACK: \w+ = "([\w-]+)";/.exec(source)?.[1] ?? null;
7204
+ }
7205
+ function addSlots(source, slots) {
7206
+ if (!MAP.test(source))
7207
+ throw new Error("The switch file has no DIRECTIONS map.");
7208
+ const lines2 = source.split("\n");
7209
+ let last = -1;
7210
+ lines2.forEach((line, index) => {
7211
+ if (line.startsWith("import "))
7212
+ last = index;
7051
7213
  });
7052
- res.end(payload);
7214
+ lines2.splice(last + 1, 0, ...slots.map((slot) => `import { ${slot.name} } from "./${slot.key}";`));
7215
+ const added = slots.map((slot) => ` "${slot.key}": ${slot.name},`).join("\n");
7216
+ return lines2.join("\n").replace(MAP, (_, body) => `const DIRECTIONS = {
7217
+ ${body.replace(/^\s*\n|\s+$/g, "").replace(/,?$/, ",")}
7218
+ ${added}
7219
+ } as const;`);
7220
+ }
7221
+ function placeholderSource(name) {
7222
+ return `export function ${name}() {
7223
+ return null;
7053
7224
  }
7054
- var CAPTURE_DEADLINE_MS = 15e3;
7055
- var CAPTURE_LOAD_MS = Math.floor(CAPTURE_DEADLINE_MS * LOAD_SHARE);
7056
- function captureSlug(title) {
7057
- return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "direction";
7225
+ `;
7058
7226
  }
7059
- function referenceName(value) {
7060
- const raw = Array.isArray(value) ? value[0] ?? "" : value ?? "";
7061
- const safe = [...raw].filter((character) => {
7062
- const code = character.charCodeAt(0);
7063
- return code >= 32 && code <= 126 && character !== "/" && character !== "\\";
7064
- }).join("").trim().slice(0, 80);
7065
- return safe || "image";
7227
+
7228
+ // ../server/dist/generation/facts.js
7229
+ var IMAGE = /\.(png|jpe?g|webp|avif|gif|svg)$/i;
7230
+ var IMAGE_LIMIT = 24;
7231
+ var EXAMPLE_LINES = 250;
7232
+ var VARIED_LINES = 600;
7233
+ function clip(source, limit) {
7234
+ const lines2 = source.replace(/\n$/, "").split("\n");
7235
+ if (lines2.length <= limit)
7236
+ return source;
7237
+ const hidden = lines2.length - limit;
7238
+ return [
7239
+ ...lines2.slice(0, limit),
7240
+ `// \u2026 ${hidden} more ${hidden === 1 ? "line" : "lines"}, not shown`
7241
+ ].join("\n");
7242
+ }
7243
+ var BASELINE_LINES = 15;
7244
+ var STUB = /Re-export what your|A first direction for/;
7245
+ async function text2(path) {
7246
+ return readFile13(path, "utf8").catch(() => null);
7247
+ }
7248
+ async function firstFile(cwd, candidates) {
7249
+ for (const candidate of candidates) {
7250
+ if (await text2(join13(cwd, candidate)) !== null)
7251
+ return candidate;
7252
+ }
7253
+ return null;
7066
7254
  }
7067
- function isAddressInUse(cause) {
7068
- return typeof cause === "object" && cause !== null && "code" in cause && cause.code === "EADDRINUSE";
7255
+ function cssImports(source, from) {
7256
+ return [...source.matchAll(/import\s+["']([^"']+\.css)["']/g)].map((match) => normalize(join13(dirname7(from), match[1] ?? "")));
7069
7257
  }
7070
- function isKnownAgent(value) {
7071
- return isString(value) && Object.hasOwn(KNOWN_AGENTS, value);
7258
+ async function globalStylesheet(cwd) {
7259
+ const entries = [];
7260
+ const html = await text2(join13(cwd, "index.html"));
7261
+ const script = html === null ? null : /<script[^>]+type="module"[^>]+src="\/?([^"]+)"/.exec(html);
7262
+ if (script?.[1] !== void 0)
7263
+ entries.push(script[1]);
7264
+ for (const root of ["app", "src/app"]) {
7265
+ for (const extension of ["tsx", "jsx", "ts", "js"])
7266
+ entries.push(`${root}/layout.${extension}`);
7267
+ }
7268
+ const sheets = [];
7269
+ for (const entry of entries) {
7270
+ const source = await text2(join13(cwd, entry));
7271
+ if (source !== null)
7272
+ sheets.push(...cssImports(source, entry));
7273
+ }
7274
+ for (const sheet of sheets) {
7275
+ const source = await text2(join13(cwd, sheet));
7276
+ if (source !== null && /:root\s*\{|@font-face/.test(source))
7277
+ return sheet;
7278
+ }
7279
+ return firstFile(cwd, sheets);
7072
7280
  }
7073
- function isAllowedMutationHost(hostname) {
7074
- const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
7075
- if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1")
7076
- return true;
7077
- if (bare.endsWith(".local"))
7078
- return true;
7079
- if (!net3.isIPv4(bare))
7080
- return false;
7081
- const [first, second] = bare.split(".").map(Number);
7082
- return first === 10 || first === 172 && second !== void 0 && second >= 16 && second <= 31 || first === 192 && second === 168;
7281
+ async function images(cwd) {
7282
+ const found = [];
7283
+ const walk = async (folder) => {
7284
+ let items;
7285
+ try {
7286
+ items = await readdir4(join13(cwd, "public", folder), { withFileTypes: true });
7287
+ } catch {
7288
+ return;
7289
+ }
7290
+ for (const item of items) {
7291
+ if (found.length >= IMAGE_LIMIT || item.name.startsWith("."))
7292
+ continue;
7293
+ const path = folder === "" ? item.name : `${folder}/${item.name}`;
7294
+ if (item.isDirectory())
7295
+ await walk(path);
7296
+ else if (IMAGE.test(item.name))
7297
+ found.push(`/${path}`);
7298
+ }
7299
+ };
7300
+ await walk("");
7301
+ return found;
7083
7302
  }
7084
- function isLoopbackAddress(address) {
7085
- if (address === void 0)
7086
- return false;
7087
- return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1" || address.startsWith("127.");
7303
+ async function componentFile(cwd, from, specifier) {
7304
+ const base = normalize(join13(dirname7(from), specifier));
7305
+ return firstFile(cwd, [
7306
+ base,
7307
+ ...["tsx", "jsx", "ts", "js"].map((extension) => `${base}.${extension}`)
7308
+ ]);
7088
7309
  }
7089
- function isTrustedMutation(req) {
7090
- if (!isLoopbackAddress(req.socket.remoteAddress))
7091
- return false;
7092
- if (!isString(req.headers.host))
7093
- return false;
7094
- let host;
7310
+ async function example(cwd, switchPath, prefer) {
7311
+ const source = await text2(join13(cwd, switchPath));
7312
+ if (source === null)
7313
+ return null;
7314
+ const directions = readDirections(source);
7315
+ const first = prefer ?? fallbackKey(source);
7316
+ const ordered = [...directions].sort((a, b) => Number(b.key === first) - Number(a.key === first));
7317
+ for (const direction of ordered) {
7318
+ const limit = direction.key === prefer ? VARIED_LINES : EXAMPLE_LINES;
7319
+ const path = await componentFile(cwd, switchPath, direction.from);
7320
+ const body = path === null ? null : await text2(join13(cwd, path));
7321
+ if (path === null || body === null || body === placeholderSource(direction.name) || STUB.test(body))
7322
+ continue;
7323
+ if (body.split("\n").length < BASELINE_LINES) {
7324
+ const target = /from\s+["'](\.[^"']+)["']/.exec(body)?.[1];
7325
+ const followed = target === void 0 ? null : await componentFile(cwd, path, target);
7326
+ const real = followed === null ? null : await text2(join13(cwd, followed));
7327
+ if (followed !== null && real !== null) {
7328
+ return {
7329
+ key: direction.key,
7330
+ path: followed,
7331
+ source: clip(real, limit)
7332
+ };
7333
+ }
7334
+ continue;
7335
+ }
7336
+ return { key: direction.key, path, source: clip(body, limit) };
7337
+ }
7338
+ return null;
7339
+ }
7340
+ async function dependencyNames(cwd) {
7095
7341
  try {
7096
- host = new URL(`http://${req.headers.host}`);
7342
+ const pkg = parseJson(await text2(join13(cwd, "package.json")) ?? "");
7343
+ return isJsonRecord(pkg) && isJsonRecord(pkg.dependencies) ? Object.keys(pkg.dependencies) : [];
7097
7344
  } catch {
7098
- return false;
7345
+ return [];
7099
7346
  }
7100
- if (!isAllowedMutationHost(host.hostname))
7101
- return false;
7102
- const rawOrigin = req.headers.origin;
7103
- if (rawOrigin === void 0)
7104
- return true;
7347
+ }
7348
+ async function readProjectFacts(cwd, switchPath, prefer = null) {
7349
+ const dependencies = await dependencyNames(cwd);
7350
+ const sheetPath = await globalStylesheet(cwd);
7351
+ const css = sheetPath === null ? null : await text2(join13(cwd, sheetPath));
7352
+ return {
7353
+ dependencies,
7354
+ stylesheet: sheetPath === null || css === null ? null : {
7355
+ path: sheetPath,
7356
+ fonts: [
7357
+ ...new Set([...css.matchAll(/font-family:\s*["']([^"']+)["']/g)].map((match) => match[1] ?? ""))
7358
+ ],
7359
+ tokens: /:root\s*\{[\s\S]*?\n\}/.exec(css)?.[0] ?? ""
7360
+ },
7361
+ images: await images(cwd),
7362
+ example: await example(cwd, switchPath, prefer)
7363
+ };
7364
+ }
7365
+ function factsBlock(facts, surface, varied = null) {
7366
+ const lines2 = [
7367
+ "Leglas read the project for you. Follow its house style: differ in the idea, not in the craft."
7368
+ ];
7369
+ if (facts.dependencies.length > 0)
7370
+ lines2.push(`Dependencies: ${facts.dependencies.join(", ")}.`);
7371
+ if (facts.stylesheet !== null) {
7372
+ const fonts = facts.stylesheet.fonts.length === 0 ? "" : ` It declares the fonts ${facts.stylesheet.fonts.join(", ")}.`;
7373
+ lines2.push(`The global stylesheet ${facts.stylesheet.path} is already loaded on every page.${fonts}`);
7374
+ if (facts.stylesheet.tokens !== "")
7375
+ lines2.push("It sets these tokens:", "```css", facts.stylesheet.tokens, "```");
7376
+ }
7377
+ if (facts.images.length > 0)
7378
+ lines2.push(`Images the project already serves from its root: ${facts.images.join(", ")}.`);
7379
+ if (facts.example !== null) {
7380
+ lines2.push(varied?.key === facts.example.key ? `${varied.title}, the direction being varied, is ${facts.example.path}. Its imports are relative to that file:` : `The ${surface}'s current direction, ${facts.example.path}, shows how directions here are written:`, "```tsx", facts.example.source.trimEnd(), "```");
7381
+ }
7382
+ return lines2.join("\n");
7383
+ }
7384
+
7385
+ // ../server/dist/generation/prompts.js
7386
+ function described(base) {
7387
+ const idea = base.idea.replace(/\.$/, "");
7388
+ return idea === "" ? `"${base.title}"` : `"${base.title}" (${idea})`;
7389
+ }
7390
+ function steer(brief) {
7391
+ if (brief === "")
7392
+ return ".";
7393
+ return `. What to explore: ${/[.!?]$/.test(brief) ? brief : `${brief}.`}`;
7394
+ }
7395
+ var ANSWER = "Answer from this brief alone: do not read, search or run anything. Reply with only a JSON array of objects with key (kebab-case), title and idea (one line).";
7396
+ function planPrompt(input) {
7397
+ const { base } = input;
7398
+ if (base !== void 0 && base !== null) {
7399
+ return `Propose ${input.count} variations of the ${input.surface} direction ${described(base)}${steer(input.brief)} Every variation stays recognisably ${base.title} and changes one deliberate thing; name each for what it changes. Decide them together so no two change the same thing. ${ANSWER}`;
7400
+ }
7401
+ const already = input.existing.length === 0 ? "" : ` The ${input.surface} already has these directions: ${input.existing.join(", ")}; yours must differ from them too.`;
7402
+ return `Propose ${input.count} genuinely different design directions for the ${input.surface} of this product: ${input.brief} Decide all of them together; if two would look alike at a glance, replace one.${already} ${ANSWER}`;
7403
+ }
7404
+ function replacePrompt2(input) {
7405
+ const avoid = input.avoid.map((concept) => `${concept.title} (${concept.idea})`).join("; ");
7406
+ const { base } = input;
7407
+ if (base !== void 0 && base !== null) {
7408
+ return `Propose 1 more variation of the ${input.surface} direction ${described(base)}${steer(input.brief)} It stays recognisably ${base.title} and changes something none of these change: ${avoid}. ${ANSWER}`;
7409
+ }
7410
+ return `Propose 1 design direction for the ${input.surface} of this product: ${input.brief} It must not look like any of these: ${avoid}. ${ANSWER}`;
7411
+ }
7412
+ function buildPrompt(input) {
7413
+ const others = input.others.map((other) => `${other.title} (${other.idea})`).join("; ");
7414
+ const { base } = input;
7415
+ const varying = base !== void 0 && base !== null;
7416
+ const rivals = others === "" ? "" : varying ? ` The other variations are ${others}; change something different from them.` : ` The other directions are ${others}; yours must not look like any of them.`;
7417
+ const what = varying ? `Build this variation of the ${input.surface} direction "${base.title}": ${input.concept.title}, ${input.concept.idea.replace(/\.$/, "")}. Keep it recognisably ${base.title} and change only what the variation names.` : `Build this design direction for the ${input.surface}: ${input.concept.title}, ${input.concept.idea.replace(/\.$/, "")}.`;
7418
+ const product = varying ? steer(input.brief).slice(1) : ` The product: ${input.brief}`;
7419
+ return `${what}${product}${rivals}
7420
+
7421
+ Everything you need is in this message, so do not look around the project. It is ${input.stack}. Your file is ${input.file}. It holds a placeholder: read it once, then replace it in a single write with a component exported as ${input.name}. Keep every new style in that file, in a <style> element or inline.
7422
+
7423
+ ${input.facts}
7424
+
7425
+ This is a first draft for someone comparing directions side by side, not a finished page. Aim for about 250 lines and write it in one pass: no second drafts, and no review, lint, tests or screenshots. Leglas renders and captures it for you. Your task ends when the file is written.`;
7426
+ }
7427
+ function fixPrompt(input) {
7428
+ return `Leglas rendered ${input.file} and the page reported: ${input.errors.slice(0, 3).join(" | ")}. Fix what your file causes, in as few edits as you can, and change nothing else. If nothing in your file causes it, change nothing. Do not look around the project.`;
7429
+ }
7430
+ function kebab(value) {
7431
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
7432
+ }
7433
+ function pascal(key) {
7434
+ return key.split("-").filter((part) => part !== "").map((part) => /^\d/.test(part) ? `N${part}` : part[0].toUpperCase() + part.slice(1)).join("");
7435
+ }
7436
+ function parseConcepts(reply, count) {
7437
+ const start = reply.indexOf("[");
7438
+ const end = reply.lastIndexOf("]");
7439
+ if (start === -1 || end < start)
7440
+ throw new Error("The plan reply held no JSON array.");
7441
+ let parsed2;
7105
7442
  try {
7106
- const origin = new URL(rawOrigin);
7107
- return origin.protocol === "http:" && origin.host === host.host;
7443
+ parsed2 = parseJson(reply.slice(start, end + 1));
7108
7444
  } catch {
7109
- return false;
7445
+ throw new Error("The plan reply's JSON did not parse.");
7110
7446
  }
7447
+ if (!Array.isArray(parsed2))
7448
+ throw new Error("The plan reply held no JSON array.");
7449
+ const concepts = [];
7450
+ for (const entry of parsed2) {
7451
+ if (!isJsonRecord(entry))
7452
+ continue;
7453
+ const title = isString(entry.title) ? entry.title.trim() : "";
7454
+ const key = kebab(isString(entry.key) && entry.key.trim() !== "" ? entry.key : title);
7455
+ if (title === "" || key === "" || concepts.some((concept) => concept.key === key))
7456
+ continue;
7457
+ concepts.push({ key, title, idea: isString(entry.idea) ? entry.idea.trim() : "" });
7458
+ if (concepts.length === count)
7459
+ return concepts;
7460
+ }
7461
+ throw new Error(`The plan reply named fewer than ${count} distinct directions.`);
7462
+ }
7463
+
7464
+ // ../server/dist/generation/generation.js
7465
+ var MAX_DIRECTIONS = 6;
7466
+ var BUILD_DEADLINE_MS = 5 * 6e4;
7467
+ var PLAN_DEADLINE_MS = 2 * 6e4;
7468
+ var FIX_DEADLINE_MS = 3 * 6e4;
7469
+ var KILL_GRACE_MS = 5e3;
7470
+ var RECHECK_MS = 1500;
7471
+ var OWN_FAILURES = /* @__PURE__ */ new Set([
7472
+ "broken",
7473
+ "not-written",
7474
+ "too-slow",
7475
+ "unexpected"
7476
+ ]);
7477
+ var BUSY = "A set of directions is already being built. Wait for it, or stop it first.";
7478
+ var KEPT_JOBS = 5;
7479
+ var CLAUDE = {
7480
+ id: "claude",
7481
+ name: "Claude",
7482
+ plan: (prompt) => planArgs(prompt),
7483
+ build: (prompt) => buildArgs(prompt),
7484
+ result: resultText
7485
+ };
7486
+ function codex(servers) {
7487
+ return {
7488
+ id: "codex",
7489
+ name: "Codex",
7490
+ plan: (prompt) => codexPlanArgs(prompt, servers),
7491
+ build: (prompt) => codexBuildArgs(prompt, servers),
7492
+ result: codexResultText
7493
+ };
7111
7494
  }
7112
- function isEnded(request, failedIds) {
7113
- return isTerminal(request.status) || failedIds.includes(request.id);
7495
+ function wait(milliseconds) {
7496
+ return new Promise((resolve5) => {
7497
+ setTimeout(resolve5, milliseconds).unref?.();
7498
+ });
7114
7499
  }
7115
- function jsonBody(body) {
7500
+ function surfaceSlug(surface) {
7501
+ return surface.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
7502
+ }
7503
+ function baseOf(preview, surface) {
7504
+ let key;
7116
7505
  try {
7117
- const parsed2 = parseJson(body || "{}");
7118
- return isJsonRecord(parsed2) ? parsed2 : null;
7506
+ key = new URL(preview.url, "http://leglas.invalid").searchParams.get(`v-${surfaceSlug(surface)}`);
7119
7507
  } catch {
7120
7508
  return null;
7121
7509
  }
7510
+ return key === null || key === "" ? null : { title: preview.title, key, idea: preview.note ?? "" };
7122
7511
  }
7123
- function requestIntent(value) {
7512
+ async function stackOf(cwd, switchPath) {
7513
+ let deps = {};
7514
+ try {
7515
+ const pkg = parseJson(await readFile14(join14(cwd, "package.json"), "utf8"));
7516
+ if (isJsonRecord(pkg)) {
7517
+ for (const group of [pkg.devDependencies, pkg.dependencies]) {
7518
+ if (!isJsonRecord(group))
7519
+ continue;
7520
+ for (const [name, version2] of Object.entries(group)) {
7521
+ if (isString(version2))
7522
+ deps[name] = version2;
7523
+ }
7524
+ }
7525
+ }
7526
+ } catch {
7527
+ deps = {};
7528
+ }
7529
+ const major = /\d+/.exec(deps.react ?? "")?.[0];
7530
+ const react = major === void 0 ? "React" : `React ${major}`;
7531
+ const typescript = switchPath.endsWith(".tsx") ? " and TypeScript" : "";
7532
+ if (deps.next !== void 0)
7533
+ return `a Next.js app with ${react}${typescript}`;
7534
+ if (deps.vite !== void 0)
7535
+ return `a Vite app with ${react}${typescript}`;
7536
+ return `an app with ${react}${typescript}`;
7537
+ }
7538
+ function provenance(job) {
7539
+ const words = job.surface.replace(/-/g, " ");
7540
+ return {
7541
+ askedFor: job.brief === "" ? void 0 : job.brief,
7542
+ // Variations sit under the direction they vary, as any family does on the rail.
7543
+ basedOn: job.basedOn ?? void 0,
7544
+ tags: [words.charAt(0).toUpperCase() + words.slice(1)]
7545
+ };
7546
+ }
7547
+ function copy(job) {
7548
+ return {
7549
+ ...job,
7550
+ slots: job.slots.map((slot) => ({
7551
+ ...slot,
7552
+ failure: slot.failure === null ? null : { ...slot.failure }
7553
+ }))
7554
+ };
7555
+ }
7556
+ function createGenerations(deps) {
7557
+ const now = deps.now ?? Date.now;
7558
+ const spawn7 = deps.spawn ?? ((command, args, options) => nodeSpawn4(command, args, { ...options, env: agentEnvironment() }));
7559
+ const lives = [];
7560
+ let closed = false;
7561
+ const busy = () => lives.some((live) => live.job.state === "planning" || live.job.state === "building");
7562
+ const changed = () => deps.onChange();
7563
+ let registry = Promise.resolve();
7564
+ const serially = (task) => {
7565
+ const next = registry.then(task, task);
7566
+ registry = next.catch(() => {
7567
+ });
7568
+ return next;
7569
+ };
7570
+ const run4 = (agent, args, deadlineMs, onLine) => {
7571
+ const lines2 = [];
7572
+ let timedOut2 = false;
7573
+ let child = null;
7574
+ const done = new Promise((resolve5) => {
7575
+ if (closed) {
7576
+ resolve5({ code: null, lines: lines2, error: "Leglas is closing.", timedOut: timedOut2 });
7577
+ return;
7578
+ }
7579
+ try {
7580
+ child = spawn7(agent.id, args, {
7581
+ cwd: deps.cwd,
7582
+ shell: false,
7583
+ stdio: ["ignore", "pipe", "pipe"],
7584
+ ...ownGroup()
7585
+ });
7586
+ } catch (error) {
7587
+ resolve5({
7588
+ code: null,
7589
+ lines: lines2,
7590
+ error: error instanceof Error ? error.message : String(error),
7591
+ timedOut: timedOut2
7592
+ });
7593
+ return;
7594
+ }
7595
+ let buffered = "";
7596
+ const take = (chunk) => {
7597
+ buffered += chunk.toString();
7598
+ const parts = buffered.split("\n");
7599
+ buffered = parts.pop() ?? "";
7600
+ lines2.push(...parts);
7601
+ if (onLine !== void 0)
7602
+ for (const line of parts)
7603
+ onLine(line);
7604
+ };
7605
+ child.stdout.on("data", take);
7606
+ child.stderr.on("data", take);
7607
+ const deadline = setTimeout(() => {
7608
+ timedOut2 = true;
7609
+ if (child !== null)
7610
+ signalTree(child, "SIGTERM");
7611
+ setTimeout(() => {
7612
+ if (child !== null)
7613
+ signalTree(child, "SIGKILL");
7614
+ }, KILL_GRACE_MS).unref?.();
7615
+ }, deadlineMs);
7616
+ deadline.unref?.();
7617
+ child.once("error", (error) => {
7618
+ clearTimeout(deadline);
7619
+ resolve5({ code: null, lines: lines2, error: error.message, timedOut: timedOut2 });
7620
+ });
7621
+ child.once("close", (code) => {
7622
+ clearTimeout(deadline);
7623
+ if (buffered !== "")
7624
+ lines2.push(buffered);
7625
+ resolve5({ code, lines: lines2, error: null, timedOut: timedOut2 });
7626
+ });
7627
+ });
7628
+ return {
7629
+ done,
7630
+ stop: async () => {
7631
+ if (child === null)
7632
+ return;
7633
+ signalTree(child, "SIGTERM");
7634
+ const exited = await Promise.race([
7635
+ done.then(() => true),
7636
+ wait(KILL_GRACE_MS).then(() => false)
7637
+ ]);
7638
+ if (!exited && child !== null) {
7639
+ signalTree(child, "SIGKILL");
7640
+ await Promise.race([done, wait(KILL_GRACE_MS)]);
7641
+ }
7642
+ }
7643
+ };
7644
+ };
7645
+ const failureOf2 = (agent, outcome) => classifyFailure({
7646
+ agent: agent.name,
7647
+ error: outcome.error,
7648
+ exitCode: outcome.code,
7649
+ lines: outcome.lines.slice(-40)
7650
+ });
7651
+ const settle = (live) => {
7652
+ const { job } = live;
7653
+ for (const slot of job.slots) {
7654
+ if (slot.state !== "building" && slot.state !== "checking")
7655
+ slot.activity = null;
7656
+ }
7657
+ if (job.state === "planning" || job.state === "failed")
7658
+ return;
7659
+ if (job.slots.some((slot) => slot.state === "building" || slot.state === "checking")) {
7660
+ job.state = "building";
7661
+ return;
7662
+ }
7663
+ job.state = job.slots.every((slot) => slot.state === "stopped") ? "stopped" : "done";
7664
+ job.endedAt = now();
7665
+ };
7666
+ const restore = async (slot) => {
7667
+ await writeFile7(join14(deps.cwd, slot.file), placeholderSource(pascal(slot.key)), "utf8").catch(() => {
7668
+ });
7669
+ };
7670
+ const begin = (live, slot) => {
7671
+ const attempt = (live.attempts.get(slot.key) ?? 0) + 1;
7672
+ live.attempts.set(slot.key, attempt);
7673
+ return attempt;
7674
+ };
7675
+ const owns = (live, slot, attempt) => live.attempts.get(slot.key) === attempt;
7676
+ const guarded = (work, onError) => work.catch((error) => onError(error instanceof Error ? error.message : String(error)));
7677
+ const detach = (live, work, onError) => {
7678
+ const kept = guarded(work, onError);
7679
+ live.pending.add(kept);
7680
+ void kept.then(() => live.pending.delete(kept));
7681
+ };
7682
+ const drain = async (live) => {
7683
+ while (live.pending.size > 0)
7684
+ await Promise.all(live.pending);
7685
+ };
7686
+ const release = async (live, slot) => {
7687
+ const running = live.runs.get(slot.key);
7688
+ await running?.stop();
7689
+ await restore(slot);
7690
+ if (running !== void 0 && live.runs.get(slot.key) === running)
7691
+ live.runs.delete(slot.key);
7692
+ };
7693
+ const fail = (live, slot, failure) => {
7694
+ slot.state = "failed";
7695
+ slot.failure = failure;
7696
+ slot.endedAt = now();
7697
+ detach(live, restore(slot), () => {
7698
+ });
7699
+ if (OWN_FAILURES.has(failure.code))
7700
+ return;
7701
+ const same = live.job.slots.filter((other) => other.failure?.code === failure.code).length;
7702
+ if (same >= 2) {
7703
+ for (const other of live.job.slots) {
7704
+ if (other.state !== "building" && other.state !== "checking")
7705
+ continue;
7706
+ begin(live, other);
7707
+ other.state = "stopped";
7708
+ other.failure = {
7709
+ code: "same-failure",
7710
+ message: `Stopped after two directions failed the same way: ${failure.message}`
7711
+ };
7712
+ other.endedAt = now();
7713
+ detach(live, release(live, other), () => {
7714
+ });
7715
+ }
7716
+ }
7717
+ };
7718
+ const lost = (live, slot, attempt, message2) => {
7719
+ if (!owns(live, slot, attempt))
7720
+ return;
7721
+ fail(live, slot, { code: "unexpected", message: message2 });
7722
+ settle(live);
7723
+ changed();
7724
+ };
7725
+ const follow = (live, slot, attempt) => (line) => {
7726
+ const activity = activityFrom(live.agent.id, line, deps.cwd);
7727
+ if (activity === null || activity === slot.activity || !owns(live, slot, attempt))
7728
+ return;
7729
+ slot.activity = activity;
7730
+ changed();
7731
+ };
7732
+ const build = async (live, slot, attempt) => {
7733
+ const concept = live.concepts.get(slot.key);
7734
+ if (concept === void 0 || !owns(live, slot, attempt))
7735
+ return;
7736
+ slot.state = "building";
7737
+ if (slot.startedAt === null)
7738
+ slot.startedAt = now();
7739
+ slot.endedAt = null;
7740
+ slot.failure = null;
7741
+ slot.fixed = false;
7742
+ slot.activity = null;
7743
+ changed();
7744
+ const others = [...live.concepts.values()].filter((other) => other !== concept);
7745
+ const prompt = buildPrompt({
7746
+ surface: live.job.surface,
7747
+ brief: live.job.brief,
7748
+ concept,
7749
+ others,
7750
+ file: slot.file,
7751
+ name: pascal(slot.key),
7752
+ stack: live.stack,
7753
+ facts: live.facts ?? "",
7754
+ base: live.base
7755
+ });
7756
+ const building = run4(live.agent, live.agent.build(prompt), BUILD_DEADLINE_MS, follow(live, slot, attempt));
7757
+ live.runs.set(slot.key, building);
7758
+ const outcome = await building.done;
7759
+ if (!owns(live, slot, attempt))
7760
+ return;
7761
+ if (live.runs.get(slot.key) === building)
7762
+ live.runs.delete(slot.key);
7763
+ const written = await readFile14(join14(deps.cwd, slot.file), "utf8").catch(() => "");
7764
+ if (!owns(live, slot, attempt))
7765
+ return;
7766
+ if (outcome.timedOut) {
7767
+ fail(live, slot, {
7768
+ code: "too-slow",
7769
+ message: `The build took longer than ${BUILD_DEADLINE_MS / 6e4} minutes, so Leglas stopped it.`
7770
+ });
7771
+ } else if (outcome.error !== null || outcome.code !== 0) {
7772
+ fail(live, slot, failureOf2(live.agent, outcome));
7773
+ } else if (written === placeholderSource(pascal(slot.key))) {
7774
+ fail(live, slot, {
7775
+ code: "not-written",
7776
+ message: "The build finished without writing its file."
7777
+ });
7778
+ } else {
7779
+ slot.state = "checking";
7780
+ slot.activity = null;
7781
+ changed();
7782
+ await check(live, slot, attempt);
7783
+ }
7784
+ settle(live);
7785
+ changed();
7786
+ };
7787
+ const ownErrors = async (live, slot, attempt) => {
7788
+ const others = live.job.slots.filter((other) => other !== slot);
7789
+ const until = now() + BUILD_DEADLINE_MS + FIX_DEADLINE_MS;
7790
+ for (; ; ) {
7791
+ const report = await deps.render(slot.title);
7792
+ if (!owns(live, slot, attempt))
7793
+ return null;
7794
+ const errors = report?.errors ?? [];
7795
+ const names = (file) => errors.some((error) => error.includes(file));
7796
+ const waiting = !names(slot.file) && others.some((other) => (other.state === "building" || other.state === "checking") && names(other.file));
7797
+ if (!waiting || now() >= until) {
7798
+ return errors.filter((error) => !others.some((other) => error.includes(other.file)));
7799
+ }
7800
+ await wait(RECHECK_MS);
7801
+ if (!owns(live, slot, attempt))
7802
+ return null;
7803
+ }
7804
+ };
7805
+ const check = async (live, slot, attempt) => {
7806
+ let errors = await ownErrors(live, slot, attempt);
7807
+ if (errors === null)
7808
+ return;
7809
+ if (errors.length > 0) {
7810
+ const fixing = run4(live.agent, live.agent.build(fixPrompt({ file: slot.file, errors })), FIX_DEADLINE_MS, follow(live, slot, attempt));
7811
+ live.runs.set(slot.key, fixing);
7812
+ const outcome = await fixing.done;
7813
+ if (!owns(live, slot, attempt))
7814
+ return;
7815
+ slot.activity = null;
7816
+ changed();
7817
+ if (live.runs.get(slot.key) === fixing)
7818
+ live.runs.delete(slot.key);
7819
+ if (outcome.timedOut || outcome.error !== null || outcome.code !== 0) {
7820
+ fail(live, slot, outcome.timedOut ? { code: "too-slow", message: "The fix took too long, so Leglas stopped it." } : failureOf2(live.agent, outcome));
7821
+ return;
7822
+ }
7823
+ slot.fixed = true;
7824
+ errors = await ownErrors(live, slot, attempt);
7825
+ if (errors === null)
7826
+ return;
7827
+ if (errors.length > 0) {
7828
+ fail(live, slot, {
7829
+ code: "broken",
7830
+ message: `The page still reports: ${errors[0] ?? ""}`
7831
+ });
7832
+ return;
7833
+ }
7834
+ }
7835
+ slot.state = "ready";
7836
+ slot.endedAt = now();
7837
+ };
7838
+ const freeTitle = (title, taken) => {
7839
+ let candidate = title;
7840
+ for (let suffix = 2; taken.has(candidate); suffix += 1)
7841
+ candidate = `${title} ${suffix}`;
7842
+ taken.add(candidate);
7843
+ return candidate;
7844
+ };
7845
+ const plan = async (live, existing) => {
7846
+ const { job } = live;
7847
+ const planning = run4(live.agent, live.agent.plan(planPrompt({
7848
+ surface: job.surface,
7849
+ brief: job.brief,
7850
+ count: job.count,
7851
+ existing,
7852
+ base: live.base
7853
+ })), PLAN_DEADLINE_MS);
7854
+ live.plan = planning;
7855
+ const outcome = await planning.done;
7856
+ live.plan = null;
7857
+ if (job.state !== "planning")
7858
+ return;
7859
+ if (outcome.timedOut || outcome.error !== null || outcome.code !== 0) {
7860
+ job.state = "failed";
7861
+ job.error = outcome.timedOut ? "Planning took too long, so Leglas stopped it." : failureOf2(live.agent, outcome).message;
7862
+ job.endedAt = now();
7863
+ changed();
7864
+ return;
7865
+ }
7866
+ let concepts;
7867
+ try {
7868
+ concepts = parseConcepts(live.agent.result(outcome.lines) ?? "", job.count);
7869
+ } catch (error) {
7870
+ job.state = "failed";
7871
+ job.error = error instanceof Error ? error.message : String(error);
7872
+ job.endedAt = now();
7873
+ changed();
7874
+ return;
7875
+ }
7876
+ live.facts = factsBlock(await readProjectFacts(deps.cwd, live.switchPath, live.base?.key ?? null), job.surface, live.base);
7877
+ const slug = surfaceSlug(job.surface);
7878
+ const keys = new Set(existing);
7879
+ const titles = new Set(await deps.titles());
7880
+ const folder = dirname8(live.switchPath);
7881
+ const slots = [];
7882
+ const extension = live.switchPath.endsWith(".jsx") ? "jsx" : "tsx";
7883
+ const taken = async (key) => keys.has(key) || await isFile(join14(deps.cwd, folder, `${key}.${extension}`));
7884
+ for (const concept of concepts) {
7885
+ let key = `${slug}-${concept.key}`;
7886
+ for (let suffix = 2; await taken(key); suffix += 1)
7887
+ key = `${slug}-${concept.key}-${suffix}`;
7888
+ keys.add(key);
7889
+ const title = freeTitle(concept.title, titles);
7890
+ live.concepts.set(key, { ...concept, title });
7891
+ slots.push({
7892
+ key,
7893
+ title,
7894
+ idea: concept.idea,
7895
+ file: join14(folder, `${key}.${extension}`),
7896
+ state: "building",
7897
+ startedAt: null,
7898
+ endedAt: null,
7899
+ failure: null,
7900
+ fixed: false,
7901
+ activity: null
7902
+ });
7903
+ }
7904
+ const source = await readFile14(join14(deps.cwd, live.switchPath), "utf8");
7905
+ for (const slot of slots) {
7906
+ await writeFile7(join14(deps.cwd, slot.file), placeholderSource(pascal(slot.key)), {
7907
+ encoding: "utf8",
7908
+ flag: "wx"
7909
+ });
7910
+ }
7911
+ await writeFile7(join14(deps.cwd, live.switchPath), addSlots(source, slots.map((slot) => ({ key: slot.key, name: pascal(slot.key) }))), "utf8");
7912
+ for (const slot of slots) {
7913
+ await serially(() => deps.register({
7914
+ title: slot.title,
7915
+ url: `/?v-${slug}=${slot.key}`,
7916
+ note: slot.idea,
7917
+ ...provenance(job)
7918
+ }));
7919
+ }
7920
+ job.slots = slots;
7921
+ if (job.state !== "planning") {
7922
+ for (const slot of slots) {
7923
+ slot.state = "stopped";
7924
+ slot.endedAt = now();
7925
+ }
7926
+ changed();
7927
+ return;
7928
+ }
7929
+ job.state = "building";
7930
+ job.plannedAt = now();
7931
+ changed();
7932
+ await Promise.all(job.slots.map((slot) => {
7933
+ const attempt = begin(live, slot);
7934
+ return guarded(build(live, slot, attempt), (message2) => lost(live, slot, attempt, message2));
7935
+ }));
7936
+ };
7937
+ const find = (id) => lives.find((live) => live.job.id === id);
7938
+ const stop = async (id, key) => {
7939
+ const live = find(id);
7940
+ if (live === void 0)
7941
+ return false;
7942
+ const { job } = live;
7943
+ if (key === void 0 && job.state === "planning") {
7944
+ job.state = "stopped";
7945
+ job.endedAt = now();
7946
+ changed();
7947
+ await live.plan?.stop();
7948
+ await drain(live);
7949
+ return true;
7950
+ }
7951
+ const targets = job.slots.filter((slot) => (key === void 0 || slot.key === key) && (slot.state === "building" || slot.state === "checking"));
7952
+ for (const slot of targets) {
7953
+ begin(live, slot);
7954
+ slot.state = "stopped";
7955
+ slot.endedAt = now();
7956
+ }
7957
+ settle(live);
7958
+ changed();
7959
+ await Promise.all(targets.map((slot) => release(live, slot)));
7960
+ return targets.length > 0;
7961
+ };
7962
+ return {
7963
+ async start(request) {
7964
+ if (closed)
7965
+ return { ok: false, error: "Leglas is closing." };
7966
+ const agent = request.agent.agent;
7967
+ if (agent !== "claude" && agent !== "codex") {
7968
+ return {
7969
+ ok: false,
7970
+ error: "Building directions runs on Claude or Codex. Choose one of them as the agent to use it."
7971
+ };
7972
+ }
7973
+ const count = Math.floor(request.count);
7974
+ if (!Number.isFinite(count) || count < 1 || count > MAX_DIRECTIONS) {
7975
+ return { ok: false, error: `Ask for between 1 and ${MAX_DIRECTIONS} directions.` };
7976
+ }
7977
+ const brief = request.brief.trim();
7978
+ const base = request.basedOn ?? null;
7979
+ if (brief === "" && base === null)
7980
+ return { ok: false, error: "Describe what the directions are for." };
7981
+ const slug = surfaceSlug(request.surface);
7982
+ if (slug === "")
7983
+ return { ok: false, error: "Name the surface to build directions for." };
7984
+ if (busy())
7985
+ return { ok: false, error: BUSY };
7986
+ const switchPath = await findSwitch(deps.cwd, slug);
7987
+ if (switchPath === null) {
7988
+ return {
7989
+ ok: false,
7990
+ error: `The ${slug} has no switch file yet. Run \`leglas new ${slug} --from <your component>\` and render ${pascal(slug)}Switch where the ${slug} is, then build directions into it.`
7991
+ };
7992
+ }
7993
+ const existing = directionKeys(await readFile14(join14(deps.cwd, switchPath), "utf8"));
7994
+ if (base !== null && !existing.includes(base.key)) {
7995
+ return {
7996
+ ok: false,
7997
+ error: `${base.title} is not a direction of the ${slug}, so Leglas cannot build variations of it.`
7998
+ };
7999
+ }
8000
+ const unbuilt = base === null ? void 0 : lives.flatMap((kept) => kept.job.slots).find((slot) => slot.key === base.key && slot.state !== "ready");
8001
+ if (base !== null && unbuilt !== void 0) {
8002
+ return {
8003
+ ok: false,
8004
+ error: `${base.title} has no finished design yet, so there is nothing to vary. Retry it first.`
8005
+ };
8006
+ }
8007
+ const job = {
8008
+ id: `gen-${now().toString(36)}`,
8009
+ surface: slug,
8010
+ brief,
8011
+ count,
8012
+ state: "planning",
8013
+ startedAt: now(),
8014
+ plannedAt: null,
8015
+ endedAt: null,
8016
+ error: null,
8017
+ slots: [],
8018
+ basedOn: base?.title ?? null,
8019
+ agent
8020
+ };
8021
+ const live = {
8022
+ job,
8023
+ concepts: /* @__PURE__ */ new Map(),
8024
+ runs: /* @__PURE__ */ new Map(),
8025
+ plan: null,
8026
+ switchPath,
8027
+ facts: null,
8028
+ stack: await stackOf(deps.cwd, switchPath),
8029
+ attempts: /* @__PURE__ */ new Map(),
8030
+ base,
8031
+ agent: agent === "codex" ? codex(await codexServers(deps.codexHome)) : CLAUDE,
8032
+ pending: /* @__PURE__ */ new Set()
8033
+ };
8034
+ if (busy())
8035
+ return { ok: false, error: BUSY };
8036
+ lives.push(live);
8037
+ while (lives.length > KEPT_JOBS)
8038
+ lives.shift();
8039
+ changed();
8040
+ detach(live, plan(live, existing), (message2) => {
8041
+ job.state = "failed";
8042
+ job.error = message2;
8043
+ job.endedAt = now();
8044
+ changed();
8045
+ });
8046
+ return { ok: true, job: copy(job) };
8047
+ },
8048
+ snapshot: () => lives.map((live) => copy(live.job)),
8049
+ stop,
8050
+ retry(id, key) {
8051
+ const live = find(id);
8052
+ const slot = live?.job.slots.find((candidate) => candidate.key === key);
8053
+ if (closed || live === void 0 || slot === void 0 || slot.state !== "failed" && slot.state !== "stopped" || live.runs.has(slot.key))
8054
+ return false;
8055
+ const attempt = begin(live, slot);
8056
+ slot.state = "building";
8057
+ slot.startedAt = now();
8058
+ slot.failure = null;
8059
+ detach(live, restore(slot).then(() => build(live, slot, attempt)), (message2) => lost(live, slot, attempt, message2));
8060
+ live.job.state = "building";
8061
+ live.job.endedAt = null;
8062
+ changed();
8063
+ return true;
8064
+ },
8065
+ replace(id, key) {
8066
+ const live = find(id);
8067
+ const slot = live?.job.slots.find((candidate) => candidate.key === key);
8068
+ if (closed || live === void 0 || slot === void 0 || slot.state === "building" || slot.state === "checking" || live.runs.has(slot.key))
8069
+ return false;
8070
+ const attempt = begin(live, slot);
8071
+ slot.state = "building";
8072
+ slot.startedAt = now();
8073
+ slot.failure = null;
8074
+ live.job.state = "building";
8075
+ live.job.endedAt = null;
8076
+ changed();
8077
+ const replacing = async () => {
8078
+ const asking = run4(live.agent, live.agent.plan(replacePrompt2({
8079
+ surface: live.job.surface,
8080
+ brief: live.job.brief,
8081
+ avoid: [...live.concepts.values()],
8082
+ base: live.base
8083
+ })), PLAN_DEADLINE_MS);
8084
+ live.runs.set(slot.key, asking);
8085
+ const outcome = await asking.done;
8086
+ if (!owns(live, slot, attempt))
8087
+ return;
8088
+ if (live.runs.get(slot.key) === asking)
8089
+ live.runs.delete(slot.key);
8090
+ let concept;
8091
+ try {
8092
+ if (outcome.code !== 0)
8093
+ throw new Error(failureOf2(live.agent, outcome).message);
8094
+ concept = parseConcepts(live.agent.result(outcome.lines) ?? "", 1)[0];
8095
+ } catch (error) {
8096
+ fail(live, slot, {
8097
+ code: "unreadable-plan",
8098
+ message: error instanceof Error ? error.message : String(error)
8099
+ });
8100
+ settle(live);
8101
+ changed();
8102
+ return;
8103
+ }
8104
+ const titles = new Set(await deps.titles());
8105
+ if (!owns(live, slot, attempt))
8106
+ return;
8107
+ titles.delete(slot.title);
8108
+ const title = freeTitle(concept.title, titles);
8109
+ await serially(async () => {
8110
+ await deps.unregister([slot.title]);
8111
+ return deps.register({
8112
+ title,
8113
+ url: `/?v-${surfaceSlug(live.job.surface)}=${slot.key}`,
8114
+ note: concept.idea,
8115
+ ...provenance(live.job)
8116
+ });
8117
+ });
8118
+ live.concepts.set(slot.key, { ...concept, title });
8119
+ slot.title = title;
8120
+ slot.idea = concept.idea;
8121
+ if (!owns(live, slot, attempt))
8122
+ return;
8123
+ await restore(slot);
8124
+ await build(live, slot, attempt);
8125
+ };
8126
+ detach(live, replacing(), (message2) => lost(live, slot, attempt, message2));
8127
+ return true;
8128
+ },
8129
+ async close() {
8130
+ closed = true;
8131
+ await Promise.race([
8132
+ Promise.all(lives.map(async (live) => {
8133
+ await stop(live.job.id);
8134
+ await drain(live);
8135
+ })),
8136
+ wait(KILL_GRACE_MS * 2 + 1e3)
8137
+ ]);
8138
+ }
8139
+ };
8140
+ }
8141
+
8142
+ // ../server/dist/server-info.js
8143
+ import { lstat as lstat2, mkdir as mkdir7, readFile as readFile15, rename as rename2, rm as rm4, writeFile as writeFile8 } from "fs/promises";
8144
+ import { dirname as dirname9, join as join15 } from "path";
8145
+ var SERVER_INFO_PATH = ".leglas/server.json";
8146
+ async function writeServerInfo(cwd, info) {
8147
+ const path = join15(cwd, SERVER_INFO_PATH);
8148
+ const directory = dirname9(path);
8149
+ await mkdir7(directory, { recursive: true });
8150
+ if (!(await lstat2(directory)).isDirectory())
8151
+ return;
8152
+ const existing = await lstat2(path).catch(() => null);
8153
+ if (existing !== null && !existing.isFile())
8154
+ return;
8155
+ const temporary = `${path}.${process.pid}.tmp`;
8156
+ await writeFile8(temporary, `${JSON.stringify({ ...info, startedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)}
8157
+ `, "utf8");
8158
+ await rename2(temporary, path).catch(async (cause) => {
8159
+ await rm4(temporary, { force: true }).catch(() => {
8160
+ });
8161
+ throw cause;
8162
+ });
8163
+ }
8164
+ async function readServerInfo(cwd) {
8165
+ try {
8166
+ const value = parseJson(await readFile15(join15(cwd, SERVER_INFO_PATH), "utf8"));
8167
+ if (!isJsonRecord(value) || !isNumber(value.port) || !Number.isInteger(value.port) || value.port < 1 || value.port > 65535 || !isString(value.url) || value.url === "" || !isNumber(value.pid) || !Number.isInteger(value.pid))
8168
+ return null;
8169
+ return { port: value.port, url: value.url, pid: value.pid };
8170
+ } catch {
8171
+ return null;
8172
+ }
8173
+ }
8174
+ async function removeServerInfo(cwd, expected) {
8175
+ if (expected !== void 0) {
8176
+ const current = await readServerInfo(cwd);
8177
+ if (current !== null && (current.port !== expected.port || current.pid !== expected.pid))
8178
+ return;
8179
+ }
8180
+ await rm4(join15(cwd, SERVER_INFO_PATH), { force: true });
8181
+ }
8182
+
8183
+ // ../server/dist/server.js
8184
+ var LEGLAS_PREFIX = "/leglas";
8185
+ var DEFAULT_PORT = 4100;
8186
+ var PORT_ATTEMPTS = 20;
8187
+ var REFERENCE_MAX_BYTES = 1e7;
8188
+ var ATTACHED_WINDOW_MS = 6e3;
8189
+ var CONTENT_TYPES = {
8190
+ ".css": "text/css; charset=utf-8",
8191
+ ".gif": "image/gif",
8192
+ ".html": "text/html; charset=utf-8",
8193
+ ".ico": "image/x-icon",
8194
+ ".jpeg": "image/jpeg",
8195
+ ".jpg": "image/jpeg",
8196
+ ".js": "text/javascript; charset=utf-8",
8197
+ ".json": "application/json; charset=utf-8",
8198
+ ".mjs": "text/javascript; charset=utf-8",
8199
+ ".png": "image/png",
8200
+ ".svg": "image/svg+xml",
8201
+ ".txt": "text/plain; charset=utf-8",
8202
+ ".webp": "image/webp",
8203
+ ".woff": "font/woff",
8204
+ ".woff2": "font/woff2"
8205
+ };
8206
+ function isContentExtension(extension) {
8207
+ return Object.hasOwn(CONTENT_TYPES, extension);
8208
+ }
8209
+ var FILES_PREFIX = `${LEGLAS_PREFIX}/files`;
8210
+ function sendJson2(res, status, body) {
8211
+ const payload = JSON.stringify(body);
8212
+ res.writeHead(status, {
8213
+ "content-type": "application/json; charset=utf-8",
8214
+ "cache-control": "no-store"
8215
+ });
8216
+ res.end(payload);
8217
+ }
8218
+ function etagMatches(value, etag) {
8219
+ if (value === void 0)
8220
+ return false;
8221
+ const values = Array.isArray(value) ? value : [value];
8222
+ return values.some((header) => header.split(",").some((candidate) => {
8223
+ const tag = candidate.trim();
8224
+ return tag === "*" || tag === etag || tag === `W/${etag}`;
8225
+ }));
8226
+ }
8227
+ function sendConditionalJson(req, res, body) {
8228
+ const payload = JSON.stringify(body);
8229
+ const etag = `"${createHash2("sha256").update(payload).digest("base64url")}"`;
8230
+ if (etagMatches(req.headers["if-none-match"], etag)) {
8231
+ res.writeHead(304, { etag, "cache-control": "private, no-cache" });
8232
+ res.end();
8233
+ return;
8234
+ }
8235
+ res.writeHead(200, {
8236
+ "content-type": "application/json; charset=utf-8",
8237
+ "cache-control": "private, no-cache",
8238
+ etag
8239
+ });
8240
+ res.end(payload);
8241
+ }
8242
+ var CAPTURE_DEADLINE_MS = 15e3;
8243
+ var CAPTURE_LOAD_MS = Math.floor(CAPTURE_DEADLINE_MS * LOAD_SHARE);
8244
+ function captureSlug(title) {
8245
+ return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "direction";
8246
+ }
8247
+ function referenceName(value) {
8248
+ const raw = Array.isArray(value) ? value[0] ?? "" : value ?? "";
8249
+ const safe = [...raw].filter((character) => {
8250
+ const code = character.charCodeAt(0);
8251
+ return code >= 32 && code <= 126 && character !== "/" && character !== "\\";
8252
+ }).join("").trim().slice(0, 80);
8253
+ return safe || "image";
8254
+ }
8255
+ function isAddressInUse(cause) {
8256
+ return typeof cause === "object" && cause !== null && "code" in cause && cause.code === "EADDRINUSE";
8257
+ }
8258
+ function isKnownAgent(value) {
8259
+ return isString(value) && Object.hasOwn(KNOWN_AGENTS, value);
8260
+ }
8261
+ function isAllowedMutationHost(hostname) {
8262
+ const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
8263
+ if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1")
8264
+ return true;
8265
+ if (bare.endsWith(".local"))
8266
+ return true;
8267
+ if (!net3.isIPv4(bare))
8268
+ return false;
8269
+ const [first, second] = bare.split(".").map(Number);
8270
+ return first === 10 || first === 172 && second !== void 0 && second >= 16 && second <= 31 || first === 192 && second === 168;
8271
+ }
8272
+ function isLoopbackAddress(address) {
8273
+ if (address === void 0)
8274
+ return false;
8275
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1" || address.startsWith("127.");
8276
+ }
8277
+ function isTrustedMutation(req) {
8278
+ if (!isLoopbackAddress(req.socket.remoteAddress))
8279
+ return false;
8280
+ if (!isString(req.headers.host))
8281
+ return false;
8282
+ let host;
8283
+ try {
8284
+ host = new URL(`http://${req.headers.host}`);
8285
+ } catch {
8286
+ return false;
8287
+ }
8288
+ if (!isAllowedMutationHost(host.hostname))
8289
+ return false;
8290
+ const rawOrigin = req.headers.origin;
8291
+ if (rawOrigin === void 0)
8292
+ return true;
8293
+ try {
8294
+ const origin = new URL(rawOrigin);
8295
+ return origin.protocol === "http:" && origin.host === host.host;
8296
+ } catch {
8297
+ return false;
8298
+ }
8299
+ }
8300
+ function isEnded(request, failedIds) {
8301
+ return isTerminal(request.status) || failedIds.includes(request.id);
8302
+ }
8303
+ function jsonBody(body) {
8304
+ try {
8305
+ const parsed2 = parseJson(body || "{}");
8306
+ return isJsonRecord(parsed2) ? parsed2 : null;
8307
+ } catch {
8308
+ return null;
8309
+ }
8310
+ }
8311
+ function requestIntent(value) {
7124
8312
  if (value === null || value === void 0)
7125
8313
  return "";
7126
8314
  return isString(value) ? value.trim() : null;
@@ -7150,8 +8338,8 @@ function probe(target, timeoutMs = 1e3) {
7150
8338
  });
7151
8339
  }
7152
8340
  function serveFrom(res, dir, relativePath) {
7153
- const relative6 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
7154
- const candidate = join12(dir, relative6);
8341
+ const relative8 = normalize2(relativePath).replace(/^(\.\.[/\\])+/, "");
8342
+ const candidate = join16(dir, relative8);
7155
8343
  if (!candidate.startsWith(dir))
7156
8344
  return false;
7157
8345
  if (!existsSync3(candidate) || !statSync(candidate).isFile())
@@ -7165,25 +8353,25 @@ function serveFrom(res, dir, relativePath) {
7165
8353
  return true;
7166
8354
  }
7167
8355
  function serveShellFile(res, shellDir, urlPath) {
7168
- const relative6 = normalize(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
7169
- const isRoot = relative6 === "" || relative6 === "." || relative6 === "/";
7170
- return serveFrom(res, shellDir, isRoot ? "index.html" : relative6);
8356
+ const relative8 = normalize2(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
8357
+ const isRoot = relative8 === "" || relative8 === "." || relative8 === "/";
8358
+ return serveFrom(res, shellDir, isRoot ? "index.html" : relative8);
7171
8359
  }
7172
8360
  var HEALTH_PROBE_MS = 3e3;
7173
8361
  function fileStamp(path) {
7174
8362
  try {
7175
- const stat5 = statSync(path);
7176
- return `${stat5.dev}:${stat5.ino}:${stat5.size}:${stat5.mtimeMs}:${stat5.ctimeMs}`;
8363
+ const stat6 = statSync(path);
8364
+ return `${stat6.dev}:${stat6.ino}:${stat6.size}:${stat6.mtimeMs}:${stat6.ctimeMs}`;
7177
8365
  } catch {
7178
8366
  return null;
7179
8367
  }
7180
8368
  }
7181
8369
  function watchLiveFiles(cwd, configPath, live) {
7182
- const leglasDir = join12(cwd, ".leglas");
8370
+ const leglasDir = join16(cwd, ".leglas");
7183
8371
  const targets = [
7184
- { path: join12(cwd, LOCAL_PREVIEWS_PATH), change: "config" },
7185
- { path: join12(cwd, REQUESTS_PATH), change: "requests" },
7186
- { path: join12(cwd, ANNOTATIONS_PATH), change: "requests" }
8372
+ { path: join16(cwd, LOCAL_PREVIEWS_PATH), change: "config" },
8373
+ { path: join16(cwd, REQUESTS_PATH), change: "requests" },
8374
+ { path: join16(cwd, ANNOTATIONS_PATH), change: "requests" }
7187
8375
  ];
7188
8376
  const byName = new Map(targets.map((target) => [basename3(target.path), target]));
7189
8377
  const known = new Map(targets.map((target) => [target.path, fileStamp(target.path)]));
@@ -7297,7 +8485,7 @@ function watchLiveFiles(cwd, configPath, live) {
7297
8485
  }
7298
8486
  if (configPath !== null) {
7299
8487
  try {
7300
- const directory = dirname8(configPath);
8488
+ const directory = dirname10(configPath);
7301
8489
  const name = basename3(configPath);
7302
8490
  const target = { path: configPath, change: "config" };
7303
8491
  known.set(configPath, fileStamp(configPath));
@@ -7335,7 +8523,7 @@ function watchLiveFiles(cwd, configPath, live) {
7335
8523
  }
7336
8524
  };
7337
8525
  }
7338
- function watchHealth(target, live) {
8526
+ function watchHealth(target, live, intervalMs) {
7339
8527
  let previous = null;
7340
8528
  let probing = false;
7341
8529
  let closed = false;
@@ -7358,7 +8546,7 @@ function watchHealth(target, live) {
7358
8546
  }).finally(() => {
7359
8547
  probing = false;
7360
8548
  });
7361
- }, HEALTH_PROBE_MS);
8549
+ }, intervalMs);
7362
8550
  timer.unref?.();
7363
8551
  return {
7364
8552
  close: () => {
@@ -7382,15 +8570,15 @@ function configStalenessNotice(cwd, boot, current) {
7382
8570
  if (boot === null && current === null)
7383
8571
  return null;
7384
8572
  if (boot === null && current !== null) {
7385
- const label = relative3(cwd, current.path) || current.path;
8573
+ const label = relative4(cwd, current.path) || current.path;
7386
8574
  return `${label} appeared after Leglas started. Restart leglas to pick it up.`;
7387
8575
  }
7388
8576
  if (boot !== null && current === null) {
7389
- const label = relative3(cwd, boot.path) || boot.path;
8577
+ const label = relative4(cwd, boot.path) || boot.path;
7390
8578
  return `${label} was removed after Leglas started. Restart leglas to run without it.`;
7391
8579
  }
7392
8580
  if (boot !== null && current !== null && (boot.path !== current.path || boot.mtimeMs !== current.mtimeMs)) {
7393
- const label = relative3(cwd, current.path) || current.path;
8581
+ const label = relative4(cwd, current.path) || current.path;
7394
8582
  return `${label} changed after Leglas started. Restart leglas to pick it up.`;
7395
8583
  }
7396
8584
  return null;
@@ -7436,7 +8624,7 @@ async function bind2(server, requested) {
7436
8624
  throw new Error(`No free port between ${requested} and ${requested + PORT_ATTEMPTS - 1}.`);
7437
8625
  }
7438
8626
  async function startServer(options) {
7439
- const { config, configErrors = [], configWarnings = [], shellDir = null, project = "", cwd = process.cwd(), leglasCommand = "npx -y leglas", fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
8627
+ const { config, configErrors = [], configWarnings = [], shellDir = null, project = "", cwd = process.cwd(), leglasCommand = "npx -y leglas", fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents(), captureDeadlineMs = CAPTURE_DEADLINE_MS, healthProbeMs = HEALTH_PROBE_MS } = options;
7440
8628
  const browserPool = options.pool ?? createBrowserPool();
7441
8629
  let shares = null;
7442
8630
  let liveHealth = null;
@@ -7481,6 +8669,7 @@ async function startServer(options) {
7481
8669
  let lastSeen = null;
7482
8670
  const externallyAttached = () => lastSeen !== null && Date.now() - lastSeen < ATTACHED_WINDOW_MS;
7483
8671
  let runner = null;
8672
+ let generations = null;
7484
8673
  let agentsCache = null;
7485
8674
  let agentsInflight = null;
7486
8675
  const AGENTS_FRESH_MS = 3e4;
@@ -7531,11 +8720,36 @@ async function startServer(options) {
7531
8720
  });
7532
8721
  });
7533
8722
  };
8723
+ const readGenerationBody = (req, res, run4) => {
8724
+ if (!hasJsonBody(req)) {
8725
+ return sendJson2(res, 400, { ok: false, error: "A generation request must be JSON." });
8726
+ }
8727
+ let body = "";
8728
+ req.on("data", (chunk) => body += chunk);
8729
+ req.on("end", () => {
8730
+ const parsed2 = jsonBody(body);
8731
+ if (parsed2 === null)
8732
+ return sendJson2(res, 400, { ok: false, error: "Body must be JSON." });
8733
+ if (generations === null)
8734
+ return sendJson2(res, 503, { ok: false, error: "Leglas is still starting." });
8735
+ void run4(parsed2, generations);
8736
+ });
8737
+ };
8738
+ const actOnGeneration = async (res, action, parsed2, act) => {
8739
+ if (!isString(parsed2.id) || parsed2.slot !== void 0 && !isString(parsed2.slot)) {
8740
+ return sendJson2(res, 400, {
8741
+ ok: false,
8742
+ error: "Name the generation by its id, and the direction by its slot."
8743
+ });
8744
+ }
8745
+ const done = await act(parsed2.id, isString(parsed2.slot) ? parsed2.slot : void 0);
8746
+ return done ? sendJson2(res, 200, { ok: true }) : sendJson2(res, 409, { ok: false, error: `Nothing to ${action} there right now.` });
8747
+ };
7534
8748
  const handleRequest = (req, res, context) => {
7535
8749
  const url = req.url ?? "/";
7536
8750
  const path = url.split("?")[0] ?? "/";
7537
8751
  const query = new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
7538
- if (!context.remote && req.method === "POST" && path.startsWith(`${LEGLAS_PREFIX}/api/`) && !isTrustedMutation(req)) {
8752
+ if (!context.remote && req.method !== "GET" && req.method !== "HEAD" && path.startsWith(`${LEGLAS_PREFIX}/api/`) && !isTrustedMutation(req)) {
7539
8753
  return sendJson2(res, 403, { ok: false, error: "Cross-origin API mutations are refused." });
7540
8754
  }
7541
8755
  if (context.remote && path === `${LEGLAS_PREFIX}/api/config`) {
@@ -7553,6 +8767,48 @@ async function startServer(options) {
7553
8767
  if (context.remote && path.startsWith(`${LEGLAS_PREFIX}/api/`)) {
7554
8768
  return sendJson2(res, 403, { error: "Not available to viewers." });
7555
8769
  }
8770
+ if (path === `${LEGLAS_PREFIX}/api/generate` && req.method === "GET") {
8771
+ return sendJson2(res, 200, { ok: true, jobs: generations?.snapshot() ?? [] });
8772
+ }
8773
+ if (path === `${LEGLAS_PREFIX}/api/generate` && req.method === "POST") {
8774
+ return void readGenerationBody(req, res, async (parsed2, running) => {
8775
+ if (!isString(parsed2.surface) || !isString(parsed2.brief)) {
8776
+ return sendJson2(res, 400, {
8777
+ ok: false,
8778
+ error: "Name a surface and describe the directions in a brief."
8779
+ });
8780
+ }
8781
+ let basedOn = null;
8782
+ if (parsed2.basedOn !== void 0 && parsed2.basedOn !== null && parsed2.basedOn !== "") {
8783
+ const title = parsed2.basedOn;
8784
+ const preview = isString(title) ? (await livePreviews()).find((entry) => entry.title === title) : void 0;
8785
+ basedOn = preview === void 0 ? null : baseOf(preview, parsed2.surface);
8786
+ if (basedOn === null) {
8787
+ return sendJson2(res, 422, {
8788
+ ok: false,
8789
+ error: `${isString(title) ? title : "That"} is not a direction of the ${surfaceSlug(parsed2.surface)}, so Leglas cannot build variations of it.`
8790
+ });
8791
+ }
8792
+ }
8793
+ const started = await running.start({
8794
+ surface: parsed2.surface,
8795
+ brief: parsed2.brief,
8796
+ count: isNumber(parsed2.count) ? parsed2.count : 3,
8797
+ agent: await readAgentChoice(cwd),
8798
+ basedOn
8799
+ });
8800
+ return started.ok ? sendJson2(res, 202, { ok: true, job: started.job }) : sendJson2(res, 422, { ok: false, error: started.error });
8801
+ });
8802
+ }
8803
+ if (path === `${LEGLAS_PREFIX}/api/generate/stop` && req.method === "POST") {
8804
+ return void readGenerationBody(req, res, (parsed2, running) => actOnGeneration(res, "stop", parsed2, (id, slot) => running.stop(id, slot)));
8805
+ }
8806
+ if (path === `${LEGLAS_PREFIX}/api/generate/retry` && req.method === "POST") {
8807
+ return void readGenerationBody(req, res, (parsed2, running) => actOnGeneration(res, "retry", parsed2, (id, slot) => slot === void 0 ? false : running.retry(id, slot)));
8808
+ }
8809
+ if (path === `${LEGLAS_PREFIX}/api/generate/replace` && req.method === "POST") {
8810
+ return void readGenerationBody(req, res, (parsed2, running) => actOnGeneration(res, "replace", parsed2, (id, slot) => slot === void 0 ? false : running.replace(id, slot)));
8811
+ }
7556
8812
  if (path === `${LEGLAS_PREFIX}/api/update` && req.method === "GET" || req.method === "POST" && ["check", "skip", "install"].some((action) => path === `${LEGLAS_PREFIX}/api/update/${action}`)) {
7557
8813
  const updates = options.updates;
7558
8814
  if (updates === void 0) {
@@ -7816,8 +9072,8 @@ async function startServer(options) {
7816
9072
  const id = newRequestId();
7817
9073
  const file = `${REFERENCES_DIR}/${id}.${image.kind}`;
7818
9074
  try {
7819
- await mkdir8(join12(cwd, REFERENCES_DIR), { recursive: true });
7820
- await writeFile8(join12(cwd, file), body);
9075
+ await mkdir8(join16(cwd, REFERENCES_DIR), { recursive: true });
9076
+ await writeFile9(join16(cwd, file), body);
7821
9077
  void pruneReferences(cwd).catch(() => {
7822
9078
  });
7823
9079
  return sendJson2(res, 200, {
@@ -7859,7 +9115,7 @@ async function startServer(options) {
7859
9115
  }
7860
9116
  const references = parsed2.references ?? [];
7861
9117
  if (references.length > 0) {
7862
- const present = new Set((await readdir3(join12(cwd, REFERENCES_DIR)).catch(() => [])).map((name) => name.slice(0, name.indexOf(".") === -1 ? name.length : name.indexOf("."))));
9118
+ const present = new Set((await readdir5(join16(cwd, REFERENCES_DIR)).catch(() => [])).map((name) => name.slice(0, name.indexOf(".") === -1 ? name.length : name.indexOf("."))));
7863
9119
  const gone = references.filter((id2) => !present.has(id2));
7864
9120
  if (gone.length > 0) {
7865
9121
  return sendJson2(res, 410, {
@@ -7989,7 +9245,7 @@ async function startServer(options) {
7989
9245
  const timer = setTimeout(() => {
7990
9246
  timedOut2();
7991
9247
  controller.abort();
7992
- }, CAPTURE_DEADLINE_MS);
9248
+ }, captureDeadlineMs);
7993
9249
  timer.unref?.();
7994
9250
  try {
7995
9251
  const captureInput = {
@@ -8022,8 +9278,8 @@ async function startServer(options) {
8022
9278
  const noteSuffix = isString(parsed2.note) ? `-${parsed2.note.replace(/[^A-Za-z0-9_-]+/g, "-")}` : "";
8023
9279
  const name = `${captureSlug(preview.title)}-${result.frame.width}${noteSuffix}.png`;
8024
9280
  const relativeFile = `${CAPTURES_DIR}/show/${name}`;
8025
- await mkdir8(join12(cwd, CAPTURES_DIR, "show"), { recursive: true });
8026
- await writeFile8(join12(cwd, relativeFile), shot.png);
9281
+ await mkdir8(join16(cwd, CAPTURES_DIR, "show"), { recursive: true });
9282
+ await writeFile9(join16(cwd, relativeFile), shot.png);
8027
9283
  return sendJson2(res, 200, {
8028
9284
  ok: true,
8029
9285
  file: relativeFile,
@@ -8403,15 +9659,15 @@ async function startServer(options) {
8403
9659
  const rest = path.slice(FILES_PREFIX.length + 1);
8404
9660
  const slash = rest.indexOf("/");
8405
9661
  const slug = slash === -1 ? rest : rest.slice(0, slash);
8406
- let relative6 = slash === -1 ? "" : rest.slice(slash + 1);
9662
+ let relative8 = slash === -1 ? "" : rest.slice(slash + 1);
8407
9663
  try {
8408
- relative6 = decodeURIComponent(relative6);
9664
+ relative8 = decodeURIComponent(relative8);
8409
9665
  } catch {
8410
- relative6 = "";
9666
+ relative8 = "";
8411
9667
  }
8412
9668
  const dir = fileMounts.get(slug);
8413
9669
  const serveMount = () => {
8414
- if (dir !== void 0 && relative6 !== "" && serveFrom(res, dir, relative6))
9670
+ if (dir !== void 0 && relative8 !== "" && serveFrom(res, dir, relative8))
8415
9671
  return;
8416
9672
  res.writeHead(404, {
8417
9673
  "content-type": "text/plain; charset=utf-8",
@@ -8421,7 +9677,7 @@ async function startServer(options) {
8421
9677
  };
8422
9678
  if (!context.remote)
8423
9679
  return serveMount();
8424
- if (relative6.split("/").some((segment) => segment.startsWith("."))) {
9680
+ if (relative8.split("/").some((segment) => segment.startsWith("."))) {
8425
9681
  return sendJson2(res, 403, { ok: false, error: "Not available to viewers." });
8426
9682
  }
8427
9683
  return void (shares?.fileSlugAllowed(slug, context.grantId ?? "") ?? Promise.resolve(false)).then((allowed) => {
@@ -8502,7 +9758,7 @@ async function startServer(options) {
8502
9758
  port = await bind2(server, options.port ?? DEFAULT_PORT);
8503
9759
  options.updates?.setPort(port);
8504
9760
  const liveFiles = watchLiveFiles(cwd, bootConfigPath, live);
8505
- liveHealth = watchHealth(target, live);
9761
+ liveHealth = watchHealth(target, live, healthProbeMs);
8506
9762
  await pruneCaptures(cwd, (await readRequests(cwd).catch(() => [])).map((request) => request.id)).catch(() => {
8507
9763
  });
8508
9764
  await writeServerInfo(cwd, {
@@ -8522,6 +9778,42 @@ async function startServer(options) {
8522
9778
  if (options.claudeAgentSession !== void 0)
8523
9779
  runnerOptions.claudeAgentSession = options.claudeAgentSession;
8524
9780
  runner = startRunner(runnerOptions);
9781
+ const renderDirection = async (title) => {
9782
+ const preview = (await livePreviews()).find((entry) => entry.title === title);
9783
+ if (preview === void 0)
9784
+ return null;
9785
+ const browser = await browserPool.acquire();
9786
+ if (browser === null)
9787
+ return null;
9788
+ const address = server.address();
9789
+ const renderPort = address !== null && !isString(address) ? address.port : options.port ?? DEFAULT_PORT;
9790
+ try {
9791
+ const captured = await capturePage(browser, {
9792
+ url: previewUrl(`http://127.0.0.1:${renderPort}`, preview),
9793
+ width: 1440,
9794
+ timeoutMs: CAPTURE_LOAD_MS
9795
+ });
9796
+ return { errors: captured.errors };
9797
+ } catch (error) {
9798
+ return { errors: [error instanceof Error ? error.message : String(error)] };
9799
+ }
9800
+ };
9801
+ const generationDeps = {
9802
+ cwd,
9803
+ render: renderDirection,
9804
+ register: (input) => addLocalPreview(cwd, input, (config?.previews ?? []).filter((entry) => entry.local !== true)),
9805
+ unregister: async (titles) => {
9806
+ await dropLocalPreviews(cwd, titles);
9807
+ },
9808
+ titles: async () => {
9809
+ const local = await readLocalPreviews(cwd).catch(() => null);
9810
+ return new Set([...config?.previews ?? [], ...local?.previews ?? []].map((entry) => entry.title));
9811
+ },
9812
+ onChange: () => live.nudge("generation")
9813
+ };
9814
+ if (options.generationSpawn !== void 0)
9815
+ generationDeps.spawn = options.generationSpawn;
9816
+ generations = createGenerations(generationDeps);
8525
9817
  options.updates?.onBusy(() => runner?.snapshot().running ?? false);
8526
9818
  options.updates?.onChange(() => live.nudge("update"));
8527
9819
  let closePromise = null;
@@ -8539,6 +9831,7 @@ async function startServer(options) {
8539
9831
  shares?.close() ?? Promise.resolve(),
8540
9832
  branches.stop(),
8541
9833
  runner.stop(),
9834
+ generations?.close() ?? Promise.resolve(),
8542
9835
  browserPool.close(),
8543
9836
  live.close()
8544
9837
  ]);
@@ -8560,9 +9853,9 @@ async function startServer(options) {
8560
9853
  // ../server/dist/update.js
8561
9854
  import { spawn as spawnChild2 } from "child_process";
8562
9855
  import { existsSync as existsSync4, readFileSync, realpathSync } from "fs";
8563
- import { lstat as lstat3, mkdir as mkdir9, mkdtemp, rename as rename3, rm as rm5, writeFile as writeFile9 } from "fs/promises";
8564
- import { homedir as homedir2 } from "os";
8565
- import { dirname as dirname9, join as join13, posix as posix2, win32 } from "path";
9856
+ import { lstat as lstat3, mkdir as mkdir9, mkdtemp, rename as rename3, rm as rm5, writeFile as writeFile10 } from "fs/promises";
9857
+ import { homedir as homedir3 } from "os";
9858
+ import { dirname as dirname11, join as join17, posix as posix2, win32 } from "path";
8566
9859
  var DAY_MS = 24 * 60 * 60 * 1e3;
8567
9860
  var INSTALL_DEADLINE_MS = 5 * 60 * 1e3;
8568
9861
  var REGISTRY = "https://registry.npmjs.org";
@@ -8762,7 +10055,7 @@ function restartCommand(install, argv, latest, port, options) {
8762
10055
  return spawnCommand(["leglas", ...rest], options.platform);
8763
10056
  }
8764
10057
  if (install.kind === "project" && install.root !== void 0) {
8765
- const shim = (windows ? win32.join : join13)(install.root, "node_modules", ".bin", windows ? "leglas.cmd" : "leglas");
10058
+ const shim = (windows ? win32.join : join17)(install.root, "node_modules", ".bin", windows ? "leglas.cmd" : "leglas");
8766
10059
  return spawnCommand(options.exists(shim) ? [shim, ...rest] : ["yarn", "leglas", ...rest], options.platform);
8767
10060
  }
8768
10061
  throw new Error(CHECKOUT_NOTICE);
@@ -8793,7 +10086,7 @@ function readState(path) {
8793
10086
  }
8794
10087
  function defaultStatePath(home) {
8795
10088
  try {
8796
- return join13(home(), ".leglas", "update.json");
10089
+ return join17(home(), ".leglas", "update.json");
8797
10090
  } catch {
8798
10091
  return null;
8799
10092
  }
@@ -8811,16 +10104,16 @@ function mergeState(current, saved) {
8811
10104
  async function writeState(path, state) {
8812
10105
  let temporary = null;
8813
10106
  try {
8814
- const directory = dirname9(path);
10107
+ const directory = dirname11(path);
8815
10108
  await mkdir9(directory, { recursive: true });
8816
10109
  if (!(await lstat3(directory)).isDirectory())
8817
10110
  return;
8818
10111
  const existing = await lstat3(path).catch(() => null);
8819
10112
  if (existing !== null && !existing.isFile())
8820
10113
  return;
8821
- temporary = await mkdtemp(join13(directory, ".update-"));
8822
- const file = join13(temporary, "update.json");
8823
- await writeFile9(file, `${JSON.stringify(state, null, 2)}
10114
+ temporary = await mkdtemp(join17(directory, ".update-"));
10115
+ const file = join17(temporary, "update.json");
10116
+ await writeFile10(file, `${JSON.stringify(state, null, 2)}
8824
10117
  `, "utf8");
8825
10118
  await rename3(file, path);
8826
10119
  } catch {
@@ -8881,7 +10174,7 @@ function installerReason(stdout, stderr) {
8881
10174
  const lines2 = (output2) => output2.split(/\r?\n/).filter((line) => !/^\s+at /.test(line) && !/A complete log of this run|info Visit https:\/\/yarnpkg\.com/.test(line)).map((line) => line.trim()).filter(Boolean);
8882
10175
  const errors = lines2(stderr);
8883
10176
  const output = lines2(stdout);
8884
- const npm = [...errors, ...output].find((line) => /^npm error\s+\S/.test(line) && !/^npm error code\s/.test(line));
10177
+ const npm = [...errors, ...output].find((line) => /^npm error\s+\S/.test(line) && !/^npm error (code|syscall|file|path|dest|errno)\s/.test(line));
8885
10178
  return npm?.replace(/^npm error\s+/, "") ?? errors[0] ?? output[0] ?? null;
8886
10179
  }
8887
10180
  function installVersion(install, version2, deps) {
@@ -8981,7 +10274,7 @@ function createUpdateService(input) {
8981
10274
  const fetcher = deps.fetch ?? fetch;
8982
10275
  const now = deps.now ?? Date.now;
8983
10276
  const timeoutMs = deps.timeoutMs ?? 4e3;
8984
- const statePath = deps.statePath === void 0 ? defaultStatePath(deps.homedir ?? homedir2) : deps.statePath;
10277
+ const statePath = deps.statePath === void 0 ? defaultStatePath(deps.homedir ?? homedir3) : deps.statePath;
8985
10278
  const exists = deps.exists ?? existsSync4;
8986
10279
  const env = deps.env ?? process.env;
8987
10280
  const install = detectInstall(input.entry, input.cwd, exists, deps.realpath ?? realPath, env);
@@ -9204,7 +10497,7 @@ async function runClassify(options, deps) {
9204
10497
  const declared = await Promise.all(
9205
10498
  options.changes.map(async (change) => ({
9206
10499
  ...change,
9207
- exists: await stat4(join14(options.cwd, change.path)).then(
10500
+ exists: await stat5(join18(options.cwd, change.path)).then(
9208
10501
  () => true,
9209
10502
  () => false
9210
10503
  )
@@ -9291,7 +10584,7 @@ function detectFramework(packageJson) {
9291
10584
  return "react";
9292
10585
  }
9293
10586
  }
9294
- function surfaceSlug(surface) {
10587
+ function surfaceSlug2(surface) {
9295
10588
  return surface.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
9296
10589
  }
9297
10590
  function titleCase(slug) {
@@ -9388,14 +10681,14 @@ function placeholder(label, note) {
9388
10681
  return `export function ${label}() {
9389
10682
  return (
9390
10683
  <div style={{ padding: "3rem", fontFamily: "system-ui" }}>
9391
- <p>${note}</p>
10684
+ <p>{${JSON.stringify(note)}}</p>
9392
10685
  </div>
9393
10686
  );
9394
10687
  }
9395
10688
  `;
9396
10689
  }
9397
10690
  function planNew(options) {
9398
- const slug = surfaceSlug(options.surface);
10691
+ const slug = surfaceSlug2(options.surface);
9399
10692
  const framework = detectFramework(options.packageJson);
9400
10693
  const name = titleCase(slug).replace(/\s/g, "");
9401
10694
  const dir = `.leglas/variants/${slug}`;
@@ -9439,7 +10732,7 @@ Rewriting your component automatically is how a tool breaks a codebase it does n
9439
10732
 
9440
10733
  // src/explore.ts
9441
10734
  function planExplore(surface, count, basedOn = null) {
9442
- const slug = surfaceSlug(surface);
10735
+ const slug = surfaceSlug2(surface);
9443
10736
  const goal = basedOn === null ? `Build ${count} design directions for "${surface}".
9444
10737
 
9445
10738
  The set exists to be chosen from, and the choice only means something if the directions genuinely disagree: ${count} variants of one idea would make it empty. What counts as different is yours to decide, and the strongest sets disagree about more than styling.
@@ -9477,9 +10770,176 @@ function runExplore(options, deps) {
9477
10770
  return { exitCode: 0 };
9478
10771
  }
9479
10772
 
10773
+ // src/json.ts
10774
+ function isJsonObject(value) {
10775
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10776
+ }
10777
+ function isString2(value) {
10778
+ return typeof value === "string";
10779
+ }
10780
+ function isNumber2(value) {
10781
+ return typeof value === "number";
10782
+ }
10783
+ async function bodyOf(response) {
10784
+ try {
10785
+ return JSON.parse(await response.text());
10786
+ } catch {
10787
+ return null;
10788
+ }
10789
+ }
10790
+
10791
+ // src/running.ts
10792
+ import { realpath as realpath2 } from "fs/promises";
10793
+ var NOT_RUNNING = "Leglas is not running here. Start it with npx leglas, then try again.";
10794
+ async function sameDirectory(left, right) {
10795
+ const [a, b] = await Promise.all([
10796
+ realpath2(left).catch(() => left),
10797
+ realpath2(right).catch(() => right)
10798
+ ]);
10799
+ return a === b;
10800
+ }
10801
+ async function findLeglas(cwd, port, request) {
10802
+ const server = port === null ? await readServerInfo(cwd) : null;
10803
+ const chosen = port ?? server?.port ?? DEFAULT_PORT;
10804
+ try {
10805
+ const health = await request(`http://127.0.0.1:${chosen}/leglas/api/health`, {
10806
+ signal: AbortSignal.timeout(2e3)
10807
+ });
10808
+ if (health.status !== 200) return { ok: false, error: NOT_RUNNING };
10809
+ const answered = await health.text().then((text3) => JSON.parse(text3)).catch(() => ({}));
10810
+ if (answered === null) return { ok: false, error: NOT_RUNNING };
10811
+ if (isJsonObject(answered) && isString2(answered.cwd) && !await sameDirectory(answered.cwd, cwd)) {
10812
+ return {
10813
+ ok: false,
10814
+ error: `The Leglas on port ${chosen} serves another project. Start one here with npx leglas, or name the right one with --port.`
10815
+ };
10816
+ }
10817
+ return { ok: true, port: chosen, base: `http://127.0.0.1:${chosen}` };
10818
+ } catch {
10819
+ return { ok: false, error: NOT_RUNNING };
10820
+ }
10821
+ }
10822
+
10823
+ // src/run-explore-build.ts
10824
+ var POLL_MS2 = 1e3;
10825
+ var WAIT_MS = 20 * 6e4;
10826
+ function readJob(value) {
10827
+ if (value === void 0 || !isJsonObject(value)) return null;
10828
+ const { id, state, slots, error, startedAt, endedAt } = value;
10829
+ if (!isString2(id) || !isString2(state) || !Array.isArray(slots)) return null;
10830
+ const read = slots.flatMap((entry) => {
10831
+ if (!isJsonObject(entry) || !isString2(entry.key) || !isString2(entry.title) || !isString2(entry.state))
10832
+ return [];
10833
+ const failure = entry.failure === void 0 || entry.failure === null || !isJsonObject(entry.failure) ? null : entry.failure;
10834
+ return [
10835
+ {
10836
+ key: entry.key,
10837
+ title: entry.title,
10838
+ state: entry.state,
10839
+ failure: failure !== null && isString2(failure.message) ? { message: failure.message } : null,
10840
+ fixed: entry.fixed === true
10841
+ }
10842
+ ];
10843
+ });
10844
+ return {
10845
+ id,
10846
+ state,
10847
+ error: isString2(error) ? error : null,
10848
+ slots: read,
10849
+ startedAt: isNumber2(startedAt) ? startedAt : Date.now(),
10850
+ endedAt: isNumber2(endedAt) ? endedAt : null
10851
+ };
10852
+ }
10853
+ async function runExploreBuild(options, deps) {
10854
+ const request = deps.fetch ?? fetch;
10855
+ const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve5) => setTimeout(resolve5, milliseconds)));
10856
+ const fail = (error) => {
10857
+ if (options.json) deps.log(JSON.stringify({ ok: false, error }));
10858
+ else deps.error(error);
10859
+ return { exitCode: 1 };
10860
+ };
10861
+ const found = await findLeglas(options.cwd, options.port, request);
10862
+ if (!found.ok) return fail(found.error);
10863
+ const base = `http://127.0.0.1:${found.port}/leglas/api/generate`;
10864
+ let started;
10865
+ try {
10866
+ started = await bodyOf(
10867
+ await request(base, {
10868
+ method: "POST",
10869
+ headers: { "content-type": "application/json", origin: `http://127.0.0.1:${found.port}` },
10870
+ body: JSON.stringify({
10871
+ surface: options.surface,
10872
+ brief: options.brief,
10873
+ count: options.count,
10874
+ basedOn: options.basedOn
10875
+ })
10876
+ })
10877
+ );
10878
+ } catch {
10879
+ return fail("Leglas did not answer. Is it still running?");
10880
+ }
10881
+ const answer = isJsonObject(started) ? started : null;
10882
+ const first = readJob(answer?.job);
10883
+ if (answer?.ok !== true || first === null) {
10884
+ return fail(isString2(answer?.error) ? answer.error : "Leglas could not start the build.");
10885
+ }
10886
+ if (!options.json) {
10887
+ deps.log(
10888
+ options.basedOn === null ? `Planning ${options.count} directions for the ${options.surface}\u2026` : `Planning ${options.count} variations of ${options.basedOn}\u2026`
10889
+ );
10890
+ }
10891
+ const said = /* @__PURE__ */ new Map();
10892
+ let announced = false;
10893
+ let job = first;
10894
+ const deadline = Date.now() + WAIT_MS;
10895
+ while (Date.now() < deadline) {
10896
+ await sleep(POLL_MS2);
10897
+ let listed;
10898
+ try {
10899
+ listed = await bodyOf(await request(base));
10900
+ } catch {
10901
+ return fail("Leglas stopped answering while the directions were being built.");
10902
+ }
10903
+ const jobs = isJsonObject(listed) ? listed.jobs : void 0;
10904
+ const latest = Array.isArray(jobs) ? jobs.map(readJob).find((candidate) => candidate?.id === first.id) : void 0;
10905
+ if (latest === void 0 || latest === null) continue;
10906
+ job = latest;
10907
+ if (!options.json && !announced && job.slots.length > 0) {
10908
+ announced = true;
10909
+ deps.log(
10910
+ `On the rail: ${job.slots.map((slot) => slot.title).join(", ")}. Building them now.`
10911
+ );
10912
+ }
10913
+ for (const slot of job.slots) {
10914
+ if (options.json || said.get(slot.key) === slot.state) continue;
10915
+ said.set(slot.key, slot.state);
10916
+ if (slot.state === "ready")
10917
+ deps.log(
10918
+ `Ready: ${slot.title}${slot.fixed ? " (its first version did not render; fixed)" : ""}`
10919
+ );
10920
+ else if (slot.state === "failed")
10921
+ deps.log(`Failed: ${slot.title}. ${slot.failure?.message ?? ""}`.trim());
10922
+ else if (slot.state === "stopped") deps.log(`Stopped: ${slot.title}`);
10923
+ }
10924
+ if (job.state === "done" || job.state === "failed" || job.state === "stopped") break;
10925
+ }
10926
+ const ready = job.slots.filter((slot) => slot.state === "ready").length;
10927
+ const ok = job.state === "done" && ready === job.slots.length && ready > 0;
10928
+ if (options.json) {
10929
+ deps.log(JSON.stringify({ ok, job }));
10930
+ return { exitCode: ok ? 0 : 1 };
10931
+ }
10932
+ if (job.state === "failed") return fail(job.error ?? "The build failed.");
10933
+ const seconds = job.endedAt === null ? null : Math.round((job.endedAt - job.startedAt) / 1e3);
10934
+ deps.log(
10935
+ `${ready} of ${job.slots.length} directions ready${seconds === null ? "" : ` in ${seconds} s`}. Compare them in the interface, or keep one with leglas keep.`
10936
+ );
10937
+ return { exitCode: ok ? 0 : 1 };
10938
+ }
10939
+
9480
10940
  // src/run-init.ts
9481
- import { readFile as readFile12, writeFile as writeFile10 } from "fs/promises";
9482
- import { join as join15 } from "path";
10941
+ import { readFile as readFile16, writeFile as writeFile11 } from "fs/promises";
10942
+ import { join as join19 } from "path";
9483
10943
 
9484
10944
  // src/init.ts
9485
10945
  var AGENTS_MARKER_START = "<!-- leglas:start -->";
@@ -9598,7 +11058,8 @@ Useful to know:
9598
11058
  they have asked for that is not done yet. Add \`--screenshot\` to render a PNG
9599
11059
  you can inspect. Run it when handed a direction you did not register yourself.
9600
11060
  - Every command accepts \`--json\` and prints one envelope with a stable exit
9601
- code, so you can drive it without parsing prose.
11061
+ code, so you can drive it without parsing prose. \`watch\` keeps running, so
11062
+ it prints one JSON line per event instead.
9602
11063
 
9603
11064
  ${AGENTS_MARKER_END}
9604
11065
  `;
@@ -9640,7 +11101,7 @@ ${AGENTS_SECTION}`
9640
11101
  // src/run-init.ts
9641
11102
  async function readIfPresent(path) {
9642
11103
  try {
9643
- return await readFile12(path, "utf8");
11104
+ return await readFile16(path, "utf8");
9644
11105
  } catch {
9645
11106
  return null;
9646
11107
  }
@@ -9648,18 +11109,18 @@ async function readIfPresent(path) {
9648
11109
  async function runInit(options, deps) {
9649
11110
  const existingConfig = findConfigFile(options.cwd);
9650
11111
  const plan = planInit({
9651
- agents: await readIfPresent(join15(options.cwd, "AGENTS.md")),
11112
+ agents: await readIfPresent(join19(options.cwd, "AGENTS.md")),
9652
11113
  config: existingConfig === null ? null : "present",
9653
- gitignore: await readIfPresent(join15(options.cwd, ".gitignore")),
11114
+ gitignore: await readIfPresent(join19(options.cwd, ".gitignore")),
9654
11115
  force: options.force
9655
11116
  });
9656
11117
  const touched = [];
9657
11118
  for (const write2 of plan.writes) {
9658
- await writeFile10(join15(options.cwd, write2.path), write2.contents, "utf8");
11119
+ await writeFile11(join19(options.cwd, write2.path), write2.contents, "utf8");
9659
11120
  touched.push(write2.path);
9660
11121
  }
9661
11122
  if (plan.gitignore !== null) {
9662
- await writeFile10(join15(options.cwd, ".gitignore"), plan.gitignore, "utf8");
11123
+ await writeFile11(join19(options.cwd, ".gitignore"), plan.gitignore, "utf8");
9663
11124
  touched.push(".gitignore");
9664
11125
  }
9665
11126
  if (options.json) {
@@ -9679,11 +11140,11 @@ async function runInit(options, deps) {
9679
11140
 
9680
11141
  // src/run-keep.ts
9681
11142
  import { existsSync as existsSync5 } from "fs";
9682
- import { copyFile as copyFile2, mkdir as mkdir10, readFile as readFile13, rm as rm6, writeFile as writeFile11 } from "fs/promises";
9683
- import { dirname as dirname10, join as join16 } from "path";
11143
+ import { copyFile as copyFile2, mkdir as mkdir10, readFile as readFile17, rm as rm6, writeFile as writeFile12 } from "fs/promises";
11144
+ import { dirname as dirname12, isAbsolute as isAbsolute3, join as join20, relative as relative5 } from "path";
9684
11145
 
9685
11146
  // src/keep.ts
9686
- import { basename as basename4, extname as extname4, normalize as normalize2 } from "path";
11147
+ import { basename as basename4, extname as extname4, isAbsolute as isAbsolute2, normalize as normalize3 } from "path";
9687
11148
  function surfaceOf(url) {
9688
11149
  if (!url.startsWith("/") || !url.includes("?")) return null;
9689
11150
  for (const pair of url.slice(url.indexOf("?") + 1).split("&")) {
@@ -9712,8 +11173,8 @@ function planKeep(options) {
9712
11173
  error: `Cannot tell which file produces ${JSON.stringify(options.title)}. Its URL (${winner.url}) was not generated by leglas new, so there is nothing safe to move.`
9713
11174
  };
9714
11175
  }
9715
- const to = normalize2(options.to);
9716
- if (to.startsWith("..")) {
11176
+ const to = normalize3(options.to);
11177
+ if (to.startsWith("..") || isAbsolute2(to)) {
9717
11178
  return { ok: false, error: "The destination has to be inside the project." };
9718
11179
  }
9719
11180
  if (to.split(/[/\\]/)[0] === ".leglas") {
@@ -9768,15 +11229,15 @@ async function writeLogEntry(options) {
9768
11229
  annotations: await readAnnotations(options.cwd),
9769
11230
  date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
9770
11231
  });
9771
- const dir = join16(options.cwd, options.logDir);
11232
+ const dir = join20(options.cwd, options.logDir);
9772
11233
  await mkdir10(dir, { recursive: true });
9773
- const file = join16(dir, `${entry.slug}.md`);
9774
- await writeFile11(file, entry.markdown, "utf8");
11234
+ const file = join20(dir, `${entry.slug}.md`);
11235
+ await writeFile12(file, entry.markdown, "utf8");
9775
11236
  if (entry.pictures.length > 0) {
9776
- const pictureDir = join16(dir, entry.slug);
11237
+ const pictureDir = join20(dir, entry.slug);
9777
11238
  await mkdir10(pictureDir, { recursive: true });
9778
11239
  for (const picture of entry.pictures) {
9779
- await copyFile2(join16(options.cwd, picture.from), join16(pictureDir, picture.to)).catch(() => {
11240
+ await copyFile2(join20(options.cwd, picture.from), join20(pictureDir, picture.to)).catch(() => {
9780
11241
  });
9781
11242
  }
9782
11243
  }
@@ -9797,19 +11258,20 @@ async function runKeep(options, deps) {
9797
11258
  await readRenames(options.cwd)
9798
11259
  );
9799
11260
  if (!resolved.ok) return fail(resolved.error);
9800
- const plan = planKeep({ title: resolved.title, previews, to: options.to });
11261
+ const destination = isAbsolute3(options.to) ? relative5(options.cwd, options.to) : options.to;
11262
+ const plan = planKeep({ title: resolved.title, previews, to: destination });
9801
11263
  if (!plan.ok) return fail(plan.error);
9802
- const from = join16(options.cwd, plan.move.from);
9803
- const to = join16(options.cwd, plan.move.to);
11264
+ const from = join20(options.cwd, plan.move.from);
11265
+ const to = join20(options.cwd, plan.move.to);
9804
11266
  if (!existsSync5(from)) {
9805
11267
  return fail(`${plan.move.from} does not exist. Nothing to keep.`);
9806
11268
  }
9807
11269
  if (existsSync5(to)) {
9808
11270
  return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
9809
11271
  }
9810
- const source = await readFile13(from, "utf8");
9811
- await mkdir10(dirname10(to), { recursive: true });
9812
- await writeFile11(to, renameExport(source, plan.exportName), "utf8");
11272
+ const source = await readFile17(from, "utf8");
11273
+ await mkdir10(dirname12(to), { recursive: true });
11274
+ await writeFile12(to, renameExport(source, plan.exportName), "utf8");
9813
11275
  const surface = plan.removeDir.slice(plan.removeDir.lastIndexOf("/") + 1);
9814
11276
  let logged = null;
9815
11277
  let logError = null;
@@ -9824,7 +11286,7 @@ async function runKeep(options, deps) {
9824
11286
  } catch (error) {
9825
11287
  logError = error instanceof Error ? error.message : String(error);
9826
11288
  }
9827
- await rm6(join16(options.cwd, plan.removeDir), { recursive: true, force: true });
11289
+ await rm6(join20(options.cwd, plan.removeDir), { recursive: true, force: true });
9828
11290
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
9829
11291
  if (options.json) {
9830
11292
  deps.log(
@@ -9863,11 +11325,11 @@ async function runKeep(options, deps) {
9863
11325
 
9864
11326
  // src/run-new.ts
9865
11327
  import { existsSync as existsSync6 } from "fs";
9866
- import { mkdir as mkdir11, readFile as readFile14, writeFile as writeFile12 } from "fs/promises";
9867
- import { dirname as dirname11, join as join17 } from "path";
11328
+ import { mkdir as mkdir11, readFile as readFile18, writeFile as writeFile13 } from "fs/promises";
11329
+ import { dirname as dirname13, join as join21 } from "path";
9868
11330
  async function readIfPresent2(path) {
9869
11331
  try {
9870
- return await readFile14(path, "utf8");
11332
+ return await readFile18(path, "utf8");
9871
11333
  } catch {
9872
11334
  return null;
9873
11335
  }
@@ -9875,7 +11337,7 @@ async function readIfPresent2(path) {
9875
11337
  async function runNew(options, deps) {
9876
11338
  let from;
9877
11339
  if (options.from !== void 0) {
9878
- const contents = await readIfPresent2(join17(options.cwd, options.from));
11340
+ const contents = await readIfPresent2(join21(options.cwd, options.from));
9879
11341
  if (contents === null) {
9880
11342
  const message2 = `${options.from} does not exist, so there is nothing to use as the baseline.`;
9881
11343
  if (options.json) deps.log(JSON.stringify({ ok: false, error: message2 }));
@@ -9886,8 +11348,8 @@ async function runNew(options, deps) {
9886
11348
  }
9887
11349
  const plan = planNew({
9888
11350
  surface: options.surface,
9889
- packageJson: await readIfPresent2(join17(options.cwd, "package.json")),
9890
- gitignore: await readIfPresent2(join17(options.cwd, ".gitignore")),
11351
+ packageJson: await readIfPresent2(join21(options.cwd, "package.json")),
11352
+ gitignore: await readIfPresent2(join21(options.cwd, ".gitignore")),
9891
11353
  from
9892
11354
  });
9893
11355
  const fail = (error) => {
@@ -9917,7 +11379,7 @@ async function runNew(options, deps) {
9917
11379
  deps.log(plan.instructions);
9918
11380
  return { exitCode: 0, written: [] };
9919
11381
  }
9920
- const existing = plan.writes.filter((write2) => existsSync6(join17(options.cwd, write2.path)));
11382
+ const existing = plan.writes.filter((write2) => existsSync6(join21(options.cwd, write2.path)));
9921
11383
  if (existing.length > 0) {
9922
11384
  return fail(
9923
11385
  `${existing[0]?.path} already exists. Delete it first, or pick another surface name.`
@@ -9925,13 +11387,13 @@ async function runNew(options, deps) {
9925
11387
  }
9926
11388
  const written = [];
9927
11389
  for (const write2 of plan.writes) {
9928
- const target = join17(options.cwd, write2.path);
9929
- await mkdir11(dirname11(target), { recursive: true });
9930
- await writeFile12(target, write2.contents, "utf8");
11390
+ const target = join21(options.cwd, write2.path);
11391
+ await mkdir11(dirname13(target), { recursive: true });
11392
+ await writeFile13(target, write2.contents, "utf8");
9931
11393
  written.push(write2.path);
9932
11394
  }
9933
11395
  if (plan.gitignore !== null) {
9934
- await writeFile12(join17(options.cwd, ".gitignore"), plan.gitignore, "utf8");
11396
+ await writeFile13(join21(options.cwd, ".gitignore"), plan.gitignore, "utf8");
9935
11397
  written.push(".gitignore");
9936
11398
  }
9937
11399
  if (options.json) {
@@ -9959,8 +11421,8 @@ async function runNew(options, deps) {
9959
11421
  }
9960
11422
 
9961
11423
  // src/run-log.ts
9962
- import { readFile as readFile15, readdir as readdir4 } from "fs/promises";
9963
- import { join as join18 } from "path";
11424
+ import { readFile as readFile19, readdir as readdir6 } from "fs/promises";
11425
+ import { join as join22 } from "path";
9964
11426
  function headline(markdown) {
9965
11427
  const first = markdown.split("\n", 1)[0] ?? "";
9966
11428
  return first.replace(/^#\s*/, "").trim();
@@ -9970,7 +11432,7 @@ async function runLog(options, deps) {
9970
11432
  const dir = loaded.config?.logDir ?? DEFAULT_LOG_DIR;
9971
11433
  let names;
9972
11434
  try {
9973
- names = (await readdir4(join18(options.cwd, dir))).filter((name) => name.endsWith(".md")).sort().reverse();
11435
+ names = (await readdir6(join22(options.cwd, dir))).filter((name) => name.endsWith(".md")).sort().reverse();
9974
11436
  } catch {
9975
11437
  names = [];
9976
11438
  }
@@ -9983,7 +11445,7 @@ async function runLog(options, deps) {
9983
11445
  else deps.error(error);
9984
11446
  return { exitCode: 1 };
9985
11447
  }
9986
- const markdown = await readFile15(join18(options.cwd, dir, found), "utf8");
11448
+ const markdown = await readFile19(join22(options.cwd, dir, found), "utf8");
9987
11449
  if (options.json) deps.log(JSON.stringify({ ok: true, entry: wanted, markdown }));
9988
11450
  else deps.log(markdown.trimEnd());
9989
11451
  return { exitCode: 0 };
@@ -9991,7 +11453,7 @@ async function runLog(options, deps) {
9991
11453
  const entries = await Promise.all(
9992
11454
  names.map(async (name) => ({
9993
11455
  entry: name.replace(/\.md$/, ""),
9994
- title: headline(await readFile15(join18(options.cwd, dir, name), "utf8")),
11456
+ title: headline(await readFile19(join22(options.cwd, dir, name), "utf8")),
9995
11457
  file: `${dir}/${name}`
9996
11458
  }))
9997
11459
  );
@@ -10009,21 +11471,21 @@ async function runLog(options, deps) {
10009
11471
  }
10010
11472
 
10011
11473
  // src/run-previews.ts
10012
- import { readFile as readFile16, writeFile as writeFile13 } from "fs/promises";
10013
- import { join as join19 } from "path";
11474
+ import { readFile as readFile20, writeFile as writeFile14 } from "fs/promises";
11475
+ import { join as join23 } from "path";
10014
11476
  function envelope(deps, ok, body) {
10015
11477
  deps.log(JSON.stringify({ ok, ...body }));
10016
11478
  }
10017
11479
  async function ensureIgnored(cwd) {
10018
- const path = join19(cwd, ".gitignore");
11480
+ const path = join23(cwd, ".gitignore");
10019
11481
  let current = null;
10020
11482
  try {
10021
- current = await readFile16(path, "utf8");
11483
+ current = await readFile20(path, "utf8");
10022
11484
  } catch {
10023
11485
  current = null;
10024
11486
  }
10025
11487
  const next = ignoreEntry(current);
10026
- if (next !== null) await writeFile13(path, next, "utf8");
11488
+ if (next !== null) await writeFile14(path, next, "utf8");
10027
11489
  }
10028
11490
  async function runAdd(options, deps) {
10029
11491
  const loaded = await loadConfig(options.cwd);
@@ -10164,56 +11626,6 @@ async function runRequests(options, deps) {
10164
11626
  return { exitCode: 0 };
10165
11627
  }
10166
11628
 
10167
- // src/json.ts
10168
- function isJsonObject(value) {
10169
- return typeof value === "object" && value !== null && !Array.isArray(value);
10170
- }
10171
- function isString2(value) {
10172
- return typeof value === "string";
10173
- }
10174
- function isNumber2(value) {
10175
- return typeof value === "number";
10176
- }
10177
- async function bodyOf(response) {
10178
- try {
10179
- return JSON.parse(await response.text());
10180
- } catch {
10181
- return null;
10182
- }
10183
- }
10184
-
10185
- // src/running.ts
10186
- import { realpath as realpath2 } from "fs/promises";
10187
- var NOT_RUNNING = "Leglas is not running here. Start it with npx leglas, then try again.";
10188
- async function sameDirectory(left, right) {
10189
- const [a, b] = await Promise.all([
10190
- realpath2(left).catch(() => left),
10191
- realpath2(right).catch(() => right)
10192
- ]);
10193
- return a === b;
10194
- }
10195
- async function findLeglas(cwd, port, request) {
10196
- const server = port === null ? await readServerInfo(cwd) : null;
10197
- const chosen = port ?? server?.port ?? DEFAULT_PORT;
10198
- try {
10199
- const health = await request(`http://127.0.0.1:${chosen}/leglas/api/health`, {
10200
- signal: AbortSignal.timeout(2e3)
10201
- });
10202
- if (health.status !== 200) return { ok: false, error: NOT_RUNNING };
10203
- const answered = await health.text().then((text2) => JSON.parse(text2)).catch(() => ({}));
10204
- if (answered === null) return { ok: false, error: NOT_RUNNING };
10205
- if (isJsonObject(answered) && isString2(answered.cwd) && !await sameDirectory(answered.cwd, cwd)) {
10206
- return {
10207
- ok: false,
10208
- error: `The Leglas on port ${chosen} serves another project. Start one here with npx leglas, or name the right one with --port.`
10209
- };
10210
- }
10211
- return { ok: true, port: chosen, base: `http://127.0.0.1:${chosen}` };
10212
- } catch {
10213
- return { ok: false, error: NOT_RUNNING };
10214
- }
10215
- }
10216
-
10217
11629
  // src/run-share.ts
10218
11630
  var TUNNEL_WAIT_MS = 6e4;
10219
11631
  var TUNNEL_POLL_MS = 500;
@@ -10664,24 +12076,24 @@ async function runShow(options, deps) {
10664
12076
 
10665
12077
  // src/run-watch.ts
10666
12078
  import { spawn as spawn4 } from "child_process";
10667
- import { mkdir as mkdir12, readFile as readFile17, writeFile as writeFile14 } from "fs/promises";
10668
- import { dirname as dirname12, join as join20 } from "path";
10669
- var POLL_MS2 = 2e3;
12079
+ import { mkdir as mkdir12, readFile as readFile21, writeFile as writeFile15 } from "fs/promises";
12080
+ import { dirname as dirname14, join as join24 } from "path";
12081
+ var POLL_MS3 = 2e3;
10670
12082
  var HEARTBEAT_TIMEOUT_MS = 1e3;
10671
12083
  async function saveTemplate(cwd, run4) {
10672
- const path = join20(cwd, WATCH_PATH);
12084
+ const path = join24(cwd, WATCH_PATH);
10673
12085
  let config = {};
10674
12086
  try {
10675
- const parsed2 = JSON.parse(await readFile17(path, "utf8"));
12087
+ const parsed2 = JSON.parse(await readFile21(path, "utf8"));
10676
12088
  if (isJsonObject(parsed2)) config = parsed2;
10677
12089
  } catch {
10678
12090
  }
10679
12091
  config.run = run4;
10680
- await mkdir12(dirname12(path), { recursive: true });
10681
- await writeFile14(path, `${JSON.stringify(config, null, 2)}
12092
+ await mkdir12(dirname14(path), { recursive: true });
12093
+ await writeFile15(path, `${JSON.stringify(config, null, 2)}
10682
12094
  `, "utf8");
10683
12095
  }
10684
- function spawnAgent(command, args, cwd) {
12096
+ function spawnAgent(command, args, cwd, json) {
10685
12097
  return new Promise((resolve5) => {
10686
12098
  let settled = false;
10687
12099
  const settle = (outcome) => {
@@ -10689,7 +12101,10 @@ function spawnAgent(command, args, cwd) {
10689
12101
  settled = true;
10690
12102
  resolve5(outcome);
10691
12103
  };
10692
- const child = spawn4(command, args, { cwd, stdio: "inherit" });
12104
+ const child = spawn4(command, args, {
12105
+ cwd,
12106
+ stdio: json ? ["inherit", 2, "inherit"] : "inherit"
12107
+ });
10693
12108
  child.on("error", (error) => settle({ ok: false, error: error.message }));
10694
12109
  child.on(
10695
12110
  "close",
@@ -10700,6 +12115,13 @@ function spawnAgent(command, args, cwd) {
10700
12115
  });
10701
12116
  }
10702
12117
  async function runWatch(options, deps) {
12118
+ const json = options.json === true;
12119
+ const emit = (event) => deps.log(JSON.stringify(event));
12120
+ const refuse2 = (error) => {
12121
+ if (json) deps.log(JSON.stringify({ ok: false, error }));
12122
+ else deps.error(error);
12123
+ return { exitCode: 1 };
12124
+ };
10703
12125
  const saved = options.run === void 0 ? await readAgentChoice(options.cwd) : { agent: null, run: null };
10704
12126
  const raw = options.run ?? saved.run;
10705
12127
  let template;
@@ -10707,10 +12129,7 @@ async function runWatch(options, deps) {
10707
12129
  let synthesizedAgent = null;
10708
12130
  if (raw !== null) {
10709
12131
  const parsed2 = parseTemplate(raw);
10710
- if (!parsed2.ok) {
10711
- deps.error(parsed2.error);
10712
- return { exitCode: 1 };
10713
- }
12132
+ if (!parsed2.ok) return refuse2(parsed2.error);
10714
12133
  template = parsed2.template;
10715
12134
  shownCommand2 = raw;
10716
12135
  } else if (saved.agent !== null && saved.agent !== "custom") {
@@ -10722,10 +12141,9 @@ async function runWatch(options, deps) {
10722
12141
  shownCommand2 = [template.command, ...template.args].join(" ");
10723
12142
  synthesizedAgent = adapter.name;
10724
12143
  } else {
10725
- deps.error(
12144
+ return refuse2(
10726
12145
  'Watch needs an agent command the first time: pick an agent in the interface, or pass --run "claude -p {prompt}".'
10727
12146
  );
10728
- return { exitCode: 1 };
10729
12147
  }
10730
12148
  if (options.run !== void 0) await saveTemplate(options.cwd, options.run).catch(() => {
10731
12149
  });
@@ -10741,26 +12159,36 @@ async function runWatch(options, deps) {
10741
12159
  } catch {
10742
12160
  }
10743
12161
  };
10744
- if (synthesizedAgent !== null) {
10745
- deps.log(`Using ${synthesizedAgent}, chosen in the interface.`);
12162
+ if (json) {
12163
+ emit({ event: "watching", command: shownCommand2, agent: synthesizedAgent });
12164
+ } else {
12165
+ if (synthesizedAgent !== null) {
12166
+ deps.log(`Using ${synthesizedAgent}, chosen in the interface.`);
12167
+ }
12168
+ deps.log(`Watching for change requests. Each one runs: ${shownCommand2}`);
12169
+ deps.log("Stop with Ctrl-C.");
10746
12170
  }
10747
- deps.log(`Watching for change requests. Each one runs: ${shownCommand2}`);
10748
- deps.log("Stop with Ctrl-C.");
10749
12171
  const failed = /* @__PURE__ */ new Set();
10750
12172
  let stopped = false;
10751
12173
  let busy = false;
10752
12174
  let announced = false;
10753
12175
  let inflight = null;
10754
12176
  const handle = async (request) => {
10755
- deps.log("");
10756
- deps.log(` ${request.title}: ${request.intent}`);
10757
- if (request.target !== null) deps.log(` ${request.target}`);
12177
+ const { id, title, intent, target } = request;
12178
+ if (json) {
12179
+ emit({ event: "started", id, title, intent, target });
12180
+ } else {
12181
+ deps.log("");
12182
+ deps.log(` ${title}: ${intent}`);
12183
+ if (target !== null) deps.log(` ${target}`);
12184
+ }
10758
12185
  await markPickedUp(options.cwd, request.id);
10759
12186
  const { command, args } = commandFor(template, request.prompt);
10760
- const outcome = await spawnAgent(command, args, options.cwd);
12187
+ const outcome = await spawnAgent(command, args, options.cwd, json);
10761
12188
  if (outcome.ok && outcome.code === 0) {
10762
12189
  await removeRequest(options.cwd, request.id);
10763
- deps.log(` done ${request.title}`);
12190
+ if (json) emit({ event: "done", id, title });
12191
+ else deps.log(` done ${title}`);
10764
12192
  return;
10765
12193
  }
10766
12194
  failed.add(request.id);
@@ -10770,8 +12198,12 @@ async function runWatch(options, deps) {
10770
12198
  exitCode: outcome.ok ? outcome.code : null
10771
12199
  });
10772
12200
  await markFailed(options.cwd, request.id, failure);
10773
- deps.error(` failed ${request.title}: ${failure.message}`);
10774
- deps.error(" Left in the queue and not retried.");
12201
+ if (json) {
12202
+ emit({ event: "failed", id, title, code: failure.code, reason: failure.message });
12203
+ } else {
12204
+ deps.error(` failed ${title}: ${failure.message}`);
12205
+ deps.error(" Left in the queue and not retried.");
12206
+ }
10775
12207
  };
10776
12208
  const tick = async () => {
10777
12209
  if (stopped) return;
@@ -10790,14 +12222,16 @@ async function runWatch(options, deps) {
10790
12222
  await inflight;
10791
12223
  }
10792
12224
  } catch (error) {
10793
- deps.error(` ! ${error instanceof Error ? error.message : String(error)}`);
12225
+ const message2 = error instanceof Error ? error.message : String(error);
12226
+ if (json) emit({ event: "error", message: message2 });
12227
+ else deps.error(` ! ${message2}`);
10794
12228
  } finally {
10795
12229
  inflight = null;
10796
12230
  busy = false;
10797
12231
  }
10798
12232
  };
10799
12233
  return new Promise((resolve5) => {
10800
- const timer = setInterval(() => void tick(), POLL_MS2);
12234
+ const timer = setInterval(() => void tick(), POLL_MS3);
10801
12235
  const stop = () => {
10802
12236
  if (stopped) return;
10803
12237
  stopped = true;
@@ -10805,7 +12239,10 @@ async function runWatch(options, deps) {
10805
12239
  process.off("SIGINT", stop);
10806
12240
  process.off("SIGTERM", stop);
10807
12241
  void Promise.resolve(inflight).catch(() => {
10808
- }).then(() => heartbeat(false)).then(() => resolve5({ exitCode: 0 }));
12242
+ }).then(() => heartbeat(false)).then(() => {
12243
+ if (json) emit({ event: "stopped" });
12244
+ resolve5({ exitCode: 0 });
12245
+ });
10809
12246
  };
10810
12247
  process.on("SIGINT", stop);
10811
12248
  process.on("SIGTERM", stop);
@@ -10895,13 +12332,13 @@ function createHandoff() {
10895
12332
  import { existsSync as existsSync7 } from "fs";
10896
12333
  import { realpath as realpath4 } from "fs/promises";
10897
12334
  import { createRequire } from "module";
10898
- import { basename as basename6, dirname as dirname13, join as join21, relative as relative5, resolve as resolve4 } from "path";
12335
+ import { basename as basename6, dirname as dirname15, join as join25, relative as relative7, resolve as resolve4 } from "path";
10899
12336
  import { fileURLToPath } from "url";
10900
12337
 
10901
12338
  // src/dev-server-owner.ts
10902
12339
  import { execFile as execFile2 } from "child_process";
10903
12340
  import { realpath as realpath3 } from "fs/promises";
10904
- import { basename as basename5, isAbsolute as isAbsolute2, relative as relative4, resolve as resolve3 } from "path";
12341
+ import { basename as basename5, isAbsolute as isAbsolute4, relative as relative6, resolve as resolve3 } from "path";
10905
12342
  import { promisify as promisify2 } from "util";
10906
12343
  var run2 = promisify2(execFile2);
10907
12344
  var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
@@ -10970,8 +12407,8 @@ async function inspectLocalDevServer(origin) {
10970
12407
  }
10971
12408
  }
10972
12409
  function isInside(parent, child) {
10973
- const path = relative4(resolve3(parent), resolve3(child));
10974
- return path === "" || !path.startsWith("..") && !isAbsolute2(path);
12410
+ const path = relative6(resolve3(parent), resolve3(child));
12411
+ return path === "" || !path.startsWith("..") && !isAbsolute4(path);
10975
12412
  }
10976
12413
  function devServerOwnerWarning(origin, projectRoot, owners) {
10977
12414
  const port = localDevServerPort(origin);
@@ -10984,11 +12421,11 @@ function devServerOwnerWarning(origin, projectRoot, owners) {
10984
12421
 
10985
12422
  // src/run.ts
10986
12423
  function findShellDir() {
10987
- const bundled = join21(dirname13(fileURLToPath(import.meta.url)), "shell");
10988
- if (existsSync7(join21(bundled, "index.html"))) return bundled;
12424
+ const bundled = join25(dirname15(fileURLToPath(import.meta.url)), "shell");
12425
+ if (existsSync7(join25(bundled, "index.html"))) return bundled;
10989
12426
  try {
10990
12427
  const require2 = createRequire(import.meta.url);
10991
- return dirname13(require2.resolve("@leglas/shell/dist/index.html"));
12428
+ return dirname15(require2.resolve("@leglas/shell/dist/index.html"));
10992
12429
  } catch {
10993
12430
  return null;
10994
12431
  }
@@ -10999,7 +12436,7 @@ function shellWord(value) {
10999
12436
  return `'${value.replaceAll("'", `'\\''`)}'`;
11000
12437
  }
11001
12438
  function embeddedLeglasCommand() {
11002
- const entry = join21(dirname13(fileURLToPath(import.meta.url)), "bin.js");
12439
+ const entry = join25(dirname15(fileURLToPath(import.meta.url)), "bin.js");
11003
12440
  if (!existsSync7(entry)) return "npx -y leglas";
11004
12441
  return [process.execPath, entry].map(shellWord).join(" ");
11005
12442
  }
@@ -11038,7 +12475,7 @@ async function runWithServices(options, deps, services = {}) {
11038
12475
  const fileMounts = /* @__PURE__ */ new Map();
11039
12476
  for (const preview of merged?.previews ?? []) {
11040
12477
  if (preview.file !== void 0) {
11041
- const absolute = join21(options.cwd, preview.file);
12478
+ const absolute = join25(options.cwd, preview.file);
11042
12479
  if (!existsSync7(absolute)) {
11043
12480
  previewErrors.push(
11044
12481
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -11049,7 +12486,7 @@ async function runWithServices(options, deps, services = {}) {
11049
12486
  for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
11050
12487
  slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
11051
12488
  }
11052
- fileMounts.set(slug, dirname13(absolute));
12489
+ fileMounts.set(slug, dirname15(absolute));
11053
12490
  previews.push({
11054
12491
  ...preview,
11055
12492
  url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename6(absolute))}`
@@ -11061,8 +12498,8 @@ async function runWithServices(options, deps, services = {}) {
11061
12498
  const config = merged === null ? null : { ...merged, previews };
11062
12499
  const configWarnings = [];
11063
12500
  const projectRoot = await realpath4(
11064
- loaded.path === null ? options.cwd : dirname13(loaded.path)
11065
- ).catch(() => resolve4(loaded.path === null ? options.cwd : dirname13(loaded.path)));
12501
+ loaded.path === null ? options.cwd : dirname15(loaded.path)
12502
+ ).catch(() => resolve4(loaded.path === null ? options.cwd : dirname15(loaded.path)));
11066
12503
  const ownerWarning = needsApp && app === null ? (services.inspectLocalDevServer ?? inspectLocalDevServer)(devServer).then((owners) => devServerOwnerWarning(devServer, projectRoot, owners)).catch(() => null) : Promise.resolve(null);
11067
12504
  const serverOptions = {
11068
12505
  config,
@@ -11100,7 +12537,7 @@ async function runWithServices(options, deps, services = {}) {
11100
12537
  })
11101
12538
  );
11102
12539
  } else {
11103
- const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative5(options.cwd, loaded.path) || loaded.path;
12540
+ const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative7(options.cwd, loaded.path) || loaded.path;
11104
12541
  deps.log(`Leglas ${url}`);
11105
12542
  deps.log(
11106
12543
  `app ${devServer}${app !== null ? " (started by Leglas)" : health.reachable ? "" : " (not reachable)"}`
@@ -11177,18 +12614,17 @@ async function startViewer(options, deps) {
11177
12614
  updates
11178
12615
  });
11179
12616
  const { handOff, handedOff } = createHandoff();
11180
- updates.onRestart(
11181
- (command) => handOff(command, result.stop, {
11182
- spawn: spawn5,
11183
- exit: (code) => process.exit(code),
11184
- target: process
11185
- })
11186
- );
12617
+ const handoff = deps.handoff ?? {
12618
+ spawn: spawn5,
12619
+ exit: (code) => process.exit(code),
12620
+ target: process
12621
+ };
12622
+ updates.onRestart((command) => handOff(command, result.stop, handoff));
11187
12623
  (deps.installShutdown ?? installShutdown)(async () => {
11188
12624
  if (handedOff()) return;
11189
12625
  await result.stop();
11190
- process.exit(0);
11191
- });
12626
+ handoff.exit(0);
12627
+ }, handoff.target);
11192
12628
  }
11193
12629
 
11194
12630
  // src/bin.ts
@@ -11198,7 +12634,7 @@ Usage
11198
12634
  leglas init Prepare a project and teach its agents
11199
12635
  leglas [options] Start the server and open the interface
11200
12636
  leglas new <surface> Scaffold a branch point for a surface
11201
- leglas explore <surface> Brief an agent's exploration of a surface
12637
+ leglas explore <surface> Brief an agent's exploration of a surface, or build it
11202
12638
  leglas classify Decide where a direction should live
11203
12639
  leglas add --title T --url U Register a preview on this machine
11204
12640
  leglas list Show every preview, shared and local
@@ -11223,13 +12659,19 @@ Options for new
11223
12659
  --from <path> Use an existing component as the baseline
11224
12660
 
11225
12661
  Options for explore
11226
- --count <n> How many directions (default 3)
12662
+ --count <n> How many directions (default 3; up to 6 with --build)
11227
12663
  --based-on <title> Variants of an existing direction instead of new ones
12664
+ --build Build the set with your agent (Claude or Codex) in the
12665
+ running Leglas, instead of printing a brief for an agent
12666
+ --brief <text> What the directions are for (needs --build; optional
12667
+ with --based-on)
12668
+ --port <port> Running Leglas port (needs --build)
11228
12669
 
11229
12670
  Options for watch
11230
12671
  --run <command> Your agent, with {prompt} where the request goes, for
11231
12672
  example "claude -p {prompt}". Remembered after first use
11232
12673
  --port <port> Port Leglas itself is on (default: 4100)
12674
+ --json One JSON line per event; the agent's output goes to stderr
11233
12675
 
11234
12676
  Options for classify
11235
12677
  --change <path> A file the direction creates or wires up (repeatable)
@@ -11325,6 +12767,26 @@ if (parsed.kind === "keep") {
11325
12767
  );
11326
12768
  process.exit(outcome.exitCode);
11327
12769
  }
12770
+ if (parsed.kind === "explore" && parsed.build) {
12771
+ const outcome = await runExploreBuild(
12772
+ {
12773
+ surface: parsed.surface,
12774
+ brief: parsed.brief ?? "",
12775
+ count: parsed.count,
12776
+ basedOn: parsed.basedOn,
12777
+ json: parsed.json,
12778
+ cwd: process.cwd(),
12779
+ port: parsed.port
12780
+ },
12781
+ {
12782
+ log: (line) => process.stdout.write(`${line}
12783
+ `),
12784
+ error: (line) => process.stderr.write(`${line}
12785
+ `)
12786
+ }
12787
+ );
12788
+ process.exit(outcome.exitCode);
12789
+ }
11328
12790
  if (parsed.kind === "explore") {
11329
12791
  const outcome = runExplore(
11330
12792
  { surface: parsed.surface, count: parsed.count, basedOn: parsed.basedOn, json: parsed.json },
@@ -11342,7 +12804,7 @@ if (parsed.kind === "requests") {
11342
12804
  }
11343
12805
  if (parsed.kind === "watch") {
11344
12806
  const outcome = await runWatch(
11345
- { run: parsed.run, port: parsed.port, cwd: process.cwd() },
12807
+ { run: parsed.run, port: parsed.port, cwd: process.cwd(), json: parsed.json },
11346
12808
  previewDeps
11347
12809
  );
11348
12810
  process.exit(outcome.exitCode);