@code-yeongyu/senpi-codemode 2026.10.4 → 2026.10.6

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 (42) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +27 -2
  3. package/package.json +4 -4
  4. package/src/bridge/kernel-tools-protocol.ts +1 -1
  5. package/src/bridge/protocol.ts +11 -0
  6. package/src/config/feature-settings.ts +110 -0
  7. package/src/config/memory-settings.ts +8 -0
  8. package/src/config/settings-constants.ts +7 -0
  9. package/src/config/settings-overrides.ts +72 -0
  10. package/src/config/settings.ts +35 -78
  11. package/src/config/unknown-settings.ts +19 -0
  12. package/src/extension/bridge-tool-call.ts +37 -0
  13. package/src/extension/idle-parking-kernel.ts +272 -0
  14. package/src/extension/kernel-registration.ts +25 -0
  15. package/src/extension/kernel-replacement.ts +230 -0
  16. package/src/extension/kernel-tools-probe.ts +24 -0
  17. package/src/extension/session-manager.ts +49 -38
  18. package/src/kernels/jl/kernel.ts +6 -2
  19. package/src/kernels/jl/runner.jl +154 -0
  20. package/src/kernels/js/context-manager.ts +1 -1
  21. package/src/kernels/js/kernel-tools-types.ts +2 -1
  22. package/src/kernels/js/worker-core.js +1 -0
  23. package/src/kernels/js/worker-global-sizes.js +84 -14
  24. package/src/kernels/py/kernel-contract.ts +2 -1
  25. package/src/kernels/py/kernel.ts +48 -8
  26. package/src/kernels/py/prelude.py +6 -1
  27. package/src/kernels/py/transport.ts +25 -4
  28. package/src/kernels/rb/kernel.ts +6 -2
  29. package/src/kernels/rb/runner.rb +141 -0
  30. package/src/kernels/session-env.ts +3 -0
  31. package/src/kernels/shared/kernel-death.ts +39 -0
  32. package/src/kernels/shared/kernel-memory-host.ts +14 -6
  33. package/src/kernels/{js/kernel-tools-host.ts → shared/kernel-tools-pump.ts} +2 -2
  34. package/src/kernels/shared/process-group-cpu.ts +139 -0
  35. package/src/kernels/shared/subprocess-contract.ts +5 -1
  36. package/src/kernels/shared/subprocess-kernel.ts +66 -3
  37. package/src/kernels/shared/subprocess-queue.ts +5 -0
  38. package/src/kernels/shared/subprocess-startup.ts +90 -0
  39. package/src/tool/cell-runtime.ts +17 -4
  40. package/src/tool/detached-cell-notification.ts +13 -2
  41. package/src/tool/run-eval-cell.ts +5 -6
  42. package/src/tool/types.ts +14 -0
@@ -1,7 +1,8 @@
1
1
  // allow: SIZE_OK — one persistent Python lifecycle state machine owns queue, generation, and retirement.
2
- import type { KernelInterruptHandle } from "../../tool/types.ts";
2
+ import type { KernelInterruptHandle, PendingCell } from "../../tool/types.ts";
3
3
  import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts";
4
4
  import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
5
+ import { describeExit } from "../shared/kernel-death.ts";
5
6
  import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
6
7
  import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
7
8
  import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, ResultMessage } from "./kernel-contract.ts";
