mixdog 0.9.95 → 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 (178) 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/package.json +12 -9
  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/src/defaults/mixdog-config.template.json +5 -8
  10. package/src/rules/agent/30-explorer.md +7 -4
  11. package/src/rules/shared/01-tool.md +20 -29
  12. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  13. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  14. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  15. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  16. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  17. package/src/runtime/agent/orchestrator/mcp/client.mjs +26 -11
  18. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  19. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  20. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  21. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  22. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  23. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  25. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  26. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  28. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  29. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  30. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  31. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  32. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  33. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +28 -4
  34. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +4 -33
  35. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  36. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  37. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  38. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +1 -27
  39. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -10
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +5 -0
  41. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  42. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  44. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  45. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  46. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  47. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  48. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +12 -0
  49. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -33
  50. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  51. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  53. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +118 -18
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  56. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  57. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  59. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +10 -26
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +3 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -23
  63. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +23 -9
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  66. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  67. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  70. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  72. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +87 -15
  73. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +6 -15
  74. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  76. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  77. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +29 -1
  78. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  79. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  80. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +10 -10
  81. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +56 -8
  82. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  83. package/src/runtime/channels/lib/config.mjs +2 -2
  84. package/src/runtime/channels/lib/inbound-handler.mjs +1 -1
  85. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  86. package/src/runtime/memory/index.mjs +5 -38
  87. package/src/runtime/memory/lib/agent-ipc.mjs +145 -81
  88. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  89. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  90. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +12 -33
  91. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  92. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  93. package/src/runtime/memory/lib/http-router.mjs +8 -0
  94. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  95. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  96. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  97. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  98. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  99. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  100. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  101. package/src/runtime/memory/tool-defs.mjs +1 -5
  102. package/src/runtime/search/tool-defs.mjs +2 -2
  103. package/src/runtime/shared/agent-route-config.mjs +119 -0
  104. package/src/runtime/shared/atomic-file.mjs +80 -4
  105. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  106. package/src/runtime/shared/child-guardian.mjs +204 -108
  107. package/src/runtime/shared/child-spawn-gate.mjs +60 -31
  108. package/src/runtime/shared/config.mjs +130 -44
  109. package/src/runtime/shared/resource-admission.mjs +146 -97
  110. package/src/runtime/shared/stream-progress.mjs +6 -0
  111. package/src/runtime/shared/tool-surface.mjs +0 -5
  112. package/src/runtime/shared/turn-snapshot.mjs +31 -5
  113. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  114. package/src/session-runtime/config-helpers.mjs +39 -37
  115. package/src/session-runtime/model-route-api.mjs +7 -5
  116. package/src/session-runtime/provider-auth-api.mjs +0 -7
  117. package/src/session-runtime/runtime-core.mjs +11 -11
  118. package/src/session-runtime/runtime-tunables.mjs +4 -4
  119. package/src/session-runtime/session-lifecycle.mjs +9 -2
  120. package/src/session-runtime/session-turn-api.mjs +41 -9
  121. package/src/session-runtime/settings-api.mjs +5 -17
  122. package/src/session-runtime/tool-defs.mjs +3 -3
  123. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  124. package/src/session-runtime/workflow.mjs +14 -24
  125. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  126. package/src/standalone/agent-tool/helpers.mjs +2 -2
  127. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  128. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  129. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  130. package/src/standalone/backend-daemon.mjs +97 -36
  131. package/src/standalone/channel-admin.mjs +12 -12
  132. package/src/standalone/channel-daemon-client.mjs +17 -1
  133. package/src/standalone/channel-daemon-transport.mjs +90 -8
  134. package/src/standalone/engine-daemon-client.mjs +380 -205
  135. package/src/standalone/engine-daemon-protocol.mjs +3 -4
  136. package/src/standalone/engine-daemon-service.mjs +378 -134
  137. package/src/standalone/engine-daemon-transport.mjs +268 -77
  138. package/src/standalone/explore-tool.mjs +187 -70
  139. package/src/standalone/fair-call-scheduler.mjs +264 -0
  140. package/src/standalone/session-protocol.mjs +188 -0
  141. package/src/tui/App.jsx +30 -17
  142. package/src/tui/app/app-view.jsx +5 -2
  143. package/src/tui/app/core-memory-picker.mjs +19 -19
  144. package/src/tui/app/doctor.mjs +5 -11
  145. package/src/tui/app/message-selector.mjs +103 -0
  146. package/src/tui/app/onboarding-steps.mjs +1 -2
  147. package/src/tui/app/project-picker.mjs +80 -56
  148. package/src/tui/app/prompt-submit.mjs +46 -41
  149. package/src/tui/app/resume-picker.mjs +2 -2
  150. package/src/tui/app/route-pickers.mjs +3 -1
  151. package/src/tui/app/settings-picker.mjs +6 -8
  152. package/src/tui/app/transcript-window.mjs +38 -0
  153. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  154. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  155. package/src/tui/app/use-prompt-hint.mjs +3 -2
  156. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  157. package/src/tui/app/use-transcript-scroll.mjs +9 -2
  158. package/src/tui/app/use-transcript-window.mjs +50 -17
  159. package/src/tui/components/PromptInput.jsx +71 -38
  160. package/src/tui/components/Spinner.jsx +1 -1
  161. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  162. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  163. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  164. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  165. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  166. package/src/tui/dist/index.mjs +920 -1592
  167. package/src/tui/engine/live-share.mjs +34 -0
  168. package/src/tui/engine/session-api-ext.mjs +1 -4
  169. package/src/tui/engine/session-api.mjs +103 -33
  170. package/src/tui/engine/session-flow.mjs +7 -1
  171. package/src/tui/engine/tool-card-results.mjs +4 -2
  172. package/src/tui/engine/tool-result-text.mjs +10 -0
  173. package/src/tui/engine/turn.mjs +9 -69
  174. package/src/tui/engine-local-session.mjs +47 -31
  175. package/src/tui/index.jsx +1 -2
  176. package/src/tui/spinner-meta.mjs +4 -4
  177. package/src/tui/spinner-verbs.mjs +51 -3
  178. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
