mixdog 0.8.0 → 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 (285) 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 +344 -0
  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 +1223 -95
  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 +2325 -450
  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 +118 -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 +77 -29
  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/cache/post-edit-marks.mjs +4 -4
  114. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +1 -1
  115. package/src/runtime/agent/orchestrator/session/compact.mjs +1173 -267
  116. package/src/runtime/agent/orchestrator/session/context-utils.mjs +199 -36
  117. package/src/runtime/agent/orchestrator/session/loop.mjs +851 -674
  118. package/src/runtime/agent/orchestrator/session/manager.mjs +1593 -466
  119. package/src/runtime/agent/orchestrator/session/manager.reactive-persist.test.mjs +107 -0
  120. package/src/runtime/agent/orchestrator/session/store.mjs +291 -46
  121. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  122. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -16
  123. package/src/runtime/agent/orchestrator/tool-loop-guard.mjs +3 -219
  124. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +34 -7
  125. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
  126. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +19 -0
  127. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +9 -9
  128. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +60 -37
  129. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +21 -2
  130. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +1 -1
  131. package/src/runtime/agent/orchestrator/tools/builtin/diagnostics-tool.mjs +0 -7
  132. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -3
  133. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +36 -12
  134. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -0
  135. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -2
  136. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +5 -12
  137. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +1 -1
  138. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +4 -36
  139. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +2 -40
  140. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +148 -27
  141. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +2 -2
  142. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +43 -75
  143. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +90 -20
  144. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +1 -1
  145. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -5
  146. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +86 -0
  147. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +11 -11
  148. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4106 -4019
  149. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +33 -4
  150. package/src/runtime/agent/orchestrator/tools/patch.mjs +90 -6
  151. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +6 -4
  152. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +4 -4
  153. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +8 -1
  154. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +4 -4
  155. package/src/runtime/channels/index.mjs +152 -24
  156. package/src/runtime/channels/lib/scheduler.mjs +18 -14
  157. package/src/runtime/channels/lib/session-discovery.mjs +3 -2
  158. package/src/runtime/channels/lib/tool-format.mjs +0 -2
  159. package/src/runtime/channels/lib/transcript-discovery.mjs +3 -2
  160. package/src/runtime/channels/lib/webhook.mjs +1 -1
  161. package/src/runtime/channels/tool-defs.mjs +29 -29
  162. package/src/runtime/memory/index.mjs +635 -107
  163. package/src/runtime/memory/lib/agent-ipc.mjs +29 -12
  164. package/src/runtime/memory/lib/core-memory-store.mjs +2 -2
  165. package/src/runtime/memory/lib/embedding-model-config.mjs +55 -0
  166. package/src/runtime/memory/lib/embedding-provider.mjs +31 -4
  167. package/src/runtime/memory/lib/embedding-worker.mjs +19 -10
  168. package/src/runtime/memory/lib/memory-cycle1.mjs +38 -17
  169. package/src/runtime/memory/lib/memory-cycle2.mjs +6 -7
  170. package/src/runtime/memory/lib/memory-cycle3.mjs +4 -4
  171. package/src/runtime/memory/lib/memory-ops-policy.mjs +2 -1
  172. package/src/runtime/memory/lib/memory-session-merge.mjs +38 -0
  173. package/src/runtime/memory/lib/memory.mjs +88 -9
  174. package/src/runtime/memory/lib/model-profile.mjs +1 -1
  175. package/src/runtime/memory/lib/pg/adapter.mjs +15 -1
  176. package/src/runtime/memory/lib/pg/supervisor.mjs +12 -0
  177. package/src/runtime/memory/lib/runtime-fetcher.mjs +37 -3
  178. package/src/runtime/memory/lib/session-ingest.mjs +194 -0
  179. package/src/runtime/memory/lib/trace-store.mjs +96 -51
  180. package/src/runtime/memory/tool-defs.mjs +46 -37
  181. package/src/runtime/search/index.mjs +102 -466
  182. package/src/runtime/search/lib/web-tools.mjs +45 -25
  183. package/src/runtime/search/tool-defs.mjs +16 -23
  184. package/src/runtime/shared/abort-controller.mjs +1 -1
  185. package/src/runtime/shared/atomic-file.mjs +4 -3
  186. package/src/runtime/shared/background-tasks.mjs +122 -11
  187. package/src/runtime/shared/child-spawn-gate.mjs +145 -0
  188. package/src/runtime/shared/config.mjs +7 -4
  189. package/src/runtime/shared/err-text.mjs +131 -4
  190. package/src/runtime/shared/llm/cost.mjs +2 -2
  191. package/src/runtime/shared/llm/http-agent.mjs +23 -7
  192. package/src/runtime/shared/llm/index.mjs +34 -11
  193. package/src/runtime/shared/llm/usage-log.mjs +4 -4
  194. package/src/runtime/shared/markdown-frontmatter.mjs +56 -0
  195. package/src/runtime/shared/singleton-owner.mjs +104 -0
  196. package/src/runtime/shared/tool-execution-contract.mjs +199 -20
  197. package/src/runtime/shared/tool-execution-contract.test.mjs +183 -0
  198. package/src/runtime/shared/tool-surface.mjs +624 -98
  199. package/src/runtime/shared/user-data-guard.mjs +0 -2
  200. package/src/standalone/agent-task-status.mjs +203 -0
  201. package/src/standalone/agent-task-status.test.mjs +76 -0
  202. package/src/standalone/agent-tool.mjs +1913 -0
  203. package/src/standalone/channel-worker.mjs +370 -14
  204. package/src/standalone/explore-tool.mjs +165 -70
  205. package/src/standalone/folder-dialog.mjs +314 -0
  206. package/src/standalone/hook-bus.mjs +898 -22
  207. package/src/standalone/memory-runtime-proxy.mjs +320 -0
  208. package/src/standalone/projects.mjs +226 -0
  209. package/src/standalone/provider-admin.mjs +41 -24
  210. package/src/standalone/seeds.mjs +2 -69
  211. package/src/standalone/usage-dashboard.mjs +96 -8
  212. package/src/tui/App.jsx +4798 -2153
  213. package/src/tui/components/AnsiText.jsx +39 -28
  214. package/src/tui/components/ContextPanel.jsx +87 -29
  215. package/src/tui/components/Markdown.jsx +43 -77
  216. package/src/tui/components/MarkdownTable.jsx +9 -184
  217. package/src/tui/components/Message.jsx +28 -11
  218. package/src/tui/components/Picker.jsx +95 -56
  219. package/src/tui/components/PromptInput.jsx +367 -239
  220. package/src/tui/components/QueuedCommands.jsx +1 -1
  221. package/src/tui/components/SlashCommandPalette.jsx +27 -21
  222. package/src/tui/components/Spinner.jsx +67 -38
  223. package/src/tui/components/StatusLine.jsx +606 -38
  224. package/src/tui/components/TextEntryPanel.jsx +128 -9
  225. package/src/tui/components/ToolExecution.jsx +617 -368
  226. package/src/tui/components/TurnDone.jsx +3 -3
  227. package/src/tui/components/UsagePanel.jsx +3 -5
  228. package/src/tui/components/tool-output-format.mjs +365 -0
  229. package/src/tui/components/tool-output-format.test.mjs +220 -0
  230. package/src/tui/dist/index.mjs +8915 -2418
  231. package/src/tui/engine-runtime-notification.test.mjs +115 -0
  232. package/src/tui/engine-tool-result-text.test.mjs +75 -0
  233. package/src/tui/engine.mjs +1455 -279
  234. package/src/tui/figures.mjs +21 -40
  235. package/src/tui/index.jsx +75 -31
  236. package/src/tui/input-editing.mjs +25 -0
  237. package/src/tui/markdown/format-token.mjs +511 -68
  238. package/src/tui/markdown/format-token.test.mjs +216 -0
  239. package/src/tui/markdown/render-ansi.mjs +94 -0
  240. package/src/tui/markdown/render-ansi.test.mjs +108 -0
  241. package/src/tui/markdown/stream-fence.mjs +34 -0
  242. package/src/tui/markdown/stream-fence.test.mjs +26 -0
  243. package/src/tui/markdown/table-layout.mjs +250 -0
  244. package/src/tui/paste-attachments.mjs +0 -7
  245. package/src/tui/spinner-verbs.mjs +1 -2
  246. package/src/tui/statusline-ansi-bridge.mjs +172 -0
  247. package/src/tui/statusline-ansi-bridge.test.mjs +159 -0
  248. package/src/tui/theme.mjs +746 -24
  249. package/src/tui/time-format.mjs +1 -1
  250. package/src/tui/transcript-tool-failures.mjs +67 -0
  251. package/src/tui/transcript-tool-failures.test.mjs +111 -0
  252. package/src/ui/ansi.mjs +1 -2
  253. package/src/ui/markdown.mjs +85 -26
  254. package/src/ui/markdown.test.mjs +70 -0
  255. package/src/ui/model-display.mjs +121 -0
  256. package/src/ui/session-stats.mjs +44 -0
  257. package/src/ui/statusline-context-label.test.mjs +15 -0
  258. package/src/ui/statusline.mjs +386 -178
  259. package/src/ui/tool-card.mjs +3 -16
  260. package/src/vendor/statusline/bin/statusline-lib.mjs +8 -4
  261. package/src/vendor/statusline/bin/statusline-route.mjs +169 -37
  262. package/src/vendor/statusline/bin/statusline-route.test.mjs +80 -0
  263. package/src/vendor/statusline/scripts/lib/gateway-settings.mjs +3 -3
  264. package/src/vendor/statusline/src/gateway/claude-current.mjs +1 -1
  265. package/src/vendor/statusline/src/gateway/route-meta.mjs +44 -6
  266. package/src/vendor/statusline/src/gateway/session-routes.mjs +1 -1
  267. package/src/workflows/default/WORKFLOW.md +12 -5
  268. package/src/workflows/default/workflow.json +0 -1
  269. package/src/workflows/solo/WORKFLOW.md +15 -0
  270. package/src/workflows/solo/workflow.json +7 -0
  271. package/vendor/ink/build/output.js +6 -1
  272. package/src/agents/scheduler-task.md +0 -3
  273. package/src/agents/web-researcher/AGENT.md +0 -3
  274. package/src/agents/web-researcher/agent.json +0 -6
  275. package/src/agents/webhook-handler.md +0 -3
  276. package/src/rules/bridge/00-common.md +0 -5
  277. package/src/rules/bridge/30-explorer.md +0 -4
  278. package/src/rules/lead/00-tool-lead.md +0 -5
  279. package/src/rules/shared/00-language.md +0 -3
  280. package/src/runtime/agent/orchestrator/tools/builtin/native-edit-runner.mjs +0 -110
  281. package/src/runtime/agent/orchestrator/tools/mutation-content-cache.mjs +0 -67
  282. package/src/runtime/memory/lib/bridge-trace-queries.mjs +0 -120
  283. package/src/runtime/shared/llm/pid-cleanup.mjs +0 -27
  284. package/src/standalone/bridge-tool.mjs +0 -1414
  285. package/src/tui/runtime/shared/process-shutdown.mjs +0 -1
