@code-yeongyu/senpi-codemode 2026.10.10-3 → 2026.10.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,54 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.10.10-5] - 2026-10-07
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ - A running, queued or detached eval row now leads with the cell's summary (or its first code line), then the language, state and elapsed time, on one line; the code is shown on expand. A call still streaming its arguments shows that row instead of the raw `eval code="..."` fallback. Completed rows are unchanged ([#2802](https://github.com/code-yeongyu/senpi/issues/2802)).
24
+
25
+ - A JavaScript cell may declare a name the kernel or platform already defines (`log`, `fetch`, `print`, `URL`, ...): the value persists for your later cells while the kernel and imported libraries keep the original, `delete <name>` restores it, and the cell notes the shadowing ([#2793](https://github.com/code-yeongyu/senpi/issues/2793)).
26
+
27
+ ### Fixed
28
+
29
+ - A live eval row whose cell has no summary skips a first code line that holds only escape or control characters and leads with the next line that has content, instead of showing only an ellipsis ([#2850](https://github.com/code-yeongyu/senpi/issues/2850)).
30
+
31
+ - The `require` in a JavaScript cell carries `require.resolve`, `require.resolve.paths` and `require.cache` like Node's own `require`, and the call and `resolve` share one lookup, so they always name the same copy: builtins natively (including Bun's, such as `bun:sqlite`), then the project, then the managed package environment ([#2832](https://github.com/code-yeongyu/senpi/issues/2832)).
32
+
33
+ - A live eval row whose cell has no summary sanitizes its first code line before measuring it, so escape and control characters in that line never reach the one-line row ([#2839](https://github.com/code-yeongyu/senpi/issues/2839)).
34
+
35
+ - A live eval row stays one line in every terminal: its headline is measured and cut in screen cells, so a summary with wide characters (Korean, Chinese, Japanese, emoji) no longer wraps a narrow terminal, and a `peek`/`stop` call still streaming in renders `eval peek` instead of `eval peek undefined` ([#2831](https://github.com/code-yeongyu/senpi/issues/2831)).
36
+
37
+ - Stopping or timing out a JavaScript cell that awaits something that never settles (a promise, a `fetch` whose server never answers, a polling loop on timers, `Bun.sleep` or `node:timers/promises`, a loop of short `Bun.spawn` children) now keeps the worker and every global instead of restarting it. Stop ends the cell and everything it started: its timers are cleared, pending sleeps and `fetch` requests reject, and the sockets, servers, WebSockets, WebViews, nested workers and child processes it opened are closed; the result arrives once its children are gone. A stopped cell's own `catch`/`finally` can no longer print, call tools, start processes, schedule timers or open connections. An unhandled promise rejection no longer crashes the JavaScript kernel: it is reported on the running or next cell, naming the cell it came from, with bursts folded into one line; an uncaught exception still restarts the worker. A cell stopped during a `Bun.$` command still restarts the worker, as before ([#2788](https://github.com/code-yeongyu/senpi/issues/2788)).
38
+
39
+ - JavaScript cells can call `require(...)` and `createRequire(...)`: builtins, relative CommonJS and JSON files, and packages from the project or the managed package environment resolve as they do for `import` ([#2792](https://github.com/code-yeongyu/senpi/issues/2792)).
40
+
41
+ - A detached eval cell's completion notification now carries the same output its result would have shown in the foreground (head, tail, elision marker and full-output notice) instead of a 512-byte tail, and images the cell displayed are delivered with the notification ([#2789](https://github.com/code-yeongyu/senpi/issues/2789)).
42
+
43
+ ### Removed
44
+
45
+ ## [2026.10.10-4] - 2026-10-06
46
+
47
+ ### Breaking Changes
48
+
49
+ ### Added
50
+
51
+ ### Changed
52
+
53
+ ### Fixed
54
+
55
+ - A detached eval cell that waits for its kernel to start now says so (`waiting for the js kernel to be ready`) instead of `queued behind in the js kernel` with an empty predecessor ([#2790](https://github.com/code-yeongyu/senpi/issues/2790)).
56
+
57
+ - A stopped (or failed) detached cell's result and notification show its buffered output instead of the live `1/1 cells running` frame, so a cancelled cell no longer reads as still running. The kernel-state note now says plainly whether the kernel was restarted: `The JavaScript worker was not restarted; variables from earlier cells are kept.` instead of `... remains running; its existing variables are preserved.`, or `The JavaScript worker was restarted; variables from earlier cells are lost.` instead of `... was unresponsive to interrupt and was restarted ...` (the worker may have answered the interrupt and still needed a restart) ([#2791](https://github.com/code-yeongyu/senpi/issues/2791)).
58
+
59
+ - An isolated (`isolate: true`) eval cell's result names QuickJS as its runtime (`quickjs <version>, sandbox`) instead of the persistent kernel's Bun or Node runtime ([#2811](https://github.com/code-yeongyu/senpi/issues/2811)).
60
+
61
+ ### Removed
62
+
15
63
  ## [2026.10.10-3] - 2026-10-06
16
64
 
17
65
  ### Breaking Changes
package/README.md CHANGED
@@ -364,6 +364,7 @@ options object and asynchronous helpers are `await`-able.
364
364
  | `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)). |
365
365
  | `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 }`. |
366
366
  | `tool_schema(name?)` | Returns a tool's parameter schema without calling it; omit `name` to list tool names. |
367
+ | `require(specifier)`, `createRequire(path)` (js) | CommonJS loading inside a cell, resolved like `import`: builtins (with or without `node:`), relative files and JSON from the cell's directory, bare packages from the session's project and then its managed package environment (`%bun add`/`%npm add`). A missing module raises the runtime's own `MODULE_NOT_FOUND` error. Like Node's `require`, it carries `require.resolve` (the same lookup the call uses, so both name the same copy; builtins resolve to the runtime's own id), `require.resolve.paths` and `require.cache` (the runtime's CommonJS cache). |
367
368
  | `tool(fn, metadata?)` (js) | Registers a named function as a kernel tool for in-process children. `metadata.name` registers it under that name instead of the function's; arguments are still passed in the function's parameter order. |
368
369
  | `tool.defined()` / `tool.undefine(name)` (js) | List the kernel tools this kernel defines (sorted), and remove one (`true` if it existed). A descriptor taken before `undefine` can no longer be invoked. Both names are reserved: a kernel tool can't be registered as `defined` or `undefine` (`reserved_tool_name`), and a host tool with either name is shadowed in the `tool` namespace, so it can't be called from a JavaScript cell. |
369
370
  | `@tool` / `@tool(name=, description=, schema=)` (py) | Registers a Python function as a kernel tool for in-process children; the schema is inferred from its type hints (`tool_schema("eval:kernel-tools")` lists the rules). Callbacks run while the kernel is idle or its cell is parked in a host call, never in the middle of a running computation. `tool.defined()` and `tool.undefine(name)` work as in JavaScript. Ruby and Julia kernels answer `tools_unavailable`. |
@@ -465,6 +466,10 @@ free; otherwise it settles `cancelled` with `eval_background_capacity_reached`,
465
466
  listing the live cells and a stop-or-wait remedy. Cells that complete inside the
466
467
  window return normally. Cancelling a queued cell never interrupts its predecessor.
467
468
  Do not re-run a detached or queued cell; each detached cell completes as one notification.
469
+ The notification carries the same text the cell's result would have shown in the
470
+ foreground (the output sink's configured head, its tail, the middle-elision marker and
471
+ the full-output artifact notice), framed by the outcome line and the kernel-state note,
472
+ plus any images the cell displayed.
468
473
 
469
474
  Queued steering also detaches an eligible interactive foreground call, including
470
475
  one paused in a host tool bridge, without cancelling its computation. If the
@@ -505,11 +510,29 @@ Use `eval({ action: "peek", cell_id })` for its state and buffered output, or
505
510
  `eval({ action: "stop", cell_id })` to cancel it. Stopping a queued cell removes it
506
511
  without interrupting the active cell; kernel state is retained. Python running-cell stop interrupts the
507
512
  existing kernel and preserves variables. JavaScript stop is cooperative first:
508
- the worker rejects the cell's pending bridge `tool.*` calls and kills the
509
- `Bun.spawn` children it started, and a cell that settles within the 2 s grace
510
- keeps the worker and every global. Only a cell that stays unsettled (a
511
- never-resolving promise, an un-abortable `fetch`, a `Bun.$` command) costs the
512
- worker VM. A worker blocked in a synchronous call (`Bun.spawnSync`,
513
+ Stop ends the cell and everything it started, not the kernel's variables. The
514
+ worker rejects the cell's pending bridge `tool.*` calls and releases the cell:
515
+ its timers are cleared (the globals and the `node:timers` module alike), its
516
+ pending `Bun.sleep`, `node:timers/promises` waits, `fetch` requests and file
517
+ reads reject with the interruption, and the sockets, servers, file streams,
518
+ `readline` interfaces, WebSockets, WebViews, nested workers and child processes
519
+ it opened are closed or terminated, whether made with a factory or a
520
+ constructor. A resource that will not close is reported on the cell output. The stop result arrives once those children are gone, and the
521
+ worker and every global from earlier cells survive. If the stopped cell's own
522
+ `catch`/`finally` still runs, it cannot print, call tools, start processes,
523
+ schedule timers or open connections. One boundary remains: code that resumes
524
+ because a later cell resolves a promise the stopped cell was awaiting runs
525
+ until it reaches an operation a stopped cell may no longer start (output, tool
526
+ calls, processes, timers, network, files, message channels, workers).
527
+
528
+ An unhandled promise rejection never crashes the JavaScript kernel; its
529
+ variables are kept, as in the Node REPL and Jupyter. The rejection is reported
530
+ on the cell that is running, or on the next cell, naming the cell that started
531
+ the work when it is known ("from cell <id>, after it was stopped"), with the
532
+ error message and the top of its stack. A burst becomes one report plus
533
+ "... and K more unhandled promise rejections". A fatal error (an uncaught
534
+ exception) still restarts the worker, and the result says variables are lost. Only a cell stopped during a `Bun.$` command costs
535
+ the worker VM. A worker blocked in a synchronous call (`Bun.spawnSync`,
513
536
  `child_process.spawnSync`) cannot be stopped at all; after a 3 s termination
514
537
  deadline a fresh worker replaces it, the cell output gains a stderr line naming
515
538
  the blocked synchronous call, and the blocked call keeps running until it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.10.10-3",
3
+ "version": "2026.10.10-5",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -33,15 +33,15 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@babel/parser": "8.0.4",
36
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-3",
36
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-5",
37
37
  "quickjs-wasi": "3.6.2",
38
38
  "typebox": "1.3.34"
39
39
  },
40
40
  "peerDependencies": {
41
- "@code-yeongyu/senpi": "2026.10.10-3"
41
+ "@code-yeongyu/senpi": "2026.10.10-5"
42
42
  },
43
43
  "devDependencies": {
44
- "@code-yeongyu/senpi": "2026.10.10-3"
44
+ "@code-yeongyu/senpi": "2026.10.10-5"
45
45
  },
46
46
  "keywords": [
47
47
  "senpi",
@@ -1,4 +1,9 @@
1
1
  import type { ExtensionContext } from "@code-yeongyu/senpi";
2
+ import type { EvalImageContent } from "../tool/image-resize.ts";
3
+
4
+ /** A notification's message content: plain text, or text and the cells' images in one message. */
5
+ export type EvalNotificationContent = string | ({ readonly type: "text"; readonly text: string } | EvalImageContent)[];
6
+
2
7
  import type { EvalDetachedCellNotification, EvalDetachedCellNotifier } from "../tool/detached-cell-manager.ts";
3
8
 
4
9
  const NON_INTERACTIVE_MODES = new Set(["print", "json"]);
@@ -11,7 +16,7 @@ export type EvalNotifyMode = "wake" | "next-turn" | "off";
11
16
  export interface EvalNotifierDeps {
12
17
  /** Deliver a model-visible notification without rendering synthetic user input. */
13
18
  readonly sendMessage: (
14
- message: { customType: string; content: string; display: boolean },
19
+ message: { customType: string; content: EvalNotificationContent; display: boolean },
15
20
  options: { triggerTurn: boolean; deliverAs: "steer" | "followUp" },
16
21
  ) => void;
17
22
  readonly getContext: () => ExtensionContext | undefined;
@@ -43,10 +48,16 @@ export class EvalNotifier implements EvalDetachedCellNotifier {
43
48
  this.#deps.sendMessage(
44
49
  {
45
50
  customType: EVAL_NOTIFICATION_CUSTOM_TYPE,
46
- content: pending.map((cell) => cell.content).join("\n\n"),
51
+ content: notificationContent(pending),
47
52
  display: false,
48
53
  },
49
54
  { triggerTurn: true, deliverAs: mode === "wake" ? "steer" : "followUp" },
50
55
  );
51
56
  }
52
57
  }
58
+
59
+ function notificationContent(cells: readonly EvalDetachedCellNotification[]): EvalNotificationContent {
60
+ const text = cells.map((cell) => cell.content).join("\n\n");
61
+ const images = cells.flatMap((cell) => cell.images ?? []);
62
+ return images.length === 0 ? text : [{ type: "text", text }, ...images];
63
+ }
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  resolveMaxDetachedCells,
11
11
  resolveRunBudgetSeconds,
12
12
  } from "./config/settings.ts";
13
- import { EvalNotifier } from "./extension/eval-notifier.ts";
13
+ import { type EvalNotificationContent, EvalNotifier } from "./extension/eval-notifier.ts";
14
14
  import { EVAL_CELLS_STATUS_KEY } from "./extension/eval-status.ts";
15
15
  import { EvalStatusTicker } from "./extension/eval-status-ticker.ts";
16
16
  import { hostLine, modelIdFrom } from "./extension/host-facts.ts";
@@ -57,7 +57,7 @@ export interface CodemodeExtensionAPI {
57
57
  getActiveTools(): string[];
58
58
  getAllTools(): readonly EvalSchemaToolInfo[];
59
59
  sendMessage(
60
- message: { customType: string; content: string; display: boolean },
60
+ message: { customType: string; content: EvalNotificationContent; display: boolean },
61
61
  options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
62
62
  ): void;
63
63
  /** Optional host event bus; a host without one turns extension event emission into a harmless no-op. */
@@ -0,0 +1,268 @@
1
+ import { currentCell, onReleaseOf, settleWithCell } from "./cell-run-context.js";
2
+
3
+ // What a cell owns, and how each kind ends when the cell is stopped. Every patch is a no-op outside a cell, refuses
4
+ // (or never starts) new work for a released cell, and registers what a live cell starts so release can end it.
5
+ const RESOURCE_CLOSERS = ["terminate", "destroy", "stop", "close", "end"];
6
+
7
+ // Some host bindings are read-only (Bun.fetch); those keep their own behaviour and are not owned by cells.
8
+ function replace(owner, name, replacement, patched) {
9
+ const original = owner[name];
10
+ try {
11
+ owner[name] = replacement;
12
+ } catch {
13
+ return;
14
+ }
15
+ if (owner[name] !== replacement) return;
16
+ patched.push(() => {
17
+ owner[name] = original;
18
+ });
19
+ }
20
+
21
+ function builtin(name) {
22
+ return process.getBuiltinModule?.(name);
23
+ }
24
+
25
+ function closeResource(resource) {
26
+ for (const method of RESOURCE_CLOSERS) {
27
+ if (typeof resource?.[method] !== "function") continue;
28
+ if (method === "stop") resource.stop(true);
29
+ else resource[method]();
30
+ return;
31
+ }
32
+ }
33
+
34
+ function ownResultsOf(owner, names, patched) {
35
+ for (const name of names) {
36
+ const original = owner?.[name];
37
+ if (typeof original !== "function") continue;
38
+ const own = function (...args) {
39
+ const cell = currentCell();
40
+ if (cell === undefined) return original.apply(this, args);
41
+ if (cell.released) throw cell.interruption;
42
+ const result = original.apply(this, args);
43
+ const register = (resource) => {
44
+ onReleaseOf(cell, () => closeResource(resource));
45
+ return resource;
46
+ };
47
+ return result instanceof Promise ? result.then(register) : register(result);
48
+ };
49
+ Object.assign(own, original);
50
+ replace(owner, name, own, patched);
51
+ }
52
+ }
53
+
54
+ // `globals` is the global scope already patched: a module binding that is the same owned function is left alone.
55
+ function patchTimers(scope, patched, globals = undefined) {
56
+ for (const [name, clearName] of [
57
+ ["setTimeout", "clearTimeout"],
58
+ ["setInterval", "clearInterval"],
59
+ ["setImmediate", "clearImmediate"],
60
+ ]) {
61
+ const original = scope?.[name];
62
+ const clear = scope?.[clearName];
63
+ if (typeof original !== "function" || typeof clear !== "function") continue;
64
+ if (globals !== undefined && original === globals[name]) continue;
65
+ const owned = function (callback, ...rest) {
66
+ const cell = currentCell();
67
+ if (cell === undefined || typeof callback !== "function") return original.call(this, callback, ...rest);
68
+ if (cell.released) {
69
+ const never = original.call(this, () => {}, ...rest);
70
+ clear.call(scope, never);
71
+ return never;
72
+ }
73
+ let forget = () => {};
74
+ const handle = original.call(
75
+ this,
76
+ function (...args) {
77
+ if (name !== "setInterval") forget();
78
+ return callback.apply(this, args);
79
+ },
80
+ ...rest,
81
+ );
82
+ forget = onReleaseOf(cell, () => clear.call(scope, handle));
83
+ return handle;
84
+ };
85
+ Object.assign(owned, original);
86
+ replace(scope, name, owned, patched);
87
+ }
88
+ }
89
+
90
+ function patchPromiseApi(owner, name, patched) {
91
+ const original = owner?.[name];
92
+ if (typeof original !== "function") return;
93
+ const bound = function (...args) {
94
+ const cell = currentCell();
95
+ if (cell === undefined) return original.apply(this, args);
96
+ if (cell.released) return Promise.reject(cell.interruption);
97
+ return settleWithCell(cell, original.apply(this, args));
98
+ };
99
+ Object.assign(bound, original);
100
+ replace(owner, name, bound, patched);
101
+ }
102
+
103
+ function patchTimerPromises(patched) {
104
+ const timers = builtin("node:timers/promises");
105
+ if (timers === undefined) return;
106
+ patchPromiseApi(timers, "setTimeout", patched);
107
+ patchPromiseApi(timers, "setImmediate", patched);
108
+ const original = timers.setInterval;
109
+ if (typeof original !== "function") return;
110
+ const owned = function (...args) {
111
+ const cell = currentCell();
112
+ if (cell?.released) throw cell.interruption;
113
+ const iterator = original.apply(this, args);
114
+ if (cell === undefined) return iterator;
115
+ onReleaseOf(cell, () => void iterator.return?.());
116
+ return iterator;
117
+ };
118
+ replace(timers, "setInterval", owned, patched);
119
+ }
120
+
121
+ function patchFetch(scope, patched, name = "fetch") {
122
+ const original = scope[name];
123
+ if (typeof original !== "function") return;
124
+ const owned = function (input, init) {
125
+ const cell = currentCell();
126
+ if (cell === undefined) return original.call(this, input, init);
127
+ if (cell.released) return Promise.reject(cell.interruption);
128
+ const controller = new AbortController();
129
+ const own = init?.signal ?? (input instanceof Request ? input.signal : undefined);
130
+ const signal = own === undefined ? controller.signal : AbortSignal.any([own, controller.signal]);
131
+ const forget = onReleaseOf(cell, () => controller.abort(cell.interruption));
132
+ return original.call(this, input, { ...init, signal }).finally(forget);
133
+ };
134
+ Object.assign(owned, original);
135
+ replace(scope, name, owned, patched);
136
+ }
137
+
138
+ // A released cell's code may still be resumed by a short I/O completion; refusing new I/O at that point makes such a
139
+ // loop throw the interruption on its next operation instead of running on in the kept worker.
140
+ function refuseWhenReleased(owner, names, patched) {
141
+ for (const name of names) {
142
+ const original = owner?.[name];
143
+ if (typeof original !== "function") continue;
144
+ const guarded = function (...args) {
145
+ const cell = currentCell();
146
+ if (cell?.released) throw cell.interruption;
147
+ const result = original.apply(this, args);
148
+ return cell === undefined ? result : settleWithCell(cell, result);
149
+ };
150
+ Object.assign(guarded, original);
151
+ replace(owner, name, guarded, patched);
152
+ }
153
+ }
154
+
155
+ // The callback form: a released cell's operation is never started, so its callback never runs, the same way a timer a
156
+ // released cell sets never fires.
157
+ function neverStartWhenReleased(owner, names, patched) {
158
+ for (const name of names) {
159
+ const original = owner?.[name];
160
+ if (typeof original !== "function") continue;
161
+ const guarded = function (...args) {
162
+ if (currentCell()?.released) return undefined;
163
+ return original.apply(this, args);
164
+ };
165
+ Object.assign(guarded, original);
166
+ replace(owner, name, guarded, patched);
167
+ }
168
+ }
169
+
170
+ const FS_OPERATIONS = ["readFile", "writeFile", "appendFile", "readdir", "stat", "lstat", "open", "access", "mkdir", "rm", "unlink", "rename", "copyFile"];
171
+
172
+ // For ES classes only (Worker, WebSocket, MessageChannel): `instanceof` keeps accepting instances the runtime creates
173
+ // internally from the original class. Node's function-style constructors are owned on activation instead.
174
+ function patchClass(scope, name, close, patched) {
175
+ const Original = scope?.[name];
176
+ if (typeof Original !== "function") return;
177
+ const Owned = class extends Original {
178
+ constructor(...args) {
179
+ const cell = currentCell();
180
+ if (cell?.released) throw cell.interruption;
181
+ super(...args);
182
+ if (cell !== undefined) onReleaseOf(cell, () => close(this));
183
+ }
184
+ static [Symbol.hasInstance](value) {
185
+ return value instanceof Original;
186
+ }
187
+ };
188
+ Object.defineProperty(Owned, "name", { value: Original.name });
189
+ replace(scope, name, Owned, patched);
190
+ }
191
+
192
+ const NODE_RESOURCE_FACTORIES = {
193
+ "node:net": ["createConnection", "connect", "createServer"],
194
+ "node:tls": ["connect", "createServer"],
195
+ "node:http": ["request", "get", "createServer"],
196
+ "node:https": ["request", "get", "createServer"],
197
+ "node:dgram": ["createSocket"],
198
+ "node:fs": ["createReadStream", "createWriteStream", "watch"],
199
+ "node:readline": ["createInterface"],
200
+ "node:readline/promises": ["createInterface"],
201
+ };
202
+
203
+ // Node's socket and server types are function-style constructors that Node's own subclasses call with `.call(this)`
204
+ // (http.Server calls net.Server), so they cannot be swapped for a class. A socket or server only does anything once it
205
+ // connects, listens or binds, so that call is where a cell takes ownership of it; http, https and tls inherit these.
206
+ const NODE_ACTIVATIONS = {
207
+ "node:net": { Socket: ["connect"], Server: ["listen"] },
208
+ "node:dgram": { Socket: ["bind"] },
209
+ };
210
+
211
+ function ownOnActivation(proto, names, patched) {
212
+ for (const name of names) {
213
+ const original = proto?.[name];
214
+ if (typeof original !== "function") continue;
215
+ const owned = new WeakSet();
216
+ const activate = function (...args) {
217
+ const cell = currentCell();
218
+ if (cell === undefined) return original.apply(this, args);
219
+ if (cell.released) throw cell.interruption;
220
+ if (!owned.has(this)) {
221
+ owned.add(this);
222
+ onReleaseOf(cell, () => closeResource(this));
223
+ }
224
+ return original.apply(this, args);
225
+ };
226
+ replace(proto, name, activate, patched);
227
+ }
228
+ }
229
+
230
+ export function installCellOwnership(scope = globalThis) {
231
+ const patched = [];
232
+ patchTimers(scope, patched);
233
+ patchTimers(builtin("node:timers"), patched, scope);
234
+ patchTimerPromises(patched);
235
+ patchFetch(scope, patched);
236
+ patchClass(scope, "WebSocket", (socket) => socket.close(), patched);
237
+ patchClass(scope, "Worker", (worker) => void worker.terminate(), patched);
238
+ refuseWhenReleased(builtin("node:fs/promises"), FS_OPERATIONS, patched);
239
+ neverStartWhenReleased(builtin("node:fs"), FS_OPERATIONS, patched);
240
+ patchClass(scope, "MessageChannel", (channel) => {
241
+ channel.port1.close();
242
+ channel.port2.close();
243
+ }, patched);
244
+ patchClass(scope, "BroadcastChannel", (channel) => channel.close(), patched);
245
+ const bun = scope.Bun;
246
+ if (bun !== undefined) {
247
+ patchPromiseApi(bun, "sleep", patched);
248
+ if (bun.fetch !== scope.fetch) patchFetch(bun, patched, "fetch");
249
+ refuseWhenReleased(bun, ["file", "write"], patched);
250
+ ownResultsOf(bun, ["connect", "listen", "serve", "udpSocket"], patched);
251
+ }
252
+ for (const [module, factories] of Object.entries(NODE_RESOURCE_FACTORIES)) ownResultsOf(builtin(module), factories, patched);
253
+ patchClass(builtin("node:worker_threads"), "Worker", (worker) => void worker.terminate(), patched);
254
+ for (const [module, types] of Object.entries(NODE_ACTIVATIONS)) {
255
+ for (const [name, methods] of Object.entries(types)) ownOnActivation(builtin(module)?.[name]?.prototype, methods, patched);
256
+ }
257
+ // Node hands `import("node:…")` its own ESM namespace; without this sync a cell importing the module gets the
258
+ // unowned originals.
259
+ syncBuiltinExports();
260
+ return () => {
261
+ for (const undo of patched.reverse()) undo();
262
+ syncBuiltinExports();
263
+ };
264
+ }
265
+
266
+ function syncBuiltinExports() {
267
+ builtin("node:module")?.syncBuiltinESMExports?.();
268
+ }
@@ -0,0 +1,89 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ // Every continuation a cell creates (awaits, timers, I/O callbacks) runs inside its cell record. Stopping a cell
4
+ // releases it: everything it owns is cleared, rejected or closed (`cell-ownership.js` decides what a cell owns), and
5
+ // anything its code tries to start afterwards is refused, while the kernel and its variables stay.
6
+ const cellRuns = new AsyncLocalStorage();
7
+ const releasedInterruptions = new WeakMap();
8
+ const MAX_CAUSE_DEPTH = 8;
9
+
10
+ export function runInCell(cell, run) {
11
+ return cellRuns.run(cell, run);
12
+ }
13
+
14
+ export function currentCell() {
15
+ return cellRuns.getStore();
16
+ }
17
+
18
+ export function releasedCellError() {
19
+ const cell = cellRuns.getStore();
20
+ return cell?.released === true ? cell.interruption : undefined;
21
+ }
22
+
23
+ export function assertCellLive() {
24
+ const error = releasedCellError();
25
+ if (error !== undefined) throw error;
26
+ }
27
+
28
+ export function onCellRelease(close) {
29
+ const cell = cellRuns.getStore();
30
+ return cell === undefined ? () => {} : onReleaseOf(cell, close);
31
+ }
32
+
33
+ export function onReleaseOf(cell, close) {
34
+ cell.onRelease ??= new Set();
35
+ cell.onRelease.add(close);
36
+ return () => cell.onRelease.delete(close);
37
+ }
38
+
39
+ /**
40
+ * Releases the cell: every closer runs even when an earlier one throws, and the failures are returned so the caller
41
+ * can report them; a resource that would not close is a fact the user should see, not something to swallow.
42
+ */
43
+ export function releaseCell(cell) {
44
+ cell.released = true;
45
+ releasedInterruptions.set(cell.interruption, cell);
46
+ const failures = [];
47
+ for (const close of cell.onRelease ?? []) {
48
+ try {
49
+ close();
50
+ } catch (error) {
51
+ failures.push(error);
52
+ }
53
+ }
54
+ cell.onRelease?.clear();
55
+ return failures;
56
+ }
57
+
58
+ /**
59
+ * The cell an unhandled rejection came from: a released cell whose interruption is the reason or sits in its `cause`
60
+ * chain, or the cell whose async context the rejection runs in (Node exposes it to the handler; Bun does not).
61
+ */
62
+ export function cellOfRejection(reason) {
63
+ let current = reason;
64
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && typeof current === "object" && current !== null; depth++) {
65
+ const cell = releasedInterruptions.get(current);
66
+ if (cell !== undefined) return cell;
67
+ current = current.cause;
68
+ }
69
+ return cellRuns.getStore();
70
+ }
71
+
72
+ // An operation already in flight when its cell is released rejects with the interruption at once, so the stopped
73
+ // cell's continuation never runs again; a non-promise result is returned as it is.
74
+ export function settleWithCell(cell, result) {
75
+ if (result === null || typeof result !== "object" || typeof result.then !== "function") return result;
76
+ return new Promise((resolve, reject) => {
77
+ const forget = onReleaseOf(cell, () => reject(cell.interruption));
78
+ result.then(
79
+ (value) => {
80
+ forget();
81
+ resolve(value);
82
+ },
83
+ (error) => {
84
+ forget();
85
+ reject(error);
86
+ },
87
+ );
88
+ });
89
+ }
@@ -441,7 +441,11 @@ function reportCrash(error) {
441
441
  }
442
442
  }
443
443
  process.on("uncaughtException", reportCrash);
444
- process.on("unhandledRejection", reportCrash);
444
+ // Once the worker core is up it reports unhandled rejections to the cells (the kernel keeps running); before that, one
445
+ // is a startup crash.
446
+ process.on("unhandledRejection", (reason) => {
447
+ if (process.listenerCount("unhandledRejection") === 1) reportCrash(reason);
448
+ });
445
449
 
446
450
  const cwd = process.env.SENPI_CODEMODE_PROCESS_CWD ?? process.cwd();
447
451
  const poolWidth = Number.parseInt(process.env.SENPI_CODEMODE_PROCESS_POOL_WIDTH ?? "1", 10) || 1;
@@ -0,0 +1,61 @@
1
+ import { cellOfRejection } from "./cell-run-context.js";
2
+
3
+ const STACK_FRAMES = 3;
4
+
5
+ // An unhandled rejection is reported, never fatal: the kernel keeps its variables (the Node REPL and Jupyter norm). The
6
+ // report names the cell that started the work when it is known and lands on the cell running now, or on the next one.
7
+ // A burst is folded into one report plus a count, so a stopped loop cannot flood a cell's output.
8
+ export function createRejectionReports({ activeCell, emitText }) {
9
+ let waiting = null;
10
+ const shownInCell = new WeakMap();
11
+
12
+ function report(reason) {
13
+ const text = describe(reason, cellOfRejection(reason), activeCell());
14
+ const cell = activeCell();
15
+ if (cell === null || cell.released) {
16
+ if (waiting === null) waiting = { text, more: 0 };
17
+ else waiting.more++;
18
+ return;
19
+ }
20
+ const shown = shownInCell.get(cell) ?? 0;
21
+ shownInCell.set(cell, shown + 1);
22
+ if (shown === 0) emitText(text);
23
+ }
24
+
25
+ return {
26
+ report,
27
+ startCell(cell) {
28
+ if (waiting === null) return;
29
+ emitText(`${waiting.text}${moreLine(waiting.more)}`);
30
+ shownInCell.set(cell, 1);
31
+ waiting = null;
32
+ },
33
+ finishCell(cell) {
34
+ const shown = shownInCell.get(cell) ?? 0;
35
+ if (shown > 1) emitText(moreLine(shown - 1));
36
+ },
37
+ };
38
+ }
39
+
40
+ function moreLine(more) {
41
+ return more === 0 ? "" : `... and ${more} more unhandled promise ${more === 1 ? "rejection" : "rejections"}\n`;
42
+ }
43
+
44
+ function describe(reason, origin, running) {
45
+ const where =
46
+ origin === undefined
47
+ ? "(the cell that started it is unknown)"
48
+ : origin === running && !origin.released
49
+ ? "in this cell"
50
+ : `from cell ${origin.cellId}${origin.released ? ", after it was stopped" : ""}`;
51
+ return `Unhandled promise rejection ${where}: ${summary(reason)}\n`;
52
+ }
53
+
54
+ function summary(reason) {
55
+ if (!(reason instanceof Error)) return String(reason);
56
+ const frames = (reason.stack ?? "")
57
+ .split("\n")
58
+ .filter((line) => line.trim().startsWith("at "))
59
+ .slice(0, STACK_FRAMES);
60
+ return [`${reason.name}: ${reason.message}`, ...frames.map((line) => ` ${line.trim()}`)].join("\n");
61
+ }