shadow-claw 1.23.4 → 1.24.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 (255) hide show
  1. package/README.md +42 -5
  2. package/bin/cli.mjs +166 -0
  3. package/bin/cli.test.mjs +60 -0
  4. package/bin/commands/backup.mjs +70 -0
  5. package/bin/commands/clients.mjs +41 -0
  6. package/bin/commands/peer-id.mjs +76 -0
  7. package/bin/commands/peer-id.test.mjs +104 -0
  8. package/bin/commands/send.mjs +51 -0
  9. package/bin/commands/tasks.mjs +59 -0
  10. package/bin/commands/webrtc-listen.mjs +254 -0
  11. package/bin/prerender-dsd-shell/prerender-dsd-shell.test.mjs +46 -0
  12. package/bin/rolldown.config.test.mjs +401 -8
  13. package/bin/utils/control-client.mjs +243 -0
  14. package/bin/utils/control-client.test.mjs +83 -0
  15. package/bin/utils/ulid.mjs +45 -0
  16. package/bin/utils/webrtc-control-client.mjs +699 -0
  17. package/bin/utils/webrtc-control-client.test.mjs +318 -0
  18. package/dist/electron/main.cjs +1 -1
  19. package/dist/public/AGENTS.md +5 -3
  20. package/dist/public/README.md +42 -5
  21. package/dist/public/agent.worker.js +1 -1
  22. package/dist/public/backup-controller-C_s9in2t.js +1 -0
  23. package/dist/public/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.css +1 -0
  24. package/dist/public/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.html +1 -0
  25. package/dist/public/components/settings/shadow-claw-task-server/shadow-claw-task-server.css +1 -1
  26. package/dist/public/components/settings/shadow-claw-task-server/shadow-claw-task-server.html +1 -1
  27. package/dist/public/components/shadow-claw-settings/shadow-claw-settings.html +1 -1
  28. package/dist/public/config-CrHchneq.js +1 -0
  29. package/dist/public/{configurePeerJs-B4Ym9VMj.js → configurePeerJs-CxpZeDJJ.js} +1 -1
  30. package/dist/public/{connections-BEn6JYdu.js → connections-DvbgdkaB.js} +1 -1
  31. package/dist/public/{custom-element-security-DdYlUozq.js → custom-element-security-NCqQ5lmC.js} +1 -1
  32. package/dist/public/docs/README.md +3 -1
  33. package/dist/public/docs/architecture/overview.md +12 -12
  34. package/dist/public/docs/decisions/bundled-typescript-architecture.md +1 -1
  35. package/dist/public/docs/decisions/indexeddb-and-opfs-storage.md +2 -2
  36. package/dist/public/docs/decisions/native-web-components-and-signals.md +1 -1
  37. package/dist/public/docs/example/article/index.html +1 -1
  38. package/dist/public/docs/guides/configuring-messaging-channels.md +41 -6
  39. package/dist/public/docs/guides/server-development-configuration.md +24 -0
  40. package/dist/public/docs/publishing/index.html +1 -1
  41. package/dist/public/docs/skill-creator/index.html +1 -1
  42. package/dist/public/docs/subsystems/backup.md +56 -0
  43. package/dist/public/docs/subsystems/cli.md +124 -0
  44. package/dist/public/docs/subsystems/control-plane.md +158 -0
  45. package/dist/public/docs/subsystems/notifications.md +54 -2
  46. package/dist/public/docs/subsystems/reactive-ui.md +1 -0
  47. package/dist/public/downloadGroupFile-By0GR6eV.js +1 -0
  48. package/dist/public/e2e/README.md +5 -1
  49. package/dist/public/{e2e-bridge-BQ4KzUt5.js → e2e-bridge-IGWsRl4i.js} +1 -1
  50. package/dist/public/file-viewer-D0eflgt6.js +1 -0
  51. package/dist/public/getAllTasks-vpBlgGsc.js +1 -0
  52. package/dist/public/getGroupDir-P1h9wl6S.js +1 -0
  53. package/dist/public/{git-DEUvQTUA.js → git-7Vq_SJSp.js} +2 -2
  54. package/dist/public/{iframe-sanitizer-0NS4XLOd.js → iframe-sanitizer-y2Z1pPwY.js} +1 -1
  55. package/dist/public/index.html +1 -1
  56. package/dist/public/index.js +1 -1
  57. package/dist/public/{initChatSplitResize-llGkSJXg.js → initChatSplitResize-CzV2E7Ul.js} +1 -1
  58. package/dist/public/initControlPlane-C9jpnWev.js +1 -0
  59. package/dist/public/llms.txt +1 -1
  60. package/dist/public/main/index.html +1 -1
  61. package/dist/public/main/memory/index.html +1 -1
  62. package/dist/public/{markdown-CyYR4App.js → markdown-BnpKxSxl.js} +1 -1
  63. package/dist/public/{mcp-reconnect-CSx4aMwy.js → mcp-reconnect-9emsqGRe.js} +1 -1
  64. package/dist/public/{memoryStorage-Dwg8NhAZ.js → memoryStorage-C0KvLNUp.js} +1 -1
  65. package/dist/public/{orchestrator-DRfEudXk.js → orchestrator-3h9xxHUm.js} +676 -676
  66. package/dist/public/orchestrator-nJj59RqD.js +15 -0
  67. package/dist/public/{peerjs-CurBuKvk.js → peerjs-BP4laMXj.js} +1 -1
  68. package/dist/public/{push-client-Dii-vPEh.js → push-client-CrjEXK3U.js} +1 -1
  69. package/dist/public/service-worker/push-handler.js +1 -1
  70. package/dist/public/service-worker.js +1 -1
  71. package/dist/public/{shadow-claw-5IHaDDMJ.js → shadow-claw-CbwETBWf.js} +2 -2
  72. package/dist/public/{shadow-claw-accounts-CZvz4iQz.js → shadow-claw-accounts-B4sBTQEj.js} +1 -1
  73. package/dist/public/{shadow-claw-channels-BKXFyRS3.js → shadow-claw-channels-BDWG5Qa3.js} +1 -1
  74. package/dist/public/{shadow-claw-chat-CCLuhWdI.js → shadow-claw-chat-Crv0DTCY.js} +6 -6
  75. package/dist/public/shadow-claw-control-plane-CX1IGbh9.js +199 -0
  76. package/dist/public/{shadow-claw-conversations-DV-87UUh.js → shadow-claw-conversations-_9qAaRXk.js} +1 -1
  77. package/dist/public/shadow-claw-dialog-BrOwAOdk.js +1 -0
  78. package/dist/public/{shadow-claw-file-viewer-Dk2dbAUd.js → shadow-claw-file-viewer-Blf6yGVD.js} +1 -1
  79. package/dist/public/shadow-claw-files-CYySBDCl.js +840 -0
  80. package/dist/public/{shadow-claw-git-DRA7_qck.js → shadow-claw-git-v8S0JjuR.js} +2 -2
  81. package/dist/public/{shadow-claw-integrations-1Xu5snuV.js → shadow-claw-integrations-D7ufpwhX.js} +1 -1
  82. package/dist/public/{shadow-claw-llm-6Aci1BDP.js → shadow-claw-llm-GWgoVfhI.js} +2 -2
  83. package/dist/public/{shadow-claw-mcp-remote-BUjAeVXC.js → shadow-claw-mcp-remote-kY4LMFNN.js} +1 -1
  84. package/dist/public/{shadow-claw-networking-FQe4nCp3.js → shadow-claw-networking-qHbP95z9.js} +1 -1
  85. package/dist/public/{shadow-claw-notifications-DV2wibL3.js → shadow-claw-notifications-YyHOK2Fr.js} +1 -1
  86. package/dist/public/{shadow-claw-page-header-B63S4HTU.js → shadow-claw-page-header-DMKbkv3s.js} +1 -1
  87. package/dist/public/{shadow-claw-pages-DE3jWj5K.js → shadow-claw-pages-DFZHhzFZ.js} +1 -1
  88. package/dist/public/{shadow-claw-settings-S6DAKfYx.js → shadow-claw-settings-DhxrKIfA.js} +10 -3
  89. package/dist/public/{shadow-claw-storage-DaacA3_K.js → shadow-claw-storage-BFynbP0j.js} +2 -2
  90. package/dist/public/{shadow-claw-task-server-IjKX042p.js → shadow-claw-task-server-3BIeb5oZ.js} +25 -4
  91. package/dist/public/{shadow-claw-tasks-Cy0JYmGU.js → shadow-claw-tasks-Caxc9atL.js} +1 -1
  92. package/dist/public/{shadow-claw-terminal-znAt7HyP.js → shadow-claw-terminal-Beb5qF-U.js} +1 -1
  93. package/dist/public/{shadow-claw-tools-Cutv9Si5.js → shadow-claw-tools-BfJx3A9W.js} +1 -1
  94. package/dist/public/{shadow-claw-webvm-BH8mPDEu.js → shadow-claw-webvm-BCS8ykeP.js} +1 -1
  95. package/dist/public/{syncWebMcpRegistration-BqJxaMGR.js → syncWebMcpRegistration-B_M2fCjH.js} +1 -1
  96. package/dist/server.js +286 -189
  97. package/package.json +2 -1
  98. package/src/__mocks__/jszip.cjs +15 -5
  99. package/src/components/settings/shadow-claw-accounts/shadow-claw-accounts.test.ts +35 -0
  100. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.css +117 -0
  101. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.html +81 -0
  102. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.test.ts +127 -0
  103. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.ts +292 -0
  104. package/src/components/settings/shadow-claw-git/shadow-claw-git.test.ts +72 -0
  105. package/src/components/settings/shadow-claw-integrations/connection-test-auth.test.ts +20 -30
  106. package/src/components/settings/shadow-claw-integrations/shadow-claw-integrations.test.ts +116 -0
  107. package/src/components/settings/shadow-claw-llm/model-ranking.test.ts +143 -51
  108. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +307 -4
  109. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.ts +8 -8
  110. package/src/components/settings/shadow-claw-mcp-remote/shadow-claw-mcp-remote.test.ts +31 -0
  111. package/src/components/settings/shadow-claw-peerjs/shadow-claw-peerjs.test.ts +186 -0
  112. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +76 -0
  113. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.css +23 -2
  114. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.html +1 -1
  115. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +19 -0
  116. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.ts +2 -1
  117. package/src/components/shadow-claw/utils/initChatSplitResize.test.ts +180 -13
  118. package/src/components/shadow-claw/utils/setupEffects.test.ts +39 -0
  119. package/src/components/shadow-claw-a2ui/catalog/basic/shared.test.ts +117 -0
  120. package/src/components/shadow-claw-a2ui/shadow-claw-a2ui.test.ts +1159 -1
  121. package/src/components/shadow-claw-chat/shadow-claw-chat.test.ts +1523 -19
  122. package/src/components/shadow-claw-chat/utils/escapeHtml.test.ts +13 -0
  123. package/src/components/shadow-claw-chat/utils/getPeerChatDisplayStatus.test.ts +20 -0
  124. package/src/components/shadow-claw-conversations/shadow-claw-conversations.test.ts +494 -0
  125. package/src/components/shadow-claw-dialog/shadow-claw-dialog.ts +13 -2
  126. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +273 -0
  127. package/src/components/shadow-claw-files/shadow-claw-files.test.ts +408 -1
  128. package/src/components/shadow-claw-pdf-viewer/utils/installGetOrInsertComputedPolyfill.test.ts +65 -0
  129. package/src/components/shadow-claw-settings/shadow-claw-settings.html +7 -0
  130. package/src/components/shadow-claw-settings/shadow-claw-settings.test.ts +221 -0
  131. package/src/components/shadow-claw-settings/shadow-claw-settings.ts +2 -0
  132. package/src/components/shadow-claw-tasks/shadow-claw-tasks.test.ts +407 -0
  133. package/src/components/shadow-claw-terminal/utils/consumeEscapeSequence.test.ts +58 -0
  134. package/src/components/shadow-claw-tools/shadow-claw-tools.test.ts +80 -0
  135. package/src/config/config.ts +10 -0
  136. package/src/core/backup-controller.test.ts +101 -0
  137. package/src/core/backup-controller.ts +137 -0
  138. package/src/core/control-plane-client.test.ts +408 -0
  139. package/src/core/control-plane-client.ts +425 -0
  140. package/src/core/orchestrator/orchestrator.ts +4 -0
  141. package/src/core/orchestrator/utils/compactContext.test.ts +78 -0
  142. package/src/core/orchestrator/utils/operations/channel.test.ts +254 -9
  143. package/src/core/orchestrator/utils/operations/provider.test.ts +326 -34
  144. package/src/core/orchestrator/utils/operations/vm.test.ts +114 -2
  145. package/src/core/orchestrator/utils/syncProxyConfigToServiceWorker.test.ts +60 -0
  146. package/src/core/utils/initControlPlane.test.ts +276 -0
  147. package/src/core/utils/initControlPlane.ts +437 -0
  148. package/src/core/utils/initializeApp.ts +18 -0
  149. package/src/db/getSession.test.ts +13 -1
  150. package/src/server/app.test.ts +12 -0
  151. package/src/server/app.ts +18 -8
  152. package/src/server/client-registry.test.ts +250 -0
  153. package/src/server/client-registry.ts +477 -0
  154. package/src/server/config.ts +15 -0
  155. package/src/server/control-plane-types.ts +120 -0
  156. package/src/server/control-plane.test.ts +472 -0
  157. package/src/server/control-plane.ts +581 -0
  158. package/src/server/routes/backup.test.ts +263 -0
  159. package/src/server/routes/backup.ts +262 -0
  160. package/src/server/routes/integrations-email.test.ts +532 -0
  161. package/src/server/routes/llamafile.test.ts +172 -48
  162. package/src/server/routes/mesh-llm.test.ts +192 -0
  163. package/src/server/routes/ollama.test.ts +49 -0
  164. package/src/server/server-peer.test.ts +86 -0
  165. package/src/server/server-peer.ts +330 -0
  166. package/src/server/server.ts +35 -0
  167. package/src/server/services/llamafile-manager.test.ts +230 -6
  168. package/src/server/services/transformers-runtime.test.ts +186 -18
  169. package/src/server/webrtc.test.ts +21 -0
  170. package/src/server/webrtc.ts +72 -0
  171. package/src/service-worker/fetch-proxy.test.ts +64 -1
  172. package/src/service-worker/push-handler.ts +39 -0
  173. package/src/shell/vm.test.ts +744 -12
  174. package/src/storage/ensureMainGroupIndex.test.ts +153 -0
  175. package/src/storage/getGroupFile.test.ts +66 -0
  176. package/src/storage/memoryStorage.test.ts +82 -2
  177. package/src/storage/writeGroupFileBytes.test.ts +143 -0
  178. package/src/stores/chat-ui.test.ts +20 -0
  179. package/src/stores/orchestrator.test.ts +134 -0
  180. package/src/stores/toast.test.ts +50 -0
  181. package/src/stores/tools.test.ts +147 -10
  182. package/src/subsystems/channels/imessage.test.ts +59 -0
  183. package/src/subsystems/channels/peerjs.test.ts +500 -62
  184. package/src/subsystems/channels/peerjs.ts +71 -4
  185. package/src/subsystems/channels/telegram.test.ts +177 -0
  186. package/src/subsystems/email/catalog.test.ts +1 -0
  187. package/src/subsystems/email/connections.test.ts +39 -1
  188. package/src/subsystems/git/credentials.test.ts +160 -825
  189. package/src/subsystems/git/git.test.ts +463 -0
  190. package/src/subsystems/notifications/__mocks__/node-sqlite.cjs +12 -1
  191. package/src/subsystems/notifications/push-routes.test.ts +33 -0
  192. package/src/subsystems/notifications/push-routes.ts +23 -0
  193. package/src/subsystems/notifications/push-store.ts +1 -1
  194. package/src/subsystems/providers/builtin-ai-tasks.test.ts +74 -0
  195. package/src/subsystems/providers/litert-lm-provider.test.ts +212 -51
  196. package/src/subsystems/providers/model-registry.test.ts +115 -0
  197. package/src/subsystems/providers/providers.test.ts +94 -1
  198. package/src/subsystems/providers/transformers-js-provider.test.ts +279 -0
  199. package/src/subsystems/providers/utils/createModelCacheFetch.test.ts +2 -2
  200. package/src/subsystems/providers/utils/downloadModelToCache.test.ts +2 -2
  201. package/src/subsystems/providers/utils/loadModelStream.test.ts +2 -2
  202. package/src/subsystems/skills/activateSkill.test.ts +101 -0
  203. package/src/subsystems/skills/discoverSkills.test.ts +251 -0
  204. package/src/subsystems/tools/cron.test.ts +64 -0
  205. package/src/subsystems/tools/declarative.test.ts +255 -89
  206. package/src/testing/assets/v86.9pfs/libv86.mjs +63 -0
  207. package/src/testing/assets/v86.ext2/libv86.mjs +3 -0
  208. package/src/testing/assets_missing_v86/assets/v86.9pfs/libv86.mjs +1 -0
  209. package/src/testing/jest-setup.ts +328 -40
  210. package/src/testing/mock-v86.mjs +49 -0
  211. package/src/ui/a2ui/types.ts +4 -3
  212. package/src/ui/a2ui/utils/normaliseComponentsToMap.test.ts +102 -0
  213. package/src/ui/a2ui/utils/resolveChildIds.test.ts +63 -0
  214. package/src/ui/a2ui/utils/resolveDynamicNumber.test.ts +13 -1
  215. package/src/worker/tools/bash/utils/waitForVMReady.test.ts +40 -0
  216. package/src/worker/tools/email/email.test.ts +193 -8
  217. package/src/worker/tools/email/utils/asEmailAttachmentInputs.test.ts +47 -0
  218. package/src/worker/tools/git/git.test.ts +214 -3
  219. package/src/worker/tools/rooms/invite-to-room.test.ts +54 -0
  220. package/src/worker/tools/rooms/leave-room.test.ts +40 -0
  221. package/src/worker/tools/rooms/list-room-members.test.ts +75 -0
  222. package/src/worker/tools/tasks/tasks-utils.test.ts +22 -0
  223. package/src/worker/tools/ui/ask-user.test.ts +50 -0
  224. package/src/worker/tools/ui/clear-chat.test.ts +19 -0
  225. package/src/worker/tools/ui/get-current-time.test.ts +19 -0
  226. package/src/worker/tools/ui/javascript.test.ts +116 -0
  227. package/src/worker/tools/ui/list-components.test.ts +10 -0
  228. package/src/worker/tools/ui/list-tool-profiles.test.ts +42 -0
  229. package/src/worker/tools/ui/manage-tools.test.ts +34 -0
  230. package/src/worker/tools/ui/render-component.test.ts +346 -0
  231. package/src/worker/tools/ui/send-notification.test.ts +40 -0
  232. package/src/worker/tools/ui/utils/getAllowFullInternetAccess.test.ts +31 -0
  233. package/src/worker/tools/ui/utils/parseBooleanConfig.test.ts +25 -0
  234. package/src/worker/tools/workspace/attach-file.test.ts +84 -0
  235. package/src/worker/tools/workspace/copy-file.test.ts +69 -0
  236. package/src/worker/tools/workspace/create-directory.test.ts +40 -0
  237. package/src/worker/tools/workspace/delete-file.test.ts +73 -0
  238. package/src/worker/tools/workspace/diff-files.test.ts +94 -0
  239. package/src/worker/tools/workspace/list-files.test.ts +36 -0
  240. package/src/worker/tools/workspace/move-file.test.ts +66 -0
  241. package/src/worker/tools/workspace/open-file.test.ts +57 -0
  242. package/src/worker/tools/workspace/patch-file.test.ts +81 -0
  243. package/src/worker/tools/workspace/read-file.test.ts +116 -0
  244. package/src/worker/tools/workspace/send-file.test.ts +82 -0
  245. package/src/worker/tools/workspace/update-memory.test.ts +29 -0
  246. package/src/worker/tools/workspace/write-file.test.ts +28 -0
  247. package/src/worker/utils/rate-limit.test.ts +199 -0
  248. package/src/worker/utils/tool-state.test.ts +29 -0
  249. package/src/worker/utils/transformers-js-utils.test.ts +59 -0
  250. package/dist/public/config-BYAuxx3j.js +0 -1
  251. package/dist/public/downloadGroupFile-Drdvg1Ev.js +0 -1
  252. package/dist/public/file-viewer-CcZYw-PS.js +0 -1
  253. package/dist/public/orchestrator-o9Fj67BN.js +0 -15
  254. package/dist/public/shadow-claw-dialog-ByyHgxM9.js +0 -1
  255. package/dist/public/shadow-claw-files-BpfpJIuP.js +0 -840
