@code-yeongyu/senpi-codemode 2026.9.28 → 2026.9.29-3

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 (52) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +13 -1
  3. package/package.json +4 -4
  4. package/src/bridge/memory-protocol.ts +39 -0
  5. package/src/bridge/protocol.ts +18 -0
  6. package/src/bridge/reserved.ts +2 -0
  7. package/src/config/memory-settings.ts +149 -0
  8. package/src/config/settings.ts +8 -2
  9. package/src/extension/session-manager-proxy.ts +6 -0
  10. package/src/extension/session-manager.ts +27 -25
  11. package/src/host-sdk.ts +1 -1
  12. package/src/index.ts +4 -0
  13. package/src/kernels/jl/kernel.ts +4 -1
  14. package/src/kernels/js/context-manager.ts +43 -5
  15. package/src/kernels/js/inline-worker.ts +2 -1
  16. package/src/kernels/js/interrupt-bounds.ts +8 -2
  17. package/src/kernels/js/kernel-contract.ts +13 -0
  18. package/src/kernels/js/local-module-loader.ts +2 -0
  19. package/src/kernels/js/webview-host.ts +59 -0
  20. package/src/kernels/js/worker-core.js +32 -3
  21. package/src/kernels/js/worker-global-sizes.js +123 -0
  22. package/src/kernels/js/worker-heap.js +59 -0
  23. package/src/kernels/js/worker-host.ts +2 -1
  24. package/src/kernels/js/worker-memory.js +94 -0
  25. package/src/kernels/js/worker-runtime.js +7 -2
  26. package/src/kernels/js/worker-slot.ts +16 -5
  27. package/src/kernels/js/worker-startup.ts +1 -0
  28. package/src/kernels/js/worker-webview-client.js +79 -0
  29. package/src/kernels/js/worker-webview.js +204 -0
  30. package/src/kernels/py/kernel-contract.ts +3 -0
  31. package/src/kernels/py/kernel.ts +15 -2
  32. package/src/kernels/py/prelude.py +327 -11
  33. package/src/kernels/py/transport.ts +4 -1
  34. package/src/kernels/rb/kernel.ts +4 -1
  35. package/src/kernels/shared/kernel-memory-host.ts +63 -0
  36. package/src/kernels/shared/kernel-memory.ts +136 -0
  37. package/src/kernels/shared/subprocess-contract.ts +10 -1
  38. package/src/kernels/shared/subprocess-kernel.ts +23 -2
  39. package/src/kernels/shared/subprocess-queue.ts +2 -0
  40. package/src/output/streaming-output-buffer.ts +229 -12
  41. package/src/output/streaming-output.ts +2 -2
  42. package/src/prompt/eval-prompt.ts +1 -1
  43. package/src/skill/bun-1-4/references/runtime-apis.md +1 -0
  44. package/src/tool/cell-runtime.ts +52 -10
  45. package/src/tool/detached-cell-contract.ts +4 -0
  46. package/src/tool/detached-cell-manager.ts +16 -1
  47. package/src/tool/detached-cell-notification.ts +10 -1
  48. package/src/tool/image.ts +6 -2
  49. package/src/tool/run-eval-cell.ts +19 -17
  50. package/src/tool/settled-image-spill.ts +108 -0
  51. package/src/tool/terminal-snapshot-store.ts +82 -10
  52. package/src/tool/types.ts +10 -0
@@ -1,7 +1,14 @@
1
1
  import type { EvalStatusEvent, HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
- import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
2
+ import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP, MEMORY_COLLECTED_OP } from "../../bridge/reserved.ts";
3
3
  import type { KernelInterruptHandle } from "../../tool/types.ts";
