@lumi.ai/runner 0.6.3 → 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 +303 -76
  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
@@ -230,6 +230,8 @@ function jobTarget(job) {
230
230
  return { kind: "chat", chatId: job.chatId };
231
231
  return null;
232
232
  }
233
+ var MAX_RUN_REPORT_CHARS = 6e3;
234
+ var MAX_RUN_REPORT_SUMMARY_CHARS = 300;
233
235
 
234
236
  // ../shared/dist/jobProgress.js
235
237
  var MAX_JOB_STEPS = 10;
@@ -754,8 +756,35 @@ function forgetShip(config2, shipId) {
754
756
  if (next.allowLocalMcp) {
755
757
  next.allowLocalMcp = next.allowLocalMcp.filter((id) => id !== shipId);
756
758
  }
759
+ if (next.shipRunnerIds) {
760
+ const ids = { ...next.shipRunnerIds };
761
+ delete ids[shipId];
762
+ next.shipRunnerIds = ids;
763
+ }
757
764
  return next;
758
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
+ }
759
788
  function allowsLocalMcp(config2, shipId) {
760
789
  return Array.isArray(config2.allowLocalMcp) && config2.allowLocalMcp.includes(shipId);
761
790
  }
@@ -772,7 +801,7 @@ function mcpUrl(config2) {
772
801
  }
773
802
 
774
803
  // src/version.ts
775
- var RUNNER_VERSION = true ? "0.6.3" : "0.0.0-dev";
804
+ var RUNNER_VERSION = true ? "0.7.0" : "0.0.0-dev";
776
805
 
777
806
  // src/auth.ts
778
807
  import { signInWithCustomToken } from "firebase/auth";
@@ -867,7 +896,7 @@ async function signInToShip(fb, config2, shipId) {
867
896
  );
868
897
  }
869
898
  const cred = await signInWithCustomToken(fb.auth, session.customToken);
870
- return cred.user;
899
+ return { user: cred.user, runnerId: session.runnerId || shipRunnerId(config2, shipId) };
871
900
  }
