bosun 0.40.21 → 0.41.1

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 (80) hide show
  1. package/.env.example +8 -0
  2. package/README.md +20 -0
  3. package/agent/agent-custom-tools.mjs +23 -5
  4. package/agent/agent-event-bus.mjs +248 -6
  5. package/agent/agent-pool.mjs +131 -30
  6. package/agent/agent-work-analyzer.mjs +8 -16
  7. package/agent/primary-agent.mjs +81 -7
  8. package/agent/retry-queue.mjs +164 -0
  9. package/bench/swebench/bosun-swebench.mjs +5 -0
  10. package/bosun.config.example.json +25 -0
  11. package/bosun.schema.json +825 -183
  12. package/cli.mjs +267 -8
  13. package/config/config-doctor.mjs +51 -2
  14. package/config/config.mjs +232 -5
  15. package/github/github-auth-manager.mjs +70 -19
  16. package/infra/library-manager.mjs +894 -60
  17. package/infra/monitor.mjs +701 -69
  18. package/infra/runtime-accumulator.mjs +376 -84
  19. package/infra/session-tracker.mjs +95 -28
  20. package/infra/test-runtime.mjs +267 -0
  21. package/lib/codebase-audit.mjs +133 -18
  22. package/package.json +30 -8
  23. package/server/setup-web-server.mjs +29 -1
  24. package/server/ui-server.mjs +1571 -49
  25. package/setup.mjs +27 -24
  26. package/shell/codex-shell.mjs +34 -3
  27. package/shell/copilot-shell.mjs +50 -8
  28. package/task/msg-hub.mjs +193 -0
  29. package/task/pipeline.mjs +544 -0
  30. package/task/task-claims.mjs +6 -10
  31. package/task/task-cli.mjs +38 -2
  32. package/task/task-executor-pipeline.mjs +143 -0
  33. package/task/task-executor.mjs +36 -27
  34. package/telegram/get-telegram-chat-id.mjs +57 -47
  35. package/ui/components/chat-view.js +18 -1
  36. package/ui/components/workspace-switcher.js +321 -9
  37. package/ui/demo-defaults.js +17830 -10433
  38. package/ui/demo.html +9 -1
  39. package/ui/modules/router.js +1 -1
  40. package/ui/modules/settings-schema.js +2 -0
  41. package/ui/modules/state.js +54 -57
  42. package/ui/modules/voice-client-sdk.js +376 -37
  43. package/ui/modules/voice-client.js +173 -33
  44. package/ui/setup.html +68 -2
  45. package/ui/styles/components.css +571 -1
  46. package/ui/styles.css +201 -1
  47. package/ui/tabs/dashboard.js +74 -0
  48. package/ui/tabs/library.js +410 -55
  49. package/ui/tabs/logs.js +10 -0
  50. package/ui/tabs/settings.js +178 -99
  51. package/ui/tabs/tasks.js +1083 -507
  52. package/ui/tabs/telemetry.js +34 -0
  53. package/ui/tabs/workflow-canvas-utils.mjs +38 -1
  54. package/ui/tabs/workflows.js +1275 -402
  55. package/voice/voice-agents-sdk.mjs +2 -2
  56. package/voice/voice-relay.mjs +28 -20
  57. package/workflow/declarative-workflows.mjs +145 -0
  58. package/workflow/msg-hub.mjs +237 -0
  59. package/workflow/pipeline-workflows.mjs +287 -0
  60. package/workflow/pipeline.mjs +828 -315
  61. package/workflow/project-detection.mjs +559 -0
  62. package/workflow/workflow-cli.mjs +128 -0
  63. package/workflow/workflow-contract.mjs +433 -232
  64. package/workflow/workflow-engine.mjs +510 -47
  65. package/workflow/workflow-nodes/custom-loader.mjs +251 -0
  66. package/workflow/workflow-nodes.mjs +2024 -184
  67. package/workflow/workflow-templates.mjs +118 -24
  68. package/workflow-templates/agents.mjs +20 -20
  69. package/workflow-templates/bosun-native.mjs +212 -2
  70. package/workflow-templates/code-quality.mjs +20 -14
  71. package/workflow-templates/continuation-loop.mjs +339 -0
  72. package/workflow-templates/github.mjs +516 -40
  73. package/workflow-templates/planning.mjs +446 -17
  74. package/workflow-templates/reliability.mjs +65 -12
  75. package/workflow-templates/task-batch.mjs +27 -10
  76. package/workflow-templates/task-execution.mjs +752 -0
  77. package/workflow-templates/task-lifecycle.mjs +117 -14
  78. package/workspace/context-cache.mjs +66 -18
  79. package/workspace/workspace-manager.mjs +153 -1
  80. package/workflow-templates/issue-continuation.mjs +0 -243
@@ -53,6 +53,7 @@ import {
53
53
  streamRetryDelay,
54
54
  MAX_STREAM_RETRIES,
55
55
  } from "../infra/stream-resilience.mjs";
56
+ import { ensureTestRuntimeSandbox } from "../infra/test-runtime.mjs";
56
57
  import { compressAllItems, estimateSavings, estimateContextUsagePct, recordShreddingEvent } from "../workspace/context-cache.mjs";
