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,330 @@
1
+ /**
2
+ * ShadowClaw — Server-Side WebRTC Peer & A2A Bridge
3
+ *
4
+ * Enables the ShadowClaw server to act as a first-class WebRTC peer using
5
+ * node-datachannel. Can connect to local or remote PeerJS signaling servers,
6
+ * communicate directly with browser clients, other ShadowClaw servers,
7
+ * and CLI tools over RTCDataChannels.
8
+ */
9
+
10
+ import { getPeerClass } from "./webrtc.js";
11
+ import { ulid } from "../utils/ulid.js";
12
+ import type {
13
+ ControlMessage,
14
+ CommandExecutePayload,
15
+ CommandResultPayload,
16
+ } from "./control-plane-types.js";
17
+
18
+ export interface ServerPeerOptions {
19
+ peerId?: string;
20
+ host?: string;
21
+ port?: number;
22
+ path?: string;
23
+ secure?: boolean;
24
+ verbose?: boolean;
25
+ handlers?: Record<string, (args: any) => Promise<any> | any>;
26
+ }
27
+
28
+ export class ServerPeer {
29
+ private _peerId: string;
30
+ private _host: string;
31
+ private _port: number;
32
+ private _path: string;
33
+ private _secure: boolean;
34
+ private _verbose: boolean;
35
+ private _peer: any = null;
36
+ private _connections: Map<string, any> = new Map();
37
+ private _pendingCommands: Map<
38
+ string,
39
+ {
40
+ resolve: (val: any) => void;
41
+ reject: (err: any) => void;
42
+ timeout: NodeJS.Timeout;
43
+ }
44
+ > = new Map();
45
+ private _handlers: Map<string, (args: any) => Promise<any> | any> = new Map();
46
+ private _isOpen = false;
47
+
48
+ constructor(options: ServerPeerOptions = {}) {
49
+ this._peerId = options.peerId || `server-${ulid().toLowerCase()}`;
50
+ this._host = options.host || "127.0.0.1";
51
+ this._port = options.port || 8888;
52
+ this._path = options.path || "/";
53
+ this._secure = options.secure ?? false;
54
+ this._verbose = options.verbose ?? false;
55
+
56
+ if (options.handlers) {
57
+ for (const [action, fn] of Object.entries(options.handlers)) {
58
+ this._handlers.set(action, fn);
59
+ }
60
+ }
61
+ }
62
+
63
+ public get peerId(): string {
64
+ return this._peerId;
65
+ }
66
+
67
+ public get isOpen(): boolean {
68
+ return this._isOpen;
69
+ }
70
+
71
+ private log(msg: string): void {
72
+ if (this._verbose) {
73
+ console.log(`[server-peer] ${msg}`);
74
+ }
75
+ }
76
+
77
+ public async start(): Promise<string> {
78
+ const Peer = await getPeerClass();
79
+
80
+ return new Promise((resolve, reject) => {
81
+ try {
82
+ const peer = new Peer(this._peerId, {
83
+ host: this._host,
84
+ port: this._port,
85
+ path: this._path,
86
+ secure: this._secure,
87
+ });
88
+
89
+ this._peer = peer;
90
+
91
+ peer.on("open", (id: string) => {
92
+ this._peerId = id;
93
+ this._isOpen = true;
94
+ this.log(`Server peer opened with ID: ${id}`);
95
+ resolve(id);
96
+ });
97
+
98
+ peer.on("connection", (conn: any) => {
99
+ this._setupConnection(conn);
100
+ });
101
+
102
+ peer.on("error", (err: any) => {
103
+ this.log(`Server peer error: ${err.message || String(err)}`);
104
+ if (!this._isOpen) {
105
+ reject(err);
106
+ }
107
+ });
108
+
109
+ peer.on("close", () => {
110
+ this._isOpen = false;
111
+ this._connections.clear();
112
+ this.log("Server peer closed");
113
+ });
114
+ } catch (err) {
115
+ reject(err);
116
+ }
117
+ });
118
+ }
119
+
120
+ private _setupConnection(conn: any): void {
121
+ const remotePeerId = conn.peer;
122
+
123
+ conn.on("open", () => {
124
+ this._connections.set(remotePeerId, conn);
125
+ this.log(`DataChannel connected with peer: ${remotePeerId}`);
126
+ });
127
+
128
+ conn.on("data", (data: any) => {
129
+ this._handleData(remotePeerId, conn, data);
130
+ });
131
+
132
+ conn.on("close", () => {
133
+ this._connections.delete(remotePeerId);
134
+ this.log(`DataChannel closed for peer: ${remotePeerId}`);
135
+ });
136
+
137
+ conn.on("error", (err: any) => {
138
+ this.log(`DataChannel error with ${remotePeerId}: ${err.message}`);
139
+ });
140
+ }
141
+
142
+ private async _handleData(
143
+ _remotePeerId: string,
144
+ conn: any,
145
+ rawData: any,
146
+ ): Promise<void> {
147
+ let msg: any = rawData;
148
+ if (typeof rawData === "string") {
149
+ try {
150
+ msg = JSON.parse(rawData);
151
+ } catch {
152
+ return;
153
+ }
154
+ }
155
+
156
+ if (!msg || typeof msg !== "object") {
157
+ return;
158
+ }
159
+
160
+ // Handle Control Plane execute commands
161
+ if (msg.type === "command:execute") {
162
+ const payload = msg.payload as CommandExecutePayload;
163
+ const { commandId, action, args } = payload || {};
164
+
165
+ const handler = this._handlers.get(action);
166
+ if (!handler) {
167
+ const result: ControlMessage = {
168
+ id: ulid(),
169
+ type: "command:result",
170
+ replyTo: msg.id,
171
+ payload: {
172
+ commandId,
173
+ success: false,
174
+ error: `Unknown action: ${action}`,
175
+ } as CommandResultPayload,
176
+ };
177
+ conn.send(result);
178
+ return;
179
+ }
180
+
181
+ try {
182
+ const data = await handler(args);
183
+ const result: ControlMessage = {
184
+ id: ulid(),
185
+ type: "command:result",
186
+ replyTo: msg.id,
187
+ payload: {
188
+ commandId,
189
+ success: true,
190
+ data,
191
+ } as CommandResultPayload,
192
+ };
193
+ conn.send(result);
194
+ } catch (err: any) {
195
+ const result: ControlMessage = {
196
+ id: ulid(),
197
+ type: "command:result",
198
+ replyTo: msg.id,
199
+ payload: {
200
+ commandId,
201
+ success: false,
202
+ error: err.message || String(err),
203
+ } as CommandResultPayload,
204
+ };
205
+ conn.send(result);
206
+ }
207
+ return;
208
+ }
209
+
210
+ // Handle Control Plane command result responses
211
+ if (msg.type === "command:result") {
212
+ const res = msg.payload as CommandResultPayload;
213
+ if (res?.commandId && this._pendingCommands.has(res.commandId)) {
214
+ const pending = this._pendingCommands.get(res.commandId)!;
215
+ clearTimeout(pending.timeout);
216
+ this._pendingCommands.delete(res.commandId);
217
+ pending.resolve(res);
218
+ }
219
+ return;
220
+ }
221
+ }
222
+
223
+ public isPeerConnected(remotePeerId: string): boolean {
224
+ const conn = this._connections.get(remotePeerId);
225
+ return !!conn && (conn.open || conn._open);
226
+ }
227
+
228
+ public getConnectedPeers(): string[] {
229
+ return Array.from(this._connections.keys());
230
+ }
231
+
232
+ public async sendCommand(
233
+ remotePeerId: string,
234
+ action: string,
235
+ args: Record<string, unknown> = {},
236
+ timeoutMs: number = 30_000,
237
+ ): Promise<CommandResultPayload> {
238
+ if (!this._peer || !this._isOpen) {
239
+ throw new Error("Server peer is not running");
240
+ }
241
+
242
+ let conn = this._connections.get(remotePeerId);
243
+ if (!conn || !conn.open) {
244
+ conn = this._peer.connect(remotePeerId, {
245
+ reliable: true,
246
+ serialization: "binary",
247
+ });
248
+ this._setupConnection(conn);
249
+
250
+ await new Promise<void>((resolve, reject) => {
251
+ const t = setTimeout(
252
+ () => {
253
+ reject(
254
+ new Error(
255
+ `Connection to peer ${remotePeerId} timed out after ${timeoutMs}ms`,
256
+ ),
257
+ );
258
+ },
259
+ Math.min(10_000, timeoutMs),
260
+ );
261
+
262
+ conn.on("open", () => {
263
+ clearTimeout(t);
264
+ resolve();
265
+ });
266
+ conn.on("error", (err: any) => {
267
+ clearTimeout(t);
268
+ reject(err);
269
+ });
270
+ });
271
+ }
272
+
273
+ const commandId = ulid();
274
+ const commandPayload: CommandExecutePayload = {
275
+ commandId,
276
+ action: action as any,
277
+ args,
278
+ };
279
+
280
+ const message: ControlMessage = {
281
+ id: ulid(),
282
+ type: "command:execute",
283
+ payload: commandPayload,
284
+ };
285
+
286
+ return new Promise<CommandResultPayload>((resolve, reject) => {
287
+ const timeout = setTimeout(() => {
288
+ this._pendingCommands.delete(commandId);
289
+ reject(
290
+ new Error(
291
+ `Command ${action} to peer ${remotePeerId} timed out after ${timeoutMs}ms`,
292
+ ),
293
+ );
294
+ }, timeoutMs);
295
+
296
+ this._pendingCommands.set(commandId, { resolve, reject, timeout });
297
+
298
+ try {
299
+ conn.send(message);
300
+ } catch (err) {
301
+ clearTimeout(timeout);
302
+ this._pendingCommands.delete(commandId);
303
+ reject(err);
304
+ }
305
+ });
306
+ }
307
+
308
+ public close(): void {
309
+ this._isOpen = false;
310
+ for (const [, pending] of this._pendingCommands) {
311
+ clearTimeout(pending.timeout);
312
+ pending.reject(new Error("Server peer closed"));
313
+ }
314
+ this._pendingCommands.clear();
315
+
316
+ for (const conn of this._connections.values()) {
317
+ try {
318
+ conn.close();
319
+ } catch (_) {}
320
+ }
321
+ this._connections.clear();
322
+
323
+ if (this._peer) {
324
+ try {
325
+ this._peer.destroy();
326
+ } catch (_) {}
327
+ this._peer = null;
328
+ }
329
+ }
330
+ }
@@ -7,6 +7,8 @@ import tcpPortUsed from "tcp-port-used";
7
7
  import { DEFAULT_DEV_IP } from "../config/config.js";
