@oh-my-pi/pi-coding-agent 14.9.3 → 14.9.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.
Files changed (108) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/package.json +7 -7
  3. package/src/async/job-manager.ts +66 -9
  4. package/src/capability/rule.ts +20 -0
  5. package/src/cli/setup-cli.ts +14 -161
  6. package/src/cli/stats-cli.ts +56 -2
  7. package/src/cli.ts +0 -1
  8. package/src/config/model-registry.ts +13 -0
  9. package/src/config/model-resolver.ts +8 -2
  10. package/src/config/settings-schema.ts +1 -11
  11. package/src/edit/index.ts +8 -0
  12. package/src/edit/renderer.ts +6 -1
  13. package/src/edit/streaming.ts +53 -2
  14. package/src/eval/eval.lark +30 -10
  15. package/src/eval/js/context-manager.ts +334 -601
  16. package/src/eval/js/shared/helpers.ts +237 -0
  17. package/src/eval/js/shared/indirect-eval.ts +30 -0
  18. package/src/eval/js/{prelude.txt → shared/prelude.txt} +0 -2
  19. package/src/eval/js/shared/rewrite-imports.ts +211 -0
  20. package/src/eval/js/shared/runtime.ts +168 -0
  21. package/src/eval/js/shared/types.ts +18 -0
  22. package/src/eval/js/tool-bridge.ts +2 -4
  23. package/src/eval/js/worker-core.ts +146 -0
  24. package/src/eval/js/worker-entry.ts +24 -0
  25. package/src/eval/js/worker-protocol.ts +41 -0
  26. package/src/eval/parse.ts +218 -49
  27. package/src/eval/py/display.ts +71 -0
  28. package/src/eval/py/executor.ts +97 -96
  29. package/src/eval/py/index.ts +2 -2
  30. package/src/eval/py/kernel.ts +472 -900
  31. package/src/eval/py/prelude.py +106 -87
  32. package/src/eval/py/runner.py +879 -0
  33. package/src/eval/py/runtime.ts +3 -16
  34. package/src/eval/py/tool-bridge.ts +137 -0
  35. package/src/export/html/template.css +12 -0
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +113 -7
  38. package/src/extensibility/plugins/loader.ts +31 -6
  39. package/src/extensibility/skills.ts +20 -0
  40. package/src/internal-urls/agent-protocol.ts +63 -52
  41. package/src/internal-urls/artifact-protocol.ts +51 -51
  42. package/src/internal-urls/docs-index.generated.ts +35 -3
  43. package/src/internal-urls/index.ts +6 -19
  44. package/src/internal-urls/local-protocol.ts +49 -7
  45. package/src/internal-urls/mcp-protocol.ts +2 -8
  46. package/src/internal-urls/memory-protocol.ts +89 -59
  47. package/src/internal-urls/router.ts +38 -22
  48. package/src/internal-urls/rule-protocol.ts +2 -20
  49. package/src/internal-urls/skill-protocol.ts +4 -27
  50. package/src/main.ts +1 -1
  51. package/src/mcp/manager.ts +17 -0
  52. package/src/modes/components/session-observer-overlay.ts +2 -2
  53. package/src/modes/components/tool-execution.ts +6 -0
  54. package/src/modes/components/tree-selector.ts +4 -0
  55. package/src/modes/controllers/command-controller.ts +0 -23
  56. package/src/modes/controllers/event-controller.ts +23 -2
  57. package/src/modes/controllers/mcp-command-controller.ts +7 -10
  58. package/src/modes/interactive-mode.ts +2 -2
  59. package/src/modes/theme/theme.ts +27 -27
  60. package/src/modes/types.ts +1 -1
  61. package/src/modes/utils/ui-helpers.ts +14 -9
  62. package/src/prompts/commands/orchestrate.md +1 -0
  63. package/src/prompts/system/project-prompt.md +10 -2
  64. package/src/prompts/system/subagent-system-prompt.md +8 -8
  65. package/src/prompts/system/system-prompt.md +13 -7
  66. package/src/prompts/tools/ask.md +0 -1
  67. package/src/prompts/tools/bash.md +0 -10
  68. package/src/prompts/tools/eval.md +15 -30
  69. package/src/prompts/tools/github.md +6 -5
  70. package/src/prompts/tools/hashline.md +1 -0
  71. package/src/prompts/tools/job.md +14 -6
  72. package/src/prompts/tools/task.md +20 -3
  73. package/src/registry/agent-registry.ts +2 -1
  74. package/src/sdk.ts +87 -89
  75. package/src/session/agent-session.ts +58 -21
  76. package/src/session/artifacts.ts +7 -4
  77. package/src/session/history-storage.ts +77 -19
  78. package/src/session/session-manager.ts +30 -1
  79. package/src/ssh/connection-manager.ts +32 -16
  80. package/src/ssh/sshfs-mount.ts +10 -7
  81. package/src/system-prompt.ts +0 -5
  82. package/src/task/executor.ts +14 -2
  83. package/src/task/index.ts +19 -5
  84. package/src/tool-discovery/tool-index.ts +21 -8
  85. package/src/tools/ast-edit.ts +3 -2
  86. package/src/tools/ast-grep.ts +3 -2
  87. package/src/tools/bash.ts +15 -9
  88. package/src/tools/browser/tab-protocol.ts +4 -0
  89. package/src/tools/browser/tab-supervisor.ts +98 -7
  90. package/src/tools/browser/tab-worker.ts +104 -58
  91. package/src/tools/eval.ts +49 -11
  92. package/src/tools/fetch.ts +1 -1
  93. package/src/tools/gh.ts +140 -4
  94. package/src/tools/index.ts +12 -11
  95. package/src/tools/job.ts +48 -12
  96. package/src/tools/read.ts +5 -4
  97. package/src/tools/search.ts +3 -2
  98. package/src/tools/todo-write.ts +1 -1
  99. package/src/web/scrapers/mastodon.ts +1 -1
  100. package/src/web/scrapers/repology.ts +7 -7
  101. package/src/web/search/index.ts +6 -4
  102. package/src/cli/jupyter-cli.ts +0 -106
  103. package/src/commands/jupyter.ts +0 -32
  104. package/src/eval/py/cancellation.ts +0 -28
  105. package/src/eval/py/gateway-coordinator.ts +0 -424
  106. package/src/internal-urls/jobs-protocol.ts +0 -120
  107. package/src/prompts/system/now-prompt.md +0 -7
  108. /package/src/eval/js/{prelude.ts → shared/prelude.ts} +0 -0
@@ -1,163 +1,48 @@
1
- import { $env, $flag, isBunTestRuntime, logger, Snowflake } from "@oh-my-pi/pi-utils";
1
+ /**
2
+ * Subprocess-backed Python runner.
3
+ *
4
+ * Speaks NDJSON with `runner.py` over stdin/stdout. One subprocess per kernel
5
+ * instance; sessions reuse a single subprocess across executions. Cancellation
6
+ * is `kill("SIGINT")` which raises a real `KeyboardInterrupt` inside user
7
+ * code. Shutdown writes `{"type":"exit"}` and escalates to SIGTERM/SIGKILL on
8
+ * timeout.
9
+ */
10
+ import * as fs from "node:fs";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { $flag, isBunTestRuntime, logger, Snowflake } from "@oh-my-pi/pi-utils";
14
+ import type { Subprocess } from "bun";
2
15
  import { $ } from "bun";
3
16
  import { Settings } from "../../config/settings";
4
- import { htmlToBasicMarkdown } from "../../web/scrapers/types";
5
- import { createCancellationError, getAbortReason, getExecutionCancellationError } from "./cancellation";
6
- import { acquireSharedGateway, releaseSharedGateway, shutdownSharedGateway } from "./gateway-coordinator";
7
-
17
+ import { type KernelDisplayOutput, renderKernelDisplay } from "./display";
8
18
  import { PYTHON_PRELUDE } from "./prelude";
19
+ import RUNNER_SCRIPT from "./runner.py" with { type: "text" };
9
20
  import { filterEnv, resolvePythonRuntime } from "./runtime";
10
21
 
