@oh-my-pi/pi-utils 16.1.1 → 16.1.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 +20 -0
- package/dist/types/abortable.d.ts +9 -9
- package/dist/types/dirs.d.ts +7 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/postmortem.d.ts +8 -0
- package/package.json +2 -2
- package/src/abortable.ts +39 -88
- package/src/dirs.ts +14 -0
- package/src/index.ts +1 -1
- package/src/postmortem.ts +26 -0
- package/src/stream.ts +4 -4
- package/src/temp.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.1.3] - 2026-06-19
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Expanded the `TempDir` Windows retry window from 4×10ms to 40×25ms (1s total) to accommodate SQLite WAL/SHM file handle release delays
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Made EPIPE rejections from IPC `send()` to worker subprocesses (`syscall: "send"`) non-fatal: the global `unhandledRejection` handler now logs and continues instead of terminating the session when an optional subsystem's pipe breaks. A broken optional subsystem (TTS/STT/tiny-title/MCP) can no longer crash the whole agent session mid-task. ([#2997](https://github.com/can1357/oh-my-pi/issues/2997))
|
|
14
|
+
|
|
15
|
+
## [16.1.2] - 2026-06-19
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
21
|
+
### Removed
|
|
22
|
+
|
|
23
|
+
- 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.
|
|
24
|
+
|
|
5
25
|
## [16.0.11] - 2026-06-19
|
|
6
26
|
|
|
7
27
|
### Removed
|
|
@@ -2,18 +2,18 @@ export declare class AbortError extends Error {
|
|
|
2
2
|
constructor(signal: AbortSignal);
|
|
3
3
|
}
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
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
|
|
9
|
-
* and
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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.
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -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"). */
|
package/dist/types/index.d.ts
CHANGED
|
@@ -8,6 +8,14 @@ export declare enum Reason {
|
|
|
8
8
|
UNHANDLED_REJECTION = "unhandled_rejection",// Unhandled promise rejection
|
|
9
9
|
MANUAL = "manual"
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Detect an EPIPE rejection that originated from an IPC `send()` to a worker
|
|
13
|
+
* subprocess (`syscall: "send"`), as opposed to a stdin/stdout pipe write
|
|
14
|
+
* (`syscall: "write"`). Only the IPC-send path can break an optional worker
|
|
15
|
+
* subsystem without affecting the main process, so only this shape is safe to
|
|
16
|
+
* swallow at the global `unhandledRejection` level. See issue #2997.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isIpcSendEpipe(err: Error): boolean;
|
|
11
19
|
/**
|
|
12
20
|
* Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
|
|
13
21
|
*
|
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.
|
|
4
|
+
"version": "16.1.3",
|
|
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.
|
|
34
|
+
"@oh-my-pi/pi-natives": "16.1.3",
|
|
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
|
-
*
|
|
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
|
|
26
|
-
* and
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
34
|
-
if (
|
|
35
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
package/src/postmortem.ts
CHANGED
|
@@ -65,6 +65,19 @@ function runCleanup(reason: Reason): Promise<void> {
|
|
|
65
65
|
// Worker thread: exit only (workers use self.addEventListener for exceptions)
|
|
66
66
|
let inspectorOpened = false;
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Detect an EPIPE rejection that originated from an IPC `send()` to a worker
|
|
70
|
+
* subprocess (`syscall: "send"`), as opposed to a stdin/stdout pipe write
|
|
71
|
+
* (`syscall: "write"`). Only the IPC-send path can break an optional worker
|
|
72
|
+
* subsystem without affecting the main process, so only this shape is safe to
|
|
73
|
+
* swallow at the global `unhandledRejection` level. See issue #2997.
|
|
74
|
+
*/
|
|
75
|
+
export function isIpcSendEpipe(err: Error): boolean {
|
|
76
|
+
const code = (err as { code?: unknown }).code;
|
|
77
|
+
const syscall = (err as { syscall?: unknown }).syscall;
|
|
78
|
+
return code === "EPIPE" && syscall === "send";
|
|
79
|
+
}
|
|
80
|
+
|
|
68
81
|
function formatFatalError(label: string, err: Error): string {
|
|
69
82
|
const name = err.name || "Error";
|
|
70
83
|
const message = err.message || "(no message)";
|
|
@@ -95,6 +108,19 @@ if (isMainThread) {
|
|
|
95
108
|
})
|
|
96
109
|
.on("unhandledRejection", async reason => {
|
|
97
110
|
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
111
|
+
// EPIPE from an IPC `send()` (`syscall: "send"`) originates from a
|
|
112
|
+
// worker subprocess whose pipe broke between the exit being observed
|
|
113
|
+
// and the next `proc.send()` — a race window that Bun surfaces as an
|
|
114
|
+
// async rejection rather than the synchronous "cannot be used after
|
|
115
|
+
// the process has exited" guard. Every `send()` target is an optional
|
|
116
|
+
// worker subsystem (TTS, STT, tiny-title, MCP servers), so a broken
|
|
117
|
+
// send pipe must never take down the whole session. Log and continue
|
|
118
|
+
// instead of exiting; the owning client detects the dead worker via
|
|
119
|
+
// its own `onExit`/error path and respawns or disables it. See #2997.
|
|
120
|
+
if (isIpcSendEpipe(err)) {
|
|
121
|
+
logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
98
124
|
process.stderr.write(formatFatalError("Unhandled Rejection", err));
|
|
99
125
|
logger.error("Unhandled rejection", { err });
|
|
100
126
|
await runCleanup(Reason.UNHANDLED_REJECTION);
|
package/src/stream.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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)) {
|
package/src/temp.ts
CHANGED
|
@@ -78,8 +78,8 @@ function normalizePrefix(prefix?: string): string {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
const kRemoveOptions = { recursive: true, force: true } as const;
|
|
81
|
-
const kRemoveRetries =
|
|
82
|
-
const kRemoveRetryDelayMs =
|
|
81
|
+
const kRemoveRetries = 40;
|
|
82
|
+
const kRemoveRetryDelayMs = 25;
|
|
83
83
|
const kRetryableRemoveErrorCodes = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]);
|
|
84
84
|
const kSleepBuffer = new Int32Array(new SharedArrayBuffer(4));
|
|
85
85
|
|