@code-yeongyu/senpi-codemode 2026.9.5 → 2026.9.7-2

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 (41) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +34 -5
  3. package/package.json +4 -4
  4. package/src/bridge/protocol.ts +1 -0
  5. package/src/bridge/reserved.ts +2 -0
  6. package/src/extension/runtime-factory.ts +3 -0
  7. package/src/extension/session-manager.ts +7 -0
  8. package/src/kernels/AGENTS.md +7 -0
  9. package/src/kernels/jl/kernel.ts +6 -2
  10. package/src/kernels/js/context-manager.ts +73 -129
  11. package/src/kernels/js/inline-worker.ts +2 -2
  12. package/src/kernels/js/interrupt-bounds.ts +66 -0
  13. package/src/kernels/js/kernel-contract.ts +3 -0
  14. package/src/kernels/js/run-queue.ts +18 -3
  15. package/src/kernels/js/worker-core.js +59 -0
  16. package/src/kernels/js/worker-indirect-eval.js +10 -6
  17. package/src/kernels/js/worker-runtime.js +16 -0
  18. package/src/kernels/js/worker-shell-capture.d.ts +25 -0
  19. package/src/kernels/js/worker-shell-capture.js +70 -8
  20. package/src/kernels/js/worker-slot.ts +106 -0
  21. package/src/kernels/js/worker-startup.ts +70 -0
  22. package/src/kernels/py/kernel-contract.ts +3 -0
  23. package/src/kernels/py/transport.ts +11 -3
  24. package/src/kernels/rb/kernel.ts +6 -2
  25. package/src/kernels/session-env.ts +56 -0
  26. package/src/kernels/shared/runtime-asset.ts +40 -14
  27. package/src/kernels/shared/subprocess-contract.ts +3 -0
  28. package/src/kernels/shared/subprocess-kernel.ts +9 -1
  29. package/src/output/output-meta.ts +26 -2
  30. package/src/prompt/eval-prompt.ts +10 -5
  31. package/src/tool/cell-execution.ts +9 -11
  32. package/src/tool/detached-cell-contract.ts +45 -0
  33. package/src/tool/detached-cell-manager.ts +43 -71
  34. package/src/tool/detached-cell-notification.ts +4 -5
  35. package/src/tool/detached-cell-snapshot.ts +2 -0
  36. package/src/tool/detached-cell-status.ts +30 -0
  37. package/src/tool/detached-eval-result.ts +1 -0
  38. package/src/tool/detached-notification-queue.ts +2 -2
  39. package/src/tool/image.ts +12 -3
  40. package/src/tool/interrupt-note.ts +29 -15
  41. package/src/tool/types.ts +2 -0
