mixdog 1.2.1 → 1.2.2

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 (233) hide show
  1. package/package.json +1 -1
  2. package/scripts/gate-local.mjs +19 -3
  3. package/scripts/model-catalog-audit.mjs +1 -15
  4. package/src/agents/reviewer/AGENT.md +3 -3
  5. package/src/agents/reviewer/agent.json +1 -1
  6. package/src/defaults/shipped-history.json +1 -0
  7. package/src/repl/slash-commands.mjs +16 -1
  8. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -4
  9. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +10 -0
  10. package/src/runtime/agent/orchestrator/mcp/client-config.mjs +36 -7
  11. package/src/runtime/agent/orchestrator/mcp/client-handshake.mjs +49 -10
  12. package/src/runtime/agent/orchestrator/mcp/client-transport.mjs +40 -3
  13. package/src/runtime/agent/orchestrator/mcp/client.mjs +230 -96
  14. package/src/runtime/agent/orchestrator/mcp/oauth-callback.mjs +103 -0
  15. package/src/runtime/agent/orchestrator/mcp/oauth-credentials.mjs +150 -0
  16. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +111 -0
  17. package/src/runtime/agent/orchestrator/mcp/oauth-probe.mjs +87 -0
  18. package/src/runtime/agent/orchestrator/mcp/oauth-provider.mjs +95 -0
  19. package/src/runtime/agent/orchestrator/mcp/oauth-request.mjs +122 -0
  20. package/src/runtime/agent/orchestrator/mcp/oauth-shared.mjs +9 -0
  21. package/src/runtime/agent/orchestrator/mcp/oauth.mjs +229 -0
  22. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +188 -29
  23. package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +2 -1
  24. package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +94 -31
  25. package/src/runtime/agent/orchestrator/providers/anthropic-error-contract.mjs +96 -0
  26. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +39 -18
  27. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +55 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +25 -21
  29. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +3 -1
  30. package/src/runtime/agent/orchestrator/providers/anthropic-midstream-recovery.mjs +18 -4
  31. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +13 -1
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-midstream.mjs +10 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-recovery.mjs +23 -9
  35. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +46 -20
  36. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +14 -9
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +73 -35
  38. package/src/runtime/agent/orchestrator/providers/anthropic-overload-fallback.mjs +69 -0
  39. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +4 -0
  40. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -1
  41. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -7
  42. package/src/runtime/agent/orchestrator/providers/antigravity-oauth-catalog.mjs +30 -9
  43. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  44. package/src/runtime/agent/orchestrator/providers/antigravity-request.mjs +30 -14
  45. package/src/runtime/agent/orchestrator/providers/antigravity-response.mjs +2 -1
  46. package/src/runtime/agent/orchestrator/providers/antigravity-retry-policy.mjs +93 -0
  47. package/src/runtime/agent/orchestrator/providers/antigravity-transport.mjs +82 -9
  48. package/src/runtime/agent/orchestrator/providers/command-code-oauth.mjs +3 -0
  49. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +38 -5
  50. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  51. package/src/runtime/agent/orchestrator/providers/cursor-wire-guards.mjs +97 -2
  52. package/src/runtime/agent/orchestrator/providers/cursor-wire-stream-bridge.mjs +102 -23
  53. package/src/runtime/agent/orchestrator/providers/cursor-wire-transport.mjs +103 -33
  54. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +29 -6
  55. package/src/runtime/agent/orchestrator/providers/cursor.mjs +18 -1
  56. package/src/runtime/agent/orchestrator/providers/effort-configuration.mjs +77 -17
  57. package/src/runtime/agent/orchestrator/providers/gemini-cache-policy.mjs +1 -1
  58. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +29 -8
  59. package/src/runtime/agent/orchestrator/providers/gemini-error-contract.mjs +191 -0
  60. package/src/runtime/agent/orchestrator/providers/gemini-family.mjs +9 -0
  61. package/src/runtime/agent/orchestrator/providers/gemini-history-signatures.mjs +9 -4
  62. package/src/runtime/agent/orchestrator/providers/gemini-response.mjs +3 -1
  63. package/src/runtime/agent/orchestrator/providers/gemini-retry-policy.mjs +180 -0
  64. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +21 -82
  65. package/src/runtime/agent/orchestrator/providers/gemini-sdk-request.mjs +4 -1
  66. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +71 -12
  67. package/src/runtime/agent/orchestrator/providers/gemini-thinking.mjs +21 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +56 -23
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +3 -0
  70. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +68 -19
  71. package/src/runtime/agent/orchestrator/providers/mimo-oauth.mjs +3 -0
  72. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +2 -1
  73. package/src/runtime/agent/orchestrator/providers/muse-code-oauth.mjs +4 -9
  74. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +4 -2
  75. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-result.mjs +22 -0
  76. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +1 -0
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-error-contract.mjs +50 -0
  78. package/src/runtime/agent/orchestrator/providers/openai-compat-response-normalization.mjs +23 -0
  79. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +16 -8
  80. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-state.mjs +3 -2
  81. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +94 -13
  82. package/src/runtime/agent/orchestrator/providers/openai-compat-retry-profile.mjs +193 -0
  83. package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +66 -2
  84. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +2 -1
  85. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +6 -0
  86. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +4 -2
  87. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +23 -14
  88. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +17 -3
  89. package/src/runtime/agent/orchestrator/providers/openai-direct-request.mjs +42 -25
  90. package/src/runtime/agent/orchestrator/providers/openai-error-contract.mjs +274 -0
  91. package/src/runtime/agent/orchestrator/providers/openai-http-sse-request.mjs +72 -32
  92. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/terminal-frames.mjs +18 -11
  93. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-outcome.mjs +1 -2
  94. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-stamps.mjs +2 -2
  95. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-state.mjs +2 -3
  96. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +2 -2
  97. package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +1 -3
  98. package/src/runtime/agent/orchestrator/providers/openai-oauth-dispatch.mjs +34 -3
  99. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +5 -1
  100. package/src/runtime/agent/orchestrator/providers/openai-oauth-tokens.mjs +22 -2
  101. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +5 -0
  102. package/src/runtime/agent/orchestrator/providers/openai-responses-input.mjs +5 -9
  103. package/src/runtime/agent/orchestrator/providers/openai-retry-policy.mjs +9 -0
  104. package/src/runtime/agent/orchestrator/providers/openai-ws-handshake.mjs +16 -8
  105. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/attempt-context.mjs +1 -2
  106. package/src/runtime/agent/orchestrator/providers/openai-ws-send-attempts/policy.mjs +25 -14
  107. package/src/runtime/agent/orchestrator/providers/openai-ws-terminal.mjs +18 -11
  108. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +31 -20
  109. package/src/runtime/agent/orchestrator/providers/provider-error-kind.mjs +581 -0
  110. package/src/runtime/agent/orchestrator/providers/retry-backoff.mjs +46 -7
  111. package/src/runtime/agent/orchestrator/providers/retry-classification.mjs +118 -219
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -0
  113. package/src/runtime/agent/orchestrator/providers/retry-executor.mjs +148 -45
  114. package/src/runtime/agent/orchestrator/providers/retry-midstream.mjs +81 -16
  115. package/src/runtime/agent/orchestrator/runtime-core/config-helpers.mjs +3 -0
  116. package/src/runtime/agent/orchestrator/runtime-core/goal-reminder.mjs +0 -18
  117. package/src/runtime/agent/orchestrator/runtime-core/model-capabilities.mjs +57 -28
  118. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +19 -16
  119. package/src/runtime/agent/orchestrator/runtime-core/plugin-mcp.mjs +1 -97
  120. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +3 -1
  121. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +4 -0
  122. package/src/runtime/agent/orchestrator/session/loop/step-auto-effort.mjs +102 -18
  123. package/src/runtime/agent/orchestrator/session/loop/tool-phase.mjs +3 -1
  124. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +30 -2
  125. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +11 -2
  126. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +41 -21
  127. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +5 -10
  128. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +18 -7
  130. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +155 -29
  131. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +6 -3
  132. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +16 -0
  133. package/src/runtime/agent/orchestrator/session/store-agent-worker-rows.mjs +22 -10
  134. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +5 -0
  136. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +3 -0
  137. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +20 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
  139. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/deadline-plan.mjs +12 -15
  140. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  141. package/src/runtime/memory/lib/pg/process.mjs +34 -24
  142. package/src/runtime/memory/lib/pg/supervisor.mjs +16 -13
  143. package/src/runtime/office/README.md +1 -1
  144. package/src/runtime/office/shared/values.mjs +0 -5
  145. package/src/runtime/shared/background-tasks.mjs +92 -14
  146. package/src/runtime/shared/err-text.mjs +25 -0
  147. package/src/runtime/shared/error-presentation.mjs +159 -19
  148. package/src/runtime/shared/llm/anthropic-betas.mjs +4 -3
  149. package/src/runtime/shared/llm/anthropic-effort.mjs +123 -134
  150. package/src/runtime/shared/llm/anthropic-thinking-contract.mjs +50 -14
  151. package/src/runtime/shared/llm/model-catalog.mjs +53 -40
  152. package/src/runtime/shared/llm/openai-daybreak.mjs +4 -16
  153. package/src/runtime/shared/llm/provider-catalog-registry.mjs +11 -0
  154. package/src/runtime/shared/llm/provider-model-identities.mjs +13 -4
  155. package/src/runtime/shared/llm/usage-rollup.mjs +0 -1
  156. package/src/runtime/shared/model-display.mjs +13 -0
  157. package/src/runtime/shared/quota-resume-store.mjs +11 -0
  158. package/src/runtime/shared/session-baseline-store.mjs +960 -0
  159. package/src/runtime/shared/statusline/statusline-format.mjs +4 -4
  160. package/src/runtime/shared/task-notification-envelope.mjs +2 -2
  161. package/src/runtime/shared/turn-changes.mjs +447 -219
  162. package/src/runtime/shared/turn-snapshot-store.mjs +182 -1
  163. package/src/runtime/shared/turn-snapshot.mjs +170 -48
  164. package/src/runtime/shared/turn-worktree-snapshot.mjs +135 -46
  165. package/src/runtime/shared/worktrees/contract.mjs +0 -24
  166. package/src/runtime/shared/worktrees/test-support.mjs +19 -4
  167. package/src/session-runtime/boot/apis.mjs +6 -0
  168. package/src/session-runtime/boot/infrastructure.mjs +6 -0
  169. package/src/session-runtime/lifecycle/session-catalog.mjs +2 -0
  170. package/src/session-runtime/lifecycle/teardown.mjs +1 -1
  171. package/src/session-runtime/mcp-glue/connect.mjs +29 -9
  172. package/src/session-runtime/mcp-glue/server-catalog.mjs +20 -3
  173. package/src/session-runtime/mcp-glue.mjs +3 -0
  174. package/src/session-runtime/mcp-oauth-login.mjs +174 -0
  175. package/src/session-runtime/model-route/model-switch-target.mjs +34 -0
  176. package/src/session-runtime/model-route/set-route.mjs +83 -15
  177. package/src/session-runtime/model-route/web-search-route.mjs +1 -1
  178. package/src/session-runtime/native-web-search/routes.mjs +22 -3
  179. package/src/session-runtime/provider-models/model-meta.mjs +12 -3
  180. package/src/session-runtime/quick-model-rows/web-search-rows.mjs +13 -8
  181. package/src/session-runtime/quick-web-search-models.mjs +13 -38
  182. package/src/session-runtime/resource-extension-sync.mjs +4 -0
  183. package/src/session-runtime/resource-mcp-api.mjs +98 -2
  184. package/src/session-runtime/runtime-review-api.mjs +13 -0
  185. package/src/session-runtime/services/agent-tool/job-task-reconcile.mjs +13 -1
  186. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +7 -2
  187. package/src/session-runtime/services/agent-tool/send-flow.mjs +22 -4
  188. package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +2 -0
  189. package/src/session-runtime/services/agent-tool/spawn-flow/spawn-run.mjs +22 -5
  190. package/src/session-runtime/services/agent-tool/worker-index/row-liveness.mjs +8 -1
  191. package/src/session-runtime/services/agent-tool/worker-index/row-shape.mjs +2 -2
  192. package/src/session-runtime/services/agent-tool/worker-rows.mjs +17 -0
  193. package/src/session-runtime/settings-compaction-api.mjs +1 -0
  194. package/src/standalone/daemon-drain-policy.mjs +8 -0
  195. package/src/standalone/daemon-telemetry.mjs +55 -0
  196. package/src/standalone/daemon.mjs +3 -7
  197. package/src/standalone/session-protocol.mjs +7 -0
  198. package/src/standalone/session-service/agent-tree/agent-cancel.mjs +33 -2
  199. package/src/standalone/session-service/agent-tree/agent-turns.mjs +26 -10
  200. package/src/standalone/session-service/agent-tree.mjs +12 -0
  201. package/src/standalone/session-service/quota-resume.mjs +8 -0
  202. package/src/standalone/session-service/retention/busy.mjs +15 -1
  203. package/src/standalone/session-service-api.mjs +5 -0
  204. package/src/standalone/session-service.mjs +1 -0
  205. package/src/tui/app/app-view.jsx +1 -0
  206. package/src/tui/app/create-app-pickers.mjs +4 -0
  207. package/src/tui/app/extension-pickers/mcp-login.mjs +107 -0
  208. package/src/tui/app/extension-pickers/mcp-servers-picker.mjs +2 -1
  209. package/src/tui/app/extension-pickers.mjs +2 -1
  210. package/src/tui/app/model-options.mjs +11 -16
  211. package/src/tui/app/slash-commands.mjs +7 -2
  212. package/src/tui/app/slash-dispatch/panel-commands.mjs +16 -1
  213. package/src/tui/components/StatusLine.jsx +2 -0
  214. package/src/tui/components/status-line/local-line.mjs +5 -2
  215. package/src/tui/components/status-line/use-statusline-text.mjs +7 -1
  216. package/src/tui/dist/index.mjs +816 -43
  217. package/src/tui/session/quota-resume.mjs +46 -14
  218. package/src/tui/session/session-api/commands.mjs +33 -24
  219. package/src/tui/session/session-api/extensions.mjs +22 -0
  220. package/src/tui/session/session-api/integrations.mjs +7 -0
  221. package/src/tui/session/session-api/lifecycle.mjs +5 -1
  222. package/src/tui/session/session-api/routes.mjs +71 -7
  223. package/src/tui/session/session-api/settings.mjs +5 -14
  224. package/src/tui/session/session-flow/drain.mjs +7 -1
  225. package/src/tui/session/session-flow/failed-turn-rewind.mjs +10 -4
  226. package/src/tui/session/turn.mjs +38 -15
  227. package/src/tui/statusline-ansi-bridge.mjs +1 -1
  228. package/src/ui/statusline-agents.mjs +15 -4
  229. package/src/ui/statusline.mjs +7 -3
  230. package/src/ui/streaming-markdown-heal.mjs +34 -3
  231. package/src/vendor/statusline/src/gateway/claude-current.mjs +14 -4
  232. package/src/runtime/memory/lib/memory-session-merge.mjs +0 -32
  233. package/src/runtime/shared/llm/usage-session-history.mjs +0 -266
