@code-yeongyu/senpi-codemode 2026.9.5 → 2026.9.7

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,63 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.9.7] - 2026-09-07
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ ### Fixed
24
+
25
+ ### Removed
26
+
27
+ ## [2026.9.6] - 2026-09-06
28
+
29
+ ### Breaking Changes
30
+
31
+ ### Added
32
+
33
+ ### Changed
34
+
35
+ - 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.
36
+ - 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.
37
+
38
+ ### Fixed
39
+
40
+ - `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.
41
+ - `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.
42
+ - 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.
43
+
44
+ ### Removed
45
+
46
+ ## [2026.9.5-3] - 2026-09-05
47
+
48
+ ### Breaking Changes
49
+
50
+ ### Added
51
+
52
+ ### Changed
53
+
54
+ ### Fixed
55
+
56
+ ### Removed
57
+
58
+ ## [2026.9.5-2] - 2026-09-05
59
+
60
+ ### Breaking Changes
61
+
62
+ ### Added
63
+
64
+ ### Changed
65
+
66
+ - 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`.
67
+
68
+ ### Fixed
69
+
70
+ ### Removed
71
+
15
72
  ## [2026.9.5] - 2026-09-05
16
73
 
17
74
  ### Breaking Changes
package/README.md CHANGED
@@ -166,10 +166,26 @@ when the call had no summary), clearing as soon as the last detached cell settle
166
166
 
167
167
  Use `eval({ action: "peek", cell_id })` for its state and buffered output, or
168
168
  `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://…`.
169
+ existing kernel and preserves variables. JavaScript stop is cooperative first:
170
+ the worker rejects the cell's pending bridge `tool.*` calls and kills the
171
+ `Bun.spawn` children it started, and a cell that settles within the 2 s grace
172
+ keeps the worker and every global. Only a cell that stays unsettled (a
173
+ never-resolving promise, an un-abortable `fetch`, a `Bun.$` command) costs the
174
+ worker VM. A worker blocked in a synchronous call (`Bun.spawnSync`,
175
+ `child_process.spawnSync`) cannot be stopped at all; after a 3 s termination
176
+ deadline a fresh worker replaces it, the cell output gains a stderr line naming
177
+ the blocked synchronous call, and the blocked call keeps running until it
178
+ returns. Kernel-level timeouts follow the same path. Stop results and detached
179
+ completion messages report the real outcome - variables preserved, worker
180
+ restarted, or outcome unknown - never a per-language assumption; oversized
181
+ buffered output is written under the session local root and referenced as
182
+ `local://…`.
183
+
184
+ Commands a cell runs through `Bun.$` never read the host's terminal: the worker
185
+ thread shares the TUI's stdin, so the shell wrapper hands every template an
186
+ empty pipe (`true | ( … )`) while a cell is active. Output, exit codes, `cwd`,
187
+ `env`, and explicit `< ${input}` redirects are unchanged; `Bun.spawn` and
188
+ `Bun.spawnSync` already default stdin to `/dev/null`.
173
189
 
174
190
  ## Output and artifacts
175
191
 
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",
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",
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"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.9.5"
40
+ "@code-yeongyu/senpi": "2026.9.7"
41
41
  },