package/CHANGELOG.md CHANGED
@@ -12,6 +12,80 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.9.7-2] - 2026-09-07
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ ### Fixed
24
+
25
+ - The JS kernel's shell capture now pins the worker's environment view for `Bun.spawnSync` as well as `Bun.spawn`, so a cell calling it without an explicit `env` sees the session's `PI_*` values instead of the inherited OS environ.
26
+ - Eval kernels and every child they spawn now see the active session's `PI_*` environment (`PI_SESSION_ID`, `PI_SESSION_FILE`, `PI_PROVIDER`, `PI_MODEL`, `PI_REASONING_LEVEL`) exactly as bash-tool children do: inherited `PI_*` values are dropped before the session values are applied, so subprocesses such as `omo-agent-toolkit ulw-loop` resolve the same session as the `bash` tool instead of a cwd-global one.
27
+ - JavaScript eval cells no longer lose their completion value when a nested function, callback, or try/catch helper contains `return`: the cell wrapper now skips last-expression capture only for a genuine top-level `return`, and a property named `return` no longer primes the statement scanner as the keyword (#1439).
28
+ - Eval output truncation notices now name the real cause: a width-clamped line reports `N line(s) clamped to M columns (… dropped)`, a byte-capped tail reports the actual cap, and a notice never presents the output's own size as a limit.
29
+
30
+ ### Removed
31
+
32
+ ## [2026.9.7] - 2026-09-07
33
+
34
+ ### Breaking Changes
35
+
36
+ ### Added
37
+
38
+ ### Changed
39
+
40
+ ### Fixed
41
+
42
+ ### Removed
43
+
44
+ ## [2026.9.6] - 2026-09-06
45
+
46
+ ### Breaking Changes
47
+
48
+ ### Added
49
+
50
+ ### Changed
51
+
52
+ - The Bun eval description now tells the model to shell out through `Bun.$` or `Bun.spawn` and never `Bun.spawnSync`, because a synchronous child blocks the worker and a stop or timeout then loses every variable.
53
+ - JavaScript eval cells now interrupt cooperatively: `stop` and kernel timeouts first ask the worker to settle the cell (pending bridge `tool.*` calls are rejected, `Bun.spawn` children are killed) and keep the worker VM and its globals when the cell settles within a 2 s grace; only an unsettled cell restarts the worker.
54
+
55
+ ### Fixed
56
+
57
+ - `eval({ action: "stop" })` no longer hangs when the JavaScript worker is blocked in a synchronous call such as `Bun.spawnSync`: worker termination is bounded by a 3 s deadline, a fresh worker replaces the blocked one, and the cell output names the blocked synchronous call.
58
+ - `Bun.$` commands run from a JavaScript cell no longer inherit the TUI's terminal as stdin (a stdin reader such as `cat`, an ssh or git credential prompt, or a keychain prompt blocked the cell forever); the shell wrapper isolates stdin while a cell is active without changing output, exit codes, `cwd`, `env`, or explicit stdin redirects.
59
+ - Stop results and detached-cell completion notifications report the real interrupt outcome (variables preserved, worker restarted, or outcome unknown) instead of a hardcoded per-language note.
60
+
61
+ ### Removed
62
+
63
+ ## [2026.9.5-3] - 2026-09-05
64
+
65
+ ### Breaking Changes
66
+
67
+ ### Added
68
+
69
+ ### Changed
70
+
71
+ ### Fixed
72
+
73
+ ### Removed
74
+
75
+ ## [2026.9.5-2] - 2026-09-05
76
+
77
+ ### Breaking Changes
78
+
79
+ ### Added
80
+
81
+ ### Changed
82
+
83
+ - The GPT eval dialect now routes a wait or a long run through `tool.monitor` inside the cell (the subscription line precedes the detach note, and the `## Tool Guidelines` line says so when `monitor` is reachable), so a GPT model no longer reads "long cells detach" as the way to wait on a `--watch`.
84
+
85
+ ### Fixed
86
+
87
+ ### Removed
88
+
15
89
  ## [2026.9.5] - 2026-09-05
16
90
 
17
91
  ### Breaking Changes
package/README.md CHANGED
@@ -54,6 +54,19 @@ task-tool names are known.
54
54
  A missing optional interpreter removes that language from the session's `eval`
55
55
  schema; it is not an installation failure.
56
56
 
57
+ ### Session environment
58
+
59
+ Every kernel starts with the active session's `PI_*` environment — `PI_SESSION_ID`,
60
+ `PI_SESSION_FILE` (when the session is persistent), `PI_PROVIDER`, `PI_MODEL`, and
61
+ `PI_REASONING_LEVEL` (when set) — resolved at session start, mirroring the bash tool's
62
+ session environment contract. The values are visible to `env()`/`process.env`/`os.environ`
63
+ inside cells and are inherited by every child process a cell spawns
64
+ (`Bun.$`, `Bun.spawn`, `child_process`, `subprocess`, ...). Inherited `PI_*` values from
65
+ the launching environment are dropped first, so a child spawned from a cell sees exactly
66
+ what a child spawned from the bash tool sees. The values snapshot at kernel start, so a
67
+ mid-session model switch updates the bash tool's next command but not already-running
68
+ kernels; a new session starts fresh kernels with fresh values.
69
+
57
70
  ## Settings
58
71
 
59
72
  Configuration is loaded in this order:
@@ -116,7 +129,7 @@ options object and asynchronous helpers are `await`-able.
116
129
  | `print(value, ...)` | Emits text output. |
117
130
  | `read(path, offset?, limit?)` | Reads text with 1-indexed line slicing. `local://` paths resolve under the session artifact root. |
118
131
  | `write(path, content)` | Creates parent directories and writes text. `local://` paths persist in the session artifact root. |
119
- | `env(key?, value?)` | Reads all kernel environment values, one value, or sets one value. |
132
+ | `env(key?, value?)` | Reads all kernel environment values, one value, or sets one value. Includes the session's `PI_*` values (see [Session environment](#session-environment)). |
120
133
  | `tool.<name>(args)` | Invokes an active Senpi tool through the normal `pi.executeTool` pipeline and returns `{ text, images?, details?, hasError? }` in every kernel; image blocks arrive as `images[i] = { mimeType, dataBase64 }`. |
121
134
  | `tool_schema(name?)` | Returns a tool's parameter schema without calling it; omit `name` to list tool names. |
122
135
  | `completion(prompt, model?, system?, schema?)` | Requests a one-shot host completion; `schema` asks the host to parse structured output. |
@@ -166,10 +179,26 @@ when the call had no summary), clearing as soon as the last detached cell settle
166
179
 
