@code-yeongyu/senpi-codemode 2026.10.9 → 2026.10.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.
Files changed (96) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/README.md +161 -11
  3. package/package.json +6 -5
  4. package/src/bridge/protocol.ts +8 -3
  5. package/src/bridge/reserved.ts +2 -0
  6. package/src/bridges/eval-environment-schemas.ts +48 -0
  7. package/src/bridges/eval-virtual-schemas.ts +11 -4
  8. package/src/bridges/reserved-dispatch.ts +13 -1
  9. package/src/config/executable-settings.json +5 -0
  10. package/src/config/project-trust.ts +51 -0
  11. package/src/config/settings-overrides.ts +2 -0
  12. package/src/environments/js-environments.ts +208 -0
  13. package/src/environments/js-installer.ts +268 -0
  14. package/src/environments/js-revision-files.ts +91 -0
  15. package/src/environments/no-links.ts +132 -0
  16. package/src/environments/packages-install.ts +98 -0
  17. package/src/environments/project-resolves.ts +54 -0
  18. package/src/environments/py-environment.ts +7 -3
  19. package/src/environments/py-installer.ts +5 -1
  20. package/src/environments/python-environments.ts +1 -1
  21. package/src/environments/revision-store.ts +20 -4
  22. package/src/extension/bridge-tool-call.ts +5 -1
  23. package/src/extension/eval-notifier.ts +13 -2
  24. package/src/extension/kernel-registration.ts +7 -2
  25. package/src/extension/runtime-factory.ts +51 -13
  26. package/src/extension/runtime-info.ts +6 -2
  27. package/src/extension/session-manager-contract.ts +3 -0
  28. package/src/extension/session-manager.ts +16 -2
  29. package/src/index.ts +3 -2
  30. package/src/interpreters/detect.ts +34 -1
  31. package/src/kernels/AGENTS.md +2 -0
  32. package/src/kernels/js/cell-dispatch.ts +44 -0
  33. package/src/kernels/js/cell-ownership.js +268 -0
  34. package/src/kernels/js/cell-run-context.js +89 -0
  35. package/src/kernels/js/context-manager.ts +46 -51
  36. package/src/kernels/js/control-frames.ts +24 -0
  37. package/src/kernels/js/host-entries.ts +62 -0
  38. package/src/kernels/js/kernel-contract.ts +16 -1
  39. package/src/kernels/js/kernel-memory-bridge.ts +1 -1
  40. package/src/kernels/js/kernel-memory.ts +79 -0
  41. package/src/kernels/js/kernel-tools-registry.js +1 -0
  42. package/src/kernels/js/local-module-loader.ts +17 -6
  43. package/src/kernels/js/process-drain-scan.d.ts +8 -0
  44. package/src/kernels/js/process-drain-scan.js +28 -0
  45. package/src/kernels/js/process-entry.js +466 -0
  46. package/src/kernels/js/process-memory-host.ts +83 -0
  47. package/src/kernels/js/process-worker.ts +298 -0
  48. package/src/kernels/js/rejection-reports.js +61 -0
  49. package/src/kernels/js/run-queue.ts +7 -0
  50. package/src/kernels/js/worker-core.js +53 -8
  51. package/src/kernels/js/worker-cwd.js +5 -2
  52. package/src/kernels/js/worker-host.ts +9 -2
  53. package/src/kernels/js/worker-indirect-eval.js +37 -13
  54. package/src/kernels/js/worker-package-resolve.js +153 -0
  55. package/src/kernels/js/worker-require.d.ts +19 -0
  56. package/src/kernels/js/worker-require.js +52 -0
  57. package/src/kernels/js/worker-runtime.js +43 -9
  58. package/src/kernels/js/worker-shadow-guard.js +2 -6
  59. package/src/kernels/js/worker-shell-capture.js +5 -0
  60. package/src/kernels/js/worker-slot.ts +9 -0
  61. package/src/kernels/js/worker-startup.ts +49 -1
  62. package/src/kernels/js/worker-webview.js +15 -1
  63. package/src/kernels/py/host-cell.ts +8 -31
  64. package/src/kernels/py/prelude.py +33 -2
  65. package/src/kernels/py/process.ts +4 -0
  66. package/src/kernels/sandbox/sandbox-cell.ts +141 -0
  67. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  68. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  69. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  70. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  71. package/src/kernels/shared/host-cell.ts +57 -0
  72. package/src/kernels/shared/subprocess-process.ts +14 -0
  73. package/src/prompt/eval-prompt.ts +8 -1
  74. package/src/tool/AGENTS.md +1 -0
  75. package/src/tool/cell-handler.ts +12 -11
  76. package/src/tool/cell-reserved-dispatch.ts +1 -0
  77. package/src/tool/cell-runtime.ts +4 -2
  78. package/src/tool/detached-cell-contract.ts +3 -0
  79. package/src/tool/detached-cell-manager.ts +4 -8
  80. package/src/tool/detached-cell-notification.ts +11 -63
  81. package/src/tool/detached-cell-snapshot.ts +17 -0
  82. package/src/tool/detached-eval-result.ts +13 -7
  83. package/src/tool/detached-notification-queue.ts +1 -2
  84. package/src/tool/eval-request.ts +23 -1
  85. package/src/tool/eval-tool-options.ts +3 -0
  86. package/src/tool/eval-tool.ts +17 -4
  87. package/src/tool/interrupt-note.ts +4 -5
  88. package/src/tool/live-headline.ts +60 -0
  89. package/src/tool/load-cell.ts +18 -3
  90. package/src/tool/magic-cell-host.ts +60 -2
  91. package/src/tool/magic-cells.ts +12 -2
  92. package/src/tool/managed-cell.ts +0 -4
  93. package/src/tool/render.ts +74 -15
  94. package/src/tool/run-eval-cell.ts +46 -4
  95. package/src/tool/runtime-label.ts +2 -1
  96. package/src/tool/types.ts +23 -1
