agent-nuvira 3.3.9 → 3.3.11

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 (153) hide show
  1. package/dist/agents/orchestrator.d.ts.map +1 -1
  2. package/dist/agents/orchestrator.js +6 -0
  3. package/dist/agents/orchestrator.js.map +1 -1
  4. package/dist/cli/chat.d.ts.map +1 -1
  5. package/dist/cli/chat.js +27 -17
  6. package/dist/cli/chat.js.map +1 -1
  7. package/dist/cli/config.d.ts +28 -0
  8. package/dist/cli/config.d.ts.map +1 -1
  9. package/dist/cli/config.js +155 -0
  10. package/dist/cli/config.js.map +1 -1
  11. package/dist/cli/eval.d.ts +8 -0
  12. package/dist/cli/eval.d.ts.map +1 -1
  13. package/dist/cli/eval.js +64 -0
  14. package/dist/cli/eval.js.map +1 -1
  15. package/dist/cli/loop-executor.d.ts +13 -0
  16. package/dist/cli/loop-executor.d.ts.map +1 -1
  17. package/dist/cli/loop-executor.js +22 -18
  18. package/dist/cli/loop-executor.js.map +1 -1
  19. package/dist/config/capability-mode.d.ts +122 -0
  20. package/dist/config/capability-mode.d.ts.map +1 -0
  21. package/dist/config/capability-mode.js +132 -0
  22. package/dist/config/capability-mode.js.map +1 -0
  23. package/dist/config/limits.d.ts +47 -0
  24. package/dist/config/limits.d.ts.map +1 -0
  25. package/dist/config/limits.js +64 -0
  26. package/dist/config/limits.js.map +1 -0
  27. package/dist/config/process-env.d.ts.map +1 -1
  28. package/dist/config/process-env.js +44 -0
  29. package/dist/config/process-env.js.map +1 -1
  30. package/dist/config/types.d.ts +77 -0
  31. package/dist/config/types.d.ts.map +1 -1
  32. package/dist/config/work-digest.d.ts +46 -0
  33. package/dist/config/work-digest.d.ts.map +1 -0
  34. package/dist/config/work-digest.js +80 -0
  35. package/dist/config/work-digest.js.map +1 -0
  36. package/dist/gateway/inbound-media.d.ts +18 -0
  37. package/dist/gateway/inbound-media.d.ts.map +1 -1
  38. package/dist/gateway/inbound-media.js +96 -2
  39. package/dist/gateway/inbound-media.js.map +1 -1
  40. package/dist/inference/anthropic-adapter.d.ts +6 -0
  41. package/dist/inference/anthropic-adapter.d.ts.map +1 -1
  42. package/dist/inference/anthropic-adapter.js +79 -8
  43. package/dist/inference/anthropic-adapter.js.map +1 -1
  44. package/dist/inference/gemini-adapter.d.ts +6 -0
  45. package/dist/inference/gemini-adapter.d.ts.map +1 -1
  46. package/dist/inference/gemini-adapter.js +73 -8
  47. package/dist/inference/gemini-adapter.js.map +1 -1
  48. package/dist/inference/groq-adapter.d.ts +3 -0
  49. package/dist/inference/groq-adapter.d.ts.map +1 -1
  50. package/dist/inference/groq-adapter.js +83 -40
  51. package/dist/inference/groq-adapter.js.map +1 -1
  52. package/dist/inference/interface.d.ts +7 -0
  53. package/dist/inference/interface.d.ts.map +1 -1
  54. package/dist/inference/model-probe.d.ts +17 -0
  55. package/dist/inference/model-probe.d.ts.map +1 -1
  56. package/dist/inference/model-probe.js +58 -1
  57. package/dist/inference/model-probe.js.map +1 -1
  58. package/dist/inference/model-validator.d.ts +16 -1
  59. package/dist/inference/model-validator.d.ts.map +1 -1
  60. package/dist/inference/model-validator.js +83 -2
  61. package/dist/inference/model-validator.js.map +1 -1
  62. package/dist/inference/nim-adapter.js +4 -4
  63. package/dist/inference/nim-adapter.js.map +1 -1
  64. package/dist/inference/openai-compat-adapter.d.ts +9 -0
  65. package/dist/inference/openai-compat-adapter.d.ts.map +1 -1
  66. package/dist/inference/openai-compat-adapter.js +101 -51
  67. package/dist/inference/openai-compat-adapter.js.map +1 -1
  68. package/dist/inference/openrouter-adapter.d.ts +3 -0
  69. package/dist/inference/openrouter-adapter.d.ts.map +1 -1
  70. package/dist/inference/openrouter-adapter.js +134 -57
  71. package/dist/inference/openrouter-adapter.js.map +1 -1
  72. package/dist/inference/reasoning-effort.d.ts +130 -0
  73. package/dist/inference/reasoning-effort.d.ts.map +1 -0
  74. package/dist/inference/reasoning-effort.js +237 -0
  75. package/dist/inference/reasoning-effort.js.map +1 -0
  76. package/dist/inference/route-resolver.d.ts +7 -0
  77. package/dist/inference/route-resolver.d.ts.map +1 -1
  78. package/dist/inference/route-resolver.js +2 -1
  79. package/dist/inference/route-resolver.js.map +1 -1
  80. package/dist/inference/sse.d.ts +6 -0
  81. package/dist/inference/sse.d.ts.map +1 -1
  82. package/dist/inference/sse.js +1 -0
  83. package/dist/inference/sse.js.map +1 -1
  84. package/dist/inference/tools.d.ts +13 -0
  85. package/dist/inference/tools.d.ts.map +1 -1
  86. package/dist/inference/tools.js +20 -2
  87. package/dist/inference/tools.js.map +1 -1
  88. package/dist/learning/autonomy-policy.d.ts +8 -0
  89. package/dist/learning/autonomy-policy.d.ts.map +1 -1
  90. package/dist/learning/autonomy-policy.js +34 -0
  91. package/dist/learning/autonomy-policy.js.map +1 -1
  92. package/dist/learning/capability-parity.d.ts +108 -0
  93. package/dist/learning/capability-parity.d.ts.map +1 -0
  94. package/dist/learning/capability-parity.js +154 -0
  95. package/dist/learning/capability-parity.js.map +1 -0
  96. package/dist/learning/cost-tracker.d.ts +28 -4
  97. package/dist/learning/cost-tracker.d.ts.map +1 -1
  98. package/dist/learning/cost-tracker.js +58 -8
  99. package/dist/learning/cost-tracker.js.map +1 -1
  100. package/dist/learning/eval-framework.d.ts +17 -0
  101. package/dist/learning/eval-framework.d.ts.map +1 -1
  102. package/dist/learning/eval-framework.js +63 -2
  103. package/dist/learning/eval-framework.js.map +1 -1
  104. package/dist/learning/model-capability.d.ts +94 -0
  105. package/dist/learning/model-capability.d.ts.map +1 -0
  106. package/dist/learning/model-capability.js +172 -0
  107. package/dist/learning/model-capability.js.map +1 -0
  108. package/dist/learning/model-registry.d.ts +33 -0
  109. package/dist/learning/model-registry.d.ts.map +1 -1
  110. package/dist/learning/model-registry.js +79 -0
  111. package/dist/learning/model-registry.js.map +1 -1
  112. package/dist/learning/reasoning-trace.d.ts +1 -1
  113. package/dist/learning/reasoning-trace.d.ts.map +1 -1
  114. package/dist/learning/reasoning-trace.js.map +1 -1
  115. package/dist/learning/resolve-options.d.ts.map +1 -1
  116. package/dist/learning/resolve-options.js +16 -3
  117. package/dist/learning/resolve-options.js.map +1 -1
  118. package/dist/learning/run-trace.d.ts +68 -0
  119. package/dist/learning/run-trace.d.ts.map +1 -1
  120. package/dist/learning/run-trace.js +101 -0
  121. package/dist/learning/run-trace.js.map +1 -1
  122. package/dist/tools/loop-skill-hint.d.ts +27 -0
  123. package/dist/tools/loop-skill-hint.d.ts.map +1 -1
  124. package/dist/tools/loop-skill-hint.js +189 -5
  125. package/dist/tools/loop-skill-hint.js.map +1 -1
  126. package/dist/tools/read-extract.d.ts +9 -3
  127. package/dist/tools/read-extract.d.ts.map +1 -1
  128. package/dist/tools/read-extract.js +70 -25
  129. package/dist/tools/read-extract.js.map +1 -1
  130. package/dist/tools/tool-loop.d.ts +120 -0
  131. package/dist/tools/tool-loop.d.ts.map +1 -1
  132. package/dist/tools/tool-loop.js +447 -5
  133. package/dist/tools/tool-loop.js.map +1 -1
  134. package/dist/web-dashboard/attachment-extract.d.ts +8 -1
  135. package/dist/web-dashboard/attachment-extract.d.ts.map +1 -1
  136. package/dist/web-dashboard/attachment-extract.js +14 -3
  137. package/dist/web-dashboard/attachment-extract.js.map +1 -1
  138. package/dist/web-dashboard/server.d.ts.map +1 -1
  139. package/dist/web-dashboard/server.js +160 -21
  140. package/dist/web-dashboard/server.js.map +1 -1
  141. package/dist/web-dashboard/src/types.d.ts +26 -0
  142. package/dist/web-dashboard/src/types.d.ts.map +1 -1
  143. package/dist/web-dashboard/workspace-guard.d.ts.map +1 -1
  144. package/dist/web-dashboard/workspace-guard.js +44 -4
  145. package/dist/web-dashboard/workspace-guard.js.map +1 -1
  146. package/package.json +1 -1
  147. package/src/web-dashboard/public/assets/index-BdKf5Xw2.js +207 -0
  148. package/src/web-dashboard/public/assets/index-BdKf5Xw2.js.map +1 -0
  149. package/src/web-dashboard/public/assets/index-C9eBskc9.css +1 -0
  150. package/src/web-dashboard/public/index.html +2 -2
  151. package/src/web-dashboard/public/assets/index-Beportyl.js +0 -207
  152. package/src/web-dashboard/public/assets/index-Beportyl.js.map +0 -1
  153. package/src/web-dashboard/public/assets/index-gtQyg9nm.css +0 -1
