mixdog 0.8.1 → 0.9.1

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 (299) hide show
  1. package/README.md +47 -23
  2. package/package.json +34 -28
  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 -29
  62. package/src/lib/plugin-paths.cjs +1 -7
  63. package/src/lib/rules-builder.cjs +242 -98
  64. package/src/lib/text-utils.cjs +1 -1
  65. package/src/mixdog-session-runtime.mjs +2211 -446
  66. package/src/output-styles/default.md +12 -28
  67. package/src/output-styles/extreme-simple.md +9 -6
  68. package/src/output-styles/simple.md +22 -9
  69. package/src/repl.mjs +108 -59
  70. package/src/rules/agent/00-common.md +15 -0
  71. package/src/rules/{bridge → agent}/20-skip-protocol.md +1 -2
  72. package/src/rules/agent/30-explorer.md +22 -0
  73. package/src/rules/{bridge → agent}/40-cycle1-agent.md +7 -0
  74. package/src/rules/{bridge → agent}/41-cycle2-agent.md +7 -0
  75. package/src/rules/{bridge → agent}/42-cycle3-agent.md +7 -0
  76. package/src/rules/lead/01-general.md +9 -5
  77. package/src/rules/lead/04-workflow.md +51 -12
  78. package/src/rules/lead/lead-tool.md +6 -0
  79. package/src/rules/shared/01-tool.md +12 -1
  80. package/src/runtime/agent/orchestrator/activity-bus.mjs +7 -18
  81. package/src/runtime/agent/orchestrator/agent-owner.mjs +11 -0
  82. package/src/runtime/agent/orchestrator/{smart-bridge/bridge-llm.mjs → agent-runtime/agent-dispatch.mjs} +138 -111
  83. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +94 -0
  84. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/cache-strategy.mjs +32 -23
  85. package/src/runtime/agent/orchestrator/{smart-bridge → agent-runtime}/session-builder.mjs +33 -27
  86. package/src/runtime/agent/orchestrator/{bridge-trace.mjs → agent-trace.mjs} +132 -81
  87. package/src/runtime/agent/orchestrator/cache-mtime.mjs +0 -21
  88. package/src/runtime/agent/orchestrator/config.mjs +174 -55
  89. package/src/runtime/agent/orchestrator/context/collect.mjs +195 -487
  90. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/internal-roles.mjs +77 -29
  92. package/src/runtime/agent/orchestrator/internal-tools.mjs +5 -6
  93. package/src/runtime/agent/orchestrator/mcp/client.mjs +15 -9
  94. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -1
  95. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +380 -268
  96. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +131 -93
  97. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +236 -4
  98. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +49 -0
  99. package/src/runtime/agent/orchestrator/providers/gemini.mjs +58 -13
  100. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -149
  101. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +132 -2
  102. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +4 -1
  103. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +45 -0
  104. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +61 -116
  105. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +25 -0
  106. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +79 -255
  107. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +203 -71
  108. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +512 -312
  109. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +343 -518
  110. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +6 -6
  111. package/src/runtime/agent/orchestrator/providers/registry.mjs +88 -51
  112. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +289 -11
  113. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +41 -8
  114. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  115. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  116. package/src/runtime/agent/orchestrator/session/loop.mjs +840 -612
  117. package/src/runtime/agent/orchestrator/session/manager.mjs +1574 -459
  118. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  119. package/src/runtime/agent/orchestrator/session/store.mjs +67 -29
  120. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  121. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  122. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  123. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +35 -8
  124. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -8
  125. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -53
  126. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  127. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  128. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  129. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  130. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  131. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  133. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  138. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  139. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  140. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  141. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  142. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  143. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  144. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  145. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  146. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  147. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  148. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  149. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  150. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  151. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  152. package/src/runtime/channels/index.mjs +149 -51
  153. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  154. package/src/runtime/channels/lib/config.mjs +0 -1
  155. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  156. package/src/runtime/channels/lib/executor.mjs +0 -3
  157. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  158. package/src/runtime/channels/lib/output-forwarder.mjs +1 -8
  159. package/src/runtime/channels/lib/runtime-paths.mjs +0 -6
  160. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  161. package/src/runtime/channels/lib/session-discovery.mjs +3 -6
  162. package/src/runtime/channels/lib/tool-format.mjs +0 -1
  163. package/src/runtime/channels/lib/transcript-discovery.mjs +4 -12
  164. package/src/runtime/channels/lib/webhook.mjs +1 -1
  165. package/src/runtime/channels/tool-defs.mjs +29 -29
  166. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  167. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  168. package/src/runtime/memory/index.mjs +519 -118
  169. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  170. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  171. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  172. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  173. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  174. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  175. package/src/runtime/memory/lib/memory-cycle1.mjs +28 -7
  176. package/src/runtime/memory/lib/memory-cycle2.mjs +4 -5
  177. package/src/runtime/memory/lib/memory-cycle3.mjs +2 -2
  178. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -2
  179. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  180. package/src/runtime/memory/lib/memory.mjs +88 -9
  181. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  182. package/src/runtime/memory/lib/pg/adapter.mjs +1 -1
  183. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  184. package/src/runtime/memory/lib/runtime-fetcher.mjs +80 -21
  185. package/src/runtime/memory/lib/session-ingest.mjs +196 -0
  186. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  187. package/src/runtime/memory/tool-defs.mjs +46 -37
  188. package/src/runtime/search/index.mjs +104 -473
  189. package/src/runtime/search/lib/config.mjs +0 -4
  190. package/src/runtime/search/lib/state.mjs +1 -15
  191. package/src/runtime/search/lib/web-tools.mjs +44 -25
  192. package/src/runtime/search/tool-defs.mjs +16 -23
  193. package/src/runtime/shared/abort-controller.mjs +1 -1
  194. package/src/runtime/shared/atomic-file.mjs +4 -3
  195. package/src/runtime/shared/background-tasks.mjs +122 -11
  196. package/src/runtime/shared/child-spawn-gate.mjs +139 -0
  197. package/src/runtime/shared/config.mjs +7 -4
  198. package/src/runtime/shared/err-text.mjs +131 -4
  199. package/src/runtime/shared/llm/cost.mjs +2 -2
  200. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  201. package/src/runtime/shared/llm/index.mjs +34 -11
  202. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  203. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  204. package/src/runtime/shared/singleton-owner.mjs +104 -0
  205. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  206. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  207. package/src/runtime/shared/tool-surface.mjs +624 -95
  208. package/src/runtime/shared/user-data-guard.mjs +0 -2
  209. package/src/standalone/agent-task-status.mjs +203 -0
  210. package/src/standalone/agent-task-status.test.mjs +76 -0
  211. package/src/standalone/agent-tool.mjs +1913 -0
  212. package/src/standalone/channel-worker.mjs +370 -14
  213. package/src/standalone/explore-tool.mjs +165 -70
  214. package/src/standalone/folder-dialog.mjs +314 -0
  215. package/src/standalone/hook-bus.mjs +898 -22
  216. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  217. package/src/standalone/projects.mjs +226 -0
  218. package/src/standalone/provider-admin.mjs +41 -24
  219. package/src/standalone/seeds.mjs +1 -78
  220. package/src/standalone/usage-dashboard.mjs +96 -8
  221. package/src/tui/App.jsx +4800 -2140
  222. package/src/tui/components/AnsiText.jsx +39 -28
  223. package/src/tui/components/ContextPanel.jsx +14 -4
  224. package/src/tui/components/Markdown.jsx +43 -77
  225. package/src/tui/components/MarkdownTable.jsx +9 -184
  226. package/src/tui/components/Message.jsx +28 -11
  227. package/src/tui/components/Picker.jsx +95 -56
  228. package/src/tui/components/PromptInput.jsx +428 -239
  229. package/src/tui/components/QueuedCommands.jsx +1 -1
  230. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  231. package/src/tui/components/Spinner.jsx +67 -38
  232. package/src/tui/components/StatusLine.jsx +606 -38
  233. package/src/tui/components/TextEntryPanel.jsx +128 -9
  234. package/src/tui/components/ToolExecution.jsx +597 -362
  235. package/src/tui/components/TurnDone.jsx +3 -3
  236. package/src/tui/components/UsagePanel.jsx +3 -5
  237. package/src/tui/components/tool-output-format.mjs +499 -0
  238. package/src/tui/components/tool-output-format.test.mjs +312 -0
  239. package/src/tui/dist/index.mjs +9192 -2399
  240. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  241. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  242. package/src/tui/engine.mjs +1455 -279
  243. package/src/tui/figures.mjs +21 -40
  244. package/src/tui/index.jsx +75 -31
  245. package/src/tui/input-editing.mjs +25 -0
  246. package/src/tui/markdown/format-token.mjs +671 -69
  247. package/src/tui/markdown/format-token.test.mjs +312 -0
  248. package/src/tui/markdown/render-ansi.mjs +94 -0
  249. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  250. package/src/tui/markdown/stream-fence.mjs +34 -0
  251. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  252. package/src/tui/markdown/table-layout.mjs +250 -0
  253. package/src/tui/paste-attachments.mjs +0 -7
  254. package/src/tui/spinner-verbs.mjs +1 -2
  255. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  256. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  257. package/src/tui/theme.mjs +756 -24
  258. package/src/tui/time-format.mjs +1 -1
  259. package/src/tui/transcript-tool-failures.mjs +67 -0
  260. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  261. package/src/ui/ansi.mjs +1 -2
  262. package/src/ui/markdown.mjs +85 -26
  263. package/src/ui/markdown.test.mjs +70 -0
  264. package/src/ui/model-display.mjs +121 -0
  265. package/src/ui/session-stats.mjs +44 -0
  266. package/src/ui/statusline-context-label.test.mjs +15 -0
  267. package/src/ui/statusline.mjs +386 -178
  268. package/src/ui/tool-card.mjs +2 -13
  269. package/src/vendor/statusline/bin/statusline-lib.mjs +1 -620
  270. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  271. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  272. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  273. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  274. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  275. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  276. package/src/workflows/default/WORKFLOW.md +12 -5
  277. package/src/workflows/default/workflow.json +0 -1
  278. package/src/workflows/solo/WORKFLOW.md +15 -0
  279. package/src/workflows/solo/workflow.json +7 -0
  280. package/vendor/ink/build/ink.js +54 -8
  281. package/vendor/ink/build/output.js +6 -1
  282. package/src/agents/scheduler-task.md +0 -3
  283. package/src/agents/web-researcher/AGENT.md +0 -3
  284. package/src/agents/web-researcher/agent.json +0 -6
  285. package/src/agents/webhook-handler.md +0 -3
  286. package/src/hooks/lib/permission-rules.cjs +0 -170
  287. package/src/hooks/lib/settings-loader.cjs +0 -112
  288. package/src/lib/hook-pipe-path.cjs +0 -10
  289. package/src/rules/bridge/00-common.md +0 -5
  290. package/src/rules/bridge/30-explorer.md +0 -4
  291. package/src/rules/lead/00-tool-lead.md +0 -5
  292. package/src/rules/shared/00-language.md +0 -3
  293. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  294. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  295. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  296. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  297. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  298. package/src/standalone/bridge-tool.mjs +0 -1414
  299. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -88,10 +88,6 @@ export function loadUsageState() {
88
88
  return state
89
89
  }