@@ -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,98 @@
1
+ import type { JsEnvironments, JsInstallReceipt } from "./js-environments.ts";
2
+ import type { InstallReceipt } from "./py-environment.ts";
3
+ import { EnvironmentError } from "./py-installer.ts";
4
+ import type { PythonEnvironments } from "./python-environments.ts";
5
+
6
+ export interface PackagesInstallEnvironments {
7
+ readonly python?: PythonEnvironments;
8
+ readonly js?: JsEnvironments;
9
+ }
10
+
11
+ export const PACKAGES_INSTALL_DEFAULT_TIMEOUT_SECONDS = 600;
12
+
13
+ interface PackagesInstallRequest {
14
+ readonly manager: "pip" | "bun" | "npm";
15
+ readonly requirements: readonly string[];
16
+ readonly timeoutSeconds: number;
17
+ }
18
+
19
+ function invalid(message: string): EnvironmentError {
20
+ return new EnvironmentError("environment_install_failed", `packages.install(): ${message}`);
21
+ }
22
+
23
+ function parseRequest(args: unknown): PackagesInstallRequest {
24
+ if (typeof args !== "object" || args === null) throw invalid("expected (manager, requirements, {timeout?})");
25
+ const manager = "manager" in args ? args.manager : undefined;
26
+ if (manager !== "pip" && manager !== "bun" && manager !== "npm") {
27
+ throw invalid('manager must be "pip" (Python) or "bun" / "npm" (JavaScript)');
28
+ }
29
+ const raw = "requirements" in args ? args.requirements : undefined;
30
+ const list = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : undefined;
31
+ if (
32
+ list === undefined ||
33
+ list.length === 0 ||
34
+ !list.every((item) => typeof item === "string" && item.trim() !== "")
35
+ ) {
36
+ throw invalid("requirements must be a non-empty string or a non-empty list of non-empty strings");
37
+ }
38
+ const timeout = "timeout" in args ? args.timeout : undefined;
39
+ if (timeout !== undefined && (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0)) {
40
+ throw invalid("timeout must be a positive number of seconds");
41
+ }
42
+ return {
43
+ manager,
44
+ requirements: list.map((item: string) => item.trim()),
45
+ timeoutSeconds: timeout ?? PACKAGES_INSTALL_DEFAULT_TIMEOUT_SECONDS,
46
+ };
47
+ }
48
+
49
+ function jsArguments(requirements: readonly string[]): string {
50
+ for (const requirement of requirements) {
51
+ if (/\s/.test(requirement)) throw invalid(`a package spec cannot contain whitespace: ${requirement}`);
52
+ }
53
+ return requirements.join(" ");
54
+ }
55
+
56
+ /**
57
+ * `packages.install(manager, requirements, {timeout?})`: the in-cell form of `%pip install` / `%bun add` /
58
+ * `%npm add`. It drives the same session environment the magic cells use and returns its receipt; a stop of the
59
+ * owning cell cancels it (environment_install_cancelled), and the timeout fails it with environment_install_timeout.
60
+ * pip receives the requirement list as separate arguments, never re-split. The installer's output is not streamed
61
+ * into the cell: the receipt is the result, and a failure carries the installer's stderr tail.
62
+ */
63
+ export async function runPackagesInstall(
64
+ args: unknown,
65
+ environments: PackagesInstallEnvironments,
66
+ signal: AbortSignal | undefined,
67
+ ): Promise<InstallReceipt | JsInstallReceipt> {
68
+ const request = parseRequest(args);
69
+ const { python, js } = environments;
70
+ const manager = request.manager;
71
+ let install: ((signal: AbortSignal) => Promise<InstallReceipt | JsInstallReceipt>) | undefined;
72
+ if (manager === "pip") {
73
+ if (python !== undefined) install = (signal) => python.install(request.requirements, signal);
74
+ } else if (js !== undefined) {
75
+ install = (signal) => js.install(jsArguments(request.requirements), signal, undefined, manager);
76
+ }
77
+ if (install === undefined) {
78
+ throw new EnvironmentError(
79
+ "environment_installer_unavailable",
80
+ manager === "pip"
81
+ ? "pip installs need this session to have a Python interpreter"
82
+ : "bun/npm installs run only in a JavaScript cell",
83
+ );
84
+ }
85
+ const timeout = AbortSignal.timeout(request.timeoutSeconds * 1_000);
86
+ const combined = signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
87
+ try {
88
+ return await install(combined);
89
+ } catch (error) {
90
+ if (timeout.aborted && !(signal?.aborted ?? false)) {
91
+ throw new EnvironmentError(
92
+ "environment_install_timeout",
93
+ `the install did not finish within ${request.timeoutSeconds}s and was stopped`,
94
+ );
95
+ }
96
+ throw error;
97
+ }
98
+ }
@@ -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
+ }
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import { promisify } from "node:util";
5
5
  import { assertInstalledInRevision, assertNoEditableInstalls } from "./editable-check.ts";
