@code-yeongyu/senpi-codemode 2026.10.7 → 2026.10.9

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 (56) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +67 -5
  3. package/package.json +5 -4
  4. package/src/bridge/http-server.ts +9 -1
  5. package/src/bridge/kernel-tools-protocol.ts +8 -0
  6. package/src/bridge/protocol.ts +4 -0
  7. package/src/bridges/eval-virtual-schemas.ts +22 -0
  8. package/src/environments/editable-check.ts +116 -0
  9. package/src/environments/install-lock.ts +223 -0
  10. package/src/environments/py-environment.ts +98 -0
  11. package/src/environments/py-installer.ts +247 -0
  12. package/src/environments/python-environments.ts +85 -0
  13. package/src/environments/revision-store.ts +110 -0
  14. package/src/extension/bridge-tool-call.ts +2 -0
  15. package/src/extension/cell-kernel-tools.ts +28 -0
  16. package/src/extension/kernel-registration.ts +5 -0
  17. package/src/extension/kernel-replacement.ts +20 -0
  18. package/src/extension/runtime-factory.ts +12 -0
  19. package/src/extension/session-manager-proxy.ts +6 -0
  20. package/src/extension/session-manager.ts +24 -2
  21. package/src/index.ts +1 -0
  22. package/src/kernels/jl/prelude.jl +6 -1
  23. package/src/kernels/js/context-manager.ts +15 -1
  24. package/src/kernels/js/kernel-tools-errors.js +2 -0
  25. package/src/kernels/js/kernel-tools-errors.ts +2 -0
  26. package/src/kernels/js/local-module-loader.ts +9 -4
  27. package/src/kernels/js/worker-runtime.js +3 -1
  28. package/src/kernels/js/workpool.js +5 -2
  29. package/src/kernels/py/kernel-contract.ts +11 -2
  30. package/src/kernels/py/kernel-tools-host.ts +97 -0
  31. package/src/kernels/py/kernel.ts +48 -10
  32. package/src/kernels/py/prelude.py +771 -18
  33. package/src/kernels/py/process.ts +15 -4
  34. package/src/kernels/py/startup.ts +58 -20
  35. package/src/kernels/py/transport.ts +49 -8
  36. package/src/kernels/rb/workpool.rb +7 -1
  37. package/src/kernels/sandbox/vendor/pi-codemode/LICENSE +21 -0
  38. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +42 -0
  39. package/src/kernels/sandbox/vendor/pi-codemode/identifier.ts +14 -0
  40. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +514 -0
  41. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +431 -0
  42. package/src/kernels/sandbox/vendor/pi-codemode/runtime/protocol.ts +61 -0
  43. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +231 -0
  44. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +185 -0
  45. package/src/kernels/sandbox/vendor/pi-codemode/wasm.ts +37 -0
  46. package/src/kernels/shared/cell-source-at-start.ts +18 -0
  47. package/src/kernels/shared/kernel-tools-pump.ts +6 -0
  48. package/src/kernels/shared/process-cpu.ts +69 -0
  49. package/src/kernels/shared/subprocess-queue.ts +1 -0
  50. package/src/tool/cell-handler.ts +1 -1
  51. package/src/tool/eval-tool-options.ts +3 -0
  52. package/src/tool/load-cell.ts +73 -0
  53. package/src/tool/magic-cell-host.ts +65 -0
  54. package/src/tool/magic-cells.ts +65 -0
  55. package/src/tool/run-eval-cell.ts +30 -0
  56. package/src/tool/types.ts +27 -0