@@ -28,7 +28,7 @@ import { getAutoRouter } from '../learning/auto-router.js';
28
28
  import { readRecallHits } from '../context/session-recall.js';
29
29
  import { getRouterPromotion } from '../learning/router-promotion.js';
30
30
  import { AUTH_CLEAR_THRESHOLD } from '../learning/key-hygiene.js';
31
- import { ACTION_LOG_FILENAME, aggregateActionTelemetry, readActionTelemetryFile, getModelRegistry, } from '../learning/model-registry.js';
31
+ import { ACTION_LOG_FILENAME, DEFAULT_STALE_MS, aggregateActionTelemetry, readActionTelemetryFile, getModelRegistry, } from '../learning/model-registry.js';
32
32
  import { classifyModelEntitlement } from '../inference/model-entitlement.js';
33
33
  import { listUnattendedJobs } from '../learning/unattended-job.js';
34
34
  import { AdminSessions, countAdminRoleUsers, isAdminConfigured, listAdminUsers, mustChangePasswordFor, removeAdminUser, roleForUser, verifyAdmin, writeAdminUser, MIN_ADMIN_PASSWORD_LENGTH, } from './src/admin-auth.js';
@@ -1212,6 +1212,48 @@ async function readModelsHealth() {
1212
1212
  registryVerified,
1213
1213
  };
