@code-yeongyu/senpi-codemode 2026.10.3 → 2026.10.5

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.
@@ -0,0 +1,110 @@
1
+ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
2
+ import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
3
+ import { MEMORY_COLLECTED_OP } from "../../bridge/reserved.ts";
4
+ import { KernelMemoryPolicy } from "../shared/kernel-memory.ts";
5
+ import type { ResultMessage } from "./kernel-contract.ts";
6
+
7
+ export interface JavaScriptMemoryReading {
8
+ readonly liveBytes: number;
9
+ readonly measure: "heap";
10
+ }
11
+
12
+ /** A query waits at most this long: the worker answers between cells, never inside one. */
13
+ const MEMORY_QUERY_TIMEOUT_MS = 5_000;
14
+
15
+ type QueryResult = Extract<KernelToHostMessage, { type: "memory-query-result" }>;
16
+
17
+ /**
18
+ * The host half of one JS kernel's memory: the post-cell policy (notice, ceiling, recycle), the last
19
+ * heap reading the worker sent (result frames, idle collections, queries), and the on-demand queries
20
+ * still waiting for the worker. Losing the worker forgets the reading and rejects every waiting query.
21
+ */
22
+ export class KernelMemoryBridge {
23
+ readonly #policy: KernelMemoryPolicy | null;
24
+ readonly #onCollected: ((liveBytes: number) => void) | undefined;
25
+ readonly #queries = new Map<
26
+ string,
27
+ { pending: PromiseWithResolvers<JavaScriptMemoryReading>; timer: ReturnType<typeof setTimeout> }
28
+ >();
29
+ #lastLiveBytes: number | undefined;
30
+
31
+ constructor(thresholds: KernelMemoryThresholds | undefined, onCollected?: (liveBytes: number) => void) {
32
+ this.#policy = thresholds === undefined ? null : new KernelMemoryPolicy("js", thresholds);
33
+ this.#onCollected = onCollected;
34
+ }
35
+
36
+ get lastLiveBytes(): number | undefined {
37
+ return this.#lastLiveBytes;
38
+ }
39
+
40
+ /** Consumes the worker's memory messages (idle collections, query results); false for anything else. */
41
+ consume(message: KernelToHostMessage): boolean {
42
+ if (message.type === "memory-query-result") {
43
+ this.#answered(message);
44
+ return true;
45
+ }
46
+ if (message.type !== "status" || message.event.op !== MEMORY_COLLECTED_OP) return false;
47
+ const liveBytes = message.event.liveBytes;
48
+ if (typeof liveBytes !== "number") return true;
49
+ this.#lastLiveBytes = liveBytes;
50
+ this.#policy?.observeLive(liveBytes);
51
+ this.#onCollected?.(liveBytes);
52
+ return true;
53
+ }
54
+
55
+ settled(message: ResultMessage): ResultMessage {
56
+ if (message.memory === undefined) return message;
57
+ this.#lastLiveBytes = message.memory.liveBytes;
58
+ if (this.#policy === null) return message;
59
+ return { ...message, memory: this.#policy.annotate(message.memory) };
60
+ }
61
+
62
+ /** True when an over-ceiling kernel must restart now: it is `idle` (nothing running or queued). */
63
+ claimRecycle(idle: boolean): boolean {
64
+ if (!idle || this.#policy?.recyclePending !== true) return false;
65
+ this.#policy.recycleStarted();
66
+ return true;
67
+ }
68
+
69
+ /**
70
+ * A fresh reading from the worker. The worker answers between cells, so the wait is bounded:
71
+ * a kernel whose cell never yields (a sync loop) cannot answer until that cell settles, and the
72
+ * query rejects after {@link MEMORY_QUERY_TIMEOUT_MS} instead of hanging its caller forever.
73
+ */
74
+ query(post: (message: HostToKernelMessage) => void): Promise<JavaScriptMemoryReading> {
75
+ const requestId = crypto.randomUUID();
76
+ const pending = Promise.withResolvers<JavaScriptMemoryReading>();
77
+ const timer = setTimeout(() => {
78
+ if (!this.#queries.delete(requestId)) return;
79
+ pending.reject(
80
+ new Error(
81
+ `memory-query timed out after ${MEMORY_QUERY_TIMEOUT_MS}ms (a cell is running; queries answer between cells)`,
82
+ ),
83
+ );
84
+ }, MEMORY_QUERY_TIMEOUT_MS);
85
+ this.#queries.set(requestId, { pending, timer });
86
+ post({ type: "memory-query", requestId });
87
+ return pending.promise;
88
+ }
89
+
90
+ /** The worker is gone (reset, crash, stop, recycle): its memory and its unanswered queries went with it. */
91
+ workerLost(error: Error): void {
92
+ this.#policy?.kernelRetired();
93
+ this.#lastLiveBytes = undefined;
94
+ const waiting = [...this.#queries.values()];
95
+ this.#queries.clear();
96
+ for (const { pending, timer } of waiting) {
97
+ clearTimeout(timer);
98
+ pending.reject(error);
99
+ }
100
+ }
101
+
102
+ #answered(result: QueryResult): void {
103
+ const waiting = this.#queries.get(result.requestId);
104
+ if (waiting === undefined) return;
105
+ this.#queries.delete(result.requestId);
106
+ clearTimeout(waiting.timer);
107
+ this.#lastLiveBytes = result.liveBytes;
108
+ waiting.pending.resolve({ liveBytes: result.liveBytes, measure: result.measure });
109
+ }
110
+ }
@@ -0,0 +1,32 @@
1
+ import type { ToolCallMessage } from "./kernel-contract.ts";
2
+
3
+ // Pull-API fallback queue bound: a nextToolCall consumer this far behind is already stalled, and the
4
+ // normal push path (onMessage) never reads the queue, so unbounded growth only pins tool args (#1695).
5
+ const MAX_PENDING_TOOL_CALLS = 256;
6
+
7
+ /** Tool calls for `nextToolCall()` consumers: handed to a waiting consumer, else queued up to the bound. */
8
+ export class ToolCallQueue {
9
+ readonly #waiters: Array<(message: ToolCallMessage) => void> = [];
10
+ readonly #pending: ToolCallMessage[] = [];
11
+
12
+ push(message: ToolCallMessage): void {
13
+ const waiter = this.#waiters.shift();
14
+ if (waiter) {
15
+ waiter(message);
16
+ return;
17
+ }
18
+ this.#pending.push(message);
19
+ if (this.#pending.length > MAX_PENDING_TOOL_CALLS) this.#pending.shift();
20
+ }
21
+
22
+ async next(): Promise<ToolCallMessage> {
23
+ const pending = this.#pending.shift();
24
+ if (pending) return pending;
25
+ return await new Promise((resolve) => this.#waiters.push(resolve));
26
+ }
27
+
28
+ clear(): void {
29
+ this.#pending.length = 0;
30
+ this.#waiters.length = 0;
31
+ }
32
+ }
@@ -0,0 +1,35 @@
1
+ import type { EvalStatusEvent } from "../../bridge/protocol.ts";
2
+ import { terminateProcessTrees } from "./process-tree-host.ts";
3
+
4
+ /** How long a lost worker's children get to honour SIGTERM before the host sends SIGKILL. */
5
+ const WORKER_LOSS_CHILD_GRACE_MS = 1_000;
6
+
7
+ /** Live cell children the worker reported; retired by the host when the worker itself is lost. */
8
+ export class WorkerChildren {
9
+ readonly #pids = new Set<number>();
10
+ readonly #collectOrphanedChildren: ((pids: readonly number[]) => Promise<unknown>) | undefined;
11
+
12
+ constructor(collectOrphanedChildren?: (pids: readonly number[]) => Promise<unknown>) {
13
+ this.#collectOrphanedChildren = collectOrphanedChildren;
14
+ }
15
+
16
+ track(event: EvalStatusEvent): void {
17
+ const pid = event.pid;
18
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return;
19
+ if (event.state === "spawned") this.#pids.add(pid);
20
+ else if (event.state === "exited") this.#pids.delete(pid);
21
+ }
22
+
23
+ /**
24
+ * A terminated or crashed worker never reaches its own cell-end cleanup, so the host retires the
25
+ * children it still owned; a pid `ps` no longer lists as our child was reused and is skipped.
26
+ */
27
+ async retire(): Promise<void> {
28
+ if (this.#pids.size === 0) return;
29
+ const pids = [...this.#pids];
30
+ this.#pids.clear();
31
+ await terminateProcessTrees(pids, { graceMs: WORKER_LOSS_CHILD_GRACE_MS, ownerPid: process.pid });
32
+ // The worker that owned these children and their exit watchers is gone, so nothing else will wait on them.
33
+ await this.#collectOrphanedChildren?.(pids);
34
+ }
35
+ }
@@ -3,6 +3,7 @@ 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
5
  import { installSessionCwd } from "./worker-cwd.js";
6
+ import { createHeapProbe } from "./worker-heap.js";
6
7
  import { createWorkerMemory } from "./worker-memory.js";
7
8
  import { JsWorkerRuntime } from "./worker-runtime.js";
8
9
  import { installKernelWebView } from "./worker-webview.js";
@@ -23,11 +24,13 @@ const SESSION_ENVIRONMENT_KEYS = [
23
24
  "PI_PROVIDER",
24
25
  "PI_MODEL",
25
26
  "PI_REASONING_LEVEL",
27
+ "OMO_BROWSER_ENGINE",
26
28
  ];
27
29
 
28
30
  export function createWorkerCore(transport, options) {
29
31
  let runtime = null;
30
32
  let memory = null;
33
+ let heapProbe = null;
31
34
  let activeCell = null;
32
35
  const pendingTools = new Map();
33
36
  const pendingWebViewPorts = new Map();
@@ -146,6 +149,11 @@ export function createWorkerCore(transport, options) {
146
149
  emit({ type: "ready" });
147
150
  return;
148
151
  }
152
+ if (message.type === "memory-query") {
153
+ heapProbe ??= createHeapProbe();
154
+ emit({ type: "memory-query-result", requestId: message.requestId, liveBytes: Math.round(heapProbe.estimate()), measure: "heap" });
155
+ return;
156
+ }
149
157
  if (message.type === "run") {
150
158
  void runCell(message);
151
159
  return;
@@ -90,6 +90,15 @@ export function bridgeError(error: Error): {
90
90
  return { message: error.message, name: error.name, stack: error.stack };
91
91
  }
92
92
 
93
+ /** The result a cell settles with when its worker crashed under it. */
94
+ export function crashedResult(
95
+ cellId: string,
96
+ error: Error,
97
+ durationMs: number,
98
+ ): Extract<KernelToHostMessage, { type: "result" }> {
99
+ return { type: "result", cellId, ok: false, error: bridgeError(error), durationMs };
100
+ }
101
+
93
102
  function wrapNodeWorker(worker: Worker, mode: JavaScriptKernelMode): WorkerLike {
94
103
  return {
95
104
  mode,
@@ -0,0 +1,60 @@
1
+ import type { JavaScriptRunQueue } from "./run-queue.ts";
2
+ import { WorkerStartupCancelledError } from "./worker-host.ts";
3
+
4
+ export interface WorkerRecoveryHost {
5
+ readonly runs: JavaScriptRunQueue;
6
+ isOpen(): boolean;
7
+ ensureReady(): Promise<void>;
8
+ startNext(): void;
9
+ }
10
+
11
+ /** Brings a worker up for waiting cells, and replaces a lost or retired worker one recovery at a time. */
12
+ export class WorkerRecovery {
13
+ readonly #host: WorkerRecoveryHost;
14
+ #recovery: Promise<void> | null = null;
15
+
16
+ constructor(host: WorkerRecoveryHost) {
17
+ this.#host = host;
18
+ }
19
+
20
+ get inFlight(): Promise<void> | null {
21
+ return this.#recovery;
22
+ }
23
+
24
+ /** Starts a worker when none is live and hands it the next waiting cell; a failed start fails the waiting cells. */
25
+ async bringUp(): Promise<void> {
26
+ try {
27
+ await this.#host.ensureReady();
28
+ if (this.#host.isOpen()) this.#host.startNext();
29
+ } catch (error) {
30
+ this.#failWaiting(error);
31
+ }
32
+ }
33
+
34
+ /** Retires through `retire` (a no-op when the worker is already gone), then brings a fresh worker up. */
35
+ async recover(retire: () => Promise<unknown>): Promise<void> {
36
+ if (this.#recovery) return await this.#recovery;
37
+ const recovery = this.#perform(retire);
38
+ this.#recovery = recovery;
39
+ try {
40
+ await recovery;
41
+ } finally {
42
+ if (this.#recovery === recovery) this.#recovery = null;
43
+ }
44
+ }
45
+
46
+ async #perform(retire: () => Promise<unknown>): Promise<void> {
47
+ try {
48
+ await retire();
49
+ } catch (error) {
50
+ this.#failWaiting(error);
51
+ return;
52
+ }
53
+ if (this.#host.isOpen()) await this.bringUp();
54
+ }
55
+
56
+ #failWaiting(error: unknown): void {
57
+ if (error instanceof WorkerStartupCancelledError) return;
58
+ this.#host.runs.rejectWaiting(error instanceof Error ? error : new Error(String(error)));
59
+ }
60
+ }
@@ -2,11 +2,12 @@ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
2
2
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
3
3
  import type { EvalKernelRunInput } from "../../tool/types.ts";
4
4
  import type { SessionEnvironment } from "../session-env.ts";
5
+ import type { KernelLifecycle } from "../shared/kernel-death.ts";
5
6
  import type { KernelSpawnProcess } from "./process.ts";
6
7
  import type { PythonStartupStage } from "./startup.ts";
7
8
  import type { PythonTransportResult } from "./transport.ts";
8
9
 
9
- export interface PythonKernelStartOptions {
10
+ export interface PythonKernelStartOptions extends KernelLifecycle {
10
11
  readonly interpreterPath: string;
11
12
  readonly sessionId: string;
12
13
  readonly cwd: string;
@@ -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
  })();
@@ -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,11 @@
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";
4
5
  import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
5
6
  import { SubprocessKernel, type SubprocessKernelMemory, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
6
7
 
7
- export interface RubyKernelStartOptions {
8
+ export interface RubyKernelStartOptions extends KernelLifecycle {
8
9
  readonly cwd: string;
9
10
  readonly sessionId: string;
10
11
  readonly connection: BridgeConnectionConfig;
@@ -41,6 +42,7 @@ export class RubyKernel extends SubprocessKernel {
41
42
  spawn: options.spawn,
42
43
  onMessage: options.onMessage,
43
44
  memory: options.memory && { language: "rb", ...options.memory },
45
+ onDeath: options.onDeath,
44
46
  });
45
47
  }
46
48
  }
@@ -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
+ }
@@ -2,6 +2,7 @@ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
2
2
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
3
3
  import type { EvalKernelRunInput, EvalLanguage } from "../../tool/types.ts";
4
4
  import type { SessionEnvironment } from "../session-env.ts";
5
+ import type { KernelLifecycle } from "./kernel-death.ts";
5
6
  import type { FootprintReader } from "./kernel-memory-host.ts";
6
7
  import type { SubprocessSpawn } from "./subprocess-process.ts";
7
8
 
@@ -16,7 +17,7 @@ export interface SubprocessKernelMemory {
16
17
  readonly readFootprint: FootprintReader;
17
18
  }
18
19
 
19
- export interface SubprocessKernelOptions {
20
+ export interface SubprocessKernelOptions extends KernelLifecycle {
20
21
  readonly command: string;
21
22
  readonly args: readonly string[];
22
23
  readonly cwd?: string;
@@ -1,9 +1,10 @@
1
1
  import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
2
  import { decodeBridgeFrame, encodeBridgeFrame, isKernelToHostMessage } from "../../bridge/protocol.ts";
3
- import type { KernelInterruptHandle } from "../../tool/types.ts";
3
+ import type { KernelInterruptHandle, PendingCell } from "../../tool/types.ts";
4
4
  import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts";
5
5
  import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
6
6
  import { applySessionEnvironment } from "../session-env.ts";
7
+ import { describeExit } from "./kernel-death.ts";
7
8
  import { KernelMemoryHost } from "./kernel-memory-host.ts";
8
9
  import type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts";
9
10
  import { type SubprocessLike, SubprocessProcess, type SubprocessSpawn, spawnSubprocess } from "./subprocess-process.ts";
@@ -19,6 +20,7 @@ import {
19
20
  KernelRetirementError,
20
21
  KernelStartupError,
21
22
  type PendingRun,
23
+ settlePendingRun,
22
24
  timeoutResult,
23
25
  } from "./subprocess-run.ts";
24
26
 
@@ -43,6 +45,7 @@ export class SubprocessKernel {
43
45
  private retirementFailure: Error | null = null;
44
46
  private failure: Error | null = null;
45
47
  private closed = false;
48
+ private dead = false;
46
49
 
47
50
  constructor(options: SubprocessKernelOptions) {
48
51
  this.options = options;
@@ -52,6 +55,14 @@ export class SubprocessKernel {
52
55
  this.spawnProcess();
53
56
  }
54
57
 
58
+ isAlive(): boolean {
59
+ return !this.dead;
60
+ }
61
+
62
+ drainPending(): readonly PendingCell[] {
63
+ return this.runs.drain().map((run) => ({ input: run.input, settle: (result) => settlePendingRun(run, result) }));
64
+ }
65
+
55
66
  run(input: KernelRunInput): Promise<KernelResult> {
56
67
  if (this.closed) throw new KernelClosedError();
57
68
  const run = this.runs.enqueue(input);
@@ -243,7 +254,27 @@ export class SubprocessKernel {
243
254
  }
244
255
  this.process = null;
245
256
  this.processReady = false;
246
- this.failClosed(new KernelExitedError(signal ?? code ?? "unknown"));
257
+ const error = new KernelExitedError(signal ?? code ?? "unknown");
258
+ if (this.options.onDeath) this.die(error, describeExit(code, signal), this.options.onDeath);
259
+ else this.failClosed(error);
260
+ }
261
+
262
+ /**
263
+ * The interpreter exited on its own and is gone: the running cell fails once (its side effects may
264
+ * already have happened), the cells that never started stay queued for `drainPending`, and the owner
265
+ * replaces this instance. `run()` is never reached on it again.
266
+ */
267
+ private die(error: Error, reason: string, onDeath: (reason: string) => void): void {
268
+ this.failure = error;
269
+ this.closed = true;
270
+ this.dead = true;
271
+ this.runs.clearToolCalls();
272
+ const active = this.runs.active;
273
+ if (active) {
274
+ this.runs.releaseActive(active);
275
+ this.runs.settle(active, failureResult(active, new Error(`${error.message}; every global is lost`)));
276
+ }
277
+ onDeath(reason);
247
278
  }
248
279
 
249
280
  private accepts(process: SubprocessProcess): boolean {