@lumi.ai/runner 0.6.4 → 0.8.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 +24 -4
  2. package/dist/cli.js +224 -51
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -50,12 +50,31 @@ It prints the approval link and an 8-character code. Open the link anywhere you
50
50
  approve this Ship, and the server finishes on its own — nothing is pasted back.
51
51
 
52
52
  For CI or unattended provisioning, where nobody is at a terminal at all, paste a Ship key from the
53
- Daemons page instead:
53
+ Daemons page instead — that page prints this command with the two values already filled in:
54
54
 
55
55
  ```bash
56
56
  lumi-runner login --key <shipKey> --api-key <firebaseWebApiKey> --project <projectId>
57
57
  ```
58
58
 
59
+ A key exists only once a captain has approved the machine on that Ship's Daemons page, and it only
60
+ appears in that list after one of the two flows above. So this is the **second** visit to a machine,
61
+ not the first.
62
+
63
+ ### More than one account
64
+
65
+ One machine can serve Ships belonging to **different people**. Run `setup` again and choose *Add
66
+ Ships, from this or another account*; the second person approves in their own browser, with their
67
+ own sign-in.
68
+
69
+ Nothing is taken away by that. Approving only ever ADDS Ships, so the machine keeps every key and
70
+ every Ship it already had — the `/connect` page can only see the Ships of whoever is signed in to
71
+ it, so it is never allowed to decide that the others should go. To stop serving one, say so
72
+ explicitly: `lumi-runner ship remove <shipId>`.
73
+
74
+ A machine ends up with one identity per account that approved it — `status` lists them — because a
75
+ runner id belongs to an account rather than to hardware. Only `setup`'s *Disconnect and start over*
76
+ removes anything, and it asks twice.
77
+
59
78
  ## Commands
60
79
 
61
80
  | Command | What it does |
@@ -246,9 +265,10 @@ npm i -g @lumi.ai/runner && lumi-runner uninstall && npm rm -g @lumi.ai/runner
246
265
 
247
266
  `lumi-runner doctor` reports a machine that is already in this state, naming the missing path.
248
267
 
249
- Neither command touches `~/.lumi-runner`. That directory is this machine's **identity** — its
250
- runner id and your session — so uninstalling and reinstalling keeps the approvals your captains
251
- already granted. Add `--purge` to delete it too, and expect to be approved again from scratch.
268
+ Neither command touches `~/.lumi-runner`. That directory is this machine's **identity** — the
269
+ runner id each account knows it by, and its Ship keys — so uninstalling and reinstalling keeps the
270
+ approvals your captains already granted. Add `--purge` to delete it too, and expect to be approved
271
+ again from scratch, by every account.
252
272
 
253
273
  ## Upgrading from `crew-runner`
254
274
 
package/dist/cli.js CHANGED
@@ -116,6 +116,11 @@ function effectiveAgentTools(agent) {
116
116
  };
117
117
  }
118
118
 
119
+ // ../shared/dist/browser.js
120
+ var DEFAULT_BROWSER_CONSENT_MS = 60 * 60 * 1e3;
121
+ var LONG_BROWSER_CONSENT_MS = 24 * 60 * 60 * 1e3;
122
+ var MAX_BROWSER_SCREENSHOT_BYTES = 2 * 1024 * 1024;
123
+
119
124
  // ../shared/dist/chat.js
120
125
  var MAX_CHAT_MESSAGE_CHARS = 8e3;
121
126
  var MAX_CHAT_MESSAGES_IN_PROMPT = 40;
