mixdog 0.9.95 → 0.9.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/README.md +0 -17
  6. package/package.json +16 -12
  7. package/scripts/.tmp-coverage-report.mjs +96 -0
  8. package/scripts/build-tui.mjs +17 -1
  9. package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
  10. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/defaults/skills/setup/SKILL.md +1 -1
  13. package/src/lib/keychain-cjs.cjs +1 -1
  14. package/src/lib/mixdog-debug.cjs +2 -3
  15. package/src/rules/agent/30-explorer.md +7 -4
  16. package/src/rules/lead/01-general.md +1 -2
  17. package/src/rules/lead/lead-tool.md +0 -3
  18. package/src/rules/shared/01-tool.md +21 -29
  19. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  23. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  24. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +104 -41
  26. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  27. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  39. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  41. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  42. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  43. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +36 -10
  44. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -33
  46. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  49. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +21 -29
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +7 -10
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +45 -25
  52. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  53. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  54. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  55. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  57. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  59. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  62. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  63. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  64. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +71 -42
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +4 -33
  66. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  67. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +116 -26
  71. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  72. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  73. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  77. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +12 -26
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +5 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -33
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +42 -45
  81. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +27 -9
  82. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  84. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  86. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  87. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  88. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  89. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  90. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +142 -26
  91. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  92. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +47 -19
  93. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -2
  94. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  95. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  97. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -20
  99. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  100. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  101. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +12 -11
  102. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +63 -9
  104. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  105. package/src/runtime/channels/lib/config.mjs +34 -34
  106. package/src/runtime/channels/lib/inbound-handler.mjs +8 -8
  107. package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
  108. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  109. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  110. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  111. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  112. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  113. package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
  114. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  115. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  116. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  117. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  118. package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
  119. package/src/runtime/channels/lib/worker-main.mjs +38 -38
  120. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  121. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  122. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  123. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/memory/index.mjs +76 -248
  126. package/src/runtime/memory/lib/agent-ipc.mjs +146 -81
  127. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  128. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  129. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +13 -34
  130. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  131. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  132. package/src/runtime/memory/lib/http-router.mjs +9 -6
  133. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  134. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  135. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  136. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  137. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  138. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  139. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  140. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  141. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  142. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  143. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  144. package/src/runtime/memory/lib/memory.mjs +1 -1
  145. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  146. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  147. package/src/runtime/memory/tool-defs.mjs +1 -5
  148. package/src/runtime/search/index.mjs +2 -2
  149. package/src/runtime/search/tool-defs.mjs +2 -2
  150. package/src/runtime/shared/abort-race.mjs +70 -0
  151. package/src/runtime/shared/agent-route-config.mjs +119 -0
  152. package/src/runtime/shared/atomic-file.mjs +80 -4
  153. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  154. package/src/runtime/shared/child-guardian.mjs +204 -108
  155. package/src/runtime/shared/child-spawn-gate.mjs +139 -72
  156. package/src/runtime/shared/config.mjs +120 -47
  157. package/src/runtime/shared/llm/index.mjs +1 -1
  158. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  159. package/src/runtime/shared/resource-admission.mjs +150 -100
  160. package/src/runtime/shared/stream-progress.mjs +6 -0
  161. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  162. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  163. package/src/runtime/shared/tool-surface.mjs +91 -27
  164. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  165. package/src/runtime/shared/turn-snapshot.mjs +47 -6
  166. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  167. package/src/session-runtime/channel-config-api.mjs +5 -5
  168. package/src/session-runtime/config-helpers.mjs +39 -37
  169. package/src/session-runtime/config-lifecycle.mjs +27 -27
  170. package/src/session-runtime/lifecycle-api.mjs +22 -3
  171. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  172. package/src/session-runtime/model-route-api.mjs +7 -5
  173. package/src/session-runtime/prewarm.mjs +1 -1
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +1 -1
  176. package/src/session-runtime/remote-control.mjs +6 -7
  177. package/src/session-runtime/runtime-core.mjs +63 -39
  178. package/src/session-runtime/runtime-tunables.mjs +4 -4
  179. package/src/session-runtime/self-update.mjs +1 -1
  180. package/src/session-runtime/session-lifecycle.mjs +45 -17
  181. package/src/session-runtime/session-title.mjs +1 -1
  182. package/src/session-runtime/session-turn-api.mjs +96 -24
  183. package/src/session-runtime/settings-api.mjs +12 -24
  184. package/src/session-runtime/tool-defs.mjs +3 -3
  185. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  186. package/src/session-runtime/workflow.mjs +14 -24
  187. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  188. package/src/standalone/agent-tool/helpers.mjs +2 -2
  189. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  190. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  191. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  192. package/src/standalone/channel-admin.mjs +53 -64
  193. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +33 -19
  194. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +127 -46
  195. package/src/standalone/channel-worker.mjs +124 -647
  196. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +270 -154
  197. package/src/standalone/explore-tool.mjs +187 -70
  198. package/src/standalone/fair-call-scheduler.mjs +264 -0
  199. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  200. package/src/standalone/session-client.mjs +1134 -0
  201. package/src/standalone/session-protocol.mjs +192 -0
  202. package/src/standalone/session-runtime-pool.mjs +393 -0
  203. package/src/standalone/session-runtime-record.mjs +20 -0
  204. package/src/standalone/session-runtime-worker.mjs +203 -0
  205. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +481 -224
  206. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +390 -121
  207. package/src/standalone/session-wire.mjs +55 -0
  208. package/src/tui/App.jsx +34 -21
  209. package/src/tui/app/app-format.mjs +1 -1
  210. package/src/tui/app/app-view.jsx +5 -2
  211. package/src/tui/app/channel-pickers.mjs +41 -41
  212. package/src/tui/app/core-memory-picker.mjs +19 -19
  213. package/src/tui/app/doctor.mjs +5 -11
  214. package/src/tui/app/input-parsers.mjs +2 -2
  215. package/src/tui/app/message-selector.mjs +103 -0
  216. package/src/tui/app/onboarding-steps.mjs +3 -4
  217. package/src/tui/app/project-picker.mjs +80 -56
  218. package/src/tui/app/prompt-submit.mjs +48 -43
  219. package/src/tui/app/resume-picker.mjs +2 -2
  220. package/src/tui/app/route-pickers.mjs +3 -1
  221. package/src/tui/app/settings-picker.mjs +31 -33
  222. package/src/tui/app/transcript-window.mjs +38 -0
  223. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  224. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  225. package/src/tui/app/use-prompt-hint.mjs +3 -2
  226. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  227. package/src/tui/app/use-transcript-scroll.mjs +10 -3
  228. package/src/tui/app/use-transcript-window.mjs +50 -17
  229. package/src/tui/components/PromptInput.jsx +71 -38
  230. package/src/tui/components/Spinner.jsx +1 -1
  231. package/src/tui/components/ToolExecution.jsx +1 -1
  232. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  233. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  234. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  235. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  236. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  237. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  238. package/src/tui/dist/index.mjs +1031 -1676
  239. package/src/tui/hooks/useSession.mjs +16 -0
  240. package/src/tui/index.jsx +12 -13
  241. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  242. package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
  243. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  244. package/src/tui/{engine → session}/context-state.mjs +3 -3
  245. package/src/tui/{engine → session}/labels.mjs +2 -2
  246. package/src/tui/{engine → session}/live-share.mjs +38 -4
  247. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  248. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  249. package/src/tui/session/oauth-flows.mjs +140 -0
  250. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  251. package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
  252. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  253. package/src/tui/{engine → session}/session-api-ext.mjs +30 -20
  254. package/src/tui/{engine → session}/session-api.mjs +110 -93
  255. package/src/tui/{engine → session}/session-flow.mjs +8 -2
  256. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  257. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  258. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  259. package/src/tui/{engine → session}/tool-card-results.mjs +8 -6
  260. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  261. package/src/tui/{engine → session}/tool-result-text.mjs +13 -3
  262. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  263. package/src/tui/{engine → session}/turn.mjs +38 -176
  264. package/src/tui/{engine-local-session.mjs → session-local.mjs} +103 -103
  265. package/src/tui/session.mjs +22 -0
  266. package/src/tui/spinner-meta.mjs +4 -4
  267. package/src/tui/spinner-verbs.mjs +51 -3
  268. package/src/ui/statusline-segments.mjs +1 -1
  269. package/src/ui/statusline.mjs +1 -1
  270. package/vendor/ink/build/components/App.js +10 -1
  271. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  272. package/src/standalone/engine-daemon-client.mjs +0 -894
  273. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
  274. package/src/standalone/engine-daemon-protocol.mjs +0 -33
  275. package/src/tui/engine/turn-watchdog.mjs +0 -92
  276. package/src/tui/engine.mjs +0 -22
  277. package/src/tui/hooks/useEngine.mjs +0 -16
  278. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  279. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  280. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  281. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  282. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -1,34 +1,49 @@
