@lumi.ai/runner 0.4.0 → 0.5.1

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 +22 -4
  2. package/dist/cli.js +202 -50
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,7 +5,7 @@ workspace.
5
5
 
6
6
  Crew agents don't run in the cloud. They run **on your machine**, in your checkouts, with your
7
7
  tools. This daemon is what makes that happen: it watches the job queues of the Ships you assign it
8
- to, claims work one job at a time, and executes each as a headless Claude session.
8
+ to, claims work oldest-first, and executes each job as a headless Claude session.
9
9
 
10
10
  ```bash
11
11
  npm i -g @lumi.ai/runner
@@ -98,6 +98,24 @@ reachability, the background service, and whether that service still points at a
98
98
  Your `PATH` is captured into the unit at install time. launchd and systemd start processes with a
99
99
  minimal environment, so without that `claude`, `git` and `gh` would not be found.
100
100
 
101
+ ## How many jobs at once
102
+
103
+ One, until you say otherwise — the same behaviour this daemon has always had.
104
+
105
+ ```bash
106
+ lumi-runner config set parallel 3 # this machine, across every Ship
107
+ lumi-runner config set parallel 1 --ship shp_abc123 # cap one Ship's share of it
108
+ lumi-runner config set parallel default --ship shp_abc123 # back to the machine number
109
+ lumi-runner config list # what is set, and what each Ship gets
110
+ ```
111
+
112
+ The machine number is a ceiling, not a total: three Ships at `2` each on a machine set to `3` will
113
+ never run more than three sessions. A Ship with no number of its own may use the whole machine.
114
+
115
+ Each job is a separate `claude` process with its own empty working directory, so they don't share
116
+ state — but they do share your CPU, your RAM and your Claude usage window. Start at 2 and watch a
117
+ real job before going higher. Restart the daemon (`lumi-runner service restart`) to pick up a change.
118
+
101
119
  ## While a job runs
102
120
 
103
121
  - **Idle sleep is inhibited** (`caffeinate` / `systemd-inhibit`, best-effort on Windows), so a
@@ -105,9 +123,9 @@ minimal environment, so without that `claude`, `git` and `gh` would not be found
105
123
  gets that veto on macOS, and it shouldn't.
106
124
  - **A desktop notification** fires on job start, finish and terminal failure, and when the daemon is
107
125
  stopped with work in flight. Silence it with `lumi-runner config set notifications off`.
108
- - **SIGTERM releases the job.** The daemon aborts the session, hands the job back to the queue with
109
- its retry budget **unspent**, and only then writes itself offline. Stopping the daemon never costs
110
- you an attempt.
126
+ - **SIGTERM releases the job.** The daemon aborts every running session, hands each job back to the
127
+ queue with its retry budget **unspent**, and only then writes itself offline. Stopping the daemon
128
+ never costs you an attempt.
111
129
 
112
130
  ## Credentials
113
131
 
package/dist/cli.js CHANGED
@@ -535,14 +535,24 @@ function saveConfig(config2) {
535
535
  function forgetShip(config2, shipId) {
536
536
  const shipKeys = { ...config2.shipKeys ?? {} };
537
537
  delete shipKeys[shipId];
538
- return { ...config2, shipKeys, ships: config2.ships.filter((id) => id !== shipId) };
538
+ const next = {
539
+ ...config2,
540
+ shipKeys,
541
+ ships: config2.ships.filter((id) => id !== shipId)
542
+ };
543
+ if (next.shipParallelJobs) {
544
+ const overrides = { ...next.shipParallelJobs };
545
+ delete overrides[shipId];
546
+ next.shipParallelJobs = overrides;
547
+ }
548
+ return next;
539
549
  }
540
550
  function mcpUrl(config2) {
541
551
  return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`;
542
552
  }
543
553
 
544
554
  // src/version.ts
545
- var RUNNER_VERSION = true ? "0.4.0" : "0.0.0-dev";
555
+ var RUNNER_VERSION = true ? "0.5.1" : "0.0.0-dev";
546
556
 
