mixdog 0.9.0 → 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 (240) hide show
  1. package/package.json +10 -3
  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/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -1,12 +1,5 @@
1
- function hasCliWorker() {
2
- return true;
3
- }
4
1
  function startCliWorker(_options) {
5
2
  }
6
- async function stopCliWorker() {
7
- }
8
3
  export {
9
- hasCliWorker,
10
- startCliWorker,
11
- stopCliWorker
4
+ startCliWorker
12
5
  };
@@ -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) {
@@ -276,9 +327,9 @@ function loadProfileConfig() {
276
327
  export {
277
328
  DATA_DIR,
278
329
  DEFAULT_HOLIDAY_COUNTRY,
279
- applyDefaults,
280
330
  createBackend,
281
331
  getDiscordToken,
332
+ getTelegramToken,
282
333
  isInQuietWindow,
283
334
  loadConfig,
284
335
  loadProfileConfig
@@ -68,4 +68,4 @@ function dropTrace(event, fields) {
68
68
  } catch {}
69
69
  }
70
70
 
71
- export { dropTrace, _dtPreview, DROP_TRACE_ENABLED };
71
+ export { dropTrace, _dtPreview };
@@ -196,8 +196,5 @@ export {
196
196
  ensureNopluginDir,
197
197
  evaluateFilter,
198
198
  logEvent,
199
- parseGeneric,
200
- parseGithub,
201
- parseSentry,
202
199
  runScript
203
200
  };
@@ -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
  };
@@ -109,41 +109,3 @@ export async function ingestTranscript(filePath, { cwd } = {}) {
109
109
  }
110
110
  }
111
111
 
112
- export async function listCoreMemories(args = {}) {
113
- const rawProjectId = args && Object.prototype.hasOwnProperty.call(args, 'project_id')
114
- ? args.project_id
115
- : args?.projectScope
116
- const projectId = rawProjectId == null || rawProjectId === 'all' ? '*' : rawProjectId
117
- const result = await memoryFetch('POST', '/api/tool', {
118
- name: 'memory',
119
- arguments: {
120
- action: 'core',
121
- op: 'list',
122
- project_id: projectId,
123
- },
124
- }, 30_000)
125
- if (!result || result.error) {
126
- throw new Error(result?.error || 'core memory list: empty response')
127
- }
128
- return result
129
- }
130
-
131
- export async function searchMemories(args = {}) {
132
- const result = await memoryFetch('POST', '/api/tool', {
133
- name: 'search_memories',
134
- arguments: args && typeof args === 'object' ? args : {},
135
- }, 30_000)
136
- if (!result || result.error) {
137
- throw new Error(result?.error || 'memory_search: empty response')
138
- }
139
- return result
140
- }
141
-
142
- export async function isHealthy() {
143
- try {
144
- const result = await memoryFetch('GET', '/health')
145
- return result.status === 'ok'
146
- } catch {
147
- return false
148
- }
149
- }
@@ -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,
@@ -282,7 +282,12 @@ class OutputForwarder {
282
282
  this.commitReadProgress(item.nextFileSize);
283
283
  return;
284
284
  }
285
- const formatted = item.preformatted ? item.text : formatForDiscord(item.text);
285
+ // Formatting is routed through the active backend via the send-callback so
286
+ // the forwarder stays backend-agnostic (Discord/Telegram/etc). Preformatted
287
+ // items (tool logs) are sent as-is. Fallback to raw text when no hook.
288
+ const formatted = item.preformatted
289
+ ? item.text
290
+ : (this.cb.formatOutgoing ? this.cb.formatOutgoing(item.text) : item.text);
286
291
  const hash = item.skipHashDedup
287
292
  ? ""
288
293
  : item.dedupKey
