@lumi.ai/runner 0.5.3 → 0.5.5

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 +145 -24
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -10,14 +10,14 @@ import {
10
10
  deleteField,
11
11
  doc as doc7,
12
12
  getDoc as getDoc5,
13
- getDocs as getDocs3,
13
+ getDocs as getDocs4,
14
14
  onSnapshot as onSnapshot2,
15
- orderBy as orderBy3,
16
- query as query3,
15
+ orderBy as orderBy4,
16
+ query as query4,
17
17
  runTransaction as runTransaction2,
18
18
  setDoc as setDoc2,
19
19
  updateDoc as updateDoc2,
20
- where as where3
20
+ where as where4
21
21
  } from "firebase/firestore";
22
22
 
23
23
  // ../shared/dist/engines/claude.js
@@ -117,6 +117,7 @@ function effectiveAgentTools(agent) {
117
117
  }
118
118
 
119
119
  // ../shared/dist/chat.js
120
+ var MAX_CHAT_MESSAGE_CHARS = 8e3;
120
121
  var MAX_CHAT_MESSAGES_IN_PROMPT = 40;
121
122
 
122
123
  // ../shared/dist/collections.js
@@ -474,6 +475,11 @@ var DEFAULT_SHIP_SETTINGS = {
474
475
  };
475
476
  var SHIP_INVITE_TTL_MS = 14 * 24 * 60 * 60 * 1e3;
476
477
 
478
+ // ../shared/dist/taskRelation.js
479
+ function taskBlockedBy(task) {
480
+ return Array.isArray(task?.blockedBy) ? task.blockedBy : [];
481
+ }
482
+
477
483
  // ../shared/dist/usage.js
