mixdog 0.9.95 → 0.9.97

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 (282) hide show
  1. package/LICENSES/Apache-2.0.txt +201 -0
  2. package/LICENSES/MIT.txt +56 -0
  3. package/LICENSES/codex-NOTICE.txt +6 -0
  4. package/NOTICE.md +46 -9
  5. package/README.md +0 -17
  6. package/package.json +16 -12
  7. package/scripts/.tmp-coverage-report.mjs +96 -0
  8. package/scripts/build-tui.mjs +17 -1
  9. package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
  10. package/scripts/fixtures/patch-replay-corpus.json +98 -0
  11. package/src/defaults/mixdog-config.template.json +5 -8
  12. package/src/defaults/skills/setup/SKILL.md +1 -1
  13. package/src/lib/keychain-cjs.cjs +1 -1
  14. package/src/lib/mixdog-debug.cjs +2 -3
  15. package/src/rules/agent/30-explorer.md +7 -4
  16. package/src/rules/lead/01-general.md +1 -2
  17. package/src/rules/lead/lead-tool.md +0 -3
  18. package/src/rules/shared/01-tool.md +21 -29
  19. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +18 -2
  20. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +31 -1
  21. package/src/runtime/agent/orchestrator/agent-runtime/maintenance-route.mjs +6 -17
  22. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -1
  23. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
  24. package/src/runtime/agent/orchestrator/config.mjs +246 -67
  25. package/src/runtime/agent/orchestrator/mcp/client.mjs +104 -41
  26. package/src/runtime/agent/orchestrator/mcp/reconnect-singleflight.mjs +23 -0
  27. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +113 -14
  28. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +5 -1
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +32 -11
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +89 -2
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -1
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +19 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +144 -39
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +22 -8
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-transport-policy.mjs +1 -1
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +3 -4
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
  39. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +67 -7
  41. package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +291 -0
  42. package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +21 -0
  43. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +36 -10
  44. package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -33
  46. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +13 -0
  47. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +2 -2
  48. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +0 -24
  49. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +21 -29
  50. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +7 -10
  51. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +45 -25
  52. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
  53. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +142 -84
  54. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
  55. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +4 -0
  56. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +31 -1
  57. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
  58. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +29 -9
  59. package/src/runtime/agent/orchestrator/session/manager.mjs +2 -0
  60. package/src/runtime/agent/orchestrator/session/result-classification.mjs +65 -0
  61. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +86 -8
  62. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
  63. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
  64. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +71 -42
  65. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +4 -33
  66. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +4 -5
  67. package/src/runtime/agent/orchestrator/stall-policy.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  69. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +35 -1
  70. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +116 -26
  71. package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
  72. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +8 -9
  73. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +2 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/device-paths.mjs +2 -2
  75. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +6 -15
  77. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +12 -26
  78. package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +5 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +83 -33
  80. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +42 -45
  81. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +27 -9
  82. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +7 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +17 -39
  84. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +73 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
  86. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +0 -2
  87. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +3 -8
  88. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +30 -0
  89. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +5 -5
  90. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +142 -26
  91. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
  92. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +47 -19
  93. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +3 -2
  94. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
  95. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +83 -13
  96. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +39 -1
  97. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -20
  99. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +16 -0
  100. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +108 -12
  101. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +12 -11
  102. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +63 -9
  104. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +2 -2
  105. package/src/runtime/channels/lib/config.mjs +34 -34
  106. package/src/runtime/channels/lib/inbound-handler.mjs +8 -8
  107. package/src/runtime/channels/lib/interaction-handlers.mjs +6 -6
  108. package/src/runtime/channels/lib/memory-client.mjs +31 -157
  109. package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
  110. package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
  111. package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
  112. package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
  113. package/src/runtime/channels/lib/runtime-paths.mjs +4 -30
  114. package/src/runtime/channels/lib/scheduler.mjs +2 -2
  115. package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
  116. package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
  117. package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
  118. package/src/runtime/channels/lib/worker-ipc.mjs +7 -7
  119. package/src/runtime/channels/lib/worker-main.mjs +38 -38
  120. package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
  121. package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
  122. package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
  123. package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/memory/index.mjs +76 -248
  126. package/src/runtime/memory/lib/agent-ipc.mjs +146 -81
  127. package/src/runtime/memory/lib/compact-vector-cache.mjs +88 -0
  128. package/src/runtime/memory/lib/core-memory-store.mjs +18 -1
  129. package/src/runtime/memory/lib/cycle-llm-adapters.mjs +13 -34
  130. package/src/runtime/memory/lib/embedding-provider.mjs +47 -26
  131. package/src/runtime/memory/lib/embedding-worker.mjs +30 -34
  132. package/src/runtime/memory/lib/http-router.mjs +9 -6
  133. package/src/runtime/memory/lib/ko-morph.mjs +3 -20
  134. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -1
  135. package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
  136. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -0
  137. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +4 -2
  138. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -0
  139. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
  140. package/src/runtime/memory/lib/memory-cycle3.mjs +3 -1
  141. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
  142. package/src/runtime/memory/lib/memory-log.mjs +1 -1
  143. package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
  144. package/src/runtime/memory/lib/memory.mjs +1 -1
  145. package/src/runtime/memory/lib/pg/process.mjs +32 -9
  146. package/src/runtime/memory/lib/session-ingest.mjs +1 -1
  147. package/src/runtime/memory/tool-defs.mjs +1 -5
  148. package/src/runtime/search/index.mjs +2 -2
  149. package/src/runtime/search/tool-defs.mjs +2 -2
  150. package/src/runtime/shared/abort-race.mjs +70 -0
  151. package/src/runtime/shared/agent-route-config.mjs +119 -0
  152. package/src/runtime/shared/atomic-file.mjs +80 -4
  153. package/src/runtime/shared/channel-notification-routing.mjs +2 -2
  154. package/src/runtime/shared/child-guardian.mjs +204 -108
  155. package/src/runtime/shared/child-spawn-gate.mjs +139 -72
  156. package/src/runtime/shared/config.mjs +120 -47
  157. package/src/runtime/shared/llm/index.mjs +1 -1
  158. package/src/runtime/shared/owner-fair-gate.mjs +134 -0
  159. package/src/runtime/shared/resource-admission.mjs +150 -100
  160. package/src/runtime/shared/stream-progress.mjs +6 -0
  161. package/src/runtime/shared/tool-execution-owner.mjs +12 -0
  162. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  163. package/src/runtime/shared/tool-surface.mjs +91 -27
  164. package/src/runtime/shared/tool-workload-gates.mjs +29 -0
  165. package/src/runtime/shared/turn-snapshot.mjs +47 -6
  166. package/src/runtime/shared/turn-worktree-snapshot.mjs +14 -2
  167. package/src/session-runtime/channel-config-api.mjs +5 -5
  168. package/src/session-runtime/config-helpers.mjs +39 -37
  169. package/src/session-runtime/config-lifecycle.mjs +27 -27
  170. package/src/session-runtime/lifecycle-api.mjs +22 -3
  171. package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
  172. package/src/session-runtime/model-route-api.mjs +7 -5
  173. package/src/session-runtime/prewarm.mjs +1 -1
  174. package/src/session-runtime/provider-auth-api.mjs +0 -7
  175. package/src/session-runtime/provider-models.mjs +1 -1
  176. package/src/session-runtime/remote-control.mjs +6 -7
  177. package/src/session-runtime/runtime-core.mjs +63 -39
  178. package/src/session-runtime/runtime-tunables.mjs +4 -4
  179. package/src/session-runtime/self-update.mjs +1 -1
  180. package/src/session-runtime/session-lifecycle.mjs +45 -17
  181. package/src/session-runtime/session-title.mjs +1 -1
  182. package/src/session-runtime/session-turn-api.mjs +96 -24
  183. package/src/session-runtime/settings-api.mjs +12 -24
  184. package/src/session-runtime/tool-defs.mjs +3 -3
  185. package/src/session-runtime/workflow-agents-api.mjs +55 -81
  186. package/src/session-runtime/workflow.mjs +14 -24
  187. package/src/standalone/agent-dispatch-broker.mjs +198 -0
  188. package/src/standalone/agent-tool/helpers.mjs +2 -2
  189. package/src/standalone/agent-tool/spawn-flow.mjs +2 -0
  190. package/src/standalone/agent-tool/spawn-preset.mjs +11 -21
  191. package/src/standalone/agent-tool/tool-def.mjs +0 -14
  192. package/src/standalone/channel-admin.mjs +53 -64
  193. package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +33 -19
  194. package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +127 -46
  195. package/src/standalone/channel-worker.mjs +124 -647
  196. package/src/standalone/{backend-daemon.mjs → daemon.mjs} +270 -154
  197. package/src/standalone/explore-tool.mjs +187 -70
  198. package/src/standalone/fair-call-scheduler.mjs +264 -0
  199. package/src/standalone/memory-runtime-proxy.mjs +58 -1
  200. package/src/standalone/session-client.mjs +1134 -0
  201. package/src/standalone/session-protocol.mjs +192 -0
  202. package/src/standalone/session-runtime-pool.mjs +393 -0
  203. package/src/standalone/session-runtime-record.mjs +20 -0
  204. package/src/standalone/session-runtime-worker.mjs +203 -0
  205. package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +481 -224
  206. package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +390 -121
  207. package/src/standalone/session-wire.mjs +55 -0
  208. package/src/tui/App.jsx +34 -21
  209. package/src/tui/app/app-format.mjs +1 -1
  210. package/src/tui/app/app-view.jsx +5 -2
  211. package/src/tui/app/channel-pickers.mjs +41 -41
  212. package/src/tui/app/core-memory-picker.mjs +19 -19
  213. package/src/tui/app/doctor.mjs +5 -11
  214. package/src/tui/app/input-parsers.mjs +2 -2
  215. package/src/tui/app/message-selector.mjs +103 -0
  216. package/src/tui/app/onboarding-steps.mjs +3 -4
  217. package/src/tui/app/project-picker.mjs +80 -56
  218. package/src/tui/app/prompt-submit.mjs +48 -43
  219. package/src/tui/app/resume-picker.mjs +2 -2
  220. package/src/tui/app/route-pickers.mjs +3 -1
  221. package/src/tui/app/settings-picker.mjs +31 -33
  222. package/src/tui/app/transcript-window.mjs +38 -0
  223. package/src/tui/app/use-prompt-draft-flow.mjs +5 -7
  224. package/src/tui/app/use-prompt-handlers.mjs +86 -21
  225. package/src/tui/app/use-prompt-hint.mjs +3 -2
  226. package/src/tui/app/use-prompt-queue-history.mjs +10 -3
  227. package/src/tui/app/use-transcript-scroll.mjs +10 -3
  228. package/src/tui/app/use-transcript-window.mjs +50 -17
  229. package/src/tui/components/PromptInput.jsx +71 -38
  230. package/src/tui/components/Spinner.jsx +1 -1
  231. package/src/tui/components/ToolExecution.jsx +1 -1
  232. package/src/tui/components/prompt-input/edit-helpers.mjs +9 -11
  233. package/src/tui/components/prompt-input/escape-policy.mjs +42 -0
  234. package/src/tui/components/prompt-input/immediate-render.mjs +0 -10
  235. package/src/tui/components/prompt-input/interrupt-policy.mjs +10 -0
  236. package/src/tui/components/prompt-input/restore-policy.mjs +10 -0
  237. package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
  238. package/src/tui/dist/index.mjs +1031 -1676
  239. package/src/tui/hooks/useSession.mjs +16 -0
  240. package/src/tui/index.jsx +12 -13
  241. package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
  242. package/src/tui/{engine → session}/agent-job-feed.mjs +4 -4
  243. package/src/tui/{engine → session}/boot-profile.mjs +2 -2
  244. package/src/tui/{engine → session}/context-state.mjs +3 -3
  245. package/src/tui/{engine → session}/labels.mjs +2 -2
  246. package/src/tui/{engine → session}/live-share.mjs +38 -4
  247. package/src/tui/{engine → session}/notice-text.mjs +2 -2
  248. package/src/tui/{engine → session}/notification-plan.mjs +3 -3
  249. package/src/tui/session/oauth-flows.mjs +140 -0
  250. package/src/tui/{engine → session}/prompt-history.mjs +3 -3
  251. package/src/tui/{engine → session}/queue-helpers.mjs +2 -2
  252. package/src/tui/{engine → session}/render-timing.mjs +2 -2
  253. package/src/tui/{engine → session}/session-api-ext.mjs +30 -20
  254. package/src/tui/{engine → session}/session-api.mjs +110 -93
  255. package/src/tui/{engine → session}/session-flow.mjs +8 -2
  256. package/src/tui/{engine → session}/session-stats.mjs +3 -3
  257. package/src/tui/{engine → session}/tool-approval.mjs +3 -3
  258. package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
  259. package/src/tui/{engine → session}/tool-card-results.mjs +8 -6
  260. package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
  261. package/src/tui/{engine → session}/tool-result-text.mjs +13 -3
  262. package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
  263. package/src/tui/{engine → session}/turn.mjs +38 -176
  264. package/src/tui/{engine-local-session.mjs → session-local.mjs} +103 -103
  265. package/src/tui/session.mjs +22 -0
  266. package/src/tui/spinner-meta.mjs +4 -4
  267. package/src/tui/spinner-verbs.mjs +51 -3
  268. package/src/ui/statusline-segments.mjs +1 -1
  269. package/src/ui/statusline.mjs +1 -1
  270. package/vendor/ink/build/components/App.js +10 -1
  271. package/src/session-runtime/memory-daemon-probe.mjs +0 -61
  272. package/src/standalone/engine-daemon-client.mjs +0 -894
  273. package/src/standalone/engine-daemon-local-bridge.mjs +0 -20
  274. package/src/standalone/engine-daemon-protocol.mjs +0 -33
  275. package/src/tui/engine/turn-watchdog.mjs +0 -92
  276. package/src/tui/engine.mjs +0 -22
  277. package/src/tui/hooks/useEngine.mjs +0 -16
  278. /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
  279. /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
  280. /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
  281. /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
  282. /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
