leglas 0.4.1 → 0.5.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 +22 -7
- package/dist/args.d.ts +2 -0
- package/dist/bin.js +726 -140
- package/dist/index.d.ts +1 -1
- package/dist/index.js +725 -140
- package/dist/shell/assets/index-B7_ncDLZ.js +14 -0
- package/dist/shell/assets/index-f2TzxtQm.css +1 -0
- package/dist/shell/index.html +2 -2
- package/package.json +1 -1
- package/dist/shell/assets/index-B1hUXNY3.js +0 -14
- package/dist/shell/assets/index-DCg7pmCj.css +0 -1
package/dist/bin.js
CHANGED
|
@@ -63,6 +63,7 @@ function parseAdd(rest) {
|
|
|
63
63
|
let branch;
|
|
64
64
|
let file;
|
|
65
65
|
let basedOn;
|
|
66
|
+
let askedFor;
|
|
66
67
|
const tags = [];
|
|
67
68
|
let json = false;
|
|
68
69
|
for (let index = 0; index < rest.length; index += 1) {
|
|
@@ -81,7 +82,9 @@ function parseAdd(rest) {
|
|
|
81
82
|
} else {
|
|
82
83
|
value = argument.slice(equals + 1);
|
|
83
84
|
}
|
|
84
|
-
if (!["--title", "--url", "--note", "--tag", "--branch", "--file", "--based-on"].includes(
|
|
85
|
+
if (!["--title", "--url", "--note", "--tag", "--branch", "--file", "--based-on", "--asked-for"].includes(
|
|
86
|
+
flag
|
|
87
|
+
)) {
|
|
85
88
|
return { kind: "error", message: `leglas add does not take ${flag}.` };
|
|
86
89
|
}
|
|
87
90
|
if (value === void 0 || value === "") {
|
|
@@ -93,6 +96,7 @@ function parseAdd(rest) {
|
|
|
93
96
|
else if (flag === "--branch") branch = value;
|
|
94
97
|
else if (flag === "--file") file = value;
|
|
95
98
|
else if (flag === "--based-on") basedOn = value;
|
|
99
|
+
else if (flag === "--asked-for") askedFor = value;
|
|
96
100
|
else tags.push(value);
|
|
97
101
|
}
|
|
98
102
|
if (title === void 0) {
|
|
@@ -106,7 +110,16 @@ function parseAdd(rest) {
|
|
|
106
110
|
}
|
|
107
111
|
return {
|
|
108
112
|
kind: "add",
|
|
109
|
-
preview: {
|
|
113
|
+
preview: {
|
|
114
|
+
title,
|
|
115
|
+
url,
|
|
116
|
+
note,
|
|
117
|
+
tags: tags.length > 0 ? tags : void 0,
|
|
118
|
+
branch,
|
|
119
|
+
file,
|
|
120
|
+
basedOn,
|
|
121
|
+
askedFor
|
|
122
|
+
},
|
|
110
123
|
json
|
|
111
124
|
};
|
|
112
125
|
}
|
|
@@ -353,7 +366,7 @@ function parseArgs(argv) {
|
|
|
353
366
|
|
|
354
367
|
// src/run-classify.ts
|
|
355
368
|
import { stat } from "fs/promises";
|
|
356
|
-
import { join as
|
|
369
|
+
import { join as join10 } from "path";
|
|
357
370
|
|
|
358
371
|
// ../server/dist/config.js
|
|
359
372
|
var DEFAULT_DEV_SERVER = "http://localhost:3000";
|
|
@@ -448,6 +461,10 @@ function normalizeConfig(raw, options = {}) {
|
|
|
448
461
|
if (basedOn !== void 0 && (typeof basedOn !== "string" || basedOn.trim() === "")) {
|
|
449
462
|
errors.push(`${at} has a basedOn that is not a direction title.`);
|
|
450
463
|
}
|
|
464
|
+
const askedFor = entry["askedFor"];
|
|
465
|
+
if (askedFor !== void 0 && (typeof askedFor !== "string" || askedFor.trim() === "")) {
|
|
466
|
+
errors.push(`${at} has an askedFor that is not a change request.`);
|
|
467
|
+
}
|
|
451
468
|
const tags = entry["tags"];
|
|
452
469
|
previews.push({
|
|
453
470
|
title: typeof title === "string" ? title : "",
|
|
@@ -456,7 +473,8 @@ function normalizeConfig(raw, options = {}) {
|
|
|
456
473
|
tags: Array.isArray(tags) ? tags.filter((tag) => typeof tag === "string") : [],
|
|
457
474
|
...typeof branch === "string" ? { branch } : {},
|
|
458
475
|
...typeof file === "string" ? { file } : {},
|
|
459
|
-
...typeof basedOn === "string" && basedOn.trim() !== "" ? { basedOn } : {}
|
|
476
|
+
...typeof basedOn === "string" && basedOn.trim() !== "" ? { basedOn } : {},
|
|
477
|
+
...typeof askedFor === "string" && askedFor.trim() !== "" ? { askedFor } : {}
|
|
460
478
|
});
|
|
461
479
|
});
|
|
462
480
|
const devCommand = source["devCommand"];
|
|
@@ -572,6 +590,10 @@ import { spawn } from "child_process";
|
|
|
572
590
|
import { constants } from "fs";
|
|
573
591
|
import { access, mkdir, readFile, writeFile } from "fs/promises";
|
|
574
592
|
import { delimiter, dirname, isAbsolute, join, relative } from "path";
|
|
593
|
+
var CODEX_WORKSPACE_CONFIG = [
|
|
594
|
+
"-c",
|
|
595
|
+
"sandbox_workspace_write.network_access=true"
|
|
596
|
+
];
|
|
575
597
|
var KNOWN_AGENTS = {
|
|
576
598
|
claude: {
|
|
577
599
|
name: "Claude",
|
|
@@ -602,6 +624,12 @@ var KNOWN_AGENTS = {
|
|
|
602
624
|
"--permission-mode",
|
|
603
625
|
"acceptEdits"
|
|
604
626
|
],
|
|
627
|
+
// Non-interactive Claude cannot approve a Bash call: acceptEdits covers
|
|
628
|
+
// files, so a command the prompt requires is refused every time with
|
|
629
|
+
// nobody there to say yes. This allows exactly that command and nothing
|
|
630
|
+
// wider. Codex needs no equivalent, because workspace-write already lets
|
|
631
|
+
// it run commands.
|
|
632
|
+
allowArgs: (command) => ["--allowedTools", `Bash(${command} *)`],
|
|
605
633
|
// Every stream-json event names its session.
|
|
606
634
|
sessionFrom: (event) => typeof event.session_id === "string" && event.session_id !== "" ? event.session_id : null,
|
|
607
635
|
authArgs: ["auth", "status"],
|
|
@@ -622,15 +650,41 @@ var KNOWN_AGENTS = {
|
|
|
622
650
|
codex: {
|
|
623
651
|
name: "Codex",
|
|
624
652
|
binary: "codex",
|
|
625
|
-
|
|
626
|
-
|
|
653
|
+
// `--skip-git-repo-check` is what lets Codex run at all in a project the
|
|
654
|
+
// user never put under version control: without it codex-cli refuses
|
|
655
|
+
// before it reaches a model, with "Not inside a trusted directory and
|
|
656
|
+
// --skip-git-repo-check was not specified", and every Codex request in a
|
|
657
|
+
// non-git project fails for a reason nothing in Leglas explained. The flag
|
|
658
|
+
// moves that precondition and only that: `-s workspace-write` still
|
|
659
|
+
// confines writes to the project, so the sandbox boundary is unchanged,
|
|
660
|
+
// and in a git repository the flag does nothing at all.
|
|
661
|
+
args: (prompt) => [
|
|
662
|
+
"exec",
|
|
663
|
+
"--json",
|
|
664
|
+
...CODEX_WORKSPACE_CONFIG,
|
|
665
|
+
"-s",
|
|
666
|
+
"workspace-write",
|
|
667
|
+
"--skip-git-repo-check",
|
|
668
|
+
prompt
|
|
669
|
+
],
|
|
670
|
+
terminalArgs: (prompt) => [
|
|
671
|
+
"exec",
|
|
672
|
+
...CODEX_WORKSPACE_CONFIG,
|
|
673
|
+
"-s",
|
|
674
|
+
"workspace-write",
|
|
675
|
+
"--skip-git-repo-check",
|
|
676
|
+
prompt
|
|
677
|
+
],
|
|
627
678
|
// No sandbox flag here: `codex exec resume` refuses it and inherits the
|
|
628
|
-
// session's own sandbox, which the first turn set to workspace-write.
|
|
679
|
+
// session's own sandbox, which the first turn set to workspace-write. The
|
|
680
|
+
// repository check is per invocation, so resume needs the flag of its own.
|
|
629
681
|
resumeArgs: (sessionId, prompt) => [
|
|
630
682
|
"exec",
|
|
631
683
|
"resume",
|
|
632
684
|
sessionId,
|
|
633
685
|
"--json",
|
|
686
|
+
...CODEX_WORKSPACE_CONFIG,
|
|
687
|
+
"--skip-git-repo-check",
|
|
634
688
|
prompt
|
|
635
689
|
],
|
|
636
690
|
sessionFrom: (event) => event.type === "thread.started" && typeof event.thread_id === "string" ? event.thread_id : null,
|
|
@@ -656,7 +710,7 @@ var KNOWN_AGENTS = {
|
|
|
656
710
|
}
|
|
657
711
|
};
|
|
658
712
|
var PROBE_TIMEOUT_MS = 3e3;
|
|
659
|
-
function execProbe(binary, args) {
|
|
713
|
+
function execProbe(binary, args, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
660
714
|
return new Promise((resolve) => {
|
|
661
715
|
let child;
|
|
662
716
|
try {
|
|
@@ -669,7 +723,10 @@ function execProbe(binary, args) {
|
|
|
669
723
|
if (stdout.length < 4096)
|
|
670
724
|
stdout += chunk.toString();
|
|
671
725
|
});
|
|
672
|
-
const deadline = setTimeout(() =>
|
|
726
|
+
const deadline = setTimeout(() => {
|
|
727
|
+
child.kill("SIGKILL");
|
|
728
|
+
resolve(null);
|
|
729
|
+
}, timeoutMs);
|
|
673
730
|
child.once("error", () => {
|
|
674
731
|
clearTimeout(deadline);
|
|
675
732
|
resolve(null);
|
|
@@ -738,10 +795,10 @@ function shownCommand(value) {
|
|
|
738
795
|
function claudeActivity(event, cwd) {
|
|
739
796
|
if (event.type !== "assistant")
|
|
740
797
|
return null;
|
|
741
|
-
const
|
|
742
|
-
if (
|
|
798
|
+
const message2 = record(event.message);
|
|
799
|
+
if (message2 === null || !Array.isArray(message2.content))
|
|
743
800
|
return null;
|
|
744
|
-
for (const rawBlock of
|
|
801
|
+
for (const rawBlock of message2.content) {
|
|
745
802
|
const block = record(rawBlock);
|
|
746
803
|
if (block?.type !== "tool_use" || typeof block.name !== "string")
|
|
747
804
|
continue;
|
|
@@ -810,6 +867,25 @@ function sessionFrom(agent, line) {
|
|
|
810
867
|
return null;
|
|
811
868
|
return KNOWN_AGENTS[agent].sessionFrom(event);
|
|
812
869
|
}
|
|
870
|
+
function retryFrom(agent, line) {
|
|
871
|
+
if (agent !== "claude" && agent !== "cursor")
|
|
872
|
+
return null;
|
|
873
|
+
let event;
|
|
874
|
+
try {
|
|
875
|
+
event = record(JSON.parse(line));
|
|
876
|
+
} catch {
|
|
877
|
+
return null;
|
|
878
|
+
}
|
|
879
|
+
if (event === null || event.type !== "system" || event.subtype !== "api_retry")
|
|
880
|
+
return null;
|
|
881
|
+
const attempt = typeof event.attempt === "number" ? event.attempt : 1;
|
|
882
|
+
return {
|
|
883
|
+
attempt,
|
|
884
|
+
max: typeof event.max_retries === "number" ? event.max_retries : null,
|
|
885
|
+
status: typeof event.error_status === "number" ? event.error_status : null,
|
|
886
|
+
reason: typeof event.error === "string" && event.error !== "" ? event.error.toLowerCase() : null
|
|
887
|
+
};
|
|
888
|
+
}
|
|
813
889
|
function isAgentChoice(value) {
|
|
814
890
|
return value === "custom" || typeof value === "string" && Object.hasOwn(KNOWN_AGENTS, value);
|
|
815
891
|
}
|
|
@@ -953,8 +1029,8 @@ async function loadConfig(cwd) {
|
|
|
953
1029
|
exported = module.default;
|
|
954
1030
|
}
|
|
955
1031
|
} catch (error) {
|
|
956
|
-
const
|
|
957
|
-
return { config: null, errors: [`${label} could not be loaded: ${
|
|
1032
|
+
const message2 = error instanceof Error ? error.message : String(error);
|
|
1033
|
+
return { config: null, errors: [`${label} could not be loaded: ${message2}`], path };
|
|
958
1034
|
}
|
|
959
1035
|
const result2 = normalizeConfig(exported);
|
|
960
1036
|
return {
|
|
@@ -1024,7 +1100,8 @@ async function addLocalPreview(cwd, input, shared) {
|
|
|
1024
1100
|
...input.tags === void 0 ? {} : { tags: input.tags },
|
|
1025
1101
|
...input.branch === void 0 ? {} : { branch: input.branch },
|
|
1026
1102
|
...input.file === void 0 ? {} : { file: input.file },
|
|
1027
|
-
...input.basedOn === void 0 ? {} : { basedOn: input.basedOn }
|
|
1103
|
+
...input.basedOn === void 0 ? {} : { basedOn: input.basedOn },
|
|
1104
|
+
...input.askedFor === void 0 ? {} : { askedFor: input.askedFor }
|
|
1028
1105
|
};
|
|
1029
1106
|
const check = normalizeConfig({ previews: [candidate] }, { requireDevCommand: false });
|
|
1030
1107
|
if (check.config === null) {
|
|
@@ -1256,17 +1333,220 @@ async function startAppProcess(options) {
|
|
|
1256
1333
|
throw new Error(`${options.label} did not start within ${Math.round(readyTimeoutMs / 1e3)}s. Check that its dev command serves the port it is given.`);
|
|
1257
1334
|
}
|
|
1258
1335
|
|
|
1259
|
-
// ../server/dist/
|
|
1260
|
-
|
|
1336
|
+
// ../server/dist/failure.js
|
|
1337
|
+
var NEEDS_TRUST = /not inside a trusted directory|--skip-git-repo-check/i;
|
|
1338
|
+
var MISSING_BINARY = /\b(ENOENT|EACCES|ENOTDIR)\b/;
|
|
1339
|
+
var NOT_SIGNED_IN = /not logged in|not signed in|please (?:re-?)?(?:run|sign|log)\s*in|\/login\b|invalid api key|unauthorized|authentication_failed|\b401\b/i;
|
|
1340
|
+
var LIMIT = /\b429\b|rate limit|usage limit|quota exceeded|too many requests/i;
|
|
1341
|
+
var OVERLOADED = /\b(?:503|529)\b|overloaded|service unavailable/i;
|
|
1342
|
+
function fromStatus(status, reason) {
|
|
1343
|
+
if (status === 401 || status === 403 || reason === "authentication_failed")
|
|
1344
|
+
return "not-signed-in";
|
|
1345
|
+
if (status === 429 || reason === "rate_limit")
|
|
1346
|
+
return "provider-limit";
|
|
1347
|
+
if (status === 529 || status === 503 || reason === "overloaded")
|
|
1348
|
+
return "provider-overloaded";
|
|
1349
|
+
return null;
|
|
1350
|
+
}
|
|
1351
|
+
function fromLines(lines) {
|
|
1352
|
+
for (const line of [...lines].reverse()) {
|
|
1353
|
+
if (NEEDS_TRUST.test(line))
|
|
1354
|
+
return "needs-trust";
|
|
1355
|
+
if (NOT_SIGNED_IN.test(line))
|
|
1356
|
+
return "not-signed-in";
|
|
1357
|
+
if (LIMIT.test(line))
|
|
1358
|
+
return "provider-limit";
|
|
1359
|
+
if (OVERLOADED.test(line))
|
|
1360
|
+
return "provider-overloaded";
|
|
1361
|
+
}
|
|
1362
|
+
return null;
|
|
1363
|
+
}
|
|
1364
|
+
function attempts(retry) {
|
|
1365
|
+
if (retry === null || retry === void 0)
|
|
1366
|
+
return "";
|
|
1367
|
+
const total = retry.max === null ? retry.attempt : Math.max(retry.attempt, retry.max);
|
|
1368
|
+
return ` It retried ${total} times first.`;
|
|
1369
|
+
}
|
|
1370
|
+
function message(code, input) {
|
|
1371
|
+
const agent = input.agent;
|
|
1372
|
+
switch (code) {
|
|
1373
|
+
case "cancelled":
|
|
1374
|
+
return "You stopped this run.";
|
|
1375
|
+
case "stopped":
|
|
1376
|
+
return "Leglas shut down while this was running.";
|
|
1377
|
+
case "missing-agent":
|
|
1378
|
+
return `${agent} could not be started. Its command is not on this machine's PATH any more.`;
|
|
1379
|
+
case "not-signed-in":
|
|
1380
|
+
return `${agent} is not signed in. Sign in to it in a terminal, then run this again.`;
|
|
1381
|
+
case "provider-overloaded":
|
|
1382
|
+
return `${agent}'s provider was overloaded and gave up.${attempts(input.retry)}`;
|
|
1383
|
+
case "provider-limit":
|
|
1384
|
+
return `${agent} reported a rate or usage limit, so nothing ran.`;
|
|
1385
|
+
case "needs-trust":
|
|
1386
|
+
return `Codex refused this project: it is not a git repository and Codex has no trust on record for it.`;
|
|
1387
|
+
case "not-registered":
|
|
1388
|
+
return `${agent} finished without registering the new direction, so nothing reached the rail. Its last output is in the Leglas terminal.`;
|
|
1389
|
+
case "agent-error":
|
|
1390
|
+
return input.exitCode === null || input.exitCode === void 0 ? `${agent} stopped without finishing. Its last output is in the Leglas terminal.` : `${agent} exited with code ${input.exitCode}. Its last output is in the Leglas terminal.`;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
function classifyFailure(input) {
|
|
1394
|
+
const lines = input.lines ?? [];
|
|
1395
|
+
const error = input.error ?? null;
|
|
1396
|
+
const code = error === "cancelled" ? "cancelled" : error === "not-registered" ? "not-registered" : error !== null && /^stopped by /.test(error) ? "stopped" : error !== null && MISSING_BINARY.test(error) ? "missing-agent" : (error !== null ? fromLines([error]) : null) ?? fromStatus(input.retry?.status ?? null, input.retry?.reason ?? null) ?? fromLines(lines) ?? "agent-error";
|
|
1397
|
+
return { code, message: message(code, input) };
|
|
1398
|
+
}
|
|
1399
|
+
function sessionShaped(code) {
|
|
1400
|
+
return code === "agent-error";
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
// ../server/dist/annotations.js
|
|
1261
1404
|
import { randomBytes } from "crypto";
|
|
1405
|
+
import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
1262
1406
|
import { dirname as dirname4, join as join5 } from "path";
|
|
1407
|
+
var ANNOTATIONS_PATH = ".leglas/annotations.json";
|
|
1408
|
+
var NOTE_CAP = 500;
|
|
1409
|
+
var SELECTOR_CAP = 300;
|
|
1410
|
+
var TEXT_CAP = 120;
|
|
1411
|
+
var TAG_CAP = 40;
|
|
1412
|
+
var CLASS_CAP = 8;
|
|
1413
|
+
var CLASS_LENGTH_CAP = 60;
|
|
1414
|
+
var COVERS_CAP = 8;
|
|
1415
|
+
function isRecord2(value) {
|
|
1416
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1417
|
+
}
|
|
1418
|
+
function text(value, cap) {
|
|
1419
|
+
return typeof value === "string" ? value.trim().slice(0, cap) : "";
|
|
1420
|
+
}
|
|
1421
|
+
function fraction(value) {
|
|
1422
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
1423
|
+
return 0.5;
|
|
1424
|
+
return Math.min(1, Math.max(0, value));
|
|
1425
|
+
}
|
|
1426
|
+
function size(value) {
|
|
1427
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : 0;
|
|
1428
|
+
}
|
|
1429
|
+
function anchorFrom(value) {
|
|
1430
|
+
if (!isRecord2(value))
|
|
1431
|
+
return null;
|
|
1432
|
+
const selector = text(value["selector"], SELECTOR_CAP);
|
|
1433
|
+
if (selector === "")
|
|
1434
|
+
return null;
|
|
1435
|
+
const rect = isRecord2(value["rect"]) ? value["rect"] : {};
|
|
1436
|
+
const classes = Array.isArray(value["classes"]) ? value["classes"].filter((entry) => typeof entry === "string").slice(0, CLASS_CAP).map((entry) => entry.slice(0, CLASS_LENGTH_CAP)) : [];
|
|
1437
|
+
const rawRegion = isRecord2(value["region"]) ? value["region"] : null;
|
|
1438
|
+
const region = rawRegion === null ? null : {
|
|
1439
|
+
height: fraction(rawRegion["height"]),
|
|
1440
|
+
width: fraction(rawRegion["width"]),
|
|
1441
|
+
x: fraction(rawRegion["x"]),
|
|
1442
|
+
y: fraction(rawRegion["y"])
|
|
1443
|
+
};
|
|
1444
|
+
const covers = Array.isArray(value["covers"]) ? value["covers"].filter(isRecord2).slice(0, COVERS_CAP).map((entry) => ({
|
|
1445
|
+
tag: text(entry["tag"], TAG_CAP) || "element",
|
|
1446
|
+
text: text(entry["text"], TEXT_CAP)
|
|
1447
|
+
})) : [];
|
|
1448
|
+
return {
|
|
1449
|
+
classes,
|
|
1450
|
+
...covers.length === 0 ? {} : { covers },
|
|
1451
|
+
...region === null ? {} : { region },
|
|
1452
|
+
rect: {
|
|
1453
|
+
height: size(rect["height"]),
|
|
1454
|
+
width: size(rect["width"]),
|
|
1455
|
+
x: size(rect["x"]),
|
|
1456
|
+
y: size(rect["y"])
|
|
1457
|
+
},
|
|
1458
|
+
selector,
|
|
1459
|
+
spot: {
|
|
1460
|
+
x: fraction(isRecord2(value["spot"]) ? value["spot"]["x"] : void 0),
|
|
1461
|
+
y: fraction(isRecord2(value["spot"]) ? value["spot"]["y"] : void 0)
|
|
1462
|
+
},
|
|
1463
|
+
tag: text(value["tag"], TAG_CAP) || "element",
|
|
1464
|
+
text: text(value["text"], TEXT_CAP),
|
|
1465
|
+
viewport: size(value["viewport"])
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
async function readAnnotations(cwd) {
|
|
1469
|
+
try {
|
|
1470
|
+
const raw = await readFile4(join5(cwd, ANNOTATIONS_PATH), "utf8");
|
|
1471
|
+
const parsed2 = JSON.parse(raw);
|
|
1472
|
+
if (!Array.isArray(parsed2.annotations))
|
|
1473
|
+
return [];
|
|
1474
|
+
return parsed2.annotations.flatMap((entry, index) => {
|
|
1475
|
+
if (!isRecord2(entry))
|
|
1476
|
+
return [];
|
|
1477
|
+
const anchor = anchorFrom(entry["anchor"]);
|
|
1478
|
+
const title = text(entry["title"], TAG_CAP * 4);
|
|
1479
|
+
if (anchor === null || title === "")
|
|
1480
|
+
return [];
|
|
1481
|
+
return [
|
|
1482
|
+
{
|
|
1483
|
+
anchor,
|
|
1484
|
+
id: typeof entry["id"] === "string" ? entry["id"] : String(index),
|
|
1485
|
+
note: text(entry["note"], NOTE_CAP),
|
|
1486
|
+
title
|
|
1487
|
+
}
|
|
1488
|
+
];
|
|
1489
|
+
});
|
|
1490
|
+
} catch {
|
|
1491
|
+
return [];
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
async function write(cwd, annotations) {
|
|
1495
|
+
const path = join5(cwd, ANNOTATIONS_PATH);
|
|
1496
|
+
await mkdir3(dirname4(path), { recursive: true });
|
|
1497
|
+
await writeFile3(path, `${JSON.stringify({ annotations }, null, 2)}
|
|
1498
|
+
`, "utf8");
|
|
1499
|
+
}
|
|
1500
|
+
async function addAnnotation(cwd, input) {
|
|
1501
|
+
const annotation = { ...input, id: randomBytes(6).toString("base64url") };
|
|
1502
|
+
await write(cwd, [...await readAnnotations(cwd), annotation]);
|
|
1503
|
+
return annotation;
|
|
1504
|
+
}
|
|
1505
|
+
async function removeAnnotations(cwd, ids) {
|
|
1506
|
+
const wanted = new Set(ids);
|
|
1507
|
+
const annotations = await readAnnotations(cwd);
|
|
1508
|
+
const remaining = annotations.filter((entry) => !wanted.has(entry.id));
|
|
1509
|
+
const dropped = annotations.length - remaining.length;
|
|
1510
|
+
if (dropped > 0)
|
|
1511
|
+
await write(cwd, remaining);
|
|
1512
|
+
return dropped;
|
|
1513
|
+
}
|
|
1514
|
+
function annotationsFor(annotations, title) {
|
|
1515
|
+
return annotations.filter((entry) => entry.title === title);
|
|
1516
|
+
}
|
|
1517
|
+
function describeAnchor(anchor) {
|
|
1518
|
+
const where = `about ${anchor.rect.width}\xD7${anchor.rect.height} at (${anchor.rect.x}, ${anchor.rect.y}) in a ${anchor.viewport}px-wide viewport`;
|
|
1519
|
+
if (anchor.region !== void 0) {
|
|
1520
|
+
const covered = (anchor.covers ?? []).map((entry) => entry.text === "" ? `<${entry.tag}>` : `<${entry.tag}> \u201C${entry.text}\u201D`).join(", ");
|
|
1521
|
+
const inside = covered === "" ? "" : ` covering ${covered};`;
|
|
1522
|
+
return `an area inside <${anchor.tag}>;${inside} path ${anchor.selector}; ${where}`;
|
|
1523
|
+
}
|
|
1524
|
+
const parts = [`<${anchor.tag}>`];
|
|
1525
|
+
if (anchor.classes.length > 0)
|
|
1526
|
+
parts.push(`class "${anchor.classes.join(" ")}"`);
|
|
1527
|
+
if (anchor.text !== "")
|
|
1528
|
+
parts.push(`reading \u201C${anchor.text}\u201D`);
|
|
1529
|
+
return `${parts.join(", ")}; path ${anchor.selector}; ${where}`;
|
|
1530
|
+
}
|
|
1531
|
+
function describeAnnotations(annotations) {
|
|
1532
|
+
return annotations.map((annotation, index) => {
|
|
1533
|
+
const said = annotation.note === "" ? "Look at this." : annotation.note;
|
|
1534
|
+
return `${index + 1}. ${said}
|
|
1535
|
+
The element: ${describeAnchor(annotation.anchor)}`;
|
|
1536
|
+
}).join("\n\n");
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// ../server/dist/requests.js
|
|
1540
|
+
import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
|
|
1541
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
1542
|
+
import { dirname as dirname5, join as join6 } from "path";
|
|
1263
1543
|
var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
|
|
1264
|
-
function
|
|
1544
|
+
function variantSlot(url) {
|
|
1265
1545
|
if (!url.startsWith("/"))
|
|
1266
1546
|
return null;
|
|
1267
|
-
const query = url.slice(url.indexOf("?") + 1);
|
|
1268
1547
|
if (!url.includes("?"))
|
|
1269
1548
|
return null;
|
|
1549
|
+
const query = url.slice(url.indexOf("?") + 1);
|
|
1270
1550
|
for (const pair of query.split("&")) {
|
|
1271
1551
|
const [rawKey, rawValue] = pair.split("=");
|
|
1272
1552
|
if (rawKey === void 0 || rawValue === void 0)
|
|
@@ -1277,37 +1557,125 @@ function targetFor(url) {
|
|
|
1277
1557
|
const option = decodeURIComponent(rawValue);
|
|
1278
1558
|
if (!SAFE_SEGMENT.test(surface) || !SAFE_SEGMENT.test(option))
|
|
1279
1559
|
return null;
|
|
1280
|
-
return
|
|
1560
|
+
return { surface, option };
|
|
1281
1561
|
}
|
|
1282
1562
|
return null;
|
|
1283
1563
|
}
|
|
1284
|
-
function
|
|
1564
|
+
function targetFor(url) {
|
|
1565
|
+
const slot = variantSlot(url);
|
|
1566
|
+
return slot === null ? null : `.leglas/variants/${slot.surface}/${slot.option}.tsx`;
|
|
1567
|
+
}
|
|
1568
|
+
function composeRequest(preview, intent, mode, notes = [], leglasCommand = "npx -y leglas") {
|
|
1285
1569
|
const target = preview.file ?? targetFor(preview.url);
|
|
1286
1570
|
const cleaned = intent.trim();
|
|
1571
|
+
const asked = changeBlock(cleaned, notes);
|
|
1572
|
+
const recorded = cleaned === "" ? notes.map((entry) => entry.note).filter((entry) => entry !== "").join("; ") : cleaned;
|
|
1573
|
+
const prompt = mode === "variant" ? variantPrompt(preview, recorded, asked, target, leglasCommand) : replacePrompt(preview, asked, target);
|
|
1574
|
+
return { prompt, target, mode };
|
|
1575
|
+
}
|
|
1576
|
+
var ANCHORS = `Each path and rectangle was recorded when the note was left, against the design as it looked then. Trust the element's own words first, then its tag and classes, then the path, and treat the rectangle as a hint about where on the page to look rather than a fact.`;
|
|
1577
|
+
function changeBlock(cleaned, notes) {
|
|
1578
|
+
if (notes.length === 0)
|
|
1579
|
+
return `What to change: ${cleaned}`;
|
|
1580
|
+
const many = notes.length === 1 ? "a note" : `${notes.length} notes`;
|
|
1581
|
+
const lead = cleaned === "" ? `What to change, left as ${many} on the design itself:` : `What to change: ${cleaned}
|
|
1582
|
+
|
|
1583
|
+
And ${many} left on the design itself:`;
|
|
1584
|
+
return `${lead}
|
|
1585
|
+
|
|
1586
|
+
${describeAnnotations(notes)}
|
|
1587
|
+
|
|
1588
|
+
${ANCHORS}`;
|
|
1589
|
+
}
|
|
1590
|
+
var SCOPE = `This request came from the running Leglas interface. Request collection, direction discovery and the live-server check are already complete. Do not run Leglas explore, requests, list, show, help or version commands, do not inspect package caches, and do not start or restart the app or Leglas. Use the existing live preview if visual inspection is useful.
|
|
1591
|
+
|
|
1592
|
+
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.
|
|
1593
|
+
|
|
1594
|
+
Leave every other direction exactly as it is; they are alternatives being compared side by side, so changing a sibling destroys the comparison. Keep the change additive: do not rewrite shared components that other directions rely on.`;
|
|
1595
|
+
function registrationCommand(leglasCommand) {
|
|
1596
|
+
return `${leglasCommand} add`;
|
|
1597
|
+
}
|
|
1598
|
+
function replacePrompt(preview, asked, target) {
|
|
1287
1599
|
const where = target === null ? `The direction is titled "${preview.title}" and renders at ${preview.url}. Find what produces it.` : `It lives at ${target}.`;
|
|
1288
1600
|
const pace = target === null ? `Once found, make the change and finish. ` : `Make the change in that file and finish. `;
|
|
1289
|
-
|
|
1601
|
+
return `In this project, change only the "${preview.title}" design direction. ${where}
|
|
1602
|
+
|
|
1603
|
+
${asked}
|
|
1604
|
+
|
|
1605
|
+
${pace}${SCOPE} The direction is already registered, so nothing needs re-registering.`;
|
|
1606
|
+
}
|
|
1607
|
+
function variantPrompt(preview, recorded, asked, target, leglasCommand) {
|
|
1608
|
+
const slot = variantSlot(preview.url);
|
|
1609
|
+
const parent = JSON.stringify(preview.title);
|
|
1610
|
+
const askedFor = JSON.stringify(recorded);
|
|
1611
|
+
const add = registrationCommand(leglasCommand);
|
|
1612
|
+
const source = target === null ? `Find what renders it first.` : `Its source is ${target}.`;
|
|
1613
|
+
const [make, register] = preview.file !== void 0 ? [
|
|
1614
|
+
`Copy that file to a new file beside it and make the change in the copy.`,
|
|
1615
|
+
` ${add} --title "<name>" --file "<the new file>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
|
|
1616
|
+
] : slot !== null ? [
|
|
1617
|
+
`Copy that file to a new one in the same folder and make the change in the copy. The new file's name without its extension is its key, and that key has to be listed in the DIRECTIONS map in .leglas/variants/${slot.surface}/switch.tsx or its URL will not resolve.`,
|
|
1618
|
+
` ${add} --title "<name>" --url "/?v-${slot.surface}=<key>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
|
|
1619
|
+
] : [
|
|
1620
|
+
`Copy its source rather than editing it, and make the change in the copy. Add the new direction the way this project already switches between them; if it has a Leglas branch point, that is the DIRECTIONS map in .leglas/variants/<surface>/switch.tsx.`,
|
|
1621
|
+
` ${add} --title "<name>" --url "<the URL that shows it>" --based-on ${parent} --note "<what this direction is, one line>" --asked-for ${askedFor}`
|
|
1622
|
+
];
|
|
1623
|
+
return `In this project, add a new design direction based on the "${preview.title}" direction. Leave "${preview.title}" itself exactly as it is: it is the thing the new one will be compared against.
|
|
1624
|
+
|
|
1625
|
+
${source} ${make}
|
|
1290
1626
|
|
|
1291
|
-
|
|
1627
|
+
${asked}
|
|
1292
1628
|
|
|
1293
|
-
|
|
1629
|
+
Then register it, which is what puts it on the rail:
|
|
1294
1630
|
|
|
1295
|
-
|
|
1296
|
-
|
|
1631
|
+
${register}
|
|
1632
|
+
|
|
1633
|
+
Name it for its idea rather than numbering it, and keep the name short enough to read in a narrow rail. Pass --asked-for exactly as given above; it is the user's own words and the interface shows them. Registering it is the last step; finish there.
|
|
1634
|
+
|
|
1635
|
+
${SCOPE}`;
|
|
1297
1636
|
}
|
|
1298
1637
|
var REQUESTS_PATH = ".leglas/requests.json";
|
|
1638
|
+
var TERMINAL = ["failed", "cancelled"];
|
|
1639
|
+
function isTerminal(status) {
|
|
1640
|
+
return TERMINAL.includes(status);
|
|
1641
|
+
}
|
|
1642
|
+
var FAILURE_CODES = [
|
|
1643
|
+
"cancelled",
|
|
1644
|
+
"stopped",
|
|
1645
|
+
"missing-agent",
|
|
1646
|
+
"not-signed-in",
|
|
1647
|
+
"provider-overloaded",
|
|
1648
|
+
"provider-limit",
|
|
1649
|
+
"needs-trust",
|
|
1650
|
+
"not-registered",
|
|
1651
|
+
"agent-error"
|
|
1652
|
+
];
|
|
1653
|
+
function failureOf(value) {
|
|
1654
|
+
if (typeof value !== "object" || value === null)
|
|
1655
|
+
return null;
|
|
1656
|
+
const entry = value;
|
|
1657
|
+
if (typeof entry.message !== "string" || entry.message === "")
|
|
1658
|
+
return null;
|
|
1659
|
+
if (entry.code === void 0 || !FAILURE_CODES.includes(entry.code))
|
|
1660
|
+
return null;
|
|
1661
|
+
return { code: entry.code, message: entry.message };
|
|
1662
|
+
}
|
|
1299
1663
|
async function readRequests(cwd) {
|
|
1300
1664
|
try {
|
|
1301
|
-
const raw = await
|
|
1665
|
+
const raw = await readFile5(join6(cwd, REQUESTS_PATH), "utf8");
|
|
1302
1666
|
const parsed2 = JSON.parse(raw);
|
|
1303
1667
|
if (!Array.isArray(parsed2.requests))
|
|
1304
1668
|
return [];
|
|
1305
1669
|
return parsed2.requests.map((request, index) => {
|
|
1306
|
-
const entry = request;
|
|
1670
|
+
const { failure: rawFailure, ...entry } = request;
|
|
1671
|
+
const status = entry.status === "picked-up" || entry.status === "failed" || entry.status === "cancelled" ? entry.status : "queued";
|
|
1672
|
+
const failure = isTerminal(status) ? failureOf(rawFailure) : null;
|
|
1307
1673
|
return {
|
|
1308
1674
|
...entry,
|
|
1309
1675
|
id: typeof entry.id === "string" ? entry.id : String(index),
|
|
1310
|
-
status
|
|
1676
|
+
status,
|
|
1677
|
+
mode: entry.mode === "variant" ? "variant" : "replace",
|
|
1678
|
+
...failure === null ? {} : { failure }
|
|
1311
1679
|
};
|
|
1312
1680
|
});
|
|
1313
1681
|
} catch {
|
|
@@ -1315,23 +1683,23 @@ async function readRequests(cwd) {
|
|
|
1315
1683
|
}
|
|
1316
1684
|
}
|
|
1317
1685
|
async function writeQueue(cwd, requests) {
|
|
1318
|
-
const path =
|
|
1319
|
-
await
|
|
1320
|
-
await
|
|
1686
|
+
const path = join6(cwd, REQUESTS_PATH);
|
|
1687
|
+
await mkdir4(dirname5(path), { recursive: true });
|
|
1688
|
+
await writeFile4(path, `${JSON.stringify({ requests }, null, 2)}
|
|
1321
1689
|
`, "utf8");
|
|
1322
1690
|
}
|
|
1323
1691
|
async function appendRequest(cwd, request) {
|
|
1324
1692
|
await writeQueue(cwd, [
|
|
1325
1693
|
...await readRequests(cwd),
|
|
1326
|
-
{ ...request, id:
|
|
1694
|
+
{ ...request, id: randomBytes2(6).toString("base64url"), status: "queued" }
|
|
1327
1695
|
]);
|
|
1328
1696
|
}
|
|
1329
1697
|
async function collectRequests(cwd) {
|
|
1330
1698
|
const requests = await readRequests(cwd);
|
|
1331
|
-
const collected = requests.map((request) => ({ ...request, status: "picked-up" })
|
|
1332
|
-
if (requests.some((request) => request.status
|
|
1699
|
+
const collected = requests.map((request) => isTerminal(request.status) ? request : { ...request, status: "picked-up" });
|
|
1700
|
+
if (requests.some((request) => request.status === "queued"))
|
|
1333
1701
|
await writeQueue(cwd, collected);
|
|
1334
|
-
return collected;
|
|
1702
|
+
return collected.filter((request) => !isTerminal(request.status));
|
|
1335
1703
|
}
|
|
1336
1704
|
async function markPickedUp(cwd, id) {
|
|
1337
1705
|
const requests = await readRequests(cwd);
|
|
@@ -1340,6 +1708,17 @@ async function markPickedUp(cwd, id) {
|
|
|
1340
1708
|
await writeQueue(cwd, requests.map((request) => request.id === id ? { ...request, status: "picked-up" } : request));
|
|
1341
1709
|
return true;
|
|
1342
1710
|
}
|
|
1711
|
+
async function markFailed(cwd, id, failure) {
|
|
1712
|
+
const requests = await readRequests(cwd);
|
|
1713
|
+
if (!requests.some((request) => request.id === id))
|
|
1714
|
+
return false;
|
|
1715
|
+
await writeQueue(cwd, requests.map((request) => request.id === id ? {
|
|
1716
|
+
...request,
|
|
1717
|
+
status: failure.code === "cancelled" ? "cancelled" : "failed",
|
|
1718
|
+
failure
|
|
1719
|
+
} : request));
|
|
1720
|
+
return true;
|
|
1721
|
+
}
|
|
1343
1722
|
async function removeRequest(cwd, id) {
|
|
1344
1723
|
const requests = await readRequests(cwd);
|
|
1345
1724
|
const remaining = requests.filter((request) => request.id !== id);
|
|
@@ -1350,7 +1729,7 @@ async function removeRequest(cwd, id) {
|
|
|
1350
1729
|
}
|
|
1351
1730
|
async function clearRequests(cwd) {
|
|
1352
1731
|
const requests = await readRequests(cwd);
|
|
1353
|
-
const pending = requests.filter((request) => request.status
|
|
1732
|
+
const pending = requests.filter((request) => request.status === "queued");
|
|
1354
1733
|
const cleared = requests.length - pending.length;
|
|
1355
1734
|
if (cleared > 0)
|
|
1356
1735
|
await writeQueue(cwd, pending);
|
|
@@ -1359,10 +1738,13 @@ async function clearRequests(cwd) {
|
|
|
1359
1738
|
|
|
1360
1739
|
// ../server/dist/runner.js
|
|
1361
1740
|
import { spawn as nodeSpawn } from "child_process";
|
|
1741
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
1742
|
+
import { join as join7 } from "path";
|
|
1362
1743
|
var POLL_MS = 2e3;
|
|
1363
1744
|
var OUTPUT_LINES = 20;
|
|
1745
|
+
var CANCEL_GRACE_MS = 5e3;
|
|
1364
1746
|
var SESSION_TURNS_CAP = 8;
|
|
1365
|
-
function resolveCommand(choice, prompt, sessionId = null) {
|
|
1747
|
+
function resolveCommand(choice, prompt, sessionId = null, registration = null) {
|
|
1366
1748
|
if (choice.agent === null)
|
|
1367
1749
|
return null;
|
|
1368
1750
|
if (choice.agent === "custom") {
|
|
@@ -1374,12 +1756,13 @@ function resolveCommand(choice, prompt, sessionId = null) {
|
|
|
1374
1756
|
return { agent: "custom", name: "Custom", ...commandFor(parsed2.template, prompt), resumed: false };
|
|
1375
1757
|
}
|
|
1376
1758
|
const adapter = KNOWN_AGENTS[choice.agent];
|
|
1759
|
+
const allow = registration !== null && "allowArgs" in adapter ? adapter.allowArgs(registration) : [];
|
|
1377
1760
|
if (sessionId !== null && "resumeArgs" in adapter) {
|
|
1378
1761
|
return {
|
|
1379
1762
|
agent: choice.agent,
|
|
1380
1763
|
name: adapter.name,
|
|
1381
1764
|
command: adapter.binary,
|
|
1382
|
-
args: adapter.resumeArgs(sessionId, prompt),
|
|
1765
|
+
args: [...adapter.resumeArgs(sessionId, prompt), ...allow],
|
|
1383
1766
|
resumed: true
|
|
1384
1767
|
};
|
|
1385
1768
|
}
|
|
@@ -1387,7 +1770,7 @@ function resolveCommand(choice, prompt, sessionId = null) {
|
|
|
1387
1770
|
agent: choice.agent,
|
|
1388
1771
|
name: adapter.name,
|
|
1389
1772
|
command: adapter.binary,
|
|
1390
|
-
args: adapter.args(prompt),
|
|
1773
|
+
args: [...adapter.args(prompt), ...allow],
|
|
1391
1774
|
resumed: false
|
|
1392
1775
|
};
|
|
1393
1776
|
}
|
|
@@ -1417,12 +1800,17 @@ function startRunner(options) {
|
|
|
1417
1800
|
const setEvery = options.setInterval ?? ((callback, milliseconds) => setInterval(callback, milliseconds));
|
|
1418
1801
|
const clearEvery = options.clearInterval ?? ((handle2) => clearInterval(handle2));
|
|
1419
1802
|
const failed = /* @__PURE__ */ new Set();
|
|
1803
|
+
const setLater = options.setTimeout ?? ((callback, milliseconds) => {
|
|
1804
|
+
setTimeout(callback, milliseconds).unref?.();
|
|
1805
|
+
});
|
|
1420
1806
|
let state = {
|
|
1421
1807
|
running: false,
|
|
1422
1808
|
requestId: null,
|
|
1423
1809
|
agent: null,
|
|
1424
1810
|
activity: null,
|
|
1425
|
-
startedAt: null
|
|
1811
|
+
startedAt: null,
|
|
1812
|
+
stopping: false,
|
|
1813
|
+
waiting: null
|
|
1426
1814
|
};
|
|
1427
1815
|
let stopped = false;
|
|
1428
1816
|
let ticking = null;
|
|
@@ -1430,15 +1818,30 @@ function startRunner(options) {
|
|
|
1430
1818
|
let active = null;
|
|
1431
1819
|
const sessions = /* @__PURE__ */ new Map();
|
|
1432
1820
|
const idle = () => {
|
|
1433
|
-
state = {
|
|
1821
|
+
state = {
|
|
1822
|
+
running: false,
|
|
1823
|
+
requestId: null,
|
|
1824
|
+
agent: null,
|
|
1825
|
+
activity: null,
|
|
1826
|
+
startedAt: null,
|
|
1827
|
+
stopping: false,
|
|
1828
|
+
waiting: null
|
|
1829
|
+
};
|
|
1434
1830
|
};
|
|
1435
1831
|
const rememberLine = (lines, line) => {
|
|
1436
1832
|
lines.push(line);
|
|
1437
1833
|
if (lines.length > OUTPUT_LINES)
|
|
1438
1834
|
lines.splice(0, lines.length - OUTPUT_LINES);
|
|
1439
1835
|
};
|
|
1440
|
-
const reportFailure = (request,
|
|
1441
|
-
|
|
1836
|
+
const reportFailure = async (request, failure, lines) => {
|
|
1837
|
+
await markFailed(options.cwd, request.id, failure).catch(() => {
|
|
1838
|
+
});
|
|
1839
|
+
failed.add(request.id);
|
|
1840
|
+
if (failure.code === "cancelled") {
|
|
1841
|
+
console.error(`Leglas stopped the run for ${request.title}.`);
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
console.error(`Leglas agent failed for ${request.title}: ${failure.message}`);
|
|
1442
1845
|
for (const line of lines)
|
|
1443
1846
|
console.error(` ${line}`);
|
|
1444
1847
|
};
|
|
@@ -1456,19 +1859,31 @@ function startRunner(options) {
|
|
|
1456
1859
|
error: error instanceof Error ? error.message : String(error)
|
|
1457
1860
|
});
|
|
1458
1861
|
}
|
|
1459
|
-
const current = {
|
|
1862
|
+
const current = {
|
|
1863
|
+
child,
|
|
1864
|
+
requestId: request.id,
|
|
1865
|
+
cancelled: false,
|
|
1866
|
+
abandon: () => {
|
|
1867
|
+
}
|
|
1868
|
+
};
|
|
1460
1869
|
active = current;
|
|
1461
1870
|
const stdoutFlush = lineReader(child.stdout, (line) => {
|
|
1462
1871
|
rememberLine(lines, line);
|
|
1463
1872
|
const sessionId = sessionFrom(resolved.agent, line);
|
|
1464
1873
|
if (sessionId !== null)
|
|
1465
1874
|
observed.sessionId = sessionId;
|
|
1875
|
+
const retry = retryFrom(resolved.agent, line);
|
|
1876
|
+
if (retry !== null) {
|
|
1877
|
+
observed.retry = retry;
|
|
1878
|
+
if (active === current)
|
|
1879
|
+
state = { ...state, waiting: retry };
|
|
1880
|
+
}
|
|
1466
1881
|
const activity = activityFrom(resolved.agent, line, options.cwd);
|
|
1467
1882
|
if (activity !== null) {
|
|
1468
1883
|
if (activity.startsWith("editing"))
|
|
1469
1884
|
observed.edited = true;
|
|
1470
1885
|
if (active === current)
|
|
1471
|
-
state = { ...state, activity };
|
|
1886
|
+
state = { ...state, activity, waiting: null };
|
|
1472
1887
|
}
|
|
1473
1888
|
});
|
|
1474
1889
|
const stderrFlush = lineReader(child.stderr, (line) => rememberLine(lines, line));
|
|
@@ -1482,6 +1897,7 @@ function startRunner(options) {
|
|
|
1482
1897
|
stderrFlush();
|
|
1483
1898
|
resolve(outcome);
|
|
1484
1899
|
};
|
|
1900
|
+
current.abandon = () => settle({ ok: false, error: "cancelled" });
|
|
1485
1901
|
child.once("error", (error) => settle({ ok: false, error: error.message }));
|
|
1486
1902
|
child.once("close", (code, signal) => {
|
|
1487
1903
|
if (current.cancelled)
|
|
@@ -1495,10 +1911,12 @@ function startRunner(options) {
|
|
|
1495
1911
|
active = null;
|
|
1496
1912
|
});
|
|
1497
1913
|
};
|
|
1914
|
+
const registered = () => readFile6(join7(options.cwd, LOCAL_PREVIEWS_PATH), "utf8").catch(() => null);
|
|
1498
1915
|
const handle = async (request, choice) => {
|
|
1499
1916
|
const session = choice.agent !== null ? sessions.get(choice.agent) ?? null : null;
|
|
1500
1917
|
const continuable = session !== null && session.turns < SESSION_TURNS_CAP;
|
|
1501
|
-
|
|
1918
|
+
const registration = request.mode === "variant" && options.leglasCommand !== void 0 ? registrationCommand(options.leglasCommand) : null;
|
|
1919
|
+
let resolved = resolveCommand(choice, request.prompt, continuable ? session.id : null, registration);
|
|
1502
1920
|
if (resolved === null)
|
|
1503
1921
|
return;
|
|
1504
1922
|
const lines = [];
|
|
@@ -1506,7 +1924,7 @@ function startRunner(options) {
|
|
|
1506
1924
|
if (!await markPickedUp(options.cwd, request.id))
|
|
1507
1925
|
return;
|
|
1508
1926
|
if (stopped) {
|
|
1509
|
-
|
|
1927
|
+
await reportFailure(request, classifyFailure({ agent: resolved.name, error: "stopped by shutdown" }), []);
|
|
1510
1928
|
return;
|
|
1511
1929
|
}
|
|
1512
1930
|
state = {
|
|
@@ -1514,25 +1932,47 @@ function startRunner(options) {
|
|
|
1514
1932
|
requestId: request.id,
|
|
1515
1933
|
agent: resolved.name,
|
|
1516
1934
|
activity: null,
|
|
1517
|
-
startedAt: Date.now()
|
|
1935
|
+
startedAt: Date.now(),
|
|
1936
|
+
stopping: false,
|
|
1937
|
+
waiting: null
|
|
1938
|
+
};
|
|
1939
|
+
const observed = {
|
|
1940
|
+
sessionId: null,
|
|
1941
|
+
edited: false,
|
|
1942
|
+
retry: null
|
|
1518
1943
|
};
|
|
1519
|
-
const
|
|
1944
|
+
const before = request.mode === "variant" ? await registered() : null;
|
|
1945
|
+
const agent = resolved.name;
|
|
1520
1946
|
let outcome = await runChild(request, resolved, lines, observed);
|
|
1521
|
-
const
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1947
|
+
const verdict = () => classifyFailure({
|
|
1948
|
+
agent,
|
|
1949
|
+
error: outcome.ok ? null : stopped && outcome.error === "cancelled" ? "stopped by shutdown" : outcome.error,
|
|
1950
|
+
exitCode: outcome.ok ? outcome.code : null,
|
|
1951
|
+
lines,
|
|
1952
|
+
retry: observed.retry
|
|
1953
|
+
});
|
|
1954
|
+
let failure = verdict();
|
|
1955
|
+
if (!(outcome.ok && outcome.code === 0) && resolved.resumed && !observed.edited && sessionShaped(failure.code) && // Not redundant with the verdict: a stop that lands between the first
|
|
1956
|
+
// child settling and the retry starting finds no child to cancel, so
|
|
1957
|
+
// nothing says "cancelled". Stopped still means stopped.
|
|
1525
1958
|
!stopped) {
|
|
1526
1959
|
sessions.delete(resolved.agent);
|
|
1527
|
-
const cold = resolveCommand(choice, request.prompt);
|
|
1960
|
+
const cold = resolveCommand(choice, request.prompt, null, registration);
|
|
1528
1961
|
if (cold !== null) {
|
|
1529
1962
|
resolved = cold;
|
|
1530
1963
|
observed.sessionId = null;
|
|
1531
|
-
|
|
1964
|
+
observed.retry = null;
|
|
1965
|
+
state = { ...state, activity: null, waiting: null };
|
|
1532
1966
|
outcome = await runChild(request, resolved, lines, observed);
|
|
1967
|
+
failure = verdict();
|
|
1533
1968
|
}
|
|
1534
1969
|
}
|
|
1535
1970
|
if (outcome.ok && outcome.code === 0) {
|
|
1971
|
+
if (request.mode === "variant" && await registered() === before) {
|
|
1972
|
+
sessions.delete(resolved.agent);
|
|
1973
|
+
await reportFailure(request, classifyFailure({ agent, error: "not-registered" }), lines);
|
|
1974
|
+
return;
|
|
1975
|
+
}
|
|
1536
1976
|
if (observed.sessionId !== null) {
|
|
1537
1977
|
const previous = sessions.get(resolved.agent);
|
|
1538
1978
|
sessions.set(resolved.agent, {
|
|
@@ -1540,12 +1980,14 @@ function startRunner(options) {
|
|
|
1540
1980
|
turns: resolved.resumed && previous?.id === observed.sessionId ? previous.turns + 1 : 1
|
|
1541
1981
|
});
|
|
1542
1982
|
}
|
|
1983
|
+
if (request.mode === "replace" && request.notes !== void 0) {
|
|
1984
|
+
await removeAnnotations(options.cwd, request.notes).catch(() => 0);
|
|
1985
|
+
}
|
|
1543
1986
|
await removeRequest(options.cwd, request.id);
|
|
1544
1987
|
return;
|
|
1545
1988
|
}
|
|
1546
1989
|
sessions.delete(resolved.agent);
|
|
1547
|
-
|
|
1548
|
-
reportFailure(request, outcome.ok ? `${resolved.command} exited ${outcome.code}` : outcome.error, lines);
|
|
1990
|
+
await reportFailure(request, failure, lines);
|
|
1549
1991
|
} finally {
|
|
1550
1992
|
idle();
|
|
1551
1993
|
}
|
|
@@ -1579,12 +2021,23 @@ function startRunner(options) {
|
|
|
1579
2021
|
return false;
|
|
1580
2022
|
if (id !== void 0 && active.requestId !== id)
|
|
1581
2023
|
return false;
|
|
1582
|
-
|
|
1583
|
-
|
|
2024
|
+
const current = active;
|
|
2025
|
+
current.cancelled = true;
|
|
2026
|
+
failed.add(current.requestId);
|
|
2027
|
+
state = { ...state, stopping: true, waiting: null };
|
|
1584
2028
|
try {
|
|
1585
|
-
|
|
2029
|
+
current.child.kill("SIGTERM");
|
|
1586
2030
|
} catch {
|
|
1587
2031
|
}
|
|
2032
|
+
setLater(() => {
|
|
2033
|
+
if (active !== current)
|
|
2034
|
+
return;
|
|
2035
|
+
try {
|
|
2036
|
+
current.child.kill("SIGKILL");
|
|
2037
|
+
} catch {
|
|
2038
|
+
}
|
|
2039
|
+
current.abandon();
|
|
2040
|
+
}, CANCEL_GRACE_MS);
|
|
1588
2041
|
return true;
|
|
1589
2042
|
};
|
|
1590
2043
|
const stop = () => {
|
|
@@ -1609,12 +2062,12 @@ function startRunner(options) {
|
|
|
1609
2062
|
}
|
|
1610
2063
|
|
|
1611
2064
|
// ../server/dist/renames.js
|
|
1612
|
-
import { mkdir as
|
|
1613
|
-
import { dirname as
|
|
2065
|
+
import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile5 } from "fs/promises";
|
|
2066
|
+
import { dirname as dirname6, join as join8 } from "path";
|
|
1614
2067
|
var RENAMES_PATH = ".leglas/renames.json";
|
|
1615
2068
|
async function readRenames(cwd) {
|
|
1616
2069
|
try {
|
|
1617
|
-
const raw = await
|
|
2070
|
+
const raw = await readFile7(join8(cwd, RENAMES_PATH), "utf8");
|
|
1618
2071
|
const parsed2 = JSON.parse(raw);
|
|
1619
2072
|
if (parsed2.renames === null || typeof parsed2.renames !== "object")
|
|
1620
2073
|
return {};
|
|
@@ -1624,9 +2077,9 @@ async function readRenames(cwd) {
|
|
|
1624
2077
|
}
|
|
1625
2078
|
}
|
|
1626
2079
|
async function writeRenames(cwd, renames) {
|
|
1627
|
-
const path =
|
|
1628
|
-
await
|
|
1629
|
-
await
|
|
2080
|
+
const path = join8(cwd, RENAMES_PATH);
|
|
2081
|
+
await mkdir5(dirname6(path), { recursive: true });
|
|
2082
|
+
await writeFile5(path, `${JSON.stringify({ renames }, null, 2)}
|
|
1630
2083
|
`, "utf8");
|
|
1631
2084
|
}
|
|
1632
2085
|
function resolveTitle(input, titles, renames) {
|
|
@@ -1644,7 +2097,7 @@ function resolveTitle(input, titles, renames) {
|
|
|
1644
2097
|
import { createReadStream, existsSync as existsSync2, statSync } from "fs";
|
|
1645
2098
|
import http2 from "http";
|
|
1646
2099
|
import net3 from "net";
|
|
1647
|
-
import { extname, join as
|
|
2100
|
+
import { extname, join as join9, normalize, relative as relative3 } from "path";
|
|
1648
2101
|
var LEGLAS_PREFIX = "/leglas";
|
|
1649
2102
|
var DEFAULT_PORT = 4100;
|
|
1650
2103
|
var PORT_ATTEMPTS = 20;
|
|
@@ -1717,6 +2170,9 @@ function isTrustedMutation(req) {
|
|
|
1717
2170
|
return false;
|
|
1718
2171
|
}
|
|
1719
2172
|
}
|
|
2173
|
+
function isEnded(request, failedIds) {
|
|
2174
|
+
return isTerminal(request.status) || failedIds.includes(request.id);
|
|
2175
|
+
}
|
|
1720
2176
|
function hasJsonBody(req) {
|
|
1721
2177
|
const contentType = req.headers["content-type"];
|
|
1722
2178
|
return typeof contentType === "string" && contentType.split(";", 1)[0]?.trim().toLowerCase() === "application/json";
|
|
@@ -1743,7 +2199,7 @@ function probe(target, timeoutMs = 1e3) {
|
|
|
1743
2199
|
}
|
|
1744
2200
|
function serveFrom(res, dir, relativePath) {
|
|
1745
2201
|
const relative5 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
|
|
1746
|
-
const candidate =
|
|
2202
|
+
const candidate = join9(dir, relative5);
|
|
1747
2203
|
if (!candidate.startsWith(dir))
|
|
1748
2204
|
return false;
|
|
1749
2205
|
if (!existsSync2(candidate) || !statSync(candidate).isFile())
|
|
@@ -1828,7 +2284,7 @@ async function bind(server, requested) {
|
|
|
1828
2284
|
throw new Error(`No free port between ${requested} and ${requested + PORT_ATTEMPTS - 1}.`);
|
|
1829
2285
|
}
|
|
1830
2286
|
async function startServer(options) {
|
|
1831
|
-
const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
|
|
2287
|
+
const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), leglasCommand = "npx -y leglas", fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
|
|
1832
2288
|
const target = config?.devServer ?? "http://localhost:3000";
|
|
1833
2289
|
const proxy = createProxyHandler({ target });
|
|
1834
2290
|
const bootConfigSnapshot = snapshotConfig(cwd);
|
|
@@ -1945,20 +2401,51 @@ async function startServer(options) {
|
|
|
1945
2401
|
} catch {
|
|
1946
2402
|
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
1947
2403
|
}
|
|
2404
|
+
if (parsed2.mode !== void 0 && parsed2.mode !== "variant" && parsed2.mode !== "replace") {
|
|
2405
|
+
return sendJson(res, 400, {
|
|
2406
|
+
ok: false,
|
|
2407
|
+
error: 'mode must be "variant" or "replace".'
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2410
|
+
const mode = parsed2.mode === "replace" ? "replace" : "variant";
|
|
1948
2411
|
const localRead = await readLocalPreviews(cwd).catch(() => null);
|
|
1949
2412
|
const local = localRead?.errors.length === 0 ? localRead.previews : [];
|
|
1950
2413
|
const localTitles = new Set(local.map((entry) => entry.title));
|
|
1951
2414
|
const bootConfig = config?.previews ?? [];
|
|
1952
2415
|
const boot = localRead === null || localRead.errors.length > 0 ? bootConfig : bootConfig.filter((entry) => entry.local !== true || localTitles.has(entry.title));
|
|
1953
2416
|
const preview = [...boot, ...local].find((entry) => entry.title === parsed2.title);
|
|
1954
|
-
if (!preview
|
|
2417
|
+
if (!preview) {
|
|
2418
|
+
return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
|
|
2419
|
+
}
|
|
2420
|
+
const notes = annotationsFor(await readAnnotations(cwd).catch(() => []), preview.title);
|
|
2421
|
+
if (!parsed2.intent?.trim() && notes.length === 0) {
|
|
1955
2422
|
return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
|
|
1956
2423
|
}
|
|
1957
|
-
const
|
|
2424
|
+
const intent = (parsed2.intent ?? "").trim();
|
|
2425
|
+
const live = (await readRequests(cwd).catch(() => [])).filter((entry) => entry.status === "queued" || entry.status === "picked-up");
|
|
2426
|
+
const sameNotes = (entry) => {
|
|
2427
|
+
const before = [...entry.notes ?? []].sort().join(",");
|
|
2428
|
+
return before === notes.map((note) => note.id).sort().join(",");
|
|
2429
|
+
};
|
|
2430
|
+
if (live.some((entry) => entry.title === preview.title && entry.intent === intent && // The same words in the other mode are not the same request:
|
|
2431
|
+
// one forks the direction and the other rewrites it. Only a
|
|
2432
|
+
// genuine repeat is refused.
|
|
2433
|
+
(entry.mode ?? "replace") === mode && sameNotes(entry))) {
|
|
2434
|
+
return sendJson(res, 409, {
|
|
2435
|
+
ok: false,
|
|
2436
|
+
duplicate: true,
|
|
2437
|
+
error: `That exact change to ${preview.title} is already waiting.`
|
|
2438
|
+
});
|
|
2439
|
+
}
|
|
2440
|
+
const composed = composeRequest(preview, intent, mode, notes, leglasCommand);
|
|
1958
2441
|
void appendRequest(cwd, {
|
|
1959
2442
|
title: preview.title,
|
|
1960
2443
|
url: preview.url,
|
|
1961
|
-
intent
|
|
2444
|
+
intent,
|
|
2445
|
+
// The ids travel with the request so a change made in place can
|
|
2446
|
+
// forget the notes it answered. A fork leaves them where they are:
|
|
2447
|
+
// the direction they point at was not touched.
|
|
2448
|
+
...notes.length === 0 ? {} : { notes: notes.map((entry) => entry.id) },
|
|
1962
2449
|
...composed
|
|
1963
2450
|
}).then(() => {
|
|
1964
2451
|
runner?.nudge();
|
|
@@ -2034,21 +2521,33 @@ async function startServer(options) {
|
|
|
2034
2521
|
agent: null,
|
|
2035
2522
|
activity: null,
|
|
2036
2523
|
startedAt: null,
|
|
2524
|
+
stopping: false,
|
|
2525
|
+
waiting: null,
|
|
2037
2526
|
failedIds: []
|
|
2038
2527
|
};
|
|
2039
2528
|
return void readRequests(cwd).then((requests) => sendJson(res, 200, {
|
|
2040
|
-
requests: requests.map(({ id, title, intent, status }) => ({
|
|
2529
|
+
requests: requests.map(({ id, title, intent, status, failure }) => ({
|
|
2041
2530
|
id,
|
|
2042
2531
|
title,
|
|
2043
2532
|
intent,
|
|
2044
|
-
|
|
2533
|
+
// The run in flight is the one thing the file cannot know. After
|
|
2534
|
+
// that the file is the record, including across a restart, and the
|
|
2535
|
+
// process-local failed set only covers a request whose verdict
|
|
2536
|
+
// could not be written.
|
|
2537
|
+
status: snapshot.running && snapshot.requestId === id ? "running" : status === "queued" && snapshot.failedIds.includes(id) ? "failed" : status,
|
|
2538
|
+
failure: failure ?? null
|
|
2045
2539
|
})),
|
|
2046
2540
|
agent: {
|
|
2047
2541
|
attached: externallyAttached(),
|
|
2048
2542
|
running: snapshot.running,
|
|
2049
2543
|
name: snapshot.running ? snapshot.agent : null,
|
|
2050
2544
|
activity: snapshot.running ? snapshot.activity : null,
|
|
2051
|
-
startedAt: snapshot.running ? snapshot.startedAt : null
|
|
2545
|
+
startedAt: snapshot.running ? snapshot.startedAt : null,
|
|
2546
|
+
// A stop that has been asked for but not yet obeyed. The card
|
|
2547
|
+
// says so rather than going on describing a live run.
|
|
2548
|
+
stopping: snapshot.running && snapshot.stopping,
|
|
2549
|
+
// Why a run that looks stalled is stalled, while it is stalled.
|
|
2550
|
+
waiting: snapshot.running ? snapshot.waiting : null
|
|
2052
2551
|
}
|
|
2053
2552
|
}));
|
|
2054
2553
|
}
|
|
@@ -2091,8 +2590,8 @@ async function startServer(options) {
|
|
|
2091
2590
|
if (request === void 0) {
|
|
2092
2591
|
return sendJson(res, 404, { ok: false, error: "No such request." });
|
|
2093
2592
|
}
|
|
2094
|
-
if (!(runner?.snapshot().failedIds
|
|
2095
|
-
return sendJson(res, 400, { ok: false, error: "Only
|
|
2593
|
+
if (!isEnded(request, runner?.snapshot().failedIds ?? [])) {
|
|
2594
|
+
return sendJson(res, 400, { ok: false, error: "Only an ended request can be run again." });
|
|
2096
2595
|
}
|
|
2097
2596
|
try {
|
|
2098
2597
|
if (!await removeRequest(cwd, request.id)) {
|
|
@@ -2103,7 +2602,11 @@ async function startServer(options) {
|
|
|
2103
2602
|
url: request.url,
|
|
2104
2603
|
intent: request.intent,
|
|
2105
2604
|
target: request.target,
|
|
2106
|
-
prompt: request.prompt
|
|
2605
|
+
prompt: request.prompt,
|
|
2606
|
+
// The stored prompt already carries the mode's instructions; the
|
|
2607
|
+
// field travels with it so the queue keeps saying which kind of
|
|
2608
|
+
// change this is.
|
|
2609
|
+
...request.mode === void 0 ? {} : { mode: request.mode }
|
|
2107
2610
|
});
|
|
2108
2611
|
runner?.nudge();
|
|
2109
2612
|
return sendJson(res, 200, { ok: true });
|
|
@@ -2112,6 +2615,65 @@ async function startServer(options) {
|
|
|
2112
2615
|
}
|
|
2113
2616
|
});
|
|
2114
2617
|
}
|
|
2618
|
+
if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "GET") {
|
|
2619
|
+
return void readAnnotations(cwd).then((annotations) => sendJson(res, 200, { annotations }));
|
|
2620
|
+
}
|
|
2621
|
+
if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "POST") {
|
|
2622
|
+
if (!hasJsonBody(req)) {
|
|
2623
|
+
return sendJson(res, 400, { ok: false, error: "A note must be JSON." });
|
|
2624
|
+
}
|
|
2625
|
+
let body = "";
|
|
2626
|
+
req.on("data", (chunk) => body += chunk);
|
|
2627
|
+
return void req.on("end", async () => {
|
|
2628
|
+
let parsed2;
|
|
2629
|
+
try {
|
|
2630
|
+
parsed2 = JSON.parse(body || "{}");
|
|
2631
|
+
} catch {
|
|
2632
|
+
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
2633
|
+
}
|
|
2634
|
+
if (typeof parsed2.title !== "string" || parsed2.title.trim() === "") {
|
|
2635
|
+
return sendJson(res, 400, { ok: false, error: "A note needs a direction." });
|
|
2636
|
+
}
|
|
2637
|
+
const anchor = anchorFrom(parsed2.anchor);
|
|
2638
|
+
if (anchor === null) {
|
|
2639
|
+
return sendJson(res, 400, { ok: false, error: "A note needs something to point at." });
|
|
2640
|
+
}
|
|
2641
|
+
try {
|
|
2642
|
+
const annotation = await addAnnotation(cwd, {
|
|
2643
|
+
anchor,
|
|
2644
|
+
note: typeof parsed2.note === "string" ? parsed2.note.trim() : "",
|
|
2645
|
+
title: parsed2.title
|
|
2646
|
+
});
|
|
2647
|
+
return sendJson(res, 200, { ok: true, annotation });
|
|
2648
|
+
} catch {
|
|
2649
|
+
return sendJson(res, 500, { ok: false, error: "The note could not be kept." });
|
|
2650
|
+
}
|
|
2651
|
+
});
|
|
2652
|
+
}
|
|
2653
|
+
if (path === `${LEGLAS_PREFIX}/api/annotations/delete` && req.method === "POST") {
|
|
2654
|
+
if (!hasJsonBody(req)) {
|
|
2655
|
+
return sendJson(res, 400, { ok: false, error: "Delete must be JSON." });
|
|
2656
|
+
}
|
|
2657
|
+
let body = "";
|
|
2658
|
+
req.on("data", (chunk) => body += chunk);
|
|
2659
|
+
return void req.on("end", async () => {
|
|
2660
|
+
let parsed2;
|
|
2661
|
+
try {
|
|
2662
|
+
parsed2 = JSON.parse(body || "{}");
|
|
2663
|
+
} catch {
|
|
2664
|
+
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
2665
|
+
}
|
|
2666
|
+
const ids = Array.isArray(parsed2.ids) ? parsed2.ids.filter((entry) => typeof entry === "string") : [];
|
|
2667
|
+
if (ids.length === 0) {
|
|
2668
|
+
return sendJson(res, 400, { ok: false, error: "Body needs the notes to forget." });
|
|
2669
|
+
}
|
|
2670
|
+
try {
|
|
2671
|
+
return sendJson(res, 200, { ok: true, deleted: await removeAnnotations(cwd, ids) });
|
|
2672
|
+
} catch {
|
|
2673
|
+
return sendJson(res, 500, { ok: false, error: "The notes could not be forgotten." });
|
|
2674
|
+
}
|
|
2675
|
+
});
|
|
2676
|
+
}
|
|
2115
2677
|
if (path === `${LEGLAS_PREFIX}/api/requests/dismiss` && req.method === "POST") {
|
|
2116
2678
|
if (!hasJsonBody(req)) {
|
|
2117
2679
|
return sendJson(res, 400, { ok: false, error: "Dismiss must be JSON." });
|
|
@@ -2128,8 +2690,9 @@ async function startServer(options) {
|
|
|
2128
2690
|
if (typeof parsed2.id !== "string") {
|
|
2129
2691
|
return sendJson(res, 400, { ok: false, error: "Body needs a request id." });
|
|
2130
2692
|
}
|
|
2131
|
-
|
|
2132
|
-
|
|
2693
|
+
const target2 = (await readRequests(cwd)).find((entry) => entry.id === parsed2.id);
|
|
2694
|
+
if (target2 === void 0 || !isEnded(target2, runner?.snapshot().failedIds ?? [])) {
|
|
2695
|
+
return sendJson(res, 400, { ok: false, error: "Only an ended request can be dismissed." });
|
|
2133
2696
|
}
|
|
2134
2697
|
try {
|
|
2135
2698
|
if (!await removeRequest(cwd, parsed2.id)) {
|
|
@@ -2204,7 +2767,7 @@ async function startServer(options) {
|
|
|
2204
2767
|
proxy.upgrade(req, socket, head);
|
|
2205
2768
|
});
|
|
2206
2769
|
const port = await bind(server, options.port ?? DEFAULT_PORT);
|
|
2207
|
-
runner = startRunner({ cwd, externallyAttached });
|
|
2770
|
+
runner = startRunner({ cwd, externallyAttached, leglasCommand });
|
|
2208
2771
|
let closePromise = null;
|
|
2209
2772
|
return {
|
|
2210
2773
|
port,
|
|
@@ -2229,7 +2792,7 @@ async function runClassify(options, deps) {
|
|
|
2229
2792
|
const declared = await Promise.all(
|
|
2230
2793
|
options.changes.map(async (change) => ({
|
|
2231
2794
|
...change,
|
|
2232
|
-
exists: await stat(
|
|
2795
|
+
exists: await stat(join10(options.cwd, change.path)).then(
|
|
2233
2796
|
() => true,
|
|
2234
2797
|
() => false
|
|
2235
2798
|
)
|
|
@@ -2494,8 +3057,8 @@ function runExplore(options, deps) {
|
|
|
2494
3057
|
}
|
|
2495
3058
|
|
|
2496
3059
|
// src/run-init.ts
|
|
2497
|
-
import { readFile as
|
|
2498
|
-
import { join as
|
|
3060
|
+
import { readFile as readFile8, writeFile as writeFile6 } from "fs/promises";
|
|
3061
|
+
import { join as join11 } from "path";
|
|
2499
3062
|
|
|
2500
3063
|
// src/init.ts
|
|
2501
3064
|
var AGENTS_MARKER_START = "<!-- leglas:start -->";
|
|
@@ -2513,6 +3076,11 @@ direction. Build it directly. A planning or approval step before implementing
|
|
|
2513
3076
|
costs more than the work itself, and the directions on screen are the thing
|
|
2514
3077
|
being asked for.
|
|
2515
3078
|
|
|
3079
|
+
A request that says it came from the running Leglas interface has already
|
|
3080
|
+
completed exploration, request collection and the live-server check. Follow
|
|
3081
|
+
the exact source and registration command in that request directly. Do not
|
|
3082
|
+
repeat \`explore\`, \`requests\`, \`list\`, CLI help/version checks or server startup.
|
|
3083
|
+
|
|
2516
3084
|
When asked for design variations, alternatives, or "a few options":
|
|
2517
3085
|
|
|
2518
3086
|
1. **Add beside what exists. Never replace it.** Every direction has to render
|
|
@@ -2628,7 +3196,7 @@ ${AGENTS_SECTION}`
|
|
|
2628
3196
|
// src/run-init.ts
|
|
2629
3197
|
async function readIfPresent(path) {
|
|
2630
3198
|
try {
|
|
2631
|
-
return await
|
|
3199
|
+
return await readFile8(path, "utf8");
|
|
2632
3200
|
} catch {
|
|
2633
3201
|
return null;
|
|
2634
3202
|
}
|
|
@@ -2636,18 +3204,18 @@ async function readIfPresent(path) {
|
|
|
2636
3204
|
async function runInit(options, deps) {
|
|
2637
3205
|
const existingConfig = findConfigFile(options.cwd);
|
|
2638
3206
|
const plan = planInit({
|
|
2639
|
-
agents: await readIfPresent(
|
|
3207
|
+
agents: await readIfPresent(join11(options.cwd, "AGENTS.md")),
|
|
2640
3208
|
config: existingConfig === null ? null : "present",
|
|
2641
|
-
gitignore: await readIfPresent(
|
|
3209
|
+
gitignore: await readIfPresent(join11(options.cwd, ".gitignore")),
|
|
2642
3210
|
force: options.force
|
|
2643
3211
|
});
|
|
2644
3212
|
const touched = [];
|
|
2645
|
-
for (const
|
|
2646
|
-
await
|
|
2647
|
-
touched.push(
|
|
3213
|
+
for (const write2 of plan.writes) {
|
|
3214
|
+
await writeFile6(join11(options.cwd, write2.path), write2.contents, "utf8");
|
|
3215
|
+
touched.push(write2.path);
|
|
2648
3216
|
}
|
|
2649
3217
|
if (plan.gitignore !== null) {
|
|
2650
|
-
await
|
|
3218
|
+
await writeFile6(join11(options.cwd, ".gitignore"), plan.gitignore, "utf8");
|
|
2651
3219
|
touched.push(".gitignore");
|
|
2652
3220
|
}
|
|
2653
3221
|
if (options.json) {
|
|
@@ -2667,8 +3235,8 @@ async function runInit(options, deps) {
|
|
|
2667
3235
|
|
|
2668
3236
|
// src/run-keep.ts
|
|
2669
3237
|
import { existsSync as existsSync3 } from "fs";
|
|
2670
|
-
import { mkdir as
|
|
2671
|
-
import { dirname as
|
|
3238
|
+
import { mkdir as mkdir6, readFile as readFile9, rm as rm2, writeFile as writeFile7 } from "fs/promises";
|
|
3239
|
+
import { dirname as dirname7, join as join12 } from "path";
|
|
2672
3240
|
|
|
2673
3241
|
// src/keep.ts
|
|
2674
3242
|
import { basename as basename2, extname as extname2, normalize as normalize2 } from "path";
|
|
@@ -2767,18 +3335,18 @@ async function runKeep(options, deps) {
|
|
|
2767
3335
|
if (!resolved.ok) return fail(resolved.error);
|
|
2768
3336
|
const plan = planKeep({ title: resolved.title, previews, to: options.to });
|
|
2769
3337
|
if (!plan.ok) return fail(plan.error);
|
|
2770
|
-
const from =
|
|
2771
|
-
const to =
|
|
3338
|
+
const from = join12(options.cwd, plan.move.from);
|
|
3339
|
+
const to = join12(options.cwd, plan.move.to);
|
|
2772
3340
|
if (!existsSync3(from)) {
|
|
2773
3341
|
return fail(`${plan.move.from} does not exist. Nothing to keep.`);
|
|
2774
3342
|
}
|
|
2775
3343
|
if (existsSync3(to)) {
|
|
2776
3344
|
return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
|
|
2777
3345
|
}
|
|
2778
|
-
const source = await
|
|
2779
|
-
await
|
|
2780
|
-
await
|
|
2781
|
-
await rm2(
|
|
3346
|
+
const source = await readFile9(from, "utf8");
|
|
3347
|
+
await mkdir6(dirname7(to), { recursive: true });
|
|
3348
|
+
await writeFile7(to, renameExport(source, plan.exportName), "utf8");
|
|
3349
|
+
await rm2(join12(options.cwd, plan.removeDir), { recursive: true, force: true });
|
|
2782
3350
|
const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
|
|
2783
3351
|
if (options.json) {
|
|
2784
3352
|
deps.log(
|
|
@@ -2813,11 +3381,11 @@ async function runKeep(options, deps) {
|
|
|
2813
3381
|
|
|
2814
3382
|
// src/run-new.ts
|
|
2815
3383
|
import { existsSync as existsSync4 } from "fs";
|
|
2816
|
-
import { mkdir as
|
|
2817
|
-
import { dirname as
|
|
3384
|
+
import { mkdir as mkdir7, readFile as readFile10, writeFile as writeFile8 } from "fs/promises";
|
|
3385
|
+
import { dirname as dirname8, join as join13 } from "path";
|
|
2818
3386
|
async function readIfPresent2(path) {
|
|
2819
3387
|
try {
|
|
2820
|
-
return await
|
|
3388
|
+
return await readFile10(path, "utf8");
|
|
2821
3389
|
} catch {
|
|
2822
3390
|
return null;
|
|
2823
3391
|
}
|
|
@@ -2825,19 +3393,19 @@ async function readIfPresent2(path) {
|
|
|
2825
3393
|
async function runNew(options, deps) {
|
|
2826
3394
|
let from;
|
|
2827
3395
|
if (options.from !== void 0) {
|
|
2828
|
-
const contents = await readIfPresent2(
|
|
3396
|
+
const contents = await readIfPresent2(join13(options.cwd, options.from));
|
|
2829
3397
|
if (contents === null) {
|
|
2830
|
-
const
|
|
2831
|
-
if (options.json) deps.log(JSON.stringify({ ok: false, error:
|
|
2832
|
-
else deps.log(
|
|
3398
|
+
const message2 = `${options.from} does not exist, so there is nothing to use as the baseline.`;
|
|
3399
|
+
if (options.json) deps.log(JSON.stringify({ ok: false, error: message2 }));
|
|
3400
|
+
else deps.log(message2);
|
|
2833
3401
|
return { exitCode: 1, written: [] };
|
|
2834
3402
|
}
|
|
2835
3403
|
from = { path: options.from, contents };
|
|
2836
3404
|
}
|
|
2837
3405
|
const plan = planNew({
|
|
2838
3406
|
surface: options.surface,
|
|
2839
|
-
packageJson: await readIfPresent2(
|
|
2840
|
-
gitignore: await readIfPresent2(
|
|
3407
|
+
packageJson: await readIfPresent2(join13(options.cwd, "package.json")),
|
|
3408
|
+
gitignore: await readIfPresent2(join13(options.cwd, ".gitignore")),
|
|
2841
3409
|
from
|
|
2842
3410
|
});
|
|
2843
3411
|
const fail = (error) => {
|
|
@@ -2853,26 +3421,26 @@ async function runNew(options, deps) {
|
|
|
2853
3421
|
deps.log(JSON.stringify({ ok: true, files: plan.writes, instructions: plan.instructions, previews: plan.previews }));
|
|
2854
3422
|
return { exitCode: 0, written: [] };
|
|
2855
3423
|
}
|
|
2856
|
-
for (const
|
|
2857
|
-
deps.log(`--- ${
|
|
2858
|
-
deps.log(
|
|
3424
|
+
for (const write2 of plan.writes) {
|
|
3425
|
+
deps.log(`--- ${write2.path}`);
|
|
3426
|
+
deps.log(write2.contents);
|
|
2859
3427
|
}
|
|
2860
3428
|
deps.log(plan.instructions);
|
|
2861
3429
|
return { exitCode: 0, written: [] };
|
|
2862
3430
|
}
|
|
2863
|
-
const existing = plan.writes.filter((
|
|
3431
|
+
const existing = plan.writes.filter((write2) => existsSync4(join13(options.cwd, write2.path)));
|
|
2864
3432
|
if (existing.length > 0) {
|
|
2865
3433
|
return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
|
|
2866
3434
|
}
|
|
2867
3435
|
const written = [];
|
|
2868
|
-
for (const
|
|
2869
|
-
const target =
|
|
2870
|
-
await
|
|
2871
|
-
await
|
|
2872
|
-
written.push(
|
|
3436
|
+
for (const write2 of plan.writes) {
|
|
3437
|
+
const target = join13(options.cwd, write2.path);
|
|
3438
|
+
await mkdir7(dirname8(target), { recursive: true });
|
|
3439
|
+
await writeFile8(target, write2.contents, "utf8");
|
|
3440
|
+
written.push(write2.path);
|
|
2873
3441
|
}
|
|
2874
3442
|
if (plan.gitignore !== null) {
|
|
2875
|
-
await
|
|
3443
|
+
await writeFile8(join13(options.cwd, ".gitignore"), plan.gitignore, "utf8");
|
|
2876
3444
|
written.push(".gitignore");
|
|
2877
3445
|
}
|
|
2878
3446
|
if (options.json) {
|
|
@@ -2893,21 +3461,21 @@ async function runNew(options, deps) {
|
|
|
2893
3461
|
}
|
|
2894
3462
|
|
|
2895
3463
|
// src/run-previews.ts
|
|
2896
|
-
import { readFile as
|
|
2897
|
-
import { join as
|
|
3464
|
+
import { readFile as readFile11, writeFile as writeFile9 } from "fs/promises";
|
|
3465
|
+
import { join as join14 } from "path";
|
|
2898
3466
|
function envelope(deps, ok, body) {
|
|
2899
3467
|
deps.log(JSON.stringify({ ok, ...body }));
|
|
2900
3468
|
}
|
|
2901
3469
|
async function ensureIgnored(cwd) {
|
|
2902
|
-
const path =
|
|
3470
|
+
const path = join14(cwd, ".gitignore");
|
|
2903
3471
|
let current = null;
|
|
2904
3472
|
try {
|
|
2905
|
-
current = await
|
|
3473
|
+
current = await readFile11(path, "utf8");
|
|
2906
3474
|
} catch {
|
|
2907
3475
|
current = null;
|
|
2908
3476
|
}
|
|
2909
3477
|
const next = ignoreEntry(current);
|
|
2910
|
-
if (next !== null) await
|
|
3478
|
+
if (next !== null) await writeFile9(path, next, "utf8");
|
|
2911
3479
|
}
|
|
2912
3480
|
async function runAdd(options, deps) {
|
|
2913
3481
|
const loaded = await loadConfig(options.cwd);
|
|
@@ -2931,7 +3499,8 @@ async function runAdd(options, deps) {
|
|
|
2931
3499
|
tags: options.preview.tags,
|
|
2932
3500
|
branch: options.preview.branch,
|
|
2933
3501
|
file: options.preview.file,
|
|
2934
|
-
basedOn: options.preview.basedOn
|
|
3502
|
+
basedOn: options.preview.basedOn,
|
|
3503
|
+
askedFor: options.preview.askedFor
|
|
2935
3504
|
},
|
|
2936
3505
|
shared
|
|
2937
3506
|
);
|
|
@@ -2991,6 +3560,7 @@ async function runList(options, deps) {
|
|
|
2991
3560
|
note: preview.note ?? null,
|
|
2992
3561
|
tags: preview.tags,
|
|
2993
3562
|
basedOn: preview.basedOn ?? null,
|
|
3563
|
+
askedFor: preview.askedFor ?? null,
|
|
2994
3564
|
local: preview.local,
|
|
2995
3565
|
branch: preview.branch ?? null,
|
|
2996
3566
|
file: preview.file ?? null
|
|
@@ -3153,23 +3723,23 @@ async function runShow(options, deps) {
|
|
|
3153
3723
|
|
|
3154
3724
|
// src/run-watch.ts
|
|
3155
3725
|
import { spawn as spawn3 } from "child_process";
|
|
3156
|
-
import { mkdir as
|
|
3157
|
-
import { dirname as
|
|
3726
|
+
import { mkdir as mkdir8, readFile as readFile12, writeFile as writeFile10 } from "fs/promises";
|
|
3727
|
+
import { dirname as dirname9, join as join15 } from "path";
|
|
3158
3728
|
var POLL_MS2 = 2e3;
|
|
3159
3729
|
var HEARTBEAT_TIMEOUT_MS = 1e3;
|
|
3160
3730
|
async function saveTemplate(cwd, run3) {
|
|
3161
|
-
const path =
|
|
3731
|
+
const path = join15(cwd, WATCH_PATH);
|
|
3162
3732
|
let config = {};
|
|
3163
3733
|
try {
|
|
3164
|
-
const parsed2 = JSON.parse(await
|
|
3734
|
+
const parsed2 = JSON.parse(await readFile12(path, "utf8"));
|
|
3165
3735
|
if (typeof parsed2 === "object" && parsed2 !== null && !Array.isArray(parsed2)) {
|
|
3166
3736
|
config = parsed2;
|
|
3167
3737
|
}
|
|
3168
3738
|
} catch {
|
|
3169
3739
|
}
|
|
3170
3740
|
config.run = run3;
|
|
3171
|
-
await
|
|
3172
|
-
await
|
|
3741
|
+
await mkdir8(dirname9(path), { recursive: true });
|
|
3742
|
+
await writeFile10(path, `${JSON.stringify(config, null, 2)}
|
|
3173
3743
|
`, "utf8");
|
|
3174
3744
|
}
|
|
3175
3745
|
function spawnAgent(command, args, cwd) {
|
|
@@ -3255,9 +3825,13 @@ async function runWatch(options, deps) {
|
|
|
3255
3825
|
return;
|
|
3256
3826
|
}
|
|
3257
3827
|
failed.add(request.id);
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3828
|
+
const failure = classifyFailure({
|
|
3829
|
+
agent: (shownCommand2.split(/\s+/)[0] ?? command).split("/").pop() ?? command,
|
|
3830
|
+
error: outcome.ok ? null : outcome.error,
|
|
3831
|
+
exitCode: outcome.ok ? outcome.code : null
|
|
3832
|
+
});
|
|
3833
|
+
await markFailed(options.cwd, request.id, failure);
|
|
3834
|
+
deps.error(` failed ${request.title}: ${failure.message}`);
|
|
3261
3835
|
deps.error(" Left in the queue and not retried.");
|
|
3262
3836
|
};
|
|
3263
3837
|
const tick = async () => {
|
|
@@ -3304,18 +3878,28 @@ async function runWatch(options, deps) {
|
|
|
3304
3878
|
// src/run.ts
|
|
3305
3879
|
import { existsSync as existsSync5 } from "fs";
|
|
3306
3880
|
import { createRequire } from "module";
|
|
3307
|
-
import { basename as basename3, dirname as
|
|
3881
|
+
import { basename as basename3, dirname as dirname10, join as join16, relative as relative4 } from "path";
|
|
3308
3882
|
import { fileURLToPath } from "url";
|
|
3309
3883
|
function findShellDir() {
|
|
3310
|
-
const bundled =
|
|
3311
|
-
if (existsSync5(
|
|
3884
|
+
const bundled = join16(dirname10(fileURLToPath(import.meta.url)), "shell");
|
|
3885
|
+
if (existsSync5(join16(bundled, "index.html"))) return bundled;
|
|
3312
3886
|
try {
|
|
3313
3887
|
const require2 = createRequire(import.meta.url);
|
|
3314
|
-
return
|
|
3888
|
+
return dirname10(require2.resolve("@leglas/shell/dist/index.html"));
|
|
3315
3889
|
} catch {
|
|
3316
3890
|
return null;
|
|
3317
3891
|
}
|
|
3318
3892
|
}
|
|
3893
|
+
function shellWord(value) {
|
|
3894
|
+
if (/^[A-Za-z0-9_./:=+\\-]+$/.test(value)) return value;
|
|
3895
|
+
if (process.platform === "win32") return `"${value.replaceAll('"', '""')}"`;
|
|
3896
|
+
return `'${value.replaceAll("'", `'\\''`)}'`;
|
|
3897
|
+
}
|
|
3898
|
+
function embeddedLeglasCommand() {
|
|
3899
|
+
const entry = join16(dirname10(fileURLToPath(import.meta.url)), "bin.js");
|
|
3900
|
+
if (!existsSync5(entry)) return "npx -y leglas";
|
|
3901
|
+
return [process.execPath, entry].map(shellWord).join(" ");
|
|
3902
|
+
}
|
|
3319
3903
|
async function run2(options, deps) {
|
|
3320
3904
|
const loaded = await loadConfig(options.cwd);
|
|
3321
3905
|
const local = await readLocalPreviews(options.cwd);
|
|
@@ -3345,7 +3929,7 @@ async function run2(options, deps) {
|
|
|
3345
3929
|
const fileMounts = /* @__PURE__ */ new Map();
|
|
3346
3930
|
for (const preview of merged?.previews ?? []) {
|
|
3347
3931
|
if (preview.file !== void 0) {
|
|
3348
|
-
const absolute =
|
|
3932
|
+
const absolute = join16(options.cwd, preview.file);
|
|
3349
3933
|
if (!existsSync5(absolute)) {
|
|
3350
3934
|
worktreeErrors.push(
|
|
3351
3935
|
`"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
|
|
@@ -3356,7 +3940,7 @@ async function run2(options, deps) {
|
|
|
3356
3940
|
for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
|
|
3357
3941
|
slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
|
|
3358
3942
|
}
|
|
3359
|
-
fileMounts.set(slug,
|
|
3943
|
+
fileMounts.set(slug, dirname10(absolute));
|
|
3360
3944
|
previews.push({
|
|
3361
3945
|
...preview,
|
|
3362
3946
|
url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
|
|
@@ -3397,6 +3981,7 @@ async function run2(options, deps) {
|
|
|
3397
3981
|
// directory does. Either way saved layout survives a port change.
|
|
3398
3982
|
project: loaded.path ?? options.cwd,
|
|
3399
3983
|
cwd: options.cwd,
|
|
3984
|
+
leglasCommand: embeddedLeglasCommand(),
|
|
3400
3985
|
...options.port === void 0 ? {} : { port: options.port }
|
|
3401
3986
|
});
|
|
3402
3987
|
const url = `${server.url}${LEGLAS_PREFIX}`;
|
|
@@ -3502,6 +4087,7 @@ Options for add
|
|
|
3502
4087
|
--branch <name> Back the preview with a checkout of this git branch
|
|
3503
4088
|
--file <path> Preview a plain HTML file served by Leglas itself
|
|
3504
4089
|
--based-on <title> The direction this is a variant of; groups the family
|
|
4090
|
+
--asked-for <text> The change that was asked for, in the words that were typed
|
|
3505
4091
|
`;
|
|
3506
4092
|
function version() {
|
|
3507
4093
|
const require2 = createRequire2(import.meta.url);
|