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
@@ -0,0 +1,198 @@
1
+ import { createFairCallScheduler } from './fair-call-scheduler.mjs';
2
+
3
+ const MEMORY_AGENTS = Object.freeze([
4
+ 'cycle1-agent',
5
+ 'cycle2-agent',
6
+ 'cycle3-agent',
7
+ ]);
8
+ const MEMORY_AGENT_SET = new Set(MEMORY_AGENTS);
9
+
10
+ function abortError(signal) {
11
+ return signal?.reason instanceof Error
12
+ ? signal.reason
13
+ : new Error(String(signal?.reason || 'agent dispatch canceled'));
14
+ }
15
+
16
+ function awaitSharedPreparation(promise, signal) {
17
+ if (!signal) return promise;
18
+ if (signal.aborted) return Promise.reject(abortError(signal));
19
+ let listener = null;
20
+ const aborted = new Promise((_, reject) => {
21
+ listener = () => reject(abortError(signal));
22
+ signal.addEventListener('abort', listener, { once: true });
23
+ });
24
+ return Promise.race([promise, aborted]).finally(() => {
25
+ if (listener) {
26
+ try { signal.removeEventListener('abort', listener); } catch {}
27
+ }
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Process-singleton memory LLM broker.
33
+ *
34
+ * The daemon owns one provider/session/memory graph. Memory
35
+ * process keeps PG, embeddings, and recall, and forwards only cycle LLM calls
36
+ * here. A singleton is NOT a serial lane: every call gets its own dispatch
37
+ * session + AbortController and the fair scheduler starts calls in parallel
38
+ * unless an operator explicitly configures a finite active limit.
39
+ */
40
+ export function createAgentDispatchBroker({
41
+ loadConfig,
42
+ initProviders,
43
+ makeAgentDispatch,
44
+ log = () => {},
45
+ activeMax = Infinity,
46
+ queueMax = 256,
47
+ onActivityChanged = null,
48
+ } = {}) {
49
+ if (typeof loadConfig !== 'function') throw new TypeError('loadConfig is required');
50
+ if (typeof initProviders !== 'function') throw new TypeError('initProviders is required');
51
+ if (typeof makeAgentDispatch !== 'function') throw new TypeError('makeAgentDispatch is required');
52
+
53
+ const scheduler = createFairCallScheduler({
54
+ name: 'memory agent dispatch',
55
+ activeMax,
56
+ queueMax,
57
+ minOwnerQueue: Math.max(8, Math.floor(queueMax / 16)),
58
+ });
59
+ const inFlight = new Map();
60
+ const dispatchers = new Map(MEMORY_AGENTS.map((agent) => [
61
+ agent,
62
+ makeAgentDispatch({
63
+ agent,
64
+ taskType: 'maintenance',
65
+ sourceType: 'memory-cycle',
66
+ brief: false,
67
+ }),
68
+ ]));
69
+ let nextCallId = 0;
70
+ let closed = false;
71
+ let preparedProviderSignature = null;
72
+ let providerPreparePromise = null;
73
+
74
+ async function prepareProviders(signal = null) {
75
+ const config = loadConfig();
76
+ const providers = config?.providers || {};
77
+ let signature = null;
78
+ try { signature = JSON.stringify(providers); } catch {}
79
+ if (signature !== null && preparedProviderSignature === signature) return;
80
+ if (providerPreparePromise) {
81
+ await awaitSharedPreparation(providerPreparePromise, signal);
82
+ if (signature !== null && preparedProviderSignature === signature) return;
83
+ return prepareProviders(signal);
84
+ }
85
+ const pending = Promise.resolve()
86
+ // Process-global preparation is shared by every request. One caller's
87
+ // cancellation may stop waiting but must never abort initialization
88
+ // underneath independent calls.
89
+ .then(() => initProviders(providers))
90
+ .then(() => {
91
+ preparedProviderSignature = signature;
92
+ });
93
+ const tracked = pending.finally(() => {
94
+ if (providerPreparePromise === tracked) providerPreparePromise = null;
95
+ });
96
+ providerPreparePromise = tracked;
97
+ await awaitSharedPreparation(tracked, signal);
98
+ }
99
+
100
+ async function warmup() {
101
+ if (closed) throw new Error('memory agent dispatch broker is closed');
102
+ await prepareProviders();
103
+ return true;
104
+ }
105
+
106
+ function dispatch(params = {}, { callId = null, signal = null } = {}) {
107
+ if (closed) return Promise.reject(new Error('memory agent dispatch broker is closed'));
108
+ const agent = String(params.agent || '').trim();
109
+ if (!MEMORY_AGENT_SET.has(agent)) {
110
+ return Promise.reject(new Error(`memory agent dispatch denied for "${agent || 'unknown'}"`));
111
+ }
112
+ const prompt = String(params.prompt ?? '');
113
+ if (!prompt) return Promise.reject(new Error(`agent dispatch prompt required for "${agent}"`));
114
+
115
+ const id = String(callId || `broker-${process.pid}-${++nextCallId}`);
116
+ const existing = inFlight.get(id);
117
+ if (existing) return existing.promise;
118
+
119
+ const controller = new AbortController();
120
+ const abortFromCaller = () => {
121
+ if (controller.signal.aborted) return;
122
+ try { controller.abort(signal?.reason); } catch { try { controller.abort(); } catch {} }
123
+ };
124
+ if (signal?.aborted) abortFromCaller();
125
+ else signal?.addEventListener?.('abort', abortFromCaller, { once: true });
126
+
127
+ const run = async () => {
128
+ if (controller.signal.aborted) throw abortError(controller.signal);
129
+ await prepareProviders(controller.signal);
130
+ if (controller.signal.aborted) throw abortError(controller.signal);
131
+ const timeout = Number(params.timeout);
132
+ return dispatchers.get(agent)({
133
+ prompt,
134
+ preset: params.preset || undefined,
135
+ cwd: typeof params.cwd === 'string' && params.cwd ? params.cwd : undefined,
136
+ parentSignal: controller.signal,
137
+ ...(Number.isFinite(timeout) && timeout > 0 ? { idleTimeoutMs: timeout } : {}),
138
+ });
139
+ };
140
+ const promise = scheduler.enqueue(`memory:${agent}`, run, {
141
+ signal: controller.signal,
142
+ });
143
+ let record = null;
144
+ const tracked = promise.finally(() => {
145
+ signal?.removeEventListener?.('abort', abortFromCaller);
146
+ if (inFlight.get(id) === record) inFlight.delete(id);
147
+ try { onActivityChanged?.(snapshot()); } catch {}
148
+ });
149
+ record = { controller, promise: tracked };
150
+ inFlight.set(id, record);
151
+ try { onActivityChanged?.(snapshot()); } catch {}
152
+ return tracked;
153
+ }
154
+
155
+ function cancel(callId, reason = 'memory agent dispatch canceled') {
156
+ const id = String(callId || '');
157
+ const record = inFlight.get(id);
158
+ if (!record) return false;
159
+ try { record.controller.abort(new Error(reason)); } catch { try { record.controller.abort(); } catch {} }
160
+ return true;
161
+ }
162
+
163
+ async function cancelAndWait(callId, reason = 'memory agent dispatch canceled') {
164
+ const id = String(callId || '');
165
+ const record = inFlight.get(id);
166
+ if (!record) return false;
167
+ cancel(id, reason);
168
+ try { await record.promise; } catch {}
169
+ return true;
170
+ }
171
+
172
+ function snapshot() {
173
+ return {
174
+ ...scheduler.snapshot(),
175
+ inFlight: inFlight.size,
176
+ dispatchers: dispatchers.size,
177
+ };
178
+ }
179
+
180
+ function close(reason = 'memory agent dispatch broker is closed') {
181
+ if (closed) return;
182
+ closed = true;
183
+ for (const record of inFlight.values()) {
184
+ try { record.controller.abort(new Error(reason)); } catch {}
185
+ }
186
+ scheduler.close(reason);
187
+ log(`agent dispatch broker closed (${reason})`);
188
+ }
189
+
190
+ return {
191
+ warmup,
192
+ dispatch,
193
+ cancel,
194
+ cancelAndWait,
195
+ snapshot,
196
+ close,
197
+ };
198
+ }
@@ -151,8 +151,8 @@ export function synthesizePreset(config, key) {
151
151
  };
152
152
  }
153
153
 
154
- export function normalizeAgentRoute(routeLike, fallbackProvider = '') {
155
- const provider = clean(routeLike?.provider) || clean(fallbackProvider);
154
+ export function normalizeAgentRoute(routeLike) {
155
+ const provider = clean(routeLike?.provider);
156
156
  const model = clean(routeLike?.model);
157
157
  if (!provider || !model) return null;
158
158
  return {
@@ -76,6 +76,8 @@ export function createSpawnFlow({
76
76
  const lease = await resourceAdmission.acquire('agent', {
77
77
  signal: admissionController.signal,
78
78
  label: jobMeta?.tag || type,
79
+ ownerKey: notifyContext?.callerSessionId || notifyContext?.sessionId
80
+ || clientHostPid || null,
79
81
  });
80
82
  try {
81
83
  // Yield one macrotask before doing agent work. startBackgroundTask uses
@@ -1,8 +1,4 @@
1
- import { isKnownProvider } from '../provider-admin.mjs';
2
- import {
3
- DEFAULT_AGENT_PRESETS,
4
- DEFAULT_PROVIDER,
5
- } from './tool-def.mjs';
1
+ import { configuredAgentRouteCandidates } from '../../runtime/shared/agent-route-config.mjs';
6
2
  import {
7
3
  agentPresetName,
8
4
  clean,
@@ -30,19 +26,10 @@ export function resolveAgentSpawnPreset(config, args = {}) {
30
26
  }
31
27
 
32
28
  const agentName = normalizeAgentName(args.agent);
33
- const configuredDefault = clean(config?.defaultProvider);
34
- const fallbackProvider = configuredDefault && isKnownProvider(configuredDefault)
35
- ? configuredDefault
36
- : DEFAULT_PROVIDER;
37
- const workflowSlot = agentName === 'explore' ? 'explorer'
38
- : (agentName === 'maintainer' ? 'memory' : '');
39
- const maintenanceSlot = agentName === 'explore' ? 'explore'
40
- : (agentName === 'maintainer' ? 'memory' : '');
41
29
  const agentRoute = !clean(args.preset)
42
- ? (normalizeAgentRoute(config?.agents?.[agentName], fallbackProvider)
43
- || (agentName === 'maintainer' ? normalizeAgentRoute(config?.agents?.maintenance, fallbackProvider) : null)
44
- || normalizeAgentRoute(config?.workflowRoutes?.[workflowSlot], fallbackProvider)
45
- || normalizeAgentRoute(config?.maintenance?.[maintenanceSlot], fallbackProvider))
30
+ ? configuredAgentRouteCandidates(config, agentName)
31
+ .map((candidate) => normalizeAgentRoute(candidate))
32
+ .find(Boolean) || null
46
33
  : null;
47
34
  if (agentRoute) {
48
35
  return {
@@ -60,14 +47,17 @@ export function resolveAgentSpawnPreset(config, args = {}) {
60
47
  };
61
48
  }
62
49
 
63
- const mainPreset = !clean(args.preset) && (agentName === 'explore' || agentName === 'maintainer')
50
+ const mainPreset = !clean(args.preset)
64
51
  ? findPreset(config, config?.default)
65
52
  : null;
66
- if (mainPreset) return { presetName: mainPreset.id || mainPreset.name, preset: mainPreset };
53
+ if (normalizeAgentRoute(mainPreset)) {
54
+ return { presetName: mainPreset.id || mainPreset.name, preset: mainPreset };
55
+ }
67
56
 
68
- const presetName = clean(args.preset) || DEFAULT_AGENT_PRESETS[agentName];
69
- if (!presetName) throw new Error(`agent: agent "${agentName}" has no model assignment`);
57
+ const presetName = clean(args.preset);
58
+ if (!presetName) throw new Error(`agent: agent "${agentName}" has no Main model assignment`);
70
59
  const preset = findPreset(config, presetName) || synthesizePreset(config, presetName);
71
60
  if (!preset) throw new Error(`agent: preset "${presetName}" not found`);
61
+ if (!normalizeAgentRoute(preset)) throw new Error(`agent: preset "${presetName}" has no complete route`);
72
62
  return { presetName, preset };
73
63
  }
@@ -6,20 +6,6 @@ export const PRESET_ALIASES = new Map([
6
6
  ['opus-xhigh', { base: 'opus-high', effort: 'xhigh', id: 'opus-xhigh', name: 'OPUS XHIGH' }],
7
7
  ]);
8
8
 
9
- export const DEFAULT_AGENT_PRESETS = Object.freeze({
10
- explore: 'sonnet-high',
11
- maintainer: 'haiku',
12
- worker: 'sonnet-high',
13
- 'heavy-worker': 'sonnet-high',
14
- reviewer: 'opus-xhigh',
15
- debugger: 'opus-xhigh',
16
- });
17
-
18
- // Mirrors DEFAULT_PROVIDER in mixdog-session-runtime.mjs. Used only as the
19
- // last-resort fallback when a stored agent route omits its provider and the
20
- // config carries no defaultProvider.
21
- export const DEFAULT_PROVIDER = 'anthropic-oauth';
22
-
23
9
  export const WORKER_INDEX_FILE = 'agent-workers.json';
24
10
 
25
11
  export const ACTIVE_STAGES = new Set(['connecting', 'requesting', 'streaming', 'tool_running', 'running', 'cancelling']);
@@ -11,6 +11,7 @@ import {
11
11
  import { basename, dirname, join } from 'node:path';
12
12
  import {
13
13
  SECRET_ACCOUNTS,
14
+ canonicalizeStoredChannelsConfig,
14
15
  deleteSecret,
15
16
  diagnoseDiscordTokenValue,
16
17
  getDiscordToken,
@@ -43,10 +44,10 @@ import { readHookPublicBase } from '../runtime/channels/lib/webhook/relay-tunnel
43
44
 
44
45
  const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
45
46
  const DEFAULT_CHANNELS = Object.freeze({
46
- backend: 'discord',
47
+ provider: 'discord',
47
48
  discord: {},
48
49
  access: { dmPolicy: 'allowlist', allowFrom: [], channels: {} },
49
- channel: { channelId: '', discordChannelId: '', telegramChatId: '' },
50
+ channel: { discordChannelId: '', telegramChatId: '' },
50
51
  webhook: { enabled: true, port: 3333 },
51
52
  });
52
53
 
@@ -91,36 +92,28 @@ function assertScheduleName(name) {
91
92
  }
92
93
 
93
94
  function normalizeChannelsConfig(raw = {}) {
95
+ const stored = canonicalizeStoredChannelsConfig(raw);
94
96
  return {
95
97
  ...DEFAULT_CHANNELS,
96
- ...(raw && typeof raw === 'object' ? raw : {}),
97
- discord: { ...DEFAULT_CHANNELS.discord, ...(raw?.discord || {}) },
98
- access: { ...DEFAULT_CHANNELS.access, ...(raw?.access || {}) },
99
- channel: { ...DEFAULT_CHANNELS.channel, ...(raw?.channel || {}) },
100
- webhook: { ...DEFAULT_CHANNELS.webhook, ...(raw?.webhook || {}) },
98
+ ...stored,
99
+ discord: { ...DEFAULT_CHANNELS.discord, ...(stored.discord || {}) },
100
+ access: { ...DEFAULT_CHANNELS.access, ...(stored.access || {}) },
101
+ channel: { ...DEFAULT_CHANNELS.channel, ...(stored.channel || {}) },
102
+ webhook: { ...DEFAULT_CHANNELS.webhook, ...(stored.webhook || {}) },
101
103
  };
102
104
  }
103
105
 
104
- function channelIdForBackend(entry = {}, backend = 'discord') {
105
- if (backend === 'telegram') {
106
- return String(entry?.telegramChatId || (entry?.discordChannelId ? '' : entry?.channelId) || '');
106
+ function channelIdForProvider(entry = {}, provider = 'discord') {
107
+ if (provider === 'telegram') {
108
+ return String(entry?.telegramChatId || '');
107
109
  }
108
- return String(entry?.discordChannelId || (entry?.telegramChatId ? '' : entry?.channelId) || '');
109
- }
110
-
111
- function seedBackendChannelIds(entry = {}, backend = 'discord') {
112
- const next = { ...(entry || {}) };
113
- if (next.channelId) {
114
- if (backend === 'telegram' && !next.telegramChatId && !next.discordChannelId) next.telegramChatId = next.channelId;
115
- if (backend !== 'telegram' && !next.discordChannelId && !next.telegramChatId) next.discordChannelId = next.channelId;
116
- }
117
- return next;
110
+ return String(entry?.discordChannelId || '');
118
111
  }
119
112
 
120
113
  // Resolve the single-channel entry from the config's `channel` object.
121
114
  function resolveChannelEntry(cfg = {}) {
122
115
  if (cfg.channel && typeof cfg.channel === 'object'
123
- && (cfg.channel.channelId || cfg.channel.discordChannelId || cfg.channel.telegramChatId)) {
116
+ && (cfg.channel.discordChannelId || cfg.channel.telegramChatId)) {
124
117
  return cfg.channel;
125
118
  }
126
119
  return cfg.channel && typeof cfg.channel === 'object' ? cfg.channel : {};
@@ -138,7 +131,7 @@ function updateChannelsSection(build) {
138
131
  }
139
132
 
140
133
  // Async twin of updateChannelsSection: identical normalize/strip logic, but the
141
- // channels-section RMW runs through updateSectionAsync so a debounced backend
134
+ // channels-section RMW runs through updateSectionAsync so a debounced provider
142
135
  // flush does not block the event loop. Same config lock file → linearizable
143
136
  // with the sync writers.
144
137
  async function updateChannelsSectionAsync(build) {
@@ -166,10 +159,10 @@ function listEntryDirs(dir) {
166
159
  // settings/TUI layer consumes.
167
160
  function getChannel(config = {}) {
168
161
  const cfg = normalizeChannelsConfig(config);
169
- const backend = cfg.backend === 'telegram' ? 'telegram' : 'discord';
162
+ const provider = cfg.provider === 'telegram' ? 'telegram' : 'discord';
170
163
  const entry = resolveChannelEntry(cfg);
171
164
  return {
172
- channelId: channelIdForBackend(entry, backend),
165
+ channelId: channelIdForProvider(entry, provider),
173
166
  discordChannelId: String(entry?.discordChannelId || ''),
174
167
  telegramChatId: String(entry?.telegramChatId || ''),
175
168
  };
@@ -199,39 +192,38 @@ export function forgetTelegramToken() {
199
192
  return { ok: true };
200
193
  }
201
194
 
202
- // Single-channel write: persists `cfg.channel`, keeping the per-backend id
203
- // fields (discordChannelId/telegramChatId) so a backend switch retains both
204
- // ids. `backend` selects which per-backend field the id updates; when omitted
205
- // the id lands on the active backend's field.
206
- export function setChannel({ channelId, backend = null } = {}) {
195
+ // Single-channel write: persists `cfg.channel`, keeping the per-provider id
196
+ // fields (discordChannelId/telegramChatId) so a provider switch retains both
197
+ // ids. `provider` selects which per-provider field the id updates; when omitted
198
+ // the id lands on the active provider's field.
199
+ export function setChannel({ channelId, provider = null } = {}) {
207
200
  const id = String(channelId || '').trim();
208
201
  if (!id) throw new Error('channelId is required');
209
- const targetBackend = backend === 'telegram' || backend === 'discord' ? backend : null;
202
+ const targetProvider = provider === 'telegram' || provider === 'discord' ? provider : null;
210
203
  return updateChannelsSection((cfg) => {
211
- const activeBackend = cfg.backend === 'telegram' ? 'telegram' : 'discord';
212
- const writeBackend = targetBackend || activeBackend;
213
- const current = seedBackendChannelIds(resolveChannelEntry(cfg), activeBackend);
204
+ const activeProvider = cfg.provider === 'telegram' ? 'telegram' : 'discord';
205
+ const writeProvider = targetProvider || activeProvider;
206
+ const current = resolveChannelEntry(cfg);
214
207
  const nextEntry = { ...current };
215
- if (writeBackend === 'telegram') nextEntry.telegramChatId = id;
208
+ if (writeProvider === 'telegram') nextEntry.telegramChatId = id;
216
209
  else nextEntry.discordChannelId = id;
217
- // `channelId` mirrors the active backend's id for legacy readers.
218
- nextEntry.channelId = channelIdForBackend(nextEntry, activeBackend);
210
+ delete nextEntry.channelId;
219
211
  return { ...cfg, channel: nextEntry };
220
212
  });
221
213
  }
222
214
 
223
- export async function setChannelAsync({ channelId, backend = null } = {}) {
215
+ export async function setChannelAsync({ channelId, provider = null } = {}) {
224
216
  const id = String(channelId || '').trim();
225
217
  if (!id) throw new Error('channelId is required');
226
- const targetBackend = backend === 'telegram' || backend === 'discord' ? backend : null;
218
+ const targetProvider = provider === 'telegram' || provider === 'discord' ? provider : null;
227
219
  return updateChannelsSectionAsync((cfg) => {
228
- const activeBackend = cfg.backend === 'telegram' ? 'telegram' : 'discord';
229
- const writeBackend = targetBackend || activeBackend;
230
- const current = seedBackendChannelIds(resolveChannelEntry(cfg), activeBackend);
220
+ const activeProvider = cfg.provider === 'telegram' ? 'telegram' : 'discord';
221
+ const writeProvider = targetProvider || activeProvider;
222
+ const current = resolveChannelEntry(cfg);
231
223
  const nextEntry = { ...current };
232
- if (writeBackend === 'telegram') nextEntry.telegramChatId = id;
224
+ if (writeProvider === 'telegram') nextEntry.telegramChatId = id;
233
225
  else nextEntry.discordChannelId = id;
234
- nextEntry.channelId = channelIdForBackend(nextEntry, activeBackend);
226
+ delete nextEntry.channelId;
235
227
  return { ...cfg, channel: nextEntry };
236
228
  });
237
229
  }
@@ -260,33 +252,30 @@ export async function setWebhookConfigAsync(patch = {}) {
260
252
  }));
261
253
  }
262
254
 
263
- function validateBackend(name) {
255
+ function validateProvider(name) {
264
256
  const value = String(name || '').trim();
265
257
  if (value !== 'discord' && value !== 'telegram') {
266
- throw new Error('backend must be discord or telegram');
258
+ throw new Error('provider must be discord or telegram');
267
259
  }
268
260
  return value;
269
261
  }
270
- function backendBuilder(value) {
262
+ function providerBuilder(value) {
271
263
  return (cfg) => {
272
- const activeBackend = cfg.backend === 'telegram' ? 'telegram' : 'discord';
273
- const seeded = seedBackendChannelIds(resolveChannelEntry(cfg), activeBackend);
274
- // `channelId` now mirrors the newly-selected backend's id; both per-backend
275
- // id fields are retained so switching back keeps the other id.
276
- const channel = { ...seeded, channelId: channelIdForBackend(seeded, value) };
277
- return { ...cfg, channel, backend: value };
264
+ const channel = { ...resolveChannelEntry(cfg) };
265
+ delete channel.channelId;
266
+ return { ...cfg, channel, provider: value };
278
267
  };
279
268
  }
280
- export function setBackend(name) {
281
- const value = validateBackend(name);
282
- updateChannelsSection(backendBuilder(value));
283
- return { ok: true, backend: value };
269
+ export function setChannelProvider(name) {
270
+ const value = validateProvider(name);
271
+ updateChannelsSection(providerBuilder(value));
272
+ return { ok: true, provider: value };
284
273
  }
285
- // Async twin used by the debounced backend-save flush timer.
286
- export async function setBackendAsync(name) {
287
- const value = validateBackend(name);
288
- await updateChannelsSectionAsync(backendBuilder(value));
289
- return { ok: true, backend: value };
274
+ // Async twin used by the debounced provider-save flush timer.
275
+ export async function setChannelProviderAsync(name) {
276
+ const value = validateProvider(name);
277
+ await updateChannelsSectionAsync(providerBuilder(value));
278
+ return { ok: true, provider: value };
290
279
  }
291
280
 
292
281
  function normalizeCron(time) {
@@ -578,16 +567,16 @@ export async function channelSetup(config = null) {
578
567
  const discordProblem = diagnoseDiscordTokenValue(discordToken, cfg);
579
568
  const telegramToken = getTelegramToken();
580
569
  return {
581
- backend: cfg.backend || 'discord',
570
+ provider: cfg.provider || 'discord',
582
571
  discord: {
583
- backend: 'discord',
572
+ provider: 'discord',
584
573
  authenticated: Boolean(discordToken && !discordProblem),
585
574
  stored: hasStoredSecret(SECRET_ACCOUNTS.discordToken),
586
575
  status: discordToken ? (discordProblem ? 'Invalid' : 'Set') : 'Off',
587
576
  problem: discordProblem || null,
588
577
  },
589
578
  telegram: {
590
- backend: 'telegram',
579
+ provider: 'telegram',
591
580
  authenticated: Boolean(telegramToken),
592
581
  stored: hasStoredSecret(SECRET_ACCOUNTS.telegramToken),
593
582
  status: telegramToken ? 'Set' : 'Off',
@@ -1,7 +1,7 @@
1
- // Machine-global channels daemon — attach client (TUI side).
1
+ // Machine-global channel service — attach client (TUI side).
2
2
  //
3
3
  // Mirrors the memory proxy's attach pattern (POST /client/register + /health,
4
- // 127.0.0.1 only). A TUI uses this to talk to the ONE shared channels daemon
4
+ // 127.0.0.1 only). A TUI uses this to talk to the shared channel front door
5
5
  // instead of forking its own worker: tool calls go over POST /call and the
6
6
  // worker->parent notify path arrives on a persistent SSE stream (GET /events),
7
7
  // replacing the old node-IPC `{type:'notify'}` messages.
@@ -28,18 +28,22 @@ function isPidAlive(pid) {
28
28
 
29
29
  // Read + validate the discovery file. Returns null when missing/corrupt or when
30
30
  // the recorded pid is dead (stale daemon) so the caller reclaims + respawns.
31
- export function readDaemonDiscovery(discoveryPath) {
31
+ export function readChannelDiscovery(discoveryPath) {
32
32
  let raw;
33
33
  try { raw = JSON.parse(readFileSync(discoveryPath, 'utf8')); }
34
34
  catch { return null; }
35
- const port = parsePort(raw?.port);
35
+ const endpoint = raw?.endpoints?.channel;
36
+ const port = parsePort(endpoint?.port);
36
37
  const pid = parsePid(raw?.pid);
37
- if (!port || !pid || !raw?.token) return null;
38
+ if (!port || !pid || !endpoint?.token) return null;
38
39
  if (!isPidAlive(pid)) return null; // dead daemon → treat as absent
39
- return { port, pid, token: String(raw.token) };
40
+ return { port, pid, token: String(endpoint.token) };
40
41
  }
41
42
 
42
- function request({ port, method = 'GET', path = '/', token, body = null, timeoutMs = 10_000 }) {
43
+ function request({
44
+ port, method = 'GET', path = '/', token, body = null, timeoutMs = 10_000,
45
+ agent = undefined,
46
+ }) {
43
47
  return new Promise((resolve, reject) => {
44
48
  const payload = body == null ? null : JSON.stringify(body);
45
49
  let req = null;
@@ -69,6 +73,7 @@ function request({ port, method = 'GET', path = '/', token, body = null, timeout
69
73
  port,
70
74
  path,
71
75
  method,
76
+ agent,
72
77
  headers: {
73
78
  ...(token ? { 'X-Mixdog-Daemon-Token': token } : {}),
74
79
  ...(payload
@@ -107,7 +112,7 @@ function request({ port, method = 'GET', path = '/', token, body = null, timeout
107
112
  });
108
113
  }
109
114
 
110
- export async function probeDaemonHealth({ port, token, timeoutMs = 800 } = {}) {
115
+ export async function probeChannelHealth({ port, token, timeoutMs = 800 } = {}) {
111
116
  try {
112
117
  const health = await request({ port, token, path: '/health', timeoutMs });
113
118
  return health?.status === 'ok' ? health : null;
@@ -119,7 +124,7 @@ export async function probeDaemonHealth({ port, token, timeoutMs = 800 } = {}) {
119
124
  // dispatches channel tools over HTTP. onNotify receives the SAME
120
125
  // `{type:'notify', method, params}` shape the old IPC path delivered, so the
121
126
  // TUI-side onNotify handler stays unchanged (thin glue).
122
- export async function attachToDaemon({
127
+ export async function attachChannel({
123
128
  discovery,
124
129
  leadPid = process.pid,
125
130
  cwd = process.cwd(),
@@ -130,6 +135,12 @@ export async function attachToDaemon({
130
135
  const expectedPid = parsePid(discovery?.pid);
131
136
  if (!discovery?.port || !discovery?.token || !expectedPid) throw new Error('daemon discovery {port, pid, token} required');
132
137
  const { port, token: serverToken } = discovery;
138
+ const callAgent = new http.Agent({
139
+ keepAlive: true, keepAliveMsecs: 5_000, maxSockets: 16, maxFreeSockets: 4,
140
+ });
141
+ const controlAgent = new http.Agent({
142
+ keepAlive: true, keepAliveMsecs: 5_000, maxSockets: 4, maxFreeSockets: 2,
143
+ });
133
144
  const staleDiscoveryError = (reason) => {
134
145
  const err = new Error(reason);
135
146
  err.daemonDiscoveryStale = true;
@@ -137,7 +148,7 @@ export async function attachToDaemon({
137
148
  };
138
149
  const isExpectedDaemon = (health) => Number(health?.pid) === expectedPid;
139
150
 
140
- const initialHealth = await probeDaemonHealth({ port, token: serverToken, timeoutMs: 800 });
151
+ const initialHealth = await probeChannelHealth({ port, token: serverToken, timeoutMs: 800 });
141
152
  if (!isExpectedDaemon(initialHealth)) throw staleDiscoveryError('daemon discovery pid does not match health');
142
153
 
143
154
  let reg;
@@ -147,12 +158,11 @@ export async function attachToDaemon({
147
158
  token: serverToken,
148
159
  method: 'POST',
149
160
  path: '/client/register',
150
- // Initial attachment is transport-only. Mark it with the legacy
151
- // non-stealing registration intent as well as relying on the current
152
- // daemon contract, so a newly updated desktop cannot supersede a
153
- // terminal still served by an older machine-global daemon.
154
- body: { leadPid, cwd, reattach: true },
161
+ // Channel attachment observes the current owner and never claims the
162
+ // manual routing seat merely by opening the transport.
163
+ body: { leadPid, cwd, passive: true },
155
164
  timeoutMs: 3000,
165
+ agent: controlAgent,
156
166
  });
157
167
  } catch (err) {
158
168
  if (err?.statusCode === 401 || err?.statusCode === 403) {
@@ -193,6 +203,7 @@ export async function attachToDaemon({
193
203
  ...(registrationId ? { registrationId, replaceToken, leadPid, cwd } : {}),
194
204
  },
195
205
  timeoutMs: 1500,
206
+ agent: controlAgent,
196
207
  });
197
208
  } catch { /* best-effort; daemon sweep reaps us */ }
198
209
  }
@@ -219,7 +230,7 @@ export async function attachToDaemon({
219
230
  function handleStreamLoss(reason) {
220
231
  if (closed || fatal || reconnectTimer || reconnectProbe) return;
221
232
  reconnectProbe = true;
222
- void probeDaemonHealth({ port, token: serverToken, timeoutMs: 800 }).then((health) => {
233
+ void probeChannelHealth({ port, token: serverToken, timeoutMs: 800 }).then((health) => {
223
234
  reconnectProbe = false;
224
235
  if (closed || fatal) return;
225
236
  if (!isExpectedDaemon(health)) {
@@ -310,17 +321,17 @@ export async function attachToDaemon({
310
321
  // Re-register (the daemon may have pruned us) then reopen the stream.
311
322
  // Re-register mints a fresh client token (the old one was pruned); adopt
312
323
  // it so the reopened stream and subsequent calls target the live entry.
313
- // reattach:true — a reconnect is NOT a new terminal, so it must NOT steal
314
- // the ownership seat (last-wins is reserved for genuine fresh registers).
324
+ // A reconnect is passive and must not claim the ownership seat.
315
325
  const generation = lifecycle;
316
326
  const registrationId = reconnectRegistrationId ||= randomUUID();
317
327
  const replaceToken = reconnectReplaceToken ||= clientToken;
318
328
  const registration = request({
319
329
  port, token: serverToken, method: 'POST', path: '/client/register',
320
330
  body: {
321
- leadPid, cwd, reattach: true, replaceToken,
331
+ leadPid, cwd, passive: true, replaceToken,
322
332
  registrationId,
323
333
  }, timeoutMs: 3000,
334
+ agent: controlAgent,
324
335
  }).then(async (r) => {
325
336
  const freshToken = r?.token;
326
337
  if (!freshToken) return false;
@@ -356,6 +367,7 @@ export async function attachToDaemon({
356
367
  // a retried transport failure to a single side-effect.
357
368
  body: { token: clientToken, name, args: args || {}, ...(callId ? { callId } : {}) },
358
369
  timeoutMs,
370
+ agent: callAgent,
359
371
  });
360
372
  } catch (err) {
361
373
  // Transport failure (daemon dead/restarted/unreachable) — tag so the
@@ -383,6 +395,8 @@ export async function attachToDaemon({
383
395
  closePromise = (async () => {
384
396
  if (pendingRegistration) await pendingRegistration;
385
397
  await deregister(clientToken, { registrationId, replaceToken });
398
+ callAgent.destroy();
399
+ controlAgent.destroy();
386
400
  log(`detached (${reason})`);
387
401
  })();
388
402
  return closePromise;