@@ -23,6 +23,7 @@ import {
23
23
  } from './tool-helpers.mjs';
24
24
  import { isOnDeferredToolSurface, prepareDeferredToolCallThrough } from './deferred-call-through.mjs';
25
25
  import { preDispatchDenyForSession, routeWebFetchCall } from './pre-dispatch-deny.mjs';
26
+ import { runWithToolExecutionOwner } from '../../../../shared/tool-execution-owner.mjs';
26
27
 
27
28
  let codeGraphRuntimePromise = null;
28
29
  async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
@@ -32,6 +33,11 @@ async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options
32
33
  return mod.executeCodeGraphTool(name, args, cwd, signal, options);
33
34
  }
34
35
 
36
+ export function resolveLiveToolCwd(cwd, sessionRef) {
37
+ const liveCwd = typeof sessionRef?.cwd === 'string' ? sessionRef.cwd : '';
38
+ return liveCwd || cwd;
39
+ }
40
+
35
41
  export function _scopedCacheOutcomeForCall(sessionRef, toolCallId, toolName, callerSessionId, executeOpts = {}) {
36
42
  if (executeOpts.scopedCacheOutcome) {
37
43
  if (sessionRef && toolCallId) {
@@ -51,7 +57,16 @@ export function _scopedCacheOutcomeForCall(sessionRef, toolCallId, toolName, cal
51
57
  return outcome;
52
58
  }
53
59
 
54
- export async function executeTool(name, args, cwd, callerSessionId, sessionRef, executeOpts = {}) {
60
+ export function executeTool(name, args, cwd, callerSessionId, sessionRef, executeOpts = {}) {
61
+ return runWithToolExecutionOwner(callerSessionId, () =>
62
+ executeToolOwned(name, args, cwd, callerSessionId, sessionRef, executeOpts));
63
+ }
64
+
65
+ async function executeToolOwned(name, args, cwd, callerSessionId, sessionRef, executeOpts = {}) {
66
+ // cwd is captured when the turn starts. The deferred cwd tool updates
67
+ // sessionRef.cwd in place, so every later tool call must re-read that live
68
+ // value instead of continuing to use the stale turn snapshot.
69
+ cwd = resolveLiveToolCwd(cwd, sessionRef);
55
70
  const scopedCacheOutcome = _scopedCacheOutcomeForCall(
56
71
  sessionRef,
57
72
  executeOpts.toolCallId,
@@ -174,7 +189,10 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
174
189
  && mcpToolHasField(name, 'cwd')
175
190
  && (args == null || args.cwd == null);
176
191
  const finalArgs = needsCwdInjection ? { ...(args || {}), cwd } : args;
177
- return executeMcpTool(name, finalArgs);
192
+ return executeMcpTool(name, finalArgs, {
193
+ signal: executeOpts.signal || null,
194
+ ownerKey: callerSessionId,
195
+ });
178
196
  }
179
197
  if (name === 'code_graph') {
180
198
  // cwd chain: args.cwd (caller-explicit) → session cwd → undefined (handler throws)
@@ -227,36 +245,10 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
227
245
  }
228
246
  if (name === 'apply_patch') {
229
247
  const patchArgs = typeof args === 'string' ? { patch: args } : { ...(args || {}) };
230
- // post_shell: optional verification command executed through the normal
231
- // one-shot shell path ONLY after the patch applies cleanly; a failed
232
- // patch skips it. Patch text and shell output return as ONE tool
233
- // result, so patch+verify costs a single call. Runtime-only knob:
234
- // stripped before executePatchTool sees the args.
235
- const postShell = typeof patchArgs.post_shell === 'string' && patchArgs.post_shell.trim()
236
- ? patchArgs.post_shell.trim()
237
- : null;
238
- delete patchArgs.post_shell;
239
- const patchResult = await executePatchTool(name, patchArgs, cwd, {
248
+ return executePatchTool(name, patchArgs, cwd, {
240
249
  sessionId: callerSessionId,
241
250
  toolCallId: executeOpts.toolCallId || null,
242
251
  });
243
- if (!postShell) return patchResult;
244
- const patchNorm = normalizeToolEnvelope(patchResult);
245
- const patchText = typeof patchNorm.result === 'string' ? patchNorm.result : String(patchNorm.result ?? '');
246
- // Text-based failure detection only: legacy string returns normalize
247
- // to explicitSuccess:false even on success, so that flag is unusable here.
248
- const patchFailed = /^Error[\s:[]/.test(patchText.trimStart());
249
- if (patchFailed) {
250
- return `${patchText}\n--- post_shell skipped: patch failed ---`;
251
- }
252
- const shellRes = await executeBuiltinTool('shell', { command: postShell }, cwd, completionToolOpts);
253
- const shellNorm = normalizeToolEnvelope(shellRes);
254
- const shellText = typeof shellNorm.result === 'string' ? shellNorm.result : String(shellNorm.result ?? '');
255
- const shellFailed = /^Error[\s:[]/.test(shellText.trimStart());
256
- const header = shellFailed
257
- ? '--- post_shell FAILED (patch is applied; fix and re-verify) ---'
258
- : '--- post_shell ---';
259
- return `${patchText}\n\n${header}\n${shellText}`;
260
252
  }
261
253
  if (isBuiltinTool(name)) {
262
254
  // clientHostPid threaded for the same per-terminal job-scope reason as
@@ -12,7 +12,6 @@ import {
12
12
  isSkillDisabled,
13
13
  } from '../../context/collect.mjs';
14
14
  import { isAgentOwner } from '../../agent-owner.mjs';
15
- import { _isMutationTool } from './tool-classify.mjs';
16
15
 
17
16
  // Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
18
17
  // to execute during SSE parsing so tool work overlaps with the rest of the
@@ -41,16 +40,14 @@ export function isEagerDispatchable(name, tools) {
41
40
  return set.has(name);
42
41
  }
43
42
 
44
- // Full-parallel dispatch policy: EVERY tool call in an assistant turn starts
45
- // executing immediately — ordering across dependent work is the MODEL's job
46
- // (split into separate turns). The only exception is the ordered mutation
47
- // (apply_patch), which stays in the serial batch body so multi-patch turns
48
- // keep deterministic order, the all-or-nothing failure gate, and cache
49
- // invalidation sequencing. isEagerDispatchable above remains the READ-ONLY
50
- // classifier used for dedup eligibility, steering, and post-mutation result
51
- // invalidation.
43
+ // Full dispatch policy: EVERY valid tool call in an assistant turn is queued
44
+ // immediately. Mutations rely on their own conflict scope (apply_patch uses
45
+ // per-path locks); the eager dispatcher adds only the apply_patch -> shell
46
+ // execution barrier required for same-turn validation.
47
+ // isEagerDispatchable above remains the READ-ONLY classifier used for dedup
48
+ // eligibility, steering, and post-mutation result invalidation.
52
49
  export function isParallelDispatchable(name) {
53
- return typeof name === 'string' && name.length > 0 && !_isMutationTool(name);
50
+ return typeof name === 'string' && name.length > 0;
54
51
  }
55
52
 
56
53
  // Read-only is necessary but not sufficient for result deduplication. Loader
@@ -67,6 +67,17 @@ import {
67
67
  import { _getAgentLoop } from './runtime-loaders.mjs';
68
68
  import { getAgentRuntimeSync } from './agent-runtime-singleton.mjs';
69
69
  import { recordProviderContextBaseline } from '../loop/compact-policy.mjs';
70
+ import { runAbortable, settleWithin, throwIfAborted } from '../../../../shared/abort-race.mjs';
71
+
72
+ export const DEFAULT_ASK_CLEANUP_SETTLE_MS = 2_000;
73
+
74
+ export async function settleAskCleanup(promise, { timeoutMs } = {}) {
75
+ const configured = Number(process.env.MIXDOG_ASK_CLEANUP_SETTLE_MS);
76
+ const budget = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
77
+ ? Number(timeoutMs)
78
+ : (Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_ASK_CLEANUP_SETTLE_MS);
79
+ return await settleWithin(promise, budget);
80
+ }
70
81
 
71
82
  export function persistedAssistantTranscriptMetadata(value, fallbackAt = Date.now()) {
72
83
  if (!value || typeof value !== 'object') return null;
@@ -196,7 +207,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
196
207
  if (process.env.MIXDOG_DEBUG_AGENT) {
197
208
  process.stderr.write(`[agent-trace] t0-ask-start sessionHash=${createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 8)} role=? iteration=0 promptSrc=${_promptSrc} prefetchFiles=${_prefetchFiles} callers=${_prefetchCallers} references=${_prefetchRefs}\n`);
198
209
  }
199
- const unlock = await acquireSessionLock(sessionId);
210
+ const unlock = await acquireSessionLock(sessionId, askOpts?.signal);
200
211
  // Start crash-spool hydration without delaying the user turn. A completed
201
212
  // background hydration joins the id-deduped memory drain below.
202
213
  const takeoverHydration = hydratePendingMessages(sessionId);
@@ -309,9 +320,10 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
309
320
  // computation would run detached. Capture the linked signal, install the
310
321
  // fresh controller, then re-cascade it — aborting the new controller
311
322
  // immediately when the parent already fired, or re-arming the listener.
312
- const _linkedParentSignal = runtime.parentAbortLink?.signal;
323
+ const _linkedParentSignal = askOpts?.signal || runtime.parentAbortLink?.signal;
313
324
  // Fresh controller per ask — the previous ask's controller may have aborted.
314
325
  runtime.controller = createAbortController();
326
+ const turnSignal = runtime.controller.signal;
315
327
  runtime.generation = askGeneration;
316
328
  runtime.closed = false;
317
329
  runtime.session = preSession;
@@ -469,7 +481,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
469
481
  // even to the real task. Single-turn structure with a labelled
470
482
  // `# Task` block forces the model to treat the brief as the work
471
483
  // unit, not as another piece of context to ack.
472
- const explicitPrefetchResult = await _tryBridgeExplicitPrefetch(session, explicitPrefetch);
484
+ const explicitPrefetchResult = await _tryBridgeExplicitPrefetch(session, explicitPrefetch, turnSignal);
473
485
  let _contextBlock = '';
474
486
  if (context) {
475
487
  _contextBlock += `# Additional context\n${_capCtx(context)}\n\n`;
@@ -574,7 +586,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
574
586
  },
575
587
  });
576
588
  } catch { /* trace must never break the ask path */ }
577
- const agentLoop = await _getAgentLoop();
589
+ const agentLoop = await runAbortable(turnSignal, () => _getAgentLoop());
578
590
  const _trackTextDelta = (chunk) => {
579
591
  _turnInterruption.recordTextDelta(chunk);
580
592
  _scheduleTurnCheckpoint();
@@ -645,10 +657,15 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
645
657
  onProviderSendStarted: () => {
646
658
  _turnInterruption.markProviderSendStarted();
647
659
  _scheduleTurnCheckpoint(true);
660
+ try { askOpts?.onProviderSendStarted?.(); } catch {}
648
661
  },
649
662
  onToolPhaseStarted: () => {
650
663
  _turnInterruption.markToolPhaseStarted();
651
664
  _scheduleTurnCheckpoint(true);
665
+ try { askOpts?.onToolPhaseStarted?.(); } catch {}
666
+ },
667
+ onToolPhaseCompleted: (detail) => {
668
+ try { askOpts?.onToolPhaseCompleted?.(detail); } catch {}
652
669
  },
653
670
  onUsageDelta: (d) => {
654
671
  persistIterationMetrics(d).catch(() => {});
@@ -761,6 +778,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
761
778
  session.toolApprovalHook = priorToolApprovalHook;
762
779
  }
763
780
  }
781
+ throwIfAborted(turnSignal);
764
782
  // Post-loop validation: if closeSession() landed while we were awaiting,
765
783
  // drop the save so the tombstone on disk isn't overwritten.
766
784
  const currentRuntime = _getRuntimeEntry(sessionId);
@@ -895,7 +913,10 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
895
913
  // Unified total-prompt field. Anthropic = input+cache_read+cache_write
896
914
  // (additive); OpenAI OAuth/API/Gemini = input_tokens already includes the
897
915
  // cached portion (inclusive), so the fallback must not double-count.
898
- const { isInclusiveProvider, computeCostUsd } = await import('../../../../shared/llm/cost.mjs');
916
+ const { isInclusiveProvider, computeCostUsd } = await runAbortable(
917
+ turnSignal,
918
+ () => import('../../../../shared/llm/cost.mjs'),
919
+ );
899
920
  const inclusive = isInclusiveProvider(session.provider);
900
921
  const promptTokens = typeof result.usage.promptTokens === 'number'
901
922
  ? result.usage.promptTokens
@@ -1037,15 +1058,14 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
1037
1058
  }
1038
1059
  try {
1039
1060
  const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
1040
- if (finalized.responsePreserved) {
1041
- await finalizePendingMessageDelivery(
1061
+ const cleanup = finalized.responsePreserved
1062
+ ? finalizePendingMessageDelivery(
1042
1063
  activeSession, _turnPendingEntries, durableSave,
1043
1064
  () => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
1044
- );
1045
- } else {
1046
- await durableSave;
1047
- }
1048
- clearTurnCheckpoint(sessionId, _turnCheckpointToken);
1065
+ )
1066
+ : durableSave;
1067
+ const cleanupResult = await settleAskCleanup(cleanup);
1068
+ if (cleanupResult.settled) clearTurnCheckpoint(sessionId, _turnCheckpointToken);
1049
1069
  } catch { /* cancellation cleanup is best-effort */ }
1050
1070
  if (currentRuntime) currentRuntime.session = activeSession;
1051
1071
  } else releasePendingMessages(sessionId, _turnPendingEntries);
@@ -1083,35 +1103,34 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
1083
1103
  else releasePendingMessages(sessionId, _turnPendingEntries);
1084
1104
  try {
1085
1105
  const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
1086
- if (finalized.responsePreserved) {
1087
- await finalizePendingMessageDelivery(
1106
+ const cleanup = finalized.responsePreserved
1107
+ ? finalizePendingMessageDelivery(
1088
1108
  activeSession, _turnPendingEntries, durableSave,
1089
1109
  () => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
1090
- );
1091
- } else {
1092
- await durableSave;
1093
- }
1094
- _errorStateDurable = true;
1110
+ )
1111
+ : durableSave;
1112
+ _errorStateDurable = (await settleAskCleanup(cleanup)).settled;
1095
1113
  } catch { /* provider-failure history persistence is best-effort */ }
1096
1114
  const currentRuntime = _getRuntimeEntry(sessionId);
1097
1115
  if (currentRuntime) currentRuntime.session = activeSession;
1098
1116
  } else {
1099
- const promptPersisted = await persistCompactedOutgoingAfterAskFailure({
1117
+ const compactPersist = await settleAskCleanup(persistCompactedOutgoingAfterAskFailure({
1100
1118
  sessionId,
1101
1119
  activeSession,
1102
1120
  askGeneration,
1103
1121
  turnOutgoing: _turnOutgoing,
1104
1122
  error: err,
1105
- });
1123
+ }));
1124
+ const promptPersisted = compactPersist.settled && compactPersist.value === true;
1106
1125
  if (promptPersisted) {
1107
1126
  recordPendingMessageDelivery(activeSession, _turnPendingEntries);
1108
1127
  try {
1109
1128
  const durableSave = saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
1110
- await finalizePendingMessageDelivery(
1129
+ const cleanup = finalizePendingMessageDelivery(
1111
1130
  activeSession, _turnPendingEntries, durableSave,
1112
1131
  () => saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
1113
1132
  );
1114
- _errorStateDurable = true;
1133
+ _errorStateDurable = (await settleAskCleanup(cleanup)).settled;
1115
1134
  } catch {}
1116
1135
  } else {
1117
1136
  releasePendingMessages(sessionId, _turnPendingEntries);
@@ -1119,8 +1138,9 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
1119
1138
  }
1120
1139
  if (!_errorStateDurable && activeSession) {
1121
1140
  try {
1122
- await saveSessionAsync(activeSession, { expectedGeneration: askGeneration });
1123
- _errorStateDurable = true;
1141
+ _errorStateDurable = (await settleAskCleanup(
1142
+ saveSessionAsync(activeSession, { expectedGeneration: askGeneration }),
1143
+ )).settled;
1124
1144
  } catch { /* retain checkpoint when canonical persistence fails */ }
1125
1145
  }
1126
1146
  if (_errorStateDurable) clearTurnCheckpoint(sessionId, _turnCheckpointToken);
@@ -77,8 +77,7 @@ function addCompactUsageToSession(session, usage) {
77
77
  session.totalUncachedInputTokens = (session.totalUncachedInputTokens || 0) + uncachedInputTokens;
78
78
  session.tokensCumulative = (session.tokensCumulative || 0) + inputTokens + outputTokens;
79
79
  }
80
- // A dead/unreachable memory runtime makes each proxy RPC wait ~30s (waitForPort)
81
- // and retry once (memory-runtime-proxy.mjs) — that must NEVER wedge the
80
+ // A stalled memory call must NEVER wedge the
82
81
  // compact//clear path. Bound every recall-fasttrack memory call with a short
83
82
  // local timeout: on timeout we abort (best-effort cancel via a chained signal)
84
83
  // and treat it exactly like an RPC failure, so compaction proceeds WITHOUT
@@ -93,7 +92,7 @@ function recallMemoryTimeoutMs(session) {
93
92
  // misconfigured tiny value can't turn the bound into a busy no-wait.
94
93
  return Math.max(250, configured || RECALL_MEMORY_CALL_TIMEOUT_MS);
95
94
  }
96
- // Cold-start allowance (clear/manual path only): a booting memory daemon can
95
+ // Cold-start allowance (clear/manual path only): a booting memory runtime can
97
96
  // miss the tight first bound (waitForPort + first-RPC warmup ~2-10s). On a
98
97
  // timeout we retry ONCE with a longer bound before honoring the bail-to-
99
98
  // semantic contract, so a rebooting runtime succeeds instead of instantly
@@ -1,7 +1,8 @@
1
1
  // Steering / pending-message queue with sync buffered + atomic-file persistence.
2
2
  // Extracted verbatim from manager.mjs (behavior-preserving).
3
3
  import { join } from 'path';
4
- import { readFileSync, statSync } from 'fs';
4
+ import { readFileSync } from 'fs';
5
+ import { stat } from 'fs/promises';
5
6
  import { createHash, randomBytes } from 'crypto';
6
7
  import { resolvePluginData } from '../../../../shared/plugin-paths.mjs';
7
8
  import { updateJsonAtomic } from '../../../../shared/atomic-file.mjs';
@@ -20,8 +21,8 @@ const PENDING_ORPHAN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
21
  const PENDING_ORPHAN_GRACE_MS = 60 * 60 * 1000;
21
22
  // Replay window for genuine user/steering entries. A cross-surface submit is
22
23
  // meant for a LIVE owner; entries that predate this process and exceeded the
23
- // window are still DELIVERED (CC parity, reference messageQueueManager.ts:
24
- // queued user input is never silently discarded), but annotated with an
24
+ // window are still DELIVERED (queued user input is never silently
25
+ // discarded), but annotated with an
25
26
  // explicit late-delivery header so a session resumed hours later reads them
26
27
  // as clearly-late input instead of a surprise self-injection.
27
28
  const STALE_USER_INJECTION_TTL_MS = 30 * 60 * 1000;
@@ -44,7 +45,7 @@ function isStaleUserInjection(entry, now = Date.now()) {
44
45
  return (now - enqueuedAt) > STALE_USER_INJECTION_TTL_MS;
45
46
  }
46
47
 
47
- // CC parity: never silently discard queued user input. A stale entry is
48
+ // Never silently discard queued user input. A stale entry is
48
49
  // delivered with this explicit age-annotated header so neither the user nor
49
50
  // the model mistakes it for fresh input.
50
51
  function lateDeliveryText(text, entry, now = Date.now()) {
@@ -823,7 +824,7 @@ export function hydratePendingMessages(sessionId, options = {}) {
823
824
  if (!id || inDelivery.has(id) || acked.has(id)) return false;
824
825
  return true;
825
826
  });
826
- // CC parity: stale genuine user/steering entries DELIVER with a
827
+ // Stale genuine user/steering entries DELIVER with a
827
828
  // late-delivery header instead of being silently dropped (the old
828
829
  // behavior discarded them; user report: remote/steering sends
829
830
  // silently ignored around owner restarts). Completion entries
@@ -1092,8 +1093,14 @@ export function enqueueRemotePendingMessage(sessionId, message) {
1092
1093
  // several sessions (desktop tabs, TUI + engine hosts) and a single shared
1093
1094
  // counter let the first drain of a tick swallow the mtime bump for every
1094
1095
  // other session, stranding their foreign submits until the next spool write.
1095
- const FOREIGN_SPOOL_SCAN_LIMIT = 64;
1096
+ const FOREIGN_SPOOL_SCAN_LIMIT = Math.max(
1097
+ 128,
1098
+ Number(process.env.MIXDOG_FOREIGN_SPOOL_SCAN_LIMIT) || 512,
1099
+ );
1096
1100
  const _foreignSpoolScanMtimes = new Map();
1101
+ const _foreignDrainRequests = new Map();
1102
+ let _foreignDrainScheduled = false;
1103
+ let _foreignDrainRunning = false;
1097
1104
 
1098
1105
  function _rememberForeignSpoolScan(sessionId, mtime) {
1099
1106
  _foreignSpoolScanMtimes.delete(sessionId);
@@ -1105,28 +1112,7 @@ function _rememberForeignSpoolScan(sessionId, mtime) {
1105
1112
  }
1106
1113
  }
1107
1114
 
1108
- /**
1109
- * Owner-side drain of FOREIGN user injections for a session this process
1110
- * owns: atomically removes (and returns the text of) genuine user/steering
1111
- * entries that were persisted by ANOTHER process — entries known locally
1112
- * (own steering buffers, hydrated, in-delivery, acked) and completion/
1113
- * internal-notification entries are left untouched for the normal
1114
- * askSession hydrate path.
1115
- */
1116
- export async function drainForeignUserInjections(sessionId) {
1117
- if (!isValidPendingSessionId(sessionId)) return [];
1118
- // DELIVERY path, not cleanup: this takes rows out of the spool and hands
1119
- // them to a live turn. A tombstoned (or otherwise invalidated) session may
1120
- // neither receive nor lose input, so the tombstone-TOLERANT epoch check
1121
- // used by the explicit cleanup mutations (ack / clear) is wrong here — the
1122
- // token is refused outright when it was captured from a closed session.
1123
- // Checked BEFORE the mtime memo so a later reopen still sees this spool
1124
- // state as unscanned.
1125
- const epochToken = currentPendingLifecycleToken(sessionId);
1126
- if (pendingLifecycleInvalidated(sessionId)) return [];
1127
- let mtime = 0;
1128
- try { mtime = statSync(pendingMessagesPath()).mtimeMs || 0; } catch { return []; }
1129
- if (_foreignSpoolScanMtimes.get(sessionId) === mtime) return [];
1115
+ function _foreignLocalIds(sessionId) {
1130
1116
  const localIds = new Set();
1131
1117
  for (const map of [_sessionPendingMessages, _pendingPersistBuffers, _hydratedPendingMessages]) {
1132
1118
  for (const entry of map.get(sessionId) || []) {
@@ -1146,59 +1132,86 @@ export async function drainForeignUserInjections(sessionId) {
1146
1132
  if (typeof id === 'string' && id) localIds.add(id);
1147
1133
  }
1148
1134
  } catch { /* ledger unavailable — in-memory sets still guard */ }
1149
- const taken = [];
1150
- // The mtime memo may ONLY be armed by a scan that actually reached a
1151
- // lifecycle-valid decision under the spool lock. Arming it upfront meant a
1152
- // drain refused in-lock (a close/detach/reopen landing in the window) still
1153
- // recorded this spool state as "seen": after the reopen the new owner
1154
- // polled the same unchanged mtime and skipped it, stranding the rows until
1155
- // some unrelated spool WRITE bumped the file. A refusal must leave the
1156
- // spool immediately pollable again.
1157
- let lifecycleDecided = false;
1135
+ return localIds;
1136
+ }
1137
+
1138
+ function _settleForeignDrain(request, value) {
1139
+ for (const resolve of request.waiters) {
1140
+ try { resolve(value); } catch { /* a consumer cannot break the batch */ }
1141
+ }
1142
+ }
1143
+
1144
+ function _scheduleForeignDrainBatch() {
1145
+ if (_foreignDrainScheduled || _foreignDrainRunning || _foreignDrainRequests.size === 0) return;
1146
+ _foreignDrainScheduled = true;
1147
+ setImmediate(() => {
1148
+ _foreignDrainScheduled = false;
1149
+ void _flushForeignDrainBatch();
1150
+ });
1151
+ }
1152
+
1153
+ async function _flushForeignDrainBatch() {
1154
+ if (_foreignDrainRunning) return;
1155
+ _foreignDrainRunning = true;
1156
+ const batch = [..._foreignDrainRequests.values()];
1157
+ _foreignDrainRequests.clear();
1158
1158
  try {
1159
- // ASYNC spool lock. The sync variant parked the owning process's event
1160
- // loop in Atomics.wait for the whole timeout: in the daemon (PTYs,
1161
- // engines, IPC and SSE fan-out share one loop) every poll that lost the
1162
- // race froze the entire app, and it could never win against an async
1163
- // holder in the SAME process. Waiting off the loop also lets the
1164
- // in-process lock queue serialize sibling sessions for free.
1159
+ let mtime = 0;
1160
+ try { mtime = (await stat(pendingMessagesPath())).mtimeMs || 0; }
1161
+ catch {
1162
+ for (const request of batch) _settleForeignDrain(request, []);
1163
+ return;
1164
+ }
1165
+ const candidates = batch.filter((request) =>
1166
+ _foreignSpoolScanMtimes.get(request.sessionId) !== mtime
1167
+ && !pendingLifecycleInvalidated(request.sessionId, request.epochToken));
1168
+ for (const request of batch) {
1169
+ if (!candidates.includes(request)) _settleForeignDrain(request, []);
1170
+ }
1171
+ if (candidates.length === 0) return;
1172
+ for (const request of candidates) {
1173
+ request.localIds = _foreignLocalIds(request.sessionId);
1174
+ request.taken = [];
1175
+ request.lifecycleDecided = false;
1176
+ }
1165
1177
  await updateJsonAtomic(pendingMessagesPath(), (raw) => {
1166
- // Same authority re-read INSIDE the spool lock: a close/detach
1167
- // landing in this window must neither deliver to the old owner nor
1168
- // remove the reopened owner's rows.
1169
- runPendingTestHook('foreignDrain:beforeCommit', { sessionId });
1170
- if (pendingLifecycleInvalidated(sessionId, epochToken)) return undefined;
1171
- // Authoritative from here on: whatever this pass decides (take,
1172
- // drop-stale, or "nothing foreign here") is a real answer for THIS
1173
- // spool state, so the memo may skip re-scanning it.
1174
- lifecycleDecided = true;
1175
1178
  const next = normalizePendingStore(raw);
1176
- const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
1177
- if (q.length === 0) return undefined;
1178
- const kept = [];
1179
- for (const entry of q) {
1180
- const id = pendingMessageId(entry);
1181
- const text = pendingMessageText(entry);
1182
- const foreignUser = id && !localIds.has(id)
1183
- && !isCompletionNotificationEntry(entry)
1184
- && !isLegacyUnmarkedCompletionNotification(text)
1185
- && text && !isInternalRuntimeNotificationText(text);
1186
- // CC parity: stale foreign submits still deliver, carrying a
1187
- // late-delivery header instead of being silently removed
1188
- // (user report: remote sends silently discarded around owner
1189
- // restarts).
1190
- if (foreignUser && isStaleUserInjection(entry)) {
1191
- taken.push({ text: lateDeliveryText(text, entry), id });
1179
+ let changed = false;
1180
+ for (const request of candidates) {
1181
+ const { sessionId, epochToken, localIds, taken } = request;
1182
+ // Same authority re-read INSIDE the spool lock: a close/detach
1183
+ // landing in this window must neither deliver to the old owner
1184
+ // nor remove the reopened owner's rows.
1185
+ runPendingTestHook('foreignDrain:beforeCommit', { sessionId });
1186
+ if (pendingLifecycleInvalidated(sessionId, epochToken)) continue;
1187
+ request.lifecycleDecided = true;
1188
+ const q = Array.isArray(next.sessions[sessionId]) ? next.sessions[sessionId] : [];
1189
+ if (q.length === 0) continue;
1190
+ const kept = [];
1191
+ for (const entry of q) {
1192
+ const id = pendingMessageId(entry);
1193
+ const text = pendingMessageText(entry);
1194
+ const foreignUser = id && !localIds.has(id)
1195
+ && !isCompletionNotificationEntry(entry)
1196
+ && !isLegacyUnmarkedCompletionNotification(text)
1197
+ && text && !isInternalRuntimeNotificationText(text);
1198
+ if (foreignUser && isStaleUserInjection(entry)) {
1199
+ taken.push({ text: lateDeliveryText(text, entry), id });
1200
+ } else if (foreignUser) {
1201
+ taken.push({ text, id });
1202
+ } else {
1203
+ kept.push(entry);
1204
+ }
1205
+ }
1206
+ if (taken.length === 0) continue;
1207
+ changed = true;
1208
+ if (kept.length > 0) next.sessions[sessionId] = kept;
1209
+ else {
1210
+ delete next.sessions[sessionId];
1211
+ if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
1192
1212
  }
1193
- else if (foreignUser) taken.push({ text, id });
1194
- else kept.push(entry);
1195
- }
1196
- if (taken.length === 0) return undefined;
1197
- if (kept.length > 0) next.sessions[sessionId] = kept;
1198
- else {
1199
- delete next.sessions[sessionId];
1200
- if (next.sessionTouchedAt) delete next.sessionTouchedAt[sessionId];
1201
1213
  }
1214
+ if (!changed) return undefined;
1202
1215
  next.updatedAt = Date.now();
1203
1216
  return next;
1204
1217
  }, {
@@ -1206,18 +1219,63 @@ export async function drainForeignUserInjections(sessionId) {
1206
1219
  lock: true,
1207
1220
  mode: PENDING_MESSAGES_MODE,
1208
1221
  fsync: false,
1209
- // Delivery polling must never wait on an async spool writer in the
1210
- // same daemon process: waiting synchronously prevents that writer
1211
- // from ever reaching its finally/release path.
1222
+ // Delivery polling must never wait behind a spool writer. A later
1223
+ // fs.watch/poll tick retries unchanged requests.
1212
1224
  timeoutMs: 0,
1213
1225
  });
1226
+ for (const request of candidates) {
1227
+ if (request.lifecycleDecided) _rememberForeignSpoolScan(request.sessionId, mtime);
1228
+ _settleForeignDrain(request, request.taken);
1229
+ }
1214
1230
  } catch (err) {
1215
- if (err?.code === 'ELOCKCONTENDED') return [];
1216
- try { process.stderr.write(`[session] foreign-injection drain failed sessionId=${sessionId}: ${err?.message || err}\n`); } catch {}
1217
- return [];
1231
+ if (err?.code !== 'ELOCKCONTENDED') {
1232
+ try { process.stderr.write(`[session] foreign-injection drain failed: ${err?.message || err}\n`); } catch {}
1233
+ }
1234
+ for (const request of batch) {
1235
+ if (request.waiters.length > 0) _settleForeignDrain(request, []);
1236
+ }
1237
+ } finally {
1238
+ _foreignDrainRunning = false;
1239
+ _scheduleForeignDrainBatch();
1218
1240
  }
1219
- if (lifecycleDecided) _rememberForeignSpoolScan(sessionId, mtime);
1220
- return taken;
1241
+ }
1242
+
1243
+ /**
1244
+ * Owner-side drain of FOREIGN user injections for a session this process
1245
+ * owns: atomically removes (and returns the text of) genuine user/steering
1246
+ * entries that were persisted by ANOTHER process — entries known locally
1247
+ * (own steering buffers, hydrated, in-delivery, acked) and completion/
1248
+ * internal-notification entries are left untouched for the normal
1249
+ * askSession hydrate path.
1250
+ */
1251
+ export async function drainForeignUserInjections(sessionId) {
1252
+ if (!isValidPendingSessionId(sessionId)) return [];
1253
+ // DELIVERY path, not cleanup: this takes rows out of the spool and hands
1254
+ // them to a live turn. A tombstoned (or otherwise invalidated) session may
1255
+ // neither receive nor lose input, so the tombstone-TOLERANT epoch check
1256
+ // used by the explicit cleanup mutations (ack / clear) is wrong here — the
1257
+ // token is refused outright when it was captured from a closed session.
1258
+ // Checked BEFORE the mtime memo so a later reopen still sees this spool
1259
+ // state as unscanned.
1260
+ const epochToken = currentPendingLifecycleToken(sessionId);
1261
+ if (pendingLifecycleInvalidated(sessionId)) return [];
1262
+ return new Promise((resolve) => {
1263
+ const existing = _foreignDrainRequests.get(sessionId);
1264
+ if (existing && existing.epochToken === epochToken) {
1265
+ existing.waiters.push(resolve);
1266
+ } else {
1267
+ if (existing) _settleForeignDrain(existing, []);
1268
+ _foreignDrainRequests.set(sessionId, {
1269
+ sessionId,
1270
+ epochToken,
1271
+ waiters: [resolve],
1272
+ localIds: null,
1273
+ taken: [],
1274
+ lifecycleDecided: false,
1275
+ });
1276
+ }
1277
+ _scheduleForeignDrainBatch();
1278
+ });
1221
1279
  }
1222
1280
 
1223
1281
  export function drainPendingMessages(sessionId) {