mixdog 0.9.95 → 0.9.97

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 (282) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/README.md +0 -17
  6. package/package.json +16 -12
  7. package/scripts/.tmp-coverage-report.mjs +96 -0
  8. package/scripts/build-tui.mjs +17 -1
  9. package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
  10. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/defaults/skills/setup/SKILL.md +1 -1
  13. package/src/lib/keychain-cjs.cjs +1 -1
  14. package/src/lib/mixdog-debug.cjs +2 -3
  15. package/src/rules/agent/30-explorer.md +7 -4
  16. package/src/rules/lead/01-general.md +1 -2
  17. package/src/rules/lead/lead-tool.md +0 -3
  18. package/src/rules/shared/01-tool.md +21 -29
  19. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  23. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  24. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +104 -41
  26. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  27. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  39. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  41. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  42. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  43. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +36 -10
  44. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -33
  46. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  49. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +21 -29
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +7 -10
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +45 -25
  52. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  53. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  54. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  55. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  57. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  59. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  62. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  63. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  64. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +71 -42
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +4 -33
  66. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  67. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +116 -26
  71. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  72. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  73. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  77. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +12 -26
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +5 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -33
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +42 -45
  81. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +27 -9
  82. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  84. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  86. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  87. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  88. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  89. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  90. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +142 -26
  91. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  92. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +47 -19
  93. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -2
  94. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  95. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  97. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -20
  99. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  100. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  101. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +12 -11
  102. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +63 -9
  104. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  105. package/src/runtime/channels/lib/config.mjs +34 -34
  106. package/src/runtime/channels/lib/inbound-handler.mjs +8 -8
  107. package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
  108. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  109. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  110. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  111. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  112. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  113. package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
  114. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  115. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  116. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  117. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  118. package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
  119. package/src/runtime/channels/lib/worker-main.mjs +38 -38
  120. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  121. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  122. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  123. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/memory/index.mjs +76 -248
  126. package/src/runtime/memory/lib/agent-ipc.mjs +146 -81
  127. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  128. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  129. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +13 -34
  130. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  131. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  132. package/src/runtime/memory/lib/http-router.mjs +9 -6
  133. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  134. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  135. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  136. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  137. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  138. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  139. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  140. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  141. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  142. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  143. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  144. package/src/runtime/memory/lib/memory.mjs +1 -1
  145. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  146. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  147. package/src/runtime/memory/tool-defs.mjs +1 -5
  148. package/src/runtime/search/index.mjs +2 -2
  149. package/src/runtime/search/tool-defs.mjs +2 -2
  150. package/src/runtime/shared/abort-race.mjs +70 -0
  151. package/src/runtime/shared/agent-route-config.mjs +119 -0
  152. package/src/runtime/shared/atomic-file.mjs +80 -4
  153. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  154. package/src/runtime/shared/child-guardian.mjs +204 -108
  155. package/src/runtime/shared/child-spawn-gate.mjs +139 -72
  156. package/src/runtime/shared/config.mjs +120 -47
  157. package/src/runtime/shared/llm/index.mjs +1 -1
  158. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  159. package/src/runtime/shared/resource-admission.mjs +150 -100
  160. package/src/runtime/shared/stream-progress.mjs +6 -0
  161. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  162. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  163. package/src/runtime/shared/tool-surface.mjs +91 -27
  164. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  165. package/src/runtime/shared/turn-snapshot.mjs +47 -6
  166. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  167. package/src/session-runtime/channel-config-api.mjs +5 -5
  168. package/src/session-runtime/config-helpers.mjs +39 -37
  169. package/src/session-runtime/config-lifecycle.mjs +27 -27
  170. package/src/session-runtime/lifecycle-api.mjs +22 -3
  171. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  172. package/src/session-runtime/model-route-api.mjs +7 -5
  173. package/src/session-runtime/prewarm.mjs +1 -1
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +1 -1
  176. package/src/session-runtime/remote-control.mjs +6 -7
  177. package/src/session-runtime/runtime-core.mjs +63 -39
  178. package/src/session-runtime/runtime-tunables.mjs +4 -4
  179. package/src/session-runtime/self-update.mjs +1 -1
  180. package/src/session-runtime/session-lifecycle.mjs +45 -17
  181. package/src/session-runtime/session-title.mjs +1 -1
  182. package/src/session-runtime/session-turn-api.mjs +96 -24
  183. package/src/session-runtime/settings-api.mjs +12 -24
  184. package/src/session-runtime/tool-defs.mjs +3 -3
  185. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  186. package/src/session-runtime/workflow.mjs +14 -24
  187. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  188. package/src/standalone/agent-tool/helpers.mjs +2 -2
  189. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  190. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  191. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  192. package/src/standalone/channel-admin.mjs +53 -64
  193. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +33 -19
  194. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +127 -46
  195. package/src/standalone/channel-worker.mjs +124 -647
  196. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +270 -154
  197. package/src/standalone/explore-tool.mjs +187 -70
  198. package/src/standalone/fair-call-scheduler.mjs +264 -0
  199. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  200. package/src/standalone/session-client.mjs +1134 -0
  201. package/src/standalone/session-protocol.mjs +192 -0
  202. package/src/standalone/session-runtime-pool.mjs +393 -0
  203. package/src/standalone/session-runtime-record.mjs +20 -0
  204. package/src/standalone/session-runtime-worker.mjs +203 -0
  205. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +481 -224
  206. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +390 -121
  207. package/src/standalone/session-wire.mjs +55 -0
  208. package/src/tui/App.jsx +34 -21
  209. package/src/tui/app/app-format.mjs +1 -1
  210. package/src/tui/app/app-view.jsx +5 -2
  211. package/src/tui/app/channel-pickers.mjs +41 -41
  212. package/src/tui/app/core-memory-picker.mjs +19 -19
  213. package/src/tui/app/doctor.mjs +5 -11
  214. package/src/tui/app/input-parsers.mjs +2 -2
  215. package/src/tui/app/message-selector.mjs +103 -0
  216. package/src/tui/app/onboarding-steps.mjs +3 -4
  217. package/src/tui/app/project-picker.mjs +80 -56
  218. package/src/tui/app/prompt-submit.mjs +48 -43
  219. package/src/tui/app/resume-picker.mjs +2 -2
  220. package/src/tui/app/route-pickers.mjs +3 -1
  221. package/src/tui/app/settings-picker.mjs +31 -33
  222. package/src/tui/app/transcript-window.mjs +38 -0
  223. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  224. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  225. package/src/tui/app/use-prompt-hint.mjs +3 -2
  226. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  227. package/src/tui/app/use-transcript-scroll.mjs +10 -3
  228. package/src/tui/app/use-transcript-window.mjs +50 -17
  229. package/src/tui/components/PromptInput.jsx +71 -38
  230. package/src/tui/components/Spinner.jsx +1 -1
  231. package/src/tui/components/ToolExecution.jsx +1 -1
  232. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  233. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  234. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  235. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  236. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  237. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  238. package/src/tui/dist/index.mjs +1031 -1676
  239. package/src/tui/hooks/useSession.mjs +16 -0
  240. package/src/tui/index.jsx +12 -13
  241. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  242. package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
  243. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  244. package/src/tui/{engine → session}/context-state.mjs +3 -3
  245. package/src/tui/{engine → session}/labels.mjs +2 -2
  246. package/src/tui/{engine → session}/live-share.mjs +38 -4
  247. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  248. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  249. package/src/tui/session/oauth-flows.mjs +140 -0
  250. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  251. package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
  252. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  253. package/src/tui/{engine → session}/session-api-ext.mjs +30 -20
  254. package/src/tui/{engine → session}/session-api.mjs +110 -93
  255. package/src/tui/{engine → session}/session-flow.mjs +8 -2
  256. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  257. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  258. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  259. package/src/tui/{engine → session}/tool-card-results.mjs +8 -6
  260. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  261. package/src/tui/{engine → session}/tool-result-text.mjs +13 -3
  262. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  263. package/src/tui/{engine → session}/turn.mjs +38 -176
  264. package/src/tui/{engine-local-session.mjs → session-local.mjs} +103 -103
  265. package/src/tui/session.mjs +22 -0
  266. package/src/tui/spinner-meta.mjs +4 -4
  267. package/src/tui/spinner-verbs.mjs +51 -3
  268. package/src/ui/statusline-segments.mjs +1 -1
  269. package/src/ui/statusline.mjs +1 -1
  270. package/vendor/ink/build/components/App.js +10 -1
  271. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  272. package/src/standalone/engine-daemon-client.mjs +0 -894
  273. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
  274. package/src/standalone/engine-daemon-protocol.mjs +0 -33
  275. package/src/tui/engine/turn-watchdog.mjs +0 -92
  276. package/src/tui/engine.mjs +0 -22
  277. package/src/tui/hooks/useEngine.mjs +0 -16
  278. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  279. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  280. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  281. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  282. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -1,60 +1,36 @@
