@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 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(path5, fileBase) {
63
- let p = path5.getFunctionParent();
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(path5) {
106
- const node = path5.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(path5, fileBase);
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 import_node_fs3 = require("fs");
226
+ var import_node_fs4 = require("fs");
227
227
  var import_node_module3 = require("module");
228
- var import_node_path4 = __toESM(require("path"), 1);
229
- var import_spec = require("@limno/spec");
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 = () => import_node_path2.default.join(import_node_os.default.homedir(), ".limno", "trust.json");
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, import_node_fs.realpathSync)(root);
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, import_node_fs.readFileSync)(trustFile(), "utf8")).projects ?? {};
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, import_node_fs.mkdirSync)(import_node_path2.default.dirname(trustFile()), { recursive: true });
262
- (0, import_node_fs.writeFileSync)(trustFile(), `${JSON.stringify({ projects }, null, 2)}
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, import_node_child_process.spawn)(cmd, args, { env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
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 = import_node_path2.default.join(folder, "runner.json");
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, import_node_fs.readFileSync)(sessionsFile, "utf8"));
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, import_node_fs.mkdirSync)(folder, { recursive: true });
336
- (0, import_node_fs.writeFileSync)(sessionsFile, `${JSON.stringify({ ...sessions(), [agent]: id }, null, 2)}
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 = import_node_path2.default.isAbsolute(p) ? p : import_node_path2.default.join(root, p);
355
- return import_node_path2.default.relative(root, abs) || p;
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 n = Array.isArray(input.ids) ? input.ids.length : 0;
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, import_node_child_process.spawn)(agent, args(agent, resume), { cwd: root, env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
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") return emit(true);
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
- return { agents: await detectAgents(force), allowed: allowed(), state };
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
- writeTrust({ ...readTrust(), [key]: { agent, at: Date.now() } });
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 import_node_child_process2 = require("child_process");
551
- var import_node_fs2 = require("fs");
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 import_node_path3 = __toESM(require("path"), 1);
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, import_node_fs2.readFileSync)(require2.resolve("../package.json"), "utf8")).version;
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, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "pnpm-lock.yaml"))) return "pnpm";
581
- if ((0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "yarn.lock"))) return "yarn";
582
- if ((0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "bun.lockb")) || (0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "bun.lock"))) return "bun";
583
- if ((0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "package-lock.json"))) return "npm";
584
- const up = import_node_path3.default.dirname(dir);
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, import_node_fs2.readFileSync)(import_node_path3.default.join(root, "package.json"), "utf8"));
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 = () => import_node_path3.default.join(import_node_os2.default.homedir(), ".limno", "update-check.json");
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, import_node_fs2.readFileSync)(cacheFile(), "utf8"));
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, import_node_fs2.mkdirSync)(import_node_path3.default.dirname(cacheFile()), { recursive: true });
617
- (0, import_node_fs2.writeFileSync)(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
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, import_node_child_process2.spawn)(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
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, import_node_fs2.writeFileSync)(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
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, import_node_fs3.readFileSync)(file, "utf8"));
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, import_node_fs3.mkdirSync)(import_node_path4.default.dirname(file), { recursive: true });
872
+ (0, import_node_fs4.mkdirSync)(import_node_path5.default.dirname(file), { recursive: true });
678
873
  const tmp = `${file}.${process.pid}.tmp`;
