@lumi.ai/runner 0.3.6 → 0.5.0

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 +313 -97
  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
@@ -532,12 +532,27 @@ function saveConfig(config2) {
532
532
  fs.writeFileSync(configPath(), `${JSON.stringify(config2, null, 2)}
533
533
  `, { mode: 384 });
534
534
  }
535
+ function forgetShip(config2, shipId) {
536
+ const shipKeys = { ...config2.shipKeys ?? {} };
537
+ delete shipKeys[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;
549
+ }
535
550
  function mcpUrl(config2) {
536
551
  return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`;
537
552
  }
538
553
 
539
554
  // src/version.ts
540
- var RUNNER_VERSION = true ? "0.3.6" : "0.0.0-dev";
555
+ var RUNNER_VERSION = true ? "0.5.0" : "0.0.0-dev";
541
556
 
542
557
  // src/auth.ts
543
558
  import { signInWithCustomToken } from "firebase/auth";
@@ -609,13 +624,11 @@ async function probeShipKey(config2, shipId) {
609
624
  }
610
625
  function pruneDeadShips(config2, failures) {
611
626
  const dropped = failures.filter((f) => f.gone).map((f) => f.shipId);
612
- if (dropped.length === 0) return { config: config2, dropped };
613
- const shipKeys = { ...config2.shipKeys ?? {} };
614
- for (const shipId of dropped) delete shipKeys[shipId];
615
- return {
616
- config: { ...config2, shipKeys, ships: config2.ships.filter((id) => !dropped.includes(id)) },
617
- dropped
618
- };
627
+ return { config: dropped.reduce(forgetShip, config2), dropped };
628
+ }
629
+ function isEnrolmentGone(error) {
630
+ const code = error?.code;
631
+ return code === "permission-denied" || code === "firestore/permission-denied";
619
632
  }
620
633
  async function signInToShip(fb, config2, shipId) {
621
634
  const key = config2.shipKeys?.[shipId];
@@ -1655,6 +1668,35 @@ function subscribeEngineLimits(db, shipId, cb, onError) {
1655
1668
  );
1656
1669
  }
1657
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
+
1658
1700
  // src/jobs/finish.ts
1659
1701
  import {
1660
1702
  addDoc,
@@ -1821,9 +1863,9 @@ async function startDaemon() {
1821
1863
  if (!s) throw new Error(`No session for Ship ${shipId}`);
1822
1864
  return s;
1823
1865
  };
1824
- const liveShips = sessions.map((s) => s.shipId);
1866
+ const serving = new Set(sessions.map((s) => s.shipId));
1825
1867
  console.log(
1826
- `Runner ${config2.runnerId} serving ${liveShips.length} Ship(s): ${liveShips.join(", ")}`
1868
+ `Runner ${config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}`
1827
1869
  );
1828
1870
  const logLines = [];
1829
1871
  const log2 = (line) => {
@@ -1835,7 +1877,13 @@ async function startDaemon() {
1835
1877
  };
1836
1878
  if (moved?.migrated) log2(`Moved runner config from ${moved.from} to ${moved.to}.`);
1837
1879
  const startedAt = Date.now();
1838
- let currentJob = 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);
1839
1887
  const approved = /* @__PURE__ */ new Map();
1840
1888
  const warnedUnapproved = /* @__PURE__ */ new Set();
1841
1889
  const shipRunnerRef = (shipId) => doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
@@ -1846,43 +1894,56 @@ async function startDaemon() {
1846
1894
  beating = true;
1847
1895
  const now = Date.now();
1848
1896
  try {
1849
- for (const shipId of liveShips) {
1850
- await setDoc2(
1851
- shipRunnerRef(shipId),
1852
- {
1853
- hostname: os3.hostname(),
1854
- version: RUNNER_VERSION,
1855
- status: "online",
1856
- lastSeenAt: now,
1857
- startedAt,
1858
- currentJob: currentJob ?? null,
1859
- lastLogLines: [...logLines]
1860
- },
1861
- { merge: true }
1862
- );
1863
- const snap = await getDoc5(shipRunnerRef(shipId));
1864
- const isApproved = snap.data()?.approved === true;
1865
- const wasApproved = approved.get(shipId) === true;
1866
- approved.set(shipId, isApproved);
1867
- if (!isApproved && !warnedUnapproved.has(shipId)) {
1868
- warnedUnapproved.add(shipId);
1869
- log2(
1870
- `Ship ${shipId}: this machine's approval was withdrawn \u2014 idle here until a captain approves it again on the Daemons page.`
1897
+ for (const shipId of [...serving]) {
1898
+ try {
1899
+ const snap = await getDoc5(shipRunnerRef(shipId));
1900
+ if (!snap.exists()) {
1901
+ forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
1902
+ continue;
1903
+ }
1904
+ const isApproved = snap.data()?.approved === true;
1905
+ const wasApproved = approved.get(shipId) === true;
1906
+ approved.set(shipId, isApproved);
1907
+ if (!isApproved && !warnedUnapproved.has(shipId)) {
1908
+ warnedUnapproved.add(shipId);
1909
+ log2(
1910
+ `Ship ${shipId}: this machine's approval was withdrawn \u2014 idle here until a captain approves it again on the Daemons page.`
1911
+ );
1912
+ }
1913
+ if (isApproved && !wasApproved) {
1914
+ warnedUnapproved.delete(shipId);
1915
+ needsRefill.add(shipId);
1916
+ }
1917
+ const live = liveJobsOn(shipId);
1918
+ await setDoc2(
1919
+ shipRunnerRef(shipId),
1920
+ {
1921
+ hostname: os3.hostname(),
1922
+ version: RUNNER_VERSION,
1923
+ status: "online",
1924
+ lastSeenAt: now,
1925
+ startedAt,
1926
+ currentJob: live[0] ?? null,
1927
+ currentJobs: live,
1928
+ parallelLimit: shipCap(config2, shipId),
1929
+ lastLogLines: [...logLines]
1930
+ },
1931
+ { merge: true }
1871
1932
  );
1872
- }
1873
- if (isApproved && !wasApproved) {
1874
- warnedUnapproved.delete(shipId);
1875
- needsRefill.add(shipId);
1933
+ } catch (e) {
1934
+ if (isEnrolmentGone(e)) {
1935
+ forgetShipLocally(shipId, "this machine is no longer enrolled on that Ship");
1936
+ continue;
1937
+ }
1938
+ console.error(`heartbeat failed (${shipId}):`, e instanceof Error ? e.message : e);
1876
1939
  }
1877
1940
  }
1878
- } catch (e) {
1879
- console.error("heartbeat failed:", e instanceof Error ? e.message : e);
1880
1941
  } finally {
1881
1942
  beating = false;
1882
1943
  }
1883
1944
  for (const shipId of [...needsRefill]) {
1884
1945
  needsRefill.delete(shipId);
1885
- if (approved.get(shipId) !== true) continue;
1946
+ if (!serving.has(shipId) || approved.get(shipId) !== true) continue;
1886
1947
  try {
1887
1948
  const snap = await getDocs3(
1888
1949
  query3(
@@ -1907,14 +1968,21 @@ async function startDaemon() {
1907
1968
  const pending = /* @__PURE__ */ new Map();
1908
1969
  const engineLimits = /* @__PURE__ */ new Map();
1909
1970
  const agentEngines = /* @__PURE__ */ new Map();
1910
- const unsubs = [];
1911
- for (const shipId of liveShips) {
1971
+ const unsubsByShip = /* @__PURE__ */ new Map();
1972
+ const listenerError = (shipId, what) => (e) => {
1973
+ if (isEnrolmentGone(e)) {
1974
+ forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
1975
+ return;
1976
+ }
1977
+ console.error(`${what} listener error (${shipId}):`, e.message);
1978
+ };
1979
+ for (const shipId of serving) {
1912
1980
  const q = query3(
1913
1981
  collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
1914
1982
  where3("status", "==", "queued"),
1915
1983
  orderBy3("createdAt", "asc")
1916
1984
  );
1917
- unsubs.push(
1985
+ unsubsByShip.set(shipId, [
1918
1986
  onSnapshot2(
1919
1987
  q,
1920
1988
  (snap) => {
@@ -1929,10 +1997,9 @@ async function startDaemon() {
1929
1997
  }
1930
1998
  poke();
1931
1999
  },
1932
- (e) => console.error(`jobs listener error (${shipId}):`, e.message)
1933
- )
1934
- );
1935
- unsubs.push(
2000
+ listenerError(shipId, "jobs")
2001
+ ),
2002
+ // Agents, purely so the claim gate can resolve a queued job's engine without a read.
1936
2003
  onSnapshot2(
1937
2004
  collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
1938
2005
  (snap) => {
@@ -1941,10 +2008,11 @@ async function startDaemon() {
1941
2008
  }
1942
2009
  poke();
1943
2010
  },
1944
- (e) => console.error(`agents listener error (${shipId}):`, e.message)
1945
- )
1946
- );
1947
- unsubs.push(
2011
+ listenerError(shipId, "agents")
2012
+ ),
2013
+ // Exhausted usage windows. The FIRST snapshot is the startup seed: a restarted daemon —
2014
+ // and a second daemon on this Ship — inherits the pause instead of running a doomed
2015
+ // session to rediscover it.
1948
2016
  subscribeEngineLimits(
1949
2017
  sess(shipId).fb.db,
1950
2018
  shipId,
@@ -1963,22 +2031,58 @@ async function startDaemon() {
1963
2031
  }
1964
2032
  sweepLimits();
1965
2033
  },
1966
- (e) => console.error(`engine limits listener error (${shipId}):`, e.message)
2034
+ listenerError(shipId, "engine limits")
1967
2035
  )
2036
+ ]);
2037
+ }
2038
+ function forgetShipLocally(shipId, why) {
2039
+ if (!serving.delete(shipId)) return;
2040
+ for (const unsub of unsubsByShip.get(shipId) ?? []) unsub();
2041
+ unsubsByShip.delete(shipId);
2042
+ for (const key of [...pending.keys()]) {
2043
+ if (pending.get(key)?.shipId === shipId) pending.delete(key);
2044
+ }
2045
+ for (const key of [...engineLimits.keys()]) {
2046
+ if (engineLimits.get(key)?.shipId === shipId) engineLimits.delete(key);
2047
+ }
2048
+ for (const key of [...agentEngines.keys()]) {
2049
+ if (key.startsWith(`${shipId}/`)) agentEngines.delete(key);
2050
+ }
2051
+ approved.delete(shipId);
2052
+ warnedUnapproved.delete(shipId);
2053
+ needsRefill.delete(shipId);
2054
+ config2 = forgetShip(config2, shipId);
2055
+ try {
2056
+ saveConfig(config2);
2057
+ } catch (e) {
2058
+ log2(`Could not rewrite the config after leaving Ship ${shipId}: ${e instanceof Error ? e.message : e}`);
2059
+ }
2060
+ log2(
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.`
1968
2062
  );
2063
+ const stranded = [...running.values()].filter((r) => r.shipId === shipId);
2064
+ if (stranded.length > 0) {
2065
+ log2(
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.`
2067
+ );
2068
+ for (const r of stranded) r.abort.abort();
2069
+ }
2070
+ if (serving.size === 0) {
2071
+ log2(
2072
+ "This machine now serves no Ships. Staying up and idle \u2014 run `lumi-runner login` to connect it again, or `lumi-runner uninstall` to retire it."
2073
+ );
2074
+ }
1969
2075
  }
1970
2076
  const engineForJob = (shipId, job) => agentEngines.get(`${shipId}/${job.agentId}`) ?? DEFAULT_ENGINE_ID;
1971
- let working = false;
2077
+ let dispatching = false;
1972
2078
  let pokeRequested = false;
1973
2079
  let shuttingDown = false;
1974
- let sessionAbort = null;
1975
- let inFlight = null;
1976
2080
  function poke() {
1977
- if (working || shuttingDown) {
2081
+ if (dispatching || shuttingDown) {
1978
2082
  pokeRequested = !shuttingDown;
1979
2083
  return;
1980
2084
  }
1981
- void workLoop();
2085
+ dispatch();
1982
2086
  }
1983
2087
  let limitTimer = null;
1984
2088
  function armLimitTimer() {
@@ -2004,30 +2108,51 @@ async function startDaemon() {
2004
2108
  armLimitTimer();
2005
2109
  if (cleared.length > 0) poke();
2006
2110
  }
2007
- async function workLoop() {
2008
- working = true;
2111
+ function dispatch() {
2112
+ dispatching = true;
2009
2113
  try {
2010
2114
  for (; ; ) {
2011
2115
  pokeRequested = false;
2012
2116
  if (shuttingDown) break;
2013
2117
  const now = Date.now();
2014
- 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];
2015
- if (!next) break;
2016
- pending.delete(`${next.shipId}/${next.job.id}`);
2017
- inFlight = processJob(next.shipId, next.job);
2018
- try {
2019
- await inFlight;
2020
- } finally {
2021
- 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);
2022
2134
  }
2023
- if (!pokeRequested && pending.size === 0) break;
2135
+ if (!pokeRequested) break;
2024
2136
  }
2025
2137
  } finally {
2026
- working = false;
2027
- if (pokeRequested) poke();
2138
+ dispatching = false;
2028
2139
  }
2029
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
+ }
2030
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
+ }
2031
2156
  const jobRef = doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id);
2032
2157
  try {
2033
2158
  let claimed = null;
@@ -2058,7 +2183,7 @@ async function startDaemon() {
2058
2183
  async function loadSecrets(shipId) {
2059
2184
  return loadRunnerSecrets(sess(shipId).fb.db, shipId);
2060
2185
  }
2061
- async function processJob(shipId, queuedJob) {
2186
+ async function processJob(shipId, queuedJob, slot) {
2062
2187
  const job = await claim(shipId, queuedJob);
2063
2188
  if (!job) return;
2064
2189
  const target = jobTarget(job);
@@ -2078,12 +2203,13 @@ async function startDaemon() {
2078
2203
  transcriptPath: "",
2079
2204
  error: "Job names neither a task nor a chat, so there is nothing to run."
2080
2205
  });
2081
- currentJob = null;
2082
2206
  return;
2083
2207
  }
2084
2208
  const targetLabel = target.kind === "task" ? `task ${target.taskId}` : `chat ${target.chatId}`;
2085
- log2(`Claimed job ${job.id} (ship ${shipId}, ${targetLabel}, attempt ${job.attempt})`);
2086
- currentJob = {
2209
+ log2(
2210
+ `Claimed job ${job.id} (ship ${shipId}, ${targetLabel}, attempt ${job.attempt}) \u2014 slot ${running.size}/${machineCap(config2)}`
2211
+ );
2212
+ slot.mirror = {
2087
2213
  jobId: job.id,
2088
2214
  // Conditional spread: Firestore rejects an explicit undefined in the mirror write.
2089
2215
  ...target.kind === "task" ? { taskId: target.taskId } : { chatId: target.chatId },
@@ -2095,7 +2221,6 @@ async function startDaemon() {
2095
2221
  await setAgentStatus(shipId, job.agentId, "working");
2096
2222
  void heartbeat();
2097
2223
  const wake = config2.keepAwake === false ? null : inhibitSleep(`Crew job ${job.id}`);
2098
- sessionAbort = new AbortController();
2099
2224
  notify(
2100
2225
  "Crew job started",
2101
2226
  target.kind === "task" ? `Agent is working on task ${target.taskId}.` : "Agent is replying in a chat."
@@ -2155,7 +2280,7 @@ async function startDaemon() {
2155
2280
  }
2156
2281
  throw e;
2157
2282
  }
2158
- 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.");
2159
2284
  const session = await getDriver(engineId).run({
2160
2285
  prompt,
2161
2286
  agent,
@@ -2166,7 +2291,7 @@ async function startDaemon() {
2166
2291
  secrets,
2167
2292
  githubToken,
2168
2293
  timeoutMs: JOB_TIMEOUT_MS,
2169
- signal: sessionAbort.signal,
2294
+ signal: slot.abort.signal,
2170
2295
  log: log2
2171
2296
  });
2172
2297
  transcript = redactTranscript(session.transcript, [
@@ -2192,9 +2317,12 @@ async function startDaemon() {
2192
2317
  failure = e instanceof Error ? e.message : String(e);
2193
2318
  }
2194
2319
  wake?.release();
2195
- sessionAbort = null;
2196
2320
  try {
2197
- if (shuttingDown) {
2321
+ if (!serving.has(shipId)) {
2322
+ log2(
2323
+ `Job ${job.id} stopped \u2014 Ship ${shipId} removed this machine mid-run. The Ship has put the job back on its own queue; nothing more is written from here.`
2324
+ );
2325
+ } else if (shuttingDown) {
2198
2326
  await releaseJob(
2199
2327
  sess(shipId).fb.db,
2200
2328
  shipId,
@@ -2244,11 +2372,11 @@ async function startDaemon() {
2244
2372
  } catch (e) {
2245
2373
  log2(`finalize failed for ${job.id}: ${e instanceof Error ? e.message : e}`);
2246
2374
  }
2247
- currentJob = null;
2248
- await setAgentStatus(shipId, job.agentId, "idle");
2375
+ slot.mirror = void 0;
2376
+ if (serving.has(shipId)) await setAgentStatus(shipId, job.agentId, "idle");
2249
2377
  if (!shuttingDown) void heartbeat();
2250
2378
  }
2251
- await Promise.all(liveShips.map((shipId) => loadSecrets(shipId).catch(() => null)));
2379
+ await Promise.all([...serving].map((shipId) => loadSecrets(shipId).catch(() => null)));
2252
2380
  await heartbeat();
2253
2381
  const heartbeatTimer = setInterval(() => {
2254
2382
  void heartbeat();
@@ -2261,26 +2389,37 @@ async function startDaemon() {
2261
2389
  shuttingDown = true;
2262
2390
  clearInterval(heartbeatTimer);
2263
2391
  if (limitTimer) clearTimeout(limitTimer);
2264
- unsubs.forEach((u) => u());
2265
- if (sessionAbort) {
2266
- log2(`${signal} received with a job in flight \u2014 stopping the session and releasing it.`);
2267
- notify("Crew runner stopping", "A job was in progress; it has been returned to the queue.");
2268
- sessionAbort.abort();
2392
+ for (const shipUnsubs of unsubsByShip.values()) shipUnsubs.forEach((u) => u());
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();
2269
2403
  } else {
2270
2404
  log2(`${signal} received \u2014 shutting down.`);
2271
2405
  }
2272
- if (inFlight) {
2406
+ if (inFlight.length > 0) {
2273
2407
  await Promise.race([
2274
- inFlight,
2408
+ Promise.all(inFlight.map((r) => r.promise)),
2275
2409
  new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS).unref())
2276
2410
  ]);
2277
2411
  }
2278
2412
  const now = Date.now();
2279
2413
  try {
2280
- for (const shipId of liveShips) {
2414
+ for (const shipId of serving) {
2281
2415
  await setDoc2(
2282
2416
  doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
2283
- { status: "offline", lastSeenAt: now, currentJob: deleteField() },
2417
+ {
2418
+ status: "offline",
2419
+ lastSeenAt: now,
2420
+ currentJob: deleteField(),
2421
+ currentJobs: deleteField()
2422
+ },
2284
2423
  { merge: true }
2285
2424
  );
2286
2425
  }
@@ -2290,7 +2429,10 @@ async function startDaemon() {
2290
2429
  };
2291
2430
  process.on("SIGINT", () => void shutdown("SIGINT"));
2292
2431
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
2293
- log2(`Runner online \u2014 watching ${liveShips.length} 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
+ );
2294
2436
  await new Promise(() => {
2295
2437
  });
2296
2438
  }
@@ -2427,6 +2569,9 @@ var TOGGLES = {
2427
2569
  notifications: "Desktop notifications when a job starts, finishes or fails",
2428
2570
  keepAwake: "Keep this machine awake while a job is running"
2429
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"];
2430
2575
  function isToggle(key) {
2431
2576
  return Object.hasOwn(TOGGLES, key);
2432
2577
  }
@@ -2435,13 +2580,29 @@ function parseBool(value) {
2435
2580
  if (["off", "false", "no", "0"].includes(value.toLowerCase())) return false;
2436
2581
  throw new CliError(`Expected on/off, got "${value}".`);
2437
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
+ }
2438
2593
  async function runConfigList() {
2439
2594
  const config2 = requireConfig();
2440
2595
  const values = Object.fromEntries(
2441
2596
  Object.keys(TOGGLES).map((key) => [key, config2[key] !== false])
2442
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
+ }));
2443
2604
  if (isJson()) {
2444
- emitJson({ configDir: configDir(), ...values });
2605
+ emitJson({ configDir: configDir(), ...values, parallel: machine, ships });
2445
2606
  return 0;
2446
2607
  }
2447
2608
  say.line(` ${configDir()}`);
@@ -2449,13 +2610,27 @@ async function runConfigList() {
2449
2610
  for (const key of Object.keys(TOGGLES)) {
2450
2611
  say.line(` ${key.padEnd(16)} ${values[key] ? "on" : "off"} ${TOGGLES[key]}`);
2451
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
+ }
2452
2621
  return 0;
2453
2622
  }
2454
- 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);
2455
2626
  if (!isToggle(key)) {
2456
- 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.`);
2457
2633
  }
2458
- const config2 = requireConfig();
2459
2634
  config2[key] = parseBool(value);
2460
2635
  saveConfig(config2);
2461
2636
  if (isJson()) {
@@ -2465,6 +2640,41 @@ async function runConfigSet(key, value) {
2465
2640
  say.success(`${key} = ${config2[key] ? "on" : "off"}`);
2466
2641
  return 0;
2467
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
+ }
2468
2678
 
2469
2679
  // src/cli/commands/doctor.ts
2470
2680
  import { spawnSync as spawnSync2 } from "node:child_process";
@@ -3621,6 +3831,12 @@ service.command("status").description("Is the background service installed and r
3621
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)));
3622
3832
  var config = program.command("config").description("Local per-machine preferences");
3623
3833
  config.command("list").description("Show current settings").action(action(runConfigList));
3624
- 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
+ );
3625
3841
  await program.parseAsync(process.argv);
3626
3842
  //# sourceMappingURL=cli.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi.ai/runner",
3
- "version": "0.3.6",
3
+ "version": "0.5.0",
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.",