mixdog 0.9.63 → 0.9.65

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 (408) hide show
  1. package/README.md +3 -0
  2. package/package.json +9 -2
  3. package/scripts/code-graph-description-contract.mjs +3 -3
  4. package/scripts/verify-release-assets.mjs +2 -2
  5. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +11 -14
  6. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +6 -6
  7. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
  8. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +1 -0
  9. package/src/runtime/agent/orchestrator/config.mjs +19 -124
  10. package/src/runtime/agent/orchestrator/context/collect.mjs +6 -6
  11. package/src/runtime/agent/orchestrator/dispatch-persist.mjs +2 -2
  12. package/src/runtime/agent/orchestrator/internal-agents.mjs +2 -2
  13. package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +1 -1
  14. package/src/runtime/agent/orchestrator/mcp/client.mjs +4 -4
  15. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +2 -2
  16. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +4 -4
  17. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +2 -2
  18. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +4 -4
  19. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +333 -0
  20. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +2 -2
  21. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-credentials.mjs +4 -4
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +1 -1
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +3 -277
  24. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +2 -2
  25. package/src/runtime/agent/orchestrator/providers/custom-tool-wire.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +2 -2
  27. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +6 -6
  28. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +196 -0
  29. package/src/runtime/agent/orchestrator/providers/grok-oauth-tokens.mjs +460 -0
  30. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +14 -593
  31. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +1 -1
  32. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +1 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +1 -1
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +4 -4
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +5 -280
  37. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +357 -0
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +216 -0
  39. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +2 -188
  40. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -2
  41. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +1 -1
  43. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/providers/registry.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +2 -2
  46. package/src/runtime/agent/orchestrator/providers/statusline-route-meta.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/session/cache/prefetch-cache.mjs +1 -1
  48. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +3 -3
  49. package/src/runtime/agent/orchestrator/session/compact/budget.mjs +2 -2
  50. package/src/runtime/agent/orchestrator/session/compact/file-reattach.mjs +2 -2
  51. package/src/runtime/agent/orchestrator/session/compact/summary-schema.mjs +1 -1
  52. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +1 -1
  53. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +2 -2
  54. package/src/runtime/agent/orchestrator/session/context-utils.mjs +4 -4
  55. package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +1 -1
  56. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +17 -0
  57. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +1 -1
  58. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +18 -26
  59. package/src/runtime/agent/orchestrator/session/loop/termination.mjs +1 -1
  60. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +3 -3
  61. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +1 -0
  62. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  63. package/src/runtime/agent/orchestrator/session/manager/delivered-completions.mjs +1 -1
  64. package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +1 -1
  65. package/src/runtime/agent/orchestrator/session/store/live-state.mjs +49 -0
  66. package/src/runtime/agent/orchestrator/session/store/load-cache.mjs +49 -0
  67. package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +260 -0
  68. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +72 -0
  69. package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +163 -0
  70. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +1 -1
  71. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +1 -1
  72. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +4 -1
  73. package/src/runtime/agent/orchestrator/session/store.mjs +9 -561
  74. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +1 -1
  75. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +2 -2
  76. package/src/runtime/agent/orchestrator/stall-policy.mjs +11 -11
  77. package/src/runtime/agent/orchestrator/tools/bash-policy-scan.mjs +1 -1
  78. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +2 -2
  79. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +1 -1
  80. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +2 -2
  81. package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +1 -1
  82. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +2 -154
  83. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +1 -1
  84. package/src/runtime/agent/orchestrator/tools/builtin/cwd-utils.mjs +1 -1
  85. package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +1 -1
  86. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  87. package/src/runtime/agent/orchestrator/tools/builtin/glob-walk.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/path-locks.mjs +1 -1
  89. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +1 -1
  90. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +2 -2
  91. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +2 -3
  92. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +4 -4
  93. package/src/runtime/agent/orchestrator/tools/builtin/read-open.mjs +1 -1
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +1 -1
  95. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +2 -2
  96. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +5 -5
  97. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +1 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +2 -2
  99. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +2 -2
  100. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +519 -0
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +5 -449
  102. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-helpers.mjs +1 -1
  103. package/src/runtime/agent/orchestrator/tools/builtin/task-tool.mjs +212 -0
  104. package/src/runtime/agent/orchestrator/tools/builtin.mjs +1 -1
  105. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +1 -7
  106. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +9 -36
  107. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +15 -3
  108. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +429 -0
  109. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +2 -395
  110. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +2 -2
  111. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +1 -1
  112. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +1 -1
  113. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +1 -1
  114. package/src/runtime/agent/orchestrator/tools/code-graph-state.mjs +3 -3
  115. package/src/runtime/agent/orchestrator/tools/mutation-planner.mjs +5 -5
  116. package/src/runtime/agent/orchestrator/tools/next-call-utils.mjs +1 -1
  117. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +1 -1
  118. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +7 -7
  119. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +72 -9
  120. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +4 -4
  121. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +1 -1
  122. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +1 -1
  123. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -4
  124. package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
  125. package/src/runtime/agent/orchestrator/tools/result-compression.mjs +3 -3
  126. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +2 -444
  127. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +505 -0
  128. package/src/runtime/agent/orchestrator/tools/shell-exec-policy.mjs +2 -2
  129. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +1 -1
  130. package/src/runtime/channels/lib/backend-dispatch.mjs +20 -5
  131. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  132. package/src/runtime/channels/lib/telegram-format.mjs +1 -1
  133. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +4 -4
  134. package/src/runtime/channels/tool-defs.mjs +2 -3
  135. package/src/runtime/memory/index.mjs +1 -5
  136. package/src/runtime/memory/lib/core-memory-candidates.mjs +357 -0
  137. package/src/runtime/memory/lib/core-memory-store.mjs +7 -357
  138. package/src/runtime/memory/lib/cycle-signatures.mjs +1 -1
  139. package/src/runtime/memory/lib/embedding-model-config.mjs +1 -1
  140. package/src/runtime/memory/lib/embedding-provider.mjs +1 -5
  141. package/src/runtime/memory/lib/embedding-worker.mjs +1 -5
  142. package/src/runtime/memory/lib/ko-morph.mjs +2 -2
  143. package/src/runtime/memory/lib/memory-config-flags.mjs +2 -5
  144. package/src/runtime/memory/lib/memory-cycle-requests.mjs +3 -7
  145. package/src/runtime/memory/lib/memory-cycle1.mjs +4 -8
  146. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +1 -1
  147. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +1 -1
  148. package/src/runtime/memory/lib/memory-cycle2-shared.mjs +2 -5
  149. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -5
  150. package/src/runtime/memory/lib/memory-embed.mjs +2 -6
  151. package/src/runtime/memory/lib/memory-log.mjs +8 -0
  152. package/src/runtime/memory/lib/memory-ops-policy.mjs +4 -8
  153. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +1 -1
  154. package/src/runtime/memory/lib/memory-recall-store.mjs +2 -193
  155. package/src/runtime/memory/lib/memory-score.mjs +1 -1
  156. package/src/runtime/memory/lib/memory-text-utils.mjs +2 -2
  157. package/src/runtime/memory/lib/memory.mjs +2 -6
  158. package/src/runtime/memory/lib/pg/adapter.mjs +2 -6
  159. package/src/runtime/memory/lib/pg/process.mjs +1 -5
  160. package/src/runtime/memory/lib/pg/supervisor.mjs +1 -5
  161. package/src/runtime/memory/lib/recall-format.mjs +2 -2
  162. package/src/runtime/memory/lib/recall-scoring.mjs +195 -0
  163. package/src/runtime/memory/lib/runtime-fetcher.mjs +1 -5
  164. package/src/runtime/memory/lib/trace-store.mjs +2 -6
  165. package/src/runtime/search/lib/http-fetch.mjs +6 -6
  166. package/src/runtime/search/lib/web-tools.mjs +2 -2
  167. package/src/runtime/shared/buffered-appender.mjs +1 -1
  168. package/src/runtime/shared/child-guardian.mjs +1 -1
  169. package/src/runtime/shared/child-spawn-gate.mjs +1 -1
  170. package/src/runtime/shared/config.mjs +4 -4
  171. package/src/runtime/shared/launcher-control.mjs +9 -10
  172. package/src/runtime/shared/llm/index.mjs +5 -20
  173. package/src/runtime/shared/markdown-frontmatter.mjs +1 -1
  174. package/src/runtime/shared/memory-snapshot.mjs +4 -4
  175. package/src/runtime/shared/pristine-execution.mjs +1 -1
  176. package/src/runtime/shared/process-lifecycle.mjs +0 -1
  177. package/src/runtime/shared/process-shutdown.mjs +1 -1
  178. package/src/runtime/shared/resource-admission.mjs +2 -2
  179. package/src/runtime/shared/schedule-session-run.mjs +1 -1
  180. package/src/runtime/shared/schedules-db.mjs +1 -1
  181. package/src/runtime/shared/service-discovery.mjs +2 -2
  182. package/src/runtime/shared/staged-update.mjs +6 -6
  183. package/src/runtime/shared/tool-execution-contract.mjs +1 -1
  184. package/src/runtime/shared/tool-primitives.mjs +1 -1
  185. package/src/runtime/shared/tool-surface.mjs +2 -2
  186. package/src/runtime/shared/turn-snapshot.mjs +184 -0
  187. package/src/runtime/shared/update-checker.mjs +1 -1
  188. package/src/runtime/shared/user-cwd.mjs +3 -3
  189. package/src/runtime/shared/user-data-guard.mjs +4 -4
  190. package/src/runtime/shared/workspace-router.mjs +5 -5
  191. package/src/session-runtime/config-helpers.mjs +2 -3
  192. package/src/session-runtime/config-lifecycle.mjs +1 -1
  193. package/src/session-runtime/hitch-profile.mjs +1 -1
  194. package/src/session-runtime/lifecycle-api.mjs +6 -4
  195. package/src/session-runtime/model-recency.mjs +5 -5
  196. package/src/session-runtime/plugin-mcp.mjs +1 -1
  197. package/src/session-runtime/provider-request-snapshot.mjs +338 -0
  198. package/src/session-runtime/runtime-core.mjs +4 -0
  199. package/src/session-runtime/session-text.mjs +4 -4
  200. package/src/session-runtime/session-turn-api.mjs +7 -0
  201. package/src/session-runtime/tool-catalog-schema.mjs +197 -0
  202. package/src/session-runtime/tool-catalog.mjs +6 -483
  203. package/src/session-runtime/tool-defs.mjs +1 -1
  204. package/src/standalone/agent-task-status.mjs +1 -1
  205. package/src/standalone/agent-tool/helpers.mjs +2 -2
  206. package/src/standalone/channel-admin.mjs +4 -4
  207. package/src/standalone/explore-tool.mjs +3 -3
  208. package/src/standalone/hook-bus/config.mjs +1 -1
  209. package/src/standalone/hook-bus/constants.mjs +1 -1
  210. package/src/standalone/hook-bus/rules.mjs +1 -1
  211. package/src/standalone/projects.mjs +1 -1
  212. package/src/standalone/provider-admin.mjs +2 -2
  213. package/src/tui/App.jsx +1 -1
  214. package/src/tui/app/app-format.mjs +2 -2
  215. package/src/tui/app/clipboard.mjs +3 -3
  216. package/src/tui/app/input-parsers.mjs +1 -1
  217. package/src/tui/app/text-layout.mjs +1 -1
  218. package/src/tui/app/transcript-row-estimate.mjs +360 -0
  219. package/src/tui/app/transcript-window.mjs +5 -347
  220. package/src/tui/app/use-mouse-input.mjs +1 -1
  221. package/src/tui/app/use-prompt-handlers.mjs +1 -1
  222. package/src/tui/components/tool-execution/surface-detail.mjs +9 -9
  223. package/src/tui/components/tool-execution/text-format.mjs +2 -2
  224. package/src/tui/components/tool-output-format.mjs +7 -7
  225. package/src/tui/display-width.mjs +4 -4
  226. package/src/tui/dist/index.mjs +187 -99
  227. package/src/tui/engine/agent-envelope.mjs +5 -5
  228. package/src/tui/engine/live-share.mjs +100 -0
  229. package/src/tui/engine/notification-plan.mjs +1 -1
  230. package/src/tui/engine/queue-helpers.mjs +3 -3
  231. package/src/tui/engine/render-timing.mjs +4 -5
  232. package/src/tui/engine/session-api-ext.mjs +6 -3
  233. package/src/tui/engine/tool-result-status.mjs +5 -5
  234. package/src/tui/engine/tool-result-text.mjs +1 -1
  235. package/src/tui/engine/transcript-spill.mjs +585 -0
  236. package/src/tui/engine/tui-steering-persist.mjs +1 -1
  237. package/src/tui/engine.mjs +29 -474
  238. package/src/tui/lib/voice-setup.mjs +1 -1
  239. package/src/tui/markdown/format-token.mjs +1 -1
  240. package/src/tui/markdown/render-ansi.mjs +1 -1
  241. package/src/tui/markdown/table-layout.mjs +4 -4
  242. package/src/tui/paste-attachments.mjs +1 -1
  243. package/src/tui/prompt-history-store.mjs +3 -3
  244. package/src/tui/statusline-ansi-bridge.mjs +2 -2
  245. package/src/tui/theme.mjs +1 -1
  246. package/src/tui/transcript-tool-failures.mjs +2 -2
  247. package/src/ui/ansi.mjs +0 -9
  248. package/src/ui/statusline-format.mjs +2 -4
  249. package/scripts/_bench-cwc.json +0 -20
  250. package/scripts/_jitter-fuzz.jsx +0 -42
  251. package/scripts/_jitter-fuzz2.jsx +0 -30
  252. package/scripts/_jitter-probe.jsx +0 -35
  253. package/scripts/_jp2.jsx +0 -16
  254. package/scripts/_smoke_wd.mjs +0 -7
  255. package/scripts/abort-recovery-test.mjs +0 -175
  256. package/scripts/agent-dispatch-abort-compose-test.mjs +0 -31
  257. package/scripts/agent-live-arg-guard-smoke.mjs +0 -20
  258. package/scripts/agent-live-path-suffix-smoke.mjs +0 -34
  259. package/scripts/agent-live-toolcall-args-smoke.mjs +0 -45
  260. package/scripts/agent-loop-policy-test.mjs +0 -37
  261. package/scripts/agent-model-liveness-test.mjs +0 -763
  262. package/scripts/agent-parallel-smoke.mjs +0 -435
  263. package/scripts/agent-route-batch-test.mjs +0 -40
  264. package/scripts/agent-tag-reuse-smoke.mjs +0 -441
  265. package/scripts/agent-terminal-reap-test.mjs +0 -252
  266. package/scripts/agent-trace-io-test.mjs +0 -133
  267. package/scripts/ansi-color-capability-test.mjs +0 -90
  268. package/scripts/anthropic-admission-retry-integration-test.mjs +0 -119
  269. package/scripts/anthropic-maxtokens-test.mjs +0 -119
  270. package/scripts/anthropic-oauth-refresh-race-test.mjs +0 -397
  271. package/scripts/anthropic-transport-policy-test.mjs +0 -726
  272. package/scripts/apply-patch-edit-smoke.mjs +0 -71
  273. package/scripts/arg-guard-test.mjs +0 -93
  274. package/scripts/async-notify-settlement-test.mjs +0 -99
  275. package/scripts/atomic-lock-tryonce-test.mjs +0 -125
  276. package/scripts/background-task-meta-smoke.mjs +0 -38
  277. package/scripts/bench-run.mjs +0 -508
  278. package/scripts/boot-smoke.mjs +0 -137
  279. package/scripts/build-runtime-windows.ps1 +0 -242
  280. package/scripts/channel-daemon-smoke.mjs +0 -1103
  281. package/scripts/code-graph-aggregate-cwd-test.mjs +0 -158
  282. package/scripts/code-graph-disk-hit-test.mjs +0 -290
  283. package/scripts/code-graph-dispatch-test.mjs +0 -96
  284. package/scripts/code-graph-root-federation-test.mjs +0 -273
  285. package/scripts/compact-active-turn-test.mjs +0 -68
  286. package/scripts/compact-file-reattach-test.mjs +0 -88
  287. package/scripts/compact-pressure-test.mjs +0 -788
  288. package/scripts/compact-prior-context-flatten-test.mjs +0 -252
  289. package/scripts/compact-recall-digest-test.mjs +0 -57
  290. package/scripts/compact-smoke.mjs +0 -1017
  291. package/scripts/compact-trigger-migration-smoke.mjs +0 -261
  292. package/scripts/compacted-placeholder-scrub-test.mjs +0 -63
  293. package/scripts/context-mcp-metering-test.mjs +0 -1406
  294. package/scripts/debounced-skills-async-save-test.mjs +0 -57
  295. package/scripts/deferred-tool-loading-test.mjs +0 -250
  296. package/scripts/desktop-session-bridge-test.mjs +0 -1013
  297. package/scripts/dispatch-persist-recovery-test.mjs +0 -141
  298. package/scripts/embedding-worker-exit-test.mjs +0 -76
  299. package/scripts/execution-completion-dedup-test.mjs +0 -205
  300. package/scripts/execution-pending-resume-kick-test.mjs +0 -151
  301. package/scripts/execution-resume-esc-integration-test.mjs +0 -176
  302. package/scripts/explore-bench-tmp.mjs +0 -36
  303. package/scripts/explore-bench.mjs +0 -248
  304. package/scripts/explore-prompt-policy-test.mjs +0 -256
  305. package/scripts/explore-timeout-cancel-test.mjs +0 -345
  306. package/scripts/find-fuzzy-hidden-test.mjs +0 -267
  307. package/scripts/forwarder-rebind-tail-test.mjs +0 -67
  308. package/scripts/freevar-smoke.mjs +0 -98
  309. package/scripts/gemini-provider-test.mjs +0 -1448
  310. package/scripts/generate-oc-icons.mjs +0 -11
  311. package/scripts/grok-oauth-refresh-race-test.mjs +0 -273
  312. package/scripts/headless-pristine-execution-test.mjs +0 -614
  313. package/scripts/hook-bus-test.mjs +0 -552
  314. package/scripts/ingest-pure-conversation-smoke.mjs +0 -175
  315. package/scripts/internal-comms-bench-test.mjs +0 -226
  316. package/scripts/internal-comms-bench.mjs +0 -853
  317. package/scripts/internal-comms-smoke.mjs +0 -277
  318. package/scripts/internal-tools-normalization-test.mjs +0 -62
  319. package/scripts/interrupted-turn-history-test.mjs +0 -399
  320. package/scripts/lead-workflow-smoke.mjs +0 -592
  321. package/scripts/lifecycle-api-test.mjs +0 -137
  322. package/scripts/live-share-test.mjs +0 -148
  323. package/scripts/live-worker-smoke.mjs +0 -333
  324. package/scripts/log-writer-guard-smoke.mjs +0 -131
  325. package/scripts/maintenance-default-routes-test.mjs +0 -164
  326. package/scripts/max-output-recovery-persist-test.mjs +0 -81
  327. package/scripts/max-output-recovery-test.mjs +0 -368
  328. package/scripts/mcp-client-normalization-test.mjs +0 -45
  329. package/scripts/mcp-grace-deferred-test.mjs +0 -225
  330. package/scripts/memory-core-input-test.mjs +0 -167
  331. package/scripts/memory-cycle-routing-test.mjs +0 -111
  332. package/scripts/memory-meta-concurrency-test.mjs +0 -20
  333. package/scripts/memory-pg-recovery-test.mjs +0 -59
  334. package/scripts/memory-rule-contract-test.mjs +0 -93
  335. package/scripts/model-list-sanitize-test.mjs +0 -37
  336. package/scripts/mouse-tracking-restore-smoke.mjs +0 -107
  337. package/scripts/native-edit-wire-test.mjs +0 -152
  338. package/scripts/notify-completion-mirror-test.mjs +0 -73
  339. package/scripts/openai-oauth-refresh-race-test.mjs +0 -120
  340. package/scripts/openai-oauth-ws-1006-retry-test.mjs +0 -954
  341. package/scripts/openai-ws-early-settle-test.mjs +0 -216
  342. package/scripts/output-style-bench.mjs +0 -292
  343. package/scripts/output-style-smoke.mjs +0 -145
  344. package/scripts/parent-abort-link-test.mjs +0 -90
  345. package/scripts/patch-binary-cache-test.mjs +0 -181
  346. package/scripts/path-suffix-test.mjs +0 -57
  347. package/scripts/pending-completion-drop-test.mjs +0 -239
  348. package/scripts/pending-messages-lock-nonblocking-test.mjs +0 -62
  349. package/scripts/pretool-ask-runtime-test.mjs +0 -54
  350. package/scripts/process-lifecycle-test.mjs +0 -461
  351. package/scripts/prompt-immediate-render-test.mjs +0 -89
  352. package/scripts/prompt-input-parity-test.mjs +0 -145
  353. package/scripts/provider-admission-scheduler-test.mjs +0 -681
  354. package/scripts/provider-contract-test.mjs +0 -632
  355. package/scripts/provider-stream-stall-test.mjs +0 -276
  356. package/scripts/provider-toolcall-test.mjs +0 -3063
  357. package/scripts/reactive-compact-persist-smoke.mjs +0 -203
  358. package/scripts/recall-bench.mjs +0 -359
  359. package/scripts/resource-admission-test.mjs +0 -792
  360. package/scripts/result-classification-test.mjs +0 -75
  361. package/scripts/rg-runner-test.mjs +0 -240
  362. package/scripts/routing-corpus-test.mjs +0 -349
  363. package/scripts/sanitize-tool-pairs-test.mjs +0 -260
  364. package/scripts/session-bench-cache-break-test.mjs +0 -102
  365. package/scripts/session-bench.mjs +0 -1708
  366. package/scripts/session-context-bench.mjs +0 -344
  367. package/scripts/session-heartbeat-lifecycle-test.mjs +0 -68
  368. package/scripts/session-ingest-compaction-smoke.mjs +0 -279
  369. package/scripts/session-ingest-smoke.mjs +0 -177
  370. package/scripts/session-orphan-sweep-test.mjs +0 -109
  371. package/scripts/set-effort-config-test.mjs +0 -41
  372. package/scripts/shell-failure-diagnostics-test.mjs +0 -280
  373. package/scripts/shell-hardening-test.mjs +0 -209
  374. package/scripts/shell-jobs-windows-hide-test.mjs +0 -164
  375. package/scripts/ship-mode-test.mjs +0 -98
  376. package/scripts/smoke-loop-failure-summary-test.mjs +0 -38
  377. package/scripts/smoke-loop-failure-summary.mjs +0 -16
  378. package/scripts/smoke-loop-report.mjs +0 -221
  379. package/scripts/smoke-loop.mjs +0 -202
  380. package/scripts/smoke-runtime-negative.ps1 +0 -106
  381. package/scripts/statusline-quota-hysteresis-test.mjs +0 -62
  382. package/scripts/steering-drain-buckets-test.mjs +0 -467
  383. package/scripts/steering-fold-provenance-test.mjs +0 -71
  384. package/scripts/streaming-tail-window-test.mjs +0 -175
  385. package/scripts/submit-commandbusy-race-test.mjs +0 -257
  386. package/scripts/task-bench.mjs +0 -205
  387. package/scripts/terminal-bench-isolation-guards-test.mjs +0 -102
  388. package/scripts/termio-input-smoke.mjs +0 -199
  389. package/scripts/tool-result-hook-test.mjs +0 -48
  390. package/scripts/tool-smoke.mjs +0 -2453
  391. package/scripts/tool-tui-presentation-test.mjs +0 -286
  392. package/scripts/toolcall-args-test.mjs +0 -239
  393. package/scripts/tui-ambiguous-width-test.mjs +0 -113
  394. package/scripts/tui-background-failure-smoke.mjs +0 -73
  395. package/scripts/tui-perf-run.ps1 +0 -26
  396. package/scripts/tui-render-smoke.mjs +0 -90
  397. package/scripts/tui-runtime-load-bench-entry.jsx +0 -608
  398. package/scripts/tui-runtime-load-bench.mjs +0 -50
  399. package/scripts/tui-store-frame-batch-test.mjs +0 -99
  400. package/scripts/tui-transcript-jitter-harness-entry.jsx +0 -383
  401. package/scripts/tui-transcript-perf-test.mjs +0 -934
  402. package/scripts/usage-metrics-epoch-smoke.mjs +0 -114
  403. package/scripts/verify-release-assets-test.mjs +0 -281
  404. package/scripts/web-fetch-routing-test.mjs +0 -158
  405. package/scripts/webhook-smoke.mjs +0 -201
  406. package/scripts/windows-hide-spawn-options-test.mjs +0 -19
  407. package/scripts/worker-notify-rejection-test.mjs +0 -51
  408. package/scripts/write-backpressure-test.mjs +0 -147