@@ -169,6 +174,12 @@ var COLLECTIONS = {
169
174
  jobs: "jobs",
170
175
  /** `ships/{shipId}/runners/{runnerId}` — ship-scoped live daemon mirror (Daemons page). */
171
176
  runners: "runners",
177
+ /**
178
+ * `ships/{shipId}/browsers/{browserId}` — Chrome profiles people lent this Ship (PRD §15.44;
179
+ * see browser.ts). The doc id is a per-profile UUID the extension mints, NOT a uid: one person
180
+ * may lend two profiles, and the same profile may serve several Ships.
181
+ */
182
+ browsers: "browsers",
172
183
  /** `ships/{shipId}/notifications/{id}` — per-member in-app notifications. */
173
184
  notifications: "notifications",
174
185
  /** `ships/{shipId}/usage_daily/{yyyy-mm-dd}` — token-tracking aggregates. */
@@ -756,8 +767,35 @@ function forgetShip(config2, shipId) {
756
767
  if (next.allowLocalMcp) {
757
768
  next.allowLocalMcp = next.allowLocalMcp.filter((id) => id !== shipId);
758
769
  }
770
+ if (next.shipRunnerIds) {
771
+ const ids = { ...next.shipRunnerIds };
772
+ delete ids[shipId];
773
+ next.shipRunnerIds = ids;
774
+ }
759
775
  return next;
760
776
  }
777
+ function forgetAllShips(config2) {
778
+ const everything = [.../* @__PURE__ */ new Set([...config2.ships, ...Object.keys(config2.shipKeys ?? {})])];
779
+ return everything.reduce(forgetShip, config2);
780
+ }
781
+ function shipRunnerId(config2, shipId) {
782
+ return config2.shipRunnerIds?.[shipId] || config2.runnerId || "";
783
+ }
784
+ function runnerIdentities(primary, pairs) {
785
+ const byId = /* @__PURE__ */ new Map();
786
+ if (primary) byId.set(primary, []);
787
+ for (const { shipId, runnerId } of pairs) {
788
+ if (!runnerId) continue;
789
+ byId.set(runnerId, [...byId.get(runnerId) ?? [], shipId]);
790
+ }
791
+ return [...byId.entries()].map(([runnerId, shipIds]) => ({ runnerId, shipIds, primary: runnerId === primary })).sort((a, b) => Number(b.primary) - Number(a.primary) || a.runnerId.localeCompare(b.runnerId));
792
+ }
793
+ function configRunnerIdentities(config2) {
794
+ return runnerIdentities(
795
+ config2.runnerId,
796
+ config2.ships.map((shipId) => ({ shipId, runnerId: shipRunnerId(config2, shipId) }))
797
+ );
798
+ }
761
799
  function allowsLocalMcp(config2, shipId) {
762
800
  return Array.isArray(config2.allowLocalMcp) && config2.allowLocalMcp.includes(shipId);
763
801
  }
@@ -774,7 +812,7 @@ function mcpUrl(config2) {
774
812
  }
775
813
 
776
814
  // src/version.ts
777
- var RUNNER_VERSION = true ? "0.6.4" : "0.0.0-dev";
815
+ var RUNNER_VERSION = true ? "0.8.0" : "0.0.0-dev";
778
816
 
779
817
  // src/auth.ts
780
818
  import { signInWithCustomToken } from "firebase/auth";
@@ -869,7 +907,7 @@ async function signInToShip(fb, config2, shipId) {
869
907
  );
870
908
  }
871
909
  const cred = await signInWithCustomToken(fb.auth, session.customToken);
872
- return cred.user;
910
+ return { user: cred.user, runnerId: session.runnerId || shipRunnerId(config2, shipId) };
873
911
  }
