@kilogent/runner-dev 0.1.2 → 0.1.4

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 (3) hide show
  1. package/README.md +37 -11
  2. package/dist/cli.js +1348 -195
  3. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -4,7 +4,9 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // src/daemon.ts
7
- import os4 from "node:os";
7
+ import fs9 from "node:fs";
8
+ import os5 from "node:os";
9
+ import path11 from "node:path";
8
10
  import {
9
11
  collection as collection8,
10
12
  deleteField as deleteField2,
@@ -24,6 +26,8 @@ import {
24
26
  var CLAUDE_ENGINE = {
25
27
  id: "claude",
26
28
  label: "Claude Code",
29
+ vendor: "Anthropic",
30
+ accountLabel: "Claude account",
27
31
  models: [
28
32
  { id: "sonnet", label: "Sonnet", rates: { input: 3, cachedInput: 0.3, output: 15 } },
29
33
  { id: "opus", label: "Opus", rates: { input: 15, cachedInput: 1.5, output: 75 } }
@@ -41,6 +45,8 @@ var CLAUDE_ENGINE = {
41
45
  key: "claudeToken",
42
46
  label: "Claude setup-token",
43
47
  hint: "Run `claude setup-token` on any machine signed in to Claude Code.",
48
+ flow: "paste",
49
+ setupCommand: "claude setup-token",
44
50
  // `claude setup-token` emits `sk-ant-oat01-…`, around 100 characters. The floor is set
45
51
  // well below that on purpose: it exists to catch a half-copied token, not to pin a format
46
52
  // Anthropic is free to change.
@@ -58,9 +64,50 @@ var CLAUDE_ENGINE = {
58
64
  }
59
65
  };
60
66
 
67
+ // ../shared/dist/engines/codex.js
68
+ var CODEX_ENGINE = {
69
+ id: "codex",
70
+ label: "Codex CLI",
71
+ vendor: "OpenAI",
72
+ accountLabel: "ChatGPT account",
73
+ models: [
74
+ { id: "gpt-5.5", label: "GPT-5.5", rates: { input: 5, cachedInput: 0.5, output: 30 } },
75
+ { id: "gpt-5.4", label: "GPT-5.4", rates: { input: 2.5, cachedInput: 0.25, output: 15 } },
76
+ { id: "gpt-5.4-mini", label: "GPT-5.4 mini", rates: { input: 0.75, cachedInput: 0.075, output: 4.5 } },
77
+ { id: "gpt-5.6-terra", label: "GPT-5.6 Terra", rates: { input: 2, cachedInput: 0.2, output: 12 } },
78
+ { id: "gpt-5.6-luna", label: "GPT-5.6 Luna", rates: { input: 0.2, cachedInput: 0.02, output: 1.2 } }
79
+ ],
80
+ defaultModelId: "gpt-5.5",
81
+ capabilities: {
82
+ mcp: true,
83
+ bash: true,
84
+ webSearch: false,
85
+ reportsCost: false,
86
+ reportsCache: true
87
+ },
88
+ requiredSecrets: [
89
+ {
90
+ key: "codexHome",
91
+ label: "ChatGPT sign-in (Codex)",
92
+ hint: "Sign one of this Ship's machines in from Settings \u203A AI credentials \u2014 the login never leaves it.",
93
+ flow: "machine-login",
94
+ // What `codex login --device-auth` prints: four characters, a dash, five. Display only.
95
+ codePattern: "^[A-Z0-9]{4}-[A-Z0-9]{5}$"
96
+ }
97
+ ],
98
+ usageWindows: {
99
+ label: "5-hour window",
100
+ // ChatGPT plans meter Codex on a rolling 5-hour window with a weekly cap on top — the same two
101
+ // walls Claude has, and the same reason the fallback is the SHORT one: the wall text does not
102
+ // say which was hit, and parking a Ship for a week on a guess is the worse mistake.
103
+ fallbackMs: 5 * 60 * 60 * 1e3
104
+ }
105
+ };
106
+
61
107
  // ../shared/dist/engines/index.js
62
108
  var ENGINES = {
63
- claude: CLAUDE_ENGINE
109
+ claude: CLAUDE_ENGINE,
110
+ codex: CODEX_ENGINE
64
111
  };
65
112
  var DEFAULT_ENGINE_ID = "claude";
66
113
  function listEngines() {
@@ -87,6 +134,10 @@ function estimateCostUsd(engineId, modelId, tokens) {
87
134
  }
88
135
  var AGENT_ENGINE_IDS = Object.freeze(listEngines().map((e) => e.id));
89
136
  var AGENT_MODEL_IDS = Object.freeze(listEngines().flatMap((e) => e.models.map((m) => m.id)));
137
+ var MACHINE_LOGIN_ENGINE_IDS = Object.freeze(listEngines().filter((e) => e.requiredSecrets.some((r) => r.flow === "machine-login")).map((e) => e.id));
138
+ function isMachineLoginEngine(id) {
139
+ return !!id && MACHINE_LOGIN_ENGINE_IDS.includes(id);
140
+ }
90
141
 
91
142
  // ../shared/dist/agent.js
92
143
  var DEFAULT_AGENT_TOOLS = {
@@ -553,7 +604,7 @@ var COLLECTIONS = {
553
604
  * ROOT: `crewAdmins/{uid}` — one document per PLATFORM ADMIN, the people who may operate the
554
605
  * admin console.
555
606
  *
556
- * DENY-ALL to every client in packages/crew/firestore.rules, in both directions. `crewAdmin`
607
+ * DENY-ALL to every client in packages/crew/firestore.rules, in both directions. `kilogentAdmin`
557
608
  * resolves the caller and reads this with the Admin SDK, which bypasses rules — so no browser
558
609
  * ever needs a verb here, and a collection nobody may read cannot leak who the operators are.
559
610
  *
@@ -571,7 +622,7 @@ var COLLECTIONS = {
571
622
  /**
572
623
  * ROOT: `adminAudit/{entryId}` — what an admin did, with both sides of the change.
573
624
  *
574
- * Deny-all to clients; read back through `crewAdmin`. Its second job is what earns it a
625
+ * Deny-all to clients; read back through `kilogentAdmin`. Its second job is what earns it a
575
626
  * collection: it is the ONLY history `crewConfig/assistant` has. That document is overwritten in
576
627
  * place, so without this a bad prompt pushed to every workspace could not be rolled back to the
577
628
  * exact text that preceded it.
@@ -600,6 +651,14 @@ var COLLECTIONS = {
600
651
  * catch a forgotten credential-bearing collection.
601
652
  */
602
653
  credentials: "credentials",
654
+ /**
655
+ * `ships/{shipId}/credential_logins/{credId}` — the device-code half of a MACHINE-HELD credential
656
+ * while it is being signed in (PRD §15.81; see credentials.ts). Readable only by the captain who
657
+ * asked for it: the one-time code inside is a pairing secret for fifteen minutes, and a member
658
+ * who saw it could sign their own account onto the Ship's machine. Snake_case for the reason
659
+ * `mcp_servers` gives.
660
+ */
661
+ credentialLogins: "credential_logins",
603
662
  /** `ships/{shipId}/integrations/{docId}` — 3rd-party connectors (GitHub App; see github.ts). */
604
663
  integrations: "integrations",
605
664
  /**
@@ -732,12 +791,26 @@ function autonomyRule(agent) {
732
791
  var ASK_GUIDANCE = "If the task says who should approve \u2014 in its description, or because a particular person owns that area \u2014 name them in the request's `ask` so it reaches them rather than everyone.";
733
792
 
734
793
  // ../shared/dist/credentials.js
794
+ var MACHINE_LOGIN_TTL_MS = 20 * 60 * 1e3;
795
+ function isCredentialReady(c) {
796
+ return c.status === void 0 || c.status === "ready";
797
+ }
798
+ function isMachineHeldCredential(c) {
799
+ return typeof c.runnerId === "string" && c.runnerId.length > 0;
800
+ }
801
+ function machineLoginPending(c, now) {
802
+ if (c.status !== "pending" || !c.login)
803
+ return null;
804
+ if (c.login.requestedAt <= (c.login.answeredAt ?? 0))
805
+ return null;
806
+ return now - c.login.requestedAt < MACHINE_LOGIN_TTL_MS ? "start" : "expire";
807
+ }
735
808
  function resolveAgentCredential(agent, credentials, defaultEngine) {
736
809
  const engine = agent.engine ?? defaultEngine;
737
810
  if (agent.credentialId) {
738
811
  return credentials.find((c) => c.id === agent.credentialId) ?? null;
739
812
  }
740
- const forEngine = credentials.filter((c) => c.engine === engine);
813
+ const forEngine = credentials.filter((c) => c.engine === engine && isCredentialReady(c));
741
814
  const oldest = [...forEngine].sort((a, b) => a.createdAt - b.createdAt)[0];
742
815
  return oldest ?? void 0;
743
816
  }
@@ -814,9 +887,9 @@ function str(input, key) {
814
887
  const value = input[key];
815
888
  return typeof value === "string" && value.trim() ? value : void 0;
816
889
  }
817
- function basename(path9) {
818
- const parts = path9.split(/[\\/]/).filter(Boolean);
819
- return parts[parts.length - 1] ?? path9;
890
+ function basename(path13) {
891
+ const parts = path13.split(/[\\/]/).filter(Boolean);
892
+ return parts[parts.length - 1] ?? path13;
820
893
  }
821
894
  function hostOf(url) {
822
895
  try {
@@ -904,8 +977,8 @@ function builtinDetail(tool, input) {
904
977
  case "Write":
905
978
  case "Edit":
906
979
  case "MultiEdit": {
907
- const path9 = str(input, "file_path");
908
- return path9 ? basename(path9) : void 0;
980
+ const path13 = str(input, "file_path");
981
+ return path13 ? basename(path13) : void 0;
909
982
  }
910
983
  case "Glob":
911
984
  case "Grep":
@@ -1099,6 +1172,10 @@ function firstStatusIn(statuses, category) {
1099
1172
  var RUNNER_OFFLINE_AFTER_MS = 2 * 60 * 1e3;
1100
1173
 
1101
1174
  // ../shared/dist/secrets.js
1175
+ function engineSecret(secrets, key) {
1176
+ const value = secrets?.[key];
1177
+ return typeof value === "string" && value.length > 0 ? value : void 0;
1178
+ }
1102
1179
  function missingSecretsFor(engineId, secrets) {
1103
1180
  const bag = secrets;
1104
1181
  return getEngine(engineId).requiredSecrets.filter((req) => !bag?.[req.key]).map((req) => req.label);
@@ -1169,7 +1246,7 @@ import os from "node:os";
1169
1246
  import path from "node:path";
1170
1247
 
1171
1248
  // src/version.ts
1172
- var RUNNER_VERSION = true ? "0.1.2" : "0.0.0-dev";
1249
+ var RUNNER_VERSION = true ? "0.1.4" : "0.0.0-dev";
1173
1250
  var RUNNER_PACKAGE = true ? "@kilogent/runner-dev" : "@kilogent/runner-dev";
1174
1251
  var RUNNER_BIN = true ? "kilogent-runner-dev" : "kilogent-runner-dev";
1175
1252
 
@@ -1301,8 +1378,8 @@ function functionsBaseUrlFor(projectId) {
1301
1378
  return process.env.CREW_FUNCTIONS_URL?.replace(/\/$/, "") || `https://us-central1-${projectId}.cloudfunctions.net`;
1302
1379
  }
1303
1380
  var CallableError = class extends Error {
1304
- constructor(status, message2) {
1305
- super(message2);
1381
+ constructor(status, message3) {
1382
+ super(message3);
1306
1383
  this.status = status;
1307
1384
  }
1308
1385
  status;
@@ -1335,8 +1412,8 @@ async function request(baseUrl, group, op, data, headers) {
1335
1412
 
1336
1413
  // src/auth.ts
1337
1414
  var AuthError = class extends Error {
1338
- constructor(message2, gone = false) {
1339
- super(message2);
1415
+ constructor(message3, gone = false) {
1416
+ super(message3);
1340
1417
  this.gone = gone;
1341
1418
  }
1342
1419
  gone;
@@ -1675,8 +1752,8 @@ function isTransientFirestoreError(error) {
1675
1752
  if (code === "unavailable" || code === "deadline-exceeded" || code === "internal" || code === "cancelled" || code === "aborted" || code === "resource-exhausted") {
1676
1753
  return true;
1677
1754
  }
1678
- const message2 = error?.message;
1679
- return typeof message2 === "string" && message2.toLowerCase().includes("client is offline");
1755
+ const message3 = error?.message;
1756
+ return typeof message3 === "string" && message3.toLowerCase().includes("client is offline");
1680
1757
  }
1681
1758
  async function withFirestoreRetry(read, sleep2 = (ms) => new Promise((r) => setTimeout(r, ms)), delays = FIRESTORE_RETRY_DELAYS_MS) {
1682
1759
  for (let i = 0; ; i++) {
@@ -2336,36 +2413,68 @@ async function resolveJobSecrets(input) {
2336
2413
  if (resolved === void 0) {
2337
2414
  return {
2338
2415
  secrets: null,
2416
+ kind: "no-credential",
2339
2417
  problem: `This workspace has no AI credential for ${engineId}. Add one in Settings \u203A AI credentials and the crew starts taking work again.`
2340
2418
  };
2341
2419
  }
2342
2420
  if (resolved === null) {
2343
2421
  return {
2344
2422
  secrets: null,
2423
+ kind: "orphaned",
2345
2424
  problem: `This agent is set to an AI credential that no longer exists on the Ship. Point it at another one in Settings \u203A AI credentials, or add the credential back.`
2346
2425
  };
2347
2426
  }
2427
+ const requirement = getEngine(engineId).requiredSecrets[0];
2428
+ const key = requirement?.key;
2429
+ if (!key) return { secrets: null, credentialId: resolved.id };
2430
+ const bag = (value) => ({
2431
+ updatedAt: resolved.updatedAt,
2432
+ updatedBy: resolved.createdBy,
2433
+ [key]: value,
2434
+ [`${key}Tail`]: resolved.tail
2435
+ });
2436
+ if (requirement.flow === "machine-login") {
2437
+ if (!input.machine || resolved.runnerId !== input.machine.runnerId) {
2438
+ return {
2439
+ secrets: null,
2440
+ kind: "other-machine",
2441
+ credentialId: resolved.id,
2442
+ problem: `The AI credential "${resolved.label}" is held by another machine on this Ship, so this machine cannot run jobs on it.`
2443
+ };
2444
+ }
2445
+ if (!isCredentialReady(resolved)) {
2446
+ return {
2447
+ secrets: null,
2448
+ kind: "not-ready",
2449
+ credentialId: resolved.id,
2450
+ problem: `The AI credential "${resolved.label}" is not signed in yet \u2014 finish the sign-in in Settings \u203A AI credentials.`
2451
+ };
2452
+ }
2453
+ const home = input.machine.localLogin(engineId, resolved.id);
2454
+ if (!home) {
2455
+ return {
2456
+ secrets: null,
2457
+ kind: "login-missing",
2458
+ credentialId: resolved.id,
2459
+ problem: `This machine no longer holds the sign-in for the AI credential "${resolved.label}". Delete it in Settings \u203A AI credentials and add it again.`
2460
+ };
2461
+ }
2462
+ return { secrets: bag(home), credentialId: resolved.id };
2463
+ }
2348
2464
  const token = await loadCredentialToken(input.db, input.shipId, resolved.id);
2349
2465
  if (!token) {
2350
2466
  return {
2351
2467
  secrets: null,
2468
+ kind: "no-token",
2469
+ credentialId: resolved.id,
2352
2470
  problem: `The AI credential "${resolved.label}" has no token stored. Erase it in Settings \u203A AI credentials and add it again.`
2353
2471
  };
2354
2472
  }
2355
- const key = getEngine(engineId).requiredSecrets[0]?.key;
2356
- if (!key) return { secrets: null };
2357
- return {
2358
- secrets: {
2359
- updatedAt: resolved.updatedAt,
2360
- updatedBy: resolved.createdBy,
2361
- [key]: token,
2362
- [`${key}Tail`]: resolved.tail
2363
- }
2364
- };
2473
+ return { secrets: bag(token), credentialId: resolved.id };
2365
2474
  }
2366
2475
 
2367
2476
  // src/jobs/assistantCredential.ts
2368
- async function resolveAssistantCredential(db, shipId) {
2477
+ async function resolveAssistantCredential(db, shipId, machine) {
2369
2478
  const shipSnap = await getDoc5(doc5(db, COLLECTIONS.ships, shipId));
2370
2479
  const settings = shipSnap.data()?.settings;
2371
2480
  const credentialId = settings?.assistant?.credentialId ?? settings?.defaultCredentialId;
@@ -2377,7 +2486,8 @@ async function resolveAssistantCredential(db, shipId) {
2377
2486
  shipId,
2378
2487
  // The synthetic agent: two fields, both of which this module has just decided.
2379
2488
  agent: { engine, credentialId },
2380
- credentials
2489
+ credentials,
2490
+ machine
2381
2491
  });
2382
2492
  return {
2383
2493
  secrets,
@@ -2394,12 +2504,20 @@ import fs3 from "node:fs";
2394
2504
  import os2 from "node:os";
2395
2505
  import path3 from "node:path";
2396
2506
 
2507
+ // src/engines/binary.ts
2508
+ function engineBinaryEnvVar(engineId) {
2509
+ return `CREW_${engineId.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_BIN`;
2510
+ }
2511
+ function engineBinary(engineId, defaultName) {
2512
+ return process.env[engineBinaryEnvVar(engineId)] || defaultName;
2513
+ }
2514
+
2397
2515
  // src/engines/claudeEvents.ts
2398
2516
  function stepsFromClaudeEvent(event, opts) {
2399
2517
  const type = typeof event?.type === "string" ? event.type : "";
2400
2518
  if (type === "assistant") {
2401
- const message2 = event.message;
2402
- const content = Array.isArray(message2?.content) ? message2.content : [];
2519
+ const message3 = event.message;
2520
+ const content = Array.isArray(message3?.content) ? message3.content : [];
2403
2521
  const steps = [];
2404
2522
  for (const block of content) {
2405
2523
  if (!block || typeof block !== "object") continue;
@@ -2416,11 +2534,14 @@ function stepsFromClaudeEvent(event, opts) {
2416
2534
  if (type === "result") return [{ kind: "done", label: "Finished" }];
2417
2535
  return [];
2418
2536
  }
2419
-
2420
- // src/engines/mcpHealth.ts
2421
- var CONSECUTIVE_LIMIT = 2;
2422
- var AUTH_MARKER = /\b401\b|unauthoriz|invalid[_ ]token|token .{0,20}expired|expired .{0,20}token|protected resource/i;
2423
- var WORKSPACE_TOOL_PREFIX = `mcp__${WORKSPACE_MCP_KEY}__`;
2537
+ var MCP_TOOL_PREFIX = "mcp__";
2538
+ function createClaudeToolPairing() {
2539
+ return { names: /* @__PURE__ */ new Map() };
2540
+ }
2541
+ function blocksOf(event) {
2542
+ const message3 = event.message;
2543
+ return Array.isArray(message3?.content) ? message3.content : [];
2544
+ }
2424
2545
  function resultText(block) {
2425
2546
  const content = block.content;
2426
2547
  if (typeof content === "string") return content;
@@ -2431,43 +2552,67 @@ function resultText(block) {
2431
2552
  }
2432
2553
  return "";
2433
2554
  }
2434
- function blocksOf(event) {
2435
- const message2 = event.message;
2436
- return Array.isArray(message2?.content) ? message2.content : [];
2555
+ function mcpCallsFromClaudeEvent(event, pairing) {
2556
+ const type = typeof event?.type === "string" ? event.type : "";
2557
+ if (type === "assistant") {
2558
+ for (const block of blocksOf(event)) {
2559
+ if (!block || typeof block !== "object") continue;
2560
+ if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
2561
+ pairing.names.set(block.id, block.name);
2562
+ }
2563
+ }
2564
+ return [];
2565
+ }
2566
+ if (type !== "user") return [];
2567
+ const calls = [];
2568
+ for (const block of blocksOf(event)) {
2569
+ if (!block || typeof block !== "object") continue;
2570
+ if (block.type !== "tool_result" || typeof block.tool_use_id !== "string") continue;
2571
+ const name = pairing.names.get(block.tool_use_id);
2572
+ if (!name) continue;
2573
+ const server = name.startsWith(MCP_TOOL_PREFIX) ? name.slice(MCP_TOOL_PREFIX.length).split("__")[0] ?? "" : "";
2574
+ calls.push({ server, ok: !block.is_error, text: resultText(block) });
2575
+ }
2576
+ return calls;
2437
2577
  }
2578
+
2579
+ // src/engines/githubEnv.ts
2580
+ function githubSessionEnv(token) {
2581
+ if (!token) return {};
2582
+ return {
2583
+ GH_TOKEN: token,
2584
+ GITHUB_TOKEN: token,
2585
+ GIT_CONFIG_COUNT: "1",
2586
+ GIT_CONFIG_KEY_0: "url.https://x-access-token:" + token + "@github.com/.insteadOf",
2587
+ GIT_CONFIG_VALUE_0: "https://github.com/"
2588
+ };
2589
+ }
2590
+
2591
+ // src/engines/limitWindow.ts
2592
+ var MAX_LIMIT_MS = 7 * 24 * 60 * 60 * 1e3 + 60 * 60 * 1e3;
2593
+ function saneResetAt(reported, now, maxMs = MAX_LIMIT_MS) {
2594
+ return Number.isFinite(reported) && reported > now && reported <= now + maxMs;
2595
+ }
2596
+
2597
+ // src/engines/mcpHealth.ts
2598
+ var CONSECUTIVE_LIMIT = 2;
2599
+ var AUTH_MARKER = /\b401\b|unauthoriz|invalid[_ ]token|token .{0,20}expired|expired .{0,20}token|protected resource/i;
2438
2600
  function createWorkspaceMcpWatch() {
2439
- const toolNames = /* @__PURE__ */ new Map();
2440
2601
  let consecutive = 0;
2441
2602
  let reported = false;
2442
2603
  return {
2443
- observe(event) {
2444
- if (reported) return null;
2445
- const type = typeof event?.type === "string" ? event.type : "";
2446
- if (type === "assistant") {
2447
- for (const block of blocksOf(event)) {
2448
- if (!block || typeof block !== "object") continue;
2449
- if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
2450
- toolNames.set(block.id, block.name);
2451
- }
2452
- }
2604
+ observe(call) {
2605
+ if (reported || !call || typeof call !== "object") return null;
2606
+ if (call.server !== WORKSPACE_MCP_KEY) return null;
2607
+ const text2 = typeof call.text === "string" ? call.text : "";
2608
+ if (call.ok || !AUTH_MARKER.test(text2)) {
2609
+ consecutive = 0;
2453
2610
  return null;
2454
2611
  }
2455
- if (type !== "user") return null;
2456
- for (const block of blocksOf(event)) {
2457
- if (!block || typeof block !== "object") continue;
2458
- if (block.type !== "tool_result" || typeof block.tool_use_id !== "string") continue;
2459
- const name = toolNames.get(block.tool_use_id);
2460
- if (!name || !name.startsWith(WORKSPACE_TOOL_PREFIX)) continue;
2461
- if (!block.is_error || !AUTH_MARKER.test(resultText(block))) {
2462
- consecutive = 0;
2463
- continue;
2464
- }
2465
- consecutive += 1;
2466
- if (consecutive < CONSECUTIVE_LIMIT) continue;
2467
- reported = true;
2468
- return `The Workspace MCP ("${WORKSPACE_MCP_KEY}") stopped accepting this session's credential part-way through the run: ${CONSECUTIVE_LIMIT} consecutive calls came back refused ("${resultText(block).slice(0, 160).trim()}"). Everything after that point would have run with none of this Ship's tools \u2014 no task_comment, no run_report, no chat_send \u2014 so the session was stopped rather than allowed to finish blind. The run is retried with a fresh credential.`;
2469
- }
2470
- return null;
2612
+ consecutive += 1;
2613
+ if (consecutive < CONSECUTIVE_LIMIT) return null;
2614
+ reported = true;
2615
+ return `The Workspace MCP ("${WORKSPACE_MCP_KEY}") stopped accepting this session's credential part-way through the run: ${CONSECUTIVE_LIMIT} consecutive calls came back refused ("${text2.slice(0, 160).trim()}"). Everything after that point would have run with none of this Ship's tools \u2014 no task_comment, no run_report, no chat_send \u2014 so the session was stopped rather than allowed to finish blind. The run is retried with a fresh credential.`;
2471
2616
  }
2472
2617
  };
2473
2618
  }
@@ -2507,12 +2652,11 @@ function normalizeClaudeUsage(resultEvent, model, fallbackDurationS) {
2507
2652
  }
2508
2653
  var LIMIT_RE = /usage limit (?:reached|exceeded|has been reached)/i;
2509
2654
  var LIMIT_RESET_RE = /\|\s*(\d{10})\b/;
2510
- var MAX_LIMIT_MS = 7 * 24 * 60 * 60 * 1e3 + 60 * 60 * 1e3;
2511
2655
  function detectClaudeLimit(text2, now, fallbackMs) {
2512
2656
  if (!text2 || !LIMIT_RE.test(text2)) return void 0;
2513
2657
  const match = LIMIT_RESET_RE.exec(text2);
2514
2658
  const reported = match ? Number(match[1]) * 1e3 : NaN;
2515
- const sane = Number.isFinite(reported) && reported > now && reported <= now + MAX_LIMIT_MS;
2659
+ const sane = saneResetAt(reported, now);
2516
2660
  return {
2517
2661
  resetsAt: sane ? reported : now + fallbackMs,
2518
2662
  resetsAtReported: sane,
@@ -2625,7 +2769,7 @@ function cleanupSessionDirs(dirs) {
2625
2769
  fs3.rmSync(dirs.configDir, { recursive: true, force: true });
2626
2770
  }
2627
2771
  async function runClaudeSession(input) {
2628
- const bin = process.env.CREW_CLAUDE_BIN || "claude";
2772
+ const bin = claudeBinary();
2629
2773
  if (input.signal?.aborted) {
2630
2774
  return {
2631
2775
  ok: false,
@@ -2657,7 +2801,7 @@ async function runSession(input, bin, dirs) {
2657
2801
  "--allowedTools",
2658
2802
  allowedTools(input.agent, input.extraMcpServers ?? []).join(",")
2659
2803
  ];
2660
- const claudeToken = input.secrets?.claudeToken;
2804
+ const claudeToken = engineSecret(input.secrets, CLAUDE_SECRET_KEY);
2661
2805
  const env = {
2662
2806
  ...process.env,
2663
2807
  // THE INIT EVENT MUST BE TRUTHFUL, and one inherited variable is enough to make it lie.
@@ -2676,16 +2820,7 @@ async function runSession(input, bin, dirs) {
2676
2820
  MCP_CONNECTION_NONBLOCKING: void 0,
2677
2821
  MCP_SERVER_CONNECTION_BATCH_SIZE: void 0,
2678
2822
  ...claudeToken ? { CLAUDE_CODE_OAUTH_TOKEN: claudeToken } : {},
2679
- ...input.githubToken ? {
2680
- GH_TOKEN: input.githubToken,
2681
- GITHUB_TOKEN: input.githubToken,
2682
- // Per-process git auth so bare `git clone https://github.com/o/r` works WITHOUT
2683
- // touching the machine's ~/.gitconfig. A GitHub App installation token authenticates
2684
- // as `x-access-token:<token>`; a classic PAT works the same way here.
2685
- GIT_CONFIG_COUNT: "1",
2686
- GIT_CONFIG_KEY_0: "url.https://x-access-token:" + input.githubToken + "@github.com/.insteadOf",
2687
- GIT_CONFIG_VALUE_0: "https://github.com/"
2688
- } : {}
2823
+ ...githubSessionEnv(input.githubToken)
2689
2824
  };
2690
2825
  const mcpNames = {};
2691
2826
  for (const s of input.extraMcpServers ?? []) mcpNames[s.key] = s.name || s.key;
@@ -2698,6 +2833,7 @@ async function runSession(input, bin, dirs) {
2698
2833
  const expectedServers = expectedMcpServers(input.agent, input.extraMcpServers ?? []);
2699
2834
  const mcpFailed = [];
2700
2835
  const mcpWatch = createWorkspaceMcpWatch();
2836
+ const pairing = createClaudeToolPairing();
2701
2837
  const exitCode = await new Promise((resolve) => {
2702
2838
  const child = spawn3(bin, args, { cwd: dirs.workdir, env, stdio: ["ignore", "pipe", "pipe"] });
2703
2839
  const killTimer = setTimeout(() => {
@@ -2744,7 +2880,9 @@ async function runSession(input, bin, dirs) {
2744
2880
  }
2745
2881
  }
2746
2882
  if (workspaceRequired && !mcpProblem) {
2747
- stopBlindSession(mcpWatch.observe(event));
2883
+ for (const call of mcpCallsFromClaudeEvent(event, pairing)) {
2884
+ stopBlindSession(mcpWatch.observe(call));
2885
+ }
2748
2886
  }
2749
2887
  if (input.onStep) {
2750
2888
  for (const step2 of stepsFromClaudeEvent(event, { mcpNames })) input.onStep(step2);
@@ -2776,9 +2914,9 @@ async function runSession(input, bin, dirs) {
2776
2914
  const durationS = Math.round((Date.now() - startedAt) / 1e3);
2777
2915
  const { usage, result } = normalizeClaudeUsage(resultEvent, input.agent.model, durationS);
2778
2916
  const ok2 = !mcpProblem && exitCode === 0 && !!resultEvent && !result.is_error;
2779
- const resultText2 = mcpProblem ?? result.result ?? (ok2 ? "" : `Session ended with exit code ${exitCode}${resultEvent ? "" : " and no result event"}.`);
2917
+ const resultText3 = mcpProblem ?? result.result ?? (ok2 ? "" : `Session ended with exit code ${exitCode}${resultEvent ? "" : " and no result event"}.`);
2780
2918
  const limit4 = ok2 ? void 0 : detectClaudeLimit(
2781
- `${resultText2}
2919
+ `${resultText3}
2782
2920
  ${stderrLines.slice(-20).join("\n")}`,
2783
2921
  Date.now(),
2784
2922
  engineUsageWindows(CLAUDE_DRIVER_ID)?.fallbackMs ?? 5 * 60 * 60 * 1e3
@@ -2788,12 +2926,17 @@ ${stderrLines.slice(-20).join("\n")}`,
2788
2926
  transcript: `${lines.join("\n")}
2789
2927
  `,
2790
2928
  usage,
2791
- resultText: resultText2,
2929
+ resultText: resultText3,
2792
2930
  ...limit4 ? { limit: limit4 } : {},
2793
2931
  ...mcpFailed.length ? { mcpFailed } : {}
2794
2932
  };
2795
2933
  }
2796
2934
  var CLAUDE_DRIVER_ID = "claude";
2935
+ var CLAUDE_SECRET_KEY = "claudeToken";
2936
+ var CLAUDE_SECRET_PATTERNS = Object.freeze([/sk-ant-[A-Za-z0-9_-]{8,}/g]);
2937
+ function claudeBinary() {
2938
+ return engineBinary(CLAUDE_DRIVER_ID, "claude");
2939
+ }
2797
2940
  function emptyUsage() {
2798
2941
  return {
2799
2942
  engine: CLAUDE_DRIVER_ID,
@@ -2806,7 +2949,7 @@ function emptyUsage() {
2806
2949
  };
2807
2950
  }
2808
2951
  async function claudeHealthCheck() {
2809
- const bin = process.env.CREW_CLAUDE_BIN || "claude";
2952
+ const bin = claudeBinary();
2810
2953
  return new Promise((resolve) => {
2811
2954
  let settled = false;
2812
2955
  const done = (health) => {
@@ -2828,7 +2971,7 @@ async function claudeHealthCheck() {
2828
2971
  done({
2829
2972
  ok: false,
2830
2973
  detail: `\`${bin}\` not found on PATH`,
2831
- fix: "Install the Claude CLI (https://claude.com/claude-code), or set CREW_CLAUDE_BIN to its path."
2974
+ fix: `Install the Claude CLI (https://claude.com/claude-code), or set ${engineBinaryEnvVar(CLAUDE_DRIVER_ID)} to its path.`
2832
2975
  });
2833
2976
  });
2834
2977
  child.on("close", (code) => {
@@ -2847,12 +2990,533 @@ async function claudeHealthCheck() {
2847
2990
  var claudeDriver = {
2848
2991
  engineId: CLAUDE_DRIVER_ID,
2849
2992
  run: runClaudeSession,
2850
- healthCheck: claudeHealthCheck
2993
+ healthCheck: claudeHealthCheck,
2994
+ binary: claudeBinary,
2995
+ secretValues: (secrets) => {
2996
+ const token = engineSecret(secrets, CLAUDE_SECRET_KEY);
2997
+ return token ? [token] : [];
2998
+ },
2999
+ secretPatterns: CLAUDE_SECRET_PATTERNS
3000
+ };
3001
+
3002
+ // src/engines/codex.ts
3003
+ import { spawn as spawn4 } from "node:child_process";
3004
+ import fs4 from "node:fs";
3005
+ import os3 from "node:os";
3006
+ import path4 from "node:path";
3007
+
3008
+ // src/engines/ansi.ts
3009
+ var ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
3010
+ function stripAnsi(text2) {
3011
+ return text2.replace(ANSI_RE, "");
3012
+ }
3013
+
3014
+ // src/engines/codexEvents.ts
3015
+ function itemOf(event) {
3016
+ const item = event.item;
3017
+ return item && typeof item === "object" ? item : null;
3018
+ }
3019
+ function stepsFromCodexEvent(event, opts) {
3020
+ const type = typeof event?.type === "string" ? event.type : "";
3021
+ if (type === "turn.completed") return [{ kind: "done", label: "Finished" }];
3022
+ const item = itemOf(event);
3023
+ if (!item) return [];
3024
+ if (type === "item.started") {
3025
+ switch (item.type) {
3026
+ case "command_execution":
3027
+ return [describeToolUse("Bash", { command: item.command })];
3028
+ case "mcp_tool_call":
3029
+ if (typeof item.server === "string" && typeof item.tool === "string") {
3030
+ return [describeToolUse(`mcp__${item.server}__${item.tool}`, item.arguments, { mcpNames: opts?.mcpNames })];
3031
+ }
3032
+ return [];
3033
+ case "reasoning":
3034
+ return [{ kind: "thinking", label: "Thinking" }];
3035
+ case "web_search":
3036
+ return [describeToolUse("WebSearch", { query: item.query })];
3037
+ case "todo_list":
3038
+ return [describeToolUse("TodoWrite", {})];
3039
+ default:
3040
+ return [];
3041
+ }
3042
+ }
3043
+ if (type === "item.completed") {
3044
+ switch (item.type) {
3045
+ case "agent_message":
3046
+ return typeof item.text === "string" && item.text.trim() ? [describeAssistantText(item.text)] : [];
3047
+ case "file_change":
3048
+ return (item.changes ?? []).filter((c) => c && typeof c === "object" && typeof c.path === "string").map((c) => describeToolUse("Edit", { file_path: c.path }));
3049
+ // A reasoning item that only ever completes (no started line) still deserves its label.
3050
+ case "reasoning":
3051
+ return [];
3052
+ default:
3053
+ return [];
3054
+ }
3055
+ }
3056
+ return [];
3057
+ }
3058
+ function mcpCallFromCodexEvent(event) {
3059
+ if (event?.type !== "item.completed") return null;
3060
+ const item = itemOf(event);
3061
+ if (!item || item.type !== "mcp_tool_call" || typeof item.server !== "string") return null;
3062
+ const error = item.error;
3063
+ const errorText = typeof error === "string" ? error : error && typeof error === "object" && typeof error.message === "string" ? error.message : "";
3064
+ const failed = !!error || item.status === "failed";
3065
+ const text2 = failed ? errorText || `status "${item.status ?? "unknown"}"` : resultText2(item.result);
3066
+ return { server: item.server, ok: !failed, text: text2 };
3067
+ }
3068
+ function resultText2(result) {
3069
+ if (typeof result === "string") return result.slice(0, 400);
3070
+ if (result && typeof result === "object") {
3071
+ const content = result.content;
3072
+ if (Array.isArray(content)) {
3073
+ return content.map(
3074
+ (part) => part && typeof part === "object" && typeof part.text === "string" ? part.text : ""
3075
+ ).join(" ").slice(0, 400);
3076
+ }
3077
+ try {
3078
+ return JSON.stringify(result).slice(0, 400);
3079
+ } catch {
3080
+ return "";
3081
+ }
3082
+ }
3083
+ return "";
3084
+ }
3085
+
3086
+ // src/engines/codex.ts
3087
+ var CODEX_DRIVER_ID = "codex";
3088
+ var CODEX_SECRET_KEY = "codexHome";
3089
+ var WORKSPACE_TOKEN_ENV = "CREW_MCP_TOKEN";
3090
+ var AUTH_FILE = "auth.json";
3091
+ var MCP_STARTUP_TIMEOUT_S = 60;
3092
+ var MCP_TOOL_TIMEOUT_S = 120;
3093
+ var MCP_TOOL_APPROVAL = "approve";
3094
+ function codexBinary() {
3095
+ return engineBinary(CODEX_DRIVER_ID, "codex");
3096
+ }
3097
+ function codexSandbox(agent) {
3098
+ return agent.autonomy <= 1 ? "read-only" : "workspace-write";
3099
+ }
3100
+ function tomlInline(value) {
3101
+ if (typeof value === "string") return tomlString(value);
3102
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
3103
+ if (typeof value === "boolean") return value ? "true" : "false";
3104
+ if (Array.isArray(value)) return `[${value.map(tomlInline).join(", ")}]`;
3105
+ if (value && typeof value === "object") {
3106
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0);
3107
+ return `{${entries.map(([k, v]) => `${tomlKey(k)} = ${tomlInline(v)}`).join(", ")}}`;
3108
+ }
3109
+ throw new Error(`cannot express ${typeof value} in TOML`);
3110
+ }
3111
+ function tomlString(s) {
3112
+ let out = '"';
3113
+ for (const ch of s) {
3114
+ const code = ch.charCodeAt(0);
3115
+ if (ch === '"') out += '\\"';
3116
+ else if (ch === "\\") out += "\\\\";
3117
+ else if (ch === "\n") out += "\\n";
3118
+ else if (ch === "\r") out += "\\r";
3119
+ else if (ch === " ") out += "\\t";
3120
+ else if (code < 32 || code === 127) out += `\\u${code.toString(16).padStart(4, "0")}`;
3121
+ else out += ch;
3122
+ }
3123
+ return out + '"';
3124
+ }
3125
+ function tomlKey(k) {
3126
+ return /^[A-Za-z0-9_-]+$/.test(k) ? k : tomlString(k);
3127
+ }
3128
+ function codexMcpOverrides(input) {
3129
+ const overrides = [];
3130
+ const add = (key, table) => {
3131
+ overrides.push("-c", `mcp_servers.${tomlKey(key)}=${tomlInline(table)}`);
3132
+ };
3133
+ add(WORKSPACE_MCP_KEY, {
3134
+ url: input.mcpUrl,
3135
+ required: true,
3136
+ bearer_token_env_var: WORKSPACE_TOKEN_ENV,
3137
+ startup_timeout_sec: MCP_STARTUP_TIMEOUT_S,
3138
+ tool_timeout_sec: MCP_TOOL_TIMEOUT_S,
3139
+ default_tools_approval_mode: MCP_TOOL_APPROVAL,
3140
+ http_headers: {
3141
+ "X-Crew-Ship-Id": input.shipId,
3142
+ // Sent for the audit trail; on the runner path the server keys on the job id and reads the
3143
+ // acting agent and task off the job's own document (see the Claude driver's headers note).
3144
+ "X-Crew-Agent-Id": input.agent.id,
3145
+ "X-Crew-Job-Id": input.job.id,
3146
+ // Exactly one of these — a chat job has no task and a task job no chat.
3147
+ ...input.job.taskId ? { "X-Crew-Task-Id": input.job.taskId } : {},
3148
+ ...input.job.chatId ? { "X-Crew-Chat-Id": input.job.chatId } : {}
3149
+ }
3150
+ });
3151
+ for (const s of input.extraServers ?? []) {
3152
+ if (s.key === WORKSPACE_MCP_KEY) continue;
3153
+ const tools = s.tools?.length ? { enabled_tools: s.tools } : {};
3154
+ if (s.transport === "stdio") {
3155
+ add(s.key, {
3156
+ command: s.command,
3157
+ ...s.args?.length ? { args: s.args } : {},
3158
+ ...s.env && Object.keys(s.env).length > 0 ? { env: s.env } : {},
3159
+ required: false,
3160
+ default_tools_approval_mode: MCP_TOOL_APPROVAL,
3161
+ ...tools
3162
+ });
3163
+ continue;
3164
+ }
3165
+ add(s.key, {
3166
+ url: s.url,
3167
+ ...s.headers && Object.keys(s.headers).length > 0 ? { http_headers: s.headers } : {},
3168
+ required: false,
3169
+ default_tools_approval_mode: MCP_TOOL_APPROVAL,
3170
+ ...tools
3171
+ });
3172
+ }
3173
+ return overrides;
3174
+ }
3175
+ function codexArgs(input) {
3176
+ const granted = effectiveAgentTools(input.agent);
3177
+ const sandbox = codexSandbox(input.agent);
3178
+ return [
3179
+ "exec",
3180
+ "--json",
3181
+ "--color",
3182
+ "never",
3183
+ "--ephemeral",
3184
+ "--ignore-user-config",
3185
+ "--skip-git-repo-check",
3186
+ "-C",
3187
+ input.workdir,
3188
+ "-s",
3189
+ sandbox,
3190
+ "-m",
3191
+ input.agent.model,
3192
+ // Never a prompt for approval: there is nobody at this terminal. Anything the sandbox refuses
3193
+ // is refused, and the model works around it or reports it.
3194
+ "-c",
3195
+ 'approval_policy="never"',
3196
+ // A writing agent that works on GitHub needs the network for `git`; a drafts-only one does
3197
+ // not get it, because it does not get a shell that writes either.
3198
+ ...sandbox === "workspace-write" && (granted.github.enabled || granted.bash) ? ["-c", "sandbox_workspace_write.network_access=true"] : [],
3199
+ ...codexMcpOverrides(input),
3200
+ input.prompt
3201
+ ];
3202
+ }
3203
+ function normalizeCodexUsage(usage, model, durationS) {
3204
+ const n = (v) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0;
3205
+ const inputTokens = n(usage?.input_tokens);
3206
+ const cachedInputTokens = Math.min(inputTokens, n(usage?.cached_input_tokens));
3207
+ const outputTokens = n(usage?.output_tokens);
3208
+ return {
3209
+ engine: CODEX_DRIVER_ID,
3210
+ inputTokens,
3211
+ cachedInputTokens,
3212
+ outputTokens,
3213
+ costUsd: estimateCostUsd(CODEX_DRIVER_ID, model, { inputTokens, cachedInputTokens, outputTokens }),
3214
+ costReported: false,
3215
+ model,
3216
+ durationS,
3217
+ ...usage ? { raw: { ...usage } } : {}
3218
+ };
3219
+ }
3220
+ var LIMIT_RE2 = /hit your usage limit|usage limit reached|usage_limit_(?:reached|exceeded)|rate_limit_exceeded/i;
3221
+ var LIMIT_RESET_RE2 = /resets?_at["'\s:=]+(\d{10,13})\b/;
3222
+ function detectCodexLimit(text2, now, fallbackMs) {
3223
+ if (!text2 || !LIMIT_RE2.test(text2)) return void 0;
3224
+ const match = LIMIT_RESET_RE2.exec(text2);
3225
+ const raw = match ? Number(match[1]) : NaN;
3226
+ const reported = Number.isFinite(raw) ? raw < 1e12 ? raw * 1e3 : raw : NaN;
3227
+ const sane = saneResetAt(reported, now);
3228
+ return {
3229
+ resetsAt: sane ? reported : now + fallbackMs,
3230
+ resetsAtReported: sane,
3231
+ detail: text2.trim().slice(0, 300)
3232
+ };
3233
+ }
3234
+ function requiredMcpFailures(stderr, expected) {
3235
+ const m = /required MCP servers failed to initialize:\s*(.+)$/im.exec(stderr);
3236
+ if (!m) return [];
3237
+ const out = [];
3238
+ for (const e of expected) {
3239
+ const re = new RegExp(`(?:^|[\\s,])${e.key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*([^\\n]*)`);
3240
+ const hit = re.exec(m[1]);
3241
+ if (hit) out.push({ key: e.key, label: e.label, detail: hit[1].trim().slice(0, 300) || "no reason given" });
3242
+ }
3243
+ return out;
3244
+ }
3245
+ function codexWorkspaceRefusal(failure) {
3246
+ return `The Codex CLI did not load ${failure.label} ("${failure.key}"): ${failure.detail}. The session would have had none of this Ship's tools \u2014 no task_comment, no run_report, no chat_send \u2014 so it was stopped instead of run blind. If this keeps happening, update the daemon and the Codex CLI on this machine.`;
3247
+ }
3248
+ var LOGIN_LOST_RE = /not logged in|login required|refresh_token_reused|could not be refreshed|please (?:try )?sign(?:ing)? in again/i;
3249
+ function detectLoginLost(stderr) {
3250
+ const line = stderr.split("\n").find((l) => LOGIN_LOST_RE.test(l));
3251
+ return line ? `This machine's ChatGPT sign-in is no longer usable: ${line.trim().slice(0, 200)}` : void 0;
3252
+ }
3253
+ var GUIDANCE_URL_RE = /https:\/\/auth\.openai\.com\/[^\s"'<>]+/;
3254
+ var GUIDANCE_CODE_RE = /\b[A-Z0-9]{4}-[A-Z0-9]{5}\b/;
3255
+ function decodeJwtPayload(token) {
3256
+ const parts = token.split(".");
3257
+ if (parts.length < 2) return null;
3258
+ try {
3259
+ return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
3260
+ } catch {
3261
+ return null;
3262
+ }
3263
+ }
3264
+ var codexLogin = {
3265
+ spawnArgs: () => ["login", "--device-auth"],
3266
+ logoutArgs: () => ["logout"],
3267
+ statusArgs: () => ["login", "status"],
3268
+ env: (home) => ({ CODEX_HOME: home }),
3269
+ isSignedIn: (home) => fs4.existsSync(path4.join(home, AUTH_FILE)),
3270
+ parseGuidance(text2) {
3271
+ const clean = stripAnsi(text2);
3272
+ const url = GUIDANCE_URL_RE.exec(clean)?.[0];
3273
+ const code = GUIDANCE_CODE_RE.exec(clean)?.[0];
3274
+ if (!url && !code) return null;
3275
+ return { ...url ? { url } : {}, ...code ? { code } : {} };
3276
+ },
3277
+ /**
3278
+ * The account behind a completed sign-in, off the id token's claims — decoded, NOT verified,
3279
+ * because nothing here trusts it for authorization: it names whose plan the credential row
3280
+ * shows. The refresh and access tokens are never read.
3281
+ */
3282
+ readAccount(home) {
3283
+ let parsed;
3284
+ try {
3285
+ parsed = JSON.parse(fs4.readFileSync(path4.join(home, AUTH_FILE), "utf8"));
3286
+ } catch {
3287
+ return null;
3288
+ }
3289
+ const idToken = parsed.tokens?.id_token;
3290
+ const claims = typeof idToken === "string" ? decodeJwtPayload(idToken) : null;
3291
+ const auth = claims?.["https://api.openai.com/auth"] ?? {};
3292
+ const accountId = typeof auth.chatgpt_account_id === "string" && auth.chatgpt_account_id || typeof parsed.tokens?.account_id === "string" && parsed.tokens.account_id || "";
3293
+ const activeUntilRaw = auth.chatgpt_subscription_active_until;
3294
+ const activeUntil = typeof activeUntilRaw === "string" && !Number.isNaN(Date.parse(activeUntilRaw)) ? Date.parse(activeUntilRaw) : typeof activeUntilRaw === "number" ? activeUntilRaw < 1e12 ? activeUntilRaw * 1e3 : activeUntilRaw : void 0;
3295
+ return {
3296
+ ...typeof claims?.email === "string" ? { email: claims.email } : {},
3297
+ ...typeof claims?.name === "string" ? { name: claims.name } : {},
3298
+ ...typeof auth.chatgpt_plan_type === "string" ? { plan: auth.chatgpt_plan_type } : {},
3299
+ ...activeUntil !== void 0 ? { activeUntil } : {},
3300
+ ...accountId ? { accountIdTail: accountId.slice(-4) } : {}
3301
+ };
3302
+ }
3303
+ };
3304
+ function emptyUsage2() {
3305
+ return {
3306
+ engine: CODEX_DRIVER_ID,
3307
+ inputTokens: 0,
3308
+ cachedInputTokens: 0,
3309
+ outputTokens: 0,
3310
+ costUsd: 0,
3311
+ costReported: false,
3312
+ durationS: 0
3313
+ };
3314
+ }
3315
+ function refused(resultText3, extra = {}) {
3316
+ return { ok: false, transcript: "", usage: emptyUsage2(), resultText: resultText3, ...extra };
3317
+ }
3318
+ async function runCodexSession(input) {
3319
+ const bin = codexBinary();
3320
+ if (input.signal?.aborted) {
3321
+ return refused("Session cancelled before it started (runner shutting down).");
3322
+ }
3323
+ const home = engineSecret(input.secrets, CODEX_SECRET_KEY);
3324
+ if (!home) {
3325
+ return refused("This machine holds no ChatGPT sign-in for this credential.");
3326
+ }
3327
+ if (!codexLogin.isSignedIn(home)) {
3328
+ return refused(
3329
+ "This machine's ChatGPT sign-in is gone \u2014 delete the credential in Settings and add it again.",
3330
+ { credentialLost: "The sign-in directory on this machine no longer holds a login." }
3331
+ );
3332
+ }
3333
+ const workdir = fs4.mkdtempSync(path4.join(os3.tmpdir(), `crew-job-${input.job.id}-`));
3334
+ try {
3335
+ return await runSession2(input, bin, home, workdir);
3336
+ } finally {
3337
+ fs4.rmSync(workdir, { recursive: true, force: true });
3338
+ }
3339
+ }
3340
+ async function runSession2(input, bin, home, workdir) {
3341
+ const args = codexArgs({ ...input, extraServers: input.extraMcpServers, workdir });
3342
+ const env = {
3343
+ ...process.env,
3344
+ ...codexLogin.env(home),
3345
+ [WORKSPACE_TOKEN_ENV]: input.idToken,
3346
+ ...githubSessionEnv(input.githubToken)
3347
+ };
3348
+ const mcpNames = {};
3349
+ for (const s of input.extraMcpServers ?? []) mcpNames[s.key] = s.name || s.key;
3350
+ const startedAt = Date.now();
3351
+ const lines = [];
3352
+ const stderrLines = [];
3353
+ const seen = {
3354
+ completed: null,
3355
+ failed: null,
3356
+ lastMessage: ""
3357
+ };
3358
+ let mcpProblem = null;
3359
+ const workspaceRequired = effectiveAgentTools(input.agent).workspaceMcp;
3360
+ const expected = [
3361
+ ...workspaceRequired ? [{ key: WORKSPACE_MCP_KEY, label: "the Workspace MCP" }] : [],
3362
+ ...(input.extraMcpServers ?? []).map((s) => ({ key: s.key, label: s.name || s.key }))
3363
+ ];
3364
+ const mcpFailed = [];
3365
+ const mcpWatch = createWorkspaceMcpWatch();
3366
+ const exitCode = await new Promise((resolve) => {
3367
+ const child = spawn4(bin, args, { cwd: workdir, env, stdio: ["ignore", "pipe", "pipe"] });
3368
+ const killTimer = setTimeout(() => {
3369
+ input.log(`Session timeout after ${Math.round(input.timeoutMs / 6e4)} min \u2014 killing.`);
3370
+ child.kill("SIGTERM");
3371
+ setTimeout(() => child.kill("SIGKILL"), 1e4).unref();
3372
+ }, input.timeoutMs);
3373
+ const onAbort = () => {
3374
+ input.log("Daemon shutting down \u2014 stopping the session.");
3375
+ child.kill("SIGTERM");
3376
+ setTimeout(() => child.kill("SIGKILL"), 3e3).unref();
3377
+ };
3378
+ input.signal?.addEventListener("abort", onAbort, { once: true });
3379
+ const detachAbort = () => input.signal?.removeEventListener("abort", onAbort);
3380
+ const stopBlindSession = (problem) => {
3381
+ if (!problem) return;
3382
+ mcpProblem = problem;
3383
+ input.log(problem);
3384
+ child.kill("SIGTERM");
3385
+ setTimeout(() => child.kill("SIGKILL"), 3e3).unref();
3386
+ };
3387
+ let buffer = "";
3388
+ child.stdout.on("data", (chunk) => {
3389
+ buffer += chunk.toString("utf8");
3390
+ for (; ; ) {
3391
+ const nl = buffer.indexOf("\n");
3392
+ if (nl < 0) break;
3393
+ const line = buffer.slice(0, nl).trim();
3394
+ buffer = buffer.slice(nl + 1);
3395
+ if (!line) continue;
3396
+ lines.push(line);
3397
+ try {
3398
+ const event = JSON.parse(line);
3399
+ if (event.type === "turn.completed") seen.completed = event;
3400
+ if (event.type === "turn.failed") seen.failed = event;
3401
+ const item = event.item;
3402
+ if (event.type === "item.completed" && item?.type === "agent_message" && typeof item.text === "string") {
3403
+ seen.lastMessage = item.text;
3404
+ }
3405
+ if (workspaceRequired && !mcpProblem) {
3406
+ stopBlindSession(mcpWatch.observe(mcpCallFromCodexEvent(event)));
3407
+ }
3408
+ if (input.onStep) {
3409
+ for (const step2 of stepsFromCodexEvent(event, { mcpNames })) input.onStep(step2);
3410
+ }
3411
+ } catch {
3412
+ }
3413
+ }
3414
+ });
3415
+ child.stderr.on("data", (chunk) => {
3416
+ const text2 = chunk.toString("utf8").trim();
3417
+ if (text2) {
3418
+ lines.push(JSON.stringify({ type: "stderr", text: text2 }));
3419
+ stderrLines.push(text2);
3420
+ input.log(`codex stderr: ${text2.slice(0, 200)}`);
3421
+ }
3422
+ });
3423
+ child.on("error", (e) => {
3424
+ lines.push(JSON.stringify({ type: "stderr", text: `spawn error: ${e.message}` }));
3425
+ stderrLines.push(`spawn error: ${e.message}`);
3426
+ clearTimeout(killTimer);
3427
+ detachAbort();
3428
+ resolve(127);
3429
+ });
3430
+ child.on("close", (code) => {
3431
+ clearTimeout(killTimer);
3432
+ detachAbort();
3433
+ resolve(code ?? 1);
3434
+ });
3435
+ });
3436
+ const durationS = Math.round((Date.now() - startedAt) / 1e3);
3437
+ const stderr = stderrLines.join("\n");
3438
+ const usage = normalizeCodexUsage(seen.completed?.usage ?? null, input.agent.model, durationS);
3439
+ for (const failure of requiredMcpFailures(stderr, expected)) {
3440
+ if (failure.key === WORKSPACE_MCP_KEY) {
3441
+ if (!mcpProblem) mcpProblem = codexWorkspaceRefusal(failure);
3442
+ } else if (!mcpFailed.includes(failure.key)) {
3443
+ input.log(`MCP connection "${failure.key}" did not load: ${failure.detail}.`);
3444
+ mcpFailed.push(failure.key);
3445
+ }
3446
+ }
3447
+ const failedError = seen.failed?.error;
3448
+ const failedMessage = typeof failedError?.message === "string" ? failedError.message : "";
3449
+ const ok2 = exitCode === 0 && seen.completed !== null && seen.failed === null && !mcpProblem;
3450
+ const transcript = lines.join("\n") + "\n";
3451
+ if (mcpProblem) {
3452
+ return { ok: false, transcript, usage, resultText: mcpProblem, ...mcpFailed.length ? { mcpFailed } : {} };
3453
+ }
3454
+ const failureText = [failedMessage, stderr.slice(-2e3)].filter(Boolean).join("\n");
3455
+ const windows = engineUsageWindows(CODEX_DRIVER_ID);
3456
+ const limit4 = !ok2 && windows ? detectCodexLimit(failureText, Date.now(), windows.fallbackMs) : void 0;
3457
+ const credentialLost = !ok2 && !limit4 ? detectLoginLost(stderr) : void 0;
3458
+ const resultText3 = ok2 ? seen.lastMessage : failedMessage || stderr.trim().split("\n").slice(-3).join("\n") || `Codex exited ${exitCode}`;
3459
+ return {
3460
+ ok: ok2,
3461
+ transcript,
3462
+ usage,
3463
+ resultText: resultText3.slice(0, 2e4),
3464
+ ...limit4 ? { limit: limit4 } : {},
3465
+ ...credentialLost ? { credentialLost } : {},
3466
+ ...mcpFailed.length ? { mcpFailed } : {}
3467
+ };
3468
+ }
3469
+ async function codexHealthCheck() {
3470
+ const bin = codexBinary();
3471
+ return new Promise((resolve) => {
3472
+ let settled = false;
3473
+ const done = (health) => {
3474
+ if (settled) return;
3475
+ settled = true;
3476
+ resolve({ ...health, binary: bin });
3477
+ };
3478
+ let stdout = "";
3479
+ const child = spawn4(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
3480
+ const timer = setTimeout(() => {
3481
+ child.kill("SIGKILL");
3482
+ done({ ok: false, detail: `\`${bin} --version\` timed out`, fix: "Check the Codex CLI install." });
3483
+ }, 1e4);
3484
+ child.stdout.on("data", (chunk) => {
3485
+ stdout += chunk.toString("utf8");
3486
+ });
3487
+ child.on("error", () => {
3488
+ clearTimeout(timer);
3489
+ done({
3490
+ ok: false,
3491
+ detail: `\`${bin}\` not found on PATH`,
3492
+ fix: `Install the Codex CLI (https://developers.openai.com/codex), or set ${engineBinaryEnvVar(CODEX_DRIVER_ID)} to its path.`
3493
+ });
3494
+ });
3495
+ child.on("close", (code) => {
3496
+ clearTimeout(timer);
3497
+ const version = stdout.trim().split("\n")[0] || "(no version reported)";
3498
+ done(
3499
+ code === 0 ? { ok: true, detail: `${bin} \u2014 ${version}` } : { ok: false, detail: `\`${bin} --version\` exited ${code}`, fix: "Reinstall or repair the Codex CLI." }
3500
+ );
3501
+ });
3502
+ });
3503
+ }
3504
+ var codexDriver = {
3505
+ engineId: CODEX_DRIVER_ID,
3506
+ run: runCodexSession,
3507
+ healthCheck: codexHealthCheck,
3508
+ binary: codexBinary,
3509
+ // The value is a DIRECTORY, not a secret: nothing to redact, and redacting a path would scrub
3510
+ // every transcript line that mentioned the workdir's parent.
3511
+ secretValues: (_secrets) => [],
3512
+ secretPatterns: Object.freeze([]),
3513
+ login: codexLogin
2851
3514
  };
2852
3515
 
2853
3516
  // src/engines/index.ts
2854
3517
  var DRIVERS = {
2855
- claude: claudeDriver
3518
+ claude: claudeDriver,
3519
+ codex: codexDriver
2856
3520
  };
2857
3521
  function getDriver(engineId) {
2858
3522
  return engineId && Object.hasOwn(DRIVERS, engineId) ? DRIVERS[engineId] : DRIVERS[DEFAULT_ENGINE_ID];
@@ -2976,7 +3640,7 @@ function mcpSecretsToRedact(servers) {
2976
3640
  }
2977
3641
 
2978
3642
  // src/jobs/localProbe.ts
2979
- import { spawn as spawn4 } from "node:child_process";
3643
+ import { spawn as spawn5 } from "node:child_process";
2980
3644
  var PROBE_TIMEOUT_MS = 15e3;
2981
3645
  var PROTOCOL_VERSION = "2024-11-05";
2982
3646
  async function probeAndReport(input) {
@@ -3018,7 +3682,7 @@ async function probe(server) {
3018
3682
  }
3019
3683
  }
3020
3684
  async function probeStdio(server) {
3021
- const child = spawn4(server.command ?? "", server.args ?? [], {
3685
+ const child = spawn5(server.command ?? "", server.args ?? [], {
3022
3686
  // The parent's environment PLUS the Ship's, not the Ship's alone: a program started with no
3023
3687
  // PATH cannot find node, and `npx` cannot find anything at all. The Ship's values win, which is
3024
3688
  // what lets a connection override a variable the operator happens to have set.
@@ -3209,6 +3873,276 @@ function serversNeedingProbe(servers) {
3209
3873
  );
3210
3874
  }
3211
3875
 
3876
+ // src/jobs/credentialGate.ts
3877
+ function credentialGate(agent, credentials, runnerId, busyCredentialIds, defaultEngine) {
3878
+ if (!agent) return "ok";
3879
+ const resolved = resolveAgentCredential(agent, [...credentials], defaultEngine);
3880
+ if (!resolved || !isMachineHeldCredential(resolved)) return "ok";
3881
+ if (resolved.runnerId !== runnerId) return "other-machine";
3882
+ if (!isCredentialReady(resolved)) return "not-ready";
3883
+ if (busyCredentialIds.has(resolved.id)) return "busy";
3884
+ return "ok";
3885
+ }
3886
+
3887
+ // src/jobs/engineHome.ts
3888
+ import path5 from "node:path";
3889
+ var SEGMENT = /^[A-Za-z0-9_-]+$/;
3890
+ function engineCredentialHome(root, engineId, shipId, credentialId) {
3891
+ for (const segment of [engineId, shipId, credentialId]) {
3892
+ if (!SEGMENT.test(segment)) throw new Error(`not a valid path segment: ${JSON.stringify(segment)}`);
3893
+ }
3894
+ return path5.join(root, "engines", engineId, shipId, credentialId);
3895
+ }
3896
+
3897
+ // src/jobs/engineLogin.ts
3898
+ import { spawn as nodeSpawn } from "node:child_process";
3899
+ import fs5 from "node:fs";
3900
+ import path6 from "node:path";
3901
+ var DEVICE_CODE_TTL_MS = 15 * 60 * 1e3;
3902
+ function machineLoginTimeoutMs() {
3903
+ const override = Number(process.env.CREW_MACHINE_LOGIN_TIMEOUT_MS);
3904
+ return Number.isFinite(override) && override > 0 ? override : DEVICE_CODE_TTL_MS + 60 * 1e3;
3905
+ }
3906
+ var GUIDANCE_SETTLE_MS = 2e3;
3907
+ function credentialsNeedingLogin(credentials, runnerId, now) {
3908
+ const start = [];
3909
+ const expire = [];
3910
+ for (const c of credentials) {
3911
+ if (c.runnerId !== runnerId || !isMachineLoginEngine(c.engine)) continue;
3912
+ const verdict = machineLoginPending(c, now);
3913
+ if (verdict === "start") start.push(c);
3914
+ else if (verdict === "expire") expire.push(c);
3915
+ }
3916
+ return { start, expire };
3917
+ }
3918
+ function credentialsToForget(previouslyHeld, current) {
3919
+ const live = new Set(current.map((c) => c.id));
3920
+ return [...previouslyHeld].filter((id) => !live.has(id));
3921
+ }
3922
+ function orphanHomes(root, shipId, liveIds) {
3923
+ const enginesDir = path6.join(root, "engines");
3924
+ let engines = [];
3925
+ try {
3926
+ engines = fs5.readdirSync(enginesDir);
3927
+ } catch {
3928
+ return [];
3929
+ }
3930
+ const out = [];
3931
+ for (const engineId of engines) {
3932
+ const shipDir = path6.join(enginesDir, engineId, shipId);
3933
+ let creds = [];
3934
+ try {
3935
+ creds = fs5.readdirSync(shipDir);
3936
+ } catch {
3937
+ continue;
3938
+ }
3939
+ for (const credId of creds) if (!liveIds.has(credId)) out.push(path6.join(shipDir, credId));
3940
+ }
3941
+ return out;
3942
+ }
3943
+ async function runMachineLogin(input) {
3944
+ const spawn8 = input.spawn ?? nodeSpawn;
3945
+ if (input.signal?.aborted) return { phase: "failed", error: "The runner was stopping." };
3946
+ try {
3947
+ fs5.mkdirSync(input.home, { recursive: true, mode: 448 });
3948
+ } catch (e) {
3949
+ return { phase: "failed", error: `Could not create the sign-in directory: ${message2(e)}` };
3950
+ }
3951
+ return new Promise((resolve) => {
3952
+ let child;
3953
+ try {
3954
+ child = spawn8(input.bin, input.driver.spawnArgs(), {
3955
+ env: { ...process.env, ...input.driver.env(input.home) },
3956
+ stdio: ["ignore", "pipe", "pipe"]
3957
+ });
3958
+ } catch (e) {
3959
+ resolve({ phase: "failed", error: notInstalled(input.engineId, input.bin, message2(e)) });
3960
+ return;
3961
+ }
3962
+ let settled = false;
3963
+ let text2 = "";
3964
+ let stderr = "";
3965
+ let guided = false;
3966
+ let settleTimer = null;
3967
+ let expired = false;
3968
+ const finish = (outcome) => {
3969
+ if (settled) return;
3970
+ settled = true;
3971
+ clearTimeout(deadline);
3972
+ if (settleTimer) clearTimeout(settleTimer);
3973
+ input.signal?.removeEventListener("abort", onAbort);
3974
+ resolve(outcome);
3975
+ };
3976
+ const guide = () => {
3977
+ if (guided || !text2.trim()) return;
3978
+ guided = true;
3979
+ if (settleTimer) clearTimeout(settleTimer);
3980
+ const parsed = input.driver.parseGuidance(text2) ?? {};
3981
+ try {
3982
+ input.onGuidance({ text: text2, ...parsed });
3983
+ } catch (e) {
3984
+ input.log(`sign-in guidance callback failed: ${message2(e)}`);
3985
+ }
3986
+ };
3987
+ const onOutput = (chunk, isErr) => {
3988
+ const clean = stripAnsi(chunk.toString("utf8"));
3989
+ if (isErr) stderr += clean;
3990
+ text2 += clean;
3991
+ if (guided) return;
3992
+ if (input.driver.parseGuidance(text2)) guide();
3993
+ else if (!settleTimer) settleTimer = setTimeout(guide, GUIDANCE_SETTLE_MS);
3994
+ };
3995
+ child.stdout?.on("data", (c) => onOutput(c, false));
3996
+ child.stderr?.on("data", (c) => onOutput(c, true));
3997
+ const deadline = setTimeout(() => {
3998
+ expired = true;
3999
+ input.log("Sign-in did not complete before the code expired \u2014 stopping it.");
4000
+ child.kill("SIGTERM");
4001
+ setTimeout(() => child.kill("SIGKILL"), 5e3).unref();
4002
+ }, input.timeoutMs);
4003
+ const onAbort = () => {
4004
+ child.kill("SIGTERM");
4005
+ setTimeout(() => child.kill("SIGKILL"), 3e3).unref();
4006
+ };
4007
+ input.signal?.addEventListener("abort", onAbort, { once: true });
4008
+ child.on("error", (e) => {
4009
+ finish({
4010
+ phase: "failed",
4011
+ error: e.code === "ENOENT" ? notInstalled(input.engineId, input.bin, e.message) : `Could not start the sign-in: ${e.message}`
4012
+ });
4013
+ });
4014
+ child.on("close", (code) => {
4015
+ if (expired) {
4016
+ finish({ phase: "expired" });
4017
+ return;
4018
+ }
4019
+ if (input.signal?.aborted) {
4020
+ finish({ phase: "failed", error: "The runner stopped before the sign-in completed." });
4021
+ return;
4022
+ }
4023
+ if (code === 0 && input.driver.isSignedIn(input.home)) {
4024
+ finish({ phase: "ready", account: input.driver.readAccount(input.home) });
4025
+ return;
4026
+ }
4027
+ const tail2 = stderr.trim().split("\n").slice(-3).join(" ").trim() || text2.trim().split("\n").slice(-2).join(" ").trim();
4028
+ finish({
4029
+ phase: "failed",
4030
+ error: code === 0 ? "The sign-in finished but left no login behind." : `The sign-in exited ${code}${tail2 ? `: ${tail2}` : "."}`
4031
+ });
4032
+ });
4033
+ });
4034
+ }
4035
+ function notInstalled(engineId, bin, detail) {
4036
+ return `\`${bin}\` is not installed on this machine (${detail}). Install the engine's CLI, or set ${engineBinaryEnvVar(engineId)} to its path.`;
4037
+ }
4038
+ async function logoutAndRemove(input) {
4039
+ const spawn8 = input.spawn ?? nodeSpawn;
4040
+ if (fs5.existsSync(input.home)) {
4041
+ await new Promise((resolve) => {
4042
+ let child;
4043
+ try {
4044
+ child = spawn8(input.bin, input.driver.logoutArgs(), {
4045
+ env: { ...process.env, ...input.driver.env(input.home) },
4046
+ stdio: "ignore"
4047
+ });
4048
+ } catch {
4049
+ resolve();
4050
+ return;
4051
+ }
4052
+ const timer = setTimeout(() => {
4053
+ child.kill("SIGKILL");
4054
+ resolve();
4055
+ }, 15e3);
4056
+ child.on("error", () => {
4057
+ clearTimeout(timer);
4058
+ resolve();
4059
+ });
4060
+ child.on("close", () => {
4061
+ clearTimeout(timer);
4062
+ resolve();
4063
+ });
4064
+ });
4065
+ }
4066
+ try {
4067
+ fs5.rmSync(input.home, { recursive: true, force: true });
4068
+ } catch (e) {
4069
+ input.log(`Could not remove the sign-in directory ${input.home}: ${message2(e)}`);
4070
+ }
4071
+ }
4072
+ async function reportLogin(input) {
4073
+ try {
4074
+ await callFunction(functionsBaseUrl(input.config), input.idToken, "crewShips", "reportCredentialLogin", {
4075
+ shipId: input.shipId,
4076
+ credentialId: input.credentialId,
4077
+ ...input.report
4078
+ });
4079
+ } catch (e) {
4080
+ input.log(`Could not report the sign-in (${input.report.phase}) for ${input.credentialId}: ${message2(e)}`);
4081
+ }
4082
+ }
4083
+ async function machineLoginAndReport(input) {
4084
+ const { credential, shipId } = input;
4085
+ const driver = getDriver(credential.engine);
4086
+ const say2 = (report4) => input.idToken().then((idToken) => reportLogin({ config: input.config, idToken, shipId, credentialId: credential.id, report: report4, log: input.log })).catch((e) => input.log(`Could not mint a token to report the sign-in: ${message2(e)}`));
4087
+ if (!driver.login) {
4088
+ await say2({ phase: "failed", error: `Engine "${credential.engine}" does not sign in from a machine.` });
4089
+ return;
4090
+ }
4091
+ let home;
4092
+ try {
4093
+ home = engineCredentialHome(input.root, credential.engine, shipId, credential.id);
4094
+ } catch (e) {
4095
+ await say2({ phase: "failed", error: message2(e) });
4096
+ return;
4097
+ }
4098
+ input.log(`Signing this machine in for "${credential.label}" (${credential.engine}) \u2014 a captain asked.`);
4099
+ const outcome = await runMachineLogin({
4100
+ driver: driver.login,
4101
+ engineId: credential.engine,
4102
+ bin: driver.binary(),
4103
+ home,
4104
+ timeoutMs: machineLoginTimeoutMs(),
4105
+ signal: input.signal,
4106
+ spawn: input.spawn,
4107
+ log: input.log,
4108
+ onGuidance: (guidance) => {
4109
+ void say2({
4110
+ phase: "code",
4111
+ text: guidance.text,
4112
+ ...guidance.code ? { code: guidance.code } : {},
4113
+ ...guidance.url ? { url: guidance.url } : {},
4114
+ expiresAt: Date.now() + DEVICE_CODE_TTL_MS
4115
+ });
4116
+ }
4117
+ });
4118
+ if (outcome.phase === "ready") {
4119
+ const a = outcome.account;
4120
+ input.log(`Signed in for "${credential.label}"${a?.email ? ` as ${a.email}` : ""}.`);
4121
+ await say2({
4122
+ phase: "ready",
4123
+ ...a ? { account: { ...a.email ? { email: a.email } : {}, ...a.plan ? { plan: a.plan } : {}, ...a.activeUntil ? { activeUntil: a.activeUntil } : {} } } : {},
4124
+ ...a?.accountIdTail ? { accountIdTail: a.accountIdTail } : {}
4125
+ });
4126
+ return;
4127
+ }
4128
+ if (outcome.phase === "expired") {
4129
+ input.log(`The sign-in for "${credential.label}" expired before it was approved.`);
4130
+ try {
4131
+ fs5.rmSync(home, { recursive: true, force: true });
4132
+ } catch {
4133
+ }
4134
+ await say2({ phase: "expired" });
4135
+ return;
4136
+ }
4137
+ input.log(`The sign-in for "${credential.label}" failed: ${outcome.error}`);
4138
+ try {
4139
+ fs5.rmSync(home, { recursive: true, force: true });
4140
+ } catch {
4141
+ }
4142
+ await say2({ phase: "failed", error: outcome.error });
4143
+ }
4144
+ var message2 = (e) => e instanceof Error ? e.message : String(e);
4145
+
3212
4146
  // src/jobs/engineLimits.ts
3213
4147
  import {
3214
4148
  collection as collection6,
@@ -3352,30 +4286,34 @@ import {
3352
4286
  where as where3
3353
4287
  } from "firebase/firestore";
3354
4288
  import { ref as storageRef, uploadBytes } from "firebase/storage";
3355
- function redactTranscript(transcript, knownSecrets) {
4289
+ function redactTranscript(transcript, knownSecrets, patterns = []) {
3356
4290
  let out = transcript;
3357
4291
  for (const secret of knownSecrets) {
3358
4292
  if (secret && secret.length >= 8) out = out.split(secret).join("[REDACTED]");
3359
4293
  }
3360
- out = out.replace(/sk-ant-[A-Za-z0-9_-]{8,}/g, "[REDACTED]");
4294
+ for (const pattern of patterns) {
4295
+ const flags = pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g";
4296
+ out = out.replace(new RegExp(pattern.source, flags), "[REDACTED]");
4297
+ }
3361
4298
  out = out.replace(/gh[pousr]_[A-Za-z0-9]{20,}/g, "[REDACTED]");
3362
4299
  out = out.replace(/github_pat_[A-Za-z0-9_]{20,}/g, "[REDACTED]");
3363
4300
  out = out.replace(/ya29\.[A-Za-z0-9_-]{20,}/g, "[REDACTED]");
3364
4301
  out = out.replace(/crewmcp_[A-Za-z0-9]+_[a-f0-9]{64}/g, "[REDACTED]");
4302
+ out = out.replace(/eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/g, "[REDACTED]");
3365
4303
  return out;
3366
4304
  }
3367
4305
  async function uploadTranscript(storage, shipId, jobId, redacted) {
3368
- const path9 = `crew/${shipId}/transcripts/${jobId}.jsonl`;
3369
- await uploadBytes(storageRef(storage, path9), new TextEncoder().encode(redacted), {
4306
+ const path13 = `crew/${shipId}/transcripts/${jobId}.jsonl`;
4307
+ await uploadBytes(storageRef(storage, path13), new TextEncoder().encode(redacted), {
3370
4308
  contentType: "application/x-ndjson"
3371
4309
  });
3372
- return path9;
4310
+ return path13;
3373
4311
  }
3374
4312
  function utcDay(millis) {
3375
4313
  return new Date(millis).toISOString().slice(0, 10);
3376
4314
  }
3377
- function backstopReportContent(resultText2) {
3378
- const text2 = resultText2.trim();
4315
+ function backstopReportContent(resultText3) {
4316
+ const text2 = resultText3.trim();
3379
4317
  if (!text2) {
3380
4318
  const none = "This run ended without writing a report, and left no final text to fall back on.";
3381
4319
  return { report: none, summary: none };
@@ -3589,8 +4527,8 @@ function stripSessionNarration(text2) {
3589
4527
  if (dropped === 0 || dropped === paragraphs.length) return text2;
3590
4528
  return text2.slice(paragraphs[dropped].start).trim();
3591
4529
  }
3592
- function backstopReplyContent(resultText2) {
3593
- const text2 = stripSessionNarration(resultText2.trim());
4530
+ function backstopReplyContent(resultText3) {
4531
+ const text2 = stripSessionNarration(resultText3.trim());
3594
4532
  if (!text2) {
3595
4533
  return "I finished that run without writing a reply. Write again to start a fresh one.";
3596
4534
  }
@@ -3598,7 +4536,7 @@ function backstopReplyContent(resultText2) {
3598
4536
  const marker = "\n\n\u2026(truncated)";
3599
4537
  return `${text2.slice(0, MAX_CHAT_MESSAGE_CHARS - marker.length)}${marker}`;
3600
4538
  }
3601
- async function ensureChatReply(db, shipId, job, resultText2) {
4539
+ async function ensureChatReply(db, shipId, job, resultText3) {
3602
4540
  const chatRef = doc8(db, COLLECTIONS.ships, shipId, COLLECTIONS.chats, job.chatId);
3603
4541
  const messagesCol = collection7(chatRef, COLLECTIONS.chatMessages);
3604
4542
  const snap = await getDocs6(
@@ -3614,7 +4552,7 @@ async function ensureChatReply(db, shipId, job, resultText2) {
3614
4552
  return author?.type === "agent" && author.id === job.agentId;
3615
4553
  });
3616
4554
  if (replied) return "agent-replied";
3617
- const content = backstopReplyContent(resultText2);
4555
+ const content = backstopReplyContent(resultText3);
3618
4556
  await addDoc(messagesCol, {
3619
4557
  author: { type: "agent", id: job.agentId },
3620
4558
  content,
@@ -3622,17 +4560,17 @@ async function ensureChatReply(db, shipId, job, resultText2) {
3622
4560
  createdAt: Date.now(),
3623
4561
  jobId: job.id
3624
4562
  });
3625
- return resultText2.trim() ? "posted-final-text" : "posted-silence-note";
4563
+ return resultText3.trim() ? "posted-final-text" : "posted-silence-note";
3626
4564
  }
3627
4565
 
3628
4566
  // src/jobs/progress.ts
3629
4567
  var PROGRESS_FLUSH_MS = 3e3;
3630
4568
  var PROGRESS_DENIALS_BEFORE_PROBE = 2;
3631
- function redactStep(step2, secrets) {
3632
- const label = clampStepText(redactTranscript(step2.label, secrets), MAX_STEP_LABEL);
4569
+ function redactStep(step2, secrets, patterns = []) {
4570
+ const label = clampStepText(redactTranscript(step2.label, secrets, patterns), MAX_STEP_LABEL);
3633
4571
  const base = { at: step2.at, kind: step2.kind, label };
3634
4572
  if (step2.detail === void 0) return base;
3635
- const detail = clampStepText(redactTranscript(step2.detail, secrets), MAX_STEP_DETAIL);
4573
+ const detail = clampStepText(redactTranscript(step2.detail, secrets, patterns), MAX_STEP_DETAIL);
3636
4574
  return detail ? { ...base, detail } : base;
3637
4575
  }
3638
4576
  function errorCode(error) {
@@ -3664,7 +4602,8 @@ function createProgressWriter(deps) {
3664
4602
  }
3665
4603
  if (closed || inFlight || pending.length === 0) return;
3666
4604
  const secrets = deps.secrets();
3667
- for (const step2 of pending) progress = pushJobStep(progress, redactStep(step2, secrets));
4605
+ const patterns = deps.patterns?.() ?? [];
4606
+ for (const step2 of pending) progress = pushJobStep(progress, redactStep(step2, secrets, patterns));
3668
4607
  pending = [];
3669
4608
  lastWriteAt = now();
3670
4609
  inFlight = true;
@@ -3728,9 +4667,9 @@ function createProgressWriter(deps) {
3728
4667
 
3729
4668
  // src/service.ts
3730
4669
  import { spawnSync } from "node:child_process";
3731
- import fs4 from "node:fs";
3732
- import os3 from "node:os";
3733
- import path4 from "node:path";
4670
+ import fs6 from "node:fs";
4671
+ import os4 from "node:os";
4672
+ import path7 from "node:path";
3734
4673
  import { fileURLToPath } from "node:url";
3735
4674
  var SERVICE_LABEL = `com.kilogent.${RUNNER_BIN.replace(/^kilogent-/, "")}`;
3736
4675
  var LINUX_UNIT = `${RUNNER_BIN}.service`;
@@ -3758,10 +4697,10 @@ function uid() {
3758
4697
  return String(process.getuid?.() ?? 0);
3759
4698
  }
3760
4699
  function launchAgentPath() {
3761
- return path4.join(os3.homedir(), "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
4700
+ return path7.join(os4.homedir(), "Library/LaunchAgents", `${SERVICE_LABEL}.plist`);
3762
4701
  }
3763
4702
  function systemdUnitPath() {
3764
- return path4.join(os3.homedir(), ".config/systemd/user", LINUX_UNIT);
4703
+ return path7.join(os4.homedir(), ".config/systemd/user", LINUX_UNIT);
3765
4704
  }
3766
4705
  function serviceEnv() {
3767
4706
  const env = { PATH: process.env.PATH ?? "" };
@@ -3779,19 +4718,19 @@ function removeLegacyService() {
3779
4718
  for (const old of RETIRED_SERVICES) {
3780
4719
  if (old.label === SERVICE_LABEL) continue;
3781
4720
  if (process.platform === "darwin") {
3782
- const unitPath = path4.join(os3.homedir(), "Library/LaunchAgents", `${old.label}.plist`);
3783
- if (fs4.existsSync(unitPath)) {
4721
+ const unitPath = path7.join(os4.homedir(), "Library/LaunchAgents", `${old.label}.plist`);
4722
+ if (fs6.existsSync(unitPath)) {
3784
4723
  run("launchctl", ["bootout", `gui/${uid()}/${old.label}`]);
3785
- fs4.rmSync(unitPath, { force: true });
4724
+ fs6.rmSync(unitPath, { force: true });
3786
4725
  removed.push(unitPath);
3787
4726
  }
3788
4727
  continue;
3789
4728
  }
3790
4729
  if (process.platform === "linux") {
3791
- const unitPath = path4.join(os3.homedir(), ".config/systemd/user", old.unit);
3792
- if (fs4.existsSync(unitPath)) {
4730
+ const unitPath = path7.join(os4.homedir(), ".config/systemd/user", old.unit);
4731
+ if (fs6.existsSync(unitPath)) {
3793
4732
  run("systemctl", ["--user", "disable", "--now", old.unit]);
3794
- fs4.rmSync(unitPath, { force: true });
4733
+ fs6.rmSync(unitPath, { force: true });
3795
4734
  run("systemctl", ["--user", "daemon-reload"]);
3796
4735
  removed.push(unitPath);
3797
4736
  }
@@ -3855,9 +4794,9 @@ function plistXml() {
3855
4794
  ${envEntries}
3856
4795
  </dict>
3857
4796
  <key>StandardOutPath</key>
3858
- <string>${escapeXml(path4.join(logDir(), "service.out.log"))}</string>
4797
+ <string>${escapeXml(path7.join(logDir(), "service.out.log"))}</string>
3859
4798
  <key>StandardErrorPath</key>
3860
- <string>${escapeXml(path4.join(logDir(), "service.err.log"))}</string>
4799
+ <string>${escapeXml(path7.join(logDir(), "service.err.log"))}</string>
3861
4800
  </dict>
3862
4801
  </plist>
3863
4802
  `;
@@ -3903,14 +4842,14 @@ function parseSystemdCliPath(unit) {
3903
4842
  function execFacts(unitPath, parse, parseEnv) {
3904
4843
  let text2;
3905
4844
  try {
3906
- text2 = fs4.readFileSync(unitPath, "utf8");
4845
+ text2 = fs6.readFileSync(unitPath, "utf8");
3907
4846
  } catch {
3908
4847
  return {};
3909
4848
  }
3910
4849
  const unitEnv = parseEnv(text2);
3911
4850
  const execPath = parse(text2);
3912
- if (!execPath || !path4.isAbsolute(execPath)) return { ...unitEnv ? { unitEnv } : {} };
3913
- return { execPath, execMissing: !fs4.existsSync(execPath), ...unitEnv ? { unitEnv } : {} };
4851
+ if (!execPath || !path7.isAbsolute(execPath)) return { ...unitEnv ? { unitEnv } : {} };
4852
+ return { execPath, execMissing: !fs6.existsSync(execPath), ...unitEnv ? { unitEnv } : {} };
3914
4853
  }
3915
4854
  function systemdUnit() {
3916
4855
  const env = serviceEnv();
@@ -3936,7 +4875,7 @@ WantedBy=default.target
3936
4875
  function serviceStatus() {
3937
4876
  if (process.platform === "darwin") {
3938
4877
  const unitPath = launchAgentPath();
3939
- if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No LaunchAgent installed." };
4878
+ if (!fs6.existsSync(unitPath)) return { state: "not-installed", detail: "No LaunchAgent installed." };
3940
4879
  const exec = execFacts(unitPath, parsePlistCliPath, parsePlistEnv);
3941
4880
  const printed = run("launchctl", ["print", `gui/${uid()}/${SERVICE_LABEL}`]);
3942
4881
  if (!printed.ok) {
@@ -3952,7 +4891,7 @@ function serviceStatus() {
3952
4891
  }
3953
4892
  if (process.platform === "linux") {
3954
4893
  const unitPath = systemdUnitPath();
3955
- if (!fs4.existsSync(unitPath)) return { state: "not-installed", detail: "No systemd user unit installed." };
4894
+ if (!fs6.existsSync(unitPath)) return { state: "not-installed", detail: "No systemd user unit installed." };
3956
4895
  const active = run("systemctl", ["--user", "is-active", LINUX_UNIT]);
3957
4896
  return {
3958
4897
  state: active.out === "active" ? "running" : "installed",
@@ -3974,14 +4913,14 @@ function serviceStatus() {
3974
4913
  }
3975
4914
  function installService() {
3976
4915
  const notes = [];
3977
- fs4.mkdirSync(logDir(), { recursive: true, mode: 448 });
4916
+ fs6.mkdirSync(logDir(), { recursive: true, mode: 448 });
3978
4917
  for (const unit of removeLegacyService()) {
3979
4918
  notes.push(`Removed the previous crew-runner service (${unit}).`);
3980
4919
  }
3981
4920
  if (process.platform === "darwin") {
3982
4921
  const unitPath = launchAgentPath();
3983
- fs4.mkdirSync(path4.dirname(unitPath), { recursive: true });
3984
- fs4.writeFileSync(unitPath, plistXml());
4922
+ fs6.mkdirSync(path7.dirname(unitPath), { recursive: true });
4923
+ fs6.writeFileSync(unitPath, plistXml());
3985
4924
  run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
3986
4925
  const boot = retryWhileTransient(
3987
4926
  () => run("launchctl", ["bootstrap", `gui/${uid()}`, unitPath]),
@@ -3992,16 +4931,16 @@ function installService() {
3992
4931
  }
3993
4932
  if (process.platform === "linux") {
3994
4933
  const unitPath = systemdUnitPath();
3995
- fs4.mkdirSync(path4.dirname(unitPath), { recursive: true });
3996
- fs4.writeFileSync(unitPath, systemdUnit());
4934
+ fs6.mkdirSync(path7.dirname(unitPath), { recursive: true });
4935
+ fs6.writeFileSync(unitPath, systemdUnit());
3997
4936
  const reload = run("systemctl", ["--user", "daemon-reload"]);
3998
4937
  if (!reload.ok) throw new ServiceError(`systemctl daemon-reload failed: ${reload.out}`);
3999
4938
  const enable = run("systemctl", ["--user", "enable", "--now", LINUX_UNIT]);
4000
4939
  if (!enable.ok) throw new ServiceError(`systemctl enable failed: ${enable.out}`);
4001
- const linger = run("loginctl", ["show-user", os3.userInfo().username, "--property=Linger"]);
4940
+ const linger = run("loginctl", ["show-user", os4.userInfo().username, "--property=Linger"]);
4002
4941
  if (!linger.out.includes("Linger=yes")) {
4003
4942
  notes.push(
4004
- `Run \`sudo loginctl enable-linger ${os3.userInfo().username}\` so the daemon survives logout and starts at boot.`
4943
+ `Run \`sudo loginctl enable-linger ${os4.userInfo().username}\` so the daemon survives logout and starts at boot.`
4005
4944
  );
4006
4945
  }
4007
4946
  return { unitPath, notes };
@@ -4032,12 +4971,12 @@ function uninstallService() {
4032
4971
  removeLegacyService();
4033
4972
  if (process.platform === "darwin") {
4034
4973
  run("launchctl", ["bootout", `gui/${uid()}/${SERVICE_LABEL}`]);
4035
- fs4.rmSync(launchAgentPath(), { force: true });
4974
+ fs6.rmSync(launchAgentPath(), { force: true });
4036
4975
  return;
4037
4976
  }
4038
4977
  if (process.platform === "linux") {
4039
4978
  run("systemctl", ["--user", "disable", "--now", LINUX_UNIT]);
4040
- fs4.rmSync(systemdUnitPath(), { force: true });
4979
+ fs6.rmSync(systemdUnitPath(), { force: true });
4041
4980
  run("systemctl", ["--user", "daemon-reload"]);
4042
4981
  return;
4043
4982
  }
@@ -4068,7 +5007,7 @@ function restartService() {
4068
5007
  }
4069
5008
 
4070
5009
  // src/update/plan.ts
4071
- import path5 from "node:path";
5010
+ import path8 from "node:path";
4072
5011
 
4073
5012
  // src/update/semver.ts
4074
5013
  var DEV_VERSION = "0.0.0-dev";
@@ -4158,16 +5097,16 @@ function reconcile(state, current) {
4158
5097
  return { action: "keep", state };
4159
5098
  }
4160
5099
  function packageRootFrom(cliPath2) {
4161
- return path5.dirname(path5.dirname(cliPath2));
5100
+ return path8.dirname(path8.dirname(cliPath2));
4162
5101
  }
4163
5102
  function globalPrefixFrom(cliPath2) {
4164
5103
  const root = packageRootFrom(cliPath2);
4165
- const parts = root.split(path5.sep);
5104
+ const parts = root.split(path8.sep);
4166
5105
  const i = parts.lastIndexOf("node_modules");
4167
5106
  if (i < 1) return null;
4168
5107
  const before = parts.slice(0, i);
4169
5108
  if (before[before.length - 1] === "lib") before.pop();
4170
- const prefix = before.join(path5.sep);
5109
+ const prefix = before.join(path8.sep);
4171
5110
  return prefix || null;
4172
5111
  }
4173
5112
  function decideUpdate(f) {
@@ -4249,15 +5188,15 @@ async function fetchDistTags(options = {}) {
4249
5188
  }
4250
5189
 
4251
5190
  // src/update/state.ts
4252
- import fs5 from "node:fs";
4253
- import path6 from "node:path";
5191
+ import fs7 from "node:fs";
5192
+ import path9 from "node:path";
4254
5193
  var UPDATE_STATE_FILE = "update.json";
4255
5194
  function updateStatePath() {
4256
- return path6.join(configDir(), UPDATE_STATE_FILE);
5195
+ return path9.join(configDir(), UPDATE_STATE_FILE);
4257
5196
  }
4258
5197
  function readUpdateState() {
4259
5198
  try {
4260
- const parsed = JSON.parse(fs5.readFileSync(updateStatePath(), "utf8"));
5199
+ const parsed = JSON.parse(fs7.readFileSync(updateStatePath(), "utf8"));
4261
5200
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
4262
5201
  return parsed;
4263
5202
  } catch {
@@ -4266,8 +5205,8 @@ function readUpdateState() {
4266
5205
  }
4267
5206
  function writeUpdateState(state) {
4268
5207
  try {
4269
- fs5.mkdirSync(configDir(), { recursive: true, mode: 448 });
4270
- fs5.writeFileSync(updateStatePath(), `${JSON.stringify(state, null, 2)}
5208
+ fs7.mkdirSync(configDir(), { recursive: true, mode: 448 });
5209
+ fs7.writeFileSync(updateStatePath(), `${JSON.stringify(state, null, 2)}
4271
5210
  `, { mode: 384 });
4272
5211
  return true;
4273
5212
  } catch {
@@ -4276,15 +5215,15 @@ function writeUpdateState(state) {
4276
5215
  }
4277
5216
  function clearUpdateState() {
4278
5217
  try {
4279
- fs5.rmSync(updateStatePath(), { force: true });
5218
+ fs7.rmSync(updateStatePath(), { force: true });
4280
5219
  } catch {
4281
5220
  }
4282
5221
  }
4283
5222
 
4284
5223
  // src/update/install.ts
4285
- import { spawn as spawn5, spawnSync as spawnSync2 } from "node:child_process";
4286
- import fs6 from "node:fs";
4287
- import path7 from "node:path";
5224
+ import { spawn as spawn6, spawnSync as spawnSync2 } from "node:child_process";
5225
+ import fs8 from "node:fs";
5226
+ import path10 from "node:path";
4288
5227
  var INSTALL_TIMEOUT_MS = 5 * 6e4;
4289
5228
  var STDERR_KEEP = 400;
4290
5229
  function npmPresent() {
@@ -4306,7 +5245,7 @@ async function installGlobal(target, cliPath2, options = {}) {
4306
5245
  return new Promise((resolve) => {
4307
5246
  let child;
4308
5247
  try {
4309
- child = spawn5("npm", args, { stdio: ["ignore", "ignore", "pipe"] });
5248
+ child = spawn6("npm", args, { stdio: ["ignore", "ignore", "pipe"] });
4310
5249
  } catch (e) {
4311
5250
  resolve({ ok: false, detail: e instanceof Error ? e.message : String(e) });
4312
5251
  return;
@@ -4339,7 +5278,7 @@ async function installGlobal(target, cliPath2, options = {}) {
4339
5278
  function installedVersionAt(cliPath2) {
4340
5279
  try {
4341
5280
  const pkg = JSON.parse(
4342
- fs6.readFileSync(path7.join(packageRootFrom(cliPath2), "package.json"), "utf8")
5281
+ fs8.readFileSync(path10.join(packageRootFrom(cliPath2), "package.json"), "utf8")
4343
5282
  );
4344
5283
  const version = pkg?.version;
4345
5284
  return typeof version === "string" ? version : null;
@@ -4390,6 +5329,13 @@ async function startDaemon() {
4390
5329
  console.log(
4391
5330
  bannerIds.length <= 1 ? `Runner ${bannerIds[0] ?? config2.runnerId} serving ${serving.size} Ship(s): ${[...serving].join(", ")}` : `Runner ${bannerIds.length} identities serving ${serving.size} Ship(s): ` + sessions.map((s) => `${s.shipId} as ${s.runnerId}`).join(", ")
4392
5331
  );
5332
+ const machineLookup = (shipId) => ({
5333
+ runnerId: sess(shipId).runnerId,
5334
+ localLogin: (engineId, credentialId) => {
5335
+ const home = engineCredentialHome(configDir(), engineId, shipId, credentialId);
5336
+ return getDriver(engineId).login?.isSignedIn(home) ? home : null;
5337
+ }
5338
+ });
4393
5339
  const logLines = [];
4394
5340
  const log2 = (line) => {
4395
5341
  const stamped = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}`;
@@ -4444,7 +5390,7 @@ async function startDaemon() {
4444
5390
  await setDoc2(
4445
5391
  shipRunnerRef(shipId),
4446
5392
  {
4447
- hostname: os4.hostname(),
5393
+ hostname: os5.hostname(),
4448
5394
  version: RUNNER_VERSION,
4449
5395
  status: "online",
4450
5396
  lastSeenAt: now,
@@ -4503,6 +5449,10 @@ async function startDaemon() {
4503
5449
  const engineLimits = /* @__PURE__ */ new Map();
4504
5450
  const claimBackoff = /* @__PURE__ */ new Map();
4505
5451
  const agentEngines = /* @__PURE__ */ new Map();
5452
+ const agentCredentials = /* @__PURE__ */ new Map();
5453
+ const shipCredentials = /* @__PURE__ */ new Map();
5454
+ const heldCredentialIds = /* @__PURE__ */ new Map();
5455
+ const loginsInFlight = /* @__PURE__ */ new Map();
4506
5456
  const unsubsByShip = /* @__PURE__ */ new Map();
4507
5457
  const listenerError = (shipId, what) => (e) => {
4508
5458
  if (isEnrolmentGone(e)) {
@@ -4539,7 +5489,9 @@ async function startDaemon() {
4539
5489
  collection8(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.agents),
4540
5490
  (snap) => {
4541
5491
  for (const d of snap.docs) {
4542
- agentEngines.set(`${shipId}/${d.id}`, agentEngine(d.data()));
5492
+ const agent = d.data();
5493
+ agentEngines.set(`${shipId}/${d.id}`, agentEngine(agent));
5494
+ agentCredentials.set(`${shipId}/${d.id}`, { engine: agent.engine, credentialId: agent.credentialId });
4543
5495
  }
4544
5496
  poke();
4545
5497
  },
@@ -4594,6 +5546,93 @@ async function startDaemon() {
4594
5546
  */
4595
5547
  (e) => console.error(`mcp connections listener error (${shipId}):`, e.message)
4596
5548
  ),
5549
+ /**
5550
+ * AI credentials (PRD §15.81), for two things only this machine can answer.
5551
+ *
5552
+ * The CLAIM GATE: a machine-held credential lives on one machine, so a queued job on it is
5553
+ * that machine's job — `credentialGate` reads this list before any claim. And the SIGN-IN: a
5554
+ * captain asks, through a callable, for THIS machine to sign in for a new credential; the
5555
+ * document turns `pending` bound to this runner, and the only thing in the world that can
5556
+ * answer is a process on this machine. §15.40's probe listener, with two more steps.
5557
+ *
5558
+ * `credentialsNeedingLogin` deliberately acts on nothing this machine has already answered,
5559
+ * and reports an old unanswered request STALE rather than starting it: a device code lives
5560
+ * fifteen minutes, and a machine that was off when the captain clicked would otherwise show
5561
+ * a code nobody is watching. Startup is the same case, so a daemon coming up does not sign
5562
+ * in to anything on its own.
5563
+ *
5564
+ * A credential that VANISHES from the list is a sign-in to forget: the callable deleted the
5565
+ * row, and the token on this disk is the only copy left. The first snapshot also sweeps the
5566
+ * directories no live credential explains — the deletions that happened while it was off.
5567
+ */
5568
+ onSnapshot2(
5569
+ collection8(sess(shipId).fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.credentials),
5570
+ (snap) => {
5571
+ const list = snap.docs.map((d) => ({ id: d.id, ...d.data() }));
5572
+ const runnerId = sess(shipId).runnerId;
5573
+ shipCredentials.set(shipId, list);
5574
+ const now = Date.now();
5575
+ const held = heldCredentialIds.get(shipId) ?? /* @__PURE__ */ new Set();
5576
+ const gone = credentialsToForget(held, list);
5577
+ for (const id of gone) {
5578
+ held.delete(id);
5579
+ loginsInFlight.get(id)?.abort();
5580
+ for (const dir of orphanHomes(configDir(), shipId, new Set(list.map((c) => c.id)))) {
5581
+ if (!dir.endsWith(`${path11.sep}${id}`)) continue;
5582
+ const engineId = path11.basename(path11.dirname(path11.dirname(dir)));
5583
+ const driver = getDriver(engineId);
5584
+ if (!driver.login) continue;
5585
+ void logoutAndRemove({ driver: driver.login, bin: driver.binary(), home: dir, log: log2 }).then(
5586
+ () => log2(`Forgot the sign-in for credential ${id} \u2014 the credential was deleted from Ship ${shipId}.`)
5587
+ );
5588
+ }
5589
+ }
5590
+ if (!heldCredentialIds.has(shipId)) {
5591
+ for (const dir of orphanHomes(configDir(), shipId, new Set(list.map((c) => c.id)))) {
5592
+ const engineId = path11.basename(path11.dirname(path11.dirname(dir)));
5593
+ const driver = getDriver(engineId);
5594
+ if (!driver.login) continue;
5595
+ void logoutAndRemove({ driver: driver.login, bin: driver.binary(), home: dir, log: log2 }).then(
5596
+ () => log2(`Removed a stale sign-in directory for Ship ${shipId}: ${dir}`)
5597
+ );
5598
+ }
5599
+ }
5600
+ for (const c of list) if (c.runnerId === runnerId) held.add(c.id);
5601
+ heldCredentialIds.set(shipId, held);
5602
+ const { start, expire } = credentialsNeedingLogin(list, runnerId, now);
5603
+ for (const credential of expire) {
5604
+ void sess(shipId).user.getIdToken().then(
5605
+ (idToken) => reportLogin({
5606
+ config: config2,
5607
+ idToken,
5608
+ shipId,
5609
+ credentialId: credential.id,
5610
+ report: { phase: "expired", error: "This machine was offline when the sign-in was requested. Ask again." },
5611
+ log: log2
5612
+ })
5613
+ ).catch((e) => log2(`Could not report a stale sign-in request: ${e instanceof Error ? e.message : e}`));
5614
+ }
5615
+ for (const credential of start) {
5616
+ if (loginsInFlight.has(credential.id)) continue;
5617
+ const abort = new AbortController();
5618
+ loginsInFlight.set(credential.id, abort);
5619
+ void machineLoginAndReport({
5620
+ config: config2,
5621
+ idToken: () => sess(shipId).user.getIdToken(),
5622
+ shipId,
5623
+ credential,
5624
+ root: configDir(),
5625
+ signal: abort.signal,
5626
+ log: log2
5627
+ }).catch((e) => log2(`Sign-in for ${credential.id} ended abnormally: ${e instanceof Error ? e.message : e}`)).finally(() => loginsInFlight.delete(credential.id));
5628
+ }
5629
+ poke();
5630
+ },
5631
+ // ITS OWN ERROR HANDLER, not `listenerError`, for the reason the mcp_servers listener
5632
+ // gives: `credentials` is `isShipReader`, which a REVOKED machine fails, and revocation is
5633
+ // a state to sit through, not a removal.
5634
+ (e) => console.error(`credentials listener error (${shipId}):`, e.message)
5635
+ ),
4597
5636
  // Exhausted usage windows. The FIRST snapshot is the startup seed: a restarted daemon —
4598
5637
  // and a second daemon on this Ship — inherits the pause instead of running a doomed
4599
5638
  // session to rediscover it.
@@ -4632,6 +5671,20 @@ async function startDaemon() {
4632
5671
  for (const key of [...agentEngines.keys()]) {
4633
5672
  if (key.startsWith(`${shipId}/`)) agentEngines.delete(key);
4634
5673
  }
5674
+ for (const key of [...agentCredentials.keys()]) {
5675
+ if (key.startsWith(`${shipId}/`)) agentCredentials.delete(key);
5676
+ }
5677
+ shipCredentials.delete(shipId);
5678
+ heldCredentialIds.delete(shipId);
5679
+ for (const engineId of (() => {
5680
+ try {
5681
+ return fs9.readdirSync(path11.join(configDir(), "engines"));
5682
+ } catch {
5683
+ return [];
5684
+ }
5685
+ })()) {
5686
+ fs9.rmSync(path11.join(configDir(), "engines", engineId, shipId), { recursive: true, force: true });
5687
+ }
4635
5688
  approved.delete(shipId);
4636
5689
  warnedUnapproved.delete(shipId);
4637
5690
  needsRefill.delete(shipId);
@@ -4658,6 +5711,17 @@ async function startDaemon() {
4658
5711
  }
4659
5712
  }
4660
5713
  const engineForJob = (shipId, job) => agentEngines.get(`${shipId}/${job.agentId}`) ?? DEFAULT_ENGINE_ID;
5714
+ const credentialVerdict = (shipId, job) => {
5715
+ const busy = /* @__PURE__ */ new Set();
5716
+ for (const r of running.values()) if (r.credentialId) busy.add(r.credentialId);
5717
+ return credentialGate(
5718
+ agentCredentials.get(`${shipId}/${job.agentId}`),
5719
+ shipCredentials.get(shipId) ?? [],
5720
+ sess(shipId).runnerId,
5721
+ busy,
5722
+ DEFAULT_ENGINE_ID
5723
+ );
5724
+ };
4661
5725
  let dispatching = false;
4662
5726
  let pokeRequested = false;
4663
5727
  let shuttingDown = false;
@@ -4732,7 +5796,9 @@ async function startDaemon() {
4732
5796
  // the limit map is keyed by (Ship, engine) and the engine comes from the registry via
4733
5797
  // the agent, so the job loop still knows nothing about Claude. Skipped entries STAY in
4734
5798
  // `pending`, which is what lets a reset resume them with only a poke.
4735
- eligible: (p) => approved.get(p.shipId) === true && !isLimited(engineLimits, p.shipId, engineForJob(p.shipId, p.job), now) && // The connection cooldown. Same "skip, don't stop" shape as the limit gate above, and
5799
+ eligible: (p) => approved.get(p.shipId) === true && !isLimited(engineLimits, p.shipId, engineForJob(p.shipId, p.job), now) && // §15.81: a job on a machine-held credential is that machine's job. Skipped entries
5800
+ // STAY in `pending`; the credentials listener pokes when the sign-in lands.
5801
+ credentialVerdict(p.shipId, p.job) === "ok" && // The connection cooldown. Same "skip, don't stop" shape as the limit gate above, and
4736
5802
  // skipped entries STAY in `pending` for the same reason — `sweepBackoff` resumes them
4737
5803
  // with a poke. Without this the `transient` release below re-claims what it just put
4738
5804
  // down, every three seconds, forever (jobs/claimBackoff.ts).
@@ -4763,7 +5829,9 @@ async function startDaemon() {
4763
5829
  });
4764
5830
  }
4765
5831
  async function claim(shipId, job) {
4766
- if (isLimited(engineLimits, shipId, engineForJob(shipId, job), Date.now())) {
5832
+ if (isLimited(engineLimits, shipId, engineForJob(shipId, job), Date.now()) || // Same second gate for the credential: a sibling session on the same machine sign-in can
5833
+ // have started between selection and here.
5834
+ credentialVerdict(shipId, job) !== "ok") {
4767
5835
  pending.set(`${shipId}/${job.id}`, { shipId, job });
4768
5836
  return null;
4769
5837
  }
@@ -4857,10 +5925,11 @@ async function startDaemon() {
4857
5925
  let failure = null;
4858
5926
  let terminal = false;
4859
5927
  let transient = false;
5928
+ let misrouted = false;
4860
5929
  let sessionLimit = null;
4861
5930
  let engineId = DEFAULT_ENGINE_ID;
4862
5931
  let transcript = "";
4863
- let resultText2 = "";
5932
+ let resultText3 = "";
4864
5933
  let mcpConnected = [];
4865
5934
  let usage = {
4866
5935
  engine: DEFAULT_ENGINE_ID,
@@ -4880,6 +5949,8 @@ async function startDaemon() {
4880
5949
  progress: p
4881
5950
  }),
4882
5951
  secrets: () => knownSecrets,
5952
+ // Read late for the same reason: the engine is only known once the agent is.
5953
+ patterns: () => getDriver(engineId).secretPatterns,
4883
5954
  // Two consecutive DENIALS mean this job is no longer ours to write — a force-stop took it, or
4884
5955
  // the machine was revoked. The §15.30 door: the news arrives as `permission-denied`.
4885
5956
  //
@@ -4908,7 +5979,7 @@ async function startDaemon() {
4908
5979
  try {
4909
5980
  let secrets = null;
4910
5981
  const forAssistant = isAssistantId(job.agentId);
4911
- const assistantCredential = forAssistant ? await resolveAssistantCredential(sess(shipId).fb.db, shipId) : null;
5982
+ const assistantCredential = forAssistant ? await resolveAssistantCredential(sess(shipId).fb.db, shipId, machineLookup(shipId)) : null;
4912
5983
  const packed = forAssistant && target.kind === "chat" ? {
4913
5984
  kind: "assistant",
4914
5985
  ctx: await loadAssistantContext(
@@ -4931,14 +6002,17 @@ async function startDaemon() {
4931
6002
  db: sess(shipId).fb.db,
4932
6003
  shipId,
4933
6004
  agent,
4934
- credentials: await loadShipCredentials(sess(shipId).fb.db, shipId).catch(() => [])
6005
+ credentials: await loadShipCredentials(sess(shipId).fb.db, shipId).catch(() => []),
6006
+ machine: machineLookup(shipId)
4935
6007
  });
6008
+ slot.credentialId = resolvedSecrets.credentialId;
4936
6009
  if (resolvedSecrets.problem) {
6010
+ misrouted = resolvedSecrets.kind === "other-machine" || resolvedSecrets.kind === "not-ready";
4937
6011
  throw new Error(resolvedSecrets.problem);
4938
6012
  }
4939
6013
  secrets = resolvedSecrets.secrets;
4940
6014
  const missing = missingSecretsFor(engineId, secrets);
4941
- if (missing.length > 0 && !process.env.CREW_CLAUDE_BIN) {
6015
+ if (missing.length > 0) {
4942
6016
  throw new Error(
4943
6017
  `Runner credentials are not configured for this Ship \u2014 a captain must save the following in Ship Settings: ${missing.join(", ")}.`
4944
6018
  );
@@ -5006,7 +6080,8 @@ async function startDaemon() {
5006
6080
  if (slot.abort.signal.aborted) throw new Error("Stopped before the session started.");
5007
6081
  knownSecrets = [
5008
6082
  idToken,
5009
- secrets?.claudeToken,
6083
+ // The engine's own values, named by the driver — the job loop knows no engine's key.
6084
+ ...getDriver(engineId).secretValues(secrets),
5010
6085
  githubToken,
5011
6086
  // §15.31. BOTH the composed header value and the bare credential — see
5012
6087
  // `mcpSecretsToRedact` for why one of them is not enough.
@@ -5030,7 +6105,29 @@ async function startDaemon() {
5030
6105
  // swallows everything.
5031
6106
  onStep: (step2) => progress.push(step2)
5032
6107
  });
5033
- transcript = redactTranscript(session.transcript, knownSecrets);
6108
+ transcript = redactTranscript(
6109
+ session.transcript,
6110
+ knownSecrets,
6111
+ getDriver(engineId).secretPatterns
6112
+ );
6113
+ if (session.credentialLost) {
6114
+ terminal = true;
6115
+ log2(`Credential unusable on this machine: ${session.credentialLost}`);
6116
+ const lostId = slot.credentialId;
6117
+ if (lostId) {
6118
+ void sess(shipId).user.getIdToken().then(
6119
+ (idToken2) => reportLogin({
6120
+ config: config2,
6121
+ idToken: idToken2,
6122
+ shipId,
6123
+ credentialId: lostId,
6124
+ report: { phase: "failed", error: session.credentialLost ?? "The sign-in is no longer usable." },
6125
+ log: log2
6126
+ })
6127
+ ).catch(() => {
6128
+ });
6129
+ }
6130
+ }
5034
6131
  usage = session.usage;
5035
6132
  if (session.limit && getEngine(engineId).usageWindows) {
5036
6133
  sessionLimit = session.limit;
@@ -5043,7 +6140,7 @@ async function startDaemon() {
5043
6140
  });
5044
6141
  armLimitTimer();
5045
6142
  }
5046
- resultText2 = session.resultText;
6143
+ resultText3 = session.resultText;
5047
6144
  const failed = new Set(session.mcpFailed ?? []);
5048
6145
  mcpConnected = extraMcpServers.map((s) => s.key).filter((k) => !failed.has(k));
5049
6146
  if (!session.ok) failure = session.resultText || "Session failed.";
@@ -5068,7 +6165,7 @@ async function startDaemon() {
5068
6165
  // §15.41. A stopped run is the case that needs the backstop MOST: it was cut off
5069
6166
  // mid-thought, so it almost certainly never reached `run_report` — and whatever it had
5070
6167
  // got to is what the next run on this task would otherwise have to rediscover.
5071
- resultText: resultText2,
6168
+ resultText: resultText3,
5072
6169
  mcpServers: mcpConnected
5073
6170
  });
5074
6171
  const by = slot.stop.by;
@@ -5118,7 +6215,7 @@ async function startDaemon() {
5118
6215
  transcriptPath,
5119
6216
  // §15.41. Only used when the session never called `run_report` — the ordinary path is
5120
6217
  // that it did, and a real report always wins inside the transaction.
5121
- resultText: resultText2,
6218
+ resultText: resultText3,
5122
6219
  mcpServers: mcpConnected
5123
6220
  });
5124
6221
  log2(`Job ${job.id} done (${usage.inputTokens}in/${usage.outputTokens}out tokens).`);
@@ -5129,7 +6226,7 @@ async function startDaemon() {
5129
6226
  sess(shipId).fb.db,
5130
6227
  shipId,
5131
6228
  { ...job, chatId: target.chatId },
5132
- resultText2
6229
+ resultText3
5133
6230
  );
5134
6231
  if (delivery === "posted-final-text") {
5135
6232
  log2(
@@ -5150,6 +6247,13 @@ async function startDaemon() {
5150
6247
  "Crew job finished",
5151
6248
  target.kind === "task" ? `Task ${target.taskId} is done.` : delivery === "agent-replied" ? "Agent replied in a chat." : "Agent finished a chat run without replying \u2014 its answer was posted for it."
5152
6249
  );
6250
+ } else if (misrouted) {
6251
+ const held = noteTransientRelease(claimBackoff, job.id, Date.now());
6252
+ await releaseJob(sess(shipId).fb.db, shipId, job, `Not this machine's job: ${failure.slice(0, 160)}`);
6253
+ armBackoffTimer();
6254
+ log2(
6255
+ `Job ${job.id} released \u2014 ${failure.slice(0, 160)} Attempt ${job.attempt} preserved; not re-claiming it here for ${Math.round((held.eligibleAt - Date.now()) / 1e3)}s.`
6256
+ );
5153
6257
  } else if (transient) {
5154
6258
  const held = noteTransientRelease(claimBackoff, job.id, Date.now());
5155
6259
  await releaseJob(
@@ -5175,7 +6279,7 @@ async function startDaemon() {
5175
6279
  // §15.41. A failed run still did work, and the retry — or the next run after the retry
5176
6280
  // is spent — starts from the context pack alone. `error` is the tail for a human; this
5177
6281
  // is the continuity for the next session, and they are read by different readers.
5178
- resultText: resultText2,
6282
+ resultText: resultText3,
5179
6283
  mcpServers: mcpConnected
5180
6284
  });
5181
6285
  if (target.kind === "chat") {
@@ -5209,6 +6313,7 @@ async function startDaemon() {
5209
6313
  if (limitTimer) clearTimeout(limitTimer);
5210
6314
  if (updateDrainTimer) clearTimeout(updateDrainTimer);
5211
6315
  for (const shipUnsubs of unsubsByShip.values()) shipUnsubs.forEach((u) => u());
6316
+ for (const abort of loginsInFlight.values()) abort.abort();
5212
6317
  const inFlight = [...running.values()];
5213
6318
  if (inFlight.length > 0) {
5214
6319
  log2(
@@ -5387,8 +6492,8 @@ async function startDaemon() {
5387
6492
  import * as clack from "@clack/prompts";
5388
6493
  import { createColors } from "picocolors";
5389
6494
  var CliError = class extends Error {
5390
- constructor(message2, exitCode = 1) {
5391
- super(message2);
6495
+ constructor(message3, exitCode = 1) {
6496
+ super(message3);
5392
6497
  this.exitCode = exitCode;
5393
6498
  this.name = "CliError";
5394
6499
  }
@@ -5433,30 +6538,30 @@ var say = {
5433
6538
  intro(title) {
5434
6539
  if (!jsonMode) clack.intro(pc.bgCyan(pc.black(` ${title} `)));
5435
6540
  },
5436
- outro(message2) {
5437
- if (!jsonMode) clack.outro(message2);
6541
+ outro(message3) {
6542
+ if (!jsonMode) clack.outro(message3);
5438
6543
  },
5439
- info(message2) {
5440
- if (!jsonMode) clack.log.info(message2);
6544
+ info(message3) {
6545
+ if (!jsonMode) clack.log.info(message3);
5441
6546
  },
5442
- success(message2) {
5443
- if (!jsonMode) clack.log.success(message2);
6547
+ success(message3) {
6548
+ if (!jsonMode) clack.log.success(message3);
5444
6549
  },
5445
- warn(message2) {
5446
- if (!jsonMode) clack.log.warn(message2);
6550
+ warn(message3) {
6551
+ if (!jsonMode) clack.log.warn(message3);
5447
6552
  },
5448
- error(message2) {
5449
- if (!jsonMode) clack.log.error(message2);
6553
+ error(message3) {
6554
+ if (!jsonMode) clack.log.error(message3);
5450
6555
  },
5451
- step(message2) {
5452
- if (!jsonMode) clack.log.step(message2);
6556
+ step(message3) {
6557
+ if (!jsonMode) clack.log.step(message3);
5453
6558
  },
5454
6559
  note(body, title) {
5455
6560
  if (!jsonMode) clack.note(body, title);
5456
6561
  },
5457
6562
  /** Raw line straight to stdout — for `logs`, where the content IS the output. */
5458
- line(message2) {
5459
- process.stdout.write(`${message2}
6563
+ line(message3) {
6564
+ process.stdout.write(`${message3}
5460
6565
  `);
5461
6566
  }
5462
6567
  };
@@ -5716,8 +6821,8 @@ function setParallel(config2, value, ship2) {
5716
6821
 
5717
6822
  // src/cli/commands/doctor.ts
5718
6823
  import { spawnSync as spawnSync3 } from "node:child_process";
5719
- import fs7 from "node:fs";
5720
- import path8 from "node:path";
6824
+ import fs10 from "node:fs";
6825
+ import path12 from "node:path";
5721
6826
  import { collection as collection9, doc as doc10, getDoc as getDoc9, getDocs as getDocs8 } from "firebase/firestore";
5722
6827
 
5723
6828
  // src/cli/session.ts
@@ -5793,7 +6898,7 @@ function serviceBinaryCheckFrom(input) {
5793
6898
  if (status.state === "not-installed" || status.state === "unsupported") return null;
5794
6899
  const pathValue = status.unitEnv?.PATH;
5795
6900
  if (pathValue === void 0) return null;
5796
- const relative = [...new Set(binaries)].filter((b) => !path8.isAbsolute(b));
6901
+ const relative = [...new Set(binaries)].filter((b) => !path12.isAbsolute(b));
5797
6902
  if (relative.length === 0) return null;
5798
6903
  const missing = relative.filter((b) => !resolves(b, pathValue));
5799
6904
  const id = "service:path";
@@ -5809,10 +6914,10 @@ function serviceBinaryCheckFrom(input) {
5809
6914
  );
5810
6915
  }
5811
6916
  function resolvesOnPath(binary, pathValue) {
5812
- for (const dir of pathValue.split(path8.delimiter)) {
6917
+ for (const dir of pathValue.split(path12.delimiter)) {
5813
6918
  if (!dir) continue;
5814
6919
  try {
5815
- fs7.accessSync(path8.join(dir, binary), fs7.constants.X_OK);
6920
+ fs10.accessSync(path12.join(dir, binary), fs10.constants.X_OK);
5816
6921
  return true;
5817
6922
  } catch {
5818
6923
  }
@@ -5975,6 +7080,25 @@ async function checkShips(config2) {
5975
7080
  )
5976
7081
  );
5977
7082
  }
7083
+ try {
7084
+ const credentials = await loadShipCredentials(session.fb.db, shipId).catch(() => []);
7085
+ for (const cred of credentials) {
7086
+ if (!isMachineHeldCredential(cred) || cred.runnerId !== session.runnerId) continue;
7087
+ if (!isMachineLoginEngine(cred.engine)) continue;
7088
+ const driver = getDriver(cred.engine);
7089
+ const login = driver.login;
7090
+ if (!login) continue;
7091
+ const home = engineCredentialHome(configDir(), cred.engine, shipId, cred.id);
7092
+ const dirPresent = login.isSignedIn(home);
7093
+ const statusExit = dirPresent ? spawnSync3(driver.binary(), login.statusArgs(), {
7094
+ env: { ...process.env, ...login.env(home) },
7095
+ stdio: "ignore",
7096
+ timeout: 1e4
7097
+ }).status ?? 1 : 1;
7098
+ checks.push(machineCredentialCheckFrom(shipId, cred, { dirPresent, statusExit }));
7099
+ }
7100
+ } catch {
7101
+ }
5978
7102
  try {
5979
7103
  const snap = await getDocs8(
5980
7104
  collection9(session.fb.db, COLLECTIONS.ships, shipId, COLLECTIONS.mcpServers)
@@ -5989,6 +7113,35 @@ async function checkShips(config2) {
5989
7113
  }
5990
7114
  return { checks, engines, needsGithub };
5991
7115
  }
7116
+ function machineCredentialCheckFrom(shipId, cred, facts) {
7117
+ const id = `login:${shipId}:${cred.id}`;
7118
+ const label = `Ship ${shipId} \u2014 sign-in "${cred.label}"`;
7119
+ if (cred.status && cred.status !== "ready") {
7120
+ return warn(
7121
+ id,
7122
+ label,
7123
+ `The sign-in is ${cred.status.replace("-", " ")}.`,
7124
+ cred.status === "failed" || cred.status === "expired" ? "Retry it in Ship Settings \u2192 AI credentials." : "Finish it in Ship Settings \u2192 AI credentials."
7125
+ );
7126
+ }
7127
+ if (!facts.dirPresent) {
7128
+ return fail(
7129
+ id,
7130
+ label,
7131
+ "This machine no longer holds the sign-in.",
7132
+ "Delete the credential in Ship Settings \u2192 AI credentials and add it again from this machine."
7133
+ );
7134
+ }
7135
+ if (facts.statusExit !== 0) {
7136
+ return fail(
7137
+ id,
7138
+ label,
7139
+ "The engine no longer accepts the stored sign-in.",
7140
+ "Delete the credential in Ship Settings \u2192 AI credentials and add it again from this machine."
7141
+ );
7142
+ }
7143
+ return ok(id, label, `Signed in${cred.account?.email ? ` as ${cred.account.email}` : ""}.`);
7144
+ }
5992
7145
  async function runDoctor() {
5993
7146
  const checks = [checkNode()];
5994
7147
  const config2 = loadConfig();
@@ -6079,8 +7232,8 @@ function report2(checks) {
6079
7232
  }
6080
7233
 
6081
7234
  // src/cli/commands/login.ts
6082
- import { spawn as spawn6 } from "node:child_process";
6083
- import os5 from "node:os";
7235
+ import { spawn as spawn7 } from "node:child_process";
7236
+ import os6 from "node:os";
6084
7237
  import { signInWithCustomToken as signInWithCustomToken2 } from "firebase/auth";
6085
7238
  function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
6086
7239
  const approvedShips = approved.approvedShips ?? Object.keys(approved.shipKeys ?? {});
@@ -6121,7 +7274,7 @@ function buildLoginResult(approved, existing, fallbackProjectId, mcpUrl2) {
6121
7274
  function openBrowser(url) {
6122
7275
  const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
6123
7276
  try {
6124
- const child = spawn6(command, args, { stdio: "ignore", detached: true });
7277
+ const child = spawn7(command, args, { stdio: "ignore", detached: true });
6125
7278
  child.on("error", () => {
6126
7279
  });
6127
7280
  child.unref();
@@ -6140,7 +7293,7 @@ async function loginWithDeviceFlow(options) {
6140
7293
  const baseUrl = functionsBaseUrlFor(projectId);
6141
7294
  const candidates = existingConfig ? configRunnerIdentities(existingConfig).map((i) => i.runnerId) : [];
6142
7295
  const start = await callPublicFunction(baseUrl, "crewRunnerAuth", "startRunnerLogin", {
6143
- hostname: os5.hostname(),
7296
+ hostname: os6.hostname(),
6144
7297
  // The singular field stays, always: it is what an older deployment reads, and dropping it
6145
7298
  // would silently turn every re-login into a new enrolment for the length of a rollout.
6146
7299
  ...existingConfig?.runnerId ? { runnerId: existingConfig.runnerId } : {},
@@ -6389,20 +7542,20 @@ async function runServiceStatus() {
6389
7542
  }
6390
7543
 
6391
7544
  // src/cli/commands/uninstall.ts
6392
- import fs8 from "node:fs";
7545
+ import fs11 from "node:fs";
6393
7546
  async function runUninstall(options) {
6394
7547
  const before = serviceStatus();
6395
7548
  const dir = configDir();
6396
7549
  const hadService = before.state !== "not-installed" && before.state !== "unsupported";
6397
7550
  if (hadService) uninstallService();
6398
7551
  let purged = false;
6399
- if (options.purge && fs8.existsSync(dir)) {
7552
+ if (options.purge && fs11.existsSync(dir)) {
6400
7553
  const confirmed = await promptConfirm({
6401
7554
  message: `Delete ${dir}? This machine loses its identity \u2014 a captain has to approve it again after reinstalling.`,
6402
7555
  initialValue: false
6403
7556
  });
6404
7557
  if (confirmed) {
6405
- fs8.rmSync(dir, { recursive: true, force: true });
7558
+ fs11.rmSync(dir, { recursive: true, force: true });
6406
7559
  purged = true;
6407
7560
  }
6408
7561
  }
@@ -6834,10 +7987,10 @@ function action(handler) {
6834
7987
  try {
6835
7988
  process.exitCode = await handler(...args);
6836
7989
  } catch (error) {
6837
- const message2 = error instanceof Error ? error.message : String(error);
6838
- if (isJson()) process.stderr.write(`${JSON.stringify({ error: message2 })}
7990
+ const message3 = error instanceof Error ? error.message : String(error);
7991
+ if (isJson()) process.stderr.write(`${JSON.stringify({ error: message3 })}
6839
7992
  `);
6840
- else say.error(message2);
7993
+ else say.error(message3);
6841
7994
  process.exitCode = error instanceof CliError ? error.exitCode : 1;
6842
7995
  } finally {
6843
7996
  await closeFirebase();