@limno/core 0.1.2 → 0.1.4

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
@@ -34,6 +34,7 @@ __export(index_exports, {
34
34
  SOURCE_FILES: () => SOURCE_FILES,
35
35
  TEMPLATE_FILES: () => TEMPLATE_FILES,
36
36
  createLimnoService: () => createLimnoService,
37
+ isLocalRequest: () => isLocalRequest,
37
38
  overlayFile: () => overlayFile,
38
39
  shouldStamp: () => shouldStamp,
39
40
  stampSource: () => stampSource,
@@ -58,8 +59,8 @@ function jsxName(name) {
58
59
  return `${jsxName(name.object)}.${name.property.name}`;
59
60
  }
60
61
  var isCapitalized = (s) => /^[A-Z]/.test(s);
61
- function componentName(path3, fileBase) {
62
- let p = path3.getFunctionParent();
62
+ function componentName(path5, fileBase) {
63
+ let p = path5.getFunctionParent();
63
64
  while (p) {
64
65
  const node = p.node;
65
66
  let name;
@@ -101,8 +102,8 @@ function stampSource(code, id, relFile) {
101
102
  const fileBase = relFile.split("/").pop().replace(/\.[jt]sx?$/, "").replace(/^./, (c) => c.toUpperCase());
102
103
  let touched = false;
103
104
  traverse(ast, {
104
- JSXOpeningElement(path3) {
105
- const node = path3.node;
105
+ JSXOpeningElement(path5) {
106
+ const node = path5.node;
106
107
  const loc = node.loc;
107
108
  if (!loc || node.name.end == null) return;
108
109
  const name = jsxName(node.name);
@@ -110,7 +111,7 @@ function stampSource(code, id, relFile) {
110
111
  const host = node.name.type === "JSXIdentifier" && !isCapitalized(name);
111
112
  if (host) {
112
113
  if (hasAttr(node, "data-limno-src")) return;
113
- const comp = componentName(path3, fileBase);
114
+ const comp = componentName(path5, fileBase);
114
115
  s.appendLeft(node.name.end, ` data-limno-src="${escapeAttr(where)}"${comp ? ` data-limno-comp="${escapeAttr(comp)}"` : ""}`);
115
116
  touched = true;
116
117
  } else {
@@ -222,25 +223,452 @@ async function stampTemplate(code, id, relFile, root) {
222
223
 
223
224
  // src/service.ts
224
225
  var import_node_crypto = require("crypto");
226
+ var import_node_fs3 = require("fs");
227
+ var import_node_module3 = require("module");
228
+ var import_node_path4 = __toESM(require("path"), 1);
229
+ var import_spec = require("@limno/spec");
230
+
231
+ // src/runner.ts
232
+ var import_node_child_process = require("child_process");
225
233
  var import_node_fs = require("fs");
226
- var import_node_module2 = require("module");
234
+ var import_node_os = __toESM(require("os"), 1);
227
235
  var import_node_path2 = __toESM(require("path"), 1);
228
- var import_spec = require("@limno/spec");
229
- var ROUTE = "/__limno";
236
+ var NAMES = { claude: "Claude Code", codex: "Codex" };
237
+ var SIGN_IN = { claude: "claude auth login", codex: "codex login" };
238
+ var PROMPT = [
239
+ "You were started by Limno, a visual editor, because the designer clicked Send.",
240
+ "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
+ "Report progress with limno_update_status, and answer comments with limno_reply.",
242
+ "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."
244
+ ].join(" ");
245
+ var trustFile = () => import_node_path2.default.join(import_node_os.default.homedir(), ".limno", "trust.json");
246
+ function projectKey(root) {
247
+ try {
248
+ return (0, import_node_fs.realpathSync)(root);
249
+ } catch {
250
+ return root;
251
+ }
252
+ }
253
+ function readTrust() {
254
+ try {
255
+ return JSON.parse((0, import_node_fs.readFileSync)(trustFile(), "utf8")).projects ?? {};
256
+ } catch {
257
+ return {};
258
+ }
259
+ }
260
+ 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)}
263
+ `);
264
+ }
265
+ function run(cmd, args, timeoutMs = 8e3) {
266
+ return new Promise((resolve) => {
267
+ let out = "";
268
+ let child;
269
+ try {
270
+ child = (0, import_node_child_process.spawn)(cmd, args, { env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
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
+ function agentEnv() {
288
+ const env = { ...process.env };
289
+ delete env.CLAUDECODE;
290
+ return env;
291
+ }
292
+ async function detectAgent(id) {
293
+ const base = { id, name: NAMES[id], signIn: SIGN_IN[id] };
294
+ const version = await run(id, ["--version"]);
295
+ if (version.code !== 0) return { ...base, installed: false, signedIn: false };
296
+ const status = id === "claude" ? await run("claude", ["auth", "status"]) : await run("codex", ["login", "status"]);
297
+ return { ...base, installed: true, signedIn: status.code === 0 };
298
+ }
299
+ var detected = null;
300
+ async function detectAgents(force = false) {
301
+ if (!force && detected && Date.now() - detected.at < 2e4) return detected.agents;
302
+ const agents = await Promise.all(["claude", "codex"].map(detectAgent));
303
+ detected = { at: Date.now(), agents };
304
+ return agents;
305
+ }
306
+ var LOOPBACK = /^(localhost|127(?:\.\d+){3}|\[?::1\]?|[a-z0-9-]+\.localhost)$/i;
307
+ function isLocalRequest(req) {
308
+ const remote = req.socket.remoteAddress ?? "";
309
+ if (!/^(127\.|::1$|::ffff:127\.)/.test(remote)) return false;
310
+ const host = (req.headers.host ?? "").replace(/:\d+$/, "");
311
+ if (!LOOPBACK.test(host)) return false;
312
+ const origin = req.headers.origin;
313
+ if (origin) {
314
+ try {
315
+ if (!LOOPBACK.test(new URL(origin).hostname)) return false;
316
+ } catch {
317
+ return false;
318
+ }
319
+ }
320
+ return true;
321
+ }
322
+ function createRunner(root, folder, onState) {
323
+ const sessionsFile = import_node_path2.default.join(folder, "runner.json");
324
+ let child = null;
325
+ let again = false;
326
+ let state = { status: "idle", agent: null, startedAt: null, endedAt: null, log: [], files: [] };
327
+ const sessions = () => {
328
+ try {
329
+ return JSON.parse((0, import_node_fs.readFileSync)(sessionsFile, "utf8"));
330
+ } catch {
331
+ return {};
332
+ }
333
+ };
334
+ 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)}
337
+ `);
338
+ };
339
+ let emitTimer;
340
+ const emit = (now = false) => {
341
+ clearTimeout(emitTimer);
342
+ if (now) onState(state);
343
+ else emitTimer = setTimeout(() => onState(state), 120);
344
+ };
345
+ const log = (entry) => {
346
+ const last = state.log[state.log.length - 1];
347
+ if (last && last.text === entry.text) return;
348
+ state.log = [...state.log.slice(-60), { at: Date.now(), ...entry }];
349
+ if (entry.kind === "edit" && entry.file && !state.files.includes(entry.file)) state.files = [...state.files, entry.file];
350
+ emit();
351
+ };
352
+ const rel = (p) => {
353
+ 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;
356
+ };
357
+ const key = projectKey(root);
358
+ const allowed = () => readTrust()[key]?.agent ?? null;
359
+ function limnoLine(tool, input = {}) {
360
+ if (tool.endsWith("get_changes")) return "Reading your changes";
361
+ if (tool.endsWith("update_status")) {
362
+ const n = Array.isArray(input.ids) ? input.ids.length : 0;
363
+ const status = String(input.status ?? "");
364
+ 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
+ }
366
+ if (tool.endsWith("reply")) return "Replied to your comment";
367
+ return "Checking Limno";
368
+ }
369
+ function onClaudeEvent(e) {
370
+ if (e.session_id) saveSession("claude", e.session_id);
371
+ if (e.type === "system" && e.subtype === "init") {
372
+ const limno = (e.mcp_servers ?? []).find((s) => s.name === "limno");
373
+ if (limno && limno.status !== "connected") log({ kind: "error", text: "Limno\u2019s tools didn\u2019t start for the agent" });
374
+ return;
375
+ }
376
+ if (e.type === "assistant") {
377
+ for (const block of e.message?.content ?? []) {
378
+ if (block.type === "tool_use") {
379
+ const name = String(block.name);
380
+ const input = block.input ?? {};
381
+ if (name.startsWith("mcp__limno__")) log({ kind: "limno", text: limnoLine(name, input) });
382
+ else if (name === "Read") log({ kind: "read", text: `Reading ${rel(input.file_path)}`, file: rel(input.file_path) });
383
+ else if (name === "Edit" || name === "MultiEdit" || name === "Write") log({ kind: "edit", text: `Editing ${rel(input.file_path)}`, file: rel(input.file_path) });
384
+ else if (name === "Glob" || name === "Grep") log({ kind: "search", text: "Searching the project" });
385
+ } else if (block.type === "text" && String(block.text).trim()) {
386
+ log({ kind: "message", text: String(block.text).trim().split("\n")[0].slice(0, 220) });
387
+ }
388
+ }
389
+ return;
390
+ }
391
+ if (e.type === "result") {
392
+ if (e.is_error) state.error = String(e.result ?? e.subtype ?? "The agent stopped with an error").slice(0, 300);
393
+ }
394
+ }
395
+ function onCodexEvent(e) {
396
+ if (e.type === "thread.started" && e.thread_id) return saveSession("codex", e.thread_id);
397
+ if (e.type === "error" || e.type === "turn.failed") {
398
+ state.error = String(e.message ?? e.error?.message ?? "The agent stopped with an error").slice(0, 300);
399
+ return;
400
+ }
401
+ if (e.type !== "item.started" && e.type !== "item.completed") return;
402
+ const item = e.item ?? {};
403
+ if (item.type === "mcp_tool_call" && e.type === "item.started") log({ kind: "limno", text: limnoLine(String(item.tool ?? ""), item.arguments ?? {}) });
404
+ else if (item.type === "file_change" && e.type === "item.completed") {
405
+ for (const c of item.changes ?? []) log({ kind: "edit", text: `Editing ${rel(c.path)}`, file: rel(c.path) });
406
+ } 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) });
408
+ } else if (item.type === "command_execution" && e.type === "item.started") {
409
+ log({ kind: "search", text: "Looking through the project" });
410
+ }
411
+ }
412
+ function args(agent, resume) {
413
+ const server = { command: "npx", args: ["-y", "@limno/mcp", "--root", root] };
414
+ if (agent === "claude") {
415
+ return [
416
+ "-p",
417
+ PROMPT,
418
+ "--output-format",
419
+ "stream-json",
420
+ "--verbose",
421
+ "--mcp-config",
422
+ JSON.stringify({ mcpServers: { limno: server } }),
423
+ "--strict-mcp-config",
424
+ // Anything not listed is refused without a prompt: no shell, no web.
425
+ "--permission-mode",
426
+ "dontAsk",
427
+ "--allowedTools",
428
+ "Read",
429
+ "Edit",
430
+ "MultiEdit",
431
+ "Write",
432
+ "Glob",
433
+ "Grep",
434
+ "mcp__limno__limno_get_changes",
435
+ "mcp__limno__limno_update_status",
436
+ "mcp__limno__limno_reply",
437
+ "mcp__limno__limno_get_status",
438
+ ...resume ? ["--resume", resume] : []
439
+ ];
440
+ }
441
+ const opts = [
442
+ "--json",
443
+ "--sandbox",
444
+ "workspace-write",
445
+ "--skip-git-repo-check",
446
+ "-c",
447
+ `mcp_servers.limno.command=${JSON.stringify(server.command)}`,
448
+ "-c",
449
+ `mcp_servers.limno.args=${JSON.stringify(server.args)}`
450
+ ];
451
+ return resume ? ["exec", ...opts, "resume", resume, PROMPT] : ["exec", ...opts, PROMPT];
452
+ }
453
+ 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" });
455
+ child = proc;
456
+ let buf = "";
457
+ let stderr = "";
458
+ let sawEvent = false;
459
+ proc.stdout?.on("data", (d) => {
460
+ buf += d;
461
+ const lines = buf.split("\n");
462
+ buf = lines.pop() ?? "";
463
+ for (const line of lines) {
464
+ if (!line.trim()) continue;
465
+ try {
466
+ const e = JSON.parse(line);
467
+ sawEvent = true;
468
+ if (state.status === "starting") state.status = "running";
469
+ if (agent === "claude") onClaudeEvent(e);
470
+ else onCodexEvent(e);
471
+ emit();
472
+ } catch {
473
+ }
474
+ }
475
+ });
476
+ proc.stderr?.on("data", (d) => stderr = (stderr + d).slice(-2e3));
477
+ proc.on("error", (err) => {
478
+ state = { ...state, status: "failed", endedAt: Date.now(), error: `Couldn\u2019t start ${NAMES[agent]}: ${err.message}` };
479
+ child = null;
480
+ emit(true);
481
+ });
482
+ proc.on("close", (code, signal) => {
483
+ if (child !== proc) return;
484
+ child = null;
485
+ if (resume && code !== 0 && !sawEvent) {
486
+ launch(agent);
487
+ return;
488
+ }
489
+ if (state.status === "stopped") return emit(true);
490
+ const failed = code !== 0 || !!state.error;
491
+ const tail = stderr.trim().split("\n").filter(Boolean).slice(-2).join(" ").slice(0, 300);
492
+ state = {
493
+ ...state,
494
+ status: failed ? "failed" : "done",
495
+ endedAt: Date.now(),
496
+ error: failed ? state.error ?? (tail || `${NAMES[agent]} exited${signal ? ` (${signal})` : ` with code ${code}`}`) : void 0
497
+ };
498
+ emit(true);
499
+ if (again && !failed) {
500
+ again = false;
501
+ void start();
502
+ }
503
+ again = false;
504
+ });
505
+ }
506
+ async function start() {
507
+ if (child) {
508
+ again = true;
509
+ return { ok: true, queued: true };
510
+ }
511
+ const agent = allowed();
512
+ if (!agent) return { ok: false, error: "not-allowed" };
513
+ const info = (await detectAgents(true)).find((a) => a.id === agent);
514
+ if (!info?.installed) return { ok: false, error: `${NAMES[agent]} isn\u2019t installed on this computer.` };
515
+ 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
+ state = { status: "starting", agent, startedAt: Date.now(), endedAt: null, log: [], files: [] };
517
+ emit(true);
518
+ launch(agent, sessions()[agent]);
519
+ return { ok: true };
520
+ }
521
+ return {
522
+ async info(force) {
523
+ return { agents: await detectAgents(force), allowed: allowed(), state };
524
+ },
525
+ allow(agent) {
526
+ writeTrust({ ...readTrust(), [key]: { agent, at: Date.now() } });
527
+ },
528
+ forget() {
529
+ const t = readTrust();
530
+ delete t[key];
531
+ writeTrust(t);
532
+ },
533
+ start,
534
+ stop() {
535
+ if (!child) return;
536
+ again = false;
537
+ state = { ...state, status: "stopped", endedAt: Date.now() };
538
+ child.kill("SIGINT");
539
+ const c = child;
540
+ setTimeout(() => c.exitCode === null && c.kill("SIGTERM"), 3e3).unref();
541
+ emit(true);
542
+ },
543
+ close() {
544
+ child?.kill("SIGTERM");
545
+ }
546
+ };
547
+ }
548
+
549
+ // src/update.ts
550
+ var import_node_child_process2 = require("child_process");
551
+ var import_node_fs2 = require("fs");
552
+ var import_node_module2 = require("module");
553
+ var import_node_os2 = __toESM(require("os"), 1);
554
+ var import_node_path3 = __toESM(require("path"), 1);
230
555
  var require2 = (0, import_node_module2.createRequire)(importMetaUrl);
556
+ var DAY = 24 * 60 * 60 * 1e3;
557
+ function currentVersion() {
558
+ try {
559
+ return require2("../package.json").version;
560
+ } catch {
561
+ return "0.0.0";
562
+ }
563
+ }
564
+ var newer = (a, b) => {
565
+ const pa = a.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
566
+ const pb = b.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
567
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
568
+ return false;
569
+ };
570
+ function packageManager(root) {
571
+ let dir = root;
572
+ for (; ; ) {
573
+ if ((0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "pnpm-lock.yaml"))) return "pnpm";
574
+ if ((0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "yarn.lock"))) return "yarn";
575
+ 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";
576
+ if ((0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "package-lock.json"))) return "npm";
577
+ const up = import_node_path3.default.dirname(dir);
578
+ if (up === dir) return "npm";
579
+ dir = up;
580
+ }
581
+ }
582
+ function listedPackages(root) {
583
+ try {
584
+ const pkg = JSON.parse((0, import_node_fs2.readFileSync)(import_node_path3.default.join(root, "package.json"), "utf8"));
585
+ return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((n) => n.startsWith("@limno/") && n !== "@limno/cli");
586
+ } catch {
587
+ return [];
588
+ }
589
+ }
590
+ function installArgs(pm, pkgs) {
591
+ const at = pkgs.map((p) => `${p}@latest`);
592
+ if (pm === "pnpm") return ["pnpm", ["add", "-D", ...at]];
593
+ if (pm === "yarn") return ["yarn", ["add", "-D", ...at]];
594
+ if (pm === "bun") return ["bun", ["add", "-d", ...at]];
595
+ return ["npm", ["install", "-D", ...at]];
596
+ }
597
+ var cacheFile = () => import_node_path3.default.join(import_node_os2.default.homedir(), ".limno", "update-check.json");
598
+ async function latestVersion() {
599
+ try {
600
+ const cached = JSON.parse((0, import_node_fs2.readFileSync)(cacheFile(), "utf8"));
601
+ if (Date.now() - cached.at < DAY) return cached.latest;
602
+ } catch {
603
+ }
604
+ try {
605
+ const res = await fetch("https://registry.npmjs.org/-/package/@limno/core/dist-tags", { signal: AbortSignal.timeout(4e3) });
606
+ if (!res.ok) return null;
607
+ const latest = (await res.json()).latest ?? null;
608
+ if (latest) {
609
+ (0, import_node_fs2.mkdirSync)(import_node_path3.default.dirname(cacheFile()), { recursive: true });
610
+ (0, import_node_fs2.writeFileSync)(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
611
+ }
612
+ return latest;
613
+ } catch {
614
+ return null;
615
+ }
616
+ }
617
+ function createUpdater(root) {
618
+ let installing = null;
619
+ async function info() {
620
+ const current = currentVersion();
621
+ if (process.env.LIMNO_NO_UPDATE_CHECK) return { current, latest: null, available: false, command: null };
622
+ const latest = await latestVersion();
623
+ const pkgs = listedPackages(root);
624
+ const [cmd, args] = installArgs(packageManager(root), pkgs);
625
+ const available = !!latest && newer(latest, current) && pkgs.length > 0;
626
+ return { current, latest, available, command: available ? `${cmd} ${args.join(" ")}` : null };
627
+ }
628
+ function install() {
629
+ if (installing) return installing;
630
+ const pkgs = listedPackages(root);
631
+ if (!pkgs.length) return Promise.resolve({ ok: false, output: "No @limno packages found in package.json." });
632
+ const [cmd, args] = installArgs(packageManager(root), pkgs);
633
+ installing = new Promise((resolve) => {
634
+ let output = "";
635
+ const child = (0, import_node_child_process2.spawn)(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
636
+ child.stdout?.on("data", (d) => output += d);
637
+ child.stderr?.on("data", (d) => output += d);
638
+ child.on("error", (e) => resolve({ ok: false, output: String(e) }));
639
+ child.on("close", (code) => {
640
+ if (code === 0) {
641
+ try {
642
+ (0, import_node_fs2.writeFileSync)(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
643
+ } catch {
644
+ }
645
+ }
646
+ resolve({ ok: code === 0, output: output.trim().split("\n").slice(-12).join("\n") });
647
+ });
648
+ }).finally(() => {
649
+ installing = null;
650
+ });
651
+ return installing;
652
+ }
653
+ return { info, install };
654
+ }
655
+
656
+ // src/service.ts
657
+ var ROUTE = "/__limno";
658
+ var require3 = (0, import_node_module3.createRequire)(importMetaUrl);
231
659
  var STALE_MS = 3e4;
232
660
  function readJson(file) {
233
661
  try {
234
- return JSON.parse((0, import_node_fs.readFileSync)(file, "utf8"));
662
+ return JSON.parse((0, import_node_fs3.readFileSync)(file, "utf8"));
235
663
  } catch {
236
664
  return null;
237
665
  }
238
666
  }
239
667
  function writeAtomic(file, data) {
240
- (0, import_node_fs.mkdirSync)(import_node_path2.default.dirname(file), { recursive: true });
668
+ (0, import_node_fs3.mkdirSync)(import_node_path4.default.dirname(file), { recursive: true });
241
669
  const tmp = `${file}.${process.pid}.tmp`;
242
- (0, import_node_fs.writeFileSync)(tmp, data);
243
- (0, import_node_fs.renameSync)(tmp, file);
670
+ (0, import_node_fs3.writeFileSync)(tmp, data);
671
+ (0, import_node_fs3.renameSync)(tmp, file);
244
672
  }
245
673
  function body(req) {
246
674
  return new Promise((resolve, reject) => {
@@ -258,12 +686,12 @@ function send(res, status, payload, type = "application/json") {
258
686
  }
259
687
  var hash = (s) => (0, import_node_crypto.createHash)("sha1").update(s).digest("hex");
260
688
  function overlayFile() {
261
- return require2.resolve("@limno/overlay/dist/overlay.js");
689
+ return require3.resolve("@limno/overlay/dist/overlay.js");
262
690
  }
263
691
  function createLimnoService({ root, dir = ".limno" }) {
264
- const folder = import_node_path2.default.join(root, dir);
265
- const file = import_node_path2.default.join(folder, "changeset.json");
266
- const presenceFile = import_node_path2.default.join(folder, "agent.json");
692
+ const folder = import_node_path4.default.join(root, dir);
693
+ const file = import_node_path4.default.join(folder, "changeset.json");
694
+ const presenceFile = import_node_path4.default.join(folder, "agent.json");
267
695
  let lastWrittenHash = "";
268
696
  const clients = /* @__PURE__ */ new Set();
269
697
  function broadcast(event, data) {
@@ -273,6 +701,8 @@ data: ${JSON.stringify(data)}
273
701
  `;
274
702
  for (const c of clients) c.write(msg);
275
703
  }
704
+ const runner = createRunner(root, folder, (state) => broadcast("runner", state));
705
+ const updater = createUpdater(root);
276
706
  function setupInfo() {
277
707
  return { command: "npx", args: ["-y", "@limno/mcp", "--root", root], root };
278
708
  }
@@ -290,23 +720,23 @@ data: ${JSON.stringify(data)}
290
720
  return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
291
721
  }
292
722
  function installConfig(target) {
293
- const dest = target === "claude" ? import_node_path2.default.join(root, ".mcp.json") : import_node_path2.default.join(root, ".cursor", "mcp.json");
723
+ const dest = target === "claude" ? import_node_path4.default.join(root, ".mcp.json") : import_node_path4.default.join(root, ".cursor", "mcp.json");
294
724
  let config = {};
295
- if ((0, import_node_fs.existsSync)(dest)) {
725
+ if ((0, import_node_fs3.existsSync)(dest)) {
296
726
  try {
297
- config = JSON.parse((0, import_node_fs.readFileSync)(dest, "utf8"));
727
+ config = JSON.parse((0, import_node_fs3.readFileSync)(dest, "utf8"));
298
728
  } catch {
299
- throw new Error(`${import_node_path2.default.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
729
+ throw new Error(`${import_node_path4.default.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
300
730
  }
301
731
  }
302
732
  const { command, args } = setupInfo();
303
733
  const portable = args.map((a, i) => {
304
734
  if (args[i - 1] === "--root") return ".";
305
- return import_node_path2.default.isAbsolute(a) ? import_node_path2.default.relative(root, a).split(import_node_path2.default.sep).join("/") : a;
735
+ return import_node_path4.default.isAbsolute(a) ? import_node_path4.default.relative(root, a).split(import_node_path4.default.sep).join("/") : a;
306
736
  });
307
737
  config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
308
738
  writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
309
- return import_node_path2.default.relative(root, dest);
739
+ return import_node_path4.default.relative(root, dest);
310
740
  }
311
741
  let lastAgent = "";
312
742
  function pushAgent() {
@@ -317,27 +747,45 @@ data: ${JSON.stringify(data)}
317
747
  broadcast("agent", status);
318
748
  }
319
749
  function onChangeSetFile() {
320
- const raw = (0, import_node_fs.existsSync)(file) ? (0, import_node_fs.readFileSync)(file, "utf8") : "";
750
+ const raw = (0, import_node_fs3.existsSync)(file) ? (0, import_node_fs3.readFileSync)(file, "utf8") : "";
321
751
  if (!raw || hash(raw) === lastWrittenHash) return;
322
752
  const set = readJson(file);
323
753
  if (set) broadcast("changeset", set);
324
754
  }
325
- (0, import_node_fs.mkdirSync)(folder, { recursive: true });
755
+ (0, import_node_fs3.mkdirSync)(folder, { recursive: true });
326
756
  let watcher = null;
757
+ let watchedIno = -1;
327
758
  let debounce;
328
- try {
329
- watcher = (0, import_node_fs.watch)(folder, (_event, name) => {
330
- if (!name || name.endsWith(".tmp")) return;
331
- clearTimeout(debounce);
332
- debounce = setTimeout(() => {
333
- if (name === "changeset.json") onChangeSetFile();
334
- if (name === "agent.json") pushAgent();
335
- }, 40);
336
- });
337
- watcher.unref?.();
338
- } catch {
759
+ function startWatching() {
760
+ watcher?.close();
761
+ watcher = null;
762
+ try {
763
+ watchedIno = (0, import_node_fs3.statSync)(folder).ino;
764
+ watcher = (0, import_node_fs3.watch)(folder, (_event, name) => {
765
+ if (!name || name.endsWith(".tmp")) return;
766
+ clearTimeout(debounce);
767
+ debounce = setTimeout(() => {
768
+ if (name === "changeset.json") onChangeSetFile();
769
+ if (name === "agent.json") pushAgent();
770
+ }, 40);
771
+ });
772
+ watcher.unref?.();
773
+ } catch {
774
+ }
339
775
  }
340
- const presenceTimer = setInterval(pushAgent, 1e4);
776
+ startWatching();
777
+ const presenceTimer = setInterval(() => {
778
+ let ino = -1;
779
+ try {
780
+ ino = (0, import_node_fs3.statSync)(folder).ino;
781
+ } catch {
782
+ }
783
+ if (ino !== -1 && ino !== watchedIno) {
784
+ startWatching();
785
+ onChangeSetFile();
786
+ }
787
+ pushAgent();
788
+ }, 1e4);
341
789
  presenceTimer.unref();
342
790
  const keepAlive = setInterval(() => {
343
791
  for (const c of clients) c.write(": ping\n\n");
@@ -346,8 +794,8 @@ data: ${JSON.stringify(data)}
346
794
  async function openInEditor(spec) {
347
795
  const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
348
796
  if (!m) return false;
349
- const target = import_node_path2.default.resolve(root, m[1]);
350
- if (!target.startsWith(root + import_node_path2.default.sep) || !(0, import_node_fs.existsSync)(target)) return false;
797
+ const target = import_node_path4.default.resolve(root, m[1]);
798
+ if (!target.startsWith(root + import_node_path4.default.sep) || !(0, import_node_fs3.existsSync)(target)) return false;
351
799
  const { default: launch } = await import("launch-editor");
352
800
  launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
353
801
  return true;
@@ -357,7 +805,7 @@ data: ${JSON.stringify(data)}
357
805
  if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
358
806
  try {
359
807
  if (url === `${ROUTE}/overlay.js`) {
360
- return send(res, 200, (0, import_node_fs.readFileSync)(overlayFile(), "utf8"), "text/javascript");
808
+ return send(res, 200, (0, import_node_fs3.readFileSync)(overlayFile(), "utf8"), "text/javascript");
361
809
  }
362
810
  if (url === `${ROUTE}/events`) {
363
811
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
@@ -394,12 +842,41 @@ data: ${JSON.stringify(data)}
394
842
  return send(res, 409, { error: e instanceof Error ? e.message : String(e) });
395
843
  }
396
844
  }
845
+ if (url === `${ROUTE}/update` && req.method === "GET") {
846
+ return send(res, 200, await updater.info());
847
+ }
848
+ if (url === `${ROUTE}/update` && req.method === "POST") {
849
+ if (!isLocalRequest(req)) return send(res, 403, { error: "Only available from this computer" });
850
+ return send(res, 200, await updater.install());
851
+ }
852
+ if (url === `${ROUTE}/runner` && req.method === "GET") {
853
+ return send(res, 200, await runner.info(new URLSearchParams(query).has("refresh")));
854
+ }
855
+ if (url.startsWith(`${ROUTE}/runner/`) && req.method === "POST") {
856
+ if (!isLocalRequest(req)) return send(res, 403, { error: "Only available from this computer" });
857
+ const action = url.slice(`${ROUTE}/runner/`.length);
858
+ if (action === "allow") {
859
+ const { agent } = JSON.parse(await body(req));
860
+ if (agent !== "claude" && agent !== "codex") return send(res, 400, { error: "unknown agent" });
861
+ runner.allow(agent);
862
+ return send(res, 200, await runner.info());
863
+ }
864
+ if (action === "forget") {
865
+ runner.forget();
866
+ return send(res, 200, await runner.info());
867
+ }
868
+ if (action === "run") return send(res, 200, await runner.start());
869
+ if (action === "stop") {
870
+ runner.stop();
871
+ return send(res, 200, { ok: true });
872
+ }
873
+ }
397
874
  if (url === `${ROUTE}/open`) {
398
875
  const spec = new URLSearchParams(query).get("file") ?? "";
399
876
  return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
400
877
  }
401
878
  if (url === `${ROUTE}/info`) {
402
- return send(res, 200, { name: "limno", root, file: import_node_path2.default.relative(root, file) });
879
+ return send(res, 200, { name: "limno", root, file: import_node_path4.default.relative(root, file) });
403
880
  }
404
881
  send(res, 404, { error: "not found" });
405
882
  } catch (err) {
@@ -411,6 +888,7 @@ data: ${JSON.stringify(data)}
411
888
  handle: (req, res, next) => void handle(req, res, next),
412
889
  close() {
413
890
  watcher?.close();
891
+ runner.close();
414
892
  clearInterval(presenceTimer);
415
893
  clearInterval(keepAlive);
416
894
  for (const c of clients) c.end();
@@ -422,12 +900,12 @@ data: ${JSON.stringify(data)}
422
900
  // src/server.ts
423
901
  var import_node_crypto2 = require("crypto");
424
902
  var import_node_http = __toESM(require("http"), 1);
425
- var LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\]|[\w-]+\.localhost)(:\d+)?$/i;
903
+ var LOOPBACK2 = /^(localhost|127\.0\.0\.1|\[::1\]|[\w-]+\.localhost)(:\d+)?$/i;
426
904
  function allowedOrigin(origin) {
427
905
  if (!origin) return null;
428
906
  try {
429
907
  const u = new URL(origin);
430
- return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK.test(u.host) ? origin : null;
908
+ return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK2.test(u.host) ? origin : null;
431
909
  } catch {
432
910
  return null;
433
911
  }
@@ -457,7 +935,7 @@ function startServer(options) {
457
935
  async function listen(options) {
458
936
  const service = createLimnoService(options);
459
937
  const server = import_node_http.default.createServer((req, res) => {
460
- if (!LOOPBACK.test(req.headers.host ?? "")) {
938
+ if (!LOOPBACK2.test(req.headers.host ?? "")) {
461
939
  res.statusCode = 403;
462
940
  return res.end();
463
941
  }
@@ -525,6 +1003,7 @@ function shouldStamp(file, include = SOURCE_FILES) {
525
1003
  SOURCE_FILES,
526
1004
  TEMPLATE_FILES,
527
1005
  createLimnoService,
1006
+ isLocalRequest,
528
1007
  overlayFile,
529
1008
  shouldStamp,
530
1009
  stampSource,