4
- import { abandonedWorkerNote, awaitCooperativeSettlement, type WorkerRetirement } from "./interrupt-bounds.ts";
4
+ import { KernelMemoryPolicy } from "../shared/kernel-memory.ts";
5
+ import {
6
+ abandonedWorkerNote,
7
+ awaitCooperativeSettlement,
8
+ DEFAULT_INTERRUPT_BOUNDS,
9
+ type JavaScriptInterruptBounds,
10
+ type WorkerRetirement,
11
+ } from "./interrupt-bounds.ts";
5
12
  import {
6
13
  assertJavaScriptKernelOpen,
7
14
  type JavaScriptKernelMode,
@@ -53,9 +60,13 @@ export class JavaScriptKernel {
53
60
  #pendingToolCalls: ToolCallMessage[] = [];
54
61
  /** Live cell children the worker reported; retired by the host when the worker itself is lost. */
55
62
  readonly #childPids = new Set<number>();
63
+ readonly #memory: KernelMemoryPolicy | null;
64
+ readonly #interruptBounds: JavaScriptInterruptBounds;
56
65
 
57
66
  constructor(options: JavaScriptKernelOptions) {
58
67
  this.#options = options;
68
+ this.#interruptBounds = options.interruptBounds ?? DEFAULT_INTERRUPT_BOUNDS;
69
+ this.#memory = options.memory === undefined ? null : new KernelMemoryPolicy("js", options.memory);
59
70
  this.#moduleLoader = new LocalModuleLoader(options);
60
71
  this.#slot = new WorkerSlot(options, {
61
72
  isOpen: () => this.#lifecycle === "open",
@@ -226,12 +237,16 @@ export class JavaScriptKernel {
226
237
  run.interruptResult = { type: "result", cellId: run.input.cellId, ok: false, error: { message }, durationMs };
227
238
  run.interruptAck ??= Promise.withResolvers<void>();
228
239
  this.#slot.postMessage({ type: "interrupt", reason });
229
- if ((await awaitCooperativeSettlement(run)) === "settled") return { retained: run.settledByWorker };
240
+ if ((await awaitCooperativeSettlement(run, this.#interruptBounds)) === "settled") {
241
+ return { retained: run.settledByWorker };
242
+ }
230
243
  if (!this.#runs.releaseActive(run)) return { retained: run.settledByWorker };
231
244
  const retirement = await this.#terminate();
232
245
  this.#runs.settle(run, run.interruptResult ?? stoppedResult(run.input.cellId, message));
233
246
  void this.#recover(() => Promise.resolve());
234
- return retirement === "abandoned" ? { retained: false, note: abandonedWorkerNote() } : { retained: false };
247
+ return retirement === "abandoned"
248
+ ? { retained: false, note: abandonedWorkerNote(this.#interruptBounds.terminateDeadlineMs) }
249
+ : { retained: false };
235
250
  } finally {
236
251
  this.#clearToolCalls();
237
252
  }
@@ -275,6 +290,13 @@ export class JavaScriptKernel {
275
290
  this.#trackChildEvent(message.event);
276
291
  return;
277
292
  }
293
+ if (message.type === "status" && message.event.op === MEMORY_COLLECTED_OP) {
294
+ const liveBytes = message.event.liveBytes;
295
+ if (typeof liveBytes !== "number") return;
296
+ this.#memory?.observeLive(liveBytes);
297
+ this.#options.onMemoryCollected?.(liveBytes);
298
+ return;
299
+ }
278
300
  (this.#runs.active?.input.onMessage ?? this.#options.onMessage)?.(message);
279
301
  if (message.type === "tool-call") {
280
302
  const waiter = this.#toolWaiters.shift();
@@ -291,8 +313,21 @@ export class JavaScriptKernel {
291
313
  this.#clearTimeout();
292
314
  this.#runs.releaseActive(active);
293
315
  active.settledByWorker = true;
294
- this.#runs.settle(active, active.interruptResult ?? message);
316
+ this.#runs.settle(active, active.interruptResult ?? this.#withMemoryPolicy(message));
295
317
  this.#startNext();
318
+ this.#recycleOverCeilingWhenIdle();
319
+ }
320
+
321
+ #withMemoryPolicy(message: ResultMessage): ResultMessage {
322
+ if (this.#memory === null || message.memory === undefined) return message;
323
+ return { ...message, memory: this.#memory.annotate(message.memory) };
324
+ }
325
+
326
+ /** A kernel over its memory ceiling restarts only once no cell is running or queued on it. */
327
+ #recycleOverCeilingWhenIdle(): void {
328
+ if (this.#memory?.recyclePending !== true || this.#runs.active || this.#runs.hasWaiting) return;
329
+ this.#memory.recycleStarted();
330
+ void this.#restartAfterStop();
296
331
  }
297
332
 
298
333
  #handleCrash(error: Error): void {
@@ -338,6 +373,7 @@ export class JavaScriptKernel {
338
373
  async #terminate(): Promise<WorkerRetirement> {
339
374
  this.#clearTimeout();
340
375
  this.#kernelTools.rejectAll(kernelToolError("kernel_tool_stale", "JavaScript worker reset"));
376
+ this.#memory?.kernelRetired();
341
377
  const retirement = await this.#slot.retire();
342
378
  await this.#retireWorkerChildren();
343
379
  return retirement;
@@ -348,5 +384,7 @@ export class JavaScriptKernel {
348
384
  const pids = [...this.#childPids];
349
385
  this.#childPids.clear();
350
386
  await terminateProcessTrees(pids, { graceMs: WORKER_LOSS_CHILD_GRACE_MS, ownerPid: process.pid });
387
+ // The worker that owned these children and their exit watchers is gone, so nothing else will wait on them.
388
+ await this.#options.collectOrphanedChildren?.(pids);
351
389
  }
352
390
  }
@@ -1,5 +1,6 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath, pathToFileURL } from "node:url";
3
+ import type { MessagePort } from "node:worker_threads";
3
4
  import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
4
5
  import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
5
6
  import type { JavaScriptKernelMode } from "./kernel-contract.ts";
@@ -18,7 +19,7 @@ export function resolveInlineWorkerEntryUrl(options: JavaScriptInlineWorkerEntry
18
19
 
19
20
  export interface WorkerLike {
20
21
  readonly mode: JavaScriptKernelMode;
21
- postMessage(message: HostToKernelMessage): void;
22
+ postMessage(message: HostToKernelMessage, transfer?: readonly MessagePort[]): void;
22
23
  onMessage(handler: (message: KernelToHostMessage) => void): () => void;
23
24
  onError(handler: (error: Error) => void): () => void;
24
25
  terminate(): Promise<void>;
@@ -16,14 +16,20 @@ export interface CooperativeSettlementBounds {
16
16
  readonly graceMs: number;
17
17
  }
18
18
 
19
- const DEFAULT_SETTLEMENT_BOUNDS: CooperativeSettlementBounds = {
19
+ /** Every deadline an interrupt can hit; a kernel built with its own bounds chooses which stop path a slow cell takes. */
20
+ export interface JavaScriptInterruptBounds extends CooperativeSettlementBounds {
21
+ readonly terminateDeadlineMs: number;
22
+ }
23
+
24
+ export const DEFAULT_INTERRUPT_BOUNDS: JavaScriptInterruptBounds = {
20
25
  ackMs: INTERRUPT_ACK_MS,
21
26
  graceMs: JS_INTERRUPT_GRACE_MS,
27
+ terminateDeadlineMs: WORKER_TERMINATE_DEADLINE_MS,
22
28
  };
23
29
 
24
30
  export async function awaitCooperativeSettlement(
25
31
  run: PendingJavaScriptRun,
26
- bounds = DEFAULT_SETTLEMENT_BOUNDS,
32
+ bounds: CooperativeSettlementBounds = DEFAULT_INTERRUPT_BOUNDS,
27
33
  ): Promise<CooperativeSettlement> {
28
34
  if (run.settled) return "settled";
29
35
  const settled = run.settlement.then((): "settled" => "settled");
@@ -1,6 +1,8 @@
1
+ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
1
2
  import type { KernelToHostMessage } from "../../bridge/protocol.ts";
2
3
  import type { EvalKernelRunInput } from "../../tool/types.ts";
3
4
  import type { SessionEnvironment } from "../session-env.ts";
5
+ import type { JavaScriptInterruptBounds } from "./interrupt-bounds.ts";
4
6
 
5
7
  export type ResultMessage = Extract<KernelToHostMessage, { type: "result" }>;
6
8
  export type ToolCallMessage = Extract<KernelToHostMessage, { type: "tool-call" }>;
@@ -18,6 +20,11 @@ export function resolveKernelToolNameSource(names?: KernelToolNameSource): strin
18
20
  export interface JavaScriptKernelOptions {
19
21
  readonly sessionId: string;
20
22
  readonly cwd: string;
23
+ /**
24
+ * Collects the exit status of a retired worker's children once they are killed (#1962): no thread is left to
25
+ * wait on them. Absent, they stay zombies until the host's child reaper or process exit collects them.
26
+ */
27
+ readonly collectOrphanedChildren?: (pids: readonly number[]) => Promise<unknown>;
21
28
  readonly parallelPoolWidth: number;
22
29
  readonly onMessage?: (message: KernelToHostMessage) => void;
23
30
  readonly workerEntryUrl?: URL;
@@ -27,6 +34,12 @@ export interface JavaScriptKernelOptions {
27
34
  readonly hostToolNames?: KernelToolNameSource;
28
35
  /** Tool names registered in another kernel language, denied as JS kernel-tool identifiers. */
29
36
  readonly foreignLanguageNames?: KernelToolNameSource;
37
+ /** Post-cell collection, notice, and ceiling thresholds; absent leaves the worker's memory unmanaged. */
38
+ readonly memory?: KernelMemoryThresholds;
39
+ /** Live heap bytes measured by an idle collection the worker ran between cells. */
40
+ readonly onMemoryCollected?: (liveBytes: number) => void;
41
+ /** Interrupt acknowledgement, settle grace, and worker-termination deadlines; absent uses `DEFAULT_INTERRUPT_BOUNDS`. */
42
+ readonly interruptBounds?: JavaScriptInterruptBounds;
30
43
  }
31
44
 
32
45
  export type JavaScriptRunInput = EvalKernelRunInput;
@@ -77,6 +77,8 @@ function loaderPrelude(context: RuntimeModuleContext): string {
77
77
  "globalThis.__senpi_import__ = async (source, options) => {",
78
78
  " const context = globalThis.__senpi_module_context__;",
79
79
  " const specifier = String(source);",
80
+ " const kernelBun = globalThis[Symbol.for('senpi.kernel.bun')];",
81
+ " if (specifier === 'bun' && kernelBun) return { ...(await import('bun')), WebView: kernelBun.WebView, default: kernelBun };",
80
82
  " const match = /^([a-z][a-z0-9+.-]*):\\/\\/(.*)$/i.exec(specifier);",
81
83
  " let target = specifier;",
82
84
  " if (match) {",
@@ -0,0 +1,59 @@
1
+ import type { MessagePort } from "node:worker_threads";
2
+ import type { WebViewServiceConnection } from "@code-yeongyu/senpi";
3
+ import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
4
+ import { bridgeError } from "./worker-host.ts";
5
+
6
+ export type WebViewConnector = () => Promise<WebViewServiceConnection>;
7
+
8
+ // Loaded on the first WebView a cell asks for, so starting a kernel never pays for the host barrel.
9
+ const connectThroughHost: WebViewConnector = async () => (await import("@code-yeongyu/senpi")).connectWebViewService();
10
+
11
+ /**
12
+ * The WebView clients one worker generation asked for. Chrome-backed `Bun.WebView`s only run on the
13
+ * process main thread, so the worker gets a private port to the main-thread service; retiring the
14
+ * generation (reset, close, crash, timeout) releases every view created through those ports.
15
+ */
16
+ export class KernelWebViewClients {
17
+ readonly #post: (message: HostToKernelMessage, transfer: readonly MessagePort[]) => void;
18
+ readonly #connect: WebViewConnector;
19
+ readonly #connections = new Set<WebViewServiceConnection>();
20
+ #retired = false;
21
+
22
+ constructor(
23
+ post: (message: HostToKernelMessage, transfer: readonly MessagePort[]) => void,
24
+ connect: WebViewConnector = connectThroughHost,
25
+ ) {
26
+ this.#post = post;
27
+ this.#connect = connect;
28
+ }
29
+
30
+ consume(message: KernelToHostMessage): boolean {
31
+ if (message.type !== "webview-connect") return false;
32
+ void this.#grant(message.requestId);
33
+ return true;
34
+ }
35
+
36
+ async release(): Promise<void> {
37
+ this.#retired = true;
38
+ const connections = [...this.#connections];
39
+ this.#connections.clear();
40
+ await Promise.allSettled(connections.map((connection) => connection.release()));
41
+ }
42
+
43
+ async #grant(requestId: string): Promise<void> {
44
+ let connection: WebViewServiceConnection;
45
+ try {
46
+ connection = await this.#connect();
47
+ } catch (error) {
48
+ const cause = error instanceof Error ? error : new Error(String(error));
49
+ if (!this.#retired) this.#post({ type: "webview-port", requestId, ok: false, error: bridgeError(cause) }, []);
50
+ return;
51
+ }
52
+ if (this.#retired) {
53
+ await connection.release();
54
+ return;
55
+ }
56
+ this.#connections.add(connection);
57
+ this.#post({ type: "webview-port", requestId, ok: true, port: connection.port }, [connection.port]);
58
+ }
59
+ }
@@ -2,11 +2,14 @@ import { kernelToolCallContext } from "./kernel-tools-context.js";
2
2
  import { kernelToolError } from "./kernel-tools-errors.js";
3
3
  import { createKernelToolPump } from "./kernel-tools-pump.js";
4
4
  import { hostDeniedError, hostToolRefusal } from "./kernel-tools-scope.js";
5
+ import { createWorkerMemory } from "./worker-memory.js";
5
6
  import { JsWorkerRuntime } from "./worker-runtime.js";
7
+ import { installKernelWebView } from "./worker-webview.js";
6
8
 
7
- // Mirrors INTERRUPT_ACK_OP and CHILD_LIFECYCLE_OP in src/bridge/reserved.ts (this worker file cannot import TypeScript).
9
+ // Mirrors INTERRUPT_ACK_OP, CHILD_LIFECYCLE_OP, and MEMORY_COLLECTED_OP in src/bridge/reserved.ts (this worker file cannot import TypeScript).
8
10
  const INTERRUPT_ACK_OP = "interrupt-ack";
9
11
  const CHILD_LIFECYCLE_OP = "child";
12
+ const MEMORY_COLLECTED_OP = "memory-collected";
10
13
 
11
14
  // Mirrors SESSION_ENVIRONMENT_KEYS in src/kernels/session-env.ts (this worker file
12
15
  // cannot import TypeScript). Keys the active session does not set must be dropped so a
@@ -23,8 +26,10 @@ const SESSION_ENVIRONMENT_KEYS = [
23
26
 
24
27
  export function createWorkerCore(transport, options) {
25
28
  let runtime = null;
29
+ let memory = null;
26
30
  let activeCell = null;
27
31
  const pendingTools = new Map();
32
+ const pendingWebViewPorts = new Map();
28
33
  const nestedInvokes = new Map();
29
34
  const kernelTools = createKernelToolPump({
30
35
  getRuntime: () => runtime,
@@ -48,14 +53,18 @@ export function createWorkerCore(transport, options) {
48
53
  emit,
49
54
  callTool: async (toolName, args) => await callTool(toolName, args),
50
55
  });
51
- emit({ type: "result", cellId: message.cellId, ok: true, valueRepr: valueRepr(value), durationMs: durationMs(startedAtMs) });
56
+ emit({ type: "result", cellId: message.cellId, ok: true, valueRepr: valueRepr(value), durationMs: durationMs(startedAtMs), ...memoryReport() });
52
57
  } catch (error) {
53
- emit({ type: "result", cellId: message.cellId, ok: false, error: bridgeError(error), durationMs: durationMs(startedAtMs) });
58
+ emit({ type: "result", cellId: message.cellId, ok: false, error: bridgeError(error), durationMs: durationMs(startedAtMs), ...memoryReport() });
54
59
  } finally {
55
60
  activeCell = null;
56
61
  }
57
62
  }
58
63
 
64
+ function memoryReport() {
65
+ return memory === null ? {} : { memory: memory.afterCell() };
66
+ }
67
+
59
68
  async function callTool(toolName, args) {
60
69
  const nested = kernelToolCallContext.getStore();
61
70
  if (!nested && activeCell?.interruption) throw activeCell.interruption;
@@ -73,6 +82,13 @@ export function createWorkerCore(transport, options) {
73
82
  return await promise;
74
83
  }
75
84
 
85
+ function requestWebViewPort() {
86
+ const requestId = crypto.randomUUID();
87
+ const promise = new Promise((resolve, reject) => pendingWebViewPorts.set(requestId, { resolve, reject }));
88
+ emit({ type: "webview-connect", requestId });
89
+ return promise;
90
+ }
91
+
76
92
  function interruptCell(reason) {
77
93
  if (!activeCell || !runtime) return;
78
94
  emit({ type: "status", event: { op: INTERRUPT_ACK_OP, cellId: activeCell.cellId } });
@@ -87,13 +103,22 @@ export function createWorkerCore(transport, options) {
87
103
  }
88
104
 
89
105
  function onMessage(message) {
106
+ if (message.type === "run" || message.type === "interrupt" || message.type === "close") memory?.cancelIdle();
90
107
  if (kernelTools.handle(message)) return;
91
108
  if (message.type === "kernel-tools-names") {
92
109
  runtime?.kernelTools.setCollisionNames(message.hostToolNames ?? [], message.foreignLanguageNames ?? []);
93
110
  return;
94
111
  }
112
+ if (message.type === "webview-port") {
113
+ const pending = pendingWebViewPorts.get(message.requestId);
114
+ pendingWebViewPorts.delete(message.requestId);
115
+ if (message.ok) pending?.resolve(message.port);
116
+ else pending?.reject(errorFromBridge(message.error));
117
+ return;
118
+ }
95
119
  if (message.type === "init") {
96
120
  applySessionEnvironment(message.sessionEnv);
121
+ installKernelWebView(requestWebViewPort);
97
122
  runtime = new JsWorkerRuntime({
98
123
  cwd: options.cwd,
99
124
  parallelPoolWidth: options.parallelPoolWidth,
@@ -104,6 +129,10 @@ export function createWorkerCore(transport, options) {
104
129
  foreignLanguageNames: message.foreignLanguageNames ?? [],
105
130
  onChildEvent: (event) => emit({ type: "status", event: { op: CHILD_LIFECYCLE_OP, ...event } }),
106
131
  });
132
+ if (message.memory) {
133
+ memory = createWorkerMemory(message.memory, (report) => emit({ type: "status", event: { op: MEMORY_COLLECTED_OP, ...report } }));
134
+ memory.captureBaseline();
135
+ }
107
136
  emit({ type: "ready" });
108
137
  return;
109
138
  }
@@ -0,0 +1,123 @@
1
+ import { types } from "node:util";
2
+
3
+ const SAMPLED_ELEMENTS = 1_000;
4
+ const NODE_BUDGET = 200_000;
5
+ const MAX_DEPTH = 64;
6
+ const POINTER_BYTES = 8;
7
+ const OBJECT_BYTES = 16;
8
+ const FUNCTION_BYTES = 64;
9
+ const INTERNAL_GLOBAL_PREFIX = "__senpi";
10
+ const MIN_REPORTED_BYTES = 1024 * 1024;
11
+
12
+ export function captureGlobalBaseline() {
13
+ return new Set(Object.getOwnPropertyNames(globalThis));
14
+ }
15
+
16
+ /**
17
+ * Estimated retained size of each user global, largest first. Collections are sized from their length and
18
+ * a sample of up to 1,000 evenly spaced elements, so a huge array is not under-reported; a shared visited
19
+ * set counts shared objects once, and one node budget bounds the whole walk. A sampled or cut-short
20
+ * estimate is marked `approximate`. Accessors and proxies are never invoked.
21
+ */
22
+ export function largestGlobals(baseline, limit) {
23
+ const sizer = createSizer();
24
+ const sized = [];
25
+ for (const name of Object.getOwnPropertyNames(globalThis)) {
26
+ if (baseline.has(name) || name.startsWith(INTERNAL_GLOBAL_PREFIX)) continue;
27
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, name);
28
+ if (descriptor === undefined || !("value" in descriptor)) continue;
29
+ const measured = sizer.measure(descriptor.value);
30
+ if (measured === undefined) continue;
31
+ const { bytes, approximate } = measured;
32
+ sized.push({ name, bytes: Math.round(bytes), ...(approximate ? { approximate: true } : {}) });
33
+ }
34
+ return sized
35
+ .filter((global) => global.bytes >= MIN_REPORTED_BYTES)
36
+ .sort((left, right) => right.bytes - left.bytes)
37
+ .slice(0, limit);
38
+ }
39
+
40
+ function createSizer() {
41
+ const seen = new WeakSet();
42
+ let nodes = 0;
43
+ let approximate = false;
44
+
45
+ function sampled(length, at, depth) {
46
+ if (length <= SAMPLED_ELEMENTS) {
47
+ let total = 0;
48
+ for (let index = 0; index < length; index += 1) total += size(at(index), depth);
49
+ return total;
50
+ }
51
+ approximate = true;
52
+ const step = length / SAMPLED_ELEMENTS;
53
+ let total = 0;
54
+ for (let sample = 0; sample < SAMPLED_ELEMENTS; sample += 1) total += size(at(Math.floor(sample * step)), depth);
55
+ return (total / SAMPLED_ELEMENTS) * length;
56
+ }
57
+
58
+ function entriesOf(iterable, count, depth, sizeOf) {
59
+ const taken = [];
60
+ for (const entry of iterable) {
61
+ taken.push(entry);
62
+ if (taken.length >= SAMPLED_ELEMENTS) break;
63
+ }
64
+ if (taken.length < count) approximate = true;
65
+ let total = 0;
66
+ for (const entry of taken) total += sizeOf(entry, depth);
67
+ return total * (count / Math.max(1, taken.length));
68
+ }
69
+
70
+ function objectSize(value, depth) {
71
+ if (types.isProxy(value)) return OBJECT_BYTES;
72
+ if (ArrayBuffer.isView(value)) return value.byteLength;
73
+ if (types.isAnyArrayBuffer(value)) return value.byteLength;
74
+ if (typeof Blob === "function" && value instanceof Blob) return value.size;
75
+ if (Array.isArray(value)) return OBJECT_BYTES + value.length * POINTER_BYTES + sampled(value.length, (index) => value[index], depth);
76
+ if (value instanceof Map) {
77
+ const entries = Map.prototype.entries.call(value);
78
+ return OBJECT_BYTES + value.size * 2 * POINTER_BYTES + entriesOf(entries, value.size, depth, ([key, item], at) => size(key, at) + size(item, at));
79
+ }
80
+ if (value instanceof Set) return OBJECT_BYTES + value.size * POINTER_BYTES + entriesOf(Set.prototype.values.call(value), value.size, depth, size);
81
+ const keys = Object.keys(value);
82
+ const propertyValue = (index) => {
83
+ const descriptor = Object.getOwnPropertyDescriptor(value, keys[index]);
84
+ return descriptor !== undefined && "value" in descriptor ? descriptor.value : undefined;
85
+ };
86
+ return OBJECT_BYTES + keys.length * POINTER_BYTES + sampled(keys.length, propertyValue, depth);
87
+ }
88
+
89
+ // Bytes a value owns beyond the pointer-sized slot its container already charged for it.
90
+ function size(value, depth) {
91
+ switch (typeof value) {
92
+ case "string":
93
+ return OBJECT_BYTES + value.length * 2;
94
+ case "function":
95
+ return FUNCTION_BYTES;
96
+ case "object":
97
+ break;
98
+ default:
99
+ return 0;
100
+ }
101
+ if (value === null || seen.has(value)) return 0;
102
+ if (depth >= MAX_DEPTH || nodes >= NODE_BUDGET) {
103
+ approximate = true;
104
+ return 0;
105
+ }
106
+ seen.add(value);
107
+ nodes += 1;
108
+ return objectSize(value, depth + 1);
109
+ }
110
+
111
+ return {
112
+ measure(value) {
113
+ approximate = false;
114
+ try {
115
+ return { bytes: POINTER_BYTES + size(value, 0), approximate };
116
+ } catch (error) {
117
+ // A user value can still throw from an exotic element read (an index getter); it is left unsized.
118
+ if (error instanceof Error) return undefined;
119
+ throw error;
120
+ }
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,59 @@
1
+ import { getHeapStatistics, setFlagsFromString } from "node:v8";
2
+ import { runInNewContext } from "node:vm";
3
+
4
+ // Bun 1.4.3 ships its own idle collections (oven-sh/bun#43174, #43681), run by every JS thread including
5
+ // Workers; the kernel's idle collection only fills that gap on older Bun and must not compete with it.
6
+ const BUN_WITHOUT_IDLE_COLLECTIONS = "<1.4.3";
7
+
8
+ /**
9
+ * Reads and collects this worker's own heap (a worker thread owns its VM on both runtimes).
10
+ *
11
+ * Bun: `heapStats()` refreshes only at collections and walks the whole heap, and `Bun.gc(false)` only
12
+ * requests an asynchronous collection, so the estimate runs a synchronous eden collection (`edenGC()`, a
13
+ * few ms) that accounts fresh ArrayBuffers, then reads `heapSize()` (it includes `extraMemorySize`). Node: the isolate's used heap plus external memory (ArrayBuffer backing stores); `Worker`
14
+ * rejects `--expose-gc` in `execArgv`, so the gc function comes from one new context created while the
15
+ * flag is briefly on, and the second call releases backing stores V8 frees one cycle late.
16
+ */
17
+ export function createHeapProbe() {
18
+ const jsc = typeof globalThis.Bun?.gc === "function" ? process.getBuiltinModule?.("bun:jsc") : undefined;
19
+ if (jsc !== undefined && typeof jsc.heapSize === "function" && typeof jsc.edenGC === "function") {
20
+ return {
21
+ canCollect: true,
22
+ idleCollection: globalThis.Bun.semver.satisfies(globalThis.Bun.version, BUN_WITHOUT_IDLE_COLLECTIONS),
23
+ estimate() {
24
+ jsc.edenGC();
25
+ return jsc.heapSize();
26
+ },
27
+ collect() {
28
+ globalThis.Bun.gc(true);
29
+ return jsc.heapSize();
30
+ },
31
+ };
32
+ }
33
+ const gc = exposedGc();
34
+ const read = () => {
35
+ const stats = getHeapStatistics();
36
+ return stats.used_heap_size + (stats.external_memory ?? 0);
37
+ };
38
+ return {
39
+ canCollect: gc !== undefined,
40
+ idleCollection: gc !== undefined,
41
+ estimate: read,
42
+ collect() {
43
+ gc?.();
44
+ gc?.();
45
+ return read();
46
+ },
47
+ };
48
+ }
49
+
50
+ function exposedGc() {
51
+ if (typeof globalThis.gc === "function") return globalThis.gc;
52
+ setFlagsFromString("--expose-gc");
53
+ try {
54
+ const gc = runInNewContext("typeof gc === 'function' ? gc : undefined");
55
+ return typeof gc === "function" ? gc : undefined;
56
+ } finally {
57
+ setFlagsFromString("--no-expose-gc");
58
+ }
59
+ }
@@ -88,7 +88,8 @@ export function bridgeError(error: Error): {
88
88
  function wrapNodeWorker(worker: Worker, mode: JavaScriptKernelMode): WorkerLike {
89
89
  return {
90
90
  mode,
91
- postMessage: (message) => worker.postMessage(message),
91
+ postMessage: (message, transfer) =>
92
+ worker.postMessage(message, transfer === undefined ? undefined : [...transfer]),
92
93
  onMessage(handler) {
93
94
  const listener = (message: KernelToHostMessage): void => handler(message);
94
95
  worker.on("message", listener);
@@ -0,0 +1,94 @@
1
+ import { captureGlobalBaseline, largestGlobals } from "./worker-global-sizes.js";
2
+ import { createHeapProbe } from "./worker-heap.js";
3
+
4
+ const MIB = 1024 * 1024;
5
+ const MIN_GROWTH_BYTES = 64 * MIB;
6
+ const GROWTH_RATIO = 0.25;
7
+ const NOTICE_GROWTH_RATIO = 1.25;
8
+ const IDLE_COLLECT_DELAY_MS = 1_000;
9
+ // A full collection costs at most 1/20 of the time since the previous one.
10
+ const COLLECT_RATE_FLOOR = 20;
11
+ const REPORTED_GLOBALS = 5;
12
+
13
+ /**
14
+ * Post-cell memory policy of one JS kernel worker (thresholds come from `init`):
15
+ * - a settled cell whose heap estimate reached the ceiling, grew past the watermark by more than
16
+ * max(64 MiB, 25%) since the last measurement, or crossed the notice line upward (or grew 25% past it)
17
+ * gets a synchronous full collection before its result is sent, and the result reports the live size;
18
+ * - after any cell that leaves at least the watermark live, an idle full collection runs about a second
19
+ * later unless the next run, interrupt, or close arrives first: a cell that only drops a global
20
+ * allocates nothing, so nothing else would ever notice the garbage. Runtimes that collect idle
21
+ * threads themselves (Bun 1.4.3+) skip this; the post-cell measurement above still runs everywhere.
22
+ */
23
+ export function createWorkerMemory(thresholds, onIdleCollected, { heap = createHeapProbe(), now = () => performance.now() } = {}) {
24
+ let baseline = new Set();
25
+ let lastLive = 0;
26
+ let noticeRef = 0;
27
+ let lastCollectEnd = Number.NEGATIVE_INFINITY;
28
+ let lastCollectMs = 0;
29
+ let idleTimer = null;
30
+
31
+ function collect() {
32
+ const startedAt = now();
33
+ const live = heap.collect();
34
+ lastCollectEnd = now();
35
+ lastCollectMs = lastCollectEnd - startedAt;
36
+ lastLive = live;
37
+ if (live < thresholds.noticeBytes / 2) noticeRef = 0;
38
+ return live;
39
+ }
40
+
41
+ function needsCollection(estimate) {
42
+ const { gcWatermarkBytes, noticeBytes, ceilingBytes } = thresholds;
43
+ if (ceilingBytes > 0 && estimate >= ceilingBytes) return true;
44
+ if (gcWatermarkBytes > 0 && estimate >= gcWatermarkBytes) {
45
+ if (estimate > lastLive + Math.max(MIN_GROWTH_BYTES, lastLive * GROWTH_RATIO)) return true;
46
+ }
47
+ if (noticeBytes === 0 || estimate < noticeBytes) return false;
48
+ return lastLive < noticeBytes || noticeRef === 0 || estimate >= noticeRef * NOTICE_GROWTH_RATIO;
49
+ }
50
+
51
+ function worthNaming(live) {
52
+ const { noticeBytes, ceilingBytes } = thresholds;
53
+ return (noticeBytes > 0 && live >= noticeBytes) || (ceilingBytes > 0 && live >= ceilingBytes);
54
+ }
55
+
56
+ function cancelIdle() {
57
+ if (idleTimer !== null) clearTimeout(idleTimer);
58
+ idleTimer = null;
59
+ }
60
+
61
+ function scheduleIdle() {
62
+ if (!heap.idleCollection || thresholds.gcWatermarkBytes === 0 || lastLive < thresholds.gcWatermarkBytes) return;
63
+ const floorEnd = lastCollectEnd + COLLECT_RATE_FLOOR * lastCollectMs;
64
+ const delayMs = Math.max(IDLE_COLLECT_DELAY_MS, floorEnd - now());
65
+ idleTimer = setTimeout(() => {
66
+ idleTimer = null;
67
+ onIdleCollected({ liveBytes: Math.round(collect()), measure: "heap" });
68
+ }, delayMs);
69
+ idleTimer.unref?.();
70
+ }
71
+
72
+ return {
73
+ captureBaseline() {
74
+ baseline = captureGlobalBaseline();
75
+ },
76
+ cancelIdle,
77
+ afterCell() {
78
+ cancelIdle();
79
+ let liveBytes = heap.estimate();
80
+ const gcRan = heap.canCollect && needsCollection(liveBytes);
81
+ if (gcRan) liveBytes = collect();
82
+ const noticeLevel = gcRan && thresholds.noticeBytes > 0 && liveBytes >= thresholds.noticeBytes;
83
+ if (noticeLevel && (noticeRef === 0 || liveBytes >= noticeRef * NOTICE_GROWTH_RATIO)) noticeRef = liveBytes;
84
+ const globals = gcRan && worthNaming(liveBytes) ? largestGlobals(baseline, REPORTED_GLOBALS) : [];
85
+ scheduleIdle();
86
+ return {
87
+ liveBytes: Math.round(liveBytes),
88
+ measure: "heap",
89
+ ...(gcRan ? { gcRan: true } : {}),
90
+ ...(globals.length > 0 ? { globals } : {}),
91
+ };
92
+ },
93
+ };
94
+ }
@@ -6,12 +6,17 @@ import { encodeDisplayImage, resolveDisplayOps } from "./display-image.js";
6
6
  import { terminateProcessTrees } from "./process-tree.js";
7
7
  import { awaitMaybePromise, indirectEval, wrapUserCode } from "./worker-indirect-eval.js";
8
8
  import { installShellCapture } from "./worker-shell-capture.js";
9
+ import { bindKernelBun } from "./worker-webview.js";
9
10
  import { createWorkpool } from "./workpool.js";
10
11
  import { inKernelToolInvoke } from "./kernel-tools-context.js";
11
12
  import { kernelToolError } from "./kernel-tools-errors.js";
12
13
  import { createKernelToolRegistry, createToolNamespace } from "./kernel-tools-registry.js";
13
14
 
14
15
  const PREPARED_CELL_PREFIX = "/*senpi:prepared-cell*/";
16
+ // One stable source URL for the loader/contribution prelude: a per-cell URL makes
17
+ // every cell a distinct eval source string, so the code cache gains one entry
18
+ // per cell for text that is identical until contributions change.
19
+ const PRELUDE_SOURCE_URL = "senpi:kernel-prelude";
15
20
  // How long a child gets to honour SIGTERM before SIGKILL. Short, because the
16
21
  // cell has already produced its value and the caller is waiting on settle.
17
22
  const CHILD_TERMINATION_GRACE_MS = 1_000;
@@ -56,8 +61,8 @@ export class JsWorkerRuntime {
56
61
  if (!isPlainObject(prepared) || typeof prepared.prelude !== "string" || typeof prepared.code !== "string") throw new Error("Invalid prepared JavaScript cell payload");
57
62
  ({ prelude, code: cellCode } = prepared);
58
63
  }
59
- if (prelude) indirectEval(prelude, `${cellId}:prelude`);
60
- const value = await awaitMaybePromise(indirectEval(wrapUserCode(cellCode), cellId));
64
+ if (prelude) indirectEval(prelude, PRELUDE_SOURCE_URL);
65
+ const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)), cellId));
61
66
  await this.#drainPendingDisplays();
62
67
  return value;
63
68
  } finally {