wave-agent-sdk 1.2.0 → 1.3.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 (175) hide show
  1. package/dist/agent.d.ts +58 -4
  2. package/dist/agent.js +91 -19
  3. package/dist/builtin/index.js +2 -0
  4. package/dist/builtin/skills/settings.js +1 -12
  5. package/dist/builtin/skills/wave-daemon.d.ts +1 -0
  6. package/dist/builtin/skills/wave-daemon.js +194 -0
  7. package/dist/constants/images.d.ts +26 -0
  8. package/dist/constants/images.js +26 -0
  9. package/dist/constants/index.d.ts +16 -0
  10. package/dist/constants/index.js +16 -0
  11. package/dist/constants/memory.d.ts +26 -0
  12. package/dist/constants/memory.js +34 -0
  13. package/dist/constants/messages.d.ts +11 -0
  14. package/dist/constants/messages.js +11 -0
  15. package/dist/constants/plugins.d.ts +8 -0
  16. package/dist/constants/plugins.js +8 -0
  17. package/dist/constants/tools.d.ts +1 -0
  18. package/dist/constants/tools.js +1 -0
  19. package/dist/core/plugin.d.ts +53 -13
  20. package/dist/core/plugin.js +134 -26
  21. package/dist/core/session.d.ts +1 -1
  22. package/dist/core/session.js +1 -1
  23. package/dist/exec/catalog.d.ts +140 -0
  24. package/dist/exec/catalog.js +470 -0
  25. package/dist/exec/catalogAnnouncement.d.ts +89 -0
  26. package/dist/exec/catalogAnnouncement.js +293 -0
  27. package/dist/exec/constants.d.ts +51 -0
  28. package/dist/exec/constants.js +51 -0
  29. package/dist/exec/execRuntime.d.ts +55 -0
  30. package/dist/exec/execRuntime.js +217 -0
  31. package/dist/exec/workerSource.d.ts +28 -0
  32. package/dist/exec/workerSource.js +299 -0
  33. package/dist/host/index.d.ts +23 -0
  34. package/dist/host/index.js +23 -0
  35. package/dist/index.d.ts +5 -0
  36. package/dist/index.js +6 -0
  37. package/dist/managers/MemoryRuleManager.d.ts +6 -0
  38. package/dist/managers/MemoryRuleManager.js +12 -0
  39. package/dist/managers/aiManager.d.ts +35 -1
  40. package/dist/managers/aiManager.js +190 -21
  41. package/dist/managers/backgroundTaskManager.js +14 -0
  42. package/dist/managers/hookManager.d.ts +13 -0
  43. package/dist/managers/hookManager.js +31 -4
  44. package/dist/managers/liveConfigManager.d.ts +33 -0
  45. package/dist/managers/liveConfigManager.js +103 -8
  46. package/dist/managers/lspManager.d.ts +9 -0
  47. package/dist/managers/lspManager.js +47 -18
  48. package/dist/managers/mcpManager.d.ts +45 -10
  49. package/dist/managers/mcpManager.js +103 -1
  50. package/dist/managers/messageManager.d.ts +48 -5
  51. package/dist/managers/messageManager.js +107 -21
  52. package/dist/managers/permissionManager.d.ts +40 -0
  53. package/dist/managers/permissionManager.js +63 -8
  54. package/dist/managers/pluginManager.d.ts +46 -2
  55. package/dist/managers/pluginManager.js +117 -11
  56. package/dist/managers/pluginScopeManager.d.ts +15 -2
  57. package/dist/managers/pluginScopeManager.js +20 -1
  58. package/dist/managers/skillManager.d.ts +19 -0
  59. package/dist/managers/skillManager.js +44 -0
  60. package/dist/managers/slashCommandManager.d.ts +10 -0
  61. package/dist/managers/slashCommandManager.js +35 -3
  62. package/dist/managers/subagentManager.d.ts +8 -0
  63. package/dist/managers/subagentManager.js +20 -0
  64. package/dist/managers/toolManager.d.ts +29 -3
  65. package/dist/managers/toolManager.js +87 -13
  66. package/dist/prompts/autoMemory.d.ts +9 -0
  67. package/dist/prompts/autoMemory.js +30 -31
  68. package/dist/prompts/autoMemoryExtraction.d.ts +4 -0
  69. package/dist/prompts/autoMemoryExtraction.js +8 -111
  70. package/dist/prompts/memoryTypes.d.ts +63 -0
  71. package/dist/prompts/memoryTypes.js +191 -0
  72. package/dist/services/GitService.d.ts +7 -0
  73. package/dist/services/GitService.js +23 -0
  74. package/dist/services/MarketplaceService.d.ts +101 -17
  75. package/dist/services/MarketplaceService.js +318 -119
  76. package/dist/services/artifactContent.d.ts +84 -0
  77. package/dist/services/artifactContent.js +204 -0
  78. package/dist/services/artifactSession.d.ts +6 -0
  79. package/dist/services/artifactSession.js +17 -0
  80. package/dist/services/autoMemoryService.js +5 -13
  81. package/dist/services/configurationService.d.ts +60 -9
  82. package/dist/services/configurationService.js +129 -54
  83. package/dist/services/contentSummarizer.d.ts +15 -0
  84. package/dist/services/contentSummarizer.js +45 -0
  85. package/dist/services/execAvailability.d.ts +9 -0
  86. package/dist/services/execAvailability.js +32 -0
  87. package/dist/services/fileWatcher.js +61 -6
  88. package/dist/services/initializationService.js +19 -15
  89. package/dist/services/interactionService.d.ts +9 -1
  90. package/dist/services/interactionService.js +28 -8
  91. package/dist/services/jsonlHandler.d.ts +84 -0
  92. package/dist/services/jsonlHandler.js +209 -14
  93. package/dist/services/memory.d.ts +3 -1
  94. package/dist/services/memory.js +13 -9
  95. package/dist/services/officialMarketplaceMirror.js +3 -2
  96. package/dist/services/pluginLoader.d.ts +12 -4
  97. package/dist/services/pluginLoader.js +38 -7
  98. package/dist/services/remoteSettingsService.js +16 -2
  99. package/dist/services/session.d.ts +74 -0
  100. package/dist/services/session.js +144 -3
  101. package/dist/services/sessionEntries.d.ts +2 -0
  102. package/dist/services/sessionEntries.js +20 -0
  103. package/dist/stdio/index.d.ts +3 -1
  104. package/dist/stdio/index.js +3 -1
  105. package/dist/stdio/notificationRouter.js +1 -0
  106. package/dist/stdio/stdioAgent.d.ts +14 -7
  107. package/dist/stdio/stdioAgent.js +19 -0
  108. package/dist/tools/artifactTool.js +406 -273
  109. package/dist/tools/bashTool.js +8 -6
  110. package/dist/tools/editTool.js +6 -3
  111. package/dist/tools/execTool.d.ts +2 -0
  112. package/dist/tools/execTool.js +165 -0
  113. package/dist/tools/grepTool.js +7 -1
  114. package/dist/tools/readTool.js +30 -2
  115. package/dist/tools/types.d.ts +34 -8
  116. package/dist/tools/webFetchTool.js +15 -166
  117. package/dist/tools/workflowTool.js +40 -8
  118. package/dist/tools/writeTool.js +6 -3
  119. package/dist/types/agent.d.ts +20 -5
  120. package/dist/types/configuration.d.ts +39 -1
  121. package/dist/types/marketplace.d.ts +40 -2
  122. package/dist/types/mcp.d.ts +39 -0
  123. package/dist/types/permissions.d.ts +22 -0
  124. package/dist/types/permissions.js +17 -0
  125. package/dist/types/plugins.d.ts +26 -2
  126. package/dist/types/skills.d.ts +15 -0
  127. package/dist/utils/constants.d.ts +10 -0
  128. package/dist/utils/constants.js +10 -0
  129. package/dist/utils/containerSetup.js +43 -0
  130. package/dist/utils/convertMessagesForAPI.d.ts +7 -1
  131. package/dist/utils/convertMessagesForAPI.js +64 -14
  132. package/dist/utils/fileChangeReminder.d.ts +20 -0
  133. package/dist/utils/fileChangeReminder.js +153 -0
  134. package/dist/utils/fileSearch.js +4 -3
  135. package/dist/utils/fileUtils.d.ts +33 -0
  136. package/dist/utils/fileUtils.js +81 -0
  137. package/dist/utils/frontmatterYaml.d.ts +33 -0
  138. package/dist/utils/frontmatterYaml.js +192 -0
  139. package/dist/utils/imageBudget.d.ts +85 -0
  140. package/dist/utils/imageBudget.js +109 -0
  141. package/dist/utils/imageDimensions.d.ts +83 -0
  142. package/dist/utils/imageDimensions.js +232 -0
  143. package/dist/utils/imageProcessor.d.ts +66 -0
  144. package/dist/utils/imageProcessor.js +84 -0
  145. package/dist/utils/imageRewrite.d.ts +29 -0
  146. package/dist/utils/imageRewrite.js +251 -0
  147. package/dist/utils/markdownParser.d.ts +5 -1
  148. package/dist/utils/markdownParser.js +9 -51
  149. package/dist/utils/mcpInstructions.d.ts +61 -0
  150. package/dist/utils/mcpInstructions.js +126 -0
  151. package/dist/utils/mcpUtils.d.ts +7 -0
  152. package/dist/utils/mcpUtils.js +11 -2
  153. package/dist/utils/memoryAge.d.ts +32 -0
  154. package/dist/utils/memoryAge.js +47 -0
  155. package/dist/utils/memoryEntrypoint.d.ts +20 -0
  156. package/dist/utils/memoryEntrypoint.js +49 -0
  157. package/dist/utils/memoryIndex.d.ts +30 -0
  158. package/dist/utils/memoryIndex.js +76 -0
  159. package/dist/utils/messageOperations.d.ts +6 -2
  160. package/dist/utils/messageOperations.js +40 -29
  161. package/dist/utils/nestedMemory.d.ts +22 -0
  162. package/dist/utils/nestedMemory.js +61 -0
  163. package/dist/utils/npmTarball.d.ts +19 -0
  164. package/dist/utils/npmTarball.js +92 -0
  165. package/dist/utils/pluginSource.d.ts +37 -0
  166. package/dist/utils/pluginSource.js +73 -0
  167. package/dist/utils/ripgrep.d.ts +18 -4
  168. package/dist/utils/ripgrep.js +56 -4
  169. package/dist/utils/runtimeDeps.d.ts +35 -0
  170. package/dist/utils/runtimeDeps.js +426 -0
  171. package/dist/utils/skillParser.js +22 -52
  172. package/dist/utils/subagentParser.js +39 -43
  173. package/dist/utils/userSettings.d.ts +90 -0
  174. package/dist/utils/userSettings.js +291 -0
  175. package/package.json +10 -7
