@code-yeongyu/senpi-codemode 2026.10.7 → 2026.10.9

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 (56) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +67 -5
  3. package/package.json +5 -4
  4. package/src/bridge/http-server.ts +9 -1
  5. package/src/bridge/kernel-tools-protocol.ts +8 -0
  6. package/src/bridge/protocol.ts +4 -0
  7. package/src/bridges/eval-virtual-schemas.ts +22 -0
  8. package/src/environments/editable-check.ts +116 -0
  9. package/src/environments/install-lock.ts +223 -0
  10. package/src/environments/py-environment.ts +98 -0
  11. package/src/environments/py-installer.ts +247 -0
  12. package/src/environments/python-environments.ts +85 -0
  13. package/src/environments/revision-store.ts +110 -0
  14. package/src/extension/bridge-tool-call.ts +2 -0
  15. package/src/extension/cell-kernel-tools.ts +28 -0
  16. package/src/extension/kernel-registration.ts +5 -0
  17. package/src/extension/kernel-replacement.ts +20 -0
  18. package/src/extension/runtime-factory.ts +12 -0
  19. package/src/extension/session-manager-proxy.ts +6 -0
  20. package/src/extension/session-manager.ts +24 -2
  21. package/src/index.ts +1 -0
  22. package/src/kernels/jl/prelude.jl +6 -1
  23. package/src/kernels/js/context-manager.ts +15 -1
  24. package/src/kernels/js/kernel-tools-errors.js +2 -0
  25. package/src/kernels/js/kernel-tools-errors.ts +2 -0
  26. package/src/kernels/js/local-module-loader.ts +9 -4
  27. package/src/kernels/js/worker-runtime.js +3 -1
  28. package/src/kernels/js/workpool.js +5 -2
  29. package/src/kernels/py/kernel-contract.ts +11 -2
  30. package/src/kernels/py/kernel-tools-host.ts +97 -0
  31. package/src/kernels/py/kernel.ts +48 -10
  32. package/src/kernels/py/prelude.py +771 -18
  33. package/src/kernels/py/process.ts +15 -4
  34. package/src/kernels/py/startup.ts +58 -20
  35. package/src/kernels/py/transport.ts +49 -8
  36. package/src/kernels/rb/workpool.rb +7 -1
  37. package/src/kernels/sandbox/vendor/pi-codemode/LICENSE +21 -0
  38. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +42 -0
  39. package/src/kernels/sandbox/vendor/pi-codemode/identifier.ts +14 -0
  40. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +514 -0
  41. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +431 -0
  42. package/src/kernels/sandbox/vendor/pi-codemode/runtime/protocol.ts +61 -0
  43. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +231 -0
  44. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +185 -0
  45. package/src/kernels/sandbox/vendor/pi-codemode/wasm.ts +37 -0
  46. package/src/kernels/shared/cell-source-at-start.ts +18 -0
  47. package/src/kernels/shared/kernel-tools-pump.ts +6 -0
  48. package/src/kernels/shared/process-cpu.ts +69 -0
  49. package/src/kernels/shared/subprocess-queue.ts +1 -0
  50. package/src/tool/cell-handler.ts +1 -1
  51. package/src/tool/eval-tool-options.ts +3 -0
  52. package/src/tool/load-cell.ts +73 -0
  53. package/src/tool/magic-cell-host.ts +65 -0
  54. package/src/tool/magic-cells.ts +65 -0
  55. package/src/tool/run-eval-cell.ts +30 -0
  56. package/src/tool/types.ts +27 -0
@@ -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,10 +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 { KernelToolsCapability, KernelToolsDescribeResult } from "../kernels/js/kernel-tools-types.ts";
9
10
  import type { KernelLifecycle } from "../kernels/shared/kernel-death.ts";
10
11
  import type { EvalKernel, EvalLanguage } from "../tool/types.ts";
11
12
  import { type BridgeDispatchContext, dispatchBridgeCompletion } from "./bridge-dispatch.ts";
12
13
  import { type BridgeToolCallRequest, routeBridgeToolCall } from "./bridge-tool-call.ts";
14
+ import { CellKernelTools } from "./cell-kernel-tools.ts";
13
15
  import { parkWhenIdle } from "./idle-parking-kernel.ts";
