mixdog 0.9.3 → 0.9.5

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 (382) hide show
  1. package/README.md +112 -38
  2. package/package.json +10 -3
  3. package/scripts/bench/lead-review-tasks-r3.json +20 -0
  4. package/scripts/bench/lead-review-tasks.json +20 -0
  5. package/scripts/bench/r4-mixed-tasks.json +20 -0
  6. package/scripts/bench/r5-orchestrated-task.json +7 -0
  7. package/scripts/bench/review-tasks.json +20 -0
  8. package/scripts/bench/round-codex.json +114 -0
  9. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  10. package/scripts/bench/round-mixdog-lead.json +269 -0
  11. package/scripts/bench/round-mixdog.json +126 -0
  12. package/scripts/bench/round-r10-bigsample.json +679 -0
  13. package/scripts/bench/round-r11-codexalign.json +257 -0
  14. package/scripts/bench/round-r13-clientmeta.json +464 -0
  15. package/scripts/bench/round-r14-betafeatures.json +466 -0
  16. package/scripts/bench/round-r15-fulldefault.json +462 -0
  17. package/scripts/bench/round-r16-sessionid.json +466 -0
  18. package/scripts/bench/round-r17-wirebytes.json +456 -0
  19. package/scripts/bench/round-r18-prewarm.json +468 -0
  20. package/scripts/bench/round-r19-clean.json +472 -0
  21. package/scripts/bench/round-r20-prewarm-clean.json +475 -0
  22. package/scripts/bench/round-r21-delta-retry.json +473 -0
  23. package/scripts/bench/round-r22-full-probe.json +693 -0
  24. package/scripts/bench/round-r23-itemprobe.json +701 -0
  25. package/scripts/bench/round-r24-shapefix.json +677 -0
  26. package/scripts/bench/round-r25-serial.json +464 -0
  27. package/scripts/bench/round-r26-parallel3.json +671 -0
  28. package/scripts/bench/round-r27-parallel10.json +894 -0
  29. package/scripts/bench/round-r28-parallel10-stagger.json +882 -0
  30. package/scripts/bench/round-r29-parallel10-stagger166.json +886 -0
  31. package/scripts/bench/round-r30-instid.json +253 -0
  32. package/scripts/bench/round-r31-upgradeprobe.json +256 -0
  33. package/scripts/bench/round-r32-vs-codex-lead.json +254 -0
  34. package/scripts/bench/round-r33-vs-codex-codex.json +115 -0
  35. package/scripts/bench/round-r34-orchestrated.json +120 -0
  36. package/scripts/bench/round-r35-orchestrated-codex.json +61 -0
  37. package/scripts/bench/round-r36-orchestrated-capped.json +128 -0
  38. package/scripts/bench/round-r4-codex.json +114 -0
  39. package/scripts/bench/round-r4-mixed.json +225 -0
  40. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  41. package/scripts/bench/round-r6-codex.json +114 -0
  42. package/scripts/bench/round-r6-solo.json +257 -0
  43. package/scripts/bench/round-r7-full.json +254 -0
  44. package/scripts/bench/round-r8-fulldefault.json +255 -0
  45. package/scripts/bench-run.mjs +251 -32
  46. package/scripts/freevar-smoke.mjs +95 -0
  47. package/scripts/internal-comms-bench.mjs +3 -4
  48. package/scripts/internal-comms-smoke.mjs +10 -9
  49. package/scripts/model-catalog-audit.mjs +209 -0
  50. package/scripts/model-list-sanitize-test.mjs +37 -0
  51. package/scripts/mouse-probe.mjs +45 -0
  52. package/scripts/output-style-bench.mjs +13 -6
  53. package/scripts/output-style-smoke.mjs +4 -4
  54. package/scripts/provider-toolcall-test.mjs +7 -3
  55. package/scripts/recall-bench.mjs +76 -13
  56. package/scripts/recall-quality-cases.json +12 -0
  57. package/scripts/recall-usecase-cases.json +18 -0
  58. package/scripts/session-bench.mjs +152 -6
  59. package/scripts/tool-smoke.mjs +25 -65
  60. package/scripts/tui-render-smoke.mjs +90 -0
  61. package/scripts/webhook-smoke.mjs +208 -0
  62. package/src/agents/debugger/AGENT.md +4 -1
  63. package/src/agents/heavy-worker/AGENT.md +9 -8
  64. package/src/agents/maintainer/AGENT.md +4 -0
  65. package/src/agents/reviewer/AGENT.md +2 -1
  66. package/src/agents/scheduler-task/AGENT.md +2 -3
  67. package/src/agents/webhook-handler/AGENT.md +2 -3
  68. package/src/agents/worker/AGENT.md +10 -7
  69. package/src/app.mjs +12 -1
  70. package/src/headless-role.mjs +7 -1
  71. package/src/lib/rules-builder.cjs +4 -0
  72. package/src/mixdog-session-runtime.mjs +647 -2056
  73. package/src/output-styles/default.md +30 -9
  74. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  75. package/src/output-styles/minimal.md +8 -6
  76. package/src/output-styles/simple.md +21 -7
  77. package/src/rules/agent/00-common.md +6 -3
  78. package/src/rules/agent/30-explorer.md +16 -5
  79. package/src/rules/lead/01-general.md +5 -5
  80. package/src/rules/lead/lead-brief.md +15 -0
  81. package/src/rules/lead/lead-tool.md +6 -15
  82. package/src/rules/shared/01-tool.md +17 -21
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +8 -3
  84. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  85. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  86. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  87. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  88. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  89. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  90. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  91. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +62 -20
  92. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  93. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +81 -1281
  95. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +32 -3
  97. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  100. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  101. package/src/runtime/agent/orchestrator/providers/gemini.mjs +43 -1013
  102. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +17 -3
  103. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +105 -11
  104. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +356 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  107. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  108. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  109. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +40 -1143
  110. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +740 -0
  111. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  112. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +349 -2131
  113. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +143 -1002
  114. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +229 -0
  115. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  116. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +465 -0
  117. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  118. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  119. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +80 -0
  120. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  121. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  122. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  123. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  124. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  125. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  126. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  127. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  128. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +14 -2
  129. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  130. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +275 -0
  132. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  133. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  134. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  135. package/src/runtime/agent/orchestrator/session/loop.mjs +278 -402
  136. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  137. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +7 -4
  138. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +12 -0
  139. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  140. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  141. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  142. package/src/runtime/agent/orchestrator/session/manager.mjs +166 -1087
  143. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  144. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  145. package/src/runtime/agent/orchestrator/stall-policy.mjs +20 -1
  146. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  147. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  148. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +35 -44
  149. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  150. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  151. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  152. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  153. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -92
  154. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  155. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  156. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +28 -265
  157. package/src/runtime/agent/orchestrator/tools/builtin.mjs +0 -6
  158. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +57 -3
  159. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  160. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +10 -122
  161. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  162. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  163. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  164. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  165. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  166. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  167. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  168. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  169. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  170. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  171. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  172. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  173. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +0 -21
  174. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -72
  175. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  176. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  177. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  178. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  179. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  180. package/src/runtime/channels/backends/discord.mjs +27 -318
  181. package/src/runtime/channels/backends/telegram.mjs +8 -12
  182. package/src/runtime/channels/index.mjs +247 -701
  183. package/src/runtime/channels/lib/backend-dispatch.mjs +46 -0
  184. package/src/runtime/channels/lib/config.mjs +37 -149
  185. package/src/runtime/channels/lib/event-pipeline.mjs +22 -5
  186. package/src/runtime/channels/lib/event-queue.mjs +78 -13
  187. package/src/runtime/channels/lib/inbound-routing.mjs +74 -0
  188. package/src/runtime/channels/lib/interaction-workflows.mjs +5 -113
  189. package/src/runtime/channels/lib/output-forwarder.mjs +1 -1
  190. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  191. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  192. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  193. package/src/runtime/channels/lib/scheduler.mjs +27 -113
  194. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  195. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  196. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  197. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  198. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  199. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  200. package/src/runtime/channels/lib/webhook/deliveries.mjs +313 -0
  201. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  202. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  203. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  204. package/src/runtime/channels/lib/webhook.mjs +43 -616
  205. package/src/runtime/channels/tool-defs.mjs +11 -130
  206. package/src/runtime/memory/index.mjs +210 -1948
  207. package/src/runtime/memory/lib/core-memory-store.mjs +5 -1
  208. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  209. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  210. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  211. package/src/runtime/memory/lib/ko-morph.mjs +195 -0
  212. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  213. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  214. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  215. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  216. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  217. package/src/runtime/memory/lib/memory-cycle2.mjs +24 -842
  218. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  219. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  220. package/src/runtime/memory/lib/memory-recall-store.mjs +69 -12
  221. package/src/runtime/memory/lib/memory-text-utils.mjs +46 -0
  222. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  223. package/src/runtime/memory/lib/query-handlers.mjs +802 -0
  224. package/src/runtime/memory/lib/recall-format.mjs +55 -0
  225. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  226. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  227. package/src/runtime/memory/tool-defs.mjs +5 -13
  228. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  229. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  230. package/src/runtime/search/lib/web-tools.mjs +24 -602
  231. package/src/runtime/shared/atomic-file.mjs +26 -1
  232. package/src/runtime/shared/config.mjs +14 -4
  233. package/src/runtime/shared/launcher-control.mjs +2 -2
  234. package/src/runtime/shared/markdown-frontmatter.mjs +19 -0
  235. package/src/runtime/shared/schedules-store.mjs +13 -3
  236. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  237. package/src/runtime/shared/tool-primitives.mjs +308 -0
  238. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  239. package/src/runtime/shared/tool-surface.mjs +80 -898
  240. package/src/runtime/shared/transcript-writer.mjs +23 -0
  241. package/src/runtime/shared/update-checker.mjs +7 -4
  242. package/src/session-runtime/config-helpers.mjs +119 -2
  243. package/src/session-runtime/config-lifecycle.mjs +232 -0
  244. package/src/session-runtime/cwd-plugins.mjs +226 -0
  245. package/src/session-runtime/mcp-glue.mjs +177 -0
  246. package/src/session-runtime/model-recency.mjs +111 -0
  247. package/src/session-runtime/native-search.mjs +247 -0
  248. package/src/session-runtime/output-styles.mjs +11 -9
  249. package/src/session-runtime/prewarm.mjs +142 -0
  250. package/src/session-runtime/provider-models.mjs +278 -0
  251. package/src/session-runtime/provider-usage.mjs +120 -0
  252. package/src/session-runtime/quick-model-rows.mjs +205 -0
  253. package/src/session-runtime/quick-search-models.mjs +47 -0
  254. package/src/session-runtime/session-hooks.mjs +93 -0
  255. package/src/session-runtime/settings-api.mjs +352 -0
  256. package/src/session-runtime/tool-catalog.mjs +29 -29
  257. package/src/session-runtime/tool-defs.mjs +84 -0
  258. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  259. package/src/session-runtime/workflow.mjs +1 -1
  260. package/src/standalone/agent-tool/helpers.mjs +237 -0
  261. package/src/standalone/agent-tool/notify.mjs +107 -0
  262. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  263. package/src/standalone/agent-tool/render.mjs +152 -0
  264. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  265. package/src/standalone/agent-tool.mjs +138 -669
  266. package/src/standalone/channel-admin.mjs +102 -90
  267. package/src/standalone/channel-worker.mjs +4 -7
  268. package/src/standalone/explore-tool.mjs +64 -14
  269. package/src/standalone/hook-bus/config.mjs +207 -0
  270. package/src/standalone/hook-bus/constants.mjs +90 -0
  271. package/src/standalone/hook-bus/handlers.mjs +481 -0
  272. package/src/standalone/hook-bus/payload.mjs +31 -0
  273. package/src/standalone/hook-bus/rules.mjs +77 -0
  274. package/src/standalone/hook-bus.mjs +77 -870
  275. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  276. package/src/standalone/opencode-go-login.mjs +5 -1
  277. package/src/standalone/provider-admin.mjs +1 -16
  278. package/src/standalone/usage-dashboard.mjs +3 -1
  279. package/src/tui/App.jsx +1059 -8110
  280. package/src/tui/app/app-format.mjs +213 -0
  281. package/src/tui/app/channel-pickers.mjs +508 -0
  282. package/src/tui/app/clipboard.mjs +67 -0
  283. package/src/tui/app/core-memory-picker.mjs +210 -0
  284. package/src/tui/app/extension-pickers.mjs +506 -0
  285. package/src/tui/app/input-parsers.mjs +193 -0
  286. package/src/tui/app/maintenance-pickers.mjs +356 -0
  287. package/src/tui/app/model-options.mjs +334 -0
  288. package/src/tui/app/model-picker.mjs +365 -0
  289. package/src/tui/app/onboarding-steps.mjs +400 -0
  290. package/src/tui/app/project-picker.mjs +247 -0
  291. package/src/tui/app/provider-setup-picker.mjs +580 -0
  292. package/src/tui/app/resume-picker.mjs +55 -0
  293. package/src/tui/app/route-pickers.mjs +419 -0
  294. package/src/tui/app/settings-picker.mjs +489 -0
  295. package/src/tui/app/slash-commands.mjs +101 -0
  296. package/src/tui/app/slash-dispatch.mjs +427 -0
  297. package/src/tui/app/text-layout.mjs +46 -0
  298. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  299. package/src/tui/app/transcript-window.mjs +677 -0
  300. package/src/tui/app/use-mouse-input.mjs +460 -0
  301. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  302. package/src/tui/app/use-transcript-scroll.mjs +512 -0
  303. package/src/tui/app/use-transcript-window.mjs +607 -0
  304. package/src/tui/components/ConfirmBar.jsx +10 -7
  305. package/src/tui/components/Picker.jsx +64 -15
  306. package/src/tui/components/PromptInput.jsx +33 -102
  307. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  308. package/src/tui/components/StatusLine.jsx +69 -15
  309. package/src/tui/components/TextEntryPanel.jsx +11 -0
  310. package/src/tui/components/ToolExecution.jsx +52 -594
  311. package/src/tui/components/TranscriptItem.jsx +105 -0
  312. package/src/tui/components/UsagePanel.jsx +18 -4
  313. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  314. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  315. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  316. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  317. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  318. package/src/tui/display-width.mjs +20 -3
  319. package/src/tui/dist/index.mjs +13553 -12384
  320. package/src/tui/engine/agent-job-feed.mjs +133 -0
  321. package/src/tui/engine/notification-plan.mjs +76 -0
  322. package/src/tui/engine/render-timing.mjs +17 -0
  323. package/src/tui/engine/tool-approval.mjs +94 -0
  324. package/src/tui/engine/tool-card-results.mjs +234 -0
  325. package/src/tui/engine/tool-result-status.mjs +135 -0
  326. package/src/tui/engine.mjs +170 -574
  327. package/src/tui/figures.mjs +5 -0
  328. package/src/tui/index.jsx +65 -1
  329. package/src/tui/input-editing.mjs +2 -2
  330. package/src/tui/markdown/format-token.mjs +4 -1
  331. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  332. package/src/tui/theme.mjs +6 -0
  333. package/src/ui/statusline-agents.mjs +213 -0
  334. package/src/ui/statusline-format.mjs +146 -0
  335. package/src/ui/statusline-segments.mjs +148 -0
  336. package/src/ui/statusline.mjs +77 -501
  337. package/src/ui/tool-card.mjs +0 -1
  338. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  339. package/src/workflows/default/WORKFLOW.md +16 -18
  340. package/src/workflows/sequential/WORKFLOW.md +16 -18
  341. package/vendor/ink/build/display-width.js +19 -3
  342. package/vendor/ink/build/ink.js +112 -7
  343. package/vendor/ink/build/log-update.js +17 -3
  344. package/vendor/ink/build/wrap-text.js +125 -0
  345. package/scripts/_test-folder-dialog.mjs +0 -30
  346. package/scripts/fix-brief-fn.mjs +0 -35
  347. package/scripts/fix-format-tool-surface.mjs +0 -24
  348. package/scripts/fix-tool-exec-visible.mjs +0 -42
  349. package/scripts/patch-agent-brief.mjs +0 -48
  350. package/scripts/patch-app.mjs +0 -21
  351. package/scripts/patch-app2.mjs +0 -18
  352. package/scripts/patch-dist-brief.mjs +0 -96
  353. package/scripts/patch-tool-exec.mjs +0 -70
  354. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  355. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  356. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  357. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  358. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  359. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +0 -162
  360. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  361. package/src/runtime/channels/lib/holidays.mjs +0 -138
  362. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  363. package/src/runtime/shared/task-notification-envelope.test.mjs +0 -107
  364. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  365. package/src/standalone/agent-task-status.test.mjs +0 -76
  366. package/src/tui/components/tool-output-format.test.mjs +0 -399
  367. package/src/tui/display-width.test.mjs +0 -35
  368. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  369. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  370. package/src/tui/input-editing.selection.test.mjs +0 -75
  371. package/src/tui/markdown/format-token.test.mjs +0 -354
  372. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  373. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  374. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  375. package/src/tui/paste-fix.test.mjs +0 -119
  376. package/src/tui/prompt-history-store.test.mjs +0 -52
  377. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  378. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  379. package/src/ui/markdown.test.mjs +0 -70
  380. package/src/ui/statusline-context-label.test.mjs +0 -15
  381. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  382. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -1,4 +1,3 @@
