@lumi.ai/runner 0.15.11 → 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 +193 -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
  /**
@@ -314,6 +325,24 @@ var COLLECTIONS = {
314
325
  * MCP connection splits from `secrets/mcp_srv_{key}`.
315
326
  */
316
327
  oauthGrants: "oauth_grants",
328
+ /**
329
+ * `ships/{shipId}/webhooks/{hookId}` — one inbound endpoint (PRD §15.56). NON-SECRET metadata;
330
+ * the signing secret is `secrets/webhook_{hookId}`, which no rule releases to anybody.
331
+ *
332
+ * SNAKE_CASE-safe as a single word, which matters for the reason `mcp_servers` documents: the
333
+ * purge-completeness oracle finds Ship subcollections by regexing firestore.rules for
334
+ * `match /([a-z_]+)/{`, so a camelCase name would be invisible to the one test that exists to
335
+ * catch a forgotten credential-bearing collection.
336
+ */
337
+ webhooks: "webhooks",
338
+ /**
339
+ * `ships/{shipId}/webhook_deliveries/{key}` — the idempotency ledger (PRD §15.56).
340
+ *
341
+ * One document per accepted delivery, created transactionally: `tx.create` FAILING because the
342
+ * document exists IS the lock, so there is no read-then-write race for two concurrent retries to
343
+ * slip through. Swept by TTL rather than kept, since its only job is to be present.
344
+ */
345
+ webhookDeliveries: "webhook_deliveries",
317
346
  /** Top-level `users/{uid}/...` — per-user docs (runner machine registry). */
318
347
  users: "users",
319
348
  /** Top-level `githubInstallStates/{stateId}` — single-use install-flow nonces (backend only). */
@@ -400,6 +429,17 @@ function autonomyRule(agent) {
400
429
  }
401
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.";
402
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
+
403
443
  // ../shared/dist/engineLimit.js
