@code-yeongyu/senpi-codemode 2026.10.6 → 2026.10.8

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 (72) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +20 -2
  3. package/package.json +5 -4
  4. package/src/bridge/kernel-tools-protocol.ts +8 -0
  5. package/src/bridge/reserved.ts +10 -0
  6. package/src/bridges/eval-virtual-schemas.ts +100 -0
  7. package/src/bridges/handle-bridge.ts +72 -0
  8. package/src/bridges/reserved-dispatch.ts +20 -2
  9. package/src/bridges/schema-bridge.ts +4 -0
  10. package/src/completion/handle.ts +69 -0
  11. package/src/completion/tool-bridge.ts +31 -12
  12. package/src/environments/editable-check.ts +116 -0
  13. package/src/environments/install-lock.ts +223 -0
  14. package/src/environments/py-environment.ts +98 -0
  15. package/src/environments/py-installer.ts +187 -0
  16. package/src/environments/revision-store.ts +95 -0
  17. package/src/extension/bridge-dispatch.ts +41 -0
  18. package/src/extension/bridge-tool-call.ts +5 -1
  19. package/src/extension/host-facts.ts +18 -0
  20. package/src/extension/kernel-registration.ts +5 -0
  21. package/src/extension/removed-tool-hints.ts +15 -0
  22. package/src/extension/runtime-factory.ts +3 -0
  23. package/src/extension/session-manager-contract.ts +3 -0
  24. package/src/extension/session-manager.ts +32 -5
  25. package/src/extension/start-recovery.ts +111 -0
  26. package/src/handles/completion-handles.ts +202 -0
  27. package/src/handles/handle-args.ts +103 -0
  28. package/src/handles/handle-registry.ts +164 -0
  29. package/src/handles/wait.ts +214 -0
  30. package/src/handles/watch-queue.ts +102 -0
  31. package/src/index.ts +99 -94
  32. package/src/kernels/jl/handles.jl +124 -0
  33. package/src/kernels/jl/prelude.jl +9 -0
  34. package/src/kernels/jl/runner.jl +6 -1
  35. package/src/kernels/js/handles.js +93 -0
  36. package/src/kernels/js/kernel-tools-define.d.ts +6 -0
  37. package/src/kernels/js/kernel-tools-define.js +3 -1
  38. package/src/kernels/js/kernel-tools-errors.js +2 -0
  39. package/src/kernels/js/kernel-tools-errors.ts +2 -0
  40. package/src/kernels/js/kernel-tools-registry.d.ts +2 -0
  41. package/src/kernels/js/kernel-tools-registry.js +18 -9
  42. package/src/kernels/js/kernel-tools-schema.js +6 -2
  43. package/src/kernels/js/worker-runtime.js +10 -1
  44. package/src/kernels/py/host-cell.ts +42 -0
  45. package/src/kernels/py/kernel-contract.ts +13 -2
  46. package/src/kernels/py/kernel-tools-host.ts +97 -0
  47. package/src/kernels/py/kernel.ts +88 -12
  48. package/src/kernels/py/prelude.py +797 -11
  49. package/src/kernels/py/process.ts +15 -4
  50. package/src/kernels/py/startup.ts +58 -20
  51. package/src/kernels/py/transport.ts +36 -7
  52. package/src/kernels/rb/handles.rb +97 -0
  53. package/src/kernels/rb/paths.rb +37 -0
  54. package/src/kernels/rb/prelude.rb +15 -38
  55. package/src/kernels/rb/runner.rb +7 -7
  56. package/src/kernels/sandbox/vendor/pi-codemode/LICENSE +21 -0
  57. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +42 -0
  58. package/src/kernels/sandbox/vendor/pi-codemode/identifier.ts +14 -0
  59. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +514 -0
  60. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +431 -0
  61. package/src/kernels/sandbox/vendor/pi-codemode/runtime/protocol.ts +61 -0
  62. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +231 -0
  63. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +185 -0
  64. package/src/kernels/sandbox/vendor/pi-codemode/wasm.ts +37 -0
  65. package/src/kernels/shared/kernel-tools-pump.ts +6 -0
  66. package/src/kernels/shared/process-cpu.ts +69 -0
  67. package/src/kernels/shared/subprocess-queue.ts +1 -0
  68. package/src/tool/cell-handler.ts +21 -18
  69. package/src/tool/cell-reserved-dispatch.ts +51 -0
  70. package/src/tool/eval-tool-options.ts +3 -0
  71. package/src/tool/run-eval-cell.ts +2 -0
  72. package/src/tool/types.ts +15 -0
