mixdog 0.9.2 → 0.9.4

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 (417) hide show
  1. package/package.json +8 -3
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  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/review-tasks.json +20 -0
  7. package/scripts/bench/round-codex.json +114 -0
  8. package/scripts/bench/round-mixdog-lead-r3.json +269 -0
  9. package/scripts/bench/round-mixdog-lead.json +269 -0
  10. package/scripts/bench/round-mixdog.json +126 -0
  11. package/scripts/bench/round-r10-bigsample.json +679 -0
  12. package/scripts/bench/round-r11-codexalign.json +257 -0
  13. package/scripts/bench/round-r4-codex.json +114 -0
  14. package/scripts/bench/round-r4-mixed.json +225 -0
  15. package/scripts/bench/round-r5-gpt-lead.json +259 -0
  16. package/scripts/bench/round-r6-codex.json +114 -0
  17. package/scripts/bench/round-r6-solo.json +257 -0
  18. package/scripts/bench/round-r7-full.json +254 -0
  19. package/scripts/bench/round-r8-fulldefault.json +255 -0
  20. package/scripts/bench-run.mjs +215 -29
  21. package/scripts/build-tui.mjs +13 -1
  22. package/scripts/explore-bench.mjs +124 -0
  23. package/scripts/freevar-smoke.mjs +95 -0
  24. package/scripts/hook-bus-test.mjs +191 -0
  25. package/scripts/internal-comms-bench.mjs +1 -0
  26. package/scripts/internal-comms-smoke.mjs +10 -9
  27. package/scripts/mouse-probe.mjs +45 -0
  28. package/scripts/output-style-bench.mjs +13 -6
  29. package/scripts/output-style-smoke.mjs +4 -4
  30. package/scripts/path-suffix-test.mjs +57 -0
  31. package/scripts/provider-toolcall-test.mjs +7 -3
  32. package/scripts/recall-bench.mjs +207 -0
  33. package/scripts/recall-usecase-cases.json +18 -0
  34. package/scripts/recall-usecase-probe.json +6 -0
  35. package/scripts/session-bench.mjs +152 -6
  36. package/scripts/tool-smoke.mjs +30 -67
  37. package/scripts/tui-render-smoke.mjs +90 -0
  38. package/scripts/webhook-smoke.mjs +208 -0
  39. package/src/agents/debugger/AGENT.md +5 -2
  40. package/src/agents/heavy-worker/AGENT.md +21 -11
  41. package/src/agents/maintainer/AGENT.md +4 -0
  42. package/src/agents/reviewer/AGENT.md +3 -2
  43. package/src/agents/worker/AGENT.md +21 -11
  44. package/src/lib/rules-builder.cjs +4 -0
  45. package/src/mixdog-session-runtime.mjs +933 -3731
  46. package/src/output-styles/default.md +34 -9
  47. package/src/output-styles/{oneline.md → extreme-minimal.md} +5 -4
  48. package/src/output-styles/minimal.md +4 -1
  49. package/src/output-styles/simple.md +22 -7
  50. package/src/repl.mjs +5 -5
  51. package/src/rules/agent/00-common.md +2 -0
  52. package/src/rules/agent/30-explorer.md +8 -11
  53. package/src/rules/lead/lead-brief.md +12 -0
  54. package/src/rules/lead/lead-tool.md +2 -9
  55. package/src/rules/shared/01-tool.md +11 -5
  56. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +25 -0
  57. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +100 -23
  58. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +362 -0
  60. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +410 -0
  61. package/src/runtime/agent/orchestrator/agent-trace.mjs +16 -735
  62. package/src/runtime/agent/orchestrator/config.mjs +69 -2
  63. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  64. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  65. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +63 -21
  66. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  67. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +209 -0
  68. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +489 -0
  69. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +97 -1343
  70. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +607 -0
  71. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +78 -10
  72. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  73. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +81 -0
  74. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +248 -0
  75. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +303 -0
  76. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +505 -0
  77. package/src/runtime/agent/orchestrator/providers/gemini.mjs +44 -1014
  78. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +18 -4
  79. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  80. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +86 -11
  81. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +348 -0
  82. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  83. package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +108 -0
  84. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  85. package/src/runtime/agent/orchestrator/providers/openai-compat-trace.mjs +58 -0
  86. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +368 -0
  87. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +760 -0
  88. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +41 -1142
  89. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +732 -0
  90. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +193 -0
  91. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +303 -2119
  92. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +140 -995
  93. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +227 -0
  94. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +67 -0
  95. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +436 -0
  96. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1105 -0
  97. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  99. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  100. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +288 -0
  101. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +85 -0
  102. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +749 -0
  103. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +82 -0
  104. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +315 -0
  105. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +643 -0
  106. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +326 -0
  107. package/src/runtime/agent/orchestrator/session/compact.mjs +40 -2282
  108. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  109. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +274 -0
  110. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +61 -0
  111. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  112. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  113. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  114. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +47 -0
  115. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +182 -0
  116. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +173 -0
  117. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  118. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  119. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +58 -0
  120. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  121. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +239 -0
  122. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  123. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  124. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  125. package/src/runtime/agent/orchestrator/session/loop.mjs +409 -1304
  126. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +471 -0
  127. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +230 -0
  128. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  129. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +149 -0
  130. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  131. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +406 -0
  132. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +80 -0
  133. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  134. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +210 -0
  135. package/src/runtime/agent/orchestrator/session/manager.mjs +226 -2114
  136. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +189 -0
  137. package/src/runtime/agent/orchestrator/session/store.mjs +74 -179
  138. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  139. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +70 -20
  140. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +22 -2
  141. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +33 -42
  142. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  143. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  144. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +40 -0
  145. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  146. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  147. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  148. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +29 -0
  149. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +8 -0
  150. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +126 -0
  151. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +81 -87
  152. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +161 -0
  153. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +108 -0
  154. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +78 -304
  155. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -6
  156. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  157. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  158. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  159. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +551 -0
  160. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  161. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  162. package/src/runtime/agent/orchestrator/tools/code-graph/keyword-match.mjs +82 -0
  163. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  164. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  165. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  166. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1080 -0
  167. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  168. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  169. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  170. package/src/runtime/agent/orchestrator/tools/code-graph/text-columns.mjs +45 -0
  171. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  172. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +6 -6
  173. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  174. package/src/runtime/agent/orchestrator/tools/graph-binary-fetcher.mjs +6 -3
  175. package/src/runtime/agent/orchestrator/tools/patch/constants.mjs +9 -0
  176. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +171 -0
  177. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +471 -0
  178. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +436 -0
  179. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +342 -0
  180. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +359 -0
  181. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +340 -0
  182. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +643 -0
  183. package/src/runtime/agent/orchestrator/tools/patch.mjs +36 -2959
  184. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -23
  185. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +10 -74
  186. package/src/runtime/agent/orchestrator/tools/shell-powershell.mjs +77 -0
  187. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  188. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +154 -0
  189. package/src/runtime/channels/backends/discord-access.mjs +32 -0
  190. package/src/runtime/channels/backends/discord-attachments.mjs +65 -0
  191. package/src/runtime/channels/backends/discord-gateway.mjs +233 -0
  192. package/src/runtime/channels/backends/discord.mjs +12 -292
  193. package/src/runtime/channels/index.mjs +241 -894
  194. package/src/runtime/channels/lib/backend-dispatch.mjs +44 -0
  195. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  196. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  197. package/src/runtime/channels/lib/event-pipeline.mjs +18 -1
  198. package/src/runtime/channels/lib/event-queue.mjs +63 -4
  199. package/src/runtime/channels/lib/inbound-routing.mjs +111 -0
  200. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  201. package/src/runtime/channels/lib/output-forwarder.mjs +9 -1
  202. package/src/runtime/channels/lib/owner-heartbeat.mjs +75 -0
  203. package/src/runtime/channels/lib/parent-bridge.mjs +88 -0
  204. package/src/runtime/channels/lib/runtime-paths.mjs +14 -4
  205. package/src/runtime/channels/lib/session-discovery.mjs +56 -4
  206. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  207. package/src/runtime/channels/lib/tool-dispatch.mjs +158 -0
  208. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  209. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -4
  210. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -3
  211. package/src/runtime/channels/lib/voice-transcription.mjs +179 -0
  212. package/src/runtime/channels/lib/webhook/deliveries.mjs +312 -0
  213. package/src/runtime/channels/lib/webhook/log.mjs +42 -0
  214. package/src/runtime/channels/lib/webhook/ngrok.mjs +181 -0
  215. package/src/runtime/channels/lib/webhook/signature.mjs +60 -0
  216. package/src/runtime/channels/lib/webhook.mjs +36 -570
  217. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  218. package/src/runtime/channels/tool-defs.mjs +11 -130
  219. package/src/runtime/memory/index.mjs +258 -2050
  220. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  221. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +58 -0
  222. package/src/runtime/memory/lib/cycle-scheduler.mjs +497 -0
  223. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  224. package/src/runtime/memory/lib/embedding-warmup.mjs +58 -0
  225. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  226. package/src/runtime/memory/lib/memory-config-flags.mjs +91 -0
  227. package/src/runtime/memory/lib/memory-cycle.mjs +1 -1
  228. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +515 -0
  229. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +324 -0
  230. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +18 -0
  231. package/src/runtime/memory/lib/memory-cycle2.mjs +72 -837
  232. package/src/runtime/memory/lib/memory-embed.mjs +149 -0
  233. package/src/runtime/memory/lib/memory-process-lock.mjs +162 -0
  234. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  235. package/src/runtime/memory/lib/memory-recall-store.mjs +22 -2
  236. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  237. package/src/runtime/memory/lib/memory.mjs +20 -0
  238. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -1
  239. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  240. package/src/runtime/memory/lib/query-handlers.mjs +780 -0
  241. package/src/runtime/memory/lib/recall-format.mjs +238 -0
  242. package/src/runtime/memory/lib/runtime-fetcher.mjs +8 -3
  243. package/src/runtime/memory/lib/transcript-ingest.mjs +425 -0
  244. package/src/runtime/memory/tool-defs.mjs +6 -14
  245. package/src/runtime/search/lib/http-fetch.mjs +274 -0
  246. package/src/runtime/search/lib/ssrf-guard.mjs +333 -0
  247. package/src/runtime/search/lib/web-tools.mjs +24 -602
  248. package/src/runtime/shared/abort-controller.mjs +1 -1
  249. package/src/runtime/shared/atomic-file.mjs +26 -1
  250. package/src/runtime/shared/background-tasks.mjs +2 -3
  251. package/src/runtime/shared/buffered-appender.mjs +149 -0
  252. package/src/runtime/shared/launcher-control.mjs +2 -2
  253. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  254. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  255. package/src/runtime/shared/tool-primitives.mjs +308 -0
  256. package/src/runtime/shared/tool-result-summary.mjs +515 -0
  257. package/src/runtime/shared/tool-surface.mjs +80 -898
  258. package/src/runtime/shared/transcript-writer.mjs +52 -2
  259. package/src/runtime/shared/update-checker.mjs +7 -4
  260. package/src/session-runtime/config-helpers.mjs +291 -0
  261. package/src/session-runtime/config-lifecycle.mjs +232 -0
  262. package/src/session-runtime/cwd-plugins.mjs +226 -0
  263. package/src/session-runtime/effort.mjs +128 -0
  264. package/src/session-runtime/fs-utils.mjs +10 -0
  265. package/src/session-runtime/mcp-glue.mjs +177 -0
  266. package/src/session-runtime/model-capabilities.mjs +130 -0
  267. package/src/session-runtime/model-recency.mjs +111 -0
  268. package/src/session-runtime/native-search.mjs +247 -0
  269. package/src/session-runtime/output-styles.mjs +126 -0
  270. package/src/session-runtime/plugin-mcp.mjs +114 -0
  271. package/src/session-runtime/prewarm.mjs +142 -0
  272. package/src/session-runtime/provider-models.mjs +278 -0
  273. package/src/session-runtime/provider-usage.mjs +120 -0
  274. package/src/session-runtime/quick-model-rows.mjs +170 -0
  275. package/src/session-runtime/quick-search-models.mjs +46 -0
  276. package/src/session-runtime/session-hooks.mjs +93 -0
  277. package/src/session-runtime/session-text.mjs +100 -0
  278. package/src/session-runtime/settings-api.mjs +319 -0
  279. package/src/session-runtime/statusline-route.mjs +35 -0
  280. package/src/session-runtime/tool-catalog.mjs +720 -0
  281. package/src/session-runtime/tool-defs.mjs +84 -0
  282. package/src/session-runtime/warmup-schedulers.mjs +201 -0
  283. package/src/session-runtime/workflow.mjs +358 -0
  284. package/src/standalone/agent-tool/helpers.mjs +237 -0
  285. package/src/standalone/agent-tool/notify.mjs +107 -0
  286. package/src/standalone/agent-tool/provider-init.mjs +143 -0
  287. package/src/standalone/agent-tool/render.mjs +152 -0
  288. package/src/standalone/agent-tool/tool-def.mjs +55 -0
  289. package/src/standalone/agent-tool.mjs +155 -677
  290. package/src/standalone/channel-worker.mjs +7 -9
  291. package/src/standalone/explore-tool.mjs +40 -12
  292. package/src/standalone/hook-bus/config.mjs +207 -0
  293. package/src/standalone/hook-bus/constants.mjs +90 -0
  294. package/src/standalone/hook-bus/handlers.mjs +481 -0
  295. package/src/standalone/hook-bus/payload.mjs +31 -0
  296. package/src/standalone/hook-bus/rules.mjs +77 -0
  297. package/src/standalone/hook-bus.mjs +110 -746
  298. package/src/standalone/memory-runtime-proxy.mjs +7 -0
  299. package/src/standalone/opencode-go-login.mjs +125 -0
  300. package/src/standalone/provider-admin.mjs +15 -19
  301. package/src/standalone/usage-dashboard.mjs +3 -1
  302. package/src/tui/App.jsx +1163 -7571
  303. package/src/tui/app/app-format.mjs +206 -0
  304. package/src/tui/app/channel-pickers.mjs +510 -0
  305. package/src/tui/app/clipboard.mjs +67 -0
  306. package/src/tui/app/core-memory-picker.mjs +210 -0
  307. package/src/tui/app/extension-pickers.mjs +506 -0
  308. package/src/tui/app/input-parsers.mjs +193 -0
  309. package/src/tui/app/maintenance-pickers.mjs +324 -0
  310. package/src/tui/app/model-options.mjs +330 -0
  311. package/src/tui/app/model-picker.mjs +365 -0
  312. package/src/tui/app/onboarding-steps.mjs +400 -0
  313. package/src/tui/app/project-picker.mjs +247 -0
  314. package/src/tui/app/provider-setup-picker.mjs +580 -0
  315. package/src/tui/app/resume-picker.mjs +55 -0
  316. package/src/tui/app/route-pickers.mjs +419 -0
  317. package/src/tui/app/settings-picker.mjs +490 -0
  318. package/src/tui/app/slash-commands.mjs +101 -0
  319. package/src/tui/app/slash-dispatch.mjs +427 -0
  320. package/src/tui/app/text-layout.mjs +46 -0
  321. package/src/tui/app/theme-effort-pickers.mjs +154 -0
  322. package/src/tui/app/transcript-window.mjs +671 -0
  323. package/src/tui/app/use-mouse-input.mjs +460 -0
  324. package/src/tui/app/use-prompt-handlers.mjs +310 -0
  325. package/src/tui/app/use-transcript-scroll.mjs +510 -0
  326. package/src/tui/app/use-transcript-window.mjs +589 -0
  327. package/src/tui/components/ConfirmBar.jsx +1 -1
  328. package/src/tui/components/Picker.jsx +32 -4
  329. package/src/tui/components/PromptInput.jsx +259 -80
  330. package/src/tui/components/SlashCommandPalette.jsx +8 -1
  331. package/src/tui/components/StatusLine.jsx +63 -12
  332. package/src/tui/components/TextEntryPanel.jsx +11 -0
  333. package/src/tui/components/ToolExecution.jsx +56 -588
  334. package/src/tui/components/TranscriptItem.jsx +105 -0
  335. package/src/tui/components/UsagePanel.jsx +18 -4
  336. package/src/tui/components/prompt-input/edit-helpers.mjs +72 -0
  337. package/src/tui/components/prompt-input/voice-indicator.mjs +39 -0
  338. package/src/tui/components/tool-execution/ResultBody.jsx +56 -0
  339. package/src/tui/components/tool-execution/surface-detail.mjs +405 -0
  340. package/src/tui/components/tool-execution/text-format.mjs +161 -0
  341. package/src/tui/components/tool-output-format.mjs +2 -2
  342. package/src/tui/display-width.mjs +20 -3
  343. package/src/tui/dist/index.mjs +18034 -17188
  344. package/src/tui/engine/agent-envelope.mjs +296 -0
  345. package/src/tui/engine/agent-job-feed.mjs +133 -0
  346. package/src/tui/engine/boot-profile.mjs +21 -0
  347. package/src/tui/engine/labels.mjs +67 -0
  348. package/src/tui/engine/notice-text.mjs +112 -0
  349. package/src/tui/engine/notification-plan.mjs +76 -0
  350. package/src/tui/engine/queue-helpers.mjs +161 -0
  351. package/src/tui/engine/render-timing.mjs +17 -0
  352. package/src/tui/engine/session-stats.mjs +46 -0
  353. package/src/tui/engine/tool-approval.mjs +94 -0
  354. package/src/tui/engine/tool-call-fields.mjs +23 -0
  355. package/src/tui/engine/tool-card-results.mjs +234 -0
  356. package/src/tui/engine/tool-result-status.mjs +135 -0
  357. package/src/tui/engine/tool-result-text.mjs +126 -0
  358. package/src/tui/engine.mjs +405 -1385
  359. package/src/tui/figures.mjs +5 -0
  360. package/src/tui/index.jsx +105 -0
  361. package/src/tui/input-editing.mjs +60 -10
  362. package/src/tui/keyboard-protocol.mjs +2 -2
  363. package/src/tui/lib/voice-recorder.mjs +35 -19
  364. package/src/tui/markdown/format-token.mjs +11 -9
  365. package/src/tui/paste-attachments.mjs +38 -0
  366. package/src/tui/statusline-ansi-bridge.mjs +11 -3
  367. package/src/tui/theme.mjs +6 -0
  368. package/src/tui/themes/base.mjs +2 -2
  369. package/src/tui/themes/kanagawa.mjs +4 -4
  370. package/src/tui/themes/teal.mjs +4 -5
  371. package/src/tui/themes/utils.mjs +1 -1
  372. package/src/ui/statusline-agents.mjs +213 -0
  373. package/src/ui/statusline-format.mjs +146 -0
  374. package/src/ui/statusline-segments.mjs +148 -0
  375. package/src/ui/statusline.mjs +77 -462
  376. package/src/ui/tool-card.mjs +0 -1
  377. package/src/vendor/statusline/bin/statusline-route.mjs +15 -2
  378. package/src/workflows/default/WORKFLOW.md +16 -9
  379. package/src/workflows/sequential/WORKFLOW.md +16 -11
  380. package/src/workflows/solo/WORKFLOW.md +5 -1
  381. package/vendor/ink/build/display-width.js +19 -3
  382. package/vendor/ink/build/ink.js +103 -6
  383. package/vendor/ink/build/log-update.js +17 -3
  384. package/vendor/ink/build/wrap-text.js +125 -0
  385. package/scripts/_test-folder-dialog.mjs +0 -30
  386. package/scripts/fix-brief-fn.mjs +0 -35
  387. package/scripts/fix-format-tool-surface.mjs +0 -24
  388. package/scripts/fix-tool-exec-visible.mjs +0 -42
  389. package/scripts/patch-agent-brief.mjs +0 -48
  390. package/scripts/patch-app.mjs +0 -21
  391. package/scripts/patch-app2.mjs +0 -18
  392. package/scripts/patch-dist-brief.mjs +0 -96
  393. package/scripts/patch-tool-exec.mjs +0 -70
  394. package/src/examples/schedules/SCHEDULE.example.md +0 -32
  395. package/src/examples/webhooks/WEBHOOK.example.md +0 -40
  396. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +0 -107
  397. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +0 -143
  398. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -285
  399. package/src/runtime/agent/orchestrator/tools/builtin/open-config-tool.mjs +0 -26
  400. package/src/runtime/shared/channel-notification-routing.test.mjs +0 -45
  401. package/src/runtime/shared/tool-execution-contract.test.mjs +0 -183
  402. package/src/standalone/agent-task-status.test.mjs +0 -76
  403. package/src/tui/components/tool-output-format.test.mjs +0 -399
  404. package/src/tui/display-width.test.mjs +0 -35
  405. package/src/tui/engine-runtime-notification.test.mjs +0 -115
  406. package/src/tui/engine-tool-result-text.test.mjs +0 -75
  407. package/src/tui/markdown/format-token.test.mjs +0 -354
  408. package/src/tui/markdown/render-ansi.test.mjs +0 -108
  409. package/src/tui/markdown/stream-fence.test.mjs +0 -26
  410. package/src/tui/markdown/streaming-markdown.test.mjs +0 -70
  411. package/src/tui/prompt-history-store.test.mjs +0 -52
  412. package/src/tui/statusline-ansi-bridge.test.mjs +0 -159
  413. package/src/tui/transcript-tool-failures.test.mjs +0 -111
  414. package/src/ui/markdown.test.mjs +0 -70
  415. package/src/ui/statusline-context-label.test.mjs +0 -15
  416. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -186
  417. package/src/vendor/statusline/bin/statusline-route.test.mjs +0 -80
