@oh-my-pi/pi-coding-agent 9.2.2 → 9.2.4

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.
@@ -0,0 +1,597 @@
1
+ /**
2
+ * Persistent shell session executor for streaming bash tool output.
3
+ */
4
+ import * as crypto from "node:crypto";
5
+ import { logger, postmortem, ptree } from "@oh-my-pi/pi-utils";
6
+ import { OutputSink, type OutputSummary } from "../session/streaming-output";
7
+
8
+ export interface ShellSessionConfig {
9
+ shell: string;
10
+ env: Record<string, string | undefined>;
11
+ prefix?: string;
12
+ snapshotPath: string | null;
13
+ }
14
+
15
+ export interface ShellCommandOptions {
16
+ cwd?: string;
17
+ timeout?: number;
18
+ signal?: AbortSignal;
19
+ onChunk?: (chunk: string) => void;
20
+ env?: Record<string, string>;
21
+ artifactPath?: string;
22
+ artifactId?: string;
23
+ }
24
+
25
+ export interface ShellCommandResult extends OutputSummary {
26
+ exitCode: number | undefined;
27
+ cancelled: boolean;
28
+ }
29
+
30
+ const MARKER_PREFIX = "__OMP_CMD_DONE__";
31
+ const MARKER_TAIL_MAX = 128;
32
+ const ABORT_GRACE_MS = 1500;
33
+ const IS_WINDOWS = process.platform === "win32";
34
+
35
+ interface RunningCommand {
36
+ marker: string;
37
+ markerSentinel: string;
38
+ sink: OutputSink;
39
+ resolve: (result: ShellCommandResult) => void;
40
+ done: Promise<ShellCommandResult>;
41
+ cancelled: boolean;
42
+ abortReason?: "timeout" | "signal";
43
+ abortNotice?: string;
44
+ abortListener?: () => void;
45
+ completed: boolean;
46
+ }
47
+
48
+ function escapePosix(value: string): string {
49
+ return `'${value.split("'").join("'\"'\"'")}'`;
50
+ }
51
+
52
+ function isFishShell(shell: string): boolean {
53
+ return shell.includes("fish");
54
+ }
55
+
56
+ function buildEnvExports(env: Record<string, string> | undefined, fish: boolean): string {
57
+ if (!env) return "";
58
+ const entries = Object.entries(env).filter(([, value]) => value !== undefined);
59
+ if (entries.length === 0) return "";
60
+ if (fish) {
61
+ return entries.map(([key, value]) => `set -lx ${key} ${escapePosix(value)}`).join("\n");
62
+ }
63
+ return entries.map(([key, value]) => `export ${key}=${escapePosix(value)}`).join("\n");
64
+ }
65
+
66
+ function buildPosixCommandScript(
67
+ command: string,
68
+ cwd: string | undefined,
69
+ prefix: string | undefined,
70
+ marker: string,
71
+ commandEnv: Record<string, string> | undefined,
72
+ ): string {
73
+ const envExports = buildEnvExports(commandEnv, false);
74
+ const commandLine = prefix ? `${prefix} ${command}` : command;
75
+ const lines: string[] = [
76
+ "__omp_restore_errexit=0",
77
+ "case $- in *e*) __omp_restore_errexit=1 ;; esac",
78
+ "set +e",
79
+ "__omp_prev_trap_int=$(trap -p INT 2>/dev/null || true)",
80
+ "trap - INT",
81
+ "__omp_prev_exit=",
82
+ "__omp_prev_logout=",
83
+ "__omp_prev_exec=",
84
+ "if command -v typeset >/dev/null 2>&1; then __omp_prev_exit=$(typeset -f exit 2>/dev/null || true); fi",
85
+ "if command -v typeset >/dev/null 2>&1; then __omp_prev_logout=$(typeset -f logout 2>/dev/null || true); fi",
86
+ "if command -v typeset >/dev/null 2>&1; then __omp_prev_exec=$(typeset -f exec 2>/dev/null || true); fi",
87
+ 'exit() { if [ -n "$1" ]; then return "$1"; else return 0; fi; }',
88
+ 'logout() { if [ -n "$1" ]; then return "$1"; else return 0; fi; }',
89
+ 'exec() { command "$@"; return $?; }',
90
+ ];
91
+ if (envExports) lines.push(envExports);
92
+ if (cwd) lines.push(`cd -- ${escapePosix(cwd)}`);
93
+ // Redirect stdin from /dev/null to prevent interactive commands from blocking
94
+ // on the shell's stdin pipe (which is used for sending commands, not user input).
95
+ // Explicit pipes within the command (e.g., `echo "y" | cmd`) still work.
96
+ lines.push(commandLine.length > 0 ? `{ ${commandLine}; } < /dev/null` : ":");
97
+ lines.push("__omp_status=$?");
98
+ lines.push("unset -f exit logout exec 2>/dev/null");
99
+ lines.push('if [ -n "$__omp_prev_exit" ]; then eval "$__omp_prev_exit"; fi');
100
+ lines.push('if [ -n "$__omp_prev_logout" ]; then eval "$__omp_prev_logout"; fi');
101
+ lines.push('if [ -n "$__omp_prev_exec" ]; then eval "$__omp_prev_exec"; fi');
102
+ lines.push('if [ -n "$__omp_prev_trap_int" ]; then eval "$__omp_prev_trap_int"; else trap - INT; fi');
103
+ lines.push("unset __omp_prev_trap_int");
104
+ lines.push("unset __omp_prev_exit __omp_prev_logout __omp_prev_exec");
105
+ lines.push('if [ "$__omp_restore_errexit" -eq 1 ]; then set -e; fi');
106
+ lines.push("unset __omp_restore_errexit");
107
+ lines.push(`printf '\\n${marker}%d\\n' "$__omp_status"`);
108
+ return `${lines.join("\n")}\n`;
109
+ }
110
+
111
+ function buildFishCommandScript(
112
+ command: string,
113
+ cwd: string | undefined,
114
+ prefix: string | undefined,
115
+ marker: string,
116
+ commandEnv: Record<string, string> | undefined,
117
+ ): string {
118
+ const envExports = buildEnvExports(commandEnv, true);
119
+ const commandLine = prefix ? `${prefix} ${command}` : command;
120
+ const lines: string[] = [
121
+ "begin",
122
+ "functions -e __omp_prev_exit 2>/dev/null",
123
+ "functions -e __omp_prev_logout 2>/dev/null",
124
+ "functions -e __omp_prev_exec 2>/dev/null",
125
+ "functions -q exit; and functions -c exit __omp_prev_exit",
126
+ "functions -q logout; and functions -c logout __omp_prev_logout",
127
+ "functions -q exec; and functions -c exec __omp_prev_exec",
128
+ "function exit",
129
+ " if test (count $argv) -gt 0",
130
+ " set -g __omp_exit_code $argv[1]",
131
+ " else",
132
+ " set -g __omp_exit_code 0",
133
+ " end",
134
+ " return $__omp_exit_code",
135
+ "end",
136
+ "function logout",
137
+ " if test (count $argv) -gt 0",
138
+ " set -g __omp_exit_code $argv[1]",
139
+ " else",
140
+ " set -g __omp_exit_code 0",
141
+ " end",
142
+ " return $__omp_exit_code",
143
+ "end",
144
+ "function exec",
145
+ " command $argv",
146
+ " return $status",
147
+ "end",
148
+ ];
149
+ if (envExports) lines.push(envExports);
150
+ if (cwd) lines.push(`cd -- ${escapePosix(cwd)}`);
151
+ // Redirect stdin from /dev/null to prevent interactive commands from blocking
152
+ lines.push(commandLine.length > 0 ? `begin; ${commandLine}; end < /dev/null` : ":");
153
+ lines.push("if set -q __omp_exit_code");
154
+ lines.push(" set -l __omp_status $__omp_exit_code");
155
+ lines.push(" set -e __omp_exit_code");
156
+ lines.push("else");
157
+ lines.push(" set -l __omp_status $status");
158
+ lines.push("end");
159
+ lines.push("functions -e exit logout exec");
160
+ lines.push("functions -q __omp_prev_exit; and functions -c __omp_prev_exit exit; and functions -e __omp_prev_exit");
161
+ lines.push(
162
+ "functions -q __omp_prev_logout; and functions -c __omp_prev_logout logout; and functions -e __omp_prev_logout",
163
+ );
164
+ lines.push("functions -q __omp_prev_exec; and functions -c __omp_prev_exec exec; and functions -e __omp_prev_exec");
165
+ lines.push(`printf "\\n${marker}%d\\n" $__omp_status`);
166
+ lines.push("end");
167
+ return `${lines.join("\n")}\n`;
168
+ }
169
+
170
+ function getSessionArgs(shell: string, snapshotPath: string | null): string[] {
171
+ if (snapshotPath) return [];
172
+ const noLogin = process.env.OMP_BASH_NO_LOGIN || process.env.CLAUDE_BASH_NO_LOGIN;
173
+ if (noLogin) return [];
174
+ if (shell.includes("bash") || shell.includes("zsh") || shell.includes("fish")) return ["-l"];
175
+ return [];
176
+ }
177
+
178
+ function serializeEnv(env: Record<string, string | undefined>): string {
179
+ const entries = Object.entries(env).filter(([, value]) => value !== undefined);
180
+ entries.sort(([a], [b]) => a.localeCompare(b));
181
+ return entries.map(([key, value]) => `${key}=${value}`).join("\n");
182
+ }
183
+
184
+ function sanitizePersistentEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
185
+ const sanitized = { ...env };
186
+ delete sanitized.BASH_ENV;
187
+ delete sanitized.ENV;
188
+ return sanitized;
189
+ }
190
+
191
+ class ShellSession {
192
+ #child: ReturnType<typeof ptree.spawn<"pipe">> | null = null;
193
+ #stdinWriter: WritableStreamDefaultWriter<Uint8Array> | Bun.FileSink | null = null;
194
+ #buffer = "";
195
+ #queue: Promise<void> = Promise.resolve();
196
+ #chunkQueue: Promise<void> = Promise.resolve();
197
+ #current: RunningCommand | null = null;
198
+ #startPromise: Promise<void> | null = null;
199
+ #closed = false;
200
+ #encoder = new TextEncoder();
201
+ #lastExitCode: number | null | undefined = undefined;
202
+
203
+ constructor(private readonly config: ShellSessionConfig) {}
204
+
205
+ async execute(command: string, options: ShellCommandOptions): Promise<ShellCommandResult> {
206
+ const run = async () => {
207
+ try {
208
+ await this.#start();
209
+ return await this.#runCommand(command, options);
210
+ } catch (error) {
211
+ if (this.#shouldRestart(error)) {
212
+ await this.#terminateSession();
213
+ await this.#start();
214
+ return await this.#runCommand(command, options);
215
+ }
216
+ throw error;
217
+ }
218
+ };
219
+
220
+ const queued = this.#queue.then(run, run);
221
+ this.#queue = queued.then(
222
+ () => {},
223
+ () => {},
224
+ );
225
+ return queued;
226
+ }
227
+
228
+ async dispose(): Promise<void> {
229
+ this.#closed = true;
230
+ const child = this.#child;
231
+ this.#child = null;
232
+ this.#stdinWriter = null;
233
+ if (child) {
234
+ child.kill();
235
+ await child.exited.catch(() => {});
236
+ }
237
+ }
238
+
239
+ async #start(): Promise<void> {
240
+ if (this.#closed) {
241
+ throw new Error("Shell session is closed");
242
+ }
243
+ if (this.#startPromise) return this.#startPromise;
244
+ this.#startPromise = this.#spawnShell().catch(error => {
245
+ this.#startPromise = null;
246
+ throw error;
247
+ });
248
+ return this.#startPromise;
249
+ }
250
+
251
+ async #spawnShell(): Promise<void> {
252
+ const args = getSessionArgs(this.config.shell, this.config.snapshotPath);
253
+ this.#child = ptree.spawn([this.config.shell, ...args], {
254
+ stdin: "pipe",
255
+ env: this.config.env,
256
+ detached: !IS_WINDOWS,
257
+ });
258
+
259
+ if (this.#child.proc.exitCode !== null) {
260
+ this.#lastExitCode = this.#child.proc.exitCode;
261
+ throw new Error(`Shell exited immediately with code ${this.#child.proc.exitCode}`);
262
+ }
263
+
264
+ const stdin = this.#child.stdin;
265
+ if (stdin && typeof stdin === "object" && "getWriter" in stdin) {
266
+ this.#stdinWriter = (stdin as unknown as WritableStream<Uint8Array>).getWriter();
267
+ } else {
268
+ this.#stdinWriter = stdin as Bun.FileSink;
269
+ }
270
+ this.#attachStreams(this.#child);
271
+ this.#child.exited.then(code => this.#handleShellExit(code)).catch(() => this.#handleShellExit(null));
272
+
273
+ const initCommand = this.#buildInitCommand();
274
+ if (initCommand) {
275
+ await this.#runCommand(initCommand, {});
276
+ }
277
+ }
278
+
279
+ #buildInitCommand(): string | null {
280
+ if (!this.config.snapshotPath) return null;
281
+ const snapshotPath = escapePosix(this.config.snapshotPath);
282
+ if (isFishShell(this.config.shell)) {
283
+ return `source ${snapshotPath}`;
284
+ }
285
+ return `source ${snapshotPath} 2>/dev/null`;
286
+ }
287
+
288
+ #attachStreams(child: ReturnType<typeof ptree.spawn<"pipe">>): void {
289
+ const readStream = async (stream: ReadableStream<Uint8Array>) => {
290
+ const reader = stream.getReader();
291
+ const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
292
+ try {
293
+ while (true) {
294
+ const { done, value } = await reader.read();
295
+ if (done) break;
296
+ if (!value) continue;
297
+ const text = decoder.decode(value, { stream: true });
298
+ if (text) {
299
+ await this.#enqueueChunk(text);
300
+ }
301
+ }
302
+ const remaining = decoder.decode();
303
+ if (remaining) {
304
+ await this.#enqueueChunk(remaining);
305
+ }
306
+ } catch {
307
+ // ignore
308
+ } finally {
309
+ try {
310
+ await reader.cancel();
311
+ } catch {}
312
+ reader.releaseLock();
313
+ }
314
+ };
315
+
316
+ void readStream(child.stdout);
317
+ void readStream(child.stderr);
318
+ }
319
+
320
+ async #enqueueChunk(text: string): Promise<void> {
321
+ this.#chunkQueue = this.#chunkQueue.then(() => this.#processChunk(text));
322
+ return this.#chunkQueue;
323
+ }
324
+
325
+ async #processChunk(text: string): Promise<void> {
326
+ const running = this.#current;
327
+ if (!running) return;
328
+ this.#buffer += text;
329
+
330
+ const sentinel = running.markerSentinel;
331
+ while (this.#buffer.length > 0) {
332
+ const markerIndex = this.#buffer.indexOf(sentinel);
333
+ if (markerIndex === -1) {
334
+ const lastNewline = this.#buffer.lastIndexOf("\n");
335
+ if (lastNewline > -1) {
336
+ const tail = this.#buffer.slice(lastNewline);
337
+ const flushLength = tail.length <= MARKER_TAIL_MAX ? lastNewline : this.#buffer.length - MARKER_TAIL_MAX;
338
+ if (flushLength > 0) {
339
+ await running.sink.push(this.#buffer.slice(0, flushLength));
340
+ this.#buffer = this.#buffer.slice(flushLength);
341
+ }
342
+ return;
343
+ }
344
+ const flushLength = Math.max(0, this.#buffer.length - Math.min(sentinel.length, MARKER_TAIL_MAX));
345
+ if (flushLength > 0) {
346
+ await running.sink.push(this.#buffer.slice(0, flushLength));
347
+ this.#buffer = this.#buffer.slice(flushLength);
348
+ }
349
+ return;
350
+ }
351
+
352
+ if (markerIndex > 0) {
353
+ await running.sink.push(this.#buffer.slice(0, markerIndex));
354
+ }
355
+
356
+ const markerValueStart = markerIndex + sentinel.length;
357
+ const lineEnd = this.#buffer.indexOf("\n", markerValueStart);
358
+ if (lineEnd === -1) {
359
+ this.#buffer = this.#buffer.slice(markerIndex);
360
+ return;
361
+ }
362
+
363
+ const exitText = this.#buffer.slice(markerValueStart, lineEnd).trim();
364
+ const exitCode = Number.parseInt(exitText, 10);
365
+ this.#buffer = this.#buffer.slice(lineEnd + 1);
366
+ await this.#finishCommand(running, Number.isFinite(exitCode) ? exitCode : undefined);
367
+ this.#buffer = "";
368
+ return;
369
+ }
370
+ }
371
+
372
+ async #runCommand(command: string, options: ShellCommandOptions): Promise<ShellCommandResult> {
373
+ if (!this.#child || !this.#stdinWriter) {
374
+ const exitInfo = this.#lastExitCode === undefined ? "unknown" : String(this.#lastExitCode);
375
+ throw new Error(`Shell session not started (shell=${this.config.shell}, exit=${exitInfo})`);
376
+ }
377
+ this.#buffer = "";
378
+
379
+ const markerId = crypto.randomUUID().replace(/-/g, "");
380
+ const marker = `${MARKER_PREFIX}${markerId}__`;
381
+ const markerSentinel = `\n${marker}`;
382
+
383
+ const sink = new OutputSink({
384
+ onChunk: options.onChunk,
385
+ artifactPath: options.artifactPath,
386
+ artifactId: options.artifactId,
387
+ });
388
+
389
+ const { promise, resolve } = Promise.withResolvers<ShellCommandResult>();
390
+ const running: RunningCommand = {
391
+ marker,
392
+ markerSentinel,
393
+ sink,
394
+ resolve,
395
+ done: promise,
396
+ cancelled: false,
397
+ completed: false,
398
+ };
399
+
400
+ this.#current = running;
401
+
402
+ const timeoutSignal = options.timeout ? AbortSignal.timeout(options.timeout) : undefined;
403
+ let timeoutFired = false;
404
+ if (timeoutSignal) {
405
+ timeoutSignal.addEventListener(
406
+ "abort",
407
+ () => {
408
+ timeoutFired = true;
409
+ },
410
+ { once: true },
411
+ );
412
+ }
413
+
414
+ const combinedSignal = options.signal
415
+ ? AbortSignal.any(timeoutSignal ? [options.signal, timeoutSignal] : [options.signal])
416
+ : timeoutSignal;
417
+
418
+ if (combinedSignal) {
419
+ const onAbort = () => {
420
+ void this.#abortCommand(running, timeoutFired ? "timeout" : "signal", options.timeout);
421
+ };
422
+ running.abortListener = () => combinedSignal.removeEventListener("abort", onAbort);
423
+ if (combinedSignal.aborted) {
424
+ void this.#abortCommand(running, timeoutFired ? "timeout" : "signal", options.timeout);
425
+ } else {
426
+ combinedSignal.addEventListener("abort", onAbort, { once: true });
427
+ }
428
+ }
429
+
430
+ try {
431
+ const script = isFishShell(this.config.shell)
432
+ ? buildFishCommandScript(command, options.cwd, this.config.prefix, marker, options.env)
433
+ : buildPosixCommandScript(command, options.cwd, this.config.prefix, marker, options.env);
434
+ await this.#writeToStdin(script);
435
+ } catch (error) {
436
+ await this.#handleWriteFailure(error instanceof Error ? error : new Error(String(error)));
437
+ }
438
+
439
+ return await promise;
440
+ }
441
+
442
+ async #finishCommand(running: RunningCommand, exitCode: number | undefined): Promise<void> {
443
+ if (running.completed) return;
444
+ running.completed = true;
445
+ running.abortListener?.();
446
+ this.#current = null;
447
+ const summary = await running.sink.dump(running.cancelled ? running.abortNotice : undefined);
448
+ running.resolve({
449
+ exitCode: running.cancelled ? undefined : exitCode,
450
+ cancelled: running.cancelled,
451
+ ...summary,
452
+ });
453
+ }
454
+
455
+ async #abortCommand(
456
+ running: RunningCommand,
457
+ reason: "timeout" | "signal",
458
+ timeoutMs: number | undefined,
459
+ ): Promise<void> {
460
+ if (running.completed) return;
461
+ running.cancelled = true;
462
+ running.abortReason = reason;
463
+ const notice =
464
+ reason === "timeout" && timeoutMs
465
+ ? `Command timed out after ${Math.round(timeoutMs / 1000)} seconds`
466
+ : "Command cancelled";
467
+ running.abortNotice = notice;
468
+
469
+ await this.#sendInterrupt();
470
+ const completed = await Promise.race([
471
+ running.done.then(
472
+ () => true,
473
+ () => true,
474
+ ),
475
+ Bun.sleep(ABORT_GRACE_MS).then(() => false),
476
+ ]);
477
+ if (completed) return;
478
+
479
+ await this.#terminateSession();
480
+ if (running.completed) return;
481
+ running.completed = true;
482
+ running.abortListener?.();
483
+ this.#current = null;
484
+ const summary = await running.sink.dump(notice);
485
+ running.resolve({
486
+ exitCode: undefined,
487
+ cancelled: true,
488
+ ...summary,
489
+ });
490
+ }
491
+
492
+ async #sendInterrupt(): Promise<void> {
493
+ const child = this.#child;
494
+ if (!child?.pid) return;
495
+ try {
496
+ if (IS_WINDOWS) {
497
+ child.proc.kill("SIGINT");
498
+ return;
499
+ }
500
+ if (child.isProcessGroup) {
501
+ process.kill(-child.pid, "SIGINT");
502
+ } else {
503
+ process.kill(child.pid, "SIGINT");
504
+ }
505
+ } catch {}
506
+ }
507
+
508
+ async #terminateSession(): Promise<void> {
509
+ const child = this.#child;
510
+ this.#child = null;
511
+ this.#stdinWriter = null;
512
+ this.#startPromise = null;
513
+ if (child) {
514
+ child.kill();
515
+ await child.exited.catch(() => {});
516
+ }
517
+ }
518
+
519
+ async #handleShellExit(exitCode: number | null): Promise<void> {
520
+ const running = this.#current;
521
+ this.#lastExitCode = exitCode;
522
+ this.#child = null;
523
+ this.#stdinWriter = null;
524
+ this.#startPromise = null;
525
+ this.#buffer = "";
526
+
527
+ if (!running || running.completed) return;
528
+ running.cancelled = true;
529
+ running.abortReason = "signal";
530
+ running.completed = true;
531
+ running.abortListener?.();
532
+ this.#current = null;
533
+ const summary = await running.sink.dump(running.abortNotice ?? "Shell session terminated");
534
+ running.resolve({
535
+ exitCode: undefined,
536
+ cancelled: true,
537
+ ...summary,
538
+ });
539
+ }
540
+
541
+ async #handleWriteFailure(error: Error): Promise<void> {
542
+ logger.warn("Shell session write failed", { error: error.message });
543
+ await this.#terminateSession();
544
+ throw error;
545
+ }
546
+
547
+ #shouldRestart(error: unknown): boolean {
548
+ if (!(error instanceof Error)) return false;
549
+ return (
550
+ error.message.includes("Shell session not started") ||
551
+ error.message.includes("Shell session stdin unavailable")
552
+ );
553
+ }
554
+
555
+ async #writeToStdin(script: string): Promise<void> {
556
+ if (!this.#stdinWriter) {
557
+ throw new Error("Shell session stdin unavailable");
558
+ }
559
+ const payload = this.#encoder.encode(script);
560
+ const writer = this.#stdinWriter;
561
+ await Promise.resolve(writer.write(payload));
562
+ }
563
+ }
564
+
565
+ const sessions = new Map<string, ShellSession>();
566
+
567
+ function buildSessionKey(config: ShellSessionConfig): string {
568
+ return [config.shell, config.prefix ?? "", config.snapshotPath ?? "", serializeEnv(config.env)].join("\n");
569
+ }
570
+
571
+ export async function executeShellCommand(
572
+ config: ShellSessionConfig,
573
+ command: string,
574
+ options: ShellCommandOptions,
575
+ ): Promise<ShellCommandResult> {
576
+ const sanitizedConfig = { ...config, env: sanitizePersistentEnv(config.env) };
577
+ const key = buildSessionKey(sanitizedConfig);
578
+ let session = sessions.get(key);
579
+ if (!session) {
580
+ session = new ShellSession(sanitizedConfig);
581
+ sessions.set(key, session);
582
+ }
583
+ return await session.execute(command, options);
584
+ }
585
+
586
+ export const __testing = {
587
+ buildPosixCommandScript,
588
+ buildFishCommandScript,
589
+ escapePosix,
590
+ getSessionArgs,
591
+ };
592
+
593
+ postmortem.register("shell-session", async () => {
594
+ const active = Array.from(sessions.values());
595
+ sessions.clear();
596
+ await Promise.all(active.map(session => session.dispose()));
597
+ });
@@ -163,25 +163,25 @@ function filterEnv(env: Record<string, string | undefined>): Record<string, stri
163
163
  return filtered;
