@oh-my-pi/pi-coding-agent 13.14.0 → 13.14.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "13.14.0",
4
+ "version": "13.14.2",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@mozilla/readability": "^0.6",
44
- "@oh-my-pi/omp-stats": "13.14.0",
45
- "@oh-my-pi/pi-agent-core": "13.14.0",
46
- "@oh-my-pi/pi-ai": "13.14.0",
47
- "@oh-my-pi/pi-natives": "13.14.0",
48
- "@oh-my-pi/pi-tui": "13.14.0",
49
- "@oh-my-pi/pi-utils": "13.14.0",
44
+ "@oh-my-pi/omp-stats": "13.14.2",
45
+ "@oh-my-pi/pi-agent-core": "13.14.2",
46
+ "@oh-my-pi/pi-ai": "13.14.2",
47
+ "@oh-my-pi/pi-natives": "13.14.2",
48
+ "@oh-my-pi/pi-tui": "13.14.2",
49
+ "@oh-my-pi/pi-utils": "13.14.2",
50
50
  "@sinclair/typebox": "^0.34",
51
51
  "@xterm/headless": "^6.0",
52
52
  "ajv": "^8.18",
@@ -69,11 +69,16 @@ export async function executeBash(command: string, options?: BashExecutorOptions
69
69
  onChunk: options?.onChunk,
70
70
  artifactPath: options?.artifactPath,
71
71
  artifactId: options?.artifactId,
72
+ // Throttle the streaming preview callback to avoid saturating the
73
+ // event loop when commands produce massive output (e.g. seq 1 50M).
74
+ chunkThrottleMs: options?.onChunk ? 50 : 0,
72
75
  });
73
76
 
74
- let pendingChunks = Promise.resolve();
77
+ // sink.push() is synchronous — buffer management, counters, and onChunk
78
+ // all run inline. File writes (artifact path) are handled asynchronously
79
+ // inside the sink. No promise chain needed.
75
80
  const enqueueChunk = (chunk: string) => {
76
- pendingChunks = pendingChunks.then(() => sink.push(chunk)).catch(() => {});
81
+ sink.push(chunk);
77
82
  };
78
83
 
79
84
  if (options?.signal?.aborted) {
@@ -160,8 +165,6 @@ export async function executeBash(command: string, options?: BashExecutorOptions
160
165
  hardTimeoutDeferred.promise.then(() => ({ kind: "hard-timeout" as const })),
161
166
  ]);
162
167
 