1
- import { execFile, fork, spawnSync } from 'node:child_process';
1
+ import { fork } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { mkdirSync, rmSync, writeFile } from 'node:fs';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { dirname, join, resolve } from 'node:path';
5
- import { fileURLToPath, pathToFileURL } from 'node:url';
6
- import { startChildGuardian } from '../runtime/shared/child-guardian.mjs';
7
- import { detachedSpawnOpts } from '../runtime/shared/spawn-flags.mjs';
6
+ import { fileURLToPath } from 'node:url';
8
7
  import { appendBuffered } from '../runtime/shared/buffered-appender.mjs';
8
+ import { detachedSpawnOpts } from '../runtime/shared/spawn-flags.mjs';
9
9
  import { scrubLoaderVars } from '../runtime/agent/orchestrator/tools/env-scrub.mjs';
10
10
  import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../lib/mixdog-debug.cjs';
11
- import { attachToDaemon, readDaemonDiscovery, probeDaemonHealth } from './channel-daemon-client.mjs';
12
- import { claimSingletonOwner, releaseSingletonOwner } from '../runtime/shared/singleton-owner.mjs';
13
- import { safeIpcSend } from '../runtime/shared/safe-ipc-send.mjs';
14
- import { randomUUID } from 'node:crypto';
11
+ import { attachChannel, readChannelDiscovery, probeChannelHealth } from './channel-client.mjs';
15
12
 
16
13
  const CHANNEL_TOOLS = new Set([
17
14
  'reply',
18
15
  'fetch',
19
- // activate_channel_bridge/reload_config are NOT model-facing tools (no
20
- // TOOL_DEFS entry) but stay in the allow-set: mixdog-session-runtime.mjs
21
- // calls channels.execute() with these names directly as internal
22
- // Lead-only runtime plumbing (bridge-claim on start, config hot-reload).
23
16
  'activate_channel_bridge',
24
17
  'reload_config',
25
- // Lead-pushed transcript repoint (auto-acquire / newSession / resume /
26
- // clear). Not model-facing; internal Lead-only runtime plumbing.
27
18
  'rebind_current_transcript',
28
19
  ]);
29
20
 
30
21
  const WORKER_PRELOAD = fileURLToPath(new URL('./channel-worker-preload.cjs', import.meta.url));
31
- // Machine-global channels daemon entry (spawn-or-attach target). Overridable
32
- // via env so smokes can point at a stub daemon (no Discord token). Resolved
33
- // LAZILY at spawn time — an import-time constant would freeze before a smoke
34
- // that imports this module first gets a chance to set the env override.
22
+
35
23
  function daemonEntry() {
36
- return process.env.MIXDOG_CHANNEL_DAEMON_ENTRY
37
- ? resolve(process.env.MIXDOG_CHANNEL_DAEMON_ENTRY)
38
- // ONE backend process: channels, memory, and the session engine pool.
39
- : fileURLToPath(new URL('./backend-daemon.mjs', import.meta.url));
24
+ return process.env.MIXDOG_DAEMON_ENTRY
25
+ ? resolve(process.env.MIXDOG_DAEMON_ENTRY)
26
+ : fileURLToPath(new URL('./daemon.mjs', import.meta.url));
40
27
  }
41
28
 
42
- // A global package update can briefly remove channel-worker-preload.cjs while
43
- // the worker is spawning (MODULE_NOT_FOUND boot crash). Retry once before
44
- // surfacing the failure to callers.
45
- const WORKER_BOOT_MAX_ATTEMPTS = 2;
46
- const WORKER_BOOT_RETRY_DELAY_MS = 1000;
47
- // Fail an in-flight IPC send whose flush callback never fires (wedged channel)
48
- // so the FIFO send queue can't stall permanently on one stuck message.
49
- const SEND_FLUSH_TIMEOUT_MS = 10_000;
50
-
51
29
  function logLine(path, line) {
52
30
  try {
53
31
  mkdirSync(dirname(path), { recursive: true });
54
32
  appendBuffered(path, `[${new Date().toISOString()}] ${line}\n`);
55
- } catch {
56
- // Logging must never break the TUI.
57
- }
33
+ } catch {}
58
34
  }
59
35
 
60
36
  const CHANNEL_WORKER_EXIT_CLEANUPS = new Set();
