shadow-claw 1.27.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/.well-known/ai-catalog.json +11 -0
  2. package/.well-known/mcp/index.html +19 -0
  3. package/.well-known/mcp/server-card.json +52 -0
  4. package/.well-known/mcp/servers.json +15 -0
  5. package/.well-known/mcp.json +52 -0
  6. package/.well-known/oauth-protected-resource +10 -0
  7. package/.well-known/oauth-protected-resource.json +10 -0
  8. package/bin/assert-version-bump.mjs +61 -3
  9. package/bin/assert-version-bump.test.mjs +77 -1
  10. package/bin/build/build.mjs +116 -49
  11. package/bin/build/build.test.mjs +103 -0
  12. package/bin/cli.mjs +299 -237
  13. package/bin/cli.test.mjs +27 -0
  14. package/bin/commands/mcp.mjs +132 -2
  15. package/bin/commands/mcp.test.mjs +166 -0
  16. package/bin/commands/skills-index.mjs +139 -20
  17. package/bin/commands/skills-index.test.mjs +82 -0
  18. package/bin/utils/control-client.mjs +17 -0
  19. package/bin/utils/control-client.test.mjs +46 -0
  20. package/bin/version.mjs +208 -0
  21. package/bin/version.test.mjs +138 -0
  22. package/dist/electron/main.cjs +195 -185
  23. package/dist/lib/components/index.js +21 -21
  24. package/dist/lib/components/settings/shadow-claw-channel-config.js +1 -1
  25. package/dist/lib/components/settings/shadow-claw-control-plane.js +1 -1
  26. package/dist/lib/components/settings/shadow-claw-git.js +1 -1
  27. package/dist/lib/components/settings/shadow-claw-llm.js +1 -1
  28. package/dist/lib/components/settings/shadow-claw-networking.js +1 -1
  29. package/dist/lib/components/settings/shadow-claw-notifications.js +1 -1
  30. package/dist/lib/components/settings/shadow-claw-peerjs.js +1 -1
  31. package/dist/lib/components/settings/shadow-claw-storage.js +1 -1
  32. package/dist/lib/components/settings/shadow-claw-task-server.js +1 -1
  33. package/dist/lib/components/settings/shadow-claw-webvm.js +1 -1
  34. package/dist/lib/components/shadow-claw/shadow-claw.d.ts +1 -0
  35. package/dist/lib/components/shadow-claw/utils/setupAppLifecycle.d.ts +23 -0
  36. package/dist/lib/components/shadow-claw-channel-config.js +1 -1
  37. package/dist/lib/components/shadow-claw-channels.js +1 -1
  38. package/dist/lib/components/shadow-claw-chat.js +1 -1
  39. package/dist/lib/components/shadow-claw-control-plane.js +1 -1
  40. package/dist/lib/components/shadow-claw-conversations.js +1 -1
  41. package/dist/lib/components/shadow-claw-file-viewer.js +1 -1
  42. package/dist/lib/components/shadow-claw-files.js +1 -1
  43. package/dist/lib/components/shadow-claw-git.js +1 -1
  44. package/dist/lib/components/shadow-claw-llm.js +1 -1
  45. package/dist/lib/components/shadow-claw-networking.js +1 -1
  46. package/dist/lib/components/shadow-claw-notifications.js +1 -1
  47. package/dist/lib/components/shadow-claw-pages.js +1 -1
  48. package/dist/lib/components/shadow-claw-peerjs.js +1 -1
  49. package/dist/lib/components/shadow-claw-settings.js +1 -1
  50. package/dist/lib/components/shadow-claw-storage.js +1 -1
  51. package/dist/lib/components/shadow-claw-task-server.js +1 -1
  52. package/dist/lib/components/shadow-claw-tasks.js +1 -1
  53. package/dist/lib/components/shadow-claw-terminal.js +1 -1
  54. package/dist/lib/components/shadow-claw-tools.js +1 -1
  55. package/dist/lib/components/shadow-claw-webvm.js +1 -1
  56. package/dist/lib/components/shadow-claw.js +1 -1
  57. package/dist/lib/{configurePeerJs-oODzltaC.js → configurePeerJs-D8Ph0-A9.js} +2 -2
  58. package/dist/lib/{configurePeerJs-oODzltaC.js.map → configurePeerJs-D8Ph0-A9.js.map} +1 -1
  59. package/dist/lib/core/control-plane-client.d.ts +2 -0
  60. package/dist/lib/core/orchestrator/orchestrator.d.ts +10 -0
  61. package/dist/lib/core/orchestrator/utils/operations/channel.d.ts +2 -2
  62. package/dist/lib/core/utils/initControlPlane.d.ts +6 -0
  63. package/dist/lib/index.js +21 -21
  64. package/dist/lib/{initControlPlane-C2zURVIS.js → initControlPlane-BIrWmpzc.js} +76 -9
  65. package/dist/lib/initControlPlane-BIrWmpzc.js.map +1 -0
  66. package/dist/lib/{markdown-C6EHl9zs.js → markdown-DEUoM1c6.js} +2 -2
  67. package/dist/lib/{markdown-C6EHl9zs.js.map → markdown-DEUoM1c6.js.map} +1 -1
  68. package/dist/lib/{orchestrator-KRjx0eba.js → orchestrator-BtVj9bBB.js} +11 -10
  69. package/dist/lib/{orchestrator-KRjx0eba.js.map → orchestrator-BtVj9bBB.js.map} +1 -1
  70. package/dist/lib/{orchestrator-CACLa_Ro.js → orchestrator-CFCW-l1O.js} +52 -4
  71. package/dist/lib/orchestrator-CFCW-l1O.js.map +1 -0
  72. package/dist/lib/{peerjs-DFWOBcXf.js → peerjs-BoBkx9DJ.js} +23 -3
  73. package/dist/lib/peerjs-BoBkx9DJ.js.map +1 -0
  74. package/dist/lib/{push-client-_tiWQsCT.js → push-client-D6hbzDNV.js} +10 -4
  75. package/dist/lib/push-client-D6hbzDNV.js.map +1 -0
  76. package/dist/lib/{shadow-claw-6UHdiy4a.js → shadow-claw-Cuf2O5bZ.js} +71 -4
  77. package/dist/lib/shadow-claw-Cuf2O5bZ.js.map +1 -0
  78. package/dist/lib/{shadow-claw-channel-config-DmfwJc6G.js → shadow-claw-channel-config-BDPKt6Bd.js} +3 -3
  79. package/dist/lib/{shadow-claw-channel-config-DmfwJc6G.js.map → shadow-claw-channel-config-BDPKt6Bd.js.map} +1 -1
  80. package/dist/lib/{shadow-claw-channels-1N6Jl-By.js → shadow-claw-channels-BsP5JABM.js} +2 -2
  81. package/dist/lib/{shadow-claw-channels-1N6Jl-By.js.map → shadow-claw-channels-BsP5JABM.js.map} +1 -1
  82. package/dist/lib/{shadow-claw-chat-BMoqQJRK.js → shadow-claw-chat-Cam2lX4k.js} +4 -4
  83. package/dist/lib/{shadow-claw-chat-BMoqQJRK.js.map → shadow-claw-chat-Cam2lX4k.js.map} +1 -1
  84. package/dist/lib/{shadow-claw-control-plane-UG0o7pCz.js → shadow-claw-control-plane-DB2qcCFs.js} +20 -10
  85. package/dist/lib/shadow-claw-control-plane-DB2qcCFs.js.map +1 -0
  86. package/dist/lib/{shadow-claw-conversations-BwXep3iu.js → shadow-claw-conversations-C9Vf4XGx.js} +2 -2
  87. package/dist/lib/{shadow-claw-conversations-BwXep3iu.js.map → shadow-claw-conversations-C9Vf4XGx.js.map} +1 -1
  88. package/dist/lib/{shadow-claw-file-viewer-DUxzVxDT.js → shadow-claw-file-viewer-B4HYiI0v.js} +3 -3
  89. package/dist/lib/{shadow-claw-file-viewer-DUxzVxDT.js.map → shadow-claw-file-viewer-B4HYiI0v.js.map} +1 -1
  90. package/dist/lib/{shadow-claw-files-DIREEnT2.js → shadow-claw-files-DVROUESO.js} +2 -2
  91. package/dist/lib/{shadow-claw-files-DIREEnT2.js.map → shadow-claw-files-DVROUESO.js.map} +1 -1
  92. package/dist/lib/{shadow-claw-git-Bg1SWgsF.js → shadow-claw-git-BqrO7cHJ.js} +3 -3
  93. package/dist/lib/{shadow-claw-git-Bg1SWgsF.js.map → shadow-claw-git-BqrO7cHJ.js.map} +1 -1
  94. package/dist/lib/{shadow-claw-llm-CazBrVpM.js → shadow-claw-llm-BexUQdYo.js} +2 -2
  95. package/dist/lib/{shadow-claw-llm-CazBrVpM.js.map → shadow-claw-llm-BexUQdYo.js.map} +1 -1
  96. package/dist/lib/{shadow-claw-networking-Df85tCbO.js → shadow-claw-networking-CfXRGpUD.js} +2 -2
  97. package/dist/lib/{shadow-claw-networking-Df85tCbO.js.map → shadow-claw-networking-CfXRGpUD.js.map} +1 -1
  98. package/dist/lib/{shadow-claw-notifications-v62FryTc.js → shadow-claw-notifications-C9KPMgx_.js} +2 -2
  99. package/dist/lib/{shadow-claw-notifications-v62FryTc.js.map → shadow-claw-notifications-C9KPMgx_.js.map} +1 -1
  100. package/dist/lib/{shadow-claw-pages-BgPyIEZO.js → shadow-claw-pages-CGzGXmrI.js} +3 -3
  101. package/dist/lib/{shadow-claw-pages-BgPyIEZO.js.map → shadow-claw-pages-CGzGXmrI.js.map} +1 -1
  102. package/dist/lib/{shadow-claw-peerjs-xkBXILed.js → shadow-claw-peerjs-B0ZB7yhB.js} +3 -3
  103. package/dist/lib/{shadow-claw-peerjs-xkBXILed.js.map → shadow-claw-peerjs-B0ZB7yhB.js.map} +1 -1
  104. package/dist/lib/{shadow-claw-settings-CN89xRVU.js → shadow-claw-settings-DBfMZdDS.js} +2 -2
  105. package/dist/lib/{shadow-claw-settings-CN89xRVU.js.map → shadow-claw-settings-DBfMZdDS.js.map} +1 -1
  106. package/dist/lib/{shadow-claw-storage-VnhcObCe.js → shadow-claw-storage-CM-w6mgG.js} +2 -2
  107. package/dist/lib/{shadow-claw-storage-VnhcObCe.js.map → shadow-claw-storage-CM-w6mgG.js.map} +1 -1
  108. package/dist/lib/{shadow-claw-task-server-jgEC2hHB.js → shadow-claw-task-server-DV4b1g2-.js} +4 -2
  109. package/dist/lib/{shadow-claw-task-server-jgEC2hHB.js.map → shadow-claw-task-server-DV4b1g2-.js.map} +1 -1
  110. package/dist/lib/{shadow-claw-tasks-i_evIufy.js → shadow-claw-tasks-K-foOHSE.js} +3 -3
  111. package/dist/lib/{shadow-claw-tasks-i_evIufy.js.map → shadow-claw-tasks-K-foOHSE.js.map} +1 -1
  112. package/dist/lib/{shadow-claw-terminal-CwScROlu.js → shadow-claw-terminal-CpAknKyA.js} +2 -2
  113. package/dist/lib/{shadow-claw-terminal-CwScROlu.js.map → shadow-claw-terminal-CpAknKyA.js.map} +1 -1
  114. package/dist/lib/{shadow-claw-tools-DD3fyXP4.js → shadow-claw-tools-D98XZ0Ep.js} +3 -3
  115. package/dist/lib/{shadow-claw-tools-DD3fyXP4.js.map → shadow-claw-tools-D98XZ0Ep.js.map} +1 -1
  116. package/dist/lib/{shadow-claw-webvm-DbJq4I3-.js → shadow-claw-webvm-DLOb8dMI.js} +2 -2
  117. package/dist/lib/{shadow-claw-webvm-DbJq4I3-.js.map → shadow-claw-webvm-DLOb8dMI.js.map} +1 -1
  118. package/dist/lib/subsystems/channels/imessage.d.ts +1 -0
  119. package/dist/lib/subsystems/channels/peerjs.d.ts +1 -0
  120. package/dist/lib/subsystems/channels/telegram.d.ts +1 -0
  121. package/dist/lib/subsystems/channels/types.d.ts +2 -0
  122. package/dist/lib/subsystems/notifications/push-client.d.ts +1 -1
  123. package/dist/lib/{syncWebMcpRegistration-DNLnQ-jz.js → syncWebMcpRegistration-NspKefOI.js} +2 -2
  124. package/dist/lib/{syncWebMcpRegistration-DNLnQ-jz.js.map → syncWebMcpRegistration-NspKefOI.js.map} +1 -1
  125. package/dist/public/.well-known/ai-catalog.json +11 -0
  126. package/dist/public/.well-known/mcp/index.html +19 -0
  127. package/dist/public/.well-known/mcp/server-card.json +52 -0
  128. package/dist/public/.well-known/mcp/servers.json +15 -0
  129. package/dist/public/.well-known/mcp.json +52 -0
  130. package/dist/public/.well-known/oauth-protected-resource +10 -0
  131. package/dist/public/.well-known/oauth-protected-resource.json +10 -0
  132. package/dist/public/404.html +28 -1
  133. package/dist/public/AGENTS.md +1 -1
  134. package/dist/public/agent.worker.js +1 -1
  135. package/dist/public/{configurePeerJs-BqgeBjP7.js → configurePeerJs-BhBokQlC.js} +1 -1
  136. package/dist/public/docs/example/article/index.html +3 -3
  137. package/dist/public/docs/publishing/index.html +3 -3
  138. package/dist/public/docs/skill-creator/index.html +3 -3
  139. package/dist/public/docs/subsystems/cli.md +11 -8
  140. package/dist/public/docs/subsystems/mcp-server.md +1 -0
  141. package/dist/public/docs/subsystems/notifications.md +10 -0
  142. package/dist/public/{e2e-bridge-Ceqr5kGc.js → e2e-bridge-DcBoeFvd.js} +1 -1
  143. package/dist/public/{file-viewer-C-6Jc_tb.js → file-viewer-CKtVC9Ix.js} +1 -1
  144. package/dist/public/index.css +1 -1
  145. package/dist/public/index.html +3 -3
  146. package/dist/public/index.js +1 -1
  147. package/dist/public/initControlPlane-Bi8ouFEb.js +1 -0
  148. package/dist/public/llms.txt +36 -1
  149. package/dist/public/main/index.html +3 -3
  150. package/dist/public/main/memory/index.html +3 -3
  151. package/dist/public/{markdown-C3M4OW_A.js → markdown-C-hCVJtc.js} +1 -1
  152. package/dist/public/orchestrator-8qNGfw8r.js +15 -0
  153. package/dist/public/{orchestrator-DHjdLtqa.js → orchestrator-BT3Ks5ip.js} +2 -2
  154. package/dist/public/peerjs-DfhwEiiq.js +1 -0
  155. package/dist/public/push-client-BVLrE9pK.js +1 -0
  156. package/dist/public/service-worker/fetch-proxy.js +1 -1
  157. package/dist/public/service-worker.js +1 -1
  158. package/dist/public/{shadow-claw-EAt2ml5Y.js → shadow-claw-DCjxNDCX.js} +3 -3
  159. package/dist/public/{shadow-claw-channels-XsfuWFF8.js → shadow-claw-channels-CITDrqMl.js} +1 -1
  160. package/dist/public/{shadow-claw-chat-CbSaOZ4q.js → shadow-claw-chat-BQrSgSKF.js} +1 -1
  161. package/dist/public/{shadow-claw-control-plane-DHvN3Ukg.js → shadow-claw-control-plane-Cp001OY0.js} +2 -2
  162. package/dist/public/{shadow-claw-conversations-BWs5VVfs.js → shadow-claw-conversations-EhX4RvIj.js} +1 -1
  163. package/dist/public/{shadow-claw-file-viewer-BWsoW5QS.js → shadow-claw-file-viewer-DiQXIjZD.js} +1 -1
  164. package/dist/public/{shadow-claw-files-CG7MqWiV.js → shadow-claw-files-lkTnSJsn.js} +1 -1
  165. package/dist/public/{shadow-claw-git-DBRPZNav.js → shadow-claw-git-BlcA_z8x.js} +2 -2
  166. package/dist/public/{shadow-claw-llm-DMvLgKXo.js → shadow-claw-llm-CW16X9Rg.js} +1 -1
  167. package/dist/public/{shadow-claw-networking-Dd8Rw31D.js → shadow-claw-networking-ySsMBY_i.js} +1 -1
  168. package/dist/public/{shadow-claw-notifications-Dbevj-lq.js → shadow-claw-notifications-CyV2zCQ8.js} +1 -1
  169. package/dist/public/{shadow-claw-pages-3_lQAv0t.js → shadow-claw-pages-Cx7cF9ah.js} +1 -1
  170. package/dist/public/{shadow-claw-settings-t8TIlgrM.js → shadow-claw-settings-zm4PHHGi.js} +2 -2
  171. package/dist/public/{shadow-claw-storage-CfEXoSIE.js → shadow-claw-storage-dy9JAEGd.js} +1 -1
  172. package/dist/public/{shadow-claw-task-server-DxQSsgRZ.js → shadow-claw-task-server-Bleww48s.js} +2 -2
  173. package/dist/public/{shadow-claw-tasks-DNpicQwz.js → shadow-claw-tasks-CTI5oaLN.js} +1 -1
  174. package/dist/public/{shadow-claw-terminal-DpY6zU_r.js → shadow-claw-terminal-mZ3i4HaQ.js} +1 -1
  175. package/dist/public/{shadow-claw-tools-WSwWohnk.js → shadow-claw-tools-B_nuk8Zl.js} +1 -1
  176. package/dist/public/{shadow-claw-webvm-CP6oFjg5.js → shadow-claw-webvm-Vl_jH5YL.js} +1 -1
  177. package/dist/public/{syncWebMcpRegistration-DOn9gLnD.js → syncWebMcpRegistration-n2vAMJ4w.js} +1 -1
  178. package/dist/server.js +197 -187
  179. package/package.json +2 -1
  180. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.test.ts +6 -0
  181. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.ts +19 -9
  182. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +14 -0
  183. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.ts +12 -0
  184. package/src/components/shadow-claw/shadow-claw.ts +6 -0
  185. package/src/components/shadow-claw/utils/bindEventListeners.test.ts +57 -0
  186. package/src/components/shadow-claw/utils/bindEventListeners.ts +14 -0
  187. package/src/components/shadow-claw/utils/setupAppLifecycle.test.ts +201 -0
  188. package/src/components/shadow-claw/utils/setupAppLifecycle.ts +101 -0
  189. package/src/core/control-plane-client.test.ts +82 -1
  190. package/src/core/control-plane-client.ts +35 -1
  191. package/src/core/orchestrator/orchestrator.test.ts +49 -0
  192. package/src/core/orchestrator/orchestrator.ts +43 -0
  193. package/src/core/orchestrator/utils/operations/channel.test.ts +17 -0
  194. package/src/core/orchestrator/utils/operations/channel.ts +11 -5
  195. package/src/core/orchestrator/utils/operations/task.test.ts +26 -0
  196. package/src/core/orchestrator/utils/operations/task.ts +8 -1
  197. package/src/core/utils/initControlPlane.test.ts +138 -0
  198. package/src/core/utils/initControlPlane.ts +82 -0
  199. package/src/core/utils/initializeApp.test.ts +27 -1
  200. package/src/core/utils/initializeApp.ts +6 -13
  201. package/src/server/mcp/mcp-server.test.ts +6 -0
  202. package/src/server/mcp/mcp-server.ts +2 -1
  203. package/src/server/mcp/tools/built-in-tools.ts +88 -1
  204. package/src/server/mcp/tools/client-tool-relay.ts +6 -0
  205. package/src/server/mcp/tools/mcp-tools.test.ts +128 -0
  206. package/src/server/openapi/openapi-coverage.test.ts +2 -1
  207. package/src/server/openapi/openapi-spec.ts +2 -1
  208. package/src/server/openapi/paths/mcp.ts +104 -0
  209. package/src/server/openapi/paths/push.ts +3 -0
  210. package/src/server/routes/mcp.test.ts +92 -0
  211. package/src/server/routes/mcp.ts +138 -2
  212. package/src/server/utils/packageVersion.test.ts +10 -0
  213. package/src/server/utils/packageVersion.ts +32 -0
  214. package/src/service-worker/fetch-proxy-rules.test.ts +73 -0
  215. package/src/service-worker/fetch-proxy-rules.ts +44 -0
  216. package/src/service-worker/fetch-proxy.ts +1 -1
  217. package/src/subsystems/channels/imessage.test.ts +36 -0
  218. package/src/subsystems/channels/imessage.ts +11 -0
  219. package/src/subsystems/channels/peerjs.test.ts +56 -0
  220. package/src/subsystems/channels/peerjs.ts +29 -0
  221. package/src/subsystems/channels/telegram.test.ts +38 -0
  222. package/src/subsystems/channels/telegram.ts +13 -0
  223. package/src/subsystems/channels/types.ts +2 -0
  224. package/src/subsystems/notifications/push-client.test.ts +70 -0
  225. package/src/subsystems/notifications/push-client.ts +28 -3
  226. package/src/subsystems/notifications/push-routes.test.ts +82 -0
  227. package/src/subsystems/notifications/push-routes.ts +70 -16
  228. package/src/subsystems/notifications/push-store.test.ts +59 -0
  229. package/src/subsystems/notifications/push-store.ts +113 -19
  230. package/src/testing/jest-setup.ts +15 -0
  231. package/dist/lib/initControlPlane-C2zURVIS.js.map +0 -1
  232. package/dist/lib/orchestrator-CACLa_Ro.js.map +0 -1
  233. package/dist/lib/peerjs-DFWOBcXf.js.map +0 -1
  234. package/dist/lib/push-client-_tiWQsCT.js.map +0 -1
  235. package/dist/lib/shadow-claw-6UHdiy4a.js.map +0 -1
  236. package/dist/lib/shadow-claw-control-plane-UG0o7pCz.js.map +0 -1
  237. package/dist/public/initControlPlane-BN9cWyO0.js +0 -1
  238. package/dist/public/orchestrator-CMbCTjBf.js +0 -15
  239. package/dist/public/peerjs-DUCKH-m9.js +0 -1
  240. package/dist/public/push-client-D0lkwrK0.js +0 -1