1
- import { spawn } from "child_process";
2
1
  import * as fs from "fs";
3
2
  import * as os from "os";
4
3
  import * as path from "path";
@@ -7,17 +6,15 @@ import { createRequire } from "module";
7
6
  const _require = createRequire(import.meta.url);
8
7
  import { loadConfig, createBackend, loadProfileConfig, DATA_DIR } from "./lib/config.mjs";
9
8
  import { resolveVoiceRuntime } from "./lib/voice-runtime-fetcher.mjs";
10
- import { ensureReady, transcribe, stopVoiceWhisperServer } from "./lib/whisper-server.mjs";
9
+ import { ensureReady, stopVoiceWhisperServer } from "./lib/whisper-server.mjs";
11
10
  import { loadConfig as loadAgentConfig } from "../agent/orchestrator/config.mjs";
12
11
  import { captureOriginalUserCwd, readLastSessionCwd } from "../shared/user-cwd.mjs";
13
- import { managedLaunchId, enqueueLauncherCommand } from "../shared/launcher-control.mjs";
14
12
  import { initProviders } from "../agent/orchestrator/providers/registry.mjs";
15
13
  import { Scheduler } from "./lib/scheduler.mjs";
16
14
  import { startSnapshotWriter, stopSnapshotWriter, recordFetchedMessages } from "./lib/status-snapshot.mjs";
17
15
  import { hasPending as dispatchHasPending } from "../agent/orchestrator/dispatch-persist.mjs";
18
16
  import { setListener as setActivityBusListener } from "../agent/orchestrator/activity-bus.mjs";
19
17
  import { stripSoftWarns } from "../agent/orchestrator/tool-loop-guard.mjs";
20
- import { invalidatePrefetchCache } from "../agent/orchestrator/session/cache/prefetch-cache.mjs";
21
18
  import { WebhookServer } from "./lib/webhook.mjs";
22
19
  import { EventPipeline } from "./lib/event-pipeline.mjs";
23
20
  import { startCliWorker } from "./lib/cli-worker-host.mjs";
@@ -63,7 +60,12 @@ import {
63
60
  BENIGN_CRASH_STREAK_WINDOW_MS,
64
61
  } from "./lib/crash-log.mjs";
65
62
  import { dropTrace, preview, _dtIdxFlush } from "./lib/index-drop-trace.mjs";
66
- import { normalizeWhisperLanguage, detectDeviceLanguage } from "./lib/whisper-language.mjs";
63
+ import { createVoiceTranscription } from "./lib/voice-transcription.mjs";
64
+ import { createBackendDispatch } from "./lib/backend-dispatch.mjs";
65
+ import { createParentBridge } from "./lib/parent-bridge.mjs";
66
+ import { createInboundRouting } from "./lib/inbound-routing.mjs";
67
+ import { createToolDispatch } from "./lib/tool-dispatch.mjs";
68
+ import { createOwnerHeartbeat } from "./lib/owner-heartbeat.mjs";
67
69
  const memoryClientModulePath = new URL("./lib/memory-client.mjs", import.meta.url).href;
