bosun 0.41.8 → 0.41.9

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 (42) hide show
  1. package/README.md +23 -1
  2. package/agent/agent-event-bus.mjs +31 -2
  3. package/agent/agent-pool.mjs +251 -11
  4. package/agent/agent-prompts.mjs +5 -1
  5. package/agent/agent-supervisor.mjs +22 -0
  6. package/agent/primary-agent.mjs +115 -5
  7. package/cli.mjs +3 -2
  8. package/desktop/main.mjs +350 -25
  9. package/desktop/preload.cjs +8 -0
  10. package/desktop/preload.mjs +19 -0
  11. package/entrypoint.mjs +332 -0
  12. package/infra/health-status.mjs +72 -0
  13. package/infra/library-manager.mjs +58 -1
  14. package/infra/maintenance.mjs +1 -2
  15. package/infra/monitor.mjs +25 -7
  16. package/infra/session-tracker.mjs +30 -3
  17. package/package.json +10 -4
  18. package/server/bosun-mcp-server.mjs +1004 -0
  19. package/server/setup-web-server.mjs +287 -258
  20. package/server/ui-server.mjs +218 -23
  21. package/shell/claude-shell.mjs +14 -1
  22. package/shell/codex-model-profiles.mjs +166 -29
  23. package/shell/codex-shell.mjs +56 -18
  24. package/shell/opencode-providers.mjs +20 -8
  25. package/task/task-executor.mjs +28 -0
  26. package/task/task-store.mjs +13 -4
  27. package/tools/list-todos.mjs +7 -1
  28. package/ui/app.js +3 -2
  29. package/ui/components/agent-selector.js +127 -0
  30. package/ui/components/session-list.js +2 -0
  31. package/ui/demo-defaults.js +6 -6
  32. package/ui/modules/router.js +2 -0
  33. package/ui/modules/state.js +13 -5
  34. package/ui/tabs/chat.js +3 -0
  35. package/ui/tabs/library.js +284 -52
  36. package/ui/tabs/tasks.js +5 -13
  37. package/workflow/workflow-engine.mjs +16 -4
  38. package/workflow/workflow-nodes/definitions.mjs +37 -0
  39. package/workflow/workflow-nodes.mjs +489 -153
  40. package/workflow/workflow-templates.mjs +0 -5
  41. package/workflow-templates/github.mjs +106 -16
  42. package/workspace/worktree-manager.mjs +1 -1
package/README.md CHANGED
@@ -12,7 +12,7 @@ _The name "Bosun" comes from "boatswain", the ship's officer responsible for coo
12
12
  _That maps directly to the Bosun project: it does not replace the captain or crew, it orchestrates the work. Our Bosun plans tasks, routes them to the right executors, enforces operational checks, and keeps humans in control while the system keeps delivery moving. Autonomous engineering with you in control of the operation._
13
13
 
14
14
  <p align="center">
15
- <a href="https://bosun.engineer">Website</a> · <a href="https://bosun.engineer/docs/">Docs</a> · <a href="https://github.com/virtengine/bosun?tab=readme-ov-file#bosun">GitHub</a> · <a href="https://www.npmjs.com/package/bosun">npm</a> · <a href="https://github.com/virtengine/bosun/issues">Issues</a>
15
+ <a href="https://bosun.engineer">Website</a> · <a href="https://bosun.engineer/docs/">Docs</a> · <a href="https://github.com/virtengine/bosun?tab=readme-ov-file#bosun">GitHub</a> · <a href="https://www.npmjs.com/package/bosun">npm</a> · <a href="https://hub.docker.com/r/virtengine/bosun">Docker Hub</a> · <a href="https://github.com/virtengine/bosun/issues">Issues</a>
16
16
  </p>
17
17
 
18
18
  <p align="center">
@@ -41,6 +41,28 @@ First run launches setup automatically. You can also run setup directly:
41
41
  bosun --setup
