@code-yeongyu/senpi-codemode 2026.10.10 → 2026.10.12

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 (110) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/README.md +113 -11
  3. package/package.json +7 -6
  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/packages-install.ts +98 -0
  15. package/src/environments/py-environment.ts +7 -3
  16. package/src/environments/py-installer.ts +80 -44
  17. package/src/environments/python-environments.ts +1 -1
  18. package/src/extension/bridge-tool-call.ts +5 -1
  19. package/src/extension/eval-cells-rpc.ts +62 -0
  20. package/src/extension/eval-notifier.ts +13 -2
  21. package/src/extension/kernel-registration.ts +7 -2
  22. package/src/extension/runtime-factory.ts +42 -13
  23. package/src/extension/runtime-info.ts +6 -2
  24. package/src/extension/session-cwd.ts +25 -9
  25. package/src/extension/session-manager-contract.ts +3 -0
  26. package/src/extension/session-manager.ts +20 -6
  27. package/src/index.ts +8 -3
  28. package/src/interpreters/detect.ts +34 -1
  29. package/src/kernels/AGENTS.md +2 -0
  30. package/src/kernels/jl/globals.jl +128 -0
  31. package/src/kernels/jl/prelude.jl +15 -2
  32. package/src/kernels/jl/runner.jl +35 -143
  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 +23 -24
  36. package/src/kernels/js/control-frames.ts +24 -0
  37. package/src/kernels/js/group-signal-notice.js +117 -0
  38. package/src/kernels/js/inline-worker.ts +2 -0
  39. package/src/kernels/js/kernel-contract.ts +16 -1
  40. package/src/kernels/js/kernel-memory-bridge.ts +1 -1
  41. package/src/kernels/js/kernel-memory.ts +79 -0
  42. package/src/kernels/js/kernel-tools-registry.js +1 -0
  43. package/src/kernels/js/local-module-loader.ts +8 -0
  44. package/src/kernels/js/process-drain-scan.d.ts +8 -0
  45. package/src/kernels/js/process-drain-scan.js +28 -0
  46. package/src/kernels/js/process-entry.js +466 -0
  47. package/src/kernels/js/process-memory-host.ts +83 -0
  48. package/src/kernels/js/process-tree-host.ts +4 -8
  49. package/src/kernels/js/process-tree.d.ts +11 -0
  50. package/src/kernels/js/process-tree.js +55 -10
  51. package/src/kernels/js/process-worker.ts +298 -0
  52. package/src/kernels/js/rejection-reports.js +61 -0
  53. package/src/kernels/js/run-queue.ts +7 -0
  54. package/src/kernels/js/windows-tree-kill-host.ts +117 -0
  55. package/src/kernels/js/windows-tree-kill.d.ts +19 -0
  56. package/src/kernels/js/windows-tree-kill.js +100 -0
  57. package/src/kernels/js/worker-core.js +51 -8
  58. package/src/kernels/js/worker-cwd.js +46 -12
  59. package/src/kernels/js/worker-host.ts +9 -2
  60. package/src/kernels/js/worker-indirect-eval.js +37 -13
  61. package/src/kernels/js/worker-require.d.ts +19 -0
  62. package/src/kernels/js/worker-require.js +52 -0
  63. package/src/kernels/js/worker-runtime.js +66 -13
  64. package/src/kernels/js/worker-shadow-guard.js +2 -6
  65. package/src/kernels/js/worker-shell-capture.js +9 -0
  66. package/src/kernels/js/worker-slot.ts +8 -0
  67. package/src/kernels/js/worker-startup.ts +49 -1
  68. package/src/kernels/js/worker-webview.js +15 -1
  69. package/src/kernels/py/prelude.py +69 -27
  70. package/src/kernels/py/process.ts +4 -0
  71. package/src/kernels/py/transport.ts +4 -0
  72. package/src/kernels/rb/runner.rb +9 -19
  73. package/src/kernels/sandbox/sandbox-cell.ts +26 -1
  74. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
  75. package/src/kernels/shared/bun-ffi.ts +24 -0
  76. package/src/kernels/shared/kernel-memory-host.ts +30 -7
  77. package/src/kernels/shared/process-cpu.ts +7 -3
  78. package/src/kernels/shared/process-group-cpu.ts +6 -3
  79. package/src/kernels/shared/subprocess-kernel.ts +29 -3
  80. package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
  81. package/src/kernels/shared/subprocess-process.ts +24 -2
  82. package/src/kernels/shared/subprocess-queue.ts +1 -0
  83. package/src/prompt/eval-prompt.ts +8 -1
  84. package/src/tool/AGENTS.md +1 -0
  85. package/src/tool/cell-handler.ts +3 -0
  86. package/src/tool/cell-reserved-dispatch.ts +1 -0
  87. package/src/tool/cell-runtime.ts +4 -2
  88. package/src/tool/detached-cell-contract.ts +3 -0
  89. package/src/tool/detached-cell-manager.ts +20 -11
  90. package/src/tool/detached-cell-notification.ts +11 -63
  91. package/src/tool/detached-cell-snapshot.ts +17 -0
  92. package/src/tool/detached-eval-result.ts +21 -10
  93. package/src/tool/detached-notification-queue.ts +1 -2
  94. package/src/tool/eval-tool.ts +4 -2
  95. package/src/tool/interrupt-note.ts +4 -5
  96. package/src/tool/live-headline.ts +60 -0
  97. package/src/tool/managed-cell.ts +0 -4
  98. package/src/tool/render-agent.ts +110 -0
  99. package/src/tool/render-blocks.ts +252 -0
  100. package/src/tool/render-call.ts +130 -0
  101. package/src/tool/render-cell.ts +165 -0
  102. package/src/tool/render-detail.ts +152 -0
  103. package/src/tool/render-live.ts +288 -0
  104. package/src/tool/render-result.ts +202 -0
  105. package/src/tool/render-status.ts +166 -0
  106. package/src/tool/render-tail.ts +139 -0
  107. package/src/tool/render.ts +3 -1069
  108. package/src/tool/run-eval-cell.ts +24 -14
  109. package/src/tool/runtime-label.ts +2 -1
  110. package/src/tool/types.ts +13 -2
