mixdog 0.9.94 → 0.9.96

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 (259) 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 +109 -0
  5. package/package.json +26 -13
  6. package/scripts/.tmp-coverage-report.mjs +96 -0
  7. package/scripts/build-tui.mjs +17 -1
  8. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  9. package/scripts/lib/isolated-root-cleanup.mjs +19 -0
  10. package/scripts/run-suite.mjs +100 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/lib/rules-builder.cjs +4 -3
  13. package/src/output-styles/detailed.md +13 -15
  14. package/src/output-styles/extreme-minimal.md +7 -11
  15. package/src/output-styles/minimal.md +4 -7
  16. package/src/output-styles/simple.md +11 -13
  17. package/src/rules/agent/30-explorer.md +7 -4
  18. package/src/rules/lead/01-general.md +1 -2
  19. package/src/rules/lead/lead-brief.md +4 -5
  20. package/src/rules/lead/lead-tool.md +3 -2
  21. package/src/rules/shared/01-tool.md +20 -35
  22. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  23. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  24. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  26. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +13 -2
  27. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
  28. package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
  29. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  30. package/src/runtime/agent/orchestrator/mcp/client.mjs +29 -14
  31. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  32. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  33. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
  34. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
  35. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  38. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
  39. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  40. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  41. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  42. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
  43. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
  44. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  45. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  46. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +29 -16
  47. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  49. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
  50. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  51. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
  52. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +13 -14
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
  54. package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
  55. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +82 -22
  56. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  57. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  58. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +40 -36
  59. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
  61. package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
  62. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +5 -33
  63. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
  64. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
  65. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  66. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
  67. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  68. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  69. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
  70. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
  71. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -4
  72. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  73. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
  74. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +168 -83
  75. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  76. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
  77. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  78. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
  79. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +34 -18
  80. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  81. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  82. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +89 -11
  83. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
  84. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +6 -39
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -6
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -4
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +56 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +124 -19
  90. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  91. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  92. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  93. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +7 -16
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +14 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
  100. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
  101. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
  102. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
  103. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +24 -10
  104. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  105. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +20 -43
  106. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +76 -3
  107. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +13 -1
  108. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -5
  109. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +38 -13
  110. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  111. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
  112. package/src/runtime/agent/orchestrator/tools/builtin.mjs +2 -2
  113. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +8 -6
  114. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +138 -25
  115. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
  116. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
  117. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
  118. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  119. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
  120. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  121. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +2 -2
  122. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +40 -2
  123. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
  124. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +177 -16
  125. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +21 -1
  126. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +217 -25
  127. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
  128. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +14 -15
  129. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +60 -8
  130. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  131. package/src/runtime/channels/backends/discord.mjs +5 -14
  132. package/src/runtime/channels/backends/telegram.mjs +0 -5
  133. package/src/runtime/channels/lib/config.mjs +2 -2
  134. package/src/runtime/channels/lib/inbound-handler.mjs +1 -2
  135. package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
  136. package/src/runtime/channels/lib/scheduler.mjs +3 -3
  137. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  138. package/src/runtime/media/renditions.mjs +21 -2
  139. package/src/runtime/memory/index.mjs +5 -38
  140. package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
  141. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  142. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  143. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
  144. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  145. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  146. package/src/runtime/memory/lib/http-router.mjs +8 -0
  147. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  148. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  149. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  150. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  151. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  152. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  153. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  154. package/src/runtime/memory/tool-defs.mjs +7 -11
  155. package/src/runtime/search/tool-defs.mjs +2 -2
  156. package/src/runtime/shared/agent-route-config.mjs +119 -0
  157. package/src/runtime/shared/atomic-file.mjs +133 -4
  158. package/src/runtime/shared/background-tasks.mjs +6 -3
  159. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  160. package/src/runtime/shared/child-guardian.mjs +204 -108
  161. package/src/runtime/shared/child-spawn-gate.mjs +60 -31
  162. package/src/runtime/shared/config.mjs +130 -44
  163. package/src/runtime/shared/resource-admission.mjs +146 -58
  164. package/src/runtime/shared/stream-progress.mjs +6 -0
  165. package/src/runtime/shared/tool-status.mjs +10 -1
  166. package/src/runtime/shared/tool-surface.mjs +2 -7
  167. package/src/runtime/shared/turn-snapshot.mjs +411 -21
  168. package/src/runtime/shared/turn-worktree-snapshot.mjs +552 -0
  169. package/src/session-runtime/config-helpers.mjs +39 -37
  170. package/src/session-runtime/context-status.mjs +9 -3
  171. package/src/session-runtime/lifecycle-api.mjs +35 -5
  172. package/src/session-runtime/mcp-glue.mjs +11 -6
  173. package/src/session-runtime/model-route-api.mjs +7 -5
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +94 -43
  176. package/src/session-runtime/runtime-core.mjs +57 -19
  177. package/src/session-runtime/runtime-tunables.mjs +4 -0
  178. package/src/session-runtime/self-update.mjs +33 -4
  179. package/src/session-runtime/session-lifecycle.mjs +11 -2
  180. package/src/session-runtime/session-text.mjs +2 -1
  181. package/src/session-runtime/session-turn-api.mjs +139 -23
  182. package/src/session-runtime/settings-api.mjs +5 -17
  183. package/src/session-runtime/tool-defs.mjs +3 -3
  184. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  185. package/src/session-runtime/workflow.mjs +25 -28
  186. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  187. package/src/standalone/agent-tool/helpers.mjs +2 -2
  188. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  189. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  190. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  191. package/src/standalone/agent-tool.mjs +4 -4
  192. package/src/standalone/backend-daemon.mjs +631 -0
  193. package/src/standalone/channel-admin.mjs +12 -12
  194. package/src/standalone/channel-daemon-client.mjs +17 -1
  195. package/src/standalone/channel-daemon-transport.mjs +231 -9
  196. package/src/standalone/channel-worker.mjs +3 -2
  197. package/src/standalone/engine-daemon-client.mjs +1069 -0
  198. package/src/standalone/engine-daemon-protocol.mjs +32 -0
  199. package/src/standalone/engine-daemon-service.mjs +1108 -0
  200. package/src/standalone/engine-daemon-transport.mjs +794 -0
  201. package/src/standalone/explore-tool.mjs +187 -70
  202. package/src/standalone/fair-call-scheduler.mjs +264 -0
  203. package/src/standalone/session-protocol.mjs +188 -0
  204. package/src/tui/App.jsx +92 -64
  205. package/src/tui/app/app-format.mjs +4 -2
  206. package/src/tui/app/app-view.jsx +10 -2
  207. package/src/tui/app/channel-pickers.mjs +7 -6
  208. package/src/tui/app/core-memory-picker.mjs +20 -20
  209. package/src/tui/app/doctor.mjs +5 -11
  210. package/src/tui/app/extension-pickers.mjs +20 -18
  211. package/src/tui/app/maintenance-pickers.mjs +27 -27
  212. package/src/tui/app/message-selector.mjs +103 -0
  213. package/src/tui/app/onboarding-steps.mjs +24 -20
  214. package/src/tui/app/project-picker.mjs +80 -56
  215. package/src/tui/app/prompt-submit.mjs +59 -43
  216. package/src/tui/app/resume-picker.mjs +2 -2
  217. package/src/tui/app/route-pickers.mjs +16 -9
  218. package/src/tui/app/settings-picker.mjs +60 -65
  219. package/src/tui/app/slash-dispatch.mjs +32 -28
  220. package/src/tui/app/transcript-window.mjs +57 -0
  221. package/src/tui/app/usage-context-panels.mjs +22 -7
  222. package/src/tui/app/use-mouse-input.mjs +25 -3
  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 +39 -17
  227. package/src/tui/app/use-transcript-scroll.mjs +39 -10
  228. package/src/tui/app/use-transcript-window.mjs +62 -14
  229. package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
  230. package/src/tui/components/PromptInput.jsx +81 -38
  231. package/src/tui/components/Spinner.jsx +89 -95
  232. package/src/tui/components/TextEntryPanel.jsx +14 -0
  233. package/src/tui/components/ToolExecution.jsx +2 -2
  234. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  235. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  236. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  237. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  238. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  239. package/src/tui/dist/index.mjs +1694 -10633
  240. package/src/tui/engine/agent-job-feed.mjs +2 -2
  241. package/src/tui/engine/live-share.mjs +110 -3
  242. package/src/tui/engine/session-api-ext.mjs +24 -5
  243. package/src/tui/engine/session-api.mjs +165 -55
  244. package/src/tui/engine/session-flow.mjs +50 -5
  245. package/src/tui/engine/tool-card-results.mjs +10 -2
  246. package/src/tui/engine/tool-result-text.mjs +10 -0
  247. package/src/tui/engine/turn.mjs +11 -64
  248. package/src/tui/engine-local-session.mjs +1124 -0
  249. package/src/tui/engine.mjs +16 -1065
  250. package/src/tui/index.jsx +41 -4
  251. package/src/tui/markdown/stream-fence.mjs +1 -1
  252. package/src/tui/spinner-meta.mjs +80 -0
  253. package/src/tui/spinner-verbs.mjs +83 -0
  254. package/src/ui/statusline-segments.mjs +43 -10
  255. package/src/ui/statusline.mjs +10 -1
  256. package/scripts/tmp-cdp-errors.mjs +0 -41
  257. package/scripts/tmp-cdp-inspect.mjs +0 -41
  258. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
  259. package/src/standalone/channel-daemon.mjs +0 -226
