@limno/core 0.1.1 → 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/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @limno/core
2
+
3
+ Limno's framework-neutral dev tooling: JSX source stamping, the change-set store and the endpoints the editor talks to.
4
+
5
+ An internal part of Limno, installed for you by the framework packages. To add Limno to an app, run `npx @limno/cli init`.
6
+
7
+ [Limno](https://limno.vercel.app) lets you fix UI visually in your running app and hands the exact change to your coding agent.
8
+
9
+ FSL-1.1-MIT. See [LICENSE.md](./LICENSE.md).
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,18 +594,9 @@ 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
- let cli = null;
278
- for (const from of [import_node_path2.default.join(root, "package.json"), importMetaUrl]) {
279
- try {
280
- cli = import_node_path2.default.join(import_node_path2.default.dirname((0, import_node_module2.createRequire)(from).resolve("@limno/mcp/package.json")), "dist", "cli.js");
281
- if ((0, import_node_fs.existsSync)(cli)) break;
282
- cli = null;
283
- } catch {
284
- cli = null;
285
- }
286
- }
287
- return cli ? { command: "node", args: [cli, "--root", root], root } : { command: "npx", args: ["-y", "@limno/mcp", "--root", root], root };
599
+ return { command: "npx", args: ["-y", "@limno/mcp", "--root", root], root };
288
600
  }
289
601
  function agentStatus() {
290
602
  const presence = readJson(presenceFile);
@@ -300,23 +612,23 @@ data: ${JSON.stringify(data)}
300
612
  return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
301
613
  }
