@lumi.ai/runner 0.1.0

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -0
  3. package/dist/cli.js +3432 -0
  4. package/package.json +64 -0
package/dist/cli.js ADDED
@@ -0,0 +1,3432 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/index.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/daemon.ts
7
+ import os4 from "node:os";
8
+ import {
9
+ collection as collection5,
10
+ deleteField,
11
+ doc as doc7,
12
+ getDoc as getDoc5,
13
+ onSnapshot as onSnapshot2,
14
+ orderBy as orderBy3,
15
+ query as query3,
16
+ runTransaction as runTransaction2,
17
+ setDoc as setDoc2,
18
+ updateDoc as updateDoc2,
19
+ where as where3
20
+ } from "firebase/firestore";
21
+
22
+ // ../shared/dist/engines/claude.js
23
+ var CLAUDE_ENGINE = {
24
+ id: "claude",
25
+ label: "Claude Code",
26
+ models: [
27
+ { id: "sonnet", label: "Sonnet", rates: { input: 3, cachedInput: 0.3, output: 15 } },
28
+ { id: "opus", label: "Opus", rates: { input: 15, cachedInput: 1.5, output: 75 } }
29
+ ],
30
+ defaultModelId: "sonnet",
31
+ capabilities: {
32
+ mcp: true,
33
+ bash: true,
34
+ webSearch: true,
35
+ reportsCost: true,
36
+ reportsCache: true
37
+ },
38
+ requiredSecrets: [
39
+ {
40
+ key: "claudeToken",
41
+ label: "Claude setup-token",
42
+ hint: "Run `claude setup-token` on any machine signed in to Claude Code.",
43
+ // `claude setup-token` emits `sk-ant-oat01-…`, around 100 characters. The floor is set
44
+ // well below that on purpose: it exists to catch a half-copied token, not to pin a format
45
+ // Anthropic is free to change.
46
+ prefix: "sk-ant-oat01-",
47
+ minLength: 40
48
+ }
49
+ ],
50
+ usageWindows: {
51
+ label: "5-hour window",
52
+ // Claude has both a 5-hour rolling window and a weekly cap. The FALLBACK is the short one
53
+ // on purpose: when the wall message carries no parsable reset time we cannot tell which
54
+ // wall we hit, and pausing a Ship for a week on a guess is far worse than retrying in five
55
+ // hours.
56
+ fallbackMs: 5 * 60 * 60 * 1e3
57
+ }
58
+ };
59
+
60
+ // ../shared/dist/engines/index.js
61
+ var ENGINES = {
62
+ claude: CLAUDE_ENGINE
63
+ };
64
+ var DEFAULT_ENGINE_ID = "claude";
65
+ function isKnownEngine(id) {
66
+ return !!id && Object.hasOwn(ENGINES, id);
67
+ }
68
+ function getEngine(id) {
69
+ return isKnownEngine(id) ? ENGINES[id] : ENGINES[DEFAULT_ENGINE_ID];
70
+ }
71
+ function engineUsageWindows(engineId) {
72
+ return getEngine(engineId).usageWindows;
73
+ }
74
+ function engineModel(engineId, modelId) {
75
+ return getEngine(engineId).models.find((m) => m.id === modelId);
76
+ }
77
+ function estimateCostUsd(engineId, modelId, tokens) {
78
+ const model = engineModel(engineId, modelId);
79
+ if (!model)
80
+ return 0;
81
+ const fresh = Math.max(0, tokens.inputTokens - tokens.cachedInputTokens);
82
+ return (fresh * model.rates.input + tokens.cachedInputTokens * model.rates.cachedInput + tokens.outputTokens * model.rates.output) / 1e6;
83
+ }
84
+
85
+ // ../shared/dist/agent.js
86
+ var DEFAULT_AGENT_TOOLS = {
87
+ workspaceMcp: true,
88
+ github: { enabled: false, repos: [] },
89
+ bash: false,
90
+ webSearch: false,
91
+ extraMcps: []
92
+ };
93
+ function agentEngine(agent) {
94
+ return agent.engine ?? DEFAULT_ENGINE_ID;
95
+ }
96
+ function effectiveAgentTools(agent) {
97
+ const { capabilities } = getEngine(agent.engine);
98
+ const tools = { ...DEFAULT_AGENT_TOOLS, ...agent.tools };
99
+ const github = { ...DEFAULT_AGENT_TOOLS.github, ...tools.github };
100
+ return {
101
+ ...tools,
102
+ workspaceMcp: tools.workspaceMcp && capabilities.mcp,
103
+ bash: tools.bash && capabilities.bash,
104
+ webSearch: tools.webSearch && capabilities.webSearch,
105
+ // Arrays are COPIED, not spread through: a shallow spread of an agent doc missing `tools`
106
+ // hands back DEFAULT_AGENT_TOOLS' own arrays, so a caller appending to the result would
107
+ // edit the module-level defaults for every later agent.
108
+ extraMcps: [...tools.extraMcps],
109
+ github: { ...github, repos: [...github.repos], enabled: github.enabled && capabilities.bash }
110
+ };
111
+ }
112
+
113
+ // ../shared/dist/chat.js
114
+ var MAX_CHAT_MESSAGES_IN_PROMPT = 40;
115
+
116
+ // ../shared/dist/collections.js
117
+ var CREW_DATABASE_ID = "crew";
118
+ var COLLECTIONS = {
119
+ /** A Ship = one organization; the multi-tenant unit. Everything else hangs off it. */
120
+ ships: "ships",
121
+ /** `ships/{shipId}/members/{uid}` — the humans of a Ship (captain | member). */
122
+ members: "members",
123
+ /** `ships/{shipId}/tasks/{taskId}` — the board's units of work. */
124
+ tasks: "tasks",
125
+ /** `ships/{shipId}/tasks/{taskId}/activity/{eventId}` — a task's feed (comments + status). */
126
+ activity: "activity",
127
+ /** `ships/{shipId}/chats/{chatId}` — out-of-task conversations (PRD §7.8). */
128
+ chats: "chats",
129
+ /**
130
+ * `ships/{shipId}/chats/{chatId}/messages/{msgId}` — a chat's messages.
131
+ *
132
+ * A generic wire name under a specific parent, unlike `activity`: nothing else in Crew has
133
+ * `messages`, and the path is only ever composed from `chats` above it.
134
+ */
135
+ chatMessages: "messages",
136
+ /** `ships/{shipId}/agents/{agentId}` — the crew: one doc = one configured agent. */
137
+ agents: "agents",
138
+ /** `ships/{shipId}/agents/{agentId}/workflows/{workflowId}` — that agent's playbooks. */
139
+ workflows: "workflows",
140
+ /** `ships/{shipId}/knowledge/{slug}` — the org brain; doc id IS the slug (see knowledge.ts). */
141
+ knowledge: "knowledge",
142
+ /** `ships/{shipId}/knowledge_folders/{folderId}` — one-level grouping for humans. */
143
+ knowledgeFolders: "knowledge_folders",
144
+ /**
145
+ * `ships/{shipId}/indexes/{docId}` — trigger-owned derived documents (see INDEX_DOCS).
146
+ * Its OWN collection rather than a reserved id inside the source collection: a trigger writing
147
+ * into the path it watches self-fires, and a reserved doc id is a name a captain can squat.
148
+ */
149
+ indexes: "indexes",
150
+ /** `ships/{shipId}/jobs/{jobId}` — the execution queue; one doc per agent run. */
151
+ jobs: "jobs",
152
+ /** `ships/{shipId}/runners/{runnerId}` — ship-scoped live daemon mirror (Daemons page). */
153
+ runners: "runners",
154
+ /** `ships/{shipId}/notifications/{id}` — per-member in-app notifications. */
155
+ notifications: "notifications",
156
+ /** `ships/{shipId}/usage_daily/{yyyy-mm-dd}` — token-tracking aggregates. */
157
+ usageDaily: "usage_daily",
158
+ /** `ships/{shipId}/engine_limits/{engineId}` — an engine's exhausted usage window. */
159
+ engineLimits: "engine_limits",
160
+ /** `ships/{shipId}/events/{id}` — append-only Ship-level audit log. */
161
+ events: "events",
162
+ /** `ships/{shipId}/secrets/{docId}` — runner creds + MCP token hash (see secrets.ts). */
163
+ secrets: "secrets",
164
+ /** `ships/{shipId}/integrations/{docId}` — 3rd-party connectors (GitHub App; see github.ts). */
165
+ integrations: "integrations",
166
+ /** Top-level `users/{uid}/...` — per-user docs (runner machine registry). */
167
+ users: "users",
168
+ /** Top-level `githubInstallStates/{stateId}` — single-use install-flow nonces (backend only). */
169
+ githubInstallStates: "githubInstallStates",
170
+ /** Top-level `githubInstallations/{installationId}` — installation→Ship index (backend only). */
171
+ githubInstallations: "githubInstallations",
172
+ /** Top-level `runnerLoginCodes/{userCode}` — CLI device-login handshakes (backend only). */
173
+ runnerLoginCodes: "runnerLoginCodes"
174
+ };
175
+
176
+ // ../shared/dist/engineLimit.js
177
+ function isEngineLimited(limit3, now) {
178
+ return !!limit3 && limit3.resetsAt > now;
179
+ }
180
+
181
+ // ../shared/dist/github.js
182
+ var INTEGRATION_DOCS = {
183
+ /** The GitHub App installation for this Ship (this file's shape). */
184
+ github: "github"
185
+ };
186
+
187
+ // ../shared/dist/job.js
188
+ function jobTarget(job) {
189
+ if (job.taskId && job.chatId)
190
+ return null;
191
+ if (job.taskId)
192
+ return { kind: "task", taskId: job.taskId };
193
+ if (job.chatId)
194
+ return { kind: "chat", chatId: job.chatId };
195
+ return null;
196
+ }
197
+
198
+ // ../shared/dist/knowledge.js
199
+ var KNOWLEDGE_CATALOG_MAX_CHARS = 1500;
200
+ var KNOWLEDGE_CATALOG_SUMMARIES = 8;
201
+ var INDEX_DOCS = {
202
+ /** The knowledge catalog, rebuilt by the `onKnowledgeWritten` trigger. */
203
+ knowledge: "knowledge"
204
+ };
205
+ function rankKnowledge(index, labels = []) {
206
+ const entries = index?.entries;
207
+ if (!entries)
208
+ return [];
209
+ const wanted = new Set(labels.map((l) => l.toLowerCase()));
210
+ return Object.entries(entries).map(([slug, entry]) => ({
211
+ slug,
212
+ entry,
213
+ score: (entry.g ?? []).reduce((n, tag) => n + (wanted.has(tag.toLowerCase()) ? 1 : 0), 0)
214
+ })).sort((a, b) => b.score - a.score || (b.entry.u ?? 0) - (a.entry.u ?? 0) || (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
215
+ }
216
+ function knowledgeCatalogBlock(index, labels = []) {
217
+ const ranked = rankKnowledge(index, labels);
218
+ if (ranked.length === 0) {
219
+ return "# Ship knowledge\n\nThis Ship has no knowledge documents yet. `knowledge_write` creates one \u2014 use it the moment you learn something a colleague would need.";
220
+ }
221
+ const head = `# Ship knowledge
222
+
223
+ ${ranked.length} document${ranked.length === 1 ? "" : "s"}. These are SUMMARIES \u2014 call \`knowledge_get({slug})\` to read one in full. Most relevant to this task first.
224
+
225
+ `;
226
+ const OVERFLOW_PREFIX = "\nNot listed (call knowledge_get to search): ";
227
+ const RESERVE = OVERFLOW_PREFIX.length + 40;
228
+ const lines = [];
229
+ const dropped = [];
230
+ let used = head.length;
231
+ ranked.forEach((r, i) => {
232
+ const line = i < KNOWLEDGE_CATALOG_SUMMARIES ? `- \`${r.slug}\` \u2014 ${r.entry.t}: ${r.entry.s}` : `- \`${r.slug}\` \u2014 ${r.entry.t}`;
233
+ if (dropped.length === 0 && used + line.length + 1 <= KNOWLEDGE_CATALOG_MAX_CHARS - RESERVE) {
234
+ lines.push(line);
235
+ used += line.length + 1;
236
+ } else {
237
+ dropped.push(r.slug);
238
+ }
239
+ });
240
+ if (dropped.length === 0)
241
+ return head + lines.join("\n");
242
+ const named = [];
243
+ let room = KNOWLEDGE_CATALOG_MAX_CHARS - used - OVERFLOW_PREFIX.length - 20;
244
+ for (const slug of dropped) {
245
+ if (room - (slug.length + 2) < 0)
246
+ break;
247
+ named.push(slug);
248
+ room -= slug.length + 2;
249
+ }
250
+ const rest = dropped.length - named.length;
251
+ const tail = OVERFLOW_PREFIX + named.join(", ") + (rest > 0 ? ` \u2026 and ${rest} more.` : ".");
252
+ return head + lines.join("\n") + tail;
253
+ }
254
+
255
+ // ../shared/dist/docMedia.js
256
+ var DOC_MEDIA_IMAGE_TYPES = [
257
+ "image/png",
258
+ "image/jpeg",
259
+ "image/gif",
260
+ "image/webp"
261
+ ];
262
+ var DOC_MEDIA_DOC_TYPES = [
263
+ "application/pdf",
264
+ "text/csv",
265
+ "text/plain",
266
+ "text/markdown",
267
+ "application/json"
268
+ ];
269
+ var DOC_MEDIA_TYPES = [
270
+ ...DOC_MEDIA_IMAGE_TYPES,
271
+ ...DOC_MEDIA_DOC_TYPES
272
+ ];
273
+
274
+ // ../shared/dist/memory.js
275
+ var MAX_MEMORY_ENTRIES = 40;
276
+ var MAX_MEMORY_TOTAL_CHARS = 2e3;
277
+ function emptyMemory() {
278
+ return { entries: [], nextId: 1, updatedAt: 0 };
279
+ }
280
+ function memoryChars(memory) {
281
+ return (memory?.entries ?? []).reduce((n, e) => n + (e.text?.length ?? 0), 0);
282
+ }
283
+ function readMemory(memory) {
284
+ if (!memory || typeof memory !== "object" || Array.isArray(memory))
285
+ return emptyMemory();
286
+ const m = memory;
287
+ const entries = Array.isArray(m.entries) ? m.entries.filter((e) => !!e && typeof e === "object" && typeof e.id === "string" && typeof e.text === "string") : [];
288
+ const nextId = Number.isInteger(m.nextId) && m.nextId > 0 ? m.nextId : entries.length + 1;
289
+ return { entries, nextId, updatedAt: typeof m.updatedAt === "number" ? m.updatedAt : 0 };
290
+ }
291
+ function memoryBlock(memory) {
292
+ const m = readMemory(memory);
293
+ const head = "# Your memory\n\n";
294
+ if (m.entries.length === 0) {
295
+ return head + "(nothing recorded yet)\n\nThis is your private notebook \u2014 it is carried into every session on this Ship. Call `memory_write` with `add` to record something worth knowing next time, or `retire` to drop a line that is no longer true. Durable facts about how this Ship works, not what happened on one task.";
296
+ }
297
+ const chars = memoryChars(m);
298
+ const lines = m.entries.map((e) => {
299
+ const marks = [e.source === "captain" ? "captain" : "you", e.pinned ? "pinned" : null].filter(Boolean).join(", ");
300
+ return `- \`${e.id}\` (${marks}) \u2014 ${e.text}`;
301
+ }).join("\n");
302
+ return head + `${m.entries.length} of ${MAX_MEMORY_ENTRIES} entries \xB7 ${chars} of ${MAX_MEMORY_TOTAL_CHARS} characters. Entries marked (captain) were written by a human and outrank your own. Change this with \`memory_write\` \u2014 it takes \`add\` and \`retire\`, a delta, never the whole notebook. If one proved wrong while you worked, retire it before you finish.
303
+
304
+ ` + lines;
305
+ }
306
+
307
+ // ../shared/dist/riskyAction.js
308
+ var RISKY_ACTIONS = [
309
+ {
310
+ id: "git_push",
311
+ label: "Push to GitHub",
312
+ meaning: "Write commits to a connected repository.",
313
+ enforcedBy: "github-token"
314
+ },
315
+ {
316
+ id: "task_create",
317
+ label: "Create tasks",
318
+ meaning: "Delegate by opening new work, including for other agents.",
319
+ enforcedBy: "mcp"
320
+ },
321
+ {
322
+ id: "task_assign",
323
+ label: "Assign work to others",
324
+ meaning: "Hand a task to another agent or a human.",
325
+ enforcedBy: "mcp"
326
+ },
327
+ {
328
+ id: "status_done",
329
+ label: "Mark work finished",
330
+ meaning: "Move a task into a review or done status \u2014 the claim that work is complete.",
331
+ enforcedBy: "mcp"
332
+ }
333
+ ];
334
+ var RISKY_ACTION_IDS = RISKY_ACTIONS.map((a) => a.id);
335
+ function isRiskyActionId(value) {
336
+ return typeof value === "string" && RISKY_ACTION_IDS.includes(value);
337
+ }
338
+ function splitRiskyActions(riskyActions) {
339
+ const enforced = [];
340
+ const advisory = [];
341
+ for (const action2 of riskyActions) {
342
+ if (isRiskyActionId(action2))
343
+ enforced.push(action2);
344
+ else if (action2.trim())
345
+ advisory.push(action2);
346
+ }
347
+ return { enforced, advisory };
348
+ }
349
+ function autonomyRule(agent) {
350
+ const { enforced, advisory } = splitRiskyActions(agent.riskyActions ?? []);
351
+ const named = [...enforced, ...advisory];
352
+ if (agent.autonomy >= 3) {
353
+ return "You have full autonomy (level 3) within your tool list: no action needs approval. Use it carefully \u2014 nothing will stop you.";
354
+ }
355
+ if (agent.autonomy <= 1) {
356
+ return "You are at autonomy level 1 \u2014 DRAFTS ONLY. Produce comments and drafts; do not mark work as done or ready for review, do not create or reassign tasks, and do not push to GitHub. When you need any of that, call approval_request with a short summary and STOP \u2014 a captain decides, and you will be started again with their answer.";
357
+ }
358
+ return `You are at autonomy level 2. Work freely, except for these actions, which need a captain: ${named.length ? named.join(", ") : "(none configured)"}. Before any of them, call approval_request with a short summary and STOP \u2014 you will be started again with the captain's answer.`;
359
+ }
360
+
361
+ // ../shared/dist/runner.js
362
+ var RUNNER_OFFLINE_AFTER_MS = 2 * 60 * 1e3;
363
+
364
+ // ../shared/dist/secrets.js
365
+ var SECRET_DOCS = {
366
+ /** Captain-provided runner credentials (this file's shape). */
367
+ runner: "runner",
368
+ /** `{ tokenHash }` for the Workspace MCP bearer — backend-only, never client-readable. */
369
+ mcp: "mcp"
370
+ };
371
+
372
+ // ../shared/dist/task.js
373
+ var TASK_STATUS_CATEGORIES = [
374
+ "backlog",
375
+ "todo",
376
+ "in_progress",
377
+ "waiting_approval",
378
+ "review",
379
+ "done",
380
+ "failed"
381
+ ];
382
+ var TASK_STATUS_CATEGORY_META = {
383
+ backlog: { label: "Backlog", meaning: "Parked \u2014 not ready to be worked; agents ignore it." },
384
+ todo: { label: "To do", meaning: "Ready to start \u2014 assigning an agent here starts a run." },
385
+ in_progress: { label: "In progress", meaning: "Actively being worked on." },
386
+ waiting_approval: {
387
+ label: "Waiting approval",
388
+ meaning: "Blocked on a captain decision before work can continue."
389
+ },
390
+ review: { label: "Review", meaning: "Work finished but a human must check it." },
391
+ done: { label: "Done", meaning: "Complete \u2014 nothing pending." },
392
+ failed: { label: "Failed", meaning: "Ended unsuccessfully; needs human attention." }
393
+ };
394
+ var DEFAULT_TASK_STATUSES = TASK_STATUS_CATEGORIES.map((category) => ({ id: category, label: TASK_STATUS_CATEGORY_META[category].label, category }));
395
+ function shipTaskStatuses(ship2) {
396
+ const list = ship2?.settings?.taskStatuses;
397
+ return Array.isArray(list) && list.length > 0 ? list : DEFAULT_TASK_STATUSES;
398
+ }
399
+ function statusById(statuses, statusId) {
400
+ return statuses.find((s) => s.id === statusId);
401
+ }
402
+ function firstStatusIn(statuses, category) {
403
+ return statuses.find((s) => s.category === category);
404
+ }
405
+
406
+ // ../shared/dist/ship.js
407
+ var DEFAULT_SHIP_SETTINGS = {
408
+ maxChainDepth: 3,
409
+ maxJobsPerChain: 10,
410
+ maxHookDepth: 5,
411
+ defaultAutonomy: 2,
412
+ githubRepos: [],
413
+ taskStatuses: DEFAULT_TASK_STATUSES,
414
+ // UTC, not the server's zone: a default that depends on where the code runs is not a default.
415
+ // The creation wizard offers the captain's own browser zone, which is what most Ships get.
416
+ timezone: "UTC"
417
+ };
418
+
419
+ // ../shared/dist/usage.js
420
+ var EMPTY_USAGE_TOTALS = {
421
+ inputTokens: 0,
422
+ cachedInputTokens: 0,
423
+ outputTokens: 0,
424
+ costUsd: 0,
425
+ jobs: 0,
426
+ durationS: 0
427
+ };
428
+ var EMPTY_USAGE_BY_AGENT = {
429
+ inputTokens: 0,
430
+ cachedInputTokens: 0,
431
+ outputTokens: 0,
432
+ costUsd: 0,
433
+ jobs: 0
434
+ };
435
+
436
+ // ../shared/dist/workflow.js
437
+ var WORKFLOW_SENTINELS = ["generic", "assigned", "activity", "chat"];
438
+ function isWorkflowSentinel(id) {
439
+ return WORKFLOW_SENTINELS.includes(id);
440
+ }
441
+ function usableWorkflow(workflow) {
442
+ if (!workflow || !workflow.enabled)
443
+ return null;
444
+ return workflow.instructions?.trim() ? workflow : null;
445
+ }
446
+
447
+ // src/config.ts
448
+ import fs from "node:fs";
449
+ import os from "node:os";
450
+ import path from "node:path";
451
+ var DEFAULT_PROJECT_ID = "lumi-afb7d";
452
+ var LEGACY_DIR_NAME = ".crew-runner";
453
+ var DIR_NAME = ".lumi-runner";
454
+ function configDir() {
455
+ const override = process.env.LUMI_RUNNER_HOME || process.env.CREW_RUNNER_HOME;
456
+ if (override) return override;
457
+ const home = os.homedir();
458
+ const current = path.join(home, DIR_NAME);
459
+ if (fs.existsSync(current)) return current;
460
+ const legacy = path.join(home, LEGACY_DIR_NAME);
461
+ if (fs.existsSync(legacy)) return legacy;
462
+ return current;
463
+ }
464
+ function migrateLegacyDir() {
465
+ if (process.env.LUMI_RUNNER_HOME || process.env.CREW_RUNNER_HOME) return null;
466
+ const home = os.homedir();
467
+ const from = path.join(home, LEGACY_DIR_NAME);
468
+ const to = path.join(home, DIR_NAME);
469
+ if (!fs.existsSync(from) || fs.existsSync(to)) return null;
470
+ try {
471
+ fs.renameSync(from, to);
472
+ return { migrated: true, from, to };
473
+ } catch {
474
+ return { migrated: false, from, to };
475
+ }
476
+ }
477
+ function configPath() {
478
+ return path.join(configDir(), "config.json");
479
+ }
480
+ function loadConfig() {
481
+ try {
482
+ return JSON.parse(fs.readFileSync(configPath(), "utf8"));
483
+ } catch {
484
+ return null;
485
+ }
486
+ }
487
+ function requireConfig() {
488
+ const config2 = loadConfig();
489
+ if (!config2) {
490
+ console.error("Not logged in. Run: lumi-runner setup");
491
+ process.exit(1);
492
+ }
493
+ return config2;
494
+ }
495
+ function saveConfig(config2) {
496
+ fs.mkdirSync(configDir(), { recursive: true, mode: 448 });
497
+ fs.writeFileSync(configPath(), `${JSON.stringify(config2, null, 2)}
498
+ `, { mode: 384 });
499
+ }
500
+ function mcpUrl(config2) {
501
+ return process.env.CREW_MCP_URL || config2.mcpUrl || `https://us-central1-${config2.projectId}.cloudfunctions.net/workspaceMcp`;
502
+ }
503
+
504
+ // src/version.ts
505
+ var RUNNER_VERSION = true ? "0.1.0" : "0.0.0-dev";
506
+
507
+ // src/auth.ts
508
+ import os2 from "node:os";
509
+ import { signInWithCustomToken } from "firebase/auth";
510
+
511
+ // src/callables.ts
512
+ function functionsBaseUrl(config2) {
513
+ return functionsBaseUrlFor(config2.projectId);
514
+ }
515
+ function functionsBaseUrlFor(projectId) {
516
+ return process.env.CREW_FUNCTIONS_URL?.replace(/\/$/, "") || `https://us-central1-${projectId}.cloudfunctions.net`;
517
+ }
518
+ var CallableError = class extends Error {
519
+ constructor(status, message) {
520
+ super(message);
521
+ this.status = status;
522
+ }
523
+ status;
524
+ };
525
+ async function callFunction(baseUrl, idToken, name, data) {
526
+ return request(baseUrl, name, data, { authorization: `Bearer ${idToken}` });
527
+ }
528
+ async function callPublicFunction(baseUrl, name, data) {
529
+ return request(baseUrl, name, data, {});
530
+ }
531
+ async function request(baseUrl, name, data, headers) {
532
+ const res = await fetch(`${baseUrl}/${name}`, {
533
+ method: "POST",
534
+ headers: { "content-type": "application/json", ...headers },
535
+ body: JSON.stringify({ data })
536
+ });
537
+ const text = await res.text();
538
+ let body = {};
539
+ try {
540
+ body = text ? JSON.parse(text) : {};
541
+ } catch {
542
+ body = {};
543
+ }
544
+ if (!res.ok || body.error) {
545
+ const status = (body.error?.status || `HTTP_${res.status}`).toUpperCase();
546
+ throw new CallableError(status, body.error?.message || `${name} failed (${res.status}).`);
547
+ }
548
+ return body.result;
549
+ }
550
+
551
+ // src/auth.ts
552
+ var authEmulator = () => process.env.FIREBASE_AUTH_EMULATOR_HOST;
553
+ function securetokenUrl(apiKey) {
554
+ const emu = authEmulator();
555
+ return emu ? `http://${emu}/securetoken.googleapis.com/v1/token?key=${apiKey}` : `https://securetoken.googleapis.com/v1/token?key=${apiKey}`;
556
+ }
557
+ async function login(fb, config2, customToken) {
558
+ const cred = await signInWithCustomToken(fb.auth, customToken);
559
+ config2.refreshToken = cred.user.refreshToken;
560
+ saveConfig(config2);
561
+ return cred.user;
562
+ }
563
+ var AuthError = class extends Error {
564
+ };
565
+ async function signInWithStoredSession(fb) {
566
+ const config2 = requireConfig();
567
+ if (!config2.refreshToken) {
568
+ throw new AuthError("No stored session. Run `lumi-runner setup`.");
569
+ }
570
+ const tokenRes = await fetch(securetokenUrl(config2.apiKey), {
571
+ method: "POST",
572
+ headers: { "content-type": "application/x-www-form-urlencoded" },
573
+ body: new URLSearchParams({
574
+ grant_type: "refresh_token",
575
+ refresh_token: config2.refreshToken
576
+ })
577
+ });
578
+ if (!tokenRes.ok) {
579
+ throw new AuthError(`Session refresh failed (${tokenRes.status}). Run \`lumi-runner login\` again.`);
580
+ }
581
+ const { id_token: idToken } = await tokenRes.json();
582
+ let mint;
583
+ try {
584
+ mint = await callFunction(functionsBaseUrl(config2), idToken, "mintRunnerToken", {
585
+ runnerId: config2.runnerId,
586
+ hostname: os2.hostname(),
587
+ version: RUNNER_VERSION
588
+ });
589
+ } catch (e) {
590
+ throw new AuthError(`mintRunnerToken failed: ${e instanceof Error ? e.message : e}`);
591
+ }
592
+ const cred = await signInWithCustomToken(fb.auth, mint.customToken);
593
+ config2.runnerId = mint.runnerId;
594
+ config2.refreshToken = cred.user.refreshToken;
595
+ saveConfig(config2);
596
+ return cred.user;
597
+ }
598
+ async function ensureSignedIn(fb) {
599
+ try {
600
+ return await signInWithStoredSession(fb);
601
+ } catch (e) {
602
+ console.error(e instanceof Error ? e.message : String(e));
603
+ process.exit(1);
604
+ }
605
+ }
606
+
607
+ // src/firebase.ts
608
+ import { deleteApp, getApps, initializeApp } from "firebase/app";
609
+ import { getAuth, connectAuthEmulator } from "firebase/auth";
610
+ import {
611
+ getFirestore,
612
+ connectFirestoreEmulator,
613
+ terminate
614
+ } from "firebase/firestore";
615
+ import { getStorage, connectStorageEmulator } from "firebase/storage";
616
+ function initFirebase(config2) {
617
+ const app = initializeApp({
618
+ apiKey: config2.apiKey,
619
+ projectId: config2.projectId,
620
+ authDomain: `${config2.projectId}.firebaseapp.com`,
621
+ // The modern default bucket (post-2024 Firebase projects) — matches what the functions
622
+ // runtime's admin getStorage().bucket() resolves for getTranscript.
623
+ storageBucket: `${config2.projectId}.firebasestorage.app`
624
+ });
625
+ const auth = getAuth(app);
626
+ const db = getFirestore(app, CREW_DATABASE_ID);
627
+ const storage = getStorage(app);
628
+ if (process.env.FIREBASE_AUTH_EMULATOR_HOST) {
629
+ connectAuthEmulator(auth, `http://${process.env.FIREBASE_AUTH_EMULATOR_HOST}`, {
630
+ disableWarnings: true
631
+ });
632
+ }
633
+ if (process.env.FIRESTORE_EMULATOR_HOST) {
634
+ const [host, port] = process.env.FIRESTORE_EMULATOR_HOST.split(":");
635
+ connectFirestoreEmulator(db, host, Number(port));
636
+ }
637
+ if (process.env.FIREBASE_STORAGE_EMULATOR_HOST) {
638
+ const [host, port] = process.env.FIREBASE_STORAGE_EMULATOR_HOST.split(":");
639
+ connectStorageEmulator(storage, host, Number(port));
640
+ }
641
+ return { app, auth, db, storage };
642
+ }
643
+ async function closeFirebase() {
644
+ for (const app of getApps()) {
645
+ try {
646
+ await terminate(getFirestore(app, CREW_DATABASE_ID));
647
+ } catch {
648
+ }
649
+ try {
650
+ await deleteApp(app);
651
+ } catch {
652
+ }
653
+ }
654
+ }
655
+
656
+ // src/logging.ts
657
+ import fs2 from "node:fs";
658
+ import path2 from "node:path";
659
+ var MAX_BYTES = 5 * 1024 * 1024;
660
+ var KEEP = 3;
661
+ function logDir() {
662
+ return path2.join(configDir(), "logs");
663
+ }
664
+ function logFile() {
665
+ return path2.join(logDir(), "runner.log");
666
+ }
667
+ var liveBytes = null;
668
+ function rotate() {
669
+ const base = logFile();
670
+ try {
671
+ fs2.rmSync(`${base}.${KEEP}`, { force: true });
672
+ for (let i = KEEP - 1; i >= 1; i--) {
673
+ if (fs2.existsSync(`${base}.${i}`)) fs2.renameSync(`${base}.${i}`, `${base}.${i + 1}`);
674
+ }
675
+ if (fs2.existsSync(base)) fs2.renameSync(base, `${base}.1`);
676
+ } catch {
677
+ }
678
+ liveBytes = 0;
679
+ }
680
+ function appendLog(line) {
681
+ const file = logFile();
682
+ const payload = `${line}
683
+ `;
684
+ try {
685
+ if (liveBytes === null) {
686
+ fs2.mkdirSync(logDir(), { recursive: true, mode: 448 });
687
+ liveBytes = fs2.existsSync(file) ? fs2.statSync(file).size : 0;
688
+ }
689
+ if (liveBytes + payload.length > MAX_BYTES) rotate();
690
+ fs2.appendFileSync(file, payload);
691
+ liveBytes += payload.length;
692
+ } catch {
693
+ }
694
+ }
695
+ function readTail(count) {
696
+ const base = logFile();
697
+ const chunks = [];
698
+ for (const file of [`${base}.1`, base]) {
699
+ try {
700
+ chunks.push(fs2.readFileSync(file, "utf8"));
701
+ } catch {
702
+ }
703
+ }
704
+ const lines = chunks.join("").split("\n").filter((l) => l.length > 0);
705
+ return lines.slice(-count);
706
+ }
707
+ function followLog(onLine, intervalMs = 500) {
708
+ const file = logFile();
709
+ let position = 0;
710
+ try {
711
+ position = fs2.statSync(file).size;
712
+ } catch {
713
+ position = 0;
714
+ }
715
+ const tick = () => {
716
+ let size;
717
+ try {
718
+ size = fs2.statSync(file).size;
719
+ } catch {
720
+ return;
721
+ }
722
+ if (size < position) position = 0;
723
+ if (size === position) return;
724
+ try {
725
+ const fd = fs2.openSync(file, "r");
726
+ const buffer = Buffer.alloc(size - position);
727
+ fs2.readSync(fd, buffer, 0, buffer.length, position);
728
+ fs2.closeSync(fd);
729
+ position = size;
730
+ for (const line of buffer.toString("utf8").split("\n")) {
731
+ if (line.length > 0) onLine(line);
732
+ }
733
+ } catch {
734
+ }
735
+ };
736
+ const timer = setInterval(tick, intervalMs);
737
+ return () => clearInterval(timer);
738
+ }
739
+
740
+ // src/notify.ts
741
+ import { spawn } from "node:child_process";
742
+ var TITLE_VAR = "CREW_NOTIFY_TITLE";
743
+ var BODY_VAR = "CREW_NOTIFY_BODY";
744
+ var MACOS_SCRIPT = `display notification (system attribute "${BODY_VAR}") with title (system attribute "${TITLE_VAR}")`;
745
+ var WINDOWS_SCRIPT = [
746
+ "Add-Type -AssemblyName System.Windows.Forms;",
747
+ "$n = New-Object System.Windows.Forms.NotifyIcon;",
748
+ "$n.Icon = [System.Drawing.SystemIcons]::Information;",
749
+ "$n.Visible = $true;",
750
+ `$n.ShowBalloonTip(5000, $env:${TITLE_VAR}, $env:${BODY_VAR}, 'Info');`,
751
+ "Start-Sleep -Seconds 6;",
752
+ "$n.Dispose()"
753
+ ].join(" ");
754
+ function enabled() {
755
+ if (process.env.CREW_NO_NOTIFY === "1") return false;
756
+ return loadConfig()?.notifications !== false;
757
+ }
758
+ function notify(title, body) {
759
+ if (!enabled()) return;
760
+ const env = { ...process.env, [TITLE_VAR]: title, [BODY_VAR]: body };
761
+ let command;
762
+ let args;
763
+ if (process.platform === "darwin") {
764
+ command = "osascript";
765
+ args = ["-e", MACOS_SCRIPT];
766
+ } else if (process.platform === "linux") {
767
+ command = "notify-send";
768
+ args = ["--app-name=lumi-runner", title, body];
769
+ } else if (process.platform === "win32") {
770
+ command = "powershell";
771
+ args = ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT];
772
+ } else {
773
+ return;
774
+ }
775
+ try {
776
+ const child = spawn(command, args, { env, stdio: "ignore", detached: true });
777
+ child.on("error", () => {
778
+ });
779
+ child.unref();
780
+ } catch {
781
+ }
782
+ }
783
+
784
+ // src/power.ts
785
+ import { spawn as spawn2 } from "node:child_process";
786
+ var NOOP = { release: () => {
787
+ } };
788
+ function hold(command, args) {
789
+ let child;
790
+ try {
791
+ child = spawn2(command, args, { stdio: "ignore", detached: false });
792
+ } catch {
793
+ return NOOP;
794
+ }
795
+ child.on("error", () => {
796
+ });
797
+ let released = false;
798
+ return {
799
+ release() {
800
+ if (released) return;
801
+ released = true;
802
+ try {
803
+ child?.kill("SIGTERM");
804
+ } catch {
805
+ }
806
+ }
807
+ };
808
+ }
809
+ var WINDOWS_SCRIPT2 = [
810
+ `Add-Type -Name Power -Namespace Win32 -MemberDefinition '[DllImport("kernel32.dll", SetLastError = true)] public static extern uint SetThreadExecutionState(uint esFlags);';`,
811
+ // ES_CONTINUOUS (0x80000000) | ES_SYSTEM_REQUIRED (0x00000001)
812
+ "[Win32.Power]::SetThreadExecutionState(0x80000001) | Out-Null;",
813
+ "Start-Sleep -Seconds 86400"
814
+ ].join(" ");
815
+ function inhibitSleep(reason) {
816
+ if (process.env.CREW_NO_POWER === "1") return NOOP;
817
+ if (process.platform === "darwin") {
818
+ return hold("caffeinate", ["-i", "-w", String(process.pid)]);
819
+ }
820
+ if (process.platform === "linux") {
821
+ return hold("systemd-inhibit", [
822
+ "--what=idle:sleep",
823
+ `--why=${reason}`,
824
+ "--mode=block",
825
+ "--who=lumi-runner",
826
+ "sleep",
827
+ "infinity"
828
+ ]);
829
+ }
830
+ if (process.platform === "win32") {
831
+ return hold("powershell", ["-NoProfile", "-NonInteractive", "-Command", WINDOWS_SCRIPT2]);
832
+ }
833
+ return NOOP;
834
+ }
835
+
836
+ // src/jobs/contextPack.ts
837
+ import {
838
+ collection,
839
+ doc,
840
+ getDoc,
841
+ getDocs,
842
+ limit,
843
+ orderBy,
844
+ query,
845
+ where
846
+ } from "firebase/firestore";
847
+ var MAX_ACTIVITY_IN_PROMPT = 40;
848
+ var MAX_PREVIOUS_JOBS_READ = 5;
849
+ async function loadJobContext(db, shipId, job) {
850
+ const shipRef = doc(db, COLLECTIONS.ships, shipId);
851
+ const taskRef = doc(shipRef, COLLECTIONS.tasks, job.taskId);
852
+ const [shipSnap, agentSnap, taskSnap, activitySnap, jobsSnap, indexSnap] = await Promise.all([
853
+ getDoc(shipRef),
854
+ getDoc(doc(shipRef, COLLECTIONS.agents, job.agentId)),
855
+ getDoc(taskRef),
856
+ // DESC + reverse, not ASC + limit: a limit takes the FIRST rows the order produces, so an
857
+ // ascending query with a limit would hand the agent the OLDEST 40 events and hide everything
858
+ // that has happened since — the exact opposite of what continuity needs. The extra row is
859
+ // how truncation is detected without a second count query.
860
+ getDocs(
861
+ query(
862
+ collection(taskRef, COLLECTIONS.activity),
863
+ orderBy("createdAt", "desc"),
864
+ limit(MAX_ACTIVITY_IN_PROMPT + 1)
865
+ )
866
+ ),
867
+ // The extra row here is for a different reason: this job itself is usually the newest match
868
+ // and is filtered out below, so without it a full window yields one report short.
869
+ getDocs(
870
+ query(
871
+ collection(shipRef, COLLECTIONS.jobs),
872
+ where("taskId", "==", job.taskId),
873
+ orderBy("createdAt", "desc"),
874
+ limit(MAX_PREVIOUS_JOBS_READ + 1)
875
+ )
876
+ ),
877
+ // The knowledge CATALOG — one document, so this is +1 read regardless of how much the Ship
878
+ // knows, and it rides the existing Promise.all so it costs no extra latency either. The
879
+ // agent's memory is free: it is a field on the agent doc already being fetched above.
880
+ //
881
+ // ENRICHMENT, not identity, exactly like the playbook read below: a Ship whose catalog is
882
+ // missing, unreadable or not yet deployed still has a perfectly valid session. Caught rather
883
+ // than thrown, and deliberately NOT placed where a rejection would propagate.
884
+ getDoc(doc(shipRef, COLLECTIONS.indexes, INDEX_DOCS.knowledge)).catch(() => null)
885
+ ]);
886
+ if (!shipSnap.exists() || !agentSnap.exists() || !taskSnap.exists()) {
887
+ throw new Error("Job context incomplete: ship, agent or task missing.");
888
+ }
889
+ const task = { id: taskSnap.id, ...taskSnap.data() };
890
+ let parentTask = null;
891
+ if (task.parentTaskId) {
892
+ const parentSnap = await getDoc(doc(shipRef, COLLECTIONS.tasks, task.parentTaskId));
893
+ if (parentSnap.exists()) {
894
+ parentTask = { id: parentSnap.id, ...parentSnap.data() };
895
+ }
896
+ }
897
+ let workflow = null;
898
+ if (!isWorkflowSentinel(job.workflowId)) {
899
+ try {
900
+ const wfSnap = await getDoc(
901
+ doc(shipRef, COLLECTIONS.agents, job.agentId, COLLECTIONS.workflows, job.workflowId)
902
+ );
903
+ workflow = usableWorkflow(
904
+ wfSnap.exists() ? { id: wfSnap.id, ...wfSnap.data() } : null
905
+ );
906
+ } catch {
907
+ workflow = null;
908
+ }
909
+ }
910
+ const activityDocs = activitySnap.docs.slice(0, MAX_ACTIVITY_IN_PROMPT);
911
+ const agent = { id: agentSnap.id, ...agentSnap.data() };
912
+ return {
913
+ ship: { id: shipSnap.id, ...shipSnap.data() },
914
+ agent,
915
+ task,
916
+ parentTask,
917
+ workflow,
918
+ memory: readMemory(agent.memory),
919
+ knowledgeIndex: indexSnap?.exists() ? indexSnap.data() : null,
920
+ activity: activityDocs.map((d) => ({ id: d.id, ...d.data() })).reverse(),
921
+ // read newest-first, rendered chronologically
922
+ activityTruncated: activitySnap.docs.length > MAX_ACTIVITY_IN_PROMPT,
923
+ previousReports: jobsSnap.docs.filter((d) => d.id !== job.id).slice(0, MAX_PREVIOUS_JOBS_READ).map((d) => d.data().report).filter((r) => !!r).reverse()
924
+ // chronological
925
+ };
926
+ }
927
+ function standingRules(ship2, statuses, task, agent) {
928
+ const review = firstStatusIn(statuses, "review");
929
+ const done = firstStatusIn(statuses, "done");
930
+ const finishRule = "Communicate in the task activity (task_comment). When finished, post a summary comment and set the task status honestly with task_update_status" + (review && done ? `: \`${review.id}\` if a human must check your work, \`${done.id}\` only if nothing is pending.` : done ? ` (\`${done.id}\` only if nothing is pending).` : ".");
931
+ const depth = task.chainDepth ?? 0;
932
+ const maxDepth = ship2.settings?.maxChainDepth ?? DEFAULT_SHIP_SETTINGS.maxChainDepth;
933
+ const delegationRule = depth >= maxDepth ? `Do NOT delegate: this task is at chain depth ${depth}, this Ship's maximum \u2014 task_create would refuse a child. Do the work yourself, or hand the task back to a human with task_assign.` : `You may delegate: create a task with task_create and give it to another agent (or a human) with task_assign. Depth is tracked for you \u2014 this task is at depth ${depth} of a maximum ${maxDepth}, and each chain has a capped number of runs.`;
934
+ const routingRule = "When you learn something, ask these in order. (1) Would anyone but YOU need this? If yes, knowledge_write \u2014 a slug, a title and a one-line summary; that summary is all any other agent sees until they open it. (2) Will it still be true next month? If not, it belongs in a task_comment, not a store. (3) Is it about how this Ship works, or about this one task? Ship \u2192 knowledge. Task \u2192 a comment. (4) Is it useful only to you \u2014 a preference, a gotcha in your own workflow? Then memory_write. When in doubt, Ship knowledge: a note only you can read helps nobody else.";
935
+ return [
936
+ `You are crew on Ship "${ship2.name}". Work only on the task given below.`,
937
+ "Stay inside your contract. If the task is outside your responsibility range, do not do it: comment why via task_comment and set the task status back with task_update_status.",
938
+ // A BOUNDARY rule, so it sits with the delegation one rather than near the finishing
939
+ // instructions — and before them, because a level-1 agent must not read "set the status to
940
+ // done" as permission it does not have.
941
+ autonomyRule(agent),
942
+ delegationRule,
943
+ routingRule,
944
+ finishRule,
945
+ // Amended with the pointer clause, which is the only thing keeping the three tiers from
946
+ // duplicating each other: without it a diligent agent writes the same fact into its report,
947
+ // its notebook and the org brain, and the report grows into a transcript of the other two.
948
+ "End every session by calling run_report: what you did, what remains, what the next run must know. Do not repeat what you already put in your memory or in Ship knowledge \u2014 point at it instead (the entry id, or the slug)."
949
+ ].join("\n- ");
950
+ }
951
+ function formatTaskDate(millis, now = Date.now()) {
952
+ const d = new Date(millis);
953
+ const pad = (n) => String(n).padStart(2, "0");
954
+ const day = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
955
+ const midnight = (t) => new Date(t.getFullYear(), t.getMonth(), t.getDate()).getTime();
956
+ const days = Math.round((midnight(d) - midnight(new Date(now))) / 864e5);
957
+ const distance = days === 0 ? "today" : days === 1 ? "tomorrow" : days === -1 ? "yesterday" : days > 0 ? `in ${days} days` : `${-days} days ago`;
958
+ return `${day} (${distance})`;
959
+ }
960
+ function statusVocabulary(statuses) {
961
+ const lines = statuses.map(
962
+ (s) => `- \`${s.id}\` \u2014 ${s.label}: ${TASK_STATUS_CATEGORY_META[s.category].meaning}`
963
+ );
964
+ return `# Task statuses on this board
965
+
966
+ Use these ids with task_update_status:
967
+
968
+ ${lines.join("\n")}`;
969
+ }
970
+ function buildPrompt(ctx, reason) {
971
+ const parts = [];
972
+ const statuses = shipTaskStatuses(ctx.ship);
973
+ const playbook = usableWorkflow(ctx.workflow);
974
+ if (reason === "activity") {
975
+ parts.push(
976
+ "# Why this session started\n\nSomeone posted on your task after your last run. Read the newest activity below" + (playbook ? ", then follow your playbook below." : ", respond via task_comment, do any follow-up work it asks for, and set the task status honestly if it needs to change.")
977
+ );
978
+ } else if (reason === "watch") {
979
+ parts.push(
980
+ "# Why this session started\n\nOne of your playbooks watches the board, and this task matched it. Nobody assigned it to you and nobody else is working it right now \u2014 you are picking it up.\n\n**Leave it in a status that says what happened.** The board is how the watch decides what still needs attention, so a task you leave in the same status will come back to you on the next scan" + (playbook ? ", which your playbook below is written for." : ".")
981
+ );
982
+ } else if (reason === "resume_after_approval") {
983
+ parts.push(
984
+ "# 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."
985
+ );
986
+ } else if (reason === "schedule") {
987
+ parts.push(
988
+ "# 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." : ".")
989
+ );
990
+ }
991
+ parts.push(`# Who you are
992
+
993
+ ${ctx.agent.persona || `You are ${ctx.agent.name}.`}`);
994
+ if (ctx.agent.contract.trim()) {
995
+ parts.push(`# Your contract
996
+
997
+ ${ctx.agent.contract}`);
998
+ }
999
+ parts.push(memoryBlock(ctx.memory));
1000
+ parts.push(knowledgeCatalogBlock(ctx.knowledgeIndex, ctx.task.labels ?? []));
1001
+ parts.push(`# Standing rules
1002
+
1003
+ - ${standingRules(ctx.ship, statuses, ctx.task, ctx.agent)}`);
1004
+ parts.push(statusVocabulary(statuses));
1005
+ if (playbook) {
1006
+ parts.push(
1007
+ `# Your playbook: ${playbook.name}
1008
+
1009
+ This is the playbook for this kind of work \u2014 follow it. Your contract and the standing rules above still bind you: where they conflict with the playbook, they win.
1010
+
1011
+ ${playbook.instructions}`
1012
+ );
1013
+ }
1014
+ const t = ctx.task;
1015
+ const now = Date.now();
1016
+ const statusLabel = statusById(statuses, t.status)?.label ?? t.status;
1017
+ const dates = (typeof t.dueDate === "number" && t.dueDate ? `
1018
+
1019
+ Deadline: ${formatTaskDate(t.dueDate, now)} \u2014 the work must be FINISHED by this date. It does not affect when you start.` : "") + (typeof t.startAt === "number" && t.startAt > now ? `
1020
+
1021
+ Scheduled start: ${formatTaskDate(t.startAt, now)} \u2014 this run was started before that date.` : "");
1022
+ parts.push(
1023
+ `# The task (id: ${t.id})
1024
+
1025
+ ## ${t.title}
1026
+
1027
+ ${t.description || "(no description)"}
1028
+
1029
+ Labels: ${t.labels.join(", ") || "none"} \xB7 Status: ${statusLabel} (\`${t.status}\`)` + dates
1030
+ );
1031
+ if (ctx.parentTask) {
1032
+ const p = ctx.parentTask;
1033
+ const parentStatus = statusById(statuses, p.status)?.label ?? p.status;
1034
+ parts.push(
1035
+ `# Delegated from (id: ${p.id})
1036
+
1037
+ ## ${p.title}
1038
+
1039
+ Status: ${parentStatus}
1040
+
1041
+ ${p.description || "(no description)"}`
1042
+ );
1043
+ }
1044
+ if (ctx.activity.length > 0) {
1045
+ const feed = ctx.activity.map((e) => `- [${e.kind}] ${e.author.type}:${e.author.id} \u2014 ${e.content}`).join("\n");
1046
+ const note2 = ctx.activityTruncated ? `Only the newest ${MAX_ACTIVITY_IN_PROMPT} events are shown. Earlier ones \u2014 including how this task started \u2014 are not here: call task_get for the full feed if you need them.
1047
+
1048
+ ` : "";
1049
+ parts.push(`# Task activity so far
1050
+
1051
+ ${note2}${feed}`);
1052
+ }
1053
+ if (ctx.previousReports.length > 0) {
1054
+ const reports = ctx.previousReports.map((r, i) => `## Run ${i + 1}
1055
+
1056
+ ${r}`).join("\n\n");
1057
+ parts.push(`# Reports from previous runs on this task
1058
+
1059
+ ${reports}`);
1060
+ }
1061
+ if (ctx.agent.tools.github.enabled && ctx.agent.tools.github.repos.length > 0) {
1062
+ parts.push(
1063
+ `# GitHub
1064
+
1065
+ gh + git are authenticated for these repositories via a short-lived GitHub App token (expires in ~1 h): ${ctx.agent.tools.github.repos.join(", ")}`
1066
+ );
1067
+ }
1068
+ return parts.join("\n\n");
1069
+ }
1070
+
1071
+ // src/jobs/chatContext.ts
1072
+ import {
1073
+ collection as collection2,
1074
+ doc as doc2,
1075
+ getCountFromServer,
1076
+ getDoc as getDoc2,
1077
+ getDocs as getDocs2,
1078
+ limit as limit2,
1079
+ orderBy as orderBy2,
1080
+ query as query2,
1081
+ where as where2
1082
+ } from "firebase/firestore";
1083
+ var MAX_CHAT_REPORTS = 5;
1084
+ var actorKey = (a) => `${a.type}:${a.id}`;
1085
+ async function loadChatContext(db, shipId, job) {
1086
+ const shipRef = doc2(db, COLLECTIONS.ships, shipId);
1087
+ const chatRef = doc2(shipRef, COLLECTIONS.chats, job.chatId);
1088
+ const messagesCol = collection2(chatRef, COLLECTIONS.chatMessages);
1089
+ const [shipSnap, agentSnap, chatSnap, messagesSnap, countSnap, membersSnap, agentsSnap, indexSnap] = await Promise.all([
1090
+ getDoc2(shipRef),
1091
+ getDoc2(doc2(shipRef, COLLECTIONS.agents, job.agentId)),
1092
+ getDoc2(chatRef),
1093
+ getDocs2(query2(messagesCol, orderBy2("createdAt", "desc"), limit2(MAX_CHAT_MESSAGES_IN_PROMPT + 1))),
1094
+ // Aggregates bill per index entries scanned, not per document, so this stays ~1 read at
1095
+ // any thread length. Caught rather than thrown: a session that cannot count is still a
1096
+ // perfectly good session, it just cannot quote a total.
1097
+ getCountFromServer(messagesCol).catch(() => null),
1098
+ getDocs2(collection2(shipRef, COLLECTIONS.members)),
1099
+ getDocs2(collection2(shipRef, COLLECTIONS.agents)),
1100
+ // ENRICHMENT, not identity — same posture as the task pack: a Ship whose catalog is
1101
+ // missing or unreadable still has a valid session.
1102
+ getDoc2(doc2(shipRef, COLLECTIONS.indexes, INDEX_DOCS.knowledge)).catch(() => null)
1103
+ ]);
1104
+ if (!shipSnap.exists() || !agentSnap.exists() || !chatSnap.exists()) {
1105
+ throw new Error("Chat job context incomplete: ship, agent or chat missing.");
1106
+ }
1107
+ const agent = { id: agentSnap.id, ...agentSnap.data() };
1108
+ const chat = { id: chatSnap.id, ...chatSnap.data() };
1109
+ const names = {};
1110
+ for (const d of membersSnap.docs) {
1111
+ const m = d.data();
1112
+ names[`human:${d.id}`] = m.displayName || m.email || d.id;
1113
+ }
1114
+ for (const d of agentsSnap.docs) {
1115
+ names[`agent:${d.id}`] = d.data().name || d.id;
1116
+ }
1117
+ const messageDocs = messagesSnap.docs.slice(0, MAX_CHAT_MESSAGES_IN_PROMPT);
1118
+ const messagesTruncated = messagesSnap.docs.length > MAX_CHAT_MESSAGES_IN_PROMPT;
1119
+ let previousReports = [];
1120
+ if (messagesTruncated) {
1121
+ try {
1122
+ const jobsSnap = await getDocs2(
1123
+ query2(
1124
+ collection2(shipRef, COLLECTIONS.jobs),
1125
+ where2("chatId", "==", job.chatId),
1126
+ orderBy2("createdAt", "desc"),
1127
+ limit2(MAX_CHAT_REPORTS + 1)
1128
+ )
1129
+ );
1130
+ previousReports = jobsSnap.docs.filter((d) => d.id !== job.id).slice(0, MAX_CHAT_REPORTS).map((d) => d.data().report).filter((r) => !!r).reverse();
1131
+ } catch {
1132
+ previousReports = [];
1133
+ }
1134
+ }
1135
+ return {
1136
+ ship: { id: shipSnap.id, ...shipSnap.data() },
1137
+ agent,
1138
+ chat,
1139
+ messages: messageDocs.map((d) => ({ id: d.id, ...d.data() })).reverse(),
1140
+ // read newest-first, rendered chronologically
1141
+ messagesTruncated,
1142
+ totalMessages: countSnap?.data().count ?? messageDocs.length,
1143
+ names,
1144
+ previousReports,
1145
+ memory: readMemory(agent.memory),
1146
+ knowledgeIndex: indexSnap?.exists() ? indexSnap.data() : null
1147
+ };
1148
+ }
1149
+ function chatStandingRules(ship2, agent) {
1150
+ const approvalRule = "If something needs permission you do not have, do NOT ask for approval here \u2014 a chat has nothing to approve against. Create a task with task_create describing what you want to do, then say so in the chat; a captain can approve it there.";
1151
+ const replyRule = "Answer in the chat with chat_send. That is how the person hears from you \u2014 nothing else you do in this session is visible to them.";
1152
+ const turnRule = "Only a message from a PERSON starts a session like this one. Your own reply does not wake you again, so finish your thought in one reply rather than promising to continue.";
1153
+ const scopeRule = "If this turns into real work, make it a task with task_create and say in the chat that you did, naming it. Do not run multi-step work here: a chat has no status, no tracking and no report anyone will read.";
1154
+ const registerRule = "Match the register of the conversation. A question deserves an answer, not a status report.";
1155
+ const routingRule = "When you learn something, ask these in order. (1) Would anyone but YOU need this? If yes, knowledge_write \u2014 a slug, a title and a one-line summary; that summary is all any other agent sees until they open it. (2) Will it still be true next month? If not, leave it in the conversation. (3) Is it about how this Ship works, or about this one exchange? Ship \u2192 knowledge. Passing \u2192 nothing. (4) Is it useful only to you \u2014 a preference, a gotcha in your own workflow? Then memory_write. When in doubt, Ship knowledge: a note only you can read helps nobody else.";
1156
+ return [
1157
+ `You are crew on Ship "${ship2.name}". You are in a conversation.`,
1158
+ "Stay inside your contract. If you are asked for something outside your responsibility range, say so plainly and point at who or what should handle it.",
1159
+ autonomyRule(agent),
1160
+ approvalRule,
1161
+ replyRule,
1162
+ turnRule,
1163
+ scopeRule,
1164
+ registerRule,
1165
+ routingRule,
1166
+ "End the session by calling run_report: what was discussed and anything the next run must know. Do not repeat what you already put in your memory or in Ship knowledge \u2014 point at it instead."
1167
+ ].join("\n- ");
1168
+ }
1169
+ function buildChatPrompt(ctx) {
1170
+ const parts = [];
1171
+ const name = (a) => ctx.names[actorKey(a)] ?? actorKey(a);
1172
+ parts.push(
1173
+ "# Why this session started\n\nSomeone wrote to you in a chat. **This is a conversation, not a task**: nobody has assigned you work, there is no board status to move, and nothing here is tracked. Read the thread below and reply with `chat_send`."
1174
+ );
1175
+ parts.push(`# Who you are
1176
+
1177
+ ${ctx.agent.persona || `You are ${ctx.agent.name}.`}`);
1178
+ if (ctx.agent.contract.trim()) {
1179
+ parts.push(`# Your contract
1180
+
1181
+ ${ctx.agent.contract}`);
1182
+ }
1183
+ parts.push(memoryBlock(ctx.memory));
1184
+ parts.push(knowledgeCatalogBlock(ctx.knowledgeIndex, []));
1185
+ parts.push(`# Standing rules
1186
+
1187
+ - ${chatStandingRules(ctx.ship, ctx.agent)}`);
1188
+ if (ctx.previousReports.length > 0) {
1189
+ const reports = ctx.previousReports.map((r, i) => `## Earlier run ${i + 1}
1190
+
1191
+ ${r}`).join("\n\n");
1192
+ parts.push(
1193
+ `# Earlier in this chat
1194
+
1195
+ The conversation is longer than the window below. These are your own notes from previous runs on this chat, oldest first.
1196
+
1197
+ ${reports}`
1198
+ );
1199
+ }
1200
+ const title = ctx.chat.title?.trim();
1201
+ const who = ctx.chat.participants.map((p) => `${name(p)} (${p.type})`).join(", ");
1202
+ const thread = ctx.messages.map((m) => `- ${name(m.author)} (${m.author.type}): ${m.content}`).join("\n");
1203
+ const note2 = ctx.messagesTruncated ? `Showing the last ${ctx.messages.length} of ${ctx.totalMessages} messages. For older ones, call \`chat_get\` with this chat id and a \`before\` timestamp \u2014 the oldest message shown below is the cursor to start from.
1204
+
1205
+ ` : "";
1206
+ parts.push(
1207
+ `# The conversation${title ? `: ${title}` : ""}
1208
+
1209
+ Participants: ${who}
1210
+
1211
+ ${note2}${thread || "(no messages yet)"}`
1212
+ );
1213
+ if (ctx.agent.tools.github.enabled && ctx.agent.tools.github.repos.length > 0) {
1214
+ parts.push(
1215
+ `# GitHub
1216
+
1217
+ gh + git are authenticated for these repositories via a short-lived GitHub App token (expires in ~1 h): ${ctx.agent.tools.github.repos.join(", ")}
1218
+
1219
+ Note: a chat session has no task, so it is never granted write access \u2014 you can read and inspect, not push.`
1220
+ );
1221
+ }
1222
+ return parts.join("\n\n");
1223
+ }
1224
+
1225
+ // src/engines/claude.ts
1226
+ import { spawn as spawn3 } from "node:child_process";
1227
+ import fs3 from "node:fs";
1228
+ import os3 from "node:os";
1229
+ import path3 from "node:path";
1230
+ function normalizeClaudeUsage(resultEvent, model, fallbackDurationS) {
1231
+ const result = resultEvent ?? {};
1232
+ const u = result.usage ?? {};
1233
+ const cacheRead = u.cache_read_input_tokens ?? 0;
1234
+ const inputTokens = (u.input_tokens ?? 0) + cacheRead + (u.cache_creation_input_tokens ?? 0);
1235
+ const outputTokens = u.output_tokens ?? 0;
1236
+ const costReported = typeof result.total_cost_usd === "number";
1237
+ const usage = {
1238
+ engine: CLAUDE_DRIVER_ID,
1239
+ inputTokens,
1240
+ cachedInputTokens: cacheRead,
1241
+ outputTokens,
1242
+ costUsd: costReported ? result.total_cost_usd : estimateCostUsd(CLAUDE_DRIVER_ID, model, {
1243
+ inputTokens,
1244
+ cachedInputTokens: cacheRead,
1245
+ outputTokens
1246
+ }),
1247
+ costReported,
1248
+ durationS: result.duration_ms ? Math.round(result.duration_ms / 1e3) : fallbackDurationS,
1249
+ raw: { ...u }
1250
+ };
1251
+ return { usage, result };
1252
+ }
1253
+ var LIMIT_RE = /usage limit (?:reached|exceeded|has been reached)/i;
1254
+ var LIMIT_RESET_RE = /\|\s*(\d{10})\b/;
1255
+ var MAX_LIMIT_MS = 7 * 24 * 60 * 60 * 1e3 + 60 * 60 * 1e3;
1256
+ function detectClaudeLimit(text, now, fallbackMs) {
1257
+ if (!text || !LIMIT_RE.test(text)) return void 0;
1258
+ const match = LIMIT_RESET_RE.exec(text);
1259
+ const reported = match ? Number(match[1]) * 1e3 : NaN;
1260
+ const sane = Number.isFinite(reported) && reported > now && reported <= now + MAX_LIMIT_MS;
1261
+ return {
1262
+ resetsAt: sane ? reported : now + fallbackMs,
1263
+ resetsAtReported: sane,
1264
+ detail: text.trim().slice(0, 300)
1265
+ };
1266
+ }
1267
+ function allowedTools(agent) {
1268
+ const granted = effectiveAgentTools(agent);
1269
+ const tools = [];
1270
+ if (granted.workspaceMcp) tools.push("mcp__workspace");
1271
+ if (granted.bash) tools.push("Bash");
1272
+ if (granted.github.enabled) tools.push("Bash");
1273
+ if (granted.webSearch) tools.push("WebSearch", "WebFetch");
1274
+ const draftsOnly = agent.autonomy <= 1;
1275
+ tools.push("Read");
1276
+ if (!draftsOnly) tools.push("Write", "Edit");
1277
+ tools.push("Glob", "Grep");
1278
+ return [...new Set(tools)];
1279
+ }
1280
+ function createSessionDirs(jobId) {
1281
+ const workdir = fs3.mkdtempSync(path3.join(os3.tmpdir(), `crew-job-${jobId}-`));
1282
+ const configDir2 = fs3.mkdtempSync(path3.join(os3.tmpdir(), `crew-cfg-${jobId}-`));
1283
+ return { workdir, configDir: configDir2, mcpConfigPath: path3.join(configDir2, "mcp.json") };
1284
+ }
1285
+ function buildMcpConfig(input) {
1286
+ return {
1287
+ mcpServers: {
1288
+ workspace: {
1289
+ type: "http",
1290
+ url: input.mcpUrl,
1291
+ headers: {
1292
+ Authorization: `Bearer ${input.idToken}`,
1293
+ "X-Crew-Ship-Id": input.shipId,
1294
+ // Agent and task are sent for the audit trail and for external-client parity, but on
1295
+ // the runner path the server no longer TRUSTS them: it reads the acting agent and the
1296
+ // session task from this job's own doc. `X-Crew-Job-Id` is what it keys on, so it is
1297
+ // required there — dropping it now fails auth rather than silently ungating the call.
1298
+ "X-Crew-Agent-Id": input.agent.id,
1299
+ "X-Crew-Job-Id": input.job.id,
1300
+ // Exactly one of these, spread conditionally: a chat job has no task and a task job no
1301
+ // chat. An `undefined` value here would be serialized into the config as a header with
1302
+ // no value, which is a different thing from an absent header.
1303
+ ...input.job.taskId ? { "X-Crew-Task-Id": input.job.taskId } : {},
1304
+ ...input.job.chatId ? { "X-Crew-Chat-Id": input.job.chatId } : {}
1305
+ }
1306
+ }
1307
+ }
1308
+ };
1309
+ }
1310
+ function writeMcpConfig(dirs, input) {
1311
+ fs3.writeFileSync(dirs.mcpConfigPath, JSON.stringify(buildMcpConfig(input)), { mode: 384 });
1312
+ }
1313
+ function cleanupSessionDirs(dirs) {
1314
+ fs3.rmSync(dirs.workdir, { recursive: true, force: true });
1315
+ fs3.rmSync(dirs.configDir, { recursive: true, force: true });
1316
+ }
1317
+ async function runClaudeSession(input) {
1318
+ const bin = process.env.CREW_CLAUDE_BIN || "claude";
1319
+ if (input.signal?.aborted) {
1320
+ return {
1321
+ ok: false,
1322
+ transcript: "",
1323
+ usage: emptyUsage(),
1324
+ resultText: "Session cancelled before it started (runner shutting down)."
1325
+ };
1326
+ }
1327
+ const dirs = createSessionDirs(input.job.id);
1328
+ try {
1329
+ writeMcpConfig(dirs, input);
1330
+ return await runSession(input, bin, dirs);
1331
+ } finally {
1332
+ cleanupSessionDirs(dirs);
1333
+ }
1334
+ }
1335
+ async function runSession(input, bin, dirs) {
1336
+ const args = [
1337
+ "-p",
1338
+ input.prompt,
1339
+ "--output-format",
1340
+ "stream-json",
1341
+ "--verbose",
1342
+ "--model",
1343
+ input.agent.model,
1344
+ "--mcp-config",
1345
+ dirs.mcpConfigPath,
1346
+ "--strict-mcp-config",
1347
+ "--allowedTools",
1348
+ allowedTools(input.agent).join(",")
1349
+ ];
1350
+ const claudeToken = input.secrets?.claudeToken;
1351
+ const env = {
1352
+ ...process.env,
1353
+ ...claudeToken ? { CLAUDE_CODE_OAUTH_TOKEN: claudeToken } : {},
1354
+ ...input.githubToken ? {
1355
+ GH_TOKEN: input.githubToken,
1356
+ GITHUB_TOKEN: input.githubToken,
1357
+ // Per-process git auth so bare `git clone https://github.com/o/r` works WITHOUT
1358
+ // touching the machine's ~/.gitconfig. A GitHub App installation token authenticates
1359
+ // as `x-access-token:<token>`; a classic PAT works the same way here.
1360
+ GIT_CONFIG_COUNT: "1",
1361
+ GIT_CONFIG_KEY_0: "url.https://x-access-token:" + input.githubToken + "@github.com/.insteadOf",
1362
+ GIT_CONFIG_VALUE_0: "https://github.com/"
1363
+ } : {}
1364
+ };
1365
+ const startedAt = Date.now();
1366
+ const lines = [];
1367
+ const stderrLines = [];
1368
+ let resultEvent = null;
1369
+ const exitCode = await new Promise((resolve) => {
1370
+ const child = spawn3(bin, args, { cwd: dirs.workdir, env, stdio: ["ignore", "pipe", "pipe"] });
1371
+ const killTimer = setTimeout(() => {
1372
+ input.log(`Session timeout after ${Math.round(input.timeoutMs / 6e4)} min \u2014 killing.`);
1373
+ child.kill("SIGTERM");
1374
+ setTimeout(() => child.kill("SIGKILL"), 1e4).unref();
1375
+ }, input.timeoutMs);
1376
+ const onAbort = () => {
1377
+ input.log("Shutdown signalled \u2014 terminating the session.");
1378
+ child.kill("SIGTERM");
1379
+ setTimeout(() => child.kill("SIGKILL"), 3e3).unref();
1380
+ };
1381
+ input.signal?.addEventListener("abort", onAbort, { once: true });
1382
+ const detachAbort = () => input.signal?.removeEventListener("abort", onAbort);
1383
+ let buffer = "";
1384
+ child.stdout.on("data", (chunk) => {
1385
+ buffer += chunk.toString("utf8");
1386
+ for (; ; ) {
1387
+ const nl = buffer.indexOf("\n");
1388
+ if (nl < 0) break;
1389
+ const line = buffer.slice(0, nl).trim();
1390
+ buffer = buffer.slice(nl + 1);
1391
+ if (!line) continue;
1392
+ lines.push(line);
1393
+ try {
1394
+ const event = JSON.parse(line);
1395
+ if (event.type === "result") resultEvent = event;
1396
+ if (event.type === "assistant") input.log("claude: assistant turn");
1397
+ } catch {
1398
+ }
1399
+ }
1400
+ });
1401
+ child.stderr.on("data", (chunk) => {
1402
+ const text = chunk.toString("utf8").trim();
1403
+ if (text) {
1404
+ lines.push(JSON.stringify({ type: "stderr", text }));
1405
+ stderrLines.push(text);
1406
+ input.log(`claude stderr: ${text.slice(0, 200)}`);
1407
+ }
1408
+ });
1409
+ child.on("error", (e) => {
1410
+ lines.push(JSON.stringify({ type: "stderr", text: `spawn error: ${e.message}` }));
1411
+ clearTimeout(killTimer);
1412
+ detachAbort();
1413
+ resolve(127);
1414
+ });
1415
+ child.on("close", (code) => {
1416
+ clearTimeout(killTimer);
1417
+ detachAbort();
1418
+ resolve(code ?? 1);
1419
+ });
1420
+ });
1421
+ const durationS = Math.round((Date.now() - startedAt) / 1e3);
1422
+ const { usage, result } = normalizeClaudeUsage(resultEvent, input.agent.model, durationS);
1423
+ const ok2 = exitCode === 0 && !!resultEvent && !result.is_error;
1424
+ const resultText = result.result ?? (ok2 ? "" : `Session ended with exit code ${exitCode}${resultEvent ? "" : " and no result event"}.`);
1425
+ const limit3 = ok2 ? void 0 : detectClaudeLimit(
1426
+ `${resultText}
1427
+ ${stderrLines.slice(-20).join("\n")}`,
1428
+ Date.now(),
1429
+ engineUsageWindows(CLAUDE_DRIVER_ID)?.fallbackMs ?? 5 * 60 * 60 * 1e3
1430
+ );
1431
+ return {
1432
+ ok: ok2,
1433
+ transcript: `${lines.join("\n")}
1434
+ `,
1435
+ usage,
1436
+ resultText,
1437
+ ...limit3 ? { limit: limit3 } : {}
1438
+ };
1439
+ }
1440
+ var CLAUDE_DRIVER_ID = "claude";
1441
+ function emptyUsage() {
1442
+ return {
1443
+ engine: CLAUDE_DRIVER_ID,
1444
+ inputTokens: 0,
1445
+ cachedInputTokens: 0,
1446
+ outputTokens: 0,
1447
+ costUsd: 0,
1448
+ costReported: false,
1449
+ durationS: 0
1450
+ };
1451
+ }
1452
+ async function claudeHealthCheck() {
1453
+ const bin = process.env.CREW_CLAUDE_BIN || "claude";
1454
+ return new Promise((resolve) => {
1455
+ let settled = false;
1456
+ const done = (health) => {
1457
+ if (settled) return;
1458
+ settled = true;
1459
+ resolve(health);
1460
+ };
1461
+ let stdout = "";
1462
+ const child = spawn3(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
1463
+ const timer = setTimeout(() => {
1464
+ child.kill("SIGKILL");
1465
+ done({ ok: false, detail: `\`${bin} --version\` timed out`, fix: "Check the Claude CLI install." });
1466
+ }, 1e4);
1467
+ child.stdout.on("data", (chunk) => {
1468
+ stdout += chunk.toString("utf8");
1469
+ });
1470
+ child.on("error", () => {
1471
+ clearTimeout(timer);
1472
+ done({
1473
+ ok: false,
1474
+ detail: `\`${bin}\` not found on PATH`,
1475
+ fix: "Install the Claude CLI (https://claude.com/claude-code), or set CREW_CLAUDE_BIN to its path."
1476
+ });
1477
+ });
1478
+ child.on("close", (code) => {
1479
+ clearTimeout(timer);
1480
+ const version = stdout.trim().split("\n")[0] || "(no version reported)";
1481
+ done(
1482
+ code === 0 ? { ok: true, detail: `${bin} \u2014 ${version}` } : {
1483
+ ok: false,
1484
+ detail: `\`${bin} --version\` exited ${code}`,
1485
+ fix: "Reinstall or repair the Claude CLI."
1486
+ }
1487
+ );
1488
+ });
1489
+ });
1490
+ }
1491
+ var claudeDriver = {
1492
+ engineId: CLAUDE_DRIVER_ID,
1493
+ run: runClaudeSession,
1494
+ healthCheck: claudeHealthCheck
1495
+ };
1496
+
1497
+ // src/engines/index.ts
1498
+ var DRIVERS = {
1499
+ claude: claudeDriver
1500
+ };
1501
+ function getDriver(engineId) {
1502
+ return engineId && Object.hasOwn(DRIVERS, engineId) ? DRIVERS[engineId] : DRIVERS[DEFAULT_ENGINE_ID];
1503
+ }
1504
+
1505
+ // src/jobs/githubToken.ts
1506
+ import {
1507
+ doc as doc3,
1508
+ getDoc as getDoc3
1509
+ } from "firebase/firestore";
1510
+ var TerminalJobError = class extends Error {
1511
+ };
1512
+ async function readIntegration(db, shipId) {
1513
+ const snap = await getDoc3(
1514
+ doc3(db, COLLECTIONS.ships, shipId, COLLECTIONS.integrations, INTEGRATION_DOCS.github)
1515
+ );
1516
+ return snap.exists() ? snap.data() : null;
1517
+ }
1518
+ async function resolveGithubToken(input) {
1519
+ if (!input.agent.tools?.github?.enabled) return null;
1520
+ const integration = await readIntegration(input.db, input.shipId);
1521
+ if (integration?.status === "active") {
1522
+ try {
1523
+ const res = await callFunction(
1524
+ functionsBaseUrl(input.config),
1525
+ input.idToken,
1526
+ "mintGithubJobToken",
1527
+ { shipId: input.shipId, jobId: input.jobId }
1528
+ );
1529
+ return { token: res.token, source: "app" };
1530
+ } catch (e) {
1531
+ if (e instanceof CallableError && (e.status === "FAILED_PRECONDITION" || e.status === "PERMISSION_DENIED" || e.status === "NOT_FOUND")) {
1532
+ throw new TerminalJobError(e.message);
1533
+ }
1534
+ throw e;
1535
+ }
1536
+ }
1537
+ if (input.githubPat) return { token: input.githubPat, source: "pat" };
1538
+ return null;
1539
+ }
1540
+
1541
+ // src/jobs/secrets.ts
1542
+ import { doc as doc4, getDoc as getDoc4 } from "firebase/firestore";
1543
+ async function loadRunnerSecrets(db, shipId) {
1544
+ const snap = await getDoc4(
1545
+ doc4(db, COLLECTIONS.ships, shipId, COLLECTIONS.secrets, SECRET_DOCS.runner)
1546
+ );
1547
+ return snap.exists() ? snap.data() : null;
1548
+ }
1549
+ function missingSecretsFor(engineId, secrets) {
1550
+ return getEngine(engineId).requiredSecrets.filter((req) => !secrets?.[req.key]).map((req) => req.label);
1551
+ }
1552
+
1553
+ // src/jobs/engineLimits.ts
1554
+ import {
1555
+ collection as collection3,
1556
+ deleteDoc,
1557
+ doc as doc5,
1558
+ onSnapshot,
1559
+ setDoc
1560
+ } from "firebase/firestore";
1561
+ var limitKey = (shipId, engineId) => `${shipId}/${engineId}`;
1562
+ function isLimited(limits, shipId, engineId, now) {
1563
+ const entry = limits.get(limitKey(shipId, engineId));
1564
+ return !!entry && entry.resetsAt > now;
1565
+ }
1566
+ function pruneExpired(limits, now) {
1567
+ const cleared = [];
1568
+ for (const [key, entry] of limits) {
1569
+ if (entry.resetsAt <= now) {
1570
+ cleared.push(entry);
1571
+ limits.delete(key);
1572
+ }
1573
+ }
1574
+ return cleared;
1575
+ }
1576
+ function nextResetAt(limits, now) {
1577
+ let soonest = null;
1578
+ for (const entry of limits.values()) {
1579
+ if (entry.resetsAt > now && (soonest === null || entry.resetsAt < soonest)) {
1580
+ soonest = entry.resetsAt;
1581
+ }
1582
+ }
1583
+ return soonest;
1584
+ }
1585
+ function limitRef(db, shipId, engineId) {
1586
+ return doc5(db, COLLECTIONS.ships, shipId, COLLECTIONS.engineLimits, engineId);
1587
+ }
1588
+ async function noteEngineLimit(db, shipId, engineId, limit3, runnerId) {
1589
+ await setDoc(limitRef(db, shipId, engineId), {
1590
+ engineId,
1591
+ resetsAt: limit3.resetsAt,
1592
+ detectedAt: Date.now(),
1593
+ resetsAtReported: limit3.resetsAtReported,
1594
+ detail: limit3.detail.slice(0, 500),
1595
+ runnerId
1596
+ });
1597
+ }
1598
+ async function clearEngineLimit(db, shipId, engineId) {
1599
+ await deleteDoc(limitRef(db, shipId, engineId));
1600
+ }
1601
+ function subscribeEngineLimits(db, shipId, cb, onError) {
1602
+ return onSnapshot(
1603
+ collection3(db, COLLECTIONS.ships, shipId, COLLECTIONS.engineLimits),
1604
+ (snap) => cb(snap.docs.map((d) => ({ id: d.id, ...d.data() }))),
1605
+ (e) => onError?.(e)
1606
+ );
1607
+ }
1608
+
1609
+ // src/jobs/finish.ts
1610
+ import {
1611
+ addDoc,
1612
+ collection as collection4,
1613
+ doc as doc6,
1614
+ runTransaction,
1615
+ updateDoc
1616
+ } from "firebase/firestore";
1617
+ import { ref as storageRef, uploadBytes } from "firebase/storage";
1618
+ function redactTranscript(transcript, knownSecrets) {
1619
+ let out = transcript;
1620
+ for (const secret of knownSecrets) {
1621
+ if (secret && secret.length >= 8) out = out.split(secret).join("[REDACTED]");
1622
+ }
1623
+ out = out.replace(/sk-ant-[A-Za-z0-9_-]{8,}/g, "[REDACTED]");
1624
+ out = out.replace(/gh[pousr]_[A-Za-z0-9]{20,}/g, "[REDACTED]");
1625
+ out = out.replace(/github_pat_[A-Za-z0-9_]{20,}/g, "[REDACTED]");
1626
+ out = out.replace(/ya29\.[A-Za-z0-9_-]{20,}/g, "[REDACTED]");
1627
+ out = out.replace(/crewmcp_[A-Za-z0-9]+_[a-f0-9]{64}/g, "[REDACTED]");
1628
+ return out;
1629
+ }
1630
+ async function uploadTranscript(storage, shipId, jobId, redacted) {
1631
+ const path5 = `crew/${shipId}/transcripts/${jobId}.jsonl`;
1632
+ await uploadBytes(storageRef(storage, path5), new TextEncoder().encode(redacted), {
1633
+ contentType: "application/x-ndjson"
1634
+ });
1635
+ return path5;
1636
+ }
1637
+ function utcDay(millis) {
1638
+ return new Date(millis).toISOString().slice(0, 10);
1639
+ }
1640
+ async function finalizeJob(db, shipId, job, input) {
1641
+ const shipRef = doc6(db, COLLECTIONS.ships, shipId);
1642
+ const jobRef = doc6(shipRef, COLLECTIONS.jobs, job.id);
1643
+ const now = Date.now();
1644
+ const usageRef = doc6(shipRef, COLLECTIONS.usageDaily, utcDay(now));
1645
+ await runTransaction(db, async (tx) => {
1646
+ const usageSnap = await tx.get(usageRef);
1647
+ const totals = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
1648
+ const byAgent = usageSnap.data()?.byAgent ?? {};
1649
+ const agentAgg = {
1650
+ ...EMPTY_USAGE_BY_AGENT,
1651
+ ...byAgent[job.agentId] ?? {}
1652
+ };
1653
+ const u = input.usage;
1654
+ tx.update(jobRef, {
1655
+ status: input.status,
1656
+ endedAt: now,
1657
+ usage: u,
1658
+ transcriptPath: input.transcriptPath,
1659
+ ...input.error ? { error: input.error.slice(0, 1500) } : {}
1660
+ });
1661
+ tx.set(usageRef, {
1662
+ totals: {
1663
+ inputTokens: totals.inputTokens + u.inputTokens,
1664
+ cachedInputTokens: totals.cachedInputTokens + u.cachedInputTokens,
1665
+ outputTokens: totals.outputTokens + u.outputTokens,
1666
+ costUsd: totals.costUsd + u.costUsd,
1667
+ jobs: totals.jobs + 1,
1668
+ durationS: totals.durationS + u.durationS
1669
+ },
1670
+ byAgent: {
1671
+ ...byAgent,
1672
+ [job.agentId]: {
1673
+ inputTokens: agentAgg.inputTokens + u.inputTokens,
1674
+ cachedInputTokens: agentAgg.cachedInputTokens + u.cachedInputTokens,
1675
+ outputTokens: agentAgg.outputTokens + u.outputTokens,
1676
+ costUsd: agentAgg.costUsd + u.costUsd,
1677
+ jobs: agentAgg.jobs + 1
1678
+ }
1679
+ }
1680
+ });
1681
+ });
1682
+ }
1683
+ async function requeueForRetry(db, shipId, job, error) {
1684
+ await runTransaction(db, async (tx) => {
1685
+ tx.update(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
1686
+ status: "queued",
1687
+ attempt: job.attempt + 1,
1688
+ error: error.slice(0, 1500)
1689
+ });
1690
+ });
1691
+ }
1692
+ async function releaseJob(db, shipId, job, reason) {
1693
+ await updateDoc(doc6(db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id), {
1694
+ status: "queued",
1695
+ runnerId: "",
1696
+ startedAt: 0,
1697
+ error: reason.slice(0, 1500)
1698
+ });
1699
+ }
1700
+ async function markTaskFailed(db, shipId, job, error, statuses) {
1701
+ const shipRef = doc6(db, COLLECTIONS.ships, shipId);
1702
+ const taskRef = doc6(shipRef, COLLECTIONS.tasks, job.taskId);
1703
+ const now = Date.now();
1704
+ const failedStatus = firstStatusIn(statuses, "failed")?.id ?? "failed";
1705
+ await runTransaction(db, async (tx) => {
1706
+ tx.update(taskRef, { status: failedStatus, updatedAt: now });
1707
+ tx.set(doc6(collection4(taskRef, COLLECTIONS.activity)), {
1708
+ author: { type: "agent", id: job.agentId },
1709
+ createdAt: now,
1710
+ kind: "comment",
1711
+ content: `Job failed after ${job.attempt} attempts.
1712
+
1713
+ Error tail:
1714
+ \`\`\`
1715
+ ${error.slice(0, 800)}
1716
+ \`\`\``
1717
+ });
1718
+ });
1719
+ }
1720
+ async function markChatFailed(db, shipId, job, error) {
1721
+ const shipRef = doc6(db, COLLECTIONS.ships, shipId);
1722
+ const chatRef = doc6(shipRef, COLLECTIONS.chats, job.chatId);
1723
+ const now = Date.now();
1724
+ const content = `I could not finish replying \u2014 the run failed after ${job.attempt} attempt(s).
1725
+
1726
+ \`\`\`
1727
+ ${error.slice(0, 500)}
1728
+ \`\`\`
1729
+
1730
+ Write again to start a fresh run.`;
1731
+ await addDoc(collection4(chatRef, COLLECTIONS.chatMessages), {
1732
+ author: { type: "agent", id: job.agentId },
1733
+ content,
1734
+ chars: content.length,
1735
+ createdAt: now
1736
+ });
1737
+ }
1738
+
1739
+ // src/daemon.ts
1740
+ var HEARTBEAT_MS = 3e4;
1741
+ var JOB_TIMEOUT_MS = 20 * 60 * 1e3;
1742
+ var MAX_ATTEMPTS = 2;
1743
+ var SHUTDOWN_GRACE_MS = 1e4;
1744
+ async function startDaemon() {
1745
+ const moved = migrateLegacyDir();
1746
+ const config2 = requireConfig();
1747
+ if (config2.ships.length === 0) {
1748
+ console.error("No Ships assigned. Run: lumi-runner ship add <shipId>");
1749
+ process.exit(1);
1750
+ }
1751
+ const fb = initFirebase(config2);
1752
+ let user = await ensureSignedIn(fb);
1753
+ console.log(`Signed in as ${user.uid}, runner ${config2.runnerId}, ships: ${config2.ships.join(", ")}`);
1754
+ const logLines = [];
1755
+ const log2 = (line) => {
1756
+ const stamped = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}`;
1757
+ console.log(stamped);
1758
+ appendLog(stamped);
1759
+ logLines.push(stamped);
1760
+ if (logLines.length > 20) logLines.shift();
1761
+ };
1762
+ if (moved?.migrated) log2(`Moved runner config from ${moved.from} to ${moved.to}.`);
1763
+ const startedAt = Date.now();
1764
+ let currentJob = null;
1765
+ const secretsOk = /* @__PURE__ */ new Map();
1766
+ const enrolled = /* @__PURE__ */ new Set();
1767
+ const approved = /* @__PURE__ */ new Map();
1768
+ const warnedUnapproved = /* @__PURE__ */ new Set();
1769
+ const shipRunnerRef = (shipId) => doc7(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId);
1770
+ async function ensureEnrolled(shipId) {
1771
+ if (enrolled.has(shipId)) return;
1772
+ const snap = await getDoc5(shipRunnerRef(shipId));
1773
+ if (!snap.exists()) {
1774
+ await setDoc2(shipRunnerRef(shipId), {
1775
+ ownerUserId: user.uid,
1776
+ hostname: os4.hostname(),
1777
+ version: RUNNER_VERSION,
1778
+ status: "online",
1779
+ lastSeenAt: Date.now(),
1780
+ startedAt,
1781
+ keyPresent: secretsOk.get(shipId) ?? false,
1782
+ currentJob: null,
1783
+ lastLogLines: [],
1784
+ approved: false
1785
+ });
1786
+ }
1787
+ enrolled.add(shipId);
1788
+ }
1789
+ let becameEligible = false;
1790
+ let tokenStale = false;
1791
+ let beating = false;
1792
+ async function heartbeat() {
1793
+ if (beating) return;
1794
+ beating = true;
1795
+ const now = Date.now();
1796
+ try {
1797
+ await setDoc2(
1798
+ doc7(fb.db, COLLECTIONS.users, user.uid, COLLECTIONS.runners, config2.runnerId),
1799
+ {
1800
+ hostname: os4.hostname(),
1801
+ version: RUNNER_VERSION,
1802
+ status: "online",
1803
+ lastSeenAt: now,
1804
+ ships: config2.ships
1805
+ }
1806
+ );
1807
+ for (const shipId of config2.ships) {
1808
+ await ensureEnrolled(shipId);
1809
+ await setDoc2(
1810
+ shipRunnerRef(shipId),
1811
+ {
1812
+ hostname: os4.hostname(),
1813
+ version: RUNNER_VERSION,
1814
+ status: "online",
1815
+ lastSeenAt: now,
1816
+ startedAt,
1817
+ keyPresent: secretsOk.get(shipId) ?? false,
1818
+ currentJob: currentJob ?? null,
1819
+ lastLogLines: [...logLines]
1820
+ },
1821
+ { merge: true }
1822
+ );
1823
+ const snap = await getDoc5(shipRunnerRef(shipId));
1824
+ const isApproved = snap.data()?.approved === true;
1825
+ const seen = approved.has(shipId);
1826
+ const wasApproved = approved.get(shipId) === true;
1827
+ approved.set(shipId, isApproved);
1828
+ if (!isApproved && !warnedUnapproved.has(shipId)) {
1829
+ warnedUnapproved.add(shipId);
1830
+ log2(
1831
+ `Ship ${shipId}: awaiting captain approval \u2014 this machine is enrolled but idle. Approve it on the Ship's Daemons page.`
1832
+ );
1833
+ }
1834
+ if (isApproved && !wasApproved) {
1835
+ warnedUnapproved.delete(shipId);
1836
+ becameEligible = true;
1837
+ if (seen) tokenStale = true;
1838
+ }
1839
+ }
1840
+ } catch (e) {
1841
+ console.error("heartbeat failed:", e instanceof Error ? e.message : e);
1842
+ } finally {
1843
+ beating = false;
1844
+ }
1845
+ if (tokenStale) {
1846
+ tokenStale = false;
1847
+ try {
1848
+ user = await ensureSignedIn(fb);
1849
+ log2("Approval granted \u2014 session refreshed, picking up queued work.");
1850
+ } catch (e) {
1851
+ console.error("re-mint after approval failed:", e instanceof Error ? e.message : e);
1852
+ }
1853
+ }
1854
+ if (becameEligible) {
1855
+ becameEligible = false;
1856
+ poke();
1857
+ }
1858
+ }
1859
+ const pending = /* @__PURE__ */ new Map();
1860
+ const engineLimits = /* @__PURE__ */ new Map();
1861
+ const agentEngines = /* @__PURE__ */ new Map();
1862
+ const unsubs = [];
1863
+ for (const shipId of config2.ships) {
1864
+ const q = query3(
1865
+ collection5(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs),
1866
+ where3("status", "==", "queued"),
1867
+ orderBy3("createdAt", "asc")
1868
+ );
1869
+ unsubs.push(
1870
+ onSnapshot2(
1871
+ q,
1872
+ (snap) => {
1873
+ for (const key of [...pending.keys()]) {
1874
+ if (pending.get(key)?.shipId === shipId) pending.delete(key);
1875
+ }
1876
+ for (const d of snap.docs) {
1877
+ pending.set(`${shipId}/${d.id}`, {
1878
+ shipId,
1879
+ job: { id: d.id, ...d.data() }
1880
+ });
1881
+ }
1882
+ poke();
1883
+ },
1884
+ (e) => console.error(`jobs listener error (${shipId}):`, e.message)
1885
+ )
1886
+ );
1887
+ unsubs.push(
1888
+ onSnapshot2(
1889
+ collection5(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
1890
+ (snap) => {
1891
+ for (const d of snap.docs) {
1892
+ agentEngines.set(`${shipId}/${d.id}`, agentEngine(d.data()));
1893
+ }
1894
+ poke();
1895
+ },
1896
+ (e) => console.error(`agents listener error (${shipId}):`, e.message)
1897
+ )
1898
+ );
1899
+ unsubs.push(
1900
+ subscribeEngineLimits(
1901
+ fb.db,
1902
+ shipId,
1903
+ (docs) => {
1904
+ for (const key of [...engineLimits.keys()]) {
1905
+ if (engineLimits.get(key)?.shipId === shipId) engineLimits.delete(key);
1906
+ }
1907
+ for (const d of docs) {
1908
+ engineLimits.set(limitKey(shipId, d.engineId), {
1909
+ shipId,
1910
+ engineId: d.engineId,
1911
+ resetsAt: d.resetsAt,
1912
+ resetsAtReported: d.resetsAtReported,
1913
+ detail: d.detail
1914
+ });
1915
+ }
1916
+ sweepLimits();
1917
+ },
1918
+ (e) => console.error(`engine limits listener error (${shipId}):`, e.message)
1919
+ )
1920
+ );
1921
+ }
1922
+ const engineForJob = (shipId, job) => agentEngines.get(`${shipId}/${job.agentId}`) ?? DEFAULT_ENGINE_ID;
1923
+ let working = false;
1924
+ let pokeRequested = false;
1925
+ let shuttingDown = false;
1926
+ let sessionAbort = null;
1927
+ let inFlight = null;
1928
+ function poke() {
1929
+ if (working || shuttingDown) {
1930
+ pokeRequested = !shuttingDown;
1931
+ return;
1932
+ }
1933
+ void workLoop();
1934
+ }
1935
+ let limitTimer = null;
1936
+ function armLimitTimer() {
1937
+ if (limitTimer) {
1938
+ clearTimeout(limitTimer);
1939
+ limitTimer = null;
1940
+ }
1941
+ const at = nextResetAt(engineLimits, Date.now());
1942
+ if (at === null) return;
1943
+ limitTimer = setTimeout(sweepLimits, Math.max(0, at - Date.now()) + 1e3);
1944
+ limitTimer.unref();
1945
+ }
1946
+ function sweepLimits() {
1947
+ limitTimer = null;
1948
+ const cleared = pruneExpired(engineLimits, Date.now());
1949
+ for (const entry of cleared) {
1950
+ log2(
1951
+ `${getEngine(entry.engineId).label} usage window on Ship ${entry.shipId} has reset \u2014 resuming.`
1952
+ );
1953
+ void clearEngineLimit(fb.db, entry.shipId, entry.engineId).catch(() => {
1954
+ });
1955
+ }
1956
+ armLimitTimer();
1957
+ if (cleared.length > 0) poke();
1958
+ }
1959
+ async function workLoop() {
1960
+ working = true;
1961
+ try {
1962
+ for (; ; ) {
1963
+ pokeRequested = false;
1964
+ if (shuttingDown) break;
1965
+ const now = Date.now();
1966
+ const next = [...pending.values()].filter((p) => approved.get(p.shipId) === true).filter((p) => !isLimited(engineLimits, p.shipId, engineForJob(p.shipId, p.job), now)).sort((a, b) => a.job.createdAt - b.job.createdAt)[0];
1967
+ if (!next) break;
1968
+ pending.delete(`${next.shipId}/${next.job.id}`);
1969
+ inFlight = processJob(next.shipId, next.job);
1970
+ try {
1971
+ await inFlight;
1972
+ } finally {
1973
+ inFlight = null;
1974
+ }
1975
+ if (!pokeRequested && pending.size === 0) break;
1976
+ }
1977
+ } finally {
1978
+ working = false;
1979
+ if (pokeRequested) poke();
1980
+ }
1981
+ }
1982
+ async function claim(shipId, job) {
1983
+ const jobRef = doc7(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.jobs, job.id);
1984
+ try {
1985
+ let claimed = null;
1986
+ await runTransaction2(fb.db, async (tx) => {
1987
+ const snap = await tx.get(jobRef);
1988
+ if (!snap.exists() || snap.data().status !== "queued") {
1989
+ claimed = null;
1990
+ return;
1991
+ }
1992
+ const startedAt2 = Date.now();
1993
+ tx.update(jobRef, { status: "running", runnerId: config2.runnerId, startedAt: startedAt2 });
1994
+ claimed = { id: snap.id, ...snap.data(), status: "running", runnerId: config2.runnerId, startedAt: startedAt2 };
1995
+ });
1996
+ return claimed;
1997
+ } catch (e) {
1998
+ log2(`claim failed for ${job.id}: ${e instanceof Error ? e.message : e}`);
1999
+ return null;
2000
+ }
2001
+ }
2002
+ async function setAgentStatus(shipId, agentId, status) {
2003
+ try {
2004
+ await updateDoc2(doc7(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents, agentId), { status });
2005
+ } catch (e) {
2006
+ log2(`agent status update failed: ${e instanceof Error ? e.message : e}`);
2007
+ }
2008
+ }
2009
+ async function loadSecrets(shipId) {
2010
+ const secrets = await loadRunnerSecrets(fb.db, shipId);
2011
+ secretsOk.set(shipId, missingSecretsFor(DEFAULT_ENGINE_ID, secrets).length === 0);
2012
+ return secrets;
2013
+ }
2014
+ async function processJob(shipId, queuedJob) {
2015
+ const job = await claim(shipId, queuedJob);
2016
+ if (!job) return;
2017
+ const target = jobTarget(job);
2018
+ if (!target) {
2019
+ log2(`Job ${job.id} names neither a task nor a chat (or both) \u2014 failing it.`);
2020
+ await finalizeJob(fb.db, shipId, job, {
2021
+ status: "failed",
2022
+ usage: {
2023
+ engine: DEFAULT_ENGINE_ID,
2024
+ inputTokens: 0,
2025
+ cachedInputTokens: 0,
2026
+ outputTokens: 0,
2027
+ costUsd: 0,
2028
+ costReported: false,
2029
+ durationS: 0
2030
+ },
2031
+ transcriptPath: "",
2032
+ error: "Job names neither a task nor a chat, so there is nothing to run."
2033
+ });
2034
+ currentJob = null;
2035
+ return;
2036
+ }
2037
+ const targetLabel = target.kind === "task" ? `task ${target.taskId}` : `chat ${target.chatId}`;
2038
+ log2(`Claimed job ${job.id} (ship ${shipId}, ${targetLabel}, attempt ${job.attempt})`);
2039
+ currentJob = {
2040
+ jobId: job.id,
2041
+ // Conditional spread: Firestore rejects an explicit undefined in the mirror write.
2042
+ ...target.kind === "task" ? { taskId: target.taskId } : { chatId: target.chatId },
2043
+ agentId: job.agentId,
2044
+ workflowId: job.workflowId,
2045
+ ...job.workflowName ? { workflowName: job.workflowName } : {},
2046
+ startedAt: Date.now()
2047
+ };
2048
+ await setAgentStatus(shipId, job.agentId, "working");
2049
+ void heartbeat();
2050
+ const wake = config2.keepAwake === false ? null : inhibitSleep(`Crew job ${job.id}`);
2051
+ sessionAbort = new AbortController();
2052
+ notify(
2053
+ "Crew job started",
2054
+ target.kind === "task" ? `Agent is working on task ${target.taskId}.` : "Agent is replying in a chat."
2055
+ );
2056
+ let failure = null;
2057
+ let terminal = false;
2058
+ let sessionLimit = null;
2059
+ let engineId = DEFAULT_ENGINE_ID;
2060
+ let transcript = "";
2061
+ let usage = {
2062
+ engine: DEFAULT_ENGINE_ID,
2063
+ inputTokens: 0,
2064
+ cachedInputTokens: 0,
2065
+ outputTokens: 0,
2066
+ costUsd: 0,
2067
+ costReported: false,
2068
+ durationS: 0
2069
+ };
2070
+ let githubToken;
2071
+ let statuses = DEFAULT_TASK_STATUSES;
2072
+ try {
2073
+ const secrets = await loadSecrets(shipId);
2074
+ const packed = target.kind === "chat" ? { kind: "chat", ctx: await loadChatContext(fb.db, shipId, { ...job, chatId: target.chatId }) } : { kind: "task", ctx: await loadJobContext(fb.db, shipId, { ...job, taskId: target.taskId }) };
2075
+ if (packed.kind === "task" && !isWorkflowSentinel(job.workflowId) && !packed.ctx.workflow) {
2076
+ log2(
2077
+ `Playbook ${job.workflowId} is missing, disabled or empty \u2014 running a generic session.`
2078
+ );
2079
+ }
2080
+ const { ship: ship2, agent } = packed.ctx;
2081
+ engineId = agentEngine(agent);
2082
+ usage = { ...usage, engine: engineId };
2083
+ statuses = shipTaskStatuses(ship2);
2084
+ const missing = missingSecretsFor(engineId, secrets);
2085
+ if (missing.length > 0 && !process.env.CREW_CLAUDE_BIN) {
2086
+ throw new Error(
2087
+ `Runner credentials are not configured for this Ship \u2014 a captain must save the following in Ship Settings: ${missing.join(", ")}.`
2088
+ );
2089
+ }
2090
+ const prompt = packed.kind === "chat" ? buildChatPrompt(packed.ctx) : buildPrompt(packed.ctx, job.reason);
2091
+ const idToken = await user.getIdToken();
2092
+ try {
2093
+ const gh = await resolveGithubToken({
2094
+ db: fb.db,
2095
+ config: config2,
2096
+ idToken,
2097
+ shipId,
2098
+ jobId: job.id,
2099
+ agent,
2100
+ githubPat: secrets?.githubPat
2101
+ });
2102
+ githubToken = gh?.token;
2103
+ if (gh) log2(`GitHub credential resolved for job ${job.id} (${gh.source}).`);
2104
+ } catch (e) {
2105
+ if (e instanceof TerminalJobError) {
2106
+ terminal = true;
2107
+ throw new Error(`GitHub access unavailable: ${e.message}`);
2108
+ }
2109
+ throw e;
2110
+ }
2111
+ if (sessionAbort.signal.aborted) throw new Error("Shutting down before the session started.");
2112
+ const session = await getDriver(engineId).run({
2113
+ prompt,
2114
+ agent,
2115
+ job,
2116
+ shipId,
2117
+ mcpUrl: mcpUrl(config2),
2118
+ idToken,
2119
+ secrets,
2120
+ githubToken,
2121
+ timeoutMs: JOB_TIMEOUT_MS,
2122
+ signal: sessionAbort.signal,
2123
+ log: log2
2124
+ });
2125
+ transcript = redactTranscript(session.transcript, [
2126
+ idToken,
2127
+ secrets?.claudeToken,
2128
+ githubToken,
2129
+ secrets?.githubPat
2130
+ ]);
2131
+ usage = session.usage;
2132
+ if (session.limit && getEngine(engineId).usageWindows) {
2133
+ sessionLimit = session.limit;
2134
+ engineLimits.set(limitKey(shipId, engineId), {
2135
+ shipId,
2136
+ engineId,
2137
+ resetsAt: session.limit.resetsAt,
2138
+ resetsAtReported: session.limit.resetsAtReported,
2139
+ detail: session.limit.detail
2140
+ });
2141
+ armLimitTimer();
2142
+ }
2143
+ if (!session.ok) failure = session.resultText || "Session failed.";
2144
+ } catch (e) {
2145
+ failure = e instanceof Error ? e.message : String(e);
2146
+ }
2147
+ wake?.release();
2148
+ sessionAbort = null;
2149
+ try {
2150
+ if (shuttingDown) {
2151
+ await releaseJob(
2152
+ fb.db,
2153
+ shipId,
2154
+ job,
2155
+ "Runner shut down mid-job \u2014 released back to the queue without consuming a retry."
2156
+ );
2157
+ log2(`Job ${job.id} released back to the queue (attempt ${job.attempt} preserved).`);
2158
+ } else if (sessionLimit) {
2159
+ const engineLabel = getEngine(engineId).label;
2160
+ const resetsAt = new Date(sessionLimit.resetsAt).toISOString();
2161
+ await noteEngineLimit(fb.db, shipId, engineId, sessionLimit, config2.runnerId);
2162
+ await releaseJob(
2163
+ fb.db,
2164
+ shipId,
2165
+ job,
2166
+ `${engineLabel} usage limit reached \u2014 released without consuming a retry; resumes ${resetsAt}.`
2167
+ );
2168
+ log2(
2169
+ `Job ${job.id} released: ${engineId} usage limit on Ship ${shipId} until ${resetsAt}${sessionLimit.resetsAtReported ? "" : " (estimated \u2014 the engine named no reset time)"}. Attempt ${job.attempt} preserved; claims paused for this Ship+engine.`
2170
+ );
2171
+ notify(
2172
+ "Crew paused",
2173
+ `${engineLabel} usage limit reached \u2014 work resumes automatically.`
2174
+ );
2175
+ } else if (!failure) {
2176
+ const transcriptPath = await uploadTranscript(fb.storage, shipId, job.id, transcript);
2177
+ await finalizeJob(fb.db, shipId, job, { status: "done", usage, transcriptPath });
2178
+ log2(`Job ${job.id} done (${usage.inputTokens}in/${usage.outputTokens}out tokens).`);
2179
+ notify(
2180
+ "Crew job finished",
2181
+ target.kind === "task" ? `Task ${target.taskId} is done.` : "Agent replied in a chat."
2182
+ );
2183
+ } else if (!terminal && job.attempt < MAX_ATTEMPTS) {
2184
+ log2(`Job ${job.id} failed (attempt ${job.attempt}) \u2014 re-queueing: ${failure.slice(0, 120)}`);
2185
+ await requeueForRetry(fb.db, shipId, job, failure);
2186
+ } else {
2187
+ const transcriptPath = transcript ? await uploadTranscript(fb.storage, shipId, job.id, transcript) : "";
2188
+ await finalizeJob(fb.db, shipId, job, { status: "failed", usage, transcriptPath, error: failure });
2189
+ if (target.kind === "chat") {
2190
+ await markChatFailed(fb.db, shipId, { ...job, chatId: target.chatId }, failure);
2191
+ } else {
2192
+ await markTaskFailed(fb.db, shipId, { ...job, taskId: target.taskId }, failure, statuses);
2193
+ }
2194
+ log2(`Job ${job.id} FAILED terminally: ${failure.slice(0, 120)}`);
2195
+ notify("Crew job failed", `${targetLabel}: ${failure.slice(0, 120)}`);
2196
+ }
2197
+ } catch (e) {
2198
+ log2(`finalize failed for ${job.id}: ${e instanceof Error ? e.message : e}`);
2199
+ }
2200
+ currentJob = null;
2201
+ await setAgentStatus(shipId, job.agentId, "idle");
2202
+ if (!shuttingDown) void heartbeat();
2203
+ }
2204
+ await Promise.all(config2.ships.map((shipId) => loadSecrets(shipId).catch(() => null)));
2205
+ await heartbeat();
2206
+ const heartbeatTimer = setInterval(() => {
2207
+ void heartbeat();
2208
+ sweepLimits();
2209
+ }, HEARTBEAT_MS);
2210
+ let stopping = false;
2211
+ const shutdown = async (signal) => {
2212
+ if (stopping) return;
2213
+ stopping = true;
2214
+ shuttingDown = true;
2215
+ clearInterval(heartbeatTimer);
2216
+ if (limitTimer) clearTimeout(limitTimer);
2217
+ unsubs.forEach((u) => u());
2218
+ if (sessionAbort) {
2219
+ log2(`${signal} received with a job in flight \u2014 stopping the session and releasing it.`);
2220
+ notify("Crew runner stopping", "A job was in progress; it has been returned to the queue.");
2221
+ sessionAbort.abort();
2222
+ } else {
2223
+ log2(`${signal} received \u2014 shutting down.`);
2224
+ }
2225
+ if (inFlight) {
2226
+ await Promise.race([
2227
+ inFlight,
2228
+ new Promise((resolve) => setTimeout(resolve, SHUTDOWN_GRACE_MS).unref())
2229
+ ]);
2230
+ }
2231
+ const now = Date.now();
2232
+ try {
2233
+ await setDoc2(
2234
+ doc7(fb.db, COLLECTIONS.users, user.uid, COLLECTIONS.runners, config2.runnerId),
2235
+ { status: "offline", lastSeenAt: now },
2236
+ { merge: true }
2237
+ );
2238
+ for (const shipId of config2.ships) {
2239
+ await setDoc2(
2240
+ doc7(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId),
2241
+ { ownerUserId: user.uid, status: "offline", lastSeenAt: now, currentJob: deleteField() },
2242
+ { merge: true }
2243
+ );
2244
+ }
2245
+ } catch {
2246
+ }
2247
+ process.exit(0);
2248
+ };
2249
+ process.on("SIGINT", () => void shutdown("SIGINT"));
2250
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
2251
+ log2(`Runner online \u2014 watching ${config2.ships.length} Ship(s), MCP: ${mcpUrl(config2)}`);
2252
+ await new Promise(() => {
2253
+ });
2254
+ }
2255
+
2256
+ // src/cli/ui.ts
2257
+ import * as clack from "@clack/prompts";
2258
+ import { createColors } from "picocolors";
2259
+ var CliError = class extends Error {
2260
+ constructor(message, exitCode = 1) {
2261
+ super(message);
2262
+ this.exitCode = exitCode;
2263
+ this.name = "CliError";
2264
+ }
2265
+ exitCode;
2266
+ };
2267
+ var jsonMode = false;
2268
+ var assumeYes = false;
2269
+ var palette = createColors();
2270
+ var delegate = (name) => (input) => palette[name](input);
2271
+ var pc = {
2272
+ bold: delegate("bold"),
2273
+ dim: delegate("dim"),
2274
+ black: delegate("black"),
2275
+ red: delegate("red"),
2276
+ green: delegate("green"),
2277
+ yellow: delegate("yellow"),
2278
+ cyan: delegate("cyan"),
2279
+ bgCyan: delegate("bgCyan")
2280
+ };
2281
+ function configureOutput(options) {
2282
+ jsonMode = options.json === true;
2283
+ assumeYes = options.yes === true;
2284
+ if (options.color === false || jsonMode) palette = createColors(false);
2285
+ }
2286
+ function isJson() {
2287
+ return jsonMode;
2288
+ }
2289
+ function emitJson(value) {
2290
+ process.stdout.write(`${JSON.stringify(value, null, 2)}
2291
+ `);
2292
+ }
2293
+ function canPrompt() {
2294
+ return !jsonMode && process.stdin.isTTY === true && process.stdout.isTTY === true;
2295
+ }
2296
+ function requireInteractive(what, flagHint) {
2297
+ if (canPrompt()) return;
2298
+ throw new CliError(
2299
+ `${what} needs an interactive terminal. Run it in a terminal, or pass ${pc.bold(flagHint)}.`
2300
+ );
2301
+ }
2302
+ var say = {
2303
+ intro(title) {
2304
+ if (!jsonMode) clack.intro(pc.bgCyan(pc.black(` ${title} `)));
2305
+ },
2306
+ outro(message) {
2307
+ if (!jsonMode) clack.outro(message);
2308
+ },
2309
+ info(message) {
2310
+ if (!jsonMode) clack.log.info(message);
2311
+ },
2312
+ success(message) {
2313
+ if (!jsonMode) clack.log.success(message);
2314
+ },
2315
+ warn(message) {
2316
+ if (!jsonMode) clack.log.warn(message);
2317
+ },
2318
+ error(message) {
2319
+ if (!jsonMode) clack.log.error(message);
2320
+ },
2321
+ step(message) {
2322
+ if (!jsonMode) clack.log.step(message);
2323
+ },
2324
+ note(body, title) {
2325
+ if (!jsonMode) clack.note(body, title);
2326
+ },
2327
+ /** Raw line straight to stdout — for `logs`, where the content IS the output. */
2328
+ line(message) {
2329
+ process.stdout.write(`${message}
2330
+ `);
2331
+ }
2332
+ };
2333
+ function spinner2() {
2334
+ if (jsonMode) {
2335
+ return { start: () => {
2336
+ }, update: () => {
2337
+ }, stop: () => {
2338
+ } };
2339
+ }
2340
+ if (!canPrompt()) {
2341
+ return {
2342
+ start: (msg) => process.stderr.write(`${msg}
2343
+ `),
2344
+ update: (msg) => process.stderr.write(`${msg}
2345
+ `),
2346
+ stop: (msg) => msg && process.stderr.write(`${msg}
2347
+ `)
2348
+ };
2349
+ }
2350
+ const s = clack.spinner();
2351
+ return {
2352
+ start: (msg) => s.start(msg),
2353
+ update: (msg) => s.message(msg),
2354
+ stop: (msg) => s.stop(msg)
2355
+ };
2356
+ }
2357
+ function unwrap(value) {
2358
+ if (clack.isCancel(value)) throw new CliError("Cancelled.", 130);
2359
+ return value;
2360
+ }
2361
+ async function promptMultiSelect(options) {
2362
+ requireInteractive(options.message, options.flagHint);
2363
+ return unwrap(
2364
+ await clack.multiselect({
2365
+ message: options.message,
2366
+ options: options.choices,
2367
+ initialValues: options.initialValues,
2368
+ required: options.required ?? false
2369
+ })
2370
+ );
2371
+ }
2372
+ async function promptConfirm(options) {
2373
+ if (assumeYes && options.yesFlagApplies !== false) return true;
2374
+ requireInteractive(options.message, "--yes");
2375
+ return unwrap(await clack.confirm({ message: options.message, initialValue: options.initialValue }));
2376
+ }
2377
+ function glyph(level) {
2378
+ if (level === "ok") return pc.green("\u2714");
2379
+ if (level === "warn") return pc.yellow("\u25B2");
2380
+ return pc.red("\u2716");
2381
+ }
2382
+
2383
+ // src/cli/commands/config.ts
2384
+ var TOGGLES = {
2385
+ notifications: "Desktop notifications when a job starts, finishes or fails",
2386
+ keepAwake: "Keep this machine awake while a job is running"
2387
+ };
2388
+ function isToggle(key) {
2389
+ return Object.hasOwn(TOGGLES, key);
2390
+ }
2391
+ function parseBool(value) {
2392
+ if (["on", "true", "yes", "1"].includes(value.toLowerCase())) return true;
2393
+ if (["off", "false", "no", "0"].includes(value.toLowerCase())) return false;
2394
+ throw new CliError(`Expected on/off, got "${value}".`);
2395
+ }
2396
+ async function runConfigList() {
2397
+ const config2 = requireConfig();
2398
+ const values = Object.fromEntries(
2399
+ Object.keys(TOGGLES).map((key) => [key, config2[key] !== false])
2400
+ );
2401
+ if (isJson()) {
2402
+ emitJson({ configDir: configDir(), ...values });
2403
+ return 0;
2404
+ }
2405
+ say.line(` ${configDir()}`);
2406
+ say.line("");
2407
+ for (const key of Object.keys(TOGGLES)) {
2408
+ say.line(` ${key.padEnd(16)} ${values[key] ? "on" : "off"} ${TOGGLES[key]}`);
2409
+ }
2410
+ return 0;
2411
+ }
2412
+ async function runConfigSet(key, value) {
2413
+ if (!isToggle(key)) {
2414
+ throw new CliError(`Unknown setting "${key}". Known: ${Object.keys(TOGGLES).join(", ")}.`);
2415
+ }
2416
+ const config2 = requireConfig();
2417
+ config2[key] = parseBool(value);
2418
+ saveConfig(config2);
2419
+ if (isJson()) {
2420
+ emitJson({ [key]: config2[key] });
2421
+ return 0;
2422
+ }
2423
+ say.success(`${key} = ${config2[key] ? "on" : "off"}`);
2424
+ return 0;
2425
+ }
2426
+
2427
+ // src/cli/commands/doctor.ts
2428
+ import { spawnSync as spawnSync2 } from "node:child_process";
2429
+ import { collection as collection6, doc as doc8, getDoc as getDoc6, getDocs as getDocs3 } from "firebase/firestore";
2430
+
2431
+ // src/service.ts
2432
+ import { spawnSync } from "node:child_process";
2433
+ import fs4 from "node:fs";
2434
+ import os5 from "node:os";
2435
+ import path4 from "node:path";
2436
+ import { fileURLToPath } from "node:url";
2437
+ var SERVICE_LABEL = "com.lumi.runner";
2438
+ var LINUX_UNIT = "lumi-runner.service";
2439
+ var WINDOWS_TASK = "LumiRunner";
2440
+ var LEGACY_SERVICE_LABEL = "com.lumi.crew-runner";
2441
+ var LEGACY_LINUX_UNIT = "crew-runner.service";
2442
+ var LEGACY_WINDOWS_TASK = "CrewRunner";
2443
+ var ServiceError = class extends Error {
2444
+ };
2445
+ function run(command, args) {
2446
+ const result = spawnSync(command, args, { encoding: "utf8" });
2447
+ return {
2448
+ ok: result.status === 0,
2449
+ out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
2450
+ };
2451
+ }
2452
+ function cliPath() {
2453
+ return fileURLToPath(import.meta.url);
2454
+ }
2455
+ function uid() {
2456
+ return String(process.getuid?.() ?? 0);
2457
+ }
2458
+ function launchAgentPath() {
2459
+ return path4.join(os5.homedir(), "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
2460
+ }
2461
+ function systemdUnitPath() {
2462
+ return path4.join(os5.homedir(), ".config/systemd/user", LINUX_UNIT);
2463
+ }
2464
+ function legacyLaunchAgentPath() {
2465
+ return path4.join(os5.homedir(), "Library/LaunchAgents", `${LEGACY_SERVICE_LABEL}.plist`);
2466
+ }
2467
+ function legacySystemdUnitPath() {
2468
+ return path4.join(os5.homedir(), ".config/systemd/user", LEGACY_LINUX_UNIT);
2469
+ }
2470
+ function serviceEnv() {
2471
+ const env = { PATH: process.env.PATH ?? "" };
2472
+ if (process.env.LUMI_RUNNER_HOME || process.env.CREW_RUNNER_HOME) {
2473
+ env.LUMI_RUNNER_HOME = configDir();
2474
+ }
2475
+ return env;
2476
+ }
2477
+ function removeLegacyService() {
2478
+ const removed = [];
2479
+ if (process.platform === "darwin") {
2480
+ const unitPath = legacyLaunchAgentPath();
2481
+ if (fs4.existsSync(unitPath)) {
2482
+ run("launchctl", ["bootout", `gui/${uid()}/${LEGACY_SERVICE_LABEL}`]);
2483
+ fs4.rmSync(unitPath, { force: true });
2484
+ removed.push(unitPath);
2485
+ }
2486
+ return removed;
2487
+ }
2488
+ if (process.platform === "linux") {
2489
+ const unitPath = legacySystemdUnitPath();
2490
+ if (fs4.existsSync(unitPath)) {
2491
+ run("systemctl", ["--user", "disable", "--now", LEGACY_LINUX_UNIT]);
2492
+ fs4.rmSync(unitPath, { force: true });
2493
+ run("systemctl", ["--user", "daemon-reload"]);
2494
+ removed.push(unitPath);
2495
+ }
2496
+ return removed;
2497
+ }
2498
+ if (process.platform === "win32") {
2499
+ if (run("schtasks", ["/Query", "/TN", LEGACY_WINDOWS_TASK]).ok) {
2500
+ run("schtasks", ["/Delete", "/TN", LEGACY_WINDOWS_TASK, "/F"]);
2501
+ removed.push(LEGACY_WINDOWS_TASK);
2502
+ }
2503
+ return removed;
2504
+ }
2505
+ return removed;
2506
+ }
2507
+ var BOOTSTRAP_RETRY_DELAYS_MS = [250, 500, 1e3, 2e3, 2e3];
2508
+ function isTransientBootstrapError(out) {
2509
+ const text = out.toLowerCase();
2510
+ return (
2511
+ // The observed one: `Bootstrap failed: 5: Input/output error`.
2512
+ text.includes("input/output error") || // Same cause, different report: the old generation is still registered.
2513
+ text.includes("service already loaded") || text.includes("service is already loaded") || text.includes("eexist") || // launchd is mid-teardown and says so.
2514
+ text.includes("operation already in progress") || text.includes("operation now in progress")
2515
+ );
2516
+ }
2517
+ function retryWhileTransient(attempt, wait, delays = BOOTSTRAP_RETRY_DELAYS_MS) {
2518
+ let result = attempt();
2519
+ for (const delay of delays) {
2520
+ if (result.ok || !isTransientBootstrapError(result.out)) return result;
2521
+ wait(delay);
2522
+ result = attempt();
2523
+ }
2524
+ return result;
2525
+ }
2526
+ function sleepSync(ms) {
2527
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2528
+ }
2529
+ function plistXml() {
2530
+ const env = serviceEnv();
2531
+ const envEntries = Object.entries(env).map(([k, v]) => ` <key>${k}</key>
2532
+ <string>${escapeXml(v)}</string>`).join("\n");
2533
+ return `<?xml version="1.0" encoding="UTF-8"?>
2534
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2535
+ <plist version="1.0">
2536
+ <dict>
2537
+ <key>Label</key>
2538
+ <string>${SERVICE_LABEL}</string>
2539
+ <key>ProgramArguments</key>
2540
+ <array>
2541
+ <string>${escapeXml(process.execPath)}</string>
2542
+ <string>${escapeXml(cliPath())}</string>
2543
+ <string>start</string>
2544
+ </array>
2545
+ <key>RunAtLoad</key>
2546
+ <true/>
2547
+ <key>KeepAlive</key>
2548
+ <true/>
2549
+ <key>ExitTimeOut</key>
2550
+ <integer>20</integer>
2551
+ <key>EnvironmentVariables</key>
2552
+ <dict>
2553
+ ${envEntries}
2554
+ </dict>
2555
+ <key>StandardOutPath</key>
2556
+ <string>${escapeXml(path4.join(logDir(), "service.out.log"))}</string>
2557
+ <key>StandardErrorPath</key>
2558
+ <string>${escapeXml(path4.join(logDir(), "service.err.log"))}</string>
2559
+ </dict>
2560
+ </plist>
2561
+ `;
2562
+ }
2563
+ function escapeXml(value) {
2564
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2565
+ }
2566
+ function systemdUnit() {
2567
+ const env = serviceEnv();
2568
+ const envLines = Object.entries(env).map(([k, v]) => `Environment="${k}=${v}"`).join("\n");
2569
+ return `[Unit]
2570
+ Description=Lumi Crew runner daemon
2571
+ After=network-online.target
2572
+
2573
+ [Service]
2574
+ Type=simple
2575
+ ExecStart=${process.execPath} ${cliPath()} start
2576
+ Restart=always
2577
+ RestartSec=5
2578
+ # Must exceed the daemon's SHUTDOWN_GRACE_MS: a SIGKILL mid-release is exactly the stranded
2579
+ # job the graceful shutdown exists to prevent.
2580
+ TimeoutStopSec=20
2581
+ ${envLines}
2582
+
2583
+ [Install]
2584
+ WantedBy=default.target
2585
+ `;
2586
+ }
2587
+ function serviceStatus() {
2588
+ if (process.platform === "darwin") {
2589
+ const unitPath = launchAgentPath();
2590
+ if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No LaunchAgent installed." };
2591
+ const printed = run("launchctl", ["print", `gui/${uid()}/${SERVICE_LABEL}`]);
2592
+ if (!printed.ok) return { state: "installed", detail: "LaunchAgent present but not loaded.", unitPath };
2593
+ const running = /\bpid = \d+/.test(printed.out);
2594
+ return {
2595
+ state: running ? "running" : "installed",
2596
+ detail: running ? "LaunchAgent loaded and running." : "LaunchAgent loaded but not running.",
2597
+ unitPath
2598
+ };
2599
+ }
2600
+ if (process.platform === "linux") {
2601
+ const unitPath = systemdUnitPath();
2602
+ if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No systemd user unit installed." };
2603
+ const active = run("systemctl", ["--user", "is-active", LINUX_UNIT]);
2604
+ return {
2605
+ state: active.out === "active" ? "running" : "installed",
2606
+ detail: `systemd user unit is ${active.out || "unknown"}.`,
2607
+ unitPath
2608
+ };
2609
+ }
2610
+ if (process.platform === "win32") {
2611
+ const query6 = run("schtasks", ["/Query", "/TN", WINDOWS_TASK]);
2612
+ if (!query6.ok) return { state: "not-installed", detail: "No scheduled task installed." };
2613
+ return {
2614
+ state: /\bRunning\b/i.test(query6.out) ? "running" : "installed",
2615
+ detail: "Scheduled task installed (runs at logon).",
2616
+ unitPath: WINDOWS_TASK
2617
+ };
2618
+ }
2619
+ return { state: "unsupported", detail: `No service integration for ${process.platform}.` };
2620
+ }
2621
+ function installService() {
2622
+ const notes = [];
2623
+ fs4.mkdirSync(logDir(), { recursive: true, mode: 448 });
2624
+ for (const unit of removeLegacyService()) {
2625
+ notes.push(`Removed the previous crew-runner service (${unit}).`);
2626
+ }
2627
+ if (process.platform === "darwin") {
2628
+ const unitPath = launchAgentPath();
2629
+ fs4.mkdirSync(path4.dirname(unitPath), { recursive: true });
2630
+ fs4.writeFileSync(unitPath, plistXml());
2631
+ run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
2632
+ const boot = retryWhileTransient(
2633
+ () => run("launchctl", ["bootstrap", `gui/${uid()}`, unitPath]),
2634
+ sleepSync
2635
+ );
2636
+ if (!boot.ok) throw new ServiceError(`launchctl bootstrap failed: ${boot.out}`);
2637
+ return { unitPath, notes };
2638
+ }
2639
+ if (process.platform === "linux") {
2640
+ const unitPath = systemdUnitPath();
2641
+ fs4.mkdirSync(path4.dirname(unitPath), { recursive: true });
2642
+ fs4.writeFileSync(unitPath, systemdUnit());
2643
+ const reload = run("systemctl", ["--user", "daemon-reload"]);
2644
+ if (!reload.ok) throw new ServiceError(`systemctl daemon-reload failed: ${reload.out}`);
2645
+ const enable = run("systemctl", ["--user", "enable", "--now", LINUX_UNIT]);
2646
+ if (!enable.ok) throw new ServiceError(`systemctl enable failed: ${enable.out}`);
2647
+ const linger = run("loginctl", ["show-user", os5.userInfo().username, "--property=Linger"]);
2648
+ if (!linger.out.includes("Linger=yes")) {
2649
+ notes.push(
2650
+ `Run \`sudo loginctl enable-linger ${os5.userInfo().username}\` so the daemon survives logout and starts at boot.`
2651
+ );
2652
+ }
2653
+ return { unitPath, notes };
2654
+ }
2655
+ if (process.platform === "win32") {
2656
+ const command = `"${process.execPath}" "${cliPath()}" start`;
2657
+ const create = run("schtasks", [
2658
+ "/Create",
2659
+ "/TN",
2660
+ WINDOWS_TASK,
2661
+ "/TR",
2662
+ command,
2663
+ "/SC",
2664
+ "ONLOGON",
2665
+ "/RL",
2666
+ "LIMITED",
2667
+ "/F"
2668
+ ]);
2669
+ if (!create.ok) throw new ServiceError(`schtasks /Create failed: ${create.out}`);
2670
+ notes.push(
2671
+ "Task Scheduler starts the daemon at logon but does not restart it if it exits. For a true always-on box, run the daemon under pm2 or a Windows service wrapper instead."
2672
+ );
2673
+ return { unitPath: WINDOWS_TASK, notes };
2674
+ }
2675
+ throw new ServiceError(`Service install is not supported on ${process.platform}.`);
2676
+ }
2677
+ function uninstallService() {
2678
+ removeLegacyService();
2679
+ if (process.platform === "darwin") {
2680
+ run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
2681
+ fs4.rmSync(launchAgentPath(), { force: true });
2682
+ return;
2683
+ }
2684
+ if (process.platform === "linux") {
2685
+ run("systemctl", ["--user", "disable", "--now", LINUX_UNIT]);
2686
+ fs4.rmSync(systemdUnitPath(), { force: true });
2687
+ run("systemctl", ["--user", "daemon-reload"]);
2688
+ return;
2689
+ }
2690
+ if (process.platform === "win32") {
2691
+ run("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
2692
+ return;
2693
+ }
2694
+ throw new ServiceError(`Service uninstall is not supported on ${process.platform}.`);
2695
+ }
2696
+ function restartService() {
2697
+ if (process.platform === "darwin") {
2698
+ const result = run("launchctl", ["kickstart", "-k", `gui/${uid()}/${SERVICE_LABEL}`]);
2699
+ if (!result.ok) throw new ServiceError(`launchctl kickstart failed: ${result.out}`);
2700
+ return;
2701
+ }
2702
+ if (process.platform === "linux") {
2703
+ const result = run("systemctl", ["--user", "restart", LINUX_UNIT]);
2704
+ if (!result.ok) throw new ServiceError(`systemctl restart failed: ${result.out}`);
2705
+ return;
2706
+ }
2707
+ if (process.platform === "win32") {
2708
+ run("schtasks", ["/End", "/TN", WINDOWS_TASK]);
2709
+ const result = run("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
2710
+ if (!result.ok) throw new ServiceError(`schtasks /Run failed: ${result.out}`);
2711
+ return;
2712
+ }
2713
+ throw new ServiceError(`Service restart is not supported on ${process.platform}.`);
2714
+ }
2715
+
2716
+ // src/cli/session.ts
2717
+ async function openSession() {
2718
+ const config2 = requireConfig();
2719
+ const fb = initFirebase(config2);
2720
+ const user = await signInWithStoredSession(fb);
2721
+ return { config: config2, fb, user };
2722
+ }
2723
+
2724
+ // src/cli/commands/doctor.ts
2725
+ var ok = (id, label, detail) => ({ id, label, level: "ok", detail });
2726
+ var warn = (id, label, detail, fix) => ({
2727
+ id,
2728
+ label,
2729
+ level: "warn",
2730
+ detail,
2731
+ fix
2732
+ });
2733
+ var fail = (id, label, detail, fix) => ({
2734
+ id,
2735
+ label,
2736
+ level: "fail",
2737
+ detail,
2738
+ fix
2739
+ });
2740
+ function onPath(binary) {
2741
+ const probe = process.platform === "win32" ? "where" : "which";
2742
+ return spawnSync2(probe, [binary], { stdio: "ignore" }).status === 0;
2743
+ }
2744
+ function checkNode() {
2745
+ const major = Number(process.versions.node.split(".")[0]);
2746
+ return major >= 20 ? ok("node", "Node.js", `v${process.versions.node}`) : fail("node", "Node.js", `v${process.versions.node} is too old`, "Install Node.js 20 or newer.");
2747
+ }
2748
+ async function checkMcp(url) {
2749
+ try {
2750
+ const response = await fetch(url, { method: "GET", signal: AbortSignal.timeout(5e3) });
2751
+ return ok("mcp", "Workspace MCP", `${url} \u2192 HTTP ${response.status}`);
2752
+ } catch (e) {
2753
+ return fail(
2754
+ "mcp",
2755
+ "Workspace MCP",
2756
+ `${url} unreachable (${e instanceof Error ? e.message : e})`,
2757
+ "Check network access, or override the endpoint with CREW_MCP_URL."
2758
+ );
2759
+ }
2760
+ }
2761
+ function checkService() {
2762
+ const status = serviceStatus();
2763
+ if (status.state === "running") return ok("service", "Background service", status.detail);
2764
+ if (status.state === "installed") {
2765
+ return warn("service", "Background service", status.detail, "Run `lumi-runner service restart`.");
2766
+ }
2767
+ if (status.state === "unsupported") return warn("service", "Background service", status.detail);
2768
+ return warn(
2769
+ "service",
2770
+ "Background service",
2771
+ "Not installed \u2014 the daemon only runs while a terminal is open.",
2772
+ "Run `lumi-runner service install` to keep this Ship online whenever the machine is."
2773
+ );
2774
+ }
2775
+ async function checkShips(session) {
2776
+ const { config: config2, fb, user } = session;
2777
+ const checks = [];
2778
+ const engines = /* @__PURE__ */ new Set();
2779
+ let needsGithub = false;
2780
+ if (config2.ships.length === 0) {
2781
+ checks.push(
2782
+ fail("ships", "Ships", "This machine serves no Ships.", "Run `lumi-runner ship add` to pick one.")
2783
+ );
2784
+ return { checks, engines, needsGithub };
2785
+ }
2786
+ for (const shipId of config2.ships) {
2787
+ try {
2788
+ const snap = await getDoc6(
2789
+ doc8(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.runners, config2.runnerId)
2790
+ );
2791
+ if (!snap.exists()) {
2792
+ checks.push(
2793
+ warn(
2794
+ `approval:${shipId}`,
2795
+ `Ship ${shipId} \u2014 approval`,
2796
+ "This machine has not enrolled yet.",
2797
+ "Start the daemon once; it enrols on its first heartbeat, then a captain approves it."
2798
+ )
2799
+ );
2800
+ } else if (snap.data().approved !== true) {
2801
+ checks.push(
2802
+ fail(
2803
+ `approval:${shipId}`,
2804
+ `Ship ${shipId} \u2014 approval`,
2805
+ "Enrolled but awaiting captain approval \u2014 no jobs will be claimed.",
2806
+ `Approve this machine on the Ship's Daemons page.`
2807
+ )
2808
+ );
2809
+ } else if (snap.data().ownerUserId !== user.uid) {
2810
+ checks.push(
2811
+ fail(
2812
+ `approval:${shipId}`,
2813
+ `Ship ${shipId} \u2014 approval`,
2814
+ "The approval record belongs to another user.",
2815
+ "Re-run `lumi-runner login` on this machine."
2816
+ )
2817
+ );
2818
+ } else {
2819
+ checks.push(ok(`approval:${shipId}`, `Ship ${shipId} \u2014 approval`, "Approved by a captain."));
2820
+ }
2821
+ } catch (e) {
2822
+ checks.push(
2823
+ fail(
2824
+ `approval:${shipId}`,
2825
+ `Ship ${shipId} \u2014 approval`,
2826
+ `Could not read the approval record (${e instanceof Error ? e.message : e}).`,
2827
+ "Are you still a member of this Ship?"
2828
+ )
2829
+ );
2830
+ }
2831
+ let agents = [];
2832
+ try {
2833
+ const snap = await getDocs3(collection6(fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents));
2834
+ agents = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
2835
+ } catch {
2836
+ }
2837
+ const shipEngines = new Set(agents.map((agent) => agentEngine(agent)));
2838
+ if (shipEngines.size === 0) shipEngines.add(DEFAULT_ENGINE_ID);
2839
+ for (const id of shipEngines) engines.add(id);
2840
+ if (agents.some((agent) => effectiveAgentTools(agent).github.enabled)) needsGithub = true;
2841
+ try {
2842
+ const secrets = await loadRunnerSecrets(fb.db, shipId);
2843
+ const missing = [...new Set([...shipEngines].flatMap((id) => missingSecretsFor(id, secrets)))];
2844
+ checks.push(
2845
+ missing.length === 0 ? ok(`secrets:${shipId}`, `Ship ${shipId} \u2014 credentials`, "All required secrets are saved.") : fail(
2846
+ `secrets:${shipId}`,
2847
+ `Ship ${shipId} \u2014 credentials`,
2848
+ `Missing: ${missing.join(", ")}.`,
2849
+ "A captain saves these in Ship Settings \u2192 Runner credentials."
2850
+ )
2851
+ );
2852
+ } catch (e) {
2853
+ checks.push(
2854
+ fail(
2855
+ `secrets:${shipId}`,
2856
+ `Ship ${shipId} \u2014 credentials`,
2857
+ `Could not read the Ship's runner secrets (${e instanceof Error ? e.message : e}).`,
2858
+ "Only captains and approved runners may read them \u2014 get this machine approved first."
2859
+ )
2860
+ );
2861
+ }
2862
+ }
2863
+ return { checks, engines, needsGithub };
2864
+ }
2865
+ async function runDoctor() {
2866
+ const checks = [checkNode()];
2867
+ const config2 = loadConfig();
2868
+ if (!config2) {
2869
+ checks.push(
2870
+ fail("config", "Configuration", "This machine is not connected.", "Run `lumi-runner setup`.")
2871
+ );
2872
+ return report(checks);
2873
+ }
2874
+ checks.push(ok("config", "Configuration", `Runner ${config2.runnerId} on project ${config2.projectId}`));
2875
+ const progress = spinner2();
2876
+ progress.start("Running checks\u2026");
2877
+ let session = null;
2878
+ try {
2879
+ session = await openSession();
2880
+ checks.push(ok("session", "Session", `Signed in as ${session.user.uid}`));
2881
+ } catch (e) {
2882
+ checks.push(
2883
+ fail("session", "Session", e instanceof Error ? e.message : String(e), "Run `lumi-runner login`.")
2884
+ );
2885
+ }
2886
+ const engines = /* @__PURE__ */ new Set([DEFAULT_ENGINE_ID]);
2887
+ let needsGithub = false;
2888
+ if (session) {
2889
+ const shipResults = await checkShips(session);
2890
+ checks.push(...shipResults.checks);
2891
+ if (shipResults.engines.size > 0) {
2892
+ engines.clear();
2893
+ for (const id of shipResults.engines) engines.add(id);
2894
+ }
2895
+ needsGithub = shipResults.needsGithub;
2896
+ }
2897
+ for (const engineId of engines) {
2898
+ const health = await getDriver(engineId).healthCheck();
2899
+ checks.push(
2900
+ health.ok ? ok(`engine:${engineId}`, `Engine "${engineId}"`, health.detail) : fail(`engine:${engineId}`, `Engine "${engineId}"`, health.detail, health.fix)
2901
+ );
2902
+ }
2903
+ if (needsGithub) {
2904
+ for (const binary of ["git", "gh"]) {
2905
+ checks.push(
2906
+ onPath(binary) ? ok(`bin:${binary}`, `\`${binary}\``, "On PATH.") : fail(
2907
+ `bin:${binary}`,
2908
+ `\`${binary}\``,
2909
+ "Not on PATH, but an agent has GitHub enabled.",
2910
+ `Install ${binary} and make sure it is on the PATH the daemon runs with.`
2911
+ )
2912
+ );
2913
+ }
2914
+ }
2915
+ checks.push(await checkMcp(mcpUrl(config2)));
2916
+ checks.push(checkService());
2917
+ progress.stop("Checks complete.");
2918
+ return report(checks);
2919
+ }
2920
+ function report(checks) {
2921
+ const failed = checks.filter((c) => c.level === "fail");
2922
+ const warned = checks.filter((c) => c.level === "warn");
2923
+ if (isJson()) {
2924
+ emitJson({
2925
+ version: RUNNER_VERSION,
2926
+ ok: failed.length === 0,
2927
+ failed: failed.length,
2928
+ warned: warned.length,
2929
+ checks
2930
+ });
2931
+ return failed.length === 0 ? 0 : 1;
2932
+ }
2933
+ for (const check of checks) {
2934
+ say.line(` ${glyph(check.level)} ${pc.bold(check.label)} ${pc.dim(check.detail)}`);
2935
+ if (check.fix && check.level !== "ok") say.line(` ${pc.cyan("\u2192")} ${check.fix}`);
2936
+ }
2937
+ say.line("");
2938
+ if (failed.length === 0 && warned.length === 0) {
2939
+ say.success("All checks passed \u2014 this machine is ready to run jobs.");
2940
+ } else if (failed.length === 0) {
2941
+ say.warn(`${warned.length} warning(s), nothing blocking.`);
2942
+ } else {
2943
+ say.error(`${failed.length} check(s) failed \u2014 jobs will not run until these are fixed.`);
2944
+ }
2945
+ return failed.length === 0 ? 0 : 1;
2946
+ }
2947
+
2948
+ // src/cli/commands/login.ts
2949
+ import { spawn as spawn4 } from "node:child_process";
2950
+ import os6 from "node:os";
2951
+ import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
2952
+ function openBrowser(url) {
2953
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
2954
+ try {
2955
+ const child = spawn4(command, args, { stdio: "ignore", detached: true });
2956
+ child.on("error", () => {
2957
+ });
2958
+ child.unref();
2959
+ } catch {
2960
+ }
2961
+ }
2962
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2963
+ async function runLogin(options) {
2964
+ const moved = migrateLegacyDir();
2965
+ if (moved?.migrated) say.info(`Moved your runner config from ${moved.from} to ${moved.to}.`);
2966
+ return options.token ? loginWithToken(options) : loginWithDeviceFlow(options);
2967
+ }
2968
+ async function loginWithDeviceFlow(options) {
2969
+ const existingConfig = loadConfig();
2970
+ const projectId = options.project || existingConfig?.projectId || DEFAULT_PROJECT_ID;
2971
+ const baseUrl = functionsBaseUrlFor(projectId);
2972
+ const start = await callPublicFunction(baseUrl, "startRunnerLogin", {
2973
+ hostname: os6.hostname(),
2974
+ // Offer the id this machine already has: re-logging in should keep its identity, and with
2975
+ // it the captain approvals it has already earned.
2976
+ ...existingConfig?.runnerId ? { runnerId: existingConfig.runnerId } : {}
2977
+ });
2978
+ if (!options.noBrowser) openBrowser(start.verificationUrl);
2979
+ say.note(
2980
+ `${pc.bold(start.verificationUrl)}
2981
+
2982
+ Code: ${pc.bold(start.displayCode)}`,
2983
+ "Approve this machine in your browser"
2984
+ );
2985
+ const progress = spinner2();
2986
+ progress.start("Waiting for approval\u2026");
2987
+ const deadline = Date.now() + start.expiresIn * 1e3;
2988
+ let interval = start.interval;
2989
+ let approved = null;
2990
+ while (Date.now() < deadline) {
2991
+ await sleep(interval * 1e3);
2992
+ let poll;
2993
+ try {
2994
+ poll = await callPublicFunction(baseUrl, "pollRunnerLogin", {
2995
+ userCode: start.userCode,
2996
+ deviceCode: start.deviceCode
2997
+ });
2998
+ } catch (e) {
2999
+ progress.stop("");
3000
+ if (e instanceof CallableError) throw new CliError(e.message);
3001
+ throw e;
3002
+ }
3003
+ if (poll.status === "approved") {
3004
+ approved = poll;
3005
+ break;
3006
+ }
3007
+ interval = poll.interval;
3008
+ }
3009
+ if (!approved) {
3010
+ progress.stop("");
3011
+ throw new CliError("The approval window expired. Run `lumi-runner login` again.");
3012
+ }
3013
+ progress.stop("Approved.");
3014
+ const config2 = {
3015
+ ...existingConfig,
3016
+ apiKey: approved.apiKey || existingConfig?.apiKey || "",
3017
+ projectId: approved.projectId || projectId,
3018
+ runnerId: approved.runnerId,
3019
+ refreshToken: "",
3020
+ // What the human PICKED on the approval page. Their choice replaces whatever this machine
3021
+ // was serving — that page is where the decision is now made.
3022
+ ships: approved.selectedShips.length > 0 ? approved.selectedShips : existingConfig?.ships ?? [],
3023
+ ...options.mcpUrl ? { mcpUrl: options.mcpUrl } : {}
3024
+ };
3025
+ if (!config2.apiKey) {
3026
+ throw new CliError(
3027
+ "The approval did not include a Firebase apiKey. Update the web app, or pass --api-key with --token."
3028
+ );
3029
+ }
3030
+ const fb = initFirebase(config2);
3031
+ const credential = await signInWithCustomToken2(fb.auth, approved.customToken);
3032
+ config2.refreshToken = credential.user.refreshToken;
3033
+ saveConfig(config2);
3034
+ return report2({
3035
+ runnerId: config2.runnerId,
3036
+ uid: credential.user.uid,
3037
+ ships: config2.ships,
3038
+ approvedShips: approved.approvedShips,
3039
+ pendingShips: approved.pendingShips.filter((id) => config2.ships.includes(id))
3040
+ });
3041
+ }
3042
+ async function loginWithToken(options) {
3043
+ if (!options.apiKey || !options.project) {
3044
+ throw new CliError("--token also needs --api-key and --project. Omit --token to use the browser flow.");
3045
+ }
3046
+ const existing = loadConfig();
3047
+ const config2 = {
3048
+ ...existing,
3049
+ apiKey: options.apiKey,
3050
+ projectId: options.project,
3051
+ runnerId: options.runnerId || existing?.runnerId || `runner-${os6.hostname().toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 12)}-${Math.random().toString(36).slice(2, 6)}`,
3052
+ refreshToken: "",
3053
+ ships: existing?.ships ?? [],
3054
+ ...options.mcpUrl ? { mcpUrl: options.mcpUrl } : {}
3055
+ };
3056
+ const fb = initFirebase(config2);
3057
+ const user = await login(fb, config2, options.token);
3058
+ return report2({ runnerId: config2.runnerId, uid: user.uid, ships: config2.ships });
3059
+ }
3060
+ function report2(result) {
3061
+ if (isJson()) {
3062
+ emitJson({ version: RUNNER_VERSION, ...result });
3063
+ return 0;
3064
+ }
3065
+ say.success(`Connected as ${result.uid} \u2014 runner ${result.runnerId}`);
3066
+ if (result.ships.length > 0) say.info(`Serving: ${result.ships.join(", ")}`);
3067
+ if (result.pendingShips && result.pendingShips.length > 0) {
3068
+ say.warn(
3069
+ `Awaiting captain approval on: ${result.pendingShips.join(", ")} \u2014 a captain approves this machine on the Ship's Daemons page.`
3070
+ );
3071
+ }
3072
+ return 0;
3073
+ }
3074
+
3075
+ // src/cli/commands/logs.ts
3076
+ async function runLogs(options) {
3077
+ const tail = readTail(options.lines);
3078
+ if (tail.length === 0 && !options.follow) {
3079
+ say.warn(`No log output yet at ${logFile()}.`);
3080
+ return 0;
3081
+ }
3082
+ for (const line of tail) say.line(line);
3083
+ if (!options.follow) return 0;
3084
+ await new Promise((resolve) => {
3085
+ const stop = followLog((line) => say.line(line));
3086
+ const finish = () => {
3087
+ stop();
3088
+ resolve();
3089
+ };
3090
+ process.on("SIGINT", finish);
3091
+ process.on("SIGTERM", finish);
3092
+ });
3093
+ return 0;
3094
+ }
3095
+
3096
+ // src/cli/commands/service.ts
3097
+ async function runServiceInstall() {
3098
+ const before = serviceStatus();
3099
+ if (before.state !== "not-installed" && before.state !== "unsupported") {
3100
+ const replace = await promptConfirm({
3101
+ message: "A service is already installed. Reinstall it with the current settings?",
3102
+ initialValue: true
3103
+ });
3104
+ if (!replace) {
3105
+ say.info("Left the existing service alone.");
3106
+ return 0;
3107
+ }
3108
+ }
3109
+ const result = installService();
3110
+ if (isJson()) {
3111
+ emitJson({ installed: true, unitPath: result.unitPath, notes: result.notes, ...serviceStatus() });
3112
+ return 0;
3113
+ }
3114
+ say.success(`Service installed: ${result.unitPath}`);
3115
+ say.info("The daemon now starts automatically whenever this machine is on.");
3116
+ for (const note2 of result.notes) say.warn(note2);
3117
+ return 0;
3118
+ }
3119
+ async function runServiceUninstall() {
3120
+ uninstallService();
3121
+ if (isJson()) {
3122
+ emitJson({ installed: false });
3123
+ return 0;
3124
+ }
3125
+ say.success("Service removed. The daemon will not start on its own any more.");
3126
+ return 0;
3127
+ }
3128
+ async function runServiceRestart() {
3129
+ restartService();
3130
+ if (isJson()) {
3131
+ emitJson({ restarted: true, ...serviceStatus() });
3132
+ return 0;
3133
+ }
3134
+ say.success("Service restarted.");
3135
+ return 0;
3136
+ }
3137
+ async function runServiceStatus() {
3138
+ const status = serviceStatus();
3139
+ if (isJson()) {
3140
+ emitJson(status);
3141
+ return 0;
3142
+ }
3143
+ say.line(` ${status.detail}`);
3144
+ if (status.unitPath) say.line(` ${status.unitPath}`);
3145
+ return 0;
3146
+ }
3147
+
3148
+ // src/cli/commands/ship.ts
3149
+ import { collectionGroup, doc as doc9, getDoc as getDoc7, getDocs as getDocs4, query as query4, where as where4 } from "firebase/firestore";
3150
+ async function listMyShips() {
3151
+ const { fb, user } = await openSession();
3152
+ const memberships = await getDocs4(
3153
+ query4(collectionGroup(fb.db, COLLECTIONS.members), where4("uid", "==", user.uid))
3154
+ );
3155
+ const shipIds = memberships.docs.map((d) => d.ref.parent.parent?.id).filter((id) => !!id);
3156
+ const ships = await Promise.all(
3157
+ shipIds.map(async (id) => {
3158
+ const snap = await getDoc7(doc9(fb.db, COLLECTIONS.ships, id));
3159
+ return snap.exists() ? { id: snap.id, ...snap.data() } : null;
3160
+ })
3161
+ );
3162
+ return ships.filter((s) => s !== null).sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
3163
+ }
3164
+ async function pickShips(current) {
3165
+ const ships = await listMyShips();
3166
+ if (ships.length === 0) {
3167
+ throw new CliError("You are not a member of any Ship yet \u2014 create one in the web app first.");
3168
+ }
3169
+ return promptMultiSelect({
3170
+ message: "Which Ships should this machine serve?",
3171
+ choices: ships.map((ship2) => ({ value: ship2.id, label: ship2.name || ship2.id, hint: ship2.id })),
3172
+ initialValues: current.filter((id) => ships.some((s) => s.id === id)),
3173
+ flagHint: "lumi-runner ship add <shipId>"
3174
+ });
3175
+ }
3176
+ async function runShipList() {
3177
+ const config2 = requireConfig();
3178
+ if (isJson()) {
3179
+ emitJson({ ships: config2.ships });
3180
+ return 0;
3181
+ }
3182
+ if (config2.ships.length === 0) {
3183
+ say.warn("This machine serves no Ships. Run `lumi-runner ship add`.");
3184
+ return 0;
3185
+ }
3186
+ for (const shipId of config2.ships) say.line(` ${shipId}`);
3187
+ return 0;
3188
+ }
3189
+ async function runShipAdd(shipIds) {
3190
+ const config2 = requireConfig();
3191
+ if (shipIds.length === 0) {
3192
+ if (!canPrompt()) {
3193
+ throw new CliError("Pass at least one shipId, or run this in a terminal to pick interactively.");
3194
+ }
3195
+ config2.ships = await pickShips(config2.ships);
3196
+ } else {
3197
+ config2.ships = [.../* @__PURE__ */ new Set([...config2.ships, ...shipIds])];
3198
+ }
3199
+ saveConfig(config2);
3200
+ return reportAssignment(config2.ships);
3201
+ }
3202
+ async function runShipRemove(shipIds) {
3203
+ const config2 = requireConfig();
3204
+ config2.ships = config2.ships.filter((id) => !shipIds.includes(id));
3205
+ saveConfig(config2);
3206
+ return reportAssignment(config2.ships);
3207
+ }
3208
+ function reportAssignment(ships) {
3209
+ if (isJson()) {
3210
+ emitJson({ ships });
3211
+ return 0;
3212
+ }
3213
+ say.success(ships.length > 0 ? `Serving: ${ships.join(", ")}` : "Serving no Ships.");
3214
+ say.info("Restart the daemon for this to take effect: `lumi-runner service restart`.");
3215
+ return 0;
3216
+ }
3217
+
3218
+ // src/cli/commands/setup.ts
3219
+ async function runSetup(options) {
3220
+ if (!canPrompt()) {
3221
+ throw new CliError(
3222
+ "setup is interactive. On a headless machine run `lumi-runner login`, `lumi-runner ship add <id>` and `lumi-runner service install` instead."
3223
+ );
3224
+ }
3225
+ say.intro("lumi-runner setup");
3226
+ const moved = migrateLegacyDir();
3227
+ if (moved?.migrated) say.info(`Moved your runner config from ${moved.from} to ${moved.to}.`);
3228
+ const existing = loadConfig();
3229
+ const reuse = existing?.refreshToken && await promptConfirm({
3230
+ message: `This machine is already connected as ${existing.runnerId}. Keep that session?`,
3231
+ initialValue: true,
3232
+ yesFlagApplies: false
3233
+ });
3234
+ if (!reuse) {
3235
+ await runLogin(options);
3236
+ }
3237
+ const config2 = loadConfig();
3238
+ if (!config2) throw new CliError("Login did not complete.");
3239
+ if (config2.ships.length === 0) {
3240
+ config2.ships = await pickShips([]);
3241
+ saveConfig(config2);
3242
+ say.success(`Serving: ${config2.ships.join(", ") || "(none)"}`);
3243
+ } else {
3244
+ const change = await promptConfirm({
3245
+ message: `Serving ${config2.ships.join(", ")}. Change that?`,
3246
+ initialValue: false,
3247
+ yesFlagApplies: false
3248
+ });
3249
+ if (change) {
3250
+ config2.ships = await pickShips(config2.ships);
3251
+ saveConfig(config2);
3252
+ }
3253
+ }
3254
+ say.step("Checking this machine\u2026");
3255
+ const doctorExit = await runDoctor();
3256
+ if (serviceStatus().state === "not-installed") {
3257
+ const install = await promptConfirm({
3258
+ message: "Start the daemon automatically whenever this machine is on?",
3259
+ initialValue: true,
3260
+ yesFlagApplies: false
3261
+ });
3262
+ if (install) await runServiceInstall();
3263
+ else say.info("Skipped. Run `lumi-runner start` manually, or `lumi-runner service install` later.");
3264
+ }
3265
+ say.outro(
3266
+ doctorExit === 0 ? "Ready. This machine will claim jobs for its Ships." : "Setup finished, but some checks failed \u2014 fix those and re-run `lumi-runner doctor`."
3267
+ );
3268
+ return doctorExit;
3269
+ }
3270
+
3271
+ // src/cli/commands/status.ts
3272
+ import {
3273
+ collection as collection7,
3274
+ doc as doc10,
3275
+ getCountFromServer as getCountFromServer2,
3276
+ getDoc as getDoc8,
3277
+ getDocs as getDocs5,
3278
+ query as query5,
3279
+ where as where5
3280
+ } from "firebase/firestore";
3281
+ async function runStatus() {
3282
+ const config2 = loadConfig();
3283
+ if (!config2) {
3284
+ if (isJson()) {
3285
+ emitJson({ version: RUNNER_VERSION, connected: false });
3286
+ return 0;
3287
+ }
3288
+ say.warn("This machine is not connected. Run `lumi-runner setup`.");
3289
+ return 0;
3290
+ }
3291
+ const progress = spinner2();
3292
+ progress.start("Reading Ship state\u2026");
3293
+ const { fb } = await openSession();
3294
+ const ships = [];
3295
+ for (const shipId of config2.ships) {
3296
+ const shipRef = doc10(fb.db, COLLECTIONS.ships, shipId);
3297
+ const mirrorSnap = await getDoc8(doc10(shipRef, COLLECTIONS.runners, config2.runnerId));
3298
+ const mirror = mirrorSnap.data();
3299
+ let queued = 0;
3300
+ try {
3301
+ const counted = await getCountFromServer2(
3302
+ query5(collection7(shipRef, COLLECTIONS.jobs), where5("status", "==", "queued"))
3303
+ );
3304
+ queued = counted.data().count;
3305
+ } catch {
3306
+ queued = 0;
3307
+ }
3308
+ const usageSnap = await getDoc8(doc10(shipRef, COLLECTIONS.usageDaily, utcDay(Date.now())));
3309
+ const today = { ...EMPTY_USAGE_TOTALS, ...usageSnap.data()?.totals ?? {} };
3310
+ const now = Date.now();
3311
+ const limitsSnap = await getDocs5(collection7(shipRef, COLLECTIONS.engineLimits));
3312
+ const engineLimits = limitsSnap.docs.map((d) => ({ id: d.id, ...d.data() })).filter((l) => isEngineLimited(l, now));
3313
+ ships.push({
3314
+ shipId,
3315
+ enrolled: mirrorSnap.exists(),
3316
+ approved: mirror?.approved === true,
3317
+ online: mirror?.status === "online" && Date.now() - (mirror?.lastSeenAt ?? 0) < RUNNER_OFFLINE_AFTER_MS,
3318
+ lastSeenAt: mirror?.lastSeenAt ?? null,
3319
+ currentJob: mirror?.currentJob ?? null,
3320
+ queued,
3321
+ today,
3322
+ engineLimits
3323
+ });
3324
+ }
3325
+ progress.stop("");
3326
+ const service2 = serviceStatus();
3327
+ if (isJson()) {
3328
+ emitJson({
3329
+ version: RUNNER_VERSION,
3330
+ connected: true,
3331
+ runnerId: config2.runnerId,
3332
+ projectId: config2.projectId,
3333
+ service: { state: service2.state, detail: service2.detail },
3334
+ ships
3335
+ });
3336
+ return 0;
3337
+ }
3338
+ say.line("");
3339
+ say.line(` ${pc.bold("Runner")} ${config2.runnerId} ${pc.dim(`v${RUNNER_VERSION}`)}`);
3340
+ say.line(` ${pc.bold("Project")} ${config2.projectId}`);
3341
+ say.line(` ${pc.bold("Service")} ${service2.detail}`);
3342
+ say.line("");
3343
+ if (ships.length === 0) {
3344
+ say.warn("This machine serves no Ships. Run `lumi-runner ship add`.");
3345
+ return 0;
3346
+ }
3347
+ for (const ship2 of ships) {
3348
+ const state = !ship2.enrolled ? pc.dim("not enrolled") : !ship2.approved ? pc.yellow("awaiting approval") : ship2.online ? pc.green("online") : pc.red("offline");
3349
+ say.line(` ${pc.bold(ship2.shipId)} ${state}`);
3350
+ if (ship2.lastSeenAt) say.line(` last seen ${new Date(ship2.lastSeenAt).toLocaleString()}`);
3351
+ say.line(
3352
+ ship2.currentJob ? ` running job ${ship2.currentJob.jobId} on task ${ship2.currentJob.taskId} (agent ${ship2.currentJob.agentId})` : " running nothing"
3353
+ );
3354
+ say.line(` queued ${ship2.queued}`);
3355
+ for (const limit3 of ship2.engineLimits) {
3356
+ say.line(
3357
+ ` ${pc.yellow("paused")} ${getEngine(limit3.engineId).label} usage limit \u2014 resumes ${new Date(limit3.resetsAt).toLocaleString()}${limit3.resetsAtReported ? "" : " (estimated)"}`
3358
+ );
3359
+ }
3360
+ say.line(
3361
+ ` today ${ship2.today.jobs} job(s) \xB7 ${ship2.today.inputTokens.toLocaleString()} in / ${ship2.today.outputTokens.toLocaleString()} out tokens`
3362
+ );
3363
+ say.line("");
3364
+ }
3365
+ return 0;
3366
+ }
3367
+
3368
+ // src/cli/index.ts
3369
+ var program = new Command();
3370
+ program.name("lumi-runner").description("Lumi Crew runner daemon \u2014 runs agent jobs for your Ships on this machine.").version(RUNNER_VERSION).option("--json", "machine-readable output on stdout").option("-y, --yes", "assume yes for confirmations").option("--no-color", "disable coloured output").hook("preAction", (command) => {
3371
+ const options = command.opts();
3372
+ configureOutput(options);
3373
+ });
3374
+ function action(handler) {
3375
+ return async (...args) => {
3376
+ try {
3377
+ process.exitCode = await handler(...args);
3378
+ } catch (error) {
3379
+ if (error instanceof CliError) {
3380
+ say.error(error.message);
3381
+ process.exitCode = error.exitCode;
3382
+ } else {
3383
+ say.error(error instanceof Error ? error.message : String(error));
3384
+ process.exitCode = 1;
3385
+ }
3386
+ } finally {
3387
+ await closeFirebase();
3388
+ }
3389
+ };
3390
+ }
3391
+ program.command("setup").description("Connect this machine and set it up to run jobs (interactive)").option("--project <projectId>", "Firebase project to connect to").option("--no-browser", "print the approval URL instead of opening a browser").action(action(
3392
+ async (options) => runSetup({ project: options.project, noBrowser: options.browser === false })
3393
+ ));
3394
+ program.command("login").description("Connect this machine (opens a browser to approve it)").option("--project <projectId>", "Firebase project to connect to").option("--no-browser", "print the approval URL instead of opening a browser").option("--token <customToken>", "skip the browser: use a token from mintRunnerToken (CI)").option("--api-key <key>", "Firebase web API key (only with --token)").option("--runner-id <id>", "reuse an existing runner id (only with --token)").option("--mcp-url <url>", "override the Workspace MCP endpoint").action(
3395
+ action(
3396
+ async (options) => runLogin({
3397
+ project: options.project,
3398
+ noBrowser: options.browser === false,
3399
+ token: options.token,
3400
+ apiKey: options.apiKey,
3401
+ runnerId: options.runnerId,
3402
+ mcpUrl: options.mcpUrl
3403
+ })
3404
+ )
3405
+ );
3406
+ var ship = program.command("ship").description("Which Ships this machine serves");
3407
+ ship.command("add [shipIds...]").description("Serve one or more Ships (omit ids to pick interactively)").action(action(async (shipIds) => runShipAdd(shipIds ?? [])));
3408
+ ship.command("remove <shipIds...>").description("Stop serving one or more Ships").action(action(async (shipIds) => runShipRemove(shipIds)));
3409
+ ship.command("list").description("List the Ships this machine serves").action(action(runShipList));
3410
+ program.command("start").description("Run the daemon in the foreground").action(
3411
+ action(async () => {
3412
+ await startDaemon();
3413
+ return 0;
3414
+ })
3415
+ );
3416
+ program.command("doctor").description("Check whether this machine can run jobs").action(action(runDoctor));
3417
+ program.command("status").description("Show what this machine is doing").action(action(runStatus));
3418
+ program.command("logs").description("Show the daemon's log file").option("-n, --lines <count>", "how many lines to show", "50").option("-f, --follow", "keep printing new lines").action(
3419
+ action(
3420
+ async (options) => runLogs({ lines: Number(options.lines) || 50, follow: options.follow === true })
3421
+ )
3422
+ );
3423
+ var service = program.command("service").description("Run the daemon in the background, starting with the machine");
3424
+ service.command("install").description("Install and start the background service").action(action(runServiceInstall));
3425
+ service.command("uninstall").description("Stop and remove the background service").action(action(runServiceUninstall));
3426
+ service.command("restart").description("Restart the background service").action(action(runServiceRestart));
3427
+ service.command("status").description("Is the background service installed and running?").action(action(runServiceStatus));
3428
+ var config = program.command("config").description("Local per-machine preferences");
3429
+ config.command("list").description("Show current settings").action(action(runConfigList));
3430
+ config.command("set <key> <value>").description("Change a setting (notifications | keepAwake \u2192 on/off)").action(action(async (key, value) => runConfigSet(key, value)));
3431
+ await program.parseAsync(process.argv);
3432
+ //# sourceMappingURL=cli.js.map