@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.
- package/README.md +24 -4
- package/dist/cli.js +303 -76
- 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** —
|
|
250
|
-
runner id and
|
|
251
|
-
already granted. Add `--purge` to delete it too, and expect to be approved
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
${
|
|
1493
|
+
${latest.report ?? "That run left no report."}`
|
|
1494
|
+
);
|
|
1442
1495
|
parts.push(`# Reports from previous runs on this task
|
|
1443
1496
|
|
|
1444
|
-
${
|
|
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) =>
|
|
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
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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) =>
|
|
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
|
-
|
|
3798
|
-
|
|
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 !==
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4947
|
+
let session;
|
|
4836
4948
|
try {
|
|
4837
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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:
|
|
5153
|
+
runnerId: existing?.runnerId || approved.runnerId || "",
|
|
5024
5154
|
shipKeys: { ...existing?.shipKeys ?? {}, ...approved.shipKeys ?? {} },
|
|
5025
|
-
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
|
-
|
|
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
|
-
//
|
|
5059
|
-
//
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
5675
|
+
let session;
|
|
5471
5676
|
try {
|
|
5472
|
-
|
|
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,
|
|
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
|
-
|
|
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>", "
|
|
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.
|
|
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.",
|