@@ -0,0 +1,794 @@
1
+ // Machine-global ENGINE daemon — HTTP + SSE transport (server side).
2
+ //
3
+ // Sibling of channel-daemon-transport.mjs, deliberately separate: the channels
4
+ // transport routes notifies to ONE pointer client (remote ownership), while
5
+ // engine frames are shared state — every attached client (terminal TUI and the
6
+ // desktop app at the same time) must observe the same snapshot stream. Mixing
7
+ // the two routing rules into one server would put the pointer semantics on a
8
+ // path that must never target a single client.
9
+ //
10
+ // This module owns ONLY the transport (sockets, client registry, frame fan-out,
11
+ // discovery file, lifecycle). The engine pool is injected via `handleCall`, so
12
+ // the same transport is exercised by the real daemon entry AND by the smoke
13
+ // harness with a stub engine factory (no provider, no model download).
14
+ import http from 'node:http';
15
+ import { createHash, randomUUID } from 'node:crypto';
16
+ import { rmSync } from 'node:fs';
17
+ import { writeJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
18
+ import { sendJson, sendError } from '../runtime/memory/lib/http-wire.mjs';
19
+ import { ENGINE_DAEMON_PROTOCOL, engineRuntimeVersion } from './engine-daemon-protocol.mjs';
20
+ import { createFairCallScheduler } from './fair-call-scheduler.mjs';
21
+
22
+ // A loopback front door still buffers whatever a client sends before it can be
23
+ // parsed, so the body has an explicit ceiling instead of the client's memory.
24
+ const MAX_BODY_BYTES = Math.max(1, Number(process.env.MIXDOG_ENGINE_DAEMON_MAX_BODY_MB) || 32)
25
+ * 1024 * 1024;
26
+
27
+ function readLimitedBody(req, {
28
+ reserve = () => true,
29
+ release = () => {},
30
+ } = {}) {
31
+ return new Promise((resolve, reject) => {
32
+ let settled = false;
33
+ let reserved = 0;
34
+ const releaseReserved = () => {
35
+ if (reserved <= 0) return;
36
+ release(reserved);
37
+ reserved = 0;
38
+ };
39
+ const fail = (message, statusCode) => {
40
+ if (settled) return;
41
+ settled = true;
42
+ releaseReserved();
43
+ const error = new Error(message);
44
+ error.statusCode = statusCode;
45
+ try { req.destroy(); } catch {}
46
+ reject(error);
47
+ };
48
+ const declared = Number(req.headers['content-length']);
49
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
50
+ fail('request body too large', 413);
51
+ return;
52
+ }
53
+ const chunks = [];
54
+ let size = 0;
55
+ req.on('data', (chunk) => {
56
+ if (settled) return;
57
+ size += chunk.length;
58
+ if (size > MAX_BODY_BYTES) { fail('request body too large', 413); return; }
59
+ if (!reserve(chunk.length)) { fail('daemon request memory budget is busy', 503); return; }
60
+ reserved += chunk.length;
61
+ chunks.push(chunk);
62
+ });
63
+ req.on('end', () => {
64
+ if (settled) return;
65
+ settled = true;
66
+ try {
67
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
68
+ if (!raw) { resolve({}); return; }
69
+ resolve(JSON.parse(raw));
70
+ }
71
+ catch (error) {
72
+ const err = new Error(`invalid JSON body: ${error.message}`);
73
+ err.statusCode = 400;
74
+ reject(err);
75
+ }
76
+ finally { releaseReserved(); }
77
+ });
78
+ req.on('error', (error) => {
79
+ if (!settled) {
80
+ settled = true;
81
+ releaseReserved();
82
+ reject(error);
83
+ }
84
+ });
85
+ });
86
+ }
87
+
88
+ /** Identity of a call's PAYLOAD. A retry repeats it exactly; a caller-supplied
89
+ * submission id that happens to be reused carries a different one. */
90
+ function callSignature(name, args) {
91
+ try {
92
+ return createHash('sha1').update(`${name}\u0000${JSON.stringify(args ?? {})}`).digest('hex');
93
+ } catch { return null; }
94
+ }
95
+
96
+ function parsePid(value) {
97
+ const n = Number(value);
98
+ return Number.isInteger(n) && n > 0 ? n : null;
99
+ }
100
+
101
+ function isPidAlive(pid) {
102
+ const n = parsePid(pid);
103
+ if (!n) return false;
104
+ try { process.kill(n, 0); return true; }
105
+ catch (error) { return error?.code === 'EPERM'; }
106
+ }
107
+
108
+ // A stalled reader collapses the backlog to one frame per key. That is right
109
+ // for SNAPSHOTS (the newest state is the whole truth) and wrong for a BYTE
110
+ // STREAM: dropping PTY output leaves a terminal permanently corrupted. Same-key
111
+ // terminal frames therefore concatenate, bounded so a wedged reader can never
112
+ // grow the daemon's heap without limit.
113
+ const TERMINAL_BACKLOG_MAX_CHARS = 256 * 1024;
114
+ const SSE_PENDING_MAX_BYTES = Math.max(
115
+ 256 * 1024,
116
+ (Number(process.env.MIXDOG_ENGINE_DAEMON_SSE_PENDING_MB) || 8) * 1024 * 1024,
117
+ );
118
+
119
+ function terminalDataFrame(frame) {
120
+ return frame?.type === 'desktop-event'
121
+ && frame.message?.kind === 'backend-event'
122
+ && frame.message?.name === 'terminal-data'
123
+ && typeof frame.message?.value?.data === 'string'
124
+ ? frame
125
+ : null;
126
+ }
127
+
128
+ function mergePendingFrame(existing, frame, json) {
129
+ const previous = existing ? terminalDataFrame(existing.frame) : null;
130
+ const next = terminalDataFrame(frame);
131
+ if (!previous || !next) return { frame, json };
132
+ const joined = `${previous.message.value.data}${next.message.value.data}`;
133
+ const data = joined.length > TERMINAL_BACKLOG_MAX_CHARS
134
+ ? joined.slice(joined.length - TERMINAL_BACKLOG_MAX_CHARS)
135
+ : joined;
136
+ const merged = {
137
+ ...next,
138
+ message: { ...next.message, value: { ...next.message.value, data } },
139
+ };
140
+ return { frame: merged, json: JSON.stringify(merged) };
141
+ }
142
+
143
+ export function createEngineDaemonTransport({
144
+ handleCall,
145
+ discoveryPath,
146
+ serverToken = randomUUID(),
147
+ log = () => {},
148
+ clientGraceMs = 10_000,
149
+ sweepMs = 5_000,
150
+ onClientsEmpty = null,
151
+ onClientRegistered = null,
152
+ onClientDropped = null,
153
+ getStatus = () => ({}),
154
+ } = {}) {
155
+ if (typeof handleCall !== 'function') throw new Error('handleCall is required');
156
+
157
+ // token -> { token, leadPid, cwd, lifecycle, sse, pending, lastSeen }
158
+ const clients = new Map();
159
+ let boundPort = null;
160
+ let server = null;
161
+ let graceTimer = null;
162
+ let sweepTimer = null;
163
+ let everHadLifecycleClient = false;
164
+ let closed = false;
165
+ const BODY_INFLIGHT_MAX_BYTES = Math.max(
166
+ MAX_BODY_BYTES,
167
+ (Number(process.env.MIXDOG_ENGINE_DAEMON_BODY_INFLIGHT_MB) || 64) * 1024 * 1024,
168
+ );
169
+ let bodyBytesInFlight = 0;
170
+ const readBody = (req) => readLimitedBody(req, {
171
+ reserve(bytes) {
172
+ if (bodyBytesInFlight + bytes > BODY_INFLIGHT_MAX_BYTES) return false;
173
+ bodyBytesInFlight += bytes;
174
+ return true;
175
+ },
176
+ release(bytes) {
177
+ bodyBytesInFlight = Math.max(0, bodyBytesInFlight - bytes);
178
+ },
179
+ });
180
+ // Idempotency cache: a transport retry of the SAME callId must never run a
181
+ // second engine mutation (submit/abort are not idempotent).
182
+ const callCache = new Map();
183
+ let callCacheBytes = 0;
184
+ const CALL_CACHE_TTL_MS = 60_000;
185
+ // Bound the dedup table: a call that never settles would otherwise pin its
186
+ // entry for the daemon's whole life.
187
+ const CALL_CACHE_MAX = Math.max(512, Number(process.env.MIXDOG_ENGINE_DAEMON_CALL_CACHE) || 4096);
188
+ const CALL_CACHE_MAX_BYTES = Math.max(
189
+ 1024 * 1024,
190
+ (Number(process.env.MIXDOG_ENGINE_DAEMON_CALL_CACHE_MB) || 8) * 1024 * 1024,
191
+ );
192
+ // A burst of panes used to enqueue every handleCall() as a microtask. Node
193
+ // drains the whole microtask queue before returning to HTTP, so enough cheap
194
+ // calls could starve /health and /client/register even though no explicit
195
+ // mutex existed. Admit one call per event-loop turn while allowing admitted
196
+ // async calls to overlap; control-plane routes never enter this queue.
197
+ // No default concurrency gate: independent session work starts in parallel.
198
+ // The scheduler still launches one item per check phase so synchronous setup
199
+ // cannot monopolize the HTTP/control-plane turn. Operators may explicitly
200
+ // set finite lane limits for constrained hosts.
201
+ const configuredLaneLimit = (name) => {
202
+ const parsed = Math.floor(Number(process.env[name]));
203
+ return Number.isFinite(parsed) && parsed >= 1 ? parsed : Infinity;
204
+ };
205
+ const CALL_QUEUE_MAX = Math.max(1024, Number(process.env.MIXDOG_ENGINE_DAEMON_CALL_QUEUE) || 1024);
206
+ const CRITICAL_CALLS = new Set([
207
+ 'session.abort',
208
+ 'session.approve',
209
+ 'session.unsubscribe',
210
+ 'desktop.control',
211
+ 'desktop.unsubscribe',
212
+ ]);
213
+ const INTERACTIVE_CALLS = new Set([
214
+ 'session.create',
215
+ 'session.read',
216
+ 'session.subscribe',
217
+ 'session.submit',
218
+ 'session.configure',
219
+ 'project.list',
220
+ 'project.inspect',
221
+ 'project.add',
222
+ 'project.touch',
223
+ 'project.rename',
224
+ 'project.remove',
225
+ 'project.ensureDirectory',
226
+ 'desktop.init',
227
+ ]);
228
+ const INTERACTIVE_DESKTOP_METHODS = new Set([
229
+ 'termEnsure',
230
+ 'termWrite',
231
+ 'termResize',
232
+ 'termDispose',
233
+ 'termProfiles',
234
+ ]);
235
+ const normalCalls = createFairCallScheduler({
236
+ name: 'engine daemon call',
237
+ activeMax: configuredLaneLimit('MIXDOG_ENGINE_DAEMON_ACTIVE_CALLS'),
238
+ queueMax: CALL_QUEUE_MAX,
239
+ minOwnerQueue: Math.max(8, Math.floor(CALL_QUEUE_MAX / 16)),
240
+ dispatchBurst: 1,
241
+ yieldUnbounded: true,
242
+ });
243
+ const criticalCalls = createFairCallScheduler({
244
+ name: 'engine daemon critical call',
245
+ activeMax: configuredLaneLimit('MIXDOG_ENGINE_DAEMON_URGENT_RESERVE'),
246
+ queueMax: Math.max(32, Math.min(256, CALL_QUEUE_MAX)),
247
+ minOwnerQueue: 8,
248
+ });
249
+ const interactiveCalls = createFairCallScheduler({
250
+ name: 'engine daemon interactive call',
251
+ activeMax: configuredLaneLimit('MIXDOG_ENGINE_DAEMON_INTERACTIVE_RESERVE'),
252
+ queueMax: Math.max(64, Math.min(512, CALL_QUEUE_MAX)),
253
+ minOwnerQueue: 8,
254
+ dispatchBurst: 1,
255
+ yieldUnbounded: true,
256
+ });
257
+ // A timed-out registration may already have committed server-side. Replaying
258
+ // the same registrationId returns that token instead of leaking a second
259
+ // client record and another lifecycle reference.
260
+ const registrationReplays = new Map();
261
+ const REGISTRATION_REPLAY_TTL_MS = 30_000;
262
+ const REGISTRATION_REPLAY_MAX = 256;
263
+
264
+ function deleteCallCacheEntry(key, record = callCache.get(key)) {
265
+ if (!record || callCache.get(key) !== record) return false;
266
+ callCache.delete(key);
267
+ callCacheBytes = Math.max(0, callCacheBytes - (record.bytes || 0));
268
+ return true;
269
+ }
270
+
271
+ function estimateRetainedBytes(value, limit = CALL_CACHE_MAX_BYTES + 1, seen = new Set()) {
272
+ if (typeof value === 'string') return Math.min(limit, value.length * 2 + 16);
273
+ if (typeof value === 'number' || typeof value === 'bigint') return 8;
274
+ if (typeof value === 'boolean' || value == null) return 4;
275
+ if (typeof value !== 'object' || seen.has(value)) return 0;
276
+ seen.add(value);
277
+ let bytes = Array.isArray(value) ? 32 : 64;
278
+ const entries = Array.isArray(value) ? value : Object.entries(value);
279
+ for (const entry of entries) {
280
+ if (Array.isArray(value)) bytes += 8 + estimateRetainedBytes(entry, limit - bytes, seen);
281
+ else bytes += String(entry[0]).length * 2 + 16
282
+ + estimateRetainedBytes(entry[1], limit - bytes, seen);
283
+ if (bytes >= limit) break;
284
+ }
285
+ seen.delete(value);
286
+ return Math.min(limit, bytes);
287
+ }
288
+
289
+ function pruneCallCache() {
290
+ while (callCache.size > CALL_CACHE_MAX || callCacheBytes > CALL_CACHE_MAX_BYTES) {
291
+ let removed = false;
292
+ for (const [key, record] of callCache) {
293
+ // Never lose the dedup identity of an in-flight mutation.
294
+ if (!record.settled) continue;
295
+ deleteCallCacheEntry(key, record);
296
+ removed = true;
297
+ break;
298
+ }
299
+ if (!removed) return;
300
+ }
301
+ }
302
+
303
+ function callLane(name, args = {}) {
304
+ if (CRITICAL_CALLS.has(name)) return 'critical';
305
+ if (INTERACTIVE_CALLS.has(name)) return 'interactive';
306
+ if (name === 'desktop.invoke') {
307
+ const adapterMethod = String(args?.method || '');
308
+ const backendMethod = adapterMethod === 'backendInvoke'
309
+ ? String(args?.args?.[0] || '')
310
+ : adapterMethod;
311
+ if (INTERACTIVE_DESKTOP_METHODS.has(backendMethod)) return 'interactive';
312
+ }
313
+ return 'normal';
314
+ }
315
+
316
+ function dispatchCall(ownerKey, run, { lane = 'normal', signal = null } = {}) {
317
+ const scheduler = lane === 'critical'
318
+ ? criticalCalls
319
+ : lane === 'interactive'
320
+ ? interactiveCalls
321
+ : normalCalls;
322
+ return scheduler.enqueue(ownerKey, run, { signal });
323
+ }
324
+
325
+ function nowMs() { return Date.now(); }
326
+
327
+ function lifecycleClientCount() {
328
+ let count = 0;
329
+ for (const client of clients.values()) {
330
+ if (client.lifecycle) count += 1;
331
+ }
332
+ return count;
333
+ }
334
+
335
+ function cancelGrace() {
336
+ if (graceTimer) { clearTimeout(graceTimer); graceTimer = null; }
337
+ }
338
+
339
+ function maybeArmGrace(reason) {
340
+ if (closed || !everHadLifecycleClient || typeof onClientsEmpty !== 'function') return;
341
+ if (lifecycleClientCount() > 0) return;
342
+ // Never re-arm an ALREADY armed grace: the 5s sweep also calls this, and
343
+ // cancel+rearm on every tick pushed the 10s deadline out forever — the
344
+ // daemon could never self-shut down through the engine front door.
345
+ if (graceTimer) return;
346
+ graceTimer = setTimeout(() => {
347
+ graceTimer = null;
348
+ if (closed || lifecycleClientCount() > 0) return;
349
+ log(`no clients remain (${reason}) — signalling shutdown`);
350
+ try { onClientsEmpty(); } catch {}
351
+ }, clientGraceMs);
352
+ graceTimer.unref?.();
353
+ }
354
+
355
+ function removeClientRecord(token) {
356
+ const c = clients.get(token);
357
+ if (!c) return;
358
+ try { c.sse?.end?.(); } catch {}
359
+ clients.delete(token);
360
+ for (const [registrationId, replay] of registrationReplays) {
361
+ if (replay.token === token) {
362
+ clearTimeout(replay.timer);
363
+ registrationReplays.delete(registrationId);
364
+ }
365
+ }
366
+ }
367
+
368
+ function dropClient(token, reason) {
369
+ if (!clients.has(token)) return;
370
+ removeClientRecord(token);
371
+ // The engine pool refcounts VIEWS by client token: a client that is gone
372
+ // must stop holding engines open (and must stop being counted as the
373
+ // reason another client's engine survives).
374
+ if (typeof onClientDropped === 'function') {
375
+ try { onClientDropped(token, reason); } catch {}
376
+ }
377
+ log(`client dropped token=${token} (${reason})`);
378
+ maybeArmGrace(reason);
379
+ }
380
+
381
+ function startSweep() {
382
+ if (sweepTimer || closed) return;
383
+ sweepTimer = setInterval(() => {
384
+ for (const [token, c] of [...clients]) {
385
+ // A client whose owning process died can never read its stream again.
386
+ if (c.leadPid && !isPidAlive(c.leadPid)) dropClient(token, 'lead pid gone');
387
+ }
388
+ maybeArmGrace('sweep');
389
+ }, sweepMs);
390
+ sweepTimer.unref?.();
391
+ }
392
+
393
+ function registerClient({ leadPid, cwd, lifecycle = true, registrationId = null } = {}) {
394
+ const pid = parsePid(leadPid) || 0;
395
+ const ownsLifecycle = lifecycle !== false;
396
+ const replayId = registrationId ? String(registrationId).slice(0, 200) : null;
397
+ const replay = replayId ? registrationReplays.get(replayId) : null;
398
+ if (replay) {
399
+ const sameIdentity = replay.leadPid === pid
400
+ && replay.cwd === (cwd || null)
401
+ && replay.lifecycle === ownsLifecycle;
402
+ if (sameIdentity && clients.has(replay.token)) {
403
+ log(`client registration replay token=${replay.token} lead=${pid}`);
404
+ return replay.token;
405
+ }
406
+ if (clients.has(replay.token)) {
407
+ const error = new Error('registration replay identity mismatch');
408
+ error.statusCode = 409;
409
+ throw error;
410
+ }
411
+ clearTimeout(replay.timer);
412
+ registrationReplays.delete(replayId);
413
+ }
414
+ const token = randomUUID();
415
+ clients.set(token, {
416
+ token,
417
+ leadPid: pid,
418
+ cwd: cwd || null,
419
+ lifecycle: ownsLifecycle,
420
+ sse: null,
421
+ // True while the socket asked us to stop writing (see writeFrame).
422
+ paused: false,
423
+ // Frames are latest-wins per key while a client has no stream: a
424
+ // reconnecting viewer wants the CURRENT snapshot, never a backlog.
425
+ pending: new Map(),
426
+ pendingBytes: 0,
427
+ lastSeen: nowMs(),
428
+ });
429
+ if (ownsLifecycle) {
430
+ everHadLifecycleClient = true;
431
+ cancelGrace();
432
+ }
433
+ startSweep();
434
+ log(`client registered token=${token} lead=${pid} cwd=${cwd || '-'} lifecycle=${ownsLifecycle}`);
435
+ if (typeof onClientRegistered === 'function') {
436
+ try { onClientRegistered({ token, leadPid: pid, cwd: cwd || null, lifecycle: ownsLifecycle }); } catch {}
437
+ }
438
+ if (replayId) {
439
+ while (registrationReplays.size >= REGISTRATION_REPLAY_MAX) {
440
+ const oldest = registrationReplays.keys().next();
441
+ if (oldest.done) break;
442
+ const prior = registrationReplays.get(oldest.value);
443
+ clearTimeout(prior?.timer);
444
+ registrationReplays.delete(oldest.value);
445
+ }
446
+ const timer = setTimeout(() => registrationReplays.delete(replayId), REGISTRATION_REPLAY_TTL_MS);
447
+ timer.unref?.();
448
+ registrationReplays.set(replayId, {
449
+ token,
450
+ leadPid: pid,
451
+ cwd: cwd || null,
452
+ lifecycle: ownsLifecycle,
453
+ timer,
454
+ });
455
+ }
456
+ return token;
457
+ }
458
+
459
+ /** Drain the latest-wins backlog into a stream that reported room again. */
460
+ function flushPending(client) {
461
+ while (client.sse && !client.paused && client.pending.size > 0) {
462
+ const [key, entry] = client.pending.entries().next().value;
463
+ client.pending.delete(key);
464
+ client.pendingBytes = Math.max(0, client.pendingBytes - (entry.bytes || 0));
465
+ try {
466
+ if (client.sse.write(`data: ${entry.json}\n\n`) === false) client.paused = true;
467
+ } catch {
468
+ client.sse = null;
469
+ client.pending.set(key, entry);
470
+ client.pendingBytes += entry.bytes || 0;
471
+ return;
472
+ }
473
+ }
474
+ }
475
+
476
+ function pendingEntry(frame, json) {
477
+ return { frame, json, bytes: Buffer.byteLength(json) + 8 };
478
+ }
479
+
480
+ function resyncEntry(frame) {
481
+ if (frame?.type !== 'session-state' || !frame.sessionId) return null;
482
+ const marker = {
483
+ type: 'session-state',
484
+ key: frame.key,
485
+ sessionId: frame.sessionId,
486
+ revision: -1,
487
+ baseRevision: -2,
488
+ patch: { set: {}, remove: [], itemsAppend: null },
489
+ resyncRequired: true,
490
+ };
491
+ return pendingEntry(marker, JSON.stringify(marker));
492
+ }
493
+
494
+ function setPending(client, key, entry) {
495
+ const previous = client.pending.get(key);
496
+ if (previous) client.pendingBytes = Math.max(0, client.pendingBytes - (previous.bytes || 0));
497
+ client.pending.delete(key);
498
+ client.pending.set(key, entry);
499
+ client.pendingBytes += entry.bytes || 0;
500
+ while (client.pendingBytes > SSE_PENDING_MAX_BYTES && client.pending.size > 0) {
501
+ const [oldestKey, oldest] = client.pending.entries().next().value;
502
+ const marker = resyncEntry(oldest.frame);
503
+ if (marker && marker.bytes < oldest.bytes) {
504
+ client.pending.set(oldestKey, marker);
505
+ client.pendingBytes += marker.bytes - oldest.bytes;
506
+ continue;
507
+ }
508
+ client.pending.delete(oldestKey);
509
+ client.pendingBytes = Math.max(0, client.pendingBytes - (oldest.bytes || 0));
510
+ }
511
+ }
512
+
513
+ function writeFrame(client, frame, json) {
514
+ const key = frame.key || `${frame.type}:${frame.sessionId || frame.desktopId || ''}`;
515
+ // A stalled reader must never make the daemon buffer a whole stream in
516
+ // Node's socket queue: while the socket is full the backlog collapses to
517
+ // one frame per key, exactly like a client with no stream at all.
518
+ if (!client.sse || client.paused) {
519
+ const merged = mergePendingFrame(client.pending.get(key), frame, json);
520
+ setPending(client, key, pendingEntry(merged.frame, merged.json));
521
+ return;
522
+ }
523
+ try {
524
+ if (client.sse.write(`data: ${json}\n\n`) === false) client.paused = true;
525
+ } catch {
526
+ client.sse = null;
527
+ const merged = mergePendingFrame(client.pending.get(key), frame, json);
528
+ setPending(client, key, pendingEntry(merged.frame, merged.json));
529
+ }
530
+ }
531
+
532
+ /** Session and desktop state reaches only subscribed client tokens. Calls
533
+ * without a target set retain the transport-level broadcast used by
534
+ * diagnostics and compatibility tests. */
535
+ function broadcast(frame, targetTokens = null) {
536
+ const json = JSON.stringify(frame);
537
+ if (targetTokens) {
538
+ for (const token of targetTokens) {
539
+ const client = clients.get(String(token || ''));
540
+ if (client) writeFrame(client, frame, json);
541
+ }
542
+ return;
543
+ }
544
+ for (const client of clients.values()) writeFrame(client, frame, json);
545
+ }
546
+
547
+ function attachSse(token, res) {
548
+ const c = clients.get(token);
549
+ if (!c) return false;
550
+ // Interactive lane: PTY output and keystroke echoes are small writes, and
551
+ // Nagle on a loopback socket adds a full delayed-ACK round trip to each.
552
+ try { res.socket?.setNoDelay(true); } catch { /* transport default stands */ }
553
+ res.writeHead(200, {
554
+ 'Content-Type': 'text/event-stream; charset=utf-8',
555
+ 'Cache-Control': 'no-cache, no-transform',
556
+ Connection: 'keep-alive',
557
+ 'X-Accel-Buffering': 'no',
558
+ });
559
+ res.write(': attached\n\n');
560
+ c.sse = res;
561
+ c.paused = false;
562
+ c.lastSeen = nowMs();
563
+ // The stream proves the client received its token; response-loss replay is
564
+ // no longer needed for this registration.
565
+ for (const [registrationId, replay] of registrationReplays) {
566
+ if (replay.token === token) {
567
+ clearTimeout(replay.timer);
568
+ registrationReplays.delete(registrationId);
569
+ }
570
+ }
571
+ res.on('drain', () => {
572
+ if (c.sse !== res) return;
573
+ c.paused = false;
574
+ flushPending(c);
575
+ });
576
+ flushPending(c);
577
+ const ka = setInterval(() => {
578
+ try { res.write(': ka\n\n'); } catch {}
579
+ }, 15_000);
580
+ ka.unref?.();
581
+ const cleanup = () => {
582
+ clearInterval(ka);
583
+ if (c.sse === res) { c.sse = null; c.paused = false; }
584
+ // Stream loss alone never drops the client: a desktop reload or a TUI
585
+ // resize can bounce the stream while the engine keeps running.
586
+ maybeArmGrace('sse closed');
587
+ };
588
+ res.on('close', cleanup);
589
+ res.on('error', cleanup);
590
+ return true;
591
+ }
592
+
593
+ async function handleRequest(req, res) {
594
+ const url = new URL(req.url, 'http://127.0.0.1');
595
+ const pathName = url.pathname;
596
+ try {
597
+ if (req.method === 'GET' && pathName === '/health') {
598
+ // Identity travels with EVERY health probe: a view negotiates version
599
+ // skew before it attaches, so an embedder can never forget to publish
600
+ // it and leave clients guessing.
601
+ sendJson(res, {
602
+ status: 'ok',
603
+ pid: process.pid,
604
+ clients: lifecycleClientCount(),
605
+ connections: clients.size,
606
+ protocol: ENGINE_DAEMON_PROTOCOL,
607
+ version: engineRuntimeVersion(),
608
+ ...getStatus(),
609
+ activeCalls: normalCalls.active + interactiveCalls.active + criticalCalls.active,
610
+ queuedCalls: normalCalls.queued + interactiveCalls.queued + criticalCalls.queued,
611
+ queuedUrgentCalls: criticalCalls.queued,
612
+ queuedInteractiveCalls: interactiveCalls.queued,
613
+ callOwners: normalCalls.snapshot().owners,
614
+ callQueues: {
615
+ critical: criticalCalls.snapshot(),
616
+ interactive: interactiveCalls.snapshot(),
617
+ normal: normalCalls.snapshot(),
618
+ },
619
+ transportMemory: {
620
+ bodyBytesInFlight,
621
+ bodyBytesMax: BODY_INFLIGHT_MAX_BYTES,
622
+ callCacheEntries: callCache.size,
623
+ callCacheBytes,
624
+ callCacheMaxBytes: CALL_CACHE_MAX_BYTES,
625
+ ssePendingBytes: [...clients.values()]
626
+ .reduce((sum, client) => sum + (client.pendingBytes || 0), 0),
627
+ },
628
+ });
629
+ return;
630
+ }
631
+ const token = req.headers['x-mixdog-daemon-token'] || url.searchParams.get('server_token');
632
+ if (token !== serverToken) { sendError(res, 'forbidden', 403); return; }
633
+
634
+ if (req.method === 'POST' && pathName === '/client/register') {
635
+ const body = await readBody(req);
636
+ const clientToken = registerClient({
637
+ leadPid: body.leadPid,
638
+ cwd: body.cwd,
639
+ lifecycle: body.lifecycle !== false,
640
+ registrationId: body.registrationId,
641
+ });
642
+ sendJson(res, { token: clientToken, pid: process.pid });
643
+ return;
644
+ }
645
+ if (req.method === 'POST' && pathName === '/client/deregister') {
646
+ const body = await readBody(req);
647
+ if (body.token) dropClient(String(body.token), 'deregister');
648
+ sendJson(res, { ok: true });
649
+ return;
650
+ }
651
+ if (req.method === 'GET' && pathName === '/events') {
652
+ const clientToken = url.searchParams.get('token');
653
+ if (!attachSse(clientToken, res)) { sendError(res, 'unknown client token', 404); return; }
654
+ return; // stream stays open
655
+ }
656
+ if (req.method === 'POST' && pathName === '/call') {
657
+ const body = await readBody(req);
658
+ const clientToken = body.token ? String(body.token) : null;
659
+ const c = clientToken ? clients.get(clientToken) : null;
660
+ if (!c) { sendError(res, 'unknown client token', 404); return; }
661
+ c.lastSeen = nowMs();
662
+ const name = String(body.name || '');
663
+ const callId = body.callId ? String(body.callId) : null;
664
+ const addressedSessionId = String(body.args?.sessionId || '').trim();
665
+ const ownerKey = addressedSessionId
666
+ ? `session:${addressedSessionId}`
667
+ : c.leadPid
668
+ ? `pid:${c.leadPid}`
669
+ : `client:${clientToken}`;
670
+ // Scheduling fairness follows the addressed session, but idempotency
671
+ // belongs to the CALLING PROCESS. Two clients legitimately issuing the
672
+ // same callId against one shared session must not dedupe each other.
673
+ const cacheOwnerKey = c.leadPid
674
+ ? `pid:${c.leadPid}`
675
+ : `client:${clientToken}`;
676
+ const cacheKey = callId ? `${cacheOwnerKey}\u0000${callId}` : null;
677
+ // A retry is the SAME payload under the same id. A different payload
678
+ // that reuses an id (submission ids are caller-supplied) is a NEW call
679
+ // and must never be answered out of another call's result.
680
+ const signature = callId ? callSignature(name, body.args) : null;
681
+ const cached = cacheKey ? callCache.get(cacheKey) : null;
682
+ let dispatch;
683
+ if (cached && (!cached.signature || !signature || cached.signature === signature)) {
684
+ dispatch = cached.promise;
685
+ } else {
686
+ dispatch = dispatchCall(
687
+ ownerKey,
688
+ () => handleCall(name, body.args || {}, {
689
+ clientToken,
690
+ leadPid: c.leadPid ?? null,
691
+ cwd: c.cwd ?? null,
692
+ }),
693
+ { lane: callLane(name, body.args || {}) },
694
+ );
695
+ if (cacheKey) {
696
+ const record = {
697
+ promise: dispatch, at: nowMs(), signature, settled: false, bytes: 0,
698
+ };
699
+ callCache.set(cacheKey, record);
700
+ pruneCallCache();
701
+ // TTL starts at SETTLE: a long-running submit must not expire its
702
+ // dedup entry mid-flight and let a retry run the turn twice.
703
+ dispatch.then((result) => {
704
+ record.bytes = estimateRetainedBytes(result);
705
+ callCacheBytes += record.bytes;
706
+ }, () => {}).then(() => {
707
+ record.settled = true;
708
+ pruneCallCache();
709
+ const t = setTimeout(() => {
710
+ deleteCallCacheEntry(cacheKey, record);
711
+ }, CALL_CACHE_TTL_MS);
712
+ t.unref?.();
713
+ });
714
+ }
715
+ }
716
+ try {
717
+ const result = await dispatch;
718
+ sendJson(res, { result });
719
+ } catch (err) {
720
+ // Engine errors travel as a 200 {error} envelope so the client can
721
+ // tell a failed CALL from a dead TRANSPORT (which must re-attach).
722
+ sendJson(res, { error: err?.message || String(err) }, 200);
723
+ }
724
+ return;
725
+ }
726
+ if (req.method === 'POST' && pathName === '/shutdown') {
727
+ sendJson(res, { ok: true });
728
+ if (typeof onClientsEmpty === 'function') { try { onClientsEmpty(); } catch {} }
729
+ return;
730
+ }
731
+ sendError(res, 'not found', 404);
732
+ } catch (err) {
733
+ try { sendError(res, err?.message || String(err), err?.statusCode || 500); } catch {}
734
+ }
735
+ }
736
+
737
+ function writeDiscovery() {
738
+ if (!discoveryPath) return;
739
+ try {
740
+ writeJsonAtomicSync(discoveryPath, {
741
+ pid: process.pid,
742
+ port: boundPort,
743
+ token: serverToken,
744
+ startedAt: Date.now(),
745
+ }, { compact: true });
746
+ } catch (err) {
747
+ log(`discovery write failed: ${err?.message || err}`);
748
+ }
749
+ }
750
+
751
+ function start() {
752
+ return new Promise((resolve, reject) => {
753
+ server = http.createServer(handleRequest);
754
+ server.on('error', reject);
755
+ // 127.0.0.1 ONLY — the engine daemon executes tools; it must never be
756
+ // reachable off-box.
757
+ server.listen(0, '127.0.0.1', () => {
758
+ server.removeListener('error', reject);
759
+ boundPort = server.address().port;
760
+ server.on('error', (err) => log(`server error: ${err?.message || err}`));
761
+ writeDiscovery();
762
+ log(`engine daemon transport listening on 127.0.0.1:${boundPort} pid=${process.pid}`);
763
+ resolve({ port: boundPort, token: serverToken });
764
+ });
765
+ });
766
+ }
767
+
768
+ async function stop() {
769
+ closed = true;
770
+ cancelGrace();
771
+ if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
772
+ normalCalls.close('engine daemon transport is closed');
773
+ interactiveCalls.close('engine daemon transport is closed');
774
+ criticalCalls.close('engine daemon transport is closed');
775
+ for (const replay of registrationReplays.values()) clearTimeout(replay.timer);
776
+ registrationReplays.clear();
777
+ for (const token of [...clients.keys()]) removeClientRecord(token);
778
+ if (discoveryPath) { try { rmSync(discoveryPath, { force: true }); } catch {} }
779
+ await new Promise((resolve) => {
780
+ if (!server) { resolve(); return; }
781
+ server.close(() => resolve());
782
+ server = null;
783
+ });
784
+ }
785
+
786
+ return {
787
+ start,
788
+ stop,
789
+ broadcast,
790
+ get port() { return boundPort; },
791
+ get clientCount() { return lifecycleClientCount(); },
792
+ get connectionCount() { return clients.size; },
793
+ };
794
+ }