404
444
  function isEngineLimited(limit3, now) {
405
445
  return !!limit3 && limit3.resetsAt > now;
@@ -814,6 +854,16 @@ var EMPTY_USAGE_BY_AGENT = {
814
854
  jobs: 0
815
855
  };
816
856
 
857
+ // ../shared/dist/webhook.js
858
+ var MAX_WEBHOOK_FANOUT = 5;
859
+ var WEBHOOK_ACK_REASONS = {
860
+ disabled: "This hook is switched off, so the delivery was accepted and dropped.",
861
+ duplicate: "Already delivered \u2014 this is a retry of a delivery that was accepted.",
862
+ noListener: "No enabled playbook on this Ship is listening to this hook.",
863
+ rateLimited: "Deliveries are arriving faster than one per second; this one was dropped.",
864
+ fanout: `Woke the first ${MAX_WEBHOOK_FANOUT} playbooks listening; the rest were skipped.`
865
+ };
866
+
817
867
  // ../shared/dist/workflow.js
818
868
  var WORKFLOW_SENTINELS = ["generic", "assigned", "activity", "chat"];
819
869
  function isWorkflowSentinel(id) {
@@ -940,7 +990,7 @@ function mcpUrl(config2) {
940
990
  }
941
991
 
942
992
  // src/version.ts
943
- var RUNNER_VERSION = true ? "0.15.11" : "0.0.0-dev";
993
+ var RUNNER_VERSION = true ? "0.15.13" : "0.0.0-dev";
944
994
 
945
995
  // src/auth.ts
946
996
  import { signInWithCustomToken } from "firebase/auth";
@@ -2640,11 +2690,56 @@ async function loadRunnerSecrets(db, shipId) {
2640
2690
  return snap.exists() ? snap.data() : null;
2641
2691
  }
2642
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
+
2643
2738
  // src/jobs/engineLimits.ts
2644
2739
  import {
2645
- collection as collection4,
2740
+ collection as collection5,
2646
2741
  deleteDoc,
2647
- doc as doc5,
2742
+ doc as doc6,
2648
2743
  onSnapshot,
2649
2744
  setDoc
2650
2745
  } from "firebase/firestore";
@@ -2673,7 +2768,7 @@ function nextResetAt(limits, now) {
2673
2768
  return soonest;
2674
2769
  }
2675
2770
  function limitRef(db, shipId, engineId) {
2676
- return doc5(db, COLLECTIONS.ships, shipId, COLLECTIONS.engineLimits, engineId);
2771
+ return doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.engineLimits, engineId);
2677
2772
  }
2678
2773
  async function noteEngineLimit(db, shipId, engineId, limit3, runnerId) {
2679
2774
  await setDoc(limitRef(db, shipId, engineId), {
@@ -2690,7 +2785,7 @@ async function clearEngineLimit(db, shipId, engineId) {
2690
2785
  }
2691
2786
  function subscribeEngineLimits(db, shipId, cb, onError) {
2692
2787
  return onSnapshot(
2693
- collection4(db, COLLECTIONS.ships, shipId, COLLECTIONS.engineLimits),
2788
+ collection5(db, COLLECTIONS.ships, shipId, COLLECTIONS.engineLimits),
2694
2789
  (snap) => cb(snap.docs.map((d) => ({ id: d.id, ...d.data() }))),
2695
2790
  (e) => onError?.(e)
2696
2791
  );
@@ -2770,11 +2865,11 @@ function selectDispatch(input) {
2770
2865
  // src/jobs/finish.ts
2771
2866
  import {
2772
2867
  addDoc,
2773
- collection as collection5,
2868
+ collection as collection6,
2774
2869
  deleteField,
2775
- doc as doc6,
2776
- getDoc as getDoc5,
2777
- getDocs as getDocs4,
2870
+ doc as doc7,
2871
+ getDoc as getDoc6,
2872
+ getDocs as getDocs5,
2778
2873
  limit as fsLimit,
2779
2874
  orderBy as orderBy3,
2780
2875
  query as query3,
@@ -2819,10 +2914,10 @@ function backstopReportContent(resultText2) {
2819
2914
  return { report: report4, summary };
2820
2915
  }
2821
2916
  async function finalizeJob(db, shipId, job, input) {
2822
- const shipRef = doc6(db, COLLECTIONS.ships, shipId);
2823
- 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);
2824
2919
  const now = Date.now();
2825
- const usageRef = doc6(shipRef, COLLECTIONS.usageDaily, utcDay(now));
2920
+ const usageRef = doc7(shipRef, COLLECTIONS.usageDaily, utcDay(now));
2826
2921
  await runTransaction(db, async (tx) => {
2827
2922
  const usageSnap = await tx.get(usageRef);
2828
2923
  const jobSnap = await tx.get(jobRef);
@@ -2884,7 +2979,7 @@ async function finalizeJob(db, shipId, job, input) {
2884
2979
  }
2885
2980
  async function requeueForRetry(db, shipId, job, error) {
2886
2981
  await runTransaction(db, async (tx) => {
2887
- tx.update(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
2982
+ tx.update(doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
2888
2983
  status: "queued",
2889
2984
  attempt: job.attempt + 1,
2890
2985
  error: error.slice(0, 1500),
@@ -2894,7 +2989,7 @@ async function requeueForRetry(db, shipId, job, error) {
2894
2989
  });
2895
2990
  }
2896
2991
  async function releaseJob(db, shipId, job, reason) {
2897
- await updateDoc(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
2992
+ await updateDoc(doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
2898
2993
  status: "queued",
2899
2994
  runnerId: "",
2900
2995
  startedAt: 0,
@@ -2910,13 +3005,13 @@ async function releaseJob(db, shipId, job, reason) {
2910
3005
  });
2911
3006
  }
2912
3007
  async function markTaskFailed(db, shipId, job, error, statuses) {
2913
- const shipRef = doc6(db, COLLECTIONS.ships, shipId);
2914
- 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);
2915
3010
  const now = Date.now();
2916
3011
  const failedStatus = firstStatusIn(statuses, "failed")?.id ?? "failed";
2917
3012
  await runTransaction(db, async (tx) => {
2918
3013
  tx.update(taskRef, { status: failedStatus, updatedAt: now });
2919
- tx.set(doc6(collection5(taskRef, COLLECTIONS.activity)), {
3014
+ tx.set(doc7(collection6(taskRef, COLLECTIONS.activity)), {
2920
3015
  author: { type: "agent", id: job.agentId },
2921
3016
  createdAt: now,
2922
3017
  kind: "comment",
@@ -2930,9 +3025,9 @@ ${error.slice(0, 800)}
2930
3025
  });
2931
3026
  }
2932
3027
  async function markTaskStopped(db, shipId, job, stoppedBy) {
2933
- const taskRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.tasks, job.taskId);
3028
+ const taskRef = doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.tasks, job.taskId);
2934
3029
  const who = await actorName(db, shipId, stoppedBy);
2935
- await addDoc(collection5(taskRef, COLLECTIONS.activity), {
3030
+ await addDoc(collection6(taskRef, COLLECTIONS.activity), {
2936
3031
  author: { type: "agent", id: job.agentId },
2937
3032
  createdAt: Date.now(),
2938
3033
  kind: "comment",
@@ -2941,7 +3036,7 @@ async function markTaskStopped(db, shipId, job, stoppedBy) {
2941
3036
  }
2942
3037
  async function actorName(db, shipId, actorId) {
2943
3038
  try {
2944
- 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));
2945
3040
  const m = snap.data();
2946
3041
  return m?.displayName || m?.email || actorId;
2947
3042
  } catch {
@@ -2949,10 +3044,10 @@ async function actorName(db, shipId, actorId) {
2949
3044
  }
2950
3045
  }
2951
3046
  async function markChatStopped(db, shipId, job, stoppedBy) {
2952
- const chatRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
3047
+ const chatRef = doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
2953
3048
  const who = await actorName(db, shipId, stoppedBy);
2954
3049
  const content = `Stopped by ${who} before I finished. Write again to start a fresh run.`;
2955
- await addDoc(collection5(chatRef, COLLECTIONS.chatMessages), {
3050
+ await addDoc(collection6(chatRef, COLLECTIONS.chatMessages), {
2956
3051
  author: { type: "agent", id: job.agentId },
2957
3052
  content,
2958
3053
  chars: content.length,
@@ -2960,8 +3055,8 @@ async function markChatStopped(db, shipId, job, stoppedBy) {
2960
3055
  });
2961
3056
  }
2962
3057
  async function markChatFailed(db, shipId, job, error) {
2963
- const shipRef = doc6(db, COLLECTIONS.ships, shipId);
2964
- 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);
2965
3060
  const now = Date.now();
2966
3061
  const content = `I could not finish replying \u2014 the run failed after ${job.attempt} attempt(s).
2967
3062
 
@@ -2970,7 +3065,7 @@ ${error.slice(0, 500)}
2970
3065
  \`\`\`
2971
3066
 
2972
3067
  Write again to start a fresh run.`;
2973
- await addDoc(collection5(chatRef, COLLECTIONS.chatMessages), {
3068
+ await addDoc(collection6(chatRef, COLLECTIONS.chatMessages), {
2974
3069
  author: { type: "agent", id: job.agentId },
2975
3070
  content,
2976
3071
  chars: content.length,
@@ -2988,9 +3083,9 @@ function backstopReplyContent(resultText2) {
2988
3083
  return `${text.slice(0, MAX_CHAT_MESSAGE_CHARS - marker.length)}${marker}`;
2989
3084
  }
2990
3085
  async function ensureChatReply(db, shipId, job, resultText2) {
2991
- const chatRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
2992
- const messagesCol = collection5(chatRef, COLLECTIONS.chatMessages);
2993
- 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(
2994
3089
  query3(
2995
3090
  messagesCol,
2996
3091
  where3("createdAt", ">=", job.startedAt || 0),
@@ -3813,7 +3908,7 @@ async function startDaemon() {
3813
3908
  const warnedUnapproved = /* @__PURE__ */ new Set();
3814
3909
  const shipRunnerRef = (shipId) => {
3815
3910
  const session = sess(shipId);
3816
- 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);
3817
3912
  };
3818
3913
  const needsRefill = /* @__PURE__ */ new Set();
3819
3914
  let beating = false;
@@ -3824,7 +3919,7 @@ async function startDaemon() {
3824
3919
  try {
3825
3920
  for (const shipId of [...serving]) {
3826
3921
  try {
3827
- const snap = await getDoc6(shipRunnerRef(shipId));
3922
+ const snap = await getDoc7(shipRunnerRef(shipId));
3828
3923
  if (!snap.exists()) {
3829
3924
  forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
3830
3925
  continue;
@@ -3881,9 +3976,9 @@ async function startDaemon() {
3881
3976
  needsRefill.delete(shipId);
3882
3977
  if (!serving.has(shipId) || approved.get(shipId) !== true) continue;
3883
3978
  try {
3884
- const snap = await getDocs5(
3979
+ const snap = await getDocs6(
3885
3980
  query4(
3886
- collection6(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
3981
+ collection7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
3887
3982
  where4("status", "==", "queued"),
3888
3983
  orderBy4("createdAt", "asc")
3889
3984
  )
@@ -3915,7 +4010,7 @@ async function startDaemon() {
3915
4010
  };
3916
4011
  for (const shipId of serving) {
3917
4012
  const q = query4(
3918
- collection6(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
4013
+ collection7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
3919
4014
  where4("status", "==", "queued"),
3920
4015
  orderBy4("createdAt", "asc")
3921
4016
  );
@@ -3938,7 +4033,7 @@ async function startDaemon() {
3938
4033
  ),
3939
4034
  // Agents, purely so the claim gate can resolve a queued job's engine without a read.
3940
4035
  onSnapshot2(
3941
- collection6(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
4036
+ collection7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
3942
4037
  (snap) => {
3943
4038
  for (const d of snap.docs) {
3944
4039
  agentEngines.set(`${shipId}/${d.id}`, agentEngine(d.data()));
@@ -3960,7 +4055,7 @@ async function startDaemon() {
3960
4055
  * what happens. Testing is somebody's decision, and `doctor` is where it is made on purpose.
3961
4056
  */
3962
4057
  onSnapshot2(
3963
- collection6(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers),
4058
+ collection7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers),
3964
4059
  (snap) => {
3965
4060
  const servers = snap.docs.map(
3966
4061
  (d) => ({ id: d.id, ...d.data() })
@@ -4169,7 +4264,7 @@ async function startDaemon() {
4169
4264
  pending.set(`${shipId}/${job.id}`, { shipId, job });
4170
4265
  return null;
4171
4266
  }
4172
- 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);
4173
4268
  try {
4174
4269
  let claimed = null;
4175
4270
  await runTransaction2(sess(shipId).fb.db, async (tx) => {
@@ -4192,7 +4287,7 @@ async function startDaemon() {
4192
4287
  }
4193
4288
  async function setAgentStatus(shipId, agentId, status) {
4194
4289
  try {
4195
- 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 });
4196
4291
  } catch (e) {
4197
4292
  log2(`agent status update failed: ${e instanceof Error ? e.message : e}`);
4198
4293
  }
@@ -4239,8 +4334,8 @@ async function startDaemon() {
4239
4334
  void (async () => {
4240
4335
  if (slot.stop) return;
4241
4336
  try {
4242
- const snap = await getDoc6(
4243
- 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)
4244
4339
  );
4245
4340
  const fresh = snap.data();
4246
4341
  if (!fresh?.stopRequestedAt || slot.stop) return;
@@ -4280,7 +4375,7 @@ async function startDaemon() {
4280
4375
  let statuses = DEFAULT_TASK_STATUSES;
4281
4376
  let knownSecrets = [];
4282
4377
  const progress = createProgressWriter({
4283
- 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), {
4284
4379
  progress: p
4285
4380
  }),
4286
4381
  secrets: () => knownSecrets,
@@ -4294,8 +4389,8 @@ async function startDaemon() {
4294
4389
  onDenied: () => {
4295
4390
  void (async () => {
4296
4391
  try {
4297
- const snap = await getDoc6(
4298
- 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)
4299
4394
  );
4300
4395
  const fresh = snap.data();
4301
4396
  if (!fresh || fresh.status !== "running" || fresh.runnerId !== sess(shipId).runnerId) {
@@ -4310,7 +4405,7 @@ async function startDaemon() {
4310
4405
  });
4311
4406
  progress.push({ kind: "start", label: "Starting up" });
4312
4407
  try {
4313
- const secrets = await loadSecrets(shipId);
4408
+ let secrets = await loadSecrets(shipId);
4314
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 }) };
4315
4410
  if (packed.kind === "task" && !isWorkflowSentinel(job.workflowId) && !packed.ctx.workflow) {
4316
4411
  log2(
@@ -4321,6 +4416,17 @@ async function startDaemon() {
4321
4416
  engineId = agentEngine(agent);
4322
4417
  usage = { ...usage, engine: engineId };
4323
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;
4324
4430
  const missing = missingSecretsFor(engineId, secrets);
4325
4431
  if (missing.length > 0 && !process.env.CREW_CLAUDE_BIN) {
4326
4432
  throw new Error(
@@ -5119,7 +5225,7 @@ function setParallel(config2, value, ship2) {
5119
5225
  import { spawnSync as spawnSync3 } from "node:child_process";
5120
5226
  import fs7 from "node:fs";
5121
5227
  import path8 from "node:path";
5122
- 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";
5123
5229
 
5124
5230
  // src/cli/session.ts
5125
5231
  async function openShipSession(shipId) {
@@ -5288,8 +5394,8 @@ async function checkShips(config2) {
5288
5394
  continue;
5289
5395
  }
5290
5396
  try {
5291
- const snap = await getDoc7(
5292
- 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)
5293
5399
  );
5294
5400
  if (!snap.exists()) {
5295
5401
  checks.push(
@@ -5330,7 +5436,7 @@ async function checkShips(config2) {
5330
5436
  }
5331
5437
  let agents = [];
5332
5438
  try {
5333
- 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));
5334
5440
  agents = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
5335
5441
  } catch {
5336
5442
  }
@@ -5340,13 +5446,33 @@ async function checkShips(config2) {
5340
5446
  if (agents.some((agent) => effectiveAgentTools(agent).github.enabled)) needsGithub = true;
5341
5447
  try {
5342
5448
  const secrets = await loadRunnerSecrets(session.fb.db, shipId);
5343
- 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
+ );
5344
5461
  checks.push(
5345
- missing.length === 0 ? ok(`secrets:${shipId}`, `Ship ${shipId} \u2014 credentials`, "All required secrets are saved.") : fail(
5462
+ missing.length > 0 ? fail(
5346
5463
  `secrets:${shipId}`,
5347
5464
  `Ship ${shipId} \u2014 credentials`,
5348
5465
  `Missing: ${missing.join(", ")}.`,
5349
- "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."
5350
5476
  )
5351
5477
  );
5352
5478
  } catch (e) {
@@ -5360,8 +5486,8 @@ async function checkShips(config2) {
5360
5486
  );
5361
5487
  }
5362
5488
  try {
5363
- const snap = await getDocs6(
5364
- collection7(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
5489
+ const snap = await getDocs7(
5490
+ collection8(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
5365
5491
  );
5366
5492
  const servers = snap.docs.map(
5367
5493
  (d) => ({ id: d.id, ...d.data() })
@@ -5810,7 +5936,7 @@ async function runUninstall(options) {
5810
5936
  }
5811
5937
 
5812
5938
  // src/cli/commands/ship.ts
5813
- import { doc as doc9, getDoc as getDoc8 } from "firebase/firestore";
5939
+ import { doc as doc10, getDoc as getDoc9 } from "firebase/firestore";
5814
5940
  async function listMyShips() {
5815
5941
  const config2 = requireConfig();
5816
5942
  const ids = Object.keys(config2.shipKeys ?? {});
@@ -5818,7 +5944,7 @@ async function listMyShips() {
5818
5944
  ids.map(async (id) => {
5819
5945
  try {
5820
5946
  const { fb } = await openShipSession(id);
5821
- const snap = await getDoc8(doc9(fb.db, COLLECTIONS.ships, id));
5947
+ const snap = await getDoc9(doc10(fb.db, COLLECTIONS.ships, id));
5822
5948
  return { id, ...snap.data() };
5823
5949
  } catch {
5824
5950
  return null;
@@ -5978,11 +6104,11 @@ async function runSetup(options) {
5978
6104
 
5979
6105
  // src/cli/commands/status.ts
5980
6106
  import {
5981
- collection as collection8,
5982
- doc as doc10,
6107
+ collection as collection9,
6108
+ doc as doc11,
5983
6109
  getCountFromServer as getCountFromServer2,
5984
- getDoc as getDoc9,
5985
- getDocs as getDocs7,
6110
+ getDoc as getDoc10,
6111
+ getDocs as getDocs8,
5986
6112
  query as query5,
5987
6113
  where as where5
5988
6114
  } from "firebase/firestore";
@@ -6019,22 +6145,22 @@ async function runStatus() {
6019
6145
  });
6020
6146
  continue;
6021
6147
  }
6022
- const shipRef = doc10(session.fb.db, COLLECTIONS.ships, shipId);
6023
- 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));
6024
6150
  const mirror = mirrorSnap.data();
6025
6151
  let queued = 0;
6026
6152
  try {
6027
6153
  const counted = await getCountFromServer2(
6028
- query5(collection8(shipRef, COLLECTIONS.jobs), where5("status", "==", "queued"))
6154
+ query5(collection9(shipRef, COLLECTIONS.jobs), where5("status", "==", "queued"))
6029
6155
  );
6030
6156
  queued = counted.data().count;
6031
6157
  } catch {
6032
6158
  queued = 0;
6033
6159
  }
6034
- const usageSnap = await getDoc9(doc10(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
6160
+ const usageSnap = await getDoc10(doc11(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
6035
6161
  const today = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
6036
6162
  const now = Date.now();
6037
- const limitsSnap = await getDocs7(collection8(shipRef, COLLECTIONS.engineLimits));
6163
+ const limitsSnap = await getDocs8(collection9(shipRef, COLLECTIONS.engineLimits));
6038
6164
  const engineLimits = limitsSnap.docs.map((d) => ({ id: d.id, ...d.data() })).filter((l) => isEngineLimited(l, now));
6039
6165
  ships.push({
6040
6166
  shipId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi.ai/runner",
3
- "version": "0.15.11",
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.",