@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.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(path5, fileBase) {
|
|
14
|
+
let p = path5.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(path5) {
|
|
57
|
+
const node = path5.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(path5, 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,451 @@ 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";
|
|
178
|
-
import { createRequire as
|
|
179
|
-
import
|
|
177
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, renameSync, statSync, watch, writeFileSync as writeFileSync3 } from "fs";
|
|
178
|
+
import { createRequire as createRequire3 } from "module";
|
|
179
|
+
import path4 from "path";
|
|
180
180
|
import { emptyChangeSet, mergeStatuses, renderPrompt } from "@limno/spec";
|
|
181
|
-
|
|
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/update.ts
|
|
501
|
+
import { spawn as spawn2 } from "child_process";
|
|
502
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
503
|
+
import { createRequire as createRequire2 } from "module";
|
|
504
|
+
import os2 from "os";
|
|
505
|
+
import path3 from "path";
|
|
182
506
|
var require2 = createRequire2(import.meta.url);
|
|
507
|
+
var DAY = 24 * 60 * 60 * 1e3;
|
|
508
|
+
function currentVersion() {
|
|
509
|
+
try {
|
|
510
|
+
return require2("../package.json").version;
|
|
511
|
+
} catch {
|
|
512
|
+
return "0.0.0";
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
var newer = (a, b) => {
|
|
516
|
+
const pa = a.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
|
|
517
|
+
const pb = b.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
|
|
518
|
+
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
519
|
+
return false;
|
|
520
|
+
};
|
|
521
|
+
function packageManager(root) {
|
|
522
|
+
let dir = root;
|
|
523
|
+
for (; ; ) {
|
|
524
|
+
if (existsSync(path3.join(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
525
|
+
if (existsSync(path3.join(dir, "yarn.lock"))) return "yarn";
|
|
526
|
+
if (existsSync(path3.join(dir, "bun.lockb")) || existsSync(path3.join(dir, "bun.lock"))) return "bun";
|
|
527
|
+
if (existsSync(path3.join(dir, "package-lock.json"))) return "npm";
|
|
528
|
+
const up = path3.dirname(dir);
|
|
529
|
+
if (up === dir) return "npm";
|
|
530
|
+
dir = up;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function listedPackages(root) {
|
|
534
|
+
try {
|
|
535
|
+
const pkg = JSON.parse(readFileSync2(path3.join(root, "package.json"), "utf8"));
|
|
536
|
+
return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((n) => n.startsWith("@limno/") && n !== "@limno/cli");
|
|
537
|
+
} catch {
|
|
538
|
+
return [];
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function installArgs(pm, pkgs) {
|
|
542
|
+
const at = pkgs.map((p) => `${p}@latest`);
|
|
543
|
+
if (pm === "pnpm") return ["pnpm", ["add", "-D", ...at]];
|
|
544
|
+
if (pm === "yarn") return ["yarn", ["add", "-D", ...at]];
|
|
545
|
+
if (pm === "bun") return ["bun", ["add", "-d", ...at]];
|
|
546
|
+
return ["npm", ["install", "-D", ...at]];
|
|
547
|
+
}
|
|
548
|
+
var cacheFile = () => path3.join(os2.homedir(), ".limno", "update-check.json");
|
|
549
|
+
async function latestVersion() {
|
|
550
|
+
try {
|
|
551
|
+
const cached = JSON.parse(readFileSync2(cacheFile(), "utf8"));
|
|
552
|
+
if (Date.now() - cached.at < DAY) return cached.latest;
|
|
553
|
+
} catch {
|
|
554
|
+
}
|
|
555
|
+
try {
|
|
556
|
+
const res = await fetch("https://registry.npmjs.org/-/package/@limno/core/dist-tags", { signal: AbortSignal.timeout(4e3) });
|
|
557
|
+
if (!res.ok) return null;
|
|
558
|
+
const latest = (await res.json()).latest ?? null;
|
|
559
|
+
if (latest) {
|
|
560
|
+
mkdirSync2(path3.dirname(cacheFile()), { recursive: true });
|
|
561
|
+
writeFileSync2(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
|
|
562
|
+
}
|
|
563
|
+
return latest;
|
|
564
|
+
} catch {
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function createUpdater(root) {
|
|
569
|
+
let installing = null;
|
|
570
|
+
async function info() {
|
|
571
|
+
const current = currentVersion();
|
|
572
|
+
if (process.env.LIMNO_NO_UPDATE_CHECK) return { current, latest: null, available: false, command: null };
|
|
573
|
+
const latest = await latestVersion();
|
|
574
|
+
const pkgs = listedPackages(root);
|
|
575
|
+
const [cmd, args] = installArgs(packageManager(root), pkgs);
|
|
576
|
+
const available = !!latest && newer(latest, current) && pkgs.length > 0;
|
|
577
|
+
return { current, latest, available, command: available ? `${cmd} ${args.join(" ")}` : null };
|
|
578
|
+
}
|
|
579
|
+
function install() {
|
|
580
|
+
if (installing) return installing;
|
|
581
|
+
const pkgs = listedPackages(root);
|
|
582
|
+
if (!pkgs.length) return Promise.resolve({ ok: false, output: "No @limno packages found in package.json." });
|
|
583
|
+
const [cmd, args] = installArgs(packageManager(root), pkgs);
|
|
584
|
+
installing = new Promise((resolve) => {
|
|
585
|
+
let output = "";
|
|
586
|
+
const child = spawn2(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
|
|
587
|
+
child.stdout?.on("data", (d) => output += d);
|
|
588
|
+
child.stderr?.on("data", (d) => output += d);
|
|
589
|
+
child.on("error", (e) => resolve({ ok: false, output: String(e) }));
|
|
590
|
+
child.on("close", (code) => {
|
|
591
|
+
if (code === 0) {
|
|
592
|
+
try {
|
|
593
|
+
writeFileSync2(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
|
|
594
|
+
} catch {
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
resolve({ ok: code === 0, output: output.trim().split("\n").slice(-12).join("\n") });
|
|
598
|
+
});
|
|
599
|
+
}).finally(() => {
|
|
600
|
+
installing = null;
|
|
601
|
+
});
|
|
602
|
+
return installing;
|
|
603
|
+
}
|
|
604
|
+
return { info, install };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/service.ts
|
|
608
|
+
var ROUTE = "/__limno";
|
|
609
|
+
var require3 = createRequire3(import.meta.url);
|
|
183
610
|
var STALE_MS = 3e4;
|
|
184
611
|
function readJson(file) {
|
|
185
612
|
try {
|
|
186
|
-
return JSON.parse(
|
|
613
|
+
return JSON.parse(readFileSync3(file, "utf8"));
|
|
187
614
|
} catch {
|
|
188
615
|
return null;
|
|
189
616
|
}
|
|
190
617
|
}
|
|
191
618
|
function writeAtomic(file, data) {
|
|
192
|
-
|
|
619
|
+
mkdirSync3(path4.dirname(file), { recursive: true });
|
|
193
620
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
194
|
-
|
|
621
|
+
writeFileSync3(tmp, data);
|
|
195
622
|
renameSync(tmp, file);
|
|
196
623
|
}
|
|
197
624
|
function body(req) {
|
|
@@ -210,12 +637,12 @@ function send(res, status, payload, type = "application/json") {
|
|
|
210
637
|
}
|
|
211
638
|
var hash = (s) => createHash("sha1").update(s).digest("hex");
|
|
212
639
|
function overlayFile() {
|
|
213
|
-
return
|
|
640
|
+
return require3.resolve("@limno/overlay/dist/overlay.js");
|
|
214
641
|
}
|
|
215
642
|
function createLimnoService({ root, dir = ".limno" }) {
|
|
216
|
-
const folder =
|
|
217
|
-
const file =
|
|
218
|
-
const presenceFile =
|
|
643
|
+
const folder = path4.join(root, dir);
|
|
644
|
+
const file = path4.join(folder, "changeset.json");
|
|
645
|
+
const presenceFile = path4.join(folder, "agent.json");
|
|
219
646
|
let lastWrittenHash = "";
|
|
220
647
|
const clients = /* @__PURE__ */ new Set();
|
|
221
648
|
function broadcast(event, data) {
|
|
@@ -225,6 +652,8 @@ data: ${JSON.stringify(data)}
|
|
|
225
652
|
`;
|
|
226
653
|
for (const c of clients) c.write(msg);
|
|
227
654
|
}
|
|
655
|
+
const runner = createRunner(root, folder, (state) => broadcast("runner", state));
|
|
656
|
+
const updater = createUpdater(root);
|
|
228
657
|
function setupInfo() {
|
|
229
658
|
return { command: "npx", args: ["-y", "@limno/mcp", "--root", root], root };
|
|
230
659
|
}
|
|
@@ -242,23 +671,23 @@ data: ${JSON.stringify(data)}
|
|
|
242
671
|
return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
|
|
243
672
|
}
|
|
244
673
|
function installConfig(target) {
|
|
245
|
-
const dest = target === "claude" ?
|
|
674
|
+
const dest = target === "claude" ? path4.join(root, ".mcp.json") : path4.join(root, ".cursor", "mcp.json");
|
|
246
675
|
let config = {};
|
|
247
|
-
if (
|
|
676
|
+
if (existsSync2(dest)) {
|
|
248
677
|
try {
|
|
249
|
-
config = JSON.parse(
|
|
678
|
+
config = JSON.parse(readFileSync3(dest, "utf8"));
|
|
250
679
|
} catch {
|
|
251
|
-
throw new Error(`${
|
|
680
|
+
throw new Error(`${path4.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
|
|
252
681
|
}
|
|
253
682
|
}
|
|
254
683
|
const { command, args } = setupInfo();
|
|
255
684
|
const portable = args.map((a, i) => {
|
|
256
685
|
if (args[i - 1] === "--root") return ".";
|
|
257
|
-
return
|
|
686
|
+
return path4.isAbsolute(a) ? path4.relative(root, a).split(path4.sep).join("/") : a;
|
|
258
687
|
});
|
|
259
688
|
config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
|
|
260
689
|
writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
|
|
261
|
-
return
|
|
690
|
+
return path4.relative(root, dest);
|
|
262
691
|
}
|
|
263
692
|
let lastAgent = "";
|
|
264
693
|
function pushAgent() {
|
|
@@ -269,27 +698,45 @@ data: ${JSON.stringify(data)}
|
|
|
269
698
|
broadcast("agent", status);
|
|
270
699
|
}
|
|
271
700
|
function onChangeSetFile() {
|
|
272
|
-
const raw =
|
|
701
|
+
const raw = existsSync2(file) ? readFileSync3(file, "utf8") : "";
|
|
273
702
|
if (!raw || hash(raw) === lastWrittenHash) return;
|
|
274
703
|
const set = readJson(file);
|
|
275
704
|
if (set) broadcast("changeset", set);
|
|
276
705
|
}
|
|
277
|
-
|
|
706
|
+
mkdirSync3(folder, { recursive: true });
|
|
278
707
|
let watcher = null;
|
|
708
|
+
let watchedIno = -1;
|
|
279
709
|
let debounce;
|
|
280
|
-
|
|
281
|
-
watcher
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (name
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
710
|
+
function startWatching() {
|
|
711
|
+
watcher?.close();
|
|
712
|
+
watcher = null;
|
|
713
|
+
try {
|
|
714
|
+
watchedIno = statSync(folder).ino;
|
|
715
|
+
watcher = watch(folder, (_event, name) => {
|
|
716
|
+
if (!name || name.endsWith(".tmp")) return;
|
|
717
|
+
clearTimeout(debounce);
|
|
718
|
+
debounce = setTimeout(() => {
|
|
719
|
+
if (name === "changeset.json") onChangeSetFile();
|
|
720
|
+
if (name === "agent.json") pushAgent();
|
|
721
|
+
}, 40);
|
|
722
|
+
});
|
|
723
|
+
watcher.unref?.();
|
|
724
|
+
} catch {
|
|
725
|
+
}
|
|
291
726
|
}
|
|
292
|
-
|
|
727
|
+
startWatching();
|
|
728
|
+
const presenceTimer = setInterval(() => {
|
|
729
|
+
let ino = -1;
|
|
730
|
+
try {
|
|
731
|
+
ino = statSync(folder).ino;
|
|
732
|
+
} catch {
|
|
733
|
+
}
|
|
734
|
+
if (ino !== -1 && ino !== watchedIno) {
|
|
735
|
+
startWatching();
|
|
736
|
+
onChangeSetFile();
|
|
737
|
+
}
|
|
738
|
+
pushAgent();
|
|
739
|
+
}, 1e4);
|
|
293
740
|
presenceTimer.unref();
|
|
294
741
|
const keepAlive = setInterval(() => {
|
|
295
742
|
for (const c of clients) c.write(": ping\n\n");
|
|
@@ -298,8 +745,8 @@ data: ${JSON.stringify(data)}
|
|
|
298
745
|
async function openInEditor(spec) {
|
|
299
746
|
const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
|
|
300
747
|
if (!m) return false;
|
|
301
|
-
const target =
|
|
302
|
-
if (!target.startsWith(root +
|
|
748
|
+
const target = path4.resolve(root, m[1]);
|
|
749
|
+
if (!target.startsWith(root + path4.sep) || !existsSync2(target)) return false;
|
|
303
750
|
const { default: launch } = await import("launch-editor");
|
|
304
751
|
launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
|
|
305
752
|
return true;
|
|
@@ -309,7 +756,7 @@ data: ${JSON.stringify(data)}
|
|
|
309
756
|
if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
|
|
310
757
|
try {
|
|
311
758
|
if (url === `${ROUTE}/overlay.js`) {
|
|
312
|
-
return send(res, 200,
|
|
759
|
+
return send(res, 200, readFileSync3(overlayFile(), "utf8"), "text/javascript");
|
|
313
760
|
}
|
|
314
761
|
if (url === `${ROUTE}/events`) {
|
|
315
762
|
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
|
|
@@ -346,12 +793,41 @@ data: ${JSON.stringify(data)}
|
|
|
346
793
|
return send(res, 409, { error: e instanceof Error ? e.message : String(e) });
|
|
347
794
|
}
|
|
348
795
|
}
|
|
796
|
+
if (url === `${ROUTE}/update` && req.method === "GET") {
|
|
797
|
+
return send(res, 200, await updater.info());
|
|
798
|
+
}
|
|
799
|
+
if (url === `${ROUTE}/update` && req.method === "POST") {
|
|
800
|
+
if (!isLocalRequest(req)) return send(res, 403, { error: "Only available from this computer" });
|
|
801
|
+
return send(res, 200, await updater.install());
|
|
802
|
+
}
|
|
803
|
+
if (url === `${ROUTE}/runner` && req.method === "GET") {
|
|
804
|
+
return send(res, 200, await runner.info(new URLSearchParams(query).has("refresh")));
|
|
805
|
+
}
|
|
806
|
+
if (url.startsWith(`${ROUTE}/runner/`) && req.method === "POST") {
|
|
807
|
+
if (!isLocalRequest(req)) return send(res, 403, { error: "Only available from this computer" });
|
|
808
|
+
const action = url.slice(`${ROUTE}/runner/`.length);
|
|
809
|
+
if (action === "allow") {
|
|
810
|
+
const { agent } = JSON.parse(await body(req));
|
|
811
|
+
if (agent !== "claude" && agent !== "codex") return send(res, 400, { error: "unknown agent" });
|
|
812
|
+
runner.allow(agent);
|
|
813
|
+
return send(res, 200, await runner.info());
|
|
814
|
+
}
|
|
815
|
+
if (action === "forget") {
|
|
816
|
+
runner.forget();
|
|
817
|
+
return send(res, 200, await runner.info());
|
|
818
|
+
}
|
|
819
|
+
if (action === "run") return send(res, 200, await runner.start());
|
|
820
|
+
if (action === "stop") {
|
|
821
|
+
runner.stop();
|
|
822
|
+
return send(res, 200, { ok: true });
|
|
823
|
+
}
|
|
824
|
+
}
|
|
349
825
|
if (url === `${ROUTE}/open`) {
|
|
350
826
|
const spec = new URLSearchParams(query).get("file") ?? "";
|
|
351
827
|
return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
|
|
352
828
|
}
|
|
353
829
|
if (url === `${ROUTE}/info`) {
|
|
354
|
-
return send(res, 200, { name: "limno", root, file:
|
|
830
|
+
return send(res, 200, { name: "limno", root, file: path4.relative(root, file) });
|
|
355
831
|
}
|
|
356
832
|
send(res, 404, { error: "not found" });
|
|
357
833
|
} catch (err) {
|
|
@@ -363,6 +839,7 @@ data: ${JSON.stringify(data)}
|
|
|
363
839
|
handle: (req, res, next) => void handle(req, res, next),
|
|
364
840
|
close() {
|
|
365
841
|
watcher?.close();
|
|
842
|
+
runner.close();
|
|
366
843
|
clearInterval(presenceTimer);
|
|
367
844
|
clearInterval(keepAlive);
|
|
368
845
|
for (const c of clients) c.end();
|
|
@@ -374,12 +851,12 @@ data: ${JSON.stringify(data)}
|
|
|
374
851
|
// src/server.ts
|
|
375
852
|
import { createHash as createHash2 } from "crypto";
|
|
376
853
|
import http from "http";
|
|
377
|
-
var
|
|
854
|
+
var LOOPBACK2 = /^(localhost|127\.0\.0\.1|\[::1\]|[\w-]+\.localhost)(:\d+)?$/i;
|
|
378
855
|
function allowedOrigin(origin) {
|
|
379
856
|
if (!origin) return null;
|
|
380
857
|
try {
|
|
381
858
|
const u = new URL(origin);
|
|
382
|
-
return (u.protocol === "http:" || u.protocol === "https:") &&
|
|
859
|
+
return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK2.test(u.host) ? origin : null;
|
|
383
860
|
} catch {
|
|
384
861
|
return null;
|
|
385
862
|
}
|
|
@@ -409,7 +886,7 @@ function startServer(options) {
|
|
|
409
886
|
async function listen(options) {
|
|
410
887
|
const service = createLimnoService(options);
|
|
411
888
|
const server = http.createServer((req, res) => {
|
|
412
|
-
if (!
|
|
889
|
+
if (!LOOPBACK2.test(req.headers.host ?? "")) {
|
|
413
890
|
res.statusCode = 403;
|
|
414
891
|
return res.end();
|
|
415
892
|
}
|
|
@@ -476,6 +953,7 @@ export {
|
|
|
476
953
|
SOURCE_FILES,
|
|
477
954
|
TEMPLATE_FILES,
|
|
478
955
|
createLimnoService,
|
|
956
|
+
isLocalRequest,
|
|
479
957
|
overlayFile,
|
|
480
958
|
shouldStamp,
|
|
481
959
|
stampSource,
|