167
180
  Use `eval({ action: "peek", cell_id })` for its state and buffered output, or
168
181
  `eval({ action: "stop", cell_id })` to cancel it. Python stop interrupts the
169
- existing kernel and preserves variables. JavaScript stop kills and restarts its
170
- worker, so JavaScript VM state is lost. Detached completion messages state when
171
- kernel variables are available to the next eval cell; oversized buffered output
172
- is written under the session local root and referenced as `local://…`.
182
+ existing kernel and preserves variables. JavaScript stop is cooperative first:
183
+ the worker rejects the cell's pending bridge `tool.*` calls and kills the
184
+ `Bun.spawn` children it started, and a cell that settles within the 2 s grace
185
+ keeps the worker and every global. Only a cell that stays unsettled (a
186
+ never-resolving promise, an un-abortable `fetch`, a `Bun.$` command) costs the
187
+ worker VM. A worker blocked in a synchronous call (`Bun.spawnSync`,
188
+ `child_process.spawnSync`) cannot be stopped at all; after a 3 s termination
189
+ deadline a fresh worker replaces it, the cell output gains a stderr line naming
190
+ the blocked synchronous call, and the blocked call keeps running until it
191
+ returns. Kernel-level timeouts follow the same path. Stop results and detached
192
+ completion messages report the real outcome - variables preserved, worker
193
+ restarted, or outcome unknown - never a per-language assumption; oversized
194
+ buffered output is written under the session local root and referenced as
195
+ `local://…`.
196
+
197
+ Commands a cell runs through `Bun.$` never read the host's terminal: the worker
198
+ thread shares the TUI's stdin, so the shell wrapper hands every template an
199
+ empty pipe (`true | ( … )`) while a cell is active. Output, exit codes, `cwd`,
200
+ `env`, and explicit `< ${input}` redirects are unchanged; `Bun.spawn` and
201
+ `Bun.spawnSync` already default stdin to `/dev/null`.
173
202
 
174
203
  ## Output and artifacts
175
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.9.5",
3
+ "version": "2026.9.7-2",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -30,14 +30,14 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@babel/parser": "8.0.4",
33
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.5",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.7-2",
34
34
  "typebox": "1.3.18"
35
35
  },
36
36
  "peerDependencies": {
37
- "@code-yeongyu/senpi": "2026.9.5"
37
+ "@code-yeongyu/senpi": "2026.9.7-2"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.9.5"
40
+ "@code-yeongyu/senpi": "2026.9.7-2"
41
41
  },
