mixdog 0.9.1 → 0.9.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 (214) hide show
  1. package/package.json +8 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/task-bench.mjs +207 -0
  23. package/scripts/tool-failures.mjs +6 -6
  24. package/scripts/tool-smoke.mjs +306 -66
  25. package/scripts/toolcall-args-test.mjs +81 -0
  26. package/src/agents/debugger/AGENT.md +4 -4
  27. package/src/agents/heavy-worker/AGENT.md +4 -2
  28. package/src/agents/reviewer/AGENT.md +4 -4
  29. package/src/agents/worker/AGENT.md +4 -2
  30. package/src/app.mjs +10 -6
  31. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  32. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  33. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  34. package/src/headless-role.mjs +14 -14
  35. package/src/help.mjs +1 -0
  36. package/src/lib/rules-builder.cjs +32 -54
  37. package/src/mixdog-session-runtime.mjs +710 -318
  38. package/src/output-styles/default.md +12 -7
  39. package/src/output-styles/minimal.md +25 -0
  40. package/src/output-styles/oneline.md +21 -0
  41. package/src/output-styles/simple.md +10 -9
  42. package/src/repl.mjs +12 -2
  43. package/src/rules/agent/00-common.md +7 -5
  44. package/src/rules/agent/30-explorer.md +7 -8
  45. package/src/rules/lead/01-general.md +3 -1
  46. package/src/rules/lead/lead-tool.md +7 -0
  47. package/src/rules/shared/01-tool.md +17 -12
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  50. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  51. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  52. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  53. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  54. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  55. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  56. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  57. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  58. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  59. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  61. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  62. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
  63. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
  64. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  65. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  66. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  67. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  68. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  69. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
  70. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
  71. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
  72. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  73. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  74. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  75. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
  76. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  77. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  78. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  80. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  82. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  83. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  84. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  85. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  87. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  88. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  90. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  91. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  92. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  95. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  96. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  97. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  99. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  100. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  101. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  102. package/src/runtime/channels/backends/discord.mjs +99 -9
  103. package/src/runtime/channels/backends/telegram.mjs +501 -0
  104. package/src/runtime/channels/index.mjs +441 -1224
  105. package/src/runtime/channels/lib/config.mjs +54 -2
  106. package/src/runtime/channels/lib/format.mjs +4 -2
  107. package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
  108. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  109. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  110. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  111. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  112. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  113. package/src/runtime/channels/lib/webhook.mjs +59 -31
  114. package/src/runtime/channels/tool-defs.mjs +1 -1
  115. package/src/runtime/memory/index.mjs +184 -19
  116. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  117. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  118. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  119. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  120. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  121. package/src/runtime/memory/lib/memory.mjs +101 -4
  122. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  123. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  124. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  125. package/src/runtime/memory/tool-defs.mjs +6 -3
  126. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  127. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  128. package/src/runtime/shared/config.mjs +9 -0
  129. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  130. package/src/runtime/shared/schedules-store.mjs +21 -19
  131. package/src/runtime/shared/tool-surface.mjs +98 -13
  132. package/src/runtime/shared/transcript-writer.mjs +129 -0
  133. package/src/runtime/shared/update-checker.mjs +214 -0
  134. package/src/standalone/agent-tool.mjs +255 -109
  135. package/src/standalone/channel-admin.mjs +133 -40
  136. package/src/standalone/channel-worker.mjs +8 -291
  137. package/src/standalone/explore-tool.mjs +2 -2
  138. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  139. package/src/standalone/provider-admin.mjs +11 -0
  140. package/src/standalone/usage-dashboard.mjs +1 -1
  141. package/src/tui/App.jsx +2096 -732
  142. package/src/tui/components/ConfirmBar.jsx +47 -0
  143. package/src/tui/components/ContextPanel.jsx +5 -3
  144. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  145. package/src/tui/components/Markdown.jsx +22 -98
  146. package/src/tui/components/Message.jsx +14 -35
  147. package/src/tui/components/Picker.jsx +87 -12
  148. package/src/tui/components/PromptInput.jsx +83 -7
  149. package/src/tui/components/QueuedCommands.jsx +1 -1
  150. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  151. package/src/tui/components/Spinner.jsx +7 -7
  152. package/src/tui/components/StatusLine.jsx +40 -21
  153. package/src/tui/components/TextEntryPanel.jsx +51 -7
  154. package/src/tui/components/ToolExecution.jsx +170 -98
  155. package/src/tui/components/TurnDone.jsx +4 -4
  156. package/src/tui/components/UsagePanel.jsx +1 -1
  157. package/src/tui/components/tool-output-format.mjs +159 -21
  158. package/src/tui/components/tool-output-format.test.mjs +87 -0
  159. package/src/tui/display-width.mjs +69 -0
  160. package/src/tui/display-width.test.mjs +35 -0
  161. package/src/tui/dist/index.mjs +6965 -2391
  162. package/src/tui/engine.mjs +287 -126
  163. package/src/tui/index.jsx +117 -7
  164. package/src/tui/keyboard-protocol.mjs +42 -0
  165. package/src/tui/lib/voice-recorder.mjs +453 -0
  166. package/src/tui/markdown/format-token.mjs +129 -76
  167. package/src/tui/markdown/format-token.test.mjs +61 -19
  168. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  169. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  170. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  171. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  172. package/src/tui/markdown/table-layout.mjs +9 -9
  173. package/src/tui/paste-attachments.mjs +0 -11
  174. package/src/tui/prompt-history-store.mjs +129 -0
  175. package/src/tui/prompt-history-store.test.mjs +52 -0
  176. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  177. package/src/tui/theme.mjs +41 -657
  178. package/src/tui/themes/base.mjs +86 -0
  179. package/src/tui/themes/basic.mjs +85 -0
  180. package/src/tui/themes/catppuccin.mjs +72 -0
  181. package/src/tui/themes/dracula.mjs +70 -0
  182. package/src/tui/themes/everforest.mjs +71 -0
  183. package/src/tui/themes/gruvbox.mjs +71 -0
  184. package/src/tui/themes/index.mjs +71 -0
  185. package/src/tui/themes/indigo.mjs +78 -0
  186. package/src/tui/themes/kanagawa.mjs +80 -0
  187. package/src/tui/themes/light.mjs +81 -0
  188. package/src/tui/themes/nord.mjs +72 -0
  189. package/src/tui/themes/onedark.mjs +16 -0
  190. package/src/tui/themes/rosepine.mjs +70 -0
  191. package/src/tui/themes/teal.mjs +81 -0
  192. package/src/tui/themes/tokyonight.mjs +79 -0
  193. package/src/tui/themes/utils.mjs +106 -0
  194. package/src/tui/themes/warm.mjs +79 -0
  195. package/src/tui/transcript-tool-failures.mjs +13 -2
  196. package/src/ui/markdown.mjs +1 -1
  197. package/src/ui/model-display.mjs +2 -2
  198. package/src/ui/statusline.mjs +26 -27
  199. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  200. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  201. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  202. package/src/workflows/default/WORKFLOW.md +39 -12
  203. package/src/workflows/sequential/WORKFLOW.md +46 -0
  204. package/src/workflows/solo/WORKFLOW.md +7 -0
  205. package/vendor/ink/build/display-width.js +62 -0
  206. package/vendor/ink/build/ink.js +100 -12
  207. package/vendor/ink/build/measure-text.js +4 -1
  208. package/vendor/ink/build/output.js +115 -9
  209. package/vendor/ink/build/render-node-to-output.js +4 -1
  210. package/vendor/ink/build/render.js +4 -0
  211. package/src/output-styles/extreme-simple.md +0 -20
  212. package/src/rules/lead/04-workflow.md +0 -51
  213. package/src/workflows/default/workflow.json +0 -13
  214. package/src/workflows/solo/workflow.json +0 -7