547
557
  // src/auth.ts
548
558
  import { signInWithCustomToken } from "firebase/auth";
@@ -1658,6 +1668,35 @@ function subscribeEngineLimits(db, shipId, cb, onError) {
1658
1668
  );
1659
1669
  }
1660
1670
 
1671
+ // src/jobs/capacity.ts
1672
+ var DEFAULT_PARALLEL_JOBS = 1;
1673
+ var PARALLEL_MAX = 8;
1674
+ var clamp = (n) => Math.min(PARALLEL_MAX, Math.max(1, Math.floor(n)));
1675
+ var readCount = (value) => typeof value === "number" && Number.isFinite(value) && value >= 1 ? clamp(value) : null;
1676
+ function machineCap(config2) {
1677
+ return readCount(config2.maxParallelJobs) ?? DEFAULT_PARALLEL_JOBS;
1678
+ }
1679
+ function shipCap(config2, shipId) {
1680
+ const machine = machineCap(config2);
1681
+ const own = readCount(config2.shipParallelJobs?.[shipId]);
1682
+ return own === null ? machine : Math.min(own, machine);
1683
+ }
1684
+ function selectDispatch(input) {
1685
+ const free = input.machineCap - input.runningTotal;
1686
+ if (free <= 0) return [];
1687
+ const taken = new Map(input.runningByShip);
1688
+ const picked = [];
1689
+ for (const entry of [...input.pending].sort((a, b) => a.job.createdAt - b.job.createdAt)) {
1690
+ if (picked.length >= free) break;
1691
+ if (!input.eligible(entry)) continue;
1692
+ const running = taken.get(entry.shipId) ?? 0;
1693
+ if (running >= input.shipCap(entry.shipId)) continue;
1694
+ taken.set(entry.shipId, running + 1);
1695
+ picked.push(entry);
1696
+ }
1697
+ return picked;
1698
+ }
1699
+
1661
1700
  // src/jobs/finish.ts
