@oh-my-pi/pi-utils 18.4.2 → 18.4.3

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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.4.3] - 2026-09-28
6
+
7
+ ### Added
8
+
9
+ - Added an optional `onDone` callback to `readSseJsonOrText` that reports the `[DONE]` sentinel without attaching a raw-event observer ([#13650](https://github.com/can1357/oh-my-pi/pull/13650) by [@H4vC](https://github.com/H4vC)).
10
+
11
+ ### Changed
12
+
13
+ - SSE events read without raw capture now share one frozen empty `raw` array instead of allocating one per event ([#13650](https://github.com/can1357/oh-my-pi/pull/13650) by [@H4vC](https://github.com/H4vC)).
14
+
15
+ ### Fixed
16
+
17
+ - Fixed the unsettled-command report overriding an explicit non-zero exit code with 1 and printing a spurious "ended before completing" line ([#13623](https://github.com/can1357/oh-my-pi/pull/13623) by [@H4vC](https://github.com/H4vC))
18
+
5
19
  ## [18.4.2] - 2026-09-28
6
20
 
7
21
  ### Added
@@ -74,7 +74,13 @@ export declare function readSseJson<T>(stream: ReadableStream<Uint8Array>, signa
74
74
  * which is the safe direction — each is classified as text rather than trusted as
75
75
  * an event object.
76
76
  */
77
- export declare function readSseJsonOrText<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver): AsyncGenerator<T | string>;
77
+ export declare function readSseJsonOrText<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver,
78
+ /**
79
+ * Called when iteration ends on the OpenAI `[DONE]` sentinel. Lets a
80
+ * consumer tell a server-agreed end from a bare EOF without attaching an
81
+ * `onEvent` observer (which turns on per-line raw capture).
82
+ */
83
+ onDone?: () => void): AsyncGenerator<T | string>;
78
84
  /**
79
85
  * A single Server-Sent Event dispatched on a blank-line boundary.
80
86
  *
@@ -95,12 +101,13 @@ export interface ServerSentEvent {
95
101
  * Decoded wire lines for this event (`event:`/`data:`/etc.), for the
96
102
  * diagnostic pipeline. Populated only when the reader opts in via
97
103
  * {@link ReadSseEventsOptions.captureRaw} (or attaches an `onSseEvent`
98
- * observer to the JSON readers, which opt in automatically); otherwise
99
- * `[]`. Direct `readSseEvents` callers that need wire text must pass
104
+ * observer to the JSON readers, which opt in automatically); otherwise a
105
+ * shared, frozen empty array — copy or reassign it, never mutate it in
106
+ * place. Direct `readSseEvents` callers that need wire text must pass
100
107
  * `{ captureRaw: true }` — the field is allocation-free by default so
101
108
  * the token path pays no per-frame array/slice cost.
102
109
  */
103
- raw: string[];
110
+ raw: readonly string[];
104
111
  id?: string;
105
112
  retry?: number;
106
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.4.2",
3
+ "version": "18.4.3",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -57,7 +57,7 @@
57
57
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
58
58
  },
59
59
  "dependencies": {
60
- "@oh-my-pi/pi-natives": "18.4.2"
60
+ "@oh-my-pi/pi-natives": "18.4.3"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
package/src/postmortem.ts CHANGED
@@ -406,8 +406,14 @@ export function reportUnsettledEntry(work: Promise<unknown>, describe?: () => st
406
406
  drained = false;
407
407
  }, 0).unref();
408
408
  });
409
- process.once("exit", () => {
409
+ process.once("exit", code => {
410
410
  if (!pending || !drained) return;
411
+ // A non-zero code is already a failure verdict — an explicit
412
+ // `process.exit(n)` (possibly on the same loop turn as a Windows
413
+ // mid-I/O `beforeExit`, before the clearing timer runs) or an
414
+ // `exitCode` the command set. Overwriting it with 1 and a second
415
+ // "ended before completing" line only obscures the real error.
416
+ if (code !== 0) return;
411
417
  const command = describe?.();
412
418
  const subject = command ? `\`${APP_NAME} ${command}\`` : "command";
413
419
  const message = `${subject} ended before completing: the event loop drained while it was still pending (rerun with PI_DEBUG_STARTUP=1 to see the last phase reached)`;
package/src/stream.ts CHANGED
@@ -292,13 +292,14 @@ type SseFrame<T> = { ok: true; value: T } | { ok: false; raw: string; error: Syn
292
292
  /**
293
293
  * Shared `data:`-line framing for {@link readSseJson} and
294
294
  * {@link readSseJsonOrText}: skips empty events, stops at the OpenAI `[DONE]`
295
- * sentinel, notifies the diagnostic observer, and treats a container-shaped
296
- * stream tail as a clean end of iteration.
295
+ * sentinel (reporting it through `onDone`), notifies the diagnostic observer,
296
+ * and treats a container-shaped stream tail as a clean end of iteration.
297
297
  */
298
298
  async function* readSseFrames<T>(
299
299
  stream: ReadableStream<Uint8Array>,
300
300
  signal?: AbortSignal,
301
301
  onEvent?: SseEventObserver,
302
+ onDone?: () => void,
302
303
  ): AsyncGenerator<SseFrame<T>> {
303
304
  // The diagnostic observer is the only reader of `raw`; capture it exactly
304
305
  // when one is attached so the hot path stays allocation-free.
@@ -307,7 +308,10 @@ async function* readSseFrames<T>(
307
308
  notifySseEventObserver(onEvent, sse);
308
309
  const data = sse.data;
309
310
  if (data === "" || data === "[DONE]") {
310
- if (data === "[DONE]") return;
311
+ if (data === "[DONE]") {
312
+ onDone?.();
313
+ return;
314
+ }
311
315
  continue;
312
316
  }
313
317
  try {
@@ -358,8 +362,14 @@ export async function* readSseJsonOrText<T>(
358
362
  stream: ReadableStream<Uint8Array>,
359
363
  signal?: AbortSignal,
360
364
  onEvent?: SseEventObserver,
365
+ /**
366
+ * Called when iteration ends on the OpenAI `[DONE]` sentinel. Lets a
367
+ * consumer tell a server-agreed end from a bare EOF without attaching an
368
+ * `onEvent` observer (which turns on per-line raw capture).
369
+ */
370
+ onDone?: () => void,
361
371
  ): AsyncGenerator<T | string> {
362
- for await (const frame of readSseFrames<T>(stream, signal, onEvent)) {
372
+ for await (const frame of readSseFrames<T>(stream, signal, onEvent, onDone)) {
363
373
  if (!frame.ok) yield frame.raw;
364
374
  else yield frame.value;
365
375
  }
@@ -385,12 +395,13 @@ export interface ServerSentEvent {
385
395
  * Decoded wire lines for this event (`event:`/`data:`/etc.), for the
386
396
  * diagnostic pipeline. Populated only when the reader opts in via
387
397
  * {@link ReadSseEventsOptions.captureRaw} (or attaches an `onSseEvent`
388
- * observer to the JSON readers, which opt in automatically); otherwise
389
- * `[]`. Direct `readSseEvents` callers that need wire text must pass
398
+ * observer to the JSON readers, which opt in automatically); otherwise a
399
+ * shared, frozen empty array — copy or reassign it, never mutate it in
400
+ * place. Direct `readSseEvents` callers that need wire text must pass
390
401
  * `{ captureRaw: true }` — the field is allocation-free by default so
391
402
  * the token path pays no per-frame array/slice cost.
392
403
  */
393
- raw: string[];
404
+ raw: readonly string[];
394
405
  id?: string;
395
406
  retry?: number;
396
407
  }
@@ -414,6 +425,13 @@ interface SseEventState {
414
425
  // an ASCII line-ending byte, which cannot split a multi-byte UTF-8 sequence.
415
426
  const SSE_DECODER = new TextDecoder("utf-8");
416
427
 
428
+ /**
429
+ * `raw` of every event dispatched with capture off. Shared instead of one
430
+ * fresh array per event; frozen so an in-place mutation throws rather than
431
+ * leaking lines into every other event (consumers copy or reassign `raw`).
432
+ */
433
+ const EMPTY_RAW: readonly string[] = Object.freeze<string[]>([]);
434
+
417
435
  function flushSseEvent(state: SseEventState): ServerSentEvent | null {
418
436
  if (state.event === null && state.data === null && state.id === undefined && state.retry === undefined) {
419
437
  if (state.raw !== null) state.raw = [];
@@ -422,7 +440,7 @@ function flushSseEvent(state: SseEventState): ServerSentEvent | null {
422
440
  const event: ServerSentEvent = {
423
441
  event: state.event,
424
442
  data: state.data ?? "",
425
- raw: state.raw ?? [],
443
+ raw: state.raw ?? EMPTY_RAW,
426
444
  };
427
445
  if (state.id !== undefined) event.id = state.id;
428
446
  if (state.retry !== undefined) event.retry = state.retry;