@oh-my-pi/pi-utils 16.1.1 → 16.1.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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.2] - 2026-06-19
6
+
7
+ ### Added
8
+
9
+ - Added `directoryExists(dir)` to `dirs`: resolves whether a path is an existing directory, returning `false` on any stat failure (ENOENT, permission, non-directory). Lets callers check a directory is safe to `chdir` into before `setProjectDir` throws.
10
+
11
+ ### Removed
12
+
13
+ - Removed the public `createAbortableStream` API from `@oh-my-pi/pi-utils`. Consumers should use the lighter, direct-reader `abortableSource` async generator inside `@oh-my-pi/pi-utils/stream` to avoid the extra ReadableStream wrapper layer and per-chunk enqueue overhead.
14
+
5
15
  ## [16.0.11] - 2026-06-19
6
16
 
7
17
  ### Removed
@@ -2,18 +2,18 @@ export declare class AbortError extends Error {
2
2
  constructor(signal: AbortSignal);
3
3
  }
4
4
  /**
5
- * Creates an abortable stream from a given stream and signal.
5
+ * Abortable async iteration over a {@link ReadableStream}. Reads the source
6
+ * reader directly and yields each chunk, so the consumer's `for await` drives a
7
+ * single read loop with no intermediate stream or per-chunk enqueue.
6
8
  *
7
9
  * Unlike `stream.pipeThrough(..., { signal })`, this explicitly cancels the
8
- * source reader when the signal aborts. That propagates HTTP-client disconnects
9
- * and stream watchdog timeouts all the way to the backend request instead of
10
- * only stopping the local consumer.
11
- *
12
- * @param stream - The stream to make abortable
13
- * @param signal - The signal to abort the stream
14
- * @returns The abortable stream
10
+ * source reader on abort or early `break`, propagating HTTP-client disconnects
11
+ * and watchdog timeouts to the backend request instead of only stopping the
12
+ * local consumer. On abort it throws {@link AbortError}; the lock is released
13
+ * on completion, abort, throw, or early exit. The source is cancelled only on
14
+ * abort or early exit never on natural EOF.
15
15
  */
16
- export declare function createAbortableStream<T>(stream: ReadableStream<T>, signal?: AbortSignal): ReadableStream<T>;
16
+ export declare function abortableSource<T>(stream: ReadableStream<T>, signal?: AbortSignal): AsyncGenerator<T>;
17
17
  /**
18
18
  * Runs a promise-returning function (`pr`). If the given AbortSignal is aborted before or during
19
19
  * execution, the promise is rejected with a standard error.
@@ -44,6 +44,13 @@ export declare function relativePathWithinRoot(root: string, candidate: string):
44
44
  export declare function getProjectDir(): string;
45
45
  /** Set the project directory. */
46
46
  export declare function setProjectDir(dir: string): void;
47
+ /**
48
+ * Whether `dir` resolves to an existing directory. Any stat failure — a deleted
49
+ * path (ENOENT), permission error, or a non-directory — returns `false`, so
50
+ * callers can decide whether a directory is safe to `chdir` into or adopt as a
51
+ * working directory before {@link setProjectDir} throws on it.
52
+ */
53
+ export declare function directoryExists(dir: string): Promise<boolean>;
47
54
  /** Get the config directory name relative to home (e.g. ".omp" or PI_CONFIG_DIR override). */
48
55
  export declare function getConfigDirName(): string;
49
56
  /** Get the config agent directory name relative to home (e.g. ".omp/agent" or PI_CONFIG_DIR + "/agent"). */
@@ -1,4 +1,4 @@
1
- export { createAbortableStream, once, untilAborted } from "./abortable";
1
+ export { once, untilAborted } from "./abortable";
2
2
  export * from "./async";
3
3
  export * from "./color";
4
4
  export * from "./dirs";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "16.1.1",
4
+ "version": "16.1.2",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "16.1.1",
34
+ "@oh-my-pi/pi-natives": "16.1.2",
35
35
  "handlebars": "^4.7.9",
36
36
  "winston": "^3.19.0",