57
58
  import { resolveContextShreddingOptions } from "../config/context-shredding-config.mjs";
58
59
 
@@ -334,6 +335,17 @@ function envFlagEnabled(value) {
334
335
  return ["1", "true", "yes", "on", "y"].includes(raw);
335
336
  }
336
337
 
338
+ function applyNodeWarningSuppressionEnv(runtimeEnv) {
339
+ const nextEnv = { ...(runtimeEnv || {}) };
340
+ if (String(process.env.BOSUN_SUPPRESS_NODE_WARNINGS ?? "").trim() === "0") {
341
+ return nextEnv;
342
+ }
343
+ if (!nextEnv.NODE_NO_WARNINGS) {
344
+ nextEnv.NODE_NO_WARNINGS = "1";
345
+ }
346
+ return nextEnv;
347
+ }
348
+
337
349
  const GITHUB_TOKEN_CACHE_TTL_MS = 60_000;
338
350
  let cachedGithubSessionToken = null;
339
351
  let cachedGithubSessionTokenAt = 0;
@@ -884,6 +896,7 @@ function applySdkFailureCooldown(name, error, nowMs = Date.now()) {
884
896
  }
885
897
 
886
898
  const MONITOR_MONITOR_TASK_KEY = "monitor-monitor";
899
+ const MONITOR_MONITOR_THREAD_REFRESH_TURNS_REMAINING = parseBoundedNumber(process.env.DEVMODE_MONITOR_MONITOR_THREAD_REFRESH_TURNS_REMAINING, 5, 1, 1000);
887
900
  let monitorMonitorTimeoutBoundsWarningKey = "";
888
901
  let monitorMonitorTimeoutAdjustmentKey = "";
889
902
 
@@ -1086,6 +1099,7 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
1086
1099
  onThreadReady = null,
1087
1100
  taskKey: steerKey = null,
1088
1101
  envOverrides = null,
1102
+ systemPrompt = "",
1089
1103
  } = extra;
1090
1104
 
1091
1105
  let reportedThreadId = null;
@@ -1129,7 +1143,8 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
1129
1143
  envOverrides && typeof envOverrides === "object"
1130
1144
  ? { ...process.env, ...envOverrides }
1131
1145
  : process.env;
1132
- const codexOpts = buildCodexSdkOptions(codexRuntimeEnv);
1146
+ const codexSessionEnv = applyNodeWarningSuppressionEnv(codexRuntimeEnv);
1147
+ const codexOpts = buildCodexSdkOptions(codexSessionEnv);
1133
1148
  const modelOverride = String(extra?.model || "").trim();
