bosun 0.41.8 → 0.41.10

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 (58) hide show
  1. package/.env.example +1 -1
  2. package/README.md +23 -1
  3. package/agent/agent-event-bus.mjs +31 -2
  4. package/agent/agent-pool.mjs +275 -40
  5. package/agent/agent-prompts.mjs +9 -1
  6. package/agent/agent-supervisor.mjs +22 -0
  7. package/agent/autofix.mjs +1 -1
  8. package/agent/primary-agent.mjs +115 -5
  9. package/cli.mjs +3 -2
  10. package/config/config.mjs +47 -33
  11. package/config/context-shredding-config.mjs +1 -1
  12. package/config/repo-root.mjs +41 -33
  13. package/desktop/main.mjs +350 -25
  14. package/desktop/preload.cjs +8 -0
  15. package/desktop/preload.mjs +19 -0
  16. package/entrypoint.mjs +332 -0
  17. package/git/sdk-conflict-resolver.mjs +1 -1
  18. package/infra/health-status.mjs +72 -0
  19. package/infra/library-manager.mjs +58 -1
  20. package/infra/maintenance.mjs +1 -2
  21. package/infra/monitor.mjs +26 -8
  22. package/infra/session-tracker.mjs +30 -3
  23. package/package.json +12 -4
  24. package/server/bosun-mcp-server.mjs +1004 -0
  25. package/server/setup-web-server.mjs +288 -259
  26. package/server/ui-server.mjs +1323 -26
  27. package/shell/claude-shell.mjs +14 -1
  28. package/shell/codex-config.mjs +1 -1
  29. package/shell/codex-model-profiles.mjs +170 -30
  30. package/shell/codex-shell.mjs +63 -18
  31. package/shell/opencode-providers.mjs +20 -8
  32. package/task/task-executor.mjs +28 -0
  33. package/task/task-store.mjs +13 -4
  34. package/telegram/telegram-sentinel.mjs +54 -3
  35. package/tools/list-todos.mjs +7 -1
  36. package/ui/app.js +3 -2
  37. package/ui/components/agent-selector.js +127 -0
  38. package/ui/components/session-list.js +15 -10
  39. package/ui/demo-defaults.js +334 -336
  40. package/ui/modules/router.js +2 -0
  41. package/ui/modules/state.js +13 -5
  42. package/ui/tabs/chat.js +3 -0
  43. package/ui/tabs/library.js +284 -52
  44. package/ui/tabs/tasks.js +5 -13
  45. package/ui/tabs/workflows.js +766 -3
  46. package/workflow/workflow-engine.mjs +246 -5
  47. package/workflow/workflow-nodes/definitions.mjs +37 -0
  48. package/workflow/workflow-nodes.mjs +1014 -184
  49. package/workflow/workflow-templates.mjs +0 -5
  50. package/workflow-templates/_helpers.mjs +253 -0
  51. package/workflow-templates/agents.mjs +199 -226
  52. package/workflow-templates/github.mjs +106 -16
  53. package/workflow-templates/sub-workflows.mjs +233 -0
  54. package/workflow-templates/task-execution.mjs +125 -471
  55. package/workflow-templates/task-lifecycle.mjs +11 -48
  56. package/workspace/command-diagnostics.mjs +460 -0
  57. package/workspace/context-cache.mjs +396 -28
  58. package/workspace/worktree-manager.mjs +1 -1
package/.env.example CHANGED
@@ -201,7 +201,7 @@ VOICE_DELEGATE_EXECUTOR=codex-sdk
201
201
  # earlier: it summarizes large, noisy command outputs before they ever land in
202
202
  # the active turn, while preserving a `bosun --tool-log <id>` retrieval path.
203
203
  # CONTEXT_SHREDDING_ENABLED=true
204
- # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_ENABLED=false
204
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_ENABLED=true
205
205
  # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_MODE=auto
206
206
  # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_MIN_CHARS=4000
207
207
  # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_TARGET_CHARS=1800
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";
@@ -54,8 +55,7 @@ import {
54
55
  MAX_STREAM_RETRIES,
55
56
  } from "../infra/stream-resilience.mjs";
56
57
  import { ensureTestRuntimeSandbox } from "../infra/test-runtime.mjs";
