bosun 0.41.7 → 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 (43) 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/config/config.mjs +4 -1
  9. package/desktop/main.mjs +350 -25
  10. package/desktop/preload.cjs +8 -0
  11. package/desktop/preload.mjs +19 -0
  12. package/entrypoint.mjs +332 -0
  13. package/infra/health-status.mjs +72 -0
  14. package/infra/library-manager.mjs +58 -1
  15. package/infra/maintenance.mjs +1 -2
  16. package/infra/monitor.mjs +25 -7
  17. package/infra/session-tracker.mjs +30 -3
  18. package/package.json +10 -4
  19. package/server/bosun-mcp-server.mjs +1004 -0
  20. package/server/setup-web-server.mjs +287 -258
  21. package/server/ui-server.mjs +218 -23
  22. package/shell/claude-shell.mjs +14 -1
  23. package/shell/codex-model-profiles.mjs +166 -29
  24. package/shell/codex-shell.mjs +56 -18
  25. package/shell/opencode-providers.mjs +20 -8
  26. package/task/task-executor.mjs +28 -0
  27. package/task/task-store.mjs +13 -4
  28. package/tools/list-todos.mjs +7 -1
  29. package/ui/app.js +3 -2
  30. package/ui/components/agent-selector.js +127 -0
  31. package/ui/components/session-list.js +2 -0
  32. package/ui/demo-defaults.js +6 -6
  33. package/ui/modules/router.js +2 -0
  34. package/ui/modules/state.js +13 -5
  35. package/ui/tabs/chat.js +3 -0
  36. package/ui/tabs/library.js +284 -52
  37. package/ui/tabs/tasks.js +5 -13
  38. package/workflow/workflow-engine.mjs +16 -4
  39. package/workflow/workflow-nodes/definitions.mjs +37 -0
  40. package/workflow/workflow-nodes.mjs +489 -153
  41. package/workflow/workflow-templates.mjs +0 -5
  42. package/workflow-templates/github.mjs +106 -16
  43. package/workspace/worktree-manager.mjs +1 -1
@@ -80,6 +80,7 @@ import {
80
80
  scaffoldAgentProfiles,
81
81
  getBosunHomeDir,
82
82
  syncAutoDiscoveredLibraryEntries,
83
+ resolveAgentProfileLibraryMetadata,
83
84
  } from "../infra/library-manager.mjs";
84
85
  import {
85
86
  listCatalog,
@@ -319,6 +320,17 @@ function unblockInternalTask(taskId, options = {}) {
319
320
  }
320
321
  }
321
322
 
323
+ function resetExecutorTaskThrottleState(taskId, options = {}) {
324
+ const executor = uiDeps.getInternalExecutor?.() || null;
325
+ const fn = executor?.resetTaskThrottleState;
326
+ if (typeof fn !== "function") return false;
327
+ try {
328
+ return fn.call(executor, taskId, options) === true;
329
+ } catch {
330
+ return false;
331
+ }
332
+ }
333
+
322
334
  const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
323
335
  const repoRoot = resolveRepoRoot();
324
336
  const uiRootPreferred = resolve(__dirname, "..", "ui");
@@ -729,10 +741,44 @@ function isVoiceAgentProfileEntry(entry, profile) {
729
741
  }
730
742
 
731
743
  function resolveAgentProfileType(entry, profile) {
732
- const explicit = String(profile?.agentType || "").trim().toLowerCase();
733
- if (explicit === "voice" || explicit === "task" || explicit === "chat") return explicit;
734
- if (isVoiceAgentProfileEntry(entry, profile)) return "voice";
735
- return "task";
744
+ return resolveAgentProfileLibraryMetadata(entry, profile).agentType;
745
+ }
746
+
747
+ function resolveAgentProfileLibraryView(entry, profile, storageScope) {
748
+ const metadata = resolveAgentProfileLibraryMetadata(entry, profile);
749
+ return {
750
+ ...entry,
751
+ storageScope,
752
+ ...metadata,
753
+ };
754
+ }
755
+
756
+ function listManualAgentProfiles(workspaceContext) {
757
+ const resolved = listLibraryEntriesAcrossRoots(workspaceContext, { type: "agent" });
758
+ const profiles = [];
759
+ for (const { entry, rootInfo } of resolved.entries) {
760
+ const profile = getEntryContent(rootInfo.rootDir, entry);
761
+ const metadata = resolveAgentProfileLibraryMetadata(entry, profile);
762
+ if (metadata.agentCategory !== "interactive" || metadata.showInChatDropdown !== true) continue;
763
+ const sectionLabel = metadata.interactiveLabel
764
+ || (metadata.interactiveMode ? metadata.interactiveMode.charAt(0).toUpperCase() + metadata.interactiveMode.slice(1) : "Manual");
765
+ profiles.push({
766
+ id: entry.id,
767
+ name: entry.name || entry.id,
768
+ description: entry.description || "",
769
+ storageScope: rootInfo.scope,
770
+ model: String(profile?.model || "").trim() || null,
771
+ sdk: String(profile?.sdk || "").trim() || null,
772
+ sectionLabel,
773
+ ...metadata,
774
+ });
775
+ }
776
+ profiles.sort((a, b) => {
777
+ const sectionCmp = String(a.sectionLabel || "").localeCompare(String(b.sectionLabel || ""));
778
+ if (sectionCmp !== 0) return sectionCmp;
779
+ return String(a.name || a.id).localeCompare(String(b.name || b.id));
780
+ });
781
+ return profiles;
736
782
  }