@@ -1,3 +1,4 @@
1
+ import { environmentsEntry, isolationEntry, type NamedEvalSchema } from "./eval-environment-schemas.ts";
1
2
  import type { EvalSchemaResult } from "./schema-bridge.ts";
2
3
 
3
4
  /**
@@ -22,7 +23,7 @@ const handleRefSchema = {
22
23
  required: ["kind", "id", "run_epoch"],
23
24
  } as const;
24
25
 
25
- const waitEntry: EvalSchemaResult = {
26
+ const waitEntry: NamedEvalSchema = {
26
27
  name: "eval:wait",
27
28
  description: [
28
29
  "wait(handles, {timeout?, mode?}) blocks the cell until the given handles settle (js: await wait(...); py/rb: wait(...); jl: wait(handle(node)) or wait([handle(a), handle(b)])).",
@@ -44,7 +45,7 @@ const waitEntry: EvalSchemaResult = {
44
45
  },
45
46
  };
46
47
 
47
- const helpersEntry: EvalSchemaResult = {
48
+ const helpersEntry: NamedEvalSchema = {
48
49
  name: "eval:helpers",
49
50
  description: [
50
51
  "Advanced cell helpers (all four languages; js helpers are awaited).",
@@ -70,7 +71,7 @@ const helpersEntry: EvalSchemaResult = {
70
71
  },
71
72
  };
72
73
 
73
- const kernelToolsEntry: EvalSchemaResult = {
74
+ const kernelToolsEntry: NamedEvalSchema = {
74
75
  name: "eval:kernel-tools",
75
76
  description: [
76
77
  "Kernel tools: functions a cell defines that in-process children can call (js and py; rb/jl answer tools_unavailable).",
@@ -90,12 +91,18 @@ const kernelToolsEntry: EvalSchemaResult = {
90
91
  },
91
92
  };
92
93
 
93
- const VIRTUAL_ENTRIES: ReadonlyMap<string, EvalSchemaResult> = new Map([
94
+ const VIRTUAL_ENTRIES: ReadonlyMap<string, EvalSchemaResult> = new Map<string, EvalSchemaResult>([
94
95
  [waitEntry.name, waitEntry],
95
96
  [helpersEntry.name, helpersEntry],
96
97
  [kernelToolsEntry.name, kernelToolsEntry],
98
+ [environmentsEntry.name, environmentsEntry],
99
+ [isolationEntry.name, isolationEntry],
97
100
  ]);
98
101
 
99
102
  export function virtualEvalSchema(name: string): EvalSchemaResult | undefined {
100
103
  return VIRTUAL_ENTRIES.get(name);
101
104
  }
105
+
106
+ export function virtualEvalSchemaNames(): readonly string[] {
107
+ return [...VIRTUAL_ENTRIES.keys()];
108
+ }
@@ -1,5 +1,11 @@
1
1
  import type { AgentToolResult, EvalHandleHost } from "@code-yeongyu/senpi";
2
- import { RESERVED_AGENT_TOOL, RESERVED_OUTPUT_TOOL, RESERVED_SCHEMA_TOOL } from "../bridge/reserved.ts";
2
+ import {
3
+ RESERVED_AGENT_TOOL,
4
+ RESERVED_OUTPUT_TOOL,
5
+ RESERVED_PACKAGES_INSTALL_TOOL,
6
+ RESERVED_SCHEMA_TOOL,
7
+ } from "../bridge/reserved.ts";
8
+ import { type PackagesInstallEnvironments, runPackagesInstall } from "../environments/packages-install.ts";
3
9
  import type { HandleRegistry } from "../handles/handle-registry.ts";
4
10
  import type { EvalStatusEvent, ExecuteTool } from "../tool/types.ts";
5
11
  import { type AgentExecuteTool, runEvalAgent } from "./agent-bridge.ts";
@@ -21,6 +27,8 @@ export interface ReservedDispatchContext {
21
27
  readonly handles?: HandleRegistry;
22
28
  /** `ctx.evalHandleHost` at dispatch time; agent/workpool refs need it, completion refs do not. */
