agent-nuvira 3.1.1 → 3.2.0

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 (172) hide show
  1. package/dist/agents/agents/reasoner.d.ts +8 -0
  2. package/dist/agents/agents/reasoner.d.ts.map +1 -1
  3. package/dist/agents/agents/reasoner.js +53 -2
  4. package/dist/agents/agents/reasoner.js.map +1 -1
  5. package/dist/agents/agents/writer.d.ts +24 -0
  6. package/dist/agents/agents/writer.d.ts.map +1 -1
  7. package/dist/agents/agents/writer.js +194 -0
  8. package/dist/agents/agents/writer.js.map +1 -1
  9. package/dist/agents/composite-plan.d.ts +143 -0
  10. package/dist/agents/composite-plan.d.ts.map +1 -0
  11. package/dist/agents/composite-plan.js +399 -0
  12. package/dist/agents/composite-plan.js.map +1 -0
  13. package/dist/agents/long-form-plan.d.ts +156 -0
  14. package/dist/agents/long-form-plan.d.ts.map +1 -0
  15. package/dist/agents/long-form-plan.js +274 -0
  16. package/dist/agents/long-form-plan.js.map +1 -0
  17. package/dist/agents/orchestrator.d.ts +107 -0
  18. package/dist/agents/orchestrator.d.ts.map +1 -1
  19. package/dist/agents/orchestrator.js +501 -35
  20. package/dist/agents/orchestrator.js.map +1 -1
  21. package/dist/agents/prompt-assembly.d.ts +8 -0
  22. package/dist/agents/prompt-assembly.d.ts.map +1 -1
  23. package/dist/agents/prompt-assembly.js +17 -0
  24. package/dist/agents/prompt-assembly.js.map +1 -1
  25. package/dist/cli/chat.d.ts +27 -0
  26. package/dist/cli/chat.d.ts.map +1 -1
  27. package/dist/cli/chat.js +133 -8
  28. package/dist/cli/chat.js.map +1 -1
  29. package/dist/cli/execute.d.ts +12 -0
  30. package/dist/cli/execute.d.ts.map +1 -1
  31. package/dist/cli/execute.js +105 -2
  32. package/dist/cli/execute.js.map +1 -1
  33. package/dist/cli/models.d.ts.map +1 -1
  34. package/dist/cli/models.js +10 -0
  35. package/dist/cli/models.js.map +1 -1
  36. package/dist/cli/nlu.d.ts +8 -0
  37. package/dist/cli/nlu.d.ts.map +1 -1
  38. package/dist/cli/nlu.js +57 -0
  39. package/dist/cli/nlu.js.map +1 -1
  40. package/dist/cli/trace.d.ts.map +1 -1
  41. package/dist/cli/trace.js +27 -0
  42. package/dist/cli/trace.js.map +1 -1
  43. package/dist/gateway/gateway-log.d.ts +1 -1
  44. package/dist/gateway/gateway-log.d.ts.map +1 -1
  45. package/dist/gateway/gateway-log.js.map +1 -1
  46. package/dist/gateway/registry.d.ts +116 -1
  47. package/dist/gateway/registry.d.ts.map +1 -1
  48. package/dist/gateway/registry.js +575 -52
  49. package/dist/gateway/registry.js.map +1 -1
  50. package/dist/inference/model-entitlement.d.ts +46 -0
  51. package/dist/inference/model-entitlement.d.ts.map +1 -0
  52. package/dist/inference/model-entitlement.js +98 -0
  53. package/dist/inference/model-entitlement.js.map +1 -0
  54. package/dist/inference/tool-call-utils.d.ts.map +1 -1
  55. package/dist/inference/tool-call-utils.js +70 -1
  56. package/dist/inference/tool-call-utils.js.map +1 -1
  57. package/dist/learning/autonomy-policy.d.ts +334 -0
  58. package/dist/learning/autonomy-policy.d.ts.map +1 -0
  59. package/dist/learning/autonomy-policy.js +500 -0
  60. package/dist/learning/autonomy-policy.js.map +1 -0
  61. package/dist/learning/credential-fingerprint.d.ts +58 -0
  62. package/dist/learning/credential-fingerprint.d.ts.map +1 -0
  63. package/dist/learning/credential-fingerprint.js +126 -0
  64. package/dist/learning/credential-fingerprint.js.map +1 -0
  65. package/dist/learning/deferred-task.d.ts +159 -0
  66. package/dist/learning/deferred-task.d.ts.map +1 -0
  67. package/dist/learning/deferred-task.js +451 -0
  68. package/dist/learning/deferred-task.js.map +1 -0
  69. package/dist/learning/deliverable-class.d.ts +130 -0
  70. package/dist/learning/deliverable-class.d.ts.map +1 -0
  71. package/dist/learning/deliverable-class.js +432 -0
  72. package/dist/learning/deliverable-class.js.map +1 -0
  73. package/dist/learning/long-form.d.ts +252 -0
  74. package/dist/learning/long-form.d.ts.map +1 -0
  75. package/dist/learning/long-form.js +510 -0
  76. package/dist/learning/long-form.js.map +1 -0
  77. package/dist/learning/model-first-router.d.ts +17 -0
  78. package/dist/learning/model-first-router.d.ts.map +1 -1
  79. package/dist/learning/model-first-router.js +35 -0
  80. package/dist/learning/model-first-router.js.map +1 -1
  81. package/dist/learning/model-registry.d.ts +96 -4
  82. package/dist/learning/model-registry.d.ts.map +1 -1
  83. package/dist/learning/model-registry.js +128 -6
  84. package/dist/learning/model-registry.js.map +1 -1
  85. package/dist/learning/model-warmup.d.ts +97 -2
  86. package/dist/learning/model-warmup.d.ts.map +1 -1
  87. package/dist/learning/model-warmup.js +165 -60
  88. package/dist/learning/model-warmup.js.map +1 -1
  89. package/dist/learning/prompt-layers.d.ts +61 -0
  90. package/dist/learning/prompt-layers.d.ts.map +1 -0
  91. package/dist/learning/prompt-layers.js +140 -0
  92. package/dist/learning/prompt-layers.js.map +1 -0
  93. package/dist/learning/provider-limits.d.ts +66 -0
  94. package/dist/learning/provider-limits.d.ts.map +1 -0
  95. package/dist/learning/provider-limits.js +184 -0
  96. package/dist/learning/provider-limits.js.map +1 -0
  97. package/dist/learning/reasoning-trace.d.ts +36 -1
  98. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  99. package/dist/learning/reasoning-trace.js +36 -2
  100. package/dist/learning/reasoning-trace.js.map +1 -1
  101. package/dist/learning/resilient-call.d.ts +36 -1
  102. package/dist/learning/resilient-call.d.ts.map +1 -1
  103. package/dist/learning/resilient-call.js +118 -8
  104. package/dist/learning/resilient-call.js.map +1 -1
  105. package/dist/learning/unattended-job.d.ts +293 -0
  106. package/dist/learning/unattended-job.d.ts.map +1 -0
  107. package/dist/learning/unattended-job.js +544 -0
  108. package/dist/learning/unattended-job.js.map +1 -0
  109. package/dist/learning/unattended-progress.d.ts +95 -0
  110. package/dist/learning/unattended-progress.d.ts.map +1 -0
  111. package/dist/learning/unattended-progress.js +147 -0
  112. package/dist/learning/unattended-progress.js.map +1 -0
  113. package/dist/learning/working-state.d.ts +109 -0
  114. package/dist/learning/working-state.d.ts.map +1 -0
  115. package/dist/learning/working-state.js +244 -0
  116. package/dist/learning/working-state.js.map +1 -0
  117. package/dist/nlu/conversation-gate.d.ts +46 -0
  118. package/dist/nlu/conversation-gate.d.ts.map +1 -1
  119. package/dist/nlu/conversation-gate.js +73 -6
  120. package/dist/nlu/conversation-gate.js.map +1 -1
  121. package/dist/nlu/intent-confirm.d.ts +82 -0
  122. package/dist/nlu/intent-confirm.d.ts.map +1 -0
  123. package/dist/nlu/intent-confirm.js +146 -0
  124. package/dist/nlu/intent-confirm.js.map +1 -0
  125. package/dist/nlu/learnings.d.ts +101 -0
  126. package/dist/nlu/learnings.d.ts.map +1 -0
  127. package/dist/nlu/learnings.js +283 -0
  128. package/dist/nlu/learnings.js.map +1 -0
  129. package/dist/tools/coding-tools.d.ts.map +1 -1
  130. package/dist/tools/coding-tools.js +95 -19
  131. package/dist/tools/coding-tools.js.map +1 -1
  132. package/dist/tools/edit-verification.d.ts +125 -0
  133. package/dist/tools/edit-verification.d.ts.map +1 -0
  134. package/dist/tools/edit-verification.js +237 -0
  135. package/dist/tools/edit-verification.js.map +1 -0
  136. package/dist/tools/git-tool.d.ts.map +1 -1
  137. package/dist/tools/git-tool.js +37 -4
  138. package/dist/tools/git-tool.js.map +1 -1
  139. package/dist/tools/registry.d.ts +30 -2
  140. package/dist/tools/registry.d.ts.map +1 -1
  141. package/dist/tools/registry.js +40 -11
  142. package/dist/tools/registry.js.map +1 -1
  143. package/dist/tools/run-cli.d.ts.map +1 -1
  144. package/dist/tools/run-cli.js +40 -13
  145. package/dist/tools/run-cli.js.map +1 -1
  146. package/dist/tools/run-terminal.d.ts +6 -1
  147. package/dist/tools/run-terminal.d.ts.map +1 -1
  148. package/dist/tools/run-terminal.js +158 -18
  149. package/dist/tools/run-terminal.js.map +1 -1
  150. package/dist/tools/tool-loop.d.ts +35 -0
  151. package/dist/tools/tool-loop.d.ts.map +1 -1
  152. package/dist/tools/tool-loop.js +157 -3
  153. package/dist/tools/tool-loop.js.map +1 -1
  154. package/dist/web-dashboard/chat-console.d.ts +35 -0
  155. package/dist/web-dashboard/chat-console.d.ts.map +1 -1
  156. package/dist/web-dashboard/chat-console.js +97 -18
  157. package/dist/web-dashboard/chat-console.js.map +1 -1
  158. package/dist/web-dashboard/chat-retry.d.ts +143 -0
  159. package/dist/web-dashboard/chat-retry.d.ts.map +1 -0
  160. package/dist/web-dashboard/chat-retry.js +227 -0
  161. package/dist/web-dashboard/chat-retry.js.map +1 -0
  162. package/dist/web-dashboard/server.d.ts +17 -0
  163. package/dist/web-dashboard/server.d.ts.map +1 -1
  164. package/dist/web-dashboard/server.js +200 -9
  165. package/dist/web-dashboard/server.js.map +1 -1
  166. package/dist/web-dashboard/src/types.d.ts +81 -4
  167. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  168. package/package.json +1 -1
  169. package/src/web-dashboard/public/assets/{index-CQW0qv1r.js → index-kCUkORm7.js} +27 -27
  170. package/src/web-dashboard/public/assets/index-kCUkORm7.js.map +1 -0
  171. package/src/web-dashboard/public/index.html +1 -1
  172. package/src/web-dashboard/public/assets/index-CQW0qv1r.js.map +0 -1
