@code-yeongyu/senpi-codemode 2026.10.8 → 2026.10.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +100 -4
  3. package/package.json +4 -4
  4. package/src/bridge/http-server.ts +9 -1
  5. package/src/bridge/protocol.ts +4 -0
  6. package/src/bridges/eval-virtual-schemas.ts +1 -0
  7. package/src/environments/js-environments.ts +208 -0
  8. package/src/environments/js-installer.ts +268 -0
  9. package/src/environments/js-revision-files.ts +91 -0
  10. package/src/environments/no-links.ts +132 -0
  11. package/src/environments/project-resolves.ts +54 -0
  12. package/src/environments/py-installer.ts +64 -4
  13. package/src/environments/python-environments.ts +85 -0
  14. package/src/environments/revision-store.ts +38 -7
  15. package/src/extension/bridge-tool-call.ts +2 -0
  16. package/src/extension/cell-kernel-tools.ts +28 -0
  17. package/src/extension/kernel-replacement.ts +20 -0
  18. package/src/extension/runtime-factory.ts +21 -0
  19. package/src/extension/session-manager-proxy.ts +6 -0
  20. package/src/extension/session-manager.ts +10 -2
  21. package/src/index.ts +2 -0
  22. package/src/kernels/jl/prelude.jl +6 -1
  23. package/src/kernels/js/cell-dispatch.ts +44 -0
  24. package/src/kernels/js/context-manager.ts +25 -15
  25. package/src/kernels/js/host-entries.ts +62 -0
  26. package/src/kernels/js/local-module-loader.ts +21 -5
  27. package/src/kernels/js/worker-core.js +2 -0
  28. package/src/kernels/js/worker-package-resolve.js +153 -0
  29. package/src/kernels/js/worker-runtime.js +3 -1
  30. package/src/kernels/js/workpool.js +5 -2
  31. package/src/kernels/py/host-cell.ts +8 -31
  32. package/src/kernels/py/kernel.ts +8 -2
  33. package/src/kernels/py/prelude.py +112 -9
  34. package/src/kernels/py/transport.ts +13 -1
  35. package/src/kernels/rb/workpool.rb +7 -1
  36. package/src/kernels/sandbox/sandbox-cell.ts +116 -0
  37. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
  38. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
  39. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
  40. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
  41. package/src/kernels/shared/cell-source-at-start.ts +18 -0
  42. package/src/kernels/shared/host-cell.ts +57 -0
  43. package/src/tool/cell-handler.ts +10 -12
  44. package/src/tool/eval-request.ts +23 -1
  45. package/src/tool/eval-tool-options.ts +6 -0
  46. package/src/tool/eval-tool.ts +16 -4
  47. package/src/tool/load-cell.ts +88 -0
  48. package/src/tool/magic-cell-host.ts +123 -0
  49. package/src/tool/magic-cells.ts +75 -0
  50. package/src/tool/run-eval-cell.ts +57 -0
  51. package/src/tool/types.ts +48 -1