14
16
  import {
15
17
  javaScriptKernelMemory,
@@ -64,6 +66,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
64
66
  readonly #registrations: SessionKernelRegistrations;
65
67
  #kernelCreations = new Map<EvalLanguage, Promise<EvalKernel>>();
66
68
  #onMessageRefs = new Map<EvalLanguage, (message: KernelToHostMessage) => void>();
69
+ readonly #cellKernelTools = new CellKernelTools();
67
70
  #context: ExtensionContext | undefined;
68
71
  #generation = 0;
69
72
  #disposePromise: Promise<void> | undefined;
@@ -90,7 +93,13 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
90
93
  }
91
94
 
92
95
  async #call(request: BridgeToolCallRequest): Promise<unknown> {
93
- 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);
94
103
  }
95
104
 
96
105
  async getKernel(language: EvalLanguage, onMessage: (message: KernelToHostMessage) => void): Promise<EvalKernel> {
@@ -276,7 +285,20 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
276
285
  ...lifecycle,
277
286
  };
278
287
  const memory = resolveKernelMemoryThresholds(this.#options.settings.memory);
279
- return await startSubprocessKernel({ language, interpreterPath: detected.path, memory, shared });
288
+ return await startSubprocessKernel({
289
+ language,
290
+ interpreterPath: detected.path,
291
+ memory,
292
+ shared,
293
+ peerKernelToolsDescribe: (names) => this.#javaScriptKernelToolsDescribe(names),
294
+ });
295
+ }
296
+
297
+ /** Only an already running JavaScript kernel can define a colliding name; none is started for this. */
298
+ #javaScriptKernelToolsDescribe(names: readonly string[]): Promise<KernelToolsDescribeResult> | undefined {
299
+ const js = this.#kernels.get("js");
300
+ if (!js || !("describeKernelTools" in js) || typeof js.describeKernelTools !== "function") return undefined;
301
+ return (js.describeKernelTools as (names: readonly string[]) => Promise<KernelToolsDescribeResult>)(names);
280
302
  }
281
303
 
282
304
  #foreignKernelToolNames(): string[] {
package/src/index.ts CHANGED
@@ -157,6 +157,7 @@ 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 }),
160
161
  cellManager,
161
162
  handles,
162
163
  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
@@ -1,6 +1,7 @@
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";
4
5
  import { KernelToolHostPump } from "../shared/kernel-tools-pump.ts";
5
6
  import { ActiveCellControl } from "./active-cell-control.ts";
6
7
  import { DEFAULT_INTERRUPT_BOUNDS, type WorkerRetirement } from "./interrupt-bounds.ts";
@@ -195,6 +196,19 @@ export class JavaScriptKernel {
195
196
  if (this.#lifecycle !== "open" || this.#runs.active || !this.#slot.present) return;
196
197
  const next = this.#runs.startNext(performance.now());
197
198
  if (!next) return;
199
+ const input = inputAtStart(next.input);
200
+ if ("refused" in input) {
201
+ this.#runs.releaseActive(next);
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();
210
+ return;
211
+ }
198
212
  this.#activeCell.arm(next);
199
213
  this.#slot.postMessage({
200
214
  type: "kernel-tools-names",
@@ -204,7 +218,7 @@ export class JavaScriptKernel {
204
218
  this.#slot.postMessage({
205
219
  type: "run",
206
220
  cellId: next.input.cellId,
207
- code: this.#moduleLoader.prepareCell(next.input.code, next.input.kernelPreludes),
221
+ code: this.#moduleLoader.prepareCell(input.code, input.kernelPreludes, input.sourceFile),
208
222
  timeoutMs: next.input.timeoutMs,
209
223
  });
210
224
  }
@@ -8,6 +8,8 @@ export const KERNEL_TOOL_ERROR_CODES = Object.freeze([
8
8
  "kernel_tool_failed",
9
9
  "kernel_tool_recursion",
10
10
  "kernel_tool_host_denied",
11
+ "kernel_tool_cancelled",
12
+ "kernel_tool_loop_mismatch",
11
13
  ]);
12
14
 