874
912
  async function openShipSessions(config2, build) {
875
913
  const sessions = [];
@@ -877,8 +915,7 @@ async function openShipSessions(config2, build) {
877
915
  for (const shipId of config2.ships) {
878
916
  const fb = build(shipId);
879
917
  try {
880
- const user = await signInToShip(fb, config2, shipId);
881
- sessions.push({ shipId, fb, user });
918
+ sessions.push({ shipId, fb, ...await signInToShip(fb, config2, shipId) });
882
919
  } catch (e) {
883
920
  failures.push({
884
921
  shipId,
@@ -3461,8 +3498,9 @@ async function startDaemon() {
3461
3498
  return s;
3462
3499
  };
3463
3500
  const serving = new Set(sessions.map((s) => s.shipId));
3501
+ const bannerIds = [...new Set(sessions.map((s) => s.runnerId))];
3464
3502
  console.log(
3465
- `Runner ${config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}`
3503
+ bannerIds.length <= 1 ? `Runner ${bannerIds[0] ?? config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}` : `Runner ${bannerIds.length} identities serving ${serving.size} Ship(s): ` + sessions.map((s) => `${s.shipId} as ${s.runnerId}`).join(", ")
3466
3504
  );
3467
3505
  const logLines = [];
3468
3506
  const log2 = (line) => {
@@ -3483,7 +3521,10 @@ async function startDaemon() {
3483
3521
  const liveJobsOn = (shipId) => [...running.values()].filter((r) => r.shipId === shipId && r.mirror).map((r) => r.mirror).sort((a, b) => a.startedAt - b.startedAt);
3484
3522
  const approved = /* @__PURE__ */ new Map();
3485
3523
  const warnedUnapproved = /* @__PURE__ */ new Set();
3486
- const shipRunnerRef = (shipId) => doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
3524
+ const shipRunnerRef = (shipId) => {
3525
+ const session = sess(shipId);
3526
+ return doc7(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId);
3527
+ };
3487
3528
  const needsRefill = /* @__PURE__ */ new Set();
3488
3529
  let beating = false;
3489
3530
  async function heartbeat() {
@@ -3848,8 +3889,9 @@ async function startDaemon() {
3848
3889
  return;
3849
3890
  }
3850
3891
  const startedAt2 = Date.now();
3851
- tx.update(jobRef, { status: "running", runnerId: config2.runnerId, startedAt: startedAt2 });
3852
- claimed = { id: snap.id, ...snap.data(), status: "running", runnerId: config2.runnerId, startedAt: startedAt2 };
3892
+ const runnerId = sess(shipId).runnerId;
3893
+ tx.update(jobRef, { status: "running", runnerId, startedAt: startedAt2 });
3894
+ claimed = { id: snap.id, ...snap.data(), status: "running", runnerId, startedAt: startedAt2 };
3853
3895
  });
3854
3896
  return claimed;
3855
3897
  } catch (e) {
@@ -3966,7 +4008,7 @@ async function startDaemon() {
3966
4008
  doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
3967
4009
  );
3968
4010
  const fresh = snap.data();
3969
- if (!fresh || fresh.status !== "running" || fresh.runnerId !== config2.runnerId) {
4011
+ if (!fresh || fresh.status !== "running" || fresh.runnerId !== sess(shipId).runnerId) {
3970
4012
  log2(`Job ${job.id} is no longer this machine's to run \u2014 ending the session.`);
3971
4013
  slot.abort.abort();
3972
4014
  }
@@ -4115,7 +4157,13 @@ async function startDaemon() {
4115
4157
  } else if (sessionLimit) {
4116
4158
  const engineLabel = getEngine(engineId).label;
4117
4159
  const resetsAt = new Date(sessionLimit.resetsAt).toISOString();
4118
- await noteEngineLimit(sess(shipId).fb.db, shipId, engineId, sessionLimit, config2.runnerId);
4160
+ await noteEngineLimit(
4161
+ sess(shipId).fb.db,
4162
+ shipId,
4163
+ engineId,
4164
+ sessionLimit,
4165
+ sess(shipId).runnerId
4166
+ );
4119
4167
  await releaseJob(
4120
4168
  sess(shipId).fb.db,
4121
4169
  shipId,
@@ -4252,7 +4300,7 @@ async function startDaemon() {
4252
4300
  try {
4253
4301
  for (const shipId of serving) {
4254
4302
  await setDoc2(
4255
- doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
4303
+ shipRunnerRef(shipId),
4256
4304
  {
4257
4305
  status: "offline",
4258
4306
  lastSeenAt: now,
@@ -4520,6 +4568,16 @@ async function promptMultiSelect(options) {
4520
4568
  })
4521
4569
  );
4522
4570
  }
4571
+ async function promptSelect(options) {
4572
+ requireInteractive(options.message, options.flagHint);
4573
+ return unwrap(
4574
+ await clack.select({
4575
+ message: options.message,
4576
+ options: options.choices,
4577
+ initialValue: options.initialValue
4578
+ })
4579
+ );
4580
+ }
4523
4581
  async function promptConfirm(options) {
4524
4582
  if (assumeYes && options.yesFlagApplies !== false) return true;
4525
4583
  requireInteractive(options.message, "--yes");
@@ -4750,8 +4808,7 @@ import { collection as collection6, doc as doc8, getDoc as getDoc7, getDocs as g
4750
4808
  async function openShipSession(shipId) {
4751
4809
  const config2 = requireConfig();
4752
4810
  const fb = initFirebase(config2, `cli-${shipId}`);
4753
- const user = await signInToShip(fb, config2, shipId);
4754
- return { shipId, config: config2, fb, user };
4811
+ return { shipId, config: config2, fb, ...await signInToShip(fb, config2, shipId) };
4755
4812
  }
4756
4813
 
4757
4814
  // src/cli/commands/doctor.ts
@@ -4890,6 +4947,7 @@ async function checkShips(config2) {
4890
4947
  const checks = [];
4891
4948
  const engines = /* @__PURE__ */ new Set();
4892
4949
  let needsGithub = false;
4950
+ const manyIdentities = configRunnerIdentities(config2).length > 1;
4893
4951
  if (config2.ships.length === 0) {
4894
4952
  checks.push(
4895
4953
  fail("ships", "Ships", "This machine serves no Ships.", "Run `lumi-runner ship add` to pick one.")
@@ -4897,9 +4955,9 @@ async function checkShips(config2) {
4897
4955
  return { checks, engines, needsGithub };
4898
4956
  }
4899
4957
  for (const shipId of config2.ships) {
4900
- let fb;
4958
+ let session;
4901
4959
  try {
4902
- ({ fb } = await openShipSession(shipId));
4960
+ session = await openShipSession(shipId);
4903
4961
  checks.push(ok(`key:${shipId}`, `Ship ${shipId} \u2014 key`, "Runner key accepted."));
4904
4962
  } catch (e) {
4905
4963
  checks.push(
@@ -4914,7 +4972,7 @@ async function checkShips(config2) {
4914
4972
  }
4915
4973
  try {
4916
4974
  const snap = await getDoc7(
4917
- doc8(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId)
4975
+ doc8(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId)
4918
4976
  );
4919
4977
  if (!snap.exists()) {
4920
4978
  checks.push(
@@ -4935,7 +4993,13 @@ async function checkShips(config2) {
4935
4993
  )
4936
4994
  );
4937
4995
  } else {
4938
- checks.push(ok(`approval:${shipId}`, `Ship ${shipId} \u2014 approval`, "Approved by a captain."));
4996
+ checks.push(
4997
+ ok(
4998
+ `approval:${shipId}`,
4999
+ `Ship ${shipId} \u2014 approval`,
5000
+ manyIdentities ? `Approved by a captain (as ${session.runnerId}).` : "Approved by a captain."
5001
+ )
5002
+ );
4939
5003
  }
4940
5004
  } catch (e) {
4941
5005
  checks.push(
@@ -4949,7 +5013,7 @@ async function checkShips(config2) {
4949
5013
  }
4950
5014
  let agents = [];
4951
5015
  try {
4952
- const snap = await getDocs5(collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
5016
+ const snap = await getDocs5(collection6(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
4953
5017
  agents = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
4954
5018
  } catch {
4955
5019
  }
@@ -4958,7 +5022,7 @@ async function checkShips(config2) {
4958
5022
  for (const id of shipEngines) engines.add(id);
4959
5023
  if (agents.some((agent) => effectiveAgentTools(agent).github.enabled)) needsGithub = true;
4960
5024
  try {
4961
- const secrets = await loadRunnerSecrets(fb.db, shipId);
5025
+ const secrets = await loadRunnerSecrets(session.fb.db, shipId);
4962
5026
  const missing = [...new Set([...shipEngines].flatMap((id) => missingSecretsFor(id, secrets)))];
4963
5027
  checks.push(
4964
5028
  missing.length === 0 ? ok(`secrets:${shipId}`, `Ship ${shipId} \u2014 credentials`, "All required secrets are saved.") : fail(
@@ -4980,7 +5044,7 @@ async function checkShips(config2) {
4980
5044
  }
4981
5045
  try {
4982
5046
  const snap = await getDocs5(
4983
- collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
5047
+ collection6(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
4984
5048
  );
4985
5049
  const servers = snap.docs.map(
4986
5050
  (d) => ({ id: d.id, ...d.data() })
@@ -5001,7 +5065,14 @@ async function runDoctor() {
5001
5065
  );
5002
5066
  return report2(checks);
5003
5067
  }
5004
- checks.push(ok("config", "Configuration", `Runner ${config2.runnerId} on project ${config2.projectId}`));
5068
+ const identities = configRunnerIdentities(config2);
5069
+ checks.push(
5070
+ ok(
5071
+ "config",
5072
+ "Configuration",
5073
+ identities.length <= 1 ? `Runner ${config2.runnerId} on project ${config2.projectId}` : `${identities.length} runner identities on project ${config2.projectId} \u2014 ` + identities.map((i) => i.runnerId).join(", ")
5074
+ )
5075
+ );
5005
5076
  const progress = spinner2();
5006
5077
  progress.start("Running checks\u2026");
5007
5078
  const engines = /* @__PURE__ */ new Set([DEFAULT_ENGINE_ID]);
@@ -5081,21 +5152,37 @@ import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
5081
5152
  function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
5082
5153
  const approvedShips = approved.approvedShips ?? Object.keys(approved.shipKeys ?? {});
5083
5154
  const selectedShips = approved.selectedShips ?? [];
5155
+ const stampId = approved.runnerId || existing?.runnerId || "";
5156
+ const shipRunnerIds = { ...existing?.shipRunnerIds ?? {} };
5157
+ if (stampId) {
5158
+ for (const shipId of Object.keys(approved.shipKeys ?? {})) shipRunnerIds[shipId] = stampId;
5159
+ }
5084
5160
  const config2 = {
5085
5161
  ...existing,
5086
5162
  apiKey: approved.apiKey || existing?.apiKey || "",
5087
5163
  projectId: approved.projectId || fallbackProjectId,
5088
- runnerId: approved.runnerId || existing?.runnerId || "",
5164
+ runnerId: existing?.runnerId || approved.runnerId || "",
5089
5165
  shipKeys: { ...existing?.shipKeys ?? {}, ...approved.shipKeys ?? {} },
5090
- ships: selectedShips.length > 0 ? selectedShips : existing?.ships ?? [],
5166
+ ships: [.../* @__PURE__ */ new Set([...existing?.ships ?? [], ...selectedShips])],
5167
+ // Only when there is something to say, following `shipParallelJobs`: writing an empty map into
5168
+ // every config would be a key nobody asked for.
5169
+ ...Object.keys(shipRunnerIds).length > 0 ? { shipRunnerIds } : {},
5091
5170
  ...mcpUrl2 ? { mcpUrl: mcpUrl2 } : {}
5092
5171
  };
5172
+ const occupiedShips = (approved.occupiedShips ?? []).filter((id) => config2.ships.includes(id));
5093
5173
  return {
5094
5174
  config: config2,
5095
5175
  approvedShips,
5096
5176
  // Fall back to deriving it, so an older backend that omits the field reports accurately
5097
- // rather than crashing or claiming nothing is pending.
5098
- pendingShips: (approved.pendingShips ?? selectedShips.filter((id) => !approvedShips.includes(id))).filter((id) => config2.ships.includes(id))
5177
+ // rather than crashing or claiming nothing is pending. Occupied Ships are subtracted for the
5178
+ // web page's reason: they come back in BOTH sets, and printing both tells a captain they are
5179
+ // waiting on a captain.
5180
+ pendingShips: (approved.pendingShips ?? selectedShips.filter((id) => !approvedShips.includes(id))).filter((id) => config2.ships.includes(id) && !occupiedShips.includes(id)),
5181
+ occupiedShips,
5182
+ newIdentity: Boolean(
5183
+ approved.runnerId && existing?.runnerId && approved.runnerId !== existing.runnerId
5184
+ ),
5185
+ otherShips: (existing?.ships ?? []).filter((id) => !selectedShips.includes(id))
5099
5186
  };
5100
5187
  }
5101
5188
  function openBrowser(url) {
@@ -5118,11 +5205,13 @@ async function loginWithDeviceFlow(options) {
5118
5205
  const existingConfig = loadConfig();
5119
5206
  const projectId = options.project || existingConfig?.projectId || DEFAULT_PROJECT_ID;
5120
5207
  const baseUrl = functionsBaseUrlFor(projectId);
5208
+ const candidates = existingConfig ? configRunnerIdentities(existingConfig).map((i) => i.runnerId) : [];
5121
5209
  const start = await callPublicFunction(baseUrl, "startRunnerLogin", {
5122
5210
  hostname: os5.hostname(),
5123
- // Offer the id this machine already has: re-logging in should keep its identity, and with
5124
- // it the captain approvals it has already earned.
5125
- ...existingConfig?.runnerId ? { runnerId: existingConfig.runnerId } : {}
5211
+ // The singular field stays, always: it is what an older deployment reads, and dropping it
5212
+ // would silently turn every re-login into a new enrolment for the length of a rollout.
5213
+ ...existingConfig?.runnerId ? { runnerId: existingConfig.runnerId } : {},
5214
+ ...candidates.length > 0 ? { runnerIds: candidates } : {}
5126
5215
  });
5127
5216
  if (!options.noBrowser) openBrowser(start.verificationUrl);
5128
5217
  say.note(
@@ -5160,7 +5249,7 @@ Code: ${pc.bold(start.displayCode)}`,
5160
5249
  throw new CliError("The approval window expired. Run `lumi-runner login` again.");
5161
5250
  }
5162
5251
  progress.stop("Approved.");
5163
- const { config: config2, approvedShips, pendingShips } = buildLoginResult(
5252
+ const { config: config2, approvedShips, pendingShips, occupiedShips, newIdentity, otherShips } = buildLoginResult(
5164
5253
  approved,
5165
5254
  existingConfig,
5166
5255
  projectId,
@@ -5178,7 +5267,18 @@ Code: ${pc.bold(start.displayCode)}`,
5178
5267
  const first = approvedShips[0];
5179
5268
  const uid2 = first ? (await signInWithCustomToken2(fb.auth, (await exchange(config2, first)).customToken)).user.uid : "(no Ship approved yet)";
5180
5269
  saveConfig(config2);
5181
- return report3({ runnerId: config2.runnerId, uid: uid2, ships: config2.ships, approvedShips, pendingShips });
5270
+ return report3({
5271
+ // What THIS login did, which on a second account is not the machine's primary.
5272
+ runnerId: approved.runnerId || config2.runnerId,
5273
+ primaryRunnerId: config2.runnerId,
5274
+ newIdentity,
5275
+ uid: uid2,
5276
+ ships: config2.ships,
5277
+ approvedShips,
5278
+ pendingShips,
5279
+ occupiedShips,
5280
+ otherShips
5281
+ });
5182
5282
  }
5183
5283
  async function exchange(config2, shipId) {
5184
5284
  return callPublicFunction(
@@ -5196,30 +5296,37 @@ async function loginWithKey(options) {
5196
5296
  throw new CliError("That does not look like a runner key. Expected `crewrunner_<shipId>_<secret>`.");
5197
5297
  }
5198
5298
  const existing = loadConfig();
5299
+ const session = await callPublicFunction(
5300
+ functionsBaseUrlFor(options.project),
5301
+ "exchangeRunnerKey",
5302
+ { key: options.key }
5303
+ );
5304
+ const runnerId = session.runnerId || options.runnerId || existing?.runnerId || "";
5199
5305
  const config2 = {
5200
5306
  ...existing,
5201
5307
  apiKey: options.apiKey,
5202
5308
  projectId: options.project,
5203
- runnerId: options.runnerId || existing?.runnerId || `runner-${os5.hostname().toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 12)}-${Math.random().toString(36).slice(2, 6)}`,
5309
+ // The FIRST identity keeps the machine's name, exactly as in `buildLoginResult`: a key issued
5310
+ // by another account names another machine, and adopting it would strand every Ship this one
5311
+ // already serves.
5312
+ runnerId: existing?.runnerId || runnerId,
5204
5313
  shipKeys: { ...existing?.shipKeys ?? {}, [parsed]: options.key },
5205
5314
  ships: [.../* @__PURE__ */ new Set([...existing?.ships ?? [], parsed])],
5315
+ ...runnerId ? { shipRunnerIds: { ...existing?.shipRunnerIds ?? {}, [parsed]: runnerId } } : {},
5206
5316
  ...options.mcpUrl ? { mcpUrl: options.mcpUrl } : {}
5207
5317
  };
5208
- const session = await callPublicFunction(
5209
- functionsBaseUrlFor(config2.projectId),
5210
- "exchangeRunnerKey",
5211
- { key: options.key }
5212
- );
5213
- config2.runnerId = session.runnerId;
5214
5318
  const fb = initFirebase(config2);
5215
5319
  const cred = await signInWithCustomToken2(fb.auth, session.customToken);
5216
5320
  saveConfig(config2);
5217
5321
  return report3({
5218
- runnerId: config2.runnerId,
5322
+ runnerId,
5323
+ primaryRunnerId: config2.runnerId,
5324
+ newIdentity: Boolean(runnerId && config2.runnerId && runnerId !== config2.runnerId),
5219
5325
  uid: cred.user.uid,
5220
5326
  ships: config2.ships,
5221
5327
  approvedShips: [parsed],
5222
- pendingShips: []
5328
+ pendingShips: [],
5329
+ otherShips: (existing?.ships ?? []).filter((id) => id !== parsed)
5223
5330
  });
5224
5331
  }
5225
5332
  function parseKeyShipId(key) {
@@ -5232,12 +5339,27 @@ function report3(result) {
5232
5339
  return 0;
5233
5340
  }
5234
5341
  say.success(`Connected as ${result.uid} \u2014 runner ${result.runnerId}`);
5342
+ if (result.newIdentity && result.primaryRunnerId) {
5343
+ say.info(
5344
+ `That is a new identity for this machine \u2014 it is still runner ${result.primaryRunnerId} on the Ships it already served.`
5345
+ );
5346
+ }
5235
5347
  if (result.ships.length > 0) say.info(`Serving: ${result.ships.join(", ")}`);
5236
5348
  if (result.pendingShips && result.pendingShips.length > 0) {
5237
5349
  say.warn(
5238
5350
  `Awaiting captain approval on: ${result.pendingShips.join(", ")} \u2014 a captain approves this machine on the Ship's Daemons page.`
5239
5351
  );
5240
5352
  }
5353
+ if (result.occupiedShips && result.occupiedShips.length > 0) {
5354
+ say.warn(
5355
+ `Already running another daemon: ${result.occupiedShips.join(", ")} \u2014 a Ship runs one. Remove the old machine on that Ship's Daemons page, then approve this one there.`
5356
+ );
5357
+ }
5358
+ if (result.otherShips && result.otherShips.length > 0) {
5359
+ say.info(
5360
+ `Still serving ${result.otherShips.join(", ")} \u2014 this approval did not mention them. \`lumi-runner ship remove <shipId>\` to stop.`
5361
+ );
5362
+ }
5241
5363
  if (serviceStatus().state === "running") {
5242
5364
  say.info("Restart the daemon for this to take effect: `lumi-runner service restart`.");
5243
5365
  }
@@ -5455,13 +5577,42 @@ async function runSetup(options) {
5455
5577
  const moved = migrateLegacyDir();
5456
5578
  if (moved?.migrated) say.info(`Moved your runner config from ${moved.from} to ${moved.to}.`);
5457
5579
  const existing = loadConfig();
5458
- const reuse = Object.keys(existing?.shipKeys ?? {}).length > 0 && await promptConfirm({
5459
- message: `This machine is already connected as ${existing?.runnerId}. Keep its Ship keys?`,
5460
- initialValue: true,
5461
- yesFlagApplies: false
5462
- });
5463
- if (!reuse) {
5580
+ if (!existing || Object.keys(existing.shipKeys ?? {}).length === 0) {
5464
5581
  await runLogin(options);
5582
+ } else {
5583
+ const identities = configRunnerIdentities(existing);
5584
+ const who = identities.length === 1 ? `as ${identities[0].runnerId}` : `under ${identities.length} accounts (${identities.map((i) => i.runnerId).join(", ")})`;
5585
+ const action2 = await promptSelect({
5586
+ message: `This machine is already connected ${who}, serving ${existing.ships.join(", ") || "no Ships"}. What now?`,
5587
+ choices: [
5588
+ { value: "keep", label: "Nothing \u2014 keep what it has", hint: "re-check it and restart the daemon" },
5589
+ {
5590
+ value: "add",
5591
+ label: "Add Ships, from this or another account",
5592
+ hint: "opens the browser; everything it already serves is kept"
5593
+ },
5594
+ {
5595
+ value: "reset",
5596
+ label: "Disconnect and start over",
5597
+ hint: "forgets every Ship key on this machine"
5598
+ }
5599
+ ],
5600
+ initialValue: "keep",
5601
+ flagHint: "lumi-runner login"
5602
+ });
5603
+ if (action2 === "add") {
5604
+ await runLogin(options);
5605
+ } else if (action2 === "reset") {
5606
+ const sure = await promptConfirm({
5607
+ message: `Forget all ${Object.keys(existing.shipKeys ?? {}).length} Ship key(s) on this machine and connect from scratch?`,
5608
+ initialValue: false,
5609
+ yesFlagApplies: false
5610
+ // destructive: `--yes` must not answer this one
5611
+ });
5612
+ if (!sure) throw new CliError("Cancelled.", 130);
5613
+ saveConfig(forgetAllShips(existing));
5614
+ await runLogin(options);
5615
+ }
5465
5616
  }
5466
5617
  const config2 = loadConfig();
5467
5618
  if (!config2) throw new CliError("Login did not complete.");
@@ -5532,12 +5683,13 @@ async function runStatus() {
5532
5683
  progress.start("Reading Ship state\u2026");
5533
5684
  const ships = [];
5534
5685
  for (const shipId of config2.ships) {
5535
- let fb;
5686
+ let session;
5536
5687
  try {
5537
- ({ fb } = await openShipSession(shipId));
5688
+ session = await openShipSession(shipId);
5538
5689
  } catch {
5539
5690
  ships.push({
5540
5691
  shipId,
5692
+ runnerId: shipRunnerId(config2, shipId),
5541
5693
  enrolled: false,
5542
5694
  approved: false,
5543
5695
  online: false,
@@ -5550,8 +5702,8 @@ async function runStatus() {
5550
5702
  });
5551
5703
  continue;
5552
5704
  }
5553
- const shipRef = doc10(fb.db, COLLECTIONS.ships, shipId);
5554
- const mirrorSnap = await getDoc9(doc10(shipRef, COLLECTIONS.runners, config2.runnerId));
5705
+ const shipRef = doc10(session.fb.db, COLLECTIONS.ships, shipId);
5706
+ const mirrorSnap = await getDoc9(doc10(shipRef, COLLECTIONS.runners, session.runnerId));
5555
5707
  const mirror = mirrorSnap.data();
5556
5708
  let queued = 0;
5557
5709
  try {
@@ -5569,6 +5721,7 @@ async function runStatus() {
5569
5721
  const engineLimits = limitsSnap.docs.map((d) => ({ id: d.id, ...d.data() })).filter((l) => isEngineLimited(l, now));
5570
5722
  ships.push({
5571
5723
  shipId,
5724
+ runnerId: session.runnerId,
5572
5725
  enrolled: mirrorSnap.exists(),
5573
5726
  approved: mirror?.approved === true,
5574
5727
  online: mirror?.status === "online" && Date.now() - (mirror?.lastSeenAt ?? 0) < RUNNER_OFFLINE_AFTER_MS,
@@ -5581,11 +5734,19 @@ async function runStatus() {
5581
5734
  }
5582
5735
  progress.stop("");
5583
5736
  const service2 = serviceStatus();
5737
+ const identities = runnerIdentities(
5738
+ config2.runnerId,
5739
+ ships.map((s) => ({ shipId: s.shipId, runnerId: s.runnerId }))
5740
+ );
5584
5741
  if (isJson()) {
5585
5742
  emitJson({
5586
5743
  version: RUNNER_VERSION,
5587
5744
  connected: true,
5745
+ // KEPT, and kept meaning the same thing: this is a published contract, and on the machine
5746
+ // that has one identity — every machine before this release — the primary IS that identity.
5588
5747
  runnerId: config2.runnerId,
5748
+ /** Every name this machine answers to, and the Ships each one covers. */
5749
+ runnerIds: identities,
5589
5750
  projectId: config2.projectId,
5590
5751
  service: { state: service2.state, detail: service2.detail },
5591
5752
  ships
@@ -5593,7 +5754,16 @@ async function runStatus() {
5593
5754
  return 0;
5594
5755
  }
5595
5756
  say.line("");
5596
- say.line(` ${pc.bold("Runner")} ${config2.runnerId} ${pc.dim(`v${RUNNER_VERSION}`)}`);
5757
+ if (identities.length <= 1) {
5758
+ say.line(` ${pc.bold("Runner")} ${config2.runnerId} ${pc.dim(`v${RUNNER_VERSION}`)}`);
5759
+ } else {
5760
+ say.line(` ${pc.bold("Runner")} ${identities.length} identities ${pc.dim(`v${RUNNER_VERSION}`)}`);
5761
+ for (const identity of identities) {
5762
+ say.line(
5763
+ ` ${identity.runnerId} ${pc.dim(identity.shipIds.join(", ") || "no Ships")}`
5764
+ );
5765
+ }
5766
+ }
5597
5767
  say.line(` ${pc.bold("Project")} ${config2.projectId}`);
5598
5768
  say.line(` ${pc.bold("Service")} ${service2.detail}`);
5599
5769
  say.line("");
@@ -5604,6 +5774,9 @@ async function runStatus() {
5604
5774
  for (const ship2 of ships) {
5605
5775
  const state = ship2.unreachable ? pc.red("no access") : !ship2.enrolled ? pc.dim("not enrolled") : !ship2.approved ? pc.yellow("awaiting approval") : ship2.online ? pc.green("online") : pc.red("offline");
5606
5776
  say.line(` ${pc.bold(ship2.shipId)} ${state}`);
5777
+ if (identities.length > 1 && ship2.runnerId) {
5778
+ say.line(` ${pc.dim(`runner ${ship2.runnerId}`)}`);
5779
+ }
5607
5780
  if (ship2.unreachable) {
5608
5781
  say.line(` ${pc.dim("this machine\u2019s key for that Ship was revoked \u2014 re-approve it on the Daemons page")}`);
5609
5782
  continue;
@@ -5750,7 +5923,7 @@ function action(handler) {
5750
5923
  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(
5751
5924
  async (options) => runSetup({ project: options.project, noBrowser: options.browser === false })
5752
5925
  ));
5753
- 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(
5926
+ 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>", "runner id to fall back on if the backend does not name one (only with --key)").option("--mcp-url <url>", "override the Workspace MCP endpoint").action(
5754
5927
  action(
5755
5928
  async (options) => runLogin({
5756
5929
  project: options.project,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi.ai/runner",
3
- "version": "0.6.4",
3
+ "version": "0.8.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.",