872
901
  async function openShipSessions(config2, build) {
873
902
  const sessions = [];
@@ -875,8 +904,7 @@ async function openShipSessions(config2, build) {
875
904
  for (const shipId of config2.ships) {
876
905
  const fb = build(shipId);
877
906
  try {
878
- const user = await signInToShip(fb, config2, shipId);
879
- sessions.push({ shipId, fb, user });
907
+ sessions.push({ shipId, fb, ...await signInToShip(fb, config2, shipId) });
880
908
  } catch (e) {
881
909
  failures.push({
882
910
  shipId,
@@ -1196,6 +1224,13 @@ async function loadSettledBlockers(shipRef, task) {
1196
1224
  return [];
1197
1225
  }
1198
1226
  }
1227
+ function reportSummaryLine(r) {
1228
+ if (r.summary?.trim()) return r.summary.trim();
1229
+ const body = r.report?.trim();
1230
+ if (!body) return "No report.";
1231
+ const firstLine2 = body.split("\n").find((l) => l.trim())?.trim() ?? body;
1232
+ return firstLine2.length <= MAX_RUN_REPORT_SUMMARY_CHARS ? firstLine2 : `${firstLine2.slice(0, MAX_RUN_REPORT_SUMMARY_CHARS - 1)}\u2026`;
1233
+ }
1199
1234
  async function loadJobContext(db, shipId, job) {
1200
1235
  const shipRef = doc(db, COLLECTIONS.ships, shipId);
1201
1236
  const taskRef = doc(shipRef, COLLECTIONS.tasks, job.taskId);
@@ -1276,7 +1311,13 @@ async function loadJobContext(db, shipId, job) {
1276
1311
  activity: activityDocs.map((d) => ({ id: d.id, ...d.data() })).reverse(),
1277
1312
  // read newest-first, rendered chronologically
1278
1313
  activityTruncated: activitySnap.docs.length > MAX_ACTIVITY_IN_PROMPT,
1279
- previousReports: jobsSnap.docs.filter((d) => d.id !== job.id).slice(0, MAX_PREVIOUS_JOBS_READ).map((d) => d.data().report).filter((r) => !!r).reverse()
1314
+ // §15.41. No longer filtered down to jobs that HAVE a report: the catalog carries a line per
1315
+ // previous run, and "that run left no report" is itself worth knowing — it is the difference
1316
+ // between a task nobody has worked and one where three runs died without saying why.
1317
+ previousReports: jobsSnap.docs.filter((d) => d.id !== job.id).slice(0, MAX_PREVIOUS_JOBS_READ).map((d) => {
1318
+ const data = d.data();
1319
+ return { jobId: d.id, summary: data.reportSummary ?? null, report: data.report ?? null };
1320
+ }).reverse()
1280
1321
  // chronological
1281
1322
  };
1282
1323
  }
@@ -1301,7 +1342,11 @@ function standingRules(ship2, statuses, task, agent) {
1301
1342
  // Amended with the pointer clause, which is the only thing keeping the three tiers from
1302
1343
  // duplicating each other: without it a diligent agent writes the same fact into its report,
1303
1344
  // its notebook and the org brain, and the report grows into a transcript of the other two.
1304
- "End every session by calling run_report: what you did, what remains, what the next run must know. Do not repeat what you already put in your memory or in Ship knowledge \u2014 point at it instead (the entry id, or the slug)."
1345
+ // §15.41 added the summary clause. It is second, not an afterthought at the end, because that
1346
+ // one string is what every LATER run on this task reads instead of this report — and what a
1347
+ // human sees in their notification. An agent that treats it as a label writes "Run report",
1348
+ // and four runs later the catalog says nothing at all.
1349
+ "End every session by calling run_report: what you did, what remains, what the next run must know. Give it a summary too \u2014 one or two sentences that stand in for the whole report in later runs, so write the sentence you would want the next run to read first. Do not repeat what you already put in your memory or in Ship knowledge \u2014 point at it instead (the entry id, or the slug)."
1305
1350
  ].join("\n- ");
1306
1351
  }
1307
1352
  function formatTaskDate(millis, now = Date.now()) {
@@ -1341,26 +1386,17 @@ function buildPrompt(ctx, reason, mcpServers = [], tokenRepos, githubUnavailable
1341
1386
  const parts = [];
1342
1387
  const statuses = shipTaskStatuses(ctx.ship);
1343
1388
  const playbook = usableWorkflow(ctx.workflow);
1389
+ let preamble = null;
1344
1390
  if (reason === "activity") {
1345
- parts.push(
1346
- "# Why this session started\n\nSomeone posted on your task after your last run. Read the newest activity below" + (playbook ? ", then follow your playbook below." : ", respond via task_comment, do any follow-up work it asks for, and set the task status honestly if it needs to change.")
1347
- );
1391
+ preamble = "# Why this session started\n\nSomeone posted on your task after your last run. Read the newest activity below" + (playbook ? ", then follow your playbook above." : ", respond via task_comment, do any follow-up work it asks for, and set the task status honestly if it needs to change.");
1348
1392
  } else if (reason === "watch") {
1349
- parts.push(
1350
- "# Why this session started\n\nOne of your playbooks watches the board, and this task matched it. Nobody assigned it to you and nobody else is working it right now \u2014 you are picking it up.\n\n**Leave it in a status that says what happened.** The board is how the watch decides what still needs attention, so a task you leave in the same status will come back to you on the next scan" + (playbook ? ", which your playbook below is written for." : ".")
1351
- );
1393
+ preamble = "# Why this session started\n\nOne of your playbooks watches the board, and this task matched it. Nobody assigned it to you and nobody else is working it right now \u2014 you are picking it up.\n\n**Leave it in a status that says what happened.** The board is how the watch decides what still needs attention, so a task you leave in the same status will come back to you on the next scan" + (playbook ? ", which your playbook above is written for." : ".");
1352
1394
  } else if (reason === "resume_after_approval") {
1353
- parts.push(
1354
- "# Why this session started\n\nYou asked a captain for permission and stopped. They have now answered \u2014 their decision is the newest entry in the task activity below. Read it first.\n\n**If they approved it, do that thing now** \u2014 the permission is granted for this task and may be single-use, so do not ask again for the same thing. **If they refused, do not retry and do not look for a way around it**: say what you will do instead, or hand the task back with task_assign."
1355
- );
1395
+ preamble = "# Why this session started\n\nYou asked a captain for permission and stopped. They have now answered \u2014 their decision is the newest entry in the task activity below. Read it first.\n\n**If they approved it, do that thing now** \u2014 the permission is granted for this task and may be single-use, so do not ask again for the same thing. **If they refused, do not retry and do not look for a way around it**: say what you will do instead, or hand the task back with task_assign.";
1356
1396
  } else if (reason === "unblocked") {
1357
- parts.push(
1358
- '# Why this session started\n\nThis task was blocked and is not any more: everything it was waiting on is now done. Nobody has just assigned it to you \u2014 you have had it all along, and the work has become startable.\n\n**Read "What you were waiting on" below before you do anything else.** It carries what those tasks produced, which is the input this work was held up for; starting without it means redoing or contradicting somebody else' + (playbook ? ", then follow your playbook below." : "'s work.")
1359
- );
1397
+ preamble = '# Why this session started\n\nThis task was blocked and is not any more: everything it was waiting on is now done. Nobody has just assigned it to you \u2014 you have had it all along, and the work has become startable.\n\n**Read "What you were waiting on" below before you do anything else.** It carries what those tasks produced, which is the input this work was held up for; starting without it means redoing or contradicting somebody else' + (playbook ? ", then follow your playbook above." : "'s work.");
1360
1398
  } else if (reason === "schedule") {
1361
- parts.push(
1362
- "# Why this session started\n\nThis is a scheduled run: one of your own playbooks created this task on its cron and assigned it to you. It is routine work, not a request from a person, so nobody is waiting on a reply" + (playbook ? " \u2014 the playbook below is the work, and the task's description is only a record of why it exists." : ".")
1363
- );
1399
+ preamble = "# Why this session started\n\nThis is a scheduled run: one of your own playbooks created this task on its cron and assigned it to you. It is routine work, not a request from a person, so nobody is waiting on a reply" + (playbook ? " \u2014 the playbook above is the work, and the task's description is only a record of why it exists." : ".");
1364
1400
  }
1365
1401
  parts.push(`# Who you are
1366
1402
 
@@ -1385,6 +1421,7 @@ This is the playbook for this kind of work \u2014 follow it. Your contract and t
1385
1421
  ${playbook.instructions}`
1386
1422
  );
1387
1423
  }
1424
+ if (preamble) parts.push(preamble);
1388
1425
  const t = ctx.task;
1389
1426
  const now = Date.now();
1390
1427
  const statusLabel = statusById(statuses, t.status)?.label ?? t.status;
@@ -1436,12 +1473,28 @@ ${p.description || "(no description)"}`
1436
1473
  ${note2}${feed}`);
1437
1474
  }
1438
1475
  if (ctx.previousReports.length > 0) {
1439
- const reports = ctx.previousReports.map((r, i) => `## Run ${i + 1}
1476
+ const runs = ctx.previousReports;
1477
+ const latest = runs[runs.length - 1];
1478
+ const older = runs.slice(0, -1);
1479
+ const sections = [];
1480
+ if (older.length > 0) {
1481
+ const lines = older.map((r) => `- (\`${r.jobId}\`) ${reportSummaryLine(r)}`).join("\n");
1482
+ sections.push(
1483
+ `## Earlier runs
1484
+
1485
+ Oldest first, one line each. To read one in full, call \`task_get\` with this task's id and \`reportJobId\` set to the id in brackets.
1486
+
1487
+ ${lines}`
1488
+ );
1489
+ }
1490
+ sections.push(
1491
+ `## Most recent run (\`${latest.jobId}\`)
1440
1492
 
1441
- ${r}`).join("\n\n");
1493
+ ${latest.report ?? "That run left no report."}`
1494
+ );
1442
1495
  parts.push(`# Reports from previous runs on this task
1443
1496
 
1444
- ${reports}`);
1497
+ ${sections.join("\n\n")}`);
1445
1498
  }
1446
1499
  const githubRepos = tokenRepos ?? ctx.agent.tools.github.repos;
1447
1500
  if (ctx.agent.tools.github.enabled && githubUnavailable) {
@@ -1517,7 +1570,10 @@ async function loadChatContext(db, shipId, job) {
1517
1570
  limit2(MAX_CHAT_REPORTS + 1)
1518
1571
  )
1519
1572
  );
1520
- previousReports = jobsSnap.docs.filter((d) => d.id !== job.id).slice(0, MAX_CHAT_REPORTS).map((d) => d.data().report).filter((r) => !!r).reverse();
1573
+ previousReports = jobsSnap.docs.filter((d) => d.id !== job.id).slice(0, MAX_CHAT_REPORTS).map((d) => {
1574
+ const data = d.data();
1575
+ return { jobId: d.id, summary: data.reportSummary ?? null, report: data.report ?? null };
1576
+ }).reverse();
1521
1577
  } catch {
1522
1578
  previousReports = [];
1523
1579
  }
@@ -1553,7 +1609,7 @@ function chatStandingRules(ship2, agent) {
1553
1609
  scopeRule,
1554
1610
  registerRule,
1555
1611
  routingRule,
1556
- "End the session by calling run_report: what was discussed and anything the next run must know. Do not repeat what you already put in your memory or in Ship knowledge \u2014 point at it instead."
1612
+ "End the session by calling run_report: what was discussed and anything the next run must know. Give it a summary too \u2014 one or two sentences that stand in for the whole report once this conversation is long enough to scroll out of the window. Do not repeat what you already put in your memory or in Ship knowledge \u2014 point at it instead."
1557
1613
  ].join("\n- ");
1558
1614
  }
