@oh-my-pi/pi-coding-agent 9.7.0 → 10.0.0

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.
@@ -1,604 +0,0 @@
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
- #streamsDone: Promise<unknown> = Promise.resolve();
198
- #current: RunningCommand | null = null;
199
- #startPromise: Promise<void> | null = null;
200
- #closed = false;
201
- #encoder = new TextEncoder();
202
- #lastExitCode: number | null | undefined = undefined;
203
-
204
- constructor(private readonly config: ShellSessionConfig) {}
205
-
206
- async execute(command: string, options: ShellCommandOptions): Promise<ShellCommandResult> {
207
- const run = async () => {
208
- try {
209
- await this.#start();
210
- return await this.#runCommand(command, options);
211
- } catch (error) {
212
- if (this.#shouldRestart(error)) {
213
- await this.#terminateSession();
214
- await this.#start();
215
- return await this.#runCommand(command, options);
216
- }
217
- throw error;
218
- }
219
- };
220
-
221
- const queued = this.#queue.then(run, run);
222
- this.#queue = queued.then(
223
- () => {},
224
- () => {},
225
- );
226
- return queued;
227
- }
228
-
229
- async dispose(): Promise<void> {
230
- this.#closed = true;
231
- const child = this.#child;
232
- this.#child = null;
233
- this.#stdinWriter = null;
234
- if (child) {
235
- child.kill();
236
- await child.exited.catch(() => {});
237
- }
238
- }
239
-
240
- async #start(): Promise<void> {
241
- if (this.#closed) {
242
- throw new Error("Shell session is closed");
243
- }
244
- if (this.#startPromise) return this.#startPromise;
245
- this.#startPromise = this.#spawnShell().catch(error => {
246
- this.#startPromise = null;
247
- throw error;
248
- });
249
- return this.#startPromise;
250
- }
251
-
252
- async #spawnShell(): Promise<void> {
253
- const args = getSessionArgs(this.config.shell, this.config.snapshotPath);
254
- this.#child = ptree.spawn([this.config.shell, ...args], {
255
- stdin: "pipe",
256
- env: this.config.env,
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
- this.#streamsDone = Promise.allSettled([readStream(child.stdout), readStream(child.stderr)]);
317
- }
318
-
319
- async #enqueueChunk(text: string): Promise<void> {
320
- this.#chunkQueue = this.#chunkQueue.then(() => this.#processChunk(text));
321
- return this.#chunkQueue;
322
- }
323
-
324
- async #processChunk(text: string): Promise<void> {
325
- const running = this.#current;
326
- if (!running) return;
327
- this.#buffer += text;
328
-
329
- const sentinel = running.markerSentinel;
330
- while (this.#buffer.length > 0) {
331
- const markerIndex = this.#buffer.indexOf(sentinel);
332
- if (markerIndex === -1) {
333
- const lastNewline = this.#buffer.lastIndexOf("\n");
334
- if (lastNewline > -1) {
335
- const tail = this.#buffer.slice(lastNewline);
336
- const flushLength = tail.length <= MARKER_TAIL_MAX ? lastNewline : this.#buffer.length - MARKER_TAIL_MAX;
337
- if (flushLength > 0) {
338
- await running.sink.push(this.#buffer.slice(0, flushLength));
339
- this.#buffer = this.#buffer.slice(flushLength);
340
- }
341
- return;
342
- }
343
- const flushLength = Math.max(0, this.#buffer.length - Math.min(sentinel.length, MARKER_TAIL_MAX));
344
- if (flushLength > 0) {
345
- await running.sink.push(this.#buffer.slice(0, flushLength));
346
- this.#buffer = this.#buffer.slice(flushLength);
347
- }
348
- return;
349
- }
350
-
351
- if (markerIndex > 0) {
352
- await running.sink.push(this.#buffer.slice(0, markerIndex));
353
- }
354
-
355
- const markerValueStart = markerIndex + sentinel.length;
356
- const lineEnd = this.#buffer.indexOf("\n", markerValueStart);
357
- if (lineEnd === -1) {
358
- this.#buffer = this.#buffer.slice(markerIndex);
359
- return;
360
- }
361
-
362
- const exitText = this.#buffer.slice(markerValueStart, lineEnd).trim();
363
- const exitCode = Number.parseInt(exitText, 10);
364
- this.#buffer = this.#buffer.slice(lineEnd + 1);
365
- await this.#finishCommand(running, Number.isFinite(exitCode) ? exitCode : undefined);
366
- this.#buffer = "";
367
- return;
368
- }
369
- }
370
-
371
- async #runCommand(command: string, options: ShellCommandOptions): Promise<ShellCommandResult> {
372
- if (!this.#child || !this.#stdinWriter) {
373
- const exitInfo = this.#lastExitCode === undefined ? "unknown" : String(this.#lastExitCode);
374
- throw new Error(`Shell session not started (shell=${this.config.shell}, exit=${exitInfo})`);
375
- }
376
- this.#buffer = "";
377
-
378
- const markerId = crypto.randomUUID().replace(/-/g, "");
379
- const marker = `${MARKER_PREFIX}${markerId}__`;
380
- const markerSentinel = `\n${marker}`;
381
-
382
- const sink = new OutputSink({
383
- onChunk: options.onChunk,
384
- artifactPath: options.artifactPath,
385
- artifactId: options.artifactId,
386
- });
387
-
388
- const { promise, resolve } = Promise.withResolvers<ShellCommandResult>();
389
- const running: RunningCommand = {
390
- marker,
391
- markerSentinel,
392
- sink,
393
- resolve,
394
- done: promise,
395
- cancelled: false,
396
- completed: false,
397
- };
398
-
399
- this.#current = running;
400
-
401
- const timeoutSignal = options.timeout ? AbortSignal.timeout(options.timeout) : undefined;
402
- let timeoutFired = false;
403
- if (timeoutSignal) {
404
- timeoutSignal.addEventListener(
405
- "abort",
406
- () => {
407
- timeoutFired = true;
408
- },
409
- { once: true },
410
- );
411
- }
412
-
413
- const combinedSignal = options.signal
414
- ? AbortSignal.any(timeoutSignal ? [options.signal, timeoutSignal] : [options.signal])
415
- : timeoutSignal;
416
-
417
- if (combinedSignal) {
418
- const onAbort = () => {
419
- void this.#abortCommand(running, timeoutFired ? "timeout" : "signal", options.timeout);
420
- };
421
- running.abortListener = () => combinedSignal.removeEventListener("abort", onAbort);
422
- if (combinedSignal.aborted) {
423
- void this.#abortCommand(running, timeoutFired ? "timeout" : "signal", options.timeout);
424
- } else {
425
- combinedSignal.addEventListener("abort", onAbort, { once: true });
426
- }
427
- }
428
-
429
- try {
430
- const script = isFishShell(this.config.shell)
431
- ? buildFishCommandScript(command, options.cwd, this.config.prefix, marker, options.env)
432
- : buildPosixCommandScript(command, options.cwd, this.config.prefix, marker, options.env);
433
- await this.#writeToStdin(script);
434
- } catch (error) {
435
- await this.#handleWriteFailure(error instanceof Error ? error : new Error(String(error)));
436
- }
437
-
438
- return await promise;
439
- }
440
-
441
- async #finishCommand(running: RunningCommand, exitCode: number | undefined): Promise<void> {
442
- if (running.completed) return;
443
- running.completed = true;
444
- running.abortListener?.();
445
- this.#current = null;
446
- const summary = await running.sink.dump(running.cancelled ? running.abortNotice : undefined);
447
- running.resolve({
448
- exitCode: running.cancelled ? undefined : exitCode,
449
- cancelled: running.cancelled,
450
- ...summary,
451
- });
452
- }
453
-
454
- async #abortCommand(
455
- running: RunningCommand,
456
- reason: "timeout" | "signal",
457
- timeoutMs: number | undefined,
458
- ): Promise<void> {
459
- if (running.completed) return;
460
- running.cancelled = true;
461
- running.abortReason = reason;
462
- const notice =
463
- reason === "timeout" && timeoutMs
464
- ? `Command timed out after ${Math.round(timeoutMs / 1000)} seconds`
465
- : "Command cancelled";
466
- running.abortNotice = notice;
467
-
468
- await this.#sendInterrupt();
469
- const completed = await Promise.race([
470
- running.done.then(
471
- () => true,
472
- () => true,
473
- ),
474
- Bun.sleep(ABORT_GRACE_MS).then(() => false),
475
- ]);
476
- if (completed) return;
477
-
478
- await this.#terminateSession();
479
-
480
- // Drain streams and chunk queue - marker might have arrived but not yet processed
481
- await this.#streamsDone;
482
- await this.#chunkQueue;
483
-
484
- if (running.completed) return;
485
- running.completed = true;
486
- running.abortListener?.();
487
- this.#current = null;
488
- const summary = await running.sink.dump(notice);
489
- running.resolve({
490
- exitCode: undefined,
491
- cancelled: true,
492
- ...summary,
493
- });
494
- }
495
-
496
- async #sendInterrupt(): Promise<void> {
497
- const child = this.#child;
498
- if (!child?.pid) return;
499
- try {
500
- if (IS_WINDOWS) {
501
- child.proc.kill("SIGINT");
502
- return;
503
- }
504
- process.kill(child.pid, "SIGINT");
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
-
526
- if (!running || running.completed) return;
527
-
528
- // Wait for any pending chunks to be processed - marker might be in the queue
529
- await this.#streamsDone;
530
- await this.#chunkQueue;
531
-
532
- if (running.completed) return;
533
-
534
- running.cancelled = true;
535
- running.abortReason = "signal";
536
- running.completed = true;
537
- running.abortListener?.();
538
- this.#current = null;
539
- this.#buffer = "";
540
- const summary = await running.sink.dump(running.abortNotice ?? "Shell session terminated");
541
- running.resolve({
542
- exitCode: undefined,
543
- cancelled: true,
544
- ...summary,
545
- });
546
- }
547
-
548
- async #handleWriteFailure(error: Error): Promise<void> {
549
- logger.warn("Shell session write failed", { error: error.message });
550
- await this.#terminateSession();
551
- throw error;
552
- }
553
-
554
- #shouldRestart(error: unknown): boolean {
555
- if (!(error instanceof Error)) return false;
556
- return (
557
- error.message.includes("Shell session not started") ||
558
- error.message.includes("Shell session stdin unavailable")
559
- );
560
- }
561
-
562
- async #writeToStdin(script: string): Promise<void> {
563
- if (!this.#stdinWriter) {
564
- throw new Error("Shell session stdin unavailable");
565
- }
566
- const payload = this.#encoder.encode(script);
567
- const writer = this.#stdinWriter;
568
- await Promise.resolve(writer.write(payload));
569
- }
570
- }
571
-
572
- const sessions = new Map<string, ShellSession>();
573
-
574
- function buildSessionKey(config: ShellSessionConfig): string {
575
- return [config.shell, config.prefix ?? "", config.snapshotPath ?? "", serializeEnv(config.env)].join("\n");
576
- }
577
-
578
- export async function executeShellCommand(
579
- config: ShellSessionConfig,
580
- command: string,
581
- options: ShellCommandOptions,
582
- ): Promise<ShellCommandResult> {
583
- const sanitizedConfig = { ...config, env: sanitizePersistentEnv(config.env) };
584
- const key = buildSessionKey(sanitizedConfig);
585
- let session = sessions.get(key);
586
- if (!session) {
587
- session = new ShellSession(sanitizedConfig);
588
- sessions.set(key, session);
589
- }
590
- return await session.execute(command, options);
591
- }
592
-
593
- export const __testing = {
594
- buildPosixCommandScript,
595
- buildFishCommandScript,
596
- escapePosix,
597
- getSessionArgs,
598
- };
599
-
600
- postmortem.register("shell-session", async () => {
601
- const active = Array.from(sessions.values());
602
- sessions.clear();
603
- await Promise.all(active.map(session => session.dispose()));
604
- });