@lumi.ai/runner 0.5.11 → 0.6.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 +30 -0
- package/dist/cli.js +460 -51
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -117,6 +117,36 @@ Each job is a separate `claude` process with its own empty working directory, so
|
|
|
117
117
|
state — but they do share your CPU, your RAM and your Claude usage window. Start at 2 and watch a
|
|
118
118
|
real job before going higher. Restart the daemon (`lumi-runner service restart`) to pick up a change.
|
|
119
119
|
|
|
120
|
+
## MCP servers that run on this machine
|
|
121
|
+
|
|
122
|
+
A Ship can register MCP connections its agents reach. Most are remote — an `https://` address the
|
|
123
|
+
session talks to. Two kinds are **local**: a program this machine starts (`npx -y some-mcp-server`,
|
|
124
|
+
`node ./my-server.mjs`), and a server already listening on this machine's own loopback address
|
|
125
|
+
(`http://127.0.0.1:3000/mcp`).
|
|
126
|
+
|
|
127
|
+
**Those are off until you allow them, one Ship at a time.**
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
lumi-runner config set local-mcp on --ship shp_abc123 # this Ship may run programs here
|
|
131
|
+
lumi-runner config set local-mcp off --ship shp_abc123 # stop allowing it
|
|
132
|
+
lumi-runner doctor # which Ships have local connections, and whether they may run
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
There is no machine-wide form on purpose. A captain registers these in a browser and may not be the
|
|
136
|
+
person who owns this computer — and if this daemon serves several Ships, a single switch would enrol
|
|
137
|
+
every one of them, including any added later. So it is granted per Ship, by you, here.
|
|
138
|
+
|
|
139
|
+
What a Ship gets when you allow it: the connections a captain registered **on that Ship**, activated
|
|
140
|
+
on the agent running the job, and nothing else. The daemon writes them into the session's config
|
|
141
|
+
file — your own `~/.claude.json` and any `.mcp.json` lying around are still invisible to every agent
|
|
142
|
+
session, exactly as before.
|
|
143
|
+
|
|
144
|
+
Credentials for these live on the Ship, not here: a captain marks a variable secret and it is stored
|
|
145
|
+
where nothing reads it back, then handed to the program at start and scrubbed from the transcript.
|
|
146
|
+
Press **Test** on the connection in Ship Settings and this machine runs the test and reports what it
|
|
147
|
+
found — the Crew backend cannot reach a program on your laptop, so it asks you instead. Restart the
|
|
148
|
+
daemon (`lumi-runner service restart`) after changing this.
|
|
149
|
+
|
|
120
150
|
## Staying current
|
|
121
151
|
|
|
122
152
|
**This machine updates itself.** Every half hour the daemon asks npm whether there is a newer
|
package/dist/cli.js
CHANGED
|
@@ -279,6 +279,7 @@ var WORKSPACE_LABELS = {
|
|
|
279
279
|
run_report: "Writing its run report",
|
|
280
280
|
knowledge_get: "Reading the knowledge base",
|
|
281
281
|
knowledge_write: "Writing to the knowledge base",
|
|
282
|
+
knowledge_folder: "Organising the knowledge base",
|
|
282
283
|
media_get: "Opening an attachment",
|
|
283
284
|
media_attach: "Attaching a file",
|
|
284
285
|
memory_write: "Updating its notes",
|
|
@@ -320,6 +321,10 @@ function workspaceDetail(tool, input) {
|
|
|
320
321
|
return str(input, "slug") ?? str(input, "find") ?? "the catalog";
|
|
321
322
|
case "knowledge_write":
|
|
322
323
|
return str(input, "slug");
|
|
324
|
+
// The PATH, which is a name an operator can read. Never `into` or `rename` alone — a detail
|
|
325
|
+
// that says only "Runbooks" is indistinguishable from the folder it moved away from.
|
|
326
|
+
case "knowledge_folder":
|
|
327
|
+
return str(input, "path");
|
|
323
328
|
case "media_get":
|
|
324
329
|
return str(input, "mediaId");
|
|
325
330
|
case "media_attach":
|
|
@@ -437,8 +442,8 @@ ${ranked.length} document${ranked.length === 1 ? "" : "s"}. These are SUMMARIES
|
|
|
437
442
|
room -= slug.length + 2;
|
|
438
443
|
}
|
|
439
444
|
const rest = dropped.length - named.length;
|
|
440
|
-
const
|
|
441
|
-
return head + lines.join("\n") +
|
|
445
|
+
const tail2 = OVERFLOW_PREFIX + named.join(", ") + (rest > 0 ? ` \u2026 and ${rest} more.` : ".");
|
|
446
|
+
return head + lines.join("\n") + tail2;
|
|
442
447
|
}
|
|
443
448
|
|
|
444
449
|
// ../shared/dist/docMedia.js
|
|
@@ -461,6 +466,25 @@ var DOC_MEDIA_TYPES = [
|
|
|
461
466
|
];
|
|
462
467
|
|
|
463
468
|
// ../shared/dist/mcpServer.js
|
|
469
|
+
function isLocalMcpServer(server) {
|
|
470
|
+
if (server.transport === "stdio")
|
|
471
|
+
return true;
|
|
472
|
+
return isLoopbackUrl(server.url);
|
|
473
|
+
}
|
|
474
|
+
function isLoopbackUrl(value) {
|
|
475
|
+
if (typeof value !== "string" || !value.trim())
|
|
476
|
+
return false;
|
|
477
|
+
let url;
|
|
478
|
+
try {
|
|
479
|
+
url = new URL(value);
|
|
480
|
+
} catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
484
|
+
if (host === "localhost" || host === "::1")
|
|
485
|
+
return true;
|
|
486
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
487
|
+
}
|
|
464
488
|
function mcpToolGrants(server) {
|
|
465
489
|
const tools = server.tools ?? [];
|
|
466
490
|
if (tools.length === 0)
|
|
@@ -727,14 +751,20 @@ function forgetShip(config2, shipId) {
|
|
|
727
751
|
delete overrides[shipId];
|
|
728
752
|
next.shipParallelJobs = overrides;
|
|
729
753
|
}
|
|
754
|
+
if (next.allowLocalMcp) {
|
|
755
|
+
next.allowLocalMcp = next.allowLocalMcp.filter((id) => id !== shipId);
|
|
756
|
+
}
|
|
730
757
|
return next;
|
|
731
758
|
}
|
|
759
|
+
function allowsLocalMcp(config2, shipId) {
|
|
760
|
+
return Array.isArray(config2.allowLocalMcp) && config2.allowLocalMcp.includes(shipId);
|
|
761
|
+
}
|
|
732
762
|
function mcpUrl(config2) {
|
|
733
763
|
return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`;
|
|
734
764
|
}
|
|
735
765
|
|
|
736
766
|
// src/version.ts
|
|
737
|
-
var RUNNER_VERSION = true ? "0.
|
|
767
|
+
var RUNNER_VERSION = true ? "0.6.0" : "0.0.0-dev";
|
|
738
768
|
|
|
739
769
|
// src/auth.ts
|
|
740
770
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -747,8 +777,8 @@ function functionsBaseUrlFor(projectId) {
|
|
|
747
777
|
return process.env.CREW_FUNCTIONS_URL?.replace(/\/$/, "") || `https://us-central1-${projectId}.cloudfunctions.net`;
|
|
748
778
|
}
|
|
749
779
|
var CallableError = class extends Error {
|
|
750
|
-
constructor(status,
|
|
751
|
-
super(
|
|
780
|
+
constructor(status, message2) {
|
|
781
|
+
super(message2);
|
|
752
782
|
this.status = status;
|
|
753
783
|
}
|
|
754
784
|
status;
|
|
@@ -781,8 +811,8 @@ async function request(baseUrl, name, data, headers) {
|
|
|
781
811
|
|
|
782
812
|
// src/auth.ts
|
|
783
813
|
var AuthError = class extends Error {
|
|
784
|
-
constructor(
|
|
785
|
-
super(
|
|
814
|
+
constructor(message2, gone = false) {
|
|
815
|
+
super(message2);
|
|
786
816
|
this.gone = gone;
|
|
787
817
|
}
|
|
788
818
|
gone;
|
|
@@ -1100,8 +1130,8 @@ function isTransientFirestoreError(error) {
|
|
|
1100
1130
|
if (code === "unavailable" || code === "deadline-exceeded" || code === "internal" || code === "cancelled" || code === "aborted" || code === "resource-exhausted") {
|
|
1101
1131
|
return true;
|
|
1102
1132
|
}
|
|
1103
|
-
const
|
|
1104
|
-
return typeof
|
|
1133
|
+
const message2 = error?.message;
|
|
1134
|
+
return typeof message2 === "string" && message2.toLowerCase().includes("client is offline");
|
|
1105
1135
|
}
|
|
1106
1136
|
async function withFirestoreRetry(read, sleep2 = (ms) => new Promise((r) => setTimeout(r, ms)), delays = FIRESTORE_RETRY_DELAYS_MS) {
|
|
1107
1137
|
for (let i = 0; ; i++) {
|
|
@@ -1589,8 +1619,8 @@ import path3 from "node:path";
|
|
|
1589
1619
|
function stepsFromClaudeEvent(event, opts) {
|
|
1590
1620
|
const type = typeof event?.type === "string" ? event.type : "";
|
|
1591
1621
|
if (type === "assistant") {
|
|
1592
|
-
const
|
|
1593
|
-
const content = Array.isArray(
|
|
1622
|
+
const message2 = event.message;
|
|
1623
|
+
const content = Array.isArray(message2?.content) ? message2.content : [];
|
|
1594
1624
|
const steps = [];
|
|
1595
1625
|
for (const block of content) {
|
|
1596
1626
|
if (!block || typeof block !== "object") continue;
|
|
@@ -1689,6 +1719,15 @@ function buildMcpConfig(input) {
|
|
|
1689
1719
|
};
|
|
1690
1720
|
for (const s of input.extraServers ?? []) {
|
|
1691
1721
|
if (s.key === "workspace") continue;
|
|
1722
|
+
if (s.transport === "stdio") {
|
|
1723
|
+
mcpServers[s.key] = {
|
|
1724
|
+
type: "stdio",
|
|
1725
|
+
command: s.command,
|
|
1726
|
+
...s.args?.length ? { args: s.args } : {},
|
|
1727
|
+
...s.env && Object.keys(s.env).length > 0 ? { env: s.env } : {}
|
|
1728
|
+
};
|
|
1729
|
+
continue;
|
|
1730
|
+
}
|
|
1692
1731
|
mcpServers[s.key] = {
|
|
1693
1732
|
type: s.transport === "sse" ? "sse" : "http",
|
|
1694
1733
|
url: s.url,
|
|
@@ -1946,7 +1985,15 @@ async function resolveMcpServers(input) {
|
|
|
1946
1985
|
functionsBaseUrl(input.config),
|
|
1947
1986
|
input.idToken,
|
|
1948
1987
|
"mintMcpJobConnections",
|
|
1949
|
-
{
|
|
1988
|
+
{
|
|
1989
|
+
shipId: input.shipId,
|
|
1990
|
+
jobId: input.jobId,
|
|
1991
|
+
// §15.40: the backend needs this to decide whether this daemon may be handed a LOCAL
|
|
1992
|
+
// connection. Sent rather than read from `runners/{id}`, which the heartbeat writes and
|
|
1993
|
+
// which is therefore at its most stale immediately after an auto-update — exactly when a
|
|
1994
|
+
// captain is waiting for their local connection to start working.
|
|
1995
|
+
runnerVersion: RUNNER_VERSION
|
|
1996
|
+
}
|
|
1950
1997
|
);
|
|
1951
1998
|
} catch (e) {
|
|
1952
1999
|
input.log(
|
|
@@ -1957,17 +2004,265 @@ async function resolveMcpServers(input) {
|
|
|
1957
2004
|
for (const s of res.skipped ?? []) {
|
|
1958
2005
|
input.log(`MCP connection "${s.key}" skipped: ${s.reason}.`);
|
|
1959
2006
|
}
|
|
1960
|
-
return res.servers ?? [];
|
|
2007
|
+
return filterLocal(res.servers ?? [], input);
|
|
2008
|
+
}
|
|
2009
|
+
function filterLocal(servers, input) {
|
|
2010
|
+
if (servers.every((s) => !isLocalMcpServer(s))) return [...servers];
|
|
2011
|
+
if (allowsLocalMcp(input.config, input.shipId)) return [...servers];
|
|
2012
|
+
const kept = [];
|
|
2013
|
+
for (const server of servers) {
|
|
2014
|
+
if (!isLocalMcpServer(server)) {
|
|
2015
|
+
kept.push(server);
|
|
2016
|
+
continue;
|
|
2017
|
+
}
|
|
2018
|
+
input.log(
|
|
2019
|
+
`MCP connection "${server.key}" skipped: local MCP is off for this Ship on this machine \u2014 run \`lumi-runner config set local-mcp on --ship ${input.shipId}\` here to allow it.`
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
2022
|
+
return kept;
|
|
1961
2023
|
}
|
|
1962
2024
|
function mcpSecretsToRedact(servers) {
|
|
1963
2025
|
const out = [];
|
|
1964
2026
|
for (const s of servers) {
|
|
1965
2027
|
out.push(...Object.values(s.headers ?? {}));
|
|
1966
2028
|
if (s.secretValue) out.push(s.secretValue);
|
|
2029
|
+
out.push(...s.secretEnvValues ?? []);
|
|
1967
2030
|
}
|
|
1968
2031
|
return out;
|
|
1969
2032
|
}
|
|
1970
2033
|
|
|
2034
|
+
// src/jobs/localProbe.ts
|
|
2035
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
2036
|
+
var PROBE_TIMEOUT_MS = 15e3;
|
|
2037
|
+
var PROTOCOL_VERSION = "2024-11-05";
|
|
2038
|
+
async function probeAndReport(input) {
|
|
2039
|
+
const { shipId, server } = input;
|
|
2040
|
+
if (!allowsLocalMcp(input.config, shipId)) {
|
|
2041
|
+
await report(input, {
|
|
2042
|
+
ok: false,
|
|
2043
|
+
toolNames: [],
|
|
2044
|
+
error: `This machine has not allowed this Ship to run local MCP connections. On the machine, run: lumi-runner config set local-mcp on --ship ${shipId}`
|
|
2045
|
+
});
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
let resolved;
|
|
2049
|
+
try {
|
|
2050
|
+
const res = await callFunction(
|
|
2051
|
+
functionsBaseUrl(input.config),
|
|
2052
|
+
input.idToken,
|
|
2053
|
+
"mintShipMcpProbe",
|
|
2054
|
+
{ shipId, serverKey: server.id, runnerVersion: RUNNER_VERSION }
|
|
2055
|
+
);
|
|
2056
|
+
resolved = res.server;
|
|
2057
|
+
} catch (e) {
|
|
2058
|
+
await report(input, { ok: false, toolNames: [], error: message(e) });
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
const result = await probe(resolved);
|
|
2062
|
+
await report(input, result);
|
|
2063
|
+
input.log(
|
|
2064
|
+
result.ok ? `MCP connection "${server.id}" tested: ${result.toolNames.length} tool(s).` : `MCP connection "${server.id}" could not be started: ${result.error}`
|
|
2065
|
+
);
|
|
2066
|
+
}
|
|
2067
|
+
async function probe(server) {
|
|
2068
|
+
try {
|
|
2069
|
+
const toolNames = server.transport === "stdio" ? await probeStdio(server) : await probeHttp(server);
|
|
2070
|
+
return { ok: true, toolNames };
|
|
2071
|
+
} catch (e) {
|
|
2072
|
+
return { ok: false, toolNames: [], error: message(e) };
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
async function probeStdio(server) {
|
|
2076
|
+
const child = spawn4(server.command ?? "", server.args ?? [], {
|
|
2077
|
+
// The parent's environment PLUS the Ship's, not the Ship's alone: a program started with no
|
|
2078
|
+
// PATH cannot find node, and `npx` cannot find anything at all. The Ship's values win, which is
|
|
2079
|
+
// what lets a connection override a variable the operator happens to have set.
|
|
2080
|
+
env: { ...process.env, ...server.env ?? {} },
|
|
2081
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2082
|
+
});
|
|
2083
|
+
let stderr = "";
|
|
2084
|
+
child.stderr.on("data", (chunk) => {
|
|
2085
|
+
if (stderr.length < 4e3) stderr += chunk.toString();
|
|
2086
|
+
});
|
|
2087
|
+
const replies = new RpcReader(child.stdout);
|
|
2088
|
+
const spawnFailed = new Promise((_, reject) => {
|
|
2089
|
+
child.on("error", (e) => reject(new Error(`the program could not be started: ${e.message}`)));
|
|
2090
|
+
});
|
|
2091
|
+
child.on("close", () => replies.close());
|
|
2092
|
+
try {
|
|
2093
|
+
return await withTimeout(
|
|
2094
|
+
Promise.race([
|
|
2095
|
+
spawnFailed,
|
|
2096
|
+
(async () => {
|
|
2097
|
+
send(child, 1, "initialize", {
|
|
2098
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
2099
|
+
capabilities: {},
|
|
2100
|
+
clientInfo: { name: "lumi-runner", version: RUNNER_VERSION }
|
|
2101
|
+
});
|
|
2102
|
+
await replies.waitFor(1, () => stderr);
|
|
2103
|
+
child.stdin.write(
|
|
2104
|
+
`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}
|
|
2105
|
+
`
|
|
2106
|
+
);
|
|
2107
|
+
send(child, 2, "tools/list", {});
|
|
2108
|
+
return toolNamesFrom(await replies.waitFor(2, () => stderr));
|
|
2109
|
+
})()
|
|
2110
|
+
]),
|
|
2111
|
+
// The stderr tail is the useful half of a startup failure — "MODULE_NOT_FOUND", "missing
|
|
2112
|
+
// CLICKUP_API_KEY" — and without it the captain gets "timed out" and nothing to act on.
|
|
2113
|
+
() => `it did not respond within ${PROBE_TIMEOUT_MS / 1e3}s${stderr.trim() ? `: ${tail(stderr)}` : ""}`
|
|
2114
|
+
);
|
|
2115
|
+
} finally {
|
|
2116
|
+
child.kill("SIGKILL");
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
async function probeHttp(server) {
|
|
2120
|
+
const post = async (id, method, params) => {
|
|
2121
|
+
const res = await fetch(server.url ?? "", {
|
|
2122
|
+
method: "POST",
|
|
2123
|
+
headers: {
|
|
2124
|
+
"content-type": "application/json",
|
|
2125
|
+
// Streamable HTTP servers may answer either way, and a server that only speaks SSE will
|
|
2126
|
+
// refuse a request that does not say it can read one.
|
|
2127
|
+
accept: "application/json, text/event-stream",
|
|
2128
|
+
...server.headers ?? {}
|
|
2129
|
+
},
|
|
2130
|
+
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
|
|
2131
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
|
2132
|
+
});
|
|
2133
|
+
if (!res.ok) throw new Error(`the server answered ${res.status} ${res.statusText}`);
|
|
2134
|
+
const body = await res.text();
|
|
2135
|
+
return parseRpc(body, id);
|
|
2136
|
+
};
|
|
2137
|
+
await post(1, "initialize", {
|
|
2138
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
2139
|
+
capabilities: {},
|
|
2140
|
+
clientInfo: { name: "lumi-runner", version: RUNNER_VERSION }
|
|
2141
|
+
});
|
|
2142
|
+
return toolNamesFrom(await post(2, "tools/list", {}));
|
|
2143
|
+
}
|
|
2144
|
+
function parseRpc(body, id) {
|
|
2145
|
+
const candidates = body.includes("data:") ? body.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()) : [body.trim()];
|
|
2146
|
+
for (const candidate of candidates) {
|
|
2147
|
+
if (!candidate) continue;
|
|
2148
|
+
let parsed;
|
|
2149
|
+
try {
|
|
2150
|
+
parsed = JSON.parse(candidate);
|
|
2151
|
+
} catch {
|
|
2152
|
+
continue;
|
|
2153
|
+
}
|
|
2154
|
+
if (parsed.id !== id) continue;
|
|
2155
|
+
if (parsed.error) throw new Error(parsed.error.message ?? "the server returned an error");
|
|
2156
|
+
return parsed.result;
|
|
2157
|
+
}
|
|
2158
|
+
throw new Error("the server did not answer in a shape this understands");
|
|
2159
|
+
}
|
|
2160
|
+
function send(child, id, method, params) {
|
|
2161
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}
|
|
2162
|
+
`);
|
|
2163
|
+
}
|
|
2164
|
+
var RpcReader = class {
|
|
2165
|
+
buffer = "";
|
|
2166
|
+
seen = [];
|
|
2167
|
+
wake = [];
|
|
2168
|
+
closed = false;
|
|
2169
|
+
constructor(stream) {
|
|
2170
|
+
stream.on("data", (chunk) => {
|
|
2171
|
+
this.buffer += chunk.toString();
|
|
2172
|
+
let index = this.buffer.indexOf("\n");
|
|
2173
|
+
while (index >= 0) {
|
|
2174
|
+
const line = this.buffer.slice(0, index).trim();
|
|
2175
|
+
this.buffer = this.buffer.slice(index + 1);
|
|
2176
|
+
if (line) this.accept(line);
|
|
2177
|
+
index = this.buffer.indexOf("\n");
|
|
2178
|
+
}
|
|
2179
|
+
});
|
|
2180
|
+
}
|
|
2181
|
+
accept(line) {
|
|
2182
|
+
let parsed;
|
|
2183
|
+
try {
|
|
2184
|
+
parsed = JSON.parse(line);
|
|
2185
|
+
} catch {
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
if (typeof parsed.id !== "number") return;
|
|
2189
|
+
this.seen.push({
|
|
2190
|
+
id: parsed.id,
|
|
2191
|
+
...parsed.error ? { error: parsed.error.message ?? "the server returned an error" } : {},
|
|
2192
|
+
result: parsed.result
|
|
2193
|
+
});
|
|
2194
|
+
this.notify();
|
|
2195
|
+
}
|
|
2196
|
+
close() {
|
|
2197
|
+
this.closed = true;
|
|
2198
|
+
this.notify();
|
|
2199
|
+
}
|
|
2200
|
+
notify() {
|
|
2201
|
+
const waiters = this.wake;
|
|
2202
|
+
this.wake = [];
|
|
2203
|
+
for (const w of waiters) w();
|
|
2204
|
+
}
|
|
2205
|
+
/** The response with this id, or a throw naming what the program said instead. */
|
|
2206
|
+
async waitFor(id, stderr) {
|
|
2207
|
+
for (; ; ) {
|
|
2208
|
+
const hit = this.seen.find((r) => r.id === id);
|
|
2209
|
+
if (hit) {
|
|
2210
|
+
if (hit.error) throw new Error(hit.error);
|
|
2211
|
+
return hit.result;
|
|
2212
|
+
}
|
|
2213
|
+
if (this.closed) {
|
|
2214
|
+
const said = stderr().trim();
|
|
2215
|
+
throw new Error(
|
|
2216
|
+
`the program stopped without answering${said ? `: ${tail(said)}` : ""}`
|
|
2217
|
+
);
|
|
2218
|
+
}
|
|
2219
|
+
await new Promise((resolve) => this.wake.push(resolve));
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
};
|
|
2223
|
+
function toolNamesFrom(result) {
|
|
2224
|
+
const tools = result?.tools ?? [];
|
|
2225
|
+
return tools.map((t) => String(t?.name ?? "")).filter(Boolean);
|
|
2226
|
+
}
|
|
2227
|
+
async function withTimeout(work, onTimeout) {
|
|
2228
|
+
let timer;
|
|
2229
|
+
try {
|
|
2230
|
+
return await Promise.race([
|
|
2231
|
+
work,
|
|
2232
|
+
new Promise((_, reject) => {
|
|
2233
|
+
timer = setTimeout(() => reject(new Error(onTimeout())), PROBE_TIMEOUT_MS);
|
|
2234
|
+
})
|
|
2235
|
+
]);
|
|
2236
|
+
} finally {
|
|
2237
|
+
if (timer) clearTimeout(timer);
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
async function report(input, result) {
|
|
2241
|
+
try {
|
|
2242
|
+
await callFunction(
|
|
2243
|
+
functionsBaseUrl(input.config),
|
|
2244
|
+
input.idToken,
|
|
2245
|
+
"reportShipMcpProbe",
|
|
2246
|
+
{
|
|
2247
|
+
shipId: input.shipId,
|
|
2248
|
+
serverKey: input.server.id,
|
|
2249
|
+
ok: result.ok,
|
|
2250
|
+
toolNames: result.toolNames,
|
|
2251
|
+
...result.error ? { error: result.error } : {}
|
|
2252
|
+
}
|
|
2253
|
+
);
|
|
2254
|
+
} catch (e) {
|
|
2255
|
+
input.log(`Could not report the MCP test for "${input.server.id}": ${message(e)}`);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
var message = (e) => e instanceof Error ? e.message : String(e);
|
|
2259
|
+
var tail = (s) => s.trim().slice(-300);
|
|
2260
|
+
function serversNeedingProbe(servers) {
|
|
2261
|
+
return servers.filter(
|
|
2262
|
+
(s) => s.enabled !== false && isLocalMcpServer(s) && typeof s.probeRequestedAt === "number" && s.probeRequestedAt > (s.probe?.at ?? 0)
|
|
2263
|
+
);
|
|
2264
|
+
}
|
|
2265
|
+
|
|
1971
2266
|
// src/jobs/secrets.ts
|
|
1972
2267
|
import { doc as doc4, getDoc as getDoc4 } from "firebase/firestore";
|
|
1973
2268
|
async function loadRunnerSecrets(db, shipId) {
|
|
@@ -2935,15 +3230,15 @@ function clearUpdateState() {
|
|
|
2935
3230
|
}
|
|
2936
3231
|
|
|
2937
3232
|
// src/update/install.ts
|
|
2938
|
-
import { spawn as
|
|
3233
|
+
import { spawn as spawn5, spawnSync as spawnSync2 } from "node:child_process";
|
|
2939
3234
|
import fs6 from "node:fs";
|
|
2940
3235
|
import path7 from "node:path";
|
|
2941
3236
|
var INSTALL_TIMEOUT_MS = 5 * 6e4;
|
|
2942
3237
|
var STDERR_KEEP = 400;
|
|
2943
3238
|
function npmPresent() {
|
|
2944
|
-
const
|
|
3239
|
+
const probe2 = process.platform === "win32" ? "where" : "which";
|
|
2945
3240
|
try {
|
|
2946
|
-
return spawnSync2(
|
|
3241
|
+
return spawnSync2(probe2, ["npm"], { encoding: "utf8" }).status === 0;
|
|
2947
3242
|
} catch {
|
|
2948
3243
|
return false;
|
|
2949
3244
|
}
|
|
@@ -2959,7 +3254,7 @@ async function installGlobal(target, cliPath2, options = {}) {
|
|
|
2959
3254
|
return new Promise((resolve) => {
|
|
2960
3255
|
let child;
|
|
2961
3256
|
try {
|
|
2962
|
-
child =
|
|
3257
|
+
child = spawn5("npm", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
2963
3258
|
} catch (e) {
|
|
2964
3259
|
resolve({ ok: false, detail: e instanceof Error ? e.message : String(e) });
|
|
2965
3260
|
return;
|
|
@@ -3193,6 +3488,55 @@ async function startDaemon() {
|
|
|
3193
3488
|
},
|
|
3194
3489
|
listenerError(shipId, "agents")
|
|
3195
3490
|
),
|
|
3491
|
+
/**
|
|
3492
|
+
* MCP connections, for the ONE thing only this machine can answer (§15.40): a captain
|
|
3493
|
+
* pressing Test on a connection that runs here.
|
|
3494
|
+
*
|
|
3495
|
+
* A third listener on a small collection — at most 20 documents per Ship — and it earns its
|
|
3496
|
+
* place by being the only route the question has. The cloud cannot reach a program on this
|
|
3497
|
+
* laptop, so without this the Test button on a local connection is a button that does nothing.
|
|
3498
|
+
*
|
|
3499
|
+
* `serversNeedingProbe` deliberately answers "nothing" for a connection nobody asked about,
|
|
3500
|
+
* INCLUDING at startup: a daemon coming up must not spawn every program on the Ship to see
|
|
3501
|
+
* what happens. Testing is somebody's decision, and `doctor` is where it is made on purpose.
|
|
3502
|
+
*/
|
|
3503
|
+
onSnapshot2(
|
|
3504
|
+
collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers),
|
|
3505
|
+
(snap) => {
|
|
3506
|
+
const servers = snap.docs.map(
|
|
3507
|
+
(d) => ({ id: d.id, ...d.data() })
|
|
3508
|
+
);
|
|
3509
|
+
for (const server of serversNeedingProbe(servers)) {
|
|
3510
|
+
void (async () => {
|
|
3511
|
+
try {
|
|
3512
|
+
const idToken = await sess(shipId).user.getIdToken();
|
|
3513
|
+
await probeAndReport({ config: config2, idToken, shipId, server, log: log2 });
|
|
3514
|
+
} catch (e) {
|
|
3515
|
+
log2(
|
|
3516
|
+
`MCP test for "${server.id}" (${shipId}) failed to start: ${e instanceof Error ? e.message : String(e)}`
|
|
3517
|
+
);
|
|
3518
|
+
}
|
|
3519
|
+
})();
|
|
3520
|
+
}
|
|
3521
|
+
},
|
|
3522
|
+
/**
|
|
3523
|
+
* ITS OWN ERROR HANDLER, and NOT `listenerError` — this is the important line.
|
|
3524
|
+
*
|
|
3525
|
+
* `listenerError` treats any `permission-denied` as "a captain removed this machine" and
|
|
3526
|
+
* calls `forgetShipLocally`, which stops serving the Ship until the daemon restarts. That
|
|
3527
|
+
* inference is sound for the listeners it was written for; it is badly wrong here.
|
|
3528
|
+
*
|
|
3529
|
+
* `mcp_servers` is gated on `isShipReader`, which a REVOKED machine fails — and revocation
|
|
3530
|
+
* is a state the daemon is expected to sit through and recover from, without a restart and
|
|
3531
|
+
* without re-minting (e2e-runner asserts exactly that: approve → revoke → re-approve, same
|
|
3532
|
+
* process). Reading a denial here as removal would turn a temporary revocation into a
|
|
3533
|
+
* permanent one.
|
|
3534
|
+
*
|
|
3535
|
+
* The blast radius is the whole argument. This listener exists to make a captain's Test
|
|
3536
|
+
* button answer promptly. Losing it costs that button; it must never cost the Ship.
|
|
3537
|
+
*/
|
|
3538
|
+
(e) => console.error(`mcp connections listener error (${shipId}):`, e.message)
|
|
3539
|
+
),
|
|
3196
3540
|
// Exhausted usage windows. The FIRST snapshot is the startup seed: a restarted daemon —
|
|
3197
3541
|
// and a second daemon on this Ship — inherits the pause instead of running a doomed
|
|
3198
3542
|
// session to rediscover it.
|
|
@@ -3901,8 +4245,8 @@ async function startDaemon() {
|
|
|
3901
4245
|
import * as clack from "@clack/prompts";
|
|
3902
4246
|
import { createColors } from "picocolors";
|
|
3903
4247
|
var CliError = class extends Error {
|
|
3904
|
-
constructor(
|
|
3905
|
-
super(
|
|
4248
|
+
constructor(message2, exitCode = 1) {
|
|
4249
|
+
super(message2);
|
|
3906
4250
|
this.exitCode = exitCode;
|
|
3907
4251
|
this.name = "CliError";
|
|
3908
4252
|
}
|
|
@@ -3947,30 +4291,30 @@ var say = {
|
|
|
3947
4291
|
intro(title) {
|
|
3948
4292
|
if (!jsonMode) clack.intro(pc.bgCyan(pc.black(` ${title} `)));
|
|
3949
4293
|
},
|
|
3950
|
-
outro(
|
|
3951
|
-
if (!jsonMode) clack.outro(
|
|
4294
|
+
outro(message2) {
|
|
4295
|
+
if (!jsonMode) clack.outro(message2);
|
|
3952
4296
|
},
|
|
3953
|
-
info(
|
|
3954
|
-
if (!jsonMode) clack.log.info(
|
|
4297
|
+
info(message2) {
|
|
4298
|
+
if (!jsonMode) clack.log.info(message2);
|
|
3955
4299
|
},
|
|
3956
|
-
success(
|
|
3957
|
-
if (!jsonMode) clack.log.success(
|
|
4300
|
+
success(message2) {
|
|
4301
|
+
if (!jsonMode) clack.log.success(message2);
|
|
3958
4302
|
},
|
|
3959
|
-
warn(
|
|
3960
|
-
if (!jsonMode) clack.log.warn(
|
|
4303
|
+
warn(message2) {
|
|
4304
|
+
if (!jsonMode) clack.log.warn(message2);
|
|
3961
4305
|
},
|
|
3962
|
-
error(
|
|
3963
|
-
if (!jsonMode) clack.log.error(
|
|
4306
|
+
error(message2) {
|
|
4307
|
+
if (!jsonMode) clack.log.error(message2);
|
|
3964
4308
|
},
|
|
3965
|
-
step(
|
|
3966
|
-
if (!jsonMode) clack.log.step(
|
|
4309
|
+
step(message2) {
|
|
4310
|
+
if (!jsonMode) clack.log.step(message2);
|
|
3967
4311
|
},
|
|
3968
4312
|
note(body, title) {
|
|
3969
4313
|
if (!jsonMode) clack.note(body, title);
|
|
3970
4314
|
},
|
|
3971
4315
|
/** Raw line straight to stdout — for `logs`, where the content IS the output. */
|
|
3972
|
-
line(
|
|
3973
|
-
process.stdout.write(`${
|
|
4316
|
+
line(message2) {
|
|
4317
|
+
process.stdout.write(`${message2}
|
|
3974
4318
|
`);
|
|
3975
4319
|
}
|
|
3976
4320
|
};
|
|
@@ -4036,6 +4380,8 @@ var CHANNEL = "channel";
|
|
|
4036
4380
|
var CHANNEL_HELP = "Which npm release channel to follow (default: derived from the version)";
|
|
4037
4381
|
var CHECK_EVERY = "checkEvery";
|
|
4038
4382
|
var CHECK_EVERY_HELP = `Minutes between update checks (${CHECK_MINUTES_MIN}-${CHECK_MINUTES_MAX})`;
|
|
4383
|
+
var LOCAL_MCP = "local-mcp";
|
|
4384
|
+
var LOCAL_MCP_HELP = "Allow a Ship to run MCP servers on this machine (needs --ship)";
|
|
4039
4385
|
var DEFAULT_WORDS = ["default", "auto", "reset"];
|
|
4040
4386
|
function isToggle(key) {
|
|
4041
4387
|
return Object.hasOwn(TOGGLES, key);
|
|
@@ -4080,7 +4426,8 @@ async function runConfigList() {
|
|
|
4080
4426
|
const ships = config2.ships.map((shipId) => ({
|
|
4081
4427
|
shipId,
|
|
4082
4428
|
parallel: shipCap(config2, shipId),
|
|
4083
|
-
explicit: config2.shipParallelJobs?.[shipId] !== void 0
|
|
4429
|
+
explicit: config2.shipParallelJobs?.[shipId] !== void 0,
|
|
4430
|
+
localMcp: allowsLocalMcp(config2, shipId)
|
|
4084
4431
|
}));
|
|
4085
4432
|
const channel = resolveChannel(RUNNER_VERSION, config2, process.env);
|
|
4086
4433
|
const checkEvery = checkIntervalMs(config2) / 6e4;
|
|
@@ -4096,22 +4443,29 @@ async function runConfigList() {
|
|
|
4096
4443
|
say.line(` ${PARALLEL.padEnd(16)} ${String(machine).padEnd(2)} ${PARALLEL_HELP}`);
|
|
4097
4444
|
say.line(` ${CHANNEL.padEnd(16)} ${channel.padEnd(2)} ${CHANNEL_HELP}`);
|
|
4098
4445
|
say.line(` ${CHECK_EVERY.padEnd(16)} ${String(checkEvery).padEnd(2)} ${CHECK_EVERY_HELP}`);
|
|
4446
|
+
say.line(` ${LOCAL_MCP.padEnd(16)} ${"--".padEnd(2)} ${LOCAL_MCP_HELP}`);
|
|
4099
4447
|
if (ships.length > 0) {
|
|
4100
4448
|
say.line("");
|
|
4101
4449
|
for (const ship2 of ships) {
|
|
4102
4450
|
const note2 = ship2.explicit ? "" : " (machine default)";
|
|
4103
4451
|
say.line(` ${`${PARALLEL} --ship`.padEnd(16)} ${ship2.shipId} = ${ship2.parallel}${note2}`);
|
|
4104
4452
|
}
|
|
4453
|
+
for (const ship2 of ships) {
|
|
4454
|
+
say.line(
|
|
4455
|
+
` ${`${LOCAL_MCP} --ship`.padEnd(16)} ${ship2.shipId} = ${ship2.localMcp ? "on" : "off"}`
|
|
4456
|
+
);
|
|
4457
|
+
}
|
|
4105
4458
|
}
|
|
4106
4459
|
return 0;
|
|
4107
4460
|
}
|
|
4108
4461
|
async function runConfigSet(key, value, options = {}) {
|
|
4109
4462
|
const config2 = requireConfig();
|
|
4110
4463
|
if (key === PARALLEL) return setParallel(config2, value, options.ship);
|
|
4464
|
+
if (key === LOCAL_MCP) return setLocalMcp(config2, value, options.ship);
|
|
4111
4465
|
if (key === CHANNEL || key === CHECK_EVERY) return setUpdateSetting(config2, key, value, options.ship);
|
|
4112
4466
|
if (!isToggle(key)) {
|
|
4113
4467
|
throw new CliError(
|
|
4114
|
-
`Unknown setting "${key}". Known: ${[...Object.keys(TOGGLES), PARALLEL, CHANNEL, CHECK_EVERY].join(", ")}.`
|
|
4468
|
+
`Unknown setting "${key}". Known: ${[...Object.keys(TOGGLES), PARALLEL, LOCAL_MCP, CHANNEL, CHECK_EVERY].join(", ")}.`
|
|
4115
4469
|
);
|
|
4116
4470
|
}
|
|
4117
4471
|
if (options.ship) {
|
|
@@ -4156,6 +4510,37 @@ function setUpdateSetting(config2, key, value, ship2) {
|
|
|
4156
4510
|
say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
|
|
4157
4511
|
return 0;
|
|
4158
4512
|
}
|
|
4513
|
+
function setLocalMcp(config2, value, ship2) {
|
|
4514
|
+
if (!ship2) {
|
|
4515
|
+
throw new CliError(
|
|
4516
|
+
`"${LOCAL_MCP}" is granted one Ship at a time: \`lumi-runner config set ${LOCAL_MCP} ${value} --ship <shipId>\`. There is no machine-wide form \u2014 allowing a Ship to run programs here should be a decision about that Ship. \`lumi-runner ship list\` shows the ids.`
|
|
4517
|
+
);
|
|
4518
|
+
}
|
|
4519
|
+
if (!config2.ships.includes(ship2)) {
|
|
4520
|
+
throw new CliError(
|
|
4521
|
+
`This machine does not serve Ship "${ship2}". Run \`lumi-runner ship list\` to see which Ships it serves, or \`lumi-runner ship add ${ship2}\` to add it.`
|
|
4522
|
+
);
|
|
4523
|
+
}
|
|
4524
|
+
const allowed = parseBool(value);
|
|
4525
|
+
const current = new Set(config2.allowLocalMcp ?? []);
|
|
4526
|
+
if (allowed) current.add(ship2);
|
|
4527
|
+
else current.delete(ship2);
|
|
4528
|
+
if (current.size > 0) config2.allowLocalMcp = [...current].sort();
|
|
4529
|
+
else delete config2.allowLocalMcp;
|
|
4530
|
+
saveConfig(config2);
|
|
4531
|
+
if (isJson()) {
|
|
4532
|
+
emitJson({ localMcp: allowed, shipId: ship2 });
|
|
4533
|
+
return 0;
|
|
4534
|
+
}
|
|
4535
|
+
say.success(`${LOCAL_MCP} = ${allowed ? "on" : "off"} for Ship ${ship2}`);
|
|
4536
|
+
if (allowed) {
|
|
4537
|
+
say.line(
|
|
4538
|
+
" This Ship may now start MCP servers as programs on this machine, and reach servers on its loopback address. Only the connections a captain registered on that Ship, and only the ones activated on the agent running the job."
|
|
4539
|
+
);
|
|
4540
|
+
}
|
|
4541
|
+
say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
|
|
4542
|
+
return 0;
|
|
4543
|
+
}
|
|
4159
4544
|
function setParallel(config2, value, ship2) {
|
|
4160
4545
|
const parsed = parseParallel(value);
|
|
4161
4546
|
if (ship2) {
|
|
@@ -4221,8 +4606,8 @@ var fail = (id, label, detail, fix) => ({
|
|
|
4221
4606
|
fix
|
|
4222
4607
|
});
|
|
4223
4608
|
function onPath(binary) {
|
|
4224
|
-
const
|
|
4225
|
-
return spawnSync3(
|
|
4609
|
+
const probe2 = process.platform === "win32" ? "where" : "which";
|
|
4610
|
+
return spawnSync3(probe2, [binary], { stdio: "ignore" }).status === 0;
|
|
4226
4611
|
}
|
|
4227
4612
|
function checkNode() {
|
|
4228
4613
|
const major = Number(process.versions.node.split(".")[0]);
|
|
@@ -4285,6 +4670,19 @@ function versionCheckFrom(input) {
|
|
|
4285
4670
|
autoUpdate ? "Nothing to do \u2014 this machine installs it by itself. `lumi-runner update` does it now." : "Auto-update is off. Run `lumi-runner update`."
|
|
4286
4671
|
);
|
|
4287
4672
|
}
|
|
4673
|
+
function localMcpCheckFrom(shipId, servers, allowed) {
|
|
4674
|
+
const local = servers.filter((s) => s.enabled !== false && isLocalMcpServer(s));
|
|
4675
|
+
if (local.length === 0) return null;
|
|
4676
|
+
const id = `localmcp:${shipId}`;
|
|
4677
|
+
const label = `Ship ${shipId} \u2014 local MCP`;
|
|
4678
|
+
const names = local.map((s) => s.id).join(", ");
|
|
4679
|
+
return allowed ? ok(id, label, `Allowed on this machine: ${names}.`) : warn(
|
|
4680
|
+
id,
|
|
4681
|
+
label,
|
|
4682
|
+
`${local.length} connection(s) run on this machine and are not allowed here: ${names}.`,
|
|
4683
|
+
`lumi-runner config set local-mcp on --ship ${shipId}`
|
|
4684
|
+
);
|
|
4685
|
+
}
|
|
4288
4686
|
async function checkVersion(config2) {
|
|
4289
4687
|
const channel = resolveChannel(RUNNER_VERSION, config2, process.env);
|
|
4290
4688
|
const tags = await fetchDistTags();
|
|
@@ -4387,6 +4785,17 @@ async function checkShips(config2) {
|
|
|
4387
4785
|
)
|
|
4388
4786
|
);
|
|
4389
4787
|
}
|
|
4788
|
+
try {
|
|
4789
|
+
const snap = await getDocs5(
|
|
4790
|
+
collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
|
|
4791
|
+
);
|
|
4792
|
+
const servers = snap.docs.map(
|
|
4793
|
+
(d) => ({ id: d.id, ...d.data() })
|
|
4794
|
+
);
|
|
4795
|
+
const check = localMcpCheckFrom(shipId, servers, allowsLocalMcp(config2, shipId));
|
|
4796
|
+
if (check) checks.push(check);
|
|
4797
|
+
} catch {
|
|
4798
|
+
}
|
|
4390
4799
|
}
|
|
4391
4800
|
return { checks, engines, needsGithub };
|
|
4392
4801
|
}
|
|
@@ -4397,7 +4806,7 @@ async function runDoctor() {
|
|
|
4397
4806
|
checks.push(
|
|
4398
4807
|
fail("config", "Configuration", "This machine is not connected.", "Run `lumi-runner setup`.")
|
|
4399
4808
|
);
|
|
4400
|
-
return
|
|
4809
|
+
return report2(checks);
|
|
4401
4810
|
}
|
|
4402
4811
|
checks.push(ok("config", "Configuration", `Runner ${config2.runnerId} on project ${config2.projectId}`));
|
|
4403
4812
|
const progress = spinner2();
|
|
@@ -4432,9 +4841,9 @@ async function runDoctor() {
|
|
|
4432
4841
|
checks.push(checkService());
|
|
4433
4842
|
checks.push(await checkVersion(config2));
|
|
4434
4843
|
progress.stop("Checks complete.");
|
|
4435
|
-
return
|
|
4844
|
+
return report2(checks);
|
|
4436
4845
|
}
|
|
4437
|
-
function
|
|
4846
|
+
function report2(checks) {
|
|
4438
4847
|
const failed = checks.filter((c) => c.level === "fail");
|
|
4439
4848
|
const warned = checks.filter((c) => c.level === "warn");
|
|
4440
4849
|
if (isJson()) {
|
|
@@ -4463,7 +4872,7 @@ function report(checks) {
|
|
|
4463
4872
|
}
|
|
4464
4873
|
|
|
4465
4874
|
// src/cli/commands/login.ts
|
|
4466
|
-
import { spawn as
|
|
4875
|
+
import { spawn as spawn6 } from "node:child_process";
|
|
4467
4876
|
import os5 from "node:os";
|
|
4468
4877
|
import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
|
|
4469
4878
|
function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
|
|
@@ -4489,7 +4898,7 @@ function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
|
|
|
4489
4898
|
function openBrowser(url) {
|
|
4490
4899
|
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
4491
4900
|
try {
|
|
4492
|
-
const child =
|
|
4901
|
+
const child = spawn6(command, args, { stdio: "ignore", detached: true });
|
|
4493
4902
|
child.on("error", () => {
|
|
4494
4903
|
});
|
|
4495
4904
|
child.unref();
|
|
@@ -4566,7 +4975,7 @@ Code: ${pc.bold(start.displayCode)}`,
|
|
|
4566
4975
|
const first = approvedShips[0];
|
|
4567
4976
|
const uid2 = first ? (await signInWithCustomToken2(fb.auth, (await exchange(config2, first)).customToken)).user.uid : "(no Ship approved yet)";
|
|
4568
4977
|
saveConfig(config2);
|
|
4569
|
-
return
|
|
4978
|
+
return report3({ runnerId: config2.runnerId, uid: uid2, ships: config2.ships, approvedShips, pendingShips });
|
|
4570
4979
|
}
|
|
4571
4980
|
async function exchange(config2, shipId) {
|
|
4572
4981
|
return callPublicFunction(
|
|
@@ -4602,7 +5011,7 @@ async function loginWithKey(options) {
|
|
|
4602
5011
|
const fb = initFirebase(config2);
|
|
4603
5012
|
const cred = await signInWithCustomToken2(fb.auth, session.customToken);
|
|
4604
5013
|
saveConfig(config2);
|
|
4605
|
-
return
|
|
5014
|
+
return report3({
|
|
4606
5015
|
runnerId: config2.runnerId,
|
|
4607
5016
|
uid: cred.user.uid,
|
|
4608
5017
|
ships: config2.ships,
|
|
@@ -4614,7 +5023,7 @@ function parseKeyShipId(key) {
|
|
|
4614
5023
|
const parts = (key ?? "").split("_");
|
|
4615
5024
|
return parts.length === 3 && parts[0] === "crewrunner" && parts[1] && parts[2] ? parts[1] : null;
|
|
4616
5025
|
}
|
|
4617
|
-
function
|
|
5026
|
+
function report3(result) {
|
|
4618
5027
|
if (isJson()) {
|
|
4619
5028
|
emitJson({ version: RUNNER_VERSION, ...result });
|
|
4620
5029
|
return 0;
|
|
@@ -4634,12 +5043,12 @@ function report2(result) {
|
|
|
4634
5043
|
|
|
4635
5044
|
// src/cli/commands/logs.ts
|
|
4636
5045
|
async function runLogs(options) {
|
|
4637
|
-
const
|
|
4638
|
-
if (
|
|
5046
|
+
const tail2 = readTail(options.lines);
|
|
5047
|
+
if (tail2.length === 0 && !options.follow) {
|
|
4639
5048
|
say.warn(`No log output yet at ${logFile()}.`);
|
|
4640
5049
|
return 0;
|
|
4641
5050
|
}
|
|
4642
|
-
for (const line of
|
|
5051
|
+
for (const line of tail2) say.line(line);
|
|
4643
5052
|
if (!options.follow) return 0;
|
|
4644
5053
|
await new Promise((resolve) => {
|
|
4645
5054
|
const stop = followLog((line) => say.line(line));
|
|
@@ -5107,10 +5516,10 @@ function action(handler) {
|
|
|
5107
5516
|
try {
|
|
5108
5517
|
process.exitCode = await handler(...args);
|
|
5109
5518
|
} catch (error) {
|
|
5110
|
-
const
|
|
5111
|
-
if (isJson()) process.stderr.write(`${JSON.stringify({ error:
|
|
5519
|
+
const message2 = error instanceof Error ? error.message : String(error);
|
|
5520
|
+
if (isJson()) process.stderr.write(`${JSON.stringify({ error: message2 })}
|
|
5112
5521
|
`);
|
|
5113
|
-
else say.error(
|
|
5522
|
+
else say.error(message2);
|
|
5114
5523
|
process.exitCode = error instanceof CliError ? error.exitCode : 1;
|
|
5115
5524
|
} finally {
|
|
5116
5525
|
await closeFirebase();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumi.ai/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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.",
|