@@ -1,31 +1,20 @@
1
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
- import {
4
- ListToolsRequestSchema,
5
- CallToolRequestSchema
6
- } from "@modelcontextprotocol/sdk/types.js";
7
- import { spawn } from "child_process";
8
1
  import * as fs from "fs";
9
- import * as http from "http";
10
2
  import * as os from "os";
11
3
  import * as path from "path";
12
4
  import { performance } from "perf_hooks";
13
- import { pathToFileURL } from "url";
14
5
  import { createRequire } from "module";
15
6
  const _require = createRequire(import.meta.url);
16
7
  import { loadConfig, createBackend, loadProfileConfig, DATA_DIR } from "./lib/config.mjs";
17
8
  import { resolveVoiceRuntime } from "./lib/voice-runtime-fetcher.mjs";
18
- import { ensureReady, transcribe, stopVoiceWhisperServer } from "./lib/whisper-server.mjs";
9
+ import { ensureReady, stopVoiceWhisperServer } from "./lib/whisper-server.mjs";
19
10
  import { loadConfig as loadAgentConfig } from "../agent/orchestrator/config.mjs";
20
11
  import { captureOriginalUserCwd, readLastSessionCwd } from "../shared/user-cwd.mjs";
21
- import { managedLaunchId, enqueueLauncherCommand } from "../shared/launcher-control.mjs";
22
12
  import { initProviders } from "../agent/orchestrator/providers/registry.mjs";
23
13
  import { Scheduler } from "./lib/scheduler.mjs";
24
14
  import { startSnapshotWriter, stopSnapshotWriter, recordFetchedMessages } from "./lib/status-snapshot.mjs";
25
15
  import { hasPending as dispatchHasPending } from "../agent/orchestrator/dispatch-persist.mjs";
26
16
  import { setListener as setActivityBusListener } from "../agent/orchestrator/activity-bus.mjs";
