leglas 0.2.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 +125 -24
- package/dist/bin.js +1084 -242
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1084 -238
- 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-dMGYwlE1.css +0 -1
- package/dist/shell/assets/index-r7sGM7WL.js +0 -10
- 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
|
@@ -45,7 +45,7 @@ function parseNew(rest) {
|
|
|
45
45
|
if (surface === void 0) {
|
|
46
46
|
return {
|
|
47
47
|
kind: "error",
|
|
48
|
-
message: "leglas new needs a surface name, for example: leglas new hero"
|
|
48
|
+
message: "leglas new needs a surface name, for example: npx leglas new hero"
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
return { kind: "new", surface, print, json, from };
|
|
@@ -128,7 +128,7 @@ function parseClassify(rest) {
|
|
|
128
128
|
if (changes.length === 0) {
|
|
129
129
|
return {
|
|
130
130
|
kind: "error",
|
|
131
|
-
message: "leglas classify needs what the direction will touch, for example: leglas classify --change package.json --rewrite src/theme.css"
|
|
131
|
+
message: "leglas classify needs what the direction will touch, for example: npx leglas classify --change package.json --rewrite src/theme.css"
|
|
132
132
|
};
|
|
133
133
|
}
|
|
134
134
|
return { kind: "classify", changes, json };
|
|
@@ -203,7 +203,7 @@ function parseArgs(argv) {
|
|
|
203
203
|
if (title === void 0) {
|
|
204
204
|
return {
|
|
205
205
|
kind: "error",
|
|
206
|
-
message: 'leglas keep needs a direction title, for example: leglas keep "Aurora" --to src/components/hero.tsx'
|
|
206
|
+
message: 'leglas keep needs a direction title, for example: npx leglas keep "Aurora" --to src/components/hero.tsx'
|
|
207
207
|
};
|
|
208
208
|
}
|
|
209
209
|
if (to === void 0) {
|
|
@@ -253,7 +253,7 @@ function parseArgs(argv) {
|
|
|
253
253
|
if (surface === void 0) {
|
|
254
254
|
return {
|
|
255
255
|
kind: "error",
|
|
256
|
-
message: "leglas explore needs a surface name, for example: leglas explore hero --count 6"
|
|
256
|
+
message: "leglas explore needs a surface name, for example: npx leglas explore hero --count 6"
|
|
257
257
|
};
|
|
258
258
|
}
|
|
259
259
|
return { kind: "explore", surface, count, basedOn, json };
|
|
@@ -294,7 +294,7 @@ function parseArgs(argv) {
|
|
|
294
294
|
if (title === void 0) {
|
|
295
295
|
return {
|
|
296
296
|
kind: "error",
|
|
297
|
-
message: 'leglas show needs a direction title, for example: leglas show "Aurora" --json'
|
|
297
|
+
message: 'leglas show needs a direction title, for example: npx leglas show "Aurora" --json'
|
|
298
298
|
};
|
|
299
299
|
}
|
|
300
300
|
return { kind: "show", title, json };
|
|
@@ -556,8 +556,8 @@ The set exists to be chosen from, and the choice only means something if the dir
|
|
|
556
556
|
One trap, seen every time this goes wrong: a set collapses toward whichever direction is built first. Decide all ${count} before building any, and if two would read as the same direction at a glance, replace one of them.` : `Build ${count} variations of the "${basedOn}" direction for "${surface}".
|
|
557
557
|
|
|
558
558
|
The set exists to pick a variant of a direction already chosen, so every variation must stay recognisably that direction. The trap here is drift: change enough and the comparison stops being about the variant. Vary each one deliberately and hold everything else still; if a variation grows into a new direction, it belongs in its own exploration instead.`;
|
|
559
|
-
const register = basedOn === null ? ` leglas add --title "<name>" --url "/?v-${slug}=<key>" --note "<the idea, one line>"` : ` leglas add --title "<name>" --url "/?v-${slug}=<key>" --based-on ${JSON.stringify(basedOn)} --note "<the idea, one line>"`;
|
|
560
|
-
const mechanics = `Each one is its own file under .leglas/variants/${slug}/, listed in the DIRECTIONS map in that folder's switch file. If there is no switch file yet, run \`leglas new ${slug}\` first. Register each one the moment it renders, not the set at the end. The interface picks a registration up within seconds, so whoever asked watches the set fill in:
|
|
559
|
+
const register = basedOn === null ? ` npx leglas add --title "<name>" --url "/?v-${slug}=<key>" --note "<the idea, one line>"` : ` npx leglas add --title "<name>" --url "/?v-${slug}=<key>" --based-on ${JSON.stringify(basedOn)} --note "<the idea, one line>"`;
|
|
560
|
+
const mechanics = `Each one is its own file under .leglas/variants/${slug}/, listed in the DIRECTIONS map in that folder's switch file. If there is no switch file yet, run \`npx leglas new ${slug}\` first. Register each one the moment it renders, not the set at the end. The interface picks a registration up within seconds, so whoever asked watches the set fill in:
|
|
561
561
|
|
|
562
562
|
${register}
|
|
563
563
|
|
|
@@ -609,46 +609,48 @@ When asked for design variations, alternatives, or "a few options":
|
|
|
609
609
|
If a direction cannot be additive, because it must change dependencies,
|
|
610
610
|
change build configuration, or rewrite an existing file's behaviour, ask
|
|
611
611
|
where it should live before writing it:
|
|
612
|
-
\`leglas classify --change package.json --rewrite src/theme.css --json\`.
|
|
612
|
+
\`npx leglas classify --change package.json --rewrite src/theme.css --json\`.
|
|
613
613
|
When the answer is \`checkout\`, build the direction on its own git branch
|
|
614
|
-
and register it with \`leglas add --title "\u2026" --url "/" --branch <branch>\`
|
|
614
|
+
and register it with \`npx leglas add --title "\u2026" --url "/" --branch <branch>\`
|
|
615
615
|
(the config needs \`devCommand\` with \`{port}\`). Everything below is the
|
|
616
616
|
ordinary, in-app path.
|
|
617
|
-
2. Run \`leglas explore <surface> --count <n>\` first, adding
|
|
617
|
+
2. Run \`npx leglas explore <surface> --count <n>\` first, adding
|
|
618
618
|
\`--based-on "<title>"\` when the user wants variations of a direction they
|
|
619
619
|
already like. It prints what the set needs and how to register it. In
|
|
620
620
|
short: new directions must genuinely disagree with each other, variants of
|
|
621
621
|
one must not, and either way decide the whole set before building any of
|
|
622
622
|
it. The designs themselves are yours.
|
|
623
623
|
3. If the surface has no switcher yet, run
|
|
624
|
-
\`leglas new <surface> --from <the component that renders it today>\`. It
|
|
624
|
+
\`npx leglas new <surface> --from <the component that renders it today>\`. It
|
|
625
625
|
writes one under \`.leglas/variants/<surface>/\` and prints the single line
|
|
626
626
|
to add where that surface renders. \`--from\` makes the baseline re-export
|
|
627
627
|
the real component rather than copying it, so it stays live.
|
|
628
|
-
4. Before building, make sure the interface is up. If
|
|
629
|
-
already running, tell the user to run
|
|
630
|
-
rather than when the set is done: the rail picks up each
|
|
631
|
-
within seconds, so they get to watch the exploration fill in.
|
|
628
|
+
4. Before building, make sure the interface is up. If Leglas is not
|
|
629
|
+
already running, tell the user to run \`npx leglas\`, and hand them the
|
|
630
|
+
URL now rather than when the set is done: the rail picks up each
|
|
631
|
+
registration within seconds, so they get to watch the exploration fill in.
|
|
632
632
|
5. Build one direction at a time: its own file beside the others in
|
|
633
633
|
\`.leglas/variants/<surface>/\`, listed in the \`DIRECTIONS\` map in that
|
|
634
634
|
folder's \`switch\` file, then registered the moment it renders:
|
|
635
|
-
\`leglas add --title "Aurora" --url "/?v-<surface>=aurora" --note "One line on the idea."\`
|
|
635
|
+
\`npx leglas add --title "Aurora" --url "/?v-<surface>=aurora" --note "One line on the idea."\`
|
|
636
636
|
Register each direction as it lands, never the whole set at the end. To
|
|
637
637
|
the user watching the rail, a batch at the end is minutes of nothing and
|
|
638
638
|
then everything at once.
|
|
639
639
|
|
|
640
|
-
When the user asks to change one direction, check \`leglas requests --json\`
|
|
640
|
+
When the user asks to change one direction, check \`npx leglas requests --json\`
|
|
641
641
|
first: they may have described it from the interface, and the request names the
|
|
642
|
-
exact file.
|
|
642
|
+
exact file. Acknowledge them with \`npx leglas requests --clear\` once done: it
|
|
643
|
+
drops what you collected and reports anything the user typed while you worked,
|
|
644
|
+
which is yours to collect and do next.
|
|
643
645
|
|
|
644
646
|
If the user wants requests handled the moment they are typed, without relaying
|
|
645
|
-
each one, tell them about \`leglas watch --run "claude -p {prompt}"\` (any
|
|
647
|
+
each one, tell them about \`npx leglas watch --run "claude -p {prompt}"\` (any
|
|
646
648
|
agent command works; {prompt} receives the request). It runs in their
|
|
647
649
|
terminal, hands each request to that command as it arrives, and the interface
|
|
648
650
|
shows the request's progress.
|
|
649
651
|
|
|
650
652
|
When the user picks a winner, run
|
|
651
|
-
\`leglas keep "<title>" --to <path in real source>\`. It moves that direction
|
|
653
|
+
\`npx leglas keep "<title>" --to <path in real source>\`. It moves that direction
|
|
652
654
|
out of the ignored directory, deletes the rest of the exploration, and drops
|
|
653
655
|
them from the rail. Then change their component to use the kept component
|
|
654
656
|
instead of the switcher.
|
|
@@ -659,14 +661,14 @@ Useful to know:
|
|
|
659
661
|
ships. Move a direction into real source only when it wins.
|
|
660
662
|
- If the project has no running app yet, a direction can be a plain HTML
|
|
661
663
|
file: write it under \`.leglas/pages/\` and register it with
|
|
662
|
-
\`leglas add --title "Aurora" --file .leglas/pages/aurora.html\`. Leglas
|
|
664
|
+
\`npx leglas add --title "Aurora" --file .leglas/pages/aurora.html\`. Leglas
|
|
663
665
|
serves the file itself, so no dev server is needed. Sibling assets in the
|
|
664
666
|
same directory resolve normally.
|
|
665
667
|
- Titles identify previews and must be unique. The user may rename one in the
|
|
666
668
|
rail, which renames it on their machine only; the commands answer to either
|
|
667
669
|
name, so use whichever they said.
|
|
668
|
-
- \`leglas list\` shows every direction, shared and local.
|
|
669
|
-
- \`leglas show "<title>" --json\` answers for one of them: the file behind it,
|
|
670
|
+
- \`npx leglas list\` shows every direction, shared and local.
|
|
671
|
+
- \`npx leglas show "<title>" --json\` answers for one of them: the file behind it,
|
|
670
672
|
the variants based on it, what it is being compared against, and anything
|
|
671
673
|
they have asked for that is not done yet. Run it when handed a direction you
|
|
672
674
|
did not register yourself.
|
|
@@ -676,7 +678,7 @@ Useful to know:
|
|
|
676
678
|
${AGENTS_MARKER_END}
|
|
677
679
|
`;
|
|
678
680
|
var STARTER_CONFIG = `// Previews are URLs of your own app. Add one per direction you want to
|
|
679
|
-
// compare, then run \`leglas\`.
|
|
681
|
+
// compare, then run \`npx leglas\`.
|
|
680
682
|
export default {
|
|
681
683
|
// Where your dev server is. Override at the command line with --user-port.
|
|
682
684
|
devServer: "http://localhost:3000",
|
|
@@ -843,6 +845,360 @@ function normalizeConfig(raw, options = {}) {
|
|
|
843
845
|
};
|
|
844
846
|
}
|
|
845
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
|
+
|
|
846
1202
|
// ../server/dist/classify.js
|
|
847
1203
|
var MANIFESTS = /* @__PURE__ */ new Set([
|
|
848
1204
|
"package.json",
|
|
@@ -880,12 +1236,12 @@ function isExplorationFile(path) {
|
|
|
880
1236
|
}
|
|
881
1237
|
var CHECKOUT_STEPS = [
|
|
882
1238
|
"Build the direction on its own branch: git switch -c <branch>, commit it there, switch back.",
|
|
883
|
-
'Register it: leglas add --title "<title>" --url "/" --branch <branch>.',
|
|
1239
|
+
'Register it: npx leglas add --title "<title>" --url "/" --branch <branch>.',
|
|
884
1240
|
"Make sure the config sets devCommand (with {port}), so Leglas can start the checkout."
|
|
885
1241
|
];
|
|
886
1242
|
var IN_APP_STEPS = [
|
|
887
1243
|
"Author it additively under .leglas/variants/<surface>/, beside the existing directions.",
|
|
888
|
-
'Register it: leglas add --title "<title>" --url "/?v-<surface>=<direction>".'
|
|
1244
|
+
'Register it: npx leglas add --title "<title>" --url "/?v-<surface>=<direction>".'
|
|
889
1245
|
];
|
|
890
1246
|
function classifyDirection(input) {
|
|
891
1247
|
const checkout = (reason) => ({ level: "checkout", reason, steps: CHECKOUT_STEPS });
|
|
@@ -910,7 +1266,7 @@ function classifyDirection(input) {
|
|
|
910
1266
|
|
|
911
1267
|
// ../server/dist/find-config.js
|
|
912
1268
|
import { existsSync } from "fs";
|
|
913
|
-
import { dirname, join, parse } from "path";
|
|
1269
|
+
import { dirname as dirname2, join as join2, parse } from "path";
|
|
914
1270
|
var CONFIG_BASENAMES = [
|
|
915
1271
|
"leglas.config.ts",
|
|
916
1272
|
"leglas.config.mjs",
|
|
@@ -922,13 +1278,13 @@ function findConfigFile(startDir) {
|
|
|
922
1278
|
let dir = startDir;
|
|
923
1279
|
for (; ; ) {
|
|
924
1280
|
for (const basename4 of CONFIG_BASENAMES) {
|
|
925
|
-
const candidate =
|
|
1281
|
+
const candidate = join2(dir, basename4);
|
|
926
1282
|
if (existsSync(candidate))
|
|
927
1283
|
return candidate;
|
|
928
1284
|
}
|
|
929
1285
|
if (dir === root)
|
|
930
1286
|
return null;
|
|
931
|
-
const parent =
|
|
1287
|
+
const parent = dirname2(dir);
|
|
932
1288
|
if (parent === dir)
|
|
933
1289
|
return null;
|
|
934
1290
|
dir = parent;
|
|
@@ -936,19 +1292,19 @@ function findConfigFile(startDir) {
|
|
|
936
1292
|
}
|
|
937
1293
|
|
|
938
1294
|
// ../server/dist/load-config.js
|
|
939
|
-
import { readFile } from "fs/promises";
|
|
940
|
-
import { relative } from "path";
|
|
1295
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
1296
|
+
import { relative as relative2 } from "path";
|
|
941
1297
|
import { pathToFileURL } from "url";
|
|
942
1298
|
async function loadConfig(cwd) {
|
|
943
1299
|
const path = findConfigFile(cwd);
|
|
944
1300
|
if (path === null) {
|
|
945
1301
|
return { ...normalizeConfig(void 0), path: null };
|
|
946
1302
|
}
|
|
947
|
-
const label =
|
|
1303
|
+
const label = relative2(cwd, path) || path;
|
|
948
1304
|
let exported;
|
|
949
1305
|
try {
|
|
950
1306
|
if (path.endsWith(".json")) {
|
|
951
|
-
exported = JSON.parse(await
|
|
1307
|
+
exported = JSON.parse(await readFile2(path, "utf8"));
|
|
952
1308
|
} else {
|
|
953
1309
|
const module = await import(pathToFileURL(path).href);
|
|
954
1310
|
if (!("default" in module)) {
|
|
@@ -969,14 +1325,14 @@ async function loadConfig(cwd) {
|
|
|
969
1325
|
}
|
|
970
1326
|
|
|
971
1327
|
// ../server/dist/local-previews.js
|
|
972
|
-
import { mkdir, readFile as
|
|
973
|
-
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";
|
|
974
1330
|
var LOCAL_PREVIEWS_PATH = ".leglas/previews.json";
|
|
975
1331
|
async function readLocalPreviews(cwd) {
|
|
976
|
-
const path =
|
|
1332
|
+
const path = join3(cwd, LOCAL_PREVIEWS_PATH);
|
|
977
1333
|
let raw;
|
|
978
1334
|
try {
|
|
979
|
-
raw = await
|
|
1335
|
+
raw = await readFile3(path, "utf8");
|
|
980
1336
|
} catch {
|
|
981
1337
|
return { previews: [], errors: [] };
|
|
982
1338
|
}
|
|
@@ -1025,9 +1381,9 @@ async function addLocalPreview(cwd, input, shared) {
|
|
|
1025
1381
|
if (check.config === null) {
|
|
1026
1382
|
return { ok: false, error: check.errors.join(" ") };
|
|
1027
1383
|
}
|
|
1028
|
-
const path =
|
|
1029
|
-
await
|
|
1030
|
-
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)}
|
|
1031
1387
|
`, "utf8");
|
|
1032
1388
|
return { ok: true };
|
|
1033
1389
|
}
|
|
@@ -1040,9 +1396,9 @@ async function dropLocalPreviews(cwd, titles) {
|
|
|
1040
1396
|
const keep = existing.previews.filter((preview) => !titles.includes(preview.title));
|
|
1041
1397
|
if (keep.length === existing.previews.length)
|
|
1042
1398
|
return 0;
|
|
1043
|
-
const path =
|
|
1044
|
-
await
|
|
1045
|
-
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)}
|
|
1046
1402
|
`, "utf8");
|
|
1047
1403
|
return existing.previews.length - keep.length;
|
|
1048
1404
|
}
|
|
@@ -1111,10 +1467,10 @@ ${headers}\r
|
|
|
1111
1467
|
}
|
|
1112
1468
|
|
|
1113
1469
|
// ../server/dist/worktree.js
|
|
1114
|
-
import { execFile, spawn } from "child_process";
|
|
1470
|
+
import { execFile, spawn as spawn2 } from "child_process";
|
|
1115
1471
|
import { rm } from "fs/promises";
|
|
1116
1472
|
import net2 from "net";
|
|
1117
|
-
import { join as
|
|
1473
|
+
import { join as join4 } from "path";
|
|
1118
1474
|
import { promisify } from "util";
|
|
1119
1475
|
var run = promisify(execFile);
|
|
1120
1476
|
var WORKTREES_DIR = ".leglas/worktrees";
|
|
@@ -1151,7 +1507,7 @@ function answers(port) {
|
|
|
1151
1507
|
}
|
|
1152
1508
|
async function startWorktree(options) {
|
|
1153
1509
|
const readyTimeoutMs = options.readyTimeoutMs ?? 9e4;
|
|
1154
|
-
const path =
|
|
1510
|
+
const path = join4(options.cwd, WORKTREES_DIR, worktreeSlug(options.branch));
|
|
1155
1511
|
const log = options.onLog ?? (() => {
|
|
1156
1512
|
});
|
|
1157
1513
|
await rm(path, { recursive: true, force: true });
|
|
@@ -1212,7 +1568,7 @@ async function startAppProcess(options) {
|
|
|
1212
1568
|
const port = await freePort();
|
|
1213
1569
|
let child;
|
|
1214
1570
|
try {
|
|
1215
|
-
child =
|
|
1571
|
+
child = spawn2(substitutePort(options.devCommand, port), {
|
|
1216
1572
|
cwd: options.cwd,
|
|
1217
1573
|
shell: true,
|
|
1218
1574
|
// Own process group, so stopping kills the shell and whatever it spawned
|
|
@@ -1252,9 +1608,9 @@ async function startAppProcess(options) {
|
|
|
1252
1608
|
}
|
|
1253
1609
|
|
|
1254
1610
|
// ../server/dist/requests.js
|
|
1255
|
-
import { mkdir as
|
|
1611
|
+
import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
1256
1612
|
import { randomBytes } from "crypto";
|
|
1257
|
-
import { dirname as
|
|
1613
|
+
import { dirname as dirname4, join as join5 } from "path";
|
|
1258
1614
|
var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
|
|
1259
1615
|
function targetFor(url) {
|
|
1260
1616
|
if (!url.startsWith("/"))
|
|
@@ -1280,17 +1636,20 @@ function composeRequest(preview, intent) {
|
|
|
1280
1636
|
const target = preview.file ?? targetFor(preview.url);
|
|
1281
1637
|
const cleaned = intent.trim();
|
|
1282
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. `;
|
|
1283
1640
|
const prompt = `In this project, change only the "${preview.title}" design direction. ${where}
|
|
1284
1641
|
|
|
1285
1642
|
What to change: ${cleaned}
|
|
1286
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
|
+
|
|
1287
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.`;
|
|
1288
1647
|
return { prompt, target };
|
|
1289
1648
|
}
|
|
1290
1649
|
var REQUESTS_PATH = ".leglas/requests.json";
|
|
1291
1650
|
async function readRequests(cwd) {
|
|
1292
1651
|
try {
|
|
1293
|
-
const raw = await
|
|
1652
|
+
const raw = await readFile4(join5(cwd, REQUESTS_PATH), "utf8");
|
|
1294
1653
|
const parsed = JSON.parse(raw);
|
|
1295
1654
|
if (!Array.isArray(parsed.requests))
|
|
1296
1655
|
return [];
|
|
@@ -1307,9 +1666,9 @@ async function readRequests(cwd) {
|
|
|
1307
1666
|
}
|
|
1308
1667
|
}
|
|
1309
1668
|
async function writeQueue(cwd, requests) {
|
|
1310
|
-
const path =
|
|
1311
|
-
await
|
|
1312
|
-
await
|
|
1669
|
+
const path = join5(cwd, REQUESTS_PATH);
|
|
1670
|
+
await mkdir3(dirname4(path), { recursive: true });
|
|
1671
|
+
await writeFile3(path, `${JSON.stringify({ requests }, null, 2)}
|
|
1313
1672
|
`, "utf8");
|
|
1314
1673
|
}
|
|
1315
1674
|
async function appendRequest(cwd, request) {
|
|
@@ -1341,16 +1700,272 @@ async function removeRequest(cwd, id) {
|
|
|
1341
1700
|
return true;
|
|
1342
1701
|
}
|
|
1343
1702
|
async function clearRequests(cwd) {
|
|
1344
|
-
await
|
|
1703
|
+
const requests = await readRequests(cwd);
|
|
1704
|
+
const pending = requests.filter((request) => request.status !== "picked-up");
|
|
1705
|
+
const cleared = requests.length - pending.length;
|
|
1706
|
+
if (cleared > 0)
|
|
1707
|
+
await writeQueue(cwd, pending);
|
|
1708
|
+
return { cleared, pending: pending.length };
|
|
1709
|
+
}
|
|
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
|
+
};
|
|
1345
1960
|
}
|
|
1346
1961
|
|
|
1347
1962
|
// ../server/dist/renames.js
|
|
1348
|
-
import { mkdir as
|
|
1349
|
-
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";
|
|
1350
1965
|
var RENAMES_PATH = ".leglas/renames.json";
|
|
1351
1966
|
async function readRenames(cwd) {
|
|
1352
1967
|
try {
|
|
1353
|
-
const raw = await
|
|
1968
|
+
const raw = await readFile5(join6(cwd, RENAMES_PATH), "utf8");
|
|
1354
1969
|
const parsed = JSON.parse(raw);
|
|
1355
1970
|
if (parsed.renames === null || typeof parsed.renames !== "object")
|
|
1356
1971
|
return {};
|
|
@@ -1360,9 +1975,9 @@ async function readRenames(cwd) {
|
|
|
1360
1975
|
}
|
|
1361
1976
|
}
|
|
1362
1977
|
async function writeRenames(cwd, renames) {
|
|
1363
|
-
const path =
|
|
1364
|
-
await
|
|
1365
|
-
await
|
|
1978
|
+
const path = join6(cwd, RENAMES_PATH);
|
|
1979
|
+
await mkdir4(dirname5(path), { recursive: true });
|
|
1980
|
+
await writeFile4(path, `${JSON.stringify({ renames }, null, 2)}
|
|
1366
1981
|
`, "utf8");
|
|
1367
1982
|
}
|
|
1368
1983
|
function resolveTitle(input, titles, renames) {
|
|
@@ -1380,7 +1995,7 @@ function resolveTitle(input, titles, renames) {
|
|
|
1380
1995
|
import { createReadStream, existsSync as existsSync2, statSync } from "fs";
|
|
1381
1996
|
import http2 from "http";
|
|
1382
1997
|
import net3 from "net";
|
|
1383
|
-
import { extname, join as
|
|
1998
|
+
import { extname, join as join7, normalize, relative as relative3 } from "path";
|
|
1384
1999
|
var LEGLAS_PREFIX = "/leglas";
|
|
1385
2000
|
var DEFAULT_PORT = 4100;
|
|
1386
2001
|
var PORT_ATTEMPTS = 20;
|
|
@@ -1411,6 +2026,52 @@ function sendJson(res, status, body) {
|
|
|
1411
2026
|
});
|
|
1412
2027
|
res.end(payload);
|
|
1413
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
|
+
}
|
|
1414
2075
|
function probe(target, timeoutMs = 1e3) {
|
|
1415
2076
|
return new Promise((resolve) => {
|
|
1416
2077
|
let url;
|
|
@@ -1432,8 +2093,8 @@ function probe(target, timeoutMs = 1e3) {
|
|
|
1432
2093
|
});
|
|
1433
2094
|
}
|
|
1434
2095
|
function serveFrom(res, dir, relativePath) {
|
|
1435
|
-
const
|
|
1436
|
-
const candidate =
|
|
2096
|
+
const relative5 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
|
|
2097
|
+
const candidate = join7(dir, relative5);
|
|
1437
2098
|
if (!candidate.startsWith(dir))
|
|
1438
2099
|
return false;
|
|
1439
2100
|
if (!existsSync2(candidate) || !statSync(candidate).isFile())
|
|
@@ -1446,9 +2107,36 @@ function serveFrom(res, dir, relativePath) {
|
|
|
1446
2107
|
return true;
|
|
1447
2108
|
}
|
|
1448
2109
|
function serveShellFile(res, shellDir, urlPath) {
|
|
1449
|
-
const
|
|
1450
|
-
const isRoot =
|
|
1451
|
-
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);
|
|
2113
|
+
}
|
|
2114
|
+
function snapshotConfig(cwd) {
|
|
2115
|
+
const path = findConfigFile(cwd);
|
|
2116
|
+
if (path === null)
|
|
2117
|
+
return null;
|
|
2118
|
+
try {
|
|
2119
|
+
return { path, mtimeMs: statSync(path).mtimeMs };
|
|
2120
|
+
} catch {
|
|
2121
|
+
return null;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
function configStalenessNotice(cwd, boot, current) {
|
|
2125
|
+
if (boot === null && current === null)
|
|
2126
|
+
return null;
|
|
2127
|
+
if (boot === null && current !== null) {
|
|
2128
|
+
const label = relative3(cwd, current.path) || current.path;
|
|
2129
|
+
return `${label} appeared after Leglas started. Restart leglas to pick it up.`;
|
|
2130
|
+
}
|
|
2131
|
+
if (boot !== null && current === null) {
|
|
2132
|
+
const label = relative3(cwd, boot.path) || boot.path;
|
|
2133
|
+
return `${label} was removed after Leglas started. Restart leglas to run without it.`;
|
|
2134
|
+
}
|
|
2135
|
+
if (boot !== null && current !== null && (boot.path !== current.path || boot.mtimeMs !== current.mtimeMs)) {
|
|
2136
|
+
const label = relative3(cwd, current.path) || current.path;
|
|
2137
|
+
return `${label} changed after Leglas started. Restart leglas to pick it up.`;
|
|
2138
|
+
}
|
|
2139
|
+
return null;
|
|
1452
2140
|
}
|
|
1453
2141
|
var PLACEHOLDER = `<!doctype html>
|
|
1454
2142
|
<meta charset="utf-8">
|
|
@@ -1491,15 +2179,46 @@ async function bind(server, requested) {
|
|
|
1491
2179
|
throw new Error(`No free port between ${requested} and ${requested + PORT_ATTEMPTS - 1}.`);
|
|
1492
2180
|
}
|
|
1493
2181
|
async function startServer(options) {
|
|
1494
|
-
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;
|
|
1495
2183
|
const target = config?.devServer ?? "http://localhost:3000";
|
|
1496
2184
|
const proxy = createProxyHandler({ target });
|
|
2185
|
+
const bootConfigSnapshot = snapshotConfig(cwd);
|
|
1497
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
|
+
};
|
|
1498
2210
|
const server = http2.createServer((req, res) => {
|
|
1499
2211
|
const url = req.url ?? "/";
|
|
1500
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
|
+
}
|
|
1501
2216
|
if (path === `${LEGLAS_PREFIX}/api/config`) {
|
|
1502
2217
|
const boot = config?.previews ?? [];
|
|
2218
|
+
const errors = [...configErrors];
|
|
2219
|
+
const notice = configStalenessNotice(cwd, bootConfigSnapshot, snapshotConfig(cwd));
|
|
2220
|
+
if (notice !== null)
|
|
2221
|
+
errors.push(notice);
|
|
1503
2222
|
return void readLocalPreviews(cwd).then(({ previews: local }) => {
|
|
1504
2223
|
const known = new Set(boot.map((preview) => preview.title));
|
|
1505
2224
|
const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
|
|
@@ -1507,13 +2226,13 @@ async function startServer(options) {
|
|
|
1507
2226
|
project,
|
|
1508
2227
|
devServer: target,
|
|
1509
2228
|
previews: [...boot, ...fresh],
|
|
1510
|
-
errors
|
|
2229
|
+
errors
|
|
1511
2230
|
});
|
|
1512
2231
|
}).catch(() => sendJson(res, 200, {
|
|
1513
2232
|
project,
|
|
1514
2233
|
devServer: target,
|
|
1515
2234
|
previews: boot,
|
|
1516
|
-
errors
|
|
2235
|
+
errors
|
|
1517
2236
|
}));
|
|
1518
2237
|
}
|
|
1519
2238
|
if (path === `${LEGLAS_PREFIX}/api/request` && req.method === "POST") {
|
|
@@ -1537,7 +2256,54 @@ async function startServer(options) {
|
|
|
1537
2256
|
url: preview.url,
|
|
1538
2257
|
intent: parsed.intent.trim(),
|
|
1539
2258
|
...composed
|
|
1540
|
-
}).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." }));
|
|
1541
2307
|
});
|
|
1542
2308
|
}
|
|
1543
2309
|
if (path === `${LEGLAS_PREFIX}/api/watch` && req.method === "POST") {
|
|
@@ -1558,11 +2324,119 @@ async function startServer(options) {
|
|
|
1558
2324
|
});
|
|
1559
2325
|
}
|
|
1560
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
|
+
};
|
|
1561
2335
|
return void readRequests(cwd).then((requests) => sendJson(res, 200, {
|
|
1562
|
-
requests: requests.map(({ id, title, intent, status }) => ({
|
|
1563
|
-
|
|
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
|
+
}
|
|
1564
2349
|
}));
|
|
1565
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
|
+
}
|
|
1566
2440
|
if (path === `${LEGLAS_PREFIX}/api/renames` && req.method === "POST") {
|
|
1567
2441
|
let body = "";
|
|
1568
2442
|
req.on("data", (chunk) => body += chunk);
|
|
@@ -1587,21 +2461,28 @@ async function startServer(options) {
|
|
|
1587
2461
|
const rest = path.slice(FILES_PREFIX.length + 1);
|
|
1588
2462
|
const slash = rest.indexOf("/");
|
|
1589
2463
|
const slug = slash === -1 ? rest : rest.slice(0, slash);
|
|
1590
|
-
let
|
|
2464
|
+
let relative5 = slash === -1 ? "" : rest.slice(slash + 1);
|
|
1591
2465
|
try {
|
|
1592
|
-
|
|
2466
|
+
relative5 = decodeURIComponent(relative5);
|
|
1593
2467
|
} catch {
|
|
1594
|
-
|
|
2468
|
+
relative5 = "";
|
|
1595
2469
|
}
|
|
1596
2470
|
const dir = fileMounts.get(slug);
|
|
1597
|
-
if (dir !== void 0 &&
|
|
2471
|
+
if (dir !== void 0 && relative5 !== "" && serveFrom(res, dir, relative5))
|
|
1598
2472
|
return;
|
|
1599
2473
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
|
1600
2474
|
return res.end("Leglas: no such preview file.");
|
|
1601
2475
|
}
|
|
2476
|
+
if (path.startsWith(`${LEGLAS_PREFIX}/api/`)) {
|
|
2477
|
+
return sendJson(res, 404, { error: "No such Leglas API path." });
|
|
2478
|
+
}
|
|
1602
2479
|
if (path === LEGLAS_PREFIX || path.startsWith(`${LEGLAS_PREFIX}/`)) {
|
|
1603
2480
|
if (shellDir !== null && serveShellFile(res, shellDir, path))
|
|
1604
2481
|
return;
|
|
2482
|
+
if (shellDir !== null) {
|
|
2483
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
|
2484
|
+
return res.end("Leglas: no such path.");
|
|
2485
|
+
}
|
|
1605
2486
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1606
2487
|
return res.end(PLACEHOLDER);
|
|
1607
2488
|
}
|
|
@@ -1619,16 +2500,23 @@ async function startServer(options) {
|
|
|
1619
2500
|
proxy.upgrade(req, socket, head);
|
|
1620
2501
|
});
|
|
1621
2502
|
const port = await bind(server, options.port ?? DEFAULT_PORT);
|
|
2503
|
+
runner = startRunner({ cwd, externallyAttached });
|
|
2504
|
+
let closePromise = null;
|
|
1622
2505
|
return {
|
|
1623
2506
|
port,
|
|
1624
2507
|
url: `http://localhost:${port}`,
|
|
1625
|
-
close: () =>
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
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
|
+
}
|
|
1632
2520
|
};
|
|
1633
2521
|
}
|
|
1634
2522
|
|
|
@@ -1650,7 +2538,7 @@ function planKeep(options) {
|
|
|
1650
2538
|
if (!winner) {
|
|
1651
2539
|
return {
|
|
1652
2540
|
ok: false,
|
|
1653
|
-
error: `No direction called ${JSON.stringify(options.title)}. Run leglas list to see them.`
|
|
2541
|
+
error: `No direction called ${JSON.stringify(options.title)}. Run npx leglas list to see them.`
|
|
1654
2542
|
};
|
|
1655
2543
|
}
|
|
1656
2544
|
const from = targetFor(winner.url);
|
|
@@ -1687,11 +2575,11 @@ function planKeep(options) {
|
|
|
1687
2575
|
}
|
|
1688
2576
|
|
|
1689
2577
|
// src/run-init.ts
|
|
1690
|
-
import { readFile as
|
|
1691
|
-
import { join as
|
|
2578
|
+
import { readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
|
|
2579
|
+
import { join as join8 } from "path";
|
|
1692
2580
|
async function readIfPresent(path) {
|
|
1693
2581
|
try {
|
|
1694
|
-
return await
|
|
2582
|
+
return await readFile6(path, "utf8");
|
|
1695
2583
|
} catch {
|
|
1696
2584
|
return null;
|
|
1697
2585
|
}
|
|
@@ -1699,18 +2587,18 @@ async function readIfPresent(path) {
|
|
|
1699
2587
|
async function runInit(options, deps) {
|
|
1700
2588
|
const existingConfig = findConfigFile(options.cwd);
|
|
1701
2589
|
const plan = planInit({
|
|
1702
|
-
agents: await readIfPresent(
|
|
2590
|
+
agents: await readIfPresent(join8(options.cwd, "AGENTS.md")),
|
|
1703
2591
|
config: existingConfig === null ? null : "present",
|
|
1704
|
-
gitignore: await readIfPresent(
|
|
2592
|
+
gitignore: await readIfPresent(join8(options.cwd, ".gitignore")),
|
|
1705
2593
|
force: options.force
|
|
1706
2594
|
});
|
|
1707
2595
|
const touched = [];
|
|
1708
2596
|
for (const write of plan.writes) {
|
|
1709
|
-
await
|
|
2597
|
+
await writeFile5(join8(options.cwd, write.path), write.contents, "utf8");
|
|
1710
2598
|
touched.push(write.path);
|
|
1711
2599
|
}
|
|
1712
2600
|
if (plan.gitignore !== null) {
|
|
1713
|
-
await
|
|
2601
|
+
await writeFile5(join8(options.cwd, ".gitignore"), plan.gitignore, "utf8");
|
|
1714
2602
|
touched.push(".gitignore");
|
|
1715
2603
|
}
|
|
1716
2604
|
if (options.json) {
|
|
@@ -1730,8 +2618,8 @@ async function runInit(options, deps) {
|
|
|
1730
2618
|
|
|
1731
2619
|
// src/run-keep.ts
|
|
1732
2620
|
import { existsSync as existsSync3 } from "fs";
|
|
1733
|
-
import { mkdir as
|
|
1734
|
-
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";
|
|
1735
2623
|
|
|
1736
2624
|
// src/resolve-title.ts
|
|
1737
2625
|
function resolveOrExplain(input, titles, renames) {
|
|
@@ -1745,7 +2633,7 @@ function resolveOrExplain(input, titles, renames) {
|
|
|
1745
2633
|
}
|
|
1746
2634
|
return {
|
|
1747
2635
|
ok: false,
|
|
1748
|
-
error: `No direction called ${JSON.stringify(input)}. Renaming one in the rail only renames it here, and it still answers to its title in the config, which its reference block quotes. leglas list shows every title.`
|
|
2636
|
+
error: `No direction called ${JSON.stringify(input)}. Renaming one in the rail only renames it here, and it still answers to its title in the config, which its reference block quotes. npx leglas list shows every title.`
|
|
1749
2637
|
};
|
|
1750
2638
|
}
|
|
1751
2639
|
|
|
@@ -1775,18 +2663,18 @@ async function runKeep(options, deps) {
|
|
|
1775
2663
|
if (!resolved.ok) return fail(resolved.error);
|
|
1776
2664
|
const plan = planKeep({ title: resolved.title, previews, to: options.to });
|
|
1777
2665
|
if (!plan.ok) return fail(plan.error);
|
|
1778
|
-
const from =
|
|
1779
|
-
const to =
|
|
2666
|
+
const from = join9(options.cwd, plan.move.from);
|
|
2667
|
+
const to = join9(options.cwd, plan.move.to);
|
|
1780
2668
|
if (!existsSync3(from)) {
|
|
1781
2669
|
return fail(`${plan.move.from} does not exist. Nothing to keep.`);
|
|
1782
2670
|
}
|
|
1783
2671
|
if (existsSync3(to)) {
|
|
1784
2672
|
return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
|
|
1785
2673
|
}
|
|
1786
|
-
const source = await
|
|
1787
|
-
await
|
|
1788
|
-
await
|
|
1789
|
-
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 });
|
|
1790
2678
|
const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
|
|
1791
2679
|
if (options.json) {
|
|
1792
2680
|
deps.log(
|
|
@@ -1821,11 +2709,11 @@ async function runKeep(options, deps) {
|
|
|
1821
2709
|
|
|
1822
2710
|
// src/run-new.ts
|
|
1823
2711
|
import { existsSync as existsSync4 } from "fs";
|
|
1824
|
-
import { mkdir as
|
|
1825
|
-
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";
|
|
1826
2714
|
async function readIfPresent2(path) {
|
|
1827
2715
|
try {
|
|
1828
|
-
return await
|
|
2716
|
+
return await readFile8(path, "utf8");
|
|
1829
2717
|
} catch {
|
|
1830
2718
|
return null;
|
|
1831
2719
|
}
|
|
@@ -1833,7 +2721,7 @@ async function readIfPresent2(path) {
|
|
|
1833
2721
|
async function runNew(options, deps) {
|
|
1834
2722
|
let from;
|
|
1835
2723
|
if (options.from !== void 0) {
|
|
1836
|
-
const contents = await readIfPresent2(
|
|
2724
|
+
const contents = await readIfPresent2(join10(options.cwd, options.from));
|
|
1837
2725
|
if (contents === null) {
|
|
1838
2726
|
const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
|
|
1839
2727
|
if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
|
|
@@ -1844,8 +2732,8 @@ async function runNew(options, deps) {
|
|
|
1844
2732
|
}
|
|
1845
2733
|
const plan = planNew({
|
|
1846
2734
|
surface: options.surface,
|
|
1847
|
-
packageJson: await readIfPresent2(
|
|
1848
|
-
gitignore: await readIfPresent2(
|
|
2735
|
+
packageJson: await readIfPresent2(join10(options.cwd, "package.json")),
|
|
2736
|
+
gitignore: await readIfPresent2(join10(options.cwd, ".gitignore")),
|
|
1849
2737
|
from
|
|
1850
2738
|
});
|
|
1851
2739
|
const fail = (error) => {
|
|
@@ -1868,19 +2756,19 @@ async function runNew(options, deps) {
|
|
|
1868
2756
|
deps.log(plan.instructions);
|
|
1869
2757
|
return { exitCode: 0, written: [] };
|
|
1870
2758
|
}
|
|
1871
|
-
const existing = plan.writes.filter((write) => existsSync4(
|
|
2759
|
+
const existing = plan.writes.filter((write) => existsSync4(join10(options.cwd, write.path)));
|
|
1872
2760
|
if (existing.length > 0) {
|
|
1873
2761
|
return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
|
|
1874
2762
|
}
|
|
1875
2763
|
const written = [];
|
|
1876
2764
|
for (const write of plan.writes) {
|
|
1877
|
-
const target =
|
|
1878
|
-
await
|
|
1879
|
-
await
|
|
2765
|
+
const target = join10(options.cwd, write.path);
|
|
2766
|
+
await mkdir6(dirname7(target), { recursive: true });
|
|
2767
|
+
await writeFile7(target, write.contents, "utf8");
|
|
1880
2768
|
written.push(write.path);
|
|
1881
2769
|
}
|
|
1882
2770
|
if (plan.gitignore !== null) {
|
|
1883
|
-
await
|
|
2771
|
+
await writeFile7(join10(options.cwd, ".gitignore"), plan.gitignore, "utf8");
|
|
1884
2772
|
written.push(".gitignore");
|
|
1885
2773
|
}
|
|
1886
2774
|
if (options.json) {
|
|
@@ -1895,27 +2783,27 @@ async function runNew(options, deps) {
|
|
|
1895
2783
|
deps.log("Then register them so they appear in the interface:");
|
|
1896
2784
|
deps.log("");
|
|
1897
2785
|
for (const preview of plan.previews) {
|
|
1898
|
-
deps.log(` leglas add --title ${JSON.stringify(preview.title)} --url ${JSON.stringify(preview.url)}`);
|
|
2786
|
+
deps.log(` npx leglas add --title ${JSON.stringify(preview.title)} --url ${JSON.stringify(preview.url)}`);
|
|
1899
2787
|
}
|
|
1900
2788
|
return { exitCode: 0, written };
|
|
1901
2789
|
}
|
|
1902
2790
|
|
|
1903
2791
|
// src/run-previews.ts
|
|
1904
|
-
import { readFile as
|
|
1905
|
-
import { join as
|
|
2792
|
+
import { readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
|
|
2793
|
+
import { join as join11 } from "path";
|
|
1906
2794
|
function envelope(deps, ok, body) {
|
|
1907
2795
|
deps.log(JSON.stringify({ ok, ...body }));
|
|
1908
2796
|
}
|
|
1909
2797
|
async function ensureIgnored(cwd) {
|
|
1910
|
-
const path =
|
|
2798
|
+
const path = join11(cwd, ".gitignore");
|
|
1911
2799
|
let current = null;
|
|
1912
2800
|
try {
|
|
1913
|
-
current = await
|
|
2801
|
+
current = await readFile9(path, "utf8");
|
|
1914
2802
|
} catch {
|
|
1915
2803
|
current = null;
|
|
1916
2804
|
}
|
|
1917
2805
|
const next = ignoreEntry(current);
|
|
1918
|
-
if (next !== null) await
|
|
2806
|
+
if (next !== null) await writeFile8(path, next, "utf8");
|
|
1919
2807
|
}
|
|
1920
2808
|
async function runAdd(options, deps) {
|
|
1921
2809
|
const loaded = await loadConfig(options.cwd);
|
|
@@ -1924,7 +2812,7 @@ async function runAdd(options, deps) {
|
|
|
1924
2812
|
const local = await readLocalPreviews(options.cwd);
|
|
1925
2813
|
const titles = new Set([...shared, ...local.previews].map((preview) => preview.title));
|
|
1926
2814
|
if (!titles.has(options.preview.basedOn)) {
|
|
1927
|
-
const error = `--based-on names ${JSON.stringify(options.preview.basedOn)}, which is not a registered direction. leglas list shows what exists.`;
|
|
2815
|
+
const error = `--based-on names ${JSON.stringify(options.preview.basedOn)}, which is not a registered direction. npx leglas list shows what exists.`;
|
|
1928
2816
|
if (options.json) envelope(deps, false, { error });
|
|
1929
2817
|
else deps.error(error);
|
|
1930
2818
|
return { exitCode: 1 };
|
|
@@ -1957,6 +2845,7 @@ async function runAdd(options, deps) {
|
|
|
1957
2845
|
local: true,
|
|
1958
2846
|
...options.preview.branch === void 0 ? {} : { branch: options.preview.branch },
|
|
1959
2847
|
...options.preview.file === void 0 ? {} : { file: options.preview.file },
|
|
2848
|
+
note: options.preview.branch === void 0 && options.preview.file === void 0 ? "A running interface picks this up within seconds." : "Restart Leglas to see this preview: branch checkouts and file mounts are built when Leglas starts.",
|
|
1960
2849
|
...needsDevCommand ? { warning: "The config sets no devCommand, so Leglas cannot start this branch yet. Add devCommand (with {port}) to the config." } : {}
|
|
1961
2850
|
});
|
|
1962
2851
|
} else {
|
|
@@ -1972,7 +2861,7 @@ async function runAdd(options, deps) {
|
|
|
1972
2861
|
if (options.preview.branch === void 0 && options.preview.file === void 0) {
|
|
1973
2862
|
deps.log("Local to this machine. A running interface picks it up within seconds.");
|
|
1974
2863
|
} else {
|
|
1975
|
-
deps.log("Local to this machine. Restart Leglas to see it, or run leglas list.");
|
|
2864
|
+
deps.log("Local to this machine. Restart Leglas to see it, or run npx leglas list.");
|
|
1976
2865
|
}
|
|
1977
2866
|
}
|
|
1978
2867
|
return { exitCode: 0 };
|
|
@@ -2007,7 +2896,7 @@ async function runList(options, deps) {
|
|
|
2007
2896
|
return { exitCode: errors.length === 0 ? 0 : 1 };
|
|
2008
2897
|
}
|
|
2009
2898
|
if (previews.length === 0) {
|
|
2010
|
-
deps.log("No previews yet. Add one with leglas add, or list them in leglas.config.ts.");
|
|
2899
|
+
deps.log("No previews yet. Add one with npx leglas add, or list them in leglas.config.ts.");
|
|
2011
2900
|
} else {
|
|
2012
2901
|
const width = Math.max(...previews.map((preview) => preview.title.length));
|
|
2013
2902
|
for (const preview of previews) {
|
|
@@ -2022,9 +2911,16 @@ async function runList(options, deps) {
|
|
|
2022
2911
|
}
|
|
2023
2912
|
async function runRequests(options, deps) {
|
|
2024
2913
|
if (options.clear) {
|
|
2025
|
-
await clearRequests(options.cwd);
|
|
2026
|
-
if (options.json) envelope(deps, true, { cleared
|
|
2027
|
-
else
|
|
2914
|
+
const { cleared, pending } = await clearRequests(options.cwd);
|
|
2915
|
+
if (options.json) envelope(deps, true, { cleared, pending });
|
|
2916
|
+
else {
|
|
2917
|
+
deps.log(cleared === 1 ? "Cleared 1 request." : `Cleared ${cleared} requests.`);
|
|
2918
|
+
if (pending > 0) {
|
|
2919
|
+
deps.log(
|
|
2920
|
+
`${pending} arrived while you worked. Run npx leglas requests to collect ${pending === 1 ? "it" : "them"}.`
|
|
2921
|
+
);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2028
2924
|
return { exitCode: 0 };
|
|
2029
2925
|
}
|
|
2030
2926
|
const requests = await collectRequests(options.cwd);
|
|
@@ -2041,7 +2937,7 @@ async function runRequests(options, deps) {
|
|
|
2041
2937
|
if (request.target !== null) deps.log(` ${request.target}`);
|
|
2042
2938
|
}
|
|
2043
2939
|
deps.log("");
|
|
2044
|
-
deps.log("Run leglas requests --json to get the full prompts, then --clear when done.");
|
|
2940
|
+
deps.log("Run npx leglas requests --json to get the full prompts, then --clear when done.");
|
|
2045
2941
|
return { exitCode: 0 };
|
|
2046
2942
|
}
|
|
2047
2943
|
|
|
@@ -2067,7 +2963,7 @@ function planShow({ title, previews, requests }) {
|
|
|
2067
2963
|
if (!found) {
|
|
2068
2964
|
return {
|
|
2069
2965
|
ok: false,
|
|
2070
|
-
error: `No direction called ${JSON.stringify(title)}. Run leglas list to see them.`
|
|
2966
|
+
error: `No direction called ${JSON.stringify(title)}. Run npx leglas list to see them.`
|
|
2071
2967
|
};
|
|
2072
2968
|
}
|
|
2073
2969
|
const variants = previews.filter((preview) => preview.basedOn === title).map(describe);
|
|
@@ -2146,104 +3042,30 @@ async function runShow(options, deps) {
|
|
|
2146
3042
|
deps.log(` Pending, not yet done (${plan.requests.length}):`);
|
|
2147
3043
|
for (const request of plan.requests) deps.log(` ${request.status} ${request.intent}`);
|
|
2148
3044
|
deps.log("");
|
|
2149
|
-
deps.log(" Run leglas requests --json for the full prompts.");
|
|
3045
|
+
deps.log(" Run npx leglas requests --json for the full prompts.");
|
|
2150
3046
|
}
|
|
2151
3047
|
return { exitCode: 0 };
|
|
2152
3048
|
}
|
|
2153
3049
|
|
|
2154
|
-
// src/watch.ts
|
|
2155
|
-
var WATCH_PATH = ".leglas/watch.json";
|
|
2156
|
-
var PROMPT_TOKEN = "{prompt}";
|
|
2157
|
-
var EXAMPLE = `leglas watch --run "claude -p ${PROMPT_TOKEN}"`;
|
|
2158
|
-
function tokenize(template) {
|
|
2159
|
-
const tokens = [];
|
|
2160
|
-
let current = "";
|
|
2161
|
-
let started = false;
|
|
2162
|
-
let quote = null;
|
|
2163
|
-
for (const character of template) {
|
|
2164
|
-
if (quote !== null) {
|
|
2165
|
-
if (character === quote) quote = null;
|
|
2166
|
-
else current += character;
|
|
2167
|
-
continue;
|
|
2168
|
-
}
|
|
2169
|
-
if (character === '"' || character === "'") {
|
|
2170
|
-
quote = character;
|
|
2171
|
-
started = true;
|
|
2172
|
-
continue;
|
|
2173
|
-
}
|
|
2174
|
-
if (/\s/.test(character)) {
|
|
2175
|
-
if (started) tokens.push(current);
|
|
2176
|
-
current = "";
|
|
2177
|
-
started = false;
|
|
2178
|
-
continue;
|
|
2179
|
-
}
|
|
2180
|
-
current += character;
|
|
2181
|
-
started = true;
|
|
2182
|
-
}
|
|
2183
|
-
if (quote !== null) {
|
|
2184
|
-
return { ok: false, error: `The agent command has an unclosed ${quote} quote.` };
|
|
2185
|
-
}
|
|
2186
|
-
if (started) tokens.push(current);
|
|
2187
|
-
return { ok: true, tokens };
|
|
2188
|
-
}
|
|
2189
|
-
function parseTemplate(raw) {
|
|
2190
|
-
const tokenized = tokenize(raw);
|
|
2191
|
-
if (!tokenized.ok) return tokenized;
|
|
2192
|
-
const { tokens } = tokenized;
|
|
2193
|
-
const [command, ...args] = tokens;
|
|
2194
|
-
if (command === void 0) {
|
|
2195
|
-
return { ok: false, error: `Watch needs an agent command, for example: ${EXAMPLE}` };
|
|
2196
|
-
}
|
|
2197
|
-
const placeholders = tokens.filter((token) => token === PROMPT_TOKEN).length;
|
|
2198
|
-
if (placeholders === 0) {
|
|
2199
|
-
return {
|
|
2200
|
-
ok: false,
|
|
2201
|
-
error: `The agent command needs ${PROMPT_TOKEN} as a word of its own, for example: ${EXAMPLE}`
|
|
2202
|
-
};
|
|
2203
|
-
}
|
|
2204
|
-
if (placeholders > 1) {
|
|
2205
|
-
return {
|
|
2206
|
-
ok: false,
|
|
2207
|
-
error: `The agent command takes ${PROMPT_TOKEN} once, for example: ${EXAMPLE}`
|
|
2208
|
-
};
|
|
2209
|
-
}
|
|
2210
|
-
if (command === PROMPT_TOKEN) {
|
|
2211
|
-
return {
|
|
2212
|
-
ok: false,
|
|
2213
|
-
error: `The agent command must name a program before ${PROMPT_TOKEN}, for example: ${EXAMPLE}`
|
|
2214
|
-
};
|
|
2215
|
-
}
|
|
2216
|
-
return { ok: true, template: { command, args } };
|
|
2217
|
-
}
|
|
2218
|
-
function commandFor(template, prompt) {
|
|
2219
|
-
return {
|
|
2220
|
-
command: template.command,
|
|
2221
|
-
args: template.args.map((argument) => argument === PROMPT_TOKEN ? prompt : argument)
|
|
2222
|
-
};
|
|
2223
|
-
}
|
|
2224
|
-
function nextRequest(requests, failed) {
|
|
2225
|
-
return requests.find((request) => request.status === "queued" && !failed.has(request.id)) ?? null;
|
|
2226
|
-
}
|
|
2227
|
-
|
|
2228
3050
|
// src/run-watch.ts
|
|
2229
|
-
import { spawn as
|
|
2230
|
-
import { mkdir as
|
|
2231
|
-
import { dirname as
|
|
2232
|
-
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;
|
|
2233
3055
|
var HEARTBEAT_TIMEOUT_MS = 1e3;
|
|
2234
|
-
async function
|
|
3056
|
+
async function saveTemplate(cwd, run3) {
|
|
3057
|
+
const path = join12(cwd, WATCH_PATH);
|
|
3058
|
+
let config = {};
|
|
2235
3059
|
try {
|
|
2236
|
-
const
|
|
2237
|
-
|
|
2238
|
-
|
|
3060
|
+
const parsed = JSON.parse(await readFile10(path, "utf8"));
|
|
3061
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
3062
|
+
config = parsed;
|
|
3063
|
+
}
|
|
2239
3064
|
} catch {
|
|
2240
|
-
return null;
|
|
2241
3065
|
}
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
await mkdir6(dirname7(path), { recursive: true });
|
|
2246
|
-
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)}
|
|
2247
3069
|
`, "utf8");
|
|
2248
3070
|
}
|
|
2249
3071
|
function spawnAgent(command, args, cwd) {
|
|
@@ -2254,7 +3076,7 @@ function spawnAgent(command, args, cwd) {
|
|
|
2254
3076
|
settled = true;
|
|
2255
3077
|
resolve(outcome);
|
|
2256
3078
|
};
|
|
2257
|
-
const child =
|
|
3079
|
+
const child = spawn3(command, args, { cwd, stdio: "inherit" });
|
|
2258
3080
|
child.on("error", (error) => settle({ ok: false, error: error.message }));
|
|
2259
3081
|
child.on(
|
|
2260
3082
|
"close",
|
|
@@ -2265,21 +3087,34 @@ function spawnAgent(command, args, cwd) {
|
|
|
2265
3087
|
});
|
|
2266
3088
|
}
|
|
2267
3089
|
async function runWatch(options, deps) {
|
|
2268
|
-
const saved = options.run === void 0 ? await
|
|
2269
|
-
const raw = options.run ?? saved;
|
|
2270
|
-
|
|
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 {
|
|
2271
3112
|
deps.error(
|
|
2272
|
-
'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}".'
|
|
2273
3114
|
);
|
|
2274
3115
|
return { exitCode: 1 };
|
|
2275
3116
|
}
|
|
2276
|
-
|
|
2277
|
-
if (!parsed.ok) {
|
|
2278
|
-
deps.error(parsed.error);
|
|
2279
|
-
return { exitCode: 1 };
|
|
2280
|
-
}
|
|
2281
|
-
const template = parsed.template;
|
|
2282
|
-
if (options.run !== void 0) await saveTemplate(options.cwd, raw).catch(() => {
|
|
3117
|
+
if (options.run !== void 0) await saveTemplate(options.cwd, options.run).catch(() => {
|
|
2283
3118
|
});
|
|
2284
3119
|
const base = `http://localhost:${options.port ?? DEFAULT_PORT}`;
|
|
2285
3120
|
const heartbeat = async (watching) => {
|
|
@@ -2293,11 +3128,15 @@ async function runWatch(options, deps) {
|
|
|
2293
3128
|
} catch {
|
|
2294
3129
|
}
|
|
2295
3130
|
};
|
|
2296
|
-
|
|
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}`);
|
|
2297
3135
|
deps.log("Stop with Ctrl-C.");
|
|
2298
3136
|
const failed = /* @__PURE__ */ new Set();
|
|
2299
3137
|
let stopped = false;
|
|
2300
3138
|
let busy = false;
|
|
3139
|
+
let announced = false;
|
|
2301
3140
|
let inflight = null;
|
|
2302
3141
|
const handle = async (request) => {
|
|
2303
3142
|
deps.log("");
|
|
@@ -2319,7 +3158,12 @@ async function runWatch(options, deps) {
|
|
|
2319
3158
|
};
|
|
2320
3159
|
const tick = async () => {
|
|
2321
3160
|
if (stopped) return;
|
|
2322
|
-
|
|
3161
|
+
if (announced) {
|
|
3162
|
+
void heartbeat(true);
|
|
3163
|
+
} else {
|
|
3164
|
+
await heartbeat(true);
|
|
3165
|
+
announced = true;
|
|
3166
|
+
}
|
|
2323
3167
|
if (busy) return;
|
|
2324
3168
|
busy = true;
|
|
2325
3169
|
try {
|
|
@@ -2336,7 +3180,7 @@ async function runWatch(options, deps) {
|
|
|
2336
3180
|
}
|
|
2337
3181
|
};
|
|
2338
3182
|
return new Promise((resolve) => {
|
|
2339
|
-
const timer = setInterval(() => void tick(),
|
|
3183
|
+
const timer = setInterval(() => void tick(), POLL_MS2);
|
|
2340
3184
|
const stop = () => {
|
|
2341
3185
|
if (stopped) return;
|
|
2342
3186
|
stopped = true;
|
|
@@ -2355,12 +3199,12 @@ async function runWatch(options, deps) {
|
|
|
2355
3199
|
|
|
2356
3200
|
// src/run-classify.ts
|
|
2357
3201
|
import { stat } from "fs/promises";
|
|
2358
|
-
import { join as
|
|
3202
|
+
import { join as join13 } from "path";
|
|
2359
3203
|
async function runClassify(options, deps) {
|
|
2360
3204
|
const declared = await Promise.all(
|
|
2361
3205
|
options.changes.map(async (change) => ({
|
|
2362
3206
|
...change,
|
|
2363
|
-
exists: await stat(
|
|
3207
|
+
exists: await stat(join13(options.cwd, change.path)).then(
|
|
2364
3208
|
() => true,
|
|
2365
3209
|
() => false
|
|
2366
3210
|
)
|
|
@@ -2388,14 +3232,14 @@ async function runClassify(options, deps) {
|
|
|
2388
3232
|
// src/run.ts
|
|
2389
3233
|
import { existsSync as existsSync5 } from "fs";
|
|
2390
3234
|
import { createRequire } from "module";
|
|
2391
|
-
import { basename as basename3, dirname as
|
|
3235
|
+
import { basename as basename3, dirname as dirname9, join as join14, relative as relative4 } from "path";
|
|
2392
3236
|
import { fileURLToPath } from "url";
|
|
2393
3237
|
function findShellDir() {
|
|
2394
|
-
const bundled =
|
|
2395
|
-
if (existsSync5(
|
|
3238
|
+
const bundled = join14(dirname9(fileURLToPath(import.meta.url)), "shell");
|
|
3239
|
+
if (existsSync5(join14(bundled, "index.html"))) return bundled;
|
|
2396
3240
|
try {
|
|
2397
3241
|
const require2 = createRequire(import.meta.url);
|
|
2398
|
-
return
|
|
3242
|
+
return dirname9(require2.resolve("@leglas/shell/dist/index.html"));
|
|
2399
3243
|
} catch {
|
|
2400
3244
|
return null;
|
|
2401
3245
|
}
|
|
@@ -2429,7 +3273,7 @@ async function run2(options, deps) {
|
|
|
2429
3273
|
const fileMounts = /* @__PURE__ */ new Map();
|
|
2430
3274
|
for (const preview of merged?.previews ?? []) {
|
|
2431
3275
|
if (preview.file !== void 0) {
|
|
2432
|
-
const absolute =
|
|
3276
|
+
const absolute = join14(options.cwd, preview.file);
|
|
2433
3277
|
if (!existsSync5(absolute)) {
|
|
2434
3278
|
worktreeErrors.push(
|
|
2435
3279
|
`"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
|
|
@@ -2440,7 +3284,7 @@ async function run2(options, deps) {
|
|
|
2440
3284
|
for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
|
|
2441
3285
|
slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
|
|
2442
3286
|
}
|
|
2443
|
-
fileMounts.set(slug,
|
|
3287
|
+
fileMounts.set(slug, dirname9(absolute));
|
|
2444
3288
|
previews.push({
|
|
2445
3289
|
...preview,
|
|
2446
3290
|
url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
|
|
@@ -2501,7 +3345,7 @@ async function run2(options, deps) {
|
|
|
2501
3345
|
})
|
|
2502
3346
|
);
|
|
2503
3347
|
} else {
|
|
2504
|
-
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;
|
|
2505
3349
|
deps.log(`Leglas ${url}`);
|
|
2506
3350
|
deps.log(
|
|
2507
3351
|
`app ${devServer}${app !== null ? " (started by Leglas)" : health.reachable ? "" : " (not reachable)"}`
|
|
@@ -2540,6 +3384,8 @@ async function run2(options, deps) {
|
|
|
2540
3384
|
export {
|
|
2541
3385
|
AGENTS_MARKER_END,
|
|
2542
3386
|
AGENTS_MARKER_START,
|
|
3387
|
+
DEFAULT_PORT,
|
|
3388
|
+
LEGLAS_PREFIX,
|
|
2543
3389
|
PROMPT_TOKEN,
|
|
2544
3390
|
WATCH_PATH,
|
|
2545
3391
|
baselineFrom,
|