8
8
  import { createApp } from "./app.js";
9
9
  import { attachPeerServer } from "./peer.js";
10
+ import { createControlPlane } from "./control-plane.js";
11
+ import { ServerPeer } from "./server-peer.js";
10
12
  import { parseConfig, ServerConfig } from "./config.js";
11
13
 
12
14
  export async function startServer(
@@ -27,8 +29,22 @@ export async function startServer(
27
29
 
28
30
  const httpServer = http.createServer(app);
29
31
 
32
+ const controlPlane = createControlPlane({
33
+ httpServer,
34
+ app,
35
+ token: config.controlToken,
36
+ verbose: config.verbose,
37
+ });
38
+
39
+ let serverPeer: ServerPeer | null = null;
30
40
  if (config.peerjs) {
31
41
  attachPeerServer(httpServer, app);
42
+ serverPeer = new ServerPeer({
43
+ host: config.bindHost,
44
+ port: config.port,
45
+ path: "/peerjs",
46
+ verbose: config.verbose,
47
+ });
32
48
  }
33
49
 
34
50
  return new Promise<http.Server>((resolve) => {
@@ -53,8 +69,27 @@ export async function startServer(
53
69
  );
54
70
  }
55
71
 
72
+ console.log(
73
+ `Control plane active at http://${config.bindHost}:${config.port}/api/control/events (SSE) and ws://${config.bindHost}:${config.port}/ws/control (WebSocket)`,
74
+ );
75
+ console.log(`Control token: ${controlPlane.getToken()}`);
76
+
56
77
  if (config.peerjs) {
57
78
  console.log("PeerJS signaling server enabled (routes at /peerjs/*)");
79
+ if (serverPeer) {
80
+ serverPeer
81
+ .start()
82
+ .then((id) => {
83
+ console.log(`Server WebRTC peer active with ID: ${id}`);
84
+ })
85
+ .catch((err) => {
86
+ if (config.verbose) {
87
+ console.warn(
88
+ `[server] ServerPeer start notice: ${err.message || String(err)}`,
89
+ );
90
+ }
91
+ });
92
+ }
58
93
  }
59
94
 
60
95
  if (config.verbose) {
@@ -33,7 +33,7 @@ describe("LlamafileManagerService", () => {
33
33
  return mod.createLlamafileManagerService();
34
34
  }
35
35
 
36
- describe("listBinaries", () => {
36
+ describe("listBinaries & resolveBinary", () => {
37
37
  it("lists .llamafile files in the cache directory", async () => {
38
38
  statMock.mockResolvedValue({ isDirectory: () => true });
39
39
  readdirMock.mockResolvedValue([
@@ -48,6 +48,21 @@ describe("LlamafileManagerService", () => {
48
48
  expect(binaries).toHaveLength(2);
49
49
  expect(binaries[0].id).toBe("model1");
50
50
  expect(binaries[1].id).toBe("model2");
51
+
52
+ const resolved = await service.resolveBinary("model1");
53
+ expect(resolved.fileName).toBe("model1.llamafile");
54
+ });
55
+
56
+ it("throws error if model is not found", async () => {
57
+ statMock.mockResolvedValue({ isDirectory: () => true });
58
+ readdirMock.mockResolvedValue([
59
+ { name: "model1.llamafile", isFile: () => true },
60
+ ]);
61
+
62
+ const service = await getService();
63
+ await expect(service.resolveBinary("missing")).rejects.toThrow(
64
+ /not found under assets\/cache\/llamafile/,
65
+ );
51
66
  });
52
67
 
53
68
  it("throws error if no cache directory is found", async () => {
@@ -60,7 +75,7 @@ describe("LlamafileManagerService", () => {
60
75
  });
61
76
  });
62
77
 
63
- describe("getLlamafileRuntimeOptions", () => {
78
+ describe("getLlamafileRuntimeOptions & Host Validation", () => {
64
79
  it("parses options from headers", async () => {
65
80
  const service = await getService();
66
81
  const req = {
@@ -100,11 +115,54 @@ describe("LlamafileManagerService", () => {
100
115
  expect(opts.port).toBe(9090);
101
116
  expect(opts.offline).toBe(false);
102
117
  });
118
+
119
+ it("rejects non-loopback hosts by default", async () => {
120
+ const service = await getService();
121
+ const req = {
122
+ headers: {
123
+ "x-llamafile-host": "192.168.1.100",
124
+ },
125
+ body: {},
126
+ } as any;
127
+
128
+ expect(() => service.getLlamafileRuntimeOptions(req)).toThrow(
129
+ /Unsafe host '192.168.1.100'/,
130
+ );
131
+ });
103
132
  });
104
133
 
105
- describe("createLlamafilePromptEchoFilter", () => {
134
+ describe("cancelRequest and request ID", () => {
135
+ it("extracts request ID from header and body", async () => {
136
+ const service = await getService();
137
+ const reqWithHeader = {
138
+ headers: { "x-shadowclaw-request-id": "req-123" },
139
+ } as any;
140
+ expect(service.getLlamafileRequestId(reqWithHeader)).toBe("req-123");
141
+
142
+ const reqWithBody = {
143
+ headers: {},
144
+ body: { requestId: "req-456" },
145
+ } as any;
146
+ expect(service.getLlamafileRequestId(reqWithBody, reqWithBody.body)).toBe(
147
+ "req-456",
148
+ );
149
+ });
150
+
151
+ it("cancels active requests", async () => {
152
+ const service = await getService();
153
+ expect(service.cancelRequest("non-existent")).toBe(false);
154
+
155
+ const cancelFn = jest.fn();
156
+ service.__getActiveRequests().set("req-cancel", cancelFn);
157
+
158
+ expect(service.cancelRequest("req-cancel")).toBe(true);
159
+ expect(cancelFn).toHaveBeenCalled();
160
+ });
161
+ });
162
+
163
+ describe("createLlamafilePromptEchoFilter & stripLlamafilePromptEcho", () => {
106
164
  it("strips echoed prompt from output", async () => {
107
- const { createLlamafilePromptEchoFilter } =
165
+ const { createLlamafilePromptEchoFilter, stripLlamafilePromptEcho } =
108
166
  await import("./llamafile-manager.js");
109
167
  const prompt = "USER: hello\n\nASSISTANT:";
110
168
  const filter = createLlamafilePromptEchoFilter(prompt);
@@ -112,6 +170,12 @@ describe("LlamafileManagerService", () => {
112
170
  expect(filter.push(prompt)).toBe("");
113
171
  expect(filter.push(" Hello there!")).toBe(" Hello there!");
114
172
  expect(filter.flush()).toBe("");
173
+
174
+ const stripped = stripLlamafilePromptEcho(
175
+ "USER: hello\n\nASSISTANT: Hello there!",
176
+ prompt,
177
+ );
178
+ expect(stripped).toBe("Hello there!");
115
179
  });
116
180
 
117
181
  it("handles partial echoes", async () => {
@@ -129,7 +193,6 @@ describe("LlamafileManagerService", () => {
129
193
  it("spawns llamafile process and streams output", async () => {
130
194
  const service = await getService();
131
195
 
132
- // Mock listBinaries/resolveBinary
133
196
  statMock.mockResolvedValue({ isDirectory: () => true });
134
197
  readdirMock.mockResolvedValue([
135
198
  { name: "test.llamafile", isFile: () => true },
@@ -161,6 +224,7 @@ describe("LlamafileManagerService", () => {
161
224
  setHeader: jest.fn(),
162
225
  flushHeaders: jest.fn(),
163
226
  status: jest.fn().mockReturnThis(),
227
+ json: jest.fn(),
164
228
  }) as any;
165
229
 
166
230
  const body = {
@@ -177,7 +241,7 @@ describe("LlamafileManagerService", () => {
177
241
  false,
178
242
  );
179
243
 
180
- await new Promise((resolve) => setTimeout(resolve, 0)); // Wait for spawn
244
+ await new Promise((resolve) => setTimeout(resolve, 0));
181
245
 
182
246
  child.stdout.emit(
183
247
  "data",
@@ -192,5 +256,165 @@ describe("LlamafileManagerService", () => {
192
256
  );
193
257
  expect(res.end).toHaveBeenCalled();
194
258
  });
259
+
260
+ it("handles non-streaming responses and parses tool calls", async () => {
261
+ const service = await getService();
262
+
263
+ statMock.mockResolvedValue({ isDirectory: () => true });
264
+ readdirMock.mockResolvedValue([
265
+ { name: "test.llamafile", isFile: () => true },
266
+ ]);
267
+ accessMock.mockResolvedValue(undefined);
268
+
269
+ const child: any = new EventEmitter();
270
+ child.stdout = new EventEmitter();
271
+ child.stderr = new EventEmitter();
272
+ child.pid = 1234;
273
+ child.kill = jest.fn();
274
+ child.exitCode = null;
275
+ child.signalCode = null;
276
+ child.killed = false;
277
+
278
+ spawnMock.mockImplementation(() => {
279
+ queueMicrotask(() => child.emit("spawn"));
280
+
281
+ return child;
282
+ });
283
+
284
+ const req: any = new EventEmitter();
285
+ req.headers = {};
286
+ req.socket = new EventEmitter();
287
+
288
+ const res = Object.assign(new EventEmitter(), {
289
+ write: jest.fn(),
290
+ end: jest.fn(),
291
+ setHeader: jest.fn(),
292
+ flushHeaders: jest.fn(),
293
+ status: jest.fn().mockReturnThis(),
294
+ json: jest.fn(),
295
+ }) as any;
296
+
297
+ const body = {
298
+ model: "test",
299
+ messages: [{ role: "user", content: "hi" }],
300
+ stream: false,
301
+ };
302
+
303
+ const promise = service.invokeCli(
304
+ req,
305
+ res,
306
+ body,
307
+ { model: "test", offline: false },
308
+ false,
309
+ );
310
+
311
+ await new Promise((resolve) => setTimeout(resolve, 0));
312
+
313
+ child.stdout.emit(
314
+ "data",
315
+ Buffer.from(
316
+ 'USER: hi\n\nASSISTANT: call:web_search{"query":"test"}',
317
+ "utf8",
318
+ ),
319
+ );
320
+ child.emit("close", 0);
321
+
322
+ await promise;
323
+
324
+ expect(res.json).toHaveBeenCalledWith(
325
+ expect.objectContaining({
326
+ choices: expect.arrayContaining([
327
+ expect.objectContaining({
328
+ message: expect.objectContaining({
329
+ tool_calls: expect.arrayContaining([
330
+ expect.objectContaining({
331
+ function: expect.objectContaining({ name: "web_search" }),
332
+ }),
333
+ ]),
334
+ }),
335
+ }),
336
+ ]),
337
+ }),
338
+ );
339
+ });
340
+
341
+ it("throws error when binary is not executable", async () => {
342
+ const service = await getService();
343
+ statMock.mockResolvedValue({ isDirectory: () => true });
344
+ readdirMock.mockResolvedValue([
345
+ { name: "test.llamafile", isFile: () => true },
346
+ ]);
347
+ accessMock.mockRejectedValue(new Error("EACCES: permission denied"));
348
+
349
+ const req: any = { headers: {} };
350
+ const res: any = {};
351
+
352
+ await expect(
353
+ service.invokeCli(
354
+ req,
355
+ res,
356
+ { model: "test", messages: [{ role: "user", content: "hi" }] },
357
+ { model: "test", offline: false },
358
+ false,
359
+ ),
360
+ ).rejects.toThrow(/Binary not executable or not found/);
361
+ });
362
+
363
+ it("cancels active requests and extracts request IDs", async () => {
364
+ const service = await getService();
365
+
366
+ // 1. cancelRequest
367
+ expect(service.cancelRequest("non-existent")).toBe(false);
368
+
369
+ const cancelFn = jest.fn(() => {
370
+ service.__getActiveRequests().delete("req-123");
371
+ });
372
+ service.__getActiveRequests().set("req-123", cancelFn);
373
+ expect(service.cancelRequest("req-123")).toBe(true);
374
+ expect(cancelFn).toHaveBeenCalled();
375
+ expect(service.__getActiveRequests().has("req-123")).toBe(false);
376
+
377
+ // 2. getLlamafileRequestId
378
+ const reqWithHeader = {
379
+ headers: { "x-shadowclaw-request-id": "header-id" },
380
+ } as any;
381
+ expect(service.getLlamafileRequestId(reqWithHeader)).toBe("header-id");
382
+
383
+ const reqWithBody = { headers: {} } as any;
384
+ const bodyWithId = { requestId: "body-id" };
385
+ expect(service.getLlamafileRequestId(reqWithBody, bodyWithId)).toBe(
386
+ "body-id",
387
+ );
388
+ });
389
+
390
+ it("handles invokeLlamafileServer forwarding", async () => {
391
+ const { invokeLlamafileServer } = await import("./llamafile-manager.js");
392
+
393
+ const req: any = { headers: {} };
394
+ const res: any = {
395
+ status: jest.fn().mockReturnThis(),
396
+ setHeader: jest.fn().mockReturnThis(),
397
+ send: jest.fn().mockReturnThis(),
398
+ };
399
+
400
+ // Mock fetch response for server error
401
+ (global.fetch as any) = jest.fn<any>().mockResolvedValue({
402
+ ok: false,
403
+ status: 502,
404
+ headers: new Headers({ "x-custom": "test" }),
405
+ text: async () => "Bad Gateway",
406
+ });
407
+
408
+ await invokeLlamafileServer(
409
+ req,
410
+ res,
411
+ { model: "custom-model", messages: [] },
412
+ { host: "127.0.0.1", port: 8080 },
413
+ false,
414
+ );
415
+
416
+ expect(res.status).toHaveBeenCalledWith(502);
417
+ expect(res.send).toHaveBeenCalledWith("Bad Gateway");
418
+ });
195
419
  });
196
420
  });