1214
1214
  }
1215
+ /**
1216
+ * The ONE source of truth for the model/provider counts every page shows.
1217
+ *
1218
+ * Two pairs, deliberately distinct and always LABELLED:
1219
+ * - `tracked*` — the Model Availability Registry (what routing consults).
1220
+ * - `listed*` — the live provider probe (what each provider says it offers).
1221
+ *
1222
+ * These are different questions and their answers legitimately differ (a
1223
+ * provider lists ids the router can never use, and the registry tracks pairs
1224
+ * that no provider currently advertises). Showing them together, from here,
1225
+ * is what stops Overview / Timeline / Models from each headlining a different
1226
+ * number under one ambiguous word like "models".
1227
+ *
1228
+ * The live probe is EXPENSIVE (one HTTP call per provider), so its result is
1229
+ * cached for a minute and shared by all three pages rather than re-probed per
1230
+ * page. `listingAt` is returned so a page can say when the listing was taken.
1231
+ */
1232
+ let modelListingCache = null;
1233
+ const MODEL_LISTING_TTL_MS = 60_000;
1234
+ async function readModelCounts() {
1235
+ const registry = readModelRegistryData();
1236
+ const trackedModels = registry.total ?? 0;
1237
+ const trackedProviders = Array.isArray(registry.providers) ? registry.providers.length : 0;
1238
+ const now = Date.now();
1239
+ if (!modelListingCache || now - modelListingCache.at >= MODEL_LISTING_TTL_MS) {
1240
+ try {
1241
+ const health = await readModelsHealth();
1242
+ modelListingCache = { at: now, models: health.totalModels, providers: health.providers.length };
1243
+ }
1244
+ catch {
1245
+ // A failed probe keeps the previous listing rather than zeroing it:
1246
+ // "we could not reach the providers just now" is not "you have none".
1247
+ }
1248
+ }
1249
+ return {
1250
+ trackedModels,
1251
+ trackedProviders,
1252
+ listedModels: modelListingCache?.models ?? 0,
1253
+ listedProviders: modelListingCache?.providers ?? 0,
1254
+ listingAt: modelListingCache?.at ?? 0,
1255
+ };
1256
+ }
1215
1257
  /** Check local Ollama provider — no rate limits to parse */