1
- // Machine-global ENGINE daemon — HTTP + SSE transport (server side).
1
+ // Machine-global session HTTP + SSE transport (server side).
2
2
  //
3
- // Sibling of channel-daemon-transport.mjs, deliberately separate: the channels
3
+ // Sibling of channel-transport.mjs, deliberately separate: the channels
4
4
  // transport routes notifies to ONE pointer client (remote ownership), while
5
- // engine frames are shared state — every attached client (terminal TUI and the
5
+ // session frames are shared state — every attached client (terminal TUI and the
6
6
  // desktop app at the same time) must observe the same snapshot stream. Mixing
7
7
  // the two routing rules into one server would put the pointer semantics on a
8
8
  // path that must never target a single client.
9
9
  //
10
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
11
+ // lifecycle). The session pool is injected via `handleCall`, so
12
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).
13
+ // harness with a stub session runtime (no provider, no model download).
14
14
  import http from 'node:http';
15
15
  import { createHash, randomUUID } from 'node:crypto';
16
- import { rmSync } from 'node:fs';
17
- import { writeJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
18
16
  import { sendJson, sendError } from '../runtime/memory/lib/http-wire.mjs';
19
- import { ENGINE_DAEMON_PROTOCOL, engineRuntimeVersion } from './engine-daemon-protocol.mjs';
17
+ import {
18
+ compareRuntimeVersions,
19
+ SESSION_CAPABILITY_FINGERPRINT,
20
+ SESSION_PROTOCOL,
21
+ SESSION_REVISION,
22
+ runtimeVersion,
23
+ } from './session-wire.mjs';
24
+ import { createFairCallScheduler } from './fair-call-scheduler.mjs';
20
25
 
21
26
  // A loopback front door still buffers whatever a client sends before it can be
22
27
  // parsed, so the body has an explicit ceiling instead of the client's memory.
23
- const MAX_BODY_BYTES = Math.max(1, Number(process.env.MIXDOG_ENGINE_DAEMON_MAX_BODY_MB) || 64)
28
+ const MAX_BODY_BYTES = Math.max(1, Number(process.env.MIXDOG_SESSION_MAX_BODY_MB) || 32)
24
29
  * 1024 * 1024;
25
30
 
26
- function readLimitedBody(req) {
31
+ function readLimitedBody(req, {
32
+ reserve = () => true,
33
+ release = () => {},
34
+ } = {}) {
27
35
  return new Promise((resolve, reject) => {
28
36
  let settled = false;
37
+ let reserved = 0;
38
+ const releaseReserved = () => {
39
+ if (reserved <= 0) return;
40
+ release(reserved);
41
+ reserved = 0;
42
+ };
29
43
  const fail = (message, statusCode) => {
30
44
  if (settled) return;
31
45
  settled = true;
46
+ releaseReserved();
32
47
  const error = new Error(message);
33
48
  error.statusCode = statusCode;
34
49
  try { req.destroy(); } catch {}
@@ -45,21 +60,32 @@ function readLimitedBody(req) {
45
60
  if (settled) return;
46
61
  size += chunk.length;
47
62
  if (size > MAX_BODY_BYTES) { fail('request body too large', 413); return; }
63
+ if (!reserve(chunk.length)) { fail('daemon request memory budget is busy', 503); return; }
64
+ reserved += chunk.length;
48
65
  chunks.push(chunk);
49
66
  });
50
67
  req.on('end', () => {
51
68
  if (settled) return;
52
69
  settled = true;
53
- const raw = Buffer.concat(chunks).toString('utf8').trim();
54
- if (!raw) { resolve({}); return; }
55
- try { resolve(JSON.parse(raw)); }
70
+ try {
71
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
72
+ if (!raw) { resolve({}); return; }
73
+ resolve(JSON.parse(raw));
74
+ }
56
75
  catch (error) {
57
76
  const err = new Error(`invalid JSON body: ${error.message}`);
58
77
  err.statusCode = 400;
59
78
  reject(err);
60
79
  }
80
+ finally { releaseReserved(); }
81
+ });
82
+ req.on('error', (error) => {
83
+ if (!settled) {
84
+ settled = true;
85
+ releaseReserved();
86
+ reject(error);
87
+ }
61
88
  });
62
- req.on('error', (error) => { if (!settled) { settled = true; reject(error); } });
63
89
  });
64
90
  }
65
91
 
@@ -89,10 +115,15 @@ function isPidAlive(pid) {
89
115
  // terminal frames therefore concatenate, bounded so a wedged reader can never
90
116
  // grow the daemon's heap without limit.
91
117
  const TERMINAL_BACKLOG_MAX_CHARS = 256 * 1024;
118
+ const SSE_PENDING_MAX_BYTES = Math.max(
119
+ 256 * 1024,
120
+ (Number(process.env.MIXDOG_SESSION_SSE_PENDING_MB) || 8)
121
+ * 1024 * 1024,
122
+ );
92
123
 
93
124
  function terminalDataFrame(frame) {
94
125
  return frame?.type === 'desktop-event'
95
- && frame.message?.kind === 'backend-event'
126
+ && frame.message?.kind === 'desktop-event'
96
127
  && frame.message?.name === 'terminal-data'
97
128
  && typeof frame.message?.value?.data === 'string'
98
129
  ? frame
@@ -114,9 +145,8 @@ function mergePendingFrame(existing, frame, json) {
114
145
  return { frame: merged, json: JSON.stringify(merged) };
115
146
  }
116
147
 
117
- export function createEngineDaemonTransport({
148
+ export function createSessionTransport({
118
149
  handleCall,
119
- discoveryPath,
120
150
  serverToken = randomUUID(),
121
151
  log = () => {},
122
152
  clientGraceMs = 10_000,
@@ -124,6 +154,7 @@ export function createEngineDaemonTransport({
124
154
  onClientsEmpty = null,
125
155
  onClientRegistered = null,
126
156
  onClientDropped = null,
157
+ onUpgradeRequested = null,
127
158
  getStatus = () => ({}),
128
159
  } = {}) {
129
160
  if (typeof handleCall !== 'function') throw new Error('handleCall is required');
@@ -136,23 +167,112 @@ export function createEngineDaemonTransport({
136
167
  let sweepTimer = null;
137
168
  let everHadLifecycleClient = false;
138
169
  let closed = false;
170
+ let drainingReason = '';
171
+ const BODY_INFLIGHT_MAX_BYTES = Math.max(
172
+ MAX_BODY_BYTES,
173
+ (Number(process.env.MIXDOG_SESSION_BODY_INFLIGHT_MB) || 64)
174
+ * 1024 * 1024,
175
+ );
176
+ let bodyBytesInFlight = 0;
177
+ const readBody = (req) => readLimitedBody(req, {
178
+ reserve(bytes) {
179
+ if (bodyBytesInFlight + bytes > BODY_INFLIGHT_MAX_BYTES) return false;
180
+ bodyBytesInFlight += bytes;
181
+ return true;
182
+ },
183
+ release(bytes) {
184
+ bodyBytesInFlight = Math.max(0, bodyBytesInFlight - bytes);
185
+ },
186
+ });
139
187
  // Idempotency cache: a transport retry of the SAME callId must never run a
140
- // second engine mutation (submit/abort are not idempotent).
188
+ // second session mutation (submit/abort are not idempotent).
141
189
  const callCache = new Map();
190
+ let callCacheBytes = 0;
191
+ // These routes are safe to replay and can return large snapshots. Keeping
192
+ // them in the mutation-dedup cache retained multiple transcript copies for
193
+ // a full minute under review polling and pane reconciliation.
194
+ const REPLAY_SAFE_CALLS = new Set([
195
+ 'project.list',
196
+ 'project.inspect',
197
+ 'session.list',
198
+ 'session.read',
199
+ 'session.subscribe',
200
+ 'session.unsubscribe',
201
+ ]);
142
202
  const CALL_CACHE_TTL_MS = 60_000;
143
203
  // Bound the dedup table: a call that never settles would otherwise pin its
144
204
  // entry for the daemon's whole life.
145
- const CALL_CACHE_MAX = Math.max(512, Number(process.env.MIXDOG_ENGINE_DAEMON_CALL_CACHE) || 4096);
205
+ const CALL_CACHE_MAX = Math.max(512, Number(process.env.MIXDOG_SESSION_CALL_CACHE) || 4096);
206
+ const CALL_CACHE_MAX_BYTES = Math.max(
207
+ 1024 * 1024,
208
+ (Number(process.env.MIXDOG_SESSION_CALL_CACHE_MB) || 8)
209
+ * 1024 * 1024,
210
+ );
146
211
  // A burst of panes used to enqueue every handleCall() as a microtask. Node
147
212
  // drains the whole microtask queue before returning to HTTP, so enough cheap
148
213
  // calls could starve /health and /client/register even though no explicit
149
214
  // mutex existed. Admit one call per event-loop turn while allowing admitted
150
215
  // async calls to overlap; control-plane routes never enter this queue.
151
- const CALL_ACTIVE_MAX = Math.max(8, Number(process.env.MIXDOG_ENGINE_DAEMON_ACTIVE_CALLS) || 64);
152
- const CALL_QUEUE_MAX = Math.max(CALL_ACTIVE_MAX, Number(process.env.MIXDOG_ENGINE_DAEMON_CALL_QUEUE) || 1024);
153
- const callQueue = [];
154
- let activeCalls = 0;
155
- let callDispatchScheduled = false;
216
+ // No default concurrency gate: independent session work starts in parallel.
217
+ // The scheduler still launches one item per check phase so synchronous setup
218
+ // cannot monopolize the HTTP/control-plane turn. Operators may explicitly
219
+ // set finite lane limits for constrained hosts.
220
+ const configuredLaneLimit = (name) => {
221
+ const parsed = Math.floor(Number(process.env[name]));
222
+ return Number.isFinite(parsed) && parsed >= 1 ? parsed : Infinity;
223
+ };
224
+ const CALL_QUEUE_MAX = Math.max(1024, Number(process.env.MIXDOG_SESSION_CALL_QUEUE) || 1024);
225
+ const CRITICAL_CALLS = new Set([
226
+ 'session.abort',
227
+ 'session.approve',
228
+ 'session.unsubscribe',
229
+ 'desktop.control',
230
+ 'desktop.unsubscribe',
231
+ ]);
232
+ const INTERACTIVE_CALLS = new Set([
233
+ 'session.create',
234
+ 'session.read',
235
+ 'session.subscribe',
236
+ 'session.submit',
237
+ 'session.configure',
238
+ 'project.list',
239
+ 'project.inspect',
240
+ 'project.add',
241
+ 'project.touch',
242
+ 'project.rename',
243
+ 'project.remove',
244
+ 'project.ensureDirectory',
245
+ 'desktop.init',
246
+ ]);
247
+ const INTERACTIVE_DESKTOP_METHODS = new Set([
248
+ 'termEnsure',
249
+ 'termWrite',
250
+ 'termResize',
251
+ 'termDispose',
252
+ 'termProfiles',
253
+ ]);
254
+ const normalCalls = createFairCallScheduler({
255
+ name: 'session service call',
256
+ activeMax: configuredLaneLimit('MIXDOG_SESSION_ACTIVE_CALLS'),
257
+ queueMax: CALL_QUEUE_MAX,
258
+ minOwnerQueue: Math.max(8, Math.floor(CALL_QUEUE_MAX / 16)),
259
+ dispatchBurst: 1,
260
+ yieldUnbounded: true,
261
+ });
262
+ const criticalCalls = createFairCallScheduler({
263
+ name: 'session service critical call',
264
+ activeMax: configuredLaneLimit('MIXDOG_SESSION_URGENT_RESERVE'),
265
+ queueMax: Math.max(32, Math.min(256, CALL_QUEUE_MAX)),
266
+ minOwnerQueue: 8,
267
+ });
268
+ const interactiveCalls = createFairCallScheduler({
269
+ name: 'session service interactive call',
270
+ activeMax: configuredLaneLimit('MIXDOG_SESSION_INTERACTIVE_RESERVE'),
271
+ queueMax: Math.max(64, Math.min(512, CALL_QUEUE_MAX)),
272
+ minOwnerQueue: 8,
273
+ dispatchBurst: 1,
274
+ yieldUnbounded: true,
275
+ });
156
276
  // A timed-out registration may already have committed server-side. Replaying
157
277
  // the same registrationId returns that token instead of leaking a second
158
278
  // client record and another lifecycle reference.
@@ -160,58 +280,65 @@ export function createEngineDaemonTransport({
160
280
  const REGISTRATION_REPLAY_TTL_MS = 30_000;
161
281
  const REGISTRATION_REPLAY_MAX = 256;
162
282
 
163
- function pruneCallCache() {
164
- while (callCache.size > CALL_CACHE_MAX) {
165
- const oldest = callCache.keys().next();
166
- if (oldest.done) return;
167
- callCache.delete(oldest.value);
283
+ function deleteCallCacheEntry(key, record = callCache.get(key)) {
284
+ if (!record || callCache.get(key) !== record) return false;
285
+ callCache.delete(key);
286
+ callCacheBytes = Math.max(0, callCacheBytes - (record.bytes || 0));
287
+ return true;
288
+ }
289
+
290
+ function estimateRetainedBytes(value, limit = CALL_CACHE_MAX_BYTES + 1, seen = new Set()) {
291
+ if (typeof value === 'string') return Math.min(limit, value.length * 2 + 16);
292
+ if (typeof value === 'number' || typeof value === 'bigint') return 8;
293
+ if (typeof value === 'boolean' || value == null) return 4;
294
+ if (typeof value !== 'object' || seen.has(value)) return 0;
295
+ seen.add(value);
296
+ let bytes = Array.isArray(value) ? 32 : 64;
297
+ const entries = Array.isArray(value) ? value : Object.entries(value);
298
+ for (const entry of entries) {
299
+ if (Array.isArray(value)) bytes += 8 + estimateRetainedBytes(entry, limit - bytes, seen);
300
+ else bytes += String(entry[0]).length * 2 + 16
301
+ + estimateRetainedBytes(entry[1], limit - bytes, seen);
302
+ if (bytes >= limit) break;
168
303
  }
304
+ seen.delete(value);
305
+ return Math.min(limit, bytes);
169
306
  }
170
307
 
171
- function scheduleCallDispatch() {
172
- if (closed || callDispatchScheduled || activeCalls >= CALL_ACTIVE_MAX || callQueue.length === 0) return;
173
- callDispatchScheduled = true;
174
- setImmediate(() => {
175
- callDispatchScheduled = false;
176
- if (closed) {
177
- const error = new Error('engine daemon transport is closed');
178
- error.statusCode = 503;
179
- for (const queued of callQueue.splice(0)) queued.reject(error);
180
- return;
308
+ function pruneCallCache() {
309
+ while (callCache.size > CALL_CACHE_MAX || callCacheBytes > CALL_CACHE_MAX_BYTES) {
310
+ let removed = false;
311
+ for (const [key, record] of callCache) {
312
+ // Never lose the dedup identity of an in-flight mutation.
313
+ if (!record.settled) continue;
314
+ deleteCallCacheEntry(key, record);
315
+ removed = true;
316
+ break;
181
317
  }
182
- if (activeCalls >= CALL_ACTIVE_MAX) return;
183
- const queued = callQueue.shift();
184
- if (!queued) return;
185
- activeCalls += 1;
186
- Promise.resolve()
187
- .then(queued.run)
188
- .then(queued.resolve, queued.reject)
189
- .finally(() => {
190
- activeCalls = Math.max(0, activeCalls - 1);
191
- scheduleCallDispatch();
192
- });
193
- // Start at most ONE handler in this check phase. A fresh immediate gives
194
- // socket I/O (especially health/register) a poll phase between starts.
195
- scheduleCallDispatch();
196
- });
318
+ if (!removed) return;
319
+ }
197
320
  }
198
321
 
199
- function dispatchCall(run) {
200
- if (closed) {
201
- const error = new Error('engine daemon transport is closed');
202
- error.statusCode = 503;
203
- return Promise.reject(error);
204
- }
205
- if (callQueue.length >= CALL_QUEUE_MAX) {
206
- const error = new Error('engine daemon call queue is full');
207
- error.statusCode = 503;
208
- return Promise.reject(error);
322
+ function callLane(name, args = {}) {
323
+ if (CRITICAL_CALLS.has(name)) return 'critical';
324
+ if (INTERACTIVE_CALLS.has(name)) return 'interactive';
325
+ if (name === 'desktop.invoke') {
326
+ const adapterMethod = String(args?.method || '');
327
+ const desktopMethod = adapterMethod === 'invokeDesktopOperation'
328
+ ? String(args?.args?.[0] || '')
329
+ : adapterMethod;
330
+ if (INTERACTIVE_DESKTOP_METHODS.has(desktopMethod)) return 'interactive';
209
331
  }
210
- const promise = new Promise((resolve, reject) => {
211
- callQueue.push({ run, resolve, reject });
212
- });
213
- scheduleCallDispatch();
214
- return promise;
332
+ return 'normal';
333
+ }
334
+
335
+ function dispatchCall(ownerKey, run, { lane = 'normal', signal = null } = {}) {
336
+ const scheduler = lane === 'critical'
337
+ ? criticalCalls
338
+ : lane === 'interactive'
339
+ ? interactiveCalls
340
+ : normalCalls;
341
+ return scheduler.enqueue(ownerKey, run, { signal });
215
342
  }
216
343
 
217
344
  function nowMs() { return Date.now(); }
@@ -233,7 +360,7 @@ export function createEngineDaemonTransport({
233
360
  if (lifecycleClientCount() > 0) return;
234
361
  // Never re-arm an ALREADY armed grace: the 5s sweep also calls this, and
235
362
  // cancel+rearm on every tick pushed the 10s deadline out forever — the
236
- // daemon could never self-shut down through the engine front door.
363
+ // daemon could never self-shut down through the session front door.
237
364
  if (graceTimer) return;
238
365
  graceTimer = setTimeout(() => {
239
366
  graceTimer = null;
@@ -260,9 +387,9 @@ export function createEngineDaemonTransport({
260
387
  function dropClient(token, reason) {
261
388
  if (!clients.has(token)) return;
262
389
  removeClientRecord(token);
263
- // The engine pool refcounts VIEWS by client token: a client that is gone
264
- // must stop holding engines open (and must stop being counted as the
265
- // reason another client's engine survives).
390
+ // The session runtime pool refcounts VIEWS by client token: a client that is gone
391
+ // must stop holding session runtimes open (and must stop being counted as the
392
+ // reason another client's session runtime survives).
266
393
  if (typeof onClientDropped === 'function') {
267
394
  try { onClientDropped(token, reason); } catch {}
268
395
  }
@@ -270,6 +397,14 @@ export function createEngineDaemonTransport({
270
397
  maybeArmGrace(reason);
271
398
  }
272
399
 
400
+ function beginDrain(reason = 'daemon replacement') {
401
+ if (drainingReason) return false;
402
+ drainingReason = String(reason || 'daemon replacement');
403
+ cancelGrace();
404
+ for (const token of [...clients.keys()]) dropClient(token, drainingReason);
405
+ return true;
406
+ }
407
+
273
408
  function startSweep() {
274
409
  if (sweepTimer || closed) return;
275
410
  sweepTimer = setInterval(() => {
@@ -282,7 +417,7 @@ export function createEngineDaemonTransport({
282
417
  sweepTimer.unref?.();
283
418
  }
284
419
 
285
- function registerClient({ leadPid, cwd, lifecycle = true, registrationId = null } = {}) {
420
+ function registerClient({ leadPid, cwd, lifecycle = true, registrationId = null, revision = 0 } = {}) {
286
421
  const pid = parsePid(leadPid) || 0;
287
422
  const ownsLifecycle = lifecycle !== false;
288
423
  const replayId = registrationId ? String(registrationId).slice(0, 200) : null;
@@ -290,7 +425,8 @@ export function createEngineDaemonTransport({
290
425
  if (replay) {
291
426
  const sameIdentity = replay.leadPid === pid
292
427
  && replay.cwd === (cwd || null)
293
- && replay.lifecycle === ownsLifecycle;
428
+ && replay.lifecycle === ownsLifecycle
429
+ && replay.revision === Math.max(0, Number(revision) || 0);
294
430
  if (sameIdentity && clients.has(replay.token)) {
295
431
  log(`client registration replay token=${replay.token} lead=${pid}`);
296
432
  return replay.token;
@@ -309,12 +445,14 @@ export function createEngineDaemonTransport({
309
445
  leadPid: pid,
310
446
  cwd: cwd || null,
311
447
  lifecycle: ownsLifecycle,
448
+ revision: Math.max(0, Number(revision) || 0),
312
449
  sse: null,
313
450
  // True while the socket asked us to stop writing (see writeFrame).
314
451
  paused: false,
315
452
  // Frames are latest-wins per key while a client has no stream: a
316
453
  // reconnecting viewer wants the CURRENT snapshot, never a backlog.
317
454
  pending: new Map(),
455
+ pendingBytes: 0,
318
456
  lastSeen: nowMs(),
319
457
  });
320
458
  if (ownsLifecycle) {
@@ -341,6 +479,7 @@ export function createEngineDaemonTransport({
341
479
  leadPid: pid,
342
480
  cwd: cwd || null,
343
481
  lifecycle: ownsLifecycle,
482
+ revision: Math.max(0, Number(revision) || 0),
344
483
  timer,
345
484
  });
346
485
  }
@@ -352,36 +491,76 @@ export function createEngineDaemonTransport({
352
491
  while (client.sse && !client.paused && client.pending.size > 0) {
353
492
  const [key, entry] = client.pending.entries().next().value;
354
493
  client.pending.delete(key);
494
+ client.pendingBytes = Math.max(0, client.pendingBytes - (entry.bytes || 0));
355
495
  try {
356
496
  if (client.sse.write(`data: ${entry.json}\n\n`) === false) client.paused = true;
357
497
  } catch {
358
498
  client.sse = null;
359
499
  client.pending.set(key, entry);
500
+ client.pendingBytes += entry.bytes || 0;
360
501
  return;
361
502
  }
362
503
  }
363
504
  }
364
505
 
506
+ function pendingEntry(frame, json) {
507
+ return { frame, json, bytes: Buffer.byteLength(json) + 8 };
508
+ }
509
+
510
+ function resyncEntry(frame) {
511
+ if (frame?.type !== 'session-state' || !frame.sessionId) return null;
512
+ const marker = {
513
+ type: 'session-state',
514
+ key: frame.key,
515
+ sessionId: frame.sessionId,
516
+ revision: -1,
517
+ baseRevision: -2,
518
+ patch: { set: {}, remove: [], itemsAppend: null },
519
+ resyncRequired: true,
520
+ };
521
+ return pendingEntry(marker, JSON.stringify(marker));
522
+ }
523
+
524
+ function setPending(client, key, entry) {
525
+ const previous = client.pending.get(key);
526
+ if (previous) client.pendingBytes = Math.max(0, client.pendingBytes - (previous.bytes || 0));
527
+ client.pending.delete(key);
528
+ client.pending.set(key, entry);
529
+ client.pendingBytes += entry.bytes || 0;
530
+ while (client.pendingBytes > SSE_PENDING_MAX_BYTES && client.pending.size > 0) {
531
+ const [oldestKey, oldest] = client.pending.entries().next().value;
532
+ const marker = resyncEntry(oldest.frame);
533
+ if (marker && marker.bytes < oldest.bytes) {
534
+ client.pending.set(oldestKey, marker);
535
+ client.pendingBytes += marker.bytes - oldest.bytes;
536
+ continue;
537
+ }
538
+ client.pending.delete(oldestKey);
539
+ client.pendingBytes = Math.max(0, client.pendingBytes - (oldest.bytes || 0));
540
+ }
541
+ }
542
+
365
543
  function writeFrame(client, frame, json) {
366
544
  const key = frame.key || `${frame.type}:${frame.sessionId || frame.desktopId || ''}`;
367
545
  // A stalled reader must never make the daemon buffer a whole stream in
368
546
  // Node's socket queue: while the socket is full the backlog collapses to
369
547
  // one frame per key, exactly like a client with no stream at all.
370
548
  if (!client.sse || client.paused) {
371
- client.pending.set(key, mergePendingFrame(client.pending.get(key), frame, json));
549
+ const merged = mergePendingFrame(client.pending.get(key), frame, json);
550
+ setPending(client, key, pendingEntry(merged.frame, merged.json));
372
551
  return;
373
552
  }
374
553
  try {
375
554
  if (client.sse.write(`data: ${json}\n\n`) === false) client.paused = true;
376
555
  } catch {
377
556
  client.sse = null;
378
- client.pending.set(key, mergePendingFrame(client.pending.get(key), frame, json));
557
+ const merged = mergePendingFrame(client.pending.get(key), frame, json);
558
+ setPending(client, key, pendingEntry(merged.frame, merged.json));
379
559
  }
380
560
  }
381
561
 
382
562
  /** Session and desktop state reaches only subscribed client tokens. Calls
383
- * without a target set retain the transport-level broadcast used by
384
- * diagnostics and compatibility tests. */
563
+ * without a target set retain the transport-level diagnostic broadcast. */
385
564
  function broadcast(frame, targetTokens = null) {
386
565
  const json = JSON.stringify(frame);
387
566
  if (targetTokens) {
@@ -432,7 +611,7 @@ export function createEngineDaemonTransport({
432
611
  clearInterval(ka);
433
612
  if (c.sse === res) { c.sse = null; c.paused = false; }
434
613
  // Stream loss alone never drops the client: a desktop reload or a TUI
435
- // resize can bounce the stream while the engine keeps running.
614
+ // resize can bounce the stream while the session runtime keeps running.
436
615
  maybeArmGrace('sse closed');
437
616
  };
438
617
  res.on('close', cleanup);
@@ -453,11 +632,31 @@ export function createEngineDaemonTransport({
453
632
  pid: process.pid,
454
633
  clients: lifecycleClientCount(),
455
634
  connections: clients.size,
456
- protocol: ENGINE_DAEMON_PROTOCOL,
457
- version: engineRuntimeVersion(),
635
+ protocol: SESSION_PROTOCOL,
636
+ revision: SESSION_REVISION,
637
+ capabilityFingerprint: SESSION_CAPABILITY_FINGERPRINT,
638
+ draining: drainingReason || null,
639
+ version: runtimeVersion(),
458
640
  ...getStatus(),
459
- activeCalls,
460
- queuedCalls: callQueue.length,
641
+ activeCalls: normalCalls.active + interactiveCalls.active + criticalCalls.active,
642
+ queuedCalls: normalCalls.queued + interactiveCalls.queued + criticalCalls.queued,
643
+ queuedUrgentCalls: criticalCalls.queued,
644
+ queuedInteractiveCalls: interactiveCalls.queued,
645
+ callOwners: normalCalls.snapshot().owners,
646
+ callQueues: {
647
+ critical: criticalCalls.snapshot(),
648
+ interactive: interactiveCalls.snapshot(),
649
+ normal: normalCalls.snapshot(),
650
+ },
651
+ transportMemory: {
652
+ bodyBytesInFlight,
653
+ bodyBytesMax: BODY_INFLIGHT_MAX_BYTES,
654
+ callCacheEntries: callCache.size,
655
+ callCacheBytes,
656
+ callCacheMaxBytes: CALL_CACHE_MAX_BYTES,
657
+ ssePendingBytes: [...clients.values()]
658
+ .reduce((sum, client) => sum + (client.pendingBytes || 0), 0),
659
+ },
461
660
  });
462
661
  return;
463
662
  }
@@ -465,18 +664,33 @@ export function createEngineDaemonTransport({
465
664
  if (token !== serverToken) { sendError(res, 'forbidden', 403); return; }
466
665
 
467
666
  if (req.method === 'POST' && pathName === '/client/register') {
468
- const body = await readLimitedBody(req);
667
+ const body = await readBody(req);
668
+ if (drainingReason) {
669
+ sendError(res, `daemon is draining: ${drainingReason}`, 503);
670
+ return;
671
+ }
672
+ const clientProtocol = Number(body.protocol);
673
+ if (clientProtocol !== SESSION_PROTOCOL) {
674
+ sendError(res, `session protocol ${SESSION_PROTOCOL} required`, 409);
675
+ return;
676
+ }
469
677
  const clientToken = registerClient({
470
678
  leadPid: body.leadPid,
471
679
  cwd: body.cwd,
472
680
  lifecycle: body.lifecycle !== false,
473
681
  registrationId: body.registrationId,
682
+ revision: body.revision,
683
+ });
684
+ sendJson(res, {
685
+ token: clientToken,
686
+ pid: process.pid,
687
+ protocol: SESSION_PROTOCOL,
688
+ revision: SESSION_REVISION,
474
689
  });
475
- sendJson(res, { token: clientToken, pid: process.pid });
476
690
  return;
477
691
  }
478
692
  if (req.method === 'POST' && pathName === '/client/deregister') {
479
- const body = await readLimitedBody(req);
693
+ const body = await readBody(req);
480
694
  if (body.token) dropClient(String(body.token), 'deregister');
481
695
  sendJson(res, { ok: true });
482
696
  return;
@@ -487,36 +701,63 @@ export function createEngineDaemonTransport({
487
701
  return; // stream stays open
488
702
  }
489
703
  if (req.method === 'POST' && pathName === '/call') {
490
- const body = await readLimitedBody(req);
704
+ const body = await readBody(req);
491
705
  const clientToken = body.token ? String(body.token) : null;
492
706
  const c = clientToken ? clients.get(clientToken) : null;
493
707
  if (!c) { sendError(res, 'unknown client token', 404); return; }
494
708
  c.lastSeen = nowMs();
495
709
  const name = String(body.name || '');
496
710
  const callId = body.callId ? String(body.callId) : null;
711
+ const addressedSessionId = String(body.args?.sessionId || '').trim();
712
+ const ownerKey = addressedSessionId
713
+ ? `session:${addressedSessionId}`
714
+ : c.leadPid
715
+ ? `pid:${c.leadPid}`
716
+ : `client:${clientToken}`;
717
+ // Scheduling fairness follows the addressed session, but idempotency
718
+ // belongs to the CALLING PROCESS. Two clients legitimately issuing the
719
+ // same callId against one shared session must not dedupe each other.
720
+ const cacheOwnerKey = c.leadPid
721
+ ? `pid:${c.leadPid}`
722
+ : `client:${clientToken}`;
723
+ const cacheKey = callId && !REPLAY_SAFE_CALLS.has(name)
724
+ ? `${cacheOwnerKey}\u0000${callId}`
725
+ : null;
497
726
  // A retry is the SAME payload under the same id. A different payload
498
727
  // that reuses an id (submission ids are caller-supplied) is a NEW call
499
728
  // and must never be answered out of another call's result.
500
729
  const signature = callId ? callSignature(name, body.args) : null;
501
- const cached = callId ? callCache.get(callId) : null;
730
+ const cached = cacheKey ? callCache.get(cacheKey) : null;
502
731
  let dispatch;
503
732
  if (cached && (!cached.signature || !signature || cached.signature === signature)) {
504
733
  dispatch = cached.promise;
505
734
  } else {
506
- dispatch = dispatchCall(() => handleCall(name, body.args || {}, {
507
- clientToken,
508
- leadPid: c.leadPid ?? null,
509
- cwd: c.cwd ?? null,
510
- }));
511
- if (callId) {
512
- const record = { promise: dispatch, at: nowMs(), signature };
513
- callCache.set(callId, record);
735
+ dispatch = dispatchCall(
736
+ ownerKey,
737
+ () => handleCall(name, body.args || {}, {
738
+ clientToken,
739
+ leadPid: c.leadPid ?? null,
740
+ cwd: c.cwd ?? null,
741
+ revision: c.revision ?? 0,
742
+ }),
743
+ { lane: callLane(name, body.args || {}) },
744
+ );
745
+ if (cacheKey) {
746
+ const record = {
747
+ promise: dispatch, at: nowMs(), signature, settled: false, bytes: 0,
748
+ };
749
+ callCache.set(cacheKey, record);
514
750
  pruneCallCache();
515
751
  // TTL starts at SETTLE: a long-running submit must not expire its
516
752
  // dedup entry mid-flight and let a retry run the turn twice.
517
- dispatch.then(() => {}, () => {}).then(() => {
753
+ dispatch.then((result) => {
754
+ record.bytes = estimateRetainedBytes(result);
755
+ callCacheBytes += record.bytes;
756
+ }, () => {}).then(() => {
757
+ record.settled = true;
758
+ pruneCallCache();
518
759
  const t = setTimeout(() => {
519
- if (callCache.get(callId) === record) callCache.delete(callId);
760
+ deleteCallCacheEntry(cacheKey, record);
520
761
  }, CALL_CACHE_TTL_MS);
521
762
  t.unref?.();
522
763
  });
@@ -526,7 +767,7 @@ export function createEngineDaemonTransport({
526
767
  const result = await dispatch;
527
768
  sendJson(res, { result });
528
769
  } catch (err) {
529
- // Engine errors travel as a 200 {error} envelope so the client can
770
+ // Session call errors travel as a 200 {error} envelope so the client can
530
771
  // tell a failed CALL from a dead TRANSPORT (which must re-attach).
531
772
  sendJson(res, { error: err?.message || String(err) }, 200);
532
773
  }
@@ -537,38 +778,59 @@ export function createEngineDaemonTransport({
537
778
  if (typeof onClientsEmpty === 'function') { try { onClientsEmpty(); } catch {} }
538
779
  return;
539
780
  }
781
+ if (req.method === 'POST' && pathName === '/upgrade') {
782
+ const body = await readBody(req);
783
+ const requestedProtocol = Number(body.protocol);
784
+ const requestedRevision = Math.max(0, Number(body.revision) || 0);
785
+ const requestedVersion = String(body.version || '0.0.0');
786
+ const revisionOrder = requestedRevision - SESSION_REVISION;
787
+ const versionOrder = compareRuntimeVersions(requestedVersion, runtimeVersion());
788
+ const newerBuild = requestedProtocol === SESSION_PROTOCOL
789
+ && (revisionOrder > 0 || (revisionOrder === 0 && versionOrder > 0));
790
+ if (!newerBuild) {
791
+ sendError(
792
+ res,
793
+ `replacement must use protocol ${SESSION_PROTOCOL} with a revision/build newer than ${SESSION_REVISION}/${runtimeVersion()}`,
794
+ 409,
795
+ );
796
+ return;
797
+ }
798
+ sendJson(res, {
799
+ accepted: true,
800
+ protocol: SESSION_PROTOCOL,
801
+ currentRevision: SESSION_REVISION,
802
+ currentVersion: runtimeVersion(),
803
+ requestedRevision,
804
+ requestedVersion,
805
+ });
806
+ queueMicrotask(() => {
807
+ try {
808
+ onUpgradeRequested?.({
809
+ protocol: SESSION_PROTOCOL,
810
+ revision: requestedRevision,
811
+ version: requestedVersion,
812
+ });
813
+ } catch {}
814
+ });
815
+ return;
816
+ }
540
817
  sendError(res, 'not found', 404);
541
818
  } catch (err) {
542
819
  try { sendError(res, err?.message || String(err), err?.statusCode || 500); } catch {}
543
820
  }
544
821
  }
545
822
 
546
- function writeDiscovery() {
547
- if (!discoveryPath) return;
548
- try {
549
- writeJsonAtomicSync(discoveryPath, {
550
- pid: process.pid,
551
- port: boundPort,
552
- token: serverToken,
553
- startedAt: Date.now(),
554
- }, { compact: true });
555
- } catch (err) {
556
- log(`discovery write failed: ${err?.message || err}`);
557
- }
558
- }
559
-
560
823
  function start() {
561
824
  return new Promise((resolve, reject) => {
562
825
  server = http.createServer(handleRequest);
563
826
  server.on('error', reject);
564
- // 127.0.0.1 ONLY — the engine daemon executes tools; it must never be
827
+ // 127.0.0.1 ONLY — the session service executes tools; it must never be
565
828
  // reachable off-box.
566
829
  server.listen(0, '127.0.0.1', () => {
567
830
  server.removeListener('error', reject);
568
831
  boundPort = server.address().port;
569
832
  server.on('error', (err) => log(`server error: ${err?.message || err}`));
570
- writeDiscovery();
571
- log(`engine daemon transport listening on 127.0.0.1:${boundPort} pid=${process.pid}`);
833
+ log(`session service transport listening on 127.0.0.1:${boundPort} pid=${process.pid}`);
572
834
  resolve({ port: boundPort, token: serverToken });
573
835
  });
574
836
  });
@@ -578,13 +840,12 @@ export function createEngineDaemonTransport({
578
840
  closed = true;
579
841
  cancelGrace();
580
842
  if (sweepTimer) { clearInterval(sweepTimer); sweepTimer = null; }
581
- const stopped = new Error('engine daemon transport is closed');
582
- stopped.statusCode = 503;
583
- for (const queued of callQueue.splice(0)) queued.reject(stopped);
843
+ normalCalls.close('session service transport is closed');
844
+ interactiveCalls.close('session service transport is closed');
845
+ criticalCalls.close('session service transport is closed');
584
846
  for (const replay of registrationReplays.values()) clearTimeout(replay.timer);
585
847
  registrationReplays.clear();
586
848
  for (const token of [...clients.keys()]) removeClientRecord(token);
587
- if (discoveryPath) { try { rmSync(discoveryPath, { force: true }); } catch {} }
588
849
  await new Promise((resolve) => {
589
850
  if (!server) { resolve(); return; }
590
851
  server.close(() => resolve());
@@ -596,8 +857,16 @@ export function createEngineDaemonTransport({
596
857
  start,
597
858
  stop,
598
859
  broadcast,
860
+ beginDrain,
599
861
  get port() { return boundPort; },
600
862
  get clientCount() { return lifecycleClientCount(); },
601
863
  get connectionCount() { return clients.size; },
864
+ get activeCount() {
865
+ return normalCalls.active + interactiveCalls.active + criticalCalls.active;
866
+ },
867
+ get queuedCount() {
868
+ return normalCalls.queued + interactiveCalls.queued + criticalCalls.queued;
869
+ },
870
+ get draining() { return Boolean(drainingReason); },
602
871
  };
603
872
  }