@@ -66,75 +42,41 @@ function registerChannelWorkerExitCleanup(cleanup) {
66
42
  if (!channelWorkerExitHookInstalled) {
67
43
  channelWorkerExitHookInstalled = true;
68
44
  process.once('exit', () => {
69
- for (const fn of Array.from(CHANNEL_WORKER_EXIT_CLEANUPS)) {
45
+ for (const fn of CHANNEL_WORKER_EXIT_CLEANUPS) {
70
46
  try { fn(); } catch {}
71
47
  }
72
48
  CHANNEL_WORKER_EXIT_CLEANUPS.clear();
73
49
  });
74
50
  }
75
- return () => {
76
- CHANNEL_WORKER_EXIT_CLEANUPS.delete(cleanup);
77
- };
51
+ return () => { CHANNEL_WORKER_EXIT_CLEANUPS.delete(cleanup); };
78
52
  }
79
53
 
80
54
  function runtimeRoot() {
81
- return process.env.MIXDOG_RUNTIME_ROOT ? resolve(process.env.MIXDOG_RUNTIME_ROOT) : join(tmpdir(), 'mixdog');
55
+ return process.env.MIXDOG_RUNTIME_ROOT
56
+ ? resolve(process.env.MIXDOG_RUNTIME_ROOT)
57
+ : join(tmpdir(), 'mixdog');
82
58
  }
83
59
 
84
60
  export function createStandaloneChannelWorker({
85
- entry,
86
61
  rootDir,
87
62
  dataDir,
88
63
  cwd = process.cwd(),
89
64
  onNotify,
90
65
  } = {}) {
91
- if (!entry) throw new Error('channels runtime entry is required');
92
66
  if (!rootDir) throw new Error('channels runtime rootDir is required');
93
67
  if (!dataDir) throw new Error('channels runtime dataDir is required');
94
68
 
95
- let child = null;
96
- let readyPromise = null;
97
- let readyResolve = null;
98
- let readyReject = null;
99
- let stopPromise = null;
100
- let stopRequested = false;
101
- let bootGeneration = 0;
102
- let inProcessMod = null;
103
- let inProcessStartPromise = null;
104
- // Set when in-process mode (MIXDOG_CHANNEL_WORKER_PROCESS=0) holds the daemon
105
- // singleton lock, so stop() releases it (never double-own beside a daemon).
106
- let inProcessOwned = false;
107
- // Machine-global channels daemon attach handle. Replaces the per-TUI fork +
108
- // node-IPC call/notify plumbing; the daemon is SHARED and never killed on
109
- // this TUI's exit (only detached-from). The fork machinery below is retained
110
- // as an unreachable legacy path while useProcessWorker is true.
111
69
  let daemonClient = null;
70
+ let daemonPid = null;
112
71
  let attachPromise = null;
113
72
  let attachGeneration = 0;
114
- let daemonPid = null;
73
+ let stopPromise = null;
74
+ let stopRequested = false;
115
75
  let nextCallId = 1;
116
- // Per-proxy unique prefix so a callId can never collide across TUIs sharing
117
- // the same supervisor pid (which would cross-dedup two distinct calls).
118
76
  const proxyId = randomUUID();
119
- let parentExitCleanup = null;
120
- const pending = new Map();
121
- const ownedChildPids = new Set();
122
- // Strict-FIFO backpressure queue for child IPC. child.send returns false
123
- // when the channel is over its backpressure threshold; we keep at most one
124
- // send in flight and only dispatch the next once the prior send's flush
125
- // callback fires, so bursts don't unboundedly buffer and order is preserved.
126
- const sendQueue = [];
127
- let sendInFlight = false;
128
- // Guards the single in-flight send: the token invalidates a stale settler
129
- // (flush callback / watchdog / rejectPending) so only the first one to fire
130
- // releases the slot; the watchdog handle lets rejectPending cancel it.
131
- let inFlightToken = 0;
132
- let inFlightWatchdog = null;
133
- const logPath = join(dataDir, 'channels-worker-standalone.log');
134
- // One-shot bound at own-process boot: this runtime may never pass through
135
- // the channels-worker rotation path, so cap the log writer-side.
77
+ const logPath = join(dataDir, 'daemon.log');
136
78
  rotateBoundedLog(logPath, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES);
137
- const useProcessWorker = process.env.MIXDOG_CHANNEL_WORKER_PROCESS !== '0';
79
+
138
80
  const clientDir = join(runtimeRoot(), 'channel-clients');
139
81
  const clientPath = join(clientDir, `${process.pid}.json`);
140
82
  let clientHeartbeatTimer = null;
@@ -142,9 +84,6 @@ export function createStandaloneChannelWorker({
142
84
  let clientDirReady = false;
143
85
 
144
86
  function writeClientHeartbeat() {
145
- // Must write every tick: liveness readers key off the file's rolling
146
- // mtime/updatedAt, so an unchanged-skip would make the client look dead.
147
- // mkdir once and write async to keep the 5s interval off the sync-fs path.
148
87
  try {
149
88
  if (!clientDirReady) {
150
89
  mkdirSync(clientDir, { recursive: true });
@@ -158,14 +97,6 @@ export function createStandaloneChannelWorker({
158
97
  } catch {}
159
98
  }
160
99
 
161
- function startClientHeartbeat() {
162
- if (clientHeartbeatTimer) return;
163
- writeClientHeartbeat();
164
- clientHeartbeatTimer = setInterval(writeClientHeartbeat, 5000);
165
- clientHeartbeatTimer.unref?.();
166
- clientHeartbeatExitCleanup ||= registerChannelWorkerExitCleanup(stopClientHeartbeat);
167
- }
168
-
169
100
  function stopClientHeartbeat() {
170
101
  if (clientHeartbeatExitCleanup) {
171
102
  const unregister = clientHeartbeatExitCleanup;
@@ -179,17 +110,17 @@ export function createStandaloneChannelWorker({
179
110
  try { rmSync(clientPath, { force: true }); } catch {}
180
111
  }
181
112
 
113
+ function startClientHeartbeat() {
114
+ if (clientHeartbeatTimer) return;
115
+ writeClientHeartbeat();
116
+ clientHeartbeatTimer = setInterval(writeClientHeartbeat, 5_000);
117
+ clientHeartbeatTimer.unref?.();
118
+ clientHeartbeatExitCleanup ||= registerChannelWorkerExitCleanup(stopClientHeartbeat);
119
+ }
120
+
182
121
  startClientHeartbeat();
183
122
 
184
123
  function status() {
185
- if (!useProcessWorker) {
186
- return {
187
- running: Boolean(inProcessMod),
188
- pid: inProcessMod ? process.pid : null,
189
- pending: 0,
190
- mode: 'in-process',
191
- };
192
- }
193
124
  return {
194
125
  running: Boolean(daemonClient),
195
126
  pid: daemonPid,
@@ -198,219 +129,21 @@ export function createStandaloneChannelWorker({
198
129
  };
199
130
  }
200
131
 
201
- function rejectPending(error) {
202
- for (const [, item] of pending) {
203
- clearTimeout(item.timer);
204
- item.reject(error);
205
- }
206
- pending.clear();
207
- // Fail any sends still queued/in-flight for the now-dead channel so their
208
- // callers unblock instead of waiting on a flush callback that never fires.
209
- // Invalidate + disarm the in-flight watchdog so it can't later flip
210
- // sendInFlight under a freshly-started send (its owning call was already
211
- // rejected via the pending map above).
212
- inFlightToken++;
213
- if (inFlightWatchdog) { clearTimeout(inFlightWatchdog); inFlightWatchdog = null; }
214
- sendInFlight = false;
215
- while (sendQueue.length) {
216
- const item = sendQueue.shift();
217
- try { item.cb?.(error); } catch {}
218
- }
219
- }
220
-
221
- // Dispatch queued IPC sends one at a time, gating the next on the current
222
- // send's flush callback so child.send backpressure (false return) is honored.
223
- function drainSendQueue() {
224
- if (sendInFlight) return;
225
- const item = sendQueue.shift();
226
- if (!item) return;
227
- if (!child || !child.send) {
228
- try { item.cb?.(new Error('channels worker is not running')); } catch {}
229
- drainSendQueue();
230
- return;
231
- }
232
- sendInFlight = true;
233
- const token = ++inFlightToken;
234
- // Settle exactly once, whichever of {flush callback, watchdog} fires first;
235
- // a stale token (superseded by rejectPending or the sibling) is ignored.
236
- const finish = (error) => {
237
- if (token !== inFlightToken) return;
238
- inFlightToken++;
239
- if (inFlightWatchdog) { clearTimeout(inFlightWatchdog); inFlightWatchdog = null; }
240
- sendInFlight = false;
241
- try { item.cb?.(error); } catch {}
242
- drainSendQueue();
243
- };
244
- // Watchdog: if the flush callback never fires (wedged IPC channel), fail
245
- // this request via its normal rejection path and keep draining so a single
246
- // stuck send can't wedge the queue forever.
247
- inFlightWatchdog = setTimeout(() => finish(new Error('channels worker send timed out')), SEND_FLUSH_TIMEOUT_MS);
248
- inFlightWatchdog.unref?.();
249
- safeIpcSend(child, item.msg, {
250
- onComplete: (error) => finish(error),
251
- });
252
- }
253
-
254
- function sendToChild(msg, cb) {
255
- sendQueue.push({ msg, cb });
256
- drainSendQueue();
257
- }
258
-
259
- function start() {
260
- if (stopPromise) return stopPromise.then(() => start());
261
- if (!useProcessWorker) return startInProcess();
262
- // Daemon mode (default): spawn-or-attach to the machine-global channels
263
- // daemon. The fork code below is unreachable while useProcessWorker is true.
264
- stopRequested = false;
265
- return ensureDaemonAttached().then(() => status());
266
- }
267
-
268
- function spawnWorkerChild(attempt) {
269
- const workerEnv = { ...process.env };
270
- scrubLoaderVars(workerEnv);
271
- child = fork(entry, [], {
272
- cwd,
273
- execArgv: ['--require', WORKER_PRELOAD],
274
- stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
275
- detached: false,
276
- env: {
277
- ...workerEnv,
278
- MIXDOG_ROOT: rootDir,
279
- MIXDOG_DATA_DIR: dataDir,
280
- MIXDOG_STANDALONE: '1',
281
- MIXDOG_WORKER_MODE: '1',
282
- MIXDOG_CLI_OWNED: '0',
283
- // Preserve the real terminal-lead PID (host TUI, or an outer run-mcp
284
- // supervisor if one injected MIXDOG_SUPERVISOR_PID) through the fork.
285
- // Without this the worker resolves getTerminalLeadPid() to its OWN pid,
286
- // so the seat's ownerLeadPid tracks the headless worker instead of the
287
- // terminal that owns it — and the seat can never be evicted when the
288
- // owning terminal/TUI dies while the worker stays alive.
289
- MIXDOG_SUPERVISOR_PID: process.env.MIXDOG_SUPERVISOR_PID || String(process.pid),
290
- MIXDOG_QUIET_SESSION_LOG: process.env.MIXDOG_QUIET_SESSION_LOG ?? '1',
291
- },
292
- windowsHide: true,
293
- });
294
- const spawnedPid = child.pid;
295
- // Per-boot generation: a later start()/respawn bumps this, so a stale old
296
- // child's late exit is ignored instead of respawning/rejecting the current one.
297
- const myGeneration = ++bootGeneration;
298
- startChildGuardian({ childPid: spawnedPid, label: 'channel-worker', orphanGraceMs: 8000, forceGraceMs: 3000 });
299
- if (spawnedPid) ownedChildPids.add(spawnedPid);
300
- installParentExitHook();
301
- let becameReady = false;
302
-
303
- child.stderr?.on('data', (chunk) => {
304
- const text = String(chunk || '').trimEnd();
305
- if (text) logLine(logPath, text);
306
- });
307
-
308
- child.on('message', (msg) => {
309
- if (!msg || typeof msg !== 'object') return;
310
- if (msg.type === 'ready') {
311
- becameReady = true;
312
- readyResolve?.(status());
313
- readyResolve = null;
314
- readyReject = null;
315
- return;
316
- }
317
- if (msg.type === 'result' && msg.callId) {
318
- const item = pending.get(msg.callId);
319
- if (!item) return;
320
- pending.delete(msg.callId);
321
- clearTimeout(item.timer);
322
- if (msg.error) item.reject(new Error(msg.error));
323
- else item.resolve(msg.result);
324
- return;
325
- }
326
- if (msg.type === 'notify') {
327
- try { onNotify?.(msg); } catch {}
328
- }
329
- });
330
-
331
- child.on('exit', (code, signal) => {
332
- if (myGeneration !== bootGeneration) return;
333
- if (spawnedPid) ownedChildPids.delete(spawnedPid);
334
- const error = new Error(`channels runtime exited (${signal || (code ?? 'unknown')})`);
335
- // Exit code 2 = terminal (non-transient) worker start failure: reject, never respawn.
336
- // A requested stop (stopRequested) also suppresses respawn even if stopPromise's
337
- // settle timer already cleared it before the child exited.
338
- if (!becameReady && readyReject && !stopPromise && !stopRequested && code !== 2 && attempt < WORKER_BOOT_MAX_ATTEMPTS) {
339
- logLine(logPath, `worker exited before ready (${signal || (code ?? 'unknown')}), attempt ${attempt}/${WORKER_BOOT_MAX_ATTEMPTS}; retrying in ${WORKER_BOOT_RETRY_DELAY_MS}ms`);
340
- child = null;
341
- setTimeout(() => {
342
- if (stopPromise || stopRequested || !readyReject) return;
343
- spawnWorkerChild(attempt + 1);
344
- }, WORKER_BOOT_RETRY_DELAY_MS);
345
- return;
346
- }
347
- if (readyReject) readyReject(error);
348
- readyResolve = null;
349
- readyReject = null;
350
- rejectPending(error);
351
- child = null;
352
- readyPromise = null;
353
- });
354
-
355
- child.on('error', (error) => {
356
- if (myGeneration !== bootGeneration) return;
357
- logLine(logPath, `runtime error: ${error?.message || error}`);
358
- if (readyReject) readyReject(error);
359
- readyResolve = null;
360
- readyReject = null;
361
- rejectPending(error);
362
- });
363
- }
364
-
365
- async function startInProcess() {
366
- if (inProcessMod || inProcessStartPromise) return _startInProcess();
367
- // Singleton guard: in-process mode must not run a SECOND live bridge owner
368
- // beside the machine-global daemon (double Discord gateway). Claim the same
369
- // owner lock the daemon uses; if a LIVE daemon holds it, fail loudly rather
370
- // than silently double-own (unset MIXDOG_CHANNEL_WORKER_PROCESS=0 to attach).
371
- const claim = claimSingletonOwner(daemonOwnerPath, { kind: 'channel-runtime-daemon', pid: process.pid, meta: { cwd, mode: 'in-process' } });
372
- if (!claim.owned) {
373
- throw new Error(`in-process channels runtime refused: a live channels daemon (pid=${claim.owner?.pid}) already owns the bridge — refusing to double-own. Unset MIXDOG_CHANNEL_WORKER_PROCESS=0 to attach, or stop the daemon.`);
374
- }
375
- inProcessOwned = true;
376
- try {
377
- return await _startInProcess();
378
- } catch (err) {
379
- // Boot failed: release the just-claimed lock so a retry (or the daemon)
380
- // can own it instead of leaking a live lock held by a non-running runtime.
381
- if (inProcessOwned && !inProcessMod) {
382
- try { releaseSingletonOwner(daemonOwnerPath, process.pid); } catch {}
383
- inProcessOwned = false;
384
- }
385
- throw err;
386
- }
387
- }
388
-
389
- // ── Machine-global daemon: spawn-or-attach ────────────────────────────────
390
- // Second+ TUI attaches to the live daemon instead of forking. Discovery is a
391
- // pid-verified file (channel-daemon.json, 127.0.0.1 only). A stale daemon
392
- // (dead pid) or unhealthy one is respawned; a spawn race loser exits(0) so we
393
- // re-read discovery and attach to the winner (pid-verified singleton lock).
394
132
  const daemonLeadPid = Number(process.env.MIXDOG_SUPERVISOR_PID) || process.pid;
395
- const discoveryPath = join(runtimeRoot(), 'channel-daemon.json');
396
- function daemonDelay(ms) { return new Promise((r) => setTimeout(r, ms)); }
397
- // Same singleton owner file the daemon claims (dataDir-relative), so the
398
- // in-process fallback can refuse to run beside a live daemon.
399
- const daemonOwnerPath = join(dataDir, 'channel-daemon-owner.json');
400
- // Drop the cached attach so the next ensureDaemonAttached() re-reads discovery,
401
- // health-probes, and respawns a dead daemon — the recovery hinge for a daemon
402
- // restart mid-session (no TUI process restart needed).
133
+ const discoveryPath = join(runtimeRoot(), 'daemon.json');
134
+ const discoverChannel = () => readChannelDiscovery(discoveryPath);
135
+ const daemonDelay = (ms) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
136
+
403
137
  function invalidateDaemonClient(reason = 'invalidate', expected = null) {
404
- // CAS: only tear down when the live handle is still the one that failed, so
405
- // a concurrent call that already re-attached a fresh daemon isn't clobbered.
406
138
  if (expected && daemonClient !== expected) return;
407
- attachGeneration++;
139
+ attachGeneration += 1;
408
140
  const client = daemonClient;
409
141
  daemonClient = null;
410
142
  attachPromise = null;
411
143
  daemonPid = null;
412
144
  if (client) { try { client.close(reason); } catch {} }
413
145
  }
146
+
414
147
  function daemonEnv() {
415
148
  const env = { ...process.env };
416
149
  scrubLoaderVars(env);
@@ -420,19 +153,21 @@ export function createStandaloneChannelWorker({
420
153
  MIXDOG_DATA_DIR: dataDir,
421
154
  MIXDOG_STANDALONE: '1',
422
155
  MIXDOG_WORKER_MODE: '1',
423
- MIXDOG_CHANNEL_DAEMON: '1',
156
+ MIXDOG_DAEMON_HOST: '1',
424
157
  MIXDOG_CLI_OWNED: '0',
425
158
  MIXDOG_SUPERVISOR_PID: process.env.MIXDOG_SUPERVISOR_PID || String(process.pid),
426
159
  MIXDOG_QUIET_SESSION_LOG: process.env.MIXDOG_QUIET_SESSION_LOG ?? '1',
427
160
  };
428
161
  }
429
- // Fork one daemon candidate DETACHED (it outlives this TUI — machine-global)
430
- // and NEVER track it for parent-exit kill. Resolves when the candidate reports
431
- // ready OR exits (race loss/crash); the caller then re-checks discovery.
162
+
432
163
  function spawnDaemonCandidate() {
433
164
  return new Promise((resolveSpawn) => {
434
165
  let settled = false;
435
- const done = () => { if (settled) return; settled = true; resolveSpawn(); };
166
+ const done = () => {
167
+ if (settled) return;
168
+ settled = true;
169
+ resolveSpawn();
170
+ };
436
171
  let daemon;
437
172
  try {
438
173
  daemon = fork(daemonEntry(), [], {
@@ -442,56 +177,48 @@ export function createStandaloneChannelWorker({
442
177
  env: daemonEnv(),
443
178
  ...detachedSpawnOpts,
444
179
  });
445
- } catch (err) {
446
- logLine(logPath, `daemon spawn failed: ${err?.message || err}`);
180
+ } catch (error) {
181
+ logLine(logPath, `daemon spawn failed: ${error?.message || error}`);
447
182
  done();
448
183
  return;
449
184
  }
450
- // Mirror daemon stderr into the shared log ONLY until it is ready. After
451
- // ready the daemon appends its own lines directly (backend-daemon.mjs), and
452
- // this TUI may exit at any time — a live mirror would both duplicate those
453
- // lines and die with the parent. Detach the listener at the ready boundary.
454
- const mirrorDaemonStderr = (chunk) => {
185
+ const mirrorStderr = (chunk) => {
455
186
  const text = String(chunk || '').trimEnd();
456
187
  if (text) logLine(logPath, text);
457
188
  };
458
- daemon.stderr?.on('data', mirrorDaemonStderr);
459
- daemon.once('message', (msg) => {
460
- if (msg && msg.type === 'ready') {
461
- // Stop mirroring: the daemon now owns file logging (no loss, no dup).
462
- try { daemon.stderr?.off?.('data', mirrorDaemonStderr); } catch {}
463
- // Fully detach: the daemon must not be tied to this TUI's IPC/lifecycle.
464
- try { daemon.disconnect?.(); } catch {}
465
- try { daemon.unref?.(); } catch {}
466
- try { daemon.stderr?.unref?.(); } catch {}
467
- done();
468
- }
189
+ daemon.stderr?.on('data', mirrorStderr);
190
+ daemon.once('message', (message) => {
191
+ if (message?.type !== 'ready') return;
192
+ try { daemon.stderr?.off?.('data', mirrorStderr); } catch {}
193
+ try { daemon.disconnect?.(); } catch {}
194
+ try { daemon.unref?.(); } catch {}
195
+ try { daemon.stderr?.unref?.(); } catch {}
196
+ done();
469
197
  });
470
- daemon.once('exit', done); // race loss (exit 0) or crash → re-check discovery
471
- daemon.once('error', (err) => { logLine(logPath, `daemon spawn error: ${err?.message || err}`); done(); });
472
- const t = setTimeout(done, 20_000);
473
- t.unref?.();
198
+ daemon.once('exit', done);
199
+ daemon.once('error', (error) => {
200
+ logLine(logPath, `daemon spawn error: ${error?.message || error}`);
201
+ done();
202
+ });
203
+ const timer = setTimeout(done, 20_000);
204
+ timer.unref?.();
474
205
  });
475
206
  }
207
+
476
208
  function attachCancelledError() {
477
- const err = new Error('channels daemon attach superseded');
478
- err.daemonAttachCancelled = true;
479
- return err;
480
- }
481
- function discoveryMatchesHealth(discovery, health) {
482
- return Number(health?.pid) === Number(discovery?.pid);
209
+ const error = new Error('channel service attach superseded');
210
+ error.daemonAttachCancelled = true;
211
+ return error;
483
212
  }
213
+
484
214
  async function doAttach(discovery, generation) {
485
215
  let client = null;
486
216
  let fatalDuringAttach = false;
487
- client = await attachToDaemon({
217
+ client = await attachChannel({
488
218
  discovery,
489
219
  leadPid: daemonLeadPid,
490
220
  cwd,
491
- onNotify: (msg) => { try { onNotify?.(msg); } catch {} },
492
- // A stale/dead SSE endpoint drops this attach immediately. Do not revive
493
- // it after this worker has explicitly stopped; otherwise proactively
494
- // re-read discovery so notifies resume without waiting for the next call.
221
+ onNotify: (message) => { try { onNotify?.(message); } catch {} },
495
222
  onFatal: () => {
496
223
  fatalDuringAttach = true;
497
224
  invalidateDaemonClient('sse fatal', client);
@@ -501,17 +228,17 @@ export function createStandaloneChannelWorker({
501
228
  });
502
229
  if (stopRequested || generation !== attachGeneration || fatalDuringAttach) {
503
230
  await client.close('attach superseded');
231
+ const error = attachCancelledError();
504
232
  if (fatalDuringAttach && !stopRequested && generation === attachGeneration) {
505
- const err = attachCancelledError();
506
- err.daemonDiscoveryStale = true;
507
- throw err;
233
+ error.daemonDiscoveryStale = true;
508
234
  }
509
- throw attachCancelledError();
235
+ throw error;
510
236
  }
511
237
  daemonClient = client;
512
238
  daemonPid = discovery.pid;
513
239
  return client;
514
240
  }
241
+
515
242
  async function ensureDaemonAttached() {
516
243
  if (stopRequested) throw attachCancelledError();
517
244
  if (daemonClient) return daemonClient;
@@ -519,345 +246,95 @@ export function createStandaloneChannelWorker({
519
246
  const generation = attachGeneration;
520
247
  const promise = (async () => {
521
248
  const deadline = Date.now() + 30_000;
522
- const MAX_AUTH_REJECTIONS = 5;
523
249
  let authRejections = 0;
524
- const retryStaleDiscovery = async (err) => {
525
- if (!err?.daemonAuthRejected) {
526
- await daemonDelay(200);
527
- return;
528
- }
529
- authRejections++;
530
- if (authRejections >= MAX_AUTH_REJECTIONS || Date.now() >= deadline) {
531
- throw new Error(`channels daemon register rejected discovery auth ${authRejections} times`);
532
- }
533
- await daemonDelay(Math.min(200 * (2 ** (authRejections - 1)), 2000));
534
- };
535
- for (let attempt = 0; ; attempt++) {
250
+ for (let attempt = 0; ; attempt += 1) {
536
251
  if (stopRequested || generation !== attachGeneration) throw attachCancelledError();
537
- let discovery = readDaemonDiscovery(discoveryPath);
252
+ let discovery = discoverChannel();
538
253
  if (discovery) {
539
- const health = await probeDaemonHealth({ port: discovery.port, token: discovery.token, timeoutMs: attempt === 0 ? 800 : 2000 });
254
+ const health = await probeChannelHealth({
255
+ port: discovery.port,
256
+ token: discovery.token,
257
+ timeoutMs: attempt === 0 ? 800 : 2_000,
258
+ });
540
259
  if (stopRequested || generation !== attachGeneration) throw attachCancelledError();
541
- if (discoveryMatchesHealth(discovery, health)) {
260
+ if (Number(health?.pid) === Number(discovery.pid)) {
542
261
  try {
543
262
  return await doAttach(discovery, generation);
544
- } catch (err) {
545
- if (!err?.daemonDiscoveryStale) throw err;
546
- logLine(logPath, `daemon attach discovery stale: ${err.message}; re-reading`);
547
- await retryStaleDiscovery(err);
263
+ } catch (error) {
264
+ if (!error?.daemonDiscoveryStale) throw error;
265
+ if (error?.daemonAuthRejected) authRejections += 1;
266
+ if (authRejections >= 5 || Date.now() >= deadline) {
267
+ throw new Error('channel service repeatedly rejected discovery authentication');
268
+ }
269
+ await daemonDelay(Math.min(200 * (2 ** authRejections), 2_000));
548
270
  continue;
549
271
  }
550
272
  }
551
- discovery = null; // published but unhealthy → respawn
552
273
  }
553
- // No live daemon (absent / dead pid / unhealthy): spawn a candidate. A
554
- // race loser exits(0); the winner publishes discovery we then attach to.
555
274
  await spawnDaemonCandidate();
556
- const after = readDaemonDiscovery(discoveryPath);
557
- if (after) {
558
- const health = await probeDaemonHealth({ port: after.port, token: after.token, timeoutMs: 3000 });
559
- if (stopRequested || generation !== attachGeneration) throw attachCancelledError();
560
- if (discoveryMatchesHealth(after, health)) {
561
- try {
562
- return await doAttach(after, generation);
563
- } catch (err) {
564
- if (!err?.daemonDiscoveryStale) throw err;
565
- logLine(logPath, `daemon attach discovery stale: ${err.message}; re-reading`);
566
- await retryStaleDiscovery(err);
567
- continue;
275
+ discovery = discoverChannel();
276
+ if (discovery) {
277
+ const health = await probeChannelHealth({
278
+ port: discovery.port,
279
+ token: discovery.token,
280
+ timeoutMs: 3_000,
281
+ });
282
+ if (Number(health?.pid) === Number(discovery.pid)) {
283
+ try { return await doAttach(discovery, generation); }
284
+ catch (error) {
285
+ if (!error?.daemonDiscoveryStale) throw error;
568
286
  }
569
287
  }
570
288
  }
571
- if (Date.now() > deadline) throw new Error('channels daemon did not become ready');
289
+ if (Date.now() >= deadline) throw new Error('channel service did not become ready');
572
290
  await daemonDelay(200);
573
291
  }
574
292
  })();
575
293
  attachPromise = promise;
576
- try {
577
- return await promise;
578
- } finally {
579
- if (attachPromise === promise) attachPromise = null;
580
- }
294
+ try { return await promise; }
295
+ finally { if (attachPromise === promise) attachPromise = null; }
581
296
  }
582
- async function _startInProcess() {
583
- if (inProcessMod) return status();
584
- if (inProcessStartPromise) return inProcessStartPromise;
585
- inProcessStartPromise = (async () => {
586
- process.env.MIXDOG_ROOT = rootDir;
587
- process.env.MIXDOG_DATA_DIR = dataDir;
588
- process.env.MIXDOG_STANDALONE ??= '1';
589
- const mod = await import(pathToFileURL(entry).href);
590
- if (typeof mod?.start !== 'function') throw new Error('channels runtime does not export start()');
591
- await mod.start();
592
- inProcessMod = mod;
593
- return status();
594
- })().finally(() => {
595
- inProcessStartPromise = null;
596
- });
597
- return inProcessStartPromise;
297
+
298
+ function start() {
299
+ if (stopPromise) return stopPromise.then(() => start());
300
+ stopRequested = false;
301
+ startClientHeartbeat();
302
+ return ensureDaemonAttached().then(() => status());
598
303
  }
599
304
 
600
305
  async function execute(name, args = {}, { timeoutMs = 120_000 } = {}) {
601
306
  if (!CHANNEL_TOOLS.has(name)) throw new Error(`unknown channel tool: ${name}`);
602
307
  await start();
603
- if (!useProcessWorker) {
604
- const call = inProcessMod?.handleToolCallWithBridgeRetry || inProcessMod?.handleToolCall;
605
- if (typeof call !== 'function') throw new Error('channels runtime is not running');
606
- let timer = null;
607
- try {
608
- return await Promise.race([
609
- call(name, args || {}),
610
- new Promise((_, reject) => {
611
- timer = setTimeout(() => reject(new Error(`channels tool timed out: ${name}`)), timeoutMs);
612
- }),
613
- ]);
614
- } finally {
615
- if (timer) clearTimeout(timer);
616
- }
617
- }
618
- // Daemon path: dispatch over HTTP with bounded re-attach so a daemon
619
- // death/restart is transparent — a transport failure drops the stale attach
620
- // and ensureDaemonAttached re-reads discovery + respawns-if-dead.
621
- let lastDaemonErr = null;
622
- // One stable callId for this logical call, reused across retries so the
623
- // daemon dedups a retried transport failure to a single side-effect.
624
- const logicalCallId = `ch_${proxyId}_${nextCallId++}`;
625
- for (let attempt = 0; attempt < 3; attempt++) {
308
+ let lastError = null;
309
+ const callId = `ch_${proxyId}_${nextCallId++}`;
310
+ for (let attempt = 0; attempt < 3; attempt += 1) {
626
311
  const daemon = await ensureDaemonAttached();
627
312
  try {
628
- return await daemon.call(name, args || {}, { timeoutMs, callId: logicalCallId });
629
- } catch (err) {
630
- if (!err?.daemonTransportError) throw err; // tool error → surface as-is
631
- lastDaemonErr = err;
313
+ return await daemon.call(name, args || {}, { timeoutMs, callId });
314
+ } catch (error) {
315
+ if (!error?.daemonTransportError) throw error;
316
+ lastError = error;
632
317
  invalidateDaemonClient('transport failure', daemon);
633
318
  await daemonDelay(200 * (attempt + 1));
634
319
  }
635
320
  }
636
- throw lastDaemonErr || new Error('channels daemon call failed');
637
- if (!child || !child.send) throw new Error('channels worker is not running');
638
- const callId = `ch_${nextCallId++}`;
639
- return await new Promise((resolve, reject) => {
640
- const timer = setTimeout(() => {
641
- pending.delete(callId);
642
- reject(new Error(`channels tool timed out: ${name}`));
643
- }, timeoutMs);
644
- pending.set(callId, { resolve, reject, timer });
645
- sendToChild({ type: 'call', callId, name, args: args || {} }, (error) => {
646
- if (!error) return;
647
- pending.delete(callId);
648
- clearTimeout(timer);
649
- reject(error);
650
- });
651
- });
321
+ throw lastError || new Error('channel service call failed');
652
322
  }
653
323
 
654
- function forceKillTree(pid) {
655
- if (!pid) return;
656
- if (process.platform === 'win32') {
657
- execFile('taskkill.exe', ['/PID', String(pid), '/T', '/F'], { windowsHide: true }, () => {});
658
- return;
659
- }
660
- try { process.kill(pid, 'SIGKILL'); } catch {}
661
- }
662
-
663
- function forceKillTreeSync(pid) {
664
- if (!pid) return;
665
- if (process.platform === 'win32') {
666
- try {
667
- spawnSync('taskkill.exe', ['/PID', String(pid), '/T', '/F'], {
668
- stdio: 'ignore',
669
- windowsHide: true,
670
- });
671
- } catch {}
672
- return;
673
- }
674
- try { process.kill(pid, 'SIGKILL'); } catch {}
675
- }
676
-
677
- function installParentExitHook() {
678
- if (parentExitCleanup) return;
679
- parentExitCleanup = registerChannelWorkerExitCleanup(() => {
680
- parentExitCleanup = null;
681
- for (const pid of Array.from(ownedChildPids)) {
682
- forceKillTreeSync(pid);
683
- }
684
- ownedChildPids.clear();
685
- });
686
- }
687
-
688
- function uninstallParentExitHook() {
689
- if (!parentExitCleanup) return;
690
- // Refcount-aware: never strip the shared parent-exit protection while any
691
- // owned child PID is still tracked. A newer worker may have been spawned
692
- // (new PID added, hook install is a no-op because it is already present)
693
- // before an older worker finishes its async teardown; letting that old
694
- // teardown uninstall here would leave the live newer PID unprotected.
695
- if (ownedChildPids.size > 0) return;
696
- const unregister = parentExitCleanup;
697
- parentExitCleanup = null;
698
- unregister();
699
- }
700
-
701
- function unrefChildHandles(target) {
702
- try { target?.unref?.(); } catch {}
703
- try { target?.stderr?.unref?.(); } catch {}
704
- try { target?.stdout?.unref?.(); } catch {}
705
- try { target?.stdin?.unref?.(); } catch {}
706
- try { target?.channel?.unref?.(); } catch {}
707
- }
708
-
709
- function stop(reason = 'standalone shutdown', options = {}) {
710
- const waitForExit = options?.waitForExit !== false;
324
+ function stop(reason = 'standalone shutdown') {
711
325
  stopRequested = true;
712
326
  stopClientHeartbeat();
713
327
  if (stopPromise) return stopPromise;
714
- if (!useProcessWorker) {
715
- if (!inProcessMod && !inProcessStartPromise) {
716
- // Nothing running, but a lock may still be held if a prior boot claimed
717
- // then failed without clearing — release it so stop() never leaks it.
718
- if (inProcessOwned) { try { releaseSingletonOwner(daemonOwnerPath, process.pid); } catch {} inProcessOwned = false; }
719
- return Promise.resolve(false);
720
- }
721
- stopPromise = Promise.resolve(inProcessStartPromise)
722
- .catch(() => null)
723
- .then(async () => {
724
- try { await inProcessMod?.stop?.(reason); } catch {}
725
- inProcessMod = null;
726
- if (inProcessOwned) { try { releaseSingletonOwner(daemonOwnerPath, process.pid); } catch {} inProcessOwned = false; }
727
- return true;
728
- })
729
- .finally(() => {
730
- stopPromise = null;
731
- });
732
- return stopPromise;
733
- }
734
- // Daemon path: detach this TUI's client only. The shared daemon reaps
735
- // itself via client-grace once the last TUI leaves; never kill it here.
736
- if (useProcessWorker) {
737
- const inFlightAttach = attachPromise;
738
- attachGeneration++;
739
- const client = daemonClient;
740
- daemonClient = null;
741
- attachPromise = null;
742
- daemonPid = null;
743
- stopPromise = Promise.all([
744
- client ? client.close(reason).then(() => true).catch(() => true) : Promise.resolve(false),
745
- Promise.resolve(inFlightAttach).catch(() => null),
746
- ]).then(([detached]) => detached).finally(() => {
747
- stopPromise = null;
748
- });
749
- return stopPromise;
750
- }
751
- if (!child) {
752
- return Promise.resolve(false);
753
- }
754
- const target = child;
755
- const targetPid = target.pid;
756
- child = null;
757
- if (!waitForExit) {
758
- rejectPending(new Error(`channels runtime shutdown requested (${reason})`));
759
- // Fast/detached path is still TERMINAL: the caller does not block on the
760
- // full teardown, but a background escalation ladder guarantees the worker
761
- // dies so no zombie survives. Exit-hook + PID tracking stay installed
762
- // until the process ACTUALLY exits (not on IPC ack), so a parent that
763
- // force-exits mid-grace still force-kills the tree via the exit cleanup.
764
- let torn = false;
765
- const teardown = () => {
766
- if (torn) return;
767
- torn = true;
768
- clearTimeout(termTimer);
769
- clearTimeout(killTimer);
770
- if (targetPid) ownedChildPids.delete(targetPid);
771
- unrefChildHandles(target);
772
- uninstallParentExitHook();
773
- };
774
- // Bounded grace after IPC shutdown, then SIGTERM.
775
- const termTimer = setTimeout(() => {
776
- try {
777
- if (target.exitCode == null && !target.killed) target.kill('SIGTERM');
778
- } catch {}
779
- }, 1500);
780
- // Hard fallback: taskkill /T /F the whole tree, then SIGKILL the handle.
781
- const killTimer = setTimeout(() => {
782
- try {
783
- if (target.exitCode == null) forceKillTree(targetPid);
784
- } catch {}
785
- try {
786
- if (target.exitCode == null && !target.killed) target.kill('SIGKILL');
787
- } catch {}
788
- teardown();
789
- }, 3000);
790
- // Unref so these background escalation timers never keep the event loop
791
- // (or a pending /exit) alive waiting out the grace/hard-kill window.
792
- termTimer.unref?.();
793
- killTimer.unref?.();
794
- // Actual exit (or spawn error) is the only thing that tears down tracking.
795
- target.once('exit', teardown);
796
- target.once('error', teardown);
797
- stopPromise = new Promise((resolve) => {
798
- let settled = false;
799
- const settle = (ok) => {
800
- if (settled) return;
801
- settled = true;
802
- clearTimeout(sendTimer);
803
- stopPromise = null;
804
- resolve(ok);
805
- };
806
- // Resolve the caller quickly once the IPC shutdown is acked/timed out;
807
- // the escalation ladder above keeps running in the background.
808
- const sendTimer = setTimeout(() => settle(true), 250);
809
- sendTimer.unref?.();
810
- target.once('exit', () => settle(true));
811
- safeIpcSend(target, { type: 'shutdown', reason }, {
812
- onComplete: (error) => {
813
- try { target.disconnect?.(); } catch {}
814
- if (!error) {
815
- settle(true);
816
- return;
817
- }
818
- // IPC unavailable: skip the grace window and escalate now.
819
- try {
820
- if (target.exitCode == null && !target.killed) target.kill('SIGTERM');
821
- } catch {}
822
- settle(false);
823
- },
824
- });
825
- });
826
- return stopPromise;
827
- }
828
- stopPromise = new Promise((resolve) => {
829
- let settled = false;
830
- const finish = (ok) => {
831
- if (settled) return;
832
- settled = true;
833
- clearTimeout(termTimer);
834
- clearTimeout(killTimer);
835
- stopPromise = null;
836
- uninstallParentExitHook();
837
- resolve(ok);
838
- };
839
- const termTimer = setTimeout(() => {
840
- try {
841
- if (target.exitCode == null && !target.killed) target.kill('SIGTERM');
842
- } catch {}
843
- }, 1500);
844
- const killTimer = setTimeout(() => {
845
- try {
846
- if (target.exitCode == null) forceKillTree(targetPid);
847
- } catch {}
848
- try {
849
- if (target.exitCode == null && !target.killed) target.kill('SIGKILL');
850
- } catch {}
851
- finish(false);
852
- }, 5000);
853
- target.once('exit', () => finish(true));
854
- target.once('error', () => finish(false));
855
- safeIpcSend(target, { type: 'shutdown', reason }, {
856
- onComplete: () => {
857
- try { target.disconnect?.(); } catch {}
858
- },
859
- });
860
- });
328
+ const inFlightAttach = attachPromise;
329
+ attachGeneration += 1;
330
+ const client = daemonClient;
331
+ daemonClient = null;
332
+ attachPromise = null;
333
+ daemonPid = null;
334
+ stopPromise = Promise.all([
335
+ client ? client.close(reason).then(() => true).catch(() => true) : Promise.resolve(false),
336
+ Promise.resolve(inFlightAttach).catch(() => null),
337
+ ]).then(([detached]) => detached).finally(() => { stopPromise = null; });
861
338
  return stopPromise;
862
339
  }
863
340