302
614
  function installConfig(target) {
303
- 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");
304
616
  let config = {};
305
- if ((0, import_node_fs.existsSync)(dest)) {
617
+ if ((0, import_node_fs2.existsSync)(dest)) {
306
618
  try {
307
- config = JSON.parse((0, import_node_fs.readFileSync)(dest, "utf8"));
619
+ config = JSON.parse((0, import_node_fs2.readFileSync)(dest, "utf8"));
308
620
  } catch {
309
- 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.`);
310
622
  }
311
623
  }
312
624
  const { command, args } = setupInfo();
313
625
  const portable = args.map((a, i) => {
314
626
  if (args[i - 1] === "--root") return ".";
315
- 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;
316
628
  });
317
629
  config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
318
630
  writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
319
- return import_node_path2.default.relative(root, dest);
631
+ return import_node_path3.default.relative(root, dest);
320
632
  }
321
633
  let lastAgent = "";
322
634
  function pushAgent() {
@@ -327,16 +639,16 @@ data: ${JSON.stringify(data)}
327
639
  broadcast("agent", status);
328
640
  }
329
641
  function onChangeSetFile() {
330
- 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") : "";
331
643
  if (!raw || hash(raw) === lastWrittenHash) return;
332
644
  const set = readJson(file);
333
645
  if (set) broadcast("changeset", set);
334
646
  }
335
- (0, import_node_fs.mkdirSync)(folder, { recursive: true });
647
+ (0, import_node_fs2.mkdirSync)(folder, { recursive: true });
336
648
  let watcher = null;
337
649
  let debounce;
338
650
  try {
339
- watcher = (0, import_node_fs.watch)(folder, (_event, name) => {
651
+ watcher = (0, import_node_fs2.watch)(folder, (_event, name) => {
340
652
  if (!name || name.endsWith(".tmp")) return;
341
653
  clearTimeout(debounce);
342
654
  debounce = setTimeout(() => {
@@ -356,8 +668,8 @@ data: ${JSON.stringify(data)}
356
668
  async function openInEditor(spec) {
357
669
  const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
358
670
  if (!m) return false;
359
- const target = import_node_path2.default.resolve(root, m[1]);
360
- 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;
361
673
  const { default: launch } = await import("launch-editor");
362
674
  launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
363
675
  return true;
@@ -367,7 +679,7 @@ data: ${JSON.stringify(data)}
367
679
  if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
368
680
  try {
369
681
  if (url === `${ROUTE}/overlay.js`) {
370
- 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");
371
683
  }
372
684
  if (url === `${ROUTE}/events`) {
373
685
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
@@ -404,12 +716,34 @@ data: ${JSON.stringify(data)}
404
716
  return send(res, 409, { error: e instanceof Error ? e.message : String(e) });
405
717
  }
406
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
+ }
407
741
  if (url === `${ROUTE}/open`) {
408
742
  const spec = new URLSearchParams(query).get("file") ?? "";
409
743
  return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
410
744
  }
411
745
  if (url === `${ROUTE}/info`) {
412
- 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) });
413
747
  }
414
748
  send(res, 404, { error: "not found" });
415
749
  } catch (err) {
@@ -421,6 +755,7 @@ data: ${JSON.stringify(data)}
421
755
  handle: (req, res, next) => void handle(req, res, next),
422
756
  close() {
423
757
  watcher?.close();
758
+ runner.close();
424
759
  clearInterval(presenceTimer);
425
760
  clearInterval(keepAlive);
426
761
  for (const c of clients) c.end();
@@ -432,12 +767,12 @@ data: ${JSON.stringify(data)}
432
767
  // src/server.ts
433
768
  var import_node_crypto2 = require("crypto");
434
769
  var import_node_http = __toESM(require("http"), 1);
435
- 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;
436
771
  function allowedOrigin(origin) {
437
772
  if (!origin) return null;
438
773
  try {
439
774
  const u = new URL(origin);
440
- 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;
441
776
  } catch {
442
777
  return null;
443
778
  }
@@ -467,7 +802,7 @@ function startServer(options) {
467
802
  async function listen(options) {
468
803
  const service = createLimnoService(options);
469
804
  const server = import_node_http.default.createServer((req, res) => {
470
- if (!LOOPBACK.test(req.headers.host ?? "")) {
805
+ if (!LOOPBACK2.test(req.headers.host ?? "")) {
471
806
  res.statusCode = 403;
472
807
  return res.end();
473
808
  }
@@ -535,6 +870,7 @@ function shouldStamp(file, include = SOURCE_FILES) {
535
870
  SOURCE_FILES,
536
871
  TEMPLATE_FILES,
537
872
  createLimnoService,
873
+ isLocalRequest,
538
874
  overlayFile,
539
875
  shouldStamp,
540
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,18 +545,9 @@ 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
- let cli = null;
230
- for (const from of [path2.join(root, "package.json"), import.meta.url]) {
231
- try {
232
- cli = path2.join(path2.dirname(createRequire2(from).resolve("@limno/mcp/package.json")), "dist", "cli.js");
233
- if (existsSync(cli)) break;
234
- cli = null;
235
- } catch {
236
- cli = null;
237
- }
238
- }
239
- return cli ? { command: "node", args: [cli, "--root", root], root } : { command: "npx", args: ["-y", "@limno/mcp", "--root", root], root };
550
+ return { command: "npx", args: ["-y", "@limno/mcp", "--root", root], root };
240
551
  }
241
552
  function agentStatus() {
242
553
  const presence = readJson(presenceFile);
@@ -252,23 +563,23 @@ data: ${JSON.stringify(data)}
252
563
  return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
253
564
  }
254
565
  function installConfig(target) {
255
- 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");
256
567
  let config = {};
257
568
  if (existsSync(dest)) {
258
569
  try {
259
- config = JSON.parse(readFileSync(dest, "utf8"));
570
+ config = JSON.parse(readFileSync2(dest, "utf8"));
260
571
  } catch {
261
- 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.`);
262
573
  }
263
574
  }
264
575
  const { command, args } = setupInfo();
265
576
  const portable = args.map((a, i) => {
266
577
  if (args[i - 1] === "--root") return ".";
267
- 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;
268
579
  });
269
580
  config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
270
581
  writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
271
- return path2.relative(root, dest);
582
+ return path3.relative(root, dest);
272
583
  }
273
584
  let lastAgent = "";
274
585
  function pushAgent() {
@@ -279,12 +590,12 @@ data: ${JSON.stringify(data)}
279
590
  broadcast("agent", status);
280
591
  }