11
- const TEXT_ENCODER = new TextEncoder();
12
- const TEXT_DECODER = new TextDecoder();
13
- const TRACE_IPC = $flag("PI_PYTHON_IPC_TRACE");
14
-
15
- class SharedGatewayCreateError extends Error {
16
- constructor(
17
- readonly status: number,
18
- message: string,
19
- ) {
20
- super(message);
21
- }
22
- }
23
-
24
- interface ExternalGatewayConfig {
25
- url: string;
26
- token?: string;
27
- }
28
-
29
- function getExternalGatewayConfig(): ExternalGatewayConfig | null {
30
- const url = $env.PI_PYTHON_GATEWAY_URL;
31
- if (!url) return null;
32
- return {
33
- url: url.replace(/\/$/, ""),
34
- token: $env.PI_PYTHON_GATEWAY_TOKEN,
35
- };
36
- }
37
-
38
- const STARTUP_CLEANUP_TIMEOUT_MS = 2_000;
39
- const WEBSOCKET_CONNECT_TIMEOUT_MS = 10_000;
40
-
41
- interface KernelLifecycleOptions {
42
- signal?: AbortSignal;
43
- deadlineMs?: number;
44
- }
45
-
46
- interface KernelShutdownOptions {
47
- signal?: AbortSignal;
48
- timeoutMs?: number;
49
- }
50
-
51
- export interface KernelShutdownResult {
52
- confirmed: boolean;
53
- }
54
-
55
- function getRemainingTimeMs(deadlineMs?: number): number | undefined {
56
- if (deadlineMs === undefined) return undefined;
57
- return Math.max(0, deadlineMs - Date.now());
58
- }
59
-
60
- function throwIfStartupExecutionFailed(
61
- result: Pick<KernelExecuteResult, "cancelled" | "status" | "timedOut">,
62
- signal: AbortSignal | undefined,
63
- failureMessage: string,
64
- ): void {
65
- if (result.cancelled) {
66
- throw getExecutionCancellationError(result, signal, failureMessage);
67
- }
68
- if (result.status === "error") {
69
- throw new Error(failureMessage);
70
- }
71
- }
72
-
73
- function createAbortedSignal(reason: Error): AbortSignal {
74
- const controller = new AbortController();
75
- controller.abort(reason);
76
- return controller.signal;
77
- }
78
-
79
- function combineAbortSignal(
80
- options: KernelLifecycleOptions,
81
- timeoutCapMs?: number,
82
- fallbackReason = "Operation aborted",
83
- ): AbortSignal | undefined {
84
- if (options.signal?.aborted) {
85
- return options.signal;
86
- }
87
-
88
- const signals: AbortSignal[] = [];
89
- if (options.signal) {
90
- signals.push(options.signal);
91
- }
92
-
93
- const remainingMs = getRemainingTimeMs(options.deadlineMs);
94
- const timeoutMs =
95
- remainingMs === undefined
96
- ? timeoutCapMs
97
- : timeoutCapMs === undefined
98
- ? remainingMs
99
- : Math.min(remainingMs, timeoutCapMs);
100
-
101
- if (timeoutMs !== undefined) {
102
- if (timeoutMs <= 0) {
103
- return createAbortedSignal(createCancellationError("TimeoutError", fallbackReason));
104
- }
105
- signals.push(AbortSignal.timeout(timeoutMs));
106
- }
107
-
108
- if (signals.length === 0) return undefined;
109
- return signals.length === 1 ? signals[0] : AbortSignal.any(signals);
110
- }
111
-
112
- function throwIfAborted(signal: AbortSignal | undefined, fallbackReason: string): void {
113
- if (!signal?.aborted) return;
114
- throw getAbortReason(signal, fallbackReason);
115
- }
22
+ export type { KernelDisplayOutput, PythonStatusEvent } from "./display";
23
+ export { renderKernelDisplay } from "./display";
116
24
 
117
- function getStartupExecuteOptions(options: KernelLifecycleOptions): Pick<KernelExecuteOptions, "signal" | "timeoutMs"> {
118
- return {
119
- signal: combineAbortSignal(options, undefined, "Python kernel startup aborted"),
120
- timeoutMs: getRemainingTimeMs(options.deadlineMs),
121
- };
122
- }
123
-
124
- function getStartupCleanupTimeoutMs(deadlineMs?: number): number {
125
- const remainingMs = getRemainingTimeMs(deadlineMs);
126
- if (remainingMs === undefined || remainingMs <= 0) return STARTUP_CLEANUP_TIMEOUT_MS;
127
- return Math.min(STARTUP_CLEANUP_TIMEOUT_MS, remainingMs);
128
- }
129
-
130
- export interface JupyterHeader {
131
- msg_id: string;
132
- session: string;
133
- username: string;
134
- date: string;
135
- msg_type: string;
136
- version: string;
137
- }
138
-
139
- export interface JupyterMessage {
140
- channel: string;
141
- header: JupyterHeader;
142
- parent_header: Record<string, unknown>;
143
- metadata: Record<string, unknown>;
144
- content: Record<string, unknown>;
145
- buffers?: Uint8Array[];
146
- }
25
+ const TRACE_IPC = $flag("PI_PYTHON_IPC_TRACE");
147
26
 