1134
1149
  if (modelOverride) {
1135
1150
  codexOpts.env = { ...(codexOpts.env || {}), CODEX_MODEL: modelOverride };
@@ -1197,7 +1212,10 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
1197
1212
  // ── 4. Stream the turn ───────────────────────────────────────────────────
1198
1213
  try {
1199
1214
  const streamSafety = resolveCodexStreamSafety(timeoutMs);
1200
- const safePrompt = sanitizeAndBoundPrompt(`${prompt}${TOOL_OUTPUT_GUARDRAIL}`);
1215
+ const anchoredPrompt = String(systemPrompt || "").trim()
1216
+ ? `${String(systemPrompt).trim()}\n\n---\n\n${prompt}`
1217
+ : prompt;
1218
+ const safePrompt = sanitizeAndBoundPrompt(`${anchoredPrompt}${TOOL_OUTPUT_GUARDRAIL}`);
1201
1219
  const turn = await thread.runStreamed(safePrompt, {
1202
1220
  signal: controller.signal,
1203
1221
  });
@@ -1432,11 +1450,12 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
1432
1450
  envOverrides && typeof envOverrides === "object"
1433
1451
  ? { ...process.env, ...envOverrides }
1434
1452
  : process.env;
1453
+ const runtimeSessionEnv = applyNodeWarningSuppressionEnv(runtimeEnv);
1435
1454
  const token =
1436
- runtimeEnv.COPILOT_CLI_TOKEN ||
1437
- runtimeEnv.GITHUB_TOKEN ||
1438
- runtimeEnv.GH_TOKEN ||
1439
- runtimeEnv.GITHUB_PAT ||
1455
+ runtimeSessionEnv.COPILOT_CLI_TOKEN ||
1456
+ runtimeSessionEnv.GITHUB_TOKEN ||
1457
+ runtimeSessionEnv.GH_TOKEN ||
1458
+ runtimeSessionEnv.GITHUB_PAT ||
1440
1459
  undefined;
1441
1460
 
1442
1461
  // ── 3. Create & start ephemeral client (LOCAL mode) ──────────────────────
@@ -1452,15 +1471,16 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
1452
1471
 
1453
1472
  let client;
1454
1473
  let unsubscribe = null;
1474
+ let stopCopilotFirstEventWatch = null;
1455
1475
  let finalResponse = "";
1456
1476
  const allItems = [];
1457
1477
  const autoApprovePermissions = shouldAutoApproveCopilotPermissions();
1458
1478
  const clientEnv = autoApprovePermissions
1459
1479
  ? {
1460
- ...runtimeEnv,
1461
- COPILOT_ALLOW_ALL: runtimeEnv.COPILOT_ALLOW_ALL || "true",
1480
+ ...runtimeSessionEnv,
1481
+ COPILOT_ALLOW_ALL: runtimeSessionEnv.COPILOT_ALLOW_ALL || "true",
1462
1482
  }
1463
- : runtimeEnv;
1483
+ : runtimeSessionEnv;
1464
1484
  try {
1465
1485
  await withSanitizedOpenAiEnv(async () => {
1466
1486
  let clientOpts;
@@ -1480,7 +1500,7 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
1480
1500
  }
1481
1501
  }
1482
1502
  const cliLaunch = resolveCopilotCliLaunchConfig({
1483
- env: runtimeEnv,
1503
+ env: runtimeSessionEnv,
1484
1504
  repoRoot: REPO_ROOT,
1485
1505
  cliArgs: buildPoolCopilotCliArgs(mcpConfigPath),
1486
1506
  });
@@ -1686,7 +1706,50 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
1686
1706
  // Don't let this timer keep the process alive
1687
1707
  if (ht && typeof ht.unref === "function") ht.unref();
1688
1708
  });
1689
- await Promise.race([sendPromise, copilotHardTimeout]);
1709
+ // Some Copilot SDK builds can stall sendAndWait without yielding any
1710
+ // events. Apply an early watchdog so we can fail over before the full
1711
+ // task timeout elapses.
1712
+ let copilotFirstEventTimeoutMs = null;
1713
+ const firstEventWatch =
1714
+ typeof session.on === "function"
1715
+ ? new Promise((_, reject) => {
1716
+ copilotFirstEventTimeoutMs = getFirstEventTimeoutMs(timeoutMs);
1717
+ if (!Number.isFinite(copilotFirstEventTimeoutMs) || copilotFirstEventTimeoutMs <= 0) {
1718
+ return;
1719
+ }
1720
+ let settled = false;
1721
+ let off = null;
1722
+ const timer = setTimeout(() => {
1723
+ settled = true;
1724
+ if (typeof off === "function") off();
1725
+ reject(new Error("timeout_no_events"));
1726
+ }, clampTimerDelayMs(copilotFirstEventTimeoutMs, "copilot-first-event-timeout"));
1727
+ if (timer && typeof timer.unref === "function") timer.unref();
1728
+ off = session.on((event) => {
1729
+ if (settled) return;
1730
+ if (!event || typeof event !== "object") return;
1731
+ const t = String(event.type || "");
1732
+ if (
1733
+ t === "assistant.message" ||
1734
+ t === "assistant.message_delta" ||
1735
+ t === "session.idle" ||
1736
+ t === "session.error"
1737
+ ) {
1738
+ settled = true;
1739
+ clearTimeout(timer);
1740
+ if (typeof off === "function") off();
1741
+ }
1742
+ });
1743
+ stopCopilotFirstEventWatch = () => {
1744
+ settled = true;
1745
+ clearTimeout(timer);
1746
+ if (typeof off === "function") off();
1747
+ };
1748
+ })
1749
+ : null;
1750
+ await Promise.race(
1751
+ [sendPromise, copilotHardTimeout, firstEventWatch].filter(Boolean),
1752
+ );
1690
1753
  }
1691
1754
 
1692
1755
  const output =
@@ -1708,6 +1771,7 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
1708
1771
  err?.name === "AbortError" ||
1709
1772
  errMsg === "timeout" ||
1710
1773
  errMsg === "hard_timeout" ||
1774
+ errMsg === "timeout_no_events" ||
1711
1775
  errMsg === "timeout_waiting_for_idle" ||
1712
1776
  isIdleWaitTimeout;
1713
1777
 
@@ -1729,11 +1793,15 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
1729
1793
  }
1730
1794
 
1731
1795
  if (isTimeout) {
1796
+ const noEventsSuffix =
1797
+ errMsg === "timeout_no_events"
1798
+ ? ` (no events received within ${getFirstEventTimeoutMs(timeoutMs)}ms)`
1799
+ : "";
1732
1800
  return {
1733
1801
  success: false,
1734
1802
  output: "",
1735
1803
  items: allItems,
1736
- error: `${TAG} copilot timeout after ${timeoutMs}ms${isIdleWaitTimeout ? " waiting for session.idle" : ""}`,
1804
+ error: `${TAG} copilot timeout after ${timeoutMs}ms${isIdleWaitTimeout ? " waiting for session.idle" : noEventsSuffix}`,
1737
1805
  sdk: "copilot",
1738
1806
  threadId: resumeThreadId,
1739
1807
  };
@@ -1765,6 +1833,13 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
1765
1833
  threadId: resumeThreadId,
1766
1834
  };
