@code-yeongyu/senpi-codemode 2026.10.9 → 2026.10.10-11

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 (127) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +161 -11
  3. package/package.json +6 -5
  4. package/src/bridge/memory-protocol.ts +8 -1
  5. package/src/bridge/protocol.ts +9 -4
  6. package/src/bridge/reserved.ts +2 -0
  7. package/src/bridges/eval-environment-schemas.ts +52 -0
  8. package/src/bridges/eval-virtual-schemas.ts +11 -4
  9. package/src/bridges/reserved-dispatch.ts +13 -1
  10. package/src/config/executable-settings.json +5 -0
  11. package/src/config/project-trust.ts +51 -0
  12. package/src/config/settings-overrides.ts +2 -0
  13. package/src/environments/editable-check.ts +1 -1
  14. package/src/environments/js-environments.ts +208 -0
  15. package/src/environments/js-installer.ts +268 -0
  16. package/src/environments/js-revision-files.ts +91 -0
  17. package/src/environments/no-links.ts +132 -0
  18. package/src/environments/packages-install.ts +98 -0
  19. package/src/environments/project-resolves.ts +54 -0
  20. package/src/environments/py-environment.ts +7 -3
  21. package/src/environments/py-installer.ts +5 -1
  22. package/src/environments/python-environments.ts +1 -1
  23. package/src/environments/revision-store.ts +20 -4
  24. package/src/extension/bridge-tool-call.ts +5 -1
  25. package/src/extension/eval-notifier.ts +13 -2
  26. package/src/extension/kernel-registration.ts +7 -2
  27. package/src/extension/runtime-factory.ts +51 -13
  28. package/src/extension/runtime-info.ts +6 -2
  29. package/src/extension/session-cwd.ts +25 -9
  30. package/src/extension/session-manager-contract.ts +3 -0
  31. package/src/extension/session-manager.ts +20 -6
  32. package/src/index.ts +3 -2
  33. package/src/interpreters/detect.ts +34 -1
  34. package/src/kernels/AGENTS.md +2 -0
  35. package/src/kernels/jl/runner.jl +11 -22
  36. package/src/kernels/js/cell-dispatch.ts +44 -0
  37. package/src/kernels/js/cell-ownership.js +268 -0
  38. package/src/kernels/js/cell-run-context.js +89 -0
  39. package/src/kernels/js/context-manager.ts +46 -51
  40. package/src/kernels/js/control-frames.ts +24 -0
  41. package/src/kernels/js/group-signal-notice.js +117 -0
  42. package/src/kernels/js/host-entries.ts +62 -0
  43. package/src/kernels/js/inline-worker.ts +2 -0
  44. package/src/kernels/js/kernel-contract.ts +16 -1
  45. package/src/kernels/js/kernel-memory-bridge.ts +1 -1
  46. package/src/kernels/js/kernel-memory.ts +79 -0
  47. package/src/kernels/js/kernel-tools-registry.js +1 -0
  48. package/src/kernels/js/local-module-loader.ts +25 -6
  49. package/src/kernels/js/process-drain-scan.d.ts +8 -0
  50. package/src/kernels/js/process-drain-scan.js +28 -0
  51. package/src/kernels/js/process-entry.js +466 -0
  52. package/src/kernels/js/process-memory-host.ts +83 -0
  53. package/src/kernels/js/process-tree-host.ts +4 -8
  54. package/src/kernels/js/process-tree.d.ts +11 -0
  55. package/src/kernels/js/process-tree.js +55 -10
  56. package/src/kernels/js/process-worker.ts +298 -0
  57. package/src/kernels/js/rejection-reports.js +61 -0
  58. package/src/kernels/js/run-queue.ts +7 -0
  59. package/src/kernels/js/windows-tree-kill-host.ts +117 -0
  60. package/src/kernels/js/windows-tree-kill.d.ts +19 -0
  61. package/src/kernels/js/windows-tree-kill.js +100 -0
  62. package/src/kernels/js/worker-core.js +53 -8
  63. package/src/kernels/js/worker-cwd.js +46 -12
  64. package/src/kernels/js/worker-host.ts +9 -2
  65. package/src/kernels/js/worker-indirect-eval.js +37 -13
  66. package/src/kernels/js/worker-package-resolve.js +153 -0
  67. package/src/kernels/js/worker-require.d.ts +19 -0
  68. package/src/kernels/js/worker-require.js +52 -0
  69. package/src/kernels/js/worker-runtime.js +66 -13
  70. package/src/kernels/js/worker-shadow-guard.js +2 -6
  71. package/src/kernels/js/worker-shell-capture.js +9 -0
  72. package/src/kernels/js/worker-slot.ts +8 -0
  73. package/src/kernels/js/worker-startup.ts +49 -1
  74. package/src/kernels/js/worker-webview.js +15 -1
  75. package/src/kernels/py/host-cell.ts +8 -31
  76. package/src/kernels/py/prelude.py +69 -27
  77. package/src/kernels/py/process.ts +4 -0
  78. package/src/kernels/py/transport.ts +4 -0
  79. package/src/kernels/rb/runner.rb +9 -19
  80. package/src/kernels/sandbox/sandbox-cell.ts +141 -0
  81. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  82. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
  83. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  84. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  85. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  86. package/src/kernels/shared/bun-ffi.ts +24 -0
  87. package/src/kernels/shared/host-cell.ts +57 -0
  88. package/src/kernels/shared/kernel-memory-host.ts +30 -7
  89. package/src/kernels/shared/process-cpu.ts +7 -3
  90. package/src/kernels/shared/process-group-cpu.ts +6 -3
  91. package/src/kernels/shared/subprocess-kernel.ts +29 -3
  92. package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
  93. package/src/kernels/shared/subprocess-process.ts +24 -2
  94. package/src/kernels/shared/subprocess-queue.ts +1 -0
  95. package/src/prompt/eval-prompt.ts +8 -1
  96. package/src/tool/AGENTS.md +1 -0
  97. package/src/tool/cell-handler.ts +12 -11
  98. package/src/tool/cell-reserved-dispatch.ts +1 -0
  99. package/src/tool/cell-runtime.ts +4 -2
  100. package/src/tool/detached-cell-contract.ts +3 -0
  101. package/src/tool/detached-cell-manager.ts +4 -8
  102. package/src/tool/detached-cell-notification.ts +11 -63
  103. package/src/tool/detached-cell-snapshot.ts +17 -0
  104. package/src/tool/detached-eval-result.ts +13 -7
  105. package/src/tool/detached-notification-queue.ts +1 -2
  106. package/src/tool/eval-request.ts +23 -1
  107. package/src/tool/eval-tool-options.ts +3 -0
  108. package/src/tool/eval-tool.ts +19 -5
  109. package/src/tool/interrupt-note.ts +4 -5
  110. package/src/tool/live-headline.ts +60 -0
  111. package/src/tool/load-cell.ts +18 -3
  112. package/src/tool/magic-cell-host.ts +60 -2
  113. package/src/tool/magic-cells.ts +12 -2
  114. package/src/tool/managed-cell.ts +0 -4
  115. package/src/tool/render-agent.ts +110 -0
  116. package/src/tool/render-blocks.ts +252 -0
  117. package/src/tool/render-call.ts +130 -0
  118. package/src/tool/render-cell.ts +165 -0
  119. package/src/tool/render-detail.ts +152 -0
  120. package/src/tool/render-live.ts +288 -0
  121. package/src/tool/render-result.ts +202 -0
  122. package/src/tool/render-status.ts +166 -0
  123. package/src/tool/render-tail.ts +139 -0
  124. package/src/tool/render.ts +3 -1069
  125. package/src/tool/run-eval-cell.ts +53 -16
  126. package/src/tool/runtime-label.ts +2 -1
  127. package/src/tool/types.ts +34 -3
