@lumi.ai/runner 0.3.0-dev.6 → 0.3.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 (2) hide show
  1. package/dist/cli.js +249 -214
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -4,12 +4,13 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // src/daemon.ts
7
- import os4 from "node:os";
7
+ import os3 from "node:os";
8
8
  import {
9
9
  collection as collection5,
10
10
  deleteField,
11
11
  doc as doc7,
12
12
  getDoc as getDoc5,
13
+ getDocs as getDocs3,
13
14
  onSnapshot as onSnapshot2,
14
15
  orderBy as orderBy3,
15
16
  query as query3,
@@ -120,6 +121,17 @@ var COLLECTIONS = {
120
121
  ships: "ships",
121
122
  /** `ships/{shipId}/members/{uid}` — the humans of a Ship (captain | member). */
122
123
  members: "members",
124
+ /**
125
+ * `ships/{shipId}/invites/{inviteId}` — humans asked aboard but not yet arrived (PRD §15.24).
126
+ *
127
+ * Its OWN collection rather than a pending row inside `members`, and that is not tidiness:
128
+ * `members` is load-bearing in five places that all assume every document in it is a person who
129
+ * exists. `isMember()`/`isCaptain()` in firestore.rules key on the doc EXISTING, `captainUids()`
130
+ * fans notifications out to `role == 'captain'`, the "my Ships" collection-group query filters on
131
+ * `uid`, and the purge reads the member list to find `users/{uid}/runners`. A pending row would
132
+ * have to lie to all four.
133
+ */
134
+ invites: "invites",
123
135
  /** `ships/{shipId}/tasks/{taskId}` — the board's units of work. */
124
136
  tasks: "tasks",
125
137
  /** `ships/{shipId}/tasks/{taskId}/activity/{eventId}` — a task's feed (comments + status). */
@@ -170,7 +182,15 @@ var COLLECTIONS = {
170
182
  /** Top-level `githubInstallations/{installationId}` — installation→Ship index (backend only). */
171
183
  githubInstallations: "githubInstallations",
172
184
  /** Top-level `runnerLoginCodes/{userCode}` — CLI device-login handshakes (backend only). */
173
- runnerLoginCodes: "runnerLoginCodes"
185
+ runnerLoginCodes: "runnerLoginCodes",
186
+ /**
187
+ * Top-level `shipDeletions/{shipId}` — one tombstone per Ship being purged (backend only).
188
+ *
189
+ * OUTSIDE the Ship subtree on purpose: `recursiveDelete` deletes the root document even when
190
+ * descendant deletes failed, so a marker stored on the Ship would be destroyed by exactly the
191
+ * partial failure it exists to survive.
192
+ */
193
+ shipDeletions: "shipDeletions"
174
194
  };
175
195
 
176
196
  // ../shared/dist/engineLimit.js
@@ -366,7 +386,16 @@ var SECRET_DOCS = {
366
386
  /** Captain-provided runner credentials (this file's shape). */
367
387
  runner: "runner",
368
388
  /** `{ tokenHash }` for the Workspace MCP bearer — backend-only, never client-readable. */
369
- mcp: "mcp"
389
+ mcp: "mcp",
390
+ /**
391
+ * `{ runnerId, keyHash }` for THIS Ship's daemon credential (PRD §8.1) — backend-only.
392
+ *
393
+ * One document, not one per machine, because a Ship runs one daemon: issuing a key is
394
+ * therefore also how the previous one is revoked, exactly like rotating the MCP bearer.
395
+ * Only `docId == 'runner'` is released by packages/crew/firestore.rules, so this falls
396
+ * through to deny for every client without needing a rule of its own.
397
+ */
398
+ runnerKey: "runnerKey"
370
399
  };
371
400
 
372
401
  // ../shared/dist/task.js
@@ -415,6 +444,7 @@ var DEFAULT_SHIP_SETTINGS = {
415
444
  // The creation wizard offers the captain's own browser zone, which is what most Ships get.
416
445
  timezone: "UTC"
417
446
  };
447
+ var SHIP_INVITE_TTL_MS = 14 * 24 * 60 * 60 * 1e3;
418
448
 
419
449
  // ../shared/dist/usage.js
420
450
  var EMPTY_USAGE_TOTALS = {
@@ -502,10 +532,9 @@ function mcpUrl(config2) {
502
532
  }
503
533
 
504
534
  // src/version.ts
505
- var RUNNER_VERSION = true ? "0.3.0-dev.6" : "0.0.0-dev";
535
+ var RUNNER_VERSION = true ? "0.3.0" : "0.0.0-dev";
506
536
 
507
537
  // src/auth.ts
508
- import os2 from "node:os";
509
538
  import { signInWithCustomToken } from "firebase/auth";
510
539
 
511
540
  // src/callables.ts
@@ -549,59 +578,39 @@ async function request(baseUrl, name, data, headers) {
549
578
  }
550
579
 
551
580
  // src/auth.ts
552
- var authEmulator = () => process.env.FIREBASE_AUTH_EMULATOR_HOST;
553
- function securetokenUrl(apiKey) {
554
- const emu = authEmulator();
555
- return emu ? `http://${emu}/securetoken.googleapis.com/v1/token?key=${apiKey}` : `https://securetoken.googleapis.com/v1/token?key=${apiKey}`;
556
- }
557
- async function login(fb, config2, customToken) {
558
- const cred = await signInWithCustomToken(fb.auth, customToken);
559
- config2.refreshToken = cred.user.refreshToken;
560
- saveConfig(config2);
561
- return cred.user;
562
- }
563
581
  var AuthError = class extends Error {
564
582
  };
565
- async function signInWithStoredSession(fb) {
566
- const config2 = requireConfig();
567
- if (!config2.refreshToken) {
568
- throw new AuthError("No stored session. Run `lumi-runner setup`.");
569
- }
570
- const tokenRes = await fetch(securetokenUrl(config2.apiKey), {
571
- method: "POST",
572
- headers: { "content-type": "application/x-www-form-urlencoded" },
573
- body: new URLSearchParams({
574
- grant_type: "refresh_token",
575
- refresh_token: config2.refreshToken
576
- })
577
- });
578
- if (!tokenRes.ok) {
579
- throw new AuthError(`Session refresh failed (${tokenRes.status}). Run \`lumi-runner login\` again.`);
583
+ async function signInToShip(fb, config2, shipId) {
584
+ const key = config2.shipKeys?.[shipId];
585
+ if (!key) {
586
+ throw new AuthError(
587
+ `No runner key for Ship ${shipId}. Run \`lumi-runner login\` and pick it, or ask a captain to approve this machine on that Ship's Daemons page.`
588
+ );
580
589
  }
581
- const { id_token: idToken } = await tokenRes.json();
582
- let mint;
590
+ let session;
583
591
  try {
584
- mint = await callFunction(functionsBaseUrl(config2), idToken, "mintRunnerToken", {
585
- runnerId: config2.runnerId,
586
- hostname: os2.hostname(),
587
- version: RUNNER_VERSION
588
- });
592
+ session = await callPublicFunction(functionsBaseUrl(config2), "exchangeRunnerKey", { key });
589
593
  } catch (e) {
590
- throw new AuthError(`mintRunnerToken failed: ${e instanceof Error ? e.message : e}`);
594
+ throw new AuthError(
595
+ `Ship ${shipId}: ${e instanceof Error ? e.message : String(e)}`
596
+ );
591
597
  }
592
- const cred = await signInWithCustomToken(fb.auth, mint.customToken);
593
- config2.runnerId = mint.runnerId;
594
- config2.refreshToken = cred.user.refreshToken;
595
- saveConfig(config2);
598
+ const cred = await signInWithCustomToken(fb.auth, session.customToken);
596
599
  return cred.user;
597
600
  }
598
- async function ensureSignedIn(fb) {
599
- try {
600
- return await signInWithStoredSession(fb);
601
- } catch (e) {
602
- console.error(e instanceof Error ? e.message : String(e));
603
- process.exit(1);
601
+ async function openShipSessions(config2, build) {
602
+ const sessions = [];
603
+ const failures = [];
604
+ for (const shipId of config2.ships) {
605
+ const fb = build(shipId);
606
+ try {
607
+ const user = await signInToShip(fb, config2, shipId);
608
+ sessions.push({ shipId, fb, user });
609
+ } catch (e) {
610
+ failures.push({ shipId, message: e instanceof Error ? e.message : String(e) });
611
+ }
604
612
  }
613
+ return { sessions, failures };
605
614
  }
606
615
 
607
616
  // src/firebase.ts
@@ -613,15 +622,16 @@ import {
613
622
  terminate
614
623
  } from "firebase/firestore";
615
624
  import { getStorage, connectStorageEmulator } from "firebase/storage";
616
- function initFirebase(config2) {
617
- const app = initializeApp({
625
+ function initFirebase(config2, name) {
626
+ const options = {
618
627
  apiKey: config2.apiKey,
619
628
  projectId: config2.projectId,
620
629
  authDomain: `${config2.projectId}.firebaseapp.com`,
621
630
  // The modern default bucket (post-2024 Firebase projects) — matches what the functions
622
631
  // runtime's admin getStorage().bucket() resolves for getTranscript.
623
632
  storageBucket: `${config2.projectId}.firebasestorage.app`
624
- });
633
+ };
634
+ const app = name ? initializeApp(options, name) : initializeApp(options);
625
635
  const auth = getAuth(app);
626
636
  const db = getFirestore(app, CREW_DATABASE_ID);
627
637
  const storage = getStorage(app);
@@ -1225,7 +1235,7 @@ Note: a chat session has no task, so it is never granted write access \u2014 you
1225
1235
  // src/engines/claude.ts
1226
1236
  import { spawn as spawn3 } from "node:child_process";
1227
1237
  import fs3 from "node:fs";
1228
- import os3 from "node:os";
1238
+ import os2 from "node:os";
1229
1239
  import path3 from "node:path";
1230
1240
  function normalizeClaudeUsage(resultEvent, model, fallbackDurationS) {
1231
1241
  const result = resultEvent ?? {};
@@ -1278,8 +1288,8 @@ function allowedTools(agent) {
1278
1288
  return [...new Set(tools)];
1279
1289
  }
1280
1290
  function createSessionDirs(jobId) {
1281
- const workdir = fs3.mkdtempSync(path3.join(os3.tmpdir(), `crew-job-${jobId}-`));
1282
- const configDir2 = fs3.mkdtempSync(path3.join(os3.tmpdir(), `crew-cfg-${jobId}-`));
1291
+ const workdir = fs3.mkdtempSync(path3.join(os2.tmpdir(), `crew-job-${jobId}-`));
1292
+ const configDir2 = fs3.mkdtempSync(path3.join(os2.tmpdir(), `crew-cfg-${jobId}-`));
1283
1293
  return { workdir, configDir: configDir2, mcpConfigPath: path3.join(configDir2, "mcp.json") };
1284
1294
  }
1285
1295
  function buildMcpConfig(input) {
@@ -1748,9 +1758,25 @@ async function startDaemon() {
1748
1758
  console.error("No Ships assigned. Run: lumi-runner ship add <shipId>");
1749
1759
  process.exit(1);
1750
1760
  }
1751
- const fb = initFirebase(config2);
1752
- let user = await ensureSignedIn(fb);
1753
- console.log(`Signed in as ${user.uid}, runner ${config2.runnerId}, ships: ${config2.ships.join(", ")}`);
1761
+ const { sessions, failures } = await openShipSessions(
1762
+ config2,
1763
+ (shipId) => initFirebase(config2, `ship-${shipId}`)
1764
+ );
1765
+ for (const f of failures) console.error(f.message);
1766
+ if (sessions.length === 0) {
1767
+ console.error("No Ship could be signed in to. Run: lumi-runner login");
1768
+ process.exit(1);
1769
+ }
1770
+ const byShip = new Map(sessions.map((s) => [s.shipId, s]));
1771
+ const sess = (shipId) => {
1772
+ const s = byShip.get(shipId);
1773
+ if (!s) throw new Error(`No session for Ship ${shipId}`);
1774
+ return s;
1775
+ };
1776
+ const liveShips = sessions.map((s) => s.shipId);
1777
+ console.log(
1778
+ `Runner ${config2.runnerId} serving ${liveShips.length} Ship(s): ${liveShips.join(", ")}`
1779
+ );
1754
1780
  const logLines = [];
1755
1781
  const log2 = (line) => {
1756
1782
  const stamped = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}`;
@@ -1763,53 +1789,21 @@ async function startDaemon() {
1763
1789
  const startedAt = Date.now();
1764
1790
  let currentJob = null;
1765
1791
  const secretsOk = /* @__PURE__ */ new Map();
1766
- const enrolled = /* @__PURE__ */ new Set();
1767
1792
  const approved = /* @__PURE__ */ new Map();
1768
1793
  const warnedUnapproved = /* @__PURE__ */ new Set();
1769
- const shipRunnerRef = (shipId) => doc7(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
1770
- async function ensureEnrolled(shipId) {
1771
- if (enrolled.has(shipId)) return;
1772
- const snap = await getDoc5(shipRunnerRef(shipId));
1773
- if (!snap.exists()) {
1774
- await setDoc2(shipRunnerRef(shipId), {
1775
- ownerUserId: user.uid,
1776
- hostname: os4.hostname(),
1777
- version: RUNNER_VERSION,
1778
- status: "online",
1779
- lastSeenAt: Date.now(),
1780
- startedAt,
1781
- keyPresent: secretsOk.get(shipId) ?? false,
1782
- currentJob: null,
1783
- lastLogLines: [],
1784
- approved: false
1785
- });
1786
- }
1787
- enrolled.add(shipId);
1788
- }
1789
- let becameEligible = false;
1790
- let tokenStale = false;
1794
+ const shipRunnerRef = (shipId) => doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
1795
+ const needsRefill = /* @__PURE__ */ new Set();
1791
1796
  let beating = false;
1792
1797
  async function heartbeat() {
1793
1798
  if (beating) return;
1794
1799
  beating = true;
1795
1800
  const now = Date.now();
1796
1801
  try {
1797
- await setDoc2(
1798
- doc7(fb.db, COLLECTIONS.users, user.uid, COLLECTIONS.runners, config2.runnerId),
1799
- {
1800
- hostname: os4.hostname(),
1801
- version: RUNNER_VERSION,
1802
- status: "online",
1803
- lastSeenAt: now,
1804
- ships: config2.ships
1805
- }
1806
- );
1807
- for (const shipId of config2.ships) {
1808
- await ensureEnrolled(shipId);
1802
+ for (const shipId of liveShips) {
1809
1803
  await setDoc2(
1810
1804
  shipRunnerRef(shipId),
1811
1805
  {
1812
- hostname: os4.hostname(),
1806
+ hostname: os3.hostname(),
1813
1807
  version: RUNNER_VERSION,
1814
1808
  status: "online",
1815
1809
  lastSeenAt: now,
@@ -1822,19 +1816,17 @@ async function startDaemon() {
1822
1816
  );
1823
1817
  const snap = await getDoc5(shipRunnerRef(shipId));
1824
1818
  const isApproved = snap.data()?.approved === true;
1825
- const seen = approved.has(shipId);
1826
1819
  const wasApproved = approved.get(shipId) === true;
1827
1820
  approved.set(shipId, isApproved);
1828
1821
  if (!isApproved && !warnedUnapproved.has(shipId)) {
1829
1822
  warnedUnapproved.add(shipId);
1830
1823
  log2(
1831
- `Ship ${shipId}: awaiting captain approval \u2014 this machine is enrolled but idle. Approve it on the Ship's Daemons page.`
1824
+ `Ship ${shipId}: this machine's approval was withdrawn \u2014 idle here until a captain approves it again on the Daemons page.`
1832
1825
  );
1833
1826
  }
1834
1827
  if (isApproved && !wasApproved) {
1835
1828
  warnedUnapproved.delete(shipId);
1836
- becameEligible = true;
1837
- if (seen) tokenStale = true;
1829
+ needsRefill.add(shipId);
1838
1830
  }
1839
1831
  }
1840
1832
  } catch (e) {
@@ -1842,17 +1834,27 @@ async function startDaemon() {
1842
1834
  } finally {
1843
1835
  beating = false;
1844
1836
  }
1845
- if (tokenStale) {
1846
- tokenStale = false;
1837
+ for (const shipId of [...needsRefill]) {
1838
+ needsRefill.delete(shipId);
1839
+ if (approved.get(shipId) !== true) continue;
1847
1840
  try {
1848
- user = await ensureSignedIn(fb);
1849
- log2("Approval granted \u2014 session refreshed, picking up queued work.");
1841
+ const snap = await getDocs3(
1842
+ query3(
1843
+ collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
1844
+ where3("status", "==", "queued"),
1845
+ orderBy3("createdAt", "asc")
1846
+ )
1847
+ );
1848
+ for (const d of snap.docs) {
1849
+ pending.set(`${shipId}/${d.id}`, {
1850
+ shipId,
1851
+ job: { id: d.id, ...d.data() }
1852
+ });
1853
+ }
1854
+ log2(`Ship ${shipId}: approved \u2014 picking up ${snap.size} queued job(s).`);
1850
1855
  } catch (e) {
1851
- console.error("re-mint after approval failed:", e instanceof Error ? e.message : e);
1856
+ console.error(`queue refill failed (${shipId}):`, e instanceof Error ? e.message : e);
1852
1857
  }
1853
- }
1854
- if (becameEligible) {
1855
- becameEligible = false;
1856
1858
  poke();
1857
1859
  }
1858
1860
  }
@@ -1860,9 +1862,9 @@ async function startDaemon() {
1860
1862
  const engineLimits = /* @__PURE__ */ new Map();
1861
1863
  const agentEngines = /* @__PURE__ */ new Map();
1862
1864
  const unsubs = [];
1863
- for (const shipId of config2.ships) {
1865
+ for (const shipId of liveShips) {
1864
1866
  const q = query3(
1865
- collection5(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
1867
+ collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
1866
1868
  where3("status", "==", "queued"),
1867
1869
  orderBy3("createdAt", "asc")
1868
1870
  );
@@ -1886,7 +1888,7 @@ async function startDaemon() {
1886
1888
  );
1887
1889
  unsubs.push(
1888
1890
  onSnapshot2(
1889
- collection5(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
1891
+ collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
1890
1892
  (snap) => {
1891
1893
  for (const d of snap.docs) {
1892
1894
  agentEngines.set(`${shipId}/${d.id}`, agentEngine(d.data()));
@@ -1898,7 +1900,7 @@ async function startDaemon() {
1898
1900
  );
1899
1901
  unsubs.push(
1900
1902
  subscribeEngineLimits(
1901
- fb.db,
1903
+ sess(shipId).fb.db,
1902
1904
  shipId,
1903
1905
  (docs) => {
1904
1906
  for (const key of [...engineLimits.keys()]) {
@@ -1950,7 +1952,7 @@ async function startDaemon() {
1950
1952
  log2(
1951
1953
  `${getEngine(entry.engineId).label} usage window on Ship ${entry.shipId} has reset \u2014 resuming.`
1952
1954
  );
1953
- void clearEngineLimit(fb.db, entry.shipId, entry.engineId).catch(() => {
1955
+ void clearEngineLimit(sess(entry.shipId).fb.db, entry.shipId, entry.engineId).catch(() => {
1954
1956
  });
1955
1957
  }
1956
1958
  armLimitTimer();
@@ -1980,10 +1982,10 @@ async function startDaemon() {
1980
1982
  }
1981
1983
  }
1982
1984
  async function claim(shipId, job) {
1983
- const jobRef = doc7(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id);
1985
+ const jobRef = doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id);
1984
1986
  try {
1985
1987
  let claimed = null;
1986
- await runTransaction2(fb.db, async (tx) => {
1988
+ await runTransaction2(sess(shipId).fb.db, async (tx) => {
1987
1989
  const snap = await tx.get(jobRef);
1988
1990
  if (!snap.exists() || snap.data().status !== "queued") {
1989
1991
  claimed = null;
@@ -1996,18 +1998,19 @@ async function startDaemon() {
1996
1998
  return claimed;
1997
1999
  } catch (e) {
1998
2000
  log2(`claim failed for ${job.id}: ${e instanceof Error ? e.message : e}`);
2001
+ needsRefill.add(shipId);
1999
2002
  return null;
2000
2003
  }
2001
2004
  }
2002
2005
  async function setAgentStatus(shipId, agentId, status) {
2003
2006
  try {
2004
- await updateDoc2(doc7(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents, agentId), { status });
2007
+ await updateDoc2(doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents, agentId), { status });
2005
2008
  } catch (e) {
2006
2009
  log2(`agent status update failed: ${e instanceof Error ? e.message : e}`);
2007
2010
  }
2008
2011
  }
2009
2012
  async function loadSecrets(shipId) {
2010
- const secrets = await loadRunnerSecrets(fb.db, shipId);
2013
+ const secrets = await loadRunnerSecrets(sess(shipId).fb.db, shipId);
2011
2014
  secretsOk.set(shipId, missingSecretsFor(DEFAULT_ENGINE_ID, secrets).length === 0);
2012
2015
  return secrets;
2013
2016
  }
@@ -2017,7 +2020,7 @@ async function startDaemon() {
2017
2020
  const target = jobTarget(job);
2018
2021
  if (!target) {
2019
2022
  log2(`Job ${job.id} names neither a task nor a chat (or both) \u2014 failing it.`);
2020
- await finalizeJob(fb.db, shipId, job, {
2023
+ await finalizeJob(sess(shipId).fb.db, shipId, job, {
2021
2024
  status: "failed",
2022
2025
  usage: {
2023
2026
  engine: DEFAULT_ENGINE_ID,
@@ -2071,7 +2074,7 @@ async function startDaemon() {
2071
2074
  let statuses = DEFAULT_TASK_STATUSES;
2072
2075
  try {
2073
2076
  const secrets = await loadSecrets(shipId);
2074
- const packed = target.kind === "chat" ? { kind: "chat", ctx: await loadChatContext(fb.db, shipId, { ...job, chatId: target.chatId }) } : { kind: "task", ctx: await loadJobContext(fb.db, shipId, { ...job, taskId: target.taskId }) };
2077
+ const packed = target.kind === "chat" ? { kind: "chat", ctx: await loadChatContext(sess(shipId).fb.db, shipId, { ...job, chatId: target.chatId }) } : { kind: "task", ctx: await loadJobContext(sess(shipId).fb.db, shipId, { ...job, taskId: target.taskId }) };
2075
2078
  if (packed.kind === "task" && !isWorkflowSentinel(job.workflowId) && !packed.ctx.workflow) {
2076
2079
  log2(
2077
2080
  `Playbook ${job.workflowId} is missing, disabled or empty \u2014 running a generic session.`
@@ -2088,10 +2091,10 @@ async function startDaemon() {
2088
2091
  );
2089
2092
  }
2090
2093
  const prompt = packed.kind === "chat" ? buildChatPrompt(packed.ctx) : buildPrompt(packed.ctx, job.reason);
2091
- const idToken = await user.getIdToken();
2094
+ const idToken = await sess(shipId).user.getIdToken();
2092
2095
  try {
2093
2096
  const gh = await resolveGithubToken({
2094
- db: fb.db,
2097
+ db: sess(shipId).fb.db,
2095
2098
  config: config2,
2096
2099
  idToken,
2097
2100
  shipId,
@@ -2149,7 +2152,7 @@ async function startDaemon() {
2149
2152
  try {
2150
2153
  if (shuttingDown) {
2151
2154
  await releaseJob(
2152
- fb.db,
2155
+ sess(shipId).fb.db,
2153
2156
  shipId,
2154
2157
  job,
2155
2158
  "Runner shut down mid-job \u2014 released back to the queue without consuming a retry."
@@ -2158,9 +2161,9 @@ async function startDaemon() {
2158
2161
  } else if (sessionLimit) {
2159
2162
  const engineLabel = getEngine(engineId).label;
2160
2163
  const resetsAt = new Date(sessionLimit.resetsAt).toISOString();
2161
- await noteEngineLimit(fb.db, shipId, engineId, sessionLimit, config2.runnerId);
2164
+ await noteEngineLimit(sess(shipId).fb.db, shipId, engineId, sessionLimit, config2.runnerId);
2162
2165
  await releaseJob(
2163
- fb.db,
2166
+ sess(shipId).fb.db,
2164
2167
  shipId,
2165
2168
  job,
2166
2169
  `${engineLabel} usage limit reached \u2014 released without consuming a retry; resumes ${resetsAt}.`
@@ -2173,8 +2176,8 @@ async function startDaemon() {
2173
2176
  `${engineLabel} usage limit reached \u2014 work resumes automatically.`
2174
2177
  );
2175
2178
  } else if (!failure) {
2176
- const transcriptPath = await uploadTranscript(fb.storage, shipId, job.id, transcript);
2177
- await finalizeJob(fb.db, shipId, job, { status: "done", usage, transcriptPath });
2179
+ const transcriptPath = await uploadTranscript(sess(shipId).fb.storage, shipId, job.id, transcript);
2180
+ await finalizeJob(sess(shipId).fb.db, shipId, job, { status: "done", usage, transcriptPath });
2178
2181
  log2(`Job ${job.id} done (${usage.inputTokens}in/${usage.outputTokens}out tokens).`);
2179
2182
  notify(
2180
2183
  "Crew job finished",
@@ -2182,14 +2185,14 @@ async function startDaemon() {
2182
2185
  );
2183
2186
  } else if (!terminal && job.attempt < MAX_ATTEMPTS) {
2184
2187
  log2(`Job ${job.id} failed (attempt ${job.attempt}) \u2014 re-queueing: ${failure.slice(0, 120)}`);
2185
- await requeueForRetry(fb.db, shipId, job, failure);
2188
+ await requeueForRetry(sess(shipId).fb.db, shipId, job, failure);
2186
2189
  } else {
2187
- const transcriptPath = transcript ? await uploadTranscript(fb.storage, shipId, job.id, transcript) : "";
2188
- await finalizeJob(fb.db, shipId, job, { status: "failed", usage, transcriptPath, error: failure });
2190
+ const transcriptPath = transcript ? await uploadTranscript(sess(shipId).fb.storage, shipId, job.id, transcript) : "";
2191
+ await finalizeJob(sess(shipId).fb.db, shipId, job, { status: "failed", usage, transcriptPath, error: failure });
2189
2192
  if (target.kind === "chat") {
2190
- await markChatFailed(fb.db, shipId, { ...job, chatId: target.chatId }, failure);
2193
+ await markChatFailed(sess(shipId).fb.db, shipId, { ...job, chatId: target.chatId }, failure);
2191
2194
  } else {
2192
- await markTaskFailed(fb.db, shipId, { ...job, taskId: target.taskId }, failure, statuses);
2195
+ await markTaskFailed(sess(shipId).fb.db, shipId, { ...job, taskId: target.taskId }, failure, statuses);
2193
2196
  }
2194
2197
  log2(`Job ${job.id} FAILED terminally: ${failure.slice(0, 120)}`);
2195
2198
  notify("Crew job failed", `${targetLabel}: ${failure.slice(0, 120)}`);
@@ -2201,7 +2204,7 @@ async function startDaemon() {
2201
2204
  await setAgentStatus(shipId, job.agentId, "idle");
2202
2205
  if (!shuttingDown) void heartbeat();
2203
2206
  }
2204
- await Promise.all(config2.ships.map((shipId) => loadSecrets(shipId).catch(() => null)));
2207
+ await Promise.all(liveShips.map((shipId) => loadSecrets(shipId).catch(() => null)));
2205
2208
  await heartbeat();
2206
2209
  const heartbeatTimer = setInterval(() => {
2207
2210
  void heartbeat();
@@ -2230,15 +2233,10 @@ async function startDaemon() {
2230
2233
  }
2231
2234
  const now = Date.now();
2232
2235
  try {
2233
- await setDoc2(
2234
- doc7(fb.db, COLLECTIONS.users, user.uid, COLLECTIONS.runners, config2.runnerId),
2235
- { status: "offline", lastSeenAt: now },
2236
- { merge: true }
2237
- );
2238
- for (const shipId of config2.ships) {
2236
+ for (const shipId of liveShips) {
2239
2237
  await setDoc2(
2240
- doc7(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
2241
- { ownerUserId: user.uid, status: "offline", lastSeenAt: now, currentJob: deleteField() },
2238
+ doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
2239
+ { status: "offline", lastSeenAt: now, currentJob: deleteField() },
2242
2240
  { merge: true }
2243
2241
  );
2244
2242
  }
@@ -2248,7 +2246,7 @@ async function startDaemon() {
2248
2246
  };
2249
2247
  process.on("SIGINT", () => void shutdown("SIGINT"));
2250
2248
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
2251
- log2(`Runner online \u2014 watching ${config2.ships.length} Ship(s), MCP: ${mcpUrl(config2)}`);
2249
+ log2(`Runner online \u2014 watching ${liveShips.length} Ship(s), MCP: ${mcpUrl(config2)}`);
2252
2250
  await new Promise(() => {
2253
2251
  });
2254
2252
  }
@@ -2426,12 +2424,12 @@ async function runConfigSet(key, value) {
2426
2424
 
2427
2425
  // src/cli/commands/doctor.ts
2428
2426
  import { spawnSync as spawnSync2 } from "node:child_process";
2429
- import { collection as collection6, doc as doc8, getDoc as getDoc6, getDocs as getDocs3 } from "firebase/firestore";
2427
+ import { collection as collection6, doc as doc8, getDoc as getDoc6, getDocs as getDocs4 } from "firebase/firestore";
2430
2428
 
2431
2429
  // src/service.ts
2432
2430
  import { spawnSync } from "node:child_process";
2433
2431
  import fs4 from "node:fs";
2434
- import os5 from "node:os";
2432
+ import os4 from "node:os";
2435
2433
  import path4 from "node:path";
2436
2434
  import { fileURLToPath } from "node:url";
2437
2435
  var SERVICE_LABEL = "com.lumi.runner";
@@ -2456,16 +2454,16 @@ function uid() {
2456
2454
  return String(process.getuid?.() ?? 0);
2457
2455
  }
2458
2456
  function launchAgentPath() {
2459
- return path4.join(os5.homedir(), "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
2457
+ return path4.join(os4.homedir(), "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
2460
2458
  }
2461
2459
  function systemdUnitPath() {
2462
- return path4.join(os5.homedir(), ".config/systemd/user", LINUX_UNIT);
2460
+ return path4.join(os4.homedir(), ".config/systemd/user", LINUX_UNIT);
2463
2461
  }
2464
2462
  function legacyLaunchAgentPath() {
2465
- return path4.join(os5.homedir(), "Library/LaunchAgents", `${LEGACY_SERVICE_LABEL}.plist`);
2463
+ return path4.join(os4.homedir(), "Library/LaunchAgents", `${LEGACY_SERVICE_LABEL}.plist`);
2466
2464
  }
2467
2465
  function legacySystemdUnitPath() {
2468
- return path4.join(os5.homedir(), ".config/systemd/user", LEGACY_LINUX_UNIT);
2466
+ return path4.join(os4.homedir(), ".config/systemd/user", LEGACY_LINUX_UNIT);
2469
2467
  }
2470
2468
  function serviceEnv() {
2471
2469
  const env = { PATH: process.env.PATH ?? "" };
@@ -2677,10 +2675,10 @@ function installService() {
2677
2675
  if (!reload.ok) throw new ServiceError(`systemctl daemon-reload failed: ${reload.out}`);
2678
2676
  const enable = run("systemctl", ["--user", "enable", "--now", LINUX_UNIT]);
2679
2677
  if (!enable.ok) throw new ServiceError(`systemctl enable failed: ${enable.out}`);
2680
- const linger = run("loginctl", ["show-user", os5.userInfo().username, "--property=Linger"]);
2678
+ const linger = run("loginctl", ["show-user", os4.userInfo().username, "--property=Linger"]);
2681
2679
  if (!linger.out.includes("Linger=yes")) {
2682
2680
  notes.push(
2683
- `Run \`sudo loginctl enable-linger ${os5.userInfo().username}\` so the daemon survives logout and starts at boot.`
2681
+ `Run \`sudo loginctl enable-linger ${os4.userInfo().username}\` so the daemon survives logout and starts at boot.`
2684
2682
  );
2685
2683
  }
2686
2684
  return { unitPath, notes };
@@ -2747,11 +2745,26 @@ function restartService() {
2747
2745
  }
2748
2746
 
2749
2747
  // src/cli/session.ts
2748
+ async function openShipSession(shipId) {
2749
+ const config2 = requireConfig();
2750
+ const fb = initFirebase(config2, `cli-${shipId}`);
2751
+ const user = await signInToShip(fb, config2, shipId);
2752
+ return { shipId, config: config2, fb, user };
2753
+ }
2750
2754
  async function openSession() {
2751
2755
  const config2 = requireConfig();
2752
- const fb = initFirebase(config2);
2753
- const user = await signInWithStoredSession(fb);
2754
- return { config: config2, fb, user };
2756
+ if (config2.ships.length === 0) {
2757
+ throw new AuthError("No Ships assigned. Run: lumi-runner ship add <shipId>");
2758
+ }
2759
+ let last;
2760
+ for (const shipId of config2.ships) {
2761
+ try {
2762
+ return await openShipSession(shipId);
2763
+ } catch (e) {
2764
+ last = e;
2765
+ }
2766
+ }
2767
+ throw last instanceof Error ? last : new AuthError("Could not sign in to any Ship.");
2755
2768
  }
2756
2769
 
2757
2770
  // src/cli/commands/doctor.ts
@@ -2818,8 +2831,7 @@ function serviceCheckFrom(status) {
2818
2831
  function checkService() {
2819
2832
  return serviceCheckFrom(serviceStatus());
2820
2833
  }
2821
- async function checkShips(session) {
2822
- const { config: config2, fb, user } = session;
2834
+ async function checkShips(config2) {
2823
2835
  const checks = [];
2824
2836
  const engines = /* @__PURE__ */ new Set();
2825
2837
  let needsGithub = false;
@@ -2830,6 +2842,21 @@ async function checkShips(session) {
2830
2842
  return { checks, engines, needsGithub };
2831
2843
  }
2832
2844
  for (const shipId of config2.ships) {
2845
+ let fb;
2846
+ try {
2847
+ ({ fb } = await openShipSession(shipId));
2848
+ checks.push(ok(`key:${shipId}`, `Ship ${shipId} \u2014 key`, "Runner key accepted."));
2849
+ } catch (e) {
2850
+ checks.push(
2851
+ fail(
2852
+ `key:${shipId}`,
2853
+ `Ship ${shipId} \u2014 key`,
2854
+ e instanceof Error ? e.message : String(e),
2855
+ "Ask a captain to approve this machine on that Ship's Daemons page, then re-run `lumi-runner login`."
2856
+ )
2857
+ );
2858
+ continue;
2859
+ }
2833
2860
  try {
2834
2861
  const snap = await getDoc6(
2835
2862
  doc8(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId)
@@ -2839,8 +2866,8 @@ async function checkShips(session) {
2839
2866
  warn(
2840
2867
  `approval:${shipId}`,
2841
2868
  `Ship ${shipId} \u2014 approval`,
2842
- "This machine has not enrolled yet.",
2843
- "Start the daemon once; it enrols on its first heartbeat, then a captain approves it."
2869
+ "This machine has no record on that Ship.",
2870
+ "Re-run `lumi-runner login` \u2014 approving is what enrols it."
2844
2871
  )
2845
2872
  );
2846
2873
  } else if (snap.data().approved !== true) {
@@ -2848,17 +2875,8 @@ async function checkShips(session) {
2848
2875
  fail(
2849
2876
  `approval:${shipId}`,
2850
2877
  `Ship ${shipId} \u2014 approval`,
2851
- "Enrolled but awaiting captain approval \u2014 no jobs will be claimed.",
2852
- `Approve this machine on the Ship's Daemons page.`
2853
- )
2854
- );
2855
- } else if (snap.data().ownerUserId !== user.uid) {
2856
- checks.push(
2857
- fail(
2858
- `approval:${shipId}`,
2859
- `Ship ${shipId} \u2014 approval`,
2860
- "The approval record belongs to another user.",
2861
- "Re-run `lumi-runner login` on this machine."
2878
+ "Approval was withdrawn \u2014 no jobs will be claimed.",
2879
+ `Approve this machine again on the Ship's Daemons page.`
2862
2880
  )
2863
2881
  );
2864
2882
  } else {
@@ -2870,13 +2888,13 @@ async function checkShips(session) {
2870
2888
  `approval:${shipId}`,
2871
2889
  `Ship ${shipId} \u2014 approval`,
2872
2890
  `Could not read the approval record (${e instanceof Error ? e.message : e}).`,
2873
- "Are you still a member of this Ship?"
2891
+ "The key exchanged but the read was refused \u2014 report this."
2874
2892
  )
2875
2893
  );
2876
2894
  }
2877
2895
  let agents = [];
2878
2896
  try {
2879
- const snap = await getDocs3(collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
2897
+ const snap = await getDocs4(collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
2880
2898
  agents = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
2881
2899
  } catch {
2882
2900
  }
@@ -2920,26 +2938,14 @@ async function runDoctor() {
2920
2938
  checks.push(ok("config", "Configuration", `Runner ${config2.runnerId} on project ${config2.projectId}`));
2921
2939
  const progress = spinner2();
2922
2940
  progress.start("Running checks\u2026");
2923
- let session = null;
2924
- try {
2925
- session = await openSession();
2926
- checks.push(ok("session", "Session", `Signed in as ${session.user.uid}`));
2927
- } catch (e) {
2928
- checks.push(
2929
- fail("session", "Session", e instanceof Error ? e.message : String(e), "Run `lumi-runner login`.")
2930
- );
2931
- }
2932
2941
  const engines = /* @__PURE__ */ new Set([DEFAULT_ENGINE_ID]);
2933
- let needsGithub = false;
2934
- if (session) {
2935
- const shipResults = await checkShips(session);
2936
- checks.push(...shipResults.checks);
2937
- if (shipResults.engines.size > 0) {
2938
- engines.clear();
2939
- for (const id of shipResults.engines) engines.add(id);
2940
- }
2941
- needsGithub = shipResults.needsGithub;
2942
+ const shipResults = await checkShips(config2);
2943
+ checks.push(...shipResults.checks);
2944
+ if (shipResults.engines.size > 0) {
2945
+ engines.clear();
2946
+ for (const id of shipResults.engines) engines.add(id);
2942
2947
  }
2948
+ const needsGithub = shipResults.needsGithub;
2943
2949
  for (const engineId of engines) {
2944
2950
  const health = await getDriver(engineId).healthCheck();
2945
2951
  checks.push(
@@ -2993,7 +2999,7 @@ function report(checks) {
2993
2999
 
2994
3000
  // src/cli/commands/login.ts
2995
3001
  import { spawn as spawn4 } from "node:child_process";
2996
- import os6 from "node:os";
3002
+ import os5 from "node:os";
2997
3003
  import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
2998
3004
  function openBrowser(url) {
2999
3005
  const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
@@ -3009,14 +3015,14 @@ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3009
3015
  async function runLogin(options) {
3010
3016
  const moved = migrateLegacyDir();
3011
3017
  if (moved?.migrated) say.info(`Moved your runner config from ${moved.from} to ${moved.to}.`);
3012
- return options.token ? loginWithToken(options) : loginWithDeviceFlow(options);
3018
+ return options.key ? loginWithKey(options) : loginWithDeviceFlow(options);
3013
3019
  }
3014
3020
  async function loginWithDeviceFlow(options) {
3015
3021
  const existingConfig = loadConfig();
3016
3022
  const projectId = options.project || existingConfig?.projectId || DEFAULT_PROJECT_ID;
3017
3023
  const baseUrl = functionsBaseUrlFor(projectId);
3018
3024
  const start = await callPublicFunction(baseUrl, "startRunnerLogin", {
3019
- hostname: os6.hostname(),
3025
+ hostname: os5.hostname(),
3020
3026
  // Offer the id this machine already has: re-logging in should keep its identity, and with
3021
3027
  // it the captain approvals it has already earned.
3022
3028
  ...existingConfig?.runnerId ? { runnerId: existingConfig.runnerId } : {}
@@ -3062,7 +3068,10 @@ Code: ${pc.bold(start.displayCode)}`,
3062
3068
  apiKey: approved.apiKey || existingConfig?.apiKey || "",
3063
3069
  projectId: approved.projectId || projectId,
3064
3070
  runnerId: approved.runnerId,
3065
- refreshToken: "",
3071
+ // THE CREDENTIALS (PRD §8.1) — one Ship key per approved Ship, handed over on this one hop
3072
+ // and nowhere else. Merged over any we already held so that approving a SECOND Ship does not
3073
+ // silently revoke the first: this response only carries keys for what was just approved.
3074
+ shipKeys: { ...existingConfig?.shipKeys ?? {}, ...approved.shipKeys },
3066
3075
  // What the human PICKED on the approval page. Their choice replaces whatever this machine
3067
3076
  // was serving — that page is where the decision is now made.
3068
3077
  ships: approved.selectedShips.length > 0 ? approved.selectedShips : existingConfig?.ships ?? [],
@@ -3074,34 +3083,62 @@ Code: ${pc.bold(start.displayCode)}`,
3074
3083
  );
3075
3084
  }
3076
3085
  const fb = initFirebase(config2);
3077
- const credential = await signInWithCustomToken2(fb.auth, approved.customToken);
3078
- config2.refreshToken = credential.user.refreshToken;
3086
+ const first = approved.approvedShips[0];
3087
+ const uid2 = first ? (await signInWithCustomToken2(fb.auth, (await exchange(config2, first)).customToken)).user.uid : "(no Ship approved yet)";
3079
3088
  saveConfig(config2);
3080
3089
  return report2({
3081
3090
  runnerId: config2.runnerId,
3082
- uid: credential.user.uid,
3091
+ uid: uid2,
3083
3092
  ships: config2.ships,
3084
3093
  approvedShips: approved.approvedShips,
3085
3094
  pendingShips: approved.pendingShips.filter((id) => config2.ships.includes(id))
3086
3095
  });
3087
3096
  }
3088
- async function loginWithToken(options) {
3097
+ async function exchange(config2, shipId) {
3098
+ return callPublicFunction(
3099
+ functionsBaseUrlFor(config2.projectId),
3100
+ "exchangeRunnerKey",
3101
+ { key: config2.shipKeys[shipId] }
3102
+ );
3103
+ }
3104
+ async function loginWithKey(options) {
3089
3105
  if (!options.apiKey || !options.project) {
3090
- throw new CliError("--token also needs --api-key and --project. Omit --token to use the browser flow.");
3106
+ throw new CliError("--key also needs --api-key and --project. Omit --key to use the browser flow.");
3107
+ }
3108
+ const parsed = parseKeyShipId(options.key);
3109
+ if (!parsed) {
3110
+ throw new CliError("That does not look like a runner key. Expected `crewrunner_<shipId>_<secret>`.");
3091
3111
  }
3092
3112
  const existing = loadConfig();
3093
3113
  const config2 = {
3094
3114
  ...existing,
3095
3115
  apiKey: options.apiKey,
3096
3116
  projectId: options.project,
3097
- runnerId: options.runnerId || existing?.runnerId || `runner-${os6.hostname().toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 12)}-${Math.random().toString(36).slice(2, 6)}`,
3098
- refreshToken: "",
3099
- ships: existing?.ships ?? [],
3117
+ runnerId: options.runnerId || existing?.runnerId || `runner-${os5.hostname().toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 12)}-${Math.random().toString(36).slice(2, 6)}`,
3118
+ shipKeys: { ...existing?.shipKeys ?? {}, [parsed]: options.key },
3119
+ ships: [.../* @__PURE__ */ new Set([...existing?.ships ?? [], parsed])],
3100
3120
  ...options.mcpUrl ? { mcpUrl: options.mcpUrl } : {}
3101
3121
  };
3122
+ const session = await callPublicFunction(
3123
+ functionsBaseUrlFor(config2.projectId),
3124
+ "exchangeRunnerKey",
3125
+ { key: options.key }
3126
+ );
3127
+ config2.runnerId = session.runnerId;
3102
3128
  const fb = initFirebase(config2);
3103
- const user = await login(fb, config2, options.token);
3104
- return report2({ runnerId: config2.runnerId, uid: user.uid, ships: config2.ships });
3129
+ const cred = await signInWithCustomToken2(fb.auth, session.customToken);
3130
+ saveConfig(config2);
3131
+ return report2({
3132
+ runnerId: config2.runnerId,
3133
+ uid: cred.user.uid,
3134
+ ships: config2.ships,
3135
+ approvedShips: [parsed],
3136
+ pendingShips: []
3137
+ });
3138
+ }
3139
+ function parseKeyShipId(key) {
3140
+ const parts = (key ?? "").split("_");
3141
+ return parts.length === 3 && parts[0] === "crewrunner" && parts[1] && parts[2] ? parts[1] : null;
3105
3142
  }
3106
3143
  function report2(result) {
3107
3144
  if (isJson()) {
@@ -3234,10 +3271,10 @@ async function runUninstall(options) {
3234
3271
  }
3235
3272
 
3236
3273
  // src/cli/commands/ship.ts
3237
- import { collectionGroup, doc as doc9, getDoc as getDoc7, getDocs as getDocs4, query as query4, where as where4 } from "firebase/firestore";
3274
+ import { collectionGroup, doc as doc9, getDoc as getDoc7, getDocs as getDocs5, query as query4, where as where4 } from "firebase/firestore";
3238
3275
  async function listMyShips() {
3239
3276
  const { fb, user } = await openSession();
3240
- const memberships = await getDocs4(
3277
+ const memberships = await getDocs5(
3241
3278
  query4(collectionGroup(fb.db, COLLECTIONS.members), where4("uid", "==", user.uid))
3242
3279
  );
3243
3280
  const shipIds = memberships.docs.map((d) => d.ref.parent.parent?.id).filter((id) => !!id);
@@ -3314,8 +3351,8 @@ async function runSetup(options) {
3314
3351
  const moved = migrateLegacyDir();
3315
3352
  if (moved?.migrated) say.info(`Moved your runner config from ${moved.from} to ${moved.to}.`);
3316
3353
  const existing = loadConfig();
3317
- const reuse = existing?.refreshToken && await promptConfirm({
3318
- message: `This machine is already connected as ${existing.runnerId}. Keep that session?`,
3354
+ const reuse = Object.keys(existing?.shipKeys ?? {}).length > 0 && await promptConfirm({
3355
+ message: `This machine is already connected as ${existing?.runnerId}. Keep its Ship keys?`,
3319
3356
  initialValue: true,
3320
3357
  yesFlagApplies: false
3321
3358
  });
@@ -3362,7 +3399,7 @@ import {
3362
3399
  doc as doc10,
3363
3400
  getCountFromServer as getCountFromServer2,
3364
3401
  getDoc as getDoc8,
3365
- getDocs as getDocs5,
3402
+ getDocs as getDocs6,
3366
3403
  query as query5,
3367
3404
  where as where5
3368
3405
  } from "firebase/firestore";
@@ -3396,7 +3433,7 @@ async function runStatus() {
3396
3433
  const usageSnap = await getDoc8(doc10(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
3397
3434
  const today = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
3398
3435
  const now = Date.now();
3399
- const limitsSnap = await getDocs5(collection7(shipRef, COLLECTIONS.engineLimits));
3436
+ const limitsSnap = await getDocs6(collection7(shipRef, COLLECTIONS.engineLimits));
3400
3437
  const engineLimits = limitsSnap.docs.map((d) => ({ id: d.id, ...d.data() })).filter((l) => isEngineLimited(l, now));
3401
3438
  ships.push({
3402
3439
  shipId,
@@ -3464,13 +3501,11 @@ function action(handler) {
3464
3501
  try {
3465
3502
  process.exitCode = await handler(...args);
3466
3503
  } catch (error) {
3467
- if (error instanceof CliError) {
3468
- say.error(error.message);
3469
- process.exitCode = error.exitCode;
3470
- } else {
3471
- say.error(error instanceof Error ? error.message : String(error));
3472
- process.exitCode = 1;
3473
- }
3504
+ const message = error instanceof Error ? error.message : String(error);
3505
+ if (isJson()) process.stderr.write(`${JSON.stringify({ error: message })}
3506
+ `);
3507
+ else say.error(message);
3508
+ process.exitCode = error instanceof CliError ? error.exitCode : 1;
3474
3509
  } finally {
3475
3510
  await closeFirebase();
3476
3511
  }
@@ -3479,12 +3514,12 @@ function action(handler) {
3479
3514
  program.command("setup").description("Connect this machine and set it up to run jobs (interactive)").option("--project <projectId>", "Firebase project to connect to").option("--no-browser", "print the approval URL instead of opening a browser").action(action(
3480
3515
  async (options) => runSetup({ project: options.project, noBrowser: options.browser === false })
3481
3516
  ));
3482
- program.command("login").description("Connect this machine (opens a browser to approve it)").option("--project <projectId>", "Firebase project to connect to").option("--no-browser", "print the approval URL instead of opening a browser").option("--token <customToken>", "skip the browser: use a token from mintRunnerToken (CI)").option("--api-key <key>", "Firebase web API key (only with --token)").option("--runner-id <id>", "reuse an existing runner id (only with --token)").option("--mcp-url <url>", "override the Workspace MCP endpoint").action(
3517
+ program.command("login").description("Connect this machine (opens a browser to approve it)").option("--project <projectId>", "Firebase project to connect to").option("--no-browser", "print the approval URL instead of opening a browser").option("--key <shipKey>", "skip the browser: paste a Ship key from the Daemons page (CI)").option("--api-key <key>", "Firebase web API key (only with --key)").option("--runner-id <id>", "reuse an existing runner id (only with --key)").option("--mcp-url <url>", "override the Workspace MCP endpoint").action(
3483
3518
  action(
3484
3519
  async (options) => runLogin({
3485
3520
  project: options.project,
3486
3521
  noBrowser: options.browser === false,
3487
- token: options.token,
3522
+ key: options.key,
3488
3523
  apiKey: options.apiKey,
3489
3524
  runnerId: options.runnerId,
3490
3525
  mcpUrl: options.mcpUrl
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi.ai/runner",
3
- "version": "0.3.0-dev.6",
3
+ "version": "0.3.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.",