@limno/core 0.1.9 → 0.1.11
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 +279 -71
- package/dist/index.d.cts +18 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +271 -63
- package/package.json +4 -4
package/dist/index.d.cts
CHANGED
|
@@ -101,11 +101,29 @@ interface RunnerState {
|
|
|
101
101
|
/** Project-relative files the agent edited in this run. */
|
|
102
102
|
files: string[];
|
|
103
103
|
error?: string;
|
|
104
|
+
/** After a run that edited files: what to commit, and whether it has been. */
|
|
105
|
+
commit?: RunCommit;
|
|
106
|
+
}
|
|
107
|
+
interface RunCommit {
|
|
108
|
+
message: string;
|
|
109
|
+
/** Edited files git still sees as changed. Empty once they're committed. */
|
|
110
|
+
uncommitted: string[];
|
|
111
|
+
/** The project is a git repository. */
|
|
112
|
+
git: boolean;
|
|
113
|
+
/** Limno committed them (auto-commit). */
|
|
114
|
+
committed?: {
|
|
115
|
+
sha: string;
|
|
116
|
+
auto: boolean;
|
|
117
|
+
};
|
|
118
|
+
/** Why an auto-commit didn't happen. */
|
|
119
|
+
note?: string;
|
|
104
120
|
}
|
|
105
121
|
interface RunnerInfo {
|
|
106
122
|
agents: RunnerAgent[];
|
|
107
123
|
/** The agent this user allowed Limno to run on this project, if any. */
|
|
108
124
|
allowed: RunnerAgentId | null;
|
|
125
|
+
/** Commit the agent's files once Limno has verified every change (never pushes). */
|
|
126
|
+
autoCommit: boolean;
|
|
109
127
|
state: RunnerState;
|
|
110
128
|
}
|
|
111
129
|
/**
|
package/dist/index.d.ts
CHANGED
|
@@ -101,11 +101,29 @@ interface RunnerState {
|
|
|
101
101
|
/** Project-relative files the agent edited in this run. */
|
|
102
102
|
files: string[];
|
|
103
103
|
error?: string;
|
|
104
|
+
/** After a run that edited files: what to commit, and whether it has been. */
|
|
105
|
+
commit?: RunCommit;
|
|
106
|
+
}
|
|
107
|
+
interface RunCommit {
|
|
108
|
+
message: string;
|
|
109
|
+
/** Edited files git still sees as changed. Empty once they're committed. */
|
|
110
|
+
uncommitted: string[];
|
|
111
|
+
/** The project is a git repository. */
|
|
112
|
+
git: boolean;
|
|
113
|
+
/** Limno committed them (auto-commit). */
|
|
114
|
+
committed?: {
|
|
115
|
+
sha: string;
|
|
116
|
+
auto: boolean;
|
|
117
|
+
};
|
|
118
|
+
/** Why an auto-commit didn't happen. */
|
|
119
|
+
note?: string;
|
|
104
120
|
}
|
|
105
121
|
interface RunnerInfo {
|
|
106
122
|
agents: RunnerAgent[];
|
|
107
123
|
/** The agent this user allowed Limno to run on this project, if any. */
|
|
108
124
|
allowed: RunnerAgentId | null;
|
|
125
|
+
/** Commit the agent's files once Limno has verified every change (never pushes). */
|
|
126
|
+
autoCommit: boolean;
|
|
109
127
|
state: RunnerState;
|
|
110
128
|
}
|
|
111
129
|
/**
|
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(path6, fileBase) {
|
|
14
|
+
let p = path6.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(path6) {
|
|
57
|
+
const node = path6.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(path6, 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,16 +174,101 @@ async function stampTemplate(code, id, relFile, root) {
|
|
|
174
174
|
|
|
175
175
|
// src/service.ts
|
|
176
176
|
import { createHash } from "crypto";
|
|
177
|
-
import { existsSync as
|
|
177
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync4, renameSync as renameSync2, statSync, watch, writeFileSync as writeFileSync4 } from "fs";
|
|
178
178
|
import { createRequire as createRequire3 } from "module";
|
|
179
|
-
import
|
|
179
|
+
import path5 from "path";
|
|
180
180
|
import { emptyChangeSet, mergeStatuses, renderPrompt } from "@limno/spec";
|
|
181
181
|
|
|
182
182
|
// src/runner.ts
|
|
183
|
-
import { spawn } from "child_process";
|
|
184
|
-
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
|
|
183
|
+
import { spawn as spawn2 } from "child_process";
|
|
184
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, realpathSync, writeFileSync as writeFileSync2 } from "fs";
|
|
185
185
|
import os from "os";
|
|
186
|
+
import path3 from "path";
|
|
187
|
+
import { commitMessage, describeChanges } from "@limno/spec";
|
|
188
|
+
|
|
189
|
+
// src/history.ts
|
|
190
|
+
import { spawn } from "child_process";
|
|
191
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
186
192
|
import path2 from "path";
|
|
193
|
+
var KEEP = 20;
|
|
194
|
+
function runsFile(folder) {
|
|
195
|
+
return path2.join(folder, "runs.json");
|
|
196
|
+
}
|
|
197
|
+
function readRuns(folder) {
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(readFileSync(runsFile(folder), "utf8")).runs ?? [];
|
|
200
|
+
} catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function writeRuns(folder, runs) {
|
|
205
|
+
mkdirSync(folder, { recursive: true });
|
|
206
|
+
const file = runsFile(folder);
|
|
207
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
208
|
+
writeFileSync(tmp, `${JSON.stringify({ runs: runs.slice(-KEEP) }, null, 2)}
|
|
209
|
+
`);
|
|
210
|
+
renameSync(tmp, file);
|
|
211
|
+
}
|
|
212
|
+
function saveRun(folder, run2) {
|
|
213
|
+
const runs = readRuns(folder).filter((r) => r.id !== run2.id);
|
|
214
|
+
writeRuns(folder, [...runs, run2]);
|
|
215
|
+
}
|
|
216
|
+
function git(root, args, timeoutMs = 15e3) {
|
|
217
|
+
return new Promise((resolve) => {
|
|
218
|
+
let out = "";
|
|
219
|
+
let child;
|
|
220
|
+
try {
|
|
221
|
+
child = spawn("git", args, { cwd: root, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
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
|
+
async function isGitRepo(root) {
|
|
239
|
+
return (await git(root, ["rev-parse", "--is-inside-work-tree"])).out.trim() === "true";
|
|
240
|
+
}
|
|
241
|
+
async function uncommitted(root, files) {
|
|
242
|
+
if (!files.length || !await isGitRepo(root)) return [];
|
|
243
|
+
const r = await git(root, ["status", "--porcelain", "--untracked-files=all", "--", ...files]);
|
|
244
|
+
if (r.code !== 0) return [];
|
|
245
|
+
const dirty = new Set(
|
|
246
|
+
r.out.split("\n").filter(Boolean).map((l) => l.slice(3).replace(/^"|"$/g, "").split(" -> ").pop())
|
|
247
|
+
);
|
|
248
|
+
const top = (await git(root, ["rev-parse", "--show-toplevel"])).out.trim();
|
|
249
|
+
return files.filter((f) => dirty.has(top ? path2.relative(top, path2.join(root, f)) : f) || dirty.has(f));
|
|
250
|
+
}
|
|
251
|
+
async function commitFiles(root, files, message) {
|
|
252
|
+
if (!await isGitRepo(root)) return { ok: false, error: "This project isn\u2019t a git repository." };
|
|
253
|
+
for (const marker of ["MERGE_HEAD", "rebase-merge", "rebase-apply", "CHERRY_PICK_HEAD"]) {
|
|
254
|
+
const p = (await git(root, ["rev-parse", "--git-path", marker])).out.trim();
|
|
255
|
+
if (p && existsSync(path2.resolve(root, p))) return { ok: false, error: "Git is in the middle of a merge or rebase." };
|
|
256
|
+
}
|
|
257
|
+
const dirty = await uncommitted(root, files);
|
|
258
|
+
if (!dirty.length) return { ok: false, error: "Nothing to commit: those files have no changes." };
|
|
259
|
+
const add = await git(root, ["add", "--", ...dirty]);
|
|
260
|
+
if (add.code !== 0) return { ok: false, error: add.out.trim().split("\n").pop() || "git add failed" };
|
|
261
|
+
const commit = await git(root, ["commit", "-m", message, "--", ...dirty], 6e4);
|
|
262
|
+
if (commit.code !== 0) return { ok: false, error: commit.out.trim().split("\n").slice(-2).join(" ") || "git commit failed" };
|
|
263
|
+
const sha = (await git(root, ["rev-parse", "--short", "HEAD"])).out.trim();
|
|
264
|
+
return { ok: true, sha };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// src/runner.ts
|
|
268
|
+
var fullMessage = (text) => {
|
|
269
|
+
const t = String(text).trim().replace(/\n{3,}/g, "\n\n");
|
|
270
|
+
return t.length > 4e3 ? `${t.slice(0, 3999).trimEnd()}\u2026` : t;
|
|
271
|
+
};
|
|
187
272
|
var NAMES = { claude: "Claude Code", codex: "Codex" };
|
|
188
273
|
var SIGN_IN = { claude: "claude auth login", codex: "codex login" };
|
|
189
274
|
var PROMPT = [
|
|
@@ -191,9 +276,11 @@ var PROMPT = [
|
|
|
191
276
|
"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
277
|
"Report progress with limno_update_status, and answer comments with limno_reply.",
|
|
193
278
|
"Only read and edit files inside this project. Don't run shell commands; Limno checks the result on the page itself.",
|
|
194
|
-
"Finish with
|
|
279
|
+
"Finish with a summary the designer reads in Limno, at most two short sentences and 45 words in total:",
|
|
280
|
+
"first what you changed (or why you changed nothing); then, only if needed, what they must know or decide, as a direct question when it is one.",
|
|
281
|
+
"No file paths, no lists, nothing they already know from the change itself."
|
|
195
282
|
].join(" ");
|
|
196
|
-
var trustFile = () =>
|
|
283
|
+
var trustFile = () => path3.join(os.homedir(), ".limno", "trust.json");
|
|
197
284
|
function projectKey(root) {
|
|
198
285
|
try {
|
|
199
286
|
return realpathSync(root);
|
|
@@ -203,14 +290,14 @@ function projectKey(root) {
|
|
|
203
290
|
}
|
|
204
291
|
function readTrust() {
|
|
205
292
|
try {
|
|
206
|
-
return JSON.parse(
|
|
293
|
+
return JSON.parse(readFileSync2(trustFile(), "utf8")).projects ?? {};
|
|
207
294
|
} catch {
|
|
208
295
|
return {};
|
|
209
296
|
}
|
|
210
297
|
}
|
|
211
298
|
function writeTrust(projects) {
|
|
212
|
-
|
|
213
|
-
|
|
299
|
+
mkdirSync2(path3.dirname(trustFile()), { recursive: true });
|
|
300
|
+
writeFileSync2(trustFile(), `${JSON.stringify({ projects }, null, 2)}
|
|
214
301
|
`);
|
|
215
302
|
}
|
|
216
303
|
function run(cmd, args, timeoutMs = 8e3) {
|
|
@@ -218,7 +305,7 @@ function run(cmd, args, timeoutMs = 8e3) {
|
|
|
218
305
|
let out = "";
|
|
219
306
|
let child;
|
|
220
307
|
try {
|
|
221
|
-
child =
|
|
308
|
+
child = spawn2(cmd, args, { env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
|
|
222
309
|
} catch {
|
|
223
310
|
return resolve({ code: null, out: "" });
|
|
224
311
|
}
|
|
@@ -271,20 +358,20 @@ function isLocalRequest(req) {
|
|
|
271
358
|
return true;
|
|
272
359
|
}
|
|
273
360
|
function createRunner(root, folder, onState) {
|
|
274
|
-
const sessionsFile =
|
|
361
|
+
const sessionsFile = path3.join(folder, "runner.json");
|
|
275
362
|
let child = null;
|
|
276
363
|
let again = false;
|
|
277
364
|
let state = { status: "idle", agent: null, startedAt: null, endedAt: null, log: [], files: [] };
|
|
278
365
|
const sessions = () => {
|
|
279
366
|
try {
|
|
280
|
-
return JSON.parse(
|
|
367
|
+
return JSON.parse(readFileSync2(sessionsFile, "utf8"));
|
|
281
368
|
} catch {
|
|
282
369
|
return {};
|
|
283
370
|
}
|
|
284
371
|
};
|
|
285
372
|
const saveSession = (agent, id) => {
|
|
286
|
-
|
|
287
|
-
|
|
373
|
+
mkdirSync2(folder, { recursive: true });
|
|
374
|
+
writeFileSync2(sessionsFile, `${JSON.stringify({ ...sessions(), [agent]: id }, null, 2)}
|
|
288
375
|
`);
|
|
289
376
|
};
|
|
290
377
|
let emitTimer;
|
|
@@ -302,15 +389,108 @@ function createRunner(root, folder, onState) {
|
|
|
302
389
|
};
|
|
303
390
|
const rel = (p) => {
|
|
304
391
|
if (!p) return "";
|
|
305
|
-
const abs =
|
|
306
|
-
return
|
|
392
|
+
const abs = path3.isAbsolute(p) ? p : path3.join(root, p);
|
|
393
|
+
return path3.relative(root, abs) || p;
|
|
307
394
|
};
|
|
308
395
|
const key = projectKey(root);
|
|
309
396
|
const allowed = () => readTrust()[key]?.agent ?? null;
|
|
397
|
+
const autoCommitOn = () => !!readTrust()[key]?.autoCommit;
|
|
398
|
+
const changeSetFile = path3.join(folder, "changeset.json");
|
|
399
|
+
const readSet = () => {
|
|
400
|
+
try {
|
|
401
|
+
return JSON.parse(readFileSync2(changeSetFile, "utf8"));
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
let run2 = null;
|
|
407
|
+
let committing = false;
|
|
408
|
+
const say = (line) => console.log(`\x1B[36m[limno]\x1B[0m ${line}`);
|
|
409
|
+
const list = (files) => files.length <= 3 ? files.join(", ") : `${files.slice(0, 3).join(", ")} and ${files.length - 3} more`;
|
|
410
|
+
async function finishRun(agent) {
|
|
411
|
+
const startedAt = state.startedAt;
|
|
412
|
+
const files = state.files;
|
|
413
|
+
const set = readSet();
|
|
414
|
+
const ids = run2?.changeIds ?? [];
|
|
415
|
+
const changes = (set?.changes ?? []).filter((c) => ids.includes(c.id));
|
|
416
|
+
const rec = {
|
|
417
|
+
id: run2?.id ?? String(startedAt),
|
|
418
|
+
agent: NAMES[agent],
|
|
419
|
+
startedAt: startedAt ?? Date.now(),
|
|
420
|
+
endedAt: state.endedAt ?? Date.now(),
|
|
421
|
+
status: state.status === "done" ? "done" : state.status === "stopped" ? "stopped" : "failed",
|
|
422
|
+
files,
|
|
423
|
+
changeIds: ids,
|
|
424
|
+
summary: [...state.log].reverse().find((l) => l.kind === "message")?.text,
|
|
425
|
+
message: commitMessage(changes)
|
|
426
|
+
};
|
|
427
|
+
run2 = rec;
|
|
428
|
+
try {
|
|
429
|
+
saveRun(folder, rec);
|
|
430
|
+
} catch {
|
|
431
|
+
}
|
|
432
|
+
if (!files.length) {
|
|
433
|
+
if (rec.status === "done") say(`${NAMES[agent]} finished without editing any files.`);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const dirty = await uncommitted(root, files);
|
|
437
|
+
const git2 = dirty.length > 0 || await isGitRepo(root);
|
|
438
|
+
if (state.startedAt === startedAt) {
|
|
439
|
+
state.commit = { message: rec.message, uncommitted: dirty, git: git2 };
|
|
440
|
+
emit(true);
|
|
441
|
+
}
|
|
442
|
+
const n = files.length;
|
|
443
|
+
say(`${NAMES[agent]} edited ${n} ${n === 1 ? "file" : "files"}: ${list(files)}${dirty.length ? " (not committed yet)" : ""}`);
|
|
444
|
+
if (dirty.length) {
|
|
445
|
+
say(`Commit message: ${rec.message.split("\n")[0]}`);
|
|
446
|
+
if (autoCommitOn()) say("Auto-commit is on: Limno commits these files once every change is verified on the page.");
|
|
447
|
+
}
|
|
448
|
+
if (state.startedAt === startedAt) void changeSetChanged(set);
|
|
449
|
+
}
|
|
450
|
+
async function changeSetChanged(set) {
|
|
451
|
+
const commit = state.commit;
|
|
452
|
+
if (!set || !run2 || committing || !commit || commit.committed || !commit.uncommitted.length || !autoCommitOn()) return;
|
|
453
|
+
if (state.status !== "done") return;
|
|
454
|
+
const sent = set.changes.filter((c) => run2.changeIds.includes(c.id) && c.kind !== "note");
|
|
455
|
+
if (!sent.length) return;
|
|
456
|
+
if (sent.some((c) => c.status === "differs" || c.status === "failed")) {
|
|
457
|
+
if (!commit.note) {
|
|
458
|
+
commit.note = "A change doesn\u2019t match the preview yet.";
|
|
459
|
+
const off = describeChanges(sent.filter((c) => c.status === "differs" || c.status === "failed")).length || 1;
|
|
460
|
+
say(`Not auto-committing: ${off === 1 ? "1 change doesn\u2019t" : `${off} changes don\u2019t`} match the preview yet. Review in Limno.`);
|
|
461
|
+
emit(true);
|
|
462
|
+
}
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (!sent.every((c) => c.status === "verified")) return;
|
|
466
|
+
committing = true;
|
|
467
|
+
try {
|
|
468
|
+
const r = await commitFiles(root, state.files, run2.message);
|
|
469
|
+
if (r.ok) {
|
|
470
|
+
commit.committed = { sha: r.sha, auto: true };
|
|
471
|
+
commit.uncommitted = [];
|
|
472
|
+
commit.note = void 0;
|
|
473
|
+
run2.commit = { sha: r.sha, at: Date.now(), auto: true };
|
|
474
|
+
try {
|
|
475
|
+
saveRun(folder, run2);
|
|
476
|
+
} catch {
|
|
477
|
+
}
|
|
478
|
+
say(`Committed ${state.files.length === 1 ? "1 file" : `${state.files.length} files`} as ${r.sha}: ${run2.message.split("\n")[0]} (not pushed)`);
|
|
479
|
+
} else {
|
|
480
|
+
commit.note = `Not committed: ${r.error}`;
|
|
481
|
+
say(`Didn\u2019t auto-commit: ${r.error}`);
|
|
482
|
+
}
|
|
483
|
+
emit(true);
|
|
484
|
+
} finally {
|
|
485
|
+
committing = false;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
310
488
|
function limnoLine(tool, input = {}) {
|
|
311
489
|
if (tool.endsWith("get_changes")) return "Reading your changes";
|
|
312
490
|
if (tool.endsWith("update_status")) {
|
|
313
|
-
const
|
|
491
|
+
const ids = Array.isArray(input.ids) ? input.ids : [];
|
|
492
|
+
const sent = (readSet()?.changes ?? []).filter((c) => ids.includes(c.id));
|
|
493
|
+
const n = sent.length ? describeChanges(sent).length : ids.length;
|
|
314
494
|
const status = String(input.status ?? "");
|
|
315
495
|
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
496
|
}
|
|
@@ -334,7 +514,7 @@ function createRunner(root, folder, onState) {
|
|
|
334
514
|
else if (name === "Edit" || name === "MultiEdit" || name === "Write") log({ kind: "edit", text: `Editing ${rel(input.file_path)}`, file: rel(input.file_path) });
|
|
335
515
|
else if (name === "Glob" || name === "Grep") log({ kind: "search", text: "Searching the project" });
|
|
336
516
|
} else if (block.type === "text" && String(block.text).trim()) {
|
|
337
|
-
log({ kind: "message", text:
|
|
517
|
+
log({ kind: "message", text: fullMessage(block.text) });
|
|
338
518
|
}
|
|
339
519
|
}
|
|
340
520
|
return;
|
|
@@ -355,7 +535,7 @@ function createRunner(root, folder, onState) {
|
|
|
355
535
|
else if (item.type === "file_change" && e.type === "item.completed") {
|
|
356
536
|
for (const c of item.changes ?? []) log({ kind: "edit", text: `Editing ${rel(c.path)}`, file: rel(c.path) });
|
|
357
537
|
} else if (item.type === "agent_message" && e.type === "item.completed" && item.text) {
|
|
358
|
-
log({ kind: "message", text:
|
|
538
|
+
log({ kind: "message", text: fullMessage(item.text) });
|
|
359
539
|
} else if (item.type === "command_execution" && e.type === "item.started") {
|
|
360
540
|
log({ kind: "search", text: "Looking through the project" });
|
|
361
541
|
}
|
|
@@ -402,7 +582,7 @@ function createRunner(root, folder, onState) {
|
|
|
402
582
|
return resume ? ["exec", ...opts, "resume", resume, PROMPT] : ["exec", ...opts, PROMPT];
|
|
403
583
|
}
|
|
404
584
|
function launch(agent, resume) {
|
|
405
|
-
const proc =
|
|
585
|
+
const proc = spawn2(agent, args(agent, resume), { cwd: root, env: agentEnv(), stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
|
|
406
586
|
child = proc;
|
|
407
587
|
let buf = "";
|
|
408
588
|
let stderr = "";
|
|
@@ -437,7 +617,11 @@ function createRunner(root, folder, onState) {
|
|
|
437
617
|
launch(agent);
|
|
438
618
|
return;
|
|
439
619
|
}
|
|
440
|
-
if (state.status === "stopped")
|
|
620
|
+
if (state.status === "stopped") {
|
|
621
|
+
emit(true);
|
|
622
|
+
void finishRun(agent);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
441
625
|
const failed = code !== 0 || !!state.error;
|
|
442
626
|
const tail = stderr.trim().split("\n").filter(Boolean).slice(-2).join(" ").slice(0, 300);
|
|
443
627
|
state = {
|
|
@@ -447,6 +631,7 @@ function createRunner(root, folder, onState) {
|
|
|
447
631
|
error: failed ? state.error ?? (tail || `${NAMES[agent]} exited${signal ? ` (${signal})` : ` with code ${code}`}`) : void 0
|
|
448
632
|
};
|
|
449
633
|
emit(true);
|
|
634
|
+
void finishRun(agent);
|
|
450
635
|
if (again && !failed) {
|
|
451
636
|
again = false;
|
|
452
637
|
void start();
|
|
@@ -465,16 +650,32 @@ function createRunner(root, folder, onState) {
|
|
|
465
650
|
if (!info?.installed) return { ok: false, error: `${NAMES[agent]} isn\u2019t installed on this computer.` };
|
|
466
651
|
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
652
|
state = { status: "starting", agent, startedAt: Date.now(), endedAt: null, log: [], files: [] };
|
|
653
|
+
const queued = (readSet()?.changes ?? []).filter((c) => c.enabled && ["queued", "in_progress", "differs", "failed"].includes(c.status)).map((c) => c.id);
|
|
654
|
+
run2 = { id: String(state.startedAt), agent: NAMES[agent], startedAt: state.startedAt, endedAt: 0, status: "done", files: [], changeIds: queued, message: "" };
|
|
468
655
|
emit(true);
|
|
469
656
|
launch(agent, sessions()[agent]);
|
|
470
657
|
return { ok: true };
|
|
471
658
|
}
|
|
472
659
|
return {
|
|
473
660
|
async info(force) {
|
|
474
|
-
|
|
661
|
+
if (state.commit && !state.commit.committed && state.commit.uncommitted.length && !child) {
|
|
662
|
+
state.commit = { ...state.commit, uncommitted: await uncommitted(root, state.files) };
|
|
663
|
+
}
|
|
664
|
+
return { agents: await detectAgents(force), allowed: allowed(), autoCommit: autoCommitOn(), state };
|
|
665
|
+
},
|
|
666
|
+
setAutoCommit(on) {
|
|
667
|
+
const t = readTrust();
|
|
668
|
+
if (!t[key]) return;
|
|
669
|
+
t[key] = { ...t[key], autoCommit: on };
|
|
670
|
+
writeTrust(t);
|
|
671
|
+
if (on) void changeSetChanged(readSet());
|
|
672
|
+
},
|
|
673
|
+
changeSetChanged(set) {
|
|
674
|
+
void changeSetChanged(set);
|
|
475
675
|
},
|
|
476
676
|
allow(agent) {
|
|
477
|
-
|
|
677
|
+
const prev = readTrust()[key];
|
|
678
|
+
writeTrust({ ...readTrust(), [key]: { agent, at: Date.now(), autoCommit: prev?.autoCommit } });
|
|
478
679
|
},
|
|
479
680
|
forget() {
|
|
480
681
|
const t = readTrust();
|
|
@@ -498,11 +699,11 @@ function createRunner(root, folder, onState) {
|
|
|
498
699
|
}
|
|
499
700
|
|
|
500
701
|
// src/update.ts
|
|
501
|
-
import { spawn as
|
|
502
|
-
import { existsSync, mkdirSync as
|
|
702
|
+
import { spawn as spawn3 } from "child_process";
|
|
703
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
503
704
|
import { createRequire as createRequire2 } from "module";
|
|
504
705
|
import os2 from "os";
|
|
505
|
-
import
|
|
706
|
+
import path4 from "path";
|
|
506
707
|
var require2 = createRequire2(import.meta.url);
|
|
507
708
|
var TTL = 60 * 60 * 1e3;
|
|
508
709
|
function currentVersion() {
|
|
@@ -514,7 +715,7 @@ function currentVersion() {
|
|
|
514
715
|
}
|
|
515
716
|
function installedVersion(fallback) {
|
|
516
717
|
try {
|
|
517
|
-
return JSON.parse(
|
|
718
|
+
return JSON.parse(readFileSync3(require2.resolve("../package.json"), "utf8")).version;
|
|
518
719
|
} catch {
|
|
519
720
|
return fallback;
|
|
520
721
|
}
|
|
@@ -528,18 +729,18 @@ var newer = (a, b) => {
|
|
|
528
729
|
function packageManager(root) {
|
|
529
730
|
let dir = root;
|
|
530
731
|
for (; ; ) {
|
|
531
|
-
if (
|
|
532
|
-
if (
|
|
533
|
-
if (
|
|
534
|
-
if (
|
|
535
|
-
const up =
|
|
732
|
+
if (existsSync2(path4.join(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
733
|
+
if (existsSync2(path4.join(dir, "yarn.lock"))) return "yarn";
|
|
734
|
+
if (existsSync2(path4.join(dir, "bun.lockb")) || existsSync2(path4.join(dir, "bun.lock"))) return "bun";
|
|
735
|
+
if (existsSync2(path4.join(dir, "package-lock.json"))) return "npm";
|
|
736
|
+
const up = path4.dirname(dir);
|
|
536
737
|
if (up === dir) return "npm";
|
|
537
738
|
dir = up;
|
|
538
739
|
}
|
|
539
740
|
}
|
|
540
741
|
function listedPackages(root) {
|
|
541
742
|
try {
|
|
542
|
-
const pkg = JSON.parse(
|
|
743
|
+
const pkg = JSON.parse(readFileSync3(path4.join(root, "package.json"), "utf8"));
|
|
543
744
|
return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter((n) => n.startsWith("@limno/") && n !== "@limno/cli");
|
|
544
745
|
} catch {
|
|
545
746
|
return [];
|
|
@@ -552,10 +753,10 @@ function installArgs(pm, pkgs) {
|
|
|
552
753
|
if (pm === "bun") return ["bun", ["add", "-d", ...at]];
|
|
553
754
|
return ["npm", ["install", "-D", ...at]];
|
|
554
755
|
}
|
|
555
|
-
var cacheFile = () =>
|
|
756
|
+
var cacheFile = () => path4.join(os2.homedir(), ".limno", "update-check.json");
|
|
556
757
|
async function latestVersion() {
|
|
557
758
|
try {
|
|
558
|
-
const cached = JSON.parse(
|
|
759
|
+
const cached = JSON.parse(readFileSync3(cacheFile(), "utf8"));
|
|
559
760
|
if (Date.now() - cached.at < TTL) return cached.latest;
|
|
560
761
|
} catch {
|
|
561
762
|
}
|
|
@@ -564,8 +765,8 @@ async function latestVersion() {
|
|
|
564
765
|
if (!res.ok) return null;
|
|
565
766
|
const latest = (await res.json()).latest ?? null;
|
|
566
767
|
if (latest) {
|
|
567
|
-
|
|
568
|
-
|
|
768
|
+
mkdirSync3(path4.dirname(cacheFile()), { recursive: true });
|
|
769
|
+
writeFileSync3(cacheFile(), JSON.stringify({ at: Date.now(), latest }));
|
|
569
770
|
}
|
|
570
771
|
return latest;
|
|
571
772
|
} catch {
|
|
@@ -592,14 +793,14 @@ function createUpdater(root) {
|
|
|
592
793
|
const [cmd, args] = installArgs(packageManager(root), pkgs);
|
|
593
794
|
installing = new Promise((resolve) => {
|
|
594
795
|
let output = "";
|
|
595
|
-
const child =
|
|
796
|
+
const child = spawn3(cmd, args, { cwd: root, env: process.env, shell: process.platform === "win32" });
|
|
596
797
|
child.stdout?.on("data", (d) => output += d);
|
|
597
798
|
child.stderr?.on("data", (d) => output += d);
|
|
598
799
|
child.on("error", (e) => resolve({ ok: false, output: String(e) }));
|
|
599
800
|
child.on("close", (code) => {
|
|
600
801
|
if (code === 0) {
|
|
601
802
|
try {
|
|
602
|
-
|
|
803
|
+
writeFileSync3(cacheFile(), JSON.stringify({ at: 0, latest: "" }));
|
|
603
804
|
} catch {
|
|
604
805
|
}
|
|
605
806
|
}
|
|
@@ -619,16 +820,16 @@ var require3 = createRequire3(import.meta.url);
|
|
|
619
820
|
var STALE_MS = 3e4;
|
|
620
821
|
function readJson(file) {
|
|
621
822
|
try {
|
|
622
|
-
return JSON.parse(
|
|
823
|
+
return JSON.parse(readFileSync4(file, "utf8"));
|
|
623
824
|
} catch {
|
|
624
825
|
return null;
|
|
625
826
|
}
|
|
626
827
|
}
|
|
627
828
|
function writeAtomic(file, data) {
|
|
628
|
-
|
|
829
|
+
mkdirSync4(path5.dirname(file), { recursive: true });
|
|
629
830
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
630
|
-
|
|
631
|
-
|
|
831
|
+
writeFileSync4(tmp, data);
|
|
832
|
+
renameSync2(tmp, file);
|
|
632
833
|
}
|
|
633
834
|
function body(req) {
|
|
634
835
|
return new Promise((resolve, reject) => {
|
|
@@ -649,9 +850,9 @@ function overlayFile() {
|
|
|
649
850
|
return require3.resolve("@limno/overlay/dist/overlay.js");
|
|
650
851
|
}
|
|
651
852
|
function createLimnoService({ root, dir = ".limno" }) {
|
|
652
|
-
const folder =
|
|
653
|
-
const file =
|
|
654
|
-
const presenceFile =
|
|
853
|
+
const folder = path5.join(root, dir);
|
|
854
|
+
const file = path5.join(folder, "changeset.json");
|
|
855
|
+
const presenceFile = path5.join(folder, "agent.json");
|
|
655
856
|
let lastWrittenHash = "";
|
|
656
857
|
const clients = /* @__PURE__ */ new Set();
|
|
657
858
|
function broadcast(event, data) {
|
|
@@ -680,23 +881,23 @@ data: ${JSON.stringify(data)}
|
|
|
680
881
|
return { connected, client: connected ? presence?.client ?? null : null, lastSeen: presence?.lastSeen ?? null, setup: setupInfo() };
|
|
681
882
|
}
|
|
682
883
|
function installConfig(target) {
|
|
683
|
-
const dest = target === "claude" ?
|
|
884
|
+
const dest = target === "claude" ? path5.join(root, ".mcp.json") : path5.join(root, ".cursor", "mcp.json");
|
|
684
885
|
let config = {};
|
|
685
|
-
if (
|
|
886
|
+
if (existsSync3(dest)) {
|
|
686
887
|
try {
|
|
687
|
-
config = JSON.parse(
|
|
888
|
+
config = JSON.parse(readFileSync4(dest, "utf8"));
|
|
688
889
|
} catch {
|
|
689
|
-
throw new Error(`${
|
|
890
|
+
throw new Error(`${path5.relative(root, dest)} isn't valid JSON; fix or remove it first.`);
|
|
690
891
|
}
|
|
691
892
|
}
|
|
692
893
|
const { command, args } = setupInfo();
|
|
693
894
|
const portable = args.map((a, i) => {
|
|
694
895
|
if (args[i - 1] === "--root") return ".";
|
|
695
|
-
return
|
|
896
|
+
return path5.isAbsolute(a) ? path5.relative(root, a).split(path5.sep).join("/") : a;
|
|
696
897
|
});
|
|
697
898
|
config.mcpServers = { ...config.mcpServers ?? {}, limno: { command, args: portable } };
|
|
698
899
|
writeAtomic(dest, JSON.stringify(config, null, 2) + "\n");
|
|
699
|
-
return
|
|
900
|
+
return path5.relative(root, dest);
|
|
700
901
|
}
|
|
701
902
|
let lastAgent = "";
|
|
702
903
|
function pushAgent() {
|
|
@@ -707,12 +908,13 @@ data: ${JSON.stringify(data)}
|
|
|
707
908
|
broadcast("agent", status);
|
|
708
909
|
}
|
|
709
910
|
function onChangeSetFile() {
|
|
710
|
-
const raw =
|
|
911
|
+
const raw = existsSync3(file) ? readFileSync4(file, "utf8") : "";
|
|
711
912
|
if (!raw || hash(raw) === lastWrittenHash) return;
|
|
712
913
|
const set = readJson(file);
|
|
713
914
|
if (set) broadcast("changeset", set);
|
|
915
|
+
runner.changeSetChanged(set);
|
|
714
916
|
}
|
|
715
|
-
|
|
917
|
+
mkdirSync4(folder, { recursive: true });
|
|
716
918
|
let watcher = null;
|
|
717
919
|
let watchedIno = -1;
|
|
718
920
|
let debounce;
|
|
@@ -754,8 +956,8 @@ data: ${JSON.stringify(data)}
|
|
|
754
956
|
async function openInEditor(spec) {
|
|
755
957
|
const m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(spec);
|
|
756
958
|
if (!m) return false;
|
|
757
|
-
const target =
|
|
758
|
-
if (!target.startsWith(root +
|
|
959
|
+
const target = path5.resolve(root, m[1]);
|
|
960
|
+
if (!target.startsWith(root + path5.sep) || !existsSync3(target)) return false;
|
|
759
961
|
const { default: launch } = await import("launch-editor");
|
|
760
962
|
launch(`${target}${m[2] ? `:${m[2]}` : ""}${m[3] ? `:${m[3]}` : ""}`);
|
|
761
963
|
return true;
|
|
@@ -765,7 +967,7 @@ data: ${JSON.stringify(data)}
|
|
|
765
967
|
if (!url.startsWith(ROUTE)) return next ? next() : send(res, 404, { error: "not found" });
|
|
766
968
|
try {
|
|
767
969
|
if (url === `${ROUTE}/overlay.js`) {
|
|
768
|
-
return send(res, 200,
|
|
970
|
+
return send(res, 200, readFileSync4(overlayFile(), "utf8"), "text/javascript");
|
|
769
971
|
}
|
|
770
972
|
if (url === `${ROUTE}/events`) {
|
|
771
973
|
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-store", Connection: "keep-alive" });
|
|
@@ -784,6 +986,7 @@ data: ${JSON.stringify(data)}
|
|
|
784
986
|
const raw = JSON.stringify(merged, null, 2);
|
|
785
987
|
lastWrittenHash = hash(raw);
|
|
786
988
|
writeAtomic(file, raw);
|
|
989
|
+
runner.changeSetChanged(merged);
|
|
787
990
|
return send(res, 200, merged);
|
|
788
991
|
}
|
|
789
992
|
if (url === `${ROUTE}/prompt` && req.method === "GET") {
|
|
@@ -825,6 +1028,11 @@ data: ${JSON.stringify(data)}
|
|
|
825
1028
|
runner.forget();
|
|
826
1029
|
return send(res, 200, await runner.info());
|
|
827
1030
|
}
|
|
1031
|
+
if (action === "autocommit") {
|
|
1032
|
+
const { on } = JSON.parse(await body(req));
|
|
1033
|
+
runner.setAutoCommit(!!on);
|
|
1034
|
+
return send(res, 200, await runner.info());
|
|
1035
|
+
}
|
|
828
1036
|
if (action === "run") return send(res, 200, await runner.start());
|
|
829
1037
|
if (action === "stop") {
|
|
830
1038
|
runner.stop();
|
|
@@ -836,7 +1044,7 @@ data: ${JSON.stringify(data)}
|
|
|
836
1044
|
return send(res, await openInEditor(spec) ? 200 : 404, { ok: true });
|
|
837
1045
|
}
|
|
838
1046
|
if (url === `${ROUTE}/info`) {
|
|
839
|
-
return send(res, 200, { name: "limno", root, file:
|
|
1047
|
+
return send(res, 200, { name: "limno", root, file: path5.relative(root, file) });
|
|
840
1048
|
}
|
|
841
1049
|
send(res, 404, { error: "not found" });
|
|
842
1050
|
} catch (err) {
|