@oh-my-pi/pi-coding-agent 5.5.0 → 5.6.7
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 +98 -0
- package/docs/python-repl.md +77 -0
- package/examples/hooks/snake.ts +7 -7
- package/package.json +5 -5
- package/src/bun-imports.d.ts +6 -0
- package/src/cli/args.ts +7 -0
- package/src/cli/setup-cli.ts +231 -0
- package/src/cli.ts +2 -0
- package/src/core/agent-session.ts +118 -15
- package/src/core/bash-executor.ts +3 -84
- package/src/core/compaction/compaction.ts +10 -5
- package/src/core/extensions/index.ts +2 -0
- package/src/core/extensions/loader.ts +13 -1
- package/src/core/extensions/runner.ts +50 -2
- package/src/core/extensions/types.ts +67 -2
- package/src/core/keybindings.ts +51 -1
- package/src/core/prompt-templates.ts +15 -0
- package/src/core/python-executor-display.test.ts +42 -0
- package/src/core/python-executor-lifecycle.test.ts +99 -0
- package/src/core/python-executor-mapping.test.ts +41 -0
- package/src/core/python-executor-per-call.test.ts +49 -0
- package/src/core/python-executor-session.test.ts +103 -0
- package/src/core/python-executor-streaming.test.ts +77 -0
- package/src/core/python-executor-timeout.test.ts +35 -0
- package/src/core/python-executor.lifecycle.test.ts +139 -0
- package/src/core/python-executor.result.test.ts +49 -0
- package/src/core/python-executor.test.ts +180 -0
- package/src/core/python-executor.ts +313 -0
- package/src/core/python-gateway-coordinator.ts +832 -0
- package/src/core/python-kernel-display.test.ts +54 -0
- package/src/core/python-kernel-env.test.ts +138 -0
- package/src/core/python-kernel-session.test.ts +87 -0
- package/src/core/python-kernel-ws.test.ts +104 -0
- package/src/core/python-kernel.lifecycle.test.ts +249 -0
- package/src/core/python-kernel.test.ts +549 -0
- package/src/core/python-kernel.ts +1178 -0
- package/src/core/python-prelude.py +889 -0
- package/src/core/python-prelude.test.ts +140 -0
- package/src/core/python-prelude.ts +3 -0
- package/src/core/sdk.ts +24 -6
- package/src/core/session-manager.ts +174 -82
- package/src/core/settings-manager-python.test.ts +23 -0
- package/src/core/settings-manager.ts +202 -0
- package/src/core/streaming-output.test.ts +26 -0
- package/src/core/streaming-output.ts +100 -0
- package/src/core/system-prompt.python.test.ts +17 -0
- package/src/core/system-prompt.ts +3 -1
- package/src/core/timings.ts +1 -1
- package/src/core/tools/bash.ts +13 -2
- package/src/core/tools/edit-diff.ts +9 -1
- package/src/core/tools/index.test.ts +50 -23
- package/src/core/tools/index.ts +83 -1
- package/src/core/tools/python-execution.test.ts +68 -0
- package/src/core/tools/python-fallback.test.ts +72 -0
- package/src/core/tools/python-renderer.test.ts +36 -0
- package/src/core/tools/python-tool-mode.test.ts +43 -0
- package/src/core/tools/python.test.ts +121 -0
- package/src/core/tools/python.ts +760 -0
- package/src/core/tools/renderers.ts +2 -0
- package/src/core/tools/schema-validation.test.ts +1 -0
- package/src/core/tools/task/executor.ts +146 -3
- package/src/core/tools/task/worker-protocol.ts +32 -2
- package/src/core/tools/task/worker.ts +182 -15
- package/src/index.ts +6 -0
- package/src/main.ts +136 -40
- package/src/modes/interactive/components/custom-editor.ts +16 -31
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
- package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
- package/src/modes/interactive/components/history-search.ts +5 -8
- package/src/modes/interactive/components/hook-editor.ts +3 -4
- package/src/modes/interactive/components/hook-input.ts +3 -3
- package/src/modes/interactive/components/hook-selector.ts +5 -15
- package/src/modes/interactive/components/index.ts +1 -0
- package/src/modes/interactive/components/keybinding-hints.ts +66 -0
- package/src/modes/interactive/components/model-selector.ts +53 -66
- package/src/modes/interactive/components/oauth-selector.ts +5 -5
- package/src/modes/interactive/components/session-selector.ts +29 -23
- package/src/modes/interactive/components/settings-defs.ts +404 -196
- package/src/modes/interactive/components/settings-selector.ts +14 -10
- package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
- package/src/modes/interactive/components/tool-execution.ts +8 -0
- package/src/modes/interactive/components/tree-selector.ts +29 -23
- package/src/modes/interactive/components/user-message-selector.ts +6 -17
- package/src/modes/interactive/controllers/command-controller.ts +86 -37
- package/src/modes/interactive/controllers/event-controller.ts +8 -0
- package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
- package/src/modes/interactive/controllers/input-controller.ts +42 -6
- package/src/modes/interactive/interactive-mode.ts +56 -30
- package/src/modes/interactive/theme/theme-schema.json +2 -2
- package/src/modes/interactive/types.ts +6 -1
- package/src/modes/interactive/utils/ui-helpers.ts +2 -1
- package/src/modes/print-mode.ts +23 -0
- package/src/modes/rpc/rpc-mode.ts +21 -0
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/system/system-prompt.md +32 -1
- package/src/prompts/tools/python.md +91 -0
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
import { delimiter, join } from "node:path";
|
|
3
|
+
import type { Subprocess } from "bun";
|
|
4
|
+
import { nanoid } from "nanoid";
|
|
5
|
+
import { getShellConfig, killProcessTree } from "../utils/shell";
|
|
6
|
+
import { getOrCreateSnapshot } from "../utils/shell-snapshot";
|
|
7
|
+
import { logger } from "./logger";
|
|
8
|
+
import { acquireSharedGateway, releaseSharedGateway } from "./python-gateway-coordinator";
|
|
9
|
+
import { PYTHON_PRELUDE } from "./python-prelude";
|
|
10
|
+
import { htmlToBasicMarkdown } from "./tools/web-scrapers/types";
|
|
11
|
+
import { ScopeSignal } from "./utils";
|
|
12
|
+
|
|
13
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
14
|
+
const TEXT_DECODER = new TextDecoder();
|
|
15
|
+
const HEARTBEAT_INTERVAL_MS = 5000;
|
|
16
|
+
const HEARTBEAT_TIMEOUT_MS = 2000;
|
|
17
|
+
const HEARTBEAT_FAILURE_LIMIT = 1;
|
|
18
|
+
const GATEWAY_STARTUP_TIMEOUT_MS = 30000;
|
|
19
|
+
const GATEWAY_STARTUP_ATTEMPTS = 3;
|
|
20
|
+
const TRACE_IPC = process.env.OMP_PYTHON_IPC_TRACE === "1";
|
|
21
|
+
const PRELUDE_INTROSPECTION_SNIPPET = "import json\nprint(json.dumps(__omp_prelude_docs__()))";
|
|
22
|
+
|
|
23
|
+
interface ExternalGatewayConfig {
|
|
24
|
+
url: string;
|
|
25
|
+
token?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getExternalGatewayConfig(): ExternalGatewayConfig | null {
|
|
29
|
+
const url = process.env.OMP_PYTHON_GATEWAY_URL;
|
|
30
|
+
if (!url) return null;
|
|
31
|
+
return {
|
|
32
|
+
url: url.replace(/\/$/, ""),
|
|
33
|
+
token: process.env.OMP_PYTHON_GATEWAY_TOKEN,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_ENV_ALLOWLIST = new Set([
|
|
38
|
+
"PATH",
|
|
39
|
+
"HOME",
|
|
40
|
+
"USER",
|
|
41
|
+
"LOGNAME",
|
|
42
|
+
"SHELL",
|
|
43
|
+
"LANG",
|
|
44
|
+
"LC_ALL",
|
|
45
|
+
"LC_CTYPE",
|
|
46
|
+
"LC_MESSAGES",
|
|
47
|
+
"TERM",
|
|
48
|
+
"TERM_PROGRAM",
|
|
49
|
+
"TERM_PROGRAM_VERSION",
|
|
50
|
+
"TMPDIR",
|
|
51
|
+
"TEMP",
|
|
52
|
+
"TMP",
|
|
53
|
+
"XDG_CACHE_HOME",
|
|
54
|
+
"XDG_CONFIG_HOME",
|
|
55
|
+
"XDG_DATA_HOME",
|
|
56
|
+
"XDG_RUNTIME_DIR",
|
|
57
|
+
"SSH_AUTH_SOCK",
|
|
58
|
+
"SSH_AGENT_PID",
|
|
59
|
+
"CONDA_PREFIX",
|
|
60
|
+
"CONDA_DEFAULT_ENV",
|
|
61
|
+
"VIRTUAL_ENV",
|
|
62
|
+
"PYTHONPATH",
|
|
63
|
+
"APPDATA",
|
|
64
|
+
"COMSPEC",
|
|
65
|
+
"COMPUTERNAME",
|
|
66
|
+
"HOMEDRIVE",
|
|
67
|
+
"HOMEPATH",
|
|
68
|
+
"LOCALAPPDATA",
|
|
69
|
+
"NUMBER_OF_PROCESSORS",
|
|
70
|
+
"OS",
|
|
71
|
+
"PATHEXT",
|
|
72
|
+
"PROCESSOR_ARCHITECTURE",
|
|
73
|
+
"PROCESSOR_IDENTIFIER",
|
|
74
|
+
"PROGRAMDATA",
|
|
75
|
+
"PROGRAMFILES",
|
|
76
|
+
"PROGRAMFILES(X86)",
|
|
77
|
+
"PROGRAMW6432",
|
|
78
|
+
"SYSTEMDRIVE",
|
|
79
|
+
"SYSTEMROOT",
|
|
80
|
+
"USERDOMAIN",
|
|
81
|
+
"USERPROFILE",
|
|
82
|
+
"USERNAME",
|
|
83
|
+
"WINDIR",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const WINDOWS_ENV_ALLOWLIST = new Set([
|
|
87
|
+
"APPDATA",
|
|
88
|
+
"COMPUTERNAME",
|
|
89
|
+
"COMSPEC",
|
|
90
|
+
"HOMEDRIVE",
|
|
91
|
+
"HOMEPATH",
|
|
92
|
+
"LOCALAPPDATA",
|
|
93
|
+
"NUMBER_OF_PROCESSORS",
|
|
94
|
+
"OS",
|
|
95
|
+
"PATH",
|
|
96
|
+
"PATHEXT",
|
|
97
|
+
"PROCESSOR_ARCHITECTURE",
|
|
98
|
+
"PROCESSOR_IDENTIFIER",
|
|
99
|
+
"PROGRAMDATA",
|
|
100
|
+
"PROGRAMFILES",
|
|
101
|
+
"PROGRAMFILES(X86)",
|
|
102
|
+
"PROGRAMW6432",
|
|
103
|
+
"SESSIONNAME",
|
|
104
|
+
"SYSTEMDRIVE",
|
|
105
|
+
"SYSTEMROOT",
|
|
106
|
+
"TEMP",
|
|
107
|
+
"TMP",
|
|
108
|
+
"USERDOMAIN",
|
|
109
|
+
"USERDOMAIN_ROAMINGPROFILE",
|
|
110
|
+
"USERPROFILE",
|
|
111
|
+
"USERNAME",
|
|
112
|
+
"WINDIR",
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const DEFAULT_ENV_ALLOW_PREFIXES = ["LC_", "XDG_", "OMP_"];
|
|
116
|
+
|
|
117
|
+
const DEFAULT_ENV_DENYLIST = new Set([
|
|
118
|
+
"OPENAI_API_KEY",
|
|
119
|
+
"ANTHROPIC_API_KEY",
|
|
120
|
+
"GOOGLE_API_KEY",
|
|
121
|
+
"GEMINI_API_KEY",
|
|
122
|
+
"OPENROUTER_API_KEY",
|
|
123
|
+
"PERPLEXITY_API_KEY",
|
|
124
|
+
"EXA_API_KEY",
|
|
125
|
+
"AZURE_OPENAI_API_KEY",
|
|
126
|
+
"MISTRAL_API_KEY",
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
const CASE_INSENSITIVE_ENV = process.platform === "win32";
|
|
130
|
+
const BASE_ENV_ALLOWLIST = CASE_INSENSITIVE_ENV
|
|
131
|
+
? new Set([...DEFAULT_ENV_ALLOWLIST, ...WINDOWS_ENV_ALLOWLIST])
|
|
132
|
+
: DEFAULT_ENV_ALLOWLIST;
|
|
133
|
+
const NORMALIZED_ALLOWLIST = new Set(
|
|
134
|
+
Array.from(BASE_ENV_ALLOWLIST, (key) => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)),
|
|
135
|
+
);
|
|
136
|
+
const NORMALIZED_DENYLIST = new Set(
|
|
137
|
+
Array.from(DEFAULT_ENV_DENYLIST, (key) => (CASE_INSENSITIVE_ENV ? key.toUpperCase() : key)),
|
|
138
|
+
);
|
|
139
|
+
const NORMALIZED_ALLOW_PREFIXES = CASE_INSENSITIVE_ENV
|
|
140
|
+
? DEFAULT_ENV_ALLOW_PREFIXES.map((prefix) => prefix.toUpperCase())
|
|
141
|
+
: DEFAULT_ENV_ALLOW_PREFIXES;
|
|
142
|
+
|
|
143
|
+
function normalizeEnvKey(key: string): string {
|
|
144
|
+
return CASE_INSENSITIVE_ENV ? key.toUpperCase() : key;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function resolvePathKey(env: Record<string, string | undefined>): string {
|
|
148
|
+
if (!CASE_INSENSITIVE_ENV) return "PATH";
|
|
149
|
+
const match = Object.keys(env).find((candidate) => candidate.toLowerCase() === "path");
|
|
150
|
+
return match ?? "PATH";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface JupyterHeader {
|
|
154
|
+
msg_id: string;
|
|
155
|
+
session: string;
|
|
156
|
+
username: string;
|
|
157
|
+
date: string;
|
|
158
|
+
msg_type: string;
|
|
159
|
+
version: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface JupyterMessage {
|
|
163
|
+
channel: string;
|
|
164
|
+
header: JupyterHeader;
|
|
165
|
+
parent_header: Record<string, unknown>;
|
|
166
|
+
metadata: Record<string, unknown>;
|
|
167
|
+
content: Record<string, unknown>;
|
|
168
|
+
buffers?: Uint8Array[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Status event emitted by prelude helpers for TUI rendering. */
|
|
172
|
+
export interface PythonStatusEvent {
|
|
173
|
+
/** Operation name (e.g., "find", "read", "write") */
|
|
174
|
+
op: string;
|
|
175
|
+
/** Additional data fields (count, path, pattern, etc.) */
|
|
176
|
+
[key: string]: unknown;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export type KernelDisplayOutput =
|
|
180
|
+
| { type: "json"; data: unknown }
|
|
181
|
+
| { type: "image"; data: string; mimeType: string }
|
|
182
|
+
| { type: "status"; event: PythonStatusEvent };
|
|
183
|
+
|
|
184
|
+
export interface KernelExecuteOptions {
|
|
185
|
+
signal?: AbortSignal;
|
|
186
|
+
onChunk?: (text: string) => Promise<void> | void;
|
|
187
|
+
onDisplay?: (output: KernelDisplayOutput) => Promise<void> | void;
|
|
188
|
+
timeoutMs?: number;
|
|
189
|
+
silent?: boolean;
|
|
190
|
+
storeHistory?: boolean;
|
|
191
|
+
allowStdin?: boolean;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface KernelExecuteResult {
|
|
195
|
+
status: "ok" | "error";
|
|
196
|
+
executionCount?: number;
|
|
197
|
+
error?: { name: string; value: string; traceback: string[] };
|
|
198
|
+
cancelled: boolean;
|
|
199
|
+
timedOut: boolean;
|
|
200
|
+
stdinRequested: boolean;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface PreludeHelper {
|
|
204
|
+
name: string;
|
|
205
|
+
signature: string;
|
|
206
|
+
docstring: string;
|
|
207
|
+
category: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface KernelStartOptions {
|
|
211
|
+
cwd: string;
|
|
212
|
+
env?: Record<string, string | undefined>;
|
|
213
|
+
useSharedGateway?: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface PythonKernelAvailability {
|
|
217
|
+
ok: boolean;
|
|
218
|
+
pythonPath?: string;
|
|
219
|
+
reason?: string;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function filterEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
|
223
|
+
const filtered: Record<string, string | undefined> = {};
|
|
224
|
+
for (const [key, value] of Object.entries(env)) {
|
|
225
|
+
if (value === undefined) continue;
|
|
226
|
+
const normalizedKey = normalizeEnvKey(key);
|
|
227
|
+
if (NORMALIZED_DENYLIST.has(normalizedKey)) continue;
|
|
228
|
+
if (NORMALIZED_ALLOWLIST.has(normalizedKey)) {
|
|
229
|
+
const destKey = normalizedKey === "PATH" ? "PATH" : key;
|
|
230
|
+
filtered[destKey] = value;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (NORMALIZED_ALLOW_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix))) {
|
|
234
|
+
filtered[key] = value;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return filtered;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function resolveVenvPath(cwd: string): Promise<string | null> {
|
|
241
|
+
if (process.env.VIRTUAL_ENV) return process.env.VIRTUAL_ENV;
|
|
242
|
+
const candidates = [join(cwd, ".venv"), join(cwd, "venv")];
|
|
243
|
+
for (const candidate of candidates) {
|
|
244
|
+
if (await Bun.file(candidate).exists()) {
|
|
245
|
+
return candidate;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>) {
|
|
252
|
+
const env = { ...baseEnv };
|
|
253
|
+
const venvPath = env.VIRTUAL_ENV ?? (await resolveVenvPath(cwd));
|
|
254
|
+
if (venvPath) {
|
|
255
|
+
env.VIRTUAL_ENV = venvPath;
|
|
256
|
+
const binDir = process.platform === "win32" ? join(venvPath, "Scripts") : join(venvPath, "bin");
|
|
257
|
+
const pythonCandidate = join(binDir, process.platform === "win32" ? "python.exe" : "python");
|
|
258
|
+
if (await Bun.file(pythonCandidate).exists()) {
|
|
259
|
+
const pathKey = resolvePathKey(env);
|
|
260
|
+
const currentPath = env[pathKey];
|
|
261
|
+
env[pathKey] = currentPath ? `${binDir}${delimiter}${currentPath}` : binDir;
|
|
262
|
+
return { pythonPath: pythonCandidate, env };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const pythonPath = Bun.which("python") ?? Bun.which("python3");
|
|
267
|
+
if (!pythonPath) {
|
|
268
|
+
throw new Error("Python executable not found on PATH");
|
|
269
|
+
}
|
|
270
|
+
return { pythonPath, env };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function checkPythonKernelAvailability(cwd: string): Promise<PythonKernelAvailability> {
|
|
274
|
+
if (process.env.BUN_ENV === "test" || process.env.NODE_ENV === "test" || process.env.OMP_PYTHON_SKIP_CHECK === "1") {
|
|
275
|
+
return { ok: true };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const externalConfig = getExternalGatewayConfig();
|
|
279
|
+
if (externalConfig) {
|
|
280
|
+
return checkExternalGatewayAvailability(externalConfig);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
const { env } = await getShellConfig();
|
|
285
|
+
const baseEnv = filterEnv(env);
|
|
286
|
+
const runtime = await resolvePythonRuntime(cwd, baseEnv);
|
|
287
|
+
const result = Bun.spawnSync(
|
|
288
|
+
[
|
|
289
|
+
runtime.pythonPath,
|
|
290
|
+
"-c",
|
|
291
|
+
"import importlib.util,sys;sys.exit(0 if importlib.util.find_spec('kernel_gateway') and importlib.util.find_spec('ipykernel') else 1)",
|
|
292
|
+
],
|
|
293
|
+
{ cwd, env: runtime.env, stdin: "ignore", stdout: "pipe", stderr: "pipe" },
|
|
294
|
+
);
|
|
295
|
+
if (result.exitCode === 0) {
|
|
296
|
+
return { ok: true, pythonPath: runtime.pythonPath };
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
pythonPath: runtime.pythonPath,
|
|
301
|
+
reason:
|
|
302
|
+
"kernel_gateway (jupyter-kernel-gateway) or ipykernel not installed. Run: python -m pip install jupyter_kernel_gateway ipykernel",
|
|
303
|
+
};
|
|
304
|
+
} catch (err: unknown) {
|
|
305
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function checkExternalGatewayAvailability(config: ExternalGatewayConfig): Promise<PythonKernelAvailability> {
|
|
310
|
+
try {
|
|
311
|
+
const headers: Record<string, string> = {};
|
|
312
|
+
if (config.token) {
|
|
313
|
+
headers.Authorization = `token ${config.token}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const controller = new AbortController();
|
|
317
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
318
|
+
|
|
319
|
+
const response = await fetch(`${config.url}/api/kernelspecs`, {
|
|
320
|
+
headers,
|
|
321
|
+
signal: controller.signal,
|
|
322
|
+
});
|
|
323
|
+
clearTimeout(timeout);
|
|
324
|
+
|
|
325
|
+
if (response.ok) {
|
|
326
|
+
return { ok: true };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (response.status === 401 || response.status === 403) {
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
reason: `External gateway at ${config.url} requires authentication. Set OMP_PYTHON_GATEWAY_TOKEN.`,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
ok: false,
|
|
338
|
+
reason: `External gateway at ${config.url} returned status ${response.status}`,
|
|
339
|
+
};
|
|
340
|
+
} catch (err: unknown) {
|
|
341
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
342
|
+
if (message.includes("abort") || message.includes("timeout")) {
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
reason: `External gateway at ${config.url} is not reachable (timeout)`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
reason: `External gateway at ${config.url} is not reachable: ${message}`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function allocatePort(): Promise<number> {
|
|
356
|
+
return await new Promise((resolve, reject) => {
|
|
357
|
+
const server = createServer();
|
|
358
|
+
server.unref();
|
|
359
|
+
server.on("error", reject);
|
|
360
|
+
server.listen(0, "127.0.0.1", () => {
|
|
361
|
+
const address = server.address();
|
|
362
|
+
if (address && typeof address === "object") {
|
|
363
|
+
const port = address.port;
|
|
364
|
+
server.close((err: Error | null | undefined) => {
|
|
365
|
+
if (err) {
|
|
366
|
+
reject(err);
|
|
367
|
+
} else {
|
|
368
|
+
resolve(port);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
} else {
|
|
372
|
+
server.close();
|
|
373
|
+
reject(new Error("Failed to allocate port"));
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function normalizeDisplayText(text: string): string {
|
|
380
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function deserializeWebSocketMessage(data: ArrayBuffer): JupyterMessage | null {
|
|
384
|
+
const view = new DataView(data);
|
|
385
|
+
const offsetCount = view.getUint32(0, true);
|
|
386
|
+
|
|
387
|
+
if (offsetCount < 1) return null;
|
|
388
|
+
|
|
389
|
+
const offsets: number[] = [];
|
|
390
|
+
for (let i = 0; i < offsetCount; i++) {
|
|
391
|
+
offsets.push(view.getUint32(4 + i * 4, true));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const msgStart = offsets[0];
|
|
395
|
+
const msgEnd = offsets.length > 1 ? offsets[1] : data.byteLength;
|
|
396
|
+
const msgBytes = new Uint8Array(data, msgStart, msgEnd - msgStart);
|
|
397
|
+
const msgText = TEXT_DECODER.decode(msgBytes);
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const msg = JSON.parse(msgText) as {
|
|
401
|
+
channel: string;
|
|
402
|
+
header: JupyterHeader;
|
|
403
|
+
parent_header: Record<string, unknown>;
|
|
404
|
+
metadata: Record<string, unknown>;
|
|
405
|
+
content: Record<string, unknown>;
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const buffers: Uint8Array[] = [];
|
|
409
|
+
for (let i = 1; i < offsets.length; i++) {
|
|
410
|
+
const start = offsets[i];
|
|
411
|
+
const end = i + 1 < offsets.length ? offsets[i + 1] : data.byteLength;
|
|
412
|
+
buffers.push(new Uint8Array(data, start, end - start));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return { ...msg, buffers };
|
|
416
|
+
} catch {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export function serializeWebSocketMessage(msg: JupyterMessage): ArrayBuffer {
|
|
422
|
+
const msgText = JSON.stringify({
|
|
423
|
+
channel: msg.channel,
|
|
424
|
+
header: msg.header,
|
|
425
|
+
parent_header: msg.parent_header,
|
|
426
|
+
metadata: msg.metadata,
|
|
427
|
+
content: msg.content,
|
|
428
|
+
});
|
|
429
|
+
const msgBytes = TEXT_ENCODER.encode(msgText);
|
|
430
|
+
|
|
431
|
+
const buffers = msg.buffers ?? [];
|
|
432
|
+
const offsetCount = 1 + buffers.length;
|
|
433
|
+
const headerSize = 4 + offsetCount * 4;
|
|
434
|
+
|
|
435
|
+
let totalSize = headerSize + msgBytes.length;
|
|
436
|
+
for (const buf of buffers) {
|
|
437
|
+
totalSize += buf.length;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const result = new ArrayBuffer(totalSize);
|
|
441
|
+
const view = new DataView(result);
|
|
442
|
+
const bytes = new Uint8Array(result);
|
|
443
|
+
|
|
444
|
+
view.setUint32(0, offsetCount, true);
|
|
445
|
+
|
|
446
|
+
let offset = headerSize;
|
|
447
|
+
view.setUint32(4, offset, true);
|
|
448
|
+
bytes.set(msgBytes, offset);
|
|
449
|
+
offset += msgBytes.length;
|
|
450
|
+
|
|
451
|
+
for (let i = 0; i < buffers.length; i++) {
|
|
452
|
+
view.setUint32(4 + (i + 1) * 4, offset, true);
|
|
453
|
+
bytes.set(buffers[i], offset);
|
|
454
|
+
offset += buffers[i].length;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return result;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export class PythonKernel {
|
|
461
|
+
readonly id: string;
|
|
462
|
+
readonly kernelId: string;
|
|
463
|
+
readonly gatewayProcess: Subprocess | null;
|
|
464
|
+
readonly gatewayUrl: string;
|
|
465
|
+
readonly sessionId: string;
|
|
466
|
+
readonly username: string;
|
|
467
|
+
readonly isSharedGateway: boolean;
|
|
468
|
+
readonly #authToken?: string;
|
|
469
|
+
|
|
470
|
+
#ws: WebSocket | null = null;
|
|
471
|
+
#disposed = false;
|
|
472
|
+
#alive = true;
|
|
473
|
+
#heartbeatTimer?: NodeJS.Timeout;
|
|
474
|
+
#heartbeatFailures = 0;
|
|
475
|
+
#messageHandlers = new Map<string, (msg: JupyterMessage) => void>();
|
|
476
|
+
#channelHandlers = new Map<string, Set<(msg: JupyterMessage) => void>>();
|
|
477
|
+
#pendingExecutions = new Map<string, (reason: string) => void>();
|
|
478
|
+
|
|
479
|
+
private constructor(
|
|
480
|
+
id: string,
|
|
481
|
+
kernelId: string,
|
|
482
|
+
gatewayProcess: Subprocess | null,
|
|
483
|
+
gatewayUrl: string,
|
|
484
|
+
sessionId: string,
|
|
485
|
+
username: string,
|
|
486
|
+
isSharedGateway: boolean,
|
|
487
|
+
authToken?: string,
|
|
488
|
+
) {
|
|
489
|
+
this.id = id;
|
|
490
|
+
this.kernelId = kernelId;
|
|
491
|
+
this.gatewayProcess = gatewayProcess;
|
|
492
|
+
this.gatewayUrl = gatewayUrl;
|
|
493
|
+
this.sessionId = sessionId;
|
|
494
|
+
this.username = username;
|
|
495
|
+
this.isSharedGateway = isSharedGateway;
|
|
496
|
+
this.#authToken = authToken;
|
|
497
|
+
|
|
498
|
+
if (this.gatewayProcess) {
|
|
499
|
+
this.gatewayProcess.exited.then(() => {
|
|
500
|
+
this.#alive = false;
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
#authHeaders(): Record<string, string> {
|
|
506
|
+
if (!this.#authToken) return {};
|
|
507
|
+
return { Authorization: `token ${this.#authToken}` };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
static async start(options: KernelStartOptions): Promise<PythonKernel> {
|
|
511
|
+
const availability = await checkPythonKernelAvailability(options.cwd);
|
|
512
|
+
if (!availability.ok) {
|
|
513
|
+
throw new Error(availability.reason ?? "Python kernel unavailable");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const externalConfig = getExternalGatewayConfig();
|
|
517
|
+
if (externalConfig) {
|
|
518
|
+
return PythonKernel.startWithExternalGateway(externalConfig);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Try shared gateway first (unless explicitly disabled)
|
|
522
|
+
if (options.useSharedGateway !== false) {
|
|
523
|
+
try {
|
|
524
|
+
const sharedResult = await acquireSharedGateway(options.cwd);
|
|
525
|
+
if (sharedResult) {
|
|
526
|
+
return PythonKernel.startWithSharedGateway(sharedResult.url, options.cwd);
|
|
527
|
+
}
|
|
528
|
+
} catch (err) {
|
|
529
|
+
logger.warn("Failed to acquire shared gateway, falling back to local", {
|
|
530
|
+
error: err instanceof Error ? err.message : String(err),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return PythonKernel.startWithLocalGateway(options);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private static async startWithExternalGateway(config: ExternalGatewayConfig): Promise<PythonKernel> {
|
|
539
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
540
|
+
if (config.token) {
|
|
541
|
+
headers.Authorization = `token ${config.token}`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const createResponse = await fetch(`${config.url}/api/kernels`, {
|
|
545
|
+
method: "POST",
|
|
546
|
+
headers,
|
|
547
|
+
body: JSON.stringify({ name: "python3" }),
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
if (!createResponse.ok) {
|
|
551
|
+
throw new Error(`Failed to create kernel on external gateway: ${await createResponse.text()}`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const kernelInfo = (await createResponse.json()) as { id: string };
|
|
555
|
+
const kernelId = kernelInfo.id;
|
|
556
|
+
|
|
557
|
+
const kernel = new PythonKernel(nanoid(), kernelId, null, config.url, nanoid(), "omp", false, config.token);
|
|
558
|
+
|
|
559
|
+
try {
|
|
560
|
+
await kernel.connectWebSocket();
|
|
561
|
+
kernel.startHeartbeat();
|
|
562
|
+
const preludeResult = await kernel.execute(PYTHON_PRELUDE, { silent: true, storeHistory: false });
|
|
563
|
+
if (preludeResult.cancelled || preludeResult.status === "error") {
|
|
564
|
+
throw new Error("Failed to initialize Python kernel prelude");
|
|
565
|
+
}
|
|
566
|
+
return kernel;
|
|
567
|
+
} catch (err: unknown) {
|
|
568
|
+
await kernel.shutdown();
|
|
569
|
+
throw err;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private static async startWithSharedGateway(gatewayUrl: string, _cwd: string): Promise<PythonKernel> {
|
|
574
|
+
const createResponse = await fetch(`${gatewayUrl}/api/kernels`, {
|
|
575
|
+
method: "POST",
|
|
576
|
+
headers: { "Content-Type": "application/json" },
|
|
577
|
+
body: JSON.stringify({ name: "python3" }),
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
if (!createResponse.ok) {
|
|
581
|
+
await releaseSharedGateway();
|
|
582
|
+
throw new Error(`Failed to create kernel on shared gateway: ${await createResponse.text()}`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const kernelInfo = (await createResponse.json()) as { id: string };
|
|
586
|
+
const kernelId = kernelInfo.id;
|
|
587
|
+
|
|
588
|
+
const kernel = new PythonKernel(nanoid(), kernelId, null, gatewayUrl, nanoid(), "omp", true);
|
|
589
|
+
|
|
590
|
+
try {
|
|
591
|
+
await kernel.connectWebSocket();
|
|
592
|
+
kernel.startHeartbeat();
|
|
593
|
+
const preludeResult = await kernel.execute(PYTHON_PRELUDE, { silent: true, storeHistory: false });
|
|
594
|
+
if (preludeResult.cancelled || preludeResult.status === "error") {
|
|
595
|
+
throw new Error("Failed to initialize Python kernel prelude");
|
|
596
|
+
}
|
|
597
|
+
return kernel;
|
|
598
|
+
} catch (err: unknown) {
|
|
599
|
+
await kernel.shutdown();
|
|
600
|
+
throw err;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private static async startWithLocalGateway(options: KernelStartOptions): Promise<PythonKernel> {
|
|
605
|
+
const { shell, env } = await getShellConfig();
|
|
606
|
+
const filteredEnv = filterEnv(env);
|
|
607
|
+
const runtime = await resolvePythonRuntime(options.cwd, filteredEnv);
|
|
608
|
+
const snapshotPath = await getOrCreateSnapshot(shell, env).catch((err: unknown) => {
|
|
609
|
+
logger.warn("Failed to resolve shell snapshot for Python kernel", {
|
|
610
|
+
error: err instanceof Error ? err.message : String(err),
|
|
611
|
+
});
|
|
612
|
+
return null;
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const kernelEnv: Record<string, string | undefined> = {
|
|
616
|
+
...runtime.env,
|
|
617
|
+
...options.env,
|
|
618
|
+
PYTHONUNBUFFERED: "1",
|
|
619
|
+
OMP_SHELL_SNAPSHOT: snapshotPath ?? undefined,
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const pythonPathParts = [options.cwd, kernelEnv.PYTHONPATH].filter(Boolean).join(delimiter);
|
|
623
|
+
if (pythonPathParts) {
|
|
624
|
+
kernelEnv.PYTHONPATH = pythonPathParts;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
let gatewayProcess: Subprocess | null = null;
|
|
628
|
+
let gatewayUrl: string | null = null;
|
|
629
|
+
let lastError: string | null = null;
|
|
630
|
+
|
|
631
|
+
for (let attempt = 0; attempt < GATEWAY_STARTUP_ATTEMPTS; attempt += 1) {
|
|
632
|
+
const gatewayPort = await allocatePort();
|
|
633
|
+
const candidateUrl = `http://127.0.0.1:${gatewayPort}`;
|
|
634
|
+
const candidateProcess = Bun.spawn(
|
|
635
|
+
[
|
|
636
|
+
runtime.pythonPath,
|
|
637
|
+
"-m",
|
|
638
|
+
"kernel_gateway",
|
|
639
|
+
"--KernelGatewayApp.ip=127.0.0.1",
|
|
640
|
+
`--KernelGatewayApp.port=${gatewayPort}`,
|
|
641
|
+
"--KernelGatewayApp.port_retries=0",
|
|
642
|
+
"--KernelGatewayApp.allow_origin=*",
|
|
643
|
+
"--JupyterApp.answer_yes=true",
|
|
644
|
+
],
|
|
645
|
+
{
|
|
646
|
+
cwd: options.cwd,
|
|
647
|
+
stdin: "ignore",
|
|
648
|
+
stdout: "pipe",
|
|
649
|
+
stderr: "pipe",
|
|
650
|
+
env: kernelEnv,
|
|
651
|
+
},
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
let exited = false;
|
|
655
|
+
candidateProcess.exited
|
|
656
|
+
.then(() => {
|
|
657
|
+
exited = true;
|
|
658
|
+
})
|
|
659
|
+
.catch(() => {
|
|
660
|
+
exited = true;
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
const startTime = Date.now();
|
|
664
|
+
while (Date.now() - startTime < GATEWAY_STARTUP_TIMEOUT_MS) {
|
|
665
|
+
if (exited) break;
|
|
666
|
+
try {
|
|
667
|
+
const response = await fetch(`${candidateUrl}/api/kernelspecs`);
|
|
668
|
+
if (response.ok) {
|
|
669
|
+
gatewayProcess = candidateProcess;
|
|
670
|
+
gatewayUrl = candidateUrl;
|
|
671
|
+
break;
|
|
672
|
+
}
|
|
673
|
+
} catch {
|
|
674
|
+
// Gateway not ready yet
|
|
675
|
+
}
|
|
676
|
+
await Bun.sleep(100);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (gatewayProcess && gatewayUrl) break;
|
|
680
|
+
|
|
681
|
+
killProcessTree(candidateProcess.pid);
|
|
682
|
+
lastError = exited ? "Kernel gateway process exited during startup" : "Kernel gateway failed to start";
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (!gatewayProcess || !gatewayUrl) {
|
|
686
|
+
throw new Error(lastError ?? "Kernel gateway failed to start");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const createResponse = await fetch(`${gatewayUrl}/api/kernels`, {
|
|
690
|
+
method: "POST",
|
|
691
|
+
headers: { "Content-Type": "application/json" },
|
|
692
|
+
body: JSON.stringify({ name: "python3" }),
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
if (!createResponse.ok) {
|
|
696
|
+
killProcessTree(gatewayProcess.pid);
|
|
697
|
+
throw new Error(`Failed to create kernel: ${await createResponse.text()}`);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const kernelInfo = (await createResponse.json()) as { id: string };
|
|
701
|
+
const kernelId = kernelInfo.id;
|
|
702
|
+
|
|
703
|
+
const kernel = new PythonKernel(nanoid(), kernelId, gatewayProcess, gatewayUrl, nanoid(), "omp", false);
|
|
704
|
+
|
|
705
|
+
try {
|
|
706
|
+
await kernel.connectWebSocket();
|
|
707
|
+
kernel.startHeartbeat();
|
|
708
|
+
const preludeResult = await kernel.execute(PYTHON_PRELUDE, { silent: true, storeHistory: false });
|
|
709
|
+
if (preludeResult.cancelled || preludeResult.status === "error") {
|
|
710
|
+
throw new Error("Failed to initialize Python kernel prelude");
|
|
711
|
+
}
|
|
712
|
+
return kernel;
|
|
713
|
+
} catch (err: unknown) {
|
|
714
|
+
await kernel.shutdown();
|
|
715
|
+
throw err;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private async connectWebSocket(): Promise<void> {
|
|
720
|
+
const wsBase = this.gatewayUrl.replace(/^http/, "ws");
|
|
721
|
+
let wsUrl = `${wsBase}/api/kernels/${this.kernelId}/channels`;
|
|
722
|
+
if (this.#authToken) {
|
|
723
|
+
wsUrl += `?token=${encodeURIComponent(this.#authToken)}`;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
return new Promise((resolve, reject) => {
|
|
727
|
+
const ws = new WebSocket(wsUrl);
|
|
728
|
+
ws.binaryType = "arraybuffer";
|
|
729
|
+
let settled = false;
|
|
730
|
+
|
|
731
|
+
const timeout = setTimeout(() => {
|
|
732
|
+
ws.close();
|
|
733
|
+
if (!settled) {
|
|
734
|
+
settled = true;
|
|
735
|
+
reject(new Error("WebSocket connection timeout"));
|
|
736
|
+
}
|
|
737
|
+
}, 10000);
|
|
738
|
+
|
|
739
|
+
ws.onopen = () => {
|
|
740
|
+
if (settled) return;
|
|
741
|
+
settled = true;
|
|
742
|
+
clearTimeout(timeout);
|
|
743
|
+
this.#ws = ws;
|
|
744
|
+
resolve();
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
ws.onerror = (event) => {
|
|
748
|
+
const error = new Error(`WebSocket error: ${event}`);
|
|
749
|
+
if (!settled) {
|
|
750
|
+
settled = true;
|
|
751
|
+
clearTimeout(timeout);
|
|
752
|
+
reject(error);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
this.#alive = false;
|
|
756
|
+
this.#ws = null;
|
|
757
|
+
this.abortPendingExecutions(error.message);
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
ws.onclose = () => {
|
|
761
|
+
this.#alive = false;
|
|
762
|
+
this.#ws = null;
|
|
763
|
+
if (!settled) {
|
|
764
|
+
settled = true;
|
|
765
|
+
clearTimeout(timeout);
|
|
766
|
+
reject(new Error("WebSocket closed before connection"));
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
this.abortPendingExecutions("WebSocket closed");
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
ws.onmessage = (event) => {
|
|
773
|
+
let msg: JupyterMessage | null = null;
|
|
774
|
+
if (event.data instanceof ArrayBuffer) {
|
|
775
|
+
msg = deserializeWebSocketMessage(event.data);
|
|
776
|
+
} else if (typeof event.data === "string") {
|
|
777
|
+
try {
|
|
778
|
+
msg = JSON.parse(event.data) as JupyterMessage;
|
|
779
|
+
} catch {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
if (!msg) return;
|
|
784
|
+
|
|
785
|
+
if (TRACE_IPC) {
|
|
786
|
+
logger.debug("Kernel IPC recv", { channel: msg.channel, msgType: msg.header.msg_type });
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const parentId = (msg.parent_header as { msg_id?: string }).msg_id;
|
|
790
|
+
if (parentId) {
|
|
791
|
+
const handler = this.#messageHandlers.get(parentId);
|
|
792
|
+
if (handler) handler(msg);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const channelHandlers = this.#channelHandlers.get(msg.channel);
|
|
796
|
+
if (channelHandlers) {
|
|
797
|
+
for (const handler of channelHandlers) {
|
|
798
|
+
handler(msg);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
private abortPendingExecutions(reason: string): void {
|
|
806
|
+
if (this.#pendingExecutions.size === 0) return;
|
|
807
|
+
for (const cancel of this.#pendingExecutions.values()) {
|
|
808
|
+
cancel(reason);
|
|
809
|
+
}
|
|
810
|
+
this.#pendingExecutions.clear();
|
|
811
|
+
this.#messageHandlers.clear();
|
|
812
|
+
logger.warn("Aborted pending Python executions", { reason });
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
isAlive(): boolean {
|
|
816
|
+
return this.#alive && !this.#disposed && this.#ws?.readyState === WebSocket.OPEN;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async execute(code: string, options?: KernelExecuteOptions): Promise<KernelExecuteResult> {
|
|
820
|
+
if (!this.isAlive()) {
|
|
821
|
+
throw new Error("Python kernel is not running");
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const msgId = nanoid();
|
|
825
|
+
const msg: JupyterMessage = {
|
|
826
|
+
channel: "shell",
|
|
827
|
+
header: {
|
|
828
|
+
msg_id: msgId,
|
|
829
|
+
session: this.sessionId,
|
|
830
|
+
username: this.username,
|
|
831
|
+
date: new Date().toISOString(),
|
|
832
|
+
msg_type: "execute_request",
|
|
833
|
+
version: "5.5",
|
|
834
|
+
},
|
|
835
|
+
parent_header: {},
|
|
836
|
+
metadata: {},
|
|
837
|
+
content: {
|
|
838
|
+
code,
|
|
839
|
+
silent: options?.silent ?? false,
|
|
840
|
+
store_history: options?.storeHistory ?? !(options?.silent ?? false),
|
|
841
|
+
user_expressions: {},
|
|
842
|
+
allow_stdin: options?.allowStdin ?? false,
|
|
843
|
+
stop_on_error: true,
|
|
844
|
+
},
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
let status: "ok" | "error" = "ok";
|
|
848
|
+
let executionCount: number | undefined;
|
|
849
|
+
let error: { name: string; value: string; traceback: string[] } | undefined;
|
|
850
|
+
let replyReceived = false;
|
|
851
|
+
let idleReceived = false;
|
|
852
|
+
let stdinRequested = false;
|
|
853
|
+
let cancelled = false;
|
|
854
|
+
let timedOut = false;
|
|
855
|
+
|
|
856
|
+
const executionSignal = new ScopeSignal({ signal: options?.signal, timeout: options?.timeoutMs });
|
|
857
|
+
|
|
858
|
+
return new Promise((resolve) => {
|
|
859
|
+
let resolved = false;
|
|
860
|
+
const finalize = () => {
|
|
861
|
+
if (resolved) return;
|
|
862
|
+
resolved = true;
|
|
863
|
+
this.#messageHandlers.delete(msgId);
|
|
864
|
+
this.#pendingExecutions.delete(msgId);
|
|
865
|
+
executionSignal[Symbol.dispose]();
|
|
866
|
+
resolve({ status, executionCount, error, cancelled, timedOut, stdinRequested });
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
const checkDone = () => {
|
|
870
|
+
if (replyReceived && idleReceived) {
|
|
871
|
+
finalize();
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
const cancelFromClose = (reason: string) => {
|
|
876
|
+
if (resolved) return;
|
|
877
|
+
cancelled = true;
|
|
878
|
+
timedOut = false;
|
|
879
|
+
if (options?.onChunk) {
|
|
880
|
+
void options.onChunk(`[kernel] ${reason}\n`);
|
|
881
|
+
}
|
|
882
|
+
finalize();
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
this.#pendingExecutions.set(msgId, cancelFromClose);
|
|
886
|
+
|
|
887
|
+
executionSignal.catch(async () => {
|
|
888
|
+
cancelled = true;
|
|
889
|
+
timedOut = executionSignal.timedOut();
|
|
890
|
+
try {
|
|
891
|
+
await this.interrupt();
|
|
892
|
+
} finally {
|
|
893
|
+
finalize();
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
if (executionSignal.aborted) {
|
|
898
|
+
cancelFromClose("Execution aborted");
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
this.#messageHandlers.set(msgId, async (response) => {
|
|
903
|
+
switch (response.header.msg_type) {
|
|
904
|
+
case "execute_reply": {
|
|
905
|
+
replyReceived = true;
|
|
906
|
+
const replyStatus = response.content.status;
|
|
907
|
+
status = replyStatus === "error" ? "error" : "ok";
|
|
908
|
+
if (typeof response.content.execution_count === "number") {
|
|
909
|
+
executionCount = response.content.execution_count;
|
|
910
|
+
}
|
|
911
|
+
checkDone();
|
|
912
|
+
break;
|
|
913
|
+
}
|
|
914
|
+
case "stream": {
|
|
915
|
+
const text = String(response.content.text ?? "");
|
|
916
|
+
if (text && options?.onChunk) {
|
|
917
|
+
await options.onChunk(text);
|
|
918
|
+
}
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
case "execute_result":
|
|
922
|
+
case "display_data": {
|
|
923
|
+
const { text, outputs } = this.renderDisplay(response.content);
|
|
924
|
+
if (text && options?.onChunk) {
|
|
925
|
+
await options.onChunk(text);
|
|
926
|
+
}
|
|
927
|
+
if (outputs.length > 0 && options?.onDisplay) {
|
|
928
|
+
for (const output of outputs) {
|
|
929
|
+
await options.onDisplay(output);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
break;
|
|
933
|
+
}
|
|
934
|
+
case "error": {
|
|
935
|
+
const traceback = Array.isArray(response.content.traceback)
|
|
936
|
+
? response.content.traceback.map((line: unknown) => String(line))
|
|
937
|
+
: [];
|
|
938
|
+
error = {
|
|
939
|
+
name: String(response.content.ename ?? "Error"),
|
|
940
|
+
value: String(response.content.evalue ?? ""),
|
|
941
|
+
traceback,
|
|
942
|
+
};
|
|
943
|
+
const text = traceback.length > 0 ? `${traceback.join("\n")}\n` : `${error.name}: ${error.value}\n`;
|
|
944
|
+
if (options?.onChunk) {
|
|
945
|
+
await options.onChunk(text);
|
|
946
|
+
}
|
|
947
|
+
break;
|
|
948
|
+
}
|
|
949
|
+
case "status": {
|
|
950
|
+
const state = response.content.execution_state;
|
|
951
|
+
if (state === "idle") {
|
|
952
|
+
idleReceived = true;
|
|
953
|
+
checkDone();
|
|
954
|
+
}
|
|
955
|
+
break;
|
|
956
|
+
}
|
|
957
|
+
case "input_request": {
|
|
958
|
+
stdinRequested = true;
|
|
959
|
+
if (options?.onChunk) {
|
|
960
|
+
await options.onChunk(
|
|
961
|
+
"[stdin] Kernel requested input. Interactive stdin is not supported; provide input programmatically.\n",
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
this.sendMessage({
|
|
965
|
+
channel: "stdin",
|
|
966
|
+
header: {
|
|
967
|
+
msg_id: nanoid(),
|
|
968
|
+
session: this.sessionId,
|
|
969
|
+
username: this.username,
|
|
970
|
+
date: new Date().toISOString(),
|
|
971
|
+
msg_type: "input_reply",
|
|
972
|
+
version: "5.5",
|
|
973
|
+
},
|
|
974
|
+
parent_header: response.header as unknown as Record<string, unknown>,
|
|
975
|
+
metadata: {},
|
|
976
|
+
content: { value: "" },
|
|
977
|
+
});
|
|
978
|
+
break;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
try {
|
|
984
|
+
this.sendMessage(msg);
|
|
985
|
+
} catch {
|
|
986
|
+
cancelled = true;
|
|
987
|
+
finalize();
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
async introspectPrelude(): Promise<PreludeHelper[]> {
|
|
993
|
+
let output = "";
|
|
994
|
+
const result = await this.execute(PRELUDE_INTROSPECTION_SNIPPET, {
|
|
995
|
+
silent: false,
|
|
996
|
+
storeHistory: false,
|
|
997
|
+
onChunk: (text) => {
|
|
998
|
+
output += text;
|
|
999
|
+
},
|
|
1000
|
+
});
|
|
1001
|
+
if (result.cancelled || result.status === "error") {
|
|
1002
|
+
throw new Error("Failed to introspect Python prelude");
|
|
1003
|
+
}
|
|
1004
|
+
const trimmed = output.trim();
|
|
1005
|
+
if (!trimmed) return [];
|
|
1006
|
+
try {
|
|
1007
|
+
return JSON.parse(trimmed) as PreludeHelper[];
|
|
1008
|
+
} catch (err: unknown) {
|
|
1009
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1010
|
+
throw new Error(`Failed to parse Python prelude docs: ${message}`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
async interrupt(): Promise<void> {
|
|
1015
|
+
try {
|
|
1016
|
+
const controller = new AbortController();
|
|
1017
|
+
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
1018
|
+
await fetch(`${this.gatewayUrl}/api/kernels/${this.kernelId}/interrupt`, {
|
|
1019
|
+
method: "POST",
|
|
1020
|
+
headers: this.#authHeaders(),
|
|
1021
|
+
signal: controller.signal,
|
|
1022
|
+
});
|
|
1023
|
+
clearTimeout(timeout);
|
|
1024
|
+
} catch (err: unknown) {
|
|
1025
|
+
logger.warn("Failed to interrupt kernel via API", { error: err instanceof Error ? err.message : String(err) });
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
try {
|
|
1029
|
+
const msg: JupyterMessage = {
|
|
1030
|
+
channel: "control",
|
|
1031
|
+
header: {
|
|
1032
|
+
msg_id: nanoid(),
|
|
1033
|
+
session: this.sessionId,
|
|
1034
|
+
username: this.username,
|
|
1035
|
+
date: new Date().toISOString(),
|
|
1036
|
+
msg_type: "interrupt_request",
|
|
1037
|
+
version: "5.5",
|
|
1038
|
+
},
|
|
1039
|
+
parent_header: {},
|
|
1040
|
+
metadata: {},
|
|
1041
|
+
content: {},
|
|
1042
|
+
};
|
|
1043
|
+
this.sendMessage(msg);
|
|
1044
|
+
} catch (err: unknown) {
|
|
1045
|
+
logger.warn("Failed to send interrupt request", { error: err instanceof Error ? err.message : String(err) });
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async shutdown(): Promise<void> {
|
|
1050
|
+
if (this.#disposed) return;
|
|
1051
|
+
this.#disposed = true;
|
|
1052
|
+
this.#alive = false;
|
|
1053
|
+
this.abortPendingExecutions("Kernel shutdown");
|
|
1054
|
+
|
|
1055
|
+
if (this.#heartbeatTimer) {
|
|
1056
|
+
clearInterval(this.#heartbeatTimer);
|
|
1057
|
+
this.#heartbeatTimer = undefined;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
try {
|
|
1061
|
+
await fetch(`${this.gatewayUrl}/api/kernels/${this.kernelId}`, {
|
|
1062
|
+
method: "DELETE",
|
|
1063
|
+
headers: this.#authHeaders(),
|
|
1064
|
+
});
|
|
1065
|
+
} catch (err: unknown) {
|
|
1066
|
+
logger.warn("Failed to delete kernel via API", { error: err instanceof Error ? err.message : String(err) });
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (this.#ws) {
|
|
1070
|
+
this.#ws.close();
|
|
1071
|
+
this.#ws = null;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (this.isSharedGateway) {
|
|
1075
|
+
await releaseSharedGateway();
|
|
1076
|
+
} else if (this.gatewayProcess) {
|
|
1077
|
+
try {
|
|
1078
|
+
killProcessTree(this.gatewayProcess.pid);
|
|
1079
|
+
} catch (err: unknown) {
|
|
1080
|
+
logger.warn("Failed to terminate gateway process", {
|
|
1081
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async ping(timeoutMs: number = HEARTBEAT_TIMEOUT_MS): Promise<boolean> {
|
|
1088
|
+
if (!this.isAlive()) return false;
|
|
1089
|
+
try {
|
|
1090
|
+
const controller = new AbortController();
|
|
1091
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1092
|
+
const response = await fetch(`${this.gatewayUrl}/api/kernels/${this.kernelId}`, {
|
|
1093
|
+
signal: controller.signal,
|
|
1094
|
+
headers: this.#authHeaders(),
|
|
1095
|
+
});
|
|
1096
|
+
clearTimeout(timeout);
|
|
1097
|
+
if (response.ok) {
|
|
1098
|
+
this.#heartbeatFailures = 0;
|
|
1099
|
+
return true;
|
|
1100
|
+
}
|
|
1101
|
+
throw new Error(`Kernel status check failed: ${response.status}`);
|
|
1102
|
+
} catch (err: unknown) {
|
|
1103
|
+
this.#heartbeatFailures += 1;
|
|
1104
|
+
if (this.#heartbeatFailures > HEARTBEAT_FAILURE_LIMIT) {
|
|
1105
|
+
this.#alive = false;
|
|
1106
|
+
logger.warn("Kernel heartbeat failed", { error: err instanceof Error ? err.message : String(err) });
|
|
1107
|
+
}
|
|
1108
|
+
return false;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
private startHeartbeat(): void {
|
|
1113
|
+
if (this.#heartbeatTimer) return;
|
|
1114
|
+
this.#heartbeatTimer = setInterval(() => {
|
|
1115
|
+
void this.ping();
|
|
1116
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
private renderDisplay(content: Record<string, unknown>): { text: string; outputs: KernelDisplayOutput[] } {
|
|
1120
|
+
const data = content.data as Record<string, unknown> | undefined;
|
|
1121
|
+
if (!data) return { text: "", outputs: [] };
|
|
1122
|
+
|
|
1123
|
+
const outputs: KernelDisplayOutput[] = [];
|
|
1124
|
+
|
|
1125
|
+
// Handle status events (custom MIME type from prelude helpers)
|
|
1126
|
+
if (data["application/x-omp-status"] !== undefined) {
|
|
1127
|
+
const statusData = data["application/x-omp-status"];
|
|
1128
|
+
if (statusData && typeof statusData === "object" && "op" in statusData) {
|
|
1129
|
+
outputs.push({ type: "status", event: statusData as PythonStatusEvent });
|
|
1130
|
+
}
|
|
1131
|
+
// Status events don't produce text output
|
|
1132
|
+
return { text: "", outputs };
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (typeof data["image/png"] === "string") {
|
|
1136
|
+
outputs.push({ type: "image", data: data["image/png"] as string, mimeType: "image/png" });
|
|
1137
|
+
}
|
|
1138
|
+
if (data["application/json"] !== undefined) {
|
|
1139
|
+
outputs.push({ type: "json", data: data["application/json"] });
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (typeof data["text/plain"] === "string") {
|
|
1143
|
+
return { text: normalizeDisplayText(String(data["text/plain"])), outputs };
|
|
1144
|
+
}
|
|
1145
|
+
if (data["text/html"] !== undefined) {
|
|
1146
|
+
const markdown = htmlToBasicMarkdown(String(data["text/html"])) || "";
|
|
1147
|
+
return { text: markdown ? normalizeDisplayText(markdown) : "", outputs };
|
|
1148
|
+
}
|
|
1149
|
+
return { text: "", outputs };
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
private sendMessage(msg: JupyterMessage): void {
|
|
1153
|
+
if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) {
|
|
1154
|
+
throw new Error("WebSocket not connected");
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (TRACE_IPC) {
|
|
1158
|
+
logger.debug("Kernel IPC send", {
|
|
1159
|
+
channel: msg.channel,
|
|
1160
|
+
msgType: msg.header.msg_type,
|
|
1161
|
+
msgId: msg.header.msg_id,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const payload = {
|
|
1166
|
+
channel: msg.channel,
|
|
1167
|
+
header: msg.header,
|
|
1168
|
+
parent_header: msg.parent_header,
|
|
1169
|
+
metadata: msg.metadata,
|
|
1170
|
+
content: msg.content,
|
|
1171
|
+
};
|
|
1172
|
+
if (msg.buffers && msg.buffers.length > 0) {
|
|
1173
|
+
this.#ws.send(serializeWebSocketMessage(msg));
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
this.#ws.send(JSON.stringify(payload));
|
|
1177
|
+
}
|
|
1178
|
+
}
|