@@ -54,8 +54,13 @@ class MockClientWebSocket {
54
54
 
55
55
  // Mock EventSource implementation for SSE client testing
56
56
  class MockEventSource {
57
+ static readonly CONNECTING = 0;
58
+ static readonly OPEN = 1;
59
+ static readonly CLOSED = 2;
60
+
57
61
  static instances: MockEventSource[] = [];
58
62
  url: string;
63
+ readyState: number = 0; // CONNECTING
59
64
  onopen: (() => void) | null = null;
60
65
  onmessage: ((event: { data: string }) => void) | null = null;
61
66
  onerror: ((err: any) => void) | null = null;
@@ -64,9 +69,11 @@ class MockEventSource {
64
69
 
65
70
  constructor(url: string) {
66
71
  this.url = url;
72
+ this.readyState = MockEventSource.CONNECTING;
67
73
  MockEventSource.instances.push(this);
68
74
  setTimeout(() => {
69
75
  if (!this.closed) {
76
+ this.readyState = MockEventSource.OPEN;
70
77
  this.onopen?.();
71
78
  }
72
79
  }, 10);
@@ -80,6 +87,7 @@ class MockEventSource {
80
87
 
81
88
  close() {
82
89
  this.closed = true;
90
+ this.readyState = MockEventSource.CLOSED;
83
91
  }
84
92
 
85
93
  // Helper for test to simulate server event
@@ -225,6 +233,42 @@ describe("control-plane-client", () => {
225
233
 
226
234
  client.disconnect();
227
235
  });
236
+
237
+ it("ensureConnected reconnects and resets backoff for SSE when forced or disconnected", async () => {
238
+ const client = new ControlPlaneClient({
239
+ clientId: "sse-client-reconnect",
240
+ reconnectDelayMs: 500,
241
+ maxReconnectDelayMs: 30000,
242
+ EventSourceClass: MockEventSource as any,
243
+ fetchFn: mockFetch as any,
244
+ });
245
+
246
+ client.connect();
247
+ expect(MockEventSource.instances).toHaveLength(1);
248
+ jest.advanceTimersByTime(15);
249
+ expect(client.getState()).toBe("connected");
250
+
251
+ // Calling ensureConnected(false) when healthy is a no-op
252
+ client.ensureConnected(false);
253
+ expect(MockEventSource.instances).toHaveLength(1);
254
+
255
+ // Simulate disconnection and exponential backoff
256
+ const es1 = MockEventSource.instances[0];
257
+ es1.close();
258
+ es1.onerror?.(new Error("Network drop"));
259
+ expect(client.getState()).toBe("disconnected");
260
+
261
+ // Advance by small time so reconnect delay increases
262
+ jest.advanceTimersByTime(200);
263
+
264
+ // Mobile sleep wake-up: ensureConnected(true) immediately reconnects without waiting for backoff timer
265
+ client.ensureConnected(true);
266
+ expect(MockEventSource.instances).toHaveLength(2);
267
+ jest.advanceTimersByTime(15);
268
+ expect(client.getState()).toBe("connected");
269
+
270
+ client.disconnect();
271
+ });
228
272
  });
229
273
 
230
274
  describe("WebSocket Transport (Opt-in)", () => {
@@ -256,7 +300,7 @@ describe("control-plane-client", () => {
256
300
  clientId: "client-test-1",
257
301
  deviceLabel: "Test Device",
258
302
  capabilities: ["webmcp", "opfs"],
259
- version: "1.23.4",
303
+ version: "1.27.1",
260
304
  transport: "websocket",
261
305
  });
262
306
 
@@ -411,5 +455,42 @@ describe("control-plane-client", () => {
411
455
 
412
456
  client.disconnect();
413
457
  });
458
+
459
+ it("ensureConnected reconnects and resets backoff for WebSocket when forced or disconnected", async () => {
460
+ const client = new ControlPlaneClient({
461
+ transport: "websocket",
462
+ url: "ws://127.0.0.1:8888/ws/control",
463
+ clientId: "client-ws-ensure",
464
+ reconnectDelayMs: 500,
465
+ WebSocketClass: MockClientWebSocket as any,
466
+ });
467
+
468
+ client.connect();
469
+ expect(MockClientWebSocket.instances).toHaveLength(1);
470
+ jest.advanceTimersByTime(15);
471
+ expect(client.getState()).toBe("connected");
472
+
473
+ // Non-forced ensureConnected is a no-op when connected
474
+ client.ensureConnected(false);
475
+ expect(MockClientWebSocket.instances).toHaveLength(1);
476
+
477
+ // Force reconnect (e.g. mobile sleep / app switch wake-up)
478
+ client.ensureConnected(true);
479
+ expect(MockClientWebSocket.instances).toHaveLength(2);
480
+ jest.advanceTimersByTime(15);
481
+ expect(client.getState()).toBe("connected");
482
+
483
+ // Also verify when disconnected and timer is active
484
+ const ws2 = MockClientWebSocket.instances[1];
485
+ ws2.close();
486
+ expect(client.getState()).toBe("disconnected");
487
+
488
+ client.ensureConnected(false);
489
+ expect(MockClientWebSocket.instances).toHaveLength(3);
490
+ jest.advanceTimersByTime(15);
491
+ expect(client.getState()).toBe("connected");
492
+
493
+ client.disconnect();
494
+ });
414
495
  });
415
496
  });
@@ -71,7 +71,7 @@ export class ControlPlaneClient {
71
71
  this._peerId = options.peerId;
72
72
  this._deviceLabel = options.deviceLabel || "ShadowClaw Browser Client";
73
73
  this._capabilities = options.capabilities || ["webmcp", "opfs", "push"];
74
- this._version = options.version || "1.23.4";
74
+ this._version = options.version || "1.27.1";
75
75
  this._token = options.token;
76
76
  this._heartbeatIntervalMs = options.heartbeatIntervalMs || 10000;
77
77
  this._reconnectDelayMs = options.reconnectDelayMs || 1000;
@@ -113,6 +113,10 @@ export class ControlPlaneClient {
113
113
  return this._transport;
114
114
  }
115
115
 
116
+ public get version(): string {
117
+ return this._version;
118
+ }
119
+
116
120
  public registerHandler(action: string, handler: CommandHandler): void {
117
121
  this._handlers.set(action, handler);
118
122
  }
@@ -125,6 +129,36 @@ export class ControlPlaneClient {
125
129
  return this._state;
126
130
  }
127
131
 
132
+ public ensureConnected(force: boolean = false): void {
133
+ this._isDestroyed = false;
134
+ if (this._reconnectTimer) {
135
+ clearTimeout(this._reconnectTimer);
136
+ this._reconnectTimer = null;
137
+ }
138
+ this._currentReconnectDelay = this._reconnectDelayMs;
139
+
140
+ if (this._transport === "websocket") {
141
+ const isWsOpen =
142
+ this._ws &&
143
+ (this._ws.readyState === 1 ||
144
+ this._ws.readyState === (this._WebSocketClass as any)?.OPEN);
145
+ if (force || !isWsOpen || this._state !== "connected") {
146
+ this._cleanupConnection();
147
+ this.connect();
148
+ }
149
+ } else {
150
+ // SSE transport
151
+ const isEsOpen =
152
+ this._es &&
153
+ (this._es.readyState === 1 ||
154
+ this._es.readyState === (this._EventSourceClass as any)?.OPEN);
155
+ if (force || !isEsOpen || this._state !== "connected") {
156
+ this._cleanupConnection();
157
+ this.connect();
158
+ }
159
+ }
160
+ }
161
+
128
162
  public connect(): void {
129
163
  if (
130
164
  this._isDestroyed ||
@@ -1894,4 +1894,53 @@ describe("Orchestrator", () => {
1894
1894
  }
1895
1895
  });
1896
1896
  });
1897
+
1898
+ describe("ensureAllConnections", () => {
1899
+ it("coordinates Control Plane, channels, task server, and scheduler", async () => {
1900
+ const o = new Orchestrator();
1901
+ const fakeDb = {} as any;
1902
+ o.db = fakeDb;
1903
+ o.taskServerEnabled = true;
1904
+
1905
+ const mockScheduler = {
1906
+ start: jest.fn(),
1907
+ tick: jest.fn().mockResolvedValue(undefined as never),
1908
+ };
1909
+ o.scheduler = mockScheduler as any;
1910
+
1911
+ const ensureControlPlaneSpy = jest
1912
+ .spyOn(o, "ensureControlPlaneConnected")
1913
+ .mockResolvedValue(null as any);
1914
+
1915
+ const peerjsEnsureSpy = jest.fn();
1916
+ (o.peerjs as any).ensureConnected = peerjsEnsureSpy;
1917
+ o.peerjs.running = true;
1918
+ o.channelEnabledByType.peerjs = true;
1919
+ o.peerjsMyPeerId = "my-peer";
1920
+
1921
+ const replaySpy = jest
1922
+ .spyOn(orchestratorStore, "replayTaskSyncOutbox")
1923
+ .mockResolvedValue();
1924
+ const loadTasksSpy = jest
1925
+ .spyOn(orchestratorStore, "loadTasks")
1926
+ .mockResolvedValue();
1927
+
1928
+ await o.ensureAllConnections({ force: true });
1929
+
1930
+ expect(ensureControlPlaneSpy).toHaveBeenCalledWith({
1931
+ orchestrator: o,
1932
+ db: fakeDb,
1933
+ force: true,
1934
+ });
1935
+ expect(peerjsEnsureSpy).toHaveBeenCalledWith(true);
1936
+ expect(replaySpy).toHaveBeenCalledWith(fakeDb);
1937
+ expect(loadTasksSpy).toHaveBeenCalledWith(fakeDb);
1938
+ expect(mockScheduler.start).toHaveBeenCalled();
1939
+ expect(mockScheduler.tick).toHaveBeenCalled();
1940
+
1941
+ ensureControlPlaneSpy.mockRestore();
1942
+ replaySpy.mockRestore();
1943
+ loadTasksSpy.mockRestore();
1944
+ });
1945
+ });
1897
1946
  });
