@limno/core 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +522 -43
- package/dist/index.d.cts +46 -1
- package/dist/index.d.ts +46 -1
- package/dist/index.js +521 -43
- package/package.json +4 -4
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(path5, fileBase) {
|
|
63
|
+
let p = path5.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(path5) {
|
|
106
|
+
const node = path5.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(path5, 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,452 @@ async function stampTemplate(code, id, relFile, root) {
|
|
|
222
223
|
|
|
223
224
|
// src/service.ts
|
|
224
225
|
var import_node_crypto = require("crypto");
|
|
226
|
+
var import_node_fs3 = require("fs");
|
|
227
|
+
var import_node_module3 = require("module");
|
|
228
|
+
var import_node_path4 = __toESM(require("path"), 1);
|
|
229
|
+
var import_spec = require("@limno/spec");
|
|
230
|
+
|
|
231
|
+
// src/runner.ts
|
|
232
|
+
var import_node_child_process = require("child_process");
|
|
225
233
|
var import_node_fs = require("fs");
|
|
226
|
-
var
|
|
234
|
+
var import_node_os = __toESM(require("os"), 1);
|
|
227
235
|
var import_node_path2 = __toESM(require("path"), 1);
|
|
228
|
-
var
|
|
229
|
-
var
|
|
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/update.ts
|
|
550
|
+
var import_node_child_process2 = require("child_process");
|
|
551
|
+
var import_node_fs2 = require("fs");
|
|
552
|
+
var import_node_module2 = require("module");
|
|
553
|
+
var import_node_os2 = __toESM(require("os"), 1);
|
|
554
|
+
var import_node_path3 = __toESM(require("path"), 1);
|
|
230
555
|
var require2 = (0, import_node_module2.createRequire)(importMetaUrl);
|
|
556
|
+
var DAY = 24 * 60 * 60 * 1e3;
|
|
557
|
+
function currentVersion() {
|
|
558
|
+
try {
|
|
559
|
+
return require2("../package.json").version;
|
|
560
|
+
} catch {
|
|
561
|
+
return "0.0.0";
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
var newer = (a, b) => {
|
|
565
|
+
const pa = a.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
|
|
566
|
+
const pb = b.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
|
|
567
|
+
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
568
|
+
return false;
|
|
569
|
+
};
|
|
570
|
+
function packageManager(root) {
|
|
571
|
+
let dir = root;
|
|
572
|
+
for (; ; ) {
|
|
573
|
+
if ((0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
574
|
+
if ((0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "yarn.lock"))) return "yarn";
|
|
575
|
+
if ((0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "bun.lockb")) || (0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "bun.lock"))) return "bun";
|
|
576
|
+
if ((0, import_node_fs2.existsSync)(import_node_path3.default.join(dir, "package-lock.json"))) return "npm";
|
|
577
|
+
const up = import_node_path3.default.dirname(dir);
|
|
578
|
+
if (up === dir) return "npm";
|
|
579
|
+
dir = up;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
function listedPackages(root) {
|
|
583
|
+
try {
|
|
584
|
+
const pkg = JSON.parse((0, import_node_fs2.readFileSync)(import_node_path3.default.join(root, "package.json"), "utf8"));
|
|
585
|
+
return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((n) => n.startsWith("@limno/") && n !== "@limno/cli");
|
|
586
|
+
} catch {
|
|
587
|
+
return [];
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function installArgs(pm, pkgs) {
|
|
591
|
+
const at = pkgs.map((p) => `${p}@latest`);
|
|
592
|
+
if (pm === "pnpm") return ["pnpm", ["add", "-D", ...at]];
|
|
593
|
+
if (pm === "yarn") return ["yarn", ["add", "-D", ...at]];
|
|
594
|
+
if (pm === "bun") return ["bun", ["add", "-d", ...at]];
|
|
595
|
+
return ["npm", ["install", "-D", ...at]];
|
|
596
|
+
}
|
|
597
|
+
var cacheFile = () => import_node_path3.default.join(import_node_os2.default.homedir(), ".limno", "update-check.json");
|
|
598
|
+
async function latestVersion() {
|
|
599
|
+
try {
|
|
600
|
+
const cached = JSON.parse((0, import_node_fs2.readFileSync)(cacheFile(), "utf8"));
|
|
601
|
+
if (Date.now() - cached.at < DAY) return cached.latest;
|
|
602
|
+
} catch {
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
const res = await fetch("https://registry.npmjs.org/-/package/@limno/core/dist-tags", { signal: AbortSignal.timeout(4e3) });
|
|
606
|
+
if (!res.ok) return null;
|
|
607
|
+
const latest = (await res.json()).latest ?? null;
|
|
608
|
+
if (latest) {
|
|
609
|
+
(0, import_node_fs2.mkdirSync)(import_node_path3.default.dirname(cacheFile()), { recursive: true });
|
|
610
|
+
(0, import_node_fs2.writeFileSync)(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
|
|
611
|
+
}
|
|
612
|
+
return latest;
|
|
613
|
+
} catch {
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
function createUpdater(root) {
|
|
618
|
+
let installing = null;
|
|
619
|
+
async function info() {
|
|
620
|
+
const current = currentVersion();
|
|
621
|
+
if (process.env.LIMNO_NO_UPDATE_CHECK) return { current, latest: null, available: false, command: null };
|
|
622
|
+
const latest = await latestVersion();
|
|
623
|
+
const pkgs = listedPackages(root);
|
|
624
|
+
const [cmd, args] = installArgs(packageManager(root), pkgs);
|
|
625
|
+
const available = !!latest && newer(latest, current) && pkgs.length > 0;
|
|
626
|
+
return { current, latest, available, command: available ? `${cmd} ${args.join(" ")}` : null };
|
|
627
|
+
}
|
|
628
|
+
function install() {
|
|
629
|
+
if (installing) return installing;
|
|
630
|
+
const pkgs = listedPackages(root);
|
|
631
|
+
if (!pkgs.length) return Promise.resolve({ ok: false, output: "No @limno packages found in package.json." });
|
|
632
|
+
const [cmd, args] = installArgs(packageManager(root), pkgs);
|
|
633
|
+
installing = new Promise((resolve) => {
|
|
634
|
+
let output = "";
|
|
635
|
+
const child = (0, import_node_child_process2.spawn)(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
|
|
636
|
+
child.stdout?.on("data", (d) => output += d);
|
|
637
|
+
child.stderr?.on("data", (d) => output += d);
|
|
638
|
+
child.on("error", (e) => resolve({ ok: false, output: String(e) }));
|
|
639
|
+
child.on("close", (code) => {
|
|
640
|
+
if (code === 0) {
|
|
641
|
+
try {
|
|
642
|
+
(0, import_node_fs2.writeFileSync)(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
|
|
643
|
+
} catch {
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
resolve({ ok: code === 0, output: output.trim().split("\n").slice(-12).join("\n") });
|
|
647
|
+
});
|
|
648
|
+
}).finally(() => {
|
|
649
|
+
installing = null;
|
|
650
|
+
});
|
|
651
|
+
return installing;
|
|
652
|
+
}
|
|
653
|
+
return { info, install };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// src/service.ts
|
|
657
|
+
var ROUTE = "/__limno";
|
|
658
|
+
var require3 = (0, import_node_module3.createRequire)(importMetaUrl);
|
|
231
659
|
var STALE_MS = 3e4;
|
|
232
660
|
function readJson(file) {
|
|
233
661
|
try {
|
|
234
|
-
return JSON.parse((0,
|
|
662
|
+
return JSON.parse((0, import_node_fs3.readFileSync)(file, "utf8"));
|
|
235
663
|
} catch {
|
|
236
664
|
return null;
|
|
237
665
|
}
|
|
238
666
|
}
|
|
239
667
|
function writeAtomic(file, data) {
|
|
240
|
-
(0,
|
|
668
|
+
(0, import_node_fs3.mkdirSync)(import_node_path4.default.dirname(file), { recursive: true });
|
|
241
669
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
242
|
-
(0,
|
|
243
|
-
(0,
|
|
670
|
+
(0, import_node_fs3.writeFileSync)(tmp, data);
|
|
671
|
+
(0, import_node_fs3.renameSync)(tmp, file);
|
|
244
672
|
}
|
|
245
673
|
function body(req) {
|
|
246
674
|
return new Promise((resolve, reject) => {
|
|
@@ -258,12 +686,12 @@ function send(res, status, payload, type = "application/json") {
|
|
|
258
686
|
}
|
|
259
687
|
var hash = (s) => (0, import_node_crypto.createHash)("sha1").update(s).digest("hex");
|
|
260
688
|
function overlayFile() {
|
|
261
|
-
return
|
|
689
|
+
return require3.resolve("@limno/overlay/dist/overlay.js");
|
|
262
690
|
}
|
|
263
691
|
function createLimnoService({ root, dir = ".limno" }) {
|
|
264
|
-
const folder =
|
|
265
|
-
const file =
|
|
266
|
-
const presenceFile =
|
|
692
|
+
const folder = import_node_path4.default.join(root, dir);
|
|
693
|
+
const file = import_node_path4.default.join(folder, "changeset.json");
|
|
694
|
+
const presenceFile = import_node_path4.default.join(folder, "agent.json");
|
|
267
695
|
let lastWrittenHash = "";
|
|
268
696
|
const clients = /* @__PURE__ */ new Set();
|
|
269
697
|
function broadcast(event, data) {
|
|
@@ -273,6 +701,8 @@ data: ${JSON.stringify(data)}
|
|
|
273
701
|
`;
|
|
274
702
|
for (const c of clients) c.write(msg);
|
|
275
703
|
}
|
|
704
|
+
const runner = createRunner(root, folder, (state) => broadcast("runner", state));
|
|
705
|
+
const updater = createUpdater(root);
|
|
276
706
|
function setupInfo() {
|
|
277
707
|
return { command: "npx", args: ["-y", "@limno/mcp", "--root", root], root };
|
|
278
708
|
}
|
|
@@ -290,23 +720,23 @@ data: ${JSON.stringify(data)}
|
|
|
290
720
|
return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
|
|
291
721
|
}
|
|
292
722
|
function installConfig(target) {
|
|
293
|
-
const dest = target === "claude" ?
|
|
723
|
+
const dest = target === "claude" ? import_node_path4.default.join(root, ".mcp.json") : import_node_path4.default.join(root, ".cursor", "mcp.json");
|
|
294
724
|
let config = {};
|
|
295
|
-
if ((0,
|
|
725
|
+
if ((0, import_node_fs3.existsSync)(dest)) {
|
|
296
726
|
try {
|
|
297
|
-
config = JSON.parse((0,
|
|
727
|
+
config = JSON.parse((0, import_node_fs3.readFileSync)(dest, "utf8"));
|
|
298
728
|
} catch {
|
|
299
|
-
throw new Error(`${
|
|
729
|
+
throw new Error(`${import_node_path4.default.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
|
|
300
730
|
}
|
|
301
731
|
}
|
|
302
732
|
const { command, args } = setupInfo();
|
|
303
733
|
const portable = args.map((a, i) => {
|
|
304
734
|
if (args[i - 1] === "--root") return ".";
|
|
305
|
-
return
|
|
735
|
+
return import_node_path4.default.isAbsolute(a) ? import_node_path4.default.relative(root, a).split(import_node_path4.default.sep).join("/") : a;
|
|
306
736
|
});
|
|
307
737
|
config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
|
|
308
738
|
writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
|
|
309
|
-
return
|
|
739
|
+
return import_node_path4.default.relative(root, dest);
|
|
310
740
|
}
|
|
311
741
|
let lastAgent = "";
|
|
312
742
|
function pushAgent() {
|
|
@@ -317,27 +747,45 @@ data: ${JSON.stringify(data)}
|
|
|
317
747
|
broadcast("agent", status);
|
|
318
748
|
}
|
|
319
749
|
function onChangeSetFile() {
|
|
320
|
-
const raw = (0,
|
|
750
|
+
const raw = (0, import_node_fs3.existsSync)(file) ? (0, import_node_fs3.readFileSync)(file, "utf8") : "";
|
|
321
751
|
if (!raw || hash(raw) === lastWrittenHash) return;
|
|
322
752
|
const set = readJson(file);
|
|
323
753
|
if (set) broadcast("changeset", set);
|
|
324
754
|
}
|
|
325
|
-
(0,
|
|
755
|
+
(0, import_node_fs3.mkdirSync)(folder, { recursive: true });
|
|
326
756
|
let watcher = null;
|
|
757
|
+
let watchedIno = -1;
|
|
327
758
|
let debounce;
|
|
328
|
-
|
|
329
|
-
watcher
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (name
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
759
|
+
function startWatching() {
|
|
760
|
+
watcher?.close();
|
|
761
|
+
watcher = null;
|
|
762
|
+
try {
|
|
763
|
+
watchedIno = (0, import_node_fs3.statSync)(folder).ino;
|
|
764
|
+
watcher = (0, import_node_fs3.watch)(folder, (_event, name) => {
|
|
765
|
+
if (!name || name.endsWith(".tmp")) return;
|
|
766
|
+
clearTimeout(debounce);
|
|
767
|
+
debounce = setTimeout(() => {
|
|
768
|
+
if (name === "changeset.json") onChangeSetFile();
|
|
769
|
+
if (name === "agent.json") pushAgent();
|
|
770
|
+
}, 40);
|
|
771
|
+
});
|
|
772
|
+
watcher.unref?.();
|
|
773
|
+
} catch {
|
|
774
|
+
}
|
|
339
775
|
}
|
|
340
|
-
|
|
776
|
+
startWatching();
|
|
777
|
+
const presenceTimer = setInterval(() => {
|
|
778
|
+
let ino = -1;
|
|
779
|
+
try {
|
|
780
|
+
ino = (0, import_node_fs3.statSync)(folder).ino;
|
|
781
|
+
} catch {
|
|
782
|
+
}
|
|
783
|
+
if (ino !== -1 && ino !== watchedIno) {
|
|
784
|
+
startWatching();
|
|
785
|
+
onChangeSetFile();
|
|
786
|
+
}
|
|
787
|
+
pushAgent();
|
|
788
|
+
}, 1e4);
|
|
341
789
|
presenceTimer.unref();
|
|
342
790
|
const keepAlive = setInterval(() => {
|
|
343
791
|
for (const c of clients) c.write(": ping\n\n");
|
|
@@ -346,8 +794,8 @@ data: ${JSON.stringify(data)}
|
|
|
346
794
|
async function openInEditor(spec) {
|
|
347
795
|
const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
|
|
348
796
|
if (!m) return false;
|
|
349
|
-
const target =
|
|
350
|
-
if (!target.startsWith(root +
|
|
797
|
+
const target = import_node_path4.default.resolve(root, m[1]);
|
|
798
|
+
if (!target.startsWith(root + import_node_path4.default.sep) || !(0, import_node_fs3.existsSync)(target)) return false;
|
|
351
799
|
const { default: launch } = await import("launch-editor");
|
|
352
800
|
launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
|
|
353
801
|
return true;
|
|
@@ -357,7 +805,7 @@ data: ${JSON.stringify(data)}
|
|
|
357
805
|
if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
|
|
358
806
|
try {
|
|
359
807
|
if (url === `${ROUTE}/overlay.js`) {
|
|
360
|
-
return send(res, 200, (0,
|
|
808
|
+
return send(res, 200, (0, import_node_fs3.readFileSync)(overlayFile(), "utf8"), "text/javascript");
|
|
361
809
|
}
|
|
362
810
|
if (url === `${ROUTE}/events`) {
|
|
363
811
|
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
|
|
@@ -394,12 +842,41 @@ data: ${JSON.stringify(data)}
|
|
|
394
842
|
return send(res, 409, { error: e instanceof Error ? e.message : String(e) });
|
|
395
843
|
}
|
|
396
844
|
}
|
|
845
|
+
if (url === `${ROUTE}/update` && req.method === "GET") {
|
|
846
|
+
return send(res, 200, await updater.info());
|
|
847
|
+
}
|
|
848
|
+
if (url === `${ROUTE}/update` && req.method === "POST") {
|
|
849
|
+
if (!isLocalRequest(req)) return send(res, 403, { error: "Only available from this computer" });
|
|
850
|
+
return send(res, 200, await updater.install());
|
|
851
|
+
}
|
|
852
|
+
if (url === `${ROUTE}/runner` && req.method === "GET") {
|
|
853
|
+
return send(res, 200, await runner.info(new URLSearchParams(query).has("refresh")));
|
|
854
|
+
}
|
|
855
|
+
if (url.startsWith(`${ROUTE}/runner/`) && req.method === "POST") {
|
|
856
|
+
if (!isLocalRequest(req)) return send(res, 403, { error: "Only available from this computer" });
|
|
857
|
+
const action = url.slice(`${ROUTE}/runner/`.length);
|
|
858
|
+
if (action === "allow") {
|
|
859
|
+
const { agent } = JSON.parse(await body(req));
|
|
860
|
+
if (agent !== "claude" && agent !== "codex") return send(res, 400, { error: "unknown agent" });
|
|
861
|
+
runner.allow(agent);
|
|
862
|
+
return send(res, 200, await runner.info());
|
|
863
|
+
}
|
|
864
|
+
if (action === "forget") {
|
|
865
|
+
runner.forget();
|
|
866
|
+
return send(res, 200, await runner.info());
|
|
867
|
+
}
|
|
868
|
+
if (action === "run") return send(res, 200, await runner.start());
|
|
869
|
+
if (action === "stop") {
|
|
870
|
+
runner.stop();
|
|
871
|
+
return send(res, 200, { ok: true });
|
|
872
|
+
}
|
|
873
|
+
}
|
|
397
874
|
if (url === `${ROUTE}/open`) {
|
|
398
875
|
const spec = new URLSearchParams(query).get("file") ?? "";
|
|
399
876
|
return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
|
|
400
877
|
}
|
|
401
878
|
if (url === `${ROUTE}/info`) {
|
|
402
|
-
return send(res, 200, { name: "limno", root, file:
|
|
879
|
+
return send(res, 200, { name: "limno", root, file: import_node_path4.default.relative(root, file) });
|
|
403
880
|
}
|
|
404
881
|
send(res, 404, { error: "not found" });
|
|
405
882
|
} catch (err) {
|
|
@@ -411,6 +888,7 @@ data: ${JSON.stringify(data)}
|
|
|
411
888
|
handle: (req, res, next) => void handle(req, res, next),
|
|
412
889
|
close() {
|
|
413
890
|
watcher?.close();
|
|
891
|
+
runner.close();
|
|
414
892
|
clearInterval(presenceTimer);
|
|
415
893
|
clearInterval(keepAlive);
|
|
416
894
|
for (const c of clients) c.end();
|
|
@@ -422,12 +900,12 @@ data: ${JSON.stringify(data)}
|
|
|
422
900
|
// src/server.ts
|
|
423
901
|
var import_node_crypto2 = require("crypto");
|
|
424
902
|
var import_node_http = __toESM(require("http"), 1);
|
|
425
|
-
var
|
|
903
|
+
var LOOPBACK2 = /^(localhost|127\.0\.0\.1|\[::1\]|[\w-]+\.localhost)(:\d+)?$/i;
|
|
426
904
|
function allowedOrigin(origin) {
|
|
427
905
|
if (!origin) return null;
|
|
428
906
|
try {
|
|
429
907
|
const u = new URL(origin);
|
|
430
|
-
return (u.protocol === "http:" || u.protocol === "https:") &&
|
|
908
|
+
return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK2.test(u.host) ? origin : null;
|
|
431
909
|
} catch {
|
|
432
910
|
return null;
|
|
433
911
|
}
|
|
@@ -457,7 +935,7 @@ function startServer(options) {
|
|
|
457
935
|
async function listen(options) {
|
|
458
936
|
const service = createLimnoService(options);
|
|
459
937
|
const server = import_node_http.default.createServer((req, res) => {
|
|
460
|
-
if (!
|
|
938
|
+
if (!LOOPBACK2.test(req.headers.host ?? "")) {
|
|
461
939
|
res.statusCode = 403;
|
|
462
940
|
return res.end();
|
|
463
941
|
}
|
|
@@ -525,6 +1003,7 @@ function shouldStamp(file, include = SOURCE_FILES) {
|
|
|
525
1003
|
SOURCE_FILES,
|
|
526
1004
|
TEMPLATE_FILES,
|
|
527
1005
|
createLimnoService,
|
|
1006
|
+
isLocalRequest,
|
|
528
1007
|
overlayFile,
|
|
529
1008
|
shouldStamp,
|
|
530
1009
|
stampSource,
|