1216
1258
  async function checkLocalProvider() {
1217
1259
  const result = {
@@ -1463,6 +1505,8 @@ function readJSON(filePath) {
1463
1505
  function readCostData() {
1464
1506
  const data = readJSON(join(MEMORY_DIR, 'cost-tracker.json'));
1465
1507
  if (!data?.entries) {
1508
+ // The empty state deliberately omits `recent` entirely (the dashboard reads
1509
+ // a missing list as "nothing recorded"), matching the other zero counters.
1466
1510
  return { totalRequests: 0, totalCost: 0, byProvider: {}, byModel: {} };
1467
1511
  }
1468
1512
  const entries = data.entries;
@@ -1471,10 +1515,17 @@ function readCostData() {
1471
1515
  const byProvider = {};
1472
1516
  const byModel = {};
1473
1517
  const byProviderMeasured = {};
1518
+ const byProviderReported = {};
1474
1519
  let measuredCalls = 0;
1475
1520
  let estimatedCalls = 0;
1476
1521
  let measuredCost = 0;
1477
1522
  let estimatedCost = 0;
1523
+ // Provider-REPORTED cost (costReported): the exact figure the endpoint billed
1524
+ // (e.g. OpenRouter's `usage.cost`), as opposed to a locally-priced figure. It
1525
+ // is a SUBSET of measured spend — reported cost always rides on measured
1526
+ // tokens — so the UI labels it "of which" rather than a third partition.
1527
+ let reportedCalls = 0;
1528
+ let reportedCost = 0;
1478
1529
  for (const e of entries) {
1479
1530
  const cost = typeof e.costUsd === 'number' ? e.costUsd : 0;
1480
1531
  if (e.provider)
@@ -1493,6 +1544,13 @@ function readCostData() {
1493
1544
  estimatedCalls += 1;
1494
1545
  estimatedCost += cost;
1495
1546
  }
1547
+ // Reported-cost split: the provider told us the exact bill for this call.
1548
+ if (e.costReported === true) {
1549
+ reportedCalls += 1;
1550
+ reportedCost += cost;
1551
+ if (e.provider)
1552
+ byProviderReported[e.provider] = (byProviderReported[e.provider] || 0) + cost;
1553
+ }
1496
1554
  }
1497
1555
  const recent = entries.slice(-50).reverse().map((e) => ({
1498
1556
  provider: e.provider,
@@ -1501,6 +1559,7 @@ function readCostData() {
1501
1559
  totalTokens: e.totalTokens,
1502
1560
  timestamp: e.timestamp,
1503
1561
  measured: e.measured === true,
1562
+ reported: e.costReported === true,
1504
1563
  }));
1505
1564
  return {
1506
1565
  totalRequests: entries.length,
@@ -1509,10 +1568,13 @@ function readCostData() {
1509
1568
  byProvider,
1510
1569
  byModel,
1511
1570
  byProviderMeasured,
1571
+ byProviderReported,
1512
1572
  measuredCalls,
1513
1573
  estimatedCalls,
1514
1574
  measuredCost: Math.round(measuredCost * 100000) / 100000,
1515
1575
  estimatedCost: Math.round(estimatedCost * 100000) / 100000,
1576
+ reportedCalls,
1577
+ reportedCost: Math.round(reportedCost * 100000) / 100000,
1516
1578
  recent,
1517
1579
  };
1518
1580
  }
@@ -1825,10 +1887,21 @@ function readModelRegistryData() {
1825
1887
  };
1826
1888
  }).sort((a, b) => a.provider.localeCompare(b.provider));
1827
1889
  const allModels = providers.flatMap((p) => p.models);
1890
+ // Two DIFFERENT questions, two numbers. `verifiedRoutable` = proven to work
1891
+ // (status verified, not parked); `routableNow` adds the staleness gate the
1892
+ // router itself applies (`isUsable`). They diverge the moment a proof ages
1893
+ // past 7 days, and reporting only the first under a "routable" label is what
1894
+ // let the Overview contradict the Discovery Timeline's "routable now".
1895
+ const routableNow = allModels.filter((m) => {
1896
+ const verifiedAt = Number(m.lastVerifiedAt ?? 0);
1897
+ return m.status === 'verified' && !m.parked && now - verifiedAt <= DEFAULT_STALE_MS;
1898
+ }).length;
1828
1899
  return {
1829
1900
  enabled: providers.length > 0,
1830
1901
  total: allModels.length,
1831
1902
  verified: allModels.filter((m) => m.status === 'verified' && !m.parked).length,
1903
+ /** The staleness-gated subset of `verified` — see the comment above. */
1904
+ routableNow,
1832
1905
  unverified: allModels.filter((m) => m.status === 'unverified').length,
1833
1906
  unavailable: allModels.filter((m) => m.status === 'unavailable').length,
1834
1907
  parked: allModels.filter((m) => m.parked).length,
@@ -2902,8 +2975,18 @@ function readLargeJsonBody(req) {
2902
2975
  }
2903
2976
  /** Send a JSON response (the admin routes' single writer). */
2904
2977
  function writeJson(res, status, data) {
2905
- res.writeHead(status, { 'Content-Type': 'application/json' });
2906
- res.end(JSON.stringify(data));
2978
+ // The client may have gone away (a chat turn that outlives a page reload now
2979
+ // finishes and writes its response to a socket nobody is holding). Writing
2980
+ // there is a no-op, not an error — and it must never throw into the turn.
2981
+ if (res.writableEnded || res.destroyed)
2982
+ return;
2983
+ try {
2984
+ res.writeHead(status, { 'Content-Type': 'application/json' });
2985
+ res.end(JSON.stringify(data));
2986
+ }
2987
+ catch {
2988
+ /* client disconnected mid-write — nothing to do */
2989
+ }
2907
2990
  }
2908
2991
  /**
2909
2992
  * Turn a refused process-environment value into something a user can act on.
@@ -4725,6 +4808,15 @@ function handleRequest(req, res) {
4725
4808
  });
4726
4809
  return;
4727
4810
  }
4811
+ // The SHARED model/provider counts every page (Overview, Timeline, Models)
4812
+ // renders — one endpoint, so the three cannot disagree. `tracked*` is the
4813
+ // registry (routing's truth), `listed*` is the cached live provider probe.
4814
+ if (pathname === '/api/model-counts' && req.method === 'GET') {
4815
+ readModelCounts()
4816
+ .then((counts) => writeJson(res, 200, counts))
4817
+ .catch(() => writeJson(res, 500, { error: 'Failed to read model counts' }));
4818
+ return;
4819
+ }
4728
4820
  if (pathname === '/api/model-registry') {
4729
4821
  res.writeHead(200, { 'Content-Type': 'application/json' });
4730
4822
  res.end(JSON.stringify(readModelRegistryData()));
@@ -4894,6 +4986,28 @@ function handleRequest(req, res) {
4894
4986
  res.end(JSON.stringify(readRoutingInsights()));
4895
4987
  return;
4896
4988
  }
4989
+ // ── The two size limits the composer needs BEFORE it reads a file ──────
4990
+ // The browser blocks an over-cap file client-side, so it cannot discover the
4991
+ // cap from a refusal (the file never leaves the page). This is the one place
4992
+ // it can ask, and it keeps the client and the server from disagreeing about
4993
+ // the limit — the same class of bug the Process Environment page fixes for
4994
+ // the switches. Open, like the other read paths: it exposes a number, no data.
4995
+ if (pathname === '/api/limits') {
4996
+ void (async () => {
4997
+ const { resolveExtractMaxChars, resolveAttachmentMaxBytes, DEFAULT_EXTRACT_MAX_CHARS, DEFAULT_ATTACHMENT_MAX_BYTES } = await import('../config/limits.js');
4998
+ const attachmentMaxBytes = resolveAttachmentMaxBytes();
4999
+ writeJson(res, 200, {
5000
+ attachmentMaxBytes,
5001
+ attachmentMaxKb: Math.round(attachmentMaxBytes / 1024),
5002
+ extractMaxChars: resolveExtractMaxChars(),
5003
+ defaults: {
5004
+ attachmentMaxBytes: DEFAULT_ATTACHMENT_MAX_BYTES,
5005
+ extractMaxChars: DEFAULT_EXTRACT_MAX_CHARS,
5006
+ },
5007
+ });
5008
+ })();
5009
+ return;
5010
+ }
4897
5011
  if (pathname === '/api/all') {
4898
5012
  res.writeHead(200, { 'Content-Type': 'application/json' });
4899
5013
  res.end(JSON.stringify({
@@ -6106,7 +6220,10 @@ function handleRequest(req, res) {
6106
6220
  writeJson(res, 404, { ok: false, error: 'No such session.' });
6107
6221
  return;
6108
6222
  }
6109
- writeJson(res, 200, { ok: true, session: { id, ...rec } });
6223
+ // `busy` lets a client that reloaded mid-turn know the answer is still being
6224
+ // produced, so it can keep the bubble alive and poll instead of showing a
6225
+ // finished-looking thread.
6226
+ writeJson(res, 200, { ok: true, session: { id, ...rec, busy: chatConsole.isBusy(id) } });
6110
6227
  return;
6111
6228
  }
6112
6229
  // GET /api/continuity — the stored session snapshots + the semantic recall
@@ -6332,15 +6449,20 @@ function handleRequest(req, res) {
6332
6449
  return;
6333
6450
  }
6334
6451
  }
6335
- // P4 — the dashboard's Cancel button aborts the POST fetch: the server
6336
- // sees the request close and cancels the in-flight turn (the engine
6337
- // aborts the provider request — quota is not spent on a cancelled turn).
6338
- // Guarded: after the response is written (writableEnded) this never
6339
- // fires, and chatConsole.abort is a no-op once the turn finished.
6340
- // The client-disconnect signal is res 'close' (fires when the response
6341
- // stream closes): for an ABORTED fetch the response never completes, so
6342
- // writableEnded stays false and the turn is cancelled; after a normal
6343
- // completion writableEnded is true and this is a no-op.
6452
+ // P4 (revised) — a client DISCONNECT no longer cancels the turn. It used
6453
+ // to: the server treated `res` close as an abort, which meant a page
6454
+ // reload or a closed browser tab killed the in-flight turn and discarded
6455
+ // its answer. Reloading mid-turn is common, and "my question just
6456
+ // vanished" is worse than a turn that keeps running. So the turn now runs
6457
+ // to completion and PERSISTS (chat-console records it), and a reloaded
6458
+ // client resumes the same session id and finds the answer there.
6459
+ //
6460
+ // Cancel is therefore EXPLICIT: the Cancel button calls
6461
+ // `POST /api/chat/cancel`, which invokes chatConsole.abort. There is no
6462
+ // longer any ambiguity between "the user cancelled" and "the connection
6463
+ // dropped" — they are different signals because they are now different
6464
+ // requests. The one cost: a turn whose client vanishes and is never
6465
+ // cancelled keeps spending provider quota until it finishes.
6344
6466
  // A pending retry offer is answered HERE, before any turn runs: the reply
6345
6467
  // belongs to the offer, not to the composer's next request. A message that
6346
6468
  // is neither yes nor no falls through to normal handling.
@@ -6357,11 +6479,6 @@ function handleRequest(req, res) {
6357
6479
  });
6358
6480
  return;
6359
6481
  }
6360
- const onResClose = () => {
6361
- if (!res.writableEnded)
6362
- chatConsole.abort(sessionId);
6363
- };
6364
- res.on('close', onResClose);
6365
6482
  // Mark the failover log BEFORE the turn so a failure can report which
6366
6483
  // models were actually tried (same contract as the gateway).
6367
6484
  const attemptMark = markFailoverAttempts();
@@ -6375,9 +6492,6 @@ function handleRequest(req, res) {
6375
6492
  ...(keepWorktree === undefined ? {} : { keepWorktree }),
6376
6493
  ...(resume === undefined ? {} : { resume }),
6377
6494
  });
6378
- // The response is written below — remove the disconnect listener so a
6379
- // post-completion close can never touch the console again.
6380
- res.off('close', onResClose);
6381
6495
  if (!result.ok) {
6382
6496
  writeJson(res, 400, {
6383
6497
  ok: false,
@@ -6447,6 +6561,31 @@ function handleRequest(req, res) {
6447
6561
  })();
6448
6562
  return;
6449
6563
  }
6564
+ // POST /api/chat/cancel — cancel a session's IN-FLIGHT turn { sessionId }.
6565
+ // Cancel is explicit since a dropped connection no longer means "cancel" (see
6566
+ // the chat handler): only this endpoint aborts the running turn.
6567
+ if (pathname === '/api/chat/cancel' && req.method === 'POST') {
6568
+ void (async () => {
6569
+ const session = adminSessions.validate(bearerToken(req));
6570
+ if (!session) {
6571
+ writeJson(res, 401, { ok: false, error: 'Not authenticated — log in first.' });
6572
+ return;
6573
+ }
6574
+ if (!roleCan(session.role, 'routing.operate')) {
6575
+ writeJson(res, 403, { ok: false, error: `Access denied — role '${session.role}' cannot chat with the agent (requires admin or operator).` });
6576
+ return;
6577
+ }
6578
+ const body = await readJsonBody(req);
6579
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : '';
6580
+ if (!sessionId) {
6581
+ writeJson(res, 400, { ok: false, error: 'Missing sessionId.' });
6582
+ return;
6583
+ }
6584
+ const cancelled = chatConsole.abort(sessionId);
6585
+ writeJson(res, 200, { ok: true, cancelled });
6586
+ })();
6587
+ return;
6588
+ }
6450
6589
  // POST /api/chat/reset — forget a session's history { sessionId }.
6451
6590
  if (pathname === '/api/chat/reset' && req.method === 'POST') {
6452
6591
  void (async () => {