mixdog 0.9.147 → 0.9.148

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 (255) hide show
  1. package/package.json +23 -3
  2. package/scripts/build-voice-runtime-unix.sh +101 -0
  3. package/scripts/embedding-quality-probe.mjs +96 -0
  4. package/scripts/generate-voice-runtime-manifest.mjs +300 -0
  5. package/scripts/generate-voice-runtime-manifest.test.mjs +135 -0
  6. package/scripts/native-binary-arch.mjs +30 -13
  7. package/scripts/native-binary-arch.test.mjs +40 -4
  8. package/scripts/plan-voice-runtime-release.mjs +73 -0
  9. package/scripts/prepare-native-assets.mjs +50 -9
  10. package/scripts/prune-embedding-runtime.mjs +3 -3
  11. package/scripts/sync-voice-runtime-manifest.mjs +46 -0
  12. package/scripts/verify-release-assets.mjs +106 -6
  13. package/scripts/verify-voice-runtime-release.mjs +89 -0
  14. package/scripts/voice-runtime-config.json +52 -0
  15. package/src/app.mjs +42 -5
  16. package/src/headless-exec.mjs +58 -32
  17. package/src/headless-exec.test.mjs +74 -13
  18. package/src/rules/lead/01-general.md +2 -0
  19. package/src/rules/shared/10-tool-workflow.md +7 -4
  20. package/src/rules/shared/30-exploration.md +2 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +28 -9
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +6 -1
  23. package/src/runtime/agent/orchestrator/config.mjs +53 -18
  24. package/src/runtime/agent/orchestrator/mcp/cancellation.test.mjs +139 -0
  25. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +34 -5
  26. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -10
  27. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +5 -0
  28. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.mjs +98 -0
  29. package/src/runtime/agent/orchestrator/providers/anthropic-fast-mode.test.mjs +90 -0
  30. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +67 -16
  31. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +4 -82
  32. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +0 -5
  33. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -121
  34. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.mjs +48 -0
  35. package/src/runtime/agent/orchestrator/providers/anthropic-server-fallback.test.mjs +215 -0
  36. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +44 -48
  37. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +54 -31
  38. package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +56 -2
  39. package/src/runtime/agent/orchestrator/providers/cursor-auth.mjs +34 -2
  40. package/src/runtime/agent/orchestrator/providers/cursor-wire.mjs +61 -1
  41. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -1
  42. package/src/runtime/agent/orchestrator/providers/gemini.mjs +61 -15
  43. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +120 -1
  44. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +9 -0
  45. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +0 -3
  46. package/src/runtime/agent/orchestrator/providers/oauth-credential-probes.mjs +187 -84
  47. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +36 -36
  48. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +16 -6
  49. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +50 -213
  50. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +135 -126
  51. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +17 -11
  52. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +20 -7
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +11 -0
  54. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +132 -42
  55. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +28 -10
  57. package/src/runtime/agent/orchestrator/providers/registry.mjs +122 -95
  58. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +58 -3
  59. package/src/runtime/agent/orchestrator/providers/tool-stream-state.mjs +4 -22
  60. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +7 -14
  61. package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +128 -0
  62. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +27 -48
  64. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +19 -14
  65. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -43
  66. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +5 -1
  67. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +15 -2
  68. package/src/runtime/agent/orchestrator/session/manager/pending-messages-persistence.test.mjs +325 -0
  69. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +599 -195
  70. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +22 -0
  71. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +9 -2
  72. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  73. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +26 -16
  74. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +23 -11
  75. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +118 -3
  76. package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +332 -0
  77. package/src/runtime/agent/orchestrator/session/store.mjs +38 -1
  78. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +27 -6
  79. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +12 -20
  80. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +291 -128
  81. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +45 -8
  82. package/src/runtime/agent/orchestrator/tools/builtin/edit-encoding-eol.test.mjs +224 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/edit-sequential-occupation.test.mjs +53 -3
  84. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +255 -25
  85. package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +87 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +1 -1
  87. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +132 -9
  88. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-pattern-fanout.mjs +29 -3
  90. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +1 -1
  91. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +178 -29
  92. package/src/runtime/agent/orchestrator/tools/builtin/local-search-telemetry.mjs +4 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +32 -5
  94. package/src/runtime/agent/orchestrator/tools/builtin/native-search-health.test.mjs +5 -2
  95. package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +10 -1
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +38 -10
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +12 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +31 -1
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +11 -1
  100. package/src/runtime/agent/orchestrator/tools/builtin/read-windows.mjs +22 -4
  101. package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +6 -8
  102. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +25 -24
  103. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +152 -133
  104. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +114 -42
  105. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +129 -15
  106. package/src/runtime/agent/orchestrator/tools/code-graph/loose-file-target.test.mjs +72 -0
  107. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +7 -8
  108. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +4 -4
  109. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +201 -19
  110. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +26 -3
  111. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +10 -1
  112. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-search-scope.test.mjs +55 -0
  113. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +115 -25
  114. package/src/runtime/agent/orchestrator/tools/code-graph/text-span-mask.test.mjs +268 -0
  115. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -3
  116. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +296 -46
  117. package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.test.mjs +104 -0
  118. package/src/runtime/agent/orchestrator/tools/lib/shell-descendants.mjs +136 -0
  119. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +127 -38
  120. package/src/runtime/agent/orchestrator/tools/patch/engine-contract-gate.test.mjs +661 -0
  121. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +350 -9
  122. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +418 -44
  123. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -3
  124. package/src/runtime/agent/orchestrator/tools/patch/patch-codec-eol.test.mjs +552 -0
  125. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +65 -15
  126. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
  127. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +284 -109
  128. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +51 -21
  129. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +44 -27
  130. package/src/runtime/agent/orchestrator/tools/shell-policy.mjs +6 -1
  131. package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
  132. package/src/runtime/attachments/store.mjs +11 -3
  133. package/src/runtime/channels/data/voice-runtime-manifest.json +46 -70
  134. package/src/runtime/channels/lib/event-queue.mjs +51 -41
  135. package/src/runtime/channels/lib/scheduler.mjs +23 -28
  136. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +6 -17
  137. package/src/runtime/channels/lib/voice-runtime-fetcher.test.mjs +17 -0
  138. package/src/runtime/channels/lib/webhook.mjs +39 -20
  139. package/src/runtime/channels/lib/whisper-server.mjs +46 -1
  140. package/src/runtime/channels/lib/worker-main.mjs +3 -1
  141. package/src/runtime/media/adapters/gemini-video.mjs +20 -4
  142. package/src/runtime/media/jobs.mjs +13 -0
  143. package/src/runtime/media/renditions.mjs +7 -1
  144. package/src/runtime/media/store.mjs +42 -22
  145. package/src/runtime/memory/index.mjs +4 -9
  146. package/src/runtime/memory/lib/agent-ipc.mjs +1 -6
  147. package/src/runtime/memory/lib/archive-security.test.mjs +21 -1
  148. package/src/runtime/memory/lib/core-memory-store.mjs +28 -50
  149. package/src/runtime/memory/lib/embedding-model-config.mjs +37 -1
  150. package/src/runtime/memory/lib/embedding-model-config.test.mjs +33 -0
  151. package/src/runtime/memory/lib/embedding-provider.mjs +34 -12
  152. package/src/runtime/memory/lib/embedding-worker.mjs +37 -5
  153. package/src/runtime/memory/lib/ko-morph.mjs +46 -15
  154. package/src/runtime/memory/lib/memory-action-handlers.mjs +18 -1
  155. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -18
  156. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -18
  157. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +98 -45
  158. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +71 -0
  159. package/src/runtime/memory/lib/memory-cycle2-shared.test.mjs +78 -0
  160. package/src/runtime/memory/lib/memory-cycle2.mjs +37 -3
  161. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  162. package/src/runtime/memory/lib/memory-embedding-migration.test.mjs +50 -0
  163. package/src/runtime/memory/lib/memory-recall-store.mjs +9 -24
  164. package/src/runtime/memory/lib/memory.mjs +50 -12
  165. package/src/runtime/memory/lib/pg/process.mjs +1 -10
  166. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -9
  167. package/src/runtime/memory/lib/query-handlers.mjs +2 -4
  168. package/src/runtime/memory/lib/session-ingest-runtime.mjs +2 -2
  169. package/src/runtime/shared/background-tasks.mjs +15 -0
  170. package/src/runtime/shared/background-tasks.test.mjs +78 -0
  171. package/src/runtime/shared/child-guardian.mjs +2 -10
  172. package/src/runtime/shared/pid-liveness.mjs +27 -0
  173. package/src/runtime/shared/runtime-root.mjs +1 -11
  174. package/src/runtime/shared/service-discovery.mjs +1 -17
  175. package/src/runtime/shared/singleton-owner.mjs +1 -11
  176. package/src/runtime/shared/staged-update.mjs +2 -12
  177. package/src/runtime/shared/tool-status.mjs +5 -1
  178. package/src/runtime/shared/tool-surface.mjs +15 -7
  179. package/src/runtime/shared/turn-snapshot-store.mjs +168 -0
  180. package/src/runtime/shared/turn-snapshot.mjs +137 -13
  181. package/src/runtime/shared/turn-worktree-snapshot.mjs +110 -18
  182. package/src/runtime/shared/webhook-session-run.mjs +16 -2
  183. package/src/runtime/web-search/lib/http-fetch.mjs +34 -4
  184. package/src/runtime/web-search/lib/state.mjs +15 -15
  185. package/src/runtime/web-search/lib/web-tools.mjs +46 -5
  186. package/src/session-runtime/context-status.mjs +4 -12
  187. package/src/session-runtime/runtime-core.mjs +20 -1
  188. package/src/standalone/agent-dispatch-broker.mjs +33 -2
  189. package/src/standalone/agent-tool/job-task-reconcile.mjs +78 -0
  190. package/src/standalone/agent-tool/notify.mjs +3 -1
  191. package/src/standalone/agent-tool/spawn-flow.mjs +48 -66
  192. package/src/standalone/agent-tool/tag-registry.mjs +116 -33
  193. package/src/standalone/agent-tool/tool-def.mjs +2 -2
  194. package/src/standalone/agent-tool/worker-index.mjs +8 -11
  195. package/src/standalone/agent-tool/worker-rows.mjs +4 -1
  196. package/src/standalone/agent-tool.mjs +20 -55
  197. package/src/standalone/agent-watchdog-registry.mjs +28 -4
  198. package/src/standalone/channel-client.mjs +47 -15
  199. package/src/standalone/channel-transport.mjs +44 -25
  200. package/src/standalone/daemon.mjs +31 -2
  201. package/src/standalone/hook-bus/config.mjs +25 -9
  202. package/src/standalone/hook-bus/handlers.mjs +16 -52
  203. package/src/standalone/hook-bus.mjs +29 -23
  204. package/src/standalone/memory-runtime-proxy.mjs +1 -16
  205. package/src/standalone/session-client.mjs +86 -23
  206. package/src/standalone/session-protocol.mjs +0 -2
  207. package/src/standalone/session-runtime-host-health.test.mjs +5 -4
  208. package/src/standalone/session-runtime-host.mjs +101 -6
  209. package/src/standalone/session-runtime-record.mjs +1 -5
  210. package/src/standalone/session-runtime-worker.mjs +52 -57
  211. package/src/standalone/session-service.mjs +4 -1
  212. package/src/standalone/session-transport.mjs +56 -25
  213. package/src/tui/App.jsx +40 -49
  214. package/src/tui/app/app-view.jsx +14 -21
  215. package/src/tui/app/core-memory-picker.mjs +29 -16
  216. package/src/tui/app/create-app-pickers.mjs +15 -36
  217. package/src/tui/app/extension-pickers.mjs +60 -49
  218. package/src/tui/app/maintenance-pickers.mjs +100 -57
  219. package/src/tui/app/message-selector.mjs +6 -5
  220. package/src/tui/app/model-picker.mjs +30 -13
  221. package/src/tui/app/onboarding-steps.mjs +21 -12
  222. package/src/tui/app/panel-epoch.mjs +56 -0
  223. package/src/tui/app/panel-epoch.test.mjs +1009 -0
  224. package/src/tui/app/panel-surface.mjs +98 -0
  225. package/src/tui/app/project-picker.mjs +30 -29
  226. package/src/tui/app/prompt-submit.mjs +192 -97
  227. package/src/tui/app/prompt-submit.test.mjs +206 -0
  228. package/src/tui/app/provider-setup-picker.mjs +74 -43
  229. package/src/tui/app/resume-picker.mjs +11 -8
  230. package/src/tui/app/route-pickers.mjs +45 -28
  231. package/src/tui/app/settings-picker.mjs +69 -18
  232. package/src/tui/app/shell-layout.mjs +1 -3
  233. package/src/tui/app/slash-commands.mjs +0 -2
  234. package/src/tui/app/slash-dispatch.mjs +4 -2
  235. package/src/tui/app/text-entry-policy.mjs +25 -0
  236. package/src/tui/app/theme-effort-pickers.mjs +11 -16
  237. package/src/tui/app/usage-context-panels.mjs +21 -25
  238. package/src/tui/app/use-global-key-input.mjs +3 -2
  239. package/src/tui/app/use-prompt-draft-flow.mjs +6 -18
  240. package/src/tui/app/use-prompt-handlers.mjs +3 -2
  241. package/src/tui/app/use-welcome-prompt-hint.mjs +17 -3
  242. package/src/tui/components/Picker.jsx +1 -1
  243. package/src/tui/components/TextEntryPanel.jsx +7 -2
  244. package/src/tui/dist/index.mjs +652 -561
  245. package/src/tui/lib/voice-setup.mjs +1 -2
  246. package/src/tui/session/agent-job-feed.mjs +73 -11
  247. package/src/tui/session/session-api-ext.mjs +5 -6
  248. package/src/tui/session/session-api.mjs +1 -0
  249. package/src/tui/session/tool-approval.mjs +15 -5
  250. package/src/tui/session/tui-steering-persist.mjs +33 -19
  251. package/src/tui/session/tui-steering-persist.test.mjs +104 -0
  252. package/src/tui/session-local.mjs +0 -22
  253. package/scripts/.tmp-coverage-report.mjs +0 -96
  254. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.mjs +0 -57
  255. package/src/runtime/agent/orchestrator/tools/builtin/shell-monitor.test.mjs +0 -57
