@code-yeongyu/senpi-codemode 2026.10.10-9 → 2026.10.11

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 (49) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/package.json +4 -4
  3. package/src/bridge/memory-protocol.ts +8 -1
  4. package/src/bridge/protocol.ts +1 -1
  5. package/src/bridges/eval-environment-schemas.ts +5 -1
  6. package/src/environments/editable-check.ts +1 -1
  7. package/src/extension/session-cwd.ts +25 -9
  8. package/src/extension/session-manager.ts +4 -4
  9. package/src/kernels/jl/globals.jl +128 -0
  10. package/src/kernels/jl/prelude.jl +15 -2
  11. package/src/kernels/jl/runner.jl +35 -143
  12. package/src/kernels/js/group-signal-notice.js +117 -0
  13. package/src/kernels/js/inline-worker.ts +2 -0
  14. package/src/kernels/js/local-module-loader.ts +8 -0
  15. package/src/kernels/js/process-tree-host.ts +4 -8
  16. package/src/kernels/js/process-tree.d.ts +11 -0
  17. package/src/kernels/js/process-tree.js +55 -10
  18. package/src/kernels/js/windows-tree-kill-host.ts +117 -0
  19. package/src/kernels/js/windows-tree-kill.d.ts +19 -0
  20. package/src/kernels/js/windows-tree-kill.js +100 -0
  21. package/src/kernels/js/worker-cwd.js +41 -10
  22. package/src/kernels/js/worker-runtime.js +23 -4
  23. package/src/kernels/js/worker-shell-capture.js +4 -0
  24. package/src/kernels/js/worker-slot.ts +2 -3
  25. package/src/kernels/py/prelude.py +36 -25
  26. package/src/kernels/py/transport.ts +4 -0
  27. package/src/kernels/rb/runner.rb +9 -19
  28. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
  29. package/src/kernels/shared/bun-ffi.ts +24 -0
  30. package/src/kernels/shared/kernel-memory-host.ts +30 -7
  31. package/src/kernels/shared/process-cpu.ts +7 -3
  32. package/src/kernels/shared/process-group-cpu.ts +6 -3
  33. package/src/kernels/shared/subprocess-kernel.ts +29 -3
  34. package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
  35. package/src/kernels/shared/subprocess-process.ts +20 -12
  36. package/src/kernels/shared/subprocess-queue.ts +1 -0
  37. package/src/tool/eval-tool.ts +2 -1
  38. package/src/tool/render-agent.ts +110 -0
  39. package/src/tool/render-blocks.ts +252 -0
  40. package/src/tool/render-call.ts +130 -0
  41. package/src/tool/render-cell.ts +165 -0
  42. package/src/tool/render-detail.ts +152 -0
  43. package/src/tool/render-live.ts +288 -0
  44. package/src/tool/render-result.ts +202 -0
  45. package/src/tool/render-status.ts +166 -0
  46. package/src/tool/render-tail.ts +139 -0
  47. package/src/tool/render.ts +3 -1128
  48. package/src/tool/run-eval-cell.ts +7 -12
  49. package/src/tool/types.ts +11 -2
@@ -5,8 +5,9 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
5
  import { dirname, isAbsolute, join, normalize, resolve, sep } from "node:path";
6
6
  import { inspect } from "node:util";
7
7
  import { encodeDisplayImage, resolveDisplayOps } from "./display-image.js";
8
- import { terminateProcessTrees } from "./process-tree.js";
8
+ import { terminateProcessGroups, terminateProcessTrees } from "./process-tree.js";
9
9
  import { awaitMaybePromise, indirectEval, wrapUserCode } from "./worker-indirect-eval.js";
10
+ import { INJECTED_GROUP } from "./worker-cwd.js";
10
11
  import { installShellCapture } from "./worker-shell-capture.js";
11
12
  import { bindKernelBun } from "./worker-webview.js";
12
13
  import { createWorkpool } from "./workpool.js";
