@oh-my-pi/pi-utils 6.8.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.
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@oh-my-pi/pi-utils",
3
+ "version": "6.8.0",
4
+ "description": "Shared utilities for pi packages",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "import": "./src/index.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "src",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "check": "tsgo --noEmit",
20
+ "build": "tsgo -p tsconfig.build.json",
21
+ "test": "vitest --run"
22
+ },
23
+ "author": "Can Bölük",
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/can1357/oh-my-pi.git",
28
+ "directory": "packages/pi-utils"
29
+ },
30
+ "dependencies": {
31
+ "winston": "^3.17.0",
32
+ "winston-daily-rotate-file": "^5.0.0",
33
+ "strip-ansi": "^7.1.2"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^24.3.0"
37
+ },
38
+ "engines": {
39
+ "bun": ">=1.0.0"
40
+ }
41
+ }
@@ -0,0 +1,86 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ export class AbortError extends Error {
4
+ constructor(signal: AbortSignal) {
5
+ assert(signal.aborted, "Abort signal must be aborted");
6
+
7
+ const message = signal.reason instanceof Error ? signal.reason.message : "Cancelled";
8
+ super(`Aborted: ${message}`, { cause: message });
9
+ this.name = "AbortError";
10
+ this.cause = signal.reason;
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Sleep for a given number of milliseconds, respecting abort signal.
16
+ */
17
+ export async function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
18
+ return untilAborted(signal, () => Bun.sleep(ms));
19
+ }
20
+
21
+ /**
22
+ * Creates a deferred { promise, resolve, reject } triple which automatically rejects
23
+ * with { name: "AbortError" } if the given abort signal fires before resolve/reject.
24
+ *
25
+ * @param signal - Optional AbortSignal to cancel the operation
26
+ * @returns A deferred { promise, resolve, reject } triple
27
+ */
28
+ export function createAbortablePromise<T>(signal?: AbortSignal): {
29
+ promise: Promise<T>;
30
+ resolve: (value: T | PromiseLike<T>) => void;
31
+ reject: (reason?: unknown) => void;
32
+ } {
33
+ if (!signal) {
34
+ return Promise.withResolvers<T>();
35
+ } else if (signal.aborted) {
36
+ return { promise: Promise.reject(new AbortError(signal)), resolve: () => {}, reject: () => {} };
37
+ }
38
+
39
+ const { promise, resolve, reject } = Promise.withResolvers<T>();
40
+
41
+ const abortHandler = () => {
42
+ reject(new AbortError(signal));
43
+ };
44
+ signal.addEventListener("abort", abortHandler, { once: true });
45
+ promise.finally(() => {
46
+ signal.removeEventListener("abort", abortHandler);
47
+ });
48
+ return { promise, resolve, reject };
49
+ }
50
+
51
+ /**
52
+ * Runs a promise-returning function (`pr`). If the given AbortSignal is aborted before or during
53
+ * execution, the promise is rejected with a standard error.
54
+ *
55
+ * @param signal - Optional AbortSignal to cancel the operation
56
+ * @param pr - Function returning a promise to run
57
+ * @returns Promise resolving as `pr` would, or rejecting on abort
58
+ */
59
+ export function untilAborted<T>(signal: AbortSignal | undefined | null, pr: () => Promise<T>): Promise<T> {
60
+ if (!signal) {
61
+ return pr();
62
+ } else if (signal.aborted) {
63
+ return Promise.reject(new AbortError(signal));
64
+ }
65
+ const { promise, resolve, reject } = createAbortablePromise<T>(signal);
66
+ pr().then(resolve, reject);
67
+ return promise;
68
+ }
69
+
70
+ /**
71
+ * Memoizes a function with no arguments, calling it once and caching the result.
72
+ *
73
+ * @param fn - Function to be called once
74
+ * @returns A function that returns the cached result of `fn`
75
+ */
76
+ export function once<T>(fn: () => T): () => T {
77
+ let store = undefined as { value: T } | undefined;
78
+ return () => {
79
+ if (store) {
80
+ return store.value;
81
+ }
82
+ const value = fn();
83
+ store = { value };
84
+ return value;
85
+ };
86
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from "./abortable";
2
+ export * as logger from "./logger";
3
+ export * as postmortem from "./postmortem";
4
+ export * as ptree from "./ptree";
5
+ export { AbortError, ChildProcess, cspawn, Exception, NonZeroExitError } from "./ptree";
6
+ export * from "./stream";
7
+ export * from "./temp";
package/src/logger.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Centralized file logger for omp.
3
+ *
4
+ * Logs to ~/.omp/logs/ with size-based rotation, supporting concurrent omp instances.
5
+ * Each log entry includes process.pid for traceability.
6
+ */
7
+
8
+ import { existsSync, mkdirSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import winston from "winston";
12
+ import DailyRotateFile from "winston-daily-rotate-file";
13
+
14
+ /** Get the logs directory (~/.omp/logs/) */
15
+ function getLogsDir(): string {
16
+ return join(homedir(), ".omp", "logs");
17
+ }
18
+
19
+ /** Ensure logs directory exists */
20
+ function ensureLogsDir(): string {
21
+ const logsDir = getLogsDir();
22
+ if (!existsSync(logsDir)) {
23
+ mkdirSync(logsDir, { recursive: true });
24
+ }
25
+ return logsDir;
26
+ }
27
+
28
+ /** Custom format that includes pid and flattens metadata */
29
+ const logFormat = winston.format.combine(
30
+ winston.format.timestamp({ format: "YYYY-MM-DDTHH:mm:ss.SSSZ" }),
31
+ winston.format.printf(({ timestamp, level, message, ...meta }) => {
32
+ const entry: Record<string, unknown> = {
33
+ timestamp,
34
+ level,
35
+ pid: process.pid,
36
+ message,
37
+ };
38
+ // Flatten metadata into entry
39
+ for (const [key, value] of Object.entries(meta)) {
40
+ if (key !== "level" && key !== "timestamp" && key !== "message") {
41
+ entry[key] = value;
42
+ }
43
+ }
44
+ return JSON.stringify(entry);
45
+ }),
46
+ );
47
+
48
+ /** Size-based rotating file transport */
49
+ const fileTransport = new DailyRotateFile({
50
+ dirname: ensureLogsDir(),
51
+ filename: "omp.%DATE%.log",
52
+ datePattern: "YYYY-MM-DD",
53
+ maxSize: "10m",
54
+ maxFiles: 5,
55
+ zippedArchive: true,
56
+ });
57
+
58
+ /** The winston logger instance */
59
+ const winstonLogger = winston.createLogger({
60
+ level: "debug",
61
+ format: logFormat,
62
+ transports: [fileTransport],
63
+ // Don't exit on error - logging failures shouldn't crash the app
64
+ exitOnError: false,
65
+ });
66
+
67
+ /** Logger type exposed to plugins and internal code */
68
+ export interface Logger {
69
+ error(message: string, context?: Record<string, unknown>): void;
70
+ warn(message: string, context?: Record<string, unknown>): void;
71
+ debug(message: string, context?: Record<string, unknown>): void;
72
+ }
73
+
74
+ /**
75
+ * Centralized logger for omp.
76
+ *
77
+ * Logs to ~/.omp/logs/omp.YYYY-MM-DD.log with size-based rotation.
78
+ * Safe for concurrent access from multiple omp instances.
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * import { logger } from "@oh-my-pi/pi-utils";
83
+ *
84
+ * logger.error("MCP request failed", { url, method });
85
+ * logger.warn("Theme file invalid, using fallback", { path });
86
+ * logger.debug("LSP fallback triggered", { reason });
87
+ * ```
88
+ */
89
+ export function error(message: string, context?: Record<string, unknown>): void {
90
+ try {
91
+ winstonLogger.error(message, context);
92
+ } catch {
93
+ // Silently ignore logging failures
94
+ }
95
+ }
96
+
97
+ export function warn(message: string, context?: Record<string, unknown>): void {
98
+ try {
99
+ winstonLogger.warn(message, context);
100
+ } catch {
101
+ // Silently ignore logging failures
102
+ }
103
+ }
104
+
105
+ export function debug(message: string, context?: Record<string, unknown>): void {
106
+ try {
107
+ winstonLogger.debug(message, context);
108
+ } catch {
109
+ // Silently ignore logging failures
110
+ }
111
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Cleanup and postmortem handler utilities.
3
+ *
4
+ * This module provides a system for registering and running cleanup callbacks
5
+ * in response to process exit, signals, or fatal exceptions. It is intended to
6
+ * allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
7
+ */
8
+
9
+ import { logger } from ".";
10
+
11
+ // Cleanup reasons, in order of priority/meaning.
12
+ export enum Reason {
13
+ PRE_EXIT = "pre_exit", // Pre-exit phase (not used by default)
14
+ EXIT = "exit", // Normal process exit
15
+ SIGINT = "sigint", // Ctrl-C or SIGINT
16
+ SIGTERM = "sigterm", // SIGTERM
17
+ SIGHUP = "sighup", // SIGHUP
18
+ UNCAUGHT_EXCEPTION = "uncaught_exception", // Fatal exception
19
+ UNHANDLED_REJECTION = "unhandled_rejection", // Unhandled promise rejection
20
+ MANUAL = "manual", // Manual cleanup (not triggered by process)
21
+ }
22
+
23
+ // Internal list of active cleanup callbacks (in registration order)
24
+ const callbackList: ((reason: Reason) => Promise<void> | void)[] = [];
25
+ // Tracks cleanup run state (to prevent recursion/reentry issues)
26
+ let cleanupStage: "idle" | "running" | "complete" = "idle";
27
+
28
+ /**
29
+ * Internal: runs all registered cleanup callbacks for the given reason.
30
+ * Ensures each callback is invoked at most once. Handles errors and prevents reentrancy.
31
+ *
32
+ * Returns a Promise that settles after all cleanups complete or error out.
33
+ */
34
+ function runCleanup(reason: Reason): Promise<void> {
35
+ switch (cleanupStage) {
36
+ case "idle":
37
+ cleanupStage = "running";
38
+ break;
39
+ case "running":
40
+ logger.error("Cleanup invoked recursively", { stack: new Error().stack });
41
+ return Promise.resolve();
42
+ case "complete":
43
+ return Promise.resolve();
44
+ }
45
+
46
+ // Call .cleanup() for each callback that is still "armed".
47
+ // Use Promise.try to handle sync/async, but only those armed.
48
+ const promises = callbackList.reverse().map((callback) => {
49
+ return Promise.try(() => callback(reason));
50
+ });
51
+
52
+ return Promise.allSettled(promises).then((results) => {
53
+ for (const result of results) {
54
+ if (result.status === "rejected") {
55
+ const err = result.reason instanceof Error ? result.reason : new Error(String(result.reason));
56
+ logger.error("Cleanup callback failed", { err, stack: err.stack });
57
+ }
58
+ }
59
+ cleanupStage = "complete";
60
+ });
61
+ }
62
+
63
+ // Register signal and error event handlers to trigger cleanup before exit.
64
+ process
65
+ .on("SIGINT", async () => {
66
+ await runCleanup(Reason.SIGINT);
67
+ process.exit(130); // 128 + SIGINT (2)
68
+ })
69
+ .on("uncaughtException", async (err) => {
70
+ logger.error("Uncaught exception", { err, stack: err.stack });
71
+ await runCleanup(Reason.UNCAUGHT_EXCEPTION);
72
+ process.exit(1);
73
+ })
74
+ .on("unhandledRejection", async (reason) => {
75
+ const err = reason instanceof Error ? reason : new Error(String(reason));
76
+ logger.error("Unhandled rejection", { err, stack: err.stack });
77
+ await runCleanup(Reason.UNHANDLED_REJECTION);
78
+ process.exit(1);
79
+ })
80
+ .on("exit", async () => {
81
+ void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
82
+ })
83
+ .on("SIGTERM", async () => {
84
+ await runCleanup(Reason.SIGTERM);
85
+ process.exit(143); // 128 + SIGTERM (15)
86
+ })
87
+ .on("SIGHUP", async () => {
88
+ await runCleanup(Reason.SIGHUP);
89
+ process.exit(129); // 128 + SIGHUP (1)
90
+ });
91
+
92
+ /**
93
+ * Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
94
+ *
95
+ * Returns a Callback instance that can be used to cancel (unregister) or manually clean up.
96
+ * If register is called after cleanup already began, invokes callback on a microtask.
97
+ */
98
+ export function register(id: string, callback: (reason: Reason) => void | Promise<void>): () => void {
99
+ let done = false;
100
+ const exec = (reason: Reason) => {
101
+ if (done) return;
102
+ done = true;
103
+ try {
104
+ return callback(reason);
105
+ } catch (e) {
106
+ const err = e instanceof Error ? e : new Error(String(e));
107
+ logger.error("Cleanup callback failed", { err, id, stack: err.stack });
108
+ }
109
+ };
110
+
111
+ const cancel = () => {
112
+ const index = callbackList.indexOf(exec);
113
+ if (index >= 0) {
114
+ callbackList.splice(index, 1);
115
+ }
116
+ done = true;
117
+ };
118
+
119
+ if (cleanupStage !== "idle") {
120
+ // If cleanup is already running/completed, warn and run on microtask.
121
+ logger.warn("Cleanup invoked recursively", { id });
122
+ try {
123
+ callback(Reason.MANUAL);
124
+ } catch (e) {
125
+ const err = e instanceof Error ? e : new Error(String(e));
126
+ logger.error("Cleanup callback failed", { err, id, stack: err.stack });
127
+ }
128
+ return () => {};
129
+ }
130
+
131
+ // Register callback as "armed" (active).
132
+ callbackList.push(exec);
133
+ return cancel;
134
+ }
135
+
136
+ /**
137
+ * Runs all cleanup callbacks and exits the process.
138
+ */
139
+ export async function quit(code: number = 0): Promise<void> {
140
+ await runCleanup(Reason.MANUAL);
141
+ if (process.stdout.writableLength > 0) {
142
+ const { promise, resolve } = Promise.withResolvers<void>();
143
+ process.stdout.once("drain", resolve);
144
+ await Promise.race([promise, Bun.sleep(5000)]);
145
+ }
146
+ process.exit(code);
147
+ }
package/src/ptree.ts ADDED
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Process tree management utilities for Bun subprocesses.
3
+ *
4
+ * Provides:
5
+ * - Managed tracking of child subprocesses for cleanup on exit/signals.
6
+ * - Windows and Unix support for proper tree killing.
7
+ * - ChildProcess wrapper for capturing output, errors, and kill/detach.
8
+ */
9
+
10
+ import { type FileSink, type Spawn, type Subprocess, spawn, spawnSync } from "bun";
11
+ import { postmortem } from ".";
12
+
13
+ // Platform detection: process tree kill behavior differs.
14
+ const isWindows = process.platform === "win32";
15
+
16
+ // Set of live children for managed termination/cleanup on shutdown.
17
+ const managedChildren = new Set<PipedSubprocess>();
18
+
19
+ /**
20
+ * Kill a child process and its descendents.
21
+ * - Windows: uses taskkill for tree and forceful kill (/T /F)
22
+ * - Unix: negative PID sends signal to process group (tree kill)
23
+ */
24
+ function killChild(child: PipedSubprocess, signal: NodeJS.Signals = "SIGTERM"): void {
25
+ const pid = child.pid;
26
+ if (!pid) return;
27
+
28
+ try {
29
+ if (isWindows) {
30
+ // /T (tree), /F (force): ensure entire tree is killed.
31
+ spawnSync(["taskkill", ...(signal === "SIGKILL" ? ["/F"] : []), "/T", "/PID", pid.toString()], {
32
+ stdout: "ignore",
33
+ stderr: "ignore",
34
+ timeout: 1000,
35
+ });
36
+ } else {
37
+ // Send signal to process group (negative PID).
38
+ process.kill(-pid, signal);
39
+ }
40
+
41
+ // If killed, remove from managed set and clean up.
42
+ if (child.killed) {
43
+ managedChildren.delete(child);
44
+ child.unref();
45
+ }
46
+ } catch {
47
+ // Ignore: process may already be dead.
48
+ }
49
+ }
50
+
51
+ postmortem.register("managed-children", () => {
52
+ for (const child of [...managedChildren]) {
53
+ killChild(child, "SIGKILL");
54
+ managedChildren.delete(child);
55
+ }
56
+ });
57
+
58
+ /**
59
+ * Register a subprocess for managed cleanup.
60
+ * Will attach to exit Promise so removal happens even if child exits "naturally".
61
+ */
62
+ function registerManaged(child: PipedSubprocess): void {
63
+ if (child.exitCode !== null) return;
64
+ if (managedChildren.has(child)) return;
65
+ child.ref();
66
+ managedChildren.add(child);
67
+
68
+ child.exited.then(() => {
69
+ managedChildren.delete(child);
70
+ child.unref();
71
+ });
72
+ }
73
+
74
+ // A Bun subprocess with stdin=Writable, stdout/stderr=pipe (for tracking/cleanup).
75
+ type PipedSubprocess = Subprocess<"pipe" | null, "pipe", "pipe">;
76
+
77
+ /**
78
+ * ChildProcess wraps a managed subprocess, capturing output, errors, and providing
79
+ * cross-platform kill/detach logic plus AbortSignal integration.
80
+ */
81
+ export class ChildProcess {
82
+ #proc: PipedSubprocess;
83
+ #detached = false;
84
+ #nothrow = false;
85
+ #stderrTee: ReadableStream<Uint8Array<ArrayBuffer>>;
86
+ #stderrBuffer = "";
87
+ #exitReason?: Exception;
88
+ #exitReasonPending?: Exception;
89
+ #exited: Promise<void>;
90
+ #resolveExited: (ex?: PromiseLike<Exception> | Exception) => void;
91
+
92
+ constructor(proc: PipedSubprocess) {
93
+ registerManaged(proc);
94
+
95
+ const [left, right] = proc.stderr.tee();
96
+ this.#stderrTee = right;
97
+
98
+ // Capture stderr at all times, with a capped buffer for errors.
99
+ const decoder = new TextDecoder();
100
+ void (async () => {
101
+ for await (const chunk of left) {
102
+ this.#stderrBuffer += decoder.decode(chunk, { stream: true });
103
+ if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) {
104
+ this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE);
105
+ }
106
+ }
107
+ this.#stderrBuffer += decoder.decode();
108
+ if (this.#stderrBuffer.length > NonZeroExitError.MAX_TRACE) {
109
+ this.#stderrBuffer = this.#stderrBuffer.slice(-NonZeroExitError.MAX_TRACE);
110
+ }
111
+ })().catch(() => {});
112
+
113
+ const { promise, resolve } = Promise.withResolvers<Exception | undefined>();
114
+
115
+ this.#exited = promise.then((ex?: Exception) => {
116
+ if (!ex) return; // success, no exception
117
+ if (proc.killed && this.#exitReasonPending) {
118
+ ex = this.#exitReasonPending; // propagate reason if killed
119
+ }
120
+ this.#exitReason = ex;
121
+ return Promise.reject(ex);
122
+ });
123
+ this.#resolveExited = resolve;
124
+
125
+ // On exit, resolve with a ChildError if nonzero code.
126
+ proc.exited.then((exitCode) => {
127
+ if (exitCode !== 0) {
128
+ resolve(new NonZeroExitError(exitCode, this.#stderrBuffer));
129
+ } else {
130
+ resolve(undefined);
131
+ }
132
+ });
133
+
134
+ this.#proc = proc;
135
+ }
136
+
137
+ get pid(): number | undefined {
138
+ return this.#proc.pid;
139
+ }
140
+ get exited(): Promise<void> {
141
+ return this.#exited;
142
+ }
143
+ get exitCode(): number | null {
144
+ return this.#proc.exitCode;
145
+ }
146
+ get exitReason(): Exception | undefined {
147
+ return this.#exitReason;
148
+ }
149
+ get killed(): boolean {
150
+ return this.#proc.killed;
151
+ }
152
+ get stdin(): FileSink | undefined {
153
+ return this.#proc.stdin;
154
+ }
155
+ get stdout(): ReadableStream<Uint8Array<ArrayBuffer>> {
156
+ return this.#proc.stdout;
157
+ }
158
+ get stderr(): ReadableStream<Uint8Array<ArrayBuffer>> {
159
+ return this.#stderrTee;
160
+ }
161
+
162
+ /**
163
+ * Peek at the stderr buffer.
164
+ * @returns The stderr buffer.
165
+ */
166
+ peekStderr(): string {
167
+ return this.#stderrBuffer;
168
+ }
169
+
170
+ /**
171
+ * Detach this process from management (no cleanup on shutdown).
172
+ */
173
+ detach(): void {
174
+ if (this.#detached || this.#proc.killed) return;
175
+ this.#detached = true;
176
+ if (managedChildren.delete(this.#proc)) {
177
+ this.#proc.unref();
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Prevents thrown ChildError on nonzero exit code, for optional error handling.
183
+ */
184
+ nothrow(): this {
185
+ this.#nothrow = true;
186
+ return this;
187
+ }
188
+
189
+ /**
190
+ * Kill the process tree.
191
+ * Optionally set an exit reason (for better error propagation on cancellation).
192
+ */
193
+ kill(signal: NodeJS.Signals = "SIGTERM", reason?: Exception) {
194
+ if (this.#proc.killed) return;
195
+ if (reason) {
196
+ this.#exitReasonPending = reason;
197
+ }
198
+ killChild(this.#proc, signal);
199
+ }
200
+
201
+ async killAndWait(): Promise<void> {
202
+ // Try killing with SIGTERM, then SIGKILL if it doesn't exit within 1 second
203
+ this.kill("SIGTERM");
204
+ await Promise.race([this.exited, Bun.sleep(1000).then(() => this.kill("SIGKILL"))]);
205
+ }
206
+
207
+ // Output utilities (aliases for easy chaining)
208
+ async text(): Promise<string> {
209
+ return (await this.blob()).text();
210
+ }
211
+ async json(): Promise<unknown> {
212
+ return (await this.blob()).json();
213
+ }
214
+ async arrayBuffer(): Promise<ArrayBuffer> {
215
+ return (await this.blob()).arrayBuffer();
216
+ }
217
+ async bytes() {
218
+ return (await this.blob()).bytes();
219
+ }
220
+ async blob() {
221
+ const { promise, resolve, reject } = Promise.withResolvers<Blob>();
222
+
223
+ const blob = this.#proc.stdout.blob();
224
+ if (!this.#nothrow) {
225
+ this.#exited.catch((ex: Exception) => {
226
+ reject(ex);
227
+ });
228
+ }
229
+ blob.then(resolve, reject);
230
+ return promise;
231
+ }
232
+
233
+ /**
234
+ * Attach an AbortSignal to this process. Will kill tree with SIGKILL if aborted.
235
+ */
236
+ attachSignal(signal: AbortSignal): void {
237
+ const onAbort = () => {
238
+ const cause = new AbortError(signal.reason, "<cancelled>");
239
+ this.kill("SIGKILL", cause);
240
+ if (this.#proc.killed) {
241
+ queueMicrotask(() => {
242
+ try {
243
+ this.#resolveExited(cause);
244
+ } catch {
245
+ // Ignore
246
+ }
247
+ });
248
+ }
249
+ };
250
+ if (signal.aborted) {
251
+ return void onAbort();
252
+ }
253
+ signal.addEventListener("abort", onAbort, { once: true });
254
+ // Use .finally().catch() to avoid unhandled rejection when #exited rejects
255
+ this.#exited
256
+ .finally(() => {
257
+ signal.removeEventListener("abort", onAbort);
258
+ })
259
+ .catch(() => {});
260
+ }
261
+
262
+ /**
263
+ * Attach a timeout to this process. Will kill the process with SIGKILL if the timeout is reached.
264
+ */
265
+ attachTimeout(timeout: number): void {
266
+ if (timeout <= 0) return;
267
+ const timeoutId = setTimeout(() => {
268
+ this.kill("SIGKILL", new TimeoutError(timeout, this.#stderrBuffer));
269
+ }, timeout);
270
+ // Use .finally().catch() to avoid unhandled rejection when #exited rejects
271
+ this.#exited
272
+ .finally(() => {
273
+ clearTimeout(timeoutId);
274
+ })
275
+ .catch(() => {});
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Base for all exceptions representing child process nonzero exit, killed, or cancellation.
281
+ */
282
+ export abstract class Exception extends Error {
283
+ constructor(
284
+ message: string,
285
+ public readonly exitCode: number,
286
+ public readonly stderr: string,
287
+ ) {
288
+ super(message);
289
+ this.name = this.constructor.name;
290
+ }
291
+ abstract get aborted(): boolean;
292
+ }
293
+
294
+ /**
295
+ * Exception for nonzero exit codes (not cancellation).
296
+ */
297
+ export class NonZeroExitError extends Exception {
298
+ static readonly MAX_TRACE = 32 * 1024;
299
+
300
+ constructor(
301
+ public readonly exitCode: number,
302
+ public readonly stderr: string,
303
+ ) {
304
+ super(`Process exited with code ${exitCode}:\n${stderr}`, exitCode, stderr);
305
+ }
306
+ get aborted(): boolean {
307
+ return false;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Exception for explicit process abortion (via signal).
313
+ */
314
+ export class AbortError extends Exception {
315
+ constructor(
316
+ public readonly reason: unknown,
317
+ stderr: string,
318
+ ) {
319
+ const reasonString = reason instanceof Error ? reason.message : String(reason ?? "aborted");
320
+ super(`Operation cancelled: ${reasonString}`, -1, stderr);
321
+ }
322
+ get aborted(): boolean {
323
+ return true;
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Exception for process timeout.
329
+ */
330
+ export class TimeoutError extends AbortError {
331
+ constructor(timeout: number, stderr: string) {
332
+ super(new Error(`Process timed out after ${timeout}ms`), stderr);
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Options for cspawn (child spawn). Always pipes stdout/stderr, allows signal.
338
+ */
339
+ type ChildSpawnOptions = Omit<Spawn.SpawnOptions<"pipe" | null, "pipe", "pipe">, "stdout" | "stderr"> & {
340
+ signal?: AbortSignal;
341
+ };
342
+
343
+ /**
344
+ * Spawn a subprocess as a managed child process.
345
+ * - Always pipes stdout/stderr, launches in new session/process group (detached).
346
+ * - Optional AbortSignal integrates with kill-on-abort.
347
+ */
348
+ export function cspawn(cmd: string[], options?: ChildSpawnOptions): ChildProcess {
349
+ const { timeout, ...rest } = options ?? {};
350
+ const child = spawn(cmd, {
351
+ ...rest,
352
+ stdout: "pipe",
353
+ stderr: "pipe",
354
+ // Windows: new console/pgroup; Unix: setsid for process group.
355
+ detached: true,
356
+ });
357
+ const cproc = new ChildProcess(child);
358
+ if (options?.signal) {
359
+ cproc.attachSignal(options.signal);
360
+ }
361
+ if (timeout && timeout > 0) {
362
+ cproc.attachTimeout(timeout);
363
+ }
364
+ return cproc;
365
+ }
package/src/stream.ts ADDED
@@ -0,0 +1,241 @@
1
+ import { TextDecoderStream } from "node:stream/web";
2
+ import stripAnsi from "strip-ansi";
3
+
4
+ /**
5
+ * Sanitize binary output for display/storage.
6
+ * Removes characters that crash string-width or cause display issues:
7
+ * - Control characters (except tab, newline, carriage return)
8
+ * - Lone surrogates
9
+ * - Unicode Format characters (crash string-width due to a bug)
10
+ * - Characters with undefined code points
11
+ */
12
+ export function sanitizeBinaryOutput(str: string): string {
13
+ // Use Array.from to properly iterate over code points (not code units)
14
+ // This handles surrogate pairs correctly and catches edge cases where
15
+ // codePointAt() might return undefined
16
+ return Array.from(str)
17
+ .filter((char) => {
18
+ // Filter out characters that cause string-width to crash
19
+ // This includes:
20
+ // - Unicode format characters
21
+ // - Lone surrogates (already filtered by Array.from)
22
+ // - Control chars except \t \n \r
23
+ // - Characters with undefined code points
24
+
25
+ const code = char.codePointAt(0);
26
+
27
+ // Skip if code point is undefined (edge case with invalid strings)
28
+ if (code === undefined) return false;
29
+
30
+ // Allow tab, newline, carriage return
31
+ if (code === 0x09 || code === 0x0a || code === 0x0d) return true;
32
+
33
+ // Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d)
34
+ if (code <= 0x1f) return false;
35
+
36
+ // Filter out Unicode format characters
37
+ if (code >= 0xfff9 && code <= 0xfffb) return false;
38
+
39
+ return true;
40
+ })
41
+ .join("");
42
+ }
43
+
44
+ /**
45
+ * Sanitize text output: strip ANSI codes, remove binary garbage, normalize line endings.
46
+ */
47
+ export function sanitizeText(text: string): string {
48
+ return sanitizeBinaryOutput(stripAnsi(text)).replace(/\r/g, "");
49
+ }
50
+
51
+ /**
52
+ * Create a transform stream that splits lines.
53
+ */
54
+ export function createSplitterStream(delimiter: string): TransformStream<string, string> {
55
+ let buf = "";
56
+ return new TransformStream<string, string>({
57
+ transform(chunk, controller) {
58
+ buf = buf ? `${buf}${chunk}` : chunk;
59
+
60
+ while (true) {
61
+ const nl = buf.indexOf(delimiter);
62
+ if (nl === -1) break;
63
+ controller.enqueue(buf.slice(0, nl));
64
+ buf = buf.slice(nl + delimiter.length);
65
+ }
66
+ },
67
+ flush(controller) {
68
+ if (buf) {
69
+ controller.enqueue(buf);
70
+ }
71
+ },
72
+ });
73
+ }
74
+
75
+ /**
76
+ * Create a transform stream that sanitizes text.
77
+ */
78
+ export function createSanitizerStream(): TransformStream<string, string> {
79
+ return new TransformStream<string, string>({
80
+ transform(chunk, controller) {
81
+ controller.enqueue(sanitizeText(chunk));
82
+ },
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Create a transform stream that decodes text.
88
+ */
89
+ export function createTextDecoderStream(): TransformStream<Uint8Array, string> {
90
+ return new TextDecoderStream("utf-8", { ignoreBOM: true });
91
+ }
92
+
93
+ /**
94
+ * Read stream line-by-line
95
+ *
96
+ * @param delimiter Line delimiter (default: "\n")
97
+ */
98
+ export function readLines(stream: ReadableStream<Uint8Array>, delimiter = "\n"): AsyncIterable<string> {
99
+ return stream.pipeThrough(createTextDecoderStream()).pipeThrough(createSplitterStream(delimiter));
100
+ }
101
+
102
+ // =============================================================================
103
+ // SSE (Server-Sent Events)
104
+ // =============================================================================
105
+
106
+ /**
107
+ * Parsed SSE event.
108
+ */
109
+ export interface SseEvent {
110
+ /** Event type (from `event:` field, default: "message") */
111
+ event: string;
112
+ /** Event data (from `data:` field(s), joined with newlines) */
113
+ data: string;
114
+ /** Event ID (from `id:` field) */
115
+ id?: string;
116
+ /** Retry interval in ms (from `retry:` field) */
117
+ retry?: number;
118
+ }
119
+
120
+ /**
121
+ * Parse a single SSE event block (lines between blank lines).
122
+ * Returns null if the block contains no data.
123
+ */
124
+ export function parseSseEvent(block: string): SseEvent | null {
125
+ const lines = block.split("\n");
126
+ let event = "message";
127
+ const dataLines: string[] = [];
128
+ let id: string | undefined;
129
+ let retry: number | undefined;
130
+
131
+ for (const line of lines) {
132
+ // Comments start with ':'
133
+ if (line.startsWith(":")) continue;
134
+
135
+ const colonIdx = line.indexOf(":");
136
+ if (colonIdx === -1) continue;
137
+
138
+ const field = line.slice(0, colonIdx);
139
+ // Value starts after colon, with optional leading space trimmed
140
+ let value = line.slice(colonIdx + 1);
141
+ if (value.startsWith(" ")) value = value.slice(1);
142
+
143
+ switch (field) {
144
+ case "event":
145
+ event = value;
146
+ break;
147
+ case "data":
148
+ dataLines.push(value);
149
+ break;
150
+ case "id":
151
+ id = value;
152
+ break;
153
+ case "retry": {
154
+ const n = parseInt(value, 10);
155
+ if (!Number.isNaN(n)) retry = n;
156
+ break;
157
+ }
158
+ }
159
+ }
160
+
161
+ if (dataLines.length === 0) return null;
162
+
163
+ return {
164
+ event,
165
+ data: dataLines.join("\n"),
166
+ id,
167
+ retry,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Read SSE events from a stream.
173
+ *
174
+ * Handles the SSE wire format:
175
+ * - Events separated by blank lines
176
+ * - Fields: event, data, id, retry
177
+ * - Comments (lines starting with :) are ignored
178
+ * - Multiple data: lines are joined with newlines
179
+ *
180
+ * @example
181
+ * ```ts
182
+ * for await (const event of readSseEvents(response.body)) {
183
+ * if (event.data === "[DONE]") break;
184
+ * const payload = JSON.parse(event.data);
185
+ * console.log(event.event, payload);
186
+ * }
187
+ * ```
188
+ */
189
+ export async function* readSseEvents(stream: ReadableStream<Uint8Array>): AsyncGenerator<SseEvent, void, undefined> {
190
+ const blockLines: string[] = [];
191
+
192
+ for await (const rawLine of readLines(stream)) {
193
+ const line = rawLine.replace(/\r$/, "");
194
+ if (line === "") {
195
+ if (blockLines.length > 0) {
196
+ const event = parseSseEvent(blockLines.join("\n"));
197
+ if (event) yield event;
198
+ blockLines.length = 0;
199
+ }
200
+ continue;
201
+ }
202
+
203
+ blockLines.push(line);
204
+ }
205
+
206
+ if (blockLines.length > 0) {
207
+ const event = parseSseEvent(blockLines.join("\n"));
208
+ if (event) yield event;
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Read SSE data payloads from a stream, parsing JSON automatically.
214
+ *
215
+ * Convenience wrapper over readSseEvents that:
216
+ * - Skips [DONE] markers
217
+ * - Parses JSON data
218
+ * - Optionally filters by event type
219
+ *
220
+ * @example
221
+ * ```ts
222
+ * for await (const data of readSseData<ChatChunk>(response.body)) {
223
+ * console.log(data.choices[0].delta);
224
+ * }
225
+ * ```
226
+ */
227
+ export async function* readSseData<T = unknown>(
228
+ stream: ReadableStream<Uint8Array>,
229
+ eventType?: string,
230
+ ): AsyncGenerator<T, void, undefined> {
231
+ for await (const event of readSseEvents(stream)) {
232
+ if (eventType && event.event !== eventType) continue;
233
+ if (event.data === "[DONE]") continue;
234
+
235
+ try {
236
+ yield JSON.parse(event.data) as T;
237
+ } catch {
238
+ // Skip malformed JSON
239
+ }
240
+ }
241
+ }
package/src/temp.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join, sep } from "node:path";
5
+
6
+ export interface AsyncTempDir {
7
+ path: string;
8
+ remove(): Promise<void>;
9
+ toString(): string;
10
+ [Symbol.asyncDispose](): Promise<void>;
11
+ }
12
+
13
+ export interface SyncTempDir {
14
+ path: string;
15
+ remove(): void;
16
+ toString(): string;
17
+ [Symbol.dispose](): void;
18
+ }
19
+
20
+ const kTempDir = tmpdir();
21
+
22
+ function normalizePrefix(prefix?: string): string {
23
+ if (!prefix) {
24
+ return `${kTempDir}${sep}pi-temp-`;
25
+ } else if (prefix.startsWith("@")) {
26
+ return join(kTempDir, prefix.slice(1));
27
+ }
28
+ return prefix;
29
+ }
30
+
31
+ export async function createTempDir(prefix?: string): Promise<AsyncTempDir> {
32
+ const path = await mkdtemp(normalizePrefix(prefix));
33
+
34
+ let promise: Promise<void> | null = null;
35
+ const remove = () => {
36
+ if (promise) {
37
+ return promise;
38
+ }
39
+ promise = rm(path, { recursive: true, force: true }).catch(() => {});
40
+ return promise;
41
+ };
42
+
43
+ return {
44
+ path: path!,
45
+ remove,
46
+ toString: () => path,
47
+ [Symbol.asyncDispose]: remove,
48
+ };
49
+ }
50
+
51
+ export function createTempDirSync(prefix?: string): SyncTempDir {
52
+ const path = mkdtempSync(normalizePrefix(prefix));
53
+
54
+ let done = false;
55
+ const remove = () => {
56
+ if (done) {
57
+ return;
58
+ }
59
+ done = true;
60
+ try {
61
+ rmSync(path, { recursive: true, force: true });
62
+ } catch {
63
+ // Ignore cleanup errors
64
+ }
65
+ };
66
+
67
+ return {
68
+ path,
69
+ toString: () => path,
70
+ remove,
71
+ [Symbol.dispose]: remove,
72
+ };
73
+ }