@@ -12,17 +12,16 @@ import {
12
12
  existsSync,
13
13
  mkdirSync,
14
14
  mkdtempSync,
15
- readdirSync,
16
15
  readFileSync,
17
16
  rmdirSync,
18
17
  rmSync,
19
- statSync,
20
18
  symlinkSync,
21
19
  unlinkSync,
22
20
  writeFileSync,
23
21
  } from 'node:fs';
22
+ import { readdir, readFile, rm, stat } from 'node:fs/promises';
24
23
  import { join } from 'node:path';
25
- import { spawn, spawnSync } from 'node:child_process';
24
+ import { execFile, spawn, spawnSync } from 'node:child_process';
26
25
  import { createConnection, createServer } from 'node:net';
27
26
  import { isPidAlive } from '../../../shared/pid-liveness.mjs';
28
27
 
@@ -607,22 +606,36 @@ export function classifyOrphanTempPostmaster({
607
606
  return Number(uptimeSec) >= Math.max(60, Number(minUptimeSec) || ORPHAN_TEMP_PG_MIN_UPTIME_SEC);
608
607
  }
609
608
 
610
- function tempPostmasterOwnerAlive(args) {
609
+ async function tempPostmasterOwnerAlive(args) {
611
610
  const rawDataDir = pgDataDirFromArgs(args);
612
611
  if (!rawDataDir) return false;
613
612
  const dataRoot = join(rawDataDir, '..');
614
613
  for (const name of ['memory-runtime-owner.json', 'daemon-owner.json']) {
615
614
  try {
616
- const owner = JSON.parse(readFileSync(join(dataRoot, name), 'utf8'));
615
+ const owner = JSON.parse(await readFile(join(dataRoot, name), 'utf8'));
617
616
  if (Number(owner?.pid) > 0 && isPidAlive(Number(owner.pid))) return true;
618
617
  } catch {}
619
618
  }
620
619
  return false;
621
620
  }
622
621
 
623
- function listPostmasterRows() {
622
+ // Async child run: stdout on exit 0, null on any failure/timeout (the old
623
+ // spawnSync `status !== 0` contract). Never blocks the event loop.
624
+ function execFileText(file, args, options) {
625
+ return new Promise((resolve) => {
626
+ try {
627
+ execFile(file, args, { encoding: 'utf8', ...options }, (error, stdout) => {
628
+ resolve(error ? null : String(stdout || ''));
629
+ });
630
+ } catch {
631
+ resolve(null);
632
+ }
633
+ });
634
+ }
635
+
636
+ async function listPostmasterRows() {
624
637
  if (process.platform === 'win32') {
625
- const result = spawnSync(
638
+ const stdout = await execFileText(
626
639
  'powershell',
627
640
  [
628
641
  '-NoProfile',
@@ -632,10 +645,10 @@ function listPostmasterRows() {
632
645
  'started = [int](New-TimeSpan -Start $_.CreationDate -End (Get-Date)).TotalSeconds; ' +
633
646
  'args = $_.CommandLine } }',
634
647
  ],
635
- { encoding: 'utf8', timeout: 15_000, windowsHide: true }
648
+ { timeout: 15_000, windowsHide: true }
636
649
  );
637
- if (result.status !== 0) return [];
638
- return String(result.stdout || '')
650
+ if (stdout === null) return [];
651
+ return stdout
639
652
  .split(/\r?\n/)
640
653
  .filter(Boolean)
641
654
  .flatMap((line) => {
@@ -647,12 +660,9 @@ function listPostmasterRows() {
647
660
  }
648
661
  });
649
662
  }
650
- const result = spawnSync('ps', ['-eo', 'pid=,etimes=,args='], {
651
- encoding: 'utf8',
652
- timeout: 15_000,
653
- });
654
- if (result.status !== 0) return [];
655
- return String(result.stdout || '')
663
+ const stdout = await execFileText('ps', ['-eo', 'pid=,etimes=,args='], { timeout: 15_000 });
664
+ if (stdout === null) return [];
665
+ return stdout
656
666
  .split(/\r?\n/)
657
667
  .flatMap((line) => {
658
668
  const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*postgres.*)$/);
@@ -679,11 +689,11 @@ function pristineRootForArgs(args, tempRoot) {
679
689
  // (observed: 87 roots, 6.1GB, alongside 57 live postmasters). Roots a live
680
690
  // postmaster still owns are kept, and the same uptime margin that protects a
681
691
  // running harness from the process sweep protects its directory here.
682
- function sweepAbandonedPristineRoots(tempRoot, liveRoots, minAgeMs) {
692
+ async function sweepAbandonedPristineRoots(tempRoot, liveRoots, minAgeMs) {
683
693
  let removed = 0;
684
694
  let entries;
685
695
  try {
686
- entries = readdirSync(tempRoot, { withFileTypes: true });
696
+ entries = await readdir(tempRoot, { withFileTypes: true });
687
697
  } catch {
688
698
  return 0;
689
699
  }
@@ -693,12 +703,12 @@ function sweepAbandonedPristineRoots(tempRoot, liveRoots, minAgeMs) {
693
703
  const full = join(tempRoot, entry.name);
694
704
  if (liveRoots.has(normalizePathForMatch(full))) continue;
695
705
  try {
696
- if (statSync(full).mtimeMs > cutoff) continue;
706
+ if ((await stat(full)).mtimeMs > cutoff) continue;
697
707
  } catch {
698
708
  continue;
699
709
  }
700
710
  try {
701
- rmSync(full, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
711
+ await rm(full, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
702
712
  removed += 1;
703
713
  } catch {
704
714
  /* a process this sweep did not reap still holds it */
@@ -709,18 +719,18 @@ function sweepAbandonedPristineRoots(tempRoot, liveRoots, minAgeMs) {
709
719
 
710
720
  /** Best-effort machine hygiene: reap orphan temp postmasters, then drop the
711
721
  * throwaway roots no live postmaster owns. Runs periodically, not once. */
712
- export function sweepOrphanTempPostmasters({ tempRoot, minUptimeSec } = {}) {
722
+ export async function sweepOrphanTempPostmasters({ tempRoot, minUptimeSec } = {}) {
713
723
  let reaped = 0;
714
724
  try {
715
725
  const root = tempRoot || process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp';
716
726
  const liveRoots = new Set();
717
- for (const row of listPostmasterRows()) {
727
+ for (const row of await listPostmasterRows()) {
718
728
  if (!row.pid || row.pid === process.pid) continue;
719
729
  if (
720
730
  !classifyOrphanTempPostmaster({
721
731
  ...row,
722
732
  tempRoot: root,
723
- ownerAlive: tempPostmasterOwnerAlive(row.args),
733
+ ownerAlive: await tempPostmasterOwnerAlive(row.args),
724
734
  minUptimeSec,
725
735
  })
726
736
  ) {
@@ -737,7 +747,7 @@ export function sweepOrphanTempPostmasters({ tempRoot, minUptimeSec } = {}) {
737
747
  }
738
748
  }
739
749
  const minAgeMs = Math.max(60, Number(minUptimeSec) || ORPHAN_TEMP_PG_MIN_UPTIME_SEC) * 1000;
740
- const removed = sweepAbandonedPristineRoots(root, liveRoots, minAgeMs);
750
+ const removed = await sweepAbandonedPristineRoots(root, liveRoots, minAgeMs);
741
751
  if (removed > 0) __mixdogMemoryLog(`[pg-process] removed ${removed} abandoned pristine root(s)\n`);
742
752
  } catch {
743
753
  /* hygiene is best-effort */
@@ -372,6 +372,16 @@ function readPostmasterInfo(pgdata) {
372
372
  }
373
373
  }
374
374
 
375
+ // Async child run (never blocks the daemon loop): stdout on exit 0, else null.
376
+ async function _execFileText(file, args) {
377
+ const { execFile } = await import('node:child_process');
378
+ return new Promise((resolveText) => {
379
+ execFile(file, args, { encoding: 'utf8', windowsHide: true, timeout: 15_000 }, (error, stdout) => {
380
+ resolveText(error ? null : String(stdout || ''));
381
+ });
382
+ });
383
+ }
384
+
375
385
  /**
376
386
  * Best-effort process-name check: confirms the pid's comm name contains 'postgres'.
377
387
  * Falls back to true (alive) when the name cannot be determined.
@@ -383,19 +393,12 @@ async function isPostgresPid(pid) {
383
393
  return comm.includes('postgres');
384
394
  }
385
395
  if (process.platform === 'darwin') {
386
- const { spawnSync } = await import('node:child_process');
387
- const r = spawnSync('ps', ['-o', 'comm=', '-p', String(pid)], { encoding: 'utf8', windowsHide: true });
388
- if (r.status === 0) return (r.stdout || '').trim().includes('postgres');
389
- return true;
396
+ const out = await _execFileText('ps', ['-o', 'comm=', '-p', String(pid)]);
397
+ return out === null ? true : out.trim().includes('postgres');
390
398
  }
391
399
  if (process.platform === 'win32') {
392
- const { spawnSync } = await import('node:child_process');
393
- const r = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
394
- encoding: 'utf8',
395
- windowsHide: true,
396
- });
397
- if (r.status === 0) return (r.stdout || '').toLowerCase().includes('postgres');
398
- return true;
400
+ const out = await _execFileText('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH']);
401
+ return out === null ? true : out.toLowerCase().includes('postgres');
399
402
  }
400
403
  } catch {
401
404
  /* cannot read — fall back to alive */
@@ -463,8 +466,8 @@ function scheduleOrphanTempPostmasterSweep() {
463
466
  _orphanSweepStarted = true;
464
467
  const runSweep = () => {
465
468
  void _getPgProc()
466
- .then(({ sweepOrphanTempPostmasters }) => {
467
- const reaped = sweepOrphanTempPostmasters?.() || 0;
469
+ .then(async ({ sweepOrphanTempPostmasters }) => {
470
+ const reaped = (await sweepOrphanTempPostmasters?.()) || 0;
468
471
  if (reaped > 0) __mixdogMemoryLog(`[supervisor-pg] orphan sweep reaped ${reaped} temp postmaster(s)\n`);
469
472
  })
470
473
  .catch(() => {
@@ -17,7 +17,7 @@ Microsoft Office installed.
17
17
  | `design/` | Design tokens, content model, composition, creative direction for the Word and Excel composers (`compose_document`, `compose_sheet`); `design-discipline.mjs` owns the safe typeface list, palette repair (tinted dark fields, WCAG contrast, paired accent tints), and the brief rules; `docx/`, `xlsx/` format compilers; `library/` template library with the bundled `templates/`. Decks are never composed here — a slide is authored by the model as a script (`authoring/`), and `compose_slide` is refused with that route. |
18
18
  | `quality/` | Reviews and gates: `assurance-*.mjs` (trust, structure, rendered pages, checklist), aesthetics, design review, scoring, visual diff. |
19
19
  | `bench/` | Benchmarks behind `npm run bench:office*`; `bench-support.mjs` holds the shared tool-call helpers. |
20
- | `shared/` | Tiny helpers used across folders: `values.mjs` (`plainObject`, `clone`, `stableValue`, `sha256`, `clamp`, `compact`, `imageBuffer`), `page-sizes.mjs` (the paper sizes PDF create and Word `set_page` share), `column-widths.mjs` (the widths a Word or PDF table given none takes from its text), and `asar-path.mjs`. |
20
+ | `shared/` | Tiny helpers used across folders: `values.mjs` (`plainObject`, `clone`, `stableValue`, `sha256`, `clamp`, `compact`), `page-sizes.mjs` (the paper sizes PDF create and Word `set_page` share), `column-widths.mjs` (the widths a Word or PDF table given none takes from its text), and `asar-path.mjs`. |
21
21
 
22
22
  ## Dependency direction
23
23
 
@@ -32,8 +32,3 @@ export function compact(value, maximum = 120) {
32
32
  .replace(/\s+/g, ' ')
33
33
  .slice(0, maximum);
34
34
  }
35
-
36
- export function imageBuffer(image) {
37
- if (Buffer.isBuffer(image?.data)) return image.data;
38
- return Buffer.from(String(image?.data || ''), 'base64');
39
- }
@@ -18,6 +18,10 @@ export { TOOL_ASYNC_EXECUTION_CONTRACT, TOOL_MANUAL_CONTROL_CONTRACT, TOOL_SYNC_
18
18
  const TASK_TTL_MS = 30 * 60_000;
19
19
  const MAX_TASKS = 300;
20
20
  const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
21
+ // A `notifyOnCancel` task reports its cancellation to the owner once, after its
22
+ // run settles, so the notification can carry what the run produced before it
23
+ // stopped. A run that never settles still reports, header-only, after this grace.
24
+ const CANCEL_NOTIFY_GRACE_MS = 10_000;
21
25
  const tasks = new Map();
22
26
  let seq = 0;
23
27
 
@@ -151,6 +155,7 @@ export function registerBackgroundTask({
151
155
  resultType,
152
156
  renderResult,
153
157
  cancel,
158
+ notifyOnCancel = false,
154
159
  } = {}) {
155
160
  pruneTasks();
156
161
  const explicitId = clean(taskId);
@@ -189,6 +194,7 @@ export function registerBackgroundTask({
189
194
  resultType: clean(resultType) || null,
190
195
  renderResult: typeof renderResult === 'function' ? renderResult : null,
191
196
  cancel: typeof cancel === 'function' ? cancel : null,
197
+ notifyOnCancel: notifyOnCancel === true,
192
198
  };
193
199
  tasks.set(id, task);
194
200
  return task;
@@ -200,10 +206,14 @@ export function startBackgroundTask(options = {}) {
200
206
  .then(() => options.run?.())
201
207
  .then((result) => {
202
208
  completeBackgroundTask(task.taskId, { status: 'completed', result, terminalReason: 'run-resolved' });
209
+ deliverCancelledNotification(task);
203
210
  return result;
204
211
  })
205
212
  .catch((error) => {
206
- if (task.status === 'cancelled') return null;
213
+ if (task.status === 'cancelled') {
214
+ deliverCancelledNotification(task);
215
+ return null;
216
+ }
207
217
  completeBackgroundTask(task.taskId, {
208
218
  status: 'failed',
209
219
  error,
@@ -258,15 +268,62 @@ export function cleanupBackgroundTasks(options = {}) {
258
268
  return { removed: before - after, tasks: after };
259
269
  }
260
270
 
261
- export function cancelBackgroundTask(taskId, reason = 'cancelled') {
271
+ // `notify`: true notifies immediately, false never notifies (owner teardown).
272
+ // Left unset, a `notifyOnCancel` task defers its single `cancelled`
273
+ // notification until its run settles (see settleCancelledBackgroundTask); any
274
+ // other task stays silent.
275
+ function cancelRunningTask(task, reason, notify) {
276
+ try {
277
+ task.cancel?.();
278
+ } catch {}
279
+ completeBackgroundTask(task.taskId, { status: 'cancelled', error: reason, notify: notify === true });
280
+ if (task.notifyOnCancel !== true || notify === true || task.status !== 'cancelled' || task.cancelNotify) return;
281
+ if (notify === false) {
282
+ task.cancelNotify = 'silent';
283
+ return;
284
+ }
285
+ task.cancelNotify = 'pending';
286
+ task.cancelNotifyTimer = setTimeout(() => deliverCancelledNotification(task), CANCEL_NOTIFY_GRACE_MS);
287
+ task.cancelNotifyTimer.unref?.();
288
+ }
289
+
290
+ // Owner teardown/delete (`notify: false`) also silences a cancellation that is
291
+ // still waiting for its run to settle.
292
+ function resolvePendingCancelNotify(task, next) {
293
+ if (task.cancelNotify !== 'pending') return false;
294
+ clearTimeout(task.cancelNotifyTimer);
295
+ task.cancelNotifyTimer = null;
296
+ task.cancelNotify = next;
297
+ return true;
298
+ }
299
+
300
+ function silenceCancelledNotification(task) {
301
+ resolvePendingCancelNotify(task, 'silent');
302
+ }
303
+
304
+ function deliverCancelledNotification(task) {
305
+ if (!resolvePendingCancelNotify(task, 'delivered')) return false;
306
+ if (tasks.get(task.taskId) !== task) return false;
307
+ task.meta = sanitizeTaskMeta(task.meta);
308
+ return notifyTaskCompletion(task, task.notificationInstruction);
309
+ }
310
+
311
+ export function cancelBackgroundTask(taskId, reason = 'cancelled', { notify } = {}) {
262
312
  const task = getBackgroundTask(taskId);
263
313
  if (!task) return null;
264
- if (!TERMINAL_STATUSES.has(task.status)) {
265
- try {
266
- task.cancel?.();
267
- } catch {}
268
- completeBackgroundTask(task.taskId, { status: 'cancelled', error: reason, notify: false });
269
- }
314
+ if (!TERMINAL_STATUSES.has(task.status)) cancelRunningTask(task, reason, notify);
315
+ else if (notify === false) silenceCancelledNotification(task);
316
+ return task;
317
+ }
318
+
319
+ /** The cancelled run has stopped: attach what it produced before stopping (if
320
+ * any) and deliver the task's single deferred `cancelled` notification. A
321
+ * no-op unless that notification is still pending. */
322
+ export function settleCancelledBackgroundTask(taskId, { result } = {}) {
323
+ const task = getBackgroundTask(taskId);
324
+ if (task?.status !== 'cancelled' || task.cancelNotify !== 'pending') return null;
325
+ if (result !== undefined) task.result = result;
326
+ deliverCancelledNotification(task);
270
327
  return task;
271
328
  }
272
329
 
@@ -280,11 +337,11 @@ export function cancelBackgroundTasks(options = {}) {
280
337
  // visible/recoverable, but may not be claimed by whichever session happens
281
338
  // to close first.
282
339
  if (!taskMatchesScope(task, options, { includeUnattributed: false })) continue;
283
- if (TERMINAL_STATUSES.has(task.status)) continue;
284
- try {
285
- task.cancel?.();
286
- } catch {}
287
- completeBackgroundTask(task.taskId, { status: 'cancelled', error: reason, notify: options.notify === true });
340
+ if (TERMINAL_STATUSES.has(task.status)) {
341
+ if (options.notify === false) silenceCancelledNotification(task);
342
+ continue;
343
+ }
344
+ cancelRunningTask(task, reason, options.notify);
288
345
  cancelled += 1;
289
346
  }
290
347
  return { cancelled };
@@ -358,10 +415,26 @@ export function completeBackgroundTask(
358
415
  return task;
359
416
  }
360
417
 
418
+ // Surfaces whose later reads consume the structured result object itself:
419
+ // shell `task read` re-reads output via result paths and renders exit_code /
420
+ // timed_out / signal; agent job views return `job.result`. They keep it for the
421
+ // TTL; every other surface keeps only resultText + metadata once delivered.
422
+ const RESULT_KEPT_SURFACES = new Set(['shell', 'agent']);
423
+
424
+ function releaseDeliveredResult(task, body) {
425
+ if (task.result === undefined || RESULT_KEPT_SURFACES.has(task.surface)) return;
426
+ // Pin the rendered body first so re-renders and retries stay identical.
427
+ if (!task.resultText) task.resultText = body;
428
+ task.result = undefined;
429
+ }
430
+
361
431
  export function notifyTaskCompletion(task, instruction) {
362
432
  if (!task) return false;
363
433
  if (!TERMINAL_STATUSES.has(task.status)) return false;
364
434
  if (task.completionAcknowledged === true) return false;
435
+ // A deferred cancellation is delivered only by deliverCancelledNotification;
436
+ // a silent (owner-teardown) cancellation never notifies.
437
+ if (task.cancelNotify === 'pending' || task.cancelNotify === 'silent') return false;
365
438
  // `notified` tracks whether *any* completion notification was sent (e.g. an
366
439
  // early header-only preview from the agent surface). A later call that can
367
440
  // finally include the result body must still fire once, otherwise the owner
@@ -396,7 +469,10 @@ export function notifyTaskCompletion(task, instruction) {
396
469
  });
397
470
  if (sent) {
398
471
  task.notified = true;
399
- if (hasBody) task.notifiedWithBody = true;
472
+ if (hasBody) {
473
+ task.notifiedWithBody = true;
474
+ releaseDeliveredResult(task, body);
475
+ }
400
476
  }
401
477
  return sent;
402
478
  }
@@ -457,6 +533,8 @@ export function renderBackgroundTaskNotification(task) {
457
533
  status: task.status,
458
534
  result,
459
535
  error: task.error,
536
+ // A cancelled Agent's header names the cancel reason.
537
+ detail: task.surface === 'agent' && task.status === 'cancelled' ? task.error : null,
460
538
  });
461
539
  }
462
540
 
@@ -180,9 +180,34 @@ export function isCancelLikeError(error) {
180
180
  );
181
181
  }
182
182
 
183
+ // One-line reason per provider kind. Fixed sentences (optionally with a
184
+ // parenthesised value) are the renderer's translation keys.
185
+ const KIND_LINES = {
186
+ overloaded: 'Provider is busy at capacity.',
187
+ rate_limited: ({ retryAfterMs }) =>
188
+ retryAfterMs ? `Provider is rate-limited (retry in ${formatDurationMs(retryAfterMs)}).` : 'Provider is rate-limited.',
189
+ quota_exhausted: 'Provider usage limit reached.',
190
+ entitlement: 'Not included in your plan.',
191
+ billing: 'Out of credits or at spend limit.',
192
+ permission: 'Access denied by the provider.',
193
+ policy: 'Refused by the provider safety policy.',
194
+ auth: 'Provider authentication failed.',
195
+ };
196
+
197
+ // Typed evidence: a persisted descriptor or structured status/code fields.
198
+ // A bare message carries none, so its wording keeps the legacy text path.
199
+ function hasTypedEvidence(error) {
200
+ if (!error || typeof error !== 'object') return false;
201
+ return Boolean(error.errorKind?.kind) || errorHttpStatus(error) > 0 || Boolean(error.code) || Boolean(error.retryAfterMs);
202
+ }
203
+
183
204
  export function presentErrorText(error, options = {}) {
184
205
  const presentation = describeError(error);
185
206
  if (['image-size', 'payload-size', 'request'].includes(presentation.kind)) return presentation.summary;
207
+ const kindLine = hasTypedEvidence(error) ? KIND_LINES[presentation.providerKind] : null;
208
+ if (kindLine && presentation.kind !== 'unknown') {
209
+ return typeof kindLine === 'function' ? kindLine(presentation) : kindLine;
210
+ }
186
211
  const surface = options.surface || options.tool || '';
187
212
  const subject = subjectForSurface(surface);
188
213
  const max = options.max ?? 240;
@@ -1,3 +1,4 @@
1
+ import { PROVIDER_ERROR_KINDS, providerErrorKind } from '../agent/orchestrator/providers/provider-error-kind.mjs';
1
2
  import { transportErrorText } from './transport-error-text.mjs';
2
3
 
3
4
  const DETAILS_LIMIT = 12_000;
@@ -72,34 +73,167 @@ export function isRejected429(value) {
72
73
  return /\bAPI 429 request rejected\b/.test(messageOf(value));
73
74
  }
74
75
 
76
+ // Provider kind → presentation. Summaries are also the persisted form of a
77
+ // failure, so each maps back to its kind when only the text survives.
78
+ const KIND_COPY = {
79
+ overloaded: ['overloaded', 'The provider is busy right now.', 'Try again later or choose another model.'],
80
+ rate_limited: ['rate-limit', 'The provider is temporarily rate-limiting requests.', 'Try again shortly.'],
81
+ quota_exhausted: [
82
+ 'quota',
83
+ 'The provider usage limit was reached.',
84
+ 'Wait for the limit to reset or choose another account.',
85
+ ],
86
+ entitlement: [
87
+ 'entitlement',
88
+ "This model isn't included in your plan.",
89
+ 'Upgrade your plan, or choose another model or provider.',
90
+ ],
91
+ billing: [
92
+ 'billing',
93
+ 'The provider account is out of credits or at its spend limit.',
94
+ 'Check your billing with the provider, then try again.',
95
+ ],
96
+ permission: [
97
+ 'permission',
98
+ "You don't have access to this resource.",
99
+ 'Check your account permissions, or choose another model.',
100
+ ],
101
+ context_overflow: [
102
+ 'context-overflow',
103
+ 'The conversation is too long for this model.',
104
+ 'Compact the conversation or start a new one.',
105
+ ],
106
+ request_too_large: [
107
+ 'payload-size',
108
+ 'The request exceeds the size limit.',
109
+ 'Reduce the attachments or request size, then try again.',
110
+ ],
111
+ policy: [
112
+ 'policy',
113
+ 'The provider refused this request under its safety policy.',
114
+ 'Rephrase the request or choose another model.',
115
+ ],
116
+ service_tier_unavailable: [
117
+ 'service-tier',
118
+ 'The requested service tier is unavailable right now.',
119
+ 'Switch to the standard tier or try again later.',
120
+ ],
121
+ };
122
+ const SUMMARY_KINDS = new Map(Object.entries(KIND_COPY).map(([providerKind, [, summary]]) => [summary, providerKind]));
123
+
124
+ function waitLabel(ms) {
125
+ const seconds = Math.max(1, Math.ceil(ms / 1000));
126
+ if (seconds < 60) return `${seconds}s`;
127
+ const minutes = Math.ceil(seconds / 60);
128
+ if (minutes < 60) return `${minutes}m`;
129
+ const hours = Math.floor(minutes / 60);
130
+ return minutes % 60 ? `${hours}h ${minutes % 60}m` : `${hours}h`;
131
+ }
132
+
133
+ /** Local HH:mm, prefixed with month/day when the reset is not today. */
134
+ function resetLabel(resetAt) {
135
+ const reset = new Date(resetAt);
136
+ const pad = (n) => String(n).padStart(2, '0');
137
+ const time = `${pad(reset.getHours())}:${pad(reset.getMinutes())}`;
138
+ return reset.toDateString() === new Date().toDateString() ? time : `${reset.getMonth() + 1}/${reset.getDate()} ${time}`;
139
+ }
140
+
141
+ // Only legacy items (no persisted descriptor) and untyped strings reach this.
142
+ const LEGACY_TEXT_KINDS = [
143
+ [/The provider usage limit was reached|quota.*(?:hit|exceed)|usage limit/i, 'quota_exhausted'],
144
+ [/rate[_ -]?limit|too many requests/i, 'rate_limited'],
145
+ ];
146
+
147
+ /**
148
+ * The provider kind descriptor: the persisted one, else typed evidence, else
149
+ * wording. `typed` is true when the kind came from a persisted descriptor or a
150
+ * typed error: wording heuristics must then never override it.
151
+ */
152
+ function classify(value, raw, clean, status, code) {
153
+ const persisted = value?.errorKind;
154
+ if (persisted && PROVIDER_ERROR_KINDS.includes(persisted.kind)) {
155
+ return { descriptor: persisted, typed: persisted.kind !== 'unknown' };
156
+ }
157
+ const hasTypedFields =
158
+ value &&
159
+ typeof value === 'object' &&
160
+ Boolean(value.status || value.httpStatus || value.response?.status || value.code || value.retryAfterMs);
161
+ if (!hasTypedFields) {
162
+ // Legacy persisted strings: their wording (and wait) is the only evidence.
163
+ const legacy = SUMMARY_KINDS.get(clean) ?? LEGACY_TEXT_KINDS.find(([re]) => re.test(clean))?.[1];
164
+ if (legacy) {
165
+ const wait = /retryAfter=(\d+)(ms|s|m)\b/i.exec(raw);
166
+ const retryAfterMs = wait ? Number(wait[1]) * { ms: 1, s: 1000, m: 60_000 }[wait[2].toLowerCase()] : undefined;
167
+ return { descriptor: { kind: legacy, ...(retryAfterMs ? { retryAfterMs } : {}) }, typed: false };
168
+ }
169
+ }
170
+ let descriptor = value && typeof value === 'object' ? providerErrorKind(value) : null;
171
+ const typed = Boolean(descriptor) && descriptor.kind !== 'unknown';
172
+ if (!descriptor || descriptor.kind === 'unknown') {
173
+ descriptor = providerErrorKind(
174
+ Object.assign(new Error(raw), { ...(status ? { status } : {}), ...(code ? { code } : {}) })
175
+ );
176
+ }
177
+ if (descriptor.kind === 'unknown') {
178
+ const known = SUMMARY_KINDS.get(clean);
179
+ const legacy = known ?? LEGACY_TEXT_KINDS.find(([re]) => re.test(clean))?.[1];
180
+ if (legacy) return { descriptor: { ...descriptor, kind: legacy }, typed: false };
181
+ }
182
+ return { descriptor, typed };
183
+ }
184
+
75
185
  /** Presentation only: never changes retry policy or the underlying failure. */
76
186
  export function describeError(value) {
77
- const { raw, message, status, code } = unwrap(value);
78
- const rejected429 = isRejected429(value);
187
+ const { raw, message, status: textStatus, code: textCode } = unwrap(value);
79
188
  const details = safeErrorDetails(raw);
80
189
  const clean = safeErrorDetails(message)
81
190
  .replace(/^(?:Error invoking remote method ['"][^'"]+['"]:\s*)?(?:Error:\s*)?/i, '')
82
191
  .trim();
192
+ const { descriptor, typed } = classify(value, raw, clean, textStatus, textCode);
193
+ // A typed or persisted kind owns the verdict: its own status and code stand
194
+ // in for the text-derived ones, and wording below may only refine it.
195
+ const status = typed ? Number(descriptor.status) || 0 : textStatus;
196
+ const code = typed ? String(descriptor.code || '') : textCode;
197
+ // A 429 refusal without quota headers refines a rate limit, nothing else.
198
+ const rejected429 = isRejected429(value) && (!typed || descriptor.kind === 'rate_limited');
199
+ const copy = KIND_COPY[descriptor.kind];
200
+ const retryAfterMs = Number(descriptor.retryAfterMs) > 0 ? Number(descriptor.retryAfterMs) : null;
201
+ const resetAt = Number(descriptor.resetAt) > 0 ? Number(descriptor.resetAt) : null;
83
202
  let kind = 'unknown';
84
203
  let summary = '';
85
204
  let recovery = '';
86
- const transport = transportErrorText(value) || transportErrorText(raw);
205
+ // Localization hook: a recovery that carries a value is given as a template.
206
+ let recoveryTemplate = '';
207
+ let params = null;
208
+ // A persisted descriptor keeps the status and errno code, not the cause
209
+ // chain: rebuild the transport evidence from them. Wording only reads for
210
+ // untyped failures and transient ones (a bare "fetch failed" is that evidence).
211
+ const transport =
212
+ transportErrorText(value) ||
213
+ (typed && transportErrorText({ code: code.toUpperCase(), status })) ||
214
+ (!typed || descriptor.kind === 'transient' ? transportErrorText(raw) : null);
87
215
  if (
88
216
  value?.reauthRequired === true ||
89
- /invalid_grant|refresh token (?:expired|revoked|not available)|sign in again|sign-in expired|Reauth Required/i.test(raw)
217
+ (!typed &&
218
+ /invalid_grant|refresh token (?:expired|revoked|not available)|sign in again|sign-in expired|Reauth Required/i.test(raw))
90
219
  ) {
91
220
  kind = 'reauth';
92
221
  summary = 'Sign-in expired.';
93
222
  recovery = 'Sign in again from Providers.';
94
223
  } else if (
95
- status === 401 ||
96
- status === 403 ||
97
- /\b(?:invalid|expired)\s+(?:api[ _-]?key|access token|credentials?)\b|Provider authentication failed/i.test(clean)
224
+ descriptor.kind === 'auth' ||
225
+ (descriptor.kind === 'unknown' &&
226
+ /\b(?:invalid|expired)\s+(?:api[ _-]?key|access token|credentials?)\b|Provider authentication failed/i.test(clean))
98
227
  ) {
99
228
  kind = 'authentication';
100
229
  summary = 'Provider authentication failed';
101
230
  recovery = 'Check your sign-in or API key, then try again.';
231
+ } else if (rejected429) {
232
+ kind = 'request';
233
+ summary = 'The provider rejected this request.';
234
+ recovery = 'Review the request details before trying again.';
102
235
  } else if (
236
+ ['invalid_request', 'unknown', 'request_too_large'].includes(descriptor.kind) &&
103
237
  /image.*dimensions.*exceed|image.*(?:too large|maximum.*(?:size|dimension))|many-image requests|An attached image exceeds the provider limit/i.test(
104
238
  clean
105
239
  )
@@ -108,20 +242,21 @@ export function describeError(value) {
108
242
  summary = 'An attached image exceeds the provider limit.';
109
243
  recovery = 'Reduce the image dimensions or number of images, then try again.';
110
244
  } else if (
111
- status === 413 ||
245
+ descriptor.kind === 'unknown' &&
112
246
  /payload too large|request (?:body )?too large|The request exceeds the size limit/i.test(clean)
113
247
  ) {
114
- kind = 'payload-size';
115
- summary = 'The request exceeds the size limit.';
116
- recovery = 'Reduce the attachments or request size, then try again.';
117
- } else if (
118
- !rejected429 &&
119
- (status === 429 ||
120
- /rate[_ -]?limit|quota.*(?:hit|exceed)|too many requests|The provider usage limit was reached/i.test(clean))
121
- ) {
122
- kind = 'rate-limit';
123
- summary = 'The provider usage limit was reached.';
124
- recovery = 'Wait for the limit to reset or choose another account.';
248
+ [kind, summary, recovery] = KIND_COPY.request_too_large;
249
+ } else if (copy) {
250
+ [kind, summary, recovery] = copy;
251
+ if (descriptor.kind === 'rate_limited' && retryAfterMs) {
252
+ recovery = `Try again in ${waitLabel(retryAfterMs)}.`;
253
+ recoveryTemplate = 'Try again in {{wait}}.';
254
+ params = { wait: waitLabel(retryAfterMs) };
255
+ } else if (descriptor.kind === 'quota_exhausted' && resetAt) {
256
+ recovery = `It resets at ${resetLabel(resetAt)}. Switch accounts or providers to continue now.`;
257
+ recoveryTemplate = 'It resets at {{time}}. Switch accounts or providers to continue now.';
258
+ params = { time: resetLabel(resetAt) };
259
+ }
125
260
  } else if (transport) {
126
261
  kind = 'connection';
127
262
  summary = transport.replace(/\.$/, '');
@@ -150,6 +285,11 @@ export function describeError(value) {
150
285
  kind,
151
286
  summary,
152
287
  recovery,
288
+ recoveryTemplate,
289
+ params,
290
+ providerKind: descriptor.kind,
291
+ resetAt,
292
+ retryAfterMs,
153
293
  details,
154
294
  // Known structured failures ignore volatile indexes; unknown failures
155
295
  // retain their diagnostic identity rather than merging unrelated work.