@@ -27,7 +27,7 @@
27
27
  // responds with OK_PARTIAL plus a hex-encoded failures payload that
28
28
  // the JS side surfaces per-entry.
29
29
 
30
- import { existsSync, readFileSync, realpathSync, statSync, mkdirSync } from 'node:fs';
30
+ import { existsSync, readFileSync, realpathSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
31
31
  import { unlink } from 'node:fs/promises';
32
32
  import { spawn } from 'node:child_process';
33
33
  import { createInterface } from 'node:readline';
@@ -581,6 +581,41 @@ function isPatchErrorText(text) {
581
581
  return /^Error:/i.test(String(text ?? '').trimStart());
582
582
  }
583
583
 
584
+ // ── UI-only unified-diff side-channel ───────────────────────────────────────
585
+ // apply_patch's MODEL-VISIBLE result stays the compact summary ("Applied 1
586
+ // File (Native)\n OK Modify path — +1 Line · -1 Line"). The standard unified
587
+ // diff it builds internally (nativePatchStr) is stashed here, keyed by the
588
+ // tool_use id, so the agent loop can attach it to the TUI tool-result message
589
+ // as a UI-only field (never sent to the model). The TUI's expanded (ctrl+o)
590
+ // raw view then colorizes it via the existing formatExpandedResult/diff path.
591
+ // Size-capped per entry and FIFO-bounded so a huge patch (or a leaked entry
592
+ // whose take() never ran) cannot grow the transcript item or this Map.
593
+ const APPLY_PATCH_UI_DIFF_MAX_CHARS = 64 * 1024;
594
+ const APPLY_PATCH_UI_DIFF_REGISTRY_MAX = 64;
595
+ const _applyPatchUiDiffByCallId = new Map();
596
+
597
+ function registerApplyPatchUiDiff(callId, diff) {
598
+ if (!callId || typeof diff !== 'string' || !diff.trim()) return;
599
+ let text = diff;
600
+ if (text.length > APPLY_PATCH_UI_DIFF_MAX_CHARS) {
601
+ text = `${text.slice(0, APPLY_PATCH_UI_DIFF_MAX_CHARS)}\n… [diff truncated for display]`;
602
+ }
603
+ if (_applyPatchUiDiffByCallId.size >= APPLY_PATCH_UI_DIFF_REGISTRY_MAX) {
604
+ const oldest = _applyPatchUiDiffByCallId.keys().next().value;
605
+ if (oldest !== undefined) _applyPatchUiDiffByCallId.delete(oldest);
606
+ }
607
+ _applyPatchUiDiffByCallId.set(callId, text);
608
+ }
609
+
610
+ // Consume (read + remove) the UI-only unified diff for a tool_use id. Returns
611
+ // null when none was registered (error/rename-only/partial-fail/dry-skip).
612
+ export function takeApplyPatchUiDiff(callId) {
613
+ if (!callId) return null;
614
+ const value = _applyPatchUiDiffByCallId.get(callId) || null;
615
+ if (value != null) _applyPatchUiDiffByCallId.delete(callId);
616
+ return value;
617
+ }
618
+
584
619
  // Count how many source lines a hunk consumes vs produces so we can
585
620
  // surface a concise `lines_changed` figure without re-diffing.
586
621
  function countHunkChanges(hunks) {
@@ -2806,6 +2841,14 @@ async function apply_patch(args, cwd, options = {}) {
2806
2841
  if (renameLines.length > 0 && !isPatchErrorText(nativeResult)) {
2807
2842
  combined = `${renameLines.join('\n')}\n${nativeResult}`;
2808
2843
  }
2844
+ // UI-only: stash the standard unified diff (nativePatchStr) keyed by the
2845
+ // tool_use id so the agent loop can attach it to the TUI tool-result for the
2846
+ // expanded (ctrl+o) colored-diff view. NEVER added to the model-visible
2847
+ // result string. Only on clean success — partial/failed cases surface as an
2848
+ // Error card with the failure text instead.
2849
+ if (!isPatchErrorText(combined) && options?.toolCallId) {
2850
+ registerApplyPatchUiDiff(options.toolCallId, nativePatchStr);
2851
+ }
2809
2852
  if (!isPatchErrorText(combined) && rejectedV4AHunks.length > 0) {
2810
2853
  const tail = [
2811
2854
  '',
@@ -2824,6 +2867,74 @@ async function apply_patch(args, cwd, options = {}) {
2824
2867
  export const __patchTestHooks = { findFirstFailingUnifiedHunk, computeUnifiedChangeBand, collectUnifiedOps, unifiedOldLinesMatchAt, splitBufferLinesForPatch };
2825
2868
 
2826
2869
  export async function executePatchTool(name, args, cwd, options = {}) {
2870
+ // --- Opt-in failure replay capture -------------------------------------
2871
+ // When MIXDOG_PATCH_REPLAY_CAPTURE=1, a FAILED apply_patch (throw or
2872
+ // "Error:" body) is frozen to a replay file with the ORIGINAL patch args,
2873
+ // cwd, error, and a full snapshot of every target file's current bytes, so
2874
+ // scripts/patch-replay.mjs can re-run the exact failing call against updated
2875
+ // code. Local dev only; the patch body is stored verbatim (no redaction).
2876
+ return _executePatchTool(name, args, cwd, options);
2877
+ }
2878
+
2879
+ function patchReplayDir() {
2880
+ const base = process.env.MIXDOG_PATCH_REPLAY_DIR
2881
+ || pathJoin(getPluginDataDir(), 'history', 'patch-replays');
2882
+ return base;
2883
+ }
2884
+
2885
+ function getPluginDataDir() {
2886
+ try { return getPluginData(); } catch { /* fall through */ }
2887
+ return process.env.MIXDOG_DATA_DIR || pathJoin(process.env.USERPROFILE || process.env.HOME || '.', '.mixdog', 'data');
2888
+ }
2889
+
2890
+ function patchTargetPaths(patchStr, basePath) {
2891
+ const out = [];
2892
+ const re = /^\*\*\* (?:Update|Add|Delete) File:\s*(.+)$/gm;
2893
+ let m;
2894
+ while ((m = re.exec(String(patchStr || '')))) {
2895
+ const rel = m[1].trim();
2896
+ if (rel) out.push(rel);
2897
+ }
2898
+ // unified headers
2899
+ const ure = /^\+\+\+ (?:b\/)?(.+)$/gm;
2900
+ while ((m = ure.exec(String(patchStr || '')))) {
2901
+ const rel = m[1].trim();
2902
+ if (rel && rel !== '/dev/null') out.push(rel);
2903
+ }
2904
+ return [...new Set(out)];
2905
+ }
2906
+
2907
+ function maybeCapturePatchReplay(args, cwd, errorText) {
2908
+ if (process.env.MIXDOG_PATCH_REPLAY_CAPTURE !== '1') return;
2909
+ try {
2910
+ const patchStr = typeof args?.patch === 'string' ? args.patch : '';
2911
+ const basePath = pathResolve(String(args?.base_path || cwd || process.cwd()));
2912
+ const dir = patchReplayDir();
2913
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
2914
+ const rels = patchTargetPaths(patchStr, basePath);
2915
+ const files = {};
2916
+ for (const rel of rels) {
2917
+ try {
2918
+ const abs = isAbsolute(rel) ? rel : pathResolve(basePath, rel);
2919
+ files[rel] = existsSync(abs) ? readFileSync(abs, 'utf8') : null;
2920
+ } catch { files[rel] = null; }
2921
+ }
2922
+ const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
2923
+ const record = {
2924
+ id,
2925
+ ts: Date.now(),
2926
+ tool: 'apply_patch',
2927
+ args: { patch: patchStr, base_path: args?.base_path ?? null, format: args?.format ?? null, dry_run: args?.dry_run ?? null, fuzzy: args?.fuzzy ?? null, reject_partial: args?.reject_partial ?? null },
2928
+ cwd: basePath,
2929
+ error_first_line: String(errorText || '').split('\n')[0].slice(0, 400),
2930
+ targets: rels,
2931
+ file_snapshots: files,
2932
+ };
2933
+ writeFileSync(pathJoin(dir, `${id}.json`), JSON.stringify(record, null, 2), { mode: 0o600 });
2934
+ } catch { /* capture is best-effort; never affect the tool result */ }
2935
+ }
2936
+
2937
+ async function _executePatchTool(name, args, cwd, options = {}) {
2827
2938
  const effectiveCwd = cwd || process.cwd();
2828
2939
  switch (name) {
2829
2940
  case 'apply_patch': {
@@ -2831,8 +2942,11 @@ export async function executePatchTool(name, args, cwd, options = {}) {
2831
2942
  try {
2832
2943
  result = await apply_patch(args || {}, effectiveCwd, options);
2833
2944
  } catch (err) {
2834
- return `Error: ${err?.message || String(err)}`;
2945
+ const errText = `Error: ${err?.message || String(err)}`;
2946
+ maybeCapturePatchReplay(args, effectiveCwd, errText);
2947
+ return errText;
2835
2948
  }
2949
+ if (isPatchErrorText(String(result))) maybeCapturePatchReplay(args, effectiveCwd, String(result));
2836
2950
  // ② completion progress (claude "Found N" parity). Best-effort, no-op
2837
2951
  // when onProgress is absent (no progressToken). Never throws — only
2838
2952
  // emits on success (an "Error:" body is left to the tool result alone).
@@ -16,10 +16,11 @@ import {
16
16
  realpathSync
17
17
  } from "fs";
18
18
  import { join, sep } from "path";
19
- import { chunk } from "../lib/format.mjs";
19
+ import { createHash } from "crypto";
20
+ import { chunk, formatForDiscord, MAX_DISCORD_MESSAGE } from "../lib/format.mjs";
20
21
  import { withConfigLock } from "../lib/config-lock.mjs";
21
22
  import { readSection, updateSection } from "../../shared/config.mjs";
22
- const MAX_CHUNK_LIMIT = 2e3;
23
+ const MAX_CHUNK_LIMIT = MAX_DISCORD_MESSAGE;
23
24
  const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
24
25
  const RECENT_SENT_CAP = 200;
25
26
  function defaultAccess() {
@@ -52,6 +53,7 @@ function safeAttName(att) {
52
53
  }
53
54
  class DiscordBackend {
54
55
  name = "discord";
56
+ MAX_MESSAGE_LENGTH = MAX_DISCORD_MESSAGE;
55
57
  onMessage = null;
56
58
  onInteraction = null;
57
59
  onModalRequest = null;
@@ -77,6 +79,9 @@ class DiscordBackend {
77
79
  this.initialAccess = normalizeAccess(config.access);
78
80
  this.client = null;
79
81
  }
82
+ formatOutgoing(text) {
83
+ return formatForDiscord(text);
84
+ }
80
85
  // ── Lifecycle ──────────────────────────────────────────────────────
81
86
  async connect() {
82
87
  // Re-entry guard: if a connect() is already in-flight or completed, return
@@ -335,33 +340,118 @@ class DiscordBackend {
335
340
  }
336
341
  }
337
342
  if (files.length > 10) throw new Error("max 10 attachments per message");
338
- if (text && this.sendCount > 0) {
343
+ // Resume-token support (opaque to callers). A partial-send failure hands the
344
+ // caller a token { hash, nextChunkIdx, sentIds, prefixed }; a requeued retry
345
+ // passes it back so we resume at the failed chunk instead of re-sending
346
+ // chunks that already landed.
347
+ //
348
+ // The "\u3164\n" prefix is applied from this.sendCount, which advances when
349
+ // ANY backend send fully succeeds. Between the original partial failure and
350
+ // this retry, an unrelated send (scheduler/lifecycle/permission) can flip
351
+ // sendCount from 0 to >0, which would change the prefix — and therefore the
352
+ // chunked text and its hash — breaking the token match and forcing a
353
+ // duplicate full-resend. To keep the retry byte-identical to the original
354
+ // attempt, freeze the prefix decision in the token and reuse it here instead
355
+ // of recomputing from the (possibly changed) current sendCount.
356
+ const resumeToken = opts?.resumeToken;
357
+ const applyPrefix = (resumeToken && typeof resumeToken.prefixed === "boolean")
358
+ ? resumeToken.prefixed
359
+ : (text && this.sendCount > 0 ? true : false);
360
+ if (text && applyPrefix) {
339
361
  text = "\u3164\n" + text;
340
362
  }
341
363
  const access = this.loadAccess();
342
364
  const limit = Math.max(1, Math.min(access.textChunkLimit ?? MAX_CHUNK_LIMIT, MAX_CHUNK_LIMIT));
343
365
  const replyMode = access.replyToMode ?? "off";
344
366
  const chunks = chunk(text, limit);
367
+ // Hash the FINAL prefixed text so the token pins to the exact bytes sent.
368
+ const contentHash = createHash("md5").update(text).digest("hex");
369
+ let startIdx = 0;
345
370
  const sentIds = [];
371
+ // Resume only when BOTH the text hash AND the effective chunk limit match.
372
+ // The token's nextChunkIdx is an index into the chunk array, which is only
373
+ // meaningful for the same `limit`; if access.textChunkLimit changed during
374
+ // the retry window the same text can split differently, so a stale index
375
+ // could skip or duplicate a range. On mismatch fall through to startIdx=0
376
+ // (full, safe resend).
377
+ if (resumeToken && resumeToken.hash === contentHash && resumeToken.limit === limit) {
378
+ startIdx = Math.max(0, Math.min(resumeToken.nextChunkIdx ?? 0, chunks.length));
379
+ // Seed sentIds with the already-delivered ids so the returned list stays
380
+ // complete across the resume boundary.
381
+ if (Array.isArray(resumeToken.sentIds)) sentIds.push(...resumeToken.sentIds);
382
+ }
346
383
  try {
347
- for (let i = 0; i < chunks.length; i++) {
384
+ for (let i = startIdx; i < chunks.length; i++) {
348
385
  const shouldReplyTo = replyTo != null && replyMode !== "off" && (replyMode === "all" || i === 0);
349
386
  const embeds = i === 0 ? opts?.embeds ?? [] : [];
350
387
  const components = i === 0 ? opts?.components ?? [] : [];
351
- const sent = await ch.send({
388
+ const payload = {
352
389
  content: chunks[i],
353
390
  ...embeds.length > 0 ? { embeds } : {},
354
391
  ...components.length > 0 ? { components } : {},
355
392
  ...i === 0 && files.length > 0 ? { files } : {},
356
393
  ...shouldReplyTo ? { reply: { messageReference: replyTo, failIfNotExists: false } } : {}
357
- });
358
- this.noteSent(sent.id);
359
- sentIds.push(sent.id);
394
+ };
395
+ // Per-chunk send with retry. On 429 honour Retry-After; on other
396
+ // transient errors do a short backoff. Cap at 3 attempts per chunk;
397
+ // after the cap throw an error carrying a resume token so the caller
398
+ // (output-forwarder) requeues the WHOLE item and the next sendMessage
399
+ // resumes at this chunk index instead of re-sending 0..i-1.
400
+ let attempt = 0;
401
+ for (;;) {
402
+ try {
403
+ const sent = await ch.send(payload);
404
+ this.noteSent(sent.id);
405
+ sentIds.push(sent.id);
406
+ break;
407
+ } catch (err) {
408
+ attempt++;
409
+ const status = err?.status ?? err?.code ?? err?.httpStatus;
410
+ // Classify: PERMANENT = a 4xx client error (unknown channel/message,
411
+ // missing access/permissions, 404 …) that will never succeed on
412
+ // retry. TRANSIENT = 429, any 5xx, or network error (no status). A
413
+ // permanent error must NOT retry-loop and must NOT carry a resume
414
+ // token — throw it immediately flagged so the forwarder drops the
415
+ // item instead of requeuing forever.
416
+ const isPermanent = typeof status === "number" && status >= 400 && status < 500 && status !== 429;
417
+ if (isPermanent) {
418
+ const e = err instanceof Error ? err : new Error(String(err));
419
+ e.permanent = true;
420
+ throw e;
421
+ }
422
+ if (attempt >= 3) {
423
+ // Attach an opaque resume token pointing at the chunk that failed
424
+ // (i). sentIds holds every chunk already delivered (including any
425
+ // seeded from a prior token).
426
+ const e = err instanceof Error ? err : new Error(String(err));
427
+ e.resumeToken = { hash: contentHash, nextChunkIdx: i, sentIds: [...sentIds], prefixed: applyPrefix, limit };
428
+ throw e;
429
+ }
430
+ if (status === 429) {
431
+ // @discordjs/rest RateLimitError.retryAfter is ALWAYS in ms
432
+ // (no unit guess). Clamp to [0, 60000]; fall back to 1s when the
433
+ // field is absent/invalid. (discord.js also auto-sleeps rate
434
+ // limits internally, so we rarely even reach this branch.)
435
+ const retryAfterMs = Number(err?.retryAfter);
436
+ const ms = Number.isFinite(retryAfterMs) && retryAfterMs > 0 ? Math.min(retryAfterMs, 60_000) : 1000;
437
+ await new Promise((r) => setTimeout(r, ms));
438
+ } else {
439
+ // Other transient error (5xx / network): short backoff.
440
+ await new Promise((r) => setTimeout(r, 1000));
441
+ }
442
+ }
443
+ }
360
444
  }
361
445
  this.sendCount += sentIds.length;
362
446
  } catch (err) {
363
447
  const msg = err instanceof Error ? err.message : String(err);
364
- throw new Error(`send failed after ${sentIds.length}/${chunks.length} chunk(s): ${msg}`);
448
+ const wrapped = new Error(`send failed after ${sentIds.length}/${chunks.length} chunk(s): ${msg}`);
449
+ // Preserve the opaque resume token across the rewrap so the caller can
450
+ // persist it on the queue item and resume on retry.
451
+ if (err?.resumeToken) wrapped.resumeToken = err.resumeToken;
452
+ // Propagate the permanent flag so the forwarder drops (not requeues) it.
453
+ if (err?.permanent) wrapped.permanent = true;
454
+ throw wrapped;
365
455
  }
366
456
  return { sentIds };
367
457
  }