@@ -1,1103 +0,0 @@
1
- // Scripted two-client simulation for the machine-global channels daemon
2
- // transport (no Discord token needed — the channels runtime is stubbed).
3
- //
4
- // Verifies the transport + attach client contracts that later slices depend on:
5
- // 1. two TUIs attach to ONE daemon and each get an independent SSE stream;
6
- // 2. tool calls round-trip per client and carry the caller's leadPid;
7
- // 3. notifies are TARGETED (never broadcast) to the routing-pointer client;
8
- // 4. a bind-intent call (rebind_current_transcript) moves the pointer;
9
- // 5. once every client deregisters, the daemon self-shutdown fires.
10
- //
11
- // Run: node scripts/channel-daemon-smoke.mjs
12
- import os from 'node:os';
13
- import path from 'node:path';
14
- import http from 'node:http';
15
- import { EventEmitter } from 'node:events';
16
- import { spawn } from 'node:child_process';
17
- import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
18
- import { fileURLToPath } from 'node:url';
19
- import { createChannelDaemonTransport } from '../src/standalone/channel-daemon-transport.mjs';
20
- import { attachToDaemon } from '../src/standalone/channel-daemon-client.mjs';
21
- import { createParentBridge, setChannelNotifySink } from '../src/runtime/channels/lib/parent-bridge.mjs';
22
-
23
- const HERE = path.dirname(fileURLToPath(import.meta.url));
24
- const STUB_ENTRY = path.join(HERE, 'channel-daemon-stub.mjs');
25
-
26
- const delay = (ms) => new Promise((r) => setTimeout(r, ms));
27
- const waitFor = async (fn, ms = 1500) => {
28
- const end = Date.now() + ms;
29
- while (Date.now() < end) { if (fn()) return true; await delay(20); }
30
- return fn();
31
- };
32
- const waitForAsync = async (fn, ms = 1500) => {
33
- const end = Date.now() + ms;
34
- while (Date.now() < end) { if (await fn()) return true; await delay(20); }
35
- return await fn();
36
- };
37
- let failures = 0;
38
- function check(label, cond) {
39
- const ok = !!cond;
40
- if (!ok) failures++;
41
- console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`);
42
- }
43
-
44
- async function main() {
45
- const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-daemon-smoke-'));
46
- const discoveryPath = path.join(tmp, 'channel-daemon.json');
47
- let clientsEmptyFired = false;
48
- let sideEffects = 0; // counts non-idempotent 'reply' dispatches
49
-
50
- // Stub channels runtime: echoes the call + the caller identity the transport
51
- // resolved, so we can assert per-client leadPid threading.
52
- const handleCall = async (name, args, ctx) => {
53
- if (name === 'reply') { sideEffects++; await delay(40); } // simulate a side effect
54
- return { ok: true, name, args, leadPid: ctx.leadPid };
55
- };
56
-
57
- const transport = createChannelDaemonTransport({
58
- handleCall,
59
- discoveryPath,
60
- clientGraceMs: 250,
61
- sweepMs: 1000,
62
- onClientsEmpty: () => { clientsEmptyFired = true; },
63
- log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[daemon] ${m}`),
64
- });
65
- const { port, token } = await transport.start();
66
- const discovery = { port, token, pid: process.pid };
67
-
68
- const notA = [];
69
- const notB = [];
70
- const clientA = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
71
- const clientB = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
72
- await delay(150); // let both SSE streams attach
73
-
74
- check('two clients registered', transport._clientsForTest.size === 2);
75
-
76
- // (2) calls round-trip per client.
77
- const rA = await clientA.call('reload_config', { k: 1 });
78
- check('client A call round-trips', rA?.ok === true && rA?.name === 'reload_config');
79
- const rB = await clientB.call('fetch', { q: 2 });
80
- check('client B call round-trips', rB?.ok === true && rB?.name === 'fetch');
81
-
82
- // (3) LAST-WINS register: the NEWEST registrant (B) steals the ownership seat,
83
- // so an inbound notify targets B — NOT the first registrant. (Under the old
84
- // first-wins model this went to A.)
85
- transport.notify('notifications/claude/channel', { content: 'to-B' });
86
- await delay(120);
87
- check('notify #1 targets newest registrant (B) only',
88
- notB.length === 1 && notB[0]?.params?.content === 'to-B' && notA.length === 0);
89
-
90
- // (4) bind-intent call moves the pointer back to A.
91
- await clientA.call('rebind_current_transcript', { transcriptPath: '/tmp/a.jsonl' });
92
- transport.notify('notifications/claude/channel', { content: 'to-A' });
93
- await delay(120);
94
- check('notify #2 delivered to A only after rebind',
95
- notA.length === 1 && notA[0]?.params?.content === 'to-A' && notB.length === 1);
96
-
97
- // (fix 1) idempotent replay: two /call with the SAME callId (a retried
98
- // transport failure) must run the non-idempotent side-effect exactly once.
99
- const dupId = 'dup-call-1';
100
- const [d1, d2] = await Promise.all([
101
- clientA.call('reply', { n: 1 }, { callId: dupId }),
102
- clientA.call('reply', { n: 1 }, { callId: dupId }),
103
- ]);
104
- check('idempotent replay: same callId → exactly one side-effect',
105
- sideEffects === 1 && d1?.ok === true && d2?.ok === true);
106
-
107
- // (targeted) the 'acquired' badge goes ONLY to the current owner (pointer=A
108
- // after the rebind), never broadcast — a displaced/non-owner TUI (B) must not
109
- // light its remote badge. Still sticky for a late owner attach (below).
110
- const remoteBefore = { A: notA.length, B: notB.length };
111
- transport.notify('notifications/mixdog/remote', { state: 'acquired' });
112
- await delay(120);
113
- const gotA = notA.slice(remoteBefore.A).filter((m) => m?.method === 'notifications/mixdog/remote');
114
- const gotB = notB.slice(remoteBefore.B).filter((m) => m?.method === 'notifications/mixdog/remote');
115
- check('remote-state acquired targets owner (A) only, not broadcast',
116
- gotA.length === 1 && gotB.length === 0);
117
-
118
- // A late client that registers becomes the NEWEST owner (steals the pointer),
119
- // so on attach it replays the sticky 'acquired' badge for itself.
120
- const notC = [];
121
- const clientC = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'C', onNotify: (m) => notC.push(m) });
122
- await waitFor(() => notC.some((m) => m?.method === 'notifications/mixdog/remote'), 1000);
123
- check('late owner receives replayed remote-state on attach',
124
- notC.filter((m) => m?.method === 'notifications/mixdog/remote' && m?.params?.state === 'acquired').length === 1);
125
- await clientC.close();
126
-
127
- // (replay) 'superseded' CLEARS the sticky: a client attaching after it must
128
- // receive NO remote-state replay (else it would wrongly stop a fresh client).
129
- transport.notify('notifications/mixdog/remote', { state: 'superseded' });
130
- await delay(60);
131
- const notD = [];
132
- const clientD = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'D', onNotify: (m) => notD.push(m) });
133
- await delay(150);
134
- check('attach after superseded receives no remote-state replay',
135
- notD.filter((m) => m?.method === 'notifications/mixdog/remote').length === 0);
136
- await clientD.close();
137
-
138
- // (sink) daemon-mode wiring: owned-runtime routes remote-state through the
139
- // parent bridge's sendNotifyToParent (NOT raw process.send). The daemon
140
- // installs a sink -> transport.notify (channel-daemon.mjs:106). Assert an
141
- // 'acquired' emitted via that sink reaches the transport and replays to a
142
- // late client — the exact daemon-mode path a raw process.send would drop.
143
- const { sendNotifyToParent } = createParentBridge({ getInstanceId: () => 'smoke' });
144
- setChannelNotifySink((method, params) => transport.notify(method, params));
145
- sendNotifyToParent('notifications/mixdog/remote', { state: 'acquired' });
146
- await delay(60);
147
- const notE = [];
148
- const clientE = await attachToDaemon({ discovery, leadPid: process.pid, cwd: 'E', onNotify: (m) => notE.push(m) });
149
- await waitFor(() => notE.some((m) => m?.method === 'notifications/mixdog/remote'), 1000);
150
- check('daemon-mode acquire via notify sink reaches transport + replays',
151
- notE.filter((m) => m?.method === 'notifications/mixdog/remote' && m?.params?.state === 'acquired').length === 1);
152
- await clientE.close();
153
- setChannelNotifySink(null);
154
-
155
- // (5) deregister both → self-shutdown fires after the client grace window.
156
- await clientA.close();
157
- await clientB.close();
158
- await delay(600);
159
- check('daemon self-shutdown fired after last client left', clientsEmptyFired === true);
160
-
161
- await transport.stop();
162
- try { rmSync(tmp, { recursive: true, force: true }); } catch {}
163
-
164
- await flipTest();
165
- await pointerStealTest();
166
- await reattachBufferTest();
167
- await pointerFailoverTest();
168
- await pointerReconnectFollowsTest();
169
- await tokenReplacementRetirementTest();
170
- await tokenReplacementReplayTest();
171
- await tokenReplacementResponseLossCloseTest();
172
- await registrationReplayTtlTest();
173
- await registrationReplayCleanupTest();
174
- await staleTokenCallTest();
175
- await clientReconnectSafetyTest();
176
- await clientTruncatedReplacementCloseTest();
177
- await workerAttachSafetyTest();
178
-
179
- console.log(failures === 0 ? '\nALL PASS' : `\n${failures} FAILURE(S)`);
180
- process.exit(failures === 0 ? 0 : 1);
181
- }
182
-
183
- // Flip coverage: two TUIs attach to ONE daemon via the REAL channel-worker.mjs
184
- // spawn-or-attach path (against the stub daemon entry — no Discord token). The
185
- // first worker spawns the daemon; the second attaches to it.
186
- async function flipTest() {
187
- const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-flip-smoke-'));
188
- // channel-worker.mjs resolves DAEMON_ENTRY + runtimeRoot at import/call time,
189
- // so the env must be set BEFORE importing it.
190
- process.env.MIXDOG_RUNTIME_ROOT = tmp;
191
- process.env.MIXDOG_DATA_DIR = tmp;
192
- process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = STUB_ENTRY;
193
- const { createStandaloneChannelWorker } = await import('../src/standalone/channel-worker.mjs');
194
- const { readDaemonDiscovery, probeDaemonHealth } = await import('../src/standalone/channel-daemon-client.mjs');
195
- const discFile = path.join(tmp, 'channel-daemon.json');
196
-
197
- const notA = [];
198
- const notB = [];
199
- const mk = (onNotify) => createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: tmp, dataDir: tmp, cwd: tmp, onNotify });
200
- const wA = mk((m) => notA.push(m));
201
- const wB = mk((m) => notB.push(m));
202
-
203
- await wA.start(); // spawns the (stub) daemon then attaches
204
- await wB.start(); // attaches to the SAME daemon (no second spawn)
205
- check('flip: worker A call round-trips', (await wA.execute('reply', { x: 1 }))?.ok === true);
206
- check('flip: worker B call round-trips', (await wB.execute('reply', { y: 2 }))?.ok === true);
207
-
208
- // B claims the transcript → routing pointer moves to B; a notify emitted by
209
- // the daemon (on A's 'fetch') must reach B only, never both TUIs.
210
- await wB.execute('rebind_current_transcript', { transcriptPath: path.join(tmp, 'b.jsonl') });
211
- await wA.execute('fetch', { probe: true });
212
- await waitFor(() => notB.some((m) => m?.params?.content === 'ping-from-stub'), 1500);
213
- check('flip: notify targets the pointer TUI (B) only',
214
- notB.some((m) => m?.params?.content === 'ping-from-stub') &&
215
- !notA.some((m) => m?.params?.content === 'ping-from-stub'));
216
-
217
- // (fix 1) daemon death mid-session: stale SSE clients must immediately
218
- // invalidate and re-read discovery. Both workers reach the replacement
219
- // without a tool call or the old five one-second stale-port retries.
220
- const beforeKill = readDaemonDiscovery(discFile);
221
- if (beforeKill?.pid) { try { process.kill(beforeKill.pid, 'SIGKILL'); } catch {} }
222
- const reattached = await waitForAsync(async () => {
223
- const discovery = readDaemonDiscovery(discFile);
224
- if (!discovery?.pid || discovery.pid === beforeKill?.pid) return false;
225
- const health = await probeDaemonHealth({ port: discovery.port, token: discovery.token, timeoutMs: 300 });
226
- return health?.clients >= 2;
227
- }, 2600);
228
- const afterKill = readDaemonDiscovery(discFile);
229
- let recovered = false;
230
- try { recovered = (await wA.execute('reply', { after: 'stale-sse-replacement' }))?.ok === true; } catch {}
231
- check('flip: stale SSE immediately re-attaches to replacement (no 1..5 stale-port storm)',
232
- reattached === true && !!afterKill?.pid && afterKill.pid !== beforeKill?.pid && recovered === true);
233
-
234
- await wA.stop();
235
- await wB.stop();
236
- await delay(700);
237
- const disc = readDaemonDiscovery(discFile);
238
- check('flip: daemon self-shutdown after last TUI detaches', disc === null);
239
- try { rmSync(tmp, { recursive: true, force: true }); } catch {}
240
- }
241
-
242
- // Last-wins ownership steal coverage with DISTINCT alive leadPids (two sleeper
243
- // child processes) — the only way to observe the targeted 'superseded' frame,
244
- // which is skipped when old/new client share a leadPid (same-TUI rebind).
245
- async function pointerStealTest() {
246
- const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-steal-smoke-'));
247
- const discoveryPath = path.join(tmp, 'channel-daemon.json');
248
- const transport = createChannelDaemonTransport({
249
- handleCall: async (name, args, ctx) => ({ ok: true, name, leadPid: ctx.leadPid }),
250
- discoveryPath,
251
- clientGraceMs: 2000,
252
- sweepMs: 5000,
253
- onClientsEmpty: () => {},
254
- log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[steal] ${m}`),
255
- });
256
- const { port, token } = await transport.start();
257
- const discovery = { port, token, pid: process.pid };
258
- const REMOTE = 'notifications/mixdog/remote';
259
-
260
- // Two long-lived children give us two DISTINCT alive lead pids.
261
- const sleeperA = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
262
- const sleeperB = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
263
- await delay(80);
264
-
265
- const notA = [];
266
- const notB = [];
267
- const clientA = await attachToDaemon({ discovery, leadPid: sleeperA.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
268
- await delay(100); // A registers → A is the owner
269
- const clientB = await attachToDaemon({ discovery, leadPid: sleeperB.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
270
- await delay(150); // B registers → steals the seat, A gets targeted superseded
271
-
272
- check('steal: newest registrant (B) becomes the owner pointer',
273
- transport._resolveTargetForTest()?.leadPid === sleeperB.pid);
274
- check('steal: displaced owner (A) got a TARGETED superseded on register',
275
- notA.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
276
- check('steal: new owner (B) got no superseded',
277
- notB.filter((m) => m?.method === REMOTE).length === 0);
278
-
279
- // 'acquired' now targets the owner (B) only — never broadcast to A.
280
- transport.notify(REMOTE, { state: 'acquired' });
281
- await delay(120);
282
- check('steal: acquired targets owner (B) only, not broadcast',
283
- notB.filter((m) => m?.params?.state === 'acquired').length === 1 &&
284
- notA.filter((m) => m?.params?.state === 'acquired').length === 0);
285
-
286
- // A bind-intent /call from A steals the seat back → B (displaced) superseded.
287
- await clientA.call('activate_channel_bridge', {});
288
- await delay(120);
289
- check('steal: bind /call moves pointer back to A',
290
- transport._resolveTargetForTest()?.leadPid === sleeperA.pid);
291
- check('steal: displaced owner (B) got targeted superseded on bind /call',
292
- notB.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
293
-
294
- // superseded via notify() (seat lost to another daemon) still BROADCASTS to
295
- // every live client, regardless of pointer.
296
- const aBefore = notA.length;
297
- const bBefore = notB.length;
298
- transport.notify(REMOTE, { state: 'superseded' });
299
- await delay(120);
300
- check('steal: superseded via notify broadcasts to ALL live clients',
301
- notA.slice(aBefore).some((m) => m?.params?.state === 'superseded') &&
302
- notB.slice(bBefore).some((m) => m?.params?.state === 'superseded'));
303
-
304
- await clientA.close();
305
- await clientB.close();
306
- try { sleeperA.kill(); } catch {}
307
- try { sleeperB.kill(); } catch {}
308
- await transport.stop();
309
- try { rmSync(tmp, { recursive: true, force: true }); } catch {}
310
- }
311
-
312
- // Low-level helpers: register/attach WITHOUT the auto-attach client, so a
313
- // client can be registered but leave its SSE stream closed (to exercise the
314
- // buffered-superseded + reattach paths that attachToDaemon hides).
315
- function rawPost(port, serverToken, p, body) {
316
- return new Promise((resolve, reject) => {
317
- const payload = JSON.stringify(body);
318
- const req = http.request({
319
- hostname: '127.0.0.1', port, path: p, method: 'POST',
320
- headers: { 'X-Mixdog-Daemon-Token': serverToken, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
321
- }, (res) => { let d = ''; res.setEncoding('utf8'); res.on('data', (c) => { d += c; }); res.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({}); } }); });
322
- req.on('error', reject); req.write(payload); req.end();
323
- });
324
- }
325
- function rawPostLoseResponse(port, serverToken, p, body) {
326
- return new Promise((resolve, reject) => {
327
- const payload = JSON.stringify(body);
328
- let settled = false;
329
- const done = (err) => {
330
- if (settled) return;
331
- settled = true;
332
- if (err) reject(err); else resolve();
333
- };
334
- const req = http.request({
335
- hostname: '127.0.0.1', port, path: p, method: 'POST',
336
- headers: { 'X-Mixdog-Daemon-Token': serverToken, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
337
- }, (res) => {
338
- // The daemon has already committed before emitting response headers; drop
339
- // the body exactly where a transport response-loss retry would begin.
340
- try { res.destroy(); } catch {}
341
- done();
342
- });
343
- req.on('error', (err) => { if (!settled) done(err); });
344
- req.write(payload);
345
- req.end();
346
- });
347
- }
348
- function rawSse(port, serverToken, clientToken, onNotify) {
349
- const req = http.request({
350
- hostname: '127.0.0.1', port,
351
- path: `/events?token=${encodeURIComponent(clientToken)}&server_token=${encodeURIComponent(serverToken)}`,
352
- method: 'GET', headers: { Accept: 'text/event-stream', 'X-Mixdog-Daemon-Token': serverToken },
353
- }, (res) => {
354
- res.setEncoding('utf8'); let buf = '';
355
- res.on('data', (chunk) => {
356
- buf += chunk; let idx;
357
- while ((idx = buf.indexOf('\n\n')) >= 0) {
358
- const raw = buf.slice(0, idx); buf = buf.slice(idx + 2);
359
- for (const line of raw.split('\n')) {
360
- if (!line.startsWith('data:')) continue;
361
- const j = line.slice(5).trim(); if (!j) continue;
362
- let m = null; try { m = JSON.parse(j); } catch { continue; }
363
- if (m?.type === 'notify') onNotify(m);
364
- }
365
- }
366
- });
367
- });
368
- req.end(); return req;
369
- }
370
-
371
- function readJsonBody(req) {
372
- return new Promise((resolve) => {
373
- let body = '';
374
- req.setEncoding('utf8');
375
- req.on('data', (chunk) => { body += chunk; });
376
- req.on('end', () => { try { resolve(JSON.parse(body || '{}')); } catch { resolve({}); } });
377
- });
378
- }
379
- function sendTestJson(res, statusCode, body) {
380
- const json = JSON.stringify(body);
381
- res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(json) });
382
- res.end(json);
383
- }
384
- async function startTestServer(handle) {
385
- const server = http.createServer(handle);
386
- await new Promise((resolve, reject) => {
387
- server.once('error', reject);
388
- server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve(); });
389
- });
390
- return server;
391
- }
392
- function testPort(server) { return server.address().port; }
393
- async function stopTestServer(server) {
394
- await new Promise((resolve) => { try { server.close(() => resolve()); } catch { resolve(); } });
395
- }
396
-
397
- // Removed client tokens must never dispatch a tool with a null ownership
398
- // context, including a callId that could otherwise hit the dedup cache.
399
- async function staleTokenCallTest() {
400
- let dispatched = 0;
401
- const transport = createChannelDaemonTransport({
402
- handleCall: async () => { dispatched++; return { ok: true }; },
403
- clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
404
- });
405
- const { port, token } = await transport.start();
406
- const stale = await rawPost(port, token, '/call', { token: 'removed-token', name: 'reply', callId: 'stale-call' });
407
- check('transport: stale /call token is rejected before dispatch',
408
- stale?.error === 'unknown client token' && dispatched === 0);
409
- await transport.stop();
410
- }
411
-
412
- // A reconnecting non-owner must replace its exact old token, not merely leave
413
- // it behind because another client currently owns the pointer.
414
- async function tokenReplacementRetirementTest() {
415
- let dispatches = 0;
416
- const transport = createChannelDaemonTransport({
417
- handleCall: async (name, args, ctx) => {
418
- dispatches++;
419
- return { ok: true, name, args, leadPid: ctx.leadPid };
420
- },
421
- clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
422
- });
423
- const { port, token } = await transport.start();
424
- const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
425
- const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
426
- const c = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
427
- await delay(80);
428
- const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
429
- await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
430
- const ownerC = await rawPost(port, token, '/client/register', { leadPid: c.pid, cwd: 'C' });
431
- const oldBufferedBeforeReplace = transport._clientsForTest.get(oldA.token)?.pending?.length === 1;
432
- const freshA = await rawPost(port, token, '/client/register', {
433
- leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: oldA.token,
434
- });
435
- const freshNotifies = [];
436
- const freshSse = rawSse(port, token, freshA.token, (message) => freshNotifies.push(message));
437
- await waitFor(() => freshNotifies.some((message) =>
438
- message?.method === 'notifications/mixdog/remote' && message?.params?.state === 'superseded'), 1_000);
439
- const nonOwnerStateMigrated =
440
- freshNotifies.some((message) => message?.method === 'notifications/mixdog/remote' && message?.params?.state === 'superseded') &&
441
- transport._clientsForTest.get(freshA.token)?.pending?.length === 0 &&
442
- transport._resolveTargetForTest()?.token === ownerC.token;
443
- const stale = await rawPost(port, token, '/call', {
444
- token: oldA.token, name: 'rebind_current_transcript',
445
- args: { transcriptPath: '/tmp/stale-a.jsonl' }, callId: 'late-old-dedup',
446
- });
447
- const [live1, live2] = await Promise.all([
448
- rawPost(port, token, '/call', {
449
- token: freshA.token, name: 'rebind_current_transcript',
450
- args: { transcriptPath: '/tmp/fresh-a.jsonl' }, callId: 'fresh-live-dedup',
451
- }),
452
- rawPost(port, token, '/call', {
453
- token: freshA.token, name: 'rebind_current_transcript',
454
- args: { transcriptPath: '/tmp/fresh-a.jsonl' }, callId: 'fresh-live-dedup',
455
- }),
456
- ]);
457
- const beforeFreshClose = transport._clientsForTest.size;
458
- const pointerBeforeFreshClose = transport._resolveTargetForTest()?.token;
459
- await rawPost(port, token, '/client/deregister', { token: freshA.token });
460
- check('reconnect: non-owner old token is retired; stale dedup call cannot move pointer',
461
- stale?.error === 'unknown client token' &&
462
- oldBufferedBeforeReplace &&
463
- nonOwnerStateMigrated &&
464
- !transport._clientsForTest.has(oldA.token) &&
465
- beforeFreshClose === 3 &&
466
- pointerBeforeFreshClose === freshA.token &&
467
- live1?.result?.ok === true && live2?.result?.ok === true &&
468
- dispatches === 1 &&
469
- !transport._clientsForTest.has(freshA.token) &&
470
- transport._clientsForTest.size === 2 &&
471
- ownerC.token !== freshA.token);
472
- try { a.kill(); } catch {}
473
- try { b.kill(); } catch {}
474
- try { c.kill(); } catch {}
475
- try { freshSse.destroy(); } catch {}
476
- await transport.stop();
477
- }
478
-
479
- // If replacement commits but its response is lost, the same logical
480
- // registration id must replay the first fresh token rather than create another.
481
- async function tokenReplacementReplayTest() {
482
- const transport = createChannelDaemonTransport({
483
- handleCall: async () => ({ ok: true }),
484
- clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
485
- });
486
- const { port, token } = await transport.start();
487
- const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
488
- const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
489
- await delay(80);
490
- const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
491
- const ownerB = await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
492
- const replacement = {
493
- leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: oldA.token,
494
- registrationId: 'response-loss-replacement-1',
495
- };
496
- await rawPostLoseResponse(port, token, '/client/register', replacement);
497
- const freshA = await rawPost(port, token, '/client/register', replacement);
498
- const countAfterReplay = transport._clientsForTest.size;
499
- const freshNotifies = [];
500
- const freshSse = rawSse(port, token, freshA.token, (message) => freshNotifies.push(message));
501
- await waitFor(() => freshNotifies.some((message) =>
502
- message?.method === 'notifications/mixdog/remote' && message?.params?.state === 'superseded'), 1_000);
503
- const pendingMigrated = transport._clientsForTest.get(freshA.token)?.pending?.length === 0;
504
- await rawPost(port, token, '/client/deregister', { token: freshA.token });
505
- check('reconnect: response-loss replacement replays one fresh token and close leaves no orphan',
506
- countAfterReplay === 2 &&
507
- !transport._clientsForTest.has(oldA.token) &&
508
- transport._resolveTargetForTest()?.token === ownerB.token &&
509
- pendingMigrated &&
510
- !transport._clientsForTest.has(freshA.token) &&
511
- transport._clientsForTest.size === 1 &&
512
- transport._registrationReplaysForTest.size === 0);
513
- try { freshSse.destroy(); } catch {}
514
- try { a.kill(); } catch {}
515
- try { b.kill(); } catch {}
516
- await transport.stop();
517
- }
518
-
519
- // If the replacement commit survives but its response is lost, an immediate
520
- // close still knows the retired token + registration id and must cancel exactly
521
- // that unknown fresh logical client, not an unrelated live owner.
522
- async function tokenReplacementResponseLossCloseTest() {
523
- const transport = createChannelDaemonTransport({
524
- handleCall: async () => ({ ok: true }),
525
- clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
526
- });
527
- const { port, token } = await transport.start();
528
- const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
529
- const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
530
- await delay(80);
531
- const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
532
- const ownerB = await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
533
- const cancellation = {
534
- token: oldA.token, replaceToken: oldA.token, leadPid: a.pid, cwd: 'A',
535
- registrationId: 'response-loss-immediate-close-1',
536
- };
537
- await rawPostLoseResponse(port, token, '/client/register', {
538
- ...cancellation, reattach: true,
539
- });
540
- const fresh = [...transport._clientsForTest.values()].find((client) => client.leadPid === a.pid);
541
- const pendingMigrated = fresh?.pending?.length === 1;
542
- const wrongClose = await rawPost(port, token, '/client/deregister', {
543
- ...cancellation, cwd: 'not-A',
544
- });
545
- const close1 = await rawPost(port, token, '/client/deregister', cancellation);
546
- const close2 = await rawPost(port, token, '/client/deregister', cancellation);
547
- check('reconnect: response-loss immediate close cancels unknown fresh client without stranding state',
548
- wrongClose?.error === 'forbidden replacement deregister' &&
549
- pendingMigrated &&
550
- close1?.ok === true && close1?.cancelled === true &&
551
- close2?.ok === true && close2?.cancelled === false &&
552
- !transport._clientsForTest.has(oldA.token) &&
553
- !transport._clientsForTest.has(fresh?.token) &&
554
- transport._clientsForTest.size === 1 &&
555
- transport._resolveTargetForTest()?.token === ownerB.token &&
556
- transport._registrationReplaysForTest.size === 0);
557
- try { a.kill(); } catch {}
558
- try { b.kill(); } catch {}
559
- await transport.stop();
560
- }
561
-
562
- // Replay TTL bounds only cancellation metadata after a response has flushed:
563
- // a valid client may delay SSE/call well beyond that TTL. A replay near expiry
564
- // refreshes the metadata lifetime so its returned token remains cancellable.
565
- async function registrationReplayTtlTest() {
566
- const transport = createChannelDaemonTransport({
567
- handleCall: async () => ({ ok: true }),
568
- clientGraceMs: 2000, sweepMs: 5000, registrationReplayTtlMs: 100,
569
- onClientsEmpty: () => {},
570
- });
571
- const { port, token } = await transport.start();
572
- const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
573
- const b = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
574
- await delay(80);
575
- const oldA = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
576
- await rawPost(port, token, '/client/register', { leadPid: b.pid, cwd: 'B' });
577
- const valid = await rawPost(port, token, '/client/register', {
578
- leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: oldA.token, registrationId: 'ttl-valid',
579
- });
580
- await delay(150);
581
- const validSurvived = transport._clientsForTest.has(valid.token) &&
582
- transport._clientsForTest.get(valid.token)?.pending?.length === 1 &&
583
- transport._registrationReplaysForTest.size === 0;
584
- await rawPost(port, token, '/client/deregister', { token: valid.token });
585
-
586
- const oldA2 = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A2' });
587
- const replacement = {
588
- leadPid: a.pid, cwd: 'A2', reattach: true, replaceToken: oldA2.token, registrationId: 'ttl-refresh',
589
- };
590
- const fresh = await rawPost(port, token, '/client/register', replacement);
591
- await delay(70);
592
- const replayed = await rawPost(port, token, '/client/register', replacement);
593
- await delay(60); // Past the original deadline, before the refreshed deadline.
594
- const replayRefreshed = replayed.token === fresh.token &&
595
- transport._clientsForTest.has(fresh.token) &&
596
- transport._registrationReplaysForTest.size === 1;
597
- await delay(70); // Full refreshed interval elapsed.
598
- const freshSurvivedRefreshExpiry = transport._clientsForTest.has(fresh.token) &&
599
- transport._registrationReplaysForTest.size === 0;
600
- await rawPost(port, token, '/client/deregister', { token: fresh.token });
601
- check('reconnect: replay TTL preserves flushed clients and refreshes near-expiry replay metadata',
602
- validSurvived && replayRefreshed && freshSurvivedRefreshExpiry);
603
- try { a.kill(); } catch {}
604
- try { b.kill(); } catch {}
605
- await transport.stop();
606
- }
607
-
608
- // Every explicit retirement, replacement, deregister, and stop must clear the
609
- // associated replay entry/timer; no metadata may target an absent token.
610
- async function registrationReplayCleanupTest() {
611
- const transport = createChannelDaemonTransport({
612
- handleCall: async () => ({ ok: true }),
613
- clientGraceMs: 2000, sweepMs: 5000, registrationReplayTtlMs: 1_000,
614
- onClientsEmpty: () => {},
615
- });
616
- const { port, token } = await transport.start();
617
- const a = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
618
- await delay(80);
619
- const old = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A' });
620
- const fresh1 = await rawPost(port, token, '/client/register', {
621
- leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: old.token, registrationId: 'cleanup-1',
622
- });
623
- const fresh2 = await rawPost(port, token, '/client/register', {
624
- leadPid: a.pid, cwd: 'A', reattach: true, replaceToken: fresh1.token, registrationId: 'cleanup-2',
625
- });
626
- const replacementClean = !transport._clientsForTest.has(fresh1.token) &&
627
- transport._registrationReplaysForTest.size === 1;
628
- await rawPost(port, token, '/client/deregister', { token: fresh2.token });
629
- const deregisterClean = transport._registrationReplaysForTest.size === 0;
630
- const old3 = await rawPost(port, token, '/client/register', { leadPid: a.pid, cwd: 'A3' });
631
- await rawPost(port, token, '/client/register', {
632
- leadPid: a.pid, cwd: 'A3', reattach: true, replaceToken: old3.token, registrationId: 'cleanup-stop',
633
- });
634
- await transport.stop();
635
- check('reconnect: replacement, deregister, and stop clear all replay metadata',
636
- replacementClean && deregisterClean &&
637
- transport._clientsForTest.size === 0 && transport._registrationReplaysForTest.size === 0);
638
- try { a.kill(); } catch {}
639
- }
640
-
641
- // Client-only SSE races: immediate 200/end cycles consume the bounded budget;
642
- // a late old response cannot start another token rotation; close waits for a
643
- // delayed reconnect registration and removes its fresh token.
644
- async function clientReconnectSafetyTest() {
645
- let registerCount = 0;
646
- let fatalReason = null;
647
- const flapping = await startTestServer(async (req, res) => {
648
- const url = new URL(req.url, 'http://127.0.0.1');
649
- if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
650
- if (url.pathname === '/client/register') {
651
- await readJsonBody(req);
652
- registerCount++;
653
- return sendTestJson(res, 200, { token: `flap-${registerCount}`, pid: process.pid });
654
- }
655
- if (url.pathname === '/client/deregister') { await readJsonBody(req); return sendTestJson(res, 200, { ok: true }); }
656
- if (url.pathname === '/events') {
657
- res.writeHead(200, { 'Content-Type': 'text/event-stream' });
658
- res.write(': attached\n\n');
659
- setTimeout(() => res.end(), 10);
660
- return;
661
- }
662
- sendTestJson(res, 404, { error: 'not found' });
663
- });
664
- const flapClient = await attachToDaemon({
665
- discovery: { port: testPort(flapping), token: 'flap', pid: process.pid },
666
- onFatal: (reason) => { fatalReason = reason; },
667
- });
668
- await waitFor(() => fatalReason !== null, 8_000);
669
- check('client: repeated 200/end SSE reconnects are bounded',
670
- fatalReason !== null && registerCount === 6);
671
- await flapClient.close();
672
- await stopTestServer(flapping);
673
-
674
- let lateRegisters = 0;
675
- const lateServer = await startTestServer(async (req, res) => {
676
- const url = new URL(req.url, 'http://127.0.0.1');
677
- if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
678
- if (url.pathname === '/client/register') {
679
- await readJsonBody(req);
680
- lateRegisters++;
681
- return sendTestJson(res, 200, { token: `late-${lateRegisters}`, pid: process.pid });
682
- }
683
- if (url.pathname === '/client/deregister') { await readJsonBody(req); return sendTestJson(res, 200, { ok: true }); }
684
- if (url.pathname === '/events') return sendTestJson(res, 500, { error: 'test SSE should be intercepted' });
685
- sendTestJson(res, 404, { error: 'not found' });
686
- });
687
- const realHttpRequest = http.request;
688
- const fakeStreams = [];
689
- http.request = function interceptedSseRequest(options, callback) {
690
- if (!String(options?.path || '').startsWith('/events')) {
691
- return realHttpRequest.apply(this, arguments);
692
- }
693
- const fakeReq = new EventEmitter();
694
- fakeReq.destroy = () => {};
695
- fakeReq.end = () => {
696
- const fakeRes = new EventEmitter();
697
- fakeRes.statusCode = 200;
698
- fakeRes.setEncoding = () => {};
699
- fakeRes.resume = () => {};
700
- fakeStreams.push({ req: fakeReq, res: fakeRes });
701
- queueMicrotask(() => callback(fakeRes));
702
- };
703
- return fakeReq;
704
- };
705
- let lateClient = null;
706
- try {
707
- lateClient = await attachToDaemon({ discovery: { port: testPort(lateServer), token: 'late', pid: process.pid } });
708
- await waitFor(() => fakeStreams.length === 1, 500);
709
- fakeStreams[0].res.emit('end');
710
- await waitFor(() => lateRegisters === 2 && fakeStreams.length === 2, 2_500);
711
- fakeStreams[0].res.emit('error', new Error('late old SSE error'));
712
- await delay(100);
713
- check('client: late old-SSE events cannot rotate a newer token', lateRegisters === 2);
714
- } finally {
715
- http.request = realHttpRequest;
716
- await lateClient?.close();
717
- }
718
- await stopTestServer(lateServer);
719
-
720
- let closeRegisters = 0;
721
- let delayedRegisterResponse = null;
722
- const liveTokens = new Set();
723
- const closeServer = await startTestServer(async (req, res) => {
724
- const url = new URL(req.url, 'http://127.0.0.1');
725
- if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
726
- if (url.pathname === '/client/register') {
727
- await readJsonBody(req);
728
- closeRegisters++;
729
- const fresh = `close-${closeRegisters}`;
730
- liveTokens.add(fresh);
731
- if (closeRegisters === 2) { delayedRegisterResponse = { res, fresh }; return; }
732
- return sendTestJson(res, 200, { token: fresh, pid: process.pid });
733
- }
734
- if (url.pathname === '/client/deregister') {
735
- const body = await readJsonBody(req);
736
- liveTokens.delete(body.token);
737
- return sendTestJson(res, 200, { ok: true });
738
- }
739
- if (url.pathname === '/events') {
740
- res.writeHead(200, { 'Content-Type': 'text/event-stream' });
741
- res.write(': attached\n\n');
742
- if (closeRegisters === 1) setTimeout(() => res.end(), 10);
743
- return;
744
- }
745
- sendTestJson(res, 404, { error: 'not found' });
746
- });
747
- const closeClient = await attachToDaemon({ discovery: { port: testPort(closeServer), token: 'close', pid: process.pid } });
748
- await waitFor(() => delayedRegisterResponse !== null, 2_500);
749
- const closing = closeClient.close('close during register');
750
- await delay(20);
751
- sendTestJson(delayedRegisterResponse.res, 200, { token: delayedRegisterResponse.fresh, pid: process.pid });
752
- await closing;
753
- check('client: close during re-register removes the fresh token',
754
- !liveTokens.has(delayedRegisterResponse.fresh) && liveTokens.size === 0);
755
- await stopTestServer(closeServer);
756
- }
757
-
758
- // End-to-end client path: the daemon commits replacement, but a proxy loses the
759
- // register response after headers/partial JSON. request() must settle, and close
760
- // must use the retained logical-registration identity to cancel the unknown
761
- // fresh transport client before any later reconnect can revive it.
762
- async function clientTruncatedReplacementCloseTest() {
763
- let cancellationObserved = false;
764
- const transport = createChannelDaemonTransport({
765
- handleCall: async () => ({ ok: true }),
766
- clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
767
- log: (line) => { if (String(line).includes('replacement deregister')) cancellationObserved = true; },
768
- });
769
- const { port, token } = await transport.start();
770
- const client = await attachToDaemon({ discovery: { port, token, pid: process.pid }, cwd: 'truncated-client' });
771
- const initialToken = client.clientToken;
772
- const realHttpRequest = http.request;
773
- let replacementRequests = 0;
774
- let truncated = false;
775
- http.request = function truncateCommittedRegister(options, callback) {
776
- if (!String(options?.path || '').startsWith('/client/register')) {
777
- return realHttpRequest.apply(this, arguments);
778
- }
779
- replacementRequests++;
780
- const fakeReq = new EventEmitter();
781
- let payload = null;
782
- let inner = null;
783
- fakeReq.write = (chunk) => { payload = Buffer.from(chunk); };
784
- fakeReq.destroy = (error) => { try { inner?.destroy?.(error); } catch {} };
785
- fakeReq.end = () => {
786
- inner = realHttpRequest(options, (realRes) => {
787
- realRes.resume();
788
- realRes.once('end', () => {
789
- const fakeRes = new EventEmitter();
790
- fakeRes.statusCode = 200;
791
- fakeRes.setEncoding = () => {};
792
- fakeRes.resume = () => {};
793
- fakeRes.destroy = () => {};
794
- callback(fakeRes);
795
- fakeRes.emit('data', '{"token":"committed-but-truncated');
796
- truncated = true;
797
- queueMicrotask(() => {
798
- fakeRes.emit('aborted');
799
- fakeRes.emit('close');
800
- });
801
- });
802
- });
803
- inner.on('error', (error) => fakeReq.emit('error', error));
804
- if (payload) inner.write(payload);
805
- inner.end();
806
- };
807
- return fakeReq;
808
- };
809
- try {
810
- await waitFor(() => !!transport._clientsForTest.get(initialToken)?.sse, 1_000);
811
- transport._clientsForTest.get(initialToken)?.sse?.end();
812
- await waitFor(() => truncated && replacementRequests === 1 && transport._clientsForTest.size === 1, 2_500);
813
- const closeStarted = Date.now();
814
- await client.close('truncated replacement response');
815
- const closeElapsed = Date.now() - closeStarted;
816
- await delay(1_200);
817
- check('client: truncated committed replacement settles and close cancels unknown fresh client',
818
- closeElapsed < 1_000 &&
819
- cancellationObserved &&
820
- replacementRequests === 1 &&
821
- transport._clientsForTest.size === 0 &&
822
- transport._registrationReplaysForTest.size === 0 &&
823
- transport._resolveTargetForTest() === null);
824
- } finally {
825
- http.request = realHttpRequest;
826
- await client.close('truncated replacement cleanup');
827
- await transport.stop();
828
- }
829
- }
830
-
831
- // Worker-only attach races: auth rejection re-reads discovery instead of
832
- // rejecting start(), a wrong but alive discovery PID is refused, and stop()
833
- // waits for an in-flight register so it cannot publish a client afterward.
834
- async function workerAttachSafetyTest() {
835
- const { createStandaloneChannelWorker } = await import('../src/standalone/channel-worker.mjs');
836
-
837
- const wrongPidServer = await startTestServer((req, res) => {
838
- if (new URL(req.url, 'http://127.0.0.1').pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
839
- sendTestJson(res, 404, { error: 'not found' });
840
- });
841
- let wrongPidRejected = false;
842
- try {
843
- await attachToDaemon({ discovery: { port: testPort(wrongPidServer), token: 'wrong-pid', pid: process.pid + 100_000 } });
844
- } catch (err) { wrongPidRejected = err?.daemonDiscoveryStale === true; }
845
- check('client: alive but wrong discovery PID is rejected', wrongPidRejected);
846
- await stopTestServer(wrongPidServer);
847
-
848
- const authTmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-auth-attach-'));
849
- const authDiscovery = path.join(authTmp, 'channel-daemon.json');
850
- const goodToken = 'good-discovery-token';
851
- let rejectedRegisters = 0;
852
- const authServer = await startTestServer(async (req, res) => {
853
- const url = new URL(req.url, 'http://127.0.0.1');
854
- if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
855
- if (url.pathname === '/client/register') {
856
- await readJsonBody(req);
857
- if (req.headers['x-mixdog-daemon-token'] !== goodToken) {
858
- rejectedRegisters++;
859
- return sendTestJson(res, 403, { error: 'forbidden' });
860
- }
861
- return sendTestJson(res, 200, { token: 'auth-client', pid: process.pid });
862
- }
863
- if (url.pathname === '/client/deregister') { await readJsonBody(req); return sendTestJson(res, 200, { ok: true }); }
864
- if (url.pathname === '/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream' }); res.write(': attached\n\n'); return; }
865
- sendTestJson(res, 404, { error: 'not found' });
866
- });
867
- const authPort = testPort(authServer);
868
- writeFileSync(authDiscovery, JSON.stringify({ pid: process.pid, port: authPort, token: 'stale-discovery-token' }));
869
- process.env.MIXDOG_RUNTIME_ROOT = authTmp;
870
- process.env.MIXDOG_DATA_DIR = authTmp;
871
- process.env.MIXDOG_CHANNEL_DAEMON_ENTRY = STUB_ENTRY;
872
- const authWorker = createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: authTmp, dataDir: authTmp, cwd: authTmp });
873
- const publishGoodDiscovery = setTimeout(() => {
874
- writeFileSync(authDiscovery, JSON.stringify({ pid: process.pid, port: authPort, token: goodToken }));
875
- }, 250);
876
- await authWorker.start();
877
- clearTimeout(publishGoodDiscovery);
878
- check('worker: initial register 403 re-reads discovery and attaches', rejectedRegisters > 0);
879
- await authWorker.stop();
880
- await stopTestServer(authServer);
881
- try { rmSync(authTmp, { recursive: true, force: true }); } catch {}
882
-
883
- const permanentTmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-auth-permanent-'));
884
- const permanentDiscovery = path.join(permanentTmp, 'channel-daemon.json');
885
- let permanentRejects = 0;
886
- const permanentServer = await startTestServer(async (req, res) => {
887
- const url = new URL(req.url, 'http://127.0.0.1');
888
- if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
889
- if (url.pathname === '/client/register') {
890
- await readJsonBody(req);
891
- permanentRejects++;
892
- return sendTestJson(res, 403, { error: 'forbidden' });
893
- }
894
- sendTestJson(res, 404, { error: 'not found' });
895
- });
896
- writeFileSync(permanentDiscovery, JSON.stringify({
897
- pid: process.pid, port: testPort(permanentServer), token: 'permanently-stale-token',
898
- }));
899
- process.env.MIXDOG_RUNTIME_ROOT = permanentTmp;
900
- process.env.MIXDOG_DATA_DIR = permanentTmp;
901
- const permanentWorker = createStandaloneChannelWorker({
902
- entry: STUB_ENTRY, rootDir: permanentTmp, dataDir: permanentTmp, cwd: permanentTmp,
903
- });
904
- const permanentStartedAt = Date.now();
905
- let permanentRejected = false;
906
- try { await permanentWorker.start(); } catch (err) {
907
- permanentRejected = /register rejected discovery auth 5 times/.test(err?.message || '');
908
- }
909
- const permanentElapsed = Date.now() - permanentStartedAt;
910
- check('worker: permanent register 403 uses bounded backoff and terminates',
911
- permanentRejected && permanentRejects === 5 && permanentElapsed >= 1000 && permanentElapsed < 6000);
912
- await permanentWorker.stop();
913
- await stopTestServer(permanentServer);
914
- try { rmSync(permanentTmp, { recursive: true, force: true }); } catch {}
915
-
916
- const stopTmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-stop-attach-'));
917
- const stopDiscovery = path.join(stopTmp, 'channel-daemon.json');
918
- let pendingRegister = null;
919
- const deregistered = new Set();
920
- const stopServer = await startTestServer(async (req, res) => {
921
- const url = new URL(req.url, 'http://127.0.0.1');
922
- if (url.pathname === '/health') return sendTestJson(res, 200, { status: 'ok', pid: process.pid });
923
- if (url.pathname === '/client/register') { await readJsonBody(req); pendingRegister = res; return; }
924
- if (url.pathname === '/client/deregister') {
925
- const body = await readJsonBody(req);
926
- deregistered.add(body.token);
927
- return sendTestJson(res, 200, { ok: true });
928
- }
929
- if (url.pathname === '/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream' }); res.write(': attached\n\n'); return; }
930
- sendTestJson(res, 404, { error: 'not found' });
931
- });
932
- const stopPort = testPort(stopServer);
933
- writeFileSync(stopDiscovery, JSON.stringify({ pid: process.pid, port: stopPort, token: 'stop-token' }));
934
- process.env.MIXDOG_RUNTIME_ROOT = stopTmp;
935
- process.env.MIXDOG_DATA_DIR = stopTmp;
936
- const stopWorker = createStandaloneChannelWorker({ entry: STUB_ENTRY, rootDir: stopTmp, dataDir: stopTmp, cwd: stopTmp });
937
- const startResult = stopWorker.start().then(() => 'started').catch(() => 'cancelled');
938
- await waitFor(() => pendingRegister !== null, 1_500);
939
- const stopping = stopWorker.stop('stop during attach');
940
- sendTestJson(pendingRegister, 200, { token: 'stop-fresh', pid: process.pid });
941
- const [stopped, startState] = await Promise.all([stopping, startResult]);
942
- check('worker: stop during attach cannot publish a client',
943
- stopped === false && startState === 'cancelled' && deregistered.has('stop-fresh'));
944
- await stopTestServer(stopServer);
945
- try { rmSync(stopTmp, { recursive: true, force: true }); } catch {}
946
- }
947
-
948
- // Fix coverage: (1) an SSE-reconnect re-register must NOT steal ownership;
949
- // (2) a targeted 'superseded' emitted while the displaced client has no live
950
- // stream is buffered and flushed when its stream (re)attaches.
951
- async function reattachBufferTest() {
952
- const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-reattach-smoke-'));
953
- const discoveryPath = path.join(tmp, 'channel-daemon.json');
954
- const transport = createChannelDaemonTransport({
955
- handleCall: async () => ({ ok: true }),
956
- discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
957
- log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[reattach] ${m}`),
958
- });
959
- const { port, token } = await transport.start();
960
- const REMOTE = 'notifications/mixdog/remote';
961
- const s1 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
962
- const s2 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
963
- await delay(80);
964
-
965
- // (1) reconnect with the SAME leadPid: the pointer FOLLOWS to the fresh token
966
- // (its old dead-stream entry is dropped), never stealing from a DIFFERENT
967
- // owner. Register X (fresh) → owner; re-register reattach:true → pointer moves
968
- // to the new token and the old entry is gone (no dead-stream blackhole).
969
- const regX = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X' });
970
- await delay(40);
971
- check('reattach: fresh register X becomes owner',
972
- transport._resolveTargetForTest()?.token === regX.token);
973
- const regReconnect = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X', reattach: true });
974
- await delay(40);
975
- check('reattach: reconnect (same pid) pointer follows fresh token, old dropped',
976
- transport._resolveTargetForTest()?.token === regReconnect.token &&
977
- !transport._clientsForTest.has(regX.token));
978
-
979
- // (2) X's current entry never opened a stream; a fresh register Y steals the
980
- // seat → X's targeted 'superseded' is BUFFERED, then delivered on late attach.
981
- const regY = await rawPost(port, token, '/client/register', { leadPid: s2.pid, cwd: 'Y' });
982
- await delay(40);
983
- check('reattach: fresh Y steals the seat from X',
984
- transport._resolveTargetForTest()?.token === regY.token);
985
- const notX = [];
986
- const sseX = rawSse(port, token, regReconnect.token, (m) => notX.push(m));
987
- await waitFor(() => notX.some((m) => m?.method === REMOTE && m?.params?.state === 'superseded'), 1000);
988
- check('reattach: buffered superseded flushed to X after late SSE attach',
989
- notX.filter((m) => m?.method === REMOTE && m?.params?.state === 'superseded').length === 1);
990
-
991
- try { sseX.destroy(); } catch {}
992
- try { s1.kill(); } catch {}
993
- try { s2.kill(); } catch {}
994
- await transport.stop();
995
- try { rmSync(tmp, { recursive: true, force: true }); } catch {}
996
- }
997
-
998
- main().catch((err) => { console.error(err); process.exit(1); });
999
-
1000
- // Pointer-failover coverage: the pointer client dies (deregister) while a
1001
- // live client remains. The transport must move the pointer to the survivor
1002
- // (reason 'failover'), deliver the sticky 'acquired' badge to it, and
1003
- // re-dispatch the survivor's stored bind intent via the injected dispatchBind
1004
- // so the output forwarder rebinds to the survivor's transcript.
1005
- async function pointerFailoverTest() {
1006
- const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-failover-smoke-'));
1007
- const discoveryPath = path.join(tmp, 'channel-daemon.json');
1008
- const REMOTE = 'notifications/mixdog/remote';
1009
- const rebinds = []; // failover re-dispatches routed through dispatchBind
1010
- const handleCall = async (name, args, ctx) => ({ ok: true, name, args, leadPid: ctx.leadPid });
1011
- const dispatchBind = (name, args, ctx) => {
1012
- rebinds.push({ name, args, leadPid: ctx.leadPid });
1013
- return handleCall(name, args, ctx);
1014
- };
1015
- const transport = createChannelDaemonTransport({
1016
- handleCall,
1017
- dispatchBind,
1018
- discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
1019
- log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[failover] ${m}`),
1020
- });
1021
- const { port, token } = await transport.start();
1022
- const discovery = { port, token, pid: process.pid };
1023
-
1024
- const sleeperA = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
1025
- const sleeperB = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
1026
- await delay(80);
1027
-
1028
- const notA = [];
1029
- const notB = [];
1030
- const clientA = await attachToDaemon({ discovery, leadPid: sleeperA.pid, cwd: 'A', onNotify: (m) => notA.push(m) });
1031
- await clientA.call('rebind_current_transcript', { transcriptPath: '/tmp/a.jsonl' }); // A stores bind intent
1032
- const clientB = await attachToDaemon({ discovery, leadPid: sleeperB.pid, cwd: 'B', onNotify: (m) => notB.push(m) });
1033
- await clientB.call('rebind_current_transcript', { transcriptPath: '/tmp/b.jsonl' }); // B owner + bind intent
1034
- await delay(120);
1035
- check('failover: newest binder (B) owns the pointer', transport._resolveTargetForTest()?.leadPid === sleeperB.pid);
1036
-
1037
- const aBefore = notA.length;
1038
- await clientB.close(); // pointer client dies → failover to survivor A
1039
- await waitFor(() => transport._resolveTargetForTest()?.leadPid === sleeperA.pid, 1000);
1040
- check('failover: pointer moves to surviving client (A)',
1041
- transport._resolveTargetForTest()?.leadPid === sleeperA.pid);
1042
- await waitFor(() => notA.slice(aBefore).some((m) => m?.method === REMOTE && m?.params?.state === 'acquired'), 1000);
1043
- check('failover: survivor (A) receives the sticky acquired badge',
1044
- notA.slice(aBefore).filter((m) => m?.method === REMOTE && m?.params?.state === 'acquired').length === 1);
1045
- await waitFor(() => rebinds.some((r) => r.leadPid === sleeperA.pid), 1000);
1046
- check('failover: survivor bind intent re-dispatched to its transcript',
1047
- rebinds.some((r) => r.name === 'rebind_current_transcript'
1048
- && r.args?.transcriptPath === '/tmp/a.jsonl' && r.leadPid === sleeperA.pid));
1049
-
1050
- await clientA.close();
1051
- try { sleeperA.kill(); } catch {}
1052
- try { sleeperB.kill(); } catch {}
1053
- await transport.stop();
1054
- try { rmSync(tmp, { recursive: true, force: true }); } catch {}
1055
- }
1056
-
1057
- // Reattach-after-pointer-loss: the pointer client's SSE drops and it
1058
- // re-registers (reattach:true, SAME leadPid) with a FRESH token. The pointer
1059
- // must FOLLOW to the new token (old dead-stream entry dropped, no failover/
1060
- // self-superseded), and a notify must reach the NEW stream — not blackhole on
1061
- // the old entry.
1062
- async function pointerReconnectFollowsTest() {
1063
- const tmp = mkdtempSync(path.join(os.tmpdir(), 'mixdog-reconnect-smoke-'));
1064
- const discoveryPath = path.join(tmp, 'channel-daemon.json');
1065
- const transport = createChannelDaemonTransport({
1066
- handleCall: async () => ({ ok: true }),
1067
- discoveryPath, clientGraceMs: 2000, sweepMs: 5000, onClientsEmpty: () => {},
1068
- log: (m) => process.env.DAEMON_SMOKE_VERBOSE && console.log(`[reconnect] ${m}`),
1069
- });
1070
- const { port, token } = await transport.start();
1071
- const s1 = spawn(process.execPath, ['-e', 'setTimeout(()=>{}, 60000)'], { stdio: 'ignore' });
1072
- await delay(80);
1073
-
1074
- // Register X (fresh) → owner; open its SSE, then let the stream drop.
1075
- const regX = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X' });
1076
- const notOld = [];
1077
- const sseOld = rawSse(port, token, regX.token, (m) => notOld.push(m));
1078
- await delay(60);
1079
- try { sseOld.destroy(); } catch {}
1080
- await delay(40);
1081
-
1082
- // Reconnect re-register (same leadPid) → fresh token; pointer must FOLLOW it.
1083
- const regNew = await rawPost(port, token, '/client/register', { leadPid: s1.pid, cwd: 'X', reattach: true });
1084
- await delay(40);
1085
- check('reconnect: pointer follows the fresh reconnect token',
1086
- transport._resolveTargetForTest()?.token === regNew.token && regNew.token !== regX.token);
1087
- check('reconnect: dead old entry was dropped', !transport._clientsForTest.has(regX.token));
1088
-
1089
- // A notify must reach the NEW stream (no blackhole on the old dead entry).
1090
- const notNew = [];
1091
- const sseNew = rawSse(port, token, regNew.token, (m) => notNew.push(m));
1092
- await delay(60);
1093
- transport.notify('notifications/claude/channel', { content: 'to-reconnect' });
1094
- await waitFor(() => notNew.some((m) => m?.params?.content === 'to-reconnect'), 1000);
1095
- check('reconnect: notify reaches the new stream, not the dead old entry',
1096
- notNew.some((m) => m?.params?.content === 'to-reconnect') &&
1097
- !notOld.some((m) => m?.params?.content === 'to-reconnect'));
1098
-
1099
- try { sseNew.destroy(); } catch {}
1100
- try { s1.kill(); } catch {}
1101
- await transport.stop();
1102
- try { rmSync(tmp, { recursive: true, force: true }); } catch {}
1103
- }