@@ -25,11 +25,14 @@ import { getAutoRouter } from '../learning/auto-router.js';
25
25
  import { readRecallHits } from '../context/session-recall.js';
26
26
  import { getRouterPromotion } from '../learning/router-promotion.js';
27
27
  import { AUTH_CLEAR_THRESHOLD } from '../learning/key-hygiene.js';
28
- import { ACTION_LOG_FILENAME, aggregateActionTelemetry, readActionTelemetryFile } from '../learning/model-registry.js';
28
+ import { ACTION_LOG_FILENAME, aggregateActionTelemetry, readActionTelemetryFile, getModelRegistry, } from '../learning/model-registry.js';
29
+ import { classifyModelEntitlement } from '../inference/model-entitlement.js';
29
30
  import { AdminSessions, countAdminRoleUsers, isAdminConfigured, listAdminUsers, removeAdminUser, roleForUser, verifyAdmin, writeAdminUser, MIN_ADMIN_PASSWORD_LENGTH, } from './src/admin-auth.js';
30
31
  import { TaskRunner } from './task-runner.js';
31
32
  import { WhatsAppPairingManager } from './whatsapp-pairing.js';
32
33
  import { ChatConsole, newChatSessionId } from './chat-console.js';
34
+ import { ChatRetryBroker } from './chat-retry.js';
35
+ import { markFailoverAttempts } from '../learning/resilient-call.js';
33
36
  import { buildProjectContext, formatProjectText } from './project-context.js';
