@lumi.ai/runner 0.6.2 → 0.6.3

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 +116 -27
  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
@@ -251,9 +251,9 @@ function str(input, key) {
251
251
  const value = input[key];
252
252
  return typeof value === "string" && value.trim() ? value : void 0;
253
253
  }
254
- function basename(path8) {
255
- const parts = path8.split(/[\\/]/).filter(Boolean);
256
- return parts[parts.length - 1] ?? path8;
254
+ function basename(path9) {
255
+ const parts = path9.split(/[\\/]/).filter(Boolean);
256
+ return parts[parts.length - 1] ?? path9;
257
257
  }
258
258
  function hostOf(url) {
259
259
  try {
@@ -341,8 +341,8 @@ function builtinDetail(tool, input) {
341
341
  case "Write":
342
342
  case "Edit":
343
343
  case "MultiEdit": {
344
- const path8 = str(input, "file_path");
345
- return path8 ? basename(path8) : void 0;
344
+ const path9 = str(input, "file_path");
345
+ return path9 ? basename(path9) : void 0;
346
346
  }
347
347
  case "Glob":
348
348
  case "Grep":
@@ -759,12 +759,20 @@ function forgetShip(config2, shipId) {
759
759
  function allowsLocalMcp(config2, shipId) {
760
760
  return Array.isArray(config2.allowLocalMcp) && config2.allowLocalMcp.includes(shipId);
761
761
  }
762
+ var TOGGLE_DEFAULTS = {
763
+ notifications: false,
764
+ keepAwake: true,
765
+ autoUpdate: true
766
+ };
767
+ function notificationsEnabled(config2) {
768
+ return config2?.notifications ?? TOGGLE_DEFAULTS.notifications;
769
+ }
762
770
  function mcpUrl(config2) {
763
771
  return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`;
764
772
  }
765
773
 
766
774
  // src/version.ts
767
- var RUNNER_VERSION = true ? "0.6.2" : "0.0.0-dev";
775
+ var RUNNER_VERSION = true ? "0.6.3" : "0.0.0-dev";
768
776
 
769
777
  // src/auth.ts
770
778
  import { signInWithCustomToken } from "firebase/auth";
@@ -1030,7 +1038,7 @@ var WINDOWS_SCRIPT = [
1030
1038
  ].join(" ");
1031
1039
  function enabled() {
1032
1040
  if (process.env.CREW_NO_NOTIFY === "1") return false;
1033
- return loadConfig()?.notifications !== false;
1041
+ return notificationsEnabled(loadConfig());
1034
1042
  }
1035
1043
  function notify(title, body) {
1036
1044
  if (!enabled()) return;
@@ -1890,7 +1898,7 @@ async function claudeHealthCheck() {
1890
1898
  const done = (health) => {
1891
1899
  if (settled) return;
1892
1900
  settled = true;
1893
- resolve(health);
1901
+ resolve({ ...health, binary: bin });
1894
1902
  };
1895
1903
  let stdout = "";
1896
1904
  const child = spawn3(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
@@ -2428,11 +2436,11 @@ function redactTranscript(transcript, knownSecrets) {
2428
2436
  return out;
2429
2437
  }
2430
2438
  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), {
2439
+ const path9 = `crew/${shipId}/transcripts/${jobId}.jsonl`;
2440
+ await uploadBytes(storageRef(storage, path9), new TextEncoder().encode(redacted), {
2433
2441
  contentType: "application/x-ndjson"
2434
2442
  });
2435
- return path8;
2443
+ return path9;
2436
2444
  }
2437
2445
  function utcDay(millis) {
2438
2446
  return new Date(millis).toISOString().slice(0, 10);
@@ -2771,6 +2779,10 @@ function serviceEnv() {
2771
2779
  }
2772
2780
  return env;
2773
2781
  }
2782
+ function serviceEnvDrift(current, installed) {
2783
+ if (!installed) return [];
2784
+ return Object.keys(current).filter((name) => current[name] !== installed[name]).sort();
2785
+ }
2774
2786
  function removeLegacyService() {
2775
2787
  const removed = [];
2776
2788
  if (process.platform === "darwin") {
@@ -2869,6 +2881,24 @@ function parsePlistCliPath(plist) {
2869
2881
  const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1]));
2870
2882
  return args.length >= 2 ? args[1] : void 0;
2871
2883
  }
2884
+ function parsePlistEnv(plist) {
2885
+ const block = /<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/.exec(plist);
2886
+ if (!block) return void 0;
2887
+ const env = {};
2888
+ for (const m of block[1].matchAll(/<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/g)) {
2889
+ env[unescapeXml(m[1])] = unescapeXml(m[2]);
2890
+ }
2891
+ return env;
2892
+ }
2893
+ function parseSystemdEnv(unit) {
2894
+ const env = {};
2895
+ let found = false;
2896
+ for (const m of unit.matchAll(/^Environment="([^="]+)=([^"]*)"\s*$/gm)) {
2897
+ env[m[1]] = m[2];
2898
+ found = true;
2899
+ }
2900
+ return found ? env : void 0;
2901
+ }
2872
2902
  function parseSystemdCliPath(unit) {
2873
2903
  const line = /^ExecStart=(.*)$/m.exec(unit);
2874
2904
  if (!line) return void 0;
@@ -2877,16 +2907,17 @@ function parseSystemdCliPath(unit) {
2877
2907
  const parts = command.slice(0, -" start".length).split(" ");
2878
2908
  return parts.length === 2 ? parts[1] : void 0;
2879
2909
  }
2880
- function execFacts(unitPath, parse) {
2910
+ function execFacts(unitPath, parse, parseEnv) {
2881
2911
  let text;
2882
2912
  try {
2883
2913
  text = fs4.readFileSync(unitPath, "utf8");
2884
2914
  } catch {
2885
2915
  return {};
2886
2916
  }
2917
+ const unitEnv = parseEnv(text);
2887
2918
  const execPath = parse(text);
2888
- if (!execPath || !path4.isAbsolute(execPath)) return {};
2889
- return { execPath, execMissing: !fs4.existsSync(execPath) };
2919
+ if (!execPath || !path4.isAbsolute(execPath)) return { ...unitEnv ? { unitEnv } : {} };
2920
+ return { execPath, execMissing: !fs4.existsSync(execPath), ...unitEnv ? { unitEnv } : {} };
2890
2921
  }
2891
2922
  function systemdUnit() {
2892
2923
  const env = serviceEnv();
@@ -2913,7 +2944,7 @@ function serviceStatus() {
2913
2944
  if (process.platform === "darwin") {
2914
2945
  const unitPath = launchAgentPath();
2915
2946
  if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No LaunchAgent installed." };
2916
- const exec = execFacts(unitPath, parsePlistCliPath);
2947
+ const exec = execFacts(unitPath, parsePlistCliPath, parsePlistEnv);
2917
2948
  const printed = run("launchctl", ["print", `gui/${uid()}/${SERVICE_LABEL}`]);
2918
2949
  if (!printed.ok) {
2919
2950
  return { state: "installed", detail: "LaunchAgent present but not loaded.", unitPath, ...exec };
@@ -2934,7 +2965,7 @@ function serviceStatus() {
2934
2965
  state: active.out === "active" ? "running" : "installed",
2935
2966
  detail: `systemd user unit is ${active.out || "unknown"}.`,
2936
2967
  unitPath,
2937
- ...execFacts(unitPath, parseSystemdCliPath)
2968
+ ...execFacts(unitPath, parseSystemdCliPath, parseSystemdEnv)
2938
2969
  };
2939
2970
  }
2940
2971
  if (process.platform === "win32") {
@@ -4437,7 +4468,7 @@ function glyph(level) {
4437
4468
 
4438
4469
  // src/cli/commands/config.ts
4439
4470
  var TOGGLES = {
4440
- notifications: "Desktop notifications when a job starts, finishes or fails",
4471
+ notifications: "Desktop notifications when a job starts, finishes or fails (off by default)",
4441
4472
  keepAwake: "Keep this machine awake while a job is running",
4442
4473
  autoUpdate: "Install new versions of the runner by itself and restart (PRD \xA715.37)"
4443
4474
  };
@@ -4487,7 +4518,7 @@ function parseParallel(value) {
4487
4518
  async function runConfigList() {
4488
4519
  const config2 = requireConfig();
4489
4520
  const values = Object.fromEntries(
4490
- Object.keys(TOGGLES).map((key) => [key, config2[key] !== false])
4521
+ Object.keys(TOGGLES).map((key) => [key, config2[key] ?? TOGGLE_DEFAULTS[key]])
4491
4522
  );
4492
4523
  const machine = machineCap(config2);
4493
4524
  const ships = config2.ships.map((shipId) => ({
@@ -4646,6 +4677,8 @@ function setParallel(config2, value, ship2) {
4646
4677
 
4647
4678
  // src/cli/commands/doctor.ts
4648
4679
  import { spawnSync as spawnSync3 } from "node:child_process";
4680
+ import fs7 from "node:fs";
4681
+ import path8 from "node:path";
4649
4682
  import { collection as collection6, doc as doc8, getDoc as getDoc7, getDocs as getDocs5 } from "firebase/firestore";
4650
4683
 
4651
4684
  // src/cli/session.ts
@@ -4717,8 +4750,36 @@ function serviceCheckFrom(status) {
4717
4750
  }
4718
4751
  return warn("service", "Background service", `Unrecognised service state: ${status.state}.`);
4719
4752
  }
4720
- function checkService() {
4721
- return serviceCheckFrom(serviceStatus());
4753
+ function serviceBinaryCheckFrom(input) {
4754
+ const { status, binaries, resolves } = input;
4755
+ if (status.state === "not-installed" || status.state === "unsupported") return null;
4756
+ const pathValue = status.unitEnv?.PATH;
4757
+ if (pathValue === void 0) return null;
4758
+ const relative = [...new Set(binaries)].filter((b) => !path8.isAbsolute(b));
4759
+ if (relative.length === 0) return null;
4760
+ const missing = relative.filter((b) => !resolves(b, pathValue));
4761
+ const id = "service:path";
4762
+ const label = "Service PATH";
4763
+ if (missing.length === 0) {
4764
+ return ok(id, label, `The daemon can reach ${relative.join(", ")}.`);
4765
+ }
4766
+ return warn(
4767
+ id,
4768
+ label,
4769
+ `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.`,
4770
+ "Run `lumi-runner service install` from a shell where these work, to rewrite the unit with the current PATH."
4771
+ );
4772
+ }
4773
+ function resolvesOnPath(binary, pathValue) {
4774
+ for (const dir of pathValue.split(path8.delimiter)) {
4775
+ if (!dir) continue;
4776
+ try {
4777
+ fs7.accessSync(path8.join(dir, binary), fs7.constants.X_OK);
4778
+ return true;
4779
+ } catch {
4780
+ }
4781
+ }
4782
+ return false;
4722
4783
  }
4723
4784
  function versionCheckFrom(input) {
4724
4785
  const { current, latest, channel, autoUpdate } = input;
@@ -4886,14 +4947,17 @@ async function runDoctor() {
4886
4947
  for (const id of shipResults.engines) engines.add(id);
4887
4948
  }
4888
4949
  const needsGithub = shipResults.needsGithub;
4950
+ const jobBinaries = [];
4889
4951
  for (const engineId of engines) {
4890
4952
  const health = await getDriver(engineId).healthCheck();
4953
+ if (health.binary) jobBinaries.push(health.binary);
4891
4954
  checks.push(
4892
4955
  health.ok ? ok(`engine:${engineId}`, `Engine "${engineId}"`, health.detail) : fail(`engine:${engineId}`, `Engine "${engineId}"`, health.detail, health.fix)
4893
4956
  );
4894
4957
  }
4895
4958
  if (needsGithub) {
4896
4959
  for (const binary of ["git", "gh"]) {
4960
+ jobBinaries.push(binary);
4897
4961
  checks.push(
4898
4962
  onPath(binary) ? ok(`bin:${binary}`, `\`${binary}\``, "On PATH.") : fail(
4899
4963
  `bin:${binary}`,
@@ -4905,7 +4969,14 @@ async function runDoctor() {
4905
4969
  }
4906
4970
  }
4907
4971
  checks.push(await checkMcp(mcpUrl(config2)));
4908
- checks.push(checkService());
4972
+ const service2 = serviceStatus();
4973
+ checks.push(serviceCheckFrom(service2));
4974
+ const servicePath = serviceBinaryCheckFrom({
4975
+ status: service2,
4976
+ binaries: jobBinaries,
4977
+ resolves: resolvesOnPath
4978
+ });
4979
+ if (servicePath) checks.push(servicePath);
4909
4980
  checks.push(await checkVersion(config2));
4910
4981
  progress.stop("Checks complete.");
4911
4982
  return report2(checks);
@@ -5161,6 +5232,17 @@ async function runServiceUninstall() {
5161
5232
  say.success("Service removed. The daemon will not start on its own any more.");
5162
5233
  return 0;
5163
5234
  }
5235
+ async function runServiceRepair(reason) {
5236
+ const result = installService();
5237
+ restartService();
5238
+ if (isJson()) {
5239
+ emitJson({ repaired: true, reason, unitPath: result.unitPath, notes: result.notes, ...serviceStatus() });
5240
+ return 0;
5241
+ }
5242
+ say.success(`Service reinstalled (${reason}): ${result.unitPath}`);
5243
+ for (const note2 of result.notes) say.warn(note2);
5244
+ return 0;
5245
+ }
5164
5246
  async function runServiceRestart() {
5165
5247
  restartService();
5166
5248
  if (isJson()) {
@@ -5185,20 +5267,20 @@ async function runServiceStatus() {
5185
5267
  }
5186
5268
 
5187
5269
  // src/cli/commands/uninstall.ts
5188
- import fs7 from "node:fs";
5270
+ import fs8 from "node:fs";
5189
5271
  async function runUninstall(options) {
5190
5272
  const before = serviceStatus();
5191
5273
  const dir = configDir();
5192
5274
  const hadService = before.state !== "not-installed" && before.state !== "unsupported";
5193
5275
  if (hadService) uninstallService();
5194
5276
  let purged = false;
5195
- if (options.purge && fs7.existsSync(dir)) {
5277
+ if (options.purge && fs8.existsSync(dir)) {
5196
5278
  const confirmed = await promptConfirm({
5197
5279
  message: `Delete ${dir}? This machine loses its identity \u2014 a captain has to approve it again after reinstalling.`,
5198
5280
  initialValue: false
5199
5281
  });
5200
5282
  if (confirmed) {
5201
- fs7.rmSync(dir, { recursive: true, force: true });
5283
+ fs8.rmSync(dir, { recursive: true, force: true });
5202
5284
  purged = true;
5203
5285
  }
5204
5286
  }
@@ -5335,7 +5417,8 @@ async function runSetup(options) {
5335
5417
  }
5336
5418
  say.step("Checking this machine\u2026");
5337
5419
  const doctorExit = await runDoctor();
5338
- const service2 = serviceStatus().state;
5420
+ const status = serviceStatus();
5421
+ const service2 = status.state;
5339
5422
  if (service2 === "not-installed") {
5340
5423
  const install = await promptConfirm({
5341
5424
  message: "Start the daemon automatically whenever this machine is on?",
@@ -5345,8 +5428,14 @@ async function runSetup(options) {
5345
5428
  if (install) await runServiceInstall();
5346
5429
  else say.info("Skipped. Run `lumi-runner start` manually, or `lumi-runner service install` later.");
5347
5430
  } else if (service2 === "running" || service2 === "installed") {
5348
- say.step("Restarting the daemon so it picks up this configuration\u2026");
5349
- await runServiceRestart();
5431
+ const drift = serviceEnvDrift(serviceEnv(), status.unitEnv);
5432
+ if (drift.length > 0) {
5433
+ say.step(`Reinstalling the service \u2014 ${drift.join(", ")} changed since it was installed\u2026`);
5434
+ await runServiceRepair(`${drift.join(", ")} changed`);
5435
+ } else {
5436
+ say.step("Restarting the daemon so it picks up this configuration\u2026");
5437
+ await runServiceRestart();
5438
+ }
5350
5439
  }
5351
5440
  say.outro(
5352
5441
  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.3",
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.",