@@ -27,6 +28,7 @@ export class PythonKernel {
27
28
  readonly #memory: KernelMemoryHost | null;
28
29
  #generation = 0;
29
30
  #closed = false;
31
+ #dead = false;
30
32
 
31
33
  private constructor(options: PythonKernelStartOptions) {
32
34
  this.#options = options;
@@ -43,6 +45,18 @@ export class PythonKernel {
43
45
  return [];
44
46
  }
45
47
 
48
+ isAlive(): boolean {
49
+ return !this.#dead;
50
+ }
51
+
52
+ drainPending(): readonly PendingCell[] {
53
+ return [...this.#queue].map((pending) => {
54
+ this.#pending.delete(pending.input.cellId);
55
+ this.#removePending(pending);
56
+ return { input: pending.input, settle: pending.resolve };
57
+ });
58
+ }
59
+
46
60
  describeKernelTools(_names: readonly string[]): Promise<never> {
47
61
  return rejectKernelToolsUnavailable();
48
62
  }
@@ -54,6 +68,7 @@ export class PythonKernel {
54
68
  run(input: PythonKernelRunOptions): Promise<ResultMessage> {
55
69
  if (this.#failure) return Promise.reject(this.#failure);
56
70
  if (this.#closed) return Promise.reject(new Error("Python kernel is closed"));
71
+ if (this.#dead) return Promise.reject(new Error("Python kernel died"));
57
72
  return new Promise<ResultMessage>((resolve, reject) => {
58
73
  const pending: PendingRun = { input, resolve, reject, startedAt: null, timeoutTimer: null };
59
74
  this.#pending.set(input.cellId, pending);
@@ -156,6 +171,7 @@ export class PythonKernel {
156
171
  #startNext(): void {
157
172
  if (
158
173
  this.#closed ||
174
+ this.#dead ||
159
175
  this.#failure ||
160
176
  this.#active ||
161
177
  this.#starting ||
@@ -234,11 +250,11 @@ export class PythonKernel {
234
250
  isOwned: () => !this.#closed && generation === this.#generation,
235
251
  onRetirementFailure: (transport, error) => {
236
252
  if (!this.#transport) this.#transport = transport;
237
- this.#recordFailure(error);
253
+ this.#recoverWhenGone(transport, this.#recordFailure(error));
238
254
  },
239
255
  onResult: (transport, result) => this.#onResult(transport, result),
240
256
  onError: (transport, error) => this.#onError(transport, error),
241
- onExit: (transport, error) => this.#onExit(transport, error),
257
+ onExit: (transport, error, exit) => this.#onExit(transport, error, describeExit(exit.code, exit.signal)),
242
258
  });
243
259
  }
244
260
 
@@ -262,15 +278,37 @@ export class PythonKernel {
262
278
  void this.reset().catch(() => undefined);
263
279
  }
264
280
 
265
- #onExit(transport: PythonKernelTransport, error: Error): void {
281
+ #onExit(transport: PythonKernelTransport, error: Error, reason: string): void {
266
282
  if (this.#transport !== transport) return;
267
283
  this.#transport = null;
284
+ // With an owner listening, a death is final for this instance: the cells that never started stay
285
+ // queued for `drainPending` and the owner replaces it. Without one it respawns lazily, as before.
286
+ const onDeath = this.#options.onDeath;
287
+ if (onDeath) this.#dead = true;
268
288
  const active = this.#active;
269
289
  if (active) {
270
290
  if (active.resolveStateRetained) active.resolveStateRetained(false);
271
- this.#settleRun(active, failedPythonResult(active.input.cellId, "Python kernel died", error.message));
291
+ const message = onDeath ? "Python kernel died; every global is lost" : "Python kernel died";
292
+ this.#settleRun(active, failedPythonResult(active.input.cellId, message, error.message));
272
293
  }
273
- this.#startNext();
294
+ if (onDeath) onDeath(reason);
295
+ else this.#startNext();
296
+ }
297
+
298
+ /**
299
+ * A retirement that timed out is reported, and no second interpreter starts while the first may live.
300
+ * Once that process is seen to exit after all, the failure is over: this instance is dead, not broken.
301
+ */
302
+ #recoverWhenGone(transport: PythonKernelTransport, failure: Error): void {
303
+ const onDeath = this.#options.onDeath;
304
+ if (!onDeath) return;
305
+ void transport.whenGone().then(() => {
306
+ if (this.#closed || this.#failure !== failure) return;
307
+ this.#failure = null;
308
+ this.#dead = true;
309
+ if (this.#transport === transport) this.#transport = null;
310
+ onDeath("an interpreter that outlived its SIGKILL grace");
311
+ });
274
312
  }
275
313
 
276
314
  #onError(transport: PythonKernelTransport, error: Error): void {
@@ -349,8 +387,10 @@ export class PythonKernel {
349
387
  try {
350
388
  await transport.retire();
351
389
  } catch (error) {
352
- if (error instanceof Error) throw this.#recordFailure(error);
353
- throw error;
390
+ if (!(error instanceof Error)) throw error;
391
+ const failure = this.#recordFailure(error);
392
+ this.#recoverWhenGone(transport, failure);
393
+ throw failure;
354
394
  }
355
395
  if (this.#transport === transport) this.#transport = null;
356
396
  })();
@@ -354,6 +354,11 @@ def write(path: str | Path, content: str) -> Path:
354
354
  return target
355
355
 
356
356
 
357
+ # The bridge is a loopback call: urllib's default opener would route it through a configured proxy
358
+ # (the environment everywhere, the registry on Windows), which a 127.0.0.1 request must never use.
359
+ _BRIDGE_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
360
+
361
+
357
362
  def bridge_post(path: str, payload: dict[str, Any]) -> Any:
358
363
  port = CONNECTION.get("port")
359
364
  token = CONNECTION.get("token")
@@ -369,7 +374,7 @@ def bridge_post(path: str, payload: dict[str, Any]) -> Any:
369
374
  emit_status(TIMEOUT_PAUSE_OP, force=True)
370
375
  try:
371
376
  try:
372
- with urllib.request.urlopen(request, timeout=60) as response:
377
+ with _BRIDGE_OPENER.open(request, timeout=60) as response:
373
378
  response_data = response.read()
374
379
  except urllib.error.HTTPError as exc:
375
380
  response_data = exc.read()
@@ -52,7 +52,11 @@ export interface PythonTransportOptions {
52
52
  readonly onRetirementFailure: (transport: PythonKernelTransport, error: Error) => void;
53
53
  readonly onResult: (transport: PythonKernelTransport, result: PythonTransportResult) => void;
54
54
  readonly onError: (transport: PythonKernelTransport, error: Error) => void;
55
- readonly onExit: (transport: PythonKernelTransport, error: Error) => void;
55
+ readonly onExit: (
56
+ transport: PythonKernelTransport,
57
+ error: Error,
58
+ exit: { readonly code: number | null; readonly signal: string | null },
59
+ ) => void;
56
60
  }
57
61
 
58
62
  const hardKillWaitMs = 500;
@@ -79,12 +83,29 @@ export class PythonKernelTransport {
79
83
  #active = true;
80
84
  #exited = false;
81
85
  #retirement: Promise<void> | null = null;
86
+ #gone: Promise<void> | null = null;
87
+ #isGone = false;
82
88
 
83
89
  private constructor(options: PythonTransportOptions, child: KernelChild) {
84
90
  this.#options = options;
85
91
  this.#child = child;
86
92
  }
87
93
 
94
+ /**
95
+ * A retirement that timed out is confirmed only by this: the process really exited after all. The
96
+ * watch is attached on demand, so a transport nobody asks this of leaves no listener on its child.
97
+ */
98
+ whenGone(): Promise<void> {
99
+ if (this.#exited || this.#isGone) return Promise.resolve();
100
+ this.#gone ??= new Promise<void>((resolve) => {
101
+ this.#child.once("exit", () => {
102
+ this.#isGone = true;
103
+ resolve();
104
+ });
105
+ });
106
+ return this.#gone;
107
+ }
108
+
88
109
  static async start(options: PythonTransportOptions): Promise<PythonKernelTransport> {
89
110
  const scriptPath = resolvePythonPreludePath();
90
111
  const invocation = splitCommand(options.interpreterPath);
@@ -139,7 +160,7 @@ export class PythonKernelTransport {
139
160
  }
140
161
 
141
162
  async close(): Promise<void> {
142
- if (this.#exited) return;
163
+ if (this.#exited || this.#isGone) return;
143
164
  if (this.#retirement) {
144
165
  await this.#retirement;
145
166
  return;
@@ -162,7 +183,7 @@ export class PythonKernelTransport {
162
183
  }
163
184
 
164
185
  retire(): Promise<void> {
165
- if (this.#exited) return Promise.resolve();
186
+ if (this.#exited || this.#isGone) return Promise.resolve();
166
187
  if (this.#retirement) return this.#retirement;
167
188
  this.#active = false;
168
189
  const retirement = hardKill(this.#child, hardKillWaitMs).finally(() => {
@@ -244,7 +265,7 @@ export class PythonKernelTransport {
244
265
  const error = new Error(this.#stderrTail.trim() || `Python kernel exited (${code ?? signal ?? "unknown"})`);
245
266
  this.#detachListeners();
246
267
  if (!active) return;
247
- if (!this.#settleStartup(error)) this.#options.onExit(this, error);
268
+ if (!this.#settleStartup(error)) this.#options.onExit(this, error, { code, signal });
248
269
  }
249
270
 
250
271
  #onError(error: Error): void {
@@ -1,10 +1,12 @@
1
1
  import { join } from "node:path";
2
2
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
3
3
  import type { SessionEnvironment } from "../session-env.ts";
4
+ import type { KernelLifecycle } from "../shared/kernel-death.ts";
5
+ import { readProcessGroupCpuTime } from "../shared/process-group-cpu.ts";
4
6
  import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
5
7
  import { SubprocessKernel, type SubprocessKernelMemory, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
6
8
 
7
- export interface RubyKernelStartOptions {
9
+ export interface RubyKernelStartOptions extends KernelLifecycle {
8
10
  readonly cwd: string;
9
11
  readonly sessionId: string;
10
12
  readonly connection: BridgeConnectionConfig;
@@ -13,7 +15,7 @@ export interface RubyKernelStartOptions {
13
15
  readonly command?: string;
14
16
  readonly spawn?: SubprocessSpawn;
15
17
  readonly onMessage?: (message: KernelToHostMessage) => void;
16
- /** Ceiling-only memory management (no notice or globals list: the runner reports no memory). */
18
+ /** Memory management: the runner reports its largest globals and the host reads the interpreter footprint for the ceiling. */
17
19
  readonly memory?: SubprocessKernelMemory;
18
20
  }
19
21
 
@@ -41,6 +43,8 @@ export class RubyKernel extends SubprocessKernel {
41
43
  spawn: options.spawn,
42
44
  onMessage: options.onMessage,
43
45
  memory: options.memory && { language: "rb", ...options.memory },
46
+ onDeath: options.onDeath,
47
+ startup: { label: "Ruby", readGroupCpuTime: readProcessGroupCpuTime },
44
48
  });
45
49
  }
46
50
  }
@@ -1,6 +1,10 @@
1
+ $stdout.write("{\"type\":\"status\",\"event\":{\"op\":\"kernel-startup\",\"stage\":\"stdlib-imports\"}}\n")
2
+ $stdout.flush
1
3
  require "json"
2
4
  require "net/http"
3
5
  require "uri"
6
+ $stdout.write("{\"type\":\"status\",\"event\":{\"op\":\"kernel-startup\",\"stage\":\"runtime-init\"}}\n")
7
+ $stdout.flush
4
8
 
5
9
  $__senpi_binding = TOPLEVEL_BINDING
6
10
  $__senpi_frame_mutex = Mutex.new
@@ -119,6 +123,135 @@ rescue JSON::GeneratorError
119
123
  value.inspect
120
124
  end
121
125
 
126
+ $__senpi_memory = nil
127
+
128
+ SENPI_SIZER_SAMPLE = 1_000
129
+ SENPI_SIZER_NODE_BUDGET = 5_000
130
+ SENPI_SIZER_MAX_DEPTH = 64
131
+ SENPI_SIZER_POINTER = 8
132
+ SENPI_SIZER_OBJECT = 40
133
+ SENPI_SIZER_MIN_REPORTED = 1024 * 1024
134
+ SENPI_ARRAY_AT = Array.instance_method(:[])
135
+ SENPI_ARRAY_SIZE = Array.instance_method(:size)
136
+ SENPI_HASH_SIZE = Hash.instance_method(:size)
137
+ SENPI_HASH_EACH = Hash.instance_method(:each_pair)
138
+ SENPI_STRING_BYTESIZE = String.instance_method(:bytesize)
139
+ SENPI_IVARS = Kernel.instance_method(:instance_variables)
140
+ SENPI_IVAR_GET = Kernel.instance_method(:instance_variable_get)
141
+
142
+ # Sizes a global without running user code: built-ins are read through their own unbound methods, so an
143
+ # override in a subclass is never called. Collections are sampled, and each global gets its own node
144
+ # budget (leaves count against it too), so one deep global never hides the ones measured after it.
145
+ class SenpiGlobalSizer
146
+ attr_reader :approximate
147
+
148
+ def initialize
149
+ @seen = {}.compare_by_identity
150
+ @nodes = 0
151
+ @approximate = false
152
+ end
153
+
154
+ def measure(value)
155
+ @approximate = false
156
+ @nodes = 0
157
+ SENPI_SIZER_POINTER + size(value, 0)
158
+ end
159
+
160
+ private
161
+
162
+ def size(value, depth)
163
+ @nodes += 1
164
+ return 0 if NilClass === value || TrueClass === value || FalseClass === value || Symbol === value
165
+ return ObjectSpace.memsize_of(value) if Integer === value || Float === value
166
+ return 0 if @seen.key?(value)
167
+ @seen[value] = true
168
+ return SENPI_SIZER_OBJECT + SENPI_STRING_BYTESIZE.bind_call(value) if String === value
169
+ if depth >= SENPI_SIZER_MAX_DEPTH || @nodes >= SENPI_SIZER_NODE_BUDGET
170
+ @approximate = true
171
+ return ObjectSpace.memsize_of(value)
172
+ end
173
+ if Array === value
174
+ length = SENPI_ARRAY_SIZE.bind_call(value)
175
+ SENPI_SIZER_OBJECT + length * SENPI_SIZER_POINTER + sampled(length, depth) { |index| SENPI_ARRAY_AT.bind_call(value, index) }
176
+ elsif Hash === value
177
+ hash_size(value, depth)
178
+ else
179
+ ivars = SENPI_IVARS.bind_call(value)
180
+ ObjectSpace.memsize_of(value) + sampled(ivars.length, depth) { |index| SENPI_IVAR_GET.bind_call(value, ivars[index]) }
181
+ end
182
+ end
183
+
184
+ def hash_size(hash, depth)
185
+ count = SENPI_HASH_SIZE.bind_call(hash)
186
+ taken = 0
187
+ total = 0
188
+ SENPI_HASH_EACH.bind_call(hash) do |key, item|
189
+ total += size(key, depth + 1) + size(item, depth + 1)
190
+ taken += 1
191
+ break if taken >= SENPI_SIZER_SAMPLE
192
+ end
193
+ @approximate = true if taken < count
194
+ SENPI_SIZER_OBJECT + count * 2 * SENPI_SIZER_POINTER + (taken.zero? ? 0 : total * count / taken)
195
+ end
196
+
197
+ # Up to SENPI_SIZER_SAMPLE evenly spaced elements; when the walk budget runs out part-way, the elements
198
+ # measured so far stand in for the rest, so a cut-short container is scaled up, not under-counted.
199
+ def sampled(length, depth)
200
+ return 0 if length.zero?
201
+ picks = [length, SENPI_SIZER_SAMPLE].min
202
+ @approximate = true if picks < length
203
+ step = length.to_f / picks
204
+ total = 0
205
+ measured = 0
206
+ picks.times do |sample|
207
+ if @nodes >= SENPI_SIZER_NODE_BUDGET
208
+ @approximate = true
209
+ break
210
+ end
211
+ total += size(yield((sample * step).floor), depth + 1)
212
+ measured += 1
213
+ end
214
+ measured.zero? ? 0 : (total.to_f / measured * length).round
215
+ end
216
+ end
217
+
218
+ def __senpi_largest_globals(limit)
219
+ require "objspace"
220
+ sizer = SenpiGlobalSizer.new
221
+ sized = []
222
+ measure = lambda do |name, value|
223
+ bytes = sizer.measure(value)
224
+ next if bytes < SENPI_SIZER_MIN_REPORTED
225
+ entry = { "name" => name, "bytes" => bytes }
226
+ entry["approximate"] = true if sizer.approximate
227
+ sized << entry
228
+ end
229
+ $__senpi_binding.local_variables.each do |name|
230
+ next if $__senpi_memory_baseline_locals.include?(name)
231
+ measure.call(name.to_s, $__senpi_binding.local_variable_get(name))
232
+ end
233
+ global_variables.each do |name|
234
+ next if $__senpi_memory_baseline_globals.include?(name) || name.to_s.start_with?("$__senpi_")
235
+ measure.call(name.to_s, eval(name.to_s))
236
+ end
237
+ sized.sort_by { |entry| -entry["bytes"] }.first(limit)
238
+ rescue StandardError
239
+ []
240
+ end
241
+
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
+
122
255
  SENPI_NON_DISPLAY_NODES = %i[
123
256
  LASGN IASGN GASGN CVASGN DASGN OP_ASGN OP_CDECL CDECL MASGN CASGN
124
257
  DEFN DEFS CLASS MODULE SCLASS ALIAS UNDEF
@@ -172,6 +305,8 @@ def __senpi_run_cell(message)
172
305
  "durationMs" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
173
306
  }
174
307
  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?
175
310
  __senpi_emit(frame)
176
311
  rescue Exception => error
177
312
  __senpi_emit({
@@ -187,11 +322,17 @@ def __senpi_run_cell(message)
187
322
  end
188
323
  end
189
324
 
325
+ # Everything defined so far belongs to the runner or the interpreter, never to the user's cells.
326
+ $__senpi_memory_baseline_locals = $__senpi_binding.local_variables
327
+ $__senpi_memory_baseline_globals = global_variables
328
+
190
329
  $__senpi_protocol_stdin.each_line do |line|
191
330
  message = JSON.parse(line)
192
331
  case message["type"]
193
332
  when "init"
333
+ __senpi_emit({ "type" => "status", "event" => { "op" => "kernel-startup", "stage" => "host-init" } })
194
334
  $__senpi_connection = message["connection"]
335
+ $__senpi_memory = message["memory"]
195
336
  __senpi_emit({ "type" => "ready" })
196
337
  when "run"
197
338
  __senpi_run_cell(message)
@@ -17,6 +17,7 @@ export const SESSION_ENVIRONMENT_KEYS = [
17
17
  "PI_PROVIDER",
18
18
  "PI_MODEL",
19
19
  "PI_REASONING_LEVEL",
20
+ "OMO_BROWSER_ENGINE",
20
21
  ] as const;
21
22
 
22
23
  /** Resolved per-session values for {@link SESSION_ENVIRONMENT_KEYS}; absent keys stay unset. */
@@ -32,6 +33,7 @@ export interface SessionEnvironmentSource {
32
33
  };
33
34
  readonly model?: { readonly provider: string; readonly id: string } | undefined;
34
35
  readonly thinkingLevel?: string | undefined;
36
+ readonly browserEngine?: string | undefined;
35
37
  }
36
38
 
37
39
  export function sessionEnvironmentFrom(source: SessionEnvironmentSource): SessionEnvironment {
@@ -47,6 +49,7 @@ export function sessionEnvironmentFrom(source: SessionEnvironmentSource): Sessio
47
49
  env.PI_MODEL = model.id;
48
50
  }
49
51
  if (source.thinkingLevel) env.PI_REASONING_LEVEL = source.thinkingLevel;
52
+ if (source.browserEngine) env.OMO_BROWSER_ENGINE = source.browserEngine;
50
53
  return env;
51
54
  }
52
55
 
@@ -0,0 +1,39 @@
1
+ import { constants } from "node:os";
2
+ import type { EvalKernelResult, EvalLanguage } from "../../tool/types.ts";
3
+
4
+ /** How the session manager hears that a kernel's interpreter died. */
5
+ export interface KernelLifecycle {
6
+ /** Called once when the interpreter died on its own; the kernel then keeps its unstarted cells for `drainPending`. */
7
+ readonly onDeath?: (reason: string) => void;
8
+ }
9
+
10
+ /** The wording a restart notice uses for an exit nobody asked for: "signal 9", "exit code 1". */
11
+ export function describeExit(code: number | null, signal: string | null): string {
12
+ if (signal !== null) {
13
+ const number = Object.entries(constants.signals).find(([name]) => name === signal)?.[1];
14
+ return `signal ${number ?? signal}`;
15
+ }
16
+ return code === null ? "an exit with no status" : `exit code ${code}`;
17
+ }
18
+
19
+ export function restartNotice(language: EvalLanguage, reason: string): string {
20
+ return `[${language} kernel was restarted after ${reason}; every global is lost]`;
21
+ }
22
+
23
+ export const EVAL_KERNEL_UNAVAILABLE = "eval_kernel_unavailable";
24
+
25
+ /** A queued cell that never ran: its kernel died again before the replacement finished one cell. */
26
+ export class KernelUnavailableError extends Error {
27
+ readonly name = "KernelUnavailableError";
28
+ readonly code = EVAL_KERNEL_UNAVAILABLE;
29
+
30
+ constructor(language: EvalLanguage, reason: string) {
31
+ super(
32
+ `${EVAL_KERNEL_UNAVAILABLE}: the ${language} kernel died again (${reason}) before its replacement finished a cell; this cell never ran. Run it again to start a fresh kernel.`,
33
+ );
34
+ }
35
+ }
36
+
37
+ export function unstartedResult(cellId: string, message: string): EvalKernelResult {
38
+ return { type: "result", cellId, ok: false, error: { message }, durationMs: 0, kernelState: "not-run" };
39
+ }
@@ -10,8 +10,9 @@ export type FootprintReader = (pid: number) => { readonly bytes: number } | unde
10
10
 
11
11
  export interface KernelMemoryHostOptions {
12
12
  /**
13
- * Host-measured kernel (rb, jl): the kernel reports no memory itself, so the host reads the
14
- * interpreter's footprint after each result. Such a kernel gets the ceiling only - no notice, no globals.
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
15
+ * notice and the ceiling both apply, with every reading counting (no collection precedes it).
15
16
  */
16
17
  readonly readFootprint?: FootprintReader;
17
18
  }
@@ -31,12 +32,15 @@ export class KernelMemoryHost {
31
32
  this.#policy =
32
33
  this.#readFootprint === undefined
33
34
  ? new KernelMemoryPolicy(language, thresholds)
34
- : new KernelMemoryPolicy(language, { ...thresholds, noticeBytes: 0 }, { collects: false });
35
+ : new KernelMemoryPolicy(language, thresholds, { collects: false });
35
36
  }
36
37
 
37
38
  annotate(result: ResultMessage, pid?: number): ResultMessage {
39
+ const runnerReport = result.memory;
38
40
  const report =
39
- this.#readFootprint === undefined ? result.memory : this.#footprintReport(this.#readFootprint, pid);
41
+ this.#readFootprint === undefined
42
+ ? runnerReport
43
+ : this.#footprintReport(this.#readFootprint, pid, runnerReport);
40
44
  return report === undefined ? result : { ...result, memory: this.#policy.annotate(report) };
41
45
  }
42
46
 
@@ -54,10 +58,14 @@ export class KernelMemoryHost {
54
58
  this.#policy.kernelRetired();
55
59
  }
56
60
 
57
- #footprintReport(read: FootprintReader, pid: number | undefined) {
61
+ #footprintReport(read: FootprintReader, pid: number | undefined, runnerReport: ResultMessage["memory"]) {
58
62
  if (pid === undefined) return undefined;
59
63
  const footprint = read(pid);
60
64
  if (footprint === undefined) return undefined;
61
- return { liveBytes: Math.round(footprint.bytes), measure: "footprint" as const };
65
+ return {
66
+ liveBytes: Math.round(footprint.bytes),
67
+ measure: "footprint" as const,
68
+ ...(runnerReport?.globals === undefined ? {} : { globals: runnerReport.globals }),
69
+ };
62
70
  }
63
71
  }
@@ -1,13 +1,13 @@
1
1
  import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
2
  import { generateCorrelationId } from "../../bridge/protocol.ts";
3
3
  import { RESERVED_AGENT_TOOL } from "../../bridge/reserved.ts";
4
- import { kernelToolError } from "./kernel-tools-errors.ts";
4
+ import { kernelToolError } from "../js/kernel-tools-errors.ts";
5
5
  import type {
6
6
  KernelToolsDescribeResult,
7
7
  KernelToolsInvokeOptions,
8
8
  KernelToolsInvokeRequest,
9
9
  KernelToolsInvokeScope,
10
- } from "./kernel-tools-types.ts";
10
+ } from "../js/kernel-tools-types.ts";
11
11
 
12
12
  type KernelToolReply = Extract<
13
13
  KernelToHostMessage,
@@ -0,0 +1,139 @@
1
+ /**
2
+ * CPU time a process group has used so far, read from the kernel without spawning anything, in
3
+ * platform units that only ever grow: the startup watchdog compares two readings and never converts.
4
+ *
5
+ * - darwin: every member from `proc_listpgrppids`, each `ri_user_time + ri_system_time` from
6
+ * `proc_pid_rusage(RUSAGE_INFO_V2)` (libproc, via `bun:ffi`);
7
+ * - linux: every `/proc/<pid>/stat` whose process group is `pgid`, `utime + stime` in clock ticks;
8
+ * - darwin on a runtime without `bun:ffi` (Node): the members' `cputime` from one `ps` call, in
9
+ * hundredths of a second;
10
+ * - win32 and anything else: `undefined`. Windows has no process groups, and the launcher's own time
11
+ * misses the interpreter a launcher such as juliaup starts, so the caller treats the CPU as unknown.
12
+ *
13
+ * `bun:ffi` is fetched with `process.getBuiltinModule` so the module still loads on Node, the same
14
+ * runtime boundary `process-footprint.ts` in coding-agent uses. Every read is synchronous and never throws.
15
+ */
16
+ import { execFileSync } from "node:child_process";
17
+ import { readdirSync, readFileSync } from "node:fs";
18
+
19
+ type BunFfi = typeof import("bun:ffi");
20
+ type GroupCpuReader = (pgid: number) => bigint | undefined;
21
+
22
+ const RUSAGE_INFO_V2 = 2;
23
+ const RI_USER_TIME_OFFSET = 16;
24
+ const RI_SYSTEM_TIME_OFFSET = 24;
25
+ const RUSAGE_BUFFER_BYTES = 256;
26
+ const MAX_GROUP_MEMBERS = 4096;
27
+ const PS_TIMEOUT_MS = 2_000;
28
+
29
+ let groupReader: GroupCpuReader | null | undefined;
30
+
31
+ export function readProcessGroupCpuTime(pgid: number): bigint | undefined {
32
+ if (!Number.isInteger(pgid) || pgid <= 0) return undefined;
33
+ if (groupReader === undefined) groupReader = createGroupReader() ?? null;
34
+ return groupReader?.(pgid);
35
+ }
36
+
37
+ /** `utime + stime` and the process group from `/proc/<pid>/stat` text, or `undefined` when malformed. */
38
+ export function parseProcStat(stat: string): { readonly pgrp: number; readonly cpuTicks: bigint } | undefined {
39
+ // The command name may itself contain spaces or parentheses: the fields start after the last ")".
40
+ const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
41
+ const pgrp = Number(fields[2]);
42
+ const utime = fields[11];
43
+ const stime = fields[12];
44
+ if (!Number.isInteger(pgrp) || utime === undefined || stime === undefined) return undefined;
45
+ if (!/^\d+$/.test(utime) || !/^\d+$/.test(stime)) return undefined;
46
+ return { pgrp, cpuTicks: BigInt(utime) + BigInt(stime) };
47
+ }
48
+
49
+ /** `ps` `cputime` (`[[hh:]mm:]ss.cc`) in hundredths of a second, or `undefined` when malformed. */
50
+ export function parsePsCpuTime(text: string): bigint | undefined {
51
+ const match = /^(?:(\d+):)?(?:(\d+):)?(\d+)\.(\d{2})$/.exec(text.trim());
52
+ if (match === null) return undefined;
53
+ const [, first, second, seconds, hundredths] = match;
54
+ const hours = second === undefined ? 0 : Number(first ?? 0);
55
+ const minutes = second === undefined ? Number(first ?? 0) : Number(second);
56
+ return BigInt(((hours * 60 + minutes) * 60 + Number(seconds)) * 100 + Number(hundredths));
57
+ }
58
+
59
+ function createGroupReader(): GroupCpuReader | undefined {
60
+ if (process.platform === "linux") return linuxGroupReader;
61
+ if (process.platform !== "darwin") return undefined;
62
+ const ffi = process.getBuiltinModule("bun:ffi") as BunFfi | undefined;
63
+ if (ffi === undefined) return darwinPsGroupReader;
64
+ try {
65
+ return darwinGroupReader(ffi);
66
+ } catch {
67
+ // A library or symbol this build cannot bind: read the group through ps instead.
68
+ return darwinPsGroupReader;
69
+ }
70
+ }
71
+
72
+ function darwinPsGroupReader(pgid: number): bigint | undefined {
73
+ let listing: string;
74
+ try {
75
+ listing = execFileSync("ps", ["-A", "-o", "pgid=,cputime="], { encoding: "utf8", timeout: PS_TIMEOUT_MS });
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ let total = 0n;
80
+ let members = 0;
81
+ for (const line of listing.split("\n")) {
82
+ const [group, cputime] = line.trim().split(/\s+/);
83
+ if (group !== String(pgid) || cputime === undefined) continue;
84
+ const used = parsePsCpuTime(cputime);
85
+ if (used === undefined) continue;
86
+ total += used;
87
+ members += 1;
88
+ }
89
+ return members === 0 ? undefined : total;
90
+ }
91
+
92
+ function linuxGroupReader(pgid: number): bigint | undefined {
93
+ let entries: string[];
94
+ try {
95
+ entries = readdirSync("/proc");
96
+ } catch {
97
+ return undefined;
98
+ }
99
+ let total = 0n;
100
+ let members = 0;
101
+ for (const entry of entries) {
102
+ if (!/^\d+$/.test(entry)) continue;
103
+ let stat: string;
104
+ try {
105
+ stat = readFileSync(`/proc/${entry}/stat`, "utf8");
106
+ } catch {
107
+ // The process exited between the directory read and this one.
108
+ continue;
109
+ }
110
+ const parsed = parseProcStat(stat);
111
+ if (parsed === undefined || parsed.pgrp !== pgid) continue;
112
+ total += parsed.cpuTicks;
113
+ members += 1;
114
+ }
115
+ return members === 0 ? undefined : total;
116
+ }
117
+
118
+ function darwinGroupReader({ dlopen, FFIType, ptr }: BunFfi): GroupCpuReader {
119
+ const library = dlopen("libSystem.B.dylib", {
120
+ proc_listpgrppids: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
121
+ proc_pid_rusage: { args: [FFIType.i32, FFIType.i32, FFIType.ptr], returns: FFIType.i32 },
122
+ });
123
+ const pids = new Int32Array(MAX_GROUP_MEMBERS);
124
+ const usage = new Uint8Array(RUSAGE_BUFFER_BYTES);
125
+ const view = new DataView(usage.buffer);
126
+ return (pgid) => {
127
+ const count = library.symbols.proc_listpgrppids(pgid, ptr(pids), pids.byteLength);
128
+ if (count <= 0) return undefined;
129
+ let total = 0n;
130
+ let members = 0;
131
+ for (const pid of pids.subarray(0, Math.min(count, MAX_GROUP_MEMBERS))) {
132
+ usage.fill(0);
133
+ if (library.symbols.proc_pid_rusage(pid, RUSAGE_INFO_V2, ptr(usage)) !== 0) continue;
134
+ total += view.getBigUint64(RI_USER_TIME_OFFSET, true) + view.getBigUint64(RI_SYSTEM_TIME_OFFSET, true);
135
+ members += 1;
136
+ }
137
+ return members === 0 ? undefined : total;
138
+ };
139
+ }