@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.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,101 @@ 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
268
+ var fullMessage = (text) => {
269
+ const t = String(text).trim().replace(/\n{3,}/g, "\n\n");
270
+ return t.length > 4e3 ? `${t.slice(0, 3999).trimEnd()}\u2026` : t;
271
+ };
187
272
  var NAMES = { claude: "Claude Code", codex: "Codex" };
188
273
  var SIGN_IN = { claude: "claude auth login", codex: "codex login" };
189
274
  var PROMPT = [
@@ -191,9 +276,11 @@ var PROMPT = [
191
276
  "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.",
192
277
  "Report progress with limno_update_status, and answer comments with limno_reply.",
193
278
  "Only read and edit files inside this project. Don't run shell commands; Limno checks the result on the page itself.",
194
- "Finish with one short sentence summarising what you changed."
279
+ "Finish with a summary the designer reads in Limno, at most two short sentences and 45 words in total:",
280
+ "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.",
281
+ "No file paths, no lists, nothing they already know from the change itself."
195
282
  ].join(" ");
196
- var trustFile = () => path2.join(os.homedir(), ".limno", "trust.json");
283
+ var trustFile = () => path3.join(os.homedir(), ".limno", "trust.json");
197
284
  function projectKey(root) {
198
285
  try {
199
286
  return realpathSync(root);
@@ -203,14 +290,14 @@ function projectKey(root) {
203
290
  }
204
291
  function readTrust() {
205
292
  try {
206
- return JSON.parse(readFileSync(trustFile(), "utf8")).projects ?? {};
293
+ return JSON.parse(readFileSync2(trustFile(), "utf8")).projects ?? {};
207
294
  } catch {
208
295
  return {};
209
296
  }
210
297
  }
211
298
  function writeTrust(projects) {
212
- mkdirSync(path2.dirname(trustFile()), { recursive: true });
213
- writeFileSync(trustFile(), `${JSON.stringify({ projects }, null, 2)}
299
+ mkdirSync2(path3.dirname(trustFile()), { recursive: true });
300
+ writeFileSync2(trustFile(), `${JSON.stringify({ projects }, null, 2)}
214
301
  `);
215
302
  }
216
303
  function run(cmd, args, timeoutMs = 8e3) {
@@ -218,7 +305,7 @@ function run(cmd, args, timeoutMs = 8e3) {
218
305
  let out = "";
219
306
  let child;
220
307
  try {
221
- child = spawn(cmd, args, { env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
308
+ child = spawn2(cmd, args, { env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
222
309
  } catch {
223
310
  return resolve({ code: null, out: "" });
224
311
  }
@@ -271,20 +358,20 @@ function isLocalRequest(req) {
271
358
  return true;
272
359
  }
273
360
  function createRunner(root, folder, onState) {
274
- const sessionsFile = path2.join(folder, "runner.json");
361
+ const sessionsFile = path3.join(folder, "runner.json");
275
362
  let child = null;
276
363
  let again = false;
277
364
  let state = { status: "idle", agent: null, startedAt: null, endedAt: null, log: [], files: [] };
278
365
  const sessions = () => {
279
366
  try {
280
- return JSON.parse(readFileSync(sessionsFile, "utf8"));
367
+ return JSON.parse(readFileSync2(sessionsFile, "utf8"));
281
368
  } catch {
282
369
  return {};
283
370
  }
284
371
  };
285
372
  const saveSession = (agent, id) => {
286
- mkdirSync(folder, { recursive: true });
287
- writeFileSync(sessionsFile, `${JSON.stringify({ ...sessions(), [agent]: id }, null, 2)}
373
+ mkdirSync2(folder, { recursive: true });
374
+ writeFileSync2(sessionsFile, `${JSON.stringify({ ...sessions(), [agent]: id }, null, 2)}
288
375
  `);
289
376
  };
290
377
  let emitTimer;
@@ -302,15 +389,108 @@ function createRunner(root, folder, onState) {
302
389
  };
303
390
  const rel = (p) => {
304
391
  if (!p) return "";
305
- const abs = path2.isAbsolute(p) ? p : path2.join(root, p);
306
- return path2.relative(root, abs) || p;
392
+ const abs = path3.isAbsolute(p) ? p : path3.join(root, p);
393
+ return path3.relative(root, abs) || p;
307
394
  };
308
395
  const key = projectKey(root);
309
396
  const allowed = () => readTrust()[key]?.agent ?? null;
397
+ const autoCommitOn = () => !!readTrust()[key]?.autoCommit;
398
+ const changeSetFile = path3.join(folder, "changeset.json");
399
+ const readSet = () => {
400
+ try {
401
+ return JSON.parse(readFileSync2(changeSetFile, "utf8"));
402
+ } catch {
403
+ return null;
404
+ }
405
+ };
406
+ let run2 = null;
407
+ let committing = false;
408
+ const say = (line) => console.log(`\x1B[36m[limno]\x1B[0m ${line}`);
409
+ const list = (files) => files.length <= 3 ? files.join(", ") : `${files.slice(0, 3).join(", ")} and ${files.length - 3} more`;
410
+ async function finishRun(agent) {
411
+ const startedAt = state.startedAt;
412
+ const files = state.files;
413
+ const set = readSet();
414
+ const ids = run2?.changeIds ?? [];
415
+ const changes = (set?.changes ?? []).filter((c) => ids.includes(c.id));
416
+ const rec = {
417
+ id: run2?.id ?? String(startedAt),
418
+ agent: NAMES[agent],
419
+ startedAt: startedAt ?? Date.now(),
420
+ endedAt: state.endedAt ?? Date.now(),
421
+ status: state.status === "done" ? "done" : state.status === "stopped" ? "stopped" : "failed",
422
+ files,
423
+ changeIds: ids,
424
+ summary: [...state.log].reverse().find((l) => l.kind === "message")?.text,
425
+ message: commitMessage(changes)
426
+ };
427
+ run2 = rec;
428
+ try {
429
+ saveRun(folder, rec);
430
+ } catch {
431
+ }
432
+ if (!files.length) {
433
+ if (rec.status === "done") say(`${NAMES[agent]} finished without editing any files.`);
434
+ return;
435
+ }
436
+ const dirty = await uncommitted(root, files);
437
+ const git2 = dirty.length > 0 || await isGitRepo(root);
438
+ if (state.startedAt === startedAt) {
439
+ state.commit = { message: rec.message, uncommitted: dirty, git: git2 };
440
+ emit(true);
441
+ }
442
+ const n = files.length;
443
+ say(`${NAMES[agent]} edited ${n} ${n === 1 ? "file" : "files"}: ${list(files)}${dirty.length ? " (not committed yet)" : ""}`);
444
+ if (dirty.length) {
445
+ say(`Commit message: ${rec.message.split("\n")[0]}`);
446
+ if (autoCommitOn()) say("Auto-commit is on: Limno commits these files once every change is verified on the page.");
447
+ }
448
+ if (state.startedAt === startedAt) void changeSetChanged(set);
449
+ }
450
+ async function changeSetChanged(set) {
451
+ const commit = state.commit;
452
+ if (!set || !run2 || committing || !commit || commit.committed || !commit.uncommitted.length || !autoCommitOn()) return;
453
+ if (state.status !== "done") return;
454
+ const sent = set.changes.filter((c) => run2.changeIds.includes(c.id) && c.kind !== "note");
455
+ if (!sent.length) return;
456
+ if (sent.some((c) => c.status === "differs" || c.status === "failed")) {
457
+ if (!commit.note) {
458
+ commit.note = "A change doesn\u2019t match the preview yet.";
459
+ const off = describeChanges(sent.filter((c) => c.status === "differs" || c.status === "failed")).length || 1;
460
+ say(`Not auto-committing: ${off === 1 ? "1 change doesn\u2019t" : `${off} changes don\u2019t`} match the preview yet. Review in Limno.`);
461
+ emit(true);
462
+ }
463
+ return;
464
+ }
465
+ if (!sent.every((c) => c.status === "verified")) return;
466
+ committing = true;
467
+ try {
468
+ const r = await commitFiles(root, state.files, run2.message);
469
+ if (r.ok) {
470
+ commit.committed = { sha: r.sha, auto: true };
471
+ commit.uncommitted = [];
472
+ commit.note = void 0;
473
+ run2.commit = { sha: r.sha, at: Date.now(), auto: true };
474
+ try {
475
+ saveRun(folder, run2);
476
+ } catch {
477
+ }
478
+ say(`Committed ${state.files.length === 1 ? "1 file" : `${state.files.length} files`} as ${r.sha}: ${run2.message.split("\n")[0]} (not pushed)`);
479
+ } else {
480
+ commit.note = `Not committed: ${r.error}`;
481
+ say(`Didn\u2019t auto-commit: ${r.error}`);
482
+ }
483
+ emit(true);
484
+ } finally {
485
+ committing = false;
486
+ }
487
+ }
310
488
  function limnoLine(tool, input = {}) {
311
489
  if (tool.endsWith("get_changes")) return "Reading your changes";
312
490
  if (tool.endsWith("update_status")) {
313
- const n = Array.isArray(input.ids) ? input.ids.length : 0;
491
+ const ids = Array.isArray(input.ids) ? input.ids : [];
492
+ const sent = (readSet()?.changes ?? []).filter((c) => ids.includes(c.id));
493
+ const n = sent.length ? describeChanges(sent).length : ids.length;
314
494
  const status = String(input.status ?? "");
315
495
  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
496
  }
@@ -334,7 +514,7 @@ function createRunner(root, folder, onState) {
334
514
  else if (name === "Edit" || name === "MultiEdit" || name === "Write") log({ kind: "edit", text: `Editing ${rel(input.file_path)}`, file: rel(input.file_path) });
335
515
  else if (name === "Glob" || name === "Grep") log({ kind: "search", text: "Searching the project" });
336
516
  } else if (block.type === "text" && String(block.text).trim()) {
337
- log({ kind: "message", text: String(block.text).trim().split("\n")[0].slice(0, 220) });
517
+ log({ kind: "message", text: fullMessage(block.text) });
338
518
  }
339
519
  }
340
520
  return;
@@ -355,7 +535,7 @@ function createRunner(root, folder, onState) {
355
535
  else if (item.type === "file_change" && e.type === "item.completed") {
356
536
  for (const c of item.changes ?? []) log({ kind: "edit", text: `Editing ${rel(c.path)}`, file: rel(c.path) });
357
537
  } else if (item.type === "agent_message" && e.type === "item.completed" && item.text) {
358
- log({ kind: "message", text: String(item.text).trim().split("\n")[0].slice(0, 220) });
538
+ log({ kind: "message", text: fullMessage(item.text) });
359
539
  } else if (item.type === "command_execution" && e.type === "item.started") {
360
540
  log({ kind: "search", text: "Looking through the project" });
361
541
  }
@@ -402,7 +582,7 @@ function createRunner(root, folder, onState) {
402
582
  return resume ? ["exec", ...opts, "resume", resume, PROMPT] : ["exec", ...opts, PROMPT];
403
583
  }
404
584
  function launch(agent, resume) {
405
- const proc = spawn(agent, args(agent, resume), { cwd: root, env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
585
+ const proc = spawn2(agent, args(agent, resume), { cwd: root, env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
406
586
  child = proc;
407
587
  let buf = "";
408
588
  let stderr = "";
@@ -437,7 +617,11 @@ function createRunner(root, folder, onState) {
437
617
  launch(agent);
438
618
  return;
439
619
  }
440
- if (state.status === "stopped") return emit(true);
620
+ if (state.status === "stopped") {
621
+ emit(true);
622
+ void finishRun(agent);
623
+ return;
624
+ }
441
625
  const failed = code !== 0 || !!state.error;
442
626
  const tail = stderr.trim().split("\n").filter(Boolean).slice(-2).join(" ").slice(0, 300);
443
627
  state = {
@@ -447,6 +631,7 @@ function createRunner(root, folder, onState) {
447
631
  error: failed ? state.error ?? (tail || `${NAMES[agent]} exited${signal ? ` (${signal})` : ` with code ${code}`}`) : void 0
448
632
  };
449
633
  emit(true);
634
+ void finishRun(agent);
450
635
  if (again && !failed) {
451
636
  again = false;
452
637
  void start();
@@ -465,16 +650,32 @@ function createRunner(root, folder, onState) {
465
650
  if (!info?.installed) return { ok: false, error: `${NAMES[agent]} isn\u2019t installed on this computer.` };
466
651
  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
652
  state = { status: "starting", agent, startedAt: Date.now(), endedAt: null, log: [], files: [] };
653
+ const queued = (readSet()?.changes ?? []).filter((c) => c.enabled && ["queued", "in_progress", "differs", "failed"].includes(c.status)).map((c) => c.id);
654
+ run2 = { id: String(state.startedAt), agent: NAMES[agent], startedAt: state.startedAt, endedAt: 0, status: "done", files: [], changeIds: queued, message: "" };
468
655
  emit(true);
469
656
  launch(agent, sessions()[agent]);
470
657
  return { ok: true };
471
658
  }
472
659
  return {
473
660
  async info(force) {
474
- return { agents: await detectAgents(force), allowed: allowed(), state };
661
+ if (state.commit && !state.commit.committed && state.commit.uncommitted.length && !child) {
662
+ state.commit = { ...state.commit, uncommitted: await uncommitted(root, state.files) };
663
+ }
664
+ return { agents: await detectAgents(force), allowed: allowed(), autoCommit: autoCommitOn(), state };
665
+ },
666
+ setAutoCommit(on) {
667
+ const t = readTrust();
668
+ if (!t[key]) return;
669
+ t[key] = { ...t[key], autoCommit: on };
670
+ writeTrust(t);
671
+ if (on) void changeSetChanged(readSet());
672
+ },
673
+ changeSetChanged(set) {
674
+ void changeSetChanged(set);
475
675
  },
476
676
  allow(agent) {
477
- writeTrust({ ...readTrust(), [key]: { agent, at: Date.now() } });
677
+ const prev = readTrust()[key];
678
+ writeTrust({ ...readTrust(), [key]: { agent, at: Date.now(), autoCommit: prev?.autoCommit } });
478
679
  },
479
680
  forget() {
480
681
  const t = readTrust();
@@ -498,11 +699,11 @@ function createRunner(root, folder, onState) {
498
699
  }
499
700
 
500
701
  // 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";
702
+ import { spawn as spawn3 } from "child_process";
703
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
503
704
  import { createRequire as createRequire2 } from "module";
504
705
  import os2 from "os";
505
- import path3 from "path";
706
+ import path4 from "path";
506
707
  var require2 = createRequire2(import.meta.url);
507
708
  var TTL = 60 * 60 * 1e3;
508
709
  function currentVersion() {
@@ -514,7 +715,7 @@ function currentVersion() {
514
715
  }
515
716
  function installedVersion(fallback) {
516
717
  try {
517
- return JSON.parse(readFileSync2(require2.resolve("../package.json"), "utf8")).version;
718
+ return JSON.parse(readFileSync3(require2.resolve("../package.json"), "utf8")).version;
518
719
  } catch {
519
720
  return fallback;
520
721
  }
@@ -528,18 +729,18 @@ var newer = (a, b) => {
528
729
  function packageManager(root) {
529
730
  let dir = root;
530
731
  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);
732
+ if (existsSync2(path4.join(dir, "pnpm-lock.yaml"))) return "pnpm";
733
+ if (existsSync2(path4.join(dir, "yarn.lock"))) return "yarn";
734
+ if (existsSync2(path4.join(dir, "bun.lockb")) || existsSync2(path4.join(dir, "bun.lock"))) return "bun";
735
+ if (existsSync2(path4.join(dir, "package-lock.json"))) return "npm";
736
+ const up = path4.dirname(dir);
536
737
  if (up === dir) return "npm";
537
738
  dir = up;
538
739
  }
539
740
  }
540
741
  function listedPackages(root) {
541
742
  try {
542
- const pkg = JSON.parse(readFileSync2(path3.join(root, "package.json"), "utf8"));
743
+ const pkg = JSON.parse(readFileSync3(path4.join(root, "package.json"), "utf8"));
543
744
  return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((n) => n.startsWith("@limno/") && n !== "@limno/cli");
544
745
  } catch {
545
746
  return [];
@@ -552,10 +753,10 @@ function installArgs(pm, pkgs) {
552
753
  if (pm === "bun") return ["bun", ["add", "-d", ...at]];
553
754
  return ["npm", ["install", "-D", ...at]];
554
755
  }
555
- var cacheFile = () => path3.join(os2.homedir(), ".limno", "update-check.json");
756
+ var cacheFile = () => path4.join(os2.homedir(), ".limno", "update-check.json");
556
757
  async function latestVersion() {
557
758
  try {
558
- const cached = JSON.parse(readFileSync2(cacheFile(), "utf8"));
759
+ const cached = JSON.parse(readFileSync3(cacheFile(), "utf8"));
559
760
  if (Date.now() - cached.at < TTL) return cached.latest;
560
761
  } catch {
561
762
  }
@@ -564,8 +765,8 @@ async function latestVersion() {
564
765
  if (!res.ok) return null;
565
766
  const latest = (await res.json()).latest ?? null;
566
767
  if (latest) {
567
- mkdirSync2(path3.dirname(cacheFile()), { recursive: true });
568
- writeFileSync2(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
768
+ mkdirSync3(path4.dirname(cacheFile()), { recursive: true });
769
+ writeFileSync3(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
569
770
  }
570
771
  return latest;
571
772
  } catch {
@@ -592,14 +793,14 @@ function createUpdater(root) {
592
793
  const [cmd, args] = installArgs(packageManager(root), pkgs);
593
794
  installing = new Promise((resolve) => {
594
795
  let output = "";
595
- const child = spawn2(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
796
+ const child = spawn3(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
596
797
  child.stdout?.on("data", (d) => output += d);
597
798
  child.stderr?.on("data", (d) => output += d);
598
799
  child.on("error", (e) => resolve({ ok: false, output: String(e) }));
599
800
  child.on("close", (code) => {
600
801
  if (code === 0) {
601
802
  try {
602
- writeFileSync2(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
803
+ writeFileSync3(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
603
804
  } catch {
604
805
  }
605
806
  }
@@ -619,16 +820,16 @@ var require3 = createRequire3(import.meta.url);
619
820
  var STALE_MS = 3e4;
620
821
  function readJson(file) {
621
822
  try {
622
- return JSON.parse(readFileSync3(file, "utf8"));
823
+ return JSON.parse(readFileSync4(file, "utf8"));
623
824
  } catch {
624
825
  return null;
625
826
  }
626
827
  }
627
828
  function writeAtomic(file, data) {
628
- mkdirSync3(path4.dirname(file), { recursive: true });
829
+ mkdirSync4(path5.dirname(file), { recursive: true });
629
830
  const tmp = `${file}.${process.pid}.tmp`;
630
- writeFileSync3(tmp, data);
631
- renameSync(tmp, file);
831
+ writeFileSync4(tmp, data);
832
+ renameSync2(tmp, file);
632
833
  }
633
834
  function body(req) {
634
835
  return new Promise((resolve, reject) => {
@@ -649,9 +850,9 @@ function overlayFile() {
649
850
  return require3.resolve("@limno/overlay/dist/overlay.js");
650
851
  }
651
852
  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");
853
+ const folder = path5.join(root, dir);
854
+ const file = path5.join(folder, "changeset.json");
855
+ const presenceFile = path5.join(folder, "agent.json");
655
856
  let lastWrittenHash = "";
656
857
  const clients = /* @__PURE__ */ new Set();
657
858
  function broadcast(event, data) {
@@ -680,23 +881,23 @@ data: ${JSON.stringify(data)}
680
881
  return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
681
882
  }
682
883
  function installConfig(target) {
683
- const dest = target === "claude" ? path4.join(root, ".mcp.json") : path4.join(root, ".cursor", "mcp.json");
884
+ const dest = target === "claude" ? path5.join(root, ".mcp.json") : path5.join(root, ".cursor", "mcp.json");
684
885
  let config = {};
685
- if (existsSync2(dest)) {
886
+ if (existsSync3(dest)) {
686
887
  try {
687
- config = JSON.parse(readFileSync3(dest, "utf8"));
888
+ config = JSON.parse(readFileSync4(dest, "utf8"));
688
889
  } catch {
689
- throw new Error(`${path4.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
890
+ throw new Error(`${path5.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
690
891
  }
691
892
  }
692
893
  const { command, args } = setupInfo();
693
894
  const portable = args.map((a, i) => {
694
895
  if (args[i - 1] === "--root") return ".";
695
- return path4.isAbsolute(a) ? path4.relative(root, a).split(path4.sep).join("/") : a;
896
+ return path5.isAbsolute(a) ? path5.relative(root, a).split(path5.sep).join("/") : a;
696
897
  });
697
898
  config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
698
899
  writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
699
- return path4.relative(root, dest);
900
+ return path5.relative(root, dest);
700
901
  }
701
902
  let lastAgent = "";
702
903
  function pushAgent() {
@@ -707,12 +908,13 @@ data: ${JSON.stringify(data)}
707
908
  broadcast("agent", status);
708
909
  }
709
910
  function onChangeSetFile() {
710
- const raw = existsSync2(file) ? readFileSync3(file, "utf8") : "";
911
+ const raw = existsSync3(file) ? readFileSync4(file, "utf8") : "";
711
912
  if (!raw || hash(raw) === lastWrittenHash) return;
712
913
  const set = readJson(file);
713
914
  if (set) broadcast("changeset", set);
915
+ runner.changeSetChanged(set);
714
916
  }
715
- mkdirSync3(folder, { recursive: true });
917
+ mkdirSync4(folder, { recursive: true });
716
918
  let watcher = null;
717
919
  let watchedIno = -1;
718
920
  let debounce;
@@ -754,8 +956,8 @@ data: ${JSON.stringify(data)}
754
956
  async function openInEditor(spec) {
755
957
  const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
756
958
  if (!m) return false;
757
- const target = path4.resolve(root, m[1]);
758
- if (!target.startsWith(root + path4.sep) || !existsSync2(target)) return false;
959
+ const target = path5.resolve(root, m[1]);
960
+ if (!target.startsWith(root + path5.sep) || !existsSync3(target)) return false;
759
961
  const { default: launch } = await import("launch-editor");
760
962
  launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
761
963
  return true;
@@ -765,7 +967,7 @@ data: ${JSON.stringify(data)}
765
967
  if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
766
968
  try {
767
969
  if (url === `${ROUTE}/overlay.js`) {
768
- return send(res, 200, readFileSync3(overlayFile(), "utf8"), "text/javascript");
970
+ return send(res, 200, readFileSync4(overlayFile(), "utf8"), "text/javascript");
769
971
  }
770
972
  if (url === `${ROUTE}/events`) {
771
973
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
@@ -784,6 +986,7 @@ data: ${JSON.stringify(data)}
784
986
  const raw = JSON.stringify(merged, null, 2);
785
987
  lastWrittenHash = hash(raw);
786
988
  writeAtomic(file, raw);
989
+ runner.changeSetChanged(merged);
787
990
  return send(res, 200, merged);
788
991
  }
789
992
  if (url === `${ROUTE}/prompt` && req.method === "GET") {
@@ -825,6 +1028,11 @@ data: ${JSON.stringify(data)}
825
1028
  runner.forget();
826
1029
  return send(res, 200, await runner.info());
827
1030
  }
1031
+ if (action === "autocommit") {
1032
+ const { on } = JSON.parse(await body(req));
1033
+ runner.setAutoCommit(!!on);
1034
+ return send(res, 200, await runner.info());
1035
+ }
828
1036
  if (action === "run") return send(res, 200, await runner.start());
829
1037
  if (action === "stop") {
830
1038
  runner.stop();
@@ -836,7 +1044,7 @@ data: ${JSON.stringify(data)}
836
1044
  return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
837
1045
  }
838
1046
  if (url === `${ROUTE}/info`) {
839
- return send(res, 200, { name: "limno", root, file: path4.relative(root, file) });
1047
+ return send(res, 200, { name: "limno", root, file: path5.relative(root, file) });
840
1048
  }
841
1049
  send(res, 404, { error: "not found" });
842
1050
  } catch (err) {