737
783
 
738
784
  function resolveVoiceLibraryRoot(callContext = {}) {
@@ -4072,6 +4118,13 @@ const workflowEngineListenerCleanup = new WeakMap();
4072
4118
  let workflowWsSeq = 0;
4073
4119
  let uiInstanceLockPath = "";
4074
4120
  let uiInstanceLockHeld = false;
4121
+
4122
+ // ── Unified setup state (entrypoint integration) ────────────────────────────
4123
+ // When running via entrypoint.mjs, `_setupMode` starts true and flips to false
4124
+ // once the wizard completes. In standalone ui-server mode, it's always false.
4125
+ let _setupMode = false;
4126
+ /** @type {(() => void)|null} */
4127
+ let _setupOnComplete = null;
4075
4128
  let _sessionTokenLastTouchedAt = 0;
4076
4129
  let _localRequestAddressCache = {
4077
4130
  loadedAt: 0,
@@ -4134,19 +4187,39 @@ async function resolveExecPrimaryPrompt() {
4134
4187
 
4135
4188
  /**
4136
4189
  * Resolve the bosun config directory. Falls back through:
4137
- * 1. uiDeps.configDir (injected at server start)
4138
- * 2. BOSUN_DIR env var
4139
- * 3. ~/bosun (standard default)
4190
+ * 1. uiDeps.configDir (explicitly injected at server start)
4191
+ * 2. BOSUN_CONFIG_PATH parent directory
4192
+ * 3. repo-local config when REPO_ROOT explicitly points at a managed repo
4193
+ * 4. BOSUN_HOME/BOSUN_DIR/test sandbox/default home dir
4140
4194
  * Ensures the directory exists.
4141
4195
  */
4142
4196
  function resolveUiConfigDir() {
4143
4197
  const sandbox = ensureTestRuntimeSandbox();
4198
+ if (uiDeps.configDir) {
4199
+ const injectedDir = resolve(String(uiDeps.configDir));
4200
+ try { mkdirSync(injectedDir, { recursive: true }); } catch { /* ok */ }
4201
+ return injectedDir;
4202
+ }
4144
4203
  if (process.env.BOSUN_CONFIG_PATH) {
4145
4204
  const fromConfigPath = dirname(resolve(process.env.BOSUN_CONFIG_PATH));
4146
4205
  try { mkdirSync(fromConfigPath, { recursive: true }); } catch { /* ok */ }
4147
- if (!uiDeps.configDir) uiDeps.configDir = fromConfigPath;
4148
4206
  return fromConfigPath;
4149
4207
  }
4208
+ if (String(process.env.REPO_ROOT || "").trim()) {
4209
+ const repoLocalConfigDirCandidates = [
4210
+ resolve(repoRoot, ".bosun"),
4211
+ repoRoot,
4212
+ ];
4213
+ for (const candidate of repoLocalConfigDirCandidates) {
4214
+ try {
4215
+ if (!existsSync(resolve(candidate, "bosun.config.json"))) continue;
4216
+ mkdirSync(candidate, { recursive: true });
4217
+ return candidate;
4218
+ } catch {
4219
+ // Fall through to the next candidate.
4220
+ }
4221
+ }
4222
+ }
4150
4223
  const isWslInteropRuntime = Boolean(
4151
4224
  process.env.WSL_DISTRO_NAME
4152
4225
  || process.env.WSL_INTEROP
@@ -4176,8 +4249,6 @@ function resolveUiConfigDir() {
4176
4249
  || resolve(baseDir, "bosun");
4177
4250
  if (dir) {
4178
4251
  try { mkdirSync(dir, { recursive: true }); } catch { /* ok */ }
4179
- // Cache it so subsequent calls don't re-resolve
4180
- if (!uiDeps.configDir) uiDeps.configDir = dir;
4181
4252
  }
4182
4253
  return dir;
4183
4254
  }
@@ -8379,10 +8450,49 @@ function getExpectedDesktopApiKey() {
8379
8450
  return fromEnv;
8380
8451
  }
8381
8452
 
8453
+ /**
8454
+ * Check whether the request carries a valid user-configured API key.
8455
+ * Set via BOSUN_API_KEY env var — intended for external clients (Electron app
8456
+ * connecting to a remote/Docker instance, CLI tools, third-party integrations).
8457
+ * Unlike the desktop API key (auto-generated per install), this is a
8458
+ * user-chosen secret that can be set in .env, docker-compose.yml, etc.
8459
+ */
8460
+ function checkApiKey(req) {
8461
+ const expected = String(process.env.BOSUN_API_KEY || "").trim();
8462
+ if (!expected || expected.length < 8) return false;
8463
+ const authHeader = req.headers.authorization || "";
8464
+ // Accept as Bearer token
8465
+ if (authHeader.startsWith("Bearer ")) {
8466
+ const provided = authHeader.slice(7).trim();
8467
+ if (!provided) return false;
8468
+ try {
8469
+ const a = Buffer.from(provided);
8470
+ const b = Buffer.from(expected);
8471
+ if (a.length !== b.length) return false;
8472
+ return timingSafeEqual(a, b);
8473
+ } catch { return false; }
8474
+ }
8475
+ // Accept as X-API-Key header
8476
+ const apiKeyHeader = String(req.headers["x-api-key"] || "").trim();
8477
+ if (apiKeyHeader) {
8478
+ try {
8479
+ const a = Buffer.from(apiKeyHeader);
8480
+ const b = Buffer.from(expected);
8481
+ if (a.length !== b.length) return false;
8482
+ return timingSafeEqual(a, b);
8483
+ } catch { return false; }
8484
+ }
8485
+ return false;
8486
+ }
8487
+
8382
8488
  async function requireAuth(req) {
8383
8489
  if (isAllowUnsafe()) return { ok: true, source: "unsafe", issueSessionCookie: false };
8490
+ // User-configured API key (BOSUN_API_KEY env) — external clients, Docker
8491
+ // Issue a session cookie so the browser can authenticate WebSocket upgrades
8492
+ // (the WS constructor doesn't support custom headers).
8493
+ if (checkApiKey(req)) return { ok: true, source: "api-key", issueSessionCookie: true };
8384
8494
  // Desktop Electron API key — non-expiring, set via BOSUN_DESKTOP_API_KEY env
8385
- if (checkDesktopApiKey(req)) return { ok: true, source: "desktop-api-key", issueSessionCookie: false };
8495
+ if (checkDesktopApiKey(req)) return { ok: true, source: "desktop-api-key", issueSessionCookie: true };
8386
8496
  // Session token (browser access)
8387
8497
  if (checkSessionToken(req)) return { ok: true, source: "session", issueSessionCookie: false };
8388
8498
  // Telegram initData HMAC
@@ -8406,6 +8516,19 @@ async function requireAuth(req) {
8406
8516
 
8407
8517
  function resolveWsAuthSource(req, url) {
8408
8518
  if (isAllowUnsafe()) return "unsafe";
8519
+ // User-configured API key (BOSUN_API_KEY) — check Bearer header and query param
8520
+ if (checkApiKey(req)) return "api-key";
8521
+ const qApiKey = url.searchParams.get("apiKey") || "";
8522
+ if (qApiKey) {
8523
+ const expected = String(process.env.BOSUN_API_KEY || "").trim();
8524
+ if (expected && expected.length >= 8) {
8525
+ try {
8526
+ const a = Buffer.from(qApiKey);
8527
+ const b = Buffer.from(expected);
8528
+ if (a.length === b.length && timingSafeEqual(a, b)) return "api-key";
8529
+ } catch { /* ignore */ }
8530
+ }
8531
+ }
8409
8532
  // Desktop Electron API key (query param: desktopKey=...)
8410
8533
  const desktopKey = getExpectedDesktopApiKey();
8411
8534
  if (desktopKey) {
@@ -12898,7 +13021,14 @@ async function handleApi(req, res, url) {
12898
13021
  : undefined;
12899
13022
  const metadataPatch = buildTaskMetadataPatch(body || {});
12900
13023
  const requestedStatus = normalizeTaskStatusKey(body?.status);
12901
- const clearsBlockedState = requestedStatus === "todo";
13024
+ const currentLooksBlocked =
13025
+ normalizeTaskStatusKey(previousTask?.status) === "blocked" ||
13026
+ Boolean(previousTask?.blockedReason) ||
13027
+ Boolean(previousTask?.cooldownUntil) ||
13028
+ Boolean(previousTask?.meta?.autoRecovery) ||
13029
+ Boolean(previousTask?.meta?.worktreeFailure?.blockedReason);
13030
+ const clearsBlockedState =
13031
+ currentLooksBlocked && Boolean(requestedStatus) && requestedStatus !== "blocked";
12902
13032
  const nextMeta = (Object.keys(metadataPatch.meta).length > 0 || clearsBlockedState)
12903
13033
  ? buildTaskMetaPatch(previousTask?.meta, metadataPatch.meta, { clearBlockedState: clearsBlockedState })
12904
13034
  : null;
@@ -12932,6 +13062,9 @@ async function handleApi(req, res, url) {
12932
13062
  ? await adapter.updateTask(taskId, patch)
12933
13063
  : await adapter.updateTaskStatus(taskId, patch.status);
12934
13064
  const updated = withTaskMetadataTopLevel(updatedRaw);
13065
+ if (clearsBlockedState) {
13066
+ resetExecutorTaskThrottleState(taskId);
13067
+ }
12935
13068
  const nextStatus = updated?.status || patch.status || null;
12936
13069
  const lifecycleAction = inferLifecycleAction(
12937
13070
  previousTask?.status || null,
@@ -13039,7 +13172,14 @@ async function handleApi(req, res, url) {
13039
13172
  : undefined;
13040
13173
  const metadataPatch = buildTaskMetadataPatch(body || {});
13041
13174
  const requestedStatus = normalizeTaskStatusKey(body?.status);
13042
- const clearsBlockedState = requestedStatus === "todo";
13175
+ const currentLooksBlocked =
13176
+ normalizeTaskStatusKey(previousTask?.status) === "blocked" ||
13177
+ Boolean(previousTask?.blockedReason) ||
13178
+ Boolean(previousTask?.cooldownUntil) ||
13179
+ Boolean(previousTask?.meta?.autoRecovery) ||
13180
+ Boolean(previousTask?.meta?.worktreeFailure?.blockedReason);
13181
+ const clearsBlockedState =
13182
+ currentLooksBlocked && Boolean(requestedStatus) && requestedStatus !== "blocked";
13043
13183
  const nextMeta = (Object.keys(metadataPatch.meta).length > 0 || clearsBlockedState)
13044
13184
  ? buildTaskMetaPatch(previousTask?.meta, metadataPatch.meta, { clearBlockedState: clearsBlockedState })
13045
13185
  : null;
@@ -13073,6 +13213,9 @@ async function handleApi(req, res, url) {
13073
13213
  ? await adapter.updateTask(taskId, patch)
13074
13214
  : await adapter.updateTaskStatus(taskId, patch.status);
13075
13215
  const updated = withTaskMetadataTopLevel(updatedRaw);
13216
+ if (clearsBlockedState) {
13217
+ resetExecutorTaskThrottleState(taskId);
13218
+ }
13076
13219
  const nextStatus = updated?.status || patch.status || null;
13077
13220
  const lifecycleAction = inferLifecycleAction(
13078
13221
  previousTask?.status || null,
@@ -13491,16 +13634,14 @@ async function handleApi(req, res, url) {
13491
13634
  search: search || undefined,
13492
13635
  });
13493
13636
  let data = resolved.entries.map(({ entry, rootInfo }) => {
13494
- const base = {
13495
- ...entry,
13496
- storageScope: rootInfo.scope,
13497
- };
13498
- if (entry?.type !== "agent") return base;
13637
+ if (entry?.type !== "agent") {
13638
+ return {
13639
+ ...entry,
13640
+ storageScope: rootInfo.scope,
13641
+ };
13642
+ }
13499
13643
  const profile = getEntryContent(rootInfo.rootDir, entry);
13500
- return {
13501
- ...base,
13502
- agentType: resolveAgentProfileType(entry, profile),
13503
- };
13644
+ return resolveAgentProfileLibraryView(entry, profile, rootInfo.scope);
13504
13645
  });
13505
13646
  if (type === "agent" && (agentTypeRaw === "voice" || agentTypeRaw === "task" || agentTypeRaw === "chat")) {
13506
13647
  data = data.filter((entry) => {
@@ -16836,6 +16977,7 @@ async function handleApi(req, res, url) {
16836
16977
  status: "todo",
16837
16978
  source: "manual-retry",
16838
16979
  });
16980
+ resetExecutorTaskThrottleState(taskId);
16839
16981
  if (!nextTask) {
16840
16982
  if (typeof adapter.updateTask === "function") {
16841
16983
  await adapter.updateTask(taskId, {
@@ -16900,6 +17042,7 @@ async function handleApi(req, res, url) {
16900
17042
  status: targetStatus,
16901
17043
  source: "api.tasks.unblock",
16902
17044
  });
17045
+ resetExecutorTaskThrottleState(taskId);
16903
17046
  if (!updatedTask) {
16904
17047
  const nextMeta = task?.meta && typeof task.meta === "object"
16905
17048
  ? Object.fromEntries(Object.entries(task.meta).filter(([key]) => key !== "autoRecovery"))
@@ -17079,10 +17222,13 @@ async function handleApi(req, res, url) {
17079
17222
 
17080
17223
  if (path === "/api/agents/available" && req.method === "GET") {
17081
17224
  try {
17225
+ const workspaceContext = resolveWorkspaceContextFromRequest(url, { allowAll: false })
17226
+ || { workspaceDir: repoRoot, workspaceRoot: repoRoot };
17082
17227
  const agents = getAvailableAgents();
17083
17228
  const active = getPrimaryAgentSelection();
17084
17229
  const mode = getAgentMode();
17085
- jsonResponse(res, 200, { ok: true, agents, active, mode });
17230
+ const manualAgents = listManualAgentProfiles(workspaceContext);
17231
+ jsonResponse(res, 200, { ok: true, agents, active, mode, manualAgents });
17086
17232
  } catch (err) {
17087
17233
  jsonResponse(res, 500, { ok: false, error: err.message });
17088
17234
  }
@@ -19255,6 +19401,21 @@ export async function startTelegramUiServer(options = {}) {
19255
19401
  const taskStoreModule = await ensureTaskStoreApi();
19256
19402
  const sandbox = ensureTestRuntimeSandbox();
19257
19403
 
19404
+ // ── Setup mode integration (entrypoint.mjs) ───────────────────────────
19405
+ if (options.setupMode) {
19406
+ _setupMode = true;
19407
+ _setupOnComplete = () => {
19408
+ _setupMode = false;
19409
+ console.log("[telegram-ui] setup complete — portal mode active");
19410
+ // Notify entrypoint to start monitor
19411
+ try {
19412
+ import("../entrypoint.mjs").then((m) => {
19413
+ if (typeof m.markSetupComplete === "function") m.markSetupComplete();
19414
+ }).catch(() => {});
19415
+ } catch { /* not running via entrypoint */ }
19416
+ };
19417
+ }
19418
+
19258
19419
  const rawPort = options.port ?? getDefaultPort();
19259
19420
  const configuredPort = Number(rawPort);
19260
19421
  const isTestRun =
@@ -19427,6 +19588,18 @@ export async function startTelegramUiServer(options = {}) {
19427
19588
  return;
19428
19589
  }
19429
19590
 
19591
+ // Docker / load-balancer health check — no auth required
19592
+ if (url.pathname === "/healthz") {
19593
+ try {
19594
+ const { getHealthStatus } = await import("../infra/health-status.mjs");
19595
+ jsonResponse(res, 200, getHealthStatus());
19596
+ } catch {
19597
+ // Fallback if health module unavailable
19598
+ jsonResponse(res, 200, { status: "ok", server: "bosun" });
19599
+ }
19600
+ return;
19601
+ }
19602
+
19430
19603
  // GitHub OAuth callback — public (no session auth required)
19431
19604
  // Accept both /github/callback (registered in GitHub App settings) and
19432
19605
  // /api/github/callback (documented API path) so either works.
@@ -19445,6 +19618,16 @@ export async function startTelegramUiServer(options = {}) {
19445
19618
  return;
19446
19619
  }
19447
19620
 
19621
+ // Setup wizard API routes — handled before the general /api/ catch-all
19622
+ // so the setup wizard works whether running standalone or unified with portal.
19623
+ if (url.pathname.startsWith("/api/setup/")) {
19624
+ const { handleSetupApi } = await import("./setup-web-server.mjs");
19625
+ const handled = await handleSetupApi(req, res, url, {
19626
+ onComplete: _setupOnComplete || undefined,
19627
+ });
19628
+ if (handled) return;
19629
+ }
19630
+
19448
19631
  if (url.pathname.startsWith("/api/")) {
19449
19632
  await handleApi(req, res, url);
19450
19633
  return;
@@ -19463,6 +19646,15 @@ export async function startTelegramUiServer(options = {}) {
19463
19646
  return;
19464
19647
  }
19465
19648
 
19649
+ // ── Setup wizard page ──────────────────────────────────────────────────
19650
+ // When in setup mode, redirect / → /setup so the user lands on the wizard.
19651
+ // When setup is complete, /setup still works for re-configuration access.
19652
+ if (_setupMode && url.pathname === "/") {
19653
+ res.writeHead(302, { Location: "/setup" });
19654
+ res.end();
19655
+ return;
19656
+ }
19657
+
19466
19658
  // /demo and /ui/demo are convenience aliases for /demo.html (the self-contained mock UI demo)
19467
19659
  if (url.pathname === "/demo" || url.pathname === "/ui/demo") {
19468
19660
  const qs = url.search || "";
@@ -20111,6 +20303,9 @@ export function stopTelegramUiServer() {
20111
20303
  if (!uiServer) return;
20112
20304
  stopTunnel();
20113
20305
  stopWsHeartbeat();
20306
+ // Clear injected configDir so it does not leak between server lifecycles
20307
+ // (tests start/stop servers repeatedly with different config directories).
20308
+ delete uiDeps.configDir;
20114
20309
  for (const socket of wsClients) {
20115
20310
  try {
20116
20311
  stopLogStream(socket);
@@ -437,9 +437,22 @@ async function saveState() {
437
437
  }
438
438
 
439
439
  function extractTaskHeading(msg) {
440
- // Prompt first line is "# TASKID — Task Title" (from _buildTaskPrompt).
440
+ // Prompt first line is "# Task: Task Title" (or legacy "# TASKID — Task Title").
441
441
  // Return just the task title portion, or a short fallback.
442
442
  const firstLine = msg.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 heading = firstLine.slice(index).trim();
453
+ if (heading) return heading;
454
+ }
455
+ }
443
456
  const dashIdx = firstLine.indexOf(' \u2014 ');
444
457
  const heading = dashIdx !== -1 ? firstLine.slice(dashIdx + 3).trim() : firstLine;
445
458
  return heading || 'Execute Task';
@@ -9,6 +9,33 @@ function clean(value) {
9
9
  return String(value ?? "").trim();
10
10
  }
11
11
 
12
+ function isAzureOpenAIBaseUrl(value) {
13
+ try {
14
+ const parsed = value instanceof URL ? value : new URL(String(value || ""));
15
+ const host = String(parsed.hostname || "").toLowerCase();
16
+ return host === "openai.azure.com" || host.endsWith(".openai.azure.com");
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function normalizeAzureOpenAIBaseUrl(value) {
23
+ const raw = clean(value);
24
+ if (!raw) return "";
25
+ try {
26
+ const parsed = new URL(raw);
27
+ if (!isAzureOpenAIBaseUrl(parsed)) {
28
+ return raw;
29
+ }
30
+ parsed.pathname = "/openai/v1";
31
+ parsed.search = "";
32
+ parsed.hash = "";
33
+ return parsed.toString().replace(/\/+$/, "");
34
+ } catch {
35
+ return raw;
36
+ }
37
+ }
38
+
12
39
  function normalizeProfileName(value, fallback = DEFAULT_ACTIVE_PROFILE) {
13
40
  const raw = clean(value).toLowerCase();
14
41
  if (!raw) return fallback;
@@ -19,20 +46,6 @@ function profilePrefix(name) {
19
46
  return `CODEX_MODEL_PROFILE_${name.toUpperCase()}_`;
20
47
  }
21
48
 
22
- function inferGlobalProvider(env) {
23
- const baseUrl = clean(env.OPENAI_BASE_URL).toLowerCase();
24
- if ((() => {
25
- try {
26
- const parsed = new URL(baseUrl);
27
- const host = String(parsed.hostname || "").toLowerCase();
28
- return host === "openai.azure.com" || host.endsWith(".openai.azure.com");
29
- } catch {
30
- return false;
31
- }
32
- })()) return "azure";
33
- return "openai";
34
- }
35
-
36
49
  function normalizeProvider(value, fallback) {
37
50
  const raw = clean(value).toLowerCase();
38
51
  if (!raw) return fallback;
@@ -48,6 +61,17 @@ function normalizeProvider(value, fallback) {
48
61
  return fallback;
49
62
  }
50
63
 
64
+ function hasEnvValue(env, key) {
65
+ return Boolean(key && clean(env?.[key]));
66
+ }
67
+
68
+ function inferProviderKindFromSection(name, section, fallback = "openai") {
69
+ if (isAzureOpenAIBaseUrl(section?.baseUrl)) return "azure";
70
+ const normalized = normalizeProvider(name, "");
71
+ if (normalized) return normalized;
72
+ return fallback;
73
+ }
74
+
51
75
  function readProfileField(env, profileName, field) {
52
76
  return clean(env[`${profilePrefix(profileName)}${field}`]);
53
77
  }
@@ -75,17 +99,99 @@ function profileRecord(env, profileName, globalProvider) {
75
99
  };
76
100
  }
77
101
 
78
- function readCodexConfigTopLevelModel() {
102
+ function readCodexConfigRuntimeDefaults() {
79
103
  try {
80
104
  const configPath = resolve(homedir(), ".codex", "config.toml");
81
- if (!existsSync(configPath)) return "";
105
+ if (!existsSync(configPath)) {
106
+ return { model: "", modelProvider: "", providers: {} };
107
+ }
82
108
  const content = readFileSync(configPath, "utf8");
83
109
  const head = content.split(/\n\[/)[0] || "";
84
- const match = head.match(/^\s*model\s*=\s*"([^"]+)"/m);
85
- return match ? match[1].trim() : "";
110
+ const modelMatch = head.match(/^\s*model\s*=\s*"([^"]+)"/m);
111
+ const modelProviderMatch = head.match(/^\s*model_provider\s*=\s*"([^"]+)"/m);
112
+ const providers = {};
113
+ const providerSectionRegex = /^\[model_providers\.([^\]]+)\]\s*([\s\S]*?)(?=^\[[^\]]+\]|\Z)/gm;
114
+ for (const match of content.matchAll(providerSectionRegex)) {
115
+ const [, rawName = "", body = ""] = match;
116
+ const name = clean(rawName);
117
+ if (!name) continue;
118
+ const baseUrlMatch = body.match(/^\s*base_url\s*=\s*"([^"]+)"/m);
119
+ const envKeyMatch = body.match(/^\s*env_key\s*=\s*"([^"]+)"/m);
120
+ providers[name] = {
121
+ name,
122
+ baseUrl: clean(baseUrlMatch?.[1]),
123
+ envKey: clean(envKeyMatch?.[1]),
124
+ };
125
+ }
126
+ return {
127
+ model: clean(modelMatch?.[1]),
128
+ modelProvider: clean(modelProviderMatch?.[1]),
129
+ providers,
130
+ };
86
131
  } catch {
87
- return "";
132
+ return { model: "", modelProvider: "", providers: {} };
133
+ }
134
+ }
135
+
136
+ function readCodexConfigTopLevelModel() {
137
+ return readCodexConfigRuntimeDefaults().model;
138
+ }
139
+
140
+ function selectConfigProviderForRuntime(configDefaults, env, preferredProvider = "") {
141
+ const providers = configDefaults?.providers || {};
142
+ const preferred = clean(preferredProvider).toLowerCase();
143
+ const entries = Object.values(providers).map((section) => ({
144
+ ...section,
145
+ provider: inferProviderKindFromSection(section.name, section, preferred || "openai"),
146
+ }));
147
+ const matchingEntries = preferred
148
+ ? entries.filter((section) => section.provider === preferred)
149
+ : entries;
150
+ const envBackedEntries = matchingEntries.filter(
151
+ (section) => !section.envKey || hasEnvValue(env, section.envKey),
152
+ );
153
+ const preferredNames = preferred === "azure"
154
+ ? ["azure"]
155
+ : preferred === "openai"
156
+ ? ["openai"]
157
+ : [];
158
+ const findNamed = (sections) => preferredNames
159
+ .map((name) => sections.find((section) => section.name === name))
160
+ .find(Boolean);
161
+
162
+ const configuredName = clean(configDefaults?.modelProvider);
163
+ if (configuredName) {
164
+ const configuredSection = providers[configuredName];
165
+ if (configuredSection) {
166
+ const configured = {
167
+ ...configuredSection,
168
+ provider: inferProviderKindFromSection(
169
+ configuredSection.name,
170
+ configuredSection,
171
+ preferred || "openai",
172
+ ),
173
+ };
174
+ const preferredMatches = !preferred || configured.provider === preferred;
175
+ if (preferredMatches && (!configured.envKey || hasEnvValue(env, configured.envKey))) {
176
+ return configured;
177
+ }
178
+ }
88
179
  }
180
+
181
+ return (
182
+ findNamed(envBackedEntries) ||
183
+ envBackedEntries[0] ||
184
+ findNamed(matchingEntries) ||
185
+ matchingEntries[0] ||
186
+ null
187
+ );
188
+ }
189
+
190
+ function inferGlobalProvider(env, configDefaults = null) {
191
+ const baseUrl = clean(env.OPENAI_BASE_URL).toLowerCase();
192
+ if (isAzureOpenAIBaseUrl(baseUrl)) return "azure";
193
+ const configured = selectConfigProviderForRuntime(configDefaults, env);
194
+ return configured?.provider || "openai";
89
195
  }
90
196
 
91
197
  /**
@@ -96,6 +202,7 @@ function readCodexConfigTopLevelModel() {
96
202
  */
97
203
  export function resolveCodexProfileRuntime(envInput = process.env) {
98
204
  const sourceEnv = { ...envInput };
205
+ const configDefaults = readCodexConfigRuntimeDefaults();
99
206
  const activeProfile = normalizeProfileName(
100
207
  sourceEnv.CODEX_MODEL_PROFILE,
101
208
  DEFAULT_ACTIVE_PROFILE,
@@ -105,7 +212,7 @@ export function resolveCodexProfileRuntime(envInput = process.env) {
105
212
  DEFAULT_SUBAGENT_PROFILE,
106
213
  );
107
214
 
108
- const globalProvider = inferGlobalProvider(sourceEnv);
215
+ const globalProvider = inferGlobalProvider(sourceEnv, configDefaults);
109
216
  const active = profileRecord(sourceEnv, activeProfile, globalProvider);
110
217
  const sub = profileRecord(sourceEnv, subagentProfile, globalProvider);
111
218
 
@@ -116,24 +223,54 @@ export function resolveCodexProfileRuntime(envInput = process.env) {
116
223
  if (active.model) {
117
224
  env.CODEX_MODEL = active.model;
118
225
  }
119
- if (active.baseUrl) {
120
- env.OPENAI_BASE_URL = active.baseUrl;
121
- }
122
226
 
123
227
  const profileApiKey = active.apiKey;
124
228
  const resolvedProvider = active.provider || globalProvider;
229
+ const configProvider = selectConfigProviderForRuntime(
230
+ configDefaults,
231
+ sourceEnv,
232
+ resolvedProvider,
233
+ );
234
+ const runtimeBaseUrl =
235
+ clean(active.baseUrl) ||
236
+ clean(env.OPENAI_BASE_URL) ||
237
+ clean(configProvider?.baseUrl);
238
+ if (runtimeBaseUrl) {
239
+ const normalizedBaseUrl =
240
+ resolvedProvider === "azure"
241
+ ? normalizeAzureOpenAIBaseUrl(runtimeBaseUrl)
242
+ : runtimeBaseUrl;
243
+ env.OPENAI_BASE_URL = normalizedBaseUrl;
244
+ active.baseUrl = normalizedBaseUrl;
245
+ }
246
+
247
+ if (!profileApiKey && configProvider?.envKey && hasEnvValue(sourceEnv, configProvider.envKey)) {
248
+ const configuredApiKey = clean(sourceEnv[configProvider.envKey]);
249
+ if (resolvedProvider === "azure") {
250
+ env.AZURE_OPENAI_API_KEY = configuredApiKey;
251
+ if (!env.OPENAI_API_KEY) {
252
+ env.OPENAI_API_KEY = configuredApiKey;
253
+ }
254
+ } else {
255
+ env.OPENAI_API_KEY = configuredApiKey;
256
+ }
257
+ }
125
258
 
126
259
  // Azure deployments often differ from default model names.
127
260
  // If the env is using Azure and the model is still the default,
128
261
  // prefer the top-level ~/.codex/config.toml model when present.
129
- const activeModelExplicit =
130
- Boolean(readProfileField(sourceEnv, activeProfile, "MODEL")) ||
131
- Boolean(clean(sourceEnv.CODEX_MODEL));
132
- if (
262
+ const activeProfileModelExplicit = Boolean(
263
+ readProfileField(sourceEnv, activeProfile, "MODEL"),
264
+ );
265
+ const runtimeModelExplicit = Boolean(clean(sourceEnv.CODEX_MODEL));
266
+ const activeModelValue = clean(env.CODEX_MODEL);
267
+ const shouldPreferAzureConfigModel =
133
268
  resolvedProvider === "azure" &&
134
269
  configModel &&
135
- (!activeModelExplicit || clean(env.CODEX_MODEL) === "gpt-5.3-codex")
136
- ) {
270
+ !activeProfileModelExplicit &&
271
+ !runtimeModelExplicit &&
272
+ !activeModelValue;
273
+ if (shouldPreferAzureConfigModel) {
137
274
  env.CODEX_MODEL = configModel;
138
275
  active.model = configModel;
139
276
  }