mixdog 0.9.151 → 0.9.152

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 (175) hide show
  1. package/README.md +110 -68
  2. package/package.json +3 -2
  3. package/scripts/tool-stress.mjs +0 -2
  4. package/src/defaults/skills/setup/SKILL.md +18 -27
  5. package/src/headless-exec.mjs +3 -10
  6. package/src/headless-exec.test.mjs +2 -7
  7. package/src/runtime/agent/orchestrator/config.mjs +6 -66
  8. package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
  9. package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
  10. package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
  11. package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
  12. package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
  13. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
  14. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
  15. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
  16. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
  17. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
  19. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +10 -2
  20. package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
  21. package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
  23. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
  24. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
  25. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
  26. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
  27. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -0
  28. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -2
  29. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
  30. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +4 -7
  31. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +9 -0
  32. package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +235 -0
  33. package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
  34. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
  35. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
  36. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +14 -4
  37. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
  38. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +24 -1
  39. package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +161 -0
  40. package/src/runtime/agent/orchestrator/session/context-utils.mjs +14 -4
  41. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +12 -1
  43. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +31 -18
  44. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -0
  45. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +6 -1
  47. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +1 -3
  48. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +556 -0
  49. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +457 -0
  50. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +105 -186
  51. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
  52. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +2 -0
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +23 -1
  55. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
  57. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
  58. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
  59. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
  60. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
  61. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
  62. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
  63. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
  64. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
  65. package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
  69. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
  71. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
  73. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
  74. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
  75. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  76. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +8 -0
  77. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  78. package/src/runtime/browser-bridge/client.mjs +103 -0
  79. package/src/runtime/browser-bridge/tool-defs.mjs +42 -0
  80. package/src/runtime/computer-bridge/client.mjs +101 -0
  81. package/src/runtime/computer-bridge/tool-defs.mjs +53 -0
  82. package/src/runtime/media/store.mjs +18 -8
  83. package/src/runtime/media/store.test.mjs +14 -0
  84. package/src/runtime/memory/index.mjs +1 -1
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
  86. package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
  87. package/src/runtime/shared/child-spawn-gate.mjs +17 -0
  88. package/src/runtime/shared/child-spawn-remote.mjs +69 -7
  89. package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
  90. package/src/runtime/shared/pristine-execution-contract.json +2 -1
  91. package/src/runtime/shared/provider-api-key.mjs +1 -0
  92. package/src/runtime/shared/session-runtime-health.mjs +91 -0
  93. package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
  94. package/src/runtime/shared/skill-document.mjs +86 -0
  95. package/src/runtime/shared/skill-document.test.mjs +81 -0
  96. package/src/runtime/shared/tool-surface.mjs +18 -0
  97. package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
  98. package/src/runtime/shared/turn-snapshot.mjs +32 -9
  99. package/src/runtime/shared/user-cwd.mjs +33 -14
  100. package/src/runtime/shared/user-cwd.test.mjs +43 -0
  101. package/src/session-runtime/cwd-plugins.mjs +26 -26
  102. package/src/session-runtime/cwd-plugins.test.mjs +75 -0
  103. package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
  104. package/src/session-runtime/global-extensions.mjs +26 -0
  105. package/src/session-runtime/global-extensions.test.mjs +28 -0
  106. package/src/session-runtime/goal-runtime.mjs +721 -0
  107. package/src/session-runtime/goal-runtime.test.mjs +108 -0
  108. package/src/session-runtime/lifecycle-api.mjs +18 -4
  109. package/src/session-runtime/lifecycle-api.test.mjs +21 -0
  110. package/src/session-runtime/mcp-glue.mjs +49 -47
  111. package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
  112. package/src/session-runtime/plugin-mcp.mjs +96 -22
  113. package/src/session-runtime/prewarm.mjs +1 -10
  114. package/src/session-runtime/resource-api-global.test.mjs +69 -0
  115. package/src/session-runtime/resource-api.mjs +173 -58
  116. package/src/session-runtime/runtime-core.mjs +148 -20
  117. package/src/session-runtime/runtime-tunables.mjs +6 -8
  118. package/src/session-runtime/session-turn-api.mjs +22 -11
  119. package/src/session-runtime/skills-api.mjs +79 -25
  120. package/src/session-runtime/skills-api.test.mjs +99 -0
  121. package/src/session-runtime/tool-catalog-schema.mjs +0 -5
  122. package/src/session-runtime/tool-catalog.mjs +4 -13
  123. package/src/session-runtime/tool-policy-surface.test.mjs +33 -11
  124. package/src/session-runtime/tool-surface.mjs +12 -0
  125. package/src/standalone/agent-dispatch-broker.mjs +5 -9
  126. package/src/standalone/agent-tool/notify.mjs +6 -8
  127. package/src/standalone/agent-tool/notify.test.mjs +37 -0
  128. package/src/standalone/daemon.mjs +31 -15
  129. package/src/standalone/hook-bus/config.mjs +1 -0
  130. package/src/standalone/plugin-admin.mjs +17 -0
  131. package/src/standalone/plugin-admin.test.mjs +7 -0
  132. package/src/standalone/provider-admin.mjs +1 -0
  133. package/src/standalone/session-protocol.mjs +5 -0
  134. package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
  135. package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
  136. package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
  137. package/src/standalone/session-runtime-host-factory.mjs +17 -0
  138. package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
  139. package/src/standalone/session-runtime-host-health.test.mjs +5 -0
  140. package/src/standalone/session-runtime-host.mjs +696 -58
  141. package/src/standalone/session-runtime-inline-host.mjs +351 -0
  142. package/src/standalone/session-runtime-inline-host.test.mjs +83 -0
  143. package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
  144. package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
  145. package/src/standalone/session-runtime-shard-host.test.mjs +569 -0
  146. package/src/standalone/session-runtime-shard-router.mjs +149 -0
  147. package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
  148. package/src/standalone/session-runtime-worker.mjs +476 -18
  149. package/src/standalone/session-service.mjs +81 -6
  150. package/src/tui/app/app-view.jsx +1 -0
  151. package/src/tui/app/core-memory-picker.mjs +2 -0
  152. package/src/tui/app/extension-pickers.mjs +1 -1
  153. package/src/tui/app/maintenance-pickers.mjs +3 -3
  154. package/src/tui/app/model-options.mjs +2 -0
  155. package/src/tui/app/model-picker.mjs +14 -4
  156. package/src/tui/app/panel-handoff.test.mjs +311 -0
  157. package/src/tui/app/project-picker.mjs +1 -0
  158. package/src/tui/app/route-pickers.mjs +21 -6
  159. package/src/tui/app/settings-picker.mjs +26 -3
  160. package/src/tui/app/slash-commands.mjs +1 -0
  161. package/src/tui/app/slash-dispatch.mjs +73 -22
  162. package/src/tui/app/theme-effort-pickers.mjs +7 -2
  163. package/src/tui/components/Picker.jsx +10 -1
  164. package/src/tui/dist/index.mjs +163 -44
  165. package/src/tui/session/context-state.mjs +1 -0
  166. package/src/tui/session/goal-continuation.mjs +110 -0
  167. package/src/tui/session/goal-continuation.test.mjs +84 -0
  168. package/src/tui/session/queue-helpers.mjs +5 -2
  169. package/src/tui/session/session-action-surface.test.mjs +108 -0
  170. package/src/tui/session/session-api-ext.mjs +39 -8
  171. package/src/tui/session/session-api.mjs +58 -0
  172. package/src/tui/session/session-flow.mjs +6 -0
  173. package/src/tui/session/turn.mjs +1 -0
  174. package/src/tui/session-local.mjs +15 -1
  175. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
