create-walle 0.10.0 → 0.10.2

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 (381) hide show
  1. package/README.md +88 -41
  2. package/lib/owner-write-safety.js +135 -0
  3. package/lib/public-runtime.js +13 -5
  4. package/lib/release-schema.json +3 -2
  5. package/lib/runtime-closure-policy.json +7 -5
  6. package/package.json +10 -4
  7. package/template/bin/deps-doctor.js +1 -1
  8. package/template/bin/node-bin.sh +11 -0
  9. package/template/coding-task-manager/api-prompts.js +10 -22
  10. package/template/coding-task-manager/bin/capture-codex-rollout-records.js +417 -0
  11. package/template/coding-task-manager/bin/census-codex-lead-history.js +325 -0
  12. package/template/coding-task-manager/bin/claude-hook.js +7 -3
  13. package/template/coding-task-manager/bin/claude-table-parity.js +307 -0
  14. package/template/coding-task-manager/bin/conversation-storage-auto-migration-lib.js +16 -1
  15. package/template/coding-task-manager/bin/conversation-storage-generation-evidence.js +9 -2
  16. package/template/coding-task-manager/bin/conversation-storage-service-entry-lib.js +16 -1
  17. package/template/coding-task-manager/bin/conversation-storage-walle-owner.js +247 -0
  18. package/template/coding-task-manager/bin/ctm-machine-pair.js +160 -0
  19. package/template/coding-task-manager/bin/lead-wait-census.js +1348 -0
  20. package/template/coding-task-manager/bin/repair-foreign-clone.js +564 -0
  21. package/template/coding-task-manager/bin/repair-resume-echo-titles.js +248 -0
  22. package/template/coding-task-manager/bin/seam-replay-codex.js +301 -0
  23. package/template/coding-task-manager/bin/write-lock-bench.js +111 -0
  24. package/template/coding-task-manager/db.js +705 -286
  25. package/template/coding-task-manager/git-utils.js +44 -0
  26. package/template/coding-task-manager/lib/adopted-host-redial.js +4 -0
  27. package/template/coding-task-manager/lib/agent-presets.js +15 -0
  28. package/template/coding-task-manager/lib/agent-title-apply.js +88 -0
  29. package/template/coding-task-manager/lib/agent-title-backfill-read.js +92 -0
  30. package/template/coding-task-manager/lib/api/host-stats.js +98 -0
  31. package/template/coding-task-manager/lib/api/ingress.js +35 -12
  32. package/template/coding-task-manager/lib/api/machine-pairing.js +285 -0
  33. package/template/coding-task-manager/lib/api/machine-pool.js +307 -0
  34. package/template/coding-task-manager/lib/api/machines.js +461 -0
  35. package/template/coding-task-manager/lib/api/misc-ops.js +20 -5
  36. package/template/coding-task-manager/lib/api/providers.js +53 -6
  37. package/template/coding-task-manager/lib/api/resume-from-store.js +25 -0
  38. package/template/coding-task-manager/lib/api/sessions.js +160 -8
  39. package/template/coding-task-manager/lib/api/transcript-history.js +17 -4
  40. package/template/coding-task-manager/lib/api/worktrees.js +38 -37
  41. package/template/coding-task-manager/lib/approval-drift.js +16 -1
  42. package/template/coding-task-manager/lib/approval-logical-frame.js +5 -1
  43. package/template/coding-task-manager/lib/approval-scope.js +68 -4
  44. package/template/coding-task-manager/lib/auth-context.js +32 -2
  45. package/template/coding-task-manager/lib/auth-rate-limit.js +41 -1
  46. package/template/coding-task-manager/lib/auth-rules.js +186 -45
  47. package/template/coding-task-manager/lib/broadcast-serialize.js +2 -1
  48. package/template/coding-task-manager/lib/browser-session-broker.js +9 -1
  49. package/template/coding-task-manager/lib/browser-use-adapter.js +33 -2
  50. package/template/coding-task-manager/lib/claude-markdown-ansi.js +27 -85
  51. package/template/coding-task-manager/lib/claude-session-title.js +6 -30
  52. package/template/coding-task-manager/lib/claude-table-layout.js +322 -0
  53. package/template/coding-task-manager/lib/client-diag-handler.js +25 -0
  54. package/template/coding-task-manager/lib/codex-config-guard.js +33 -3
  55. package/template/coding-task-manager/lib/codex-harness-qualification.js +12 -0
  56. package/template/coding-task-manager/lib/codex-history-terminal-renderer.js +33 -33
  57. package/template/coding-task-manager/lib/codex-launch-health.js +29 -2
  58. package/template/coding-task-manager/lib/codex-link-preparation.js +9 -8
  59. package/template/coding-task-manager/lib/codex-local-links.js +267 -0
  60. package/template/coding-task-manager/lib/codex-markdown-lists.js +160 -0
  61. package/template/coding-task-manager/lib/codex-native-history-prefix.js +4 -0
  62. package/template/coding-task-manager/lib/codex-paths.js +2 -1
  63. package/template/coding-task-manager/lib/codex-source-handoff.js +186 -0
  64. package/template/coding-task-manager/lib/coding-agent-models.js +21 -30
  65. package/template/coding-task-manager/lib/coding-session-model-adapters.js +27 -2
  66. package/template/coding-task-manager/lib/coding-session-model-projection.js +5 -0
  67. package/template/coding-task-manager/lib/coding-session-model-runtime.js +1 -2
  68. package/template/coding-task-manager/lib/coding-session-model-state.js +10 -0
  69. package/template/coding-task-manager/lib/coding-session-model-sync-state.js +0 -1
  70. package/template/coding-task-manager/lib/conversation-import/cursor-guard-owner.js +10 -0
  71. package/template/coding-task-manager/lib/create/resolve-launch-params.js +8 -1
  72. package/template/coding-task-manager/lib/ctm-boot-lifecycle.js +17 -1
  73. package/template/coding-task-manager/lib/db-domain-tables.js +7 -0
  74. package/template/coding-task-manager/lib/db-domains.js +21 -2
  75. package/template/coding-task-manager/lib/db-owner-op-registry.js +7 -0
  76. package/template/coding-task-manager/lib/db-owner-worker-client.js +22 -0
  77. package/template/coding-task-manager/lib/db-schema-source-files.js +1 -0
  78. package/template/coding-task-manager/lib/feature-manifest.js +1 -3
  79. package/template/coding-task-manager/lib/fs-watcher.js +3 -2
  80. package/template/coding-task-manager/lib/harness-history-transition.js +41 -14
  81. package/template/coding-task-manager/lib/headless-inflight-ops.js +203 -0
  82. package/template/coding-task-manager/lib/headless-missing-reply.js +42 -0
  83. package/template/coding-task-manager/lib/headless-term-service.js +60 -23
  84. package/template/coding-task-manager/lib/history-transition-read-op.js +179 -23
  85. package/template/coding-task-manager/lib/host-control-dial.js +167 -0
  86. package/template/coding-task-manager/lib/host-handoff-orchestrator.js +16 -2
  87. package/template/coding-task-manager/lib/host-handoff.js +51 -13
  88. package/template/coding-task-manager/lib/host-health-ledger.js +164 -12
  89. package/template/coding-task-manager/lib/host-self-truth-watchdog.js +4 -0
  90. package/template/coding-task-manager/lib/host-stats.js +328 -0
  91. package/template/coding-task-manager/lib/host-threadpool-evacuation.js +160 -0
  92. package/template/coding-task-manager/lib/host-threadpool-monitor.js +14 -1
  93. package/template/coding-task-manager/lib/hosted-input-dispatch.js +203 -0
  94. package/template/coding-task-manager/lib/hosted-sync-worker.js +113 -5
  95. package/template/coding-task-manager/lib/instance-id-ownership-manifest.js +4 -0
  96. package/template/coding-task-manager/lib/instance-manifest-runtime.js +3 -0
  97. package/template/coding-task-manager/lib/instance-manifest.js +13 -1
  98. package/template/coding-task-manager/lib/instance-sync-status.js +15 -0
  99. package/template/coding-task-manager/lib/kernel/diag-log.js +12 -0
  100. package/template/coding-task-manager/lib/launch-model-override.js +160 -1
  101. package/template/coding-task-manager/lib/launch-plan-worker.js +59 -2
  102. package/template/coding-task-manager/lib/lead-model-observer.js +17 -2
  103. package/template/coding-task-manager/lib/lead-model-switch-runtime.js +57 -4
  104. package/template/coding-task-manager/lib/lead-model-switch.js +11 -2
  105. package/template/coding-task-manager/lib/lead-model-transition-runtime.js +42 -4
  106. package/template/coding-task-manager/lib/lead-model-transition-store.js +61 -3
  107. package/template/coding-task-manager/lib/legacy-draft-cancellation-recovery.js +111 -0
  108. package/template/coding-task-manager/lib/login-tripwire.js +18 -0
  109. package/template/coding-task-manager/lib/machine-pairing.js +238 -0
  110. package/template/coding-task-manager/lib/machine-pool.js +409 -0
  111. package/template/coding-task-manager/lib/machine-registry.js +749 -0
  112. package/template/coding-task-manager/lib/machine-self.js +66 -0
  113. package/template/coding-task-manager/lib/main-thread-lint-exemptions.json +56 -4
  114. package/template/coding-task-manager/lib/media-journal-control-reader.js +473 -0
  115. package/template/coding-task-manager/lib/media-journal.js +84 -27
  116. package/template/coding-task-manager/lib/media-store-options.js +43 -15
  117. package/template/coding-task-manager/lib/migrations/v56-host-samples-and-machine-pool.js +76 -0
  118. package/template/coding-task-manager/lib/mirror-feed-sanitize.js +12 -3
  119. package/template/coding-task-manager/lib/model-execution-recipe-metadata.js +16 -3
  120. package/template/coding-task-manager/lib/model-proxy/anthropic-face.js +4 -0
  121. package/template/coding-task-manager/lib/model-proxy/chat-response-translators.js +3 -0
  122. package/template/coding-task-manager/lib/model-proxy/collaboration-codec.js +94 -0
  123. package/template/coding-task-manager/lib/model-proxy/handoff.js +494 -0
  124. package/template/coding-task-manager/lib/model-proxy/lead-worker-picks.js +25 -0
  125. package/template/coding-task-manager/lib/model-proxy/native-lead-listener.js +61 -7
  126. package/template/coding-task-manager/lib/model-proxy/proxy-cli.js +200 -0
  127. package/template/coding-task-manager/lib/model-proxy/proxy-instance-record.js +257 -0
  128. package/template/coding-task-manager/lib/model-proxy/responses-face.js +24 -2
  129. package/template/coding-task-manager/lib/model-proxy/stream-accounting.js +205 -0
  130. package/template/coding-task-manager/lib/model-proxy/tokens.js +78 -0
  131. package/template/coding-task-manager/lib/model-proxy/upstream-outcome.js +9 -0
  132. package/template/coding-task-manager/lib/native-history-control.js +62 -14
  133. package/template/coding-task-manager/lib/native-lead-settings-client.js +2 -1
  134. package/template/coding-task-manager/lib/native-lead-tls.js +18 -4
  135. package/template/coding-task-manager/lib/oauth-proxy-supervisor.js +628 -64
  136. package/template/coding-task-manager/lib/owner-action-manifest-data.json +14 -0
  137. package/template/coding-task-manager/lib/painted-row-recorder.js +9 -1
  138. package/template/coding-task-manager/lib/prompt-admission-trace.js +69 -0
  139. package/template/coding-task-manager/lib/prompt-submission-contract.js +21 -1
  140. package/template/coding-task-manager/lib/provider-login-broker.js +192 -9
  141. package/template/coding-task-manager/lib/provider-login-capture.js +153 -0
  142. package/template/coding-task-manager/lib/queue-draft-status-overlay.js +36 -0
  143. package/template/coding-task-manager/lib/raw-codex-history-window.js +5 -3
  144. package/template/coding-task-manager/lib/raw-history-block.js +3 -1
  145. package/template/coding-task-manager/lib/read-pool-client.js +36 -24
  146. package/template/coding-task-manager/lib/read-pool-op-registry.js +10 -1
  147. package/template/coding-task-manager/lib/read-pool-telemetry.js +14 -5
  148. package/template/coding-task-manager/lib/recent-catalog-query.js +26 -0
  149. package/template/coding-task-manager/lib/recent-session-catalog-response.js +9 -0
  150. package/template/coding-task-manager/lib/remote-command-context.js +12 -0
  151. package/template/coding-task-manager/lib/remote-command-runtime.js +18 -5
  152. package/template/coding-task-manager/lib/remote-machine-catalog.js +1062 -0
  153. package/template/coding-task-manager/lib/remote-machine-create.js +693 -0
  154. package/template/coding-task-manager/lib/remote-machine-link.js +939 -0
  155. package/template/coding-task-manager/lib/remote-machine-proxy.js +520 -0
  156. package/template/coding-task-manager/lib/remote-session-forward.js +306 -0
  157. package/template/coding-task-manager/lib/remote-submission-status.js +6 -0
  158. package/template/coding-task-manager/lib/restart-outcome-truth.js +20 -3
  159. package/template/coding-task-manager/lib/restart-preflight.js +9 -2
  160. package/template/coding-task-manager/lib/restore-backoff-gate.js +10 -2
  161. package/template/coding-task-manager/lib/restore-prepare-retry.js +12 -5
  162. package/template/coding-task-manager/lib/restore-resume-batch.js +22 -1
  163. package/template/coding-task-manager/lib/resume-blocked-wire.js +62 -1
  164. package/template/coding-task-manager/lib/resume-cwd.js +13 -0
  165. package/template/coding-task-manager/lib/resume-from-store.js +12 -1
  166. package/template/coding-task-manager/lib/resume-origin-facts.js +38 -1
  167. package/template/coding-task-manager/lib/resume-preflight.js +21 -16
  168. package/template/coding-task-manager/lib/review-repo-admission.js +39 -6
  169. package/template/coding-task-manager/lib/review-repo-registry.js +17 -0
  170. package/template/coding-task-manager/lib/root-delivery-runtime.js +72 -15
  171. package/template/coding-task-manager/lib/root-delivery-selection.js +37 -1
  172. package/template/coding-task-manager/lib/root-prompt-admission.js +43 -11
  173. package/template/coding-task-manager/lib/root-prompt-flow.js +175 -38
  174. package/template/coding-task-manager/lib/scrollback-archive-store.js +7 -4
  175. package/template/coding-task-manager/lib/server-memory-sampler-runtime.js +10 -0
  176. package/template/coding-task-manager/lib/server-memory-sampler.js +41 -2
  177. package/template/coding-task-manager/lib/session-analysis-memory.js +2 -0
  178. package/template/coding-task-manager/lib/session-create/codex-native-provider-gate.js +218 -0
  179. package/template/coding-task-manager/lib/session-create/codex-resume-exit-classify.js +52 -0
  180. package/template/coding-task-manager/lib/session-create/delegate-worker.js +95 -6
  181. package/template/coding-task-manager/lib/session-create/identity.js +20 -1
  182. package/template/coding-task-manager/lib/session-create/prepared.js +69 -1
  183. package/template/coding-task-manager/lib/session-create/provider-launch-context.js +77 -0
  184. package/template/coding-task-manager/lib/session-create/provider-process.js +38 -39
  185. package/template/coding-task-manager/lib/session-create/response.js +30 -12
  186. package/template/coding-task-manager/lib/session-create/transcript.js +13 -4
  187. package/template/coding-task-manager/lib/session-create/worker-continuation.js +43 -1
  188. package/template/coding-task-manager/lib/session-create/worker-notices.js +94 -5
  189. package/template/coding-task-manager/lib/session-create/worker-observability.js +99 -7
  190. package/template/coding-task-manager/lib/session-create/worker-stop.js +11 -3
  191. package/template/coding-task-manager/lib/session-create/worker-wait-watcher.js +315 -0
  192. package/template/coding-task-manager/lib/session-geometry-authority-handler.js +10 -1
  193. package/template/coding-task-manager/lib/session-host-manager.js +152 -206
  194. package/template/coding-task-manager/lib/session-jobs.js +17 -9
  195. package/template/coding-task-manager/lib/session-lifecycle.js +20 -50
  196. package/template/coding-task-manager/lib/session-model-action-preconditions.js +68 -3
  197. package/template/coding-task-manager/lib/session-resource-sampler.js +67 -9
  198. package/template/coding-task-manager/lib/session-standup.js +67 -4
  199. package/template/coding-task-manager/lib/session-status-ledger.js +42 -3
  200. package/template/coding-task-manager/lib/session-status-runtime.js +45 -1
  201. package/template/coding-task-manager/lib/session-status.js +2 -2
  202. package/template/coding-task-manager/lib/session-title-mirror.js +25 -57
  203. package/template/coding-task-manager/lib/session-transcript-trash.js +8 -1
  204. package/template/coding-task-manager/lib/startup-pointer-write.js +57 -0
  205. package/template/coding-task-manager/lib/state-sync/painted-frame-field.js +11 -0
  206. package/template/coding-task-manager/lib/storage/coding-session-model-store.js +21 -22
  207. package/template/coding-task-manager/lib/surviving-owner-bootstrap.js +7 -2
  208. package/template/coding-task-manager/lib/surviving-owner-recovery.js +2 -1
  209. package/template/coding-task-manager/lib/terminal-draft-http-status.js +25 -0
  210. package/template/coding-task-manager/lib/transcript-archiver.js +229 -229
  211. package/template/coding-task-manager/lib/transcript-frame-cache.js +12 -2
  212. package/template/coding-task-manager/lib/transcript-guard-job.js +55 -4
  213. package/template/coding-task-manager/lib/transcript-link.js +77 -0
  214. package/template/coding-task-manager/lib/transcript-manifest-store.js +8 -4
  215. package/template/coding-task-manager/lib/transcript-source.js +8 -3
  216. package/template/coding-task-manager/lib/transcript-store.js +40 -0
  217. package/template/coding-task-manager/lib/tunnel-lane-governor.js +6 -3
  218. package/template/coding-task-manager/lib/typing-interaction-trace.js +136 -2
  219. package/template/coding-task-manager/lib/typing-worker-stages.js +208 -1
  220. package/template/coding-task-manager/lib/usage-ledger/claude-parse.js +53 -2
  221. package/template/coding-task-manager/lib/usage-ledger/ingest.js +13 -4
  222. package/template/coding-task-manager/lib/usage-ledger/read.js +14 -0
  223. package/template/coding-task-manager/lib/usage-ledger/schema.js +49 -1
  224. package/template/coding-task-manager/lib/usage-ledger/store.js +337 -6
  225. package/template/coding-task-manager/lib/usage-ledger/task-read.js +123 -5
  226. package/template/coding-task-manager/lib/visible-revision-watchdog.js +8 -12
  227. package/template/coding-task-manager/lib/wake-skip-reasons.js +46 -0
  228. package/template/coding-task-manager/lib/walle-supervisor.js +98 -14
  229. package/template/coding-task-manager/lib/worker-failure-classify.js +13 -1
  230. package/template/coding-task-manager/lib/ws/input.js +1 -0
  231. package/template/coding-task-manager/lib/ws/state-sync.js +19 -2
  232. package/template/coding-task-manager/lib/ws/terminal-attach.js +32 -1
  233. package/template/coding-task-manager/lib/ws-router.js +68 -1
  234. package/template/coding-task-manager/lib/ws-send-admission.js +22 -4
  235. package/template/coding-task-manager/oauth-proxy.js +99 -55
  236. package/template/coding-task-manager/package-lock.json +24 -0
  237. package/template/coding-task-manager/package.json +3 -0
  238. package/template/coding-task-manager/protocol/message-table.json +0 -10
  239. package/template/coding-task-manager/protocol/messages.js +0 -10
  240. package/template/coding-task-manager/protocol/ws-schema.json +0 -20
  241. package/template/coding-task-manager/protocol/ws-schema.md +0 -2
  242. package/template/coding-task-manager/providers/codex-mcp-extract.js +121 -19
  243. package/template/coding-task-manager/providers/codex-mcp.js +14 -5
  244. package/template/coding-task-manager/public/css/app.css +279 -2
  245. package/template/coding-task-manager/public/css/reviews.css +4 -0
  246. package/template/coding-task-manager/public/index.html +297 -240
  247. package/template/coding-task-manager/public/js/app/machines-page.js +1252 -0
  248. package/template/coding-task-manager/public/js/app/models-tab.js +546 -36
  249. package/template/coding-task-manager/public/js/app/worktrees-panel.js +7 -5
  250. package/template/coding-task-manager/public/js/background-work.js +13 -7
  251. package/template/coding-task-manager/public/js/client-health-core.js +81 -0
  252. package/template/coding-task-manager/public/js/client-health-main.js +61 -0
  253. package/template/coding-task-manager/public/js/coding-session-model-presentation.js +44 -4
  254. package/template/coding-task-manager/public/js/core/session-commands.js +676 -0
  255. package/template/coding-task-manager/public/js/core/session-selectors.js +5 -1
  256. package/template/coding-task-manager/public/js/history-app-seam.js +26 -2
  257. package/template/coding-task-manager/public/js/history-painted-rows.js +17 -2
  258. package/template/coding-task-manager/public/js/history-reader-park.js +13 -1
  259. package/template/coding-task-manager/public/js/history-scroller.js +187 -9
  260. package/template/coding-task-manager/public/js/history-transcript-pages.js +5 -5
  261. package/template/coding-task-manager/public/js/machine-pool-shared.js +436 -0
  262. package/template/coding-task-manager/public/js/private-state-scope.js +31 -3
  263. package/template/coding-task-manager/public/js/prompt-submission-client.js +173 -4
  264. package/template/coding-task-manager/public/js/prompt-submission-tray.js +281 -0
  265. package/template/coding-task-manager/public/js/protocol.js +0 -10
  266. package/template/coding-task-manager/public/js/queue-panel-submission-reconcile.js +174 -1
  267. package/template/coding-task-manager/public/js/reviews.js +7 -3
  268. package/template/coding-task-manager/public/js/root-draft-lifetime.js +139 -2
  269. package/template/coding-task-manager/public/js/root-queue-builder-lifetime.js +16 -0
  270. package/template/coding-task-manager/public/js/row-join.js +18 -3
  271. package/template/coding-task-manager/public/js/session-model-picker.js +1 -1
  272. package/template/coding-task-manager/public/js/session-search-utils.js +46 -19
  273. package/template/coding-task-manager/public/js/session-usage-view.js +1 -0
  274. package/template/coding-task-manager/public/js/state-sync-client.js +4 -0
  275. package/template/coding-task-manager/public/js/terminal/runtime-02.js +16 -7
  276. package/template/coding-task-manager/public/js/terminal/runtime-03.js +41 -0
  277. package/template/coding-task-manager/public/js/terminal/runtime-04.js +41 -4
  278. package/template/coding-task-manager/public/js/terminal/runtime-12.js +52 -2
  279. package/template/coding-task-manager/public/js/terminal/runtime-16.js +12 -12
  280. package/template/coding-task-manager/public/js/terminal/runtime-18.js +14 -0
  281. package/template/coding-task-manager/public/js/terminal/runtime-19.js +8 -4
  282. package/template/coding-task-manager/public/js/terminal/runtime-20.js +210 -73
  283. package/template/coding-task-manager/public/js/terminal/runtime-21.js +19 -6
  284. package/template/coding-task-manager/public/js/terminal/runtime-22.js +86 -0
  285. package/template/coding-task-manager/public/js/terminal-history-viewport.js +1 -3
  286. package/template/coding-task-manager/public/js/terminal-wheel.js +39 -2
  287. package/template/coding-task-manager/public/js/walle/chat-conversation.js +11 -3
  288. package/template/coding-task-manager/public/js/walle-session.js +30 -7
  289. package/template/coding-task-manager/public/m/app.css +226 -8
  290. package/template/coding-task-manager/public/m/app.js +502 -9
  291. package/template/coding-task-manager/public/m/index.html +38 -1
  292. package/template/coding-task-manager/public/m/machines.js +1313 -0
  293. package/template/coding-task-manager/public/m/pool-failover.js +391 -0
  294. package/template/coding-task-manager/public/m/private-queue-actions.js +26 -7
  295. package/template/coding-task-manager/public/m/sw.js +5 -0
  296. package/template/coding-task-manager/queue-engine.js +13 -0
  297. package/template/coding-task-manager/server/approvals.js +5 -6
  298. package/template/coding-task-manager/server/boot.js +136 -17
  299. package/template/coding-task-manager/server.js +964 -425
  300. package/template/coding-task-manager/session-integrity.js +7 -9
  301. package/template/coding-task-manager/workers/db-owner-worker.js +53 -7
  302. package/template/coding-task-manager/workers/read-pool-worker.js +17 -6
  303. package/template/coding-task-manager/workers/session-host-pool-process.js +18 -4
  304. package/template/coding-task-manager/workers/session-host-process.js +14 -3
  305. package/template/coding-task-manager/workers/transcript-archive-worker.js +1 -1
  306. package/template/coding-task-manager/workers/worktree-worker.js +34 -0
  307. package/template/package-lock.json +2 -2
  308. package/template/package.json +1 -1
  309. package/template/scripts/conversation-storage-v2-migration.js +78 -11
  310. package/template/shared/conversation-storage-v2-contract.js +42 -6
  311. package/template/shared/fixtures/worker-status-payloads.json +392 -0
  312. package/template/shared/git-remote-key.js +83 -0
  313. package/template/shared/host-identity.js +98 -2
  314. package/template/shared/owner-write-safety.js +135 -0
  315. package/template/shared/public-runtime.js +13 -5
  316. package/template/shared/release-schema.json +3 -2
  317. package/template/shared/report-citations.js +14 -0
  318. package/template/shared/sqlite-write-lock.js +361 -223
  319. package/template/shared/storage/contract.js +35 -3
  320. package/template/wall-e/agent.js +21 -0
  321. package/template/wall-e/api-walle.js +5 -0
  322. package/template/wall-e/bin/brain-purge-legacy-ctm.js +3 -2
  323. package/template/wall-e/bin/walle-mcp-stdio.js +12 -6
  324. package/template/wall-e/brain/helper-rules-schema.js +129 -0
  325. package/template/wall-e/brain/migrations.js +51 -22
  326. package/template/wall-e/brain/model-deployment-facts.js +31 -1
  327. package/template/wall-e/brain/owner-call-whitelist.js +16 -0
  328. package/template/wall-e/brain/schema-ensures.js +22 -0
  329. package/template/wall-e/brain.js +1 -1
  330. package/template/wall-e/coding/host-capabilities.js +2 -2
  331. package/template/wall-e/coding/journal-dispatcher.js +51 -0
  332. package/template/wall-e/coding/session-journal.js +28 -40
  333. package/template/wall-e/coding-orchestrator.js +23 -26
  334. package/template/wall-e/fly.toml +1 -1
  335. package/template/wall-e/http/auth.js +18 -1
  336. package/template/wall-e/http/blog-views-api.js +318 -0
  337. package/template/wall-e/http/helper-rules-api.js +321 -0
  338. package/template/wall-e/http/model-proxy-providers.js +3 -1
  339. package/template/wall-e/http/telemetry-api.js +12 -0
  340. package/template/wall-e/lib/blog-view-policy.js +88 -0
  341. package/template/wall-e/lib/blog-view-store.js +169 -0
  342. package/template/wall-e/lib/client-config-lock.js +2 -1
  343. package/template/wall-e/lib/coding-journal-writer-child-client.js +67 -18
  344. package/template/wall-e/lib/error-kinds.js +10 -1
  345. package/template/wall-e/lib/feature-manifest.js +1 -1
  346. package/template/wall-e/lib/helper-rules/cell-state.js +123 -0
  347. package/template/wall-e/lib/helper-rules/evaluate.js +357 -0
  348. package/template/wall-e/lib/helper-rules/explore-rubric.md +12 -0
  349. package/template/wall-e/lib/helper-rules/grade.js +277 -0
  350. package/template/wall-e/lib/helper-rules/label.js +247 -0
  351. package/template/wall-e/lib/helper-rules/lead-call-hook.js +522 -0
  352. package/template/wall-e/lib/helper-rules/store.js +667 -0
  353. package/template/wall-e/lib/mcp-integration.js +5 -6
  354. package/template/wall-e/lib/runtime-worker-pool.js +1 -0
  355. package/template/wall-e/lib/scheduler-worker-jobs.js +6 -0
  356. package/template/wall-e/lib/telemetry-db-schema.js +37 -0
  357. package/template/wall-e/lib/telemetry-process-client.js +76 -1
  358. package/template/wall-e/llm/default-fallback.js +4 -0
  359. package/template/wall-e/llm/model-catalog.js +13 -4
  360. package/template/wall-e/llm/model-roles.js +6 -0
  361. package/template/wall-e/llm/portkey-http.js +150 -0
  362. package/template/wall-e/llm/portkey-sync.js +15 -145
  363. package/template/wall-e/llm/worker-mcp-routing.js +61 -0
  364. package/template/wall-e/llm/worker-route.js +43 -1
  365. package/template/wall-e/loops/brain-custom-writes.js +45 -0
  366. package/template/wall-e/mcp-protocol-contract.js +41 -1
  367. package/template/wall-e/mcp-server.js +13 -2
  368. package/template/wall-e/mcp-worker-schemas.js +20 -2
  369. package/template/wall-e/memory/ctm-context-client.js +25 -5
  370. package/template/wall-e/shared/release-schema.json +3 -2
  371. package/template/wall-e/shared/sqlite-write-lock.js +361 -223
  372. package/template/wall-e/shared/storage/contract.js +35 -3
  373. package/template/wall-e/skills/_templates/delegate-worker/VERSION +1 -1
  374. package/template/wall-e/skills/_templates/delegate-worker/released-sha256.json +6 -0
  375. package/template/wall-e/skills/_templates/delegate-worker/templates/claude/SKILL.md +7 -2
  376. package/template/wall-e/skills/_templates/delegate-worker/templates/codex/SKILL.md +7 -2
  377. package/template/wall-e/tools/local-tools.js +12 -5
  378. package/template/wall-e/workers/brain-owner-worker.js +9 -0
  379. package/template/wall-e/workers/coding-journal-writer-child.js +8 -1
  380. package/template/wall-e/workers/telemetry-worker.js +8 -0
  381. package/template/coding-task-manager/lib/giant-freeze-park.js +0 -31
