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/README.md +55 -6
- package/dist/bin.js +992 -204
- package/dist/index.d.ts +1 -1
- package/dist/index.js +992 -200
- package/dist/shell/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/shell/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/shell/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/shell/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/shell/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/shell/assets/index-BQHDjn0q.css +1 -0
- package/dist/shell/assets/index-T-uR7kT7.js +14 -0
- package/dist/shell/index.html +2 -2
- package/dist/watch.d.ts +2 -45
- package/package.json +3 -3
- package/dist/shell/assets/index-Bl87Vq9G.js +0 -10
- package/dist/shell/assets/index-dMGYwlE1.css +0 -1
- package/dist/shell/assets/manrope-cyrillic-wght-normal-Dvxsihut.woff2 +0 -0
- package/dist/shell/assets/manrope-greek-wght-normal-DL7QRZyv.woff2 +0 -0
- package/dist/shell/assets/manrope-latin-ext-wght-normal-Ch3YOpNY.woff2 +0 -0
- package/dist/shell/assets/manrope-latin-wght-normal-DHIcAJRg.woff2 +0 -0
- package/dist/shell/assets/manrope-vietnamese-wght-normal-usUDDRr7.woff2 +0 -0
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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,14 +1325,14 @@ async function loadConfig(cwd) {
|
|
|
971
1325
|
}
|
|
972
1326
|
|
|
973
1327
|
// ../server/dist/local-previews.js
|
|
974
|
-
import { mkdir, readFile as
|
|
975
|
-
import { dirname as
|
|
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 =
|
|
1332
|
+
const path = join3(cwd, LOCAL_PREVIEWS_PATH);
|
|
979
1333
|
let raw;
|
|
980
1334
|
try {
|
|
981
|
-
raw = await
|
|
1335
|
+
raw = await readFile3(path, "utf8");
|
|
982
1336
|
} catch {
|
|
983
1337
|
return { previews: [], errors: [] };
|
|
984
1338
|
}
|
|
@@ -1027,9 +1381,9 @@ async function addLocalPreview(cwd, input, shared) {
|
|
|
1027
1381
|
if (check.config === null) {
|
|
1028
1382
|
return { ok: false, error: check.errors.join(" ") };
|
|
1029
1383
|
}
|
|
1030
|
-
const path =
|
|
1031
|
-
await
|
|
1032
|
-
await
|
|
1384
|
+
const path = join3(cwd, LOCAL_PREVIEWS_PATH);
|
|
1385
|
+
await mkdir2(dirname3(path), { recursive: true });
|
|
1386
|
+
await writeFile2(path, `${JSON.stringify({ previews: [...existing.previews.map(toStored), candidate] }, null, 2)}
|
|
1033
1387
|
`, "utf8");
|
|
1034
1388
|
return { ok: true };
|
|
1035
1389
|
}
|
|
@@ -1042,9 +1396,9 @@ async function dropLocalPreviews(cwd, titles) {
|
|
|
1042
1396
|
const keep = existing.previews.filter((preview) => !titles.includes(preview.title));
|
|
1043
1397
|
if (keep.length === existing.previews.length)
|
|
1044
1398
|
return 0;
|
|
1045
|
-
const path =
|
|
1046
|
-
await
|
|
1047
|
-
await
|
|
1399
|
+
const path = join3(cwd, LOCAL_PREVIEWS_PATH);
|
|
1400
|
+
await mkdir2(dirname3(path), { recursive: true });
|
|
1401
|
+
await writeFile2(path, `${JSON.stringify({ previews: keep.map(toStored) }, null, 2)}
|
|
1048
1402
|
`, "utf8");
|
|
1049
1403
|
return existing.previews.length - keep.length;
|
|
1050
1404
|
}
|
|
@@ -1113,10 +1467,10 @@ ${headers}\r
|
|
|
1113
1467
|
}
|
|
1114
1468
|
|
|
1115
1469
|
// ../server/dist/worktree.js
|
|
1116
|
-
import { execFile, spawn } from "child_process";
|
|
1470
|
+
import { execFile, spawn as spawn2 } from "child_process";
|
|
1117
1471
|
import { rm } from "fs/promises";
|
|
1118
1472
|
import net2 from "net";
|
|
1119
|
-
import { join as
|
|
1473
|
+
import { join as join4 } from "path";
|
|
1120
1474
|
import { promisify } from "util";
|
|
1121
1475
|
var run = promisify(execFile);
|
|
1122
1476
|
var WORKTREES_DIR = ".leglas/worktrees";
|
|
@@ -1153,7 +1507,7 @@ function answers(port) {
|
|
|
1153
1507
|
}
|
|
1154
1508
|
async function startWorktree(options) {
|
|
1155
1509
|
const readyTimeoutMs = options.readyTimeoutMs ?? 9e4;
|
|
1156
|
-
const path =
|
|
1510
|
+
const path = join4(options.cwd, WORKTREES_DIR, worktreeSlug(options.branch));
|
|
1157
1511
|
const log = options.onLog ?? (() => {
|
|
1158
1512
|
});
|
|
1159
1513
|
await rm(path, { recursive: true, force: true });
|
|
@@ -1214,7 +1568,7 @@ async function startAppProcess(options) {
|
|
|
1214
1568
|
const port = await freePort();
|
|
1215
1569
|
let child;
|
|
1216
1570
|
try {
|
|
1217
|
-
child =
|
|
1571
|
+
child = spawn2(substitutePort(options.devCommand, port), {
|
|
1218
1572
|
cwd: options.cwd,
|
|
1219
1573
|
shell: true,
|
|
1220
1574
|
// Own process group, so stopping kills the shell and whatever it spawned
|
|
@@ -1254,9 +1608,9 @@ async function startAppProcess(options) {
|
|
|
1254
1608
|
}
|
|
1255
1609
|
|
|
1256
1610
|
// ../server/dist/requests.js
|
|
1257
|
-
import { mkdir as
|
|
1611
|
+
import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
1258
1612
|
import { randomBytes } from "crypto";
|
|
1259
|
-
import { dirname as
|
|
1613
|
+
import { dirname as dirname4, join as join5 } from "path";
|
|
1260
1614
|
var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
|
|
1261
1615
|
function targetFor(url) {
|
|
1262
1616
|
if (!url.startsWith("/"))
|
|
@@ -1282,17 +1636,20 @@ function composeRequest(preview, intent) {
|
|
|
1282
1636
|
const target = preview.file ?? targetFor(preview.url);
|
|
1283
1637
|
const cleaned = intent.trim();
|
|
1284
1638
|
const where = target === null ? `The direction is titled "${preview.title}" and renders at ${preview.url}. Find what produces it.` : `It lives at ${target}.`;
|
|
1639
|
+
const pace = target === null ? `Once found, make the change and finish. ` : `Make the change in that file and finish. `;
|
|
1285
1640
|
const prompt = `In this project, change only the "${preview.title}" design direction. ${where}
|
|
1286
1641
|
|
|
1287
1642
|
What to change: ${cleaned}
|
|
1288
1643
|
|
|
1644
|
+
${pace}This is a scoped design change: no test run, no build, and no survey of the rest of the project is needed. The result is checked visually in a live preview, not by tooling.
|
|
1645
|
+
|
|
1289
1646
|
Leave every other direction exactly as it is; they are alternatives being compared side by side, so changing a sibling destroys the comparison. The direction is already registered, so nothing needs re-registering. Keep the change additive: do not rewrite shared components that other directions rely on.`;
|
|
1290
1647
|
return { prompt, target };
|
|
1291
1648
|
}
|
|
1292
1649
|
var REQUESTS_PATH = ".leglas/requests.json";
|
|
1293
1650
|
async function readRequests(cwd) {
|
|
1294
1651
|
try {
|
|
1295
|
-
const raw = await
|
|
1652
|
+
const raw = await readFile4(join5(cwd, REQUESTS_PATH), "utf8");
|
|
1296
1653
|
const parsed = JSON.parse(raw);
|
|
1297
1654
|
if (!Array.isArray(parsed.requests))
|
|
1298
1655
|
return [];
|
|
@@ -1309,9 +1666,9 @@ async function readRequests(cwd) {
|
|
|
1309
1666
|
}
|
|
1310
1667
|
}
|
|
1311
1668
|
async function writeQueue(cwd, requests) {
|
|
1312
|
-
const path =
|
|
1313
|
-
await
|
|
1314
|
-
await
|
|
1669
|
+
const path = join5(cwd, REQUESTS_PATH);
|
|
1670
|
+
await mkdir3(dirname4(path), { recursive: true });
|
|
1671
|
+
await writeFile3(path, `${JSON.stringify({ requests }, null, 2)}
|
|
1315
1672
|
`, "utf8");
|
|
1316
1673
|
}
|
|
1317
1674
|
async function appendRequest(cwd, request) {
|
|
@@ -1351,13 +1708,264 @@ async function clearRequests(cwd) {
|
|
|
1351
1708
|
return { cleared, pending: pending.length };
|
|
1352
1709
|
}
|
|
1353
1710
|
|
|
1711
|
+
// ../server/dist/runner.js
|
|
1712
|
+
import { spawn as nodeSpawn } from "child_process";
|
|
1713
|
+
var POLL_MS = 2e3;
|
|
1714
|
+
var OUTPUT_LINES = 20;
|
|
1715
|
+
var SESSION_TURNS_CAP = 8;
|
|
1716
|
+
function resolveCommand(choice, prompt, sessionId = null) {
|
|
1717
|
+
if (choice.agent === null)
|
|
1718
|
+
return null;
|
|
1719
|
+
if (choice.agent === "custom") {
|
|
1720
|
+
if (choice.run === null)
|
|
1721
|
+
return null;
|
|
1722
|
+
const parsed = parseTemplate(choice.run);
|
|
1723
|
+
if (!parsed.ok)
|
|
1724
|
+
return null;
|
|
1725
|
+
return { agent: "custom", name: "Custom", ...commandFor(parsed.template, prompt), resumed: false };
|
|
1726
|
+
}
|
|
1727
|
+
const adapter = KNOWN_AGENTS[choice.agent];
|
|
1728
|
+
if (sessionId !== null && "resumeArgs" in adapter) {
|
|
1729
|
+
return {
|
|
1730
|
+
agent: choice.agent,
|
|
1731
|
+
name: adapter.name,
|
|
1732
|
+
command: adapter.binary,
|
|
1733
|
+
args: adapter.resumeArgs(sessionId, prompt),
|
|
1734
|
+
resumed: true
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
return {
|
|
1738
|
+
agent: choice.agent,
|
|
1739
|
+
name: adapter.name,
|
|
1740
|
+
command: adapter.binary,
|
|
1741
|
+
args: adapter.args(prompt),
|
|
1742
|
+
resumed: false
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
function lineReader(stream, onLine) {
|
|
1746
|
+
let buffered = "";
|
|
1747
|
+
const flush = () => {
|
|
1748
|
+
if (buffered === "")
|
|
1749
|
+
return;
|
|
1750
|
+
onLine(buffered.replace(/\r$/, ""));
|
|
1751
|
+
buffered = "";
|
|
1752
|
+
};
|
|
1753
|
+
stream.on("data", (chunk) => {
|
|
1754
|
+
buffered += chunk.toString();
|
|
1755
|
+
const lines = buffered.split("\n");
|
|
1756
|
+
buffered = lines.pop() ?? "";
|
|
1757
|
+
for (const line of lines)
|
|
1758
|
+
onLine(line.replace(/\r$/, ""));
|
|
1759
|
+
});
|
|
1760
|
+
stream.on("end", flush);
|
|
1761
|
+
return flush;
|
|
1762
|
+
}
|
|
1763
|
+
function defaultSpawn(command, args, options) {
|
|
1764
|
+
return nodeSpawn(command, args, options);
|
|
1765
|
+
}
|
|
1766
|
+
function startRunner(options) {
|
|
1767
|
+
const spawn4 = options.spawn ?? defaultSpawn;
|
|
1768
|
+
const setEvery = options.setInterval ?? ((callback, milliseconds) => setInterval(callback, milliseconds));
|
|
1769
|
+
const clearEvery = options.clearInterval ?? ((handle2) => clearInterval(handle2));
|
|
1770
|
+
const failed = /* @__PURE__ */ new Set();
|
|
1771
|
+
let state = {
|
|
1772
|
+
running: false,
|
|
1773
|
+
requestId: null,
|
|
1774
|
+
agent: null,
|
|
1775
|
+
activity: null,
|
|
1776
|
+
startedAt: null
|
|
1777
|
+
};
|
|
1778
|
+
let stopped = false;
|
|
1779
|
+
let ticking = null;
|
|
1780
|
+
let stopPromise = null;
|
|
1781
|
+
let active = null;
|
|
1782
|
+
const sessions = /* @__PURE__ */ new Map();
|
|
1783
|
+
const idle = () => {
|
|
1784
|
+
state = { running: false, requestId: null, agent: null, activity: null, startedAt: null };
|
|
1785
|
+
};
|
|
1786
|
+
const rememberLine = (lines, line) => {
|
|
1787
|
+
lines.push(line);
|
|
1788
|
+
if (lines.length > OUTPUT_LINES)
|
|
1789
|
+
lines.splice(0, lines.length - OUTPUT_LINES);
|
|
1790
|
+
};
|
|
1791
|
+
const reportFailure = (request, error, lines) => {
|
|
1792
|
+
console.error(`Leglas agent failed for ${request.title}: ${error}`);
|
|
1793
|
+
for (const line of lines)
|
|
1794
|
+
console.error(` ${line}`);
|
|
1795
|
+
};
|
|
1796
|
+
const runChild = (request, resolved, lines, observed) => {
|
|
1797
|
+
let child;
|
|
1798
|
+
try {
|
|
1799
|
+
child = spawn4(resolved.command, resolved.args, {
|
|
1800
|
+
cwd: options.cwd,
|
|
1801
|
+
shell: false,
|
|
1802
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1803
|
+
});
|
|
1804
|
+
} catch (error) {
|
|
1805
|
+
return Promise.resolve({
|
|
1806
|
+
ok: false,
|
|
1807
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
const current = { child, requestId: request.id, cancelled: false };
|
|
1811
|
+
active = current;
|
|
1812
|
+
const stdoutFlush = lineReader(child.stdout, (line) => {
|
|
1813
|
+
rememberLine(lines, line);
|
|
1814
|
+
const sessionId = sessionFrom(resolved.agent, line);
|
|
1815
|
+
if (sessionId !== null)
|
|
1816
|
+
observed.sessionId = sessionId;
|
|
1817
|
+
const activity = activityFrom(resolved.agent, line, options.cwd);
|
|
1818
|
+
if (activity !== null) {
|
|
1819
|
+
if (activity.startsWith("editing"))
|
|
1820
|
+
observed.edited = true;
|
|
1821
|
+
if (active === current)
|
|
1822
|
+
state = { ...state, activity };
|
|
1823
|
+
}
|
|
1824
|
+
});
|
|
1825
|
+
const stderrFlush = lineReader(child.stderr, (line) => rememberLine(lines, line));
|
|
1826
|
+
return new Promise((resolve) => {
|
|
1827
|
+
let settled = false;
|
|
1828
|
+
const settle = (outcome) => {
|
|
1829
|
+
if (settled)
|
|
1830
|
+
return;
|
|
1831
|
+
settled = true;
|
|
1832
|
+
stdoutFlush();
|
|
1833
|
+
stderrFlush();
|
|
1834
|
+
resolve(outcome);
|
|
1835
|
+
};
|
|
1836
|
+
child.once("error", (error) => settle({ ok: false, error: error.message }));
|
|
1837
|
+
child.once("close", (code, signal) => {
|
|
1838
|
+
if (current.cancelled)
|
|
1839
|
+
return settle({ ok: false, error: "cancelled" });
|
|
1840
|
+
if (signal !== null)
|
|
1841
|
+
return settle({ ok: false, error: `stopped by ${signal}` });
|
|
1842
|
+
settle({ ok: true, code: code ?? 0 });
|
|
1843
|
+
});
|
|
1844
|
+
}).finally(() => {
|
|
1845
|
+
if (active === current)
|
|
1846
|
+
active = null;
|
|
1847
|
+
});
|
|
1848
|
+
};
|
|
1849
|
+
const handle = async (request, choice) => {
|
|
1850
|
+
const session = choice.agent !== null ? sessions.get(choice.agent) ?? null : null;
|
|
1851
|
+
const continuable = session !== null && session.turns < SESSION_TURNS_CAP;
|
|
1852
|
+
let resolved = resolveCommand(choice, request.prompt, continuable ? session.id : null);
|
|
1853
|
+
if (resolved === null)
|
|
1854
|
+
return;
|
|
1855
|
+
const lines = [];
|
|
1856
|
+
try {
|
|
1857
|
+
if (!await markPickedUp(options.cwd, request.id))
|
|
1858
|
+
return;
|
|
1859
|
+
if (stopped) {
|
|
1860
|
+
failed.add(request.id);
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
state = {
|
|
1864
|
+
running: true,
|
|
1865
|
+
requestId: request.id,
|
|
1866
|
+
agent: resolved.name,
|
|
1867
|
+
activity: null,
|
|
1868
|
+
startedAt: Date.now()
|
|
1869
|
+
};
|
|
1870
|
+
const observed = { sessionId: null, edited: false };
|
|
1871
|
+
let outcome = await runChild(request, resolved, lines, observed);
|
|
1872
|
+
const cancelled = !outcome.ok && outcome.error === "cancelled";
|
|
1873
|
+
if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && !cancelled && // Not redundant with the line above: a stop that lands between the
|
|
1874
|
+
// first child settling and the retry starting finds no child to
|
|
1875
|
+
// cancel, so nothing says "cancelled". Stopped still means stopped.
|
|
1876
|
+
!stopped) {
|
|
1877
|
+
sessions.delete(resolved.agent);
|
|
1878
|
+
const cold = resolveCommand(choice, request.prompt);
|
|
1879
|
+
if (cold !== null) {
|
|
1880
|
+
resolved = cold;
|
|
1881
|
+
observed.sessionId = null;
|
|
1882
|
+
state = { ...state, activity: null };
|
|
1883
|
+
outcome = await runChild(request, resolved, lines, observed);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
if (outcome.ok && outcome.code === 0) {
|
|
1887
|
+
if (observed.sessionId !== null) {
|
|
1888
|
+
const previous = sessions.get(resolved.agent);
|
|
1889
|
+
sessions.set(resolved.agent, {
|
|
1890
|
+
id: observed.sessionId,
|
|
1891
|
+
turns: resolved.resumed && previous?.id === observed.sessionId ? previous.turns + 1 : 1
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
await removeRequest(options.cwd, request.id);
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
sessions.delete(resolved.agent);
|
|
1898
|
+
failed.add(request.id);
|
|
1899
|
+
reportFailure(request, outcome.ok ? `${resolved.command} exited ${outcome.code}` : outcome.error, lines);
|
|
1900
|
+
} finally {
|
|
1901
|
+
idle();
|
|
1902
|
+
}
|
|
1903
|
+
};
|
|
1904
|
+
const tick = async () => {
|
|
1905
|
+
if (stopped)
|
|
1906
|
+
return;
|
|
1907
|
+
const choice = await readAgentChoice(options.cwd);
|
|
1908
|
+
if (choice.agent === null || stopped)
|
|
1909
|
+
return;
|
|
1910
|
+
if (options.externallyAttached())
|
|
1911
|
+
return;
|
|
1912
|
+
const request = nextRequest(await readRequests(options.cwd), failed);
|
|
1913
|
+
if (request !== null && !stopped)
|
|
1914
|
+
await handle(request, choice);
|
|
1915
|
+
};
|
|
1916
|
+
const schedule = () => {
|
|
1917
|
+
if (stopped || ticking !== null)
|
|
1918
|
+
return;
|
|
1919
|
+
const task = tick();
|
|
1920
|
+
ticking = task;
|
|
1921
|
+
void task.catch((error) => console.error(`Leglas runner: ${error instanceof Error ? error.message : String(error)}`)).finally(() => {
|
|
1922
|
+
if (ticking === task)
|
|
1923
|
+
ticking = null;
|
|
1924
|
+
});
|
|
1925
|
+
};
|
|
1926
|
+
const timer = setEvery(schedule, POLL_MS);
|
|
1927
|
+
schedule();
|
|
1928
|
+
const cancel = (id) => {
|
|
1929
|
+
if (active === null || active.cancelled)
|
|
1930
|
+
return false;
|
|
1931
|
+
if (id !== void 0 && active.requestId !== id)
|
|
1932
|
+
return false;
|
|
1933
|
+
active.cancelled = true;
|
|
1934
|
+
failed.add(active.requestId);
|
|
1935
|
+
try {
|
|
1936
|
+
active.child.kill("SIGTERM");
|
|
1937
|
+
} catch {
|
|
1938
|
+
}
|
|
1939
|
+
return true;
|
|
1940
|
+
};
|
|
1941
|
+
const stop = () => {
|
|
1942
|
+
if (stopPromise !== null)
|
|
1943
|
+
return stopPromise;
|
|
1944
|
+
stopped = true;
|
|
1945
|
+
clearEvery(timer);
|
|
1946
|
+
cancel();
|
|
1947
|
+
stopPromise = Promise.resolve(ticking).catch(() => {
|
|
1948
|
+
}).then(() => {
|
|
1949
|
+
});
|
|
1950
|
+
return stopPromise;
|
|
1951
|
+
};
|
|
1952
|
+
return {
|
|
1953
|
+
stop,
|
|
1954
|
+
snapshot: () => ({ ...state, failedIds: [...failed] }),
|
|
1955
|
+
cancel,
|
|
1956
|
+
// schedule already refuses to overlap a tick in flight, so a nudge during
|
|
1957
|
+
// a run costs nothing and a nudge between runs starts the next one now.
|
|
1958
|
+
nudge: schedule
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1354
1962
|
// ../server/dist/renames.js
|
|
1355
|
-
import { mkdir as
|
|
1356
|
-
import { dirname as
|
|
1963
|
+
import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
|
|
1964
|
+
import { dirname as dirname5, join as join6 } from "path";
|
|
1357
1965
|
var RENAMES_PATH = ".leglas/renames.json";
|
|
1358
1966
|
async function readRenames(cwd) {
|
|
1359
1967
|
try {
|
|
1360
|
-
const raw = await
|
|
1968
|
+
const raw = await readFile5(join6(cwd, RENAMES_PATH), "utf8");
|
|
1361
1969
|
const parsed = JSON.parse(raw);
|
|
1362
1970
|
if (parsed.renames === null || typeof parsed.renames !== "object")
|
|
1363
1971
|
return {};
|
|
@@ -1367,9 +1975,9 @@ async function readRenames(cwd) {
|
|
|
1367
1975
|
}
|
|
1368
1976
|
}
|
|
1369
1977
|
async function writeRenames(cwd, renames) {
|
|
1370
|
-
const path =
|
|
1371
|
-
await
|
|
1372
|
-
await
|
|
1978
|
+
const path = join6(cwd, RENAMES_PATH);
|
|
1979
|
+
await mkdir4(dirname5(path), { recursive: true });
|
|
1980
|
+
await writeFile4(path, `${JSON.stringify({ renames }, null, 2)}
|
|
1373
1981
|
`, "utf8");
|
|
1374
1982
|
}
|
|
1375
1983
|
function resolveTitle(input, titles, renames) {
|
|
@@ -1387,7 +1995,7 @@ function resolveTitle(input, titles, renames) {
|
|
|
1387
1995
|
import { createReadStream, existsSync as existsSync2, statSync } from "fs";
|
|
1388
1996
|
import http2 from "http";
|
|
1389
1997
|
import net3 from "net";
|
|
1390
|
-
import { extname, join as
|
|
1998
|
+
import { extname, join as join7, normalize, relative as relative3 } from "path";
|
|
1391
1999
|
var LEGLAS_PREFIX = "/leglas";
|
|
1392
2000
|
var DEFAULT_PORT = 4100;
|
|
1393
2001
|
var PORT_ATTEMPTS = 20;
|
|
@@ -1418,6 +2026,52 @@ function sendJson(res, status, body) {
|
|
|
1418
2026
|
});
|
|
1419
2027
|
res.end(payload);
|
|
1420
2028
|
}
|
|
2029
|
+
function isKnownAgent(value) {
|
|
2030
|
+
return typeof value === "string" && Object.hasOwn(KNOWN_AGENTS, value);
|
|
2031
|
+
}
|
|
2032
|
+
function isAllowedMutationHost(hostname) {
|
|
2033
|
+
const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
2034
|
+
if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1")
|
|
2035
|
+
return true;
|
|
2036
|
+
if (bare.endsWith(".local"))
|
|
2037
|
+
return true;
|
|
2038
|
+
if (!net3.isIPv4(bare))
|
|
2039
|
+
return false;
|
|
2040
|
+
const [first, second] = bare.split(".").map(Number);
|
|
2041
|
+
return first === 10 || first === 172 && second !== void 0 && second >= 16 && second <= 31 || first === 192 && second === 168;
|
|
2042
|
+
}
|
|
2043
|
+
function isLoopbackAddress(address) {
|
|
2044
|
+
if (address === void 0)
|
|
2045
|
+
return false;
|
|
2046
|
+
return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1" || address.startsWith("127.");
|
|
2047
|
+
}
|
|
2048
|
+
function isTrustedMutation(req) {
|
|
2049
|
+
if (!isLoopbackAddress(req.socket.remoteAddress))
|
|
2050
|
+
return false;
|
|
2051
|
+
if (typeof req.headers.host !== "string")
|
|
2052
|
+
return false;
|
|
2053
|
+
let host;
|
|
2054
|
+
try {
|
|
2055
|
+
host = new URL(`http://${req.headers.host}`);
|
|
2056
|
+
} catch {
|
|
2057
|
+
return false;
|
|
2058
|
+
}
|
|
2059
|
+
if (!isAllowedMutationHost(host.hostname))
|
|
2060
|
+
return false;
|
|
2061
|
+
const rawOrigin = req.headers.origin;
|
|
2062
|
+
if (rawOrigin === void 0)
|
|
2063
|
+
return true;
|
|
2064
|
+
try {
|
|
2065
|
+
const origin = new URL(rawOrigin);
|
|
2066
|
+
return origin.protocol === "http:" && origin.host === host.host;
|
|
2067
|
+
} catch {
|
|
2068
|
+
return false;
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
function hasJsonBody(req) {
|
|
2072
|
+
const contentType = req.headers["content-type"];
|
|
2073
|
+
return typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
|
|
2074
|
+
}
|
|
1421
2075
|
function probe(target, timeoutMs = 1e3) {
|
|
1422
2076
|
return new Promise((resolve) => {
|
|
1423
2077
|
let url;
|
|
@@ -1439,8 +2093,8 @@ function probe(target, timeoutMs = 1e3) {
|
|
|
1439
2093
|
});
|
|
1440
2094
|
}
|
|
1441
2095
|
function serveFrom(res, dir, relativePath) {
|
|
1442
|
-
const
|
|
1443
|
-
const candidate =
|
|
2096
|
+
const relative5 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
|
|
2097
|
+
const candidate = join7(dir, relative5);
|
|
1444
2098
|
if (!candidate.startsWith(dir))
|
|
1445
2099
|
return false;
|
|
1446
2100
|
if (!existsSync2(candidate) || !statSync(candidate).isFile())
|
|
@@ -1453,9 +2107,9 @@ function serveFrom(res, dir, relativePath) {
|
|
|
1453
2107
|
return true;
|
|
1454
2108
|
}
|
|
1455
2109
|
function serveShellFile(res, shellDir, urlPath) {
|
|
1456
|
-
const
|
|
1457
|
-
const isRoot =
|
|
1458
|
-
return serveFrom(res, shellDir, isRoot ? "index.html" :
|
|
2110
|
+
const relative5 = normalize(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
|
|
2111
|
+
const isRoot = relative5 === "" || relative5 === "." || relative5 === "/";
|
|
2112
|
+
return serveFrom(res, shellDir, isRoot ? "index.html" : relative5);
|
|
1459
2113
|
}
|
|
1460
2114
|
function snapshotConfig(cwd) {
|
|
1461
2115
|
const path = findConfigFile(cwd);
|
|
@@ -1471,15 +2125,15 @@ function configStalenessNotice(cwd, boot, current) {
|
|
|
1471
2125
|
if (boot === null && current === null)
|
|
1472
2126
|
return null;
|
|
1473
2127
|
if (boot === null && current !== null) {
|
|
1474
|
-
const label =
|
|
2128
|
+
const label = relative3(cwd, current.path) || current.path;
|
|
1475
2129
|
return `${label} appeared after Leglas started. Restart leglas to pick it up.`;
|
|
1476
2130
|
}
|
|
1477
2131
|
if (boot !== null && current === null) {
|
|
1478
|
-
const label =
|
|
2132
|
+
const label = relative3(cwd, boot.path) || boot.path;
|
|
1479
2133
|
return `${label} was removed after Leglas started. Restart leglas to run without it.`;
|
|
1480
2134
|
}
|
|
1481
2135
|
if (boot !== null && current !== null && (boot.path !== current.path || boot.mtimeMs !== current.mtimeMs)) {
|
|
1482
|
-
const label =
|
|
2136
|
+
const label = relative3(cwd, current.path) || current.path;
|
|
1483
2137
|
return `${label} changed after Leglas started. Restart leglas to pick it up.`;
|
|
1484
2138
|
}
|
|
1485
2139
|
return null;
|
|
@@ -1525,14 +2179,40 @@ async function bind(server, requested) {
|
|
|
1525
2179
|
throw new Error(`No free port between ${requested} and ${requested + PORT_ATTEMPTS - 1}.`);
|
|
1526
2180
|
}
|
|
1527
2181
|
async function startServer(options) {
|
|
1528
|
-
const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map() } = options;
|
|
2182
|
+
const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
|
|
1529
2183
|
const target = config?.devServer ?? "http://localhost:3000";
|
|
1530
2184
|
const proxy = createProxyHandler({ target });
|
|
1531
2185
|
const bootConfigSnapshot = snapshotConfig(cwd);
|
|
1532
2186
|
let lastSeen = null;
|
|
2187
|
+
const externallyAttached = () => lastSeen !== null && Date.now() - lastSeen < ATTACHED_WINDOW_MS;
|
|
2188
|
+
let runner = null;
|
|
2189
|
+
let agentsCache = null;
|
|
2190
|
+
let agentsInflight = null;
|
|
2191
|
+
const AGENTS_FRESH_MS = 3e4;
|
|
2192
|
+
const probeAgents = () => {
|
|
2193
|
+
agentsInflight ??= detect().then((agents) => {
|
|
2194
|
+
agentsCache = { at: Date.now(), agents };
|
|
2195
|
+
return agents;
|
|
2196
|
+
}).finally(() => {
|
|
2197
|
+
agentsInflight = null;
|
|
2198
|
+
});
|
|
2199
|
+
return agentsInflight;
|
|
2200
|
+
};
|
|
2201
|
+
const currentAgents = () => {
|
|
2202
|
+
if (agentsCache === null)
|
|
2203
|
+
return probeAgents();
|
|
2204
|
+
if (Date.now() - agentsCache.at > AGENTS_FRESH_MS) {
|
|
2205
|
+
void probeAgents().catch(() => {
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
return Promise.resolve(agentsCache.agents);
|
|
2209
|
+
};
|
|
1533
2210
|
const server = http2.createServer((req, res) => {
|
|
1534
2211
|
const url = req.url ?? "/";
|
|
1535
2212
|
const path = url.split("?")[0] ?? "/";
|
|
2213
|
+
if (req.method === "POST" && path.startsWith(`${LEGLAS_PREFIX}/api/`) && !isTrustedMutation(req)) {
|
|
2214
|
+
return sendJson(res, 403, { ok: false, error: "Cross-origin API mutations are refused." });
|
|
2215
|
+
}
|
|
1536
2216
|
if (path === `${LEGLAS_PREFIX}/api/config`) {
|
|
1537
2217
|
const boot = config?.previews ?? [];
|
|
1538
2218
|
const errors = [...configErrors];
|
|
@@ -1576,7 +2256,54 @@ async function startServer(options) {
|
|
|
1576
2256
|
url: preview.url,
|
|
1577
2257
|
intent: parsed.intent.trim(),
|
|
1578
2258
|
...composed
|
|
1579
|
-
}).then(() =>
|
|
2259
|
+
}).then(() => {
|
|
2260
|
+
runner?.nudge();
|
|
2261
|
+
sendJson(res, 200, { ok: true, ...composed });
|
|
2262
|
+
}).catch(() => sendJson(res, 200, { ok: true, ...composed, queued: false }));
|
|
2263
|
+
});
|
|
2264
|
+
}
|
|
2265
|
+
if (path === `${LEGLAS_PREFIX}/api/agents` && req.method === "GET") {
|
|
2266
|
+
return void Promise.all([currentAgents(), readAgentChoice(cwd)]).then(([agents, choice]) => sendJson(res, 200, {
|
|
2267
|
+
agents,
|
|
2268
|
+
choice: choice.agent,
|
|
2269
|
+
customRun: choice.run
|
|
2270
|
+
}));
|
|
2271
|
+
}
|
|
2272
|
+
if (path === `${LEGLAS_PREFIX}/api/agent` && req.method === "POST") {
|
|
2273
|
+
if (!isLoopbackAddress(req.socket.remoteAddress)) {
|
|
2274
|
+
return sendJson(res, 403, {
|
|
2275
|
+
ok: false,
|
|
2276
|
+
error: "The agent choice can only be made from the machine running Leglas."
|
|
2277
|
+
});
|
|
2278
|
+
}
|
|
2279
|
+
if (!hasJsonBody(req)) {
|
|
2280
|
+
return sendJson(res, 400, { ok: false, error: "Agent choice must be JSON." });
|
|
2281
|
+
}
|
|
2282
|
+
let body = "";
|
|
2283
|
+
req.on("data", (chunk) => body += chunk);
|
|
2284
|
+
return void req.on("end", () => {
|
|
2285
|
+
let parsed;
|
|
2286
|
+
try {
|
|
2287
|
+
parsed = JSON.parse(body || "{}");
|
|
2288
|
+
} catch {
|
|
2289
|
+
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
2290
|
+
}
|
|
2291
|
+
if (!isKnownAgent(parsed.agent) && parsed.agent !== "custom") {
|
|
2292
|
+
return sendJson(res, 400, { ok: false, error: "Body needs a known agent." });
|
|
2293
|
+
}
|
|
2294
|
+
if (parsed.run !== void 0 && typeof parsed.run !== "string") {
|
|
2295
|
+
return sendJson(res, 400, { ok: false, error: "The custom run command must be a string." });
|
|
2296
|
+
}
|
|
2297
|
+
if (parsed.agent === "custom") {
|
|
2298
|
+
if (typeof parsed.run !== "string") {
|
|
2299
|
+
return sendJson(res, 400, { ok: false, error: "A custom agent needs a run command." });
|
|
2300
|
+
}
|
|
2301
|
+
const template = parseTemplate(parsed.run);
|
|
2302
|
+
if (!template.ok)
|
|
2303
|
+
return sendJson(res, 400, { ok: false, error: template.error });
|
|
2304
|
+
return void saveAgentChoice(cwd, { agent: "custom", run: parsed.run }).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 500, { ok: false, error: "Agent choice could not be saved." }));
|
|
2305
|
+
}
|
|
2306
|
+
return void saveAgentChoice(cwd, { agent: parsed.agent }).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 500, { ok: false, error: "Agent choice could not be saved." }));
|
|
1580
2307
|
});
|
|
1581
2308
|
}
|
|
1582
2309
|
if (path === `${LEGLAS_PREFIX}/api/watch` && req.method === "POST") {
|
|
@@ -1597,11 +2324,119 @@ async function startServer(options) {
|
|
|
1597
2324
|
});
|
|
1598
2325
|
}
|
|
1599
2326
|
if (path === `${LEGLAS_PREFIX}/api/requests` && req.method === "GET") {
|
|
2327
|
+
const snapshot = runner?.snapshot() ?? {
|
|
2328
|
+
running: false,
|
|
2329
|
+
requestId: null,
|
|
2330
|
+
agent: null,
|
|
2331
|
+
activity: null,
|
|
2332
|
+
startedAt: null,
|
|
2333
|
+
failedIds: []
|
|
2334
|
+
};
|
|
1600
2335
|
return void readRequests(cwd).then((requests) => sendJson(res, 200, {
|
|
1601
|
-
requests: requests.map(({ id, title, intent, status }) => ({
|
|
1602
|
-
|
|
2336
|
+
requests: requests.map(({ id, title, intent, status }) => ({
|
|
2337
|
+
id,
|
|
2338
|
+
title,
|
|
2339
|
+
intent,
|
|
2340
|
+
status: snapshot.running && snapshot.requestId === id ? "running" : snapshot.failedIds.includes(id) ? "failed" : status
|
|
2341
|
+
})),
|
|
2342
|
+
agent: {
|
|
2343
|
+
attached: externallyAttached(),
|
|
2344
|
+
running: snapshot.running,
|
|
2345
|
+
name: snapshot.running ? snapshot.agent : null,
|
|
2346
|
+
activity: snapshot.running ? snapshot.activity : null,
|
|
2347
|
+
startedAt: snapshot.running ? snapshot.startedAt : null
|
|
2348
|
+
}
|
|
1603
2349
|
}));
|
|
1604
2350
|
}
|
|
2351
|
+
if (path === `${LEGLAS_PREFIX}/api/requests/cancel` && req.method === "POST") {
|
|
2352
|
+
if (!hasJsonBody(req)) {
|
|
2353
|
+
return sendJson(res, 200, { ok: true, cancelled: runner?.cancel() ?? false });
|
|
2354
|
+
}
|
|
2355
|
+
let body = "";
|
|
2356
|
+
req.on("data", (chunk) => body += chunk);
|
|
2357
|
+
return void req.on("end", () => {
|
|
2358
|
+
let parsed;
|
|
2359
|
+
try {
|
|
2360
|
+
parsed = JSON.parse(body || "{}");
|
|
2361
|
+
} catch {
|
|
2362
|
+
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
2363
|
+
}
|
|
2364
|
+
if (parsed.id !== void 0 && typeof parsed.id !== "string") {
|
|
2365
|
+
return sendJson(res, 400, { ok: false, error: "The request id must be a string." });
|
|
2366
|
+
}
|
|
2367
|
+
return sendJson(res, 200, { ok: true, cancelled: runner?.cancel(parsed.id) ?? false });
|
|
2368
|
+
});
|
|
2369
|
+
}
|
|
2370
|
+
if (path === `${LEGLAS_PREFIX}/api/requests/retry` && req.method === "POST") {
|
|
2371
|
+
if (!hasJsonBody(req)) {
|
|
2372
|
+
return sendJson(res, 400, { ok: false, error: "Retry must be JSON." });
|
|
2373
|
+
}
|
|
2374
|
+
let body = "";
|
|
2375
|
+
req.on("data", (chunk) => body += chunk);
|
|
2376
|
+
return void req.on("end", async () => {
|
|
2377
|
+
let parsed;
|
|
2378
|
+
try {
|
|
2379
|
+
parsed = JSON.parse(body || "{}");
|
|
2380
|
+
} catch {
|
|
2381
|
+
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
2382
|
+
}
|
|
2383
|
+
if (typeof parsed.id !== "string") {
|
|
2384
|
+
return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
|
|
2385
|
+
}
|
|
2386
|
+
const request = (await readRequests(cwd)).find((entry) => entry.id === parsed.id);
|
|
2387
|
+
if (request === void 0) {
|
|
2388
|
+
return sendJson(res, 404, { ok: false, error: "No such request." });
|
|
2389
|
+
}
|
|
2390
|
+
if (!(runner?.snapshot().failedIds.includes(request.id) ?? false)) {
|
|
2391
|
+
return sendJson(res, 400, { ok: false, error: "Only a failed request can be retried." });
|
|
2392
|
+
}
|
|
2393
|
+
try {
|
|
2394
|
+
if (!await removeRequest(cwd, request.id)) {
|
|
2395
|
+
return sendJson(res, 404, { ok: false, error: "No such request." });
|
|
2396
|
+
}
|
|
2397
|
+
await appendRequest(cwd, {
|
|
2398
|
+
title: request.title,
|
|
2399
|
+
url: request.url,
|
|
2400
|
+
intent: request.intent,
|
|
2401
|
+
target: request.target,
|
|
2402
|
+
prompt: request.prompt
|
|
2403
|
+
});
|
|
2404
|
+
runner?.nudge();
|
|
2405
|
+
return sendJson(res, 200, { ok: true });
|
|
2406
|
+
} catch {
|
|
2407
|
+
return sendJson(res, 500, { ok: false, error: "The request could not be retried." });
|
|
2408
|
+
}
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
if (path === `${LEGLAS_PREFIX}/api/requests/dismiss` && req.method === "POST") {
|
|
2412
|
+
if (!hasJsonBody(req)) {
|
|
2413
|
+
return sendJson(res, 400, { ok: false, error: "Dismiss must be JSON." });
|
|
2414
|
+
}
|
|
2415
|
+
let body = "";
|
|
2416
|
+
req.on("data", (chunk) => body += chunk);
|
|
2417
|
+
return void req.on("end", async () => {
|
|
2418
|
+
let parsed;
|
|
2419
|
+
try {
|
|
2420
|
+
parsed = JSON.parse(body || "{}");
|
|
2421
|
+
} catch {
|
|
2422
|
+
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
2423
|
+
}
|
|
2424
|
+
if (typeof parsed.id !== "string") {
|
|
2425
|
+
return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
|
|
2426
|
+
}
|
|
2427
|
+
if (!(runner?.snapshot().failedIds.includes(parsed.id) ?? false)) {
|
|
2428
|
+
return sendJson(res, 400, { ok: false, error: "Only a failed request can be dismissed." });
|
|
2429
|
+
}
|
|
2430
|
+
try {
|
|
2431
|
+
if (!await removeRequest(cwd, parsed.id)) {
|
|
2432
|
+
return sendJson(res, 404, { ok: false, error: "No such request." });
|
|
2433
|
+
}
|
|
2434
|
+
return sendJson(res, 200, { ok: true });
|
|
2435
|
+
} catch {
|
|
2436
|
+
return sendJson(res, 500, { ok: false, error: "The request could not be dismissed." });
|
|
2437
|
+
}
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
1605
2440
|
if (path === `${LEGLAS_PREFIX}/api/renames` && req.method === "POST") {
|
|
1606
2441
|
let body = "";
|
|
1607
2442
|
req.on("data", (chunk) => body += chunk);
|
|
@@ -1626,14 +2461,14 @@ async function startServer(options) {
|
|
|
1626
2461
|
const rest = path.slice(FILES_PREFIX.length + 1);
|
|
1627
2462
|
const slash = rest.indexOf("/");
|
|
1628
2463
|
const slug = slash === -1 ? rest : rest.slice(0, slash);
|
|
1629
|
-
let
|
|
2464
|
+
let relative5 = slash === -1 ? "" : rest.slice(slash + 1);
|
|
1630
2465
|
try {
|
|
1631
|
-
|
|
2466
|
+
relative5 = decodeURIComponent(relative5);
|
|
1632
2467
|
} catch {
|
|
1633
|
-
|
|
2468
|
+
relative5 = "";
|
|
1634
2469
|
}
|
|
1635
2470
|
const dir = fileMounts.get(slug);
|
|
1636
|
-
if (dir !== void 0 &&
|
|
2471
|
+
if (dir !== void 0 && relative5 !== "" && serveFrom(res, dir, relative5))
|
|
1637
2472
|
return;
|
|
1638
2473
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
|
1639
2474
|
return res.end("Leglas: no such preview file.");
|
|
@@ -1665,16 +2500,23 @@ async function startServer(options) {
|
|
|
1665
2500
|
proxy.upgrade(req, socket, head);
|
|
1666
2501
|
});
|
|
1667
2502
|
const port = await bind(server, options.port ?? DEFAULT_PORT);
|
|
2503
|
+
runner = startRunner({ cwd, externallyAttached });
|
|
2504
|
+
let closePromise = null;
|
|
1668
2505
|
return {
|
|
1669
2506
|
port,
|
|
1670
2507
|
url: `http://localhost:${port}`,
|
|
1671
|
-
close: () =>
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
2508
|
+
close: () => {
|
|
2509
|
+
if (closePromise !== null)
|
|
2510
|
+
return closePromise;
|
|
2511
|
+
closePromise = runner.stop().then(() => new Promise((done) => {
|
|
2512
|
+
for (const socket of sockets)
|
|
2513
|
+
socket.destroy();
|
|
2514
|
+
sockets.clear();
|
|
2515
|
+
server.closeAllConnections();
|
|
2516
|
+
server.close(() => done());
|
|
2517
|
+
}));
|
|
2518
|
+
return closePromise;
|
|
2519
|
+
}
|
|
1678
2520
|
};
|
|
1679
2521
|
}
|
|
1680
2522
|
|
|
@@ -1733,11 +2575,11 @@ function planKeep(options) {
|
|
|
1733
2575
|
}
|
|
1734
2576
|
|
|
1735
2577
|
// src/run-init.ts
|
|
1736
|
-
import { readFile as
|
|
1737
|
-
import { join as
|
|
2578
|
+
import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
|
|
2579
|
+
import { join as join8 } from "path";
|
|
1738
2580
|
async function readIfPresent(path) {
|
|
1739
2581
|
try {
|
|
1740
|
-
return await
|
|
2582
|
+
return await readFile6(path, "utf8");
|
|
1741
2583
|
} catch {
|
|
1742
2584
|
return null;
|
|
1743
2585
|
}
|
|
@@ -1745,18 +2587,18 @@ async function readIfPresent(path) {
|
|
|
1745
2587
|
async function runInit(options, deps) {
|
|
1746
2588
|
const existingConfig = findConfigFile(options.cwd);
|
|
1747
2589
|
const plan = planInit({
|
|
1748
|
-
agents: await readIfPresent(
|
|
2590
|
+
agents: await readIfPresent(join8(options.cwd, "AGENTS.md")),
|
|
1749
2591
|
config: existingConfig === null ? null : "present",
|
|
1750
|
-
gitignore: await readIfPresent(
|
|
2592
|
+
gitignore: await readIfPresent(join8(options.cwd, ".gitignore")),
|
|
1751
2593
|
force: options.force
|
|
1752
2594
|
});
|
|
1753
2595
|
const touched = [];
|
|
1754
2596
|
for (const write of plan.writes) {
|
|
1755
|
-
await
|
|
2597
|
+
await writeFile5(join8(options.cwd, write.path), write.contents, "utf8");
|
|
1756
2598
|
touched.push(write.path);
|
|
1757
2599
|
}
|
|
1758
2600
|
if (plan.gitignore !== null) {
|
|
1759
|
-
await
|
|
2601
|
+
await writeFile5(join8(options.cwd, ".gitignore"), plan.gitignore, "utf8");
|
|
1760
2602
|
touched.push(".gitignore");
|
|
1761
2603
|
}
|
|
1762
2604
|
if (options.json) {
|
|
@@ -1776,8 +2618,8 @@ async function runInit(options, deps) {
|
|
|
1776
2618
|
|
|
1777
2619
|
// src/run-keep.ts
|
|
1778
2620
|
import { existsSync as existsSync3 } from "fs";
|
|
1779
|
-
import { mkdir as
|
|
1780
|
-
import { dirname as
|
|
2621
|
+
import { mkdir as mkdir5, readFile as readFile7, rm as rm2, writeFile as writeFile6 } from "fs/promises";
|
|
2622
|
+
import { dirname as dirname6, join as join9 } from "path";
|
|
1781
2623
|
|
|
1782
2624
|
// src/resolve-title.ts
|
|
1783
2625
|
function resolveOrExplain(input, titles, renames) {
|
|
@@ -1821,18 +2663,18 @@ async function runKeep(options, deps) {
|
|
|
1821
2663
|
if (!resolved.ok) return fail(resolved.error);
|
|
1822
2664
|
const plan = planKeep({ title: resolved.title, previews, to: options.to });
|
|
1823
2665
|
if (!plan.ok) return fail(plan.error);
|
|
1824
|
-
const from =
|
|
1825
|
-
const to =
|
|
2666
|
+
const from = join9(options.cwd, plan.move.from);
|
|
2667
|
+
const to = join9(options.cwd, plan.move.to);
|
|
1826
2668
|
if (!existsSync3(from)) {
|
|
1827
2669
|
return fail(`${plan.move.from} does not exist. Nothing to keep.`);
|
|
1828
2670
|
}
|
|
1829
2671
|
if (existsSync3(to)) {
|
|
1830
2672
|
return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
|
|
1831
2673
|
}
|
|
1832
|
-
const source = await
|
|
1833
|
-
await
|
|
1834
|
-
await
|
|
1835
|
-
await rm2(
|
|
2674
|
+
const source = await readFile7(from, "utf8");
|
|
2675
|
+
await mkdir5(dirname6(to), { recursive: true });
|
|
2676
|
+
await writeFile6(to, renameExport(source, plan.exportName), "utf8");
|
|
2677
|
+
await rm2(join9(options.cwd, plan.removeDir), { recursive: true, force: true });
|
|
1836
2678
|
const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
|
|
1837
2679
|
if (options.json) {
|
|
1838
2680
|
deps.log(
|
|
@@ -1867,11 +2709,11 @@ async function runKeep(options, deps) {
|
|
|
1867
2709
|
|
|
1868
2710
|
// src/run-new.ts
|
|
1869
2711
|
import { existsSync as existsSync4 } from "fs";
|
|
1870
|
-
import { mkdir as
|
|
1871
|
-
import { dirname as
|
|
2712
|
+
import { mkdir as mkdir6, readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
|
|
2713
|
+
import { dirname as dirname7, join as join10 } from "path";
|
|
1872
2714
|
async function readIfPresent2(path) {
|
|
1873
2715
|
try {
|
|
1874
|
-
return await
|
|
2716
|
+
return await readFile8(path, "utf8");
|
|
1875
2717
|
} catch {
|
|
1876
2718
|
return null;
|
|
1877
2719
|
}
|
|
@@ -1879,7 +2721,7 @@ async function readIfPresent2(path) {
|
|
|
1879
2721
|
async function runNew(options, deps) {
|
|
1880
2722
|
let from;
|
|
1881
2723
|
if (options.from !== void 0) {
|
|
1882
|
-
const contents = await readIfPresent2(
|
|
2724
|
+
const contents = await readIfPresent2(join10(options.cwd, options.from));
|
|
1883
2725
|
if (contents === null) {
|
|
1884
2726
|
const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
|
|
1885
2727
|
if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
|
|
@@ -1890,8 +2732,8 @@ async function runNew(options, deps) {
|
|
|
1890
2732
|
}
|
|
1891
2733
|
const plan = planNew({
|
|
1892
2734
|
surface: options.surface,
|
|
1893
|
-
packageJson: await readIfPresent2(
|
|
1894
|
-
gitignore: await readIfPresent2(
|
|
2735
|
+
packageJson: await readIfPresent2(join10(options.cwd, "package.json")),
|
|
2736
|
+
gitignore: await readIfPresent2(join10(options.cwd, ".gitignore")),
|
|
1895
2737
|
from
|
|
1896
2738
|
});
|
|
1897
2739
|
const fail = (error) => {
|
|
@@ -1914,19 +2756,19 @@ async function runNew(options, deps) {
|
|
|
1914
2756
|
deps.log(plan.instructions);
|
|
1915
2757
|
return { exitCode: 0, written: [] };
|
|
1916
2758
|
}
|
|
1917
|
-
const existing = plan.writes.filter((write) => existsSync4(
|
|
2759
|
+
const existing = plan.writes.filter((write) => existsSync4(join10(options.cwd, write.path)));
|
|
1918
2760
|
if (existing.length > 0) {
|
|
1919
2761
|
return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
|
|
1920
2762
|
}
|
|
1921
2763
|
const written = [];
|
|
1922
2764
|
for (const write of plan.writes) {
|
|
1923
|
-
const target =
|
|
1924
|
-
await
|
|
1925
|
-
await
|
|
2765
|
+
const target = join10(options.cwd, write.path);
|
|
2766
|
+
await mkdir6(dirname7(target), { recursive: true });
|
|
2767
|
+
await writeFile7(target, write.contents, "utf8");
|
|
1926
2768
|
written.push(write.path);
|
|
1927
2769
|
}
|
|
1928
2770
|
if (plan.gitignore !== null) {
|
|
1929
|
-
await
|
|
2771
|
+
await writeFile7(join10(options.cwd, ".gitignore"), plan.gitignore, "utf8");
|
|
1930
2772
|
written.push(".gitignore");
|
|
1931
2773
|
}
|
|
1932
2774
|
if (options.json) {
|
|
@@ -1947,21 +2789,21 @@ async function runNew(options, deps) {
|
|
|
1947
2789
|
}
|
|
1948
2790
|
|
|
1949
2791
|
// src/run-previews.ts
|
|
1950
|
-
import { readFile as
|
|
1951
|
-
import { join as
|
|
2792
|
+
import { readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
|
|
2793
|
+
import { join as join11 } from "path";
|
|
1952
2794
|
function envelope(deps, ok, body) {
|
|
1953
2795
|
deps.log(JSON.stringify({ ok, ...body }));
|
|
1954
2796
|
}
|
|
1955
2797
|
async function ensureIgnored(cwd) {
|
|
1956
|
-
const path =
|
|
2798
|
+
const path = join11(cwd, ".gitignore");
|
|
1957
2799
|
let current = null;
|
|
1958
2800
|
try {
|
|
1959
|
-
current = await
|
|
2801
|
+
current = await readFile9(path, "utf8");
|
|
1960
2802
|
} catch {
|
|
1961
2803
|
current = null;
|
|
1962
2804
|
}
|
|
1963
2805
|
const next = ignoreEntry(current);
|
|
1964
|
-
if (next !== null) await
|
|
2806
|
+
if (next !== null) await writeFile8(path, next, "utf8");
|
|
1965
2807
|
}
|
|
1966
2808
|
async function runAdd(options, deps) {
|
|
1967
2809
|
const loaded = await loadConfig(options.cwd);
|
|
@@ -2205,99 +3047,25 @@ async function runShow(options, deps) {
|
|
|
2205
3047
|
return { exitCode: 0 };
|
|
2206
3048
|
}
|
|
2207
3049
|
|
|
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
3050
|
// src/run-watch.ts
|
|
2283
|
-
import { spawn as
|
|
2284
|
-
import { mkdir as
|
|
2285
|
-
import { dirname as
|
|
2286
|
-
var
|
|
3051
|
+
import { spawn as spawn3 } from "child_process";
|
|
3052
|
+
import { mkdir as mkdir7, readFile as readFile10, writeFile as writeFile9 } from "fs/promises";
|
|
3053
|
+
import { dirname as dirname8, join as join12 } from "path";
|
|
3054
|
+
var POLL_MS2 = 2e3;
|
|
2287
3055
|
var HEARTBEAT_TIMEOUT_MS = 1e3;
|
|
2288
|
-
async function
|
|
3056
|
+
async function saveTemplate(cwd, run3) {
|
|
3057
|
+
const path = join12(cwd, WATCH_PATH);
|
|
3058
|
+
let config = {};
|
|
2289
3059
|
try {
|
|
2290
|
-
const
|
|
2291
|
-
|
|
2292
|
-
|
|
3060
|
+
const parsed = JSON.parse(await readFile10(path, "utf8"));
|
|
3061
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
3062
|
+
config = parsed;
|
|
3063
|
+
}
|
|
2293
3064
|
} catch {
|
|
2294
|
-
return null;
|
|
2295
3065
|
}
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
await mkdir6(dirname7(path), { recursive: true });
|
|
2300
|
-
await writeFile8(path, `${JSON.stringify({ run: run3 }, null, 2)}
|
|
3066
|
+
config.run = run3;
|
|
3067
|
+
await mkdir7(dirname8(path), { recursive: true });
|
|
3068
|
+
await writeFile9(path, `${JSON.stringify(config, null, 2)}
|
|
2301
3069
|
`, "utf8");
|
|
2302
3070
|
}
|
|
2303
3071
|
function spawnAgent(command, args, cwd) {
|
|
@@ -2308,7 +3076,7 @@ function spawnAgent(command, args, cwd) {
|
|
|
2308
3076
|
settled = true;
|
|
2309
3077
|
resolve(outcome);
|
|
2310
3078
|
};
|
|
2311
|
-
const child =
|
|
3079
|
+
const child = spawn3(command, args, { cwd, stdio: "inherit" });
|
|
2312
3080
|
child.on("error", (error) => settle({ ok: false, error: error.message }));
|
|
2313
3081
|
child.on(
|
|
2314
3082
|
"close",
|
|
@@ -2319,21 +3087,34 @@ function spawnAgent(command, args, cwd) {
|
|
|
2319
3087
|
});
|
|
2320
3088
|
}
|
|
2321
3089
|
async function runWatch(options, deps) {
|
|
2322
|
-
const saved = options.run === void 0 ? await
|
|
2323
|
-
const raw = options.run ?? saved;
|
|
2324
|
-
|
|
3090
|
+
const saved = options.run === void 0 ? await readAgentChoice(options.cwd) : { agent: null, run: null };
|
|
3091
|
+
const raw = options.run ?? saved.run;
|
|
3092
|
+
let template;
|
|
3093
|
+
let shownCommand2;
|
|
3094
|
+
let synthesizedAgent = null;
|
|
3095
|
+
if (raw !== null) {
|
|
3096
|
+
const parsed = parseTemplate(raw);
|
|
3097
|
+
if (!parsed.ok) {
|
|
3098
|
+
deps.error(parsed.error);
|
|
3099
|
+
return { exitCode: 1 };
|
|
3100
|
+
}
|
|
3101
|
+
template = parsed.template;
|
|
3102
|
+
shownCommand2 = raw;
|
|
3103
|
+
} else if (saved.agent !== null && saved.agent !== "custom") {
|
|
3104
|
+
const adapter = KNOWN_AGENTS[saved.agent];
|
|
3105
|
+
template = {
|
|
3106
|
+
command: adapter.binary,
|
|
3107
|
+
args: adapter.terminalArgs(PROMPT_TOKEN)
|
|
3108
|
+
};
|
|
3109
|
+
shownCommand2 = [template.command, ...template.args].join(" ");
|
|
3110
|
+
synthesizedAgent = adapter.name;
|
|
3111
|
+
} else {
|
|
2325
3112
|
deps.error(
|
|
2326
|
-
'Watch needs an agent command the first time:
|
|
3113
|
+
'Watch needs an agent command the first time: pick an agent in the interface, or pass --run "claude -p {prompt}".'
|
|
2327
3114
|
);
|
|
2328
3115
|
return { exitCode: 1 };
|
|
2329
3116
|
}
|
|
2330
|
-
|
|
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(() => {
|
|
3117
|
+
if (options.run !== void 0) await saveTemplate(options.cwd, options.run).catch(() => {
|
|
2337
3118
|
});
|
|
2338
3119
|
const base = `http://localhost:${options.port ?? DEFAULT_PORT}`;
|
|
2339
3120
|
const heartbeat = async (watching) => {
|
|
@@ -2347,11 +3128,15 @@ async function runWatch(options, deps) {
|
|
|
2347
3128
|
} catch {
|
|
2348
3129
|
}
|
|
2349
3130
|
};
|
|
2350
|
-
|
|
3131
|
+
if (synthesizedAgent !== null) {
|
|
3132
|
+
deps.log(`Using ${synthesizedAgent}, chosen in the interface.`);
|
|
3133
|
+
}
|
|
3134
|
+
deps.log(`Watching for change requests. Each one runs: ${shownCommand2}`);
|
|
2351
3135
|
deps.log("Stop with Ctrl-C.");
|
|
2352
3136
|
const failed = /* @__PURE__ */ new Set();
|
|
2353
3137
|
let stopped = false;
|
|
2354
3138
|
let busy = false;
|
|
3139
|
+
let announced = false;
|
|
2355
3140
|
let inflight = null;
|
|
2356
3141
|
const handle = async (request) => {
|
|
2357
3142
|
deps.log("");
|
|
@@ -2373,7 +3158,12 @@ async function runWatch(options, deps) {
|
|
|
2373
3158
|
};
|
|
2374
3159
|
const tick = async () => {
|
|
2375
3160
|
if (stopped) return;
|
|
2376
|
-
|
|
3161
|
+
if (announced) {
|
|
3162
|
+
void heartbeat(true);
|
|
3163
|
+
} else {
|
|
3164
|
+
await heartbeat(true);
|
|
3165
|
+
announced = true;
|
|
3166
|
+
}
|
|
2377
3167
|
if (busy) return;
|
|
2378
3168
|
busy = true;
|
|
2379
3169
|
try {
|
|
@@ -2390,7 +3180,7 @@ async function runWatch(options, deps) {
|
|
|
2390
3180
|
}
|
|
2391
3181
|
};
|
|
2392
3182
|
return new Promise((resolve) => {
|
|
2393
|
-
const timer = setInterval(() => void tick(),
|
|
3183
|
+
const timer = setInterval(() => void tick(), POLL_MS2);
|
|
2394
3184
|
const stop = () => {
|
|
2395
3185
|
if (stopped) return;
|
|
2396
3186
|
stopped = true;
|
|
@@ -2409,12 +3199,12 @@ async function runWatch(options, deps) {
|
|
|
2409
3199
|
|
|
2410
3200
|
// src/run-classify.ts
|
|
2411
3201
|
import { stat } from "fs/promises";
|
|
2412
|
-
import { join as
|
|
3202
|
+
import { join as join13 } from "path";
|
|
2413
3203
|
async function runClassify(options, deps) {
|
|
2414
3204
|
const declared = await Promise.all(
|
|
2415
3205
|
options.changes.map(async (change) => ({
|
|
2416
3206
|
...change,
|
|
2417
|
-
exists: await stat(
|
|
3207
|
+
exists: await stat(join13(options.cwd, change.path)).then(
|
|
2418
3208
|
() => true,
|
|
2419
3209
|
() => false
|
|
2420
3210
|
)
|
|
@@ -2442,14 +3232,14 @@ async function runClassify(options, deps) {
|
|
|
2442
3232
|
// src/run.ts
|
|
2443
3233
|
import { existsSync as existsSync5 } from "fs";
|
|
2444
3234
|
import { createRequire } from "module";
|
|
2445
|
-
import { basename as basename3, dirname as
|
|
3235
|
+
import { basename as basename3, dirname as dirname9, join as join14, relative as relative4 } from "path";
|
|
2446
3236
|
import { fileURLToPath } from "url";
|
|
2447
3237
|
function findShellDir() {
|
|
2448
|
-
const bundled =
|
|
2449
|
-
if (existsSync5(
|
|
3238
|
+
const bundled = join14(dirname9(fileURLToPath(import.meta.url)), "shell");
|
|
3239
|
+
if (existsSync5(join14(bundled, "index.html"))) return bundled;
|
|
2450
3240
|
try {
|
|
2451
3241
|
const require2 = createRequire(import.meta.url);
|
|
2452
|
-
return
|
|
3242
|
+
return dirname9(require2.resolve("@leglas/shell/dist/index.html"));
|
|
2453
3243
|
} catch {
|
|
2454
3244
|
return null;
|
|
2455
3245
|
}
|
|
@@ -2483,7 +3273,7 @@ async function run2(options, deps) {
|
|
|
2483
3273
|
const fileMounts = /* @__PURE__ */ new Map();
|
|
2484
3274
|
for (const preview of merged?.previews ?? []) {
|
|
2485
3275
|
if (preview.file !== void 0) {
|
|
2486
|
-
const absolute =
|
|
3276
|
+
const absolute = join14(options.cwd, preview.file);
|
|
2487
3277
|
if (!existsSync5(absolute)) {
|
|
2488
3278
|
worktreeErrors.push(
|
|
2489
3279
|
`"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
|
|
@@ -2494,7 +3284,7 @@ async function run2(options, deps) {
|
|
|
2494
3284
|
for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
|
|
2495
3285
|
slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
|
|
2496
3286
|
}
|
|
2497
|
-
fileMounts.set(slug,
|
|
3287
|
+
fileMounts.set(slug, dirname9(absolute));
|
|
2498
3288
|
previews.push({
|
|
2499
3289
|
...preview,
|
|
2500
3290
|
url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
|
|
@@ -2555,7 +3345,7 @@ async function run2(options, deps) {
|
|
|
2555
3345
|
})
|
|
2556
3346
|
);
|
|
2557
3347
|
} else {
|
|
2558
|
-
const configLabel = loaded.path === null ? "no config file, previewing the app root" :
|
|
3348
|
+
const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative4(options.cwd, loaded.path) || loaded.path;
|
|
2559
3349
|
deps.log(`Leglas ${url}`);
|
|
2560
3350
|
deps.log(
|
|
2561
3351
|
`app ${devServer}${app !== null ? " (started by Leglas)" : health.reachable ? "" : " (not reachable)"}`
|
|
@@ -2594,6 +3384,8 @@ async function run2(options, deps) {
|
|
|
2594
3384
|
export {
|
|
2595
3385
|
AGENTS_MARKER_END,
|
|
2596
3386
|
AGENTS_MARKER_START,
|
|
3387
|
+
DEFAULT_PORT,
|
|
3388
|
+
LEGLAS_PREFIX,
|
|
2597
3389
|
PROMPT_TOKEN,
|
|
2598
3390
|
WATCH_PATH,
|
|
2599
3391
|
baselineFrom,
|