@@ -0,0 +1,231 @@
1
+ // Vendored from https://github.com/earendil-works/pi (packages/codemode/src) at v1.0.1, commit a7229ddc21810d6245105978033b7df645ecc2f7.
2
+ // MIT license; see LICENSE beside this file. Local changes are listed in VENDORED.md.
3
+ /**
4
+ * Worker thread entry. One worker runs one script inside a fresh QuickJS VM
5
+ * (a separate wasm instance), relays tool calls and output to the host, and
6
+ * reports the result. The host terminates the worker when the script settles,
7
+ * times out, or is aborted; the worker exists so that a spinning script never
8
+ * blocks the host thread.
9
+ *
10
+ * Importing this module starts the worker. Hosts that bundle their code (for
11
+ * example a Bun compiled executable) add a file that imports
12
+ * `@earendil-works/pi-codemode/worker` as a separate entrypoint and pass its URL
13
+ * or embedded-module string specifier as `workerUrl`.
14
+ */
15
+ import { parentPort, workerData } from "node:worker_threads";
16
+ import { JSException, type JSValueHandle, MAX_STACK_SIZE, QuickJS } from "quickjs-wasi";
17
+ import { PRELUDE_SOURCE } from "./prelude-source.ts";
18
+ import { isHostToWorkerMessage, type WorkerData, type WorkerToHostMessage } from "./protocol.ts";
19
+
20
+ function post(message: WorkerToHostMessage): void {
21
+ parentPort?.postMessage(message);
22
+ }
23
+
24
+ function crash(error: unknown): void {
25
+ post({ type: "crash", message: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });
26
+ }
27
+
28
+ /**
29
+ * QuickJS writes engine diagnostics to fd 1 and 2, which the default shim
30
+ * forwards to the host's stdout and stderr. That output belongs to the host
31
+ * application (for example a TUI), so it is discarded. Reporting every byte as
32
+ * written keeps libc from retrying.
33
+ */
34
+ function discardOutput(memory: { readonly buffer: ArrayBufferLike }) {
35
+ return {
36
+ fd_write(_fd: number, iovsPtr: number, iovsLen: number, nwrittenPtr: number): number {
37
+ const view = new DataView(memory.buffer);
38
+ let written = 0;
39
+ for (let i = 0; i < iovsLen; i++) {
40
+ written += view.getUint32(iovsPtr + i * 8 + 4, true);
41
+ }
42
+ view.setUint32(nwrittenPtr, written, true);
43
+ return 0;
44
+ },
45
+ };
46
+ }
47
+
48
+ function describeException(error: JSException): string {
49
+ const head = error.message ? `${error.name}: ${error.message}` : error.name;
50
+ const stack = error.stack?.trimEnd();
51
+ return JSON.stringify({ name: error.name, message: error.message, stack: stack ? `${head}\n${stack}` : head });
52
+ }
53
+ // senpi-change begin: output streaming
54
+ /**
55
+ * Sends one output item as frames of at most `frameBytes` UTF-16 bytes, each only after taking that many bytes
56
+ * of credit. With no credit left the worker sleeps until the host consumes a frame or interrupts the run, so
57
+ * neither side ever holds more than the window. Returns false when the run was interrupted while waiting.
58
+ */
59
+ function streamItem(
60
+ stream: { credits: Int32Array; frameBytes: number },
61
+ interrupt: Int32Array,
62
+ itemId: number,
63
+ type: "text" | "image",
64
+ payload: string,
65
+ mimeType: string | undefined,
66
+ ): boolean {
67
+ const frameUnits = Math.max(1, Math.floor(stream.frameBytes / 2));
68
+ let offset = 0;
69
+ let seq = 0;
70
+ do {
71
+ let end = Math.min(payload.length, offset + frameUnits);
72
+ // Never split a surrogate pair across frames.
73
+ if (end < payload.length && end - offset > 1) {
74
+ const last = payload.charCodeAt(end - 1);
75
+ if (last >= 0xd800 && last <= 0xdbff) end--;
76
+ }
77
+ const chunk = payload.slice(offset, end);
78
+ const bytes = Math.max(2, chunk.length * 2);
79
+ for (;;) {
80
+ if (Atomics.load(interrupt, 0) !== 0) return false;
81
+ const available = Atomics.load(stream.credits, 0);
82
+ if (available >= bytes) {
83
+ if (Atomics.compareExchange(stream.credits, 0, available, available - bytes) === available) break;
84
+ continue;
85
+ }
86
+ Atomics.wait(stream.credits, 0, available);
87
+ }
88
+ post({
89
+ type: "output-frame",
90
+ bytes,
91
+ frame: {
92
+ itemId,
93
+ seq,
94
+ final: end >= payload.length,
95
+ type,
96
+ chunk,
97
+ ...(mimeType === undefined ? {} : { mimeType }),
98
+ },
99
+ });
100
+ offset = end;
101
+ seq++;
102
+ } while (offset < payload.length);
103
+ return true;
104
+ }
105
+ // senpi-change end
106
+
107
+ async function main(data: WorkerData): Promise<void> {
108
+ const interrupt = new Int32Array(data.interrupt);
109
+ // senpi-change begin: output streaming
110
+ const stream =
111
+ data.stream === undefined
112
+ ? undefined
113
+ : { credits: new Int32Array(data.stream.credits), frameBytes: data.stream.frameBytes };
114
+ let nextItemId = 0;
115
+ // senpi-change end
116
+ const vm = await QuickJS.create({
117
+ wasm: data.wasm,
118
+ memoryLimit: data.memoryLimitBytes,
119
+ // Without a guard, deep recursion overflows the wasm stack and traps instead of throwing a
120
+ // catchable RangeError.
121
+ maxStackSize: MAX_STACK_SIZE,
122
+ interruptHandler: () => Atomics.load(interrupt, 0) !== 0,
123
+ wasi: discardOutput,
124
+ });
125
+
126
+ // Called from the prelude with primitives only.
127
+ const bridge = vm.newFunction("bridge", (kind, a, b, c) => {
128
+ switch (kind.toString()) {
129
+ case "call":
130
+ case "global":
131
+ post({
132
+ type: "call",
133
+ id: a.toNumber(),
134
+ target: kind.toString() === "call" ? "tool" : "global",
135
+ name: b.toString(),
136
+ args: c === undefined || c.isUndefined ? undefined : c.toString(),
137
+ });
138
+ break;
139
+ case "output":
140
+ // senpi-change begin: output streaming
141
+ if (stream !== undefined) {
142
+ const image = a.toString() === "image";
143
+ streamItem(stream, interrupt, nextItemId++, image ? "image" : "text", b.toString(), image ? c.toString() : undefined);
144
+ break;
145
+ }
146
+ // senpi-change end
147
+ post({
148
+ type: "output",
149
+ item:
150
+ a.toString() === "image"
151
+ ? { type: "image", data: b.toString(), mimeType: c.toString() }
152
+ : { type: "text", text: b.toString() },
153
+ });
154
+ break;
155
+ case "done":
156
+ if (a.toBoolean()) {
157
+ post({
158
+ type: "done",
159
+ ok: true,
160
+ value: b === undefined || b.isUndefined ? undefined : b.toString(),
161
+ writes: c.toString(),
162
+ });
163
+ } else {
164
+ post({ type: "done", ok: false, error: b.toString() });
165
+ }
166
+ break;
167
+ }
168
+ return vm.undefined;
169
+ });
170
+
171
+ // The VM lives until the host terminates the worker, so these handles are never disposed.
172
+ const api = vm.withScope((scope) =>
173
+ scope.escape(
174
+ vm.callFunction(
175
+ vm.evalCode(PRELUDE_SOURCE, "codemode-prelude.js"),
176
+ vm.undefined,
177
+ bridge,
178
+ vm.newString(JSON.stringify(data.tools)),
179
+ vm.newString(JSON.stringify(data.globals)),
180
+ vm.newString(JSON.stringify(data.store)),
181
+ // senpi-change begin: output streaming and builtin policy
182
+ vm.newString(JSON.stringify({ streamOutput: stream !== undefined, store: data.storePolicy ?? "collect" })),
183
+ // senpi-change end
184
+ ),
185
+ ),
186
+ );
187
+ const settle = api.getProp("settle");
188
+ const run = api.getProp("run");
189
+ const stalled = api.getProp("stalled");
190
+ /** Run queued jobs, then fail a script that waits on nothing that can ever resume it. */
191
+ const drain = () => {
192
+ vm.executePendingJobs();
193
+ vm.callFunction(stalled, api).dispose();
194
+ };
195
+
196
+ parentPort?.on("message", (message: unknown) => {
197
+ if (!isHostToWorkerMessage(message)) return;
198
+ try {
199
+ vm.withScope(() => {
200
+ vm.callFunction(
201
+ settle,
202
+ api,
203
+ vm.newNumber(message.id),
204
+ message.ok ? vm.true : vm.false,
205
+ message.payload === undefined ? vm.undefined : vm.newString(message.payload),
206
+ );
207
+ });
208
+ drain();
209
+ } catch (error) {
210
+ crash(error);
211
+ }
212
+ });
213
+
214
+ // The prefix shares the first line with the script so reported line numbers
215
+ // match the script as written.
216
+ let fn: JSValueHandle;
217
+ try {
218
+ fn = vm.evalCode(`(async (tools, console) => {${data.code}\n})`, "codemode.js");
219
+ } catch (error) {
220
+ if (!(error instanceof JSException)) throw error;
221
+ post({ type: "done", ok: false, error: describeException(error) });
222
+ return;
223
+ }
224
+ vm.callFunction(run, api, fn).dispose();
225
+ fn.dispose();
226
+ drain();
227
+ }
228
+
229
+ if (parentPort) {
230
+ main(workerData as WorkerData).catch(crash);
231
+ }
@@ -0,0 +1,185 @@
1
+ // Vendored from https://github.com/earendil-works/pi (packages/codemode/src) at v1.0.1, commit a7229ddc21810d6245105978033b7df645ecc2f7.
2
+ // MIT license; see LICENSE beside this file. Local changes are listed in VENDORED.md.
3
+ import type { CodemodeWasmModule } from "./wasm.ts";
4
+
5
+ export interface CodemodeToolContext {
6
+ /**
7
+ * Aborted when the script finishes (including unawaited calls), the
8
+ * execution times out, the caller aborts, or the sandbox is closed.
9
+ */
10
+ signal: AbortSignal;
11
+ }
12
+
13
+ /** A JSON Schema document. Only used to render declarations; values are not validated against it. */
14
+ export type CodemodeJsonSchema = { [key: string]: unknown } | boolean;
15
+
16
+ export interface CodemodeTool {
17
+ /**
18
+ * The script calls tools as `tools.<id>(args)`, where `<id>` is the name with characters that
19
+ * are not valid in identifiers replaced by `_` (see `toCodemodeIdentifier`), and also as
20
+ * `tools["<name>"](args)`. Globals are called as `<name>(args)` and must be identifiers, or
21
+ * `<namespace>.<member>`, which groups them into a frozen namespace object.
22
+ */
23
+ name: string;
24
+ /** Shown as a doc comment in {@link renderDeclarations}, and listed in `ALL_TOOLS` for tools. */
25
+ description?: string;
26
+ /** Schema of the single argument. Rendered as the parameter type; `unknown` when omitted. */
27
+ inputSchema?: CodemodeJsonSchema;
28
+ /** Schema of the resolved value. Rendered as the promise type; `unknown` when omitted. */
29
+ outputSchema?: CodemodeJsonSchema;
30
+ /** Globals only: `execute` receives all call arguments as an array instead of the first one. */
31
+ spread?: boolean;
32
+ /**
33
+ * Globals only: TypeScript parameter list and return type for {@link renderDeclarations}, for
34
+ * example `(type: string, id?: string): Promise<Model[]>`. Replaces the rendering from the schemas.
35
+ */
36
+ signature?: string;
37
+ /**
38
+ * `args` is whatever the script passed, after a JSON round trip. The return
39
+ * value must be JSON-serializable; a thrown error surfaces in the script as
40
+ * an `Error` with the same message.
41
+ */
42
+ execute(args: unknown, context: CodemodeToolContext): Promise<unknown> | unknown;
43
+ }
44
+
45
+ /**
46
+ * One item of the script's output, in the order the script produced it: `text()` and `console.*`
47
+ * produce text items, `image()` image items. `data` is base64.
48
+ */
49
+ export type CodemodeOutputItem = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string };
50
+
51
+ export type CodemodeCallStatus = "ok" | "error" | "cancelled";
52
+
53
+ export interface CodemodeCall {
54
+ name: string;
55
+ status: CodemodeCallStatus;
56
+ durationMs: number;
57
+ }
58
+
59
+ export type CodemodeErrorKind =
60
+ /** The script threw or failed to parse. `name` and `stack` come from the script's error. */
61
+ | "script"
62
+ /** The overall deadline expired. The worker was terminated. */
63
+ | "timeout"
64
+ /** The caller's signal fired or the sandbox was closed. The worker was terminated. */
65
+ | "aborted"
66
+ /** The worker or VM failed outside the script's control (for example a wasm trap or a missing worker file). */
67
+ | "sandbox";
68
+
69
+ export interface CodemodeError {
70
+ kind: CodemodeErrorKind;
71
+ name?: string;
72
+ message: string;
73
+ stack?: string;
74
+ }
75
+
76
+ /** Keys the script changed with `store()`. Only successful executions report writes. */
77
+ export interface CodemodeStoreWrites {
78
+ set: Record<string, unknown>;
79
+ /** Keys stored as `undefined`. */
80
+ delete: string[];
81
+ }
82
+ // senpi-change begin: output streaming
83
+ /**
84
+ * One transport frame of an output item in `output: "stream"` mode. An item is split into frames of at most
85
+ * `frameBytes` (UTF-16 bytes of `chunk`); frames of one item share `itemId`, arrive in `seq` order, and the
86
+ * last one has `final: true`. Image frames carry base64 text and repeat the item's `mimeType`.
87
+ */
88
+ export interface CodemodeOutputFrame {
89
+ itemId: number;
90
+ seq: number;
91
+ final: boolean;
92
+ type: "text" | "image";
93
+ chunk: string;
94
+ mimeType?: string;
95
+ }
96
+
97
+ /** Bounds observed in `output: "stream"` mode: frames delivered and the most bytes ever in flight. */
98
+ export interface CodemodeStreamStats {
99
+ frames: number;
100
+ maxInFlightBytes: number;
101
+ windowBytes: number;
102
+ }
103
+ // senpi-change end
104
+
105
+ // senpi-change begin: output streaming (replaces upstream's CodemodeResult union to add `streamed`)
106
+ /** `output` is kept for failed executions too, up to the failure. `exit()` completes with `value: undefined`. */
107
+ export type CodemodeResult = (
108
+ | {
109
+ ok: true;
110
+ value: unknown;
111
+ output: CodemodeOutputItem[];
112
+ calls: CodemodeCall[];
113
+ storeWrites: CodemodeStoreWrites;
114
+ }
115
+ | { ok: false; error: CodemodeError; output: CodemodeOutputItem[]; calls: CodemodeCall[] }
116
+ ) & {
117
+ /** Set in `output: "stream"` mode, where `output` stays empty because every item went to `onOutputFrame`. */
118
+ streamed?: CodemodeStreamStats;
119
+ };
120
+ // senpi-change end
121
+
122
+ export interface CodemodeSandboxOptions {
123
+ tools?: CodemodeTool[];
124
+ /**
125
+ * Functions exposed as top-level identifiers instead of on `tools`, for host helpers such as
126
+ * attaching an image to the result. They behave like tools (JSON round trip, promise result)
127
+ * but are not recorded in `result.calls`. Names must be identifiers and may not shadow the
128
+ * built-in globals (`tools`, `ALL_TOOLS`, `console`, `text`, `image`, `exit`, `store`, `load`).
129
+ */
130
+ globals?: CodemodeTool[];
131
+ /**
132
+ * Overall deadline per execution, including time spent in tools. `Infinity` disables the
133
+ * deadline; the execution then only ends when the script settles or is aborted.
134
+ * Default: 300000.
135
+ */
136
+ timeoutMs?: number;
137
+ /**
138
+ * Maximum memory the QuickJS VM may allocate. Allocations beyond it fail inside the script as
139
+ * `InternalError: out of memory`. Default: no limit beyond wasm32's 4 GiB address space.
140
+ */
141
+ memoryLimitBytes?: number;
142
+ /**
143
+ * Compiled `quickjs-wasi/quickjs.wasm`, usually from {@link loadQuickJSWasm}. Default:
144
+ * `loadQuickJSWasm()`, the file in the installed `quickjs-wasi` package. Pass it when that file
145
+ * is not on disk, for example in a Bun compiled executable.
146
+ */
147
+ wasm?: CodemodeWasmModule | Promise<CodemodeWasmModule>;
148
+ /**
149
+ * Worker entry that imports `@earendil-works/pi-codemode/worker`. Default: this package's own
150
+ * worker file. Pass it when this package is bundled, since the default is resolved relative to
151
+ * the module that creates the sandbox. Bun compiled executables require the relative string
152
+ * specifier of an embedded build entrypoint; other hosts usually use a URL.
153
+ */
154
+ workerUrl?: string | URL;
155
+ // senpi-change begin: output streaming and builtin policy
156
+ /**
157
+ * `"collect"` (default) keeps output items until the script settles and returns them in `result.output`.
158
+ * `"stream"` keeps nothing: each item is split into frames handed to `onOutputFrame` as they arrive, the
159
+ * worker blocks once `windowBytes` of frames are unconsumed, and the per-script output limits do not apply.
160
+ */
161
+ output?: "collect" | "stream";
162
+ /** Receives frames in order in `"stream"` mode; the next frame is handed over only after this settles. */
163
+ onOutputFrame?: (frame: CodemodeOutputFrame) => void | Promise<void>;
164
+ /** Bytes of frames that may be unconsumed at once in `"stream"` mode. Default: 262144. */
165
+ windowBytes?: number;
166
+ /** Largest frame in `"stream"` mode, at most `windowBytes`. Default: 65536. */
167
+ frameBytes?: number;
168
+ /**
169
+ * `store: "collect"` (default) is upstream's `store()`/`load()`. `"reject"` keeps the names but holds no state:
170
+ * `load()` returns `undefined` and `store()` throws an error named `CodemodeStoreDisabledError`.
171
+ */
172
+ builtins?: { store?: "collect" | "reject" };
173
+ // senpi-change end
174
+ }
175
+
176
+ export interface CodemodeExecuteOptions {
177
+ signal?: AbortSignal;
178
+ /** Overrides the sandbox default for this execution. */
179
+ timeoutMs?: number;
180
+ /**
181
+ * Values the script reads with `load(key)`. Must be JSON-serializable. The script's own
182
+ * `store()` calls come back as `result.storeWrites`; persisting them is up to the caller.
183
+ */
184
+ store?: Readonly<Record<string, unknown>>;
185
+ }
@@ -0,0 +1,37 @@
1
+ // Vendored from https://github.com/earendil-works/pi (packages/codemode/src) at v1.0.1, commit a7229ddc21810d6245105978033b7df645ecc2f7.
2
+ // MIT license; see LICENSE beside this file. Local changes are listed in VENDORED.md.
3
+ import { readFile } from "node:fs/promises";
4
+ import { createRequire } from "node:module";
5
+
6
+ /**
7
+ * A compiled `WebAssembly.Module` of `quickjs-wasi/quickjs.wasm`. Typed opaquely because the Node
8
+ * type definitions do not declare the WebAssembly globals (they live in the DOM lib).
9
+ */
10
+ export type CodemodeWasmModule = object;
11
+
12
+ interface WebAssemblyGlobal {
13
+ compile(bytes: Uint8Array): Promise<CodemodeWasmModule>;
14
+ }
15
+
16
+ const modules = new Map<string, Promise<CodemodeWasmModule>>();
17
+
18
+ /**
19
+ * Read and compile the QuickJS wasm once per path. `path` defaults to the file in the installed
20
+ * `quickjs-wasi` package; pass it when that file lives elsewhere, for example embedded in a Bun
21
+ * compiled executable. A failed load is retried on the next call.
22
+ */
23
+ export function loadQuickJSWasm(path?: string): Promise<CodemodeWasmModule> {
24
+ const resolved = path ?? createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm");
25
+ let module = modules.get(resolved);
26
+ if (!module) {
27
+ const { WebAssembly } = globalThis as unknown as { WebAssembly: WebAssemblyGlobal };
28
+ module = readFile(resolved)
29
+ .then((bytes) => WebAssembly.compile(bytes))
30
+ .catch((error: unknown) => {
31
+ modules.delete(resolved);
32
+ throw error;
33
+ });
34
+ modules.set(resolved, module);
35
+ }
36
+ return module;
37
+ }
@@ -0,0 +1,18 @@
1
+ import type { CellSourceAtStart, EvalKernelRunInput } from "../../tool/types.ts";
2
+
3
+ /** The run input with its turn-time source applied, or the refusal to settle the cell with. */
4
+ export function inputAtStart<T extends EvalKernelRunInput>(input: T): T | { readonly refused: string } {
5
+ if (input.resolveAtStart === undefined) return input;
6
+ let source: CellSourceAtStart;
7
+ try {
8
+ source = input.resolveAtStart();
9
+ } catch (error) {
10
+ return { refused: error instanceof Error ? error.message : String(error) };
11
+ }
12
+ if (!source.ok) return { refused: source.message };
13
+ return {
14
+ ...input,
15
+ code: source.code,
16
+ ...(source.sourceFile === undefined ? {} : { sourceFile: source.sourceFile }),
17
+ };
18
+ }
@@ -159,6 +159,9 @@ function codeOf(
159
159
  | "kernel_tool_missing"
160
160
  | "kernel_tool_recursion"
161
161
  | "kernel_tool_host_denied"
162
+ | "kernel_tool_cancelled"
163
+ | "kernel_tool_loop_mismatch"
164
+ | "tool_name_collision"
162
165
  | "tools_unavailable"
163
166
  | "invalid_tool_definition" {
164
167
  if (
@@ -166,6 +169,9 @@ function codeOf(
166
169
  code === "kernel_tool_missing" ||
167
170
  code === "kernel_tool_recursion" ||
168
171
  code === "kernel_tool_host_denied" ||
172
+ code === "kernel_tool_cancelled" ||
173
+ code === "kernel_tool_loop_mismatch" ||
174
+ code === "tool_name_collision" ||
169
175
  code === "tools_unavailable" ||
170
176
  code === "invalid_tool_definition"
171
177
  ) {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * CPU time a kernel child has used so far, in platform units that only ever grow; a watchdog compares
3
+ * two readings and never converts.
4
+ *
5
+ * - linux and darwin: the child's whole process group (`readProcessGroupCpuTime`), so a helper process
6
+ * the runner forks counts as activity. The kernel child is spawned detached, so its pid is its pgid.
7
+ * - win32 under Bun: the child's own kernel + user time from `GetProcessTimes` (kernel32, via `bun:ffi`).
8
+ * Windows has no process groups; this is the interpreter itself when it is launched directly.
9
+ * - anything else, or Windows under Node: `undefined`, and the caller treats the CPU as unknown.
10
+ */
11
+ import { readProcessGroupCpuTime } from "./process-group-cpu.ts";
12
+
13
+ type BunFfi = typeof import("bun:ffi");
14
+ type CpuReader = (pid: number) => bigint | undefined;
15
+
16
+ const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000;
17
+ const FILETIME_BYTES = 8;
18
+
19
+ let windowsReader: CpuReader | null | undefined;
20
+
21
+ export function readKernelCpuTime(pid: number): bigint | undefined {
22
+ if (!Number.isInteger(pid) || pid <= 0) return undefined;
23
+ if (process.platform !== "win32") return readProcessGroupCpuTime(pid);
24
+ if (windowsReader === undefined) windowsReader = createWindowsReader() ?? null;
25
+ return windowsReader?.(pid);
26
+ }
27
+
28
+ function createWindowsReader(): CpuReader | undefined {
29
+ const ffi = process.getBuiltinModule("bun:ffi") as BunFfi | undefined;
30
+ if (ffi === undefined) return undefined;
31
+ try {
32
+ return bindGetProcessTimes(ffi);
33
+ } catch {
34
+ // kernel32 always exports these; a binding failure means this runtime cannot call it at all.
35
+ return undefined;
36
+ }
37
+ }
38
+
39
+ function bindGetProcessTimes({ dlopen, FFIType, ptr }: BunFfi): CpuReader {
40
+ const library = dlopen("kernel32.dll", {
41
+ OpenProcess: { args: [FFIType.u32, FFIType.i32, FFIType.u32], returns: FFIType.ptr },
42
+ GetProcessTimes: {
43
+ args: [FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr, FFIType.ptr],
44
+ returns: FFIType.i32,
45
+ },
46
+ CloseHandle: { args: [FFIType.ptr], returns: FFIType.i32 },
47
+ });
48
+ const times = new Uint8Array(FILETIME_BYTES * 4);
49
+ const view = new DataView(times.buffer);
50
+ return (pid) => {
51
+ const handle = library.symbols.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
52
+ if (!handle) return undefined;
53
+ try {
54
+ times.fill(0);
55
+ const base = ptr(times);
56
+ const ok = library.symbols.GetProcessTimes(
57
+ handle,
58
+ base,
59
+ base + FILETIME_BYTES,
60
+ base + FILETIME_BYTES * 2,
61
+ base + FILETIME_BYTES * 3,
62
+ );
63
+ if (ok === 0) return undefined;
64
+ return view.getBigUint64(FILETIME_BYTES * 2, true) + view.getBigUint64(FILETIME_BYTES * 3, true);
65
+ } finally {
66
+ library.symbols.CloseHandle(handle);
67
+ }
68
+ };
69
+ }
@@ -120,6 +120,7 @@ export class SubprocessRunQueue {
120
120
  return false;
121
121
  case "webview-connect":
122
122
  case "memory-query-result":
123
+ case "kernel-tools-defined":
123
124
  return false;
124
125
  default: {
125
126
  const exhaustive: never = message;
@@ -48,7 +48,7 @@ export interface CellBridgeRuntime {
48
48
  readonly ctx: ExtensionContext;
49
49
  readonly artifactPath?: string;
50
50
  readonly imageResizer?: EvalImageResizer;
51
- /** This cell's live kernel-tool capability; only a JS kernel has one (#1754). */
51
+ /** This cell's live kernel-tool capability; JS and Python kernels have one (#1754, #2731). */
52
52
  readonly kernelTools?: KernelToolsCapability;
53
53
  /** The session generation's handle registry behind `wait()` / `handle()` / completion handles. */
54
54
  readonly handles?: HandleRegistry;
@@ -8,6 +8,7 @@ import type {
8
8
  import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
9
9
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
10
10
  import type { ResolvedCodemodeSettings } from "../config/settings.ts";
11
+ import type { PythonEnvironments } from "../environments/python-environments.ts";
11
12
  import type { EvalExecutionTracker } from "../extension/session-manager.ts";
12
13
  import type { HandleRegistry } from "../handles/handle-registry.ts";
13
14
  import type { EvalTimeoutFactory } from "./cell-execution.ts";
@@ -69,6 +70,8 @@ export interface CreateEvalToolOptions {
69
70
  readonly bunSkillPath?: string;
70
71
  /** Kernel globals of the tools active when a cell is submitted; read once per cell. */
71
72
  readonly kernelPreludes?: () => readonly KernelPreludeContribution[];
73
+ /** The session's Python environments: `%pip` / `%environment` cells and the import root of every Python cell. */
74
+ readonly pythonEnvironments?: PythonEnvironments;
72
75
  /** Contributions whose documentation lines the description lists; snapshot taken when the tool is (re)registered. */
73
76
  readonly promptKernelPreludes?: readonly KernelPreludeContribution[];
74
77
  }
@@ -0,0 +1,73 @@
1
+ import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, join, relative, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export type LoadedCell =
6
+ | { readonly ok: true; readonly code: string; readonly sourceFile: string }
7
+ | { readonly ok: false; readonly message: string };
8
+
9
+ export interface LoadCellOptions {
10
+ readonly cwd: string;
11
+ readonly artifactsDir: string | undefined;
12
+ }
13
+
14
+ const MAX_LOAD_BYTES = 8 * 1024 * 1024;
15
+ const URL_SCHEME = /^([a-z][a-z0-9+.-]*):\/\//i;
16
+
17
+ function errorCode(error: unknown): string | undefined {
18
+ return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
19
+ ? error.code
20
+ : undefined;
21
+ }
22
+
23
+ function resolveTarget(target: string, options: LoadCellOptions): { path: string } | { message: string } {
24
+ const scheme = URL_SCHEME.exec(target)?.[1]?.toLowerCase();
25
+ try {
26
+ if (scheme === "file") return { path: fileURLToPath(target) };
27
+ if (scheme === "local") {
28
+ if (options.artifactsDir === undefined)
29
+ return { message: "%load local:// needs a session artifacts directory" };
30
+ const root = join(options.artifactsDir, "local");
31
+ const path = resolve(root, decodeURIComponent(target.slice("local://".length)));
32
+ const inside = relative(root, path);
33
+ if (inside.startsWith("..") || isAbsolute(inside))
34
+ return { message: `%load path escapes local://: ${target}` };
35
+ return { path };
36
+ }
37
+ } catch {
38
+ // fileURLToPath and decodeURIComponent throw on a malformed URL; the message names the user's own text.
39
+ return { message: `%load could not read the path in ${target}` };
40
+ }
41
+ if (scheme !== undefined) {
42
+ return {
43
+ message: `%load reads local files only (a path, local:// or file://); it does not fetch ${scheme}:// URLs`,
44
+ };
45
+ }
46
+ return { path: resolve(options.cwd, target) };
47
+ }
48
+
49
+ /**
50
+ * Reads a `%load` target when its cell's turn comes in the kernel's queue, so a file the previous cell wrote is
51
+ * there. Every failure is a refusal that names the target as the user wrote it, never an absolute path.
52
+ */
53
+ export function loadCell(target: string, options: LoadCellOptions): LoadedCell {
54
+ const resolved = resolveTarget(target, options);
55
+ if ("message" in resolved) return { ok: false, message: resolved.message };
56
+ let fd: number | undefined;
57
+ try {
58
+ // One non-blocking open, then checks on that same handle: a FIFO or device swapped in after a check can
59
+ // neither block the host nor be read, and the size cap keeps a huge file from stalling the event loop.
60
+ fd = openSync(resolved.path, constants.O_RDONLY | constants.O_NONBLOCK);
61
+ const info = fstatSync(fd);
62
+ if (!info.isFile()) return { ok: false, message: `not a file: ${target}` };
63
+ if (info.size > MAX_LOAD_BYTES) return { ok: false, message: `%load reads files up to 8 MiB: ${target}` };
64
+ return { ok: true, code: readFileSync(fd, "utf8"), sourceFile: resolved.path };
65
+ } catch (error) {
66
+ const code = errorCode(error);
67
+ if (code === "ENOENT" || code === "ENOTDIR") return { ok: false, message: `file not found: ${target}` };
68
+ if (code === "EACCES" || code === "EPERM") return { ok: false, message: `permission denied: ${target}` };
69
+ return { ok: false, message: `%load could not read ${target}${code === undefined ? "" : ` (${code})`}` };
70
+ } finally {
71
+ if (fd !== undefined) closeSync(fd);
72
+ }
73
+ }