@lumi.ai/runner 0.6.4 → 0.7.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 +213 -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
@@ -756,8 +756,35 @@ function forgetShip(config2, shipId) {
756
756
  if (next.allowLocalMcp) {
757
757
  next.allowLocalMcp = next.allowLocalMcp.filter((id) => id !== shipId);
758
758
  }
759
+ if (next.shipRunnerIds) {
760
+ const ids = { ...next.shipRunnerIds };
761
+ delete ids[shipId];
762
+ next.shipRunnerIds = ids;
763
+ }
759
764
  return next;
760
765
  }
766
+ function forgetAllShips(config2) {
767
+ const everything = [.../* @__PURE__ */ new Set([...config2.ships, ...Object.keys(config2.shipKeys ?? {})])];
768
+ return everything.reduce(forgetShip, config2);
769
+ }
770
+ function shipRunnerId(config2, shipId) {
771
+ return config2.shipRunnerIds?.[shipId] || config2.runnerId || "";
772
+ }
773
+ function runnerIdentities(primary, pairs) {
774
+ const byId = /* @__PURE__ */ new Map();
775
+ if (primary) byId.set(primary, []);
776
+ for (const { shipId, runnerId } of pairs) {
777
+ if (!runnerId) continue;
778
+ byId.set(runnerId, [...byId.get(runnerId) ?? [], shipId]);
779
+ }
780
+ 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));
781
+ }
782
+ function configRunnerIdentities(config2) {
783
+ return runnerIdentities(
784
+ config2.runnerId,
785
+ config2.ships.map((shipId) => ({ shipId, runnerId: shipRunnerId(config2, shipId) }))
786
+ );
787
+ }
761
788
  function allowsLocalMcp(config2, shipId) {
762
789
  return Array.isArray(config2.allowLocalMcp) && config2.allowLocalMcp.includes(shipId);
763
790
  }
@@ -774,7 +801,7 @@ function mcpUrl(config2) {
774
801
  }
775
802
 
776
803
  // src/version.ts
777
- var RUNNER_VERSION = true ? "0.6.4" : "0.0.0-dev";
804
+ var RUNNER_VERSION = true ? "0.7.0" : "0.0.0-dev";
778
805
 
779
806
  // src/auth.ts
780
807
  import { signInWithCustomToken } from "firebase/auth";
@@ -869,7 +896,7 @@ async function signInToShip(fb, config2, shipId) {
869
896
  );
870
897
  }
871
898
  const cred = await signInWithCustomToken(fb.auth, session.customToken);
872
- return cred.user;
899
+ return { user: cred.user, runnerId: session.runnerId || shipRunnerId(config2, shipId) };
873
900
  }
