@lumi.ai/runner 0.15.12 → 0.15.13

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 +165 -67
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -6,11 +6,11 @@ import { Command } from "commander";
6
6
  // src/daemon.ts
7
7
  import os4 from "node:os";
8
8
  import {
9
- collection as collection6,
9
+ collection as collection7,
10
10
  deleteField as deleteField2,
11
- doc as doc7,
12
- getDoc as getDoc6,
13
- getDocs as getDocs5,
11
+ doc as doc8,
12
+ getDoc as getDoc7,
13
+ getDocs as getDocs6,
14
14
  onSnapshot as onSnapshot2,
15
15
  orderBy as orderBy4,
16
16
  query as query4,
@@ -287,6 +287,17 @@ var COLLECTIONS = {
287
287
  events: "events",
288
288
  /** `ships/{shipId}/secrets/{docId}` — runner creds + MCP token hash (see secrets.ts). */
289
289
  secrets: "secrets",
290
+ /**
291
+ * `ships/{shipId}/credentials/{credId}` — the AI accounts this crew thinks with (PRD §15.62;
292
+ * see credentials.ts). NON-SECRET metadata only — the token is `secrets/cred_{credId}`, which no
293
+ * rule releases to anybody but an enrolled runner. Same split as `mcp_servers`.
294
+ *
295
+ * A single lowercase word, which matters for the reason `mcp_servers` documents: the
296
+ * purge-completeness oracle finds Ship subcollections by regexing firestore.rules for
297
+ * `match /([a-z_]+)/{`, so a camelCase name would be invisible to the one test that exists to
298
+ * catch a forgotten credential-bearing collection.
299
+ */
300
+ credentials: "credentials",
290
301
  /** `ships/{shipId}/integrations/{docId}` — 3rd-party connectors (GitHub App; see github.ts). */
291
302
  integrations: "integrations",
292
303
  /**
@@ -418,6 +429,17 @@ function autonomyRule(agent) {
418
429
  }
419
430
  var ASK_GUIDANCE = "If the task says who should approve \u2014 in its description, or because a particular person owns that area \u2014 name them in the request's `ask` so it reaches them rather than everyone.";
420
431
 
432
+ // ../shared/dist/credentials.js
433
+ function resolveAgentCredential(agent, credentials, defaultEngine) {
434
+ const engine = agent.engine ?? defaultEngine;
435
+ if (agent.credentialId) {
436
+ return credentials.find((c) => c.id === agent.credentialId) ?? null;
437
+ }
438
+ const forEngine = credentials.filter((c) => c.engine === engine);
439
+ const oldest = [...forEngine].sort((a, b) => a.createdAt - b.createdAt)[0];
440
+ return oldest ?? void 0;
441
+ }
442
+
421
443
  // ../shared/dist/engineLimit.js
422
444
  function isEngineLimited(limit3, now) {
423
445
  return !!limit3 && limit3.resetsAt > now;
@@ -968,7 +990,7 @@ function mcpUrl(config2) {
968
990
  }
969
991
 
970
992
  // src/version.ts
971
- var RUNNER_VERSION = true ? "0.15.12" : "0.0.0-dev";
993
+ var RUNNER_VERSION = true ? "0.15.13" : "0.0.0-dev";
972
994
 
973
995
  // src/auth.ts
974
996
  import { signInWithCustomToken } from "firebase/auth";
@@ -2668,11 +2690,56 @@ async function loadRunnerSecrets(db, shipId) {
2668
2690
  return snap.exists() ? snap.data() : null;
2669
2691
  }
2670
2692
 
2693
+ // src/jobs/credentials.ts
2694
+ import { collection as collection4, doc as doc5, getDoc as getDoc5, getDocs as getDocs4 } from "firebase/firestore";
2695
+ async function loadShipCredentials(db, shipId) {
2696
+ const snap = await withFirestoreRetry(
2697
+ () => getDocs4(collection4(db, COLLECTIONS.ships, shipId, COLLECTIONS.credentials))
2698
+ );
2699
+ return snap.docs.map((d) => ({ id: d.id, ...d.data() }));
2700
+ }
2701
+ async function loadCredentialToken(db, shipId, credentialId) {
2702
+ const snap = await withFirestoreRetry(
2703
+ () => getDoc5(doc5(db, COLLECTIONS.ships, shipId, COLLECTIONS.secrets, credentialId))
2704
+ );
2705
+ if (!snap.exists()) return null;
2706
+ const token = snap.data().token;
2707
+ return typeof token === "string" && token.trim() ? token : null;
2708
+ }
2709
+ async function resolveJobSecrets(input) {
2710
+ const { agent, credentials, runnerSecrets } = input;
2711
+ const engineId = agentEngine(agent);
2712
+ const resolved = resolveAgentCredential(agent, credentials, DEFAULT_ENGINE_ID);
2713
+ if (resolved === void 0) return { secrets: runnerSecrets };
2714
+ if (resolved === null) {
2715
+ return {
2716
+ secrets: runnerSecrets,
2717
+ problem: `This agent is set to an AI credential that no longer exists on the Ship. Point it at another one in Settings \u203A AI credentials, or add the credential back.`
2718
+ };
2719
+ }
2720
+ const token = await loadCredentialToken(input.db, input.shipId, resolved.id);
2721
+ if (!token) {
2722
+ return {
2723
+ secrets: runnerSecrets,
2724
+ problem: `The AI credential "${resolved.label}" has no token stored. Erase it in Settings \u203A AI credentials and add it again.`
2725
+ };
2726
+ }
2727
+ const key = getEngine(engineId).requiredSecrets[0]?.key;
2728
+ if (!key) return { secrets: runnerSecrets };
2729
+ return {
2730
+ secrets: {
2731
+ ...runnerSecrets ?? { updatedAt: resolved.updatedAt, updatedBy: resolved.createdBy },
2732
+ [key]: token,
2733
+ [`${key}Tail`]: resolved.tail
2734
+ }
2735
+ };
2736
+ }
2737
+
2671
2738
  // src/jobs/engineLimits.ts
2672
2739
  import {
2673
- collection as collection4,
2740
+ collection as collection5,
2674
2741
  deleteDoc,
2675
- doc as doc5,
2742
+ doc as doc6,
2676
2743
  onSnapshot,
2677
2744
  setDoc
2678
2745
  } from "firebase/firestore";
@@ -2701,7 +2768,7 @@ function nextResetAt(limits, now) {
2701
2768
  return soonest;
2702
2769
  }
2703
2770
  function limitRef(db, shipId, engineId) {
2704
- return doc5(db, COLLECTIONS.ships, shipId, COLLECTIONS.engineLimits, engineId);
2771
+ return doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.engineLimits, engineId);
2705
2772
  }
2706
2773
  async function noteEngineLimit(db, shipId, engineId, limit3, runnerId) {
2707
2774
  await setDoc(limitRef(db, shipId, engineId), {
@@ -2718,7 +2785,7 @@ async function clearEngineLimit(db, shipId, engineId) {
2718
2785
  }
2719
2786
  function subscribeEngineLimits(db, shipId, cb, onError) {
2720
2787
  return onSnapshot(
2721
- collection4(db, COLLECTIONS.ships, shipId, COLLECTIONS.engineLimits),
2788
+ collection5(db, COLLECTIONS.ships, shipId, COLLECTIONS.engineLimits),
2722
2789
  (snap) => cb(snap.docs.map((d) => ({ id: d.id, ...d.data() }))),
2723
2790
  (e) => onError?.(e)
2724
2791
  );
@@ -2798,11 +2865,11 @@ function selectDispatch(input) {
2798
2865
  // src/jobs/finish.ts
2799
2866
  import {
2800
2867
  addDoc,
2801
- collection as collection5,
2868
+ collection as collection6,
2802
2869
  deleteField,
2803
- doc as doc6,
2804
- getDoc as getDoc5,
2805
- getDocs as getDocs4,
2870
+ doc as doc7,
2871
+ getDoc as getDoc6,
2872
+ getDocs as getDocs5,
2806
2873
  limit as fsLimit,
2807
2874
  orderBy as orderBy3,
2808
2875
  query as query3,
@@ -2847,10 +2914,10 @@ function backstopReportContent(resultText2) {
2847
2914
  return { report: report4, summary };
2848
2915
  }
2849
2916
  async function finalizeJob(db, shipId, job, input) {
2850
- const shipRef = doc6(db, COLLECTIONS.ships, shipId);
2851
- const jobRef = doc6(shipRef, COLLECTIONS.jobs, job.id);
2917
+ const shipRef = doc7(db, COLLECTIONS.ships, shipId);
2918
+ const jobRef = doc7(shipRef, COLLECTIONS.jobs, job.id);
2852
2919
  const now = Date.now();
2853
- const usageRef = doc6(shipRef, COLLECTIONS.usageDaily, utcDay(now));
2920
+ const usageRef = doc7(shipRef, COLLECTIONS.usageDaily, utcDay(now));
2854
2921
  await runTransaction(db, async (tx) => {
2855
2922
  const usageSnap = await tx.get(usageRef);
2856
2923
  const jobSnap = await tx.get(jobRef);
@@ -2912,7 +2979,7 @@ async function finalizeJob(db, shipId, job, input) {
2912
2979
  }
2913
2980
  async function requeueForRetry(db, shipId, job, error) {
2914
2981
  await runTransaction(db, async (tx) => {
2915
- tx.update(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
2982
+ tx.update(doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
2916
2983
  status: "queued",
2917
2984
  attempt: job.attempt + 1,
2918
2985
  error: error.slice(0, 1500),
@@ -2922,7 +2989,7 @@ async function requeueForRetry(db, shipId, job, error) {
2922
2989
  });
2923
2990
  }
2924
2991
  async function releaseJob(db, shipId, job, reason) {
2925
- await updateDoc(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
2992
+ await updateDoc(doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
2926
2993
  status: "queued",
2927
2994
  runnerId: "",
2928
2995
  startedAt: 0,
@@ -2938,13 +3005,13 @@ async function releaseJob(db, shipId, job, reason) {
2938
3005
  });
2939
3006
  }
2940
3007
  async function markTaskFailed(db, shipId, job, error, statuses) {
2941
- const shipRef = doc6(db, COLLECTIONS.ships, shipId);
2942
- const taskRef = doc6(shipRef, COLLECTIONS.tasks, job.taskId);
3008
+ const shipRef = doc7(db, COLLECTIONS.ships, shipId);
3009
+ const taskRef = doc7(shipRef, COLLECTIONS.tasks, job.taskId);
2943
3010
  const now = Date.now();
2944
3011
  const failedStatus = firstStatusIn(statuses, "failed")?.id ?? "failed";
2945
3012
  await runTransaction(db, async (tx) => {
2946
3013
  tx.update(taskRef, { status: failedStatus, updatedAt: now });
2947
- tx.set(doc6(collection5(taskRef, COLLECTIONS.activity)), {
3014
+ tx.set(doc7(collection6(taskRef, COLLECTIONS.activity)), {
2948
3015
  author: { type: "agent", id: job.agentId },
2949
3016
  createdAt: now,
2950
3017
  kind: "comment",
@@ -2958,9 +3025,9 @@ ${error.slice(0, 800)}
2958
3025
  });
2959
3026
  }
2960
3027
  async function markTaskStopped(db, shipId, job, stoppedBy) {
2961
- const taskRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.tasks, job.taskId);
3028
+ const taskRef = doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.tasks, job.taskId);
2962
3029
  const who = await actorName(db, shipId, stoppedBy);
2963
- await addDoc(collection5(taskRef, COLLECTIONS.activity), {
3030
+ await addDoc(collection6(taskRef, COLLECTIONS.activity), {
2964
3031
  author: { type: "agent", id: job.agentId },
2965
3032
  createdAt: Date.now(),
2966
3033
  kind: "comment",
@@ -2969,7 +3036,7 @@ async function markTaskStopped(db, shipId, job, stoppedBy) {
2969
3036
  }
2970
3037
  async function actorName(db, shipId, actorId) {
2971
3038
  try {
2972
- const snap = await getDoc5(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.members, actorId));
3039
+ const snap = await getDoc6(doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.members, actorId));
2973
3040
  const m = snap.data();
2974
3041
  return m?.displayName || m?.email || actorId;
2975
3042
  } catch {
@@ -2977,10 +3044,10 @@ async function actorName(db, shipId, actorId) {
2977
3044
  }
2978
3045
  }
2979
3046
  async function markChatStopped(db, shipId, job, stoppedBy) {
2980
- const chatRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
3047
+ const chatRef = doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
2981
3048
  const who = await actorName(db, shipId, stoppedBy);
2982
3049
  const content = `Stopped by ${who} before I finished. Write again to start a fresh run.`;
2983
- await addDoc(collection5(chatRef, COLLECTIONS.chatMessages), {
3050
+ await addDoc(collection6(chatRef, COLLECTIONS.chatMessages), {
2984
3051
  author: { type: "agent", id: job.agentId },
2985
3052
  content,
2986
3053
  chars: content.length,
@@ -2988,8 +3055,8 @@ async function markChatStopped(db, shipId, job, stoppedBy) {
2988
3055
  });
2989
3056
  }
2990
3057
  async function markChatFailed(db, shipId, job, error) {
2991
- const shipRef = doc6(db, COLLECTIONS.ships, shipId);
2992
- const chatRef = doc6(shipRef, COLLECTIONS.chats, job.chatId);
3058
+ const shipRef = doc7(db, COLLECTIONS.ships, shipId);
3059
+ const chatRef = doc7(shipRef, COLLECTIONS.chats, job.chatId);
2993
3060
  const now = Date.now();
2994
3061
  const content = `I could not finish replying \u2014 the run failed after ${job.attempt} attempt(s).
2995
3062
 
@@ -2998,7 +3065,7 @@ ${error.slice(0, 500)}
2998
3065
  \`\`\`
2999
3066
 
3000
3067
  Write again to start a fresh run.`;
3001
- await addDoc(collection5(chatRef, COLLECTIONS.chatMessages), {
3068
+ await addDoc(collection6(chatRef, COLLECTIONS.chatMessages), {
3002
3069
  author: { type: "agent", id: job.agentId },
3003
3070
  content,
3004
3071
  chars: content.length,
@@ -3016,9 +3083,9 @@ function backstopReplyContent(resultText2) {
3016
3083
  return `${text.slice(0, MAX_CHAT_MESSAGE_CHARS - marker.length)}${marker}`;
3017
3084
  }
3018
3085
  async function ensureChatReply(db, shipId, job, resultText2) {
3019
- const chatRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
3020
- const messagesCol = collection5(chatRef, COLLECTIONS.chatMessages);
3021
- const snap = await getDocs4(
3086
+ const chatRef = doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
3087
+ const messagesCol = collection6(chatRef, COLLECTIONS.chatMessages);
3088
+ const snap = await getDocs5(
3022
3089
  query3(
3023
3090
  messagesCol,
3024
3091
  where3("createdAt", ">=", job.startedAt || 0),
@@ -3841,7 +3908,7 @@ async function startDaemon() {
3841
3908
  const warnedUnapproved = /* @__PURE__ */ new Set();
3842
3909
  const shipRunnerRef = (shipId) => {
3843
3910
  const session = sess(shipId);
3844
- return doc7(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId);
3911
+ return doc8(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId);
3845
3912
  };
3846
3913
  const needsRefill = /* @__PURE__ */ new Set();
3847
3914
  let beating = false;
@@ -3852,7 +3919,7 @@ async function startDaemon() {
3852
3919
  try {
3853
3920
  for (const shipId of [...serving]) {
3854
3921
  try {
3855
- const snap = await getDoc6(shipRunnerRef(shipId));
3922
+ const snap = await getDoc7(shipRunnerRef(shipId));
3856
3923
  if (!snap.exists()) {
3857
3924
  forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
3858
3925
  continue;
@@ -3909,9 +3976,9 @@ async function startDaemon() {
3909
3976
  needsRefill.delete(shipId);
3910
3977
  if (!serving.has(shipId) || approved.get(shipId) !== true) continue;
3911
3978
  try {
3912
- const snap = await getDocs5(
3979
+ const snap = await getDocs6(
3913
3980
  query4(
3914
- collection6(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
3981
+ collection7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
3915
3982
  where4("status", "==", "queued"),
3916
3983
  orderBy4("createdAt", "asc")
3917
3984
  )
@@ -3943,7 +4010,7 @@ async function startDaemon() {
3943
4010
  };
3944
4011
  for (const shipId of serving) {
3945
4012
  const q = query4(
3946
- collection6(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
4013
+ collection7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
3947
4014
  where4("status", "==", "queued"),
3948
4015
  orderBy4("createdAt", "asc")
3949
4016
  );
@@ -3966,7 +4033,7 @@ async function startDaemon() {
3966
4033
  ),
3967
4034
  // Agents, purely so the claim gate can resolve a queued job's engine without a read.
3968
4035
  onSnapshot2(
3969
- collection6(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
4036
+ collection7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
3970
4037
  (snap) => {
3971
4038
  for (const d of snap.docs) {
3972
4039
  agentEngines.set(`${shipId}/${d.id}`, agentEngine(d.data()));
@@ -3988,7 +4055,7 @@ async function startDaemon() {
3988
4055
  * what happens. Testing is somebody's decision, and `doctor` is where it is made on purpose.
3989
4056
  */
3990
4057
  onSnapshot2(
3991
- collection6(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers),
4058
+ collection7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers),
3992
4059
  (snap) => {
3993
4060
  const servers = snap.docs.map(
3994
4061
  (d) => ({ id: d.id, ...d.data() })
@@ -4197,7 +4264,7 @@ async function startDaemon() {
4197
4264
  pending.set(`${shipId}/${job.id}`, { shipId, job });
4198
4265
  return null;
4199
4266
  }
4200
- const jobRef = doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id);
4267
+ const jobRef = doc8(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id);
4201
4268
  try {
4202
4269
  let claimed = null;
4203
4270
  await runTransaction2(sess(shipId).fb.db, async (tx) => {
@@ -4220,7 +4287,7 @@ async function startDaemon() {
4220
4287
  }
4221
4288
  async function setAgentStatus(shipId, agentId, status) {
4222
4289
  try {
4223
- await updateDoc2(doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents, agentId), { status });
4290
+ await updateDoc2(doc8(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents, agentId), { status });
4224
4291
  } catch (e) {
4225
4292
  log2(`agent status update failed: ${e instanceof Error ? e.message : e}`);
4226
4293
  }
@@ -4267,8 +4334,8 @@ async function startDaemon() {
4267
4334
  void (async () => {
4268
4335
  if (slot.stop) return;
4269
4336
  try {
4270
- const snap = await getDoc6(
4271
- doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
4337
+ const snap = await getDoc7(
4338
+ doc8(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
4272
4339
  );
4273
4340
  const fresh = snap.data();
4274
4341
  if (!fresh?.stopRequestedAt || slot.stop) return;
@@ -4308,7 +4375,7 @@ async function startDaemon() {
4308
4375
  let statuses = DEFAULT_TASK_STATUSES;
4309
4376
  let knownSecrets = [];
4310
4377
  const progress = createProgressWriter({
4311
- write: (p) => updateDoc2(doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
4378
+ write: (p) => updateDoc2(doc8(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
4312
4379
  progress: p
4313
4380
  }),
4314
4381
  secrets: () => knownSecrets,
@@ -4322,8 +4389,8 @@ async function startDaemon() {
4322
4389
  onDenied: () => {
4323
4390
  void (async () => {
4324
4391
  try {
4325
- const snap = await getDoc6(
4326
- doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
4392
+ const snap = await getDoc7(
4393
+ doc8(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
4327
4394
  );
4328
4395
  const fresh = snap.data();
4329
4396
  if (!fresh || fresh.status !== "running" || fresh.runnerId !== sess(shipId).runnerId) {
@@ -4338,7 +4405,7 @@ async function startDaemon() {
4338
4405
  });
4339
4406
  progress.push({ kind: "start", label: "Starting up" });
4340
4407
  try {
4341
- const secrets = await loadSecrets(shipId);
4408
+ let secrets = await loadSecrets(shipId);
4342
4409
  const packed = target.kind === "chat" ? { kind: "chat", ctx: await loadChatContext(sess(shipId).fb.db, shipId, { ...job, chatId: target.chatId }) } : { kind: "task", ctx: await loadJobContext(sess(shipId).fb.db, shipId, { ...job, taskId: target.taskId }) };
4343
4410
  if (packed.kind === "task" && !isWorkflowSentinel(job.workflowId) && !packed.ctx.workflow) {
4344
4411
  log2(
@@ -4349,6 +4416,17 @@ async function startDaemon() {
4349
4416
  engineId = agentEngine(agent);
4350
4417
  usage = { ...usage, engine: engineId };
4351
4418
  statuses = shipTaskStatuses(ship2);
4419
+ const resolvedSecrets = await resolveJobSecrets({
4420
+ db: sess(shipId).fb.db,
4421
+ shipId,
4422
+ agent,
4423
+ runnerSecrets: secrets,
4424
+ credentials: await loadShipCredentials(sess(shipId).fb.db, shipId).catch(() => [])
4425
+ });
4426
+ if (resolvedSecrets.problem) {
4427
+ throw new Error(resolvedSecrets.problem);
4428
+ }
4429
+ secrets = resolvedSecrets.secrets;
4352
4430
  const missing = missingSecretsFor(engineId, secrets);
4353
4431
  if (missing.length > 0 && !process.env.CREW_CLAUDE_BIN) {
4354
4432
  throw new Error(
@@ -5147,7 +5225,7 @@ function setParallel(config2, value, ship2) {
5147
5225
  import { spawnSync as spawnSync3 } from "node:child_process";
5148
5226
  import fs7 from "node:fs";
5149
5227
  import path8 from "node:path";
5150
- import { collection as collection7, doc as doc8, getDoc as getDoc7, getDocs as getDocs6 } from "firebase/firestore";
5228
+ import { collection as collection8, doc as doc9, getDoc as getDoc8, getDocs as getDocs7 } from "firebase/firestore";
5151
5229
 
5152
5230
  // src/cli/session.ts
5153
5231
  async function openShipSession(shipId) {
@@ -5316,8 +5394,8 @@ async function checkShips(config2) {
5316
5394
  continue;
5317
5395
  }
5318
5396
  try {
5319
- const snap = await getDoc7(
5320
- doc8(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId)
5397
+ const snap = await getDoc8(
5398
+ doc9(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId)
5321
5399
  );
5322
5400
  if (!snap.exists()) {
5323
5401
  checks.push(
@@ -5358,7 +5436,7 @@ async function checkShips(config2) {
5358
5436
  }
5359
5437
  let agents = [];
5360
5438
  try {
5361
- const snap = await getDocs6(collection7(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
5439
+ const snap = await getDocs7(collection8(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
5362
5440
  agents = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
5363
5441
  } catch {
5364
5442
  }
@@ -5368,13 +5446,33 @@ async function checkShips(config2) {
5368
5446
  if (agents.some((agent) => effectiveAgentTools(agent).github.enabled)) needsGithub = true;
5369
5447
  try {
5370
5448
  const secrets = await loadRunnerSecrets(session.fb.db, shipId);
5371
- const missing = [...new Set([...shipEngines].flatMap((id) => missingSecretsFor(id, secrets)))];
5449
+ const credentials = await loadShipCredentials(session.fb.db, shipId).catch(() => []);
5450
+ const missing = [
5451
+ ...new Set(
5452
+ [...shipEngines].flatMap((id) => {
5453
+ if (credentials.some((c) => c.engine === id)) return [];
5454
+ return missingSecretsFor(id, secrets);
5455
+ })
5456
+ )
5457
+ ];
5458
+ const orphaned = agents.filter(
5459
+ (agent) => resolveAgentCredential(agent, credentials, DEFAULT_ENGINE_ID) === null
5460
+ );
5372
5461
  checks.push(
5373
- missing.length === 0 ? ok(`secrets:${shipId}`, `Ship ${shipId} \u2014 credentials`, "All required secrets are saved.") : fail(
5462
+ missing.length > 0 ? fail(
5374
5463
  `secrets:${shipId}`,
5375
5464
  `Ship ${shipId} \u2014 credentials`,
5376
5465
  `Missing: ${missing.join(", ")}.`,
5377
- "A captain saves these in Ship Settings \u2192 Runner credentials."
5466
+ "A captain saves these in Ship Settings \u2192 AI credentials."
5467
+ ) : orphaned.length > 0 ? fail(
5468
+ `secrets:${shipId}`,
5469
+ `Ship ${shipId} \u2014 credentials`,
5470
+ `${orphaned.length} agent(s) are set to a credential that no longer exists: ${orphaned.map((a) => a.name).join(", ")}.`,
5471
+ "Point them at another one in Ship Settings \u2192 AI credentials."
5472
+ ) : ok(
5473
+ `secrets:${shipId}`,
5474
+ `Ship ${shipId} \u2014 credentials`,
5475
+ "All required secrets are saved."
5378
5476
  )
5379
5477
  );
5380
5478
  } catch (e) {
@@ -5388,8 +5486,8 @@ async function checkShips(config2) {
5388
5486
  );
5389
5487
  }
5390
5488
  try {
5391
- const snap = await getDocs6(
5392
- collection7(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
5489
+ const snap = await getDocs7(
5490
+ collection8(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
5393
5491
  );
5394
5492
  const servers = snap.docs.map(
5395
5493
  (d) => ({ id: d.id, ...d.data() })
@@ -5838,7 +5936,7 @@ async function runUninstall(options) {
5838
5936
  }
5839
5937
 
5840
5938
  // src/cli/commands/ship.ts
5841
- import { doc as doc9, getDoc as getDoc8 } from "firebase/firestore";
5939
+ import { doc as doc10, getDoc as getDoc9 } from "firebase/firestore";
5842
5940
  async function listMyShips() {
5843
5941
  const config2 = requireConfig();
5844
5942
  const ids = Object.keys(config2.shipKeys ?? {});
@@ -5846,7 +5944,7 @@ async function listMyShips() {
5846
5944
  ids.map(async (id) => {
5847
5945
  try {
5848
5946
  const { fb } = await openShipSession(id);
5849
- const snap = await getDoc8(doc9(fb.db, COLLECTIONS.ships, id));
5947
+ const snap = await getDoc9(doc10(fb.db, COLLECTIONS.ships, id));
5850
5948
  return { id, ...snap.data() };
5851
5949
  } catch {
5852
5950
  return null;
@@ -6006,11 +6104,11 @@ async function runSetup(options) {
6006
6104
 
6007
6105
  // src/cli/commands/status.ts
6008
6106
  import {
6009
- collection as collection8,
6010
- doc as doc10,
6107
+ collection as collection9,
6108
+ doc as doc11,
6011
6109
  getCountFromServer as getCountFromServer2,
6012
- getDoc as getDoc9,
6013
- getDocs as getDocs7,
6110
+ getDoc as getDoc10,
6111
+ getDocs as getDocs8,
6014
6112
  query as query5,
6015
6113
  where as where5
6016
6114
  } from "firebase/firestore";
@@ -6047,22 +6145,22 @@ async function runStatus() {
6047
6145
  });
6048
6146
  continue;
6049
6147
  }
6050
- const shipRef = doc10(session.fb.db, COLLECTIONS.ships, shipId);
6051
- const mirrorSnap = await getDoc9(doc10(shipRef, COLLECTIONS.runners, session.runnerId));
6148
+ const shipRef = doc11(session.fb.db, COLLECTIONS.ships, shipId);
6149
+ const mirrorSnap = await getDoc10(doc11(shipRef, COLLECTIONS.runners, session.runnerId));
6052
6150
  const mirror = mirrorSnap.data();
6053
6151
  let queued = 0;
6054
6152
  try {
6055
6153
  const counted = await getCountFromServer2(
6056
- query5(collection8(shipRef, COLLECTIONS.jobs), where5("status", "==", "queued"))
6154
+ query5(collection9(shipRef, COLLECTIONS.jobs), where5("status", "==", "queued"))
6057
6155
  );
6058
6156
  queued = counted.data().count;
6059
6157
  } catch {
6060
6158
  queued = 0;
6061
6159
  }
6062
- const usageSnap = await getDoc9(doc10(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
6160
+ const usageSnap = await getDoc10(doc11(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
6063
6161
  const today = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
6064
6162
  const now = Date.now();
6065
- const limitsSnap = await getDocs7(collection8(shipRef, COLLECTIONS.engineLimits));
6163
+ const limitsSnap = await getDocs8(collection9(shipRef, COLLECTIONS.engineLimits));
6066
6164
  const engineLimits = limitsSnap.docs.map((d) => ({ id: d.id, ...d.data() })).filter((l) => isEngineLimited(l, now));
6067
6165
  ships.push({
6068
6166
  shipId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi.ai/runner",
3
- "version": "0.15.12",
3
+ "version": "0.15.13",
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.",