@@ -0,0 +1,85 @@
1
+ import type { ResolvedCodemodeSettings } from "../config/settings.ts";
2
+ import {
3
+ type EnvironmentMode,
4
+ type InstallReceipt,
5
+ installPythonPackages,
6
+ managedEnvironmentBase,
7
+ projectPythonBase,
8
+ pythonAbiTag,
9
+ } from "./py-environment.ts";
10
+ import { EnvironmentError } from "./py-installer.ts";
11
+ import { readActiveRevisionSync } from "./revision-store.ts";
12
+
13
+ export interface PythonEnvironmentsOptions {
14
+ readonly artifactsDir: string;
15
+ readonly cwd: string;
16
+ readonly interpreter: string;
17
+ readonly settings: Pick<ResolvedCodemodeSettings, "environments">;
18
+ }
19
+
20
+ export class PythonEnvironments {
21
+ readonly #options: PythonEnvironmentsOptions;
22
+ #mode: EnvironmentMode = "managed";
23
+ #currentBase: string | undefined;
24
+ #managedBase: Promise<string> | undefined;
25
+
26
+ constructor(options: PythonEnvironmentsOptions) {
27
+ this.#options = options;
28
+ }
29
+
30
+ get mode(): EnvironmentMode {
31
+ return this.#mode;
32
+ }
33
+
34
+ /**
35
+ * The revision a Python cell imports from, read from the current mode's root each time a cell starts, so an
36
+ * install by another session sharing the project root is seen. Undefined while nothing is installed there.
37
+ */
38
+ get activeRoot(): string | undefined {
39
+ return this.#currentBase === undefined ? undefined : readActiveRevisionSync(this.#currentBase)?.dir;
40
+ }
41
+
42
+ async setMode(mode: EnvironmentMode): Promise<string> {
43
+ const base = await this.#base(mode);
44
+ this.#mode = mode;
45
+ this.#currentBase = base;
46
+ return base;
47
+ }
48
+
49
+ async install(
50
+ requirements: string,
51
+ signal: AbortSignal,
52
+ onOutput?: (stream: "stdout" | "stderr", data: string) => void,
53
+ ): Promise<InstallReceipt> {
54
+ if (this.#options.settings.environments?.autoProvision === false) {
55
+ throw new EnvironmentError(
56
+ "environment_installer_unavailable",
57
+ "installs are turned off for this project (environments.autoProvision is false)",
58
+ );
59
+ }
60
+ const mode = this.#mode;
61
+ const base = await this.#base(mode);
62
+ const receipt = await installPythonPackages({
63
+ base,
64
+ mode,
65
+ interpreter: this.#options.interpreter,
66
+ requirements,
67
+ cwd: this.#options.cwd,
68
+ signal,
69
+ ...(onOutput === undefined ? {} : { onOutput }),
70
+ });
71
+ if (this.#mode === mode) this.#currentBase = base;
72
+ return receipt;
73
+ }
74
+
75
+ #base(mode: EnvironmentMode): Promise<string> {
76
+ if (mode === "project") return Promise.resolve(projectPythonBase(this.#options.cwd));
77
+ const configured = this.#options.settings.environments?.managedRoot;
78
+ this.#managedBase ??= pythonAbiTag(this.#options.interpreter).then((abi) =>
79
+ configured === undefined
80
+ ? managedEnvironmentBase(this.#options.artifactsDir, "py", abi)
81
+ : managedEnvironmentBase(configured, "py", abi),
82
+ );
83
+ return this.#managedBase;
84
+ }
85
+ }
@@ -1,7 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { readFileSync } from "node:fs";
3
+ import { chmod, cp, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { type LockWaitNotice, withRootLock } from "./install-lock.ts";
6
+ import { assertLinkStaysInside, assertNoLinksBelow, recordedInstallSources } from "./no-links.ts";
5
7
  import { EnvironmentError } from "./py-installer.ts";
6
8
 
7
9
  const POINTER = "active";
@@ -12,11 +14,25 @@ export interface Revision {
12
14
  readonly dir: string;
13
15
  }
14
16
 
17
+ /** The same read, synchronous: the pointer is one short file, read when a Python cell starts running. */
18
+ export function readActiveRevisionSync(base: string): Revision | undefined {
19
+ try {
20
+ return parsePointer(base, readFileSync(join(base, POINTER), "utf8"));
21
+ } catch (error) {
22
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
23
+ throw error;
24
+ }
25
+ }
26
+
27
+ function parsePointer(base: string, text: string): Revision | undefined {
28
+ const name = text.trim();
29
+ const match = REVISION.exec(name);
30
+ return match?.[1] === undefined ? undefined : { number: Number(match[1]), dir: join(base, name) };
31
+ }
32
+
15
33
  export async function readActiveRevision(base: string): Promise<Revision | undefined> {
16
34
  try {
17
- const name = (await readFile(join(base, POINTER), "utf8")).trim();
18
- const match = REVISION.exec(name);
19
- return match?.[1] === undefined ? undefined : { number: Number(match[1]), dir: join(base, name) };
35
+ return parsePointer(base, await readFile(join(base, POINTER), "utf8"));
20
36
  } catch (error) {
21
37
  if (error instanceof Error && "code" in error && error.code === "ENOENT") return undefined;
22
38
  throw error;
@@ -35,7 +51,7 @@ export async function publishNextRevision(
35
51
  signal?: AbortSignal,
36
52
  onLockWait?: (notice: LockWaitNotice) => void,
37
53
  ): Promise<{ readonly revision: Revision; readonly previous: Revision | undefined }> {
38
- await mkdir(base, { recursive: true });
54
+ await mkdirPrivate(base);
39
55
  return withRootLock(
40
56
  base,
41
57
  async () => {
@@ -44,8 +60,18 @@ export async function publishNextRevision(
44
60
  const staging = join(base, `.staging-rev-${number}-${process.pid}-${randomUUID()}`);
45
61
  const dir = join(base, `rev-${number}`);
46
62
  try {
47
- if (previous === undefined) await mkdir(staging, { recursive: true });
48
- 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);
49
75
  await build(staging, previous);
50
76
  signal?.throwIfAborted();
51
77
  await renameRetrying(staging, dir);
@@ -93,3 +119,8 @@ async function highestRevision(base: string): Promise<number> {
93
119
  }
94
120
  return highest;
95
121
  }
122
+
123
+ /** Creates `dir` and any missing parents private to the user (0700; the umask can only narrow it further). */
124
+ export async function mkdirPrivate(dir: string): Promise<void> {
125
+ await mkdir(dir, { recursive: true, mode: 0o700 });
126
+ }
@@ -8,6 +8,8 @@ export interface BridgeToolCallRequest {
8
8
  readonly toolName: string;
9
9
  readonly args: unknown;
10
10
  readonly callId: string;
11
+ /** The calling run's secret; absent for a call made outside any run (a plain thread), which gets no kernel tools. */
12
+ readonly cellToken?: string;
11
13
  readonly signal: AbortSignal;
12
14
  }
13
15
 
@@ -0,0 +1,28 @@
1
+ import { kernelToolsStorage } from "@code-yeongyu/senpi";
2
+ import type { KernelToolsCapability } from "../kernels/js/kernel-tools-types.ts";
3
+
4
+ /**
5
+ * Kernel-tools capabilities of the subprocess-kernel cells running right now, keyed by a secret minted per run and
6
+ * sent only to the kernel running that cell. Those kernels (py/rb/jl) reach the host over the bridge, outside the
7
+ * submitting cell's async context, so a bridge call carries its run's secret and runs inside that run's capability:
8
+ * the same `kernelToolsStorage` scope the JS worker path enters per host call (#1754).
9
+ */
10
+ export class CellKernelTools {
11
+ readonly #byToken = new Map<string, KernelToolsCapability>();
12
+
13
+ /** Makes `capability` the kernel tools of calls carrying `token` until the returned release runs. */
14
+ bind(token: string, capability: KernelToolsCapability): () => void {
15
+ this.#byToken.set(token, capability);
16
+ return () => {
17
+ if (this.#byToken.get(token) === capability) this.#byToken.delete(token);
18
+ };
19
+ }
20
+
21
+ /** Runs `call` with the capability bound to `token`; an absent, unknown or released token gets none. */
22
+ async run<T>(token: string | undefined, call: () => Promise<T>): Promise<T> {
23
+ const capability = token === undefined ? undefined : this.#byToken.get(token);
24
+ return capability === undefined
25
+ ? await kernelToolsStorage.exit(call)
26
+ : await kernelToolsStorage.run(capability, call);
27
+ }
28
+ }
@@ -13,6 +13,7 @@ import type {
13
13
  KernelInterruptHandle,
14
14
  PendingCell,
15
15
  } from "../tool/types.ts";
16
+ import { hasKernelTools, type KernelToolsMethods } from "./kernel-tools-probe.ts";
16
17
 
17
18
  export type StartKernel = (lifecycle: Required<KernelLifecycle>) => Promise<EvalKernel>;
18
19
 
@@ -50,10 +51,29 @@ export class ReplaceableKernel implements EvalKernel {
50
51
  #closed = false;
51
52
  #closeFailure: unknown;
52
53
 
54
+ // Present only when the instance has kernel tools, so a probe sees what it would see on the instance itself.
55
+ declare readonly describeKernelTools?: KernelToolsMethods["describeKernelTools"];
56
+ declare readonly invokeKernelTool?: KernelToolsMethods["invokeKernelTool"];
57
+
53
58
  private constructor(language: EvalLanguage, start: StartKernel, first: EvalKernel) {
54
59
  this.#language = language;
55
60
  this.#start = start;
56
61
  this.#kernel = first;
62
+ // Python is the one subprocess kernel with kernel tools; rb and jl only carry tools_unavailable stubs to hide.
63
+ if (language === "py" && hasKernelTools(first)) {
64
+ // Always the CURRENT instance: after a replacement the old definitions are gone with the old interpreter.
65
+ const tools: KernelToolsMethods = {
66
+ describeKernelTools: (names) => this.#currentTools().describeKernelTools(names),
67
+ invokeKernelTool: (request, options) => this.#currentTools().invokeKernelTool(request, options),
68
+ };
69
+ Object.assign(this, tools);
70
+ }
71
+ }
72
+
73
+ #currentTools(): KernelToolsMethods {
74
+ const current = this.#kernel;
75
+ if (!hasKernelTools(current)) throw new Error(`The ${this.#language} kernel has no kernel tools`);
76
+ return current;
57
77
  }
58
78
 
59
79
  /** Starts the first instance; one that cannot hand back its queue is returned as it is. */
@@ -8,6 +8,8 @@ import {
8
8
  type ResolvedCodemodeSettings,
9
9
  resolveEnabledLanguages,
10
10
  } from "../config/settings.ts";
11
+ import { JsEnvironments } from "../environments/js-environments.ts";
12
+ import { PythonEnvironments } from "../environments/python-environments.ts";
11
13
  import type { HandleRegistry } from "../handles/handle-registry.ts";
12
14
  import {
13
15
  createInterpreterDetector,
@@ -47,6 +49,8 @@ export type SessionRuntime = {
47
49
  readonly artifactsDir: string;
48
50
  readonly executeTool: AgentExecuteTool;
49
51
  readonly spawns: boolean;
52
+ readonly pythonEnvironments?: PythonEnvironments;
53
+ readonly jsEnvironments: JsEnvironments;
50
54
  };
51
55
 
52
56
  export async function createRuntime(
@@ -96,6 +100,23 @@ export async function createRuntime(
96
100
  artifactsDir: artifacts.dir,
97
101
  executeTool,
98
102
  spawns: activeTools.has(settings.taskTools.task),
103
+ jsEnvironments: new JsEnvironments({
104
+ artifactsDir: artifacts.dir,
105
+ cwd: ctx.cwd,
106
+ runtime: jsRuntimeInfo().name,
107
+ env: { ...process.env, ...sessionEnv },
108
+ settings,
109
+ }),
110
+ ...(availability.py.detected.ok && enabledLanguages.py
111
+ ? {
112
+ pythonEnvironments: new PythonEnvironments({
113
+ artifactsDir: artifacts.dir,
114
+ cwd: ctx.cwd,
115
+ interpreter: availability.py.detected.path,
116
+ settings,
117
+ }),
118
+ }
119
+ : {}),
99
120
  };
100
121
  }
101
122
 
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext } from "@code-yeongyu/senpi";
2
2
  import type { KernelToHostMessage } from "../bridge/protocol.ts";
3
3
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
4
+ import type { KernelToolsCapability } from "../kernels/js/kernel-tools-types.ts";
4
5
  import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
5
6
  import {
6
7
  CodemodeSessionDisposedError,
@@ -106,6 +107,11 @@ export class SessionManagerProxy implements CodemodeSessionManager, EvalExecutio
106
107
  this.#current?.releaseKernelListener?.(language, onMessage);
107
108
  }
108
109
 
110
+ bindCellKernelTools(token: string, capability: KernelToolsCapability): () => void {
111
+ // Bound to the generation that runs the cell: a later generation never serves that cell's host calls.
112
+ return this.#current?.bindCellKernelTools?.(token, capability) ?? (() => undefined);
113
+ }
114
+
109
115
  async complete(request: CompletionRequest, ctx: ExtensionContext): Promise<CompletionResult> {
110
116
  this.assertEvalExecutionAllowed();
111
117
  const current = this.#current;
@@ -6,11 +6,12 @@ import type { CompletionRequest, CompletionResult } from "../completion/handler.
6
6
  import { resolveKernelMemoryThresholds } from "../config/memory-settings.ts";
7
7
  import { collectOrphanedChildren } from "../host-sdk.ts";
8
8
  import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
9
- import type { KernelToolsDescribeResult } from "../kernels/js/kernel-tools-types.ts";
9
+ import type { KernelToolsCapability, KernelToolsDescribeResult } from "../kernels/js/kernel-tools-types.ts";
10
10
  import type { KernelLifecycle } from "../kernels/shared/kernel-death.ts";
11
11
  import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
12
12
  import { type BridgeDispatchContext, dispatchBridgeCompletion } from "./bridge-dispatch.ts";
13
13
  import { type BridgeToolCallRequest, routeBridgeToolCall } from "./bridge-tool-call.ts";
14
+ import { CellKernelTools } from "./cell-kernel-tools.ts";
14
15
  import { parkWhenIdle } from "./idle-parking-kernel.ts";
15
16
  import {
16
17
  javaScriptKernelMemory,
@@ -65,6 +66,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
65
66
  readonly #registrations: SessionKernelRegistrations;
66
67
  #kernelCreations = new Map<EvalLanguage, Promise<EvalKernel>>();
67
68
  #onMessageRefs = new Map<EvalLanguage, (message: KernelToHostMessage) => void>();
69
+ readonly #cellKernelTools = new CellKernelTools();
68
70
  #context: ExtensionContext | undefined;
69
71
  #generation = 0;
70
72
  #disposePromise: Promise<void> | undefined;
@@ -91,7 +93,13 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
91
93
  }
92
94
 
93
95
  async #call(request: BridgeToolCallRequest): Promise<unknown> {
94
- return await routeBridgeToolCall(this.#options, request, this.#context?.evalHandleHost);
96
+ return await this.#cellKernelTools.run(request.cellToken, () =>
97
+ routeBridgeToolCall(this.#options, request, this.#context?.evalHandleHost),
98
+ );
99
+ }
100
+
101
+ bindCellKernelTools(token: string, capability: KernelToolsCapability): () => void {
102
+ return this.#cellKernelTools.bind(token, capability);
95
103
  }
96
104
 
97
105
  async getKernel(language: EvalLanguage, onMessage: (message: KernelToHostMessage) => void): Promise<EvalKernel> {
package/src/index.ts CHANGED
@@ -157,6 +157,8 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
157
157
  complete,
158
158
  settings: runtime.settings,
159
159
  artifactsDir: runtime.artifactsDir,
160
+ ...(runtime.pythonEnvironments === undefined ? {} : { pythonEnvironments: runtime.pythonEnvironments }),
161
+ jsEnvironments: runtime.jsEnvironments,
160
162
  cellManager,
161
163
  handles,
162
164
  executionTracker: manager,
@@ -210,9 +210,14 @@ function Base.getproperty(pool::SenpiWorkpool, name::Symbol)
210
210
  getfield(pool, name)
211
211
  end
212
212
 
213
- function workpool(agent::AbstractDict, name::AbstractString; mode=nothing)
213
+ function workpool(agent::AbstractDict, name::AbstractString; mode=nothing, tools=nothing)
214
214
  args = Dict{String, Any}("op" => "create", "agent" => agent, "name" => name)
215
215
  mode !== nothing && (args["mode"] = mode)
216
+ if tools !== nothing
217
+ (tools isa AbstractVector && all(name -> name isa AbstractString, tools)) ||
218
+ throw(SenpiBridgeError("workpool(tools=...) takes a vector of tool names; got $(typeof(tools))", "invalid_tools"))
219
+ args["tools"] = collect(String, tools)
220
+ end
216
221
  result = senpi_workpool_call(args)
217
222
  details = get(result, "details", nothing)
218
223
  if details isa AbstractDict
@@ -0,0 +1,44 @@
1
+ import type { HostToKernelMessage } from "../../bridge/protocol.ts";
2
+ import type { HostCellExecutor } from "../../tool/types.ts";
3
+ import { inputAtStart } from "../shared/cell-source-at-start.ts";
4
+ import { refusedEntry } from "./host-entries.ts";
5
+ import {
6
+ type JavaScriptKernelOptions,
7
+ type JavaScriptRunInput,
8
+ resolveKernelToolNameSource,
9
+ } from "./kernel-contract.ts";
10
+ import type { LocalModuleLoader } from "./local-module-loader.ts";
11
+
12
+ export type CellDispatch =
13
+ | { readonly kind: "host"; readonly host: HostCellExecutor }
14
+ | { readonly kind: "worker"; readonly frames: readonly HostToKernelMessage[] };
15
+
16
+ /**
17
+ * Decides at the cell's turn how it runs: as a host entry (an install, or a turn-time refusal such as an unreadable
18
+ * `%load` file) or in the worker, with the frames the worker needs (the current kernel-tool names, then the cell).
19
+ */
20
+ export function dispatchCell(
21
+ queued: JavaScriptRunInput,
22
+ loader: LocalModuleLoader,
23
+ options: Pick<JavaScriptKernelOptions, "hostToolNames" | "foreignLanguageNames">,
24
+ ): CellDispatch {
25
+ const input = inputAtStart(queued);
26
+ if ("refused" in input) return { kind: "host", host: refusedEntry(input.refused) };
27
+ if (input.host !== undefined) return { kind: "host", host: input.host };
28
+ return {
29
+ kind: "worker",
30
+ frames: [
31
+ {
32
+ type: "kernel-tools-names",
33
+ hostToolNames: resolveKernelToolNameSource(options.hostToolNames),
34
+ foreignLanguageNames: resolveKernelToolNameSource(options.foreignLanguageNames),
35
+ },
36
+ {
37
+ type: "run",
38
+ cellId: input.cellId,
39
+ code: loader.prepareCell(input.code, input.kernelPreludes, input.sourceFile, input.packageRoot?.()),
40
+ timeoutMs: input.timeoutMs,
41
+ },
42
+ ],
43
+ };
44
+ }
@@ -3,14 +3,15 @@ import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
3
3
  import type { KernelInterruptHandle } from "../../tool/types.ts";
4
4
  import { KernelToolHostPump } from "../shared/kernel-tools-pump.ts";
5
5
  import { ActiveCellControl } from "./active-cell-control.ts";
6
- import { DEFAULT_INTERRUPT_BOUNDS, type WorkerRetirement } from "./interrupt-bounds.ts";
6
+ import { dispatchCell } from "./cell-dispatch.ts";
7
+ import { HostEntries } from "./host-entries.ts";
8
+ import { DEFAULT_INTERRUPT_BOUNDS, JS_INTERRUPT_GRACE_MS, type WorkerRetirement } from "./interrupt-bounds.ts";
7
9
  import {
8
10
  assertJavaScriptKernelOpen,
9
11
  type JavaScriptKernelMode,
10
12
  type JavaScriptRunInput,
11
13
  type LifecycleState,
12
14
  type ResultMessage,
13
- resolveKernelToolNameSource,
14
15
  type ToolCallMessage,
15
16
  } from "./kernel-contract.ts";
16
17
  import { type JavaScriptMemoryReading, KernelMemoryBridge } from "./kernel-memory-bridge.ts";
@@ -21,7 +22,7 @@ import type {
21
22
  KernelToolsInvokeRequest,
22
23
  } from "./kernel-tools-types.ts";
23
24
  import { type JavaScriptKernelOptions, LocalModuleLoader } from "./local-module-loader.ts";
24
- import { JavaScriptRunQueue } from "./run-queue.ts";
25
+ import { JavaScriptRunQueue, type PendingJavaScriptRun } from "./run-queue.ts";
25
26
  import { ToolCallQueue } from "./tool-call-queue.ts";
26
27
  import { WorkerChildren } from "./worker-children.ts";
27
28
  import { crashedResult } from "./worker-host.ts";
@@ -34,6 +35,15 @@ export type { JavaScriptKernelOptions } from "./local-module-loader.ts";
34
35
  export { type JavaScriptWorkerEntryUrlOptions, resolveJsWorkerEntryUrl } from "./worker-startup.ts";
35
36
 
36
37
  export class JavaScriptKernel {
38
+ readonly #hostEntries = new HostEntries<PendingJavaScriptRun>({
39
+ emit: (run, message) => (run.input.onMessage ?? this.#options.onMessage)?.(message),
40
+ settle: (run, result) => {
41
+ if (!this.#runs.releaseActive(run)) return;
42
+ this.#runs.settle(run, result);
43
+ this.#startNext();
44
+ },
45
+ durationMs: (run) => this.#runs.durationMs(run, performance.now()),
46
+ });
37
47
  readonly #options: JavaScriptKernelOptions;
38
48
  readonly #moduleLoader: LocalModuleLoader;
39
49
  readonly #slot: WorkerSlot;
@@ -128,6 +138,7 @@ export class JavaScriptKernel {
128
138
  const cancelled = this.cancelQueued(cellId, reason);
129
139
  return { stateRetained: Promise.resolve(true), ...(cancelled ? {} : { note: "cell not found" }) };
130
140
  }
141
+ if (active && this.#hostEntries.abort(active, reason)) return { stateRetained: Promise.resolve(true) };
131
142
  if (!active) {
132
143
  // A worker still stuck in startup is not a healthy idle worker: retiring it is the only recovery.
133
144
  const wedgedInStartup = this.#slot.startingUp;
@@ -162,11 +173,14 @@ export class JavaScriptKernel {
162
173
  if (this.#closePromise) return await this.#closePromise;
163
174
  this.#slot.postMessage({ type: "close" });
164
175
  this.#lifecycle = "closing";
176
+ const graceMs = this.#options.interruptBounds?.graceMs ?? JS_INTERRUPT_GRACE_MS;
177
+ const hostEntriesStopped = this.#hostEntries.stopAll("JS kernel closed", graceMs);
165
178
  this.#runs.settleAll("JS kernel closed");
166
179
  this.#kernelTools.rejectAll(kernelToolError("kernel_tool_stale", "JS kernel closed"));
167
180
  this.#toolCalls.clear();
168
181
  const recovery = this.#recovery.inFlight;
169
182
  const closePromise = (async () => {
183
+ await hostEntriesStopped;
170
184
  if (recovery) await recovery;
171
185
  await this.#terminate();
172
186
  })().finally(() => {
@@ -195,18 +209,13 @@ export class JavaScriptKernel {
195
209
  if (this.#lifecycle !== "open" || this.#runs.active || !this.#slot.present) return;
196
210
  const next = this.#runs.startNext(performance.now());
197
211
  if (!next) return;
212
+ const dispatch = dispatchCell(next.input, this.#moduleLoader, this.#options);
213
+ if (dispatch.kind === "host") {
214
+ this.#hostEntries.start(next, dispatch.host);
215
+ return;
216
+ }
198
217
  this.#activeCell.arm(next);
199
- this.#slot.postMessage({
200
- type: "kernel-tools-names",
201
- hostToolNames: resolveKernelToolNameSource(this.#options.hostToolNames),
202
- foreignLanguageNames: resolveKernelToolNameSource(this.#options.foreignLanguageNames),
203
- });
204
- this.#slot.postMessage({
205
- type: "run",
206
- cellId: next.input.cellId,
207
- code: this.#moduleLoader.prepareCell(next.input.code, next.input.kernelPreludes),
208
- timeoutMs: next.input.timeoutMs,
209
- });
218
+ for (const frame of dispatch.frames) this.#slot.postMessage(frame);
210
219
  }
211
220
 
212
221
  async #restartAfterStop(): Promise<void> {
@@ -247,7 +256,8 @@ export class JavaScriptKernel {
247
256
  this.#activeCell.disarm();
248
257
  this.#kernelTools.rejectAll(kernelToolError("kernel_tool_stale", error.message));
249
258
  this.#memory.workerLost(error);
250
- if (active) {
259
+ // A crash mid-install stops the install (process group killed, nothing published); it settles itself once stopped.
260
+ if (active && !this.#hostEntries.abort(active, `JavaScript worker crashed: ${error.message}`)) {
251
261
  this.#runs.releaseActive(active);
252
262
  this.#runs.settle(
253
263
  active,
@@ -0,0 +1,62 @@
1
+ import type { KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import type { HostCellExecutor } from "../../tool/types.ts";
3
+ import { runHostCell } from "../shared/host-cell.ts";
4
+ import type { ResultMessage } from "./kernel-contract.ts";
5
+
6
+ export interface HostEntryKernel<Run> {
7
+ emit(run: Run, message: KernelToHostMessage): void;
8
+ settle(run: Run, result: ResultMessage): void;
9
+ durationMs(run: Run): number;
10
+ }
11
+
12
+ /** The host entries (installs) a JavaScript kernel is running: started per queue slot, stopped by interrupt and close. */
13
+ export class HostEntries<Run extends { readonly input: { readonly cellId: string } }> {
14
+ readonly #aborts = new Map<Run, AbortController>();
15
+ readonly #done = new Map<Run, Promise<unknown>>();
16
+ readonly #kernel: HostEntryKernel<Run>;
17
+
18
+ constructor(kernel: HostEntryKernel<Run>) {
19
+ this.#kernel = kernel;
20
+ }
21
+
22
+ /** Runs `host` for `run`'s queue slot; it holds the slot like a cell but never reaches the worker. */
23
+ start(run: Run, host: HostCellExecutor): void {
24
+ const entry = runHostCell(run.input.cellId, host, {
25
+ emit: (message) => this.#kernel.emit(run, message),
26
+ settle: (result) => {
27
+ this.#aborts.delete(run);
28
+ this.#kernel.settle(run, result);
29
+ },
30
+ durationMs: () => this.#kernel.durationMs(run),
31
+ settleAfterAbort: true,
32
+ });
33
+ this.#aborts.set(run, entry.abort);
34
+ this.#done.set(run, entry.done);
35
+ void entry.done.finally(() => this.#done.delete(run));
36
+ }
37
+
38
+ /** Aborts `run`'s host entry; false when `run` is not one. */
39
+ abort(run: Run, reason: string): boolean {
40
+ const abort = this.#aborts.get(run);
41
+ if (abort === undefined) return false;
42
+ abort.abort(new Error(reason));
43
+ return true;
44
+ }
45
+
46
+ /** Aborts every running entry and waits for them to stop, at most `ms`: a closed kernel leaves no install running. */
47
+ async stopAll(reason: string, ms: number): Promise<void> {
48
+ const running = [...this.#done.values()];
49
+ for (const abort of this.#aborts.values()) abort.abort(new Error(reason));
50
+ let timer: ReturnType<typeof setTimeout> | undefined;
51
+ await Promise.race([
52
+ Promise.allSettled(running),
53
+ new Promise<void>((resolve) => (timer = setTimeout(resolve, ms))),
54
+ ]);
55
+ if (timer !== undefined) clearTimeout(timer);
56
+ }
57
+ }
58
+
59
+ /** A turn-time refusal (an unreadable `%load` file, a refused magic) settles in queue order as a failed host entry. */
60
+ export function refusedEntry(message: string): HostCellExecutor {
61
+ return async () => ({ ok: false, error: { message } });
62
+ }
@@ -1,4 +1,4 @@
1
- import { join, resolve, sep } from "node:path";
1
+ import { dirname, join, resolve, sep } from "node:path";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import type { KernelPreludeContribution } from "@code-yeongyu/senpi";
4
4
  import type { BridgeConnectionConfig } from "../../bridge/protocol.ts";
@@ -98,7 +98,13 @@ function loaderPrelude(context: RuntimeModuleContext): string {
98
98
  " const urlModule = await import('node:url');",
99
99
  " target = urlModule.pathToFileURL(specifier).href;",
100
100
  " }",
101
- " return options === undefined ? import(target) : import(target, options);",
101
+ " const load = (resolved) => (options === undefined ? import(resolved) : import(resolved, options));",
102
+ " const resolvePackage = globalThis[Symbol.for('senpi.kernel.resolvePackage')];",
103
+ " if (target === specifier && typeof resolvePackage === 'function') {",
104
+ " const resolved = resolvePackage(specifier, [context.cwdUrl, context.packageRootUrl]);",
105
+ " if (resolved !== undefined) return await load(resolved);",
106
+ " }",
107
+ " return await load(target);",
102
108
  "};",
103
109
  ].join("\n");
104
110
  }
@@ -115,14 +121,24 @@ function contributionPrelude(plan: KernelPreludePlan): string {
115
121
 
116
122
  export class LocalModuleLoader {
117
123
  readonly #prelude: string;
124
+ readonly #cwdUrl: string;
118
125
  readonly #contributions = new KernelPreludeTracker();
119
126
 
120
127
  constructor(options: LocalModuleLoaderOptions) {
121
128
  this.#prelude = loaderPrelude(runtimeContext(options));
129
+ this.#cwdUrl = directoryUrl(options.cwd);
122
130
  }
123
131
 
124
- prepareCell(code: string, contributions: readonly KernelPreludeContribution[] = []): string {
125
- const prelude = `${this.#prelude}\n${contributionPrelude(this.#contributions.plan(contributions))}`;
126
- return `${PREPARED_CELL_PREFIX}${JSON.stringify({ prelude, code: rewriteImports(code) })}`;
132
+ prepareCell(
133
+ code: string,
134
+ contributions: readonly KernelPreludeContribution[] = [],
135
+ sourceFile?: string,
136
+ packageRoot?: string,
137
+ ): string {
138
+ // A %load cell resolves its relative imports from its own file's directory for that cell only.
139
+ const cwdUrl = sourceFile === undefined ? this.#cwdUrl : directoryUrl(dirname(sourceFile));
140
+ const base = `\nglobalThis.__senpi_module_context__ = { ...globalThis.__senpi_module_context__, cwdUrl: ${JSON.stringify(cwdUrl)}, packageRootUrl: ${JSON.stringify(packageRoot === undefined ? null : directoryUrl(packageRoot))} };`;
141
+ const prelude = `${this.#prelude}${base}\n${contributionPrelude(this.#contributions.plan(contributions))}`;
142
+ return `${PREPARED_CELL_PREFIX}${JSON.stringify({ prelude, code: rewriteImports(code), ...(sourceFile === undefined ? {} : { sourceFile }) })}`;
127
143
  }
128
144
  }
@@ -3,6 +3,7 @@ import { kernelToolError } from "./kernel-tools-errors.js";
3
3
  import { createKernelToolPump } from "./kernel-tools-pump.js";
4
4
  import { hostDeniedError, hostToolRefusal } from "./kernel-tools-scope.js";
5
5
  import { installSessionCwd } from "./worker-cwd.js";
6
+ import { installPackageResolver } from "./worker-package-resolve.js";
6
7
  import { createHeapProbe } from "./worker-heap.js";
7
8
  import { createWorkerMemory } from "./worker-memory.js";
8
9
  import { JsWorkerRuntime } from "./worker-runtime.js";
@@ -129,6 +130,7 @@ export function createWorkerCore(transport, options) {
129
130
  applySessionEnvironment(message.sessionEnv);
130
131
  installSessionCwd(options.cwd);
131
132
  installKernelWebView(requestWebViewPort);
133
+ installPackageResolver();
132
134
  runtime = new JsWorkerRuntime({
133
135
  cwd: options.cwd,
134
136
  parallelPoolWidth: options.parallelPoolWidth,