57
- import { compressAllItems, estimateSavings, estimateContextUsagePct, recordShreddingEvent } from "../workspace/context-cache.mjs";
58
- import { resolveContextShreddingOptions } from "../config/context-shredding-config.mjs";
58
+ import { maybeCompressSessionItems } from "../workspace/context-cache.mjs";
59
59
 
60
60
  // Lazy-load MCP registry to avoid circular dependencies.
61
61
  // Cached at module scope per AGENTS.md hard rules.
@@ -97,6 +97,31 @@ const HARD_TIMEOUT_BUFFER_MS = 5 * 60_000; // 5 minutes
97
97
 
98
98
  /** Tag for console logging */
99
99
  const TAG = "[agent-pool]";
100
+ const require = createRequire(import.meta.url);
101
+ const MODULE_PRESENCE_CACHE = new Map();
102
+
103
+ function hasOptionalModule(specifier) {
104
+ if (MODULE_PRESENCE_CACHE.has(specifier)) {
105
+ return MODULE_PRESENCE_CACHE.get(specifier);
106
+ }
107
+ let ok = false;
108
+ try {
109
+ require.resolve(specifier);
110
+ ok = true;
111
+ } catch {
112
+ // ESM-only packages have no CJS "require" export so require.resolve
113
+ // throws even when the package is installed. Fall back to checking
114
+ // whether the package directory exists on disk.
115
+ try {
116
+ const pkgDir = resolve(__dirname, "..", "node_modules", ...specifier.split("/"));
117
+ ok = existsSync(resolve(pkgDir, "package.json"));
118
+ } catch {
119
+ ok = false;
120
+ }
121
+ }
122
+ MODULE_PRESENCE_CACHE.set(specifier, ok);
123
+ return ok;
124
+ }
100
125
  const MAX_PROMPT_BYTES = 180_000;
101
126
  const MAX_SET_TIMEOUT_MS = 2_147_483_647; // Node.js setTimeout 32-bit signed max
102
127
  let timeoutClampWarningKey = "";
@@ -226,29 +251,12 @@ async function maybeCompressResultItems(
226
251
 
227
252
  const resolvedSessionType = normalizeSessionType(sessionType, "task");
228
253
  const agentType = normalizeSdkForShredding(sdk);
229
- const shreddingOpts = resolveContextShreddingOptions(
230
- resolvedSessionType,
254
+ return maybeCompressSessionItems(items, {
255
+ sessionType: resolvedSessionType,
231
256
  agentType,
232
- );
233
- if (shreddingOpts?._skip === true) return items;
234
-
235
- const usagePct = estimateContextUsagePct(items);
236
- const threshold = Number.isFinite(shreddingOpts?.contextUsageThreshold)
237
- ? Number(shreddingOpts.contextUsageThreshold)
238
- : 0.5;
239
- if (usagePct < threshold) return items;
240
-
241
- shreddingOpts.contextUsagePct = usagePct;
242
- const compressedItems = await compressAllItems(items, shreddingOpts);
243
- try {
244
- const savings = estimateSavings(items, compressedItems);
245
- if (savings.savedChars > 0) {
246
- recordShreddingEvent({ ...savings, agentType: agentType || sdk });
247
- }
248
- } catch {
249
- /* non-fatal */
250
- }
251
- return compressedItems;
257
+ force: forceCompression,
258
+ skip: skipCompression,
259
+ });
252
260
  }
253
261
 