13
15
  export class KernelToolError extends Error {
@@ -8,6 +8,8 @@ export const KERNEL_TOOL_ERROR_CODES = [
8
8
  "kernel_tool_failed",
9
9
  "kernel_tool_recursion",
10
10
  "kernel_tool_host_denied",
11
+ "kernel_tool_cancelled",
12
+ "kernel_tool_loop_mismatch",
11
13
  ] as const;
12
14
 
13
15
  export type KernelToolErrorCode = (typeof KERNEL_TOOL_ERROR_CODES)[number];
@@ -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";
@@ -121,8 +121,13 @@ export class LocalModuleLoader {
121
121
  this.#prelude = loaderPrelude(runtimeContext(options));
122
122
  }
123
123
 
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) })}`;
124
+ prepareCell(code: string, contributions: readonly KernelPreludeContribution[] = [], sourceFile?: string): string {
125
+ // A %load cell resolves its relative imports from its own file's directory for that cell only.
126
+ const base =
127
+ sourceFile === undefined
128
+ ? ""
129
+ : `\nglobalThis.__senpi_module_context__ = { ...globalThis.__senpi_module_context__, cwdUrl: ${JSON.stringify(directoryUrl(dirname(sourceFile)))} };`;
130
+ const prelude = `${this.#prelude}${base}\n${contributionPrelude(this.#contributions.plan(contributions))}`;
131
+ return `${PREPARED_CELL_PREFIX}${JSON.stringify({ prelude, code: rewriteImports(code), ...(sourceFile === undefined ? {} : { sourceFile }) })}`;
127
132
  }
128
133
  }
@@ -65,13 +65,15 @@ export class JsWorkerRuntime {
65
65
  try {
66
66
  let prelude = "";
67
67
  let cellCode = code;
68
+ let sourceName = cellId;
68
69
  if (code.startsWith(PREPARED_CELL_PREFIX)) {
69
70
  const prepared = JSON.parse(code.slice(PREPARED_CELL_PREFIX.length));
70
71
  if (!isPlainObject(prepared) || typeof prepared.prelude !== "string" || typeof prepared.code !== "string") throw new Error("Invalid prepared JavaScript cell payload");
71
72
  ({ prelude, code: cellCode } = prepared);
73
+ if (typeof prepared.sourceFile === "string") sourceName = prepared.sourceFile;
72
74
  }
73
75
  if (prelude) indirectEval(prelude, PRELUDE_SOURCE_URL);
74
- const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)), cellId));
76
+ const value = await awaitMaybePromise(indirectEval(bindKernelBun(wrapUserCode(cellCode)), sourceName));
75
77
  await this.#drainPendingDisplays();
76
78
  return value;