37
37
  "winston-daily-rotate-file": "^5.0.0"
package/src/abortable.ts CHANGED
@@ -10,101 +10,52 @@ export class AbortError extends Error {
10
10
  }
11
11
  }
12
12
 
13
- type AbortableStreamReadResult<T> = { done: true; value?: T } | { done: false; value: T };
14
-
15
- interface AbortableStreamReader<T> {
16
- read(): Promise<AbortableStreamReadResult<T>>;
17
- cancel(reason?: unknown): Promise<void>;
18
- releaseLock(): void;
19
- }
20
-
21
13
  /**
22
- * Creates an abortable stream from a given stream and signal.
14
+ * Abortable async iteration over a {@link ReadableStream}. Reads the source
15
+ * reader directly and yields each chunk, so the consumer's `for await` drives a
16
+ * single read loop with no intermediate stream or per-chunk enqueue.
23
17
  *
24
18
  * Unlike `stream.pipeThrough(..., { signal })`, this explicitly cancels the
25
- * source reader when the signal aborts. That propagates HTTP-client disconnects
26
- * and stream watchdog timeouts all the way to the backend request instead of
27
- * only stopping the local consumer.
28
- *
29
- * @param stream - The stream to make abortable
30
- * @param signal - The signal to abort the stream
31
- * @returns The abortable stream
19
+ * source reader on abort or early `break`, propagating HTTP-client disconnects
20
+ * and watchdog timeouts to the backend request instead of only stopping the
21
+ * local consumer. On abort it throws {@link AbortError}; the lock is released
22
+ * on completion, abort, throw, or early exit. The source is cancelled only on
23
+ * abort or early exit never on natural EOF.
32
24
  */
