@granular-software/sdk 0.1.0 → 0.2.1

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.
package/dist/index.js CHANGED
@@ -1,10 +1,7 @@
1
1
  'use strict';
2
2
 
3
- var WebSocket = require('ws');
4
3
  var Automerge = require('@automerge/automerge');
5
4
 
6
- function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
7
-
8
5
  function _interopNamespace(e) {
9
6
  if (e && e.__esModule) return e;
10
7
  var n = Object.create(null);
@@ -23,10 +20,14 @@ function _interopNamespace(e) {
23
20
  return Object.freeze(n);
24
21
  }
25
22
 
26
- var WebSocket__default = /*#__PURE__*/_interopDefault(WebSocket);
27
23
  var Automerge__namespace = /*#__PURE__*/_interopNamespace(Automerge);
28
24
 
29
25
  // src/ws-client.ts
26
+ var GlobalWebSocket = void 0;
27
+ if (typeof globalThis !== "undefined" && globalThis.WebSocket) {
28
+ GlobalWebSocket = globalThis.WebSocket;
29
+ }
30
+ var READY_STATE_OPEN = 1;
30
31
  var WSClient = class {
31
32
  ws = null;
32
33
  url;
@@ -41,7 +42,9 @@ var WSClient = class {
41
42
  syncState = Automerge__namespace.initSyncState();
42
43
  reconnectTimer = null;
43
44
  isExplicitlyDisconnected = false;
45
+ options;
44
46
  constructor(options) {
47
+ this.options = options;
45
48
  this.url = options.url;
46
49
  this.sessionId = options.sessionId;
47
50
  this.token = options.token;
@@ -52,34 +55,68 @@ var WSClient = class {
52
55
  */
53
56
  async connect() {
54
57
  this.isExplicitlyDisconnected = false;
58
+ const WebSocketClass = this.options.WebSocketCtor || GlobalWebSocket;
59
+ if (!WebSocketClass) {
60
+ throw new Error('No WebSocket implementation found. If using Node.js, please install "ws" and pass the constructor to the SDK options: { WebSocketCtor: WebSocket }.');
61
+ }
55
62
  return new Promise((resolve, reject) => {
56
63
  try {
57
64
  const wsUrl = new URL(this.url);
58
65
  wsUrl.searchParams.set("sessionId", this.sessionId);
59
66
  wsUrl.searchParams.set("token", this.token);
60
- this.ws = new WebSocket__default.default(wsUrl.toString());
61
- this.ws.on("open", () => {
62
- this.emit("open", {});
63
- resolve();
64
- });
65
- this.ws.on("message", (data) => {
66
- try {
67
- const message = JSON.parse(data.toString());
68
- this.handleMessage(message);
69
- } catch (error) {
70
- console.error("[Granular] Failed to parse message:", error);
71
- }
72
- });
73
- this.ws.on("error", (error) => {
74
- this.emit("error", error);
75
- if (this.ws?.readyState !== WebSocket__default.default.OPEN) {
76
- reject(error);
77
- }
78
- });
79
- this.ws.on("close", () => {
80
- this.emit("close", {});
81
- this.handleDisconnect();
82
- });
67
+ this.ws = new WebSocketClass(wsUrl.toString());
68
+ if (!this.ws) throw new Error("Failed to create WebSocket");
69
+ const socket = this.ws;
70
+ if (typeof socket.on === "function") {
71
+ socket.on("open", () => {
72
+ this.emit("open", {});
73
+ resolve();
74
+ });
75
+ socket.on("message", (data) => {
76
+ try {
77
+ const message = JSON.parse(data.toString());
78
+ this.handleMessage(message);
79
+ } catch (error) {
80
+ console.error("[Granular] Failed to parse message:", error);
81
+ }
82
+ });
83
+ socket.on("error", (error) => {
84
+ this.emit("error", error);
85
+ if (socket.readyState !== READY_STATE_OPEN) {
86
+ reject(error);
87
+ }
88
+ });
89
+ socket.on("close", () => {
90
+ this.emit("close", {});
91
+ this.handleDisconnect();
92
+ });
93
+ } else {
94
+ this.ws.onopen = () => {
95
+ this.emit("open", {});
96
+ resolve();
97
+ };
98
+ this.ws.onmessage = (event) => {
99
+ try {
100
+ const data = event.data;
101
+ const message = JSON.parse(data.toString());
102
+ this.handleMessage(message);
103
+ } catch (error) {
104
+ console.error("[Granular] Failed to parse message:", error);
105
+ }
106
+ };
107
+ this.ws.onerror = (event) => {
108
+ const error = new Error("WebSocket error");
109
+ error.event = event;
110
+ this.emit("error", error);
111
+ if (this.ws?.readyState !== READY_STATE_OPEN) {
112
+ reject(error);
113
+ }
114
+ };
115
+ this.ws.onclose = () => {
116
+ this.emit("close", {});
117
+ this.handleDisconnect();
118
+ };
119
+ }
83
120
  } catch (error) {
84
121
  reject(error);
85
122
  }
@@ -99,8 +136,8 @@ var WSClient = class {
99
136
  console.log("[Granular DEBUG] Received message:", JSON.stringify(message).slice(0, 500));
100
137
  if ("type" in message && message.type === "sync") {
101
138
  const syncMessage = message;
139
+ let bytes;
102
140
  try {
103
- let bytes;
104
141
  const payload = syncMessage.message || syncMessage.data;
105
142
  if (typeof payload === "string") {
106
143
  const binaryString = atob(payload);
@@ -116,6 +153,7 @@ var WSClient = class {
116
153
  } else {
117
154
  return;
118
155
  }
156
+ console.log("[Granular DEBUG] Applying sync bytes:", bytes.length);
119
157
  const [newDoc, newSyncState] = Automerge__namespace.receiveSyncMessage(
120
158
  this.doc,
121
159
  this.syncState,
@@ -123,8 +161,49 @@ var WSClient = class {
123
161
  );
124
162
  this.doc = newDoc;
125
163
  this.syncState = newSyncState;
164
+ const docAny = this.doc;
165
+ if (docAny.catalog) {
166
+ console.log("[Granular DEBUG] Doc catalog sync applied. Keys in catalog:", Object.keys(docAny.catalog || {}));
167
+ console.log("[Granular DEBUG] RawToolCatalogs:", Object.keys(docAny.catalog.rawToolCatalogs || {}));
168
+ } else {
169
+ console.log("[Granular DEBUG] Doc synced but no catalog yet. Keys in doc:", Object.keys(docAny));
170
+ }
126
171
  this.emit("sync", this.doc);
127
172
  } catch (e) {
173
+ try {
174
+ console.log("[Granular DEBUG] receiveSyncMessage failed, trying applyChanges...");
175
+ const [newDoc] = Automerge__namespace.applyChanges(this.doc, [bytes]);
176
+ this.doc = newDoc;
177
+ this.emit("sync", this.doc);
178
+ console.log("[Granular DEBUG] applyChanges succeeded. Doc:", JSON.stringify(Automerge__namespace.toJS(this.doc)));
179
+ } catch (applyError) {
180
+ console.warn("[Granular] Failed to apply sync message (both sync & applyChanges)", e, applyError);
181
+ }
182
+ }
183
+ return;
184
+ }
185
+ if ("type" in message && message.type === "snapshot") {
186
+ const snapshotMessage = message;
187
+ try {
188
+ const bytes = new Uint8Array(snapshotMessage.data);
189
+ console.log("[Granular DEBUG] Loading snapshot bytes:", bytes.length);
190
+ this.doc = Automerge__namespace.load(bytes);
191
+ this.emit("sync", this.doc);
192
+ console.log("[Granular DEBUG] Snapshot loaded. Doc:", JSON.stringify(Automerge__namespace.toJS(this.doc)));
193
+ } catch (e) {
194
+ console.warn("[Granular] Failed to load snapshot message", e);
195
+ }
196
+ return;
197
+ }
198
+ if ("type" in message && message.type === "change") {
199
+ const changeMessage = message;
200
+ try {
201
+ const bytes = new Uint8Array(changeMessage.data);
202
+ const [newDoc] = Automerge__namespace.applyChanges(this.doc, [bytes]);
203
+ this.doc = newDoc;
204
+ this.emit("sync", this.doc);
205
+ } catch (e) {
206
+ console.warn("[Granular] Failed to apply change message", e);
128
207
  }
129
208
  return;
130
209
  }
@@ -162,7 +241,7 @@ var WSClient = class {
162
241
  * @throws {Error} If connection is closed or timeout occurs
163
242
  */
164
243
  async call(method, params) {
165
- if (!this.ws || this.ws.readyState !== WebSocket__default.default.OPEN) {
244
+ if (!this.ws || this.ws.readyState !== READY_STATE_OPEN) {
166
245
  throw new Error("WebSocket not connected");
167
246
  }
168
247
  const id = `rpc-${this.nextRpcId++}`;
@@ -291,6 +370,10 @@ var Session = class {
291
370
  /** Tracks which tools are instance methods (className set, not static) */
292
371
  instanceTools = /* @__PURE__ */ new Set();
293
372
  currentDomainRevision = null;
373
+ /** Local effect registry: name → full ToolWithHandler */
374
+ effects = /* @__PURE__ */ new Map();
375
+ /** Last known tools for diffing */
376
+ lastKnownTools = /* @__PURE__ */ new Map();
294
377
  constructor(client, clientId) {
295
378
  this.client = client;
296
379
  this.clientId = clientId || `client_${Date.now()}`;
@@ -406,6 +489,85 @@ var Session = class {
406
489
  rejected: result.rejected
407
490
  };
408
491
  }
492
+ /**
493
+ * Publish a single effect (tool) to the sandbox.
494
+ *
495
+ * Adds the effect to the local registry and re-publishes the
496
+ * full tool catalog to the server. If an effect with the same
497
+ * name already exists, it is replaced.
498
+ *
499
+ * @param effect - The effect (tool) to publish
500
+ * @returns PublishToolsResult with domainRevision
501
+ *
502
+ * @example
503
+ * ```typescript
504
+ * await env.publishEffect({
505
+ * name: 'get_bio',
506
+ * description: 'Get biography of an author',
507
+ * className: 'author',
508
+ * inputSchema: { type: 'object', properties: { detailed: { type: 'boolean' } } },
509
+ * handler: async (id, params) => ({ bio: `Bio of ${id}` }),
510
+ * });
511
+ * ```
512
+ */
513
+ async publishEffect(effect) {
514
+ this.effects.set(effect.name, effect);
515
+ return this._syncEffects();
516
+ }
517
+ /**
518
+ * Publish multiple effects (tools) at once.
519
+ *
520
+ * Adds all effects to the local registry and re-publishes the
521
+ * full tool catalog in a single RPC call.
522
+ *
523
+ * @param effects - Array of effects to publish
524
+ * @returns PublishToolsResult with domainRevision
525
+ *
526
+ * @example
527
+ * ```typescript
528
+ * await env.publishEffects([
529
+ * { name: 'get_bio', description: '...', inputSchema: {}, handler: async (id) => ({}) },
530
+ * { name: 'search', description: '...', inputSchema: {}, handler: async (params) => ({}) },
531
+ * ]);
532
+ * ```
533
+ */
534
+ async publishEffects(effects) {
535
+ for (const effect of effects) {
536
+ this.effects.set(effect.name, effect);
537
+ }
538
+ return this._syncEffects();
539
+ }
540
+ /**
541
+ * Remove an effect by name and re-publish the remaining catalog.
542
+ *
543
+ * @param name - The name of the effect to remove
544
+ * @returns PublishToolsResult with domainRevision
545
+ */
546
+ async unpublishEffect(name) {
547
+ this.effects.delete(name);
548
+ this.toolHandlers.delete(name);
549
+ this.instanceTools.delete(name);
550
+ return this._syncEffects();
551
+ }
552
+ /**
553
+ * Remove all effects and publish an empty catalog.
554
+ *
555
+ * @returns PublishToolsResult with domainRevision
556
+ */
557
+ async unpublishAllEffects() {
558
+ this.effects.clear();
559
+ this.toolHandlers.clear();
560
+ this.instanceTools.clear();
561
+ return this._syncEffects();
562
+ }
563
+ /**
564
+ * Internal: re-publish the full effect catalog to the server.
565
+ * Called after any mutation to the effects Map.
566
+ */
567
+ async _syncEffects() {
568
+ const allEffects = Array.from(this.effects.values());
569
+ return this.publishTools(allEffects);
570
+ }
409
571
  /**
410
572
  * Submit a job to execute code in the sandbox.
411
573
  *
@@ -423,9 +585,9 @@ var Session = class {
423
585
  * execute locally and return the result to the sandbox.
424
586
  */
425
587
  async submitJob(code, domainRevision) {
426
- const revision = domainRevision || this.currentDomainRevision;
588
+ const revision = domainRevision || this.currentDomainRevision || this.client.doc?.domain?.active || void 0;
427
589
  if (!revision) {
428
- throw new Error("No domain revision available. Call publishTools() first.");
590
+ throw new Error("No domain revision available. Call publishTools() or ensure schema is activated.");
429
591
  }
430
592
  const result = await this.client.call("job.submit", {
431
593
  domainRevision: revision,
@@ -456,6 +618,72 @@ var Session = class {
456
618
  async answerPrompt(promptId, answer) {
457
619
  await this.client.call("prompt.answer", { promptId, value: answer });
458
620
  }
621
+ /**
622
+ * Get the current list of available tools.
623
+ * Consolidates tools from all connected clients.
624
+ */
625
+ getTools() {
626
+ const doc = this.client.doc;
627
+ const toolMap = /* @__PURE__ */ new Map();
628
+ const domainPkg = doc.domain?.packages?.domain;
629
+ if (domainPkg?.tools && Array.isArray(domainPkg.tools)) {
630
+ for (const tool of domainPkg.tools) {
631
+ if (!tool?.name) continue;
632
+ toolMap.set(tool.name, {
633
+ name: tool.name,
634
+ description: tool.description,
635
+ inputSchema: tool.inputSchema,
636
+ outputSchema: tool.outputSchema,
637
+ className: tool.className || void 0,
638
+ static: tool.static || false,
639
+ ready: false,
640
+ publishedAt: void 0
641
+ });
642
+ }
643
+ }
644
+ const catalogs = doc.catalog?.rawToolCatalogs || {};
645
+ for (const [clientId, catalog] of Object.entries(catalogs)) {
646
+ const cat = catalog;
647
+ if (!cat.tools) continue;
648
+ for (const tool of cat.tools) {
649
+ if (!tool?.name) continue;
650
+ const existing = toolMap.get(tool.name);
651
+ if (existing?.publishedAt && cat.publishedAt && existing.publishedAt > cat.publishedAt) continue;
652
+ const isLocal = clientId === this.clientId;
653
+ const ready = isLocal ? this.effects.has(tool.name) || this.toolHandlers.has(tool.name) || this.instanceTools.has(tool.name) : true;
654
+ toolMap.set(tool.name, {
655
+ name: tool.name,
656
+ description: tool.description,
657
+ inputSchema: tool.inputSchema,
658
+ outputSchema: tool.outputSchema,
659
+ className: tool.className || existing?.className,
660
+ static: tool.static || existing?.static || false,
661
+ clientId,
662
+ ready,
663
+ publishedAt: cat.publishedAt
664
+ });
665
+ }
666
+ }
667
+ return Array.from(toolMap.values());
668
+ }
669
+ /**
670
+ * Subscribe to tool changes (added, removed, updated).
671
+ * @param callback - Function called with change events
672
+ * @returns Unsubscribe function
673
+ */
674
+ onToolsChanged(callback) {
675
+ const handler = (data) => callback(data);
676
+ if (!this.eventListeners.has("tools:changed")) {
677
+ this.eventListeners.set("tools:changed", []);
678
+ }
679
+ this.eventListeners.get("tools:changed").push(handler);
680
+ return () => {
681
+ const listeners = this.eventListeners.get("tools:changed");
682
+ if (listeners) {
683
+ this.eventListeners.set("tools:changed", listeners.filter((h) => h !== handler));
684
+ }
685
+ };
686
+ }
459
687
  /**
460
688
  * Get the current domain state and available tools
461
689
  */
@@ -664,7 +892,10 @@ import { ${allImports} } from "./sandbox-tools";
664
892
  });
665
893
  }
666
894
  setupEventHandlers() {
667
- this.client.on("sync", (doc) => this.emit("sync", doc));
895
+ this.client.on("sync", (doc) => {
896
+ this.emit("sync", doc);
897
+ this.checkForToolChanges();
898
+ });
668
899
  this.client.on("prompt", (prompt) => this.emit("prompt", prompt));
669
900
  this.client.on("disconnect", () => this.emit("disconnect", {}));
670
901
  this.client.on("job.status", (data) => {
@@ -683,6 +914,33 @@ import { ${allImports} } from "./sandbox-tools";
683
914
  handlers.forEach((h) => h(data));
684
915
  }
685
916
  }
917
+ /**
918
+ * Check for changes in the tool catalog and emit 'tools:changed' if needed
919
+ */
920
+ checkForToolChanges() {
921
+ const currentTools = this.getTools();
922
+ const currentMap = new Map(currentTools.map((t) => [t.name, t]));
923
+ const added = [];
924
+ const removed = [];
925
+ for (const [name, tool] of currentMap) {
926
+ if (!this.lastKnownTools.has(name)) {
927
+ added.push(name);
928
+ }
929
+ }
930
+ for (const name of this.lastKnownTools.keys()) {
931
+ if (!currentMap.has(name)) {
932
+ removed.push(name);
933
+ }
934
+ }
935
+ if (added.length > 0 || removed.length > 0) {
936
+ this.lastKnownTools = currentMap;
937
+ this.emit("tools:changed", {
938
+ tools: currentTools,
939
+ added,
940
+ removed
941
+ });
942
+ }
943
+ }
686
944
  };
687
945
  var JobImplementation = class {
688
946
  id;
@@ -756,6 +1014,18 @@ var JobImplementation = class {
756
1014
  this._rejectResult(jobData.error || new Error("Job failed"));
757
1015
  }
758
1016
  });
1017
+ this.client.on("tool.call.start", (data) => {
1018
+ const d = data;
1019
+ if (d.jobId === id) {
1020
+ this.emit("toolCallStart", { callId: d.callId, toolName: d.toolName, input: d.input, timestamp: d.timestamp });
1021
+ }
1022
+ });
1023
+ this.client.on("tool.call.end", (data) => {
1024
+ const d = data;
1025
+ if (d.jobId === id) {
1026
+ this.emit("toolCallEnd", { callId: d.callId, toolName: d.toolName, result: d.result, error: d.error, durationMs: d.durationMs, timestamp: d.timestamp });
1027
+ }
1028
+ });
759
1029
  }
760
1030
  get result() {
761
1031
  return this._resultPromise;
@@ -1470,11 +1740,57 @@ var Environment = class _Environment extends Session {
1470
1740
  async publishTools(tools, revision = "1.0.0") {
1471
1741
  return super.publishTools(tools, revision);
1472
1742
  }
1743
+ /**
1744
+ * Publish a single effect (tool) incrementally.
1745
+ *
1746
+ * Adds the effect to the local registry and re-publishes the
1747
+ * full tool catalog to the server. If an effect with the same
1748
+ * name already exists, it is replaced.
1749
+ *
1750
+ * @example
1751
+ * ```typescript
1752
+ * await env.publishEffect({
1753
+ * name: 'get_bio',
1754
+ * description: 'Get biography',
1755
+ * inputSchema: { type: 'object', properties: { detailed: { type: 'boolean' } } },
1756
+ * handler: async (params) => ({ bio: 'Hello' }),
1757
+ * });
1758
+ * ```
1759
+ */
1760
+ async publishEffect(effect) {
1761
+ return super.publishEffect(effect);
1762
+ }
1763
+ /**
1764
+ * Publish multiple effects (tools) at once.
1765
+ *
1766
+ * Adds all effects to the local registry and re-publishes the
1767
+ * full tool catalog in a single RPC call.
1768
+ */
1769
+ async publishEffects(effects) {
1770
+ return super.publishEffects(effects);
1771
+ }
1772
+ /**
1773
+ * Remove an effect by name and re-publish the remaining catalog.
1774
+ */
1775
+ async unpublishEffect(name) {
1776
+ return super.unpublishEffect(name);
1777
+ }
1778
+ /**
1779
+ * Remove all effects and publish an empty catalog.
1780
+ */
1781
+ async unpublishAllEffects() {
1782
+ return super.unpublishAllEffects();
1783
+ }
1473
1784
  };
1474
1785
  var Granular = class {
1475
1786
  apiKey;
1476
1787
  apiUrl;
1477
1788
  httpUrl;
1789
+ WebSocketCtor;
1790
+ /** Sandbox-level effect registry: sandboxId → (toolName → ToolWithHandler) */
1791
+ sandboxEffects = /* @__PURE__ */ new Map();
1792
+ /** Active environments tracker: sandboxId → Environment[] */
1793
+ activeEnvironments = /* @__PURE__ */ new Map();
1478
1794
  /**
1479
1795
  * Create a new Granular client
1480
1796
  * @param options - Client configuration
@@ -1482,6 +1798,7 @@ var Granular = class {
1482
1798
  constructor(options) {
1483
1799
  this.apiKey = options.apiKey;
1484
1800
  this.apiUrl = options.apiUrl || "wss://api.granular.dev/v2/ws";
1801
+ this.WebSocketCtor = options.WebSocketCtor;
1485
1802
  this.httpUrl = this.apiUrl.replace("wss://", "https://").replace("/ws", "");
1486
1803
  }
1487
1804
  /**
@@ -1552,7 +1869,7 @@ var Granular = class {
1552
1869
  * ```
1553
1870
  */
1554
1871
  async connect(options) {
1555
- const clientId = `client_${Date.now()}`;
1872
+ const clientId = options.clientId || `client_${Date.now()}`;
1556
1873
  const sandbox = await this.findOrCreateSandbox(options.sandbox);
1557
1874
  for (const profileName of options.user.permissions) {
1558
1875
  const profileId = await this.ensurePermissionProfile(sandbox.sandboxId, profileName);
@@ -1565,14 +1882,104 @@ var Granular = class {
1565
1882
  const client = new WSClient({
1566
1883
  url: this.apiUrl,
1567
1884
  sessionId: envData.environmentId,
1568
- token: this.apiKey
1885
+ token: this.apiKey,
1886
+ WebSocketCtor: this.WebSocketCtor
1569
1887
  });
1570
1888
  await client.connect();
1571
1889
  const graphqlEndpoint = `${this.httpUrl}/orchestrator/graphql`;
1572
1890
  const environment = new Environment(client, envData, clientId, this.apiKey, graphqlEndpoint);
1891
+ if (!this.activeEnvironments.has(sandbox.sandboxId)) {
1892
+ this.activeEnvironments.set(sandbox.sandboxId, []);
1893
+ }
1894
+ this.activeEnvironments.get(sandbox.sandboxId).push(environment);
1895
+ environment.on("disconnect", () => {
1896
+ const list = this.activeEnvironments.get(sandbox.sandboxId);
1897
+ if (list) {
1898
+ this.activeEnvironments.set(sandbox.sandboxId, list.filter((e) => e !== environment));
1899
+ }
1900
+ });
1573
1901
  await environment.hello();
1902
+ const effects = this.sandboxEffects.get(sandbox.sandboxId);
1903
+ if (effects && effects.size > 0) {
1904
+ const effectsList = Array.from(effects.values());
1905
+ console.log(`[Granular] Auto-publishing ${effectsList.length} effects for sandbox ${sandbox.sandboxId}`);
1906
+ await environment.publishEffects(effectsList);
1907
+ }
1574
1908
  return environment;
1575
1909
  }
1910
+ // ── Sandbox-Level Effects ──
1911
+ /**
1912
+ * Register an effect (tool) for a specific sandbox.
1913
+ *
1914
+ * The effect will be automatically published to:
1915
+ * 1. Any currently active environments for this sandbox
1916
+ * 2. Any new environments created/connected for this sandbox
1917
+ *
1918
+ * @param sandboxNameOrId - The name or ID of the sandbox
1919
+ * @param effect - The tool definition and handler
1920
+ */
1921
+ async registerEffect(sandboxNameOrId, effect) {
1922
+ const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
1923
+ const sandboxId = sandbox.sandboxId;
1924
+ if (!this.sandboxEffects.has(sandboxId)) {
1925
+ this.sandboxEffects.set(sandboxId, /* @__PURE__ */ new Map());
1926
+ }
1927
+ this.sandboxEffects.get(sandboxId).set(effect.name, effect);
1928
+ const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
1929
+ for (const env of activeEnvs) {
1930
+ await env.publishEffect(effect);
1931
+ }
1932
+ }
1933
+ /**
1934
+ * Register multiple effects (tools) for a specific sandbox.
1935
+ *
1936
+ * batch version of `registerEffect`.
1937
+ */
1938
+ async registerEffects(sandboxNameOrId, effects) {
1939
+ const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
1940
+ const sandboxId = sandbox.sandboxId;
1941
+ if (!this.sandboxEffects.has(sandboxId)) {
1942
+ this.sandboxEffects.set(sandboxId, /* @__PURE__ */ new Map());
1943
+ }
1944
+ const map = this.sandboxEffects.get(sandboxId);
1945
+ for (const effect of effects) {
1946
+ map.set(effect.name, effect);
1947
+ }
1948
+ const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
1949
+ for (const env of activeEnvs) {
1950
+ await env.publishEffects(effects);
1951
+ }
1952
+ }
1953
+ /**
1954
+ * Unregister an effect from a sandbox.
1955
+ *
1956
+ * Removes it from the local registry and unpublishes it from
1957
+ * all active environments.
1958
+ */
1959
+ async unregisterEffect(sandboxNameOrId, name) {
1960
+ const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
1961
+ const sandboxId = sandbox.sandboxId;
1962
+ const map = this.sandboxEffects.get(sandboxId);
1963
+ if (map) {
1964
+ map.delete(name);
1965
+ }
1966
+ const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
1967
+ for (const env of activeEnvs) {
1968
+ await env.unpublishEffect(name);
1969
+ }
1970
+ }
1971
+ /**
1972
+ * Unregister all effects for a sandbox.
1973
+ */
1974
+ async unregisterAllEffects(sandboxNameOrId) {
1975
+ const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
1976
+ const sandboxId = sandbox.sandboxId;
1977
+ this.sandboxEffects.delete(sandboxId);
1978
+ const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
1979
+ for (const env of activeEnvs) {
1980
+ await env.unpublishAllEffects();
1981
+ }
1982
+ }
1576
1983
  /**
1577
1984
  * Find a sandbox by name or create it if it doesn't exist
1578
1985
  */