679
- (0, import_node_fs3.writeFileSync)(tmp, data);
680
- (0, import_node_fs3.renameSync)(tmp, file);
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 = import_node_path4.default.join(root, dir);
702
- const file = import_node_path4.default.join(folder, "changeset.json");
703
- const presenceFile = import_node_path4.default.join(folder, "agent.json");
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" ? import_node_path4.default.join(root, ".mcp.json") : import_node_path4.default.join(root, ".cursor", "mcp.json");
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, import_node_fs3.existsSync)(dest)) {
929
+ if ((0, import_node_fs4.existsSync)(dest)) {
735
930
  try {
736
- config = JSON.parse((0, import_node_fs3.readFileSync)(dest, "utf8"));
931
+ config = JSON.parse((0, import_node_fs4.readFileSync)(dest, "utf8"));
737
932
  } catch {
738
- throw new Error(`${import_node_path4.default.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
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 import_node_path4.default.isAbsolute(a) ? import_node_path4.default.relative(root, a).split(import_node_path4.default.sep).join("/") : a;
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 import_node_path4.default.relative(root, dest);
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, import_node_fs3.existsSync)(file) ? (0, import_node_fs3.readFileSync)(file, "utf8") : "";
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, import_node_fs3.mkdirSync)(folder, { recursive: true });
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, import_node_fs3.statSync)(folder).ino;
773
- watcher = (0, import_node_fs3.watch)(folder, (_event, name) => {
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, import_node_fs3.statSync)(folder).ino;
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 = import_node_path4.default.resolve(root, m[1]);
807
- if (!target.startsWith(root + import_node_path4.default.sep) || !(0, import_node_fs3.existsSync)(target)) return false;
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, import_node_fs3.readFileSync)(overlayFile(), "utf8"), "text/javascript");
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, import_spec.emptyChangeSet)());
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, import_spec.mergeStatuses)(incoming, readJson(file));
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, import_spec.emptyChangeSet)();
840
- return send(res, 200, (0, import_spec.renderPrompt)(set, { mcp: false, statuses: ["draft", "queued", "in_progress", "differs", "failed"] }), "text/markdown; charset=utf-8");
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: import_node_path4.default.relative(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(path5, fileBase) {
14
- let p = path5.getFunctionParent();
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(path5) {
57
- const node = path5.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(path5, fileBase);
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 existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, renameSync, statSync, watch, writeFileSync as writeFileSync3 } from "fs";
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 path4 from "path";
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 = () => path2.join(os.homedir(), ".limno", "trust.json");
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(readFileSync(trustFile(), "utf8")).projects ?? {};
287
+ return JSON.parse(readFileSync2(trustFile(), "utf8")).projects ?? {};
207
288
  } catch {
208
289
  return {};
209
290
  }
210
291
  }
211
292
  function writeTrust(projects) {
212
- mkdirSync(path2.dirname(trustFile()), { recursive: true });
213
- writeFileSync(trustFile(), `${JSON.stringify({ projects }, null, 2)}
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 = spawn(cmd, args, { env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
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 = path2.join(folder, "runner.json");
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(readFileSync(sessionsFile, "utf8"));
361
+ return JSON.parse(readFileSync2(sessionsFile, "utf8"));
281
362
  } catch {
282
363
  return {};
283
364
  }
284
365
  };
285
366
  const saveSession = (agent, id) => {
286
- mkdirSync(folder, { recursive: true });
287
- writeFileSync(sessionsFile, `${JSON.stringify({ ...sessions(), [agent]: id }, null, 2)}
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 = path2.isAbsolute(p) ? p : path2.join(root, p);
306
- return path2.relative(root, abs) || p;
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 n = Array.isArray(input.ids) ? input.ids.length : 0;
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 = spawn(agent, args(agent, resume), { cwd: root, env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
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") return emit(true);
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
- return { agents: await detectAgents(force), allowed: allowed(), state };
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
- writeTrust({ ...readTrust(), [key]: { agent, at: Date.now() } });
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 spawn2 } from "child_process";
502
- import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
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 path3 from "path";
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(readFileSync2(require2.resolve("../package.json"), "utf8")).version;
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 (existsSync(path3.join(dir, "pnpm-lock.yaml"))) return "pnpm";
532
- if (existsSync(path3.join(dir, "yarn.lock"))) return "yarn";
533
- if (existsSync(path3.join(dir, "bun.lockb")) || existsSync(path3.join(dir, "bun.lock"))) return "bun";
534
- if (existsSync(path3.join(dir, "package-lock.json"))) return "npm";
535
- const up = path3.dirname(dir);
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(readFileSync2(path3.join(root, "package.json"), "utf8"));
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 = () => path3.join(os2.homedir(), ".limno", "update-check.json");
750
+ var cacheFile = () => path4.join(os2.homedir(), ".limno", "update-check.json");
556
751
  async function latestVersion() {
557
752
  try {
558
- const cached = JSON.parse(readFileSync2(cacheFile(), "utf8"));
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
- mkdirSync2(path3.dirname(cacheFile()), { recursive: true });
568
- writeFileSync2(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
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 = spawn2(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
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
- writeFileSync2(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
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(readFileSync3(file, "utf8"));
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
- mkdirSync3(path4.dirname(file), { recursive: true });
823
+ mkdirSync4(path5.dirname(file), { recursive: true });
629
824
  const tmp = `${file}.${process.pid}.tmp`;
630
- writeFileSync3(tmp, data);
631
- renameSync(tmp, file);
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 = path4.join(root, dir);
653
- const file = path4.join(folder, "changeset.json");
654
- const presenceFile = path4.join(folder, "agent.json");
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" ? path4.join(root, ".mcp.json") : path4.join(root, ".cursor", "mcp.json");
878
+ const dest = target === "claude" ? path5.join(root, ".mcp.json") : path5.join(root, ".cursor", "mcp.json");
684
879
  let config = {};
685
- if (existsSync2(dest)) {
880
+ if (existsSync3(dest)) {
686
881
  try {
687
- config = JSON.parse(readFileSync3(dest, "utf8"));
882
+ config = JSON.parse(readFileSync4(dest, "utf8"));
688
883
  } catch {
689
- throw new Error(`${path4.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
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 path4.isAbsolute(a) ? path4.relative(root, a).split(path4.sep).join("/") : a;
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 path4.relative(root, dest);
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 = existsSync2(file) ? readFileSync3(file, "utf8") : "";
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
- mkdirSync3(folder, { recursive: true });
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 = path4.resolve(root, m[1]);
758
- if (!target.startsWith(root + path4.sep) || !existsSync2(target)) return false;
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, readFileSync3(overlayFile(), "utf8"), "text/javascript");
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: path4.relative(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.8",
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.3",
46
- "@limno/overlay": "0.1.8",
47
- "@limno/spec": "0.1.3",
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
  },