@lumi.ai/runner 0.5.12 → 0.6.2
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 +524 -53
- 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
|
@@ -442,8 +442,8 @@ ${ranked.length} document${ranked.length === 1 ? "" : "s"}. These are SUMMARIES
|
|
|
442
442
|
room -= slug.length + 2;
|
|
443
443
|
}
|
|
444
444
|
const rest = dropped.length - named.length;
|
|
445
|
-
const
|
|
446
|
-
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;
|
|
447
447
|
}
|
|
448
448
|
|
|
449
449
|
// ../shared/dist/docMedia.js
|
|
@@ -466,6 +466,25 @@ var DOC_MEDIA_TYPES = [
|
|
|
466
466
|
];
|
|
467
467
|
|
|
468
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
|
+
}
|
|
469
488
|
function mcpToolGrants(server) {
|
|
470
489
|
const tools = server.tools ?? [];
|
|
471
490
|
if (tools.length === 0)
|
|
@@ -732,14 +751,20 @@ function forgetShip(config2, shipId) {
|
|
|
732
751
|
delete overrides[shipId];
|
|
733
752
|
next.shipParallelJobs = overrides;
|
|
734
753
|
}
|
|
754
|
+
if (next.allowLocalMcp) {
|
|
755
|
+
next.allowLocalMcp = next.allowLocalMcp.filter((id) => id !== shipId);
|
|
756
|
+
}
|
|
735
757
|
return next;
|
|
736
758
|
}
|
|
759
|
+
function allowsLocalMcp(config2, shipId) {
|
|
760
|
+
return Array.isArray(config2.allowLocalMcp) && config2.allowLocalMcp.includes(shipId);
|
|
761
|
+
}
|
|
737
762
|
function mcpUrl(config2) {
|
|
738
763
|
return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`;
|
|
739
764
|
}
|
|
740
765
|
|
|
741
766
|
// src/version.ts
|
|
742
|
-
var RUNNER_VERSION = true ? "0.
|
|
767
|
+
var RUNNER_VERSION = true ? "0.6.2" : "0.0.0-dev";
|
|
743
768
|
|
|
744
769
|
// src/auth.ts
|
|
745
770
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -752,8 +777,8 @@ function functionsBaseUrlFor(projectId) {
|
|
|
752
777
|
return process.env.CREW_FUNCTIONS_URL?.replace(/\/$/, "") || `https://us-central1-${projectId}.cloudfunctions.net`;
|
|
753
778
|
}
|
|
754
779
|
var CallableError = class extends Error {
|
|
755
|
-
constructor(status,
|
|
756
|
-
super(
|
|
780
|
+
constructor(status, message2) {
|
|
781
|
+
super(message2);
|
|
757
782
|
this.status = status;
|
|
758
783
|
}
|
|
759
784
|
status;
|
|
@@ -786,8 +811,8 @@ async function request(baseUrl, name, data, headers) {
|
|
|
786
811
|
|
|
787
812
|
// src/auth.ts
|
|
788
813
|
var AuthError = class extends Error {
|
|
789
|
-
constructor(
|
|
790
|
-
super(
|
|
814
|
+
constructor(message2, gone = false) {
|
|
815
|
+
super(message2);
|
|
791
816
|
this.gone = gone;
|
|
792
817
|
}
|
|
793
818
|
gone;
|
|
@@ -1105,8 +1130,8 @@ function isTransientFirestoreError(error) {
|
|
|
1105
1130
|
if (code === "unavailable" || code === "deadline-exceeded" || code === "internal" || code === "cancelled" || code === "aborted" || code === "resource-exhausted") {
|
|
1106
1131
|
return true;
|
|
1107
1132
|
}
|
|
1108
|
-
const
|
|
1109
|
-
return typeof
|
|
1133
|
+
const message2 = error?.message;
|
|
1134
|
+
return typeof message2 === "string" && message2.toLowerCase().includes("client is offline");
|
|
1110
1135
|
}
|
|
1111
1136
|
async function withFirestoreRetry(read, sleep2 = (ms) => new Promise((r) => setTimeout(r, ms)), delays = FIRESTORE_RETRY_DELAYS_MS) {
|
|
1112
1137
|
for (let i = 0; ; i++) {
|
|
@@ -1594,8 +1619,8 @@ import path3 from "node:path";
|
|
|
1594
1619
|
function stepsFromClaudeEvent(event, opts) {
|
|
1595
1620
|
const type = typeof event?.type === "string" ? event.type : "";
|
|
1596
1621
|
if (type === "assistant") {
|
|
1597
|
-
const
|
|
1598
|
-
const content = Array.isArray(
|
|
1622
|
+
const message2 = event.message;
|
|
1623
|
+
const content = Array.isArray(message2?.content) ? message2.content : [];
|
|
1599
1624
|
const steps = [];
|
|
1600
1625
|
for (const block of content) {
|
|
1601
1626
|
if (!block || typeof block !== "object") continue;
|
|
@@ -1694,6 +1719,15 @@ function buildMcpConfig(input) {
|
|
|
1694
1719
|
};
|
|
1695
1720
|
for (const s of input.extraServers ?? []) {
|
|
1696
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
|
+
}
|
|
1697
1731
|
mcpServers[s.key] = {
|
|
1698
1732
|
type: s.transport === "sse" ? "sse" : "http",
|
|
1699
1733
|
url: s.url,
|
|
@@ -1951,7 +1985,15 @@ async function resolveMcpServers(input) {
|
|
|
1951
1985
|
functionsBaseUrl(input.config),
|
|
1952
1986
|
input.idToken,
|
|
1953
1987
|
"mintMcpJobConnections",
|
|
1954
|
-
{
|
|
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
|
+
}
|
|
1955
1997
|
);
|
|
1956
1998
|
} catch (e) {
|
|
1957
1999
|
input.log(
|
|
@@ -1962,17 +2004,265 @@ async function resolveMcpServers(input) {
|
|
|
1962
2004
|
for (const s of res.skipped ?? []) {
|
|
1963
2005
|
input.log(`MCP connection "${s.key}" skipped: ${s.reason}.`);
|
|
1964
2006
|
}
|
|
1965
|
-
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;
|
|
1966
2023
|
}
|
|
1967
2024
|
function mcpSecretsToRedact(servers) {
|
|
1968
2025
|
const out = [];
|
|
1969
2026
|
for (const s of servers) {
|
|
1970
2027
|
out.push(...Object.values(s.headers ?? {}));
|
|
1971
2028
|
if (s.secretValue) out.push(s.secretValue);
|
|
2029
|
+
out.push(...s.secretEnvValues ?? []);
|
|
1972
2030
|
}
|
|
1973
2031
|
return out;
|
|
1974
2032
|
}
|
|
1975
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
|
+
|
|
1976
2266
|
// src/jobs/secrets.ts
|
|
1977
2267
|
import { doc as doc4, getDoc as getDoc4 } from "firebase/firestore";
|
|
1978
2268
|
async function loadRunnerSecrets(db, shipId) {
|
|
@@ -2038,6 +2328,48 @@ function subscribeEngineLimits(db, shipId, cb, onError) {
|
|
|
2038
2328
|
);
|
|
2039
2329
|
}
|
|
2040
2330
|
|
|
2331
|
+
// src/jobs/claimBackoff.ts
|
|
2332
|
+
var BACKOFF_BASE_MS = 3e3;
|
|
2333
|
+
var BACKOFF_MAX_MS = 6e4;
|
|
2334
|
+
var BACKOFF_RETENTION_MS = 10 * 6e4;
|
|
2335
|
+
function backoffFor(releases) {
|
|
2336
|
+
return Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, releases - 1));
|
|
2337
|
+
}
|
|
2338
|
+
function noteTransientRelease(backoff, jobId, now) {
|
|
2339
|
+
const releases = (backoff.get(jobId)?.releases ?? 0) + 1;
|
|
2340
|
+
const entry = { releases, eligibleAt: now + backoffFor(releases), poked: false };
|
|
2341
|
+
backoff.set(jobId, entry);
|
|
2342
|
+
return entry;
|
|
2343
|
+
}
|
|
2344
|
+
function isBackedOff(backoff, jobId, now) {
|
|
2345
|
+
const entry = backoff.get(jobId);
|
|
2346
|
+
return !!entry && entry.eligibleAt > now;
|
|
2347
|
+
}
|
|
2348
|
+
function clearBackoff(backoff, jobId) {
|
|
2349
|
+
backoff.delete(jobId);
|
|
2350
|
+
}
|
|
2351
|
+
function pruneExpired2(backoff, now) {
|
|
2352
|
+
const cleared = [];
|
|
2353
|
+
for (const [key, entry] of backoff) {
|
|
2354
|
+
if (entry.eligibleAt > now) continue;
|
|
2355
|
+
if (!entry.poked) {
|
|
2356
|
+
cleared.push(key);
|
|
2357
|
+
entry.poked = true;
|
|
2358
|
+
}
|
|
2359
|
+
if (now - entry.eligibleAt >= BACKOFF_RETENTION_MS) backoff.delete(key);
|
|
2360
|
+
}
|
|
2361
|
+
return cleared;
|
|
2362
|
+
}
|
|
2363
|
+
function nextEligibleAt(backoff, now) {
|
|
2364
|
+
let soonest = null;
|
|
2365
|
+
for (const entry of backoff.values()) {
|
|
2366
|
+
if (entry.eligibleAt > now && (soonest === null || entry.eligibleAt < soonest)) {
|
|
2367
|
+
soonest = entry.eligibleAt;
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
return soonest;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2041
2373
|
// src/jobs/capacity.ts
|
|
2042
2374
|
var DEFAULT_PARALLEL_JOBS = 1;
|
|
2043
2375
|
var PARALLEL_MAX = 8;
|
|
@@ -2940,15 +3272,15 @@ function clearUpdateState() {
|
|
|
2940
3272
|
}
|
|
2941
3273
|
|
|
2942
3274
|
// src/update/install.ts
|
|
2943
|
-
import { spawn as
|
|
3275
|
+
import { spawn as spawn5, spawnSync as spawnSync2 } from "node:child_process";
|
|
2944
3276
|
import fs6 from "node:fs";
|
|
2945
3277
|
import path7 from "node:path";
|
|
2946
3278
|
var INSTALL_TIMEOUT_MS = 5 * 6e4;
|
|
2947
3279
|
var STDERR_KEEP = 400;
|
|
2948
3280
|
function npmPresent() {
|
|
2949
|
-
const
|
|
3281
|
+
const probe2 = process.platform === "win32" ? "where" : "which";
|
|
2950
3282
|
try {
|
|
2951
|
-
return spawnSync2(
|
|
3283
|
+
return spawnSync2(probe2, ["npm"], { encoding: "utf8" }).status === 0;
|
|
2952
3284
|
} catch {
|
|
2953
3285
|
return false;
|
|
2954
3286
|
}
|
|
@@ -2964,7 +3296,7 @@ async function installGlobal(target, cliPath2, options = {}) {
|
|
|
2964
3296
|
return new Promise((resolve) => {
|
|
2965
3297
|
let child;
|
|
2966
3298
|
try {
|
|
2967
|
-
child =
|
|
3299
|
+
child = spawn5("npm", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
2968
3300
|
} catch (e) {
|
|
2969
3301
|
resolve({ ok: false, detail: e instanceof Error ? e.message : String(e) });
|
|
2970
3302
|
return;
|
|
@@ -3155,6 +3487,7 @@ async function startDaemon() {
|
|
|
3155
3487
|
}
|
|
3156
3488
|
const pending = /* @__PURE__ */ new Map();
|
|
3157
3489
|
const engineLimits = /* @__PURE__ */ new Map();
|
|
3490
|
+
const claimBackoff = /* @__PURE__ */ new Map();
|
|
3158
3491
|
const agentEngines = /* @__PURE__ */ new Map();
|
|
3159
3492
|
const unsubsByShip = /* @__PURE__ */ new Map();
|
|
3160
3493
|
const listenerError = (shipId, what) => (e) => {
|
|
@@ -3198,6 +3531,55 @@ async function startDaemon() {
|
|
|
3198
3531
|
},
|
|
3199
3532
|
listenerError(shipId, "agents")
|
|
3200
3533
|
),
|
|
3534
|
+
/**
|
|
3535
|
+
* MCP connections, for the ONE thing only this machine can answer (§15.40): a captain
|
|
3536
|
+
* pressing Test on a connection that runs here.
|
|
3537
|
+
*
|
|
3538
|
+
* A third listener on a small collection — at most 20 documents per Ship — and it earns its
|
|
3539
|
+
* place by being the only route the question has. The cloud cannot reach a program on this
|
|
3540
|
+
* laptop, so without this the Test button on a local connection is a button that does nothing.
|
|
3541
|
+
*
|
|
3542
|
+
* `serversNeedingProbe` deliberately answers "nothing" for a connection nobody asked about,
|
|
3543
|
+
* INCLUDING at startup: a daemon coming up must not spawn every program on the Ship to see
|
|
3544
|
+
* what happens. Testing is somebody's decision, and `doctor` is where it is made on purpose.
|
|
3545
|
+
*/
|
|
3546
|
+
onSnapshot2(
|
|
3547
|
+
collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers),
|
|
3548
|
+
(snap) => {
|
|
3549
|
+
const servers = snap.docs.map(
|
|
3550
|
+
(d) => ({ id: d.id, ...d.data() })
|
|
3551
|
+
);
|
|
3552
|
+
for (const server of serversNeedingProbe(servers)) {
|
|
3553
|
+
void (async () => {
|
|
3554
|
+
try {
|
|
3555
|
+
const idToken = await sess(shipId).user.getIdToken();
|
|
3556
|
+
await probeAndReport({ config: config2, idToken, shipId, server, log: log2 });
|
|
3557
|
+
} catch (e) {
|
|
3558
|
+
log2(
|
|
3559
|
+
`MCP test for "${server.id}" (${shipId}) failed to start: ${e instanceof Error ? e.message : String(e)}`
|
|
3560
|
+
);
|
|
3561
|
+
}
|
|
3562
|
+
})();
|
|
3563
|
+
}
|
|
3564
|
+
},
|
|
3565
|
+
/**
|
|
3566
|
+
* ITS OWN ERROR HANDLER, and NOT `listenerError` — this is the important line.
|
|
3567
|
+
*
|
|
3568
|
+
* `listenerError` treats any `permission-denied` as "a captain removed this machine" and
|
|
3569
|
+
* calls `forgetShipLocally`, which stops serving the Ship until the daemon restarts. That
|
|
3570
|
+
* inference is sound for the listeners it was written for; it is badly wrong here.
|
|
3571
|
+
*
|
|
3572
|
+
* `mcp_servers` is gated on `isShipReader`, which a REVOKED machine fails — and revocation
|
|
3573
|
+
* is a state the daemon is expected to sit through and recover from, without a restart and
|
|
3574
|
+
* without re-minting (e2e-runner asserts exactly that: approve → revoke → re-approve, same
|
|
3575
|
+
* process). Reading a denial here as removal would turn a temporary revocation into a
|
|
3576
|
+
* permanent one.
|
|
3577
|
+
*
|
|
3578
|
+
* The blast radius is the whole argument. This listener exists to make a captain's Test
|
|
3579
|
+
* button answer promptly. Losing it costs that button; it must never cost the Ship.
|
|
3580
|
+
*/
|
|
3581
|
+
(e) => console.error(`mcp connections listener error (${shipId}):`, e.message)
|
|
3582
|
+
),
|
|
3201
3583
|
// Exhausted usage windows. The FIRST snapshot is the startup seed: a restarted daemon —
|
|
3202
3584
|
// and a second daemon on this Ship — inherits the pause instead of running a doomed
|
|
3203
3585
|
// session to rediscover it.
|
|
@@ -3288,6 +3670,23 @@ async function startDaemon() {
|
|
|
3288
3670
|
limitTimer = setTimeout(sweepLimits, Math.max(0, at - Date.now()) + 1e3);
|
|
3289
3671
|
limitTimer.unref();
|
|
3290
3672
|
}
|
|
3673
|
+
let backoffTimer = null;
|
|
3674
|
+
function armBackoffTimer() {
|
|
3675
|
+
if (backoffTimer) {
|
|
3676
|
+
clearTimeout(backoffTimer);
|
|
3677
|
+
backoffTimer = null;
|
|
3678
|
+
}
|
|
3679
|
+
const at = nextEligibleAt(claimBackoff, Date.now());
|
|
3680
|
+
if (at === null) return;
|
|
3681
|
+
backoffTimer = setTimeout(sweepBackoff, Math.max(0, at - Date.now()) + 1e3);
|
|
3682
|
+
backoffTimer.unref();
|
|
3683
|
+
}
|
|
3684
|
+
function sweepBackoff() {
|
|
3685
|
+
backoffTimer = null;
|
|
3686
|
+
const cleared = pruneExpired2(claimBackoff, Date.now());
|
|
3687
|
+
armBackoffTimer();
|
|
3688
|
+
if (cleared.length > 0) poke();
|
|
3689
|
+
}
|
|
3291
3690
|
function sweepLimits() {
|
|
3292
3691
|
limitTimer = null;
|
|
3293
3692
|
const cleared = pruneExpired(engineLimits, Date.now());
|
|
@@ -3319,7 +3718,11 @@ async function startDaemon() {
|
|
|
3319
3718
|
// the limit map is keyed by (Ship, engine) and the engine comes from the registry via
|
|
3320
3719
|
// the agent, so the job loop still knows nothing about Claude. Skipped entries STAY in
|
|
3321
3720
|
// `pending`, which is what lets a reset resume them with only a poke.
|
|
3322
|
-
eligible: (p) => approved.get(p.shipId) === true && !isLimited(engineLimits, p.shipId, engineForJob(p.shipId, p.job), now)
|
|
3721
|
+
eligible: (p) => approved.get(p.shipId) === true && !isLimited(engineLimits, p.shipId, engineForJob(p.shipId, p.job), now) && // The connection cooldown. Same "skip, don't stop" shape as the limit gate above, and
|
|
3722
|
+
// skipped entries STAY in `pending` for the same reason — `sweepBackoff` resumes them
|
|
3723
|
+
// with a poke. Without this the `transient` release below re-claims what it just put
|
|
3724
|
+
// down, every three seconds, forever (jobs/claimBackoff.ts).
|
|
3725
|
+
!isBackedOff(claimBackoff, p.job.id, now)
|
|
3323
3726
|
});
|
|
3324
3727
|
for (const pick of picks) {
|
|
3325
3728
|
pending.delete(`${pick.shipId}/${pick.job.id}`);
|
|
@@ -3675,14 +4078,16 @@ async function startDaemon() {
|
|
|
3675
4078
|
target.kind === "task" ? `Task ${target.taskId} is done.` : delivery === "agent-replied" ? "Agent replied in a chat." : "Agent finished a chat run without replying \u2014 its answer was posted for it."
|
|
3676
4079
|
);
|
|
3677
4080
|
} else if (transient) {
|
|
4081
|
+
const held = noteTransientRelease(claimBackoff, job.id, Date.now());
|
|
3678
4082
|
await releaseJob(
|
|
3679
4083
|
sess(shipId).fb.db,
|
|
3680
4084
|
shipId,
|
|
3681
4085
|
job,
|
|
3682
4086
|
`Lost the connection to Firestore \u2014 released without consuming a retry: ${failure.slice(0, 120)}`
|
|
3683
4087
|
);
|
|
4088
|
+
armBackoffTimer();
|
|
3684
4089
|
log2(
|
|
3685
|
-
`Job ${job.id} released: lost the connection to Firestore. Attempt ${job.attempt} preserved \u2014 ${failure.slice(0, 120)}
|
|
4090
|
+
`Job ${job.id} released: lost the connection to Firestore. Attempt ${job.attempt} preserved \u2014 ${failure.slice(0, 120)}. Not re-claiming it here for ${Math.round((held.eligibleAt - Date.now()) / 1e3)}s (drop ${held.releases} on this machine); another daemon may take it meanwhile.`
|
|
3686
4091
|
);
|
|
3687
4092
|
} else if (!terminal && job.attempt < MAX_ATTEMPTS) {
|
|
3688
4093
|
log2(`Job ${job.id} failed (attempt ${job.attempt}) \u2014 re-queueing: ${failure.slice(0, 120)}`);
|
|
@@ -3704,6 +4109,7 @@ async function startDaemon() {
|
|
|
3704
4109
|
log2(`Job ${job.id} FAILED terminally: ${failure.slice(0, 120)}`);
|
|
3705
4110
|
notify("Crew job failed", `${targetLabel}: ${failure.slice(0, 120)}`);
|
|
3706
4111
|
}
|
|
4112
|
+
if (!transient) clearBackoff(claimBackoff, job.id);
|
|
3707
4113
|
} catch (e) {
|
|
3708
4114
|
log2(`finalize failed for ${job.id}: ${e instanceof Error ? e.message : e}`);
|
|
3709
4115
|
}
|
|
@@ -3906,8 +4312,8 @@ async function startDaemon() {
|
|
|
3906
4312
|
import * as clack from "@clack/prompts";
|
|
3907
4313
|
import { createColors } from "picocolors";
|
|
3908
4314
|
var CliError = class extends Error {
|
|
3909
|
-
constructor(
|
|
3910
|
-
super(
|
|
4315
|
+
constructor(message2, exitCode = 1) {
|
|
4316
|
+
super(message2);
|
|
3911
4317
|
this.exitCode = exitCode;
|
|
3912
4318
|
this.name = "CliError";
|
|
3913
4319
|
}
|
|
@@ -3952,30 +4358,30 @@ var say = {
|
|
|
3952
4358
|
intro(title) {
|
|
3953
4359
|
if (!jsonMode) clack.intro(pc.bgCyan(pc.black(` ${title} `)));
|
|
3954
4360
|
},
|
|
3955
|
-
outro(
|
|
3956
|
-
if (!jsonMode) clack.outro(
|
|
4361
|
+
outro(message2) {
|
|
4362
|
+
if (!jsonMode) clack.outro(message2);
|
|
3957
4363
|
},
|
|
3958
|
-
info(
|
|
3959
|
-
if (!jsonMode) clack.log.info(
|
|
4364
|
+
info(message2) {
|
|
4365
|
+
if (!jsonMode) clack.log.info(message2);
|
|
3960
4366
|
},
|
|
3961
|
-
success(
|
|
3962
|
-
if (!jsonMode) clack.log.success(
|
|
4367
|
+
success(message2) {
|
|
4368
|
+
if (!jsonMode) clack.log.success(message2);
|
|
3963
4369
|
},
|
|
3964
|
-
warn(
|
|
3965
|
-
if (!jsonMode) clack.log.warn(
|
|
4370
|
+
warn(message2) {
|
|
4371
|
+
if (!jsonMode) clack.log.warn(message2);
|
|
3966
4372
|
},
|
|
3967
|
-
error(
|
|
3968
|
-
if (!jsonMode) clack.log.error(
|
|
4373
|
+
error(message2) {
|
|
4374
|
+
if (!jsonMode) clack.log.error(message2);
|
|
3969
4375
|
},
|
|
3970
|
-
step(
|
|
3971
|
-
if (!jsonMode) clack.log.step(
|
|
4376
|
+
step(message2) {
|
|
4377
|
+
if (!jsonMode) clack.log.step(message2);
|
|
3972
4378
|
},
|
|
3973
4379
|
note(body, title) {
|
|
3974
4380
|
if (!jsonMode) clack.note(body, title);
|
|
3975
4381
|
},
|
|
3976
4382
|
/** Raw line straight to stdout — for `logs`, where the content IS the output. */
|
|
3977
|
-
line(
|
|
3978
|
-
process.stdout.write(`${
|
|
4383
|
+
line(message2) {
|
|
4384
|
+
process.stdout.write(`${message2}
|
|
3979
4385
|
`);
|
|
3980
4386
|
}
|
|
3981
4387
|
};
|
|
@@ -4041,6 +4447,8 @@ var CHANNEL = "channel";
|
|
|
4041
4447
|
var CHANNEL_HELP = "Which npm release channel to follow (default: derived from the version)";
|
|
4042
4448
|
var CHECK_EVERY = "checkEvery";
|
|
4043
4449
|
var CHECK_EVERY_HELP = `Minutes between update checks (${CHECK_MINUTES_MIN}-${CHECK_MINUTES_MAX})`;
|
|
4450
|
+
var LOCAL_MCP = "local-mcp";
|
|
4451
|
+
var LOCAL_MCP_HELP = "Allow a Ship to run MCP servers on this machine (needs --ship)";
|
|
4044
4452
|
var DEFAULT_WORDS = ["default", "auto", "reset"];
|
|
4045
4453
|
function isToggle(key) {
|
|
4046
4454
|
return Object.hasOwn(TOGGLES, key);
|
|
@@ -4085,7 +4493,8 @@ async function runConfigList() {
|
|
|
4085
4493
|
const ships = config2.ships.map((shipId) => ({
|
|
4086
4494
|
shipId,
|
|
4087
4495
|
parallel: shipCap(config2, shipId),
|
|
4088
|
-
explicit: config2.shipParallelJobs?.[shipId] !== void 0
|
|
4496
|
+
explicit: config2.shipParallelJobs?.[shipId] !== void 0,
|
|
4497
|
+
localMcp: allowsLocalMcp(config2, shipId)
|
|
4089
4498
|
}));
|
|
4090
4499
|
const channel = resolveChannel(RUNNER_VERSION, config2, process.env);
|
|
4091
4500
|
const checkEvery = checkIntervalMs(config2) / 6e4;
|
|
@@ -4101,22 +4510,29 @@ async function runConfigList() {
|
|
|
4101
4510
|
say.line(` ${PARALLEL.padEnd(16)} ${String(machine).padEnd(2)} ${PARALLEL_HELP}`);
|
|
4102
4511
|
say.line(` ${CHANNEL.padEnd(16)} ${channel.padEnd(2)} ${CHANNEL_HELP}`);
|
|
4103
4512
|
say.line(` ${CHECK_EVERY.padEnd(16)} ${String(checkEvery).padEnd(2)} ${CHECK_EVERY_HELP}`);
|
|
4513
|
+
say.line(` ${LOCAL_MCP.padEnd(16)} ${"--".padEnd(2)} ${LOCAL_MCP_HELP}`);
|
|
4104
4514
|
if (ships.length > 0) {
|
|
4105
4515
|
say.line("");
|
|
4106
4516
|
for (const ship2 of ships) {
|
|
4107
4517
|
const note2 = ship2.explicit ? "" : " (machine default)";
|
|
4108
4518
|
say.line(` ${`${PARALLEL} --ship`.padEnd(16)} ${ship2.shipId} = ${ship2.parallel}${note2}`);
|
|
4109
4519
|
}
|
|
4520
|
+
for (const ship2 of ships) {
|
|
4521
|
+
say.line(
|
|
4522
|
+
` ${`${LOCAL_MCP} --ship`.padEnd(16)} ${ship2.shipId} = ${ship2.localMcp ? "on" : "off"}`
|
|
4523
|
+
);
|
|
4524
|
+
}
|
|
4110
4525
|
}
|
|
4111
4526
|
return 0;
|
|
4112
4527
|
}
|
|
4113
4528
|
async function runConfigSet(key, value, options = {}) {
|
|
4114
4529
|
const config2 = requireConfig();
|
|
4115
4530
|
if (key === PARALLEL) return setParallel(config2, value, options.ship);
|
|
4531
|
+
if (key === LOCAL_MCP) return setLocalMcp(config2, value, options.ship);
|
|
4116
4532
|
if (key === CHANNEL || key === CHECK_EVERY) return setUpdateSetting(config2, key, value, options.ship);
|
|
4117
4533
|
if (!isToggle(key)) {
|
|
4118
4534
|
throw new CliError(
|
|
4119
|
-
`Unknown setting "${key}". Known: ${[...Object.keys(TOGGLES), PARALLEL, CHANNEL, CHECK_EVERY].join(", ")}.`
|
|
4535
|
+
`Unknown setting "${key}". Known: ${[...Object.keys(TOGGLES), PARALLEL, LOCAL_MCP, CHANNEL, CHECK_EVERY].join(", ")}.`
|
|
4120
4536
|
);
|
|
4121
4537
|
}
|
|
4122
4538
|
if (options.ship) {
|
|
@@ -4161,6 +4577,37 @@ function setUpdateSetting(config2, key, value, ship2) {
|
|
|
4161
4577
|
say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
|
|
4162
4578
|
return 0;
|
|
4163
4579
|
}
|
|
4580
|
+
function setLocalMcp(config2, value, ship2) {
|
|
4581
|
+
if (!ship2) {
|
|
4582
|
+
throw new CliError(
|
|
4583
|
+
`"${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.`
|
|
4584
|
+
);
|
|
4585
|
+
}
|
|
4586
|
+
if (!config2.ships.includes(ship2)) {
|
|
4587
|
+
throw new CliError(
|
|
4588
|
+
`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.`
|
|
4589
|
+
);
|
|
4590
|
+
}
|
|
4591
|
+
const allowed = parseBool(value);
|
|
4592
|
+
const current = new Set(config2.allowLocalMcp ?? []);
|
|
4593
|
+
if (allowed) current.add(ship2);
|
|
4594
|
+
else current.delete(ship2);
|
|
4595
|
+
if (current.size > 0) config2.allowLocalMcp = [...current].sort();
|
|
4596
|
+
else delete config2.allowLocalMcp;
|
|
4597
|
+
saveConfig(config2);
|
|
4598
|
+
if (isJson()) {
|
|
4599
|
+
emitJson({ localMcp: allowed, shipId: ship2 });
|
|
4600
|
+
return 0;
|
|
4601
|
+
}
|
|
4602
|
+
say.success(`${LOCAL_MCP} = ${allowed ? "on" : "off"} for Ship ${ship2}`);
|
|
4603
|
+
if (allowed) {
|
|
4604
|
+
say.line(
|
|
4605
|
+
" 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."
|
|
4606
|
+
);
|
|
4607
|
+
}
|
|
4608
|
+
say.line(" Restart the daemon for this to take effect: `lumi-runner service restart`.");
|
|
4609
|
+
return 0;
|
|
4610
|
+
}
|
|
4164
4611
|
function setParallel(config2, value, ship2) {
|
|
4165
4612
|
const parsed = parseParallel(value);
|
|
4166
4613
|
if (ship2) {
|
|
@@ -4226,8 +4673,8 @@ var fail = (id, label, detail, fix) => ({
|
|
|
4226
4673
|
fix
|
|
4227
4674
|
});
|
|
4228
4675
|
function onPath(binary) {
|
|
4229
|
-
const
|
|
4230
|
-
return spawnSync3(
|
|
4676
|
+
const probe2 = process.platform === "win32" ? "where" : "which";
|
|
4677
|
+
return spawnSync3(probe2, [binary], { stdio: "ignore" }).status === 0;
|
|
4231
4678
|
}
|
|
4232
4679
|
function checkNode() {
|
|
4233
4680
|
const major = Number(process.versions.node.split(".")[0]);
|
|
@@ -4290,6 +4737,19 @@ function versionCheckFrom(input) {
|
|
|
4290
4737
|
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`."
|
|
4291
4738
|
);
|
|
4292
4739
|
}
|
|
4740
|
+
function localMcpCheckFrom(shipId, servers, allowed) {
|
|
4741
|
+
const local = servers.filter((s) => s.enabled !== false && isLocalMcpServer(s));
|
|
4742
|
+
if (local.length === 0) return null;
|
|
4743
|
+
const id = `localmcp:${shipId}`;
|
|
4744
|
+
const label = `Ship ${shipId} \u2014 local MCP`;
|
|
4745
|
+
const names = local.map((s) => s.id).join(", ");
|
|
4746
|
+
return allowed ? ok(id, label, `Allowed on this machine: ${names}.`) : warn(
|
|
4747
|
+
id,
|
|
4748
|
+
label,
|
|
4749
|
+
`${local.length} connection(s) run on this machine and are not allowed here: ${names}.`,
|
|
4750
|
+
`lumi-runner config set local-mcp on --ship ${shipId}`
|
|
4751
|
+
);
|
|
4752
|
+
}
|
|
4293
4753
|
async function checkVersion(config2) {
|
|
4294
4754
|
const channel = resolveChannel(RUNNER_VERSION, config2, process.env);
|
|
4295
4755
|
const tags = await fetchDistTags();
|
|
@@ -4392,6 +4852,17 @@ async function checkShips(config2) {
|
|
|
4392
4852
|
)
|
|
4393
4853
|
);
|
|
4394
4854
|
}
|
|
4855
|
+
try {
|
|
4856
|
+
const snap = await getDocs5(
|
|
4857
|
+
collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
|
|
4858
|
+
);
|
|
4859
|
+
const servers = snap.docs.map(
|
|
4860
|
+
(d) => ({ id: d.id, ...d.data() })
|
|
4861
|
+
);
|
|
4862
|
+
const check = localMcpCheckFrom(shipId, servers, allowsLocalMcp(config2, shipId));
|
|
4863
|
+
if (check) checks.push(check);
|
|
4864
|
+
} catch {
|
|
4865
|
+
}
|
|
4395
4866
|
}
|
|
4396
4867
|
return { checks, engines, needsGithub };
|
|
4397
4868
|
}
|
|
@@ -4402,7 +4873,7 @@ async function runDoctor() {
|
|
|
4402
4873
|
checks.push(
|
|
4403
4874
|
fail("config", "Configuration", "This machine is not connected.", "Run `lumi-runner setup`.")
|
|
4404
4875
|
);
|
|
4405
|
-
return
|
|
4876
|
+
return report2(checks);
|
|
4406
4877
|
}
|
|
4407
4878
|
checks.push(ok("config", "Configuration", `Runner ${config2.runnerId} on project ${config2.projectId}`));
|
|
4408
4879
|
const progress = spinner2();
|
|
@@ -4437,9 +4908,9 @@ async function runDoctor() {
|
|
|
4437
4908
|
checks.push(checkService());
|
|
4438
4909
|
checks.push(await checkVersion(config2));
|
|
4439
4910
|
progress.stop("Checks complete.");
|
|
4440
|
-
return
|
|
4911
|
+
return report2(checks);
|
|
4441
4912
|
}
|
|
4442
|
-
function
|
|
4913
|
+
function report2(checks) {
|
|
4443
4914
|
const failed = checks.filter((c) => c.level === "fail");
|
|
4444
4915
|
const warned = checks.filter((c) => c.level === "warn");
|
|
4445
4916
|
if (isJson()) {
|
|
@@ -4468,7 +4939,7 @@ function report(checks) {
|
|
|
4468
4939
|
}
|
|
4469
4940
|
|
|
4470
4941
|
// src/cli/commands/login.ts
|
|
4471
|
-
import { spawn as
|
|
4942
|
+
import { spawn as spawn6 } from "node:child_process";
|
|
4472
4943
|
import os5 from "node:os";
|
|
4473
4944
|
import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
|
|
4474
4945
|
function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
|
|
@@ -4494,7 +4965,7 @@ function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
|
|
|
4494
4965
|
function openBrowser(url) {
|
|
4495
4966
|
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
4496
4967
|
try {
|
|
4497
|
-
const child =
|
|
4968
|
+
const child = spawn6(command, args, { stdio: "ignore", detached: true });
|
|
4498
4969
|
child.on("error", () => {
|
|
4499
4970
|
});
|
|
4500
4971
|
child.unref();
|
|
@@ -4571,7 +5042,7 @@ Code: ${pc.bold(start.displayCode)}`,
|
|
|
4571
5042
|
const first = approvedShips[0];
|
|
4572
5043
|
const uid2 = first ? (await signInWithCustomToken2(fb.auth, (await exchange(config2, first)).customToken)).user.uid : "(no Ship approved yet)";
|
|
4573
5044
|
saveConfig(config2);
|
|
4574
|
-
return
|
|
5045
|
+
return report3({ runnerId: config2.runnerId, uid: uid2, ships: config2.ships, approvedShips, pendingShips });
|
|
4575
5046
|
}
|
|
4576
5047
|
async function exchange(config2, shipId) {
|
|
4577
5048
|
return callPublicFunction(
|
|
@@ -4607,7 +5078,7 @@ async function loginWithKey(options) {
|
|
|
4607
5078
|
const fb = initFirebase(config2);
|
|
4608
5079
|
const cred = await signInWithCustomToken2(fb.auth, session.customToken);
|
|
4609
5080
|
saveConfig(config2);
|
|
4610
|
-
return
|
|
5081
|
+
return report3({
|
|
4611
5082
|
runnerId: config2.runnerId,
|
|
4612
5083
|
uid: cred.user.uid,
|
|
4613
5084
|
ships: config2.ships,
|
|
@@ -4619,7 +5090,7 @@ function parseKeyShipId(key) {
|
|
|
4619
5090
|
const parts = (key ?? "").split("_");
|
|
4620
5091
|
return parts.length === 3 && parts[0] === "crewrunner" && parts[1] && parts[2] ? parts[1] : null;
|
|
4621
5092
|
}
|
|
4622
|
-
function
|
|
5093
|
+
function report3(result) {
|
|
4623
5094
|
if (isJson()) {
|
|
4624
5095
|
emitJson({ version: RUNNER_VERSION, ...result });
|
|
4625
5096
|
return 0;
|
|
@@ -4639,12 +5110,12 @@ function report2(result) {
|
|
|
4639
5110
|
|
|
4640
5111
|
// src/cli/commands/logs.ts
|
|
4641
5112
|
async function runLogs(options) {
|
|
4642
|
-
const
|
|
4643
|
-
if (
|
|
5113
|
+
const tail2 = readTail(options.lines);
|
|
5114
|
+
if (tail2.length === 0 && !options.follow) {
|
|
4644
5115
|
say.warn(`No log output yet at ${logFile()}.`);
|
|
4645
5116
|
return 0;
|
|
4646
5117
|
}
|
|
4647
|
-
for (const line of
|
|
5118
|
+
for (const line of tail2) say.line(line);
|
|
4648
5119
|
if (!options.follow) return 0;
|
|
4649
5120
|
await new Promise((resolve) => {
|
|
4650
5121
|
const stop = followLog((line) => say.line(line));
|
|
@@ -5112,10 +5583,10 @@ function action(handler) {
|
|
|
5112
5583
|
try {
|
|
5113
5584
|
process.exitCode = await handler(...args);
|
|
5114
5585
|
} catch (error) {
|
|
5115
|
-
const
|
|
5116
|
-
if (isJson()) process.stderr.write(`${JSON.stringify({ error:
|
|
5586
|
+
const message2 = error instanceof Error ? error.message : String(error);
|
|
5587
|
+
if (isJson()) process.stderr.write(`${JSON.stringify({ error: message2 })}
|
|
5117
5588
|
`);
|
|
5118
|
-
else say.error(
|
|
5589
|
+
else say.error(message2);
|
|
5119
5590
|
process.exitCode = error instanceof CliError ? error.exitCode : 1;
|
|
5120
5591
|
} finally {
|
|
5121
5592
|
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.2",
|
|
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.",
|