@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 +9 -0
- package/dist/index.cjs +377 -41
- package/dist/index.d.cts +46 -1
- package/dist/index.d.ts +46 -1
- package/dist/index.js +373 -38
- package/package.json +4 -4
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(
|
|
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,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
|
-
|
|
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" ?
|
|
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,
|
|
617
|
+
if ((0, import_node_fs2.existsSync)(dest)) {
|
|
306
618
|
try {
|
|
307
|
-
config = JSON.parse((0,
|
|
619
|
+
config = JSON.parse((0, import_node_fs2.readFileSync)(dest, "utf8"));
|
|
308
620
|
} catch {
|
|
309
|
-
throw new Error(`${
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
647
|
+
(0, import_node_fs2.mkdirSync)(folder, { recursive: true });
|
|
336
648
|
let watcher = null;
|
|
337
649
|
let debounce;
|
|
338
650
|
try {
|
|
339
|
-
watcher = (0,
|
|
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 =
|
|
360
|
-
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;
|
|
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,
|
|
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:
|
|
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
|
|
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:") &&
|
|
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 (!
|
|
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
|
-
|
|
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,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
|
-
|
|
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" ?
|
|
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(
|
|
570
|
+
config = JSON.parse(readFileSync2(dest, "utf8"));
|
|
260
571
|
} catch {
|
|
261
|
-
throw new Error(`${
|
|
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
|
|
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
|
|
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) ?
|
|
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
|
-
|
|
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 =
|
|
312
|
-
if (!target.startsWith(root +
|
|
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,
|
|
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:
|
|
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
|
|
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:") &&
|
|
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 (!
|
|
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.
|
|
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.
|
|
46
|
-
"@limno/overlay": "0.1.
|
|
47
|
-
"@limno/spec": "0.1.
|
|
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
|
},
|