6
- import { parsePipRequirements, runPipInstall } from "./py-installer.ts";
6
+ import { normalizePipInstall, parsePipRequirements, runPipInstall } from "./py-installer.ts";
7
7
  import { publishNextRevision, type Revision } from "./revision-store.ts";
8
8
 
9
9
  const execFileAsync = promisify(execFile);
@@ -40,12 +40,16 @@ export async function installPythonPackages(input: {
40
40
  readonly base: string;
41
41
  readonly mode: EnvironmentMode;
42
42
  readonly interpreter: string;
43
- readonly requirements: string;
43
+ /** `%pip` argument text, or the requirement list `packages.install()` passes without re-parsing. */
44
+ readonly requirements: string | readonly string[];
44
45
  readonly cwd: string;
45
46
  readonly signal: AbortSignal;
46
47
  readonly onOutput?: (stream: "stdout" | "stderr", data: string) => void;
47
48
  }): Promise<InstallReceipt> {
48
- const requested = parsePipRequirements(input.requirements);
49
+ const requested =
50
+ typeof input.requirements === "string"
51
+ ? parsePipRequirements(input.requirements)
52
+ : normalizePipInstall(["install", ...input.requirements]);
49
53
  let stdout = "";
50
54
  const { revision } = await publishNextRevision(
51
55
  input.base,
@@ -125,7 +125,11 @@ export function splitShellWords(text: string, posix = process.platform !== "win3
125
125
  }
126
126
 
127
127
  export function parsePipRequirements(text: string): string[] {
128
- const args = splitShellWords(text);
128
+ return normalizePipInstall(splitShellWords(text));
129
+ }
130
+
131
+ /** `%pip` words already split (`["install", ...]`): `packages.install()` passes its list as-is, so nothing is re-quoted. */
132
+ export function normalizePipInstall(args: readonly string[]): string[] {
129
133
  const command = args[0] === "install" ? args.slice(1) : undefined;
130
134
  if (command === undefined) {
131
135
  throw new EnvironmentError("environment_install_failed", "only `%pip install <requirements>` is supported");
@@ -47,7 +47,7 @@ export class PythonEnvironments {
47
47
  }
48
48
 
49
49
  async install(
50
- requirements: string,
50
+ requirements: string | readonly string[],
51
51
  signal: AbortSignal,
52
52
  onOutput?: (stream: "stdout" | "stderr", data: string) => void,
53
53
  ): Promise<InstallReceipt> {
@@ -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 mkdir(base, { recursive: true });
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, { recursive: true });
63
- else await cp(previous.dir, staging, { recursive: true, verbatimSymlinks: true });
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
+ }
@@ -19,7 +19,10 @@ export interface BridgeToolCallRequest {
19
19
  // and ordinary tool results are marshalled to { text, images, details, hasError } — the raw
20
20
  // { content } shape left python cells unable to reach tool.read image blocks.
21
21
  export async function routeBridgeToolCall(
22
- options: Pick<CreateCodemodeSessionManagerOptions, "executeTool" | "listTools" | "settings" | "handles">,
22
+ options: Pick<
23
+ CreateCodemodeSessionManagerOptions,
24
+ "executeTool" | "listTools" | "settings" | "handles" | "environments"
25
+ >,
23
26
  request: BridgeToolCallRequest,
24
27
  evalHandleHost?: EvalHandleHost,
25
28
  ): Promise<unknown> {
@@ -39,5 +42,6 @@ export async function routeBridgeToolCall(
39
42
  marshalToolResult,
40
43
  ...(options.handles === undefined ? {} : { handles: options.handles }),
41
44
  ...(evalHandleHost === undefined ? {} : { evalHandleHost }),
45
+ ...(options.environments === undefined ? {} : { environments: options.environments }),
42
46
  });
43
47
  }
@@ -1,4 +1,9 @@
1
1
  import type { ExtensionContext } from "@code-yeongyu/senpi";
2
+ import type { EvalImageContent } from "../tool/image-resize.ts";
3
+
4
+ /** A notification's message content: plain text, or text and the cells' images in one message. */
5
+ export type EvalNotificationContent = string | ({ readonly type: "text"; readonly text: string } | EvalImageContent)[];
6
+
2
7
  import type { EvalDetachedCellNotification, EvalDetachedCellNotifier } from "../tool/detached-cell-manager.ts";
3
8
 
4
9
  const NON_INTERACTIVE_MODES = new Set(["print", "json"]);
@@ -11,7 +16,7 @@ export type EvalNotifyMode = "wake" | "next-turn" | "off";
11
16
  export interface EvalNotifierDeps {
12
17
  /** Deliver a model-visible notification without rendering synthetic user input. */
13
18
  readonly sendMessage: (
14
- message: { customType: string; content: string; display: boolean },
19
+ message: { customType: string; content: EvalNotificationContent; display: boolean },
15
20
  options: { triggerTurn: boolean; deliverAs: "steer" | "followUp" },
16
21
  ) => void;
17
22
  readonly getContext: () => ExtensionContext | undefined;
@@ -43,10 +48,16 @@ export class EvalNotifier implements EvalDetachedCellNotifier {
43
48
  this.#deps.sendMessage(
44
49
  {
45
50
  customType: EVAL_NOTIFICATION_CUSTOM_TYPE,
46
- content: pending.map((cell) => cell.content).join("\n\n"),
51
+ content: notificationContent(pending),
47
52
  display: false,
48
53
  },
49
54
  { triggerTurn: true, deliverAs: mode === "wake" ? "steer" : "followUp" },
50
55
  );
51
56
  }
52
57
  }
58
+
59
+ function notificationContent(cells: readonly EvalDetachedCellNotification[]): EvalNotificationContent {
60
+ const text = cells.map((cell) => cell.content).join("\n\n");
61
+ const images = cells.flatMap((cell) => cell.images ?? []);
62
+ return images.length === 0 ? text : [{ type: "text", text }, ...images];
63
+ }
@@ -63,11 +63,16 @@ export class SessionKernelRegistrations {
63
63
  }
64
64
 
65
65
  export function javaScriptKernelMemory(kernel: JavaScriptKernel): RegisteredKernelSource {
66
+ const measure = kernel.mode === "process" ? "footprint" : "heap";
66
67
  return {
67
- measure: "heap",
68
+ measure,
68
69
  lastLiveBytes: () => kernel.lastLiveBytes,
69
70
  busy: () => kernel.queueSnapshot().activeCellId !== null,
70
- queryMemory: () => kernel.queryMemory(),
71
+ queryMemory: async () => {
72
+ const reading = await kernel.queryMemory();
73
+ return reading === undefined ? undefined : { liveBytes: reading.liveBytes, measure: reading.measure };
74
+ },
75
+ ...(measure === "footprint" ? { pid: () => kernel.processPid } : {}),
71
76
  };
72
77
  }
73
78
 
@@ -2,12 +2,15 @@ import { type ExtensionContext, withBundledBunCommands } from "@code-yeongyu/sen
2
2
  import type { AgentExecuteTool } from "../bridges/agent-bridge.ts";
3
3
  import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
4
4
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
5
+ import { resolveJsIsolation } from "../config/feature-settings.ts";
6
+ import { withoutUntrustedInterpreter } from "../config/project-trust.ts";
5
7
  import {
6
8
  type CodemodeSettings,
7
9
  loadCodemodeSettings,
8
10
  type ResolvedCodemodeSettings,
9
11
  resolveEnabledLanguages,
10
12
  } from "../config/settings.ts";
13
+ import { JsEnvironments } from "../environments/js-environments.ts";
11
14
  import { PythonEnvironments } from "../environments/python-environments.ts";
12
15
  import type { HandleRegistry } from "../handles/handle-registry.ts";
13
16
  import {
@@ -15,6 +18,7 @@ import {
15
18
  getInterpreterAvailability,
16
19
  type InterpreterAvailability,
17
20
  } from "../interpreters/detect.ts";
21
+ import { processRuntimeInfo } from "../kernels/js/process-worker.ts";
18
22
  import { sessionEnvironmentFrom } from "../kernels/session-env.ts";
19
23
  import { resolveSessionArtifactsDir } from "../output/streaming-output.ts";
20
24
  import type { EnabledEvalLanguages, EvalLanguage, EvalRuntimes } from "../tool/types.ts";
@@ -49,6 +53,7 @@ export type SessionRuntime = {
49
53
  readonly executeTool: AgentExecuteTool;
50
54
  readonly spawns: boolean;
51
55
  readonly pythonEnvironments?: PythonEnvironments;
56
+ readonly jsEnvironments: JsEnvironments;
52
57
  };
53
58
 
54
59
  export async function createRuntime(
@@ -60,12 +65,25 @@ export async function createRuntime(
60
65
  handles?: HandleRegistry,
61
66
  ): Promise<SessionRuntime> {
62
67
  const loaded = await loadCodemodeSettings({ cwd: ctx.cwd });
68
+ const trusted = withoutUntrustedInterpreter(
69
+ loaded.settings,
70
+ loaded.source,
71
+ ctx.cwd,
72
+ typeof ctx.isProjectTrusted === "function" ? () => ctx.isProjectTrusted() : undefined,
73
+ );
63
74
  const settings: ResolvedCodemodeSettings = {
64
- ...loaded.settings,
65
- languages: resolveEnabledLanguages(loaded.settings),
75
+ ...trusted.settings,
76
+ languages: resolveEnabledLanguages(trusted.settings),
66
77
  };
67
78
  const availability = await getInterpreterAvailability(settings, createInterpreterDetector());
79
+ const pyReason = availability.py.detected.ok ? undefined : availability.py.detected.reason;
80
+ reportSettingsProblems(ctx, [
81
+ ...loaded.warnings,
82
+ ...(trusted.warning === undefined ? [] : [trusted.warning]),
83
+ ...(pyReason === undefined ? [] : [pyReason]),
84
+ ]);
68
85
  const enabledLanguages = enabledLanguagesFrom(settings, availability);
86
+ const jsProcessIsolation = resolveJsIsolation(settings) === "process";
69
87
  const artifacts = resolveSessionArtifactsDir(ctx.sessionManager.getSessionFile());
70
88
  const activeTools = new Set(pi.getActiveTools());
71
89
  const executeTool = createExecuteTool(pi, activeTools);
@@ -74,6 +92,15 @@ export async function createRuntime(
74
92
  const sessionEnv = { ...sessionEnvironmentFrom(ctx), ...bundledBunPathEntry() };
75
93
  const configuredPoolWidth = settings.parallelPoolWidth;
76
94
  const parallelPoolWidth = Number.isFinite(configuredPoolWidth) ? Math.max(1, Math.trunc(configuredPoolWidth)) : 1;
95
+ const pythonEnvironments =
96
+ availability.py.detected.ok && enabledLanguages.py
97
+ ? new PythonEnvironments({
98
+ artifactsDir: artifacts.dir,
99
+ cwd: ctx.cwd,
100
+ interpreter: availability.py.detected.path,
101
+ settings,
102
+ })
103
+ : undefined;
77
104
  const manager = await create({
78
105
  sessionId,
79
106
  ownerSessionId: ctx.sessionManager.getSessionId(),
@@ -86,6 +113,7 @@ export async function createRuntime(
86
113
  listTools: () => pi.getAllTools(),
87
114
  complete,
88
115
  ...(handles === undefined ? {} : { handles }),
116
+ ...(pythonEnvironments === undefined ? {} : { environments: { python: pythonEnvironments } }),
89
117
  });
90
118
  return {
91
119
  sessionId,
@@ -93,24 +121,34 @@ export async function createRuntime(
93
121
  parallelPoolWidth,
94
122
  manager,
95
123
  enabledLanguages,
96
- runtimes: runtimesFromAvailability(availability, jsRuntimeInfo()),
124
+ runtimes: runtimesFromAvailability(
125
+ availability,
126
+ (jsProcessIsolation ? processRuntimeInfo(undefined) : undefined) ??
127
+ jsRuntimeInfo(process.versions, process.execPath, jsProcessIsolation),
128
+ ),
97
129
  settings,
98
130
  artifactsDir: artifacts.dir,
99
131
  executeTool,
100
132
  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
- : {}),
133
+ jsEnvironments: new JsEnvironments({
134
+ artifactsDir: artifacts.dir,
135
+ cwd: ctx.cwd,
136
+ runtime: jsRuntimeInfo().name,
137
+ env: { ...process.env, ...sessionEnv },
138
+ settings,
139
+ }),
140
+ ...(pythonEnvironments === undefined ? {} : { pythonEnvironments }),
111
141
  };
112
142
  }
113
143
 
144
+ /** Settings problems reach the user: a notice with a UI, otherwise stderr (print and RPC runs). */
145
+ function reportSettingsProblems(ctx: ExtensionContext, problems: readonly string[]): void {
146
+ for (const problem of problems) {
147
+ if (ctx.hasUI) ctx.ui.notify(`[senpi-codemode] ${problem}`, "warning");
148
+ else console.error(`[senpi-codemode] ${problem}`);
149
+ }
150
+ }
151
+
114
152
  export function createExecuteTool(pi: CodemodeRuntimeAPI, activeTools?: ReadonlySet<string>): AgentExecuteTool {
115
153
  // A cell names tools directly and cannot run tool_search first, so eval opts in to
116
154
  // lazy activation. Eligibility still belongs to the extension that registered the tool.
@@ -10,10 +10,14 @@ export interface JsRuntimeVersions {
10
10
  export function jsRuntimeInfo(
11
11
  versions: JsRuntimeVersions = process.versions,
12
12
  execPath: string = process.execPath,
13
+ processIsolation = false,
13
14
  ): EvalRuntimeInfo {
14
15
  const bun = versions.bun;
15
- if (bun !== undefined && bun.length > 0) return { name: "bun", version: bun, path: execPath };
16
- return { name: "node", version: versions.node, path: execPath };
16
+ const base =
17
+ bun !== undefined && bun.length > 0
18
+ ? { name: "bun", version: bun, path: execPath }
19
+ : { name: "node", version: versions.node, path: execPath };
20
+ return processIsolation ? { ...base, isolation: "process" } : base;
17
21
  }
18
22
 
19
23
  /** Short host-line segment, e.g. "node 26.7.0" or "bun 1.4.0". */
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@code-yeongyu/senpi";
2
2
  import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
3
3
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
4
4
  import type { CodemodeSettings } from "../config/settings.ts";
5
+ import type { PackagesInstallEnvironments } from "../environments/packages-install.ts";
5
6
  import type { HandleRegistry } from "../handles/handle-registry.ts";
6
7
  import type { InterpreterAvailability } from "../interpreters/detect.ts";
7
8
  import type { SessionEnvironment } from "../kernels/session-env.ts";
@@ -42,4 +43,6 @@ export interface CreateCodemodeSessionManagerOptions {
42
43
  readonly complete: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
43
44
  /** The session generation's handle registry; subprocess kernels reach `wait()`/`handle()` through the bridge. */
44
45
  readonly handles?: HandleRegistry;
46
+ /** The Python environment `packages.install("pip", ...)` reaches from a Python cell over the bridge. */
47
+ readonly environments?: PackagesInstallEnvironments;
45
48
  }