@lumi.ai/runner 0.15.9 → 0.15.10
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/dist/cli.js +117 -26
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -830,6 +830,7 @@ import fs from "node:fs";
|
|
|
830
830
|
import os from "node:os";
|
|
831
831
|
import path from "node:path";
|
|
832
832
|
var DEFAULT_PROJECT_ID = "lumi-afb7d";
|
|
833
|
+
var DEFAULT_MCP_URL = "https://crew.kilogent.com/mcp";
|
|
833
834
|
var LEGACY_DIR_NAME = ".crew-runner";
|
|
834
835
|
var DIR_NAME = ".lumi-runner";
|
|
835
836
|
function configDir() {
|
|
@@ -935,11 +936,11 @@ function notificationsEnabled(config2) {
|
|
|
935
936
|
return config2?.notifications ?? TOGGLE_DEFAULTS.notifications;
|
|
936
937
|
}
|
|
937
938
|
function mcpUrl(config2) {
|
|
938
|
-
return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp
|
|
939
|
+
return process.env.CREW_MCP_URL || config2.mcpUrl || (config2.projectId === DEFAULT_PROJECT_ID ? DEFAULT_MCP_URL : `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`);
|
|
939
940
|
}
|
|
940
941
|
|
|
941
942
|
// src/version.ts
|
|
942
|
-
var RUNNER_VERSION = true ? "0.15.
|
|
943
|
+
var RUNNER_VERSION = true ? "0.15.10" : "0.0.0-dev";
|
|
943
944
|
|
|
944
945
|
// src/auth.ts
|
|
945
946
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -1053,6 +1054,28 @@ async function openShipSessions(config2, build) {
|
|
|
1053
1054
|
}
|
|
1054
1055
|
return { sessions, failures };
|
|
1055
1056
|
}
|
|
1057
|
+
function idTokenLifeMs(idToken, now) {
|
|
1058
|
+
const payload = idToken.split(".")[1];
|
|
1059
|
+
if (!payload) return null;
|
|
1060
|
+
try {
|
|
1061
|
+
const json = Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
|
1062
|
+
const exp = JSON.parse(json).exp;
|
|
1063
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) return null;
|
|
1064
|
+
return exp * 1e3 - now;
|
|
1065
|
+
} catch {
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
async function sessionIdToken(user, opts) {
|
|
1070
|
+
const idToken = await user.getIdToken(true);
|
|
1071
|
+
const life = idTokenLifeMs(idToken, opts.now ?? Date.now());
|
|
1072
|
+
if (life !== null && life < opts.timeoutMs) {
|
|
1073
|
+
opts.log?.(
|
|
1074
|
+
`This session's credential expires in ${Math.round(life / 1e3)}s but the job may run for ${Math.round(opts.timeoutMs / 1e3)}s. It is frozen into the session config at spawn, so the agent will lose every Crew tool when it lapses. Check this machine's clock.`
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
return idToken;
|
|
1078
|
+
}
|
|
1056
1079
|
|
|
1057
1080
|
// src/firebase.ts
|
|
1058
1081
|
import { deleteApp, getApps, initializeApp } from "firebase/app";
|
|
@@ -1877,6 +1900,61 @@ function stepsFromClaudeEvent(event, opts) {
|
|
|
1877
1900
|
return [];
|
|
1878
1901
|
}
|
|
1879
1902
|
|
|
1903
|
+
// src/engines/mcpHealth.ts
|
|
1904
|
+
var CONSECUTIVE_LIMIT = 2;
|
|
1905
|
+
var AUTH_MARKER = /\b401\b|unauthoriz|invalid[_ ]token|token .{0,20}expired|expired .{0,20}token|protected resource/i;
|
|
1906
|
+
var WORKSPACE_TOOL_PREFIX = `mcp__${WORKSPACE_MCP_KEY}__`;
|
|
1907
|
+
function resultText(block) {
|
|
1908
|
+
const content = block.content;
|
|
1909
|
+
if (typeof content === "string") return content;
|
|
1910
|
+
if (Array.isArray(content)) {
|
|
1911
|
+
return content.map(
|
|
1912
|
+
(part) => part && typeof part === "object" && typeof part.text === "string" ? part.text : ""
|
|
1913
|
+
).join(" ");
|
|
1914
|
+
}
|
|
1915
|
+
return "";
|
|
1916
|
+
}
|
|
1917
|
+
function blocksOf(event) {
|
|
1918
|
+
const message2 = event.message;
|
|
1919
|
+
return Array.isArray(message2?.content) ? message2.content : [];
|
|
1920
|
+
}
|
|
1921
|
+
function createWorkspaceMcpWatch() {
|
|
1922
|
+
const toolNames = /* @__PURE__ */ new Map();
|
|
1923
|
+
let consecutive = 0;
|
|
1924
|
+
let reported = false;
|
|
1925
|
+
return {
|
|
1926
|
+
observe(event) {
|
|
1927
|
+
if (reported) return null;
|
|
1928
|
+
const type = typeof event?.type === "string" ? event.type : "";
|
|
1929
|
+
if (type === "assistant") {
|
|
1930
|
+
for (const block of blocksOf(event)) {
|
|
1931
|
+
if (!block || typeof block !== "object") continue;
|
|
1932
|
+
if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
|
|
1933
|
+
toolNames.set(block.id, block.name);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
return null;
|
|
1937
|
+
}
|
|
1938
|
+
if (type !== "user") return null;
|
|
1939
|
+
for (const block of blocksOf(event)) {
|
|
1940
|
+
if (!block || typeof block !== "object") continue;
|
|
1941
|
+
if (block.type !== "tool_result" || typeof block.tool_use_id !== "string") continue;
|
|
1942
|
+
const name = toolNames.get(block.tool_use_id);
|
|
1943
|
+
if (!name || !name.startsWith(WORKSPACE_TOOL_PREFIX)) continue;
|
|
1944
|
+
if (!block.is_error || !AUTH_MARKER.test(resultText(block))) {
|
|
1945
|
+
consecutive = 0;
|
|
1946
|
+
continue;
|
|
1947
|
+
}
|
|
1948
|
+
consecutive += 1;
|
|
1949
|
+
if (consecutive < CONSECUTIVE_LIMIT) continue;
|
|
1950
|
+
reported = true;
|
|
1951
|
+
return `The Workspace MCP ("${WORKSPACE_MCP_KEY}") stopped accepting this session's credential part-way through the run: ${CONSECUTIVE_LIMIT} consecutive calls came back refused ("${resultText(block).slice(0, 160).trim()}"). Everything after that point would have run with none of this Ship's tools \u2014 no task_comment, no run_report, no chat_send \u2014 so the session was stopped rather than allowed to finish blind. The run is retried with a fresh credential.`;
|
|
1952
|
+
}
|
|
1953
|
+
return null;
|
|
1954
|
+
}
|
|
1955
|
+
};
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1880
1958
|
// src/engines/claude.ts
|
|
1881
1959
|
function normalizeClaudeUsage(resultEvent, model, fallbackDurationS) {
|
|
1882
1960
|
const result = resultEvent ?? {};
|
|
@@ -2052,6 +2130,7 @@ async function runSession(input, bin, dirs) {
|
|
|
2052
2130
|
let resultEvent = null;
|
|
2053
2131
|
let mcpProblem = null;
|
|
2054
2132
|
const workspaceRequired = effectiveAgentTools(input.agent).workspaceMcp;
|
|
2133
|
+
const mcpWatch = createWorkspaceMcpWatch();
|
|
2055
2134
|
const exitCode = await new Promise((resolve) => {
|
|
2056
2135
|
const child = spawn3(bin, args, { cwd: dirs.workdir, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
2057
2136
|
const killTimer = setTimeout(() => {
|
|
@@ -2066,6 +2145,13 @@ async function runSession(input, bin, dirs) {
|
|
|
2066
2145
|
};
|
|
2067
2146
|
input.signal?.addEventListener("abort", onAbort, { once: true });
|
|
2068
2147
|
const detachAbort = () => input.signal?.removeEventListener("abort", onAbort);
|
|
2148
|
+
const stopBlindSession = (problem) => {
|
|
2149
|
+
if (!problem) return;
|
|
2150
|
+
mcpProblem = problem;
|
|
2151
|
+
input.log(problem);
|
|
2152
|
+
child.kill("SIGTERM");
|
|
2153
|
+
setTimeout(() => child.kill("SIGKILL"), 3e3).unref();
|
|
2154
|
+
};
|
|
2069
2155
|
let buffer = "";
|
|
2070
2156
|
child.stdout.on("data", (chunk) => {
|
|
2071
2157
|
buffer += chunk.toString("utf8");
|
|
@@ -2081,12 +2167,10 @@ async function runSession(input, bin, dirs) {
|
|
|
2081
2167
|
if (event.type === "result") resultEvent = event;
|
|
2082
2168
|
if (event.type === "assistant") input.log("claude: assistant turn");
|
|
2083
2169
|
if (event.type === "system" && event.subtype === "init" && !mcpProblem) {
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
setTimeout(() => child.kill("SIGKILL"), 3e3).unref();
|
|
2089
|
-
}
|
|
2170
|
+
stopBlindSession(workspaceMcpProblem(event, workspaceRequired));
|
|
2171
|
+
}
|
|
2172
|
+
if (workspaceRequired && !mcpProblem) {
|
|
2173
|
+
stopBlindSession(mcpWatch.observe(event));
|
|
2090
2174
|
}
|
|
2091
2175
|
if (input.onStep) {
|
|
2092
2176
|
for (const step2 of stepsFromClaudeEvent(event, { mcpNames })) input.onStep(step2);
|
|
@@ -2118,9 +2202,9 @@ async function runSession(input, bin, dirs) {
|
|
|
2118
2202
|
const durationS = Math.round((Date.now() - startedAt) / 1e3);
|
|
2119
2203
|
const { usage, result } = normalizeClaudeUsage(resultEvent, input.agent.model, durationS);
|
|
2120
2204
|
const ok2 = !mcpProblem && exitCode === 0 && !!resultEvent && !result.is_error;
|
|
2121
|
-
const
|
|
2205
|
+
const resultText2 = mcpProblem ?? result.result ?? (ok2 ? "" : `Session ended with exit code ${exitCode}${resultEvent ? "" : " and no result event"}.`);
|
|
2122
2206
|
const limit3 = ok2 ? void 0 : detectClaudeLimit(
|
|
2123
|
-
`${
|
|
2207
|
+
`${resultText2}
|
|
2124
2208
|
${stderrLines.slice(-20).join("\n")}`,
|
|
2125
2209
|
Date.now(),
|
|
2126
2210
|
engineUsageWindows(CLAUDE_DRIVER_ID)?.fallbackMs ?? 5 * 60 * 60 * 1e3
|
|
@@ -2130,7 +2214,7 @@ ${stderrLines.slice(-20).join("\n")}`,
|
|
|
2130
2214
|
transcript: `${lines.join("\n")}
|
|
2131
2215
|
`,
|
|
2132
2216
|
usage,
|
|
2133
|
-
resultText,
|
|
2217
|
+
resultText: resultText2,
|
|
2134
2218
|
...limit3 ? { limit: limit3 } : {}
|
|
2135
2219
|
};
|
|
2136
2220
|
}
|
|
@@ -2721,8 +2805,8 @@ async function uploadTranscript(storage, shipId, jobId, redacted) {
|
|
|
2721
2805
|
function utcDay(millis) {
|
|
2722
2806
|
return new Date(millis).toISOString().slice(0, 10);
|
|
2723
2807
|
}
|
|
2724
|
-
function backstopReportContent(
|
|
2725
|
-
const text =
|
|
2808
|
+
function backstopReportContent(resultText2) {
|
|
2809
|
+
const text = resultText2.trim();
|
|
2726
2810
|
if (!text) {
|
|
2727
2811
|
const none = "This run ended without writing a report, and left no final text to fall back on.";
|
|
2728
2812
|
return { report: none, summary: none };
|
|
@@ -2752,7 +2836,11 @@ async function finalizeJob(db, shipId, job, input) {
|
|
|
2752
2836
|
const wroteReport = !!jobSnap.data()?.report;
|
|
2753
2837
|
const backstop = !wroteReport && input.resultText !== void 0 ? backstopReportContent(input.resultText) : null;
|
|
2754
2838
|
tx.update(jobRef, {
|
|
2755
|
-
|
|
2839
|
+
// `reportBackstop` rides with the pair rather than being derived later, because after the
|
|
2840
|
+
// write there is nothing left to derive it FROM: a synthesized report and a written one are
|
|
2841
|
+
// the same two strings. It is what stops a screen labelling a session's closing message
|
|
2842
|
+
// "Report" — see the field's docblock in @lumi/crew-shared.
|
|
2843
|
+
...backstop ? { report: backstop.report, reportSummary: backstop.summary, reportBackstop: true } : {},
|
|
2756
2844
|
status: input.status,
|
|
2757
2845
|
endedAt: now,
|
|
2758
2846
|
usage: u,
|
|
@@ -2890,8 +2978,8 @@ Write again to start a fresh run.`;
|
|
|
2890
2978
|
});
|
|
2891
2979
|
}
|
|
2892
2980
|
var REPLY_SCAN_LIMIT = 20;
|
|
2893
|
-
function backstopReplyContent(
|
|
2894
|
-
const text =
|
|
2981
|
+
function backstopReplyContent(resultText2) {
|
|
2982
|
+
const text = resultText2.trim();
|
|
2895
2983
|
if (!text) {
|
|
2896
2984
|
return "I finished that run without writing a reply. Write again to start a fresh one.";
|
|
2897
2985
|
}
|
|
@@ -2899,7 +2987,7 @@ function backstopReplyContent(resultText) {
|
|
|
2899
2987
|
const marker = "\n\n\u2026(truncated)";
|
|
2900
2988
|
return `${text.slice(0, MAX_CHAT_MESSAGE_CHARS - marker.length)}${marker}`;
|
|
2901
2989
|
}
|
|
2902
|
-
async function ensureChatReply(db, shipId, job,
|
|
2990
|
+
async function ensureChatReply(db, shipId, job, resultText2) {
|
|
2903
2991
|
const chatRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
|
|
2904
2992
|
const messagesCol = collection5(chatRef, COLLECTIONS.chatMessages);
|
|
2905
2993
|
const snap = await getDocs4(
|
|
@@ -2915,14 +3003,14 @@ async function ensureChatReply(db, shipId, job, resultText) {
|
|
|
2915
3003
|
return author?.type === "agent" && author.id === job.agentId;
|
|
2916
3004
|
});
|
|
2917
3005
|
if (replied) return "agent-replied";
|
|
2918
|
-
const content = backstopReplyContent(
|
|
3006
|
+
const content = backstopReplyContent(resultText2);
|
|
2919
3007
|
await addDoc(messagesCol, {
|
|
2920
3008
|
author: { type: "agent", id: job.agentId },
|
|
2921
3009
|
content,
|
|
2922
3010
|
chars: content.length,
|
|
2923
3011
|
createdAt: Date.now()
|
|
2924
3012
|
});
|
|
2925
|
-
return
|
|
3013
|
+
return resultText2.trim() ? "posted-final-text" : "posted-silence-note";
|
|
2926
3014
|
}
|
|
2927
3015
|
|
|
2928
3016
|
// src/jobs/progress.ts
|
|
@@ -4177,7 +4265,7 @@ async function startDaemon() {
|
|
|
4177
4265
|
let sessionLimit = null;
|
|
4178
4266
|
let engineId = DEFAULT_ENGINE_ID;
|
|
4179
4267
|
let transcript = "";
|
|
4180
|
-
let
|
|
4268
|
+
let resultText2 = "";
|
|
4181
4269
|
let usage = {
|
|
4182
4270
|
engine: DEFAULT_ENGINE_ID,
|
|
4183
4271
|
inputTokens: 0,
|
|
@@ -4239,7 +4327,10 @@ async function startDaemon() {
|
|
|
4239
4327
|
`Runner credentials are not configured for this Ship \u2014 a captain must save the following in Ship Settings: ${missing.join(", ")}.`
|
|
4240
4328
|
);
|
|
4241
4329
|
}
|
|
4242
|
-
const idToken = await sess(shipId).user
|
|
4330
|
+
const idToken = await sessionIdToken(sess(shipId).user, {
|
|
4331
|
+
timeoutMs: JOB_TIMEOUT_MS,
|
|
4332
|
+
log: log2
|
|
4333
|
+
});
|
|
4243
4334
|
extraMcpServers = await resolveMcpServers({
|
|
4244
4335
|
config: config2,
|
|
4245
4336
|
idToken,
|
|
@@ -4338,7 +4429,7 @@ async function startDaemon() {
|
|
|
4338
4429
|
});
|
|
4339
4430
|
armLimitTimer();
|
|
4340
4431
|
}
|
|
4341
|
-
|
|
4432
|
+
resultText2 = session.resultText;
|
|
4342
4433
|
if (!session.ok) failure = session.resultText || "Session failed.";
|
|
4343
4434
|
} catch (e) {
|
|
4344
4435
|
failure = e instanceof Error ? e.message : String(e);
|
|
@@ -4361,7 +4452,7 @@ async function startDaemon() {
|
|
|
4361
4452
|
// §15.41. A stopped run is the case that needs the backstop MOST: it was cut off
|
|
4362
4453
|
// mid-thought, so it almost certainly never reached `run_report` — and whatever it had
|
|
4363
4454
|
// got to is what the next run on this task would otherwise have to rediscover.
|
|
4364
|
-
resultText,
|
|
4455
|
+
resultText: resultText2,
|
|
4365
4456
|
mcpServers: extraMcpServers.map((s) => s.key)
|
|
4366
4457
|
});
|
|
4367
4458
|
const by = slot.stop.by;
|
|
@@ -4411,7 +4502,7 @@ async function startDaemon() {
|
|
|
4411
4502
|
transcriptPath,
|
|
4412
4503
|
// §15.41. Only used when the session never called `run_report` — the ordinary path is
|
|
4413
4504
|
// that it did, and a real report always wins inside the transaction.
|
|
4414
|
-
resultText,
|
|
4505
|
+
resultText: resultText2,
|
|
4415
4506
|
mcpServers: extraMcpServers.map((s) => s.key)
|
|
4416
4507
|
});
|
|
4417
4508
|
log2(`Job ${job.id} done (${usage.inputTokens}in/${usage.outputTokens}out tokens).`);
|
|
@@ -4422,7 +4513,7 @@ async function startDaemon() {
|
|
|
4422
4513
|
sess(shipId).fb.db,
|
|
4423
4514
|
shipId,
|
|
4424
4515
|
{ ...job, chatId: target.chatId },
|
|
4425
|
-
|
|
4516
|
+
resultText2
|
|
4426
4517
|
);
|
|
4427
4518
|
if (delivery === "posted-final-text") {
|
|
4428
4519
|
log2(
|
|
@@ -4468,7 +4559,7 @@ async function startDaemon() {
|
|
|
4468
4559
|
// §15.41. A failed run still did work, and the retry — or the next run after the retry
|
|
4469
4560
|
// is spent — starts from the context pack alone. `error` is the tail for a human; this
|
|
4470
4561
|
// is the continuity for the next session, and they are read by different readers.
|
|
4471
|
-
resultText,
|
|
4562
|
+
resultText: resultText2,
|
|
4472
4563
|
mcpServers: extraMcpServers.map((s) => s.key)
|
|
4473
4564
|
});
|
|
4474
4565
|
if (target.kind === "chat") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumi.ai/runner",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.10",
|
|
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.",
|