@@ -0,0 +1,514 @@
1
+ // Vendored from https://github.com/earendil-works/pi (packages/codemode/src) at v1.0.1, commit a7229ddc21810d6245105978033b7df645ecc2f7.
2
+ // MIT license; see LICENSE beside this file. Local changes are listed in VENDORED.md.
3
+ import { Worker } from "node:worker_threads";
4
+ import { toCodemodeIdentifier } from "../identifier.ts";
5
+ import type {
6
+ CodemodeOutputFrame, // senpi-change
7
+ CodemodeCall,
8
+ CodemodeCallStatus,
9
+ CodemodeError,
10
+ CodemodeExecuteOptions,
11
+ CodemodeOutputItem,
12
+ CodemodeResult,
13
+ CodemodeSandboxOptions,
14
+ CodemodeStoreWrites,
15
+ CodemodeTool,
16
+ } from "../types.ts";
17
+ import { type CodemodeWasmModule, loadQuickJSWasm } from "../wasm.ts";
18
+ import {
19
+ type HostToWorkerMessage,
20
+ isWorkerToHostMessage,
21
+ type WorkerData,
22
+ type WorkerToHostMessage,
23
+ } from "./protocol.ts";
24
+
25
+ const DEFAULT_TIMEOUT_MS = 300_000;
26
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
27
+ const RESERVED_GLOBALS: ReadonlySet<string> = new Set([
28
+ "tools",
29
+ "ALL_TOOLS",
30
+ "console",
31
+ "text",
32
+ "image",
33
+ "exit",
34
+ "globalThis",
35
+ "store",
36
+ "load",
37
+ ]);
38
+
39
+ function errorMessage(error: unknown): string {
40
+ return error instanceof Error ? error.message : String(error);
41
+ }
42
+
43
+ function serializeStore(store: Readonly<Record<string, unknown>> | undefined): Record<string, string> {
44
+ const serialized: Record<string, string> = {};
45
+ for (const [key, value] of Object.entries(store ?? {})) {
46
+ const json = JSON.stringify(value);
47
+ if (json !== undefined) serialized[key] = json;
48
+ }
49
+ return serialized;
50
+ }
51
+
52
+ function parseStoreWrites(json: string): CodemodeStoreWrites {
53
+ const writes: CodemodeStoreWrites = { set: {}, delete: [] };
54
+ for (const [key, value] of JSON.parse(json) as [string, string?][]) {
55
+ if (value === undefined) writes.delete.push(key);
56
+ else writes.set[key] = JSON.parse(value);
57
+ }
58
+ return writes;
59
+ }
60
+
61
+ function defaultWorkerUrl(): URL {
62
+ // `.ts` when running from source (tests, tsx), `.js` from the published dist.
63
+ return new URL(import.meta.url.endsWith(".ts") ? "./worker.ts" : "./worker.js", import.meta.url);
64
+ }
65
+
66
+ interface PendingCall {
67
+ record: CodemodeCall | undefined;
68
+ startedAt: number;
69
+ controller: AbortController;
70
+ }
71
+
72
+ interface ExecutionOptions {
73
+ code: string;
74
+ tools: ReadonlyMap<string, CodemodeTool>;
75
+ globals: ReadonlyMap<string, CodemodeTool>;
76
+ timeoutMs: number;
77
+ signal: AbortSignal | undefined;
78
+ memoryLimitBytes: number | undefined;
79
+ store: Record<string, string>;
80
+ wasm: Promise<CodemodeWasmModule>;
81
+ workerUrl: string | URL;
82
+ // senpi-change begin: output streaming and builtin policy
83
+ stream: StreamOptions | undefined;
84
+ storePolicy: "collect" | "reject";
85
+ // senpi-change end
86
+ }
87
+ // senpi-change begin: output streaming
88
+ interface StreamOptions {
89
+ onOutputFrame: (frame: CodemodeOutputFrame) => void | Promise<void>;
90
+ windowBytes: number;
91
+ frameBytes: number;
92
+ }
93
+
94
+ const DEFAULT_WINDOW_BYTES = 256 * 1024;
95
+ const DEFAULT_FRAME_BYTES = 64 * 1024;
96
+ // senpi-change end
97
+
98
+ /**
99
+ * One script run in its own worker and QuickJS VM. A fresh worker per run keeps
100
+ * termination simple: a runaway script, including one that only spins the
101
+ * microtask queue, is killed with `terminate()` and cannot poison a later run.
102
+ */
103
+ class Execution {
104
+ readonly promise: Promise<CodemodeResult>;
105
+ private resolveResult!: (result: CodemodeResult) => void;
106
+ private worker: Worker | undefined;
107
+ private readonly interrupt = new SharedArrayBuffer(4);
108
+ private readonly tools: ReadonlyMap<string, CodemodeTool>;
109
+ private readonly globals: ReadonlyMap<string, CodemodeTool>;
110
+ private readonly signal: AbortSignal | undefined;
111
+ private readonly timer: NodeJS.Timeout | undefined;
112
+ private readonly output: CodemodeOutputItem[] = [];
113
+ private readonly calls: CodemodeCall[] = [];
114
+ private readonly pending = new Map<number, PendingCall>();
115
+ private finished = false;
116
+ // senpi-change begin: output streaming
117
+ private readonly stream: StreamOptions | undefined;
118
+ private readonly credits: Int32Array | undefined;
119
+ private frameChain: Promise<void> = Promise.resolve();
120
+ private frames = 0;
121
+ private inFlightBytes = 0;
122
+ private maxInFlightBytes = 0;
123
+ private frameFailure: string | undefined;
124
+ /** Set when an abort or timeout settles the run: frames still queued are dropped, never handed over. */
125
+ private abandoned = false;
126
+ private abandonDrain: ((late: CodemodeError) => void) | undefined;
127
+ // senpi-change end
128
+
129
+ constructor(options: ExecutionOptions) {
130
+ this.promise = new Promise<CodemodeResult>((resolve) => {
131
+ this.resolveResult = resolve;
132
+ });
133
+ this.tools = options.tools;
134
+ this.globals = options.globals;
135
+ this.signal = options.signal;
136
+ // senpi-change begin: output streaming
137
+ this.stream = options.stream;
138
+ if (options.stream !== undefined) {
139
+ this.credits = new Int32Array(new SharedArrayBuffer(4));
140
+ Atomics.store(this.credits, 0, options.stream.windowBytes);
141
+ }
142
+ // senpi-change end
143
+
144
+ if (Number.isFinite(options.timeoutMs)) {
145
+ this.timer = setTimeout(() => {
146
+ this.finish({ kind: "timeout", message: `Execution timed out after ${options.timeoutMs} ms` });
147
+ }, options.timeoutMs);
148
+ }
149
+
150
+ if (options.signal) {
151
+ if (options.signal.aborted) {
152
+ this.onAbort();
153
+ } else {
154
+ options.signal.addEventListener("abort", this.onAbort, { once: true });
155
+ }
156
+ }
157
+
158
+ options.wasm.then(
159
+ (wasm) => this.start(options, wasm),
160
+ (error: unknown) => {
161
+ this.finish({ kind: "sandbox", message: `Failed to load QuickJS: ${errorMessage(error)}` });
162
+ },
163
+ );
164
+ }
165
+
166
+ abort(message: string): Promise<CodemodeResult> {
167
+ this.finish({ kind: "aborted", message });
168
+ return this.promise;
169
+ }
170
+
171
+ private start(options: ExecutionOptions, wasm: CodemodeWasmModule): void {
172
+ if (this.finished) return;
173
+ const workerData: WorkerData = {
174
+ code: options.code,
175
+ tools: [...options.tools.values()].map((tool) => ({
176
+ name: tool.name,
177
+ jsName: toCodemodeIdentifier(tool.name),
178
+ description: tool.description ?? "",
179
+ })),
180
+ globals: [...options.globals.values()].map((global) => ({
181
+ name: global.name,
182
+ spread: global.spread === true,
183
+ })),
184
+ wasm,
185
+ memoryLimitBytes: options.memoryLimitBytes,
186
+ store: options.store,
187
+ interrupt: this.interrupt,
188
+ // senpi-change begin: output streaming and builtin policy
189
+ ...(this.credits === undefined || this.stream === undefined
190
+ ? {}
191
+ : { stream: { credits: this.credits.buffer as SharedArrayBuffer, frameBytes: this.stream.frameBytes } }),
192
+ storePolicy: options.storePolicy,
193
+ // senpi-change end
194
+ };
195
+ let worker: Worker;
196
+ try {
197
+ worker = new Worker(options.workerUrl, { workerData });
198
+ } catch (error) {
199
+ this.finish({ kind: "sandbox", message: `Failed to start worker: ${errorMessage(error)}` });
200
+ return;
201
+ }
202
+ this.worker = worker;
203
+ worker.on("message", (message: unknown) => this.handleMessage(message));
204
+ worker.on("error", (error: unknown) => {
205
+ this.finish({
206
+ kind: "sandbox",
207
+ name: error instanceof Error ? error.name : undefined,
208
+ message: errorMessage(error),
209
+ });
210
+ });
211
+ worker.on("exit", (code) => {
212
+ this.finish({ kind: "sandbox", message: `Worker exited with code ${code} before the script settled` });
213
+ });
214
+ }
215
+
216
+ private readonly onAbort = (): void => {
217
+ const reason: unknown = this.signal?.reason;
218
+ this.finish({ kind: "aborted", message: reason instanceof Error ? reason.message : "Execution aborted" });
219
+ };
220
+
221
+ private post(message: HostToWorkerMessage): void {
222
+ this.worker?.postMessage(message);
223
+ }
224
+
225
+ private handleMessage(message: unknown): void {
226
+ if (this.finished || !isWorkerToHostMessage(message)) return;
227
+ switch (message.type) {
228
+ case "output":
229
+ this.output.push(message.item);
230
+ break;
231
+ // senpi-change begin: output streaming
232
+ case "output-frame":
233
+ this.enqueueFrame(message.frame, message.bytes);
234
+ break;
235
+ // senpi-change end
236
+ case "call":
237
+ void this.handleCall(message);
238
+ break;
239
+ case "done":
240
+ this.handleDone(message);
241
+ break;
242
+ case "crash":
243
+ this.finish({ kind: "sandbox", message: message.message });
244
+ break;
245
+ }
246
+ }
247
+
248
+ private handleDone(message: Extract<WorkerToHostMessage, { type: "done" }>): void {
249
+ if (!message.ok) {
250
+ const parsed = JSON.parse(message.error) as Omit<CodemodeError, "kind">;
251
+ this.finish({ kind: "script", ...parsed });
252
+ return;
253
+ }
254
+ this.finish(undefined, message.value === undefined ? undefined : JSON.parse(message.value), message.writes);
255
+ }
256
+
257
+ private async handleCall(message: Extract<WorkerToHostMessage, { type: "call" }>): Promise<void> {
258
+ const { id, name } = message;
259
+ const isTool = message.target === "tool";
260
+ const record: CodemodeCall | undefined = isTool ? { name, status: "cancelled", durationMs: 0 } : undefined;
261
+ if (record) this.calls.push(record);
262
+ const pending: PendingCall = { record, startedAt: performance.now(), controller: new AbortController() };
263
+ this.pending.set(id, pending);
264
+
265
+ let status: CodemodeCallStatus;
266
+ let reply: HostToWorkerMessage;
267
+ try {
268
+ const tool = (isTool ? this.tools : this.globals).get(name);
269
+ if (!tool) throw new Error(`Unknown ${isTool ? "tool" : "global"} "${name}"`);
270
+ const args: unknown = message.args === undefined ? undefined : JSON.parse(message.args);
271
+ const value = await tool.execute(args, { signal: pending.controller.signal });
272
+ reply = { type: "result", id, ok: true, payload: value === undefined ? undefined : JSON.stringify(value) };
273
+ status = "ok";
274
+ } catch (error) {
275
+ reply = { type: "result", id, ok: false, payload: errorMessage(error) };
276
+ status = "error";
277
+ }
278
+
279
+ // Already cancelled by finish(): the record keeps "cancelled" and the
280
+ // worker is gone or going.
281
+ if (!this.pending.delete(id)) return;
282
+ if (record) {
283
+ record.status = status;
284
+ record.durationMs = performance.now() - pending.startedAt;
285
+ }
286
+ this.post(reply);
287
+ }
288
+ // senpi-change begin: output streaming
289
+ /**
290
+ * Hands frames to the consumer one at a time, in arrival order, and returns their credit only once consumed.
291
+ * The message loop never waits here: frames queue on the chain, and the worker cannot send past the window.
292
+ */
293
+ private enqueueFrame(frame: CodemodeOutputFrame, bytes: number): void {
294
+ const stream = this.stream;
295
+ const credits = this.credits;
296
+ if (stream === undefined || credits === undefined) return;
297
+ this.inFlightBytes += bytes;
298
+ this.maxInFlightBytes = Math.max(this.maxInFlightBytes, this.inFlightBytes);
299
+ this.frameChain = this.frameChain
300
+ .then(async () => {
301
+ if (this.frameFailure === undefined && !this.abandoned) await stream.onOutputFrame(frame);
302
+ this.frames++;
303
+ })
304
+ .catch((error: unknown) => {
305
+ this.frameFailure ??= errorMessage(error);
306
+ this.finish({ kind: "sandbox", message: `Output consumer failed: ${this.frameFailure}` });
307
+ })
308
+ .finally(() => {
309
+ this.inFlightBytes -= bytes;
310
+ Atomics.add(credits, 0, bytes);
311
+ Atomics.notify(credits, 0);
312
+ });
313
+ }
314
+ // senpi-change end
315
+
316
+ private finish(error: CodemodeError | undefined, value?: unknown, writes?: string): void {
317
+ // senpi-change begin: output streaming
318
+ // A settled run whose last frames are still being consumed keeps its timer and abort listener armed:
319
+ // a late abort, timeout or close() abandons those frames and settles the run, so a consumer that never
320
+ // returns can't keep it alive.
321
+ if (this.finished) {
322
+ if (this.abandonDrain !== undefined && (error?.kind === "aborted" || error?.kind === "timeout")) {
323
+ this.abandonDrain(error);
324
+ }
325
+ return;
326
+ }
327
+ this.finished = true;
328
+ const abandon = error?.kind === "aborted" || error?.kind === "timeout";
329
+ const draining = this.stream !== undefined && !abandon;
330
+ if (!draining) {
331
+ clearTimeout(this.timer);
332
+ this.signal?.removeEventListener("abort", this.onAbort);
333
+ }
334
+ // senpi-change end
335
+
336
+ const now = performance.now();
337
+ for (const pending of this.pending.values()) {
338
+ if (pending.record) pending.record.durationMs = now - pending.startedAt;
339
+ pending.controller.abort();
340
+ }
341
+ this.pending.clear();
342
+
343
+ const result: CodemodeResult = error
344
+ ? { ok: false, error, output: this.output, calls: this.calls }
345
+ : {
346
+ ok: true,
347
+ value,
348
+ output: this.output,
349
+ calls: this.calls,
350
+ storeWrites: writes === undefined ? { set: {}, delete: [] } : parseStoreWrites(writes),
351
+ };
352
+ // senpi-change begin: output streaming
353
+ // Settle only after every frame that arrived has been consumed, so output precedes the result. An abort or
354
+ // timeout settles at once instead, and one arriving while frames drain abandons them (see the top).
355
+ if (abandon) this.abandoned = true;
356
+ let lateResult: CodemodeResult | undefined;
357
+ const abandoned = new Promise<void>((resolve) => {
358
+ this.abandonDrain = (late) => {
359
+ // Every frame that arrived was consumed: the run finished as it reports, and an abort changes nothing.
360
+ if (this.inFlightBytes === 0) return;
361
+ this.abandoned = true;
362
+ lateResult = { ok: false, error: late, output: this.output, calls: this.calls };
363
+ resolve();
364
+ };
365
+ });
366
+ const afterFrames = (): Promise<void> => (abandon ? Promise.resolve() : Promise.race([this.frameChain, abandoned]));
367
+ let settled = false;
368
+ const settle = (): void => {
369
+ if (settled) return;
370
+ settled = true;
371
+ this.abandonDrain = undefined;
372
+ clearTimeout(this.timer);
373
+ this.signal?.removeEventListener("abort", this.onAbort);
374
+ // A consumer that failed while the last frames drained lost output, so the run cannot report success.
375
+ const final =
376
+ lateResult ??
377
+ (this.frameFailure !== undefined && result.ok
378
+ ? {
379
+ ok: false,
380
+ error: { kind: "sandbox", message: `Output consumer failed: ${this.frameFailure}` },
381
+ output: this.output,
382
+ calls: this.calls,
383
+ }
384
+ : result);
385
+ if (this.stream !== undefined) {
386
+ final.streamed = {
387
+ frames: this.frames,
388
+ maxInFlightBytes: this.maxInFlightBytes,
389
+ windowBytes: this.stream.windowBytes,
390
+ };
391
+ }
392
+ this.resolveResult(final);
393
+ };
394
+ if (!this.worker) {
395
+ void afterFrames().then(settle);
396
+ return;
397
+ }
398
+ Atomics.store(new Int32Array(this.interrupt), 0, 1);
399
+ // A worker sleeping on credits must wake to see the interrupt before terminate() can stop it.
400
+ if (this.credits !== undefined) Atomics.notify(this.credits, 0);
401
+ this.worker
402
+ .terminate()
403
+ .catch(() => undefined)
404
+ .then(afterFrames)
405
+ .then(settle);
406
+ // senpi-change end
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Runs JavaScript in a QuickJS VM (a separate wasm instance) inside a worker
412
+ * thread. The script sees `tools.<name>(args)` for every registered tool, `ALL_TOOLS`,
413
+ * the output helpers `text`, `image`, `exit`, and `console.*`, `store`/`load`, and the
414
+ * configured globals; nothing else (no timers, `fetch`, `process`, `require`, modules).
415
+ *
416
+ * Each `execute()` gets its own worker and VM; the sandbox only holds the tool
417
+ * table and defaults. `close()` aborts in-flight executions.
418
+ */
419
+ export class CodemodeSandbox {
420
+ private readonly toolsByName = new Map<string, CodemodeTool>();
421
+ private readonly globalsByName = new Map<string, CodemodeTool>();
422
+ private readonly timeoutMs: number;
423
+ private readonly memoryLimitBytes: number | undefined;
424
+ private readonly wasm: CodemodeWasmModule | Promise<CodemodeWasmModule> | undefined;
425
+ private readonly workerUrl: string | URL;
426
+ private readonly running = new Set<Execution>();
427
+ // senpi-change begin: output streaming and builtin policy
428
+ private readonly stream: StreamOptions | undefined;
429
+ private readonly storePolicy: "collect" | "reject";
430
+ // senpi-change end
431
+ private closed = false;
432
+
433
+ constructor(options: CodemodeSandboxOptions = {}) {
434
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
435
+ this.memoryLimitBytes = options.memoryLimitBytes;
436
+ this.wasm = options.wasm;
437
+ this.workerUrl = options.workerUrl ?? defaultWorkerUrl();
438
+ // senpi-change begin: output streaming and builtin policy
439
+ this.storePolicy = options.builtins?.store ?? "collect";
440
+ if (options.output === "stream") {
441
+ if (options.onOutputFrame === undefined) throw new Error('output: "stream" needs onOutputFrame');
442
+ const windowBytes = options.windowBytes ?? DEFAULT_WINDOW_BYTES;
443
+ const frameBytes = Math.min(options.frameBytes ?? DEFAULT_FRAME_BYTES, windowBytes);
444
+ // A frame must hold a whole surrogate pair (4 bytes), or the worker can't avoid splitting one.
445
+ if (!(windowBytes >= 4 && frameBytes >= 4)) throw new Error("windowBytes and frameBytes must be at least 4");
446
+ this.stream = { onOutputFrame: options.onOutputFrame, windowBytes, frameBytes };
447
+ }
448
+ // senpi-change end
449
+ for (const tool of options.tools ?? []) this.registerTool(tool);
450
+ const namespaces = new Set<string>();
451
+ for (const global of options.globals ?? []) {
452
+ const parts = global.name.split(".");
453
+ if (parts.length > 2 || !parts.every((part) => IDENTIFIER.test(part)) || RESERVED_GLOBALS.has(parts[0])) {
454
+ throw new Error(`Invalid global name "${global.name}"`);
455
+ }
456
+ if (this.globalsByName.has(global.name)) throw new Error(`Global "${global.name}" is already registered`);
457
+ if (parts.length === 2) namespaces.add(parts[0]);
458
+ this.globalsByName.set(global.name, global);
459
+ }
460
+ for (const name of namespaces) {
461
+ if (this.globalsByName.has(name)) throw new Error(`Global "${name}" conflicts with the namespace "${name}"`);
462
+ }
463
+ }
464
+
465
+ /** Throws if a tool with the same name is already registered. */
466
+ registerTool(tool: CodemodeTool): void {
467
+ if (this.toolsByName.has(tool.name)) throw new Error(`Tool "${tool.name}" is already registered`);
468
+ this.toolsByName.set(tool.name, tool);
469
+ }
470
+
471
+ unregisterTool(name: string): boolean {
472
+ return this.toolsByName.delete(name);
473
+ }
474
+
475
+ get tools(): CodemodeTool[] {
476
+ return [...this.toolsByName.values()];
477
+ }
478
+
479
+ get globals(): CodemodeTool[] {
480
+ return [...this.globalsByName.values()];
481
+ }
482
+
483
+ /**
484
+ * `code` is an async function body: `return` and top-level `await` work.
485
+ * Never rejects for script failures; those come back as `{ ok: false }`.
486
+ * The script can use `store(key, value)` and `load(key)` on `options.store`.
487
+ */
488
+ execute(code: string, options: CodemodeExecuteOptions = {}): Promise<CodemodeResult> {
489
+ if (this.closed) return Promise.reject(new Error("Sandbox is closed"));
490
+ const execution = new Execution({
491
+ code,
492
+ tools: new Map(this.toolsByName),
493
+ globals: this.globalsByName,
494
+ timeoutMs: options.timeoutMs ?? this.timeoutMs,
495
+ signal: options.signal,
496
+ memoryLimitBytes: this.memoryLimitBytes,
497
+ store: serializeStore(options.store),
498
+ wasm: this.wasm === undefined ? loadQuickJSWasm() : Promise.resolve(this.wasm),
499
+ workerUrl: this.workerUrl,
500
+ // senpi-change begin: output streaming and builtin policy
501
+ stream: this.stream,
502
+ storePolicy: this.storePolicy,
503
+ // senpi-change end
504
+ });
505
+ this.running.add(execution);
506
+ return execution.promise.finally(() => this.running.delete(execution));
507
+ }
508
+
509
+ /** Aborts in-flight executions (they resolve with `kind: "aborted"`) and rejects new ones. */
510
+ async close(): Promise<void> {
511
+ this.closed = true;
512
+ await Promise.all([...this.running].map((execution) => execution.abort("Sandbox closed")));
513
+ }
514
+ }