@@ -74,6 +74,8 @@ import {
74
74
 
75
75
  import { syncWebMcpRegistration } from "./utils/syncWebMcpRegistration.js";
76
76
  import { DEFAULT_DIRECT_TOOL_COMMAND_POLICY } from "./utils/types.js";
77
+ import { ensureControlPlaneConnected } from "../utils/initControlPlane.js";
78
+ import { applyAllChannelRunningStates } from "./utils/operations/channel.js";
77
79
 
78
80
  import type { ShadowClawDatabase } from "../../db/db.js";
79
81
  import type { A2UIAction } from "../../ui/a2ui/types.js";
@@ -104,6 +106,9 @@ export class Orchestrator {
104
106
  ...DEFAULT_DIRECT_TOOL_COMMAND_POLICY,
105
107
  };
106
108
 
109
+ ensureControlPlaneConnected: typeof ensureControlPlaneConnected =
110
+ ensureControlPlaneConnected;
111
+
107
112
  events: EventBus = new EventBus();
108
113
  gitProxyUrl: string = "/git-proxy";
109
114
 
@@ -287,6 +292,44 @@ export class Orchestrator {
287
292
  unregisterWebMcpTools();
288
293
  }
289
294
 
295
+ /**
296
+ * Ensure all enabled external connections (Control Plane, Channels, Task Server)
297
+ * are actively connected and healthy (e.g. on initial app load, wake-up from sleep,
298
+ * switching back from another app, or regaining network connectivity).
299
+ */
300
+ async ensureAllConnections(options: { force?: boolean } = {}): Promise<void> {
301
+ const force = options.force ?? false;
302
+
303
+ // 1. Control Plane
304
+ try {
305
+ await this.ensureControlPlaneConnected({
306
+ orchestrator: this,
307
+ db: this.db || undefined,
308
+ force,
309
+ });
310
+ } catch (err) {
311
+ console.warn("Error ensuring Control Plane connection:", err);
312
+ }
313
+
314
+ // 2. Messaging Channels
315
+ try {
316
+ applyAllChannelRunningStates(this, force);
317
+ } catch (err) {
318
+ console.warn("Error ensuring channel connections:", err);
319
+ }
320
+
321
+ // 3. Server Task Scheduling & Local Scheduler
322
+ if (this.taskServerEnabled && this.db) {
323
+ void orchestratorStore.replayTaskSyncOutbox(this.db);
324
+ void orchestratorStore.loadTasks(this.db);
325
+ }
326
+
327
+ if (this.scheduler) {
328
+ this.scheduler.start();
329
+ void this.scheduler.tick();
330
+ }
331
+ }
332
+
290
333
  stopCurrentRequest(groupId = DEFAULT_GROUP_ID): void {
291
334
  if (this.state !== "thinking" && this.state !== "responding") {
292
335
  return;
@@ -192,6 +192,23 @@ describe("channel operations", () => {
192
192
  expect(state.peerjs.stop).toHaveBeenCalled();
193
193
  });
194
194
 
195
+ it("applyChannelRunningState invokes ensureConnected if channel is already running", () => {
196
+ const ensureConnectedMock = jest.fn();
197
+ const state = makeState({
198
+ channelEnabledByType: { telegram: true } as any,
199
+ telegramBotToken: "token",
200
+ telegram: {
201
+ running: true,
202
+ start: jest.fn(),
203
+ stop: jest.fn(),
204
+ ensureConnected: ensureConnectedMock,
205
+ } as any,
206
+ });
207
+
208
+ applyAllChannelRunningStates(state, true);
209
+ expect(ensureConnectedMock).toHaveBeenCalledWith(true);
210
+ });
211
+
195
212
  it("clearPeerJsTypingState invokes store update", () => {
196
213
  clearPeerJsTypingState("peer:group1");
197
214
  });
@@ -191,6 +191,7 @@ export function applyChannelRunningState(
191
191
  | "peerjsMyPeerId"
192
192
  >,
193
193
  channelType: ChannelType,
194
+ force: boolean = false,
194
195
  ): void {
195
196
  const channel = getChannelByType(state, channelType);
196
197
  if (!channel) {
@@ -198,7 +199,11 @@ export function applyChannelRunningState(
198
199
  }
199
200
 
200
201
  if (shouldRunChannel(state, channelType)) {
201
- channel.start();
202
+ if (!channel.running) {
203
+ channel.start();
204
+ } else if (typeof (channel as any).ensureConnected === "function") {
205
+ (channel as any).ensureConnected(force);
206
+ }
202
207
 
203
208
  return;
204
209
  }
@@ -218,11 +223,12 @@ export function applyAllChannelRunningStates(
218
223
  | "imessageServerUrl"
219
224
  | "peerjsMyPeerId"
220
225
  >,
226
+ force: boolean = false,
221
227
  ): void {
222
- applyChannelRunningState(state, "browser");
223
- applyChannelRunningState(state, "telegram");
224
- applyChannelRunningState(state, "imessage");
225
- applyChannelRunningState(state, "peerjs");
228
+ applyChannelRunningState(state, "browser", force);
229
+ applyChannelRunningState(state, "telegram", force);
230
+ applyChannelRunningState(state, "imessage", force);
231
+ applyChannelRunningState(state, "peerjs", force);
226
232
  }
227
233
 
228
234
  export function clearPeerJsTypingState(groupId: string): void {
@@ -57,4 +57,30 @@ describe("task operations", () => {
57
57
  expect.objectContaining({ method: "DELETE" }),
58
58
  );
59
59
  });
60
+
61
+ it("syncTaskToServer sets targetAddressSpace private for local hostnames", async () => {
62
+ await syncTaskToServer(
63
+ {
64
+ taskServerUrl: "https://hostname:8888/schedule",
65
+ taskServerEnabled: true,
66
+ } as any,
67
+ {
68
+ id: "t2",
69
+ groupId: "br:main",
70
+ prompt: "hello",
71
+ schedule: "*/5 * * * *",
72
+ enabled: true,
73
+ lastRun: null,
74
+ createdAt: 1,
75
+ } as any,
76
+ );
77
+
78
+ expect((globalThis as any).fetch).toHaveBeenCalledWith(
79
+ "https://hostname:8888/schedule/tasks",
80
+ expect.objectContaining({
81
+ method: "POST",
82
+ targetAddressSpace: "private",
83
+ }),
84
+ );
85
+ });
60
86
  });
@@ -23,7 +23,14 @@ function getTaskFetchOptions(
23
23
  host === "[::1]"
24
24
  ) {
25
25
  opts.targetAddressSpace = "loopback";
26
- } else if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host)) {
26
+ } else if (
27
+ /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host) ||
28
+ !host.includes(".") ||
29
+ host.endsWith(".local") ||
30
+ host.endsWith(".lan") ||
31
+ host.endsWith(".home") ||
32
+ host.endsWith(".internal")
33
+ ) {
27
34
  opts.targetAddressSpace = "private";
28
35
  }
