@limno/core 0.1.9 → 0.1.11

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,101 @@ 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
317
+ var fullMessage = (text) => {
318
+ const t = String(text).trim().replace(/\n{3,}/g, "\n\n");
319
+ return t.length > 4e3 ? `${t.slice(0, 3999).trimEnd()}\u2026` : t;
320
+ };
236
321
  var NAMES = { claude: "Claude Code", codex: "Codex" };
237
322
  var SIGN_IN = { claude: "claude auth login", codex: "codex login" };
238
323
  var PROMPT = [
@@ -240,26 +325,28 @@ var PROMPT = [
240
325
  "Call limno_get_changes to read the visual changes and comments waiting for you, then implement them in this project's source by following the rules it gives you.",
241
326
  "Report progress with limno_update_status, and answer comments with limno_reply.",
242
327
  "Only read and edit files inside this project. Don't run shell commands; Limno checks the result on the page itself.",
243
- "Finish with one short sentence summarising what you changed."
328
+ "Finish with a summary the designer reads in Limno, at most two short sentences and 45 words in total:",
329
+ "first what you changed (or why you changed nothing); then, only if needed, what they must know or decide, as a direct question when it is one.",
330
+ "No file paths, no lists, nothing they already know from the change itself."
244
331
  ].join(" ");
245
- var trustFile = () => import_node_path2.default.join(import_node_os.default.homedir(), ".limno", "trust.json");
332
+ var trustFile = () => import_node_path3.default.join(import_node_os.default.homedir(), ".limno", "trust.json");
246
333
  function projectKey(root) {
247
334
  try {
248
- return (0, import_node_fs.realpathSync)(root);
335
+ return (0, import_node_fs2.realpathSync)(root);
249
336
  } catch {
250
337
  return root;
251
338
  }
252
339
  }
253
340
  function readTrust() {
254
341
  try {
255
- return JSON.parse((0, import_node_fs.readFileSync)(trustFile(), "utf8")).projects ?? {};
342
+ return JSON.parse((0, import_node_fs2.readFileSync)(trustFile(), "utf8")).projects ?? {};
256
343
  } catch {
257
344
  return {};
258
345
  }
259
346
  }
260
347
  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)}
348
+ (0, import_node_fs2.mkdirSync)(import_node_path3.default.dirname(trustFile()), { recursive: true });
349
+ (0, import_node_fs2.writeFileSync)(trustFile(), `${JSON.stringify({ projects }, null, 2)}
263
350
  `);
264
351
  }