@@ -0,0 +1,276 @@
1
+ import { jest } from "@jest/globals";
2
+ import {
3
+ shouldConnectControlPlane,
4
+ detectCapabilities,
5
+ createDefaultControlPlaneClient,
6
+ } from "./initControlPlane.js";
7
+
8
+ describe("initControlPlane", () => {
9
+ const origWebSocket = globalThis.WebSocket;
10
+ const origEventSource = (globalThis as any).EventSource;
11
+
12
+ beforeAll(() => {
13
+ (globalThis as any).WebSocket = class MockWS {} as any;
14
+ });
15
+
16
+ afterAll(() => {
17
+ globalThis.WebSocket = origWebSocket;
18
+ (globalThis as any).EventSource = origEventSource;
19
+ });
20
+ describe("shouldConnectControlPlane", () => {
21
+ it("returns false for file: protocol", () => {
22
+ expect(
23
+ shouldConnectControlPlane({
24
+ protocol: "file:",
25
+ hostname: "localhost",
26
+ }),
27
+ ).toBe(false);
28
+ });
29
+
30
+ it("returns true for web-hosted domains including static hosts and custom servers", () => {
31
+ expect(
32
+ shouldConnectControlPlane({
33
+ protocol: "https:",
34
+ hostname: "user.github.io",
35
+ }),
36
+ ).toBe(true);
37
+
38
+ expect(
39
+ shouldConnectControlPlane({
40
+ protocol: "http:",
41
+ hostname: "127.0.0.1",
42
+ }),
43
+ ).toBe(true);
44
+
45
+ expect(
46
+ shouldConnectControlPlane({
47
+ protocol: "https:",
48
+ hostname: "my-shadowclaw-server.internal",
49
+ }),
50
+ ).toBe(true);
51
+ });
52
+ });
53
+
54
+ describe("detectCapabilities", () => {
55
+ it("detects available browser capabilities", () => {
56
+ const capabilities = detectCapabilities();
57
+ expect(Array.isArray(capabilities)).toBe(true);
58
+ });
59
+ });
60
+
61
+ describe("isControlPlaneEnabled", () => {
62
+ it("returns false by default (opt-in)", async () => {
63
+ const { isControlPlaneEnabled } = await import("./initControlPlane.js");
64
+ expect(await isControlPlaneEnabled()).toBe(false);
65
+ });
66
+
67
+ it("respects localStorage override", async () => {
68
+ const { isControlPlaneEnabled } = await import("./initControlPlane.js");
69
+ localStorage.setItem("control_plane_enabled", "true");
70
+ expect(await isControlPlaneEnabled()).toBe(true);
71
+ localStorage.removeItem("control_plane_enabled");
72
+ });
73
+ });
74
+
75
+ describe("getControlPlaneServerUrl", () => {
76
+ afterEach(() => {
77
+ localStorage.removeItem("control_plane_url");
78
+ });
79
+
80
+ it("defaults to 127.0.0.1 on static hosts like github.io", async () => {
81
+ const { getControlPlaneServerUrl } =
82
+ await import("./initControlPlane.js");
83
+
84
+ const urls = getControlPlaneServerUrl({
85
+ protocol: "https:",
86
+ hostname: "xt-ml.github.io",
87
+ host: "xt-ml.github.io",
88
+ });
89
+
90
+ expect(urls.httpUrl).toBe("http://127.0.0.1:8888");
91
+ expect(urls.wsUrl).toBe("ws://127.0.0.1:8888/ws/control");
92
+ });
93
+
94
+ it("respects configured custom server URL from localStorage", async () => {
95
+ const { getControlPlaneServerUrl } =
96
+ await import("./initControlPlane.js");
97
+
98
+ localStorage.setItem(
99
+ "control_plane_url",
100
+ "https://my-server.example.com",
101
+ );
102
+
103
+ const urls = getControlPlaneServerUrl();
104
+ expect(urls.httpUrl).toBe("https://my-server.example.com");
105
+ expect(urls.wsUrl).toBe("wss://my-server.example.com/ws/control");
106
+ });
107
+ });
108
+
109
+ describe("getOrCreateControlPlaneClientId", () => {
110
+ afterEach(() => {
111
+ localStorage.removeItem("control_plane_client_id");
112
+ });
113
+
114
+ it("generates a persistent non-empty client ID and stores in localStorage", async () => {
115
+ const { getOrCreateControlPlaneClientId } =
116
+ await import("./initControlPlane.js");
117
+ localStorage.removeItem("control_plane_client_id");
118
+
119
+ const id1 = getOrCreateControlPlaneClientId();
120
+ expect(typeof id1).toBe("string");
121
+ expect(id1.length).toBeGreaterThan(0);
122
+ expect(localStorage.getItem("control_plane_client_id")).toBe(id1);
123
+
124
+ // Subsequent call returns the exact same ID
125
+ const id2 = getOrCreateControlPlaneClientId();
126
+ expect(id2).toBe(id1);
127
+ });
128
+
129
+ it("respects pre-existing client ID in localStorage", async () => {
130
+ const { getOrCreateControlPlaneClientId } =
131
+ await import("./initControlPlane.js");
132
+ localStorage.setItem("control_plane_client_id", "my-custom-client-123");
133
+
134
+ const id = getOrCreateControlPlaneClientId();
135
+ expect(id).toBe("my-custom-client-123");
136
+ });
137
+ });
138
+
139
+ describe("createDefaultControlPlaneClient", () => {
140
+ afterEach(async () => {
141
+ const { stopControlPlaneClient } = await import("./initControlPlane.js");
142
+ stopControlPlaneClient();
143
+ localStorage.removeItem("control_plane_client_id");
144
+ });
145
+
146
+ it("creates and connects a ControlPlaneClient with orchestrator handlers", () => {
147
+ const mockOrchestrator: any = {
148
+ submitMessage: jest.fn(),
149
+ state: "idle",
150
+ };
151
+
152
+ const client = createDefaultControlPlaneClient({
153
+ orchestrator: mockOrchestrator,
154
+ clientId: "test-client-init",
155
+ autoConnect: false,
156
+ });
157
+
158
+ expect(client).toBeDefined();
159
+ expect(client.getState()).toBe("disconnected");
160
+ });
161
+
162
+ it("generates a non-empty clientId automatically when not provided in options", async () => {
163
+ const client = createDefaultControlPlaneClient({
164
+ autoConnect: false,
165
+ });
166
+
167
+ expect(client).toBeDefined();
168
+ // Should have generated and saved a client ID
169
+ const savedId = localStorage.getItem("control_plane_client_id");
170
+ expect(savedId).toBeTruthy();
171
+ expect(savedId?.length).toBeGreaterThan(0);
172
+ });
173
+
174
+ it("cleans up existing active client when re-created", async () => {
175
+ const { getActiveControlPlaneClient } =
176
+ await import("./initControlPlane.js");
177
+
178
+ const client1 = createDefaultControlPlaneClient({ autoConnect: false });
179
+ expect(getActiveControlPlaneClient()).toBe(client1);
180
+
181
+ const client2 = createDefaultControlPlaneClient({ autoConnect: false });
182
+ expect(getActiveControlPlaneClient()).toBe(client2);
183
+ expect(client1.getState()).toBe("disconnected");
184
+ });
185
+
186
+ it("handles send-message with browserChat and submitMessage", async () => {
187
+ const mockSubmit = jest.fn();
188
+ const mockOrchestrator: any = {
189
+ browserChat: {
190
+ submit: mockSubmit,
191
+ },
192
+ };
193
+
194
+ const client = createDefaultControlPlaneClient({
195
+ orchestrator: mockOrchestrator,
196
+ autoConnect: false,
197
+ });
198
+
199
+ const handler = (client as any)._handlers.get("send-message");
200
+ expect(handler).toBeDefined();
201
+
202
+ const result = await handler({ text: "Hello CLI", groupId: "br:main" });
203
+ expect(result).toEqual({ queued: true, groupId: "br:main" });
204
+ expect(mockSubmit).toHaveBeenCalledWith("Hello CLI", "br:main");
205
+ });
206
+
207
+ it("handles read-state", async () => {
208
+ const mockOrchestrator: any = {
209
+ activeGroupId: "br:custom",
210
+ state: "responding",
211
+ };
212
+
213
+ const client = createDefaultControlPlaneClient({
214
+ orchestrator: mockOrchestrator,
215
+ autoConnect: false,
216
+ });
217
+
218
+ const handler = (client as any)._handlers.get("read-state");
219
+ expect(handler).toBeDefined();
220
+
221
+ const stateResult = await handler();
222
+ expect(stateResult.activeGroupId).toBe("br:custom");
223
+ expect(stateResult.state).toBe("responding");
224
+ });
225
+
226
+ it("handles list-tasks and filters by groupId when specified", async () => {
227
+ const { openDatabase } = await import("../../db/openDatabase.js");
228
+ const { saveTask } = await import("../../db/saveTask.js");
229
+ const db = await openDatabase();
230
+
231
+ await saveTask(db, {
232
+ id: "task-1",
233
+ groupId: "br:main",
234
+ name: "Main task",
235
+ prompt: "Do main work",
236
+ enabled: true,
237
+ createdAt: Date.now(),
238
+ lastRun: null,
239
+ });
240
+
241
+ await saveTask(db, {
242
+ id: "task-2",
243
+ groupId: "peer:cli-123",
244
+ name: "Peer task",
245
+ prompt: "Do peer work",
246
+ enabled: true,
247
+ createdAt: Date.now(),
248
+ lastRun: null,
249
+ });
250
+
251
+ const client = createDefaultControlPlaneClient({
252
+ autoConnect: false,
253
+ });
254
+
255
+ const handler = (client as any)._handlers.get("list-tasks");
256
+ expect(handler).toBeDefined();
257
+
258
+ // All tasks
259
+ const allResult = await handler({});
260
+ expect(allResult.tasks.some((t: any) => t.id === "task-1")).toBe(true);
261
+ expect(allResult.tasks.some((t: any) => t.id === "task-2")).toBe(true);
262
+
263
+ // Filtered by groupId
264
+ const filteredResult = await handler({ groupId: "br:main" });
265
+ expect(
266
+ filteredResult.tasks.every((t: any) => t.groupId === "br:main"),
267
+ ).toBe(true);
268
+ expect(filteredResult.tasks.some((t: any) => t.id === "task-1")).toBe(
269
+ true,
270
+ );
271
+ expect(filteredResult.tasks.some((t: any) => t.id === "task-2")).toBe(
272
+ false,
273
+ );
274
+ });
275
+ });
276
+ });
@@ -0,0 +1,437 @@
1
+ /**
2
+ * ShadowClaw — Control Plane Browser Initialization Helper
3
+ */
4
+
5
+ import { ControlPlaneClient } from "../control-plane-client.js";
6
+ import { getDeploymentNamespace } from "../app-routes.js";
7
+ import { getDb } from "../../db/db.js";
8
+ import { getAllTasks } from "../../db/getAllTasks.js";
9
+ import { ulid } from "../../utils/ulid.js";
10
+ import {
11
+ CONFIG_KEYS,
12
+ DEFAULT_CONTROL_PLANE_ENABLED,
13
+ DEFAULT_CONTROL_PLANE_TRANSPORT,
14
+ } from "../../config/config.js";
15
+ import type { ControlPlaneTransport } from "../../server/control-plane-types.js";
16
+
17
+ export function getOrCreateControlPlaneClientId(): string {
18
+ if (typeof localStorage !== "undefined") {
19
+ try {
20
+ const stored = localStorage.getItem(CONFIG_KEYS.CONTROL_PLANE_CLIENT_ID);
21
+ if (stored && stored.trim()) {
22
+ return stored.trim();
23
+ }
24
+ } catch (_) {}
25
+ }
26
+
27
+ const namespace = getDeploymentNamespace();
28
+ const generated = namespace
29
+ ? `${namespace}-${ulid().toLowerCase()}`
30
+ : `client-${ulid().toLowerCase()}`;
31
+
32
+ if (typeof localStorage !== "undefined") {
33
+ try {
34
+ localStorage.setItem(CONFIG_KEYS.CONTROL_PLANE_CLIENT_ID, generated);
35
+ } catch (_) {}
36
+ }
37
+
38
+ return generated;
39
+ }
40
+
41
+ export function isControlPlaneEnabledSync(): boolean {
42
+ if (typeof localStorage !== "undefined") {
43
+ try {
44
+ const val = localStorage.getItem(CONFIG_KEYS.CONTROL_PLANE_ENABLED);
45
+ if (val !== null) {
46
+ return val === "true" || val === "1";
47
+ }
48
+ } catch (_) {}
49
+ }
50
+ return DEFAULT_CONTROL_PLANE_ENABLED;
51
+ }
52
+
53
+ export async function isControlPlaneEnabled(db?: any): Promise<boolean> {
54
+ if (typeof localStorage !== "undefined") {
55
+ try {
56
+ const val = localStorage.getItem(CONFIG_KEYS.CONTROL_PLANE_ENABLED);
57
+ if (val !== null) {
58
+ return val === "true" || val === "1";
59
+ }
60
+ } catch (_) {}
61
+ }
62
+
63
+ if (db) {
64
+ try {
65
+ const { getConfig } = await import("../../db/getConfig.js");
66
+ const val = await getConfig(db, CONFIG_KEYS.CONTROL_PLANE_ENABLED);
67
+ if (val !== null && val !== undefined) {
68
+ return val === "true" || val === "1";
69
+ }
70
+ } catch (_) {}
71
+ }
72
+
73
+ return DEFAULT_CONTROL_PLANE_ENABLED;
74
+ }
75
+
76
+ export function shouldConnectControlPlane(loc?: {
77
+ protocol?: string;
78
+ hostname?: string;
79
+ }): boolean {
80
+ if (typeof EventSource === "undefined" && typeof WebSocket === "undefined") {
81
+ return false;
82
+ }
83
+
84
+ const currentLoc = loc || (typeof location !== "undefined" ? location : null);
85
+ if (!currentLoc) {
86
+ return false;
87
+ }
88
+
89
+ if (currentLoc.protocol === "file:") {
90
+ return false;
91
+ }
92
+
93
+ if (
94
+ typeof document !== "undefined" &&
95
+ document.querySelector('meta[name="shadowclaw-static-only"]')
96
+ ) {
97
+ return false;
98
+ }
99
+
100
+ return true;
101
+ }
102
+
103
+ export function getControlPlaneServerUrl(loc?: {
104
+ protocol?: string;
105
+ host?: string;
106
+ hostname?: string;
107
+ }): { httpUrl: string; wsUrl: string } {
108
+ let customUrl = "";
109
+ if (typeof localStorage !== "undefined") {
110
+ try {
111
+ customUrl = (
112
+ localStorage.getItem(CONFIG_KEYS.CONTROL_PLANE_URL) || ""
113
+ ).trim();
114
+ } catch (_) {}
115
+ }
116
+
117
+ const currentLoc = loc || (typeof location !== "undefined" ? location : null);
118
+ const isStaticHost =
119
+ currentLoc &&
120
+ (currentLoc.protocol === "file:" ||
121
+ (currentLoc.hostname &&
122
+ (currentLoc.hostname.endsWith(".github.io") ||
123
+ currentLoc.hostname.endsWith(".pages.dev"))));
124
+
125
+ let baseHttp = customUrl;
126
+ if (!baseHttp) {
127
+ if (isStaticHost) {
128
+ baseHttp = "http://127.0.0.1:8888";
129
+ } else if (currentLoc && currentLoc.host) {
130
+ baseHttp = `${currentLoc.protocol}//${currentLoc.host}`;
131
+ } else {
132
+ baseHttp = "http://127.0.0.1:8888";
133
+ }
134
+ }
135
+
136
+ baseHttp = baseHttp.replace(/\/+$/, "");
137
+
138
+ const baseWs = baseHttp.replace(/^https?:/, (proto) =>
139
+ proto === "https:" ? "wss:" : "ws:",
140
+ );
141
+
142
+ return {
143
+ httpUrl: baseHttp,
144
+ wsUrl: `${baseWs}/ws/control`,
145
+ };
146
+ }
147
+
148
+ export function detectCapabilities(): string[] {
149
+ const caps: string[] = [];
150
+
151
+ if (typeof navigator !== "undefined") {
152
+ if (
153
+ navigator.storage &&
154
+ typeof navigator.storage.getDirectory === "function"
155
+ ) {
156
+ caps.push("opfs");
157
+ }
158
+
159
+ if (
160
+ "serviceWorker" in navigator &&
161
+ typeof window !== "undefined" &&
162
+ "PushManager" in window
163
+ ) {
164
+ caps.push("push");
165
+ }
166
+
167
+ if (
168
+ (typeof document !== "undefined" && (document as any).modelContext) ||
169
+ ((navigator as any) && (navigator as any).modelContext)
170
+ ) {
171
+ caps.push("webmcp");
172
+ }
173
+ }
174
+
175
+ if (typeof RTCPeerConnection !== "undefined") {
176
+ caps.push("peerjs");
177
+ }
178
+
179
+ return caps;
180
+ }
181
+
182
+ export function detectDeviceLabel(): string {
183
+ if (typeof navigator === "undefined") {
184
+ return "ShadowClaw Node";
185
+ }
186
+
187
+ const ua = navigator.userAgent;
188
+ let device = "Desktop";
189
+ if (/iPad/i.test(ua)) device = "iPad";
190
+ else if (/iPhone/i.test(ua)) device = "iPhone";
191
+ else if (/Android/i.test(ua)) device = "Android";
192
+ else if (/Macintosh/i.test(ua)) device = "Mac";
193
+ else if (/Windows/i.test(ua)) device = "Windows";
194
+ else if (/Linux/i.test(ua)) device = "Linux";
195
+
196
+ let browser = "Browser";
197
+ if (/Chrome/i.test(ua) && !/Edg/i.test(ua)) browser = "Chrome";
198
+ else if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) browser = "Safari";
199
+ else if (/Firefox/i.test(ua)) browser = "Firefox";
200
+ else if (/Edg/i.test(ua)) browser = "Edge";
201
+
202
+ return `${device} ${browser}`;
203
+ }
204
+
205
+ export interface InitControlPlaneOptions {
206
+ orchestrator?: any;
207
+ clientId?: string;
208
+ transport?: ControlPlaneTransport;
209
+ autoConnect?: boolean;
210
+ }
211
+
212
+ let activeClient: ControlPlaneClient | null = null;
213
+
214
+ export async function executeClientControlCommand(
215
+ action: string,
216
+ args: any = {},
217
+ options: InitControlPlaneOptions = {},
218
+ ): Promise<any> {
219
+ const clientId = getOrCreateControlPlaneClientId();
220
+ const deviceLabel = detectDeviceLabel();
221
+ const capabilities = detectCapabilities();
222
+
223
+ switch (action) {
224
+ case "send-message": {
225
+ if (!args.text) {
226
+ throw new Error("Missing text parameter");
227
+ }
228
+ const { orchestratorStore } =
229
+ await import("../../stores/orchestrator.js");
230
+ const orch = options.orchestrator || orchestratorStore.orchestrator;
231
+ const targetGroupId =
232
+ args.groupId || orchestratorStore.activeGroupId || "br:main";
233
+
234
+ if (orch?.browserChat?.submit) {
235
+ orch.browserChat.submit(args.text, targetGroupId);
236
+ return { queued: true, groupId: targetGroupId };
237
+ }
238
+ if (orch?.submitMessage) {
239
+ orch.submitMessage(args.text, targetGroupId);
240
+ return { queued: true, groupId: targetGroupId };
241
+ }
242
+ if (orchestratorStore.sendMessage) {
243
+ orchestratorStore.sendMessage(args.text);
244
+ return { queued: true, groupId: targetGroupId };
245
+ }
246
+ return { queued: false, error: "Orchestrator not ready" };
247
+ }
248
+
249
+ case "list-tasks": {
250
+ const db = await getDb();
251
+ let tasks = await getAllTasks(db);
252
+ if (args?.groupId) {
253
+ tasks = tasks.filter((t) => t.groupId === args.groupId);
254
+ }
255
+ return { tasks };
256
+ }
257
+
258
+ case "read-state": {
259
+ const { orchestratorStore } =
260
+ await import("../../stores/orchestrator.js");
261
+ const orch = options.orchestrator || orchestratorStore.orchestrator;
262
+ return {
263
+ clientId,
264
+ deviceLabel,
265
+ capabilities,
266
+ activeGroupId:
267
+ options.orchestrator?.activeGroupId ||
268
+ orchestratorStore.activeGroupId ||
269
+ orch?.activeGroupId ||
270
+ "br:main",
271
+ state:
272
+ options.orchestrator?.state ||
273
+ orchestratorStore.state ||
274
+ orch?.state ||
275
+ "idle",
276
+ };
277
+ }
278
+
279
+ case "invoke-tool": {
280
+ if (!args.toolName) {
281
+ throw new Error("Missing toolName parameter");
282
+ }
283
+ const ctx =
284
+ (typeof document !== "undefined" && (document as any).modelContext) ||
285
+ (typeof navigator !== "undefined" && (navigator as any).modelContext);
286
+
287
+ const result = await ctx.invoke(args.toolName, args.input);
288
+ return { result };
289
+ }
290
+
291
+ case "trigger-backup": {
292
+ const { orchestratorStore } =
293
+ await import("../../stores/orchestrator.js");
294
+ const targetGroupId =
295
+ args.groupId ||
296
+ options.orchestrator?.activeGroupId ||
297
+ orchestratorStore.activeGroupId ||
298
+ "br:main";
299
+ const db = await getDb();
300
+ const { getGroupDir } = await import("../../storage/getGroupDir.js");
301
+ const groupDir = await getGroupDir(db, targetGroupId);
302
+
303
+ async function collectFiles(
304
+ dir: FileSystemDirectoryHandle,
305
+ prefix = "",
306
+ ): Promise<string[]> {
307
+ const result: string[] = [];
308
+ for await (const [name, handle] of (dir as any).entries()) {
309
+ const relPath = prefix ? `${prefix}/${name}` : name;
310
+ if (handle.kind === "directory") {
311
+ const sub = await collectFiles(handle, relPath);
312
+ result.push(...sub);
313
+ } else {
314
+ result.push(relPath);
315
+ }
316
+ }
317
+ return result;
318
+ }
319
+
320
+ async function readFile(relPath: string): Promise<Uint8Array | null> {
321
+ try {
322
+ const parts = relPath.split("/");
323
+ let cur: FileSystemDirectoryHandle = groupDir;
324
+ for (let i = 0; i < parts.length - 1; i++) {
325
+ cur = await cur.getDirectoryHandle(parts[i]);
326
+ }
327
+ const fileHandle = await cur.getFileHandle(parts[parts.length - 1]);
328
+ const file = await fileHandle.getFile();
329
+ const buffer = await file.arrayBuffer();
330
+ return new Uint8Array(buffer);
331
+ } catch {
332
+ return null;
333
+ }
334
+ }
335
+
336
+ const { BackupController } = await import("../backup-controller.js");
337
+ const backupCtrl = new BackupController({
338
+ clientId,
339
+ token: args.token,
340
+ fileEnumerator: () => collectFiles(groupDir),
341
+ fileReader: (filePath) => readFile(filePath),
342
+ });
343
+
344
+ const result = await backupCtrl.initiate();
345
+ return result;
346
+ }
347
+
348
+ default:
349
+ throw new Error(`Unknown action '${action}'`);
350
+ }
351
+ }
352
+
353
+ export function createDefaultControlPlaneClient(
354
+ options: InitControlPlaneOptions = {},
355
+ ): ControlPlaneClient {
356
+ if (activeClient) {
357
+ try {
358
+ activeClient.disconnect();
359
+ } catch (_) {}
360
+ activeClient = null;
361
+ }
362
+
363
+ const clientId =
364
+ (options.clientId && options.clientId.trim()) ||
365
+ getOrCreateControlPlaneClientId();
366
+ const capabilities = detectCapabilities();
367
+ const deviceLabel = detectDeviceLabel();
368
+
369
+ let configuredTransport: ControlPlaneTransport | null = null;
370
+ if (typeof localStorage !== "undefined") {
371
+ try {
372
+ const stored = localStorage.getItem(CONFIG_KEYS.CONTROL_PLANE_TRANSPORT);
373
+ if (stored === "sse" || stored === "websocket") {
374
+ configuredTransport = stored;
375
+ }
376
+ } catch (_) {}
377
+ }
378
+
379
+ const transport =
380
+ options.transport ||
381
+ configuredTransport ||
382
+ (DEFAULT_CONTROL_PLANE_TRANSPORT as ControlPlaneTransport);
383
+
384
+ let peerId: string | undefined;
385
+ if (typeof localStorage !== "undefined") {
386
+ try {
387
+ // CONFIG_KEYS.PEERJS_MY_PEER_ID is "peerjs_my_peer_id" — set by configurePeerJs via setConfig.
388
+ // We also check the raw IndexedDB config key written by setConfig, which mirrors to localStorage
389
+ // under the same key name in browser environments that support it.
390
+ peerId = localStorage.getItem(CONFIG_KEYS.PEERJS_MY_PEER_ID) || undefined;
391
+ } catch (_) {}
392
+ }
393
+
394
+ const { httpUrl, wsUrl } = getControlPlaneServerUrl();
395
+
396
+ const client = new ControlPlaneClient({
397
+ clientId,
398
+ deviceLabel,
399
+ capabilities,
400
+ peerId,
401
+ transport,
402
+ httpUrl,
403
+ url: wsUrl,
404
+ handlers: {
405
+ "send-message": (args: any) =>
406
+ executeClientControlCommand("send-message", args, options),
407
+ "list-tasks": (args: any) =>
408
+ executeClientControlCommand("list-tasks", args, options),
409
+ "read-state": (args: any) =>
410
+ executeClientControlCommand("read-state", args, options),
411
+ "invoke-tool": (args: any) =>
412
+ executeClientControlCommand("invoke-tool", args, options),
413
+ "trigger-backup": (args: any) =>
414
+ executeClientControlCommand("trigger-backup", args, options),
415
+ },
416
+ });
417
+
418
+ if (options.autoConnect !== false && shouldConnectControlPlane()) {
419
+ client.connect();
420
+ }
421
+
422
+ activeClient = client;
423
+ return client;
424
+ }
425
+
426
+ export function getActiveControlPlaneClient(): ControlPlaneClient | null {
427
+ return activeClient;
428
+ }
429
+
430
+ export function stopControlPlaneClient(): void {
431
+ if (activeClient) {
432
+ try {
433
+ activeClient.disconnect();
434
+ } catch (_) {}
435
+ activeClient = null;
436
+ }
437
+ }