@@ -17,18 +17,29 @@ import { rmSync } from 'node:fs';
17
17
  import { writeJsonAtomicSync } from '../runtime/shared/atomic-file.mjs';
18
18
  import { sendJson, sendError } from '../runtime/memory/lib/http-wire.mjs';
19
19
  import { ENGINE_DAEMON_PROTOCOL, engineRuntimeVersion } from './engine-daemon-protocol.mjs';
20
+ import { createFairCallScheduler } from './fair-call-scheduler.mjs';
20
21
 
21
22
  // A loopback front door still buffers whatever a client sends before it can be
22
23
  // 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)
24
+ const MAX_BODY_BYTES = Math.max(1, Number(process.env.MIXDOG_ENGINE_DAEMON_MAX_BODY_MB) || 32)
24
25
  * 1024 * 1024;
25
26
 
26
- function readLimitedBody(req) {
27
+ function readLimitedBody(req, {
28
+ reserve = () => true,
29
+ release = () => {},
30
+ } = {}) {
27
31
  return new Promise((resolve, reject) => {
28
32
  let settled = false;
33
+ let reserved = 0;
34
+ const releaseReserved = () => {
35
+ if (reserved <= 0) return;
36
+ release(reserved);
37
+ reserved = 0;
38
+ };
29
39
  const fail = (message, statusCode) => {
30
40
  if (settled) return;
31
41
  settled = true;
42
+ releaseReserved();
32
43
  const error = new Error(message);
33
44
  error.statusCode = statusCode;
34
45
  try { req.destroy(); } catch {}
@@ -45,21 +56,32 @@ function readLimitedBody(req) {
45
56
  if (settled) return;
46
57
  size += chunk.length;
47
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;
48
61
  chunks.push(chunk);
49
62
  });
50
63
  req.on('end', () => {
51
64
  if (settled) return;
52
65
  settled = true;
53
- const raw = Buffer.concat(chunks).toString('utf8').trim();
54
- if (!raw) { resolve({}); return; }
55
- try { resolve(JSON.parse(raw)); }
66
+ try {
67
+ const raw = Buffer.concat(chunks).toString('utf8').trim();
68
+ if (!raw) { resolve({}); return; }
69
+ resolve(JSON.parse(raw));
70
+ }
56
71
  catch (error) {
57
72
  const err = new Error(`invalid JSON body: ${error.message}`);
58
73
  err.statusCode = 400;
59
74
  reject(err);
60
75
  }
76
+ finally { releaseReserved(); }
77
+ });
78
+ req.on('error', (error) => {
79
+ if (!settled) {
80
+ settled = true;
81
+ releaseReserved();
82
+ reject(error);
83
+ }
61
84
  });
62
- req.on('error', (error) => { if (!settled) { settled = true; reject(error); } });
63
85
  });
64
86
  }
65
87
 
@@ -89,6 +111,10 @@ function isPidAlive(pid) {
89
111
  // terminal frames therefore concatenate, bounded so a wedged reader can never
90
112
  // grow the daemon's heap without limit.
91
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
+ );
92
118
 
93
119
  function terminalDataFrame(frame) {
94
120
  return frame?.type === 'desktop-event'
@@ -136,23 +162,98 @@ export function createEngineDaemonTransport({
136
162
  let sweepTimer = null;
137
163
  let everHadLifecycleClient = false;
138
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
+ });
139
180
  // Idempotency cache: a transport retry of the SAME callId must never run a
140
181
  // second engine mutation (submit/abort are not idempotent).
141
182
  const callCache = new Map();
183
+ let callCacheBytes = 0;
142
184
  const CALL_CACHE_TTL_MS = 60_000;
143
185
  // Bound the dedup table: a call that never settles would otherwise pin its
144
186
  // entry for the daemon's whole life.
145
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
+ );
146
192
  // A burst of panes used to enqueue every handleCall() as a microtask. Node
147
193
  // drains the whole microtask queue before returning to HTTP, so enough cheap
148
194
  // calls could starve /health and /client/register even though no explicit
149
195
  // mutex existed. Admit one call per event-loop turn while allowing admitted
150
196
  // 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;
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
+ });
156
257
  // A timed-out registration may already have committed server-side. Replaying
