@oh-my-pi/pi-coding-agent 5.4.2 → 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.
Files changed (97) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/docs/python-repl.md +77 -0
  3. package/examples/hooks/snake.ts +7 -7
  4. package/package.json +5 -5
  5. package/src/bun-imports.d.ts +6 -0
  6. package/src/cli/args.ts +7 -0
  7. package/src/cli/setup-cli.ts +231 -0
  8. package/src/cli.ts +2 -0
  9. package/src/core/agent-session.ts +118 -15
  10. package/src/core/bash-executor.ts +3 -84
  11. package/src/core/compaction/compaction.ts +10 -5
  12. package/src/core/extensions/index.ts +2 -0
  13. package/src/core/extensions/loader.ts +13 -1
  14. package/src/core/extensions/runner.ts +50 -2
  15. package/src/core/extensions/types.ts +67 -2
  16. package/src/core/keybindings.ts +51 -1
  17. package/src/core/prompt-templates.ts +15 -0
  18. package/src/core/python-executor-display.test.ts +42 -0
  19. package/src/core/python-executor-lifecycle.test.ts +99 -0
  20. package/src/core/python-executor-mapping.test.ts +41 -0
  21. package/src/core/python-executor-per-call.test.ts +49 -0
  22. package/src/core/python-executor-session.test.ts +103 -0
  23. package/src/core/python-executor-streaming.test.ts +77 -0
  24. package/src/core/python-executor-timeout.test.ts +35 -0
  25. package/src/core/python-executor.lifecycle.test.ts +139 -0
  26. package/src/core/python-executor.result.test.ts +49 -0
  27. package/src/core/python-executor.test.ts +180 -0
  28. package/src/core/python-executor.ts +313 -0
  29. package/src/core/python-gateway-coordinator.ts +832 -0
  30. package/src/core/python-kernel-display.test.ts +54 -0
  31. package/src/core/python-kernel-env.test.ts +138 -0
  32. package/src/core/python-kernel-session.test.ts +87 -0
  33. package/src/core/python-kernel-ws.test.ts +104 -0
  34. package/src/core/python-kernel.lifecycle.test.ts +249 -0
  35. package/src/core/python-kernel.test.ts +549 -0
  36. package/src/core/python-kernel.ts +1178 -0
  37. package/src/core/python-prelude.py +889 -0
  38. package/src/core/python-prelude.test.ts +140 -0
  39. package/src/core/python-prelude.ts +3 -0
  40. package/src/core/sdk.ts +24 -6
  41. package/src/core/session-manager.ts +174 -82
  42. package/src/core/settings-manager-python.test.ts +23 -0
  43. package/src/core/settings-manager.ts +202 -0
  44. package/src/core/streaming-output.test.ts +26 -0
  45. package/src/core/streaming-output.ts +100 -0
  46. package/src/core/system-prompt.python.test.ts +17 -0
  47. package/src/core/system-prompt.ts +3 -1
  48. package/src/core/timings.ts +1 -1
  49. package/src/core/tools/bash.ts +13 -2
  50. package/src/core/tools/edit-diff.ts +9 -1
  51. package/src/core/tools/index.test.ts +50 -23
  52. package/src/core/tools/index.ts +83 -1
  53. package/src/core/tools/python-execution.test.ts +68 -0
  54. package/src/core/tools/python-fallback.test.ts +72 -0
  55. package/src/core/tools/python-renderer.test.ts +36 -0
  56. package/src/core/tools/python-tool-mode.test.ts +43 -0
  57. package/src/core/tools/python.test.ts +121 -0
  58. package/src/core/tools/python.ts +760 -0
  59. package/src/core/tools/renderers.ts +2 -0
  60. package/src/core/tools/schema-validation.test.ts +1 -0
  61. package/src/core/tools/task/executor.ts +146 -3
  62. package/src/core/tools/task/worker-protocol.ts +32 -2
  63. package/src/core/tools/task/worker.ts +182 -15
  64. package/src/index.ts +6 -0
  65. package/src/main.ts +136 -40
  66. package/src/modes/interactive/components/custom-editor.ts +16 -31
  67. package/src/modes/interactive/components/extensions/extension-dashboard.ts +5 -16
  68. package/src/modes/interactive/components/extensions/extension-list.ts +5 -13
  69. package/src/modes/interactive/components/history-search.ts +5 -8
  70. package/src/modes/interactive/components/hook-editor.ts +3 -4
  71. package/src/modes/interactive/components/hook-input.ts +3 -3
  72. package/src/modes/interactive/components/hook-selector.ts +5 -15
  73. package/src/modes/interactive/components/index.ts +1 -0
  74. package/src/modes/interactive/components/keybinding-hints.ts +66 -0
  75. package/src/modes/interactive/components/model-selector.ts +53 -66
  76. package/src/modes/interactive/components/oauth-selector.ts +5 -5
  77. package/src/modes/interactive/components/session-selector.ts +29 -23
  78. package/src/modes/interactive/components/settings-defs.ts +404 -196
  79. package/src/modes/interactive/components/settings-selector.ts +14 -10
  80. package/src/modes/interactive/components/status-line-segment-editor.ts +7 -7
  81. package/src/modes/interactive/components/tool-execution.ts +8 -0
  82. package/src/modes/interactive/components/tree-selector.ts +29 -23
  83. package/src/modes/interactive/components/user-message-selector.ts +6 -17
  84. package/src/modes/interactive/controllers/command-controller.ts +86 -37
  85. package/src/modes/interactive/controllers/event-controller.ts +8 -0
  86. package/src/modes/interactive/controllers/extension-ui-controller.ts +51 -0
  87. package/src/modes/interactive/controllers/input-controller.ts +42 -6
  88. package/src/modes/interactive/interactive-mode.ts +56 -30
  89. package/src/modes/interactive/theme/theme-schema.json +2 -2
  90. package/src/modes/interactive/types.ts +6 -1
  91. package/src/modes/interactive/utils/ui-helpers.ts +2 -1
  92. package/src/modes/print-mode.ts +23 -0
  93. package/src/modes/rpc/rpc-mode.ts +21 -0
  94. package/src/prompts/agents/reviewer.md +1 -1
  95. package/src/prompts/system/system-prompt.md +32 -1
  96. package/src/prompts/tools/python.md +91 -0
  97. package/src/prompts/tools/task.md +5 -1
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { executePythonWithKernel, type PythonKernelExecutor } from "./python-executor";
3
+ import type { KernelExecuteOptions, KernelExecuteResult } from "./python-kernel";
4
+
5
+ class FakeKernel implements PythonKernelExecutor {
6
+ private result: KernelExecuteResult;
7
+ private onExecute?: (options?: KernelExecuteOptions) => void;
8
+
9
+ constructor(result: KernelExecuteResult, onExecute?: (options?: KernelExecuteOptions) => void) {
10
+ this.result = result;
11
+ this.onExecute = onExecute;
12
+ }
13
+
14
+ async execute(_code: string, options?: KernelExecuteOptions): Promise<KernelExecuteResult> {
15
+ this.onExecute?.(options);
16
+ return this.result;
17
+ }
18
+ }
19
+
20
+ describe("executePythonWithKernel result mapping", () => {
21
+ it("adds timeout annotation when cancelled", async () => {
22
+ const kernel = new FakeKernel({
23
+ status: "ok",
24
+ cancelled: true,
25
+ timedOut: true,
26
+ stdinRequested: false,
27
+ });
28
+
29
+ const result = await executePythonWithKernel(kernel, "sleep()", { timeout: 5000 });
30
+
31
+ expect(result.exitCode).toBeUndefined();
32
+ expect(result.cancelled).toBe(true);
33
+ expect(result.output).toContain("Command timed out after 5 seconds");
34
+ });
35
+
36
+ it("maps kernel error status to exit code 1", async () => {
37
+ const kernel = new FakeKernel(
38
+ { status: "error", cancelled: false, timedOut: false, stdinRequested: false },
39
+ (options) => {
40
+ options?.onChunk?.("Traceback...\n");
41
+ },
42
+ );
43
+
44
+ const result = await executePythonWithKernel(kernel, "raise ValueError('boom')");
45
+
46
+ expect(result.exitCode).toBe(1);
47
+ expect(result.output).toContain("Traceback");
48
+ });
49
+ });
@@ -0,0 +1,180 @@
1
+ import { afterEach, describe, expect, it, vi } from "bun:test";
2
+ import { rmSync } from "node:fs";
3
+ import {
4
+ disposeAllKernelSessions,
5
+ executePythonWithKernel,
6
+ getPreludeDocs,
7
+ type PythonKernelExecutor,
8
+ resetPreludeDocsCache,
9
+ warmPythonEnvironment,
10
+ } from "./python-executor";
11
+ import { type KernelExecuteOptions, type KernelExecuteResult, type PreludeHelper, PythonKernel } from "./python-kernel";
12
+ import { DEFAULT_MAX_BYTES } from "./tools/truncate";
13
+
14
+ class FakeKernel implements PythonKernelExecutor {
15
+ private result: KernelExecuteResult;
16
+ private onExecute: (options?: KernelExecuteOptions) => void;
17
+
18
+ constructor(result: KernelExecuteResult, onExecute: (options?: KernelExecuteOptions) => void) {
19
+ this.result = result;
20
+ this.onExecute = onExecute;
21
+ }
22
+
23
+ async execute(_code: string, options?: KernelExecuteOptions): Promise<KernelExecuteResult> {
24
+ this.onExecute(options);
25
+ return this.result;
26
+ }
27
+ }
28
+
29
+ describe("executePythonWithKernel", () => {
30
+ it("captures text and display outputs", async () => {
31
+ const kernel = new FakeKernel(
32
+ { status: "ok", cancelled: false, timedOut: false, stdinRequested: false },
33
+ (options) => {
34
+ options?.onChunk?.("hello\n");
35
+ options?.onDisplay?.({ type: "json", data: { foo: "bar" } });
36
+ },
37
+ );
38
+
39
+ const result = await executePythonWithKernel(kernel, "print('hello')");
40
+
41
+ expect(result.exitCode).toBe(0);
42
+ expect(result.output).toContain("hello");
43
+ expect(result.displayOutputs).toHaveLength(1);
44
+ });
45
+
46
+ it("marks stdin request as error", async () => {
47
+ const kernel = new FakeKernel(
48
+ { status: "ok", cancelled: false, timedOut: false, stdinRequested: true },
49
+ () => {},
50
+ );
51
+
52
+ const result = await executePythonWithKernel(kernel, "input('prompt')");
53
+
54
+ expect(result.exitCode).toBe(1);
55
+ expect(result.stdinRequested).toBe(true);
56
+ expect(result.output).toContain("Kernel requested stdin; interactive input is not supported.");
57
+ });
58
+
59
+ it("maps error status to exit code 1", async () => {
60
+ const kernel = new FakeKernel(
61
+ { status: "error", cancelled: false, timedOut: false, stdinRequested: false },
62
+ (options) => {
63
+ options?.onChunk?.("Traceback\n");
64
+ },
65
+ );
66
+
67
+ const result = await executePythonWithKernel(kernel, "raise ValueError('nope')");
68
+
69
+ expect(result.exitCode).toBe(1);
70
+ expect(result.cancelled).toBe(false);
71
+ expect(result.output).toContain("Traceback");
72
+ });
73
+
74
+ it("sanitizes streamed chunks", async () => {
75
+ const kernel = new FakeKernel(
76
+ { status: "ok", cancelled: false, timedOut: false, stdinRequested: false },
77
+ (options) => {
78
+ options?.onChunk?.("\u001b[31mred\r\n");
79
+ },
80
+ );
81
+
82
+ const result = await executePythonWithKernel(kernel, "print('red')");
83
+
84
+ expect(result.output).toBe("red\n");
85
+ });
86
+
87
+ it("returns cancelled result with timeout annotation", async () => {
88
+ const kernel = new FakeKernel(
89
+ { status: "ok", cancelled: true, timedOut: true, stdinRequested: false },
90
+ (options) => {
91
+ options?.onChunk?.("partial output\n");
92
+ },
93
+ );
94
+
95
+ const result = await executePythonWithKernel(kernel, "while True: pass", { timeout: 4100 });
96
+
97
+ expect(result.exitCode).toBeUndefined();
98
+ expect(result.cancelled).toBe(true);
99
+ expect(result.output).toContain("Command timed out after 4 seconds");
100
+ });
101
+
102
+ it("returns cancelled result without timeout annotation", async () => {
103
+ const kernel = new FakeKernel(
104
+ { status: "ok", cancelled: true, timedOut: false, stdinRequested: false },
105
+ (options) => {
106
+ options?.onChunk?.("cancelled output\n");
107
+ },
108
+ );
109
+
110
+ const result = await executePythonWithKernel(kernel, "while True: pass");
111
+
112
+ expect(result.exitCode).toBeUndefined();
113
+ expect(result.cancelled).toBe(true);
114
+ expect(result.output).toContain("cancelled output");
115
+ expect(result.output).not.toContain("Command timed out");
116
+ });
117
+
118
+ it("truncates large output and stores full output file", async () => {
119
+ const largeOutput = `${"x".repeat(DEFAULT_MAX_BYTES + 1024)}TAIL`;
120
+ const kernel = new FakeKernel(
121
+ { status: "ok", cancelled: false, timedOut: false, stdinRequested: false },
122
+ (options) => {
123
+ options?.onChunk?.(largeOutput);
124
+ },
125
+ );
126
+
127
+ const result = await executePythonWithKernel(kernel, "print('big')");
128
+
129
+ expect(result.truncated).toBe(true);
130
+ expect(result.fullOutputPath).toBeDefined();
131
+ expect(result.output).toContain("TAIL");
132
+
133
+ const fullText = await Bun.file(result.fullOutputPath as string).text();
134
+ expect(fullText).toBe(largeOutput);
135
+
136
+ rmSync(result.fullOutputPath as string, { force: true });
137
+ });
138
+ });
139
+
140
+ afterEach(async () => {
141
+ await disposeAllKernelSessions();
142
+ resetPreludeDocsCache();
143
+ vi.restoreAllMocks();
144
+ });
145
+
146
+ describe("warmPythonEnvironment", () => {
147
+ it("caches prelude docs on warmup", async () => {
148
+ const previousSkip = process.env.OMP_PYTHON_SKIP_CHECK;
149
+ process.env.OMP_PYTHON_SKIP_CHECK = "1";
150
+ const docs: PreludeHelper[] = [
151
+ {
152
+ name: "read",
153
+ signature: "(path)",
154
+ docstring: "Read file contents.",
155
+ category: "File I/O",
156
+ },
157
+ ];
158
+ const kernel = {
159
+ introspectPrelude: vi.fn().mockResolvedValue(docs),
160
+ ping: vi.fn().mockResolvedValue(true),
161
+ isAlive: () => true,
162
+ shutdown: vi.fn().mockResolvedValue(undefined),
163
+ };
164
+ const startSpy = vi.spyOn(PythonKernel, "start").mockResolvedValue(kernel as unknown as PythonKernel);
165
+
166
+ const result = await warmPythonEnvironment("/tmp/test", "session-1");
167
+
168
+ expect(result.ok).toBe(true);
169
+ expect(result.docs).toEqual(docs);
170
+ expect(getPreludeDocs()).toEqual(docs);
171
+ expect(kernel.introspectPrelude).toHaveBeenCalledTimes(1);
172
+
173
+ startSpy.mockRestore();
174
+ if (previousSkip === undefined) {
175
+ delete process.env.OMP_PYTHON_SKIP_CHECK;
176
+ } else {
177
+ process.env.OMP_PYTHON_SKIP_CHECK = previousSkip;
178
+ }
179
+ });
180
+ });
@@ -0,0 +1,313 @@
1
+ import stripAnsi from "strip-ansi";
2
+ import { sanitizeBinaryOutput } from "../utils/shell";
3
+ import { logger } from "./logger";
4
+ import {
5
+ checkPythonKernelAvailability,
6
+ type KernelDisplayOutput,
7
+ type KernelExecuteOptions,
8
+ type KernelExecuteResult,
9
+ type PreludeHelper,
10
+ PythonKernel,
11
+ } from "./python-kernel";
12
+ import { createOutputSink } from "./streaming-output";
13
+ import { DEFAULT_MAX_BYTES } from "./tools/truncate";
14
+
15
+ export type PythonKernelMode = "session" | "per-call";
16
+
17
+ export interface PythonExecutorOptions {
18
+ /** Working directory for command execution */
19
+ cwd?: string;
20
+ /** Timeout in milliseconds */
21
+ timeout?: number;
22
+ /** Callback for streaming output chunks (already sanitized) */
23
+ onChunk?: (chunk: string) => void;
24
+ /** AbortSignal for cancellation */
25
+ signal?: AbortSignal;
26
+ /** Session identifier for kernel reuse */
27
+ sessionId?: string;
28
+ /** Kernel mode (session reuse vs per-call) */
29
+ kernelMode?: PythonKernelMode;
30
+ /** Restart the kernel before executing */
31
+ reset?: boolean;
32
+ /** Use shared gateway across pi instances (default: true) */
33
+ useSharedGateway?: boolean;
34
+ }
35
+
36
+ export interface PythonKernelExecutor {
37
+ execute: (code: string, options?: KernelExecuteOptions) => Promise<KernelExecuteResult>;
38
+ }
39
+
40
+ export interface PythonResult {
41
+ /** Combined stdout + stderr output (sanitized, possibly truncated) */
42
+ output: string;
43
+ /** Execution exit code (0 ok, 1 error, undefined if cancelled) */
44
+ exitCode: number | undefined;
45
+ /** Whether the execution was cancelled via signal */
46
+ cancelled: boolean;
47
+ /** Whether the output was truncated */
48
+ truncated: boolean;
49
+ /** Path to temp file containing full output (if output exceeded truncation threshold) */
50
+ fullOutputPath?: string;
51
+ /** Rich display outputs captured from display_data/execute_result */
52
+ displayOutputs: KernelDisplayOutput[];
53
+ /** Whether stdin was requested */
54
+ stdinRequested: boolean;
55
+ }
56
+
57
+ interface KernelSession {
58
+ id: string;
59
+ kernel: PythonKernel;
60
+ queue: Promise<void>;
61
+ restartCount: number;
62
+ dead: boolean;
63
+ lastUsedAt: number;
64
+ heartbeatTimer?: NodeJS.Timeout;
65
+ }
66
+
67
+ const kernelSessions = new Map<string, KernelSession>();
68
+ let cachedPreludeDocs: PreludeHelper[] | null = null;
69
+
70
+ export async function disposeAllKernelSessions(): Promise<void> {
71
+ const sessions = Array.from(kernelSessions.values());
72
+ await Promise.allSettled(sessions.map((session) => disposeKernelSession(session)));
73
+ }
74
+
75
+ function sanitizeChunk(text: string): string {
76
+ return sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, "");
77
+ }
78
+
79
+ async function ensureKernelAvailable(cwd: string): Promise<void> {
80
+ const availability = await checkPythonKernelAvailability(cwd);
81
+ if (!availability.ok) {
82
+ throw new Error(availability.reason ?? "Python kernel unavailable");
83
+ }
84
+ }
85
+
86
+ export async function warmPythonEnvironment(
87
+ cwd: string,
88
+ sessionId?: string,
89
+ useSharedGateway?: boolean,
90
+ ): Promise<{ ok: boolean; reason?: string; docs: PreludeHelper[] }> {
91
+ try {
92
+ await ensureKernelAvailable(cwd);
93
+ } catch (err: unknown) {
94
+ const reason = err instanceof Error ? err.message : String(err);
95
+ cachedPreludeDocs = [];
96
+ return { ok: false, reason, docs: [] };
97
+ }
98
+ if (cachedPreludeDocs && cachedPreludeDocs.length > 0) {
99
+ return { ok: true, docs: cachedPreludeDocs };
100
+ }
101
+ const resolvedSessionId = sessionId ?? `session:${cwd}`;
102
+ try {
103
+ const docs = await withKernelSession(
104
+ resolvedSessionId,
105
+ cwd,
106
+ async (kernel) => kernel.introspectPrelude(),
107
+ useSharedGateway,
108
+ );
109
+ cachedPreludeDocs = docs;
110
+ return { ok: true, docs };
111
+ } catch (err: unknown) {
112
+ const reason = err instanceof Error ? err.message : String(err);
113
+ cachedPreludeDocs = [];
114
+ return { ok: false, reason, docs: [] };
115
+ }
116
+ }
117
+
118
+ export function getPreludeDocs(): PreludeHelper[] {
119
+ return cachedPreludeDocs ?? [];
120
+ }
121
+
122
+ export function resetPreludeDocsCache(): void {
123
+ cachedPreludeDocs = null;
124
+ }
125
+
126
+ async function createKernelSession(sessionId: string, cwd: string, useSharedGateway?: boolean): Promise<KernelSession> {
127
+ const kernel = await PythonKernel.start({ cwd, useSharedGateway });
128
+ const session: KernelSession = {
129
+ id: sessionId,
130
+ kernel,
131
+ queue: Promise.resolve(),
132
+ restartCount: 0,
133
+ dead: false,
134
+ lastUsedAt: Date.now(),
135
+ };
136
+
137
+ session.heartbeatTimer = setInterval(async () => {
138
+ if (session.dead) return;
139
+ const ok = await session.kernel.ping().catch(() => false);
140
+ if (!ok) {
141
+ session.dead = true;
142
+ }
143
+ }, 5000);
144
+
145
+ return session;
146
+ }
147
+
148
+ async function restartKernelSession(session: KernelSession, cwd: string, useSharedGateway?: boolean): Promise<void> {
149
+ session.restartCount += 1;
150
+ if (session.restartCount > 1) {
151
+ throw new Error("Python kernel restarted too many times in this session");
152
+ }
153
+ try {
154
+ await session.kernel.shutdown();
155
+ } catch (err) {
156
+ logger.warn("Failed to shutdown crashed kernel", { error: err instanceof Error ? err.message : String(err) });
157
+ }
158
+ const kernel = await PythonKernel.start({ cwd, useSharedGateway });
159
+ session.kernel = kernel;
160
+ session.dead = false;
161
+ session.lastUsedAt = Date.now();
162
+ }
163
+
164
+ async function disposeKernelSession(session: KernelSession): Promise<void> {
165
+ if (session.heartbeatTimer) {
166
+ clearInterval(session.heartbeatTimer);
167
+ }
168
+ try {
169
+ await session.kernel.shutdown();
170
+ } catch (err) {
171
+ logger.warn("Failed to shutdown kernel", { error: err instanceof Error ? err.message : String(err) });
172
+ }
173
+ kernelSessions.delete(session.id);
174
+ }
175
+
176
+ async function withKernelSession<T>(
177
+ sessionId: string,
178
+ cwd: string,
179
+ handler: (kernel: PythonKernel) => Promise<T>,
180
+ useSharedGateway?: boolean,
181
+ ): Promise<T> {
182
+ let session = kernelSessions.get(sessionId);
183
+ if (!session) {
184
+ session = await createKernelSession(sessionId, cwd, useSharedGateway);
185
+ kernelSessions.set(sessionId, session);
186
+ }
187
+
188
+ const run = async (): Promise<T> => {
189
+ session!.lastUsedAt = Date.now();
190
+ if (session!.dead || !session!.kernel.isAlive()) {
191
+ await restartKernelSession(session!, cwd, useSharedGateway);
192
+ }
193
+ try {
194
+ const result = await handler(session!.kernel);
195
+ session!.restartCount = 0;
196
+ return result;
197
+ } catch (err) {
198
+ if (!session!.dead && session!.kernel.isAlive()) {
199
+ throw err;
200
+ }
201
+ await restartKernelSession(session!, cwd, useSharedGateway);
202
+ const result = await handler(session!.kernel);
203
+ session!.restartCount = 0;
204
+ return result;
205
+ }
206
+ };
207
+
208
+ const task = session.queue.then(run, run);
209
+ session.queue = task.then(
210
+ () => undefined,
211
+ () => undefined,
212
+ );
213
+ return task;
214
+ }
215
+
216
+ async function executeWithKernel(
217
+ kernel: PythonKernelExecutor,
218
+ code: string,
219
+ options: PythonExecutorOptions | undefined,
220
+ ): Promise<PythonResult> {
221
+ const sink = createOutputSink(DEFAULT_MAX_BYTES, DEFAULT_MAX_BYTES * 2, options?.onChunk);
222
+ const writer = sink.getWriter();
223
+ const displayOutputs: KernelDisplayOutput[] = [];
224
+
225
+ try {
226
+ const result = await kernel.execute(code, {
227
+ signal: options?.signal,
228
+ timeoutMs: options?.timeout,
229
+ onChunk: async (text) => {
230
+ await writer.write(sanitizeChunk(text));
231
+ },
232
+ onDisplay: async (output) => {
233
+ displayOutputs.push(output);
234
+ },
235
+ });
236
+
237
+ if (result.cancelled) {
238
+ const secs = options?.timeout ? Math.round(options.timeout / 1000) : undefined;
239
+ const annotation =
240
+ result.timedOut && secs !== undefined ? `Command timed out after ${secs} seconds` : undefined;
241
+ return {
242
+ exitCode: undefined,
243
+ cancelled: true,
244
+ displayOutputs,
245
+ stdinRequested: result.stdinRequested,
246
+ ...sink.dump(annotation),
247
+ };
248
+ }
249
+
250
+ if (result.stdinRequested) {
251
+ return {
252
+ exitCode: 1,
253
+ cancelled: false,
254
+ displayOutputs,
255
+ stdinRequested: true,
256
+ ...sink.dump("Kernel requested stdin; interactive input is not supported."),
257
+ };
258
+ }
259
+
260
+ const exitCode = result.status === "ok" ? 0 : 1;
261
+ return {
262
+ exitCode,
263
+ cancelled: false,
264
+ displayOutputs,
265
+ stdinRequested: false,
266
+ ...sink.dump(),
267
+ };
268
+ } catch (err) {
269
+ const error = err instanceof Error ? err : new Error(String(err));
270
+ logger.error("Python execution failed", { error: error.message });
271
+ throw error;
272
+ } finally {
273
+ await writer.close();
274
+ }
275
+ }
276
+
277
+ export async function executePythonWithKernel(
278
+ kernel: PythonKernelExecutor,
279
+ code: string,
280
+ options?: PythonExecutorOptions,
281
+ ): Promise<PythonResult> {
282
+ return await executeWithKernel(kernel, code, options);
283
+ }
284
+
285
+ export async function executePython(code: string, options?: PythonExecutorOptions): Promise<PythonResult> {
286
+ const cwd = options?.cwd ?? process.cwd();
287
+ await ensureKernelAvailable(cwd);
288
+
289
+ const kernelMode = options?.kernelMode ?? "session";
290
+ const useSharedGateway = options?.useSharedGateway;
291
+ if (kernelMode === "per-call") {
292
+ const kernel = await PythonKernel.start({ cwd, useSharedGateway });
293
+ try {
294
+ return await executeWithKernel(kernel, code, options);
295
+ } finally {
296
+ await kernel.shutdown();
297
+ }
298
+ }
299
+
300
+ const sessionId = options?.sessionId ?? `session:${cwd}`;
301
+ if (options?.reset) {
302
+ const existing = kernelSessions.get(sessionId);
303
+ if (existing) {
304
+ await disposeKernelSession(existing);
305
+ }
306
+ }
307
+ return await withKernelSession(
308
+ sessionId,
309
+ cwd,
310
+ async (kernel) => executeWithKernel(kernel, code, options),
311
+ useSharedGateway,
312
+ );
313
+ }