@lumi.ai/runner 0.3.5 → 0.4.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 +16 -3
  2. package/dist/cli.js +126 -65
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -37,10 +37,23 @@ runner id and Ship list from the approval — nothing is copied or pasted. If yo
37
37
 
38
38
  `setup` then runs `doctor` and offers to install the background service.
39
39
 
40
- For CI or headless provisioning there is a non-interactive path:
40
+ ### A server or VPS
41
+
42
+ `setup` is interactive and refuses without a terminal, but it does not need a browser **on that
43
+ machine**:
44
+
45
+ ```bash
46
+ lumi-runner setup --no-browser
47
+ ```
48
+
49
+ It prints the approval link and an 8-character code. Open the link anywhere you do have a browser,
50
+ approve this Ship, and the server finishes on its own — nothing is pasted back.
51
+
52
+ For CI or unattended provisioning, where nobody is at a terminal at all, paste a Ship key from the
53
+ Daemons page instead:
41
54
 
42
55
  ```bash
43
- lumi-runner login --token <customToken> --api-key <key> --project <projectId>
56
+ lumi-runner login --key <shipKey> --api-key <firebaseWebApiKey> --project <projectId>
44
57
  ```
45
58
 
46
59
  ## Commands
@@ -48,7 +61,7 @@ lumi-runner login --token <customToken> --api-key <key> --project <projectId>
48
61
  | Command | What it does |
49
62
  |---|---|
50
63
  | `setup` | One-time onboarding: login → pick Ships → doctor → background service |
51
- | `login` | Connect this machine (browser approval, or `--token` for CI) |
64
+ | `login` | Connect this machine (browser approval, `--no-browser` for a server, or `--key` for CI) |
52
65
  | `ship add [ids…]` / `remove` / `list` | Which Ships this machine serves (omit ids to pick from a list) |
53
66
  | `doctor` | **Preflight** — can this machine actually run a job? Non-zero exit if not |
54
67
  | `service install` / `uninstall` / `restart` / `status` | Run the daemon in the background, always |
package/dist/cli.js CHANGED
@@ -432,6 +432,10 @@ var SECRET_DOCS = {
432
432
  */
433
433
  runnerKey: "runnerKey"
434
434
  };
435
+ function missingSecretsFor(engineId, secrets) {
436
+ const bag = secrets;
437
+ return getEngine(engineId).requiredSecrets.filter((req) => !bag?.[req.key]).map((req) => req.label);
438
+ }
435
439
 
436
440
  // ../shared/dist/ship.js
