bosun 0.36.2 → 0.36.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/monitor.mjs CHANGED
@@ -59,7 +59,7 @@ import {
59
59
  generateWeeklyAgentWorkReport,
60
60
  shouldSendWeeklyReport,
61
61
  } from "./agent-work-report.mjs";
62
- import { PRCleanupDaemon } from "./pr-cleanup-daemon.mjs";
62
+
63
63
  import {
64
64
  execPrimaryPrompt,
65
65
  initPrimaryAgent,
@@ -96,11 +96,7 @@ import {
96
96
  } from "./container-runner.mjs";
97
97
  import { ensureCodexConfig, printConfigSummary } from "./codex-config.mjs";
98
98
  import { RestartController } from "./restart-controller.mjs";
99
- import {
100
- analyzeMergeStrategy,
101
- executeDecision,
102
- resetMergeStrategyDedup,
103
- } from "./merge-strategy.mjs";
99
+
104
100
  import { assessTask, quickAssess } from "./task-assessment.mjs";
105
101
  import {
106
102
  getBosunCoAuthorTrailer,
@@ -227,10 +223,7 @@ import { createErrorDetector } from "./error-detector.mjs";
227
223
  import { createAgentSupervisor } from "./agent-supervisor.mjs";
228
224
  import { getSessionTracker } from "./session-tracker.mjs";
229
225
  import { pullWorkspaceRepos } from "./workspace-manager.mjs";
230
- import {
231
- startGitHubReconciler,
232
- stopGitHubReconciler,
233
- } from "./github-reconciler.mjs";
226
+
234
227
  import {
235
228
  getKanbanBackendName,
236
229
  setKanbanBackend,
@@ -1249,6 +1242,8 @@ function isBenignWorkspaceSyncFailure(errorText) {
1249
1242
  let workspaceCount = 0;
1250
1243
  let repoCount = 0;
1251
1244
  let failedRepoCount = 0;
1245
+ let nonBenignFailedRepoCount = 0;
1246
+ let benignFailedRepoCount = 0;
1252
1247
  let workspaceExceptionCount = 0;
1253
1248
  try {
1254
1249
  for (const wsId of workspaceIds) {
@@ -1262,6 +1257,8 @@ function isBenignWorkspaceSyncFailure(errorText) {
1262
1257
  if (failed.length > 0) {
1263
1258
  const benignFailed = failed.filter((r) => isBenignWorkspaceSyncFailure(r?.error));
1264
1259
  const nonBenignFailed = failed.filter((r) => !isBenignWorkspaceSyncFailure(r?.error));
1260
+ benignFailedRepoCount += benignFailed.length;
1261
+ nonBenignFailedRepoCount += nonBenignFailed.length;
1265
1262
  if (nonBenignFailed.length === 0) {
1266
1263
  clearWorkspaceSyncWarnForWorkspace(wsId);
1267
1264
  console.log(
@@ -1304,6 +1301,7 @@ function isBenignWorkspaceSyncFailure(errorText) {
1304
1301
  }
1305
1302
  } catch (err) {
1306
1303
  failedRepoCount += 1;
1304
+ nonBenignFailedRepoCount += 1;
1307
1305
  workspaceExceptionCount += 1;
1308
1306
  const errText = formatMonitorError(err).replace(/\s+/g, " ").trim();
1309
1307
  const errSnippet = (errText || "unknown error").slice(0, 180);
@@ -1318,10 +1316,10 @@ function isBenignWorkspaceSyncFailure(errorText) {
1318
1316
  }
1319
1317
  } finally {
1320
1318
  const durationMs = Date.now() - runStartedAt;
1321
- const summary = `[monitor] workspace sync: cycle complete (${workspaceCount} workspace(s), ${repoCount} repo(s), ${failedRepoCount} failure(s), ${workspaceExceptionCount} exception(s), ${Math.round(durationMs / 1000)}s)`;
1322
- if (repoCount > 0 && failedRepoCount >= repoCount) {
1319
+ const summary = `[monitor] workspace sync: cycle complete (${workspaceCount} workspace(s), ${repoCount} repo(s), ${failedRepoCount} failure(s), ${nonBenignFailedRepoCount} non-benign, ${benignFailedRepoCount} benign, ${workspaceExceptionCount} exception(s), ${Math.round(durationMs / 1000)}s)`;
1320
+ if (repoCount > 0 && nonBenignFailedRepoCount >= repoCount) {
1323
1321
  console.warn(
1324
- `[monitor] workspace sync: all repos failed this cycle (${failedRepoCount}/${repoCount})`,
1322
+ `[monitor] workspace sync: all repos failed this cycle (${nonBenignFailedRepoCount}/${repoCount})`,
1325
1323
  );
1326
1324
  }
1327
1325
  if (workspaceExceptionCount > 0) {
@@ -1976,11 +1974,7 @@ if (primaryAgentReady) {
1976
1974
  void initPrimaryAgent(primaryAgentName);
1977
1975
  }
1978
1976
 
1979
- // Merge strategy: Codex-powered merge decision analysis
1980
- // Enabled by default unless CODEX_ANALYZE_MERGE_STRATEGY=false
1981
- const codexAnalyzeMergeStrategy =
1982
- agentPoolEnabled &&
1983
- (process.env.CODEX_ANALYZE_MERGE_STRATEGY || "").toLowerCase() !== "false";
1977
+ // Merge strategy: now handled by PR_MERGE_STRATEGY workflow template
1984
1978
  const mergeStrategyMode = String(
1985
1979
  process.env.MERGE_STRATEGY_MODE || "smart",
1986
1980
  ).toLowerCase();
@@ -2439,9 +2433,6 @@ function restartSelf(reason) {
2439
2433
  vkLogStream.stop();
2440
2434
  vkLogStream = null;
2441
2435
  }
2442
- if (prCleanupDaemon) {
2443
- prCleanupDaemon.stop();
2444
- }
2445
2436
  const shutdownPromises = [];
2446
2437
  if (agentEndpoint) {
2447
2438
  shutdownPromises.push(
@@ -2515,6 +2506,7 @@ function detectChangedFiles(repoRootPath) {
2515
2506
  cwd: repoRootPath,
2516
2507
  encoding: "utf8",
2517
2508
  timeout: 10_000,
2509
+ stdio: ["pipe", "pipe", "pipe"],
2518
2510
  });
2519
2511
  return output
2520
2512
  .split(/\r?\n/)
@@ -2532,6 +2524,7 @@ function getChangeSummary(repoRootPath, files) {
2532
2524
  cwd: repoRootPath,
2533
2525
  encoding: "utf8",
2534
2526
  timeout: 10_000,
2527
+ stdio: ["pipe", "pipe", "pipe"],
2535
2528
  });
2536
2529
  return diff.trim() || files.join(", ");
2537
2530
  } catch {
@@ -3536,9 +3529,6 @@ function restartVibeKanbanProcess() {
3536
3529
  vkLogStream.stop();
3537
3530
  vkLogStream = null;
3538
3531
  }
3539
- if (prCleanupDaemon) {
3540
- prCleanupDaemon.stop();
3541
- }
3542
3532
  // Just kill the process — the exit handler will auto-restart it
3543
3533
  if (vibeKanbanProcess && !vibeKanbanProcess.killed) {
3544
3534
  try {
@@ -14125,9 +14115,6 @@ function selfRestartForSourceChange(
14125
14115
  vkLogStream.stop();
14126
14116
  vkLogStream = null;
14127
14117
  }
14128
- if (prCleanupDaemon) {
14129
- prCleanupDaemon.stop();
14130
- }
14131
14118
  // ── Agent isolation: by default, do NOT stop internal executor on self-restart ──
14132
14119
  // Task agents run as in-process SDK async iterators. Stopping the executor
14133
14120
  // during a normal restart is unnecessary because process.exit(75) kills them.
@@ -15409,8 +15396,6 @@ let agentEndpoint = null;
15409
15396
  let agentEventBus = null;
15410
15397
  /** @type {import("./review-agent.mjs").ReviewAgent|null} */
15411
15398
  let reviewAgent = null;
15412
- /** @type {Map<string, import("./merge-strategy.mjs").MergeContext>} */
15413
- const pendingMergeStrategyByTask = new Map();
15414
15399
  /** @type {Map<string, { approved: boolean, reviewedAt: string }>} */
15415
15400
  const reviewGateResults = new Map();
15416
15401
  /** @type {import("./sync-engine.mjs").SyncEngine|null} */
@@ -15419,53 +15404,6 @@ let syncEngine = null;
15419
15404
  let errorDetector = null;
15420
15405
  /** @type {import("./agent-supervisor.mjs").AgentSupervisor|null} */
15421
15406
  let agentSupervisor = null;
15422
- /** @type {import("./pr-cleanup-daemon.mjs").PRCleanupDaemon|null} */
15423
- let prCleanupDaemon = null;
15424
- /** @type {import("./github-reconciler.mjs").GitHubReconciler|null} */
15425
- let ghReconciler = null;
15426
-
15427
- function restartGitHubReconciler() {
15428
- if (isWorkflowReplacingModule("github-reconciler.mjs")) {
15429
- console.log("[monitor] skipping legacy GitHub reconciler — handled by workflow");
15430
- return;
15431
- }
15432
- try {
15433
- stopGitHubReconciler();
15434
- ghReconciler = null;
15435
- } catch {
15436
- /* best effort */
15437
- }
15438
-
15439
- const activeKanbanBackend = getActiveKanbanBackend();
15440
- if (activeKanbanBackend !== "github") {
15441
- return;
15442
- }
15443
- if (!githubReconcile?.enabled) {
15444
- return;
15445
- }
15446
- const repo =
15447
- process.env.GITHUB_REPOSITORY ||
15448
- (process.env.GITHUB_REPO_OWNER && process.env.GITHUB_REPO_NAME
15449
- ? `${process.env.GITHUB_REPO_OWNER}/${process.env.GITHUB_REPO_NAME}`
15450
- : "") ||
15451
- repoSlug ||
15452
- "unknown/unknown";
15453
- if (!repo || repo === "unknown/unknown") {
15454
- console.warn("[gh-reconciler] disabled — missing repo slug");
15455
- return;
15456
- }
15457
-
15458
- ghReconciler = startGitHubReconciler({
15459
- repoSlug: repo,
15460
- intervalMs: githubReconcile.intervalMs,
15461
- mergedLookbackHours: githubReconcile.mergedLookbackHours,
15462
- trackingLabels: githubReconcile.trackingLabels,
15463
- sendTelegram:
15464
- telegramToken && telegramChatId
15465
- ? (msg) => void sendTelegramMessage(msg)
15466
- : null,
15467
- });
15468
- }
15469
15407
 
15470
15408
  if (!isMonitorTestRuntime) {
15471
15409
  if (workflowAutomationEnabled) {
@@ -16148,7 +16086,6 @@ startAgentWorkAnalyzer();
16148
16086
  startAgentAlertTailer();
16149
16087
  startMonitorMonitorSupervisor();
16150
16088
  startTaskPlannerStatusLoop();
16151
- restartGitHubReconciler();
16152
16089
 
16153
16090
  // ── Two-way Telegram :workflow: primary agent ────────────────────────────────────────
16154
16091
  injectMonitorFunctions({
@@ -16177,7 +16114,7 @@ injectMonitorFunctions({
16177
16114
  getReviewAgentEnabled: () => isReviewAgentEnabled(),
16178
16115
  getSyncEngine: () => syncEngine,
16179
16116
  getErrorDetector: () => errorDetector,
16180
- getPrCleanupDaemon: () => prCleanupDaemon,
16117
+ getPrCleanupDaemon: () => null,
16181
16118
  getWorkspaceMonitor: () => workspaceMonitor,
16182
16119
  getMonitorMonitorStatus: () => getMonitorMonitorStatusSnapshot(),
16183
16120
  getTaskStoreStats: () => {
@@ -16279,31 +16216,9 @@ if (isContainerEnabled()) {
16279
16216
  }
16280
16217
  }
16281
16218
 
16282
- // ── Start PR Cleanup Daemon ──────────────────────────────────────────────────
16283
- // Automatically resolves PR conflicts and CI failures every 30 minutes
16284
- if (config.prCleanupEnabled !== false) {
16285
- if (isWorkflowReplacingModule("pr-cleanup-daemon.mjs")) {
16286
- console.log("[monitor] skipping legacy PR cleanup daemon — handled by workflow");
16287
- } else {
16288
- const prRepoRoot = effectiveRepoRoot || repoRoot || process.cwd();
16289
- const flowGateControlsMerges =
16290
- isFlowPrimaryEnabled() && isFlowReviewGateEnabled();
16291
- console.log(`[monitor] Starting PR cleanup daemon (repoRoot: ${prRepoRoot})...`);
16292
- if (flowGateControlsMerges) {
16293
- console.log(
16294
- "[monitor] Flow review gate is active — PR cleanup daemon auto-merge is disabled",
16295
- );
16296
- }
16297
- prCleanupDaemon = new PRCleanupDaemon({
16298
- intervalMs: 30 * 60 * 1000, // 30 minutes
16299
- maxConcurrentCleanups: 3,
16300
- dryRun: false,
16301
- autoMerge: !flowGateControlsMerges,
16302
- repoRoot: prRepoRoot,
16303
- });
16304
- prCleanupDaemon.start();
16305
- }
16306
- }
16219
+ // ── Start PR Watchdog & Kanban Sync handled by workflow templates ────────────
16220
+ // PR conflict resolution, CI repair, and GitHub↔kanban sync are now managed
16221
+ // by the BOSUN_PR_WATCHDOG_TEMPLATE and GITHUB_KANBAN_SYNC_TEMPLATE workflows.
16307
16222
  } else {
16308
16223
  console.log(
16309
16224
  "[monitor] test runtime detected (VITEST/NODE_ENV=test) — runtime services disabled",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.36.2",
3
+ "version": "0.36.3",
4
4
  "description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
5
5
  "type": "module",
6
6
  "license": "Apache 2.0",
@@ -95,9 +95,12 @@
95
95
  "build": "node vendor-sync.mjs",
96
96
  "build:docs": "node build-docs.mjs",
97
97
  "shared-workspaces": "node shared-workspace-cli.mjs",
98
- "syntax:check": "node -e \"const fs=require('fs'),path=require('path');const files=fs.readdirSync('.').filter(f=>f.endsWith('.mjs'));let fail=0;for(const f of files){try{require('child_process').execSync('node --check '+f,{stdio:'pipe'});}catch(e){console.error('Syntax error: '+f);console.error(e.stderr.toString());fail=1;}}if(fail)process.exit(1);console.log('Syntax OK: '+files.length+' files checked');\"",
98
+ "syntax:check": "node --experimental-vm-modules --no-warnings=ExperimentalWarning syntax-check.mjs",
99
99
  "pretest": "npm run syntax:check",
100
100
  "test": "vitest run --config vitest.config.mjs",
101
+ "test:vitest": "vitest run --config vitest.config.mjs",
102
+ "test:node": "node --test tests/*.node.test.mjs",
103
+ "test:all": "npm run test:vitest && npm run test:node",
101
104
  "test:voice-provider-smoke": "vitest run --config vitest.config.mjs tests/voice-provider-smoke.test.mjs",
102
105
  "check:native-call-parity": "vitest run --config vitest.config.mjs tests/voice-provider-smoke.test.mjs tests/native-call-parity-checklist.test.mjs",
103
106
  "test:watch": "vitest",
@@ -128,6 +131,8 @@
128
131
  "agent-prompts.mjs",
129
132
  "agent-sdk.mjs",
130
133
  "agent-work-report.mjs",
134
+ "analyze-agent-work-helpers.mjs",
135
+ "analyze-agent-work.mjs",
131
136
  "anomaly-detector.mjs",
132
137
  "autofix.mjs",
133
138
  "claude-shell.mjs",
@@ -159,11 +164,14 @@
159
164
  "github-reconciler.mjs",
160
165
  "get-telegram-chat-id.mjs",
161
166
  "git-commit-helpers.mjs",
167
+ "git-editor-fix.mjs",
162
168
  "git-safety.mjs",
163
169
  "kanban-adapter.mjs",
164
170
  "lib/logger.mjs",
165
171
  "library-manager.mjs",
166
172
  "maintenance.mjs",
173
+ "mcp-registry.mjs",
174
+ "meeting-workflow-service.mjs",
167
175
  "merge-strategy.mjs",
168
176
  "monitor.mjs",
169
177
  "opencode-shell.mjs",
@@ -191,6 +199,7 @@
191
199
  "sync-engine.mjs",
192
200
  "task-archiver.mjs",
193
201
  "task-assessment.mjs",
202
+ "task-cli.mjs",
194
203
  "task-complexity.mjs",
195
204
  "task-claims.mjs",
196
205
  "task-context.mjs",
@@ -213,6 +222,8 @@
213
222
  "vibe-kanban-wrapper.mjs",
214
223
  "vk-error-resolver.mjs",
215
224
  "vk-log-stream.mjs",
225
+ "voice-relay.mjs",
226
+ "voice-tools.mjs",
216
227
  "workspace-manager.mjs",
217
228
  "workspace-monitor.mjs",
218
229
  "workspace-reaper.mjs",
package/primary-agent.mjs CHANGED
@@ -148,14 +148,18 @@ const ADAPTERS = {
148
148
  * Forward an SDK-native command to the Codex shell.
149
149
  * /clear is handled specially as a reset; others are sent as user input.
150
150
  */
151
- execSdkCommand: async (command, args) => {
151
+ execSdkCommand: async (command, args, options = {}) => {
152
152
  const cmd = command.startsWith("/") ? command : `/${command}`;
153
153
  if (cmd === "/clear") {
154
154
  await resetThread();
155
155
  return "Session cleared.";
156
156
  }
157
157
  const fullCmd = args ? `${cmd} ${args}` : cmd;
158
- return execCodexPrompt(fullCmd, { persistent: true });
158
+ return execCodexPrompt(fullCmd, {
159
+ persistent: true,
160
+ cwd: options.cwd,
161
+ sessionId: options.sessionId || null,
162
+ });
159
163
  },
160
164
  },
161
165
  "copilot-sdk": {
@@ -169,14 +173,18 @@ const ADAPTERS = {
169
173
  reset: resetCopilotSession,
170
174
  init: async () => initCopilotShell(),
171
175
  sdkCommands: ["/status", "/model", "/clear"],
172
- execSdkCommand: async (command, args) => {
176
+ execSdkCommand: async (command, args, options = {}) => {
173
177
  const cmd = command.startsWith("/") ? command : `/${command}`;
174
178
  if (cmd === "/clear") {
175
179
  await resetCopilotSession();
176
180
  return "Session cleared.";
177
181
  }
178
182
  const fullCmd = args ? `${cmd} ${args}` : cmd;
179
- return execCopilotPrompt(fullCmd, { persistent: true });
183
+ return execCopilotPrompt(fullCmd, {
184
+ persistent: true,
185
+ cwd: options.cwd,
186
+ sessionId: options.sessionId || null,
187
+ });
180
188
  },
181
189
  },
182
190
  "claude-sdk": {
@@ -193,14 +201,17 @@ const ADAPTERS = {
193
201
  return true;
194
202
  },
195
203
  sdkCommands: ["/compact", "/status", "/model", "/clear"],
196
- execSdkCommand: async (command, args) => {
204
+ execSdkCommand: async (command, args, options = {}) => {
197
205
  const cmd = command.startsWith("/") ? command : `/${command}`;
198
206
  if (cmd === "/clear") {
199
207
  await resetClaudeSession();
200
208
  return "Session cleared.";
201
209
  }
202
210
  const fullCmd = args ? `${cmd} ${args}` : cmd;
203
- return execClaudePrompt(fullCmd, {});
211
+ return execClaudePrompt(fullCmd, {
212
+ cwd: options.cwd,
213
+ sessionId: options.sessionId || null,
214
+ });
204
215
  },
205
216
  },
206
217
  "gemini-sdk": {
@@ -218,14 +229,18 @@ const ADAPTERS = {
218
229
  switchSession: switchGeminiSession,
219
230
  createSession: createGeminiSession,
220
231
  sdkCommands: ["/status", "/model", "/clear"],
221
- execSdkCommand: async (command, args) => {
232
+ execSdkCommand: async (command, args, options = {}) => {
222
233
  const cmd = command.startsWith("/") ? command : `/${command}`;
223
234
  if (cmd === "/clear") {
224
235
  await resetGeminiSession();
225
236
  return "Session cleared.";
226
237
  }
227
238
  const fullCmd = args ? `${cmd} ${args}` : cmd;
228
- return execGeminiPrompt(fullCmd, { persistent: true });
239
+ return execGeminiPrompt(fullCmd, {
240
+ persistent: true,
241
+ cwd: options.cwd,
242
+ sessionId: options.sessionId || null,
243
+ });
229
244
  },
230
245
  },
231
246
  "opencode-sdk": {
@@ -246,14 +261,18 @@ const ADAPTERS = {
246
261
  switchSession: switchOpencodeSession,
247
262
  createSession: createOpencodeSession,
248
263
  sdkCommands: ["/status", "/model", "/sessions", "/clear"],
249
- execSdkCommand: async (command, args) => {
264
+ execSdkCommand: async (command, args, options = {}) => {
250
265
  const cmd = command.startsWith("/") ? command : `/${command}`;
251
266
  if (cmd === "/clear") {
252
267
  await resetOpencodeSession();
253
268
  return "Session cleared.";
254
269
  }
255
270
  const fullCmd = args ? `${cmd} ${args}` : cmd;
256
- return execOpencodePrompt(fullCmd, { persistent: true });
271
+ return execOpencodePrompt(fullCmd, {
272
+ persistent: true,
273
+ cwd: options.cwd,
274
+ sessionId: options.sessionId || null,
275
+ });
257
276
  },
258
277
  },
259
278
  };
@@ -959,9 +978,10 @@ export function getSdkCommands(adapterName) {
959
978
  * @param {string} command — e.g. "/compact", "/model"
960
979
  * @param {string} [args] — optional arguments string
961
980
  * @param {string} [adapterName] — target adapter (defaults to active)
981
+ * @param {object} [options] — execution overrides (e.g. cwd/sessionId)
962
982
  * @returns {Promise<string|object>}
963
983
  */
964
- export async function execSdkCommand(command, args = "", adapterName) {
984
+ export async function execSdkCommand(command, args = "", adapterName, options = {}) {
965
985
  const adapter = adapterName ? ADAPTERS[adapterName] : activeAdapter;
966
986
  if (!adapter) {
967
987
  throw new Error(`Unknown adapter: ${adapterName || "(none)"}`);
@@ -973,5 +993,5 @@ export async function execSdkCommand(command, args = "", adapterName) {
973
993
  if (typeof adapter.execSdkCommand !== "function") {
974
994
  throw new Error(`Adapter ${adapter.name} does not support SDK commands.`);
975
995
  }
976
- return adapter.execSdkCommand(cmd, args);
996
+ return adapter.execSdkCommand(cmd, args, options);
977
997
  }
@@ -236,6 +236,7 @@ export class SessionTracker {
236
236
  // Direct message format (role/content)
237
237
  if (event && event.role && event.content !== undefined) {
238
238
  const msg = {
239
+ id: event.id || `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
239
240
  role: event.role,
240
241
  content: String(event.content).slice(0, MAX_MESSAGE_CHARS),
241
242
  timestamp: event.timestamp || new Date().toISOString(),
@@ -577,6 +578,73 @@ export class SessionTracker {
577
578
  this.#markDirty(sessionId);
578
579
  }
579
580
 
581
+ /**
582
+ * Edit a previously recorded user message in-place.
583
+ * @param {string} sessionId
584
+ * @param {Object} payload
585
+ * @param {string} [payload.messageId]
586
+ * @param {string} [payload.timestamp]
587
+ * @param {string} [payload.previousContent]
588
+ * @param {string} payload.content
589
+ * @returns {{ok:boolean,error?:string,message?:object,index?:number}}
590
+ */
591
+ editUserMessage(sessionId, payload = {}) {
592
+ const session = this.#sessions.get(sessionId);
593
+ if (!session) return { ok: false, error: "Session not found" };
594
+
595
+ const nextContent = String(payload?.content || "").trim();
596
+ if (!nextContent) return { ok: false, error: "content is required" };
597
+
598
+ const messageId = String(payload?.messageId || "").trim();
599
+ const timestamp = String(payload?.timestamp || "").trim();
600
+ const previousContent = payload?.previousContent != null
601
+ ? String(payload.previousContent)
602
+ : "";
603
+ const messages = Array.isArray(session.messages) ? session.messages : [];
604
+
605
+ let idx = -1;
606
+ if (messageId) {
607
+ idx = messages.findIndex((msg) => String(msg?.id || "") === messageId);
608
+ }
609
+
610
+ if (idx < 0 && timestamp) {
611
+ idx = messages.findIndex((msg) => {
612
+ if (String(msg?.role || "").toLowerCase() !== "user") return false;
613
+ if (String(msg?.timestamp || "") !== timestamp) return false;
614
+ if (!previousContent) return true;
615
+ return String(msg?.content || "") === previousContent;
616
+ });
617
+ }
618
+
619
+ if (idx < 0 && previousContent) {
620
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
621
+ const msg = messages[i];
622
+ if (String(msg?.role || "").toLowerCase() !== "user") continue;
623
+ if (String(msg?.content || "") === previousContent) {
624
+ idx = i;
625
+ break;
626
+ }
627
+ }
628
+ }
629
+
630
+ if (idx < 0) return { ok: false, error: "Message not found" };
631
+
632
+ const target = messages[idx];
633
+ if (String(target?.role || "").toLowerCase() !== "user") {
634
+ return { ok: false, error: "Only user messages can be edited" };
635
+ }
636
+
637
+ target.id = target.id || `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
638
+ target.content = nextContent.slice(0, MAX_MESSAGE_CHARS);
639
+ target.edited = true;
640
+ target.editedAt = new Date().toISOString();
641
+ session.lastActivityAt = Date.now();
642
+ session.lastActiveAt = new Date().toISOString();
643
+ this.#markDirty(sessionId);
644
+
645
+ return { ok: true, message: { ...target }, index: idx };
646
+ }
647
+
580
648
  /**
581
649
  * Flush all dirty sessions to disk immediately.
582
650
  */
@@ -46,9 +46,9 @@ const streamConfig = readInternalExecutorStreamConfig();
46
46
  export const MAX_STREAM_RETRIES = parseNumericSetting({
47
47
  envKey: "INTERNAL_EXECUTOR_STREAM_MAX_RETRIES",
48
48
  configValue: streamConfig.maxRetries,
49
- fallback: 5,
49
+ fallback: 8,
50
50
  min: 1,
51
- max: 12,
51
+ max: 20,
52
52
  });
53
53
 
54
54
  /** Base backoff in ms. Doubles per attempt: 2 s → 4 s → 8 s → 16 s → 32 s. */
@@ -62,7 +62,7 @@ const STREAM_RETRY_BASE_MS = parseNumericSetting({
62
62
  const STREAM_RETRY_MAX_MS = parseNumericSetting({
63
63
  envKey: "INTERNAL_EXECUTOR_STREAM_RETRY_MAX_MS",
64
64
  configValue: streamConfig.retryMaxMs,
65
- fallback: 32_000,
65
+ fallback: 60_000,
66
66
  min: STREAM_RETRY_BASE_MS,
67
67
  max: 300_000,
68
68
  });
@@ -111,23 +111,33 @@ export function isTransientStreamError(err) {
111
111
  msg.includes("service_unavailable") ||
112
112
  msg.includes("529") || // Azure overloaded
113
113
  msg.includes("rate_limit_exceeded") ||
114
- msg.includes("overloaded_error") // Anthropic overloaded
114
+ msg.includes("overloaded_error") || // Anthropic overloaded
115
+ // ── Azure / Foundry specific ────────────────────────────────────────────
116
+ msg.includes("reconnecting") ||
117
+ msg.includes("upstream connect error") ||
118
+ msg.includes("no healthy upstream") ||
119
+ msg.includes("gateway timeout") ||
120
+ msg.includes("model is currently overloaded") ||
121
+ msg.includes("the server had an error") ||
122
+ msg.includes("an error occurred during streaming")
115
123
  );
116
124
  }
117
125
 
118
126
  /**
119
- * Exponential backoff delay for stream retries, with ±1 s jitter.
127
+ * Exponential backoff delay for stream retries, with ±25% jitter.
120
128
  *
121
129
  * attempt 0 → ~2 s
122
130
  * attempt 1 → ~4 s
123
131
  * attempt 2 → ~8 s
124
132
  * attempt 3 → ~16 s
125
- * attempt 4 → ~32 s (capped)
133
+ * attempt 4 → ~32 s
134
+ * attempt 5+ → ~60 s (capped)
126
135
  *
127
136
  * @param {number} attempt zero-based retry index
128
137
  * @returns {number} delay in milliseconds
129
138
  */
130
139
  export function streamRetryDelay(attempt) {
131
140
  const base = Math.min(STREAM_RETRY_BASE_MS * 2 ** attempt, STREAM_RETRY_MAX_MS);
132
- return base + Math.random() * 1_000;
141
+ // ±25% jitter to avoid thundering herd on Azure reconnect
142
+ return base + (Math.random() - 0.5) * 0.5 * base;
133
143
  }
package/ui/app.js CHANGED
@@ -665,7 +665,7 @@ function SidebarNav({ collapsed = false, onToggle }) {
665
665
  <button
666
666
  key=${tab.id}
667
667
  class="sidebar-nav-item ${isActive ? "active" : ""} ${isChild ? "sidebar-nav-child" : ""}"
668
- style=${`position:relative${isChild ? ";padding-left:28px;font-size:0.85em" : ""}`}
668
+ style="position:relative"
669
669
  aria-label=${tab.label}
670
670
  aria-current=${isActive ? "page" : null}
671
671
  title=${collapsed ? tab.label : undefined}
@@ -962,7 +962,7 @@ function InspectorPanel({ onResizeStart, onResizeReset, showResizer }) {
962
962
  * Bottom Navigation
963
963
  * ═══════════════════════════════════════════════ */
964
964
  const PRIMARY_NAV_TABS = ["dashboard", "chat", "tasks", "agents"];
965
- const MORE_NAV_TABS = ["control", "infra", "logs", "library", "workflows", "settings"];
965
+ const MORE_NAV_TABS = ["control", "infra", "logs", "telemetry", "library", "workflows", "settings"];
966
966
 
967
967
  function getTabsById(ids) {
968
968
  return ids
@@ -2068,6 +2068,21 @@ function App() {
2068
2068
  }
2069
2069
 
2070
2070
  /* ─── Mount ─── */
2071
- const mountApp = () => preactRender(html`<${App} />`, document.getElementById("app"));
2072
- globalThis.__veRemountApp = mountApp;
2071
+ const mountRoot = () => document.getElementById("app");
2072
+ const mountApp = () => {
2073
+ const root = mountRoot();
2074
+ if (!root) return;
2075
+ preactRender(html`<${App} />`, root);
2076
+ };
2077
+ const remountApp = () => {
2078
+ const root = mountRoot();
2079
+ if (!root) return;
2080
+ try {
2081
+ preactRender(null, root);
2082
+ } catch {
2083
+ root.replaceChildren();
2084
+ }
2085
+ preactRender(html`<${App} />`, root);
2086
+ };
2087
+ globalThis.__veRemountApp = remountApp;
2073
2088
  mountApp();