23
29
  readonly evalHandleHost?: EvalHandleHost;
30
+ /** The calling cell's session environments; `packages.install()` fails with environment_installer_unavailable without one. */
31
+ readonly environments?: PackagesInstallEnvironments;
24
32
  }
25
33
 
26
34
  class SchemaUnavailableError extends Error {
@@ -36,6 +44,7 @@ export function isReservedToolName(toolName: string): boolean {
36
44
  toolName === RESERVED_AGENT_TOOL ||
37
45
  toolName === RESERVED_OUTPUT_TOOL ||
38
46
  toolName === RESERVED_SCHEMA_TOOL ||
47
+ toolName === RESERVED_PACKAGES_INSTALL_TOOL ||
39
48
  isHandleToolName(toolName)
40
49
  );
41
50
  }
@@ -66,6 +75,9 @@ export async function runReservedTool(toolName: string, context: ReservedDispatc
66
75
  marshalToolResult: context.marshalToolResult,
67
76
  });
68
77
  }
78
+ if (toolName === RESERVED_PACKAGES_INSTALL_TOOL) {
79
+ return await runPackagesInstall(context.args, context.environments ?? {}, context.signal);
80
+ }
69
81
  if (toolName === RESERVED_SCHEMA_TOOL) {
70
82
  const listTools = context.listTools;
71
83
  if (listTools === undefined) throw new SchemaUnavailableError();
@@ -0,0 +1,5 @@
1
+ {
2
+ "projectExecutableSettings": [
3
+ "languages.pyInterpreter"
4
+ ]
5
+ }
@@ -0,0 +1,51 @@
1
+ import { join } from "node:path";
2
+ import executableSettings from "./executable-settings.json" with { type: "json" };
3
+ import type { CodemodeSettings } from "./settings.ts";
4
+
5
+ /**
6
+ * Settings that name an executable run at session start (an interpreter's `--version` probe, then the kernel). The
7
+ * one list of them lives in `executable-settings.json`, which senpi's project-trust check also reads: a project
8
+ * codemode file that sets any of them asks for trust. Without trust they are dropped with a warning and never run.
9
+ * Trust is asked only when the project's own file sets one, so a host that cannot answer still starts sessions;
10
+ * such a host counts as not trusting the project.
11
+ */
12
+ export const PROJECT_EXECUTABLE_SETTINGS: readonly string[] = executableSettings.projectExecutableSettings;
13
+
14
+ export function withoutUntrustedInterpreter<Settings extends CodemodeSettings>(
15
+ settings: Settings,
16
+ source: string | null,
17
+ cwd: string,
18
+ isProjectTrusted: (() => boolean) | undefined,
19
+ ): { readonly settings: Settings; readonly warning?: string } {
20
+ const projectFile = join(cwd, ".senpi", "codemode.json");
21
+ if (source !== projectFile) return { settings };
22
+ const named = PROJECT_EXECUTABLE_SETTINGS.filter((path) => valueAt(settings, path) !== undefined);
23
+ if (named.length === 0 || isProjectTrusted?.() === true) return { settings };
24
+ let trimmed: Settings = settings;
25
+ for (const path of named) trimmed = withoutPath(trimmed, path);
26
+ const list = named.map((path) => `${path} "${String(valueAt(settings, path))}"`).join(", ");
27
+ return {
28
+ settings: trimmed,
29
+ warning: `${list} in ${projectFile} is ignored because this project is not trusted; it was not run`,
30
+ };
31
+ }
32
+
33
+ function valueAt(value: unknown, path: string): unknown {
34
+ let current: unknown = value;
35
+ for (const key of path.split(".")) {
36
+ if (typeof current !== "object" || current === null) return undefined;
37
+ current = Reflect.get(current, key);
38
+ }
39
+ return current;
40
+ }
41
+
42
+ function withoutPath<T>(value: T, path: string): T {
43
+ const copy = structuredClone(value);
44
+ const keys = path.split(".");
45
+ const last = keys.pop();
46
+ let parent: unknown = copy;
47
+ for (const key of keys)
48
+ parent = typeof parent === "object" && parent !== null ? Reflect.get(parent, key) : undefined;
49
+ if (last !== undefined && typeof parent === "object" && parent !== null) Reflect.deleteProperty(parent, last);
50
+ return copy;
51
+ }
@@ -20,6 +20,8 @@ export function resolveEnabledLanguages(
20
20
  env: Environment = process.env,
21
21
  ): CodemodeSettings["languages"] {
22
22
  return {
23
+ // Keep the non-flag keys (languages.pyInterpreter); only the four enable flags take env overrides.
24
+ ...settings.languages,
23
25
  py: resolveLanguage(settings.languages.py, env[languageEnvironmentFlags.py]),
24
26
  js: resolveLanguage(settings.languages.js, env[languageEnvironmentFlags.js]),
25
27
  rb: resolveLanguage(settings.languages.rb, env[languageEnvironmentFlags.rb]),
@@ -60,7 +60,7 @@ async function refuseOutsideLinks(root: string, dir: string): Promise<void> {
60
60
  export async function assertInstalledInRevision(stdout: string, staging: string): Promise<void> {
61
61
  const line = stdout.split("\n").find((entry) => entry.startsWith("Successfully installed "));
62
62
  if (line === undefined) return;
63
- const present = new Set((await readdir(staging).catch(() => [] as string[])).flatMap(installedName));
63
+ const present = new Set((await readdir(staging).catch((): string[] => [])).flatMap(installedName));
64
64
  for (const token of line.slice("Successfully installed ".length).trim().split(/\s+/u)) {
65
65
  const name = token.slice(0, token.lastIndexOf("-"));
66
66
  if (!present.has(canonicalName(name))) {
@@ -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
+ }
@@ -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,
@@ -1,5 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { mkdtemp, rm } from "node:fs/promises";
2
3
  import { devNull } from "node:os";
4
+ import { join } from "node:path";
3
5
  import { terminateProcessTrees } from "../kernels/js/process-tree-host.ts";
4
6
 
5
7
  export type EnvironmentErrorCode =
@@ -125,7 +127,11 @@ export function splitShellWords(text: string, posix = process.platform !== "win3
125
127
  }
126
128
 
127
129
  export function parsePipRequirements(text: string): string[] {
128
- const args = splitShellWords(text);
130
+ return normalizePipInstall(splitShellWords(text));
131
+ }
132
+
133
+ /** `%pip` words already split (`["install", ...]`): `packages.install()` passes its list as-is, so nothing is re-quoted. */
134
+ export function normalizePipInstall(args: readonly string[]): string[] {
129
135
  const command = args[0] === "install" ? args.slice(1) : undefined;
130
136
  if (command === undefined) {
131
137
  throw new EnvironmentError("environment_install_failed", "only `%pip install <requirements>` is supported");
@@ -176,7 +182,7 @@ export function isolatedPipEnv(): NodeJS.ProcessEnv {
176
182
  return { ...env, PIP_CONFIG_FILE: process.platform === "win32" ? "nul" : devNull, PYTHONNOUSERSITE: "1" };
177
183
  }
178
184
 
179
- export function runPipInstall(input: {
185
+ export async function runPipInstall(input: {
180
186
  readonly interpreter: string;
181
187
  readonly root: string;
182
188
  readonly args: readonly string[];
@@ -196,52 +202,82 @@ export function runPipInstall(input: {
196
202
  input.root,
197
203
  ...input.args,
198
204
  ];
199
- return new Promise((resolve, reject) => {
200
- if (input.signal.aborted) {
201
- reject(new EnvironmentError("environment_install_cancelled", "the install was cancelled before it started"));
202
- return;
203
- }
204
- const child = spawn(input.interpreter, argv, {
205
- cwd: input.cwd,
206
- stdio: ["ignore", "pipe", "pipe"],
207
- env: isolatedPipEnv(),
208
- });
209
- let stderrTail = "";
210
- child.stdout.setEncoding("utf8").on("data", (data: string) => input.onOutput?.("stdout", data));
211
- child.stderr.setEncoding("utf8").on("data", (data: string) => {
212
- stderrTail = (stderrTail + data).slice(-STDERR_TAIL_BYTES);
213
- input.onOutput?.("stderr", data);
214
- });
215
- // pip's build backends run in their own subprocesses; stopping only pip would leave them writing.
216
- const onAbort = () => {
217
- const pid = child.pid;
218
- if (pid === undefined) child.kill("SIGKILL");
219
- else void terminateProcessTrees([pid], { graceMs: PIP_TREE_GRACE_MS }).catch(() => child.kill("SIGKILL"));
220
- };
221
- input.signal.addEventListener("abort", onAbort, { once: true });
222
- child.once("error", (error) => {
223
- input.signal.removeEventListener("abort", onAbort);
224
- reject(
225
- new EnvironmentError("environment_installer_unavailable", `${input.interpreter} -m pip: ${error.message}`),
226
- );
227
- });
228
- child.once("close", (code, signal) => {
229
- input.signal.removeEventListener("abort", onAbort);
205
+ if (input.signal.aborted) {
206
+ throw new EnvironmentError("environment_install_cancelled", "the install was cancelled before it started");
207
+ }
208
+ const scratch = await mkdtemp(join(input.root, ".pip-"));
209
+ try {
210
+ await new Promise<void>((resolve, reject) => {
230
211
  if (input.signal.aborted) {
231
- reject(new EnvironmentError("environment_install_cancelled", "the install was cancelled; pip was stopped"));
232
- } else if (code === 0) resolve();
233
- else if (/No module named pip/.test(stderrTail)) {
234
- reject(new EnvironmentError("environment_installer_unavailable", `${input.interpreter} has no pip module`));
235
- } else if (/ResolutionImpossible|conflicting dependencies/.test(stderrTail)) {
236
- reject(new EnvironmentError("environment_resolution_conflict", stderrTail.trim()));
237
- } else {
212
+ reject(
213
+ new EnvironmentError("environment_install_cancelled", "the install was cancelled before it started"),
214
+ );
215
+ return;
216
+ }
217
+ const child = spawn(input.interpreter, argv, {
218
+ cwd: input.cwd,
219
+ stdio: ["ignore", "pipe", "pipe"],
220
+ env: { ...isolatedPipEnv(), TMPDIR: scratch, TEMP: scratch, TMP: scratch },
221
+ });
222
+ let termination: Promise<void> | undefined;
223
+ let stderrTail = "";
224
+ child.stdout.setEncoding("utf8").on("data", (data: string) => input.onOutput?.("stdout", data));
225
+ child.stderr.setEncoding("utf8").on("data", (data: string) => {
226
+ stderrTail = (stderrTail + data).slice(-STDERR_TAIL_BYTES);
227
+ input.onOutput?.("stderr", data);
228
+ });
229
+ // pip's build backends run in their own subprocesses; stopping only pip would leave them writing.
230
+ const onAbort = () => {
231
+ const pid = child.pid;
232
+ if (pid === undefined) child.kill("SIGKILL");
233
+ else
234
+ termination = terminateProcessTrees([pid], { graceMs: PIP_TREE_GRACE_MS }).catch(() => {
235
+ child.kill("SIGKILL");
236
+ });
237
+ };
238
+ input.signal.addEventListener("abort", onAbort, { once: true });
239
+ child.once("error", (error) => {
240
+ input.signal.removeEventListener("abort", onAbort);
238
241
  reject(
239
242
  new EnvironmentError(
240
- "environment_install_failed",
241
- stderrTail.trim() || `pip exited with ${code ?? signal}`,
243
+ "environment_installer_unavailable",
244
+ `${input.interpreter} -m pip: ${error.message}`,
242
245
  ),
243
246
  );
244
- }
247
+ });
248
+ child.once("close", async (code, signal) => {
249
+ input.signal.removeEventListener("abort", onAbort);
250
+ await termination;
251
+ if (input.signal.aborted) {
252
+ reject(
253
+ new EnvironmentError("environment_install_cancelled", "the install was cancelled; pip was stopped"),
254
+ );
255
+ } else if (code === 0) resolve();
256
+ else if (/No module named pip/.test(stderrTail)) {
257
+ reject(
258
+ new EnvironmentError("environment_installer_unavailable", `${input.interpreter} has no pip module`),
259
+ );
260
+ } else if (/ResolutionImpossible|conflicting dependencies/.test(stderrTail)) {
261
+ reject(new EnvironmentError("environment_resolution_conflict", stderrTail.trim()));
262
+ } else {
263
+ reject(
264
+ new EnvironmentError(
265
+ "environment_install_failed",
266
+ stderrTail.trim() || `pip exited with ${code ?? signal}`,
267
+ ),
268
+ );
269
+ }
270
+ });
245
271
  });
246
- });
272
+ } finally {
273
+ // A killed pip/build backend cannot clean Python's tempfile directories itself.
274
+ try {
275
+ await rm(scratch, { recursive: true, force: true, maxRetries: 3 });
276
+ } catch (cause) {
277
+ // Keep the install's success or typed failure; cleanup is a separate diagnostic.
278
+ process.emitWarning(`pip scratch cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
279
+ code: "SENPI_PIP_SCRATCH_CLEANUP",
280
+ });
281
+ }
282
+ }
247
283
  }
@@ -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> {
@@ -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
  }
@@ -0,0 +1,62 @@
1
+ import { EvalCellNotFoundError, type EvalDetachedCellManager } from "../tool/detached-cell-manager.ts";
2
+ import { listEvalCells } from "../tool/detached-eval-result.ts";
3
+
4
+ const DEFAULT_TAIL_BYTES = 8 * 1024;
5
+ const MAX_TAIL_BYTES = 64 * 1024;
6
+
7
+ class InvalidEvalCellRequestError extends Error {
8
+ readonly code = "invalid_eval_cell_request";
9
+
10
+ constructor(message: string) {
11
+ super(message);
12
+ this.name = "InvalidEvalCellRequestError";
13
+ }
14
+ }
15
+
16
+ /** Extension RPC handlers never invoke a model tool or write a session entry. */
17
+ export function registerEvalCellsRpc(
18
+ rpc: { handle?(name: string, handler: (data: unknown) => unknown | Promise<unknown>): void } | undefined,
19
+ getCells: () => EvalDetachedCellManager | undefined,
20
+ ): void {
21
+ rpc?.handle?.("eval_cells.list", () => {
22
+ const cells = getCells();
23
+ return cells === undefined ? [] : listEvalCells(cells);
24
+ });
25
+ for (const action of ["peek", "stop"] as const) {
26
+ rpc?.handle?.(`eval_cells.${action}`, async (data) => {
27
+ if (
28
+ typeof data !== "object" ||
29
+ data === null ||
30
+ !("cellId" in data) ||
31
+ typeof data.cellId !== "string" ||
32
+ data.cellId.trim().length === 0
33
+ ) {
34
+ throw new InvalidEvalCellRequestError("cellId must be a non-empty string");
35
+ }
36
+ const tailBytes = action === "peek" && "tailBytes" in data ? data.tailBytes : DEFAULT_TAIL_BYTES;
37
+ if (typeof tailBytes !== "number" || !Number.isSafeInteger(tailBytes) || tailBytes < 0) {
38
+ throw new InvalidEvalCellRequestError("tailBytes must be a non-negative safe integer");
39
+ }
40
+ const cells = getCells();
41
+ if (cells === undefined) throw new EvalCellNotFoundError(data.cellId);
42
+ const snapshot = action === "stop" ? await cells.stop(data.cellId) : cells.peek(data.cellId);
43
+ const bytes = Buffer.from(snapshot.outputTail, "utf8");
44
+ let start = Math.max(0, bytes.length - Math.min(tailBytes, MAX_TAIL_BYTES));
45
+ // Drop a partial leading UTF-8 character instead of expanding it into a replacement character.
46
+ while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
47
+ return {
48
+ cellId: snapshot.cellId,
49
+ language: snapshot.language,
50
+ state: snapshot.state,
51
+ startedAtMs: snapshot.startedAtMs,
52
+ ...(snapshot.result.details.summary ? { summary: snapshot.result.details.summary } : {}),
53
+ ...(snapshot.queuedBehind === undefined ? {} : { queuedBehind: snapshot.queuedBehind }),
54
+ outputTail: bytes.subarray(start).toString("utf8"),
55
+ ...(snapshot.stateRetained === undefined ? {} : { stateRetained: snapshot.stateRetained }),
56
+ ...(snapshot.interruptNote === undefined ? {} : { interruptNote: snapshot.interruptNote }),
57
+ ...(snapshot.hardLimitSeconds === undefined ? {} : { hardLimitSeconds: snapshot.hardLimitSeconds }),
58
+ ...(snapshot.runBudgetSeconds === undefined ? {} : { runBudgetSeconds: snapshot.runBudgetSeconds }),
59
+ };
60
+ });
61
+ }
62
+ }
@@ -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