@code-yeongyu/senpi-codemode 2026.10.9 → 2026.10.10
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 +22 -0
- package/README.md +49 -1
- package/package.json +4 -4
- package/src/environments/js-environments.ts +208 -0
- package/src/environments/js-installer.ts +268 -0
- package/src/environments/js-revision-files.ts +91 -0
- package/src/environments/no-links.ts +132 -0
- package/src/environments/project-resolves.ts +54 -0
- package/src/environments/revision-store.ts +20 -4
- package/src/extension/runtime-factory.ts +9 -0
- package/src/index.ts +1 -0
- package/src/kernels/js/cell-dispatch.ts +44 -0
- package/src/kernels/js/context-manager.ts +23 -27
- package/src/kernels/js/host-entries.ts +62 -0
- package/src/kernels/js/local-module-loader.ts +17 -6
- package/src/kernels/js/worker-core.js +2 -0
- package/src/kernels/js/worker-package-resolve.js +153 -0
- package/src/kernels/py/host-cell.ts +8 -31
- package/src/kernels/sandbox/sandbox-cell.ts +116 -0
- package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
- package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
- package/src/kernels/shared/host-cell.ts +57 -0
- package/src/tool/cell-handler.ts +9 -11
- package/src/tool/eval-request.ts +23 -1
- package/src/tool/eval-tool-options.ts +3 -0
- package/src/tool/eval-tool.ts +16 -4
- package/src/tool/load-cell.ts +18 -3
- package/src/tool/magic-cell-host.ts +60 -2
- package/src/tool/magic-cells.ts +12 -2
- package/src/tool/run-eval-cell.ts +30 -3
- package/src/tool/types.ts +21 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const NPMRC_CARRIED_KEY = /^(?:registry|@[^\s=:/]+:registry|strict-ssl|ca|cafile)$/;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A revision carries forward only the registry settings of its `.npmrc`: `registry`, `@scope:registry`, `strict-ssl`,
|
|
8
|
+
* `ca` and `cafile`. Every other key, credentials included, is dropped by default, and the file is rewritten as a
|
|
9
|
+
* regular file: a symlinked `.npmrc` is replaced, never written through, so the file it pointed at stays untouched.
|
|
10
|
+
*/
|
|
11
|
+
export async function carryNpmrcSettings(root: string): Promise<void> {
|
|
12
|
+
const path = join(root, ".npmrc");
|
|
13
|
+
let text: string;
|
|
14
|
+
try {
|
|
15
|
+
text = await readFile(path, "utf8");
|
|
16
|
+
} catch {
|
|
17
|
+
await rm(path, { recursive: true, force: true });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const kept = text
|
|
21
|
+
.split(/\r\n|\r|\n/)
|
|
22
|
+
.map((line) => line.trim())
|
|
23
|
+
.filter((line) => {
|
|
24
|
+
const key = line.split("=", 1)[0]?.trim() ?? "";
|
|
25
|
+
return line.includes("=") && NPMRC_CARRIED_KEY.test(key);
|
|
26
|
+
})
|
|
27
|
+
.flatMap((line) => {
|
|
28
|
+
const carried = withoutUrlCredentials(line);
|
|
29
|
+
return carried === undefined ? [] : [carried];
|
|
30
|
+
});
|
|
31
|
+
await rm(path, { recursive: true, force: true });
|
|
32
|
+
if (kept.length > 0) await writeFile(path, `${kept.join("\n")}\n`, { mode: 0o600, flag: "wx" });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The `file:` and absolute path specs a revision's `package.json` already records, as written (npm writes a relative
|
|
37
|
+
* `file:../..` path) and resolved against the revision: the installer echoes them on every later install.
|
|
38
|
+
*/
|
|
39
|
+
export async function recordedFileSpecs(root: string): Promise<string[]> {
|
|
40
|
+
try {
|
|
41
|
+
const manifest: unknown = JSON.parse(await readFile(join(root, "package.json"), "utf8"));
|
|
42
|
+
if (typeof manifest !== "object" || manifest === null || !("dependencies" in manifest)) return [];
|
|
43
|
+
const dependencies = manifest.dependencies;
|
|
44
|
+
if (typeof dependencies !== "object" || dependencies === null) return [];
|
|
45
|
+
return Object.values(dependencies).flatMap((spec) => {
|
|
46
|
+
if (typeof spec !== "string") return [];
|
|
47
|
+
if (spec.startsWith("file:")) {
|
|
48
|
+
const path = spec.slice("file:".length);
|
|
49
|
+
return isAbsolute(path) ? [path] : [path, resolve(root, path)];
|
|
50
|
+
}
|
|
51
|
+
return isAbsolute(spec) ? [spec] : [];
|
|
52
|
+
});
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A carried `registry`/`@scope:registry` value keeps only its scheme, host and path: user info, a query and a fragment
|
|
60
|
+
* can each carry a credential, so they are cleared with the WHATWG URL parser npm itself uses. The path is kept, since
|
|
61
|
+
* registries are addressed by it, so a registry that embeds a token in its path keeps it in the revision's private
|
|
62
|
+
* (0600, in a 0700 directory) copy. A value that does not parse is dropped, and so is one that names an environment
|
|
63
|
+
* variable (`${...}`): npm expands it at install time, and a parsed copy would be a different URL. Other carried keys
|
|
64
|
+
* pass unchanged.
|
|
65
|
+
*/
|
|
66
|
+
function withoutUrlCredentials(line: string): string | undefined {
|
|
67
|
+
const separator = line.indexOf("=");
|
|
68
|
+
const key = line.slice(0, separator).trim();
|
|
69
|
+
if (!key.endsWith("registry")) return line;
|
|
70
|
+
const raw = line.slice(separator + 1).trim();
|
|
71
|
+
if (raw.includes("${")) return undefined;
|
|
72
|
+
const quote = raw.length >= 2 && (raw[0] === '"' || raw[0] === "'") && raw.endsWith(raw[0]) ? raw[0] : "";
|
|
73
|
+
let url: URL;
|
|
74
|
+
try {
|
|
75
|
+
url = new URL(quote === "" ? raw : raw.slice(1, -1));
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
url.username = "";
|
|
80
|
+
url.password = "";
|
|
81
|
+
url.search = "";
|
|
82
|
+
url.hash = "";
|
|
83
|
+
return `${key}=${quote}${url.href}${quote}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Without its own `package.json`, bun walks up from the revision and installs into the nearest one above it. */
|
|
87
|
+
export async function seedPackageJson(root: string): Promise<void> {
|
|
88
|
+
await writeFile(join(root, "package.json"), '{"private":true}\n', { flag: "wx" }).catch((error: unknown) => {
|
|
89
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { lstat, readdir, readFile, readlink, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { EnvironmentError } from "./py-installer.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every managed-root write goes through here: each component of `path` strictly below `root` that exists must be a
|
|
7
|
+
* real file or directory, never a link, so a write can never land in what a planted link points at. `root` itself
|
|
8
|
+
* may be a link (a user may relocate their whole artifacts directory); nothing below it may.
|
|
9
|
+
*/
|
|
10
|
+
export async function assertNoLinksBelow(root: string, path: string): Promise<void> {
|
|
11
|
+
const below = relative(root, path);
|
|
12
|
+
if (below.startsWith("..") || isAbsolute(below)) {
|
|
13
|
+
throw new EnvironmentError("environment_install_failed", "refusing to write outside the managed environment");
|
|
14
|
+
}
|
|
15
|
+
let current = root;
|
|
16
|
+
for (const part of below.split(sep).filter((segment) => segment !== "")) {
|
|
17
|
+
current = join(current, part);
|
|
18
|
+
const stats = await lstat(current).catch(missing);
|
|
19
|
+
if (stats === undefined) return;
|
|
20
|
+
if (stats.isSymbolicLink()) {
|
|
21
|
+
throw new EnvironmentError(
|
|
22
|
+
"environment_install_failed",
|
|
23
|
+
`${relative(root, current)} is a link; refusing to write through it`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The local directories a revision's `package.json` records as install sources, by package name, as realpaths: npm
|
|
31
|
+
* writes `file:<path relative to the revision>`, bun the absolute path. Only these may be link targets out of the
|
|
32
|
+
* revision, because installing from a directory links the package to it.
|
|
33
|
+
*/
|
|
34
|
+
export async function recordedInstallSources(revision: string): Promise<ReadonlyMap<string, string>> {
|
|
35
|
+
const sources = new Map<string, string>();
|
|
36
|
+
let manifest: unknown;
|
|
37
|
+
try {
|
|
38
|
+
manifest = JSON.parse(await readFile(join(revision, "package.json"), "utf8"));
|
|
39
|
+
} catch {
|
|
40
|
+
return sources;
|
|
41
|
+
}
|
|
42
|
+
if (typeof manifest !== "object" || manifest === null || !("dependencies" in manifest)) return sources;
|
|
43
|
+
const dependencies = manifest.dependencies;
|
|
44
|
+
if (typeof dependencies !== "object" || dependencies === null) return sources;
|
|
45
|
+
for (const [name, spec] of Object.entries(dependencies)) {
|
|
46
|
+
if (typeof spec !== "string") continue;
|
|
47
|
+
const path = spec.startsWith("file:") ? spec.slice("file:".length) : spec;
|
|
48
|
+
if (!isAbsolute(path) && !path.startsWith("./") && !path.startsWith("../")) continue;
|
|
49
|
+
const source = await realpath(resolve(revision, path)).catch(() => undefined);
|
|
50
|
+
if (source !== undefined && (await stat(source)).isDirectory()) sources.set(name, source);
|
|
51
|
+
}
|
|
52
|
+
return sources;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A copy filter. A link inside `tree` may point inside it (relatively: an absolute link would keep pointing at the
|
|
57
|
+
* previous revision from the copy), or out of it only to exactly the matching file of a recorded install source:
|
|
58
|
+
* the link at `node_modules/<name>/<rest>` may resolve to `<source of name>/<rest>` and to nothing else. Whether a
|
|
59
|
+
* target is inside is judged from the link's own text, without following further links.
|
|
60
|
+
*/
|
|
61
|
+
export async function assertLinkStaysInside(
|
|
62
|
+
path: string,
|
|
63
|
+
tree: string,
|
|
64
|
+
sources: ReadonlyMap<string, string>,
|
|
65
|
+
): Promise<boolean> {
|
|
66
|
+
if (!(await lstat(path)).isSymbolicLink()) return true;
|
|
67
|
+
const target = await readlink(path);
|
|
68
|
+
const resolved = resolve(dirname(path), target);
|
|
69
|
+
const inside = relative(tree, resolved);
|
|
70
|
+
const where = relative(tree, path);
|
|
71
|
+
if (inside === "" || (!inside.startsWith("..") && !isAbsolute(inside))) {
|
|
72
|
+
if (!isAbsolute(target)) return true;
|
|
73
|
+
throw new EnvironmentError(
|
|
74
|
+
"environment_install_failed",
|
|
75
|
+
`${where} is an absolute link into its revision; refusing to build on it`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (await isRecordedSourceLink(where, resolved, sources)) return true;
|
|
79
|
+
throw new EnvironmentError(
|
|
80
|
+
"environment_install_failed",
|
|
81
|
+
`${where} links outside its revision; refusing to build on it`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function isRecordedSourceLink(
|
|
86
|
+
where: string,
|
|
87
|
+
resolved: string,
|
|
88
|
+
sources: ReadonlyMap<string, string>,
|
|
89
|
+
): Promise<boolean> {
|
|
90
|
+
const parts = where.split(sep);
|
|
91
|
+
if (parts[0] !== "node_modules" || parts[1] === undefined) return false;
|
|
92
|
+
const scoped = parts[1].startsWith("@");
|
|
93
|
+
const name = scoped ? `${parts[1]}/${parts[2] ?? ""}` : parts[1];
|
|
94
|
+
const source = sources.get(name);
|
|
95
|
+
if (source === undefined) return false;
|
|
96
|
+
// `source` is a realpath; the expected target is that path plus the link's own path below the package, taken
|
|
97
|
+
// literally, so a file of the source that is itself a link out of it does not count as the source.
|
|
98
|
+
const expected = join(source, ...parts.slice(scoped ? 3 : 2));
|
|
99
|
+
const actual = await realpath(resolved).catch(() => undefined);
|
|
100
|
+
return actual !== undefined && actual === expected;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function missing(error: unknown): undefined {
|
|
104
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Checks every link in a built revision as the next copy will, so an install that leaves a link the next install
|
|
110
|
+
* would refuse (an installer keeping links into a directory the manifest no longer records) is refused now, while
|
|
111
|
+
* the previous revision is still active.
|
|
112
|
+
*/
|
|
113
|
+
export async function assertBuiltRevisionLinks(revision: string): Promise<void> {
|
|
114
|
+
const sources = await recordedInstallSources(revision);
|
|
115
|
+
const walk = async (dir: string): Promise<void> => {
|
|
116
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
117
|
+
const path = join(dir, entry.name);
|
|
118
|
+
if (entry.isSymbolicLink()) {
|
|
119
|
+
await assertLinkStaysInside(path, revision, sources).catch((error: unknown) => {
|
|
120
|
+
const where = relative(revision, path);
|
|
121
|
+
throw error instanceof EnvironmentError
|
|
122
|
+
? new EnvironmentError(
|
|
123
|
+
"environment_install_failed",
|
|
124
|
+
`the install left ${where} linked outside the revision, so nothing was published`,
|
|
125
|
+
)
|
|
126
|
+
: error;
|
|
127
|
+
});
|
|
128
|
+
} else if (entry.isDirectory()) await walk(path);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
await walk(revision);
|
|
132
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether a bare import of `name` from the session directory resolves in the project before a managed revision: the
|
|
6
|
+
* host-side twin of the kernel resolver (`kernels/js/worker-package-resolve.js` stays plain JavaScript, and a `.ts`
|
|
7
|
+
* module may not import it). Like the resolver it stops at the first `node_modules/<name>` directory up the chain,
|
|
8
|
+
* and that directory wins only when the resolver would take an entry from it.
|
|
9
|
+
*/
|
|
10
|
+
export function projectResolves(cwd: string, name: string): boolean {
|
|
11
|
+
for (let directory = cwd; ; directory = dirname(directory)) {
|
|
12
|
+
const packageDir = join(directory, "node_modules", name);
|
|
13
|
+
if (isDirectory(packageDir)) return hasRootEntry(packageDir);
|
|
14
|
+
if (dirname(directory) === directory) return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function hasRootEntry(packageDir: string): boolean {
|
|
19
|
+
let manifest: unknown;
|
|
20
|
+
try {
|
|
21
|
+
manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
|
|
22
|
+
} catch {
|
|
23
|
+
return legacyFile(join(packageDir, "index.js"));
|
|
24
|
+
}
|
|
25
|
+
// The resolver throws on a null manifest and treats any other non-object as one without fields.
|
|
26
|
+
if (manifest === null) return true;
|
|
27
|
+
if (typeof manifest !== "object") return legacyFile(join(packageDir, "index.js"));
|
|
28
|
+
// With `exports` the resolver commits to this package: it imports the "." target or fails, never falling through.
|
|
29
|
+
if ("exports" in manifest && manifest.exports !== undefined && manifest.exports !== null) return true;
|
|
30
|
+
const moduleEntry =
|
|
31
|
+
"Bun" in globalThis && "module" in manifest && typeof manifest.module === "string" ? manifest.module : undefined;
|
|
32
|
+
const main = "main" in manifest && typeof manifest.main === "string" ? manifest.main : "index.js";
|
|
33
|
+
return legacyFile(join(packageDir, moduleEntry ?? main));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function legacyFile(path: string): boolean {
|
|
37
|
+
return [path, `${path}.js`, `${path}.mjs`, `${path}.cjs`, join(path, "index.js")].some(isFile);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isFile(path: string): boolean {
|
|
41
|
+
try {
|
|
42
|
+
return statSync(path).isFile();
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isDirectory(path: string): boolean {
|
|
49
|
+
try {
|
|
50
|
+
return statSync(path).isDirectory();
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { chmod, cp, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { type LockWaitNotice, withRootLock } from "./install-lock.ts";
|
|
6
|
+
import { assertLinkStaysInside, assertNoLinksBelow, recordedInstallSources } from "./no-links.ts";
|
|
6
7
|
import { EnvironmentError } from "./py-installer.ts";
|
|
7
8
|
|
|
8
9
|
const POINTER = "active";
|
|
@@ -50,7 +51,7 @@ export async function publishNextRevision(
|
|
|
50
51
|
signal?: AbortSignal,
|
|
51
52
|
onLockWait?: (notice: LockWaitNotice) => void,
|
|
52
53
|
): Promise<{ readonly revision: Revision; readonly previous: Revision | undefined }> {
|
|
53
|
-
await
|
|
54
|
+
await mkdirPrivate(base);
|
|
54
55
|
return withRootLock(
|
|
55
56
|
base,
|
|
56
57
|
async () => {
|
|
@@ -59,8 +60,18 @@ export async function publishNextRevision(
|
|
|
59
60
|
const staging = join(base, `.staging-rev-${number}-${process.pid}-${randomUUID()}`);
|
|
60
61
|
const dir = join(base, `rev-${number}`);
|
|
61
62
|
try {
|
|
62
|
-
if (previous === undefined) await mkdir(staging, {
|
|
63
|
-
else
|
|
63
|
+
if (previous === undefined) await mkdir(staging, { mode: 0o700 });
|
|
64
|
+
else {
|
|
65
|
+
await assertNoLinksBelow(base, previous.dir);
|
|
66
|
+
const sources = await recordedInstallSources(previous.dir);
|
|
67
|
+
await cp(previous.dir, staging, {
|
|
68
|
+
recursive: true,
|
|
69
|
+
verbatimSymlinks: true,
|
|
70
|
+
filter: async (source) => await assertLinkStaysInside(source, previous.dir, sources),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// Explicit, not left to the umask: a revision holds the user's installed packages and carried registry config.
|
|
74
|
+
await chmod(staging, 0o700);
|
|
64
75
|
await build(staging, previous);
|
|
65
76
|
signal?.throwIfAborted();
|
|
66
77
|
await renameRetrying(staging, dir);
|
|
@@ -108,3 +119,8 @@ async function highestRevision(base: string): Promise<number> {
|
|
|
108
119
|
}
|
|
109
120
|
return highest;
|
|
110
121
|
}
|
|
122
|
+
|
|
123
|
+
/** Creates `dir` and any missing parents private to the user (0700; the umask can only narrow it further). */
|
|
124
|
+
export async function mkdirPrivate(dir: string): Promise<void> {
|
|
125
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
126
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type ResolvedCodemodeSettings,
|
|
9
9
|
resolveEnabledLanguages,
|
|
10
10
|
} from "../config/settings.ts";
|
|
11
|
+
import { JsEnvironments } from "../environments/js-environments.ts";
|
|
11
12
|
import { PythonEnvironments } from "../environments/python-environments.ts";
|
|
12
13
|
import type { HandleRegistry } from "../handles/handle-registry.ts";
|
|
13
14
|
import {
|
|
@@ -49,6 +50,7 @@ export type SessionRuntime = {
|
|
|
49
50
|
readonly executeTool: AgentExecuteTool;
|
|
50
51
|
readonly spawns: boolean;
|
|
51
52
|
readonly pythonEnvironments?: PythonEnvironments;
|
|
53
|
+
readonly jsEnvironments: JsEnvironments;
|
|
52
54
|
};
|
|
53
55
|
|
|
54
56
|
export async function createRuntime(
|
|
@@ -98,6 +100,13 @@ export async function createRuntime(
|
|
|
98
100
|
artifactsDir: artifacts.dir,
|
|
99
101
|
executeTool,
|
|
100
102
|
spawns: activeTools.has(settings.taskTools.task),
|
|
103
|
+
jsEnvironments: new JsEnvironments({
|
|
104
|
+
artifactsDir: artifacts.dir,
|
|
105
|
+
cwd: ctx.cwd,
|
|
106
|
+
runtime: jsRuntimeInfo().name,
|
|
107
|
+
env: { ...process.env, ...sessionEnv },
|
|
108
|
+
settings,
|
|
109
|
+
}),
|
|
101
110
|
...(availability.py.detected.ok && enabledLanguages.py
|
|
102
111
|
? {
|
|
103
112
|
pythonEnvironments: new PythonEnvironments({
|
package/src/index.ts
CHANGED
|
@@ -158,6 +158,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
|
|
|
158
158
|
settings: runtime.settings,
|
|
159
159
|
artifactsDir: runtime.artifactsDir,
|
|
160
160
|
...(runtime.pythonEnvironments === undefined ? {} : { pythonEnvironments: runtime.pythonEnvironments }),
|
|
161
|
+
jsEnvironments: runtime.jsEnvironments,
|
|
161
162
|
cellManager,
|
|
162
163
|
handles,
|
|
163
164
|
executionTracker: manager,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { HostToKernelMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import type { HostCellExecutor } from "../../tool/types.ts";
|
|
3
|
+
import { inputAtStart } from "../shared/cell-source-at-start.ts";
|
|
4
|
+
import { refusedEntry } from "./host-entries.ts";
|
|
5
|
+
import {
|
|
6
|
+
type JavaScriptKernelOptions,
|
|
7
|
+
type JavaScriptRunInput,
|
|
8
|
+
resolveKernelToolNameSource,
|
|
9
|
+
} from "./kernel-contract.ts";
|
|
10
|
+
import type { LocalModuleLoader } from "./local-module-loader.ts";
|
|
11
|
+
|
|
12
|
+
export type CellDispatch =
|
|
13
|
+
| { readonly kind: "host"; readonly host: HostCellExecutor }
|
|
14
|
+
| { readonly kind: "worker"; readonly frames: readonly HostToKernelMessage[] };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Decides at the cell's turn how it runs: as a host entry (an install, or a turn-time refusal such as an unreadable
|
|
18
|
+
* `%load` file) or in the worker, with the frames the worker needs (the current kernel-tool names, then the cell).
|
|
19
|
+
*/
|
|
20
|
+
export function dispatchCell(
|
|
21
|
+
queued: JavaScriptRunInput,
|
|
22
|
+
loader: LocalModuleLoader,
|
|
23
|
+
options: Pick<JavaScriptKernelOptions, "hostToolNames" | "foreignLanguageNames">,
|
|
24
|
+
): CellDispatch {
|
|
25
|
+
const input = inputAtStart(queued);
|
|
26
|
+
if ("refused" in input) return { kind: "host", host: refusedEntry(input.refused) };
|
|
27
|
+
if (input.host !== undefined) return { kind: "host", host: input.host };
|
|
28
|
+
return {
|
|
29
|
+
kind: "worker",
|
|
30
|
+
frames: [
|
|
31
|
+
{
|
|
32
|
+
type: "kernel-tools-names",
|
|
33
|
+
hostToolNames: resolveKernelToolNameSource(options.hostToolNames),
|
|
34
|
+
foreignLanguageNames: resolveKernelToolNameSource(options.foreignLanguageNames),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
type: "run",
|
|
38
|
+
cellId: input.cellId,
|
|
39
|
+
code: loader.prepareCell(input.code, input.kernelPreludes, input.sourceFile, input.packageRoot?.()),
|
|
40
|
+
timeoutMs: input.timeoutMs,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
2
|
import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
|
|
3
3
|
import type { KernelInterruptHandle } from "../../tool/types.ts";
|
|
4
|
-
import { inputAtStart } from "../shared/cell-source-at-start.ts";
|
|
5
4
|
import { KernelToolHostPump } from "../shared/kernel-tools-pump.ts";
|
|
6
5
|
import { ActiveCellControl } from "./active-cell-control.ts";
|
|
7
|
-
import {
|
|
6
|
+
import { dispatchCell } from "./cell-dispatch.ts";
|
|
7
|
+
import { HostEntries } from "./host-entries.ts";
|
|
8
|
+
import { DEFAULT_INTERRUPT_BOUNDS, JS_INTERRUPT_GRACE_MS, type WorkerRetirement } from "./interrupt-bounds.ts";
|
|
8
9
|
import {
|
|
9
10
|
assertJavaScriptKernelOpen,
|
|
10
11
|
type JavaScriptKernelMode,
|
|
11
12
|
type JavaScriptRunInput,
|
|
12
13
|
type LifecycleState,
|
|
13
14
|
type ResultMessage,
|
|
14
|
-
resolveKernelToolNameSource,
|
|
15
15
|
type ToolCallMessage,
|
|
16
16
|
} from "./kernel-contract.ts";
|
|
17
17
|
import { type JavaScriptMemoryReading, KernelMemoryBridge } from "./kernel-memory-bridge.ts";
|
|
@@ -22,7 +22,7 @@ import type {
|
|
|
22
22
|
KernelToolsInvokeRequest,
|
|
23
23
|
} from "./kernel-tools-types.ts";
|
|
24
24
|
import { type JavaScriptKernelOptions, LocalModuleLoader } from "./local-module-loader.ts";
|
|
25
|
-
import { JavaScriptRunQueue } from "./run-queue.ts";
|
|
25
|
+
import { JavaScriptRunQueue, type PendingJavaScriptRun } from "./run-queue.ts";
|
|
26
26
|
import { ToolCallQueue } from "./tool-call-queue.ts";
|
|
27
27
|
import { WorkerChildren } from "./worker-children.ts";
|
|
28
28
|
import { crashedResult } from "./worker-host.ts";
|
|
@@ -35,6 +35,15 @@ export type { JavaScriptKernelOptions } from "./local-module-loader.ts";
|
|
|
35
35
|
export { type JavaScriptWorkerEntryUrlOptions, resolveJsWorkerEntryUrl } from "./worker-startup.ts";
|
|
36
36
|
|
|
37
37
|
export class JavaScriptKernel {
|
|
38
|
+
readonly #hostEntries = new HostEntries<PendingJavaScriptRun>({
|
|
39
|
+
emit: (run, message) => (run.input.onMessage ?? this.#options.onMessage)?.(message),
|
|
40
|
+
settle: (run, result) => {
|
|
41
|
+
if (!this.#runs.releaseActive(run)) return;
|
|
42
|
+
this.#runs.settle(run, result);
|
|
43
|
+
this.#startNext();
|
|
44
|
+
},
|
|
45
|
+
durationMs: (run) => this.#runs.durationMs(run, performance.now()),
|
|
46
|
+
});
|
|
38
47
|
readonly #options: JavaScriptKernelOptions;
|
|
39
48
|
readonly #moduleLoader: LocalModuleLoader;
|
|
40
49
|
readonly #slot: WorkerSlot;
|
|
@@ -129,6 +138,7 @@ export class JavaScriptKernel {
|
|
|
129
138
|
const cancelled = this.cancelQueued(cellId, reason);
|
|
130
139
|
return { stateRetained: Promise.resolve(true), ...(cancelled ? {} : { note: "cell not found" }) };
|
|
131
140
|
}
|
|
141
|
+
if (active && this.#hostEntries.abort(active, reason)) return { stateRetained: Promise.resolve(true) };
|
|
132
142
|
if (!active) {
|
|
133
143
|
// A worker still stuck in startup is not a healthy idle worker: retiring it is the only recovery.
|
|
134
144
|
const wedgedInStartup = this.#slot.startingUp;
|
|
@@ -163,11 +173,14 @@ export class JavaScriptKernel {
|
|
|
163
173
|
if (this.#closePromise) return await this.#closePromise;
|
|
164
174
|
this.#slot.postMessage({ type: "close" });
|
|
165
175
|
this.#lifecycle = "closing";
|
|
176
|
+
const graceMs = this.#options.interruptBounds?.graceMs ?? JS_INTERRUPT_GRACE_MS;
|
|
177
|
+
const hostEntriesStopped = this.#hostEntries.stopAll("JS kernel closed", graceMs);
|
|
166
178
|
this.#runs.settleAll("JS kernel closed");
|
|
167
179
|
this.#kernelTools.rejectAll(kernelToolError("kernel_tool_stale", "JS kernel closed"));
|
|
168
180
|
this.#toolCalls.clear();
|
|
169
181
|
const recovery = this.#recovery.inFlight;
|
|
170
182
|
const closePromise = (async () => {
|
|
183
|
+
await hostEntriesStopped;
|
|
171
184
|
if (recovery) await recovery;
|
|
172
185
|
await this.#terminate();
|
|
173
186
|
})().finally(() => {
|
|
@@ -196,31 +209,13 @@ export class JavaScriptKernel {
|
|
|
196
209
|
if (this.#lifecycle !== "open" || this.#runs.active || !this.#slot.present) return;
|
|
197
210
|
const next = this.#runs.startNext(performance.now());
|
|
198
211
|
if (!next) return;
|
|
199
|
-
const
|
|
200
|
-
if ("
|
|
201
|
-
this.#
|
|
202
|
-
this.#runs.settle(next, {
|
|
203
|
-
type: "result",
|
|
204
|
-
cellId: next.input.cellId,
|
|
205
|
-
ok: false,
|
|
206
|
-
error: { message: input.refused },
|
|
207
|
-
durationMs: 0,
|
|
208
|
-
});
|
|
209
|
-
this.#startNext();
|
|
212
|
+
const dispatch = dispatchCell(next.input, this.#moduleLoader, this.#options);
|
|
213
|
+
if (dispatch.kind === "host") {
|
|
214
|
+
this.#hostEntries.start(next, dispatch.host);
|
|
210
215
|
return;
|
|
211
216
|
}
|
|
212
217
|
this.#activeCell.arm(next);
|
|
213
|
-
this.#slot.postMessage(
|
|
214
|
-
type: "kernel-tools-names",
|
|
215
|
-
hostToolNames: resolveKernelToolNameSource(this.#options.hostToolNames),
|
|
216
|
-
foreignLanguageNames: resolveKernelToolNameSource(this.#options.foreignLanguageNames),
|
|
217
|
-
});
|
|
218
|
-
this.#slot.postMessage({
|
|
219
|
-
type: "run",
|
|
220
|
-
cellId: next.input.cellId,
|
|
221
|
-
code: this.#moduleLoader.prepareCell(input.code, input.kernelPreludes, input.sourceFile),
|
|
222
|
-
timeoutMs: next.input.timeoutMs,
|
|
223
|
-
});
|
|
218
|
+
for (const frame of dispatch.frames) this.#slot.postMessage(frame);
|
|
224
219
|
}
|
|
225
220
|
|
|
226
221
|
async #restartAfterStop(): Promise<void> {
|
|
@@ -261,7 +256,8 @@ export class JavaScriptKernel {
|
|
|
261
256
|
this.#activeCell.disarm();
|
|
262
257
|
this.#kernelTools.rejectAll(kernelToolError("kernel_tool_stale", error.message));
|
|
263
258
|
this.#memory.workerLost(error);
|
|
264
|
-
|
|
259
|
+
// A crash mid-install stops the install (process group killed, nothing published); it settles itself once stopped.
|
|
260
|
+
if (active && !this.#hostEntries.abort(active, `JavaScript worker crashed: ${error.message}`)) {
|
|
265
261
|
this.#runs.releaseActive(active);
|
|
266
262
|
this.#runs.settle(
|
|
267
263
|
active,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import type { HostCellExecutor } from "../../tool/types.ts";
|
|
3
|
+
import { runHostCell } from "../shared/host-cell.ts";
|
|
4
|
+
import type { ResultMessage } from "./kernel-contract.ts";
|
|
5
|
+
|
|
6
|
+
export interface HostEntryKernel<Run> {
|
|
7
|
+
emit(run: Run, message: KernelToHostMessage): void;
|
|
8
|
+
settle(run: Run, result: ResultMessage): void;
|
|
9
|
+
durationMs(run: Run): number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The host entries (installs) a JavaScript kernel is running: started per queue slot, stopped by interrupt and close. */
|
|
13
|
+
export class HostEntries<Run extends { readonly input: { readonly cellId: string } }> {
|
|
14
|
+
readonly #aborts = new Map<Run, AbortController>();
|
|
15
|
+
readonly #done = new Map<Run, Promise<unknown>>();
|
|
16
|
+
readonly #kernel: HostEntryKernel<Run>;
|
|
17
|
+
|
|
18
|
+
constructor(kernel: HostEntryKernel<Run>) {
|
|
19
|
+
this.#kernel = kernel;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Runs `host` for `run`'s queue slot; it holds the slot like a cell but never reaches the worker. */
|
|
23
|
+
start(run: Run, host: HostCellExecutor): void {
|
|
24
|
+
const entry = runHostCell(run.input.cellId, host, {
|
|
25
|
+
emit: (message) => this.#kernel.emit(run, message),
|
|
26
|
+
settle: (result) => {
|
|
27
|
+
this.#aborts.delete(run);
|
|
28
|
+
this.#kernel.settle(run, result);
|
|
29
|
+
},
|
|
30
|
+
durationMs: () => this.#kernel.durationMs(run),
|
|
31
|
+
settleAfterAbort: true,
|
|
32
|
+
});
|
|
33
|
+
this.#aborts.set(run, entry.abort);
|
|
34
|
+
this.#done.set(run, entry.done);
|
|
35
|
+
void entry.done.finally(() => this.#done.delete(run));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Aborts `run`'s host entry; false when `run` is not one. */
|
|
39
|
+
abort(run: Run, reason: string): boolean {
|
|
40
|
+
const abort = this.#aborts.get(run);
|
|
41
|
+
if (abort === undefined) return false;
|
|
42
|
+
abort.abort(new Error(reason));
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Aborts every running entry and waits for them to stop, at most `ms`: a closed kernel leaves no install running. */
|
|
47
|
+
async stopAll(reason: string, ms: number): Promise<void> {
|
|
48
|
+
const running = [...this.#done.values()];
|
|
49
|
+
for (const abort of this.#aborts.values()) abort.abort(new Error(reason));
|
|
50
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
51
|
+
await Promise.race([
|
|
52
|
+
Promise.allSettled(running),
|
|
53
|
+
new Promise<void>((resolve) => (timer = setTimeout(resolve, ms))),
|
|
54
|
+
]);
|
|
55
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A turn-time refusal (an unreadable `%load` file, a refused magic) settles in queue order as a failed host entry. */
|
|
60
|
+
export function refusedEntry(message: string): HostCellExecutor {
|
|
61
|
+
return async () => ({ ok: false, error: { message } });
|
|
62
|
+
}
|
|
@@ -98,7 +98,13 @@ function loaderPrelude(context: RuntimeModuleContext): string {
|
|
|
98
98
|
" const urlModule = await import('node:url');",
|
|
99
99
|
" target = urlModule.pathToFileURL(specifier).href;",
|
|
100
100
|
" }",
|
|
101
|
-
"
|
|
101
|
+
" const load = (resolved) => (options === undefined ? import(resolved) : import(resolved, options));",
|
|
102
|
+
" const resolvePackage = globalThis[Symbol.for('senpi.kernel.resolvePackage')];",
|
|
103
|
+
" if (target === specifier && typeof resolvePackage === 'function') {",
|
|
104
|
+
" const resolved = resolvePackage(specifier, [context.cwdUrl, context.packageRootUrl]);",
|
|
105
|
+
" if (resolved !== undefined) return await load(resolved);",
|
|
106
|
+
" }",
|
|
107
|
+
" return await load(target);",
|
|
102
108
|
"};",
|
|
103
109
|
].join("\n");
|
|
104
110
|
}
|
|
@@ -115,18 +121,23 @@ function contributionPrelude(plan: KernelPreludePlan): string {
|
|
|
115
121
|
|
|
116
122
|
export class LocalModuleLoader {
|
|
117
123
|
readonly #prelude: string;
|
|
124
|
+
readonly #cwdUrl: string;
|
|
118
125
|
readonly #contributions = new KernelPreludeTracker();
|
|
119
126
|
|
|
120
127
|
constructor(options: LocalModuleLoaderOptions) {
|
|
121
128
|
this.#prelude = loaderPrelude(runtimeContext(options));
|
|
129
|
+
this.#cwdUrl = directoryUrl(options.cwd);
|
|
122
130
|
}
|
|
123
131
|
|
|
124
|
-
prepareCell(
|
|
132
|
+
prepareCell(
|
|
133
|
+
code: string,
|
|
134
|
+
contributions: readonly KernelPreludeContribution[] = [],
|
|
135
|
+
sourceFile?: string,
|
|
136
|
+
packageRoot?: string,
|
|
137
|
+
): string {
|
|
125
138
|
// A %load cell resolves its relative imports from its own file's directory for that cell only.
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
? ""
|
|
129
|
-
: `\nglobalThis.__senpi_module_context__ = { ...globalThis.__senpi_module_context__, cwdUrl: ${JSON.stringify(directoryUrl(dirname(sourceFile)))} };`;
|
|
139
|
+
const cwdUrl = sourceFile === undefined ? this.#cwdUrl : directoryUrl(dirname(sourceFile));
|
|
140
|
+
const base = `\nglobalThis.__senpi_module_context__ = { ...globalThis.__senpi_module_context__, cwdUrl: ${JSON.stringify(cwdUrl)}, packageRootUrl: ${JSON.stringify(packageRoot === undefined ? null : directoryUrl(packageRoot))} };`;
|
|
130
141
|
const prelude = `${this.#prelude}${base}\n${contributionPrelude(this.#contributions.plan(contributions))}`;
|
|
131
142
|
return `${PREPARED_CELL_PREFIX}${JSON.stringify({ prelude, code: rewriteImports(code), ...(sourceFile === undefined ? {} : { sourceFile }) })}`;
|
|
132
143
|
}
|
|
@@ -3,6 +3,7 @@ import { kernelToolError } from "./kernel-tools-errors.js";
|
|
|
3
3
|
import { createKernelToolPump } from "./kernel-tools-pump.js";
|
|
4
4
|
import { hostDeniedError, hostToolRefusal } from "./kernel-tools-scope.js";
|
|
5
5
|
import { installSessionCwd } from "./worker-cwd.js";
|
|
6
|
+
import { installPackageResolver } from "./worker-package-resolve.js";
|
|
6
7
|
import { createHeapProbe } from "./worker-heap.js";
|
|
7
8
|
import { createWorkerMemory } from "./worker-memory.js";
|
|
8
9
|
import { JsWorkerRuntime } from "./worker-runtime.js";
|
|
@@ -129,6 +130,7 @@ export function createWorkerCore(transport, options) {
|
|
|
129
130
|
applySessionEnvironment(message.sessionEnv);
|
|
130
131
|
installSessionCwd(options.cwd);
|
|
131
132
|
installKernelWebView(requestWebViewPort);
|
|
133
|
+
installPackageResolver();
|
|
132
134
|
runtime = new JsWorkerRuntime({
|
|
133
135
|
cwd: options.cwd,
|
|
134
136
|
parallelPoolWidth: options.parallelPoolWidth,
|