leglas 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -845,6 +845,360 @@ function normalizeConfig(raw, options = {}) {
845
845
  };
846
846
  }
847
847
 
848
+ // ../server/dist/agent-command.js
849
+ var WATCH_PATH = ".leglas/watch.json";
850
+ var PROMPT_TOKEN = "{prompt}";
851
+ var EXAMPLE = `npx leglas watch --run "claude -p ${PROMPT_TOKEN}"`;
852
+ function tokenize(template) {
853
+ const tokens = [];
854
+ let current = "";
855
+ let started = false;
856
+ let quote = null;
857
+ for (const character of template) {
858
+ if (quote !== null) {
859
+ if (character === quote)
860
+ quote = null;
861
+ else
862
+ current += character;
863
+ continue;
864
+ }
865
+ if (character === '"' || character === "'") {
866
+ quote = character;
867
+ started = true;
868
+ continue;
869
+ }
870
+ if (/\s/.test(character)) {
871
+ if (started)
872
+ tokens.push(current);
873
+ current = "";
874
+ started = false;
875
+ continue;
876
+ }
877
+ current += character;
878
+ started = true;
879
+ }
880
+ if (quote !== null) {
881
+ return { ok: false, error: `The agent command has an unclosed ${quote} quote.` };
882
+ }
883
+ if (started)
884
+ tokens.push(current);
885
+ return { ok: true, tokens };
886
+ }
887
+ function parseTemplate(raw) {
888
+ const tokenized = tokenize(raw);
889
+ if (!tokenized.ok)
890
+ return tokenized;
891
+ const { tokens } = tokenized;
892
+ const [command, ...args] = tokens;
893
+ if (command === void 0) {
894
+ return { ok: false, error: `Watch needs an agent command, for example: ${EXAMPLE}` };
895
+ }
896
+ if (tokens.some((token) => token !== PROMPT_TOKEN && token.includes(PROMPT_TOKEN))) {
897
+ return {
898
+ ok: false,
899
+ error: `${PROMPT_TOKEN} must stand as a word of its own, for example: ${EXAMPLE}`
900
+ };
901
+ }
902
+ const placeholders = tokens.filter((token) => token === PROMPT_TOKEN).length;
903
+ if (placeholders > 1) {
904
+ return {
905
+ ok: false,
906
+ error: `The agent command takes ${PROMPT_TOKEN} once, for example: ${EXAMPLE}`
907
+ };
908
+ }
909
+ if (command === PROMPT_TOKEN) {
910
+ return {
911
+ ok: false,
912
+ error: `The agent command must name a program before ${PROMPT_TOKEN}, for example: ${EXAMPLE}`
913
+ };
914
+ }
915
+ return { ok: true, template: { command, args } };
916
+ }
917
+ function commandFor(template, prompt) {
918
+ if (!template.args.includes(PROMPT_TOKEN)) {
919
+ return { command: template.command, args: [...template.args, prompt] };
920
+ }
921
+ return {
922
+ command: template.command,
923
+ args: template.args.map((argument) => argument === PROMPT_TOKEN ? prompt : argument)
924
+ };
925
+ }
926
+ function nextRequest(requests, failed) {
927
+ return requests.find((request) => request.status === "queued" && !failed.has(request.id)) ?? null;
928
+ }
929
+
930
+ // ../server/dist/agents.js
931
+ import { spawn } from "child_process";
932
+ import { constants } from "fs";
933
+ import { access, mkdir, readFile, writeFile } from "fs/promises";
934
+ import { delimiter, dirname, isAbsolute, join, relative } from "path";
935
+ var KNOWN_AGENTS = {
936
+ claude: {
937
+ name: "Claude",
938
+ binary: "claude",
939
+ args: (prompt) => [
940
+ "-p",
941
+ prompt,
942
+ "--output-format",
943
+ "stream-json",
944
+ "--verbose",
945
+ "--permission-mode",
946
+ "acceptEdits"
947
+ ],
948
+ terminalArgs: (prompt) => [
949
+ "-p",
950
+ prompt,
951
+ "--permission-mode",
952
+ "acceptEdits"
953
+ ],
954
+ resumeArgs: (sessionId, prompt) => [
955
+ "-p",
956
+ "--resume",
957
+ sessionId,
958
+ prompt,
959
+ "--output-format",
960
+ "stream-json",
961
+ "--verbose",
962
+ "--permission-mode",
963
+ "acceptEdits"
964
+ ],
965
+ // Every stream-json event names its session.
966
+ sessionFrom: (event) => typeof event.session_id === "string" && event.session_id !== "" ? event.session_id : null,
967
+ authArgs: ["auth", "status"],
968
+ // `claude auth status` prints JSON with a loggedIn boolean. Only that
969
+ // field decides; any other shape stays unknown.
970
+ authVerdict: (result) => {
971
+ try {
972
+ const parsed = record(JSON.parse(result.stdout));
973
+ if (parsed?.loggedIn === true)
974
+ return "ok";
975
+ if (parsed?.loggedIn === false)
976
+ return "signed-out";
977
+ } catch {
978
+ }
979
+ return "unknown";
980
+ }
981
+ },
982
+ codex: {
983
+ name: "Codex",
984
+ binary: "codex",
985
+ args: (prompt) => ["exec", "--json", "-s", "workspace-write", prompt],
986
+ terminalArgs: (prompt) => ["exec", "-s", "workspace-write", prompt],
987
+ // No sandbox flag here: `codex exec resume` refuses it and inherits the
988
+ // session's own sandbox, which the first turn set to workspace-write.
989
+ resumeArgs: (sessionId, prompt) => [
990
+ "exec",
991
+ "resume",
992
+ sessionId,
993
+ "--json",
994
+ prompt
995
+ ],
996
+ sessionFrom: (event) => event.type === "thread.started" && typeof event.thread_id === "string" ? event.thread_id : null,
997
+ authArgs: ["login", "status"],
998
+ // `codex login status` exits 0 when logged in and nonzero when not.
999
+ authVerdict: (result) => result.code === 0 ? "ok" : "signed-out"
1000
+ },
1001
+ cursor: {
1002
+ name: "Cursor",
1003
+ binary: "cursor-agent",
1004
+ args: (prompt) => ["-p", prompt, "--output-format", "stream-json"],
1005
+ terminalArgs: (prompt) => ["-p", prompt],
1006
+ authArgs: ["status"],
1007
+ // UNVERIFIED: cursor-agent was not available on the build machine. The
1008
+ // reading is deliberately loose, and anything ambiguous stays unknown.
1009
+ authVerdict: (result) => {
1010
+ if (/logged in|signed in/i.test(result.stdout))
1011
+ return "ok";
1012
+ if (result.code !== 0 || /not logged in|log in|sign in/i.test(result.stdout))
1013
+ return "signed-out";
1014
+ return "unknown";
1015
+ }
1016
+ }
1017
+ };
1018
+ var PROBE_TIMEOUT_MS = 3e3;
1019
+ function execProbe(binary, args) {
1020
+ return new Promise((resolve) => {
1021
+ let child;
1022
+ try {
1023
+ child = spawn(binary, [...args], { shell: false, stdio: ["ignore", "pipe", "ignore"] });
1024
+ } catch {
1025
+ return resolve(null);
1026
+ }
1027
+ let stdout = "";
1028
+ child.stdout?.on("data", (chunk) => {
1029
+ if (stdout.length < 4096)
1030
+ stdout += chunk.toString();
1031
+ });
1032
+ const deadline = setTimeout(() => child.kill("SIGKILL"), PROBE_TIMEOUT_MS);
1033
+ child.once("error", () => {
1034
+ clearTimeout(deadline);
1035
+ resolve(null);
1036
+ });
1037
+ child.once("close", (code, signal) => {
1038
+ clearTimeout(deadline);
1039
+ resolve(signal !== null ? null : { code: code ?? 0, stdout });
1040
+ });
1041
+ });
1042
+ }
1043
+ async function pathLookup(binary) {
1044
+ const entries = (process.env.PATH ?? "").split(delimiter).filter((entry) => entry !== "");
1045
+ const extensions = process.platform === "win32" ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter((entry) => entry !== "") : [""];
1046
+ for (const entry of entries) {
1047
+ for (const extension of extensions) {
1048
+ try {
1049
+ await access(join(entry, `${binary}${extension}`), constants.X_OK);
1050
+ return true;
1051
+ } catch {
1052
+ }
1053
+ }
1054
+ }
1055
+ return false;
1056
+ }
1057
+ async function detectAgents(lookup = pathLookup, probe2 = execProbe) {
1058
+ const entries = Object.entries(KNOWN_AGENTS);
1059
+ return Promise.all(entries.map(async ([id, adapter]) => {
1060
+ const available = await lookup(adapter.binary).catch(() => false);
1061
+ if (!available)
1062
+ return { id, name: adapter.name, available, auth: "unknown" };
1063
+ const result = await probe2(adapter.binary, adapter.authArgs).catch(() => null);
1064
+ return {
1065
+ id,
1066
+ name: adapter.name,
1067
+ available,
1068
+ auth: result === null ? "unknown" : adapter.authVerdict(result)
1069
+ };
1070
+ }));
1071
+ }
1072
+ function record(value) {
1073
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
1074
+ }
1075
+ function shownPath(value, cwd) {
1076
+ if (typeof value !== "string" || value === "")
1077
+ return null;
1078
+ if (!isAbsolute(value))
1079
+ return value;
1080
+ return relative(cwd, value) || ".";
1081
+ }
1082
+ function shownCommand(value) {
1083
+ let command = Array.isArray(value) ? value.filter((part) => typeof part === "string").join(" ") : typeof value === "string" ? value : "";
1084
+ command = command.trim();
1085
+ const wrapped = /^(?:\S*\/)?(?:bash|sh|zsh)\s+-l?c\s+([\s\S]*)$/.exec(command);
1086
+ if (wrapped?.[1] !== void 0) {
1087
+ command = wrapped[1].trim();
1088
+ const quote = command[0];
1089
+ if ((quote === "'" || quote === '"') && command.endsWith(quote) && command.length > 1) {
1090
+ command = command.slice(1, -1);
1091
+ }
1092
+ }
1093
+ command = (command.split("\n")[0] ?? "").replace(/\s+/g, " ").trim();
1094
+ if (command === "")
1095
+ return null;
1096
+ return command.length > 48 ? `${command.slice(0, 47)}\u2026` : command;
1097
+ }
1098
+ function claudeActivity(event, cwd) {
1099
+ if (event.type !== "assistant")
1100
+ return null;
1101
+ const message = record(event.message);
1102
+ if (message === null || !Array.isArray(message.content))
1103
+ return null;
1104
+ for (const rawBlock of message.content) {
1105
+ const block = record(rawBlock);
1106
+ if (block?.type !== "tool_use" || typeof block.name !== "string")
1107
+ continue;
1108
+ const input = record(block.input);
1109
+ if (["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(block.name)) {
1110
+ const path = shownPath(input?.file_path ?? input?.notebook_path, cwd);
1111
+ return path === null ? `using ${block.name}` : `editing ${path}`;
1112
+ }
1113
+ if (block.name === "Read") {
1114
+ const path = shownPath(input?.file_path ?? input?.path, cwd);
1115
+ return path === null ? "using Read" : `reading ${path}`;
1116
+ }
1117
+ if (block.name === "Bash") {
1118
+ const command = shownCommand(input?.command);
1119
+ return command === null ? "running a command" : `running ${command}`;
1120
+ }
1121
+ if (block.name === "Grep" || block.name === "Glob")
1122
+ return "searching the project";
1123
+ return `using ${block.name}`;
1124
+ }
1125
+ return null;
1126
+ }
1127
+ function codexActivity(event, cwd) {
1128
+ if (event.type !== "item.started" && event.type !== "item.completed")
1129
+ return null;
1130
+ const item = record(event.item);
1131
+ if (item === null)
1132
+ return null;
1133
+ if (item.type === "command_execution") {
1134
+ const command = shownCommand(item.command);
1135
+ return command === null ? "running a command" : `running ${command}`;
1136
+ }
1137
+ if (item.type !== "file_change")
1138
+ return null;
1139
+ const first = Array.isArray(item.changes) ? record(item.changes[0]) : null;
1140
+ const path = shownPath(first?.path ?? item.path, cwd);
1141
+ return path === null ? null : `editing ${path}`;
1142
+ }
1143
+ function activityFrom(agent, line, cwd = process.cwd()) {
1144
+ let event;
1145
+ try {
1146
+ event = record(JSON.parse(line));
1147
+ } catch {
1148
+ return null;
1149
+ }
1150
+ if (event === null)
1151
+ return null;
1152
+ if (agent === "claude")
1153
+ return claudeActivity(event, cwd);
1154
+ if (agent === "codex")
1155
+ return codexActivity(event, cwd);
1156
+ if (agent === "cursor")
1157
+ return claudeActivity(event, cwd);
1158
+ return null;
1159
+ }
1160
+ function sessionFrom(agent, line) {
1161
+ if (agent !== "claude" && agent !== "codex")
1162
+ return null;
1163
+ let event;
1164
+ try {
1165
+ event = record(JSON.parse(line));
1166
+ } catch {
1167
+ return null;
1168
+ }
1169
+ if (event === null)
1170
+ return null;
1171
+ return KNOWN_AGENTS[agent].sessionFrom(event);
1172
+ }
1173
+ function isAgentChoice(value) {
1174
+ return value === "custom" || typeof value === "string" && Object.hasOwn(KNOWN_AGENTS, value);
1175
+ }
1176
+ async function readWatchConfig(cwd) {
1177
+ try {
1178
+ const parsed = JSON.parse(await readFile(join(cwd, WATCH_PATH), "utf8"));
1179
+ return record(parsed) ?? {};
1180
+ } catch {
1181
+ return {};
1182
+ }
1183
+ }
1184
+ async function readAgentChoice(cwd) {
1185
+ const config = await readWatchConfig(cwd);
1186
+ return {
1187
+ agent: isAgentChoice(config.agent) ? config.agent : null,
1188
+ run: typeof config.run === "string" && config.run !== "" ? config.run : null
1189
+ };
1190
+ }
1191
+ async function saveAgentChoice(cwd, choice) {
1192
+ const config = await readWatchConfig(cwd);
1193
+ config.agent = choice.agent;
1194
+ if (choice.run !== void 0)
1195
+ config.run = choice.run;
1196
+ const path = join(cwd, WATCH_PATH);
1197
+ await mkdir(dirname(path), { recursive: true });
1198
+ await writeFile(path, `${JSON.stringify(config, null, 2)}
1199
+ `, "utf8");
1200
+ }
1201
+
848
1202
  // ../server/dist/classify.js
849
1203
  var MANIFESTS = /* @__PURE__ */ new Set([
850
1204
  "package.json",
@@ -912,7 +1266,7 @@ function classifyDirection(input) {
912
1266
 
913
1267
  // ../server/dist/find-config.js
914
1268
  import { existsSync } from "fs";
915
- import { dirname, join, parse } from "path";
1269
+ import { dirname as dirname2, join as join2, parse } from "path";
916
1270
  var CONFIG_BASENAMES = [
917
1271
  "leglas.config.ts",
918
1272
  "leglas.config.mjs",
@@ -924,13 +1278,13 @@ function findConfigFile(startDir) {
924
1278
  let dir = startDir;
925
1279
  for (; ; ) {
926
1280
  for (const basename4 of CONFIG_BASENAMES) {
927
- const candidate = join(dir, basename4);
1281
+ const candidate = join2(dir, basename4);
928
1282
  if (existsSync(candidate))
929
1283
  return candidate;
930
1284
  }
931
1285
  if (dir === root)
932
1286
  return null;
933
- const parent = dirname(dir);
1287
+ const parent = dirname2(dir);
934
1288
  if (parent === dir)
935
1289
  return null;
936
1290
  dir = parent;
@@ -938,19 +1292,19 @@ function findConfigFile(startDir) {
938
1292
  }
939
1293
 
940
1294
  // ../server/dist/load-config.js
941
- import { readFile } from "fs/promises";
942
- import { relative } from "path";
1295
+ import { readFile as readFile2 } from "fs/promises";
1296
+ import { relative as relative2 } from "path";
943
1297
  import { pathToFileURL } from "url";
944
1298
  async function loadConfig(cwd) {
945
1299
  const path = findConfigFile(cwd);
946
1300
  if (path === null) {
947
1301
  return { ...normalizeConfig(void 0), path: null };
948
1302
  }
949
- const label = relative(cwd, path) || path;
1303
+ const label = relative2(cwd, path) || path;
950
1304
  let exported;
951
1305
  try {
952
1306
  if (path.endsWith(".json")) {
953
- exported = JSON.parse(await readFile(path, "utf8"));
1307
+ exported = JSON.parse(await readFile2(path, "utf8"));
954
1308
  } else {
955
1309
  const module = await import(pathToFileURL(path).href);
956
1310
  if (!("default" in module)) {
@@ -971,16 +1325,25 @@ async function loadConfig(cwd) {
971
1325
  }
972
1326
 
973
1327
  // ../server/dist/local-previews.js
974
- import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
975
- import { dirname as dirname2, join as join2 } from "path";
1328
+ import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1329
+ import { dirname as dirname3, join as join3 } from "path";
976
1330
  var LOCAL_PREVIEWS_PATH = ".leglas/previews.json";
977
1331
  async function readLocalPreviews(cwd) {
978
- const path = join2(cwd, LOCAL_PREVIEWS_PATH);
1332
+ const path = join3(cwd, LOCAL_PREVIEWS_PATH);
979
1333
  let raw;
980
1334
  try {
981
- raw = await readFile2(path, "utf8");
982
- } catch {
983
- return { previews: [], errors: [] };
1335
+ raw = await readFile3(path, "utf8");
1336
+ } catch (error) {
1337
+ const code = error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : null;
1338
+ if (code === "ENOENT") {
1339
+ return { previews: [], errors: [] };
1340
+ }
1341
+ return {
1342
+ previews: [],
1343
+ errors: [
1344
+ `${LOCAL_PREVIEWS_PATH} could not be read (${code ?? "unknown error"}). Check its permissions and file type; nothing shared is lost.`
1345
+ ]
1346
+ };
984
1347
  }
985
1348
  let parsed;
986
1349
  try {
@@ -1027,9 +1390,9 @@ async function addLocalPreview(cwd, input, shared) {
1027
1390
  if (check.config === null) {
1028
1391
  return { ok: false, error: check.errors.join(" ") };
1029
1392
  }
1030
- const path = join2(cwd, LOCAL_PREVIEWS_PATH);
1031
- await mkdir(dirname2(path), { recursive: true });
1032
- await writeFile(path, `${JSON.stringify({ previews: [...existing.previews.map(toStored), candidate] }, null, 2)}
1393
+ const path = join3(cwd, LOCAL_PREVIEWS_PATH);
1394
+ await mkdir2(dirname3(path), { recursive: true });
1395
+ await writeFile2(path, `${JSON.stringify({ previews: [...existing.previews.map(toStored), candidate] }, null, 2)}
1033
1396
  `, "utf8");
1034
1397
  return { ok: true };
1035
1398
  }
@@ -1042,9 +1405,9 @@ async function dropLocalPreviews(cwd, titles) {
1042
1405
  const keep = existing.previews.filter((preview) => !titles.includes(preview.title));
1043
1406
  if (keep.length === existing.previews.length)
1044
1407
  return 0;
1045
- const path = join2(cwd, LOCAL_PREVIEWS_PATH);
1046
- await mkdir(dirname2(path), { recursive: true });
1047
- await writeFile(path, `${JSON.stringify({ previews: keep.map(toStored) }, null, 2)}
1408
+ const path = join3(cwd, LOCAL_PREVIEWS_PATH);
1409
+ await mkdir2(dirname3(path), { recursive: true });
1410
+ await writeFile2(path, `${JSON.stringify({ previews: keep.map(toStored) }, null, 2)}
1048
1411
  `, "utf8");
1049
1412
  return existing.previews.length - keep.length;
1050
1413
  }
@@ -1113,10 +1476,10 @@ ${headers}\r
1113
1476
  }
1114
1477
 
1115
1478
  // ../server/dist/worktree.js
1116
- import { execFile, spawn } from "child_process";
1479
+ import { execFile, spawn as spawn2 } from "child_process";
1117
1480
  import { rm } from "fs/promises";
1118
1481
  import net2 from "net";
1119
- import { join as join3 } from "path";
1482
+ import { join as join4 } from "path";
1120
1483
  import { promisify } from "util";
1121
1484
  var run = promisify(execFile);
1122
1485
  var WORKTREES_DIR = ".leglas/worktrees";
@@ -1153,7 +1516,7 @@ function answers(port) {
1153
1516
  }
1154
1517
  async function startWorktree(options) {
1155
1518
  const readyTimeoutMs = options.readyTimeoutMs ?? 9e4;
1156
- const path = join3(options.cwd, WORKTREES_DIR, worktreeSlug(options.branch));
1519
+ const path = join4(options.cwd, WORKTREES_DIR, worktreeSlug(options.branch));
1157
1520
  const log = options.onLog ?? (() => {
1158
1521
  });
1159
1522
  await rm(path, { recursive: true, force: true });
@@ -1214,7 +1577,7 @@ async function startAppProcess(options) {
1214
1577
  const port = await freePort();
1215
1578
  let child;
1216
1579
  try {
1217
- child = spawn(substitutePort(options.devCommand, port), {
1580
+ child = spawn2(substitutePort(options.devCommand, port), {
1218
1581
  cwd: options.cwd,
1219
1582
  shell: true,
1220
1583
  // Own process group, so stopping kills the shell and whatever it spawned
@@ -1254,9 +1617,9 @@ async function startAppProcess(options) {
1254
1617
  }
1255
1618
 
1256
1619
  // ../server/dist/requests.js
1257
- import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1620
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1258
1621
  import { randomBytes } from "crypto";
1259
- import { dirname as dirname3, join as join4 } from "path";
1622
+ import { dirname as dirname4, join as join5 } from "path";
1260
1623
  var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
1261
1624
  function targetFor(url) {
1262
1625
  if (!url.startsWith("/"))
@@ -1282,17 +1645,20 @@ function composeRequest(preview, intent) {
1282
1645
  const target = preview.file ?? targetFor(preview.url);
1283
1646
  const cleaned = intent.trim();
1284
1647
  const where = target === null ? `The direction is titled "${preview.title}" and renders at ${preview.url}. Find what produces it.` : `It lives at ${target}.`;
1648
+ const pace = target === null ? `Once found, make the change and finish. ` : `Make the change in that file and finish. `;
1285
1649
  const prompt = `In this project, change only the "${preview.title}" design direction. ${where}
1286
1650
 
1287
1651
  What to change: ${cleaned}
1288
1652
 
1653
+ ${pace}This is a scoped design change: no test run, no build, and no survey of the rest of the project is needed. The result is checked visually in a live preview, not by tooling.
1654
+
1289
1655
  Leave every other direction exactly as it is; they are alternatives being compared side by side, so changing a sibling destroys the comparison. The direction is already registered, so nothing needs re-registering. Keep the change additive: do not rewrite shared components that other directions rely on.`;
1290
1656
  return { prompt, target };
1291
1657
  }
1292
1658
  var REQUESTS_PATH = ".leglas/requests.json";
1293
1659
  async function readRequests(cwd) {
1294
1660
  try {
1295
- const raw = await readFile3(join4(cwd, REQUESTS_PATH), "utf8");
1661
+ const raw = await readFile4(join5(cwd, REQUESTS_PATH), "utf8");
1296
1662
  const parsed = JSON.parse(raw);
1297
1663
  if (!Array.isArray(parsed.requests))
1298
1664
  return [];
@@ -1309,9 +1675,9 @@ async function readRequests(cwd) {
1309
1675
  }
1310
1676
  }
1311
1677
  async function writeQueue(cwd, requests) {
1312
- const path = join4(cwd, REQUESTS_PATH);
1313
- await mkdir2(dirname3(path), { recursive: true });
1314
- await writeFile2(path, `${JSON.stringify({ requests }, null, 2)}
1678
+ const path = join5(cwd, REQUESTS_PATH);
1679
+ await mkdir3(dirname4(path), { recursive: true });
1680
+ await writeFile3(path, `${JSON.stringify({ requests }, null, 2)}
1315
1681
  `, "utf8");
1316
1682
  }
1317
1683
  async function appendRequest(cwd, request) {
@@ -1351,13 +1717,264 @@ async function clearRequests(cwd) {
1351
1717
  return { cleared, pending: pending.length };
1352
1718
  }
1353
1719
 
1720
+ // ../server/dist/runner.js
1721
+ import { spawn as nodeSpawn } from "child_process";
1722
+ var POLL_MS = 2e3;
1723
+ var OUTPUT_LINES = 20;
1724
+ var SESSION_TURNS_CAP = 8;
1725
+ function resolveCommand(choice, prompt, sessionId = null) {
1726
+ if (choice.agent === null)
1727
+ return null;
1728
+ if (choice.agent === "custom") {
1729
+ if (choice.run === null)
1730
+ return null;
1731
+ const parsed = parseTemplate(choice.run);
1732
+ if (!parsed.ok)
1733
+ return null;
1734
+ return { agent: "custom", name: "Custom", ...commandFor(parsed.template, prompt), resumed: false };
1735
+ }
1736
+ const adapter = KNOWN_AGENTS[choice.agent];
1737
+ if (sessionId !== null && "resumeArgs" in adapter) {
1738
+ return {
1739
+ agent: choice.agent,
1740
+ name: adapter.name,
1741
+ command: adapter.binary,
1742
+ args: adapter.resumeArgs(sessionId, prompt),
1743
+ resumed: true
1744
+ };
1745
+ }
1746
+ return {
1747
+ agent: choice.agent,
1748
+ name: adapter.name,
1749
+ command: adapter.binary,
1750
+ args: adapter.args(prompt),
1751
+ resumed: false
1752
+ };
1753
+ }
1754
+ function lineReader(stream, onLine) {
1755
+ let buffered = "";
1756
+ const flush = () => {
1757
+ if (buffered === "")
1758
+ return;
1759
+ onLine(buffered.replace(/\r$/, ""));
1760
+ buffered = "";
1761
+ };
1762
+ stream.on("data", (chunk) => {
1763
+ buffered += chunk.toString();
1764
+ const lines = buffered.split("\n");
1765
+ buffered = lines.pop() ?? "";
1766
+ for (const line of lines)
1767
+ onLine(line.replace(/\r$/, ""));
1768
+ });
1769
+ stream.on("end", flush);
1770
+ return flush;
1771
+ }
1772
+ function defaultSpawn(command, args, options) {
1773
+ return nodeSpawn(command, args, options);
1774
+ }
1775
+ function startRunner(options) {
1776
+ const spawn4 = options.spawn ?? defaultSpawn;
1777
+ const setEvery = options.setInterval ?? ((callback, milliseconds) => setInterval(callback, milliseconds));
1778
+ const clearEvery = options.clearInterval ?? ((handle2) => clearInterval(handle2));
1779
+ const failed = /* @__PURE__ */ new Set();
1780
+ let state = {
1781
+ running: false,
1782
+ requestId: null,
1783
+ agent: null,
1784
+ activity: null,
1785
+ startedAt: null
1786
+ };
1787
+ let stopped = false;
1788
+ let ticking = null;
1789
+ let stopPromise = null;
1790
+ let active = null;
1791
+ const sessions = /* @__PURE__ */ new Map();
1792
+ const idle = () => {
1793
+ state = { running: false, requestId: null, agent: null, activity: null, startedAt: null };
1794
+ };
1795
+ const rememberLine = (lines, line) => {
1796
+ lines.push(line);
1797
+ if (lines.length > OUTPUT_LINES)
1798
+ lines.splice(0, lines.length - OUTPUT_LINES);
1799
+ };
1800
+ const reportFailure = (request, error, lines) => {
1801
+ console.error(`Leglas agent failed for ${request.title}: ${error}`);
1802
+ for (const line of lines)
1803
+ console.error(` ${line}`);
1804
+ };
1805
+ const runChild = (request, resolved, lines, observed) => {
1806
+ let child;
1807
+ try {
1808
+ child = spawn4(resolved.command, resolved.args, {
1809
+ cwd: options.cwd,
1810
+ shell: false,
1811
+ stdio: ["ignore", "pipe", "pipe"]
1812
+ });
1813
+ } catch (error) {
1814
+ return Promise.resolve({
1815
+ ok: false,
1816
+ error: error instanceof Error ? error.message : String(error)
1817
+ });
1818
+ }
1819
+ const current = { child, requestId: request.id, cancelled: false };
1820
+ active = current;
1821
+ const stdoutFlush = lineReader(child.stdout, (line) => {
1822
+ rememberLine(lines, line);
1823
+ const sessionId = sessionFrom(resolved.agent, line);
1824
+ if (sessionId !== null)
1825
+ observed.sessionId = sessionId;
1826
+ const activity = activityFrom(resolved.agent, line, options.cwd);
1827
+ if (activity !== null) {
1828
+ if (activity.startsWith("editing"))
1829
+ observed.edited = true;
1830
+ if (active === current)
1831
+ state = { ...state, activity };
1832
+ }
1833
+ });
1834
+ const stderrFlush = lineReader(child.stderr, (line) => rememberLine(lines, line));
1835
+ return new Promise((resolve) => {
1836
+ let settled = false;
1837
+ const settle = (outcome) => {
1838
+ if (settled)
1839
+ return;
1840
+ settled = true;
1841
+ stdoutFlush();
1842
+ stderrFlush();
1843
+ resolve(outcome);
1844
+ };
1845
+ child.once("error", (error) => settle({ ok: false, error: error.message }));
1846
+ child.once("close", (code, signal) => {
1847
+ if (current.cancelled)
1848
+ return settle({ ok: false, error: "cancelled" });
1849
+ if (signal !== null)
1850
+ return settle({ ok: false, error: `stopped by ${signal}` });
1851
+ settle({ ok: true, code: code ?? 0 });
1852
+ });
1853
+ }).finally(() => {
1854
+ if (active === current)
1855
+ active = null;
1856
+ });
1857
+ };
1858
+ const handle = async (request, choice) => {
1859
+ const session = choice.agent !== null ? sessions.get(choice.agent) ?? null : null;
1860
+ const continuable = session !== null && session.turns < SESSION_TURNS_CAP;
1861
+ let resolved = resolveCommand(choice, request.prompt, continuable ? session.id : null);
1862
+ if (resolved === null)
1863
+ return;
1864
+ const lines = [];
1865
+ try {
1866
+ if (!await markPickedUp(options.cwd, request.id))
1867
+ return;
1868
+ if (stopped) {
1869
+ failed.add(request.id);
1870
+ return;
1871
+ }
1872
+ state = {
1873
+ running: true,
1874
+ requestId: request.id,
1875
+ agent: resolved.name,
1876
+ activity: null,
1877
+ startedAt: Date.now()
1878
+ };
1879
+ const observed = { sessionId: null, edited: false };
1880
+ let outcome = await runChild(request, resolved, lines, observed);
1881
+ const cancelled = !outcome.ok && outcome.error === "cancelled";
1882
+ if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && !cancelled && // Not redundant with the line above: a stop that lands between the
1883
+ // first child settling and the retry starting finds no child to
1884
+ // cancel, so nothing says "cancelled". Stopped still means stopped.
1885
+ !stopped) {
1886
+ sessions.delete(resolved.agent);
1887
+ const cold = resolveCommand(choice, request.prompt);
1888
+ if (cold !== null) {
1889
+ resolved = cold;
1890
+ observed.sessionId = null;
1891
+ state = { ...state, activity: null };
1892
+ outcome = await runChild(request, resolved, lines, observed);
1893
+ }
1894
+ }
1895
+ if (outcome.ok && outcome.code === 0) {
1896
+ if (observed.sessionId !== null) {
1897
+ const previous = sessions.get(resolved.agent);
1898
+ sessions.set(resolved.agent, {
1899
+ id: observed.sessionId,
1900
+ turns: resolved.resumed && previous?.id === observed.sessionId ? previous.turns + 1 : 1
1901
+ });
1902
+ }
1903
+ await removeRequest(options.cwd, request.id);
1904
+ return;
1905
+ }
1906
+ sessions.delete(resolved.agent);
1907
+ failed.add(request.id);
1908
+ reportFailure(request, outcome.ok ? `${resolved.command} exited ${outcome.code}` : outcome.error, lines);
1909
+ } finally {
1910
+ idle();
1911
+ }
1912
+ };
1913
+ const tick = async () => {
1914
+ if (stopped)
1915
+ return;
1916
+ const choice = await readAgentChoice(options.cwd);
1917
+ if (choice.agent === null || stopped)
1918
+ return;
1919
+ if (options.externallyAttached())
1920
+ return;
1921
+ const request = nextRequest(await readRequests(options.cwd), failed);
1922
+ if (request !== null && !stopped)
1923
+ await handle(request, choice);
1924
+ };
1925
+ const schedule = () => {
1926
+ if (stopped || ticking !== null)
1927
+ return;
1928
+ const task = tick();
1929
+ ticking = task;
1930
+ void task.catch((error) => console.error(`Leglas runner: ${error instanceof Error ? error.message : String(error)}`)).finally(() => {
1931
+ if (ticking === task)
1932
+ ticking = null;
1933
+ });
1934
+ };
1935
+ const timer = setEvery(schedule, POLL_MS);
1936
+ schedule();
1937
+ const cancel = (id) => {
1938
+ if (active === null || active.cancelled)
1939
+ return false;
1940
+ if (id !== void 0 && active.requestId !== id)
1941
+ return false;
1942
+ active.cancelled = true;
1943
+ failed.add(active.requestId);
1944
+ try {
1945
+ active.child.kill("SIGTERM");
1946
+ } catch {
1947
+ }
1948
+ return true;
1949
+ };
1950
+ const stop = () => {
1951
+ if (stopPromise !== null)
1952
+ return stopPromise;
1953
+ stopped = true;
1954
+ clearEvery(timer);
1955
+ cancel();
1956
+ stopPromise = Promise.resolve(ticking).catch(() => {
1957
+ }).then(() => {
1958
+ });
1959
+ return stopPromise;
1960
+ };
1961
+ return {
1962
+ stop,
1963
+ snapshot: () => ({ ...state, failedIds: [...failed] }),
1964
+ cancel,
1965
+ // schedule already refuses to overlap a tick in flight, so a nudge during
1966
+ // a run costs nothing and a nudge between runs starts the next one now.
1967
+ nudge: schedule
1968
+ };
1969
+ }
1970
+
1354
1971
  // ../server/dist/renames.js
1355
- import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
1356
- import { dirname as dirname4, join as join5 } from "path";
1972
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1973
+ import { dirname as dirname5, join as join6 } from "path";
1357
1974
  var RENAMES_PATH = ".leglas/renames.json";
1358
1975
  async function readRenames(cwd) {
1359
1976
  try {
1360
- const raw = await readFile4(join5(cwd, RENAMES_PATH), "utf8");
1977
+ const raw = await readFile5(join6(cwd, RENAMES_PATH), "utf8");
1361
1978
  const parsed = JSON.parse(raw);
1362
1979
  if (parsed.renames === null || typeof parsed.renames !== "object")
1363
1980
  return {};
@@ -1367,9 +1984,9 @@ async function readRenames(cwd) {
1367
1984
  }
1368
1985
  }
1369
1986
  async function writeRenames(cwd, renames) {
1370
- const path = join5(cwd, RENAMES_PATH);
1371
- await mkdir3(dirname4(path), { recursive: true });
1372
- await writeFile3(path, `${JSON.stringify({ renames }, null, 2)}
1987
+ const path = join6(cwd, RENAMES_PATH);
1988
+ await mkdir4(dirname5(path), { recursive: true });
1989
+ await writeFile4(path, `${JSON.stringify({ renames }, null, 2)}
1373
1990
  `, "utf8");
1374
1991
  }
1375
1992
  function resolveTitle(input, titles, renames) {
@@ -1387,7 +2004,7 @@ function resolveTitle(input, titles, renames) {
1387
2004
  import { createReadStream, existsSync as existsSync2, statSync } from "fs";
1388
2005
  import http2 from "http";
1389
2006
  import net3 from "net";
1390
- import { extname, join as join6, normalize, relative as relative2 } from "path";
2007
+ import { extname, join as join7, normalize, relative as relative3 } from "path";
1391
2008
  var LEGLAS_PREFIX = "/leglas";
1392
2009
  var DEFAULT_PORT = 4100;
1393
2010
  var PORT_ATTEMPTS = 20;
@@ -1418,6 +2035,52 @@ function sendJson(res, status, body) {
1418
2035
  });
1419
2036
  res.end(payload);
1420
2037
  }
2038
+ function isKnownAgent(value) {
2039
+ return typeof value === "string" && Object.hasOwn(KNOWN_AGENTS, value);
2040
+ }
2041
+ function isAllowedMutationHost(hostname) {
2042
+ const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
2043
+ if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1")
2044
+ return true;
2045
+ if (bare.endsWith(".local"))
2046
+ return true;
2047
+ if (!net3.isIPv4(bare))
2048
+ return false;
2049
+ const [first, second] = bare.split(".").map(Number);
2050
+ return first === 10 || first === 172 && second !== void 0 && second >= 16 && second <= 31 || first === 192 && second === 168;
2051
+ }
2052
+ function isLoopbackAddress(address) {
2053
+ if (address === void 0)
2054
+ return false;
2055
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1" || address.startsWith("127.");
2056
+ }
2057
+ function isTrustedMutation(req) {
2058
+ if (!isLoopbackAddress(req.socket.remoteAddress))
2059
+ return false;
2060
+ if (typeof req.headers.host !== "string")
2061
+ return false;
2062
+ let host;
2063
+ try {
2064
+ host = new URL(`http://${req.headers.host}`);
2065
+ } catch {
2066
+ return false;
2067
+ }
2068
+ if (!isAllowedMutationHost(host.hostname))
2069
+ return false;
2070
+ const rawOrigin = req.headers.origin;
2071
+ if (rawOrigin === void 0)
2072
+ return true;
2073
+ try {
2074
+ const origin = new URL(rawOrigin);
2075
+ return origin.protocol === "http:" && origin.host === host.host;
2076
+ } catch {
2077
+ return false;
2078
+ }
2079
+ }
2080
+ function hasJsonBody(req) {
2081
+ const contentType = req.headers["content-type"];
2082
+ return typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
2083
+ }
1421
2084
  function probe(target, timeoutMs = 1e3) {
1422
2085
  return new Promise((resolve) => {
1423
2086
  let url;
@@ -1439,8 +2102,8 @@ function probe(target, timeoutMs = 1e3) {
1439
2102
  });
1440
2103
  }
1441
2104
  function serveFrom(res, dir, relativePath) {
1442
- const relative4 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
1443
- const candidate = join6(dir, relative4);
2105
+ const relative5 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
2106
+ const candidate = join7(dir, relative5);
1444
2107
  if (!candidate.startsWith(dir))
1445
2108
  return false;
1446
2109
  if (!existsSync2(candidate) || !statSync(candidate).isFile())
@@ -1453,9 +2116,9 @@ function serveFrom(res, dir, relativePath) {
1453
2116
  return true;
1454
2117
  }
1455
2118
  function serveShellFile(res, shellDir, urlPath) {
1456
- const relative4 = normalize(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
1457
- const isRoot = relative4 === "" || relative4 === "." || relative4 === "/";
1458
- return serveFrom(res, shellDir, isRoot ? "index.html" : relative4);
2119
+ const relative5 = normalize(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
2120
+ const isRoot = relative5 === "" || relative5 === "." || relative5 === "/";
2121
+ return serveFrom(res, shellDir, isRoot ? "index.html" : relative5);
1459
2122
  }
1460
2123
  function snapshotConfig(cwd) {
1461
2124
  const path = findConfigFile(cwd);
@@ -1471,15 +2134,15 @@ function configStalenessNotice(cwd, boot, current) {
1471
2134
  if (boot === null && current === null)
1472
2135
  return null;
1473
2136
  if (boot === null && current !== null) {
1474
- const label = relative2(cwd, current.path) || current.path;
2137
+ const label = relative3(cwd, current.path) || current.path;
1475
2138
  return `${label} appeared after Leglas started. Restart leglas to pick it up.`;
1476
2139
  }
1477
2140
  if (boot !== null && current === null) {
1478
- const label = relative2(cwd, boot.path) || boot.path;
2141
+ const label = relative3(cwd, boot.path) || boot.path;
1479
2142
  return `${label} was removed after Leglas started. Restart leglas to run without it.`;
1480
2143
  }
1481
2144
  if (boot !== null && current !== null && (boot.path !== current.path || boot.mtimeMs !== current.mtimeMs)) {
1482
- const label = relative2(cwd, current.path) || current.path;
2145
+ const label = relative3(cwd, current.path) || current.path;
1483
2146
  return `${label} changed after Leglas started. Restart leglas to pick it up.`;
1484
2147
  }
1485
2148
  return null;
@@ -1525,27 +2188,63 @@ async function bind(server, requested) {
1525
2188
  throw new Error(`No free port between ${requested} and ${requested + PORT_ATTEMPTS - 1}.`);
1526
2189
  }
1527
2190
  async function startServer(options) {
1528
- const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map() } = options;
2191
+ const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
1529
2192
  const target = config?.devServer ?? "http://localhost:3000";
1530
2193
  const proxy = createProxyHandler({ target });
1531
2194
  const bootConfigSnapshot = snapshotConfig(cwd);
1532
2195
  let lastSeen = null;
2196
+ const externallyAttached = () => lastSeen !== null && Date.now() - lastSeen < ATTACHED_WINDOW_MS;
2197
+ let runner = null;
2198
+ let agentsCache = null;
2199
+ let agentsInflight = null;
2200
+ const AGENTS_FRESH_MS = 3e4;
2201
+ const probeAgents = () => {
2202
+ agentsInflight ??= detect().then((agents) => {
2203
+ agentsCache = { at: Date.now(), agents };
2204
+ return agents;
2205
+ }).finally(() => {
2206
+ agentsInflight = null;
2207
+ });
2208
+ return agentsInflight;
2209
+ };
2210
+ const currentAgents = () => {
2211
+ if (agentsCache === null)
2212
+ return probeAgents();
2213
+ if (Date.now() - agentsCache.at > AGENTS_FRESH_MS) {
2214
+ void probeAgents().catch(() => {
2215
+ });
2216
+ }
2217
+ return Promise.resolve(agentsCache.agents);
2218
+ };
1533
2219
  const server = http2.createServer((req, res) => {
1534
2220
  const url = req.url ?? "/";
1535
2221
  const path = url.split("?")[0] ?? "/";
2222
+ if (req.method === "POST" && path.startsWith(`${LEGLAS_PREFIX}/api/`) && !isTrustedMutation(req)) {
2223
+ return sendJson(res, 403, { ok: false, error: "Cross-origin API mutations are refused." });
2224
+ }
1536
2225
  if (path === `${LEGLAS_PREFIX}/api/config`) {
1537
2226
  const boot = config?.previews ?? [];
1538
2227
  const errors = [...configErrors];
1539
2228
  const notice = configStalenessNotice(cwd, bootConfigSnapshot, snapshotConfig(cwd));
1540
2229
  if (notice !== null)
1541
2230
  errors.push(notice);
1542
- return void readLocalPreviews(cwd).then(({ previews: local }) => {
1543
- const known = new Set(boot.map((preview) => preview.title));
2231
+ return void readLocalPreviews(cwd).then(({ previews: local, errors: localErrors }) => {
2232
+ if (localErrors.length > 0) {
2233
+ return sendJson(res, 200, {
2234
+ project,
2235
+ devServer: target,
2236
+ previews: boot,
2237
+ errors
2238
+ });
2239
+ }
2240
+ const localTitles = new Set(local.map((preview) => preview.title));
2241
+ const currentBoot = boot.filter((preview) => preview.local !== true || localTitles.has(preview.title));
2242
+ const known = new Set(currentBoot.map((preview) => preview.title));
1544
2243
  const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
1545
2244
  sendJson(res, 200, {
1546
2245
  project,
1547
2246
  devServer: target,
1548
- previews: [...boot, ...fresh],
2247
+ previews: [...currentBoot, ...fresh],
1549
2248
  errors
1550
2249
  });
1551
2250
  }).catch(() => sendJson(res, 200, {
@@ -1555,6 +2254,47 @@ async function startServer(options) {
1555
2254
  errors
1556
2255
  }));
1557
2256
  }
2257
+ if (path === `${LEGLAS_PREFIX}/api/previews/delete` && req.method === "POST") {
2258
+ let body = "";
2259
+ req.on("data", (chunk) => body += chunk);
2260
+ return void req.on("end", async () => {
2261
+ let parsed;
2262
+ try {
2263
+ parsed = JSON.parse(body || "{}");
2264
+ } catch {
2265
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2266
+ }
2267
+ const titles = parsed.titles;
2268
+ if (!Array.isArray(titles) || titles.length === 0 || titles.some((title) => typeof title !== "string" || title.trim() === "")) {
2269
+ return sendJson(res, 400, {
2270
+ ok: false,
2271
+ error: "Body needs a non-empty array of direction titles."
2272
+ });
2273
+ }
2274
+ const unique = [...new Set(titles)];
2275
+ try {
2276
+ const local = await readLocalPreviews(cwd);
2277
+ if (local.errors.length > 0) {
2278
+ return sendJson(res, 409, { ok: false, error: local.errors.join(" ") });
2279
+ }
2280
+ const localTitles = new Set(local.previews.map((preview) => preview.title));
2281
+ const unknown = unique.filter((title) => !localTitles.has(title));
2282
+ if (unknown.length > 0) {
2283
+ return sendJson(res, 400, {
2284
+ ok: false,
2285
+ error: "Only machine-local directions can be deleted from the registry."
2286
+ });
2287
+ }
2288
+ const deleted = await dropLocalPreviews(cwd, unique);
2289
+ return sendJson(res, 200, { ok: true, deleted });
2290
+ } catch {
2291
+ return sendJson(res, 500, {
2292
+ ok: false,
2293
+ error: "The directions could not be deleted from Leglas."
2294
+ });
2295
+ }
2296
+ });
2297
+ }
1558
2298
  if (path === `${LEGLAS_PREFIX}/api/request` && req.method === "POST") {
1559
2299
  let body = "";
1560
2300
  req.on("data", (chunk) => body += chunk);
@@ -1565,8 +2305,12 @@ async function startServer(options) {
1565
2305
  } catch {
1566
2306
  return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
1567
2307
  }
1568
- const local = await readLocalPreviews(cwd).then((read) => read.previews, () => []);
1569
- const preview = [...config?.previews ?? [], ...local].find((entry) => entry.title === parsed.title);
2308
+ const localRead = await readLocalPreviews(cwd).catch(() => null);
2309
+ const local = localRead?.errors.length === 0 ? localRead.previews : [];
2310
+ const localTitles = new Set(local.map((entry) => entry.title));
2311
+ const bootConfig = config?.previews ?? [];
2312
+ const boot = localRead === null || localRead.errors.length > 0 ? bootConfig : bootConfig.filter((entry) => entry.local !== true || localTitles.has(entry.title));
2313
+ const preview = [...boot, ...local].find((entry) => entry.title === parsed.title);
1570
2314
  if (!preview || !parsed.intent?.trim()) {
1571
2315
  return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
1572
2316
  }
@@ -1576,7 +2320,54 @@ async function startServer(options) {
1576
2320
  url: preview.url,
1577
2321
  intent: parsed.intent.trim(),
1578
2322
  ...composed
1579
- }).then(() => sendJson(res, 200, { ok: true, ...composed })).catch(() => sendJson(res, 200, { ok: true, ...composed, queued: false }));
2323
+ }).then(() => {
2324
+ runner?.nudge();
2325
+ sendJson(res, 200, { ok: true, ...composed });
2326
+ }).catch(() => sendJson(res, 200, { ok: true, ...composed, queued: false }));
2327
+ });
2328
+ }
2329
+ if (path === `${LEGLAS_PREFIX}/api/agents` && req.method === "GET") {
2330
+ return void Promise.all([currentAgents(), readAgentChoice(cwd)]).then(([agents, choice]) => sendJson(res, 200, {
2331
+ agents,
2332
+ choice: choice.agent,
2333
+ customRun: choice.run
2334
+ }));
2335
+ }
2336
+ if (path === `${LEGLAS_PREFIX}/api/agent` && req.method === "POST") {
2337
+ if (!isLoopbackAddress(req.socket.remoteAddress)) {
2338
+ return sendJson(res, 403, {
2339
+ ok: false,
2340
+ error: "The agent choice can only be made from the machine running Leglas."
2341
+ });
2342
+ }
2343
+ if (!hasJsonBody(req)) {
2344
+ return sendJson(res, 400, { ok: false, error: "Agent choice must be JSON." });
2345
+ }
2346
+ let body = "";
2347
+ req.on("data", (chunk) => body += chunk);
2348
+ return void req.on("end", () => {
2349
+ let parsed;
2350
+ try {
2351
+ parsed = JSON.parse(body || "{}");
2352
+ } catch {
2353
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2354
+ }
2355
+ if (!isKnownAgent(parsed.agent) && parsed.agent !== "custom") {
2356
+ return sendJson(res, 400, { ok: false, error: "Body needs a known agent." });
2357
+ }
2358
+ if (parsed.run !== void 0 && typeof parsed.run !== "string") {
2359
+ return sendJson(res, 400, { ok: false, error: "The custom run command must be a string." });
2360
+ }
2361
+ if (parsed.agent === "custom") {
2362
+ if (typeof parsed.run !== "string") {
2363
+ return sendJson(res, 400, { ok: false, error: "A custom agent needs a run command." });
2364
+ }
2365
+ const template = parseTemplate(parsed.run);
2366
+ if (!template.ok)
2367
+ return sendJson(res, 400, { ok: false, error: template.error });
2368
+ return void saveAgentChoice(cwd, { agent: "custom", run: parsed.run }).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 500, { ok: false, error: "Agent choice could not be saved." }));
2369
+ }
2370
+ return void saveAgentChoice(cwd, { agent: parsed.agent }).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 500, { ok: false, error: "Agent choice could not be saved." }));
1580
2371
  });
1581
2372
  }
1582
2373
  if (path === `${LEGLAS_PREFIX}/api/watch` && req.method === "POST") {
@@ -1597,11 +2388,119 @@ async function startServer(options) {
1597
2388
  });
1598
2389
  }
1599
2390
  if (path === `${LEGLAS_PREFIX}/api/requests` && req.method === "GET") {
2391
+ const snapshot = runner?.snapshot() ?? {
2392
+ running: false,
2393
+ requestId: null,
2394
+ agent: null,
2395
+ activity: null,
2396
+ startedAt: null,
2397
+ failedIds: []
2398
+ };
1600
2399
  return void readRequests(cwd).then((requests) => sendJson(res, 200, {
1601
- requests: requests.map(({ id, title, intent, status }) => ({ id, title, intent, status })),
1602
- agent: { attached: lastSeen !== null && Date.now() - lastSeen < ATTACHED_WINDOW_MS }
2400
+ requests: requests.map(({ id, title, intent, status }) => ({
2401
+ id,
2402
+ title,
2403
+ intent,
2404
+ status: snapshot.running && snapshot.requestId === id ? "running" : snapshot.failedIds.includes(id) ? "failed" : status
2405
+ })),
2406
+ agent: {
2407
+ attached: externallyAttached(),
2408
+ running: snapshot.running,
2409
+ name: snapshot.running ? snapshot.agent : null,
2410
+ activity: snapshot.running ? snapshot.activity : null,
2411
+ startedAt: snapshot.running ? snapshot.startedAt : null
2412
+ }
1603
2413
  }));
1604
2414
  }
2415
+ if (path === `${LEGLAS_PREFIX}/api/requests/cancel` && req.method === "POST") {
2416
+ if (!hasJsonBody(req)) {
2417
+ return sendJson(res, 200, { ok: true, cancelled: runner?.cancel() ?? false });
2418
+ }
2419
+ let body = "";
2420
+ req.on("data", (chunk) => body += chunk);
2421
+ return void req.on("end", () => {
2422
+ let parsed;
2423
+ try {
2424
+ parsed = JSON.parse(body || "{}");
2425
+ } catch {
2426
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2427
+ }
2428
+ if (parsed.id !== void 0 && typeof parsed.id !== "string") {
2429
+ return sendJson(res, 400, { ok: false, error: "The request id must be a string." });
2430
+ }
2431
+ return sendJson(res, 200, { ok: true, cancelled: runner?.cancel(parsed.id) ?? false });
2432
+ });
2433
+ }
2434
+ if (path === `${LEGLAS_PREFIX}/api/requests/retry` && req.method === "POST") {
2435
+ if (!hasJsonBody(req)) {
2436
+ return sendJson(res, 400, { ok: false, error: "Retry must be JSON." });
2437
+ }
2438
+ let body = "";
2439
+ req.on("data", (chunk) => body += chunk);
2440
+ return void req.on("end", async () => {
2441
+ let parsed;
2442
+ try {
2443
+ parsed = JSON.parse(body || "{}");
2444
+ } catch {
2445
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2446
+ }
2447
+ if (typeof parsed.id !== "string") {
2448
+ return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
2449
+ }
2450
+ const request = (await readRequests(cwd)).find((entry) => entry.id === parsed.id);
2451
+ if (request === void 0) {
2452
+ return sendJson(res, 404, { ok: false, error: "No such request." });
2453
+ }
2454
+ if (!(runner?.snapshot().failedIds.includes(request.id) ?? false)) {
2455
+ return sendJson(res, 400, { ok: false, error: "Only a failed request can be retried." });
2456
+ }
2457
+ try {
2458
+ if (!await removeRequest(cwd, request.id)) {
2459
+ return sendJson(res, 404, { ok: false, error: "No such request." });
2460
+ }
2461
+ await appendRequest(cwd, {
2462
+ title: request.title,
2463
+ url: request.url,
2464
+ intent: request.intent,
2465
+ target: request.target,
2466
+ prompt: request.prompt
2467
+ });
2468
+ runner?.nudge();
2469
+ return sendJson(res, 200, { ok: true });
2470
+ } catch {
2471
+ return sendJson(res, 500, { ok: false, error: "The request could not be retried." });
2472
+ }
2473
+ });
2474
+ }
2475
+ if (path === `${LEGLAS_PREFIX}/api/requests/dismiss` && req.method === "POST") {
2476
+ if (!hasJsonBody(req)) {
2477
+ return sendJson(res, 400, { ok: false, error: "Dismiss must be JSON." });
2478
+ }
2479
+ let body = "";
2480
+ req.on("data", (chunk) => body += chunk);
2481
+ return void req.on("end", async () => {
2482
+ let parsed;
2483
+ try {
2484
+ parsed = JSON.parse(body || "{}");
2485
+ } catch {
2486
+ return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
2487
+ }
2488
+ if (typeof parsed.id !== "string") {
2489
+ return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
2490
+ }
2491
+ if (!(runner?.snapshot().failedIds.includes(parsed.id) ?? false)) {
2492
+ return sendJson(res, 400, { ok: false, error: "Only a failed request can be dismissed." });
2493
+ }
2494
+ try {
2495
+ if (!await removeRequest(cwd, parsed.id)) {
2496
+ return sendJson(res, 404, { ok: false, error: "No such request." });
2497
+ }
2498
+ return sendJson(res, 200, { ok: true });
2499
+ } catch {
2500
+ return sendJson(res, 500, { ok: false, error: "The request could not be dismissed." });
2501
+ }
2502
+ });
2503
+ }
1605
2504
  if (path === `${LEGLAS_PREFIX}/api/renames` && req.method === "POST") {
1606
2505
  let body = "";
1607
2506
  req.on("data", (chunk) => body += chunk);
@@ -1626,14 +2525,14 @@ async function startServer(options) {
1626
2525
  const rest = path.slice(FILES_PREFIX.length + 1);
1627
2526
  const slash = rest.indexOf("/");
1628
2527
  const slug = slash === -1 ? rest : rest.slice(0, slash);
1629
- let relative4 = slash === -1 ? "" : rest.slice(slash + 1);
2528
+ let relative5 = slash === -1 ? "" : rest.slice(slash + 1);
1630
2529
  try {
1631
- relative4 = decodeURIComponent(relative4);
2530
+ relative5 = decodeURIComponent(relative5);
1632
2531
  } catch {
1633
- relative4 = "";
2532
+ relative5 = "";
1634
2533
  }
1635
2534
  const dir = fileMounts.get(slug);
1636
- if (dir !== void 0 && relative4 !== "" && serveFrom(res, dir, relative4))
2535
+ if (dir !== void 0 && relative5 !== "" && serveFrom(res, dir, relative5))
1637
2536
  return;
1638
2537
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
1639
2538
  return res.end("Leglas: no such preview file.");
@@ -1665,16 +2564,23 @@ async function startServer(options) {
1665
2564
  proxy.upgrade(req, socket, head);
1666
2565
  });
1667
2566
  const port = await bind(server, options.port ?? DEFAULT_PORT);
2567
+ runner = startRunner({ cwd, externallyAttached });
2568
+ let closePromise = null;
1668
2569
  return {
1669
2570
  port,
1670
2571
  url: `http://localhost:${port}`,
1671
- close: () => new Promise((done) => {
1672
- for (const socket of sockets)
1673
- socket.destroy();
1674
- sockets.clear();
1675
- server.closeAllConnections();
1676
- server.close(() => done());
1677
- })
2572
+ close: () => {
2573
+ if (closePromise !== null)
2574
+ return closePromise;
2575
+ closePromise = runner.stop().then(() => new Promise((done) => {
2576
+ for (const socket of sockets)
2577
+ socket.destroy();
2578
+ sockets.clear();
2579
+ server.closeAllConnections();
2580
+ server.close(() => done());
2581
+ }));
2582
+ return closePromise;
2583
+ }
1678
2584
  };
1679
2585
  }
1680
2586
 
@@ -1733,11 +2639,11 @@ function planKeep(options) {
1733
2639
  }
1734
2640
 
1735
2641
  // src/run-init.ts
1736
- import { readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
1737
- import { join as join7 } from "path";
2642
+ import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
2643
+ import { join as join8 } from "path";
1738
2644
  async function readIfPresent(path) {
1739
2645
  try {
1740
- return await readFile5(path, "utf8");
2646
+ return await readFile6(path, "utf8");
1741
2647
  } catch {
1742
2648
  return null;
1743
2649
  }
@@ -1745,18 +2651,18 @@ async function readIfPresent(path) {
1745
2651
  async function runInit(options, deps) {
1746
2652
  const existingConfig = findConfigFile(options.cwd);
1747
2653
  const plan = planInit({
1748
- agents: await readIfPresent(join7(options.cwd, "AGENTS.md")),
2654
+ agents: await readIfPresent(join8(options.cwd, "AGENTS.md")),
1749
2655
  config: existingConfig === null ? null : "present",
1750
- gitignore: await readIfPresent(join7(options.cwd, ".gitignore")),
2656
+ gitignore: await readIfPresent(join8(options.cwd, ".gitignore")),
1751
2657
  force: options.force
1752
2658
  });
1753
2659
  const touched = [];
1754
2660
  for (const write of plan.writes) {
1755
- await writeFile4(join7(options.cwd, write.path), write.contents, "utf8");
2661
+ await writeFile5(join8(options.cwd, write.path), write.contents, "utf8");
1756
2662
  touched.push(write.path);
1757
2663
  }
1758
2664
  if (plan.gitignore !== null) {
1759
- await writeFile4(join7(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2665
+ await writeFile5(join8(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1760
2666
  touched.push(".gitignore");
1761
2667
  }
1762
2668
  if (options.json) {
@@ -1776,8 +2682,8 @@ async function runInit(options, deps) {
1776
2682
 
1777
2683
  // src/run-keep.ts
1778
2684
  import { existsSync as existsSync3 } from "fs";
1779
- import { mkdir as mkdir4, readFile as readFile6, rm as rm2, writeFile as writeFile5 } from "fs/promises";
1780
- import { dirname as dirname5, join as join8 } from "path";
2685
+ import { mkdir as mkdir5, readFile as readFile7, rm as rm2, writeFile as writeFile6 } from "fs/promises";
2686
+ import { dirname as dirname6, join as join9 } from "path";
1781
2687
 
1782
2688
  // src/resolve-title.ts
1783
2689
  function resolveOrExplain(input, titles, renames) {
@@ -1821,18 +2727,18 @@ async function runKeep(options, deps) {
1821
2727
  if (!resolved.ok) return fail(resolved.error);
1822
2728
  const plan = planKeep({ title: resolved.title, previews, to: options.to });
1823
2729
  if (!plan.ok) return fail(plan.error);
1824
- const from = join8(options.cwd, plan.move.from);
1825
- const to = join8(options.cwd, plan.move.to);
2730
+ const from = join9(options.cwd, plan.move.from);
2731
+ const to = join9(options.cwd, plan.move.to);
1826
2732
  if (!existsSync3(from)) {
1827
2733
  return fail(`${plan.move.from} does not exist. Nothing to keep.`);
1828
2734
  }
1829
2735
  if (existsSync3(to)) {
1830
2736
  return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
1831
2737
  }
1832
- const source = await readFile6(from, "utf8");
1833
- await mkdir4(dirname5(to), { recursive: true });
1834
- await writeFile5(to, renameExport(source, plan.exportName), "utf8");
1835
- await rm2(join8(options.cwd, plan.removeDir), { recursive: true, force: true });
2738
+ const source = await readFile7(from, "utf8");
2739
+ await mkdir5(dirname6(to), { recursive: true });
2740
+ await writeFile6(to, renameExport(source, plan.exportName), "utf8");
2741
+ await rm2(join9(options.cwd, plan.removeDir), { recursive: true, force: true });
1836
2742
  const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
1837
2743
  if (options.json) {
1838
2744
  deps.log(
@@ -1867,11 +2773,11 @@ async function runKeep(options, deps) {
1867
2773
 
1868
2774
  // src/run-new.ts
1869
2775
  import { existsSync as existsSync4 } from "fs";
1870
- import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
1871
- import { dirname as dirname6, join as join9 } from "path";
2776
+ import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
2777
+ import { dirname as dirname7, join as join10 } from "path";
1872
2778
  async function readIfPresent2(path) {
1873
2779
  try {
1874
- return await readFile7(path, "utf8");
2780
+ return await readFile8(path, "utf8");
1875
2781
  } catch {
1876
2782
  return null;
1877
2783
  }
@@ -1879,7 +2785,7 @@ async function readIfPresent2(path) {
1879
2785
  async function runNew(options, deps) {
1880
2786
  let from;
1881
2787
  if (options.from !== void 0) {
1882
- const contents = await readIfPresent2(join9(options.cwd, options.from));
2788
+ const contents = await readIfPresent2(join10(options.cwd, options.from));
1883
2789
  if (contents === null) {
1884
2790
  const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
1885
2791
  if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
@@ -1890,8 +2796,8 @@ async function runNew(options, deps) {
1890
2796
  }
1891
2797
  const plan = planNew({
1892
2798
  surface: options.surface,
1893
- packageJson: await readIfPresent2(join9(options.cwd, "package.json")),
1894
- gitignore: await readIfPresent2(join9(options.cwd, ".gitignore")),
2799
+ packageJson: await readIfPresent2(join10(options.cwd, "package.json")),
2800
+ gitignore: await readIfPresent2(join10(options.cwd, ".gitignore")),
1895
2801
  from
1896
2802
  });
1897
2803
  const fail = (error) => {
@@ -1914,19 +2820,19 @@ async function runNew(options, deps) {
1914
2820
  deps.log(plan.instructions);
1915
2821
  return { exitCode: 0, written: [] };
1916
2822
  }
1917
- const existing = plan.writes.filter((write) => existsSync4(join9(options.cwd, write.path)));
2823
+ const existing = plan.writes.filter((write) => existsSync4(join10(options.cwd, write.path)));
1918
2824
  if (existing.length > 0) {
1919
2825
  return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
1920
2826
  }
1921
2827
  const written = [];
1922
2828
  for (const write of plan.writes) {
1923
- const target = join9(options.cwd, write.path);
1924
- await mkdir5(dirname6(target), { recursive: true });
1925
- await writeFile6(target, write.contents, "utf8");
2829
+ const target = join10(options.cwd, write.path);
2830
+ await mkdir6(dirname7(target), { recursive: true });
2831
+ await writeFile7(target, write.contents, "utf8");
1926
2832
  written.push(write.path);
1927
2833
  }
1928
2834
  if (plan.gitignore !== null) {
1929
- await writeFile6(join9(options.cwd, ".gitignore"), plan.gitignore, "utf8");
2835
+ await writeFile7(join10(options.cwd, ".gitignore"), plan.gitignore, "utf8");
1930
2836
  written.push(".gitignore");
1931
2837
  }
1932
2838
  if (options.json) {
@@ -1947,21 +2853,21 @@ async function runNew(options, deps) {
1947
2853
  }
1948
2854
 
1949
2855
  // src/run-previews.ts
1950
- import { readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
1951
- import { join as join10 } from "path";
2856
+ import { readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
2857
+ import { join as join11 } from "path";
1952
2858
  function envelope(deps, ok, body) {
1953
2859
  deps.log(JSON.stringify({ ok, ...body }));
1954
2860
  }
1955
2861
  async function ensureIgnored(cwd) {
1956
- const path = join10(cwd, ".gitignore");
2862
+ const path = join11(cwd, ".gitignore");
1957
2863
  let current = null;
1958
2864
  try {
1959
- current = await readFile8(path, "utf8");
2865
+ current = await readFile9(path, "utf8");
1960
2866
  } catch {
1961
2867
  current = null;
1962
2868
  }
1963
2869
  const next = ignoreEntry(current);
1964
- if (next !== null) await writeFile7(path, next, "utf8");
2870
+ if (next !== null) await writeFile8(path, next, "utf8");
1965
2871
  }
1966
2872
  async function runAdd(options, deps) {
1967
2873
  const loaded = await loadConfig(options.cwd);
@@ -2205,99 +3111,25 @@ async function runShow(options, deps) {
2205
3111
  return { exitCode: 0 };
2206
3112
  }
2207
3113
 
2208
- // src/watch.ts
2209
- var WATCH_PATH = ".leglas/watch.json";
2210
- var PROMPT_TOKEN = "{prompt}";
2211
- var EXAMPLE = `npx leglas watch --run "claude -p ${PROMPT_TOKEN}"`;
2212
- function tokenize(template) {
2213
- const tokens = [];
2214
- let current = "";
2215
- let started = false;
2216
- let quote = null;
2217
- for (const character of template) {
2218
- if (quote !== null) {
2219
- if (character === quote) quote = null;
2220
- else current += character;
2221
- continue;
2222
- }
2223
- if (character === '"' || character === "'") {
2224
- quote = character;
2225
- started = true;
2226
- continue;
2227
- }
2228
- if (/\s/.test(character)) {
2229
- if (started) tokens.push(current);
2230
- current = "";
2231
- started = false;
2232
- continue;
2233
- }
2234
- current += character;
2235
- started = true;
2236
- }
2237
- if (quote !== null) {
2238
- return { ok: false, error: `The agent command has an unclosed ${quote} quote.` };
2239
- }
2240
- if (started) tokens.push(current);
2241
- return { ok: true, tokens };
2242
- }
2243
- function parseTemplate(raw) {
2244
- const tokenized = tokenize(raw);
2245
- if (!tokenized.ok) return tokenized;
2246
- const { tokens } = tokenized;
2247
- const [command, ...args] = tokens;
2248
- if (command === void 0) {
2249
- return { ok: false, error: `Watch needs an agent command, for example: ${EXAMPLE}` };
2250
- }
2251
- const placeholders = tokens.filter((token) => token === PROMPT_TOKEN).length;
2252
- if (placeholders === 0) {
2253
- return {
2254
- ok: false,
2255
- error: `The agent command needs ${PROMPT_TOKEN} as a word of its own, for example: ${EXAMPLE}`
2256
- };
2257
- }
2258
- if (placeholders > 1) {
2259
- return {
2260
- ok: false,
2261
- error: `The agent command takes ${PROMPT_TOKEN} once, for example: ${EXAMPLE}`
2262
- };
2263
- }
2264
- if (command === PROMPT_TOKEN) {
2265
- return {
2266
- ok: false,
2267
- error: `The agent command must name a program before ${PROMPT_TOKEN}, for example: ${EXAMPLE}`
2268
- };
2269
- }
2270
- return { ok: true, template: { command, args } };
2271
- }
2272
- function commandFor(template, prompt) {
2273
- return {
2274
- command: template.command,
2275
- args: template.args.map((argument) => argument === PROMPT_TOKEN ? prompt : argument)
2276
- };
2277
- }
2278
- function nextRequest(requests, failed) {
2279
- return requests.find((request) => request.status === "queued" && !failed.has(request.id)) ?? null;
2280
- }
2281
-
2282
3114
  // src/run-watch.ts
2283
- import { spawn as spawn2 } from "child_process";
2284
- import { mkdir as mkdir6, readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
2285
- import { dirname as dirname7, join as join11 } from "path";
2286
- var POLL_MS = 2e3;
3115
+ import { spawn as spawn3 } from "child_process";
3116
+ import { mkdir as mkdir7, readFile as readFile10, writeFile as writeFile9 } from "fs/promises";
3117
+ import { dirname as dirname8, join as join12 } from "path";
3118
+ var POLL_MS2 = 2e3;
2287
3119
  var HEARTBEAT_TIMEOUT_MS = 1e3;
2288
- async function readSavedTemplate(cwd) {
3120
+ async function saveTemplate(cwd, run3) {
3121
+ const path = join12(cwd, WATCH_PATH);
3122
+ let config = {};
2289
3123
  try {
2290
- const raw = await readFile9(join11(cwd, WATCH_PATH), "utf8");
2291
- const parsed = JSON.parse(raw);
2292
- return typeof parsed.run === "string" && parsed.run !== "" ? parsed.run : null;
3124
+ const parsed = JSON.parse(await readFile10(path, "utf8"));
3125
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
3126
+ config = parsed;
3127
+ }
2293
3128
  } catch {
2294
- return null;
2295
3129
  }
2296
- }
2297
- async function saveTemplate(cwd, run3) {
2298
- const path = join11(cwd, WATCH_PATH);
2299
- await mkdir6(dirname7(path), { recursive: true });
2300
- await writeFile8(path, `${JSON.stringify({ run: run3 }, null, 2)}
3130
+ config.run = run3;
3131
+ await mkdir7(dirname8(path), { recursive: true });
3132
+ await writeFile9(path, `${JSON.stringify(config, null, 2)}
2301
3133
  `, "utf8");
2302
3134
  }
2303
3135
  function spawnAgent(command, args, cwd) {
@@ -2308,7 +3140,7 @@ function spawnAgent(command, args, cwd) {
2308
3140
  settled = true;
2309
3141
  resolve(outcome);
2310
3142
  };
2311
- const child = spawn2(command, args, { cwd, stdio: "inherit" });
3143
+ const child = spawn3(command, args, { cwd, stdio: "inherit" });
2312
3144
  child.on("error", (error) => settle({ ok: false, error: error.message }));
2313
3145
  child.on(
2314
3146
  "close",
@@ -2319,21 +3151,34 @@ function spawnAgent(command, args, cwd) {
2319
3151
  });
2320
3152
  }
2321
3153
  async function runWatch(options, deps) {
2322
- const saved = options.run === void 0 ? await readSavedTemplate(options.cwd) : null;
2323
- const raw = options.run ?? saved;
2324
- if (raw === null) {
3154
+ const saved = options.run === void 0 ? await readAgentChoice(options.cwd) : { agent: null, run: null };
3155
+ const raw = options.run ?? saved.run;
3156
+ let template;
3157
+ let shownCommand2;
3158
+ let synthesizedAgent = null;
3159
+ if (raw !== null) {
3160
+ const parsed = parseTemplate(raw);
3161
+ if (!parsed.ok) {
3162
+ deps.error(parsed.error);
3163
+ return { exitCode: 1 };
3164
+ }
3165
+ template = parsed.template;
3166
+ shownCommand2 = raw;
3167
+ } else if (saved.agent !== null && saved.agent !== "custom") {
3168
+ const adapter = KNOWN_AGENTS[saved.agent];
3169
+ template = {
3170
+ command: adapter.binary,
3171
+ args: adapter.terminalArgs(PROMPT_TOKEN)
3172
+ };
3173
+ shownCommand2 = [template.command, ...template.args].join(" ");
3174
+ synthesizedAgent = adapter.name;
3175
+ } else {
2325
3176
  deps.error(
2326
- 'Watch needs an agent command the first time: npx leglas watch --run "claude -p {prompt}"'
3177
+ 'Watch needs an agent command the first time: pick an agent in the interface, or pass --run "claude -p {prompt}".'
2327
3178
  );
2328
3179
  return { exitCode: 1 };
2329
3180
  }
2330
- const parsed = parseTemplate(raw);
2331
- if (!parsed.ok) {
2332
- deps.error(parsed.error);
2333
- return { exitCode: 1 };
2334
- }
2335
- const template = parsed.template;
2336
- if (options.run !== void 0) await saveTemplate(options.cwd, raw).catch(() => {
3181
+ if (options.run !== void 0) await saveTemplate(options.cwd, options.run).catch(() => {
2337
3182
  });
2338
3183
  const base = `http://localhost:${options.port ?? DEFAULT_PORT}`;
2339
3184
  const heartbeat = async (watching) => {
@@ -2347,11 +3192,15 @@ async function runWatch(options, deps) {
2347
3192
  } catch {
2348
3193
  }
2349
3194
  };
2350
- deps.log(`Watching for change requests. Each one runs: ${raw}`);
3195
+ if (synthesizedAgent !== null) {
3196
+ deps.log(`Using ${synthesizedAgent}, chosen in the interface.`);
3197
+ }
3198
+ deps.log(`Watching for change requests. Each one runs: ${shownCommand2}`);
2351
3199
  deps.log("Stop with Ctrl-C.");
2352
3200
  const failed = /* @__PURE__ */ new Set();
2353
3201
  let stopped = false;
2354
3202
  let busy = false;
3203
+ let announced = false;
2355
3204
  let inflight = null;
2356
3205
  const handle = async (request) => {
2357
3206
  deps.log("");
@@ -2373,7 +3222,12 @@ async function runWatch(options, deps) {
2373
3222
  };
2374
3223
  const tick = async () => {
2375
3224
  if (stopped) return;
2376
- void heartbeat(true);
3225
+ if (announced) {
3226
+ void heartbeat(true);
3227
+ } else {
3228
+ await heartbeat(true);
3229
+ announced = true;
3230
+ }
2377
3231
  if (busy) return;
2378
3232
  busy = true;
2379
3233
  try {
@@ -2390,7 +3244,7 @@ async function runWatch(options, deps) {
2390
3244
  }
2391
3245
  };
2392
3246
  return new Promise((resolve) => {
2393
- const timer = setInterval(() => void tick(), POLL_MS);
3247
+ const timer = setInterval(() => void tick(), POLL_MS2);
2394
3248
  const stop = () => {
2395
3249
  if (stopped) return;
2396
3250
  stopped = true;
@@ -2409,12 +3263,12 @@ async function runWatch(options, deps) {
2409
3263
 
2410
3264
  // src/run-classify.ts
2411
3265
  import { stat } from "fs/promises";
2412
- import { join as join12 } from "path";
3266
+ import { join as join13 } from "path";
2413
3267
  async function runClassify(options, deps) {
2414
3268
  const declared = await Promise.all(
2415
3269
  options.changes.map(async (change) => ({
2416
3270
  ...change,
2417
- exists: await stat(join12(options.cwd, change.path)).then(
3271
+ exists: await stat(join13(options.cwd, change.path)).then(
2418
3272
  () => true,
2419
3273
  () => false
2420
3274
  )
@@ -2442,14 +3296,14 @@ async function runClassify(options, deps) {
2442
3296
  // src/run.ts
2443
3297
  import { existsSync as existsSync5 } from "fs";
2444
3298
  import { createRequire } from "module";
2445
- import { basename as basename3, dirname as dirname8, join as join13, relative as relative3 } from "path";
3299
+ import { basename as basename3, dirname as dirname9, join as join14, relative as relative4 } from "path";
2446
3300
  import { fileURLToPath } from "url";
2447
3301
  function findShellDir() {
2448
- const bundled = join13(dirname8(fileURLToPath(import.meta.url)), "shell");
2449
- if (existsSync5(join13(bundled, "index.html"))) return bundled;
3302
+ const bundled = join14(dirname9(fileURLToPath(import.meta.url)), "shell");
3303
+ if (existsSync5(join14(bundled, "index.html"))) return bundled;
2450
3304
  try {
2451
3305
  const require2 = createRequire(import.meta.url);
2452
- return dirname8(require2.resolve("@leglas/shell/dist/index.html"));
3306
+ return dirname9(require2.resolve("@leglas/shell/dist/index.html"));
2453
3307
  } catch {
2454
3308
  return null;
2455
3309
  }
@@ -2483,7 +3337,7 @@ async function run2(options, deps) {
2483
3337
  const fileMounts = /* @__PURE__ */ new Map();
2484
3338
  for (const preview of merged?.previews ?? []) {
2485
3339
  if (preview.file !== void 0) {
2486
- const absolute = join13(options.cwd, preview.file);
3340
+ const absolute = join14(options.cwd, preview.file);
2487
3341
  if (!existsSync5(absolute)) {
2488
3342
  worktreeErrors.push(
2489
3343
  `"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
@@ -2494,7 +3348,7 @@ async function run2(options, deps) {
2494
3348
  for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
2495
3349
  slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
2496
3350
  }
2497
- fileMounts.set(slug, dirname8(absolute));
3351
+ fileMounts.set(slug, dirname9(absolute));
2498
3352
  previews.push({
2499
3353
  ...preview,
2500
3354
  url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
@@ -2555,7 +3409,7 @@ async function run2(options, deps) {
2555
3409
  })
2556
3410
  );
2557
3411
  } else {
2558
- const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative3(options.cwd, loaded.path) || loaded.path;
3412
+ const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative4(options.cwd, loaded.path) || loaded.path;
2559
3413
  deps.log(`Leglas ${url}`);
2560
3414
  deps.log(
2561
3415
  `app ${devServer}${app !== null ? " (started by Leglas)" : health.reachable ? "" : " (not reachable)"}`
@@ -2594,6 +3448,8 @@ async function run2(options, deps) {
2594
3448
  export {
2595
3449
  AGENTS_MARKER_END,
2596
3450
  AGENTS_MARKER_START,
3451
+ DEFAULT_PORT,
3452
+ LEGLAS_PREFIX,
2597
3453
  PROMPT_TOKEN,
2598
3454
  WATCH_PATH,
2599
3455
  baselineFrom,