27
17
  import { stripSoftWarns } from "../agent/orchestrator/tool-loop-guard.mjs";
28
- import { invalidatePrefetchCache } from "../agent/orchestrator/session/cache/prefetch-cache.mjs";
29
18
  import { WebhookServer } from "./lib/webhook.mjs";
30
19
  import { EventPipeline } from "./lib/event-pipeline.mjs";
31
20
  import { startCliWorker } from "./lib/cli-worker-host.mjs";
@@ -62,128 +51,32 @@ import {
62
51
  RUNTIME_ROOT
63
52
  } from "./lib/runtime-paths.mjs";
64
53
  import { getDiscordToken } from "./lib/config.mjs";
54
+ import { bootProfile, localTimestamp } from "./lib/boot-profile.mjs";
55
+ import {
56
+ isChannelsDegraded,
57
+ logCrash,
58
+ _isBenignCrash,
59
+ BENIGN_CRASH_FATAL_THRESHOLD,
60
+ BENIGN_CRASH_STREAK_WINDOW_MS,
61
+ } from "./lib/crash-log.mjs";
62
+ import { dropTrace, preview, _dtIdxFlush } from "./lib/index-drop-trace.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";
65
69
  const memoryClientModulePath = new URL("./lib/memory-client.mjs", import.meta.url).href;
66
70
  const {
67
71
  appendEntry: memoryAppendEntry,
68
72
  ingestTranscript: memoryIngestTranscript,
69
73
  } = await import(memoryClientModulePath);
70
- const DEFAULT_PLUGIN_VERSION = "0.0.1";
71
- const BOOT_PROFILE_ENABLED = /^(1|true|yes|on)$/i.test(String(process.env.MIXDOG_BOOT_PROFILE || ""));
72
- const BOOT_PROFILE_START = globalThis.__mixdogBootProfileStart || (globalThis.__mixdogBootProfileStart = performance.now());
73
- function bootProfile(event, fields = {}) {
74
- if (!BOOT_PROFILE_ENABLED) return;
75
- const elapsedMs = performance.now() - BOOT_PROFILE_START;
76
- const parts = [`[mixdog-boot] +${elapsedMs.toFixed(1)}ms`, `channels:${event}`];
77
- for (const [key, value] of Object.entries(fields || {})) {
78
- if (value === undefined || value === null || value === "") continue;
79
- parts.push(`${key}=${String(value).replace(/\s+/g, "_")}`);
80
- }
81
- try { process.stderr.write(`${parts.join(" ")}\n`); } catch {}
82
- }
83
- function localTimestamp() {
84
- return (/* @__PURE__ */ new Date()).toLocaleString("sv-SE", { hour12: false });
85
- }
86
- function readPluginVersion() {
87
- try {
88
- const pkg = JSON.parse(fs.readFileSync(new URL("../../../package.json", import.meta.url), "utf8"));
89
- return pkg.version || DEFAULT_PLUGIN_VERSION;
90
- } catch {
91
- return DEFAULT_PLUGIN_VERSION;
92
- }
93
- }
94
- const PLUGIN_VERSION = readPluginVersion();
95
- let crashLogging = false;
96
- let _channelsDegraded = false;
97
- let _stderrBroken = false;
98
- function isChannelsDegraded() { return _channelsDegraded; }
99
-
100
- // stderr can break when the parent stdio pipe closes. Node then emits an
101
- // async 'error' on process.stderr, which sync try/catch around write() does
102
- // not catch — without a listener, that error becomes uncaughtException and
103
- // re-enters logCrash, looping until the disk fills. Register a suppressor
104
- // once at load time and stop writing to stderr after the first EPIPE so the
105
- // loop cannot start.
106
- try {
107
- process.stderr.on('error', (e) => {
108
- if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
109
- _stderrBroken = true;
110
- _channelsDegraded = true;
111
- }
112
- });
113
- } catch {}
114
-
115
- // Crash log guards: dedup repeated identical errors (a single broken handler
116
- // can fire thousands of times per minute) and rotate at a 10 MB cap so the
117
- // file cannot grow unbounded. One .old generation is kept; older rolls drop.
118
- const CRASH_LOG_MAX_BYTES = 10 * 1024 * 1024;
119
- let _lastCrashSig = "";
120
- let _crashRepeatCount = 0;
121
-
122
- function _writeCrashLine(crashLog, line) {
123
- try {
124
- let size = 0;
125
- try { size = fs.statSync(crashLog).size; } catch {}
126
- if (size + line.length > CRASH_LOG_MAX_BYTES) {
127
- try { fs.renameSync(crashLog, crashLog + ".old"); } catch {}
128
- }
129
- fs.appendFileSync(crashLog, line);
130
- } catch {}
131
- }
132
-
133
- function logCrash(label, err) {
134
- if (crashLogging) return;
135
- crashLogging = true;
136
- const msg = `[${localTimestamp()}] mixdog: ${label}: ${err}
137
- ${err instanceof Error ? err.stack : ""}
138
- `;
139
- if (!_stderrBroken) {
140
- try { process.stderr.write(msg); } catch (e) {
141
- if (e && (e.code === 'EPIPE' || /EPIPE/.test(String(e.message || '')))) {
142
- _stderrBroken = true;
143
- }
144
- }
145
- }
146
- const sig = `${label}|${err && err.message ? err.message : String(err)}`;
147
- const crashLog = path.join(DATA_DIR, "crash.log");
148
- if (sig === _lastCrashSig) {
149
- // Same error repeating — count it but skip the disk write. The next
150
- // distinct error (or EPIPE branch below) flushes the suppressed total.
151
- _crashRepeatCount += 1;
152
- } else {
153
- if (_crashRepeatCount > 0) {
154
- _writeCrashLine(crashLog, `[${localTimestamp()}] mixdog: previous error repeated ${_crashRepeatCount} more time(s)\n`);
155
- _crashRepeatCount = 0;
156
- }
157
- _lastCrashSig = sig;
158
- _writeCrashLine(crashLog, msg);
159
- }
160
- if (err instanceof Error && err.message.includes("EPIPE")) {
161
- _channelsDegraded = true;
162
- _stderrBroken = true;
163
- }
164
- crashLogging = false;
165
- }
166
74
  // Zombie-Lead repro (2026-07-02): logCrash-then-survive left a worker alive
167
75
  // after an unhandled rejection whose async state was already corrupted
168
76
  // (observed: EPERM on active-instance.json rename retry), so it spun
169
77
  // forever doing nothing useful — a zombie Lead. Fatal-exit on repeat.
170
- // Benign whitelist: transient EPERM/EACCES/EBUSY on the active-instance
171
- // rename path is expected under Windows file-lock contention and is
172
- // already retried elsewhere (atomic-file.mjs RETRY_CODES) — a single
173
- // occurrence must NOT be fatal, only a run of 3+ in a row without an
174
- // intervening distinct/successful event.
175
- const BENIGN_CRASH_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
176
- const BENIGN_CRASH_FATAL_THRESHOLD = 3;
177
- // "In a row" needs a time dimension: benign errors minutes/hours apart are
178
- // independent contention events, not a corrupted-state run. Only count a
179
- // streak when hits land within this window of the previous one.
180
- const BENIGN_CRASH_STREAK_WINDOW_MS = 60_000;
181
78
  let _benignCrashStreak = 0;
182
79
  let _lastBenignCrashAt = 0;
