@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.
Files changed (3) hide show
  1. package/README.md +24 -4
  2. package/dist/cli.js +206 -52
  3. 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, and whether that service still points at a CLI that exists.
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
- - **A desktop notification** fires on job start, finish and terminal failure, and when the daemon is
191
- stopped with work in flight. Silence it with `lumi-runner config set notifications off`.
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` | Disable desktop notifications |
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(path8) {
255
- const parts = path8.split(/[\\/]/).filter(Boolean);
256
- return parts[parts.length - 1] ?? path8;
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 path8 = str(input, "file_path");
345
- return path8 ? basename(path8) : void 0;
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.2" : "0.0.0-dev";
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()?.notifications !== false;
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
- previousReports: jobsSnap.docs.filter((d) => d.id !== job.id).slice(0, MAX_PREVIOUS_JOBS_READ).map((d) => d.data().report).filter((r) => !!r).reverse()
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
- "End every session by calling run_report: what you did, what remains, what the next run must know. 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)."
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
- parts.push(
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
- parts.push(
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
- parts.push(
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
- parts.push(
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
- parts.push(
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 reports = ctx.previousReports.map((r, i) => `## Run ${i + 1}
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
- ${r}`).join("\n\n");
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
- ${reports}`);
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) => d.data().report).filter((r) => !!r).reverse();
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 reports = ctx.previousReports.map((r, i) => `## Earlier run ${i + 1}
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
- ${r}`).join("\n\n");
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
- ${reports}`
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 path8 = `crew/${shipId}/transcripts/${jobId}.jsonl`;
2432
- await uploadBytes(storageRef(storage, path8), new TextEncoder().encode(redacted), {
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 path8;
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] !== false])
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 checkService() {
4721
- return serviceCheckFrom(serviceStatus());
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
- checks.push(checkService());
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 fs7 from "node:fs";
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 && fs7.existsSync(dir)) {
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
- fs7.rmSync(dir, { recursive: true, force: true });
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 service2 = serviceStatus().state;
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
- say.step("Restarting the daemon so it picks up this configuration\u2026");
5349
- await runServiceRestart();
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.2",
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.",