@lumi.ai/runner 0.5.6 → 0.5.8

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 +39 -1
  2. package/dist/cli.js +1454 -803
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -4,12 +4,12 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // src/daemon.ts
7
- import os3 from "node:os";
7
+ import os4 from "node:os";
8
8
  import {
9
9
  collection as collection5,
10
10
  deleteField as deleteField2,
11
11
  doc as doc7,
12
- getDoc as getDoc5,
12
+ getDoc as getDoc6,
13
13
  getDocs as getDocs4,
14
14
  onSnapshot as onSnapshot2,
15
15
  orderBy as orderBy4,
@@ -242,15 +242,18 @@ function clampStepText(text, max) {
242
242
  function firstLine(text) {
243
243
  return text.split("\n").find((l) => l.trim()) ?? "";
244
244
  }
245
+ function step(kind, label, detail) {
246
+ return detail ? { kind, label, detail } : { kind, label };
247
+ }
245
248
  function str(input, key) {
246
249
  if (!input || typeof input !== "object")
247
250
  return void 0;
248
251
  const value = input[key];
249
252
  return typeof value === "string" && value.trim() ? value : void 0;
250
253
  }
251
- function basename(path5) {
252
- const parts = path5.split(/[\\/]/).filter(Boolean);
253
- return parts[parts.length - 1] ?? path5;
254
+ function basename(path8) {
255
+ const parts = path8.split(/[\\/]/).filter(Boolean);
256
+ return parts[parts.length - 1] ?? path8;
254
257
  }
255
258
  function hostOf(url) {
256
259
  try {
@@ -333,8 +336,8 @@ function builtinDetail(tool, input) {
333
336
  case "Write":
334
337
  case "Edit":
335
338
  case "MultiEdit": {
336
- const path5 = str(input, "file_path");
337
- return path5 ? basename(path5) : void 0;
339
+ const path8 = str(input, "file_path");
340
+ return path8 ? basename(path8) : void 0;
338
341
  }
339
342
  case "Glob":
340
343
  case "Grep":
@@ -354,36 +357,28 @@ function builtinDetail(tool, input) {
354
357
  function describeToolUse(name, input, opts) {
355
358
  if (name.startsWith(WORKSPACE_PREFIX)) {
356
359
  const tool = name.slice(WORKSPACE_PREFIX.length);
357
- return {
358
- kind: "tool",
359
- label: WORKSPACE_LABELS[tool] ?? "Working",
360
- detail: workspaceDetail(tool, input)
361
- };
360
+ return step("tool", WORKSPACE_LABELS[tool] ?? "Working", workspaceDetail(tool, input));
362
361
  }
363
362
  if (name.startsWith(MCP_PREFIX)) {
364
363
  const rest = name.slice(MCP_PREFIX.length);
365
364
  const split = rest.indexOf("__");
366
365
  const key = split < 0 ? rest : rest.slice(0, split);
367
366
  const tool = split < 0 ? void 0 : rest.slice(split + 2);
368
- return {
369
- kind: "tool",
370
- label: `Using ${opts?.mcpNames?.[key] ?? key}`,
371
- detail: tool || void 0
372
- };
367
+ return step("tool", `Using ${opts?.mcpNames?.[key] ?? key}`, tool);
373
368
  }
374
369
  const label = BUILTIN_LABELS[name];
375
370
  if (label)
376
- return { kind: "tool", label, detail: builtinDetail(name, input) };
377
- return { kind: "tool", label: "Working" };
371
+ return step("tool", label, builtinDetail(name, input));
372
+ return step("tool", "Working");
378
373
  }
379
374
  function describeAssistantText(text) {
380
- return { kind: "saying", label: "Working it out", detail: firstLine(text) || void 0 };
375
+ return step("saying", "Working it out", firstLine(text));
381
376
  }
382
- function pushJobStep(progress, step, max = MAX_JOB_STEPS) {
383
- const steps = [...progress?.steps ?? [], step];
377
+ function pushJobStep(progress, step2, max = MAX_JOB_STEPS) {
378
+ const steps = [...progress?.steps ?? [], step2];
384
379
  return {
385
380
  seq: (progress?.seq ?? 0) + 1,
386
- updatedAt: step.at,
381
+ updatedAt: step2.at,
387
382
  // Keep the NEWEST `max`, oldest first — a reader renders top to bottom.
388
383
  steps: steps.slice(Math.max(0, steps.length - max))
389
384
  };
@@ -739,7 +734,7 @@ function mcpUrl(config2) {
739
734
  }
740
735
 
741
736
  // src/version.ts
742
- var RUNNER_VERSION = true ? "0.5.6" : "0.0.0-dev";
737
+ var RUNNER_VERSION = true ? "0.5.8" : "0.0.0-dev";
743
738
 
744
739
  // src/auth.ts
745
740
  import { signInWithCustomToken } from "firebase/auth";
@@ -1777,7 +1772,7 @@ async function runSession(input, bin, dirs) {
1777
1772
  if (event.type === "result") resultEvent = event;
1778
1773
  if (event.type === "assistant") input.log("claude: assistant turn");
1779
1774
  if (input.onStep) {
1780
- for (const step of stepsFromClaudeEvent(event, { mcpNames })) input.onStep(step);
1775
+ for (const step2 of stepsFromClaudeEvent(event, { mcpNames })) input.onStep(step2);
1781
1776
  }
1782
1777
  } catch {
1783
1778
  }
@@ -2054,6 +2049,7 @@ import {
2054
2049
  collection as collection4,
2055
2050
  deleteField,
2056
2051
  doc as doc6,
2052
+ getDoc as getDoc5,
2057
2053
  getDocs as getDocs3,
2058
2054
  limit as fsLimit,
2059
2055
  orderBy as orderBy3,
@@ -2076,11 +2072,11 @@ function redactTranscript(transcript, knownSecrets) {
2076
2072
  return out;
2077
2073
  }
2078
2074
  async function uploadTranscript(storage, shipId, jobId, redacted) {
2079
- const path5 = `crew/${shipId}/transcripts/${jobId}.jsonl`;
2080
- await uploadBytes(storageRef(storage, path5), new TextEncoder().encode(redacted), {
2075
+ const path8 = `crew/${shipId}/transcripts/${jobId}.jsonl`;
2076
+ await uploadBytes(storageRef(storage, path8), new TextEncoder().encode(redacted), {
2081
2077
  contentType: "application/x-ndjson"
2082
2078
  });
2083
- return path5;
2079
+ return path8;
2084
2080
  }
2085
2081
  function utcDay(millis) {
2086
2082
  return new Date(millis).toISOString().slice(0, 10);
@@ -2190,16 +2186,27 @@ ${error.slice(0, 800)}
2190
2186
  }
2191
2187
  async function markTaskStopped(db, shipId, job, stoppedBy) {
2192
2188
  const taskRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.tasks, job.taskId);
2189
+ const who = await actorName(db, shipId, stoppedBy);
2193
2190
  await addDoc(collection4(taskRef, COLLECTIONS.activity), {
2194
2191
  author: { type: "agent", id: job.agentId },
2195
2192
  createdAt: Date.now(),
2196
2193
  kind: "comment",
2197
- content: `This run was stopped by ${stoppedBy} before it finished. The task is unchanged.`
2194
+ content: `This run was stopped by ${who} before it finished. The task is unchanged.`
2198
2195
  });
2199
2196
  }
2197
+ async function actorName(db, shipId, actorId) {
2198
+ try {
2199
+ const snap = await getDoc5(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.members, actorId));
2200
+ const m = snap.data();
2201
+ return m?.displayName || m?.email || actorId;
2202
+ } catch {
2203
+ return actorId;
2204
+ }
2205
+ }
2200
2206
  async function markChatStopped(db, shipId, job, stoppedBy) {
2201
2207
  const chatRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
2202
- const content = `Stopped by ${stoppedBy} before I finished. Write again to start a fresh run.`;
2208
+ const who = await actorName(db, shipId, stoppedBy);
2209
+ const content = `Stopped by ${who} before I finished. Write again to start a fresh run.`;
2203
2210
  await addDoc(collection4(chatRef, COLLECTIONS.chatMessages), {
2204
2211
  author: { type: "agent", id: job.agentId },
2205
2212
  content,
@@ -2264,16 +2271,24 @@ async function ensureChatReply(db, shipId, job, resultText) {
2264
2271
  // src/jobs/progress.ts
2265
2272
  var PROGRESS_FLUSH_MS = 3e3;
2266
2273
  var PROGRESS_DENIALS_BEFORE_PROBE = 2;
2267
- function redactStep(step, secrets) {
2268
- const label = clampStepText(redactTranscript(step.label, secrets), MAX_STEP_LABEL);
2269
- if (step.detail === void 0) return { ...step, label };
2270
- const detail = clampStepText(redactTranscript(step.detail, secrets), MAX_STEP_DETAIL);
2271
- return detail ? { ...step, label, detail } : { at: step.at, kind: step.kind, label };
2274
+ function redactStep(step2, secrets) {
2275
+ const label = clampStepText(redactTranscript(step2.label, secrets), MAX_STEP_LABEL);
2276
+ const base = { at: step2.at, kind: step2.kind, label };
2277
+ if (step2.detail === void 0) return base;
2278
+ const detail = clampStepText(redactTranscript(step2.detail, secrets), MAX_STEP_DETAIL);
2279
+ return detail ? { ...base, detail } : base;
2280
+ }
2281
+ function errorCode(error) {
2282
+ return error?.code ?? "";
2272
2283
  }
2273
2284
  function isDenied(error) {
2274
- const code = error?.code ?? "";
2285
+ const code = errorCode(error);
2275
2286
  return code === "permission-denied" || code.endsWith("/permission-denied");
2276
2287
  }
2288
+ function isUnwritable(error) {
2289
+ const code = errorCode(error);
2290
+ return code === "invalid-argument" || code.endsWith("/invalid-argument");
2291
+ }
2277
2292
  function createProgressWriter(deps) {
2278
2293
  const now = deps.now ?? Date.now;
2279
2294
  const flushMs = deps.flushMs ?? PROGRESS_FLUSH_MS;
@@ -2292,12 +2307,18 @@ function createProgressWriter(deps) {
2292
2307
  }
2293
2308
  if (closed || inFlight || pending.length === 0) return;
2294
2309
  const secrets = deps.secrets();
2295
- for (const step of pending) progress = pushJobStep(progress, redactStep(step, secrets));
2310
+ for (const step2 of pending) progress = pushJobStep(progress, redactStep(step2, secrets));
2296
2311
  pending = [];
2297
2312
  lastWriteAt = now();
2298
2313
  inFlight = true;
2299
2314
  const snapshot = progress;
2300
- void deps.write(snapshot).then(() => {
2315
+ let written;
2316
+ try {
2317
+ written = deps.write(snapshot);
2318
+ } catch (e) {
2319
+ written = Promise.reject(e);
2320
+ }
2321
+ void written.then(() => {
2301
2322
  denials = 0;
2302
2323
  }).catch((e) => {
2303
2324
  if (isDenied(e)) {
@@ -2309,9 +2330,12 @@ function createProgressWriter(deps) {
2309
2330
  return;
2310
2331
  }
2311
2332
  denials = 0;
2333
+ if (isUnwritable(e)) closed = true;
2312
2334
  if (!loggedFailure) {
2313
2335
  loggedFailure = true;
2314
- deps.log(`progress write failed (further ones silent): ${e instanceof Error ? e.message : e}`);
2336
+ deps.log(
2337
+ `progress write failed${closed ? " \u2014 live steps are off for this job" : " (further ones silent)"}: ${e instanceof Error ? e.message : e}`
2338
+ );
2315
2339
  }
2316
2340
  }).finally(() => {
2317
2341
  inFlight = false;
@@ -2329,9 +2353,9 @@ function createProgressWriter(deps) {
2329
2353
  timer.unref?.();
2330
2354
  }
2331
2355
  return {
2332
- push(step) {
2356
+ push(step2) {
2333
2357
  if (closed) return;
2334
- pending.push({ ...step, at: now() });
2358
+ pending.push({ ...step2, at: now() });
2335
2359
  schedule();
2336
2360
  },
2337
2361
  stop() {
@@ -2345,188 +2369,809 @@ function createProgressWriter(deps) {
2345
2369
  };
2346
2370
  }
2347
2371
 
2348
- // src/daemon.ts
2349
- var HEARTBEAT_MS = 3e4;
2350
- var JOB_TIMEOUT_MS = 20 * 60 * 1e3;
2351
- var MAX_ATTEMPTS = 2;
2352
- var SHUTDOWN_GRACE_MS = 1e4;
2353
- var STOP_POLL_MS = 5e3;
2354
- async function startDaemon() {
2355
- const moved = migrateLegacyDir();
2356
- let config2 = requireConfig();
2357
- if (config2.ships.length === 0) {
2358
- console.error("No Ships assigned. Run: lumi-runner ship add <shipId>");
2359
- process.exit(1);
2360
- }
2361
- const { sessions, failures } = await openShipSessions(
2362
- config2,
2363
- (shipId) => initFirebase(config2, `ship-${shipId}`)
2364
- );
2365
- for (const f of failures) console.error(f.message);
2366
- const orphanFailures = (await Promise.all(orphanShipKeys(config2).map((shipId) => probeShipKey(config2, shipId)))).filter((f) => f !== null);
2367
- const pruned = pruneDeadShips(config2, [...failures, ...orphanFailures]);
2368
- if (pruned.dropped.length > 0) {
2369
- saveConfig(pruned.config);
2370
- config2 = pruned.config;
2371
- console.log(
2372
- `Forgot ${pruned.dropped.length} Ship(s) this machine can no longer open: ${pruned.dropped.join(", ")}. Deleted, or this machine was revoked \u2014 run \`lumi-runner login\` if that was not expected.`
2373
- );
2374
- }
2375
- if (sessions.length === 0) {
2376
- console.error("No Ship could be signed in to. Run: lumi-runner login");
2377
- process.exit(1);
2378
- }
2379
- const byShip = new Map(sessions.map((s) => [s.shipId, s]));
2380
- const sess = (shipId) => {
2381
- const s = byShip.get(shipId);
2382
- if (!s) throw new Error(`No session for Ship ${shipId}`);
2383
- return s;
2384
- };
2385
- const serving = new Set(sessions.map((s) => s.shipId));
2386
- console.log(
2387
- `Runner ${config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}`
2388
- );
2389
- const logLines = [];
2390
- const log2 = (line) => {
2391
- const stamped = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}`;
2392
- console.log(stamped);
2393
- appendLog(stamped);
2394
- logLines.push(stamped);
2395
- if (logLines.length > 20) logLines.shift();
2396
- };
2397
- if (moved?.migrated) log2(`Moved runner config from ${moved.from} to ${moved.to}.`);
2398
- const startedAt = Date.now();
2399
- const running = /* @__PURE__ */ new Map();
2400
- const runningByShip = () => {
2401
- const counts = /* @__PURE__ */ new Map();
2402
- for (const r of running.values()) counts.set(r.shipId, (counts.get(r.shipId) ?? 0) + 1);
2403
- return counts;
2372
+ // src/service.ts
2373
+ import { spawnSync } from "node:child_process";
2374
+ import fs4 from "node:fs";
2375
+ import os3 from "node:os";
2376
+ import path4 from "node:path";
2377
+ import { fileURLToPath } from "node:url";
2378
+ var SERVICE_LABEL = "com.lumi.runner";
2379
+ var LINUX_UNIT = "lumi-runner.service";
2380
+ var WINDOWS_TASK = "LumiRunner";
2381
+ var LEGACY_SERVICE_LABEL = "com.lumi.crew-runner";
2382
+ var LEGACY_LINUX_UNIT = "crew-runner.service";
2383
+ var LEGACY_WINDOWS_TASK = "CrewRunner";
2384
+ var ServiceError = class extends Error {
2385
+ };
2386
+ function run(command, args) {
2387
+ const result = spawnSync(command, args, { encoding: "utf8" });
2388
+ return {
2389
+ ok: result.status === 0,
2390
+ out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
2404
2391
  };
2405
- const liveJobsOn = (shipId) => [...running.values()].filter((r) => r.shipId === shipId && r.mirror).map((r) => r.mirror).sort((a, b) => a.startedAt - b.startedAt);
2406
- const approved = /* @__PURE__ */ new Map();
2407
- const warnedUnapproved = /* @__PURE__ */ new Set();
2408
- const shipRunnerRef = (shipId) => doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
2409
- const needsRefill = /* @__PURE__ */ new Set();
2410
- let beating = false;
2411
- async function heartbeat() {
2412
- if (beating) return;
2413
- beating = true;
2414
- const now = Date.now();
2415
- try {
2416
- for (const shipId of [...serving]) {
2417
- try {
2418
- const snap = await getDoc5(shipRunnerRef(shipId));
2419
- if (!snap.exists()) {
2420
- forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
2421
- continue;
2422
- }
2423
- const isApproved = snap.data()?.approved === true;
2424
- const wasApproved = approved.get(shipId) === true;
2425
- approved.set(shipId, isApproved);
2426
- if (!isApproved && !warnedUnapproved.has(shipId)) {
2427
- warnedUnapproved.add(shipId);
2428
- log2(
2429
- `Ship ${shipId}: this machine's approval was withdrawn \u2014 idle here until a captain approves it again on the Daemons page.`
2430
- );
2431
- }
2432
- if (isApproved && !wasApproved) {
2433
- warnedUnapproved.delete(shipId);
2434
- needsRefill.add(shipId);
2435
- }
2436
- const live = liveJobsOn(shipId);
2437
- await setDoc2(
2438
- shipRunnerRef(shipId),
2439
- {
2440
- hostname: os3.hostname(),
2441
- version: RUNNER_VERSION,
2442
- status: "online",
2443
- lastSeenAt: now,
2444
- startedAt,
2445
- currentJob: live[0] ?? null,
2446
- currentJobs: live,
2447
- parallelLimit: shipCap(config2, shipId),
2448
- lastLogLines: [...logLines]
2449
- },
2450
- { merge: true }
2451
- );
2452
- } catch (e) {
2453
- if (isEnrolmentGone(e)) {
2454
- forgetShipLocally(shipId, "this machine is no longer enrolled on that Ship");
2455
- continue;
2456
- }
2457
- console.error(`heartbeat failed (${shipId}):`, e instanceof Error ? e.message : e);
2458
- }
2459
- }
2460
- } finally {
2461
- beating = false;
2392
+ }
2393
+ function cliPath() {
2394
+ return fileURLToPath(import.meta.url);
2395
+ }
2396
+ function uid() {
2397
+ return String(process.getuid?.() ?? 0);
2398
+ }
2399
+ function launchAgentPath() {
2400
+ return path4.join(os3.homedir(), "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
2401
+ }
2402
+ function systemdUnitPath() {
2403
+ return path4.join(os3.homedir(), ".config/systemd/user", LINUX_UNIT);
2404
+ }
2405
+ function legacyLaunchAgentPath() {
2406
+ return path4.join(os3.homedir(), "Library/LaunchAgents", `${LEGACY_SERVICE_LABEL}.plist`);
2407
+ }
2408
+ function legacySystemdUnitPath() {
2409
+ return path4.join(os3.homedir(), ".config/systemd/user", LEGACY_LINUX_UNIT);
2410
+ }
2411
+ function serviceEnv() {
2412
+ const env = { PATH: process.env.PATH ?? "" };
2413
+ if (process.env.LUMI_RUNNER_HOME || process.env.CREW_RUNNER_HOME) {
2414
+ env.LUMI_RUNNER_HOME = configDir();
2415
+ }
2416
+ return env;
2417
+ }
2418
+ function removeLegacyService() {
2419
+ const removed = [];
2420
+ if (process.platform === "darwin") {
2421
+ const unitPath = legacyLaunchAgentPath();
2422
+ if (fs4.existsSync(unitPath)) {
2423
+ run("launchctl", ["bootout", `gui/${uid()}/${LEGACY_SERVICE_LABEL}`]);
2424
+ fs4.rmSync(unitPath, { force: true });
2425
+ removed.push(unitPath);
2462
2426
  }
2463
- for (const shipId of [...needsRefill]) {
2464
- needsRefill.delete(shipId);
2465
- if (!serving.has(shipId) || approved.get(shipId) !== true) continue;
2466
- try {
2467
- const snap = await getDocs4(
2468
- query4(
2469
- collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
2470
- where4("status", "==", "queued"),
2471
- orderBy4("createdAt", "asc")
2472
- )
2473
- );
2474
- for (const d of snap.docs) {
2475
- pending.set(`${shipId}/${d.id}`, {
2476
- shipId,
2477
- job: { id: d.id, ...d.data() }
2478
- });
2479
- }
2480
- log2(`Ship ${shipId}: approved \u2014 picking up ${snap.size} queued job(s).`);
2481
- } catch (e) {
2482
- console.error(`queue refill failed (${shipId}):`, e instanceof Error ? e.message : e);
2483
- }
2484
- poke();
2427
+ return removed;
2428
+ }
2429
+ if (process.platform === "linux") {
2430
+ const unitPath = legacySystemdUnitPath();
2431
+ if (fs4.existsSync(unitPath)) {
2432
+ run("systemctl", ["--user", "disable", "--now", LEGACY_LINUX_UNIT]);
2433
+ fs4.rmSync(unitPath, { force: true });
2434
+ run("systemctl", ["--user", "daemon-reload"]);
2435
+ removed.push(unitPath);
2485
2436
  }
2437
+ return removed;
2486
2438
  }
2487
- const pending = /* @__PURE__ */ new Map();
2488
- const engineLimits = /* @__PURE__ */ new Map();
2489
- const agentEngines = /* @__PURE__ */ new Map();
2490
- const unsubsByShip = /* @__PURE__ */ new Map();
2491
- const listenerError = (shipId, what) => (e) => {
2492
- if (isEnrolmentGone(e)) {
2493
- forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
2494
- return;
2439
+ if (process.platform === "win32") {
2440
+ if (run("schtasks", ["/Query", "/TN", LEGACY_WINDOWS_TASK]).ok) {
2441
+ run("schtasks", ["/Delete", "/TN", LEGACY_WINDOWS_TASK, "/F"]);
2442
+ removed.push(LEGACY_WINDOWS_TASK);
2495
2443
  }
2496
- console.error(`${what} listener error (${shipId}):`, e.message);
2497
- };
2498
- for (const shipId of serving) {
2499
- const q = query4(
2500
- collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
2501
- where4("status", "==", "queued"),
2502
- orderBy4("createdAt", "asc")
2503
- );
2504
- unsubsByShip.set(shipId, [
2505
- onSnapshot2(
2506
- q,
2507
- (snap) => {
2508
- for (const key of [...pending.keys()]) {
2509
- if (pending.get(key)?.shipId === shipId) pending.delete(key);
2510
- }
2511
- for (const d of snap.docs) {
2512
- pending.set(`${shipId}/${d.id}`, {
2513
- shipId,
2514
- job: { id: d.id, ...d.data() }
2515
- });
2516
- }
2517
- poke();
2518
- },
2519
- listenerError(shipId, "jobs")
2520
- ),
2521
- // Agents, purely so the claim gate can resolve a queued job's engine without a read.
2522
- onSnapshot2(
2523
- collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
2524
- (snap) => {
2525
- for (const d of snap.docs) {
2526
- agentEngines.set(`${shipId}/${d.id}`, agentEngine(d.data()));
2527
- }
2528
- poke();
2529
- },
2444
+ return removed;
2445
+ }
2446
+ return removed;
2447
+ }
2448
+ var BOOTSTRAP_RETRY_DELAYS_MS = [250, 500, 1e3, 2e3, 2e3];
2449
+ function isTransientBootstrapError(out) {
2450
+ const text = out.toLowerCase();
2451
+ return (
2452
+ // The observed one: `Bootstrap failed: 5: Input/output error`.
2453
+ text.includes("input/output error") || // Same cause, different report: the old generation is still registered.
2454
+ text.includes("service already loaded") || text.includes("service is already loaded") || text.includes("eexist") || // launchd is mid-teardown and says so.
2455
+ text.includes("operation already in progress") || text.includes("operation now in progress")
2456
+ );
2457
+ }
2458
+ function retryWhileTransient(attempt, wait, delays = BOOTSTRAP_RETRY_DELAYS_MS) {
2459
+ let result = attempt();
2460
+ for (const delay of delays) {
2461
+ if (result.ok || !isTransientBootstrapError(result.out)) return result;
2462
+ wait(delay);
2463
+ result = attempt();
2464
+ }
2465
+ return result;
2466
+ }
2467
+ function sleepSync(ms) {
2468
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2469
+ }
2470
+ function plistXml() {
2471
+ const env = serviceEnv();
2472
+ const envEntries = Object.entries(env).map(([k, v]) => ` <key>${k}</key>
2473
+ <string>${escapeXml(v)}</string>`).join("\n");
2474
+ return `<?xml version="1.0" encoding="UTF-8"?>
2475
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2476
+ <plist version="1.0">
2477
+ <dict>
2478
+ <key>Label</key>
2479
+ <string>${SERVICE_LABEL}</string>
2480
+ <key>ProgramArguments</key>
2481
+ <array>
2482
+ <string>${escapeXml(process.execPath)}</string>
2483
+ <string>${escapeXml(cliPath())}</string>
2484
+ <string>start</string>
2485
+ </array>
2486
+ <key>RunAtLoad</key>
2487
+ <true/>
2488
+ <key>KeepAlive</key>
2489
+ <true/>
2490
+ <key>ExitTimeOut</key>
2491
+ <integer>20</integer>
2492
+ <key>EnvironmentVariables</key>
2493
+ <dict>
2494
+ ${envEntries}
2495
+ </dict>
2496
+ <key>StandardOutPath</key>
2497
+ <string>${escapeXml(path4.join(logDir(), "service.out.log"))}</string>
2498
+ <key>StandardErrorPath</key>
2499
+ <string>${escapeXml(path4.join(logDir(), "service.err.log"))}</string>
2500
+ </dict>
2501
+ </plist>
2502
+ `;
2503
+ }
2504
+ function escapeXml(value) {
2505
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2506
+ }
2507
+ function unescapeXml(value) {
2508
+ return value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&amp;/g, "&");
2509
+ }
2510
+ function parsePlistCliPath(plist) {
2511
+ const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(plist);
2512
+ if (!block) return void 0;
2513
+ const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1]));
2514
+ return args.length >= 2 ? args[1] : void 0;
2515
+ }
2516
+ function parseSystemdCliPath(unit) {
2517
+ const line = /^ExecStart=(.*)$/m.exec(unit);
2518
+ if (!line) return void 0;
2519
+ const command = line[1].trim();
2520
+ if (!command.endsWith(" start")) return void 0;
2521
+ const parts = command.slice(0, -" start".length).split(" ");
2522
+ return parts.length === 2 ? parts[1] : void 0;
2523
+ }
2524
+ function execFacts(unitPath, parse) {
2525
+ let text;
2526
+ try {
2527
+ text = fs4.readFileSync(unitPath, "utf8");
2528
+ } catch {
2529
+ return {};
2530
+ }
2531
+ const execPath = parse(text);
2532
+ if (!execPath || !path4.isAbsolute(execPath)) return {};
2533
+ return { execPath, execMissing: !fs4.existsSync(execPath) };
2534
+ }
2535
+ function systemdUnit() {
2536
+ const env = serviceEnv();
2537
+ const envLines = Object.entries(env).map(([k, v]) => `Environment="${k}=${v}"`).join("\n");
2538
+ return `[Unit]
2539
+ Description=Lumi Crew runner daemon
2540
+ After=network-online.target
2541
+
2542
+ [Service]
2543
+ Type=simple
2544
+ ExecStart=${process.execPath} ${cliPath()} start
2545
+ Restart=always
2546
+ RestartSec=5
2547
+ # Must exceed the daemon's SHUTDOWN_GRACE_MS: a SIGKILL mid-release is exactly the stranded
2548
+ # job the graceful shutdown exists to prevent.
2549
+ TimeoutStopSec=20
2550
+ ${envLines}
2551
+
2552
+ [Install]
2553
+ WantedBy=default.target
2554
+ `;
2555
+ }
2556
+ function serviceStatus() {
2557
+ if (process.platform === "darwin") {
2558
+ const unitPath = launchAgentPath();
2559
+ if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No LaunchAgent installed." };
2560
+ const exec = execFacts(unitPath, parsePlistCliPath);
2561
+ const printed = run("launchctl", ["print", `gui/${uid()}/${SERVICE_LABEL}`]);
2562
+ if (!printed.ok) {
2563
+ return { state: "installed", detail: "LaunchAgent present but not loaded.", unitPath, ...exec };
2564
+ }
2565
+ const running = /\bpid = \d+/.test(printed.out);
2566
+ return {
2567
+ state: running ? "running" : "installed",
2568
+ detail: running ? "LaunchAgent loaded and running." : "LaunchAgent loaded but not running.",
2569
+ unitPath,
2570
+ ...exec
2571
+ };
2572
+ }
2573
+ if (process.platform === "linux") {
2574
+ const unitPath = systemdUnitPath();
2575
+ if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No systemd user unit installed." };
2576
+ const active = run("systemctl", ["--user", "is-active", LINUX_UNIT]);
2577
+ return {
2578
+ state: active.out === "active" ? "running" : "installed",
2579
+ detail: `systemd user unit is ${active.out || "unknown"}.`,
2580
+ unitPath,
2581
+ ...execFacts(unitPath, parseSystemdCliPath)
2582
+ };
2583
+ }
2584
+ if (process.platform === "win32") {
2585
+ const query6 = run("schtasks", ["/Query", "/TN", WINDOWS_TASK]);
2586
+ if (!query6.ok) return { state: "not-installed", detail: "No scheduled task installed." };
2587
+ return {
2588
+ state: /\bRunning\b/i.test(query6.out) ? "running" : "installed",
2589
+ detail: "Scheduled task installed (runs at logon).",
2590
+ unitPath: WINDOWS_TASK
2591
+ };
2592
+ }
2593
+ return { state: "unsupported", detail: `No service integration for ${process.platform}.` };
2594
+ }
2595
+ function installService() {
2596
+ const notes = [];
2597
+ fs4.mkdirSync(logDir(), { recursive: true, mode: 448 });
2598
+ for (const unit of removeLegacyService()) {
2599
+ notes.push(`Removed the previous crew-runner service (${unit}).`);
2600
+ }
2601
+ if (process.platform === "darwin") {
2602
+ const unitPath = launchAgentPath();
2603
+ fs4.mkdirSync(path4.dirname(unitPath), { recursive: true });
2604
+ fs4.writeFileSync(unitPath, plistXml());
2605
+ run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
2606
+ const boot = retryWhileTransient(
2607
+ () => run("launchctl", ["bootstrap", `gui/${uid()}`, unitPath]),
2608
+ sleepSync
2609
+ );
2610
+ if (!boot.ok) throw new ServiceError(`launchctl bootstrap failed: ${boot.out}`);
2611
+ return { unitPath, notes };
2612
+ }
2613
+ if (process.platform === "linux") {
2614
+ const unitPath = systemdUnitPath();
2615
+ fs4.mkdirSync(path4.dirname(unitPath), { recursive: true });
2616
+ fs4.writeFileSync(unitPath, systemdUnit());
2617
+ const reload = run("systemctl", ["--user", "daemon-reload"]);
2618
+ if (!reload.ok) throw new ServiceError(`systemctl daemon-reload failed: ${reload.out}`);
2619
+ const enable = run("systemctl", ["--user", "enable", "--now", LINUX_UNIT]);
2620
+ if (!enable.ok) throw new ServiceError(`systemctl enable failed: ${enable.out}`);
2621
+ const linger = run("loginctl", ["show-user", os3.userInfo().username, "--property=Linger"]);
2622
+ if (!linger.out.includes("Linger=yes")) {
2623
+ notes.push(
2624
+ `Run \`sudo loginctl enable-linger ${os3.userInfo().username}\` so the daemon survives logout and starts at boot.`
2625
+ );
2626
+ }
2627
+ return { unitPath, notes };
2628
+ }
2629
+ if (process.platform === "win32") {
2630
+ const command = `"${process.execPath}" "${cliPath()}" start`;
2631
+ const create = run("schtasks", [
2632
+ "/Create",
2633
+ "/TN",
2634
+ WINDOWS_TASK,
2635
+ "/TR",
2636
+ command,
2637
+ "/SC",
2638
+ "ONLOGON",
2639
+ "/RL",
2640
+ "LIMITED",
2641
+ "/F"
2642
+ ]);
2643
+ if (!create.ok) throw new ServiceError(`schtasks /Create failed: ${create.out}`);
2644
+ notes.push(
2645
+ "Task Scheduler starts the daemon at logon but does not restart it if it exits. For a true always-on box, run the daemon under pm2 or a Windows service wrapper instead."
2646
+ );
2647
+ return { unitPath: WINDOWS_TASK, notes };
2648
+ }
2649
+ throw new ServiceError(`Service install is not supported on ${process.platform}.`);
2650
+ }
2651
+ function uninstallService() {
2652
+ removeLegacyService();
2653
+ if (process.platform === "darwin") {
2654
+ run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
2655
+ fs4.rmSync(launchAgentPath(), { force: true });
2656
+ return;
2657
+ }
2658
+ if (process.platform === "linux") {
2659
+ run("systemctl", ["--user", "disable", "--now", LINUX_UNIT]);
2660
+ fs4.rmSync(systemdUnitPath(), { force: true });
2661
+ run("systemctl", ["--user", "daemon-reload"]);
2662
+ return;
2663
+ }
2664
+ if (process.platform === "win32") {
2665
+ run("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
2666
+ return;
2667
+ }
2668
+ throw new ServiceError(`Service uninstall is not supported on ${process.platform}.`);
2669
+ }
2670
+ function restartService() {
2671
+ if (process.platform === "darwin") {
2672
+ const result = run("launchctl", ["kickstart", "-k", `gui/${uid()}/${SERVICE_LABEL}`]);
2673
+ if (!result.ok) throw new ServiceError(`launchctl kickstart failed: ${result.out}`);
2674
+ return;
2675
+ }
2676
+ if (process.platform === "linux") {
2677
+ const result = run("systemctl", ["--user", "restart", LINUX_UNIT]);
2678
+ if (!result.ok) throw new ServiceError(`systemctl restart failed: ${result.out}`);
2679
+ return;
2680
+ }
2681
+ if (process.platform === "win32") {
2682
+ run("schtasks", ["/End", "/TN", WINDOWS_TASK]);
2683
+ const result = run("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
2684
+ if (!result.ok) throw new ServiceError(`schtasks /Run failed: ${result.out}`);
2685
+ return;
2686
+ }
2687
+ throw new ServiceError(`Service restart is not supported on ${process.platform}.`);
2688
+ }
2689
+
2690
+ // src/update/plan.ts
2691
+ import path5 from "node:path";
2692
+
2693
+ // src/update/semver.ts
2694
+ var DEV_VERSION = "0.0.0-dev";
2695
+ var NUMERIC = /^(0|[1-9]\d*)$/;
2696
+ function parseVersion(raw) {
2697
+ if (typeof raw !== "string") return null;
2698
+ const trimmed = raw.trim();
2699
+ if (!trimmed) return null;
2700
+ const [withoutBuild] = trimmed.split("+", 1);
2701
+ const dash = withoutBuild.indexOf("-");
2702
+ const core = dash < 0 ? withoutBuild : withoutBuild.slice(0, dash);
2703
+ const pre = dash < 0 ? "" : withoutBuild.slice(dash + 1);
2704
+ const parts = core.split(".");
2705
+ if (parts.length !== 3) return null;
2706
+ if (!parts.every((p) => NUMERIC.test(p))) return null;
2707
+ const [major, minor, patch] = parts.map(Number);
2708
+ if (dash >= 0 && pre === "") return null;
2709
+ const prerelease = pre === "" ? [] : pre.split(".");
2710
+ if (prerelease.some((id) => id === "")) return null;
2711
+ return {
2712
+ major,
2713
+ minor,
2714
+ patch,
2715
+ prerelease: prerelease.map((id) => NUMERIC.test(id) ? Number(id) : id)
2716
+ };
2717
+ }
2718
+ function hasPrerelease(raw) {
2719
+ const parsed = parseVersion(raw);
2720
+ return !!parsed && parsed.prerelease.length > 0;
2721
+ }
2722
+ function comparePrerelease(a, b) {
2723
+ if (a.length === 0) return b.length === 0 ? 0 : 1;
2724
+ if (b.length === 0) return -1;
2725
+ for (let i = 0; i < Math.min(a.length, b.length); i++) {
2726
+ const x = a[i];
2727
+ const y = b[i];
2728
+ if (x === y) continue;
2729
+ const xNum = typeof x === "number";
2730
+ const yNum = typeof y === "number";
2731
+ if (xNum !== yNum) return xNum ? -1 : 1;
2732
+ if (xNum && yNum) return x < y ? -1 : 1;
2733
+ return x < y ? -1 : 1;
2734
+ }
2735
+ if (a.length === b.length) return 0;
2736
+ return a.length < b.length ? -1 : 1;
2737
+ }
2738
+ function compareVersions(a, b) {
2739
+ for (const key of ["major", "minor", "patch"]) {
2740
+ if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
2741
+ }
2742
+ return comparePrerelease(a.prerelease, b.prerelease);
2743
+ }
2744
+ function isNewer(candidate, current) {
2745
+ if (current === DEV_VERSION) return false;
2746
+ const a = parseVersion(candidate);
2747
+ const b = parseVersion(current);
2748
+ if (!a || !b) return false;
2749
+ return compareVersions(a, b) === 1;
2750
+ }
2751
+
2752
+ // src/update/plan.ts
2753
+ var CHANNELS = ["latest", "dev"];
2754
+ var PACKAGE_NAME = "@lumi.ai/runner";
2755
+ var UPDATE_CHECK_MS = 30 * 6e4;
2756
+ var UPDATE_FIRST_CHECK_MS = 2 * 6e4;
2757
+ var UPDATE_JITTER_MS = 10 * 6e4;
2758
+ var CHECK_MINUTES_MIN = 5;
2759
+ var CHECK_MINUTES_MAX = 24 * 60;
2760
+ var UPDATE_DRAIN_MAX_MS = 30 * 6e4;
2761
+ var MAX_UPDATE_ATTEMPTS = 3;
2762
+ var UPDATE_BACKOFF_MS = [15 * 6e4, 2 * 36e5, 12 * 36e5];
2763
+ var channelFor = (version) => hasPrerelease(version) ? "dev" : "latest";
2764
+ function resolveChannel(current, config2, env = {}) {
2765
+ const fromEnv = env.LUMI_RUNNER_CHANNEL;
2766
+ if (fromEnv && CHANNELS.includes(fromEnv)) return fromEnv;
2767
+ const fromConfig = config2.updateChannel;
2768
+ if (typeof fromConfig === "string" && CHANNELS.includes(fromConfig)) {
2769
+ return fromConfig;
2770
+ }
2771
+ return channelFor(current);
2772
+ }
2773
+ function checkIntervalMs(config2) {
2774
+ const raw = config2.updateCheckMinutes;
2775
+ if (typeof raw !== "number" || !Number.isFinite(raw)) return UPDATE_CHECK_MS;
2776
+ const clamped = Math.min(CHECK_MINUTES_MAX, Math.max(CHECK_MINUTES_MIN, Math.floor(raw)));
2777
+ return clamped * 6e4;
2778
+ }
2779
+ function nextAttemptAllowed(state, target, now) {
2780
+ if (!state || typeof state !== "object") return { allowed: true };
2781
+ if (state.target !== target) return { allowed: true };
2782
+ const attempts = typeof state.attempts === "number" && state.attempts > 0 ? state.attempts : 0;
2783
+ if (attempts === 0) return { allowed: true };
2784
+ if (attempts >= MAX_UPDATE_ATTEMPTS) return { allowed: false, reason: "exhausted" };
2785
+ if (typeof state.at !== "number" || !Number.isFinite(state.at)) return { allowed: true };
2786
+ const wait = UPDATE_BACKOFF_MS[Math.min(attempts - 1, UPDATE_BACKOFF_MS.length - 1)];
2787
+ return now - state.at >= wait ? { allowed: true } : { allowed: false, reason: "backoff" };
2788
+ }
2789
+ function reconcile(state, current) {
2790
+ if (!state || typeof state.target !== "string") return { action: "none" };
2791
+ if (state.target === current) return { action: "clear", target: state.target };
2792
+ return { action: "keep", state };
2793
+ }
2794
+ function packageRootFrom(cliPath2) {
2795
+ return path5.dirname(path5.dirname(cliPath2));
2796
+ }
2797
+ function globalPrefixFrom(cliPath2) {
2798
+ const root = packageRootFrom(cliPath2);
2799
+ const parts = root.split(path5.sep);
2800
+ const i = parts.lastIndexOf("node_modules");
2801
+ if (i < 1) return null;
2802
+ const before = parts.slice(0, i);
2803
+ if (before[before.length - 1] === "lib") before.pop();
2804
+ const prefix = before.join(path5.sep);
2805
+ return prefix || null;
2806
+ }
2807
+ function decideUpdate(f) {
2808
+ if (f.platform === "win32") return { update: false, reason: "windows" };
2809
+ if (f.current === DEV_VERSION) return { update: false, reason: "dev-build" };
2810
+ if (!f.autoUpdate) return { update: false, reason: "disabled" };
2811
+ const service2 = f.service();
2812
+ if (service2.state !== "running") return { update: false, reason: "service-not-running" };
2813
+ if (!service2.execPath || service2.execPath !== f.cliPath) {
2814
+ return { update: false, reason: "foreign-unit" };
2815
+ }
2816
+ if (!f.npmPresent()) return { update: false, reason: "no-npm" };
2817
+ if (f.distTags === null) return { update: false, reason: "registry-unreachable" };
2818
+ const target = f.distTags[f.channel];
2819
+ if (!target) return { update: false, reason: "no-such-tag" };
2820
+ if (!isNewer(target, f.current)) return { update: false, reason: "up-to-date" };
2821
+ const attempt = nextAttemptAllowed(f.state, target, f.now);
2822
+ if (!attempt.allowed) return { update: false, reason: attempt.reason };
2823
+ return { update: true, target, channel: f.channel };
2824
+ }
2825
+ function describeRefusal(reason, f) {
2826
+ switch (reason) {
2827
+ case "windows":
2828
+ return "Auto-update is not available on Windows \u2014 run `lumi-runner update` to upgrade.";
2829
+ case "dev-build":
2830
+ return "Development build \u2014 auto-update is off.";
2831
+ case "disabled":
2832
+ return "Auto-update is off (`lumi-runner config set autoUpdate on` to enable).";
2833
+ case "service-not-running":
2834
+ return "Auto-update needs the background service (this daemon would have nothing to restart it). Run `lumi-runner service install`, or `lumi-runner update` by hand.";
2835
+ case "foreign-unit":
2836
+ return "This process is not the one the installed service runs, so auto-update is off for it. Run `lumi-runner update` if you meant to upgrade the installed copy.";
2837
+ case "no-npm":
2838
+ return "Auto-update needs `npm` on the service PATH and could not find it.";
2839
+ case "registry-unreachable":
2840
+ return "Could not reach the npm registry to check for updates \u2014 will retry.";
2841
+ case "no-such-tag":
2842
+ return "The npm registry has no release on this channel yet.";
2843
+ case "up-to-date":
2844
+ return `Up to date (${f.current}).`;
2845
+ case "backoff":
2846
+ return "A recent update attempt did not take \u2014 waiting before trying again.";
2847
+ case "exhausted":
2848
+ return `Gave up updating after ${MAX_UPDATE_ATTEMPTS} attempts. Still on ${f.current} \u2014 run \`lumi-runner update\` to see why, or wait for the next release.`;
2849
+ }
2850
+ }
2851
+
2852
+ // src/update/registry.ts
2853
+ var DEFAULT_REGISTRY = "https://registry.npmjs.org";
2854
+ var REGISTRY_TIMEOUT_MS = 1e4;
2855
+ function registryBase(env = process.env) {
2856
+ return (env.LUMI_RUNNER_REGISTRY || DEFAULT_REGISTRY).replace(/\/+$/, "");
2857
+ }
2858
+ async function fetchDistTags(options = {}) {
2859
+ const base = options.registry ?? registryBase();
2860
+ const doFetch = options.fetchImpl ?? fetch;
2861
+ const url = `${base}/${encodeURIComponent(PACKAGE_NAME)}`;
2862
+ const controller = new AbortController();
2863
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? REGISTRY_TIMEOUT_MS);
2864
+ try {
2865
+ const response = await doFetch(url, {
2866
+ headers: { accept: "application/vnd.npm.install-v1+json" },
2867
+ signal: controller.signal
2868
+ });
2869
+ if (!response.ok) return null;
2870
+ const body = await response.json();
2871
+ const tags = body?.["dist-tags"];
2872
+ if (!tags || typeof tags !== "object" || Array.isArray(tags)) return null;
2873
+ const out = {};
2874
+ for (const [tag, version] of Object.entries(tags)) {
2875
+ if (typeof version === "string") out[tag] = version;
2876
+ }
2877
+ return out;
2878
+ } catch {
2879
+ return null;
2880
+ } finally {
2881
+ clearTimeout(timer);
2882
+ }
2883
+ }
2884
+
2885
+ // src/update/state.ts
2886
+ import fs5 from "node:fs";
2887
+ import path6 from "node:path";
2888
+ var UPDATE_STATE_FILE = "update.json";
2889
+ function updateStatePath() {
2890
+ return path6.join(configDir(), UPDATE_STATE_FILE);
2891
+ }
2892
+ function readUpdateState() {
2893
+ try {
2894
+ const parsed = JSON.parse(fs5.readFileSync(updateStatePath(), "utf8"));
2895
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
2896
+ return parsed;
2897
+ } catch {
2898
+ return null;
2899
+ }
2900
+ }
2901
+ function writeUpdateState(state) {
2902
+ try {
2903
+ fs5.mkdirSync(configDir(), { recursive: true, mode: 448 });
2904
+ fs5.writeFileSync(updateStatePath(), `${JSON.stringify(state, null, 2)}
2905
+ `, { mode: 384 });
2906
+ return true;
2907
+ } catch {
2908
+ return false;
2909
+ }
2910
+ }
2911
+ function clearUpdateState() {
2912
+ try {
2913
+ fs5.rmSync(updateStatePath(), { force: true });
2914
+ } catch {
2915
+ }
2916
+ }
2917
+
2918
+ // src/update/install.ts
2919
+ import { spawn as spawn4, spawnSync as spawnSync2 } from "node:child_process";
2920
+ import fs6 from "node:fs";
2921
+ import path7 from "node:path";
2922
+ var INSTALL_TIMEOUT_MS = 5 * 6e4;
2923
+ var STDERR_KEEP = 400;
2924
+ function npmPresent() {
2925
+ const probe = process.platform === "win32" ? "where" : "which";
2926
+ try {
2927
+ return spawnSync2(probe, ["npm"], { encoding: "utf8" }).status === 0;
2928
+ } catch {
2929
+ return false;
2930
+ }
2931
+ }
2932
+ function installArgs(target, cliPath2) {
2933
+ const args = ["install", "--global", "--no-fund", "--no-audit", `${PACKAGE_NAME}@${target}`];
2934
+ const prefix = globalPrefixFrom(cliPath2);
2935
+ if (prefix) args.push("--prefix", prefix);
2936
+ return args;
2937
+ }
2938
+ async function installGlobal(target, cliPath2, options = {}) {
2939
+ const args = installArgs(target, cliPath2);
2940
+ return new Promise((resolve) => {
2941
+ let child;
2942
+ try {
2943
+ child = spawn4("npm", args, { stdio: ["ignore", "ignore", "pipe"] });
2944
+ } catch (e) {
2945
+ resolve({ ok: false, detail: e instanceof Error ? e.message : String(e) });
2946
+ return;
2947
+ }
2948
+ let stderr = "";
2949
+ child.stderr?.on("data", (chunk) => {
2950
+ stderr = `${stderr}${chunk.toString()}`.slice(-STDERR_KEEP);
2951
+ });
2952
+ let settled = false;
2953
+ const finish = (result) => {
2954
+ if (settled) return;
2955
+ settled = true;
2956
+ clearTimeout(timer);
2957
+ resolve(result);
2958
+ };
2959
+ const timeoutMs = options.timeoutMs ?? INSTALL_TIMEOUT_MS;
2960
+ const timer = setTimeout(() => {
2961
+ child.kill("SIGTERM");
2962
+ setTimeout(() => child.kill("SIGKILL"), 5e3).unref();
2963
+ finish({ ok: false, detail: `npm install timed out after ${timeoutMs / 6e4} minutes.` });
2964
+ }, timeoutMs);
2965
+ timer.unref();
2966
+ child.on("error", (e) => finish({ ok: false, detail: e.message }));
2967
+ child.on(
2968
+ "close",
2969
+ (code) => finish({ ok: code === 0, detail: stderr.trim() || `npm exited ${code}` })
2970
+ );
2971
+ });
2972
+ }
2973
+ function installedVersionAt(cliPath2) {
2974
+ try {
2975
+ const pkg = JSON.parse(
2976
+ fs6.readFileSync(path7.join(packageRootFrom(cliPath2), "package.json"), "utf8")
2977
+ );
2978
+ const version = pkg?.version;
2979
+ return typeof version === "string" ? version : null;
2980
+ } catch {
2981
+ return null;
2982
+ }
2983
+ }
2984
+
2985
+ // src/daemon.ts
2986
+ var HEARTBEAT_MS = 3e4;
2987
+ var JOB_TIMEOUT_MS = 20 * 60 * 1e3;
2988
+ var MAX_ATTEMPTS = 2;
2989
+ var SHUTDOWN_GRACE_MS = 1e4;
2990
+ var STOP_POLL_MS = 5e3;
2991
+ async function startDaemon() {
2992
+ const moved = migrateLegacyDir();
2993
+ let config2 = requireConfig();
2994
+ if (config2.ships.length === 0) {
2995
+ console.error("No Ships assigned. Run: lumi-runner ship add <shipId>");
2996
+ process.exit(1);
2997
+ }
2998
+ const { sessions, failures } = await openShipSessions(
2999
+ config2,
3000
+ (shipId) => initFirebase(config2, `ship-${shipId}`)
3001
+ );
3002
+ for (const f of failures) console.error(f.message);
3003
+ const orphanFailures = (await Promise.all(orphanShipKeys(config2).map((shipId) => probeShipKey(config2, shipId)))).filter((f) => f !== null);
3004
+ const pruned = pruneDeadShips(config2, [...failures, ...orphanFailures]);
3005
+ if (pruned.dropped.length > 0) {
3006
+ saveConfig(pruned.config);
3007
+ config2 = pruned.config;
3008
+ console.log(
3009
+ `Forgot ${pruned.dropped.length} Ship(s) this machine can no longer open: ${pruned.dropped.join(", ")}. Deleted, or this machine was revoked \u2014 run \`lumi-runner login\` if that was not expected.`
3010
+ );
3011
+ }
3012
+ if (sessions.length === 0) {
3013
+ console.error("No Ship could be signed in to. Run: lumi-runner login");
3014
+ process.exit(1);
3015
+ }
3016
+ const byShip = new Map(sessions.map((s) => [s.shipId, s]));
3017
+ const sess = (shipId) => {
3018
+ const s = byShip.get(shipId);
3019
+ if (!s) throw new Error(`No session for Ship ${shipId}`);
3020
+ return s;
3021
+ };
3022
+ const serving = new Set(sessions.map((s) => s.shipId));
3023
+ console.log(
3024
+ `Runner ${config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}`
3025
+ );
3026
+ const logLines = [];
3027
+ const log2 = (line) => {
3028
+ const stamped = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}`;
3029
+ console.log(stamped);
3030
+ appendLog(stamped);
3031
+ logLines.push(stamped);
3032
+ if (logLines.length > 20) logLines.shift();
3033
+ };
3034
+ if (moved?.migrated) log2(`Moved runner config from ${moved.from} to ${moved.to}.`);
3035
+ const startedAt = Date.now();
3036
+ const running = /* @__PURE__ */ new Map();
3037
+ const runningByShip = () => {
3038
+ const counts = /* @__PURE__ */ new Map();
3039
+ for (const r of running.values()) counts.set(r.shipId, (counts.get(r.shipId) ?? 0) + 1);
3040
+ return counts;
3041
+ };
3042
+ const liveJobsOn = (shipId) => [...running.values()].filter((r) => r.shipId === shipId && r.mirror).map((r) => r.mirror).sort((a, b) => a.startedAt - b.startedAt);
3043
+ const approved = /* @__PURE__ */ new Map();
3044
+ const warnedUnapproved = /* @__PURE__ */ new Set();
3045
+ const shipRunnerRef = (shipId) => doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
3046
+ const needsRefill = /* @__PURE__ */ new Set();
3047
+ let beating = false;
3048
+ async function heartbeat() {
3049
+ if (beating) return;
3050
+ beating = true;
3051
+ const now = Date.now();
3052
+ try {
3053
+ for (const shipId of [...serving]) {
3054
+ try {
3055
+ const snap = await getDoc6(shipRunnerRef(shipId));
3056
+ if (!snap.exists()) {
3057
+ forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
3058
+ continue;
3059
+ }
3060
+ const isApproved = snap.data()?.approved === true;
3061
+ const wasApproved = approved.get(shipId) === true;
3062
+ approved.set(shipId, isApproved);
3063
+ if (!isApproved && !warnedUnapproved.has(shipId)) {
3064
+ warnedUnapproved.add(shipId);
3065
+ log2(
3066
+ `Ship ${shipId}: this machine's approval was withdrawn \u2014 idle here until a captain approves it again on the Daemons page.`
3067
+ );
3068
+ }
3069
+ if (isApproved && !wasApproved) {
3070
+ warnedUnapproved.delete(shipId);
3071
+ needsRefill.add(shipId);
3072
+ }
3073
+ const live = liveJobsOn(shipId);
3074
+ await setDoc2(
3075
+ shipRunnerRef(shipId),
3076
+ {
3077
+ hostname: os4.hostname(),
3078
+ version: RUNNER_VERSION,
3079
+ status: "online",
3080
+ lastSeenAt: now,
3081
+ startedAt,
3082
+ currentJob: live[0] ?? null,
3083
+ currentJobs: live,
3084
+ parallelLimit: shipCap(config2, shipId),
3085
+ lastLogLines: [...logLines],
3086
+ // §15.37. Written on EVERY heartbeat, `null` when idle, exactly like `currentJob`
3087
+ // above — so a machine that finishes or abandons an update clears its own badge
3088
+ // without needing a separate write. Both names must appear in `liveFields()` in
3089
+ // firestore.rules, and that rules change must be deployed BEFORE this version is
3090
+ // published: `hasOnly()` fails the whole write for one unlisted key, which would
3091
+ // take every heartbeat down, not just the badge.
3092
+ updateAvailable: updateAvailableVersion,
3093
+ updatePhase
3094
+ },
3095
+ { merge: true }
3096
+ );
3097
+ } catch (e) {
3098
+ if (isEnrolmentGone(e)) {
3099
+ forgetShipLocally(shipId, "this machine is no longer enrolled on that Ship");
3100
+ continue;
3101
+ }
3102
+ console.error(`heartbeat failed (${shipId}):`, e instanceof Error ? e.message : e);
3103
+ }
3104
+ }
3105
+ } finally {
3106
+ beating = false;
3107
+ }
3108
+ for (const shipId of [...needsRefill]) {
3109
+ needsRefill.delete(shipId);
3110
+ if (!serving.has(shipId) || approved.get(shipId) !== true) continue;
3111
+ try {
3112
+ const snap = await getDocs4(
3113
+ query4(
3114
+ collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
3115
+ where4("status", "==", "queued"),
3116
+ orderBy4("createdAt", "asc")
3117
+ )
3118
+ );
3119
+ for (const d of snap.docs) {
3120
+ pending.set(`${shipId}/${d.id}`, {
3121
+ shipId,
3122
+ job: { id: d.id, ...d.data() }
3123
+ });
3124
+ }
3125
+ log2(`Ship ${shipId}: approved \u2014 picking up ${snap.size} queued job(s).`);
3126
+ } catch (e) {
3127
+ console.error(`queue refill failed (${shipId}):`, e instanceof Error ? e.message : e);
3128
+ }
3129
+ poke();
3130
+ }
3131
+ }
3132
+ const pending = /* @__PURE__ */ new Map();
3133
+ const engineLimits = /* @__PURE__ */ new Map();
3134
+ const agentEngines = /* @__PURE__ */ new Map();
3135
+ const unsubsByShip = /* @__PURE__ */ new Map();
3136
+ const listenerError = (shipId, what) => (e) => {
3137
+ if (isEnrolmentGone(e)) {
3138
+ forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
3139
+ return;
3140
+ }
3141
+ console.error(`${what} listener error (${shipId}):`, e.message);
3142
+ };
3143
+ for (const shipId of serving) {
3144
+ const q = query4(
3145
+ collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
3146
+ where4("status", "==", "queued"),
3147
+ orderBy4("createdAt", "asc")
3148
+ );
3149
+ unsubsByShip.set(shipId, [
3150
+ onSnapshot2(
3151
+ q,
3152
+ (snap) => {
3153
+ for (const key of [...pending.keys()]) {
3154
+ if (pending.get(key)?.shipId === shipId) pending.delete(key);
3155
+ }
3156
+ for (const d of snap.docs) {
3157
+ pending.set(`${shipId}/${d.id}`, {
3158
+ shipId,
3159
+ job: { id: d.id, ...d.data() }
3160
+ });
3161
+ }
3162
+ poke();
3163
+ },
3164
+ listenerError(shipId, "jobs")
3165
+ ),
3166
+ // Agents, purely so the claim gate can resolve a queued job's engine without a read.
3167
+ onSnapshot2(
3168
+ collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
3169
+ (snap) => {
3170
+ for (const d of snap.docs) {
3171
+ agentEngines.set(`${shipId}/${d.id}`, agentEngine(d.data()));
3172
+ }
3173
+ poke();
3174
+ },
2530
3175
  listenerError(shipId, "agents")
2531
3176
  ),
2532
3177
  // Exhausted usage windows. The FIRST snapshot is the startup seed: a restarted daemon —
@@ -2596,9 +3241,14 @@ async function startDaemon() {
2596
3241
  let dispatching = false;
2597
3242
  let pokeRequested = false;
2598
3243
  let shuttingDown = false;
3244
+ let updating = null;
3245
+ let updateDrainTimer = null;
3246
+ let nextUpdateCheckAt = Date.now() + UPDATE_FIRST_CHECK_MS + Math.floor(Math.random() * UPDATE_JITTER_MS);
3247
+ let updateAvailableVersion = null;
3248
+ let updatePhase = null;
2599
3249
  function poke() {
2600
- if (dispatching || shuttingDown) {
2601
- pokeRequested = !shuttingDown;
3250
+ if (dispatching || shuttingDown || updating) {
3251
+ pokeRequested = !shuttingDown && !updating;
2602
3252
  return;
2603
3253
  }
2604
3254
  dispatch();
@@ -2632,7 +3282,7 @@ async function startDaemon() {
2632
3282
  try {
2633
3283
  for (; ; ) {
2634
3284
  pokeRequested = false;
2635
- if (shuttingDown) break;
3285
+ if (shuttingDown || updating) break;
2636
3286
  const now = Date.now();
2637
3287
  const picks = selectDispatch({
2638
3288
  pending: pending.values(),
@@ -2664,6 +3314,10 @@ async function startDaemon() {
2664
3314
  log2(`job ${job.id} ended abnormally: ${e instanceof Error ? e.message : e}`);
2665
3315
  }).finally(() => {
2666
3316
  running.delete(job.id);
3317
+ if (updating) {
3318
+ if (running.size === 0) void applyUpdate();
3319
+ return;
3320
+ }
2667
3321
  if (!shuttingDown) poke();
2668
3322
  });
2669
3323
  }
@@ -2741,7 +3395,7 @@ async function startDaemon() {
2741
3395
  void (async () => {
2742
3396
  if (slot.stop) return;
2743
3397
  try {
2744
- const snap = await getDoc5(
3398
+ const snap = await getDoc6(
2745
3399
  doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
2746
3400
  );
2747
3401
  const fresh = snap.data();
@@ -2796,7 +3450,7 @@ async function startDaemon() {
2796
3450
  onDenied: () => {
2797
3451
  void (async () => {
2798
3452
  try {
2799
- const snap = await getDoc5(
3453
+ const snap = await getDoc6(
2800
3454
  doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
2801
3455
  );
2802
3456
  const fresh = snap.data();
@@ -2889,7 +3543,7 @@ async function startDaemon() {
2889
3543
  // §15.36. The driver emits Crew's step vocabulary; the writer coalesces, redacts and
2890
3544
  // persists. A throw here must never reach the driver's stdout loop, which is why `push`
2891
3545
  // swallows everything.
2892
- onStep: (step) => progress.push(step)
3546
+ onStep: (step2) => progress.push(step2)
2893
3547
  });
2894
3548
  transcript = redactTranscript(session.transcript, knownSecrets);
2895
3549
  usage = session.usage;
@@ -3038,623 +3692,491 @@ async function startDaemon() {
3038
3692
  const heartbeatTimer = setInterval(() => {
3039
3693
  void heartbeat();
3040
3694
  sweepLimits();
3695
+ void maybeCheckForUpdate();
3041
3696
  }, HEARTBEAT_MS);
3042
3697
  let stopping = false;
3043
- const shutdown = async (signal) => {
3698
+ const shutdown = async (reason) => {
3044
3699
  if (stopping) return;
3045
3700
  stopping = true;
3046
3701
  shuttingDown = true;
3047
3702
  clearInterval(heartbeatTimer);
3048
- if (limitTimer) clearTimeout(limitTimer);
3049
- for (const shipUnsubs of unsubsByShip.values()) shipUnsubs.forEach((u) => u());
3050
- const inFlight = [...running.values()];
3051
- if (inFlight.length > 0) {
3052
- log2(
3053
- `${signal} received with ${inFlight.length} job(s) in flight \u2014 stopping the sessions and releasing them.`
3054
- );
3055
- notify(
3056
- "Crew runner stopping",
3057
- `${inFlight.length} job(s) were in progress; they have been returned to the queue.`
3058
- );
3059
- for (const r of inFlight) r.abort.abort();
3060
- } else {
3061
- log2(`${signal} received \u2014 shutting down.`);
3062
- }
3063
- if (inFlight.length > 0) {
3064
- await Promise.race([
3065
- Promise.all(inFlight.map((r) => r.promise)),
3066
- new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS).unref())
3067
- ]);
3068
- }
3069
- const now = Date.now();
3070
- try {
3071
- for (const shipId of serving) {
3072
- await setDoc2(
3073
- doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
3074
- {
3075
- status: "offline",
3076
- lastSeenAt: now,
3077
- currentJob: deleteField2(),
3078
- currentJobs: deleteField2()
3079
- },
3080
- { merge: true }
3081
- );
3082
- }
3083
- } catch {
3084
- }
3085
- process.exit(0);
3086
- };
3087
- process.on("SIGINT", () => void shutdown("SIGINT"));
3088
- process.on("SIGTERM", () => void shutdown("SIGTERM"));
3089
- const caps = [...serving].map((s) => `${s}:${shipCap(config2, s)}`).join(" ");
3090
- log2(
3091
- `Runner online \u2014 watching ${serving.size} Ship(s), up to ${machineCap(config2)} job(s) at once (${caps}), MCP: ${mcpUrl(config2)}`
3092
- );
3093
- await new Promise(() => {
3094
- });
3095
- }
3096
-
3097
- // src/cli/ui.ts
3098
- import * as clack from "@clack/prompts";
3099
- import { createColors } from "picocolors";
3100
- var CliError = class extends Error {
3101
- constructor(message, exitCode = 1) {
3102
- super(message);
3103
- this.exitCode = exitCode;
3104
- this.name = "CliError";
3105
- }
3106
- exitCode;
3107
- };
3108
- var jsonMode = false;
3109
- var assumeYes = false;
3110
- var palette = createColors();
3111
- var delegate = (name) => (input) => palette[name](input);
3112
- var pc = {
3113
- bold: delegate("bold"),
3114
- dim: delegate("dim"),
3115
- black: delegate("black"),
3116
- red: delegate("red"),
3117
- green: delegate("green"),
3118
- yellow: delegate("yellow"),
3119
- cyan: delegate("cyan"),
3120
- bgCyan: delegate("bgCyan")
3121
- };
3122
- function configureOutput(options) {
3123
- jsonMode = options.json === true;
3124
- assumeYes = options.yes === true;
3125
- if (options.color === false || jsonMode) palette = createColors(false);
3126
- }
3127
- function isJson() {
3128
- return jsonMode;
3129
- }
3130
- function emitJson(value) {
3131
- process.stdout.write(`${JSON.stringify(value, null, 2)}
3132
- `);
3133
- }
3134
- function canPrompt() {
3135
- return !jsonMode && process.stdin.isTTY === true && process.stdout.isTTY === true;
3136
- }
3137
- function requireInteractive(what, flagHint) {
3138
- if (canPrompt()) return;
3139
- throw new CliError(
3140
- `${what} needs an interactive terminal. Run it in a terminal, or pass ${pc.bold(flagHint)}.`
3141
- );
3142
- }
3143
- var say = {
3144
- intro(title) {
3145
- if (!jsonMode) clack.intro(pc.bgCyan(pc.black(` ${title} `)));
3146
- },
3147
- outro(message) {
3148
- if (!jsonMode) clack.outro(message);
3149
- },
3150
- info(message) {
3151
- if (!jsonMode) clack.log.info(message);
3152
- },
3153
- success(message) {
3154
- if (!jsonMode) clack.log.success(message);
3155
- },
3156
- warn(message) {
3157
- if (!jsonMode) clack.log.warn(message);
3158
- },
3159
- error(message) {
3160
- if (!jsonMode) clack.log.error(message);
3161
- },
3162
- step(message) {
3163
- if (!jsonMode) clack.log.step(message);
3164
- },
3165
- note(body, title) {
3166
- if (!jsonMode) clack.note(body, title);
3167
- },
3168
- /** Raw line straight to stdout — for `logs`, where the content IS the output. */
3169
- line(message) {
3170
- process.stdout.write(`${message}
3171
- `);
3172
- }
3173
- };
3174
- function spinner2() {
3175
- if (jsonMode) {
3176
- return { start: () => {
3177
- }, update: () => {
3178
- }, stop: () => {
3179
- } };
3180
- }
3181
- if (!canPrompt()) {
3703
+ if (limitTimer) clearTimeout(limitTimer);
3704
+ if (updateDrainTimer) clearTimeout(updateDrainTimer);
3705
+ for (const shipUnsubs of unsubsByShip.values()) shipUnsubs.forEach((u) => u());
3706
+ const inFlight = [...running.values()];
3707
+ if (inFlight.length > 0) {
3708
+ log2(
3709
+ `${reason} with ${inFlight.length} job(s) in flight \u2014 stopping the sessions and releasing them.`
3710
+ );
3711
+ notify(
3712
+ "Crew runner stopping",
3713
+ `${inFlight.length} job(s) were in progress; they have been returned to the queue.`
3714
+ );
3715
+ for (const r of inFlight) r.abort.abort();
3716
+ } else {
3717
+ log2(`${reason} \u2014 shutting down.`);
3718
+ }
3719
+ if (inFlight.length > 0) {
3720
+ await Promise.race([
3721
+ Promise.all(inFlight.map((r) => r.promise)),
3722
+ new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS).unref())
3723
+ ]);
3724
+ }
3725
+ const now = Date.now();
3726
+ try {
3727
+ for (const shipId of serving) {
3728
+ await setDoc2(
3729
+ doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
3730
+ {
3731
+ status: "offline",
3732
+ lastSeenAt: now,
3733
+ currentJob: deleteField2(),
3734
+ currentJobs: deleteField2()
3735
+ },
3736
+ { merge: true }
3737
+ );
3738
+ }
3739
+ } catch {
3740
+ }
3741
+ process.exit(0);
3742
+ };
3743
+ process.on("SIGINT", () => void shutdown("SIGINT received"));
3744
+ process.on("SIGTERM", () => void shutdown("SIGTERM received"));
3745
+ const loggedRefusals = /* @__PURE__ */ new Set();
3746
+ let checkingUpdate = false;
3747
+ let applyingUpdate = false;
3748
+ function updateFacts(distTags) {
3182
3749
  return {
3183
- start: (msg) => process.stderr.write(`${msg}
3184
- `),
3185
- update: (msg) => process.stderr.write(`${msg}
3186
- `),
3187
- stop: (msg) => msg && process.stderr.write(`${msg}
3188
- `)
3750
+ platform: process.platform,
3751
+ current: RUNNER_VERSION,
3752
+ autoUpdate: config2.autoUpdate !== false,
3753
+ // absent means on
3754
+ channel: resolveChannel(RUNNER_VERSION, config2, process.env),
3755
+ // THUNKS. Both shell out synchronously, and the ladder refuses most machines before it
3756
+ // reaches them — see the note on `UpdateFacts.service`.
3757
+ service: () => {
3758
+ const s = serviceStatus();
3759
+ return { state: s.state, execPath: s.execPath };
3760
+ },
3761
+ npmPresent,
3762
+ cliPath: cliPath(),
3763
+ distTags,
3764
+ state: readUpdateState(),
3765
+ now: Date.now()
3189
3766
  };
3190
3767
  }
3191
- const s = clack.spinner();
3192
- return {
3193
- start: (msg) => s.start(msg),
3194
- update: (msg) => s.message(msg),
3195
- stop: (msg) => s.stop(msg)
3196
- };
3197
- }
3198
- function unwrap(value) {
3199
- if (clack.isCancel(value)) throw new CliError("Cancelled.", 130);
3200
- return value;
3201
- }
3202
- async function promptMultiSelect(options) {
3203
- requireInteractive(options.message, options.flagHint);
3204
- return unwrap(
3205
- await clack.multiselect({
3206
- message: options.message,
3207
- options: options.choices,
3208
- initialValues: options.initialValues,
3209
- required: options.required ?? false
3210
- })
3211
- );
3212
- }
3213
- async function promptConfirm(options) {
3214
- if (assumeYes && options.yesFlagApplies !== false) return true;
3215
- requireInteractive(options.message, "--yes");
3216
- return unwrap(await clack.confirm({ message: options.message, initialValue: options.initialValue }));
3217
- }
3218
- function glyph(level) {
3219
- if (level === "ok") return pc.green("\u2714");
3220
- if (level === "warn") return pc.yellow("\u25B2");
3221
- return pc.red("\u2716");
3222
- }
3223
-
3224
- // src/cli/commands/config.ts
3225
- var TOGGLES = {
3226
- notifications: "Desktop notifications when a job starts, finishes or fails",
3227
- keepAwake: "Keep this machine awake while a job is running"
3228
- };
3229
- var PARALLEL = "parallel";
3230
- var PARALLEL_HELP = "How many jobs to run at once (1 = one at a time, as before)";
3231
- var DEFAULT_WORDS = ["default", "auto", "reset"];
3232
- function isToggle(key) {
3233
- return Object.hasOwn(TOGGLES, key);
3234
- }
3235
- function parseBool(value) {
3236
- if (["on", "true", "yes", "1"].includes(value.toLowerCase())) return true;
3237
- if (["off", "false", "no", "0"].includes(value.toLowerCase())) return false;
3238
- throw new CliError(`Expected on/off, got "${value}".`);
3239
- }
3240
- function parseParallel(value) {
3241
- if (DEFAULT_WORDS.includes(value.toLowerCase())) return null;
3242
- const n = Number(value);
3243
- if (!Number.isInteger(n) || n < 1 || n > PARALLEL_MAX) {
3244
- throw new CliError(
3245
- `Expected a whole number from 1 to ${PARALLEL_MAX} (or "default"), got "${value}".`
3246
- );
3247
- }
3248
- return n;
3249
- }
3250
- async function runConfigList() {
3251
- const config2 = requireConfig();
3252
- const values = Object.fromEntries(
3253
- Object.keys(TOGGLES).map((key) => [key, config2[key] !== false])
3254
- );
3255
- const machine = machineCap(config2);
3256
- const ships = config2.ships.map((shipId) => ({
3257
- shipId,
3258
- parallel: shipCap(config2, shipId),
3259
- explicit: config2.shipParallelJobs?.[shipId] !== void 0
3260
- }));
3261
- if (isJson()) {
3262
- emitJson({ configDir: configDir(), ...values, parallel: machine, ships });
3263
- return 0;
3264
- }
3265
- say.line(` ${configDir()}`);
3266
- say.line("");
3267
- for (const key of Object.keys(TOGGLES)) {
3268
- say.line(` ${key.padEnd(16)} ${values[key] ? "on" : "off"} ${TOGGLES[key]}`);
3768
+ function refuse(reason) {
3769
+ if (loggedRefusals.has(reason)) return;
3770
+ loggedRefusals.add(reason);
3771
+ if (reason !== "up-to-date") log2(describeRefusal(reason, { current: RUNNER_VERSION }));
3269
3772
  }
3270
- say.line(` ${PARALLEL.padEnd(16)} ${String(machine).padEnd(2)} ${PARALLEL_HELP}`);
3271
- if (ships.length > 0) {
3272
- say.line("");
3273
- for (const ship2 of ships) {
3274
- const note2 = ship2.explicit ? "" : " (machine default)";
3275
- say.line(` ${`${PARALLEL} --ship`.padEnd(16)} ${ship2.shipId} = ${ship2.parallel}${note2}`);
3773
+ async function maybeCheckForUpdate() {
3774
+ if (checkingUpdate || updating || shuttingDown || Date.now() < nextUpdateCheckAt) return;
3775
+ checkingUpdate = true;
3776
+ try {
3777
+ const local = decideUpdate(updateFacts(null));
3778
+ if (!local.update && local.reason !== "registry-unreachable") {
3779
+ refuse(local.reason);
3780
+ return;
3781
+ }
3782
+ const decision = decideUpdate(updateFacts(await fetchDistTags()));
3783
+ if (!decision.update) {
3784
+ refuse(decision.reason);
3785
+ return;
3786
+ }
3787
+ loggedRefusals.clear();
3788
+ beginUpdate(decision.target, decision.channel);
3789
+ } catch (e) {
3790
+ log2(`Update check failed: ${e instanceof Error ? e.message : e}`);
3791
+ } finally {
3792
+ checkingUpdate = false;
3793
+ nextUpdateCheckAt = Date.now() + checkIntervalMs(config2);
3276
3794
  }
3277
3795
  }
3278
- return 0;
3279
- }
3280
- async function runConfigSet(key, value, options = {}) {
3281
- const config2 = requireConfig();
3282
- if (key === PARALLEL) return setParallel(config2, value, options.ship);
3283
- if (!isToggle(key)) {
3284
- throw new CliError(
3285
- `Unknown setting "${key}". Known: ${[...Object.keys(TOGGLES), PARALLEL].join(", ")}.`
3796
+ function beginUpdate(target, channel) {
3797
+ updating = { target, channel };
3798
+ updateAvailableVersion = target;
3799
+ updatePhase = "draining";
3800
+ log2(
3801
+ `Update available: ${RUNNER_VERSION} \u2192 ${target} (${channel}). Not claiming new jobs; ${running.size} in flight.`
3286
3802
  );
3287
- }
3288
- if (options.ship) {
3289
- throw new CliError(`"${key}" is a machine-wide setting \u2014 it takes no --ship.`);
3290
- }
3291
- config2[key] = parseBool(value);
3292
- saveConfig(config2);
3293
- if (isJson()) {
3294
- emitJson({ [key]: config2[key] });
3295
- return 0;
3296
- }
3297
- say.success(`${key} = ${config2[key] ? "on" : "off"}`);
3298
- return 0;
3299
- }
3300
- function setParallel(config2, value, ship2) {
3301
- const parsed = parseParallel(value);
3302
- if (ship2) {
3303
- if (!config2.ships.includes(ship2)) {
3304
- throw new CliError(
3305
- `This machine does not serve Ship "${ship2}". Run \`lumi-runner ship list\` to see which Ships it serves, or \`lumi-runner ship add ${ship2}\` to add it.`
3306
- );
3307
- }
3308
- const overrides = { ...config2.shipParallelJobs ?? {} };
3309
- if (parsed === null) delete overrides[ship2];
3310
- else overrides[ship2] = parsed;
3311
- config2.shipParallelJobs = overrides;
3312
- } else if (parsed === null) {
3313
- delete config2.maxParallelJobs;
3314
- } else {
3315
- config2.maxParallelJobs = parsed;
3316
- }
3317
- saveConfig(config2);
3318
- const effective = ship2 ? shipCap(config2, ship2) : machineCap(config2);
3319
- if (isJson()) {
3320
- emitJson({ parallel: effective, ...ship2 ? { shipId: ship2 } : {} });
3321
- return 0;
3322
- }
3323
- const machine = machineCap(config2);
3324
- say.success(
3325
- ship2 ? `${PARALLEL} for Ship ${ship2} = ${effective}${parsed === null ? " (machine default)" : ""}` : `${PARALLEL} = ${effective}`
3326
- );
3327
- if (ship2 && parsed !== null && parsed > machine) {
3328
- say.line(
3329
- ` Capped at the machine-wide limit of ${machine}. Raise it with \`lumi-runner config set ${PARALLEL} ${parsed}\`.`
3803
+ updateDrainTimer = setTimeout(
3804
+ () => abandonUpdate("jobs did not finish in time"),
3805
+ UPDATE_DRAIN_MAX_MS
3330
3806
  );
3331
- }
3332
- say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
3333
- return 0;
3334
- }
3335
-
3336
- // src/cli/commands/doctor.ts
3337
- import { spawnSync as spawnSync2 } from "node:child_process";
3338
- import { collection as collection6, doc as doc8, getDoc as getDoc6, getDocs as getDocs5 } from "firebase/firestore";
3339
-
3340
- // src/service.ts
3341
- import { spawnSync } from "node:child_process";
3342
- import fs4 from "node:fs";
3343
- import os4 from "node:os";
3344
- import path4 from "node:path";
3345
- import { fileURLToPath } from "node:url";
3346
- var SERVICE_LABEL = "com.lumi.runner";
3347
- var LINUX_UNIT = "lumi-runner.service";
3348
- var WINDOWS_TASK = "LumiRunner";
3349
- var LEGACY_SERVICE_LABEL = "com.lumi.crew-runner";
3350
- var LEGACY_LINUX_UNIT = "crew-runner.service";
3351
- var LEGACY_WINDOWS_TASK = "CrewRunner";
3352
- var ServiceError = class extends Error {
3353
- };
3354
- function run(command, args) {
3355
- const result = spawnSync(command, args, { encoding: "utf8" });
3356
- return {
3357
- ok: result.status === 0,
3358
- out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
3359
- };
3360
- }
3361
- function cliPath() {
3362
- return fileURLToPath(import.meta.url);
3363
- }
3364
- function uid() {
3365
- return String(process.getuid?.() ?? 0);
3366
- }
3367
- function launchAgentPath() {
3368
- return path4.join(os4.homedir(), "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
3369
- }
3370
- function systemdUnitPath() {
3371
- return path4.join(os4.homedir(), ".config/systemd/user", LINUX_UNIT);
3372
- }
3373
- function legacyLaunchAgentPath() {
3374
- return path4.join(os4.homedir(), "Library/LaunchAgents", `${LEGACY_SERVICE_LABEL}.plist`);
3375
- }
3376
- function legacySystemdUnitPath() {
3377
- return path4.join(os4.homedir(), ".config/systemd/user", LEGACY_LINUX_UNIT);
3378
- }
3379
- function serviceEnv() {
3380
- const env = { PATH: process.env.PATH ?? "" };
3381
- if (process.env.LUMI_RUNNER_HOME || process.env.CREW_RUNNER_HOME) {
3382
- env.LUMI_RUNNER_HOME = configDir();
3383
- }
3384
- return env;
3385
- }
3386
- function removeLegacyService() {
3387
- const removed = [];
3388
- if (process.platform === "darwin") {
3389
- const unitPath = legacyLaunchAgentPath();
3390
- if (fs4.existsSync(unitPath)) {
3391
- run("launchctl", ["bootout", `gui/${uid()}/${LEGACY_SERVICE_LABEL}`]);
3392
- fs4.rmSync(unitPath, { force: true });
3393
- removed.push(unitPath);
3807
+ updateDrainTimer.unref();
3808
+ if (running.size === 0) void applyUpdate();
3809
+ }
3810
+ function abandonUpdate(why) {
3811
+ if (!updating) return;
3812
+ const { target } = updating;
3813
+ updating = null;
3814
+ updateAvailableVersion = null;
3815
+ updatePhase = null;
3816
+ if (updateDrainTimer) {
3817
+ clearTimeout(updateDrainTimer);
3818
+ updateDrainTimer = null;
3394
3819
  }
3395
- return removed;
3396
- }
3397
- if (process.platform === "linux") {
3398
- const unitPath = legacySystemdUnitPath();
3399
- if (fs4.existsSync(unitPath)) {
3400
- run("systemctl", ["--user", "disable", "--now", LEGACY_LINUX_UNIT]);
3401
- fs4.rmSync(unitPath, { force: true });
3402
- run("systemctl", ["--user", "daemon-reload"]);
3403
- removed.push(unitPath);
3820
+ log2(`Update to ${target} abandoned \u2014 ${why}. Claiming again; will retry later.`);
3821
+ poke();
3822
+ }
3823
+ async function applyUpdate() {
3824
+ if (!updating || applyingUpdate || shuttingDown) return;
3825
+ applyingUpdate = true;
3826
+ const { target } = updating;
3827
+ if (updateDrainTimer) {
3828
+ clearTimeout(updateDrainTimer);
3829
+ updateDrainTimer = null;
3830
+ }
3831
+ try {
3832
+ updatePhase = "installing";
3833
+ log2(`No jobs in flight \u2014 installing ${PACKAGE_NAME}@${target}\u2026`);
3834
+ await heartbeat();
3835
+ const previous = readUpdateState();
3836
+ const attempts = previous?.target === target ? (previous.attempts ?? 0) + 1 : 1;
3837
+ writeUpdateState({ target, from: RUNNER_VERSION, attempts, at: Date.now() });
3838
+ const result = await installGlobal(target, cliPath());
3839
+ if (!result.ok) {
3840
+ applyingUpdate = false;
3841
+ abandonUpdate(`npm install failed: ${result.detail.slice(0, 200)}`);
3842
+ return;
3843
+ }
3844
+ const onDisk = installedVersionAt(cliPath());
3845
+ if (onDisk !== target) {
3846
+ applyingUpdate = false;
3847
+ abandonUpdate(
3848
+ `npm reported success but ${cliPath()} is still ${onDisk ?? "unreadable"} \u2014 the install probably went to a different npm prefix`
3849
+ );
3850
+ return;
3851
+ }
3852
+ log2(`Updated to ${target} \u2014 restarting to pick it up.`);
3853
+ await shutdown(`Updated to ${target}`);
3854
+ } catch (e) {
3855
+ applyingUpdate = false;
3856
+ abandonUpdate(`the install threw: ${e instanceof Error ? e.message : e}`);
3404
3857
  }
3405
- return removed;
3406
3858
  }
3407
- if (process.platform === "win32") {
3408
- if (run("schtasks", ["/Query", "/TN", LEGACY_WINDOWS_TASK]).ok) {
3409
- run("schtasks", ["/Delete", "/TN", LEGACY_WINDOWS_TASK, "/F"]);
3410
- removed.push(LEGACY_WINDOWS_TASK);
3859
+ {
3860
+ const verdict = reconcile(readUpdateState(), RUNNER_VERSION);
3861
+ if (verdict.action === "clear") {
3862
+ log2(`Self-update to ${verdict.target} completed.`);
3863
+ clearUpdateState();
3864
+ } else if (verdict.action === "keep") {
3865
+ log2(
3866
+ `A previous self-update to ${verdict.state.target} did not take \u2014 still on ${RUNNER_VERSION} (attempt ${verdict.state.attempts} of ${MAX_UPDATE_ATTEMPTS}).`
3867
+ );
3411
3868
  }
3412
- return removed;
3413
3869
  }
3414
- return removed;
3415
- }
3416
- var BOOTSTRAP_RETRY_DELAYS_MS = [250, 500, 1e3, 2e3, 2e3];
3417
- function isTransientBootstrapError(out) {
3418
- const text = out.toLowerCase();
3419
- return (
3420
- // The observed one: `Bootstrap failed: 5: Input/output error`.
3421
- text.includes("input/output error") || // Same cause, different report: the old generation is still registered.
3422
- text.includes("service already loaded") || text.includes("service is already loaded") || text.includes("eexist") || // launchd is mid-teardown and says so.
3423
- text.includes("operation already in progress") || text.includes("operation now in progress")
3870
+ const caps = [...serving].map((s) => `${s}:${shipCap(config2, s)}`).join(" ");
3871
+ log2(
3872
+ `Runner online \u2014 watching ${serving.size} Ship(s), up to ${machineCap(config2)} job(s) at once (${caps}), MCP: ${mcpUrl(config2)}`
3424
3873
  );
3874
+ log2(
3875
+ `Version ${RUNNER_VERSION} on the ${resolveChannel(RUNNER_VERSION, config2, process.env)} channel; auto-update ${config2.autoUpdate === false ? "off" : "on"}.`
3876
+ );
3877
+ await new Promise(() => {
3878
+ });
3425
3879
  }
3426
- function retryWhileTransient(attempt, wait, delays = BOOTSTRAP_RETRY_DELAYS_MS) {
3427
- let result = attempt();
3428
- for (const delay of delays) {
3429
- if (result.ok || !isTransientBootstrapError(result.out)) return result;
3430
- wait(delay);
3431
- result = attempt();
3880
+
3881
+ // src/cli/ui.ts
3882
+ import * as clack from "@clack/prompts";
3883
+ import { createColors } from "picocolors";
3884
+ var CliError = class extends Error {
3885
+ constructor(message, exitCode = 1) {
3886
+ super(message);
3887
+ this.exitCode = exitCode;
3888
+ this.name = "CliError";
3432
3889
  }
3433
- return result;
3434
- }
3435
- function sleepSync(ms) {
3436
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
3437
- }
3438
- function plistXml() {
3439
- const env = serviceEnv();
3440
- const envEntries = Object.entries(env).map(([k, v]) => ` <key>${k}</key>
3441
- <string>${escapeXml(v)}</string>`).join("\n");
3442
- return `<?xml version="1.0" encoding="UTF-8"?>
3443
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3444
- <plist version="1.0">
3445
- <dict>
3446
- <key>Label</key>
3447
- <string>${SERVICE_LABEL}</string>
3448
- <key>ProgramArguments</key>
3449
- <array>
3450
- <string>${escapeXml(process.execPath)}</string>
3451
- <string>${escapeXml(cliPath())}</string>
3452
- <string>start</string>
3453
- </array>
3454
- <key>RunAtLoad</key>
3455
- <true/>
3456
- <key>KeepAlive</key>
3457
- <true/>
3458
- <key>ExitTimeOut</key>
3459
- <integer>20</integer>
3460
- <key>EnvironmentVariables</key>
3461
- <dict>
3462
- ${envEntries}
3463
- </dict>
3464
- <key>StandardOutPath</key>
3465
- <string>${escapeXml(path4.join(logDir(), "service.out.log"))}</string>
3466
- <key>StandardErrorPath</key>
3467
- <string>${escapeXml(path4.join(logDir(), "service.err.log"))}</string>
3468
- </dict>
3469
- </plist>
3470
- `;
3890
+ exitCode;
3891
+ };
3892
+ var jsonMode = false;
3893
+ var assumeYes = false;
3894
+ var palette = createColors();
3895
+ var delegate = (name) => (input) => palette[name](input);
3896
+ var pc = {
3897
+ bold: delegate("bold"),
3898
+ dim: delegate("dim"),
3899
+ black: delegate("black"),
3900
+ red: delegate("red"),
3901
+ green: delegate("green"),
3902
+ yellow: delegate("yellow"),
3903
+ cyan: delegate("cyan"),
3904
+ bgCyan: delegate("bgCyan")
3905
+ };
3906
+ function configureOutput(options) {
3907
+ jsonMode = options.json === true;
3908
+ assumeYes = options.yes === true;
3909
+ if (options.color === false || jsonMode) palette = createColors(false);
3471
3910
  }
3472
- function escapeXml(value) {
3473
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3911
+ function isJson() {
3912
+ return jsonMode;
3474
3913
  }
3475
- function unescapeXml(value) {
3476
- return value.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&amp;/g, "&");
3914
+ function emitJson(value) {
3915
+ process.stdout.write(`${JSON.stringify(value, null, 2)}
3916
+ `);
3477
3917
  }
3478
- function parsePlistCliPath(plist) {
3479
- const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(plist);
3480
- if (!block) return void 0;
3481
- const args = [...block[1].matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1]));
3482
- return args.length >= 2 ? args[1] : void 0;
3918
+ function canPrompt() {
3919
+ return !jsonMode && process.stdin.isTTY === true && process.stdout.isTTY === true;
3483
3920
  }
3484
- function parseSystemdCliPath(unit) {
3485
- const line = /^ExecStart=(.*)$/m.exec(unit);
3486
- if (!line) return void 0;
3487
- const command = line[1].trim();
3488
- if (!command.endsWith(" start")) return void 0;
3489
- const parts = command.slice(0, -" start".length).split(" ");
3490
- return parts.length === 2 ? parts[1] : void 0;
3921
+ function requireInteractive(what, flagHint) {
3922
+ if (canPrompt()) return;
3923
+ throw new CliError(
3924
+ `${what} needs an interactive terminal. Run it in a terminal, or pass ${pc.bold(flagHint)}.`
3925
+ );
3491
3926
  }
3492
- function execFacts(unitPath, parse) {
3493
- let text;
3494
- try {
3495
- text = fs4.readFileSync(unitPath, "utf8");
3496
- } catch {
3497
- return {};
3927
+ var say = {
3928
+ intro(title) {
3929
+ if (!jsonMode) clack.intro(pc.bgCyan(pc.black(` ${title} `)));
3930
+ },
3931
+ outro(message) {
3932
+ if (!jsonMode) clack.outro(message);
3933
+ },
3934
+ info(message) {
3935
+ if (!jsonMode) clack.log.info(message);
3936
+ },
3937
+ success(message) {
3938
+ if (!jsonMode) clack.log.success(message);
3939
+ },
3940
+ warn(message) {
3941
+ if (!jsonMode) clack.log.warn(message);
3942
+ },
3943
+ error(message) {
3944
+ if (!jsonMode) clack.log.error(message);
3945
+ },
3946
+ step(message) {
3947
+ if (!jsonMode) clack.log.step(message);
3948
+ },
3949
+ note(body, title) {
3950
+ if (!jsonMode) clack.note(body, title);
3951
+ },
3952
+ /** Raw line straight to stdout — for `logs`, where the content IS the output. */
3953
+ line(message) {
3954
+ process.stdout.write(`${message}
3955
+ `);
3498
3956
  }
3499
- const execPath = parse(text);
3500
- if (!execPath || !path4.isAbsolute(execPath)) return {};
3501
- return { execPath, execMissing: !fs4.existsSync(execPath) };
3502
- }
3503
- function systemdUnit() {
3504
- const env = serviceEnv();
3505
- const envLines = Object.entries(env).map(([k, v]) => `Environment="${k}=${v}"`).join("\n");
3506
- return `[Unit]
3507
- Description=Lumi Crew runner daemon
3508
- After=network-online.target
3509
-
3510
- [Service]
3511
- Type=simple
3512
- ExecStart=${process.execPath} ${cliPath()} start
3513
- Restart=always
3514
- RestartSec=5
3515
- # Must exceed the daemon's SHUTDOWN_GRACE_MS: a SIGKILL mid-release is exactly the stranded
3516
- # job the graceful shutdown exists to prevent.
3517
- TimeoutStopSec=20
3518
- ${envLines}
3519
-
3520
- [Install]
3521
- WantedBy=default.target
3522
- `;
3523
- }
3524
- function serviceStatus() {
3525
- if (process.platform === "darwin") {
3526
- const unitPath = launchAgentPath();
3527
- if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No LaunchAgent installed." };
3528
- const exec = execFacts(unitPath, parsePlistCliPath);
3529
- const printed = run("launchctl", ["print", `gui/${uid()}/${SERVICE_LABEL}`]);
3530
- if (!printed.ok) {
3531
- return { state: "installed", detail: "LaunchAgent present but not loaded.", unitPath, ...exec };
3532
- }
3533
- const running = /\bpid = \d+/.test(printed.out);
3534
- return {
3535
- state: running ? "running" : "installed",
3536
- detail: running ? "LaunchAgent loaded and running." : "LaunchAgent loaded but not running.",
3537
- unitPath,
3538
- ...exec
3539
- };
3957
+ };
3958
+ function spinner2() {
3959
+ if (jsonMode) {
3960
+ return { start: () => {
3961
+ }, update: () => {
3962
+ }, stop: () => {
3963
+ } };
3540
3964
  }
3541
- if (process.platform === "linux") {
3542
- const unitPath = systemdUnitPath();
3543
- if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No systemd user unit installed." };
3544
- const active = run("systemctl", ["--user", "is-active", LINUX_UNIT]);
3965
+ if (!canPrompt()) {
3545
3966
  return {
3546
- state: active.out === "active" ? "running" : "installed",
3547
- detail: `systemd user unit is ${active.out || "unknown"}.`,
3548
- unitPath,
3549
- ...execFacts(unitPath, parseSystemdCliPath)
3967
+ start: (msg) => process.stderr.write(`${msg}
3968
+ `),
3969
+ update: (msg) => process.stderr.write(`${msg}
3970
+ `),
3971
+ stop: (msg) => msg && process.stderr.write(`${msg}
3972
+ `)
3550
3973
  };
3551
3974
  }
3552
- if (process.platform === "win32") {
3553
- const query6 = run("schtasks", ["/Query", "/TN", WINDOWS_TASK]);
3554
- if (!query6.ok) return { state: "not-installed", detail: "No scheduled task installed." };
3555
- return {
3556
- state: /\bRunning\b/i.test(query6.out) ? "running" : "installed",
3557
- detail: "Scheduled task installed (runs at logon).",
3558
- unitPath: WINDOWS_TASK
3559
- };
3975
+ const s = clack.spinner();
3976
+ return {
3977
+ start: (msg) => s.start(msg),
3978
+ update: (msg) => s.message(msg),
3979
+ stop: (msg) => s.stop(msg)
3980
+ };
3981
+ }
3982
+ function unwrap(value) {
3983
+ if (clack.isCancel(value)) throw new CliError("Cancelled.", 130);
3984
+ return value;
3985
+ }
3986
+ async function promptMultiSelect(options) {
3987
+ requireInteractive(options.message, options.flagHint);
3988
+ return unwrap(
3989
+ await clack.multiselect({
3990
+ message: options.message,
3991
+ options: options.choices,
3992
+ initialValues: options.initialValues,
3993
+ required: options.required ?? false
3994
+ })
3995
+ );
3996
+ }
3997
+ async function promptConfirm(options) {
3998
+ if (assumeYes && options.yesFlagApplies !== false) return true;
3999
+ requireInteractive(options.message, "--yes");
4000
+ return unwrap(await clack.confirm({ message: options.message, initialValue: options.initialValue }));
4001
+ }
4002
+ function glyph(level) {
4003
+ if (level === "ok") return pc.green("\u2714");
4004
+ if (level === "warn") return pc.yellow("\u25B2");
4005
+ return pc.red("\u2716");
4006
+ }
4007
+
4008
+ // src/cli/commands/config.ts
4009
+ var TOGGLES = {
4010
+ notifications: "Desktop notifications when a job starts, finishes or fails",
4011
+ keepAwake: "Keep this machine awake while a job is running",
4012
+ autoUpdate: "Install new versions of the runner by itself and restart (PRD \xA715.37)"
4013
+ };
4014
+ var PARALLEL = "parallel";
4015
+ var PARALLEL_HELP = "How many jobs to run at once (1 = one at a time, as before)";
4016
+ var CHANNEL = "channel";
4017
+ var CHANNEL_HELP = "Which npm release channel to follow (default: derived from the version)";
4018
+ var CHECK_EVERY = "checkEvery";
4019
+ var CHECK_EVERY_HELP = `Minutes between update checks (${CHECK_MINUTES_MIN}-${CHECK_MINUTES_MAX})`;
4020
+ var DEFAULT_WORDS = ["default", "auto", "reset"];
4021
+ function isToggle(key) {
4022
+ return Object.hasOwn(TOGGLES, key);
4023
+ }
4024
+ function parseChannel(value) {
4025
+ if (DEFAULT_WORDS.includes(value.toLowerCase())) return null;
4026
+ const lower = value.toLowerCase();
4027
+ if (CHANNELS.includes(lower)) return lower;
4028
+ throw new CliError(`Expected ${CHANNELS.join(" or ")} (or "default"), got "${value}".`);
4029
+ }
4030
+ function parseCheckEvery(value) {
4031
+ if (DEFAULT_WORDS.includes(value.toLowerCase())) return null;
4032
+ const n = Number(value);
4033
+ if (!Number.isInteger(n) || n < CHECK_MINUTES_MIN || n > CHECK_MINUTES_MAX) {
4034
+ throw new CliError(
4035
+ `Expected a whole number of minutes from ${CHECK_MINUTES_MIN} to ${CHECK_MINUTES_MAX} (or "default"), got "${value}".`
4036
+ );
4037
+ }
4038
+ return n;
4039
+ }
4040
+ function parseBool(value) {
4041
+ if (["on", "true", "yes", "1"].includes(value.toLowerCase())) return true;
4042
+ if (["off", "false", "no", "0"].includes(value.toLowerCase())) return false;
4043
+ throw new CliError(`Expected on/off, got "${value}".`);
4044
+ }
4045
+ function parseParallel(value) {
4046
+ if (DEFAULT_WORDS.includes(value.toLowerCase())) return null;
4047
+ const n = Number(value);
4048
+ if (!Number.isInteger(n) || n < 1 || n > PARALLEL_MAX) {
4049
+ throw new CliError(
4050
+ `Expected a whole number from 1 to ${PARALLEL_MAX} (or "default"), got "${value}".`
4051
+ );
3560
4052
  }
3561
- return { state: "unsupported", detail: `No service integration for ${process.platform}.` };
4053
+ return n;
3562
4054
  }
3563
- function installService() {
3564
- const notes = [];
3565
- fs4.mkdirSync(logDir(), { recursive: true, mode: 448 });
3566
- for (const unit of removeLegacyService()) {
3567
- notes.push(`Removed the previous crew-runner service (${unit}).`);
4055
+ async function runConfigList() {
4056
+ const config2 = requireConfig();
4057
+ const values = Object.fromEntries(
4058
+ Object.keys(TOGGLES).map((key) => [key, config2[key] !== false])
4059
+ );
4060
+ const machine = machineCap(config2);
4061
+ const ships = config2.ships.map((shipId) => ({
4062
+ shipId,
4063
+ parallel: shipCap(config2, shipId),
4064
+ explicit: config2.shipParallelJobs?.[shipId] !== void 0
4065
+ }));
4066
+ const channel = resolveChannel(RUNNER_VERSION, config2, process.env);
4067
+ const checkEvery = checkIntervalMs(config2) / 6e4;
4068
+ if (isJson()) {
4069
+ emitJson({ configDir: configDir(), ...values, parallel: machine, channel, checkEvery, ships });
4070
+ return 0;
3568
4071
  }
3569
- if (process.platform === "darwin") {
3570
- const unitPath = launchAgentPath();
3571
- fs4.mkdirSync(path4.dirname(unitPath), { recursive: true });
3572
- fs4.writeFileSync(unitPath, plistXml());
3573
- run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
3574
- const boot = retryWhileTransient(
3575
- () => run("launchctl", ["bootstrap", `gui/${uid()}`, unitPath]),
3576
- sleepSync
3577
- );
3578
- if (!boot.ok) throw new ServiceError(`launchctl bootstrap failed: ${boot.out}`);
3579
- return { unitPath, notes };
4072
+ say.line(` ${configDir()}`);
4073
+ say.line("");
4074
+ for (const key of Object.keys(TOGGLES)) {
4075
+ say.line(` ${key.padEnd(16)} ${values[key] ? "on" : "off"} ${TOGGLES[key]}`);
3580
4076
  }
3581
- if (process.platform === "linux") {
3582
- const unitPath = systemdUnitPath();
3583
- fs4.mkdirSync(path4.dirname(unitPath), { recursive: true });
3584
- fs4.writeFileSync(unitPath, systemdUnit());
3585
- const reload = run("systemctl", ["--user", "daemon-reload"]);
3586
- if (!reload.ok) throw new ServiceError(`systemctl daemon-reload failed: ${reload.out}`);
3587
- const enable = run("systemctl", ["--user", "enable", "--now", LINUX_UNIT]);
3588
- if (!enable.ok) throw new ServiceError(`systemctl enable failed: ${enable.out}`);
3589
- const linger = run("loginctl", ["show-user", os4.userInfo().username, "--property=Linger"]);
3590
- if (!linger.out.includes("Linger=yes")) {
3591
- notes.push(
3592
- `Run \`sudo loginctl enable-linger ${os4.userInfo().username}\` so the daemon survives logout and starts at boot.`
3593
- );
4077
+ say.line(` ${PARALLEL.padEnd(16)} ${String(machine).padEnd(2)} ${PARALLEL_HELP}`);
4078
+ say.line(` ${CHANNEL.padEnd(16)} ${channel.padEnd(2)} ${CHANNEL_HELP}`);
4079
+ say.line(` ${CHECK_EVERY.padEnd(16)} ${String(checkEvery).padEnd(2)} ${CHECK_EVERY_HELP}`);
4080
+ if (ships.length > 0) {
4081
+ say.line("");
4082
+ for (const ship2 of ships) {
4083
+ const note2 = ship2.explicit ? "" : " (machine default)";
4084
+ say.line(` ${`${PARALLEL} --ship`.padEnd(16)} ${ship2.shipId} = ${ship2.parallel}${note2}`);
3594
4085
  }
3595
- return { unitPath, notes };
3596
4086
  }
3597
- if (process.platform === "win32") {
3598
- const command = `"${process.execPath}" "${cliPath()}" start`;
3599
- const create = run("schtasks", [
3600
- "/Create",
3601
- "/TN",
3602
- WINDOWS_TASK,
3603
- "/TR",
3604
- command,
3605
- "/SC",
3606
- "ONLOGON",
3607
- "/RL",
3608
- "LIMITED",
3609
- "/F"
3610
- ]);
3611
- if (!create.ok) throw new ServiceError(`schtasks /Create failed: ${create.out}`);
3612
- notes.push(
3613
- "Task Scheduler starts the daemon at logon but does not restart it if it exits. For a true always-on box, run the daemon under pm2 or a Windows service wrapper instead."
4087
+ return 0;
4088
+ }
4089
+ async function runConfigSet(key, value, options = {}) {
4090
+ const config2 = requireConfig();
4091
+ if (key === PARALLEL) return setParallel(config2, value, options.ship);
4092
+ if (key === CHANNEL || key === CHECK_EVERY) return setUpdateSetting(config2, key, value, options.ship);
4093
+ if (!isToggle(key)) {
4094
+ throw new CliError(
4095
+ `Unknown setting "${key}". Known: ${[...Object.keys(TOGGLES), PARALLEL, CHANNEL, CHECK_EVERY].join(", ")}.`
3614
4096
  );
3615
- return { unitPath: WINDOWS_TASK, notes };
3616
4097
  }
3617
- throw new ServiceError(`Service install is not supported on ${process.platform}.`);
4098
+ if (options.ship) {
4099
+ throw new CliError(`"${key}" is a machine-wide setting \u2014 it takes no --ship.`);
4100
+ }
4101
+ config2[key] = parseBool(value);
4102
+ saveConfig(config2);
4103
+ if (isJson()) {
4104
+ emitJson({ [key]: config2[key] });
4105
+ return 0;
4106
+ }
4107
+ say.success(`${key} = ${config2[key] ? "on" : "off"}`);
4108
+ return 0;
3618
4109
  }
3619
- function uninstallService() {
3620
- removeLegacyService();
3621
- if (process.platform === "darwin") {
3622
- run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
3623
- fs4.rmSync(launchAgentPath(), { force: true });
3624
- return;
4110
+ function setUpdateSetting(config2, key, value, ship2) {
4111
+ if (ship2) throw new CliError(`"${key}" is a machine-wide setting \u2014 it takes no --ship.`);
4112
+ if (key === CHANNEL) {
4113
+ const parsed = parseChannel(value);
4114
+ if (parsed === null) delete config2.updateChannel;
4115
+ else config2.updateChannel = parsed;
4116
+ } else {
4117
+ const parsed = parseCheckEvery(value);
4118
+ if (parsed === null) delete config2.updateCheckMinutes;
4119
+ else config2.updateCheckMinutes = parsed;
3625
4120
  }
3626
- if (process.platform === "linux") {
3627
- run("systemctl", ["--user", "disable", "--now", LINUX_UNIT]);
3628
- fs4.rmSync(systemdUnitPath(), { force: true });
3629
- run("systemctl", ["--user", "daemon-reload"]);
3630
- return;
4121
+ saveConfig(config2);
4122
+ const channel = resolveChannel(RUNNER_VERSION, config2, process.env);
4123
+ const minutes = checkIntervalMs(config2) / 6e4;
4124
+ if (isJson()) {
4125
+ emitJson({ channel, checkEvery: minutes });
4126
+ return 0;
3631
4127
  }
3632
- if (process.platform === "win32") {
3633
- run("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
3634
- return;
4128
+ say.success(key === CHANNEL ? `${CHANNEL} = ${channel}` : `${CHECK_EVERY} = ${minutes}`);
4129
+ if (key === CHANNEL && config2.updateChannel === void 0) {
4130
+ say.line(` Derived from the installed version (${RUNNER_VERSION}).`);
3635
4131
  }
3636
- throw new ServiceError(`Service uninstall is not supported on ${process.platform}.`);
4132
+ if (key === CHANNEL && parseChannel(value) === "latest" && RUNNER_VERSION.includes("-")) {
4133
+ say.line(
4134
+ ` This machine is on a prerelease (${RUNNER_VERSION}). If \`latest\` is behind it, nothing will install until a stable release passes it \u2014 to move now: \`npm i -g @lumi.ai/runner@latest\`.`
4135
+ );
4136
+ }
4137
+ say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
4138
+ return 0;
3637
4139
  }
3638
- function restartService() {
3639
- if (process.platform === "darwin") {
3640
- const result = run("launchctl", ["kickstart", "-k", `gui/${uid()}/${SERVICE_LABEL}`]);
3641
- if (!result.ok) throw new ServiceError(`launchctl kickstart failed: ${result.out}`);
3642
- return;
4140
+ function setParallel(config2, value, ship2) {
4141
+ const parsed = parseParallel(value);
4142
+ if (ship2) {
4143
+ if (!config2.ships.includes(ship2)) {
4144
+ throw new CliError(
4145
+ `This machine does not serve Ship "${ship2}". Run \`lumi-runner ship list\` to see which Ships it serves, or \`lumi-runner ship add ${ship2}\` to add it.`
4146
+ );
4147
+ }
4148
+ const overrides = { ...config2.shipParallelJobs ?? {} };
4149
+ if (parsed === null) delete overrides[ship2];
4150
+ else overrides[ship2] = parsed;
4151
+ config2.shipParallelJobs = overrides;
4152
+ } else if (parsed === null) {
4153
+ delete config2.maxParallelJobs;
4154
+ } else {
4155
+ config2.maxParallelJobs = parsed;
3643
4156
  }
3644
- if (process.platform === "linux") {
3645
- const result = run("systemctl", ["--user", "restart", LINUX_UNIT]);
3646
- if (!result.ok) throw new ServiceError(`systemctl restart failed: ${result.out}`);
3647
- return;
4157
+ saveConfig(config2);
4158
+ const effective = ship2 ? shipCap(config2, ship2) : machineCap(config2);
4159
+ if (isJson()) {
4160
+ emitJson({ parallel: effective, ...ship2 ? { shipId: ship2 } : {} });
4161
+ return 0;
3648
4162
  }
3649
- if (process.platform === "win32") {
3650
- run("schtasks", ["/End", "/TN", WINDOWS_TASK]);
3651
- const result = run("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
3652
- if (!result.ok) throw new ServiceError(`schtasks /Run failed: ${result.out}`);
3653
- return;
4163
+ const machine = machineCap(config2);
4164
+ say.success(
4165
+ ship2 ? `${PARALLEL} for Ship ${ship2} = ${effective}${parsed === null ? " (machine default)" : ""}` : `${PARALLEL} = ${effective}`
4166
+ );
4167
+ if (ship2 && parsed !== null && parsed > machine) {
4168
+ say.line(
4169
+ ` Capped at the machine-wide limit of ${machine}. Raise it with \`lumi-runner config set ${PARALLEL} ${parsed}\`.`
4170
+ );
3654
4171
  }
3655
- throw new ServiceError(`Service restart is not supported on ${process.platform}.`);
4172
+ say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
4173
+ return 0;
3656
4174
  }
3657
4175
 
4176
+ // src/cli/commands/doctor.ts
4177
+ import { spawnSync as spawnSync3 } from "node:child_process";
4178
+ import { collection as collection6, doc as doc8, getDoc as getDoc7, getDocs as getDocs5 } from "firebase/firestore";
4179
+
3658
4180
  // src/cli/session.ts
3659
4181
  async function openShipSession(shipId) {
3660
4182
  const config2 = requireConfig();
@@ -3681,7 +4203,7 @@ var fail = (id, label, detail, fix) => ({
3681
4203
  });
3682
4204
  function onPath(binary) {
3683
4205
  const probe = process.platform === "win32" ? "where" : "which";
3684
- return spawnSync2(probe, [binary], { stdio: "ignore" }).status === 0;
4206
+ return spawnSync3(probe, [binary], { stdio: "ignore" }).status === 0;
3685
4207
  }
3686
4208
  function checkNode() {
3687
4209
  const major = Number(process.versions.node.split(".")[0]);
@@ -3727,6 +4249,33 @@ function serviceCheckFrom(status) {
3727
4249
  function checkService() {
3728
4250
  return serviceCheckFrom(serviceStatus());
3729
4251
  }
4252
+ function versionCheckFrom(input) {
4253
+ const { current, latest, channel, autoUpdate } = input;
4254
+ const label = "Runner version";
4255
+ if (current === DEV_VERSION) {
4256
+ return ok("version", label, `${current} \u2014 development build, not checked.`);
4257
+ }
4258
+ if (latest === null) {
4259
+ return warn("version", label, `${current} \u2014 could not reach the npm registry to check.`);
4260
+ }
4261
+ if (!isNewer(latest, current)) return ok("version", label, `${current} (${channel})`);
4262
+ return warn(
4263
+ "version",
4264
+ label,
4265
+ `${current} \u2014 ${latest} is available on \`${channel}\`.`,
4266
+ autoUpdate ? "Nothing to do \u2014 this machine installs it by itself. `lumi-runner update` does it now." : "Auto-update is off. Run `lumi-runner update`."
4267
+ );
4268
+ }
4269
+ async function checkVersion(config2) {
4270
+ const channel = resolveChannel(RUNNER_VERSION, config2, process.env);
4271
+ const tags = await fetchDistTags();
4272
+ return versionCheckFrom({
4273
+ current: RUNNER_VERSION,
4274
+ latest: tags?.[channel] ?? null,
4275
+ channel,
4276
+ autoUpdate: config2.autoUpdate !== false
4277
+ });
4278
+ }
3730
4279
  async function checkShips(config2) {
3731
4280
  const checks = [];
3732
4281
  const engines = /* @__PURE__ */ new Set();
@@ -3754,7 +4303,7 @@ async function checkShips(config2) {
3754
4303
  continue;
3755
4304
  }
3756
4305
  try {
3757
- const snap = await getDoc6(
4306
+ const snap = await getDoc7(
3758
4307
  doc8(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId)
3759
4308
  );
3760
4309
  if (!snap.exists()) {
@@ -3862,6 +4411,7 @@ async function runDoctor() {
3862
4411
  }
3863
4412
  checks.push(await checkMcp(mcpUrl(config2)));
3864
4413
  checks.push(checkService());
4414
+ checks.push(await checkVersion(config2));
3865
4415
  progress.stop("Checks complete.");
3866
4416
  return report(checks);
3867
4417
  }
@@ -3894,7 +4444,7 @@ function report(checks) {
3894
4444
  }
3895
4445
 
3896
4446
  // src/cli/commands/login.ts
3897
- import { spawn as spawn4 } from "node:child_process";
4447
+ import { spawn as spawn5 } from "node:child_process";
3898
4448
  import os5 from "node:os";
3899
4449
  import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
3900
4450
  function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
@@ -3920,7 +4470,7 @@ function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
3920
4470
  function openBrowser(url) {
3921
4471
  const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
3922
4472
  try {
3923
- const child = spawn4(command, args, { stdio: "ignore", detached: true });
4473
+ const child = spawn5(command, args, { stdio: "ignore", detached: true });
3924
4474
  child.on("error", () => {
3925
4475
  });
3926
4476
  child.unref();
@@ -4140,20 +4690,20 @@ async function runServiceStatus() {
4140
4690
  }
4141
4691
 
4142
4692
  // src/cli/commands/uninstall.ts
4143
- import fs5 from "node:fs";
4693
+ import fs7 from "node:fs";
4144
4694
  async function runUninstall(options) {
4145
4695
  const before = serviceStatus();
4146
4696
  const dir = configDir();
4147
4697
  const hadService = before.state !== "not-installed" && before.state !== "unsupported";
4148
4698
  if (hadService) uninstallService();
4149
4699
  let purged = false;
4150
- if (options.purge && fs5.existsSync(dir)) {
4700
+ if (options.purge && fs7.existsSync(dir)) {
4151
4701
  const confirmed = await promptConfirm({
4152
4702
  message: `Delete ${dir}? This machine loses its identity \u2014 a captain has to approve it again after reinstalling.`,
4153
4703
  initialValue: false
4154
4704
  });
4155
4705
  if (confirmed) {
4156
- fs5.rmSync(dir, { recursive: true, force: true });
4706
+ fs7.rmSync(dir, { recursive: true, force: true });
4157
4707
  purged = true;
4158
4708
  }
4159
4709
  }
@@ -4179,7 +4729,7 @@ async function runUninstall(options) {
4179
4729
  }
4180
4730
 
4181
4731
  // src/cli/commands/ship.ts
4182
- import { doc as doc9, getDoc as getDoc7 } from "firebase/firestore";
4732
+ import { doc as doc9, getDoc as getDoc8 } from "firebase/firestore";
4183
4733
  async function listMyShips() {
4184
4734
  const config2 = requireConfig();
4185
4735
  const ids = Object.keys(config2.shipKeys ?? {});
@@ -4187,7 +4737,7 @@ async function listMyShips() {
4187
4737
  ids.map(async (id) => {
4188
4738
  try {
4189
4739
  const { fb } = await openShipSession(id);
4190
- const snap = await getDoc7(doc9(fb.db, COLLECTIONS.ships, id));
4740
+ const snap = await getDoc8(doc9(fb.db, COLLECTIONS.ships, id));
4191
4741
  return { id, ...snap.data() };
4192
4742
  } catch {
4193
4743
  return null;
@@ -4314,7 +4864,7 @@ import {
4314
4864
  collection as collection7,
4315
4865
  doc as doc10,
4316
4866
  getCountFromServer as getCountFromServer2,
4317
- getDoc as getDoc8,
4867
+ getDoc as getDoc9,
4318
4868
  getDocs as getDocs6,
4319
4869
  query as query5,
4320
4870
  where as where5
@@ -4352,7 +4902,7 @@ async function runStatus() {
4352
4902
  continue;
4353
4903
  }
4354
4904
  const shipRef = doc10(fb.db, COLLECTIONS.ships, shipId);
4355
- const mirrorSnap = await getDoc8(doc10(shipRef, COLLECTIONS.runners, config2.runnerId));
4905
+ const mirrorSnap = await getDoc9(doc10(shipRef, COLLECTIONS.runners, config2.runnerId));
4356
4906
  const mirror = mirrorSnap.data();
4357
4907
  let queued = 0;
4358
4908
  try {
@@ -4363,7 +4913,7 @@ async function runStatus() {
4363
4913
  } catch {
4364
4914
  queued = 0;
4365
4915
  }
4366
- const usageSnap = await getDoc8(doc10(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
4916
+ const usageSnap = await getDoc9(doc10(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
4367
4917
  const today = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
4368
4918
  const now = Date.now();
4369
4919
  const limitsSnap = await getDocs6(collection7(shipRef, COLLECTIONS.engineLimits));
@@ -4427,6 +4977,106 @@ async function runStatus() {
4427
4977
  return 0;
4428
4978
  }
4429
4979
 
4980
+ // src/cli/commands/update.ts
4981
+ function pickChannel(option) {
4982
+ if (option) {
4983
+ const lower = option.toLowerCase();
4984
+ if (!CHANNELS.includes(lower)) {
4985
+ throw new CliError(`Expected ${CHANNELS.join(" or ")}, got "${option}".`);
4986
+ }
4987
+ return lower;
4988
+ }
4989
+ return resolveChannel(RUNNER_VERSION, loadConfig() ?? {}, process.env);
4990
+ }
4991
+ async function runUpdate(options = {}) {
4992
+ if (RUNNER_VERSION === DEV_VERSION) {
4993
+ if (isJson()) {
4994
+ emitJson({ current: RUNNER_VERSION, updateAvailable: null, reason: "dev-build" });
4995
+ return 0;
4996
+ }
4997
+ say.info("Development build \u2014 nothing to update.");
4998
+ return 0;
4999
+ }
5000
+ const channel = pickChannel(options.channel);
5001
+ const tags = await fetchDistTags();
5002
+ const latest = tags?.[channel] ?? null;
5003
+ if (latest === null) {
5004
+ const reason = tags === null ? "the npm registry could not be reached" : `nothing is published on \`${channel}\``;
5005
+ if (isJson()) {
5006
+ emitJson({ current: RUNNER_VERSION, channel, updateAvailable: null, reason });
5007
+ return options.check ? 0 : 1;
5008
+ }
5009
+ if (options.check) {
5010
+ say.info(`Could not check for updates \u2014 ${reason}.`);
5011
+ return 0;
5012
+ }
5013
+ throw new CliError(`Could not update \u2014 ${reason}.`);
5014
+ }
5015
+ if (!isNewer(latest, RUNNER_VERSION)) {
5016
+ if (isJson()) {
5017
+ emitJson({ current: RUNNER_VERSION, latest, channel, updateAvailable: null });
5018
+ return 0;
5019
+ }
5020
+ say.success(`Already up to date \u2014 ${RUNNER_VERSION} on \`${channel}\`.`);
5021
+ return 0;
5022
+ }
5023
+ if (options.check) {
5024
+ if (isJson()) {
5025
+ emitJson({ current: RUNNER_VERSION, latest, channel, updateAvailable: latest });
5026
+ return 0;
5027
+ }
5028
+ say.info(`${RUNNER_VERSION} \u2192 ${latest} is available on \`${channel}\`.`);
5029
+ say.line(" Install it with: lumi-runner update");
5030
+ return 0;
5031
+ }
5032
+ const args = installArgs(latest, cliPath());
5033
+ if (process.platform === "win32") {
5034
+ if (isJson()) {
5035
+ emitJson({ current: RUNNER_VERSION, latest, channel, updateAvailable: latest, command: `npm ${args.join(" ")}` });
5036
+ return 0;
5037
+ }
5038
+ say.info(`${RUNNER_VERSION} \u2192 ${latest} is available.`);
5039
+ say.line(" Windows locks the running file, so stop the task first, then install:");
5040
+ say.line(" schtasks /End /TN LumiRunner");
5041
+ say.line(` npm ${args.join(" ")}`);
5042
+ return 0;
5043
+ }
5044
+ if (options.dryRun) {
5045
+ if (isJson()) {
5046
+ emitJson({ current: RUNNER_VERSION, latest, channel, updateAvailable: latest, command: `npm ${args.join(" ")}`, installed: false });
5047
+ return 0;
5048
+ }
5049
+ say.info(`Would run: npm ${args.join(" ")}`);
5050
+ return 0;
5051
+ }
5052
+ if (!npmPresent()) throw new CliError("`npm` is not on PATH, so there is nothing to install with.");
5053
+ say.line(`Installing ${PACKAGE_NAME}@${latest}\u2026`);
5054
+ const result = await installGlobal(latest, cliPath());
5055
+ if (!result.ok) throw new CliError(`npm install failed: ${result.detail.slice(0, 400)}`);
5056
+ const onDisk = installedVersionAt(cliPath());
5057
+ if (onDisk !== latest) {
5058
+ throw new CliError(
5059
+ `npm reported success, but ${cliPath()} is still ${onDisk ?? "unreadable"}. The install probably went to a different npm prefix \u2014 check \`npm prefix -g\` and which node is on PATH.`
5060
+ );
5061
+ }
5062
+ clearUpdateState();
5063
+ const service2 = serviceStatus();
5064
+ let restarted = false;
5065
+ if (service2.state === "running" || service2.state === "installed") {
5066
+ restartService();
5067
+ restarted = true;
5068
+ }
5069
+ if (isJson()) {
5070
+ emitJson({ current: RUNNER_VERSION, latest, channel, installed: true, restarted });
5071
+ return 0;
5072
+ }
5073
+ say.success(`Updated to ${latest}.`);
5074
+ say.line(
5075
+ restarted ? " The background service has been restarted on the new version." : " Restart your daemon to pick it up."
5076
+ );
5077
+ return 0;
5078
+ }
5079
+
4430
5080
  // src/cli/index.ts
4431
5081
  var program = new Command();
4432
5082
  program.name("lumi-runner").description("Lumi Crew runner daemon \u2014 runs agent jobs for your Ships on this machine.").version(RUNNER_VERSION).option("--json", "machine-readable output on stdout").option("-y, --yes", "assume yes for confirmations").option("--no-color", "disable coloured output").hook("preAction", (command) => {
@@ -4475,6 +5125,7 @@ program.command("start").description("Run the daemon in the foreground").action(
4475
5125
  );
4476
5126
  program.command("doctor").description("Check whether this machine can run jobs").action(action(runDoctor));
4477
5127
  program.command("status").description("Show what this machine is doing").action(action(runStatus));
5128
+ program.command("update").description("Update this machine to the latest published version").option("--check", "report whether a newer version exists, and install nothing").option("--dry-run", "print the npm command that would run").option("--channel <name>", "latest or dev (default: whichever this version came from)").action(action((options) => runUpdate(options)));
4478
5129
  program.command("logs").description("Show the daemon's log file").option("-n, --lines <count>", "how many lines to show", "50").option("-f, --follow", "keep printing new lines").action(
4479
5130
  action(
4480
5131
  async (options) => runLogs({ lines: Number(options.lines) || 50, follow: options.follow === true })