@lumi.ai/runner 0.5.4 → 0.5.6
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.
- package/dist/cli.js +490 -39
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -7,17 +7,17 @@ import { Command } from "commander";
|
|
|
7
7
|
import os3 from "node:os";
|
|
8
8
|
import {
|
|
9
9
|
collection as collection5,
|
|
10
|
-
deleteField,
|
|
10
|
+
deleteField as deleteField2,
|
|
11
11
|
doc as doc7,
|
|
12
12
|
getDoc as getDoc5,
|
|
13
|
-
getDocs as
|
|
13
|
+
getDocs as getDocs4,
|
|
14
14
|
onSnapshot as onSnapshot2,
|
|
15
|
-
orderBy as
|
|
16
|
-
query as
|
|
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
|
|
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
|
|
@@ -230,6 +231,164 @@ function jobTarget(job) {
|
|
|
230
231
|
return null;
|
|
231
232
|
}
|
|
232
233
|
|
|
234
|
+
// ../shared/dist/jobProgress.js
|
|
235
|
+
var MAX_JOB_STEPS = 10;
|
|
236
|
+
var MAX_STEP_LABEL = 80;
|
|
237
|
+
var MAX_STEP_DETAIL = 120;
|
|
238
|
+
function clampStepText(text, max) {
|
|
239
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
240
|
+
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}\u2026`;
|
|
241
|
+
}
|
|
242
|
+
function firstLine(text) {
|
|
243
|
+
return text.split("\n").find((l) => l.trim()) ?? "";
|
|
244
|
+
}
|
|
245
|
+
function str(input, key) {
|
|
246
|
+
if (!input || typeof input !== "object")
|
|
247
|
+
return void 0;
|
|
248
|
+
const value = input[key];
|
|
249
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
250
|
+
}
|
|
251
|
+
function basename(path5) {
|
|
252
|
+
const parts = path5.split(/[\\/]/).filter(Boolean);
|
|
253
|
+
return parts[parts.length - 1] ?? path5;
|
|
254
|
+
}
|
|
255
|
+
function hostOf(url) {
|
|
256
|
+
try {
|
|
257
|
+
return new URL(url).hostname;
|
|
258
|
+
} catch {
|
|
259
|
+
return "";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
var WORKSPACE_PREFIX = "mcp__workspace__";
|
|
263
|
+
var MCP_PREFIX = "mcp__";
|
|
264
|
+
var WORKSPACE_LABELS = {
|
|
265
|
+
ship_info: "Checking the Ship",
|
|
266
|
+
agent_list: "Looking up the crew",
|
|
267
|
+
task_list: "Looking through the board",
|
|
268
|
+
task_get: "Reading a task",
|
|
269
|
+
task_create: "Creating a task",
|
|
270
|
+
task_update_status: "Moving a task",
|
|
271
|
+
task_assign: "Assigning a task",
|
|
272
|
+
task_relate: "Linking two tasks",
|
|
273
|
+
task_comment: "Commenting on a task",
|
|
274
|
+
approval_request: "Asking for approval",
|
|
275
|
+
approval_decide: "Answering an approval",
|
|
276
|
+
run_report: "Writing its run report",
|
|
277
|
+
knowledge_get: "Reading the knowledge base",
|
|
278
|
+
knowledge_write: "Writing to the knowledge base",
|
|
279
|
+
media_get: "Opening an attachment",
|
|
280
|
+
media_attach: "Attaching a file",
|
|
281
|
+
memory_write: "Updating its notes",
|
|
282
|
+
chat_get: "Reading the conversation",
|
|
283
|
+
chat_send: "Writing its reply"
|
|
284
|
+
};
|
|
285
|
+
var BUILTIN_LABELS = {
|
|
286
|
+
Bash: "Running a command",
|
|
287
|
+
Read: "Reading a file",
|
|
288
|
+
Write: "Writing a file",
|
|
289
|
+
Edit: "Editing a file",
|
|
290
|
+
MultiEdit: "Editing a file",
|
|
291
|
+
Glob: "Looking for files",
|
|
292
|
+
Grep: "Searching the code",
|
|
293
|
+
WebSearch: "Searching the web",
|
|
294
|
+
WebFetch: "Reading a web page",
|
|
295
|
+
TodoWrite: "Planning its next steps",
|
|
296
|
+
Task: "Delegating to a sub-agent"
|
|
297
|
+
};
|
|
298
|
+
function workspaceDetail(tool, input) {
|
|
299
|
+
switch (tool) {
|
|
300
|
+
case "task_list":
|
|
301
|
+
return str(input, "status") ?? str(input, "query");
|
|
302
|
+
case "task_get":
|
|
303
|
+
return str(input, "taskId");
|
|
304
|
+
case "task_create":
|
|
305
|
+
return str(input, "title");
|
|
306
|
+
case "task_update_status":
|
|
307
|
+
return str(input, "status");
|
|
308
|
+
case "task_relate":
|
|
309
|
+
return str(input, "blockedBy") ? "blocked by" : str(input, "waitingOn") ? "waiting on" : void 0;
|
|
310
|
+
case "task_comment": {
|
|
311
|
+
const content = str(input, "content");
|
|
312
|
+
return content ? firstLine(content) : void 0;
|
|
313
|
+
}
|
|
314
|
+
case "approval_request":
|
|
315
|
+
return str(input, "action") ?? str(input, "summary");
|
|
316
|
+
case "knowledge_get":
|
|
317
|
+
return str(input, "slug") ?? str(input, "find") ?? "the catalog";
|
|
318
|
+
case "knowledge_write":
|
|
319
|
+
return str(input, "slug");
|
|
320
|
+
case "media_get":
|
|
321
|
+
return str(input, "mediaId");
|
|
322
|
+
case "media_attach":
|
|
323
|
+
return str(input, "name");
|
|
324
|
+
default:
|
|
325
|
+
return void 0;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function builtinDetail(tool, input) {
|
|
329
|
+
switch (tool) {
|
|
330
|
+
case "Bash":
|
|
331
|
+
return str(input, "command");
|
|
332
|
+
case "Read":
|
|
333
|
+
case "Write":
|
|
334
|
+
case "Edit":
|
|
335
|
+
case "MultiEdit": {
|
|
336
|
+
const path5 = str(input, "file_path");
|
|
337
|
+
return path5 ? basename(path5) : void 0;
|
|
338
|
+
}
|
|
339
|
+
case "Glob":
|
|
340
|
+
case "Grep":
|
|
341
|
+
return str(input, "pattern");
|
|
342
|
+
case "WebSearch":
|
|
343
|
+
return str(input, "query");
|
|
344
|
+
case "WebFetch": {
|
|
345
|
+
const url = str(input, "url");
|
|
346
|
+
return url ? hostOf(url) || void 0 : void 0;
|
|
347
|
+
}
|
|
348
|
+
case "Task":
|
|
349
|
+
return str(input, "description");
|
|
350
|
+
default:
|
|
351
|
+
return void 0;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function describeToolUse(name, input, opts) {
|
|
355
|
+
if (name.startsWith(WORKSPACE_PREFIX)) {
|
|
356
|
+
const tool = name.slice(WORKSPACE_PREFIX.length);
|
|
357
|
+
return {
|
|
358
|
+
kind: "tool",
|
|
359
|
+
label: WORKSPACE_LABELS[tool] ?? "Working",
|
|
360
|
+
detail: workspaceDetail(tool, input)
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
if (name.startsWith(MCP_PREFIX)) {
|
|
364
|
+
const rest = name.slice(MCP_PREFIX.length);
|
|
365
|
+
const split = rest.indexOf("__");
|
|
366
|
+
const key = split < 0 ? rest : rest.slice(0, split);
|
|
367
|
+
const tool = split < 0 ? void 0 : rest.slice(split + 2);
|
|
368
|
+
return {
|
|
369
|
+
kind: "tool",
|
|
370
|
+
label: `Using ${opts?.mcpNames?.[key] ?? key}`,
|
|
371
|
+
detail: tool || void 0
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const label = BUILTIN_LABELS[name];
|
|
375
|
+
if (label)
|
|
376
|
+
return { kind: "tool", label, detail: builtinDetail(name, input) };
|
|
377
|
+
return { kind: "tool", label: "Working" };
|
|
378
|
+
}
|
|
379
|
+
function describeAssistantText(text) {
|
|
380
|
+
return { kind: "saying", label: "Working it out", detail: firstLine(text) || void 0 };
|
|
381
|
+
}
|
|
382
|
+
function pushJobStep(progress, step, max = MAX_JOB_STEPS) {
|
|
383
|
+
const steps = [...progress?.steps ?? [], step];
|
|
384
|
+
return {
|
|
385
|
+
seq: (progress?.seq ?? 0) + 1,
|
|
386
|
+
updatedAt: step.at,
|
|
387
|
+
// Keep the NEWEST `max`, oldest first — a reader renders top to bottom.
|
|
388
|
+
steps: steps.slice(Math.max(0, steps.length - max))
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
233
392
|
// ../shared/dist/knowledge.js
|
|
234
393
|
var KNOWLEDGE_CATALOG_MAX_CHARS = 1500;
|
|
235
394
|
var KNOWLEDGE_CATALOG_SUMMARIES = 8;
|
|
@@ -580,7 +739,7 @@ function mcpUrl(config2) {
|
|
|
580
739
|
}
|
|
581
740
|
|
|
582
741
|
// src/version.ts
|
|
583
|
-
var RUNNER_VERSION = true ? "0.5.
|
|
742
|
+
var RUNNER_VERSION = true ? "0.5.6" : "0.0.0-dev";
|
|
584
743
|
|
|
585
744
|
// src/auth.ts
|
|
586
745
|
import { signInWithCustomToken } from "firebase/auth";
|
|
@@ -1415,6 +1574,31 @@ import { spawn as spawn3 } from "node:child_process";
|
|
|
1415
1574
|
import fs3 from "node:fs";
|
|
1416
1575
|
import os2 from "node:os";
|
|
1417
1576
|
import path3 from "node:path";
|
|
1577
|
+
|
|
1578
|
+
// src/engines/claudeEvents.ts
|
|
1579
|
+
function stepsFromClaudeEvent(event, opts) {
|
|
1580
|
+
const type = typeof event?.type === "string" ? event.type : "";
|
|
1581
|
+
if (type === "assistant") {
|
|
1582
|
+
const message = event.message;
|
|
1583
|
+
const content = Array.isArray(message?.content) ? message.content : [];
|
|
1584
|
+
const steps = [];
|
|
1585
|
+
for (const block of content) {
|
|
1586
|
+
if (!block || typeof block !== "object") continue;
|
|
1587
|
+
if (block.type === "tool_use" && typeof block.name === "string") {
|
|
1588
|
+
steps.push(describeToolUse(block.name, block.input, { mcpNames: opts?.mcpNames }));
|
|
1589
|
+
} else if (block.type === "thinking" || block.type === "redacted_thinking") {
|
|
1590
|
+
steps.push({ kind: "thinking", label: "Thinking" });
|
|
1591
|
+
} else if (block.type === "text" && typeof block.text === "string" && block.text.trim()) {
|
|
1592
|
+
steps.push(describeAssistantText(block.text));
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
return steps;
|
|
1596
|
+
}
|
|
1597
|
+
if (type === "result") return [{ kind: "done", label: "Finished" }];
|
|
1598
|
+
return [];
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// src/engines/claude.ts
|
|
1418
1602
|
function normalizeClaudeUsage(resultEvent, model, fallbackDurationS) {
|
|
1419
1603
|
const result = resultEvent ?? {};
|
|
1420
1604
|
const u = result.usage ?? {};
|
|
@@ -1558,6 +1742,8 @@ async function runSession(input, bin, dirs) {
|
|
|
1558
1742
|
GIT_CONFIG_VALUE_0: "https://github.com/"
|
|
1559
1743
|
} : {}
|
|
1560
1744
|
};
|
|
1745
|
+
const mcpNames = {};
|
|
1746
|
+
for (const s of input.extraMcpServers ?? []) mcpNames[s.key] = s.name || s.key;
|
|
1561
1747
|
const startedAt = Date.now();
|
|
1562
1748
|
const lines = [];
|
|
1563
1749
|
const stderrLines = [];
|
|
@@ -1590,6 +1776,9 @@ async function runSession(input, bin, dirs) {
|
|
|
1590
1776
|
const event = JSON.parse(line);
|
|
1591
1777
|
if (event.type === "result") resultEvent = event;
|
|
1592
1778
|
if (event.type === "assistant") input.log("claude: assistant turn");
|
|
1779
|
+
if (input.onStep) {
|
|
1780
|
+
for (const step of stepsFromClaudeEvent(event, { mcpNames })) input.onStep(step);
|
|
1781
|
+
}
|
|
1593
1782
|
} catch {
|
|
1594
1783
|
}
|
|
1595
1784
|
}
|
|
@@ -1863,9 +2052,15 @@ function selectDispatch(input) {
|
|
|
1863
2052
|
import {
|
|
1864
2053
|
addDoc,
|
|
1865
2054
|
collection as collection4,
|
|
2055
|
+
deleteField,
|
|
1866
2056
|
doc as doc6,
|
|
2057
|
+
getDocs as getDocs3,
|
|
2058
|
+
limit as fsLimit,
|
|
2059
|
+
orderBy as orderBy3,
|
|
2060
|
+
query as query3,
|
|
1867
2061
|
runTransaction,
|
|
1868
|
-
updateDoc
|
|
2062
|
+
updateDoc,
|
|
2063
|
+
where as where3
|
|
1869
2064
|
} from "firebase/firestore";
|
|
1870
2065
|
import { ref as storageRef, uploadBytes } from "firebase/storage";
|
|
1871
2066
|
function redactTranscript(transcript, knownSecrets) {
|
|
@@ -1909,6 +2104,16 @@ async function finalizeJob(db, shipId, job, input) {
|
|
|
1909
2104
|
endedAt: now,
|
|
1910
2105
|
usage: u,
|
|
1911
2106
|
transcriptPath: input.transcriptPath,
|
|
2107
|
+
// §15.36. The live strip dies with the run — the transcript is the record, and a settled job
|
|
2108
|
+
// still carrying steps would show a spinner over work that finished.
|
|
2109
|
+
//
|
|
2110
|
+
// `stopRequestedAt` is cleared on EVERY terminal path, including a plain `done`: the marker
|
|
2111
|
+
// is self-consuming (§15.13's `startAt` shape), and leaving one behind on a job that
|
|
2112
|
+
// succeeded anyway is what would let the watchdog's stop branch re-examine settled jobs
|
|
2113
|
+
// forever. Deleting an absent field is not an affected key, so both are legal whether or not
|
|
2114
|
+
// anything ever set them.
|
|
2115
|
+
progress: deleteField(),
|
|
2116
|
+
stopRequestedAt: deleteField(),
|
|
1912
2117
|
...input.error ? { error: input.error.slice(0, 1500) } : {},
|
|
1913
2118
|
// Omitted rather than written empty, so a job that had none looks exactly like every job
|
|
1914
2119
|
// written before §15.31 — there is nothing to migrate and nothing to read defensively.
|
|
@@ -1941,7 +2146,9 @@ async function requeueForRetry(db, shipId, job, error) {
|
|
|
1941
2146
|
tx.update(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
|
|
1942
2147
|
status: "queued",
|
|
1943
2148
|
attempt: job.attempt + 1,
|
|
1944
|
-
error: error.slice(0, 1500)
|
|
2149
|
+
error: error.slice(0, 1500),
|
|
2150
|
+
// The steps describe an attempt that is over; the next one starts its own stream.
|
|
2151
|
+
progress: deleteField()
|
|
1945
2152
|
});
|
|
1946
2153
|
});
|
|
1947
2154
|
}
|
|
@@ -1950,7 +2157,15 @@ async function releaseJob(db, shipId, job, reason) {
|
|
|
1950
2157
|
status: "queued",
|
|
1951
2158
|
runnerId: "",
|
|
1952
2159
|
startedAt: 0,
|
|
1953
|
-
error: reason.slice(0, 1500)
|
|
2160
|
+
error: reason.slice(0, 1500),
|
|
2161
|
+
// §15.36: cleared here too, not only on the terminal paths. A released job sits `queued`
|
|
2162
|
+
// waiting to be re-claimed, and steps left behind would show a live-looking strip describing a
|
|
2163
|
+
// session that is no longer running anywhere.
|
|
2164
|
+
//
|
|
2165
|
+
// `stopRequestedAt` deliberately SURVIVES a release. The job goes back on the queue still
|
|
2166
|
+
// carrying the request, so whichever daemon claims it next honours the stop instead of running
|
|
2167
|
+
// work somebody already asked to end.
|
|
2168
|
+
progress: deleteField()
|
|
1954
2169
|
});
|
|
1955
2170
|
}
|
|
1956
2171
|
async function markTaskFailed(db, shipId, job, error, statuses) {
|
|
@@ -1973,6 +2188,25 @@ ${error.slice(0, 800)}
|
|
|
1973
2188
|
});
|
|
1974
2189
|
});
|
|
1975
2190
|
}
|
|
2191
|
+
async function markTaskStopped(db, shipId, job, stoppedBy) {
|
|
2192
|
+
const taskRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.tasks, job.taskId);
|
|
2193
|
+
await addDoc(collection4(taskRef, COLLECTIONS.activity), {
|
|
2194
|
+
author: { type: "agent", id: job.agentId },
|
|
2195
|
+
createdAt: Date.now(),
|
|
2196
|
+
kind: "comment",
|
|
2197
|
+
content: `This run was stopped by ${stoppedBy} before it finished. The task is unchanged.`
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
async function markChatStopped(db, shipId, job, stoppedBy) {
|
|
2201
|
+
const chatRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
|
|
2202
|
+
const content = `Stopped by ${stoppedBy} before I finished. Write again to start a fresh run.`;
|
|
2203
|
+
await addDoc(collection4(chatRef, COLLECTIONS.chatMessages), {
|
|
2204
|
+
author: { type: "agent", id: job.agentId },
|
|
2205
|
+
content,
|
|
2206
|
+
chars: content.length,
|
|
2207
|
+
createdAt: Date.now()
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
1976
2210
|
async function markChatFailed(db, shipId, job, error) {
|
|
1977
2211
|
const shipRef = doc6(db, COLLECTIONS.ships, shipId);
|
|
1978
2212
|
const chatRef = doc6(shipRef, COLLECTIONS.chats, job.chatId);
|
|
@@ -1991,12 +2225,132 @@ Write again to start a fresh run.`;
|
|
|
1991
2225
|
createdAt: now
|
|
1992
2226
|
});
|
|
1993
2227
|
}
|
|
2228
|
+
var REPLY_SCAN_LIMIT = 20;
|
|
2229
|
+
function backstopReplyContent(resultText) {
|
|
2230
|
+
const text = resultText.trim();
|
|
2231
|
+
if (!text) {
|
|
2232
|
+
return "I finished that run without writing a reply. Write again to start a fresh one.";
|
|
2233
|
+
}
|
|
2234
|
+
if (text.length <= MAX_CHAT_MESSAGE_CHARS) return text;
|
|
2235
|
+
const marker = "\n\n\u2026(truncated)";
|
|
2236
|
+
return `${text.slice(0, MAX_CHAT_MESSAGE_CHARS - marker.length)}${marker}`;
|
|
2237
|
+
}
|
|
2238
|
+
async function ensureChatReply(db, shipId, job, resultText) {
|
|
2239
|
+
const chatRef = doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
|
|
2240
|
+
const messagesCol = collection4(chatRef, COLLECTIONS.chatMessages);
|
|
2241
|
+
const snap = await getDocs3(
|
|
2242
|
+
query3(
|
|
2243
|
+
messagesCol,
|
|
2244
|
+
where3("createdAt", ">=", job.startedAt || 0),
|
|
2245
|
+
orderBy3("createdAt", "desc"),
|
|
2246
|
+
fsLimit(REPLY_SCAN_LIMIT)
|
|
2247
|
+
)
|
|
2248
|
+
);
|
|
2249
|
+
const replied = snap.docs.some((d) => {
|
|
2250
|
+
const author = d.data().author;
|
|
2251
|
+
return author?.type === "agent" && author.id === job.agentId;
|
|
2252
|
+
});
|
|
2253
|
+
if (replied) return "agent-replied";
|
|
2254
|
+
const content = backstopReplyContent(resultText);
|
|
2255
|
+
await addDoc(messagesCol, {
|
|
2256
|
+
author: { type: "agent", id: job.agentId },
|
|
2257
|
+
content,
|
|
2258
|
+
chars: content.length,
|
|
2259
|
+
createdAt: Date.now()
|
|
2260
|
+
});
|
|
2261
|
+
return resultText.trim() ? "posted-final-text" : "posted-silence-note";
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// src/jobs/progress.ts
|
|
2265
|
+
var PROGRESS_FLUSH_MS = 3e3;
|
|
2266
|
+
var PROGRESS_DENIALS_BEFORE_PROBE = 2;
|
|
2267
|
+
function redactStep(step, secrets) {
|
|
2268
|
+
const label = clampStepText(redactTranscript(step.label, secrets), MAX_STEP_LABEL);
|
|
2269
|
+
if (step.detail === void 0) return { ...step, label };
|
|
2270
|
+
const detail = clampStepText(redactTranscript(step.detail, secrets), MAX_STEP_DETAIL);
|
|
2271
|
+
return detail ? { ...step, label, detail } : { at: step.at, kind: step.kind, label };
|
|
2272
|
+
}
|
|
2273
|
+
function isDenied(error) {
|
|
2274
|
+
const code = error?.code ?? "";
|
|
2275
|
+
return code === "permission-denied" || code.endsWith("/permission-denied");
|
|
2276
|
+
}
|
|
2277
|
+
function createProgressWriter(deps) {
|
|
2278
|
+
const now = deps.now ?? Date.now;
|
|
2279
|
+
const flushMs = deps.flushMs ?? PROGRESS_FLUSH_MS;
|
|
2280
|
+
let progress;
|
|
2281
|
+
let pending = [];
|
|
2282
|
+
let timer = null;
|
|
2283
|
+
let lastWriteAt = 0;
|
|
2284
|
+
let inFlight = false;
|
|
2285
|
+
let denials = 0;
|
|
2286
|
+
let closed = false;
|
|
2287
|
+
let loggedFailure = false;
|
|
2288
|
+
function flush() {
|
|
2289
|
+
if (timer) {
|
|
2290
|
+
clearTimeout(timer);
|
|
2291
|
+
timer = null;
|
|
2292
|
+
}
|
|
2293
|
+
if (closed || inFlight || pending.length === 0) return;
|
|
2294
|
+
const secrets = deps.secrets();
|
|
2295
|
+
for (const step of pending) progress = pushJobStep(progress, redactStep(step, secrets));
|
|
2296
|
+
pending = [];
|
|
2297
|
+
lastWriteAt = now();
|
|
2298
|
+
inFlight = true;
|
|
2299
|
+
const snapshot = progress;
|
|
2300
|
+
void deps.write(snapshot).then(() => {
|
|
2301
|
+
denials = 0;
|
|
2302
|
+
}).catch((e) => {
|
|
2303
|
+
if (isDenied(e)) {
|
|
2304
|
+
denials += 1;
|
|
2305
|
+
if (denials >= PROGRESS_DENIALS_BEFORE_PROBE) {
|
|
2306
|
+
closed = true;
|
|
2307
|
+
deps.onDenied();
|
|
2308
|
+
}
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
denials = 0;
|
|
2312
|
+
if (!loggedFailure) {
|
|
2313
|
+
loggedFailure = true;
|
|
2314
|
+
deps.log(`progress write failed (further ones silent): ${e instanceof Error ? e.message : e}`);
|
|
2315
|
+
}
|
|
2316
|
+
}).finally(() => {
|
|
2317
|
+
inFlight = false;
|
|
2318
|
+
if (pending.length > 0 && !closed) schedule();
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
function schedule() {
|
|
2322
|
+
if (closed || timer) return;
|
|
2323
|
+
const due = lastWriteAt + flushMs - now();
|
|
2324
|
+
if (due <= 0 && !inFlight) {
|
|
2325
|
+
flush();
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
timer = setTimeout(flush, Math.max(due, 0));
|
|
2329
|
+
timer.unref?.();
|
|
2330
|
+
}
|
|
2331
|
+
return {
|
|
2332
|
+
push(step) {
|
|
2333
|
+
if (closed) return;
|
|
2334
|
+
pending.push({ ...step, at: now() });
|
|
2335
|
+
schedule();
|
|
2336
|
+
},
|
|
2337
|
+
stop() {
|
|
2338
|
+
closed = true;
|
|
2339
|
+
if (timer) {
|
|
2340
|
+
clearTimeout(timer);
|
|
2341
|
+
timer = null;
|
|
2342
|
+
}
|
|
2343
|
+
pending = [];
|
|
2344
|
+
}
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
1994
2347
|
|
|
1995
2348
|
// src/daemon.ts
|
|
1996
2349
|
var HEARTBEAT_MS = 3e4;
|
|
1997
2350
|
var JOB_TIMEOUT_MS = 20 * 60 * 1e3;
|
|
1998
2351
|
var MAX_ATTEMPTS = 2;
|
|
1999
2352
|
var SHUTDOWN_GRACE_MS = 1e4;
|
|
2353
|
+
var STOP_POLL_MS = 5e3;
|
|
2000
2354
|
async function startDaemon() {
|
|
2001
2355
|
const moved = migrateLegacyDir();
|
|
2002
2356
|
let config2 = requireConfig();
|
|
@@ -2110,11 +2464,11 @@ async function startDaemon() {
|
|
|
2110
2464
|
needsRefill.delete(shipId);
|
|
2111
2465
|
if (!serving.has(shipId) || approved.get(shipId) !== true) continue;
|
|
2112
2466
|
try {
|
|
2113
|
-
const snap = await
|
|
2114
|
-
|
|
2467
|
+
const snap = await getDocs4(
|
|
2468
|
+
query4(
|
|
2115
2469
|
collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
|
|
2116
|
-
|
|
2117
|
-
|
|
2470
|
+
where4("status", "==", "queued"),
|
|
2471
|
+
orderBy4("createdAt", "asc")
|
|
2118
2472
|
)
|
|
2119
2473
|
);
|
|
2120
2474
|
for (const d of snap.docs) {
|
|
@@ -2142,10 +2496,10 @@ async function startDaemon() {
|
|
|
2142
2496
|
console.error(`${what} listener error (${shipId}):`, e.message);
|
|
2143
2497
|
};
|
|
2144
2498
|
for (const shipId of serving) {
|
|
2145
|
-
const q =
|
|
2499
|
+
const q = query4(
|
|
2146
2500
|
collection5(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
|
|
2147
|
-
|
|
2148
|
-
|
|
2501
|
+
where4("status", "==", "queued"),
|
|
2502
|
+
orderBy4("createdAt", "asc")
|
|
2149
2503
|
);
|
|
2150
2504
|
unsubsByShip.set(shipId, [
|
|
2151
2505
|
onSnapshot2(
|
|
@@ -2383,6 +2737,23 @@ async function startDaemon() {
|
|
|
2383
2737
|
...job.workflowName ? { workflowName: job.workflowName } : {},
|
|
2384
2738
|
startedAt: Date.now()
|
|
2385
2739
|
};
|
|
2740
|
+
const stopPoll = setInterval(() => {
|
|
2741
|
+
void (async () => {
|
|
2742
|
+
if (slot.stop) return;
|
|
2743
|
+
try {
|
|
2744
|
+
const snap = await getDoc5(
|
|
2745
|
+
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
|
|
2746
|
+
);
|
|
2747
|
+
const fresh = snap.data();
|
|
2748
|
+
if (!fresh?.stopRequestedAt || slot.stop) return;
|
|
2749
|
+
slot.stop = { at: fresh.stopRequestedAt, by: fresh.stopRequestedBy?.id ?? "someone" };
|
|
2750
|
+
log2(`Job ${job.id}: stop requested \u2014 ending the session.`);
|
|
2751
|
+
slot.abort.abort();
|
|
2752
|
+
} catch {
|
|
2753
|
+
}
|
|
2754
|
+
})();
|
|
2755
|
+
}, STOP_POLL_MS);
|
|
2756
|
+
stopPoll.unref();
|
|
2386
2757
|
await setAgentStatus(shipId, job.agentId, "working");
|
|
2387
2758
|
void heartbeat();
|
|
2388
2759
|
const wake = config2.keepAwake === false ? null : inhibitSleep(`Crew job ${job.id}`);
|
|
@@ -2396,6 +2767,7 @@ async function startDaemon() {
|
|
|
2396
2767
|
let sessionLimit = null;
|
|
2397
2768
|
let engineId = DEFAULT_ENGINE_ID;
|
|
2398
2769
|
let transcript = "";
|
|
2770
|
+
let resultText = "";
|
|
2399
2771
|
let usage = {
|
|
2400
2772
|
engine: DEFAULT_ENGINE_ID,
|
|
2401
2773
|
inputTokens: 0,
|
|
@@ -2408,6 +2780,37 @@ async function startDaemon() {
|
|
|
2408
2780
|
let githubToken;
|
|
2409
2781
|
let extraMcpServers = [];
|
|
2410
2782
|
let statuses = DEFAULT_TASK_STATUSES;
|
|
2783
|
+
let knownSecrets = [];
|
|
2784
|
+
const progress = createProgressWriter({
|
|
2785
|
+
write: (p) => updateDoc2(doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
|
|
2786
|
+
progress: p
|
|
2787
|
+
}),
|
|
2788
|
+
secrets: () => knownSecrets,
|
|
2789
|
+
// Two consecutive DENIALS mean this job is no longer ours to write — a force-stop took it, or
|
|
2790
|
+
// the machine was revoked. The §15.30 door: the news arrives as `permission-denied`.
|
|
2791
|
+
//
|
|
2792
|
+
// A PROBE RATHER THAN A BARE ABORT, and it is not optional. If a deploy lands the daemon
|
|
2793
|
+
// before the rules, every progress write is denied on a perfectly healthy job, and aborting
|
|
2794
|
+
// on that signal alone would kill live work for the length of the rollout. One read settles
|
|
2795
|
+
// which of the two it is.
|
|
2796
|
+
onDenied: () => {
|
|
2797
|
+
void (async () => {
|
|
2798
|
+
try {
|
|
2799
|
+
const snap = await getDoc5(
|
|
2800
|
+
doc7(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id)
|
|
2801
|
+
);
|
|
2802
|
+
const fresh = snap.data();
|
|
2803
|
+
if (!fresh || fresh.status !== "running" || fresh.runnerId !== config2.runnerId) {
|
|
2804
|
+
log2(`Job ${job.id} is no longer this machine's to run \u2014 ending the session.`);
|
|
2805
|
+
slot.abort.abort();
|
|
2806
|
+
}
|
|
2807
|
+
} catch {
|
|
2808
|
+
}
|
|
2809
|
+
})();
|
|
2810
|
+
},
|
|
2811
|
+
log: log2
|
|
2812
|
+
});
|
|
2813
|
+
progress.push({ kind: "start", label: "Starting up" });
|
|
2411
2814
|
try {
|
|
2412
2815
|
const secrets = await loadSecrets(shipId);
|
|
2413
2816
|
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 }) };
|
|
@@ -2461,6 +2864,15 @@ async function startDaemon() {
|
|
|
2461
2864
|
throw e;
|
|
2462
2865
|
}
|
|
2463
2866
|
if (slot.abort.signal.aborted) throw new Error("Stopped before the session started.");
|
|
2867
|
+
knownSecrets = [
|
|
2868
|
+
idToken,
|
|
2869
|
+
secrets?.claudeToken,
|
|
2870
|
+
githubToken,
|
|
2871
|
+
secrets?.githubPat,
|
|
2872
|
+
// §15.31. BOTH the composed header value and the bare credential — see
|
|
2873
|
+
// `mcpSecretsToRedact` for why one of them is not enough.
|
|
2874
|
+
...mcpSecretsToRedact(extraMcpServers)
|
|
2875
|
+
];
|
|
2464
2876
|
const session = await getDriver(engineId).run({
|
|
2465
2877
|
prompt,
|
|
2466
2878
|
agent,
|
|
@@ -2473,17 +2885,13 @@ async function startDaemon() {
|
|
|
2473
2885
|
githubToken,
|
|
2474
2886
|
timeoutMs: JOB_TIMEOUT_MS,
|
|
2475
2887
|
signal: slot.abort.signal,
|
|
2476
|
-
log: log2
|
|
2888
|
+
log: log2,
|
|
2889
|
+
// §15.36. The driver emits Crew's step vocabulary; the writer coalesces, redacts and
|
|
2890
|
+
// persists. A throw here must never reach the driver's stdout loop, which is why `push`
|
|
2891
|
+
// swallows everything.
|
|
2892
|
+
onStep: (step) => progress.push(step)
|
|
2477
2893
|
});
|
|
2478
|
-
transcript = redactTranscript(session.transcript,
|
|
2479
|
-
idToken,
|
|
2480
|
-
secrets?.claudeToken,
|
|
2481
|
-
githubToken,
|
|
2482
|
-
secrets?.githubPat,
|
|
2483
|
-
// §15.31. BOTH the composed header value and the bare credential — see
|
|
2484
|
-
// `mcpSecretsToRedact` for why one of them is not enough.
|
|
2485
|
-
...mcpSecretsToRedact(extraMcpServers)
|
|
2486
|
-
]);
|
|
2894
|
+
transcript = redactTranscript(session.transcript, knownSecrets);
|
|
2487
2895
|
usage = session.usage;
|
|
2488
2896
|
if (session.limit && getEngine(engineId).usageWindows) {
|
|
2489
2897
|
sessionLimit = session.limit;
|
|
@@ -2496,17 +2904,36 @@ async function startDaemon() {
|
|
|
2496
2904
|
});
|
|
2497
2905
|
armLimitTimer();
|
|
2498
2906
|
}
|
|
2907
|
+
resultText = session.resultText;
|
|
2499
2908
|
if (!session.ok) failure = session.resultText || "Session failed.";
|
|
2500
2909
|
} catch (e) {
|
|
2501
2910
|
failure = e instanceof Error ? e.message : String(e);
|
|
2502
2911
|
transient = isTransientFirestoreError(e);
|
|
2503
2912
|
}
|
|
2504
2913
|
wake?.release();
|
|
2914
|
+
progress.stop();
|
|
2915
|
+
clearInterval(stopPoll);
|
|
2505
2916
|
try {
|
|
2506
2917
|
if (!serving.has(shipId)) {
|
|
2507
2918
|
log2(
|
|
2508
2919
|
`Job ${job.id} stopped \u2014 Ship ${shipId} removed this machine mid-run. The Ship has put the job back on its own queue; nothing more is written from here.`
|
|
2509
2920
|
);
|
|
2921
|
+
} else if (slot.stop && failure) {
|
|
2922
|
+
const transcriptPath = transcript ? await uploadTranscript(sess(shipId).fb.storage, shipId, job.id, transcript) : "";
|
|
2923
|
+
await finalizeJob(sess(shipId).fb.db, shipId, job, {
|
|
2924
|
+
status: "stopped",
|
|
2925
|
+
usage,
|
|
2926
|
+
transcriptPath,
|
|
2927
|
+
mcpServers: extraMcpServers.map((s) => s.key)
|
|
2928
|
+
});
|
|
2929
|
+
const by = slot.stop.by;
|
|
2930
|
+
if (target.kind === "chat") {
|
|
2931
|
+
await markChatStopped(sess(shipId).fb.db, shipId, { ...job, chatId: target.chatId }, by);
|
|
2932
|
+
} else {
|
|
2933
|
+
await markTaskStopped(sess(shipId).fb.db, shipId, { ...job, taskId: target.taskId }, by);
|
|
2934
|
+
}
|
|
2935
|
+
log2(`Job ${job.id} STOPPED by ${by} (attempt ${job.attempt}).`);
|
|
2936
|
+
notify("Crew job stopped", `${targetLabel} was stopped before it finished.`);
|
|
2510
2937
|
} else if (shuttingDown) {
|
|
2511
2938
|
await releaseJob(
|
|
2512
2939
|
sess(shipId).fb.db,
|
|
@@ -2541,9 +2968,33 @@ async function startDaemon() {
|
|
|
2541
2968
|
mcpServers: extraMcpServers.map((s) => s.key)
|
|
2542
2969
|
});
|
|
2543
2970
|
log2(`Job ${job.id} done (${usage.inputTokens}in/${usage.outputTokens}out tokens).`);
|
|
2971
|
+
let delivery = "agent-replied";
|
|
2972
|
+
if (target.kind === "chat") {
|
|
2973
|
+
try {
|
|
2974
|
+
delivery = await ensureChatReply(
|
|
2975
|
+
sess(shipId).fb.db,
|
|
2976
|
+
shipId,
|
|
2977
|
+
{ ...job, chatId: target.chatId },
|
|
2978
|
+
resultText
|
|
2979
|
+
);
|
|
2980
|
+
if (delivery === "posted-final-text") {
|
|
2981
|
+
log2(
|
|
2982
|
+
`Job ${job.id}: the agent never called chat_send \u2014 posted its final text to chat ${target.chatId} instead.`
|
|
2983
|
+
);
|
|
2984
|
+
} else if (delivery === "posted-silence-note") {
|
|
2985
|
+
log2(
|
|
2986
|
+
`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.`
|
|
2987
|
+
);
|
|
2988
|
+
}
|
|
2989
|
+
} catch (e) {
|
|
2990
|
+
log2(
|
|
2991
|
+
`Job ${job.id}: could not check whether the agent replied in chat ${target.chatId} \u2014 ${e instanceof Error ? e.message : e}`
|
|
2992
|
+
);
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2544
2995
|
notify(
|
|
2545
2996
|
"Crew job finished",
|
|
2546
|
-
target.kind === "task" ? `Task ${target.taskId} is done.` : "Agent replied in a chat."
|
|
2997
|
+
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."
|
|
2547
2998
|
);
|
|
2548
2999
|
} else if (transient) {
|
|
2549
3000
|
await releaseJob(
|
|
@@ -2623,8 +3074,8 @@ async function startDaemon() {
|
|
|
2623
3074
|
{
|
|
2624
3075
|
status: "offline",
|
|
2625
3076
|
lastSeenAt: now,
|
|
2626
|
-
currentJob:
|
|
2627
|
-
currentJobs:
|
|
3077
|
+
currentJob: deleteField2(),
|
|
3078
|
+
currentJobs: deleteField2()
|
|
2628
3079
|
},
|
|
2629
3080
|
{ merge: true }
|
|
2630
3081
|
);
|
|
@@ -2884,7 +3335,7 @@ function setParallel(config2, value, ship2) {
|
|
|
2884
3335
|
|
|
2885
3336
|
// src/cli/commands/doctor.ts
|
|
2886
3337
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
2887
|
-
import { collection as collection6, doc as doc8, getDoc as getDoc6, getDocs as
|
|
3338
|
+
import { collection as collection6, doc as doc8, getDoc as getDoc6, getDocs as getDocs5 } from "firebase/firestore";
|
|
2888
3339
|
|
|
2889
3340
|
// src/service.ts
|
|
2890
3341
|
import { spawnSync } from "node:child_process";
|
|
@@ -3099,10 +3550,10 @@ function serviceStatus() {
|
|
|
3099
3550
|
};
|
|
3100
3551
|
}
|
|
3101
3552
|
if (process.platform === "win32") {
|
|
3102
|
-
const
|
|
3103
|
-
if (!
|
|
3553
|
+
const query6 = run("schtasks", ["/Query", "/TN", WINDOWS_TASK]);
|
|
3554
|
+
if (!query6.ok) return { state: "not-installed", detail: "No scheduled task installed." };
|
|
3104
3555
|
return {
|
|
3105
|
-
state: /\bRunning\b/i.test(
|
|
3556
|
+
state: /\bRunning\b/i.test(query6.out) ? "running" : "installed",
|
|
3106
3557
|
detail: "Scheduled task installed (runs at logon).",
|
|
3107
3558
|
unitPath: WINDOWS_TASK
|
|
3108
3559
|
};
|
|
@@ -3339,7 +3790,7 @@ async function checkShips(config2) {
|
|
|
3339
3790
|
}
|
|
3340
3791
|
let agents = [];
|
|
3341
3792
|
try {
|
|
3342
|
-
const snap = await
|
|
3793
|
+
const snap = await getDocs5(collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
|
|
3343
3794
|
agents = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
|
|
3344
3795
|
} catch {
|
|
3345
3796
|
}
|
|
@@ -3864,9 +4315,9 @@ import {
|
|
|
3864
4315
|
doc as doc10,
|
|
3865
4316
|
getCountFromServer as getCountFromServer2,
|
|
3866
4317
|
getDoc as getDoc8,
|
|
3867
|
-
getDocs as
|
|
3868
|
-
query as
|
|
3869
|
-
where as
|
|
4318
|
+
getDocs as getDocs6,
|
|
4319
|
+
query as query5,
|
|
4320
|
+
where as where5
|
|
3870
4321
|
} from "firebase/firestore";
|
|
3871
4322
|
async function runStatus() {
|
|
3872
4323
|
const config2 = loadConfig();
|
|
@@ -3906,7 +4357,7 @@ async function runStatus() {
|
|
|
3906
4357
|
let queued = 0;
|
|
3907
4358
|
try {
|
|
3908
4359
|
const counted = await getCountFromServer2(
|
|
3909
|
-
|
|
4360
|
+
query5(collection7(shipRef, COLLECTIONS.jobs), where5("status", "==", "queued"))
|
|
3910
4361
|
);
|
|
3911
4362
|
queued = counted.data().count;
|
|
3912
4363
|
} catch {
|
|
@@ -3915,7 +4366,7 @@ async function runStatus() {
|
|
|
3915
4366
|
const usageSnap = await getDoc8(doc10(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
|
|
3916
4367
|
const today = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
|
|
3917
4368
|
const now = Date.now();
|
|
3918
|
-
const limitsSnap = await
|
|
4369
|
+
const limitsSnap = await getDocs6(collection7(shipRef, COLLECTIONS.engineLimits));
|
|
3919
4370
|
const engineLimits = limitsSnap.docs.map((d) => ({ id: d.id, ...d.data() })).filter((l) => isEngineLimited(l, now));
|
|
3920
4371
|
ships.push({
|
|
3921
4372
|
shipId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumi.ai/runner",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
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.",
|