@@ -292,48 +297,25 @@ class OutputForwarder {
292
297
  this.commitReadProgress(item.nextFileSize);
293
298
  return;
294
299
  }
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
- }
300
+ // Backend owns chunking + send retry: pass the whole (unchunked) text once.
301
+ // On failure the backend has already exhausted its per-chunk retries and
302
+ // throws; we re-throw so drainQueue/scheduleRetry requeues the whole item.
303
+ try {
304
+ // Hand back any opaque resume token stored from a prior partial-send
305
+ // failure so the backend resumes at the failed chunk instead of
306
+ // re-sending chunks that already landed. The token is opaque here —
307
+ // the forwarder only stores/passes/clears it, never interprets it.
308
+ await this.cb.send(targetChannelId, formatted, item._resumeToken ? { resumeToken: item._resumeToken } : undefined);
309
+ // Full success drop any stale token so a later reuse of this item
310
+ // object can't carry a dead token.
311
+ item._resumeToken = undefined;
312
+ dropTrace("discord.send.ok", null);
313
+ } catch (err) {
314
+ // Persist the opaque resume token (if any) on the item so the requeued
315
+ // retry resumes from the failed chunk.
316
+ if (err?.resumeToken) item._resumeToken = err.resumeToken;
317
+ dropTrace("discord.send.err", { channelId: this.channelId, err: String(err) });
318
+ throw err;
337
319
  }