34
37
  import { isVaultRef } from '../enterprise/vault.js';
35
38
  import { ROLES, roleCan } from '../enterprise/rbac.js';
@@ -322,6 +325,50 @@ export function broadcastTyping(event) {
322
325
  catch { /* client disconnected */ }
323
326
  }
324
327
  }
328
+ /**
329
+ * Push a deferred-retry update to the open dashboard.
330
+ *
331
+ * The retry runs minutes or hours after the turn that queued it, long after the
332
+ * per-session chat stream has been torn down (the client unsubscribes when the
333
+ * POST resolves), so it rides the PERSISTENT `/api/sse` channel the app opens
334
+ * once at load. The session id is in the payload because that channel is shared
335
+ * by every session and by every other panel.
336
+ */
337
+ export function broadcastChatRetry(event) {
338
+ const payload = `event: chat-retry\ndata: ${JSON.stringify({ ...event, serverTime: Date.now() })}\n\n`;
339
+ for (const client of sseClients) {
340
+ try {
341
+ client.res.write(payload);
342
+ }
343
+ catch { /* client disconnected */ }
344
+ }
345
+ }
346
+ /**
347
+ * The deferred-retry drain (one timer per process; idempotent).
348
+ *
349
+ * Armed with the server and cleared on shutdown. A retry is meant to outlive the
350
+ * user's attention — that is the whole point of the offer — so unlike the quota
351
+ * watcher this is NOT tied to an open SSE client: closing the tab must not
352
+ * cancel the work the agent promised to finish.
353
+ */
354
+ let chatRetryTimer = null;
355
+ function armChatRetryDrain() {
356
+ if (chatRetryTimer)
357
+ return;
358
+ chatRetryTimer = setInterval(() => {
359
+ void chatRetryBroker.drain().catch(() => undefined);
360
+ }, CHAT_RETRY_DRAIN_INTERVAL_MS);
361
+ // Never hold the event loop open for a background convenience.
362
+ chatRetryTimer.unref?.();
363
+ }
364
+ function disarmChatRetryDrain() {
365
+ if (!chatRetryTimer)
366
+ return;
367
+ clearInterval(chatRetryTimer);
368
+ chatRetryTimer = null;
369
+ }
370
+ /** How often the dashboard checks for a retry whose wait is over (ms). */
371
+ const CHAT_RETRY_DRAIN_INTERVAL_MS = 15_000;
325
372
  // ─── ZIP Generation (no external dependencies) ─────────────────────────────