77
79
  } finally {
@@ -4,8 +4,11 @@ import { kernelToolError } from "./kernel-tools-errors.js";
4
4
  // Only host-tool sugar: no admission, worker, or queue state belongs to the kernel.
5
5
  export async function createWorkpool(callTool, agent, name, options = {}) {
6
6
  if (inKernelToolInvoke()) throw kernelToolError("kernel_tool_recursion", "kernel tools may not invoke workpool()");
7
- if (options === null || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some(key => key !== "mode")) {
8
- throw new TypeError("workpool() options only accept mode");
7
+ if (options === null || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some(key => key !== "mode" && key !== "tools")) {
8
+ throw new TypeError("workpool() options only accept mode and tools");
9
+ }
10
+ if (options.tools !== undefined && (!Array.isArray(options.tools) || !options.tools.every(name => typeof name === "string"))) {
11
+ throw workpoolError("invalid_tools", `workpool() tools takes an array of tool names; got ${Array.isArray(options.tools) ? "an array with a non-string" : typeof options.tools}`);
9
12
  }
10
13
  const call = async args => {
11
14
  try {
@@ -3,7 +3,8 @@ import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/p
3
3
  import type { EvalKernelRunInput } from "../../tool/types.ts";
4
4
  import type { SessionEnvironment } from "../session-env.ts";
5
5
  import type { KernelLifecycle } from "../shared/kernel-death.ts";
6
- import type { KernelSpawnProcess } from "./process.ts";
6
+ import type { PeerKernelToolsDescribe } from "./kernel-tools-host.ts";
7
+ import type { KernelSpawnProcess, KillProcessGroup } from "./process.ts";
7
8
  import type { PythonStartupStage } from "./startup.ts";
8
9
  import type { PythonTransportResult } from "./transport.ts";
9
10
 
@@ -15,14 +16,22 @@ export interface PythonKernelStartOptions extends KernelLifecycle {
15
16
  readonly env?: NodeJS.ProcessEnv;
16
17
  /** Per-session PI_* values merged into the interpreter environment at spawn. */
17
18
  readonly sessionEnv?: SessionEnvironment;
18
- /** Python bootstrap inactivity guard per advancing stage, not a total readiness deadline. */
19
+ /** How long a starting interpreter may show no stage change, output or CPU use before startup fails. */
19
20
  readonly startupTimeoutMs?: number;
21
+ /** Total startup backstop for an interpreter that stays busy but never becomes ready. */
22
+ readonly startupCeilingMs?: number;
23
+ /** Reads the interpreter's CPU time; the default reads the process group (Unix) or the process (Windows). */
24
+ readonly readCpuTime?: (pid: number | undefined) => bigint | undefined;
25
+ /** Signals the interpreter's process group when it is retired; the default is `process.kill(-pid, signal)`. */
26
+ readonly killProcessGroup?: KillProcessGroup;
20
27
  /** Observes bootstrap control events without mixing them into cell output callbacks. */
21
28
  readonly onStartupProgress?: (stage: PythonStartupStage) => void;
22
29
  readonly onMessage?: (message: KernelToHostMessage) => void;
23
30
  readonly spawnProcess?: KernelSpawnProcess;
24
31
  /** Post-cell collection, notice, and ceiling thresholds sent on `init`; absent leaves memory unmanaged. */
25
32
  readonly memory?: KernelMemoryThresholds;
33
+ /** The JavaScript kernel's describe, so a name defined in both languages is a describe-time collision. */
34
+ readonly peerKernelToolsDescribe?: PeerKernelToolsDescribe;
26
35
  }
27
36
 
28
37
  export type PythonKernelRunOptions = EvalKernelRunInput;
@@ -0,0 +1,97 @@
1
+ import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import { kernelToolError } from "../js/kernel-tools-errors.ts";
3
+ import type {
4
+ KernelToolsDescribeResult,
5
+ KernelToolsInvokeOptions,
6
+ KernelToolsInvokeRequest,
7
+ } from "../js/kernel-tools-types.ts";
8
+ import { KernelToolHostPump } from "../shared/kernel-tools-pump.ts";
9
+
10
+ export type KernelToolReplyEvent = {
11
+ readonly requestId: string;
12
+ readonly ok: boolean;
13
+ readonly code?: string;
14
+ readonly message?: string;
15
+ readonly output?: string;
16
+ };
17
+
18
+ function replyEvent(message: Extract<KernelToHostMessage, { type: "kernel-tool-invoke-reply" }>): KernelToolReplyEvent {
19
+ return {
20
+ requestId: message.requestId,
21
+ ok: message.ok,
22
+ ...(message.ok ? {} : { message: message.error.message }),
23
+ ...(!message.ok && message.error.code !== undefined ? { code: message.error.code } : {}),
24
+ ...(message.output === undefined ? {} : { output: message.output }),
25
+ };
26
+ }
27
+
28
+ export type PeerKernelToolsDescribe = (names: readonly string[]) => Promise<KernelToolsDescribeResult> | undefined;
29
+
30
+ /** The host half of Python kernel tools: frames over the live transport, names, and generation fencing. */
31
+ export class PythonKernelTools {
32
+ readonly #pump: KernelToolHostPump;
33
+ readonly #peerDescribe: PeerKernelToolsDescribe | undefined;
34
+ #names: readonly string[] = [];
35
+
36
+ constructor(options: {
37
+ readonly post: (message: HostToKernelMessage) => void;
38
+ readonly isOpen: () => boolean;
39
+ readonly peerDescribe?: PeerKernelToolsDescribe;
40
+ }) {
41
+ this.#pump = new KernelToolHostPump(options.post, options.isOpen);
42
+ this.#peerDescribe = options.peerDescribe;
43
+ }
44
+
45
+ get events(): EventTarget {
46
+ return this.#pump.events;
47
+ }
48
+
49
+ listNames(): readonly string[] {
50
+ return this.#names;
51
+ }
52
+
53
+ consume(message: KernelToHostMessage): boolean {
54
+ if (message.type === "kernel-tools-defined") {
55
+ this.#names = [...message.names];
56
+ return true;
57
+ }
58
+ if (message.type === "kernel-tool-invoke-reply") {
59
+ // Published even when the waiter is gone (a cancelled call), so its explicit outcome stays observable.
60
+ this.#pump.events.dispatchEvent(
61
+ new CustomEvent<KernelToolReplyEvent>("kernelToolReply", { detail: replyEvent(message) }),
62
+ );
63
+ }
64
+ return this.#pump.consume(message);
65
+ }
66
+
67
+ /** The interpreter is gone (restart, reset, close, death): its definitions and in-flight calls are too. */
68
+ retire(reason: "kernel_tool_stale" | "tools_unavailable", message: string): void {
69
+ this.#names = [];
70
+ this.#pump.rejectAll(kernelToolError(reason, message));
71
+ }
72
+
73
+ async describe(names: readonly string[]): Promise<KernelToolsDescribeResult> {
74
+ const own = await this.#pump.describe(names);
75
+ const peer = await this.#peerDescribe?.(names)?.catch(() => undefined);
76
+ if (!peer) return own;
77
+ const definedInJs = new Set(peer.results.filter((entry) => entry.ok).map((entry) => entry.name));
78
+ return {
79
+ results: own.results.map((entry) =>
80
+ entry.ok && definedInJs.has(entry.name)
81
+ ? {
82
+ name: entry.name,
83
+ ok: false,
84
+ error: {
85
+ code: "tool_name_collision",
86
+ message: `Kernel tool ${entry.name} is defined in both js and py`,
87
+ },
88
+ }
89
+ : entry,
90
+ ),
91
+ };
92
+ }
93
+
94
+ async invoke(request: KernelToolsInvokeRequest, options?: AbortSignal | KernelToolsInvokeOptions): Promise<unknown> {
95
+ return await this.#pump.invoke(request, options);
96
+ }
97
+ }
@@ -1,13 +1,18 @@
1
1
  // allow: SIZE_OK — one persistent Python lifecycle state machine owns queue, generation, and retirement.
2
2
  import type { KernelInterruptHandle, PendingCell } from "../../tool/types.ts";
3
- import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts";
4
- import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
3
+ import type {
4
+ KernelToolsDescribeResult,
5
+ KernelToolsInvokeOptions,
6
+ KernelToolsInvokeRequest,
7
+ } from "../js/kernel-tools-types.ts";
8
+ import { inputAtStart } from "../shared/cell-source-at-start.ts";
5
9
  import { describeExit } from "../shared/kernel-death.ts";
6
10
  import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
7
11
  import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
8
12
  import { runHostCell } from "./host-cell.ts";
9
13
  import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, ResultMessage } from "./kernel-contract.ts";
10
- import { pythonStartupHangGuardMs } from "./startup.ts";
14
+ import { PythonKernelTools } from "./kernel-tools-host.ts";
15
+ import { pythonStartupCeilingMs, pythonStartupHangGuardMs } from "./startup.ts";
11
16
  import { failedPythonResult, PythonKernelTransport } from "./transport.ts";
12
17
 
13
18
  export type { PythonKernelRunOptions, PythonKernelStartOptions } from "./kernel-contract.ts";
@@ -15,6 +20,15 @@ export type { KernelChild, KernelSpawnOptions, KernelSpawnProcess } from "./proc
15
20
 
16
21
  const interruptEscalationMs = 5_000;
17
22
 
23
+ // Every interpreter this process starts gets its own generation, so a kernel tool descriptor taken from one
24
+ // never resolves in another, whether it was replaced by reset, by a lazy restart after a crash, or by a new
25
+ // kernel instance an owner created.
26
+ let lastInterpreterGeneration = 0;
27
+ function nextInterpreterGeneration(): number {
28
+ lastInterpreterGeneration += 1;
29
+ return lastInterpreterGeneration;
30
+ }
31
+
18
32
  export class PythonKernel {
19
33
  readonly #options: PythonKernelStartOptions;
20
34
  #transport: PythonKernelTransport | null = null;
@@ -29,11 +43,17 @@ export class PythonKernel {
29
43
  readonly #memory: KernelMemoryHost | null;
30
44
  #generation = 0;
31
45
  #closed = false;
46
+ readonly #tools: PythonKernelTools;
32
47
  #dead = false;
33
48
 
34
49
  private constructor(options: PythonKernelStartOptions) {
35
50
  this.#options = options;
36
51
  this.#memory = options.memory === undefined ? null : new KernelMemoryHost("py", options.memory);
52
+ this.#tools = new PythonKernelTools({
53
+ post: (message) => this.#transport?.post(message),
54
+ isOpen: () => this.#transport !== null && !this.#closed,
55
+ ...(options.peerKernelToolsDescribe === undefined ? {} : { peerDescribe: options.peerKernelToolsDescribe }),
56
+ });
37
57
  }
38
58
 
39
59
  static async start(options: PythonKernelStartOptions): Promise<PythonKernel> {
@@ -43,7 +63,11 @@ export class PythonKernel {
43
63
  }
44
64
 
45
65
  listKernelToolNames(): readonly string[] {
46
- return [];
66
+ return this.#tools.listNames();
67
+ }
68
+
69
+ get kernelToolEvents(): EventTarget {
70
+ return this.#tools.events;
47
71
  }
48
72
 
49
73
  isAlive(): boolean {
@@ -58,12 +82,15 @@ export class PythonKernel {
58
82
  });
59
83
  }
60
84
 
61
- describeKernelTools(_names: readonly string[]): Promise<never> {
62
- return rejectKernelToolsUnavailable();
85
+ describeKernelTools(names: readonly string[]): Promise<KernelToolsDescribeResult> {
86
+ return this.#tools.describe(names);
63
87
  }
64
88
 
65
- invokeKernelTool(_request: unknown, _options?: AbortSignal | KernelToolsInvokeOptions): Promise<never> {
66
- return rejectKernelToolsUnavailable();
89
+ invokeKernelTool(
90
+ request: KernelToolsInvokeRequest,
91
+ options?: AbortSignal | KernelToolsInvokeOptions,
92
+ ): Promise<unknown> {
93
+ return this.#tools.invoke(request, options);
67
94
  }
68
95
 
69
96
  run(input: PythonKernelRunOptions): Promise<ResultMessage> {
@@ -131,6 +158,7 @@ export class PythonKernel {
131
158
  if (this.#failure) throw this.#failure;
132
159
  if (this.#closed) throw new Error("Python kernel is closed");
133
160
  const generation = ++this.#generation;
161
+ this.#tools.retire("kernel_tool_stale", "Python kernel was reset; its kernel tools are gone");
134
162
  const prior = this.#starting;
135
163
  const operation = (async () => {
136
164
  await prior?.catch(() => undefined);
@@ -156,6 +184,7 @@ export class PythonKernel {
156
184
  if (this.#closePromise) return await this.#closePromise;
157
185
  this.#closed = true;
158
186
  this.#generation += 1;
187
+ this.#tools.retire("tools_unavailable", "Python kernel is closed");
159
188
  this.#settleAllPending("Python kernel closed");
160
189
  const starting = this.#starting;
161
190
  this.#closePromise = (async () => {
@@ -215,10 +244,15 @@ export class PythonKernel {
215
244
  });
216
245
  return;
217
246
  }
247
+ const input = inputAtStart(pending.input);
248
+ if ("refused" in input) {
249
+ this.#settleRun(pending, failedPythonResult(pending.input.cellId, input.refused));
250
+ return;
251
+ }
218
252
  try {
219
253
  this.#transport?.run({
220
- ...pending.input,
221
- preludePlan: this.#preludes.plan(pending.input.kernelPreludes ?? []),
254
+ ...input,
255
+ preludePlan: this.#preludes.plan(input.kernelPreludes ?? []),
222
256
  });
223
257
  } catch (error) {
224
258
  const failure = error instanceof Error ? error : new Error(String(error));
@@ -283,6 +317,7 @@ export class PythonKernel {
283
317
  ...this.#options,
284
318
  onMessage: (message) => {
285
319
  if (message.type === "result") return;
320
+ if (generation === this.#generation && this.#tools.consume(message)) return;
286
321
  const callback =
287
322
  message.type === "ready" || message.type === "init-failed" || message.type === "closed"
288
323
  ? this.#options.onMessage
@@ -290,6 +325,8 @@ export class PythonKernel {
290
325
  callback?.(message);
291
326
  },
292
327
  startupTimeoutMs: this.#options.startupTimeoutMs ?? pythonStartupHangGuardMs,
328
+ startupCeilingMs: this.#options.startupCeilingMs ?? pythonStartupCeilingMs,
329
+ kernelGeneration: nextInterpreterGeneration(),
293
330
  isOwned: () => !this.#closed && generation === this.#generation,
294
331
  onRetirementFailure: (transport, error) => {
295
332
  if (!this.#transport) this.#transport = transport;
@@ -324,6 +361,7 @@ export class PythonKernel {
324
361
  #onExit(transport: PythonKernelTransport, error: Error, reason: string): void {
325
362
  if (this.#transport !== transport) return;
326
363
  this.#transport = null;
364
+ this.#tools.retire("kernel_tool_stale", "Python kernel died; its kernel tools are gone");
327
365
  // With an owner listening, a death is final for this instance: the cells that never started stay
328
366
  // queued for `drainPending` and the owner replaces it. Without one it respawns lazily, as before.
329
367
  const onDeath = this.#options.onDeath;