@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.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(path3, fileBase) {
14
- let p = path3.getFunctionParent();
13
+ function componentName(path5, fileBase) {
14
+ let p = path5.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(path3) {
57
- const node = path3.node;
56
+ JSXOpeningElement(path5) {
57
+ const node = path5.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(path3, fileBase);
65
+ const comp = componentName(path5, 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,24 +174,451 @@ async function stampTemplate(code, id, relFile, root) {
174
174
 
175
175
  // src/service.ts
176
176
  import { createHash } from "crypto";
177
- import { existsSync, mkdirSync, readFileSync, renameSync, watch, writeFileSync } from "fs";
178
- import { createRequire as createRequire2 } from "module";
179
- import path2 from "path";
177
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, renameSync, statSync, watch, writeFileSync as writeFileSync3 } from "fs";
178
+ import { createRequire as createRequire3 } from "module";
179
+ import path4 from "path";
180
180
  import { emptyChangeSet, mergeStatuses, renderPrompt } from "@limno/spec";
181
- var ROUTE = "/__limno";
181
+
182
+ // src/runner.ts
183
+ import { spawn } from "child_process";
184
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
185
+ import os from "os";
186
+ import path2 from "path";
187
+ var NAMES = { claude: "Claude Code", codex: "Codex" };
188
+ var SIGN_IN = { claude: "claude auth login", codex: "codex login" };
189
+ var PROMPT = [
190
+ "You were started by Limno, a visual editor, because the designer clicked Send.",
191
+ "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
+ "Report progress with limno_update_status, and answer comments with limno_reply.",
193
+ "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."
195
+ ].join(" ");
196
+ var trustFile = () => path2.join(os.homedir(), ".limno", "trust.json");
197
+ function projectKey(root) {
198
+ try {
199
+ return realpathSync(root);
200
+ } catch {
201
+ return root;
202
+ }
203
+ }
204
+ function readTrust() {
205
+ try {
206
+ return JSON.parse(readFileSync(trustFile(), "utf8")).projects ?? {};
207
+ } catch {
208
+ return {};
209
+ }
210
+ }
211
+ function writeTrust(projects) {
212
+ mkdirSync(path2.dirname(trustFile()), { recursive: true });
213
+ writeFileSync(trustFile(), `${JSON.stringify({ projects }, null, 2)}
214
+ `);
215
+ }
216
+ function run(cmd, args, timeoutMs = 8e3) {
217
+ return new Promise((resolve) => {
218
+ let out = "";
219
+ let child;
220
+ try {
221
+ child = spawn(cmd, args, { env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
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
+ function agentEnv() {
239
+ const env = { ...process.env };
240
+ delete env.CLAUDECODE;
241
+ return env;
242
+ }
243
+ async function detectAgent(id) {
244
+ const base = { id, name: NAMES[id], signIn: SIGN_IN[id] };
245
+ const version = await run(id, ["--version"]);
246
+ if (version.code !== 0) return { ...base, installed: false, signedIn: false };
247
+ const status = id === "claude" ? await run("claude", ["auth", "status"]) : await run("codex", ["login", "status"]);
248
+ return { ...base, installed: true, signedIn: status.code === 0 };
249
+ }
250
+ var detected = null;
251
+ async function detectAgents(force = false) {
252
+ if (!force && detected && Date.now() - detected.at < 2e4) return detected.agents;
253
+ const agents = await Promise.all(["claude", "codex"].map(detectAgent));
254
+ detected = { at: Date.now(), agents };
255
+ return agents;
256
+ }
257
+ var LOOPBACK = /^(localhost|127(?:\.\d+){3}|\[?::1\]?|[a-z0-9-]+\.localhost)$/i;
258
+ function isLocalRequest(req) {
259
+ const remote = req.socket.remoteAddress ?? "";
260
+ if (!/^(127\.|::1$|::ffff:127\.)/.test(remote)) return false;
261
+ const host = (req.headers.host ?? "").replace(/:\d+$/, "");
262
+ if (!LOOPBACK.test(host)) return false;
263
+ const origin = req.headers.origin;
264
+ if (origin) {
265
+ try {
266
+ if (!LOOPBACK.test(new URL(origin).hostname)) return false;
267
+ } catch {
268
+ return false;
269
+ }
270
+ }
271
+ return true;
272
+ }
273
+ function createRunner(root, folder, onState) {
274
+ const sessionsFile = path2.join(folder, "runner.json");
275
+ let child = null;
276
+ let again = false;
277
+ let state = { status: "idle", agent: null, startedAt: null, endedAt: null, log: [], files: [] };
278
+ const sessions = () => {
279
+ try {
280
+ return JSON.parse(readFileSync(sessionsFile, "utf8"));
281
+ } catch {
282
+ return {};
283
+ }
284
+ };
285
+ const saveSession = (agent, id) => {
286
+ mkdirSync(folder, { recursive: true });
287
+ writeFileSync(sessionsFile, `${JSON.stringify({ ...sessions(), [agent]: id }, null, 2)}
288
+ `);
289
+ };
290
+ let emitTimer;
291
+ const emit = (now = false) => {
292
+ clearTimeout(emitTimer);
293
+ if (now) onState(state);
294
+ else emitTimer = setTimeout(() => onState(state), 120);
295
+ };
296
+ const log = (entry) => {
297
+ const last = state.log[state.log.length - 1];
298
+ if (last && last.text === entry.text) return;
299
+ state.log = [...state.log.slice(-60), { at: Date.now(), ...entry }];
300
+ if (entry.kind === "edit" && entry.file && !state.files.includes(entry.file)) state.files = [...state.files, entry.file];
301
+ emit();
302
+ };
303
+ const rel = (p) => {
304
+ if (!p) return "";
305
+ const abs = path2.isAbsolute(p) ? p : path2.join(root, p);
306
+ return path2.relative(root, abs) || p;
307
+ };
308
+ const key = projectKey(root);
309
+ const allowed = () => readTrust()[key]?.agent ?? null;
310
+ function limnoLine(tool, input = {}) {
311
+ if (tool.endsWith("get_changes")) return "Reading your changes";
312
+ if (tool.endsWith("update_status")) {
313
+ const n = Array.isArray(input.ids) ? input.ids.length : 0;
314
+ const status = String(input.status ?? "");
315
+ 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
+ }
317
+ if (tool.endsWith("reply")) return "Replied to your comment";
318
+ return "Checking Limno";
319
+ }
320
+ function onClaudeEvent(e) {
321
+ if (e.session_id) saveSession("claude", e.session_id);
322
+ if (e.type === "system" && e.subtype === "init") {
323
+ const limno = (e.mcp_servers ?? []).find((s) => s.name === "limno");
324
+ if (limno && limno.status !== "connected") log({ kind: "error", text: "Limno\u2019s tools didn\u2019t start for the agent" });
325
+ return;
326
+ }
327
+ if (e.type === "assistant") {
328
+ for (const block of e.message?.content ?? []) {
329
+ if (block.type === "tool_use") {
330
+ const name = String(block.name);
331
+ const input = block.input ?? {};
332
+ if (name.startsWith("mcp__limno__")) log({ kind: "limno", text: limnoLine(name, input) });
333
+ else if (name === "Read") log({ kind: "read", text: `Reading ${rel(input.file_path)}`, file: rel(input.file_path) });
334
+ else if (name === "Edit" || name === "MultiEdit" || name === "Write") log({ kind: "edit", text: `Editing ${rel(input.file_path)}`, file: rel(input.file_path) });
335
+ else if (name === "Glob" || name === "Grep") log({ kind: "search", text: "Searching the project" });
336
+ } else if (block.type === "text" && String(block.text).trim()) {
337
+ log({ kind: "message", text: String(block.text).trim().split("\n")[0].slice(0, 220) });
338
+ }
339
+ }
340
+ return;
341
+ }
342
+ if (e.type === "result") {
343
+ if (e.is_error) state.error = String(e.result ?? e.subtype ?? "The agent stopped with an error").slice(0, 300);
344
+ }
345
+ }
346
+ function onCodexEvent(e) {
347
+ if (e.type === "thread.started" && e.thread_id) return saveSession("codex", e.thread_id);
348
+ if (e.type === "error" || e.type === "turn.failed") {
349
+ state.error = String(e.message ?? e.error?.message ?? "The agent stopped with an error").slice(0, 300);
350
+ return;
351
+ }
352
+ if (e.type !== "item.started" && e.type !== "item.completed") return;
353
+ const item = e.item ?? {};
354
+ if (item.type === "mcp_tool_call" && e.type === "item.started") log({ kind: "limno", text: limnoLine(String(item.tool ?? ""), item.arguments ?? {}) });
355
+ else if (item.type === "file_change" && e.type === "item.completed") {
356
+ for (const c of item.changes ?? []) log({ kind: "edit", text: `Editing ${rel(c.path)}`, file: rel(c.path) });
357
+ } 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) });
359
+ } else if (item.type === "command_execution" && e.type === "item.started") {
360
+ log({ kind: "search", text: "Looking through the project" });
361
+ }
362
+ }
363
+ function args(agent, resume) {
364
+ const server = { command: "npx", args: ["-y", "@limno/mcp", "--root", root] };
365
+ if (agent === "claude") {
366
+ return [
367
+ "-p",
368
+ PROMPT,
369
+ "--output-format",
370
+ "stream-json",
371
+ "--verbose",
372
+ "--mcp-config",
373
+ JSON.stringify({ mcpServers: { limno: server } }),
374
+ "--strict-mcp-config",
375
+ // Anything not listed is refused without a prompt: no shell, no web.
376
+ "--permission-mode",
377
+ "dontAsk",
378
+ "--allowedTools",
379
+ "Read",
380
+ "Edit",
381
+ "MultiEdit",
382
+ "Write",
383
+ "Glob",
384
+ "Grep",
385
+ "mcp__limno__limno_get_changes",
386
+ "mcp__limno__limno_update_status",
387
+ "mcp__limno__limno_reply",
388
+ "mcp__limno__limno_get_status",
389
+ ...resume ? ["--resume", resume] : []
390
+ ];
391
+ }
392
+ const opts = [
393
+ "--json",
394
+ "--sandbox",
395
+ "workspace-write",
396
+ "--skip-git-repo-check",
397
+ "-c",
398
+ `mcp_servers.limno.command=${JSON.stringify(server.command)}`,
399
+ "-c",
400
+ `mcp_servers.limno.args=${JSON.stringify(server.args)}`
401
+ ];
402
+ return resume ? ["exec", ...opts, "resume", resume, PROMPT] : ["exec", ...opts, PROMPT];
403
+ }
404
+ function launch(agent, resume) {
405
+ const proc = spawn(agent, args(agent, resume), { cwd: root, env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
406
+ child = proc;
407
+ let buf = "";
408
+ let stderr = "";
409
+ let sawEvent = false;
410
+ proc.stdout?.on("data", (d) => {
411
+ buf += d;
412
+ const lines = buf.split("\n");
413
+ buf = lines.pop() ?? "";
414
+ for (const line of lines) {
415
+ if (!line.trim()) continue;
416
+ try {
417
+ const e = JSON.parse(line);
418
+ sawEvent = true;
419
+ if (state.status === "starting") state.status = "running";
420
+ if (agent === "claude") onClaudeEvent(e);
421
+ else onCodexEvent(e);
422
+ emit();
423
+ } catch {
424
+ }
425
+ }
426
+ });
427
+ proc.stderr?.on("data", (d) => stderr = (stderr + d).slice(-2e3));
428
+ proc.on("error", (err) => {
429
+ state = { ...state, status: "failed", endedAt: Date.now(), error: `Couldn\u2019t start ${NAMES[agent]}: ${err.message}` };
430
+ child = null;
431
+ emit(true);
432
+ });
433
+ proc.on("close", (code, signal) => {
434
+ if (child !== proc) return;
435
+ child = null;
436
+ if (resume && code !== 0 && !sawEvent) {
437
+ launch(agent);
438
+ return;
439
+ }
440
+ if (state.status === "stopped") return emit(true);
441
+ const failed = code !== 0 || !!state.error;
442
+ const tail = stderr.trim().split("\n").filter(Boolean).slice(-2).join(" ").slice(0, 300);
443
+ state = {
444
+ ...state,
445
+ status: failed ? "failed" : "done",
446
+ endedAt: Date.now(),
447
+ error: failed ? state.error ?? (tail || `${NAMES[agent]} exited${signal ? ` (${signal})` : ` with code ${code}`}`) : void 0
448
+ };
449
+ emit(true);
450
+ if (again && !failed) {
451
+ again = false;
452
+ void start();
453
+ }
454
+ again = false;
455
+ });
456
+ }
457
+ async function start() {
458
+ if (child) {
459
+ again = true;
460
+ return { ok: true, queued: true };
461
+ }
462
+ const agent = allowed();
463
+ if (!agent) return { ok: false, error: "not-allowed" };
464
+ const info = (await detectAgents(true)).find((a) => a.id === agent);
465
+ if (!info?.installed) return { ok: false, error: `${NAMES[agent]} isn\u2019t installed on this computer.` };
466
+ 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
+ state = { status: "starting", agent, startedAt: Date.now(), endedAt: null, log: [], files: [] };
468
+ emit(true);
469
+ launch(agent, sessions()[agent]);
470
+ return { ok: true };
471
+ }
472
+ return {
473
+ async info(force) {
474
+ return { agents: await detectAgents(force), allowed: allowed(), state };
475
+ },
476
+ allow(agent) {
477
+ writeTrust({ ...readTrust(), [key]: { agent, at: Date.now() } });
478
+ },
479
+ forget() {
480
+ const t = readTrust();
481
+ delete t[key];
482
+ writeTrust(t);
483
+ },
484
+ start,
485
+ stop() {
486
+ if (!child) return;
487
+ again = false;
488
+ state = { ...state, status: "stopped", endedAt: Date.now() };
489
+ child.kill("SIGINT");
490
+ const c = child;
491
+ setTimeout(() => c.exitCode === null && c.kill("SIGTERM"), 3e3).unref();
492
+ emit(true);
493
+ },
494
+ close() {
495
+ child?.kill("SIGTERM");
496
+ }
497
+ };
498
+ }
499
+
500
+ // 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";
503
+ import { createRequire as createRequire2 } from "module";
504
+ import os2 from "os";
505
+ import path3 from "path";
182
506
  var require2 = createRequire2(import.meta.url);
507
+ var DAY = 24 * 60 * 60 * 1e3;
508
+ function currentVersion() {
509
+ try {
510
+ return require2("../package.json").version;
511
+ } catch {
512
+ return "0.0.0";
513
+ }
514
+ }
515
+ var newer = (a, b) => {
516
+ const pa = a.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
517
+ const pb = b.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
518
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
519
+ return false;
520
+ };
521
+ function packageManager(root) {
522
+ let dir = root;
523
+ for (; ; ) {
524
+ if (existsSync(path3.join(dir, "pnpm-lock.yaml"))) return "pnpm";
525
+ if (existsSync(path3.join(dir, "yarn.lock"))) return "yarn";
526
+ if (existsSync(path3.join(dir, "bun.lockb")) || existsSync(path3.join(dir, "bun.lock"))) return "bun";
527
+ if (existsSync(path3.join(dir, "package-lock.json"))) return "npm";
528
+ const up = path3.dirname(dir);
529
+ if (up === dir) return "npm";
530
+ dir = up;
531
+ }
532
+ }
533
+ function listedPackages(root) {
534
+ try {
535
+ const pkg = JSON.parse(readFileSync2(path3.join(root, "package.json"), "utf8"));
536
+ return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((n) => n.startsWith("@limno/") && n !== "@limno/cli");
537
+ } catch {
538
+ return [];
539
+ }
540
+ }
541
+ function installArgs(pm, pkgs) {
542
+ const at = pkgs.map((p) => `${p}@latest`);
543
+ if (pm === "pnpm") return ["pnpm", ["add", "-D", ...at]];
544
+ if (pm === "yarn") return ["yarn", ["add", "-D", ...at]];
545
+ if (pm === "bun") return ["bun", ["add", "-d", ...at]];
546
+ return ["npm", ["install", "-D", ...at]];
547
+ }
548
+ var cacheFile = () => path3.join(os2.homedir(), ".limno", "update-check.json");
549
+ async function latestVersion() {
550
+ try {
551
+ const cached = JSON.parse(readFileSync2(cacheFile(), "utf8"));
552
+ if (Date.now() - cached.at < DAY) return cached.latest;
553
+ } catch {
554
+ }
555
+ try {
556
+ const res = await fetch("https://registry.npmjs.org/-/package/@limno/core/dist-tags", { signal: AbortSignal.timeout(4e3) });
557
+ if (!res.ok) return null;
558
+ const latest = (await res.json()).latest ?? null;
559
+ if (latest) {
560
+ mkdirSync2(path3.dirname(cacheFile()), { recursive: true });
561
+ writeFileSync2(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
562
+ }
563
+ return latest;
564
+ } catch {
565
+ return null;
566
+ }
567
+ }
568
+ function createUpdater(root) {
569
+ let installing = null;
570
+ async function info() {
571
+ const current = currentVersion();
572
+ if (process.env.LIMNO_NO_UPDATE_CHECK) return { current, latest: null, available: false, command: null };
573
+ const latest = await latestVersion();
574
+ const pkgs = listedPackages(root);
575
+ const [cmd, args] = installArgs(packageManager(root), pkgs);
576
+ const available = !!latest && newer(latest, current) && pkgs.length > 0;
577
+ return { current, latest, available, command: available ? `${cmd} ${args.join(" ")}` : null };
578
+ }
579
+ function install() {
580
+ if (installing) return installing;
581
+ const pkgs = listedPackages(root);
582
+ if (!pkgs.length) return Promise.resolve({ ok: false, output: "No @limno packages found in package.json." });
583
+ const [cmd, args] = installArgs(packageManager(root), pkgs);
584
+ installing = new Promise((resolve) => {
585
+ let output = "";
586
+ const child = spawn2(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
587
+ child.stdout?.on("data", (d) => output += d);
588
+ child.stderr?.on("data", (d) => output += d);
589
+ child.on("error", (e) => resolve({ ok: false, output: String(e) }));
590
+ child.on("close", (code) => {
591
+ if (code === 0) {
592
+ try {
593
+ writeFileSync2(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
594
+ } catch {
595
+ }
596
+ }
597
+ resolve({ ok: code === 0, output: output.trim().split("\n").slice(-12).join("\n") });
598
+ });
599
+ }).finally(() => {
600
+ installing = null;
601
+ });
602
+ return installing;
603
+ }
604
+ return { info, install };
605
+ }
606
+
607
+ // src/service.ts
608
+ var ROUTE = "/__limno";
609
+ var require3 = createRequire3(import.meta.url);
183
610
  var STALE_MS = 3e4;
184
611
  function readJson(file) {
185
612
  try {
186
- return JSON.parse(readFileSync(file, "utf8"));
613
+ return JSON.parse(readFileSync3(file, "utf8"));
187
614
  } catch {
188
615
  return null;
189
616
  }
190
617
  }
191
618
  function writeAtomic(file, data) {
192
- mkdirSync(path2.dirname(file), { recursive: true });
619
+ mkdirSync3(path4.dirname(file), { recursive: true });
193
620
  const tmp = `${file}.${process.pid}.tmp`;
194
- writeFileSync(tmp, data);
621
+ writeFileSync3(tmp, data);
195
622
  renameSync(tmp, file);
196
623
  }
197
624
  function body(req) {
@@ -210,12 +637,12 @@ function send(res, status, payload, type = "application/json") {
210
637
  }
211
638
  var hash = (s) => createHash("sha1").update(s).digest("hex");
212
639
  function overlayFile() {
213
- return require2.resolve("@limno/overlay/dist/overlay.js");
640
+ return require3.resolve("@limno/overlay/dist/overlay.js");
214
641
  }
215
642
  function createLimnoService({ root, dir = ".limno" }) {
216
- const folder = path2.join(root, dir);
217
- const file = path2.join(folder, "changeset.json");
218
- const presenceFile = path2.join(folder, "agent.json");
643
+ const folder = path4.join(root, dir);
644
+ const file = path4.join(folder, "changeset.json");
645
+ const presenceFile = path4.join(folder, "agent.json");
219
646
  let lastWrittenHash = "";
220
647
  const clients = /* @__PURE__ */ new Set();
221
648
  function broadcast(event, data) {
@@ -225,6 +652,8 @@ data: ${JSON.stringify(data)}
225
652
  `;
226
653
  for (const c of clients) c.write(msg);
227
654
  }
655
+ const runner = createRunner(root, folder, (state) => broadcast("runner", state));
656
+ const updater = createUpdater(root);
228
657
  function setupInfo() {
229
658
  return { command: "npx", args: ["-y", "@limno/mcp", "--root", root], root };
230
659
  }
@@ -242,23 +671,23 @@ data: ${JSON.stringify(data)}
242
671
  return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
243
672
  }
244
673
  function installConfig(target) {
245
- const dest = target === "claude" ? path2.join(root, ".mcp.json") : path2.join(root, ".cursor", "mcp.json");
674
+ const dest = target === "claude" ? path4.join(root, ".mcp.json") : path4.join(root, ".cursor", "mcp.json");
246
675
  let config = {};
247
- if (existsSync(dest)) {
676
+ if (existsSync2(dest)) {
248
677
  try {
249
- config = JSON.parse(readFileSync(dest, "utf8"));
678
+ config = JSON.parse(readFileSync3(dest, "utf8"));
250
679
  } catch {
251
- throw new Error(`${path2.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
680
+ throw new Error(`${path4.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
252
681
  }
253
682
  }
254
683
  const { command, args } = setupInfo();
255
684
  const portable = args.map((a, i) => {
256
685
  if (args[i - 1] === "--root") return ".";
257
- return path2.isAbsolute(a) ? path2.relative(root, a).split(path2.sep).join("/") : a;
686
+ return path4.isAbsolute(a) ? path4.relative(root, a).split(path4.sep).join("/") : a;
258
687
  });
259
688
  config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
260
689
  writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
261
- return path2.relative(root, dest);
690
+ return path4.relative(root, dest);
262
691
  }
263
692
  let lastAgent = "";
264
693
  function pushAgent() {
@@ -269,27 +698,45 @@ data: ${JSON.stringify(data)}
269
698
  broadcast("agent", status);
270
699
  }
271
700
  function onChangeSetFile() {
272
- const raw = existsSync(file) ? readFileSync(file, "utf8") : "";
701
+ const raw = existsSync2(file) ? readFileSync3(file, "utf8") : "";
273
702
  if (!raw || hash(raw) === lastWrittenHash) return;
274
703
  const set = readJson(file);
275
704
  if (set) broadcast("changeset", set);
276
705
  }
277
- mkdirSync(folder, { recursive: true });
706
+ mkdirSync3(folder, { recursive: true });
278
707
  let watcher = null;
708
+ let watchedIno = -1;
279
709
  let debounce;
280
- try {
281
- watcher = watch(folder, (_event, name) => {
282
- if (!name || name.endsWith(".tmp")) return;
283
- clearTimeout(debounce);
284
- debounce = setTimeout(() => {
285
- if (name === "changeset.json") onChangeSetFile();
286
- if (name === "agent.json") pushAgent();
287
- }, 40);
288
- });
289
- watcher.unref?.();
290
- } catch {
710
+ function startWatching() {
711
+ watcher?.close();
712
+ watcher = null;
713
+ try {
714
+ watchedIno = statSync(folder).ino;
715
+ watcher = watch(folder, (_event, name) => {
716
+ if (!name || name.endsWith(".tmp")) return;
717
+ clearTimeout(debounce);
718
+ debounce = setTimeout(() => {
719
+ if (name === "changeset.json") onChangeSetFile();
720
+ if (name === "agent.json") pushAgent();
721
+ }, 40);
722
+ });
723
+ watcher.unref?.();
724
+ } catch {
725
+ }
291
726
  }
292
- const presenceTimer = setInterval(pushAgent, 1e4);
727
+ startWatching();
728
+ const presenceTimer = setInterval(() => {
729
+ let ino = -1;
730
+ try {
731
+ ino = statSync(folder).ino;
732
+ } catch {
733
+ }
734
+ if (ino !== -1 && ino !== watchedIno) {
735
+ startWatching();
736
+ onChangeSetFile();
737
+ }
738
+ pushAgent();
739
+ }, 1e4);
293
740
  presenceTimer.unref();
294
741
  const keepAlive = setInterval(() => {
295
742
  for (const c of clients) c.write(": ping\n\n");
@@ -298,8 +745,8 @@ data: ${JSON.stringify(data)}
298
745
  async function openInEditor(spec) {
299
746
  const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
300
747
  if (!m) return false;
301
- const target = path2.resolve(root, m[1]);
302
- if (!target.startsWith(root + path2.sep) || !existsSync(target)) return false;
748
+ const target = path4.resolve(root, m[1]);
749
+ if (!target.startsWith(root + path4.sep) || !existsSync2(target)) return false;
303
750
  const { default: launch } = await import("launch-editor");
304
751
  launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
305
752
  return true;
@@ -309,7 +756,7 @@ data: ${JSON.stringify(data)}
309
756
  if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
310
757
  try {
311
758
  if (url === `${ROUTE}/overlay.js`) {
312
- return send(res, 200, readFileSync(overlayFile(), "utf8"), "text/javascript");
759
+ return send(res, 200, readFileSync3(overlayFile(), "utf8"), "text/javascript");
313
760
  }
314
761
  if (url === `${ROUTE}/events`) {
315
762
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
@@ -346,12 +793,41 @@ data: ${JSON.stringify(data)}
346
793
  return send(res, 409, { error: e instanceof Error ? e.message : String(e) });
347
794
  }
348
795
  }
796
+ if (url === `${ROUTE}/update` && req.method === "GET") {
797
+ return send(res, 200, await updater.info());
798
+ }
799
+ if (url === `${ROUTE}/update` && req.method === "POST") {
800
+ if (!isLocalRequest(req)) return send(res, 403, { error: "Only available from this computer" });
801
+ return send(res, 200, await updater.install());
802
+ }
803
+ if (url === `${ROUTE}/runner` && req.method === "GET") {
804
+ return send(res, 200, await runner.info(new URLSearchParams(query).has("refresh")));
805
+ }
806
+ if (url.startsWith(`${ROUTE}/runner/`) && req.method === "POST") {
807
+ if (!isLocalRequest(req)) return send(res, 403, { error: "Only available from this computer" });
808
+ const action = url.slice(`${ROUTE}/runner/`.length);
809
+ if (action === "allow") {
810
+ const { agent } = JSON.parse(await body(req));
811
+ if (agent !== "claude" && agent !== "codex") return send(res, 400, { error: "unknown agent" });
812
+ runner.allow(agent);
813
+ return send(res, 200, await runner.info());
814
+ }
815
+ if (action === "forget") {
816
+ runner.forget();
817
+ return send(res, 200, await runner.info());
818
+ }
819
+ if (action === "run") return send(res, 200, await runner.start());
820
+ if (action === "stop") {
821
+ runner.stop();
822
+ return send(res, 200, { ok: true });
823
+ }
824
+ }
349
825
  if (url === `${ROUTE}/open`) {
350
826
  const spec = new URLSearchParams(query).get("file") ?? "";
351
827
  return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
352
828
  }
353
829
  if (url === `${ROUTE}/info`) {
354
- return send(res, 200, { name: "limno", root, file: path2.relative(root, file) });
830
+ return send(res, 200, { name: "limno", root, file: path4.relative(root, file) });
355
831
  }
356
832
  send(res, 404, { error: "not found" });
357
833
  } catch (err) {
@@ -363,6 +839,7 @@ data: ${JSON.stringify(data)}
363
839
  handle: (req, res, next) => void handle(req, res, next),
364
840
  close() {
365
841
  watcher?.close();
842
+ runner.close();
366
843
  clearInterval(presenceTimer);
367
844
  clearInterval(keepAlive);
368
845
  for (const c of clients) c.end();
@@ -374,12 +851,12 @@ data: ${JSON.stringify(data)}
374
851
  // src/server.ts
375
852
  import { createHash as createHash2 } from "crypto";
376
853
  import http from "http";
377
- var LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\]|[\w-]+\.localhost)(:\d+)?$/i;
854
+ var LOOPBACK2 = /^(localhost|127\.0\.0\.1|\[::1\]|[\w-]+\.localhost)(:\d+)?$/i;
378
855
  function allowedOrigin(origin) {
379
856
  if (!origin) return null;
380
857
  try {
381
858
  const u = new URL(origin);
382
- return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK.test(u.host) ? origin : null;
859
+ return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK2.test(u.host) ? origin : null;
383
860
  } catch {
384
861
  return null;
385
862
  }
@@ -409,7 +886,7 @@ function startServer(options) {
409
886
  async function listen(options) {
410
887
  const service = createLimnoService(options);
411
888
  const server = http.createServer((req, res) => {
412
- if (!LOOPBACK.test(req.headers.host ?? "")) {
889
+ if (!LOOPBACK2.test(req.headers.host ?? "")) {
413
890
  res.statusCode = 403;
414
891
  return res.end();
415
892
  }
@@ -476,6 +953,7 @@ export {
476
953
  SOURCE_FILES,
477
954
  TEMPLATE_FILES,
478
955
  createLimnoService,
956
+ isLocalRequest,
479
957
  overlayFile,
480
958
  shouldStamp,
481
959
  stampSource,