338
320
  if (!item.skipHashDedup) {
339
321
  this.lastHash = hash;
@@ -344,7 +326,9 @@ class OutputForwarder {
344
326
 
345
327
  ${_bt.trim()}` : _bt.trim();
346
328
  }
347
- this.sentCount += chunks.length;
329
+ // sentCount now tracks delivered items (the forwarder no longer knows the
330
+ // backend's chunk count). Increment by 1 per delivered item.
331
+ this.sentCount += 1;
348
332
  this.commitReadProgress(item.nextFileSize);
349
333
  }
350
334
  scheduleRetry() {
@@ -375,7 +359,13 @@ ${_bt.trim()}` : _bt.trim();
375
359
  // finalLane[0] is being drained when this.sending; coalesce into tail only
376
360
  // when tail is not index 0 (i.e. length >= 2) or drain is not in flight.
377
361
  const ftLen = this.finalLane.length;
378
- const ftTail = (ftLen > 0 && !(this.sending && ftLen === 1))
362
+ // A partially-delivered item carries an opaque _resumeToken pinned (by
363
+ // hash) to its exact text. Mutating its text here would break that hash on
364
+ // the next retry, forcing the backend to full-resend from chunk 0 and
365
+ // duplicate the chunks already delivered. Treat any tokenized item as
366
+ // sealed: never coalesce/cap-merge into it; new text goes behind it as a
367
+ // separate item.
368
+ const ftTail = (ftLen > 0 && !(this.sending && ftLen === 1) && !this.finalLane[ftLen - 1]._resumeToken)
379
369
  ? this.finalLane[ftLen - 1] : null;
380
370
  if (ftTail) {
381
371
  ftTail.text = `${ftTail.text}\n\n${newText}`;
@@ -391,6 +381,8 @@ ${_bt.trim()}` : _bt.trim();
391
381
  const capEnd = this.sending ? 1 : 0;
392
382
  for (let i = this.finalLane.length - 1; i >= capEnd; i--) {
393
383
  const cap = this.finalLane[i];
384
+ // Never fold into a sealed (partially-delivered) item — see above.
385
+ if (cap._resumeToken) continue;
394
386
  cap.text = `${cap.text}\n\n${newText}`;
395
387
  cap.bufferText = `${cap.bufferText}\n\n${newText}`;
396
388
  cap.nextFileSize = nextFileSize;
@@ -446,8 +438,20 @@ ${_bt.trim()}` : _bt.trim();
446
438
  }
447
439
  lane.shift();
448
440
  } catch (err) {
449
- process.stderr.write(`mixdog: send failed: ${err}
450
- `);
441
+ // Permanent (non-retryable) send failure — e.g. Telegram 400 "chat
442
+ // not found", Discord 403/404. Retrying would loop forever and wedge
443
+ // the queue, so DROP the item: advance the read cursor past its bytes
444
+ // (so it never reprocesses), clear any stale resume token, and keep
445
+ // draining the next item.
446
+ if (err?.permanent) {
447
+ process.stderr.write(`[output-forwarder] permanent send failure, dropping item: ${err?.message || err}\n`);
448
+ dropTrace("drain.send.permanent", { lane: fromFinal ? "final" : "stream", itemType: item?.type, err: String(err) });
449
+ item._resumeToken = undefined;
450
+ this.commitReadProgress(item.nextFileSize);
451
+ lane.shift();
452
+ continue;
453
+ }
454
+ process.stderr.write(`[output-forwarder] send failed: ${err}\n`);
451
455
  dropTrace("drain.send.err", { finalLen: this.finalLane.length, streamLen: this.streamLane.length, lane: fromFinal ? "final" : "stream", itemType: item?.type, err: String(err) });
452
456
  this.scheduleRetry();
453
457
  break;
@@ -467,8 +471,7 @@ ${_bt.trim()}` : _bt.trim();
467
471
  }
468
472
  await this.cb.react(this.channelId, this.userMessageId, newEmoji);
469
473
  this.emoji = newEmoji;
470
- } catch {
471
- }
474
+ } catch {} // best-effort: reaction is non-critical
472
475
  }
473
476
  await this.deliverQueueItem(item);
474
477
  }
@@ -487,7 +490,7 @@ ${_bt.trim()}` : _bt.trim();
487
490
  try {
488
491
  this.pendingFinalFlush = true;
489
492
  this.updateState((state) => { state.pendingFinalFlush = true; });
490
- } catch {}
493
+ } catch {} // best-effort: durable flush marker is non-critical
491
494
  if (retries < 5) {
492
495
  setTimeout(() => void this.forwardFinalText(retries + 1, channelId), 300);
493
496
  } else {
@@ -511,8 +514,7 @@ ${_bt.trim()}` : _bt.trim();
511
514
  if (this.userMessageId && this.emoji) {
512
515
  try {
513
516
  await this.cb.removeReaction(channelId, this.userMessageId, this.emoji);
514
- } catch {
515
- }
517
+ } catch {} // best-effort: remove reaction is non-critical
516
518
  }
517
519
  const { text: newText, nextFileSize } = this.extractNewText();
518
520
  if (newText) {
@@ -520,14 +522,23 @@ ${_bt.trim()}` : _bt.trim();
520
522
  try {
521
523
  await this.deliverQueueItem(finalItem);
522
524
  } 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;
525
+ // Permanent (non-retryable) failure drop instead of requeue so we
526
+ // don't loop forever on a dead channel. extractNewText already
527
+ // advanced the read cursor, so commit it and let the frame go.
528
+ if (err?.permanent) {
529
+ process.stderr.write(`[output-forwarder] permanent final-send failure, dropping: ${err?.message || err}\n`);
530
+ this.commitReadProgress(nextFileSize);
531
+ } else {
532
+ // Transient send failure: extractNewText already advanced the read
533
+ // cursor past these bytes, so dropping the item here would lose the
534
+ // final text. Requeue the WHOLE item and let drainQueue retry
535
+ // instead of silently discarding. The backend owns chunking+retry,
536
+ // so a requeued send restarts from chunk 0. pendingFinalFlush stays
537
+ // set so a process restart can also resume the flush.
538
+ this.finalLane.push(finalItem);
539
+ this.scheduleRetry();
540
+ return;
541
+ }
531
542
  }
532
543
  } else {
533
544
  this.commitReadProgress(nextFileSize);
@@ -546,7 +557,7 @@ ${_bt.trim()}` : _bt.trim();
546
557
  try {
547
558
  this.pendingFinalFlush = false;
548
559
  this.updateState((state) => { state.pendingFinalFlush = false; });
549
- } catch {}
560
+ } catch {} // best-effort: clear flush marker is non-critical
550
561
  this.updateState((state) => {
551
562
  state.sessionIdle = true;
552
563
  });
@@ -569,7 +580,7 @@ ${_bt.trim()}` : _bt.trim();
569
580
  // isHidden, because that helper would re-consult the module-local
570
581
  // HIDDEN_TOOLS Set and ignore the OutputForwarder static.
571
582
  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;
583
+ if (name === "reply" || name === "react" || name === "edit_message" || name === "fetch" || name === "download_attachment") return true;
573
584
  return false;
574
585
  };
