@code-yeongyu/senpi-codemode 2026.10.9 → 2026.10.10-2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +52 -4
- package/package.json +6 -5
- package/src/config/executable-settings.json +5 -0
- package/src/config/project-trust.ts +51 -0
- package/src/config/settings-overrides.ts +2 -0
- package/src/environments/js-environments.ts +208 -0
- package/src/environments/js-installer.ts +268 -0
- package/src/environments/js-revision-files.ts +91 -0
- package/src/environments/no-links.ts +132 -0
- package/src/environments/project-resolves.ts +54 -0
- package/src/environments/revision-store.ts +20 -4
- package/src/extension/runtime-factory.ts +32 -2
- package/src/extension/session-manager.ts +8 -1
- package/src/index.ts +1 -0
- package/src/interpreters/detect.ts +34 -1
- package/src/kernels/js/cell-dispatch.ts +44 -0
- package/src/kernels/js/context-manager.ts +23 -27
- package/src/kernels/js/host-entries.ts +62 -0
- package/src/kernels/js/kernel-contract.ts +2 -0
- package/src/kernels/js/kernel-tools-registry.js +1 -0
- package/src/kernels/js/local-module-loader.ts +17 -6
- package/src/kernels/js/worker-core.js +3 -0
- package/src/kernels/js/worker-package-resolve.js +153 -0
- package/src/kernels/js/worker-runtime.js +1 -0
- package/src/kernels/js/worker-startup.ts +1 -0
- package/src/kernels/py/host-cell.ts +8 -31
- package/src/kernels/py/prelude.py +10 -2
- package/src/kernels/py/process.ts +4 -0
- package/src/kernels/sandbox/sandbox-cell.ts +116 -0
- package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +1 -0
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +14 -1
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +4 -1
- package/src/kernels/sandbox/vendor/pi-codemode/types.ts +2 -0
- package/src/kernels/shared/host-cell.ts +57 -0
- package/src/prompt/eval-prompt.ts +8 -1
- package/src/tool/cell-handler.ts +9 -11
- package/src/tool/eval-request.ts +23 -1
- package/src/tool/eval-tool-options.ts +3 -0
- package/src/tool/eval-tool.ts +17 -4
- package/src/tool/load-cell.ts +18 -3
- package/src/tool/magic-cell-host.ts +60 -2
- package/src/tool/magic-cells.ts +12 -2
- package/src/tool/run-eval-cell.ts +30 -3
- package/src/tool/types.ts +21 -1
|
@@ -3,10 +3,12 @@ import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
|
3
3
|
import { type BridgeServerHandle, startBridgeServer } from "../bridge/http-server.ts";
|
|
4
4
|
import type { KernelToHostMessage } from "../bridge/protocol.ts";
|
|
5
5
|
import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
|
|
6
|
+
import { resolveKernelToolsEnabled } from "../config/feature-settings.ts";
|
|
6
7
|
import { resolveKernelMemoryThresholds } from "../config/memory-settings.ts";
|
|
7
8
|
import { collectOrphanedChildren } from "../host-sdk.ts";
|
|
8
9
|
import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
|
|
9
10
|
import type { KernelToolsCapability, KernelToolsDescribeResult } from "../kernels/js/kernel-tools-types.ts";
|
|
11
|
+
import type { SessionEnvironment } from "../kernels/session-env.ts";
|
|
10
12
|
import type { KernelLifecycle } from "../kernels/shared/kernel-death.ts";
|
|
11
13
|
import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
|
|
12
14
|
import { type BridgeDispatchContext, dispatchBridgeCompletion } from "./bridge-dispatch.ts";
|
|
@@ -261,6 +263,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
261
263
|
foreignLanguageNames: () => this.#foreignKernelToolNames(),
|
|
262
264
|
memory: resolveKernelMemoryThresholds(this.#options.settings.memory),
|
|
263
265
|
collectOrphanedChildren,
|
|
266
|
+
...(resolveKernelToolsEnabled(this.#options.settings) ? {} : { kernelToolsEnabled: false }),
|
|
264
267
|
...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
|
|
265
268
|
...(localRoots ? { localRoots: { ...localRoots } } : {}),
|
|
266
269
|
...(this.#options.artifactsDir ? { artifactsDir: this.#options.artifactsDir } : {}),
|
|
@@ -276,10 +279,14 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
276
279
|
...(localRoots ? { localRoots: { ...localRoots } } : {}),
|
|
277
280
|
...(this.#options.artifactsDir ? { artifactsDir: this.#options.artifactsDir } : {}),
|
|
278
281
|
};
|
|
282
|
+
// The Python prelude reads this when a cell defines an @tool; only a disabled setting adds it.
|
|
283
|
+
const sessionEnv: SessionEnvironment | undefined = resolveKernelToolsEnabled(this.#options.settings)
|
|
284
|
+
? this.#options.sessionEnv
|
|
285
|
+
: { ...this.#options.sessionEnv, SENPI_CODEMODE_KERNEL_TOOLS: "0" };
|
|
279
286
|
const shared = {
|
|
280
287
|
sessionId: this.#options.sessionId,
|
|
281
288
|
cwd: this.#options.cwd,
|
|
282
|
-
...(
|
|
289
|
+
...(sessionEnv ? { sessionEnv } : {}),
|
|
283
290
|
connection,
|
|
284
291
|
onMessage,
|
|
285
292
|
...lifecycle,
|
package/src/index.ts
CHANGED
|
@@ -158,6 +158,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
|
|
|
158
158
|
settings: runtime.settings,
|
|
159
159
|
artifactsDir: runtime.artifactsDir,
|
|
160
160
|
...(runtime.pythonEnvironments === undefined ? {} : { pythonEnvironments: runtime.pythonEnvironments }),
|
|
161
|
+
jsEnvironments: runtime.jsEnvironments,
|
|
161
162
|
cellManager,
|
|
162
163
|
handles,
|
|
163
164
|
executionTracker: manager,
|
|
@@ -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
|
|
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,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { HostToKernelMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import type { HostCellExecutor } from "../../tool/types.ts";
|
|
3
|
+
import { inputAtStart } from "../shared/cell-source-at-start.ts";
|
|
4
|
+
import { refusedEntry } from "./host-entries.ts";
|
|
5
|
+
import {
|
|
6
|
+
type JavaScriptKernelOptions,
|
|
7
|
+
type JavaScriptRunInput,
|
|
8
|
+
resolveKernelToolNameSource,
|
|
9
|
+
} from "./kernel-contract.ts";
|
|
10
|
+
import type { LocalModuleLoader } from "./local-module-loader.ts";
|
|
11
|
+
|
|
12
|
+
export type CellDispatch =
|
|
13
|
+
| { readonly kind: "host"; readonly host: HostCellExecutor }
|
|
14
|
+
| { readonly kind: "worker"; readonly frames: readonly HostToKernelMessage[] };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Decides at the cell's turn how it runs: as a host entry (an install, or a turn-time refusal such as an unreadable
|
|
18
|
+
* `%load` file) or in the worker, with the frames the worker needs (the current kernel-tool names, then the cell).
|
|
19
|
+
*/
|
|
20
|
+
export function dispatchCell(
|
|
21
|
+
queued: JavaScriptRunInput,
|
|
22
|
+
loader: LocalModuleLoader,
|
|
23
|
+
options: Pick<JavaScriptKernelOptions, "hostToolNames" | "foreignLanguageNames">,
|
|
24
|
+
): CellDispatch {
|
|
25
|
+
const input = inputAtStart(queued);
|
|
26
|
+
if ("refused" in input) return { kind: "host", host: refusedEntry(input.refused) };
|
|
27
|
+
if (input.host !== undefined) return { kind: "host", host: input.host };
|
|
28
|
+
return {
|
|
29
|
+
kind: "worker",
|
|
30
|
+
frames: [
|
|
31
|
+
{
|
|
32
|
+
type: "kernel-tools-names",
|
|
33
|
+
hostToolNames: resolveKernelToolNameSource(options.hostToolNames),
|
|
34
|
+
foreignLanguageNames: resolveKernelToolNameSource(options.foreignLanguageNames),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
type: "run",
|
|
38
|
+
cellId: input.cellId,
|
|
39
|
+
code: loader.prepareCell(input.code, input.kernelPreludes, input.sourceFile, input.packageRoot?.()),
|
|
40
|
+
timeoutMs: input.timeoutMs,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
2
|
import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
|
|
3
3
|
import type { KernelInterruptHandle } from "../../tool/types.ts";
|
|
4
|
-
import { inputAtStart } from "../shared/cell-source-at-start.ts";
|
|
5
4
|
import { KernelToolHostPump } from "../shared/kernel-tools-pump.ts";
|
|
6
5
|
import { ActiveCellControl } from "./active-cell-control.ts";
|
|
7
|
-
import {
|
|
6
|
+
import { dispatchCell } from "./cell-dispatch.ts";
|
|
7
|
+
import { HostEntries } from "./host-entries.ts";
|
|
8
|
+
import { DEFAULT_INTERRUPT_BOUNDS, JS_INTERRUPT_GRACE_MS, type WorkerRetirement } from "./interrupt-bounds.ts";
|
|
8
9
|
import {
|
|
9
10
|
assertJavaScriptKernelOpen,
|
|
10
11
|
type JavaScriptKernelMode,
|
|
11
12
|
type JavaScriptRunInput,
|
|
12
13
|
type LifecycleState,
|
|
13
14
|
type ResultMessage,
|
|
14
|
-
resolveKernelToolNameSource,
|
|
15
15
|
type ToolCallMessage,
|
|
16
16
|
} from "./kernel-contract.ts";
|
|
17
17
|
import { type JavaScriptMemoryReading, KernelMemoryBridge } from "./kernel-memory-bridge.ts";
|
|
@@ -22,7 +22,7 @@ import type {
|
|
|
22
22
|
KernelToolsInvokeRequest,
|
|
23
23
|
} from "./kernel-tools-types.ts";
|
|
24
24
|
import { type JavaScriptKernelOptions, LocalModuleLoader } from "./local-module-loader.ts";
|
|
25
|
-
import { JavaScriptRunQueue } from "./run-queue.ts";
|
|
25
|
+
import { JavaScriptRunQueue, type PendingJavaScriptRun } from "./run-queue.ts";
|
|
26
26
|
import { ToolCallQueue } from "./tool-call-queue.ts";
|
|
27
27
|
import { WorkerChildren } from "./worker-children.ts";
|
|
28
28
|
import { crashedResult } from "./worker-host.ts";
|
|
@@ -35,6 +35,15 @@ export type { JavaScriptKernelOptions } from "./local-module-loader.ts";
|
|
|
35
35
|
export { type JavaScriptWorkerEntryUrlOptions, resolveJsWorkerEntryUrl } from "./worker-startup.ts";
|
|
36
36
|
|
|
37
37
|
export class JavaScriptKernel {
|
|
38
|
+
readonly #hostEntries = new HostEntries<PendingJavaScriptRun>({
|
|
39
|
+
emit: (run, message) => (run.input.onMessage ?? this.#options.onMessage)?.(message),
|
|
40
|
+
settle: (run, result) => {
|
|
41
|
+
if (!this.#runs.releaseActive(run)) return;
|
|
42
|
+
this.#runs.settle(run, result);
|
|
43
|
+
this.#startNext();
|
|
44
|
+
},
|
|
45
|
+
durationMs: (run) => this.#runs.durationMs(run, performance.now()),
|
|
46
|
+
});
|
|
38
47
|
readonly #options: JavaScriptKernelOptions;
|
|
39
48
|
readonly #moduleLoader: LocalModuleLoader;
|
|
40
49
|
readonly #slot: WorkerSlot;
|
|
@@ -129,6 +138,7 @@ export class JavaScriptKernel {
|
|
|
129
138
|
const cancelled = this.cancelQueued(cellId, reason);
|
|
130
139
|
return { stateRetained: Promise.resolve(true), ...(cancelled ? {} : { note: "cell not found" }) };
|
|
131
140
|
}
|
|
141
|
+
if (active && this.#hostEntries.abort(active, reason)) return { stateRetained: Promise.resolve(true) };
|
|
132
142
|
if (!active) {
|
|
133
143
|
// A worker still stuck in startup is not a healthy idle worker: retiring it is the only recovery.
|
|
134
144
|
const wedgedInStartup = this.#slot.startingUp;
|
|
@@ -163,11 +173,14 @@ export class JavaScriptKernel {
|
|
|
163
173
|
if (this.#closePromise) return await this.#closePromise;
|
|
164
174
|
this.#slot.postMessage({ type: "close" });
|
|
165
175
|
this.#lifecycle = "closing";
|
|
176
|
+
const graceMs = this.#options.interruptBounds?.graceMs ?? JS_INTERRUPT_GRACE_MS;
|
|
177
|
+
const hostEntriesStopped = this.#hostEntries.stopAll("JS kernel closed", graceMs);
|
|
166
178
|
this.#runs.settleAll("JS kernel closed");
|
|
167
179
|
this.#kernelTools.rejectAll(kernelToolError("kernel_tool_stale", "JS kernel closed"));
|
|
168
180
|
this.#toolCalls.clear();
|
|
169
181
|
const recovery = this.#recovery.inFlight;
|
|
170
182
|
const closePromise = (async () => {
|
|
183
|
+
await hostEntriesStopped;
|
|
171
184
|
if (recovery) await recovery;
|
|
172
185
|
await this.#terminate();
|
|
173
186
|
})().finally(() => {
|
|
@@ -196,31 +209,13 @@ export class JavaScriptKernel {
|
|
|
196
209
|
if (this.#lifecycle !== "open" || this.#runs.active || !this.#slot.present) return;
|
|
197
210
|
const next = this.#runs.startNext(performance.now());
|
|
198
211
|
if (!next) return;
|
|
199
|
-
const
|
|
200
|
-
if ("
|
|
201
|
-
this.#
|
|
202
|
-
this.#runs.settle(next, {
|
|
203
|
-
type: "result",
|
|
204
|
-
cellId: next.input.cellId,
|
|
205
|
-
ok: false,
|
|
206
|
-
error: { message: input.refused },
|
|
207
|
-
durationMs: 0,
|
|
208
|
-
});
|
|
209
|
-
this.#startNext();
|
|
212
|
+
const dispatch = dispatchCell(next.input, this.#moduleLoader, this.#options);
|
|
213
|
+
if (dispatch.kind === "host") {
|
|
214
|
+
this.#hostEntries.start(next, dispatch.host);
|
|
210
215
|
return;
|
|
211
216
|
}
|
|
212
217
|
this.#activeCell.arm(next);
|
|
213
|
-
this.#slot.postMessage(
|
|
214
|
-
type: "kernel-tools-names",
|
|
215
|
-
hostToolNames: resolveKernelToolNameSource(this.#options.hostToolNames),
|
|
216
|
-
foreignLanguageNames: resolveKernelToolNameSource(this.#options.foreignLanguageNames),
|
|
217
|
-
});
|
|
218
|
-
this.#slot.postMessage({
|
|
219
|
-
type: "run",
|
|
220
|
-
cellId: next.input.cellId,
|
|
221
|
-
code: this.#moduleLoader.prepareCell(input.code, input.kernelPreludes, input.sourceFile),
|
|
222
|
-
timeoutMs: next.input.timeoutMs,
|
|
223
|
-
});
|
|
218
|
+
for (const frame of dispatch.frames) this.#slot.postMessage(frame);
|
|
224
219
|
}
|
|
225
220
|
|
|
226
221
|
async #restartAfterStop(): Promise<void> {
|
|
@@ -261,7 +256,8 @@ export class JavaScriptKernel {
|
|
|
261
256
|
this.#activeCell.disarm();
|
|
262
257
|
this.#kernelTools.rejectAll(kernelToolError("kernel_tool_stale", error.message));
|
|
263
258
|
this.#memory.workerLost(error);
|
|
264
|
-
|
|
259
|
+
// A crash mid-install stops the install (process group killed, nothing published); it settles itself once stopped.
|
|
260
|
+
if (active && !this.#hostEntries.abort(active, `JavaScript worker crashed: ${error.message}`)) {
|
|
265
261
|
this.#runs.releaseActive(active);
|
|
266
262
|
this.#runs.settle(
|
|
267
263
|
active,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import type { HostCellExecutor } from "../../tool/types.ts";
|
|
3
|
+
import { runHostCell } from "../shared/host-cell.ts";
|
|
4
|
+
import type { ResultMessage } from "./kernel-contract.ts";
|
|
5
|
+
|
|
6
|
+
export interface HostEntryKernel<Run> {
|
|
7
|
+
emit(run: Run, message: KernelToHostMessage): void;
|
|
8
|
+
settle(run: Run, result: ResultMessage): void;
|
|
9
|
+
durationMs(run: Run): number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The host entries (installs) a JavaScript kernel is running: started per queue slot, stopped by interrupt and close. */
|
|
13
|
+
export class HostEntries<Run extends { readonly input: { readonly cellId: string } }> {
|
|
14
|
+
readonly #aborts = new Map<Run, AbortController>();
|
|
15
|
+
readonly #done = new Map<Run, Promise<unknown>>();
|
|
16
|
+
readonly #kernel: HostEntryKernel<Run>;
|
|
17
|
+
|
|
18
|
+
constructor(kernel: HostEntryKernel<Run>) {
|
|
19
|
+
this.#kernel = kernel;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Runs `host` for `run`'s queue slot; it holds the slot like a cell but never reaches the worker. */
|
|
23
|
+
start(run: Run, host: HostCellExecutor): void {
|
|
24
|
+
const entry = runHostCell(run.input.cellId, host, {
|
|
25
|
+
emit: (message) => this.#kernel.emit(run, message),
|
|
26
|
+
settle: (result) => {
|
|
27
|
+
this.#aborts.delete(run);
|
|
28
|
+
this.#kernel.settle(run, result);
|
|
29
|
+
},
|
|
30
|
+
durationMs: () => this.#kernel.durationMs(run),
|
|
31
|
+
settleAfterAbort: true,
|
|
32
|
+
});
|
|
33
|
+
this.#aborts.set(run, entry.abort);
|
|
34
|
+
this.#done.set(run, entry.done);
|
|
35
|
+
void entry.done.finally(() => this.#done.delete(run));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Aborts `run`'s host entry; false when `run` is not one. */
|
|
39
|
+
abort(run: Run, reason: string): boolean {
|
|
40
|
+
const abort = this.#aborts.get(run);
|
|
41
|
+
if (abort === undefined) return false;
|
|
42
|
+
abort.abort(new Error(reason));
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Aborts every running entry and waits for them to stop, at most `ms`: a closed kernel leaves no install running. */
|
|
47
|
+
async stopAll(reason: string, ms: number): Promise<void> {
|
|
48
|
+
const running = [...this.#done.values()];
|
|
49
|
+
for (const abort of this.#aborts.values()) abort.abort(new Error(reason));
|
|
50
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
51
|
+
await Promise.race([
|
|
52
|
+
Promise.allSettled(running),
|
|
53
|
+
new Promise<void>((resolve) => (timer = setTimeout(resolve, ms))),
|
|
54
|
+
]);
|
|
55
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A turn-time refusal (an unreadable `%load` file, a refused magic) settles in queue order as a failed host entry. */
|
|
60
|
+
export function refusedEntry(message: string): HostCellExecutor {
|
|
61
|
+
return async () => ({ ok: false, error: { message } });
|
|
62
|
+
}
|
|
@@ -32,6 +32,8 @@ export interface JavaScriptKernelOptions {
|
|
|
32
32
|
readonly sessionEnv?: SessionEnvironment;
|
|
33
33
|
/** Host tool names denied as JS kernel-tool identifiers (init protocol). */
|
|
34
34
|
readonly hostToolNames?: KernelToolNameSource;
|
|
35
|
+
/** `kernelTools.enabled`; false makes `tool(fn)` refuse with `tools_unavailable`. Unset keeps them on. */
|
|
36
|
+
readonly kernelToolsEnabled?: boolean;
|
|
35
37
|
/** Tool names registered in another kernel language, denied as JS kernel-tool identifiers. */
|
|
36
38
|
readonly foreignLanguageNames?: KernelToolNameSource;
|
|
37
39
|
/** Post-cell collection, notice, and ceiling thresholds; absent leaves the worker's memory unmanaged. */
|
|
@@ -45,6 +45,7 @@ export function createKernelToolRegistry(options = {}) {
|
|
|
45
45
|
return generation;
|
|
46
46
|
},
|
|
47
47
|
define(fn, metadata) {
|
|
48
|
+
if (options.disabled === true) throw kernelToolError("tools_unavailable", "kernel tools are turned off for this project (kernelTools.enabled is false)");
|
|
48
49
|
assertJs();
|
|
49
50
|
const parsed = parseToolFunction(fn);
|
|
50
51
|
const resolved = resolveToolMetadata(metadata, parsed.params);
|
|
@@ -98,7 +98,13 @@ function loaderPrelude(context: RuntimeModuleContext): string {
|
|
|
98
98
|
" const urlModule = await import('node:url');",
|
|
99
99
|
" target = urlModule.pathToFileURL(specifier).href;",
|
|
100
100
|
" }",
|
|
101
|
-
"
|
|
101
|
+
" const load = (resolved) => (options === undefined ? import(resolved) : import(resolved, options));",
|
|
102
|
+
" const resolvePackage = globalThis[Symbol.for('senpi.kernel.resolvePackage')];",
|
|
103
|
+
" if (target === specifier && typeof resolvePackage === 'function') {",
|
|
104
|
+
" const resolved = resolvePackage(specifier, [context.cwdUrl, context.packageRootUrl]);",
|
|
105
|
+
" if (resolved !== undefined) return await load(resolved);",
|
|
106
|
+
" }",
|
|
107
|
+
" return await load(target);",
|
|
102
108
|
"};",
|
|
103
109
|
].join("\n");
|
|
104
110
|
}
|
|
@@ -115,18 +121,23 @@ function contributionPrelude(plan: KernelPreludePlan): string {
|
|
|
115
121
|
|
|
116
122
|
export class LocalModuleLoader {
|
|
117
123
|
readonly #prelude: string;
|
|
124
|
+
readonly #cwdUrl: string;
|
|
118
125
|
readonly #contributions = new KernelPreludeTracker();
|
|
119
126
|
|
|
120
127
|
constructor(options: LocalModuleLoaderOptions) {
|
|
121
128
|
this.#prelude = loaderPrelude(runtimeContext(options));
|
|
129
|
+
this.#cwdUrl = directoryUrl(options.cwd);
|
|
122
130
|
}
|
|
123
131
|
|
|
124
|
-
prepareCell(
|
|
132
|
+
prepareCell(
|
|
133
|
+
code: string,
|
|
134
|
+
contributions: readonly KernelPreludeContribution[] = [],
|
|
135
|
+
sourceFile?: string,
|
|
136
|
+
packageRoot?: string,
|
|
137
|
+
): string {
|
|
125
138
|
// A %load cell resolves its relative imports from its own file's directory for that cell only.
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
? ""
|
|
129
|
-
: `\nglobalThis.__senpi_module_context__ = { ...globalThis.__senpi_module_context__, cwdUrl: ${JSON.stringify(directoryUrl(dirname(sourceFile)))} };`;
|
|
139
|
+
const cwdUrl = sourceFile === undefined ? this.#cwdUrl : directoryUrl(dirname(sourceFile));
|
|
140
|
+
const base = `\nglobalThis.__senpi_module_context__ = { ...globalThis.__senpi_module_context__, cwdUrl: ${JSON.stringify(cwdUrl)}, packageRootUrl: ${JSON.stringify(packageRoot === undefined ? null : directoryUrl(packageRoot))} };`;
|
|
130
141
|
const prelude = `${this.#prelude}${base}\n${contributionPrelude(this.#contributions.plan(contributions))}`;
|
|
131
142
|
return `${PREPARED_CELL_PREFIX}${JSON.stringify({ prelude, code: rewriteImports(code), ...(sourceFile === undefined ? {} : { sourceFile }) })}`;
|
|
132
143
|
}
|
|
@@ -3,6 +3,7 @@ import { kernelToolError } from "./kernel-tools-errors.js";
|
|
|
3
3
|
import { createKernelToolPump } from "./kernel-tools-pump.js";
|
|
4
4
|
import { hostDeniedError, hostToolRefusal } from "./kernel-tools-scope.js";
|
|
5
5
|
import { installSessionCwd } from "./worker-cwd.js";
|
|
6
|
+
import { installPackageResolver } from "./worker-package-resolve.js";
|
|
6
7
|
import { createHeapProbe } from "./worker-heap.js";
|
|
7
8
|
import { createWorkerMemory } from "./worker-memory.js";
|
|
8
9
|
import { JsWorkerRuntime } from "./worker-runtime.js";
|
|
@@ -129,6 +130,7 @@ export function createWorkerCore(transport, options) {
|
|
|
129
130
|
applySessionEnvironment(message.sessionEnv);
|
|
130
131
|
installSessionCwd(options.cwd);
|
|
131
132
|
installKernelWebView(requestWebViewPort);
|
|
133
|
+
installPackageResolver();
|
|
132
134
|
runtime = new JsWorkerRuntime({
|
|
133
135
|
cwd: options.cwd,
|
|
134
136
|
parallelPoolWidth: options.parallelPoolWidth,
|
|
@@ -137,6 +139,7 @@ export function createWorkerCore(transport, options) {
|
|
|
137
139
|
kernelGeneration: message.kernelGeneration ?? 1,
|
|
138
140
|
hostToolNames: message.hostToolNames ?? [],
|
|
139
141
|
foreignLanguageNames: message.foreignLanguageNames ?? [],
|
|
142
|
+
kernelToolsDisabled: message.kernelToolsDisabled === true,
|
|
140
143
|
onChildEvent: (event) => emit({ type: "status", event: { op: CHILD_LIFECYCLE_OP, ...event } }),
|
|
141
144
|
onShellWaitChange: () => {
|
|
142
145
|
if (activeCell?.interruption) acknowledgeInterrupt();
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { isBuiltin } from "node:module";
|
|
3
|
+
import { dirname, join, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
// A cell's bare import runs `import()` from this worker's own module, so the runtime resolves it from Senpi's install
|
|
7
|
+
// tree, not the session's project or its managed package revision. This resolver walks `node_modules` from each root
|
|
8
|
+
// the cell names, in order, and resolves the package entry the way ESM does (the `exports` field with import
|
|
9
|
+
// conditions), so ESM-only packages load and the first root that has the package wins.
|
|
10
|
+
const RESOLVE_PACKAGE = Symbol.for("senpi.kernel.resolvePackage");
|
|
11
|
+
const CONDITIONS = typeof globalThis.Bun === "object" ? ["bun", "node", "import", "default"] : ["node", "import", "default"];
|
|
12
|
+
|
|
13
|
+
function isFile(path) {
|
|
14
|
+
try {
|
|
15
|
+
return statSync(path).isFile();
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isDirectory(path) {
|
|
22
|
+
try {
|
|
23
|
+
return statSync(path).isDirectory();
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function splitSpecifier(specifier) {
|
|
30
|
+
const parts = specifier.split("/");
|
|
31
|
+
const nameParts = specifier.startsWith("@") ? 2 : 1;
|
|
32
|
+
if (parts.length < nameParts || parts.slice(0, nameParts).some((part) => part === "" || part === "." || part === ".."))
|
|
33
|
+
return undefined;
|
|
34
|
+
const rest = parts.slice(nameParts).join("/");
|
|
35
|
+
return { name: parts.slice(0, nameParts).join("/"), subpath: rest === "" ? "." : `./${rest}` };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function conditionalTarget(target) {
|
|
39
|
+
if (typeof target === "string") return target;
|
|
40
|
+
if (Array.isArray(target)) {
|
|
41
|
+
for (const candidate of target) {
|
|
42
|
+
const resolved = conditionalTarget(candidate);
|
|
43
|
+
if (resolved !== undefined) return resolved;
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
if (target === null || typeof target !== "object") return undefined;
|
|
48
|
+
for (const [condition, value] of Object.entries(target)) {
|
|
49
|
+
if (condition !== "default" && !CONDITIONS.includes(condition)) continue;
|
|
50
|
+
const resolved = conditionalTarget(value);
|
|
51
|
+
if (resolved !== undefined) return resolved;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function exportsTarget(exportsField, subpath) {
|
|
57
|
+
const subpathKeyed =
|
|
58
|
+
exportsField !== null &&
|
|
59
|
+
typeof exportsField === "object" &&
|
|
60
|
+
!Array.isArray(exportsField) &&
|
|
61
|
+
Object.keys(exportsField).some((key) => key.startsWith("."));
|
|
62
|
+
if (!subpathKeyed) return subpath === "." ? conditionalTarget(exportsField) : undefined;
|
|
63
|
+
if (Object.hasOwn(exportsField, subpath)) return conditionalTarget(exportsField[subpath]);
|
|
64
|
+
let best;
|
|
65
|
+
for (const key of Object.keys(exportsField)) {
|
|
66
|
+
const star = key.indexOf("*");
|
|
67
|
+
if (star < 0 || key.includes("*", star + 1)) continue;
|
|
68
|
+
const prefix = key.slice(0, star);
|
|
69
|
+
const suffix = key.slice(star + 1);
|
|
70
|
+
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix) || subpath.length < prefix.length + suffix.length)
|
|
71
|
+
continue;
|
|
72
|
+
if (best === undefined || prefix.length > best.prefix.length) {
|
|
73
|
+
best = { key, prefix, match: subpath.slice(prefix.length, subpath.length - suffix.length) };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (best === undefined || hasInvalidSegment(best.match)) return undefined;
|
|
77
|
+
const target = conditionalTarget(exportsField[best.key]);
|
|
78
|
+
return target === undefined ? undefined : target.replaceAll("*", best.match);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Node refuses a pattern match or target with "." / ".." / "node_modules" segments (ERR_INVALID_MODULE_SPECIFIER).
|
|
82
|
+
function hasInvalidSegment(path) {
|
|
83
|
+
return path.split(/[\\/]/).some((segment) => segment === "." || segment === ".." || segment === "node_modules");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function legacyFile(path) {
|
|
87
|
+
for (const candidate of [path, `${path}.js`, `${path}.mjs`, `${path}.cjs`, join(path, "index.js")]) {
|
|
88
|
+
if (isFile(candidate)) return candidate;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function packageEntry(packageDir, subpath, specifier) {
|
|
94
|
+
let manifest;
|
|
95
|
+
try {
|
|
96
|
+
manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"));
|
|
97
|
+
} catch {
|
|
98
|
+
return legacyFile(join(packageDir, subpath === "." ? "index.js" : subpath));
|
|
99
|
+
}
|
|
100
|
+
if (manifest.exports !== undefined && manifest.exports !== null) {
|
|
101
|
+
const target = exportsTarget(manifest.exports, subpath);
|
|
102
|
+
if (typeof target !== "string" || !target.startsWith("./") || hasInvalidSegment(target.slice(2))) {
|
|
103
|
+
const error = new Error(`Package subpath '${subpath}' is not exported for import by '${specifier}'`);
|
|
104
|
+
error.code = "ERR_PACKAGE_PATH_NOT_EXPORTED";
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
return join(packageDir, target);
|
|
108
|
+
}
|
|
109
|
+
if (subpath !== ".") return legacyFile(join(packageDir, subpath));
|
|
110
|
+
const moduleEntry = typeof globalThis.Bun === "object" && typeof manifest.module === "string" ? manifest.module : undefined;
|
|
111
|
+
const main = moduleEntry ?? (typeof manifest.main === "string" ? manifest.main : "index.js");
|
|
112
|
+
return legacyFile(join(packageDir, main));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function findPackageDir(name, rootUrl) {
|
|
116
|
+
let directory = fileURLToPath(rootUrl);
|
|
117
|
+
for (;;) {
|
|
118
|
+
const candidate = join(directory, "node_modules", name);
|
|
119
|
+
if (isDirectory(candidate)) return candidate;
|
|
120
|
+
const parent = dirname(directory);
|
|
121
|
+
if (parent === directory) return undefined;
|
|
122
|
+
directory = parent;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The file URL the bare `specifier` resolves to from the first of `rootUrls` whose `node_modules` chain has its
|
|
128
|
+
* package, or undefined when it names a builtin or no root has the package (the caller then imports it natively).
|
|
129
|
+
*/
|
|
130
|
+
export function resolvePackage(specifier, rootUrls) {
|
|
131
|
+
if (specifier.includes(":") || isBuiltin(specifier)) return undefined;
|
|
132
|
+
const parts = splitSpecifier(specifier);
|
|
133
|
+
if (parts === undefined) return undefined;
|
|
134
|
+
for (const rootUrl of rootUrls) {
|
|
135
|
+
if (typeof rootUrl !== "string") continue;
|
|
136
|
+
const packageDir = findPackageDir(parts.name, rootUrl);
|
|
137
|
+
if (packageDir === undefined) continue;
|
|
138
|
+
const entry = packageEntry(packageDir, parts.subpath, specifier);
|
|
139
|
+
if (entry === undefined) continue;
|
|
140
|
+
let real = entry;
|
|
141
|
+
try {
|
|
142
|
+
real = realpathSync(entry);
|
|
143
|
+
} catch {
|
|
144
|
+
// A missing target fails in the import itself, with the runtime's own error.
|
|
145
|
+
}
|
|
146
|
+
return pathToFileURL(real.endsWith(sep) ? real.slice(0, -1) : real).href;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function installPackageResolver() {
|
|
152
|
+
globalThis[RESOLVE_PACKAGE] = resolvePackage;
|
|
153
|
+
}
|
|
@@ -47,6 +47,7 @@ export class JsWorkerRuntime {
|
|
|
47
47
|
generation: options.kernelGeneration ?? 1,
|
|
48
48
|
hostToolNames: options.hostToolNames ?? [],
|
|
49
49
|
foreignLanguageNames: options.foreignLanguageNames ?? [],
|
|
50
|
+
disabled: options.kernelToolsDisabled === true,
|
|
50
51
|
});
|
|
51
52
|
this.#installGlobals();
|
|
52
53
|
}
|
|
@@ -68,6 +68,7 @@ async function initializeWorker(worker: WorkerLike, hooks: WorkerStartupHooks, s
|
|
|
68
68
|
foreignLanguageNames: resolveKernelToolNameSource(options.foreignLanguageNames),
|
|
69
69
|
...(options.sessionEnv === undefined ? {} : { sessionEnv: options.sessionEnv }),
|
|
70
70
|
...(options.memory === undefined ? {} : { memory: options.memory }),
|
|
71
|
+
...(options.kernelToolsEnabled === false ? { kernelToolsDisabled: true } : {}),
|
|
71
72
|
});
|
|
72
73
|
await ready;
|
|
73
74
|
}
|
|
@@ -1,42 +1,19 @@
|
|
|
1
1
|
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
2
|
import type { HostCellExecutor } from "../../tool/types.ts";
|
|
3
|
-
|
|
4
|
-
type HostCellOutcome = Awaited<ReturnType<HostCellExecutor>>;
|
|
5
|
-
|
|
3
|
+
import { runHostCell as runSharedHostCell } from "../shared/host-cell.ts";
|
|
6
4
|
import type { PendingRun, ResultMessage } from "./kernel-contract.ts";
|
|
7
|
-
import { failedPythonResult } from "./transport.ts";
|
|
8
5
|
|
|
9
6
|
export function runHostCell(
|
|
10
7
|
pending: PendingRun,
|
|
11
8
|
host: HostCellExecutor,
|
|
12
9
|
io: { readonly emit: (message: KernelToHostMessage) => void; readonly settle: (result: ResultMessage) => void },
|
|
13
10
|
): void {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const emit = (message: KernelToHostMessage): void => {
|
|
20
|
-
if (!finished && !abort.signal.aborted) io.emit(message);
|
|
21
|
-
};
|
|
22
|
-
// Calling the executor inside a promise keeps a synchronous throw in this cell instead of failing the queue.
|
|
23
|
-
const done = new Promise<HostCellOutcome>((resolve) => resolve(host({ signal: abort.signal, emit }))).then(
|
|
24
|
-
(outcome): ResultMessage =>
|
|
25
|
-
outcome.ok
|
|
26
|
-
? {
|
|
27
|
-
type: "result",
|
|
28
|
-
cellId,
|
|
29
|
-
ok: true,
|
|
30
|
-
durationMs: 0,
|
|
31
|
-
...(outcome.valueRepr === undefined ? {} : { valueRepr: outcome.valueRepr }),
|
|
32
|
-
}
|
|
33
|
-
: { type: "result", cellId, ok: false, error: outcome.error, durationMs: 0 },
|
|
34
|
-
(error: unknown): ResultMessage =>
|
|
35
|
-
failedPythonResult(cellId, error instanceof Error ? error.message : String(error)),
|
|
36
|
-
);
|
|
37
|
-
pending.hostDone = done;
|
|
38
|
-
void done.then((result) => {
|
|
39
|
-
finished = true;
|
|
40
|
-
if (!abort.signal.aborted) io.settle(result);
|
|
11
|
+
const run = runSharedHostCell(pending.input.cellId, host, {
|
|
12
|
+
emit: io.emit,
|
|
13
|
+
settle: io.settle,
|
|
14
|
+
durationMs: () => 0,
|
|
15
|
+
settleAfterAbort: false,
|
|
41
16
|
});
|
|
17
|
+
pending.hostAbort = run.abort;
|
|
18
|
+
pending.hostDone = run.done;
|
|
42
19
|
}
|