42
42
  "keywords": [
43
43
  "senpi",
@@ -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;
@@ -1,6 +1,6 @@
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 CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
4
4
  import { SubprocessKernel, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
5
5
 
6
6
  export interface JuliaKernelStartOptions {
@@ -17,7 +17,7 @@ export interface JuliaRunnerPathOptions extends CodemodeRuntimeAssetEnvironment
17
17
  }
18
18
 
19
19
  export function resolveJuliaRunnerPath(options: JuliaRunnerPathOptions = {}): string {
20
- return resolveCodemodeRuntimeAsset(
20
+ return requireCodemodeRuntimeAsset(
21
21
  options.localPath ?? join(import.meta.dirname, "runner.jl"),
22
22
  join("kernels", "jl", "runner.jl"),
23
23
  options,
@@ -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
 
@@ -0,0 +1,66 @@
1
+ import type { WorkerLike } from "./inline-worker.ts";
2
+ import type { PendingJavaScriptRun } from "./run-queue.ts";
3
+
4
+ /** How long the worker gets to acknowledge `interrupt`; silence means its event loop is blocked in synchronous code. */
5
+ export const INTERRUPT_ACK_MS = 500;
6
+ /** How long an acknowledged cell gets to settle before the VM is replaced. */
7
+ export const JS_INTERRUPT_GRACE_MS = 2_000;
8
+ /** How long `worker.terminate()` may take before the worker is abandoned as blocked in a synchronous call. */
9
+ export const WORKER_TERMINATE_DEADLINE_MS = 3_000;
10
+
11
+ export type CooperativeSettlement = "settled" | "unresponsive";
12
+ export type WorkerRetirement = "terminated" | "abandoned";
13
+
14
+ export interface CooperativeSettlementBounds {
15
+ readonly ackMs: number;
16
+ readonly graceMs: number;
17
+ }
18
+
19
+ const DEFAULT_SETTLEMENT_BOUNDS: CooperativeSettlementBounds = {
20
+ ackMs: INTERRUPT_ACK_MS,
21
+ graceMs: JS_INTERRUPT_GRACE_MS,
22
+ };
23
+
24
+ export async function awaitCooperativeSettlement(
25
+ run: PendingJavaScriptRun,
26
+ bounds = DEFAULT_SETTLEMENT_BOUNDS,
27
+ ): Promise<CooperativeSettlement> {
28
+ if (run.settled) return "settled";
29
+ const settled = run.settlement.then((): "settled" => "settled");
30
+ const acked = run.interruptAck?.promise.then((): "acked" => "acked") ?? Promise.resolve<"acked">("acked");
31
+ const first = await raceDeadline(Promise.race([settled, acked]), bounds.ackMs, "unresponsive");
32
+ if (first !== "acked") return first;
33
+ return await raceDeadline(settled, bounds.graceMs, "unresponsive");
34
+ }
35
+
36
+ export async function retireWorker(
37
+ worker: WorkerLike,
38
+ deadlineMs = WORKER_TERMINATE_DEADLINE_MS,
39
+ ): Promise<WorkerRetirement> {
40
+ const termination = worker.terminate().then((): WorkerRetirement => "terminated");
41
+ const outcome = await raceDeadline(termination, deadlineMs, "abandoned");
42
+ if (outcome === "abandoned") void termination.then(undefined, ignoreLateTerminationFailure);
43
+ return outcome;
44
+ }
45
+
46
+ export function abandonedWorkerNote(deadlineMs = WORKER_TERMINATE_DEADLINE_MS): string {
47
+ return `JavaScript worker did not stop within ${deadlineMs}ms: a synchronous call (for example Bun.spawnSync or child_process.spawnSync) is blocking it. A fresh worker replaced it; the blocked call keeps running until it returns.\n`;
48
+ }
49
+
50
+ async function raceDeadline<T extends string, Fallback extends string>(
51
+ operation: Promise<T>,
52
+ deadlineMs: number,
53
+ fallback: Fallback,
54
+ ): Promise<T | Fallback> {
55
+ let timer: ReturnType<typeof setTimeout> | undefined;
56
+ const deadline = new Promise<Fallback>((resolve) => {
57
+ timer = setTimeout(() => resolve(fallback), deadlineMs);
58
+ });
59
+ try {
60
+ return await Promise.race([operation, deadline]);
61
+ } finally {
62
+ if (timer !== undefined) clearTimeout(timer);
63
+ }
64
+ }
65
+
66
+ function ignoreLateTerminationFailure(): void {}
@@ -7,8 +7,13 @@ export interface PendingJavaScriptRun {
7
7
  readonly input: JavaScriptRunInput;
8
8
  readonly resolve: (message: ResultMessage) => void;
9
9
  readonly reject: (error: Error) => void;
10
+ readonly settlement: Promise<ResultMessage>;
10
11
  startedAtMs: number | null;
11
12
  settled: boolean;
13
+ /** Host-composed result that wins over whatever the worker reports once an interrupt is in flight. */
14
+ interruptResult: ResultMessage | null;
15
+ interruptAck: PromiseWithResolvers<void> | null;
16
+ settledByWorker: boolean;
12
17
  }
13
18
 
14
19
  export class JavaScriptRunQueue {
@@ -24,9 +29,19 @@ export class JavaScriptRunQueue {
24
29
  }
25
30
 
26
31
  enqueue(input: JavaScriptRunInput): Promise<ResultMessage> {
27
- return new Promise((resolve, reject) => {
28
- this.#queue.push({ input, resolve, reject, startedAtMs: null, settled: false });
32
+ const { promise, resolve, reject } = Promise.withResolvers<ResultMessage>();
33
+ this.#queue.push({
34
+ input,
35
+ resolve,
36
+ reject,
37
+ settlement: promise,
38
+ startedAtMs: null,
39
+ settled: false,
40
+ interruptResult: null,
41
+ interruptAck: null,
42
+ settledByWorker: false,
29
43
  });
44
+ return promise;
30
45
  }
31
46
 
32
47
  startNext(startedAtMs: number): PendingJavaScriptRun | null {
@@ -64,7 +79,7 @@ export class JavaScriptRunQueue {
64
79
  settleAll(message: string): void {
65
80
  const active = this.#active;
66
81
  this.#active = null;
67
- if (active) this.settle(active, stoppedResult(active.input.cellId, message));
82
+ if (active) this.settle(active, active.interruptResult ?? stoppedResult(active.input.cellId, message));
68
83
  for (const queued of this.#queue.splice(0)) this.settle(queued, stoppedResult(queued.input.cellId, message));
69
84
  }
70
85
 
@@ -1,7 +1,11 @@
1
1
  import { JsWorkerRuntime } from "./worker-runtime.js";
2
2
 
3
+ // Mirrors INTERRUPT_ACK_OP in src/bridge/reserved.ts (this worker file cannot import TypeScript).
4
+ const INTERRUPT_ACK_OP = "interrupt-ack";
5
+
3
6
  export function createWorkerCore(transport, options) {
4
7
  let runtime = null;
8
+ let activeCell = null;
5
9
  const pendingTools = new Map();
6
10
 
7
11
  function emit(message) {
@@ -14,6 +18,7 @@ export function createWorkerCore(transport, options) {
14
18
  return;
15
19
  }
16
20
  const startedAtMs = performance.now();
21
+ activeCell = { cellId: message.cellId, interruption: null };
17
22
  try {
18
23
  const value = await runtime.run(message.code, message.cellId, {
19
24
  emit,
@@ -22,16 +27,31 @@ export function createWorkerCore(transport, options) {
22
27
  emit({ type: "result", cellId: message.cellId, ok: true, valueRepr: valueRepr(value), durationMs: durationMs(startedAtMs) });
23
28
  } catch (error) {
24
29
  emit({ type: "result", cellId: message.cellId, ok: false, error: bridgeError(error), durationMs: durationMs(startedAtMs) });
30
+ } finally {
31
+ activeCell = null;
25
32
  }
26
33
  }
27
34
 
28
35
  async function callTool(toolName, args) {
36
+ if (activeCell?.interruption) throw activeCell.interruption;
29
37
  const callId = `js-${crypto.randomUUID()}`;
30
38
  const promise = new Promise((resolve, reject) => pendingTools.set(callId, { resolve, reject }));
31
39
  emit({ type: "tool-call", callId, toolName, args });
32
40
  return await promise;
33
41
  }
34
42
 
43
+ function interruptCell(reason) {
44
+ if (!activeCell || !runtime) return;
45
+ emit({ type: "status", event: { op: INTERRUPT_ACK_OP, cellId: activeCell.cellId } });
46
+ const interruption = cellInterruptedError(reason);
47
+ activeCell.interruption = interruption;
48
+ for (const [callId, pending] of pendingTools) {
49
+ pendingTools.delete(callId);
50
+ pending.reject(interruption);
51
+ }
52
+ runtime.interrupt();
53
+ }
54
+
35
55
  function onMessage(message) {
36
56
  if (message.type === "init") {
37
57
  runtime = new JsWorkerRuntime({
@@ -55,6 +75,10 @@ export function createWorkerCore(transport, options) {
55
75
  else pending.reject(errorFromBridge(message.error));
56
76
  return;
57
77
  }
78
+ if (message.type === "interrupt") {
79
+ interruptCell(message.reason ?? "interrupted");
80
+ return;
81
+ }
58
82
  if (message.type === "close") {
59
83
  emit({ type: "closed" });
60
84
  transport.close();
@@ -79,6 +103,12 @@ function valueRepr(value) {
79
103
  return JSON.stringify(value);
80
104
  }
81
105
 
106
+ function cellInterruptedError(reason) {
107
+ const error = new Error(`JS cell interrupted: ${reason}`);
108
+ error.name = "CellInterruptedError";
109
+ return error;
110
+ }
111
+
82
112
  function bridgeError(error) {
83
113
  if (error instanceof Error) {
84
114
  return { name: error.name, message: error.message, stack: error.stack };