@@ -0,0 +1,293 @@
1
+ /**
2
+ * The MCP catalog, announced in the conversation instead of the `Exec` tool
3
+ * description. opencode only made this move on its v2 line (v1 shipped the catalog
4
+ * inside `execute`'s description, which is what this repo did until now).
5
+ *
6
+ * Why it cannot stay in the description: `tools[]` sits inside the cached prefix,
7
+ * and the catalog is a function of the pool. Every server that connects, drops, or
8
+ * changes its `tools/list` would rewrite the declaration and take the whole cached
9
+ * prefix with it. The description is a pool-independent constant now; the catalog is
10
+ * appended at the tail, where an addition invalidates nothing that already went out.
11
+ *
12
+ * State lives in the history, not in the process (same rule as
13
+ * `utils/mcpInstructions.ts`): each announcement leads with a marker line carrying
14
+ * the hash of a full catalog body. Three consequences, all deliberate:
15
+ * - Once the marker is gone (compaction, rewind), the next turn announces the full
16
+ * catalog again. A duplicate is better than a loss.
17
+ * - A delta is only emitted while the full catalog it is relative to is still in
18
+ * the history. The marker carries that baseline's hash (`bh`) so the check stays
19
+ * local: a delta is relative, and without its basis it says nothing.
20
+ * - The hash covers rendered text, so editing the renderer re-announces the catalog
21
+ * once for existing sessions. That is what buys the invariant "same hash ⇒ the
22
+ * same bytes the model read". It is also why a full catalog carries no "this
23
+ * supersedes the previous one" preamble: such a line would depend on history, and
24
+ * a history-dependent body can never compare equal to a freshly rendered one —
25
+ * the session would re-announce on every single turn.
26
+ */
27
+ import { createHash } from "node:crypto";
28
+ import { renderSearchSignature } from "./catalog.js";
29
+ /** Marks an announcement so the history scan can find it. */
30
+ export const EXEC_CATALOG_MARKER_PREFIX = "<!-- exec-catalog ";
31
+ export const EXEC_CATALOG_MARKER_SUFFIX = " -->";
32
+ /** Opens a full catalog body; the entries follow. */
33
+ const CATALOG_HEADER = "MCP tools reachable inside a sandbox script, called as `tools.<name>` (no other name resolves):";
34
+ /**
35
+ * What the model is told when a pool it has seen empties. It names no tool on purpose:
36
+ * with an empty pool `Exec` is not even declared, so naming it would point at something
37
+ * the model cannot call.
38
+ *
39
+ * This text is only ever reached by a session that already read a catalog, which is why
40
+ * it says "updates may add or remove tools" rather than introducing the concept: to a
41
+ * session that never had one, "no MCP tools are available" describes a capability it had
42
+ * no reason to expect (see the `last === null` branch).
43
+ */
44
+ const EMPTY_POOL_NOTE = "No MCP tools are currently available. Later catalog updates may add or remove tools.";
45
+ /**
46
+ * What the model is told when the channel closes: `Exec` was switched off, excluded
47
+ * or denied, so the catalog's calling form no longer exists and the flat
48
+ * declarations are back. The listed tools are not gone — the way they were listed is.
49
+ */
50
+ const REMOVED_NOTE = "The MCP tool catalog is no longer available, so the tools an earlier catalog listed cannot be called this way any more. The tool declarations of this session are the current source of truth.";
51
+ const DELTA_HEADER = "The MCP tool catalog has changed:";
52
+ /**
53
+ * A full catalog body: the header, the rendered entries, and — only when the budget
54
+ * truncated them — how to reach the rest.
55
+ *
56
+ * The search section is the single place the call form is taught (the entry point
57
+ * stays registered either way, it is merely not advertised). Making it conditional
58
+ * is what keeps the two statements from drifting: while the catalog is complete,
59
+ * nothing anywhere claims a search is needed.
60
+ */
61
+ export function renderFullCatalogNote(catalog) {
62
+ if (catalog.total === 0)
63
+ return EMPTY_POOL_NOTE;
64
+ const parts = [CATALOG_HEADER, catalog.text];
65
+ if (catalog.truncated) {
66
+ parts.push("", "The catalog above is partial. Call this to list or search the complete pool:", "", ...renderSearchSignature()
67
+ .split("\n")
68
+ .map((line) => ` ${line}`));
69
+ }
70
+ return parts.join("\n");
71
+ }
72
+ /** `h` in the marker payload, and the value `bh` points back at. */
73
+ function hashNote(note) {
74
+ return createHash("sha256").update(note).digest("hex").slice(0, 12);
75
+ }
76
+ /** `{ k, h, t, ns, bh }` — the marker, as one line. */
77
+ function renderMarker(marker) {
78
+ const payload = { k: marker.kind };
79
+ if (marker.hash !== undefined)
80
+ payload.h = marker.hash;
81
+ if (marker.truncated !== undefined)
82
+ payload.t = marker.truncated;
83
+ if (marker.namespaces !== undefined)
84
+ payload.ns = marker.namespaces;
85
+ if (marker.base !== undefined)
86
+ payload.bh = marker.base;
87
+ return `${EXEC_CATALOG_MARKER_PREFIX}${JSON.stringify(payload)}${EXEC_CATALOG_MARKER_SUFFIX}`;
88
+ }
89
+ /**
90
+ * Read the announcement state out of the history.
91
+ *
92
+ * Only this module's own messages are read: a marker quoted anywhere else — a reply
93
+ * explaining the mechanism, a hook echoing one — is prose *about* a marker, not a
94
+ * statement about the pool. Counting it would invent an announcement, and the
95
+ * invented state then misleads every later turn. A marker that cannot be parsed is
96
+ * skipped for the same reason (worst case: one duplicate announcement).
97
+ */
98
+ export function collectExecCatalogState(messages) {
99
+ const fullHashes = new Set();
100
+ let last = null;
101
+ for (const message of messages) {
102
+ if (message.isMeta !== true)
103
+ continue;
104
+ for (const block of message.blocks) {
105
+ if (block.type !== "text")
106
+ continue;
107
+ for (const line of block.content.split("\n")) {
108
+ const marker = parseMarkerLine(line);
109
+ if (!marker)
110
+ continue;
111
+ last = marker;
112
+ if (marker.kind === "full" && marker.hash)
113
+ fullHashes.add(marker.hash);
114
+ }
115
+ }
116
+ }
117
+ return { last, fullHashes };
118
+ }
119
+ function parseMarkerLine(line) {
120
+ const trimmed = line.trim();
121
+ if (!trimmed.startsWith(EXEC_CATALOG_MARKER_PREFIX) ||
122
+ !trimmed.endsWith(EXEC_CATALOG_MARKER_SUFFIX)) {
123
+ return null;
124
+ }
125
+ const payload = trimmed.slice(EXEC_CATALOG_MARKER_PREFIX.length, trimmed.length - EXEC_CATALOG_MARKER_SUFFIX.length);
126
+ try {
127
+ const parsed = JSON.parse(payload);
128
+ const kind = parsed.k;
129
+ if (kind !== "full" && kind !== "delta" && kind !== "removed")
130
+ return null;
131
+ return {
132
+ kind,
133
+ hash: typeof parsed.h === "string" ? parsed.h : undefined,
134
+ truncated: parsed.t === 1 ? 1 : 0,
135
+ namespaces: readNamespaceCounts(parsed.ns),
136
+ base: typeof parsed.bh === "string" ? parsed.bh : undefined,
137
+ };
138
+ }
139
+ catch {
140
+ // A marker we cannot read (hand-edited or truncated history) must not take the
141
+ // turn down; the worst case is announcing the catalog once more.
142
+ return null;
143
+ }
144
+ }
145
+ function readNamespaceCounts(value) {
146
+ if (!value || typeof value !== "object" || Array.isArray(value))
147
+ return undefined;
148
+ const counts = {};
149
+ for (const [name, count] of Object.entries(value)) {
150
+ if (typeof count !== "number" || !Number.isFinite(count))
151
+ return undefined;
152
+ counts[name] = count;
153
+ }
154
+ return counts;
155
+ }
156
+ /**
157
+ * The next announcement for this turn, or null when there is nothing to say.
158
+ *
159
+ * The decision table, in order: a channel that was never open says nothing; a closed
160
+ * channel is announced once; a session that has announced nothing (or whose last word
161
+ * was the closing notice) gets the full catalog; an unchanged hash says nothing; a
162
+ * changed pool with its basis still present may go out as a namespace-level delta;
163
+ * anything else is the full catalog.
164
+ */
165
+ export function buildExecCatalogAnnouncement(state, current) {
166
+ const last = state.last;
167
+ if (current === undefined) {
168
+ // Nothing was ever announced, so there is nothing to retract: most sessions have
169
+ // no `Exec` at all, and a closing notice there would be a message about a channel
170
+ // the model never saw. Silence, not "observed absence".
171
+ if (last === null)
172
+ return null;
173
+ // `removed` carries no hash: the channel is not a catalog, so there is nothing
174
+ // to compare. Re-opening it therefore always re-announces in full, even when the
175
+ // pool never moved — going by content here would leave the session mute forever
176
+ // after a switch was flipped off and back on.
177
+ if (last.kind === "removed")
178
+ return null;
179
+ return envelope({ kind: "removed" }, REMOVED_NOTE);
180
+ }
181
+ const note = renderFullCatalogNote(current);
182
+ const hash = hashNote(note);
183
+ const namespaces = namespaceCounts(current);
184
+ const full = {
185
+ kind: "full",
186
+ hash,
187
+ truncated: current.truncated ? 1 : 0,
188
+ namespaces,
189
+ };
190
+ if (last === null) {
191
+ // A session that never had a catalog has nothing to learn from "there is nothing":
192
+ // with an empty pool `Exec` is not even declared, so the note points at no tool the
193
+ // model can see or call. It is announced only to a session that has already seen a
194
+ // catalog, where "the catalog is now empty" is a change rather than a first word.
195
+ return current.total === 0 ? null : envelope(full, note);
196
+ }
197
+ if (last.kind === "removed")
198
+ return envelope(full, note);
199
+ if (last.hash === hash) {
200
+ // The pool renders identically, so an unchanged turn stays silent — unless the
201
+ // last word was a delta whose basis has since been compacted away: nothing the
202
+ // model can still see describes the catalog then, so it is re-announced in full.
203
+ if (last.kind === "delta" && !hasBasis(last, state)) {
204
+ return envelope(full, note);
205
+ }
206
+ return null;
207
+ }
208
+ if (!hasBasis(last, state))
209
+ return envelope(full, note);
210
+ const changed = chooseChangedAnnouncement(last, current, note, namespaces, hash);
211
+ return changed;
212
+ }
213
+ /**
214
+ * Is the full catalog a delta would be relative to still in the history? For a
215
+ * `full` last word that is the message itself; for a delta it is the anchor it
216
+ * passed along its chain.
217
+ */
218
+ function hasBasis(last, state) {
219
+ const anchor = last.kind === "full" ? last.hash : last.base;
220
+ return anchor !== undefined && state.fullHashes.has(anchor);
221
+ }
222
+ /**
223
+ * Delta or full catalog, for a pool that changed while the basis is still present.
224
+ *
225
+ * Namespace counts are all a marker carries, so a delta can only say "this server
226
+ * appeared / changed size / went away" — never a signature. That is enough only
227
+ * while the catalog is truncated (the model already has the entries it can see) and
228
+ * only when some count actually moved. Otherwise the entries themselves are what
229
+ * changed, and only the full catalog can say how.
230
+ */
231
+ function chooseChangedAnnouncement(last, current, note, namespaces, hash) {
232
+ const full = {
233
+ kind: "full",
234
+ hash,
235
+ truncated: current.truncated ? 1 : 0,
236
+ namespaces,
237
+ };
238
+ const truncated = current.truncated ? 1 : 0;
239
+ if (last.truncated !== truncated || !current.truncated) {
240
+ return envelope(full, note);
241
+ }
242
+ const delta = renderDeltaNote(last, current);
243
+ // A delta is a means, not an end: when it is no longer the shorter of the two
244
+ // (a wholesale server swap, say) the full catalog wins.
245
+ if (delta === null || delta.length >= note.length) {
246
+ return envelope(full, note);
247
+ }
248
+ return envelope({
249
+ kind: "delta",
250
+ hash,
251
+ truncated,
252
+ namespaces,
253
+ base: last.kind === "full" ? last.hash : last.base,
254
+ }, delta);
255
+ }
256
+ /**
257
+ * The namespace-level differences between two snapshots, or null when the counts
258
+ * are identical (nothing a delta could say).
259
+ */
260
+ function renderDeltaNote(last, current) {
261
+ const before = last.namespaces;
262
+ if (!before)
263
+ return null;
264
+ const after = namespaceCounts(current);
265
+ const lines = [];
266
+ for (const [name, count] of Object.entries(after)) {
267
+ const previous = before[name];
268
+ if (previous === undefined) {
269
+ lines.push(`- \`mcp__${name}\` is now available (${tools(count)}). Search it for signatures.`);
270
+ }
271
+ else if (previous !== count) {
272
+ lines.push(`- \`mcp__${name}\` now has ${tools(count)} (was ${previous}). ` +
273
+ "Search it again before relying on results from before this change.");
274
+ }
275
+ }
276
+ for (const name of Object.keys(before)) {
277
+ if (after[name] === undefined) {
278
+ lines.push(`- \`mcp__${name}\` is no longer available and must not be used.`);
279
+ }
280
+ }
281
+ if (lines.length === 0)
282
+ return null;
283
+ return [DELTA_HEADER, ...lines].join("\n");
284
+ }
285
+ function tools(count) {
286
+ return `${count} tool${count === 1 ? "" : "s"}`;
287
+ }
288
+ function namespaceCounts(catalog) {
289
+ return Object.fromEntries(catalog.namespaces.map((namespace) => [namespace.name, namespace.count]));
290
+ }
291
+ function envelope(marker, body) {
292
+ return `${renderMarker(marker)}\n${body}`;
293
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Exec sandbox constants.
3
+ *
4
+ * Almost everything here is a tunable default, not a contract. In particular no
5
+ * *knob* may be rendered into model-visible text: the catalog has to stay
6
+ * byte-identical for an unchanged MCP pool, so changing a budget must not change
7
+ * what the model reads (see `Exec`'s description and
8
+ * `exec/catalogAnnouncement.ts`).
9
+ *
10
+ * The exception is `EXEC_RESERVED_NAMESPACE`: it is part of the sandbox API
11
+ * surface, so the tool description has to name it (and it is derived from the
12
+ * same constant, so the two cannot drift).
13
+ */
14
+ /**
15
+ * Wall-clock budget for one Exec script. The parent terminates the sandbox
16
+ * worker when it expires, so a script blocked in `await` is killed instead of
17
+ * spinning on the host event loop forever.
18
+ */
19
+ export declare const EXEC_DEFAULT_TIMEOUT_MS = 60000;
20
+ /** Maximum number of sandbox -> host tool calls per Exec script. */
21
+ export declare const EXEC_DEFAULT_MAX_TOOL_CALLS = 50;
22
+ /** Maximum number of `console` output characters returned to the model. */
23
+ export declare const EXEC_DEFAULT_MAX_LOG_CHARS = 8000;
24
+ /**
25
+ * Maximum number of characters of the *script return value* handed back to the
26
+ * model. Mirrors the `DEFAULT_MAX_RESULT_SIZE_CHARS` bound that the flat MCP and
27
+ * Bash paths apply to their own results: without it a script could return a
28
+ * payload far larger than the call it replaced would have produced.
29
+ */
30
+ export declare const EXEC_DEFAULT_MAX_RESULT_CHARS = 100000;
31
+ /** Maximum number of images propagated from nested MCP calls. */
32
+ export declare const EXEC_DEFAULT_MAX_IMAGES = 4;
33
+ /**
34
+ * Maximum size of the MCP catalog, in estimated tokens (a plain `chars / 4`; see
35
+ * `estimateCatalogTokens`).
36
+ *
37
+ * Provenance: opencode's `catalogBudget` — `defaultCatalogBudget = 2_000` in
38
+ * `packages/codemode/src/tool-runtime.ts`. Deliberately not CJK-aware: MCP tool
39
+ * descriptions are overwhelmingly English. Tunable, not a contract.
40
+ */
41
+ export declare const EXEC_DEFAULT_CATALOG_TOKENS = 2000;
42
+ /**
43
+ * Reserved property on the sandbox `tools` object hosting search. The `$`
44
+ * prefix cannot collide with an MCP tool name (`[A-Za-z0-9_.-]`).
45
+ */
46
+ export declare const EXEC_RESERVED_NAMESPACE = "$codemode";
47
+ /**
48
+ * Internal name the sandbox uses to reach host-side catalog search. Matches the
49
+ * sandbox path `tools.$codemode.search` but never appears in model-visible text.
50
+ */
51
+ export declare const EXEC_SEARCH_CALL = "$codemode.search";
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Exec sandbox constants.
3
+ *
4
+ * Almost everything here is a tunable default, not a contract. In particular no
5
+ * *knob* may be rendered into model-visible text: the catalog has to stay
6
+ * byte-identical for an unchanged MCP pool, so changing a budget must not change
7
+ * what the model reads (see `Exec`'s description and
8
+ * `exec/catalogAnnouncement.ts`).
9
+ *
10
+ * The exception is `EXEC_RESERVED_NAMESPACE`: it is part of the sandbox API
11
+ * surface, so the tool description has to name it (and it is derived from the
12
+ * same constant, so the two cannot drift).
13
+ */
14
+ /**
15
+ * Wall-clock budget for one Exec script. The parent terminates the sandbox
16
+ * worker when it expires, so a script blocked in `await` is killed instead of
17
+ * spinning on the host event loop forever.
18
+ */
19
+ export const EXEC_DEFAULT_TIMEOUT_MS = 60000;
20
+ /** Maximum number of sandbox -> host tool calls per Exec script. */
21
+ export const EXEC_DEFAULT_MAX_TOOL_CALLS = 50;
22
+ /** Maximum number of `console` output characters returned to the model. */
23
+ export const EXEC_DEFAULT_MAX_LOG_CHARS = 8000;
24
+ /**
25
+ * Maximum number of characters of the *script return value* handed back to the
26
+ * model. Mirrors the `DEFAULT_MAX_RESULT_SIZE_CHARS` bound that the flat MCP and
27
+ * Bash paths apply to their own results: without it a script could return a
28
+ * payload far larger than the call it replaced would have produced.
29
+ */
30
+ export const EXEC_DEFAULT_MAX_RESULT_CHARS = 100000;
31
+ /** Maximum number of images propagated from nested MCP calls. */
32
+ export const EXEC_DEFAULT_MAX_IMAGES = 4;
33
+ /**
34
+ * Maximum size of the MCP catalog, in estimated tokens (a plain `chars / 4`; see
35
+ * `estimateCatalogTokens`).
36
+ *
37
+ * Provenance: opencode's `catalogBudget` — `defaultCatalogBudget = 2_000` in
38
+ * `packages/codemode/src/tool-runtime.ts`. Deliberately not CJK-aware: MCP tool
39
+ * descriptions are overwhelmingly English. Tunable, not a contract.
40
+ */
41
+ export const EXEC_DEFAULT_CATALOG_TOKENS = 2000;
42
+ /**
43
+ * Reserved property on the sandbox `tools` object hosting search. The `$`
44
+ * prefix cannot collide with an MCP tool name (`[A-Za-z0-9_.-]`).
45
+ */
46
+ export const EXEC_RESERVED_NAMESPACE = "$codemode";
47
+ /**
48
+ * Internal name the sandbox uses to reach host-side catalog search. Matches the
49
+ * sandbox path `tools.$codemode.search` but never appears in model-visible text.
50
+ */
51
+ export const EXEC_SEARCH_CALL = "$codemode.search";
@@ -0,0 +1,55 @@
1
+ import type { ToolContext } from "../tools/types.js";
2
+ import type { ExecPoolEntry } from "./catalog.js";
3
+ export interface RunExecOptions {
4
+ code: string;
5
+ /** Every MCP tool the sandbox may call. Also the allowlist for nested calls. */
6
+ pool: ExecPoolEntry[];
7
+ context: ToolContext;
8
+ /**
9
+ * Called as each nested call is issued, before it is dispatched, in the order
10
+ * the script issues them. Lets the caller show live progress; a call that is
11
+ * refused afterwards (over the limit) still reports.
12
+ */
13
+ onToolCall?: (name: string) => void;
14
+ timeoutMs?: number;
15
+ maxToolCalls?: number;
16
+ maxLogChars?: number;
17
+ maxResultChars?: number;
18
+ }
19
+ export interface ExecCallResult {
20
+ /**
21
+ * What the script's `await` resolves to — the tool's structured output, else its
22
+ * text, else `null` (the rule `renderToolSignature` promises in the catalog).
23
+ * Plain JSON only: it crosses the worker boundary and is deep-cloned inside the
24
+ * sandbox.
25
+ */
26
+ output: unknown;
27
+ /**
28
+ * Images to hoist onto the `Exec` result. Never seen by the script: a nested call
29
+ * resolves to the tool's output, and an image is not something a script composes
30
+ * with.
31
+ */
32
+ images?: Array<{
33
+ data: string;
34
+ mediaType?: string;
35
+ }>;
36
+ }
37
+ export interface ExecRunResult {
38
+ ok: boolean;
39
+ /** Serialized script return value. Only set when `ok`. */
40
+ value?: string;
41
+ error?: string;
42
+ logs: string[];
43
+ toolCalls: number;
44
+ images: Array<{
45
+ data: string;
46
+ mediaType?: string;
47
+ }>;
48
+ }
49
+ /**
50
+ * Run one Exec script in a terminable sandbox worker.
51
+ *
52
+ * Never rejects: every failure mode (script throw, budget, abort, worker crash)
53
+ * comes back as `{ ok: false, error }` so the model gets a result to act on.
54
+ */
55
+ export declare function runExecScript(options: RunExecOptions): Promise<ExecRunResult>;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Parent-side driver for the Exec sandbox worker.
3
+ *
4
+ * Owns everything the sandbox must not: the MCP pool, the permission context,
5
+ * the wall-clock budget and termination. The sandbox only ever sends plain
6
+ * JSON (`{ name, args }`) and only ever receives plain JSON back.
7
+ */
8
+ import { Worker } from "node:worker_threads";
9
+ import { logger } from "../utils/globalLogger.js";
10
+ import { EXEC_DEFAULT_MAX_IMAGES, EXEC_DEFAULT_MAX_LOG_CHARS, EXEC_DEFAULT_MAX_RESULT_CHARS, EXEC_DEFAULT_MAX_TOOL_CALLS, EXEC_DEFAULT_TIMEOUT_MS, EXEC_RESERVED_NAMESPACE, EXEC_SEARCH_CALL, } from "./constants.js";
11
+ import { EXEC_WORKER_SOURCE } from "./workerSource.js";
12
+ import { renderSearchCallForm, renderToolSignature, resolveSearchQuery, } from "./catalog.js";
13
+ const DYNAMIC_IMPORT_PATTERN = /dynamic import callback/i;
14
+ /**
15
+ * Rewrite engine-internal messages the model cannot act on into something it
16
+ * can. `node:vm` refuses `import()` with an internal-sounding message that
17
+ * mentions a callback the model has no way to know about.
18
+ */
19
+ function humanizeError(message) {
20
+ if (DYNAMIC_IMPORT_PATTERN.test(message)) {
21
+ return "import() is not available inside Exec";
22
+ }
23
+ return message;
24
+ }
25
+ async function handleExecCall(name, args, pool, context) {
26
+ if (name === EXEC_SEARCH_CALL) {
27
+ // The shape is validated against the schema the tool description renders from,
28
+ // not by hand — see `resolveSearchQuery`.
29
+ const query = resolveSearchQuery(args);
30
+ const matches = [...pool.values()].filter((entry) => {
31
+ if (query.length === 0)
32
+ return true;
33
+ return (entry.name.toLowerCase().includes(query) ||
34
+ (entry.description ?? "").toLowerCase().includes(query));
35
+ });
36
+ // Return the same rendered signature the catalog shows, not the raw schema,
37
+ // so a hit can be copied verbatim into a call. Rendering is done on the
38
+ // matched entries only, never the whole pool. The value is the array itself,
39
+ // not its JSON text — same rule as an MCP call: a script composes values, and
40
+ // making it parse a string first is the kind of extra step a signature should
41
+ // not have to mention.
42
+ return {
43
+ output: matches.map((entry) => ({
44
+ name: entry.name,
45
+ description: entry.description,
46
+ signature: renderToolSignature(entry),
47
+ })),
48
+ };
49
+ }
50
+ if (!pool.has(name)) {
51
+ throw new Error(`Unknown tool "${name}". Only MCP tools are reachable from Exec. ` +
52
+ `Use ${renderSearchCallForm()} to find one.`);
53
+ }
54
+ const mcpManager = context.mcpManager;
55
+ if (!mcpManager) {
56
+ throw new Error("MCP manager is not available in the Exec context");
57
+ }
58
+ // The single MCP funnel: it runs the permission/approval check internally and
59
+ // keys it on the flattened name, so a nested call is approved exactly like a
60
+ // flat MCP call.
61
+ const result = await mcpManager.executeMcpTool(name, args, context);
62
+ return { output: result.output, images: result.images };
63
+ }
64
+ function terminate(worker) {
65
+ worker.terminate().catch(() => {
66
+ /* already gone */
67
+ });
68
+ }
69
+ /**
70
+ * Run one Exec script in a terminable sandbox worker.
71
+ *
72
+ * Never rejects: every failure mode (script throw, budget, abort, worker crash)
73
+ * comes back as `{ ok: false, error }` so the model gets a result to act on.
74
+ */
75
+ export function runExecScript(options) {
76
+ const timeoutMs = options.timeoutMs ?? EXEC_DEFAULT_TIMEOUT_MS;
77
+ const maxToolCalls = options.maxToolCalls ?? EXEC_DEFAULT_MAX_TOOL_CALLS;
78
+ const maxLogChars = options.maxLogChars ?? EXEC_DEFAULT_MAX_LOG_CHARS;
79
+ const maxResultChars = options.maxResultChars ?? EXEC_DEFAULT_MAX_RESULT_CHARS;
80
+ const pool = new Map(options.pool.map((entry) => [entry.name, entry]));
81
+ const images = [];
82
+ return new Promise((resolve) => {
83
+ const worker = new Worker(EXEC_WORKER_SOURCE, { eval: true });
84
+ let settled = false;
85
+ let toolCalls = 0;
86
+ const finish = (result) => {
87
+ if (settled)
88
+ return;
89
+ settled = true;
90
+ clearTimeout(budget);
91
+ options.context.abortSignal?.removeEventListener("abort", onAbort);
92
+ terminate(worker);
93
+ resolve(result);
94
+ };
95
+ const budget = setTimeout(() => {
96
+ finish({
97
+ ok: false,
98
+ error: `Exec script exceeded the ${timeoutMs}ms budget and was terminated. ` +
99
+ `Tool calls that already completed still apply.`,
100
+ logs: [],
101
+ toolCalls,
102
+ images,
103
+ });
104
+ }, timeoutMs);
105
+ const onAbort = () => {
106
+ finish({
107
+ ok: false,
108
+ error: "Exec script was aborted.",
109
+ logs: [],
110
+ toolCalls,
111
+ images,
112
+ });
113
+ };
114
+ if (options.context.abortSignal?.aborted) {
115
+ onAbort();
116
+ return;
117
+ }
118
+ options.context.abortSignal?.addEventListener("abort", onAbort, {
119
+ once: true,
120
+ });
121
+ worker.on("message", (message) => {
122
+ if (settled)
123
+ return;
124
+ if (message.kind === "call") {
125
+ const call = message;
126
+ toolCalls += 1;
127
+ options.onToolCall?.(call.name);
128
+ if (toolCalls > maxToolCalls) {
129
+ worker.postMessage({
130
+ kind: "result",
131
+ id: call.id,
132
+ ok: false,
133
+ error: `Exec tool-call limit reached (${maxToolCalls}).`,
134
+ });
135
+ return;
136
+ }
137
+ handleExecCall(call.name, call.args, pool, options.context).then((result) => {
138
+ if (result.images?.length) {
139
+ for (const image of result.images) {
140
+ if (images.length < EXEC_DEFAULT_MAX_IMAGES)
141
+ images.push(image);
142
+ }
143
+ }
144
+ if (settled)
145
+ return;
146
+ worker.postMessage({
147
+ kind: "result",
148
+ id: call.id,
149
+ ok: true,
150
+ value: result.output,
151
+ });
152
+ }, (error) => {
153
+ if (settled)
154
+ return;
155
+ worker.postMessage({
156
+ kind: "result",
157
+ id: call.id,
158
+ ok: false,
159
+ error: humanizeError(error instanceof Error ? error.message : String(error)),
160
+ });
161
+ });
162
+ return;
163
+ }
164
+ if (message.kind === "done") {
165
+ const done = message;
166
+ const logs = Array.isArray(done.logs) ? done.logs : [];
167
+ if (done.ok) {
168
+ finish({
169
+ ok: true,
170
+ value: done.value,
171
+ logs,
172
+ toolCalls,
173
+ images,
174
+ });
175
+ }
176
+ else {
177
+ finish({
178
+ ok: false,
179
+ error: humanizeError(done.error ?? "Exec script failed"),
180
+ logs,
181
+ toolCalls,
182
+ images,
183
+ });
184
+ }
185
+ }
186
+ });
187
+ worker.on("error", (error) => {
188
+ logger.error(`[Exec] sandbox worker error: ${error.message}`);
189
+ finish({
190
+ ok: false,
191
+ error: humanizeError(error.message),
192
+ logs: [],
193
+ toolCalls,
194
+ images,
195
+ });
196
+ });
197
+ worker.on("exit", (code) => {
198
+ finish({
199
+ ok: false,
200
+ error: `Exec sandbox exited unexpectedly (code ${code})`,
201
+ logs: [],
202
+ toolCalls,
203
+ images,
204
+ });
205
+ });
206
+ worker.postMessage({
207
+ kind: "run",
208
+ code: options.code,
209
+ toolNames: options.pool.map((entry) => entry.name),
210
+ searchName: EXEC_SEARCH_CALL,
211
+ reservedNamespace: EXEC_RESERVED_NAMESPACE,
212
+ timeoutMs,
213
+ maxLogChars,
214
+ maxResultChars,
215
+ });
216
+ });
217
+ }