@@ -1,18 +1,22 @@
1
1
  // Resident native search: forwards rg-style windowed line requests to ONE
2
- // long-lived `mixdog-graph --serve-search` process. The server embeds the
3
- // ripgrep matcher/searcher/printer crates and is the only local-search backend.
4
- // Unsupported requests and server failures are explicit errors at the caller.
2
+ // long-lived engine that embeds the ripgrep matcher/searcher/printer crates
3
+ // and is the only local-search backend. Unsupported requests and server
4
+ // failures are explicit errors at the caller.
5
+ //
6
+ // The engine is reached through one crash-isolated
7
+ // `mixdog-graph --serve-search` child process.
8
+ //
5
9
  // MIXDOG_SEARCH_SERVER=0 disables; MIXDOG_SEARCH_SERVER_BIN overrides the
6
- // binary (dev builds).
7
- import { spawn } from 'node:child_process';
10
+ // executable.
8
11
  import { existsSync } from 'node:fs';
9
- import { createInterface } from 'node:readline';
10
- import { hiddenSpawnOpts } from '../../../../shared/spawn-flags.mjs';
12
+ import {
13
+ bindChildLifecycle,
14
+ createNativeSearchTransport,
15
+ } from './native-search-transport.mjs';
11
16
  import { reportRuntimeWorkerUnhealthy } from '../../../../shared/session-runtime-health.mjs';
12
17
  import { invalidateBuiltinResultCache } from './cache-layers.mjs';
13
18
  import { getPluginData } from '../../config.mjs';
14
19
  import { ensureGraphBinary } from '../graph-binary-fetcher.mjs';
15
- import { fuzzyRank, prepareFuzzyItems } from './fuzzy-match.mjs';
16
20
 
