@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.
Files changed (2) hide show
  1. package/dist/cli.js +117 -26
  2. 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.9" : "0.0.0-dev";
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
- mcpProblem = workspaceMcpProblem(event, workspaceRequired);
2085
- if (mcpProblem) {
2086
- input.log(mcpProblem);
2087
- child.kill("SIGTERM");
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 resultText = mcpProblem ?? result.result ?? (ok2 ? "" : `Session ended with exit code ${exitCode}${resultEvent ? "" : " and no result event"}.`);
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
- `${resultText}
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(resultText) {
2725
- const text = resultText.trim();
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
- ...backstop ? { report: backstop.report, reportSummary: backstop.summary } : {},
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(resultText) {
2894
- const text = resultText.trim();
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, resultText) {
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(resultText);
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 resultText.trim() ? "posted-final-text" : "posted-silence-note";
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 resultText = "";
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.getIdToken();
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
- resultText = session.resultText;
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
- resultText
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.9",
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.",