90
90
 
91
- export function saveUsageState(state) {
92
- scheduleUsageFlush(state)
93
- }
94
-
95
91
  export function updateProviderState(state, provider, patch) {
96
92
  let normalizedPatch = { ...patch }
97
93
  const remaining =
@@ -125,7 +121,7 @@ export function noteProviderSuccess(state, provider, extra = {}) {
125
121
  })
126
122
  }
127
123
 
128
- export const PROVIDER_ERROR_KIND = {
124
+ const PROVIDER_ERROR_KIND = {
129
125
  AUTH: 'auth',
130
126
  QUOTA: 'quota',
131
127
  PAYMENT: 'payment',
@@ -151,16 +147,6 @@ export function classifyProviderError(error) {
151
147
  return PROVIDER_ERROR_KIND.UNKNOWN
152
148
  }
153
149
 
154
- /** Structured HTTP error for search backends (enables cooldown via classifyProviderError). */
155
- export function providerHttpError(provider, status, detail = '') {
156
- const code = Number(status)
157
- const snippet = detail ? `: ${String(detail).slice(0, 200)}` : ''
158
- const err = new Error(`[search:${provider}] HTTP ${code}${snippet}`)
159
- err.status = code
160
- err.provider = provider
161
- return err
162
- }
163
-
164
150
  const PROVIDER_DISABLE_TTL_MS = {
165
151
  auth: 24 * 3600 * 1000,
166
152
  quota: 24 * 3600 * 1000,
@@ -3,12 +3,26 @@ import dns from 'dns'
3
3
  import net from 'net'
4
4
  import { Agent, fetch as undiciFetch } from 'undici'
5
5
 
6
- import { JSDOM } from 'jsdom'
7
- import puppeteer from 'puppeteer-core'
8
6
  import { Readability } from '@mozilla/readability'
9
7
  import { isWSL } from '../../shared/wsl.mjs'
10
8
  import { startChildGuardian } from '../../shared/child-guardian.mjs'
11
9
 
10
+ // Lazy heavy deps: importing jsdom (~400ms) and puppeteer-core (~130ms) at
11
+ // module load added ~540ms to the first web search even when the request never
12
+ // scraped HTML. Load them on first actual use and cache the resolved binding so
13
+ // repeat calls pay nothing. The search runtime itself is already dynamically
14
+ // imported, so this keeps that first-use cost proportional to what the request
15
+ // truly needs (a plain fetch path touches neither).
16
+ let _JSDOM = null
17
+ async function loadJSDOM() {
18
+ if (!_JSDOM) ({ JSDOM: _JSDOM } = await import('jsdom'))
19
+ return _JSDOM
20
+ }
21
+ let _puppeteer = null
22
+ async function loadPuppeteer() {
23
+ if (!_puppeteer) _puppeteer = (await import('puppeteer-core')).default
24
+ return _puppeteer
25
+ }
12
26
 
13
27
  const PKG_VERSION = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version } catch { return '0.0.1' } })()