575
586
  /** Concatenate text blocks from a transcript entry (user or assistant). */
@@ -684,13 +695,12 @@ ${_bt.trim()}` : _bt.trim();
684
695
  this.watchDebounce = null;
685
696
  let _wfStat = null;
686
697
  if (this.transcriptPath) {
687
- try { _wfStat = statSync(this.transcriptPath); } catch {}
698
+ try { _wfStat = statSync(this.transcriptPath); } catch {} // best-effort: stat during flush
688
699
  }
689
700
  if (this.transcriptPath && !_wfStat) {
690
701
  const relocated = detectCurrentSessionTranscript()?.transcriptPath ?? findLatestTranscriptByMtime();
691
702
  if (relocated && relocated !== this.transcriptPath) {
692
- process.stderr.write(`mixdog: watched transcript gone during flush, relocated to ${relocated}
693
- `);
703
+ process.stderr.write(`[output-forwarder] watched transcript gone during flush, relocated to ${relocated}\n`);
694
704
  dropTrace("watch.flush.relocate", { from: this.transcriptPath, to: relocated });
695
705
  this.closeWatcher();
696
706
  this.transcriptPath = relocated;
@@ -755,11 +765,7 @@ ${_bt.trim()}` : _bt.trim();
755
765
  }
756
766
  export {
757
767
  OutputForwarder,
758
- cwdToProjectSlug,
759
- detectCurrentSessionTranscript,
760
- discoverCurrentClaudeSession,
761
768
  discoverSessionBoundTranscript,
762
769
  findLatestTranscriptByMtime,
763
- getLatestInteractiveClaudeSession,
764
- listInteractiveClaudeSessions
770
+ sameResolvedPath
765
771
  };
@@ -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();
@@ -468,11 +496,7 @@ function clearActiveInstance(instanceId) {
468
496
  });
469
497
  }
470
498
  export {
471
- ACTIVE_INSTANCE_FILE,
472
- OWNER_DIR,
473
499
  RUNTIME_ROOT,
474
- RUNTIME_STALE_TTL,
475
- buildActiveInstanceState,
476
500
  cleanupInstanceRuntimeFiles,
477
501
  cleanupStaleRuntimeFiles,
478
502
  clearActiveInstance,
@@ -485,13 +509,12 @@ export {
485
509
  getPermissionResultPath,
486
510
  getTerminalLeadPid,
487
511
  getStatusPath,
488
- getStopFlagPath,
489
512
  getTurnEndPath,
490
513
  notePreviousServerIfAny,
491
514
  makeInstanceId,
492
515
  readActiveInstance,
493
516
  refreshActiveInstance,
494
517
  releaseOwnedChannelLocks,
495
- writeActiveInstance,
518
+ touchUiHeartbeat,
496
519
  writeServerPid
497
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 = [];
@@ -1,7 +1,6 @@
1
1
  import { readFileSync, readdirSync, statSync } from "fs";
2
2
  import { execFileSync } from "child_process";
3
3
  import { basename, join, resolve } from "path";
4
- import { homedir } from "os";
5
4
  import { mixdogHome } from "../../shared/plugin-paths.mjs";
6
5
 
7
6
  function cwdToProjectSlug(cwd) {
@@ -94,9 +93,6 @@ function getLatestInteractiveClaudeSession() {
94
93
 
95
94
  export {
96
95
  cwdToProjectSlug,
97
- getParentPid,
98
- readSessionRecord,
99
- isInteractiveSession,
100
96
  discoverCurrentClaudeSession,
101
97
  listInteractiveClaudeSessions,
102
98
  getLatestInteractiveClaudeSession