@lumi.ai/runner 0.15.12 → 0.15.14

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 +194 -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,
@@ -277,6 +277,34 @@ var COLLECTIONS = {
277
277
  /** ROOT collection (§15.44): one liveness document per relay instance, overwritten each
278
278
  * report. Not a Ship subcollection — a relay serves every Ship and belongs to none. */
279
279
  browserRelays: "browserRelays",
280
+ /**
281
+ * ROOT (§15.64): `crew_stripe_events/{eventId}` — the webhook idempotency ledger.
282
+ *
283
+ * Root rather than Ship-scoped because a Stripe event is addressed to an ACCOUNT, and the Ship
284
+ * it concerns is read out of its metadata AFTER the duplicate check. Scoping it under a Ship
285
+ * would mean resolving the Ship before knowing whether the event had already been handled,
286
+ * which is the wrong order — and would strand the ledger row when that Ship is purged.
287
+ */
288
+ crewStripeEvents: "crewStripeEvents",
289
+ /**
290
+ * ROOT (§15.64): `crewBillingAccounts/{uid}` — one person's subscription, entire.
291
+ *
292
+ * Keyed by auth uid, not by Ship, because ONE PERSON HAS ONE SUBSCRIPTION covering every
293
+ * workspace they own. That is the whole of §15.64's second shape: a workspace is free, a seat is
294
+ * paid, and the seat count is pooled across the account — so the subscription cannot live on a
295
+ * Ship without being copied onto each of them, and N copies of one fact is N chances for a stale
296
+ * one.
297
+ *
298
+ * What a Ship still carries is only what `firestore.rules` and the triggers must read without a
299
+ * network call: `billing.status` and `billing.trialEnd`. Everything else — customer, subscription
300
+ * id, seats, interval, period end, the seat-sync error — is here, once.
301
+ *
302
+ * Deny-all to clients. Writable it is an account-takeover primitive (point your uid at somebody
303
+ * else's customer and the Portal session minted for you opens their card); readable it leaks who
304
+ * is a customer. The app never touches it — the Billing page gets its numbers from
305
+ * `crewBillingPreview`, which asks Stripe.
306
+ */
307
+ crewBillingAccounts: "crewBillingAccounts",
280
308
  /** `ships/{shipId}/notifications/{id}` — per-member in-app notifications. */
281
309
  notifications: "notifications",
282
310
  /** `ships/{shipId}/usage_daily/{yyyy-mm-dd}` — token-tracking aggregates. */
@@ -287,6 +315,17 @@ var COLLECTIONS = {
287
315
  events: "events",
288
316
  /** `ships/{shipId}/secrets/{docId}` — runner creds + MCP token hash (see secrets.ts). */
289
317
  secrets: "secrets",
318
+ /**
319
+ * `ships/{shipId}/credentials/{credId}` — the AI accounts this crew thinks with (PRD §15.62;
320
+ * see credentials.ts). NON-SECRET metadata only — the token is `secrets/cred_{credId}`, which no
321
+ * rule releases to anybody but an enrolled runner. Same split as `mcp_servers`.
322
+ *
323
+ * A single lowercase word, which matters for the reason `mcp_servers` documents: the
324
+ * purge-completeness oracle finds Ship subcollections by regexing firestore.rules for
325
+ * `match /([a-z_]+)/{`, so a camelCase name would be invisible to the one test that exists to
326
+ * catch a forgotten credential-bearing collection.
327
+ */
328
+ credentials: "credentials",
290
329
  /** `ships/{shipId}/integrations/{docId}` — 3rd-party connectors (GitHub App; see github.ts). */
291
330
  integrations: "integrations",
292
331
  /**
@@ -418,6 +457,17 @@ function autonomyRule(agent) {
418
457
  }
419
458
  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
459
 
460
+ // ../shared/dist/credentials.js
461
+ function resolveAgentCredential(agent, credentials, defaultEngine) {
462
+ const engine = agent.engine ?? defaultEngine;
463
+ if (agent.credentialId) {
464
+ return credentials.find((c) => c.id === agent.credentialId) ?? null;
465
+ }
466
+ const forEngine = credentials.filter((c) => c.engine === engine);
467
+ const oldest = [...forEngine].sort((a, b) => a.createdAt - b.createdAt)[0];
468
+ return oldest ?? void 0;
469
+ }
470
+
421
471
  // ../shared/dist/engineLimit.js
422
472
  function isEngineLimited(limit3, now) {
423
473
  return !!limit3 && limit3.resetsAt > now;
@@ -839,6 +889,7 @@ var WEBHOOK_ACK_REASONS = {
839
889
  duplicate: "Already delivered \u2014 this is a retry of a delivery that was accepted.",
840
890
  noListener: "No enabled playbook on this Ship is listening to this hook.",
841
891
  rateLimited: "Deliveries are arriving faster than one per second; this one was dropped.",
892
+ shipPaused: "This Ship's subscription is not active, so the delivery was accepted and no agent was woken.",
842
893
  fanout: `Woke the first ${MAX_WEBHOOK_FANOUT} playbooks listening; the rest were skipped.`
843
894
  };
844
895
 
@@ -968,7 +1019,7 @@ function mcpUrl(config2) {
968
1019
  }
969
1020
 
970
1021
  // src/version.ts
971
- var RUNNER_VERSION = true ? "0.15.12" : "0.0.0-dev";
1022
+ var RUNNER_VERSION = true ? "0.15.14" : "0.0.0-dev";
972
1023
 
973
1024
  // src/auth.ts
974
1025
  import { signInWithCustomToken } from "firebase/auth";
@@ -2668,11 +2719,56 @@ async function loadRunnerSecrets(db, shipId) {
2668
2719
  return snap.exists() ? snap.data() : null;
2669
2720
  }
2670
2721
 
2722
+ // src/jobs/credentials.ts
2723
+ import { collection as collection4, doc as doc5, getDoc as getDoc5, getDocs as getDocs4 } from "firebase/firestore";
2724
+ async function loadShipCredentials(db, shipId) {
2725
+ const snap = await withFirestoreRetry(
2726
+ () => getDocs4(collection4(db, COLLECTIONS.ships, shipId, COLLECTIONS.credentials))
2727
+ );
2728
+ return snap.docs.map((d) => ({ id: d.id, ...d.data() }));
2729
+ }
2730
+ async function loadCredentialToken(db, shipId, credentialId) {
2731
+ const snap = await withFirestoreRetry(
2732
+ () => getDoc5(doc5(db, COLLECTIONS.ships, shipId, COLLECTIONS.secrets, credentialId))
2733
+ );
2734
+ if (!snap.exists()) return null;
2735
+ const token = snap.data().token;
2736
+ return typeof token === "string" && token.trim() ? token : null;
2737
+ }
2738
+ async function resolveJobSecrets(input) {
2739
+ const { agent, credentials, runnerSecrets } = input;
2740
+ const engineId = agentEngine(agent);
2741
+ const resolved = resolveAgentCredential(agent, credentials, DEFAULT_ENGINE_ID);
2742
+ if (resolved === void 0) return { secrets: runnerSecrets };
2743
+ if (resolved === null) {
2744
+ return {
2745
+ secrets: runnerSecrets,
2746
+ 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.`
2747
+ };
2748
+ }
2749
+ const token = await loadCredentialToken(input.db, input.shipId, resolved.id);
2750
+ if (!token) {
2751
+ return {
2752
+ secrets: runnerSecrets,
2753
+ problem: `The AI credential "${resolved.label}" has no token stored. Erase it in Settings \u203A AI credentials and add it again.`
2754
+ };
2755
+ }
2756
+ const key = getEngine(engineId).requiredSecrets[0]?.key;
2757
+ if (!key) return { secrets: runnerSecrets };
2758
+ return {
2759
+ secrets: {
2760
+ ...runnerSecrets ?? { updatedAt: resolved.updatedAt, updatedBy: resolved.createdBy },
2761
+ [key]: token,
2762
+ [`${key}Tail`]: resolved.tail
2763
+ }
2764
+ };
2765
+ }
2766
+
2671
2767
  // src/jobs/engineLimits.ts
2672
2768
  import {
2673
- collection as collection4,
2769
+ collection as collection5,
2674
2770
  deleteDoc,
2675
- doc as doc5,
2771
+ doc as doc6,
2676
2772
  onSnapshot,
2677
2773
  setDoc
2678
2774
  } from "firebase/firestore";
@@ -2701,7 +2797,7 @@ function nextResetAt(limits, now) {
2701
2797
  return soonest;
2702
2798
  }
2703
2799
  function limitRef(db, shipId, engineId) {
2704
- return doc5(db, COLLECTIONS.ships, shipId, COLLECTIONS.engineLimits, engineId);
2800
+ return doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.engineLimits, engineId);
2705
2801
  }
2706
2802
  async function noteEngineLimit(db, shipId, engineId, limit3, runnerId) {
2707
2803
  await setDoc(limitRef(db, shipId, engineId), {
@@ -2718,7 +2814,7 @@ async function clearEngineLimit(db, shipId, engineId) {
2718
2814
  }
2719
2815
  function subscribeEngineLimits(db, shipId, cb, onError) {
2720
2816
  return onSnapshot(
2721
- collection4(db, COLLECTIONS.ships, shipId, COLLECTIONS.engineLimits),
2817
+ collection5(db, COLLECTIONS.ships, shipId, COLLECTIONS.engineLimits),
2722
2818
  (snap) => cb(snap.docs.map((d) => ({ id: d.id, ...d.data() }))),
2723
2819
  (e) => onError?.(e)
2724
2820
  );
@@ -2798,11 +2894,11 @@ function selectDispatch(input) {
2798
2894
  // src/jobs/finish.ts
2799
2895
  import {
2800
2896
  addDoc,
2801
- collection as collection5,
2897
+ collection as collection6,
2802
2898
  deleteField,
2803
- doc as doc6,
2804
- getDoc as getDoc5,
2805
- getDocs as getDocs4,
2899
+ doc as doc7,
2900
+ getDoc as getDoc6,
2901
+ getDocs as getDocs5,
2806
2902
  limit as fsLimit,
2807
2903
  orderBy as orderBy3,
2808
2904
  query as query3,
@@ -2847,10 +2943,10 @@ function backstopReportContent(resultText2) {
2847
2943
  return { report: report4, summary };
2848
2944
  }
2849
2945
  async function finalizeJob(db, shipId, job, input) {
2850
- const shipRef = doc6(db, COLLECTIONS.ships, shipId);
2851
- const jobRef = doc6(shipRef, COLLECTIONS.jobs, job.id);
2946
+ const shipRef = doc7(db, COLLECTIONS.ships, shipId);
2947
+ const jobRef = doc7(shipRef, COLLECTIONS.jobs, job.id);
2852
2948
  const now = Date.now();
2853
- const usageRef = doc6(shipRef, COLLECTIONS.usageDaily, utcDay(now));
2949
+ const usageRef = doc7(shipRef, COLLECTIONS.usageDaily, utcDay(now));
2854
2950
  await runTransaction(db, async (tx) => {
2855
2951
  const usageSnap = await tx.get(usageRef);
2856
2952
  const jobSnap = await tx.get(jobRef);
@@ -2912,7 +3008,7 @@ async function finalizeJob(db, shipId, job, input) {
2912
3008
  }
2913
3009
  async function requeueForRetry(db, shipId, job, error) {
2914
3010
  await runTransaction(db, async (tx) => {
2915
- tx.update(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
3011
+ tx.update(doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
2916
3012
  status: "queued",
2917
3013
  attempt: job.attempt + 1,
2918
3014
  error: error.slice(0, 1500),
@@ -2922,7 +3018,7 @@ async function requeueForRetry(db, shipId, job, error) {
2922
3018
  });
2923
3019
  }
2924
3020
  async function releaseJob(db, shipId, job, reason) {
2925
- await updateDoc(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
3021
+ await updateDoc(doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
2926
3022
  status: "queued",
2927
3023
  runnerId: "",
2928
3024
  startedAt: 0,
@@ -2938,13 +3034,13 @@ async function releaseJob(db, shipId, job, reason) {
2938
3034
  });
2939
3035
  }
2940
3036
  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);
3037
+ const shipRef = doc7(db, COLLECTIONS.ships, shipId);
3038
+ const taskRef = doc7(shipRef, COLLECTIONS.tasks, job.taskId);
2943
3039
  const now = Date.now();
2944
3040
  const failedStatus = firstStatusIn(statuses, "failed")?.id ?? "failed";
2945
3041
  await runTransaction(db, async (tx) => {
2946
3042
  tx.update(taskRef, { status: failedStatus, updatedAt: now });
2947
- tx.set(doc6(collection5(taskRef, COLLECTIONS.activity)), {
3043
+ tx.set(doc7(collection6(taskRef, COLLECTIONS.activity)), {
2948
3044
  author: { type: "agent", id: job.agentId },
2949
3045
  createdAt: now,
2950
3046
  kind: "comment",
@@ -2958,9 +3054,9 @@ ${error.slice(0, 800)}
2958
3054
  });
2959
3055
  }
2960
3056
  async function markTaskStopped(db, shipId, job, stoppedBy) {
2961
- const taskRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.tasks, job.taskId);
3057
+ const taskRef = doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.tasks, job.taskId);
2962
3058
  const who = await actorName(db, shipId, stoppedBy);
2963
- await addDoc(collection5(taskRef, COLLECTIONS.activity), {
3059
+ await addDoc(collection6(taskRef, COLLECTIONS.activity), {
2964
3060
  author: { type: "agent", id: job.agentId },
2965
3061
  createdAt: Date.now(),
2966
3062
  kind: "comment",
@@ -2969,7 +3065,7 @@ async function markTaskStopped(db, shipId, job, stoppedBy) {
2969
3065
  }
2970
3066
  async function actorName(db, shipId, actorId) {
2971
3067
  try {
2972
- const snap = await getDoc5(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.members, actorId));
3068
+ const snap = await getDoc6(doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.members, actorId));
2973
3069
  const m = snap.data();
2974
3070
  return m?.displayName || m?.email || actorId;
2975
3071
  } catch {
@@ -2977,10 +3073,10 @@ async function actorName(db, shipId, actorId) {
2977
3073
  }
2978
3074
  }
2979
3075
  async function markChatStopped(db, shipId, job, stoppedBy) {
2980
- const chatRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
3076
+ const chatRef = doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
2981
3077
  const who = await actorName(db, shipId, stoppedBy);
2982
3078
  const content = `Stopped by ${who} before I finished. Write again to start a fresh run.`;
2983
- await addDoc(collection5(chatRef, COLLECTIONS.chatMessages), {
3079
+ await addDoc(collection6(chatRef, COLLECTIONS.chatMessages), {
2984
3080
  author: { type: "agent", id: job.agentId },
2985
3081
  content,
2986
3082
  chars: content.length,
@@ -2988,8 +3084,8 @@ async function markChatStopped(db, shipId, job, stoppedBy) {
2988
3084
  });
2989
3085
  }
2990
3086
  async function markChatFailed(db, shipId, job, error) {
2991
- const shipRef = doc6(db, COLLECTIONS.ships, shipId);
2992
- const chatRef = doc6(shipRef, COLLECTIONS.chats, job.chatId);
3087
+ const shipRef = doc7(db, COLLECTIONS.ships, shipId);
3088
+ const chatRef = doc7(shipRef, COLLECTIONS.chats, job.chatId);
2993
3089
  const now = Date.now();
2994
3090
  const content = `I could not finish replying \u2014 the run failed after ${job.attempt} attempt(s).
2995
3091
 
@@ -2998,7 +3094,7 @@ ${error.slice(0, 500)}
2998
3094
  \`\`\`
2999
3095
 
3000
3096
  Write again to start a fresh run.`;
3001
- await addDoc(collection5(chatRef, COLLECTIONS.chatMessages), {
3097
+ await addDoc(collection6(chatRef, COLLECTIONS.chatMessages), {
3002
3098
  author: { type: "agent", id: job.agentId },
3003
3099
  content,
3004
3100
  chars: content.length,
@@ -3016,9 +3112,9 @@ function backstopReplyContent(resultText2) {
3016
3112
  return `${text.slice(0, MAX_CHAT_MESSAGE_CHARS - marker.length)}${marker}`;
3017
3113
  }
3018
3114
  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(
3115
+ const chatRef = doc7(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
3116
+ const messagesCol = collection6(chatRef, COLLECTIONS.chatMessages);
3117
+ const snap = await getDocs5(
3022
3118
  query3(
3023
3119
  messagesCol,
3024
3120
  where3("createdAt", ">=", job.startedAt || 0),
@@ -3841,7 +3937,7 @@ async function startDaemon() {
3841
3937
  const warnedUnapproved = /* @__PURE__ */ new Set();
3842
3938
  const shipRunnerRef = (shipId) => {
3843
3939
  const session = sess(shipId);
3844
- return doc7(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId);
3940
+ return doc8(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId);
3845
3941
  };
3846
3942
  const needsRefill = /* @__PURE__ */ new Set();
3847
3943
  let beating = false;
@@ -3852,7 +3948,7 @@ async function startDaemon() {
3852
3948
  try {
3853
3949
  for (const shipId of [...serving]) {
3854
3950
  try {
3855
- const snap = await getDoc6(shipRunnerRef(shipId));
3951
+ const snap = await getDoc7(shipRunnerRef(shipId));
3856
3952
  if (!snap.exists()) {
3857
3953
  forgetShipLocally(shipId, "a captain removed this machine on the Daemons page");
3858
3954
  continue;
@@ -3909,9 +4005,9 @@ async function startDaemon() {
3909
4005
  needsRefill.delete(shipId);
3910
4006
  if (!serving.has(shipId) || approved.get(shipId) !== true) continue;
3911
4007
  try {
3912
- const snap = await getDocs5(
4008
+ const snap = await getDocs6(
3913
4009
  query4(
3914
- collection6(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
4010
+ collection7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
3915
4011
  where4("status", "==", "queued"),
3916
4012
  orderBy4("createdAt", "asc")
3917
4013
  )
@@ -3943,7 +4039,7 @@ async function startDaemon() {
3943
4039
  };
3944
4040
  for (const shipId of serving) {
3945
4041
  const q = query4(
3946
- collection6(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
4042
+ collection7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
3947
4043
  where4("status", "==", "queued"),
3948
4044
  orderBy4("createdAt", "asc")
3949
4045
  );
@@ -3966,7 +4062,7 @@ async function startDaemon() {
3966
4062
  ),
3967
4063
  // Agents, purely so the claim gate can resolve a queued job's engine without a read.
3968
4064
  onSnapshot2(
3969
- collection6(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
4065
+ collection7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
3970
4066
  (snap) => {
3971
4067
  for (const d of snap.docs) {
3972
4068
  agentEngines.set(`${shipId}/${d.id}`, agentEngine(d.data()));
@@ -3988,7 +4084,7 @@ async function startDaemon() {
3988
4084
  * what happens. Testing is somebody's decision, and `doctor` is where it is made on purpose.
3989
4085
  */
3990
4086
  onSnapshot2(
3991
- collection6(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers),
4087
+ collection7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers),
3992
4088
  (snap) => {
3993
4089
  const servers = snap.docs.map(
3994
4090
  (d) => ({ id: d.id, ...d.data() })
@@ -4197,7 +4293,7 @@ async function startDaemon() {
4197
4293
  pending.set(`${shipId}/${job.id}`, { shipId, job });
4198
4294
  return null;
4199
4295
  }
4200
- const jobRef = doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id);
4296
+ const jobRef = doc8(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id);
4201
4297
  try {
4202
4298
  let claimed = null;
4203
4299
  await runTransaction2(sess(shipId).fb.db, async (tx) => {
@@ -4220,7 +4316,7 @@ async function startDaemon() {
4220
4316
  }
4221
4317
  async function setAgentStatus(shipId, agentId, status) {
4222
4318
  try {
4223
- await updateDoc2(doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents, agentId), { status });
4319
+ await updateDoc2(doc8(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents, agentId), { status });
4224
4320
  } catch (e) {
4225
4321
  log2(`agent status update failed: ${e instanceof Error ? e.message : e}`);
4226
4322
  }
@@ -4267,8 +4363,8 @@ async function startDaemon() {
4267
4363
  void (async () => {
4268
4364
  if (slot.stop) return;
4269
4365
  try {
4270
- const snap = await getDoc6(
4271
- doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
4366
+ const snap = await getDoc7(
4367
+ doc8(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
4272
4368
  );
4273
4369
  const fresh = snap.data();
4274
4370
  if (!fresh?.stopRequestedAt || slot.stop) return;
@@ -4308,7 +4404,7 @@ async function startDaemon() {
4308
4404
  let statuses = DEFAULT_TASK_STATUSES;
4309
4405
  let knownSecrets = [];
4310
4406
  const progress = createProgressWriter({
4311
- write: (p) => updateDoc2(doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
4407
+ write: (p) => updateDoc2(doc8(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
4312
4408
  progress: p
4313
4409
  }),
4314
4410
  secrets: () => knownSecrets,
@@ -4322,8 +4418,8 @@ async function startDaemon() {
4322
4418
  onDenied: () => {
4323
4419
  void (async () => {
4324
4420
  try {
4325
- const snap = await getDoc6(
4326
- doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
4421
+ const snap = await getDoc7(
4422
+ doc8(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
4327
4423
  );
4328
4424
  const fresh = snap.data();
4329
4425
  if (!fresh || fresh.status !== "running" || fresh.runnerId !== sess(shipId).runnerId) {
@@ -4338,7 +4434,7 @@ async function startDaemon() {
4338
4434
  });
4339
4435
  progress.push({ kind: "start", label: "Starting up" });
4340
4436
  try {
4341
- const secrets = await loadSecrets(shipId);
4437
+ let secrets = await loadSecrets(shipId);
4342
4438
  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
4439
  if (packed.kind === "task" && !isWorkflowSentinel(job.workflowId) && !packed.ctx.workflow) {
4344
4440
  log2(
@@ -4349,6 +4445,17 @@ async function startDaemon() {
4349
4445
  engineId = agentEngine(agent);
4350
4446
  usage = { ...usage, engine: engineId };
4351
4447
  statuses = shipTaskStatuses(ship2);
4448
+ const resolvedSecrets = await resolveJobSecrets({
4449
+ db: sess(shipId).fb.db,
4450
+ shipId,
4451
+ agent,
4452
+ runnerSecrets: secrets,
4453
+ credentials: await loadShipCredentials(sess(shipId).fb.db, shipId).catch(() => [])
4454
+ });
4455
+ if (resolvedSecrets.problem) {
4456
+ throw new Error(resolvedSecrets.problem);
4457
+ }
4458
+ secrets = resolvedSecrets.secrets;
4352
4459
  const missing = missingSecretsFor(engineId, secrets);
4353
4460
  if (missing.length > 0 && !process.env.CREW_CLAUDE_BIN) {
4354
4461
  throw new Error(
@@ -5147,7 +5254,7 @@ function setParallel(config2, value, ship2) {
5147
5254
  import { spawnSync as spawnSync3 } from "node:child_process";
5148
5255
  import fs7 from "node:fs";
5149
5256
  import path8 from "node:path";
5150
- import { collection as collection7, doc as doc8, getDoc as getDoc7, getDocs as getDocs6 } from "firebase/firestore";
5257
+ import { collection as collection8, doc as doc9, getDoc as getDoc8, getDocs as getDocs7 } from "firebase/firestore";
5151
5258
 
5152
5259
  // src/cli/session.ts
5153
5260
  async function openShipSession(shipId) {
@@ -5316,8 +5423,8 @@ async function checkShips(config2) {
5316
5423
  continue;
5317
5424
  }
5318
5425
  try {
5319
- const snap = await getDoc7(
5320
- doc8(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId)
5426
+ const snap = await getDoc8(
5427
+ doc9(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, session.runnerId)
5321
5428
  );
5322
5429
  if (!snap.exists()) {
5323
5430
  checks.push(
@@ -5358,7 +5465,7 @@ async function checkShips(config2) {
5358
5465
  }
5359
5466
  let agents = [];
5360
5467
  try {
5361
- const snap = await getDocs6(collection7(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
5468
+ const snap = await getDocs7(collection8(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
5362
5469
  agents = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
5363
5470
  } catch {
5364
5471
  }
@@ -5368,13 +5475,33 @@ async function checkShips(config2) {
5368
5475
  if (agents.some((agent) => effectiveAgentTools(agent).github.enabled)) needsGithub = true;
5369
5476
  try {
5370
5477
  const secrets = await loadRunnerSecrets(session.fb.db, shipId);
5371
- const missing = [...new Set([...shipEngines].flatMap((id) => missingSecretsFor(id, secrets)))];
5478
+ const credentials = await loadShipCredentials(session.fb.db, shipId).catch(() => []);
5479
+ const missing = [
5480
+ ...new Set(
5481
+ [...shipEngines].flatMap((id) => {
5482
+ if (credentials.some((c) => c.engine === id)) return [];
5483
+ return missingSecretsFor(id, secrets);
5484
+ })
5485
+ )
5486
+ ];
5487
+ const orphaned = agents.filter(
5488
+ (agent) => resolveAgentCredential(agent, credentials, DEFAULT_ENGINE_ID) === null
5489
+ );
5372
5490
  checks.push(
5373
- missing.length === 0 ? ok(`secrets:${shipId}`, `Ship ${shipId} \u2014 credentials`, "All required secrets are saved.") : fail(
5491
+ missing.length > 0 ? fail(
5374
5492
  `secrets:${shipId}`,
5375
5493
  `Ship ${shipId} \u2014 credentials`,
5376
5494
  `Missing: ${missing.join(", ")}.`,
5377
- "A captain saves these in Ship Settings \u2192 Runner credentials."
5495
+ "A captain saves these in Ship Settings \u2192 AI credentials."
5496
+ ) : orphaned.length > 0 ? fail(
5497
+ `secrets:${shipId}`,
5498
+ `Ship ${shipId} \u2014 credentials`,
5499
+ `${orphaned.length} agent(s) are set to a credential that no longer exists: ${orphaned.map((a) => a.name).join(", ")}.`,
5500
+ "Point them at another one in Ship Settings \u2192 AI credentials."
5501
+ ) : ok(
5502
+ `secrets:${shipId}`,
5503
+ `Ship ${shipId} \u2014 credentials`,
5504
+ "All required secrets are saved."
5378
5505
  )
5379
5506
  );
5380
5507
  } catch (e) {
@@ -5388,8 +5515,8 @@ async function checkShips(config2) {
5388
5515
  );
5389
5516
  }
5390
5517
  try {
5391
- const snap = await getDocs6(
5392
- collection7(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
5518
+ const snap = await getDocs7(
5519
+ collection8(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
5393
5520
  );
5394
5521
  const servers = snap.docs.map(
5395
5522
  (d) => ({ id: d.id, ...d.data() })
@@ -5838,7 +5965,7 @@ async function runUninstall(options) {
5838
5965
  }
5839
5966
 
5840
5967
  // src/cli/commands/ship.ts
5841
- import { doc as doc9, getDoc as getDoc8 } from "firebase/firestore";
5968
+ import { doc as doc10, getDoc as getDoc9 } from "firebase/firestore";
5842
5969
  async function listMyShips() {
5843
5970
  const config2 = requireConfig();
5844
5971
  const ids = Object.keys(config2.shipKeys ?? {});
@@ -5846,7 +5973,7 @@ async function listMyShips() {
5846
5973
  ids.map(async (id) => {
5847
5974
  try {
5848
5975
  const { fb } = await openShipSession(id);
5849
- const snap = await getDoc8(doc9(fb.db, COLLECTIONS.ships, id));
5976
+ const snap = await getDoc9(doc10(fb.db, COLLECTIONS.ships, id));
5850
5977
  return { id, ...snap.data() };
5851
5978
  } catch {
5852
5979
  return null;
@@ -6006,11 +6133,11 @@ async function runSetup(options) {
6006
6133
 
6007
6134
  // src/cli/commands/status.ts
6008
6135
  import {
6009
- collection as collection8,
6010
- doc as doc10,
6136
+ collection as collection9,
6137
+ doc as doc11,
6011
6138
  getCountFromServer as getCountFromServer2,
6012
- getDoc as getDoc9,
6013
- getDocs as getDocs7,
6139
+ getDoc as getDoc10,
6140
+ getDocs as getDocs8,
6014
6141
  query as query5,
6015
6142
  where as where5
6016
6143
  } from "firebase/firestore";
@@ -6047,22 +6174,22 @@ async function runStatus() {
6047
6174
  });
6048
6175
  continue;
6049
6176
  }
6050
- const shipRef = doc10(session.fb.db, COLLECTIONS.ships, shipId);
6051
- const mirrorSnap = await getDoc9(doc10(shipRef, COLLECTIONS.runners, session.runnerId));
6177
+ const shipRef = doc11(session.fb.db, COLLECTIONS.ships, shipId);
6178
+ const mirrorSnap = await getDoc10(doc11(shipRef, COLLECTIONS.runners, session.runnerId));
6052
6179
  const mirror = mirrorSnap.data();
6053
6180
  let queued = 0;
6054
6181
  try {
6055
6182
  const counted = await getCountFromServer2(
6056
- query5(collection8(shipRef, COLLECTIONS.jobs), where5("status", "==", "queued"))
6183
+ query5(collection9(shipRef, COLLECTIONS.jobs), where5("status", "==", "queued"))
6057
6184
  );
6058
6185
  queued = counted.data().count;
6059
6186
  } catch {
6060
6187
  queued = 0;
6061
6188
  }
6062
- const usageSnap = await getDoc9(doc10(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
6189
+ const usageSnap = await getDoc10(doc11(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
6063
6190
  const today = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
6064
6191
  const now = Date.now();
6065
- const limitsSnap = await getDocs7(collection8(shipRef, COLLECTIONS.engineLimits));
6192
+ const limitsSnap = await getDocs8(collection9(shipRef, COLLECTIONS.engineLimits));
6066
6193
  const engineLimits = limitsSnap.docs.map((d) => ({ id: d.id, ...d.data() })).filter((l) => isEngineLimited(l, now));
6067
6194
  ships.push({
6068
6195
  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.14",
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.",