157
258
  // the same registrationId returns that token instead of leaking a second
158
259
  // client record and another lifecycle reference.
@@ -160,58 +261,65 @@ export function createEngineDaemonTransport({
160
261
  const REGISTRATION_REPLAY_TTL_MS = 30_000;
161
262
  const REGISTRATION_REPLAY_MAX = 256;
162
263
 
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);
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;
168
284
  }
285
+ seen.delete(value);
286
+ return Math.min(limit, bytes);
169
287
  }
170
288
 
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;
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;
181
298
  }
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
- });
299
+ if (!removed) return;
300
+ }
197
301
  }
198
302
 
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);
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';
209
312
  }
210
- const promise = new Promise((resolve, reject) => {
211
- callQueue.push({ run, resolve, reject });
212
- });
213
- scheduleCallDispatch();
214
- return promise;
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 });
215
323
  }
216
324
 
217
325
  function nowMs() { return Date.now(); }
@@ -315,6 +423,7 @@ export function createEngineDaemonTransport({
315
423
  // Frames are latest-wins per key while a client has no stream: a
316
424
  // reconnecting viewer wants the CURRENT snapshot, never a backlog.
317
425
  pending: new Map(),
426
+ pendingBytes: 0,
318
427
  lastSeen: nowMs(),
319
428
  });
320
429
  if (ownsLifecycle) {
@@ -352,30 +461,71 @@ export function createEngineDaemonTransport({
352
461
  while (client.sse && !client.paused && client.pending.size > 0) {
353
462
  const [key, entry] = client.pending.entries().next().value;
354
463
  client.pending.delete(key);
464
+ client.pendingBytes = Math.max(0, client.pendingBytes - (entry.bytes || 0));
355
465
  try {
356
466
  if (client.sse.write(`data: ${entry.json}\n\n`) === false) client.paused = true;
357
467
  } catch {
358
468
  client.sse = null;
359
469
  client.pending.set(key, entry);
470
+ client.pendingBytes += entry.bytes || 0;
360
471
  return;
361
472
  }
362
473
  }
363
474
  }
364
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
+
365
513
  function writeFrame(client, frame, json) {
366
514
  const key = frame.key || `${frame.type}:${frame.sessionId || frame.desktopId || ''}`;
367
515
  // A stalled reader must never make the daemon buffer a whole stream in
368
516
  // Node's socket queue: while the socket is full the backlog collapses to
369
517
  // one frame per key, exactly like a client with no stream at all.
370
518
  if (!client.sse || client.paused) {
371
- client.pending.set(key, mergePendingFrame(client.pending.get(key), frame, json));
519
+ const merged = mergePendingFrame(client.pending.get(key), frame, json);
520
+ setPending(client, key, pendingEntry(merged.frame, merged.json));
372
521
  return;
373
522
  }
374
523
  try {
375
524
  if (client.sse.write(`data: ${json}\n\n`) === false) client.paused = true;
376
525
  } catch {
377
526
  client.sse = null;
378
- client.pending.set(key, mergePendingFrame(client.pending.get(key), frame, json));
527
+ const merged = mergePendingFrame(client.pending.get(key), frame, json);
528
+ setPending(client, key, pendingEntry(merged.frame, merged.json));
379
529
  }
380
530
  }
381
531
 
@@ -456,8 +606,25 @@ export function createEngineDaemonTransport({
456
606
  protocol: ENGINE_DAEMON_PROTOCOL,
457
607
  version: engineRuntimeVersion(),
458
608
  ...getStatus(),
459
- activeCalls,
460
- queuedCalls: callQueue.length,
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
+ },
461
628
  });
462
629
  return;
463
630
  }