281
592
  function onChangeSetFile() {
282
- const raw = existsSync(file) ? readFileSync(file, "utf8") : "";
593
+ const raw = existsSync(file) ? readFileSync2(file, "utf8") : "";
283
594
  if (!raw || hash(raw) === lastWrittenHash) return;
284
595
  const set = readJson(file);
285
596
  if (set) broadcast("changeset", set);
286
597
  }
287
- mkdirSync(folder, { recursive: true });
598
+ mkdirSync2(folder, { recursive: true });
288
599
  let watcher = null;
289
600
  let debounce;
290
601
  try {
@@ -308,8 +619,8 @@ data: ${JSON.stringify(data)}
308
619
  async function openInEditor(spec) {
309
620
  const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
310
621
  if (!m) return false;
311
- const target = path2.resolve(root, m[1]);
312
- 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;
313
624
  const { default: launch } = await import("launch-editor");
314
625
  launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
315
626
  return true;
@@ -319,7 +630,7 @@ data: ${JSON.stringify(data)}
319
630
  if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
320
631
  try {
321
632
  if (url === `${ROUTE}/overlay.js`) {
322
- return send(res, 200, readFileSync(overlayFile(), "utf8"), "text/javascript");
633
+ return send(res, 200, readFileSync2(overlayFile(), "utf8"), "text/javascript");
323
634
  }
324
635
  if (url === `${ROUTE}/events`) {
325
636
  res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
@@ -356,12 +667,34 @@ data: ${JSON.stringify(data)}
356
667
  return send(res, 409, { error: e instanceof Error ? e.message : String(e) });
357
668
  }
358
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
+ }
359
692
  if (url === `${ROUTE}/open`) {
360
693
  const spec = new URLSearchParams(query).get("file") ?? "";
361
694
  return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
362
695
  }
363
696
  if (url === `${ROUTE}/info`) {
364
- 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) });
365
698
  }
366
699
  send(res, 404, { error: "not found" });
367
700
  } catch (err) {
@@ -373,6 +706,7 @@ data: ${JSON.stringify(data)}
373
706
  handle: (req, res, next) => void handle(req, res, next),
374
707
  close() {
375
708
  watcher?.close();
709
+ runner.close();
376
710
  clearInterval(presenceTimer);
377
711
  clearInterval(keepAlive);
378
712
  for (const c of clients) c.end();
@@ -384,12 +718,12 @@ data: ${JSON.stringify(data)}
384
718
  // src/server.ts
385
719
  import { createHash as createHash2 } from "crypto";
386
720
  import http from "http";
387
- 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;
388
722
  function allowedOrigin(origin) {
389
723
  if (!origin) return null;
390
724
  try {
391
725
  const u = new URL(origin);
392
- 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;
393
727
  } catch {
394
728
  return null;
395
729
  }
@@ -419,7 +753,7 @@ function startServer(options) {
419
753
  async function listen(options) {
420
754
  const service = createLimnoService(options);
421
755
  const server = http.createServer((req, res) => {
422
- if (!LOOPBACK.test(req.headers.host ?? "")) {
756
+ if (!LOOPBACK2.test(req.headers.host ?? "")) {
423
757
  res.statusCode = 403;
424
758
  return res.end();
425
759
  }
@@ -486,6 +820,7 @@ export {
486
820
  SOURCE_FILES,
487
821
  TEMPLATE_FILES,
488
822
  createLimnoService,
823
+ isLocalRequest,
489
824
  overlayFile,
490
825
  shouldStamp,
491
826
  stampSource,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limno/core",
3
- "version": "0.1.1",
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",
@@ -42,9 +42,9 @@
42
42
  "dependencies": {
43
43
  "@babel/parser": "^7.29.0",
44
44
  "@babel/traverse": "^7.29.0",
45
- "@limno/mcp": "0.1.0",
46
- "@limno/overlay": "0.1.1",
47
- "@limno/spec": "0.1.0",
45
+ "@limno/mcp": "0.1.1",
46
+ "@limno/overlay": "0.1.3",
47
+ "@limno/spec": "0.1.1",
48
48
  "launch-editor": "^2.14.1",
49
49
  "magic-string": "^0.30.17"
50
50
  },