mixdog 0.8.1 → 0.9.0

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 (280) hide show
  1. package/README.md +47 -23
  2. package/package.json +33 -27
  3. package/scripts/_test-folder-dialog.mjs +30 -0
  4. package/scripts/agent-parallel-smoke.mjs +388 -0
  5. package/scripts/agent-tag-reuse-smoke.mjs +183 -0
  6. package/scripts/background-task-meta-smoke.mjs +38 -0
  7. package/scripts/boot-smoke.mjs +52 -9
  8. package/scripts/build-runtime-linux.sh +348 -0
  9. package/scripts/build-runtime-macos.sh +217 -0
  10. package/scripts/build-runtime-windows.ps1 +242 -0
  11. package/scripts/compact-active-turn-test.mjs +68 -0
  12. package/scripts/compact-smoke.mjs +859 -129
  13. package/scripts/compact-trigger-migration-smoke.mjs +187 -0
  14. package/scripts/fix-brief-fn.mjs +35 -0
  15. package/scripts/fix-format-tool-surface.mjs +24 -0
  16. package/scripts/fix-tool-exec-visible.mjs +42 -0
  17. package/scripts/generate-runtime-manifest.mjs +166 -0
  18. package/scripts/hook-bus-test.mjs +330 -0
  19. package/scripts/lead-workflow-smoke.mjs +33 -39
  20. package/scripts/live-worker-smoke.mjs +43 -37
  21. package/scripts/llm-trace-summary.mjs +315 -0
  22. package/scripts/memory-meta-concurrency-test.mjs +20 -0
  23. package/scripts/output-style-smoke.mjs +56 -15
  24. package/scripts/parent-abort-link-test.mjs +44 -0
  25. package/scripts/patch-agent-brief.mjs +48 -0
  26. package/scripts/patch-app.mjs +21 -0
  27. package/scripts/patch-app2.mjs +18 -0
  28. package/scripts/patch-dist-brief.mjs +96 -0
  29. package/scripts/patch-tool-exec.mjs +70 -0
  30. package/scripts/pretool-ask-runtime-test.mjs +54 -0
  31. package/scripts/provider-toolcall-test.mjs +376 -0
  32. package/scripts/reactive-compact-persist-smoke.mjs +124 -0
  33. package/scripts/sanitize-tool-pairs-test.mjs +260 -0
  34. package/scripts/session-context-bench.mjs +205 -33
  35. package/scripts/session-ingest-smoke.mjs +177 -0
  36. package/scripts/set-effort-config-test.mjs +41 -0
  37. package/scripts/smoke-runtime-negative.ps1 +106 -0
  38. package/scripts/smoke-runtime-negative.sh +97 -0
  39. package/scripts/smoke.mjs +25 -0
  40. package/scripts/tool-result-hook-test.mjs +48 -0
  41. package/scripts/tool-smoke.mjs +1222 -90
  42. package/scripts/toolcall-args-test.mjs +150 -0
  43. package/scripts/tui-background-failure-smoke.mjs +73 -0
  44. package/scripts/usage-metrics-epoch-smoke.mjs +114 -0
  45. package/src/agents/debugger/AGENT.md +8 -0
  46. package/src/agents/explore/AGENT.md +4 -0
  47. package/src/agents/heavy-worker/AGENT.md +9 -3
  48. package/src/agents/maintainer/AGENT.md +4 -0
  49. package/src/agents/reviewer/AGENT.md +8 -0
  50. package/src/agents/scheduler-task/AGENT.md +12 -0
  51. package/src/agents/scheduler-task/agent.json +6 -0
  52. package/src/agents/webhook-handler/AGENT.md +12 -0
  53. package/src/agents/webhook-handler/agent.json +6 -0
  54. package/src/agents/worker/AGENT.md +9 -3
  55. package/src/app.mjs +77 -3
  56. package/src/defaults/hidden-roles.json +17 -12
  57. package/src/headless-role.mjs +117 -0
  58. package/src/help.mjs +30 -0
  59. package/src/hooks/lib/permission-evaluator.cjs +11 -475
  60. package/src/lib/keychain-cjs.cjs +9 -1
  61. package/src/lib/mixdog-debug.cjs +0 -7
  62. package/src/lib/rules-builder.cjs +240 -96
  63. package/src/lib/text-utils.cjs +1 -1
  64. package/src/mixdog-session-runtime.mjs +2212 -446
  65. package/src/output-styles/default.md +12 -28
  66. package/src/output-styles/extreme-simple.md +9 -6
  67. package/src/output-styles/simple.md +22 -9
  68. package/src/repl.mjs +110 -59
  69. package/src/rules/agent/00-common.md +15 -0
  70. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  71. package/src/rules/agent/30-explorer.md +22 -0
  72. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  73. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  75. package/src/rules/lead/01-general.md +9 -5
  76. package/src/rules/lead/04-workflow.md +51 -12
  77. package/src/rules/lead/lead-tool.md +6 -0
  78. package/src/rules/shared/01-tool.md +12 -1
  79. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  80. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  81. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  82. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  83. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  85. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  86. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  87. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  88. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  89. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  90. package/src/runtime/agent/orchestrator/internal-roles.mjs +76 -28
  91. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  92. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  93. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  94. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +377 -243
  95. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +146 -93
  96. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  97. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  98. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  99. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  100. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  101. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  102. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  103. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  104. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  106. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +221 -70
  107. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +477 -147
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -496
  109. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  110. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  111. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +31 -11
  112. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  113. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  114. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  115. package/src/runtime/agent/orchestrator/session/loop.mjs +840 -612
  116. package/src/runtime/agent/orchestrator/session/manager.mjs +1574 -459
  117. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  118. package/src/runtime/agent/orchestrator/session/store.mjs +67 -29
  119. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  120. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  121. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  122. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
  123. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
  124. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
  125. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  126. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  127. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  128. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  129. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  130. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  131. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  133. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  137. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  138. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  139. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  140. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  141. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  142. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  143. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  144. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  145. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  146. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  147. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  148. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  149. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  150. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  151. package/src/runtime/channels/index.mjs +152 -24
  152. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  153. package/src/runtime/channels/lib/session-discovery.mjs +3 -2
  154. package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
  155. package/src/runtime/channels/lib/webhook.mjs +1 -1
  156. package/src/runtime/channels/tool-defs.mjs +29 -29
  157. package/src/runtime/memory/index.mjs +520 -98
  158. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  159. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  160. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  161. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  162. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  163. package/src/runtime/memory/lib/memory-cycle1.mjs +28 -7
  164. package/src/runtime/memory/lib/memory-cycle2.mjs +4 -5
  165. package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
  166. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
  167. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  168. package/src/runtime/memory/lib/memory.mjs +88 -9
  169. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  170. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  171. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  172. package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
  173. package/src/runtime/memory/lib/session-ingest.mjs +194 -0
  174. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  175. package/src/runtime/memory/tool-defs.mjs +46 -37
  176. package/src/runtime/search/index.mjs +102 -466
  177. package/src/runtime/search/lib/web-tools.mjs +45 -25
  178. package/src/runtime/search/tool-defs.mjs +16 -23
  179. package/src/runtime/shared/abort-controller.mjs +1 -1
  180. package/src/runtime/shared/atomic-file.mjs +4 -3
  181. package/src/runtime/shared/background-tasks.mjs +122 -11
  182. package/src/runtime/shared/child-spawn-gate.mjs +145 -0
  183. package/src/runtime/shared/config.mjs +7 -4
  184. package/src/runtime/shared/err-text.mjs +131 -4
  185. package/src/runtime/shared/llm/cost.mjs +2 -2
  186. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  187. package/src/runtime/shared/llm/index.mjs +34 -11
  188. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  189. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  190. package/src/runtime/shared/singleton-owner.mjs +104 -0
  191. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  192. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  193. package/src/runtime/shared/tool-surface.mjs +624 -95
  194. package/src/runtime/shared/user-data-guard.mjs +0 -2
  195. package/src/standalone/agent-task-status.mjs +203 -0
  196. package/src/standalone/agent-task-status.test.mjs +76 -0
  197. package/src/standalone/agent-tool.mjs +1913 -0
  198. package/src/standalone/channel-worker.mjs +370 -14
  199. package/src/standalone/explore-tool.mjs +165 -70
  200. package/src/standalone/folder-dialog.mjs +314 -0
  201. package/src/standalone/hook-bus.mjs +898 -22
  202. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  203. package/src/standalone/projects.mjs +226 -0
  204. package/src/standalone/provider-admin.mjs +41 -24
  205. package/src/standalone/seeds.mjs +2 -69
  206. package/src/standalone/usage-dashboard.mjs +96 -8
  207. package/src/tui/App.jsx +4788 -2151
  208. package/src/tui/components/AnsiText.jsx +39 -28
  209. package/src/tui/components/ContextPanel.jsx +14 -4
  210. package/src/tui/components/Markdown.jsx +43 -77
  211. package/src/tui/components/MarkdownTable.jsx +9 -184
  212. package/src/tui/components/Message.jsx +28 -11
  213. package/src/tui/components/Picker.jsx +95 -56
  214. package/src/tui/components/PromptInput.jsx +367 -239
  215. package/src/tui/components/QueuedCommands.jsx +1 -1
  216. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  217. package/src/tui/components/Spinner.jsx +67 -38
  218. package/src/tui/components/StatusLine.jsx +606 -38
  219. package/src/tui/components/TextEntryPanel.jsx +128 -9
  220. package/src/tui/components/ToolExecution.jsx +592 -362
  221. package/src/tui/components/TurnDone.jsx +3 -3
  222. package/src/tui/components/UsagePanel.jsx +3 -5
  223. package/src/tui/components/tool-output-format.mjs +365 -0
  224. package/src/tui/components/tool-output-format.test.mjs +220 -0
  225. package/src/tui/dist/index.mjs +8826 -2390
  226. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  227. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  228. package/src/tui/engine.mjs +1455 -279
  229. package/src/tui/figures.mjs +21 -40
  230. package/src/tui/index.jsx +75 -31
  231. package/src/tui/input-editing.mjs +25 -0
  232. package/src/tui/markdown/format-token.mjs +511 -68
  233. package/src/tui/markdown/format-token.test.mjs +216 -0
  234. package/src/tui/markdown/render-ansi.mjs +94 -0
  235. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  236. package/src/tui/markdown/stream-fence.mjs +34 -0
  237. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  238. package/src/tui/markdown/table-layout.mjs +250 -0
  239. package/src/tui/paste-attachments.mjs +0 -7
  240. package/src/tui/spinner-verbs.mjs +1 -2
  241. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  242. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  243. package/src/tui/theme.mjs +746 -24
  244. package/src/tui/time-format.mjs +1 -1
  245. package/src/tui/transcript-tool-failures.mjs +67 -0
  246. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  247. package/src/ui/ansi.mjs +1 -2
  248. package/src/ui/markdown.mjs +85 -26
  249. package/src/ui/markdown.test.mjs +70 -0
  250. package/src/ui/model-display.mjs +121 -0
  251. package/src/ui/session-stats.mjs +44 -0
  252. package/src/ui/statusline-context-label.test.mjs +15 -0
  253. package/src/ui/statusline.mjs +386 -178
  254. package/src/ui/tool-card.mjs +2 -13
  255. package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
  256. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  257. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  258. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  259. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  260. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  261. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  262. package/src/workflows/default/WORKFLOW.md +12 -5
  263. package/src/workflows/default/workflow.json +0 -1
  264. package/src/workflows/solo/WORKFLOW.md +15 -0
  265. package/src/workflows/solo/workflow.json +7 -0
  266. package/vendor/ink/build/output.js +6 -1
  267. package/src/agents/scheduler-task.md +0 -3
  268. package/src/agents/web-researcher/AGENT.md +0 -3
  269. package/src/agents/web-researcher/agent.json +0 -6
  270. package/src/agents/webhook-handler.md +0 -3
  271. package/src/rules/bridge/00-common.md +0 -5
  272. package/src/rules/bridge/30-explorer.md +0 -4
  273. package/src/rules/lead/00-tool-lead.md +0 -5
  274. package/src/rules/shared/00-language.md +0 -3
  275. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  276. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  277. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  278. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  279. package/src/standalone/bridge-tool.mjs +0 -1414
  280. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -0,0 +1,260 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { sanitizeToolPairs, sanitizeAnthropicContentPairs } from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
