mixdog 0.9.1 → 0.9.3

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 (301) hide show
  1. package/package.json +9 -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/anthropic-maxtokens-test.mjs +119 -0
  6. package/scripts/background-task-meta-smoke.mjs +1 -1
  7. package/scripts/bench-run.mjs +262 -0
  8. package/scripts/build-tui.mjs +13 -1
  9. package/scripts/compact-smoke.mjs +12 -0
  10. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  11. package/scripts/explore-bench.mjs +124 -0
  12. package/scripts/hook-bus-test.mjs +191 -0
  13. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  14. package/scripts/internal-comms-bench.mjs +727 -0
  15. package/scripts/internal-comms-smoke.mjs +75 -0
  16. package/scripts/lead-workflow-smoke.mjs +4 -4
  17. package/scripts/live-worker-smoke.mjs +9 -9
  18. package/scripts/output-style-bench.mjs +285 -0
  19. package/scripts/output-style-smoke.mjs +13 -10
  20. package/scripts/patch-replay.mjs +90 -0
  21. package/scripts/path-suffix-test.mjs +57 -0
  22. package/scripts/provider-stream-stall-test.mjs +276 -0
  23. package/scripts/provider-toolcall-test.mjs +599 -1
  24. package/scripts/recall-bench.mjs +207 -0
  25. package/scripts/routing-corpus.mjs +281 -0
  26. package/scripts/session-bench.mjs +1526 -0
  27. package/scripts/session-diag.mjs +595 -0
  28. package/scripts/task-bench.mjs +207 -0
  29. package/scripts/tool-failures.mjs +6 -6
  30. package/scripts/tool-smoke.mjs +310 -67
  31. package/scripts/toolcall-args-test.mjs +81 -0
  32. package/src/agents/debugger/AGENT.md +4 -4
  33. package/src/agents/heavy-worker/AGENT.md +20 -9
  34. package/src/agents/reviewer/AGENT.md +4 -4
  35. package/src/agents/worker/AGENT.md +17 -9
  36. package/src/app.mjs +10 -6
  37. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  38. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  39. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  40. package/src/headless-role.mjs +14 -14
  41. package/src/help.mjs +1 -0
  42. package/src/lib/rules-builder.cjs +32 -54
  43. package/src/mixdog-session-runtime.mjs +1040 -2036
  44. package/src/output-styles/default.md +12 -7
  45. package/src/output-styles/minimal.md +25 -0
  46. package/src/output-styles/oneline.md +21 -0
  47. package/src/output-styles/simple.md +10 -9
  48. package/src/repl.mjs +17 -7
  49. package/src/rules/agent/00-common.md +7 -5
  50. package/src/rules/agent/30-explorer.md +8 -12
  51. package/src/rules/lead/01-general.md +3 -1
  52. package/src/rules/lead/lead-tool.md +13 -2
  53. package/src/rules/shared/01-tool.md +23 -12
  54. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  55. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  59. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  60. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  61. package/src/runtime/agent/orchestrator/context/collect.mjs +182 -67
  62. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  63. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +100 -18
  65. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  66. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +313 -84
  70. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +104 -38
  71. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +28 -33
  72. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  73. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  74. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  75. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  76. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  77. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +227 -31
  78. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +83 -6
  79. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +229 -115
  80. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +213 -33
  81. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  82. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +49 -17
  83. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  84. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +22 -17
  85. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  86. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  87. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  88. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  89. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  90. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  91. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  92. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  93. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  94. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  95. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  96. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  97. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  98. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  99. package/src/runtime/agent/orchestrator/session/loop.mjs +513 -1000
  100. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  101. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  102. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  103. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  104. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  105. package/src/runtime/agent/orchestrator/session/manager.mjs +232 -1152
  106. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  107. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  108. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  109. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  110. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  111. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  113. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  114. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  115. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  116. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  117. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  118. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  119. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  120. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  121. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +14 -5
  123. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  124. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  125. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -2
  126. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  127. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  129. package/src/runtime/agent/orchestrator/tools/builtin.mjs +70 -1
  130. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  131. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  132. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  134. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  135. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  136. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  137. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  138. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  139. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  140. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  141. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  142. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +39 -4189
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +119 -5
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  148. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  149. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  150. package/src/runtime/channels/backends/discord.mjs +99 -9
  151. package/src/runtime/channels/backends/telegram.mjs +501 -0
  152. package/src/runtime/channels/index.mjs +437 -1439
  153. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  154. package/src/runtime/channels/lib/config.mjs +54 -2
  155. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  156. package/src/runtime/channels/lib/format.mjs +4 -2
  157. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  158. package/src/runtime/channels/lib/output-forwarder.mjs +88 -67
  159. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  160. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  161. package/src/runtime/channels/lib/telegram-format.mjs +280 -0
  162. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  164. package/src/runtime/channels/lib/webhook.mjs +59 -31
  165. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  166. package/src/runtime/channels/tool-defs.mjs +1 -1
  167. package/src/runtime/memory/index.mjs +465 -345
  168. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  169. package/src/runtime/memory/lib/core-memory-store.mjs +352 -2
  170. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  171. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  172. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  173. package/src/runtime/memory/lib/memory-cycle2.mjs +65 -9
  174. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  175. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  176. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  177. package/src/runtime/memory/lib/memory.mjs +121 -4
  178. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  179. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  180. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  181. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  182. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  183. package/src/runtime/memory/tool-defs.mjs +10 -7
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/background-tasks.mjs +2 -3
  186. package/src/runtime/shared/buffered-appender.mjs +149 -0
  187. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  188. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  189. package/src/runtime/shared/config.mjs +9 -0
  190. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  191. package/src/runtime/shared/schedules-store.mjs +21 -19
  192. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  193. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  194. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  195. package/src/runtime/shared/tool-surface.mjs +98 -13
  196. package/src/runtime/shared/transcript-writer.mjs +156 -0
  197. package/src/runtime/shared/update-checker.mjs +214 -0
  198. package/src/session-runtime/config-helpers.mjs +209 -0
  199. package/src/session-runtime/effort.mjs +128 -0
  200. package/src/session-runtime/fs-utils.mjs +10 -0
  201. package/src/session-runtime/model-capabilities.mjs +130 -0
  202. package/src/session-runtime/output-styles.mjs +124 -0
  203. package/src/session-runtime/plugin-mcp.mjs +114 -0
  204. package/src/session-runtime/session-text.mjs +100 -0
  205. package/src/session-runtime/statusline-route.mjs +35 -0
  206. package/src/session-runtime/tool-catalog.mjs +720 -0
  207. package/src/session-runtime/workflow.mjs +358 -0
  208. package/src/standalone/agent-tool.mjs +302 -117
  209. package/src/standalone/channel-admin.mjs +133 -40
  210. package/src/standalone/channel-worker.mjs +10 -292
  211. package/src/standalone/explore-tool.mjs +12 -5
  212. package/src/standalone/hook-bus.mjs +165 -8
  213. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  214. package/src/standalone/opencode-go-login.mjs +121 -0
  215. package/src/standalone/provider-admin.mjs +25 -3
  216. package/src/standalone/usage-dashboard.mjs +1 -1
  217. package/src/tui/App.jsx +2883 -778
  218. package/src/tui/components/ConfirmBar.jsx +47 -0
  219. package/src/tui/components/ContextPanel.jsx +5 -3
  220. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  221. package/src/tui/components/Markdown.jsx +22 -98
  222. package/src/tui/components/Message.jsx +14 -35
  223. package/src/tui/components/Picker.jsx +87 -12
  224. package/src/tui/components/PromptInput.jsx +355 -22
  225. package/src/tui/components/QueuedCommands.jsx +1 -1
  226. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  227. package/src/tui/components/Spinner.jsx +7 -7
  228. package/src/tui/components/StatusLine.jsx +40 -21
  229. package/src/tui/components/TextEntryPanel.jsx +51 -7
  230. package/src/tui/components/ToolExecution.jsx +183 -101
  231. package/src/tui/components/TurnDone.jsx +4 -4
  232. package/src/tui/components/UsagePanel.jsx +1 -1
  233. package/src/tui/components/tool-output-format.mjs +161 -23
  234. package/src/tui/components/tool-output-format.test.mjs +87 -0
  235. package/src/tui/display-width.mjs +69 -0
  236. package/src/tui/display-width.test.mjs +35 -0
  237. package/src/tui/dist/index.mjs +6731 -2333
  238. package/src/tui/engine/agent-envelope.mjs +296 -0
  239. package/src/tui/engine/boot-profile.mjs +21 -0
  240. package/src/tui/engine/labels.mjs +67 -0
  241. package/src/tui/engine/notice-text.mjs +112 -0
  242. package/src/tui/engine/queue-helpers.mjs +161 -0
  243. package/src/tui/engine/session-stats.mjs +46 -0
  244. package/src/tui/engine/tool-call-fields.mjs +23 -0
  245. package/src/tui/engine/tool-result-text.mjs +126 -0
  246. package/src/tui/engine.mjs +569 -948
  247. package/src/tui/index.jsx +117 -7
  248. package/src/tui/input-editing.mjs +58 -8
  249. package/src/tui/input-editing.selection.test.mjs +75 -0
  250. package/src/tui/keyboard-protocol.mjs +42 -0
  251. package/src/tui/lib/voice-recorder.mjs +469 -0
  252. package/src/tui/markdown/format-token.mjs +128 -76
  253. package/src/tui/markdown/format-token.test.mjs +61 -19
  254. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  255. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  256. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  257. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  258. package/src/tui/markdown/table-layout.mjs +9 -9
  259. package/src/tui/paste-attachments.mjs +36 -9
  260. package/src/tui/paste-fix.test.mjs +119 -0
  261. package/src/tui/prompt-history-store.mjs +129 -0
  262. package/src/tui/prompt-history-store.test.mjs +52 -0
  263. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  264. package/src/tui/theme.mjs +41 -657
  265. package/src/tui/themes/base.mjs +86 -0
  266. package/src/tui/themes/basic.mjs +85 -0
  267. package/src/tui/themes/catppuccin.mjs +72 -0
  268. package/src/tui/themes/dracula.mjs +70 -0
  269. package/src/tui/themes/everforest.mjs +71 -0
  270. package/src/tui/themes/gruvbox.mjs +71 -0
  271. package/src/tui/themes/index.mjs +71 -0
  272. package/src/tui/themes/indigo.mjs +78 -0
  273. package/src/tui/themes/kanagawa.mjs +80 -0
  274. package/src/tui/themes/light.mjs +81 -0
  275. package/src/tui/themes/nord.mjs +72 -0
  276. package/src/tui/themes/onedark.mjs +16 -0
  277. package/src/tui/themes/rosepine.mjs +70 -0
  278. package/src/tui/themes/teal.mjs +80 -0
  279. package/src/tui/themes/tokyonight.mjs +79 -0
  280. package/src/tui/themes/utils.mjs +106 -0
  281. package/src/tui/themes/warm.mjs +79 -0
  282. package/src/tui/transcript-tool-failures.mjs +13 -2
  283. package/src/ui/markdown.mjs +1 -1
  284. package/src/ui/model-display.mjs +2 -2
  285. package/src/ui/statusline.mjs +75 -27
  286. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  287. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  288. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  289. package/src/workflows/default/WORKFLOW.md +46 -12
  290. package/src/workflows/sequential/WORKFLOW.md +51 -0
  291. package/src/workflows/solo/WORKFLOW.md +12 -1
  292. package/vendor/ink/build/display-width.js +62 -0
  293. package/vendor/ink/build/ink.js +100 -12
  294. package/vendor/ink/build/measure-text.js +4 -1
  295. package/vendor/ink/build/output.js +115 -9
  296. package/vendor/ink/build/render-node-to-output.js +4 -1
  297. package/vendor/ink/build/render.js +4 -0
  298. package/src/output-styles/extreme-simple.md +0 -20
  299. package/src/rules/lead/04-workflow.md +0 -51
  300. package/src/workflows/default/workflow.json +0 -13
  301. package/src/workflows/solo/workflow.json +0 -7