437
441
  var DEFAULT_SHIP_SETTINGS = {
@@ -528,12 +532,17 @@ function saveConfig(config2) {
528
532
  fs.writeFileSync(configPath(), `${JSON.stringify(config2, null, 2)}
529
533
  `, { mode: 384 });
530
534
  }
535
+ function forgetShip(config2, shipId) {
536
+ const shipKeys = { ...config2.shipKeys ?? {} };
537
+ delete shipKeys[shipId];
538
+ return { ...config2, shipKeys, ships: config2.ships.filter((id) => id !== shipId) };
539
+ }
531
540
  function mcpUrl(config2) {
532
541
  return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`;
533
542
  }
534
543
 
535
544
  // src/version.ts
536
- var RUNNER_VERSION = true ? "0.3.5" : "0.0.0-dev";
545
+ var RUNNER_VERSION = true ? "0.4.0" : "0.0.0-dev";
537
546
 
538
547
  // src/auth.ts
539
548
  import { signInWithCustomToken } from "firebase/auth";
@@ -605,13 +614,11 @@ async function probeShipKey(config2, shipId) {
605
614
  }
606
615
  function pruneDeadShips(config2, failures) {
607
616
  const dropped = failures.filter((f) => f.gone).map((f) => f.shipId);
608
- if (dropped.length === 0) return { config: config2, dropped };
609
- const shipKeys = { ...config2.shipKeys ?? {} };
610
- for (const shipId of dropped) delete shipKeys[shipId];
611
- return {
612
- config: { ...config2, shipKeys, ships: config2.ships.filter((id) => !dropped.includes(id)) },
613
- dropped
614
- };
617
+ return { config: dropped.reduce(forgetShip, config2), dropped };
618
+ }
619
+ function isEnrolmentGone(error) {
620
+ const code = error?.code;
621
+ return code === "permission-denied" || code === "firestore/permission-denied";
615
622
  }
616
623
  async function signInToShip(fb, config2, shipId) {
617
624
  const key = config2.shipKeys?.[shipId];
@@ -1594,9 +1601,6 @@ async function loadRunnerSecrets(db, shipId) {
1594
1601
  );
1595
1602
  return snap.exists() ? snap.data() : null;
1596
1603
  }
1597
- function missingSecretsFor(engineId, secrets) {
1598
- return getEngine(engineId).requiredSecrets.filter((req) => !secrets?.[req.key]).map((req) => req.label);
1599
- }
1600
1604
 
1601
1605
  // src/jobs/engineLimits.ts
1602
1606
  import {
@@ -1820,9 +1824,9 @@ async function startDaemon() {
1820
1824
  if (!s) throw new Error(`No session for Ship ${shipId}`);
1821
1825
  return s;
1822
1826
  };
1823
- const liveShips = sessions.map((s) => s.shipId);
1827
+ const serving = new Set(sessions.map((s) => s.shipId));
1824
1828
  console.log(
1825
- `Runner ${config2.runnerId} serving ${liveShips.length} Ship(s): ${liveShips.join(", ")}`
1829
+ `Runner ${config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}`
1826
1830
  );
1827
1831
  const logLines = [];
1828
1832
  const log2 = (line) => {
@@ -1835,7 +1839,7 @@ async function startDaemon() {
1835
1839
  if (moved?.migrated) log2(`Moved runner config from ${moved.from} to ${moved.to}.`);
1836
1840
  const startedAt = Date.now();
1837
1841
  let currentJob = null;
1838
- const secretsOk = /* @__PURE__ */ new Map();
1842
+ let currentJobShip = null;
1839
1843
  const approved = /* @__PURE__ */ new Map();
1840
1844
  const warnedUnapproved = /* @__PURE__ */ new Set();
1841
1845
  const shipRunnerRef = (shipId) => doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
@@ -1846,44 +1850,53 @@ async function startDaemon() {
1846
1850
  beating = true;
1847
1851
  const now = Date.now();
1848
1852
  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
- keyPresent: secretsOk.get(shipId) ?? false,
1859
- currentJob: currentJob ?? null,
1860
- lastLogLines: [...logLines]
1861
- },
1862
- { merge: true }
1863
- );
1864
- const snap = await getDoc5(shipRunnerRef(shipId));
1865
- const isApproved = snap.data()?.approved === true;
1866
- const wasApproved = approved.get(shipId) === true;
1867
- approved.set(shipId, isApproved);
1868
- if (!isApproved && !warnedUnapproved.has(shipId)) {
1869
- warnedUnapproved.add(shipId);
1870
- log2(
1871
- `Ship ${shipId}: this machine's approval was withdrawn \u2014 idle here until a captain approves it again on the Daemons page.`
1853
+ for (const shipId of [...serving]) {
1854
+ try {
1855
+ const snap = await getDoc5(shipRunnerRef(shipId));
1856
+ if (!snap.exists()) {
1857
+ forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
1858
+ continue;
1859
+ }
1860
+ const isApproved = snap.data()?.approved === true;
1861
+ const wasApproved = approved.get(shipId) === true;
1862
+ approved.set(shipId, isApproved);
1863
+ if (!isApproved && !warnedUnapproved.has(shipId)) {
1864
+ warnedUnapproved.add(shipId);
1865
+ log2(
1866
+ `Ship ${shipId}: this machine's approval was withdrawn \u2014 idle here until a captain approves it again on the Daemons page.`
1867
+ );
1868
+ }
1869
+ if (isApproved && !wasApproved) {
1870
+ warnedUnapproved.delete(shipId);
1871
+ needsRefill.add(shipId);
1872
+ }
1873
+ await setDoc2(
1874
+ shipRunnerRef(shipId),
1875
+ {
1876
+ hostname: os3.hostname(),
1877
+ version: RUNNER_VERSION,
1878
+ status: "online",
1879
+ lastSeenAt: now,
1880
+ startedAt,
1881
+ currentJob: currentJob ?? null,
1882
+ lastLogLines: [...logLines]
1883
+ },
1884
+ { merge: true }
1872
1885
  );
1873
- }
1874
- if (isApproved && !wasApproved) {
1875
- warnedUnapproved.delete(shipId);
1876
- needsRefill.add(shipId);
1886
+ } catch (e) {
1887
+ if (isEnrolmentGone(e)) {
1888
+ forgetShipLocally(shipId, "this machine is no longer enrolled on that Ship");
1889
+ continue;
1890
+ }
1891
+ console.error(`heartbeat failed (${shipId}):`, e instanceof Error ? e.message : e);
1877
1892
  }
1878
1893
  }
1879
- } catch (e) {
1880
- console.error("heartbeat failed:", e instanceof Error ? e.message : e);
1881
1894
  } finally {
1882
1895
  beating = false;
1883
1896
  }
1884
1897
  for (const shipId of [...needsRefill]) {
1885
1898
  needsRefill.delete(shipId);
1886
- if (approved.get(shipId) !== true) continue;
1899
+ if (!serving.has(shipId) || approved.get(shipId) !== true) continue;
1887
1900
  try {
1888
1901
  const snap = await getDocs3(
1889
1902
  query3(
@@ -1908,14 +1921,21 @@ async function startDaemon() {
1908
1921
  const pending = /* @__PURE__ */ new Map();
1909
1922
  const engineLimits = /* @__PURE__ */ new Map();
1910
1923
  const agentEngines = /* @__PURE__ */ new Map();
1911
- const unsubs = [];
1912
- for (const shipId of liveShips) {
1924
+ const unsubsByShip = /* @__PURE__ */ new Map();
1925
+ const listenerError = (shipId, what) => (e) => {
1926
+ if (isEnrolmentGone(e)) {
1927
+ forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
1928
+ return;
1929
+ }
1930
+ console.error(`${what} listener error (${shipId}):`, e.message);
1931
+ };
1932
+ for (const shipId of serving) {
1913
1933
  const q = query3(
1914
1934
  collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
1915
1935
  where3("status", "==", "queued"),
1916
1936
  orderBy3("createdAt", "asc")
1917
1937
  );
1918
- unsubs.push(
1938
+ unsubsByShip.set(shipId, [
1919
1939
  onSnapshot2(
1920
1940
  q,
1921
1941
  (snap) => {
@@ -1930,10 +1950,9 @@ async function startDaemon() {
1930
1950
  }
1931
1951
  poke();
1932
1952
  },
1933
- (e) => console.error(`jobs listener error (${shipId}):`, e.message)
1934
- )
1935
- );
1936
- unsubs.push(
1953
+ listenerError(shipId, "jobs")
1954
+ ),
1955
+ // Agents, purely so the claim gate can resolve a queued job's engine without a read.
1937
1956
  onSnapshot2(
1938
1957
  collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
1939
1958
  (snap) => {
@@ -1942,10 +1961,11 @@ async function startDaemon() {
1942
1961
  }
1943
1962
  poke();
1944
1963
  },
1945
- (e) => console.error(`agents listener error (${shipId}):`, e.message)
1946
- )
1947
- );
1948
- unsubs.push(
1964
+ listenerError(shipId, "agents")
1965
+ ),
1966
+ // Exhausted usage windows. The FIRST snapshot is the startup seed: a restarted daemon —
1967
+ // and a second daemon on this Ship — inherits the pause instead of running a doomed
1968
+ // session to rediscover it.
1949
1969
  subscribeEngineLimits(
1950
1970
  sess(shipId).fb.db,
1951
1971
  shipId,
@@ -1964,9 +1984,46 @@ async function startDaemon() {
1964
1984
  }
1965
1985
  sweepLimits();
1966
1986
  },
1967
- (e) => console.error(`engine limits listener error (${shipId}):`, e.message)
1987
+ listenerError(shipId, "engine limits")
1968
1988
  )
1989
+ ]);
1990
+ }
1991
+ function forgetShipLocally(shipId, why) {
1992
+ if (!serving.delete(shipId)) return;
1993
+ for (const unsub of unsubsByShip.get(shipId) ?? []) unsub();
1994
+ unsubsByShip.delete(shipId);
1995
+ for (const key of [...pending.keys()]) {
1996
+ if (pending.get(key)?.shipId === shipId) pending.delete(key);
1997
+ }
1998
+ for (const key of [...engineLimits.keys()]) {
1999
+ if (engineLimits.get(key)?.shipId === shipId) engineLimits.delete(key);
2000
+ }
2001
+ for (const key of [...agentEngines.keys()]) {
2002
+ if (key.startsWith(`${shipId}/`)) agentEngines.delete(key);
2003
+ }
2004
+ approved.delete(shipId);
2005
+ warnedUnapproved.delete(shipId);
2006
+ needsRefill.delete(shipId);
2007
+ config2 = forgetShip(config2, shipId);
2008
+ try {
2009
+ saveConfig(config2);
2010
+ } catch (e) {
2011
+ log2(`Could not rewrite the config after leaving Ship ${shipId}: ${e instanceof Error ? e.message : e}`);
2012
+ }
2013
+ log2(
2014
+ `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.`
1969
2015
  );
2016
+ if (currentJobShip === shipId && sessionAbort) {
2017
+ 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.`
2019
+ );
2020
+ sessionAbort.abort();
2021
+ }
2022
+ if (serving.size === 0) {
2023
+ log2(
2024
+ "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."
2025
+ );
2026
+ }
1970
2027
  }
1971
2028
  const engineForJob = (shipId, job) => agentEngines.get(`${shipId}/${job.agentId}`) ?? DEFAULT_ENGINE_ID;
1972
2029
  let working = false;
@@ -2057,9 +2114,7 @@ async function startDaemon() {
2057
2114
  }
2058
2115
  }
2059
2116
  async function loadSecrets(shipId) {
2060
- const secrets = await loadRunnerSecrets(sess(shipId).fb.db, shipId);
2061
- secretsOk.set(shipId, missingSecretsFor(DEFAULT_ENGINE_ID, secrets).length === 0);
2062
- return secrets;
2117
+ return loadRunnerSecrets(sess(shipId).fb.db, shipId);
2063
2118
  }
2064
2119
  async function processJob(shipId, queuedJob) {
2065
2120
  const job = await claim(shipId, queuedJob);
@@ -2095,6 +2150,7 @@ async function startDaemon() {
2095
2150
  ...job.workflowName ? { workflowName: job.workflowName } : {},
2096
2151
  startedAt: Date.now()
2097
2152
  };
2153
+ currentJobShip = shipId;
2098
2154
  await setAgentStatus(shipId, job.agentId, "working");
2099
2155
  void heartbeat();
2100
2156
  const wake = config2.keepAwake === false ? null : inhibitSleep(`Crew job ${job.id}`);
@@ -2197,7 +2253,11 @@ async function startDaemon() {
2197
2253
  wake?.release();
2198
2254
  sessionAbort = null;
2199
2255
  try {
2200
- if (shuttingDown) {
2256
+ if (!serving.has(shipId)) {
2257
+ log2(
2258
+ `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.`
2259
+ );
2260
+ } else if (shuttingDown) {
2201
2261
  await releaseJob(
2202
2262
  sess(shipId).fb.db,
2203
2263
  shipId,
@@ -2248,10 +2308,11 @@ async function startDaemon() {
2248
2308
  log2(`finalize failed for ${job.id}: ${e instanceof Error ? e.message : e}`);
2249
2309
  }
2250
2310
  currentJob = null;
2251
- await setAgentStatus(shipId, job.agentId, "idle");
2311
+ currentJobShip = null;
2312
+ if (serving.has(shipId)) await setAgentStatus(shipId, job.agentId, "idle");
2252
2313
  if (!shuttingDown) void heartbeat();
2253
2314
  }
2254
- await Promise.all(liveShips.map((shipId) => loadSecrets(shipId).catch(() => null)));
2315
+ await Promise.all([...serving].map((shipId) => loadSecrets(shipId).catch(() => null)));
2255
2316
  await heartbeat();
2256
2317
  const heartbeatTimer = setInterval(() => {
2257
2318
  void heartbeat();
@@ -2264,7 +2325,7 @@ async function startDaemon() {
2264
2325
  shuttingDown = true;
2265
2326
  clearInterval(heartbeatTimer);
2266
2327
  if (limitTimer) clearTimeout(limitTimer);
2267
- unsubs.forEach((u) => u());
2328
+ for (const shipUnsubs of unsubsByShip.values()) shipUnsubs.forEach((u) => u());
2268
2329
  if (sessionAbort) {
2269
2330
  log2(`${signal} received with a job in flight \u2014 stopping the session and releasing it.`);
2270
2331
  notify("Crew runner stopping", "A job was in progress; it has been returned to the queue.");
@@ -2280,7 +2341,7 @@ async function startDaemon() {
2280
2341
  }
2281
2342
  const now = Date.now();
2282
2343
  try {
2283
- for (const shipId of liveShips) {
2344
+ for (const shipId of serving) {
2284
2345
  await setDoc2(
2285
2346
  doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
2286
2347
  { status: "offline", lastSeenAt: now, currentJob: deleteField() },
@@ -2293,7 +2354,7 @@ async function startDaemon() {
2293
2354
  };
2294
2355
  process.on("SIGINT", () => void shutdown("SIGINT"));
2295
2356
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
2296
- log2(`Runner online \u2014 watching ${liveShips.length} Ship(s), MCP: ${mcpUrl(config2)}`);
2357
+ log2(`Runner online \u2014 watching ${serving.size} Ship(s), MCP: ${mcpUrl(config2)}`);
2297
2358
  await new Promise(() => {
2298
2359
  });
2299
2360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi.ai/runner",
3
- "version": "0.3.5",
3
+ "version": "0.4.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.",