bosun 0.34.9 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -83,6 +83,16 @@ TELEGRAM_MINIAPP_ENABLED=false
83
83
  # Public hostname override. By default the server auto-detects your LAN IP.
84
84
  # Set this when using a tunnel (ngrok, Cloudflare) or a public domain.
85
85
  # TELEGRAM_UI_PUBLIC_HOST=your-lan-ip-or-domain
86
+ # Browser URL handling:
87
+ # manual (default) = never auto-open browser windows/tabs
88
+ # auto = permit auto-open when BOSUN_UI_AUTO_OPEN_BROWSER=true
89
+ # BOSUN_UI_BROWSER_OPEN_MODE=manual
90
+ # Legacy auto-open toggle for UI server (requires BOSUN_UI_BROWSER_OPEN_MODE=auto)
91
+ # BOSUN_UI_AUTO_OPEN_BROWSER=false
92
+ # Show full /?token=... browser URL in logs (default: false; token is hidden)
93
+ # BOSUN_UI_LOG_TOKENIZED_BROWSER_URL=false
94
+ # Setup wizard browser auto-open (default: true when mode=auto)
95
+ # BOSUN_SETUP_AUTO_OPEN_BROWSER=true
86
96
  # Full public URL override (takes precedence over host/port auto-detection).
87
97
  # Use when you have a reverse proxy or tunnel with HTTPS.
88
98
  # TELEGRAM_UI_BASE_URL=https://your-public-ui.example.com
package/README.md CHANGED
@@ -48,6 +48,7 @@ Requires:
48
48
 
49
49
  - Routes work across Codex, Copilot, and Claude executors
50
50
  - Automates retries, failover, and PR lifecycle management
51
+ - Auto-labels attached PRs with `bosun-needs-fix` when CI fails (`Build + Tests`)
51
52
  - Monitors runs and recovers from stalled or broken states
52
53
  - Provides Telegram control and a Mini App dashboard
53
54
  - Integrates with GitHub, Jira, and Vibe-Kanban boards
package/agent-pool.mjs CHANGED
@@ -44,6 +44,7 @@ import { fileURLToPath } from "node:url";
44
44
  import { loadConfig } from "./config.mjs";
45
45
  import { resolveRepoRoot, resolveAgentRepoRoot } from "./repo-root.mjs";
46
46
  import { resolveCodexProfileRuntime } from "./codex-model-profiles.mjs";
47
+ import { getGitHubToken } from "./github-auth-manager.mjs";
47
48
 
48
49
  // Lazy-load MCP registry to avoid circular dependencies.
49
50
  // Cached at module scope per AGENTS.md hard rules.
@@ -86,6 +87,36 @@ const HARD_TIMEOUT_BUFFER_MS = 5 * 60_000; // 5 minutes
86
87
  /** Tag for console logging */
87
88
  const TAG = "[agent-pool]";
88
89
  const MAX_PROMPT_BYTES = 180_000;
90
+ const MAX_SET_TIMEOUT_MS = 2_147_483_647; // Node.js setTimeout 32-bit signed max
91
+ let timeoutClampWarningKey = "";
92
+ const DEFAULT_FIRST_EVENT_TIMEOUT_MS = 120_000;
93
+
94
+ function clampTimerDelayMs(delayMs, label = "timer") {
95
+ const parsed = Number(delayMs);
96
+ if (!Number.isFinite(parsed) || parsed < 1) return 1;
97
+ const clamped = Math.min(Math.trunc(parsed), MAX_SET_TIMEOUT_MS);
98
+ if (clamped !== Math.trunc(parsed)) {
99
+ const warningKey = `${label}:${parsed}`;
100
+ if (timeoutClampWarningKey !== warningKey) {
101
+ timeoutClampWarningKey = warningKey;
102
+ console.warn(
103
+ `${TAG} ${label} delay ${parsed}ms exceeds Node timer max; clamped to ${MAX_SET_TIMEOUT_MS}ms`,
104
+ );
105
+ }
106
+ }
107
+ return clamped;
108
+ }
109
+
110
+ function getFirstEventTimeoutMs(totalTimeoutMs) {
111
+ const configured = Number(
112
+ process.env.AGENT_POOL_FIRST_EVENT_TIMEOUT_MS || DEFAULT_FIRST_EVENT_TIMEOUT_MS,
113
+ );
114
+ if (!Number.isFinite(configured) || configured <= 0) return null;
115
+ const budgetMs = Number(totalTimeoutMs);
116
+ if (!Number.isFinite(budgetMs) || budgetMs <= 2_000) return null;
117
+ const maxAllowed = Math.max(5_000, budgetMs - 1_000);
118
+ return clampTimerDelayMs(Math.min(Math.trunc(configured), maxAllowed), "first-event-timeout");
119
+ }
89
120
 