1559
1615
  function buildChatPrompt(ctx, mcpServers = [], tokenRepos, githubUnavailable) {
@@ -1576,15 +1632,22 @@ ${ctx.agent.contract}`);
1576
1632
 
1577
1633
  - ${chatStandingRules(ctx.ship, ctx.agent)}`);
1578
1634
  if (ctx.previousReports.length > 0) {
1579
- const reports = ctx.previousReports.map((r, i) => `## Earlier run ${i + 1}
1635
+ const runs = ctx.previousReports;
1636
+ const latest = runs[runs.length - 1];
1637
+ const older = runs.slice(0, -1);
1638
+ const sections = [];
1639
+ if (older.length > 0) {
1640
+ sections.push(older.map((r) => `- ${reportSummaryLine(r)}`).join("\n"));
1641
+ }
1642
+ sections.push(`## Most recent run
1580
1643
 
1581
- ${r}`).join("\n\n");
1644
+ ${latest.report ?? "That run left no report."}`);
1582
1645
  parts.push(
1583
1646
  `# Earlier in this chat
1584
1647
 
1585
1648
  The conversation is longer than the window below. These are your own notes from previous runs on this chat, oldest first.
1586
1649
 
1587
- ${reports}`
1650
+ ${sections.join("\n\n")}`
1588
1651
  );
1589
1652
  }
1590
1653
  const title = ctx.chat.title?.trim();
@@ -2445,6 +2508,19 @@ async function uploadTranscript(storage, shipId, jobId, redacted) {
2445
2508
  function utcDay(millis) {
2446
2509
  return new Date(millis).toISOString().slice(0, 10);
2447
2510
  }
2511
+ function backstopReportContent(resultText) {
2512
+ const text = resultText.trim();
2513
+ if (!text) {
2514
+ const none = "This run ended without writing a report, and left no final text to fall back on.";
2515
+ return { report: none, summary: none };
2516
+ }
2517
+ const marker = "\n\n\u2026(truncated)";
2518
+ const report4 = text.length <= MAX_RUN_REPORT_CHARS ? text : `${text.slice(0, MAX_RUN_REPORT_CHARS - marker.length)}${marker}`;
2519
+ const firstLine2 = text.split("\n").find((l) => l.trim())?.trim() ?? text;
2520
+ const ellipsis = "\u2026";
2521
+ const summary = firstLine2.length <= MAX_RUN_REPORT_SUMMARY_CHARS ? firstLine2 : `${firstLine2.slice(0, MAX_RUN_REPORT_SUMMARY_CHARS - ellipsis.length)}${ellipsis}`;
2522
+ return { report: report4, summary };
2523
+ }
2448
2524
  async function finalizeJob(db, shipId, job, input) {
2449
2525
  const shipRef = doc6(db, COLLECTIONS.ships, shipId);
2450
2526
  const jobRef = doc6(shipRef, COLLECTIONS.jobs, job.id);
@@ -2452,6 +2528,7 @@ async function finalizeJob(db, shipId, job, input) {
2452
2528
  const usageRef = doc6(shipRef, COLLECTIONS.usageDaily, utcDay(now));
2453
2529
  await runTransaction(db, async (tx) => {
2454
2530
  const usageSnap = await tx.get(usageRef);
2531
+ const jobSnap = await tx.get(jobRef);
2455
2532
  const totals = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
2456
2533
  const byAgent = usageSnap.data()?.byAgent ?? {};
2457
2534
  const agentAgg = {
@@ -2459,7 +2536,10 @@ async function finalizeJob(db, shipId, job, input) {
2459
2536
  ...byAgent[job.agentId] ?? {}
2460
2537
  };
2461
2538
  const u = input.usage;
2539
+ const wroteReport = !!jobSnap.data()?.report;
2540
+ const backstop = !wroteReport && input.resultText !== void 0 ? backstopReportContent(input.resultText) : null;
2462
2541
  tx.update(jobRef, {
2542
+ ...backstop ? { report: backstop.report, reportSummary: backstop.summary } : {},
2463
2543
  status: input.status,
2464
2544
  endedAt: now,
2465
2545
  usage: u,
@@ -3407,8 +3487,9 @@ async function startDaemon() {
3407
3487
  return s;
3408
3488
  };
3409
3489
  const serving = new Set(sessions.map((s) => s.shipId));
3490
+ const bannerIds = [...new Set(sessions.map((s) => s.runnerId))];
3410
3491
  console.log(
3411
- `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(", ")
3412
3493
  );
3413
3494
  const logLines = [];
3414
3495
  const log2 = (line) => {
@@ -3429,7 +3510,10 @@ async function startDaemon() {
3429
3510
  const liveJobsOn = (shipId) => [...running.values()].filter((r) => r.shipId === shipId && r.mirror).map((r) => r.mirror).sort((a, b) => a.startedAt - b.startedAt);
3430
3511
  const approved = /* @__PURE__ */ new Map();
3431
3512
  const warnedUnapproved = /* @__PURE__ */ new Set();
3432
- 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
+ };
3433
3517
  const needsRefill = /* @__PURE__ */ new Set();
3434
3518
  let beating = false;
3435
3519
  async function heartbeat() {
@@ -3794,8 +3878,9 @@ async function startDaemon() {
3794
3878
  return;
3795
3879
  }
3796
3880
  const startedAt2 = Date.now();
3797
- tx.update(jobRef, { status: "running", runnerId: config2.runnerId, startedAt: startedAt2 });
3798
- 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 };
3799
3884
  });