@@ -5,10 +5,28 @@ import { join } from 'node:path';
5
5
  import test from 'node:test';
6
6
 
7
7
  import { classifyCliInvocation } from './headless-command.mjs';
8
- import { runHeadlessExec } from './headless-exec.mjs';
8
+ import { prewarmHeadlessSearch, runHeadlessExec } from './headless-exec.mjs';
9
9
  import { resolveCursorOAuthAccessToken } from './runtime/agent/orchestrator/providers/cursor-auth.mjs';
10
10
  import { createPristineExecutionBoundary } from './runtime/shared/pristine-execution.mjs';
11
11
 
12
+ test('headless search prewarm starts the native server and Project inventory together', async () => {
13
+ const calls = [];
14
+ await prewarmHeadlessSearch('/app/project', {
15
+ loadNativeSearch: async () => ({
16
+ async warmNativeSearchServer() {
17
+ calls.push('server');
18
+ return true;
19
+ },
20
+ }),
21
+ loadListTool: async () => ({
22
+ async prewarmFindEnumeration(root) {
23
+ calls.push(`index:${root}`);
24
+ },
25
+ }),
26
+ });
27
+ assert.deepEqual(calls.sort(), ['index:/app/project', 'server']);
28
+ });
29
+
12
30
  test('pristine headless execution binds Cursor OAuth credentials in process', async () => {
13
31
  const root = mkdtempSync(join(tmpdir(), 'mixdog-headless-cursor-auth-test-'));
14
32
  const dataDir = join(root, 'data');
@@ -71,6 +89,7 @@ test('headless exec runs one implicit-approval session and waits for tracked tas
71
89
  clientHostPid: 123,
72
90
  ask: async (_prompt, optionsForAsk) => {
73
91
  optionsForAsk.onUsageDelta({
92
+ model: 'gpt-fallback',
74
93
  deltaInput: 11,
75
94
  deltaCachedRead: 7,
76
95
  deltaCacheWrite: 3,
@@ -110,6 +129,7 @@ test('headless exec runs one implicit-approval session and waits for tracked tas
110
129
  assert.equal(runtimeClosed, true);
111
130
  assert.deepEqual(cleanupOrder, ['runtime', 'memory', 'boundary']);
112
131
  const usage = JSON.parse(readFileSync(usageLogPath, 'utf8'));
132
+ assert.deepEqual(usage.sessions[0].models, ['gpt-test', 'gpt-fallback']);
113
133
  assert.deepEqual(usage.totals, {
114
134
  inputTokens: 11,
115
135
  cacheTokens: 7,
@@ -122,18 +142,17 @@ test('headless exec runs one implicit-approval session and waits for tracked tas
122
142
  }
123
143
  });
124
144
 
125
- test('headless exec drains tracked work and returns the completion follow-up answer', async () => {
145
+ test('headless exec answers an arrived completion and exits without waiting on live work', async () => {
126
146
  const output = [];
127
147
  const errors = [];
128
148
  const prompts = [];
129
- let active = true;
149
+ let closeOptions = null;
130
150
  let notificationListener = null;
131
151
  const code = await runHeadlessExec({
132
152
  message: 'start the long task',
133
153
  provider: 'openai-oauth',
134
154
  model: 'gpt-test',
135
155
  usageLogPath: '',
136
- idlePollMs: 1,
137
156
  write: (text) => output.push(text),
138
157
  writeErr: (text) => errors.push(text),
139
158
  boundaryFactory: () => ({
@@ -151,20 +170,20 @@ test('headless exec drains tracked work and returns the completion follow-up ans
151
170
  async ask(prompt) {
152
171
  prompts.push(prompt);
153
172
  if (prompts.length === 1) {
154
- setTimeout(() => {
155
- active = false;
156
- notificationListener?.({
157
- content: 'background task completed',
158
- meta: { status: 'completed', execution_id: 'job_drain' },
159
- });
160
- }, 10);
173
+ notificationListener?.({
174
+ content: 'background task completed',
175
+ meta: { status: 'completed', execution_id: 'job_drain' },
176
+ });
161
177
  return { result: { content: 'task started' } };
162
178
  }
163
179
  return { result: { content: 'final result' } };
164
180
  },
165
- async close() {},
181
+ async close(reason, options) { closeOptions = options ?? null; },
166
182
  }),
167
- hasActiveTasks: () => active,
183
+ // The job the model left running never ends — a server it was asked to
184
+ // keep up. Exit must not wait for it, and must not reap it either: this
185
+ // test hangs on a regression instead of failing slowly.
186
+ hasActiveTasks: () => true,
168
187
  installSignalCleanupFn: () => ({ uninstall() {} }),
169
188
  });
170
189
 
@@ -172,6 +191,7 @@ test('headless exec drains tracked work and returns the completion follow-up ans
172
191
  assert.deepEqual(errors, []);
173
192
  assert.deepEqual(prompts, ['start the long task', '']);
174
193
  assert.deepEqual(output, ['final result\n']);
194
+ assert.deepEqual(closeOptions, { keepBackgroundWork: true });
175
195
  });
176
196
 
177
197
  test('headless exec emits a timestamped JSONL lifecycle and exact tool count', async () => {
@@ -351,6 +371,47 @@ test('headless exec emits structured JSONL failure before returning exit 1', asy
351
371
  assert.deepEqual(errors, ['mixdog: boom\n']);
352
372
  });
353
373
 
374
+ test('headless exec exposes typed refusal as an API error result', async () => {
375
+ const output = [];
376
+ const code = await runHeadlessExec({
377
+ message: 'request',
378
+ provider: 'anthropic-oauth',
379
+ model: 'claude-test',
380
+ json: true,
381
+ usageLogPath: '',
382
+ write: (text) => output.push(text),
383
+ writeErr() {},
384
+ boundaryFactory: () => ({
385
+ loadConfig: () => ({ providers: { 'anthropic-oauth': { enabled: true } } }),
386
+ cleanup() {},
387
+ }),
388
+ runtimeFactory: async () => ({
389
+ id: 'sess_json_refusal',
390
+ model: 'claude-test',
391
+ clientHostPid: 123,
392
+ async ask() {
393
+ return {
394
+ result: {
395
+ content: 'Unable to respond.',
396
+ stopReason: 'refusal',
397
+ terminationReason: 'refusal',
398
+ },
399
+ };
400
+ },
401
+ async close() {},
402
+ }),
403
+ hasActiveTasks: () => false,
404
+ installSignalCleanupFn: () => ({ uninstall() {} }),
405
+ });
406
+
407
+ const terminal = output.join('').trim().split('\n').map((line) => JSON.parse(line)).at(-1);
408
+ assert.equal(code, 0);
409
+ assert.equal(terminal.subtype, 'success');
410
+ assert.equal(terminal.is_error, true);
411
+ assert.equal(terminal.stop_reason, 'refusal');
412
+ assert.equal(terminal.termination_reason, 'refusal');
413
+ });
414
+
354
415
  test('--json is accepted for exec and rejected for the interactive command', () => {
355
416
  const exec = classifyCliInvocation([
356
417
  'exec',
@@ -10,6 +10,8 @@
10
10
  recoverability.
11
11
  - Mid-task: replacement supersedes; addition folds in; status gets a brief
12
12
  answer while work continues. After compaction, resume the summary.
13
+ - Periodic task reports stay in-turn: after each update, call `task wait` for
14
+ the requested interval again until the task settles or the request changes.
13
15
  - Auto-compact owns context management: never propose stopping work to the
14
16
  user and never stop on your own judgment; resume and continue the work
15
17
  after every compaction.
@@ -13,9 +13,10 @@
13
13
  apply one analysis to many targets as one parameterized call when supported.
14
14
  - Route each evidence facet once to its primary owner, preferring the operation
15
15
  that directly returns the evidence needed for the next decision. A summary,
16
- overview, or enumeration is not a prerequisite when that operation's complete
17
- inputs are already known; if independently required, batch it with the
18
- detailed operation.
16
+ overview, or enumeration is not a prerequisite to an operation whose complete
17
+ inputs are already known, and is itself that operation when structure is the
18
+ evidence sought; if independently required, batch it with the detailed
19
+ operation.
19
20
  - Known state — system guarantees, supplied facts, visible tool returns,
20
21
  applied patches, and passed checks — is never re-found, re-derived, or
21
22
  re-verified at any granularity: no re-query call, no confirmation subcommand
@@ -27,7 +28,9 @@
27
28
  necessary.
28
29
  - Evidence that determines the answer, edit, or deliverable ends retrieval.
29
30
  - Treat failure as new evidence and re-enter that loop only for the affected
30
- facets. Report a blocker when no deterministic next action remains.
31
+ facets. Do not abandon a viable approach after one failure or leave the
32
+ required deliverable half-finished. Report a blocker when no deterministic
33
+ next action remains.
31
34
  - Use only named tools present in the current tool surface; deferred tools
32
35
  auto-load on a direct call, and when their exact arguments are not visible,
33
36
  call `load_tool` first and read the surfaced schema.
@@ -6,7 +6,8 @@
6
6
  a successful owner result closes that facet.
7
7
  - Route the missing evidence to its primary owner:
8
8
  repository state, history, or diff→`git`;
9
- exact symbol declaration, body, usage, or relation→`code_graph`;
9
+ source-file structure or outline, and exact symbol declaration, body,
10
+ usage, or relation→`code_graph`;
10
11
  literal, regex, or text location→`grep`;
11
12
  known-file content, range, or image→`read`;
12
13
  wildcard or recursive file paths→`glob`;
@@ -412,19 +412,38 @@ export function resolveProviderPromptCacheLane(provider, opts = {}, config = {})
412
412
  || promptCacheLaneAutoRequested(opts?.promptCacheLane?.auto)
413
413
  || promptCacheLaneAutoRequested(config?.promptCacheLaneAuto)
414
414
  || promptCacheLaneAutoRequested(config?.openaiCacheLaneAuto);
415
- const rawLimit = requestedAuto
416
- ? 'auto'
417
- : (opts?.promptCacheLaneShards
418
- ?? opts?.promptCacheLane?.shards
419
- ?? opts?.openaiCacheLaneShards
415
+ // Lane SHARDS, requested either by their own name (`*CacheLaneShards`) or
416
+ // through the legacy `*CacheMaxParallel` / MIXDOG_*_CACHE_MAX_PARALLEL
417
+ // aliases. Those knobs also bounded in-flight admission for the removed
418
+ // compat cache lane — that half is gone for good, admission now belongs
419
+ // solely to the provider/account scheduler — but on OpenAI direct/OAuth
420
+ // they have always selected the prompt-cache shard count as well, and
421
+ // existing configurations depend on it. They stay accepted here at LOWER
422
+ // precedence than the explicitly named shard settings.
423
+ // Exception: xAI compatibility routing passes promptCacheLaneIgnoreAliases
424
+ // so the alias keeps meaning nothing there (openai-compat-xai.mjs), where
425
+ // it never selected shards and would silently fan out cache keys.
426
+ const ignoreAliases = opts?.promptCacheLaneIgnoreAliases === true
427
+ || config?.promptCacheLaneIgnoreAliases === true;
428
+ const rawShards = opts?.promptCacheLaneShards
429
+ ?? opts?.promptCacheLane?.shards
430
+ ?? opts?.openaiCacheLaneShards
431
+ ?? config?.promptCacheLaneShards
432
+ ?? config?.openaiCacheLaneShards
433
+ ?? env[`MIXDOG_${envKey}_CACHE_LANE_SHARDS`]
434
+ ?? env.MIXDOG_OPENAI_CACHE_LANE_SHARDS;
435
+ const rawAlias = ignoreAliases
436
+ ? undefined
437
+ : (opts?.promptCacheLaneMaxParallel
438
+ ?? opts?.promptCacheLane?.maxParallel
420
439
  ?? opts?.openaiCacheMaxParallel
421
- ?? config?.promptCacheLaneShards
422
- ?? config?.openaiCacheLaneShards
440
+ ?? config?.promptCacheLaneMaxParallel
423
441
  ?? config?.openaiCacheMaxParallel
424
- ?? env[`MIXDOG_${envKey}_CACHE_LANE_SHARDS`]
425
442
  ?? env[`MIXDOG_${envKey}_CACHE_MAX_PARALLEL`]
426
- ?? env.MIXDOG_OPENAI_CACHE_LANE_SHARDS
427
443
  ?? env.MIXDOG_OPENAI_CACHE_MAX_PARALLEL);
444
+ const rawLimit = requestedAuto
445
+ ? 'auto'
446
+ : (rawShards ?? rawAlias);
428
447
  const shards = parsePromptCacheLaneLimit(rawLimit, defaultPromptCacheLaneShards(provider));
429
448
  const auto = shards <= 0;
430
449
  const seed = cleanString(
@@ -347,7 +347,7 @@ function classifyToolFailure(resultText, toolName) {
347
347
  if (/^\[shell-tool-failed\](?:\s|$)/i.test(leading)) return 'tool-call/failure';
348
348
  if (/^\[shell-run-failed\](?:\s|$)/i.test(leading)) {
349
349
  if (/\[timeout:|cause:\s*(?:timeout|cancellation)\b/i.test(leading)) return 'timeout/abort';
350
- if (/cause:\s*(?:output-limit|output-capture-error|background-adoption-failed)\b/i.test(leading)) return 'runtime/failure';
350
+ if (/cause:\s*(?:output-limit|output-capture-error|background-promotion-failed)\b/i.test(leading)) return 'runtime/failure';
351
351
  if (/\[signal:\s*[^\]]+/i.test(leading)) return 'process/signal';
352
352
  return 'command-exit';
353
353
  }
@@ -377,6 +377,11 @@ function classifyToolFailure(resultText, toolName) {
377
377
  if (/not in allow-list|not allowed/.test(text)) return 'permission';
378
378
  if (String(toolName || '') === 'shell' || /^\s*\[exit code:\s*\d+\]/i.test(raw)) return 'command-exit';
379
379
  if (isReadOnlyNavigationMiss(toolName, raw)) return 'navigation/miss';
380
+ // An absent executable is a fact about the environment, not a defect in
381
+ // the tool that reported it: the caller installs it or takes another
382
+ // route. Filed under runtime/failure it padded the count that is supposed
383
+ // to surface real tool bugs.
384
+ if (/executable not found in this environment/.test(text)) return 'environment/unavailable';
380
385
  if (/enoent|cannot find|not found at this path|path does not exist|no such file|file not found in graph|unreadable/.test(text)) return 'path/enoent';
381
386
  if (/timed out|timeout|interrupted|aborted/.test(text)) return 'timeout/abort';
382
387
  if (/unknown tool|tool.*not.*available|missing.*tool/.test(text)) return 'tool-surface';
@@ -5,13 +5,7 @@ import {
5
5
  canonicalizeAgentRouteStorage,
6
6
  } from '../../shared/agent-route-config.mjs';
7
7
  import { OPENAI_COMPAT_PRESETS } from './providers/openai-compat-presets.mjs';
8
- import {
9
- hasAnthropicOAuthCredentials,
10
- hasOpenAIOAuthCredentials,
11
- hasGrokOAuthCredentials,
12
- hasCursorOAuthCredentials,
13
- hasAntigravityOAuthCredentials,
14
- } from './providers/oauth-credential-probes.mjs';
8
+ import { oauthCredentialProbeState } from './providers/oauth-credential-probes.mjs';
15
9
 
16
10
  // Thin wrapper around resolvePluginData so callers in this orchestrator tree
17
11
  // can import a single helper without reaching into shared/.
@@ -165,22 +159,40 @@ function buildDefaultConfig(options = {}) {
165
159
  };
166
160
  }
167
161
  // OAuth provider detection uses lightweight credential probes so config
168
- // load does not import provider runtimes or SDKs. WebSocket transport is on by default —
169
- // measured ~96% cross-session cache hit with delta payloads. Users who
170
- // need to force SSE (e.g. a corporate proxy blocking WSS) can set
171
- // `websocket: false` in mixdog-config.json (agent.providers.openai-oauth).
172
- providers['openai-oauth'] = { enabled: detectCredentials ? hasOpenAIOAuthCredentials() : false, websocket: true };
173
- providers['anthropic-oauth'] = { enabled: detectCredentials ? hasAnthropicOAuthCredentials() : false };
162
+ // load does not import provider runtimes or SDKs.
163
+ //
164
+ // The probe has THREE outcomes and `enabled` is only a boolean, so the
165
+ // third one is carried alongside it: a credential file that EXISTS but
166
+ // could not be read/parsed this instant (lock contention, EACCES, a torn
167
+ // atomic write) yields enabled:false + credentialProbeUnavailable:true.
168
+ // Without that marker the registry cannot tell such a load apart from a
169
+ // real logout/disable and would latch a momentary FS failure as a user
170
+ // opt-out. The marker is ephemeral: loadConfig drops it as soon as the
171
+ // stored config states `enabled` itself, and saveConfig never persists it.
172
+ const oauthEntry = (name, extra = {}) => {
173
+ const state = detectCredentials ? oauthCredentialProbeState(name) : 'absent';
174
+ return {
175
+ enabled: state === 'present',
176
+ ...(state === 'unreadable' ? { credentialProbeUnavailable: true } : {}),
177
+ ...extra,
178
+ };
179
+ };
180
+ // WebSocket transport is on by default — measured ~96% cross-session cache
181
+ // hit with delta payloads. Users who need to force SSE (e.g. a corporate
182
+ // proxy blocking WSS) can set `websocket: false` in mixdog-config.json
183
+ // (agent.providers.openai-oauth).
184
+ providers['openai-oauth'] = oauthEntry('openai-oauth', { websocket: true });
185
+ providers['anthropic-oauth'] = oauthEntry('anthropic-oauth');
174
186
  // Grok OAuth ("Grok Build"). Like the other OAuth entries it is not
175
187
  // stored in mixdog-config.json — enabled at runtime from the presence of
176
188
  // Mixdog-owned credentials.
177
- providers['grok-oauth'] = { enabled: detectCredentials ? hasGrokOAuthCredentials() : false };
189
+ providers['grok-oauth'] = oauthEntry('grok-oauth');
178
190
  // Experimental direct Cursor wire provider. It remains disabled unless a
179
191
  // Mixdog-owned login or CURSOR_ACCESS_TOKEN is present.
180
- providers['cursor-oauth'] = { enabled: detectCredentials ? hasCursorOAuthCredentials() : false };
192
+ providers['cursor-oauth'] = oauthEntry('cursor-oauth');
181
193
  // Google Antigravity (Cloud Code Assist). Gemini and Claude behind one
182
194
  // Google login; enabled only once a login has stored tokens + project.
183
- providers['antigravity-oauth'] = { enabled: detectCredentials ? hasAntigravityOAuthCredentials() : false };
195
+ providers['antigravity-oauth'] = oauthEntry('antigravity-oauth');
184
196
  // Local providers — opt-in via setup UI after HTTP ping confirms server is running
185
197
  providers.ollama = { enabled: false, baseURL: 'http://localhost:11434/v1' };
186
198
  providers.lmstudio = { enabled: false, baseURL: 'http://localhost:1234/v1' };
@@ -461,6 +473,14 @@ export function loadConfig(options = {}) {
461
473
  for (const [name, val] of Object.entries(raw.providers)) {
462
474
  if (val && typeof val === 'object') {
463
475
  mergedProviders[name] = { ...(mergedProviders[name] || {}), ...val };
476
+ // A STORED `enabled` is the user's own decision (setup
477
+ // UI disable / hand edit) and outranks the probe: keep
478
+ // it authoritative by dropping the probe-availability
479
+ // marker, so a disable still removes the provider even
480
+ // while its credential file happens to be unreadable.
481
+ if (Object.prototype.hasOwnProperty.call(val, 'enabled')) {
482
+ delete mergedProviders[name].credentialProbeUnavailable;
483
+ }
464
484
  } else {
465
485
  mergedProviders[name] = val;
466
486
  }
@@ -679,13 +699,28 @@ function buildAgentSaveBuilder(config, appliedDirty) {
679
699
  // provider keys are keychain-only (loadConfig overlays them into memory;
680
700
  // persisting would leak plaintext back into mixdog-config.json). It stays
681
701
  // in KNOWN_PROVIDER_KEYS so the generic passthrough loop also skips it.
682
- const KNOWN_PROVIDER_KEYS = new Set(['apiKey', 'enabled', 'baseURL']);
702
+ // `credentialProbeUnavailable` is a per-load probe observation, not user
703
+ // state: persisting it would turn one unreadable-file moment into a marker
704
+ // that outlives the condition on every later load. Listed here so the
705
+ // generic passthrough loop below skips it.
706
+ const KNOWN_PROVIDER_KEYS = new Set(['apiKey', 'enabled', 'baseURL', 'credentialProbeUnavailable']);
683
707
  const persistedProviders = {};
684
708
  if (config.providers) {
685
709
  for (const [name, val] of Object.entries(config.providers)) {
686
710
  if (!val || typeof val !== 'object') continue;
687
711
  const slim = {};
688
- if (typeof val.enabled === 'boolean') slim.enabled = val.enabled;
712
+ // NEVER persist an `enabled:false` that came from a credential probe
713
+ // which could not READ the credential this load. Writing it would
714
+ // convert a momentary FS failure into a stored user decision: the
715
+ // marker is stripped on save, so the next load would read that false
716
+ // as a deliberate disable and the provider would stay off for good.
717
+ // Omitting the key leaves `enabled` derived from the probe again on
718
+ // the next load. This can never erase a real user disable: loadConfig
719
+ // drops the marker whenever the stored config states `enabled`
720
+ // itself, so a marker in memory means nothing was stored for it.
721
+ const probeUnavailable = val.credentialProbeUnavailable === true;
722
+ const skipEnabled = probeUnavailable && val.enabled === false;
723
+ if (typeof val.enabled === 'boolean' && !skipEnabled) slim.enabled = val.enabled;
689
724
  if (val.baseURL) slim.baseURL = val.baseURL;
690
725
  for (const [k, v] of Object.entries(val)) {
691
726
  if (KNOWN_PROVIDER_KEYS.has(k)) continue;
@@ -0,0 +1,139 @@
1
+ // MCP tool-call cancellation: an aborted caller (hook timeout, aborted turn,
2
+ // closed pane) must settle immediately AND release its admission slot, in every
3
+ // phase of a call — queued, admitted-and-running, and stalled mid-reconnect.
4
+ import assert from 'node:assert/strict';
5
+ import test from 'node:test';
6
+
7
+ import {
8
+ _registerMcpServerForTest,
9
+ executeMcpTool,
10
+ getMcpAdmissionSnapshot,
11
+ } from './client.mjs';
12
+
13
+ const never = () => new Promise(() => {});
14
+
15
+ function deferred() {
16
+ let resolve;
17
+ const promise = new Promise((res) => { resolve = res; });
18
+ return { promise, resolve };
19
+ }
20
+
21
+ async function waitFor(predicate, label, timeoutMs = 2_000) {
22
+ const deadline = Date.now() + timeoutMs;
23
+ while (Date.now() < deadline) {
24
+ if (predicate()) return;
25
+ await new Promise((resolve) => { setTimeout(resolve, 10); });
26
+ }
27
+ throw new Error(`timed out waiting for ${label}`);
28
+ }
29
+
30
+ function slotsFor(scopeId, serverName) {
31
+ const gate = getMcpAdmissionSnapshot(scopeId).find((entry) => entry.name === serverName);
32
+ return { active: gate?.active ?? 0, queued: gate?.queued ?? 0 };
33
+ }
34
+
35
+ /** Settle-state probe: a cancellation fix is only real if the promise settles
36
+ * while the callee is still stuck. */
37
+ function track(promise) {
38
+ const state = { settled: false, error: null };
39
+ promise.then(
40
+ () => { state.settled = true; },
41
+ (error) => { state.settled = true; state.error = error; },
42
+ );
43
+ return state;
44
+ }
45
+
46
+ test('an aborted caller settles a QUEUED call and frees its queue seat', async () => {
47
+ const scopeId = 'mcp-cancel-queued';
48
+ _registerMcpServerForTest(scopeId, 'busy', [{ name: 'hang' }], {
49
+ cfg: { maxConcurrency: 1, timeoutMs: 'off' },
50
+ callTool: never,
51
+ });
52
+ const held = executeMcpTool('mcp__busy__hang', {}, { scopeId });
53
+ held.catch(() => {});
54
+ await waitFor(() => slotsFor(scopeId, 'busy').active === 1, 'the first call to occupy the slot');
55
+
56
+ const controller = new AbortController();
57
+ const queued = executeMcpTool('mcp__busy__hang', {}, { scopeId, signal: controller.signal });
58
+ const state = track(queued);
59
+ await waitFor(() => slotsFor(scopeId, 'busy').queued === 1, 'the second call to queue');
60
+
61
+ controller.abort(new Error('queued hook aborted'));
62
+ await assert.rejects(queued, /queued hook aborted/);
63
+ assert.equal(state.settled, true);
64
+ assert.equal(slotsFor(scopeId, 'busy').queued, 0, 'the queue seat is released');
65
+ });
66
+
67
+ test('an aborted caller settles an ADMITTED call whose server ignores cancellation', async () => {
68
+ const scopeId = 'mcp-cancel-active';
69
+ let sawSignal = false;
70
+ _registerMcpServerForTest(scopeId, 'deaf', [{ name: 'hang' }], {
71
+ // Server call timeout DISABLED: the caller's abort is the only bound.
72
+ cfg: { maxConcurrency: 1, timeoutMs: 'off' },
73
+ callTool: (_params, _schema, options) => {
74
+ sawSignal = Boolean(options?.signal);
75
+ return never();
76
+ },
77
+ });
78
+ const controller = new AbortController();
79
+ const call = executeMcpTool('mcp__deaf__hang', {}, { scopeId, signal: controller.signal });
80
+ const state = track(call);
81
+ await waitFor(() => slotsFor(scopeId, 'deaf').active === 1, 'the call to be admitted');
82
+ assert.equal(state.settled, false, 'a running call does not settle on its own');
83
+
84
+ controller.abort(new Error('mcp_tool hook timed out: mcp__deaf__hang'));
85
+ await assert.rejects(call, /mcp_tool hook timed out/);
86
+ assert.equal(sawSignal, true, 'the abort signal reaches the SDK request');
87
+ await waitFor(() => slotsFor(scopeId, 'deaf').active === 0, 'the admission slot to be released');
88
+
89
+ // The freed slot is genuinely reusable.
90
+ _registerMcpServerForTest(scopeId, 'deaf-ok', [{ name: 'go' }], {
91
+ cfg: { maxConcurrency: 1 },
92
+ callTool: async () => ({ content: [{ type: 'text', text: 'ran after cancel' }] }),
93
+ });
94
+ assert.equal(await executeMcpTool('mcp__deaf-ok__go', {}, { scopeId }), 'ran after cancel');
95
+ });
96
+
97
+ test('an aborted caller settles a call stalled inside a shared reconnect', async () => {
98
+ const scopeId = 'mcp-cancel-reconnect';
99
+ const closeEntered = deferred();
100
+ _registerMcpServerForTest(scopeId, 'flaky', [{ name: 'boom' }], {
101
+ cfg: { maxConcurrency: 1, timeoutMs: 'off' },
102
+ // A plain failure (not a timeout, not an abort) is what sends the call into
103
+ // the shared reconnect path.
104
+ callTool: async () => { throw new Error('transport went away'); },
105
+ // Teardown never completes, so the reconnect stays in flight forever.
106
+ close: () => { closeEntered.resolve(); return never(); },
107
+ });
108
+ const controller = new AbortController();
109
+ const call = executeMcpTool('mcp__flaky__boom', {}, { scopeId, signal: controller.signal });
110
+ const state = track(call);
111
+ await closeEntered.promise;
112
+ await waitFor(() => slotsFor(scopeId, 'flaky').active === 1, 'the call to hold its slot in reconnect');
113
+ assert.equal(state.settled, false, 'the stalled reconnect keeps the call pending');
114
+
115
+ controller.abort(new Error('hook aborted during reconnect'));
116
+ await assert.rejects(call, /hook aborted during reconnect/);
117
+ await waitFor(
118
+ () => slotsFor(scopeId, 'flaky').active === 0,
119
+ 'the admission slot to be released while the reconnect is still stalled',
120
+ );
121
+ });
122
+
123
+ test('cancellation never turns into a reconnect-and-retry (no duplicate side effect)', async () => {
124
+ const scopeId = 'mcp-cancel-no-retry';
125
+ let calls = 0;
126
+ const closeEntered = deferred();
127
+ _registerMcpServerForTest(scopeId, 'once', [{ name: 'effect' }], {
128
+ cfg: { maxConcurrency: 1, timeoutMs: 'off' },
129
+ callTool: async () => { calls += 1; throw new Error('transport went away'); },
130
+ close: () => { closeEntered.resolve(); return never(); },
131
+ });
132
+ const controller = new AbortController();
133
+ const call = executeMcpTool('mcp__once__effect', {}, { scopeId, signal: controller.signal });
134
+ await closeEntered.promise;
135
+ controller.abort(new Error('aborted before retry'));
136
+ await assert.rejects(call, /aborted before retry/);
137
+ await new Promise((resolve) => { setTimeout(resolve, 50); });
138
+ assert.equal(calls, 1, 'the aborted call is never replayed against a reconnected server');
139
+ });
@@ -44,23 +44,25 @@ function run(cmd, args) {
44
44
  async function enumerate() {
45
45
  const childrenOf = new Map();
46
46
  const tokenOf = new Map();
47
+ const nameOf = new Map();
47
48
  const out = isWin
48
49
  ? await run('powershell.exe', [
49
50
  '-NoProfile', '-NonInteractive', '-Command',
50
- `Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToString('o'))" }`,
51
+ `Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.Name) $($_.CreationDate.ToString('o'))" }`,
51
52
  ])
52
- : await run('ps', ['-A', '-o', 'pid=,ppid=,lstart=']);
53
+ : await run('ps', ['-A', '-o', 'pid=,ppid=,comm=,lstart=']);
53
54
  for (const line of out.split(/\r?\n/)) {
54
- const m = line.trim().match(/^(\d+)\s+(\d+)(?:\s+(.+))?$/);
55
+ const m = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)(?:\s+(.+))?$/);
55
56
  if (!m) continue;
56
57
  const pid = Number(m[1]);
57
58
  const ppid = Number(m[2]);
58
- const token = (m[3] || '').trim() || null;
59
+ const token = (m[4] || '').trim() || null;
59
60
  if (!childrenOf.has(ppid)) childrenOf.set(ppid, []);
60
61
  childrenOf.get(ppid).push(pid);
61
62
  tokenOf.set(pid, token);
63
+ nameOf.set(pid, m[3]);
62
64
  }
63
- return { childrenOf, tokenOf };
65
+ return { childrenOf, tokenOf, nameOf };
64
66
  }
65
67
 
66
68
  // BFS every descendant pid of rootPid (excludes rootPid).
@@ -87,6 +89,33 @@ async function collectDescendants(rootPid) {
87
89
  return descendantsOf(childrenOf, rootPid);
88
90
  }
89
91
 
92
+ /** Descendant pids WITH their image names, from one batched process-table
93
+ * read. Shared with the shell runner's post-exit descendant observation so
94
+ * both use a single enumeration implementation. */
95
+ export async function collectDescendantProcesses(rootPid) {
96
+ const { childrenOf, nameOf } = await enumerate().catch(() => ({ childrenOf: new Map(), nameOf: new Map() }));
97
+ return descendantsOf(childrenOf, rootPid).map((pid) => ({ pid, name: nameOf.get(pid) || '' }));
98
+ }
99
+
100
+ /** Force-kill whole process trees by pid. Windows batches one taskkill /T /F
101
+ * for every pid; POSIX escalates SIGTERM → SIGKILL. Best-effort: the caller
102
+ * verifies liveness afterwards rather than trusting the signal. */
103
+ export async function killProcessTrees(pids) {
104
+ const targets = [...new Set((pids || []).map(Number).filter((pid) => Number.isInteger(pid) && pid > 0))];
105
+ if (targets.length === 0) return;
106
+ if (isWin) {
107
+ const args = ['/F', '/T'];
108
+ for (const pid of targets) args.push('/PID', String(pid));
109
+ await run('taskkill', args);
110
+ return;
111
+ }
112
+ for (const pid of targets) { try { process.kill(pid, 'SIGTERM'); } catch { /* ignore */ } }
113
+ await delay(300);
114
+ for (const pid of targets) {
115
+ if (isAlive(pid)) { try { process.kill(pid, 'SIGKILL'); } catch { /* ignore */ } }
116
+ }
117
+ }
118
+
90
119
  /**
91
120
  * Immediate, non-blocking tree kill for a transport whose MCP handshake never
92
121
  * completed (shutdown-during-connect). Unlike shutdownStdioChild there is no