148
- /** Status event emitted by prelude helpers for TUI rendering. */
149
- export interface PythonStatusEvent {
150
- /** Operation name (e.g., "find", "read", "write") */
151
- op: string;
152
- /** Additional data fields (count, path, pattern, etc.) */
153
- [key: string]: unknown;
27
+ // Cache the runner script on disk so the subprocess loads it normally. Cached
28
+ // per script hash so installs don't race across versions.
29
+ const RUNNER_CACHE_DIR = path.join(os.tmpdir(), "omp-python-runner");
30
+ let RUNNER_SCRIPT_PATH: string | null = null;
31
+
32
+ async function ensureRunnerScript(): Promise<string> {
33
+ if (RUNNER_SCRIPT_PATH) return RUNNER_SCRIPT_PATH;
34
+ await fs.promises.mkdir(RUNNER_CACHE_DIR, { recursive: true });
35
+ const hash = Bun.hash(RUNNER_SCRIPT).toString(36);
36
+ const target = path.join(RUNNER_CACHE_DIR, `runner-${hash}.py`);
37
+ if (!fs.existsSync(target)) {
38
+ await Bun.write(target, RUNNER_SCRIPT);
39
+ }
40
+ RUNNER_SCRIPT_PATH = target;
41
+ return target;
154
42
  }
155
43
 
156
- export type KernelDisplayOutput =
157
- | { type: "json"; data: unknown }
158
- | { type: "image"; data: string; mimeType: string }
159
- | { type: "markdown" }
160
- | { type: "status"; event: PythonStatusEvent };
44
+ const SHUTDOWN_GRACE_MS = 1_000;
45
+ const STARTUP_TIMEOUT_MS = 10_000;
161
46
 
162
47
  export interface KernelExecuteOptions {
163
48
  signal?: AbortSignal;
@@ -178,10 +63,23 @@ export interface KernelExecuteResult {
178
63
  stdinRequested: boolean;
179
64
  }
180
65
 
66
+ export interface KernelShutdownResult {
67
+ confirmed: boolean;
68
+ }
69
+
70
+ interface KernelLifecycleOptions {
71
+ signal?: AbortSignal;
72
+ deadlineMs?: number;
73
+ }
74
+
181
75
  interface KernelStartOptions extends KernelLifecycleOptions {
182
76
  cwd: string;
183
77
  env?: Record<string, string | undefined>;
184
- useSharedGateway?: boolean;
78
+ }
79
+
80
+ interface KernelShutdownOptions {
81
+ signal?: AbortSignal;
82
+ timeoutMs?: number;
185
83
  }
186
84
 
187
85
  export interface PythonKernelAvailability {
@@ -190,231 +88,91 @@ export interface PythonKernelAvailability {
190
88
  reason?: string;
191
89
  }
192
90
 
91
+ function getRemainingTimeMs(deadlineMs?: number): number | undefined {
92
+ if (deadlineMs === undefined) return undefined;
93
+ return Math.max(0, deadlineMs - Date.now());
94
+ }
95
+
96
+ function createAbortError(name: "AbortError" | "TimeoutError", message: string): Error {
97
+ const err = new Error(message);
98
+ err.name = name;
99
+ return err;
100
+ }
101
+
102
+ function throwIfAborted(signal: AbortSignal | undefined, fallbackReason: string): void {
103
+ if (!signal?.aborted) return;
104
+ const reason = signal.reason;
105
+ if (reason instanceof Error) throw reason;
106
+ throw createAbortError("AbortError", typeof reason === "string" ? reason : fallbackReason);
107
+ }
108
+
193
109
  export async function checkPythonKernelAvailability(cwd: string): Promise<PythonKernelAvailability> {
194
110
  if (isBunTestRuntime() || $flag("PI_PYTHON_SKIP_CHECK")) {
195
111
  return { ok: true };
196
112
  }
197
-
198
- const externalConfig = getExternalGatewayConfig();
199
- if (externalConfig) {
200
- return checkExternalGatewayAvailability(externalConfig);
201
- }
202
-
203
113
  try {
204
114
  const settings = await Settings.init();
205
115
  const { env } = settings.getShellConfig();
206
116
  const baseEnv = filterEnv(env);
207
117
  const runtime = resolvePythonRuntime(cwd, baseEnv);
208
- const checkScript =
209
- "import importlib.util,sys;sys.exit(0 if importlib.util.find_spec('kernel_gateway') and importlib.util.find_spec('ipykernel') else 1)";
210
- const result = await $`${runtime.pythonPath} -c ${checkScript}`.quiet().nothrow().cwd(cwd).env(runtime.env);
211
- if (result.exitCode === 0) {
118
+ const probe = await $`${runtime.pythonPath} -c "import sys;sys.exit(0)"`
119
+ .quiet()
120
+ .nothrow()
121
+ .cwd(cwd)
122
+ .env(runtime.env);
123
+ if (probe.exitCode === 0) {
212
124
  return { ok: true, pythonPath: runtime.pythonPath };
213
125
  }
214
126
  return {
215
127
  ok: false,
216
128
  pythonPath: runtime.pythonPath,
217
- reason:
218
- "kernel_gateway (jupyter-kernel-gateway) or ipykernel not installed. Run: python -m pip install jupyter_kernel_gateway ipykernel",
129
+ reason: `Python interpreter at ${runtime.pythonPath} returned exit code ${probe.exitCode}`,
219
130
  };
220
- } catch (err: unknown) {
131
+ } catch (err) {
221
132
  return { ok: false, reason: err instanceof Error ? err.message : String(err) };
222
133
  }
223
134
  }
224
135
 
225
- async function checkExternalGatewayAvailability(config: ExternalGatewayConfig): Promise<PythonKernelAvailability> {
226
- try {
227
- const headers: Record<string, string> = {};
228
- if (config.token) {
229
- headers.Authorization = `token ${config.token}`;
230
- }
231
-
232
- const controller = new AbortController();
233
-
234
- const response = await fetch(`${config.url}/api/kernelspecs`, {
235
- headers,
236
- signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]),
237
- });
238
-
239
- if (response.ok) {
240
- return { ok: true };
241
- }
242
-
243
- if (response.status === 401 || response.status === 403) {
244
- return {
245
- ok: false,
246
- reason: `External gateway at ${config.url} requires authentication. Set PI_PYTHON_GATEWAY_TOKEN.`,
247
- };
248
- }
249
-
250
- return {
251
- ok: false,
252
- reason: `External gateway at ${config.url} returned status ${response.status}`,
253
- };
254
- } catch (err: unknown) {
255
- const message = err instanceof Error ? err.message : String(err);
256
- if (message.includes("abort") || message.includes("timeout")) {
257
- return {
258
- ok: false,
259
- reason: `External gateway at ${config.url} is not reachable (timeout)`,
260
- };
261
- }
262
- return {
263
- ok: false,
264
- reason: `External gateway at ${config.url} is not reachable: ${message}`,
265
- };
266
- }
267
- }
268
-
269
- function normalizeDisplayText(text: string): string {
270
- return text.endsWith("\n") ? text : `${text}\n`;
271
- }
272
-
273
- /** Renders a Jupyter display_data message into text and structured outputs. */
274
- export async function renderKernelDisplay(content: Record<string, unknown>): Promise<{
275
- text: string;
276
- outputs: KernelDisplayOutput[];
277
- }> {
278
- const data = content.data as Record<string, unknown> | undefined;
279
- if (!data) return { text: "", outputs: [] };
280
-
281
- const outputs: KernelDisplayOutput[] = [];
282
-
283
- // Handle status events (custom MIME type from prelude helpers)
284
- if (data["application/x-omp-status"] !== undefined) {
285
- const statusData = data["application/x-omp-status"];
286
- if (statusData && typeof statusData === "object" && "op" in statusData) {
287
- outputs.push({ type: "status", event: statusData as PythonStatusEvent });
288
- }
289
- // Status events don't produce text output
290
- return { text: "", outputs };
291
- }
292
-
293
- if (typeof data["image/png"] === "string") {
294
- outputs.push({ type: "image", data: data["image/png"] as string, mimeType: "image/png" });
295
- }
296
- if (data["application/json"] !== undefined) {
297
- outputs.push({ type: "json", data: data["application/json"] });
298
- }
299
-
300
- // Check text/markdown before text/plain since Markdown objects provide both
301
- // (text/plain is just the repr)
302
- if (typeof data["text/markdown"] === "string") {
303
- outputs.push({ type: "markdown" });
304
- return { text: normalizeDisplayText(String(data["text/markdown"])), outputs };
305
- }
306
- if (typeof data["text/plain"] === "string") {
307
- return { text: normalizeDisplayText(String(data["text/plain"])), outputs };
308
- }
309
- if (data["text/html"] !== undefined) {
310
- const markdown = (await htmlToBasicMarkdown(String(data["text/html"]))) || "";
311
- return { text: markdown ? normalizeDisplayText(markdown) : "", outputs };
312
- }
313
- return { text: "", outputs };
314
- }
315
-
316
- export function deserializeWebSocketMessage(data: ArrayBuffer): JupyterMessage | null {
317
- const view = new DataView(data);
318
- const offsetCount = view.getUint32(0, true);
319
-
320
- if (offsetCount < 1) return null;
321
-
322
- const offsets: number[] = [];
323
- for (let i = 0; i < offsetCount; i++) {
324
- offsets.push(view.getUint32(4 + i * 4, true));
325
- }
326
-
327
- const msgStart = offsets[0];
328
- const msgEnd = offsets.length > 1 ? offsets[1] : data.byteLength;
329
- const msgBytes = new Uint8Array(data, msgStart, msgEnd - msgStart);
330
- const msgText = TEXT_DECODER.decode(msgBytes);
331
-
332
- try {
333
- const msg = JSON.parse(msgText) as {
334
- channel: string;
335
- header: JupyterHeader;
336
- parent_header: Record<string, unknown>;
337
- metadata: Record<string, unknown>;
338
- content: Record<string, unknown>;
339
- };
340
-
341
- const buffers: Uint8Array[] = [];
342
- for (let i = 1; i < offsets.length; i++) {
343
- const start = offsets[i];
344
- const end = i + 1 < offsets.length ? offsets[i + 1] : data.byteLength;
345
- buffers.push(new Uint8Array(data, start, end - start));
346
- }
347
-
348
- return { ...msg, buffers };
349
- } catch {
350
- return null;
351
- }
136
+ type FrameType = "started" | "stdout" | "stderr" | "display" | "result" | "error" | "done";
137
+
138
+ interface Frame {
139
+ type: FrameType;
140
+ id?: string;
141
+ data?: string;
142
+ bundle?: Record<string, unknown>;
143
+ ename?: string;
144
+ evalue?: string;
145
+ traceback?: string[];
146
+ status?: "ok" | "error";
147
+ executionCount?: number;
148
+ cancelled?: boolean;
352
149
  }
353
150
 
354
- export function serializeWebSocketMessage(msg: JupyterMessage): ArrayBuffer {
355
- const msgText = JSON.stringify({
356
- channel: msg.channel,
357
- header: msg.header,
358
- parent_header: msg.parent_header,
359
- metadata: msg.metadata,
360
- content: msg.content,
361
- });
362
-
363
- const buffers = msg.buffers ?? [];
364
- const offsetCount = 1 + buffers.length;
365
- const headerSize = 4 + offsetCount * 4;
366
- const msgBytes = Buffer.byteLength(msgText);
367
- let totalSize = headerSize + msgBytes;
368
- for (const buf of buffers) {
369
- totalSize += buf.length;
370
- }
371
-
372
- const result = new ArrayBuffer(totalSize);
373
- const view = new DataView(result);
374
- const bytes = new Uint8Array(result);
375
-
376
- view.setUint32(0, offsetCount, true);
377
-
378
- let offset = headerSize;
379
- view.setUint32(4, offset, true);
380
- TEXT_ENCODER.encodeInto(msgText, bytes.subarray(offset));
381
- offset += msgBytes;
382
-
383
- for (let i = 0; i < buffers.length; i++) {
384
- view.setUint32(4 + (i + 1) * 4, offset, true);
385
- bytes.set(buffers[i], offset);
386
- offset += buffers[i].length;
387
- }
388
-
389
- return result;
151
+ interface PendingExecution {
152
+ resolve: (result: KernelExecuteResult) => void;
153
+ options?: KernelExecuteOptions;
154
+ status: "ok" | "error";
155
+ executionCount?: number;
156
+ error?: { name: string; value: string; traceback: string[] };
157
+ cancelled: boolean;
158
+ timedOut: boolean;
159
+ stdinRequested: boolean;
160
+ settled: boolean;
390
161
  }
391
162
 
392
163
  export class PythonKernel {
393
- readonly #authToken?: string;
394
-
395
- #ws: WebSocket | null = null;
396
- #disposed = false;
164
+ readonly id: string;
165
+ #proc: Subprocess | null = null;
166
+ #stdin: Bun.FileSink | null = null;
397
167
  #alive = true;
398
- #shutdownStarted = false;
168
+ #disposed = false;
399
169
  #shutdownConfirmed = false;
400
- #messageHandlers = new Map<string, (msg: JupyterMessage) => void>();
401
- #channelHandlers = new Map<string, Set<(msg: JupyterMessage) => void>>();
402
- #pendingExecutions = new Map<string, (reason: string) => void>();
403
- private constructor(
404
- readonly id: string,
405
- readonly kernelId: string,
406
- readonly gatewayUrl: string,
407
- readonly sessionId: string,
408
- readonly username: string,
409
- readonly isSharedGateway: boolean,
410
- authToken?: string,
411
- ) {
412
- this.#authToken = authToken;
413
- }
170
+ #exitedPromise: Promise<number> | null = null;
171
+ #pending = new Map<string, PendingExecution>();
172
+ #readBuffer = "";
414
173
 
415
- #authHeaders(): Record<string, string> {
416
- if (!this.#authToken) return {};
417
- return { Authorization: `token ${this.#authToken}` };
174
+ private constructor(id: string) {
175
+ this.id = id;
418
176
  }
419
177
 
420
178
  static async start(options: KernelStartOptions): Promise<PythonKernel> {
@@ -427,629 +185,443 @@ export class PythonKernel {
427
185
  throw new Error(availability.reason ?? "Python kernel unavailable");
428
186
  }
429
187
 
430
- const startup = { signal: options.signal, deadlineMs: options.deadlineMs };
431
- const startupSignal = combineAbortSignal(startup, undefined, "Python kernel startup aborted");
432
-
433
- const externalConfig = getExternalGatewayConfig();
434
- if (externalConfig) {
435
- return PythonKernel.#startWithExternalGateway(externalConfig, options.cwd, options.env, startup);
436
- }
437
-
438
- if (options.useSharedGateway === false) {
439
- throw new Error("Shared Python gateway required; local gateways are disabled");
440
- }
441
-
442
- for (let attempt = 0; attempt < 2; attempt += 1) {
443
- throwIfAborted(startupSignal, "Python kernel startup aborted");
444
- try {
445
- const sharedResult = await logger.time(
446
- "PythonKernel.start:acquireSharedGateway",
447
- acquireSharedGateway,
448
- options.cwd,
449
- );
450
- if (!sharedResult) {
451
- throw new Error("Shared Python gateway unavailable");
452
- }
453
- const kernel = await logger.time(
454
- "PythonKernel.start:startWithSharedGateway",
455
- PythonKernel.#startWithSharedGateway,
456
- sharedResult.url,
457
- options.cwd,
458
- options.env,
459
- startup,
460
- );
461
- return kernel;
462
- } catch (err) {
463
- logger.debug("PythonKernel.start:sharedFailed");
464
- if (attempt === 0 && err instanceof SharedGatewayCreateError && err.status >= 500) {
465
- logger.warn("Shared gateway kernel creation failed, retrying", {
466
- status: err.status,
467
- });
468
- continue;
469
- }
470
- logger.warn("Failed to acquire shared gateway", {
471
- error: err instanceof Error ? err.message : String(err),
472
- });
473
- throw err;
474
- }
475
- }
476
-
477
- throw new Error("Shared Python gateway unavailable after retry");
478
- }
479
-
480
- static async #startWithExternalGateway(
481
- config: ExternalGatewayConfig,
482
- cwd: string,
483
- env?: Record<string, string | undefined>,
484
- startup: KernelLifecycleOptions = {},
485
- ): Promise<PythonKernel> {
486
- const headers: Record<string, string> = { "Content-Type": "application/json" };
487
- if (config.token) {
488
- headers.Authorization = `token ${config.token}`;
489
- }
490
-
491
- const startupSignal = combineAbortSignal(startup, undefined, "Python kernel startup aborted");
492
- throwIfAborted(startupSignal, "Python kernel startup aborted");
493
- const createResponse = await fetch(`${config.url}/api/kernels`, {
494
- method: "POST",
495
- headers,
496
- body: JSON.stringify({ name: "python3" }),
497
- signal: startupSignal,
188
+ const settings = await Settings.init();
189
+ const { env: shellEnv } = settings.getShellConfig();
190
+ const baseEnv = filterEnv(shellEnv);
191
+ const runtime = resolvePythonRuntime(options.cwd, baseEnv);
192
+ const spawnEnv: Record<string, string> = {};
193
+ for (const [key, value] of Object.entries(runtime.env)) {
194
+ if (typeof value === "string") spawnEnv[key] = value;
195
+ }
196
+ for (const [key, value] of Object.entries(options.env ?? {})) {
197
+ if (typeof value === "string") spawnEnv[key] = value;
198
+ }
199
+ // Unbuffered IO is critical for streaming.
200
+ spawnEnv.PYTHONUNBUFFERED = "1";
201
+ spawnEnv.PYTHONIOENCODING = "utf-8";
202
+
203
+ const scriptPath = await ensureRunnerScript();
204
+ const kernel = new PythonKernel(Snowflake.next());
205
+
206
+ const proc = Bun.spawn([runtime.pythonPath, "-u", scriptPath], {
207
+ cwd: options.cwd,
208
+ env: spawnEnv,
209
+ stdin: "pipe",
210
+ stdout: "pipe",
211
+ stderr: "pipe",
212
+ windowsHide: true,
213
+ });
214
+ kernel.#proc = proc;
215
+ kernel.#stdin = proc.stdin;
216
+ kernel.#exitedPromise = proc.exited;
217
+ void kernel.#exitedPromise.then(code => {
218
+ kernel.#alive = false;
219
+ kernel.#abortPendingExecutions(`Python kernel exited with code ${code}`);
498
220
  });
499
221
 
500
- if (!createResponse.ok) {
501
- throw new Error(`Failed to create kernel on external gateway: ${await createResponse.text()}`);
502
- }
503
-
504
- const kernelInfo = (await createResponse.json()) as { id: string };
505
- const kernelId = kernelInfo.id;
222
+ kernel.#startReader(proc.stdout as ReadableStream<Uint8Array>);
223
+ kernel.#startStderrDrain(proc.stderr as ReadableStream<Uint8Array>);
506
224
 
507
- const kernel = new PythonKernel(
508
- Snowflake.next(),
509
- kernelId,
510
- config.url,
511
- Snowflake.next(),
512
- "omp",
513
- false,
514
- config.token,
515
- );
225
+ const startup = { signal: options.signal, deadlineMs: options.deadlineMs };
226
+ const startupBudget = Math.min(getRemainingTimeMs(startup.deadlineMs) ?? STARTUP_TIMEOUT_MS, STARTUP_TIMEOUT_MS);
516
227
 
517
228
  try {
518
- await kernel.#connectWebSocket(startup);
519
- await kernel.#initializeKernelEnvironment(cwd, env, startup);
520
- const preludeOptions = getStartupExecuteOptions(startup);
521
- const preludeResult = await kernel.execute(PYTHON_PRELUDE, {
522
- ...preludeOptions,
523
- silent: true,
524
- storeHistory: false,
525
- });
526
- throwIfStartupExecutionFailed(
527
- preludeResult,
528
- preludeOptions.signal,
529
- "Failed to initialize Python kernel prelude",
530
- );
531
-
229
+ const initScript = buildInitScript(options.cwd, options.env);
230
+ await kernel.#executeWithBudget(initScript, startup.signal, startupBudget, "Python kernel init");
231
+ await kernel.#executeWithBudget(PYTHON_PRELUDE, startup.signal, startupBudget, "Python kernel prelude");
532
232
  return kernel;
533
- } catch (err: unknown) {
534
- await kernel.shutdown({ timeoutMs: getStartupCleanupTimeoutMs(startup.deadlineMs) });
233
+ } catch (err) {
234
+ await kernel.shutdown({ timeoutMs: SHUTDOWN_GRACE_MS }).catch(() => {});
535
235
  throw err;
536
236
  }
537
237
  }
538
238
 
539
- static async #startWithSharedGateway(
540
- gatewayUrl: string,
541
- cwd: string,
542
- env?: Record<string, string | undefined>,
543
- startup: KernelLifecycleOptions = {},
544
- ): Promise<PythonKernel> {
545
- const startupSignal = combineAbortSignal(startup, undefined, "Python kernel startup aborted");
546
- throwIfAborted(startupSignal, "Python kernel startup aborted");
547
- const createResponse = await logger.time(
548
- "startWithSharedGateway:createKernel",
549
- fetch,
550
- `${gatewayUrl}/api/kernels`,
551
- {
552
- method: "POST",
553
- headers: { "Content-Type": "application/json" },
554
- body: JSON.stringify({ name: "python3" }),
555
- signal: startupSignal,
556
- },
557
- );
558
-
559
- if (!createResponse.ok) {
560
- logger.debug(`sharedGateway:fetch:notOk:${createResponse.status}`);
561
- await shutdownSharedGateway();
562
- const text = await createResponse.text();
563
- throw new SharedGatewayCreateError(
564
- createResponse.status,
565
- `Failed to create kernel on shared gateway: ${text}`,
566
- );
567
- }
568
-
569
- const kernelInfo = (await logger.time(
570
- "startWithSharedGateway:parseJson",
571
- createResponse.json.bind(createResponse),
572
- )) as { id: string };
573
- const kernelId = kernelInfo.id;
574
-
575
- const kernel = new PythonKernel(Snowflake.next(), kernelId, gatewayUrl, Snowflake.next(), "omp", true);
576
-
577
- try {
578
- await logger.time("startWithSharedGateway:connectWS", kernel.#connectWebSocket.bind(kernel), startup);
579
- await logger.time(
580
- "startWithSharedGateway:initEnv",
581
- kernel.#initializeKernelEnvironment.bind(kernel),
582
- cwd,
583
- env,
584
- startup,
585
- );
586
- const preludeOptions = getStartupExecuteOptions(startup);
587
- const preludeResult = await logger.time(
588
- "startWithSharedGateway:prelude",
589
- kernel.execute.bind(kernel),
590
- PYTHON_PRELUDE,
591
- {
592
- ...preludeOptions,
593
- silent: true,
594
- storeHistory: false,
595
- },
596
- );
597
- throwIfStartupExecutionFailed(
598
- preludeResult,
599
- preludeOptions.signal,
600
- "Failed to initialize Python kernel prelude",
601
- );
602
-
603
- return kernel;
604
- } catch (err: unknown) {
605
- await kernel.shutdown({ timeoutMs: getStartupCleanupTimeoutMs(startup.deadlineMs) });
606
- throw err;
607
- }
239
+ isAlive(): boolean {
240
+ return this.#alive && !this.#disposed;
608
241
  }
609
242
 
610
- async #connectWebSocket(options: KernelLifecycleOptions = {}): Promise<void> {
611
- const wsBase = this.gatewayUrl.replace(/^http/, "ws");
612
- let wsUrl = `${wsBase}/api/kernels/${this.kernelId}/channels`;
613
- if (this.#authToken) {
614
- wsUrl += `?token=${encodeURIComponent(this.#authToken)}`;
243
+ async execute(code: string, options?: KernelExecuteOptions): Promise<KernelExecuteResult> {
244
+ if (!this.isAlive()) {
245
+ throw new Error("Python kernel is not running");
615
246
  }
616
247
 
617
- const connectSignal = combineAbortSignal(options, WEBSOCKET_CONNECT_TIMEOUT_MS, "WebSocket connection timeout");
618
- throwIfAborted(connectSignal, "WebSocket connection timeout");
619
-
620
- const { promise, resolve, reject } = Promise.withResolvers<void>();
621
- const ws = new WebSocket(wsUrl);
622
- ws.binaryType = "arraybuffer";
623
- let settled = false;
624
-
625
- const finalize = (): void => {
626
- if (connectSignal) {
627
- connectSignal.removeEventListener("abort", onAbort);
628
- }
629
- };
630
-
631
- const onAbort = () => {
632
- ws.close();
633
- if (settled) return;
634
- settled = true;
635
- finalize();
636
- reject(getAbortReason(connectSignal, "WebSocket connection timeout"));
248
+ const msgId = Snowflake.next();
249
+ const { promise, resolve } = Promise.withResolvers<KernelExecuteResult>();
250
+ const pending: PendingExecution = {
251
+ resolve,
252
+ options,
253
+ status: "ok",
254
+ cancelled: false,
255
+ timedOut: false,
256
+ stdinRequested: false,
257
+ settled: false,
637
258
  };
259
+ this.#pending.set(msgId, pending);
638
260
 
639
- if (connectSignal) {
640
- connectSignal.addEventListener("abort", onAbort, { once: true });
641
- }
642
-
643
- ws.onopen = () => {
644
- if (settled) return;
645
- settled = true;
646
- finalize();
647
- this.#ws = ws;
648
- resolve();
261
+ const finalize = () => {
262
+ if (pending.settled) return;
263
+ pending.settled = true;
264
+ this.#pending.delete(msgId);
265
+ cleanup();
266
+ resolve({
267
+ status: pending.status,
268
+ executionCount: pending.executionCount,
269
+ error: pending.error,
270
+ cancelled: pending.cancelled,
271
+ timedOut: pending.timedOut,
272
+ stdinRequested: pending.stdinRequested,
273
+ });
649
274
  };
650
275
 
651
- ws.onerror = event => {
652
- const error = new Error(`WebSocket error: ${event}`);
653
- if (!settled) {
654
- settled = true;
655
- finalize();
656
- reject(error);
657
- return;
658
- }
659
- this.#alive = false;
660
- this.#ws = null;
661
- this.#abortPendingExecutions(error.message);
276
+ const onAbort = () => {
277
+ pending.cancelled = true;
278
+ pending.timedOut = pending.timedOut || isTimeoutReason(options?.signal?.reason);
279
+ void this.interrupt();
662
280
  };
281
+ const timeoutId =
282
+ typeof options?.timeoutMs === "number" && options.timeoutMs > 0
283
+ ? setTimeout(() => {
284
+ pending.timedOut = true;
285
+ pending.cancelled = true;
286
+ void this.interrupt();
287
+ }, options.timeoutMs)
288
+ : undefined;
663
289
 
664
- ws.onclose = () => {
665
- this.#alive = false;
666
- this.#ws = null;
667
- if (!settled) {
668
- settled = true;
669
- finalize();
670
- reject(new Error("WebSocket closed before connection"));
671
- return;
672
- }
673
- this.#abortPendingExecutions("WebSocket closed");
290
+ const cleanup = () => {
291
+ if (timeoutId) clearTimeout(timeoutId);
292
+ options?.signal?.removeEventListener("abort", onAbort);
674
293
  };
675
294
 
676
- ws.onmessage = event => {
677
- let msg: JupyterMessage | null = null;
678
- if (event.data instanceof ArrayBuffer) {
679
- msg = deserializeWebSocketMessage(event.data);
680
- } else if (typeof event.data === "string") {
681
- try {
682
- msg = JSON.parse(event.data) as JupyterMessage;
683
- } catch {
684
- return;
685
- }
295
+ if (options?.signal) {
296
+ if (options.signal.aborted) {
297
+ onAbort();
298
+ } else {
299
+ options.signal.addEventListener("abort", onAbort, { once: true });
686
300
  }
687
- if (!msg) return;
301
+ }
688
302
 
689
- if (TRACE_IPC) {
690
- logger.debug("Kernel IPC recv", { channel: msg.channel, msgType: msg.header.msg_type });
691
- }
303
+ // Stash finalize on the pending entry so the reader can call it on `done`.
304
+ (pending as PendingExecution & { finalize: () => void }).finalize = finalize;
692
305
 
693
- const parentId = (msg.parent_header as { msg_id?: string }).msg_id;
694
- if (parentId) {
695
- const handler = this.#messageHandlers.get(parentId);
696
- if (handler) handler(msg);
697
- }
306
+ const payload = JSON.stringify({
307
+ id: msgId,
308
+ code,
309
+ silent: options?.silent ?? false,
310
+ storeHistory: options?.storeHistory ?? !(options?.silent ?? false),
311
+ });
698
312
 
699
- const channelHandlers = this.#channelHandlers.get(msg.channel);
700
- if (channelHandlers) {
701
- for (const handler of channelHandlers) {
702
- handler(msg);
703
- }
704
- }
705
- };
313
+ try {
314
+ await this.#writeLine(payload);
315
+ } catch (err) {
316
+ pending.cancelled = true;
317
+ pending.error = {
318
+ name: "TransportError",
319
+ value: err instanceof Error ? err.message : String(err),
320
+ traceback: [],
321
+ };
322
+ finalize();
323
+ }
706
324
 
707
325
  return promise;
708
326
  }
709
327
 
710
- async #initializeKernelEnvironment(
711
- cwd: string,
712
- env?: Record<string, string | undefined>,
713
- options: KernelLifecycleOptions = {},
714
- ): Promise<void> {
715
- const envEntries = Object.entries(env ?? {}).filter(([, value]) => value !== undefined);
716
- const envPayload = Object.fromEntries(envEntries);
717
- const initScript = [
718
- "import os, sys",
719
- `__omp_cwd = ${JSON.stringify(cwd)}`,
720
- "os.chdir(__omp_cwd)",
721
- `__omp_env = ${JSON.stringify(envPayload)}`,
722
- "for __omp_key, __omp_val in __omp_env.items():\n os.environ[__omp_key] = __omp_val",
723
- "if __omp_cwd not in sys.path:\n sys.path.insert(0, __omp_cwd)",
724
- ].join("\n");
725
- const executeOptions = getStartupExecuteOptions(options);
726
- const result = await this.execute(initScript, {
727
- ...executeOptions,
728
- silent: true,
729
- storeHistory: false,
730
- });
731
- throwIfStartupExecutionFailed(result, executeOptions.signal, "Failed to initialize Python kernel environment");
732
- }
733
-
734
- #abortPendingExecutions(reason: string): void {
735
- if (this.#pendingExecutions.size === 0) return;
736
- for (const cancel of this.#pendingExecutions.values()) {
737
- cancel(reason);
328
+ async interrupt(): Promise<void> {
329
+ if (!this.#proc || this.#disposed) return;
330
+ try {
331
+ this.#proc.kill("SIGINT");
332
+ } catch (err) {
333
+ logger.warn("Failed to interrupt python runner", { error: err instanceof Error ? err.message : String(err) });
738
334
  }
739
- this.#pendingExecutions.clear();
740
- this.#messageHandlers.clear();
741
- logger.warn("Aborted pending Python executions", { reason });
742
335
  }
743
336
 
744
- isAlive(): boolean {
745
- return this.#alive && !this.#disposed && this.#ws?.readyState === WebSocket.OPEN;
746
- }
337
+ async shutdown(options?: KernelShutdownOptions): Promise<KernelShutdownResult> {
338
+ if (this.#shutdownConfirmed) return { confirmed: true };
747
339
 
748
- async execute(code: string, options?: KernelExecuteOptions): Promise<KernelExecuteResult> {
749
- if (!this.isAlive()) {
750
- throw new Error("Python kernel is not running");
340
+ this.#alive = false;
341
+ this.#abortPendingExecutions("Python kernel shutdown");
342
+
343
+ const timeoutMs = options?.timeoutMs ?? SHUTDOWN_GRACE_MS;
344
+ const proc = this.#proc;
345
+ if (!proc) {
346
+ this.#shutdownConfirmed = true;
347
+ this.#disposed = true;
348
+ return { confirmed: true };
751
349
  }
752
350
 
753
- const msgId = Snowflake.next();
754
- const msg: JupyterMessage = {
755
- channel: "shell",
756
- header: {
757
- msg_id: msgId,
758
- session: this.sessionId,
759
- username: this.username,
760
- date: new Date().toISOString(),
761
- msg_type: "execute_request",
762
- version: "5.5",
763
- },
764
- parent_header: {},
765
- metadata: {},
766
- content: {
767
- code,
768
- silent: options?.silent ?? false,
769
- store_history: options?.storeHistory ?? !(options?.silent ?? false),
770
- user_expressions: {},
771
- allow_stdin: options?.allowStdin ?? false,
772
- stop_on_error: true,
773
- },
774
- };
351
+ try {
352
+ await this.#writeLine(JSON.stringify({ type: "exit" })).catch(() => {});
353
+ } catch {
354
+ /* writer may already be closed */
355
+ }
775
356
 
776
- let status: "ok" | "error" = "ok";
777
- let executionCount: number | undefined;
778
- let error: { name: string; value: string; traceback: string[] } | undefined;
779
- let replyReceived = false;
780
- let idleReceived = false;
781
- let stdinRequested = false;
782
- let cancelled = false;
783
- let timedOut = false;
357
+ try {
358
+ this.#stdin?.end();
359
+ } catch {
360
+ /* ignore */
361
+ }
784
362
 
785
- const controller = new AbortController();
786
- const onAbort = () => {
787
- controller.abort(options?.signal?.reason ?? new Error("Aborted"));
788
- };
789
- if (options?.signal) {
790
- if (options.signal.aborted) {
791
- onAbort();
792
- } else {
793
- options.signal.addEventListener("abort", onAbort, { once: true });
363
+ const exited = this.#waitForExitWithTimeout(timeoutMs);
364
+ let result = await exited;
365
+ if (!result) {
366
+ try {
367
+ proc.kill("SIGTERM");
368
+ } catch {
369
+ /* ignore */
794
370
  }
371
+ result = await this.#waitForExitWithTimeout(timeoutMs);
372
+ }
373
+ if (!result) {
374
+ try {
375
+ proc.kill("SIGKILL");
376
+ } catch {
377
+ /* ignore */
378
+ }
379
+ result = await this.#waitForExitWithTimeout(timeoutMs);
795
380
  }
796
- const timeoutId =
797
- typeof options?.timeoutMs === "number" && options.timeoutMs > 0
798
- ? setTimeout(() => {
799
- timedOut = true;
800
- controller.abort(new Error("Timeout"));
801
- }, options.timeoutMs)
802
- : undefined;
803
381
 
804
- const { promise, resolve } = Promise.withResolvers<KernelExecuteResult>();
382
+ const confirmed = !!result;
383
+ this.#shutdownConfirmed = confirmed;
384
+ this.#disposed = true;
385
+ return { confirmed };
386
+ }
805
387
 
806
- let resolved = false;
807
- const finalize = () => {
808
- if (resolved) return;
809
- resolved = true;
810
- this.#messageHandlers.delete(msgId);
811
- this.#pendingExecutions.delete(msgId);
812
- if (timeoutId) clearTimeout(timeoutId);
813
- if (options?.signal) {
814
- options.signal.removeEventListener("abort", onAbort);
815
- }
816
- resolve({ status, executionCount, error, cancelled, timedOut, stdinRequested });
817
- };
388
+ #abortPendingExecutions(reason: string): void {
389
+ if (this.#pending.size === 0) return;
390
+ const pending = Array.from(this.#pending.values());
391
+ this.#pending.clear();
392
+ for (const entry of pending) {
393
+ if (entry.settled) continue;
394
+ entry.settled = true;
395
+ void entry.options?.onChunk?.(`[kernel] ${reason}\n`);
396
+ entry.resolve({
397
+ status: "error",
398
+ cancelled: true,
399
+ timedOut: entry.timedOut,
400
+ stdinRequested: entry.stdinRequested,
401
+ executionCount: entry.executionCount,
402
+ error: entry.error,
403
+ });
404
+ }
405
+ }
818
406
 
819
- const checkDone = () => {
820
- if (replyReceived && idleReceived) {
821
- finalize();
822
- }
823
- };
407
+ async #writeLine(line: string): Promise<void> {
408
+ if (!this.#stdin) {
409
+ throw new Error("Python kernel stdin is not open");
410
+ }
411
+ if (TRACE_IPC) {
412
+ logger.debug("PythonKernel send", { preview: line.slice(0, 120) });
413
+ }
414
+ this.#stdin.write(`${line}\n`);
415
+ this.#stdin.flush();
416
+ }
824
417
 
825
- const cancelFromClose = (reason: string) => {
826
- if (resolved) return;
827
- cancelled = true;
828
- timedOut = false;
829
- if (options?.onChunk) {
830
- void options.onChunk(`[kernel] ${reason}\n`);
418
+ #startReader(stream: ReadableStream<Uint8Array>): void {
419
+ const reader = stream.getReader();
420
+ const decoder = new TextDecoder();
421
+ const loop = async () => {
422
+ try {
423
+ while (true) {
424
+ const { done, value } = await reader.read();
425
+ if (done) break;
426
+ this.#readBuffer += decoder.decode(value, { stream: true });
427
+ await this.#flushFrames();
428
+ }
429
+ this.#readBuffer += decoder.decode();
430
+ await this.#flushFrames();
431
+ } catch (err) {
432
+ logger.warn("Python kernel reader failed", { error: err instanceof Error ? err.message : String(err) });
433
+ } finally {
434
+ try {
435
+ reader.releaseLock();
436
+ } catch {
437
+ /* ignore */
438
+ }
831
439
  }
832
- finalize();
833
440
  };
441
+ void loop();
442
+ }
834
443
 
835
- this.#pendingExecutions.set(msgId, cancelFromClose);
836
-
837
- const onExecutionAbort = () => {
838
- cancelled = true;
839
- void (async () => {
444
+ #startStderrDrain(stream: ReadableStream<Uint8Array>): void {
445
+ // Wrapper writes its own crashes to stderr; surface them via logger so the
446
+ // host operator can debug runtime issues without polluting tool output.
447
+ const reader = stream.getReader();
448
+ const decoder = new TextDecoder();
449
+ const loop = async () => {
450
+ try {
451
+ while (true) {
452
+ const { done, value } = await reader.read();
453
+ if (done) break;
454
+ const text = decoder.decode(value);
455
+ if (text.trim()) {
456
+ logger.warn("Python runner stderr", { text });
457
+ }
458
+ }
459
+ } catch {
460
+ /* ignore */
461
+ } finally {
840
462
  try {
841
- await this.interrupt();
842
- } finally {
843
- finalize();
463
+ reader.releaseLock();
464
+ } catch {
465
+ /* ignore */
844
466
  }
845
- })();
467
+ }
846
468
  };
847
- controller.signal.addEventListener("abort", onExecutionAbort, { once: true });
469
+ void loop();
470
+ }
848
471
 
849
- if (controller.signal.aborted) {
850
- cancelFromClose("Execution aborted");
851
- return promise;
472
+ async #flushFrames(): Promise<void> {
473
+ while (true) {
474
+ const nl = this.#readBuffer.indexOf("\n");
475
+ if (nl < 0) return;
476
+ const line = this.#readBuffer.slice(0, nl);
477
+ this.#readBuffer = this.#readBuffer.slice(nl + 1);
478
+ if (!line.trim()) continue;
479
+ let frame: Frame;
480
+ try {
481
+ frame = JSON.parse(line) as Frame;
482
+ } catch (err) {
483
+ logger.warn("Python runner emitted invalid JSON", {
484
+ line: line.slice(0, 200),
485
+ error: err instanceof Error ? err.message : String(err),
486
+ });
487
+ continue;
488
+ }
489
+ if (TRACE_IPC) {
490
+ logger.debug("PythonKernel recv", { type: frame.type, id: frame.id });
491
+ }
492
+ await this.#handleFrame(frame);
852
493
  }
494
+ }
853
495
 
854
- this.#messageHandlers.set(msgId, async response => {
855
- switch (response.header.msg_type) {
856
- case "execute_reply": {
857
- replyReceived = true;
858
- const replyStatus = response.content.status;
859
- status = replyStatus === "error" ? "error" : "ok";
860
- if (typeof response.content.execution_count === "number") {
861
- executionCount = response.content.execution_count;
862
- }
863
- checkDone();
864
- break;
496
+ async #handleFrame(frame: Frame): Promise<void> {
497
+ const rid = frame.id;
498
+ if (!rid) return;
499
+ const pending = this.#pending.get(rid) as (PendingExecution & { finalize?: () => void }) | undefined;
500
+ if (!pending) return;
501
+
502
+ switch (frame.type) {
503
+ case "started":
504
+ return;
505
+ case "stdout":
506
+ case "stderr": {
507
+ const text = frame.data ?? "";
508
+ if (text && pending.options?.onChunk) {
509
+ await pending.options.onChunk(text);
865
510
  }
866
- case "stream": {
867
- const text = String(response.content.text ?? "");
868
- if (text && options?.onChunk) {
869
- await options.onChunk(text);
870
- }
871
- break;
511
+ return;
512
+ }
513
+ case "display":
514
+ case "result": {
515
+ const bundle = frame.bundle ?? {};
516
+ const { text, outputs } = await renderKernelDisplay(bundle);
517
+ if (text && pending.options?.onChunk) {
518
+ await pending.options.onChunk(text);
872
519
  }
873
- case "execute_result":
874
- case "display_data": {
875
- const { text, outputs } = await renderKernelDisplay(response.content);
876
- if (text && options?.onChunk) {
877
- await options.onChunk(text);
878
- }
879
- if (outputs.length > 0 && options?.onDisplay) {
880
- for (const output of outputs) {
881
- await options.onDisplay(output);
882
- }
520
+ if (outputs.length > 0 && pending.options?.onDisplay) {
521
+ for (const output of outputs) {
522
+ await pending.options.onDisplay(output);
883
523
  }
884
- break;
885
524
  }
886
- case "error": {
887
- const traceback = Array.isArray(response.content.traceback)
888
- ? response.content.traceback.map((line: unknown) => String(line))
889
- : [];
890
- error = {
891
- name: String(response.content.ename ?? "Error"),
892
- value: String(response.content.evalue ?? ""),
893
- traceback,
894
- };
895
- const text = traceback.length > 0 ? `${traceback.join("\n")}\n` : `${error.name}: ${error.value}\n`;
896
- if (options?.onChunk) {
897
- await options.onChunk(text);
898
- }
899
- break;
525
+ return;
526
+ }
527
+ case "error": {
528
+ const traceback = Array.isArray(frame.traceback) ? frame.traceback.map(String) : [];
529
+ pending.status = "error";
530
+ pending.error = {
531
+ name: String(frame.ename ?? "Error"),
532
+ value: String(frame.evalue ?? ""),
533
+ traceback,
534
+ };
535
+ const message =
536
+ traceback.length > 0 ? `${traceback.join("\n")}\n` : `${pending.error.name}: ${pending.error.value}\n`;
537
+ if (pending.options?.onChunk) {
538
+ await pending.options.onChunk(message);
900
539
  }
901
- case "status": {
902
- const state = response.content.execution_state;
903
- if (state === "idle") {
904
- idleReceived = true;
905
- checkDone();
906
- }
907
- break;
540
+ return;
541
+ }
542
+ case "done": {
543
+ if (typeof frame.executionCount === "number") {
544
+ pending.executionCount = frame.executionCount;
908
545
  }
909
- case "input_request": {
910
- stdinRequested = true;
911
- if (options?.onChunk) {
912
- await options.onChunk(
913
- "[stdin] Kernel requested input. Interactive stdin is not supported; provide input programmatically.\n",
914
- );
915
- }
916
- this.#sendMessage({
917
- channel: "stdin",
918
- header: {
919
- msg_id: Snowflake.next(),
920
- session: this.sessionId,
921
- username: this.username,
922
- date: new Date().toISOString(),
923
- msg_type: "input_reply",
924
- version: "5.5",
925
- },
926
- parent_header: response.header as unknown as Record<string, unknown>,
927
- metadata: {},
928
- content: { value: "" },
929
- });
930
- break;
546
+ if (frame.status === "error" && pending.status === "ok") {
547
+ pending.status = "error";
548
+ }
549
+ if (frame.cancelled) {
550
+ pending.cancelled = true;
931
551
  }
552
+ pending.finalize?.();
553
+ return;
932
554
  }
933
- });
934
-
935
- try {
936
- this.#sendMessage(msg);
937
- } catch {
938
- cancelled = true;
939
- finalize();
940
- }
941
- return promise;
942
- }
943
-
944
- async interrupt(): Promise<void> {
945
- try {
946
- await fetch(`${this.gatewayUrl}/api/kernels/${this.kernelId}/interrupt`, {
947
- method: "POST",
948
- headers: this.#authHeaders(),
949
- signal: AbortSignal.timeout(2000),
950
- });
951
- } catch (err: unknown) {
952
- logger.warn("Failed to interrupt kernel via API", { error: err instanceof Error ? err.message : String(err) });
953
- }
954
-
955
- try {
956
- const msg: JupyterMessage = {
957
- channel: "control",
958
- header: {
959
- msg_id: Snowflake.next(),
960
- session: this.sessionId,
961
- username: this.username,
962
- date: new Date().toISOString(),
963
- msg_type: "interrupt_request",
964
- version: "5.5",
965
- },
966
- parent_header: {},
967
- metadata: {},
968
- content: {},
969
- };
970
- this.#sendMessage(msg);
971
- } catch (err: unknown) {
972
- logger.warn("Failed to send interrupt request", { error: err instanceof Error ? err.message : String(err) });
973
555
  }
974
556
  }
975
557
 
976
- async shutdown(options?: KernelShutdownOptions): Promise<KernelShutdownResult> {
977
- if (this.#shutdownConfirmed) return { confirmed: true };
978
- if (!this.#shutdownStarted) {
979
- this.#shutdownStarted = true;
980
- this.#alive = false;
981
- this.#abortPendingExecutions("Kernel shutdown");
982
-
983
- if (this.#ws) {
984
- this.#ws.close();
985
- this.#ws = null;
558
+ async #executeWithBudget(
559
+ code: string,
560
+ signal: AbortSignal | undefined,
561
+ timeoutMs: number,
562
+ label: string,
563
+ ): Promise<void> {
564
+ const controller = new AbortController();
565
+ const cleanups: Array<() => void> = [];
566
+ if (signal) {
567
+ if (signal.aborted) {
568
+ controller.abort(signal.reason);
569
+ } else {
570
+ const onAbort = () => controller.abort(signal.reason);
571
+ signal.addEventListener("abort", onAbort, { once: true });
572
+ cleanups.push(() => signal.removeEventListener("abort", onAbort));
986
573
  }
987
574
  }
988
-
989
- const shutdownSignal = combineAbortSignal(
990
- { signal: options?.signal },
991
- options?.timeoutMs,
992
- "Python kernel shutdown timed out",
993
- );
994
-
995
- let confirmed = false;
575
+ const timer =
576
+ timeoutMs > 0
577
+ ? setTimeout(() => controller.abort(createAbortError("TimeoutError", `${label} timed out`)), timeoutMs)
578
+ : undefined;
579
+ if (timer) cleanups.push(() => clearTimeout(timer));
996
580
  try {
997
- const response = await fetch(`${this.gatewayUrl}/api/kernels/${this.kernelId}`, {
998
- method: "DELETE",
999
- headers: this.#authHeaders(),
1000
- signal: shutdownSignal,
581
+ throwIfAborted(controller.signal, label);
582
+ const result = await this.execute(code, {
583
+ signal: controller.signal,
584
+ silent: true,
585
+ storeHistory: false,
1001
586
  });
1002
- const deleteConfirmed = response.status === 404 || response.status === 410;
1003
- confirmed = response.ok || deleteConfirmed;
1004
- if (!confirmed) {
1005
- logger.warn("Kernel delete request was not confirmed", {
1006
- status: response.status,
1007
- statusText: response.statusText,
1008
- });
587
+ if (result.cancelled) {
588
+ throw createAbortError(result.timedOut ? "TimeoutError" : "AbortError", `${label} cancelled`);
1009
589
  }
1010
- } catch (err: unknown) {
1011
- logger.warn("Failed to delete kernel via API", { error: err instanceof Error ? err.message : String(err) });
1012
- }
1013
- this.#shutdownConfirmed = confirmed;
1014
- this.#disposed = confirmed;
1015
-
1016
- if (this.isSharedGateway) {
1017
- try {
1018
- await releaseSharedGateway();
1019
- } catch (err: unknown) {
1020
- logger.warn("Failed to release shared gateway after kernel shutdown", {
1021
- error: err instanceof Error ? err.message : String(err),
1022
- });
590
+ if (result.status === "error") {
591
+ const reason = result.error?.value ?? "Python kernel init failed";
592
+ throw new Error(`${label} failed: ${reason}`);
1023
593
  }
594
+ } finally {
595
+ for (const cleanup of cleanups) cleanup();
1024
596
  }
1025
-
1026
- return { confirmed };
1027
597
  }
1028
598
 
1029
- #sendMessage(msg: JupyterMessage): void {
1030
- if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) {
1031
- throw new Error("WebSocket not connected");
1032
- }
599
+ #waitForExitWithTimeout(timeoutMs: number): Promise<number | null> {
600
+ if (!this.#exitedPromise) return Promise.resolve(0);
601
+ const exitedPromise = this.#exitedPromise;
602
+ const timeout = new Promise<null>(resolve => {
603
+ const timer = setTimeout(() => resolve(null), Math.max(0, timeoutMs));
604
+ (timer as { unref?: () => void }).unref?.();
605
+ });
606
+ return Promise.race([exitedPromise.then(code => code as number | null), timeout]);
607
+ }
608
+ }
1033
609
 
1034
- if (TRACE_IPC) {
1035
- logger.debug("Kernel IPC send", {
1036
- channel: msg.channel,
1037
- msgType: msg.header.msg_type,
1038
- msgId: msg.header.msg_id,
1039
- });
1040
- }
610
+ function isTimeoutReason(reason: unknown): boolean {
611
+ if (reason instanceof DOMException) return reason.name === "TimeoutError";
612
+ if (reason instanceof Error) return reason.name === "TimeoutError";
613
+ return false;
614
+ }
1041
615
 
1042
- const payload = {
1043
- channel: msg.channel,
1044
- header: msg.header,
1045
- parent_header: msg.parent_header,
1046
- metadata: msg.metadata,
1047
- content: msg.content,
1048
- };
1049
- if (msg.buffers && msg.buffers.length > 0) {
1050
- this.#ws.send(serializeWebSocketMessage(msg));
1051
- return;
1052
- }
1053
- this.#ws.send(JSON.stringify(payload));
1054
- }
616
+ function buildInitScript(cwd: string, env?: Record<string, string | undefined>): string {
617
+ const envEntries = Object.entries(env ?? {}).filter(([, value]) => value !== undefined);
618
+ const envPayload = Object.fromEntries(envEntries);
619
+ return [
620
+ "import os, sys",
621
+ `__omp_cwd = ${JSON.stringify(cwd)}`,
622
+ "os.chdir(__omp_cwd)",
623
+ `__omp_env = ${JSON.stringify(envPayload)}`,
624
+ "for __omp_key, __omp_val in __omp_env.items():\n os.environ[__omp_key] = __omp_val",
625
+ "if __omp_cwd not in sys.path:\n sys.path.insert(0, __omp_cwd)",
626
+ ].join("\n");
1055
627
  }