@@ -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". */
@@ -1,3 +1,4 @@
1
+ import { statSync } from "node:fs";
1
2
  import { stat } from "node:fs/promises";
2
3
 
3
4
  // Only these codes mean the directory is gone or was never one; anything else (EACCES, ELOOP,
@@ -21,15 +22,30 @@ export async function assertSessionCwdAvailable(cwd: string): Promise<void> {
21
22
  try {
22
23
  isDirectory = (await stat(cwd)).isDirectory();
23
24
  } catch (error) {
24
- if (
25
- error instanceof Error &&
26
- "code" in error &&
27
- typeof error.code === "string" &&
28
- MISSING_DIRECTORY_CODES.has(error.code)
29
- ) {
30
- throw new CodemodeSessionCwdUnavailableError(cwd, error.code);
31
- }
32
- throw error;
25
+ throw sessionCwdStatError(cwd, error);
33
26
  }
34
27
  if (!isDirectory) throw new CodemodeSessionCwdUnavailableError(cwd, "not a directory");
35
28
  }
29
+
30
+ /** Warm reuse checks every cell without a filesystem thread-pool round trip. */
31
+ export function assertSessionCwdAvailableSync(cwd: string): void {
32
+ let isDirectory: boolean;
33
+ try {
34
+ isDirectory = statSync(cwd).isDirectory();
35
+ } catch (error) {
36
+ throw sessionCwdStatError(cwd, error);
37
+ }
38
+ if (!isDirectory) throw new CodemodeSessionCwdUnavailableError(cwd, "not a directory");
39
+ }
40
+
41
+ function sessionCwdStatError(cwd: string, error: unknown): unknown {
42
+ if (
43
+ error instanceof Error &&
44
+ "code" in error &&
45
+ typeof error.code === "string" &&
46
+ MISSING_DIRECTORY_CODES.has(error.code)
47
+ ) {
48
+ return new CodemodeSessionCwdUnavailableError(cwd, error.code);
49
+ }
50
+ return error;
51
+ }
@@ -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
  }
