@granular-software/sdk 0.2.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.mjs CHANGED
@@ -1,7 +1,11 @@
1
- import WebSocket from 'ws';
2
1
  import * as Automerge from '@automerge/automerge';
3
2
 
4
3
  // src/ws-client.ts
4
+ var GlobalWebSocket = void 0;
5
+ if (typeof globalThis !== "undefined" && globalThis.WebSocket) {
6
+ GlobalWebSocket = globalThis.WebSocket;
7
+ }
8
+ var READY_STATE_OPEN = 1;
5
9
  var WSClient = class {
6
10
  ws = null;
7
11
  url;
@@ -16,7 +20,9 @@ var WSClient = class {
16
20
  syncState = Automerge.initSyncState();
17
21
  reconnectTimer = null;
18
22
  isExplicitlyDisconnected = false;
23
+ options;
19
24
  constructor(options) {
25
+ this.options = options;
20
26
  this.url = options.url;
21
27
  this.sessionId = options.sessionId;
22
28
  this.token = options.token;
@@ -27,34 +33,68 @@ var WSClient = class {
27
33
  */
28
34
  async connect() {
29
35
  this.isExplicitlyDisconnected = false;
36
+ const WebSocketClass = this.options.WebSocketCtor || GlobalWebSocket;
37
+ if (!WebSocketClass) {
38
+ throw new Error('No WebSocket implementation found. If using Node.js, please install "ws" and pass the constructor to the SDK options: { WebSocketCtor: WebSocket }.');
39
+ }
30
40
  return new Promise((resolve, reject) => {
31
41
  try {
32
42
  const wsUrl = new URL(this.url);
33
43
  wsUrl.searchParams.set("sessionId", this.sessionId);
34
44
  wsUrl.searchParams.set("token", this.token);
35
- this.ws = new WebSocket(wsUrl.toString());
36
- this.ws.on("open", () => {
37
- this.emit("open", {});
38
- resolve();
39
- });
40
- this.ws.on("message", (data) => {
41
- try {
42
- const message = JSON.parse(data.toString());
43
- this.handleMessage(message);
44
- } catch (error) {
45
- console.error("[Granular] Failed to parse message:", error);
46
- }
47
- });
48
- this.ws.on("error", (error) => {
49
- this.emit("error", error);
50
- if (this.ws?.readyState !== WebSocket.OPEN) {
51
- reject(error);
52
- }
53
- });
54
- this.ws.on("close", () => {
55
- this.emit("close", {});
56
- this.handleDisconnect();
57
- });
45
+ this.ws = new WebSocketClass(wsUrl.toString());
46
+ if (!this.ws) throw new Error("Failed to create WebSocket");
47
+ const socket = this.ws;
48
+ if (typeof socket.on === "function") {
49
+ socket.on("open", () => {
50
+ this.emit("open", {});
51
+ resolve();
52
+ });
53
+ socket.on("message", (data) => {
54
+ try {
55
+ const message = JSON.parse(data.toString());
56
+ this.handleMessage(message);
57
+ } catch (error) {
58
+ console.error("[Granular] Failed to parse message:", error);
59
+ }
60
+ });
61
+ socket.on("error", (error) => {
62
+ this.emit("error", error);
63
+ if (socket.readyState !== READY_STATE_OPEN) {
64
+ reject(error);
65
+ }
66
+ });
67
+ socket.on("close", () => {
68
+ this.emit("close", {});
69
+ this.handleDisconnect();
70
+ });
71
+ } else {
72
+ this.ws.onopen = () => {
73
+ this.emit("open", {});
74
+ resolve();
75
+ };
76
+ this.ws.onmessage = (event) => {
77
+ try {
78
+ const data = event.data;
79
+ const message = JSON.parse(data.toString());
80
+ this.handleMessage(message);
81
+ } catch (error) {
82
+ console.error("[Granular] Failed to parse message:", error);
83
+ }
84
+ };
85
+ this.ws.onerror = (event) => {
86
+ const error = new Error("WebSocket error");
87
+ error.event = event;
88
+ this.emit("error", error);
89
+ if (this.ws?.readyState !== READY_STATE_OPEN) {
90
+ reject(error);
91
+ }
92
+ };
93
+ this.ws.onclose = () => {
94
+ this.emit("close", {});
95
+ this.handleDisconnect();
96
+ };
97
+ }
58
98
  } catch (error) {
59
99
  reject(error);
60
100
  }
@@ -74,8 +114,8 @@ var WSClient = class {
74
114
  console.log("[Granular DEBUG] Received message:", JSON.stringify(message).slice(0, 500));
75
115
  if ("type" in message && message.type === "sync") {
76
116
  const syncMessage = message;
117
+ let bytes;
77
118
  try {
78
- let bytes;
79
119
  const payload = syncMessage.message || syncMessage.data;
80
120
  if (typeof payload === "string") {
81
121
  const binaryString = atob(payload);
@@ -91,6 +131,7 @@ var WSClient = class {
91
131
  } else {
92
132
  return;
93
133
  }
134
+ console.log("[Granular DEBUG] Applying sync bytes:", bytes.length);
94
135
  const [newDoc, newSyncState] = Automerge.receiveSyncMessage(
95
136
  this.doc,
96
137
  this.syncState,
@@ -98,8 +139,49 @@ var WSClient = class {
98
139
  );
99
140
  this.doc = newDoc;
100
141
  this.syncState = newSyncState;
142
+ const docAny = this.doc;
143
+ if (docAny.catalog) {
144
+ console.log("[Granular DEBUG] Doc catalog sync applied. Keys in catalog:", Object.keys(docAny.catalog || {}));
145
+ console.log("[Granular DEBUG] RawToolCatalogs:", Object.keys(docAny.catalog.rawToolCatalogs || {}));
146
+ } else {
147
+ console.log("[Granular DEBUG] Doc synced but no catalog yet. Keys in doc:", Object.keys(docAny));
148
+ }
149
+ this.emit("sync", this.doc);
150
+ } catch (e) {
151
+ try {
152
+ console.log("[Granular DEBUG] receiveSyncMessage failed, trying applyChanges...");
153
+ const [newDoc] = Automerge.applyChanges(this.doc, [bytes]);
154
+ this.doc = newDoc;
155
+ this.emit("sync", this.doc);
156
+ console.log("[Granular DEBUG] applyChanges succeeded. Doc:", JSON.stringify(Automerge.toJS(this.doc)));
157
+ } catch (applyError) {
158
+ console.warn("[Granular] Failed to apply sync message (both sync & applyChanges)", e, applyError);
159
+ }
160
+ }
161
+ return;
162
+ }
163
+ if ("type" in message && message.type === "snapshot") {
164
+ const snapshotMessage = message;
165
+ try {
166
+ const bytes = new Uint8Array(snapshotMessage.data);
167
+ console.log("[Granular DEBUG] Loading snapshot bytes:", bytes.length);
168
+ this.doc = Automerge.load(bytes);
169
+ this.emit("sync", this.doc);
170
+ console.log("[Granular DEBUG] Snapshot loaded. Doc:", JSON.stringify(Automerge.toJS(this.doc)));
171
+ } catch (e) {
172
+ console.warn("[Granular] Failed to load snapshot message", e);
173
+ }
174
+ return;
175
+ }
176
+ if ("type" in message && message.type === "change") {
177
+ const changeMessage = message;
178
+ try {
179
+ const bytes = new Uint8Array(changeMessage.data);
180
+ const [newDoc] = Automerge.applyChanges(this.doc, [bytes]);
181
+ this.doc = newDoc;
101
182
  this.emit("sync", this.doc);
102
183
  } catch (e) {
184
+ console.warn("[Granular] Failed to apply change message", e);
103
185
  }
104
186
  return;
105
187
  }
@@ -137,7 +219,7 @@ var WSClient = class {
137
219
  * @throws {Error} If connection is closed or timeout occurs
138
220
  */
139
221
  async call(method, params) {
140
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
222
+ if (!this.ws || this.ws.readyState !== READY_STATE_OPEN) {
141
223
  throw new Error("WebSocket not connected");
142
224
  }
143
225
  const id = `rpc-${this.nextRpcId++}`;
@@ -266,6 +348,10 @@ var Session = class {
266
348
  /** Tracks which tools are instance methods (className set, not static) */
267
349
  instanceTools = /* @__PURE__ */ new Set();
268
350
  currentDomainRevision = null;
351
+ /** Local effect registry: name → full ToolWithHandler */
352
+ effects = /* @__PURE__ */ new Map();
353
+ /** Last known tools for diffing */
354
+ lastKnownTools = /* @__PURE__ */ new Map();
269
355
  constructor(client, clientId) {
270
356
  this.client = client;
271
357
  this.clientId = clientId || `client_${Date.now()}`;
@@ -381,6 +467,85 @@ var Session = class {
381
467
  rejected: result.rejected
382
468
  };
383
469
  }
470
+ /**
471
+ * Publish a single effect (tool) to the sandbox.
472
+ *
473
+ * Adds the effect to the local registry and re-publishes the
474
+ * full tool catalog to the server. If an effect with the same
475
+ * name already exists, it is replaced.
476
+ *
477
+ * @param effect - The effect (tool) to publish
478
+ * @returns PublishToolsResult with domainRevision
479
+ *
480
+ * @example
481
+ * ```typescript
482
+ * await env.publishEffect({
483
+ * name: 'get_bio',
484
+ * description: 'Get biography of an author',
485
+ * className: 'author',
486
+ * inputSchema: { type: 'object', properties: { detailed: { type: 'boolean' } } },
487
+ * handler: async (id, params) => ({ bio: `Bio of ${id}` }),
488
+ * });
489
+ * ```
490
+ */
491
+ async publishEffect(effect) {
492
+ this.effects.set(effect.name, effect);
493
+ return this._syncEffects();
494
+ }
495
+ /**
496
+ * Publish multiple effects (tools) at once.
497
+ *
498
+ * Adds all effects to the local registry and re-publishes the
499
+ * full tool catalog in a single RPC call.
500
+ *
501
+ * @param effects - Array of effects to publish
502
+ * @returns PublishToolsResult with domainRevision
503
+ *
504
+ * @example
505
+ * ```typescript
506
+ * await env.publishEffects([
507
+ * { name: 'get_bio', description: '...', inputSchema: {}, handler: async (id) => ({}) },
508
+ * { name: 'search', description: '...', inputSchema: {}, handler: async (params) => ({}) },
509
+ * ]);
510
+ * ```
511
+ */
512
+ async publishEffects(effects) {
513
+ for (const effect of effects) {
514
+ this.effects.set(effect.name, effect);
515
+ }
516
+ return this._syncEffects();
517
+ }
518
+ /**
519
+ * Remove an effect by name and re-publish the remaining catalog.
520
+ *
521
+ * @param name - The name of the effect to remove
522
+ * @returns PublishToolsResult with domainRevision
523
+ */
524
+ async unpublishEffect(name) {
525
+ this.effects.delete(name);
526
+ this.toolHandlers.delete(name);
527
+ this.instanceTools.delete(name);
528
+ return this._syncEffects();
529
+ }
530
+ /**
531
+ * Remove all effects and publish an empty catalog.
532
+ *
533
+ * @returns PublishToolsResult with domainRevision
534
+ */
535
+ async unpublishAllEffects() {
536
+ this.effects.clear();
537
+ this.toolHandlers.clear();
538
+ this.instanceTools.clear();
539
+ return this._syncEffects();
540
+ }
541
+ /**
542
+ * Internal: re-publish the full effect catalog to the server.
543
+ * Called after any mutation to the effects Map.
544
+ */
545
+ async _syncEffects() {
546
+ const allEffects = Array.from(this.effects.values());
547
+ return this.publishTools(allEffects);
548
+ }
384
549
  /**
385
550
  * Submit a job to execute code in the sandbox.
386
551
  *
@@ -398,9 +563,9 @@ var Session = class {
398
563
  * execute locally and return the result to the sandbox.
399
564
  */
400
565
  async submitJob(code, domainRevision) {
401
- const revision = domainRevision || this.currentDomainRevision;
566
+ const revision = domainRevision || this.currentDomainRevision || this.client.doc?.domain?.active || void 0;
402
567
  if (!revision) {
403
- throw new Error("No domain revision available. Call publishTools() first.");
568
+ throw new Error("No domain revision available. Call publishTools() or ensure schema is activated.");
404
569
  }
405
570
  const result = await this.client.call("job.submit", {
406
571
  domainRevision: revision,
@@ -431,6 +596,72 @@ var Session = class {
431
596
  async answerPrompt(promptId, answer) {
432
597
  await this.client.call("prompt.answer", { promptId, value: answer });
433
598
  }
599
+ /**
600
+ * Get the current list of available tools.
601
+ * Consolidates tools from all connected clients.
602
+ */
603
+ getTools() {
604
+ const doc = this.client.doc;
605
+ const toolMap = /* @__PURE__ */ new Map();
606
+ const domainPkg = doc.domain?.packages?.domain;
607
+ if (domainPkg?.tools && Array.isArray(domainPkg.tools)) {
608
+ for (const tool of domainPkg.tools) {
609
+ if (!tool?.name) continue;
610
+ toolMap.set(tool.name, {
611
+ name: tool.name,
612
+ description: tool.description,
613
+ inputSchema: tool.inputSchema,
614
+ outputSchema: tool.outputSchema,
615
+ className: tool.className || void 0,
616
+ static: tool.static || false,
617
+ ready: false,
618
+ publishedAt: void 0
619
+ });
620
+ }
621
+ }
622
+ const catalogs = doc.catalog?.rawToolCatalogs || {};
623
+ for (const [clientId, catalog] of Object.entries(catalogs)) {
624
+ const cat = catalog;
625
+ if (!cat.tools) continue;
626
+ for (const tool of cat.tools) {
627
+ if (!tool?.name) continue;
628
+ const existing = toolMap.get(tool.name);
629
+ if (existing?.publishedAt && cat.publishedAt && existing.publishedAt > cat.publishedAt) continue;
630
+ const isLocal = clientId === this.clientId;
631
+ const ready = isLocal ? this.effects.has(tool.name) || this.toolHandlers.has(tool.name) || this.instanceTools.has(tool.name) : true;
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 || existing?.className,
638
+ static: tool.static || existing?.static || false,
639
+ clientId,
640
+ ready,
641
+ publishedAt: cat.publishedAt
642
+ });
643
+ }
644
+ }
645
+ return Array.from(toolMap.values());
646
+ }
647
+ /**
648
+ * Subscribe to tool changes (added, removed, updated).
649
+ * @param callback - Function called with change events
650
+ * @returns Unsubscribe function
651
+ */
652
+ onToolsChanged(callback) {
653
+ const handler = (data) => callback(data);
654
+ if (!this.eventListeners.has("tools:changed")) {
655
+ this.eventListeners.set("tools:changed", []);
656
+ }
657
+ this.eventListeners.get("tools:changed").push(handler);
658
+ return () => {
659
+ const listeners = this.eventListeners.get("tools:changed");
660
+ if (listeners) {
661
+ this.eventListeners.set("tools:changed", listeners.filter((h) => h !== handler));
662
+ }
663
+ };
664
+ }
434
665
  /**
435
666
  * Get the current domain state and available tools
436
667
  */
@@ -639,7 +870,10 @@ import { ${allImports} } from "./sandbox-tools";
639
870
  });
640
871
  }
641
872
  setupEventHandlers() {
642
- this.client.on("sync", (doc) => this.emit("sync", doc));
873
+ this.client.on("sync", (doc) => {
874
+ this.emit("sync", doc);
875
+ this.checkForToolChanges();
876
+ });
643
877
  this.client.on("prompt", (prompt) => this.emit("prompt", prompt));
644
878
  this.client.on("disconnect", () => this.emit("disconnect", {}));
645
879
  this.client.on("job.status", (data) => {
@@ -658,6 +892,33 @@ import { ${allImports} } from "./sandbox-tools";
658
892
  handlers.forEach((h) => h(data));
659
893
  }
660
894
  }
895
+ /**
896
+ * Check for changes in the tool catalog and emit 'tools:changed' if needed
897
+ */
898
+ checkForToolChanges() {
899
+ const currentTools = this.getTools();
900
+ const currentMap = new Map(currentTools.map((t) => [t.name, t]));
901
+ const added = [];
902
+ const removed = [];
903
+ for (const [name, tool] of currentMap) {
904
+ if (!this.lastKnownTools.has(name)) {
905
+ added.push(name);
906
+ }
907
+ }
908
+ for (const name of this.lastKnownTools.keys()) {
909
+ if (!currentMap.has(name)) {
910
+ removed.push(name);
911
+ }
912
+ }
913
+ if (added.length > 0 || removed.length > 0) {
914
+ this.lastKnownTools = currentMap;
915
+ this.emit("tools:changed", {
916
+ tools: currentTools,
917
+ added,
918
+ removed
919
+ });
920
+ }
921
+ }
661
922
  };
662
923
  var JobImplementation = class {
663
924
  id;
@@ -731,6 +992,18 @@ var JobImplementation = class {
731
992
  this._rejectResult(jobData.error || new Error("Job failed"));
732
993
  }
733
994
  });
995
+ this.client.on("tool.call.start", (data) => {
996
+ const d = data;
997
+ if (d.jobId === id) {
998
+ this.emit("toolCallStart", { callId: d.callId, toolName: d.toolName, input: d.input, timestamp: d.timestamp });
999
+ }
1000
+ });
1001
+ this.client.on("tool.call.end", (data) => {
1002
+ const d = data;
1003
+ if (d.jobId === id) {
1004
+ this.emit("toolCallEnd", { callId: d.callId, toolName: d.toolName, result: d.result, error: d.error, durationMs: d.durationMs, timestamp: d.timestamp });
1005
+ }
1006
+ });
734
1007
  }
735
1008
  get result() {
736
1009
  return this._resultPromise;
@@ -1445,11 +1718,57 @@ var Environment = class _Environment extends Session {
1445
1718
  async publishTools(tools, revision = "1.0.0") {
1446
1719
  return super.publishTools(tools, revision);
1447
1720
  }
1721
+ /**
1722
+ * Publish a single effect (tool) incrementally.
1723
+ *
1724
+ * Adds the effect to the local registry and re-publishes the
1725
+ * full tool catalog to the server. If an effect with the same
1726
+ * name already exists, it is replaced.
1727
+ *
1728
+ * @example
1729
+ * ```typescript
1730
+ * await env.publishEffect({
1731
+ * name: 'get_bio',
1732
+ * description: 'Get biography',
1733
+ * inputSchema: { type: 'object', properties: { detailed: { type: 'boolean' } } },
1734
+ * handler: async (params) => ({ bio: 'Hello' }),
1735
+ * });
1736
+ * ```
1737
+ */
1738
+ async publishEffect(effect) {
1739
+ return super.publishEffect(effect);
1740
+ }
1741
+ /**
1742
+ * Publish multiple effects (tools) at once.
1743
+ *
1744
+ * Adds all effects to the local registry and re-publishes the
1745
+ * full tool catalog in a single RPC call.
1746
+ */
1747
+ async publishEffects(effects) {
1748
+ return super.publishEffects(effects);
1749
+ }
1750
+ /**
1751
+ * Remove an effect by name and re-publish the remaining catalog.
1752
+ */
1753
+ async unpublishEffect(name) {
1754
+ return super.unpublishEffect(name);
1755
+ }
1756
+ /**
1757
+ * Remove all effects and publish an empty catalog.
1758
+ */
1759
+ async unpublishAllEffects() {
1760
+ return super.unpublishAllEffects();
1761
+ }
1448
1762
  };
1449
1763
  var Granular = class {
1450
1764
  apiKey;
1451
1765
  apiUrl;
1452
1766
  httpUrl;
1767
+ WebSocketCtor;
1768
+ /** Sandbox-level effect registry: sandboxId → (toolName → ToolWithHandler) */
1769
+ sandboxEffects = /* @__PURE__ */ new Map();
1770
+ /** Active environments tracker: sandboxId → Environment[] */
1771
+ activeEnvironments = /* @__PURE__ */ new Map();
1453
1772
  /**
1454
1773
  * Create a new Granular client
1455
1774
  * @param options - Client configuration
@@ -1457,6 +1776,7 @@ var Granular = class {
1457
1776
  constructor(options) {
1458
1777
  this.apiKey = options.apiKey;
1459
1778
  this.apiUrl = options.apiUrl || "wss://api.granular.dev/v2/ws";
1779
+ this.WebSocketCtor = options.WebSocketCtor;
1460
1780
  this.httpUrl = this.apiUrl.replace("wss://", "https://").replace("/ws", "");
1461
1781
  }
1462
1782
  /**
@@ -1527,7 +1847,7 @@ var Granular = class {
1527
1847
  * ```
1528
1848
  */
1529
1849
  async connect(options) {
1530
- const clientId = `client_${Date.now()}`;
1850
+ const clientId = options.clientId || `client_${Date.now()}`;
1531
1851
  const sandbox = await this.findOrCreateSandbox(options.sandbox);
1532
1852
  for (const profileName of options.user.permissions) {
1533
1853
  const profileId = await this.ensurePermissionProfile(sandbox.sandboxId, profileName);
@@ -1540,14 +1860,104 @@ var Granular = class {
1540
1860
  const client = new WSClient({
1541
1861
  url: this.apiUrl,
1542
1862
  sessionId: envData.environmentId,
1543
- token: this.apiKey
1863
+ token: this.apiKey,
1864
+ WebSocketCtor: this.WebSocketCtor
1544
1865
  });
1545
1866
  await client.connect();
1546
1867
  const graphqlEndpoint = `${this.httpUrl}/orchestrator/graphql`;
1547
1868
  const environment = new Environment(client, envData, clientId, this.apiKey, graphqlEndpoint);
1869
+ if (!this.activeEnvironments.has(sandbox.sandboxId)) {
1870
+ this.activeEnvironments.set(sandbox.sandboxId, []);
1871
+ }
1872
+ this.activeEnvironments.get(sandbox.sandboxId).push(environment);
1873
+ environment.on("disconnect", () => {
1874
+ const list = this.activeEnvironments.get(sandbox.sandboxId);
1875
+ if (list) {
1876
+ this.activeEnvironments.set(sandbox.sandboxId, list.filter((e) => e !== environment));
1877
+ }
1878
+ });
1548
1879
  await environment.hello();
1880
+ const effects = this.sandboxEffects.get(sandbox.sandboxId);
1881
+ if (effects && effects.size > 0) {
1882
+ const effectsList = Array.from(effects.values());
1883
+ console.log(`[Granular] Auto-publishing ${effectsList.length} effects for sandbox ${sandbox.sandboxId}`);
1884
+ await environment.publishEffects(effectsList);
1885
+ }
1549
1886
  return environment;
1550
1887
  }
1888
+ // ── Sandbox-Level Effects ──
1889
+ /**
1890
+ * Register an effect (tool) for a specific sandbox.
1891
+ *
1892
+ * The effect will be automatically published to:
1893
+ * 1. Any currently active environments for this sandbox
1894
+ * 2. Any new environments created/connected for this sandbox
1895
+ *
1896
+ * @param sandboxNameOrId - The name or ID of the sandbox
1897
+ * @param effect - The tool definition and handler
1898
+ */
1899
+ async registerEffect(sandboxNameOrId, effect) {
1900
+ const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
1901
+ const sandboxId = sandbox.sandboxId;
1902
+ if (!this.sandboxEffects.has(sandboxId)) {
1903
+ this.sandboxEffects.set(sandboxId, /* @__PURE__ */ new Map());
1904
+ }
1905
+ this.sandboxEffects.get(sandboxId).set(effect.name, effect);
1906
+ const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
1907
+ for (const env of activeEnvs) {
1908
+ await env.publishEffect(effect);
1909
+ }
1910
+ }
1911
+ /**
1912
+ * Register multiple effects (tools) for a specific sandbox.
1913
+ *
1914
+ * batch version of `registerEffect`.
1915
+ */
1916
+ async registerEffects(sandboxNameOrId, effects) {
1917
+ const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
1918
+ const sandboxId = sandbox.sandboxId;
1919
+ if (!this.sandboxEffects.has(sandboxId)) {
1920
+ this.sandboxEffects.set(sandboxId, /* @__PURE__ */ new Map());
1921
+ }
1922
+ const map = this.sandboxEffects.get(sandboxId);
1923
+ for (const effect of effects) {
1924
+ map.set(effect.name, effect);
1925
+ }
1926
+ const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
1927
+ for (const env of activeEnvs) {
1928
+ await env.publishEffects(effects);
1929
+ }
1930
+ }
1931
+ /**
1932
+ * Unregister an effect from a sandbox.
1933
+ *
1934
+ * Removes it from the local registry and unpublishes it from
1935
+ * all active environments.
1936
+ */
1937
+ async unregisterEffect(sandboxNameOrId, name) {
1938
+ const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
1939
+ const sandboxId = sandbox.sandboxId;
1940
+ const map = this.sandboxEffects.get(sandboxId);
1941
+ if (map) {
1942
+ map.delete(name);
1943
+ }
1944
+ const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
1945
+ for (const env of activeEnvs) {
1946
+ await env.unpublishEffect(name);
1947
+ }
1948
+ }
1949
+ /**
1950
+ * Unregister all effects for a sandbox.
1951
+ */
1952
+ async unregisterAllEffects(sandboxNameOrId) {
1953
+ const sandbox = await this.findOrCreateSandbox(sandboxNameOrId);
1954
+ const sandboxId = sandbox.sandboxId;
1955
+ this.sandboxEffects.delete(sandboxId);
1956
+ const activeEnvs = this.activeEnvironments.get(sandboxId) || [];
1957
+ for (const env of activeEnvs) {
1958
+ await env.unpublishAllEffects();
1959
+ }
1960
+ }
1551
1961
  /**
1552
1962
  * Find a sandbox by name or create it if it doesn't exist
1553
1963
  */