@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,98 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { assertInstalledInRevision, assertNoEditableInstalls } from "./editable-check.ts";
6
+ import { parsePipRequirements, runPipInstall } from "./py-installer.ts";
7
+ import { publishNextRevision, type Revision } from "./revision-store.ts";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ export type EnvironmentMode = "managed" | "project";
12
+
13
+ export interface InstallReceipt {
14
+ readonly manager: "pip";
15
+ readonly mode: EnvironmentMode;
16
+ readonly root: string;
17
+ readonly revision: number;
18
+ readonly requested: readonly string[];
19
+ readonly resolved: readonly string[];
20
+ readonly changed: boolean;
21
+ }
22
+
23
+ export function managedEnvironmentBase(artifactsDir: string, language: "py", abiTag: string): string {
24
+ return join(artifactsDir, "environments", language, abiTag);
25
+ }
26
+
27
+ export function projectPythonBase(cwd: string): string {
28
+ return join(cwd, ".senpi", "python-packages");
29
+ }
30
+
31
+ export async function pythonAbiTag(interpreter: string): Promise<string> {
32
+ const { stdout } = await execFileAsync(interpreter, [
33
+ "-c",
34
+ "import sys, sysconfig; print(sys.implementation.cache_tag, sysconfig.get_platform(), sys.executable)",
35
+ ]);
36
+ return createHash("sha256").update(stdout.trim()).digest("hex").slice(0, 12);
37
+ }
38
+
39
+ export async function installPythonPackages(input: {
40
+ readonly base: string;
41
+ readonly mode: EnvironmentMode;
42
+ readonly interpreter: string;
43
+ readonly requirements: string;
44
+ readonly cwd: string;
45
+ readonly signal: AbortSignal;
46
+ readonly onOutput?: (stream: "stdout" | "stderr", data: string) => void;
47
+ }): Promise<InstallReceipt> {
48
+ const requested = parsePipRequirements(input.requirements);
49
+ let stdout = "";
50
+ const { revision } = await publishNextRevision(
51
+ input.base,
52
+ async (staging) => {
53
+ await runPipInstall({
54
+ interpreter: input.interpreter,
55
+ root: staging,
56
+ args: requested,
57
+ cwd: input.cwd,
58
+ signal: input.signal,
59
+ onOutput: (stream, data) => {
60
+ if (stream === "stdout") stdout += data;
61
+ input.onOutput?.(stream, data);
62
+ },
63
+ });
64
+ await assertInstalledInRevision(stdout, staging);
65
+ await assertNoEditableInstalls(staging);
66
+ },
67
+ input.signal,
68
+ ({ holder, waitedMs }) =>
69
+ input.onOutput?.(
70
+ "stderr",
71
+ `[senpi] waiting ${Math.round(waitedMs / 1000)}s for another install into this environment${holder === undefined ? "" : ` (pid ${holder.pid} on ${holder.host})`}\n`,
72
+ ),
73
+ );
74
+ const resolved = installedDistributions(stdout);
75
+ return receipt(input.mode, revision, requested, resolved);
76
+ }
77
+
78
+ function receipt(
79
+ mode: EnvironmentMode,
80
+ revision: Revision,
81
+ requested: readonly string[],
82
+ resolved: readonly string[],
83
+ ): InstallReceipt {
84
+ return {
85
+ manager: "pip",
86
+ mode,
87
+ root: revision.dir,
88
+ revision: revision.number,
89
+ requested,
90
+ resolved,
91
+ changed: resolved.length > 0,
92
+ };
93
+ }
94
+
95
+ function installedDistributions(stdout: string): string[] {
96
+ const line = stdout.split("\n").find((entry) => entry.startsWith("Successfully installed "));
97
+ return line === undefined ? [] : line.slice("Successfully installed ".length).trim().split(/\s+/);
98
+ }
@@ -0,0 +1,247 @@
1
+ import { spawn } from "node:child_process";
2
+ import { devNull } from "node:os";
3
+ import { terminateProcessTrees } from "../kernels/js/process-tree-host.ts";
4
+
5
+ export type EnvironmentErrorCode =
6
+ | "environment_install_failed"
7
+ | "environment_install_timeout"
8
+ | "environment_install_cancelled"
9
+ | "environment_installer_unavailable"
10
+ | "environment_resolution_conflict"
11
+ | "environment_language_mismatch";
12
+
13
+ export class EnvironmentError extends Error {
14
+ readonly name = "EnvironmentError";
15
+ readonly code: EnvironmentErrorCode;
16
+
17
+ constructor(code: EnvironmentErrorCode, message: string) {
18
+ super(`${code}: ${message}`);
19
+ this.code = code;
20
+ }
21
+ }
22
+
23
+ // pip accepts any unambiguous prefix of a long option and grouped short flags, so a denylist of destination
24
+ // options can't be complete. Only these options are accepted, each spelled out in full.
25
+ const ALLOWED_FLAGS = new Set([
26
+ "--upgrade",
27
+ "--no-deps",
28
+ "--pre",
29
+ "--force-reinstall",
30
+ "--no-index",
31
+ "--no-cache-dir",
32
+ "--only-binary",
33
+ "--no-binary",
34
+ "--prefer-binary",
35
+ "--index-url",
36
+ "--extra-index-url",
37
+ "--find-links",
38
+ "--constraint",
39
+ "--requirement",
40
+ "--quiet",
41
+ "--verbose",
42
+ ]);
43
+ const FLAGS_WITH_VALUE = new Set([
44
+ "--only-binary",
45
+ "--no-binary",
46
+ "--index-url",
47
+ "--extra-index-url",
48
+ "--find-links",
49
+ "--constraint",
50
+ "--requirement",
51
+ ]);
52
+ const SHORT_ALIASES: Readonly<Record<string, string>> = {
53
+ "-U": "--upgrade",
54
+ "-i": "--index-url",
55
+ "-f": "--find-links",
56
+ "-c": "--constraint",
57
+ "-r": "--requirement",
58
+ "-q": "--quiet",
59
+ "-v": "--verbose",
60
+ };
61
+ const STDERR_TAIL_BYTES = 4_096;
62
+ const PIP_TREE_GRACE_MS = 2_000;
63
+
64
+ /**
65
+ * Splits a %pip argument line the way the platform's shell would for these inputs: single and double quotes
66
+ * group (`"pkg[extra]>=1.0"`) and `#` at the start of a word begins a comment. A backslash escapes the next
67
+ * character only in POSIX mode; on Windows it is a path separator and stays as written. An unclosed quote is
68
+ * refused rather than guessed.
69
+ */
70
+ const DOUBLE_QUOTE_ESCAPES: ReadonlySet<string> = new Set(['"', "\\", "$", "`"]);
71
+
72
+ export function splitShellWords(text: string, posix = process.platform !== "win32"): string[] {
73
+ const words: string[] = [];
74
+ let word = "";
75
+ let inWord = false;
76
+ let quote: "'" | '"' | undefined;
77
+ for (let index = 0; index < text.length; index++) {
78
+ const char = text[index] ?? "";
79
+ if (quote === "'") {
80
+ if (char === "'") quote = undefined;
81
+ else word += char;
82
+ continue;
83
+ }
84
+ if (posix && char === "\\" && index + 1 < text.length) {
85
+ const next = text[index + 1] ?? "";
86
+ // A backslash-newline is a line continuation in a shell, inside double quotes or not: both characters go.
87
+ if (next === "\n") {
88
+ index += 1;
89
+ continue;
90
+ }
91
+ // Inside double quotes a shell only escapes these; any other backslash stays as written.
92
+ if (quote === '"' && !DOUBLE_QUOTE_ESCAPES.has(next)) {
93
+ word += char;
94
+ continue;
95
+ }
96
+ index += 1;
97
+ word += next;
98
+ inWord = true;
99
+ continue;
100
+ }
101
+ if (quote === '"') {
102
+ if (char === '"') quote = undefined;
103
+ else word += char;
104
+ continue;
105
+ }
106
+ if (char === "'" || char === '"') {
107
+ quote = char;
108
+ inWord = true;
109
+ continue;
110
+ }
111
+ if (/\s/u.test(char)) {
112
+ if (inWord) words.push(word);
113
+ word = "";
114
+ inWord = false;
115
+ continue;
116
+ }
117
+ if (char === "#" && !inWord) break;
118
+ word += char;
119
+ inWord = true;
120
+ }
121
+ if (quote !== undefined)
122
+ throw new EnvironmentError("environment_install_failed", `unclosed ${quote} quote in %pip arguments`);
123
+ if (inWord) words.push(word);
124
+ return words;
125
+ }
126
+
127
+ export function parsePipRequirements(text: string): string[] {
128
+ const args = splitShellWords(text);
129
+ const command = args[0] === "install" ? args.slice(1) : undefined;
130
+ if (command === undefined) {
131
+ throw new EnvironmentError("environment_install_failed", "only `%pip install <requirements>` is supported");
132
+ }
133
+ if (command.length === 0) throw new EnvironmentError("environment_install_failed", "name at least one requirement");
134
+ const normalized: string[] = [];
135
+ for (let index = 0; index < command.length; index++) {
136
+ const arg = command[index] ?? "";
137
+ if (!arg.startsWith("-")) {
138
+ normalized.push(arg);
139
+ continue;
140
+ }
141
+ const [spelled = arg, attached] = arg.split(/=(.*)/su, 2);
142
+ const flag = SHORT_ALIASES[spelled] ?? spelled;
143
+ if (!ALLOWED_FLAGS.has(flag)) {
144
+ throw new EnvironmentError(
145
+ "environment_install_failed",
146
+ `${spelled} is not allowed: %pip accepts ${[...ALLOWED_FLAGS].join(", ")} (each spelled out in full); packages always install into the session's environment root`,
147
+ );
148
+ }
149
+ if (!FLAGS_WITH_VALUE.has(flag)) {
150
+ if (attached !== undefined)
151
+ throw new EnvironmentError("environment_install_failed", `${spelled} takes no value`);
152
+ normalized.push(flag);
153
+ continue;
154
+ }
155
+ const value = attached ?? command[++index];
156
+ if (value === undefined || value === "" || value.startsWith("-")) {
157
+ throw new EnvironmentError("environment_install_failed", `${spelled} needs a value`);
158
+ }
159
+ normalized.push(`${flag}=${value}`);
160
+ }
161
+ const named = normalized.some((arg) => !arg.startsWith("-") || arg.startsWith("--requirement="));
162
+ if (!named) throw new EnvironmentError("environment_install_failed", "name at least one requirement");
163
+ return normalized;
164
+ }
165
+
166
+ /**
167
+ * pip's environment with every PIP_* variable removed and its config file pointed at nothing: `--isolated`
168
+ * alone still honours PIP_CONFIG_FILE, and a configured target, root or prefix would install outside the revision.
169
+ */
170
+ export function isolatedPipEnv(): NodeJS.ProcessEnv {
171
+ const env: NodeJS.ProcessEnv = {};
172
+ for (const [key, value] of Object.entries(process.env)) {
173
+ if (!key.toUpperCase().startsWith("PIP_")) env[key] = value;
174
+ }
175
+ // pip skips every config file when PIP_CONFIG_FILE equals Python's os.devnull: "nul" on Windows, not Node's "\\\\.\\nul".
176
+ return { ...env, PIP_CONFIG_FILE: process.platform === "win32" ? "nul" : devNull, PYTHONNOUSERSITE: "1" };
177
+ }
178
+
179
+ export function runPipInstall(input: {
180
+ readonly interpreter: string;
181
+ readonly root: string;
182
+ readonly args: readonly string[];
183
+ readonly cwd: string;
184
+ readonly signal: AbortSignal;
185
+ readonly onOutput?: (stream: "stdout" | "stderr", data: string) => void;
186
+ }): Promise<void> {
187
+ const argv = [
188
+ "-m",
189
+ "pip",
190
+ "install",
191
+ // Ignore pip's config files and PIP_* variables: a configured `root` or `prefix` would write outside the revision.
192
+ "--isolated",
193
+ "--disable-pip-version-check",
194
+ "--no-input",
195
+ "--target",
196
+ input.root,
197
+ ...input.args,
198
+ ];
199
+ return new Promise((resolve, reject) => {
200
+ if (input.signal.aborted) {
201
+ reject(new EnvironmentError("environment_install_cancelled", "the install was cancelled before it started"));
202
+ return;
203
+ }
204
+ const child = spawn(input.interpreter, argv, {
205
+ cwd: input.cwd,
206
+ stdio: ["ignore", "pipe", "pipe"],
207
+ env: isolatedPipEnv(),
208
+ });
209
+ let stderrTail = "";
210
+ child.stdout.setEncoding("utf8").on("data", (data: string) => input.onOutput?.("stdout", data));
211
+ child.stderr.setEncoding("utf8").on("data", (data: string) => {
212
+ stderrTail = (stderrTail + data).slice(-STDERR_TAIL_BYTES);
213
+ input.onOutput?.("stderr", data);
214
+ });
215
+ // pip's build backends run in their own subprocesses; stopping only pip would leave them writing.
216
+ const onAbort = () => {
217
+ const pid = child.pid;
218
+ if (pid === undefined) child.kill("SIGKILL");
219
+ else void terminateProcessTrees([pid], { graceMs: PIP_TREE_GRACE_MS }).catch(() => child.kill("SIGKILL"));
220
+ };
221
+ input.signal.addEventListener("abort", onAbort, { once: true });
222
+ child.once("error", (error) => {
223
+ input.signal.removeEventListener("abort", onAbort);
224
+ reject(
225
+ new EnvironmentError("environment_installer_unavailable", `${input.interpreter} -m pip: ${error.message}`),
226
+ );
227
+ });
228
+ child.once("close", (code, signal) => {
229
+ input.signal.removeEventListener("abort", onAbort);
230
+ if (input.signal.aborted) {
231
+ reject(new EnvironmentError("environment_install_cancelled", "the install was cancelled; pip was stopped"));
232
+ } else if (code === 0) resolve();
233
+ else if (/No module named pip/.test(stderrTail)) {
234
+ reject(new EnvironmentError("environment_installer_unavailable", `${input.interpreter} has no pip module`));
235
+ } else if (/ResolutionImpossible|conflicting dependencies/.test(stderrTail)) {
236
+ reject(new EnvironmentError("environment_resolution_conflict", stderrTail.trim()));
237
+ } else {
238
+ reject(
239
+ new EnvironmentError(
240
+ "environment_install_failed",
241
+ stderrTail.trim() || `pip exited with ${code ?? signal}`,
242
+ ),
243
+ );
244
+ }
245
+ });
246
+ });
247
+ }
@@ -0,0 +1,85 @@
1
+ import type { ResolvedCodemodeSettings } from "../config/settings.ts";
2
+ import {
3
+ type EnvironmentMode,
4
+ type InstallReceipt,
5
+ installPythonPackages,
6
+ managedEnvironmentBase,
7
+ projectPythonBase,
8
+ pythonAbiTag,
9
+ } from "./py-environment.ts";
10
+ import { EnvironmentError } from "./py-installer.ts";
11
+ import { readActiveRevisionSync } from "./revision-store.ts";
12
+
13
+ export interface PythonEnvironmentsOptions {
14
+ readonly artifactsDir: string;
15
+ readonly cwd: string;
16
+ readonly interpreter: string;
17
+ readonly settings: Pick<ResolvedCodemodeSettings, "environments">;
18
+ }
19
+
20
+ export class PythonEnvironments {
21
+ readonly #options: PythonEnvironmentsOptions;
22
+ #mode: EnvironmentMode = "managed";
23
+ #currentBase: string | undefined;
24
+ #managedBase: Promise<string> | undefined;
25
+
26
+ constructor(options: PythonEnvironmentsOptions) {
27
+ this.#options = options;
28
+ }
29
+
30
+ get mode(): EnvironmentMode {
31
+ return this.#mode;
32
+ }
33
+
34
+ /**
35
+ * The revision a Python cell imports from, read from the current mode's root each time a cell starts, so an
36
+ * install by another session sharing the project root is seen. Undefined while nothing is installed there.
37
+ */
38
+ get activeRoot(): string | undefined {
39
+ return this.#currentBase === undefined ? undefined : readActiveRevisionSync(this.#currentBase)?.dir;
40
+ }
41
+
42
+ async setMode(mode: EnvironmentMode): Promise<string> {
43
+ const base = await this.#base(mode);
44
+ this.#mode = mode;
45
+ this.#currentBase = base;
46
+ return base;
47
+ }
48
+
49
+ async install(
50
+ requirements: string,
51
+ signal: AbortSignal,
52
+ onOutput?: (stream: "stdout" | "stderr", data: string) => void,
53
+ ): Promise<InstallReceipt> {
54
+ if (this.#options.settings.environments?.autoProvision === false) {
55
+ throw new EnvironmentError(
56
+ "environment_installer_unavailable",
57
+ "installs are turned off for this project (environments.autoProvision is false)",
58
+ );
59
+ }
60
+ const mode = this.#mode;
61
+ const base = await this.#base(mode);
62
+ const receipt = await installPythonPackages({
63
+ base,
64
+ mode,
65
+ interpreter: this.#options.interpreter,
66
+ requirements,
67
+ cwd: this.#options.cwd,
68
+ signal,
69
+ ...(onOutput === undefined ? {} : { onOutput }),
70
+ });
71
+ if (this.#mode === mode) this.#currentBase = base;
72
+ return receipt;
73
+ }
74
+
75
+ #base(mode: EnvironmentMode): Promise<string> {
76
+ if (mode === "project") return Promise.resolve(projectPythonBase(this.#options.cwd));
77
+ const configured = this.#options.settings.environments?.managedRoot;
78
+ this.#managedBase ??= pythonAbiTag(this.#options.interpreter).then((abi) =>
79
+ configured === undefined
80
+ ? managedEnvironmentBase(this.#options.artifactsDir, "py", abi)
81
+ : managedEnvironmentBase(configured, "py", abi),
82
+ );
83
+ return this.#managedBase;
84
+ }
85
+ }
@@ -0,0 +1,110 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { type LockWaitNotice, withRootLock } from "./install-lock.ts";
6
+ import { EnvironmentError } from "./py-installer.ts";
7
+
8
+ const POINTER = "active";
9
+ const REVISION = /^rev-(\d+)$/;
10
+
11
+ export interface Revision {
12
+ readonly number: number;
13
+ readonly dir: string;
14
+ }
15
+
16
+ /** The same read, synchronous: the pointer is one short file, read when a Python cell starts running. */
17
+ export function readActiveRevisionSync(base: string): Revision | undefined {
18
+ try {
19
+ return parsePointer(base, readFileSync(join(base, POINTER), "utf8"));
20
+ } catch (error) {
21
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
22
+ throw error;
23
+ }
24
+ }
25
+
26
+ function parsePointer(base: string, text: string): Revision | undefined {
27
+ const name = text.trim();
28
+ const match = REVISION.exec(name);
29
+ return match?.[1] === undefined ? undefined : { number: Number(match[1]), dir: join(base, name) };
30
+ }
31
+
32
+ export async function readActiveRevision(base: string): Promise<Revision | undefined> {
33
+ try {
34
+ return parsePointer(base, await readFile(join(base, POINTER), "utf8"));
35
+ } catch (error) {
36
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Builds the next revision under the root's lock: a staging copy of the active revision (so packages
43
+ * accumulate) is handed to `build`, renamed to `rev-<n>` only after `build` succeeds, and published by an
44
+ * atomic replace of the `active` pointer. A failed, cancelled or interrupted build is deleted; the
45
+ * previous revision stays active and is never modified, so a running kernel never sees a partial install.
46
+ */
47
+ export async function publishNextRevision(
48
+ base: string,
49
+ build: (staging: string, previous: Revision | undefined) => Promise<void>,
50
+ signal?: AbortSignal,
51
+ onLockWait?: (notice: LockWaitNotice) => void,
52
+ ): Promise<{ readonly revision: Revision; readonly previous: Revision | undefined }> {
53
+ await mkdir(base, { recursive: true });
54
+ return withRootLock(
55
+ base,
56
+ async () => {
57
+ const previous = await readActiveRevision(base);
58
+ const number = Math.max(previous?.number ?? 0, await highestRevision(base)) + 1;
59
+ const staging = join(base, `.staging-rev-${number}-${process.pid}-${randomUUID()}`);
60
+ const dir = join(base, `rev-${number}`);
61
+ try {
62
+ if (previous === undefined) await mkdir(staging, { recursive: true });
63
+ else await cp(previous.dir, staging, { recursive: true, verbatimSymlinks: true });
64
+ await build(staging, previous);
65
+ signal?.throwIfAborted();
66
+ await renameRetrying(staging, dir);
67
+ const pointer = join(base, `.${POINTER}-${process.pid}-${randomUUID()}`);
68
+ await writeFile(pointer, `rev-${number}\n`);
69
+ await renameRetrying(pointer, join(base, POINTER));
70
+ } catch (error) {
71
+ await rm(staging, { recursive: true, force: true });
72
+ throw error;
73
+ }
74
+ return { revision: { number, dir }, previous };
75
+ },
76
+ signal,
77
+ onLockWait,
78
+ );
79
+ }
80
+
81
+ const RENAME_RETRY_DELAYS_MS = [20, 50, 100, 200, 400, 800];
82
+
83
+ // On Windows a rename fails with EPERM/EBUSY/EACCES while another process (an indexer, antivirus) holds a
84
+ // handle on the source or the replaced file; those holds are brief, so the rename is retried before failing.
85
+ async function renameRetrying(from: string, to: string): Promise<void> {
86
+ for (let attempt = 0; ; attempt++) {
87
+ try {
88
+ await rename(from, to);
89
+ return;
90
+ } catch (error) {
91
+ const code = error instanceof Error && "code" in error ? error.code : undefined;
92
+ const transient = code === "EPERM" || code === "EBUSY" || code === "EACCES";
93
+ const delay = RENAME_RETRY_DELAYS_MS[attempt];
94
+ if (!transient || delay === undefined) {
95
+ const reason = error instanceof Error ? error.message : String(error);
96
+ throw new EnvironmentError("environment_install_failed", `could not publish the new revision: ${reason}`);
97
+ }
98
+ await new Promise((resolve) => setTimeout(resolve, delay));
99
+ }
100
+ }
101
+ }
102
+
103
+ async function highestRevision(base: string): Promise<number> {
104
+ let highest = 0;
105
+ for (const entry of await readdir(base)) {
106
+ const match = REVISION.exec(entry);
107
+ if (match?.[1] !== undefined) highest = Math.max(highest, Number(match[1]));
108
+ }
109
+ return highest;
110
+ }
@@ -8,6 +8,8 @@ export interface BridgeToolCallRequest {
8
8
  readonly toolName: string;
9
9
  readonly args: unknown;
10
10
  readonly callId: string;
11
+ /** The calling run's secret; absent for a call made outside any run (a plain thread), which gets no kernel tools. */
12
+ readonly cellToken?: string;
11
13
  readonly signal: AbortSignal;
12
14
  }
13
15
 
@@ -0,0 +1,28 @@
1
+ import { kernelToolsStorage } from "@code-yeongyu/senpi";
2
+ import type { KernelToolsCapability } from "../kernels/js/kernel-tools-types.ts";
3
+
4
+ /**
5
+ * Kernel-tools capabilities of the subprocess-kernel cells running right now, keyed by a secret minted per run and
6
+ * sent only to the kernel running that cell. Those kernels (py/rb/jl) reach the host over the bridge, outside the
7
+ * submitting cell's async context, so a bridge call carries its run's secret and runs inside that run's capability:
8
+ * the same `kernelToolsStorage` scope the JS worker path enters per host call (#1754).
9
+ */
10
+ export class CellKernelTools {
11
+ readonly #byToken = new Map<string, KernelToolsCapability>();
12
+
13
+ /** Makes `capability` the kernel tools of calls carrying `token` until the returned release runs. */
14
+ bind(token: string, capability: KernelToolsCapability): () => void {
15
+ this.#byToken.set(token, capability);
16
+ return () => {
17
+ if (this.#byToken.get(token) === capability) this.#byToken.delete(token);
18
+ };
19
+ }
20
+
21
+ /** Runs `call` with the capability bound to `token`; an absent, unknown or released token gets none. */
22
+ async run<T>(token: string | undefined, call: () => Promise<T>): Promise<T> {
23
+ const capability = token === undefined ? undefined : this.#byToken.get(token);
24
+ return capability === undefined
25
+ ? await kernelToolsStorage.exit(call)
26
+ : await kernelToolsStorage.run(capability, call);
27
+ }
28
+ }
@@ -4,6 +4,7 @@ import type { BridgeConnectionConfig, KernelToHostMessage } from "../bridge/prot
4
4
  import { JuliaKernel } from "../kernels/jl/kernel.ts";
5
5
  import type { JavaScriptKernel } from "../kernels/js/context-manager.ts";
6
6
  import { PythonKernel } from "../kernels/py/kernel.ts";
7
+ import type { PeerKernelToolsDescribe } from "../kernels/py/kernel-tools-host.ts";
7
8
  import { defaultSpawn } from "../kernels/py/process.ts";
8
9
  import { RubyKernel } from "../kernels/rb/kernel.ts";
9
10
  import type { SessionEnvironment } from "../kernels/session-env.ts";
@@ -27,6 +28,7 @@ export interface SubprocessKernelStart {
27
28
  readonly connection: BridgeConnectionConfig;
28
29
  readonly onMessage: (message: KernelToHostMessage) => void;
29
30
  };
31
+ readonly peerKernelToolsDescribe?: PeerKernelToolsDescribe;
30
32
  }
31
33
 
32
34
  export function registerKernel(sessionId: string, language: EvalLanguage, memory: RegisteredKernelSource): string {
@@ -82,6 +84,9 @@ export async function startSubprocessKernel(start: SubprocessKernelStart): Promi
82
84
  ...shared,
83
85
  interpreterPath: start.interpreterPath,
84
86
  memory,
87
+ ...(start.peerKernelToolsDescribe === undefined
88
+ ? {}
89
+ : { peerKernelToolsDescribe: start.peerKernelToolsDescribe }),
85
90
  spawnProcess: (options) => {
86
91
  const child = defaultSpawn(options);
87
92
  pid = child.pid;
@@ -13,6 +13,7 @@ import type {
13
13
  KernelInterruptHandle,
14
14
  PendingCell,
15
15
  } from "../tool/types.ts";
16
+ import { hasKernelTools, type KernelToolsMethods } from "./kernel-tools-probe.ts";
16
17
 
17
18
  export type StartKernel = (lifecycle: Required<KernelLifecycle>) => Promise<EvalKernel>;
18
19
 
@@ -50,10 +51,29 @@ export class ReplaceableKernel implements EvalKernel {
50
51
  #closed = false;
51
52
  #closeFailure: unknown;
52
53
 
54
+ // Present only when the instance has kernel tools, so a probe sees what it would see on the instance itself.
55
+ declare readonly describeKernelTools?: KernelToolsMethods["describeKernelTools"];
56
+ declare readonly invokeKernelTool?: KernelToolsMethods["invokeKernelTool"];
57
+
53
58
  private constructor(language: EvalLanguage, start: StartKernel, first: EvalKernel) {
54
59
  this.#language = language;
55
60
  this.#start = start;
56
61
  this.#kernel = first;
62
+ // Python is the one subprocess kernel with kernel tools; rb and jl only carry tools_unavailable stubs to hide.
63
+ if (language === "py" && hasKernelTools(first)) {
64
+ // Always the CURRENT instance: after a replacement the old definitions are gone with the old interpreter.
65
+ const tools: KernelToolsMethods = {
66
+ describeKernelTools: (names) => this.#currentTools().describeKernelTools(names),
67
+ invokeKernelTool: (request, options) => this.#currentTools().invokeKernelTool(request, options),
68
+ };
69
+ Object.assign(this, tools);
70
+ }
71
+ }
72
+
73
+ #currentTools(): KernelToolsMethods {
74
+ const current = this.#kernel;
75
+ if (!hasKernelTools(current)) throw new Error(`The ${this.#language} kernel has no kernel tools`);
76
+ return current;
57
77
  }
58
78
 
59
79
  /** Starts the first instance; one that cannot hand back its queue is returned as it is. */
@@ -8,6 +8,7 @@ import {
8
8
  type ResolvedCodemodeSettings,
9
9
  resolveEnabledLanguages,
10
10
  } from "../config/settings.ts";
11
+ import { PythonEnvironments } from "../environments/python-environments.ts";
11
12
  import type { HandleRegistry } from "../handles/handle-registry.ts";
12
13
  import {
13
14
  createInterpreterDetector,
@@ -47,6 +48,7 @@ export type SessionRuntime = {
47
48
  readonly artifactsDir: string;
48
49
  readonly executeTool: AgentExecuteTool;
49
50
  readonly spawns: boolean;
51
+ readonly pythonEnvironments?: PythonEnvironments;
50
52
  };
51
53
 
52
54
  export async function createRuntime(
@@ -96,6 +98,16 @@ export async function createRuntime(
96
98
  artifactsDir: artifacts.dir,
97
99
  executeTool,
98
100
  spawns: activeTools.has(settings.taskTools.task),
101
+ ...(availability.py.detected.ok && enabledLanguages.py
102
+ ? {
103
+ pythonEnvironments: new PythonEnvironments({
104
+ artifactsDir: artifacts.dir,
105
+ cwd: ctx.cwd,
106
+ interpreter: availability.py.detected.path,
107
+ settings,
108
+ }),
109
+ }
110
+ : {}),
99
111
  };
100
112
  }
101
113