14
28
  import {
@@ -491,7 +505,8 @@ function classifyJavascriptRenderingPlaceholder(page) {
491
505
  return null
492
506
  }
493
507
 
494
- function extractReadableArticle(url, html) {
508
+ async function extractReadableArticle(url, html) {
509
+ const JSDOM = await loadJSDOM()
495
510
  const dom = new JSDOM(html, { url })
496
511
  try {
497
512
  const doc = dom.window.document
@@ -701,11 +716,11 @@ async function fetchPinnedForPausedRequest(url, { signal, method = 'GET', header
701
716
  currentUrl = new URL(location, currentUrl).toString()
702
717
  continue
703
718
  }
704
- const body = await readBodyBytesWithCap(response, MAX_BODY_BYTES)
719
+ const respBody = await readBodyBytesWithCap(response, MAX_BODY_BYTES)
705
720
  return {
706
721
  status: response.status,
707
722
  responseHeaders: headersToCdpPairs(response.headers),
708
- body,
723
+ body: respBody,
709
724
  }
710
725
  }
711
726
  }
@@ -812,7 +827,7 @@ async function scrapeWithReadability(url, timeoutMs, signal) {
812
827
  currentUrl = target
813
828
  html = await fetchHtml(currentUrl, timeoutMs, signal)
814
829
  }
815
- return extractReadableArticle(currentUrl, html)
830
+ return await extractReadableArticle(currentUrl, html)
816
831
  }
817
832
 
818
833
  function resolveBrowserLaunchOptions() {
@@ -888,23 +903,26 @@ async function _getPoolBrowser() {
888
903
  }
889
904
  if (_poolBrowser) return _poolBrowser
890
905
  if (!_poolLaunching) {
891
- _poolLaunching = puppeteer.launch({
892
- headless: true,
893
- ...resolveBrowserLaunchOptions(),
894
- args: buildPuppeteerLaunchArgs(),
895
- }).then((browser) => {
896
- _poolBrowser = browser
897
- try {
898
- const proc = browser.process?.()
899
- startChildGuardian({ childPid: proc?.pid, label: 'puppeteer-browser' })
900
- } catch {}
901
- browser.on('disconnected', () => {
902
- if (_poolBrowser === browser) _poolBrowser = null
906
+ _poolLaunching = loadPuppeteer()
907
+ .then((puppeteer) => puppeteer.launch({
908
+ headless: true,
909
+ ...resolveBrowserLaunchOptions(),
910
+ args: buildPuppeteerLaunchArgs(),
911
+ }))
912
+ .then((browser) => {
913
+ _poolBrowser = browser
914
+ try {
915
+ const proc = browser.process?.()
916
+ startChildGuardian({ childPid: proc?.pid, label: 'puppeteer-browser' })
917
+ } catch {}
918
+ browser.on('disconnected', () => {
919
+ if (_poolBrowser === browser) _poolBrowser = null
920
+ })
921
+ return browser
922
+ })
923
+ .finally(() => {
924
+ _poolLaunching = null
903
925
  })
904
- return browser
905
- }).finally(() => {
906
- _poolLaunching = null
907
- })
908
926
  }
909
927
  return _poolLaunching
910
928
  }
@@ -1033,7 +1051,7 @@ async function scrapeWithPuppeteer(url, timeoutMs, signal) {
1033
1051
  }
1034
1052
  try {
1035
1053
  return {
1036
- ...extractReadableArticle(finalUrl, html),
1054
+ ...(await extractReadableArticle(finalUrl, html)),
1037
1055
  extractor: 'puppeteer',
1038
1056
  }
1039
1057
  } catch {
@@ -1088,7 +1106,8 @@ function filterLinks(rawLinks, baseUrl, { limit = 50, sameDomainOnly = true, sea
1088
1106
  return items
1089
1107
  }
1090
1108
 
1091
- function extractLinksFromHtml(baseUrl, html, options) {
1109
+ async function extractLinksFromHtml(baseUrl, html, options) {
1110
+ const JSDOM = await loadJSDOM()
1092
1111
  const dom = new JSDOM(html, { url: baseUrl })
1093
1112
  try {
1094
1113
  const links = Array.from(dom.window.document.querySelectorAll('a[href]')).map(link => ({
@@ -1103,7 +1122,7 @@ function extractLinksFromHtml(baseUrl, html, options) {
1103
1122
 
1104
1123
  async function mapWithHttp(url, options, timeoutMs, signal) {
1105
1124
  const html = await fetchHtml(url, timeoutMs, signal)
1106
- return extractLinksFromHtml(url, html, options)
1125
+ return await extractLinksFromHtml(url, html, options)
1107
1126
  }
1108
1127
 
1109
1128
  async function mapWithPuppeteer(url, options, timeoutMs, signal) {
@@ -1,44 +1,37 @@
1
1
  import {
2
- TOOL_ASYNC_EXECUTION_CONTRACT,
3
2
  TOOL_SYNC_EXECUTION_CONTRACT,
4
- executionModeSchemaDescription,
5
- } from '../shared/background-tasks.mjs';
3
+ } from '../shared/tool-execution-contract.mjs';
6
4
 
7
5
  export const TOOL_DEFS = [
8
6
  {
9
7
  name: 'search',
10
8
  title: 'Mixdog Web Search',
11
- description: `Web search through the Web Researcher agent. Prefer mode=async for broad/current web research or multi-query searches; use sync only when the next step must block on this result. ${TOOL_SYNC_EXECUTION_CONTRACT} ${TOOL_ASYNC_EXECUTION_CONTRACT} Keeps Mixdog search caching, fan-out, and result formatting.`,
9
+ description: `Web/docs/current-info search. Not repo-local; use explore/code_graph/grep. ${TOOL_SYNC_EXECUTION_CONTRACT} Cached; query supports array fan-out.`,
12
10
  inputSchema: {
13
11
  type: 'object',
14
12
  properties: {
15
- query: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Search query, or array for fan-out. Keep each query focused.' },
16
- mode: { type: 'string', enum: ['async', 'sync'], description: `${executionModeSchemaDescription('sync')} Prefer async for non-trivial web research; choose sync only for an explicit blocking lookup.` },
17
- action: { type: 'string', enum: ['run', 'list', 'status', 'read', 'cancel'], description: 'Default run. list/status/read/cancel are manual recovery controls for async search tasks.' },
18
- task_id: { type: 'string', description: 'Shared background task id for manual status/read/cancel recovery.' },
19
- firstResponseTimeoutMs: { type: 'number', minimum: 0, description: 'Abort only when the Web Researcher produces no first stream/tool activity within this many ms. Default 120s. 0 disables this watchdog.' },
20
- idleTimeoutMs: { type: 'number', minimum: 0, description: 'Stale watchdog after first Web Researcher stream/tool activity. Default 30m. 0 disables stale abort.' },
21
- site: { type: 'string', description: 'Optional site/domain filter.' },
22
- type: { type: 'string', enum: ['web', 'news', 'images'], description: 'Search type. Default web.' },
23
- maxResults: { type: 'number', minimum: 1, maximum: 20, description: 'Maximum results to return, 1-20.' },
13
+ query: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'Query or array for fan-out.' },
14
+ site: { type: 'string', description: 'Site/domain filter.' },
15
+ type: { type: 'string', enum: ['web', 'news', 'images'], description: 'Default web.' },
16
+ maxResults: { type: 'number', minimum: 1, maximum: 20, description: 'Result count, 1-20.' },
24
17
  locale: {
25
- description: 'Optional locale hint as a string or structured location/language object.',
18
+ description: 'Optional locale hint.',
26
19
  anyOf: [
27
20
  { type: 'string' },
28
21
  {
29
22
  type: 'object',
30
23
  properties: {
31
- country: { type: 'string', description: 'Country code or name for result localization.' },
32
- language: { type: 'string', description: 'Preferred result language.' },
33
- region: { type: 'string', description: 'Region/state hint.' },
34
- city: { type: 'string', description: 'City hint.' },
35
- timezone: { type: 'string', description: 'IANA timezone hint.' },
24
+ country: { type: 'string', description: 'Country.' },
25
+ language: { type: 'string', description: 'Language.' },
26
+ region: { type: 'string', description: 'Region.' },
27
+ city: { type: 'string', description: 'City.' },
28
+ timezone: { type: 'string', description: 'Timezone.' },
36
29
  },
37
30
  additionalProperties: false,
38
31
  },
39
32
  ],
40
33
  },
41
- contextSize: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Search context size hint. Default low.' },
34
+ contextSize: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Default low.' },
42
35
  },
43
36
  required: [],
44
37
  additionalProperties: false,
@@ -48,12 +41,12 @@ export const TOOL_DEFS = [
48
41
  {
49
42
  name: 'web_fetch',
50
43
  title: 'Mixdog Web Fetch',
51
- description: 'Fetch full page body from a URL (web page, article, docs). Requires url. Use after search to read a result.',
44
+ description: 'Use after search. Fetch page/docs body from URL.',
52
45
  inputSchema: {
53
46
  type: 'object',
54
47
  properties: {
55
- url: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'URL, or array of URLs, to fetch.' },
56
- startIndex: { type: 'number', minimum: 0, description: 'Character offset for paging large pages.' },
48
+ url: { anyOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' }, minItems: 1 }], description: 'URL or array of URLs.' },
49
+ startIndex: { type: 'number', minimum: 0, description: 'Character offset.' },
57
50
  maxLength: { type: 'number', minimum: 0, description: 'Maximum characters to return.' },
58
51
  },
59
52
  required: ['url'],
@@ -1,5 +1,5 @@
1
1
  /**
2
- * AbortController helpers — ported from the Claude Code CLI pattern.
2
+ * AbortController helpers — ported from a reference agent CLI pattern.
3
3
  *
4
4
  * `createAbortController()` raises the signal's max listener cap so long-running
5
5
  * sessions with many per-iteration handlers don't trip Node's default warning.
@@ -121,9 +121,10 @@ export function withFileLockSync(lockPath, fn, opts = {}) {
121
121
  // For secret-bearing critical sections, the lock file sits beside
122
122
  // the secret in the same (shared-home) directory; clamp it owner-only
123
123
  // too. Fail-closed: an unenforceable ACL aborts before fn() runs.
124
- if (opts.secret === true) _enforceOwnerOnlyAclWin32(lockPath);
125
- try { return fn(); }
126
- finally {
124
+ try {
125
+ if (opts.secret === true) _enforceOwnerOnlyAclWin32(lockPath);
126
+ return fn();
127
+ } finally {
127
128
  try { closeSync(fd); } catch {}
128
129
  // Only unlink if we still own the lock. If our hold exceeded
129
130
  // staleMs and another process already stole + replaced the
@@ -19,6 +19,14 @@ const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']);
19
19
  const tasks = new Map();
20
20
  let seq = 0;
21
21
 
22
+ // Owner-injected fallback used by notifyToolCompletion: when no notifyFn is
23
+ // available (or it declines), enqueue the completion onto the caller session so
24
+ // the owner is never left waiting on a canonical completion that never arrives.
25
+ let _enqueueFallback = null;
26
+ export function setBackgroundTaskEnqueueFallback(fn) {
27
+ _enqueueFallback = typeof fn === 'function' ? fn : null;
28
+ }
29
+
22
30
  function clean(value) {
23
31
  return String(value ?? '').trim();
24
32
  }
@@ -41,7 +49,7 @@ export function resolveExecutionMode(args = {}, defaultMode = 'sync') {
41
49
 
42
50
  export function executionModeSchemaDescription(defaultMode = 'sync') {
43
51
  const defaultText = defaultMode === 'async' ? 'Default async.' : 'Default sync.';
44
- return `Execution mode: sync returns the result in this call; async returns a task_id immediately and sends an owner-session notification on completion. ${defaultText}`;
52
+ return `sync = inline result; async = task_id + completion notification. ${defaultText}`;
45
53
  }
46
54
 
47
55
  export function taskIdFromArgs(args = {}) {
@@ -75,6 +83,45 @@ function normalizeTaskScope(options = {}) {
75
83
  };
76
84
  }
77
85
 
86
+ const RENDER_META_OMIT_KEYS = new Set([
87
+ // Internal watchdog/control knobs are intentionally not part of the public
88
+ // task notification surface. They may appear on legacy/in-flight task meta
89
+ // from older callers, but rendering them teaches the model/user to set them.
90
+ 'firstResponseTimeoutMs',
91
+ 'idleTimeoutMs',
92
+ 'spawnPrepTimeoutMs',
93
+ 'watchdogPolicy',
94
+ ]);
95
+
96
+ function isInternalTaskMetaKey(key) {
97
+ const name = clean(key);
98
+ if (!name) return true;
99
+ if (RENDER_META_OMIT_KEYS.has(name)) return true;
100
+ if (/timeoutms$/i.test(name)) return true;
101
+ return false;
102
+ }
103
+
104
+ /** Drop internal control knobs from meta before persisting on a task row. */
105
+ export function sanitizeTaskMeta(meta = {}) {
106
+ if (!meta || typeof meta !== 'object') return {};
107
+ const out = {};
108
+ for (const [key, value] of Object.entries(meta)) {
109
+ if (isInternalTaskMetaKey(key)) continue;
110
+ out[key] = value;
111
+ }
112
+ return out;
113
+ }
114
+
115
+ function publicTaskMeta(meta = {}) {
116
+ const out = {};
117
+ for (const [key, value] of Object.entries(meta || {})) {
118
+ if (value == null || value === '') continue;
119
+ if (isInternalTaskMetaKey(key)) continue;
120
+ out[key] = value;
121
+ }
122
+ return out;
123
+ }
124
+
78
125
  function hasScopeCriteria(scope) {
79
126
  return Boolean(scope?.callerSessionId || scope?.routingSessionId || scope?.clientHostPid);
80
127
  }
@@ -153,7 +200,7 @@ export function registerBackgroundTask({
153
200
  ownerSessionId: notifyContext.callerSessionId || notifyContext.routingSessionId || null,
154
201
  routingSessionId: notifyContext.routingSessionId || notifyContext.callerSessionId || null,
155
202
  clientHostPid: notifyContext.clientHostPid || null,
156
- meta: meta && typeof meta === 'object' ? meta : {},
203
+ meta: sanitizeTaskMeta(meta && typeof meta === 'object' ? meta : {}),
157
204
  resultType: clean(resultType) || null,
158
205
  renderResult: typeof renderResult === 'function' ? renderResult : null,
159
206
  cancel: typeof cancel === 'function' ? cancel : null,
@@ -167,7 +214,7 @@ export function startBackgroundTask(options = {}) {
167
214
  task.promise = Promise.resolve()
168
215
  .then(() => options.run?.())
169
216
  .then((result) => {
170
- completeBackgroundTask(task.taskId, { status: 'completed', result });
217
+ completeBackgroundTask(task.taskId, { status: 'completed', result, terminalReason: 'run-resolved' });
171
218
  return result;
172
219
  })
173
220
  .catch((error) => {
@@ -175,6 +222,7 @@ export function startBackgroundTask(options = {}) {
175
222
  completeBackgroundTask(task.taskId, {
176
223
  status: 'failed',
177
224
  error,
225
+ terminalReason: 'run-rejected',
178
226
  });
179
227
  return null;
180
228
  });
@@ -241,7 +289,14 @@ function resultTextForTask(task) {
241
289
  if (task.resultText) return task.resultText;
242
290
  if (task.result !== undefined) {
243
291
  if (typeof task.renderResult === 'function') {
244
- try { return String(task.renderResult(task.result, task) || ''); } catch {}
292
+ try {
293
+ const rendered = String(task.renderResult(task.result, task) || '');
294
+ if (rendered) return rendered;
295
+ } catch (err) {
296
+ // Don't silently swallow a renderer throw: fall through to the JSON
297
+ // fallback below so the completion notification still carries a body.
298
+ try { process.stderr.write(`[background-${task.surface}] renderResult failed: ${err?.message || err}\n`); } catch {}
299
+ }
245
300
  }
246
301
  if (typeof task.result === 'string') return task.result;
247
302
  try { return JSON.stringify(task.result, null, 2); } catch {}
@@ -257,6 +312,7 @@ export function completeBackgroundTask(taskId, {
257
312
  resultType,
258
313
  instruction,
259
314
  notify = true,
315
+ terminalReason = null,
260
316
  } = {}) {
261
317
  const task = getBackgroundTask(taskId);
262
318
  if (!task) return null;
@@ -265,17 +321,30 @@ export function completeBackgroundTask(taskId, {
265
321
  task.status = normalizeStatus(status);
266
322
  task.finishedAtMs = now;
267
323
  task.finishedAt = new Date(now).toISOString();
324
+ if (terminalReason) task.terminalReason = terminalReason;
268
325
  if (result !== undefined) task.result = result;
269
326
  if (resultText != null) task.resultText = compactText(resultText);
270
327
  if (error != null) task.error = presentErrorText(error, { surface: task.surface });
271
328
  if (resultType) task.resultType = resultType;
272
- if (notify) notifyTaskCompletion(task, instruction);
329
+ if (notify) {
330
+ task.meta = sanitizeTaskMeta(task.meta);
331
+ notifyTaskCompletion(task, instruction);
332
+ }
273
333
  return task;
274
334
  }
275
335
 
276
336
  export function notifyTaskCompletion(task, instruction) {
277
- if (!task || task.notified === true) return false;
337
+ if (!task) return false;
278
338
  if (!TERMINAL_STATUSES.has(task.status)) return false;
339
+ // `notified` tracks whether *any* completion notification was sent (e.g. an
340
+ // early header-only preview from the agent surface). A later call that can
341
+ // finally include the result body must still fire once, otherwise the owner
342
+ // only ever sees the bodyless preview. Gate on `notifiedWithBody` so the
343
+ // body-carrying notification is delivered exactly once.
344
+ const body = resultTextForTask(task);
345
+ const hasBody = Boolean(body);
346
+ if (task.notifiedWithBody === true) return false;
347
+ if (task.notified === true && !hasBody) return false;
279
348
  const text = renderBackgroundTask(task, { includeResult: true });
280
349
  const sent = notifyToolCompletion({
281
350
  surface: task.surface,
@@ -285,12 +354,43 @@ export function notifyTaskCompletion(task, instruction) {
285
354
  resultType: task.resultType || `${task.surface}_task_result`,
286
355
  instruction,
287
356
  context: task.notifyContext,
357
+ enqueueFallback: _enqueueFallback || undefined,
288
358
  logPrefix: `background-${task.surface}`,
289
359
  });
290
- if (sent) task.notified = true;
360
+ if (sent) {
361
+ task.notified = true;
362
+ if (hasBody) task.notifiedWithBody = true;
363
+ }
291
364
  return sent;
292
365
  }
293
366
 
367
+ // Safety net for the "task stuck in running" failure mode: when an upstream
368
+ // owner (e.g. the agent surface) knows a job has reached a terminal state but
369
+ // the normal run-resolved path did not mark the task — typically because a
370
+ // post-result step (session save) hung or threw before the task promise could
371
+ // settle — this forces the task to a terminal state and fires the completion
372
+ // notification. Idempotent: a no-op once the task is already terminal.
373
+ export function reconcileBackgroundTask(taskId, {
374
+ status = 'completed',
375
+ result,
376
+ resultText,
377
+ error,
378
+ instruction,
379
+ terminalReason = 'reconciled',
380
+ } = {}) {
381
+ const task = getBackgroundTask(taskId);
382
+ if (!task) return null;
383
+ if (TERMINAL_STATUSES.has(task.status)) return task;
384
+ return completeBackgroundTask(task.taskId, {
385
+ status,
386
+ result,
387
+ resultText,
388
+ error,
389
+ instruction,
390
+ terminalReason,
391
+ });
392
+ }
393
+
294
394
  export function notifyBackgroundTaskProgress(taskOrId, {
295
395
  text,
296
396
  resultText,
@@ -317,6 +417,7 @@ export function notifyBackgroundTaskProgress(taskOrId, {
317
417
  resultType: resultType || `${task.surface}_task_progress`,
318
418
  instruction,
319
419
  context: task.notifyContext,
420
+ enqueueFallback: _enqueueFallback || undefined,
320
421
  logPrefix: `background-${task.surface}`,
321
422
  });
322
423
  if (sent && once) task.progressNotifiedKeys.add(progressKey);
@@ -334,13 +435,14 @@ export function taskSummary(task) {
334
435
  startedAt: task.startedAt,
335
436
  finishedAt: task.finishedAt,
336
437
  error: task.error,
337
- ...task.meta,
438
+ ...publicTaskMeta(task.meta),
338
439
  };
339
440
  }
340
441
 
341
442
  export function renderBackgroundTask(taskOrId, { includeResult = false } = {}) {
342
443
  const task = typeof taskOrId === 'string' ? getBackgroundTask(taskOrId) : taskOrId;
343
444
  if (!task) return 'Error: background task not found';
445
+ const visibleMeta = publicTaskMeta(sanitizeTaskMeta(task.meta));
344
446
  const lines = [
345
447
  'background task',
346
448
  `task_id: ${task.taskId}`,
@@ -352,8 +454,7 @@ export function renderBackgroundTask(taskOrId, { includeResult = false } = {}) {
352
454
  task.finishedAt ? `finished: ${task.finishedAt}` : null,
353
455
  task.error ? `error: ${task.error}` : null,
354
456
  ];
355
- for (const [key, value] of Object.entries(task.meta || {})) {
356
- if (value == null || value === '') continue;
457
+ for (const [key, value] of Object.entries(visibleMeta)) {
357
458
  lines.push(`${key}: ${value}`);
358
459
  }
359
460
  if (task.status === 'running') {
@@ -361,7 +462,17 @@ export function renderBackgroundTask(taskOrId, { includeResult = false } = {}) {
361
462
  }
362
463
  if (includeResult) {
363
464
  const body = resultTextForTask(task);
364
- if (body) lines.push('', body);
465
+ if (body) {
466
+ lines.push('', body);
467
+ } else if (TERMINAL_STATUSES.has(task.status) && task.error) {
468
+ const errorText = String(task.error || '').trim();
469
+ lines.push('', /^error\s*:/i.test(errorText) ? errorText : `Error: ${errorText}`);
470
+ } else if (TERMINAL_STATUSES.has(task.status) && task.status === 'completed') {
471
+ // Terminal-completed task with no extractable body: surface a placeholder
472
+ // instead of silently omitting the result so the owner isn't left with a
473
+ // header-only card that looks truncated.
474
+ lines.push('', '(no result body — use read for full output)');
475
+ }
365
476
  }
366
477
  return lines.filter((line) => line !== null).join('\n');
367
478
  }
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ // ── Module-global child-spawn semaphore ──────────────────────────────────
4
+ //
5
+ // Single-daemon premise: tool execution is in-process, so a module-level
6
+ // singleton semaphore bounds the number of concurrent child processes (rg,
7
+ // mixdog-graph, …) across ALL agents/workers in this daemon. The goal is to
8
+ // absorb the over-saturation tail — when N agents fire grep/code_graph at
9
+ // once each child no longer fans out across every core, and the rg 20s
10
+ // deadline stops tripping en masse — WITHOUT serializing: up to the cap run
11
+ // concurrently, only the overflow queues and drains as slots free.
12
+ //
13
+ // IMPORTANT: this is a resource-control knob and is deliberately NOT exposed
14
+ // on any tool JSON schema / tool parameter surface. The only tuning surface
15
+ // is the internal constant + the MIXDOG_CHILD_SPAWN_MAX_INFLIGHT env override.
16
+
17
+ import os from 'node:os';
18
+
19
+ function _defaultMaxInflight() {
20
+ const override = Number(process.env.MIXDOG_CHILD_SPAWN_MAX_INFLIGHT);
21
+ if (Number.isFinite(override) && override >= 1) return Math.floor(override);
22
+ let cpus = 0;
23
+ try { cpus = os.cpus()?.length || 0; } catch { cpus = 0; }
24
+ return Math.max(4, cpus || 4);
25
+ }
26
+
27
+ const MAX_INFLIGHT = _defaultMaxInflight();
28
+
29
+ // Warn (once-throttled, stderr only) when a waiter sat in the queue longer
30
+ // than this — a coarse signal that the cap is undersized for the load. Kept
31
+ // intentionally quiet so a busy daemon does not spam stderr.
32
+ const SLOW_WAIT_MS = Math.max(
33
+ 1000,
34
+ Number(process.env.MIXDOG_CHILD_SPAWN_SLOW_MS) || 10000,
35
+ );
36
+ const SLOW_WARN_THROTTLE_MS = 30000;
37
+
38
+ let _inflight = 0;
39
+ /** @type {Array<{ resolve: () => void, reject: (e: any) => void, signal: AbortSignal | null, onAbort: (() => void) | null, enqueuedAt: number }>} */
40
+ const _queue = [];
41
+ let _lastSlowWarnAt = 0;
42
+
43
+ function _maybeWarnSlow(waitedMs) {
44
+ if (waitedMs < SLOW_WAIT_MS) return;
45
+ const now = Date.now();
46
+ if (now - _lastSlowWarnAt < SLOW_WARN_THROTTLE_MS) return;
47
+ _lastSlowWarnAt = now;
48
+ try {
49
+ process.stderr.write(
50
+ `[child-spawn-gate] queue wait ${waitedMs}ms (inflight cap=${MAX_INFLIGHT}, queued=${_queue.length}); `
51
+ + 'raise MIXDOG_CHILD_SPAWN_MAX_INFLIGHT if this persists\n',
52
+ );
53
+ } catch { /* ignore */ }
54
+ }
55
+
56
+ function _drain() {
57
+ while (_inflight < MAX_INFLIGHT && _queue.length > 0) {
58
+ const waiter = _queue.shift();
59
+ if (waiter.onAbort && waiter.signal) {
60
+ try { waiter.signal.removeEventListener('abort', waiter.onAbort); } catch { /* ignore */ }
61
+ }
62
+ _inflight++;
63
+ _maybeWarnSlow(Date.now() - waiter.enqueuedAt);
64
+ waiter.resolve();
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Acquire one child-spawn slot. Resolves immediately when below the cap,
70
+ * otherwise queues until a slot frees. The returned function releases the
71
+ * slot and is idempotent (safe to call from multiple settle paths — only the
72
+ * first call counts). Supports an optional AbortSignal: aborting while still
73
+ * queued rejects with the signal reason and removes the waiter (no leak); a
74
+ * post-acquire abort is a no-op here — the caller owns teardown and must still
75
+ * call release().
76
+ *
77
+ * @param {AbortSignal | null} [signal]
78
+ * @returns {Promise<() => void>}
79
+ */
80
+ export function acquire(signal = null) {
81
+ if (signal && signal.aborted) {
82
+ return Promise.reject(signal.reason ?? _abortError());
83
+ }
84
+ return new Promise((resolve, reject) => {
85
+ let released = false;
86
+ const release = () => {
87
+ if (released) return;
88
+ released = true;
89
+ _inflight = Math.max(0, _inflight - 1);
90
+ _drain();
91
+ };
92
+ if (_inflight < MAX_INFLIGHT && _queue.length === 0) {
93
+ _inflight++;
94
+ resolve(release);
95
+ return;
96
+ }
97
+ const waiter = {
98
+ enqueuedAt: Date.now(),
99
+ signal: signal || null,
100
+ onAbort: null,
101
+ resolve: () => resolve(release),
102
+ reject,
103
+ };
104
+ if (signal) {
105
+ waiter.onAbort = () => {
106
+ const idx = _queue.indexOf(waiter);
107
+ if (idx !== -1) _queue.splice(idx, 1);
108
+ try { signal.removeEventListener('abort', waiter.onAbort); } catch { /* ignore */ }
109
+ reject(signal.reason ?? _abortError());
110
+ };
111
+ try { signal.addEventListener('abort', waiter.onAbort, { once: true }); } catch { /* ignore */ }
112
+ }
113
+ _queue.push(waiter);
114
+ });
115
+ }
116
+
117
+ function _abortError() {
118
+ const e = new Error('child-spawn-gate: aborted while queued');
119
+ e.code = 'ABORT_ERR';
120
+ return e;
121
+ }
122
+
123
+ /**
124
+ * Run `fn` while holding one child-spawn slot. Release is guaranteed in a
125
+ * finally so a throw/return from `fn` cannot leak a slot or deadlock the gate.
126
+ *
127
+ * @template T
128
+ * @param {(args: { signal: AbortSignal | null }) => Promise<T> | T} fn
129
+ * @param {AbortSignal | null} [signal]
130
+ * @returns {Promise<T>}
131
+ */
132
+ export async function withGate(fn, signal = null) {
133
+ const release = await acquire(signal);
134
+ try {
135
+ return await fn({ signal: signal || null });
136
+ } finally {
137
+ release();
138
+ }
139
+ }