29
36
  } catch (_) {}
@@ -3,10 +3,12 @@ import {
3
3
  shouldConnectControlPlane,
4
4
  detectCapabilities,
5
5
  createDefaultControlPlaneClient,
6
+ ensureControlPlaneConnected,
6
7
  executeClientControlCommand,
7
8
  getControlPlaneServerUrl,
8
9
  getActiveControlPlaneClient,
9
10
  stopControlPlaneClient,
11
+ detectClientVersion,
10
12
  } from "./initControlPlane.js";
11
13
 
12
14
  describe("initControlPlane", () => {
@@ -90,6 +92,65 @@ describe("initControlPlane", () => {
90
92
  });
91
93
  });
92
94
 
95
+ describe("detectClientVersion", () => {
96
+ afterEach(() => {
97
+ document
98
+ .querySelectorAll('meta[name="version"], meta[name="revision"]')
99
+ .forEach((el) => el.remove());
100
+ });
101
+
102
+ it("returns default fallback 1.27.1 when no DOM metadata is present", () => {
103
+ expect(detectClientVersion()).toBe("1.27.1");
104
+ });
105
+
106
+ it("returns '<semver> (<short-rev>)' when both version and revision are present", () => {
107
+ const metaVersion = document.createElement("meta");
108
+ metaVersion.setAttribute("name", "version");
109
+ metaVersion.setAttribute("content", "1.27.1");
110
+ document.head.appendChild(metaVersion);
111
+
112
+ const metaRevision = document.createElement("meta");
113
+ metaRevision.setAttribute("name", "revision");
114
+ metaRevision.setAttribute(
115
+ "content",
116
+ "ad65dc305c71a18cb2152e4f47da1491148fb07f",
117
+ );
118
+ document.head.appendChild(metaRevision);
119
+
120
+ expect(detectClientVersion()).toBe("1.27.1 (ad65dc3)");
121
+ });
122
+
123
+ it("returns '<semver>' when only version is present", () => {
124
+ const metaVersion = document.createElement("meta");
125
+ metaVersion.setAttribute("name", "version");
126
+ metaVersion.setAttribute("content", "1.27.1");
127
+ document.head.appendChild(metaVersion);
128
+
129
+ expect(detectClientVersion()).toBe("1.27.1");
130
+ });
131
+
132
+ it("returns '<short-rev>' when only revision is present", () => {
133
+ const metaRevision = document.createElement("meta");
134
+ metaRevision.setAttribute("name", "revision");
135
+ metaRevision.setAttribute(
136
+ "content",
137
+ "ad65dc305c71a18cb2152e4f47da1491148fb07f",
138
+ );
139
+ document.head.appendChild(metaRevision);
140
+
141
+ expect(detectClientVersion()).toBe("ad65dc3");
142
+ });
143
+
144
+ it("handles 7-char short revision directly", () => {
145
+ const metaRevision = document.createElement("meta");
146
+ metaRevision.setAttribute("name", "revision");
147
+ metaRevision.setAttribute("content", "ad65dc3");
148
+ document.head.appendChild(metaRevision);
149
+
150
+ expect(detectClientVersion()).toBe("ad65dc3");
151
+ });
152
+ });
153
+
93
154
  describe("isControlPlaneEnabled", () => {
94
155
  it("returns false by default (opt-in)", async () => {
95
156
  const { isControlPlaneEnabled } = await import("./initControlPlane.js");
@@ -800,6 +861,24 @@ describe("initControlPlane", () => {
800
861
  localStorage.removeItem("control_plane_transport");
801
862
  });
802
863
 
864
+ it("passes detected or custom version to ControlPlaneClient", () => {
865
+ const metaVersion = document.createElement("meta");
866
+ metaVersion.setAttribute("name", "version");
867
+ metaVersion.setAttribute("content", "1.27.1");
868
+ document.head.appendChild(metaVersion);
869
+
870
+ const client1 = createDefaultControlPlaneClient({ autoConnect: false });
871
+ expect((client1 as any).version).toBe("1.27.1");
872
+
873
+ const client2 = createDefaultControlPlaneClient({
874
+ autoConnect: false,
875
+ version: "custom-v2",
876
+ });
877
+ expect((client2 as any).version).toBe("custom-v2");
878
+
879
+ metaVersion.remove();
880
+ });
881
+
803
882
  it("manages active client lifecycle and autoConnect", () => {
804
883
  stopControlPlaneClient();
805
884
  expect(getActiveControlPlaneClient()).toBeNull();
@@ -811,4 +890,63 @@ describe("initControlPlane", () => {
811
890
  expect(getActiveControlPlaneClient()).toBeNull();
812
891
  });
813
892
  });
893
+
894
+ describe("ensureControlPlaneConnected", () => {
895
+ afterEach(() => {
896
+ stopControlPlaneClient();
897
+ localStorage.removeItem("control_plane_enabled");
898
+ localStorage.removeItem("control_plane_url");
899
+ localStorage.removeItem("control_plane_transport");
900
+ });
901
+
902
+ it("returns null when control plane is disabled", async () => {
903
+ localStorage.setItem("control_plane_enabled", "false");
904
+ const client = await ensureControlPlaneConnected();
905
+ expect(client).toBeNull();
906
+ expect(getActiveControlPlaneClient()).toBeNull();
907
+ });
908
+
909
+ it("syncs config from db to localStorage and connects when enabled", async () => {
910
+ const mockDb = {
911
+ transaction: () => ({
912
+ objectStore: () => ({
913
+ get: () => ({
914
+ onsuccess: null,
915
+ onerror: null,
916
+ }),
917
+ }),
918
+ }),
919
+ } as any;
920
+
921
+ // Mock getConfig
922
+ jest.unstable_mockModule("../../db/getConfig.js", () => ({
923
+ getConfig: jest.fn(async (_db: any, key: string) => {
924
+ if (key === "control_plane_enabled") return "true";
925
+ if (key === "control_plane_url") return "http://192.168.1.50:8888";
926
+ if (key === "control_plane_transport") return "websocket";
927
+ return undefined;
928
+ }),
929
+ }));
930
+
931
+ localStorage.removeItem("control_plane_enabled");
932
+ localStorage.removeItem("control_plane_url");
933
+
934
+ const client = await ensureControlPlaneConnected({ db: mockDb });
935
+ expect(client).not.toBeNull();
936
+ expect(getActiveControlPlaneClient()).toBe(client);
937
+ expect(localStorage.getItem("control_plane_enabled")).toBe("true");
938
+ expect(localStorage.getItem("control_plane_url")).toBe(
939
+ "http://192.168.1.50:8888",
940
+ );
941
+
942
+ // Subsequent call reuses active client and calls ensureConnected
943
+ const ensureConnectedSpy = jest.spyOn(client!, "ensureConnected");
944
+ const client2 = await ensureControlPlaneConnected({
945
+ db: mockDb,
946
+ force: true,
947
+ });
948
+ expect(client2).toBe(client);
949
+ expect(ensureConnectedSpy).toHaveBeenCalledWith(true);
950
+ });
951
+ });
814
952
  });
@@ -202,11 +202,43 @@ export function detectDeviceLabel(): string {
202
202
  return `${device} ${browser}`;
203
203
  }
204
204
 
205
+ export function detectClientVersion(): string {
206
+ let version = "";
207
+ let revision = "";
208
+
209
+ if (typeof document !== "undefined") {
210
+ version =
211
+ document
212
+ .querySelector('meta[name="version"]')
213
+ ?.getAttribute("content")
214
+ ?.trim() || "";
215
+ revision =
216
+ document
217
+ .querySelector('meta[name="revision"]')
218
+ ?.getAttribute("content")
219
+ ?.trim() || "";
220
+ }
221
+
222
+ const shortRevision = revision.length > 7 ? revision.slice(0, 7) : revision;
223
+
224
+ if (version && shortRevision) {
225
+ return `${version} (${shortRevision})`;
226
+ }
227
+ if (version) {
228
+ return version;
229
+ }
230
+ if (shortRevision) {
231
+ return shortRevision;
232
+ }
233
+ return "1.27.1";
234
+ }
235
+
205
236
  export interface InitControlPlaneOptions {
206
237
  orchestrator?: any;
207
238
  clientId?: string;
208
239
  transport?: ControlPlaneTransport;
209
240
  autoConnect?: boolean;
241
+ version?: string;
210
242
  }
211
243
 
212
244
  let activeClient: ControlPlaneClient | null = null;
@@ -263,6 +295,7 @@ export async function executeClientControlCommand(
263
295
  clientId,
264
296
  deviceLabel,
265
297
  capabilities,
298
+ version: options.version || detectClientVersion(),
266
299
  activeGroupId:
267
300
  options.orchestrator?.activeGroupId ||
268
301
  orchestratorStore.activeGroupId ||
@@ -646,6 +679,7 @@ export function createDefaultControlPlaneClient(
646
679
  getOrCreateControlPlaneClientId();
647
680
  const capabilities = detectCapabilities();
648
681
  const deviceLabel = detectDeviceLabel();
682
+ const version = options.version || detectClientVersion();
649
683
 
650
684
  let configuredTransport: ControlPlaneTransport | null = null;
651
685
  if (typeof localStorage !== "undefined") {
@@ -678,6 +712,7 @@ export function createDefaultControlPlaneClient(
678
712
  clientId,
679
713
  deviceLabel,
680
714
  capabilities,
715
+ version,
681
716
  peerId,
682
717
  transport,
683
718
  httpUrl,
@@ -718,3 +753,50 @@ export function stopControlPlaneClient(): void {
718
753
  activeClient = null;
719
754
  }
720
755
  }
756
+
757
+ export async function ensureControlPlaneConnected(
758
+ options: InitControlPlaneOptions & { db?: any; force?: boolean } = {},
759
+ ): Promise<ControlPlaneClient | null> {
760
+ let db = options.db;
761
+ if (!db) {
762
+ try {
763
+ db = await getDb();
764
+ } catch (_) {}
765
+ }
766
+
767
+ const enabled = await isControlPlaneEnabled(db);
768
+ if (!enabled) {
769
+ stopControlPlaneClient();
770
+ return null;
771
+ }
772
+
773
+ if (!shouldConnectControlPlane()) {
774
+ return null;
775
+ }
776
+
777
+ // Synchronize configuration from db to localStorage so synchronous lookups remain consistent
778
+ if (typeof localStorage !== "undefined" && db) {
779
+ try {
780
+ const { getConfig } = await import("../../db/getConfig.js");
781
+ const [url, transport] = await Promise.all([
782
+ getConfig(db, CONFIG_KEYS.CONTROL_PLANE_URL),
783
+ getConfig(db, CONFIG_KEYS.CONTROL_PLANE_TRANSPORT),
784
+ ]);
785
+ if (url) {
786
+ localStorage.setItem(CONFIG_KEYS.CONTROL_PLANE_URL, url);
787
+ }
788
+ if (transport) {
789
+ localStorage.setItem(CONFIG_KEYS.CONTROL_PLANE_TRANSPORT, transport);
790
+ }
791
+ localStorage.setItem(CONFIG_KEYS.CONTROL_PLANE_ENABLED, "true");
792
+ } catch (_) {}
793
+ }
794
+
795
+ const client = getActiveControlPlaneClient();
796
+ if (client) {
797
+ client.ensureConnected(options.force);
798
+ return client;
799
+ }
800
+
801
+ return createDefaultControlPlaneClient(options);
802
+ }