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