@@ -1,12 +1,15 @@
1
1
  import { join } from "node:path";
2
2
  import type { ExtensionContext } from "@code-yeongyu/senpi";
3
+ import { readProcessFootprint } from "@code-yeongyu/senpi";
3
4
  import { type BridgeServerHandle, startBridgeServer } from "../bridge/http-server.ts";
4
5
  import type { KernelToHostMessage } from "../bridge/protocol.ts";
5
6
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
7
+ import { resolveJsIsolation, resolveKernelToolsEnabled } from "../config/feature-settings.ts";
6
8
  import { resolveKernelMemoryThresholds } from "../config/memory-settings.ts";
7
9
  import { collectOrphanedChildren } from "../host-sdk.ts";
8
10
  import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
9
11
  import type { KernelToolsCapability, KernelToolsDescribeResult } from "../kernels/js/kernel-tools-types.ts";
12
+ import type { SessionEnvironment } from "../kernels/session-env.ts";
10
13
  import type { KernelLifecycle } from "../kernels/shared/kernel-death.ts";
11
14
  import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
12
15
  import { type BridgeDispatchContext, dispatchBridgeCompletion } from "./bridge-dispatch.ts";
@@ -21,7 +24,7 @@ import {
21
24
  } from "./kernel-registration.ts";
22
25
  import type { RegisteredKernelSource } from "./kernel-registry.ts";
23
26
  import { ReplaceableKernel } from "./kernel-replacement.ts";