4
+ import { repairTranscriptBeforeProviderSend } from '../src/runtime/agent/orchestrator/session/loop.mjs';
5
+
6
+ const TOOL_MISSING_STUB = '[Older tool result unavailable after context compaction]';
7
+
8
+ const assistantA = {
9
+ role: 'assistant',
10
+ content: '',
11
+ toolCalls: [{ id: 'call_a', name: 'grep', arguments: {} }],
12
+ };
13
+
14
+ const toolA = {
15
+ role: 'tool',
16
+ toolCallId: 'call_a',
17
+ content: 'grep output',
18
+ toolKind: 'normal',
19
+ };
20
+
21
+ test('reattaches interleaved tool result immediately after assistant toolCalls', () => {
22
+ const user = { role: 'user', content: 'continue' };
23
+ const input = [assistantA, user, toolA];
24
+ const out = sanitizeToolPairs(input);
25
+
26
+ assert.deepEqual(out, [assistantA, toolA, user]);
27
+ assert.equal(out.filter((m) => m.role === 'tool').length, 1);
28
+ });
29
+
30
+ test('contiguous block for multiple toolCalls when results were separated', () => {
31
+ const assistant = {
32
+ role: 'assistant',
33
+ content: '',
34
+ toolCalls: [
35
+ { id: 'call_a', name: 'read', arguments: {} },
36
+ { id: 'call_b', name: 'grep', arguments: {} },
37
+ ],
38
+ };
39
+ const user = { role: 'user', content: 'next' };
40
+ const input = [
41
+ assistant,
42
+ { role: 'tool', toolCallId: 'call_a', content: 'a', toolKind: 'normal' },
43
+ user,
44
+ { role: 'tool', toolCallId: 'call_b', content: 'b', toolKind: 'normal' },
45
+ ];
46
+ const out = sanitizeToolPairs(input);
47
+
48
+ assert.equal(out.length, 4);
49
+ assert.equal(out[0], assistant);
50
+ assert.equal(out[1].toolCallId, 'call_a');
51
+ assert.equal(out[1].content, 'a');
52
+ assert.equal(out[2].toolCallId, 'call_b');
53
+ assert.equal(out[3], user);
54
+ });
55
+
56
+ test('inserts stub when assistant toolCall has no matching tool message', () => {
57
+ const out = sanitizeToolPairs([assistantA, { role: 'user', content: 'hi' }]);
58
+
59
+ assert.equal(out.length, 3);
60
+ assert.equal(out[1].role, 'tool');
61
+ assert.equal(out[1].toolCallId, 'call_a');
62
+ assert.equal(out[1].content, TOOL_MISSING_STUB);
63
+ });
64
+
65
+ test('drops tool messages with no surviving assistant tool_call', () => {
66
+ const orphan = { role: 'tool', toolCallId: 'orphan', content: 'gone' };
67
+ const out = sanitizeToolPairs([orphan, { role: 'user', content: 'hi' }]);
68
+
69
+ assert.deepEqual(out, [{ role: 'user', content: 'hi' }]);
70
+ });
71
+
72
+ test('drops malformed tool messages without toolCallId', () => {
73
+ const malformed = { role: 'tool', content: 'no id' };
74
+ const out = sanitizeToolPairs([malformed, assistantA, toolA]);
75
+
76
+ assert.equal(out.length, 2);
77
+ assert.equal(out[0], assistantA);
78
+ assert.equal(out[1].toolCallId, 'call_a');
79
+ });
80
+
81
+ test('duplicate same-id prefers valid result after assistant over stale before', () => {
82
+ const stale = { role: 'tool', toolCallId: 'call_a', content: 'stale', toolKind: 'normal' };
83
+ const valid = { role: 'tool', toolCallId: 'call_a', content: 'valid', toolKind: 'normal' };
84
+ const out = sanitizeToolPairs([stale, assistantA, { role: 'user', content: 'x' }, valid]);
85
+
86
+ assert.equal(out.length, 3);
87
+ assert.equal(out[1].content, 'valid');
88
+ });
89
+
90
+ test('duplicate same-id in contiguous block after assistant keeps first there', () => {
91
+ const first = { role: 'tool', toolCallId: 'call_a', content: 'first', toolKind: 'normal' };
92
+ const second = { role: 'tool', toolCallId: 'call_a', content: 'second', toolKind: 'normal' };
93
+ const out = sanitizeToolPairs([assistantA, first, { role: 'user', content: 'x' }, second]);
94
+
95
+ assert.equal(out.length, 3);
96
+ assert.equal(out[1].content, 'first');
97
+ });
98
+
99
+ test('debugger06: assistantBlocks + role tool keeps REAL_BODY after sanitizeToolPairs', () => {
100
+ const REAL_BODY = 'REAL_BODY from debugger06 fixture';
101
+ const assistant = {
102
+ role: 'assistant',
103
+ content: '',
104
+ assistantBlocks: [
105
+ { type: 'text', text: 'calling tool' },
106
+ { type: 'tool_use', id: 'call_blocks', name: 'read', input: { path: 'x' } },
107
+ ],
108
+ };
109
+ const toolMsg = {
110
+ role: 'tool',
111
+ toolCallId: 'call_blocks',
112
+ content: REAL_BODY,
113
+ toolKind: 'normal',
114
+ };
115
+ const user = { role: 'user', content: 'next' };
116
+ const out = sanitizeToolPairs([assistant, user, toolMsg]);
117
+
118
+ assert.equal(out.length, 3);
119
+ assert.equal(out[0], assistant);
120
+ assert.equal(out[1].role, 'tool');
121
+ assert.equal(out[1].toolCallId, 'call_blocks');
122
+ assert.equal(out[1].content, REAL_BODY);
123
+ assert.equal(out[2], user);
124
+ });
125
+
126
+ test('debugger06: assistant content tool_use + role tool keeps REAL_BODY after sanitizeToolPairs', () => {
127
+ const REAL_BODY = 'REAL_BODY content-array fixture';
128
+ const assistant = {
129
+ role: 'assistant',
130
+ content: [
131
+ { type: 'text', text: 'hi' },
132
+ { type: 'tool_use', id: 'call_content', name: 'grep', input: {} },
133
+ ],
134
+ };
135
+ const toolMsg = {
136
+ role: 'tool',
137
+ toolCallId: 'call_content',
138
+ content: REAL_BODY,
139
+ };
140
+ const out = sanitizeToolPairs([assistant, toolMsg]);
141
+
142
+ assert.equal(out.length, 2);
143
+ assert.equal(out[1].content, REAL_BODY);
144
+ });
145
+
146
+ test('sanitizeAnthropicContentPairs reorders tool_result before user text for multi-tool assistant', () => {
147
+ const assistant = {
148
+ role: 'assistant',
149
+ content: [
150
+ { type: 'tool_use', id: 'call_a', name: 'read', input: {} },
151
+ { type: 'tool_use', id: 'call_b', name: 'grep', input: {} },
152
+ ],
153
+ };
154
+ const user = {
155
+ role: 'user',
156
+ content: [
157
+ { type: 'text', text: 'continue after tools' },
158
+ { type: 'tool_result', tool_use_id: 'call_a', content: 'a out' },
159
+ { type: 'tool_result', tool_use_id: 'call_b', content: 'b out' },
160
+ ],
161
+ };
162
+ const out = sanitizeAnthropicContentPairs([assistant, user]);
163
+
164
+ assert.equal(out.length, 2);
165
+ assert.equal(out[1].content[0].type, 'tool_result');
166
+ assert.equal(out[1].content[1].type, 'tool_result');
167
+ assert.equal(out[1].content[2].type, 'text');
168
+ });
169
+
170
+ test('sanitizeAnthropicContentPairs inserts user tool_result when no following user message', () => {
171
+ const assistant = {
172
+ role: 'assistant',
173
+ content: [{ type: 'tool_use', id: 'call_x', name: 'shell', input: {} }],
174
+ };
175
+ const out = sanitizeAnthropicContentPairs([assistant]);
176
+
177
+ assert.equal(out.length, 2);
178
+ assert.equal(out[0], assistant);
179
+ assert.equal(out[1].role, 'user');
180
+ assert.equal(out[1].content.length, 1);
181
+ assert.equal(out[1].content[0].type, 'tool_result');
182
+ assert.equal(out[1].content[0].tool_use_id, 'call_x');
183
+ });
184
+
185
+ test('sanitizeAnthropicContentPairs stubs missing ids and leads with all tool_result blocks', () => {
186
+ const assistant = {
187
+ role: 'assistant',
188
+ content: [
189
+ { type: 'tool_use', id: 'call_a', name: 'read', input: {} },
190
+ { type: 'tool_use', id: 'call_b', name: 'grep', input: {} },
191
+ ],
192
+ };
193
+ const user = {
194
+ role: 'user',
195
+ content: [
196
+ { type: 'text', text: 'steer' },
197
+ { type: 'tool_result', tool_use_id: 'call_a', content: 'only a' },
198
+ ],
199
+ };
200
+ const out = sanitizeAnthropicContentPairs([assistant, user]);
201
+ const blocks = out[1].content;
202
+
203
+ assert.equal(blocks[0].type, 'tool_result');
204
+ assert.equal(blocks[0].tool_use_id, 'call_b');
205
+ assert.equal(blocks[1].type, 'tool_result');
206
+ assert.equal(blocks[1].tool_use_id, 'call_a');
207
+ assert.equal(blocks[2].type, 'text');
208
+ });
209
+
210
+ test('debugger06: sanitizeAnthropicContentPairs drops leading orphan tool_result', () => {
211
+ const leadingOrphan = {
212
+ role: 'user',
213
+ content: [{ type: 'tool_result', tool_use_id: 'stale_lead', content: 'orphan' }],
214
+ };
215
+ const assistant = {
216
+ role: 'assistant',
217
+ content: [{ type: 'text', text: 'hello' }],
218
+ };
219
+ const out = sanitizeAnthropicContentPairs([leadingOrphan, assistant]);
220
+
221
+ assert.equal(out.length, 1);
222
+ assert.equal(out[0].role, 'assistant');
223
+ });
224
+
225
+ test('debugger06: sanitizeAnthropicContentPairs drops stale tool_result and reorders valid', () => {
226
+ const assistant = {
227
+ role: 'assistant',
228
+ content: [{ type: 'tool_use', id: 'call_live', name: 'read', input: {} }],
229
+ };
230
+ const user = {
231
+ role: 'user',
232
+ content: [
233
+ { type: 'tool_result', tool_use_id: 'stale_other', content: 'drop me' },
234
+ { type: 'text', text: 'between' },
235
+ { type: 'tool_result', tool_use_id: 'call_live', content: 'VALID_KEEP' },
236
+ ],
237
+ };
238
+ const out = sanitizeAnthropicContentPairs([assistant, user]);
239
+ const blocks = out[1].content;
240
+
241
+ assert.equal(blocks[0].type, 'tool_result');
242
+ assert.equal(blocks[0].tool_use_id, 'call_live');
243
+ assert.equal(blocks[0].content, 'VALID_KEEP');
244
+ assert.equal(blocks[1].type, 'text');
245
+ assert.ok(!blocks.some((b) => b.tool_use_id === 'stale_other'));
246
+ });
247
+
248
+ test('loop pre-send repair reattaches separated assistant/tool instead of deleting', () => {
249
+ const user = { role: 'user', content: 'steer' };
250
+ const msgs = [assistantA, user, { ...toolA }];
251
+ repairTranscriptBeforeProviderSend(msgs, 'test-sess');
252
+
253
+ assert.equal(msgs.length, 3);
254
+ assert.equal(msgs[0].role, 'assistant');
255
+ assert.equal(msgs[1].toolCallId, 'call_a');
256
+ assert.equal(msgs[1].content, 'grep output');
257
+ assert.equal(msgs[2], user);
258
+ });
259
+
260
+
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
- import { fileURLToPath } from 'node:url';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
  import { loadConfig, getDefaultPreset, getPreset } from '../src/runtime/agent/orchestrator/config.mjs';