90
121
  function sanitizeAndBoundPrompt(text) {
91
122
  if (typeof text !== "string") return "";
@@ -112,6 +143,82 @@ function envFlagEnabled(value) {
112
143
  return ["1", "true", "yes", "on", "y"].includes(raw);
113
144
  }
114
145
 
146
+ const GITHUB_TOKEN_CACHE_TTL_MS = 60_000;
147
+ let cachedGithubSessionToken = null;
148
+ let cachedGithubSessionTokenAt = 0;
149
+ let githubSessionTokenPromise = null;
150
+ let githubTokenSourceLogged = "";
151
+
152
+ function parseGithubRepoSlug(raw) {
153
+ const text = String(raw || "").trim();
154
+ if (!text) return { owner: "", repo: "" };
155
+ const m = text.match(/^([^/\s]+)\/([^/\s]+)$/);
156
+ if (!m) return { owner: "", repo: "" };
157
+ return { owner: m[1], repo: m[2] };
158
+ }
159
+
160
+ async function resolveGithubSessionToken() {
161
+ const now = Date.now();
162
+ if (
163
+ cachedGithubSessionToken &&
164
+ now - cachedGithubSessionTokenAt < GITHUB_TOKEN_CACHE_TTL_MS
165
+ ) {
166
+ return cachedGithubSessionToken;
167
+ }
168
+ if (githubSessionTokenPromise) return githubSessionTokenPromise;
169
+
170
+ githubSessionTokenPromise = (async () => {
171
+ const explicitToken =
172
+ process.env.GH_TOKEN ||
173
+ process.env.GITHUB_TOKEN ||
174
+ process.env.COPILOT_CLI_TOKEN ||
175
+ process.env.GITHUB_PAT ||
176
+ "";
177
+ if (explicitToken) {
178
+ cachedGithubSessionToken = explicitToken;
179
+ cachedGithubSessionTokenAt = Date.now();
180
+ return explicitToken;
181
+ }
182
+
183
+ try {
184
+ const { owner, repo } = parseGithubRepoSlug(process.env.GITHUB_REPOSITORY);
185
+ const { token, type } = await getGitHubToken({
186
+ owner: owner || undefined,
187
+ repo: repo || undefined,
188
+ });
189
+ if (token) {
190
+ cachedGithubSessionToken = token;
191
+ cachedGithubSessionTokenAt = Date.now();
192
+ if (githubTokenSourceLogged !== type) {
193
+ githubTokenSourceLogged = type || "unknown";
194
+ console.log(`${TAG} injected GitHub token into agent session env (${type || "unknown"})`);
195
+ }
196
+ return token;
197
+ }
198
+ } catch {
199
+ // best effort
200
+ }
201
+ return "";
202
+ })();
203
+
204
+ try {
205
+ return await githubSessionTokenPromise;
206
+ } finally {
207
+ githubSessionTokenPromise = null;
208
+ }
209
+ }
210
+
211
+ function injectGitHubSessionEnv(baseEnv, token) {
212
+ const env = { ...(baseEnv || {}) };
213
+ const resolved = String(token || "").trim();
214
+ if (!resolved) return env;
215
+ if (!env.GH_TOKEN) env.GH_TOKEN = resolved;
216
+ if (!env.GITHUB_TOKEN) env.GITHUB_TOKEN = resolved;
217
+ if (!env.GITHUB_PAT) env.GITHUB_PAT = resolved;
218
+ if (!env.COPILOT_CLI_TOKEN) env.COPILOT_CLI_TOKEN = resolved;
219
+ return env;
220
+ }
221
+
115
222
  /**
116
223
  * Extract a human-readable task heading from the prompt built by _buildTaskPrompt.
117
224
  * The first line is "# TASKID — Task Title"; we return the title portion only.
@@ -172,7 +279,7 @@ function createScopedAbortController(externalAC, timeoutMs) {
172
279
  if (!controller.signal.aborted) {
173
280
  controller.abort("timeout");
174
281
  }
175
- }, timeoutMs);
282
+ }, clampTimerDelayMs(timeoutMs, "abort-timeout"));
176
283
  if (timeoutHandle && typeof timeoutHandle.unref === "function") {
177
284
  timeoutHandle.unref();
178
285
  }
@@ -253,7 +360,7 @@ function shouldFallbackForSdkError(error) {
253
360
  * @param {string} name SDK canonical name
254
361
  * @returns {{ ok: boolean, reason: string|null }}
255
362
  */
256
- function hasSdkPrerequisites(name) {
363
+ function hasSdkPrerequisites(name, runtimeEnv = process.env) {
257
364
  // Test mocks bypass prerequisite checks
258
365
  if (process.env[`__MOCK_${name.toUpperCase()}_AVAILABLE`] === "1") {
259
366
  return { ok: true, reason: null };
@@ -262,10 +369,10 @@ function hasSdkPrerequisites(name) {
262
369
  if (name === "codex") {
263
370
  // Codex needs an OpenAI API key (or Azure key, or profile-specific key)
264
371
  const hasKey =
265
- process.env.OPENAI_API_KEY ||
266
- process.env.AZURE_OPENAI_API_KEY ||
267
- process.env.CODEX_MODEL_PROFILE_XL_API_KEY ||
268
- process.env.CODEX_MODEL_PROFILE_M_API_KEY;
372
+ runtimeEnv.OPENAI_API_KEY ||
373
+ runtimeEnv.AZURE_OPENAI_API_KEY ||
374
+ runtimeEnv.CODEX_MODEL_PROFILE_XL_API_KEY ||
375
+ runtimeEnv.CODEX_MODEL_PROFILE_M_API_KEY;
269
376
  if (!hasKey) {
270
377
  return { ok: false, reason: "no API key (OPENAI_API_KEY / AZURE_OPENAI_API_KEY)" };
271
378
  }
@@ -277,7 +384,7 @@ function hasSdkPrerequisites(name) {
277
384
  return { ok: true, reason: null };
278
385
  }
279
386
  if (name === "claude") {
280
- const hasKey = process.env.ANTHROPIC_API_KEY;
387
+ const hasKey = runtimeEnv.ANTHROPIC_API_KEY;
281
388
  if (!hasKey) {
282
389
  return { ok: false, reason: "no ANTHROPIC_API_KEY" };
283
390
  }
@@ -337,6 +444,36 @@ async function withSanitizedOpenAiEnv(fn) {
337
444
  }
338
445
  }
339
446
 
447
+ async function withTemporaryEnv(overrides, fn) {
448
+ if (!overrides || typeof overrides !== "object") {
449
+ return await fn();
450
+ }
451
+ const prev = new Map();
452
+ const touched = [];
453
+ for (const [key, value] of Object.entries(overrides)) {
454
+ if (!key) continue;
455
+ prev.set(key, process.env[key]);
456
+ touched.push(key);
457
+ if (value == null) {
458
+ delete process.env[key];
459
+ } else {
460
+ process.env[key] = String(value);
461
+ }
462
+ }
463
+ try {
464
+ return await fn();
465
+ } finally {
466
+ for (const key of touched) {
467
+ const oldValue = prev.get(key);
468
+ if (oldValue == null) {
469
+ delete process.env[key];
470
+ } else {
471
+ process.env[key] = oldValue;
472
+ }
473
+ }
474
+ }
475
+ }
476
+
340
477
  /**
341
478
  * Build Codex SDK constructor options with Azure auto-detection.
342
479
  * When OPENAI_BASE_URL points to Azure, configures the SDK with Azure
@@ -706,6 +843,8 @@ export function getAvailableSdks() {
706
843
  * @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string }>}
707
844
  */
708
845
  async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
846
+ // Coerce to number — prevents string concatenation in setTimeout arithmetic
847
+ timeoutMs = Number(timeoutMs) || DEFAULT_TIMEOUT_MS;
709
848
  const {
710
849
  onEvent,
711
850
  abortController: externalAC,
@@ -816,6 +955,9 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
816
955
  // Hard timeout: safety net if the SDK's async iterator ignores AbortSignal.
817
956
  // Fires HARD_TIMEOUT_BUFFER_MS after the soft timeout to forcibly break the loop.
818
957
  let hardTimer;
958
+ let firstEventTimer = null;
959
+ let firstEventTimeoutHit = false;
960
+ let eventCount = 0;
819
961
 
820
962
  // ── 4. Stream the turn ───────────────────────────────────────────────────
821
963
  try {
@@ -826,19 +968,23 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
826
968
 
827
969
  let finalResponse = "";
828
970
  const allItems = [];
829
-
830
971
  // Race the event iterator against a hard timeout.
831
972
  // The soft timeout fires controller.abort() which the SDK should honor.
832
973
  // The hard timeout is a safety net in case the SDK iterator ignores the abort.
833
974
  const hardTimeoutPromise = new Promise((_, reject) => {
834
975
  hardTimer = setTimeout(
835
976
  () => reject(new Error("hard_timeout")),
836
- timeoutMs + HARD_TIMEOUT_BUFFER_MS,
977
+ clampTimerDelayMs(timeoutMs + HARD_TIMEOUT_BUFFER_MS, "codex-hard-timeout"),
837
978
  );
838
979
  });
839
980
 
840
981
  const iterateEvents = async () => {
841
982
  for await (const event of turn.events) {
983
+ eventCount += 1;
984
+ if (firstEventTimer) {
985
+ clearTimeout(firstEventTimer);
986
+ firstEventTimer = null;
987
+ }
842
988
  if (controller.signal.aborted) break;
843
989
  if (event?.type === "thread.started" && event?.thread_id) {
844
990
  emitThreadReady(event.thread_id);
@@ -859,8 +1005,19 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
859
1005
  }
860
1006
  };
861
1007
 
1008
+ const firstEventTimeoutMs = getFirstEventTimeoutMs(timeoutMs);
1009
+ if (firstEventTimeoutMs) {
1010
+ firstEventTimer = setTimeout(() => {
1011
+ if (eventCount > 0 || controller.signal.aborted) return;
1012
+ firstEventTimeoutHit = true;
1013
+ controller.abort("first_event_timeout");
1014
+ }, firstEventTimeoutMs);
1015
+ if (typeof firstEventTimer.unref === "function") firstEventTimer.unref();
1016
+ }
1017
+
862
1018
  await Promise.race([iterateEvents(), hardTimeoutPromise]);
863
1019
  clearTimeout(hardTimer);
1020
+ if (firstEventTimer) clearTimeout(firstEventTimer);
864
1021
  clearAbortScope();
865
1022
 
866
1023
  const output =
@@ -877,17 +1034,21 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
877
1034
  } catch (err) {
878
1035
  clearAbortScope();
879
1036
  if (hardTimer) clearTimeout(hardTimer);
1037
+ if (firstEventTimer) clearTimeout(firstEventTimer);
880
1038
  if (steerKey) unregisterActiveSession(steerKey);
881
1039
  const isTimeout =
882
1040
  err.name === "AbortError" ||
883
1041
  String(err) === "timeout" ||
884
1042
  err.message === "hard_timeout";
885
1043
  if (isTimeout) {
1044
+ const firstEventSuffix = firstEventTimeoutHit
1045
+ ? ` (no events received within ${getFirstEventTimeoutMs(timeoutMs)}ms)`
1046
+ : "";
886
1047
  return {
887
1048
  success: false,
888
1049
  output: "",
889
1050
  items: [],
890
- error: `${TAG} codex timeout after ${timeoutMs}ms${err.message === "hard_timeout" ? " (hard timeout — SDK iterator unresponsive)" : ""}`,
1051
+ error: `${TAG} codex timeout after ${timeoutMs}ms${err.message === "hard_timeout" ? " (hard timeout — SDK iterator unresponsive)" : ""}${firstEventSuffix}`,
891
1052
  sdk: "codex",
892
1053
  threadId: null,
893
1054
  };
@@ -971,6 +1132,8 @@ function autoRespondToUserInput(request) {
971
1132
  * @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string }>}
972
1133
  */
973
1134
  async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
1135
+ // Coerce to number — prevents string concatenation in setTimeout arithmetic
1136
+ timeoutMs = Number(timeoutMs) || DEFAULT_TIMEOUT_MS;
974
1137
  const {
975
1138
  onEvent,
976
1139
  abortController: externalAC,
@@ -978,6 +1141,7 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
978
1141
  onThreadReady = null,
979
1142
  model: requestedModel = null,
980
1143
  taskKey: steerKey = null,
1144
+ envOverrides = null,
981
1145
  } = extra;
982
1146
 
983
1147
  // ── 1. Load the SDK ──────────────────────────────────────────────────────
@@ -998,11 +1162,15 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
998
1162
  }
999
1163
 
1000
1164
  // ── 2. Detect auth token ─────────────────────────────────────────────────
1165
+ const runtimeEnv =
1166
+ envOverrides && typeof envOverrides === "object"
1167
+ ? { ...process.env, ...envOverrides }
1168
+ : process.env;
1001
1169
  const token =
1002
- process.env.COPILOT_CLI_TOKEN ||
1003
- process.env.GITHUB_TOKEN ||
1004
- process.env.GH_TOKEN ||
1005
- process.env.GITHUB_PAT ||
1170
+ runtimeEnv.COPILOT_CLI_TOKEN ||
1171
+ runtimeEnv.GITHUB_TOKEN ||
1172
+ runtimeEnv.GH_TOKEN ||
1173
+ runtimeEnv.GITHUB_PAT ||
1006
1174
  undefined;
1007
1175
 
1008
1176
  // ── 3. Create & start ephemeral client (LOCAL mode) ──────────────────────
@@ -1023,10 +1191,10 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
1023
1191
  const autoApprovePermissions = shouldAutoApproveCopilotPermissions();
1024
1192
  const clientEnv = autoApprovePermissions
1025
1193
  ? {
1026
- ...process.env,
1027
- COPILOT_ALLOW_ALL: process.env.COPILOT_ALLOW_ALL || "true",
1194
+ ...runtimeEnv,
1195
+ COPILOT_ALLOW_ALL: runtimeEnv.COPILOT_ALLOW_ALL || "true",
1028
1196
  }
1029
- : process.env;
1197
+ : runtimeEnv;
1030
1198
  try {
1031
1199
  await withSanitizedOpenAiEnv(async () => {
1032
1200
  let clientOpts;
@@ -1236,7 +1404,7 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
1236
1404
  // the run to continue rather than stalling for the full hard timeout.
1237
1405
  if (finalResponse.trim()) return finish(resolveP);
1238
1406
  finish(() => rejectP(new Error("timeout_waiting_for_idle")));
1239
- }, timeoutMs + 1000);
1407
+ }, clampTimerDelayMs(timeoutMs + 1000, "copilot-idle-timeout"));
1240
1408
  if (idleTimer && typeof idleTimer.unref === "function") {
1241
1409
  idleTimer.unref();
1242
1410
  }
@@ -1247,7 +1415,7 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
1247
1415
  const copilotHardTimeout = new Promise((_, reject) => {
1248
1416
  const ht = setTimeout(
1249
1417
  () => reject(new Error("hard_timeout")),
1250
- timeoutMs + HARD_TIMEOUT_BUFFER_MS,
1418
+ clampTimerDelayMs(timeoutMs + HARD_TIMEOUT_BUFFER_MS, "copilot-hard-timeout"),
1251
1419
  );
1252
1420
  // Don't let this timer keep the process alive
1253
1421
  if (ht && typeof ht.unref === "function") ht.unref();
@@ -1367,6 +1535,8 @@ async function resumeCopilotThread(
1367
1535
  * @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string }>}
1368
1536
  */
1369
1537
  async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
1538
+ // Coerce to number — prevents string concatenation in setTimeout arithmetic
1539
+ timeoutMs = Number(timeoutMs) || DEFAULT_TIMEOUT_MS;
1370
1540
  const {
1371
1541
  onEvent,
1372
1542
  abortController: externalAC,
@@ -1376,6 +1546,7 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
1376
1546
  onThreadReady = null,
1377
1547
  model: requestedModel = null,
1378
1548
  taskKey: steerKey = null,
1549
+ envOverrides = null,
1379
1550
  } = extra;
1380
1551
 
1381
1552
  // ── 1. Load the SDK ──────────────────────────────────────────────────────
@@ -1396,10 +1567,14 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
1396
1567
  }
1397
1568
 
1398
1569
  // ── 2. Detect auth ──────────────────────────────────────────────────────
1570
+ const runtimeEnv =
1571
+ envOverrides && typeof envOverrides === "object"
1572
+ ? { ...process.env, ...envOverrides }
1573
+ : process.env;
1399
1574
  const apiKey =
1400
- process.env.ANTHROPIC_API_KEY ||
1401
- process.env.CLAUDE_API_KEY ||
1402
- process.env.CLAUDE_KEY ||
1575
+ runtimeEnv.ANTHROPIC_API_KEY ||
1576
+ runtimeEnv.CLAUDE_API_KEY ||
1577
+ runtimeEnv.CLAUDE_KEY ||
1403
1578
  undefined;
1404
1579
 
1405
1580
  // ── 3. Build message queue ───────────────────────────────────────────────
@@ -1539,31 +1714,33 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
1539
1714
  settingSources: ["user", "project"],
1540
1715
  permissionMode:
1541
1716
  claudePermissionMode ||
1542
- process.env.CLAUDE_PERMISSION_MODE ||
1717
+ runtimeEnv.CLAUDE_PERMISSION_MODE ||
1543
1718
  "bypassPermissions",
1544
1719
  };
1545
1720
  if (apiKey) options.apiKey = apiKey;
1546
1721
  const explicitAllowedTools = normalizeList(claudeAllowedTools);
1547
1722
  const allowedTools = explicitAllowedTools.length
1548
1723
  ? explicitAllowedTools
1549
- : normalizeList(process.env.CLAUDE_ALLOWED_TOOLS);
1724
+ : normalizeList(runtimeEnv.CLAUDE_ALLOWED_TOOLS);
1550
1725
  if (allowedTools.length) {
1551
1726
  options.allowedTools = allowedTools;
1552
1727
  }
1553
1728
 
1554
1729
  const model = String(
1555
1730
  requestedModel ||
1556
- process.env.CLAUDE_MODEL ||
1557
- process.env.CLAUDE_CODE_MODEL ||
1558
- process.env.ANTHROPIC_MODEL ||
1731
+ runtimeEnv.CLAUDE_MODEL ||
1732
+ runtimeEnv.CLAUDE_CODE_MODEL ||
1733
+ runtimeEnv.ANTHROPIC_MODEL ||
1559
1734
  "",
1560
1735
  ).trim();
1561
1736
  if (model) options.model = model;
1562
1737
 
1563
- const result = queryFn({
1564
- prompt: msgQueue.iterator(),
1565
- options,
1566
- });
1738
+ const result = await withTemporaryEnv(runtimeEnv, async () =>
1739
+ queryFn({
1740
+ prompt: msgQueue.iterator(),
1741
+ options,
1742
+ }),
1743
+ );
1567
1744
 
1568
1745
  let finalResponse = "";
1569
1746
  let activeClaudeSessionId = resumeThreadId || null;
@@ -1623,7 +1800,10 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
1623
1800
  })();
1624
1801
 
1625
1802
  const hardTimeout = new Promise((_, reject) => {
1626
- hardTimer = setTimeout(() => reject(new Error("hard-timeout")), hardTimeoutMs);
1803
+ hardTimer = setTimeout(
1804
+ () => reject(new Error("hard-timeout")),
1805
+ clampTimerDelayMs(hardTimeoutMs, "claude-hard-timeout"),
1806
+ );
1627
1807
  if (hardTimer && typeof hardTimer.unref === "function") {
1628
1808
  hardTimer.unref();
1629
1809
  }
@@ -1758,13 +1938,24 @@ export async function launchEphemeralThread(
1758
1938
  timeoutMs = DEFAULT_TIMEOUT_MS,
1759
1939
  extra = {},
1760
1940
  ) {
1941
+ const resolvedGithubToken = await resolveGithubSessionToken();
1942
+ const baseRuntimeEnv =
1943
+ extra?.envOverrides && typeof extra.envOverrides === "object"
1944
+ ? { ...process.env, ...extra.envOverrides }
1945
+ : { ...process.env };
1946
+ const sessionEnv = injectGitHubSessionEnv(baseRuntimeEnv, resolvedGithubToken);
1947
+ const launchExtra = {
1948
+ ...extra,
1949
+ envOverrides: sessionEnv,
1950
+ };
1951
+
1761
1952
  // ── Resolve MCP servers for this launch ──────────────────────────────────
1762
1953
  try {
1763
- if (!extra._mcpResolved) {
1954
+ if (!launchExtra._mcpResolved) {
1764
1955
  const cfg = loadConfig();
1765
1956
  const mcpCfg = cfg.mcpServers || {};
1766
1957
  if (mcpCfg.enabled !== false) {
1767
- const requestedIds = extra.mcpServers || [];
1958
+ const requestedIds = launchExtra.mcpServers || [];
1768
1959
  const defaultIds = mcpCfg.defaultServers || [];
1769
1960
  if (requestedIds.length || defaultIds.length) {
1770
1961
  const registry = await getMcpRegistry();
@@ -1774,19 +1965,19 @@ export async function launchEphemeralThread(
1774
1965
  { defaultServers: defaultIds, catalogOverrides: mcpCfg.catalogOverrides || {} },
1775
1966
  );
1776
1967
  if (resolved.length) {
1777
- extra._resolvedMcpServers = resolved;
1968
+ launchExtra._resolvedMcpServers = resolved;
1778
1969
  }
1779
1970
  }
1780
1971
  }
1781
- extra._mcpResolved = true;
1972
+ launchExtra._mcpResolved = true;
1782
1973
  }
1783
1974
  } catch (mcpErr) {
1784
1975
  console.warn(`${TAG} MCP server resolution failed (non-fatal): ${mcpErr.message}`);
1785
1976
  }
1786
1977
 
1787
1978
  // Determine the primary SDK to try
1788
- const requestedSdk = extra.sdk
1789
- ? String(extra.sdk).trim().toLowerCase()
1979
+ const requestedSdk = launchExtra.sdk
1980
+ ? String(launchExtra.sdk).trim().toLowerCase()
1790
1981
  : null;
1791
1982
 
1792
1983
  const primaryName =
@@ -1794,7 +1985,7 @@ export async function launchEphemeralThread(
1794
1985
  ? requestedSdk
1795
1986
  : resolvePoolSdkName();
1796
1987
 
1797
- const attemptOrder = extra?.disableFallback
1988
+ const attemptOrder = launchExtra?.disableFallback
1798
1989
  ? [primaryName]
1799
1990
  : [
1800
1991
  primaryName,
@@ -1805,7 +1996,7 @@ export async function launchEphemeralThread(
1805
1996
  const triedSdkNames = [];
1806
1997
  const missingPrereqSdks = [];
1807
1998
  const cooledDownSdks = [];
1808
- const ignoreSdkCooldown = extra?.ignoreSdkCooldown === true;
1999
+ const ignoreSdkCooldown = launchExtra?.ignoreSdkCooldown === true;
1809
2000
 
1810
2001
  for (const name of attemptOrder) {
1811
2002
  const adapter = SDK_ADAPTERS[name];
@@ -1831,7 +2022,7 @@ export async function launchEphemeralThread(
1831
2022
  }
1832
2023
 
1833
2024
  // Check prerequisites before wasting time trying an unconfigured SDK
1834
- const prereq = hasSdkPrerequisites(name);
2025
+ const prereq = hasSdkPrerequisites(name, sessionEnv);
1835
2026
  if (!prereq.ok) {
1836
2027
  missingPrereqSdks.push({ name, reason: prereq.reason });
1837
2028
  if (name === primaryName) {
@@ -1858,7 +2049,7 @@ export async function launchEphemeralThread(
1858
2049
 
1859
2050
  triedSdkNames.push(name);
1860
2051
  const launcher = await adapter.load();
1861
- const result = await launcher(prompt, cwd, timeoutMs, extra);
2052
+ const result = await launcher(prompt, cwd, timeoutMs, launchExtra);
1862
2053
  lastAttemptResult = result;
1863
2054
 
1864
2055
  if (result.success) {
@@ -2256,6 +2447,8 @@ function isPoisonedCodexResumeError(errorValue) {
2256
2447
  * @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string, threadId: string|null }>}
2257
2448
  */
2258
2449
  async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
2450
+ // Coerce to number — prevents string concatenation in setTimeout arithmetic
2451
+ timeoutMs = Number(timeoutMs) || DEFAULT_TIMEOUT_MS;
2259
2452
  const { onEvent, abortController: externalAC, envOverrides = null } = extra;
2260
2453
 
2261
2454
  let CodexClass;
@@ -2324,6 +2517,9 @@ async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
2324
2517
  timeoutMs,
2325
2518
  );
2326
2519
  let hardTimer;
2520
+ let firstEventTimer = null;
2521
+ let firstEventTimeoutHit = false;
2522
+ let eventCount = 0;
2327
2523
 
2328
2524
  try {
2329
2525
  const safePrompt = sanitizeAndBoundPrompt(prompt);
@@ -2337,12 +2533,17 @@ async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
2337
2533
  const hardTimeoutPromise = new Promise((_, reject) => {
2338
2534
  hardTimer = setTimeout(
2339
2535
  () => reject(new Error("hard_timeout")),
2340
- timeoutMs + HARD_TIMEOUT_BUFFER_MS,
2536
+ clampTimerDelayMs(timeoutMs + HARD_TIMEOUT_BUFFER_MS, "codex-resume-hard-timeout"),
2341
2537
  );
2342
2538
  });
2343
2539
 
2344
2540
  const iterateEvents = async () => {
2345
2541
  for await (const event of turn.events) {
2542
+ eventCount += 1;
2543
+ if (firstEventTimer) {
2544
+ clearTimeout(firstEventTimer);
2545
+ firstEventTimer = null;
2546
+ }
2346
2547
  if (controller.signal.aborted) break;
2347
2548
  if (typeof onEvent === "function")
2348
2549
  try {
@@ -2359,8 +2560,19 @@ async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
2359
2560
  }
2360
2561
  };
2361
2562
 
2563
+ const firstEventTimeoutMs = getFirstEventTimeoutMs(timeoutMs);
2564
+ if (firstEventTimeoutMs) {
2565
+ firstEventTimer = setTimeout(() => {
2566
+ if (eventCount > 0 || controller.signal.aborted) return;
2567
+ firstEventTimeoutHit = true;
2568
+ controller.abort("first_event_timeout");
2569
+ }, firstEventTimeoutMs);
2570
+ if (typeof firstEventTimer.unref === "function") firstEventTimer.unref();
2571
+ }
2572
+
2362
2573
  await Promise.race([iterateEvents(), hardTimeoutPromise]);
2363
2574
  clearTimeout(hardTimer);
2575
+ if (firstEventTimer) clearTimeout(firstEventTimer);
2364
2576
  clearAbortScope();
2365
2577
 
2366
2578
  const newThreadId = thread.id || threadId;
@@ -2375,16 +2587,21 @@ async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
2375
2587
  } catch (err) {
2376
2588
  clearAbortScope();
2377
2589
  if (hardTimer) clearTimeout(hardTimer);
2590
+ if (firstEventTimer) clearTimeout(firstEventTimer);
2378
2591
  const isTimeout =
2379
2592
  err.name === "AbortError" ||
2380
2593
  String(err) === "timeout" ||
2381
2594
  err.message === "hard_timeout";
2595
+ const firstEventSuffix =
2596
+ isTimeout && firstEventTimeoutHit
2597
+ ? ` (no events received within ${getFirstEventTimeoutMs(timeoutMs)}ms)`
2598
+ : "";
2382
2599
  return {
2383
2600
  success: false,
2384
2601
  output: "",
2385
2602
  items: [],
2386
2603
  error: isTimeout
2387
- ? `${TAG} codex resume timeout after ${timeoutMs}ms${err.message === "hard_timeout" ? " (hard timeout)" : ""}`
2604
+ ? `${TAG} codex resume timeout after ${timeoutMs}ms${err.message === "hard_timeout" ? " (hard timeout)" : ""}${firstEventSuffix}`
2388
2605
  : `Thread resume error: ${err.message}`,
2389
2606
  sdk: "codex",
2390
2607
  threadId: null,
@@ -2456,6 +2673,15 @@ export async function launchOrResumeThread(
2456
2673
  ) {
2457
2674
  await ensureThreadRegistryLoaded();
2458
2675
  const { taskKey, ...restExtra } = extra;
2676
+ const resolvedGithubToken = await resolveGithubSessionToken();
2677
+ const restBaseEnv =
2678
+ restExtra?.envOverrides && typeof restExtra.envOverrides === "object"
2679
+ ? { ...process.env, ...restExtra.envOverrides }
2680
+ : { ...process.env };
2681
+ restExtra.envOverrides = injectGitHubSessionEnv(
2682
+ restBaseEnv,
2683
+ resolvedGithubToken,
2684
+ );
2459
2685
  // Pass taskKey through as steer key so SDK launchers can register active sessions
2460
2686
  restExtra.taskKey = taskKey;
2461
2687
  if (restExtra.sdk) {
@@ -308,6 +308,12 @@ const RE_ERROR_NOISE = [
308
308
  // Session completion indicators
309
309
  const RE_SESSION_DONE = /"Done"\s*:\s*"/;
310
310
  const STR_TASK_COMPLETE = "task_complete";
311
+ const INTERNAL_SESSION_COMPLETION_MARKERS = [
312
+ "EVT[turn.completed]",
313
+ "EVT[session.completed]",
314
+ "EVT[response.completed]",
315
+ "EVT[thread.completed]",
316
+ ];
311
317
 
312
318
  // ── Main Detector Class ─────────────────────────────────────────────────────
313
319
 
@@ -1061,7 +1067,13 @@ export class AnomalyDetector {
1061
1067
  * Detect session completion (mark as dead to stop analysis).
1062
1068
  */
1063
1069
  #detectSessionCompletion(line, state) {
1064
- if (RE_SESSION_DONE.test(line) || line.includes(STR_TASK_COMPLETE)) {
1070
+ if (
1071
+ RE_SESSION_DONE.test(line) ||
1072
+ line.includes(STR_TASK_COMPLETE) ||
1073
+ INTERNAL_SESSION_COMPLETION_MARKERS.some((marker) =>
1074
+ line.includes(marker),
1075
+ )
1076
+ ) {
1065
1077
  state.isDead = true;
1066
1078
  }
1067
1079
  }