@@ -0,0 +1,939 @@
1
+ 'use strict';
2
+
3
+ // WP-3 (docs/remote-machine-hub-sessions-2026-10-22.html §6.4, §7, §8 AC-3a..3d, §9 S-22/S-23/S-24):
4
+ // the hub-side LIVE PLANE relay.
5
+ //
6
+ // A remote session (created through lib/remote-machine-create.js — WP-2) lives IN THE BOX's
7
+ // process. It is deliberately NOT an entry in this process's `sessions` Map (OQ-4: ~16 list/attach
8
+ // `.ptyProcess` dereferences is more than merging is worth), so every frame the hub's browser sends
9
+ // about it — a keystroke, a prompt, an approval verdict, a resize — must be carried to the box and
10
+ // every frame the box produces must be carried back. This module owns that carry:
11
+ //
12
+ // LINK one upstream WS per machine, lazy, opened on the first viewed session and closed
13
+ // after IDLE_CLOSE_MS with no viewers. `listLinks().length <= 1` per machine by
14
+ // construction (a Map keyed by the machine's minted instance id).
15
+ // INTERCEPT the single dispatch seam (lib/ws-router.js's pre-dispatch hook, consulted at
16
+ // `dispatchWs` before ANY local handler). A message naming a MIRROR session is
17
+ // diverted here; a message naming a local session returns false immediately and takes
18
+ // the byte-identical existing path (S-22, and the brief's "local sessions must take
19
+ // the byte-identical existing path").
20
+ // FORWARD hub browser -> box, VERBATIM. `clientMessageId` (S-24) and every other id is the
21
+ // ORIGINAL one the browser minted — this module never re-mints an idempotency key.
22
+ // FAN-OUT box -> every hub browser socket viewing that session (S-22). Reply frames
23
+ // (submit-prompt-status, approval-resolution-result, model-change-result, parked,
24
+ // killed, …) go to the ORIGINATING hub socket ONLY, matched by the original
25
+ // `clientMessageId` when the frame carries one.
26
+ // RECONNECT full-jitter backoff 500 ms -> 30 s cap, reset on success; on every (re)connect the
27
+ // link re-attaches every viewed session and issues `keyframe-req`, so the box's own
28
+ // seq/keyframe authority is what re-syncs content (no duplicate/no gap by design).
29
+ // PINGS two consecutive missed WS pings force a reconnect (the link owns its own liveness —
30
+ // the peer-control allowlist deliberately has no CTM `ping` message).
31
+ //
32
+ // BACKPRESSURE (§10 risk 2), the choice this module makes: the box sees exactly ONE attached
33
+ // client — the link — so its credit/window is the LINK's, not any single hub browser's. Fan-out to
34
+ // hub sockets is a local, unconditional, non-blocking send per socket, so a slow/stalled hub viewer
35
+ // can never stall the frames the OTHER viewers of the same session receive; the hub socket acks
36
+ // (`ack`/`nack`/`frame-receipt`) are forwarded upstream VERBATIM, per session, which is the credit
37
+ // signal the box already understands (lib/ws/state-sync.js keys acks by session). Nothing is
38
+ // re-minted, no local credit is invented.
39
+ //
40
+ // No durable state (lead ruling 4): links and viewer refcounts are in-memory and rebuilt lazily
41
+ // from viewed sessions after a hub restart. Everything is injectable for tests.
42
+
43
+ const { registerWsPreDispatchHook } = require('./ws-router');
44
+ const remoteMachineCreate = require('./remote-machine-create');
45
+
46
+ const BACKOFF_BASE_MS = 500;
47
+ const BACKOFF_CAP_MS = 30000;
48
+ const IDLE_CLOSE_MS = 60000;
49
+ const PING_MISS_LIMIT = 2;
50
+ const HEARTBEAT_MS = 15000;
51
+
52
+ // Message types a hub browser sends that name a session and therefore may be relayed upstream.
53
+ // Deliberately a closed set: a type nobody classified is NOT relayed (it falls through to the
54
+ // local handler, which is the pre-WP-3 behaviour for a session it does not know).
55
+ const RELAY_TYPES = Object.freeze(new Set([
56
+ // viewing / resync (S-22)
57
+ // WP-3b: `terminal-bootstrap-ack` is the matched pair of `terminal-bootstrap` (the viewer's
58
+ // receipt of the cold bootstrap) — both cross the hop, so both are relayed.
59
+ 'attach', 'view', 'keyframe-req', 'snapshot', 'terminal-bootstrap', 'terminal-bootstrap-ack', 'wake-resync',
60
+ // typing + geometry
61
+ 'input', 'resize', 'reflow', 'reconcile', 'flow', 'detach', 'interrupt-session',
62
+ // prompts + approvals + model (S-23/S-24). `image-deliver-now` is WP-3 of
63
+ // docs/remote-session-image-upload-forwarding-2026-10-24.html §6: the viewer asks the BOX that
64
+ // owns the session to deliver the already-uploaded draft, so the frame must cross the hop.
65
+ // WP-1 (docs/remote-resume-choice-click-2026-10-25.html §5 D-1, §9 S-1): `resume-prompt-select`
66
+ // is the resume strip's choice chip. A mirror id must reach the BOX that owns the session —
67
+ // otherwise the hub's LOCAL handler rejects the click
68
+ // (`resume-prompt-rejected {reason:'session_not_found',channel:'choice'}`).
69
+ 'submit-prompt', 'approval-resolve', 'model-change', 'image-deliver-now',
70
+ 'resume-prompt-select',
71
+ // lifecycle (WP-6 routes the hub UI here; the relay just carries them)
72
+ 'park', 'wake', 'kill',
73
+ // delivery credit (lib/ws/state-sync.js keys these by session)
74
+ 'ack', 'nack', 'frame-receipt',
75
+ ]));
76
+
77
+ // The subset that means "this socket is viewing the session" — the first viewer of a session
78
+ // triggers the upstream attach + keyframe-req.
79
+ const VIEW_TYPES = Object.freeze(new Set([
80
+ 'attach', 'view', 'keyframe-req', 'snapshot', 'terminal-bootstrap', 'wake-resync',
81
+ ]));
82
+
83
+ // Downstream types that are a REPLY to one specific request, so they are delivered to the
84
+ // originating hub socket only (never fanned out to co-viewers).
85
+ //
86
+ // WP-6 fix: this set lists the frame types the BOX ACTUALLY SENDS back for a relayed request. The
87
+ // pre-WP-6 set named `parked`, `killed` and `wake-result`, which do not exist anywhere in the box's
88
+ // code — so a box's park/wake reply was fanned out to every co-viewer of the session instead of
89
+ // being routed to the socket that asked. The real answers are:
90
+ // park / pin / wake -> lib/ws/lifecycle.js `session-lifecycle-result` (+ `action`)
91
+ // interrupt-session -> lib/ws/input.js `session-interrupt-result`
92
+ // resume-prompt-select -> lib/ws/input.js `resume-prompt-result`
93
+ // kill -> a BROADCAST `exit` frame (server.js handleKill), which is NOT a
94
+ // reply: every viewer must see it, so it is deliberately absent here
95
+ // and fans out.
96
+ // tests/remote-machine-link.test.js pins this set against the `type:` literals the box's own
97
+ // handlers emit, so a renamed reply type fails the test instead of silently fanning out.
98
+ const REPLY_TYPES = Object.freeze(new Set([
99
+ 'submit-prompt-status',
100
+ 'approval-resolution-result',
101
+ 'model-change-result',
102
+ 'session-lifecycle-result',
103
+ 'session-interrupt-result',
104
+ 'resume-prompt-result',
105
+ 'request-error',
106
+ 'error',
107
+ ]));
108
+
109
+ // Requests whose reply (or auth-error verdict) is routed back to the originating socket. `input`
110
+ // is deliberately absent: a keystroke has no reply, and remembering every one would evict the
111
+ // pending prompt/approval entries from the bounded map below.
112
+ // WP-1 (docs/remote-resume-choice-click-2026-10-25.html §5 D-3, §9 S-2): `resume-prompt-select`
113
+ // is remembered so its `resume-prompt-result` (which echoes the browser's `requestId`) can be
114
+ // routed to the socket that asked instead of being fanned out to every co-viewer.
115
+ const REMEMBER_TYPES = Object.freeze(new Set([
116
+ 'submit-prompt', 'approval-resolve', 'model-change', 'park', 'wake', 'kill', 'interrupt-session',
117
+ 'resume-prompt-select',
118
+ ]));
119
+ const PENDING_TTL_MS = 60000;
120
+ const PENDING_MAX = 512;
121
+ // Bounded queue of frames sent to a link that is still dialing (see forward()):
122
+ const PENDING_SEND_MAX = 64;
123
+
124
+ // WP-6: the request -> reply-type pairs the box's handlers really implement (see REPLY_TYPES).
125
+ const REPLY_FOR_REQUEST = Object.freeze({
126
+ 'submit-prompt': 'submit-prompt-status',
127
+ 'approval-resolve': 'approval-resolution-result',
128
+ 'model-change': 'model-change-result',
129
+ 'park': 'session-lifecycle-result',
130
+ 'wake': 'session-lifecycle-result',
131
+ 'interrupt-session': 'session-interrupt-result',
132
+ 'resume-prompt-select': 'resume-prompt-result',
133
+ });
134
+
135
+ // WP-6: how long a catalog `list` request waits for the box's `sessions` reply before giving up.
136
+ const CATALOG_TIMEOUT_MS = 5000;
137
+
138
+ let _injected = null;
139
+ let _uninstallIntercept = null;
140
+ let _productionCache = null;
141
+
142
+ /** Test/host seam: override any production dependency (registry, WebSocket, timers, clock, log). */
143
+ function configure(deps) {
144
+ _injected = deps && typeof deps === 'object' ? deps : null;
145
+ }
146
+
147
+ function _string(value, max = 200) {
148
+ return String(value == null ? '' : value).trim().slice(0, max);
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------------------------
152
+ // Dependencies (lazy: a pure unit test must never load db.js / machine-registry)
153
+ // ---------------------------------------------------------------------------------------------
154
+
155
+ function _defaultMachineRow(machineId, deps) {
156
+ const registry = require('./machine-registry');
157
+ const db = require('../db-owner-client');
158
+ const listed = registry.listMachines({ db }) || {};
159
+ const machines = Array.isArray(listed.machines) ? listed.machines : [];
160
+ return machines.find((row) => _string(row.instanceId, 160) === _string(machineId, 160)) || null;
161
+ }
162
+
163
+ function _productionDeps() {
164
+ return {
165
+ env: process.env,
166
+ now: () => Date.now(),
167
+ random: Math.random,
168
+ setTimeoutFn: global.setTimeout,
169
+ clearTimeoutFn: global.clearTimeout,
170
+ setIntervalFn: global.setInterval,
171
+ clearIntervalFn: global.clearInterval,
172
+ // The machine ROW (url/enabled/health) for a registered machine — resolved through the same
173
+ // registry read the routes and WP-2's forward use.
174
+ machineRow: _defaultMachineRow,
175
+ // Which machine owns a ctm session id (lib/remote-machine-create.js's mirror registry, which
176
+ // WP-6 widened to the union over the live catalog and the at-rest sync mirror rows).
177
+ mirrorFor: (sessionId) => remoteMachineCreate.getMirrorSessionMachine(sessionId),
178
+ // WP-6 catalog lane. The hooks are lazy `require`s and the catalog never requires THIS module
179
+ // back (it takes the link as a peer wired by server.js), so the lib/** graph stays acyclic.
180
+ onLinkReady: (link) => { try { require('./remote-machine-catalog').onLinkReady(link); } catch { /* expected: a catalog fault must never break the link */ } },
181
+ onSessionList: (link, rows, frame) => { try { require('./remote-machine-catalog').recordSessionList(link.machineInstanceId, rows); } catch { /* expected: ditto — the box's list is a freshness bonus, not a link obligation */ } },
182
+ onRelayedKill: (link) => { try { require('./remote-machine-catalog').fetchCatalog(link.machineInstanceId); } catch { /* expected: ditto — the kill already crossed; the refresh is best effort */ } },
183
+ catalogTimeoutMs: CATALOG_TIMEOUT_MS,
184
+ log: console,
185
+ idleCloseMs: IDLE_CLOSE_MS,
186
+ heartbeatMs: HEARTBEAT_MS,
187
+ backoffBaseMs: BACKOFF_BASE_MS,
188
+ backoffCapMs: BACKOFF_CAP_MS,
189
+ };
190
+ }
191
+
192
+ function _d(overrides) {
193
+ // The production dependency object is PURE (constants + closures, no per-call state), and it is
194
+ // built on the hot path: `interceptRelay` runs for every relayable frame of every LOCAL session
195
+ // too (a keystroke, a resize, a frame-receipt), so re-assembling 14 properties per message would
196
+ // tax exactly the typing lane this module must not slow down. Memoized once; a caller that passes
197
+ // its own deps still gets a private merged object.
198
+ if (!_productionCache) _productionCache = _productionDeps();
199
+ if (!_injected && !overrides) return _productionCache;
200
+ return Object.assign({}, _productionCache, _injected || {}, overrides || {});
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------------------------
204
+ // Small helpers
205
+ // ---------------------------------------------------------------------------------------------
206
+
207
+ /**
208
+ * The session id a frame names. `view` is the one set-declaring type (D-1), so it yields every id
209
+ * it declares; every other type yields its single `id` (or `sessionId`, the spelling a few frames
210
+ * use).
211
+ */
212
+ function sessionIdsOf(msg) {
213
+ if (!msg || typeof msg !== 'object') return [];
214
+ if (msg.type === 'view') {
215
+ const ids = Array.isArray(msg.ids) ? msg.ids : [];
216
+ const out = [];
217
+ for (const raw of ids.slice(0, 64)) {
218
+ const id = _string(raw, 200);
219
+ if (id && !out.includes(id)) out.push(id);
220
+ }
221
+ return out;
222
+ }
223
+ const id = _string(msg.id || msg.sessionId, 200);
224
+ return id ? [id] : [];
225
+ }
226
+
227
+ function _frameSessionId(frame) {
228
+ if (!frame || typeof frame !== 'object') return '';
229
+ return _string(frame.id || frame.sessionId, 200);
230
+ }
231
+
232
+ /** Deliver an already-serialized frame to one hub browser socket (fan-out or reply routing). */
233
+ function _sendToCtx(ctx, payload) {
234
+ try {
235
+ if (ctx && ctx.ws && ctx.ws.readyState === 1 && typeof ctx.send === 'function') ctx.send(payload);
236
+ } catch {
237
+ // expected: the hub browser socket died while a relayed frame was in flight — the box owns the
238
+ // session and WP-5's UI re-subscribes on the next connect.
239
+ }
240
+ }
241
+
242
+ function _sendFrame(ctx, frame) {
243
+ _sendToCtx(ctx, JSON.stringify(frame));
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------------------------
247
+ // Links
248
+ // ---------------------------------------------------------------------------------------------
249
+
250
+ const _links = new Map(); // machineInstanceId -> link
251
+
252
+ function _newLink(machineId, machineName, url) {
253
+ return {
254
+ machineInstanceId: machineId,
255
+ machineName,
256
+ url,
257
+ socket: null,
258
+ ready: false,
259
+ closed: false,
260
+ dialing: false,
261
+ // WP-1 (D-3): 'idle' | 'credential_missing'. The latter is terminal — the link stays usable
262
+ // in-memory (no socket, no timers) and only an explicit openLink on a hub whose env now
263
+ // carries the token dials again.
264
+ state: 'idle',
265
+ attempts: 0,
266
+ generation: 0,
267
+ pingMisses: 0,
268
+ heartbeatTimer: null,
269
+ reconnectTimer: null,
270
+ idleTimer: null,
271
+ viewers: new Map(), // sessionId -> Set<ctx>
272
+ pending: new Map(), // key -> {ctx, replyType, at}
273
+ pendingSeq: 0,
274
+ pendingSends: [], // frames sent while the link was still dialing (flushed on hello)
275
+ // WP-6 catalog lane: the box's `sessions` reply to a `list` request. `catalogUsers` is the
276
+ // refcount of catalog-initiated users — it holds the 60 s idle close off while a request is in
277
+ // flight and is released the moment the reply lands, so a catalog fetch never keeps a machine
278
+ // link open.
279
+ catalogWaiters: new Map(), // seq -> finish(result)
280
+ catalogSeq: 0,
281
+ catalogUsers: 0,
282
+ lastSessions: null, // {rows, frame, at} — the most recent list the box sent this link
283
+ };
284
+ }
285
+
286
+ function linkFor(machineId) {
287
+ return _links.get(_string(machineId, 160)) || null;
288
+ }
289
+
290
+ function listLinks() {
291
+ return [..._links.values()];
292
+ }
293
+
294
+ /**
295
+ * Open (or reuse) the one link to a machine. `machine` is a registry row or a machine instance id.
296
+ * Never throws: a machine that is not registered / disabled / url-less yields null and the caller
297
+ * degrades to the existing "no frames" UX.
298
+ */
299
+ function openLink(machine, deps) {
300
+ const d = _d(deps);
301
+ const row = typeof machine === 'string' ? d.machineRow(_string(machine, 160), d) : machine;
302
+ if (!row) return null;
303
+ const machineId = _string(row.instanceId, 160);
304
+ if (!machineId) return null;
305
+ const existing = _links.get(machineId);
306
+ if (existing && !existing.closed) {
307
+ // D-3: a link parked on a missing credential is retried by the NEXT acquire — that is the
308
+ // "hub restarted with CTM_SYNC_PEER_TOKEN set" path, and it is the only thing that can clear
309
+ // the state, because nothing here polls. The state is deliberately NOT reset first: `_dial`
310
+ // reads `credential_missing` as "already reported", so a hub that still has no credential
311
+ // re-dials on each acquire without logging the same line again (and a successful dial clears it).
312
+ if (existing.state === 'credential_missing') _dial(existing, d);
313
+ return existing;
314
+ }
315
+ if (!row.enabled) return null;
316
+ const url = _string(row.url, 1000);
317
+ if (!url) return null;
318
+ if (_string(row.health, 40) === 'unreachable') return null;
319
+ const link = _newLink(machineId, _string(row.displayName, 200), url);
320
+ _links.set(machineId, link);
321
+ _dial(link, d);
322
+ return link;
323
+ }
324
+
325
+ function _dial(link, d) {
326
+ if (link.closed || link.dialing || link.ready) return;
327
+ link.dialing = true;
328
+ const dial = remoteMachineCreate.preparePeerDial(link.url, { ...d, machineName: link.machineName });
329
+ if (!dial.ok) {
330
+ link.dialing = false;
331
+ // WP-1 (D-3): a missing peer credential is a CONFIGURATION error, not a transient one. The
332
+ // token is read from process.env on every call and env changes only on restart, so a retry
333
+ // would fail identically forever — one report, then a terminal state, no reconnect timer.
334
+ // `openLink` re-dials from that state, which is what a hub restarted WITH the credential needs.
335
+ if (dial.code === remoteMachineCreate.PEER_CREDENTIAL_MISSING) {
336
+ if (link.state !== 'credential_missing') {
337
+ link.state = 'credential_missing';
338
+ _report(d, `link ${link.machineInstanceId}: ${dial.error}`);
339
+ }
340
+ return;
341
+ }
342
+ _report(d, `link ${link.machineInstanceId}: ${dial.error}`);
343
+ _scheduleReconnect(link, d);
344
+ return;
345
+ }
346
+ link.state = 'idle';
347
+ const generation = link.generation + 1;
348
+ link.generation = generation;
349
+ let socket = null;
350
+ try {
351
+ socket = new dial.WebSocketImpl(dial.url, { headers: dial.headers });
352
+ } catch (error) {
353
+ link.dialing = false;
354
+ _report(d, `link ${link.machineInstanceId}: connect failed: ${error?.message || error}`);
355
+ _scheduleReconnect(link, d);
356
+ return;
357
+ }
358
+ link.socket = socket;
359
+ const isCurrent = () => link.generation === generation && !link.closed;
360
+ socket.on('message', (raw) => { if (isCurrent()) _onUpstreamFrame(link, raw, d); });
361
+ socket.on('pong', () => { if (isCurrent()) link.pingMisses = 0; });
362
+ socket.on('error', (error) => { if (isCurrent()) _report(d, `link ${link.machineInstanceId}: socket error: ${error?.message || error}`); });
363
+ socket.on('close', () => { if (isCurrent()) _onSocketGone(link, d); });
364
+ }
365
+
366
+ function _onSocketGone(link, d) {
367
+ link.socket = null;
368
+ link.ready = false;
369
+ link.dialing = false;
370
+ _stopHeartbeat(link, d);
371
+ if (link.closed) return;
372
+ _scheduleReconnect(link, d);
373
+ }
374
+
375
+ /** Full-jitter backoff: `random() * min(cap, base * 2^attempt)`. */
376
+ function backoffDelayMs(attempt, d) {
377
+ const base = Number(d?.backoffBaseMs || BACKOFF_BASE_MS) || BACKOFF_BASE_MS;
378
+ const cap = Number(d?.backoffCapMs || BACKOFF_CAP_MS) || BACKOFF_CAP_MS;
379
+ const ceiling = Math.min(cap, base * (2 ** Math.max(0, attempt)));
380
+ const random = typeof d?.random === 'function' ? d.random : Math.random;
381
+ return Math.max(0, Math.floor(random() * ceiling));
382
+ }
383
+
384
+ function _scheduleReconnect(link, d) {
385
+ if (link.closed || link.reconnectTimer) return;
386
+ const delay = backoffDelayMs(link.attempts, d);
387
+ link.attempts += 1;
388
+ const timer = d.setTimeoutFn(() => {
389
+ link.reconnectTimer = null;
390
+ if (!link.closed) _dial(link, d);
391
+ }, delay);
392
+ if (timer && typeof timer.unref === 'function') timer.unref();
393
+ link.reconnectTimer = timer;
394
+ }
395
+
396
+ function _startHeartbeat(link, d) {
397
+ _stopHeartbeat(link, d);
398
+ const every = Number(d.heartbeatMs || HEARTBEAT_MS) || HEARTBEAT_MS;
399
+ const timer = d.setIntervalFn(() => heartbeatTick(link, d), every);
400
+ if (timer && typeof timer.unref === 'function') timer.unref();
401
+ link.heartbeatTimer = timer;
402
+ }
403
+
404
+ function _stopHeartbeat(link, d) {
405
+ if (link.heartbeatTimer) {
406
+ try { d.clearIntervalFn(link.heartbeatTimer); } catch { /* expected: the injected interval is already cleared */ }
407
+ link.heartbeatTimer = null;
408
+ }
409
+ }
410
+
411
+ /**
412
+ * One liveness tick. Exposed (and driven by the injected interval) so a test can prove the
413
+ * "two missed pings -> reconnect" rule with mock timers and no real sleep.
414
+ */
415
+ function heartbeatTick(link, deps) {
416
+ const d = _d(deps);
417
+ if (link.closed || !link.ready || !link.socket) return;
418
+ if (link.pingMisses >= PING_MISS_LIMIT) {
419
+ _report(d, `link ${link.machineInstanceId}: ${link.pingMisses} missed pings — reconnecting`);
420
+ _forceReconnect(link, d);
421
+ return;
422
+ }
423
+ link.pingMisses += 1;
424
+ try { link.socket.ping?.(); } catch { /* expected: the socket died between the ready check and the ping */ }
425
+ }
426
+
427
+ function _forceReconnect(link, d) {
428
+ const socket = link.socket;
429
+ // Bump the generation FIRST: the close/error handlers of the socket we are abandoning must not
430
+ // schedule a second reconnect for the same dead connection.
431
+ link.generation += 1;
432
+ link.socket = null;
433
+ link.ready = false;
434
+ link.dialing = false;
435
+ _stopHeartbeat(link, d);
436
+ try { socket?.terminate?.(); } catch { /* expected: the abandoned socket is already gone */ }
437
+ if (!link.closed) _scheduleReconnect(link, d);
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------------------------
441
+ // Upstream (box -> hub)
442
+ // ---------------------------------------------------------------------------------------------
443
+
444
+ function _onUpstreamFrame(link, raw, d) {
445
+ let frame = null;
446
+ try {
447
+ frame = JSON.parse(raw.toString());
448
+ } catch {
449
+ return; // expected: a binary/non-JSON frame on this lane is not a relayable protocol frame
450
+ }
451
+ if (!frame || typeof frame !== 'object') return;
452
+
453
+ if (frame.type === 'hello') {
454
+ _onHello(link, frame, d);
455
+ return;
456
+ }
457
+ if (frame.type === 'sessions') {
458
+ // WP-6: the box's session list — the reply to our catalog `list` request, and also a frame the
459
+ // box BROADCASTS on every session-list change (server.js handleList / the list broadcast). It
460
+ // names no single session, so it is recorded for the catalog and settled to any waiter; it is
461
+ // never fanned out to a hub browser (the hub builds its own list).
462
+ _deliverSessionList(link, frame, d);
463
+ return;
464
+ }
465
+ if (frame.type === 'terminal-protocol-incompatible') {
466
+ _report(d, `link ${link.machineInstanceId}: box refused the protocol: ${_string(frame.reason, 80)}`);
467
+ _forceReconnect(link, d);
468
+ return;
469
+ }
470
+ if (frame.type === 'auth-error') {
471
+ // The box answers a peer's client-info with `auth-error insufficient_scope` (ws build
472
+ // compatibility runs before the peer-control rule table) — routine noise on this lane,
473
+ // tolerated exactly as WP-2's dial tolerates it. A verdict that NAMES a relayed message type
474
+ // is the box refusing THAT frame, so it is reported to the socket that sent it instead of
475
+ // being swallowed (a silent refusal is indistinguishable from a broken relay).
476
+ _routeAuthError(link, frame);
477
+ return;
478
+ }
479
+
480
+ const sessionId = _frameSessionId(frame);
481
+ if (!sessionId) return;
482
+ if (_routeReplyToOrigin(link, frame, sessionId, d)) return;
483
+ _fanout(link, frame, sessionId, d);
484
+ }
485
+
486
+ /**
487
+ * The box's own handshake. Both the client-info AND the re-attach of every viewed session must
488
+ * follow it, in that order: the box's protocol gate drops a terminal-protocol frame that arrives
489
+ * before a compatible client-info (see lib/remote-machine-create.js's dial-order note).
490
+ */
491
+ function _onHello(link, frame, d) {
492
+ const alreadyReady = link.ready;
493
+ link.ready = true;
494
+ link.dialing = false;
495
+ link.attempts = 0; // backoff resets ON SUCCESS (full-jitter contract)
496
+ _sendUpstream(link, remoteMachineCreate.peerClientInfoFrame(frame), d);
497
+ // Re-attach AFTER the client-info above: the box's protocol gate drops a terminal-protocol frame
498
+ // that arrives before a compatible client-info (the dial-order rule the create lane learned).
499
+ link.pingMisses = 0;
500
+ _startHeartbeat(link, d);
501
+ for (const sessionId of link.viewers.keys()) _attachUpstream(link, sessionId, d, alreadyReady ? 'link-reconnect' : 'link-ready');
502
+ // WP-6: a catalog `list` requested while the link was still dialing is flushed HERE, after the
503
+ // client-info above — the box's protocol gate drops a terminal-protocol frame that arrives first.
504
+ for (const queued of link.pendingSends.splice(0)) _sendUpstream(link, queued, d);
505
+ // A catalog request that was queued while this link was dialing sends its `list` HERE, so this
506
+ // hello has already asked the box for its list — skip the refresh hook below rather than paying a
507
+ // second identical round-trip on the same connection.
508
+ const catalogAsked = link.catalogWaiters.size > 0;
509
+ for (const finish of [...link.catalogWaiters.values()]) _sendCatalogList(link, finish, d);
510
+ // WP-6 §8: "on link (re)connect" is one of the catalog's refresh triggers. Injected (not
511
+ // required) so this module never has to depend on the catalog module it feeds.
512
+ if (!catalogAsked && typeof d.onLinkReady === 'function') {
513
+ try { d.onLinkReady(link); } catch { /* expected: a catalog hook fault must not break the link */ }
514
+ }
515
+ }
516
+
517
+ function _sendCatalogList(link, finish, d) {
518
+ if (!_sendUpstream(link, { type: 'list' }, d)) {
519
+ finish({ ok: false, code: 'machine_unreachable', error: 'catalog_send_failed' });
520
+ }
521
+ }
522
+
523
+ function _deliverSessionList(link, frame, d) {
524
+ const rows = Array.isArray(frame.sessions) ? frame.sessions : [];
525
+ link.lastSessions = { rows, frame, at: d.now ? d.now() : Date.now() };
526
+ if (typeof d.onSessionList === 'function') {
527
+ try { d.onSessionList(link, rows, frame); } catch { /* expected: a catalog hook fault must not break the link */ }
528
+ }
529
+ for (const finish of [...link.catalogWaiters.values()]) finish({ ok: true, rows, frame });
530
+ }
531
+
532
+ /**
533
+ * WP-6 (§6.6/AC-6a): ask the box for its session list over this link. One link user for the
534
+ * duration of the request, released on the reply — so the 60 s idle close still applies to a
535
+ * catalog-only link and the hub never accumulates a permanently open connection per machine.
536
+ *
537
+ * @returns {Promise<{ok: true, rows: Array, frame: object}|{ok: false, code: string, error: string}>}
538
+ */
539
+ function requestSessionList(link, deps = {}) {
540
+ return new Promise((resolve) => {
541
+ const d = _d(deps);
542
+ if (!link || link.closed) return resolve({ ok: false, code: 'machine_unreachable', error: 'link_closed' });
543
+ const timeoutMs = Number(d.catalogTimeoutMs || CATALOG_TIMEOUT_MS) || CATALOG_TIMEOUT_MS;
544
+ const seq = ++link.catalogSeq;
545
+ let done = false;
546
+ let timer = null;
547
+ const finish = (result) => {
548
+ if (done) return;
549
+ done = true;
550
+ if (timer) { try { d.clearTimeoutFn(timer); } catch { /* expected: the injected timer already fired */ } }
551
+ link.catalogWaiters.delete(seq);
552
+ _releaseCatalogUser(link, d);
553
+ resolve(result);
554
+ };
555
+ link.catalogWaiters.set(seq, finish);
556
+ _holdCatalogUser(link, d);
557
+ timer = d.setTimeoutFn(() => finish({ ok: false, code: 'machine_unreachable', error: 'catalog_timeout' }), timeoutMs);
558
+ if (timer && typeof timer.unref === 'function') timer.unref();
559
+ if (link.ready) _sendCatalogList(link, finish, d);
560
+ });
561
+ }
562
+
563
+ function _holdCatalogUser(link, d) {
564
+ link.catalogUsers = (Number(link.catalogUsers) || 0) + 1;
565
+ _clearIdleClose(link, d);
566
+ }
567
+
568
+ function _releaseCatalogUser(link, d) {
569
+ link.catalogUsers = Math.max(0, (Number(link.catalogUsers) || 0) - 1);
570
+ _maybeScheduleIdleClose(link, d);
571
+ }
572
+
573
+ function _attachUpstream(link, sessionId, d, reason) {
574
+ const sentAttach = _sendUpstream(link, { type: 'attach', id: sessionId }, d);
575
+ const sentKeyframe = _sendUpstream(link, {
576
+ type: 'keyframe-req', id: sessionId, requestToken: `hub-link-${sessionId}`, reason: _string(reason, 48) || 'hub-link',
577
+ }, d);
578
+ return sentAttach && sentKeyframe;
579
+ }
580
+
581
+ /** The ONE raw send site for this module's upstream lane (the peer socket, not a hub browser). */
582
+ function _sendUpstream(link, frame, d) {
583
+ if (!link || !link.socket || !link.ready) return false;
584
+ try {
585
+ link.socket.send(JSON.stringify(frame));
586
+ return true;
587
+ } catch (error) {
588
+ _report(d, `link ${link.machineInstanceId}: send failed: ${error?.message || error}`);
589
+ return false;
590
+ }
591
+ }
592
+
593
+ function _pendingKey(sessionId, frame) {
594
+ const cm = _string(frame && frame.clientMessageId, 200);
595
+ return cm ? `${sessionId}\u0000cm:${cm}` : '';
596
+ }
597
+
598
+ function _rememberPending(link, sessionId, msg, ctx, d) {
599
+ if (!REMEMBER_TYPES.has(msg.type)) return;
600
+ const now = d.now();
601
+ // Bounded by construction: a keystroke stream cannot grow this map without bound.
602
+ for (const [key, entry] of link.pending) {
603
+ if (now - entry.at > PENDING_TTL_MS) link.pending.delete(key);
604
+ }
605
+ while (link.pending.size >= PENDING_MAX) {
606
+ const oldest = link.pending.keys().next();
607
+ if (oldest.done) break;
608
+ link.pending.delete(oldest.value);
609
+ }
610
+ const clientMessageId = _string(msg.clientMessageId, 200);
611
+ // WP-1 (docs/remote-resume-choice-click-2026-10-25.html §5 D-3, §6): the browser mints a
612
+ // `requestId` for a resume choice and the box echoes it back; keeping it on the pending entry is
613
+ // what lets two concurrent viewers' results be told apart (a requestId-less request stores '').
614
+ const requestId = _string(msg.requestId, 200);
615
+ const key = _pendingKey(sessionId, msg) || `${sessionId}\u0000seq:${++link.pendingSeq}`;
616
+ link.pending.set(key, {
617
+ ctx, replyType: REPLY_FOR_REQUEST[msg.type] || '', requestType: msg.type, clientMessageId, requestId, at: now, sessionId,
618
+ });
619
+ }
620
+
621
+ function _takePending(link, match) {
622
+ for (const [key, entry] of link.pending) {
623
+ if (match(entry)) {
624
+ link.pending.delete(key);
625
+ return entry;
626
+ }
627
+ }
628
+ return null;
629
+ }
630
+
631
+ /**
632
+ * The box's `auth-error` verdict on a relayed frame names the message type it refused (the §6.2
633
+ * peer-control allowlist is a closed set, so a relayed type it does not contain is refused here).
634
+ * Deliver it to the originating socket; the routine verdict on our own client-info carries no
635
+ * `messageType` and stays noise.
636
+ */
637
+ function _routeAuthError(link, frame) {
638
+ const messageType = _string(frame.messageType, 40);
639
+ if (!messageType) return;
640
+ const entry = _takePending(link, (e) => e.requestType === messageType);
641
+ if (entry) _sendFrame(entry.ctx, frame);
642
+ }
643
+
644
+ /**
645
+ * Route a reply frame to the ORIGINATING hub socket only (S-22): matched by the ORIGINAL
646
+ * `clientMessageId` first (S-24 — the id the browser minted, forwarded verbatim), then by the
647
+ * request/response type pair.
648
+ */
649
+ function _routeReplyToOrigin(link, frame, sessionId, d) {
650
+ if (!REPLY_TYPES.has(frame.type)) return false;
651
+ // WP-1 (docs/remote-resume-choice-click-2026-10-25.html §5 D-3, §6, RFC 8887 §4.3.2): a resume
652
+ // choice result is correlated by the `requestId` the browser minted and the box echoed — never by
653
+ // "oldest pending entry of this reply type" — because two viewers of ONE mirror session can have
654
+ // their choices in flight at once and the box may complete them out of order. An identified result
655
+ // that matches no pending request belongs to nobody: it is CONSUMED here (`return true`) so
656
+ // `_onUpstreamFrame` never fans it out to co-viewers who did not ask for it.
657
+ if (frame.type === 'resume-prompt-result') {
658
+ const requestId = _string(frame.requestId, 200);
659
+ if (requestId) {
660
+ const entry = _takePending(link, (e) => e.sessionId === sessionId
661
+ && e.replyType === frame.type && e.requestId === requestId);
662
+ if (entry) _sendFrame(entry.ctx, frame);
663
+ return true;
664
+ }
665
+ // A result carrying no requestId keeps the legacy clientMessageId/type fallback below.
666
+ }
667
+ const clientMessageId = _string(frame.clientMessageId, 200);
668
+ const entry = (clientMessageId
669
+ ? _takePending(link, (e) => e.sessionId === sessionId && e.clientMessageId === clientMessageId)
670
+ : null)
671
+ || _takePending(link, (e) => e.sessionId === sessionId && e.replyType === frame.type);
672
+ if (!entry) return false;
673
+ _sendFrame(entry.ctx, frame);
674
+ return true;
675
+ }
676
+
677
+ function _fanout(link, frame, sessionId, d) {
678
+ const viewers = link.viewers.get(sessionId);
679
+ if (!viewers || viewers.size === 0) return;
680
+ const payload = JSON.stringify(frame);
681
+ for (const ctx of viewers) _sendToCtx(ctx, payload);
682
+ }
683
+
684
+ // ---------------------------------------------------------------------------------------------
685
+ // Downstream (hub browser -> box)
686
+ // ---------------------------------------------------------------------------------------------
687
+
688
+ /**
689
+ * Register `ctx` as a viewer of `sessionId` on this link. The FIRST viewer of a session makes the
690
+ * link attach upstream and ask for a keyframe, so the box pushes that session's frames to the link.
691
+ *
692
+ * @returns {boolean} true when this call was the session's first viewer
693
+ */
694
+ function viewSession(link, sessionId, ctx, deps) {
695
+ if (!link || link.closed) return false;
696
+ const d = _d(deps);
697
+ const id = _string(sessionId, 200);
698
+ if (!id) return false;
699
+ _clearIdleClose(link, d);
700
+ let viewers = link.viewers.get(id);
701
+ const first = !viewers || viewers.size === 0;
702
+ if (!viewers) {
703
+ viewers = new Set();
704
+ link.viewers.set(id, viewers);
705
+ }
706
+ if (ctx) {
707
+ viewers.add(ctx);
708
+ // AC-3d: a hub browser tab closing RELEASES its viewers (and only then arms the machine's
709
+ // 60 s idle close) — it never closes the upstream link directly. Hooked here, once per socket,
710
+ // because the relay must not need an edit to server.js's own `ws.on('close')`.
711
+ if (ctx.ws && typeof ctx.ws.once === 'function' && !ctx.ws._remoteRelayCloseHooked) {
712
+ ctx.ws._remoteRelayCloseHooked = true;
713
+ ctx.ws.once('close', () => releaseSocket(ctx, d));
714
+ }
715
+ }
716
+ if (first && link.ready) _attachUpstream(link, id, d, 'hub-view');
717
+ return first;
718
+ }
719
+
720
+ function releaseViewer(link, sessionId, ctx, deps) {
721
+ if (!link) return;
722
+ const d = _d(deps);
723
+ const viewers = link.viewers.get(_string(sessionId, 200));
724
+ if (!viewers) return;
725
+ viewers.delete(ctx);
726
+ if (viewers.size === 0) link.viewers.delete(_string(sessionId, 200));
727
+ _maybeScheduleIdleClose(link, d);
728
+ }
729
+
730
+ /** A hub socket went away: drop it from every session's viewer set on every link. */
731
+ function releaseSocket(ctx, deps) {
732
+ const d = _d(deps);
733
+ for (const link of _links.values()) {
734
+ for (const [sessionId, viewers] of [...link.viewers]) {
735
+ if (viewers.delete(ctx) && viewers.size === 0) link.viewers.delete(sessionId);
736
+ }
737
+ _maybeScheduleIdleClose(link, d);
738
+ }
739
+ }
740
+
741
+ function _maybeScheduleIdleClose(link, d) {
742
+ for (const viewers of link.viewers.values()) {
743
+ if (viewers.size > 0) return; // a machine link lives as long as ANY of its sessions is viewed
744
+ }
745
+ // WP-6: a catalog request in flight is a link user too (released on the reply), so the link is
746
+ // never closed out from under it — but it is NOT a reason to keep the link open afterwards.
747
+ if ((Number(link.catalogUsers) || 0) > 0) return;
748
+ if (link.idleTimer || link.closed) return;
749
+ const ms = Number(d.idleCloseMs || IDLE_CLOSE_MS) || IDLE_CLOSE_MS;
750
+ const timer = d.setTimeoutFn(() => {
751
+ link.idleTimer = null;
752
+ if (link.closed) return;
753
+ for (const viewers of link.viewers.values()) {
754
+ if (viewers.size > 0) return; // someone came back during the idle window
755
+ }
756
+ if ((Number(link.catalogUsers) || 0) > 0) return; // a catalog request arrived during the window
757
+ closeLink(link, d);
758
+ }, ms);
759
+ if (timer && typeof timer.unref === 'function') timer.unref();
760
+ link.idleTimer = timer;
761
+ }
762
+
763
+ function _clearIdleClose(link, d) {
764
+ if (!link.idleTimer) return;
765
+ try { d.clearTimeoutFn(link.idleTimer); } catch { /* expected: the injected idle timer is already fired/cleared */ }
766
+ link.idleTimer = null;
767
+ }
768
+
769
+ /**
770
+ * Carry one hub-browser frame to the box, VERBATIM (S-24: `clientMessageId` and every other id is
771
+ * forwarded as-is, never re-minted). A request that can produce a reply is remembered so the reply
772
+ * can be routed back to the originating socket only.
773
+ *
774
+ * While the link is down this drops the frame (and returns false): hub browsers already show the
775
+ * existing reconnect/stale UX for a session that stops producing frames, and WP-5 owns any UI
776
+ * beyond that. Nothing is queued or replayed — the box is the authority and the reconnect path
777
+ * re-syncs via keyframe.
778
+ */
779
+ function forward(link, msg, ctx, deps) {
780
+ if (!link || link.closed) return false;
781
+ const d = _d(deps);
782
+ const sessionId = sessionIdsOf(msg)[0] || '';
783
+ _rememberPending(link, sessionId, msg, ctx, d);
784
+ if (!link.ready) {
785
+ // WP-6 (§7, AC-6b): a lifecycle frame can be the FIRST thing a hub browser sends about a
786
+ // session nobody has viewed yet — its mirror lookup opens the link, and the frame arrives while
787
+ // the socket is still dialing. Dropping it would mean "the hub ignored park" for exactly the
788
+ // sync-mirror-only case WP-6 must route. Bounded, and flushed the moment the box says hello.
789
+ if (link.pendingSends.length < PENDING_SEND_MAX) link.pendingSends.push(msg);
790
+ return true;
791
+ }
792
+ const sent = _sendUpstream(link, msg, d);
793
+ // WP-6: a relayed kill changes the box's session list, so the catalog re-reads it right away
794
+ // rather than waiting out the ~15 s probe cadence (the row becomes exited on the box), and the
795
+ // in-memory mirror registration for the killed id is dropped (the catalog keeps routing it).
796
+ if (sent && msg.type === 'kill') {
797
+ remoteMachineCreate.forgetMirrorSession(sessionId);
798
+ if (typeof d.onRelayedKill === 'function') {
799
+ try { d.onRelayedKill(link); } catch { /* expected: a catalog fault never breaks the relay */ }
800
+ }
801
+ }
802
+ return sent;
803
+ }
804
+
805
+ // ---------------------------------------------------------------------------------------------
806
+ // The dispatch seam
807
+ // ---------------------------------------------------------------------------------------------
808
+
809
+ /**
810
+ * Pre-dispatch hook (lib/ws-router.js). Returns true only for a frame that names at least one
811
+ * MIRROR session; every other frame returns false immediately — no msg mutation, no allocation on
812
+ * the local path — so a local session's handling is byte-identical to pre-WP-3.
813
+ */
814
+ function interceptRelay(ctx, msg, deps) {
815
+ if (!msg || typeof msg !== 'object') return false;
816
+ const type = typeof msg.type === 'string' ? msg.type : '';
817
+ if (!RELAY_TYPES.has(type)) return false;
818
+ const d = _d(deps);
819
+ // The overwhelmingly common case is a LOCAL session, which must take the byte-identical existing
820
+ // path: one id string, one mirror-map miss, `false`. `sessionIdsOf` (an array per frame) is only
821
+ // paid for `view`, the one type that declares a set — so a keystroke or an ack on a local session
822
+ // never allocates here.
823
+ const ids = type === 'view' ? sessionIdsOf(msg) : [(_string(msg.id || msg.sessionId, 200))];
824
+ if (ids.length === 0 || !ids[0]) return false;
825
+
826
+ const mirrors = [];
827
+ for (const id of ids) {
828
+ const mirror = d.mirrorFor(id);
829
+ if (mirror) mirrors.push({ id, mirror });
830
+ }
831
+ if (mirrors.length === 0) return false;
832
+ // A `view` frame declaring BOTH local and remote sessions is left to the local handler (it owns
833
+ // the local viewer set); the mirror ids are still registered below.
834
+ const mixedView = type === 'view' && mirrors.length !== ids.length;
835
+
836
+ let handled = true;
837
+ for (const { id, mirror } of mirrors) {
838
+ const link = _ensureLink(mirror, d);
839
+ if (!link) { handled = false; continue; }
840
+ if (VIEW_TYPES.has(type)) {
841
+ const first = viewSession(link, id, ctx, d);
842
+ // `attach`/`view` are synthesized upstream by viewSession; `keyframe-req` and the rest are
843
+ // carried verbatim below (a repeat keyframe-req is exactly what a re-sync asks for).
844
+ if (type === 'attach' || type === 'view') continue;
845
+ if (type === 'keyframe-req' && first) continue; // viewSession just issued the equivalent one
846
+ }
847
+ if (type === 'detach') {
848
+ releaseViewer(link, id, ctx, d);
849
+ continue;
850
+ }
851
+ forward(link, msg, ctx, d);
852
+ }
853
+ return mixedView ? false : handled;
854
+ }
855
+
856
+ function _ensureLink(mirror, d) {
857
+ const machineId = _string(mirror.machineInstanceId, 160);
858
+ const existing = _links.get(machineId);
859
+ if (existing && !existing.closed) {
860
+ _clearIdleClose(existing, d);
861
+ return existing;
862
+ }
863
+ return openLink(machineId, d);
864
+ }
865
+
866
+ function installIntercept() {
867
+ if (_uninstallIntercept) return _uninstallIntercept;
868
+ _uninstallIntercept = registerWsPreDispatchHook('remote-machine-link', (ctx, msg) => interceptRelay(ctx, msg));
869
+ return _uninstallIntercept;
870
+ }
871
+
872
+ function closeLink(link, deps) {
873
+ const d = _d(deps);
874
+ if (!link || link.closed) return;
875
+ link.closed = true;
876
+ if (link.reconnectTimer) {
877
+ try { d.clearTimeoutFn(link.reconnectTimer); } catch { /* expected: the injected timer is already cleared */ }
878
+ link.reconnectTimer = null;
879
+ }
880
+ _clearIdleClose(link, d);
881
+ _stopHeartbeat(link, d);
882
+ const socket = link.socket;
883
+ link.socket = null;
884
+ link.ready = false;
885
+ link.generation += 1;
886
+ try { socket?.close?.(); } catch { /* expected: the upstream socket is already gone */ }
887
+ if (_links.get(link.machineInstanceId) === link) _links.delete(link.machineInstanceId);
888
+ }
889
+
890
+ function _report(d, message) {
891
+ // REPORT (never throw): a link fault is a degraded remote session, not a hub fault.
892
+ if (d && d.log && typeof d.log.warn === 'function') d.log.warn(`[remote-machine-link] ${message}`);
893
+ }
894
+
895
+ /** Test seam: links and their timers are module state and must never leak between tests. */
896
+ function _resetForTests() {
897
+ for (const link of [..._links.values()]) {
898
+ link.closed = true;
899
+ // A pending catalog request is settled, not abandoned: an unresolved promise would keep a
900
+ // test's await (and the process) alive.
901
+ for (const finish of [...(link.catalogWaiters || new Map()).values()]) {
902
+ try { finish({ ok: false, code: 'machine_unreachable', error: 'link_closed' }); } catch { /* expected: test teardown */ }
903
+ }
904
+ try { link.reconnectTimer && clearTimeout(link.reconnectTimer); } catch { /* expected: test teardown */ }
905
+ try { link.idleTimer && clearTimeout(link.idleTimer); } catch { /* expected: test teardown */ }
906
+ try { link.heartbeatTimer && clearInterval(link.heartbeatTimer); } catch { /* expected: test teardown */ }
907
+ }
908
+ _links.clear();
909
+ _injected = null;
910
+ }
911
+
912
+ module.exports = {
913
+ BACKOFF_BASE_MS,
914
+ BACKOFF_CAP_MS,
915
+ IDLE_CLOSE_MS,
916
+ PING_MISS_LIMIT,
917
+ CATALOG_TIMEOUT_MS,
918
+ RELAY_TYPES,
919
+ VIEW_TYPES,
920
+ REPLY_TYPES,
921
+ REPLY_FOR_REQUEST,
922
+ REMEMBER_TYPES,
923
+ configure,
924
+ installIntercept,
925
+ openLink,
926
+ linkFor,
927
+ listLinks,
928
+ closeLink,
929
+ viewSession,
930
+ releaseViewer,
931
+ releaseSocket,
932
+ forward,
933
+ interceptRelay,
934
+ heartbeatTick,
935
+ backoffDelayMs,
936
+ sessionIdsOf,
937
+ requestSessionList,
938
+ _resetForTests,
939
+ };