@limno/core 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +270 -68
- package/dist/index.d.cts +18 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +262 -60
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -59,8 +59,8 @@ function jsxName(name) {
|
|
|
59
59
|
return `${jsxName(name.object)}.${name.property.name}`;
|
|
60
60
|
}
|
|
61
61
|
var isCapitalized = (s) => /^[A-Z]/.test(s);
|
|
62
|
-
function componentName(
|
|
63
|
-
let p =
|
|
62
|
+
function componentName(path6, fileBase) {
|
|
63
|
+
let p = path6.getFunctionParent();
|
|
64
64
|
while (p) {
|
|
65
65
|
const node = p.node;
|
|
66
66
|
let name;
|
|
@@ -102,8 +102,8 @@ function stampSource(code, id, relFile) {
|
|
|
102
102
|
const fileBase = relFile.split("/").pop().replace(/\.[jt]sx?$/, "").replace(/^./, (c) => c.toUpperCase());
|
|
103
103
|
let touched = false;
|
|
104
104
|
traverse(ast, {
|
|
105
|
-
JSXOpeningElement(
|
|
106
|
-
const node =
|
|
105
|
+
JSXOpeningElement(path6) {
|
|
106
|
+
const node = path6.node;
|
|
107
107
|
const loc = node.loc;
|
|
108
108
|
if (!loc || node.name.end == null) return;
|
|
109
109
|
const name = jsxName(node.name);
|
|
@@ -111,7 +111,7 @@ function stampSource(code, id, relFile) {
|
|
|
111
111
|
const host = node.name.type === "JSXIdentifier" && !isCapitalized(name);
|
|
112
112
|
if (host) {
|
|
113
113
|
if (hasAttr(node, "data-limno-src")) return;
|
|
114
|
-
const comp = componentName(
|
|
114
|
+
const comp = componentName(path6, fileBase);
|
|
115
115
|
s.appendLeft(node.name.end, ` data-limno-src="${escapeAttr(where)}"${comp ? ` data-limno-comp="${escapeAttr(comp)}"` : ""}`);
|
|
116
116
|
touched = true;
|
|
117
117
|
} else {
|
|
@@ -223,16 +223,97 @@ async function stampTemplate(code, id, relFile, root) {
|
|
|
223
223
|
|
|
224
224
|
// src/service.ts
|
|
225
225
|
var import_node_crypto = require("crypto");
|
|
226
|
-
var
|
|
226
|
+
var import_node_fs4 = require("fs");
|
|
227
227
|
var import_node_module3 = require("module");
|
|
228
|
-
var
|
|
229
|
-
var
|
|
228
|
+
var import_node_path5 = __toESM(require("path"), 1);
|
|
229
|
+
var import_spec2 = require("@limno/spec");
|
|
230
230
|
|
|
231
231
|
// src/runner.ts
|
|
232
|
+
var import_node_child_process2 = require("child_process");
|
|
233
|
+
var import_node_fs2 = require("fs");
|
|
234
|
+
var import_node_os = __toESM(require("os"), 1);
|
|
235
|
+
var import_node_path3 = __toESM(require("path"), 1);
|
|
236
|
+
var import_spec = require("@limno/spec");
|
|
237
|
+
|
|
238
|
+
// src/history.ts
|
|
232
239
|
var import_node_child_process = require("child_process");
|
|
233
240
|
var import_node_fs = require("fs");
|
|
234
|
-
var import_node_os = __toESM(require("os"), 1);
|
|
235
241
|
var import_node_path2 = __toESM(require("path"), 1);
|
|
242
|
+
var KEEP = 20;
|
|
243
|
+
function runsFile(folder) {
|
|
244
|
+
return import_node_path2.default.join(folder, "runs.json");
|
|
245
|
+
}
|
|
246
|
+
function readRuns(folder) {
|
|
247
|
+
try {
|
|
248
|
+
return JSON.parse((0, import_node_fs.readFileSync)(runsFile(folder), "utf8")).runs ?? [];
|
|
249
|
+
} catch {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function writeRuns(folder, runs) {
|
|
254
|
+
(0, import_node_fs.mkdirSync)(folder, { recursive: true });
|
|
255
|
+
const file = runsFile(folder);
|
|
256
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
257
|
+
(0, import_node_fs.writeFileSync)(tmp, `${JSON.stringify({ runs: runs.slice(-KEEP) }, null, 2)}
|
|
258
|
+
`);
|
|
259
|
+
(0, import_node_fs.renameSync)(tmp, file);
|
|
260
|
+
}
|
|
261
|
+
function saveRun(folder, run2) {
|
|
262
|
+
const runs = readRuns(folder).filter((r) => r.id !== run2.id);
|
|
263
|
+
writeRuns(folder, [...runs, run2]);
|
|
264
|
+
}
|
|
265
|
+
function git(root, args, timeoutMs = 15e3) {
|
|
266
|
+
return new Promise((resolve) => {
|
|
267
|
+
let out = "";
|
|
268
|
+
let child;
|
|
269
|
+
try {
|
|
270
|
+
child = (0, import_node_child_process.spawn)("git", args, { cwd: root, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
271
|
+
} catch {
|
|
272
|
+
return resolve({ code: null, out: "" });
|
|
273
|
+
}
|
|
274
|
+
const timer = setTimeout(() => child.kill(), timeoutMs);
|
|
275
|
+
child.stdout?.on("data", (d) => out += d);
|
|
276
|
+
child.stderr?.on("data", (d) => out += d);
|
|
277
|
+
child.on("error", () => {
|
|
278
|
+
clearTimeout(timer);
|
|
279
|
+
resolve({ code: null, out });
|
|
280
|
+
});
|
|
281
|
+
child.on("close", (code) => {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
resolve({ code, out });
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
async function isGitRepo(root) {
|
|
288
|
+
return (await git(root, ["rev-parse", "--is-inside-work-tree"])).out.trim() === "true";
|
|
289
|
+
}
|
|
290
|
+
async function uncommitted(root, files) {
|
|
291
|
+
if (!files.length || !await isGitRepo(root)) return [];
|
|
292
|
+
const r = await git(root, ["status", "--porcelain", "--untracked-files=all", "--", ...files]);
|
|
293
|
+
if (r.code !== 0) return [];
|
|
294
|
+
const dirty = new Set(
|
|
295
|
+
r.out.split("\n").filter(Boolean).map((l) => l.slice(3).replace(/^"|"$/g, "").split(" -> ").pop())
|
|
296
|
+
);
|
|
297
|
+
const top = (await git(root, ["rev-parse", "--show-toplevel"])).out.trim();
|
|
298
|
+
return files.filter((f) => dirty.has(top ? import_node_path2.default.relative(top, import_node_path2.default.join(root, f)) : f) || dirty.has(f));
|
|
299
|
+
}
|
|
300
|
+
async function commitFiles(root, files, message) {
|
|
301
|
+
if (!await isGitRepo(root)) return { ok: false, error: "This project isn\u2019t a git repository." };
|
|
302
|
+
for (const marker of ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD"]) {
|
|
303
|
+
const p = (await git(root, ["rev-parse", "--git-path", marker])).out.trim();
|
|
304
|
+
if (p && (0, import_node_fs.existsSync)(import_node_path2.default.resolve(root, p))) return { ok: false, error: "Git is in the middle of a merge or rebase." };
|
|
305
|
+
}
|
|
306
|
+
const dirty = await uncommitted(root, files);
|
|
307
|
+
if (!dirty.length) return { ok: false, error: "Nothing to commit: those files have no changes." };
|
|
308
|
+
const add = await git(root, ["add", "--", ...dirty]);
|
|
309
|
+
if (add.code !== 0) return { ok: false, error: add.out.trim().split("\n").pop() || "git add failed" };
|
|
310
|
+
const commit = await git(root, ["commit", "-m", message, "--", ...dirty], 6e4);
|
|
311
|
+
if (commit.code !== 0) return { ok: false, error: commit.out.trim().split("\n").slice(-2).join(" ") || "git commit failed" };
|
|
312
|
+
const sha = (await git(root, ["rev-parse", "--short", "HEAD"])).out.trim();
|
|
313
|
+
return { ok: true, sha };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/runner.ts
|
|
236
317
|
var NAMES = { claude: "Claude Code", codex: "Codex" };
|
|
237
318
|
var SIGN_IN = { claude: "claude auth login", codex: "codex login" };
|
|
238
319
|
var PROMPT = [
|
|
@@ -242,24 +323,24 @@ var PROMPT = [
|
|
|
242
323
|
"Only read and edit files inside this project. Don't run shell commands; Limno checks the result on the page itself.",
|
|
243
324
|
"Finish with one short sentence summarising what you changed."
|
|
244
325
|
].join(" ");
|
|
245
|
-
var trustFile = () =>
|
|
326
|
+
var trustFile = () => import_node_path3.default.join(import_node_os.default.homedir(), ".limno", "trust.json");
|
|
246
327
|
function projectKey(root) {
|
|
247
328
|
try {
|
|
248
|
-
return (0,
|
|
329
|
+
return (0, import_node_fs2.realpathSync)(root);
|
|
249
330
|
} catch {
|
|
250
331
|
return root;
|
|
251
332
|
}
|
|
252
333
|
}
|
|
253
334
|
function readTrust() {
|
|
254
335
|
try {
|
|
255
|
-
return JSON.parse((0,
|
|
336
|
+
return JSON.parse((0, import_node_fs2.readFileSync)(trustFile(), "utf8")).projects ?? {};
|
|
256
337
|
} catch {
|
|
257
338
|
return {};
|
|
258
339
|
}
|
|
259
340
|
}
|
|
260
341
|
function writeTrust(projects) {
|
|
261
|
-
(0,
|
|
262
|
-
(0,
|
|
342
|
+
(0, import_node_fs2.mkdirSync)(import_node_path3.default.dirname(trustFile()), { recursive: true });
|
|
343
|
+
(0, import_node_fs2.writeFileSync)(trustFile(), `${JSON.stringify({ projects }, null, 2)}
|
|
263
344
|
`);
|
|
264
345
|
}
|
|
265
346
|
function run(cmd, args, timeoutMs = 8e3) {
|
|
@@ -267,7 +348,7 @@ function run(cmd, args, timeoutMs = 8e3) {
|
|
|
267
348
|
let out = "";
|
|
268
349
|
let child;
|
|
269
350
|
try {
|
|
270
|
-
child = (0,
|
|
351
|
+
child = (0, import_node_child_process2.spawn)(cmd, args, { env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
|
|
271
352
|
} catch {
|
|
272
353
|
return resolve({ code: null, out: "" });
|
|
273
354
|
}
|
|
@@ -320,20 +401,20 @@ function isLocalRequest(req) {
|
|
|
320
401
|
return true;
|
|
321
402
|
}
|
|
322
403
|
function createRunner(root, folder, onState) {
|
|
323
|
-
const sessionsFile =
|
|
404
|
+
const sessionsFile = import_node_path3.default.join(folder, "runner.json");
|
|
324
405
|
let child = null;
|
|
325
406
|
let again = false;
|
|
326
407
|
let state = { status: "idle", agent: null, startedAt: null, endedAt: null, log: [], files: [] };
|
|
327
408
|
const sessions = () => {
|
|
328
409
|
try {
|
|
329
|
-
return JSON.parse((0,
|
|
410
|
+
return JSON.parse((0, import_node_fs2.readFileSync)(sessionsFile, "utf8"));
|
|
330
411
|
} catch {
|
|
331
412
|
return {};
|
|
332
413
|
}
|
|
333
414
|
};
|
|
334
415
|
const saveSession = (agent, id) => {
|
|
335
|
-
(0,
|
|
336
|
-
(0,
|
|
416
|
+
(0, import_node_fs2.mkdirSync)(folder, { recursive: true });
|
|
417
|
+
(0, import_node_fs2.writeFileSync)(sessionsFile, `${JSON.stringify({ ...sessions(), [agent]: id }, null, 2)}
|
|
337
418
|
`);
|
|
338
419
|
};
|
|
339
420
|
let emitTimer;
|
|
@@ -351,15 +432,108 @@ function createRunner(root, folder, onState) {
|
|
|
351
432
|
};
|
|
352
433
|
const rel = (p) => {
|
|
353
434
|
if (!p) return "";
|
|
354
|
-
const abs =
|
|
355
|
-
return
|
|
435
|
+
const abs = import_node_path3.default.isAbsolute(p) ? p : import_node_path3.default.join(root, p);
|
|
436
|
+
return import_node_path3.default.relative(root, abs) || p;
|
|
356
437
|
};
|
|
357
438
|
const key = projectKey(root);
|
|
358
439
|
const allowed = () => readTrust()[key]?.agent ?? null;
|
|
440
|
+
const autoCommitOn = () => !!readTrust()[key]?.autoCommit;
|
|
441
|
+
const changeSetFile = import_node_path3.default.join(folder, "changeset.json");
|
|
442
|
+
const readSet = () => {
|
|
443
|
+
try {
|
|
444
|
+
return JSON.parse((0, import_node_fs2.readFileSync)(changeSetFile, "utf8"));
|
|
445
|
+
} catch {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
let run2 = null;
|
|
450
|
+
let committing = false;
|
|
451
|
+
const say = (line) => console.log(`\x1B[36m[limno]\x1B[0m ${line}`);
|
|
452
|
+
const list = (files) => files.length <= 3 ? files.join(", ") : `${files.slice(0, 3).join(", ")} and ${files.length - 3} more`;
|
|
453
|
+
async function finishRun(agent) {
|
|
454
|
+
const startedAt = state.startedAt;
|
|
455
|
+
const files = state.files;
|
|
456
|
+
const set = readSet();
|
|
457
|
+
const ids = run2?.changeIds ?? [];
|
|
458
|
+
const changes = (set?.changes ?? []).filter((c) => ids.includes(c.id));
|
|
459
|
+
const rec = {
|
|
460
|
+
id: run2?.id ?? String(startedAt),
|
|
461
|
+
agent: NAMES[agent],
|
|
462
|
+
startedAt: startedAt ?? Date.now(),
|
|
463
|
+
endedAt: state.endedAt ?? Date.now(),
|
|
464
|
+
status: state.status === "done" ? "done" : state.status === "stopped" ? "stopped" : "failed",
|
|
465
|
+
files,
|
|
466
|
+
changeIds: ids,
|
|
467
|
+
summary: [...state.log].reverse().find((l) => l.kind === "message")?.text,
|
|
468
|
+
message: (0, import_spec.commitMessage)(changes)
|
|
469
|
+
};
|
|
470
|
+
run2 = rec;
|
|
471
|
+
try {
|
|
472
|
+
saveRun(folder, rec);
|
|
473
|
+
} catch {
|
|
474
|
+
}
|
|
475
|
+
if (!files.length) {
|
|
476
|
+
if (rec.status === "done") say(`${NAMES[agent]} finished without editing any files.`);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const dirty = await uncommitted(root, files);
|
|
480
|
+
const git2 = dirty.length > 0 || await isGitRepo(root);
|
|
481
|
+
if (state.startedAt === startedAt) {
|
|
482
|
+
state.commit = { message: rec.message, uncommitted: dirty, git: git2 };
|
|
483
|
+
emit(true);
|
|
484
|
+
}
|
|
485
|
+
const n = files.length;
|
|
486
|
+
say(`${NAMES[agent]} edited ${n} ${n === 1 ? "file" : "files"}: ${list(files)}${dirty.length ? " (not committed yet)" : ""}`);
|
|
487
|
+
if (dirty.length) {
|
|
488
|
+
say(`Commit message: ${rec.message.split("\n")[0]}`);
|
|
489
|
+
if (autoCommitOn()) say("Auto-commit is on: Limno commits these files once every change is verified on the page.");
|
|
490
|
+
}
|
|
491
|
+
if (state.startedAt === startedAt) void changeSetChanged(set);
|
|
492
|
+
}
|
|
493
|
+
async function changeSetChanged(set) {
|
|
494
|
+
const commit = state.commit;
|
|
495
|
+
if (!set || !run2 || committing || !commit || commit.committed || !commit.uncommitted.length || !autoCommitOn()) return;
|
|
496
|
+
if (state.status !== "done") return;
|
|
497
|
+
const sent = set.changes.filter((c) => run2.changeIds.includes(c.id) && c.kind !== "note");
|
|
498
|
+
if (!sent.length) return;
|
|
499
|
+
if (sent.some((c) => c.status === "differs" || c.status === "failed")) {
|
|
500
|
+
if (!commit.note) {
|
|
501
|
+
commit.note = "A change doesn\u2019t match the preview yet.";
|
|
502
|
+
const off = (0, import_spec.describeChanges)(sent.filter((c) => c.status === "differs" || c.status === "failed")).length || 1;
|
|
503
|
+
say(`Not auto-committing: ${off === 1 ? "1 change doesn\u2019t" : `${off} changes don\u2019t`} match the preview yet. Review in Limno.`);
|
|
504
|
+
emit(true);
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (!sent.every((c) => c.status === "verified")) return;
|
|
509
|
+
committing = true;
|
|
510
|
+
try {
|
|
511
|
+
const r = await commitFiles(root, state.files, run2.message);
|
|
512
|
+
if (r.ok) {
|
|
513
|
+
commit.committed = { sha: r.sha, auto: true };
|
|
514
|
+
commit.uncommitted = [];
|
|
515
|
+
commit.note = void 0;
|
|
516
|
+
run2.commit = { sha: r.sha, at: Date.now(), auto: true };
|
|
517
|
+
try {
|
|
518
|
+
saveRun(folder, run2);
|
|
519
|
+
} catch {
|
|
520
|
+
}
|
|
521
|
+
say(`Committed ${state.files.length === 1 ? "1 file" : `${state.files.length} files`} as ${r.sha}: ${run2.message.split("\n")[0]} (not pushed)`);
|
|
522
|
+
} else {
|
|
523
|
+
commit.note = `Not committed: ${r.error}`;
|
|
524
|
+
say(`Didn\u2019t auto-commit: ${r.error}`);
|
|
525
|
+
}
|
|
526
|
+
emit(true);
|
|
527
|
+
} finally {
|
|
528
|
+
committing = false;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
359
531
|
function limnoLine(tool, input = {}) {
|
|
360
532
|
if (tool.endsWith("get_changes")) return "Reading your changes";
|
|
361
533
|
if (tool.endsWith("update_status")) {
|
|
362
|
-
const
|
|
534
|
+
const ids = Array.isArray(input.ids) ? input.ids : [];
|
|
535
|
+
const sent = (readSet()?.changes ?? []).filter((c) => ids.includes(c.id));
|
|
536
|
+
const n = sent.length ? (0, import_spec.describeChanges)(sent).length : ids.length;
|
|
363
537
|
const status = String(input.status ?? "");
|
|
364
538
|
return status === "implemented" ? `Marked ${n || ""} ${n === 1 ? "change" : "changes"} done`.replace(" ", " ") : status === "failed" ? "Reported a change it couldn\u2019t make" : "Working on your changes";
|
|
365
539
|
}
|
|
@@ -451,7 +625,7 @@ function createRunner(root, folder, onState) {
|
|
|
451
625
|
return resume ? ["exec", ...opts, "resume", resume, PROMPT] : ["exec", ...opts, PROMPT];
|
|
452
626
|
}
|
|
453
627
|
function launch(agent, resume) {
|
|
454
|
-
const proc = (0,
|
|
628
|
+
const proc = (0, import_node_child_process2.spawn)(agent, args(agent, resume), { cwd: root, env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
|
|
455
629
|
child = proc;
|
|
456
630
|
let buf = "";
|
|
457
631
|
let stderr = "";
|
|
@@ -486,7 +660,11 @@ function createRunner(root, folder, onState) {
|
|
|
486
660
|
launch(agent);
|
|
487
661
|
return;
|
|
488
662
|
}
|
|
489
|
-
if (state.status === "stopped")
|
|
663
|
+
if (state.status === "stopped") {
|
|
664
|
+
emit(true);
|
|
665
|
+
void finishRun(agent);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
490
668
|
const failed = code !== 0 || !!state.error;
|
|
491
669
|
const tail = stderr.trim().split("\n").filter(Boolean).slice(-2).join(" ").slice(0, 300);
|
|
492
670
|
state = {
|
|
@@ -496,6 +674,7 @@ function createRunner(root, folder, onState) {
|
|
|
496
674
|
error: failed ? state.error ?? (tail || `${NAMES[agent]} exited${signal ? ` (${signal})` : ` with code ${code}`}`) : void 0
|
|
497
675
|
};
|
|
498
676
|
emit(true);
|
|
677
|
+
void finishRun(agent);
|
|
499
678
|
if (again && !failed) {
|
|
500
679
|
again = false;
|
|
501
680
|
void start();
|
|
@@ -514,16 +693,32 @@ function createRunner(root, folder, onState) {
|
|
|
514
693
|
if (!info?.installed) return { ok: false, error: `${NAMES[agent]} isn\u2019t installed on this computer.` };
|
|
515
694
|
if (!info.signedIn) return { ok: false, error: `${NAMES[agent]} isn\u2019t signed in. Run \u201C${SIGN_IN[agent]}\u201D in a terminal, then send again.` };
|
|
516
695
|
state = { status: "starting", agent, startedAt: Date.now(), endedAt: null, log: [], files: [] };
|
|
696
|
+
const queued = (readSet()?.changes ?? []).filter((c) => c.enabled && ["queued", "in_progress", "differs", "failed"].includes(c.status)).map((c) => c.id);
|
|
697
|
+
run2 = { id: String(state.startedAt), agent: NAMES[agent], startedAt: state.startedAt, endedAt: 0, status: "done", files: [], changeIds: queued, message: "" };
|
|
517
698
|
emit(true);
|
|
518
699
|
launch(agent, sessions()[agent]);
|
|
519
700
|
return { ok: true };
|
|
520
701
|
}
|
|
521
702
|
return {
|
|
522
703
|
async info(force) {
|
|
523
|
-
|
|
704
|
+
if (state.commit && !state.commit.committed && state.commit.uncommitted.length && !child) {
|
|
705
|
+
state.commit = { ...state.commit, uncommitted: await uncommitted(root, state.files) };
|
|
706
|
+
}
|
|
707
|
+
return { agents: await detectAgents(force), allowed: allowed(), autoCommit: autoCommitOn(), state };
|
|
708
|
+
},
|
|
709
|
+
setAutoCommit(on) {
|
|
710
|
+
const t = readTrust();
|
|
711
|
+
if (!t[key]) return;
|
|
712
|
+
t[key] = { ...t[key], autoCommit: on };
|
|
713
|
+
writeTrust(t);
|
|
714
|
+
if (on) void changeSetChanged(readSet());
|
|
715
|
+
},
|
|
716
|
+
changeSetChanged(set) {
|
|
717
|
+
void changeSetChanged(set);
|
|
524
718
|
},
|
|
525
719
|
allow(agent) {
|
|
526
|
-
|
|
720
|
+
const prev = readTrust()[key];
|
|
721
|
+
writeTrust({ ...readTrust(), [key]: { agent, at: Date.now(), autoCommit: prev?.autoCommit } });
|
|
527
722
|
},
|
|
528
723
|
forget() {
|
|
529
724
|
const t = readTrust();
|
|
@@ -547,11 +742,11 @@ function createRunner(root, folder, onState) {
|
|
|
547
742
|
}
|
|
548
743
|
|
|
549
744
|
// src/update.ts
|
|
550
|
-
var
|
|
551
|
-
var
|
|
745
|
+
var import_node_child_process3 = require("child_process");
|
|
746
|
+
var import_node_fs3 = require("fs");
|
|
552
747
|
var import_node_module2 = require("module");
|
|
553
748
|
var import_node_os2 = __toESM(require("os"), 1);
|
|
554
|
-
var
|
|
749
|
+
var import_node_path4 = __toESM(require("path"), 1);
|
|
555
750
|
var require2 = (0, import_node_module2.createRequire)(importMetaUrl);
|
|
556
751
|
var TTL = 60 * 60 * 1e3;
|
|
557
752
|
function currentVersion() {
|
|
@@ -563,7 +758,7 @@ function currentVersion() {
|
|
|
563
758
|
}
|
|
564
759
|
function installedVersion(fallback) {
|
|
565
760
|
try {
|
|
566
|
-
return JSON.parse((0,
|
|
761
|
+
return JSON.parse((0, import_node_fs3.readFileSync)(require2.resolve("../package.json"), "utf8")).version;
|
|
567
762
|
} catch {
|
|
568
763
|
return fallback;
|
|
569
764
|
}
|
|
@@ -577,18 +772,18 @@ var newer = (a, b) => {
|
|
|
577
772
|
function packageManager(root) {
|
|
578
773
|
let dir = root;
|
|
579
774
|
for (; ; ) {
|
|
580
|
-
if ((0,
|
|
581
|
-
if ((0,
|
|
582
|
-
if ((0,
|
|
583
|
-
if ((0,
|
|
584
|
-
const up =
|
|
775
|
+
if ((0, import_node_fs3.existsSync)(import_node_path4.default.join(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
776
|
+
if ((0, import_node_fs3.existsSync)(import_node_path4.default.join(dir, "yarn.lock"))) return "yarn";
|
|
777
|
+
if ((0, import_node_fs3.existsSync)(import_node_path4.default.join(dir, "bun.lockb")) || (0, import_node_fs3.existsSync)(import_node_path4.default.join(dir, "bun.lock"))) return "bun";
|
|
778
|
+
if ((0, import_node_fs3.existsSync)(import_node_path4.default.join(dir, "package-lock.json"))) return "npm";
|
|
779
|
+
const up = import_node_path4.default.dirname(dir);
|
|
585
780
|
if (up === dir) return "npm";
|
|
586
781
|
dir = up;
|
|
587
782
|
}
|
|
588
783
|
}
|
|
589
784
|
function listedPackages(root) {
|
|
590
785
|
try {
|
|
591
|
-
const pkg = JSON.parse((0,
|
|
786
|
+
const pkg = JSON.parse((0, import_node_fs3.readFileSync)(import_node_path4.default.join(root, "package.json"), "utf8"));
|
|
592
787
|
return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((n) => n.startsWith("@limno/") && n !== "@limno/cli");
|
|
593
788
|
} catch {
|
|
594
789
|
return [];
|
|
@@ -601,10 +796,10 @@ function installArgs(pm, pkgs) {
|
|
|
601
796
|
if (pm === "bun") return ["bun", ["add", "-d", ...at]];
|
|
602
797
|
return ["npm", ["install", "-D", ...at]];
|
|
603
798
|
}
|
|
604
|
-
var cacheFile = () =>
|
|
799
|
+
var cacheFile = () => import_node_path4.default.join(import_node_os2.default.homedir(), ".limno", "update-check.json");
|
|
605
800
|
async function latestVersion() {
|
|
606
801
|
try {
|
|
607
|
-
const cached = JSON.parse((0,
|
|
802
|
+
const cached = JSON.parse((0, import_node_fs3.readFileSync)(cacheFile(), "utf8"));
|
|
608
803
|
if (Date.now() - cached.at < TTL) return cached.latest;
|
|
609
804
|
} catch {
|
|
610
805
|
}
|
|
@@ -613,8 +808,8 @@ async function latestVersion() {
|
|
|
613
808
|
if (!res.ok) return null;
|
|
614
809
|
const latest = (await res.json()).latest ?? null;
|
|
615
810
|
if (latest) {
|
|
616
|
-
(0,
|
|
617
|
-
(0,
|
|
811
|
+
(0, import_node_fs3.mkdirSync)(import_node_path4.default.dirname(cacheFile()), { recursive: true });
|
|
812
|
+
(0, import_node_fs3.writeFileSync)(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
|
|
618
813
|
}
|
|
619
814
|
return latest;
|
|
620
815
|
} catch {
|
|
@@ -641,14 +836,14 @@ function createUpdater(root) {
|
|
|
641
836
|
const [cmd, args] = installArgs(packageManager(root), pkgs);
|
|
642
837
|
installing = new Promise((resolve) => {
|
|
643
838
|
let output = "";
|
|
644
|
-
const child = (0,
|
|
839
|
+
const child = (0, import_node_child_process3.spawn)(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
|
|
645
840
|
child.stdout?.on("data", (d) => output += d);
|
|
646
841
|
child.stderr?.on("data", (d) => output += d);
|
|
647
842
|
child.on("error", (e) => resolve({ ok: false, output: String(e) }));
|
|
648
843
|
child.on("close", (code) => {
|
|
649
844
|
if (code === 0) {
|
|
650
845
|
try {
|
|
651
|
-
(0,
|
|
846
|
+
(0, import_node_fs3.writeFileSync)(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
|
|
652
847
|
} catch {
|
|
653
848
|
}
|
|
654
849
|
}
|
|
@@ -668,16 +863,16 @@ var require3 = (0, import_node_module3.createRequire)(importMetaUrl);
|
|
|
668
863
|
var STALE_MS = 3e4;
|
|
669
864
|
function readJson(file) {
|
|
670
865
|
try {
|
|
671
|
-
return JSON.parse((0,
|
|
866
|
+
return JSON.parse((0, import_node_fs4.readFileSync)(file, "utf8"));
|
|
672
867
|
} catch {
|
|
673
868
|
return null;
|
|
674
869
|
}
|
|
675
870
|
}
|
|
676
871
|
function writeAtomic(file, data) {
|
|
677
|
-
(0,
|
|
872
|
+
(0, import_node_fs4.mkdirSync)(import_node_path5.default.dirname(file), { recursive: true });
|
|
678
873
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
679
|
-
(0,
|
|
680
|
-
(0,
|
|
874
|
+
(0, import_node_fs4.writeFileSync)(tmp, data);
|
|
875
|
+
(0, import_node_fs4.renameSync)(tmp, file);
|
|
681
876
|
}
|
|
682
877
|
function body(req) {
|
|
683
878
|
return new Promise((resolve, reject) => {
|
|
@@ -698,9 +893,9 @@ function overlayFile() {
|
|
|
698
893
|
return require3.resolve("@limno/overlay/dist/overlay.js");
|
|
699
894
|
}
|
|
700
895
|
function createLimnoService({ root, dir = ".limno" }) {
|
|
701
|
-
const folder =
|
|
702
|
-
const file =
|
|
703
|
-
const presenceFile =
|
|
896
|
+
const folder = import_node_path5.default.join(root, dir);
|
|
897
|
+
const file = import_node_path5.default.join(folder, "changeset.json");
|
|
898
|
+
const presenceFile = import_node_path5.default.join(folder, "agent.json");
|
|
704
899
|
let lastWrittenHash = "";
|
|
705
900
|
const clients = /* @__PURE__ */ new Set();
|
|
706
901
|
function broadcast(event, data) {
|
|
@@ -729,23 +924,23 @@ data: ${JSON.stringify(data)}
|
|
|
729
924
|
return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
|
|
730
925
|
}
|
|
731
926
|
function installConfig(target) {
|
|
732
|
-
const dest = target === "claude" ?
|
|
927
|
+
const dest = target === "claude" ? import_node_path5.default.join(root, ".mcp.json") : import_node_path5.default.join(root, ".cursor", "mcp.json");
|
|
733
928
|
let config = {};
|
|
734
|
-
if ((0,
|
|
929
|
+
if ((0, import_node_fs4.existsSync)(dest)) {
|
|
735
930
|
try {
|
|
736
|
-
config = JSON.parse((0,
|
|
931
|
+
config = JSON.parse((0, import_node_fs4.readFileSync)(dest, "utf8"));
|
|
737
932
|
} catch {
|
|
738
|
-
throw new Error(`${
|
|
933
|
+
throw new Error(`${import_node_path5.default.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
|
|
739
934
|
}
|
|
740
935
|
}
|
|
741
936
|
const { command, args } = setupInfo();
|
|
742
937
|
const portable = args.map((a, i) => {
|
|
743
938
|
if (args[i - 1] === "--root") return ".";
|
|
744
|
-
return
|
|
939
|
+
return import_node_path5.default.isAbsolute(a) ? import_node_path5.default.relative(root, a).split(import_node_path5.default.sep).join("/") : a;
|
|
745
940
|
});
|
|
746
941
|
config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
|
|
747
942
|
writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
|
|
748
|
-
return
|
|
943
|
+
return import_node_path5.default.relative(root, dest);
|
|
749
944
|
}
|
|
750
945
|
let lastAgent = "";
|
|
751
946
|
function pushAgent() {
|
|
@@ -756,12 +951,13 @@ data: ${JSON.stringify(data)}
|
|
|
756
951
|
broadcast("agent", status);
|
|
757
952
|
}
|
|
758
953
|
function onChangeSetFile() {
|
|
759
|
-
const raw = (0,
|
|
954
|
+
const raw = (0, import_node_fs4.existsSync)(file) ? (0, import_node_fs4.readFileSync)(file, "utf8") : "";
|
|
760
955
|
if (!raw || hash(raw) === lastWrittenHash) return;
|
|
761
956
|
const set = readJson(file);
|
|
762
957
|
if (set) broadcast("changeset", set);
|
|
958
|
+
runner.changeSetChanged(set);
|
|
763
959
|
}
|
|
764
|
-
(0,
|
|
960
|
+
(0, import_node_fs4.mkdirSync)(folder, { recursive: true });
|
|
765
961
|
let watcher = null;
|
|
766
962
|
let watchedIno = -1;
|
|
767
963
|
let debounce;
|
|
@@ -769,8 +965,8 @@ data: ${JSON.stringify(data)}
|
|
|
769
965
|
watcher?.close();
|
|
770
966
|
watcher = null;
|
|
771
967
|
try {
|
|
772
|
-
watchedIno = (0,
|
|
773
|
-
watcher = (0,
|
|
968
|
+
watchedIno = (0, import_node_fs4.statSync)(folder).ino;
|
|
969
|
+
watcher = (0, import_node_fs4.watch)(folder, (_event, name) => {
|
|
774
970
|
if (!name || name.endsWith(".tmp")) return;
|
|
775
971
|
clearTimeout(debounce);
|
|
776
972
|
debounce = setTimeout(() => {
|
|
@@ -786,7 +982,7 @@ data: ${JSON.stringify(data)}
|
|
|
786
982
|
const presenceTimer = setInterval(() => {
|
|
787
983
|
let ino = -1;
|
|
788
984
|
try {
|
|
789
|
-
ino = (0,
|
|
985
|
+
ino = (0, import_node_fs4.statSync)(folder).ino;
|
|
790
986
|
} catch {
|
|
791
987
|
}
|
|
792
988
|
if (ino !== -1 && ino !== watchedIno) {
|
|
@@ -803,8 +999,8 @@ data: ${JSON.stringify(data)}
|
|
|
803
999
|
async function openInEditor(spec) {
|
|
804
1000
|
const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
|
|
805
1001
|
if (!m) return false;
|
|
806
|
-
const target =
|
|
807
|
-
if (!target.startsWith(root +
|
|
1002
|
+
const target = import_node_path5.default.resolve(root, m[1]);
|
|
1003
|
+
if (!target.startsWith(root + import_node_path5.default.sep) || !(0, import_node_fs4.existsSync)(target)) return false;
|
|
808
1004
|
const { default: launch } = await import("launch-editor");
|
|
809
1005
|
launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
|
|
810
1006
|
return true;
|
|
@@ -814,7 +1010,7 @@ data: ${JSON.stringify(data)}
|
|
|
814
1010
|
if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
|
|
815
1011
|
try {
|
|
816
1012
|
if (url === `${ROUTE}/overlay.js`) {
|
|
817
|
-
return send(res, 200, (0,
|
|
1013
|
+
return send(res, 200, (0, import_node_fs4.readFileSync)(overlayFile(), "utf8"), "text/javascript");
|
|
818
1014
|
}
|
|
819
1015
|
if (url === `${ROUTE}/events`) {
|
|
820
1016
|
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
|
|
@@ -824,20 +1020,21 @@ data: ${JSON.stringify(data)}
|
|
|
824
1020
|
return;
|
|
825
1021
|
}
|
|
826
1022
|
if (url === `${ROUTE}/changeset` && req.method === "GET") {
|
|
827
|
-
return send(res, 200, readJson(file) ?? (0,
|
|
1023
|
+
return send(res, 200, readJson(file) ?? (0, import_spec2.emptyChangeSet)());
|
|
828
1024
|
}
|
|
829
1025
|
if (url === `${ROUTE}/changeset` && req.method === "PUT") {
|
|
830
1026
|
const incoming = JSON.parse(await body(req));
|
|
831
|
-
const merged = (0,
|
|
1027
|
+
const merged = (0, import_spec2.mergeStatuses)(incoming, readJson(file));
|
|
832
1028
|
merged.updatedAt = Date.now();
|
|
833
1029
|
const raw = JSON.stringify(merged, null, 2);
|
|
834
1030
|
lastWrittenHash = hash(raw);
|
|
835
1031
|
writeAtomic(file, raw);
|
|
1032
|
+
runner.changeSetChanged(merged);
|
|
836
1033
|
return send(res, 200, merged);
|
|
837
1034
|
}
|
|
838
1035
|
if (url === `${ROUTE}/prompt` && req.method === "GET") {
|
|
839
|
-
const set = readJson(file) ?? (0,
|
|
840
|
-
return send(res, 200, (0,
|
|
1036
|
+
const set = readJson(file) ?? (0, import_spec2.emptyChangeSet)();
|
|
1037
|
+
return send(res, 200, (0, import_spec2.renderPrompt)(set, { mcp: false, statuses: ["draft", "queued", "in_progress", "differs", "failed"] }), "text/markdown; charset=utf-8");
|
|
841
1038
|
}
|
|
842
1039
|
if (url === `${ROUTE}/agent` && req.method === "GET") {
|
|
843
1040
|
return send(res, 200, agentStatus());
|
|
@@ -874,6 +1071,11 @@ data: ${JSON.stringify(data)}
|
|
|
874
1071
|
runner.forget();
|
|
875
1072
|
return send(res, 200, await runner.info());
|
|
876
1073
|
}
|
|
1074
|
+
if (action === "autocommit") {
|
|
1075
|
+
const { on } = JSON.parse(await body(req));
|
|
1076
|
+
runner.setAutoCommit(!!on);
|
|
1077
|
+
return send(res, 200, await runner.info());
|
|
1078
|
+
}
|
|
877
1079
|
if (action === "run") return send(res, 200, await runner.start());
|
|
878
1080
|
if (action === "stop") {
|
|
879
1081
|
runner.stop();
|
|
@@ -885,7 +1087,7 @@ data: ${JSON.stringify(data)}
|
|
|
885
1087
|
return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
|
|
886
1088
|
}
|
|
887
1089
|
if (url === `${ROUTE}/info`) {
|
|
888
|
-
return send(res, 200, { name: "limno", root, file:
|
|
1090
|
+
return send(res, 200, { name: "limno", root, file: import_node_path5.default.relative(root, file) });
|
|
889
1091
|
}
|
|
890
1092
|
send(res, 404, { error: "not found" });
|
|
891
1093
|
} catch (err) {
|
package/dist/index.d.cts
CHANGED
|
@@ -101,11 +101,29 @@ interface RunnerState {
|
|
|
101
101
|
/** Project-relative files the agent edited in this run. */
|
|
102
102
|
files: string[];
|
|
103
103
|
error?: string;
|
|
104
|
+
/** After a run that edited files: what to commit, and whether it has been. */
|
|
105
|
+
commit?: RunCommit;
|
|
106
|
+
}
|
|
107
|
+
interface RunCommit {
|
|
108
|
+
message: string;
|
|
109
|
+
/** Edited files git still sees as changed. Empty once they're committed. */
|
|
110
|
+
uncommitted: string[];
|
|
111
|
+
/** The project is a git repository. */
|
|
112
|
+
git: boolean;
|
|
113
|
+
/** Limno committed them (auto-commit). */
|
|
114
|
+
committed?: {
|
|
115
|
+
sha: string;
|
|
116
|
+
auto: boolean;
|
|
117
|
+
};
|
|
118
|
+
/** Why an auto-commit didn't happen. */
|
|
119
|
+
note?: string;
|
|
104
120
|
}
|
|
105
121
|
interface RunnerInfo {
|
|
106
122
|
agents: RunnerAgent[];
|
|
107
123
|
/** The agent this user allowed Limno to run on this project, if any. */
|
|
108
124
|
allowed: RunnerAgentId | null;
|
|
125
|
+
/** Commit the agent's files once Limno has verified every change (never pushes). */
|
|
126
|
+
autoCommit: boolean;
|
|
109
127
|
state: RunnerState;
|
|
110
128
|
}
|
|
111
129
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -101,11 +101,29 @@ interface RunnerState {
|
|
|
101
101
|
/** Project-relative files the agent edited in this run. */
|
|
102
102
|
files: string[];
|
|
103
103
|
error?: string;
|
|
104
|
+
/** After a run that edited files: what to commit, and whether it has been. */
|
|
105
|
+
commit?: RunCommit;
|
|
106
|
+
}
|
|
107
|
+
interface RunCommit {
|
|
108
|
+
message: string;
|
|
109
|
+
/** Edited files git still sees as changed. Empty once they're committed. */
|
|
110
|
+
uncommitted: string[];
|
|
111
|
+
/** The project is a git repository. */
|
|
112
|
+
git: boolean;
|
|
113
|
+
/** Limno committed them (auto-commit). */
|
|
114
|
+
committed?: {
|
|
115
|
+
sha: string;
|
|
116
|
+
auto: boolean;
|
|
117
|
+
};
|
|
118
|
+
/** Why an auto-commit didn't happen. */
|
|
119
|
+
note?: string;
|
|
104
120
|
}
|
|
105
121
|
interface RunnerInfo {
|
|
106
122
|
agents: RunnerAgent[];
|
|
107
123
|
/** The agent this user allowed Limno to run on this project, if any. */
|
|
108
124
|
allowed: RunnerAgentId | null;
|
|
125
|
+
/** Commit the agent's files once Limno has verified every change (never pushes). */
|
|
126
|
+
autoCommit: boolean;
|
|
109
127
|
state: RunnerState;
|
|
110
128
|
}
|
|
111
129
|
/**
|
package/dist/index.js
CHANGED
|
@@ -10,8 +10,8 @@ function jsxName(name) {
|
|
|
10
10
|
return `${jsxName(name.object)}.${name.property.name}`;
|
|
11
11
|
}
|
|
12
12
|
var isCapitalized = (s) => /^[A-Z]/.test(s);
|
|
13
|
-
function componentName(
|
|
14
|
-
let p =
|
|
13
|
+
function componentName(path6, fileBase) {
|
|
14
|
+
let p = path6.getFunctionParent();
|
|
15
15
|
while (p) {
|
|
16
16
|
const node = p.node;
|
|
17
17
|
let name;
|
|
@@ -53,8 +53,8 @@ function stampSource(code, id, relFile) {
|
|
|
53
53
|
const fileBase = relFile.split("/").pop().replace(/\.[jt]sx?$/, "").replace(/^./, (c) => c.toUpperCase());
|
|
54
54
|
let touched = false;
|
|
55
55
|
traverse(ast, {
|
|
56
|
-
JSXOpeningElement(
|
|
57
|
-
const node =
|
|
56
|
+
JSXOpeningElement(path6) {
|
|
57
|
+
const node = path6.node;
|
|
58
58
|
const loc = node.loc;
|
|
59
59
|
if (!loc || node.name.end == null) return;
|
|
60
60
|
const name = jsxName(node.name);
|
|
@@ -62,7 +62,7 @@ function stampSource(code, id, relFile) {
|
|
|
62
62
|
const host = node.name.type === "JSXIdentifier" && !isCapitalized(name);
|
|
63
63
|
if (host) {
|
|
64
64
|
if (hasAttr(node, "data-limno-src")) return;
|
|
65
|
-
const comp = componentName(
|
|
65
|
+
const comp = componentName(path6, fileBase);
|
|
66
66
|
s.appendLeft(node.name.end, ` data-limno-src="${escapeAttr(where)}"${comp ? ` data-limno-comp="${escapeAttr(comp)}"` : ""}`);
|
|
67
67
|
touched = true;
|
|
68
68
|
} else {
|
|
@@ -174,16 +174,97 @@ async function stampTemplate(code, id, relFile, root) {
|
|
|
174
174
|
|
|
175
175
|
// src/service.ts
|
|
176
176
|
import { createHash } from "crypto";
|
|
177
|
-
import { existsSync as
|
|
177
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync as renameSync2, statSync, watch, writeFileSync as writeFileSync4 } from "fs";
|
|
178
178
|
import { createRequire as createRequire3 } from "module";
|
|
179
|
-
import
|
|
179
|
+
import path5 from "path";
|
|
180
180
|
import { emptyChangeSet, mergeStatuses, renderPrompt } from "@limno/spec";
|
|
181
181
|
|
|
182
182
|
// src/runner.ts
|
|
183
|
-
import { spawn } from "child_process";
|
|
184
|
-
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
|
|
183
|
+
import { spawn as spawn2 } from "child_process";
|
|
184
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, realpathSync, writeFileSync as writeFileSync2 } from "fs";
|
|
185
185
|
import os from "os";
|
|
186
|
+
import path3 from "path";
|
|
187
|
+
import { commitMessage, describeChanges } from "@limno/spec";
|
|
188
|
+
|
|
189
|
+
// src/history.ts
|
|
190
|
+
import { spawn } from "child_process";
|
|
191
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
186
192
|
import path2 from "path";
|
|
193
|
+
var KEEP = 20;
|
|
194
|
+
function runsFile(folder) {
|
|
195
|
+
return path2.join(folder, "runs.json");
|
|
196
|
+
}
|
|
197
|
+
function readRuns(folder) {
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(readFileSync(runsFile(folder), "utf8")).runs ?? [];
|
|
200
|
+
} catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function writeRuns(folder, runs) {
|
|
205
|
+
mkdirSync(folder, { recursive: true });
|
|
206
|
+
const file = runsFile(folder);
|
|
207
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
208
|
+
writeFileSync(tmp, `${JSON.stringify({ runs: runs.slice(-KEEP) }, null, 2)}
|
|
209
|
+
`);
|
|
210
|
+
renameSync(tmp, file);
|
|
211
|
+
}
|
|
212
|
+
function saveRun(folder, run2) {
|
|
213
|
+
const runs = readRuns(folder).filter((r) => r.id !== run2.id);
|
|
214
|
+
writeRuns(folder, [...runs, run2]);
|
|
215
|
+
}
|
|
216
|
+
function git(root, args, timeoutMs = 15e3) {
|
|
217
|
+
return new Promise((resolve) => {
|
|
218
|
+
let out = "";
|
|
219
|
+
let child;
|
|
220
|
+
try {
|
|
221
|
+
child = spawn("git", args, { cwd: root, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
222
|
+
} catch {
|
|
223
|
+
return resolve({ code: null, out: "" });
|
|
224
|
+
}
|
|
225
|
+
const timer = setTimeout(() => child.kill(), timeoutMs);
|
|
226
|
+
child.stdout?.on("data", (d) => out += d);
|
|
227
|
+
child.stderr?.on("data", (d) => out += d);
|
|
228
|
+
child.on("error", () => {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
resolve({ code: null, out });
|
|
231
|
+
});
|
|
232
|
+
child.on("close", (code) => {
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
resolve({ code, out });
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
async function isGitRepo(root) {
|
|
239
|
+
return (await git(root, ["rev-parse", "--is-inside-work-tree"])).out.trim() === "true";
|
|
240
|
+
}
|
|
241
|
+
async function uncommitted(root, files) {
|
|
242
|
+
if (!files.length || !await isGitRepo(root)) return [];
|
|
243
|
+
const r = await git(root, ["status", "--porcelain", "--untracked-files=all", "--", ...files]);
|
|
244
|
+
if (r.code !== 0) return [];
|
|
245
|
+
const dirty = new Set(
|
|
246
|
+
r.out.split("\n").filter(Boolean).map((l) => l.slice(3).replace(/^"|"$/g, "").split(" -> ").pop())
|
|
247
|
+
);
|
|
248
|
+
const top = (await git(root, ["rev-parse", "--show-toplevel"])).out.trim();
|
|
249
|
+
return files.filter((f) => dirty.has(top ? path2.relative(top, path2.join(root, f)) : f) || dirty.has(f));
|
|
250
|
+
}
|
|
251
|
+
async function commitFiles(root, files, message) {
|
|
252
|
+
if (!await isGitRepo(root)) return { ok: false, error: "This project isn\u2019t a git repository." };
|
|
253
|
+
for (const marker of ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD"]) {
|
|
254
|
+
const p = (await git(root, ["rev-parse", "--git-path", marker])).out.trim();
|
|
255
|
+
if (p && existsSync(path2.resolve(root, p))) return { ok: false, error: "Git is in the middle of a merge or rebase." };
|
|
256
|
+
}
|
|
257
|
+
const dirty = await uncommitted(root, files);
|
|
258
|
+
if (!dirty.length) return { ok: false, error: "Nothing to commit: those files have no changes." };
|
|
259
|
+
const add = await git(root, ["add", "--", ...dirty]);
|
|
260
|
+
if (add.code !== 0) return { ok: false, error: add.out.trim().split("\n").pop() || "git add failed" };
|
|
261
|
+
const commit = await git(root, ["commit", "-m", message, "--", ...dirty], 6e4);
|
|
262
|
+
if (commit.code !== 0) return { ok: false, error: commit.out.trim().split("\n").slice(-2).join(" ") || "git commit failed" };
|
|
263
|
+
const sha = (await git(root, ["rev-parse", "--short", "HEAD"])).out.trim();
|
|
264
|
+
return { ok: true, sha };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// src/runner.ts
|
|
187
268
|
var NAMES = { claude: "Claude Code", codex: "Codex" };
|
|
188
269
|
var SIGN_IN = { claude: "claude auth login", codex: "codex login" };
|
|
189
270
|
var PROMPT = [
|
|
@@ -193,7 +274,7 @@ var PROMPT = [
|
|
|
193
274
|
"Only read and edit files inside this project. Don't run shell commands; Limno checks the result on the page itself.",
|
|
194
275
|
"Finish with one short sentence summarising what you changed."
|
|
195
276
|
].join(" ");
|
|
196
|
-
var trustFile = () =>
|
|
277
|
+
var trustFile = () => path3.join(os.homedir(), ".limno", "trust.json");
|
|
197
278
|
function projectKey(root) {
|
|
198
279
|
try {
|
|
199
280
|
return realpathSync(root);
|
|
@@ -203,14 +284,14 @@ function projectKey(root) {
|
|
|
203
284
|
}
|
|
204
285
|
function readTrust() {
|
|
205
286
|
try {
|
|
206
|
-
return JSON.parse(
|
|
287
|
+
return JSON.parse(readFileSync2(trustFile(), "utf8")).projects ?? {};
|
|
207
288
|
} catch {
|
|
208
289
|
return {};
|
|
209
290
|
}
|
|
210
291
|
}
|
|
211
292
|
function writeTrust(projects) {
|
|
212
|
-
|
|
213
|
-
|
|
293
|
+
mkdirSync2(path3.dirname(trustFile()), { recursive: true });
|
|
294
|
+
writeFileSync2(trustFile(), `${JSON.stringify({ projects }, null, 2)}
|
|
214
295
|
`);
|
|
215
296
|
}
|
|
216
297
|
function run(cmd, args, timeoutMs = 8e3) {
|
|
@@ -218,7 +299,7 @@ function run(cmd, args, timeoutMs = 8e3) {
|
|
|
218
299
|
let out = "";
|
|
219
300
|
let child;
|
|
220
301
|
try {
|
|
221
|
-
child =
|
|
302
|
+
child = spawn2(cmd, args, { env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
|
|
222
303
|
} catch {
|
|
223
304
|
return resolve({ code: null, out: "" });
|
|
224
305
|
}
|
|
@@ -271,20 +352,20 @@ function isLocalRequest(req) {
|
|
|
271
352
|
return true;
|
|
272
353
|
}
|
|
273
354
|
function createRunner(root, folder, onState) {
|
|
274
|
-
const sessionsFile =
|
|
355
|
+
const sessionsFile = path3.join(folder, "runner.json");
|
|
275
356
|
let child = null;
|
|
276
357
|
let again = false;
|
|
277
358
|
let state = { status: "idle", agent: null, startedAt: null, endedAt: null, log: [], files: [] };
|
|
278
359
|
const sessions = () => {
|
|
279
360
|
try {
|
|
280
|
-
return JSON.parse(
|
|
361
|
+
return JSON.parse(readFileSync2(sessionsFile, "utf8"));
|
|
281
362
|
} catch {
|
|
282
363
|
return {};
|
|
283
364
|
}
|
|
284
365
|
};
|
|
285
366
|
const saveSession = (agent, id) => {
|
|
286
|
-
|
|
287
|
-
|
|
367
|
+
mkdirSync2(folder, { recursive: true });
|
|
368
|
+
writeFileSync2(sessionsFile, `${JSON.stringify({ ...sessions(), [agent]: id }, null, 2)}
|
|
288
369
|
`);
|
|
289
370
|
};
|
|
290
371
|
let emitTimer;
|
|
@@ -302,15 +383,108 @@ function createRunner(root, folder, onState) {
|
|
|
302
383
|
};
|
|
303
384
|
const rel = (p) => {
|
|
304
385
|
if (!p) return "";
|
|
305
|
-
const abs =
|
|
306
|
-
return
|
|
386
|
+
const abs = path3.isAbsolute(p) ? p : path3.join(root, p);
|
|
387
|
+
return path3.relative(root, abs) || p;
|
|
307
388
|
};
|
|
308
389
|
const key = projectKey(root);
|
|
309
390
|
const allowed = () => readTrust()[key]?.agent ?? null;
|
|
391
|
+
const autoCommitOn = () => !!readTrust()[key]?.autoCommit;
|
|
392
|
+
const changeSetFile = path3.join(folder, "changeset.json");
|
|
393
|
+
const readSet = () => {
|
|
394
|
+
try {
|
|
395
|
+
return JSON.parse(readFileSync2(changeSetFile, "utf8"));
|
|
396
|
+
} catch {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
let run2 = null;
|
|
401
|
+
let committing = false;
|
|
402
|
+
const say = (line) => console.log(`\x1B[36m[limno]\x1B[0m ${line}`);
|
|
403
|
+
const list = (files) => files.length <= 3 ? files.join(", ") : `${files.slice(0, 3).join(", ")} and ${files.length - 3} more`;
|
|
404
|
+
async function finishRun(agent) {
|
|
405
|
+
const startedAt = state.startedAt;
|
|
406
|
+
const files = state.files;
|
|
407
|
+
const set = readSet();
|
|
408
|
+
const ids = run2?.changeIds ?? [];
|
|
409
|
+
const changes = (set?.changes ?? []).filter((c) => ids.includes(c.id));
|
|
410
|
+
const rec = {
|
|
411
|
+
id: run2?.id ?? String(startedAt),
|
|
412
|
+
agent: NAMES[agent],
|
|
413
|
+
startedAt: startedAt ?? Date.now(),
|
|
414
|
+
endedAt: state.endedAt ?? Date.now(),
|
|
415
|
+
status: state.status === "done" ? "done" : state.status === "stopped" ? "stopped" : "failed",
|
|
416
|
+
files,
|
|
417
|
+
changeIds: ids,
|
|
418
|
+
summary: [...state.log].reverse().find((l) => l.kind === "message")?.text,
|
|
419
|
+
message: commitMessage(changes)
|
|
420
|
+
};
|
|
421
|
+
run2 = rec;
|
|
422
|
+
try {
|
|
423
|
+
saveRun(folder, rec);
|
|
424
|
+
} catch {
|
|
425
|
+
}
|
|
426
|
+
if (!files.length) {
|
|
427
|
+
if (rec.status === "done") say(`${NAMES[agent]} finished without editing any files.`);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const dirty = await uncommitted(root, files);
|
|
431
|
+
const git2 = dirty.length > 0 || await isGitRepo(root);
|
|
432
|
+
if (state.startedAt === startedAt) {
|
|
433
|
+
state.commit = { message: rec.message, uncommitted: dirty, git: git2 };
|
|
434
|
+
emit(true);
|
|
435
|
+
}
|
|
436
|
+
const n = files.length;
|
|
437
|
+
say(`${NAMES[agent]} edited ${n} ${n === 1 ? "file" : "files"}: ${list(files)}${dirty.length ? " (not committed yet)" : ""}`);
|
|
438
|
+
if (dirty.length) {
|
|
439
|
+
say(`Commit message: ${rec.message.split("\n")[0]}`);
|
|
440
|
+
if (autoCommitOn()) say("Auto-commit is on: Limno commits these files once every change is verified on the page.");
|
|
441
|
+
}
|
|
442
|
+
if (state.startedAt === startedAt) void changeSetChanged(set);
|
|
443
|
+
}
|
|
444
|
+
async function changeSetChanged(set) {
|
|
445
|
+
const commit = state.commit;
|
|
446
|
+
if (!set || !run2 || committing || !commit || commit.committed || !commit.uncommitted.length || !autoCommitOn()) return;
|
|
447
|
+
if (state.status !== "done") return;
|
|
448
|
+
const sent = set.changes.filter((c) => run2.changeIds.includes(c.id) && c.kind !== "note");
|
|
449
|
+
if (!sent.length) return;
|
|
450
|
+
if (sent.some((c) => c.status === "differs" || c.status === "failed")) {
|
|
451
|
+
if (!commit.note) {
|
|
452
|
+
commit.note = "A change doesn\u2019t match the preview yet.";
|
|
453
|
+
const off = describeChanges(sent.filter((c) => c.status === "differs" || c.status === "failed")).length || 1;
|
|
454
|
+
say(`Not auto-committing: ${off === 1 ? "1 change doesn\u2019t" : `${off} changes don\u2019t`} match the preview yet. Review in Limno.`);
|
|
455
|
+
emit(true);
|
|
456
|
+
}
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (!sent.every((c) => c.status === "verified")) return;
|
|
460
|
+
committing = true;
|
|
461
|
+
try {
|
|
462
|
+
const r = await commitFiles(root, state.files, run2.message);
|
|
463
|
+
if (r.ok) {
|
|
464
|
+
commit.committed = { sha: r.sha, auto: true };
|
|
465
|
+
commit.uncommitted = [];
|
|
466
|
+
commit.note = void 0;
|
|
467
|
+
run2.commit = { sha: r.sha, at: Date.now(), auto: true };
|
|
468
|
+
try {
|
|
469
|
+
saveRun(folder, run2);
|
|
470
|
+
} catch {
|
|
471
|
+
}
|
|
472
|
+
say(`Committed ${state.files.length === 1 ? "1 file" : `${state.files.length} files`} as ${r.sha}: ${run2.message.split("\n")[0]} (not pushed)`);
|
|
473
|
+
} else {
|
|
474
|
+
commit.note = `Not committed: ${r.error}`;
|
|
475
|
+
say(`Didn\u2019t auto-commit: ${r.error}`);
|
|
476
|
+
}
|
|
477
|
+
emit(true);
|
|
478
|
+
} finally {
|
|
479
|
+
committing = false;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
310
482
|
function limnoLine(tool, input = {}) {
|
|
311
483
|
if (tool.endsWith("get_changes")) return "Reading your changes";
|
|
312
484
|
if (tool.endsWith("update_status")) {
|
|
313
|
-
const
|
|
485
|
+
const ids = Array.isArray(input.ids) ? input.ids : [];
|
|
486
|
+
const sent = (readSet()?.changes ?? []).filter((c) => ids.includes(c.id));
|
|
487
|
+
const n = sent.length ? describeChanges(sent).length : ids.length;
|
|
314
488
|
const status = String(input.status ?? "");
|
|
315
489
|
return status === "implemented" ? `Marked ${n || ""} ${n === 1 ? "change" : "changes"} done`.replace(" ", " ") : status === "failed" ? "Reported a change it couldn\u2019t make" : "Working on your changes";
|
|
316
490
|
}
|
|
@@ -402,7 +576,7 @@ function createRunner(root, folder, onState) {
|
|
|
402
576
|
return resume ? ["exec", ...opts, "resume", resume, PROMPT] : ["exec", ...opts, PROMPT];
|
|
403
577
|
}
|
|
404
578
|
function launch(agent, resume) {
|
|
405
|
-
const proc =
|
|
579
|
+
const proc = spawn2(agent, args(agent, resume), { cwd: root, env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
|
|
406
580
|
child = proc;
|
|
407
581
|
let buf = "";
|
|
408
582
|
let stderr = "";
|
|
@@ -437,7 +611,11 @@ function createRunner(root, folder, onState) {
|
|
|
437
611
|
launch(agent);
|
|
438
612
|
return;
|
|
439
613
|
}
|
|
440
|
-
if (state.status === "stopped")
|
|
614
|
+
if (state.status === "stopped") {
|
|
615
|
+
emit(true);
|
|
616
|
+
void finishRun(agent);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
441
619
|
const failed = code !== 0 || !!state.error;
|
|
442
620
|
const tail = stderr.trim().split("\n").filter(Boolean).slice(-2).join(" ").slice(0, 300);
|
|
443
621
|
state = {
|
|
@@ -447,6 +625,7 @@ function createRunner(root, folder, onState) {
|
|
|
447
625
|
error: failed ? state.error ?? (tail || `${NAMES[agent]} exited${signal ? ` (${signal})` : ` with code ${code}`}`) : void 0
|
|
448
626
|
};
|
|
449
627
|
emit(true);
|
|
628
|
+
void finishRun(agent);
|
|
450
629
|
if (again && !failed) {
|
|
451
630
|
again = false;
|
|
452
631
|
void start();
|
|
@@ -465,16 +644,32 @@ function createRunner(root, folder, onState) {
|
|
|
465
644
|
if (!info?.installed) return { ok: false, error: `${NAMES[agent]} isn\u2019t installed on this computer.` };
|
|
466
645
|
if (!info.signedIn) return { ok: false, error: `${NAMES[agent]} isn\u2019t signed in. Run \u201C${SIGN_IN[agent]}\u201D in a terminal, then send again.` };
|
|
467
646
|
state = { status: "starting", agent, startedAt: Date.now(), endedAt: null, log: [], files: [] };
|
|
647
|
+
const queued = (readSet()?.changes ?? []).filter((c) => c.enabled && ["queued", "in_progress", "differs", "failed"].includes(c.status)).map((c) => c.id);
|
|
648
|
+
run2 = { id: String(state.startedAt), agent: NAMES[agent], startedAt: state.startedAt, endedAt: 0, status: "done", files: [], changeIds: queued, message: "" };
|
|
468
649
|
emit(true);
|
|
469
650
|
launch(agent, sessions()[agent]);
|
|
470
651
|
return { ok: true };
|
|
471
652
|
}
|
|
472
653
|
return {
|
|
473
654
|
async info(force) {
|
|
474
|
-
|
|
655
|
+
if (state.commit && !state.commit.committed && state.commit.uncommitted.length && !child) {
|
|
656
|
+
state.commit = { ...state.commit, uncommitted: await uncommitted(root, state.files) };
|
|
657
|
+
}
|
|
658
|
+
return { agents: await detectAgents(force), allowed: allowed(), autoCommit: autoCommitOn(), state };
|
|
659
|
+
},
|
|
660
|
+
setAutoCommit(on) {
|
|
661
|
+
const t = readTrust();
|
|
662
|
+
if (!t[key]) return;
|
|
663
|
+
t[key] = { ...t[key], autoCommit: on };
|
|
664
|
+
writeTrust(t);
|
|
665
|
+
if (on) void changeSetChanged(readSet());
|
|
666
|
+
},
|
|
667
|
+
changeSetChanged(set) {
|
|
668
|
+
void changeSetChanged(set);
|
|
475
669
|
},
|
|
476
670
|
allow(agent) {
|
|
477
|
-
|
|
671
|
+
const prev = readTrust()[key];
|
|
672
|
+
writeTrust({ ...readTrust(), [key]: { agent, at: Date.now(), autoCommit: prev?.autoCommit } });
|
|
478
673
|
},
|
|
479
674
|
forget() {
|
|
480
675
|
const t = readTrust();
|
|
@@ -498,11 +693,11 @@ function createRunner(root, folder, onState) {
|
|
|
498
693
|
}
|
|
499
694
|
|
|
500
695
|
// src/update.ts
|
|
501
|
-
import { spawn as
|
|
502
|
-
import { existsSync, mkdirSync as
|
|
696
|
+
import { spawn as spawn3 } from "child_process";
|
|
697
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
503
698
|
import { createRequire as createRequire2 } from "module";
|
|
504
699
|
import os2 from "os";
|
|
505
|
-
import
|
|
700
|
+
import path4 from "path";
|
|
506
701
|
var require2 = createRequire2(import.meta.url);
|
|
507
702
|
var TTL = 60 * 60 * 1e3;
|
|
508
703
|
function currentVersion() {
|
|
@@ -514,7 +709,7 @@ function currentVersion() {
|
|
|
514
709
|
}
|
|
515
710
|
function installedVersion(fallback) {
|
|
516
711
|
try {
|
|
517
|
-
return JSON.parse(
|
|
712
|
+
return JSON.parse(readFileSync3(require2.resolve("../package.json"), "utf8")).version;
|
|
518
713
|
} catch {
|
|
519
714
|
return fallback;
|
|
520
715
|
}
|
|
@@ -528,18 +723,18 @@ var newer = (a, b) => {
|
|
|
528
723
|
function packageManager(root) {
|
|
529
724
|
let dir = root;
|
|
530
725
|
for (; ; ) {
|
|
531
|
-
if (
|
|
532
|
-
if (
|
|
533
|
-
if (
|
|
534
|
-
if (
|
|
535
|
-
const up =
|
|
726
|
+
if (existsSync2(path4.join(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
727
|
+
if (existsSync2(path4.join(dir, "yarn.lock"))) return "yarn";
|
|
728
|
+
if (existsSync2(path4.join(dir, "bun.lockb")) || existsSync2(path4.join(dir, "bun.lock"))) return "bun";
|
|
729
|
+
if (existsSync2(path4.join(dir, "package-lock.json"))) return "npm";
|
|
730
|
+
const up = path4.dirname(dir);
|
|
536
731
|
if (up === dir) return "npm";
|
|
537
732
|
dir = up;
|
|
538
733
|
}
|
|
539
734
|
}
|
|
540
735
|
function listedPackages(root) {
|
|
541
736
|
try {
|
|
542
|
-
const pkg = JSON.parse(
|
|
737
|
+
const pkg = JSON.parse(readFileSync3(path4.join(root, "package.json"), "utf8"));
|
|
543
738
|
return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((n) => n.startsWith("@limno/") && n !== "@limno/cli");
|
|
544
739
|
} catch {
|
|
545
740
|
return [];
|
|
@@ -552,10 +747,10 @@ function installArgs(pm, pkgs) {
|
|
|
552
747
|
if (pm === "bun") return ["bun", ["add", "-d", ...at]];
|
|
553
748
|
return ["npm", ["install", "-D", ...at]];
|
|
554
749
|
}
|
|
555
|
-
var cacheFile = () =>
|
|
750
|
+
var cacheFile = () => path4.join(os2.homedir(), ".limno", "update-check.json");
|
|
556
751
|
async function latestVersion() {
|
|
557
752
|
try {
|
|
558
|
-
const cached = JSON.parse(
|
|
753
|
+
const cached = JSON.parse(readFileSync3(cacheFile(), "utf8"));
|
|
559
754
|
if (Date.now() - cached.at < TTL) return cached.latest;
|
|
560
755
|
} catch {
|
|
561
756
|
}
|
|
@@ -564,8 +759,8 @@ async function latestVersion() {
|
|
|
564
759
|
if (!res.ok) return null;
|
|
565
760
|
const latest = (await res.json()).latest ?? null;
|
|
566
761
|
if (latest) {
|
|
567
|
-
|
|
568
|
-
|
|
762
|
+
mkdirSync3(path4.dirname(cacheFile()), { recursive: true });
|
|
763
|
+
writeFileSync3(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
|
|
569
764
|
}
|
|
570
765
|
return latest;
|
|
571
766
|
} catch {
|
|
@@ -592,14 +787,14 @@ function createUpdater(root) {
|
|
|
592
787
|
const [cmd, args] = installArgs(packageManager(root), pkgs);
|
|
593
788
|
installing = new Promise((resolve) => {
|
|
594
789
|
let output = "";
|
|
595
|
-
const child =
|
|
790
|
+
const child = spawn3(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
|
|
596
791
|
child.stdout?.on("data", (d) => output += d);
|
|
597
792
|
child.stderr?.on("data", (d) => output += d);
|
|
598
793
|
child.on("error", (e) => resolve({ ok: false, output: String(e) }));
|
|
599
794
|
child.on("close", (code) => {
|
|
600
795
|
if (code === 0) {
|
|
601
796
|
try {
|
|
602
|
-
|
|
797
|
+
writeFileSync3(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
|
|
603
798
|
} catch {
|
|
604
799
|
}
|
|
605
800
|
}
|
|
@@ -619,16 +814,16 @@ var require3 = createRequire3(import.meta.url);
|
|
|
619
814
|
var STALE_MS = 3e4;
|
|
620
815
|
function readJson(file) {
|
|
621
816
|
try {
|
|
622
|
-
return JSON.parse(
|
|
817
|
+
return JSON.parse(readFileSync4(file, "utf8"));
|
|
623
818
|
} catch {
|
|
624
819
|
return null;
|
|
625
820
|
}
|
|
626
821
|
}
|
|
627
822
|
function writeAtomic(file, data) {
|
|
628
|
-
|
|
823
|
+
mkdirSync4(path5.dirname(file), { recursive: true });
|
|
629
824
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
630
|
-
|
|
631
|
-
|
|
825
|
+
writeFileSync4(tmp, data);
|
|
826
|
+
renameSync2(tmp, file);
|
|
632
827
|
}
|
|
633
828
|
function body(req) {
|
|
634
829
|
return new Promise((resolve, reject) => {
|
|
@@ -649,9 +844,9 @@ function overlayFile() {
|
|
|
649
844
|
return require3.resolve("@limno/overlay/dist/overlay.js");
|
|
650
845
|
}
|
|
651
846
|
function createLimnoService({ root, dir = ".limno" }) {
|
|
652
|
-
const folder =
|
|
653
|
-
const file =
|
|
654
|
-
const presenceFile =
|
|
847
|
+
const folder = path5.join(root, dir);
|
|
848
|
+
const file = path5.join(folder, "changeset.json");
|
|
849
|
+
const presenceFile = path5.join(folder, "agent.json");
|
|
655
850
|
let lastWrittenHash = "";
|
|
656
851
|
const clients = /* @__PURE__ */ new Set();
|
|
657
852
|
function broadcast(event, data) {
|
|
@@ -680,23 +875,23 @@ data: ${JSON.stringify(data)}
|
|
|
680
875
|
return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
|
|
681
876
|
}
|
|
682
877
|
function installConfig(target) {
|
|
683
|
-
const dest = target === "claude" ?
|
|
878
|
+
const dest = target === "claude" ? path5.join(root, ".mcp.json") : path5.join(root, ".cursor", "mcp.json");
|
|
684
879
|
let config = {};
|
|
685
|
-
if (
|
|
880
|
+
if (existsSync3(dest)) {
|
|
686
881
|
try {
|
|
687
|
-
config = JSON.parse(
|
|
882
|
+
config = JSON.parse(readFileSync4(dest, "utf8"));
|
|
688
883
|
} catch {
|
|
689
|
-
throw new Error(`${
|
|
884
|
+
throw new Error(`${path5.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
|
|
690
885
|
}
|
|
691
886
|
}
|
|
692
887
|
const { command, args } = setupInfo();
|
|
693
888
|
const portable = args.map((a, i) => {
|
|
694
889
|
if (args[i - 1] === "--root") return ".";
|
|
695
|
-
return
|
|
890
|
+
return path5.isAbsolute(a) ? path5.relative(root, a).split(path5.sep).join("/") : a;
|
|
696
891
|
});
|
|
697
892
|
config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
|
|
698
893
|
writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
|
|
699
|
-
return
|
|
894
|
+
return path5.relative(root, dest);
|
|
700
895
|
}
|
|
701
896
|
let lastAgent = "";
|
|
702
897
|
function pushAgent() {
|
|
@@ -707,12 +902,13 @@ data: ${JSON.stringify(data)}
|
|
|
707
902
|
broadcast("agent", status);
|
|
708
903
|
}
|
|
709
904
|
function onChangeSetFile() {
|
|
710
|
-
const raw =
|
|
905
|
+
const raw = existsSync3(file) ? readFileSync4(file, "utf8") : "";
|
|
711
906
|
if (!raw || hash(raw) === lastWrittenHash) return;
|
|
712
907
|
const set = readJson(file);
|
|
713
908
|
if (set) broadcast("changeset", set);
|
|
909
|
+
runner.changeSetChanged(set);
|
|
714
910
|
}
|
|
715
|
-
|
|
911
|
+
mkdirSync4(folder, { recursive: true });
|
|
716
912
|
let watcher = null;
|
|
717
913
|
let watchedIno = -1;
|
|
718
914
|
let debounce;
|
|
@@ -754,8 +950,8 @@ data: ${JSON.stringify(data)}
|
|
|
754
950
|
async function openInEditor(spec) {
|
|
755
951
|
const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
|
|
756
952
|
if (!m) return false;
|
|
757
|
-
const target =
|
|
758
|
-
if (!target.startsWith(root +
|
|
953
|
+
const target = path5.resolve(root, m[1]);
|
|
954
|
+
if (!target.startsWith(root + path5.sep) || !existsSync3(target)) return false;
|
|
759
955
|
const { default: launch } = await import("launch-editor");
|
|
760
956
|
launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
|
|
761
957
|
return true;
|
|
@@ -765,7 +961,7 @@ data: ${JSON.stringify(data)}
|
|
|
765
961
|
if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
|
|
766
962
|
try {
|
|
767
963
|
if (url === `${ROUTE}/overlay.js`) {
|
|
768
|
-
return send(res, 200,
|
|
964
|
+
return send(res, 200, readFileSync4(overlayFile(), "utf8"), "text/javascript");
|
|
769
965
|
}
|
|
770
966
|
if (url === `${ROUTE}/events`) {
|
|
771
967
|
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
|
|
@@ -784,6 +980,7 @@ data: ${JSON.stringify(data)}
|
|
|
784
980
|
const raw = JSON.stringify(merged, null, 2);
|
|
785
981
|
lastWrittenHash = hash(raw);
|
|
786
982
|
writeAtomic(file, raw);
|
|
983
|
+
runner.changeSetChanged(merged);
|
|
787
984
|
return send(res, 200, merged);
|
|
788
985
|
}
|
|
789
986
|
if (url === `${ROUTE}/prompt` && req.method === "GET") {
|
|
@@ -825,6 +1022,11 @@ data: ${JSON.stringify(data)}
|
|
|
825
1022
|
runner.forget();
|
|
826
1023
|
return send(res, 200, await runner.info());
|
|
827
1024
|
}
|
|
1025
|
+
if (action === "autocommit") {
|
|
1026
|
+
const { on } = JSON.parse(await body(req));
|
|
1027
|
+
runner.setAutoCommit(!!on);
|
|
1028
|
+
return send(res, 200, await runner.info());
|
|
1029
|
+
}
|
|
828
1030
|
if (action === "run") return send(res, 200, await runner.start());
|
|
829
1031
|
if (action === "stop") {
|
|
830
1032
|
runner.stop();
|
|
@@ -836,7 +1038,7 @@ data: ${JSON.stringify(data)}
|
|
|
836
1038
|
return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
|
|
837
1039
|
}
|
|
838
1040
|
if (url === `${ROUTE}/info`) {
|
|
839
|
-
return send(res, 200, { name: "limno", root, file:
|
|
1041
|
+
return send(res, 200, { name: "limno", root, file: path5.relative(root, file) });
|
|
840
1042
|
}
|
|
841
1043
|
send(res, 404, { error: "not found" });
|
|
842
1044
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@limno/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Limno's framework-neutral dev tooling: JSX source stamping, the change-set store and the endpoints the editor talks to.",
|
|
5
5
|
"license": "FSL-1.1-MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@babel/parser": "^7.29.0",
|
|
44
44
|
"@babel/traverse": "^7.29.0",
|
|
45
|
-
"@limno/mcp": "0.1.
|
|
46
|
-
"@limno/overlay": "0.1.
|
|
47
|
-
"@limno/spec": "0.1.
|
|
45
|
+
"@limno/mcp": "0.1.4",
|
|
46
|
+
"@limno/overlay": "0.1.10",
|
|
47
|
+
"@limno/spec": "0.1.4",
|
|
48
48
|
"launch-editor": "^2.14.1",
|
|
49
49
|
"magic-string": "^0.30.17"
|
|
50
50
|
},
|