@@ -3,12 +3,27 @@ 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
+ }
26
+
12
27
 
13
28
  const PKG_VERSION = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version } catch { return '0.0.1' } })()
14
29
  import {
@@ -491,7 +506,8 @@ function classifyJavascriptRenderingPlaceholder(page) {
491
506
  return null
492
507
  }
493
508
 
494
- function extractReadableArticle(url, html) {
509
+ async function extractReadableArticle(url, html) {
510
+ const JSDOM = await loadJSDOM()
495
511
  const dom = new JSDOM(html, { url })
496
512
  try {
497
513
  const doc = dom.window.document
@@ -701,11 +717,11 @@ async function fetchPinnedForPausedRequest(url, { signal, method = 'GET', header
701
717
  currentUrl = new URL(location, currentUrl).toString()
702
718
  continue
703
719
  }
704
- const body = await readBodyBytesWithCap(response, MAX_BODY_BYTES)
720
+ const respBody = await readBodyBytesWithCap(response, MAX_BODY_BYTES)
705
721
  return {
706
722
  status: response.status,
707
723
  responseHeaders: headersToCdpPairs(response.headers),
708
- body,
724
+ body: respBody,
709
725
  }
710
726
  }
711
727
  }
@@ -812,7 +828,7 @@ async function scrapeWithReadability(url, timeoutMs, signal) {
812
828
  currentUrl = target
813
829
  html = await fetchHtml(currentUrl, timeoutMs, signal)
814
830
  }
815
- return extractReadableArticle(currentUrl, html)
831
+ return await extractReadableArticle(currentUrl, html)
816
832
  }
817
833
 
818
834
  function resolveBrowserLaunchOptions() {
@@ -888,23 +904,26 @@ async function _getPoolBrowser() {
888
904
  }
889
905
  if (_poolBrowser) return _poolBrowser
890
906
  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
907
+ _poolLaunching = loadPuppeteer()
908
+ .then((puppeteer) => puppeteer.launch({
909
+ headless: true,
910
+ ...resolveBrowserLaunchOptions(),
911
+ args: buildPuppeteerLaunchArgs(),
912
+ }))
913
+ .then((browser) => {
914
+ _poolBrowser = browser
915
+ try {
916
+ const proc = browser.process?.()
917
+ startChildGuardian({ childPid: proc?.pid, label: 'puppeteer-browser' })
918
+ } catch {}
919
+ browser.on('disconnected', () => {
920
+ if (_poolBrowser === browser) _poolBrowser = null
921
+ })
922
+ return browser
923
+ })
924
+ .finally(() => {
925
+ _poolLaunching = null
903
926
  })
904
- return browser
905
- }).finally(() => {
906
- _poolLaunching = null
907
- })
908
927
  }