3800
3885
  return claimed;
3801
3886
  } catch (e) {
@@ -3912,7 +3997,7 @@ async function startDaemon() {
3912
3997
  doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
3913
3998
  );
3914
3999
  const fresh = snap.data();
3915
- if (!fresh || fresh.status !== "running" || fresh.runnerId !== config2.runnerId) {
4000
+ if (!fresh || fresh.status !== "running" || fresh.runnerId !== sess(shipId).runnerId) {
3916
4001
  log2(`Job ${job.id} is no longer this machine's to run \u2014 ending the session.`);
3917
4002
  slot.abort.abort();
3918
4003
  }
@@ -4036,6 +4121,10 @@ async function startDaemon() {
4036
4121
  status: "stopped",
4037
4122
  usage,
4038
4123
  transcriptPath,
4124
+ // §15.41. A stopped run is the case that needs the backstop MOST: it was cut off
4125
+ // mid-thought, so it almost certainly never reached `run_report` — and whatever it had
4126
+ // got to is what the next run on this task would otherwise have to rediscover.
4127
+ resultText,
4039
4128
  mcpServers: extraMcpServers.map((s) => s.key)
4040
4129
  });
4041
4130
  const by = slot.stop.by;
@@ -4057,7 +4146,13 @@ async function startDaemon() {
4057
4146
  } else if (sessionLimit) {
4058
4147
  const engineLabel = getEngine(engineId).label;
4059
4148
  const resetsAt = new Date(sessionLimit.resetsAt).toISOString();
4060
- 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
+ );
4061
4156
  await releaseJob(
4062
4157
  sess(shipId).fb.db,
4063
4158
  shipId,
@@ -4077,6 +4172,9 @@ async function startDaemon() {
4077
4172
  status: "done",
4078
4173
  usage,
4079
4174
  transcriptPath,
4175
+ // §15.41. Only used when the session never called `run_report` — the ordinary path is
4176
+ // that it did, and a real report always wins inside the transaction.
4177
+ resultText,
4080
4178
  mcpServers: extraMcpServers.map((s) => s.key)
4081
4179
  });
4082
4180
  log2(`Job ${job.id} done (${usage.inputTokens}in/${usage.outputTokens}out tokens).`);
@@ -4130,6 +4228,10 @@ async function startDaemon() {
4130
4228
  usage,
4131
4229
  transcriptPath,
4132
4230
  error: failure,
4231
+ // §15.41. A failed run still did work, and the retry — or the next run after the retry
4232
+ // is spent — starts from the context pack alone. `error` is the tail for a human; this
4233
+ // is the continuity for the next session, and they are read by different readers.
4234
+ resultText,
4133
4235
  mcpServers: extraMcpServers.map((s) => s.key)
4134
4236
  });
