@limno/core 0.1.2 → 0.1.3

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(path4, fileBase) {
63
+ let p = path4.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(path4) {
106
+ const node = path4.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(path4, 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,345 @@ async function stampTemplate(code, id, relFile, root) {
222
223
 
223
224
  // src/service.ts
224
225
  var import_node_crypto = require("crypto");
225
- var import_node_fs = require("fs");
226
+ var import_node_fs2 = require("fs");
226
227
  var import_node_module2 = require("module");
227
- var import_node_path2 = __toESM(require("path"), 1);
228
+ var import_node_path3 = __toESM(require("path"), 1);
228
229
  var import_spec = require("@limno/spec");
230
+
231
+ // src/runner.ts
232
+ var import_node_child_process = require("child_process");
233
+ var import_node_fs = require("fs");
234
+ var import_node_os = __toESM(require("os"), 1);
235
+ var import_node_path2 = __toESM(require("path"), 1);
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/service.ts
229
550
  var ROUTE = "/__limno";
230
551
  var require2 = (0, import_node_module2.createRequire)(importMetaUrl);
231
552
  var STALE_MS = 3e4;
232
553
  function readJson(file) {
233
554
  try {
234
- return JSON.parse((0, import_node_fs.readFileSync)(file, "utf8"));
555
+ return JSON.parse((0, import_node_fs2.readFileSync)(file, "utf8"));
235
556
  } catch {
236
557
  return null;
237
558
  }
238
559
  }
239
560
  function writeAtomic(file, data) {
240
- (0, import_node_fs.mkdirSync)(import_node_path2.default.dirname(file), { recursive: true });
561
+ (0, import_node_fs2.mkdirSync)(import_node_path3.default.dirname(file), { recursive: true });
241
562
  const tmp = `${file}.${process.pid}.tmp`;
242
- (0, import_node_fs.writeFileSync)(tmp, data);
243
- (0, import_node_fs.renameSync)(tmp, file);
563
+ (0, import_node_fs2.writeFileSync)(tmp, data);
564
+ (0, import_node_fs2.renameSync)(tmp, file);
244
565
  }
245
566
  function body(req) {
246
567
  return new Promise((resolve, reject) => {
@@ -261,9 +582,9 @@ function overlayFile() {
261
582
  return require2.resolve("@limno/overlay/dist/overlay.js");
262
583
  }
263
584
  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");
585
+ const folder = import_node_path3.default.join(root, dir);
586
+ const file = import_node_path3.default.join(folder, "changeset.json");
587
+ const presenceFile = import_node_path3.default.join(folder, "agent.json");
267
588
  let lastWrittenHash = "";
268
589
  const clients = /* @__PURE__ */ new Set();
269
590
  function broadcast(event, data) {
@@ -273,6 +594,7 @@ data: ${JSON.stringify(data)}
273
594
  `;
274
595
  for (const c of clients) c.write(msg);
275
596
  }
597
+ const runner = createRunner(root, folder, (state) => broadcast("runner", state));
276
598
  function setupInfo() {
277
599
  return { command: "npx", args: ["-y", "@limno/mcp", "--root", root], root };
278
600
  }
@@ -290,23 +612,23 @@ data: ${JSON.stringify(data)}
290
612
  return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
291
613
  }
292
614
  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");
615
+ const dest = target === "claude" ? import_node_path3.default.join(root, ".mcp.json") : import_node_path3.default.join(root, ".cursor", "mcp.json");
294
616
  let config = {};
295
- if ((0, import_node_fs.existsSync)(dest)) {
617
+ if ((0, import_node_fs2.existsSync)(dest)) {
296
618
  try {
297
- config = JSON.parse((0, import_node_fs.readFileSync)(dest, "utf8"));
619
+ config = JSON.parse((0, import_node_fs2.readFileSync)(dest, "utf8"));
298
620
  } catch {
299
- throw new Error(`${import_node_path2.default.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
621
+ throw new Error(`${import_node_path3.default.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
300
622
  }
301
623
  }
302
624
  const { command, args } = setupInfo();
303
625
  const portable = args.map((a, i) => {
304
626
  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;
627
+ return import_node_path3.default.isAbsolute(a) ? import_node_path3.default.relative(root, a).split(import_node_path3.default.sep).join("/") : a;
306
628
  });
307
629
  config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
308
630
  writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
309
- return import_node_path2.default.relative(root, dest);
631
+ return import_node_path3.default.relative(root, dest);
310
632
  }
311
633
  let lastAgent = "";
312
634
  function pushAgent() {
@@ -317,16 +639,16 @@ data: ${JSON.stringify(data)}
317
639
  broadcast("agent", status);
318
640
  }
319
641
  function onChangeSetFile() {
320
- const raw = (0, import_node_fs.existsSync)(file) ? (0, import_node_fs.readFileSync)(file, "utf8") : "";
642
+ const raw = (0, import_node_fs2.existsSync)(file) ? (0, import_node_fs2.readFileSync)(file, "utf8") : "";
321
643
  if (!raw || hash(raw) === lastWrittenHash) return;
322
644
  const set = readJson(file);
323
645
  if (set) broadcast("changeset", set);
324
646
  }
325
- (0, import_node_fs.mkdirSync)(folder, { recursive: true });
647
+ (0, import_node_fs2.mkdirSync)(folder, { recursive: true });
326
648
  let watcher = null;
327
649
  let debounce;
328
650
  try {
329
- watcher = (0, import_node_fs.watch)(folder, (_event, name) => {
651
+ watcher = (0, import_node_fs2.watch)(folder, (_event, name) => {
330
652
  if (!name || name.endsWith(".tmp")) return;
331
653
  clearTimeout(debounce);
332
654
  debounce = setTimeout(() => {
@@ -346,8 +668,8 @@ data: ${JSON.stringify(data)}
346
668
  async function openInEditor(spec) {
347
669
  const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
348
670
  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;
671
+ const target = import_node_path3.default.resolve(root, m[1]);
672
+ if (!target.startsWith(root + import_node_path3.default.sep) || !(0, import_node_fs2.existsSync)(target)) return false;
351
673
  const { default: launch } = await import("launch-editor");
352
674
  launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
353
675
  return true;
@@ -357,7 +679,7 @@ data: ${JSON.stringify(data)}
357
679
  if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
358
680
  try {
359
681
  if (url === `${ROUTE}/overlay.js`) {
360
- return send(res, 200, (0, import_node_fs.readFileSync)(overlayFile(), "utf8"), "text/javascript");
682
+ return send(res, 200, (0, import_node_fs2.readFileSync)(overlayFile(), "utf8"), "text/javascript");
361
683
  }
362
684
  if (url === `${ROUTE}/events`) {
363
685
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
@@ -394,12 +716,34 @@ data: ${JSON.stringify(data)}
394
716
  return send(res, 409, { error: e instanceof Error ? e.message : String(e) });
395
717
  }
396
718
  }
719
+ if (url === `${ROUTE}/runner` && req.method === "GET") {
720
+ return send(res, 200, await runner.info(new URLSearchParams(query).has("refresh")));
721
+ }
722
+ if (url.startsWith(`${ROUTE}/runner/`) && req.method === "POST") {
723
+ if (!isLocalRequest(req)) return send(res, 403, { error: "Only available from this computer" });
724
+ const action = url.slice(`${ROUTE}/runner/`.length);
725
+ if (action === "allow") {
726
+ const { agent } = JSON.parse(await body(req));
727
+ if (agent !== "claude" && agent !== "codex") return send(res, 400, { error: "unknown agent" });
728
+ runner.allow(agent);
729
+ return send(res, 200, await runner.info());
730
+ }
731
+ if (action === "forget") {
732
+ runner.forget();
733
+ return send(res, 200, await runner.info());
734
+ }
735
+ if (action === "run") return send(res, 200, await runner.start());
736
+ if (action === "stop") {
737
+ runner.stop();
738
+ return send(res, 200, { ok: true });
739
+ }
740
+ }
397
741
  if (url === `${ROUTE}/open`) {
398
742
  const spec = new URLSearchParams(query).get("file") ?? "";
399
743
  return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
400
744
  }
401
745
  if (url === `${ROUTE}/info`) {
402
- return send(res, 200, { name: "limno", root, file: import_node_path2.default.relative(root, file) });
746
+ return send(res, 200, { name: "limno", root, file: import_node_path3.default.relative(root, file) });
403
747
  }
404
748
  send(res, 404, { error: "not found" });
405
749
  } catch (err) {
@@ -411,6 +755,7 @@ data: ${JSON.stringify(data)}
411
755
  handle: (req, res, next) => void handle(req, res, next),
412
756
  close() {
413
757
  watcher?.close();
758
+ runner.close();
414
759
  clearInterval(presenceTimer);
415
760
  clearInterval(keepAlive);
416
761
  for (const c of clients) c.end();
@@ -422,12 +767,12 @@ data: ${JSON.stringify(data)}
422
767
  // src/server.ts
423
768
  var import_node_crypto2 = require("crypto");
424
769
  var import_node_http = __toESM(require("http"), 1);
425
- var LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\]|[\w-]+\.localhost)(:\d+)?$/i;
770
+ var LOOPBACK2 = /^(localhost|127\.0\.0\.1|\[::1\]|[\w-]+\.localhost)(:\d+)?$/i;
426
771
  function allowedOrigin(origin) {
427
772
  if (!origin) return null;
428
773
  try {
429
774
  const u = new URL(origin);
430
- return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK.test(u.host) ? origin : null;
775
+ return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK2.test(u.host) ? origin : null;
431
776
  } catch {
432
777
  return null;
433
778
  }
@@ -457,7 +802,7 @@ function startServer(options) {
457
802
  async function listen(options) {
458
803
  const service = createLimnoService(options);
459
804
  const server = import_node_http.default.createServer((req, res) => {
460
- if (!LOOPBACK.test(req.headers.host ?? "")) {
805
+ if (!LOOPBACK2.test(req.headers.host ?? "")) {
461
806
  res.statusCode = 403;
462
807
  return res.end();
463
808
  }
@@ -525,6 +870,7 @@ function shouldStamp(file, include = SOURCE_FILES) {
525
870
  SOURCE_FILES,
526
871
  TEMPLATE_FILES,
527
872
  createLimnoService,
873
+ isLocalRequest,
528
874
  overlayFile,
529
875
  shouldStamp,
530
876
  stampSource,
package/dist/index.d.cts CHANGED
@@ -71,4 +71,49 @@ declare const SOURCE_FILES: RegExp;
71
71
  /** True for files that should be stamped: JSX/TSX outside node_modules. */
72
72
  declare function shouldStamp(file: string, include?: RegExp): boolean;
73
73
 
74
- export { type AgentStatus, type LimnoServer, type LimnoService, type LimnoServiceOptions, ROUTE, SOURCE_FILES, TEMPLATE_FILES, createLimnoService, overlayFile, shouldStamp, stampSource, stampTemplate, startServer };
74
+ /**
75
+ * Runs the user's own coding agent in the background when they click Send, so there's nothing to
76
+ * connect and no session to restart. The agent gets Limno's MCP tools for that run only (nothing is
77
+ * written to its config), may read and edit files in the project, and can't run shell commands
78
+ * (Claude Code) or runs them in Codex's workspace sandbox.
79
+ */
80
+ type RunnerAgentId = "claude" | "codex";
81
+ interface RunnerAgent {
82
+ id: RunnerAgentId;
83
+ name: string;
84
+ installed: boolean;
85
+ signedIn: boolean;
86
+ /** What to run in a terminal to sign in, when installed but signed out. */
87
+ signIn: string;
88
+ }
89
+ interface RunnerLogEntry {
90
+ at: number;
91
+ kind: "read" | "edit" | "search" | "limno" | "message" | "error";
92
+ text: string;
93
+ file?: string;
94
+ }
95
+ interface RunnerState {
96
+ status: "idle" | "starting" | "running" | "done" | "failed" | "stopped";
97
+ agent: RunnerAgentId | null;
98
+ startedAt: number | null;
99
+ endedAt: number | null;
100
+ log: RunnerLogEntry[];
101
+ /** Project-relative files the agent edited in this run. */
102
+ files: string[];
103
+ error?: string;
104
+ }
105
+ interface RunnerInfo {
106
+ agents: RunnerAgent[];
107
+ /** The agent this user allowed Limno to run on this project, if any. */
108
+ allowed: RunnerAgentId | null;
109
+ state: RunnerState;
110
+ }
111
+ /**
112
+ * Starting an agent edits files on this machine, so only the user's own browser may ask: the
113
+ * request must come from this computer, to a localhost address, from a localhost page. That stops
114
+ * other devices on the network (a dev server started with --host) and other websites (which can
115
+ * send requests to localhost but always carry their own Origin).
116
+ */
117
+ declare function isLocalRequest(req: IncomingMessage): boolean;
118
+
119
+ export { type AgentStatus, type LimnoServer, type LimnoService, type LimnoServiceOptions, ROUTE, type RunnerAgent, type RunnerAgentId, type RunnerInfo, type RunnerLogEntry, type RunnerState, SOURCE_FILES, TEMPLATE_FILES, createLimnoService, isLocalRequest, overlayFile, shouldStamp, stampSource, stampTemplate, startServer };
package/dist/index.d.ts CHANGED
@@ -71,4 +71,49 @@ declare const SOURCE_FILES: RegExp;
71
71
  /** True for files that should be stamped: JSX/TSX outside node_modules. */
72
72
  declare function shouldStamp(file: string, include?: RegExp): boolean;
73
73
 
74
- export { type AgentStatus, type LimnoServer, type LimnoService, type LimnoServiceOptions, ROUTE, SOURCE_FILES, TEMPLATE_FILES, createLimnoService, overlayFile, shouldStamp, stampSource, stampTemplate, startServer };
74
+ /**
75
+ * Runs the user's own coding agent in the background when they click Send, so there's nothing to
76
+ * connect and no session to restart. The agent gets Limno's MCP tools for that run only (nothing is
77
+ * written to its config), may read and edit files in the project, and can't run shell commands
78
+ * (Claude Code) or runs them in Codex's workspace sandbox.
79
+ */
80
+ type RunnerAgentId = "claude" | "codex";
81
+ interface RunnerAgent {
82
+ id: RunnerAgentId;
83
+ name: string;
84
+ installed: boolean;
85
+ signedIn: boolean;
86
+ /** What to run in a terminal to sign in, when installed but signed out. */
87
+ signIn: string;
88
+ }
89
+ interface RunnerLogEntry {
90
+ at: number;
91
+ kind: "read" | "edit" | "search" | "limno" | "message" | "error";
92
+ text: string;
93
+ file?: string;
94
+ }
95
+ interface RunnerState {
96
+ status: "idle" | "starting" | "running" | "done" | "failed" | "stopped";
97
+ agent: RunnerAgentId | null;
98
+ startedAt: number | null;
99
+ endedAt: number | null;
100
+ log: RunnerLogEntry[];
101
+ /** Project-relative files the agent edited in this run. */
102
+ files: string[];
103
+ error?: string;
104
+ }
105
+ interface RunnerInfo {
106
+ agents: RunnerAgent[];
107
+ /** The agent this user allowed Limno to run on this project, if any. */
108
+ allowed: RunnerAgentId | null;
109
+ state: RunnerState;
110
+ }
111
+ /**
112
+ * Starting an agent edits files on this machine, so only the user's own browser may ask: the
113
+ * request must come from this computer, to a localhost address, from a localhost page. That stops
114
+ * other devices on the network (a dev server started with --host) and other websites (which can
115
+ * send requests to localhost but always carry their own Origin).
116
+ */
117
+ declare function isLocalRequest(req: IncomingMessage): boolean;
118
+
119
+ export { type AgentStatus, type LimnoServer, type LimnoService, type LimnoServiceOptions, ROUTE, type RunnerAgent, type RunnerAgentId, type RunnerInfo, type RunnerLogEntry, type RunnerState, SOURCE_FILES, TEMPLATE_FILES, createLimnoService, isLocalRequest, overlayFile, shouldStamp, stampSource, stampTemplate, startServer };
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(path4, fileBase) {
14
+ let p = path4.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(path4) {
57
+ const node = path4.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(path4, 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,344 @@ 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";
177
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, watch, writeFileSync as writeFileSync2 } from "fs";
178
178
  import { createRequire as createRequire2 } from "module";
179
- import path2 from "path";
179
+ import path3 from "path";
180
180
  import { emptyChangeSet, mergeStatuses, renderPrompt } from "@limno/spec";
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/service.ts
181
501
  var ROUTE = "/__limno";
182
502
  var require2 = createRequire2(import.meta.url);
183
503
  var STALE_MS = 3e4;
184
504
  function readJson(file) {
185
505
  try {
186
- return JSON.parse(readFileSync(file, "utf8"));
506
+ return JSON.parse(readFileSync2(file, "utf8"));
187
507
  } catch {
188
508
  return null;
189
509
  }
190
510
  }
191
511
  function writeAtomic(file, data) {
192
- mkdirSync(path2.dirname(file), { recursive: true });
512
+ mkdirSync2(path3.dirname(file), { recursive: true });
193
513
  const tmp = `${file}.${process.pid}.tmp`;
194
- writeFileSync(tmp, data);
514
+ writeFileSync2(tmp, data);
195
515
  renameSync(tmp, file);
196
516
  }
197
517
  function body(req) {
@@ -213,9 +533,9 @@ function overlayFile() {
213
533
  return require2.resolve("@limno/overlay/dist/overlay.js");
214
534
  }
215
535
  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");
536
+ const folder = path3.join(root, dir);
537
+ const file = path3.join(folder, "changeset.json");
538
+ const presenceFile = path3.join(folder, "agent.json");
219
539
  let lastWrittenHash = "";
220
540
  const clients = /* @__PURE__ */ new Set();
221
541
  function broadcast(event, data) {
@@ -225,6 +545,7 @@ data: ${JSON.stringify(data)}
225
545
  `;
226
546
  for (const c of clients) c.write(msg);
227
547
  }
548
+ const runner = createRunner(root, folder, (state) => broadcast("runner", state));
228
549
  function setupInfo() {
229
550
  return { command: "npx", args: ["-y", "@limno/mcp", "--root", root], root };
230
551
  }
@@ -242,23 +563,23 @@ data: ${JSON.stringify(data)}
242
563
  return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
243
564
  }
244
565
  function installConfig(target) {
245
- const dest = target === "claude" ? path2.join(root, ".mcp.json") : path2.join(root, ".cursor", "mcp.json");
566
+ const dest = target === "claude" ? path3.join(root, ".mcp.json") : path3.join(root, ".cursor", "mcp.json");
246
567
  let config = {};
247
568
  if (existsSync(dest)) {
248
569
  try {
249
- config = JSON.parse(readFileSync(dest, "utf8"));
570
+ config = JSON.parse(readFileSync2(dest, "utf8"));
250
571
  } catch {
251
- throw new Error(`${path2.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
572
+ throw new Error(`${path3.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
252
573
  }
253
574
  }
254
575
  const { command, args } = setupInfo();
255
576
  const portable = args.map((a, i) => {
256
577
  if (args[i - 1] === "--root") return ".";
257
- return path2.isAbsolute(a) ? path2.relative(root, a).split(path2.sep).join("/") : a;
578
+ return path3.isAbsolute(a) ? path3.relative(root, a).split(path3.sep).join("/") : a;
258
579
  });
259
580
  config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
260
581
  writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
261
- return path2.relative(root, dest);
582
+ return path3.relative(root, dest);
262
583
  }
263
584
  let lastAgent = "";
264
585
  function pushAgent() {
@@ -269,12 +590,12 @@ data: ${JSON.stringify(data)}
269
590
  broadcast("agent", status);
270
591
  }
271
592
  function onChangeSetFile() {
272
- const raw = existsSync(file) ? readFileSync(file, "utf8") : "";
593
+ const raw = existsSync(file) ? readFileSync2(file, "utf8") : "";
273
594
  if (!raw || hash(raw) === lastWrittenHash) return;
274
595
  const set = readJson(file);
275
596
  if (set) broadcast("changeset", set);
276
597
  }
277
- mkdirSync(folder, { recursive: true });
598
+ mkdirSync2(folder, { recursive: true });
278
599
  let watcher = null;
279
600
  let debounce;
280
601
  try {
@@ -298,8 +619,8 @@ data: ${JSON.stringify(data)}
298
619
  async function openInEditor(spec) {
299
620
  const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
300
621
  if (!m) return false;
301
- const target = path2.resolve(root, m[1]);
302
- if (!target.startsWith(root + path2.sep) || !existsSync(target)) return false;
622
+ const target = path3.resolve(root, m[1]);
623
+ if (!target.startsWith(root + path3.sep) || !existsSync(target)) return false;
303
624
  const { default: launch } = await import("launch-editor");
304
625
  launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
305
626
  return true;
@@ -309,7 +630,7 @@ data: ${JSON.stringify(data)}
309
630
  if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
310
631
  try {
311
632
  if (url === `${ROUTE}/overlay.js`) {
312
- return send(res, 200, readFileSync(overlayFile(), "utf8"), "text/javascript");
633
+ return send(res, 200, readFileSync2(overlayFile(), "utf8"), "text/javascript");
313
634
  }
314
635
  if (url === `${ROUTE}/events`) {
315
636
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
@@ -346,12 +667,34 @@ data: ${JSON.stringify(data)}
346
667
  return send(res, 409, { error: e instanceof Error ? e.message : String(e) });
347
668
  }
348
669
  }
670
+ if (url === `${ROUTE}/runner` && req.method === "GET") {
671
+ return send(res, 200, await runner.info(new URLSearchParams(query).has("refresh")));
672
+ }
673
+ if (url.startsWith(`${ROUTE}/runner/`) && req.method === "POST") {
674
+ if (!isLocalRequest(req)) return send(res, 403, { error: "Only available from this computer" });
675
+ const action = url.slice(`${ROUTE}/runner/`.length);
676
+ if (action === "allow") {
677
+ const { agent } = JSON.parse(await body(req));
678
+ if (agent !== "claude" && agent !== "codex") return send(res, 400, { error: "unknown agent" });
679
+ runner.allow(agent);
680
+ return send(res, 200, await runner.info());
681
+ }
682
+ if (action === "forget") {
683
+ runner.forget();
684
+ return send(res, 200, await runner.info());
685
+ }
686
+ if (action === "run") return send(res, 200, await runner.start());
687
+ if (action === "stop") {
688
+ runner.stop();
689
+ return send(res, 200, { ok: true });
690
+ }
691
+ }
349
692
  if (url === `${ROUTE}/open`) {
350
693
  const spec = new URLSearchParams(query).get("file") ?? "";
351
694
  return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
352
695
  }
353
696
  if (url === `${ROUTE}/info`) {
354
- return send(res, 200, { name: "limno", root, file: path2.relative(root, file) });
697
+ return send(res, 200, { name: "limno", root, file: path3.relative(root, file) });
355
698
  }
356
699
  send(res, 404, { error: "not found" });
357
700
  } catch (err) {
@@ -363,6 +706,7 @@ data: ${JSON.stringify(data)}
363
706
  handle: (req, res, next) => void handle(req, res, next),
364
707
  close() {
365
708
  watcher?.close();
709
+ runner.close();
366
710
  clearInterval(presenceTimer);
367
711
  clearInterval(keepAlive);
368
712
  for (const c of clients) c.end();
@@ -374,12 +718,12 @@ data: ${JSON.stringify(data)}
374
718
  // src/server.ts
375
719
  import { createHash as createHash2 } from "crypto";
376
720
  import http from "http";
377
- var LOOPBACK = /^(localhost|127\.0\.0\.1|\[::1\]|[\w-]+\.localhost)(:\d+)?$/i;
721
+ var LOOPBACK2 = /^(localhost|127\.0\.0\.1|\[::1\]|[\w-]+\.localhost)(:\d+)?$/i;
378
722
  function allowedOrigin(origin) {
379
723
  if (!origin) return null;
380
724
  try {
381
725
  const u = new URL(origin);
382
- return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK.test(u.host) ? origin : null;
726
+ return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK2.test(u.host) ? origin : null;
383
727
  } catch {
384
728
  return null;
385
729
  }
@@ -409,7 +753,7 @@ function startServer(options) {
409
753
  async function listen(options) {
410
754
  const service = createLimnoService(options);
411
755
  const server = http.createServer((req, res) => {
412
- if (!LOOPBACK.test(req.headers.host ?? "")) {
756
+ if (!LOOPBACK2.test(req.headers.host ?? "")) {
413
757
  res.statusCode = 403;
414
758
  return res.end();
415
759
  }
@@ -476,6 +820,7 @@ export {
476
820
  SOURCE_FILES,
477
821
  TEMPLATE_FILES,
478
822
  createLimnoService,
823
+ isLocalRequest,
479
824
  overlayFile,
480
825
  shouldStamp,
481
826
  stampSource,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limno/core",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Limno's framework-neutral dev tooling: JSX source stamping, the change-set store and the endpoints the editor talks to.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "type": "module",
@@ -43,7 +43,7 @@
43
43
  "@babel/parser": "^7.29.0",
44
44
  "@babel/traverse": "^7.29.0",
45
45
  "@limno/mcp": "0.1.1",
46
- "@limno/overlay": "0.1.2",
46
+ "@limno/overlay": "0.1.3",
47
47
  "@limno/spec": "0.1.1",
48
48
  "launch-editor": "^2.14.1",
49
49
  "magic-string": "^0.30.17"