909
928
  return _poolLaunching
910
929
  }
@@ -1033,7 +1052,7 @@ async function scrapeWithPuppeteer(url, timeoutMs, signal) {
1033
1052
  }
1034
1053
  try {
1035
1054
  return {
1036
- ...extractReadableArticle(finalUrl, html),
1055
+ ...(await extractReadableArticle(finalUrl, html)),
1037
1056
  extractor: 'puppeteer',
1038
1057
  }
1039
1058
  } catch {
@@ -1088,7 +1107,8 @@ function filterLinks(rawLinks, baseUrl, { limit = 50, sameDomainOnly = true, sea
1088
1107
  return items
1089
1108
  }
1090
1109
 
1091
- function extractLinksFromHtml(baseUrl, html, options) {
1110
+ async function extractLinksFromHtml(baseUrl, html, options) {
1111
+ const JSDOM = await loadJSDOM()
1092
1112
  const dom = new JSDOM(html, { url: baseUrl })
1093
1113
  try {
1094
1114
  const links = Array.from(dom.window.document.querySelectorAll('a[href]')).map(link => ({
@@ -1103,7 +1123,7 @@ function extractLinksFromHtml(baseUrl, html, options) {
1103
1123
 
1104
1124
  async function mapWithHttp(url, options, timeoutMs, signal) {
1105
1125
  const html = await fetchHtml(url, timeoutMs, signal)
1106
- return extractLinksFromHtml(url, html, options)
1126
+ return await extractLinksFromHtml(url, html, options)
1107
1127
  }
1108
1128
 
1109
1129
  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,145 @@
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
+ }
140
+
141
+ export function gateStats() {
142
+ return { inflight: _inflight, queued: _queue.length, maxInflight: MAX_INFLIGHT };
143
+ }
144
+
145
+ export const CHILD_SPAWN_MAX_INFLIGHT = MAX_INFLIGHT;
@@ -15,7 +15,7 @@ import {
15
15
  } from './user-data-guard.mjs'
16
16
 
17
17
  const _require = createRequire(import.meta.url)
18
- const { getSecret: _getSecret, setSecret: _setSecret, deleteSecret: _deleteSecret } = _require('../../lib/keychain-cjs.cjs')
18
+ const { getSecret: _getSecret, setSecret: _setSecret, deleteSecret: _deleteSecret, hasSecret: _hasSecret } = _require('../../lib/keychain-cjs.cjs')
19
19
 
20
20
  const DATA_DIR = resolvePluginData()
21
21
 
@@ -93,7 +93,7 @@ function readAll() {
93
93
  // failed write), not a fresh install. Self-heal from the newest
94
94
  // structurally-complete backup instead of silently collapsing to {} —
95
95
  // which callers (readSection/getCapabilities) merge with in-memory
96
- // defaults, dropping the user's presets/roles/sections. In-memory only:
96
+ // defaults, dropping the user's presets/agent routes/sections. In-memory only:
97
97
  // readAll() runs OUTSIDE the config lock (unlike the RMW path), so we do
98
98
  // not write here to avoid an unlocked-write race; the next writeSection
99
99
  // re-persists the file under the lock.
@@ -220,7 +220,7 @@ export function updateSection(section, updater) {
220
220
  // false (default), file tools are cwd-scoped — matches the setup UI's
221
221
  // out-of-the-box "OFF" toggle so a fresh install is restrictive until the
222
222
  // user explicitly opts in. This ONLY controls the main-agent path gate —
223
- // bridge role Edit/Write to HOME paths always go through Discord approval
223
+ // agent role Edit/Write to HOME paths always go through Discord approval
224
224
  // regardless (enforced in hooks/pre-tool-subagent.cjs).
225
225
  const CAPABILITY_DEFAULTS = Object.freeze({ homeAccess: false })
226
226
 
@@ -387,7 +387,10 @@ export function deleteSecret(account) {
387
387
  * browser.
388
388
  */
389
389
  export function hasStoredSecret(account) {
390
- try { return !!_getSecret(account) } catch { return false }
390
+ try {
391
+ if (typeof _hasSecret === 'function') return !!_hasSecret(account)
392
+ return !!_getSecret(account)
393
+ } catch { return false }
391
394
  }
392
395
 
393
396
  export { CONFIG_PATH }