@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.
- package/CHANGELOG.md +32 -0
- package/package.json +7 -7
- package/src/cli/update-cli.ts +2 -7
- package/src/commit/changelog/detect.ts +2 -1
- package/src/commit/changelog/index.ts +2 -1
- package/src/config/prompt-templates.ts +2 -1
- package/src/config/settings-manager.ts +39 -3
- package/src/exec/bash-executor.ts +53 -3
- package/src/exec/shell-session.ts +597 -0
- package/src/ipy/gateway-coordinator.ts +5 -5
- package/src/ipy/kernel.ts +8 -7
- package/src/lsp/config.ts +13 -16
- package/src/lsp/index.ts +5 -5
- package/src/modes/components/settings-defs.ts +9 -0
- package/src/modes/components/status-line.ts +28 -32
- package/src/patch/applicator.ts +5 -4
- package/src/prompts/system/system-prompt.md +42 -58
- package/src/sdk.ts +3 -0
- package/src/system-prompt.ts +5 -0
- package/src/utils/shell-snapshot.ts +27 -13
- package/src/utils/tools-manager.ts +9 -9
|
@@ -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
|
-
|
|
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 (
|
|
170
|
+
if (fs.existsSync(candidate)) {
|
|
171
171
|
return candidate;
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
174
|
return null;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>) {
|
|
178
178
|
const env = { ...baseEnv };
|
|
179
|
-
const venvPath = env.VIRTUAL_ENV ??
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
243
|
+
if (fs.existsSync(candidate)) {
|
|
243
244
|
return candidate;
|
|
244
245
|
}
|
|
245
246
|
}
|
|
246
247
|
return null;
|
|
247
248
|
}
|
|
248
249
|
|
|
249
|
-
|
|
250
|
+
function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string | undefined>) {
|
|
250
251
|
const env = { ...baseEnv };
|
|
251
|
-
const venvPath = env.VIRTUAL_ENV ??
|
|
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 (
|
|
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 =
|
|
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 =
|
|
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),
|