@@ -465,7 +632,7 @@ export function createEngineDaemonTransport({
465
632
  if (token !== serverToken) { sendError(res, 'forbidden', 403); return; }
466
633
 
467
634
  if (req.method === 'POST' && pathName === '/client/register') {
468
- const body = await readLimitedBody(req);
635
+ const body = await readBody(req);
469
636
  const clientToken = registerClient({
470
637
  leadPid: body.leadPid,
471
638
  cwd: body.cwd,
@@ -476,7 +643,7 @@ export function createEngineDaemonTransport({
476
643
  return;
477
644
  }
478
645
  if (req.method === 'POST' && pathName === '/client/deregister') {
479
- const body = await readLimitedBody(req);
646
+ const body = await readBody(req);
480
647
  if (body.token) dropClient(String(body.token), 'deregister');
481
648
  sendJson(res, { ok: true });
482
649
  return;
@@ -487,36 +654,60 @@ export function createEngineDaemonTransport({
487
654
  return; // stream stays open
488
655
  }
489
656
  if (req.method === 'POST' && pathName === '/call') {
490
- const body = await readLimitedBody(req);
657
+ const body = await readBody(req);
491
658
  const clientToken = body.token ? String(body.token) : null;
492
659
  const c = clientToken ? clients.get(clientToken) : null;
493
660
  if (!c) { sendError(res, 'unknown client token', 404); return; }
494
661
  c.lastSeen = nowMs();
495
662
  const name = String(body.name || '');
496
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;
497
677
  // A retry is the SAME payload under the same id. A different payload
498
678
  // that reuses an id (submission ids are caller-supplied) is a NEW call
499
679
  // and must never be answered out of another call's result.
500
680
  const signature = callId ? callSignature(name, body.args) : null;
501
- const cached = callId ? callCache.get(callId) : null;
681
+ const cached = cacheKey ? callCache.get(cacheKey) : null;
502
682
  let dispatch;
503
683
  if (cached && (!cached.signature || !signature || cached.signature === signature)) {
504
684
  dispatch = cached.promise;
505
685
  } 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);
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);
514
700
  pruneCallCache();
515
701
  // TTL starts at SETTLE: a long-running submit must not expire its
516
702
  // dedup entry mid-flight and let a retry run the turn twice.
517
- dispatch.then(() => {}, () => {}).then(() => {
703
+ dispatch.then((result) => {
704
+ record.bytes = estimateRetainedBytes(result);
705
+ callCacheBytes += record.bytes;
706
+ }, () => {}).then(() => {
707
+ record.settled = true;
708
+ pruneCallCache();
518
709
  const t = setTimeout(() => {
519
- if (callCache.get(callId) === record) callCache.delete(callId);
710
+ deleteCallCacheEntry(cacheKey, record);
520
711
  }, CALL_CACHE_TTL_MS);
521
712
  t.unref?.();
522
713
  });
@@ -578,9 +769,9 @@ export function createEngineDaemonTransport({
578
769
  closed = true;
579
770
  cancelGrace();
580
771
  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);
772
+ normalCalls.close('engine daemon transport is closed');
773
+ interactiveCalls.close('engine daemon transport is closed');
774
+ criticalCalls.close('engine daemon transport is closed');
584
775
  for (const replay of registrationReplays.values()) clearTimeout(replay.timer);
585
776
  registrationReplays.clear();
586
777
  for (const token of [...clients.keys()]) removeClientRecord(token);