33
- export function createAbortableStream<T>(stream: ReadableStream<T>, signal?: AbortSignal): ReadableStream<T> {
34
- if (!signal) return stream;
35
- let reader: AbortableStreamReader<T> | undefined;
36
- let closed = false;
25
+ export async function* abortableSource<T>(stream: ReadableStream<T>, signal?: AbortSignal): AsyncGenerator<T> {
26
+ if (signal?.aborted) throw new AbortError(signal);
27
+ const reader = stream.getReader();
37
28
  let onAbort: (() => void) | undefined;
38
-
39
- const cleanup = () => {
40
- if (onAbort) signal.removeEventListener("abort", onAbort);
41
- onAbort = undefined;
42
- const currentReader = reader;
43
- reader = undefined;
44
- try {
45
- currentReader?.releaseLock();
46
- } catch {}
47
- };
48
-
49
- const cancelReader = (reason: unknown): Promise<void> => {
50
- if (closed) return Promise.resolve();
51
- closed = true;
52
- const currentReader = reader;
53
- reader = undefined;
54
- if (onAbort) signal.removeEventListener("abort", onAbort);
55
- onAbort = undefined;
56
- if (!currentReader) return Promise.resolve();
57
- return currentReader
58
- .cancel(reason)
59
- .catch(() => {})
60
- .finally(() => {
61
- try {
62
- currentReader.releaseLock();
63
- } catch {}
64
- });
65
- };
66
-
67
- return new ReadableStream<T>({
68
- start(controller) {
69
- reader = stream.getReader();
70
- onAbort = () => {
71
- void cancelReader(signal.reason);
72
- try {
73
- controller.error(new AbortError(signal));
74
- } catch {}
75
- };
76
- if (signal.aborted) {
77
- onAbort();
29
+ if (signal) {
30
+ onAbort = () => {
31
+ void reader.cancel(signal.reason).catch(() => {});
32
+ };
33
+ signal.addEventListener("abort", onAbort, { once: true });
34
+ }
35
+ let completed = false;
36
+ try {
37
+ for (;;) {
38
+ const result = await reader.read();
39
+ if (signal?.aborted) throw new AbortError(signal);
40
+ if (result.done) {
41
+ completed = true;
78
42
  return;
79
43
  }
80
- signal.addEventListener("abort", onAbort, { once: true });
81
- void (async () => {
82
- try {
83
- for (;;) {
84
- const currentReader = reader;
85
- if (!currentReader) return;
86
- const { value, done } = await currentReader.read();
87
- if (closed) return;
88
- if (done) {
89
- closed = true;
90
- cleanup();
91
- controller.close();
92
- return;
93
- }
94
- controller.enqueue(value);
95
- }
96
- } catch (error) {
97
- if (closed) return;
98
- closed = true;
99
- cleanup();
100
- controller.error(signal.aborted ? new AbortError(signal) : error);
101
- }
102
- })();
103
- },
104
- cancel(reason) {
105
- return cancelReader(reason);
106
- },
107
- });
44
+ yield result.value;
45
+ }
46
+ } finally {
47
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
48
+ // Propagate early-exit (`break`/`return`) and abort to the backend; skip
49
+ // on natural EOF where the stream already closed itself.
50
+ if (!completed) {
51
+ try {
52
+ await reader.cancel();
53
+ } catch {}
54
+ }
55
+ try {
56
+ reader.releaseLock();
57
+ } catch {}
58
+ }
108
59
  }
109
60
 
110
61
  /**
package/src/dirs.ts CHANGED
@@ -185,6 +185,20 @@ export function setProjectDir(dir: string): void {
185
185
  process.chdir(projectDir);
186
186
  }
187
187
 
188
+ /**
189
+ * Whether `dir` resolves to an existing directory. Any stat failure — a deleted
190
+ * path (ENOENT), permission error, or a non-directory — returns `false`, so
191
+ * callers can decide whether a directory is safe to `chdir` into or adopt as a
192
+ * working directory before {@link setProjectDir} throws on it.
193
+ */
194
+ export async function directoryExists(dir: string): Promise<boolean> {
195
+ try {
196
+ return (await fs.promises.stat(dir)).isDirectory();
197
+ } catch {
198
+ return false;
199
+ }
200
+ }
201
+
188
202
  /** Get the config directory name relative to home (e.g. ".omp" or PI_CONFIG_DIR override). */
189
203
  export function getConfigDirName(): string {
190
204
  return process.env.PI_CONFIG_DIR || CONFIG_DIR_NAME;
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { createAbortableStream, once, untilAborted } from "./abortable";
1
+ export { once, untilAborted } from "./abortable";
2
2
  export * from "./async";
3
3
  export * from "./color";
4
4
  export * from "./dirs";
package/src/stream.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createAbortableStream } from "./abortable";
1
+ import { abortableSource } from "./abortable";
2
2
 
3
3
  const LF = 0x0a;
4
4
  type JsonlChunkResult = {
@@ -23,7 +23,7 @@ function parseJsonlChunkCompat(input: Uint8Array | string, beg?: number, end?: n
23
23
 
24
24
  export async function* readLines(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<Uint8Array> {
25
25
  const buffer = new ConcatSink();
26
- const source = createAbortableStream(stream, signal);
26
+ const source = abortableSource(stream, signal);
27
27
  try {
28
28
  for await (const chunk of source) {
29
29
  for (const line of buffer.appendAndFlushLines(chunk)) {
@@ -46,7 +46,7 @@ export async function* readLines(stream: ReadableStream<Uint8Array>, signal?: Ab
46
46
 
47
47
  export async function* readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<T> {
48
48
  const buffer = new ConcatSink();
49
- const source = createAbortableStream(stream, signal);
49
+ const source = abortableSource(stream, signal);
50
50
  try {
51
51
  for await (const chunk of source) {
52
52
  yield* buffer.pullJSONL<T>(chunk, 0, chunk.length);
@@ -339,7 +339,7 @@ export async function* readSseEvents(
339
339
  ): AsyncGenerator<ServerSentEvent> {
340
340
  const lineBuffer = new ConcatSink();
341
341
  const state: SseEventState = { event: null, data: null, raw: [] };
342
- const source = createAbortableStream(stream, signal);
342
+ const source = abortableSource(stream, signal);
343
343
  try {
344
344
  for await (const chunk of source) {
345
345
  for (const line of lineBuffer.appendAndFlushLines(chunk)) {