874
901
  async function openShipSessions(config2, build) {
875
902
  const sessions = [];
@@ -877,8 +904,7 @@ async function openShipSessions(config2, build) {
877
904
  for (const shipId of config2.ships) {
878
905
  const fb = build(shipId);
879
906
  try {
880
- const user = await signInToShip(fb, config2, shipId);
881
- sessions.push({ shipId, fb, user });
907
+ sessions.push({ shipId, fb, ...await signInToShip(fb, config2, shipId) });
882
908
  } catch (e) {
883
909
  failures.push({
884
910
  shipId,
@@ -3461,8 +3487,9 @@ async function startDaemon() {
3461
3487
  return s;
3462
3488
  };
3463
3489
  const serving = new Set(sessions.map((s) => s.shipId));
3490
+ const bannerIds = [...new Set(sessions.map((s) => s.runnerId))];
3464
3491
  console.log(
3465
- `Runner ${config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}`
3492
+ 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
3493
  );
3467
3494
  const logLines = [];
3468
3495
  const log2 = (line) => {
@@ -3483,7 +3510,10 @@ async function startDaemon() {
3483
3510
  const liveJobsOn = (shipId) => [...running.values()].filter((r) => r.shipId === shipId && r.mirror).map((r) => r.mirror).sort((a, b) => a.startedAt - b.startedAt);
3484
3511
  const approved = /* @__PURE__ */ new Map();
3485
3512
  const warnedUnapproved = /* @__PURE__ */ new Set();
3486
- const shipRunnerRef = (shipId) => doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
3513
+ const shipRunnerRef = (shipId) => {
3514
+ const session = sess(shipId);
3515
+ return doc7(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId);
3516
+ };
3487
3517
  const needsRefill = /* @__PURE__ */ new Set();
3488
3518
  let beating = false;
3489
3519
  async function heartbeat() {
@@ -3848,8 +3878,9 @@ async function startDaemon() {
3848
3878
  return;
3849
3879
  }
3850
3880
  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 };
3881
+ const runnerId = sess(shipId).runnerId;
3882
+ tx.update(jobRef, { status: "running", runnerId, startedAt: startedAt2 });
3883
+ claimed = { id: snap.id, ...snap.data(), status: "running", runnerId, startedAt: startedAt2 };
3853
3884
  });
3854
3885
  return claimed;
3855
3886
  } catch (e) {
@@ -3966,7 +3997,7 @@ async function startDaemon() {
3966
3997
  doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
3967
3998
  );
3968
3999
  const fresh = snap.data();
3969
- if (!fresh || fresh.status !== "running" || fresh.runnerId !== config2.runnerId) {
4000
+ if (!fresh || fresh.status !== "running" || fresh.runnerId !== sess(shipId).runnerId) {
3970
4001
  log2(`Job ${job.id} is no longer this machine's to run \u2014 ending the session.`);
3971
4002
  slot.abort.abort();
3972
4003
  }
@@ -4115,7 +4146,13 @@ async function startDaemon() {
4115
4146
  } else if (sessionLimit) {
4116
4147
  const engineLabel = getEngine(engineId).label;
4117
4148
  const resetsAt = new Date(sessionLimit.resetsAt).toISOString();
4118
- await noteEngineLimit(sess(shipId).fb.db, shipId, engineId, sessionLimit, config2.runnerId);
4149
+ await noteEngineLimit(
4150
+ sess(shipId).fb.db,
4151
+ shipId,
4152
+ engineId,
4153
+ sessionLimit,
4154
+ sess(shipId).runnerId
4155
+ );
4119
4156
  await releaseJob(
4120
4157
  sess(shipId).fb.db,
4121
4158
  shipId,
@@ -4252,7 +4289,7 @@ async function startDaemon() {
4252
4289
  try {
4253
4290
  for (const shipId of serving) {
4254
4291
  await setDoc2(
4255
- doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
4292
+ shipRunnerRef(shipId),
4256
4293
  {
4257
4294
  status: "offline",
4258
4295
  lastSeenAt: now,
@@ -4520,6 +4557,16 @@ async function promptMultiSelect(options) {
4520
4557
  })
4521
4558
  );
4522
4559
  }
4560
+ async function promptSelect(options) {
4561
+ requireInteractive(options.message, options.flagHint);
4562
+ return unwrap(
4563
+ await clack.select({
4564
+ message: options.message,
4565
+ options: options.choices,
4566
+ initialValue: options.initialValue
4567
+ })
4568
+ );
4569
+ }
4523
4570
  async function promptConfirm(options) {
4524
4571
  if (assumeYes && options.yesFlagApplies !== false) return true;
4525
4572
  requireInteractive(options.message, "--yes");
@@ -4750,8 +4797,7 @@ import { collection as collection6, doc as doc8, getDoc as getDoc7, getDocs as g
4750
4797
  async function openShipSession(shipId) {
4751
4798
  const config2 = requireConfig();
4752
4799
  const fb = initFirebase(config2, `cli-${shipId}`);
4753
- const user = await signInToShip(fb, config2, shipId);
4754
- return { shipId, config: config2, fb, user };
4800
+ return { shipId, config: config2, fb, ...await signInToShip(fb, config2, shipId) };
4755
4801
  }
4756
4802
 
4757
4803
  // src/cli/commands/doctor.ts
@@ -4890,6 +4936,7 @@ async function checkShips(config2) {
4890
4936
  const checks = [];
4891
4937
  const engines = /* @__PURE__ */ new Set();
4892
4938
  let needsGithub = false;
4939
+ const manyIdentities = configRunnerIdentities(config2).length > 1;
4893
4940
  if (config2.ships.length === 0) {
4894
4941
  checks.push(
4895
4942
  fail("ships", "Ships", "This machine serves no Ships.", "Run `lumi-runner ship add` to pick one.")
@@ -4897,9 +4944,9 @@ async function checkShips(config2) {
4897
4944
  return { checks, engines, needsGithub };
4898
4945
  }
4899
4946
  for (const shipId of config2.ships) {
4900
- let fb;
4947
+ let session;
4901
4948
  try {
4902
- ({ fb } = await openShipSession(shipId));
4949
+ session = await openShipSession(shipId);
4903
4950
  checks.push(ok(`key:${shipId}`, `Ship ${shipId} \u2014 key`, "Runner key accepted."));
4904
4951
  } catch (e) {
4905
4952
  checks.push(
@@ -4914,7 +4961,7 @@ async function checkShips(config2) {
4914
4961
  }
4915
4962
  try {
4916
4963
  const snap = await getDoc7(
4917
- doc8(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId)
4964
+ doc8(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId)
4918
4965
  );
4919
4966
  if (!snap.exists()) {
4920
4967
  checks.push(
@@ -4935,7 +4982,13 @@ async function checkShips(config2) {
4935
4982
  )
4936
4983
  );
4937
4984
  } else {
4938
- checks.push(ok(`approval:${shipId}`, `Ship ${shipId} \u2014 approval`, "Approved by a captain."));
4985
+ checks.push(
4986
+ ok(
4987
+ `approval:${shipId}`,
4988
+ `Ship ${shipId} \u2014 approval`,
4989
+ manyIdentities ? `Approved by a captain (as ${session.runnerId}).` : "Approved by a captain."
4990
+ )
4991
+ );
4939
4992
  }
4940
4993
  } catch (e) {
4941
4994
  checks.push(
@@ -4949,7 +5002,7 @@ async function checkShips(config2) {
4949
5002
  }
4950
5003
  let agents = [];
4951
5004
  try {
4952
- const snap = await getDocs5(collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
5005
+ const snap = await getDocs5(collection6(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
4953
5006
  agents = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
4954
5007
  } catch {
4955
5008
  }
@@ -4958,7 +5011,7 @@ async function checkShips(config2) {
4958
5011
  for (const id of shipEngines) engines.add(id);
4959
5012
  if (agents.some((agent) => effectiveAgentTools(agent).github.enabled)) needsGithub = true;
4960
5013
  try {
4961
- const secrets = await loadRunnerSecrets(fb.db, shipId);
5014
+ const secrets = await loadRunnerSecrets(session.fb.db, shipId);
4962
5015
  const missing = [...new Set([...shipEngines].flatMap((id) => missingSecretsFor(id, secrets)))];
4963
5016
  checks.push(
4964
5017
  missing.length === 0 ? ok(`secrets:${shipId}`, `Ship ${shipId} \u2014 credentials`, "All required secrets are saved.") : fail(
@@ -4980,7 +5033,7 @@ async function checkShips(config2) {
4980
5033
  }
4981
5034
  try {
4982
5035
  const snap = await getDocs5(
4983
- collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
5036
+ collection6(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
4984
5037
  );
4985
5038
  const servers = snap.docs.map(
4986
5039
  (d) => ({ id: d.id, ...d.data() })
@@ -5001,7 +5054,14 @@ async function runDoctor() {
5001
5054
  );
5002
5055
  return report2(checks);
5003
5056
  }
5004
- checks.push(ok("config", "Configuration", `Runner ${config2.runnerId} on project ${config2.projectId}`));
5057
+ const identities = configRunnerIdentities(config2);
5058
+ checks.push(
5059
+ ok(
5060
+ "config",
5061
+ "Configuration",
5062
+ 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(", ")
5063
+ )
5064
+ );
5005
5065
  const progress = spinner2();
5006
5066
  progress.start("Running checks\u2026");
5007
5067
  const engines = /* @__PURE__ */ new Set([DEFAULT_ENGINE_ID]);
@@ -5081,21 +5141,37 @@ import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
5081
5141
  function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
5082
5142
  const approvedShips = approved.approvedShips ?? Object.keys(approved.shipKeys ?? {});
5083
5143
  const selectedShips = approved.selectedShips ?? [];
5144
+ const stampId = approved.runnerId || existing?.runnerId || "";
5145
+ const shipRunnerIds = { ...existing?.shipRunnerIds ?? {} };
5146
+ if (stampId) {
5147
+ for (const shipId of Object.keys(approved.shipKeys ?? {})) shipRunnerIds[shipId] = stampId;
5148
+ }
5084
5149
  const config2 = {
5085
5150
  ...existing,
5086
5151
  apiKey: approved.apiKey || existing?.apiKey || "",
5087
5152
  projectId: approved.projectId || fallbackProjectId,
5088
- runnerId: approved.runnerId || existing?.runnerId || "",
5153
+ runnerId: existing?.runnerId || approved.runnerId || "",
5089
5154
  shipKeys: { ...existing?.shipKeys ?? {}, ...approved.shipKeys ?? {} },
5090
- ships: selectedShips.length > 0 ? selectedShips : existing?.ships ?? [],
5155
+ ships: [.../* @__PURE__ */ new Set([...existing?.ships ?? [], ...selectedShips])],
5156
+ // Only when there is something to say, following `shipParallelJobs`: writing an empty map into
5157
+ // every config would be a key nobody asked for.
5158
+ ...Object.keys(shipRunnerIds).length > 0 ? { shipRunnerIds } : {},
5091
5159
  ...mcpUrl2 ? { mcpUrl: mcpUrl2 } : {}
5092
5160
  };
5161
+ const occupiedShips = (approved.occupiedShips ?? []).filter((id) => config2.ships.includes(id));
5093
5162
  return {
5094
5163
  config: config2,
5095
5164
  approvedShips,
5096
5165
  // 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))
5166
+ // rather than crashing or claiming nothing is pending. Occupied Ships are subtracted for the
5167
+ // web page's reason: they come back in BOTH sets, and printing both tells a captain they are
5168
+ // waiting on a captain.
5169
+ pendingShips: (approved.pendingShips ?? selectedShips.filter((id) => !approvedShips.includes(id))).filter((id) => config2.ships.includes(id) && !occupiedShips.includes(id)),
5170
+ occupiedShips,
5171
+ newIdentity: Boolean(
5172
+ approved.runnerId && existing?.runnerId && approved.runnerId !== existing.runnerId
5173
+ ),
5174
+ otherShips: (existing?.ships ?? []).filter((id) => !selectedShips.includes(id))
5099
5175
  };
5100
5176
  }
5101
5177
  function openBrowser(url) {
@@ -5118,11 +5194,13 @@ async function loginWithDeviceFlow(options) {
5118
5194
  const existingConfig = loadConfig();
5119
5195
  const projectId = options.project || existingConfig?.projectId || DEFAULT_PROJECT_ID;
5120
5196
  const baseUrl = functionsBaseUrlFor(projectId);
5197
+ const candidates = existingConfig ? configRunnerIdentities(existingConfig).map((i) => i.runnerId) : [];
5121
5198
  const start = await callPublicFunction(baseUrl, "startRunnerLogin", {
5122
5199
  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 } : {}
5200
+ // The singular field stays, always: it is what an older deployment reads, and dropping it
5201
+ // would silently turn every re-login into a new enrolment for the length of a rollout.
5202
+ ...existingConfig?.runnerId ? { runnerId: existingConfig.runnerId } : {},
5203
+ ...candidates.length > 0 ? { runnerIds: candidates } : {}
5126
5204
  });
5127
5205
  if (!options.noBrowser) openBrowser(start.verificationUrl);
5128
5206
  say.note(
@@ -5160,7 +5238,7 @@ Code: ${pc.bold(start.displayCode)}`,
5160
5238
  throw new CliError("The approval window expired. Run `lumi-runner login` again.");
5161
5239
  }
5162
5240
  progress.stop("Approved.");
5163
- const { config: config2, approvedShips, pendingShips } = buildLoginResult(
5241
+ const { config: config2, approvedShips, pendingShips, occupiedShips, newIdentity, otherShips } = buildLoginResult(
5164
5242
  approved,
5165
5243
  existingConfig,
5166
5244
  projectId,
@@ -5178,7 +5256,18 @@ Code: ${pc.bold(start.displayCode)}`,
5178
5256
  const first = approvedShips[0];
5179
5257
  const uid2 = first ? (await signInWithCustomToken2(fb.auth, (await exchange(config2, first)).customToken)).user.uid : "(no Ship approved yet)";
5180
5258
  saveConfig(config2);
5181
- return report3({ runnerId: config2.runnerId, uid: uid2, ships: config2.ships, approvedShips, pendingShips });
5259
+ return report3({
5260
+ // What THIS login did, which on a second account is not the machine's primary.
5261
+ runnerId: approved.runnerId || config2.runnerId,
5262
+ primaryRunnerId: config2.runnerId,
5263
+ newIdentity,
5264
+ uid: uid2,
5265
+ ships: config2.ships,
5266
+ approvedShips,
5267
+ pendingShips,
5268
+ occupiedShips,
5269
+ otherShips
5270
+ });
5182
5271
  }
5183
5272
  async function exchange(config2, shipId) {
5184
5273
  return callPublicFunction(
@@ -5196,30 +5285,37 @@ async function loginWithKey(options) {
5196
5285
  throw new CliError("That does not look like a runner key. Expected `crewrunner_<shipId>_<secret>`.");
5197
5286
  }
5198
5287
  const existing = loadConfig();
5288
+ const session = await callPublicFunction(
5289
+ functionsBaseUrlFor(options.project),
5290
+ "exchangeRunnerKey",
5291
+ { key: options.key }
5292
+ );
5293
+ const runnerId = session.runnerId || options.runnerId || existing?.runnerId || "";
5199
5294
  const config2 = {
5200
5295
  ...existing,
5201
5296
  apiKey: options.apiKey,
5202
5297
  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)}`,
5298
+ // The FIRST identity keeps the machine's name, exactly as in `buildLoginResult`: a key issued
5299
+ // by another account names another machine, and adopting it would strand every Ship this one
5300
+ // already serves.
5301
+ runnerId: existing?.runnerId || runnerId,
5204
5302
  shipKeys: { ...existing?.shipKeys ?? {}, [parsed]: options.key },
5205
5303
  ships: [.../* @__PURE__ */ new Set([...existing?.ships ?? [], parsed])],
5304
+ ...runnerId ? { shipRunnerIds: { ...existing?.shipRunnerIds ?? {}, [parsed]: runnerId } } : {},
5206
5305
  ...options.mcpUrl ? { mcpUrl: options.mcpUrl } : {}
5207
5306
  };
5208
- const session = await callPublicFunction(
5209
- functionsBaseUrlFor(config2.projectId),
5210
- "exchangeRunnerKey",
5211
- { key: options.key }
5212
- );
5213
- config2.runnerId = session.runnerId;
5214
5307
  const fb = initFirebase(config2);
5215
5308
  const cred = await signInWithCustomToken2(fb.auth, session.customToken);
5216
5309
  saveConfig(config2);
5217
5310
  return report3({
5218
- runnerId: config2.runnerId,
5311
+ runnerId,
5312
+ primaryRunnerId: config2.runnerId,
5313
+ newIdentity: Boolean(runnerId && config2.runnerId && runnerId !== config2.runnerId),
5219
5314
  uid: cred.user.uid,
5220
5315
  ships: config2.ships,
5221
5316
  approvedShips: [parsed],
5222
- pendingShips: []
5317
+ pendingShips: [],
5318
+ otherShips: (existing?.ships ?? []).filter((id) => id !== parsed)
5223
5319
  });
5224
5320
  }
5225
5321
  function parseKeyShipId(key) {
@@ -5232,12 +5328,27 @@ function report3(result) {
5232
5328
  return 0;
5233
5329
  }
5234
5330
  say.success(`Connected as ${result.uid} \u2014 runner ${result.runnerId}`);
5331
+ if (result.newIdentity && result.primaryRunnerId) {
5332
+ say.info(
5333
+ `That is a new identity for this machine \u2014 it is still runner ${result.primaryRunnerId} on the Ships it already served.`
5334
+ );
5335
+ }
5235
5336
  if (result.ships.length > 0) say.info(`Serving: ${result.ships.join(", ")}`);
5236
5337
  if (result.pendingShips && result.pendingShips.length > 0) {
5237
5338
  say.warn(
5238
5339
  `Awaiting captain approval on: ${result.pendingShips.join(", ")} \u2014 a captain approves this machine on the Ship's Daemons page.`
5239
5340
  );
5240
5341
  }
5342
+ if (result.occupiedShips && result.occupiedShips.length > 0) {
5343
+ say.warn(
5344
+ `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.`
5345
+ );
5346
+ }
5347
+ if (result.otherShips && result.otherShips.length > 0) {
5348
+ say.info(
5349
+ `Still serving ${result.otherShips.join(", ")} \u2014 this approval did not mention them. \`lumi-runner ship remove <shipId>\` to stop.`
5350
+ );
5351
+ }
5241
5352
  if (serviceStatus().state === "running") {
5242
5353
  say.info("Restart the daemon for this to take effect: `lumi-runner service restart`.");
5243
5354
  }
@@ -5455,13 +5566,42 @@ async function runSetup(options) {
5455
5566
  const moved = migrateLegacyDir();
5456
5567
  if (moved?.migrated) say.info(`Moved your runner config from ${moved.from} to ${moved.to}.`);
5457
5568
  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) {
5569
+ if (!existing || Object.keys(existing.shipKeys ?? {}).length === 0) {
5464
5570
  await runLogin(options);
5571
+ } else {
5572
+ const identities = configRunnerIdentities(existing);
5573
+ const who = identities.length === 1 ? `as ${identities[0].runnerId}` : `under ${identities.length} accounts (${identities.map((i) => i.runnerId).join(", ")})`;
5574
+ const action2 = await promptSelect({
5575
+ message: `This machine is already connected ${who}, serving ${existing.ships.join(", ") || "no Ships"}. What now?`,
5576
+ choices: [
5577
+ { value: "keep", label: "Nothing \u2014 keep what it has", hint: "re-check it and restart the daemon" },
5578
+ {
5579
+ value: "add",
5580
+ label: "Add Ships, from this or another account",
5581
+ hint: "opens the browser; everything it already serves is kept"
5582
+ },
5583
+ {
5584
+ value: "reset",
5585
+ label: "Disconnect and start over",
5586
+ hint: "forgets every Ship key on this machine"
5587
+ }
5588
+ ],
5589
+ initialValue: "keep",
5590
+ flagHint: "lumi-runner login"
5591
+ });
5592
+ if (action2 === "add") {
5593
+ await runLogin(options);
5594
+ } else if (action2 === "reset") {
5595
+ const sure = await promptConfirm({
5596
+ message: `Forget all ${Object.keys(existing.shipKeys ?? {}).length} Ship key(s) on this machine and connect from scratch?`,
5597
+ initialValue: false,
5598
+ yesFlagApplies: false
5599
+ // destructive: `--yes` must not answer this one
5600
+ });
5601
+ if (!sure) throw new CliError("Cancelled.", 130);
5602
+ saveConfig(forgetAllShips(existing));
5603
+ await runLogin(options);
5604
+ }
5465
5605
  }
5466
5606
  const config2 = loadConfig();
5467
5607
  if (!config2) throw new CliError("Login did not complete.");
@@ -5532,12 +5672,13 @@ async function runStatus() {
5532
5672
  progress.start("Reading Ship state\u2026");
5533
5673
  const ships = [];
5534
5674
  for (const shipId of config2.ships) {
5535
- let fb;
5675
+ let session;
5536
5676
  try {
5537
- ({ fb } = await openShipSession(shipId));
5677
+ session = await openShipSession(shipId);
5538
5678
  } catch {
5539
5679
  ships.push({
5540
5680
  shipId,
5681
+ runnerId: shipRunnerId(config2, shipId),
5541
5682
  enrolled: false,
5542
5683
  approved: false,
5543
5684
  online: false,
@@ -5550,8 +5691,8 @@ async function runStatus() {
5550
5691
  });
5551
5692
  continue;
5552
5693
  }
5553
- const shipRef = doc10(fb.db, COLLECTIONS.ships, shipId);
5554
- const mirrorSnap = await getDoc9(doc10(shipRef, COLLECTIONS.runners, config2.runnerId));
5694
+ const shipRef = doc10(session.fb.db, COLLECTIONS.ships, shipId);
5695
+ const mirrorSnap = await getDoc9(doc10(shipRef, COLLECTIONS.runners, session.runnerId));
5555
5696
  const mirror = mirrorSnap.data();
5556
5697
  let queued = 0;
5557
5698
  try {
@@ -5569,6 +5710,7 @@ async function runStatus() {
5569
5710
  const engineLimits = limitsSnap.docs.map((d) => ({ id: d.id, ...d.data() })).filter((l) => isEngineLimited(l, now));
5570
5711
  ships.push({
5571
5712
  shipId,
5713
+ runnerId: session.runnerId,
5572
5714
  enrolled: mirrorSnap.exists(),
5573
5715
  approved: mirror?.approved === true,
5574
5716
  online: mirror?.status === "online" && Date.now() - (mirror?.lastSeenAt ?? 0) < RUNNER_OFFLINE_AFTER_MS,
@@ -5581,11 +5723,19 @@ async function runStatus() {
5581
5723
  }
5582
5724
  progress.stop("");
5583
5725
  const service2 = serviceStatus();
5726
+ const identities = runnerIdentities(
5727
+ config2.runnerId,
5728
+ ships.map((s) => ({ shipId: s.shipId, runnerId: s.runnerId }))
5729
+ );
5584
5730
  if (isJson()) {
5585
5731
  emitJson({
5586
5732
  version: RUNNER_VERSION,
5587
5733
  connected: true,
5734
+ // KEPT, and kept meaning the same thing: this is a published contract, and on the machine
5735
+ // that has one identity — every machine before this release — the primary IS that identity.
5588
5736
  runnerId: config2.runnerId,
5737
+ /** Every name this machine answers to, and the Ships each one covers. */
5738
+ runnerIds: identities,
5589
5739
  projectId: config2.projectId,
5590
5740
  service: { state: service2.state, detail: service2.detail },
5591
5741
  ships
@@ -5593,7 +5743,16 @@ async function runStatus() {
5593
5743
  return 0;
5594
5744
  }
5595
5745
  say.line("");
5596
- say.line(` ${pc.bold("Runner")} ${config2.runnerId} ${pc.dim(`v${RUNNER_VERSION}`)}`);
5746
+ if (identities.length <= 1) {
5747
+ say.line(` ${pc.bold("Runner")} ${config2.runnerId} ${pc.dim(`v${RUNNER_VERSION}`)}`);
5748
+ } else {
5749
+ say.line(` ${pc.bold("Runner")} ${identities.length} identities ${pc.dim(`v${RUNNER_VERSION}`)}`);
5750
+ for (const identity of identities) {
5751
+ say.line(
5752
+ ` ${identity.runnerId} ${pc.dim(identity.shipIds.join(", ") || "no Ships")}`
5753
+ );
5754
+ }
5755
+ }
5597
5756
  say.line(` ${pc.bold("Project")} ${config2.projectId}`);
5598
5757
  say.line(` ${pc.bold("Service")} ${service2.detail}`);
5599
5758
  say.line("");
@@ -5604,6 +5763,9 @@ async function runStatus() {
5604
5763
  for (const ship2 of ships) {
5605
5764
  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
5765
  say.line(` ${pc.bold(ship2.shipId)} ${state}`);
5766
+ if (identities.length > 1 && ship2.runnerId) {
5767
+ say.line(` ${pc.dim(`runner ${ship2.runnerId}`)}`);
5768
+ }
5607
5769
  if (ship2.unreachable) {
5608
5770
  say.line(` ${pc.dim("this machine\u2019s key for that Ship was revoked \u2014 re-approve it on the Daemons page")}`);
5609
5771
  continue;
@@ -5750,7 +5912,7 @@ function action(handler) {
5750
5912
  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
5913
  async (options) => runSetup({ project: options.project, noBrowser: options.browser === false })
5752
5914
  ));
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(
5915
+ 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
5916
  action(
5755
5917
  async (options) => runLogin({
5756
5918
  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.7.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.",