@lumi.ai/runner 0.15.19 → 0.15.20

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 +105 -16
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1065,7 +1065,7 @@ function mcpUrl(config2) {
1065
1065
  }
1066
1066
 
1067
1067
  // src/version.ts
1068
- var RUNNER_VERSION = true ? "0.15.19" : "0.0.0-dev";
1068
+ var RUNNER_VERSION = true ? "0.15.20" : "0.0.0-dev";
1069
1069
 
1070
1070
  // src/auth.ts
1071
1071
  import { signInWithCustomToken } from "firebase/auth";
@@ -2127,16 +2127,40 @@ function detectClaudeLimit(text, now, fallbackMs) {
2127
2127
  detail: text.trim().slice(0, 300)
2128
2128
  };
2129
2129
  }
2130
- function workspaceMcpProblem(initEvent, required) {
2131
- if (!required) return null;
2130
+ function expectedMcpServers(agent, extraServers = []) {
2131
+ const out = [];
2132
+ if (effectiveAgentTools(agent).workspaceMcp) {
2133
+ out.push({ key: WORKSPACE_MCP_KEY, label: "the Workspace MCP" });
2134
+ }
2135
+ for (const s of extraServers) out.push({ key: s.key, label: s.name || s.key });
2136
+ return out;
2137
+ }
2138
+ function mcpConnectionFailures(initEvent, expected) {
2139
+ if (expected.length === 0) return [];
2140
+ const byKey = new Map(expected.map((e) => [e.key, e]));
2141
+ const found = /* @__PURE__ */ new Map();
2132
2142
  const errors = initEvent.mcp_server_errors;
2133
- if (!Array.isArray(errors)) return null;
2134
- const mine = errors.find(
2135
- (e) => e && typeof e === "object" && e.name === WORKSPACE_MCP_KEY
2136
- );
2137
- if (!mine) return null;
2138
- const detail = mine.message || mine.type || "no reason given";
2139
- return `The Claude CLI refused to load the Workspace MCP ("${WORKSPACE_MCP_KEY}"): ${detail}. The session would have had none of this Ship's tools \u2014 no task_comment, no run_report, no chat_send \u2014 so it was stopped instead of run blind. This is a runner/CLI mismatch on this machine: update the daemon (\`npm i -g @lumi.ai/runner\`) and the Claude CLI, then retry.`;
2143
+ if (Array.isArray(errors)) {
2144
+ for (const e of errors) {
2145
+ if (!e || typeof e !== "object" || typeof e.name !== "string") continue;
2146
+ const want = byKey.get(e.name);
2147
+ if (want) found.set(want.key, { ...want, detail: e.message || e.type || "no reason given" });
2148
+ }
2149
+ }
2150
+ const servers = initEvent.mcp_servers;
2151
+ if (Array.isArray(servers)) {
2152
+ for (const s of servers) {
2153
+ if (!s || typeof s !== "object" || typeof s.name !== "string") continue;
2154
+ const want = byKey.get(s.name);
2155
+ if (want && s.status !== "connected" && !found.has(want.key)) {
2156
+ found.set(want.key, { ...want, detail: `status "${s.status ?? "unknown"}"` });
2157
+ }
2158
+ }
2159
+ }
2160
+ return [...found.values()];
2161
+ }
2162
+ function workspaceRefusal(failure) {
2163
+ return `The Claude CLI did not load ${failure.label} ("${failure.key}"): ${failure.detail}. The session would have had none of this Ship's tools \u2014 no task_comment, no run_report, no chat_send \u2014 so it was stopped instead of run blind. If this keeps happening, update the daemon (\`npm i -g @lumi.ai/runner\`) and the Claude CLI on this machine.`;
2140
2164
  }
2141
2165
  function allowedTools(agent, extraServers = []) {
2142
2166
  const granted = effectiveAgentTools(agent);
@@ -2244,6 +2268,21 @@ async function runSession(input, bin, dirs) {
2244
2268
  const claudeToken = input.secrets?.claudeToken;
2245
2269
  const env = {
2246
2270
  ...process.env,
2271
+ // THE INIT EVENT MUST BE TRUTHFUL, and one inherited variable is enough to make it lie.
2272
+ //
2273
+ // `MCP_CONNECTION_NONBLOCKING` lets the CLI start before its MCP servers have settled. The init
2274
+ // event then reports `mcp_servers: []` with no status and no error — indistinguishable from a
2275
+ // server that failed — while the connection completes moments later. `mcpConnectionFailures`
2276
+ // reads that event and can only be as honest as it is, so a daemon started from a shell that
2277
+ // happens to set this would either miss every blind session or refuse every healthy one.
2278
+ //
2279
+ // It is DELETED rather than set to a value, because "off" for these is absence: the CLI reads
2280
+ // the variable's presence. A daemon is a long-lived process started from somebody's terminal,
2281
+ // an npm script, or a launchd unit, and it must not inherit a decision about its own
2282
+ // observability from any of them. Found the hard way — every local session for a day reported
2283
+ // an empty server list because the shell that launched the daemon set this.
2284
+ MCP_CONNECTION_NONBLOCKING: void 0,
2285
+ MCP_SERVER_CONNECTION_BATCH_SIZE: void 0,
2247
2286
  ...claudeToken ? { CLAUDE_CODE_OAUTH_TOKEN: claudeToken } : {},
2248
2287
  ...input.githubToken ? {
2249
2288
  GH_TOKEN: input.githubToken,
@@ -2264,6 +2303,8 @@ async function runSession(input, bin, dirs) {
2264
2303
  let resultEvent = null;
2265
2304
  let mcpProblem = null;
2266
2305
  const workspaceRequired = effectiveAgentTools(input.agent).workspaceMcp;
2306
+ const expectedServers = expectedMcpServers(input.agent, input.extraMcpServers ?? []);
2307
+ const mcpFailed = [];
2267
2308
  const mcpWatch = createWorkspaceMcpWatch();
2268
2309
  const exitCode = await new Promise((resolve) => {
2269
2310
  const child = spawn3(bin, args, { cwd: dirs.workdir, env, stdio: ["ignore", "pipe", "pipe"] });
@@ -2301,7 +2342,14 @@ async function runSession(input, bin, dirs) {
2301
2342
  if (event.type === "result") resultEvent = event;
2302
2343
  if (event.type === "assistant") input.log("claude: assistant turn");
2303
2344
  if (event.type === "system" && event.subtype === "init" && !mcpProblem) {
2304
- stopBlindSession(workspaceMcpProblem(event, workspaceRequired));
2345
+ for (const failure of mcpConnectionFailures(event, expectedServers)) {
2346
+ if (failure.key === WORKSPACE_MCP_KEY) {
2347
+ stopBlindSession(workspaceRefusal(failure));
2348
+ continue;
2349
+ }
2350
+ input.log(`MCP connection "${failure.key}" did not load: ${failure.detail}.`);
2351
+ if (!mcpFailed.includes(failure.key)) mcpFailed.push(failure.key);
2352
+ }
2305
2353
  }
2306
2354
  if (workspaceRequired && !mcpProblem) {
2307
2355
  stopBlindSession(mcpWatch.observe(event));
@@ -2349,7 +2397,8 @@ ${stderrLines.slice(-20).join("\n")}`,
2349
2397
  `,
2350
2398
  usage,
2351
2399
  resultText: resultText2,
2352
- ...limit3 ? { limit: limit3 } : {}
2400
+ ...limit3 ? { limit: limit3 } : {},
2401
+ ...mcpFailed.length ? { mcpFailed } : {}
2353
2402
  };
2354
2403
  }
2355
2404
  var CLAUDE_DRIVER_ID = "claude";
@@ -3158,8 +3207,45 @@ Use Try again above to run it once more.`;
3158
3207
  });
3159
3208
  }
3160
3209
  var REPLY_SCAN_LIMIT = 20;
3210
+ var NARRATION_MARKERS = [
3211
+ // Our own heading and the harness's own word for the block above it — see `chatStandingRules`.
3212
+ /\bsystem prompt\b/i,
3213
+ /\bstanding rules\b/i,
3214
+ // "Based on my available tools, …"
3215
+ /\bmy (?:available |current )?tools?\b/i,
3216
+ // "Let me check what tools I do have access to."
3217
+ /\btools?\b[^.?!]{0,40}\bI (?:do )?have access to\b/i,
3218
+ // Both orders of "<the toolbox> is <missing>", bounded to one sentence so a paragraph that
3219
+ // merely mentions tools and, later, something being unavailable does not match.
3220
+ /\b(?:mcp|tools?)\b[^.?!]{0,60}\b(?:not available|unavailable|(?:are|is)n'?t available|not connected|not loaded|no access)\b/i,
3221
+ /\b(?:not available|unavailable|no access|(?:do not|don'?t|does not|doesn'?t) have access)\b[^.?!]{0,60}\b(?:mcp|tools?)\b/i
3222
+ ];
3223
+ function isSessionNarration(paragraph) {
3224
+ if (paragraph.includes("```")) return false;
3225
+ if (!/\b(?:i|i'm|i'll|i've|my|me)\b/i.test(paragraph)) return false;
3226
+ return NARRATION_MARKERS.some((re) => re.test(paragraph));
3227
+ }
3228
+ function paragraphsWithOffsets(text) {
3229
+ const out = [];
3230
+ const breaks = /\n{2,}/g;
3231
+ let start = 0;
3232
+ let m;
3233
+ while ((m = breaks.exec(text)) !== null) {
3234
+ out.push({ text: text.slice(start, m.index), start });
3235
+ start = m.index + m[0].length;
3236
+ }
3237
+ out.push({ text: text.slice(start), start });
3238
+ return out;
3239
+ }
3240
+ function stripSessionNarration(text) {
3241
+ const paragraphs = paragraphsWithOffsets(text);
3242
+ let dropped = 0;
3243
+ while (dropped < paragraphs.length && isSessionNarration(paragraphs[dropped].text)) dropped += 1;
3244
+ if (dropped === 0 || dropped === paragraphs.length) return text;
3245
+ return text.slice(paragraphs[dropped].start).trim();
3246
+ }
3161
3247
  function backstopReplyContent(resultText2) {
3162
- const text = resultText2.trim();
3248
+ const text = stripSessionNarration(resultText2.trim());
3163
3249
  if (!text) {
3164
3250
  return "I finished that run without writing a reply. Write again to start a fresh one.";
3165
3251
  }
@@ -4444,6 +4530,7 @@ async function startDaemon() {
4444
4530
  let engineId = DEFAULT_ENGINE_ID;
4445
4531
  let transcript = "";
4446
4532
  let resultText2 = "";
4533
+ let mcpConnected = [];
4447
4534
  let usage = {
4448
4535
  engine: DEFAULT_ENGINE_ID,
4449
4536
  inputTokens: 0,
@@ -4616,6 +4703,8 @@ async function startDaemon() {
4616
4703
  armLimitTimer();
4617
4704
  }
4618
4705
  resultText2 = session.resultText;
4706
+ const failed = new Set(session.mcpFailed ?? []);
4707
+ mcpConnected = extraMcpServers.map((s) => s.key).filter((k) => !failed.has(k));
4619
4708
  if (!session.ok) failure = session.resultText || "Session failed.";
4620
4709
  } catch (e) {
4621
4710
  failure = e instanceof Error ? e.message : String(e);
@@ -4639,7 +4728,7 @@ async function startDaemon() {
4639
4728
  // mid-thought, so it almost certainly never reached `run_report` — and whatever it had
4640
4729
  // got to is what the next run on this task would otherwise have to rediscover.
4641
4730
  resultText: resultText2,
4642
- mcpServers: extraMcpServers.map((s) => s.key)
4731
+ mcpServers: mcpConnected
4643
4732
  });
4644
4733
  const by = slot.stop.by;
4645
4734
  if (target.kind === "chat") {
@@ -4689,7 +4778,7 @@ async function startDaemon() {
4689
4778
  // §15.41. Only used when the session never called `run_report` — the ordinary path is
4690
4779
  // that it did, and a real report always wins inside the transaction.
4691
4780
  resultText: resultText2,
4692
- mcpServers: extraMcpServers.map((s) => s.key)
4781
+ mcpServers: mcpConnected
4693
4782
  });
4694
4783
  log2(`Job ${job.id} done (${usage.inputTokens}in/${usage.outputTokens}out tokens).`);
4695
4784
  let delivery = "agent-replied";
@@ -4746,7 +4835,7 @@ async function startDaemon() {
4746
4835
  // is spent — starts from the context pack alone. `error` is the tail for a human; this
4747
4836
  // is the continuity for the next session, and they are read by different readers.
4748
4837
  resultText: resultText2,
4749
- mcpServers: extraMcpServers.map((s) => s.key)
4838
+ mcpServers: mcpConnected
4750
4839
  });
4751
4840
  if (target.kind === "chat") {
4752
4841
  await markChatFailed(sess(shipId).fb.db, shipId, { ...job, chatId: target.chatId }, failure);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi.ai/runner",
3
- "version": "0.15.19",
3
+ "version": "0.15.20",
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.",