leglas 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/bin.ts
4
- import { spawn as spawn3 } from "child_process";
4
+ import { spawn as spawn4 } from "child_process";
5
5
  import { createRequire as createRequire2 } from "module";
6
6
 
7
7
  // src/args.ts
@@ -353,7 +353,7 @@ function parseArgs(argv) {
353
353
 
354
354
  // src/run-classify.ts
355
355
  import { stat } from "fs/promises";
356
- import { join as join7 } from "path";
356
+ import { join as join8 } from "path";
357
357
 
358
358
  // ../server/dist/config.js
359
359
  var DEFAULT_DEV_SERVER = "http://localhost:3000";
@@ -485,6 +485,360 @@ function normalizeConfig(raw, options = {}) {
485
485
  };
486
486
  }
487
487
 
488
+ // ../server/dist/agent-command.js
489
+ var WATCH_PATH = ".leglas/watch.json";
490
+ var PROMPT_TOKEN = "{prompt}";
491
+ var EXAMPLE = `npx leglas watch --run "claude -p ${PROMPT_TOKEN}"`;
492
+ function tokenize(template) {
493
+ const tokens = [];
494
+ let current = "";
495
+ let started = false;
496
+ let quote = null;
497
+ for (const character of template) {
498
+ if (quote !== null) {
499
+ if (character === quote)
500
+ quote = null;
501
+ else
502
+ current += character;
503
+ continue;
504
+ }
505
+ if (character === '"' || character === "'") {
506
+ quote = character;
507
+ started = true;
508
+ continue;
509
+ }
510
+ if (/\s/.test(character)) {
511
+ if (started)
512
+ tokens.push(current);
513
+ current = "";
514
+ started = false;
515
+ continue;
516
+ }
517
+ current += character;
518
+ started = true;
519
+ }
520
+ if (quote !== null) {
521
+ return { ok: false, error: `The agent command has an unclosed ${quote} quote.` };
522
+ }
523
+ if (started)
524
+ tokens.push(current);
525
+ return { ok: true, tokens };
526
+ }
527
+ function parseTemplate(raw) {
528
+ const tokenized = tokenize(raw);
529
+ if (!tokenized.ok)
530
+ return tokenized;
531
+ const { tokens } = tokenized;
532
+ const [command, ...args] = tokens;
533
+ if (command === void 0) {
534
+ return { ok: false, error: `Watch needs an agent command, for example: ${EXAMPLE}` };
535
+ }
536
+ if (tokens.some((token) => token !== PROMPT_TOKEN && token.includes(PROMPT_TOKEN))) {
537
+ return {
538
+ ok: false,
539
+ error: `${PROMPT_TOKEN} must stand as a word of its own, for example: ${EXAMPLE}`
540
+ };
541
+ }
542
+ const placeholders = tokens.filter((token) => token === PROMPT_TOKEN).length;
543
+ if (placeholders > 1) {
544
+ return {
545
+ ok: false,
546
+ error: `The agent command takes ${PROMPT_TOKEN} once, for example: ${EXAMPLE}`
547
+ };
548
+ }
549
+ if (command === PROMPT_TOKEN) {
550
+ return {
551
+ ok: false,
552
+ error: `The agent command must name a program before ${PROMPT_TOKEN}, for example: ${EXAMPLE}`
553
+ };
554
+ }
555
+ return { ok: true, template: { command, args } };
556
+ }
557
+ function commandFor(template, prompt) {
558
+ if (!template.args.includes(PROMPT_TOKEN)) {
559
+ return { command: template.command, args: [...template.args, prompt] };
560
+ }
561
+ return {
562
+ command: template.command,
563
+ args: template.args.map((argument) => argument === PROMPT_TOKEN ? prompt : argument)
564
+ };
565
+ }
566
+ function nextRequest(requests, failed) {
567
+ return requests.find((request) => request.status === "queued" && !failed.has(request.id)) ?? null;
568
+ }
569
+
570
+ // ../server/dist/agents.js
571
+ import { spawn } from "child_process";
572
+ import { constants } from "fs";
573
+ import { access, mkdir, readFile, writeFile } from "fs/promises";
574
+ import { delimiter, dirname, isAbsolute, join, relative } from "path";
575
+ var KNOWN_AGENTS = {
576
+ claude: {
577
+ name: "Claude",
578
+ binary: "claude",
579
+ args: (prompt) => [
580
+ "-p",
581
+ prompt,
582
+ "--output-format",
583
+ "stream-json",
584
+ "--verbose",
585
+ "--permission-mode",
586
+ "acceptEdits"
587
+ ],
588
+ terminalArgs: (prompt) => [
589
+ "-p",
590
+ prompt,
591
+ "--permission-mode",
592
+ "acceptEdits"
593
+ ],
594
+ resumeArgs: (sessionId, prompt) => [
595
+ "-p",
596
+ "--resume",
597
+ sessionId,
598
+ prompt,
599
+ "--output-format",
600
+ "stream-json",
601
+ "--verbose",
602
+ "--permission-mode",
603
+ "acceptEdits"
604
+ ],
605
+ // Every stream-json event names its session.
606
+ sessionFrom: (event) => typeof event.session_id === "string" && event.session_id !== "" ? event.session_id : null,
607
+ authArgs: ["auth", "status"],
608
+ // `claude auth status` prints JSON with a loggedIn boolean. Only that
609
+ // field decides; any other shape stays unknown.
610
+ authVerdict: (result2) => {
611
+ try {
612
+ const parsed2 = record(JSON.parse(result2.stdout));
613
+ if (parsed2?.loggedIn === true)
614
+ return "ok";
615
+ if (parsed2?.loggedIn === false)
616
+ return "signed-out";
617
+ } catch {
618
+ }
619
+ return "unknown";
620
+ }
621
+ },
622
+ codex: {
623
+ name: "Codex",
624
+ binary: "codex",
625
+ args: (prompt) => ["exec", "--json", "-s", "workspace-write", prompt],
626
+ terminalArgs: (prompt) => ["exec", "-s", "workspace-write", prompt],
627
+ // No sandbox flag here: `codex exec resume` refuses it and inherits the
628
+ // session's own sandbox, which the first turn set to workspace-write.
629
+ resumeArgs: (sessionId, prompt) => [
630
+ "exec",
631
+ "resume",
632
+ sessionId,
633
+ "--json",
634
+ prompt
635
+ ],
636
+ sessionFrom: (event) => event.type === "thread.started" && typeof event.thread_id === "string" ? event.thread_id : null,
637
+ authArgs: ["login", "status"],
638
+ // `codex login status` exits 0 when logged in and nonzero when not.
639
+ authVerdict: (result2) => result2.code === 0 ? "ok" : "signed-out"
640
+ },
641
+ cursor: {
642
+ name: "Cursor",
643
+ binary: "cursor-agent",
644
+ args: (prompt) => ["-p", prompt, "--output-format", "stream-json"],
645
+ terminalArgs: (prompt) => ["-p", prompt],
646
+ authArgs: ["status"],
647
+ // UNVERIFIED: cursor-agent was not available on the build machine. The
648
+ // reading is deliberately loose, and anything ambiguous stays unknown.
649
+ authVerdict: (result2) => {
650
+ if (/logged in|signed in/i.test(result2.stdout))
651
+ return "ok";
652
+ if (result2.code !== 0 || /not logged in|log in|sign in/i.test(result2.stdout))
653
+ return "signed-out";
654
+ return "unknown";
655
+ }
656
+ }
657
+ };
658
+ var PROBE_TIMEOUT_MS = 3e3;
659
+ function execProbe(binary, args) {
660
+ return new Promise((resolve) => {
661
+ let child;
662
+ try {
663
+ child = spawn(binary, [...args], { shell: false, stdio: ["ignore", "pipe", "ignore"] });
664
+ } catch {
665
+ return resolve(null);
666
+ }
667
+ let stdout = "";
668
+ child.stdout?.on("data", (chunk) => {
669
+ if (stdout.length < 4096)
670
+ stdout += chunk.toString();
671
+ });
672
+ const deadline = setTimeout(() => child.kill("SIGKILL"), PROBE_TIMEOUT_MS);
673
+ child.once("error", () => {
674
+ clearTimeout(deadline);
675
+ resolve(null);
676
+ });
677
+ child.once("close", (code, signal) => {
678
+ clearTimeout(deadline);
679
+ resolve(signal !== null ? null : { code: code ?? 0, stdout });
680
+ });
681
+ });
682
+ }
683
+ async function pathLookup(binary) {
684
+ const entries = (process.env.PATH ?? "").split(delimiter).filter((entry) => entry !== "");
685
+ const extensions = process.platform === "win32" ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((entry) => entry !== "") : [""];
686
+ for (const entry of entries) {
687
+ for (const extension of extensions) {
688
+ try {
689
+ await access(join(entry, `${binary}${extension}`), constants.X_OK);
690
+ return true;
691
+ } catch {
692
+ }
693
+ }
694
+ }
695
+ return false;
696
+ }
697
+ async function detectAgents(lookup = pathLookup, probe2 = execProbe) {
698
+ const entries = Object.entries(KNOWN_AGENTS);
699
+ return Promise.all(entries.map(async ([id, adapter]) => {
700
+ const available = await lookup(adapter.binary).catch(() => false);
701
+ if (!available)
702
+ return { id, name: adapter.name, available, auth: "unknown" };
703
+ const result2 = await probe2(adapter.binary, adapter.authArgs).catch(() => null);
704
+ return {
705
+ id,
706
+ name: adapter.name,
707
+ available,
708
+ auth: result2 === null ? "unknown" : adapter.authVerdict(result2)
709
+ };
710
+ }));
711
+ }
712
+ function record(value) {
713
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
714
+ }
715
+ function shownPath(value, cwd) {
716
+ if (typeof value !== "string" || value === "")
717
+ return null;
718
+ if (!isAbsolute(value))
719
+ return value;
720
+ return relative(cwd, value) || ".";
721
+ }
722
+ function shownCommand(value) {
723
+ let command = Array.isArray(value) ? value.filter((part) => typeof part === "string").join(" ") : typeof value === "string" ? value : "";
724
+ command = command.trim();
725
+ const wrapped = /^(?:\S*\/)?(?:bash|sh|zsh)\s+-l?c\s+([\s\S]*)$/.exec(command);
726
+ if (wrapped?.[1] !== void 0) {
727
+ command = wrapped[1].trim();
728
+ const quote = command[0];
729
+ if ((quote === "'" || quote === '"') && command.endsWith(quote) && command.length > 1) {
730
+ command = command.slice(1, -1);
731
+ }
732
+ }
733
+ command = (command.split("\n")[0] ?? "").replace(/\s+/g, " ").trim();
734
+ if (command === "")
735
+ return null;
736
+ return command.length > 48 ? `${command.slice(0, 47)}\u2026` : command;
737
+ }
738
+ function claudeActivity(event, cwd) {
739
+ if (event.type !== "assistant")
740
+ return null;
741
+ const message = record(event.message);
742
+ if (message === null || !Array.isArray(message.content))
743
+ return null;
744
+ for (const rawBlock of message.content) {
745
+ const block = record(rawBlock);
746
+ if (block?.type !== "tool_use" || typeof block.name !== "string")
747
+ continue;
748
+ const input = record(block.input);
749
+ if (["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(block.name)) {
750
+ const path = shownPath(input?.file_path ?? input?.notebook_path, cwd);
751
+ return path === null ? `using ${block.name}` : `editing ${path}`;
752
+ }
753
+ if (block.name === "Read") {
754
+ const path = shownPath(input?.file_path ?? input?.path, cwd);
755
+ return path === null ? "using Read" : `reading ${path}`;
756
+ }
757
+ if (block.name === "Bash") {
758
+ const command = shownCommand(input?.command);
759
+ return command === null ? "running a command" : `running ${command}`;
760
+ }
761
+ if (block.name === "Grep" || block.name === "Glob")
762
+ return "searching the project";
763
+ return `using ${block.name}`;
764
+ }
765
+ return null;
766
+ }
767
+ function codexActivity(event, cwd) {
768
+ if (event.type !== "item.started" && event.type !== "item.completed")
769
+ return null;
770
+ const item = record(event.item);
771
+ if (item === null)
772
+ return null;
773
+ if (item.type === "command_execution") {
774
+ const command = shownCommand(item.command);
775
+ return command === null ? "running a command" : `running ${command}`;
776
+ }
777
+ if (item.type !== "file_change")
778
+ return null;
779
+ const first = Array.isArray(item.changes) ? record(item.changes[0]) : null;
780
+ const path = shownPath(first?.path ?? item.path, cwd);
781
+ return path === null ? null : `editing ${path}`;
782
+ }
783
+ function activityFrom(agent, line, cwd = process.cwd()) {
784
+ let event;
785
+ try {
786
+ event = record(JSON.parse(line));
787
+ } catch {
788
+ return null;
789
+ }
790
+ if (event === null)
791
+ return null;
792
+ if (agent === "claude")
793
+ return claudeActivity(event, cwd);
794
+ if (agent === "codex")
795
+ return codexActivity(event, cwd);
796
+ if (agent === "cursor")
797
+ return claudeActivity(event, cwd);
798
+ return null;
799
+ }
800
+ function sessionFrom(agent, line) {
801
+ if (agent !== "claude" && agent !== "codex")
802
+ return null;
803
+ let event;
804
+ try {
805
+ event = record(JSON.parse(line));
806
+ } catch {
807
+ return null;
808
+ }
809
+ if (event === null)
810
+ return null;
811
+ return KNOWN_AGENTS[agent].sessionFrom(event);
812
+ }
813
+ function isAgentChoice(value) {
814
+ return value === "custom" || typeof value === "string" && Object.hasOwn(KNOWN_AGENTS, value);
815
+ }
816
+ async function readWatchConfig(cwd) {
817
+ try {
818
+ const parsed2 = JSON.parse(await readFile(join(cwd, WATCH_PATH), "utf8"));
819
+ return record(parsed2) ?? {};
820
+ } catch {
821
+ return {};
822
+ }
823
+ }
824
+ async function readAgentChoice(cwd) {
825
+ const config = await readWatchConfig(cwd);
826
+ return {
827
+ agent: isAgentChoice(config.agent) ? config.agent : null,
828
+ run: typeof config.run === "string" && config.run !== "" ? config.run : null
829
+ };
830
+ }
831
+ async function saveAgentChoice(cwd, choice) {
832
+ const config = await readWatchConfig(cwd);
833
+ config.agent = choice.agent;
834
+ if (choice.run !== void 0)
835
+ config.run = choice.run;
836
+ const path = join(cwd, WATCH_PATH);
837
+ await mkdir(dirname(path), { recursive: true });
838
+ await writeFile(path, `${JSON.stringify(config, null, 2)}
839
+ `, "utf8");
840
+ }
841
+
488
842
  // ../server/dist/classify.js
489
843
  var MANIFESTS = /* @__PURE__ */ new Set([
490
844
  "package.json",
@@ -552,7 +906,7 @@ function classifyDirection(input) {
552
906
 
553
907
  // ../server/dist/find-config.js
554
908
  import { existsSync } from "fs";
555
- import { dirname, join, parse } from "path";
909
+ import { dirname as dirname2, join as join2, parse } from "path";
556
910
  var CONFIG_BASENAMES = [
557
911
  "leglas.config.ts",
558
912
  "leglas.config.mjs",
@@ -564,13 +918,13 @@ function findConfigFile(startDir) {
564
918
  let dir = startDir;
565
919
  for (; ; ) {
566
920
  for (const basename4 of CONFIG_BASENAMES) {
567
- const candidate = join(dir, basename4);
921
+ const candidate = join2(dir, basename4);
568
922
  if (existsSync(candidate))
569
923
  return candidate;
570
924
  }
571
925
  if (dir === root)
572
926
  return null;
573
- const parent = dirname(dir);
927
+ const parent = dirname2(dir);
574
928
  if (parent === dir)
575
929
  return null;
576
930
  dir = parent;
@@ -578,19 +932,19 @@ function findConfigFile(startDir) {
578
932
  }
579
933
 
580
934
  // ../server/dist/load-config.js
581
- import { readFile } from "fs/promises";
582
- import { relative } from "path";
935
+ import { readFile as readFile2 } from "fs/promises";
936
+ import { relative as relative2 } from "path";
583
937
  import { pathToFileURL } from "url";
584
938
  async function loadConfig(cwd) {
585
939
  const path = findConfigFile(cwd);
586
940
  if (path === null) {
587
941
  return { ...normalizeConfig(void 0), path: null };
588
942
  }
589
- const label = relative(cwd, path) || path;
943
+ const label = relative2(cwd, path) || path;
590
944
  let exported;
591
945
  try {
592
946
  if (path.endsWith(".json")) {
593
- exported = JSON.parse(await readFile(path, "utf8"));
947
+ exported = JSON.parse(await readFile2(path, "utf8"));
594
948
  } else {
595
949
  const module = await import(pathToFileURL(path).href);
596
950
  if (!("default" in module)) {
@@ -611,14 +965,14 @@ async function loadConfig(cwd) {
611
965
  }
612
966
 
613
967
  // ../server/dist/local-previews.js
614
- import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
615
- import { dirname as dirname2, join as join2 } from "path";
968
+ import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
969
+ import { dirname as dirname3, join as join3 } from "path";
616
970
  var LOCAL_PREVIEWS_PATH = ".leglas/previews.json";
617
971
  async function readLocalPreviews(cwd) {
618
- const path = join2(cwd, LOCAL_PREVIEWS_PATH);
972
+ const path = join3(cwd, LOCAL_PREVIEWS_PATH);
619
973
  let raw;
620
974
  try {
621
- raw = await readFile2(path, "utf8");
975
+ raw = await readFile3(path, "utf8");
622
976
  } catch {
623
977
  return { previews: [], errors: [] };
624
978
  }
@@ -667,9 +1021,9 @@ async function addLocalPreview(cwd, input, shared) {
667
1021
  if (check.config === null) {
668
1022
  return { ok: false, error: check.errors.join(" ") };
669
1023
  }
670
- const path = join2(cwd, LOCAL_PREVIEWS_PATH);
671
- await mkdir(dirname2(path), { recursive: true });
672
- await writeFile(path, `${JSON.stringify({ previews: [...existing.previews.map(toStored), candidate] }, null, 2)}
1024
+ const path = join3(cwd, LOCAL_PREVIEWS_PATH);
1025
+ await mkdir2(dirname3(path), { recursive: true });
1026
+ await writeFile2(path, `${JSON.stringify({ previews: [...existing.previews.map(toStored), candidate] }, null, 2)}
673
1027
  `, "utf8");
674
1028
  return { ok: true };
675
1029
  }
@@ -682,9 +1036,9 @@ async function dropLocalPreviews(cwd, titles) {
682
1036
  const keep = existing.previews.filter((preview) => !titles.includes(preview.title));
683
1037
  if (keep.length === existing.previews.length)
684
1038
  return 0;
685
- const path = join2(cwd, LOCAL_PREVIEWS_PATH);
686
- await mkdir(dirname2(path), { recursive: true });
687
- await writeFile(path, `${JSON.stringify({ previews: keep.map(toStored) }, null, 2)}
1039
+ const path = join3(cwd, LOCAL_PREVIEWS_PATH);
1040
+ await mkdir2(dirname3(path), { recursive: true });
1041
+ await writeFile2(path, `${JSON.stringify({ previews: keep.map(toStored) }, null, 2)}
688
1042
  `, "utf8");
689
1043
  return existing.previews.length - keep.length;
690
1044
  }
@@ -753,10 +1107,10 @@ ${headers}\r
753
1107
  }
754
1108
 
755
1109
  // ../server/dist/worktree.js
756
- import { execFile, spawn } from "child_process";
1110
+ import { execFile, spawn as spawn2 } from "child_process";
757
1111
  import { rm } from "fs/promises";
758
1112
  import net2 from "net";
759
- import { join as join3 } from "path";
1113
+ import { join as join4 } from "path";
760
1114
  import { promisify } from "util";
761
1115
  var run = promisify(execFile);
762
1116
  var WORKTREES_DIR = ".leglas/worktrees";
@@ -793,7 +1147,7 @@ function answers(port) {
793
1147
  }
794
1148
  async function startWorktree(options) {
795
1149
  const readyTimeoutMs = options.readyTimeoutMs ?? 9e4;
796
- const path = join3(options.cwd, WORKTREES_DIR, worktreeSlug(options.branch));
1150
+ const path = join4(options.cwd, WORKTREES_DIR, worktreeSlug(options.branch));
797
1151
  const log = options.onLog ?? (() => {
798
1152
  });
799
1153
  await rm(path, { recursive: true, force: true });
@@ -854,7 +1208,7 @@ async function startAppProcess(options) {
854
1208
  const port = await freePort();
855
1209
  let child;
856
1210
  try {
857
- child = spawn(substitutePort(options.devCommand, port), {
1211
+ child = spawn2(substitutePort(options.devCommand, port), {
858
1212
  cwd: options.cwd,
859
1213
  shell: true,
860
1214
  // Own process group, so stopping kills the shell and whatever it spawned
@@ -894,9 +1248,9 @@ async function startAppProcess(options) {
894
1248
  }
895
1249
 
896
1250
  // ../server/dist/requests.js
897
- import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1251
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
898
1252
  import { randomBytes } from "crypto";
899
- import { dirname as dirname3, join as join4 } from "path";
1253
+ import { dirname as dirname4, join as join5 } from "path";
900
1254
  var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
901
1255
  function targetFor(url) {
902
1256
  if (!url.startsWith("/"))
@@ -922,17 +1276,20 @@ function composeRequest(preview, intent) {
922
1276
  const target = preview.file ?? targetFor(preview.url);
923
1277
  const cleaned = intent.trim();
924
1278
  const where = target === null ? `The direction is titled "${preview.title}" and renders at ${preview.url}. Find what produces it.` : `It lives at ${target}.`;
1279
+ const pace = target === null ? `Once found, make the change and finish. ` : `Make the change in that file and finish. `;
925
1280
  const prompt = `In this project, change only the "${preview.title}" design direction. ${where}
926
1281
 
927
1282
  What to change: ${cleaned}
928
1283
 
1284
+ ${pace}This is a scoped design change: no test run, no build, and no survey of the rest of the project is needed. The result is checked visually in a live preview, not by tooling.
1285
+
929
1286
  Leave every other direction exactly as it is; they are alternatives being compared side by side, so changing a sibling destroys the comparison. The direction is already registered, so nothing needs re-registering. Keep the change additive: do not rewrite shared components that other directions rely on.`;
930
1287
  return { prompt, target };
931
1288
  }
932
1289
  var REQUESTS_PATH = ".leglas/requests.json";
933
1290
  async function readRequests(cwd) {
934
1291
  try {
935
- const raw = await readFile3(join4(cwd, REQUESTS_PATH), "utf8");
1292
+ const raw = await readFile4(join5(cwd, REQUESTS_PATH), "utf8");
936
1293
  const parsed2 = JSON.parse(raw);
937
1294
  if (!Array.isArray(parsed2.requests))
938
1295
  return [];
@@ -949,9 +1306,9 @@ async function readRequests(cwd) {
949
1306
  }
950
1307
  }
951
1308
  async function writeQueue(cwd, requests) {
952
- const path = join4(cwd, REQUESTS_PATH);
953
- await mkdir2(dirname3(path), { recursive: true });
954
- await writeFile2(path, `${JSON.stringify({ requests }, null, 2)}
1309
+ const path = join5(cwd, REQUESTS_PATH);
1310
+ await mkdir3(dirname4(path), { recursive: true });
1311
+ await writeFile3(path, `${JSON.stringify({ requests }, null, 2)}
955
1312
  `, "utf8");
956
1313
  }
957
1314
  async function appendRequest(cwd, request) {
@@ -991,13 +1348,264 @@ async function clearRequests(cwd) {
991
1348
  return { cleared, pending: pending.length };
992
1349
  }
993
1350
 
1351
+ // ../server/dist/runner.js
1352
+ import { spawn as nodeSpawn } from "child_process";
1353
+ var POLL_MS = 2e3;
1354
+ var OUTPUT_LINES = 20;
1355
+ var SESSION_TURNS_CAP = 8;
1356
+ function resolveCommand(choice, prompt, sessionId = null) {
1357
+ if (choice.agent === null)
1358
+ return null;
1359
+ if (choice.agent === "custom") {
1360
+ if (choice.run === null)
1361
+ return null;
1362
+ const parsed2 = parseTemplate(choice.run);
1363
+ if (!parsed2.ok)
1364
+ return null;
1365
+ return { agent: "custom", name: "Custom", ...commandFor(parsed2.template, prompt), resumed: false };
1366
+ }
1367
+ const adapter = KNOWN_AGENTS[choice.agent];
1368
+ if (sessionId !== null && "resumeArgs" in adapter) {
1369
+ return {
1370
+ agent: choice.agent,
1371
+ name: adapter.name,
1372
+ command: adapter.binary,
1373
+ args: adapter.resumeArgs(sessionId, prompt),
1374
+ resumed: true
1375
+ };
1376
+ }
1377
+ return {
1378
+ agent: choice.agent,
1379
+ name: adapter.name,
1380
+ command: adapter.binary,
1381
+ args: adapter.args(prompt),
1382
+ resumed: false
1383
+ };
1384
+ }
1385
+ function lineReader(stream, onLine) {
1386
+ let buffered = "";
1387
+ const flush = () => {
1388
+ if (buffered === "")
1389
+ return;
1390
+ onLine(buffered.replace(/\r$/, ""));
1391
+ buffered = "";
1392
+ };
1393
+ stream.on("data", (chunk) => {
1394
+ buffered += chunk.toString();
1395
+ const lines = buffered.split("\n");
1396
+ buffered = lines.pop() ?? "";
1397
+ for (const line of lines)
1398
+ onLine(line.replace(/\r$/, ""));
1399
+ });
1400
+ stream.on("end", flush);
1401
+ return flush;
1402
+ }
1403
+ function defaultSpawn(command, args, options) {
1404
+ return nodeSpawn(command, args, options);
1405
+ }
1406
+ function startRunner(options) {
1407
+ const spawn5 = options.spawn ?? defaultSpawn;
1408
+ const setEvery = options.setInterval ?? ((callback, milliseconds) => setInterval(callback, milliseconds));
1409
+ const clearEvery = options.clearInterval ?? ((handle2) => clearInterval(handle2));
1410
+ const failed = /* @__PURE__ */ new Set();
1411
+ let state = {
1412
+ running: false,
1413
+ requestId: null,
1414
+ agent: null,
1415
+ activity: null,
1416
+ startedAt: null
1417
+ };
1418
+ let stopped = false;
1419
+ let ticking = null;
1420
+ let stopPromise = null;
1421
+ let active = null;
1422
+ const sessions = /* @__PURE__ */ new Map();
1423
+ const idle = () => {
1424
+ state = { running: false, requestId: null, agent: null, activity: null, startedAt: null };
1425
+ };
1426
+ const rememberLine = (lines, line) => {
1427
+ lines.push(line);
1428
+ if (lines.length > OUTPUT_LINES)
1429
+ lines.splice(0, lines.length - OUTPUT_LINES);
1430
+ };
1431
+ const reportFailure = (request, error, lines) => {
1432
+ console.error(`Leglas agent failed for ${request.title}: ${error}`);
1433
+ for (const line of lines)
1434
+ console.error(` ${line}`);
1435
+ };
1436
+ const runChild = (request, resolved, lines, observed) => {
1437
+ let child;
1438
+ try {
1439
+ child = spawn5(resolved.command, resolved.args, {
1440
+ cwd: options.cwd,
1441
+ shell: false,
1442
+ stdio: ["ignore", "pipe", "pipe"]
1443
+ });
1444
+ } catch (error) {
1445
+ return Promise.resolve({
1446
+ ok: false,
1447
+ error: error instanceof Error ? error.message : String(error)
1448
+ });
1449
+ }
1450
+ const current = { child, requestId: request.id, cancelled: false };
1451
+ active = current;
1452
+ const stdoutFlush = lineReader(child.stdout, (line) => {
1453
+ rememberLine(lines, line);
1454
+ const sessionId = sessionFrom(resolved.agent, line);
1455
+ if (sessionId !== null)
1456
+ observed.sessionId = sessionId;
1457
+ const activity = activityFrom(resolved.agent, line, options.cwd);
1458
+ if (activity !== null) {
1459
+ if (activity.startsWith("editing"))
1460
+ observed.edited = true;
1461
+ if (active === current)
1462
+ state = { ...state, activity };
1463
+ }
1464
+ });
1465
+ const stderrFlush = lineReader(child.stderr, (line) => rememberLine(lines, line));
1466
+ return new Promise((resolve) => {
1467
+ let settled = false;
1468
+ const settle = (outcome) => {
1469
+ if (settled)
1470
+ return;
1471
+ settled = true;
1472
+ stdoutFlush();
1473
+ stderrFlush();
1474
+ resolve(outcome);
1475
+ };
1476
+ child.once("error", (error) => settle({ ok: false, error: error.message }));
1477
+ child.once("close", (code, signal) => {
1478
+ if (current.cancelled)
1479
+ return settle({ ok: false, error: "cancelled" });
1480
+ if (signal !== null)
1481
+ return settle({ ok: false, error: `stopped by ${signal}` });
1482
+ settle({ ok: true, code: code ?? 0 });
1483
+ });
1484
+ }).finally(() => {
1485
+ if (active === current)
1486
+ active = null;
1487
+ });
1488
+ };
1489
+ const handle = async (request, choice) => {
1490
+ const session = choice.agent !== null ? sessions.get(choice.agent) ?? null : null;
1491
+ const continuable = session !== null && session.turns < SESSION_TURNS_CAP;
1492
+ let resolved = resolveCommand(choice, request.prompt, continuable ? session.id : null);
1493
+ if (resolved === null)
1494
+ return;
1495
+ const lines = [];
1496
+ try {
1497
+ if (!await markPickedUp(options.cwd, request.id))
1498
+ return;
1499
+ if (stopped) {
1500
+ failed.add(request.id);
1501
+ return;
1502
+ }
1503
+ state = {
1504
+ running: true,
1505
+ requestId: request.id,
1506
+ agent: resolved.name,
1507
+ activity: null,
1508
+ startedAt: Date.now()
1509
+ };
1510
+ const observed = { sessionId: null, edited: false };
1511
+ let outcome = await runChild(request, resolved, lines, observed);
1512
+ const cancelled = !outcome.ok && outcome.error === "cancelled";
1513
+ if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && !cancelled && // Not redundant with the line above: a stop that lands between the
1514
+ // first child settling and the retry starting finds no child to
1515
+ // cancel, so nothing says "cancelled". Stopped still means stopped.
1516
+ !stopped) {
1517
+ sessions.delete(resolved.agent);
1518
+ const cold = resolveCommand(choice, request.prompt);
1519
+ if (cold !== null) {
1520
+ resolved = cold;
1521
+ observed.sessionId = null;
1522
+ state = { ...state, activity: null };
1523
+ outcome = await runChild(request, resolved, lines, observed);
1524
+ }
1525
+ }
1526
+ if (outcome.ok && outcome.code === 0) {
1527
+ if (observed.sessionId !== null) {
1528
+ const previous = sessions.get(resolved.agent);
1529
+ sessions.set(resolved.agent, {
1530
+ id: observed.sessionId,
1531
+ turns: resolved.resumed && previous?.id === observed.sessionId ? previous.turns + 1 : 1
1532
+ });
1533
+ }
1534
+ await removeRequest(options.cwd, request.id);
1535
+ return;
1536
+ }
1537
+ sessions.delete(resolved.agent);
1538
+ failed.add(request.id);
1539
+ reportFailure(request, outcome.ok ? `${resolved.command} exited ${outcome.code}` : outcome.error, lines);
1540
+ } finally {
1541
+ idle();
1542
+ }
1543
+ };
1544
+ const tick = async () => {
1545
+ if (stopped)
1546
+ return;
1547
+ const choice = await readAgentChoice(options.cwd);
1548
+ if (choice.agent === null || stopped)
1549
+ return;
1550
+ if (options.externallyAttached())
1551
+ return;
1552
+ const request = nextRequest(await readRequests(options.cwd), failed);
1553
+ if (request !== null && !stopped)
1554
+ await handle(request, choice);
1555
+ };
1556
+ const schedule = () => {
1557
+ if (stopped || ticking !== null)
1558
+ return;
1559
+ const task = tick();
1560
+ ticking = task;
1561
+ void task.catch((error) => console.error(`Leglas runner: ${error instanceof Error ? error.message : String(error)}`)).finally(() => {
1562
+ if (ticking === task)
1563
+ ticking = null;
1564
+ });
1565
+ };
1566
+ const timer = setEvery(schedule, POLL_MS);
1567
+ schedule();
1568
+ const cancel = (id) => {
1569
+ if (active === null || active.cancelled)
1570
+ return false;
1571
+ if (id !== void 0 && active.requestId !== id)
1572
+ return false;
1573
+ active.cancelled = true;
1574
+ failed.add(active.requestId);
1575
+ try {
1576
+ active.child.kill("SIGTERM");
1577
+ } catch {
1578
+ }
1579
+ return true;
1580
+ };
1581
+ const stop = () => {
1582
+ if (stopPromise !== null)
1583
+ return stopPromise;
1584
+ stopped = true;
1585
+ clearEvery(timer);
1586
+ cancel();
1587
+ stopPromise = Promise.resolve(ticking).catch(() => {
1588
+ }).then(() => {
1589
+ });
1590
+ return stopPromise;
1591
+ };
1592
+ return {
1593
+ stop,
1594
+ snapshot: () => ({ ...state, failedIds: [...failed] }),
1595
+ cancel,
1596
+ // schedule already refuses to overlap a tick in flight, so a nudge during
1597
+ // a run costs nothing and a nudge between runs starts the next one now.
1598
+ nudge: schedule
1599
+ };
1600
+ }
1601
+
994
1602
  // ../server/dist/renames.js
995
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
996
- import { dirname as dirname4, join as join5 } from "path";
1603
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1604
+ import { dirname as dirname5, join as join6 } from "path";
997
1605
  var RENAMES_PATH = ".leglas/renames.json";
998
1606
  async function readRenames(cwd) {
999
1607
  try {
1000
- const raw = await readFile4(join5(cwd, RENAMES_PATH), "utf8");
1608
+ const raw = await readFile5(join6(cwd, RENAMES_PATH), "utf8");
1001
1609
  const parsed2 = JSON.parse(raw);
1002
1610
  if (parsed2.renames === null || typeof parsed2.renames !== "object")
1003
1611
  return {};
@@ -1007,9 +1615,9 @@ async function readRenames(cwd) {
1007
1615
  }
1008
1616
  }
1009
1617
  async function writeRenames(cwd, renames) {
1010
- const path = join5(cwd, RENAMES_PATH);
1011
- await mkdir3(dirname4(path), { recursive: true });
1012
- await writeFile3(path, `${JSON.stringify({ renames }, null, 2)}
1618
+ const path = join6(cwd, RENAMES_PATH);
1619
+ await mkdir4(dirname5(path), { recursive: true });
1620
+ await writeFile4(path, `${JSON.stringify({ renames }, null, 2)}
1013
1621
  `, "utf8");
1014
1622
  }
1015
1623
  function resolveTitle(input, titles, renames) {
@@ -1027,7 +1635,7 @@ function resolveTitle(input, titles, renames) {
1027
1635
  import { createReadStream, existsSync as existsSync2, statSync } from "fs";
1028
1636
  import http2 from "http";
1029
1637
  import net3 from "net";
1030
- import { extname, join as join6, normalize, relative as relative2 } from "path";
1638
+ import { extname, join as join7, normalize, relative as relative3 } from "path";
1031
1639
  var LEGLAS_PREFIX = "/leglas";
1032
1640
  var DEFAULT_PORT = 4100;
1033
1641
  var PORT_ATTEMPTS = 20;
@@ -1058,6 +1666,52 @@ function sendJson(res, status, body) {
1058
1666
  });
1059
1667
  res.end(payload);
1060
1668
  }
1669
+ function isKnownAgent(value) {
1670
+ return typeof value === "string" && Object.hasOwn(KNOWN_AGENTS, value);
1671
+ }
1672
+ function isAllowedMutationHost(hostname) {
1673
+ const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
1674
+ if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1")
1675
+ return true;
1676
+ if (bare.endsWith(".local"))
1677
+ return true;
1678
+ if (!net3.isIPv4(bare))
1679
+ return false;
1680
+ const [first, second] = bare.split(".").map(Number);
1681
+ return first === 10 || first === 172 && second !== void 0 && second >= 16 && second <= 31 || first === 192 && second === 168;
1682
+ }
1683
+ function isLoopbackAddress(address) {
1684
+ if (address === void 0)
1685
+ return false;
1686
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1" || address.startsWith("127.");
1687
+ }
1688
+ function isTrustedMutation(req) {
1689
+ if (!isLoopbackAddress(req.socket.remoteAddress))
1690
+ return false;
1691
+ if (typeof req.headers.host !== "string")
1692
+ return false;
1693
+ let host;
1694
+ try {
1695
+ host = new URL(`http://${req.headers.host}`);
1696
+ } catch {
1697
+ return false;
1698
+ }
1699
+ if (!isAllowedMutationHost(host.hostname))
1700
+ return false;
1701
+ const rawOrigin = req.headers.origin;
1702
+ if (rawOrigin === void 0)
1703
+ return true;
1704
+ try {
1705
+ const origin = new URL(rawOrigin);
1706
+ return origin.protocol === "http:" && origin.host === host.host;
1707
+ } catch {
1708
+ return false;
1709
+ }
1710
+ }
1711
+ function hasJsonBody(req) {
1712
+ const contentType = req.headers["content-type"];
1713
+ return typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
1714
+ }
1061
1715
  function probe(target, timeoutMs = 1e3) {
1062
1716
  return new Promise((resolve) => {
1063
1717
  let url;
@@ -1079,8 +1733,8 @@ function probe(target, timeoutMs = 1e3) {
1079
1733
  });
1080
1734
  }
1081
1735
  function serveFrom(res, dir, relativePath) {
1082
- const relative4 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
1083
- const candidate = join6(dir, relative4);
1736
+ const relative5 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
1737
+ const candidate = join7(dir, relative5);
1084
1738
  if (!candidate.startsWith(dir))
1085
1739
  return false;
1086
1740
  if (!existsSync2(candidate) || !statSync(candidate).isFile())
@@ -1093,9 +1747,9 @@ function serveFrom(res, dir, relativePath) {
1093
1747
  return true;
1094
1748
  }
1095
1749
  function serveShellFile(res, shellDir, urlPath) {
1096
- const relative4 = normalize(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
1097
- const isRoot = relative4 === "" || relative4 === "." || relative4 === "/";
1098
- return serveFrom(res, shellDir, isRoot ? "index.html" : relative4);
1750
+ const relative5 = normalize(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
1751
+ const isRoot = relative5 === "" || relative5 === "." || relative5 === "/";
1752
+ return serveFrom(res, shellDir, isRoot ? "index.html" : relative5);
1099
1753
  }
1100
1754
  function snapshotConfig(cwd) {
1101
1755
  const path = findConfigFile(cwd);
@@ -1111,15 +1765,15 @@ function configStalenessNotice(cwd, boot, current) {
1111
1765
  if (boot === null && current === null)
1112
1766
  return null;
1113
1767
  if (boot === null && current !== null) {
1114
- const label = relative2(cwd, current.path) || current.path;
1768
+ const label = relative3(cwd, current.path) || current.path;
1115
1769
  return `${label} appeared after Leglas started. Restart leglas to pick it up.`;
1116
1770
  }
1117
1771
  if (boot !== null && current === null) {
1118
- const label = relative2(cwd, boot.path) || boot.path;
1772
+ const label = relative3(cwd, boot.path) || boot.path;
1119
1773
  return `${label} was removed after Leglas started. Restart leglas to run without it.`;
1120
1774
  }
1121
1775
  if (boot !== null && current !== null && (boot.path !== current.path || boot.mtimeMs !== current.mtimeMs)) {
1122
- const label = relative2(cwd, current.path) || current.path;
1776
+ const label = relative3(cwd, current.path) || current.path;
1123
1777
  return `${label} changed after Leglas started. Restart leglas to pick it up.`;
1124
1778
  }
1125
1779
  return null;
@@ -1165,14 +1819,40 @@ async function bind(server, requested) {
1165
1819
  throw new Error(`No free port between ${requested} and ${requested + PORT_ATTEMPTS - 1}.`);
1166
1820
  }
1167
1821
  async function startServer(options) {
1168
- const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map() } = options;
1822
+ const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
1169
1823
  const target = config?.devServer ?? "http://localhost:3000";
1170
1824
  const proxy = createProxyHandler({ target });
1171
1825
  const bootConfigSnapshot = snapshotConfig(cwd);
1172
1826
  let lastSeen = null;
1827
+ const externallyAttached = () => lastSeen !== null && Date.now() - lastSeen < ATTACHED_WINDOW_MS;
1828
+ let runner = null;
1829
+ let agentsCache = null;
1830
+ let agentsInflight = null;
1831
+ const AGENTS_FRESH_MS = 3e4;
1832
+ const probeAgents = () => {
1833
+ agentsInflight ??= detect().then((agents) => {
1834
+ agentsCache = { at: Date.now(), agents };
1835
+ return agents;
1836
+ }).finally(() => {
1837
+ agentsInflight = null;
1838
+ });
1839
+ return agentsInflight;
1840
+ };
1841
+ const currentAgents = () => {
1842
+ if (agentsCache === null)
1843
+ return probeAgents();
1844
+ if (Date.now() - agentsCache.at > AGENTS_FRESH_MS) {
1845
+ void probeAgents().catch(() => {
1846
+ });
1847
+ }
1848
+ return Promise.resolve(agentsCache.agents);
1849
+ };
1173
1850
  const server = http2.createServer((req, res) => {
1174
1851
  const url = req.url ?? "/";
1175
1852
  const path = url.split("?")[0] ?? "/";
1853
+ if (req.method === "POST" && path.startsWith(`${LEGLAS_PREFIX}/api/`) && !isTrustedMutation(req)) {
1854
+ return sendJson(res, 403, { ok: false, error: "Cross-origin API mutations are refused." });
1855
+ }
1176
1856
  if (path === `${LEGLAS_PREFIX}/api/config`) {
1177
1857
  const boot = config?.previews ?? [];
1178
1858
  const errors = [...configErrors];
@@ -1216,7 +1896,54 @@ async function startServer(options) {
1216
1896
  url: preview.url,
1217
1897
  intent: parsed2.intent.trim(),
1218
1898
  ...composed
1219
- }).then(() => sendJson(res, 200, { ok: true, ...composed })).catch(() => sendJson(res, 200, { ok: true, ...composed, queued: false }));
1899
+ }).then(() => {
1900
+ runner?.nudge();
1901
+ sendJson(res, 200, { ok: true, ...composed });
1902
+ }).catch(() => sendJson(res, 200, { ok: true, ...composed, queued: false }));
1903
+ });
1904
+ }
1905
+ if (path === `${LEGLAS_PREFIX}/api/agents` && req.method === "GET") {
1906
+ return void Promise.all([currentAgents(), readAgentChoice(cwd)]).then(([agents, choice]) => sendJson(res, 200, {
1907
+ agents,
1908
+ choice: choice.agent,
1909
+ customRun: choice.run
1910
+ }));
1911
+ }
1912
+ if (path === `${LEGLAS_PREFIX}/api/agent` && req.method === "POST") {
1913
+ if (!isLoopbackAddress(req.socket.remoteAddress)) {
1914
+ return sendJson(res, 403, {
1915
+ ok: false,
1916
+ error: "The agent choice can only be made from the machine running Leglas."
1917
+ });
1918
+ }
1919
+ if (!hasJsonBody(req)) {
1920
+ return sendJson(res, 400, { ok: false, error: "Agent choice must be JSON." });
1921
+ }
1922
+ let body = "";
1923
+ req.on("data", (chunk) => body += chunk);
1924
+ return void req.on("end", () => {
1925
+ let parsed2;
1926
+ try {
1927
+ parsed2 = JSON.parse(body || "{}");
1928
+ } catch {
1929
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
1930
+ }
1931
+ if (!isKnownAgent(parsed2.agent) && parsed2.agent !== "custom") {
1932
+ return sendJson(res, 400, { ok: false, error: "Body needs a known agent." });
1933
+ }
1934
+ if (parsed2.run !== void 0 && typeof parsed2.run !== "string") {
1935
+ return sendJson(res, 400, { ok: false, error: "The custom run command must be a string." });
1936
+ }
1937
+ if (parsed2.agent === "custom") {
1938
+ if (typeof parsed2.run !== "string") {
1939
+ return sendJson(res, 400, { ok: false, error: "A custom agent needs a run command." });
1940
+ }
1941
+ const template = parseTemplate(parsed2.run);
1942
+ if (!template.ok)
1943
+ return sendJson(res, 400, { ok: false, error: template.error });
1944
+ return void saveAgentChoice(cwd, { agent: "custom", run: parsed2.run }).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 500, { ok: false, error: "Agent choice could not be saved." }));
1945
+ }
1946
+ return void saveAgentChoice(cwd, { agent: parsed2.agent }).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 500, { ok: false, error: "Agent choice could not be saved." }));
1220
1947
  });
1221
1948
  }
1222
1949
  if (path === `${LEGLAS_PREFIX}/api/watch` && req.method === "POST") {
@@ -1237,11 +1964,119 @@ async function startServer(options) {
1237
1964
  });
1238
1965
  }
1239
1966
  if (path === `${LEGLAS_PREFIX}/api/requests` && req.method === "GET") {
1967
+ const snapshot = runner?.snapshot() ?? {
1968
+ running: false,
1969
+ requestId: null,
1970
+ agent: null,
1971
+ activity: null,
1972
+ startedAt: null,
1973
+ failedIds: []
1974
+ };
1240
1975
  return void readRequests(cwd).then((requests) => sendJson(res, 200, {
1241
- requests: requests.map(({ id, title, intent, status }) => ({ id, title, intent, status })),
1242
- agent: { attached: lastSeen !== null && Date.now() - lastSeen < ATTACHED_WINDOW_MS }
1976
+ requests: requests.map(({ id, title, intent, status }) => ({
1977
+ id,
1978
+ title,
1979
+ intent,
1980
+ status: snapshot.running && snapshot.requestId === id ? "running" : snapshot.failedIds.includes(id) ? "failed" : status
1981
+ })),
1982
+ agent: {
1983
+ attached: externallyAttached(),
1984
+ running: snapshot.running,
1985
+ name: snapshot.running ? snapshot.agent : null,
1986
+ activity: snapshot.running ? snapshot.activity : null,
1987
+ startedAt: snapshot.running ? snapshot.startedAt : null
1988
+ }
1243
1989
  }));
1244
1990
  }
1991
+ if (path === `${LEGLAS_PREFIX}/api/requests/cancel` && req.method === "POST") {
1992
+ if (!hasJsonBody(req)) {
1993
+ return sendJson(res, 200, { ok: true, cancelled: runner?.cancel() ?? false });
1994
+ }
1995
+ let body = "";
1996
+ req.on("data", (chunk) => body += chunk);
1997
+ return void req.on("end", () => {
1998
+ let parsed2;
1999
+ try {
2000
+ parsed2 = JSON.parse(body || "{}");
2001
+ } catch {
2002
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2003
+ }
2004
+ if (parsed2.id !== void 0 && typeof parsed2.id !== "string") {
2005
+ return sendJson(res, 400, { ok: false, error: "The request id must be a string." });
2006
+ }
2007
+ return sendJson(res, 200, { ok: true, cancelled: runner?.cancel(parsed2.id) ?? false });
2008
+ });
2009
+ }
2010
+ if (path === `${LEGLAS_PREFIX}/api/requests/retry` && req.method === "POST") {
2011
+ if (!hasJsonBody(req)) {
2012
+ return sendJson(res, 400, { ok: false, error: "Retry must be JSON." });
2013
+ }
2014
+ let body = "";
2015
+ req.on("data", (chunk) => body += chunk);
2016
+ return void req.on("end", async () => {
2017
+ let parsed2;
2018
+ try {
2019
+ parsed2 = JSON.parse(body || "{}");
2020
+ } catch {
2021
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2022
+ }
2023
+ if (typeof parsed2.id !== "string") {
2024
+ return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
2025
+ }
2026
+ const request = (await readRequests(cwd)).find((entry) => entry.id === parsed2.id);
2027
+ if (request === void 0) {
2028
+ return sendJson(res, 404, { ok: false, error: "No such request." });
2029
+ }
2030
+ if (!(runner?.snapshot().failedIds.includes(request.id) ?? false)) {
2031
+ return sendJson(res, 400, { ok: false, error: "Only a failed request can be retried." });
2032
+ }
2033
+ try {
2034
+ if (!await removeRequest(cwd, request.id)) {
2035
+ return sendJson(res, 404, { ok: false, error: "No such request." });
2036
+ }
2037
+ await appendRequest(cwd, {
2038
+ title: request.title,
2039
+ url: request.url,
2040
+ intent: request.intent,
2041
+ target: request.target,
2042
+ prompt: request.prompt
2043
+ });
2044
+ runner?.nudge();
2045
+ return sendJson(res, 200, { ok: true });
2046
+ } catch {
2047
+ return sendJson(res, 500, { ok: false, error: "The request could not be retried." });
2048
+ }
2049
+ });
2050
+ }
2051
+ if (path === `${LEGLAS_PREFIX}/api/requests/dismiss` && req.method === "POST") {
2052
+ if (!hasJsonBody(req)) {
2053
+ return sendJson(res, 400, { ok: false, error: "Dismiss must be JSON." });
2054
+ }
2055
+ let body = "";
2056
+ req.on("data", (chunk) => body += chunk);
2057
+ return void req.on("end", async () => {
2058
+ let parsed2;
2059
+ try {
2060
+ parsed2 = JSON.parse(body || "{}");
2061
+ } catch {
2062
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2063
+ }
2064
+ if (typeof parsed2.id !== "string") {
2065
+ return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
2066
+ }
2067
+ if (!(runner?.snapshot().failedIds.includes(parsed2.id) ?? false)) {
2068
+ return sendJson(res, 400, { ok: false, error: "Only a failed request can be dismissed." });
2069
+ }
2070
+ try {
2071
+ if (!await removeRequest(cwd, parsed2.id)) {
2072
+ return sendJson(res, 404, { ok: false, error: "No such request." });
2073
+ }
2074
+ return sendJson(res, 200, { ok: true });
2075
+ } catch {
2076
+ return sendJson(res, 500, { ok: false, error: "The request could not be dismissed." });
2077
+ }
2078
+ });
2079
+ }
1245
2080
  if (path === `${LEGLAS_PREFIX}/api/renames` && req.method === "POST") {
1246
2081
  let body = "";
1247
2082
  req.on("data", (chunk) => body += chunk);
@@ -1266,14 +2101,14 @@ async function startServer(options) {
1266
2101
  const rest = path.slice(FILES_PREFIX.length + 1);
1267
2102
  const slash = rest.indexOf("/");
1268
2103
  const slug = slash === -1 ? rest : rest.slice(0, slash);
1269
- let relative4 = slash === -1 ? "" : rest.slice(slash + 1);
2104
+ let relative5 = slash === -1 ? "" : rest.slice(slash + 1);
1270
2105
  try {
1271
- relative4 = decodeURIComponent(relative4);
2106
+ relative5 = decodeURIComponent(relative5);
1272
2107
  } catch {
1273
- relative4 = "";
2108
+ relative5 = "";
1274
2109
  }
1275
2110
  const dir = fileMounts.get(slug);
1276
- if (dir !== void 0 && relative4 !== "" && serveFrom(res, dir, relative4))
2111
+ if (dir !== void 0 && relative5 !== "" && serveFrom(res, dir, relative5))
1277
2112
  return;
1278
2113
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
1279
2114
  return res.end("Leglas: no such preview file.");
@@ -1305,16 +2140,23 @@ async function startServer(options) {
1305
2140
  proxy.upgrade(req, socket, head);
1306
2141
  });
1307
2142
  const port = await bind(server, options.port ?? DEFAULT_PORT);
2143
+ runner = startRunner({ cwd, externallyAttached });
2144
+ let closePromise = null;
1308
2145
  return {
1309
2146
  port,
1310
2147
  url: `http://localhost:${port}`,
1311
- close: () => new Promise((done) => {
1312
- for (const socket of sockets)
1313
- socket.destroy();
1314
- sockets.clear();
1315
- server.closeAllConnections();
1316
- server.close(() => done());
1317
- })
2148
+ close: () => {
2149
+ if (closePromise !== null)
2150
+ return closePromise;
2151
+ closePromise = runner.stop().then(() => new Promise((done) => {
2152
+ for (const socket of sockets)
2153
+ socket.destroy();
2154
+ sockets.clear();
2155
+ server.closeAllConnections();
2156
+ server.close(() => done());
2157
+ }));
2158
+ return closePromise;
2159
+ }
1318
2160
  };
1319
2161
  }
1320
2162
 
@@ -1323,7 +2165,7 @@ async function runClassify(options, deps) {
1323
2165
  const declared = await Promise.all(
1324
2166
  options.changes.map(async (change) => ({
1325
2167
  ...change,
1326
- exists: await stat(join7(options.cwd, change.path)).then(
2168
+ exists: await stat(join8(options.cwd, change.path)).then(
1327
2169
  () => true,
1328
2170
  () => false
1329
2171
  )
@@ -1588,8 +2430,8 @@ function runExplore(options, deps) {
1588
2430
  }
1589
2431
 
1590
2432
  // src/run-init.ts
1591
- import { readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1592
- import { join as join8 } from "path";
2433
+ import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
2434
+ import { join as join9 } from "path";
1593
2435
 
1594
2436
  // src/init.ts
1595
2437
  var AGENTS_MARKER_START = "<!-- leglas:start -->";
@@ -1722,7 +2564,7 @@ ${AGENTS_SECTION}`
1722
2564
  // src/run-init.ts
1723
2565
  async function readIfPresent(path) {
1724
2566
  try {
1725
- return await readFile5(path, "utf8");
2567
+ return await readFile6(path, "utf8");
1726
2568
  } catch {
1727
2569
  return null;
1728
2570
  }
@@ -1730,18 +2572,18 @@ async function readIfPresent(path) {
1730
2572
  async function runInit(options, deps) {
1731
2573
  const existingConfig = findConfigFile(options.cwd);
1732
2574
  const plan = planInit({
1733
- agents: await readIfPresent(join8(options.cwd, "AGENTS.md")),
2575
+ agents: await readIfPresent(join9(options.cwd, "AGENTS.md")),
1734
2576
  config: existingConfig === null ? null : "present",
1735
- gitignore: await readIfPresent(join8(options.cwd, ".gitignore")),
2577
+ gitignore: await readIfPresent(join9(options.cwd, ".gitignore")),
1736
2578
  force: options.force
1737
2579
  });
1738
2580
  const touched = [];
1739
2581
  for (const write of plan.writes) {
1740
- await writeFile4(join8(options.cwd, write.path), write.contents, "utf8");
2582
+ await writeFile5(join9(options.cwd, write.path), write.contents, "utf8");
1741
2583
  touched.push(write.path);
1742
2584
  }
1743
2585
  if (plan.gitignore !== null) {
1744
- await writeFile4(join8(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2586
+ await writeFile5(join9(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1745
2587
  touched.push(".gitignore");
1746
2588
  }
1747
2589
  if (options.json) {
@@ -1761,8 +2603,8 @@ async function runInit(options, deps) {
1761
2603
 
1762
2604
  // src/run-keep.ts
1763
2605
  import { existsSync as existsSync3 } from "fs";
1764
- import { mkdir as mkdir4, readFile as readFile6, rm as rm2, writeFile as writeFile5 } from "fs/promises";
1765
- import { dirname as dirname5, join as join9 } from "path";
2606
+ import { mkdir as mkdir5, readFile as readFile7, rm as rm2, writeFile as writeFile6 } from "fs/promises";
2607
+ import { dirname as dirname6, join as join10 } from "path";
1766
2608
 
1767
2609
  // src/keep.ts
1768
2610
  import { basename as basename2, extname as extname2, normalize as normalize2 } from "path";
@@ -1861,18 +2703,18 @@ async function runKeep(options, deps) {
1861
2703
  if (!resolved.ok) return fail(resolved.error);
1862
2704
  const plan = planKeep({ title: resolved.title, previews, to: options.to });
1863
2705
  if (!plan.ok) return fail(plan.error);
1864
- const from = join9(options.cwd, plan.move.from);
1865
- const to = join9(options.cwd, plan.move.to);
2706
+ const from = join10(options.cwd, plan.move.from);
2707
+ const to = join10(options.cwd, plan.move.to);
1866
2708
  if (!existsSync3(from)) {
1867
2709
  return fail(`${plan.move.from} does not exist. Nothing to keep.`);
1868
2710
  }
1869
2711
  if (existsSync3(to)) {
1870
2712
  return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
1871
2713
  }
1872
- const source = await readFile6(from, "utf8");
1873
- await mkdir4(dirname5(to), { recursive: true });
1874
- await writeFile5(to, renameExport(source, plan.exportName), "utf8");
1875
- await rm2(join9(options.cwd, plan.removeDir), { recursive: true, force: true });
2714
+ const source = await readFile7(from, "utf8");
2715
+ await mkdir5(dirname6(to), { recursive: true });
2716
+ await writeFile6(to, renameExport(source, plan.exportName), "utf8");
2717
+ await rm2(join10(options.cwd, plan.removeDir), { recursive: true, force: true });
1876
2718
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
1877
2719
  if (options.json) {
1878
2720
  deps.log(
@@ -1907,11 +2749,11 @@ async function runKeep(options, deps) {
1907
2749
 
1908
2750
  // src/run-new.ts
1909
2751
  import { existsSync as existsSync4 } from "fs";
1910
- import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
1911
- import { dirname as dirname6, join as join10 } from "path";
2752
+ import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
2753
+ import { dirname as dirname7, join as join11 } from "path";
1912
2754
  async function readIfPresent2(path) {
1913
2755
  try {
1914
- return await readFile7(path, "utf8");
2756
+ return await readFile8(path, "utf8");
1915
2757
  } catch {
1916
2758
  return null;
1917
2759
  }
@@ -1919,7 +2761,7 @@ async function readIfPresent2(path) {
1919
2761
  async function runNew(options, deps) {
1920
2762
  let from;
1921
2763
  if (options.from !== void 0) {
1922
- const contents = await readIfPresent2(join10(options.cwd, options.from));
2764
+ const contents = await readIfPresent2(join11(options.cwd, options.from));
1923
2765
  if (contents === null) {
1924
2766
  const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
1925
2767
  if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
@@ -1930,8 +2772,8 @@ async function runNew(options, deps) {
1930
2772
  }
1931
2773
  const plan = planNew({
1932
2774
  surface: options.surface,
1933
- packageJson: await readIfPresent2(join10(options.cwd, "package.json")),
1934
- gitignore: await readIfPresent2(join10(options.cwd, ".gitignore")),
2775
+ packageJson: await readIfPresent2(join11(options.cwd, "package.json")),
2776
+ gitignore: await readIfPresent2(join11(options.cwd, ".gitignore")),
1935
2777
  from
1936
2778
  });
1937
2779
  const fail = (error) => {
@@ -1954,19 +2796,19 @@ async function runNew(options, deps) {
1954
2796
  deps.log(plan.instructions);
1955
2797
  return { exitCode: 0, written: [] };
1956
2798
  }
1957
- const existing = plan.writes.filter((write) => existsSync4(join10(options.cwd, write.path)));
2799
+ const existing = plan.writes.filter((write) => existsSync4(join11(options.cwd, write.path)));
1958
2800
  if (existing.length > 0) {
1959
2801
  return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
1960
2802
  }
1961
2803
  const written = [];
1962
2804
  for (const write of plan.writes) {
1963
- const target = join10(options.cwd, write.path);
1964
- await mkdir5(dirname6(target), { recursive: true });
1965
- await writeFile6(target, write.contents, "utf8");
2805
+ const target = join11(options.cwd, write.path);
2806
+ await mkdir6(dirname7(target), { recursive: true });
2807
+ await writeFile7(target, write.contents, "utf8");
1966
2808
  written.push(write.path);
1967
2809
  }
1968
2810
  if (plan.gitignore !== null) {
1969
- await writeFile6(join10(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2811
+ await writeFile7(join11(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1970
2812
  written.push(".gitignore");
1971
2813
  }
1972
2814
  if (options.json) {
@@ -1987,21 +2829,21 @@ async function runNew(options, deps) {
1987
2829
  }
1988
2830
 
1989
2831
  // src/run-previews.ts
1990
- import { readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
1991
- import { join as join11 } from "path";
2832
+ import { readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
2833
+ import { join as join12 } from "path";
1992
2834
  function envelope(deps, ok, body) {
1993
2835
  deps.log(JSON.stringify({ ok, ...body }));
1994
2836
  }
1995
2837
  async function ensureIgnored(cwd) {
1996
- const path = join11(cwd, ".gitignore");
2838
+ const path = join12(cwd, ".gitignore");
1997
2839
  let current = null;
1998
2840
  try {
1999
- current = await readFile8(path, "utf8");
2841
+ current = await readFile9(path, "utf8");
2000
2842
  } catch {
2001
2843
  current = null;
2002
2844
  }
2003
2845
  const next = ignoreEntry(current);
2004
- if (next !== null) await writeFile7(path, next, "utf8");
2846
+ if (next !== null) await writeFile8(path, next, "utf8");
2005
2847
  }
2006
2848
  async function runAdd(options, deps) {
2007
2849
  const loaded = await loadConfig(options.cwd);
@@ -2246,100 +3088,24 @@ async function runShow(options, deps) {
2246
3088
  }
2247
3089
 
2248
3090
  // src/run-watch.ts
2249
- import { spawn as spawn2 } from "child_process";
2250
- import { mkdir as mkdir6, readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
2251
- import { dirname as dirname7, join as join12 } from "path";
2252
-
2253
- // src/watch.ts
2254
- var WATCH_PATH = ".leglas/watch.json";
2255
- var PROMPT_TOKEN = "{prompt}";
2256
- var EXAMPLE = `npx leglas watch --run "claude -p ${PROMPT_TOKEN}"`;
2257
- function tokenize(template) {
2258
- const tokens = [];
2259
- let current = "";
2260
- let started = false;
2261
- let quote = null;
2262
- for (const character of template) {
2263
- if (quote !== null) {
2264
- if (character === quote) quote = null;
2265
- else current += character;
2266
- continue;
2267
- }
2268
- if (character === '"' || character === "'") {
2269
- quote = character;
2270
- started = true;
2271
- continue;
2272
- }
2273
- if (/\s/.test(character)) {
2274
- if (started) tokens.push(current);
2275
- current = "";
2276
- started = false;
2277
- continue;
2278
- }
2279
- current += character;
2280
- started = true;
2281
- }
2282
- if (quote !== null) {
2283
- return { ok: false, error: `The agent command has an unclosed ${quote} quote.` };
2284
- }
2285
- if (started) tokens.push(current);
2286
- return { ok: true, tokens };
2287
- }
2288
- function parseTemplate(raw) {
2289
- const tokenized = tokenize(raw);
2290
- if (!tokenized.ok) return tokenized;
2291
- const { tokens } = tokenized;
2292
- const [command, ...args] = tokens;
2293
- if (command === void 0) {
2294
- return { ok: false, error: `Watch needs an agent command, for example: ${EXAMPLE}` };
2295
- }
2296
- const placeholders = tokens.filter((token) => token === PROMPT_TOKEN).length;
2297
- if (placeholders === 0) {
2298
- return {
2299
- ok: false,
2300
- error: `The agent command needs ${PROMPT_TOKEN} as a word of its own, for example: ${EXAMPLE}`
2301
- };
2302
- }
2303
- if (placeholders > 1) {
2304
- return {
2305
- ok: false,
2306
- error: `The agent command takes ${PROMPT_TOKEN} once, for example: ${EXAMPLE}`
2307
- };
2308
- }
2309
- if (command === PROMPT_TOKEN) {
2310
- return {
2311
- ok: false,
2312
- error: `The agent command must name a program before ${PROMPT_TOKEN}, for example: ${EXAMPLE}`
2313
- };
2314
- }
2315
- return { ok: true, template: { command, args } };
2316
- }
2317
- function commandFor(template, prompt) {
2318
- return {
2319
- command: template.command,
2320
- args: template.args.map((argument) => argument === PROMPT_TOKEN ? prompt : argument)
2321
- };
2322
- }
2323
- function nextRequest(requests, failed) {
2324
- return requests.find((request) => request.status === "queued" && !failed.has(request.id)) ?? null;
2325
- }
2326
-
2327
- // src/run-watch.ts
2328
- var POLL_MS = 2e3;
3091
+ import { spawn as spawn3 } from "child_process";
3092
+ import { mkdir as mkdir7, readFile as readFile10, writeFile as writeFile9 } from "fs/promises";
3093
+ import { dirname as dirname8, join as join13 } from "path";
3094
+ var POLL_MS2 = 2e3;
2329
3095
  var HEARTBEAT_TIMEOUT_MS = 1e3;
2330
- async function readSavedTemplate(cwd) {
3096
+ async function saveTemplate(cwd, run3) {
3097
+ const path = join13(cwd, WATCH_PATH);
3098
+ let config = {};
2331
3099
  try {
2332
- const raw = await readFile9(join12(cwd, WATCH_PATH), "utf8");
2333
- const parsed2 = JSON.parse(raw);
2334
- return typeof parsed2.run === "string" && parsed2.run !== "" ? parsed2.run : null;
3100
+ const parsed2 = JSON.parse(await readFile10(path, "utf8"));
3101
+ if (typeof parsed2 === "object" && parsed2 !== null && !Array.isArray(parsed2)) {
3102
+ config = parsed2;
3103
+ }
2335
3104
  } catch {
2336
- return null;
2337
3105
  }
2338
- }
2339
- async function saveTemplate(cwd, run3) {
2340
- const path = join12(cwd, WATCH_PATH);
2341
- await mkdir6(dirname7(path), { recursive: true });
2342
- await writeFile8(path, `${JSON.stringify({ run: run3 }, null, 2)}
3106
+ config.run = run3;
3107
+ await mkdir7(dirname8(path), { recursive: true });
3108
+ await writeFile9(path, `${JSON.stringify(config, null, 2)}
2343
3109
  `, "utf8");
2344
3110
  }
2345
3111
  function spawnAgent(command, args, cwd) {
@@ -2350,7 +3116,7 @@ function spawnAgent(command, args, cwd) {
2350
3116
  settled = true;
2351
3117
  resolve(outcome);
2352
3118
  };
2353
- const child = spawn2(command, args, { cwd, stdio: "inherit" });
3119
+ const child = spawn3(command, args, { cwd, stdio: "inherit" });
2354
3120
  child.on("error", (error) => settle({ ok: false, error: error.message }));
2355
3121
  child.on(
2356
3122
  "close",
@@ -2361,21 +3127,34 @@ function spawnAgent(command, args, cwd) {
2361
3127
  });
2362
3128
  }
2363
3129
  async function runWatch(options, deps) {
2364
- const saved = options.run === void 0 ? await readSavedTemplate(options.cwd) : null;
2365
- const raw = options.run ?? saved;
2366
- if (raw === null) {
3130
+ const saved = options.run === void 0 ? await readAgentChoice(options.cwd) : { agent: null, run: null };
3131
+ const raw = options.run ?? saved.run;
3132
+ let template;
3133
+ let shownCommand2;
3134
+ let synthesizedAgent = null;
3135
+ if (raw !== null) {
3136
+ const parsed2 = parseTemplate(raw);
3137
+ if (!parsed2.ok) {
3138
+ deps.error(parsed2.error);
3139
+ return { exitCode: 1 };
3140
+ }
3141
+ template = parsed2.template;
3142
+ shownCommand2 = raw;
3143
+ } else if (saved.agent !== null && saved.agent !== "custom") {
3144
+ const adapter = KNOWN_AGENTS[saved.agent];
3145
+ template = {
3146
+ command: adapter.binary,
3147
+ args: adapter.terminalArgs(PROMPT_TOKEN)
3148
+ };
3149
+ shownCommand2 = [template.command, ...template.args].join(" ");
3150
+ synthesizedAgent = adapter.name;
3151
+ } else {
2367
3152
  deps.error(
2368
- 'Watch needs an agent command the first time: npx leglas watch --run "claude -p {prompt}"'
3153
+ 'Watch needs an agent command the first time: pick an agent in the interface, or pass --run "claude -p {prompt}".'
2369
3154
  );
2370
3155
  return { exitCode: 1 };
2371
3156
  }
2372
- const parsed2 = parseTemplate(raw);
2373
- if (!parsed2.ok) {
2374
- deps.error(parsed2.error);
2375
- return { exitCode: 1 };
2376
- }
2377
- const template = parsed2.template;
2378
- if (options.run !== void 0) await saveTemplate(options.cwd, raw).catch(() => {
3157
+ if (options.run !== void 0) await saveTemplate(options.cwd, options.run).catch(() => {
2379
3158
  });
2380
3159
  const base = `http://localhost:${options.port ?? DEFAULT_PORT}`;
2381
3160
  const heartbeat = async (watching) => {
@@ -2389,11 +3168,15 @@ async function runWatch(options, deps) {
2389
3168
  } catch {
2390
3169
  }
2391
3170
  };
2392
- deps.log(`Watching for change requests. Each one runs: ${raw}`);
3171
+ if (synthesizedAgent !== null) {
3172
+ deps.log(`Using ${synthesizedAgent}, chosen in the interface.`);
3173
+ }
3174
+ deps.log(`Watching for change requests. Each one runs: ${shownCommand2}`);
2393
3175
  deps.log("Stop with Ctrl-C.");
2394
3176
  const failed = /* @__PURE__ */ new Set();
2395
3177
  let stopped = false;
2396
3178
  let busy = false;
3179
+ let announced = false;
2397
3180
  let inflight = null;
2398
3181
  const handle = async (request) => {
2399
3182
  deps.log("");
@@ -2415,7 +3198,12 @@ async function runWatch(options, deps) {
2415
3198
  };
2416
3199
  const tick = async () => {
2417
3200
  if (stopped) return;
2418
- void heartbeat(true);
3201
+ if (announced) {
3202
+ void heartbeat(true);
3203
+ } else {
3204
+ await heartbeat(true);
3205
+ announced = true;
3206
+ }
2419
3207
  if (busy) return;
2420
3208
  busy = true;
2421
3209
  try {
@@ -2432,7 +3220,7 @@ async function runWatch(options, deps) {
2432
3220
  }
2433
3221
  };
2434
3222
  return new Promise((resolve) => {
2435
- const timer = setInterval(() => void tick(), POLL_MS);
3223
+ const timer = setInterval(() => void tick(), POLL_MS2);
2436
3224
  const stop = () => {
2437
3225
  if (stopped) return;
2438
3226
  stopped = true;
@@ -2452,14 +3240,14 @@ async function runWatch(options, deps) {
2452
3240
  // src/run.ts
2453
3241
  import { existsSync as existsSync5 } from "fs";
2454
3242
  import { createRequire } from "module";
2455
- import { basename as basename3, dirname as dirname8, join as join13, relative as relative3 } from "path";
3243
+ import { basename as basename3, dirname as dirname9, join as join14, relative as relative4 } from "path";
2456
3244
  import { fileURLToPath } from "url";
2457
3245
  function findShellDir() {
2458
- const bundled = join13(dirname8(fileURLToPath(import.meta.url)), "shell");
2459
- if (existsSync5(join13(bundled, "index.html"))) return bundled;
3246
+ const bundled = join14(dirname9(fileURLToPath(import.meta.url)), "shell");
3247
+ if (existsSync5(join14(bundled, "index.html"))) return bundled;
2460
3248
  try {
2461
3249
  const require2 = createRequire(import.meta.url);
2462
- return dirname8(require2.resolve("@leglas/shell/dist/index.html"));
3250
+ return dirname9(require2.resolve("@leglas/shell/dist/index.html"));
2463
3251
  } catch {
2464
3252
  return null;
2465
3253
  }
@@ -2493,7 +3281,7 @@ async function run2(options, deps) {
2493
3281
  const fileMounts = /* @__PURE__ */ new Map();
2494
3282
  for (const preview of merged?.previews ?? []) {
2495
3283
  if (preview.file !== void 0) {
2496
- const absolute = join13(options.cwd, preview.file);
3284
+ const absolute = join14(options.cwd, preview.file);
2497
3285
  if (!existsSync5(absolute)) {
2498
3286
  worktreeErrors.push(
2499
3287
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -2504,7 +3292,7 @@ async function run2(options, deps) {
2504
3292
  for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
2505
3293
  slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
2506
3294
  }
2507
- fileMounts.set(slug, dirname8(absolute));
3295
+ fileMounts.set(slug, dirname9(absolute));
2508
3296
  previews.push({
2509
3297
  ...preview,
2510
3298
  url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
@@ -2565,7 +3353,7 @@ async function run2(options, deps) {
2565
3353
  })
2566
3354
  );
2567
3355
  } else {
2568
- const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative3(options.cwd, loaded.path) || loaded.path;
3356
+ const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative4(options.cwd, loaded.path) || loaded.path;
2569
3357
  deps.log(`Leglas ${url}`);
2570
3358
  deps.log(
2571
3359
  `app ${devServer}${app !== null ? " (started by Leglas)" : health.reachable ? "" : " (not reachable)"}`
@@ -2659,7 +3447,7 @@ function version() {
2659
3447
  async function openBrowser(url) {
2660
3448
  const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2661
3449
  try {
2662
- spawn3(command, [url], { detached: true, stdio: "ignore" }).unref();
3450
+ spawn4(command, [url], { detached: true, stdio: "ignore" }).unref();
2663
3451
  } catch {
2664
3452
  }
2665
3453
  }