1767
1835
  } finally {
1836
+ try {
1837
+ if (typeof stopCopilotFirstEventWatch === "function") {
1838
+ stopCopilotFirstEventWatch();
1839
+ }
1840
+ } catch {
1841
+ /* best effort */
1842
+ }
1768
1843
  clearAbortScope();
1769
1844
  if (steerKey) unregisterActiveSession(steerKey);
1770
1845
  try {
@@ -1831,6 +1906,7 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
1831
1906
  model: requestedModel = null,
1832
1907
  taskKey: steerKey = null,
1833
1908
  envOverrides = null,
1909
+ systemPrompt = "",
1834
1910
  } = extra;
1835
1911
 
1836
1912
  // ── 1. Load the SDK ──────────────────────────────────────────────────────
@@ -1855,10 +1931,11 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
1855
1931
  envOverrides && typeof envOverrides === "object"
1856
1932
  ? { ...process.env, ...envOverrides }
1857
1933
  : process.env;
1934
+ const runtimeSessionEnv = applyNodeWarningSuppressionEnv(runtimeEnv);
1858
1935
  const apiKey =
1859
- runtimeEnv.ANTHROPIC_API_KEY ||
1860
- runtimeEnv.CLAUDE_API_KEY ||
1861
- runtimeEnv.CLAUDE_KEY ||
1936
+ runtimeSessionEnv.ANTHROPIC_API_KEY ||
1937
+ runtimeSessionEnv.CLAUDE_API_KEY ||
1938
+ runtimeSessionEnv.CLAUDE_KEY ||
1862
1939
  undefined;
1863
1940
 
1864
1941
  // ── 3. Build message queue ───────────────────────────────────────────────
@@ -1973,7 +2050,10 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
1973
2050
  `# ${extractTaskHeading(prompt)}\n\n${prompt}\n\n---\n` +
1974
2051
  'Do NOT respond with "Ready" or ask what to do. EXECUTE this task.';
1975
2052
 
1976
- msgQueue.push(makeUserMessage(formattedPrompt));
2053
+ const anchoredPrompt = String(systemPrompt || "").trim()
2054
+ ? `${String(systemPrompt).trim()}\n\n---\n\n${formattedPrompt}`
2055
+ : formattedPrompt;
2056
+ msgQueue.push(makeUserMessage(anchoredPrompt));
1977
2057
 
1978
2058
  // Register active session for mid-execution steering (Claude uses message queue)
1979
2059
  if (steerKey) {
@@ -1998,28 +2078,28 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
1998
2078
  settingSources: ["user", "project"],
1999
2079
  permissionMode:
2000
2080
  claudePermissionMode ||
2001
- runtimeEnv.CLAUDE_PERMISSION_MODE ||
2081
+ runtimeSessionEnv.CLAUDE_PERMISSION_MODE ||
2002
2082
  "bypassPermissions",
2003
2083
  };
2004
2084
  if (apiKey) options.apiKey = apiKey;
2005
2085
  const explicitAllowedTools = normalizeList(claudeAllowedTools);
2006
2086
  const allowedTools = explicitAllowedTools.length
2007
2087
  ? explicitAllowedTools
2008
- : normalizeList(runtimeEnv.CLAUDE_ALLOWED_TOOLS);
2088
+ : normalizeList(runtimeSessionEnv.CLAUDE_ALLOWED_TOOLS);
2009
2089
  if (allowedTools.length) {
2010
2090
  options.allowedTools = allowedTools;
2011
2091
  }
2012
2092
 
2013
2093
  const model = String(
2014
2094
  requestedModel ||
2015
- runtimeEnv.CLAUDE_MODEL ||
2016
- runtimeEnv.CLAUDE_CODE_MODEL ||
2017
- runtimeEnv.ANTHROPIC_MODEL ||
2095
+ runtimeSessionEnv.CLAUDE_MODEL ||
2096
+ runtimeSessionEnv.CLAUDE_CODE_MODEL ||
2097
+ runtimeSessionEnv.ANTHROPIC_MODEL ||
2018
2098
  "",
2019
2099
  ).trim();
2020
2100
  if (model) options.model = model;
2021
2101
 
2022
- const result = await withTemporaryEnv(runtimeEnv, async () =>
2102
+ const result = await withTemporaryEnv(runtimeSessionEnv, async () =>
2023
2103
  queryFn({
2024
2104
  prompt: msgQueue.iterator(),
2025
2105
  options,
@@ -2521,7 +2601,10 @@ export async function execPooledPrompt(userMessage, options = {}) {
2521
2601
  /** @type {Map<string, ThreadRecord>} In-memory registry keyed by taskKey */
2522
2602
  const threadRegistry = new Map();
2523
2603
 
2524
- const THREAD_REGISTRY_FILE = resolve(__dirname, "..", "logs", "thread-registry.json");
2604
+ const testSandbox = ensureTestRuntimeSandbox();
2605
+ const THREAD_REGISTRY_FILE = testSandbox?.cacheDir
2606
+ ? resolve(testSandbox.cacheDir, "thread-registry.json")
2607
+ : resolve(__dirname, "..", "logs", "thread-registry.json");
2525
2608
  const THREAD_MAX_AGE_MS = 12 * 60 * 60 * 1000; // 12 hours
2526
2609
 
2527
2610
  /** Maximum turns before a thread is considered exhausted and must be replaced */
@@ -2707,7 +2790,7 @@ async function loadThreadRegistry() {
2707
2790
  async function saveThreadRegistry() {
2708
2791
  try {
2709
2792
  const { writeFile, mkdir } = await import("node:fs/promises");
2710
- await mkdir(resolve(__dirname, "..", "logs"), { recursive: true });
2793
+ await mkdir(dirname(THREAD_REGISTRY_FILE), { recursive: true });
2711
2794
  const obj = Object.fromEntries(threadRegistry);
2712
2795
  await writeFile(THREAD_REGISTRY_FILE, JSON.stringify(obj, null, 2), "utf8");
2713
2796
  } catch {
@@ -2799,7 +2882,8 @@ async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
2799
2882
  envOverrides && typeof envOverrides === "object"
2800
2883
  ? { ...process.env, ...envOverrides }
2801
2884
  : process.env;
2802
- const codexOpts = buildCodexSdkOptions(codexRuntimeEnv);
2885
+ const codexSessionEnv = applyNodeWarningSuppressionEnv(codexRuntimeEnv);
2886
+ const codexOpts = buildCodexSdkOptions(codexSessionEnv);
2803
2887
  const modelOverride = String(extra?.model || "").trim();
2804
2888
  if (modelOverride) {
2805
2889
  codexOpts.env = { ...(codexOpts.env || {}), CODEX_MODEL: modelOverride };
@@ -3013,6 +3097,7 @@ export async function launchOrResumeThread(
3013
3097
  restBaseEnv,
3014
3098
  resolvedGithubToken,
3015
3099
  );
3100
+ restExtra.envOverrides = applyNodeWarningSuppressionEnv(restExtra.envOverrides);
3016
3101
  // Pass taskKey through as steer key so SDK launchers can register active sessions
3017
3102
  restExtra.taskKey = taskKey;
3018
3103
  if (restExtra.sdk) {
@@ -3035,19 +3120,32 @@ export async function launchOrResumeThread(
3035
3120
  // Check registry for existing thread
3036
3121
  const existing = threadRegistry.get(taskKey);
3037
3122
  if (existing && existing.alive && existing.threadId) {
3123
+ const turnsRemaining = MAX_THREAD_TURNS - existing.turnCount;
3124
+ const shouldForceRefreshMonitorMonitorThread =
3125
+ String(taskKey || "").trim() === MONITOR_MONITOR_TASK_KEY &&
3126
+ turnsRemaining <= MONITOR_MONITOR_THREAD_REFRESH_TURNS_REMAINING;
3127
+ if (shouldForceRefreshMonitorMonitorThread) {
3128
+ console.log(
3129
+ `${TAG} proactively refreshing monitor-monitor thread with ${turnsRemaining} turns remaining (threshold=${MONITOR_MONITOR_THREAD_REFRESH_TURNS_REMAINING})`,
3130
+ );
3131
+ existing.alive = false;
3132
+ threadRegistry.set(taskKey, existing);
3133
+ saveThreadRegistry().catch(() => {});
3134
+ }
3135
+
3038
3136
  // Approaching-exhaustion warning (non-blocking — still proceeds with resume)
3039
3137
  if (
3040
3138
  existing.turnCount >= THREAD_EXHAUSTION_WARNING_THRESHOLD &&
3041
- existing.turnCount < MAX_THREAD_TURNS
3139
+ existing.turnCount < MAX_THREAD_TURNS &&
3140
+ existing.alive
3042
3141
  ) {
3043
- const remaining = MAX_THREAD_TURNS - existing.turnCount;
3044
3142
  console.warn(
3045
- `${TAG} :alert: thread for task "${taskKey}" approaching exhaustion: ${existing.turnCount}/${MAX_THREAD_TURNS} turns (${remaining} remaining)`,
3143
+ `${TAG} :alert: thread for task "${taskKey}" approaching exhaustion: ${existing.turnCount}/${MAX_THREAD_TURNS} turns (${turnsRemaining} remaining)`,
3046
3144
  );
3047
3145
  }
3048
3146
 
3049
3147
  // Check if thread has exceeded max turns — force fresh start
3050
- if (existing.turnCount >= MAX_THREAD_TURNS) {
3148
+ if (existing.alive && existing.turnCount >= MAX_THREAD_TURNS) {
3051
3149
  console.warn(
3052
3150
  `${TAG} thread for task "${taskKey}" exceeded ${MAX_THREAD_TURNS} turns (has ${existing.turnCount}) — invalidating and starting fresh`,
3053
3151
  );
@@ -3055,7 +3153,10 @@ export async function launchOrResumeThread(
3055
3153
  threadRegistry.set(taskKey, existing);
3056
3154
  saveThreadRegistry().catch(() => {});
3057
3155
  // Fall through to fresh launch below
3058
- } else if (Date.now() - existing.createdAt > THREAD_MAX_ABSOLUTE_AGE_MS) {
3156
+ } else if (
3157
+ existing.alive &&
3158
+ Date.now() - existing.createdAt > THREAD_MAX_ABSOLUTE_AGE_MS
3159
+ ) {
3059
3160
  console.warn(
3060
3161
  `${TAG} thread for task "${taskKey}" exceeded absolute age limit — invalidating and starting fresh`,
3061
3162
  );
@@ -69,25 +69,17 @@ const ALERT_COOLDOWN_RETENTION_MS = Math.max(
69
69
  FAILED_SESSION_TRANSIENT_ALERT_MIN_COOLDOWN_MS * 3,
70
70
  3 * 60 * 60 * 1000,
71
71
  ); // keep cooldown history bounded
72
- const ALERT_COOLDOWN_REPLAY_MIN_BYTES = 256 * 1024;
73
- const ALERT_COOLDOWN_REPLAY_DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
74
- const ALERT_COOLDOWN_REPLAY_MAX_CAP_BYTES = 64 * 1024 * 1024;
75
-
76
72
  function normalizeReplayMaxBytes(value) {
73
+ const fallbackBytes = 8 * 1024 * 1024;
74
+ const minBytes = 256 * 1024;
75
+ const maxBytes = 64 * 1024 * 1024;
77
76
  const parsed = Number(value);
78
- if (!Number.isFinite(parsed) || parsed <= 0) {
79
- return ALERT_COOLDOWN_REPLAY_DEFAULT_MAX_BYTES;
80
- }
81
- const rounded = Math.floor(parsed);
82
- return Math.min(
83
- ALERT_COOLDOWN_REPLAY_MAX_CAP_BYTES,
84
- Math.max(ALERT_COOLDOWN_REPLAY_MIN_BYTES, rounded),
85
- );
77
+ if (!Number.isFinite(parsed)) return fallbackBytes;
78
+ const rounded = Math.trunc(parsed);
79
+ return Math.min(maxBytes, Math.max(minBytes, rounded));
86
80
  }
87
-
88
- const ALERT_COOLDOWN_REPLAY_MAX_BYTES = Math.max(
89
- ALERT_COOLDOWN_REPLAY_MIN_BYTES,
90
- normalizeReplayMaxBytes(process.env.AGENT_ALERT_COOLDOWN_REPLAY_MAX_BYTES),
81
+ const ALERT_COOLDOWN_REPLAY_MAX_BYTES = normalizeReplayMaxBytes(
82
+ process.env.AGENT_ALERT_COOLDOWN_REPLAY_MAX_BYTES,
91
83
  );
92
84
 
93
85
  function getAlertCooldownMs(alert) {
@@ -67,7 +67,9 @@ import {
67
67
  import { getModelsForExecutor, normalizeExecutorKey } from "../task/task-complexity.mjs";
68
68
 
69
69
  /** Valid agent interaction modes */
70
- const VALID_MODES = ["ask", "agent", "plan", "web", "instant"];
70
+ const CORE_MODES = ["ask", "agent", "plan", "web", "instant"];
71
+ /** Custom modes loaded from library */
72
+ const _customModes = new Map();
71
73
 
72
74
  const MODE_ALIASES = Object.freeze({
73
75
  code: "agent",
@@ -116,7 +118,37 @@ function normalizeAgentMode(rawMode, fallback = "agent") {
116
118
  const normalized = String(rawMode || "").trim().toLowerCase();
117
119
  if (!normalized) return fallback;
118
120
  const mapped = MODE_ALIASES[normalized] || normalized;
119
- return VALID_MODES.includes(mapped) ? mapped : fallback;
121
+ return getValidModes().includes(mapped) ? mapped : fallback;
122
+ }
123
+
124
+ /**
125
+ * Get all valid modes including dynamically registered custom modes.
126
+ * @returns {string[]}
127
+ */
128
+ function getValidModes() {
129
+ return [...CORE_MODES, ..._customModes.keys()];
130
+ }
131
+
132
+ /**
133
+ * Get mode prefix for a given mode, including custom modes.
134
+ * @param {string} mode
135
+ * @returns {string}
136
+ */
137
+ function getModePrefix(mode) {
138
+ if (MODE_PREFIXES[mode] !== undefined) return MODE_PREFIXES[mode];
139
+ const custom = _customModes.get(mode);
140
+ return custom?.prefix || "";
141
+ }
142
+
143
+ /**
144
+ * Get execution policy for a given mode, including custom modes.
145
+ * @param {string} mode
146
+ * @returns {object|null}
147
+ */
148
+ function getModeExecPolicy(mode) {
149
+ if (MODE_EXEC_POLICIES[mode]) return MODE_EXEC_POLICIES[mode];
150
+ const custom = _customModes.get(mode);
151
+ return custom?.execPolicy || null;
120
152
  }
121
153
 
122
154
  function normalizeAttachments(input) {
@@ -908,7 +940,7 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
908
940
  (options && options.sessionType ? String(options.sessionType) : "") ||
909
941
  "primary";
910
942
  const effectiveMode = normalizeAgentMode(options.mode || agentMode, agentMode);
911
- const modePolicy = MODE_EXEC_POLICIES[effectiveMode] || null;
943
+ const modePolicy = getModeExecPolicy(effectiveMode);
912
944
  const timeoutMs = options.timeoutMs || modePolicy?.timeoutMs || PRIMARY_EXEC_TIMEOUT_MS;
913
945
  const maxFailoverAttempts = Number.isInteger(options.maxFailoverAttempts)
914
946
  ? Math.max(0, Number(options.maxFailoverAttempts))
@@ -918,7 +950,7 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
918
950
  const attachmentsAppended = options.attachmentsAppended === true;
919
951
 
920
952
  // Apply mode prefix (options.mode overrides the global setting for this call)
921
- const modePrefix = MODE_PREFIXES[effectiveMode] || "";
953
+ const modePrefix = getModePrefix(effectiveMode);
922
954
  const messageWithAttachments = attachments.length && !attachmentsAppended
923
955
  ? appendAttachmentsToPrompt(userMessage, attachments).message
924
956
  : userMessage;
@@ -1241,8 +1273,8 @@ export function getAgentMode() {
1241
1273
  */
1242
1274
  export function setAgentMode(mode) {
1243
1275
  const normalized = normalizeAgentMode(mode, "");
1244
- if (!VALID_MODES.includes(normalized)) {
1245
- return { ok: false, mode: agentMode, error: `Invalid mode "${mode}". Valid: ${VALID_MODES.join(", ")}` };
1276
+ if (!getValidModes().includes(normalized)) {
1277
+ return { ok: false, mode: agentMode, error: `Invalid mode "${mode}". Valid: ${getValidModes().join(", ")}` };
1246
1278
  }
1247
1279
  agentMode = normalized;
1248
1280
  return { ok: true, mode: agentMode };
@@ -1254,10 +1286,52 @@ export function setAgentMode(mode) {
1254
1286
  * @returns {string}
1255
1287
  */
1256
1288
  export function applyModePrefix(userMessage) {
1257
- const prefix = MODE_PREFIXES[agentMode] || "";
1289
+ const prefix = getModePrefix(agentMode);
1258
1290
  return prefix ? prefix + userMessage : userMessage;
1259
1291
  }
1260
1292
 
1293
+ /**
1294
+ * Register a custom interaction mode at runtime.
1295
+ * Core modes cannot be overridden.
1296
+ * @param {string} id
1297
+ * @param {{ prefix?: string, execPolicy?: object|null, toolFilter?: object|null, description?: string }} config
1298
+ */
1299
+ export function registerCustomMode(id, config) {
1300
+ if (!id || typeof id !== "string") return;
1301
+ const modeId = id.trim().toLowerCase();
1302
+ if (CORE_MODES.includes(modeId)) return;
1303
+ _customModes.set(modeId, {
1304
+ prefix: config.prefix || "",
1305
+ execPolicy: config.execPolicy || null,
1306
+ toolFilter: config.toolFilter || null,
1307
+ description: config.description || "",
1308
+ });
1309
+ }
1310
+
1311
+ /**
1312
+ * List all available modes (core + custom) with metadata.
1313
+ * @returns {Array<{id: string, description: string, core: boolean}>}
1314
+ */
1315
+ export function listAvailableModes() {
1316
+ const modes = CORE_MODES.map((m) => ({
1317
+ id: m,
1318
+ description: MODE_PREFIXES[m]?.slice(0, 80) || "Full agentic behavior",
1319
+ core: true,
1320
+ }));
1321
+ for (const [id, cfg] of _customModes) {
1322
+ modes.push({ id, description: cfg.description, core: false });
1323
+ }
1324
+ return modes;
1325
+ }
1326
+
1327
+ /**
1328
+ * Get all registered custom modes.
1329
+ * @returns {Array<{id: string, prefix: string, execPolicy: object|null, toolFilter: object|null, description: string}>}
1330
+ */
1331
+ export function getCustomModes() {
1332
+ return [..._customModes.entries()].map(([id, cfg]) => ({ id, ...cfg }));
1333
+ }
1334
+
1261
1335
  /**
1262
1336
  * Get the list of available agent adapters with capabilities.
1263
1337
  * @returns {Array<{id:string, name:string, provider:string, available:boolean, busy:boolean, capabilities:object}>}
@@ -0,0 +1,164 @@
1
+ /**
2
+ * retry-queue.mjs
3
+ *
4
+ * Pure reducer utilities for retry queue state.
5
+ */
6
+
7
+ const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
8
+
9
+ function isoDayKey(ts) {
10
+ return new Date(ts).toISOString().slice(0, 10);
11
+ }
12
+
13
+ function normalizeItem(raw = {}, now = Date.now()) {
14
+ const taskId = String(raw.taskId || "").trim();
15
+ if (!taskId) return null;
16
+ const retryCount = Number.isFinite(Number(raw.retryCount))
17
+ ? Math.max(0, Math.trunc(Number(raw.retryCount)))
18
+ : 0;
19
+ const nextAttemptAt = Number.isFinite(Number(raw.nextAttemptAt))
20
+ ? Math.max(0, Math.trunc(Number(raw.nextAttemptAt)))
21
+ : now;
22
+ const updatedAt = Number.isFinite(Number(raw.updatedAt))
23
+ ? Math.max(0, Math.trunc(Number(raw.updatedAt)))
24
+ : now;
25
+ const expiresAt = Number.isFinite(Number(raw.expiresAt))
26
+ ? Math.max(0, Math.trunc(Number(raw.expiresAt)))
27
+ : nextAttemptAt + DEFAULT_RETENTION_MS;
28
+ return {
29
+ taskId,
30
+ taskTitle: String(raw.taskTitle || "").trim() || "",
31
+ lastError: String(raw.lastError || "").trim() || "",
32
+ retryCount,
33
+ maxRetries: Number.isFinite(Number(raw.maxRetries))
34
+ ? Math.max(0, Math.trunc(Number(raw.maxRetries)))
35
+ : null,
36
+ nextAttemptAt,
37
+ status: String(raw.status || "pending"),
38
+ reason: String(raw.reason || "").trim() || "",
39
+ updatedAt,
40
+ expiresAt,
41
+ };
42
+ }
43
+
44
+ function materialize(itemsByTask) {
45
+ return Array.from(itemsByTask.values()).sort((a, b) => {
46
+ if (a.nextAttemptAt !== b.nextAttemptAt) return a.nextAttemptAt - b.nextAttemptAt;
47
+ return a.updatedAt - b.updatedAt;
48
+ });
49
+ }
50
+
51
+ function ensureDay(state, now) {
52
+ const dayKey = isoDayKey(now);
53
+ if (state.stats.dayKey === dayKey) return state.stats;
54
+ return {
55
+ ...state.stats,
56
+ dayKey,
57
+ totalRetriesToday: 0,
58
+ };
59
+ }
60
+
61
+ export function createRetryQueueState(now = Date.now()) {
62
+ return {
63
+ itemsByTask: new Map(),
64
+ stats: {
65
+ dayKey: isoDayKey(now),
66
+ totalRetriesToday: 0,
67
+ peakRetryDepth: 0,
68
+ exhaustedTaskIds: [],
69
+ },
70
+ };
71
+ }
72
+
73
+ export function reduceRetryQueue(state, action = {}) {
74
+ const now = Number.isFinite(Number(action.now))
75
+ ? Math.max(0, Math.trunc(Number(action.now)))
76
+ : Date.now();
77
+ const type = String(action.type || "").trim().toLowerCase();
78
+ const nextItems = new Map(state?.itemsByTask || []);
79
+ let stats = ensureDay(
80
+ state && state.stats ? state : createRetryQueueState(now),
81
+ now,
82
+ );
83
+
84
+ if (type === "add" || type === "upsert") {
85
+ const item = normalizeItem(action.item, now);
86
+ if (!item) return { itemsByTask: nextItems, stats };
87
+ nextItems.set(item.taskId, item);
88
+ if (item.retryCount > stats.peakRetryDepth) {
89
+ stats = { ...stats, peakRetryDepth: item.retryCount };
90
+ }
91
+ return { itemsByTask: nextItems, stats };
92
+ }
93
+
94
+ if (type === "remove") {
95
+ const taskId = String(action.taskId || "").trim();
96
+ if (taskId) nextItems.delete(taskId);
97
+ return { itemsByTask: nextItems, stats };
98
+ }
99
+
100
+ if (type === "bump-count") {
101
+ const taskId = String(action.taskId || "").trim();
102
+ if (!taskId) return { itemsByTask: nextItems, stats };
103
+ const prev = nextItems.get(taskId) || normalizeItem({ taskId }, now);
104
+ if (!prev) return { itemsByTask: nextItems, stats };
105
+ const nextRetry = Number.isFinite(Number(action.retryCount))
106
+ ? Math.max(0, Math.trunc(Number(action.retryCount)))
107
+ : prev.retryCount + 1;
108
+ const nextItem = normalizeItem({
109
+ ...prev,
110
+ ...action.item,
111
+ taskId,
112
+ retryCount: nextRetry,
113
+ updatedAt: now,
114
+ }, now);
115
+ nextItems.set(taskId, nextItem);
116
+ const peakRetryDepth = Math.max(stats.peakRetryDepth || 0, nextRetry);
117
+ stats = {
118
+ ...stats,
119
+ totalRetriesToday: Math.max(0, (stats.totalRetriesToday || 0) + 1),
120
+ peakRetryDepth,
121
+ };
122
+ return { itemsByTask: nextItems, stats };
123
+ }
124
+
125
+ if (type === "mark-exhausted") {
126
+ const taskId = String(action.taskId || "").trim();
127
+ if (!taskId) return { itemsByTask: nextItems, stats };
128
+ const exhausted = new Set(stats.exhaustedTaskIds || []);
129
+ exhausted.add(taskId);
130
+ nextItems.delete(taskId);
131
+ stats = { ...stats, exhaustedTaskIds: Array.from(exhausted) };
132
+ return { itemsByTask: nextItems, stats };
133
+ }
134
+
135
+ if (type === "expire") {
136
+ for (const [taskId, item] of nextItems) {
137
+ if (!item) {
138
+ nextItems.delete(taskId);
139
+ continue;
140
+ }
141
+ if (item.expiresAt <= now) {
142
+ nextItems.delete(taskId);
143
+ }
144
+ }
145
+ return { itemsByTask: nextItems, stats };
146
+ }
147
+
148
+ return { itemsByTask: nextItems, stats };
149
+ }
150
+
151
+ export function snapshotRetryQueue(state) {
152
+ const items = materialize(state?.itemsByTask || new Map());
153
+ return {
154
+ count: items.length,
155
+ items,
156
+ stats: {
157
+ totalRetriesToday: Number(state?.stats?.totalRetriesToday || 0),
158
+ peakRetryDepth: Number(state?.stats?.peakRetryDepth || 0),
159
+ exhaustedTaskIds: Array.isArray(state?.stats?.exhaustedTaskIds)
160
+ ? [...new Set(state.stats.exhaustedTaskIds.map((id) => String(id || "").trim()).filter(Boolean))]
161
+ : [],
162
+ },
163
+ };
164
+ }