183
- function _isBenignCrash(err) {
184
- const code = err?.code || (/\b(EPERM|EACCES|EBUSY)\b/.exec(String(err?.message || err)) || [])[0];
185
- return BENIGN_CRASH_CODES.has(code);
186
- }
187
80
  function _fatalCrash(label, err) {
188
81
  logCrash(label, err);
189
82
  const benign = _isBenignCrash(err);
@@ -236,17 +129,6 @@ let config = loadConfig();
236
129
  let backend = createBackend(config);
237
130
  const INSTANCE_ID = makeInstanceId();
238
131
  const TERMINAL_LEAD_PID = getTerminalLeadPid();
239
- // ── drop-trace instrumentation ──────────────────────────────────────────────
240
- const _dropTraceLog = path.join(DATA_DIR, "drop-trace.log");
241
- const DROP_TRACE_ENABLED =
242
- process.env.MIXDOG_DROP_TRACE === "1" ||
243
- process.env.MIXDOG_DROP_TRACE === "true" ||
244
- process.env.MIXDOG_DEBUG_CHANNELS === "1" ||
245
- process.env.MIXDOG_DEBUG_CHANNELS === "true";
246
- // One-shot rotation for drop-trace.log at worker boot.
247
- if (DROP_TRACE_ENABLED) {
248
- try { if (fs.statSync(_dropTraceLog).size > 10 * 1024 * 1024) fs.renameSync(_dropTraceLog, _dropTraceLog + '.1') } catch {}
249
- }
250
132
  // Rotate additional worker logs (10 MB threshold).
251
133
  for (const _rotLog of ["channels-worker.log", "schedule.log", "event.log", "memory-worker.log", "mcp-debug.log", "webhook.log", "pg.log", "session-start.log"]) {
252
134
  const _rotPath = path.join(DATA_DIR, _rotLog);
@@ -287,42 +169,6 @@ try {
287
169
  try {
288
170
  pruneStalePluginDataLogSiblings(DATA_DIR, DEFAULT_STALE_LOG_SIBLING_MAX);
289
171
  } catch {}
290
-
291
- // ── Buffered drop-trace writer (channels/index) ──────────────────────────────
292
- // Flushes every 1 s OR when buffer reaches 64 KB — whichever fires first.
293
- // Drains on process exit so no log lines are lost.
294
- let _dtIdxBuf = "";
295
- let _dtIdxBytes = 0;
296
- let _dtIdxFlushTimer = null;
297
- let _dtIdxStream = null;
298
- function _dtIdxGetStream() {
299
- if (!_dtIdxStream) _dtIdxStream = fs.createWriteStream(_dropTraceLog, { flags: "a" });
300
- return _dtIdxStream;
301
- }
302
- async function _dtIdxFlush() {
303
- if (_dtIdxFlushTimer) { clearTimeout(_dtIdxFlushTimer); _dtIdxFlushTimer = null; }
304
- if (!_dtIdxBuf) return;
305
- const stream = _dtIdxGetStream();
306
- const buf = _dtIdxBuf;
307
- _dtIdxBuf = "";
308
- _dtIdxBytes = 0;
309
- try {
310
- const ok = stream.write(buf);
311
- if (!ok) { const { once } = await import("node:events"); await once(stream, "drain").catch(() => {}); }
312
- } catch {}
313
- }
314
- function _dtIdxScheduleFlush() {
315
- if (_dtIdxFlushTimer) return;
316
- _dtIdxFlushTimer = setTimeout(() => { void _dtIdxFlush(); }, 1000);
317
- if (_dtIdxFlushTimer.unref) _dtIdxFlushTimer.unref();
318
- }
319
- function _dtIdxAppend(line) {
320
- _dtIdxBuf += line;
321
- _dtIdxBytes += Buffer.byteLength(line);
322
- if (_dtIdxBytes >= 65536) { void _dtIdxFlush(); return; }
323
- _dtIdxScheduleFlush();
324
- }
325
- process.on("exit", () => { void _dtIdxFlush(); });
326
172
  // SIGTERM: flush the drop-trace buffer, but do NOT exit here. In worker
327
173
  // mode the graceful `_channelsShutdownHandler` below owns shutdown
328
174
  // (stop() → cleanup → process.exit). In non-worker mode no SIGTERM
@@ -332,21 +178,6 @@ process.on("SIGTERM", () => {
332
178
  void _dtIdxFlush();
333
179
  if (!_isWorkerMode) process.exit(0);
334
180
  });
335
-
336
- function preview(text) {
337
- if (!text) return "";
338
- const s = String(text).replace(/\n/g, "\\n");
339
- return s.length > 120 ? s.slice(0, 120) + "…" : s;
340
- }
341
- function dropTrace(event, fields) {
342
- if (!DROP_TRACE_ENABLED) return;
343
- try {
344
- const ts = (/* @__PURE__ */ new Date()).toISOString();
345
- const loc = `[${ts}][pid=${process.pid}] ${event}`;
346
- const kv = fields ? " " + Object.entries(fields).map(([k, v]) => `${k}=${v}`).join(" ") : "";
347
- _dtIdxAppend(loc + kv + "\n");
348
- } catch {}
349
- }
350
181
  // ────────────────────────────────────────────────────────────────────────────
351
182
  ensureRuntimeDirs();
352
183
  cleanupStaleRuntimeFiles();
@@ -372,66 +203,11 @@ const INSTRUCTIONS = "";
372
203
  // never `connect()`ed to any transport, so `.notification()` silently
373
204
  // threw 'Not connected' inside the SDK and every call was dropped by an
374
205
  // outer `.catch(() => {})`. That regression is what this path replaces.
375
- function normalizeChannelNotifyParams(method, params) {
376
- if (method === 'notifications/claude/channel' && params && params.meta) {
377
- const m = {};
378
- for (const [k, v] of Object.entries(params.meta)) {
379
- if (v === undefined || v === null) continue;
380
- m[k] = k === 'silent_to_agent' ? (v === true || v === 'true') : String(v);
381
- }
382
- return { ...params, meta: m };
383
- }
384
- return params;
385
- }
386
-
387
- function sendNotifyToParent(method, params) {
388
- // CC channel schema requires meta: Record<string,string> (channelNotification.ts).
389
- // Coerce every meta value to string so a non-string (e.g. a Discord
390
- // interaction.type number) can't fail zod and silently drop the notify.
391
- // silent_to_agent stays boolean — an internal routing flag the daemon
392
- // router / agentNotify consume (=== true) before the CC zod boundary.
393
- const outParams = normalizeChannelNotifyParams(method, params);
394
- if (!process.send) {
395
- try { process.stderr.write(`mixdog channels: notify dropped (no IPC channel): ${method}\n`); } catch {}
396
- return;
397
- }
398
- try {
399
- process.send({ type: 'notify', method, params: outParams });
400
- } catch (err) {
401
- try { process.stderr.write(`mixdog channels: notify IPC send failed: ${err && err.message || err}\n`); } catch {}
402
- }
403
- }
404
-
405
- // ── Memory worker bridge (worker → parent → memory) ─────────────────
406
- // The channels worker does not own the memory worker handle. To trigger
407
- // memory tool actions (e.g. cycle1) we send `memory_call_request` to the
408
- // parent, which routes through callWorker('memory', ...) and ships the
409
- // result back as `memory_call_response`. The response listener is
410
- // integrated into the main IPC handler below (not a second listener).
411
- const _memoryCallPending = new Map();
412
- let _memoryCallSeq = 0;
413
-
414
- function callMemoryAction(action, args, timeoutMs) {
415
- return new Promise((resolve, reject) => {
416
- if (!process.send) return reject(new Error('not a worker process'));
417
- const callId = `mc_${INSTANCE_ID}_${++_memoryCallSeq}_${Math.random().toString(36).slice(2, 8)}`;
418
- const timer = setTimeout(() => {
419
- _memoryCallPending.delete(callId);
420
- reject(new Error(`memory_call ${action} timed out after ${timeoutMs}ms`));
421
- }, timeoutMs);
422
- _memoryCallPending.set(callId, {
423
- resolve: (v) => { clearTimeout(timer); resolve(v); },
424
- reject: (e) => { clearTimeout(timer); reject(e); },
425
- });
426
- try {
427
- process.send({ type: 'memory_call_request', callId, action, args: args || {} });
428
- } catch (e) {
429
- _memoryCallPending.delete(callId);
430
- clearTimeout(timer);
431
- reject(e);
432
- }
433
- });
434
- }
206
+ const {
207
+ sendNotifyToParent,
208
+ callMemoryAction,
209
+ handleMemoryCallResponse,
210
+ } = createParentBridge({ getInstanceId: () => INSTANCE_ID });
435
211
  function resolveChannelLabel(channelsConfig, label) {
436
212
  if (!label || !channelsConfig) return label;
437
213
  const entry = channelsConfig[label];
@@ -533,15 +309,20 @@ forwarder.setOnIdle(() => {
533
309
  // (webhook enabled or event rules present). Without an event pipeline the
534
310
  // forwarder's ownerGetter stayed null and _isOwner() failed open, letting a
535
311
  // non-owner process forward transcript output (duplicate Discord sends).
536
- // The closure reads bridgeRuntimeConnected at call time.
537
- forwarder.setOwnerGetter(() => bridgeRuntimeConnected);
312
+ // The closure reads bridgeRuntimeConnected at call time as a fast-path AND;
313
+ // bridgeRuntimeConnected alone can go stale (e.g. this process lost the seat
314
+ // but has not yet observed it), so currentOwnerState().owned is re-read at
315
+ // probe time as the source of truth for ownership.
316
+ forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
538
317
  function applyTranscriptBinding(channelId, transcriptPath, options = {}) {
539
318
  if (!transcriptPath) return;
540
319
  forwarder.setContext(channelId, transcriptPath, { replayFromStart: options.replayFromStart, catchUpFromPersisted: options.catchUpFromPersisted });
541
320
  const boundTranscriptPath = forwarder.transcriptPath || transcriptPath;
542
321
  forwarder.startWatch();
543
322
  void memoryIngestTranscript(boundTranscriptPath, { cwd: options.cwd });
544
- refreshActiveInstance(INSTANCE_ID, { channelId, transcriptPath: boundTranscriptPath });
323
+ // onlyIfOwned: binds happen on the owned path, but discovery/poll loops
324
+ // above can outlast an ownership handoff — never overwrite a newer owner.
325
+ refreshActiveInstance(INSTANCE_ID, { channelId, transcriptPath: boundTranscriptPath }, { onlyIfOwned: true });
545
326
  if (options.persistStatus !== false) {
546
327
  statusState.update((state) => {
547
328
  state.channelId = channelId;
@@ -768,13 +549,7 @@ let bridgeRuntimeStarting = false;
768
549
  let _ownedRuntimeStopRequested = false;
769
550
  let bridgeOwnershipRefreshInFlight = null;
770
551
  let bridgeOwnershipTimer = null;
771
- let lastOwnershipNote = "";
772
552
  const ACTIVE_OWNER_STALE_MS = 1e4;
773
- // Owner heartbeat: keep active-instance.json fresh so other sessions cannot
774
- // steal the seat after 10 s of channel-action silence. unref'd interval —
775
- // never blocks process exit. Single JSON atomic write, no measurable load.
776
- const OWNER_HEARTBEAT_INTERVAL_MS = 5e3;
777
- let ownerHeartbeatTimer = null;
778
553
  // Owner gating here is multi-process runtime coordination: only the active
779
554
  // bindingReady gates all send paths until the boot-time refreshBridgeOwnership
780
555
  // ({ restoreBinding: true }) call completes. Without this, scheduler/webhook
@@ -784,31 +559,21 @@ let bindingReadyStatus = "pending";
784
559
  let _bindingReadyResolve;
785
560
  const bindingReady = new Promise((r) => { _bindingReadyResolve = r; });
786
561
  dropTrace("bindingReady.create", { status: bindingReadyStatus });
787
- function logOwnership(note) {
788
- if (lastOwnershipNote === note) return;
789
- lastOwnershipNote = note;
790
- process.stderr.write(`[ownership] ${note}
791
- `);
792
- }
793
- function currentOwnerState() {
794
- const active = readActiveInstance();
795
- return {
796
- active,
797
- // Strict last-wins: this process owns the bridge ONLY when active-instance
798
- // names exactly this INSTANCE_ID. A newer remote session that claims the
799
- // seat overwrites instanceId, so the old owner immediately reads owned=false
800
- // and disconnects on its next refresh tick. No PID/terminal fallback —
801
- // that used to let a co-terminal worker wrongly self-claim.
802
- owned: active?.instanceId === INSTANCE_ID
803
- };
804
- }
805
- function getBridgeOwnershipSnapshot() {
806
- return currentOwnerState();
807
- }
808
- function claimBridgeOwnership(reason) {
809
- refreshActiveInstance(INSTANCE_ID);
810
- logOwnership(`claimed owner (${reason})`);
811
- }
562
+ // ── Bridge ownership snapshot + owner heartbeat ─────────────────────────────
563
+ // Extracted lib/owner-heartbeat.mjs. Owns its own heartbeat timer + last-note
564
+ // dedup; bound to live identity + active-instance primitives.
565
+ const {
566
+ logOwnership,
567
+ currentOwnerState,
568
+ getBridgeOwnershipSnapshot,
569
+ claimBridgeOwnership,
570
+ startOwnerHeartbeat,
571
+ stopOwnerHeartbeat,
572
+ } = createOwnerHeartbeat({
573
+ getInstanceId: () => INSTANCE_ID,
574
+ readActiveInstance,
575
+ refreshActiveInstance,
576
+ });
812
577
  async function bindPersistedTranscriptIfAny() {
813
578
  // Main-channel fallback requires channelBridgeActive (set in start() before
814
579
  // refreshBridgeOwnership → startOwnedRuntime, including pre-connect binds).
@@ -958,7 +723,20 @@ async function startOwnedRuntime(options = {}) {
958
723
  // Advertise active-instance.json BEFORE backend connect so a newer remote
959
724
  // session's last-wins claim is visible immediately. backendReady=false
960
725
  // marks the partial state until backend.connect() succeeds.
961
- refreshActiveInstance(INSTANCE_ID, { backendReady: false });
726
+ // onlyIfOwned: startOwnedRuntime is only entered on the owned path, but a
727
+ // newer session can claim the seat between that check and this write; CAS
728
+ // aborts instead of re-stealing. The write result is checked below so a
729
+ // CAS abort stops the start path immediately (heartbeat/backend/scheduler
730
+ // never start) instead of relying on the 3s ownership timer to notice.
731
+ const casResult = refreshActiveInstance(INSTANCE_ID, { backendReady: false }, { onlyIfOwned: true });
732
+ // A successful CAS write always sets instanceId to ours; any other result
733
+ // (aborted write returning the stale/foreign/missing prior state) means
734
+ // the seat was not ours to claim — abort the start path here rather than
735
+ // relying on the 3s ownership timer to notice later.
736
+ if (casResult?.instanceId !== INSTANCE_ID) {
737
+ bridgeRuntimeStarting = false;
738
+ return;
739
+ }
962
740
  startOwnerHeartbeat();
963
741
  // Re-check after each post-connect await so a stopOwnedRuntime() landing
964
742
  // mid-start cannot be overridden by the resuming start (scheduler/snapshot/
@@ -996,7 +774,11 @@ async function startOwnedRuntime(options = {}) {
996
774
  return;
997
775
  }
998
776
  bridgeRuntimeConnected = true;
999
- refreshActiveInstance(INSTANCE_ID, { backendReady: true });
777
+ // onlyIfOwned: backend.connect() above can take seconds — a newer owner
778
+ // claiming the seat during that await must not be overwritten here. On
779
+ // CAS abort the next refreshBridgeOwnership tick observes owned=false
780
+ // and stops this runtime; backendReady stays unset for the lost seat.
781
+ refreshActiveInstance(INSTANCE_ID, { backendReady: true }, { onlyIfOwned: true });
1000
782
  // initProviders must complete before scheduler.start() — otherwise the
1001
783
  // scheduler's first fire can land before the registry is populated and
1002
784
  // return `Provider "<name>" not found or not enabled`. The previous
@@ -1087,26 +869,14 @@ async function stopOwnedRuntime(reason) {
1087
869
  function refreshBridgeOwnershipSafe(options = {}) {
1088
870
  refreshBridgeOwnership(options).catch(err => process.stderr.write(`[channels] refreshBridgeOwnership rejected: ${err?.message || err}\n`));
1089
871
  }
1090
- function startOwnerHeartbeat() {
1091
- if (ownerHeartbeatTimer) return;
1092
- ownerHeartbeatTimer = setInterval(() => {
1093
- try {
1094
- // Last-wins guard: only refresh the seat if we STILL own it. If a newer
1095
- // remote session claimed active-instance.json since our last tick, do
1096
- // NOT overwrite it back that would re-steal ownership and cause
1097
- // ping-pong / double backend connections. The bridgeOwnershipTimer's
1098
- // refreshBridgeOwnership() will observe owned=false and disconnect us.
1099
- if (currentOwnerState().owned) refreshActiveInstance(INSTANCE_ID);
1100
- } catch (e) {
1101
- process.stderr.write(`[ownership] heartbeat refresh failed: ${e instanceof Error ? e.message : String(e)}\n`);
1102
- }
1103
- }, OWNER_HEARTBEAT_INTERVAL_MS);
1104
- ownerHeartbeatTimer.unref?.();
1105
- }
1106
- function stopOwnerHeartbeat() {
1107
- if (!ownerHeartbeatTimer) return;
1108
- clearInterval(ownerHeartbeatTimer);
1109
- ownerHeartbeatTimer = null;
872
+ // Tell the parent session that this worker LOST the bridge seat to a newer
873
+ // remote session (last-wins). The parent flips its remote mode OFF entirely —
874
+ // exactly one session holds remote; losers fully release, no handover.
875
+ function notifyRemoteSuperseded() {
876
+ if (!process.send) return;
877
+ try {
878
+ process.send({ type: 'notify', method: 'notifications/mixdog/remote', params: { state: 'superseded' } });
879
+ } catch {}
1110
880
  }
1111
881
  async function refreshBridgeOwnership(options = {}) {
1112
882
  // Coalesce concurrent callers onto the in-flight refresh so backend tool
@@ -1126,14 +896,18 @@ async function refreshBridgeOwnership(options = {}) {
1126
896
  }
1127
897
  const { active, owned } = currentOwnerState();
1128
898
  if (owned) {
1129
- refreshActiveInstance(INSTANCE_ID);
899
+ // onlyIfOwned: ownership was checked outside the file lock; CAS mode
900
+ // prevents this periodic tick from overwriting a newer owner that
901
+ // claimed the seat in between (re-steal / ping-pong guard).
902
+ refreshActiveInstance(INSTANCE_ID, undefined, { onlyIfOwned: true });
1130
903
  await startOwnedRuntime(options);
1131
904
  return;
1132
905
  }
1133
906
  // Not the owner. Two sub-cases:
1134
907
  // (a) A live remote session holds the seat (active-instance names a
1135
908
  // different, non-stale instance) → last-wins: we lost, go quiet
1136
- // (disconnect if we were connected).
909
+ // (disconnect if we were connected) and tell the parent session to
910
+ // drop remote mode entirely (single-holder, no handover).
1137
911
  // (b) There is NO live owner (active is null/stale — e.g. our own entry
1138
912
  // was cleared after a backend-connect failure or a bridge
1139
913
  // deactivate/reactivate) → this remote session claims the empty seat
@@ -1142,6 +916,7 @@ async function refreshBridgeOwnership(options = {}) {
1142
916
  if (active && active.instanceId && active.instanceId !== INSTANCE_ID) {
1143
917
  if (bridgeRuntimeConnected) {
1144
918
  await stopOwnedRuntime("ownership lost (newer remote session)");
919
+ notifyRemoteSuperseded();
1145
920
  }
1146
921
  return;
1147
922
  }
@@ -1371,8 +1146,10 @@ function wireEventQueueHandlers(eventQueue) {
1371
1146
  // Defensive ownership probe: the queue tick should only run in the active
1372
1147
  // owner process. Non-owner instances see bridgeRuntimeConnected=false and
1373
1148
  // will skip the tick even if an errant start() slipped through.
1374
- eventQueue.setOwnerGetter(() => bridgeRuntimeConnected);
1375
- forwarder.setOwnerGetter(() => bridgeRuntimeConnected);
1149
+ // bridgeRuntimeConnected is a fast-path AND; currentOwnerState().owned is
1150
+ // re-read at probe time so a stale-connected flag cannot mask a lost seat.
1151
+ eventQueue.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
1152
+ forwarder.setOwnerGetter(() => bridgeRuntimeConnected && currentOwnerState().owned);
1376
1153
  }
1377
1154
  function editDiscordMessage(channelId, messageId, label) {
1378
1155
  // Behavior-preserving: route through the backend abstraction (which uses
@@ -1601,544 +1378,75 @@ backend.onInteraction = (interaction) => {
1601
1378
  }
1602
1379
  });
1603
1380
  };
1604
- function isVoiceAttachment(contentType) {
1605
- if (typeof contentType !== 'string') return false;
1606
- const ct = contentType.toLowerCase();
1607
- return ct.startsWith("audio/") || ct.startsWith("application/ogg");
1608
- }
1609
- function runCmd(cmd, args, capture = false) {
1610
- return new Promise((resolve, reject) => {
1611
- const proc = spawn(cmd, args, {
1612
- stdio: capture ? ["ignore", "pipe", "ignore"] : "ignore",
1613
- windowsHide: true
1614
- });
1615
- let out = "";
1616
- if (capture && proc.stdout) proc.stdout.on("data", (d) => {
1617
- out += d;
1618
- });
1619
- proc.on("close", (code) => code === 0 ? resolve(out) : reject(new Error(`${cmd} exit ${code}`)));
1620
- proc.on("error", reject);
1621
- });
1622
- }
1623
- let resolvedWhisperLanguage = null;
1624
- function normalizeWhisperLanguage(value) {
1625
- const raw = String(value ?? "").trim().toLowerCase();
1626
- if (!raw || raw === "auto") return null;
1627
- if (raw.startsWith("ko")) return "ko";
1628
- if (raw.startsWith("ja")) return "ja";
1629
- if (raw.startsWith("en")) return "en";
1630
- if (raw.startsWith("zh")) return "zh";
1631
- if (raw.startsWith("de")) return "de";
1632
- if (raw.startsWith("fr")) return "fr";
1633
- if (raw.startsWith("es")) return "es";
1634
- if (raw.startsWith("it")) return "it";
1635
- if (raw.startsWith("pt")) return "pt";
1636
- if (raw.startsWith("ru")) return "ru";
1637
- return raw;
1638
- }
1639
- function detectDeviceLanguage() {
1640
- if (resolvedWhisperLanguage) return resolvedWhisperLanguage;
1641
- const candidates = [
1642
- process.env.MIXDOG_CHANNELS_WHISPER_LANGUAGE,
1643
- process.env.LC_ALL,
1644
- process.env.LC_MESSAGES,
1645
- process.env.LANG,
1646
- Intl.DateTimeFormat().resolvedOptions().locale
1647
- ];
1648
- for (const candidate of candidates) {
1649
- const normalized = normalizeWhisperLanguage(candidate);
1650
- if (normalized) {
1651
- resolvedWhisperLanguage = normalized;
1652
- return normalized;
1653
- }
1654
- }
1655
- resolvedWhisperLanguage = "auto";
1656
- return resolvedWhisperLanguage;
1657
- }
1658
- // ── voice.transcription concurrency queue (max=1 by default, config-driven) ──
1659
- const _voiceTranscriptionQueue = (() => {
1660
- let running = 0;
1661
- const pending = [];
1662
- function drain() {
1663
- const limit = config.voice?.transcription?.maxConcurrency ?? 1;
1664
- while (running < limit && pending.length > 0) {
1665
- const { fn, resolve, reject } = pending.shift();
1666
- running++;
1667
- fn().then(resolve, reject).finally(() => { running--; drain(); });
1668
- }
1669
- }
1670
- return function enqueue(fn) {
1671
- return new Promise((resolve, reject) => { pending.push({ fn, resolve, reject }); drain(); });
1672
- };
1673
- })();
1674
-
1675
- // ── wav + transcript cache keyed by attachment id ──
1676
- const _voiceWavCache = new Map(); // attachmentId → wavPath
1677
- const _voiceTranscriptCache = new Map(); // attachmentId → transcript string
1678
- const _voiceInflight = new Map(); // attachmentId → Promise<string|null>
1679
- const _voiceFfmpegInflight = new Map(); // attachmentId|wavPath → Promise<void> single-flight ffmpeg
1680
-
1681
- async function _probeAudioDurationSec(filePath) {
1682
- try {
1683
- const ffprobePath = (() => { try { return _require('ffprobe-static').path; } catch { return 'ffprobe'; } })();
1684
- return await new Promise((resolve, reject) => {
1685
- const args = ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', filePath];
1686
- let out = '';
1687
- const proc = spawn(ffprobePath, args, { windowsHide: true });
1688
- proc.stdout.on('data', (d) => { out += d; });
1689
- proc.on('close', (code) => { code === 0 ? resolve(parseFloat(out.trim()) || null) : reject(new Error(`ffprobe exit ${code}`)); });
1690
- proc.on('error', reject);
1691
- });
1692
- } catch {
1693
- return null;
1694
- }
1695
- }
1696
-
1697
- async function transcribeVoice(audioPath, { attachmentId } = {}) {
1698
- // ── size gate (config: voice.transcription.maxFileSizeMB) ──
1699
- const maxSizeBytes = (config.voice?.transcription?.maxFileSizeMB ?? 0) * 1024 * 1024;
1700
- if (maxSizeBytes > 0) {
1701
- try {
1702
- const stat = await fs.promises.stat(audioPath);
1703
- if (stat.size > maxSizeBytes) {
1704
- process.stderr.write(`mixdog: voice.transcription skipped — file too large (${(stat.size / 1024 / 1024).toFixed(1)} MB > ${config.voice.transcription.maxFileSizeMB} MB): ${audioPath}\n`);
1705
- return null;
1706
- }
1707
- } catch { /* stat failure: proceed */ }
1708
- }
1709
- // ── duration gate (config: voice.transcription.maxDurationSec) ──
1710
- const maxDurationSec = config.voice?.transcription?.maxDurationSec ?? 0;
1711
- if (maxDurationSec > 0) {
1712
- const dur = await _probeAudioDurationSec(audioPath);
1713
- if (dur !== null && dur > maxDurationSec) {
1714
- process.stderr.write(`mixdog: voice.transcription skipped — audio too long (${Math.floor(dur)}s > ${maxDurationSec}s): ${audioPath}\n`);
1715
- return null;
1716
- }
1717
- }
1718
- // ── transcript cache hit ──
1719
- if (attachmentId && _voiceTranscriptCache.has(attachmentId)) {
1720
- process.stderr.write(`mixdog: voice.transcription cache hit (${attachmentId})\n`);
1721
- return _voiceTranscriptCache.get(attachmentId);
1722
- }
1723
- if (attachmentId && _voiceInflight.has(attachmentId)) {
1724
- return _voiceInflight.get(attachmentId);
1725
- }
1726
- const p = _voiceTranscriptionQueue(() => _doTranscribeVoice(audioPath, attachmentId));
1727
- if (attachmentId) {
1728
- _voiceInflight.set(attachmentId, p);
1729
- p.catch((err) => {
1730
- try { process.stderr.write(`mixdog: voice.transcription inflight rejection: ${err?.stack || err}\n`); } catch {}
1731
- }).finally(() => _voiceInflight.delete(attachmentId));
1732
- }
1733
- return p;
1734
- }
1735
-
1736
- async function _doTranscribeVoice(audioPath, attachmentId) {
1737
- try {
1738
- const runtime = resolveVoiceRuntime(DATA_DIR);
1739
- if (!runtime?.installed) {
1740
- const missing = [runtime?.binary ? null : 'binary', runtime?.model ? null : 'model', runtime?.ffmpeg ? null : 'ffmpeg'].filter(Boolean).join(' + ');
1741
- throw new Error(`voice runtime not installed (missing: ${missing}) — open the setup wizard and click "Install voice"`);
1742
- }
1743
- const whisperCmd = runtime.whisperCmd;
1744
- const modelPath = runtime.modelPath;
1745
- const ffmpegPath = runtime.ffmpegPath;
1746
- const lang = normalizeWhisperLanguage(config.voice?.language) ?? detectDeviceLanguage();
1747
- const _cpuCount = (() => { try { return os.cpus().length; } catch { return 2; } })();
1748
- const threadCount = config.voice?.transcription?.threadCount ?? Math.max(1, Math.ceil(_cpuCount / 4));
1749
- // ── wav cache keyed by attachment id ──
1750
- let wavPath;
1751
- if (attachmentId && _voiceWavCache.has(attachmentId)) {
1752
- wavPath = _voiceWavCache.get(attachmentId);
1753
- if (!fs.existsSync(wavPath)) {
1754
- _voiceWavCache.delete(attachmentId);
1755
- wavPath = undefined;
1756
- } else {
1757
- process.stderr.write(`mixdog: voice.transcription wav cache hit (${attachmentId})\n`);
1758
- }
1759
- }
1760
- if (!wavPath) {
1761
- wavPath = audioPath.replace(/\.[^.]+$/, ".wav");
1762
- const sampleRate = config.voice?.transcription?.sampleRate ?? 16000;
1763
- const channels = config.voice?.transcription?.channels ?? 1;
1764
- // Single-flight: parallel callers for the same key share one ffmpeg spawn.
1765
- const _ffmpegKey = attachmentId || wavPath;
1766
- if (_voiceFfmpegInflight.has(_ffmpegKey)) {
1767
- await _voiceFfmpegInflight.get(_ffmpegKey);
1768
- } else {
1769
- const _ffmpegPromise = runCmd(ffmpegPath, ["-i", audioPath, "-ar", String(sampleRate), "-ac", String(channels), "-threads", String(threadCount), "-y", wavPath]);
1770
- _voiceFfmpegInflight.set(_ffmpegKey, _ffmpegPromise);
1771
- try {
1772
- await _ffmpegPromise;
1773
- if (attachmentId) _voiceWavCache.set(attachmentId, wavPath);
1774
- } finally {
1775
- _voiceFfmpegInflight.delete(_ffmpegKey);
1776
- }
1777
- }
1778
- }
1779
- process.stderr.write(`mixdog: voice.transcription start runtime=${runtime.kind} cmd=${path.basename(whisperCmd)}\n`);
1780
- await ensureReady({ serverCmd: runtime.serverCmd, modelPath, threadCount, host: '127.0.0.1' });
1781
- const text = await transcribe(wavPath, { language: lang });
1782
- const result = text.trim() || null;
1783
- if (attachmentId && result) _voiceTranscriptCache.set(attachmentId, result);
1784
- return result;
1785
- } catch (err) {
1786
- if (err?.message?.startsWith('voice runtime not installed')) throw err; // propagate setup errors; caller posts user-visible failure
1787
- process.stderr.write(`mixdog: voice.transcription failed: ${err}\n`);
1788
- return null;
1789
- }
1790
- }
1381
+ const { isVoiceAttachment, transcribeVoice } = createVoiceTranscription({
1382
+ getConfig: () => config,
1383
+ dataDir: DATA_DIR,
1384
+ });
1791
1385
  import { TOOL_DEFS } from './tool-defs.mjs';
1792
- function createHttpMcpServer() {
1793
- const s = new Server(
1794
- { name: "mixdog", version: PLUGIN_VERSION },
1795
- { capabilities: { tools: {} }, instructions: INSTRUCTIONS }
1796
- );
1797
- s.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS }));
1798
- s.setRequestHandler(CallToolRequestSchema, async (req) => {
1799
- const toolName = req.params.name;
1800
- const args = req.params.arguments ?? {};
1801
- return handleToolCallWithBridgeRetry(toolName, args);
1802
- });
1803
- return s;
1804
- }
1805
1386
  // Tool dispatch in worker mode goes through the IPC `call` handler at the
1806
- // bottom of this file (parent's `callWorker` → `handleToolCall`). The HTTP
1807
- // MCP path uses its own short-lived `Server` instance built by
1808
- // `createHttpMcpServer()` above. There is no orphan worker-level Server.
1809
- const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch", "react", "edit_message", "download_attachment", "trigger_schedule"]);
1387
+ // bottom of this file (parent's `callWorker` → `handleToolCall`). There is no
1388
+ // orphan worker-level MCP Server: the parent (server.mjs) owns the single
1389
+ // connected transport and routes CallTool through the IPC `call` path.
1390
+ const BACKEND_TOOLS = /* @__PURE__ */ new Set(["reply", "fetch"]);
1391
+ // ── Inbound routing / dedup / ownership helpers ─────────────────────────────
1392
+ // Extracted → lib/inbound-routing.mjs. Bound to live config/identity getters.
1393
+ // Created here (ahead of backend-dispatch) so labelForChannelId is initialised
1394
+ // before it is passed into createBackendDispatch below.
1395
+ const {
1396
+ writeChannelOwner,
1397
+ shouldDropDuplicateInbound,
1398
+ resolveInboundRoute,
1399
+ labelForChannelId,
1400
+ } = createInboundRouting({
1401
+ getConfig: () => config,
1402
+ getInstanceId: () => INSTANCE_ID,
1403
+ getChannelOwnerPath,
1404
+ });
1810
1405
  // ── Backend-tool dispatch helpers ───────────────────────────────────────────
1811
1406
  // Each helper dispatches through the local backend (this process is always the
1812
- // owner in opt-in remote mode). The MCP-result formatting (text shape, cache
1813
- // invalidation, isError flag) is kept here so results stay consistent.
1814
- // schedule_status / schedule_control share their result-formatting between
1815
- // the local (owner) MCP case handlers and the owner-side HTTP routes that
1816
- // serve proxied standby sessions. Keeping the body here makes both paths
1817
- // byte-identical and reads the LIVE scheduler.
1818
- function scheduleStatusResult() {
1819
- const statuses = scheduler.getStatus();
1820
- if (statuses.length === 0) {
1821
- return { content: [{ type: "text", text: "no schedules configured" }] };
1822
- }
1823
- const lines = statuses.map((s) => {
1824
- const state = s.running ? " [RUNNING]" : "";
1825
- const last = s.lastFired ? ` (last: ${s.lastFired})` : "";
1826
- return ` ${s.name} ${s.time} ${s.days} (${s.type})${state}${last}`;
1827
- });
1828
- return { content: [{ type: "text", text: lines.join("\n") }] };
1829
- }
1830
- function scheduleControlResult(args) {
1831
- const scName = args.name;
1832
- const action = args.action;
1833
- // Validate that the named schedule actually exists.
1834
- const _scAll = [...(scheduler.nonInteractive || []), ...(scheduler.interactive || [])];
1835
- const _scKnown = _scAll.some(s => s.name === scName);
1836
- if (!_scKnown) {
1837
- return { content: [{ type: "text", text: `schedule_control: unknown schedule "${scName}" — use schedule_status to list valid names` }], isError: true };
1838
- }
1839
- if (action === "defer") {
1840
- const minutes = args.minutes ?? 30;
1841
- if (typeof minutes !== "number" || !Number.isFinite(minutes) || minutes <= 0) {
1842
- return { content: [{ type: "text", text: `schedule_control: minutes must be a positive number, got ${JSON.stringify(minutes)}` }], isError: true };
1843
- }
1844
- scheduler.defer(scName, minutes);
1845
- return { content: [{ type: "text", text: `deferred "${scName}" for ${minutes} minutes` }] };
1846
- } else if (action === "skip_today") {
1847
- scheduler.skipToday(scName);
1848
- return { content: [{ type: "text", text: `skipped "${scName}" for today` }] };
1849
- }
1850
- return { content: [{ type: "text", text: `unknown action: ${action}` }], isError: true };
1851
- }
1852
- async function dispatchReply(args) {
1853
- const sendOpts = {
1854
- replyTo: args.reply_to,
1855
- files: args.files ?? [],
1856
- embeds: args.embeds ?? [],
1857
- components: args.components ?? []
1858
- };
1859
- let ids;
1860
- // Pre-send activity bump keeps idle gating consistent during the await.
1861
- scheduler.noteActivity();
1862
- const sendResult = await backend.sendMessage(args.chat_id, args.text, sendOpts);
1863
- scheduler.noteActivity();
1864
- ids = sendResult.sentIds;
1865
- const text = ids.length === 1 ? `sent (id: ${ids[0]})` : `sent ${ids.length} parts (ids: ${ids.join(", ")})`;
1866
- return { content: [{ type: "text", text }] };
1867
- }
1868
- async function dispatchFetch(args) {
1869
- const channelId = resolveChannelLabel(config.channelsConfig, args.channel);
1870
- const limit = args.limit ?? 20;
1871
- let msgs;
1872
- msgs = await backend.fetchMessages(channelId, limit);
1873
- recordFetchedMessages(channelId, args.channel !== channelId ? args.channel : labelForChannelId(channelId), msgs);
1874
- const text = msgs.length === 0 ? "(no messages)" : msgs.map((m) => {
1875
- const atts = m.attachmentCount > 0 ? ` +${m.attachmentCount}att` : "";
1876
- return `[${m.ts}] ${m.user}: ${m.text} (id: ${m.id}${atts})`;
1877
- }).join("\n");
1878
- return { content: [{ type: "text", text }] };
1879
- }
1880
- async function dispatchReact(args) {
1881
- await backend.react(args.chat_id, args.message_id, args.emoji);
1882
- return { content: [{ type: "text", text: "reacted" }] };
1883
- }
1884
- async function dispatchEditMessage(args) {
1885
- const opts = { embeds: args.embeds ?? [], components: args.components ?? [] };
1886
- let id;
1887
- id = await backend.editMessage(args.chat_id, args.message_id, args.text, opts);
1888
- return { content: [{ type: "text", text: `edited (id: ${id})` }] };
1889
- }
1890
- async function dispatchDownloadAttachment(args) {
1891
- let files;
1892
- files = await backend.downloadAttachment(args.chat_id, args.message_id);
1893
- if (files.length === 0) {
1894
- return { content: [{ type: "text", text: "message has no attachments" }] };
1895
- }
1896
- const lines = files.map(
1897
- (f) => ` ${f.path} (${f.name}, ${f.contentType}, ${(f.size / 1024).toFixed(0)}KB)`
1898
- );
1899
- // Each downloaded file lands on the local FS; if any of them
1900
- // had a stale prefetch entry from a prior session, drop it so
1901
- // the next prefetch sees the fresh contents.
1902
- for (const f of files) {
1903
- if (f && typeof f.path === "string" && f.path) {
1904
- invalidatePrefetchCache(f.path);
1905
- }
1906
- }
1907
- return { content: [{ type: "text", text: `downloaded ${files.length} attachment(s):
1908
- ${lines.join("\n")}` }] };
1909
- }
1910
- async function handleToolCall(name, args, _signal) {
1911
- if (_channelsDegraded) {
1912
- return { content: [{ type: 'text', text: `[channels degraded] ${name} unavailable — restart MCP to recover` }], isError: true }
1913
- }
1914
- let result;
1915
- try {
1916
- switch (name) {
1917
- case "reply":
1918
- result = await dispatchReply(args);
1919
- break;
1920
- case "fetch":
1921
- result = await dispatchFetch(args);
1922
- break;
1923
- case "react":
1924
- result = await dispatchReact(args);
1925
- break;
1926
- case "edit_message":
1927
- result = await dispatchEditMessage(args);
1928
- break;
1929
- case "download_attachment":
1930
- result = await dispatchDownloadAttachment(args);
1931
- break;
1932
- case "schedule_status": {
1933
- result = scheduleStatusResult();
1934
- break;
1935
- }
1936
- case "trigger_schedule": {
1937
- const triggerResult = await scheduler.triggerManual(args.name);
1938
- result = { content: [{ type: "text", text: triggerResult }] };
1939
- break;
1940
- }
1941
- case "schedule_control": {
1942
- result = scheduleControlResult(args);
1943
- break;
1944
- }
1945
- case "activate_channel_bridge": {
1946
- const active = args.active === true;
1947
- const wasActive = channelBridgeActive;
1948
- channelBridgeActive = active;
1949
- writeBridgeState(active);
1950
- if (active && !wasActive) {
1951
- refreshBridgeOwnershipSafe({ restoreBinding: true });
1952
- }
1953
- if (!active && wasActive) {
1954
- stopServerTyping();
1955
- // Tear down the owner-side runtime so Discord/scheduler/webhook/
1956
- // event-pipeline don't keep running on a deactivated bridge.
1957
- try { await stopOwnedRuntime("bridge deactivated"); } catch (e) {
1958
- process.stderr.write(`mixdog: stopOwnedRuntime on deactivate failed: ${e?.message || e}\n`);
1959
- }
1960
- }
1961
- result = { content: [{ type: "text", text: `channel bridge ${active ? "activated" : "deactivated"}` }] };
1962
- break;
1963
- }
1964
- case "reload_config": {
1965
- await reloadRuntimeConfig();
1966
- // Extend reload to the agent module so providers/presets/maintenance
1967
- // hot-reload on the same call (dynamic import: agent/index.mjs does not
1968
- // import channels, so this stays acyclic and tolerant of load order).
1969
- let agentReloadMsg = "";
1970
- if (process.env.MIXDOG_STANDALONE !== '1') {
1971
- try {
1972
- const { reloadAgentConfig } = await import("../agent/index.mjs");
1973
- await reloadAgentConfig("reload_config tool");
1974
- agentReloadMsg = ", agent providers/presets/maintenance";
1975
- } catch (err) {
1976
- process.stderr.write(`[reload_config] agent reload failed: ${err?.message || String(err)}\n`);
1977
- }
1978
- }
1979
- result = { content: [{ type: "text", text: `config reloaded — schedules, webhooks, events${agentReloadMsg} re-registered` }] };
1980
- break;
1981
- }
1982
- case "inject_command": {
1983
- const cmd = String(args?.command || "").trim();
1984
- const ALLOW = new Set(["clear"]);
1985
- if (!ALLOW.has(cmd)) {
1986
- result = { content: [{ type: "text", text: `inject_command: '${cmd}' not in allow-list (${[...ALLOW].join(", ")})` }], isError: true };
1987
- break;
1988
- }
1989
- // Unified managed-launcher control path (cross-platform). The
1990
- // command is delivered to the `mixdog`-launched child's stdin by the
1991
- // launcher that owns it — no OS/terminal keystroke injection, no new
1992
- // window. Only sessions with an engaged native managed-launch bridge
1993
- // are addressable; anything else gets a clear not-managed error
1994
- // rather than a silent no-op.
1995
- try {
1996
- const launchId = managedLaunchId();
1997
- if (!launchId) {
1998
- 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 };
1999
- break;
2000
- }
2001
- enqueueLauncherCommand(launchId, `/${cmd}`);
2002
- result = { content: [{ type: "text", text: `queued /${cmd} for managed launcher (launchId=${launchId})` }] };
2003
- } catch (err) {
2004
- result = { content: [{ type: "text", text: `inject_command error: ${err?.message || err}` }], isError: true };
2005
- }
2006
- break;
2007
- }
2008
- // memory — handled by memory-service.mjs MCP
2009
- default:
2010
- result = {
2011
- content: [{ type: "text", text: `unknown tool: ${name}` }],
2012
- isError: true
2013
- };
2014
- }
2015
- } catch (err) {
2016
- const msg = err instanceof Error ? err.message : String(err);
2017
- result = {
2018
- content: [{ type: "text", text: `${name} failed: ${msg}` }],
2019
- isError: true
2020
- };
2021
- }
2022
- return result;
2023
- }
2024
- // Bridge auto-connect retry + forwarder-aware tool dispatch wrapper. Used by
2025
- // both the HTTP MCP path (createHttpMcpServer's CallTool handler can call this)
2026
- // and the worker IPC handler at the bottom of this file. The pre-v0.6.7 code
2027
- // registered this on the orphan worker-level `Server`, which never had a
2028
- // transport, so the wrapper never actually fired. Centralised here for reuse.
2029
- // Last timestamp a forwardNewText() call was dispatched (debounce for item 4).
2030
- let _lastForwardMs = 0;
2031
-
2032
- async function handleToolCallWithBridgeRetry(toolName, args, signal) {
2033
- // Debounce: only forward when ≥250 ms have elapsed since the last forward,
2034
- // to avoid one HTTP roundtrip per tool call on rapid-fire sequences.
2035
- const now = Date.now();
2036
- if (now - _lastForwardMs >= 250) {
2037
- _lastForwardMs = now;
2038
- await forwarder.forwardNewText();
2039
- }
2040
- if (BACKEND_TOOLS.has(toolName) && !bridgeRuntimeConnected) {
2041
- // Remote-owner startup: ensure this owner's backend is connected.
2042
- for (let i = 0; i < 2 && !bridgeRuntimeConnected; i++) {
2043
- try {
2044
- await refreshBridgeOwnership();
2045
- } catch {
2046
- }
2047
- if (!bridgeRuntimeConnected) await new Promise((r) => setTimeout(r, 300));
2048
- }
2049
- if (!bridgeRuntimeConnected) {
2050
- return {
2051
- content: [{ type: "text", text: `Discord auto-connect failed after retries. Check token and network.` }],
2052
- isError: true
2053
- };
2054
- }
2055
- }
2056
- const result = await handleToolCall(toolName, args, signal);
2057
- const toolLine = OutputForwarder.buildToolLine(toolName, args);
2058
- if (toolLine) {
2059
- // Distinct from the dispatch-log ok line (server-main.mjs): this forwards
2060
- // a human-readable tool summary to Discord for the user, not operator stdout.
2061
- void forwarder.forwardToolLog(toolLine, toolName, args);
2062
- }
2063
- return result;
2064
- }
2065
- const INBOUND_DEDUP_TTL = 5 * 6e4;
2066
- const inboundSeen = /* @__PURE__ */ new Map();
2067
- const INBOUND_DEDUP_DIR = path.join(os.tmpdir(), "mixdog-inbound");
2068
- ensureDir(INBOUND_DEDUP_DIR);
2069
- function writeChannelOwner(channelId) {
2070
- const ownerPath = getChannelOwnerPath(channelId);
2071
- try {
2072
- fs.writeFileSync(ownerPath, JSON.stringify({ instanceId: INSTANCE_ID, pid: process.pid, updatedAt: Date.now() }));
2073
- return true;
2074
- } catch {
2075
- return false;
2076
- }
2077
- }
2078
- function shouldDropDuplicateInbound(msg) {
2079
- const key = `${msg.chatId}:${msg.messageId}`;
2080
- const now = Date.now();
2081
- if (inboundSeen.has(key) && now - inboundSeen.get(key) < INBOUND_DEDUP_TTL) return true;
2082
- inboundSeen.set(key, now);
2083
- const marker = path.join(INBOUND_DEDUP_DIR, key.replace(/:/g, "_"));
2084
- try {
2085
- fs.writeFileSync(marker, String(now), { flag: "wx" });
2086
- } catch (e) {
2087
- if (e.code === "EEXIST") {
2088
- try {
2089
- const stat = fs.statSync(marker);
2090
- if (now - stat.mtimeMs < INBOUND_DEDUP_TTL) return true;
2091
- } catch {}
2092
- }
2093
- }
2094
- if (Math.random() < 0.1) {
2095
- try {
2096
- for (const f of fs.readdirSync(INBOUND_DEDUP_DIR)) {
2097
- const fp = path.join(INBOUND_DEDUP_DIR, f);
2098
- try {
2099
- if (now - fs.statSync(fp).mtimeMs > INBOUND_DEDUP_TTL) removeFileIfExists(fp);
2100
- } catch {
2101
- }
2102
- }
2103
- } catch {
2104
- }
2105
- }
2106
- for (const [k, t] of inboundSeen) {
2107
- if (now - t > INBOUND_DEDUP_TTL) inboundSeen.delete(k);
2108
- }
2109
- return false;
2110
- }
2111
- function resolveInboundRoute(chatId, parentChatId) {
2112
- const main = config.channelsConfig?.main;
2113
- const findEntry = (id) => {
2114
- if (!id || !config.channelsConfig) return null;
2115
- if (typeof main === "object" && main !== null && main.channelId === id) {
2116
- return { label: "main", entry: main };
2117
- }
2118
- for (const [label, entry] of Object.entries(config.channelsConfig)) {
2119
- if (typeof entry === "object" && entry !== null && entry.channelId === id) {
2120
- return { label, entry };
2121
- }
2122
- }
2123
- return null;
2124
- };
2125
- // Prefer a direct channelsConfig match on the thread/channel id; fall back
2126
- // to the parent channel id so thread messages inherit the parent's label
2127
- // and mode (e.g. monitor) instead of being routed as untagged interactive.
2128
- const direct = findEntry(chatId);
2129
- if (direct) {
2130
- const mode = direct.entry.mode === "monitor" ? "monitor" : (direct.entry.mode || "interactive");
2131
- return { targetChatId: chatId, sourceChatId: chatId, sourceLabel: direct.label, sourceMode: mode };
2132
- }
2133
- if (parentChatId) {
2134
- const viaParent = findEntry(parentChatId);
2135
- if (viaParent) {
2136
- const mode = viaParent.entry.mode === "monitor" ? "monitor" : (viaParent.entry.mode || "interactive");
2137
- return { targetChatId: chatId, sourceChatId: parentChatId, sourceLabel: viaParent.label, sourceMode: mode };
2138
- }
2139
- }
2140
- return { targetChatId: chatId, sourceChatId: chatId, sourceLabel: undefined, sourceMode: "interactive" };
2141
- }
1407
+ // owner in opt-in remote mode). Extracted → lib/backend-dispatch.mjs. Bound to
1408
+ // live config/backend getters so runtime reloads keep the original file-level
1409
+ // reference semantics.
1410
+ const {
1411
+ dispatchReply,
1412
+ dispatchFetch,
1413
+ } = createBackendDispatch({
1414
+ getConfig: () => config,
1415
+ getBackend: () => backend,
1416
+ scheduler,
1417
+ resolveChannelLabel,
1418
+ labelForChannelId,
1419
+ });
1420
+ // ── Worker/HTTP tool-call dispatch ──────────────────────────────────────────
1421
+ // handleToolCall switch + bridge auto-connect retry wrapper. Extracted →
1422
+ // lib/tool-dispatch.mjs. The switch is entangled with ~8 runtime-lifecycle
1423
+ // functions plus mutable owner state (channelBridgeActive/bridgeRuntimeConnected)
1424
+ // and the forwarder; those are threaded as a lifecycle bag of lazy getters so
1425
+ // the module reads live file-level references at call time (original closure
1426
+ // semantics preserved). Used by the HTTP MCP CallTool path and the worker IPC
1427
+ // `call` handler at the bottom of this file.
1428
+ const {
1429
+ handleToolCall,
1430
+ handleToolCallWithBridgeRetry,
1431
+ } = createToolDispatch({
1432
+ getForwarder: () => forwarder,
1433
+ BACKEND_TOOLS,
1434
+ isChannelsDegraded,
1435
+ dispatchReply,
1436
+ dispatchFetch,
1437
+ lifecycle: {
1438
+ getBridgeRuntimeConnected: () => bridgeRuntimeConnected,
1439
+ getChannelBridgeActive: () => channelBridgeActive,
1440
+ setChannelBridgeActive: (v) => { channelBridgeActive = v; },
1441
+ writeBridgeState,
1442
+ stopServerTyping,
1443
+ claimBridgeOwnership,
1444
+ refreshBridgeOwnership,
1445
+ bindPersistedTranscriptIfAny,
1446
+ stopOwnedRuntime,
1447
+ reloadRuntimeConfig,
1448
+ },
1449
+ });
2142
1450
  const inboundQueue = (() => {
2143
1451
  let tail = Promise.resolve();
2144
1452
  let _iqDepth = 0;
@@ -2154,14 +1462,6 @@ const inboundQueue = (() => {
2154
1462
  }).finally(() => { _iqDepth--; });
2155
1463
  };
2156
1464
  })();
2157
- // ── Reverse-lookup channelId → human label from channelsConfig ──────────────
2158
- function labelForChannelId(channelId) {
2159
- if (!channelId || !config.channelsConfig) return channelId;
2160
- for (const [label, entry] of Object.entries(config.channelsConfig)) {
2161
- if (entry?.channelId === channelId) return label;
2162
- }
2163
- return channelId;
2164
- }
2165
1465
 
2166
1466
  backend.onMessage = (msg) => {
2167
1467
  const receivedAtMs = Number.isFinite(msg.receivedAtMs) ? msg.receivedAtMs : Date.now();
@@ -2189,6 +1489,7 @@ backend.onMessage = (msg) => {
2189
1489
  let boundTranscript = null;
2190
1490
  let stoleSelfTranscript = false;
2191
1491
  let transcriptPath = forwarder.hasBinding() ? forwarder.transcriptPath : "";
1492
+ let needsStealPoll = false;
2192
1493
  // Reuse the current binding only while it still points at THIS owner's own
2193
1494
  // session. discoverSessionBoundTranscript() now ranks the live parent-chain
2194
1495
  // session (the one that forked this worker and receives injected input)
@@ -2222,6 +1523,12 @@ backend.onMessage = (msg) => {
2222
1523
  transcriptPath,
2223
1524
  exists: true
2224
1525
  };
1526
+ // Fast path skips the poll below (zero added latency) unless we lack a
1527
+ // confident, currently-active self-bound candidate — that's the
1528
+ // ~ms race window right after activate, before the parent-chain
1529
+ // session record is published, where the steal gate above fails on
1530
+ // the very first inbound even though a real self session exists.
1531
+ if (!selfBound || selfBound.active !== true) needsStealPoll = true;
2225
1532
  }
2226
1533
  } else {
2227
1534
  boundTranscript = discoverSessionBoundTranscript();
@@ -2239,16 +1546,98 @@ backend.onMessage = (msg) => {
2239
1546
  }
2240
1547
  }
2241
1548
  }
2242
- if (transcriptPath) {
2243
- applyTranscriptBinding(route.targetChatId, transcriptPath, { cwd: boundTranscript?.sessionCwd });
2244
- } else {
2245
- refreshActiveInstance(INSTANCE_ID, { channelId: route.targetChatId });
1549
+ // Binding-settled signal: resolves once the queued binding task below
1550
+ // (poll-if-needed + apply-bind + rebind) has run, so the react/status
1551
+ // IIFE can read the FINAL transcriptPath/boundTranscript instead of the
1552
+ // pre-poll snapshot. onMessage itself stays synchronous — nothing here
1553
+ // blocks message ordering or delays the enqueue calls.
1554
+ let bindingDoneResolve;
1555
+ const bindingDone = new Promise((resolve) => { bindingDoneResolve = resolve; });
1556
+ const queuedAtMs = Date.now();
1557
+ const preQueueMs = queuedAtMs - onMessageAtMs;
1558
+ const gatewayToQueueMs = queuedAtMs - receivedAtMs;
1559
+ if (preQueueMs > 250 || gatewayToQueueMs > 500) {
1560
+ process.stderr.write(`mixdog: inbound latency prequeue=${preQueueMs}ms gateway_to_queue=${gatewayToQueueMs}ms channel=${route.targetChatId}\n`);
2246
1561
  }
1562
+ // ONE queued task per message: binding (poll-if-needed + bind + rebind)
1563
+ // first, then handleInbound. Keeping both phases in a single task preserves
1564
+ // the queue's per-message depth accounting — the overflow guard drops a
1565
+ // whole message, never just its handleInbound half — and guarantees
1566
+ // bindingDone/stopServerTyping always settle even when the binding phase
1567
+ // throws. FIFO is preserved: inboundQueue chains tasks in call order, so
1568
+ // this message's poll delay (if any) defers only its own delivery.
1569
+ inboundQueue(async () => {
1570
+ try {
1571
+ if (needsStealPoll) {
1572
+ const POLL_INTERVAL_MS = 50;
1573
+ const POLL_TIMEOUT_MS = 500;
1574
+ const pollStart = Date.now();
1575
+ while (Date.now() - pollStart < POLL_TIMEOUT_MS) {
1576
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
1577
+ // fresh: bypass the negative parent-pid cache inside the walk —
1578
+ // a transient parent-lookup miss cached just before this poll
1579
+ // would otherwise pin every retry to null until its TTL expires,
1580
+ // defeating the whole first-inbound recovery window.
1581
+ const retryBound = discoverSessionBoundTranscript({ fresh: true });
1582
+ const retryShouldSteal = Boolean(
1583
+ retryBound?.transcriptPath &&
1584
+ !sameResolvedPath(retryBound.transcriptPath, transcriptPath) &&
1585
+ retryBound.active === true &&
1586
+ (retryBound.parentChain === true || retryBound.cwdMatches === true)
1587
+ );
1588
+ if (retryShouldSteal) {
1589
+ process.stderr.write(`mixdog: inbound rebind (poll +${Date.now() - pollStart}ms): stealing transcript ${transcriptPath} -> ${retryBound.transcriptPath} (source=${retryBound.source || "unknown"}, exists=${retryBound.exists})\n`);
1590
+ transcriptPath = retryBound.transcriptPath;
1591
+ boundTranscript = retryBound;
1592
+ stoleSelfTranscript = true;
1593
+ break;
1594
+ }
1595
+ }
1596
+ }
1597
+ if (transcriptPath) {
1598
+ applyTranscriptBinding(route.targetChatId, transcriptPath, { cwd: boundTranscript?.sessionCwd });
1599
+ } else {
1600
+ refreshActiveInstance(INSTANCE_ID, { channelId: route.targetChatId }, { onlyIfOwned: true });
1601
+ }
1602
+ if (!boundTranscript?.exists) {
1603
+ await rebindTranscriptContext(route.targetChatId, {
1604
+ // For a stolen self transcript (not yet on disk) the sync bind above
1605
+ // persisted lastFileSize=0 for this path, so catchUpFromPersisted makes
1606
+ // setContext resume from offset 0 once the file appears — forwarding
1607
+ // the first assistant reply. Relying on replayFromStart instead would
1608
+ // race: the discovery loop only sets replayFromStart when it first saw
1609
+ // the transcript as PENDING, so a file that already exists on the first
1610
+ // loop iteration would bind at EOF and skip the reply. Non-steal keeps
1611
+ // the original catch-up-from-cursor behaviour.
1612
+ previousPath: transcriptPath,
1613
+ catchUp: true,
1614
+ catchUpFromPersisted: stoleSelfTranscript ? true : undefined,
1615
+ persistStatus: true
1616
+ });
1617
+ }
1618
+ } catch (err) {
1619
+ try { process.stderr.write(`mixdog: inbound binding error: ${err}\n`); } catch {}
1620
+ } finally {
1621
+ bindingDoneResolve();
1622
+ }
1623
+ try {
1624
+ await handleInbound(msg, route, {
1625
+ sessionId: boundTranscript?.sessionId ?? sessionIdFromTranscriptPath(transcriptPath),
1626
+ receivedAtMs,
1627
+ queuedAtMs
1628
+ });
1629
+ } catch (err) {
1630
+ process.stderr.write(`mixdog: handleInbound error: ${err}\n`);
1631
+ } finally {
1632
+ stopServerTyping();
1633
+ }
1634
+ });
2247
1635
  void (async () => {
2248
1636
  try {
2249
1637
  await backend.react(msg.chatId, msg.messageId, "\u{1F914}");
2250
1638
  } catch {
2251
1639
  }
1640
+ await bindingDone;
2252
1641
  statusState.update((state) => {
2253
1642
  state.channelId = route.targetChatId;
2254
1643
  state.userMessageId = msg.messageId;
@@ -2259,39 +1648,7 @@ backend.onMessage = (msg) => {
2259
1648
  else delete state.transcriptPath;
2260
1649
  state.sessionCwd = boundTranscript?.sessionCwd ?? null;
2261
1650
  });
2262
- if (!boundTranscript?.exists) {
2263
- await rebindTranscriptContext(route.targetChatId, {
2264
- // For a stolen self transcript (not yet on disk) the sync bind above
2265
- // persisted lastFileSize=0 for this path, so catchUpFromPersisted makes
2266
- // setContext resume from offset 0 once the file appears — forwarding
2267
- // the first assistant reply. Relying on replayFromStart instead would
2268
- // race: the discovery loop only sets replayFromStart when it first saw
2269
- // the transcript as PENDING, so a file that already exists on the first
2270
- // loop iteration would bind at EOF and skip the reply. Non-steal keeps
2271
- // the original catch-up-from-cursor behaviour.
2272
- previousPath: transcriptPath,
2273
- catchUp: true,
2274
- catchUpFromPersisted: stoleSelfTranscript ? true : undefined,
2275
- persistStatus: true
2276
- });
2277
- }
2278
1651
  })();
2279
- const queuedAtMs = Date.now();
2280
- const preQueueMs = queuedAtMs - onMessageAtMs;
2281
- const gatewayToQueueMs = queuedAtMs - receivedAtMs;
2282
- if (preQueueMs > 250 || gatewayToQueueMs > 500) {
2283
- process.stderr.write(`mixdog: inbound latency prequeue=${preQueueMs}ms gateway_to_queue=${gatewayToQueueMs}ms channel=${route.targetChatId}\n`);
2284
- }
2285
- inboundQueue(() => handleInbound(msg, route, {
2286
- sessionId: boundTranscript?.sessionId ?? sessionIdFromTranscriptPath(transcriptPath),
2287
- receivedAtMs,
2288
- queuedAtMs
2289
- }).catch((err) => {
2290
- process.stderr.write(`mixdog: handleInbound error: ${err}
2291
- `);
2292
- }).finally(() => {
2293
- stopServerTyping();
2294
- }));
2295
1652
  };
2296
1653
  async function handleInbound(msg, route, options = {}) {
2297
1654
  const handleStartMs = Date.now();
@@ -2562,17 +1919,7 @@ if (_isWorkerMode && process.send) {
2562
1919
  }
2563
1920
  return;
2564
1921
  }
2565
- if (msg && msg.type === 'memory_call_response' && msg.callId) {
2566
- // Response side of the worker → parent → memory bridge. Routed into
2567
- // this existing listener (instead of a second process.on('message'))
2568
- // to keep IPC dispatch in one place.
2569
- const pending = _memoryCallPending.get(msg.callId);
2570
- if (!pending) return;
2571
- _memoryCallPending.delete(msg.callId);
2572
- if (msg.ok) pending.resolve(msg.result);
2573
- else pending.reject(new Error(msg.error || 'memory_call failed'));
2574
- return;
2575
- }
1922
+ if (handleMemoryCallResponse(msg)) return;
2576
1923
  if (msg.type === 'cancel' && msg.callId) {
2577
1924
  const entry = _inFlightChannelCalls.get(msg.callId)
2578
1925
  if (entry) {