68
70
  const {
69
71
  appendEntry: memoryAppendEntry,
@@ -201,72 +203,11 @@ const INSTRUCTIONS = "";
201
203
  // never `connect()`ed to any transport, so `.notification()` silently
202
204
  // threw 'Not connected' inside the SDK and every call was dropped by an
203
205
  // outer `.catch(() => {})`. That regression is what this path replaces.
204
- function normalizeChannelNotifyParams(method, params) {
205
- if (method === 'notifications/claude/channel' && params && params.meta) {
206
- const m = {};
207
- for (const [k, v] of Object.entries(params.meta)) {
208
- if (v === undefined || v === null) continue;
209
- m[k] = k === 'silent_to_agent' ? (v === true || v === 'true') : String(v);
210
- }
211
- return { ...params, meta: m };
212
- }
213
- return params;
214
- }
215
-
216
- function sendNotifyToParent(method, params) {
217
- // CC channel schema requires meta: Record<string,string> (channelNotification.ts).
218
- // Coerce every meta value to string so a non-string (e.g. a Discord
219
- // interaction.type number) can't fail zod and silently drop the notify.
220
- // silent_to_agent stays boolean — an internal routing flag the daemon
221
- // router / agentNotify consume (=== true) before the CC zod boundary.
222
- const outParams = normalizeChannelNotifyParams(method, params);
223
- if (!process.send) {
224
- try { process.stderr.write(`mixdog channels: notify dropped (no IPC channel): ${method}\n`); } catch {}
225
- return;
226
- }
227
- try {
228
- process.send({ type: 'notify', method, params: outParams });
229
- } catch (err) {
230
- try { process.stderr.write(`mixdog channels: notify IPC send failed: ${err && err.message || err}\n`); } catch {}
231
- }
232
- }
233
-
234
- // ── Memory worker bridge (worker → parent → memory) ─────────────────
235
- // The channels worker does not own the memory worker handle. To trigger
236
- // memory tool actions (e.g. cycle1) we send `memory_call_request` to the
237
- // parent, which routes through callWorker('memory', ...) and ships the
238
- // result back as `memory_call_response`. The response listener is
239
- // integrated into the main IPC handler below (not a second listener).
240
- const _memoryCallPending = new Map();
241
- let _memoryCallSeq = 0;
242
-
243
- function callMemoryAction(action, args, timeoutMs) {
244
- return new Promise((resolve, reject) => {
245
- if (!process.send) return reject(new Error('not a worker process'));
246
- const callId = `mc_${INSTANCE_ID}_${++_memoryCallSeq}_${Math.random().toString(36).slice(2, 8)}`;
247
- const timer = setTimeout(() => {
248
- _memoryCallPending.delete(callId);
249
- reject(new Error(`memory_call ${action} timed out after ${timeoutMs}ms`));
250
- }, timeoutMs);
251
- _memoryCallPending.set(callId, {
252
- resolve: (v) => { clearTimeout(timer); resolve(v); },
253
- reject: (e) => { clearTimeout(timer); reject(e); },
254
- });
255
- try {
256
- process.send({ type: 'memory_call_request', callId, action, args: args || {} });
257
- } catch (e) {
258
- _memoryCallPending.delete(callId);
259
- clearTimeout(timer);
260
- reject(e);
261
- }
262
- });
263
- }
264
- function resolveChannelLabel(channelsConfig, label) {
265
- if (!label || !channelsConfig) return label;
266
- const entry = channelsConfig[label];
267
- if (entry?.channelId) return entry.channelId;
268
- return label;
269
- }
206
+ const {
207
+ sendNotifyToParent,
208
+ callMemoryAction,
209
+ handleMemoryCallResponse,
210
+ } = createParentBridge({ getInstanceId: () => INSTANCE_ID });
270
211
  let channelBridgeActive = false;
271
212
  function writeBridgeState(active) {
272
213
  try {
@@ -362,15 +303,20 @@ forwarder.setOnIdle(() => {
362
303
  // (webhook enabled or event rules present). Without an event pipeline the
363
304
  // forwarder's ownerGetter stayed null and _isOwner() failed open, letting a
364
305
  // non-owner process forward transcript output (duplicate Discord sends).
365
- // The closure reads bridgeRuntimeConnected at call time.
366
- forwarder.setOwnerGetter(() => bridgeRuntimeConnected);
306
+ // The closure reads bridgeRuntimeConnected at call time as a fast-path AND;
307
+ // bridgeRuntimeConnected alone can go stale (e.g. this process lost the seat
308
+ // but has not yet observed it), so currentOwnerState().owned is re-read at
309
+ // probe time as the source of truth for ownership.
310
+ forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
367
311
  function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
368
312
  if (!transcriptPath) return;
369
313
  forwarder.setContext(channelId, transcriptPath, { replayFromStart: options.replayFromStart, catchUpFromPersisted: options.catchUpFromPersisted });
370
314
  const boundTranscriptPath = forwarder.transcriptPath || transcriptPath;
371
315
  forwarder.startWatch();
372
316
  void memoryIngestTranscript(boundTranscriptPath, { cwd: options.cwd });
373
- refreshActiveInstance(INSTANCE_ID, { channelId, transcriptPath: boundTranscriptPath });
317
+ // onlyIfOwned: binds happen on the owned path, but discovery/poll loops
318
+ // above can outlast an ownership handoff — never overwrite a newer owner.
319
+ refreshActiveInstance(INSTANCE_ID, { channelId, transcriptPath: boundTranscriptPath }, { onlyIfOwned: true });
374
320
  if (options.persistStatus !== false) {
375
321
  statusState.update((state) => {
376
322
  state.channelId = channelId;
@@ -566,11 +512,8 @@ async function rebindTranscriptContext(channelId, options = {}) {
566
512
  const scheduler = new Scheduler(
567
513
  config.nonInteractive ?? [],
568
514
  config.interactive ?? [],
569
- // channelsConfig kept for channel-label resolution (resolveChannel)
570
- // only — quiet/schedules now come from the top-level config.
571
- config.channelsConfig,
572
- // 0.1.62: top-level normalized config carries quiet/schedules.
573
- config
515
+ // Single resolved main-channel id used for the schedule `channel` flag.
516
+ config.channelId
574
517
  );
575
518
  // Register the pending-dispatch probe so the scheduler treats an in-flight
576
519
  // bridge dispatch as "active" regardless of user-inbound silence.
@@ -597,13 +540,7 @@ let bridgeRuntimeStarting = false;
597
540
  let _ownedRuntimeStopRequested = false;
598
541
  let bridgeOwnershipRefreshInFlight = null;
599
542
  let bridgeOwnershipTimer = null;
600
- let lastOwnershipNote = "";
601
543
  const ACTIVE_OWNER_STALE_MS = 1e4;
602
- // Owner heartbeat: keep active-instance.json fresh so other sessions cannot
603
- // steal the seat after 10 s of channel-action silence. unref'd interval —
604
- // never blocks process exit. Single JSON atomic write, no measurable load.
605
- const OWNER_HEARTBEAT_INTERVAL_MS = 5e3;
606
- let ownerHeartbeatTimer = null;
607
544
  // Owner gating here is multi-process runtime coordination: only the active
608
545
  // bindingReady gates all send paths until the boot-time refreshBridgeOwnership
609
546
  // ({ restoreBinding: true }) call completes. Without this, scheduler/webhook
@@ -613,31 +550,21 @@ let bindingReadyStatus = "pending";
613
550
  let _bindingReadyResolve;
614
551
  const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
615
552
  dropTrace("bindingReady.create", { status: bindingReadyStatus });
616
- function logOwnership(note) {
617
- if (lastOwnershipNote === note) return;
618
- lastOwnershipNote = note;
619
- process.stderr.write(`[ownership] ${note}
620
- `);
621
- }
622
- function currentOwnerState() {
623
- const active = readActiveInstance();
624
- return {
625
- active,
626
- // Strict last-wins: this process owns the bridge ONLY when active-instance
627
- // names exactly this INSTANCE_ID. A newer remote session that claims the
628
- // seat overwrites instanceId, so the old owner immediately reads owned=false
629
- // and disconnects on its next refresh tick. No PID/terminal fallback —
630
- // that used to let a co-terminal worker wrongly self-claim.
631
- owned: active?.instanceId === INSTANCE_ID
632
- };
633
- }
634
- function getBridgeOwnershipSnapshot() {
635
- return currentOwnerState();
636
- }
637
- function claimBridgeOwnership(reason) {
638
- refreshActiveInstance(INSTANCE_ID);
639
- logOwnership(`claimed owner (${reason})`);
640
- }
553
+ // ── Bridge ownership snapshot + owner heartbeat ─────────────────────────────
554
+ // Extracted lib/owner-heartbeat.mjs. Owns its own heartbeat timer + last-note
555
+ // dedup; bound to live identity + active-instance primitives.
556
+ const {
557
+ logOwnership,
558
+ currentOwnerState,
559
+ getBridgeOwnershipSnapshot,
560
+ claimBridgeOwnership,
561
+ startOwnerHeartbeat,
562
+ stopOwnerHeartbeat,
563
+ } = createOwnerHeartbeat({
564
+ getInstanceId: () => INSTANCE_ID,
565
+ readActiveInstance,
566
+ refreshActiveInstance,
567
+ });
641
568
  async function bindPersistedTranscriptIfAny() {
642
569
  // Main-channel fallback requires channelBridgeActive (set in start() before
643
570
  // refreshBridgeOwnership → startOwnedRuntime, including pre-connect binds).
@@ -672,10 +599,7 @@ async function bindPersistedTranscriptIfAny() {
672
599
  }
673
600
  }
674
601
  if (!currentStatus.channelId && channelBridgeActive) {
675
- const chCfg = config.channelsConfig;
676
- const mainLabel = config.mainChannel ?? "main";
677
- const mainEntry = chCfg?.[mainLabel];
678
- const mainId = mainEntry?.channelId;
602
+ const mainId = config.channelId;
679
603
  if (mainId) {
680
604
  statusState.update((state) => {
681
605
  state.channelId = mainId;
@@ -701,17 +625,14 @@ function shouldStartEventPipelineRuntime() {
701
625
  }
702
626
  function ensureEventPipelineRuntime() {
703
627
  if (!eventPipeline) {
704
- eventPipeline = new EventPipeline(config.events, config.channelsConfig);
628
+ eventPipeline = new EventPipeline(config.events, config.channelId);
705
629
  wireEventQueueHandlers(eventPipeline.getQueue());
706
630
  }
707
631
  return eventPipeline;
708
632
  }
709
633
  function ensureWebhookServerRuntime() {
710
634
  if (!webhookServer) {
711
- // Pass top-level normalized config so the webhook gate reads the new
712
- // top-level `quiet` subtree (and `webhook.respectQuiet`) introduced in
713
- // 0.1.62. See applyDefaults() in lib/config.mjs.
714
- webhookServer = new WebhookServer(config.webhook, { quiet: config.quiet ?? null });
635
+ webhookServer = new WebhookServer(config.webhook);
715
636
  }
716
637
  wireWebhookHandlers();
717
638
  return webhookServer;
@@ -730,7 +651,7 @@ function syncOwnedWebhookAndEventRuntime({ reload = false } = {}) {
730
651
  if (shouldStartEventPipelineRuntime()) {
731
652
  const pipeline = ensureEventPipelineRuntime();
732
653
  if (reload) {
733
- pipeline.reloadConfig(config.events, config.channelsConfig);
654
+ pipeline.reloadConfig(config.events, config.channelId);
734
655
  wireEventQueueHandlers(pipeline.getQueue());
735
656
  }
736
657
  pipeline.start();
@@ -747,7 +668,7 @@ function syncOwnedWebhookAndEventRuntime({ reload = false } = {}) {
747
668
  // so we don't race the bound port — calling start() synchronously
748
669
  // here would re-listen before close() finishes and surface
749
670
  // EADDRINUSE on the same port.
750
- server.reloadConfig(config.webhook, { quiet: config.quiet ?? null }, {
671
+ server.reloadConfig(config.webhook, {
751
672
  autoStart: false
752
673
  }).then(() => {
753
674
  // A stopWebhookAndEventRuntime() / deactivate landing during the async
@@ -787,7 +708,20 @@ async function startOwnedRuntime(options = {}) {
787
708
  // Advertise active-instance.json BEFORE backend connect so a newer remote
788
709
  // session's last-wins claim is visible immediately. backendReady=false
789
710
  // marks the partial state until backend.connect() succeeds.
790
- refreshActiveInstance(INSTANCE_ID, { backendReady: false });
711
+ // onlyIfOwned: startOwnedRuntime is only entered on the owned path, but a
712
+ // newer session can claim the seat between that check and this write; CAS
713
+ // aborts instead of re-stealing. The write result is checked below so a
714
+ // CAS abort stops the start path immediately (heartbeat/backend/scheduler
715
+ // never start) instead of relying on the 3s ownership timer to notice.
716
+ const casResult = refreshActiveInstance(INSTANCE_ID, { backendReady: false }, { onlyIfOwned: true });
717
+ // A successful CAS write always sets instanceId to ours; any other result
718
+ // (aborted write returning the stale/foreign/missing prior state) means
719
+ // the seat was not ours to claim — abort the start path here rather than
720
+ // relying on the 3s ownership timer to notice later.
721
+ if (casResult?.instanceId !== INSTANCE_ID) {
722
+ bridgeRuntimeStarting = false;
723
+ return;
724
+ }
791
725
  startOwnerHeartbeat();
792
726
  // Re-check after each post-connect await so a stopOwnedRuntime() landing
793
727
  // mid-start cannot be overridden by the resuming start (scheduler/snapshot/
@@ -825,7 +759,11 @@ async function startOwnedRuntime(options = {}) {
825
759
  return;
826
760
  }
827
761
  bridgeRuntimeConnected = true;
828
- refreshActiveInstance(INSTANCE_ID, { backendReady: true });
762
+ // onlyIfOwned: backend.connect() above can take seconds — a newer owner
763
+ // claiming the seat during that await must not be overwritten here. On
764
+ // CAS abort the next refreshBridgeOwnership tick observes owned=false
765
+ // and stops this runtime; backendReady stays unset for the lost seat.
766
+ refreshActiveInstance(INSTANCE_ID, { backendReady: true }, { onlyIfOwned: true });
829
767
  // initProviders must complete before scheduler.start() — otherwise the
830
768
  // scheduler's first fire can land before the registry is populated and
831
769
  // return `Provider "<name>" not found or not enabled`. The previous
@@ -916,26 +854,14 @@ async function stopOwnedRuntime(reason) {
916
854
  function refreshBridgeOwnershipSafe(options = {}) {
917
855
  refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
918
856
  }
919
- function startOwnerHeartbeat() {
920
- if (ownerHeartbeatTimer) return;
921
- ownerHeartbeatTimer = setInterval(() => {
922
- try {
923
- // Last-wins guard: only refresh the seat if we STILL own it. If a newer
924
- // remote session claimed active-instance.json since our last tick, do
925
- // NOT overwrite it back that would re-steal ownership and cause
926
- // ping-pong / double backend connections. The bridgeOwnershipTimer's
927
- // refreshBridgeOwnership() will observe owned=false and disconnect us.
928
- if (currentOwnerState().owned) refreshActiveInstance(INSTANCE_ID);
929
- } catch (e) {
930
- process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
931
- }
932
- }, OWNER_HEARTBEAT_INTERVAL_MS);
933
- ownerHeartbeatTimer.unref?.();
934
- }
935
- function stopOwnerHeartbeat() {
936
- if (!ownerHeartbeatTimer) return;
937
- clearInterval(ownerHeartbeatTimer);
938
- ownerHeartbeatTimer = null;
857
+ // Tell the parent session that this worker LOST the bridge seat to a newer
858
+ // remote session (last-wins). The parent flips its remote mode OFF entirely —
859
+ // exactly one session holds remote; losers fully release, no handover.
860
+ function notifyRemoteSuperseded() {
861
+ if (!process.send) return;
862
+ try {
863
+ process.send({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'superseded' } });
864
+ } catch {}
939
865
  }
940
866
  async function refreshBridgeOwnership(options = {}) {
941
867
  // Coalesce concurrent callers onto the in-flight refresh so backend tool
@@ -955,14 +881,18 @@ async function refreshBridgeOwnership(options = {}) {
955
881
  }
956
882
  const { active, owned } = currentOwnerState();
957
883
  if (owned) {
958
- refreshActiveInstance(INSTANCE_ID);
884
+ // onlyIfOwned: ownership was checked outside the file lock; CAS mode
885
+ // prevents this periodic tick from overwriting a newer owner that
886
+ // claimed the seat in between (re-steal / ping-pong guard).
887
+ refreshActiveInstance(INSTANCE_ID, undefined, { onlyIfOwned: true });
959
888
  await startOwnedRuntime(options);
960
889
  return;
961
890
  }
962
891
  // Not the owner. Two sub-cases:
963
892
  // (a) A live remote session holds the seat (active-instance names a
964
893
  // different, non-stale instance) → last-wins: we lost, go quiet
965
- // (disconnect if we were connected).
894
+ // (disconnect if we were connected) and tell the parent session to
895
+ // drop remote mode entirely (single-holder, no handover).
966
896
  // (b) There is NO live owner (active is null/stale — e.g. our own entry
967
897
  // was cleared after a backend-connect failure or a bridge
968
898
  // deactivate/reactivate) → this remote session claims the empty seat
@@ -971,6 +901,7 @@ async function refreshBridgeOwnership(options = {}) {
971
901
  if (active && active.instanceId && active.instanceId !== INSTANCE_ID) {
972
902
  if (bridgeRuntimeConnected) {
973
903
  await stopOwnedRuntime("ownership lost (newer remote session)");
904
+ notifyRemoteSuperseded();
974
905
  }
975
906
  return;
976
907
  }
@@ -994,10 +925,8 @@ async function reloadRuntimeConfig() {
994
925
  scheduler.reloadConfig(
995
926
  config.nonInteractive ?? [],
996
927
  config.interactive ?? [],
997
- // channelsConfig: channel-label resolution only.
998
- config.channelsConfig,
999
- // 0.1.62: top-level normalized config (quiet/schedules).
1000
- config,
928
+ // Single resolved main-channel id used for the schedule `channel` flag.
929
+ config.channelId,
1001
930
  { restart: bridgeRuntimeConnected }
1002
931
  );
1003
932
  const nextBackend = createBackend(config);
@@ -1182,15 +1111,17 @@ function wireWebhookHandlers() {
1182
1111
  );
1183
1112
  });
1184
1113
  }
1185
- function resolveWebhookChannelId(channelLabel) {
1186
- // Fail closed: route only to channels explicitly present in config —
1187
- // the endpoint's owner-configured `channel`, else the `main` channel.
1188
- // Runtime / persisted-status fallbacks are never used (they could route
1189
- // a delivery to an arbitrary or stale channel). The endpoint channel is
1190
- // owner-authored config, not attacker payload, so honoring it is safe.
1191
- const channels = config?.channelsConfig || {};
1192
- if (channelLabel && channels[channelLabel]?.channelId) return channels[channelLabel].channelId;
1193
- return channels.main?.channelId || "";
1114
+ function resolveWebhookChannelId(channelFlag) {
1115
+ // Single main channel: the endpoint's `channel` frontmatter is a boolean-ish
1116
+ // flag (any non-empty value except "false" → post to the main channel). A raw
1117
+ // channel id is still honored verbatim for owner-authored overrides.
1118
+ const main = config?.channelId || "";
1119
+ if (channelFlag == null) return main;
1120
+ const v = String(channelFlag).trim();
1121
+ if (v === "" || v.toLowerCase() === "false") return "";
1122
+ // A pure-digit / snowflake-looking value is treated as an explicit id;
1123
+ // anything else (legacy labels like "main") maps to the main channel.
1124
+ return /^-?\d+$/.test(v) ? v : main;
1194
1125
  }
1195
1126
  function wireEventQueueHandlers(eventQueue) {
1196
1127
  if (!eventQueue) return;
@@ -1200,8 +1131,10 @@ function wireEventQueueHandlers(eventQueue) {
1200
1131
  // Defensive ownership probe: the queue tick should only run in the active
1201
1132
  // owner process. Non-owner instances see bridgeRuntimeConnected=false and
1202
1133
  // will skip the tick even if an errant start() slipped through.
1203
- eventQueue.setOwnerGetter(() => bridgeRuntimeConnected);
1204
- forwarder.setOwnerGetter(() => bridgeRuntimeConnected);
1134
+ // bridgeRuntimeConnected is a fast-path AND; currentOwnerState().owned is
1135
+ // re-read at probe time so a stale-connected flag cannot mask a lost seat.
1136
+ eventQueue.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
1137
+ forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
1205
1138
  }
1206
1139
  function editDiscordMessage(channelId, messageId, label) {
1207
1140
  // Behavior-preserving: route through the backend abstraction (which uses
@@ -1430,496 +1363,70 @@ backend.onInteraction = (interaction) => {
1430
1363
  }
1431
1364
  });
1432
1365
  };
1433
- function isVoiceAttachment(contentType) {
1434
- if (typeof contentType !== 'string') return false;
1435
- const ct = contentType.toLowerCase();
1436
- return ct.startsWith("audio/") || ct.startsWith("application/ogg");
1437
- }
1438
- function runCmd(cmd, args, capture = false) {
1439
- return new Promise((resolve, reject) => {
1440
- const proc = spawn(cmd, args, {
1441
- stdio: capture ? ["ignore", "pipe", "ignore"] : "ignore",
1442
- windowsHide: true
1443
- });
1444
- let out = "";
1445
- if (capture && proc.stdout) proc.stdout.on("data", (d) => {
1446
- out += d;
1447
- });
1448
- proc.on("close", (code) => code === 0 ? resolve(out) : reject(new Error(`${cmd} exit ${code}`)));
1449
- proc.on("error", reject);
1450
- });
1451
- }
1452
- // ── voice.transcription concurrency queue (max=1 by default, config-driven) ──
1453
- const _voiceTranscriptionQueue = (() => {
1454
- let running = 0;
1455
- const pending = [];
1456
- function drain() {
1457
- const limit = config.voice?.transcription?.maxConcurrency ?? 1;
1458
- while (running < limit && pending.length > 0) {
1459
- const { fn, resolve, reject } = pending.shift();
1460
- running++;
1461
- fn().then(resolve, reject).finally(() => { running--; drain(); });
1462
- }
1463
- }
1464
- return function enqueue(fn) {
1465
- return new Promise((resolve, reject) => { pending.push({ fn, resolve, reject }); drain(); });
1466
- };
1467
- })();
1468
-
1469
- // ── wav + transcript cache keyed by attachment id ──
1470
- const _voiceWavCache = new Map(); // attachmentId → wavPath
1471
- const _voiceTranscriptCache = new Map(); // attachmentId → transcript string
1472
- const _voiceInflight = new Map(); // attachmentId → Promise<string|null>
1473
- const _voiceFfmpegInflight = new Map(); // attachmentId|wavPath → Promise<void> single-flight ffmpeg
1474
-
1475
- async function _probeAudioDurationSec(filePath) {
1476
- try {
1477
- const ffprobePath = (() => { try { return _require('ffprobe-static').path; } catch { return 'ffprobe'; } })();
1478
- return await new Promise((resolve, reject) => {
1479
- const args = ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', filePath];
1480
- let out = '';
1481
- const proc = spawn(ffprobePath, args, { windowsHide: true });
1482
- proc.stdout.on('data', (d) => { out += d; });
1483
- proc.on('close', (code) => { code === 0 ? resolve(parseFloat(out.trim()) || null) : reject(new Error(`ffprobe exit ${code}`)); });
1484
- proc.on('error', reject);
1485
- });
1486
- } catch {
1487
- return null;
1488
- }
1489
- }
1490
-
1491
- async function transcribeVoice(audioPath, { attachmentId } = {}) {
1492
- // ── size gate (config: voice.transcription.maxFileSizeMB) ──
1493
- const maxSizeBytes = (config.voice?.transcription?.maxFileSizeMB ?? 0) * 1024 * 1024;
1494
- if (maxSizeBytes > 0) {
1495
- try {
1496
- const stat = await fs.promises.stat(audioPath);
1497
- if (stat.size > maxSizeBytes) {
1498
- process.stderr.write(`mixdog: voice.transcription skipped — file too large (${(stat.size / 1024 / 1024).toFixed(1)} MB > ${config.voice.transcription.maxFileSizeMB} MB): ${audioPath}\n`);
1499
- return null;
1500
- }
1501
- } catch { /* stat failure: proceed */ }
1502
- }
1503
- // ── duration gate (config: voice.transcription.maxDurationSec) ──
1504
- const maxDurationSec = config.voice?.transcription?.maxDurationSec ?? 0;
1505
- if (maxDurationSec > 0) {
1506
- const dur = await _probeAudioDurationSec(audioPath);
1507
- if (dur !== null && dur > maxDurationSec) {
1508
- process.stderr.write(`mixdog: voice.transcription skipped — audio too long (${Math.floor(dur)}s > ${maxDurationSec}s): ${audioPath}\n`);
1509
- return null;
1510
- }
1511
- }
1512
- // ── transcript cache hit ──
1513
- if (attachmentId && _voiceTranscriptCache.has(attachmentId)) {
1514
- process.stderr.write(`mixdog: voice.transcription cache hit (${attachmentId})\n`);
1515
- return _voiceTranscriptCache.get(attachmentId);
1516
- }
1517
- if (attachmentId && _voiceInflight.has(attachmentId)) {
1518
- return _voiceInflight.get(attachmentId);
1519
- }
1520
- const p = _voiceTranscriptionQueue(() => _doTranscribeVoice(audioPath, attachmentId));
1521
- if (attachmentId) {
1522
- _voiceInflight.set(attachmentId, p);
1523
- p.catch((err) => {
1524
- try { process.stderr.write(`mixdog: voice.transcription inflight rejection: ${err?.stack || err}\n`); } catch {}
1525
- }).finally(() => _voiceInflight.delete(attachmentId));
1526
- }
1527
- return p;
1528
- }
1529
-
1530
- async function _doTranscribeVoice(audioPath, attachmentId) {
1531
- try {
1532
- const runtime = resolveVoiceRuntime(DATA_DIR);
1533
- if (!runtime?.installed) {
1534
- const missing = [runtime?.binary ? null : 'binary', runtime?.model ? null : 'model', runtime?.ffmpeg ? null : 'ffmpeg'].filter(Boolean).join(' + ');
1535
- throw new Error(`voice runtime not installed (missing: ${missing}) — open the setup wizard and click "Install voice"`);
1536
- }
1537
- const whisperCmd = runtime.whisperCmd;
1538
- const modelPath = runtime.modelPath;
1539
- const ffmpegPath = runtime.ffmpegPath;
1540
- const lang = normalizeWhisperLanguage(config.voice?.language) ?? detectDeviceLanguage();
1541
- const _cpuCount = (() => { try { return os.cpus().length; } catch { return 2; } })();
1542
- const threadCount = config.voice?.transcription?.threadCount ?? Math.max(1, Math.ceil(_cpuCount / 4));
1543
- // ── wav cache keyed by attachment id ──
1544
- let wavPath;
1545
- if (attachmentId && _voiceWavCache.has(attachmentId)) {
1546
- wavPath = _voiceWavCache.get(attachmentId);
1547
- if (!fs.existsSync(wavPath)) {
1548
- _voiceWavCache.delete(attachmentId);
1549
- wavPath = undefined;
1550
- } else {
1551
- process.stderr.write(`mixdog: voice.transcription wav cache hit (${attachmentId})\n`);
1552
- }
1553
- }
1554
- if (!wavPath) {
1555
- wavPath = audioPath.replace(/\.[^.]+$/, ".wav");
1556
- const sampleRate = config.voice?.transcription?.sampleRate ?? 16000;
1557
- const channels = config.voice?.transcription?.channels ?? 1;
1558
- // Single-flight: parallel callers for the same key share one ffmpeg spawn.
1559
- const _ffmpegKey = attachmentId || wavPath;
1560
- if (_voiceFfmpegInflight.has(_ffmpegKey)) {
1561
- await _voiceFfmpegInflight.get(_ffmpegKey);
1562
- } else {
1563
- const _ffmpegPromise = runCmd(ffmpegPath, ["-i", audioPath, "-ar", String(sampleRate), "-ac", String(channels), "-threads", String(threadCount), "-y", wavPath]);
1564
- _voiceFfmpegInflight.set(_ffmpegKey, _ffmpegPromise);
1565
- try {
1566
- await _ffmpegPromise;
1567
- if (attachmentId) _voiceWavCache.set(attachmentId, wavPath);
1568
- } finally {
1569
- _voiceFfmpegInflight.delete(_ffmpegKey);
1570
- }
1571
- }
1572
- }
1573
- process.stderr.write(`mixdog: voice.transcription start runtime=${runtime.kind} cmd=${path.basename(whisperCmd)}\n`);
1574
- await ensureReady({ serverCmd: runtime.serverCmd, modelPath, threadCount, host: '127.0.0.1' });
1575
- const text = await transcribe(wavPath, { language: lang });
1576
- const result = text.trim() || null;
1577
- if (attachmentId && result) _voiceTranscriptCache.set(attachmentId, result);
1578
- return result;
1579
- } catch (err) {
1580
- if (err?.message?.startsWith('voice runtime not installed')) throw err; // propagate setup errors; caller posts user-visible failure
1581
- process.stderr.write(`mixdog: voice.transcription failed: ${err}\n`);
1582
- return null;
1583
- }
1584
- }
1366
+ const { isVoiceAttachment, transcribeVoice } = createVoiceTranscription({
1367
+ getConfig: () => config,
1368
+ dataDir: DATA_DIR,
1369
+ });
1585
1370
  import { TOOL_DEFS } from './tool-defs.mjs';
1586
1371
  // Tool dispatch in worker mode goes through the IPC `call` handler at the
1587
1372
  // bottom of this file (parent's `callWorker` → `handleToolCall`). There is no
1588
1373
  // orphan worker-level MCP Server: the parent (server.mjs) owns the single
1589
1374
  // connected transport and routes CallTool through the IPC `call` path.
1590
- const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch", "react", "edit_message", "download_attachment", "trigger_schedule"]);
1375
+ const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch"]);
1376
+ // ── Inbound routing / dedup / ownership helpers ─────────────────────────────
1377
+ // Extracted → lib/inbound-routing.mjs. Bound to live config/identity getters.
1378
+ const {
1379
+ writeChannelOwner,
1380
+ shouldDropDuplicateInbound,
1381
+ resolveInboundRoute,
1382
+ } = createInboundRouting({
1383
+ getConfig: () => config,
1384
+ getInstanceId: () => INSTANCE_ID,
1385
+ getChannelOwnerPath,
1386
+ });
1591
1387
  // ── Backend-tool dispatch helpers ───────────────────────────────────────────
1592
1388
  // Each helper dispatches through the local backend (this process is always the
1593
- // owner in opt-in remote mode). The MCP-result formatting (text shape, cache
1594
- // invalidation, isError flag) is kept here so results stay consistent.
1595
- // schedule_status / schedule_control share their result-formatting between
1596
- // the local (owner) MCP case handlers and the owner-side HTTP routes that
1597
- // serve proxied standby sessions. Keeping the body here makes both paths
1598
- // byte-identical and reads the LIVE scheduler.
1599
- function scheduleStatusResult() {
1600
- const statuses = scheduler.getStatus();
1601
- if (statuses.length === 0) {
1602
- return { content: [{ type: "text", text: "no schedules configured" }] };
1603
- }
1604
- const lines = statuses.map((s) => {
1605
- const state = s.running ? " [RUNNING]" : "";
1606
- const last = s.lastFired ? ` (last: ${s.lastFired})` : "";
1607
- return ` ${s.name} ${s.time} ${s.days} (${s.type})${state}${last}`;
1608
- });
1609
- return { content: [{ type: "text", text: lines.join("\n") }] };
1610
- }
1611
- function scheduleControlResult(args) {
1612
- const scName = args.name;
1613
- const action = args.action;
1614
- // Validate that the named schedule actually exists.
1615
- const _scAll = [...(scheduler.nonInteractive || []), ...(scheduler.interactive || [])];
1616
- const _scKnown = _scAll.some(s => s.name === scName);
1617
- if (!_scKnown) {
1618
- return { content: [{ type: "text", text: `schedule_control: unknown schedule "${scName}" — use schedule_status to list valid names` }], isError: true };
1619
- }
1620
- if (action === "defer") {
1621
- const minutes = args.minutes ?? 30;
1622
- if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) {
1623
- return { content: [{ type: "text", text: `schedule_control: minutes must be a positive number, got ${JSON.stringify(minutes)}` }], isError: true };
1624
- }
1625
- scheduler.defer(scName, minutes);
1626
- return { content: [{ type: "text", text: `deferred "${scName}" for ${minutes} minutes` }] };
1627
- } else if (action === "skip_today") {
1628
- scheduler.skipToday(scName);
1629
- return { content: [{ type: "text", text: `skipped "${scName}" for today` }] };
1630
- }
1631
- return { content: [{ type: "text", text: `unknown action: ${action}` }], isError: true };
1632
- }
1633
- async function dispatchReply(args) {
1634
- const sendOpts = {
1635
- replyTo: args.reply_to,
1636
- files: args.files ?? [],
1637
- embeds: args.embeds ?? [],
1638
- components: args.components ?? []
1639
- };
1640
- let ids;
1641
- // Pre-send activity bump keeps idle gating consistent during the await.
1642
- scheduler.noteActivity();
1643
- const sendResult = await backend.sendMessage(args.chat_id, args.text, sendOpts);
1644
- scheduler.noteActivity();
1645
- ids = sendResult.sentIds;
1646
- const text = ids.length === 1 ? `sent (id: ${ids[0]})` : `sent ${ids.length} parts (ids: ${ids.join(", ")})`;
1647
- return { content: [{ type: "text", text }] };
1648
- }
1649
- async function dispatchFetch(args) {
1650
- const channelId = resolveChannelLabel(config.channelsConfig, args.channel);
1651
- const limit = args.limit ?? 20;
1652
- let msgs;
1653
- msgs = await backend.fetchMessages(channelId, limit);
1654
- recordFetchedMessages(channelId, args.channel !== channelId ? args.channel : labelForChannelId(channelId), msgs);
1655
- const text = msgs.length === 0 ? "(no messages)" : msgs.map((m) => {
1656
- const atts = m.attachmentCount > 0 ? ` +${m.attachmentCount}att` : "";
1657
- return `[${m.ts}] ${m.user}: ${m.text} (id: ${m.id}${atts})`;
1658
- }).join("\n");
1659
- return { content: [{ type: "text", text }] };
1660
- }
1661
- async function dispatchReact(args) {
1662
- await backend.react(args.chat_id, args.message_id, args.emoji);
1663
- return { content: [{ type: "text", text: "reacted" }] };
1664
- }
1665
- async function dispatchEditMessage(args) {
1666
- const opts = { embeds: args.embeds ?? [], components: args.components ?? [] };
1667
- let id;
1668
- id = await backend.editMessage(args.chat_id, args.message_id, args.text, opts);
1669
- return { content: [{ type: "text", text: `edited (id: ${id})` }] };
1670
- }
1671
- async function dispatchDownloadAttachment(args) {
1672
- let files;
1673
- files = await backend.downloadAttachment(args.chat_id, args.message_id);
1674
- if (files.length === 0) {
1675
- return { content: [{ type: "text", text: "message has no attachments" }] };
1676
- }
1677
- const lines = files.map(
1678
- (f) => ` ${f.path} (${f.name}, ${f.contentType}, ${(f.size / 1024).toFixed(0)}KB)`
1679
- );
1680
- // Each downloaded file lands on the local FS; if any of them
1681
- // had a stale prefetch entry from a prior session, drop it so
1682
- // the next prefetch sees the fresh contents.
1683
- for (const f of files) {
1684
- if (f && typeof f.path === "string" && f.path) {
1685
- invalidatePrefetchCache(f.path);
1686
- }
1687
- }
1688
- return { content: [{ type: "text", text: `downloaded ${files.length} attachment(s):
1689
- ${lines.join("\n")}` }] };
1690
- }
1691
- async function handleToolCall(name, args, _signal) {
1692
- if (isChannelsDegraded()) {
1693
- return { content: [{ type: 'text', text: `[channels degraded] ${name} unavailable — restart MCP to recover` }], isError: true }
1694
- }
1695
- let result;
1696
- try {
1697
- switch (name) {
1698
- case "reply":
1699
- result = await dispatchReply(args);
1700
- break;
1701
- case "fetch":
1702
- result = await dispatchFetch(args);
1703
- break;
1704
- case "react":
1705
- result = await dispatchReact(args);
1706
- break;
1707
- case "edit_message":
1708
- result = await dispatchEditMessage(args);
1709
- break;
1710
- case "download_attachment":
1711
- result = await dispatchDownloadAttachment(args);
1712
- break;
1713
- case "schedule_status": {
1714
- result = scheduleStatusResult();
1715
- break;
1716
- }
1717
- case "trigger_schedule": {
1718
- const triggerResult = await scheduler.triggerManual(args.name);
1719
- result = { content: [{ type: "text", text: triggerResult }] };
1720
- break;
1721
- }
1722
- case "schedule_control": {
1723
- result = scheduleControlResult(args);
1724
- break;
1725
- }
1726
- case "activate_channel_bridge": {
1727
- const active = args.active === true;
1728
- const wasActive = channelBridgeActive;
1729
- channelBridgeActive = active;
1730
- writeBridgeState(active);
1731
- if (active && !wasActive) {
1732
- refreshBridgeOwnershipSafe({ restoreBinding: true });
1733
- }
1734
- if (!active && wasActive) {
1735
- stopServerTyping();
1736
- // Tear down the owner-side runtime so Discord/scheduler/webhook/
1737
- // event-pipeline don't keep running on a deactivated bridge.
1738
- try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
1739
- process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
1740
- }
1741
- }
1742
- result = { content: [{ type: "text", text: `channel bridge ${active ? "activated" : "deactivated"}` }] };
1743
- break;
1744
- }
1745
- case "reload_config": {
1746
- await reloadRuntimeConfig();
1747
- // Extend reload to the agent module so providers/presets/maintenance
1748
- // hot-reload on the same call (dynamic import: agent/index.mjs does not
1749
- // import channels, so this stays acyclic and tolerant of load order).
1750
- let agentReloadMsg = "";
1751
- if (process.env.MIXDOG_STANDALONE !== '1') {
1752
- try {
1753
- const { reloadAgentConfig } = await import("../agent/index.mjs");
1754
- await reloadAgentConfig("reload_config tool");
1755
- agentReloadMsg = ", agent providers/presets/maintenance";
1756
- } catch (err) {
1757
- process.stderr.write(`[reload_config] agent reload failed: ${err?.message || String(err)}\n`);
1758
- }
1759
- }
1760
- result = { content: [{ type: "text", text: `config reloaded — schedules, webhooks, events${agentReloadMsg} re-registered` }] };
1761
- break;
1762
- }
1763
- case "inject_command": {
1764
- const cmd = String(args?.command || "").trim();
1765
- const ALLOW = new Set(["clear"]);
1766
- if (!ALLOW.has(cmd)) {
1767
- result = { content: [{ type: "text", text: `inject_command: '${cmd}' not in allow-list (${[...ALLOW].join(", ")})` }], isError: true };
1768
- break;
1769
- }
1770
- // Unified managed-launcher control path (cross-platform). The
1771
- // command is delivered to the `mixdog`-launched child's stdin by the
1772
- // launcher that owns it — no OS/terminal keystroke injection, no new
1773
- // window. Only sessions with an engaged native managed-launch bridge
1774
- // are addressable; anything else gets a clear not-managed error
1775
- // rather than a silent no-op.
1776
- try {
1777
- const launchId = managedLaunchId();
1778
- if (!launchId) {
1779
- result = { content: [{ type: "text", text: "inject_command: this session is not a managed `mixdog` launch (MIXDOG_LAUNCH_ID unset). Managed input delivery requires the native mixdog-launch PTY/ConPTY bridge." }], isError: true };
1780
- break;
1781
- }
1782
- enqueueLauncherCommand(launchId, `/${cmd}`);
1783
- result = { content: [{ type: "text", text: `queued /${cmd} for managed launcher (launchId=${launchId})` }] };
1784
- } catch (err) {
1785
- result = { content: [{ type: "text", text: `inject_command error: ${err?.message || err}` }], isError: true };
1786
- }
1787
- break;
1788
- }
1789
- // memory — handled by memory-service.mjs MCP
1790
- default:
1791
- result = {
1792
- content: [{ type: "text", text: `unknown tool: ${name}` }],
1793
- isError: true
1794
- };
1795
- }
1796
- } catch (err) {
1797
- const msg = err instanceof Error ? err.message : String(err);
1798
- result = {
1799
- content: [{ type: "text", text: `${name} failed: ${msg}` }],
1800
- isError: true
1801
- };
1802
- }
1803
- return result;
1804
- }
1805
- // Bridge auto-connect retry + forwarder-aware tool dispatch wrapper. Used by
1806
- // both the HTTP MCP path (createHttpMcpServer's CallTool handler can call this)
1807
- // and the worker IPC handler at the bottom of this file. The pre-v0.6.7 code
1808
- // registered this on the orphan worker-level `Server`, which never had a
1809
- // transport, so the wrapper never actually fired. Centralised here for reuse.
1810
- // Last timestamp a forwardNewText() call was dispatched (debounce for item 4).
1811
- let _lastForwardMs = 0;
1812
-
1813
- async function handleToolCallWithBridgeRetry(toolName, args, signal) {
1814
- // Debounce: only forward when ≥250 ms have elapsed since the last forward,
1815
- // to avoid one HTTP roundtrip per tool call on rapid-fire sequences.
1816
- const now = Date.now();
1817
- if (now - _lastForwardMs >= 250) {
1818
- _lastForwardMs = now;
1819
- await forwarder.forwardNewText();
1820
- }
1821
- if (BACKEND_TOOLS.has(toolName) && !bridgeRuntimeConnected) {
1822
- // Remote-owner startup: ensure this owner's backend is connected.
1823
- for (let i = 0; i < 2 && !bridgeRuntimeConnected; i++) {
1824
- try {
1825
- await refreshBridgeOwnership();
1826
- } catch {
1827
- }
1828
- if (!bridgeRuntimeConnected) await new Promise((r) => setTimeout(r, 300));
1829
- }
1830
- if (!bridgeRuntimeConnected) {
1831
- return {
1832
- content: [{ type: "text", text: `Discord auto-connect failed after retries. Check token and network.` }],
1833
- isError: true
1834
- };
1835
- }
1836
- }
1837
- const result = await handleToolCall(toolName, args, signal);
1838
- const toolLine = OutputForwarder.buildToolLine(toolName, args);
1839
- if (toolLine) {
1840
- // Distinct from the dispatch-log ok line (server-main.mjs): this forwards
1841
- // a human-readable tool summary to Discord for the user, not operator stdout.
1842
- void forwarder.forwardToolLog(toolLine, toolName, args);
1843
- }
1844
- return result;
1845
- }
1846
- const INBOUND_DEDUP_TTL = 5 * 6e4;
1847
- const inboundSeen = /* @__PURE__ */ new Map();
1848
- const INBOUND_DEDUP_DIR = path.join(os.tmpdir(), "mixdog-inbound");
1849
- ensureDir(INBOUND_DEDUP_DIR);
1850
- function writeChannelOwner(channelId) {
1851
- const ownerPath = getChannelOwnerPath(channelId);
1852
- try {
1853
- fs.writeFileSync(ownerPath, JSON.stringify({ instanceId: INSTANCE_ID, pid: process.pid, updatedAt: Date.now() }));
1854
- return true;
1855
- } catch {
1856
- return false;
1857
- }
1858
- }
1859
- function shouldDropDuplicateInbound(msg) {
1860
- const key = `${msg.chatId}:${msg.messageId}`;
1861
- const now = Date.now();
1862
- if (inboundSeen.has(key) && now - inboundSeen.get(key) < INBOUND_DEDUP_TTL) return true;
1863
- inboundSeen.set(key, now);
1864
- const marker = path.join(INBOUND_DEDUP_DIR, key.replace(/:/g, "_"));
1865
- try {
1866
- fs.writeFileSync(marker, String(now), { flag: "wx" });
1867
- } catch (e) {
1868
- if (e.code === "EEXIST") {
1869
- try {
1870
- const stat = fs.statSync(marker);
1871
- if (now - stat.mtimeMs < INBOUND_DEDUP_TTL) return true;
1872
- } catch {}
1873
- }
1874
- }
1875
- if (Math.random() < 0.1) {
1876
- try {
1877
- for (const f of fs.readdirSync(INBOUND_DEDUP_DIR)) {
1878
- const fp = path.join(INBOUND_DEDUP_DIR, f);
1879
- try {
1880
- if (now - fs.statSync(fp).mtimeMs > INBOUND_DEDUP_TTL) removeFileIfExists(fp);
1881
- } catch {
1882
- }
1883
- }
1884
- } catch {
1885
- }
1886
- }
1887
- for (const [k, t] of inboundSeen) {
1888
- if (now - t > INBOUND_DEDUP_TTL) inboundSeen.delete(k);
1889
- }
1890
- return false;
1891
- }
1892
- function resolveInboundRoute(chatId, parentChatId) {
1893
- const main = config.channelsConfig?.main;
1894
- const findEntry = (id) => {
1895
- if (!id || !config.channelsConfig) return null;
1896
- if (typeof main === "object" && main !== null && main.channelId === id) {
1897
- return { label: "main", entry: main };
1898
- }
1899
- for (const [label, entry] of Object.entries(config.channelsConfig)) {
1900
- if (typeof entry === "object" && entry !== null && entry.channelId === id) {
1901
- return { label, entry };
1902
- }
1903
- }
1904
- return null;
1905
- };
1906
- // Prefer a direct channelsConfig match on the thread/channel id; fall back
1907
- // to the parent channel id so thread messages inherit the parent's label
1908
- // and mode (e.g. monitor) instead of being routed as untagged interactive.
1909
- const direct = findEntry(chatId);
1910
- if (direct) {
1911
- const mode = direct.entry.mode === "monitor" ? "monitor" : (direct.entry.mode || "interactive");
1912
- return { targetChatId: chatId, sourceChatId: chatId, sourceLabel: direct.label, sourceMode: mode };
1913
- }
1914
- if (parentChatId) {
1915
- const viaParent = findEntry(parentChatId);
1916
- if (viaParent) {
1917
- const mode = viaParent.entry.mode === "monitor" ? "monitor" : (viaParent.entry.mode || "interactive");
1918
- return { targetChatId: chatId, sourceChatId: parentChatId, sourceLabel: viaParent.label, sourceMode: mode };
1919
- }
1920
- }
1921
- return { targetChatId: chatId, sourceChatId: chatId, sourceLabel: undefined, sourceMode: "interactive" };
1922
- }
1389
+ // owner in opt-in remote mode). Extracted → lib/backend-dispatch.mjs. Bound to
1390
+ // live config/backend getters so runtime reloads keep the original file-level
1391
+ // reference semantics.
1392
+ const {
1393
+ dispatchReply,
1394
+ dispatchFetch,
1395
+ } = createBackendDispatch({
1396
+ getConfig: () => config,
1397
+ getBackend: () => backend,
1398
+ scheduler,
1399
+ });
1400
+ // ── Worker/HTTP tool-call dispatch ──────────────────────────────────────────
1401
+ // handleToolCall switch + bridge auto-connect retry wrapper. Extracted →
1402
+ // lib/tool-dispatch.mjs. The switch is entangled with ~8 runtime-lifecycle
1403
+ // functions plus mutable owner state (channelBridgeActive/bridgeRuntimeConnected)
1404
+ // and the forwarder; those are threaded as a lifecycle bag of lazy getters so
1405
+ // the module reads live file-level references at call time (original closure
1406
+ // semantics preserved). Used by the HTTP MCP CallTool path and the worker IPC
1407
+ // `call` handler at the bottom of this file.
1408
+ const {
1409
+ handleToolCall,
1410
+ handleToolCallWithBridgeRetry,
1411
+ } = createToolDispatch({
1412
+ getForwarder: () => forwarder,
1413
+ BACKEND_TOOLS,
1414
+ isChannelsDegraded,
1415
+ dispatchReply,
1416
+ dispatchFetch,
1417
+ lifecycle: {
1418
+ getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
1419
+ getChannelBridgeActive: () => channelBridgeActive,
1420
+ setChannelBridgeActive: (v) => { channelBridgeActive = v; },
1421
+ writeBridgeState,
1422
+ stopServerTyping,
1423
+ claimBridgeOwnership,
1424
+ refreshBridgeOwnership,
1425
+ bindPersistedTranscriptIfAny,
1426
+ stopOwnedRuntime,
1427
+ reloadRuntimeConfig,
1428
+ },
1429
+ });
1923
1430
  const inboundQueue = (() => {
1924
1431
  let tail = Promise.resolve();
1925
1432
  let _iqDepth = 0;
@@ -1935,14 +1442,6 @@ const inboundQueue = (() => {
1935
1442
  }).finally(() => { _iqDepth--; });
1936
1443
  };
1937
1444
  })();
1938
- // ── Reverse-lookup channelId → human label from channelsConfig ──────────────
1939
- function labelForChannelId(channelId) {
1940
- if (!channelId || !config.channelsConfig) return channelId;
1941
- for (const [label, entry] of Object.entries(config.channelsConfig)) {
1942
- if (entry?.channelId === channelId) return label;
1943
- }
1944
- return channelId;
1945
- }
1946
1445
 
1947
1446
  backend.onMessage = (msg) => {
1948
1447
  const receivedAtMs = Number.isFinite(msg.receivedAtMs) ? msg.receivedAtMs : Date.now();
@@ -1953,7 +1452,8 @@ backend.onMessage = (msg) => {
1953
1452
  }
1954
1453
  if (!channelBridgeActive) return;
1955
1454
  if (shouldDropDuplicateInbound(msg)) return;
1956
- recordFetchedMessages(msg.chatId, labelForChannelId(msg.chatId), [{ id: msg.messageId }], { markRead: true });
1455
+ // No label concept anymore: the channel id IS the identifier.
1456
+ recordFetchedMessages(msg.chatId, msg.chatId, [{ id: msg.messageId }], { markRead: true });
1957
1457
  if (!writeChannelOwner(msg.chatId)) return;
1958
1458
  const route = resolveInboundRoute(msg.chatId, msg.parentChatId);
1959
1459
  scheduler.noteActivity();
@@ -1970,6 +1470,7 @@ backend.onMessage = (msg) => {
1970
1470
  let boundTranscript = null;
1971
1471
  let stoleSelfTranscript = false;
1972
1472
  let transcriptPath = forwarder.hasBinding() ? forwarder.transcriptPath : "";
1473
+ let needsStealPoll = false;
1973
1474
  // Reuse the current binding only while it still points at THIS owner's own
1974
1475
  // session. discoverSessionBoundTranscript() now ranks the live parent-chain
1975
1476
  // session (the one that forked this worker and receives injected input)
@@ -2003,6 +1504,12 @@ backend.onMessage = (msg) => {
2003
1504
  transcriptPath,
2004
1505
  exists: true
2005
1506
  };
1507
+ // Fast path skips the poll below (zero added latency) unless we lack a
1508
+ // confident, currently-active self-bound candidate — that's the
1509
+ // ~ms race window right after activate, before the parent-chain
1510
+ // session record is published, where the steal gate above fails on
1511
+ // the very first inbound even though a real self session exists.
1512
+ if (!selfBound || selfBound.active !== true) needsStealPoll = true;
2006
1513
  }
2007
1514
  } else {
2008
1515
  boundTranscript = discoverSessionBoundTranscript();
@@ -2020,16 +1527,98 @@ backend.onMessage = (msg) => {
2020
1527
  }
2021
1528
  }
2022
1529
  }
2023
- if (transcriptPath) {
2024
- applyTranscriptBinding(route.targetChatId, transcriptPath, { cwd: boundTranscript?.sessionCwd });
2025
- } else {
2026
- refreshActiveInstance(INSTANCE_ID, { channelId: route.targetChatId });
1530
+ // Binding-settled signal: resolves once the queued binding task below
1531
+ // (poll-if-needed + apply-bind + rebind) has run, so the react/status
1532
+ // IIFE can read the FINAL transcriptPath/boundTranscript instead of the
1533
+ // pre-poll snapshot. onMessage itself stays synchronous — nothing here
1534
+ // blocks message ordering or delays the enqueue calls.
1535
+ let bindingDoneResolve;
1536
+ const bindingDone = new Promise((resolve) => { bindingDoneResolve = resolve; });
1537
+ const queuedAtMs = Date.now();
1538
+ const preQueueMs = queuedAtMs - onMessageAtMs;
1539
+ const gatewayToQueueMs = queuedAtMs - receivedAtMs;
1540
+ if (preQueueMs > 250 || gatewayToQueueMs > 500) {
1541
+ process.stderr.write(`mixdog: inbound latency prequeue=${preQueueMs}ms gateway_to_queue=${gatewayToQueueMs}ms channel=${route.targetChatId}\n`);
2027
1542
  }
1543
+ // ONE queued task per message: binding (poll-if-needed + bind + rebind)
1544
+ // first, then handleInbound. Keeping both phases in a single task preserves
1545
+ // the queue's per-message depth accounting — the overflow guard drops a
1546
+ // whole message, never just its handleInbound half — and guarantees
1547
+ // bindingDone/stopServerTyping always settle even when the binding phase
1548
+ // throws. FIFO is preserved: inboundQueue chains tasks in call order, so
1549
+ // this message's poll delay (if any) defers only its own delivery.
1550
+ inboundQueue(async () => {
1551
+ try {
1552
+ if (needsStealPoll) {
1553
+ const POLL_INTERVAL_MS = 50;
1554
+ const POLL_TIMEOUT_MS = 500;
1555
+ const pollStart = Date.now();
1556
+ while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
1557
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
1558
+ // fresh: bypass the negative parent-pid cache inside the walk —
1559
+ // a transient parent-lookup miss cached just before this poll
1560
+ // would otherwise pin every retry to null until its TTL expires,
1561
+ // defeating the whole first-inbound recovery window.
1562
+ const retryBound = discoverSessionBoundTranscript({ fresh: true });
1563
+ const retryShouldSteal = Boolean(
1564
+ retryBound?.transcriptPath &&
1565
+ !sameResolvedPath(retryBound.transcriptPath, transcriptPath) &&
1566
+ retryBound.active === true &&
1567
+ (retryBound.parentChain === true || retryBound.cwdMatches === true)
1568
+ );
1569
+ if (retryShouldSteal) {
1570
+ process.stderr.write(`mixdog: inbound rebind (poll +${Date.now() - pollStart}ms): stealing transcript ${transcriptPath} -> ${retryBound.transcriptPath} (source=${retryBound.source || "unknown"}, exists=${retryBound.exists})\n`);
1571
+ transcriptPath = retryBound.transcriptPath;
1572
+ boundTranscript = retryBound;
1573
+ stoleSelfTranscript = true;
1574
+ break;
1575
+ }
1576
+ }
1577
+ }
1578
+ if (transcriptPath) {
1579
+ applyTranscriptBinding(route.targetChatId, transcriptPath, { cwd: boundTranscript?.sessionCwd });
1580
+ } else {
1581
+ refreshActiveInstance(INSTANCE_ID, { channelId: route.targetChatId }, { onlyIfOwned: true });
1582
+ }
1583
+ if (!boundTranscript?.exists) {
1584
+ await rebindTranscriptContext(route.targetChatId, {
1585
+ // For a stolen self transcript (not yet on disk) the sync bind above
1586
+ // persisted lastFileSize=0 for this path, so catchUpFromPersisted makes
1587
+ // setContext resume from offset 0 once the file appears — forwarding
1588
+ // the first assistant reply. Relying on replayFromStart instead would
1589
+ // race: the discovery loop only sets replayFromStart when it first saw
1590
+ // the transcript as PENDING, so a file that already exists on the first
1591
+ // loop iteration would bind at EOF and skip the reply. Non-steal keeps
1592
+ // the original catch-up-from-cursor behaviour.
1593
+ previousPath: transcriptPath,
1594
+ catchUp: true,
1595
+ catchUpFromPersisted: stoleSelfTranscript ? true : undefined,
1596
+ persistStatus: true
1597
+ });
1598
+ }
1599
+ } catch (err) {
1600
+ try { process.stderr.write(`mixdog: inbound binding error: ${err}\n`); } catch {}
1601
+ } finally {
1602
+ bindingDoneResolve();
1603
+ }
1604
+ try {
1605
+ await handleInbound(msg, route, {
1606
+ sessionId: boundTranscript?.sessionId ?? sessionIdFromTranscriptPath(transcriptPath),
1607
+ receivedAtMs,
1608
+ queuedAtMs
1609
+ });
1610
+ } catch (err) {
1611
+ process.stderr.write(`mixdog: handleInbound error: ${err}\n`);
1612
+ } finally {
1613
+ stopServerTyping();
1614
+ }
1615
+ });
2028
1616
  void (async () => {
2029
1617
  try {
2030
1618
  await backend.react(msg.chatId, msg.messageId, "\u{1F914}");
2031
1619
  } catch {
2032
1620
  }
1621
+ await bindingDone;
2033
1622
  statusState.update((state) => {
2034
1623
  state.channelId = route.targetChatId;
2035
1624
  state.userMessageId = msg.messageId;
@@ -2040,39 +1629,7 @@ backend.onMessage = (msg) => {
2040
1629
  else delete state.transcriptPath;
2041
1630
  state.sessionCwd = boundTranscript?.sessionCwd ?? null;
2042
1631
  });
2043
- if (!boundTranscript?.exists) {
2044
- await rebindTranscriptContext(route.targetChatId, {
2045
- // For a stolen self transcript (not yet on disk) the sync bind above
2046
- // persisted lastFileSize=0 for this path, so catchUpFromPersisted makes
2047
- // setContext resume from offset 0 once the file appears — forwarding
2048
- // the first assistant reply. Relying on replayFromStart instead would
2049
- // race: the discovery loop only sets replayFromStart when it first saw
2050
- // the transcript as PENDING, so a file that already exists on the first
2051
- // loop iteration would bind at EOF and skip the reply. Non-steal keeps
2052
- // the original catch-up-from-cursor behaviour.
2053
- previousPath: transcriptPath,
2054
- catchUp: true,
2055
- catchUpFromPersisted: stoleSelfTranscript ? true : undefined,
2056
- persistStatus: true
2057
- });
2058
- }
2059
1632
  })();
2060
- const queuedAtMs = Date.now();
2061
- const preQueueMs = queuedAtMs - onMessageAtMs;
2062
- const gatewayToQueueMs = queuedAtMs - receivedAtMs;
2063
- if (preQueueMs > 250 || gatewayToQueueMs > 500) {
2064
- process.stderr.write(`mixdog: inbound latency prequeue=${preQueueMs}ms gateway_to_queue=${gatewayToQueueMs}ms channel=${route.targetChatId}\n`);
2065
- }
2066
- inboundQueue(() => handleInbound(msg, route, {
2067
- sessionId: boundTranscript?.sessionId ?? sessionIdFromTranscriptPath(transcriptPath),
2068
- receivedAtMs,
2069
- queuedAtMs
2070
- }).catch((err) => {
2071
- process.stderr.write(`mixdog: handleInbound error: ${err}
2072
- `);
2073
- }).finally(() => {
2074
- stopServerTyping();
2075
- }));
2076
1633
  };
2077
1634
  async function handleInbound(msg, route, options = {}) {
2078
1635
  const handleStartMs = Date.now();
@@ -2301,9 +1858,8 @@ if (_isWorkerMode && process.send) {
2301
1858
  }
2302
1859
  refreshToolExecConsumerMarker();
2303
1860
  }
2304
- const mainLabel = config?.mainChannel || 'main';
2305
1861
  const target = (statusState?.read?.().channelId)
2306
- || resolveChannelLabel(config?.channelsConfig, mainLabel)
1862
+ || config?.channelId
2307
1863
  || null;
2308
1864
  if (!target || !backend?.sendMessage) {
2309
1865
  process.stderr.write(`mixdog channels: permission_request dropped, no target channel (request_id=${request_id})\n`);
@@ -2343,17 +1899,7 @@ if (_isWorkerMode && process.send) {
2343
1899
  }
2344
1900
  return;
2345
1901
  }
2346
- if (msg && msg.type === 'memory_call_response' && msg.callId) {
2347
- // Response side of the worker → parent → memory bridge. Routed into
2348
- // this existing listener (instead of a second process.on('message'))
2349
- // to keep IPC dispatch in one place.
2350
- const pending = _memoryCallPending.get(msg.callId);
2351
- if (!pending) return;
2352
- _memoryCallPending.delete(msg.callId);
2353
- if (msg.ok) pending.resolve(msg.result);
2354
- else pending.reject(new Error(msg.error || 'memory_call failed'));
2355
- return;
2356
- }
1902
+ if (handleMemoryCallResponse(msg)) return;
2357
1903
  if (msg.type === 'cancel' && msg.callId) {
2358
1904
  const entry = _inFlightChannelCalls.get(msg.callId)
2359
1905
  if (entry) {