326
373
  /** CRC-32 lookup table (precomputed). */
327
374
  const CRC_TABLE = new Uint32Array(256);
@@ -806,7 +853,9 @@ export function readTracesData() {
806
853
  const traces = [...file.traces]
807
854
  .sort((a, b) => b.startedAt - a.startedAt)
808
855
  .map((t) => {
809
- const { steps, ...rest } = t;
856
+ // Session 3 — the full stable layer is detail-only (the index would grow
857
+ // by ~16KB per trace otherwise); it is shown in the replay view.
858
+ const { steps, systemPrompt: _systemPrompt, ...rest } = t;
810
859
  const stepCount = steps.length;
811
860
  const failedSteps = steps.filter((s) => !s.success).length;
812
861
  const totalTokens = steps.reduce((sum, s) => sum + s.inputTokens + s.outputTokens, 0);
@@ -958,12 +1007,81 @@ async function readModelsHealth() {
958
1007
  checkVLLMProvider(),
959
1008
  checkBedrockProvider(),
960
1009
  ]);
961
- const providers = results.filter(Boolean);
962
- const totalModels = providers.reduce((sum, p) => sum + p.models.length, 0);
963
- const available = providers.reduce((sum, p) => sum + p.models.filter((m) => m.status === 'available').length, 0);
964
- const limited = providers.reduce((sum, p) => sum + p.models.filter((m) => m.status === 'limited').length, 0);
965
- const unavailable = providers.reduce((sum, p) => sum + p.models.filter((m) => m.status === 'unavailable').length, 0);
966
- return { providers, lastChecked: Date.now(), totalModels, available, limited, unavailable };
1010
+ const probed = results.filter(Boolean);
1011
+ const totalModels = probed.reduce((sum, p) => sum + p.models.length, 0);
1012
+ const available = probed.reduce((sum, p) => sum + p.models.filter((m) => m.status === 'available').length, 0);
1013
+ const limited = probed.reduce((sum, p) => sum + p.models.filter((m) => m.status === 'limited').length, 0);
1014
+ const unavailable = probed.reduce((sum, p) => sum + p.models.filter((m) => m.status === 'unavailable').length, 0);
1015
+ // ── Reconcile the LISTING with the ROUTER (Models-page audit) ─────────────
1016
+ // Everything above answers "does the provider LIST this model?" — and for
1017
+ // OpenRouter that is 400+ ids stamped with the PROVIDER's rate-limit status,
1018
+ // so hundreds of models read "Available" that the router can never pick (the
1019
+ // registry holds 443 OpenRouter entries with 0 verified). Observed live: the
1020
+ // page said 509 available while the agent routed 87% of a day's calls to one
1021
+ // model. Two truths about the same models, on the same screen, with nothing
1022
+ // connecting them — which is what made the page untrustworthy.
1023
+ //
1024
+ // The fix is to ask the REGISTRY, through the SAME predicate routing uses
1025
+ // (`isUsable`). Duplicating the staleness/park rules here would recreate the
1026
+ // exact divergence this is closing.
1027
+ const registry = getModelRegistry();
1028
+ const now = Date.now();
1029
+ let routable = 0;
1030
+ let registryTotal = 0;
1031
+ let registryVerified = 0;
1032
+ for (const provider of registry.getTrackedProviders()) {
1033
+ for (const entry of registry.getAllModelsForProvider(provider)) {
1034
+ registryTotal += 1;
1035
+ if (entry.status === 'verified')
1036
+ registryVerified += 1;
1037
+ }
1038
+ }
1039
+ const providers = probed.map((p) => {
1040
+ let providerRoutable = 0;
1041
+ const models = p.models.map((m) => {
1042
+ const entry = registry.getEntry(p.provider, m.id);
1043
+ const usable = entry ? registry.isUsable(p.provider, m.id, now) : false;
1044
+ if (usable) {
1045
+ providerRoutable += 1;
1046
+ routable += 1;
1047
+ }
1048
+ return {
1049
+ ...m,
1050
+ /** The registry's own verdict for this exact pair. */
1051
+ registryStatus: entry?.status,
1052
+ /** True only when the ROUTER would use it right now. */
1053
+ routable: usable,
1054
+ // WHY each unusable verdict is unusable — the raw learned reason, so the
1055
+ // cell can distinguish "this id does not exist on the endpoint" from
1056
+ // "your key cannot use it". Without it the page could only say
1057
+ // "unavailable", which is the ambiguity that made "requires purchase"
1058
+ // tempting and wrong.
1059
+ registryError: entry?.lastError,
1060
+ /** A retired provider × model pair (the id is not served there). */
1061
+ registryDead: entry?.deadPair === true,
1062
+ // Cost/entitlement, LABELLED never filtered — a user who just bought
1063
+ // credits must still see the models they paid for.
1064
+ entitlement: classifyModelEntitlement(p.provider, m.id),
1065
+ // Parked/quota state lived only in the registry payload, so the per-model
1066
+ // health cells could never render it — the type and the chips existed but
1067
+ // nothing populated them on this path.
1068
+ parked: entry ? entry.quotaParkedUntil > now : false,
1069
+ resetsInMs: entry?.resetsInMs ?? 0,
1070
+ };
1071
+ });
1072
+ return { ...p, models, routable: providerRoutable };
1073
+ });
1074
+ return {
1075
+ providers,
1076
+ lastChecked: now,
1077
+ totalModels,
1078
+ available,
1079
+ limited,
1080
+ unavailable,
1081
+ routable,
1082
+ registryTotal,
1083
+ registryVerified,
1084
+ };
967
1085
  }
968
1086
  /** Check local Ollama provider — no rate limits to parse */
969
1087
  async function checkLocalProvider() {
@@ -2269,6 +2387,21 @@ let whatsappPairing = new WhatsAppPairingManager();
2269
2387
  // P4 — chat sessions persist through ~/.nuvira/memory/chat-sessions.json so the
2270
2388
  // sidebar can resume any past conversation after a dashboard restart.
2271
2389
  let chatConsole = new ChatConsole({ persistPath: join(MEMORY_DIR, 'chat-sessions.json') });
2390
+ /**
2391
+ * The dashboard's half of the deferred retry loop (one per process).
2392
+ *
2393
+ * Reads `chatConsole` lazily through its closures, so the test hook that swaps
2394
+ * the console keeps working: a broker captured at module load would freeze the
2395
+ * original console and retry into a session nobody is looking at.
2396
+ */
2397
+ const chatRetryBroker = new ChatRetryBroker({
2398
+ // `recordTurn: false` — the broker writes the retry outcome itself, once,
2399
+ // with the header that explains an answer arriving unprompted.
2400
+ answer: (sessionId, text) => chatConsole.answer(sessionId, text, { recordTurn: false }),
2401
+ isBusy: (sessionId) => chatConsole.isBusy(sessionId),
2402
+ notify: (event) => broadcastChatRetry(event),
2403
+ persistTurn: (sessionId, user, assistant) => chatConsole.appendTurns(sessionId, user, assistant),
2404
+ });
2272
2405
  // Phase 4 (AGENTIC_CAPABILITY_ASSESSMENT Addendum v4) — the chat console's
2273
2406
  // loop turns record their engine + per-tool telemetry into the DAG store (a
2274
2407
  // local hook, never a cross-module import: chat-console must not know about
@@ -2334,6 +2467,14 @@ export function setChatConsoleForTest(console) {
2334
2467
  };
2335
2468
  chatConsole = console;
2336
2469
  }
2470
+ /**
2471
+ * Test hook: run one deferred-retry drain immediately, instead of waiting for
2472
+ * the 15s tick. Mirrors `setChatConsoleForTest` — the seam a real-HTTP test
2473
+ * needs to observe a retry that is otherwise hours of wall-clock away.
2474
+ */
2475
+ export async function runChatRetryDrainForTest() {
2476
+ await chatRetryBroker.drain();
2477
+ }
2337
2478
  /**
2338
2479
  * The LIVE server handle, kept module-level so the Shutdown route can close
2339
2480
  * the listeners (primary + IPv6 twin) before exiting — a dashboard stopped
@@ -2358,6 +2499,9 @@ let dashboardShutdownAction = () => {
2358
2499
  }
2359
2500
  catch { /* best-effort */ }
