@lumi.ai/runner 0.6.2 → 0.6.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/README.md +24 -4
- package/dist/cli.js +206 -52
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,7 +84,9 @@ Run this first when something isn't working. Everything it checks used to be dis
|
|
|
84
84
|
running job*, surfacing as a failed task on your board minutes later: a missing `claude` binary,
|
|
85
85
|
unsaved Ship credentials, a machine no captain approved. It checks Node ≥ 20, your stored session,
|
|
86
86
|
per-Ship approval and credentials, the engine binary, `git`/`gh` when an agent uses GitHub, server
|
|
87
|
-
reachability, the background service,
|
|
87
|
+
reachability, the background service, whether that service still points at a CLI that exists, and
|
|
88
|
+
whether the **service's own `PATH`** can still reach the binaries your jobs spawn — which is a
|
|
89
|
+
different question from whether your shell can (see below).
|
|
88
90
|
|
|
89
91
|
### Background service
|
|
90
92
|
|
|
@@ -99,6 +101,21 @@ reachability, the background service, and whether that service still points at a
|
|
|
99
101
|
Your `PATH` is captured into the unit at install time. launchd and systemd start processes with a
|
|
100
102
|
minimal environment, so without that `claude`, `git` and `gh` would not be found.
|
|
101
103
|
|
|
104
|
+
**It is captured once, and a restart does not refresh it.** `service restart` re-runs the unit
|
|
105
|
+
exactly as written, so a daemon keeps the `PATH` of whatever shell first installed it. Install a
|
|
106
|
+
tool somewhere new afterwards — `~/.local/bin`, a Homebrew prefix, an nvm switch — and the daemon
|
|
107
|
+
cannot see it, while every check you can run by hand (`which claude`, `lumi-runner doctor`) is
|
|
108
|
+
answered by your *current* shell and looks fine. The symptom is a job that fails minutes later
|
|
109
|
+
with `spawn claude ENOENT`.
|
|
110
|
+
|
|
111
|
+
Two things close that gap: `doctor` reports it as **Service PATH**, and `lumi-runner setup` now
|
|
112
|
+
reinstalls the service (rather than merely restarting it) when your environment has drifted from
|
|
113
|
+
the installed unit. To fix it directly, from a shell where the tool works:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
lumi-runner service install # rewrites the unit with your current PATH
|
|
117
|
+
```
|
|
118
|
+
|
|
102
119
|
## How many jobs at once
|
|
103
120
|
|
|
104
121
|
One, until you say otherwise — the same behaviour this daemon has always had.
|
|
@@ -187,8 +204,11 @@ always as a warning — being out of date never fails the preflight.
|
|
|
187
204
|
- **Idle sleep is inhibited** (`caffeinate` / `systemd-inhibit`, best-effort on Windows), so a
|
|
188
205
|
laptop doesn't suspend mid-session. It cannot veto you choosing Shut Down — no background process
|
|
189
206
|
gets that veto on macOS, and it shouldn't.
|
|
190
|
-
- **
|
|
191
|
-
stopped with work in flight
|
|
207
|
+
- **Desktop notifications are off by default.** They fire on job start, finish, terminal failure and
|
|
208
|
+
on a daemon stopped with work in flight — which on a busy machine is an interruption every few
|
|
209
|
+
minutes carrying nothing the task board and the Daemons live log do not already show. Turn them on
|
|
210
|
+
with `lumi-runner config set notifications on`. (Sleep inhibition is a separate setting and stays
|
|
211
|
+
on: "don't close the lid" is `keepAwake`, not a notification.)
|
|
192
212
|
- **SIGTERM releases the job.** The daemon aborts every running session, hands each job back to the
|
|
193
213
|
queue with its retry budget **unspent**, and only then writes itself offline. Stopping the daemon
|
|
194
214
|
never costs you an attempt.
|
|
@@ -254,7 +274,7 @@ claiming from one queue.
|
|
|
254
274
|
|---|---|
|
|
255
275
|
| `LUMI_RUNNER_HOME` | Config + log directory (default `~/.lumi-runner`) |
|
|
256
276
|
| `CREW_CLAUDE_BIN` | Path to the Claude binary (default: `claude` from `PATH`) |
|
|
257
|
-
| `CREW_NO_NOTIFY` |
|
|
277
|
+
| `CREW_NO_NOTIFY` | Force desktop notifications off, whatever the config says (they are off by default) |
|
|
258
278
|
| `CREW_NO_POWER` | Disable sleep inhibition |
|
|
259
279
|
| `LUMI_RUNNER_REGISTRY` | npm registry to check for updates (default `https://registry.npmjs.org`) |
|
|
260
280
|
| `LUMI_RUNNER_CHANNEL` | Force the update channel: `latest` or `dev` |
|
package/dist/cli.js
CHANGED
|
@@ -230,6 +230,8 @@ function jobTarget(job) {
|
|
|
230
230
|
return { kind: "chat", chatId: job.chatId };
|
|
231
231
|
return null;
|
|
232
232
|
}
|
|
233
|
+
var MAX_RUN_REPORT_CHARS = 6e3;
|
|
234
|
+
var MAX_RUN_REPORT_SUMMARY_CHARS = 300;
|
|
233
235
|
|
|
234
236
|
// ../shared/dist/jobProgress.js
|
|
235
237
|
var MAX_JOB_STEPS = 10;
|
|
@@ -251,9 +253,9 @@ function str(input, key) {
|
|
|
251
253
|
const value = input[key];
|
|
252
254
|
return typeof value === "string" && value.trim() ? value : void 0;
|
|
253
255
|
}
|
|
254
|
-
function basename(
|
|
255
|
-
const parts =
|
|
256
|
-
return parts[parts.length - 1] ??
|
|
256
|
+
function basename(path9) {
|
|
257
|
+
const parts = path9.split(/[\\/]/).filter(Boolean);
|
|
258
|
+
return parts[parts.length - 1] ?? path9;
|
|
257
259
|
}
|
|
258
260
|
function hostOf(url) {
|
|
259
261
|
try {
|
|
@@ -341,8 +343,8 @@ function builtinDetail(tool, input) {
|
|
|
341
343
|
case "Write":
|
|
342
344
|
case "Edit":
|
|
343
345
|
case "MultiEdit": {
|
|
344
|
-
const
|
|
345
|
-
return
|
|
346
|
+
const path9 = str(input, "file_path");
|
|
347
|
+
return path9 ? basename(path9) : void 0;
|
|
346
348
|
}
|
|
347
349
|
case "Glob":
|
|
348
350
|
case "Grep":
|
|
@@ -759,12 +761,20 @@ function forgetShip(config2, shipId) {
|
|
|
759
761
|
function allowsLocalMcp(config2, shipId) {
|
|
760
762
|
return Array.isArray(config2.allowLocalMcp) && config2.allowLocalMcp.includes(shipId);
|
|
761
763
|
}
|
|
764
|
+
var TOGGLE_DEFAULTS = {
|
|
765
|
+
notifications: false,
|
|
766
|
+
keepAwake: true,
|
|
767
|
+
autoUpdate: true
|
|
768
|
+
};
|
|
769
|
+
function notificationsEnabled(config2) {
|
|
770
|
+
return config2?.notifications ?? TOGGLE_DEFAULTS.notifications;
|
|
771
|
+
}
|
|
762
772
|
function mcpUrl(config2) {
|
|
763
773
|
return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`;
|
|
764
774
|
}
|
|
765
775
|
|
|
766
776
|
// src/version.ts
|
|
767
|
-
var RUNNER_VERSION = true ? "0.6.
|
|
777
|
+
var RUNNER_VERSION = true ? "0.6.4" : "0.0.0-dev";
|
|
768
778
|
|
|
769
779
|
// src/auth.ts
|
|
770
780
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -1030,7 +1040,7 @@ var WINDOWS_SCRIPT = [
|
|
|
1030
1040
|
].join(" ");
|
|
1031
1041
|
function enabled() {
|
|
1032
1042
|
if (process.env.CREW_NO_NOTIFY === "1") return false;
|
|
1033
|
-
return loadConfig()
|
|
1043
|
+
return notificationsEnabled(loadConfig());
|
|
1034
1044
|
}
|
|
1035
1045
|
function notify(title, body) {
|
|
1036
1046
|
if (!enabled()) return;
|
|
@@ -1188,6 +1198,13 @@ async function loadSettledBlockers(shipRef, task) {
|
|
|
1188
1198
|
return [];
|
|
1189
1199
|
}
|
|
1190
1200
|
}
|
|
1201
|
+
function reportSummaryLine(r) {
|
|
1202
|
+
if (r.summary?.trim()) return r.summary.trim();
|
|
1203
|
+
const body = r.report?.trim();
|
|
1204
|
+
if (!body) return "No report.";
|
|
1205
|
+
const firstLine2 = body.split("\n").find((l) => l.trim())?.trim() ?? body;
|
|
1206
|
+
return firstLine2.length <= MAX_RUN_REPORT_SUMMARY_CHARS ? firstLine2 : `${firstLine2.slice(0, MAX_RUN_REPORT_SUMMARY_CHARS - 1)}\u2026`;
|
|
1207
|
+
}
|
|
1191
1208
|
async function loadJobContext(db, shipId, job) {
|
|
1192
1209
|
const shipRef = doc(db, COLLECTIONS.ships, shipId);
|
|
1193
1210
|
const taskRef = doc(shipRef, COLLECTIONS.tasks, job.taskId);
|
|
@@ -1268,7 +1285,13 @@ async function loadJobContext(db, shipId, job) {
|
|
|
1268
1285
|
activity: activityDocs.map((d) => ({ id: d.id, ...d.data() })).reverse(),
|
|
1269
1286
|
// read newest-first, rendered chronologically
|
|
1270
1287
|
activityTruncated: activitySnap.docs.length > MAX_ACTIVITY_IN_PROMPT,
|
|
1271
|
-
|
|
1288
|
+
// §15.41. No longer filtered down to jobs that HAVE a report: the catalog carries a line per
|
|
1289
|
+
// previous run, and "that run left no report" is itself worth knowing — it is the difference
|
|
1290
|
+
// between a task nobody has worked and one where three runs died without saying why.
|
|
1291
|
+
previousReports: jobsSnap.docs.filter((d) => d.id !== job.id).slice(0, MAX_PREVIOUS_JOBS_READ).map((d) => {
|
|
1292
|
+
const data = d.data();
|
|
1293
|
+
return { jobId: d.id, summary: data.reportSummary ?? null, report: data.report ?? null };
|
|
1294
|
+
}).reverse()
|
|
1272
1295
|
// chronological
|
|
1273
1296
|
};
|
|
1274
1297
|
}
|
|
@@ -1293,7 +1316,11 @@ function standingRules(ship2, statuses, task, agent) {
|
|
|
1293
1316
|
// Amended with the pointer clause, which is the only thing keeping the three tiers from
|
|
1294
1317
|
// duplicating each other: without it a diligent agent writes the same fact into its report,
|
|
1295
1318
|
// its notebook and the org brain, and the report grows into a transcript of the other two.
|
|
1296
|
-
|
|
1319
|
+
// §15.41 added the summary clause. It is second, not an afterthought at the end, because that
|
|
1320
|
+
// one string is what every LATER run on this task reads instead of this report — and what a
|
|
1321
|
+
// human sees in their notification. An agent that treats it as a label writes "Run report",
|
|
1322
|
+
// and four runs later the catalog says nothing at all.
|
|
1323
|
+
"End every session by calling run_report: what you did, what remains, what the next run must know. Give it a summary too \u2014 one or two sentences that stand in for the whole report in later runs, so write the sentence you would want the next run to read first. Do not repeat what you already put in your memory or in Ship knowledge \u2014 point at it instead (the entry id, or the slug)."
|
|
1297
1324
|
].join("\n- ");
|
|
1298
1325
|
}
|
|
1299
1326
|
function formatTaskDate(millis, now = Date.now()) {
|
|
@@ -1333,26 +1360,17 @@ function buildPrompt(ctx, reason, mcpServers = [], tokenRepos, githubUnavailable
|
|
|
1333
1360
|
const parts = [];
|
|
1334
1361
|
const statuses = shipTaskStatuses(ctx.ship);
|
|
1335
1362
|
const playbook = usableWorkflow(ctx.workflow);
|
|
1363
|
+
let preamble = null;
|
|
1336
1364
|
if (reason === "activity") {
|
|
1337
|
-
|
|
1338
|
-
"# Why this session started\n\nSomeone posted on your task after your last run. Read the newest activity below" + (playbook ? ", then follow your playbook below." : ", respond via task_comment, do any follow-up work it asks for, and set the task status honestly if it needs to change.")
|
|
1339
|
-
);
|
|
1365
|
+
preamble = "# Why this session started\n\nSomeone posted on your task after your last run. Read the newest activity below" + (playbook ? ", then follow your playbook above." : ", respond via task_comment, do any follow-up work it asks for, and set the task status honestly if it needs to change.");
|
|
1340
1366
|
} else if (reason === "watch") {
|
|
1341
|
-
|
|
1342
|
-
"# Why this session started\n\nOne of your playbooks watches the board, and this task matched it. Nobody assigned it to you and nobody else is working it right now \u2014 you are picking it up.\n\n**Leave it in a status that says what happened.** The board is how the watch decides what still needs attention, so a task you leave in the same status will come back to you on the next scan" + (playbook ? ", which your playbook below is written for." : ".")
|
|
1343
|
-
);
|
|
1367
|
+
preamble = "# Why this session started\n\nOne of your playbooks watches the board, and this task matched it. Nobody assigned it to you and nobody else is working it right now \u2014 you are picking it up.\n\n**Leave it in a status that says what happened.** The board is how the watch decides what still needs attention, so a task you leave in the same status will come back to you on the next scan" + (playbook ? ", which your playbook above is written for." : ".");
|
|
1344
1368
|
} else if (reason === "resume_after_approval") {
|
|
1345
|
-
|
|
1346
|
-
"# Why this session started\n\nYou asked a captain for permission and stopped. They have now answered \u2014 their decision is the newest entry in the task activity below. Read it first.\n\n**If they approved it, do that thing now** \u2014 the permission is granted for this task and may be single-use, so do not ask again for the same thing. **If they refused, do not retry and do not look for a way around it**: say what you will do instead, or hand the task back with task_assign."
|
|
1347
|
-
);
|
|
1369
|
+
preamble = "# Why this session started\n\nYou asked a captain for permission and stopped. They have now answered \u2014 their decision is the newest entry in the task activity below. Read it first.\n\n**If they approved it, do that thing now** \u2014 the permission is granted for this task and may be single-use, so do not ask again for the same thing. **If they refused, do not retry and do not look for a way around it**: say what you will do instead, or hand the task back with task_assign.";
|
|
1348
1370
|
} else if (reason === "unblocked") {
|
|
1349
|
-
|
|
1350
|
-
'# Why this session started\n\nThis task was blocked and is not any more: everything it was waiting on is now done. Nobody has just assigned it to you \u2014 you have had it all along, and the work has become startable.\n\n**Read "What you were waiting on" below before you do anything else.** It carries what those tasks produced, which is the input this work was held up for; starting without it means redoing or contradicting somebody else' + (playbook ? ", then follow your playbook below." : "'s work.")
|
|
1351
|
-
);
|
|
1371
|
+
preamble = '# Why this session started\n\nThis task was blocked and is not any more: everything it was waiting on is now done. Nobody has just assigned it to you \u2014 you have had it all along, and the work has become startable.\n\n**Read "What you were waiting on" below before you do anything else.** It carries what those tasks produced, which is the input this work was held up for; starting without it means redoing or contradicting somebody else' + (playbook ? ", then follow your playbook above." : "'s work.");
|
|
1352
1372
|
} else if (reason === "schedule") {
|
|
1353
|
-
|
|
1354
|
-
"# Why this session started\n\nThis is a scheduled run: one of your own playbooks created this task on its cron and assigned it to you. It is routine work, not a request from a person, so nobody is waiting on a reply" + (playbook ? " \u2014 the playbook below is the work, and the task's description is only a record of why it exists." : ".")
|
|
1355
|
-
);
|
|
1373
|
+
preamble = "# Why this session started\n\nThis is a scheduled run: one of your own playbooks created this task on its cron and assigned it to you. It is routine work, not a request from a person, so nobody is waiting on a reply" + (playbook ? " \u2014 the playbook above is the work, and the task's description is only a record of why it exists." : ".");
|
|
1356
1374
|
}
|
|
1357
1375
|
parts.push(`# Who you are
|
|
1358
1376
|
|
|
@@ -1377,6 +1395,7 @@ This is the playbook for this kind of work \u2014 follow it. Your contract and t
|
|
|
1377
1395
|
${playbook.instructions}`
|
|
1378
1396
|
);
|
|
1379
1397
|
}
|
|
1398
|
+
if (preamble) parts.push(preamble);
|
|
1380
1399
|
const t = ctx.task;
|
|
1381
1400
|
const now = Date.now();
|
|
1382
1401
|
const statusLabel = statusById(statuses, t.status)?.label ?? t.status;
|
|
@@ -1428,12 +1447,28 @@ ${p.description || "(no description)"}`
|
|
|
1428
1447
|
${note2}${feed}`);
|
|
1429
1448
|
}
|
|
1430
1449
|
if (ctx.previousReports.length > 0) {
|
|
1431
|
-
const
|
|
1450
|
+
const runs = ctx.previousReports;
|
|
1451
|
+
const latest = runs[runs.length - 1];
|
|
1452
|
+
const older = runs.slice(0, -1);
|
|
1453
|
+
const sections = [];
|
|
1454
|
+
if (older.length > 0) {
|
|
1455
|
+
const lines = older.map((r) => `- (\`${r.jobId}\`) ${reportSummaryLine(r)}`).join("\n");
|
|
1456
|
+
sections.push(
|
|
1457
|
+
`## Earlier runs
|
|
1458
|
+
|
|
1459
|
+
Oldest first, one line each. To read one in full, call \`task_get\` with this task's id and \`reportJobId\` set to the id in brackets.
|
|
1432
1460
|
|
|
1433
|
-
${
|
|
1461
|
+
${lines}`
|
|
1462
|
+
);
|
|
1463
|
+
}
|
|
1464
|
+
sections.push(
|
|
1465
|
+
`## Most recent run (\`${latest.jobId}\`)
|
|
1466
|
+
|
|
1467
|
+
${latest.report ?? "That run left no report."}`
|
|
1468
|
+
);
|
|
1434
1469
|
parts.push(`# Reports from previous runs on this task
|
|
1435
1470
|
|
|
1436
|
-
${
|
|
1471
|
+
${sections.join("\n\n")}`);
|
|
1437
1472
|
}
|
|
1438
1473
|
const githubRepos = tokenRepos ?? ctx.agent.tools.github.repos;
|
|
1439
1474
|
if (ctx.agent.tools.github.enabled && githubUnavailable) {
|
|
@@ -1509,7 +1544,10 @@ async function loadChatContext(db, shipId, job) {
|
|
|
1509
1544
|
limit2(MAX_CHAT_REPORTS + 1)
|
|
1510
1545
|
)
|
|
1511
1546
|
);
|
|
1512
|
-
previousReports = jobsSnap.docs.filter((d) => d.id !== job.id).slice(0, MAX_CHAT_REPORTS).map((d) =>
|
|
1547
|
+
previousReports = jobsSnap.docs.filter((d) => d.id !== job.id).slice(0, MAX_CHAT_REPORTS).map((d) => {
|
|
1548
|
+
const data = d.data();
|
|
1549
|
+
return { jobId: d.id, summary: data.reportSummary ?? null, report: data.report ?? null };
|
|
1550
|
+
}).reverse();
|
|
1513
1551
|
} catch {
|
|
1514
1552
|
previousReports = [];
|
|
1515
1553
|
}
|
|
@@ -1545,7 +1583,7 @@ function chatStandingRules(ship2, agent) {
|
|
|
1545
1583
|
scopeRule,
|
|
1546
1584
|
registerRule,
|
|
1547
1585
|
routingRule,
|
|
1548
|
-
"End the session by calling run_report: what was discussed and anything the next run must know. Do not repeat what you already put in your memory or in Ship knowledge \u2014 point at it instead."
|
|
1586
|
+
"End the session by calling run_report: what was discussed and anything the next run must know. Give it a summary too \u2014 one or two sentences that stand in for the whole report once this conversation is long enough to scroll out of the window. Do not repeat what you already put in your memory or in Ship knowledge \u2014 point at it instead."
|
|
1549
1587
|
].join("\n- ");
|
|
1550
1588
|
}
|
|
1551
1589
|
function buildChatPrompt(ctx, mcpServers = [], tokenRepos, githubUnavailable) {
|
|
@@ -1568,15 +1606,22 @@ ${ctx.agent.contract}`);
|
|
|
1568
1606
|
|
|
1569
1607
|
- ${chatStandingRules(ctx.ship, ctx.agent)}`);
|
|
1570
1608
|
if (ctx.previousReports.length > 0) {
|
|
1571
|
-
const
|
|
1609
|
+
const runs = ctx.previousReports;
|
|
1610
|
+
const latest = runs[runs.length - 1];
|
|
1611
|
+
const older = runs.slice(0, -1);
|
|
1612
|
+
const sections = [];
|
|
1613
|
+
if (older.length > 0) {
|
|
1614
|
+
sections.push(older.map((r) => `- ${reportSummaryLine(r)}`).join("\n"));
|
|
1615
|
+
}
|
|
1616
|
+
sections.push(`## Most recent run
|
|
1572
1617
|
|
|
1573
|
-
${
|
|
1618
|
+
${latest.report ?? "That run left no report."}`);
|
|
1574
1619
|
parts.push(
|
|
1575
1620
|
`# Earlier in this chat
|
|
1576
1621
|
|
|
1577
1622
|
The conversation is longer than the window below. These are your own notes from previous runs on this chat, oldest first.
|
|
1578
1623
|
|
|
1579
|
-
${
|
|
1624
|
+
${sections.join("\n\n")}`
|
|
1580
1625
|
);
|
|
1581
1626
|
}
|
|
1582
1627
|
const title = ctx.chat.title?.trim();
|
|
@@ -1890,7 +1935,7 @@ async function claudeHealthCheck() {
|
|
|
1890
1935
|
const done = (health) => {
|
|
1891
1936
|
if (settled) return;
|
|
1892
1937
|
settled = true;
|
|
1893
|
-
resolve(health);
|
|
1938
|
+
resolve({ ...health, binary: bin });
|
|
1894
1939
|
};
|
|
1895
1940
|
let stdout = "";
|
|
1896
1941
|
const child = spawn3(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
@@ -2428,15 +2473,28 @@ function redactTranscript(transcript, knownSecrets) {
|
|
|
2428
2473
|
return out;
|
|
2429
2474
|
}
|
|
2430
2475
|
async function uploadTranscript(storage, shipId, jobId, redacted) {
|
|
2431
|
-
const
|
|
2432
|
-
await uploadBytes(storageRef(storage,
|
|
2476
|
+
const path9 = `crew/${shipId}/transcripts/${jobId}.jsonl`;
|
|
2477
|
+
await uploadBytes(storageRef(storage, path9), new TextEncoder().encode(redacted), {
|
|
2433
2478
|
contentType: "application/x-ndjson"
|
|
2434
2479
|
});
|
|
2435
|
-
return
|
|
2480
|
+
return path9;
|
|
2436
2481
|
}
|
|
2437
2482
|
function utcDay(millis) {
|
|
2438
2483
|
return new Date(millis).toISOString().slice(0, 10);
|
|
2439
2484
|
}
|
|
2485
|
+
function backstopReportContent(resultText) {
|
|
2486
|
+
const text = resultText.trim();
|
|
2487
|
+
if (!text) {
|
|
2488
|
+
const none = "This run ended without writing a report, and left no final text to fall back on.";
|
|
2489
|
+
return { report: none, summary: none };
|
|
2490
|
+
}
|
|
2491
|
+
const marker = "\n\n\u2026(truncated)";
|
|
2492
|
+
const report4 = text.length <= MAX_RUN_REPORT_CHARS ? text : `${text.slice(0, MAX_RUN_REPORT_CHARS - marker.length)}${marker}`;
|
|
2493
|
+
const firstLine2 = text.split("\n").find((l) => l.trim())?.trim() ?? text;
|
|
2494
|
+
const ellipsis = "\u2026";
|
|
2495
|
+
const summary = firstLine2.length <= MAX_RUN_REPORT_SUMMARY_CHARS ? firstLine2 : `${firstLine2.slice(0, MAX_RUN_REPORT_SUMMARY_CHARS - ellipsis.length)}${ellipsis}`;
|
|
2496
|
+
return { report: report4, summary };
|
|
2497
|
+
}
|
|
2440
2498
|
async function finalizeJob(db, shipId, job, input) {
|
|
2441
2499
|
const shipRef = doc6(db, COLLECTIONS.ships, shipId);
|
|
2442
2500
|
const jobRef = doc6(shipRef, COLLECTIONS.jobs, job.id);
|
|
@@ -2444,6 +2502,7 @@ async function finalizeJob(db, shipId, job, input) {
|
|
|
2444
2502
|
const usageRef = doc6(shipRef, COLLECTIONS.usageDaily, utcDay(now));
|
|
2445
2503
|
await runTransaction(db, async (tx) => {
|
|
2446
2504
|
const usageSnap = await tx.get(usageRef);
|
|
2505
|
+
const jobSnap = await tx.get(jobRef);
|
|
2447
2506
|
const totals = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
|
|
2448
2507
|
const byAgent = usageSnap.data()?.byAgent ?? {};
|
|
2449
2508
|
const agentAgg = {
|
|
@@ -2451,7 +2510,10 @@ async function finalizeJob(db, shipId, job, input) {
|
|
|
2451
2510
|
...byAgent[job.agentId] ?? {}
|
|
2452
2511
|
};
|
|
2453
2512
|
const u = input.usage;
|
|
2513
|
+
const wroteReport = !!jobSnap.data()?.report;
|
|
2514
|
+
const backstop = !wroteReport && input.resultText !== void 0 ? backstopReportContent(input.resultText) : null;
|
|
2454
2515
|
tx.update(jobRef, {
|
|
2516
|
+
...backstop ? { report: backstop.report, reportSummary: backstop.summary } : {},
|
|
2455
2517
|
status: input.status,
|
|
2456
2518
|
endedAt: now,
|
|
2457
2519
|
usage: u,
|
|
@@ -2771,6 +2833,10 @@ function serviceEnv() {
|
|
|
2771
2833
|
}
|
|
2772
2834
|
return env;
|
|
2773
2835
|
}
|
|
2836
|
+
function serviceEnvDrift(current, installed) {
|
|
2837
|
+
if (!installed) return [];
|
|
2838
|
+
return Object.keys(current).filter((name) => current[name] !== installed[name]).sort();
|
|
2839
|
+
}
|
|
2774
2840
|
function removeLegacyService() {
|
|
2775
2841
|
const removed = [];
|
|
2776
2842
|
if (process.platform === "darwin") {
|
|
@@ -2869,6 +2935,24 @@ function parsePlistCliPath(plist) {
|
|
|
2869
2935
|
const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1]));
|
|
2870
2936
|
return args.length >= 2 ? args[1] : void 0;
|
|
2871
2937
|
}
|
|
2938
|
+
function parsePlistEnv(plist) {
|
|
2939
|
+
const block = /<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/.exec(plist);
|
|
2940
|
+
if (!block) return void 0;
|
|
2941
|
+
const env = {};
|
|
2942
|
+
for (const m of block[1].matchAll(/<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/g)) {
|
|
2943
|
+
env[unescapeXml(m[1])] = unescapeXml(m[2]);
|
|
2944
|
+
}
|
|
2945
|
+
return env;
|
|
2946
|
+
}
|
|
2947
|
+
function parseSystemdEnv(unit) {
|
|
2948
|
+
const env = {};
|
|
2949
|
+
let found = false;
|
|
2950
|
+
for (const m of unit.matchAll(/^Environment="([^="]+)=([^"]*)"\s*$/gm)) {
|
|
2951
|
+
env[m[1]] = m[2];
|
|
2952
|
+
found = true;
|
|
2953
|
+
}
|
|
2954
|
+
return found ? env : void 0;
|
|
2955
|
+
}
|
|
2872
2956
|
function parseSystemdCliPath(unit) {
|
|
2873
2957
|
const line = /^ExecStart=(.*)$/m.exec(unit);
|
|
2874
2958
|
if (!line) return void 0;
|
|
@@ -2877,16 +2961,17 @@ function parseSystemdCliPath(unit) {
|
|
|
2877
2961
|
const parts = command.slice(0, -" start".length).split(" ");
|
|
2878
2962
|
return parts.length === 2 ? parts[1] : void 0;
|
|
2879
2963
|
}
|
|
2880
|
-
function execFacts(unitPath, parse) {
|
|
2964
|
+
function execFacts(unitPath, parse, parseEnv) {
|
|
2881
2965
|
let text;
|
|
2882
2966
|
try {
|
|
2883
2967
|
text = fs4.readFileSync(unitPath, "utf8");
|
|
2884
2968
|
} catch {
|
|
2885
2969
|
return {};
|
|
2886
2970
|
}
|
|
2971
|
+
const unitEnv = parseEnv(text);
|
|
2887
2972
|
const execPath = parse(text);
|
|
2888
|
-
if (!execPath || !path4.isAbsolute(execPath)) return {};
|
|
2889
|
-
return { execPath, execMissing: !fs4.existsSync(execPath) };
|
|
2973
|
+
if (!execPath || !path4.isAbsolute(execPath)) return { ...unitEnv ? { unitEnv } : {} };
|
|
2974
|
+
return { execPath, execMissing: !fs4.existsSync(execPath), ...unitEnv ? { unitEnv } : {} };
|
|
2890
2975
|
}
|
|
2891
2976
|
function systemdUnit() {
|
|
2892
2977
|
const env = serviceEnv();
|
|
@@ -2913,7 +2998,7 @@ function serviceStatus() {
|
|
|
2913
2998
|
if (process.platform === "darwin") {
|
|
2914
2999
|
const unitPath = launchAgentPath();
|
|
2915
3000
|
if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No LaunchAgent installed." };
|
|
2916
|
-
const exec = execFacts(unitPath, parsePlistCliPath);
|
|
3001
|
+
const exec = execFacts(unitPath, parsePlistCliPath, parsePlistEnv);
|
|
2917
3002
|
const printed = run("launchctl", ["print", `gui/${uid()}/${SERVICE_LABEL}`]);
|
|
2918
3003
|
if (!printed.ok) {
|
|
2919
3004
|
return { state: "installed", detail: "LaunchAgent present but not loaded.", unitPath, ...exec };
|
|
@@ -2934,7 +3019,7 @@ function serviceStatus() {
|
|
|
2934
3019
|
state: active.out === "active" ? "running" : "installed",
|
|
2935
3020
|
detail: `systemd user unit is ${active.out || "unknown"}.`,
|
|
2936
3021
|
unitPath,
|
|
2937
|
-
...execFacts(unitPath, parseSystemdCliPath)
|
|
3022
|
+
...execFacts(unitPath, parseSystemdCliPath, parseSystemdEnv)
|
|
2938
3023
|
};
|
|
2939
3024
|
}
|
|
2940
3025
|
if (process.platform === "win32") {
|
|
@@ -4005,6 +4090,10 @@ async function startDaemon() {
|
|
|
4005
4090
|
status: "stopped",
|
|
4006
4091
|
usage,
|
|
4007
4092
|
transcriptPath,
|
|
4093
|
+
// §15.41. A stopped run is the case that needs the backstop MOST: it was cut off
|
|
4094
|
+
// mid-thought, so it almost certainly never reached `run_report` — and whatever it had
|
|
4095
|
+
// got to is what the next run on this task would otherwise have to rediscover.
|
|
4096
|
+
resultText,
|
|
4008
4097
|
mcpServers: extraMcpServers.map((s) => s.key)
|
|
4009
4098
|
});
|
|
4010
4099
|
const by = slot.stop.by;
|
|
@@ -4046,6 +4135,9 @@ async function startDaemon() {
|
|
|
4046
4135
|
status: "done",
|
|
4047
4136
|
usage,
|
|
4048
4137
|
transcriptPath,
|
|
4138
|
+
// §15.41. Only used when the session never called `run_report` — the ordinary path is
|
|
4139
|
+
// that it did, and a real report always wins inside the transaction.
|
|
4140
|
+
resultText,
|
|
4049
4141
|
mcpServers: extraMcpServers.map((s) => s.key)
|
|
4050
4142
|
});
|
|
4051
4143
|
log2(`Job ${job.id} done (${usage.inputTokens}in/${usage.outputTokens}out tokens).`);
|
|
@@ -4099,6 +4191,10 @@ async function startDaemon() {
|
|
|
4099
4191
|
usage,
|
|
4100
4192
|
transcriptPath,
|
|
4101
4193
|
error: failure,
|
|
4194
|
+
// §15.41. A failed run still did work, and the retry — or the next run after the retry
|
|
4195
|
+
// is spent — starts from the context pack alone. `error` is the tail for a human; this
|
|
4196
|
+
// is the continuity for the next session, and they are read by different readers.
|
|
4197
|
+
resultText,
|
|
4102
4198
|
mcpServers: extraMcpServers.map((s) => s.key)
|
|
4103
4199
|
});
|
|
4104
4200
|
if (target.kind === "chat") {
|
|
@@ -4437,7 +4533,7 @@ function glyph(level) {
|
|
|
4437
4533
|
|
|
4438
4534
|
// src/cli/commands/config.ts
|
|
4439
4535
|
var TOGGLES = {
|
|
4440
|
-
notifications: "Desktop notifications when a job starts, finishes or fails",
|
|
4536
|
+
notifications: "Desktop notifications when a job starts, finishes or fails (off by default)",
|
|
4441
4537
|
keepAwake: "Keep this machine awake while a job is running",
|
|
4442
4538
|
autoUpdate: "Install new versions of the runner by itself and restart (PRD \xA715.37)"
|
|
4443
4539
|
};
|
|
@@ -4487,7 +4583,7 @@ function parseParallel(value) {
|
|
|
4487
4583
|
async function runConfigList() {
|
|
4488
4584
|
const config2 = requireConfig();
|
|
4489
4585
|
const values = Object.fromEntries(
|
|
4490
|
-
Object.keys(TOGGLES).map((key) => [key, config2[key]
|
|
4586
|
+
Object.keys(TOGGLES).map((key) => [key, config2[key] ?? TOGGLE_DEFAULTS[key]])
|
|
4491
4587
|
);
|
|
4492
4588
|
const machine = machineCap(config2);
|
|
4493
4589
|
const ships = config2.ships.map((shipId) => ({
|
|
@@ -4646,6 +4742,8 @@ function setParallel(config2, value, ship2) {
|
|
|
4646
4742
|
|
|
4647
4743
|
// src/cli/commands/doctor.ts
|
|
4648
4744
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
4745
|
+
import fs7 from "node:fs";
|
|
4746
|
+
import path8 from "node:path";
|
|
4649
4747
|
import { collection as collection6, doc as doc8, getDoc as getDoc7, getDocs as getDocs5 } from "firebase/firestore";
|
|
4650
4748
|
|
|
4651
4749
|
// src/cli/session.ts
|
|
@@ -4717,8 +4815,36 @@ function serviceCheckFrom(status) {
|
|
|
4717
4815
|
}
|
|
4718
4816
|
return warn("service", "Background service", `Unrecognised service state: ${status.state}.`);
|
|
4719
4817
|
}
|
|
4720
|
-
function
|
|
4721
|
-
|
|
4818
|
+
function serviceBinaryCheckFrom(input) {
|
|
4819
|
+
const { status, binaries, resolves } = input;
|
|
4820
|
+
if (status.state === "not-installed" || status.state === "unsupported") return null;
|
|
4821
|
+
const pathValue = status.unitEnv?.PATH;
|
|
4822
|
+
if (pathValue === void 0) return null;
|
|
4823
|
+
const relative = [...new Set(binaries)].filter((b) => !path8.isAbsolute(b));
|
|
4824
|
+
if (relative.length === 0) return null;
|
|
4825
|
+
const missing = relative.filter((b) => !resolves(b, pathValue));
|
|
4826
|
+
const id = "service:path";
|
|
4827
|
+
const label = "Service PATH";
|
|
4828
|
+
if (missing.length === 0) {
|
|
4829
|
+
return ok(id, label, `The daemon can reach ${relative.join(", ")}.`);
|
|
4830
|
+
}
|
|
4831
|
+
return warn(
|
|
4832
|
+
id,
|
|
4833
|
+
label,
|
|
4834
|
+
`The daemon's PATH is missing ${missing.join(", ")} \u2014 it was captured when the service was installed, and jobs will fail with \`spawn ${missing[0]} ENOENT\` even though this terminal finds it.`,
|
|
4835
|
+
"Run `lumi-runner service install` from a shell where these work, to rewrite the unit with the current PATH."
|
|
4836
|
+
);
|
|
4837
|
+
}
|
|
4838
|
+
function resolvesOnPath(binary, pathValue) {
|
|
4839
|
+
for (const dir of pathValue.split(path8.delimiter)) {
|
|
4840
|
+
if (!dir) continue;
|
|
4841
|
+
try {
|
|
4842
|
+
fs7.accessSync(path8.join(dir, binary), fs7.constants.X_OK);
|
|
4843
|
+
return true;
|
|
4844
|
+
} catch {
|
|
4845
|
+
}
|
|
4846
|
+
}
|
|
4847
|
+
return false;
|
|
4722
4848
|
}
|
|
4723
4849
|
function versionCheckFrom(input) {
|
|
4724
4850
|
const { current, latest, channel, autoUpdate } = input;
|
|
@@ -4886,14 +5012,17 @@ async function runDoctor() {
|
|
|
4886
5012
|
for (const id of shipResults.engines) engines.add(id);
|
|
4887
5013
|
}
|
|
4888
5014
|
const needsGithub = shipResults.needsGithub;
|
|
5015
|
+
const jobBinaries = [];
|
|
4889
5016
|
for (const engineId of engines) {
|
|
4890
5017
|
const health = await getDriver(engineId).healthCheck();
|
|
5018
|
+
if (health.binary) jobBinaries.push(health.binary);
|
|
4891
5019
|
checks.push(
|
|
4892
5020
|
health.ok ? ok(`engine:${engineId}`, `Engine "${engineId}"`, health.detail) : fail(`engine:${engineId}`, `Engine "${engineId}"`, health.detail, health.fix)
|
|
4893
5021
|
);
|
|
4894
5022
|
}
|
|
4895
5023
|
if (needsGithub) {
|
|
4896
5024
|
for (const binary of ["git", "gh"]) {
|
|
5025
|
+
jobBinaries.push(binary);
|
|
4897
5026
|
checks.push(
|
|
4898
5027
|
onPath(binary) ? ok(`bin:${binary}`, `\`${binary}\``, "On PATH.") : fail(
|
|
4899
5028
|
`bin:${binary}`,
|
|
@@ -4905,7 +5034,14 @@ async function runDoctor() {
|
|
|
4905
5034
|
}
|
|
4906
5035
|
}
|
|
4907
5036
|
checks.push(await checkMcp(mcpUrl(config2)));
|
|
4908
|
-
|
|
5037
|
+
const service2 = serviceStatus();
|
|
5038
|
+
checks.push(serviceCheckFrom(service2));
|
|
5039
|
+
const servicePath = serviceBinaryCheckFrom({
|
|
5040
|
+
status: service2,
|
|
5041
|
+
binaries: jobBinaries,
|
|
5042
|
+
resolves: resolvesOnPath
|
|
5043
|
+
});
|
|
5044
|
+
if (servicePath) checks.push(servicePath);
|
|
4909
5045
|
checks.push(await checkVersion(config2));
|
|
4910
5046
|
progress.stop("Checks complete.");
|
|
4911
5047
|
return report2(checks);
|
|
@@ -5161,6 +5297,17 @@ async function runServiceUninstall() {
|
|
|
5161
5297
|
say.success("Service removed. The daemon will not start on its own any more.");
|
|
5162
5298
|
return 0;
|
|
5163
5299
|
}
|
|
5300
|
+
async function runServiceRepair(reason) {
|
|
5301
|
+
const result = installService();
|
|
5302
|
+
restartService();
|
|
5303
|
+
if (isJson()) {
|
|
5304
|
+
emitJson({ repaired: true, reason, unitPath: result.unitPath, notes: result.notes, ...serviceStatus() });
|
|
5305
|
+
return 0;
|
|
5306
|
+
}
|
|
5307
|
+
say.success(`Service reinstalled (${reason}): ${result.unitPath}`);
|
|
5308
|
+
for (const note2 of result.notes) say.warn(note2);
|
|
5309
|
+
return 0;
|
|
5310
|
+
}
|
|
5164
5311
|
async function runServiceRestart() {
|
|
5165
5312
|
restartService();
|
|
5166
5313
|
if (isJson()) {
|
|
@@ -5185,20 +5332,20 @@ async function runServiceStatus() {
|
|
|
5185
5332
|
}
|
|
5186
5333
|
|
|
5187
5334
|
// src/cli/commands/uninstall.ts
|
|
5188
|
-
import
|
|
5335
|
+
import fs8 from "node:fs";
|
|
5189
5336
|
async function runUninstall(options) {
|
|
5190
5337
|
const before = serviceStatus();
|
|
5191
5338
|
const dir = configDir();
|
|
5192
5339
|
const hadService = before.state !== "not-installed" && before.state !== "unsupported";
|
|
5193
5340
|
if (hadService) uninstallService();
|
|
5194
5341
|
let purged = false;
|
|
5195
|
-
if (options.purge &&
|
|
5342
|
+
if (options.purge && fs8.existsSync(dir)) {
|
|
5196
5343
|
const confirmed = await promptConfirm({
|
|
5197
5344
|
message: `Delete ${dir}? This machine loses its identity \u2014 a captain has to approve it again after reinstalling.`,
|
|
5198
5345
|
initialValue: false
|
|
5199
5346
|
});
|
|
5200
5347
|
if (confirmed) {
|
|
5201
|
-
|
|
5348
|
+
fs8.rmSync(dir, { recursive: true, force: true });
|
|
5202
5349
|
purged = true;
|
|
5203
5350
|
}
|
|
5204
5351
|
}
|
|
@@ -5335,7 +5482,8 @@ async function runSetup(options) {
|
|
|
5335
5482
|
}
|
|
5336
5483
|
say.step("Checking this machine\u2026");
|
|
5337
5484
|
const doctorExit = await runDoctor();
|
|
5338
|
-
const
|
|
5485
|
+
const status = serviceStatus();
|
|
5486
|
+
const service2 = status.state;
|
|
5339
5487
|
if (service2 === "not-installed") {
|
|
5340
5488
|
const install = await promptConfirm({
|
|
5341
5489
|
message: "Start the daemon automatically whenever this machine is on?",
|
|
@@ -5345,8 +5493,14 @@ async function runSetup(options) {
|
|
|
5345
5493
|
if (install) await runServiceInstall();
|
|
5346
5494
|
else say.info("Skipped. Run `lumi-runner start` manually, or `lumi-runner service install` later.");
|
|
5347
5495
|
} else if (service2 === "running" || service2 === "installed") {
|
|
5348
|
-
|
|
5349
|
-
|
|
5496
|
+
const drift = serviceEnvDrift(serviceEnv(), status.unitEnv);
|
|
5497
|
+
if (drift.length > 0) {
|
|
5498
|
+
say.step(`Reinstalling the service \u2014 ${drift.join(", ")} changed since it was installed\u2026`);
|
|
5499
|
+
await runServiceRepair(`${drift.join(", ")} changed`);
|
|
5500
|
+
} else {
|
|
5501
|
+
say.step("Restarting the daemon so it picks up this configuration\u2026");
|
|
5502
|
+
await runServiceRestart();
|
|
5503
|
+
}
|
|
5350
5504
|
}
|
|
5351
5505
|
say.outro(
|
|
5352
5506
|
doctorExit === 0 ? "Ready. This machine will claim jobs for its Ships." : "Setup finished, but some checks failed \u2014 fix those and re-run `lumi-runner doctor`."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumi.ai/runner",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Lumi Crew runner daemon — claims jobs from your Ships and executes them as headless Claude sessions on your own machine.",
|
|
6
6
|
"//name": "The ONLY package in this monorepo published to the public registry, so it is the one that does not follow the internal @lumi/crew-* convention: `@lumi` is not a scope we own, `@lumi.ai` is (the npm org). The workspace DIRECTORY stays packages/crew/runner — renaming the package is not renaming the folder.",
|