bosun 0.35.2 → 0.35.4

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/ui/tabs/agents.js CHANGED
@@ -1503,14 +1503,7 @@ export function AgentsTab() {
1503
1503
  </div>
1504
1504
 
1505
1505
  <div class="fleet-span">
1506
- <${FleetSessionsPanel}
1507
- slots=${slots}
1508
- onOpenWorkspace=${openWorkspace}
1509
- onForceStop=${handleForceStop}
1510
- />
1511
- </div>
1512
-
1513
- ${agents.length > 0 &&
1506
+ ${agents.length > 0 &&
1514
1507
  html`
1515
1508
  <div class="fleet-span">
1516
1509
  <${Collapsible} title="Agent Threads" defaultOpen=${false}>
@@ -1971,3 +1964,68 @@ function FleetSessionsPanel({ slots, onOpenWorkspace, onForceStop }) {
1971
1964
  <//>
1972
1965
  `;
1973
1966
  }
1967
+
1968
+ /* ─── Fleet Sessions Tab (standalone) ─── */
1969
+ export function FleetSessionsTab() {
1970
+ const executor = executorData.value;
1971
+ const execData = executor?.data;
1972
+ const slots = execData?.slots || [];
1973
+
1974
+ useEffect(() => {
1975
+ let active = true;
1976
+ const refreshTaskSessions = () => {
1977
+ if (!active) return;
1978
+ loadSessions({ type: "task" });
1979
+ };
1980
+ refreshTaskSessions();
1981
+ const interval = setInterval(refreshTaskSessions, 5000);
1982
+ return () => {
1983
+ active = false;
1984
+ clearInterval(interval);
1985
+ };
1986
+ }, []);
1987
+
1988
+ /* Force stop a specific agent slot */
1989
+ const handleForceStop = async (slot) => {
1990
+ const ok = await showConfirm(
1991
+ `Force-stop agent working on "${truncate(slot.taskTitle || slot.taskId || "task", 40)}"?`,
1992
+ );
1993
+ if (!ok) return;
1994
+ haptic("heavy");
1995
+ try {
1996
+ await apiFetch("/api/executor/stop-slot", {
1997
+ method: "POST",
1998
+ body: JSON.stringify({ slotIndex: slot.index, taskId: slot.taskId }),
1999
+ });
2000
+ showToast("Stop signal sent", "success");
2001
+ scheduleRefresh(200);
2002
+ } catch {
2003
+ /* toast via apiFetch */
2004
+ }
2005
+ };
2006
+
2007
+ /* Open workspace viewer for an agent */
2008
+ const [selectedAgent, setSelectedAgent] = useState(null);
2009
+ const openWorkspace = (slot, i) => {
2010
+ haptic();
2011
+ setSelectedAgent({ ...slot, index: i });
2012
+ };
2013
+
2014
+ return html`
2015
+ <div class="fleet-layout">
2016
+ <div class="fleet-span">
2017
+ <${FleetSessionsPanel}
2018
+ slots=${slots}
2019
+ onOpenWorkspace=${openWorkspace}
2020
+ onForceStop=${handleForceStop}
2021
+ />
2022
+ </div>
2023
+ </div>
2024
+ ${selectedAgent && html`
2025
+ <${WorkspaceViewer}
2026
+ agent=${selectedAgent}
2027
+ onClose=${() => setSelectedAgent(null)}
2028
+ />
2029
+ `}
2030
+ `;
2031
+ }
@@ -184,6 +184,29 @@ async function installTemplate(templateId) {
184
184
  }
185
185
  }
186
186
 
187
+ async function applyTemplateUpdate(workflowId, mode = "replace", force = false) {
188
+ try {
189
+ const data = await apiFetch(`/api/workflows/${encodeURIComponent(workflowId)}/template-update`, {
190
+ method: "POST",
191
+ headers: { "Content-Type": "application/json" },
192
+ body: JSON.stringify({ mode, force }),
193
+ });
194
+ if (data?.workflow) {
195
+ showToast(
196
+ mode === "copy"
197
+ ? "Updated template copy created"
198
+ : "Workflow updated to latest template",
199
+ "success",
200
+ );
201
+ loadWorkflows();
202
+ return data.workflow;
203
+ }
204
+ } catch (err) {
205
+ showToast(`Template update failed: ${err.message}`, "error");
206
+ }
207
+ return null;
208
+ }
209
+
187
210
  async function loadRuns(workflowId) {
188
211
  try {
189
212
  const url = workflowId
@@ -1570,6 +1593,11 @@ function WorkflowListView() {
1570
1593
  </h3>
1571
1594
  <div style="display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));">
1572
1595
  ${wfs.map(wf => html`
1596
+ ${(() => {
1597
+ const templateState = wf.metadata?.templateState || null;
1598
+ const hasTemplateUpdate = templateState?.updateAvailable === true;
1599
+ const isCustomizedTemplate = templateState?.isCustomized === true;
1600
+ return html`
1573
1601
  <div key=${wf.id} class="wf-card" style="background: var(--color-bg-secondary, #1a1f2e); border-radius: 12px; padding: 14px; border: 1px solid var(--color-border, #2a3040); cursor: pointer; transition: border-color 0.15s;"
1574
1602
  onClick=${() => {
1575
1603
  apiFetch("/api/workflows/" + wf.id).then(d => {
@@ -1583,17 +1611,70 @@ function WorkflowListView() {
1583
1611
  <span class="wf-badge" style="background: ${wf.enabled ? '#10b98130' : '#6b728030'}; color: ${wf.enabled ? '#10b981' : '#6b7280'}; font-size: 10px;">
1584
1612
  ${wf.enabled ? "Active" : "Paused"}
1585
1613
  </span>
1614
+ ${templateState?.templateId && html`
1615
+ <span class="wf-badge" style="background: #3b82f620; color: #60a5fa; font-size: 10px;">
1616
+ Template
1617
+ </span>
1618
+ `}
1619
+ ${isCustomizedTemplate && html`
1620
+ <span class="wf-badge" style="background: #f59e0b20; color: #f59e0b; font-size: 10px;">
1621
+ Customized
1622
+ </span>
1623
+ `}
1624
+ ${hasTemplateUpdate && html`
1625
+ <span class="wf-badge" style="background: #ef444420; color: #f87171; font-size: 10px;">
1626
+ Update Available
1627
+ </span>
1628
+ `}
1586
1629
  </div>
1587
1630
  ${wf.description && html`
1588
1631
  <div style="font-size: 12px; color: var(--color-text-secondary, #8b95a5); margin-bottom: 8px; line-height: 1.4;">
1589
1632
  ${wf.description.slice(0, 120)}${wf.description.length > 120 ? "…" : ""}
1590
1633
  </div>
1591
1634
  `}
1635
+ ${templateState?.templateId && html`
1636
+ <div style="font-size: 11px; color: var(--color-text-secondary, #7f8aa0); margin-bottom: 8px;">
1637
+ ${templateState.templateName || templateState.templateId}
1638
+ ${templateState.installedTemplateVersion && templateState.templateVersion && templateState.installedTemplateVersion !== templateState.templateVersion && html`
1639
+ <span> · v${templateState.installedTemplateVersion} → v${templateState.templateVersion}</span>
1640
+ `}
1641
+ </div>
1642
+ `}
1592
1643
  <div style="display: flex; gap: 8px; align-items: center; font-size: 11px; color: var(--color-text-secondary, #6b7280);">
1593
1644
  <span>${wf.nodeCount || 0} nodes</span>
1594
1645
  <span>·</span>
1595
1646
  <span>${wf.category || "custom"}</span>
1596
1647
  <div style="flex: 1;"></div>
1648
+ ${hasTemplateUpdate && html`
1649
+ <button
1650
+ class="wf-btn wf-btn-sm"
1651
+ style="font-size: 11px; border-color: #f59e0b80; color: #f59e0b;"
1652
+ onClick=${async (e) => {
1653
+ e.stopPropagation();
1654
+ if (!isCustomizedTemplate) {
1655
+ await applyTemplateUpdate(wf.id, "replace", true);
1656
+ return;
1657
+ }
1658
+ const choice = window.prompt(
1659
+ "Template update available for customized workflow.\nType 'copy' to create an updated copy, or 'replace' to overwrite this workflow.",
1660
+ "copy",
1661
+ );
1662
+ const normalized = String(choice || "").trim().toLowerCase();
1663
+ if (normalized === "copy") {
1664
+ await applyTemplateUpdate(wf.id, "copy", false);
1665
+ return;
1666
+ }
1667
+ if (normalized === "replace") {
1668
+ const ok = window.confirm("Replace this customized workflow with latest template? This cannot be undone.");
1669
+ if (!ok) return;
1670
+ await applyTemplateUpdate(wf.id, "replace", true);
1671
+ }
1672
+ }}
1673
+ >
1674
+ <span class="icon-inline">${resolveIcon("refresh")}</span>
1675
+ Update
1676
+ </button>
1677
+ `}
1597
1678
  <button
1598
1679
  class="wf-btn wf-btn-sm"
1599
1680
  style="font-size: 11px;"
@@ -1624,6 +1705,8 @@ function WorkflowListView() {
1624
1705
  </button>
1625
1706
  </div>
1626
1707
  </div>
1708
+ `;
1709
+ })()}
1627
1710
  `)}
1628
1711
  </div>
1629
1712
  </div>
package/ui-server.mjs CHANGED
@@ -355,6 +355,15 @@ async function getWorkflowEngineModule() {
355
355
  };
356
356
  _wfEngine.getWorkflowEngine({ services });
357
357
  _wfServicesReady = true;
358
+
359
+ // Resume any runs that were interrupted by a previous shutdown.
360
+ // This must happen AFTER services are wired so node executors work.
361
+ const engine = _wfEngine.getWorkflowEngine();
362
+ if (typeof engine.resumeInterruptedRuns === "function") {
363
+ engine.resumeInterruptedRuns().catch((err) => {
364
+ console.warn("[workflows] Failed to resume interrupted runs:", err.message);
365
+ });
366
+ }
358
367
  } catch (err) {
359
368
  console.warn("[workflows] services setup failed (engine still usable):", err.message);
360
369
  }
@@ -397,6 +406,27 @@ async function getWorkflowEngineModule() {
397
406
  if (result.errors.length) {
398
407
  console.warn("[workflows] Default template install errors:", result.errors);
399
408
  }
409
+ if (typeof _wfTemplates.reconcileInstalledTemplates === "function") {
410
+ const reconcile = _wfTemplates.reconcileInstalledTemplates(engine, {
411
+ autoUpdateUnmodified: true,
412
+ });
413
+ if (reconcile.autoUpdated > 0) {
414
+ console.log(
415
+ `[workflows] Auto-updated ${reconcile.autoUpdated} unmodified template workflow(s) to latest`,
416
+ );
417
+ }
418
+ if (reconcile.customized.length > 0) {
419
+ const pending = reconcile.customized.filter((entry) => entry.updateAvailable).length;
420
+ if (pending > 0) {
421
+ console.log(
422
+ `[workflows] ${pending} customized template workflow(s) have updates available`,
423
+ );
424
+ }
425
+ }
426
+ if (reconcile.errors.length > 0) {
427
+ console.warn("[workflows] Template reconcile errors:", reconcile.errors);
428
+ }
429
+ }
400
430
  } catch (err) {
401
431
  console.warn("[workflows] Default template install failed:", err.message);
402
432
  } finally {
@@ -1361,6 +1391,28 @@ let uiInstanceLockHeld = false;
1361
1391
  const logStreamers = new Map();
1362
1392
  let uiDeps = {};
1363
1393
 
1394
+ /**
1395
+ * Resolve the execPrimaryPrompt function. Prefers the injected dependency,
1396
+ * falls back to importing directly from primary-agent.mjs so the chat
1397
+ * agent works even when the UI server starts standalone.
1398
+ */
1399
+ let _fallbackExecPrimaryPrompt = null;
1400
+ async function resolveExecPrimaryPrompt() {
1401
+ if (typeof uiDeps.execPrimaryPrompt === "function") return uiDeps.execPrimaryPrompt;
1402
+ if (_fallbackExecPrimaryPrompt) return _fallbackExecPrimaryPrompt;
1403
+ try {
1404
+ const mod = await import("./primary-agent.mjs");
1405
+ if (typeof mod.execPrimaryPrompt === "function") {
1406
+ _fallbackExecPrimaryPrompt = mod.execPrimaryPrompt;
1407
+ console.log("[ui-server] loaded execPrimaryPrompt fallback from primary-agent.mjs");
1408
+ return _fallbackExecPrimaryPrompt;
1409
+ }
1410
+ } catch (err) {
1411
+ console.warn("[ui-server] failed to load execPrimaryPrompt fallback:", err.message);
1412
+ }
1413
+ return null;
1414
+ }
1415
+
1364
1416
  /**
1365
1417
  * Resolve the bosun config directory. Falls back through:
1366
1418
  * 1. uiDeps.configDir (injected at server start)
@@ -6227,6 +6279,9 @@ async function handleApi(req, res, url) {
6227
6279
  const wfMod = await getWorkflowEngine();
6228
6280
  if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
6229
6281
  const engine = wfMod.getWorkflowEngine();
6282
+ if (typeof _wfTemplates?.applyWorkflowTemplateState === "function") {
6283
+ _wfTemplates.applyWorkflowTemplateState(body);
6284
+ }
6230
6285
  const saved = await engine.save(body);
6231
6286
  jsonResponse(res, 200, { ok: true, workflow: saved });
6232
6287
  } catch (err) {
@@ -6263,6 +6318,65 @@ async function handleApi(req, res, url) {
6263
6318
  return;
6264
6319
  }
6265
6320
 
6321
+ if (path === "/api/workflows/template-updates") {
6322
+ try {
6323
+ const wfMod = await getWorkflowEngine();
6324
+ if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
6325
+ const engine = wfMod.getWorkflowEngine();
6326
+ if (typeof _wfTemplates?.reconcileInstalledTemplates === "function") {
6327
+ _wfTemplates.reconcileInstalledTemplates(engine, {
6328
+ autoUpdateUnmodified: true,
6329
+ });
6330
+ }
6331
+ const updates = engine
6332
+ .list()
6333
+ .map((wf) => {
6334
+ const state = wf.metadata?.templateState || null;
6335
+ if (!state?.templateId) return null;
6336
+ return {
6337
+ workflowId: wf.id,
6338
+ workflowName: wf.name,
6339
+ templateId: state.templateId,
6340
+ templateName: state.templateName || state.templateId,
6341
+ updateAvailable: state.updateAvailable === true,
6342
+ isCustomized: state.isCustomized === true,
6343
+ templateVersion: state.templateVersion || null,
6344
+ installedTemplateVersion: state.installedTemplateVersion || null,
6345
+ };
6346
+ })
6347
+ .filter(Boolean);
6348
+ jsonResponse(res, 200, { ok: true, updates });
6349
+ } catch (err) {
6350
+ jsonResponse(res, 500, { ok: false, error: err.message });
6351
+ }
6352
+ return;
6353
+ }
6354
+
6355
+ if (path.startsWith("/api/workflows/") && path.endsWith("/template-update")) {
6356
+ try {
6357
+ const wfMod = await getWorkflowEngine();
6358
+ if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
6359
+ const engine = wfMod.getWorkflowEngine();
6360
+ const workflowId = decodeURIComponent(path.split("/")[3] || "");
6361
+ if (!workflowId) {
6362
+ jsonResponse(res, 400, { ok: false, error: "Missing workflow id" });
6363
+ return;
6364
+ }
6365
+ const body = await readJsonBody(req).catch(() => ({}));
6366
+ const mode = String(body?.mode || "replace").toLowerCase();
6367
+ const force = body?.force === true;
6368
+ if (typeof _wfTemplates?.updateWorkflowFromTemplate !== "function") {
6369
+ jsonResponse(res, 503, { ok: false, error: "Template update service unavailable" });
6370
+ return;
6371
+ }
6372
+ const workflow = _wfTemplates.updateWorkflowFromTemplate(engine, workflowId, { mode, force });
6373
+ jsonResponse(res, 200, { ok: true, workflow });
6374
+ } catch (err) {
6375
+ jsonResponse(res, 500, { ok: false, error: err.message });
6376
+ }
6377
+ return;
6378
+ }
6379
+
6266
6380
  if (path === "/api/workflows/node-types") {
6267
6381
  try {
6268
6382
  const wfMod = await getWorkflowEngine();
@@ -6302,11 +6416,68 @@ async function handleApi(req, res, url) {
6302
6416
  const wfMod = await getWorkflowEngine();
6303
6417
  if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
6304
6418
  const engine = wfMod.getWorkflowEngine();
6305
- const runId = decodeURIComponent(path.replace("/api/workflows/runs/", "")).trim();
6419
+ const subPath = path.replace("/api/workflows/runs/", "");
6420
+ const segments = subPath.split("/").map(decodeURIComponent);
6421
+ const runId = (segments[0] || "").trim();
6422
+ const action = (segments[1] || "").trim();
6423
+
6306
6424
  if (!runId) {
6307
6425
  jsonResponse(res, 400, { ok: false, error: "runId is required" });
6308
6426
  return;
6309
6427
  }
6428
+
6429
+ // ── POST /api/workflows/runs/:id/retry ──────────────────────────
6430
+ // Manual retry endpoint. Accepts { mode: "from_failed" | "from_scratch" }.
6431
+ // If mode is omitted, returns available retry options so the UI can
6432
+ // present a choice to the user.
6433
+ if (action === "retry" && req.method === "POST") {
6434
+ const run = engine.getRunDetail ? engine.getRunDetail(runId) : null;
6435
+ if (!run) {
6436
+ jsonResponse(res, 404, { ok: false, error: "Workflow run not found" });
6437
+ return;
6438
+ }
6439
+ if (run.status !== "failed") {
6440
+ jsonResponse(res, 400, { ok: false, error: `Run status is "${run.status}" — only failed runs can be retried` });
6441
+ return;
6442
+ }
6443
+ const body = await readJsonBody(req);
6444
+ const mode = body?.mode;
6445
+ if (!mode) {
6446
+ // No mode specified — return available retry options so the UI can
6447
+ // present a picker (from scratch vs from failed step).
6448
+ const failedNodes = [];
6449
+ const nodeStatuses = run.detail?.nodeStatuses || {};
6450
+ for (const [nodeId, status] of Object.entries(nodeStatuses)) {
6451
+ if (status === "failed") failedNodes.push(nodeId);
6452
+ }
6453
+ jsonResponse(res, 200, {
6454
+ ok: true,
6455
+ runId,
6456
+ status: run.status,
6457
+ options: [
6458
+ { mode: "from_failed", label: "Retry from last failed step", failedNodes },
6459
+ { mode: "from_scratch", label: "Retry from scratch" },
6460
+ ],
6461
+ });
6462
+ return;
6463
+ }
6464
+ if (mode !== "from_failed" && mode !== "from_scratch") {
6465
+ jsonResponse(res, 400, { ok: false, error: `Invalid mode "${mode}". Use "from_failed" or "from_scratch".` });
6466
+ return;
6467
+ }
6468
+ const result = await engine.retryRun(runId, { mode });
6469
+ const retryStatus = result.ctx?.errors?.length > 0 ? "failed" : "completed";
6470
+ jsonResponse(res, 200, {
6471
+ ok: true,
6472
+ retryRunId: result.retryRunId,
6473
+ originalRunId: result.originalRunId,
6474
+ mode: result.mode,
6475
+ status: retryStatus,
6476
+ });
6477
+ return;
6478
+ }
6479
+
6480
+ // ── GET /api/workflows/runs/:id ─────────────────────────────────
6310
6481
  const run = engine.getRunDetail ? engine.getRunDetail(runId) : null;
6311
6482
  if (!run) {
6312
6483
  jsonResponse(res, 404, { ok: false, error: "Workflow run not found" });
@@ -7217,7 +7388,26 @@ async function handleApi(req, res, url) {
7217
7388
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
7218
7389
  return;
7219
7390
  }
7220
- jsonResponse(res, 200, { ok: true, session });
7391
+ // Support ?limit=N&offset=N for message pagination
7392
+ const reqUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`);
7393
+ const limitParam = reqUrl.searchParams.get("limit");
7394
+ const offsetParam = reqUrl.searchParams.get("offset");
7395
+ if (limitParam) {
7396
+ const limit = Math.max(1, Math.min(Number(limitParam) || 20, 500));
7397
+ const allMessages = session.messages || [];
7398
+ const total = allMessages.length;
7399
+ const offset = offsetParam != null
7400
+ ? Math.max(0, Math.min(Number(offsetParam) || 0, total))
7401
+ : Math.max(0, total - limit);
7402
+ const sliced = allMessages.slice(offset, offset + limit);
7403
+ jsonResponse(res, 200, {
7404
+ ok: true,
7405
+ session: { ...session, messages: sliced },
7406
+ pagination: { total, offset, limit, hasMore: offset > 0 },
7407
+ });
7408
+ } else {
7409
+ jsonResponse(res, 200, { ok: true, session });
7410
+ }
7221
7411
  } catch (err) {
7222
7412
  jsonResponse(res, 500, { ok: false, error: err.message });
7223
7413
  }
@@ -7306,12 +7496,44 @@ async function handleApi(req, res, url) {
7306
7496
  const messageModel = body?.model || undefined;
7307
7497
 
7308
7498
  // Forward to primary agent if applicable (exec records user + assistant events)
7309
- const exec = session.type === "primary" ? uiDeps.execPrimaryPrompt : null;
7499
+ let exec = session.type === "primary" ? uiDeps.execPrimaryPrompt : null;
7500
+ // Fallback: resolve execPrimaryPrompt from primary-agent.mjs if not injected
7501
+ if (!exec && session.type === "primary") {
7502
+ exec = await resolveExecPrimaryPrompt();
7503
+ }
7310
7504
  if (exec) {
7311
7505
  // Don't record user event here — execPrimaryPrompt records it
7312
7506
  // Respond immediately so the UI doesn't block on agent execution
7313
7507
  jsonResponse(res, 200, { ok: true, messageId });
7314
7508
  broadcastUiEvent(["sessions"], "invalidate", { reason: "session-message", sessionId });
7509
+
7510
+ // Build an onEvent callback so intermediate SDK events (thinking,
7511
+ // tool calls, code edits, etc.) are streamed to the UI in real-time
7512
+ // via the existing session-tracker → WebSocket listener pipeline.
7513
+ // Without this, chat/telegram dispatches only show the final
7514
+ // user+assistant pair instead of the full thought stream that Flows
7515
+ // clients see.
7516
+ const streamOnEvent = (err, event) => {
7517
+ // The adapters call onEvent(err, event) or onEvent(event).
7518
+ // Normalise both calling conventions.
7519
+ const ev = event || err;
7520
+ if (!ev) return;
7521
+ try {
7522
+ if (typeof ev === "string") {
7523
+ tracker.recordEvent(sessionId, {
7524
+ role: "system",
7525
+ type: "system",
7526
+ content: ev,
7527
+ timestamp: new Date().toISOString(),
7528
+ });
7529
+ } else {
7530
+ tracker.recordEvent(sessionId, ev);
7531
+ }
7532
+ } catch {
7533
+ /* best-effort — never crash the agent loop */
7534
+ }
7535
+ };
7536
+
7315
7537
  // Fire-and-forget: run agent asynchronously so the request handler
7316
7538
  // doesn't block and the agent doesn't appear "busy" to subsequent
7317
7539
  // messages from chat, telegram, portal, or any other source.
@@ -7320,8 +7542,11 @@ async function handleApi(req, res, url) {
7320
7542
  sessionType: "primary",
7321
7543
  mode: messageMode,
7322
7544
  model: messageModel,
7545
+ persistent: true,
7546
+ sendRawEvents: true,
7323
7547
  attachments,
7324
7548
  attachmentsAppended,
7549
+ onEvent: streamOnEvent,
7325
7550
  }).then(() => {
7326
7551
  broadcastUiEvent(["sessions"], "invalidate", { reason: "agent-response", sessionId });
7327
7552
  }).catch((execErr) => {
@@ -7335,14 +7560,20 @@ async function handleApi(req, res, url) {
7335
7560
  broadcastUiEvent(["sessions"], "invalidate", { reason: "agent-error", sessionId });
7336
7561
  });
7337
7562
  } else {
7338
- // No agent — record user event and acknowledge
7563
+ // No agent available — record user event and notify user
7339
7564
  tracker.recordEvent(sessionId, {
7340
7565
  role: "user",
7341
7566
  content: messageContent,
7342
7567
  attachments,
7343
7568
  timestamp: new Date().toISOString(),
7344
7569
  });
7345
- jsonResponse(res, 200, { ok: true, messageId });
7570
+ tracker.recordEvent(sessionId, {
7571
+ role: "system",
7572
+ type: "error",
7573
+ content: "⚠️ No agent is available to process this message. The primary agent may not be initialized — try restarting bosun or check the Logs tab for details.",
7574
+ timestamp: new Date().toISOString(),
7575
+ });
7576
+ jsonResponse(res, 200, { ok: true, messageId, warning: "no_agent_available" });
7346
7577
  broadcastUiEvent(["sessions"], "invalidate", { reason: "session-message", sessionId });
7347
7578
  }
7348
7579
  } catch (err) {