17
21
  const RESTART_BACKOFF_MS = 30_000;
18
22
  // Default request budget. Deliberately BELOW the read-only I/O watchdog in
@@ -28,7 +32,7 @@ const PROCESS_FAILURES_BEFORE_BACKOFF = 2;
28
32
  const SEARCH_TIMEOUT_RECYCLE_WINDOW_MS = 30_000;
29
33
  const SEARCH_TIMEOUT_BURST_MS = 1_000;
30
34
 
31
- let _server = null; // { child, pending: Map, sequence }
35
+ let _server = null; // { transport, exited, pending: Map, sequence }
32
36
  let _binaryPath = undefined; // undefined = unresolved, null = unavailable
33
37
  let _lastFailureAt = 0;
34
38
  let _lastFailure = null;
@@ -149,11 +153,7 @@ function unavailableError() {
149
153
  }
150
154
 
151
155
  function _setServerReferenced(server, referenced) {
152
- const method = referenced ? 'ref' : 'unref';
153
- try { server?.child?.[method]?.(); } catch {}
154
- try { server?.child?.stdin?.[method]?.(); } catch {}
155
- try { server?.child?.stdout?.[method]?.(); } catch {}
156
- try { server?.child?.stderr?.[method]?.(); } catch {}
156
+ try { server?.transport?.[referenced ? 'ref' : 'unref']?.(); } catch {}
157
157
  }
158
158
 
159
159
  function _resolveBinary() {
@@ -201,18 +201,18 @@ function _teardown(error, { countFailure = true, detail = '' } = {}) {
201
201
  for (const timer of server.cancelWatchdogs?.values?.() || []) clearTimeout(timer);
202
202
  server.cancelWatchdogs?.clear?.();
203
203
  try { server.resolveReady?.(false); } catch {}
204
- try { server.child.kill(); } catch {}
204
+ try { server.transport.kill(); } catch {}
205
205
  }
206
206
 
207
207
  export async function shutdownNativeSearchServer(reason = 'process-exit', timeoutMs = 1_000) {
208
208
  const server = _server;
209
209
  if (!server) return true;
210
- const child = server.child;
211
- const exited = new Promise((resolve) => {
212
- child.once('exit', () => resolve(true));
213
- child.once('error', () => resolve(true));
214
- });
215
- try { child.stdin?.end?.(); } catch {}
210
+ const transport = server.transport;
211
+ // The exit promise is armed at attach time rather than here: the transport
212
+ // routes each event to exactly one handler, so a second listener registered
213
+ // now would displace the teardown wiring.
214
+ const exited = server.exited;
215
+ try { transport.end(); } catch {}
216
216
  _teardown(new Error(`native search server shutdown (${reason})`), { countFailure: false });
217
217
  _warmPromise = null;
218
218
  let timer;
@@ -224,20 +224,12 @@ export async function shutdownNativeSearchServer(reason = 'process-exit', timeou
224
224
  ]);
225
225
  if (timer) clearTimeout(timer);
226
226
  if (!stopped) {
227
- try { child.kill('SIGKILL'); } catch {}
227
+ try { transport.kill('SIGKILL'); } catch {}
228
228
  }
229
229
  return stopped;
230
230
  }
231
231
 
232
- export function _bindNativeSearchServerLifecycle(child, { onError, onExit } = {}) {
233
- if (!child?.on) return;
234
- child.on('error', onError);
235
- child.on('exit', onExit);
236
- // ChildProcess stdin emits write failures asynchronously. A sync try/catch
237
- // around stdin.write cannot catch EPIPE; without this listener the session
238
- // runtime worker itself terminates and every active/queued turn is recovered.
239
- child.stdin?.on?.('error', onError);
240
- }
232
+ export { bindChildLifecycle as _bindNativeSearchServerLifecycle };
241
233
 