2360
2501
  }
2502
+ // The queue itself is PERSISTED and deliberately left alone: a restart
2503
+ // resumes it (the normal case for a quota wait). Only the timer stops.
2504
+ disarmChatRetryDrain();
2361
2505
  process.exit(0);
2362
2506
  }, 150);
2363
2507
  };
@@ -5218,11 +5362,30 @@ function handleRequest(req, res) {
5218
5362
  // stream closes): for an ABORTED fetch the response never completes, so
5219
5363
  // writableEnded stays false and the turn is cancelled; after a normal
5220
5364
  // completion writableEnded is true and this is a no-op.
5365
+ // A pending retry offer is answered HERE, before any turn runs: the reply
5366
+ // belongs to the offer, not to the composer's next request. A message that
5367
+ // is neither yes nor no falls through to normal handling.
5368
+ const retryReply = chatRetryBroker.consumeAnswer(sessionId, message);
5369
+ if (retryReply) {
5370
+ writeJson(res, 200, {
5371
+ ok: true,
5372
+ sessionId,
5373
+ content: retryReply,
5374
+ followups: [],
5375
+ provider: null,
5376
+ model: null,
5377
+ generationFailed: false,
5378
+ });
5379
+ return;
5380
+ }
5221
5381
  const onResClose = () => {
5222
5382
  if (!res.writableEnded)
5223
5383
  chatConsole.abort(sessionId);
5224
5384
  };
5225
5385
  res.on('close', onResClose);
5386
+ // Mark the failover log BEFORE the turn so a failure can report which
5387
+ // models were actually tried (same contract as the gateway).
5388
+ const attemptMark = markFailoverAttempts();
5226
5389
  const result = await chatConsole.answer(sessionId, message, { provider, model, projectContext, projectPath, attachments });
5227
5390
  // The response is written below — remove the disconnect listener so a
5228
5391
  // post-completion close can never touch the console again.
@@ -5234,14 +5397,39 @@ function handleRequest(req, res) {
5234
5397
  });