1662
1701
  import {
1663
1702
  addDoc,
@@ -1838,8 +1877,13 @@ async function startDaemon() {
1838
1877
  };
1839
1878
  if (moved?.migrated) log2(`Moved runner config from ${moved.from} to ${moved.to}.`);
1840
1879
  const startedAt = Date.now();
1841
- let currentJob = null;
1842
- let currentJobShip = null;
1880
+ const running = /* @__PURE__ */ new Map();
1881
+ const runningByShip = () => {
1882
+ const counts = /* @__PURE__ */ new Map();
1883
+ for (const r of running.values()) counts.set(r.shipId, (counts.get(r.shipId) ?? 0) + 1);
1884
+ return counts;
1885
+ };
1886
+ const liveJobsOn = (shipId) => [...running.values()].filter((r) => r.shipId === shipId && r.mirror).map((r) => r.mirror).sort((a, b) => a.startedAt - b.startedAt);
1843
1887
  const approved = /* @__PURE__ */ new Map();
1844
1888
  const warnedUnapproved = /* @__PURE__ */ new Set();
1845
1889
  const shipRunnerRef = (shipId) => doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
@@ -1870,6 +1914,7 @@ async function startDaemon() {
1870
1914
  warnedUnapproved.delete(shipId);
1871
1915
  needsRefill.add(shipId);
1872
1916
  }
1917
+ const live = liveJobsOn(shipId);
1873
1918
  await setDoc2(
1874
1919
  shipRunnerRef(shipId),
1875
1920
  {
@@ -1878,7 +1923,9 @@ async function startDaemon() {
1878
1923
  status: "online",
1879
1924
  lastSeenAt: now,
1880
1925
  startedAt,
1881
- currentJob: currentJob ?? null,
1926
+ currentJob: live[0] ?? null,
1927
+ currentJobs: live,
1928
+ parallelLimit: shipCap(config2, shipId),
1882
1929
  lastLogLines: [...logLines]
1883
1930
  },
1884
1931
  { merge: true }
@@ -2013,11 +2060,12 @@ async function startDaemon() {
2013
2060
  log2(
2014
2061
  `Ship ${shipId}: ${why}. Deleted its runner key, its assignment and its queued work from this machine. To serve it again, run \`lumi-runner login\` here and have a captain approve this machine in the browser.`
2015
2062
  );
2016
- if (currentJobShip === shipId && sessionAbort) {
2063
+ const stranded = [...running.values()].filter((r) => r.shipId === shipId);
2064
+ if (stranded.length > 0) {
2017
2065
  log2(
2018
- `Stopping the job it was running \u2014 this machine can no longer write to Ship ${shipId}, so the Ship put that job back on the queue itself.`
2066
+ `Stopping the ${stranded.length} job(s) it was running there \u2014 this machine can no longer write to Ship ${shipId}, so the Ship put them back on the queue itself.`
2019
2067
  );
2020
- sessionAbort.abort();
2068
+ for (const r of stranded) r.abort.abort();
2021
2069
  }
2022
2070
  if (serving.size === 0) {
2023
2071
  log2(
@@ -2026,17 +2074,15 @@ async function startDaemon() {
2026
2074
  }
2027
2075
  }
2028
2076
  const engineForJob = (shipId, job) => agentEngines.get(`${shipId}/${job.agentId}`) ?? DEFAULT_ENGINE_ID;
2029
- let working = false;
2077
+ let dispatching = false;
2030
2078
  let pokeRequested = false;
2031
2079
  let shuttingDown = false;
2032
- let sessionAbort = null;
2033
- let inFlight = null;
2034
2080
  function poke() {
2035
- if (working || shuttingDown) {
2081
+ if (dispatching || shuttingDown) {
2036
2082
  pokeRequested = !shuttingDown;
2037
2083
  return;
2038
2084
  }
2039
- void workLoop();
2085
+ dispatch();
2040
2086
  }
2041
2087
  let limitTimer = null;
2042
2088
  function armLimitTimer() {
@@ -2062,30 +2108,51 @@ async function startDaemon() {
2062
2108
  armLimitTimer();
2063
2109
  if (cleared.length > 0) poke();
2064
2110
  }
2065
- async function workLoop() {
2066
- working = true;
2111
+ function dispatch() {
2112
+ dispatching = true;
2067
2113
  try {
2068
2114
  for (; ; ) {
2069
2115
  pokeRequested = false;
2070
2116
  if (shuttingDown) break;
2071
2117
  const now = Date.now();
2072
- const next = [...pending.values()].filter((p) => approved.get(p.shipId) === true).filter((p) => !isLimited(engineLimits, p.shipId, engineForJob(p.shipId, p.job), now)).sort((a, b) => a.job.createdAt - b.job.createdAt)[0];
2073
- if (!next) break;
2074
- pending.delete(`${next.shipId}/${next.job.id}`);
2075
- inFlight = processJob(next.shipId, next.job);
2076
- try {
2077
- await inFlight;
2078
- } finally {
2079
- inFlight = null;
2118
+ const picks = selectDispatch({
2119
+ pending: pending.values(),
2120
+ runningByShip: runningByShip(),
2121
+ runningTotal: running.size,
2122
+ machineCap: machineCap(config2),
2123
+ shipCap: (shipId) => shipCap(config2, shipId),
2124
+ // Only Ships that have approved this machine are eligible; claiming elsewhere would
2125
+ // just be denied by the rules, so skip rather than hammer the queue. Engine-generic:
2126
+ // the limit map is keyed by (Ship, engine) and the engine comes from the registry via
2127
+ // the agent, so the job loop still knows nothing about Claude. Skipped entries STAY in
2128
+ // `pending`, which is what lets a reset resume them with only a poke.
2129
+ eligible: (p) => approved.get(p.shipId) === true && !isLimited(engineLimits, p.shipId, engineForJob(p.shipId, p.job), now)
2130
+ });
2131
+ for (const pick of picks) {
2132
+ pending.delete(`${pick.shipId}/${pick.job.id}`);
2133
+ startJob(pick.shipId, pick.job);
2080
2134
  }
2081
- if (!pokeRequested && pending.size === 0) break;
2135
+ if (!pokeRequested) break;
2082
2136
  }
2083
2137
  } finally {
2084
- working = false;
2085
- if (pokeRequested) poke();
2138
+ dispatching = false;
2086
2139
  }
2087
2140
  }
2141
+ function startJob(shipId, job) {
2142
+ const entry = { shipId, abort: new AbortController(), promise: Promise.resolve() };
2143
+ running.set(job.id, entry);
2144
+ entry.promise = processJob(shipId, job, entry).catch((e) => {
2145
+ log2(`job ${job.id} ended abnormally: ${e instanceof Error ? e.message : e}`);
2146
+ }).finally(() => {
2147
+ running.delete(job.id);
2148
+ if (!shuttingDown) poke();
2149
+ });
2150
+ }
2088
2151
  async function claim(shipId, job) {
2152
+ if (isLimited(engineLimits, shipId, engineForJob(shipId, job), Date.now())) {
2153
+ pending.set(`${shipId}/${job.id}`, { shipId, job });
2154
+ return null;
2155
+ }
2089
2156
  const jobRef = doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id);
2090
2157
  try {
2091
2158
  let claimed = null;
@@ -2116,7 +2183,7 @@ async function startDaemon() {
2116
2183
  async function loadSecrets(shipId) {
2117
2184
  return loadRunnerSecrets(sess(shipId).fb.db, shipId);
2118
2185
  }
2119
- async function processJob(shipId, queuedJob) {
2186
+ async function processJob(shipId, queuedJob, slot) {
2120
2187
  const job = await claim(shipId, queuedJob);
2121
2188
  if (!job) return;
2122
2189
  const target = jobTarget(job);
@@ -2136,12 +2203,13 @@ async function startDaemon() {
2136
2203
  transcriptPath: "",
2137
2204
  error: "Job names neither a task nor a chat, so there is nothing to run."
2138
2205
  });
2139
- currentJob = null;
2140
2206
  return;
2141
2207
  }
2142
2208
  const targetLabel = target.kind === "task" ? `task ${target.taskId}` : `chat ${target.chatId}`;
2143
- log2(`Claimed job ${job.id} (ship ${shipId}, ${targetLabel}, attempt ${job.attempt})`);
2144
- currentJob = {
2209
+ log2(
2210
+ `Claimed job ${job.id} (ship ${shipId}, ${targetLabel}, attempt ${job.attempt}) \u2014 slot ${running.size}/${machineCap(config2)}`
2211
+ );
2212
+ slot.mirror = {
2145
2213
  jobId: job.id,
2146
2214
  // Conditional spread: Firestore rejects an explicit undefined in the mirror write.
2147
2215
  ...target.kind === "task" ? { taskId: target.taskId } : { chatId: target.chatId },
@@ -2150,11 +2218,9 @@ async function startDaemon() {
2150
2218
  ...job.workflowName ? { workflowName: job.workflowName } : {},
2151
2219
  startedAt: Date.now()
2152
2220
  };
2153
- currentJobShip = shipId;
2154
2221
  await setAgentStatus(shipId, job.agentId, "working");
2155
2222
  void heartbeat();
2156
2223
  const wake = config2.keepAwake === false ? null : inhibitSleep(`Crew job ${job.id}`);
2157
- sessionAbort = new AbortController();
2158
2224
  notify(
2159
2225
  "Crew job started",
2160
2226
  target.kind === "task" ? `Agent is working on task ${target.taskId}.` : "Agent is replying in a chat."
@@ -2214,7 +2280,7 @@ async function startDaemon() {
2214
2280
  }
2215
2281
  throw e;
2216
2282
  }
2217
- if (sessionAbort.signal.aborted) throw new Error("Shutting down before the session started.");
2283
+ if (slot.abort.signal.aborted) throw new Error("Stopped before the session started.");
2218
2284
  const session = await getDriver(engineId).run({
2219
2285
  prompt,
2220
2286
  agent,
@@ -2225,7 +2291,7 @@ async function startDaemon() {
2225
2291
  secrets,
2226
2292
  githubToken,
2227
2293
  timeoutMs: JOB_TIMEOUT_MS,
2228
- signal: sessionAbort.signal,
2294
+ signal: slot.abort.signal,
2229
2295
  log: log2
2230
2296
  });
2231
2297
  transcript = redactTranscript(session.transcript, [
@@ -2251,7 +2317,6 @@ async function startDaemon() {
2251
2317
  failure = e instanceof Error ? e.message : String(e);
2252
2318
  }
2253
2319
  wake?.release();
2254
- sessionAbort = null;
2255
2320
  try {
2256
2321
  if (!serving.has(shipId)) {
2257
2322
  log2(
@@ -2307,8 +2372,7 @@ async function startDaemon() {
2307
2372
  } catch (e) {
2308
2373
  log2(`finalize failed for ${job.id}: ${e instanceof Error ? e.message : e}`);
2309
2374
  }
2310
- currentJob = null;
2311
- currentJobShip = null;
2375
+ slot.mirror = void 0;
2312
2376
  if (serving.has(shipId)) await setAgentStatus(shipId, job.agentId, "idle");
2313
2377
  if (!shuttingDown) void heartbeat();
2314
2378
  }
@@ -2326,16 +2390,22 @@ async function startDaemon() {
2326
2390
  clearInterval(heartbeatTimer);
2327
2391
  if (limitTimer) clearTimeout(limitTimer);
2328
2392
  for (const shipUnsubs of unsubsByShip.values()) shipUnsubs.forEach((u) => u());
2329
- if (sessionAbort) {
2330
- log2(`${signal} received with a job in flight \u2014 stopping the session and releasing it.`);
2331
- notify("Crew runner stopping", "A job was in progress; it has been returned to the queue.");
2332
- sessionAbort.abort();
2393
+ const inFlight = [...running.values()];
2394
+ if (inFlight.length > 0) {
2395
+ log2(
2396
+ `${signal} received with ${inFlight.length} job(s) in flight \u2014 stopping the sessions and releasing them.`
2397
+ );
2398
+ notify(
2399
+ "Crew runner stopping",
2400
+ `${inFlight.length} job(s) were in progress; they have been returned to the queue.`
2401
+ );
2402
+ for (const r of inFlight) r.abort.abort();
2333
2403
  } else {
2334
2404
  log2(`${signal} received \u2014 shutting down.`);
2335
2405
  }
2336
- if (inFlight) {
2406
+ if (inFlight.length > 0) {
2337
2407
  await Promise.race([
2338
- inFlight,
2408
+ Promise.all(inFlight.map((r) => r.promise)),
2339
2409
  new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS).unref())
2340
2410
  ]);
2341
2411
  }
@@ -2344,7 +2414,12 @@ async function startDaemon() {
2344
2414
  for (const shipId of serving) {
2345
2415
  await setDoc2(
2346
2416
  doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
2347
- { status: "offline", lastSeenAt: now, currentJob: deleteField() },
2417
+ {
2418
+ status: "offline",
2419
+ lastSeenAt: now,
2420
+ currentJob: deleteField(),
2421
+ currentJobs: deleteField()
2422
+ },
2348
2423
  { merge: true }
2349
2424
  );
2350
2425
  }
@@ -2354,7 +2429,10 @@ async function startDaemon() {
2354
2429
  };
2355
2430
  process.on("SIGINT", () => void shutdown("SIGINT"));
2356
2431
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
2357
- log2(`Runner online \u2014 watching ${serving.size} Ship(s), MCP: ${mcpUrl(config2)}`);
2432
+ const caps = [...serving].map((s) => `${s}:${shipCap(config2, s)}`).join(" ");
2433
+ log2(
2434
+ `Runner online \u2014 watching ${serving.size} Ship(s), up to ${machineCap(config2)} job(s) at once (${caps}), MCP: ${mcpUrl(config2)}`
2435
+ );
2358
2436
  await new Promise(() => {
2359
2437
  });
2360
2438
  }
@@ -2491,6 +2569,9 @@ var TOGGLES = {
2491
2569
  notifications: "Desktop notifications when a job starts, finishes or fails",
2492
2570
  keepAwake: "Keep this machine awake while a job is running"
2493
2571
  };
2572
+ var PARALLEL = "parallel";
2573
+ var PARALLEL_HELP = "How many jobs to run at once (1 = one at a time, as before)";
2574
+ var DEFAULT_WORDS = ["default", "auto", "reset"];
2494
2575
  function isToggle(key) {
2495
2576
  return Object.hasOwn(TOGGLES, key);
2496
2577
  }
@@ -2499,13 +2580,29 @@ function parseBool(value) {
2499
2580
  if (["off", "false", "no", "0"].includes(value.toLowerCase())) return false;
2500
2581
  throw new CliError(`Expected on/off, got "${value}".`);
2501
2582
  }
2583
+ function parseParallel(value) {
2584
+ if (DEFAULT_WORDS.includes(value.toLowerCase())) return null;
2585
+ const n = Number(value);
2586
+ if (!Number.isInteger(n) || n < 1 || n > PARALLEL_MAX) {
2587
+ throw new CliError(
2588
+ `Expected a whole number from 1 to ${PARALLEL_MAX} (or "default"), got "${value}".`
2589
+ );
2590
+ }
2591
+ return n;
2592
+ }
2502
2593
  async function runConfigList() {
2503
2594
  const config2 = requireConfig();
2504
2595
  const values = Object.fromEntries(
2505
2596
  Object.keys(TOGGLES).map((key) => [key, config2[key] !== false])
2506
2597
  );
2598
+ const machine = machineCap(config2);
2599
+ const ships = config2.ships.map((shipId) => ({
2600
+ shipId,
2601
+ parallel: shipCap(config2, shipId),
2602
+ explicit: config2.shipParallelJobs?.[shipId] !== void 0
2603
+ }));
2507
2604
  if (isJson()) {
2508
- emitJson({ configDir: configDir(), ...values });
2605
+ emitJson({ configDir: configDir(), ...values, parallel: machine, ships });
2509
2606
  return 0;
2510
2607
  }
2511
2608
  say.line(` ${configDir()}`);
@@ -2513,13 +2610,27 @@ async function runConfigList() {
2513
2610
  for (const key of Object.keys(TOGGLES)) {
2514
2611
  say.line(` ${key.padEnd(16)} ${values[key] ? "on" : "off"} ${TOGGLES[key]}`);
2515
2612
  }
2613
+ say.line(` ${PARALLEL.padEnd(16)} ${String(machine).padEnd(2)} ${PARALLEL_HELP}`);
2614
+ if (ships.length > 0) {
2615
+ say.line("");
2616
+ for (const ship2 of ships) {
2617
+ const note2 = ship2.explicit ? "" : " (machine default)";
2618
+ say.line(` ${`${PARALLEL} --ship`.padEnd(16)} ${ship2.shipId} = ${ship2.parallel}${note2}`);
2619
+ }
2620
+ }
2516
2621
  return 0;
2517
2622
  }
2518
- async function runConfigSet(key, value) {
2623
+ async function runConfigSet(key, value, options = {}) {
2624
+ const config2 = requireConfig();
2625
+ if (key === PARALLEL) return setParallel(config2, value, options.ship);
2519
2626
  if (!isToggle(key)) {
2520
- throw new CliError(`Unknown setting "${key}". Known: ${Object.keys(TOGGLES).join(", ")}.`);
2627
+ throw new CliError(
2628
+ `Unknown setting "${key}". Known: ${[...Object.keys(TOGGLES), PARALLEL].join(", ")}.`
2629
+ );
2630
+ }
2631
+ if (options.ship) {
2632
+ throw new CliError(`"${key}" is a machine-wide setting \u2014 it takes no --ship.`);
2521
2633
  }
2522
- const config2 = requireConfig();
2523
2634
  config2[key] = parseBool(value);
2524
2635
  saveConfig(config2);
2525
2636
  if (isJson()) {
@@ -2529,6 +2640,41 @@ async function runConfigSet(key, value) {
2529
2640
  say.success(`${key} = ${config2[key] ? "on" : "off"}`);
2530
2641
  return 0;
2531
2642
  }
2643
+ function setParallel(config2, value, ship2) {
2644
+ const parsed = parseParallel(value);
2645
+ if (ship2) {
2646
+ if (!config2.ships.includes(ship2)) {
2647
+ throw new CliError(
2648
+ `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.`
2649
+ );
2650
+ }
2651
+ const overrides = { ...config2.shipParallelJobs ?? {} };
2652
+ if (parsed === null) delete overrides[ship2];
2653
+ else overrides[ship2] = parsed;
2654
+ config2.shipParallelJobs = overrides;
2655
+ } else if (parsed === null) {
2656
+ delete config2.maxParallelJobs;
2657
+ } else {
2658
+ config2.maxParallelJobs = parsed;
2659
+ }
2660
+ saveConfig(config2);
2661
+ const effective = ship2 ? shipCap(config2, ship2) : machineCap(config2);
2662
+ if (isJson()) {
2663
+ emitJson({ parallel: effective, ...ship2 ? { shipId: ship2 } : {} });
2664
+ return 0;
2665
+ }
2666
+ const machine = machineCap(config2);
2667
+ say.success(
2668
+ ship2 ? `${PARALLEL} for Ship ${ship2} = ${effective}${parsed === null ? " (machine default)" : ""}` : `${PARALLEL} = ${effective}`
2669
+ );
2670
+ if (ship2 && parsed !== null && parsed > machine) {
2671
+ say.line(
2672
+ ` Capped at the machine-wide limit of ${machine}. Raise it with \`lumi-runner config set ${PARALLEL} ${parsed}\`.`
2673
+ );
2674
+ }
2675
+ say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
2676
+ return 0;
2677
+ }
2532
2678
 
2533
2679
  // src/cli/commands/doctor.ts
2534
2680
  import { spawnSync as spawnSync2 } from "node:child_process";
@@ -3685,6 +3831,12 @@ service.command("status").description("Is the background service installed and r
3685
3831
  program.command("uninstall").description("Remove the background service and print the final npm step").option("--purge", "also delete the config directory (loses this machine's runner id)").action(action(async (options) => runUninstall(options)));
3686
3832
  var config = program.command("config").description("Local per-machine preferences");
3687
3833
  config.command("list").description("Show current settings").action(action(runConfigList));
3688
- config.command("set <key> <value>").description("Change a setting (notifications | keepAwake \u2192 on/off)").action(action(async (key, value) => runConfigSet(key, value)));
3834
+ config.command("set <key> <value>").description(
3835
+ 'Change a setting (notifications | keepAwake \u2192 on/off, parallel \u2192 1-8 or "default")'
3836
+ ).option("--ship <shipId>", "apply to one Ship only (parallel)").action(
3837
+ action(
3838
+ async (key, value, options) => runConfigSet(key, value, options)
3839
+ )
3840
+ );
3689
3841
  await program.parseAsync(process.argv);
3690
3842
  //# sourceMappingURL=cli.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi.ai/runner",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "Lumi Crew runner daemon — claims jobs from your Ships and executes them as headless Claude sessions on your own machine.",
6
6
  "//name": "The ONLY package in this monorepo published to the public registry, so it is the one that does not follow the internal @lumi/crew-* convention: `@lumi` is not a scope we own, `@lumi.ai` is (the npm org). The workspace DIRECTORY stays packages/crew/runner — renaming the package is not renaming the folder.",