242
234
  function _ensureServer() {
243
235
  if (_server) return _server;
@@ -245,22 +237,26 @@ function _ensureServer() {
245
237
  _consecutiveProcessFailures >= PROCESS_FAILURES_BEFORE_BACKOFF
246
238
  && Date.now() - _lastFailureAt < RESTART_BACKOFF_MS
247
239
  ) return null;
248
- const binary = _resolveBinary();
249
- if (!binary) return null;
250
- let child;
240
+ let transport;
251
241
  try {
252
- child = spawn(binary, [process.cwd(), '--serve-search'], {
253
- stdio: ['pipe', 'pipe', 'pipe'],
254
- ...hiddenSpawnOpts,
242
+ transport = createNativeSearchTransport({
243
+ binaryPath: _resolveBinary(),
244
+ cwd: process.cwd(),
255
245
  });
256
246
  } catch (error) {
257
247
  noteProcessFailure(processFailure(error, null, 'spawn failed'));
258
248
  return null;
259
249
  }
250
+ if (!transport) return null;
260
251
  let resolveReady;
261
252
  const ready = new Promise((resolve) => { resolveReady = resolve; });
253
+ // Armed here, not at shutdown: the transport routes each event to exactly
254
+ // one handler, so a listener added later would displace the teardown wiring.
255
+ let markExited;
256
+ const exited = new Promise((resolve) => { markExited = resolve; });
262
257
  const server = {
263
- child,
258
+ transport,
259
+ exited,
264
260
  pending: new Map(),
265
261
  cancelWatchdogs: new Map(),
266
262
  sequence: 0,
@@ -270,11 +266,10 @@ function _ensureServer() {
270
266
  readyWaiters: 0,
271
267
  resolveReady,
272
268
  };
273
- child.stderr?.on?.('data', (chunk) => {
269
+ transport.on('stderr', (chunk) => {
274
270
  server.stderrTail = `${server.stderrTail}${String(chunk || '')}`.slice(-8192);
275
271
  });
276
- const lines = createInterface({ input: child.stdout });
277
- lines.on('line', (line) => {
272
+ transport.on('line', (line) => {
278
273
  let message;
279
274
  try { message = JSON.parse(line); } catch { return; }
280
275
  if (message?.ready === true) {
@@ -297,24 +292,27 @@ function _ensureServer() {
297
292
  clearProcessFailures();
298
293
  pending.resolve(message);
299
294
  });
300
- _bindNativeSearchServerLifecycle(child, {
301
- onError: (error) => { if (_server === server) _teardown(error); },
302
- onExit: (code, signal) => {
303
- if (_server === server) {
304
- _teardown(null, {
305
- // A clean code-0 exit is the server's own idle self-shutdown
306
- // (MIXDOG_SEARCH_SERVER_IDLE_MS), not a fault. Counting it would let
307
- // two quiet stretches trip the restart backoff and refuse the next
308
- // real search for 30s.
309
- countFailure: code !== 0,
310
- detail: `exit code=${code ?? 'null'} signal=${signal ?? 'null'}`,
311
- });
312
- }
313
- },
295
+ transport.on('error', (error) => {
296
+ markExited(true);
297
+ if (_server === server) _teardown(error);
298
+ });
299
+ transport.on('exit', (code, signal) => {
300
+ markExited(true);
301
+ if (_server === server) {
302
+ _teardown(null, {
303
+ // A clean code-0 exit is the server's own idle self-shutdown
304
+ // (MIXDOG_SEARCH_SERVER_IDLE_MS), not a fault. Counting it would let
305
+ // two quiet stretches trip the restart backoff and refuse the next
306
+ // real search for 30s.
307
+ countFailure: code !== 0,
308
+ detail: `exit code=${code ?? 'null'} signal=${signal ?? 'null'}`,
309
+ });
310
+ }
314
311
  });
315
- // A detached child can still pin a one-shot CLI/test through its pipe
316
- // handles. Keep the resident server idle-unreferenced, then ref all handles
317
- // only while a request is awaiting a response.
312
+ // An attached engine must never be the reason a one-shot CLI or test stays
313
+ // alive — a child's pipe handles and the addon's keep-alive handle both do
314
+ // that. Keep the resident server idle-unreferenced, then reference it only
315
+ // while a request is awaiting a response.
318
316
  _setServerReferenced(server, false);
319
317
  _server = server;
320
318
  return server;
@@ -384,7 +382,7 @@ function armNativeCancellationWatchdog(server, request) {
384
382
  if (_server === server) {
385
383
  _teardown(error, { countFailure: false, detail: 'cancellation grace expired' });
386
384
  } else {
387
- try { server.child.kill(); } catch {}
385
+ try { server.transport.kill(); } catch {}
388
386
  }
389
387
  }, graceMs);
390
388
  timer.unref?.();
@@ -464,7 +462,7 @@ async function requestNative(server, request, execOptions, deadlineMs) {
464
462
  let onAbort = null;
465
463
  let unsubscribeAbort = null;
466
464
  const cancelServerWork = () => {
467
- try { server.child.stdin.write(`${JSON.stringify({ cancel: request.id })}\n`); } catch {}
465
+ try { server.transport.write(`${JSON.stringify({ cancel: request.id })}\n`); } catch {}
468
466
  armNativeCancellationWatchdog(server, request);
469
467
  };
470
468
  const settle = (value, cancel = false, error = null) => {
@@ -529,7 +527,7 @@ async function requestNative(server, request, execOptions, deadlineMs) {
529
527
  }
530
528
  unsubscribeAbort = subscribeAbortSignal(execOptions.signal, onAbort);
531
529
  try {
532
- server.child.stdin.write(`${JSON.stringify(request)}\n`);
530
+ server.transport.write(`${JSON.stringify(request)}\n`);
533
531
  } catch (error) {
534
532
  if (_server === server) _teardown(error);
535
533
  else {
@@ -711,6 +709,10 @@ export async function tryServeFuzzySearch(args, execOptions = {}) {
711
709
  limit: Math.max(1, Math.min(1_000, Math.floor(Number(args?.limit) || 25))),
712
710
  hidden: args?.hidden !== false,
713
711
  includeNoise: args?.includeNoise === true,
712
+ // A fuzzy deadline returns a truthful partial window. Keep the one
713
+ // query-independent inventory walk alive so a retry can use its complete
714
+ // cache instead of starting a second whole-tree enumeration.
715
+ keepInventory: true,
714
716
  ...(Number.isFinite(Number(args?.maxDepth)) && Number(args.maxDepth) > 0
715
717
  ? { maxDepth: Math.floor(Number(args.maxDepth)) }
716
718
  : {}),
@@ -727,18 +729,8 @@ export async function tryServeFuzzySearch(args, execOptions = {}) {
727
729
  throw codedError('NATIVE_SEARCH_ERROR', String(response.error));
728
730
  }
729
731
  if (!Array.isArray(response?.matches)) throw unavailableError();
730
- // Parity with the JS scorer's noise floor: the native matcher scores any
731
- // subsequence, so a query with no real hit returns scattered junk paths
732
- // (hash-named artifacts and the like). Re-rank the returned window through
733
- // fuzzyRank, which keeps contiguous/basename hits unconditionally and
734
- // drops sub-floor subsequence-only matches instead of surfacing noise.
735
- const query = String(args?.query || '');
736
- const filtered = query
737
- ? fuzzyRank(query, prepareFuzzyItems(response.matches.map(String)))
738
- .map((entry) => entry.item.path)
739
- : response.matches.map(String);
740
732
  return {
741
- matches: filtered,
733
+ matches: response.matches.map(String),
742
734
  hasMore: response.hasMore === true,
743
735
  totalMatches: Math.max(0, Number(response.totalMatches) || 0),
744
736
  totalSeen: Math.max(0, Number(response.totalSeen) || 0),
@@ -17,11 +17,8 @@ async function serve(argsList, execOptions, opts) {
17
17
  recordLocalSearchBackend('native', performance.now() - startedAt, 'hit');
18
18
  return result;
19
19
  } catch (error) {
20
- // A backend that cannot serve this request is a miss, not an error:
21
- // the caller falls back and still returns a complete answer. Counting
22
- // the fallback as a backend error made healthy calls read as silent
23
- // failures in run telemetry (9 of them in one measured run, all of
24
- // them ordinary fallbacks for paths outside the served inventory).
20
+ // Unsupported request shapes are classified separately in telemetry,
21
+ // but still throw: there is no alternate local-search backend.
25
22
  const code = String(error?.code || '');
26
23
  const outcome = code === 'NATIVE_SEARCH_UNAVAILABLE' || code === 'NATIVE_SEARCH_UNSUPPORTED'
27
24
  ? 'miss'
@@ -0,0 +1,75 @@
1
+ // Crash-isolated transport for the resident native search engine.
2
+ //
3
+ // Search runs as one long-lived `mixdog-graph --serve-search` child. Keeping
4
+ // the Rust engine outside the daemon is intentional: a stuck native scan can
5
+ // be killed and restarted without blocking every session on the daemon loop.
6
+ import { spawn } from 'node:child_process';
7
+ import { createInterface } from 'node:readline';
8
+ import { hiddenSpawnOpts } from '../../../../shared/spawn-flags.mjs';
9
+
10
+ /** Minimal event fan-out: one transport feeds exactly one client. */
11
+ function createEmitter() {
12
+ const handlers = { line: null, stderr: null, error: null, exit: null };
13
+ return {
14
+ handlers,
15
+ emit(name, ...args) {
16
+ const handler = handlers[name];
17
+ if (typeof handler !== 'function') return;
18
+ try { handler(...args); } catch { /* a listener fault must not kill the transport */ }
19
+ },
20
+ };
21
+ }
22
+
23
+ function createChildTransport(binaryPath, cwd) {
24
+ const child = spawn(binaryPath, [cwd, '--serve-search'], {
25
+ stdio: ['pipe', 'pipe', 'pipe'],
26
+ ...hiddenSpawnOpts,
27
+ });
28
+ const { handlers, emit } = createEmitter();
29
+ child.stderr?.on?.('data', (chunk) => emit('stderr', chunk));
30
+ const lines = createInterface({ input: child.stdout });
31
+ lines.on('line', (line) => emit('line', line));
32
+ bindChildLifecycle(child, {
33
+ onError: (error) => emit('error', error),
34
+ onExit: (code, signal) => emit('exit', code, signal),
35
+ });
36
+ return {
37
+ kind: 'child',
38
+ child,
39
+ write(text) { child.stdin.write(text); },
40
+ end() { try { child.stdin?.end?.(); } catch { /* already closed */ } },
41
+ kill(signal) { try { child.kill(signal); } catch { /* already gone */ } },
42
+ ref() {
43
+ for (const handle of [child, child.stdin, child.stdout, child.stderr]) {
44
+ try { handle?.ref?.(); } catch { /* detached handle */ }
45
+ }
46
+ },
47
+ unref() {
48
+ for (const handle of [child, child.stdin, child.stdout, child.stderr]) {
49
+ try { handle?.unref?.(); } catch { /* detached handle */ }
50
+ }
51
+ },
52
+ on(name, handler) { handlers[name] = handler; },
53
+ };
54
+ }
55
+
56
+ /**
57
+ * ChildProcess stdin emits write failures asynchronously. A sync try/catch
58
+ * around stdin.write cannot catch EPIPE; without this listener the session
59
+ * runtime worker itself terminates and every active/queued turn is recovered.
60
+ */
61
+ export function bindChildLifecycle(child, { onError, onExit } = {}) {
62
+ if (!child?.on) return;
63
+ child.on('error', onError);
64
+ child.on('exit', onExit);
65
+ child.stdin?.on?.('error', onError);
66
+ }
67
+
68
+ /**
69
+ * Attach to the engine. Returns null until the verified binary is available;
70
+ * throws only on an unexpected spawn failure, which the caller counts.
71
+ */
72
+ export function createNativeSearchTransport({ binaryPath, cwd }) {
73
+ if (!binaryPath) return null;
74
+ return createChildTransport(binaryPath, cwd);
75
+ }
@@ -0,0 +1,24 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import {
5
+ bindChildLifecycle,
6
+ createNativeSearchTransport,
7
+ } from './native-search-transport.mjs';
8
+
9
+ test('search transport stays unavailable until the verified child binary exists', () => {
10
+ assert.equal(createNativeSearchTransport({ binaryPath: null, cwd: process.cwd() }), null);
11
+ });
12
+
13
+ test('child stdin failures are routed through the lifecycle error handler', () => {
14
+ const errors = [];
15
+ const listeners = {};
16
+ const child = {
17
+ on(name, handler) { listeners[name] = handler; },
18
+ stdin: { on(name, handler) { listeners[`stdin:${name}`] = handler; } },
19
+ };
20
+ bindChildLifecycle(child, { onError: (error) => errors.push(error) });
21
+ const expected = new Error('EPIPE');
22
+ listeners['stdin:error'](expected);
23
+ assert.deepEqual(errors, [expected]);
24
+ });
@@ -419,6 +419,18 @@ export function _spawnCodeGraphWorker(
419
419
  _onSignalAbort = null;
420
420
  }
421
421
  if (_releaseSlot) { try { _releaseSlot(); } catch {} _releaseSlot = null; }
422
+ // The graph has already arrived, so the worker has nothing left to run.
423
+ // It cannot exit on its own either: reading its piped stdout/stderr
424
+ // below keeps the underlying MessagePort ref'd for the worker's whole
425
+ // lifetime (the same trap save-worker.mjs documents). Measured on a
426
+ // desktop session worker: 10 builds left 26 live threads and ~450 MB
427
+ // resident with no build in flight. The timeout and abort paths already
428
+ // terminate; this is the success path they were missing.
429
+ if (_worker) {
430
+ const finished = _worker;
431
+ _worker = null;
432
+ try { void finished.terminate(); } catch { /* already down */ }
433
+ }
422
434
  if (val instanceof Error) reject(val);
423
435
  else resolve(val);
424
436
  };
@@ -34,7 +34,10 @@ export const CODE_GRAPH_MEMORY_MAX_ENTRIES = Math.max(
34
34
  1,
35
35
  Math.floor(Number(process.env.MIXDOG_CODE_GRAPH_MEMORY_MAX_ENTRIES) || 6),
36
36
  );
37
- export const CODE_GRAPH_MEMORY_MAX_SOURCE_BYTES = Math.max(
37
+ // Total retained graph memory, including nodes, symbols, indexes and source
38
+ // caches. The previous source-cache-only accounting left the much larger base
39
+ // graph unbounded across the six-entry LRU.
40
+ export const CODE_GRAPH_MEMORY_MAX_BYTES = Math.max(
38
41
  1 * 1024 * 1024,
39
42
  Math.floor((Number(process.env.MIXDOG_CODE_GRAPH_MEMORY_MAX_MB) || 48) * 1024 * 1024),
40
43
  );
@@ -31,7 +31,8 @@ import {
31
31
  } from './project-root.mjs';
32
32
  import { buildCodeGraphAsync, prewarmCodeGraph, prewarmCodeGraphSymbols } from './build.mjs';
33
33
  import { _fileInfoFromRustRecord, _runGraphFiles } from './graph-binary.mjs';
34
- import { _attachGraphRuntimeCaches } from './graph-model.mjs';
34
+ import { _attachGraphRuntimeCaches, _estimateGraphRetainedBytes } from './graph-model.mjs';
35
+ import { _pruneCodeGraphMemoryCache } from './memory-cache.mjs';
35
36
  import {
36
37
  _isFilesystemRootPath,
37
38
  collectTrustedCodeGraphRoots,
@@ -93,6 +94,23 @@ function _outlineLanguageForPath(file) {
93
94
  const _exactFileGraphInflight = new Map();
94
95
  const _exactFileGraphCache = new Map();
95
96
  const EXACT_FILE_GRAPH_CACHE_MAX = 64;
97
+ const EXACT_FILE_GRAPH_CACHE_MAX_BYTES = 8 * 1024 * 1024;
98
+
99
+ function _pruneExactFileGraphCache() {
100
+ const rows = [..._exactFileGraphCache.entries()].map(([key, entry]) => ({
101
+ key,
102
+ retainedBytes: _estimateGraphRetainedBytes(entry?.graph),
103
+ }));
104
+ let totalRetainedBytes = rows.reduce((sum, row) => sum + row.retainedBytes, 0);
105
+ for (const row of rows) {
106
+ if (
107
+ _exactFileGraphCache.size <= EXACT_FILE_GRAPH_CACHE_MAX
108
+ && totalRetainedBytes <= EXACT_FILE_GRAPH_CACHE_MAX_BYTES
109
+ ) break;
110
+ _exactFileGraphCache.delete(row.key);
111
+ totalRetainedBytes -= row.retainedBytes;
112
+ }
113
+ }
96
114
 
97
115
  async function _buildExactFileGraph(cwd, abs, signal = null) {
98
116
  const root = pathResolve(cwd);
@@ -144,9 +162,7 @@ async function _buildExactFileGraph(cwd, abs, signal = null) {
144
162
  });
145
163
  _exactFileGraphCache.delete(key);
146
164
  _exactFileGraphCache.set(key, { sourceHash, graph });
147
- while (_exactFileGraphCache.size > EXACT_FILE_GRAPH_CACHE_MAX) {
148
- _exactFileGraphCache.delete(_exactFileGraphCache.keys().next().value);
149
- }
165
+ _pruneExactFileGraphCache();
150
166
  return graph;
151
167
  })();
152
168
  const inflight = { sourceHash, promise: pending };
@@ -1244,7 +1260,10 @@ async function executeCodeGraphToolRaw(name, args, cwd, signal = null, options =
1244
1260
  }
1245
1261
  default: throw new Error(`Unknown code-graph tool: ${name}`);
1246
1262
  }
1247
- })();
1263
+ })().finally(() => {
1264
+ _pruneCodeGraphMemoryCache();
1265
+ _pruneExactFileGraphCache();
1266
+ });
1248
1267
  if (!signal) return _work;
1249
1268
  let onAbort = null;
1250
1269
  const abortP = new Promise((_, reject) => {
@@ -1,10 +1,54 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
+ import { EventEmitter } from 'node:events';
3
4
  import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
4
5
  import { tmpdir } from 'node:os';
5
6
  import { join, resolve } from 'node:path';
6
7
 
7
8
  import { _resolveBoundedSentinelFreeAggregateRootForTest } from './dispatch.mjs';
9
+ import { _spawnCodeGraphWorker } from './build.mjs';
10
+
11
+ test('successful code-graph build terminates its worker and releases the spawn slot', async () => {
12
+ let worker = null;
13
+ let releases = 0;
14
+ class FakeWorker extends EventEmitter {
15
+ constructor() {
16
+ super();
17
+ this.stdout = new EventEmitter();
18
+ this.stderr = new EventEmitter();
19
+ this.terminations = 0;
20
+ }
21
+ terminate() {
22
+ this.terminations += 1;
23
+ return Promise.resolve(0);
24
+ }
25
+ }
26
+
27
+ const pending = _spawnCodeGraphWorker(
28
+ process.cwd(),
29
+ process.cwd(),
30
+ 0,
31
+ null,
32
+ () => { releases += 1; },
33
+ null,
34
+ null,
35
+ {
36
+ createWorker() {
37
+ worker = new FakeWorker();
38
+ return worker;
39
+ },
40
+ getGeneration: () => 0,
41
+ setMemoryCache: () => {},
42
+ setDiskCache: () => {},
43
+ },
44
+ );
45
+ await new Promise((resolve) => setImmediate(resolve));
46
+ worker.emit('message', { ok: true, graph: { files: [] }, signature: 'sig' });
47
+
48
+ assert.deepEqual(await pending, { files: [] });
49
+ assert.equal(worker.terminations, 1);
50
+ assert.equal(releases, 1);
51
+ });
8
52
 
9
53
  test('sentinel-free aggregate anchors adopt only their explicit bounded cwd', () => {
10
54
  const root = mkdtempSync(join(tmpdir(), 'mixdog-codegraph-bounded-'));
@@ -216,13 +216,23 @@ async function _runGraphBinaryJsonl(absRoot, extraArgs, stdinLines = null, signa
216
216
  }
217
217
  const out = [];
218
218
  const buf = Buffer.concat(chunks).toString('utf8');
219
+ let lineNumber = 0;
219
220
  for (const line of buf.split('\n')) {
221
+ lineNumber += 1;
220
222
  const trimmed = line.trim();
221
223
  if (!trimmed) continue;
222
224
  try {
223
225
  const rec = JSON.parse(trimmed);
224
- if (rec && typeof rec.rel === 'string') out.push(rec);
225
- } catch { /* skip malformed line */ }
226
+ if (!rec || typeof rec.rel !== 'string') {
227
+ throw new Error('record is missing string rel');
228
+ }
229
+ out.push(rec);
230
+ } catch (error) {
231
+ reject(new Error(
232
+ `[code-graph] mixdog-graph emitted invalid JSONL at line ${lineNumber}: ${error?.message || error}`,
233
+ ));
234
+ return;
235
+ }
226
236
  }
227
237
  resolve(out);
228
238
  });
@@ -128,27 +128,41 @@ export function _attachGraphRuntimeCaches(graph) {
128
128
  return graph;
129
129
  }
130
130
 
131
- export function _estimateGraphRuntimeCacheBytes(graph) {
132
- if (!graph) return 0;
133
- let total = 0;
134
- for (const entry of graph._sourceTextCache?.values() || []) {
135
- total += Buffer.byteLength(String(entry?.text || ''), 'utf8');
136
- }
137
- for (const lines of graph._maskedLinesCache?.values() || []) {
138
- if (!Array.isArray(lines)) continue;
139
- for (const line of lines) total += Buffer.byteLength(String(line || ''), 'utf8');
140
- }
141
- for (const lines of graph._sourceLinesCache?.values() || []) {
142
- if (!Array.isArray(lines)) continue;
143
- for (const line of lines) total += Buffer.byteLength(String(line || ''), 'utf8');
144
- }
145
- for (const memo of graph._referenceSearchCache?.values() || []) {
146
- total += Buffer.byteLength(String(memo || ''), 'utf8');
147
- }
148
- for (const memo of graph._keywordSearchCache?.values() || []) {
149
- total += Buffer.byteLength(String(memo || ''), 'utf8');
150
- }
151
- return total;
131
+ export function _estimateGraphRetainedBytes(graph) {
132
+ const seen = new WeakSet();
133
+ const estimate = (value) => {
134
+ if (value == null) return 0;
135
+ const type = typeof value;
136
+ if (type === 'string') return 16 + (value.length * 2);
137
+ if (type === 'number' || type === 'bigint') return 8;
138
+ if (type === 'boolean') return 4;
139
+ if (type !== 'object') return 0;
140
+ if (seen.has(value)) return 0;
141
+ seen.add(value);
142
+ if (ArrayBuffer.isView(value)) return 32 + value.byteLength;
143
+ if (value instanceof ArrayBuffer) return 24 + value.byteLength;
144
+ if (value instanceof Map) {
145
+ let bytes = 48 + (value.size * 24);
146
+ for (const [key, item] of value) bytes += estimate(key) + estimate(item);
147
+ return bytes;
148
+ }
149
+ if (value instanceof Set) {
150
+ let bytes = 48 + (value.size * 16);
151
+ for (const item of value) bytes += estimate(item);
152
+ return bytes;
153
+ }
154
+ if (Array.isArray(value)) {
155
+ let bytes = 24 + (value.length * 8);
156
+ for (const item of value) bytes += estimate(item);
157
+ return bytes;
158
+ }
159
+ let bytes = 48;
160
+ for (const [key, item] of Object.entries(value)) {
161
+ bytes += 8 + estimate(key) + estimate(item);
162
+ }
163
+ return bytes;
164
+ };
165
+ return estimate(graph);
152
166
  }
153
167
 
154
168
  export function _clearGraphRuntimeCaches(graph) {