@@ -33,6 +34,9 @@ export class JsWorkerRuntime {
33
34
  #hooks = null;
34
35
  #pendingDisplays = [];
35
36
  #children = new Set();
37
+ // Groups the worker created for cell children (senpi#2995). A group outlives its leader, so a grandchild re-parented
38
+ // to init is still found through it at retirement (senpi#3020).
39
+ #ownedGroups = new Map();
36
40
  #childrenStopping;
37
41
  #shellWaits = new Set();
38
42
  #onChildEvent;
@@ -111,9 +115,14 @@ export class JsWorkerRuntime {
111
115
  #trackChild(child, spawnOptions) {
112
116
  if (child === null || typeof child !== "object" || typeof child.kill !== "function") return;
113
117
  // `detached: true` is the cell saying it wants the process to outlive it.
114
- if (isPlainObject(spawnOptions) && spawnOptions.detached === true) return;
118
+ const groupInjected = (globalThis[INJECTED_GROUP] ?? 0) > 0;
119
+ if (isPlainObject(spawnOptions) && spawnOptions.detached === true && !groupInjected) return;
115
120
  this.#children.add(child);
116
121
  const pid = Number.isInteger(child.pid) && child.pid > 0 ? child.pid : null;
122
+ // Bun.spawn children get their group from worker-cwd.js without the injection marker, so every tracked child is
123
+ // recorded; a child that is not a group leader names no group, and signalling it later finds nothing.
124
+ const ownGroupLeader = !(isPlainObject(spawnOptions) && spawnOptions.detached === false);
125
+ if (pid !== null && process.platform !== "win32" && ownGroupLeader) this.#ownedGroups.set(pid, child);
117
126
  // The host keeps its own copy of live pids: if this worker is terminated
118
127
  // while blocked, only the host can still retire them.
119
128
  if (pid !== null) this.#onChildEvent?.({ pid, state: "spawned" });
@@ -127,10 +136,20 @@ export class JsWorkerRuntime {
127
136
  #terminateChildren() {
128
137
  const children = [...this.#children].filter(child => child.exitCode === null && child.signalCode === null);
129
138
  this.#children.clear();
130
- if (children.length === 0) return undefined;
139
+ // A leader that already exited leaves its group id reserved only while the group has members; if that pid now
140
+ // names a live process, it was reused by someone else and the group is not ours to signal (senpi#3020 review).
141
+ const groups = [...this.#ownedGroups].map(([pgid, child]) => ({
142
+ pgid,
143
+ leaderExited: child.exitCode !== null || child.signalCode !== null,
144
+ }));
145
+ this.#ownedGroups.clear();
146
+ if (children.length === 0 && groups.length === 0) return undefined;
131
147
  const roots = children.map(child => child.pid).filter(pid => Number.isInteger(pid) && pid > 0);
132
148
  const settled = Promise.allSettled(children.map(child => (child.exited instanceof Promise ? child.exited : Promise.resolve())));
133
- return terminateProcessTrees(roots, { graceMs: CHILD_TERMINATION_GRACE_MS, settled });
149
+ return Promise.all([
150
+ roots.length > 0 ? terminateProcessTrees(roots, { graceMs: CHILD_TERMINATION_GRACE_MS, settled }) : undefined,
151
+ terminateProcessGroups(groups, { graceMs: CHILD_TERMINATION_GRACE_MS }),
152
+ ]);
134
153
  }
135
154
 
136
155
  async #drainPendingDisplays() {
@@ -1,4 +1,5 @@
1
1
  import { assertCellLive } from "./cell-run-context.js";
2
+ import { groupSignalNotice, noticeChildProcessGroupSignals, shellCommandText, signalsProcessGroup } from "./group-signal-notice.js";
2
3
 
3
4
  const SHELL_CONFIG_METHODS = ["env", "cwd", "nothrow", "throws"];
4
5
  const SHELL_READ_METHODS = ["text", "json", "lines", "arrayBuffer", "bytes", "blob"];
@@ -28,7 +29,9 @@ export function installShellCapture(options) {
28
29
  bun.$ = capturedShell(originalShell, options);
29
30
  bun.spawn = capturedSpawn(originalSpawn, options, pinEnv);
30
31
  if (originalSpawnSync !== null) bun.spawnSync = capturedSpawnSync(originalSpawnSync, pinEnv);
32
+ const restoreChildProcessNotice = noticeChildProcessGroupSignals(options.emitText, options.isActive);
31
33
  return () => {
34
+ restoreChildProcessNotice();
32
35
  bun.$ = originalShell;
33
36
  bun.spawn = originalSpawn;
34
37
  if (originalSpawnSync !== null) bun.spawnSync = originalSpawnSync;
@@ -43,6 +46,7 @@ function capturedShell(originalShell, options) {
43
46
  const shell = (strings, ...expressions) => {
44
47
  assertCellLive();
45
48
  if (!options.isActive()) return originalShell(strings, ...expressions);
49
+ if (signalsProcessGroup(shellCommandText(strings, expressions))) options.emitText("stderr", groupSignalNotice("Bun.$"));
46
50
  const promise = originalShell(isolateStdin(strings), ...expressions);
47
51
  return captureShellPromise(promise, options);
48
52
  };
@@ -4,7 +4,6 @@ import type { WorkerLike } from "./inline-worker.ts";
4
4
  import { retireWorker, type WorkerRetirement } from "./interrupt-bounds.ts";
5
5
  import type { JavaScriptKernelMode } from "./kernel-contract.ts";
6
6
  import type { JavaScriptKernelOptions } from "./local-module-loader.ts";
7
- import type { JavaScriptProcessWorker } from "./process-worker.ts";
8
7
  import { KernelWebViewClients } from "./webview-host.ts";
9
8
  import { WorkerStartupCancelledError } from "./worker-host.ts";
10
9
  import { startWorkerWithInlineFallback } from "./worker-startup.ts";
@@ -38,7 +37,7 @@ export class WorkerSlot {
38
37
 
39
38
  get processPid(): number | undefined {
40
39
  const worker = this.#worker;
41
- if (worker?.mode === "process") return (worker as JavaScriptProcessWorker).pid;
40
+ if (worker?.mode === "process") return worker.pid;
42
41
  return this.#lastProcessPid;
43
42
  }
44
43
 
@@ -113,7 +112,7 @@ export class WorkerSlot {
113
112
  #publish(worker: WorkerLike, generation: number): void {
114
113
  if (!this.#listeners.isOpen() || generation !== this.#generation) throw new WorkerStartupCancelledError();
115
114
  this.#worker = worker;
116
- if (worker.mode === "process") this.#lastProcessPid = (worker as JavaScriptProcessWorker).pid;
115
+ if (worker.mode === "process") this.#lastProcessPid = worker.pid;
117
116
  const webViews = new KernelWebViewClients((message, transfer) => worker.postMessage(message, transfer));
118
117
  this.#webViews = webViews;
119
118
  worker.onMessage((message) => {
@@ -661,25 +661,6 @@ class ThreadRoutedStream(io.TextIOBase):
661
661
  )
662
662
  self.cell_target: io.StringIO | None = None
663
663
 
664
- @contextlib.contextmanager
665
- def capture(self, buffer: io.StringIO, *, cell: bool) -> Iterator[None]:
666
- me = threading.get_ident()
667
- previous = self._targets.get(me)
668
- self._targets[me] = buffer
669
- inherited = self._inherited.set(buffer)
670
- if cell:
671
- self.cell_target = buffer
672
- try:
673
- yield
674
- finally:
675
- self._inherited.reset(inherited)
676
- if previous is None:
677
- self._targets.pop(me, None)
678
- else:
679
- self._targets[me] = previous
680
- if cell:
681
- self.cell_target = None
682
-
683
664
  def write(self, text: str) -> int:
684
665
  target = self._targets.get(threading.get_ident()) or self._inherited.get() or self.cell_target
685
666
  if target is None:
@@ -694,6 +675,38 @@ class ThreadRoutedStream(io.TextIOBase):
694
675
  return None
695
676
 
696
677
 
678
+ @contextlib.contextmanager
679
+ def capture_streams(
680
+ streams: tuple[ThreadRoutedStream, ThreadRoutedStream],
681
+ buffers: tuple[io.StringIO, io.StringIO],
682
+ *,
683
+ cell: bool,
684
+ ) -> Iterator[None]:
685
+ stdout, stderr = streams
686
+ out, err = buffers
687
+ me = threading.get_ident()
688
+ previous_out, previous_err = stdout._targets.get(me), stderr._targets.get(me)
689
+ stdout._targets[me], stderr._targets[me] = out, err
690
+ inherited_out, inherited_err = stdout._inherited.set(out), stderr._inherited.set(err)
691
+ if cell:
692
+ stdout.cell_target, stderr.cell_target = out, err
693
+ try:
694
+ yield
695
+ finally:
696
+ stderr._inherited.reset(inherited_err)
697
+ stdout._inherited.reset(inherited_out)
698
+ if previous_err is None:
699
+ stderr._targets.pop(me, None)
700
+ else:
701
+ stderr._targets[me] = previous_err
702
+ if previous_out is None:
703
+ stdout._targets.pop(me, None)
704
+ else:
705
+ stdout._targets[me] = previous_out
706
+ if cell:
707
+ stdout.cell_target, stderr.cell_target = None, None
708
+
709
+
697
710
  @dataclass
698
711
  class _Entry:
699
712
  name: str
@@ -905,8 +918,7 @@ class KernelToolRunner:
905
918
  if invocation.cancelled.is_set():
906
919
  raise Cancelled(CANCELLED_BEFORE_START)
907
920
  CURRENT.set(invocation)
908
- stdout, stderr = self._streams
909
- with stdout.capture(output, cell=False), stderr.capture(output, cell=False):
921
+ with capture_streams(self._streams, (output, output), cell=False):
910
922
  value = self._call(invocation, entry.fn, args)
911
923
  finally:
912
924
  CURRENT.set(None)
@@ -2218,8 +2230,7 @@ def run_cell(
2218
2230
  cell_scope = CURRENT_CELL_TOKEN.set(cell_token)
2219
2231
  try:
2220
2232
  KERNEL_TOOL_TOKEN.acquire()
2221
- cell_stdout, cell_stderr = KERNEL_TOOL_STREAMS
2222
- with cell_stdout.capture(stdout, cell=True), cell_stderr.capture(stderr, cell=True):
2233
+ with capture_streams(KERNEL_TOOL_STREAMS, (stdout, stderr), cell=True):
2223
2234
  apply_preludes(preludes)
2224
2235
  leave_source_file = _enter_source_file(source_file)
2225
2236
  body, expression = compile_cell(code, source_file or "<cell>")
@@ -2373,7 +2384,7 @@ def _start_parent_watch() -> None:
2373
2384
  ).start()
2374
2385
 
2375
2386
 
2376
- def _read_control(commands: queue.Queue[tuple[str, Any]]) -> None:
2387
+ def _read_control(commands: queue.SimpleQueue[tuple[str, Any]]) -> None:
2377
2388
  # Kernel-tool frames are served here, so a callback can be admitted while the main thread is inside a cell.
2378
2389
  for raw in sys.stdin:
2379
2390
  try:
@@ -2392,7 +2403,7 @@ def main() -> None:
2392
2403
  signal.signal(signal.SIGINT, signal.SIG_IGN)
2393
2404
  _start_parent_watch()
2394
2405
  sys.stdout, sys.stderr = KERNEL_TOOL_STREAMS
2395
- commands: queue.Queue[tuple[str, Any]] = queue.Queue()
2406
+ commands: queue.SimpleQueue[tuple[str, Any]] = queue.SimpleQueue()
2396
2407
  threading.Thread(target=_read_control, args=(commands,), name="senpi-control-reader", daemon=True).start()
2397
2408
  host_closed = False
2398
2409
  while True:
@@ -244,6 +244,10 @@ export class PythonKernelTransport {
244
244
  this.#child.stderr.on("data", onStderr);
245
245
  this.#child.on("error", onError);
246
246
  this.#child.on("exit", onExit);
247
+ // senpi#3016: a frame written while the interpreter is dying fails on the stdin stream (EPIPE), not on the child.
248
+ // #onError already ignores it once the child has exited or the transport is inactive, and the listener is never
249
+ // detached, so a late write failure is never unhandled.
250
+ this.#child.stdin.on("error", onError);
247
251
  const { sessionId, connection, memory, kernelGeneration } = this.#options;
248
252
  this.#write({
249
253
  type: "init",
@@ -9,6 +9,7 @@ $stdout.flush
9
9
  $__senpi_binding = TOPLEVEL_BINDING
10
10
  $__senpi_frame_mutex = Mutex.new
11
11
  $__senpi_current_cell = nil
12
+ $__senpi_memory_cell = nil
12
13
  $__senpi_capture_cell = nil
13
14
  $__senpi_connection = nil
14
15
  $__senpi_frame_io = STDOUT.dup
@@ -123,8 +124,6 @@ rescue JSON::GeneratorError
123
124
  value.inspect
124
125
  end
125
126
 
126
- $__senpi_memory = nil
127
-
128
127
  SENPI_SIZER_SAMPLE = 1_000
129
128
  SENPI_SIZER_NODE_BUDGET = 5_000
130
129
  SENPI_SIZER_MAX_DEPTH = 64
@@ -239,19 +238,6 @@ rescue StandardError
239
238
  []
240
239
  end
241
240
 
242
- # The host reads the interpreter's footprint and decides on the notice; the runner only names its
243
- # largest globals. Nothing here spawns a process, so the user's `$?` is left alone.
244
- def __senpi_memory_report
245
- return nil if $__senpi_memory.nil?
246
- named = __senpi_largest_globals(5)
247
- # The host replaces liveBytes with the interpreter footprint it reads itself; only globals is used.
248
- report = { "liveBytes" => 0, "measure" => "footprint" }
249
- report["globals"] = named unless named.empty?
250
- report
251
- rescue StandardError
252
- nil
253
- end
254
-
255
241
  SENPI_NON_DISPLAY_NODES = %i[
256
242
  LASGN IASGN GASGN CVASGN DASGN OP_ASGN OP_CDECL CDECL MASGN CASGN
257
243
  DEFN DEFS CLASS MODULE SCLASS ALIAS UNDEF
@@ -305,8 +291,6 @@ def __senpi_run_cell(message)
305
291
  "durationMs" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
306
292
  }
307
293
  frame["valueRepr"] = __senpi_value_repr(value) if !value.nil? && __senpi_should_display_result?(source)
308
- memory = __senpi_memory_report
309
- frame["memory"] = memory unless memory.nil?
310
294
  __senpi_emit(frame)
311
295
  rescue Exception => error
312
296
  __senpi_emit({
@@ -319,6 +303,7 @@ def __senpi_run_cell(message)
319
303
  ensure
320
304
  $__senpi_capture_cell = nil
321
305
  $__senpi_current_cell = nil
306
+ $__senpi_memory_cell = cell_id
322
307
  end
323
308
  end
324
309
 
@@ -332,10 +317,15 @@ $__senpi_protocol_stdin.each_line do |line|
332
317
  when "init"
333
318
  __senpi_emit({ "type" => "status", "event" => { "op" => "kernel-startup", "stage" => "host-init" } })
334
319
  $__senpi_connection = message["connection"]
335
- $__senpi_memory = message["memory"]
336
- __senpi_emit({ "type" => "ready" })
320
+ __senpi_emit({ "type" => "ready", "memoryGlobals" => true })
337
321
  when "run"
322
+ $__senpi_memory_cell = nil
338
323
  __senpi_run_cell(message)
324
+ when "memory-globals"
325
+ if message["cellId"] == $__senpi_memory_cell && $__senpi_current_cell.nil?
326
+ __senpi_emit({ "type" => "memory-globals-result", "cellId" => message["cellId"], "globals" => __senpi_largest_globals(5) })
327
+ $__senpi_memory_cell = nil
328
+ end
339
329
  when "close"
340
330
  __senpi_emit({ "type" => "closed" })
341
331
  exit 0
@@ -115,7 +115,7 @@ class Execution {
115
115
  private finished = false;
116
116
  // senpi-change begin: output streaming
117
117
  private readonly stream: StreamOptions | undefined;
118
- private readonly credits: Int32Array | undefined;
118
+ private readonly credits: Int32Array<SharedArrayBuffer> | undefined;
119
119
  private frameChain: Promise<void> = Promise.resolve();
120
120
  private frames = 0;
121
121
  private inFlightBytes = 0;
@@ -188,7 +188,7 @@ class Execution {
188
188
  // senpi-change begin: output streaming and builtin policy
189
189
  ...(this.credits === undefined || this.stream === undefined
190
190
  ? {}
191
- : { stream: { credits: this.credits.buffer as SharedArrayBuffer, frameBytes: this.stream.frameBytes } }),
191
+ : { stream: { credits: this.credits.buffer, frameBytes: this.stream.frameBytes } }),
192
192
  storePolicy: options.storePolicy,
193
193
  // senpi-change end
194
194
  };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The one read of `bun:ffi` behind a guard: `process.getBuiltinModule` returns the module on Bun and
3
+ * `undefined` on Node, so this module loads on either runtime, and only a result carrying the callable
4
+ * surface the CPU readers bind is treated as FFI - the same boundary `display-js.ts` uses for `Bun`.
5
+ */
6
+ export type BunFfi = {
7
+ readonly dlopen: <S>(path: string, definitions: Record<string, unknown>) => { readonly symbols: S };
8
+ readonly FFIType: Record<string, number>;
9
+ readonly ptr: (buffer: ArrayBufferView) => number;
10
+ };
11
+
12
+ /** True when `value` carries the `bun:ffi` surface the kernel CPU readers use (`dlopen`, `FFIType`, `ptr`). */
13
+ export function isBunFfi(value: unknown): value is BunFfi {
14
+ if (typeof value !== "object" || value === null) return false;
15
+ if (!("dlopen" in value) || typeof value.dlopen !== "function") return false;
16
+ if (!("ptr" in value) || typeof value.ptr !== "function") return false;
17
+ return "FFIType" in value && typeof value.FFIType === "object" && value.FFIType !== null;
18
+ }
19
+
20
+ /** The runtime's `bun:ffi` module, or `undefined` on Node and on any runtime whose builtin is not one. */
21
+ export function loadBunFfi(): BunFfi | undefined {
22
+ const ffi: unknown = process.getBuiltinModule("bun:ffi");
23
+ return isBunFfi(ffi) ? ffi : undefined;
24
+ }
@@ -1,4 +1,4 @@
1
- import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
1
+ import type { KernelMemoryReport, KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
2
2
  import type { KernelToHostMessage } from "../../bridge/protocol.ts";
3
3
  import type { EvalLanguage } from "../../tool/types.ts";
4
4
  import { KernelMemoryPolicy } from "./kernel-memory.ts";
@@ -11,7 +11,7 @@ export type FootprintReader = (pid: number) => { readonly bytes: number } | unde
11
11
  export interface KernelMemoryHostOptions {
12
12
  /**
13
13
  * Host-measured kernel (rb, jl): the host reads the interpreter's footprint after each result and
14
- * replaces the runner's live reading with it; the runner still names its largest globals, so the
14
+ * replaces the runner's live reading with it; the runner names its largest globals on demand, so the
15
15
  * notice and the ceiling both apply, with every reading counting (no collection precedes it).
16
16
  */
17
17
  readonly readFootprint?: FootprintReader;
@@ -36,12 +36,35 @@ export class KernelMemoryHost {
36
36
  }
37
37
 
38
38
  annotate(result: ResultMessage, pid?: number): ResultMessage {
39
+ return this.annotateReport(result, this.readReport(result, pid));
40
+ }
41
+
42
+ readReport(result: ResultMessage, pid?: number): KernelMemoryReport | undefined {
39
43
  const runnerReport = result.memory;
40
- const report =
41
- this.#readFootprint === undefined
42
- ? runnerReport
43
- : this.#footprintReport(this.#readFootprint, pid, runnerReport);
44
- return report === undefined ? result : { ...result, memory: this.#policy.annotate(report) };
44
+ return this.#readFootprint === undefined
45
+ ? runnerReport
46
+ : this.#footprintReport(this.#readFootprint, pid, runnerReport);
47
+ }
48
+
49
+ needsGlobals(report: KernelMemoryReport): boolean {
50
+ const { noticeBytes, ceilingBytes } = this.#policy.thresholds;
51
+ // Collect at or above the host-read notice threshold or ceiling, regardless of notice hysteresis;
52
+ // never collect below both thresholds. Zero disables a threshold, not the lazy diagnostic gate.
53
+ return (
54
+ (noticeBytes > 0 && report.liveBytes >= noticeBytes) || (ceilingBytes > 0 && report.liveBytes >= ceilingBytes)
55
+ );
56
+ }
57
+
58
+ annotateReport(result: ResultMessage, report: KernelMemoryReport | undefined): ResultMessage {
59
+ if (report === undefined) return result;
60
+ const memory = this.#policy.annotate(report);
61
+ // Safety accounting still observes every cell, but ordinary host-measured results carry no payload.
62
+ // A recycle announcement remains visible even when the replacement process is below threshold.
63
+ if (this.#readFootprint !== undefined && !this.needsGlobals(report) && memory.notice === undefined) {
64
+ const { memory: _memory, ...ordinary } = result;
65
+ return ordinary;
66
+ }
67
+ return { ...result, memory };
45
68
  }
46
69
 
47
70
  /**
@@ -8,9 +8,9 @@
8
8
  * Windows has no process groups; this is the interpreter itself when it is launched directly.
9
9
  * - anything else, or Windows under Node: `undefined`, and the caller treats the CPU as unknown.
10
10
  */
11
+ import { type BunFfi, loadBunFfi } from "./bun-ffi.ts";
11
12
  import { readProcessGroupCpuTime } from "./process-group-cpu.ts";
12
13
 
13
- type BunFfi = typeof import("bun:ffi");
14
14
  type CpuReader = (pid: number) => bigint | undefined;
15
15
 
16
16
  const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
@@ -26,7 +26,7 @@ export function readKernelCpuTime(pid: number): bigint | undefined {
26
26
  }
27
27
 
28
28
  function createWindowsReader(): CpuReader | undefined {
29
- const ffi = process.getBuiltinModule("bun:ffi") as BunFfi | undefined;
29
+ const ffi = loadBunFfi();
30
30
  if (ffi === undefined) return undefined;
31
31
  try {
32
32
  return bindGetProcessTimes(ffi);
@@ -37,7 +37,11 @@ function createWindowsReader(): CpuReader | undefined {
37
37
  }
38
38
 
39
39
  function bindGetProcessTimes({ dlopen, FFIType, ptr }: BunFfi): CpuReader {
40
- const library = dlopen("kernel32.dll", {
40
+ const library = dlopen<{
41
+ OpenProcess: (access: number, inheritHandle: number, pid: number) => number | null;
42
+ GetProcessTimes: (handle: number, create: number, exit: number, kernel: number, user: number) => number;
43
+ CloseHandle: (handle: number) => number;
44
+ }>("kernel32.dll", {
41
45
  OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.ptr },
42
46
  GetProcessTimes: {
43
47
  args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr],
@@ -15,8 +15,8 @@
15
15
  */
16
16
  import { execFileSync } from "node:child_process";
17
17
  import { readdirSync, readFileSync } from "node:fs";
18
+ import { type BunFfi, loadBunFfi } from "./bun-ffi.ts";
18
19
 
19
- type BunFfi = typeof import("bun:ffi");
20
20
  type GroupCpuReader = (pgid: number) => bigint | undefined;
21
21
 
22
22
  const RUSAGE_INFO_V2 = 2;
@@ -59,7 +59,7 @@ export function parsePsCpuTime(text: string): bigint | undefined {
59
59
  function createGroupReader(): GroupCpuReader | undefined {
60
60
  if (process.platform === "linux") return linuxGroupReader;
61
61
  if (process.platform !== "darwin") return undefined;
62
- const ffi = process.getBuiltinModule("bun:ffi") as BunFfi | undefined;
62
+ const ffi = loadBunFfi();
63
63
  if (ffi === undefined) return darwinPsGroupReader;
64
64
  try {
65
65
  return darwinGroupReader(ffi);
@@ -116,7 +116,10 @@ function linuxGroupReader(pgid: number): bigint | undefined {
116
116
  }
117
117
 
118
118
  function darwinGroupReader({ dlopen, FFIType, ptr }: BunFfi): GroupCpuReader {
119
- const library = dlopen("libSystem.B.dylib", {
119
+ const library = dlopen<{
120
+ proc_listpgrppids: (pgid: number, buffer: number, byteLength: number) => number;
121
+ proc_pid_rusage: (pid: number, flavor: number, buffer: number) => number;
122
+ }>("libSystem.B.dylib", {
120
123
  proc_listpgrppids: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
121
124
  proc_pid_rusage: { args: [FFIType.i32, FFIType.i32, FFIType.ptr], returns: FFIType.i32 },
122
125
  });
@@ -1,3 +1,4 @@
1
+ // allow: SIZE_OK — subprocess protocol, FIFO ownership, retirement, and replacement form one serialized state machine.
1
2
  import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
3
  import { decodeBridgeFrame, encodeBridgeFrame, isKernelToHostMessage } from "../../bridge/protocol.ts";
3
4
  import type { KernelInterruptHandle, PendingCell } from "../../tool/types.ts";
@@ -7,6 +8,7 @@ import { applySessionEnvironment } from "../session-env.ts";
7
8
  import { describeExit } from "./kernel-death.ts";
8
9
  import { KernelMemoryHost } from "./kernel-memory-host.ts";
9
10
  import type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts";
11
+ import { SubprocessMemoryGlobals } from "./subprocess-memory-globals.ts";
10
12
  import { type SubprocessLike, SubprocessProcess, type SubprocessSpawn, spawnSubprocess } from "./subprocess-process.ts";
11
13
  import { SubprocessRunQueue } from "./subprocess-queue.ts";
12
14
  import {
@@ -39,6 +41,7 @@ export class SubprocessKernel {
39
41
  private readonly onMessage?: (message: KernelToHostMessage) => void;
40
42
  private readonly runs = new SubprocessRunQueue();
41
43
  private readonly memory: KernelMemoryHost | null;
44
+ private readonly globals = new SubprocessMemoryGlobals();
42
45
  private process: SubprocessProcess | null = null;
43
46
  private processReady = false;
44
47
  private startup: SubprocessStartupWatchdog | null = null;
@@ -90,8 +93,10 @@ export class SubprocessKernel {
90
93
  this.runs.settleAll(new CellInterruptedError(reason));
91
94
  return { stateRetained: Promise.resolve(true) };
92
95
  }
96
+ if (this.globals.completeWithoutGlobals()) return { stateRetained: Promise.resolve(true) };
93
97
  const process = this.process;
94
98
  process?.retire();
99
+ this.globals.reset();
95
100
  this.runs.clearToolCalls();
96
101
  const run = this.runs.active;
97
102
  if (!run) return { stateRetained: Promise.resolve(true) };
@@ -126,6 +131,7 @@ export class SubprocessKernel {
126
131
 
127
132
  async reset(): Promise<void> {
128
133
  if (this.closed) throw new KernelClosedError();
134
+ this.globals.reset();
129
135
  this.settleAll(new KernelResetError());
130
136
  this.runs.clearToolCalls();
131
137
  await this.restartProcess(this.process);
@@ -135,6 +141,7 @@ export class SubprocessKernel {
135
141
  async close(): Promise<void> {
136
142
  const wasClosed = this.closed;
137
143
  this.closed = true;
144
+ this.globals.reset();
138
145
  this.startup?.stop();
139
146
  if (!wasClosed) {
140
147
  this.settleAll(new KernelClosingError());
@@ -175,6 +182,7 @@ export class SubprocessKernel {
175
182
  if (this.runs.active !== run || run.settled) return;
176
183
  const process = this.process;
177
184
  process?.retire();
185
+ this.globals.reset();
178
186
  this.runs.clearToolCalls();
179
187
  this.runs.releaseActive(run);
180
188
  this.runs.settle(run, timeoutResult(run, timeoutMs));
@@ -199,6 +207,7 @@ export class SubprocessKernel {
199
207
  },
200
208
  });
201
209
  this.process = process;
210
+ this.globals.reset();
202
211
  this.processReady = false;
203
212
  this.startup?.stop();
204
213
  const startup = this.options.startup;
@@ -237,6 +246,7 @@ export class SubprocessKernel {
237
246
  if (!this.accepts(process)) return;
238
247
  if (!this.processReady) this.startup?.observe(message);
239
248
  if (message.type === "ready") {
249
+ this.globals.reset(message.memoryGlobals === true);
240
250
  this.startup?.stop();
241
251
  this.processReady = true;
242
252
  this.runs.handleMessage(message, this.onMessage);
@@ -248,9 +258,23 @@ export class SubprocessKernel {
248
258
  this.failClosed(new KernelStartupError(message.error.message));
249
259
  return;
250
260
  }
251
- const ownResult = message.type === "result" && this.runs.active?.input.cellId === message.cellId;
252
- const settled = ownResult && this.memory ? this.memory.annotate(message, process.child.pid) : message;
253
- if (!this.runs.handleMessage(settled, this.onMessage)) return;
261
+ const run = this.runs.active;
262
+ if (message.type === "memory-globals-result") {
263
+ if (this.memory) this.globals.reply({ process, run }, message, this.memory);
264
+ return;
265
+ }
266
+ if (message.type === "result" && run?.input.cellId === message.cellId && this.memory) {
267
+ this.globals.request({ process, run, result: message }, this.memory, (result) => {
268
+ if (!this.accepts(process) || this.runs.active !== run || run.settled) return;
269
+ this.settleMessage(result);
270
+ });
271
+ return;
272
+ }
273
+ this.settleMessage(message);
274
+ }
275
+
276
+ private settleMessage(message: KernelToHostMessage): void {
277
+ if (!this.runs.handleMessage(message, this.onMessage)) return;
254
278
  this.pumpRuns();
255
279
  this.recycleOverCeilingWhenIdle();
256
280
  }
@@ -270,6 +294,7 @@ export class SubprocessKernel {
270
294
  return;
271
295
  }
272
296
  this.process = null;
297
+ this.globals.reset();
273
298
  this.processReady = false;
274
299
  const error = new KernelExitedError(signal ?? code ?? "unknown");
275
300
  if (this.options.onDeath) this.die(error, describeExit(code, signal), this.options.onDeath);
@@ -362,6 +387,7 @@ export class SubprocessKernel {
362
387
 
363
388
  private failClosed(error: Error): void {
364
389
  this.startup?.stop();
390
+ this.globals.reset();
365
391
  const process = this.process;
366
392
  this.failure = error;
367
393
  this.closed = true;
@@ -0,0 +1,101 @@
1
+ import type { KernelMemoryReport } from "../../bridge/memory-protocol.ts";
2
+ import { encodeBridgeFrame, type KernelToHostMessage } from "../../bridge/protocol.ts";
3
+ import type { KernelMemoryHost } from "./kernel-memory-host.ts";
4
+ import type { KernelResult } from "./subprocess-contract.ts";
5
+ import type { SubprocessProcess } from "./subprocess-process.ts";
6
+ import type { PendingRun } from "./subprocess-run.ts";
7
+
8
+ const GLOBALS_TIMEOUT_MS = 5_000;
9
+ type GlobalsProcess = Pick<SubprocessProcess, "send"> & {
10
+ readonly child: { readonly pid?: number };
11
+ };
12
+
13
+ interface PendingGlobals {
14
+ readonly process: GlobalsProcess;
15
+ readonly run: PendingRun;
16
+ readonly result: KernelResult;
17
+ readonly report: KernelMemoryReport;
18
+ readonly memory: KernelMemoryHost;
19
+ readonly finish: (result: KernelResult) => void;
20
+ readonly timer: ReturnType<typeof setTimeout>;
21
+ }
22
+
23
+ /** Holds FIFO cell ownership until its bounded diagnostic is returned or its deadline expires. */
24
+ export class SubprocessMemoryGlobals {
25
+ #pending: PendingGlobals | null = null;
26
+ #available = false;
27
+
28
+ reset(available = false): void {
29
+ if (this.#pending !== null) clearTimeout(this.#pending.timer);
30
+ this.#pending = null;
31
+ this.#available = available;
32
+ }
33
+
34
+ request(
35
+ owner: {
36
+ readonly process: GlobalsProcess;
37
+ readonly run: PendingRun;
38
+ readonly result: KernelResult;
39
+ },
40
+ memory: KernelMemoryHost,
41
+ finish: (result: KernelResult) => void,
42
+ ): void {
43
+ if (this.#pending !== null) return;
44
+ const report = memory.readReport(owner.result, owner.process.child.pid);
45
+ if (!this.#available || report === undefined || !memory.needsGlobals(report)) {
46
+ finish(memory.annotateReport(owner.result, report));
47
+ return;
48
+ }
49
+ const pending: PendingGlobals = {
50
+ ...owner,
51
+ report,
52
+ memory,
53
+ finish,
54
+ timer: setTimeout(() => {
55
+ if (this.#pending !== pending) return;
56
+ this.#pending = null;
57
+ pending.finish(memory.annotateReport(pending.result, report));
58
+ }, GLOBALS_TIMEOUT_MS),
59
+ };
60
+ this.#pending = pending;
61
+ owner.process.send(
62
+ encodeBridgeFrame({
63
+ type: "memory-globals",
64
+ cellId: owner.result.cellId,
65
+ }),
66
+ );
67
+ }
68
+
69
+ /** Settles an already-finished cell immediately when stop arrives during its optional diagnostic. */
70
+ completeWithoutGlobals(): boolean {
71
+ const pending = this.#pending;
72
+ if (pending === null) return false;
73
+ clearTimeout(pending.timer);
74
+ this.#pending = null;
75
+ pending.finish(pending.memory.annotateReport(pending.result, pending.report));
76
+ return true;
77
+ }
78
+
79
+ reply(
80
+ owner: {
81
+ readonly process: GlobalsProcess;
82
+ readonly run: PendingRun | null;
83
+ },
84
+ message: Extract<KernelToHostMessage, { type: "memory-globals-result" }>,
85
+ memory: KernelMemoryHost,
86
+ ): void {
87
+ const pending = this.#pending;
88
+ if (
89
+ pending === null ||
90
+ pending.process !== owner.process ||
91
+ pending.run !== owner.run ||
92
+ pending.result.cellId !== message.cellId
93
+ )
94
+ return;
95
+ clearTimeout(pending.timer);
96
+ this.#pending = null;
97
+ const report = message.globals.length === 0 ? pending.report : { ...pending.report, globals: message.globals };
98
+ const result = memory.annotateReport(pending.result, report);
99
+ pending.finish(result);
100
+ }
101
+ }