42
42
  ```
43
43
 
44
+ ### Docker quick start
45
+
46
+ Run Bosun with no local Node.js install — pull from Docker Hub:
47
+
48
+ ```bash
49
+ docker run -d --name bosun -p 3080:3080 \
50
+ -v bosun-data:/data \
51
+ -e BOSUN_API_KEY=your-secret-key \
52
+ virtengine/bosun:latest
53
+ ```
54
+
55
+ Or build from the cloned repo:
56
+
57
+ ```bash
58
+ git clone https://github.com/virtengine/bosun.git && cd bosun
59
+ docker compose up -d
60
+ ```
61
+
62
+ Open `https://localhost:3080` to start the setup wizard.
63
+
64
+ > **All installation options** — npm, source, Docker Hub, docker-compose, Electron desktop — are documented in [`INSTALL.md`](INSTALL.md) and on [bosun.engineer/docs/installation](https://bosun.engineer/docs/installation.html).
65
+
44
66
  Requires:
45
67
 
46
68
  - Node.js 18+
@@ -117,6 +117,7 @@ export class AgentEventBus {
117
117
  this._sendTelegram = options.sendTelegram || null;
118
118
  this._getTask = options.getTask || null;
119
119
  this._setTaskStatus = options.setTaskStatus || null;
120
+ this._updateTask = options.updateTask || null;
120
121
  this._supervisor = options.supervisor || null;
121
122
 
122
123
  this._maxEventLogSize = options.maxEventLogSize || DEFAULTS.maxEventLogSize;
@@ -234,7 +235,7 @@ export class AgentEventBus {
234
235
 
235
236
  // ── Dedup
236
237
  const key = `${type}:${taskId}`;
237
- const last = this._recentEmits.get(key);
238
+ const last = this._recentEmits.get(key);
238
239
  if (typeof last === "number" && ts - last < this._dedupeWindowMs) return;
239
240
  this._recentEmits.set(key, ts);
240
241
  if (this._recentEmits.size > 200) {
@@ -856,6 +857,34 @@ export class AgentEventBus {
856
857
  this._setTaskStatus(taskId, "blocked", "agent-event-bus");
857
858
  } catch { /* best-effort */ }
858
859
  }
860
+
861
+ // Persist blocked metadata so auto-recovery can unblock after cooldown.
862
+ if (this._updateTask) {
863
+ try {
864
+ const autoRecoverDelayMs = 30 * 60 * 1000; // 30 min cooldown
865
+ const retryAt = new Date(now + autoRecoverDelayMs).toISOString();
866
+ const blockedReason = String(recovery?.reason || "too many consecutive errors").trim();
867
+ const existingTask = typeof this._getTask === "function" ? this._getTask(taskId) : null;
868
+ const existingMeta = existingTask?.meta && typeof existingTask.meta === "object" ? existingTask.meta : {};
869
+ this._updateTask(taskId, {
870
+ blockedReason,
871
+ cooldownUntil: retryAt,
872
+ meta: {
873
+ ...existingMeta,
874
+ autoRecovery: {
875
+ active: true,
876
+ reason: "consecutive_errors",
877
+ errorCount: recovery?.errorCount || 0,
878
+ pattern: classification?.pattern || null,
879
+ retryAt,
880
+ recoveryDelayMs: autoRecoverDelayMs,
881
+ recordedAt: new Date(now).toISOString(),
882
+ },
883
+ },
884
+ });
885
+ } catch { /* best-effort */ }
886
+ }
887
+
859
888
  if (this._sendTelegram) {
860
889
  const title = taskTitle || taskId;
861
890
  this._sendTelegram(
@@ -1062,4 +1091,4 @@ export class AgentEventBus {
1062
1091
  export function createAgentEventBus(options) {
1063
1092
  return new AgentEventBus(options);
1064
1093
  }
1065
-
1094
+
@@ -43,6 +43,7 @@ import { resolve, dirname } from "node:path";
43
43
  import { existsSync, readFileSync } from "node:fs";
44
44
  import { homedir } from "node:os";
45
45
  import { fileURLToPath } from "node:url";
46
+ import { createRequire } from "node:module";
46
47
  import { loadConfig } from "../config/config.mjs";
47
48
  import { resolveRepoRoot, resolveAgentRepoRoot } from "../config/repo-root.mjs";
48
49
  import { resolveCodexProfileRuntime } from "../shell/codex-model-profiles.mjs";
@@ -97,6 +98,23 @@ const HARD_TIMEOUT_BUFFER_MS = 5 * 60_000; // 5 minutes
97
98
 
98
99
  /** Tag for console logging */
99
100
  const TAG = "[agent-pool]";
101
+ const require = createRequire(import.meta.url);
102
+ const MODULE_PRESENCE_CACHE = new Map();
103
+
104
+ function hasOptionalModule(specifier) {
105
+ if (MODULE_PRESENCE_CACHE.has(specifier)) {
106
+ return MODULE_PRESENCE_CACHE.get(specifier);
107
+ }
108
+ let ok = false;
109
+ try {
110
+ require.resolve(specifier);
111
+ ok = true;
112
+ } catch {
113
+ ok = false;
114
+ }
115
+ MODULE_PRESENCE_CACHE.set(specifier, ok);
116
+ return ok;
117
+ }
100
118
  const MAX_PROMPT_BYTES = 180_000;
101
119
  const MAX_SET_TIMEOUT_MS = 2_147_483_647; // Node.js setTimeout 32-bit signed max
102
120
  let timeoutClampWarningKey = "";
@@ -424,13 +442,27 @@ function injectGitHubSessionEnv(baseEnv, token) {
424
442
 
425
443
  /**
426
444
  * Extract a human-readable task heading from the prompt built by _buildTaskPrompt.
427
- * The first line is "# TASKID Task Title"; we return the title portion only.
445
+ * The first line is "# Task: Task Title" (legacy: "# TASKID Task Title");
446
+ * we return the title portion only.
428
447
  * Falls back to the raw first line if no em-dash separator is found.
429
448
  * @param {string} prompt
430
449
  * @returns {string}
431
450
  */
432
451
  function extractTaskHeading(prompt) {
433
452
  const firstLine = String(prompt || "").split(/\r?\n/)[0].replace(/^#+\s*/, "").trim();
453
+ if (!firstLine) return "Execute Task";
454
+ const lowerLine = firstLine.toLowerCase();
455
+ if (lowerLine.startsWith("task")) {
456
+ let index = 4;
457
+ while (index < firstLine.length && /\s/.test(firstLine[index])) index += 1;
458
+ const separator = firstLine[index];
459
+ if (separator === ":" || separator === "-" || separator === "\u2014") {
460
+ index += 1;
461
+ while (index < firstLine.length && /\s/.test(firstLine[index])) index += 1;
462
+ const title = firstLine.slice(index).trim();
463
+ if (title) return title;
464
+ }
465
+ }
434
466
  const dashIdx = firstLine.indexOf(" \u2014 ");
435
467
  const title = dashIdx !== -1 ? firstLine.slice(dashIdx + 3).trim() : firstLine;
436
468
  return title || "Execute Task";
@@ -580,15 +612,20 @@ function hasSdkPrerequisites(name, runtimeEnv = process.env) {
580
612
  }
581
613
 
582
614
  if (name === "codex") {
583
- // Codex needs an OpenAI API key (or Azure key, or profile-specific key),
584
- // OR a valid ~/.codex/config.toml where an env_key reference is satisfied.
615
+ if (!hasOptionalModule("@openai/codex-sdk")) {
616
+ return { ok: false, reason: "@openai/codex-sdk not installed" };
617
+ }
618
+ // Codex auth can come from env vars, config env_key mappings, or persisted
619
+ // CLI login state (for example ~/.codex/auth.json). Because login-based
620
+ // auth is valid and hard to validate exhaustively, avoid false negatives.
585
621
  const hasKey =
586
622
  runtimeEnv.OPENAI_API_KEY ||
587
623
  runtimeEnv.AZURE_OPENAI_API_KEY ||
588
624
  runtimeEnv.CODEX_MODEL_PROFILE_XL_API_KEY ||
589
625
  runtimeEnv.CODEX_MODEL_PROFILE_M_API_KEY;
590
626
  if (hasKey) return { ok: true, reason: null };
591
- // Check ~/.codex/config.toml — Codex CLI SDK reads auth env_key refs from there
627
+
628
+ // Check ~/.codex/config.toml — Codex CLI SDK reads auth env_key refs from there.
592
629
  try {
593
630
  const configToml = resolve(homedir(), ".codex", "config.toml");
594
631
  if (existsSync(configToml)) {
@@ -601,14 +638,33 @@ function hasSdkPrerequisites(name, runtimeEnv = process.env) {
601
638
  } catch {
602
639
  // best effort — fall through to failure
603
640
  }
604
- return { ok: false, reason: "no API key (OPENAI_API_KEY / AZURE_OPENAI_API_KEY) and no satisfied env_key in ~/.codex/config.toml" };
641
+
642
+ // Login-based auth sessions are sufficient even without env keys.
643
+ try {
644
+ const authJson = resolve(homedir(), ".codex", "auth.json");
645
+ if (existsSync(authJson)) {
646
+ const authText = readFileSync(authJson, "utf8").trim();
647
+ if (authText) return { ok: true, reason: null };
648
+ }
649
+ } catch {
650
+ // best effort — fall through to permissive behavior
651
+ }
652
+
653
+ // Do not hard-block Codex when auth source cannot be determined.
654
+ return { ok: true, reason: null };
605
655
  }
606
656
  if (name === "copilot") {
657
+ if (!hasOptionalModule("@github/copilot-sdk")) {
658
+ return { ok: false, reason: "@github/copilot-sdk not installed" };
659
+ }
607
660
  // Copilot auth can come from multiple sources (OAuth manager, gh auth,
608
661
  // VS Code Copilot login, env tokens). Don't block execution here.
609
662
  return { ok: true, reason: null };
610
663
  }
611
664
  if (name === "claude") {
665
+ if (!hasOptionalModule("@anthropic-ai/claude-agent-sdk")) {
666
+ return { ok: false, reason: "@anthropic-ai/claude-agent-sdk not installed" };
667
+ }
612
668
  const hasKey = runtimeEnv.ANTHROPIC_API_KEY;
613
669
  if (!hasKey) {
614
670
  return { ok: false, reason: "no ANTHROPIC_API_KEY" };
@@ -1065,6 +1121,19 @@ export function setPoolSdk(name) {
1065
1121
  export function resetPoolSdkCache() {
1066
1122
  resolvedSdkName = null;
1067
1123
  resolutionLogged = false;
1124
+ sdkFailureCooldownUntil.clear();
1125
+ }
1126
+
1127
+ export function setSdkFailureCooldownForTest(name, cooldownRemainingMs, nowMs = Date.now()) {
1128
+ if (!SDK_ADAPTERS[name]) {
1129
+ throw new Error(`Unknown SDK: ${name}`);
1130
+ }
1131
+ const remainingMs = Number(cooldownRemainingMs);
1132
+ if (!Number.isFinite(remainingMs) || remainingMs <= 0) {
1133
+ sdkFailureCooldownUntil.delete(name);
1134
+ return;
1135
+ }
1136
+ sdkFailureCooldownUntil.set(name, nowMs + Math.trunc(remainingMs));
1068
1137
  }
1069
1138
 
1070
1139
  /**
@@ -1145,6 +1214,11 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
1145
1214
  : process.env;
1146
1215
  const codexSessionEnv = applyNodeWarningSuppressionEnv(codexRuntimeEnv);
1147
1216
  const codexOpts = buildCodexSdkOptions(codexSessionEnv);
1217
+ const explicitEnvModel = String(envOverrides?.CODEX_MODEL || "").trim();
1218
+ if (explicitEnvModel) {
1219
+ codexOpts.env = { ...(codexOpts.env || {}), CODEX_MODEL: explicitEnvModel };
1220
+ codexOpts.config = { ...(codexOpts.config || {}), model: explicitEnvModel };
1221
+ }
1148
1222
  const modelOverride = String(extra?.model || "").trim();
1149
1223
  if (modelOverride) {
1150
1224
  codexOpts.env = { ...(codexOpts.env || {}), CODEX_MODEL: modelOverride };
@@ -2384,6 +2458,7 @@ export async function launchEphemeralThread(
2384
2458
  ];
2385
2459
 
2386
2460
  let lastAttemptResult = null;
2461
+ let primaryFailureResult = null;
2387
2462
  const triedSdkNames = [];
2388
2463
  const missingPrereqSdks = [];
2389
2464
  const cooledDownSdks = [];
@@ -2443,6 +2518,10 @@ export async function launchEphemeralThread(
2443
2518
  return result;
2444
2519
  }
2445
2520
 
2521
+ if (name === primaryName) {
2522
+ primaryFailureResult = result;
2523
+ }
2524
+
2446
2525
  applySdkFailureCooldown(name, result.error);
2447
2526
 
2448
2527
  if (name === primaryName) {
@@ -2456,15 +2535,170 @@ export async function launchEphemeralThread(
2456
2535
  }
2457
2536
  }
2458
2537
 
2538
+ const eligibleSdks = Array.from(
2539
+ new Set(attemptOrder.filter((name) => SDK_ADAPTERS[name] && !isDisabled(name))),
2540
+ );
2541
+ const runnableSdks = eligibleSdks.filter((name) => {
2542
+ const prereq = hasSdkPrerequisites(name, sessionEnv);
2543
+ if (!prereq.ok) {
2544
+ if (!missingPrereqSdks.some((entry) => entry.name === name)) {
2545
+ missingPrereqSdks.push({ name, reason: prereq.reason });
2546
+ }
2547
+ return false;
2548
+ }
2549
+ return true;
2550
+ });
2551
+ const cooledDownSet = new Set(cooledDownSdks.map((entry) => entry.name));
2552
+ const missingPrereqSet = new Set(missingPrereqSdks.map((entry) => entry.name));
2553
+ const allEligibleUnavailable =
2554
+ eligibleSdks.length > 0 &&
2555
+ eligibleSdks.every(
2556
+ (name) => cooledDownSet.has(name) || missingPrereqSet.has(name),
2557
+ );
2558
+
2559
+ if (
2560
+ !ignoreSdkCooldown &&
2561
+ !lastAttemptResult &&
2562
+ triedSdkNames.length === 0 &&
2563
+ runnableSdks.length > 0 &&
2564
+ runnableSdks.every((name) => cooledDownSet.has(name))
2565
+ ) {
2566
+ const forcedRetryOrder = runnableSdks.includes(primaryName)
2567
+ ? [primaryName, ...runnableSdks.filter((name) => name !== primaryName)]
2568
+ : runnableSdks;
2569
+
2570
+ for (const name of forcedRetryOrder) {
2571
+ const cooldownRemainingMs = getSdkCooldownRemainingMs(name);
2572
+ if (cooldownRemainingMs <= 0) continue;
2573
+
2574
+ const prereq = hasSdkPrerequisites(name, sessionEnv);
2575
+ if (!prereq.ok) {
2576
+ if (!missingPrereqSdks.some((entry) => entry.name === name)) {
2577
+ missingPrereqSdks.push({ name, reason: prereq.reason });
2578
+ }
2579
+ continue;
2580
+ }
2581
+
2582
+ const remainingSec = Math.max(1, Math.ceil(cooldownRemainingMs / 1000));
2583
+ console.warn(
2584
+ `${TAG} all eligible SDKs are cooling down; force-retrying "${name}" (${remainingSec}s remaining)`,
2585
+ );
2586
+
2587
+ triedSdkNames.push(name);
2588
+ const launcher = await SDK_ADAPTERS[name].load();
2589
+ const result = await launcher(prompt, cwd, timeoutMs, launchExtra);
2590
+ lastAttemptResult = result;
2591
+
2592
+ if (result.success) {
2593
+ return result;
2594
+ }
2595
+
2596
+ if (!shouldFallbackForSdkError(result.error)) {
2597
+ return result;
2598
+ }
2599
+
2600
+ applySdkFailureCooldown(name, result.error);
2601
+ break;
2602
+ }
2603
+ }
2604
+
2605
+ // If every eligible SDK is unavailable (cooldown and/or missing credentials),
2606
+ // force one cooled-down attempt so work is not blocked for the full cooldown window.
2607
+ const shouldForceCooldownBypassAttempt =
2608
+ !ignoreSdkCooldown &&
2609
+ !lastAttemptResult &&
2610
+ triedSdkNames.length === 0 &&
2611
+ cooledDownSet.has(primaryName) &&
2612
+ allEligibleUnavailable;
2613
+ const forcedSdkName = shouldForceCooldownBypassAttempt
2614
+ ? cooledDownSet.has(primaryName)
2615
+ ? primaryName
2616
+ : eligibleSdks.find((name) => cooledDownSet.has(name)) || null
2617
+ : null;
2618
+
2619
+ if (forcedSdkName) {
2620
+ const prereq = hasSdkPrerequisites(forcedSdkName, sessionEnv);
2621
+ if (!prereq.ok) {
2622
+ // Cooldown means we recently attempted this SDK. Keep one forced retry path
2623
+ // to avoid hard-blocking work on strict prerequisite heuristics.
2624
+ if (
2625
+ !missingPrereqSdks.some(
2626
+ (entry) => entry.name === forcedSdkName && entry.reason === prereq.reason,
2627
+ )
2628
+ ) {
2629
+ missingPrereqSdks.push({ name: forcedSdkName, reason: prereq.reason });
2630
+ }
2631
+ console.warn(
2632
+ `${TAG} all eligible SDKs unavailable; bypassing SDK "${forcedSdkName}" prerequisite gate for forced retry (${prereq.reason})`,
2633
+ );
2634
+ } else {
2635
+ console.warn(
2636
+ `${TAG} no runnable fallback SDK is available (cooldown/prerequisite gate); forcing SDK "${forcedSdkName}" retry`,
2637
+ );
2638
+ }
2639
+ triedSdkNames.push(forcedSdkName);
2640
+ const launcher = await SDK_ADAPTERS[forcedSdkName].load();
2641
+ const forcedResult = await launcher(prompt, cwd, timeoutMs, launchExtra);
2642
+ if (forcedResult.success) {
2643
+ return forcedResult;
2644
+ }
2645
+ lastAttemptResult = forcedResult;
2646
+ if (!shouldFallbackForSdkError(forcedResult.error)) {
2647
+ return forcedResult;
2648
+ }
2649
+ applySdkFailureCooldown(forcedSdkName, forcedResult.error);
2650
+ }
2651
+
2652
+ // Recovery path: when all SDKs were skipped due cooldown/prereq gates, force
2653
+ // one direct attempt against cooled-down SDKs to surface a concrete error.
2654
+ if (!lastAttemptResult && triedSdkNames.length === 0 && cooledDownSdks.length > 0) {
2655
+ const cooledDownRescueOrder = cooledDownSdks
2656
+ .map((entry) => entry.name)
2657
+ .filter((name, idx, arr) => SDK_ADAPTERS[name] && arr.indexOf(name) === idx)
2658
+ .sort((a, b) => {
2659
+ if (a === primaryName) return -1;
2660
+ if (b === primaryName) return 1;
2661
+ return 0;
2662
+ });
2663
+
2664
+ for (const name of cooledDownRescueOrder) {
2665
+ const remainingMs = getSdkCooldownRemainingMs(name);
2666
+ const remainingSec = Math.max(1, Math.ceil(remainingMs / 1000));
2667
+ console.warn(
2668
+ `${TAG} no runnable SDK remained after gating; rescue-attempting cooled-down SDK "${name}" (${remainingSec}s remaining)`,
2669
+ );
2670
+
2671
+ triedSdkNames.push(name);
2672
+ const launcher = await SDK_ADAPTERS[name].load();
2673
+ const result = await launcher(prompt, cwd, timeoutMs, launchExtra);
2674
+ lastAttemptResult = result;
2675
+
2676
+ if (result.success) {
2677
+ return result;
2678
+ }
2679
+
2680
+ if (!shouldFallbackForSdkError(result.error)) {
2681
+ return result;
2682
+ }
2683
+
2684
+ if (name === primaryName) {
2685
+ primaryFailureResult = result;
2686
+ }
2687
+
2688
+ applySdkFailureCooldown(name, result.error);
2689
+ }
2690
+ }
2459
2691
  // ── All SDKs exhausted ───────────────────────────────────────────────────
2460
2692
  if (lastAttemptResult) {
2693
+ if (
2694
+ primaryFailureResult &&
2695
+ lastAttemptResult.sdk !== primaryFailureResult.sdk
2696
+ ) {
2697
+ return primaryFailureResult;
2698
+ }
2461
2699
  return lastAttemptResult;
2462
2700
  }
2463
2701
 
2464
- const eligibleSdks = Array.from(
2465
- new Set(attemptOrder.filter((name) => SDK_ADAPTERS[name] && !isDisabled(name))),
2466
- );
2467
-
2468
2702
  let errorMsg = `${TAG} no SDK available.`;
2469
2703
  if (triedSdkNames.length > 0) {
2470
2704
  errorMsg += ` Tried: ${triedSdkNames.join(", ")}.`;
@@ -2888,6 +3122,11 @@ async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
2888
3122
  : process.env;
2889
3123
  const codexSessionEnv = applyNodeWarningSuppressionEnv(codexRuntimeEnv);
2890
3124
  const codexOpts = buildCodexSdkOptions(codexSessionEnv);
3125
+ const explicitEnvModel = String(envOverrides?.CODEX_MODEL || "").trim();
3126
+ if (explicitEnvModel) {
3127
+ codexOpts.env = { ...(codexOpts.env || {}), CODEX_MODEL: explicitEnvModel };
3128
+ codexOpts.config = { ...(codexOpts.config || {}), model: explicitEnvModel };
3129
+ }
2891
3130
  const modelOverride = String(extra?.model || "").trim();
2892
3131
  if (modelOverride) {
2893
3132
  codexOpts.env = { ...(codexOpts.env || {}), CODEX_MODEL: modelOverride };
@@ -3081,6 +3320,7 @@ async function resumeGenericThread(
3081
3320
  * @param {string} [extra.sessionType] Interaction type used for context-shredding policy lookup.
3082
3321
  * @param {boolean} [extra.forceContextShredding] Force compression regardless of session type defaults.
3083
3322
  * @param {boolean} [extra.skipContextShredding] Skip compression regardless of policy.
3323
+ * @param {boolean} [extra.pinSdk] Keep SDK pinned when provided (disables fallback).
3084
3324
  * @param {Function} [extra.onEvent] Event callback.
3085
3325
  * @param {AbortController} [extra.abortController]
3086
3326
  * @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string, threadId: string|null, resumed: boolean }>}
@@ -3105,8 +3345,8 @@ export async function launchOrResumeThread(
3105
3345
  restExtra.envOverrides = applyNodeWarningSuppressionEnv(restExtra.envOverrides);
3106
3346
  // Pass taskKey through as steer key so SDK launchers can register active sessions
3107
3347
  restExtra.taskKey = taskKey;
3108
- if (restExtra.sdk) {
3109
- // Task-bound runs with an explicit SDK should stay pinned to that SDK.
3348
+ if (restExtra.sdk && restExtra.pinSdk === true) {
3349
+ // Task-bound runs with an explicit SDK can optionally stay pinned to that SDK.
3110
3350
  restExtra.disableFallback = true;
3111
3351
  }
3112
3352
  timeoutMs = clampMonitorMonitorTimeout(timeoutMs, taskKey);
@@ -8,7 +8,11 @@ export { AGENT_PROMPT_DEFINITIONS, PROMPT_WORKSPACE_DIR };
8
8
 
9
9
  function normalizeTemplateValue(value) {
10
10
  if (value == null) return "";
11
- if (typeof value === "string") return value;
11
+ if (typeof value === "string") {
12
+ const text = value.trim();
13
+ if (/^\{\{\s*[\w.-]+\s*\}\}$/.test(text)) return "";
14
+ return value;
15
+ }
12
16
  if (typeof value === "number" || typeof value === "boolean") {
13
17
  return String(value);
14
18
  }
@@ -384,6 +384,7 @@ export class AgentSupervisor {
384
384
  this._sendTelegram = opts.sendTelegram || null;
385
385
  this._getTask = opts.getTask || null;
386
386
  this._setTaskStatus = opts.setTaskStatus || null;
387
+ this._updateTask = opts.updateTask || null;
387
388
  this._reviewAgent = opts.reviewAgent || null;
388
389
 
389
390
  // ── Dispatch functions ──
@@ -525,6 +526,27 @@ export class AgentSupervisor {
525
526
  this._setTaskStatus(taskId, "blocked", "supervisor");
526
527
  } catch { /* best-effort */ }
527
528
  }
529
+ // Persist recovery metadata so auto-recovery can unblock later
530
+ if (this._updateTask) {
531
+ try {
532
+ const existing = this._resolveTask(taskId);
533
+ const existingMeta = existing?.meta || {};
534
+ const cooldownMs = 30 * 60_000; // 30 minutes
535
+ this._updateTask(taskId, {
536
+ blockedReason: `Supervisor: ${reason} (situation: ${situation})`,
537
+ cooldownUntil: Date.now() + cooldownMs,
538
+ meta: {
539
+ ...existingMeta,
540
+ autoRecovery: {
541
+ active: true,
542
+ reason: "supervisor_block",
543
+ situation,
544
+ retryAt: Date.now() + cooldownMs,
545
+ },
546
+ },
547
+ });
548
+ } catch { /* best-effort */ }
549
+ }
528
550
  const task = this._resolveTask(taskId);
529
551
  const title = task?.title || taskId;
530
552
  const state = this._ensureTaskState(taskId);