@@ -0,0 +1,23 @@
1
+ import { performance } from "perf_hooks";
2
+
3
+ // Boot-timing instrumentation + shared local timestamp helper.
4
+ // Extracted verbatim from channels/index.mjs (behavior-preserving).
5
+ const BOOT_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ""));
6
+ const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
7
+
8
+ function bootProfile(event, fields = {}) {
9
+ if (!BOOT_PROFILE_ENABLED) return;
10
+ const elapsedMs = performance.now() - BOOT_PROFILE_START;
11
+ const parts = [`[mixdog-boot] +${elapsedMs.toFixed(1)}ms`, `channels:${event}`];
12
+ for (const [key, value] of Object.entries(fields || {})) {
13
+ if (value === undefined || value === null || value === "") continue;
14
+ parts.push(`${key}=${String(value).replace(/\s+/g, "_")}`);
15
+ }
16
+ try { process.stderr.write(`${parts.join(" ")}\n`); } catch {}
17
+ }
18
+
19
+ function localTimestamp() {
20
+ return (/* @__PURE__ */ new Date()).toLocaleString("sv-SE", { hour12: false });
21
+ }
22
+
23
+ export { BOOT_PROFILE_ENABLED, BOOT_PROFILE_START, bootProfile, localTimestamp };
@@ -1,7 +1,8 @@
1
1
  import { readFileSync, mkdirSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { DiscordBackend } from "../backends/discord.mjs";
4
- import { readSection, updateSection, CONFIG_PATH as MIXDOG_CONFIG_PATH, getDiscordToken, diagnoseDiscordTokenValue } from "../../shared/config.mjs";
4
+ import { TelegramBackend } from "../backends/telegram.mjs";
5
+ import { readSection, updateSection, CONFIG_PATH as MIXDOG_CONFIG_PATH, getDiscordToken, getTelegramToken, diagnoseDiscordTokenValue } from "../../shared/config.mjs";
5
6
  import { listSchedules } from "../../shared/schedules-store.mjs";
6
7
  import { resolvePluginData } from "../../shared/plugin-paths.mjs";
7
8
  import { isHolidaySync } from "./holidays.mjs";
@@ -15,6 +16,7 @@ const DEFAULT_ACCESS = {
15
16
  const DEFAULT_CONFIG = {
16
17
  backend: "discord",
17
18
  discord: { token: "" },
19
+ telegram: { token: "" },
18
20
  access: DEFAULT_ACCESS,
19
21
  mainChannel: "main",
20
22
  channelsConfig: {
@@ -47,6 +49,22 @@ function applyDefaults(config) {
47
49
  out.webhook = { ...CONFIG_DEFAULTS.webhook, ...(out.webhook || {}) };
48
50
  return out;
49
51
  }
52
+
53
+ function channelIdForBackend(entry = {}, backend = "discord") {
54
+ if (backend === "telegram") {
55
+ return String(entry?.telegramChatId || (entry?.discordChannelId ? "" : entry?.channelId) || "");
56
+ }
57
+ return String(entry?.discordChannelId || (entry?.telegramChatId ? "" : entry?.channelId) || "");
58
+ }
59
+
60
+ function channelsConfigForBackend(rawChannelsConfig = {}, backend = "discord") {
61
+ return Object.fromEntries(Object.entries(rawChannelsConfig || {}).map(([name, entry]) => {
62
+ const value = entry && typeof entry === "object" ? entry : {};
63
+ const channelId = channelIdForBackend(value, backend);
64
+ return [name, { ...value, channelId }];
65
+ }));
66
+ }
67
+
50
68
  /**
51
69
  * Shared DND / quiet-window helper used by scheduler + webhook.
52
70
  *
@@ -185,11 +203,20 @@ function loadConfig() {
185
203
  if (discordTokenProblem) {
186
204
  process.stderr.write(`mixdog: discord token ignored: ${discordTokenProblem}\n`);
187
205
  }
206
+ // Single-backend select: config.backend picks ONE of discord|telegram.
207
+ // Anything else falls back to the discord default.
208
+ const backend = raw.backend === "telegram" ? "telegram" : "discord";
209
+ const telegramToken = getTelegramToken();
210
+ const rawChannelsConfig = { ...DEFAULT_CONFIG.channelsConfig, ...(raw.channelsConfig || {}) };
188
211
  return applyDefaults({
189
212
  ...DEFAULT_CONFIG,
190
213
  ...raw,
191
- backend: "discord",
214
+ backend,
215
+ channelsConfig: channelsConfigForBackend(rawChannelsConfig, backend),
192
216
  discord: { ...DEFAULT_CONFIG.discord, ...(({ token: _, ...rest }) => rest)(raw.discord || {}), ...(discordToken && !discordTokenProblem ? { token: discordToken } : {}) },
217
+ // Merge the keychain-resolved telegram token (harmless when backend is
218
+ // discord; the secret never lands in the on-disk config either way).
219
+ telegram: { ...DEFAULT_CONFIG.telegram, ...(({ token: _t, ...rest }) => rest)(raw.telegram || {}), ...(telegramToken ? { token: telegramToken } : {}) },
193
220
  access: {
194
221
  ...DEFAULT_ACCESS,
195
222
  // Drop the retired pairing-era keys at the config layer too (the
@@ -218,6 +245,10 @@ function loadConfig() {
218
245
  }
219
246
  const HEADLESS_BACKEND = {
220
247
  name: "headless",
248
+ MAX_MESSAGE_LENGTH: 2000,
249
+ formatOutgoing(t) {
250
+ return t;
251
+ },
221
252
  async connect() {
222
253
  },
223
254
  async disconnect() {
@@ -244,6 +275,26 @@ const HEADLESS_BACKEND = {
244
275
  }
245
276
  };
246
277
  function createBackend(config) {
278
+ // Single-backend select: exactly one backend is constructed based on
279
+ // config.backend (discord|telegram). The two are mutually exclusive.
280
+ if (config.backend === "telegram") {
281
+ const telegramToken = getTelegramToken();
282
+ if (!telegramToken) {
283
+ process.stderr.write("mixdog: telegram bot not configured; channel runtime running in headless mode\n");
284
+ return HEADLESS_BACKEND;
285
+ }
286
+ const tgStateDir = config.telegram?.stateDir ?? join(DATA_DIR, "telegram");
287
+ mkdirSync(tgStateDir, { recursive: true });
288
+ return new TelegramBackend({
289
+ ...config.telegram,
290
+ configPath: CONFIG_FILE,
291
+ access: config.access,
292
+ // Single-source channel setup: the main chat is auto-allowed inside
293
+ // TelegramBackend.loadAccess() so a configured channelsConfig.main.channelId
294
+ // is enough for both inbound gating and outbound.
295
+ mainChannelId: config.channelsConfig?.main?.channelId
296
+ }, tgStateDir);
297
+ }
247
298
  const discordToken = getDiscordToken();
248
299
  const discordTokenProblem = diagnoseDiscordTokenValue(discordToken, config);
249
300
  if (discordTokenProblem) {
@@ -278,6 +329,7 @@ export {
278
329
  DEFAULT_HOLIDAY_COUNTRY,
279
330
  createBackend,
280
331
  getDiscordToken,
332
+ getTelegramToken,
281
333
  isInQuietWindow,
282
334
  loadConfig,
283
335
  loadProfileConfig
@@ -0,0 +1,106 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { DATA_DIR } from "./config.mjs";
4
+ import { localTimestamp } from "./boot-profile.mjs";
5
+
6
+ // Crash logging + degraded-state tracking for the channels worker.
7
+ // Extracted verbatim from channels/index.mjs (behavior-preserving).
8
+ //
9
+ // Degraded/stderr-broken state is module-scoped here; index.mjs reads it via
10
+ // isChannelsDegraded() and installs the process-level unhandledRejection/
11
+ // uncaughtException handlers (which need the worker's stop()).
12
+ let crashLogging = false;
13
+ let _channelsDegraded = false;
14
+ let _stderrBroken = false;
15
+ function isChannelsDegraded() { return _channelsDegraded; }
16
+
17
+ // stderr can break when the parent stdio pipe closes. Node then emits an
18
+ // async 'error' on process.stderr, which sync try/catch around write() does
19
+ // not catch — without a listener, that error becomes uncaughtException and
20
+ // re-enters logCrash, looping until the disk fills. Register a suppressor
21
+ // once at load time and stop writing to stderr after the first EPIPE so the
22
+ // loop cannot start.
23
+ try {
24
+ process.stderr.on('error', (e) => {
25
+ if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
26
+ _stderrBroken = true;
27
+ _channelsDegraded = true;
28
+ }
29
+ });
30
+ } catch {}
31
+
32
+ // Crash log guards: dedup repeated identical errors (a single broken handler
33
+ // can fire thousands of times per minute) and rotate at a 10 MB cap so the
34
+ // file cannot grow unbounded. One .old generation is kept; older rolls drop.
35
+ const CRASH_LOG_MAX_BYTES = 10 * 1024 * 1024;
36
+ let _lastCrashSig = "";
37
+ let _crashRepeatCount = 0;
38
+
39
+ function _writeCrashLine(crashLog, line) {
40
+ try {
41
+ let size = 0;
42
+ try { size = fs.statSync(crashLog).size; } catch {}
43
+ if (size + line.length > CRASH_LOG_MAX_BYTES) {
44
+ try { fs.renameSync(crashLog, crashLog + ".old"); } catch {}
45
+ }
46
+ fs.appendFileSync(crashLog, line);
47
+ } catch {}
48
+ }
49
+
50
+ function logCrash(label, err) {
51
+ if (crashLogging) return;
52
+ crashLogging = true;
53
+ const msg = `[${localTimestamp()}] mixdog: ${label}: ${err}
54
+ ${err instanceof Error ? err.stack : ""}
55
+ `;
56
+ if (!_stderrBroken) {
57
+ try { process.stderr.write(msg); } catch (e) {
58
+ if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
59
+ _stderrBroken = true;
60
+ }
61
+ }
62
+ }
63
+ const sig = `${label}|${err && err.message ? err.message : String(err)}`;
64
+ const crashLog = path.join(DATA_DIR, "crash.log");
65
+ if (sig === _lastCrashSig) {
66
+ // Same error repeating — count it but skip the disk write. The next
67
+ // distinct error (or EPIPE branch below) flushes the suppressed total.
68
+ _crashRepeatCount += 1;
69
+ } else {
70
+ if (_crashRepeatCount > 0) {
71
+ _writeCrashLine(crashLog, `[${localTimestamp()}] mixdog: previous error repeated ${_crashRepeatCount} more time(s)\n`);
72
+ _crashRepeatCount = 0;
73
+ }
74
+ _lastCrashSig = sig;
75
+ _writeCrashLine(crashLog, msg);
76
+ }
77
+ if (err instanceof Error && err.message.includes("EPIPE")) {
78
+ _channelsDegraded = true;
79
+ _stderrBroken = true;
80
+ }
81
+ crashLogging = false;
82
+ }
83
+
84
+ // Benign whitelist: transient EPERM/EACCES/EBUSY on the active-instance
85
+ // rename path is expected under Windows file-lock contention and is
86
+ // already retried elsewhere (atomic-file.mjs RETRY_CODES) — a single
87
+ // occurrence must NOT be fatal, only a run of 3+ in a row without an
88
+ // intervening distinct/successful event.
89
+ const BENIGN_CRASH_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
90
+ const BENIGN_CRASH_FATAL_THRESHOLD = 3;
91
+ // "In a row" needs a time dimension: benign errors minutes/hours apart are
92
+ // independent contention events, not a corrupted-state run. Only count a
93
+ // streak when hits land within this window of the previous one.
94
+ const BENIGN_CRASH_STREAK_WINDOW_MS = 60_000;
95
+ function _isBenignCrash(err) {
96
+ const code = err?.code || (/\b(EPERM|EACCES|EBUSY)\b/.exec(String(err?.message || err)) || [])[0];
97
+ return BENIGN_CRASH_CODES.has(code);
98
+ }
99
+
100
+ export {
101
+ isChannelsDegraded,
102
+ logCrash,
103
+ _isBenignCrash,
104
+ BENIGN_CRASH_FATAL_THRESHOLD,
105
+ BENIGN_CRASH_STREAK_WINDOW_MS,
106
+ };
@@ -140,7 +140,8 @@ function safeCodeBlock(content, lang = "") {
140
140
  const escaped = content.replace(/```/g, "`​``");
141
141
  return "```" + lang + "\n" + escaped + "\n```";
142
142
  }
143
- function chunk(text, limit = 2e3) {
143
+ const MAX_DISCORD_MESSAGE = 2000;
144
+ function chunk(text, limit = MAX_DISCORD_MESSAGE) {
144
145
  if (text.length <= limit) return [text];
145
146
  const out = [];
146
147
  let rest = text;
@@ -184,5 +185,6 @@ function chunk(text, limit = 2e3) {
184
185
  export {
185
186
  chunk,
186
187
  formatForDiscord,
187
- safeCodeBlock
188
+ safeCodeBlock,
189
+ MAX_DISCORD_MESSAGE
188
190
  };
@@ -0,0 +1,72 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { DATA_DIR } from "./config.mjs";
4
+
5
+ // Drop-trace instrumentation for channels/index.mjs.
6
+ // Extracted verbatim (behavior-preserving). Distinct from lib/drop-trace.mjs
7
+ // (which the output-forwarder uses): this instance is owned by index.mjs and
8
+ // keeps its own buffered writer + `preview` helper so the two trace streams
9
+ // stay byte-identical to the pre-split behavior.
10
+ const _dropTraceLog = path.join(DATA_DIR, "drop-trace.log");
11
+ const DROP_TRACE_ENABLED =
12
+ process.env.MIXDOG_DROP_TRACE === "1" ||
13
+ process.env.MIXDOG_DROP_TRACE === "true" ||
14
+ process.env.MIXDOG_DEBUG_CHANNELS === "1" ||
15
+ process.env.MIXDOG_DEBUG_CHANNELS === "true";
16
+ // One-shot rotation for drop-trace.log at worker boot.
17
+ if (DROP_TRACE_ENABLED) {
18
+ try { if (fs.statSync(_dropTraceLog).size > 10 * 1024 * 1024) fs.renameSync(_dropTraceLog, _dropTraceLog + '.1') } catch {}
19
+ }
20
+
21
+ // ── Buffered drop-trace writer (channels/index) ──────────────────────────────
22
+ // Flushes every 1 s OR when buffer reaches 64 KB — whichever fires first.
23
+ // Drains on process exit so no log lines are lost.
24
+ let _dtIdxBuf = "";
25
+ let _dtIdxBytes = 0;
26
+ let _dtIdxFlushTimer = null;
27
+ let _dtIdxStream = null;
28
+ function _dtIdxGetStream() {
29
+ if (!_dtIdxStream) _dtIdxStream = fs.createWriteStream(_dropTraceLog, { flags: "a" });
30
+ return _dtIdxStream;
31
+ }
32
+ async function _dtIdxFlush() {
33
+ if (_dtIdxFlushTimer) { clearTimeout(_dtIdxFlushTimer); _dtIdxFlushTimer = null; }
34
+ if (!_dtIdxBuf) return;
35
+ const stream = _dtIdxGetStream();
36
+ const buf = _dtIdxBuf;
37
+ _dtIdxBuf = "";
38
+ _dtIdxBytes = 0;
39
+ try {
40
+ const ok = stream.write(buf);
41
+ if (!ok) { const { once } = await import("node:events"); await once(stream, "drain").catch(() => {}); }
42
+ } catch {}
43
+ }
44
+ function _dtIdxScheduleFlush() {
45
+ if (_dtIdxFlushTimer) return;
46
+ _dtIdxFlushTimer = setTimeout(() => { void _dtIdxFlush(); }, 1000);
47
+ if (_dtIdxFlushTimer.unref) _dtIdxFlushTimer.unref();
48
+ }
49
+ function _dtIdxAppend(line) {
50
+ _dtIdxBuf += line;
51
+ _dtIdxBytes += Buffer.byteLength(line);
52
+ if (_dtIdxBytes >= 65536) { void _dtIdxFlush(); return; }
53
+ _dtIdxScheduleFlush();
54
+ }
55
+ process.on("exit", () => { void _dtIdxFlush(); });
56
+
57
+ function preview(text) {
58
+ if (!text) return "";
59
+ const s = String(text).replace(/\n/g, "\\n");
60
+ return s.length > 120 ? s.slice(0, 120) + "…" : s;
61
+ }
62
+ function dropTrace(event, fields) {
63
+ if (!DROP_TRACE_ENABLED) return;
64
+ try {
65
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
66
+ const loc = `[${ts}][pid=${process.pid}] ${event}`;
67
+ const kv = fields ? " " + Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(" ") : "";
68
+ _dtIdxAppend(loc + kv + "\n");
69
+ } catch {}
70
+ }
71
+
72
+ export { DROP_TRACE_ENABLED, dropTrace, preview, _dtIdxFlush };
@@ -1,6 +1,6 @@
1
1
  import { existsSync, statSync, watch, openSync, readSync, closeSync } from "fs";
2
2
  import { createHash } from "crypto";
3
- import { formatForDiscord, chunk, safeCodeBlock } from "./format.mjs";
3
+ import { safeCodeBlock } from "./format.mjs";
4
4
  import { dropTrace, _dtPreview } from "./drop-trace.mjs";
5
5
  import {
6
6
  formatToolSurface,
@@ -154,6 +154,14 @@ class OutputForwarder {
154
154
  try {
155
155
  const stat = this._pendingStat ?? statSync(this.transcriptPath);
156
156
  this._pendingStat = null;
157
+ // File shrank below our cursor: the transcript was rotated out from
158
+ // under us (buffered-appender rotation, or an external truncation).
159
+ // Treat it as a fresh file and reset the cursor instead of getting
160
+ // stuck forever (stat.size <= readFileSize would otherwise short
161
+ // circuit below and never read the new content).
162
+ if (stat.size < this.readFileSize) {
163
+ this.readFileSize = 0;
164
+ }
157
165
  if (stat.size <= this.readFileSize) {
158
166
  return { lines: [], nextFileSize: this.readFileSize };
159
167
  }
@@ -282,7 +290,12 @@ class OutputForwarder {
282
290
  this.commitReadProgress(item.nextFileSize);
283
291
  return;
284
292
  }
285
- const formatted = item.preformatted ? item.text : formatForDiscord(item.text);
293
+ // Formatting is routed through the active backend via the send-callback so
294
+ // the forwarder stays backend-agnostic (Discord/Telegram/etc). Preformatted
295
+ // items (tool logs) are sent as-is. Fallback to raw text when no hook.
296
+ const formatted = item.preformatted
297
+ ? item.text
298
+ : (this.cb.formatOutgoing ? this.cb.formatOutgoing(item.text) : item.text);
286
299
  const hash = item.skipHashDedup
287
300
  ? ""
288
301
  : item.dedupKey
@@ -292,48 +305,25 @@ class OutputForwarder {
292
305
  this.commitReadProgress(item.nextFileSize);
293
306
  return;
294
307
  }
295
- const chunks = chunk(formatted, 2e3);
296
- const _t0Send = Date.now();
297
- // Resume from _nextChunkIdx if this item is being retried after a partial send.
298
- // This avoids re-sending chunks that already landed successfully.
299
- if (item._chunks === undefined) {
300
- item._chunks = chunks;
301
- item._nextChunkIdx = 0;
302
- item._sendRetries = 0;
303
- }
304
- for (let _ci = item._nextChunkIdx; _ci < item._chunks.length; _ci++) {
305
- const c = item._chunks[_ci];
306
- try {
307
- await this.cb.send(targetChannelId, c);
308
- item._nextChunkIdx = _ci + 1;
309
- dropTrace("discord.send.ok", null);
310
- } catch (err) {
311
- // Discord 429 or transient error — honour Retry-After then re-throw so
312
- // drainQueue's retry loop calls deliverQueueItem again. Chunk progress
313
- // is stored on item so we resume from the failed chunk, not chunk 0.
314
- const status = err?.status ?? err?.code ?? err?.httpStatus;
315
- const retryAfter = err?.retryAfter ?? err?.retry_after
316
- ?? err?.headers?.["retry-after"] ?? err?.response?.headers?.["retry-after"];
317
- dropTrace("discord.send.err", { channelId: this.channelId, chunkIndex: _ci, status, retryAfter: retryAfter ?? "(none)", err: String(err) });
318
- item._sendRetries = (item._sendRetries || 0) + 1;
319
- if (item._sendRetries >= 3) {
320
- // Cap retries to avoid infinite duplicate loop — give up on this item
321
- process.stderr.write(`[output-forwarder] chunk send exceeded 3 retries at chunk ${_ci}, dropping item\n`);
322
- item._nextChunkIdx = item._chunks.length; // mark exhausted
323
- return;
324
- }
325
- if (status === 429) {
326
- if (retryAfter != null) {
327
- const ms = Number(retryAfter) > 1000 ? Number(retryAfter) : Number(retryAfter) * 1000;
328
- if (Number.isFinite(ms) && ms > 0) {
329
- await new Promise((r) => setTimeout(r, Math.min(ms, 60_000)));
330
- }
331
- } else {
332
- await new Promise((r) => setTimeout(r, 1000));
333
- }
334
- }
335
- throw err;
336
- }
308
+ // Backend owns chunking + send retry: pass the whole (unchunked) text once.
309
+ // On failure the backend has already exhausted its per-chunk retries and
310
+ // throws; we re-throw so drainQueue/scheduleRetry requeues the whole item.
311
+ try {
312
+ // Hand back any opaque resume token stored from a prior partial-send
313
+ // failure so the backend resumes at the failed chunk instead of
314
+ // re-sending chunks that already landed. The token is opaque here —
315
+ // the forwarder only stores/passes/clears it, never interprets it.
316
+ await this.cb.send(targetChannelId, formatted, item._resumeToken ? { resumeToken: item._resumeToken } : undefined);
317
+ // Full success drop any stale token so a later reuse of this item
318
+ // object can't carry a dead token.
319
+ item._resumeToken = undefined;
320
+ dropTrace("discord.send.ok", null);
321
+ } catch (err) {
322
+ // Persist the opaque resume token (if any) on the item so the requeued
323
+ // retry resumes from the failed chunk.
324
+ if (err?.resumeToken) item._resumeToken = err.resumeToken;
325
+ dropTrace("discord.send.err", { channelId: this.channelId, err: String(err) });
326
+ throw err;
337
327
  }
338
328
  if (!item.skipHashDedup) {
339
329
  this.lastHash = hash;
@@ -344,7 +334,9 @@ class OutputForwarder {
344
334
 
345
335
  ${_bt.trim()}` : _bt.trim();
346
336
  }
347
- this.sentCount += chunks.length;
337
+ // sentCount now tracks delivered items (the forwarder no longer knows the
338
+ // backend's chunk count). Increment by 1 per delivered item.
339
+ this.sentCount += 1;
348
340
  this.commitReadProgress(item.nextFileSize);
349
341
  }
350
342
  scheduleRetry() {
@@ -375,7 +367,13 @@ ${_bt.trim()}` : _bt.trim();
375
367
  // finalLane[0] is being drained when this.sending; coalesce into tail only
376
368
  // when tail is not index 0 (i.e. length >= 2) or drain is not in flight.
377
369
  const ftLen = this.finalLane.length;
378
- const ftTail = (ftLen > 0 && !(this.sending && ftLen === 1))
370
+ // A partially-delivered item carries an opaque _resumeToken pinned (by
371
+ // hash) to its exact text. Mutating its text here would break that hash on
372
+ // the next retry, forcing the backend to full-resend from chunk 0 and
373
+ // duplicate the chunks already delivered. Treat any tokenized item as
374
+ // sealed: never coalesce/cap-merge into it; new text goes behind it as a
375
+ // separate item.
376
+ const ftTail = (ftLen > 0 && !(this.sending && ftLen === 1) && !this.finalLane[ftLen - 1]._resumeToken)
379
377
  ? this.finalLane[ftLen - 1] : null;
380
378
  if (ftTail) {
381
379
  ftTail.text = `${ftTail.text}\n\n${newText}`;
@@ -391,6 +389,8 @@ ${_bt.trim()}` : _bt.trim();
391
389
  const capEnd = this.sending ? 1 : 0;
392
390
  for (let i = this.finalLane.length - 1; i >= capEnd; i--) {
393
391
  const cap = this.finalLane[i];
392
+ // Never fold into a sealed (partially-delivered) item — see above.
393
+ if (cap._resumeToken) continue;
394
394
  cap.text = `${cap.text}\n\n${newText}`;
395
395
  cap.bufferText = `${cap.bufferText}\n\n${newText}`;
396
396
  cap.nextFileSize = nextFileSize;
@@ -446,8 +446,20 @@ ${_bt.trim()}` : _bt.trim();
446
446
  }
447
447
  lane.shift();
448
448
  } catch (err) {
449
- process.stderr.write(`mixdog: send failed: ${err}
450
- `);
449
+ // Permanent (non-retryable) send failure — e.g. Telegram 400 "chat
450
+ // not found", Discord 403/404. Retrying would loop forever and wedge
451
+ // the queue, so DROP the item: advance the read cursor past its bytes
452
+ // (so it never reprocesses), clear any stale resume token, and keep
453
+ // draining the next item.
454
+ if (err?.permanent) {
455
+ process.stderr.write(`[output-forwarder] permanent send failure, dropping item: ${err?.message || err}\n`);
456
+ dropTrace("drain.send.permanent", { lane: fromFinal ? "final" : "stream", itemType: item?.type, err: String(err) });
457
+ item._resumeToken = undefined;
458
+ this.commitReadProgress(item.nextFileSize);
459
+ lane.shift();
460
+ continue;
461
+ }
462
+ process.stderr.write(`[output-forwarder] send failed: ${err}\n`);
451
463
  dropTrace("drain.send.err", { finalLen: this.finalLane.length, streamLen: this.streamLane.length, lane: fromFinal ? "final" : "stream", itemType: item?.type, err: String(err) });
452
464
  this.scheduleRetry();
453
465
  break;
@@ -467,8 +479,7 @@ ${_bt.trim()}` : _bt.trim();
467
479
  }
468
480
  await this.cb.react(this.channelId, this.userMessageId, newEmoji);
469
481
  this.emoji = newEmoji;
470
- } catch {
471
- }
482
+ } catch {} // best-effort: reaction is non-critical
472
483
  }
473
484
  await this.deliverQueueItem(item);
474
485
  }
@@ -487,7 +498,7 @@ ${_bt.trim()}` : _bt.trim();
487
498
  try {
488
499
  this.pendingFinalFlush = true;
489
500
  this.updateState((state) => { state.pendingFinalFlush = true; });
490
- } catch {}
501
+ } catch {} // best-effort: durable flush marker is non-critical
491
502
  if (retries < 5) {
492
503
  setTimeout(() => void this.forwardFinalText(retries + 1, channelId), 300);
493
504
  } else {
@@ -511,8 +522,7 @@ ${_bt.trim()}` : _bt.trim();
511
522
  if (this.userMessageId && this.emoji) {
512
523
  try {
513
524
  await this.cb.removeReaction(channelId, this.userMessageId, this.emoji);
514
- } catch {
515
- }
525
+ } catch {} // best-effort: remove reaction is non-critical
516
526
  }
517
527
  const { text: newText, nextFileSize } = this.extractNewText();
518
528
  if (newText) {
@@ -520,14 +530,23 @@ ${_bt.trim()}` : _bt.trim();
520
530
  try {
521
531
  await this.deliverQueueItem(finalItem);
522
532
  } catch (err) {
523
- // Transient send failure: extractNewText already advanced the read
524
- // cursor past these bytes, so dropping the item here would lose the
525
- // final text. Requeue it (it retains per-chunk send progress) and let
526
- // drainQueue retry instead of silently discarding. pendingFinalFlush
527
- // stays set so a process restart can also resume the flush.
528
- this.finalLane.push(finalItem);
529
- this.scheduleRetry();
530
- return;
533
+ // Permanent (non-retryable) failure drop instead of requeue so we
534
+ // don't loop forever on a dead channel. extractNewText already
535
+ // advanced the read cursor, so commit it and let the frame go.
536
+ if (err?.permanent) {
537
+ process.stderr.write(`[output-forwarder] permanent final-send failure, dropping: ${err?.message || err}\n`);
538
+ this.commitReadProgress(nextFileSize);
539
+ } else {
540
+ // Transient send failure: extractNewText already advanced the read
541
+ // cursor past these bytes, so dropping the item here would lose the
542
+ // final text. Requeue the WHOLE item and let drainQueue retry
543
+ // instead of silently discarding. The backend owns chunking+retry,
544
+ // so a requeued send restarts from chunk 0. pendingFinalFlush stays
545
+ // set so a process restart can also resume the flush.
546
+ this.finalLane.push(finalItem);
547
+ this.scheduleRetry();
548
+ return;
549
+ }
531
550
  }
532
551
  } else {
533
552
  this.commitReadProgress(nextFileSize);
@@ -546,7 +565,7 @@ ${_bt.trim()}` : _bt.trim();
546
565
  try {
547
566
  this.pendingFinalFlush = false;
548
567
  this.updateState((state) => { state.pendingFinalFlush = false; });
549
- } catch {}
568
+ } catch {} // best-effort: clear flush marker is non-critical
550
569
  this.updateState((state) => {
551
570
  state.sessionIdle = true;
552
571
  });
@@ -569,7 +588,7 @@ ${_bt.trim()}` : _bt.trim();
569
588
  // isHidden, because that helper would re-consult the module-local
570
589
  // HIDDEN_TOOLS Set and ignore the OutputForwarder static.
571
590
  if (OutputForwarder.HIDDEN_TOOLS.has(name)) return true;
572
- if ((name.includes("plugin_mixdog") && !name.endsWith("recall_memory")) || name === "reply" || name === "react" || name === "edit_message" || name === "fetch" || name === "download_attachment") return true;
591
+ if (name === "reply" || name === "react" || name === "edit_message" || name === "fetch" || name === "download_attachment") return true;
573
592
  return false;
574
593
  };
575
594
  /** Concatenate text blocks from a transcript entry (user or assistant). */
@@ -684,13 +703,12 @@ ${_bt.trim()}` : _bt.trim();
684
703
  this.watchDebounce = null;
685
704
  let _wfStat = null;
686
705
  if (this.transcriptPath) {
687
- try { _wfStat = statSync(this.transcriptPath); } catch {}
706
+ try { _wfStat = statSync(this.transcriptPath); } catch {} // best-effort: stat during flush
688
707
  }
689
708
  if (this.transcriptPath && !_wfStat) {
690
709
  const relocated = detectCurrentSessionTranscript()?.transcriptPath ?? findLatestTranscriptByMtime();
691
710
  if (relocated && relocated !== this.transcriptPath) {
692
- process.stderr.write(`mixdog: watched transcript gone during flush, relocated to ${relocated}
693
- `);
711
+ process.stderr.write(`[output-forwarder] watched transcript gone during flush, relocated to ${relocated}\n`);
694
712
  dropTrace("watch.flush.relocate", { from: this.transcriptPath, to: relocated });
695
713
  this.closeWatcher();
696
714
  this.transcriptPath = relocated;
@@ -754,5 +772,8 @@ ${_bt.trim()}` : _bt.trim();
754
772
  }
755
773
  }
756
774
  export {
757
- OutputForwarder
775
+ OutputForwarder,
776
+ discoverSessionBoundTranscript,
777
+ findLatestTranscriptByMtime,
778
+ sameResolvedPath
758
779
  };
@@ -68,6 +68,7 @@ function isPidAlive(pid) {
68
68
  return e?.code === "EPERM";
69
69
  }
70
70
  }
71
+ const UI_HEARTBEAT_STALE_MS = 5 * 60 * 1e3;
71
72
  function activeInstanceStaleReason(state) {
72
73
  const ownerPid = getActiveOwnerPid(state);
73
74
  if (!isPidAlive(ownerPid)) return `owner PID ${ownerPid ?? "unknown"} is dead`;
@@ -77,8 +78,35 @@ function activeInstanceStaleReason(state) {
77
78
  if (workerPid && !isPidAlive(workerPid)) return `worker PID ${workerPid} is dead`;
78
79
  const serverPid = parsePositivePid(state?.server_pid);
79
80
  if (serverPid && !isPidAlive(serverPid)) return `server PID ${serverPid} is dead`;
81
+ // Zombie-Lead repro (2026-07-02): a Lead's owner/channels/worker/server
82
+ // PIDs can all still be alive (process not killed) while the TUI's render
83
+ // loop is dead in the water — no signal ever fires, so pid-only staleness
84
+ // never trips. If the TUI is heartbeating (field present), treat a stale
85
+ // heartbeat as stale ownership too. Backward-compat: state written by an
86
+ // older/non-TUI process (or before the first heartbeat tick) simply omits
87
+ // ui_heartbeat_at, so this branch is a no-op and pid-only judgment stands.
88
+ const uiHeartbeatAt = Number(state?.ui_heartbeat_at);
89
+ if (Number.isFinite(uiHeartbeatAt) && uiHeartbeatAt > 0) {
90
+ const age = Date.now() - uiHeartbeatAt;
91
+ if (age > UI_HEARTBEAT_STALE_MS) {
92
+ return `ui heartbeat stale (${Math.round(age / 1000)}s since last tick)`;
93
+ }
94
+ }
80
95
  return null;
81
96
  }
97
+ // Called from src/tui on a 30s timer while the render loop is alive. Only
98
+ // touches ui_heartbeat_at (and updatedAt) so it never races/clobbers the
99
+ // channels worker's own refreshActiveInstance() writes.
100
+ function touchUiHeartbeat(instanceId) {
101
+ ensureRuntimeDirs();
102
+ try {
103
+ updateJsonAtomicSync(ACTIVE_INSTANCE_FILE, (curRaw) => {
104
+ if (!curRaw) return undefined;
105
+ if (instanceId && curRaw.instanceId !== instanceId) return undefined;
106
+ return { ...curRaw, ui_heartbeat_at: Date.now(), updatedAt: Date.now() };
107
+ }, { compact: true, fsync: false, fsyncDir: false });
108
+ } catch { /* best-effort; a missed tick just relies on the next one */ }
109
+ }
82
110
  function buildRuntimeIdentity() {
83
111
  const terminalLeadPid = getTerminalLeadPid();
84
112
  const serverPid = getServerPid();
@@ -487,5 +515,6 @@ export {
487
515
  readActiveInstance,
488
516
  refreshActiveInstance,
489
517
  releaseOwnedChannelLocks,
518
+ touchUiHeartbeat,
490
519
  writeServerPid
491
520
  };
@@ -8,7 +8,7 @@ import { runScript as execScript, ensureNopluginDir } from "./executor.mjs";
8
8
  import { withFileLockSync } from "../../shared/atomic-file.mjs";
9
9
  import { makeAgentDispatch } from '../../agent/orchestrator/agent-runtime/agent-dispatch.mjs';
10
10
 
11
- const schedulerLlm = makeAgentDispatch({ taskType: 'scheduler-task', role: 'scheduler-task', sourceType: 'scheduler' });
11
+ const schedulerLlm = makeAgentDispatch({ taskType: 'scheduler-task', agent: 'scheduler-task', sourceType: 'scheduler' });
12
12
  const SCHEDULE_LOG = join(DATA_DIR, "schedule.log");
13
13
  // Buffered async logger — coalesces per-line appends into batched writes.
14
14
  let _schedLogBuf = [];