6
6
  import { initProviders, getProvider } from '../src/runtime/agent/orchestrator/providers/registry.mjs';
7
7
  import { loadSession } from '../src/runtime/agent/orchestrator/session/store.mjs';
8
8
  import { semanticCompactMessages } from '../src/runtime/agent/orchestrator/session/compact.mjs';
9
9
  import { estimateMessagesTokens } from '../src/runtime/agent/orchestrator/session/context-utils.mjs';
10
- import * as memory from '../src/runtime/memory/index.mjs';
11
10
 
12
11
  const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
12
+ let memoryModule = null;
13
13
 
14
14
  function arg(name, fallback = null) {
15
15
  const prefix = `--${name}=`;
@@ -24,14 +24,50 @@ function flag(name) {
24
24
  return process.argv.includes(`--${name}`);
25
25
  }
26
26
 
27
+ function isMainScript() {
28
+ return import.meta.url === pathToFileURL(process.argv[1] || '').href;
29
+ }
30
+
31
+ async function withTimeout(promise, ms) {
32
+ const timeoutMs = Number(ms) || 0;
33
+ if (!(timeoutMs > 0)) return { timedOut: false, value: await promise };
34
+ let timer = null;
35
+ try {
36
+ return await Promise.race([
37
+ Promise.resolve(promise).then((value) => ({ timedOut: false, value })),
38
+ new Promise((resolve) => {
39
+ timer = setTimeout(() => resolve({ timedOut: true }), timeoutMs);
40
+ timer.unref?.();
41
+ }),
42
+ ]);
43
+ } finally {
44
+ if (timer) clearTimeout(timer);
45
+ }
46
+ }
47
+
27
48
  function textOfResult(result) {
28
49
  if (result && typeof result === 'object' && Array.isArray(result.content)) {
29
50
  return result.content.map((part) => part?.type === 'text' ? part.text || '' : JSON.stringify(part)).join('\n');
30
51
  }
52
+ if (result && typeof result === 'object' && typeof result.content === 'string') return result.content;
31
53
  if (typeof result === 'string') return result;
32
54
  return JSON.stringify(result ?? '', null, 2);
33
55
  }
34
56
 
57
+ function directCycle1Llm(provider, model) {
58
+ return async (_opts, prompt) => {
59
+ const response = await provider.send([
60
+ { role: 'system', content: 'You chunk memory entries. Output only the requested line format; no prose, no markdown.' },
61
+ { role: 'user', content: String(prompt || '') },
62
+ ], model, undefined, {
63
+ effort: 'low',
64
+ fast: true,
65
+ maxOutputTokens: Number(arg('cycle1-max-output-tokens', 1200)) || 1200,
66
+ });
67
+ return textOfResult(response).trim();
68
+ };
69
+ }
70
+
35
71
  function messageText(content) {
36
72
  if (typeof content === 'string') return content;
37
73
  if (Array.isArray(content)) {
@@ -62,6 +98,114 @@ function coverageScore(sourceText, candidateText) {
62
98
  return Number((hit / terms.length).toFixed(4));
63
99
  }
64
100
 
101
+ function latestUserText(messages, max = 1600) {
102
+ for (let i = (messages || []).length - 1; i >= 0; i -= 1) {
103
+ if (messages[i]?.role !== 'user') continue;
104
+ const text = messageText(messages[i].content).trim();
105
+ if (text) return text.slice(-max);
106
+ }
107
+ return '';
108
+ }
109
+
110
+ function recallQueryForSession(messages) {
111
+ return [
112
+ latestUserText(messages),
113
+ 'current task decisions constraints file paths changed files verification failures next steps',
114
+ ].filter(Boolean).join('\n').slice(0, 2400);
115
+ }
116
+
117
+ function parseCycle1Text(text) {
118
+ const value = String(text || '');
119
+ const num = (name) => {
120
+ const match = new RegExp(`${name}=([0-9]+)`).exec(value);
121
+ return match ? Number(match[1]) : null;
122
+ };
123
+ return {
124
+ chunks: num('chunks'),
125
+ processed: num('processed'),
126
+ skippedChunks: num('skipped_chunks'),
127
+ omitted: num('omitted'),
128
+ failedRows: num('failed_rows'),
129
+ invalidChunks: num('invalid_chunks'),
130
+ pending: num('pending'),
131
+ inFlight: /inFlight=true/.test(value),
132
+ timedOut: /timedOut=true/.test(value),
133
+ };
134
+ }
135
+
136
+ function rawChunkCount(dumpResult) {
137
+ const chunks = Array.isArray(dumpResult?.chunks) ? dumpResult.chunks : [];
138
+ return chunks.filter((chunk) => chunk?.kind === 'raw').length;
139
+ }
140
+
141
+ async function dumpSessionRoots(memoryModule, sessionId, limit) {
142
+ return await memoryModule.handleToolCall('memory', {
143
+ action: 'dump_session_roots',
144
+ sessionId,
145
+ includeRaw: true,
146
+ limit,
147
+ });
148
+ }
149
+
150
+ async function drainCycle1ForSession(memoryModule, sessionId, options = {}) {
151
+ const maxPasses = Math.max(1, Number(options.maxPasses) || 4);
152
+ const windowSize = Math.max(1, Number(options.windowSize) || 50);
153
+ const concurrency = Math.max(1, Math.min(8, Number(options.concurrency) || 4));
154
+ const rowsPerSession = Math.max(windowSize, Number(options.rowsPerSession) || (windowSize * concurrency));
155
+ const callerDeadlineMs = Math.max(0, Number(options.callerDeadlineMs) || 0);
156
+ const callLlm = typeof options.callLlm === 'function' ? options.callLlm : null;
157
+ const limit = Math.max(1, Number(options.limit) || 1000);
158
+ const runs = [];
159
+ let rawRemaining = null;
160
+ for (let pass = 1; pass <= maxPasses; pass += 1) {
161
+ const beforeDump = await dumpSessionRoots(memoryModule, sessionId, limit);
162
+ const rawBefore = rawChunkCount(beforeDump);
163
+ rawRemaining = rawBefore;
164
+ if (rawBefore === 0) break;
165
+ const startedAt = Date.now();
166
+ const cycle = await memoryModule.handleToolCall('memory', {
167
+ action: 'cycle1',
168
+ sessionId,
169
+ min_batch: 1,
170
+ session_cap: 1,
171
+ batch_size: windowSize,
172
+ window_size: windowSize,
173
+ rows_per_session: rowsPerSession,
174
+ concurrency,
175
+ ...(callerDeadlineMs > 0 ? { _callerDeadlineMs: callerDeadlineMs } : {}),
176
+ ...(callLlm ? { _callLlm: callLlm } : {}),
177
+ });
178
+ const text = textOfResult(cycle);
179
+ const parsed = parseCycle1Text(text);
180
+ const afterDump = await dumpSessionRoots(memoryModule, sessionId, limit);
181
+ const rawAfter = rawChunkCount(afterDump);
182
+ rawRemaining = rawAfter;
183
+ runs.push({
184
+ pass,
185
+ ms: Date.now() - startedAt,
186
+ rawBefore,
187
+ rawAfter,
188
+ ...parsed,
189
+ quality: cycle?.quality || null,
190
+ invalidChunksDetail: Array.isArray(cycle?.invalid_chunks) ? cycle.invalid_chunks.slice(0, 5) : [],
191
+ failedRowIds: Array.isArray(cycle?.failed_row_ids) ? cycle.failed_row_ids.slice(0, 20) : [],
192
+ text,
193
+ });
194
+ if (rawAfter === 0) break;
195
+ if ((parsed.processed || 0) === 0 && (parsed.chunks || 0) === 0 && rawAfter >= rawBefore) break;
196
+ }
197
+ return { maxPasses, windowSize, rowsPerSession, concurrency, callerDeadlineMs, rawRemaining, runs };
198
+ }
199
+
200
+ async function timed(label, timings, fn) {
201
+ const startedAt = Date.now();
202
+ try {
203
+ return await fn();
204
+ } finally {
205
+ timings[label] = Date.now() - startedAt;
206
+ }
207
+ }
208
+
65
209
  async function providerFromConfig(config, { providerName, modelName }) {
66
210
  await initProviders(config.providers || {});
67
211
  let provider = providerName;
@@ -78,8 +222,9 @@ async function providerFromConfig(config, { providerName, modelName }) {
78
222
  return { provider, model, impl };
79
223
  }
80
224
 
81
- async function judgePair(provider, model, sourceText, semanticText, recallText) {
82
- const prompt = `You are judging two context-compression strategies for continuing a coding session.\n\nEvaluate which preserves actionable state better for the next assistant turn. Prefer factual coverage, exact constraints, file paths, decisions, and current task continuity.\n\nReturn strict JSON: {"winner":"semantic"|"recall_roots"|"tie","semantic_score":1-10,"recall_roots_score":1-10,"reason":"short"}.\n\n# Original session excerpt\n${sourceText.slice(0, 30000)}\n\n# Candidate A: semantic compact\n${semanticText.slice(0, 30000)}\n\n# Candidate B: recall root/raw chunks\n${recallText.slice(0, 30000)}`;
225
+ async function judgePair(provider, model, sourceText, semanticText, candidateBText, candidateBLabel) {
226
+ const safeLabel = String(candidateBLabel || 'candidate_b').replace(/[^a-z0-9_]+/gi, '_').toLowerCase() || 'candidate_b';
227
+ const prompt = `You are judging two context-compression strategies for continuing a coding session.\n\nEvaluate which preserves actionable state better for the next assistant turn. Prefer factual coverage, exact constraints, file paths, decisions, and current task continuity.\n\nReturn strict JSON: {"winner":"semantic"|"${safeLabel}"|"tie","semantic_score":1-10,"${safeLabel}_score":1-10,"reason":"short"}.\n\n# Original session excerpt\n${sourceText.slice(0, 30000)}\n\n# Candidate A: semantic compact\n${semanticText.slice(0, 30000)}\n\n# Candidate B: ${safeLabel}\n${candidateBText.slice(0, 30000)}`;
83
228
  const response = await provider.send([
84
229
  { role: 'system', content: 'Judge compression quality. Output JSON only.' },
85
230
  { role: 'user', content: prompt },
@@ -91,7 +236,7 @@ async function judgePair(provider, model, sourceText, semanticText, recallText)
91
236
 
92
237
  async function main() {
93
238
  if (flag('help') || flag('h')) {
94
- process.stdout.write('usage: node scripts/session-context-bench.mjs --session <sessionId> [--provider p --model m] [--preset name] [--budget n] [--judge]\n');
239
+ process.stdout.write('usage: node scripts/session-context-bench.mjs --session <sessionId> [--provider p --model m] [--preset name] [--budget n] [--judge] [--recall-query q]\n');
95
240
  return;
96
241
  }
97
242
  const sessionId = arg('session');
@@ -107,57 +252,75 @@ async function main() {
107
252
  modelName: arg('model', session.model || null),
108
253
  });
109
254
 
110
- await memory.init();
111
- await memory.handleToolCall('memory', {
112
- action: 'ingest_session',
113
- sessionId,
114
- cwd: session.cwd || ROOT,
115
- messages,
116
- limit: Number(arg('ingest-limit', 500)) || 500,
117
- });
118
- await memory.handleToolCall('memory', {
119
- action: 'cycle1',
120
- min_batch: 1,
121
- session_cap: 1,
122
- batch_size: Math.max(1, Math.min(200, Number(arg('batch-size', messages.length)) || messages.length)),
123
- });
124
- const recallDumpResult = await memory.handleToolCall('memory', {
125
- action: 'dump_session_roots',
126
- sessionId,
127
- includeRaw: true,
128
- limit: Number(arg('limit', 1000)) || 1000,
129
- });
130
- const recallText = textOfResult(recallDumpResult);
131
-
255
+ const timings = {};
132
256
  const budget = Number(arg('budget', 16000)) || 16000;
133
- const semantic = await semanticCompactMessages(impl, messages, model, budget, {
257
+ const semantic = await timed('semantic_compact_ms', timings, () => semanticCompactMessages(impl, messages, model, budget, {
134
258
  force: true,
135
259
  sessionId,
136
260
  providerName: provider,
137
261
  tailTurns: Number(arg('tail-turns', 2)) || 2,
138
262
  timeoutMs: Number(arg('timeout-ms', 60000)) || 60000,
139
- });
263
+ }));
140
264
  const semanticText = renderMessages(semantic.messages);
141
265
  const sourceText = renderMessages(messages);
266
+ const candidateB = 'recall';
267
+ let candidateBText = '';
268
+ let candidateBMeta = null;
269
+ memoryModule = await import('../src/runtime/memory/index.mjs');
270
+ await timed('memory_init_ms', timings, () => memoryModule.init());
271
+ const ingest = await timed('recall_ingest_ms', timings, () => memoryModule.handleToolCall('memory', {
272
+ action: 'ingest_session',
273
+ sessionId,
274
+ cwd: session.cwd || ROOT,
275
+ messages,
276
+ limit: Number(arg('ingest-limit', 500)) || 500,
277
+ }));
278
+ const cycle1 = await timed('recall_cycle1_drain_ms', timings, () => drainCycle1ForSession(memoryModule, sessionId, {
279
+ maxPasses: Number(arg('cycle1-passes', 4)) || 4,
280
+ windowSize: Number(arg('window-size', arg('batch-size', 50))) || 50,
281
+ rowsPerSession: Number(arg('rows-per-session', 0)) || 0,
282
+ concurrency: Number(arg('concurrency', 4)) || 4,
283
+ callerDeadlineMs: Number(arg('cycle1-deadline-ms', 120000)) || 120000,
284
+ callLlm: directCycle1Llm(impl, model),
285
+ limit: Number(arg('limit', 1000)) || 1000,
286
+ }));
287
+ const recallQuery = String(arg('recall-query', recallQueryForSession(messages)) || '').trim();
288
+ const recallResult = await timed('recall_search_ms', timings, () => memoryModule.handleToolCall('memory', {
289
+ action: 'search',
290
+ sessionId,
291
+ query: recallQuery,
292
+ limit: Number(arg('recall-limit', 100)) || 100,
293
+ includeArchived: true,
294
+ includeMembers: true,
295
+ }));
296
+ candidateBText = textOfResult(recallResult);
297
+ candidateBMeta = {
298
+ chars: candidateBText.length,
299
+ lexicalCoverage: coverageScore(sourceText, candidateBText),
300
+ query: recallQuery,
301
+ ingest: textOfResult(ingest),
302
+ cycle1,
303
+ };
142
304
 
143
305
  const report = {
144
306
  sessionId,
145
307
  provider,
146
308
  model,
309
+ timings,
147
310
  source: { messages: messages.length, tokens: estimateMessagesTokens(messages), chars: sourceText.length },
148
311
  semantic: { messages: semantic.messages.length, tokens: estimateMessagesTokens(semantic.messages), chars: semanticText.length, lexicalCoverage: coverageScore(sourceText, semanticText), usage: semantic.usage || null },
149
- recallRoots: { chars: recallText.length, lexicalCoverage: coverageScore(sourceText, recallText) },
312
+ candidateB: { label: candidateB, ...candidateBMeta },
150
313
  judge: null,
151
314
  };
152
315
  if (flag('judge')) {
153
- report.judge = await judgePair(impl, model, sourceText, semanticText, recallText);
316
+ report.judge = await timed('judge_ms', timings, () => judgePair(impl, model, sourceText, semanticText, candidateBText, candidateB));
154
317
  }
155
318
 
156
319
  const outDir = resolve(arg('out', join(ROOT, '.mixdog-bench', `session-context-${sessionId}`)));
157
320
  mkdirSync(outDir, { recursive: true });
158
321
  writeFileSync(join(outDir, 'source.txt'), sourceText, 'utf8');
159
322
  writeFileSync(join(outDir, 'semantic.txt'), semanticText, 'utf8');
160
- writeFileSync(join(outDir, 'recall-roots.txt'), recallText, 'utf8');
323
+ writeFileSync(join(outDir, `${candidateB}.txt`), candidateBText, 'utf8');
161
324
  writeFileSync(join(outDir, 'report.json'), JSON.stringify(report, null, 2), 'utf8');
162
325
  process.stdout.write(`${JSON.stringify(report, null, 2)}\noutputs: ${outDir}\n`);
163
326
  }
@@ -168,5 +331,14 @@ main()
168
331
  process.exitCode = 1;
169
332
  })
170
333
  .finally(async () => {
171
- try { await memory.stop?.(); } catch {}
334
+ try {
335
+ if (memoryModule?.stop) {
336
+ const stopped = await withTimeout(memoryModule.stop(), Number(arg('stop-timeout-ms', 5000)) || 5000);
337
+ if (stopped.timedOut) process.stderr.write('warning: memory stop timed out; forcing bench exit\n');
338
+ }
339
+ } catch {}
340
+ if (isMainScript() && !flag('no-force-exit')) {
341
+ await new Promise((resolve) => process.stdout.write('', resolve));
342
+ setImmediate(() => process.exit(process.exitCode || 0));
343
+ }
172
344
  });