5235
5398
  return;
5236
5399
  }
5400
+ // A failed generation gets the SAME treatment as the gateway: report what
5401
+ // was tried, offer to keep checking, and queue the ask so the offer is
5402
+ // enforced (see ChatRetryBroker). Without this the console was a dead end
5403
+ // — the reader had to wait and re-send the message themselves.
5404
+ let content = result.content ?? '';
5405
+ // Did the offer we are about to send get a mechanism behind it? The client
5406
+ // uses this to stop offering "↻ Retry" for a turn the server is already
5407
+ // retrying on its own — a manual re-send on top of the queued one would
5408
+ // run the same ask twice.
5409
+ let retryQueued = false;
5410
+ if (result.generationFailed === true) {
5411
+ const report = chatRetryBroker.onFailure(sessionId, message, attemptMark);
5412
+ if (report) {
5413
+ content = content.trim() ? `${content}\n\n${report}` : report;
5414
+ retryQueued = true;
5415
+ // The console stored whatever the ENGINE returned (often nothing), so
5416
+ // the report the reader is about to see would vanish on reload. Record
5417
+ // the composed bubble — the same contract as the gateway's `record()`.
5418
+ chatConsole.amendLastAssistantTurn(sessionId, content);
5419
+ }
5420
+ else if (!content.trim()) {
5421
+ content = "🤖 I couldn't get an answer from the model just now — please try again in a moment.";
5422
+ }
5423
+ }
5237
5424
  writeJson(res, 200, {
5238
5425
  ok: true,
5239
5426
  sessionId,
5240
- content: result.content ?? '',
5427
+ content,
5241
5428
  followups: result.followups ?? [],
5242
5429
  provider: result.provider ?? null,
5243
5430
  model: result.model ?? null,
5244
5431
  generationFailed: result.generationFailed === true,
5432
+ retryQueued,
5245
5433
  });
5246
5434
  })();
5247
5435
  return;
@@ -5540,6 +5728,9 @@ function loadApiKeysFromConfig() {
5540
5728
  }
5541
5729
  }
5542
5730
  export function createDashboardServer(opts) {
5731
+ // Deferred retries for this dashboard's own chat sessions. Armed here (not
5732
+ // per SSE client): a promised retry must run whether or not the tab is open.
5733
+ armChatRetryDrain();
5543
5734
  // Bind values are resolved at CALL time: explicit override → env var →
5544
5735
  // import-time default. (PORT/HOST above are module constants, so the CLI's
5545
5736
  // `dashboard --port X` can NOT rely on setting BUFF_DASHBOARD_PORT after