163
- await pendingChunks;
164
-
165
168
  if (winner.kind === "hard-timeout") {
166
169
  if (shellSession) {
167
170
  resetSession = true;
@@ -215,7 +218,6 @@ export async function executeBash(command: string, options?: BashExecutorOptions
215
218
  if (userSignal) {
216
219
  userSignal.removeEventListener("abort", abortHandler);
217
220
  }
218
- await pendingChunks;
219
221
  if (resetSession) {
220
222
  shellSessions.delete(sessionKey);
221
223
  }
@@ -6,13 +6,17 @@ import { sanitizeText } from "@oh-my-pi/pi-natives";
6
6
  import { Container, ImageProtocol, Loader, Spacer, TERMINAL, Text, type TUI } from "@oh-my-pi/pi-tui";
7
7
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
8
8
  import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
9
- import { getSixelLineMask, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
9
+ import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
10
10
  import { DynamicBorder } from "./dynamic-border";
11
11
  import { truncateToVisualLines } from "./visual-truncate";
12
12
 
13
13
  // Preview line limit when not expanded (matches tool execution behavior)
14
14
  const PREVIEW_LINES = 20;
15
+ const STREAMING_LINE_CAP = PREVIEW_LINES * 5;
15
16
  const MAX_DISPLAY_LINE_CHARS = 4000;
17
+ // Minimum interval between processing incoming chunks for display (ms).
18
+ // Chunks arriving faster than this are accumulated and processed in one batch.
19
+ const CHUNK_THROTTLE_MS = 50;
16
20
 
17
21
  export class BashExecutionComponent extends Container {
18
22
  #outputLines: string[] = [];
@@ -21,7 +25,10 @@ export class BashExecutionComponent extends Container {
21
25
  #loader: Loader;
22
26
  #truncation?: TruncationMeta;
23
27
  #expanded = false;
28
+ #displayDirty = false;
29
+ #chunkGate = false;
24
30
  #contentContainer: Container;
31
+ #headerText: Text;
25
32
 
26
33
  constructor(
27
34
  private readonly command: string,
@@ -45,8 +52,8 @@ export class BashExecutionComponent extends Container {
45
52
  this.addChild(this.#contentContainer);
46
53
 
47
54
  // Command header
48
- const header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
49
- this.#contentContainer.addChild(header);
55
+ this.#headerText = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
56
+ this.#contentContainer.addChild(this.#headerText);
50
57
 
51
58
  // Loader
52
59
  this.#loader = new Loader(
@@ -72,14 +79,22 @@ export class BashExecutionComponent extends Container {
72
79
 
73
80
  override invalidate(): void {
74
81
  super.invalidate();
82
+ this.#displayDirty = false;
75
83
  this.#updateDisplay();
76
84
  }
77
85
 
78
86
  appendOutput(chunk: string): void {
79
- const clean = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
80
-
81
- // Append to output lines
82
- const incomingLines = clean.split("\n");
87
+ // During high-throughput output (e.g. seq 1 500M), processing every
88
+ // chunk would saturate the event loop. Instead, accept one chunk per
89
+ // throttle window and drop the rest — the OutputSink captures everything
90
+ // for the artifact, and setComplete() replaces with the final output.
91
+ if (this.#chunkGate) return;
92
+ this.#chunkGate = true;
93
+ setTimeout(() => {
94
+ this.#chunkGate = false;
95
+ }, CHUNK_THROTTLE_MS);
96
+
97
+ const incomingLines = chunk.split("\n");
83
98
  if (this.#outputLines.length > 0 && incomingLines.length > 0) {
84
99
  const lastIndex = this.#outputLines.length - 1;
85
100
  const mergedLines = [`${this.#outputLines[lastIndex]}${incomingLines[0]}`, ...incomingLines.slice(1)];
@@ -90,7 +105,12 @@ export class BashExecutionComponent extends Container {
90
105
  this.#outputLines.push(...this.#clampLinesPreservingSixel(incomingLines));
91
106
  }
92
107
 
93
- this.#updateDisplay();
108
+ // Cap stored lines during streaming to avoid unbounded memory growth
109
+ if (this.#outputLines.length > STREAMING_LINE_CAP) {
110
+ this.#outputLines = this.#outputLines.slice(-STREAMING_LINE_CAP);
111
+ }
112
+
113
+ this.#displayDirty = true;
94
114
  }
95
115
 
96
116
  setComplete(
@@ -115,6 +135,14 @@ export class BashExecutionComponent extends Container {
115
135
  this.#updateDisplay();
116
136
  }
117
137
 
138
+ override render(width: number): string[] {
139
+ if (this.#displayDirty) {
140
+ this.#displayDirty = false;
141
+ this.#updateDisplay();
142
+ }
143
+ return super.render(width);
144
+ }
145
+
118
146
  #updateDisplay(): void {
119
147
  const availableLines = this.#outputLines;
120
148
 
@@ -122,15 +150,16 @@ export class BashExecutionComponent extends Container {
122
150
  const previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
123
151
  const hiddenLineCount = availableLines.length - previewLogicalLines.length;
124
152
  const sixelLineMask =
125
- TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(availableLines) : undefined;
153
+ TERMINAL.imageProtocol === ImageProtocol.Sixel && isSixelPassthroughEnabled()
154
+ ? getSixelLineMask(availableLines)
155
+ : undefined;
126
156
  const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
127
157
 
128
158
  // Rebuild content container
129
159
  this.#contentContainer.clear();
130
160
 
131
161
  // Command header
132
- const header = new Text(theme.fg("bashMode", theme.bold(`$ ${this.command}`)), 1, 0);
133
- this.#contentContainer.addChild(header);
162
+ this.#contentContainer.addChild(this.#headerText);
134
163
 
135
164
  // Output
136
165
  if (availableLines.length > 0) {
@@ -72,9 +72,8 @@ export class PythonExecutionComponent extends Container {
72
72
  }
73
73
 
74
74
  appendOutput(chunk: string): void {
75
- const clean = sanitizeText(chunk);
76
-
77
- const newLines = clean.split("\n").map(line => this.#clampDisplayLine(line));
75
+ // Chunk is pre-sanitized by OutputSink.push() — no need to sanitize again.
76
+ const newLines = chunk.split("\n").map(line => this.#clampDisplayLine(line));
78
77
  if (this.#outputLines.length > 0 && newLines.length > 0) {
79
78
  this.#outputLines[this.#outputLines.length - 1] = this.#clampDisplayLine(
80
79
  `${this.#outputLines[this.#outputLines.length - 1]}${newLines[0]}`,
@@ -105,17 +105,16 @@ export class ToolExecutionComponent extends Container {
105
105
  // Cached converted images for Kitty protocol (which requires PNG), keyed by index
106
106
  #convertedImages: Map<number, { data: string; mimeType: string }> = new Map();
107
107
  // Spinner animation for partial task results
108
- #spinnerFrame = 0;
108
+ #spinnerFrame?: number;
109
109
  #spinnerInterval?: NodeJS.Timeout;
110
110
  // Track if args are still being streamed (for edit/write spinner)
111
111
  #argsComplete = false;
112
112
  #renderState: {
113
- spinnerFrame: number;
113
+ spinnerFrame?: number;
114
114
  expanded: boolean;
115
115
  isPartial: boolean;
116
116
  renderContext?: Record<string, unknown>;
117
117
  } = {
118
- spinnerFrame: 0,
119
118
  expanded: false,
120
119
  isPartial: true,
121
120
  };
@@ -328,10 +327,9 @@ export class ToolExecutionComponent extends Container {
328
327
  this.#spinnerInterval = setInterval(() => {
329
328
  const frameCount = theme.spinnerFrames.length;
330
329
  if (frameCount === 0) return;
331
- this.#spinnerFrame = (this.#spinnerFrame + 1) % frameCount;
330
+ this.#spinnerFrame = ((this.#spinnerFrame ?? -1) + 1) % frameCount;
332
331
  this.#renderState.spinnerFrame = this.#spinnerFrame;
333
332
  this.#ui.requestRender();
334
- // NO updateDisplay() — existing component closures read from renderState
335
333
  }, 80);
336
334
  } else if (!needsSpinner && this.#spinnerInterval) {
337
335
  clearInterval(this.#spinnerInterval);
@@ -346,6 +344,7 @@ export class ToolExecutionComponent extends Container {
346
344
  if (this.#spinnerInterval) {
347
345
  clearInterval(this.#spinnerInterval);
348
346
  this.#spinnerInterval = undefined;
347
+ this.#spinnerFrame = undefined;
349
348
  }
350
349
  }
351
350
 
@@ -690,7 +690,6 @@ export class CommandController {
690
690
  chunk => {
691
691
  if (this.ctx.bashComponent) {
692
692
  this.ctx.bashComponent.appendOutput(chunk);
693
- this.ctx.ui.requestRender();
694
693
  }
695
694
  },
696
695
  { excludeFromContext },
@@ -732,7 +731,6 @@ export class CommandController {
732
731
  chunk => {
733
732
  if (this.ctx.pythonComponent) {
734
733
  this.ctx.pythonComponent.appendOutput(chunk);
735
- this.ctx.ui.requestRender();
736
734
  }
737
735
  },
738
736
  { excludeFromContext },
@@ -32,6 +32,8 @@ export interface OutputSinkOptions {
32
32
  artifactId?: string;
33
33
  spillThreshold?: number;
34
34
  onChunk?: (chunk: string) => void;
35
+ /** Minimum ms between onChunk calls. 0 = every chunk (default). */
36
+ chunkThrottleMs?: number;
35
37
  }
36
38
 
37
39
  export interface TruncationResult {
@@ -521,6 +523,7 @@ export class OutputSink {
521
523
  #totalBytes = 0;
522
524
  #sawData = false;
523
525
  #truncated = false;
526
+ #lastChunkTime = 0;
524
527
 
525
528
  #file?: {
526
529
  path: string;
@@ -528,22 +531,46 @@ export class OutputSink {
528
531
  sink: Bun.FileSink;
529
532
  };
530
533
 
534
+ // Queue of chunks waiting for the file sink to be created.
535
+ #pendingFileWrites?: string[];
536
+ #fileReady = false;
537
+
531
538
  readonly #artifactPath?: string;
532
539
  readonly #artifactId?: string;
533
540
  readonly #spillThreshold: number;
534
541
  readonly #onChunk?: (chunk: string) => void;
542
+ readonly #chunkThrottleMs: number;
535
543
 
536
544
  constructor(options?: OutputSinkOptions) {
537
- const { artifactPath, artifactId, spillThreshold = DEFAULT_MAX_BYTES, onChunk } = options ?? {};
545
+ const {
546
+ artifactPath,
547
+ artifactId,
548
+ spillThreshold = DEFAULT_MAX_BYTES,
549
+ onChunk,
550
+ chunkThrottleMs = 0,
551
+ } = options ?? {};
538
552
  this.#artifactPath = artifactPath;
539
553
  this.#artifactId = artifactId;
540
554
  this.#spillThreshold = spillThreshold;
541
555
  this.#onChunk = onChunk;
556
+ this.#chunkThrottleMs = chunkThrottleMs;
542
557
  }
543
558
 
544
- async push(chunk: string): Promise<void> {
559
+ /**
560
+ * Push a chunk of output. The buffer management and onChunk callback run
561
+ * synchronously. File sink writes are deferred and serialized internally.
562
+ */
563
+ push(chunk: string): void {
545
564
  chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
546
- this.#onChunk?.(chunk);
565
+
566
+ // Throttled onChunk: only call the callback when enough time has passed.
567
+ if (this.#onChunk) {
568
+ const now = Date.now();
569
+ if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
570
+ this.#lastChunkTime = now;
571
+ this.#onChunk(chunk);
572
+ }
573
+ }
547
574
 
548
575
  const dataBytes = Buffer.byteLength(chunk, "utf-8");
549
576
  this.#totalBytes += dataBytes;
@@ -556,10 +583,9 @@ export class OutputSink {
556
583
  const threshold = this.#spillThreshold;
557
584
  const willOverflow = this.#bufferBytes + dataBytes > threshold;
558
585
 
559
- // Write to file if already spilling or about to overflow
560
- if (this.#file != null || willOverflow) {
561
- const sink = await this.#ensureFileSink();
562
- await sink?.write(chunk);
586
+ // Write to artifact file if configured and past the threshold
587
+ if (this.#artifactPath && (this.#file != null || willOverflow)) {
588
+ this.#writeToFile(chunk);
563
589
  }
564
590
 
565
591
  if (!willOverflow) {
@@ -589,14 +615,64 @@ export class OutputSink {
589
615
  if (this.#file) this.#truncated = true;
590
616
  }
591
617
 
618
+ /**
619
+ * Write a chunk to the artifact file. Handles the async file sink creation
620
+ * by queuing writes until the sink is ready, then draining synchronously.
621
+ */
622
+ #writeToFile(chunk: string): void {
623
+ if (this.#fileReady && this.#file) {
624
+ // Fast path: file sink exists, write synchronously
625
+ this.#file.sink.write(chunk);
626
+ return;
627
+ }
628
+ // File sink not yet created — queue this chunk and kick off creation
629
+ if (!this.#pendingFileWrites) {
630
+ this.#pendingFileWrites = [chunk];
631
+ void this.#createFileSink();
632
+ } else {
633
+ this.#pendingFileWrites.push(chunk);
634
+ }
635
+ }
636
+
637
+ async #createFileSink(): Promise<void> {
638
+ if (!this.#artifactPath || this.#fileReady) return;
639
+ try {
640
+ const sink = Bun.file(this.#artifactPath).writer();
641
+ this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
642
+
643
+ // Flush existing buffer to file BEFORE it gets trimmed further.
644
+ if (this.#buffer.length > 0) {
645
+ sink.write(this.#buffer);
646
+ }
647
+
648
+ // Drain any chunks that arrived while the sink was being created
649
+ if (this.#pendingFileWrites) {
650
+ for (const pending of this.#pendingFileWrites) {
651
+ sink.write(pending);
652
+ }
653
+ this.#pendingFileWrites = undefined;
654
+ }
655
+
656
+ this.#fileReady = true;
657
+ } catch {
658
+ try {
659
+ await this.#file?.sink?.end();
660
+ } catch {
661
+ /* ignore */
662
+ }
663
+ this.#file = undefined;
664
+ this.#pendingFileWrites = undefined;
665
+ }
666
+ }
667
+
592
668
  createInput(): WritableStream<Uint8Array | string> {
593
669
  const dec = new TextDecoder("utf-8", { ignoreBOM: true });
594
- const finalize = async () => {
595
- await this.push(dec.decode());
670
+ const finalize = () => {
671
+ this.push(dec.decode());
596
672
  };
597
673
  return new WritableStream({
598
- write: async chunk => {
599
- await this.push(typeof chunk === "string" ? chunk : dec.decode(chunk, { stream: true }));
674
+ write: chunk => {
675
+ this.push(typeof chunk === "string" ? chunk : dec.decode(chunk, { stream: true }));
600
676
  },
601
677
  close: finalize,
602
678
  abort: finalize,
@@ -620,32 +696,6 @@ export class OutputSink {
620
696
  artifactId: this.#file?.artifactId,
621
697
  };
622
698
  }
623
-
624
- // -- private ---------------------------------------------------------------
625
-
626
- async #ensureFileSink(): Promise<Bun.FileSink | null> {
627
- if (!this.#artifactPath) return null;
628
- if (this.#file) return this.#file.sink;
629
-
630
- try {
631
- const sink = Bun.file(this.#artifactPath).writer();
632
- this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
633
-
634
- // Flush existing buffer to file BEFORE it gets trimmed further.
635
- if (this.#buffer.length > 0) {
636
- await sink.write(this.#buffer);
637
- }
638
- return sink;
639
- } catch {
640
- try {
641
- await this.#file?.sink?.end();
642
- } catch {
643
- /* ignore */
644
- }
645
- this.#file = undefined;
646
- return null;
647
- }
648
- }
649
699
  }
650
700
 
651
701
  // =============================================================================
@@ -295,7 +295,6 @@ export async function runInteractiveBashPty(
295
295
  },
296
296
  ): Promise<BashInteractiveResult> {
297
297
  const sink = new OutputSink({ artifactPath: options.artifactPath, artifactId: options.artifactId });
298
- let pendingChunks = Promise.resolve();
299
298
  const result = await ui.custom<BashInteractiveResult>(
300
299
  (tui, uiTheme, _keybindings, done) => {
301
300
  const session = new PtySession();
@@ -309,7 +308,6 @@ export async function runInteractiveBashPty(
309
308
  tui.requestRender();
310
309
  void (async () => {
311
310
  await component.flushOutput();
312
- await pendingChunks;
313
311
  const summary = await sink.dump();
314
312
  done({
315
313
  exitCode: run.exitCode,
@@ -362,15 +360,13 @@ export async function runInteractiveBashPty(
362
360
  if (finished || err || !chunk) return;
363
361
  component.appendOutput(chunk);
364
362
  const normalizedChunk = normalizeCaptureChunk(chunk);
365
- pendingChunks = pendingChunks.then(() => sink.push(normalizedChunk)).catch(() => {});
363
+ sink.push(normalizedChunk);
366
364
  tui.requestRender();
367
365
  },
368
366
  )
369
367
  .then(finalize)
370
368
  .catch(error => {
371
- pendingChunks = pendingChunks
372
- .then(() => sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`))
373
- .catch(() => {});
369
+ sink.push(`PTY error: ${error instanceof Error ? error.message : String(error)}\n`);
374
370
  finalize({ exitCode: undefined, cancelled: false, timedOut: false });
375
371
  });
376
372
  return component;
@@ -289,8 +289,8 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
289
289
  const executorOptions: PythonExecutorOptions = {
290
290
  ...baseExecutorOptions,
291
291
  reset: isFirstCell ? reset : false,
292
- onChunk: async chunk => {
293
- await outputSink!.push(chunk);
292
+ onChunk: chunk => {
293
+ outputSink!.push(chunk);
294
294
  },
295
295
  };
296
296