@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 +376 -30
- package/dist/index.d.cts +46 -1
- package/dist/index.d.ts +46 -1
- package/dist/index.js +372 -27
- package/package.json +2 -2
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(
|
|
62
|
-
let p =
|
|
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(
|
|
105
|
-
const 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(
|
|
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
|
|
226
|
+
var import_node_fs2 = require("fs");
|
|
226
227
|
var import_node_module2 = require("module");
|
|
227
|
-
var
|
|
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,
|
|
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,
|
|
561
|
+
(0, import_node_fs2.mkdirSync)(import_node_path3.default.dirname(file), { recursive: true });
|
|
241
562
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
242
|
-
(0,
|
|
243
|
-
(0,
|
|
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 =
|
|
265
|
-
const file =
|
|
266
|
-
const presenceFile =
|
|
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" ?
|
|
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,
|
|
617
|
+
if ((0, import_node_fs2.existsSync)(dest)) {
|
|
296
618
|
try {
|
|
297
|
-
config = JSON.parse((0,
|
|
619
|
+
config = JSON.parse((0, import_node_fs2.readFileSync)(dest, "utf8"));
|
|
298
620
|
} catch {
|
|
299
|
-
throw new Error(`${
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
647
|
+
(0, import_node_fs2.mkdirSync)(folder, { recursive: true });
|
|
326
648
|
let watcher = null;
|
|
327
649
|
let debounce;
|
|
328
650
|
try {
|
|
329
|
-
watcher = (0,
|
|
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 =
|
|
350
|
-
if (!target.startsWith(root +
|
|
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,
|
|
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:
|
|
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
|
|
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:") &&
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
14
|
-
let p =
|
|
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(
|
|
57
|
-
const 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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
512
|
+
mkdirSync2(path3.dirname(file), { recursive: true });
|
|
193
513
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
194
|
-
|
|
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 =
|
|
217
|
-
const file =
|
|
218
|
-
const presenceFile =
|
|
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" ?
|
|
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(
|
|
570
|
+
config = JSON.parse(readFileSync2(dest, "utf8"));
|
|
250
571
|
} catch {
|
|
251
|
-
throw new Error(`${
|
|
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
|
|
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
|
|
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) ?
|
|
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
|
-
|
|
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 =
|
|
302
|
-
if (!target.startsWith(root +
|
|
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,
|
|
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:
|
|
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
|
|
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:") &&
|
|
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 (!
|
|
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.
|
|
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.
|
|
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"
|