164
164
  }
165
165
 
166
- async function resolveVenvPath(cwd: string): Promise<string | null> {
166
+ function resolveVenvPath(cwd: string): string | null {
167
167
  if (process.env.VIRTUAL_ENV) return process.env.VIRTUAL_ENV;
168
168
  const candidates = [path.join(cwd, ".venv"), path.join(cwd, "venv")];
169
169
  for (const candidate of candidates) {
170
- if (await Bun.file(candidate).exists()) {
170
+ if (fs.existsSync(candidate)) {
171
171
  return candidate;
172
172
  }
173
173
  }
174
174
  return null;
175
175
  }
176
176
 
177
- async function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>) {
177
+ function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>) {
178
178
  const env = { ...baseEnv };
179
- const venvPath = env.VIRTUAL_ENV ?? (await resolveVenvPath(cwd));
179
+ const venvPath = env.VIRTUAL_ENV ?? resolveVenvPath(cwd);
180
180
  if (venvPath) {
181
181
  env.VIRTUAL_ENV = venvPath;
182
182
  const binDir = process.platform === "win32" ? path.join(venvPath, "Scripts") : path.join(venvPath, "bin");
183
183
  const pythonCandidate = path.join(binDir, process.platform === "win32" ? "python.exe" : "python");
184
- if (await Bun.file(pythonCandidate).exists()) {
184
+ if (fs.existsSync(pythonCandidate)) {
185
185
  const pathKey = resolvePathKey(env);
186
186
  const currentPath = env[pathKey];
187
187
  env[pathKey] = currentPath ? `${binDir}${path.delimiter}${currentPath}` : binDir;
package/src/ipy/kernel.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs";
1
2
  import { createServer } from "node:net";
2
3
  import * as path from "node:path";
3
4
  import { logger, ptree } from "@oh-my-pi/pi-utils";
@@ -235,25 +236,25 @@ function filterEnv(env: Record<string, string | undefined>): Record<string, stri
235
236
  return filtered;
236
237
  }
237
238
 
238
- async function resolveVenvPath(cwd: string): Promise<string | null> {
239
+ function resolveVenvPath(cwd: string): string | null {
239
240
  if (process.env.VIRTUAL_ENV) return process.env.VIRTUAL_ENV;
240
241
  const candidates = [path.join(cwd, ".venv"), path.join(cwd, "venv")];
241
242
  for (const candidate of candidates) {
242
- if (await Bun.file(candidate).exists()) {
243
+ if (fs.existsSync(candidate)) {
243
244
  return candidate;
244
245
  }
245
246
  }
246
247
  return null;
247
248
  }
248
249
 
249
- async function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>) {
250
+ function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>) {
250
251
  const env = { ...baseEnv };
251
- const venvPath = env.VIRTUAL_ENV ?? (await resolveVenvPath(cwd));
252
+ const venvPath = env.VIRTUAL_ENV ?? resolveVenvPath(cwd);
252
253
  if (venvPath) {
253
254
  env.VIRTUAL_ENV = venvPath;
254
255
  const binDir = process.platform === "win32" ? path.join(venvPath, "Scripts") : path.join(venvPath, "bin");
255
256
  const pythonCandidate = path.join(binDir, process.platform === "win32" ? "python.exe" : "python");
256
- if (await Bun.file(pythonCandidate).exists()) {
257
+ if (fs.existsSync(pythonCandidate)) {
257
258
  const pathKey = resolvePathKey(env);
258
259
  const currentPath = env[pathKey];
259
260
  env[pathKey] = currentPath ? `${binDir}${path.delimiter}${currentPath}` : binDir;
@@ -281,7 +282,7 @@ export async function checkPythonKernelAvailability(cwd: string): Promise<Python
281
282
  try {
282
283
  const { env } = await SettingsManager.getGlobalShellConfig();
283
284
  const baseEnv = filterEnv(env);
284
- const runtime = await resolvePythonRuntime(cwd, baseEnv);
285
+ const runtime = resolvePythonRuntime(cwd, baseEnv);
285
286
  const checkScript =
286
287
  "import importlib.util,sys;sys.exit(0 if importlib.util.find_spec('kernel_gateway') and importlib.util.find_spec('ipykernel') else 1)";
287
288
  const result = await $`${runtime.pythonPath} -c ${checkScript}`.quiet().nothrow().cwd(cwd).env(runtime.env);
@@ -613,7 +614,7 @@ export class PythonKernel {
613
614
  private static async startWithLocalGateway(options: KernelStartOptions): Promise<PythonKernel> {
614
615
  const { shell, env } = await SettingsManager.getGlobalShellConfig();
615
616
  const filteredEnv = filterEnv(env);
616
- const runtime = await resolvePythonRuntime(options.cwd, filteredEnv);
617
+ const runtime = resolvePythonRuntime(options.cwd, filteredEnv);
617
618
  const snapshotPath = await getOrCreateSnapshot(shell, env).catch((err: unknown) => {
618
619
  logger.warn("Failed to resolve shell snapshot for Python kernel", {
619
620
  error: err instanceof Error ? err.message : String(err),