478
484
  var EMPTY_USAGE_TOTALS = {
479
485
  inputTokens: 0,
@@ -575,7 +581,7 @@ function mcpUrl(config2) {
575
581
  }
576
582
 
577
583
  // src/version.ts
578
- var RUNNER_VERSION = true ? "0.5.3" : "0.0.0-dev";
584
+ var RUNNER_VERSION = true ? "0.5.5" : "0.0.0-dev";
579
585
 
580
586
  // src/auth.ts
581
587
  import { signInWithCustomToken } from "firebase/auth";
@@ -958,6 +964,38 @@ async function withFirestoreRetry(read, sleep2 = (ms) => new Promise((r) => setT
958
964
  // src/jobs/contextPack.ts
959
965
  var MAX_ACTIVITY_IN_PROMPT = 40;
960
966
  var MAX_PREVIOUS_JOBS_READ = 5;
967
+ var MAX_BLOCKERS_IN_PROMPT = 20;
968
+ async function loadSettledBlockers(shipRef, task) {
969
+ const ids = taskBlockedBy(task).slice(0, MAX_BLOCKERS_IN_PROMPT);
970
+ if (ids.length === 0) return [];
971
+ try {
972
+ return await Promise.all(
973
+ ids.map(async (id) => {
974
+ const ref = doc(shipRef, COLLECTIONS.tasks, id);
975
+ const [snap, resultSnap] = await Promise.all([
976
+ getDoc(ref),
977
+ getDocs(
978
+ query(
979
+ collection(ref, COLLECTIONS.activity),
980
+ where("kind", "==", "result"),
981
+ orderBy("createdAt", "desc"),
982
+ limit(1)
983
+ )
984
+ ).catch(() => null)
985
+ ]);
986
+ const data = snap.data();
987
+ return {
988
+ id,
989
+ title: data?.title ?? id,
990
+ status: data?.status ?? "unknown",
991
+ result: resultSnap?.docs[0]?.data()?.content ?? null
992
+ };
993
+ })
994
+ );
995
+ } catch {
996
+ return [];
997
+ }
998
+ }
961
999
  async function loadJobContext(db, shipId, job) {
962
1000
  const shipRef = doc(db, COLLECTIONS.ships, shipId);
963
1001
  const taskRef = doc(shipRef, COLLECTIONS.tasks, job.taskId);
@@ -1025,11 +1063,13 @@ async function loadJobContext(db, shipId, job) {
1025
1063
  }
1026
1064
  const activityDocs = activitySnap.docs.slice(0, MAX_ACTIVITY_IN_PROMPT);
1027
1065
  const agent = { id: agentSnap.id, ...agentSnap.data() };
1066
+ const settledBlockers = job.reason === "unblocked" ? await loadSettledBlockers(shipRef, task) : [];
1028
1067
  return {
1029
1068
  ship: { id: shipSnap.id, ...shipSnap.data() },
1030
1069
  agent,
1031
1070
  task,
1032
1071
  parentTask,
1072
+ settledBlockers,
1033
1073
  workflow,
1034
1074
  memory: readMemory(agent.memory),
1035
1075
  knowledgeIndex: indexSnap?.exists() ? indexSnap.data() : null,
@@ -1113,6 +1153,10 @@ function buildPrompt(ctx, reason, mcpServers = []) {
1113
1153
  parts.push(
1114
1154
  "# Why this session started\n\nYou asked a captain for permission and stopped. They have now answered \u2014 their decision is the newest entry in the task activity below. Read it first.\n\n**If they approved it, do that thing now** \u2014 the permission is granted for this task and may be single-use, so do not ask again for the same thing. **If they refused, do not retry and do not look for a way around it**: say what you will do instead, or hand the task back with task_assign."
1115
1155
  );
1156
+ } else if (reason === "unblocked") {
1157
+ parts.push(
1158
+ '# Why this session started\n\nThis task was blocked and is not any more: everything it was waiting on is now done. Nobody has just assigned it to you \u2014 you have had it all along, and the work has become startable.\n\n**Read "What you were waiting on" below before you do anything else.** It carries what those tasks produced, which is the input this work was held up for; starting without it means redoing or contradicting somebody else' + (playbook ? ", then follow your playbook below." : "'s work.")
1159
+ );
1116
1160
  } else if (reason === "schedule") {
1117
1161
  parts.push(
1118
1162
  "# Why this session started\n\nThis is a scheduled run: one of your own playbooks created this task on its cron and assigned it to you. It is routine work, not a request from a person, so nobody is waiting on a reply" + (playbook ? " \u2014 the playbook below is the work, and the task's description is only a record of why it exists." : ".")
@@ -1158,6 +1202,17 @@ ${t.description || "(no description)"}
1158
1202
 
1159
1203
  Labels: ${t.labels.join(", ") || "none"} \xB7 Status: ${statusLabel} (\`${t.status}\`)` + dates
1160
1204
  );
1205
+ if (ctx.settledBlockers.length > 0) {
1206
+ const blockers = ctx.settledBlockers.map((b) => {
1207
+ const label = statusById(statuses, b.status)?.label ?? b.status;
1208
+ return `## ${b.title} (id: ${b.id}) \u2014 ${label}
1209
+
1210
+ ` + (b.result ? b.result : "It left no result note. Open it with task_get if you need to know what it did.");
1211
+ }).join("\n\n");
1212
+ parts.push(`# What you were waiting on
1213
+
1214
+ ${blockers}`);
1215
+ }
1161
1216
  if (ctx.parentTask) {
1162
1217
  const p = ctx.parentTask;
1163
1218
  const parentStatus = statusById(statuses, p.status)?.label ?? p.status;
@@ -1810,8 +1865,13 @@ import {
1810
1865
  addDoc,
1811
1866
  collection as collection4,
1812
1867
  doc as doc6,
1868
+ getDocs as getDocs3,
1869
+ limit as fsLimit,
1870
+ orderBy as orderBy3,
1871
+ query as query3,
1813
1872
  runTransaction,
1814
- updateDoc
1873
+ updateDoc,
1874
+ where as where3
1815
1875
  } from "firebase/firestore";
1816
1876
  import { ref as storageRef, uploadBytes } from "firebase/storage";
1817
1877
  function redactTranscript(transcript, knownSecrets) {
@@ -1937,6 +1997,41 @@ Write again to start a fresh run.`;
1937
1997
  createdAt: now
1938
1998
  });
1939
1999
  }
2000
+ var REPLY_SCAN_LIMIT = 20;
2001
+ function backstopReplyContent(resultText) {
2002
+ const text = resultText.trim();
2003
+ if (!text) {
2004
+ return "I finished that run without writing a reply. Write again to start a fresh one.";
2005
+ }
2006
+ if (text.length <= MAX_CHAT_MESSAGE_CHARS) return text;
2007
+ const marker = "\n\n\u2026(truncated)";
2008
+ return `${text.slice(0, MAX_CHAT_MESSAGE_CHARS - marker.length)}${marker}`;
2009
+ }
2010
+ async function ensureChatReply(db, shipId, job, resultText) {
2011
+ const chatRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
2012
+ const messagesCol = collection4(chatRef, COLLECTIONS.chatMessages);
2013
+ const snap = await getDocs3(
2014
+ query3(
2015
+ messagesCol,
2016
+ where3("createdAt", ">=", job.startedAt || 0),
2017
+ orderBy3("createdAt", "desc"),
2018
+ fsLimit(REPLY_SCAN_LIMIT)
2019
+ )
2020
+ );
2021
+ const replied = snap.docs.some((d) => {
2022
+ const author = d.data().author;
2023
+ return author?.type === "agent" && author.id === job.agentId;
2024
+ });
2025
+ if (replied) return "agent-replied";
2026
+ const content = backstopReplyContent(resultText);
2027
+ await addDoc(messagesCol, {
2028
+ author: { type: "agent", id: job.agentId },
2029
+ content,
2030
+ chars: content.length,
2031
+ createdAt: Date.now()
2032
+ });
2033
+ return resultText.trim() ? "posted-final-text" : "posted-silence-note";
2034
+ }
1940
2035
 
1941
2036
  // src/daemon.ts
1942
2037
  var HEARTBEAT_MS = 3e4;
@@ -2056,11 +2151,11 @@ async function startDaemon() {
2056
2151
  needsRefill.delete(shipId);
2057
2152
  if (!serving.has(shipId) || approved.get(shipId) !== true) continue;
2058
2153
  try {
2059
- const snap = await getDocs3(
2060
- query3(
2154
+ const snap = await getDocs4(
2155
+ query4(
2061
2156
  collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
2062
- where3("status", "==", "queued"),
2063
- orderBy3("createdAt", "asc")
2157
+ where4("status", "==", "queued"),
2158
+ orderBy4("createdAt", "asc")
2064
2159
  )
2065
2160
  );
2066
2161
  for (const d of snap.docs) {
@@ -2088,10 +2183,10 @@ async function startDaemon() {
2088
2183
  console.error(`${what} listener error (${shipId}):`, e.message);
2089
2184
  };
2090
2185
  for (const shipId of serving) {
2091
- const q = query3(
2186
+ const q = query4(
2092
2187
  collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
2093
- where3("status", "==", "queued"),
2094
- orderBy3("createdAt", "asc")
2188
+ where4("status", "==", "queued"),
2189
+ orderBy4("createdAt", "asc")
2095
2190
  );
2096
2191
  unsubsByShip.set(shipId, [
2097
2192
  onSnapshot2(
@@ -2342,6 +2437,7 @@ async function startDaemon() {
2342
2437
  let sessionLimit = null;
2343
2438
  let engineId = DEFAULT_ENGINE_ID;
2344
2439
  let transcript = "";
2440
+ let resultText = "";
2345
2441
  let usage = {
2346
2442
  engine: DEFAULT_ENGINE_ID,
2347
2443
  inputTokens: 0,
@@ -2442,6 +2538,7 @@ async function startDaemon() {
2442
2538
  });
2443
2539
  armLimitTimer();
2444
2540
  }
2541
+ resultText = session.resultText;
2445
2542
  if (!session.ok) failure = session.resultText || "Session failed.";
2446
2543
  } catch (e) {
2447
2544
  failure = e instanceof Error ? e.message : String(e);
@@ -2487,9 +2584,33 @@ async function startDaemon() {
2487
2584
  mcpServers: extraMcpServers.map((s) => s.key)
2488
2585
  });
2489
2586
  log2(`Job ${job.id} done (${usage.inputTokens}in/${usage.outputTokens}out tokens).`);
2587
+ let delivery = "agent-replied";
2588
+ if (target.kind === "chat") {
2589
+ try {
2590
+ delivery = await ensureChatReply(
2591
+ sess(shipId).fb.db,
2592
+ shipId,
2593
+ { ...job, chatId: target.chatId },
2594
+ resultText
2595
+ );
2596
+ if (delivery === "posted-final-text") {
2597
+ log2(
2598
+ `Job ${job.id}: the agent never called chat_send \u2014 posted its final text to chat ${target.chatId} instead.`
2599
+ );
2600
+ } else if (delivery === "posted-silence-note") {
2601
+ log2(
2602
+ `Job ${job.id}: the agent neither called chat_send nor produced any text \u2014 posted a note to chat ${target.chatId} so the thread is not silent.`
2603
+ );
2604
+ }
2605
+ } catch (e) {
2606
+ log2(
2607
+ `Job ${job.id}: could not check whether the agent replied in chat ${target.chatId} \u2014 ${e instanceof Error ? e.message : e}`
2608
+ );
2609
+ }
2610
+ }
2490
2611
  notify(
2491
2612
  "Crew job finished",
2492
- target.kind === "task" ? `Task ${target.taskId} is done.` : "Agent replied in a chat."
2613
+ target.kind === "task" ? `Task ${target.taskId} is done.` : delivery === "agent-replied" ? "Agent replied in a chat." : "Agent finished a chat run without replying \u2014 its answer was posted for it."
2493
2614
  );
2494
2615
  } else if (transient) {
2495
2616
  await releaseJob(
@@ -2830,7 +2951,7 @@ function setParallel(config2, value, ship2) {
2830
2951
 
2831
2952
  // src/cli/commands/doctor.ts
2832
2953
  import { spawnSync as spawnSync2 } from "node:child_process";
2833
- import { collection as collection6, doc as doc8, getDoc as getDoc6, getDocs as getDocs4 } from "firebase/firestore";
2954
+ import { collection as collection6, doc as doc8, getDoc as getDoc6, getDocs as getDocs5 } from "firebase/firestore";
2834
2955
 
2835
2956
  // src/service.ts
2836
2957
  import { spawnSync } from "node:child_process";
@@ -3045,10 +3166,10 @@ function serviceStatus() {
3045
3166
  };
3046
3167
  }
3047
3168
  if (process.platform === "win32") {
3048
- const query5 = run("schtasks", ["/Query", "/TN", WINDOWS_TASK]);
3049
- if (!query5.ok) return { state: "not-installed", detail: "No scheduled task installed." };
3169
+ const query6 = run("schtasks", ["/Query", "/TN", WINDOWS_TASK]);
3170
+ if (!query6.ok) return { state: "not-installed", detail: "No scheduled task installed." };
3050
3171
  return {
3051
- state: /\bRunning\b/i.test(query5.out) ? "running" : "installed",
3172
+ state: /\bRunning\b/i.test(query6.out) ? "running" : "installed",
3052
3173
  detail: "Scheduled task installed (runs at logon).",
3053
3174
  unitPath: WINDOWS_TASK
3054
3175
  };
@@ -3285,7 +3406,7 @@ async function checkShips(config2) {
3285
3406
  }
3286
3407
  let agents = [];
3287
3408
  try {
3288
- const snap = await getDocs4(collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
3409
+ const snap = await getDocs5(collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
3289
3410
  agents = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
3290
3411
  } catch {
3291
3412
  }
@@ -3810,9 +3931,9 @@ import {
3810
3931
  doc as doc10,
3811
3932
  getCountFromServer as getCountFromServer2,
3812
3933
  getDoc as getDoc8,
3813
- getDocs as getDocs5,
3814
- query as query4,
3815
- where as where4
3934
+ getDocs as getDocs6,
3935
+ query as query5,
3936
+ where as where5
3816
3937
  } from "firebase/firestore";
3817
3938
  async function runStatus() {
3818
3939
  const config2 = loadConfig();
@@ -3852,7 +3973,7 @@ async function runStatus() {
3852
3973
  let queued = 0;
3853
3974
  try {
3854
3975
  const counted = await getCountFromServer2(
3855
- query4(collection7(shipRef, COLLECTIONS.jobs), where4("status", "==", "queued"))
3976
+ query5(collection7(shipRef, COLLECTIONS.jobs), where5("status", "==", "queued"))
3856
3977
  );
3857
3978
  queued = counted.data().count;
3858
3979
  } catch {
@@ -3861,7 +3982,7 @@ async function runStatus() {
3861
3982
  const usageSnap = await getDoc8(doc10(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
3862
3983
  const today = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
3863
3984
  const now = Date.now();
3864
- const limitsSnap = await getDocs5(collection7(shipRef, COLLECTIONS.engineLimits));
3985
+ const limitsSnap = await getDocs6(collection7(shipRef, COLLECTIONS.engineLimits));
3865
3986
  const engineLimits = limitsSnap.docs.map((d) => ({ id: d.id, ...d.data() })).filter((l) => isEngineLimited(l, now));
3866
3987
  ships.push({
3867
3988
  shipId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumi.ai/runner",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
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.",