254
262
  function resolveCodexStreamSafety(totalTimeoutMs) {
@@ -424,13 +432,27 @@ function injectGitHubSessionEnv(baseEnv, token) {
424
432
 
425
433
  /**
426
434
  * 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.
435
+ * The first line is "# Task: Task Title" (legacy: "# TASKID Task Title");
436
+ * we return the title portion only.
428
437
  * Falls back to the raw first line if no em-dash separator is found.
429
438
  * @param {string} prompt
430
439
  * @returns {string}
431
440
  */
432
441
  function extractTaskHeading(prompt) {
433
442
  const firstLine = String(prompt || "").split(/\r?\n/)[0].replace(/^#+\s*/, "").trim();
443
+ if (!firstLine) return "Execute Task";
444
+ const lowerLine = firstLine.toLowerCase();
445
+ if (lowerLine.startsWith("task")) {
446
+ let index = 4;
447
+ while (index < firstLine.length && /\s/.test(firstLine[index])) index += 1;
448
+ const separator = firstLine[index];
449
+ if (separator === ":" || separator === "-" || separator === "\u2014") {
450
+ index += 1;
451
+ while (index < firstLine.length && /\s/.test(firstLine[index])) index += 1;
452
+ const title = firstLine.slice(index).trim();
453
+ if (title) return title;
454
+ }
455
+ }
434
456
  const dashIdx = firstLine.indexOf(" \u2014 ");
435
457
  const title = dashIdx !== -1 ? firstLine.slice(dashIdx + 3).trim() : firstLine;
436
458
  return title || "Execute Task";
@@ -580,15 +602,20 @@ function hasSdkPrerequisites(name, runtimeEnv = process.env) {
580
602
  }
581
603
 
582
604
  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.
605
+ if (!hasOptionalModule("@openai/codex-sdk")) {
606
+ return { ok: false, reason: "@openai/codex-sdk not installed" };
607
+ }
608
+ // Codex auth can come from env vars, config env_key mappings, or persisted
609
+ // CLI login state (for example ~/.codex/auth.json). Because login-based
610
+ // auth is valid and hard to validate exhaustively, avoid false negatives.
585
611
  const hasKey =
586
612
  runtimeEnv.OPENAI_API_KEY ||
587
613
  runtimeEnv.AZURE_OPENAI_API_KEY ||
588
614
  runtimeEnv.CODEX_MODEL_PROFILE_XL_API_KEY ||
589
615
  runtimeEnv.CODEX_MODEL_PROFILE_M_API_KEY;
590
616
  if (hasKey) return { ok: true, reason: null };
591
- // Check ~/.codex/config.toml — Codex CLI SDK reads auth env_key refs from there
617
+
618
+ // Check ~/.codex/config.toml — Codex CLI SDK reads auth env_key refs from there.
592
619
  try {
593
620
  const configToml = resolve(homedir(), ".codex", "config.toml");
594
621
  if (existsSync(configToml)) {
@@ -601,14 +628,33 @@ function hasSdkPrerequisites(name, runtimeEnv = process.env) {
601
628
  } catch {
602
629
  // best effort — fall through to failure
603
630
  }
604
- return { ok: false, reason: "no API key (OPENAI_API_KEY / AZURE_OPENAI_API_KEY) and no satisfied env_key in ~/.codex/config.toml" };
631
+
632
+ // Login-based auth sessions are sufficient even without env keys.
633
+ try {
634
+ const authJson = resolve(homedir(), ".codex", "auth.json");
635
+ if (existsSync(authJson)) {
636
+ const authText = readFileSync(authJson, "utf8").trim();
637
+ if (authText) return { ok: true, reason: null };
638
+ }
639
+ } catch {
640
+ // best effort — fall through to permissive behavior
641
+ }
642
+
643
+ // Do not hard-block Codex when auth source cannot be determined.
644
+ return { ok: true, reason: null };
605
645
  }
606
646
  if (name === "copilot") {
647
+ if (!hasOptionalModule("@github/copilot-sdk")) {
648
+ return { ok: false, reason: "@github/copilot-sdk not installed" };
649
+ }
607
650
  // Copilot auth can come from multiple sources (OAuth manager, gh auth,
608
651
  // VS Code Copilot login, env tokens). Don't block execution here.
609
652
  return { ok: true, reason: null };
610
653
  }
611
654
  if (name === "claude") {
655
+ if (!hasOptionalModule("@anthropic-ai/claude-agent-sdk")) {
656
+ return { ok: false, reason: "@anthropic-ai/claude-agent-sdk not installed" };
657
+ }
612
658
  const hasKey = runtimeEnv.ANTHROPIC_API_KEY;
613
659
  if (!hasKey) {
614
660
  return { ok: false, reason: "no ANTHROPIC_API_KEY" };
@@ -706,13 +752,14 @@ async function withTemporaryEnv(overrides, fn) {
706
752
  * Otherwise strips OPENAI_BASE_URL so the SDK uses its default auth.
707
753
  */
708
754
  function buildCodexSdkOptions(envInput = process.env) {
709
- const { env: resolvedEnv } = resolveCodexProfileRuntime(envInput);
755
+ const resolved = resolveCodexProfileRuntime(envInput);
756
+ const { env: resolvedEnv, configProvider } = resolved;
710
757
  const baseUrl = resolvedEnv.OPENAI_BASE_URL || "";
711
758
  const isAzure = (() => {
712
759
  try {
713
760
  const parsed = new URL(baseUrl);
714
761
  const host = String(parsed.hostname || "").toLowerCase();
715
- return host === "openai.azure.com" || host.endsWith(".openai.azure.com");
762
+ return host === "openai.azure.com" || host.endsWith(".openai.azure.com") || host.endsWith(".cognitiveservices.azure.com");
716
763
  } catch {
717
764
  return false;
718
765
  }
@@ -727,16 +774,20 @@ function buildCodexSdkOptions(envInput = process.env) {
727
774
  if (env.OPENAI_API_KEY && !env.AZURE_OPENAI_API_KEY) {
728
775
  env.AZURE_OPENAI_API_KEY = env.OPENAI_API_KEY;
729
776
  }
777
+ // Use the config.toml provider section name and env_key when available,
778
+ // so the SDK config override is consistent with the user's config.toml.
779
+ const providerSectionName = configProvider?.name || "azure";
780
+ const providerEnvKey = configProvider?.envKey || "AZURE_OPENAI_API_KEY";
730
781
  const azureModel = env.CODEX_MODEL || undefined;
731
782
  return {
732
783
  env,
733
784
  config: {
734
- model_provider: "azure",
785
+ model_provider: providerSectionName,
735
786
  model_providers: {
736
- azure: {
787
+ [providerSectionName]: {
737
788
  name: "Azure OpenAI",
738
789
  base_url: baseUrl,
739
- env_key: "AZURE_OPENAI_API_KEY",
790
+ env_key: providerEnvKey,
740
791
  wire_api: "responses",
741
792
  },
742
793
  },
@@ -1065,6 +1116,19 @@ export function setPoolSdk(name) {
1065
1116
  export function resetPoolSdkCache() {
1066
1117
  resolvedSdkName = null;
1067
1118
  resolutionLogged = false;
1119
+ sdkFailureCooldownUntil.clear();
1120
+ }
1121
+
1122
+ export function setSdkFailureCooldownForTest(name, cooldownRemainingMs, nowMs = Date.now()) {
1123
+ if (!SDK_ADAPTERS[name]) {
1124
+ throw new Error(`Unknown SDK: ${name}`);
1125
+ }
1126
+ const remainingMs = Number(cooldownRemainingMs);
1127
+ if (!Number.isFinite(remainingMs) || remainingMs <= 0) {
1128
+ sdkFailureCooldownUntil.delete(name);
1129
+ return;
1130
+ }
1131
+ sdkFailureCooldownUntil.set(name, nowMs + Math.trunc(remainingMs));
1068
1132
  }
1069
1133
 
1070
1134
  /**
@@ -1145,6 +1209,11 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
1145
1209
  : process.env;
1146
1210
  const codexSessionEnv = applyNodeWarningSuppressionEnv(codexRuntimeEnv);
1147
1211
  const codexOpts = buildCodexSdkOptions(codexSessionEnv);
1212
+ const explicitEnvModel = String(envOverrides?.CODEX_MODEL || "").trim();
1213
+ if (explicitEnvModel) {
1214
+ codexOpts.env = { ...(codexOpts.env || {}), CODEX_MODEL: explicitEnvModel };
1215
+ codexOpts.config = { ...(codexOpts.config || {}), model: explicitEnvModel };
1216
+ }
1148
1217
  const modelOverride = String(extra?.model || "").trim();
1149
1218
  if (modelOverride) {
1150
1219
  codexOpts.env = { ...(codexOpts.env || {}), CODEX_MODEL: modelOverride };
@@ -2384,6 +2453,7 @@ export async function launchEphemeralThread(
2384
2453
  ];
2385
2454
 
2386
2455
  let lastAttemptResult = null;
2456
+ let primaryFailureResult = null;
2387
2457
  const triedSdkNames = [];
2388
2458
  const missingPrereqSdks = [];
2389
2459
  const cooledDownSdks = [];
@@ -2443,6 +2513,10 @@ export async function launchEphemeralThread(
2443
2513
  return result;
2444
2514
  }
2445
2515
 
2516
+ if (name === primaryName) {
2517
+ primaryFailureResult = result;
2518
+ }
2519
+
2446
2520
  applySdkFailureCooldown(name, result.error);
2447
2521
 
2448
2522
  if (name === primaryName) {
@@ -2456,15 +2530,170 @@ export async function launchEphemeralThread(
2456
2530
  }
2457
2531
  }
2458
2532
 
2533
+ const eligibleSdks = Array.from(
2534
+ new Set(attemptOrder.filter((name) => SDK_ADAPTERS[name] && !isDisabled(name))),
2535
+ );
2536
+ const runnableSdks = eligibleSdks.filter((name) => {
2537
+ const prereq = hasSdkPrerequisites(name, sessionEnv);
2538
+ if (!prereq.ok) {
2539
+ if (!missingPrereqSdks.some((entry) => entry.name === name)) {
2540
+ missingPrereqSdks.push({ name, reason: prereq.reason });
2541
+ }
2542
+ return false;
2543
+ }
2544
+ return true;
2545
+ });
2546
+ const cooledDownSet = new Set(cooledDownSdks.map((entry) => entry.name));
2547
+ const missingPrereqSet = new Set(missingPrereqSdks.map((entry) => entry.name));
2548
+ const allEligibleUnavailable =
2549
+ eligibleSdks.length > 0 &&
2550
+ eligibleSdks.every(
2551
+ (name) => cooledDownSet.has(name) || missingPrereqSet.has(name),
2552
+ );
2553
+
2554
+ if (
2555
+ !ignoreSdkCooldown &&
2556
+ !lastAttemptResult &&
2557
+ triedSdkNames.length === 0 &&
2558
+ runnableSdks.length > 0 &&
2559
+ runnableSdks.every((name) => cooledDownSet.has(name))
2560
+ ) {
2561
+ const forcedRetryOrder = runnableSdks.includes(primaryName)
2562
+ ? [primaryName, ...runnableSdks.filter((name) => name !== primaryName)]
2563
+ : runnableSdks;
2564
+
2565
+ for (const name of forcedRetryOrder) {
2566
+ const cooldownRemainingMs = getSdkCooldownRemainingMs(name);
2567
+ if (cooldownRemainingMs <= 0) continue;
2568
+
2569
+ const prereq = hasSdkPrerequisites(name, sessionEnv);
2570
+ if (!prereq.ok) {
2571
+ if (!missingPrereqSdks.some((entry) => entry.name === name)) {
2572
+ missingPrereqSdks.push({ name, reason: prereq.reason });
2573
+ }
2574
+ continue;
2575
+ }
2576
+
2577
+ const remainingSec = Math.max(1, Math.ceil(cooldownRemainingMs / 1000));
2578
+ console.warn(
2579
+ `${TAG} all eligible SDKs are cooling down; force-retrying "${name}" (${remainingSec}s remaining)`,
2580
+ );
2581
+
2582
+ triedSdkNames.push(name);
2583
+ const launcher = await SDK_ADAPTERS[name].load();
2584
+ const result = await launcher(prompt, cwd, timeoutMs, launchExtra);
2585
+ lastAttemptResult = result;
2586
+
2587
+ if (result.success) {
2588
+ return result;
2589
+ }
2590
+
2591
+ if (!shouldFallbackForSdkError(result.error)) {
2592
+ return result;
2593
+ }
2594
+
2595
+ applySdkFailureCooldown(name, result.error);
2596
+ break;
2597
+ }
2598
+ }
2599
+
2600
+ // If every eligible SDK is unavailable (cooldown and/or missing credentials),
2601
+ // force one cooled-down attempt so work is not blocked for the full cooldown window.
2602
+ const shouldForceCooldownBypassAttempt =
2603
+ !ignoreSdkCooldown &&
2604
+ !lastAttemptResult &&
2605
+ triedSdkNames.length === 0 &&
2606
+ cooledDownSet.has(primaryName) &&
2607
+ allEligibleUnavailable;
2608
+ const forcedSdkName = shouldForceCooldownBypassAttempt
2609
+ ? cooledDownSet.has(primaryName)
2610
+ ? primaryName
2611
+ : eligibleSdks.find((name) => cooledDownSet.has(name)) || null
2612
+ : null;
2613
+
2614
+ if (forcedSdkName) {
2615
+ const prereq = hasSdkPrerequisites(forcedSdkName, sessionEnv);
2616
+ if (!prereq.ok) {
2617
+ // Cooldown means we recently attempted this SDK. Keep one forced retry path
2618
+ // to avoid hard-blocking work on strict prerequisite heuristics.
2619
+ if (
2620
+ !missingPrereqSdks.some(
2621
+ (entry) => entry.name === forcedSdkName && entry.reason === prereq.reason,
2622
+ )
2623
+ ) {
2624
+ missingPrereqSdks.push({ name: forcedSdkName, reason: prereq.reason });
2625
+ }
2626
+ console.warn(
2627
+ `${TAG} all eligible SDKs unavailable; bypassing SDK "${forcedSdkName}" prerequisite gate for forced retry (${prereq.reason})`,
2628
+ );
2629
+ } else {
2630
+ console.warn(
2631
+ `${TAG} no runnable fallback SDK is available (cooldown/prerequisite gate); forcing SDK "${forcedSdkName}" retry`,
2632
+ );
2633
+ }
2634
+ triedSdkNames.push(forcedSdkName);
2635
+ const launcher = await SDK_ADAPTERS[forcedSdkName].load();
2636
+ const forcedResult = await launcher(prompt, cwd, timeoutMs, launchExtra);
2637
+ if (forcedResult.success) {
2638
+ return forcedResult;
2639
+ }
2640
+ lastAttemptResult = forcedResult;
2641
+ if (!shouldFallbackForSdkError(forcedResult.error)) {
2642
+ return forcedResult;
2643
+ }
2644
+ applySdkFailureCooldown(forcedSdkName, forcedResult.error);
2645
+ }
2646
+
2647
+ // Recovery path: when all SDKs were skipped due cooldown/prereq gates, force
2648
+ // one direct attempt against cooled-down SDKs to surface a concrete error.
2649
+ if (!lastAttemptResult && triedSdkNames.length === 0 && cooledDownSdks.length > 0) {
2650
+ const cooledDownRescueOrder = cooledDownSdks
2651
+ .map((entry) => entry.name)
2652
+ .filter((name, idx, arr) => SDK_ADAPTERS[name] && arr.indexOf(name) === idx)
2653
+ .sort((a, b) => {
2654
+ if (a === primaryName) return -1;
2655
+ if (b === primaryName) return 1;
2656
+ return 0;
2657
+ });
2658
+
2659
+ for (const name of cooledDownRescueOrder) {
2660
+ const remainingMs = getSdkCooldownRemainingMs(name);
2661
+ const remainingSec = Math.max(1, Math.ceil(remainingMs / 1000));
2662
+ console.warn(
2663
+ `${TAG} no runnable SDK remained after gating; rescue-attempting cooled-down SDK "${name}" (${remainingSec}s remaining)`,
2664
+ );
2665
+
2666
+ triedSdkNames.push(name);
2667
+ const launcher = await SDK_ADAPTERS[name].load();
2668
+ const result = await launcher(prompt, cwd, timeoutMs, launchExtra);
2669
+ lastAttemptResult = result;
2670
+
2671
+ if (result.success) {
2672
+ return result;
2673
+ }
2674
+
2675
+ if (!shouldFallbackForSdkError(result.error)) {
2676
+ return result;
2677
+ }
2678
+
2679
+ if (name === primaryName) {
2680
+ primaryFailureResult = result;
2681
+ }
2682
+
2683
+ applySdkFailureCooldown(name, result.error);
2684
+ }
2685
+ }
2459
2686
  // ── All SDKs exhausted ───────────────────────────────────────────────────
2460
2687
  if (lastAttemptResult) {
2688
+ if (
2689
+ primaryFailureResult &&
2690
+ lastAttemptResult.sdk !== primaryFailureResult.sdk
2691
+ ) {
2692
+ return primaryFailureResult;
2693
+ }
2461
2694
  return lastAttemptResult;
2462
2695
  }
2463
2696
 
2464
- const eligibleSdks = Array.from(
2465
- new Set(attemptOrder.filter((name) => SDK_ADAPTERS[name] && !isDisabled(name))),
2466
- );
2467
-
2468
2697
  let errorMsg = `${TAG} no SDK available.`;
2469
2698
  if (triedSdkNames.length > 0) {
2470
2699
  errorMsg += ` Tried: ${triedSdkNames.join(", ")}.`;
@@ -2888,6 +3117,11 @@ async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
2888
3117
  : process.env;
2889
3118
  const codexSessionEnv = applyNodeWarningSuppressionEnv(codexRuntimeEnv);
2890
3119
  const codexOpts = buildCodexSdkOptions(codexSessionEnv);
3120
+ const explicitEnvModel = String(envOverrides?.CODEX_MODEL || "").trim();
3121
+ if (explicitEnvModel) {
3122
+ codexOpts.env = { ...(codexOpts.env || {}), CODEX_MODEL: explicitEnvModel };
3123
+ codexOpts.config = { ...(codexOpts.config || {}), model: explicitEnvModel };
3124
+ }
2891
3125
  const modelOverride = String(extra?.model || "").trim();
2892
3126
  if (modelOverride) {
2893
3127
  codexOpts.env = { ...(codexOpts.env || {}), CODEX_MODEL: modelOverride };
@@ -3081,6 +3315,7 @@ async function resumeGenericThread(
3081
3315
  * @param {string} [extra.sessionType] Interaction type used for context-shredding policy lookup.
3082
3316
  * @param {boolean} [extra.forceContextShredding] Force compression regardless of session type defaults.
3083
3317
  * @param {boolean} [extra.skipContextShredding] Skip compression regardless of policy.
3318
+ * @param {boolean} [extra.pinSdk] Keep SDK pinned when provided (disables fallback).
3084
3319
  * @param {Function} [extra.onEvent] Event callback.
3085
3320
  * @param {AbortController} [extra.abortController]
3086
3321
  * @returns {Promise<{ success: boolean, output: string, items: Array, error: string|null, sdk: string, threadId: string|null, resumed: boolean }>}
@@ -3105,8 +3340,8 @@ export async function launchOrResumeThread(
3105
3340
  restExtra.envOverrides = applyNodeWarningSuppressionEnv(restExtra.envOverrides);
3106
3341
  // Pass taskKey through as steer key so SDK launchers can register active sessions
3107
3342
  restExtra.taskKey = taskKey;
3108
- if (restExtra.sdk) {
3109
- // Task-bound runs with an explicit SDK should stay pinned to that SDK.
3343
+ if (restExtra.sdk && restExtra.pinSdk === true) {
3344
+ // Task-bound runs with an explicit SDK can optionally stay pinned to that SDK.
3110
3345
  restExtra.disableFallback = true;
3111
3346
  }
3112
3347
  timeoutMs = clampMonitorMonitorTimeout(timeoutMs, taskKey);
@@ -8,7 +8,15 @@ 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 (!text) return "";
14
+ const sanitized = text
15
+ .replace(/\{\{\s*[\w.-]+\s*\}\}/g, " ")
16
+ .replace(/[ \t]{2,}/g, " ")
17
+ .trim();
18
+ return sanitized;
19
+ }
12
20
  if (typeof value === "number" || typeof value === "boolean") {
13
21
  return String(value);
14
22
  }
@@ -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);
package/agent/autofix.mjs CHANGED
@@ -545,7 +545,7 @@ export function runCodexExec(
545
545
  try {
546
546
  const parsed = new URL(baseUrl);
547
547
  const host = String(parsed.hostname || "").toLowerCase();
548
- return host === "openai.azure.com" || host.endsWith(".openai.azure.com");
548
+ return host === "openai.azure.com" || host.endsWith(".openai.azure.com") || host.endsWith(".cognitiveservices.azure.com");
549
549
  } catch {
550
550
  return false;
551
551
  }