265
352
  function run(cmd, args, timeoutMs = 8e3) {
@@ -267,7 +354,7 @@ function run(cmd, args, timeoutMs = 8e3) {
267
354
  let out = "";
268
355
  let child;
269
356
  try {
270
- child = (0, import_node_child_process.spawn)(cmd, args, { env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
357
+ child = (0, import_node_child_process2.spawn)(cmd, args, { env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
271
358
  } catch {
272
359
  return resolve({ code: null, out: "" });
273
360
  }
@@ -320,20 +407,20 @@ function isLocalRequest(req) {
320
407
  return true;
321
408
  }
322
409
  function createRunner(root, folder, onState) {
323
- const sessionsFile = import_node_path2.default.join(folder, "runner.json");
410
+ const sessionsFile = import_node_path3.default.join(folder, "runner.json");
324
411
  let child = null;
325
412
  let again = false;
326
413
  let state = { status: "idle", agent: null, startedAt: null, endedAt: null, log: [], files: [] };
327
414
  const sessions = () => {
328
415
  try {
329
- return JSON.parse((0, import_node_fs.readFileSync)(sessionsFile, "utf8"));
416
+ return JSON.parse((0, import_node_fs2.readFileSync)(sessionsFile, "utf8"));
330
417
  } catch {
331
418
  return {};
332
419
  }
333
420
  };
334
421
  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)}
422
+ (0, import_node_fs2.mkdirSync)(folder, { recursive: true });
423
+ (0, import_node_fs2.writeFileSync)(sessionsFile, `${JSON.stringify({ ...sessions(), [agent]: id }, null, 2)}
337
424
  `);
338
425
  };
339
426
  let emitTimer;
@@ -351,15 +438,108 @@ function createRunner(root, folder, onState) {
351
438
  };
352
439
  const rel = (p) => {
353
440
  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;
441
+ const abs = import_node_path3.default.isAbsolute(p) ? p : import_node_path3.default.join(root, p);
442
+ return import_node_path3.default.relative(root, abs) || p;
356
443
  };
357
444
  const key = projectKey(root);
358
445
  const allowed = () => readTrust()[key]?.agent ?? null;
446
+ const autoCommitOn = () => !!readTrust()[key]?.autoCommit;
447
+ const changeSetFile = import_node_path3.default.join(folder, "changeset.json");
448
+ const readSet = () => {
449
+ try {
450
+ return JSON.parse((0, import_node_fs2.readFileSync)(changeSetFile, "utf8"));
451
+ } catch {
452
+ return null;
453
+ }
454
+ };
455
+ let run2 = null;
456
+ let committing = false;
457
+ const say = (line) => console.log(`\x1B[36m[limno]\x1B[0m ${line}`);
458
+ const list = (files) => files.length <= 3 ? files.join(", ") : `${files.slice(0, 3).join(", ")} and ${files.length - 3} more`;
459
+ async function finishRun(agent) {
460
+ const startedAt = state.startedAt;
461
+ const files = state.files;
462
+ const set = readSet();
463
+ const ids = run2?.changeIds ?? [];
464
+ const changes = (set?.changes ?? []).filter((c) => ids.includes(c.id));
465
+ const rec = {
466
+ id: run2?.id ?? String(startedAt),
467
+ agent: NAMES[agent],
468
+ startedAt: startedAt ?? Date.now(),
469
+ endedAt: state.endedAt ?? Date.now(),
470
+ status: state.status === "done" ? "done" : state.status === "stopped" ? "stopped" : "failed",
471
+ files,
472
+ changeIds: ids,
473
+ summary: [...state.log].reverse().find((l) => l.kind === "message")?.text,
474
+ message: (0, import_spec.commitMessage)(changes)
475
+ };
476
+ run2 = rec;
477
+ try {
478
+ saveRun(folder, rec);
479
+ } catch {
480
+ }
481
+ if (!files.length) {
482
+ if (rec.status === "done") say(`${NAMES[agent]} finished without editing any files.`);
483
+ return;
484
+ }
485
+ const dirty = await uncommitted(root, files);
486
+ const git2 = dirty.length > 0 || await isGitRepo(root);
487
+ if (state.startedAt === startedAt) {
488
+ state.commit = { message: rec.message, uncommitted: dirty, git: git2 };
489
+ emit(true);
490
+ }
491
+ const n = files.length;
492
+ say(`${NAMES[agent]} edited ${n} ${n === 1 ? "file" : "files"}: ${list(files)}${dirty.length ? " (not committed yet)" : ""}`);
493
+ if (dirty.length) {
494
+ say(`Commit message: ${rec.message.split("\n")[0]}`);
495
+ if (autoCommitOn()) say("Auto-commit is on: Limno commits these files once every change is verified on the page.");
496
+ }
497
+ if (state.startedAt === startedAt) void changeSetChanged(set);
498
+ }
499
+ async function changeSetChanged(set) {
500
+ const commit = state.commit;
501
+ if (!set || !run2 || committing || !commit || commit.committed || !commit.uncommitted.length || !autoCommitOn()) return;
502
+ if (state.status !== "done") return;
503
+ const sent = set.changes.filter((c) => run2.changeIds.includes(c.id) && c.kind !== "note");
504
+ if (!sent.length) return;
505
+ if (sent.some((c) => c.status === "differs" || c.status === "failed")) {
506
+ if (!commit.note) {
507
+ commit.note = "A change doesn\u2019t match the preview yet.";
508
+ const off = (0, import_spec.describeChanges)(sent.filter((c) => c.status === "differs" || c.status === "failed")).length || 1;
509
+ say(`Not auto-committing: ${off === 1 ? "1 change doesn\u2019t" : `${off} changes don\u2019t`} match the preview yet. Review in Limno.`);
510
+ emit(true);
511
+ }
512
+ return;
513
+ }
514
+ if (!sent.every((c) => c.status === "verified")) return;
515
+ committing = true;
516
+ try {
517
+ const r = await commitFiles(root, state.files, run2.message);
518
+ if (r.ok) {
519
+ commit.committed = { sha: r.sha, auto: true };
520
+ commit.uncommitted = [];
521
+ commit.note = void 0;
522
+ run2.commit = { sha: r.sha, at: Date.now(), auto: true };
523
+ try {
524
+ saveRun(folder, run2);
525
+ } catch {
526
+ }
527
+ say(`Committed ${state.files.length === 1 ? "1 file" : `${state.files.length} files`} as ${r.sha}: ${run2.message.split("\n")[0]} (not pushed)`);
528
+ } else {
529
+ commit.note = `Not committed: ${r.error}`;
530
+ say(`Didn\u2019t auto-commit: ${r.error}`);
531
+ }
532
+ emit(true);
533
+ } finally {
534
+ committing = false;
535
+ }
536
+ }
359
537
  function limnoLine(tool, input = {}) {
360
538
  if (tool.endsWith("get_changes")) return "Reading your changes";
361
539
  if (tool.endsWith("update_status")) {
362
- const n = Array.isArray(input.ids) ? input.ids.length : 0;
540
+ const ids = Array.isArray(input.ids) ? input.ids : [];
541
+ const sent = (readSet()?.changes ?? []).filter((c) => ids.includes(c.id));
542
+ const n = sent.length ? (0, import_spec.describeChanges)(sent).length : ids.length;
363
543
  const status = String(input.status ?? "");
364
544
  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
545
  }
@@ -383,7 +563,7 @@ function createRunner(root, folder, onState) {
383
563
  else if (name === "Edit" || name === "MultiEdit" || name === "Write") log({ kind: "edit", text: `Editing ${rel(input.file_path)}`, file: rel(input.file_path) });
384
564
  else if (name === "Glob" || name === "Grep") log({ kind: "search", text: "Searching the project" });
385
565
  } else if (block.type === "text" && String(block.text).trim()) {
386
- log({ kind: "message", text: String(block.text).trim().split("\n")[0].slice(0, 220) });
566
+ log({ kind: "message", text: fullMessage(block.text) });
387
567
  }
388
568
  }
389
569
  return;
@@ -404,7 +584,7 @@ function createRunner(root, folder, onState) {
404
584
  else if (item.type === "file_change" && e.type === "item.completed") {
405
585
  for (const c of item.changes ?? []) log({ kind: "edit", text: `Editing ${rel(c.path)}`, file: rel(c.path) });
406
586
  } else if (item.type === "agent_message" && e.type === "item.completed" && item.text) {
407
- log({ kind: "message", text: String(item.text).trim().split("\n")[0].slice(0, 220) });
587
+ log({ kind: "message", text: fullMessage(item.text) });
408
588
  } else if (item.type === "command_execution" && e.type === "item.started") {
409
589
  log({ kind: "search", text: "Looking through the project" });
410
590
  }
@@ -451,7 +631,7 @@ function createRunner(root, folder, onState) {
451
631
  return resume ? ["exec", ...opts, "resume", resume, PROMPT] : ["exec", ...opts, PROMPT];
452
632
  }
453
633
  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" });
634
+ 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
635
  child = proc;
456
636
  let buf = "";
457
637
  let stderr = "";
@@ -486,7 +666,11 @@ function createRunner(root, folder, onState) {
486
666
  launch(agent);
487
667
  return;
488
668
  }
489
- if (state.status === "stopped") return emit(true);
669
+ if (state.status === "stopped") {
670
+ emit(true);
671
+ void finishRun(agent);
672
+ return;
673
+ }
490
674
  const failed = code !== 0 || !!state.error;
491
675
  const tail = stderr.trim().split("\n").filter(Boolean).slice(-2).join(" ").slice(0, 300);
492
676
  state = {
@@ -496,6 +680,7 @@ function createRunner(root, folder, onState) {
496
680
  error: failed ? state.error ?? (tail || `${NAMES[agent]} exited${signal ? ` (${signal})` : ` with code ${code}`}`) : void 0
497
681
  };
498
682
  emit(true);
683
+ void finishRun(agent);
499
684
  if (again && !failed) {
500
685
  again = false;
501
686
  void start();
@@ -514,16 +699,32 @@ function createRunner(root, folder, onState) {
514
699
  if (!info?.installed) return { ok: false, error: `${NAMES[agent]} isn\u2019t installed on this computer.` };
515
700
  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
701
  state = { status: "starting", agent, startedAt: Date.now(), endedAt: null, log: [], files: [] };
702
+ const queued = (readSet()?.changes ?? []).filter((c) => c.enabled && ["queued", "in_progress", "differs", "failed"].includes(c.status)).map((c) => c.id);
703
+ run2 = { id: String(state.startedAt), agent: NAMES[agent], startedAt: state.startedAt, endedAt: 0, status: "done", files: [], changeIds: queued, message: "" };
517
704
  emit(true);
518
705
  launch(agent, sessions()[agent]);
519
706
  return { ok: true };
520
707
  }
521
708
  return {
522
709
  async info(force) {
523
- return { agents: await detectAgents(force), allowed: allowed(), state };
710
+ if (state.commit && !state.commit.committed && state.commit.uncommitted.length && !child) {
711
+ state.commit = { ...state.commit, uncommitted: await uncommitted(root, state.files) };
712
+ }
713
+ return { agents: await detectAgents(force), allowed: allowed(), autoCommit: autoCommitOn(), state };
714
+ },
715
+ setAutoCommit(on) {
716
+ const t = readTrust();
717
+ if (!t[key]) return;
718
+ t[key] = { ...t[key], autoCommit: on };
719
+ writeTrust(t);
720
+ if (on) void changeSetChanged(readSet());
721
+ },
722
+ changeSetChanged(set) {
723
+ void changeSetChanged(set);
524
724
  },
525
725
  allow(agent) {
526
- writeTrust({ ...readTrust(), [key]: { agent, at: Date.now() } });
726
+ const prev = readTrust()[key];
727
+ writeTrust({ ...readTrust(), [key]: { agent, at: Date.now(), autoCommit: prev?.autoCommit } });
527
728
  },
528
729
  forget() {
529
730
  const t = readTrust();
@@ -547,11 +748,11 @@ function createRunner(root, folder, onState) {
547
748
  }
548
749
 
549
750
  // src/update.ts
550
- var import_node_child_process2 = require("child_process");
551
- var import_node_fs2 = require("fs");
751
+ var import_node_child_process3 = require("child_process");
752
+ var import_node_fs3 = require("fs");
552
753
  var import_node_module2 = require("module");
553
754
  var import_node_os2 = __toESM(require("os"), 1);
554
- var import_node_path3 = __toESM(require("path"), 1);
755
+ var import_node_path4 = __toESM(require("path"), 1);
555
756
  var require2 = (0, import_node_module2.createRequire)(importMetaUrl);
556
757
  var TTL = 60 * 60 * 1e3;
557
758
  function currentVersion() {
@@ -563,7 +764,7 @@ function currentVersion() {
563
764
  }
564
765
  function installedVersion(fallback) {
565
766
  try {
566
- return JSON.parse((0, import_node_fs2.readFileSync)(require2.resolve("../package.json"), "utf8")).version;
767
+ return JSON.parse((0, import_node_fs3.readFileSync)(require2.resolve("../package.json"), "utf8")).version;
567
768
  } catch {
568
769
  return fallback;
569
770
  }
@@ -577,18 +778,18 @@ var newer = (a, b) => {
577
778
  function packageManager(root) {
578
779
  let dir = root;
579
780
  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);
781
+ if ((0, import_node_fs3.existsSync)(import_node_path4.default.join(dir, "pnpm-lock.yaml"))) return "pnpm";
782
+ if ((0, import_node_fs3.existsSync)(import_node_path4.default.join(dir, "yarn.lock"))) return "yarn";
783
+ 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";
784
+ if ((0, import_node_fs3.existsSync)(import_node_path4.default.join(dir, "package-lock.json"))) return "npm";
785
+ const up = import_node_path4.default.dirname(dir);
585
786
  if (up === dir) return "npm";
586
787
  dir = up;
587
788
  }
588
789
  }
589
790
  function listedPackages(root) {
590
791
  try {
591
- const pkg = JSON.parse((0, import_node_fs2.readFileSync)(import_node_path3.default.join(root, "package.json"), "utf8"));
792
+ const pkg = JSON.parse((0, import_node_fs3.readFileSync)(import_node_path4.default.join(root, "package.json"), "utf8"));
592
793
  return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((n) => n.startsWith("@limno/") && n !== "@limno/cli");
593
794
  } catch {
594
795
  return [];
@@ -601,10 +802,10 @@ function installArgs(pm, pkgs) {
601
802
  if (pm === "bun") return ["bun", ["add", "-d", ...at]];
602
803
  return ["npm", ["install", "-D", ...at]];
603
804
  }
604
- var cacheFile = () => import_node_path3.default.join(import_node_os2.default.homedir(), ".limno", "update-check.json");
805
+ var cacheFile = () => import_node_path4.default.join(import_node_os2.default.homedir(), ".limno", "update-check.json");
605
806
  async function latestVersion() {
606
807
  try {
607
- const cached = JSON.parse((0, import_node_fs2.readFileSync)(cacheFile(), "utf8"));
808
+ const cached = JSON.parse((0, import_node_fs3.readFileSync)(cacheFile(), "utf8"));
608
809
  if (Date.now() - cached.at < TTL) return cached.latest;
609
810
  } catch {
610
811
  }
@@ -613,8 +814,8 @@ async function latestVersion() {
613
814
  if (!res.ok) return null;
614
815
  const latest = (await res.json()).latest ?? null;
615
816
  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 }));
817
+ (0, import_node_fs3.mkdirSync)(import_node_path4.default.dirname(cacheFile()), { recursive: true });
818
+ (0, import_node_fs3.writeFileSync)(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
618
819
  }
619
820
  return latest;
620
821
  } catch {
@@ -641,14 +842,14 @@ function createUpdater(root) {
641
842
  const [cmd, args] = installArgs(packageManager(root), pkgs);
642
843
  installing = new Promise((resolve) => {
643
844
  let output = "";
644
- const child = (0, import_node_child_process2.spawn)(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
845
+ const child = (0, import_node_child_process3.spawn)(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
645
846
  child.stdout?.on("data", (d) => output += d);
646
847
  child.stderr?.on("data", (d) => output += d);
647
848
  child.on("error", (e) => resolve({ ok: false, output: String(e) }));
648
849
  child.on("close", (code) => {
649
850
  if (code === 0) {
650
851
  try {
651
- (0, import_node_fs2.writeFileSync)(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
852
+ (0, import_node_fs3.writeFileSync)(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
652
853
  } catch {
653
854
  }
654
855
  }
@@ -668,16 +869,16 @@ var require3 = (0, import_node_module3.createRequire)(importMetaUrl);
668
869
  var STALE_MS = 3e4;
669
870
  function readJson(file) {
670
871
  try {
671
- return JSON.parse((0, import_node_fs3.readFileSync)(file, "utf8"));
872
+ return JSON.parse((0, import_node_fs4.readFileSync)(file, "utf8"));
672
873
  } catch {
673
874
  return null;
674
875
  }
675
876
  }
676
877
  function writeAtomic(file, data) {
677
- (0, import_node_fs3.mkdirSync)(import_node_path4.default.dirname(file), { recursive: true });
878
+ (0, import_node_fs4.mkdirSync)(import_node_path5.default.dirname(file), { recursive: true });
678
879
  const tmp = `${file}.${process.pid}.tmp`;
679
- (0, import_node_fs3.writeFileSync)(tmp, data);
680
- (0, import_node_fs3.renameSync)(tmp, file);
880
+ (0, import_node_fs4.writeFileSync)(tmp, data);
881
+ (0, import_node_fs4.renameSync)(tmp, file);
681
882
  }
682
883
  function body(req) {
683
884
  return new Promise((resolve, reject) => {
@@ -698,9 +899,9 @@ function overlayFile() {
698
899
  return require3.resolve("@limno/overlay/dist/overlay.js");
699
900
  }
700
901
  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");
902
+ const folder = import_node_path5.default.join(root, dir);
903
+ const file = import_node_path5.default.join(folder, "changeset.json");
904
+ const presenceFile = import_node_path5.default.join(folder, "agent.json");
704
905
  let lastWrittenHash = "";
705
906
  const clients = /* @__PURE__ */ new Set();
706
907
  function broadcast(event, data) {
@@ -729,23 +930,23 @@ data: ${JSON.stringify(data)}
729
930
  return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
730
931
  }
731
932
  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");
933
+ const dest = target === "claude" ? import_node_path5.default.join(root, ".mcp.json") : import_node_path5.default.join(root, ".cursor", "mcp.json");
733
934
  let config = {};
734
- if ((0, import_node_fs3.existsSync)(dest)) {
935
+ if ((0, import_node_fs4.existsSync)(dest)) {
735
936
  try {
736
- config = JSON.parse((0, import_node_fs3.readFileSync)(dest, "utf8"));
937
+ config = JSON.parse((0, import_node_fs4.readFileSync)(dest, "utf8"));
737
938
  } catch {
738
- throw new Error(`${import_node_path4.default.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
939
+ throw new Error(`${import_node_path5.default.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
739
940
  }
740
941
  }
741
942
  const { command, args } = setupInfo();
742
943
  const portable = args.map((a, i) => {
743
944
  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;
945
+ return import_node_path5.default.isAbsolute(a) ? import_node_path5.default.relative(root, a).split(import_node_path5.default.sep).join("/") : a;
745
946
  });
746
947
  config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
747
948
  writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
748
- return import_node_path4.default.relative(root, dest);
949
+ return import_node_path5.default.relative(root, dest);
749
950
  }
750
951
  let lastAgent = "";
751
952
  function pushAgent() {
@@ -756,12 +957,13 @@ data: ${JSON.stringify(data)}
756
957
  broadcast("agent", status);
757
958
  }
758
959
  function onChangeSetFile() {
759
- const raw = (0, import_node_fs3.existsSync)(file) ? (0, import_node_fs3.readFileSync)(file, "utf8") : "";
960
+ const raw = (0, import_node_fs4.existsSync)(file) ? (0, import_node_fs4.readFileSync)(file, "utf8") : "";
760
961
  if (!raw || hash(raw) === lastWrittenHash) return;
761
962
  const set = readJson(file);
762
963
  if (set) broadcast("changeset", set);
964
+ runner.changeSetChanged(set);
763
965
  }
764
- (0, import_node_fs3.mkdirSync)(folder, { recursive: true });
966
+ (0, import_node_fs4.mkdirSync)(folder, { recursive: true });
765
967
  let watcher = null;
766
968
  let watchedIno = -1;
767
969
  let debounce;
@@ -769,8 +971,8 @@ data: ${JSON.stringify(data)}
769
971
  watcher?.close();
770
972
  watcher = null;
771
973
  try {
772
- watchedIno = (0, import_node_fs3.statSync)(folder).ino;
773
- watcher = (0, import_node_fs3.watch)(folder, (_event, name) => {
974
+ watchedIno = (0, import_node_fs4.statSync)(folder).ino;
975
+ watcher = (0, import_node_fs4.watch)(folder, (_event, name) => {
774
976
  if (!name || name.endsWith(".tmp")) return;
775
977
  clearTimeout(debounce);
776
978
  debounce = setTimeout(() => {
@@ -786,7 +988,7 @@ data: ${JSON.stringify(data)}
786
988
  const presenceTimer = setInterval(() => {
787
989
  let ino = -1;
788
990
  try {
789
- ino = (0, import_node_fs3.statSync)(folder).ino;
991
+ ino = (0, import_node_fs4.statSync)(folder).ino;
790
992
  } catch {
791
993
  }
792
994
  if (ino !== -1 && ino !== watchedIno) {
@@ -803,8 +1005,8 @@ data: ${JSON.stringify(data)}
803
1005
  async function openInEditor(spec) {
804
1006
  const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
805
1007
  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;
1008
+ const target = import_node_path5.default.resolve(root, m[1]);
1009
+ if (!target.startsWith(root + import_node_path5.default.sep) || !(0, import_node_fs4.existsSync)(target)) return false;
808
1010
  const { default: launch } = await import("launch-editor");
809
1011
  launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
810
1012
  return true;
@@ -814,7 +1016,7 @@ data: ${JSON.stringify(data)}
814
1016
  if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
815
1017
  try {
816
1018
  if (url === `${ROUTE}/overlay.js`) {
817
- return send(res, 200, (0, import_node_fs3.readFileSync)(overlayFile(), "utf8"), "text/javascript");
1019
+ return send(res, 200, (0, import_node_fs4.readFileSync)(overlayFile(), "utf8"), "text/javascript");
818
1020
  }
819
1021
  if (url === `${ROUTE}/events`) {
820
1022
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
@@ -824,20 +1026,21 @@ data: ${JSON.stringify(data)}
824
1026
  return;
825
1027
  }
826
1028
  if (url === `${ROUTE}/changeset` && req.method === "GET") {
827
- return send(res, 200, readJson(file) ?? (0, import_spec.emptyChangeSet)());
1029
+ return send(res, 200, readJson(file) ?? (0, import_spec2.emptyChangeSet)());
828
1030
  }
829
1031
  if (url === `${ROUTE}/changeset` && req.method === "PUT") {
830
1032
  const incoming = JSON.parse(await body(req));
831
- const merged = (0, import_spec.mergeStatuses)(incoming, readJson(file));
1033
+ const merged = (0, import_spec2.mergeStatuses)(incoming, readJson(file));
832
1034
  merged.updatedAt = Date.now();
833
1035
  const raw = JSON.stringify(merged, null, 2);
834
1036
  lastWrittenHash = hash(raw);
835
1037
  writeAtomic(file, raw);
1038
+ runner.changeSetChanged(merged);
836
1039
  return send(res, 200, merged);
837
1040
  }
838
1041
  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");
1042
+ const set = readJson(file) ?? (0, import_spec2.emptyChangeSet)();
1043
+ return send(res, 200, (0, import_spec2.renderPrompt)(set, { mcp: false, statuses: ["draft", "queued", "in_progress", "differs", "failed"] }), "text/markdown; charset=utf-8");
841
1044
  }
842
1045
  if (url === `${ROUTE}/agent` && req.method === "GET") {
843
1046
  return send(res, 200, agentStatus());
@@ -874,6 +1077,11 @@ data: ${JSON.stringify(data)}
874
1077
  runner.forget();
875
1078
  return send(res, 200, await runner.info());
876
1079
  }
1080
+ if (action === "autocommit") {
1081
+ const { on } = JSON.parse(await body(req));
1082
+ runner.setAutoCommit(!!on);
1083
+ return send(res, 200, await runner.info());
1084
+ }
877
1085
  if (action === "run") return send(res, 200, await runner.start());
878
1086
  if (action === "stop") {
879
1087
  runner.stop();
@@ -885,7 +1093,7 @@ data: ${JSON.stringify(data)}
885
1093
  return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
886
1094
  }
887
1095
  if (url === `${ROUTE}/info`) {
888
- return send(res, 200, { name: "limno", root, file: import_node_path4.default.relative(root, file) });
1096
+ return send(res, 200, { name: "limno", root, file: import_node_path5.default.relative(root, file) });
889
1097
  }
890
1098
  send(res, 404, { error: "not found" });
891
1099
  } catch (err) {