24
- import { assertSessionCwdAvailable } from "./session-cwd.ts";
27
+ import { assertSessionCwdAvailable, assertSessionCwdAvailableSync } from "./session-cwd.ts";
25
28
  import type {
26
29
  BridgeEndpoint,
27
30
  CodemodeSessionManager,
@@ -111,7 +114,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
111
114
  this.#onMessageRefs.set(language, onMessage);
112
115
  const existing = this.#kernels.get(language);
113
116
  if (existing) {
114
- await assertSessionCwdAvailable(this.#options.cwd);
117
+ assertSessionCwdAvailableSync(this.#options.cwd);
115
118
  return existing;
116
119
  }
117
120
  const pending = this.#kernelCreations.get(language);
@@ -252,6 +255,8 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
252
255
  this.#options.localRoots ??
253
256
  (this.#options.artifactsDir ? { local: join(this.#options.artifactsDir, "local") } : undefined);
254
257
  if (language === "js") {
258
+ const memoryThresholds = resolveKernelMemoryThresholds(this.#options.settings.memory);
259
+ const processIsolation = resolveJsIsolation(this.#options.settings) === "process";
255
260
  const kernel = new JavaScriptKernel({
256
261
  sessionId: this.#options.sessionId,
257
262
  cwd: this.#options.cwd,
@@ -259,8 +264,13 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
259
264
  onMessage,
260
265
  hostToolNames: () => this.#options.listTools?.().map((tool) => tool.name) ?? [],
261
266
  foreignLanguageNames: () => this.#foreignKernelToolNames(),
262
- memory: resolveKernelMemoryThresholds(this.#options.settings.memory),
267
+ memory: memoryThresholds,
263
268
  collectOrphanedChildren,
269
+ ...(processIsolation ? { isolation: "process" as const } : {}),
270
+ ...(processIsolation && memoryThresholds !== undefined
271
+ ? { processMemory: { thresholds: memoryThresholds, readFootprint: readProcessFootprint } }
272
+ : {}),
273
+ ...(resolveKernelToolsEnabled(this.#options.settings) ? {} : { kernelToolsEnabled: false }),
264
274
  ...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
265
275
  ...(localRoots ? { localRoots: { ...localRoots } } : {}),
266
276
  ...(this.#options.artifactsDir ? { artifactsDir: this.#options.artifactsDir } : {}),
@@ -276,10 +286,14 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
276
286
  ...(localRoots ? { localRoots: { ...localRoots } } : {}),
277
287
  ...(this.#options.artifactsDir ? { artifactsDir: this.#options.artifactsDir } : {}),
278
288
  };
289
+ // The Python prelude reads this when a cell defines an @tool; only a disabled setting adds it.
290
+ const sessionEnv: SessionEnvironment | undefined = resolveKernelToolsEnabled(this.#options.settings)
291
+ ? this.#options.sessionEnv
292
+ : { ...this.#options.sessionEnv, SENPI_CODEMODE_KERNEL_TOOLS: "0" };
279
293
  const shared = {
280
294
  sessionId: this.#options.sessionId,
281
295
  cwd: this.#options.cwd,
282
- ...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
296
+ ...(sessionEnv ? { sessionEnv } : {}),
283
297
  connection,
284
298
  onMessage,
285
299
  ...lifecycle,
@@ -297,8 +311,8 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
297
311
  /** Only an already running JavaScript kernel can define a colliding name; none is started for this. */
298
312
  #javaScriptKernelToolsDescribe(names: readonly string[]): Promise<KernelToolsDescribeResult> | undefined {
299
313
  const js = this.#kernels.get("js");
300
- if (!js || !("describeKernelTools" in js) || typeof js.describeKernelTools !== "function") return undefined;
301
- return (js.describeKernelTools as (names: readonly string[]) => Promise<KernelToolsDescribeResult>)(names);
314
+ if (!js || typeof js.describeKernelTools !== "function") return undefined;
315
+ return js.describeKernelTools(names);
302
316
  }
303
317
 
304
318
  #foreignKernelToolNames(): string[] {
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  resolveMaxDetachedCells,
11
11
  resolveRunBudgetSeconds,
12
12
  } from "./config/settings.ts";
13
- import { EvalNotifier } from "./extension/eval-notifier.ts";
13
+ import { type EvalNotificationContent, EvalNotifier } from "./extension/eval-notifier.ts";
14
14
  import { EVAL_CELLS_STATUS_KEY } from "./extension/eval-status.ts";
15
15
  import { EvalStatusTicker } from "./extension/eval-status-ticker.ts";
16
16
  import { hostLine, modelIdFrom } from "./extension/host-facts.ts";
@@ -57,7 +57,7 @@ export interface CodemodeExtensionAPI {
57
57
  getActiveTools(): string[];
58
58
  getAllTools(): readonly EvalSchemaToolInfo[];
59
59
  sendMessage(
60
- message: { customType: string; content: string; display: boolean },
60
+ message: { customType: string; content: EvalNotificationContent; display: boolean },
61
61
  options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
62
62
  ): void;
63
63
  /** Optional host event bus; a host without one turns extension event emission into a harmless no-op. */
@@ -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,
@@ -20,6 +20,8 @@ export interface InterpreterDetected {
20
20
 
21
21
  export interface InterpreterUnavailable {
22
22
  readonly ok: false;
23
+ /** Why a configured interpreter could not be used; unset when detection simply found none. */
24
+ readonly reason?: string;
23
25
  }
24
26
 
25
27
  export type InterpreterDetection = InterpreterDetected | InterpreterUnavailable;
@@ -43,6 +45,8 @@ export interface CreateInterpreterDetectorOptions {
43
45
 
44
46
  export interface InterpreterDetector {
45
47
  detect(language: CodemodeLanguage): Promise<InterpreterDetection>;
48
+ /** Probes exactly this executable (no PATH candidates, no splitting on spaces). */
49
+ detectExplicit(path: string): Promise<InterpreterDetection>;
46
50
  }
47
51
 
48
52
  export interface LanguageAvailability {
@@ -74,6 +78,9 @@ export function createInterpreterDetector(options: CreateInterpreterDetectorOpti
74
78
  cache.set(language, pending);
75
79
  return pending;
76
80
  },
81
+ detectExplicit(path) {
82
+ return probeExplicit(path, probe);
83
+ },
77
84
  };
78
85
  }
79
86
 
@@ -82,7 +89,7 @@ export async function getInterpreterAvailability(
82
89
  detector: InterpreterDetector,
83
90
  ): Promise<InterpreterAvailability> {
84
91
  return {
85
- py: await availabilityFor("py", settings.languages.py, detector),
92
+ py: await pythonAvailability(settings, detector),
86
93
  js: await availabilityFor("js", settings.languages.js, detector),
87
94
  rb: await availabilityFor("rb", settings.languages.rb, detector),
88
95
  jl: await availabilityFor("jl", settings.languages.jl, detector),
@@ -100,6 +107,32 @@ async function availabilityFor(
100
107
  };
101
108
  }
102
109
 
110
+ /** `languages.pyInterpreter`, when set, is the only interpreter Python uses; a path that does not answer makes Python unavailable. */
111
+ async function pythonAvailability(
112
+ settings: CodemodeSettings,
113
+ detector: InterpreterDetector,
114
+ ): Promise<LanguageAvailability> {
115
+ const configured = settings.languages.pyInterpreter;
116
+ if (configured === undefined || !settings.languages.py)
117
+ return await availabilityFor("py", settings.languages.py, detector);
118
+ return { enabled: true, detected: await detector.detectExplicit(configured) };
119
+ }
120
+
121
+ async function probeExplicit(path: string, probe: ExecFileProbe): Promise<InterpreterDetection> {
122
+ const reason = (detail: string): InterpreterUnavailable => ({
123
+ ok: false,
124
+ reason: `languages.pyInterpreter "${path}" ${detail}; Python is unavailable in this session`,
125
+ });
126
+ try {
127
+ const result = await probe(path, ["--version"], { timeoutMs: probeTimeoutMs });
128
+ const version = parseVersion(`${result.stdout}\n${result.stderr}`);
129
+ if (version === null) return reason("did not report a Python version");
130
+ return { ok: true, path, version, resolvedPath: path };
131
+ } catch (error) {
132
+ return reason(`could not run (${error instanceof Error ? error.message.split("\n")[0] : String(error)})`);
133
+ }
134
+ }
135
+
103
136
  async function detectUncached(
104
137
  language: CodemodeLanguage,
105
138
  hostPlatform: NodeJS.Platform,
@@ -16,6 +16,8 @@ runner/prelude assets).
16
16
  | Julia kernel | `jl/kernel.ts` + `jl/prelude.jl`, `jl/runner.jl` |
17
17
  | Shared subprocess layer | `shared/subprocess-kernel.ts`, `subprocess-{contract,process,queue,run}.ts`, `runtime-asset.ts` |
18
18
  | Session environment | `session-env.ts` (PI_* contract shared by all kernels; mirrors the core bash tool) |
19
+ | JS process isolation | `js/process-worker.ts` (host side), `js/process-entry.js` (child entry), `js/kernel-memory.ts`, `js/control-frames.ts` |
20
+ | Sandbox cells | `sandbox/sandbox-cell.ts` (host executor); `sandbox/vendor/pi-codemode/**` (vendored runtime, see its `VENDORED.md`) |
19
21
 
20
22
  ## CONVENTIONS
21
23
 
@@ -16,6 +16,7 @@ include("prelude.jl")
16
16
  const senpi_connection = Dict{String, Any}()
17
17
  const senpi_write_lock = ReentrantLock()
18
18
  global senpi_current_cell = nothing
19
+ global senpi_memory_cell = nothing
19
20
 
20
21
  function senpi_escape(text::AbstractString)
21
22
  out = IOBuffer()
@@ -290,9 +291,7 @@ function senpi_set_connection(value)
290
291
  end
291
292
  end
292
293
 
293
- const senpi_memory = Ref{Any}(nothing)
294
-
295
- const SENPI_MEMORY_INTERNALS = Set([:senpi_current_cell, :senpi_memory, :senpi_connection, :senpi_frame_io, :senpi_stdout_capture, :senpi_stderr_capture, :senpi_protocol_stdin])
294
+ const SENPI_MEMORY_INTERNALS = Set([:senpi_current_cell, :senpi_connection, :senpi_frame_io, :senpi_stdout_capture, :senpi_stderr_capture, :senpi_protocol_stdin])
296
295
 
297
296
  const SENPI_SIZER_SAMPLE = 1_000
298
297
  const SENPI_SIZER_NODE_BUDGET = 5_000
@@ -421,21 +420,6 @@ function senpi_largest_globals(limit::Int)
421
420
  end
422
421
  end
423
422
 
424
- function senpi_memory_report()
425
- try
426
- senpi_memory[] === nothing && return nothing
427
- # The host reads the interpreter's current footprint and decides on the notice; the runner only
428
- # names its largest globals, with a bounded walk, so a cell never pays for an unbounded scan.
429
- named = senpi_largest_globals(5)
430
- # The host replaces liveBytes with the interpreter footprint it reads itself; only globals is used.
431
- report = Dict{String, Any}("liveBytes" => 0, "measure" => "footprint")
432
- isempty(named) || (report["globals"] = named)
433
- report
434
- catch
435
- nothing
436
- end
437
- end
438
-
439
423
  function senpi_run_cell(message)
440
424
  cell_id = string(get(message, "cellId", ""))
441
425
  code = string(get(message, "code", ""))
@@ -452,13 +436,12 @@ function senpi_run_cell(message)
452
436
  yield()
453
437
  frame = Dict{String, Any}("type" => "result", "cellId" => cell_id, "ok" => true, "durationMs" => round(Int, (time() - started) * 1000))
454
438
  value !== nothing && senpi_should_display_result(parsed) && (frame["valueRepr"] = senpi_json(value))
455
- memory = senpi_memory_report()
456
- memory !== nothing && (frame["memory"] = memory)
457
439
  senpi_emit(frame)
458
440
  catch error
459
441
  senpi_emit(Dict("type" => "result", "cellId" => cell_id, "ok" => false, "error" => senpi_error(error), "durationMs" => round(Int, (time() - started) * 1000)))
460
442
  finally
461
443
  global senpi_current_cell = nothing
444
+ global senpi_memory_cell = cell_id
462
445
  end
463
446
  end
464
447
 
@@ -472,10 +455,16 @@ while !eof(SENPI_ORIGINAL_STDIN)
472
455
  if kind == "init"
473
456
  senpi_emit(Dict("type" => "status", "event" => Dict("op" => "kernel-startup", "stage" => "host-init")))
474
457
  senpi_set_connection(get(message, "connection", nothing))
475
- senpi_memory[] = get(message, "memory", nothing)
476
- senpi_emit(Dict("type" => "ready"))
458
+ senpi_emit(Dict("type" => "ready", "memoryGlobals" => true))
477
459
  elseif kind == "run"
460
+ global senpi_memory_cell = nothing
478
461
  senpi_run_cell(message)
462
+ elseif kind == "memory-globals"
463
+ cell_id = get(message, "cellId", nothing)
464
+ if cell_id == senpi_memory_cell && senpi_current_cell === nothing
465
+ senpi_emit(Dict("type" => "memory-globals-result", "cellId" => cell_id, "globals" => senpi_largest_globals(5)))
466
+ global senpi_memory_cell = nothing
467
+ end
479
468
  elseif kind == "close"
480
469
  senpi_emit(Dict("type" => "closed"))
481
470
  break