42
42
  "keywords": [
43
43
  "senpi",
@@ -26,6 +26,7 @@ const hostToKernelMessageSchema = Type.Union([
26
26
  type: Type.Literal("init"),
27
27
  sessionId: Type.String({ minLength: 1 }),
28
28
  connection: connectionConfigSchema,
29
+ sessionEnv: Type.Optional(Type.Record(Type.String(), Type.String())),
29
30
  }),
30
31
  Type.Object({
31
32
  type: Type.Literal("run"),
@@ -9,3 +9,5 @@ export const RESERVED_SCHEMA_TOOL = "__schema__" as const;
9
9
  export const TIMEOUT_PAUSE_OP = "timeout-pause" as const;
10
10
  /** Canonical oh-my-pi eval-timeout resume operation. */
11
11
  export const TIMEOUT_RESUME_OP = "timeout-resume" as const;
12
+ /** Status op the JS worker emits the moment it receives `interrupt`; proves its event loop is not blocked. */
13
+ export const INTERRUPT_ACK_OP = "interrupt-ack" as const;
@@ -13,6 +13,7 @@ import {
13
13
  getInterpreterAvailability,
14
14
  type InterpreterAvailability,
15
15
  } from "../interpreters/detect.ts";
16
+ import { sessionEnvironmentFrom } from "../kernels/session-env.ts";
16
17
  import { resolveSessionArtifactsDir } from "../output/streaming-output.ts";
17
18
  import type { EnabledEvalLanguages, EvalLanguage, EvalRuntimes } from "../tool/types.ts";
18
19
  import { jsRuntimeInfo, runtimesFromAvailability } from "./runtime-info.ts";
@@ -66,11 +67,13 @@ export async function createRuntime(
66
67
  const executeTool = createExecuteTool(pi, activeTools);
67
68
  const create = options.createSessionManager ?? createCodemodeSessionManager;
68
69
  const sessionId = sessionIdFrom(event);
70
+ const sessionEnv = sessionEnvironmentFrom(ctx);
69
71
  const configuredPoolWidth = settings.parallelPoolWidth;
70
72
  const parallelPoolWidth = Number.isFinite(configuredPoolWidth) ? Math.max(1, Math.trunc(configuredPoolWidth)) : 1;
71
73
  const manager = await create({
72
74
  sessionId,
73
75
  cwd: ctx.cwd,
76
+ sessionEnv,
74
77
  settings,
75
78
  availability,
76
79
  artifactsDir: artifacts.dir,
@@ -11,6 +11,7 @@ import { JuliaKernel } from "../kernels/jl/kernel.ts";
11
11
  import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
12
12
  import { PythonKernel } from "../kernels/py/kernel.ts";
13
13
  import { RubyKernel } from "../kernels/rb/kernel.ts";
14
+ import type { SessionEnvironment } from "../kernels/session-env.ts";
14
15
  import { marshalToolResult } from "../tool/image.ts";
15
16
  import type { EvalKernel, EvalKernelManager, EvalLanguage, ExecuteTool } from "../tool/types.ts";
16
17
 
@@ -40,6 +41,8 @@ export interface CreateCodemodeSessionManagerOptions {
40
41
  readonly localRoots?: Readonly<Record<string, string>>;
41
42
  /** Session-adjacent directory used for persisted eval artifacts. */
42
43
  readonly artifactsDir?: string;
44
+ /** Per-session PI_* values exposed to every kernel and the children it spawns. */
45
+ readonly sessionEnv?: SessionEnvironment;
43
46
  readonly executeTool: ExecuteTool;
44
47
  readonly listTools?: () => readonly EvalSchemaToolInfo[];
45
48
  readonly complete: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
@@ -212,6 +215,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
212
215
  cwd: this.#options.cwd,
213
216
  parallelPoolWidth,
214
217
  onMessage,
218
+ ...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
215
219
  ...(localRoots ? { localRoots: { ...localRoots } } : {}),
216
220
  ...(this.#options.artifactsDir ? { artifactsDir: this.#options.artifactsDir } : {}),
217
221
  });
@@ -230,6 +234,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
230
234
  interpreterPath: detected.path,
231
235
  sessionId: this.#options.sessionId,
232
236
  cwd: this.#options.cwd,
237
+ ...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
233
238
  connection,
234
239
  onMessage,
235
240
  });
@@ -239,6 +244,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
239
244
  command: detected.path,
240
245
  sessionId: this.#options.sessionId,
241
246
  cwd: this.#options.cwd,
247
+ ...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
242
248
  connection,
243
249
  onMessage,
244
250
  });
@@ -247,6 +253,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
247
253
  command: detected.path,
248
254
  sessionId: this.#options.sessionId,
249
255
  cwd: this.#options.cwd,
256
+ ...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
250
257
  connection,
251
258
  onMessage,
252
259
  });
@@ -15,6 +15,7 @@ runner/prelude assets).
15
15
  | Ruby kernel | `rb/kernel.ts` + `rb/prelude.rb`, `rb/runner.rb` |
16
16
  | Julia kernel | `jl/kernel.ts` + `jl/prelude.jl`, `jl/runner.jl` |
17
17
  | Shared subprocess layer | `shared/subprocess-kernel.ts`, `subprocess-{contract,process,queue,run}.ts`, `runtime-asset.ts` |
18
+ | Session environment | `session-env.ts` (PI_* contract shared by all kernels; mirrors the core bash tool) |
18
19
 
19
20
  ## CONVENTIONS
20
21
 
@@ -29,6 +30,12 @@ runner/prelude assets).
29
30
  framed subprocesses through `shared/`.
30
31
  - Subprocess retirement/restart, worker recovery, timeout, and interrupt
31
32
  semantics live here, never in the tool layer.
33
+ - Every kernel exposes the active session's `PI_*` environment (`session-env.ts`):
34
+ inherited values are deleted before the session's values are applied, so any
35
+ child spawned from a cell sees the same session environment a bash-tool child
36
+ sees. The JS worker applies it at init (`worker-core.js`; shell capture pins
37
+ the env view under Bun because `delete process.env.X` does not unsetenv),
38
+ and py/rb/jl spawn with it merged into the interpreter environment.
32
39
 
33
40
  ## ANTI-PATTERNS
34
41
 
@@ -1,12 +1,15 @@
1
1
  import { join } from "node:path";