4135
4237
  if (target.kind === "chat") {
@@ -4187,7 +4289,7 @@ async function startDaemon() {
4187
4289
  try {
4188
4290
  for (const shipId of serving) {
4189
4291
  await setDoc2(
4190
- doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
4292
+ shipRunnerRef(shipId),
4191
4293
  {
4192
4294
  status: "offline",
4193
4295
  lastSeenAt: now,
@@ -4455,6 +4557,16 @@ async function promptMultiSelect(options) {
4455
4557
  })
4456
4558
  );
4457
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
+ }
4458
4570
  async function promptConfirm(options) {
4459
4571
  if (assumeYes && options.yesFlagApplies !== false) return true;
4460
4572
  requireInteractive(options.message, "--yes");
@@ -4685,8 +4797,7 @@ import { collection as collection6, doc as doc8, getDoc as getDoc7, getDocs as g
4685
4797
  async function openShipSession(shipId) {
4686
4798
  const config2 = requireConfig();
4687
4799
  const fb = initFirebase(config2, `cli-${shipId}`);
4688
- const user = await signInToShip(fb, config2, shipId);
4689
- return { shipId, config: config2, fb, user };
4800
+ return { shipId, config: config2, fb, ...await signInToShip(fb, config2, shipId) };
4690
4801
  }
4691
4802
 
4692
4803
  // src/cli/commands/doctor.ts
@@ -4825,6 +4936,7 @@ async function checkShips(config2) {
4825
4936
  const checks = [];
4826
4937
  const engines = /* @__PURE__ */ new Set();
4827
4938
  let needsGithub = false;
4939
+ const manyIdentities = configRunnerIdentities(config2).length > 1;
4828
4940
  if (config2.ships.length === 0) {
4829
4941
  checks.push(
4830
4942
  fail("ships", "Ships", "This machine serves no Ships.", "Run `lumi-runner ship add` to pick one.")
@@ -4832,9 +4944,9 @@ async function checkShips(config2) {
4832
4944
  return { checks, engines, needsGithub };
4833
4945
  }
4834
4946
  for (const shipId of config2.ships) {
4835
- let fb;
4947
+ let session;
4836
4948
  try {
4837
- ({ fb } = await openShipSession(shipId));
4949
+ session = await openShipSession(shipId);
4838
4950
  checks.push(ok(`key:${shipId}`, `Ship ${shipId} \u2014 key`, "Runner key accepted."));
4839
4951
  } catch (e) {
4840
4952
  checks.push(
@@ -4849,7 +4961,7 @@ async function checkShips(config2) {
4849
4961
  }
4850
4962
  try {
4851
4963
  const snap = await getDoc7(
4852
- doc8(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId)
4964
+ doc8(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId)
4853
4965
  );
4854
4966
  if (!snap.exists()) {
4855
4967
  checks.push(
@@ -4870,7 +4982,13 @@ async function checkShips(config2) {
4870
4982
  )
4871
4983
  );
4872
4984
  } else {
4873
- 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
+ );
4874
4992
  }
4875
4993
  } catch (e) {
4876
4994
  checks.push(
@@ -4884,7 +5002,7 @@ async function checkShips(config2) {
4884
5002
  }
4885
5003
  let agents = [];
4886
5004
  try {
4887
- 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));
4888
5006
  agents = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
4889
5007
  } catch {
4890
5008
  }
@@ -4893,7 +5011,7 @@ async function checkShips(config2) {
4893
5011
  for (const id of shipEngines) engines.add(id);
4894
5012
  if (agents.some((agent) => effectiveAgentTools(agent).github.enabled)) needsGithub = true;
4895
5013
  try {
4896
- const secrets = await loadRunnerSecrets(fb.db, shipId);
5014
+ const secrets = await loadRunnerSecrets(session.fb.db, shipId);
4897
5015
  const missing = [...new Set([...shipEngines].flatMap((id) => missingSecretsFor(id, secrets)))];
4898
5016
  checks.push(
4899
5017
  missing.length === 0 ? ok(`secrets:${shipId}`, `Ship ${shipId} \u2014 credentials`, "All required secrets are saved.") : fail(
@@ -4915,7 +5033,7 @@ async function checkShips(config2) {
4915
5033
  }
4916
5034
  try {
4917
5035
  const snap = await getDocs5(
4918
- collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
5036
+ collection6(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
4919
5037
  );
4920
5038
  const servers = snap.docs.map(
4921
5039
  (d) => ({ id: d.id, ...d.data() })
@@ -4936,7 +5054,14 @@ async function runDoctor() {
4936
5054
  );
4937
5055
  return report2(checks);
4938
5056
  }
4939
- 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
+ );
4940
5065
  const progress = spinner2();
4941
5066
  progress.start("Running checks\u2026");
4942
5067
  const engines = /* @__PURE__ */ new Set([DEFAULT_ENGINE_ID]);
@@ -5016,21 +5141,37 @@ import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
5016
5141
  function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
5017
5142
  const approvedShips = approved.approvedShips ?? Object.keys(approved.shipKeys ?? {});
5018
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
+ }
5019
5149
  const config2 = {
5020
5150
  ...existing,
5021
5151
  apiKey: approved.apiKey || existing?.apiKey || "",
5022
5152
  projectId: approved.projectId || fallbackProjectId,
5023
- runnerId: approved.runnerId || existing?.runnerId || "",
5153
+ runnerId: existing?.runnerId || approved.runnerId || "",
5024
5154
  shipKeys: { ...existing?.shipKeys ?? {}, ...approved.shipKeys ?? {} },
5025
- 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 } : {},
5026
5159
  ...mcpUrl2 ? { mcpUrl: mcpUrl2 } : {}
5027
5160
  };
5161
+ const occupiedShips = (approved.occupiedShips ?? []).filter((id) => config2.ships.includes(id));
5028
5162
  return {
5029
5163
  config: config2,
5030
5164
  approvedShips,
5031
5165
  // Fall back to deriving it, so an older backend that omits the field reports accurately
5032
- // rather than crashing or claiming nothing is pending.
5033
- 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))
5034
5175
  };
5035
5176
  }
5036
5177
  function openBrowser(url) {
@@ -5053,11 +5194,13 @@ async function loginWithDeviceFlow(options) {
5053
5194
  const existingConfig = loadConfig();
5054
5195
  const projectId = options.project || existingConfig?.projectId || DEFAULT_PROJECT_ID;
5055
5196
  const baseUrl = functionsBaseUrlFor(projectId);
5197
+ const candidates = existingConfig ? configRunnerIdentities(existingConfig).map((i) => i.runnerId) : [];
5056
5198
  const start = await callPublicFunction(baseUrl, "startRunnerLogin", {
5057
5199
  hostname: os5.hostname(),
5058
- // Offer the id this machine already has: re-logging in should keep its identity, and with
5059
- // it the captain approvals it has already earned.
5060
- ...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 } : {}
5061
5204
  });
5062
5205
  if (!options.noBrowser) openBrowser(start.verificationUrl);
5063
5206
  say.note(
@@ -5095,7 +5238,7 @@ Code: ${pc.bold(start.displayCode)}`,
5095
5238
  throw new CliError("The approval window expired. Run `lumi-runner login` again.");
5096
5239
  }
5097
5240
  progress.stop("Approved.");
5098
- const { config: config2, approvedShips, pendingShips } = buildLoginResult(
5241
+ const { config: config2, approvedShips, pendingShips, occupiedShips, newIdentity, otherShips } = buildLoginResult(
5099
5242
  approved,
5100
5243
  existingConfig,
5101
5244
  projectId,
@@ -5113,7 +5256,18 @@ Code: ${pc.bold(start.displayCode)}`,
5113
5256
  const first = approvedShips[0];
5114
5257
  const uid2 = first ? (await signInWithCustomToken2(fb.auth, (await exchange(config2, first)).customToken)).user.uid : "(no Ship approved yet)";
5115
5258
  saveConfig(config2);
5116
- 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
+ });
5117
5271
  }
5118
5272
  async function exchange(config2, shipId) {
5119
5273
  return callPublicFunction(
@@ -5131,30 +5285,37 @@ async function loginWithKey(options) {
5131
5285
  throw new CliError("That does not look like a runner key. Expected `crewrunner_<shipId>_<secret>`.");
5132
5286
  }
5133
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 || "";
5134
5294
  const config2 = {
5135
5295
  ...existing,
5136
5296
  apiKey: options.apiKey,
5137
5297
  projectId: options.project,
5138
- 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,
5139
5302
  shipKeys: { ...existing?.shipKeys ?? {}, [parsed]: options.key },
5140
5303
  ships: [.../* @__PURE__ */ new Set([...existing?.ships ?? [], parsed])],
5304
+ ...runnerId ? { shipRunnerIds: { ...existing?.shipRunnerIds ?? {}, [parsed]: runnerId } } : {},
5141
5305
  ...options.mcpUrl ? { mcpUrl: options.mcpUrl } : {}
5142
5306
  };
5143
- const session = await callPublicFunction(
5144
- functionsBaseUrlFor(config2.projectId),
5145
- "exchangeRunnerKey",
5146
- { key: options.key }
5147
- );
5148
- config2.runnerId = session.runnerId;
5149
5307
  const fb = initFirebase(config2);
5150
5308
  const cred = await signInWithCustomToken2(fb.auth, session.customToken);
5151
5309
  saveConfig(config2);
5152
5310
  return report3({
5153
- runnerId: config2.runnerId,
5311
+ runnerId,
5312
+ primaryRunnerId: config2.runnerId,
5313
+ newIdentity: Boolean(runnerId && config2.runnerId && runnerId !== config2.runnerId),
5154
5314
  uid: cred.user.uid,
5155
5315
  ships: config2.ships,
5156
5316
  approvedShips: [parsed],
5157
- pendingShips: []
5317
+ pendingShips: [],
5318
+ otherShips: (existing?.ships ?? []).filter((id) => id !== parsed)
5158
5319
  });
5159
5320
  }
5160
5321
  function parseKeyShipId(key) {
@@ -5167,12 +5328,27 @@ function report3(result) {
5167
5328
  return 0;
5168
5329
  }
5169
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
+ }
5170
5336
  if (result.ships.length > 0) say.info(`Serving: ${result.ships.join(", ")}`);
5171
5337
  if (result.pendingShips && result.pendingShips.length > 0) {
5172
5338
  say.warn(
5173
5339
  `Awaiting captain approval on: ${result.pendingShips.join(", ")} \u2014 a captain approves this machine on the Ship's Daemons page.`
5174
5340
  );
5175
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
+ }
5176
5352
  if (serviceStatus().state === "running") {
5177
5353
  say.info("Restart the daemon for this to take effect: `lumi-runner service restart`.");
5178
5354
  }
@@ -5390,13 +5566,42 @@ async function runSetup(options) {
5390
5566
  const moved = migrateLegacyDir();
5391
5567
  if (moved?.migrated) say.info(`Moved your runner config from ${moved.from} to ${moved.to}.`);
5392
5568
  const existing = loadConfig();
5393
- const reuse = Object.keys(existing?.shipKeys ?? {}).length > 0 && await promptConfirm({
5394
- message: `This machine is already connected as ${existing?.runnerId}. Keep its Ship keys?`,
5395
- initialValue: true,
5396
- yesFlagApplies: false
5397
- });
5398
- if (!reuse) {
5569
+ if (!existing || Object.keys(existing.shipKeys ?? {}).length === 0) {
5399
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
+ }
5400
5605
  }
5401
5606
  const config2 = loadConfig();
5402
5607
  if (!config2) throw new CliError("Login did not complete.");
@@ -5467,12 +5672,13 @@ async function runStatus() {
5467
5672
  progress.start("Reading Ship state\u2026");
5468
5673
  const ships = [];
5469
5674
  for (const shipId of config2.ships) {
5470
- let fb;
5675
+ let session;
5471
5676
  try {
5472
- ({ fb } = await openShipSession(shipId));
5677
+ session = await openShipSession(shipId);
5473
5678
  } catch {
5474
5679
  ships.push({
5475
5680
  shipId,
5681
+ runnerId: shipRunnerId(config2, shipId),
5476
5682
  enrolled: false,
5477
5683
  approved: false,
5478
5684
  online: false,
@@ -5485,8 +5691,8 @@ async function runStatus() {
5485
5691
  });
5486
5692
  continue;
5487
5693
  }
5488
- const shipRef = doc10(fb.db, COLLECTIONS.ships, shipId);
5489
- 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));
5490
5696
  const mirror = mirrorSnap.data();
5491
5697
  let queued = 0;
5492
5698
  try {
@@ -5504,6 +5710,7 @@ async function runStatus() {
5504
5710
  const engineLimits = limitsSnap.docs.map((d) => ({ id: d.id, ...d.data() })).filter((l) => isEngineLimited(l, now));
5505
5711
  ships.push({
5506
5712
  shipId,
5713
+ runnerId: session.runnerId,
5507
5714
  enrolled: mirrorSnap.exists(),
5508
5715
  approved: mirror?.approved === true,
5509
5716
  online: mirror?.status === "online" && Date.now() - (mirror?.lastSeenAt ?? 0) < RUNNER_OFFLINE_AFTER_MS,
@@ -5516,11 +5723,19 @@ async function runStatus() {
5516
5723
  }
5517
5724
  progress.stop("");
5518
5725
  const service2 = serviceStatus();
5726
+ const identities = runnerIdentities(
5727
+ config2.runnerId,
5728
+ ships.map((s) => ({ shipId: s.shipId, runnerId: s.runnerId }))
5729
+ );
5519
5730
  if (isJson()) {
5520
5731
  emitJson({
5521
5732
  version: RUNNER_VERSION,
5522
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.
5523
5736
  runnerId: config2.runnerId,
5737
+ /** Every name this machine answers to, and the Ships each one covers. */
5738
+ runnerIds: identities,
5524
5739
  projectId: config2.projectId,
5525
5740
  service: { state: service2.state, detail: service2.detail },
5526
5741
  ships
@@ -5528,7 +5743,16 @@ async function runStatus() {
5528
5743
  return 0;
5529
5744
  }
5530
5745
  say.line("");
5531
- 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
+ }
5532
5756
  say.line(` ${pc.bold("Project")} ${config2.projectId}`);
5533
5757
  say.line(` ${pc.bold("Service")} ${service2.detail}`);
5534
5758
  say.line("");
@@ -5539,6 +5763,9 @@ async function runStatus() {
5539
5763
  for (const ship2 of ships) {
5540
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");
5541
5765
  say.line(` ${pc.bold(ship2.shipId)} ${state}`);
5766
+ if (identities.length > 1 && ship2.runnerId) {
5767
+ say.line(` ${pc.dim(`runner ${ship2.runnerId}`)}`);
5768
+ }
5542
5769
  if (ship2.unreachable) {
5543
5770
  say.line(` ${pc.dim("this machine\u2019s key for that Ship was revoked \u2014 re-approve it on the Daemons page")}`);
5544
5771
  continue;
@@ -5685,7 +5912,7 @@ function action(handler) {
5685
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(
5686
5913
  async (options) => runSetup({ project: options.project, noBrowser: options.browser === false })
5687
5914
  ));
5688
- 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(
5689
5916
  action(
5690
5917
  async (options) => runLogin({
5691
5918
  project: options.project,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi.ai/runner",
3
- "version": "0.6.3",
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.",