@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.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,38 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.10.5] - 2026-10-03
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ - Eval kernels expose the session's `OMO_BROWSER_ENGINE` and clear a value inherited from the host process for a session that chose no engine ([#2611](https://github.com/code-yeongyu/senpi/issues/2611)).
22
+ - A Python, Ruby, or Julia eval kernel whose interpreter dies is replaced once instead of failing every later cell: Ruby and Julia no longer stay closed after a crash, and a Python kernel whose stuck interpreter finally exits recovers instead of rejecting every cell. Cells queued behind the death keep their order and run on the replacement, whose first result says `[<language> kernel was restarted after <reason>; every global is lost]`; the cell that was running fails once and is never re-run, and a replacement that dies before finishing a cell fails the queued cells with `eval_kernel_unavailable` ([#2452](https://github.com/code-yeongyu/senpi/issues/2452))
23
+
24
+ ### Changed
25
+
26
+ ### Fixed
27
+
28
+ ### Removed
29
+
30
+ ## [2026.10.4] - 2026-10-03
31
+
32
+ ### Breaking Changes
33
+
34
+ ### Added
35
+
36
+ - Every live eval kernel in a process is listed in a process-wide registry with its session, language, measure, and last-known memory reading; a JavaScript kernel keeps the heap reading from each result and idle collection and answers an on-demand heap query between cells without running one, while Python, Ruby, and Julia kernels report their interpreter's footprint on demand. Thresholds, notices, and the result frame are unchanged ([#2561](https://github.com/code-yeongyu/senpi/issues/2561)).
37
+
38
+ - An eval regression gate records the full prompt and schema surfaces, helper witnesses across five required runtime legs, codemode-scoped eager imports, measured teardown resources (including global, named-import, promise and AbortSignal timers, named by creation site) and legacy contract results against a frozen baseline. CI provisions every interpreter and publishes the report; unrelated host imports and slow child startup cannot cause a regression failure. ([#2452](https://github.com/code-yeongyu/senpi/issues/2452))
39
+
40
+ - Added an interleaved eval timing benchmark that judges each runtime, workload and metric on adjacent base/head pairs against its own A/A-calibrated threshold (capped at 5%, or one shared band with `--band-scope global`), with process CPU accounting across interpreter crashes, a per-row minimum detectable effect, and explicit inconclusive results for incomplete, host-contaminated or noise-limited comparisons. ([#2452](https://github.com/code-yeongyu/senpi/issues/2452))
41
+ ### Changed
42
+
43
+ ### Fixed
44
+
45
+ ### Removed
46
+
15
47
  ## [2026.10.3] - 2026-10-03
16
48
 
17
49
  ### Breaking Changes
package/README.md CHANGED
@@ -339,6 +339,17 @@ bash tool for long-running commands you may want to stop. Native `Bun.$`
339
339
  cancellation is tracked in [Bun #11868](https://github.com/oven-sh/bun/issues/11868);
340
340
  the shell's interpretation and object redirects are not replaced.
341
341
 
342
+ When a Python, Ruby, or Julia interpreter dies on its own (a crash, an OOM kill,
343
+ `os.kill(os.getpid(), 9)`), the cell it was running fails once and is never run
344
+ again: its side effects may already have happened. Cells queued behind it keep
345
+ their order, callbacks, and deadlines and run on one fresh interpreter started
346
+ for that death; the first result there carries
347
+ `[<language> kernel was restarted after <reason>; every global is lost]`. If the
348
+ fresh interpreter dies too before it finished a cell, the queued cells fail with
349
+ `eval_kernel_unavailable` naming the reason, and the next cell you run starts
350
+ another. An interpreter whose exit cannot be confirmed is reported, never
351
+ replaced by a second one. The JavaScript worker keeps its own restart path.
352
+
342
353
  Commands a cell runs through `Bun.$` never read the host's terminal: the worker
343
354
  thread shares the TUI's stdin, so the shell wrapper hands every template an
344
355
  empty pipe (`true | ( … )`) while a cell is active. Output, exit codes, `cwd`,
@@ -425,6 +436,12 @@ bun run --cwd packages/senpi-codemode gate --target <base-checkout> \
425
436
  --baseline test/gate/baseline.json --write-baseline
426
437
  ```
427
438
 
439
+ Record with the canonical runtimes, so the baseline's runtime observations do not
440
+ flip between recordings: Bun 1.4.2, Python 3.14, Julia 1.12 and Ruby 4.x from
441
+ Homebrew (put `/opt/homebrew/opt/ruby/bin` first on `PATH`; macOS's system Ruby
442
+ 2.6 records a different runtime). Check `ruby --version` and `python3 --version`
443
+ before `--write-baseline`.
444
+
428
445
  The report is gitignored `gate-report.json` by default (`--report <path>` overrides it).
429
446
  `test/gate/allowlist.json` contains reviewed additive changes keyed by plan node;
430
447
  it cannot authorize removal or modification of a legacy entry. The test-only
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.10.3",
3
+ "version": "2026.10.5",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -17,6 +17,7 @@
17
17
  ],
18
18
  "scripts": {
19
19
  "gate": "bun scripts/gate-eval.ts",
20
+ "bench": "bun scripts/bench-eval.ts",
20
21
  "test": "vitest run test/",
21
22
  "clean": "node -e \"\"",
22
23
  "build": "node -e \"\""
@@ -31,14 +32,14 @@
31
32
  },
32
33
  "dependencies": {
33
34
  "@babel/parser": "8.0.4",
34
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.3",
35
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.5",
35
36
  "typebox": "1.3.34"
36
37
  },
37
38
  "peerDependencies": {
38
- "@code-yeongyu/senpi": "2026.10.3"
39
+ "@code-yeongyu/senpi": "2026.10.5"
39
40
  },
40
41
  "devDependencies": {
41
- "@code-yeongyu/senpi": "2026.10.3"
42
+ "@code-yeongyu/senpi": "2026.10.5"
42
43
  },
43
44
  "keywords": [
44
45
  "senpi",
@@ -34,6 +34,20 @@ export const kernelMemoryReportSchema = Type.Object({
34
34
  notice: Type.Optional(Type.String()),
35
35
  });
36
36
 
37
+ /** On-demand heap reading between cells (JS kernels): no cell runs and no collection is forced. */
38
+ export const kernelMemoryQueryHostToKernelSchemas = [
39
+ Type.Object({ type: Type.Literal("memory-query"), requestId: Type.String({ minLength: 1 }) }),
40
+ ] as const;
41
+
42
+ export const kernelMemoryQueryKernelToHostSchemas = [
43
+ Type.Object({
44
+ type: Type.Literal("memory-query-result"),
45
+ requestId: Type.String({ minLength: 1 }),
46
+ liveBytes: Type.Integer({ minimum: 0 }),
47
+ measure: Type.Literal("heap"),
48
+ }),
49
+ ] as const;
50
+
37
51
  export type KernelMemoryThresholds = Static<typeof kernelMemoryThresholdsSchema>;
38
52
  export type KernelMemoryReport = Static<typeof kernelMemoryReportSchema>;
39
53
  export type KernelMemoryGlobal = Static<typeof kernelMemoryGlobalSchema>;
@@ -2,7 +2,12 @@ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypt
2
2
  import { type Static, Type } from "typebox";
3
3
  import { Value } from "typebox/value";
4
4
  import { kernelToolHostToKernelSchemas, kernelToolKernelToHostSchemas } from "./kernel-tools-protocol.ts";
5
- import { kernelMemoryReportSchema, kernelMemoryThresholdsSchema } from "./memory-protocol.ts";
5
+ import {
6
+ kernelMemoryQueryHostToKernelSchemas,
7
+ kernelMemoryQueryKernelToHostSchemas,
8
+ kernelMemoryReportSchema,
9
+ kernelMemoryThresholdsSchema,
10
+ } from "./memory-protocol.ts";
6
11
 
7
12
  export const BRIDGE_FRAME_MAX_BYTES = 10 * 1024 * 1024;
8
13
 
@@ -82,8 +87,15 @@ const hostToKernelMessageSchema = Type.Union([
82
87
  error: bridgeErrorSchema,
83
88
  }),
84
89
  ...kernelToolHostToKernelSchemas,
90
+ ...kernelMemoryQueryHostToKernelSchemas,
85
91
  ]);
86
92
 
93
+ /**
94
+ * Host-set: what a kernel death did to this cell's state. `lost`: the interpreter died while it ran;
95
+ * `restarted`: it ran on a kernel replaced after a death; `not-run`: it was failed without running.
96
+ */
97
+ const kernelStateSchema = Type.Union([Type.Literal("lost"), Type.Literal("restarted"), Type.Literal("not-run")]);
98
+
87
99
  const kernelToHostMessageSchema = Type.Union([
88
100
  Type.Object({ type: Type.Literal("ready") }),
89
101
  Type.Object({ type: Type.Literal("init-failed"), error: bridgeErrorSchema }),
@@ -113,6 +125,9 @@ const kernelToHostMessageSchema = Type.Union([
113
125
  valueRepr: Type.Optional(Type.String()),
114
126
  durationMs: Type.Integer({ minimum: 0 }),
115
127
  memory: Type.Optional(kernelMemoryReportSchema),
128
+ /** Host-set: the bracketed notice that this cell ran on a kernel restarted after its interpreter died. */
129
+ notice: Type.Optional(Type.String()),
130
+ kernelState: Type.Optional(kernelStateSchema),
116
131
  }),
117
132
  Type.Object({
118
133
  type: Type.Literal("result"),
@@ -121,10 +136,13 @@ const kernelToHostMessageSchema = Type.Union([
121
136
  error: bridgeErrorSchema,
122
137
  durationMs: Type.Integer({ minimum: 0 }),
123
138
  memory: Type.Optional(kernelMemoryReportSchema),
139
+ notice: Type.Optional(Type.String()),
140
+ kernelState: Type.Optional(kernelStateSchema),
124
141
  }),
125
142
  Type.Object({ type: Type.Literal("closed") }),
126
143
  Type.Object({ type: Type.Literal("webview-connect"), requestId: Type.String({ minLength: 1 }) }),
127
144
  ...kernelToolKernelToHostSchemas,
145
+ ...kernelMemoryQueryKernelToHostSchemas,
128
146
  ]);
129
147
 
130
148
  const bridgeMessageSchema = Type.Union([hostToKernelMessageSchema, kernelToHostMessageSchema]);
@@ -0,0 +1,96 @@
1
+ import { readProcessFootprint } from "@code-yeongyu/senpi";
2
+ import type { KernelMemoryThresholds } from "../bridge/memory-protocol.ts";
3
+ import type { BridgeConnectionConfig, KernelToHostMessage } from "../bridge/protocol.ts";
4
+ import { JuliaKernel } from "../kernels/jl/kernel.ts";
5
+ import type { JavaScriptKernel } from "../kernels/js/context-manager.ts";
6
+ import { PythonKernel } from "../kernels/py/kernel.ts";
7
+ import { defaultSpawn } from "../kernels/py/process.ts";
8
+ import { RubyKernel } from "../kernels/rb/kernel.ts";
9
+ import type { SessionEnvironment } from "../kernels/session-env.ts";
10
+ import { spawnSubprocess } from "../kernels/shared/subprocess-process.ts";
11
+ import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
12
+ import { type KernelMemoryReading, kernelRegistry, type RegisteredKernelSource } from "./kernel-registry.ts";
13
+
14
+ export interface StartedKernel {
15
+ readonly kernel: EvalKernel;
16
+ readonly memory: RegisteredKernelSource;
17
+ }
18
+
19
+ export interface SubprocessKernelStart {
20
+ readonly language: Exclude<EvalLanguage, "js">;
21
+ readonly interpreterPath: string;
22
+ readonly memory: KernelMemoryThresholds;
23
+ readonly shared: {
24
+ readonly sessionId: string;
25
+ readonly cwd: string;
26
+ readonly sessionEnv?: SessionEnvironment;
27
+ readonly connection: BridgeConnectionConfig;
28
+ readonly onMessage: (message: KernelToHostMessage) => void;
29
+ };
30
+ }
31
+
32
+ export function registerKernel(sessionId: string, language: EvalLanguage, memory: RegisteredKernelSource): string {
33
+ const id = crypto.randomUUID();
34
+ kernelRegistry.register({ id, sessionId, language, source: memory });
35
+ return id;
36
+ }
37
+
38
+ export function javaScriptKernelMemory(kernel: JavaScriptKernel): RegisteredKernelSource {
39
+ return {
40
+ measure: "heap",
41
+ lastLiveBytes: () => kernel.lastLiveBytes,
42
+ busy: () => kernel.queueSnapshot().activeCellId !== null,
43
+ queryMemory: () => kernel.queryMemory(),
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Starts a py/rb/jl kernel through a spawn that remembers its interpreter's pid, so the registry can read
49
+ * that process's footprint on demand; a restarted interpreter replaces the pid.
50
+ */
51
+ export async function startSubprocessKernel(start: SubprocessKernelStart): Promise<StartedKernel> {
52
+ let pid: number | undefined;
53
+ const { shared, memory } = start;
54
+ let kernel: EvalKernel;
55
+ if (start.language === "py") {
56
+ kernel = await PythonKernel.start({
57
+ ...shared,
58
+ interpreterPath: start.interpreterPath,
59
+ memory,
60
+ spawnProcess: (options) => {
61
+ const child = defaultSpawn(options);
62
+ pid = child.pid;
63
+ return child;
64
+ },
65
+ });
66
+ } else {
67
+ // rb/jl runners report no memory: the host reads the interpreter footprint for the ceiling only.
68
+ const options = {
69
+ ...shared,
70
+ command: start.interpreterPath,
71
+ memory: { thresholds: memory, readFootprint: readProcessFootprint },
72
+ spawn: (command: string, args: readonly string[], spawnOptions: { cwd?: string; env?: NodeJS.ProcessEnv }) => {
73
+ const child = spawnSubprocess(undefined, { command, args, ...spawnOptions });
74
+ pid = child.pid;
75
+ return child;
76
+ },
77
+ };
78
+ kernel = start.language === "rb" ? RubyKernel.start(options) : JuliaKernel.start(options);
79
+ }
80
+ return { kernel, memory: footprintMemory(kernel, () => pid) };
81
+ }
82
+
83
+ function footprintMemory(kernel: EvalKernel, pid: () => number | undefined): RegisteredKernelSource {
84
+ const read = (): KernelMemoryReading | undefined => {
85
+ const current = pid();
86
+ const footprint = current === undefined ? undefined : readProcessFootprint(current);
87
+ return footprint === undefined ? undefined : { liveBytes: Math.round(footprint.bytes), measure: "footprint" };
88
+ };
89
+ return {
90
+ measure: "footprint",
91
+ lastLiveBytes: () => read()?.liveBytes,
92
+ busy: () => kernel.queueSnapshot().activeCellId !== null,
93
+ queryMemory: async () => read(),
94
+ pid,
95
+ };
96
+ }
@@ -0,0 +1,105 @@
1
+ import type { EvalLanguage } from "../tool/types.ts";
2
+
3
+ /** `heap`: a JS worker's own heap estimate; `footprint`: the interpreter process's memory footprint. */
4
+ export type KernelMeasure = "heap" | "footprint";
5
+
6
+ export interface KernelMemoryReading {
7
+ readonly liveBytes: number;
8
+ readonly measure: KernelMeasure;
9
+ }
10
+
11
+ /**
12
+ * What the registry reads from a live kernel. It is backed by the kernel object, not its worker or
13
+ * process, so a kernel that restarts its worker (ceiling, crash, reset) keeps its registry entry.
14
+ */
15
+ export interface RegisteredKernelSource {
16
+ readonly measure: KernelMeasure;
17
+ /** The last reading the host holds, without asking the kernel; `undefined` before the first one. */
18
+ lastLiveBytes(): number | undefined;
19
+ /** A cell is running, so the last reading may predate what the kernel holds now. */
20
+ busy(): boolean;
21
+ /** A fresh reading taken without running a cell; `undefined` when no worker or process is live. */
22
+ queryMemory(): Promise<KernelMemoryReading | undefined>;
23
+ pid?(): number | undefined;
24
+ }
25
+
26
+ export interface KernelRegistration {
27
+ readonly id: string;
28
+ readonly sessionId: string;
29
+ readonly language: EvalLanguage;
30
+ readonly source: RegisteredKernelSource;
31
+ }
32
+
33
+ export interface KernelListing {
34
+ readonly id: string;
35
+ readonly sessionId: string;
36
+ readonly language: EvalLanguage;
37
+ readonly measure: KernelMeasure;
38
+ readonly lastLiveBytes?: number;
39
+ readonly busy: boolean;
40
+ readonly pid?: number;
41
+ }
42
+
43
+ export interface KernelRegistry {
44
+ register(kernel: KernelRegistration): void;
45
+ unregister(id: string): boolean;
46
+ list(): KernelListing[];
47
+ lastLiveBytes(id: string): number | undefined;
48
+ query(id: string): Promise<KernelMemoryReading | undefined>;
49
+ }
50
+
51
+ // One registry per process, whichever copy of this module asks: the extension loader can evaluate the
52
+ // codemode module graph more than once in a process, and the host's memory report reads the same key.
53
+ const REGISTRY_KEY = Symbol.for("senpi.codemode.kernel-registry");
54
+
55
+ function createKernelRegistry(): KernelRegistry {
56
+ const kernels = new Map<string, KernelRegistration>();
57
+ return {
58
+ register(kernel) {
59
+ kernels.set(kernel.id, kernel);
60
+ },
61
+ unregister(id) {
62
+ return kernels.delete(id);
63
+ },
64
+ list() {
65
+ return [...kernels.values()].map(listing);
66
+ },
67
+ lastLiveBytes(id) {
68
+ return kernels.get(id)?.source.lastLiveBytes();
69
+ },
70
+ async query(id) {
71
+ return await kernels.get(id)?.source.queryMemory();
72
+ },
73
+ };
74
+ }
75
+
76
+ function listing({ id, sessionId, language, source }: KernelRegistration): KernelListing {
77
+ const lastLiveBytes = source.lastLiveBytes();
78
+ const pid = source.pid?.();
79
+ return {
80
+ id,
81
+ sessionId,
82
+ language,
83
+ measure: source.measure,
84
+ busy: source.busy(),
85
+ ...(lastLiveBytes === undefined ? {} : { lastLiveBytes }),
86
+ ...(pid === undefined ? {} : { pid }),
87
+ };
88
+ }
89
+
90
+ function isKernelRegistry(value: unknown): value is KernelRegistry {
91
+ if (typeof value !== "object" || value === null) return false;
92
+ return ["register", "unregister", "list", "lastLiveBytes", "query"].every(
93
+ (name) => typeof Reflect.get(value, name) === "function",
94
+ );
95
+ }
96
+
97
+ function processKernelRegistry(): KernelRegistry {
98
+ const existing: unknown = Reflect.get(globalThis, REGISTRY_KEY);
99
+ if (isKernelRegistry(existing)) return existing;
100
+ const created = createKernelRegistry();
101
+ Reflect.set(globalThis, REGISTRY_KEY, created);
102
+ return created;
103
+ }
104
+
105
+ export const kernelRegistry: KernelRegistry = processKernelRegistry();
@@ -0,0 +1,230 @@
1
+ import type { HostToKernelMessage } from "../bridge/protocol.ts";
2
+ import {
3
+ type KernelLifecycle,
4
+ KernelUnavailableError,
5
+ restartNotice,
6
+ unstartedResult,
7
+ } from "../kernels/shared/kernel-death.ts";
8
+ import type {
9
+ EvalKernel,
10
+ EvalKernelResult,
11
+ EvalKernelRunInput,
12
+ EvalLanguage,
13
+ KernelInterruptHandle,
14
+ PendingCell,
15
+ } from "../tool/types.ts";
16
+
17
+ export type StartKernel = (lifecycle: Required<KernelLifecycle>) => Promise<EvalKernel>;
18
+
19
+ class ReplaceableKernelClosedError extends Error {
20
+ readonly name = "ReplaceableKernelClosedError";
21
+
22
+ constructor() {
23
+ super("Kernel closed");
24
+ }
25
+ }
26
+
27
+ /**
28
+ * The one kernel a session holds for a language whose instances can die (py, rb, jl). Every caller keeps
29
+ * this object for the life of a cell - its handler, its execution, its detached record - so a death never
30
+ * strands them: on the instance's own death event this evicts it, takes back the cells that never started,
31
+ * starts ONE replacement and runs them there in their original order, with their own callbacks. The first
32
+ * result of the replacement says the kernel was restarted. A replacement that dies before it finished a
33
+ * cell is not replaced again for the same cells: they fail with `eval_kernel_unavailable`, and the next
34
+ * cell submitted starts a fresh one. The cell that was running when the interpreter died is never re-run.
35
+ */
36
+ export class ReplaceableKernel implements EvalKernel {
37
+ readonly #language: EvalLanguage;
38
+ readonly #start: StartKernel;
39
+ #kernel: EvalKernel;
40
+ #generation = 0;
41
+ #nextGeneration = 0;
42
+ #held: PendingCell[] = [];
43
+ #replacing: Promise<void> | null = null;
44
+ /** The death the current instance replaced, until that instance settled a cell of its own. */
45
+ #recovering: string | null = null;
46
+ /** A death whose cells were failed instead of replaced: the next cell's fresh instance announces it. */
47
+ #unannounced: string | null = null;
48
+ /** Carried by the first result the current replacement produces. */
49
+ #announce: string | null = null;
50
+ #closed = false;
51
+ #closeFailure: unknown;
52
+
53
+ private constructor(language: EvalLanguage, start: StartKernel, first: EvalKernel) {
54
+ this.#language = language;
55
+ this.#start = start;
56
+ this.#kernel = first;
57
+ }
58
+
59
+ /** Starts the first instance; one that cannot hand back its queue is returned as it is. */
60
+ static async create(language: EvalLanguage, start: StartKernel): Promise<EvalKernel> {
61
+ let owner: ReplaceableKernel | undefined;
62
+ const first = await start({
63
+ onDeath: (reason) => {
64
+ if (owner !== undefined) owner.#died(0, reason);
65
+ },
66
+ });
67
+ if (first.drainPending === undefined) return first;
68
+ owner = new ReplaceableKernel(language, start, first);
69
+ return owner;
70
+ }
71
+
72
+ run(input: EvalKernelRunInput): Promise<EvalKernelResult> {
73
+ if (this.#closed) return Promise.reject(new ReplaceableKernelClosedError());
74
+ if (this.#replacing === null && this.#kernel.isAlive?.() !== false) {
75
+ return this.#submit(this.#generation, this.#kernel, input);
76
+ }
77
+ return new Promise((settle) => {
78
+ this.#held.push({ input, settle });
79
+ this.#ensureReplacement();
80
+ });
81
+ }
82
+
83
+ cancelQueued(cellId: string, reason: string): boolean {
84
+ return this.#cancelHeld(cellId, reason) || this.#kernel.cancelQueued(cellId, reason);
85
+ }
86
+
87
+ async interrupt(reason = "interrupted", cellId?: string): Promise<KernelInterruptHandle> {
88
+ if (cellId !== undefined && this.#cancelHeld(cellId, reason)) return { stateRetained: Promise.resolve(true) };
89
+ if (cellId === undefined) this.#failHeld(reason === "Eval interrupted" ? reason : `Eval interrupted: ${reason}`);
90
+ return await this.#kernel.interrupt(reason, cellId);
91
+ }
92
+
93
+ queueSnapshot(): { activeCellId: string | null; queuedCellIds: readonly string[] } {
94
+ const current = this.#kernel.queueSnapshot();
95
+ return {
96
+ activeCellId: current.activeCellId,
97
+ queuedCellIds: [...current.queuedCellIds, ...this.#held.map((cell) => cell.input.cellId)],
98
+ };
99
+ }
100
+
101
+ deliverToolReply(message: Extract<HostToKernelMessage, { type: "tool-reply" }>): void {
102
+ this.#kernel.deliverToolReply(message);
103
+ }
104
+
105
+ async reset(): Promise<void> {
106
+ if (this.#replacing !== null) await this.#replacing;
107
+ if (this.#kernel.isAlive?.() === false) {
108
+ // A reset discards every global anyway, so the fresh instance announces nothing.
109
+ this.#unannounced = null;
110
+ const replacing = this.#replace(null);
111
+ this.#replacing = replacing;
112
+ await replacing;
113
+ if (this.#replacing === replacing) this.#replacing = null;
114
+ return;
115
+ }
116
+ await this.#kernel.reset();
117
+ }
118
+
119
+ async close(): Promise<void> {
120
+ this.#closed = true;
121
+ const held = this.#held.splice(0);
122
+ for (const cell of held)
123
+ cell.settle(unstartedResult(cell.input.cellId, new ReplaceableKernelClosedError().message));
124
+ if (this.#replacing !== null) await this.#replacing;
125
+ if (this.#closeFailure !== undefined) throw this.#closeFailure;
126
+ await this.#kernel.close();
127
+ }
128
+
129
+ listKernelToolNames(): readonly string[] {
130
+ return this.#kernel.listKernelToolNames?.() ?? [];
131
+ }
132
+
133
+ #submit(generation: number, kernel: EvalKernel, input: EvalKernelRunInput): Promise<EvalKernelResult> {
134
+ return kernel.run(input).then((result) => {
135
+ if (generation !== this.#generation) return result;
136
+ // The result the kernel settled for the cell its death interrupted proves nothing about the
137
+ // replacement; a result from a still-live current instance proves it works.
138
+ // A drained cell settles through this same promise already tagged; only the interrupted one is `lost`.
139
+ if (kernel.isAlive?.() === false)
140
+ return result.kernelState === undefined ? { ...result, kernelState: "lost" } : result;
141
+ this.#recovering = null;
142
+ const notice = this.#announce;
143
+ if (notice === null) return result;
144
+ this.#announce = null;
145
+ return { ...result, notice, kernelState: "restarted" };
146
+ });
147
+ }
148
+
149
+ #died(generation: number, reason: string): void {
150
+ if (generation !== this.#generation || this.#closed) return;
151
+ this.#held = [...(this.#kernel.drainPending?.() ?? []), ...this.#held];
152
+ if (this.#recovering !== null) {
153
+ this.#recovering = null;
154
+ this.#unannounced = reason;
155
+ const error = new KernelUnavailableError(this.#language, reason);
156
+ for (const cell of this.#held.splice(0)) cell.settle(unstartedResult(cell.input.cellId, error.message));
157
+ return;
158
+ }
159
+ this.#recovering = reason;
160
+ this.#ensureReplacement();
161
+ }
162
+
163
+ #ensureReplacement(): void {
164
+ if (this.#replacing !== null) return;
165
+ const reason = this.#recovering ?? this.#unannounced;
166
+ this.#recovering = reason;
167
+ this.#unannounced = null;
168
+ const replacing = this.#replace(reason).finally(() => {
169
+ if (this.#replacing === replacing) this.#replacing = null;
170
+ });
171
+ this.#replacing = replacing;
172
+ }
173
+
174
+ /** Never rejects: every failure settles the held cells with what went wrong. */
175
+ async #replace(reason: string | null): Promise<void> {
176
+ const dead = this.#kernel;
177
+ try {
178
+ await dead.close();
179
+ } catch (error) {
180
+ // The old interpreter may still be running: report that rather than start a second one.
181
+ this.#failUnavailable(reason, `its interpreter could not be retired: ${errorText(error)}`);
182
+ return;
183
+ }
184
+ if (this.#closed) return;
185
+ const generation = ++this.#nextGeneration;
186
+ let next: EvalKernel;
187
+ try {
188
+ next = await this.#start({ onDeath: (died) => this.#died(generation, died) });
189
+ } catch (error) {
190
+ this.#failUnavailable(reason, `its replacement did not start: ${errorText(error)}`);
191
+ return;
192
+ }
193
+ if (this.#closed) {
194
+ await next.close().catch((error: unknown) => {
195
+ this.#closeFailure = error;
196
+ });
197
+ return;
198
+ }
199
+ this.#kernel = next;
200
+ this.#generation = generation;
201
+ this.#announce = reason === null ? null : restartNotice(this.#language, reason);
202
+ for (const cell of this.#held.splice(0)) {
203
+ void this.#submit(generation, next, cell.input).then(cell.settle, (error: unknown) =>
204
+ cell.settle(unstartedResult(cell.input.cellId, errorText(error))),
205
+ );
206
+ }
207
+ }
208
+
209
+ #failUnavailable(reason: string | null, detail: string): void {
210
+ this.#recovering = null;
211
+ this.#unannounced = reason;
212
+ const error = new KernelUnavailableError(this.#language, reason === null ? detail : `${reason}; ${detail}`);
213
+ for (const cell of this.#held.splice(0)) cell.settle(unstartedResult(cell.input.cellId, error.message));
214
+ }
215
+
216
+ #cancelHeld(cellId: string, reason: string): boolean {
217
+ const index = this.#held.findIndex((cell) => cell.input.cellId === cellId);
218
+ const [cell] = index < 0 ? [] : this.#held.splice(index, 1);
219
+ cell?.settle(unstartedResult(cellId, reason));
220
+ return cell !== undefined;
221
+ }
222
+
223
+ #failHeld(message: string): void {
224
+ for (const cell of this.#held.splice(0)) cell.settle(unstartedResult(cell.input.cellId, message));
225
+ }
226
+ }
227
+
228
+ function errorText(error: unknown): string {
229
+ return error instanceof Error ? error.message : String(error);
230
+ }
@@ -72,6 +72,7 @@ export async function createRuntime(
72
72
  const parallelPoolWidth = Number.isFinite(configuredPoolWidth) ? Math.max(1, Math.trunc(configuredPoolWidth)) : 1;
73
73
  const manager = await create({
74
74
  sessionId,
75
+ ownerSessionId: ctx.sessionManager.getSessionId(),
75
76
  cwd: ctx.cwd,
76
77
  sessionEnv,
77
78
  settings,
@@ -0,0 +1,42 @@
1
+ import type { ExtensionContext } from "@code-yeongyu/senpi";
2
+ import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
3
+ import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
4
+ import type { CodemodeSettings } from "../config/settings.ts";
5
+ import type { InterpreterAvailability } from "../interpreters/detect.ts";
6
+ import type { SessionEnvironment } from "../kernels/session-env.ts";
7
+ import type { EvalKernelManager, ExecuteTool } from "../tool/types.ts";
8
+
9
+ export interface CodemodeSessionManager extends EvalKernelManager {
10
+ dispose(): Promise<void>;
11
+ complete(request: CompletionRequest, ctx: ExtensionContext): Promise<CompletionResult>;
12
+ setContext?(ctx: ExtensionContext): void;
13
+ bridgeEndpoint?(): BridgeEndpoint;
14
+ }
15
+
16
+ export interface BridgeEndpoint {
17
+ readonly port: number;
18
+ readonly token: string;
19
+ }
20
+
21
+ export interface EvalExecutionTracker {
22
+ assertEvalExecutionAllowed(): void;
23
+ trackEvalExecution<Result>(execution: Promise<Result>, controller: AbortController): Promise<Result>;
24
+ }
25
+
26
+ export interface CreateCodemodeSessionManagerOptions {
27
+ readonly sessionId: string;
28
+ /** The agent session that owns these kernels, as the process kernel registry lists them; defaults to `sessionId`. */
29
+ readonly ownerSessionId?: string;
30
+ readonly cwd: string;
31
+ readonly settings: CodemodeSettings;
32
+ readonly availability: InterpreterAvailability;
33
+ /** Session-scoped roots exposed to kernel helpers such as local://. */
34
+ readonly localRoots?: Readonly<Record<string, string>>;
35
+ /** Session-adjacent directory used for persisted eval artifacts. */
36
+ readonly artifactsDir?: string;
37
+ /** Per-session PI_* values exposed to every kernel and the children it spawns. */
38
+ readonly sessionEnv?: SessionEnvironment;
39
+ readonly executeTool: ExecuteTool;
40
+ readonly listTools?: () => readonly EvalSchemaToolInfo[];
41
+ readonly complete: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
42
+ }