2
2
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
3
- import { type CodemodeRuntimeAssetEnvironment, resolveCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
3
+ import type { SessionEnvironment } from "../session-env.ts";
4
+ import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
4
5
  import { SubprocessKernel, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
5
6
 
6
7
  export interface JuliaKernelStartOptions {
7
8
  readonly cwd: string;
8
9
  readonly sessionId: string;
9
10
  readonly connection: BridgeConnectionConfig;
11
+ /** Per-session PI_* values merged into the interpreter environment at spawn. */
12
+ readonly sessionEnv?: SessionEnvironment;
10
13
  readonly command?: string;
11
14
  readonly spawn?: SubprocessSpawn;
12
15
  readonly onMessage?: (message: KernelToHostMessage) => void;
@@ -17,7 +20,7 @@ export interface JuliaRunnerPathOptions extends CodemodeRuntimeAssetEnvironment
17
20
  }
18
21
 
19
22
  export function resolveJuliaRunnerPath(options: JuliaRunnerPathOptions = {}): string {
20
- return resolveCodemodeRuntimeAsset(
23
+ return requireCodemodeRuntimeAsset(
21
24
  options.localPath ?? join(import.meta.dirname, "runner.jl"),
22
25
  join("kernels", "jl", "runner.jl"),
23
26
  options,
@@ -42,6 +45,7 @@ export class JuliaKernel extends SubprocessKernel {
42
45
  ],
43
46
  cwd: options.cwd,
44
47
  sessionId: options.sessionId,
48
+ sessionEnv: options.sessionEnv,
45
49
  connection: options.connection,
46
50
  spawn: options.spawn,
47
51
  onMessage: options.onMessage,
@@ -1,9 +1,7 @@
1
- import { dirname, join } from "node:path";
2
- import { fileURLToPath, pathToFileURL } from "node:url";
3
1
  import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import { INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
4
3
  import type { KernelInterruptHandle } from "../../tool/types.ts";
5
- import { type CodemodeRuntimeAssetEnvironment, resolveCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
6
- import { createInlineWorker, type WorkerLike } from "./inline-worker.ts";
4
+ import { abandonedWorkerNote, awaitCooperativeSettlement, type WorkerRetirement } from "./interrupt-bounds.ts";
7
5
  import {
8
6
  assertJavaScriptKernelOpen,
9
7
  type JavaScriptKernelMode,
@@ -12,31 +10,20 @@ import {
12
10
  type ResultMessage,
13
11
  type ToolCallMessage,
14
12
  } from "./kernel-contract.ts";
15
- import { type JavaScriptKernelOptions, LocalModuleLoader, localBridgeConnection } from "./local-module-loader.ts";
13
+ import { type JavaScriptKernelOptions, LocalModuleLoader } from "./local-module-loader.ts";
16
14
  import { JavaScriptRunQueue, type PendingJavaScriptRun, stoppedResult } from "./run-queue.ts";
17
- import { bridgeError, spawnNodeWorker, WorkerStartupCancelledError, waitForReady } from "./worker-host.ts";
15
+ import { bridgeError, WorkerStartupCancelledError } from "./worker-host.ts";
16
+ import { WorkerSlot } from "./worker-slot.ts";
18
17
 
19
18
  export { JavaScriptKernelClosedError, type JavaScriptKernelMode, type JavaScriptRunInput } from "./kernel-contract.ts";
20
19
  export type { JavaScriptKernelOptions } from "./local-module-loader.ts";
21
-
22
- export interface JavaScriptWorkerEntryUrlOptions extends CodemodeRuntimeAssetEnvironment {
23
- readonly localPath?: string;
24
- }
25
-
26
- export function resolveJsWorkerEntryUrl(options: JavaScriptWorkerEntryUrlOptions = {}): URL {
27
- const localPath = options.localPath ?? join(dirname(fileURLToPath(import.meta.url)), "worker-entry.js");
28
- return pathToFileURL(resolveCodemodeRuntimeAsset(localPath, join("kernels", "js", "worker-entry.js"), options));
29
- }
20
+ export { type JavaScriptWorkerEntryUrlOptions, resolveJsWorkerEntryUrl } from "./worker-startup.ts";
30
21
 
31
22
  export class JavaScriptKernel {
32
23
  readonly #options: JavaScriptKernelOptions;
33
24
  readonly #moduleLoader: LocalModuleLoader;
34
- #worker: WorkerLike | null = null;
35
- #mode: JavaScriptKernelMode = "worker";
25
+ readonly #slot: WorkerSlot;
36
26
  #lifecycle: LifecycleState = "open";
37
- #ready: Promise<void> | null = null;
38
- #startupAbort: AbortController | null = null;
39
- #generation = 0;
40
27
  #activation: Promise<void> | null = null;
41
28
  #recovery: Promise<void> | null = null;
42
29
  #closePromise: Promise<void> | null = null;
@@ -48,10 +35,15 @@ export class JavaScriptKernel {
48
35
  constructor(options: JavaScriptKernelOptions) {
49
36
  this.#options = options;
50
37
  this.#moduleLoader = new LocalModuleLoader(options);
38
+ this.#slot = new WorkerSlot(options, {
39
+ isOpen: () => this.#lifecycle === "open",
40
+ onMessage: (message) => this.#handleMessage(message),
41
+ onCrash: (error) => this.#handleCrash(error),
42
+ });
51
43
  }
52
44
 
53
45
  get mode(): JavaScriptKernelMode {
54
- return this.#mode;
46
+ return this.#slot.mode;
55
47
  }
56
48
 
57
49
  async run(input: JavaScriptRunInput): Promise<ResultMessage> {
@@ -64,13 +56,16 @@ export class JavaScriptKernel {
64
56
  async interrupt(reason = "interrupted"): Promise<KernelInterruptHandle> {
65
57
  assertJavaScriptKernelOpen(this.#lifecycle, "interrupt");
66
58
  const active = this.#runs.active;
67
- const target = this.#runs.takeInterruptTarget();
68
- if (!target) return { stateRetained: Promise.resolve(true) };
69
- if (target === active) this.#clearTimeout();
70
- this.#runs.settle(target, stoppedResult(target.input.cellId, `JS cell interrupted: ${reason}`));
71
- await this.#restartAfterStop();
72
- // A restart always replaces the worker VM, so no user global survives.
73
- return { stateRetained: Promise.resolve(false) };
59
+ if (!active) {
60
+ const queued = this.#runs.takeInterruptTarget();
61
+ if (!queued) return { stateRetained: Promise.resolve(true) };
62
+ this.#runs.settle(queued, stoppedResult(queued.input.cellId, `JS cell interrupted: ${reason}`));
63
+ await this.#restartAfterStop();
64
+ return { stateRetained: Promise.resolve(false) };
65
+ }
66
+ this.#clearTimeout();
67
+ const stop = await this.#stopActive(active, reason, `JS cell interrupted: ${reason}`);
68
+ return { stateRetained: Promise.resolve(stop.retained), ...(stop.note === undefined ? {} : { note: stop.note }) };
74
69
  }
75
70
 
76
71
  async reset(): Promise<void> {
@@ -82,7 +77,7 @@ export class JavaScriptKernel {
82
77
  }
83
78
 
84
79
  deliverToolReply(message: Extract<HostToKernelMessage, { type: "tool-reply" }>): void {
85
- if (this.#lifecycle === "open") this.#worker?.postMessage(message);
80
+ if (this.#lifecycle === "open") this.#slot.postMessage(message);
86
81
  }
87
82
 
88
83
  async nextToolCall(): Promise<ToolCallMessage> {
@@ -93,7 +88,7 @@ export class JavaScriptKernel {
93
88
 
94
89
  async close(): Promise<void> {
95
90
  if (this.#closePromise) return await this.#closePromise;
96
- this.#worker?.postMessage({ type: "close" });
91
+ this.#slot.postMessage({ type: "close" });
97
92
  this.#lifecycle = "closing";
98
93
  this.#runs.settleAll("JS kernel closed");
99
94
  const recovery = this.#recovery;
@@ -129,92 +124,17 @@ export class JavaScriptKernel {
129
124
 
130
125
  async #ensureReady(): Promise<void> {
131
126
  assertJavaScriptKernelOpen(this.#lifecycle, "run");
132
- if (!this.#ready) {
133
- const generation = ++this.#generation;
134
- const controller = new AbortController();
135
- this.#startupAbort = controller;
136
- const ready = this.#startWorker(generation, controller.signal);
137
- this.#ready = ready;
138
- void ready.then(
139
- () => {
140
- if (this.#ready === ready) this.#startupAbort = null;
141
- },
142
- () => {
143
- if (this.#ready === ready) {
144
- this.#ready = null;
145
- this.#startupAbort = null;
146
- }
147
- },
148
- );
149
- }
150
- return await this.#ready;
151
- }
152
-
153
- async #startWorker(generation: number, signal: AbortSignal): Promise<void> {
154
- let worker = this.#spawnWorker();
155
- this.#publishWorker(worker, generation);
156
- try {
157
- await this.#initializeWorker(worker, signal);
158
- return;
159
- } catch (error) {
160
- if (!this.#isCurrent(worker, generation) || error instanceof WorkerStartupCancelledError) {
161
- await worker.terminate();
162
- throw new WorkerStartupCancelledError();
163
- }
164
- if (worker.mode === "inline") throw error;
165
- this.#worker = null;
166
- await worker.terminate();
167
- }
168
- if (this.#lifecycle !== "open" || generation !== this.#generation) throw new WorkerStartupCancelledError();
169
- worker = createInlineWorker(this.#options.cwd, this.#options.parallelPoolWidth);
170
- this.#publishWorker(worker, generation);
171
- await this.#initializeWorker(worker, signal);
172
- }
173
-
174
- #spawnWorker(): WorkerLike {
175
- try {
176
- const url = this.#options.workerEntryUrl ?? resolveJsWorkerEntryUrl();
177
- return spawnNodeWorker(url, this.#options.cwd, this.#options.parallelPoolWidth);
178
- } catch (error) {
179
- if (!(error instanceof Error)) throw error;
180
- return createInlineWorker(this.#options.cwd, this.#options.parallelPoolWidth);
181
- }
182
- }
183
-
184
- #publishWorker(worker: WorkerLike, generation: number): void {
185
- if (this.#lifecycle !== "open" || generation !== this.#generation) throw new WorkerStartupCancelledError();
186
- this.#worker = worker;
187
- this.#mode = worker.mode;
188
- worker.onMessage((message) => {
189
- if (this.#isCurrent(worker, generation)) this.#handleMessage(message);
190
- });
191
- worker.onError((error) => {
192
- if (this.#isCurrent(worker, generation)) this.#handleCrash(error);
193
- });
194
- }
195
-
196
- async #initializeWorker(worker: WorkerLike, signal: AbortSignal): Promise<void> {
197
- const ready = waitForReady(worker, signal);
198
- worker.postMessage({
199
- type: "init",
200
- sessionId: this.#options.sessionId,
201
- connection: localBridgeConnection(this.#options),
202
- });
203
- await ready;
204
- }
205
-
206
- #isCurrent(worker: WorkerLike, generation: number): boolean {
207
- return this.#lifecycle === "open" && this.#worker === worker && this.#generation === generation;
127
+ await this.#slot.ensureReady();
208
128
  }
209
129
 
210
130
  #startNext(): void {
211
- if (this.#lifecycle !== "open" || this.#runs.active || !this.#worker) return;
131
+ if (this.#lifecycle !== "open" || this.#runs.active || !this.#slot.present) return;
212
132
  const next = this.#runs.startNext(performance.now());
213
133
  if (!next) return;
214
134
  if (next.input.timeoutMs) {
215
135
  this.#timeout = setTimeout(() => void this.#timeoutActive(next), next.input.timeoutMs);
216
136
  }
217
- this.#worker.postMessage({
137
+ this.#slot.postMessage({
218
138
  type: "run",
219
139
  cellId: next.input.cellId,
220
140
  code: this.#moduleLoader.prepareCell(next.input.code),
@@ -223,21 +143,46 @@ export class JavaScriptKernel {
223
143
  }
224
144
 
225
145
  async #timeoutActive(run: PendingJavaScriptRun): Promise<void> {
226
- if (!this.#runs.releaseActive(run)) return;
146
+ if (this.#runs.active !== run || run.settled) return;
227
147
  const durationMs = run.input.timeoutMs ?? 0;
228
- this.#runs.settle(run, {
229
- type: "result",
230
- cellId: run.input.cellId,
231
- ok: false,
232
- error: { message: `JS cell timed out after ${durationMs}ms` },
148
+ await this.#stopActive(
149
+ run,
150
+ `timed out after ${durationMs}ms`,
151
+ `JS cell timed out after ${durationMs}ms`,
233
152
  durationMs,
234
- });
235
- await this.#restartAfterStop();
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Asks the worker to settle the active cell cooperatively (rejecting its bridge calls and killing its
158
+ * children); only a cell that stays unsettled past the grace costs the worker VM. Reports whether the
159
+ * worker state survived and, when a blocked worker had to be abandoned, the note that explains it.
160
+ */
161
+ async #stopActive(
162
+ run: PendingJavaScriptRun,
163
+ reason: string,
164
+ message: string,
165
+ durationMs = 0,
166
+ ): Promise<{ readonly retained: boolean; readonly note?: string }> {
167
+ run.interruptResult = { type: "result", cellId: run.input.cellId, ok: false, error: { message }, durationMs };
168
+ run.interruptAck ??= Promise.withResolvers<void>();
169
+ this.#slot.postMessage({ type: "interrupt", reason });
170
+ if ((await awaitCooperativeSettlement(run)) === "settled") return { retained: run.settledByWorker };
171
+ if (!this.#runs.releaseActive(run)) return { retained: run.settledByWorker };
172
+ const retirement = await this.#terminate();
173
+ this.#runs.settle(run, run.interruptResult ?? stoppedResult(run.input.cellId, message));
174
+ void this.#recover(() => Promise.resolve());
175
+ return retirement === "abandoned" ? { retained: false, note: abandonedWorkerNote() } : { retained: false };
236
176
  }
237
177
 
238
178
  async #restartAfterStop(): Promise<void> {
179
+ await this.#recover(() => this.#terminate());
180
+ }
181
+
182
+ /** One recovery at a time: retire through `retire` (a no-op when the worker is already gone), then bring a fresh worker up. */
183
+ async #recover(retire: () => Promise<unknown>): Promise<void> {
239
184
  if (this.#recovery) return await this.#recovery;
240
- const recovery = this.#performRestartAfterStop();
185
+ const recovery = this.#performRecovery(retire);
241
186
  this.#recovery = recovery;
242
187
  try {
243
188
  await recovery;
@@ -246,9 +191,9 @@ export class JavaScriptKernel {
246
191
  }
247
192
  }
248
193
 
249
- async #performRestartAfterStop(): Promise<void> {
194
+ async #performRecovery(retire: () => Promise<unknown>): Promise<void> {
250
195
  try {
251
- await this.#terminate();
196
+ await retire();
252
197
  if (this.#lifecycle !== "open") return;
253
198
  await this.#ensureReady();
254
199
  if (this.#lifecycle === "open") this.#startNext();
@@ -259,6 +204,10 @@ export class JavaScriptKernel {
259
204
  }
260
205
 
261
206
  #handleMessage(message: KernelToHostMessage): void {
207
+ if (message.type === "status" && message.event.op === INTERRUPT_ACK_OP) {
208
+ this.#runs.active?.interruptAck?.resolve();
209
+ return;
210
+ }
262
211
  this.#options.onMessage?.(message);
263
212
  this.#runs.active?.input.onMessage?.(message);
264
213
  if (message.type === "tool-call") {
@@ -272,13 +221,14 @@ export class JavaScriptKernel {
272
221
  if (!active || active.input.cellId !== message.cellId) return;
273
222
  this.#clearTimeout();
274
223
  this.#runs.releaseActive(active);
275
- this.#runs.settle(active, message);
224
+ active.settledByWorker = true;
225
+ this.#runs.settle(active, active.interruptResult ?? message);
276
226
  this.#startNext();
277
227
  }
278
228
 
279
229
  #handleCrash(error: Error): void {
280
230
  const active = this.#runs.active;
281
- if (!active && this.#startupAbort) return;
231
+ if (!active && this.#slot.startingUp) return;
282
232
  this.#clearTimeout();
283
233
  if (active) {
284
234
  this.#runs.releaseActive(active);
@@ -298,14 +248,8 @@ export class JavaScriptKernel {
298
248
  this.#timeout = null;
299
249
  }
300
250
 
301
- async #terminate(): Promise<void> {
251
+ async #terminate(): Promise<WorkerRetirement> {
302
252
  this.#clearTimeout();
303
- this.#generation += 1;
304
- this.#startupAbort?.abort();
305
- this.#startupAbort = null;
306
- this.#ready = null;
307
- const worker = this.#worker;
308
- this.#worker = null;
309
- if (worker) await worker.terminate();
253
+ return await this.#slot.retire();
310
254
  }
311
255
  }
@@ -1,7 +1,7 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath, pathToFileURL } from "node:url";
3
3
  import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
4
- import { type CodemodeRuntimeAssetEnvironment, resolveCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
4
+ import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
5
5
  import type { JavaScriptKernelMode } from "./kernel-contract.ts";
6
6
  import { spawnNodeWorker } from "./worker-host.ts";
7
7
 
@@ -12,7 +12,7 @@ export interface JavaScriptInlineWorkerEntryUrlOptions extends CodemodeRuntimeAs
12
12
  export function resolveInlineWorkerEntryUrl(options: JavaScriptInlineWorkerEntryUrlOptions = {}): URL {
13
13
  const localPath = options.localPath ?? join(dirname(fileURLToPath(import.meta.url)), "inline-worker-entry.js");
14
14
  return pathToFileURL(
15
- resolveCodemodeRuntimeAsset(localPath, join("kernels", "js", "inline-worker-entry.js"), options),
15
+ requireCodemodeRuntimeAsset(localPath, join("kernels", "js", "inline-worker-entry.js"), options),
16
16
  );
17
17
  }
18
18