@oh-my-pi/pi-utils 18.1.21 → 18.2.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/src/dirs.ts CHANGED
@@ -165,19 +165,31 @@ export function normalizePathForComparison(inputPath: string): string {
165
165
  return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
166
166
  }
167
167
 
168
+ /**
169
+ * Compare paths already normalized by {@link normalizePathForComparison}.
170
+ *
171
+ * Returns the relative path (an empty string when the paths are equal), or
172
+ * `null` when the candidate is outside the root. Callers classifying one
173
+ * candidate against several static roots can normalize each side once and
174
+ * reuse the public helpers' containment semantics without repeating realpath
175
+ * work.
176
+ */
177
+ export function relativePathWithinNormalizedRoot(normalizedRoot: string, normalizedCandidate: string): string | null {
178
+ const relative = path.relative(normalizedRoot, normalizedCandidate);
179
+ if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) return null;
180
+ return relative;
181
+ }
182
+
168
183
  export function pathIsWithin(root: string, candidate: string): boolean {
169
184
  const normalizedRoot = normalizePathForComparison(root);
170
185
  const normalizedCandidate = normalizePathForComparison(candidate);
171
- const relative = path.relative(normalizedRoot, normalizedCandidate);
172
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
186
+ return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) !== null;
173
187
  }
174
188
 
175
189
  export function relativePathWithinRoot(root: string, candidate: string): string | null {
176
- if (!pathIsWithin(root, candidate)) return null;
177
190
  const normalizedRoot = normalizePathForComparison(root);
178
191
  const normalizedCandidate = normalizePathForComparison(candidate);
179
- const relative = path.relative(normalizedRoot, normalizedCandidate);
180
- return relative || null;
192
+ return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) || null;
181
193
  }
182
194
 
183
195
  let projectDir: string | undefined;
package/src/env.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { parseEnv } from "node:util";
4
5
  import { getAgentDir, getConfigRootDir, getProjectDir, refreshDirsFromEnv } from "./dirs";
5
6
 
6
7
  export * from "./worker-host";
@@ -184,44 +185,17 @@ export function filterChildShellEnv(
184
185
  }
185
186
 
186
187
  /**
187
- * Parse one dotenv line with Bun-compatible semantics: an optional `export`
188
- * prefix, full-line `#` comments, inline `#` comments after whitespace on
189
- * unquoted values, and single/double/backtick quoting (a `#` inside quotes
190
- * stays literal). Returns undefined for blank lines, comments, and malformed
191
- * names.
192
- */
193
- function parseEnvLine(line: string): { key: string; value: string } | undefined {
194
- const trimmed = line.trim();
195
- if (!trimmed || trimmed.startsWith("#")) return undefined;
196
- const eqIndex = trimmed.indexOf("=");
197
- if (eqIndex === -1) return undefined;
198
- let key = trimmed.slice(0, eqIndex).trim();
199
- const exported = key.match(/^export[ \t]+(.*)$/);
200
- if (exported) key = exported[1].trim();
201
- if (!isValidEnvName(key)) return undefined;
202
- const raw = trimmed.slice(eqIndex + 1).replace(/^[ \t]+/, "");
203
- const quote = raw[0];
204
- if (quote === '"' || quote === "'" || quote === "`") {
205
- let close = raw.indexOf(quote, 1);
206
- while (close !== -1 && raw[close - 1] === "\\") close = raw.indexOf(quote, close + 1);
207
- return { key, value: close === -1 ? raw.slice(1) : raw.slice(1, close) };
208
- }
209
- const commentIndex = raw.search(/[ \t]#/);
210
- return { key, value: (commentIndex === -1 ? raw : raw.slice(0, commentIndex)).trimEnd() };
211
- }
212
-
213
- /**
214
- * Parses a .env file synchronously into key-value string pairs using
215
- * {@link parseEnvLine} for Bun-compatible line semantics, then mirrors valid
188
+ * Parses a complete .env file with the runtime's dotenv grammar, then retains
189
+ * only shell-identifier names and spawn-safe values before mirroring valid
216
190
  * `OMP_` variables to their `PI_` aliases.
217
191
  */
218
192
  export function parseEnvFile(filePath: string): Record<string, string> {
219
193
  const result: Record<string, string> = {};
220
194
  try {
221
- const content = fs.readFileSync(filePath, "utf-8");
222
- for (const line of content.split("\n")) {
223
- const parsed = parseEnvLine(line);
224
- if (parsed && isSafeEnvValue(parsed.value)) result[parsed.key] = parsed.value;
195
+ const parsed = parseEnv(fs.readFileSync(filePath, "utf-8"));
196
+ for (const key in parsed) {
197
+ const value = parsed[key];
198
+ if (value !== undefined && isValidEnvName(key) && isSafeEnvValue(value)) result[key] = value;
225
199
  }
226
200
  } catch {
227
201
  // File doesn't exist or can't be read - return empty result
@@ -11,7 +11,12 @@ const RETRY_DELAY_FIELD_PATTERN = /"retryDelay":\s*"([0-9.]+)(ms|s)"/i;
11
11
  // "try again in 90 minutes" / "try again in 1 hour"
12
12
  const TRY_AGAIN_PATTERN = /try again in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i;
13
13
  // "Your limit will reset in 13 minutes" / "reset in 13 minutes" / "will reset in 2h"
14
- const WILL_RESET_IN_PATTERN = /(?:will\s+)?reset in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i;
14
+ // OpenCode Go quota errors use "Resets in …" with day units and compound
15
+ // remainders ("Resets in 3 days", "Resets in 2hr 15min", "Resets in 45min").
16
+ const WILL_RESET_IN_PATTERN =
17
+ /(?:will\s+)?resets?\s+in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b/i;
18
+ // OpenCode Go compound remainder: "Resets in 2hr 15min".
19
+ const RESET_IN_HR_MIN_PATTERN = /resets?\s+in\s+~?\s*(\d+(?:\.\d+)?)\s*hr\s*(\d+(?:\.\d+)?)\s*min\b/i;
15
20
  // "Your limit will reset at 2026-09-01 09:44:51" / "reset at 2026-09-01T09:44:51Z"
16
21
  const WILL_RESET_AT_PATTERN =
17
22
  /(?:will\s+)?reset at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:?[0-9]{2})?)/i;
@@ -19,9 +24,18 @@ const CN_RESET_AT_PATTERN = /将在\s*([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-
19
24
  // "retry-after-ms=98497000" / "retry-after-ms: 7200000" / "retry-after-ms = 7200000"
20
25
  const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
21
26
 
27
+ // A timezone-naive `reset at` stamp (no `Z`/offset) is the provider's
28
+ // wall clock in an unknown zone: it cannot be converted to a delay without
29
+ // guessing the zone, so it resolves only as a fallback when the body
30
+ // carries no unambiguous relative signal.
31
+
32
+ // A conflict probe needs no new signal: when a naive `reset at` wall stamp
33
+ // disagrees with the merged relative wait, the merged wait (which already
34
+ // ignores the naive stamp) sleeps first, and the retry after it is the
35
+ // probe — success proves skew, a fresh 429 re-anchors with live timing.
36
+
22
37
  /**
23
38
  * Server-suggested retry delay extraction. Merges the patterns historically used
24
- * by the OpenAI Codex and Google Gemini retry helpers.
25
39
  *
26
40
  * Header sources (checked in order):
27
41
  * - `retry-after-ms` (milliseconds)
@@ -37,6 +51,8 @@ const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
37
51
  * - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
38
52
  * - `retry-after-ms=98497000` / `retry-after-ms: 7200000` / `retry-after-ms = 7200000`
39
53
  * - `Your limit will reset at 2026-09-01 09:44:51` / `将在 2026-09-01 09:44:51 重置`
54
+ * (offset-bearing only; a timezone-naive stamp is provider wall clock in
55
+ * an unknown zone and only resolves when no relative signal is present)
40
56
  *
41
57
  * Returns `undefined` if no signal is found, or `0` when the provider
42
58
  * explicitly asks for an immediate retry (`retry-after…=0`, or an absolute
@@ -98,6 +114,15 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
98
114
  // when the parse returns undefined, which would sleep a session the
99
115
  // provider told to retry immediately.
100
116
  let retryNow = false;
117
+
118
+ // Timezone-naive `reset at` stamps (no `Z`/offset) are the provider's
119
+ // wall clock in an unknown zone — converting them to a delay requires
120
+ // guessing the zone. They resolve AFTER every unambiguous signal below,
121
+ // and only as a fallback when none was found.
122
+ let longestNaiveMs: number | undefined;
123
+ const considerNaive = (ms: number | undefined): void => {
124
+ if (ms !== undefined && ms > 0 && (longestNaiveMs === undefined || ms > longestNaiveMs)) longestNaiveMs = ms;
125
+ };
101
126
  const consider = (ms: number | undefined): void => {
102
127
  if (ms !== undefined && ms > 0 && (longestMs === undefined || ms > longestMs)) longestMs = ms;
103
128
  };
@@ -119,14 +144,33 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
119
144
  }
120
145
  for (const pattern of [WILL_RESET_AT_PATTERN, CN_RESET_AT_PATTERN]) {
121
146
  const match = pattern.exec(body);
122
- if (match?.[1]) {
123
- // Provider timestamps without an explicit offset are interpreted as UTC.
124
- const normalized = match[1].replace(" ", "T");
125
- const hasOffset = /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/i.test(normalized);
126
- const parsed = Date.parse(hasOffset ? normalized : `${normalized}Z`);
147
+ if (!match?.[1]) continue;
148
+ // Offset-bearing stamps are unambiguous and compete by longest-wins.
149
+ // Naive stamps (provider wall clock, unknown zone) resolve after the
150
+ // relative signals below, and only when nothing unambiguous was
151
+ // found — never by guessing the zone against a conflicting signal.
152
+ const normalized = match[1].replace(" ", "T");
153
+ const hasOffset = /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/i.test(normalized);
154
+ if (hasOffset) {
155
+ const parsed = Date.parse(normalized);
127
156
  if (!Number.isNaN(parsed) && parsed > Date.now()) {
128
157
  consider(parsed - Date.now());
129
158
  }
159
+ } else {
160
+ const parsed = Date.parse(`${normalized}Z`);
161
+ if (!Number.isNaN(parsed) && parsed > Date.now()) {
162
+ considerNaive(parsed - Date.now());
163
+ }
164
+ }
165
+ }
166
+ // OpenCode Go compound remainder ("Resets in 2hr 15min"): the generic
167
+ // pattern only captures the leading "2hr", so add the trailing minutes.
168
+ const compoundResetMatch = RESET_IN_HR_MIN_PATTERN.exec(body);
169
+ if (compoundResetMatch?.[1] && compoundResetMatch[2]) {
170
+ const hours = Number.parseFloat(compoundResetMatch[1]);
171
+ const minutes = Number.parseFloat(compoundResetMatch[2]);
172
+ if (Number.isFinite(hours) && Number.isFinite(minutes) && hours >= 0 && minutes > 0) {
173
+ consider(hours * 60 * 60_000 + minutes * 60_000);
130
174
  }
131
175
  }
132
176
  const accountResetMatch = WILL_RESET_IN_PATTERN.exec(body);
@@ -188,7 +232,9 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
188
232
  considerClamped(resetSeconds > 1_000_000_000 ? resetSeconds * 1000 - Date.now() : resetSeconds * 1000);
189
233
  }
190
234
  }
191
- return longestMs ?? (retryNow ? 0 : undefined);
235
+ // Elapsed naive stamps were ignored before this change and stay ignored:
236
+ // only an explicit zero/expired relative signal is authoritative retry-now.
237
+ return longestMs ?? longestNaiveMs ?? (retryNow ? 0 : undefined);
192
238
  }
193
239
 
194
240
  function unitToMs(unit: string): number | undefined {
@@ -210,6 +256,10 @@ function unitToMs(unit: string): number | undefined {
210
256
  case "hour":
211
257
  case "hours":
212
258
  return 60 * 60_000;
259
+ case "d":
260
+ case "day":
261
+ case "days":
262
+ return 24 * 60 * 60_000;
213
263
  default:
214
264
  return undefined;
215
265
  }
package/src/index.ts CHANGED
@@ -9,7 +9,6 @@ export * from "./file-lock";
9
9
  export * from "./format";
10
10
  export * from "./frontmatter";
11
11
  export * from "./fs-error";
12
- export * from "./glob";
13
12
  export * from "./incoming-json";
14
13
  export * from "./json";
15
14
  export * from "./json-parse";
package/src/logger.ts CHANGED
@@ -152,6 +152,19 @@ function pruneStaleProcessLogs(dir: string): void {
152
152
  }
153
153
  }
154
154
 
155
+ const scheduledPruneDirs = new Set<string>();
156
+
157
+ /** Run shared retention only after logger construction has returned to the event loop. */
158
+ function schedulePruneStaleProcessLogs(dir: string): void {
159
+ if (scheduledPruneDirs.has(dir)) return;
160
+ scheduledPruneDirs.add(dir);
161
+ const immediate = setImmediate(() => {
162
+ scheduledPruneDirs.delete(dir);
163
+ pruneStaleProcessLogs(dir);
164
+ });
165
+ immediate.unref();
166
+ }
167
+
155
168
  /** Ensure a logs directory exists; return the resolved path. */
156
169
  function ensureDir(dir: string): string {
157
170
  if (!fs.existsSync(dir)) {
@@ -237,7 +250,7 @@ function formatLogInfo(info: NormalizedLogInfo): string {
237
250
  /** Build a rotating file sink with process-local rotation and shared retention. */
238
251
  function makeFileTransport(dir?: string): RotatingFileSink {
239
252
  const logsDir = ensureDir(dir ?? getLogsDir());
240
- pruneStaleProcessLogs(logsDir);
253
+ schedulePruneStaleProcessLogs(logsDir);
241
254
  return new RotatingFileSink({
242
255
  directory: logsDir,
243
256
  filenamePrefix: "omp",
package/src/peek-file.ts CHANGED
@@ -1,67 +1,20 @@
1
1
  /**
2
2
  * Read the first `maxBytes` of a file (offset 0) and pass that slice to `op`.
3
3
  *
4
- * Buffers are reused to avoid allocating on every peek: sync uses one growable
5
- * `Uint8Array`; async uses a small fixed pool of `Buffer`s with a bounded wait
6
- * queue, falling back to a fresh allocation when the pool and queue are saturated
7
- * or when `maxBytes` exceeds the pool slot size.
4
+ * Synchronous peeks reuse one growable `Uint8Array`; asynchronous peeks allocate
5
+ * their result window so callback-retained slices cannot be changed by a later read.
8
6
  */
9
7
  import * as fs from "node:fs";
10
8
 
11
- /** Async pool slot size; larger peeks allocate ad hoc. */
12
- const POOLED_BUFFER_SIZE = 512;
13
- const ASYNC_POOL_SIZE = 10;
14
- /** Cap waiter queue so heavy concurrency does not queue unbounded; overflow uses alloc. */
15
- const MAX_ASYNC_WAITERS = 4;
16
9
  const INITIAL_SYNC_BUFFER_SIZE = 1024;
17
- const EMPTY_BUFFER = Buffer.alloc(0);
10
+ const EMPTY_BUFFER = new Uint8Array(0);
18
11
 
19
- const asyncPool = Array.from({ length: ASYNC_POOL_SIZE }, () => Buffer.allocUnsafe(POOLED_BUFFER_SIZE));
20
- const availableAsyncPoolIndexes = Array.from({ length: ASYNC_POOL_SIZE }, (_, index) => index);
21
- const asyncPoolWaiters: Array<(index: number) => void> = [];
22
12
  let syncPool = new Uint8Array(INITIAL_SYNC_BUFFER_SIZE);
23
13
 
24
- /** Returns a pool slot index, or `-1` when the caller should use a standalone buffer. */
25
- function acquireAsyncPoolIndex(): Promise<number> | number {
26
- const index = availableAsyncPoolIndexes.pop();
27
- if (index !== undefined) {
28
- return index;
29
- }
30
- if (asyncPoolWaiters.length >= MAX_ASYNC_WAITERS) {
31
- return -1;
32
- }
33
- const { promise, resolve } = Promise.withResolvers<number>();
34
- asyncPoolWaiters.push(resolve);
35
- return promise;
36
- }
37
-
38
- function releaseAsyncPoolIndex(index: number): void {
39
- if (index < 0) {
40
- return;
41
- }
42
- const waiter = asyncPoolWaiters.shift();
43
- if (waiter) {
44
- waiter(index);
45
- return;
46
- }
47
- availableAsyncPoolIndexes.push(index);
48
- }
49
-
50
- async function withAsyncPoolBuffer<T>(maxBytes: number, op: (buffer: Buffer) => Promise<T>): Promise<T> {
51
- if (maxBytes <= 0) {
52
- return op(EMPTY_BUFFER);
53
- }
54
- if (maxBytes > POOLED_BUFFER_SIZE) {
55
- return op(Buffer.allocUnsafe(maxBytes));
56
- }
57
-
58
- const poolIndex = await acquireAsyncPoolIndex();
59
- const buffer = poolIndex >= 0 ? asyncPool[poolIndex] : Buffer.allocUnsafe(maxBytes);
60
- try {
61
- return await op(buffer.subarray(0, maxBytes));
62
- } finally {
63
- releaseAsyncPoolIndex(poolIndex);
64
- }
14
+ function allocateWindow(length: number): Uint8Array {
15
+ const buffer = Buffer.allocUnsafe(length);
16
+ // A plain view preserves Uint8Array.slice semantics without zeroing bytes the read replaces.
17
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
65
18
  }
66
19
 
67
20
  function withSyncPoolBuffer<T>(maxBytes: number, op: (buffer: Uint8Array) => T): T {
@@ -104,10 +57,9 @@ export async function peekFile<T>(filePath: string, maxBytes: number, op: (heade
104
57
 
105
58
  const fileHandle = await fs.promises.open(filePath, "r");
106
59
  try {
107
- return await withAsyncPoolBuffer(maxBytes, async buffer => {
108
- const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, 0);
109
- return op(buffer.subarray(0, bytesRead));
110
- });
60
+ const buffer = allocateWindow(maxBytes);
61
+ const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, 0);
62
+ return op(buffer.subarray(0, bytesRead));
111
63
  } finally {
112
64
  await fileHandle.close();
113
65
  }
@@ -116,9 +68,8 @@ export async function peekFile<T>(filePath: string, maxBytes: number, op: (heade
116
68
  /**
117
69
  * Read up to the last `maxBytes` of `filePath` and pass that slice to `op`.
118
70
  *
119
- * The tail mirror of {@link peekFile}: same pooled-buffer strategy (no per-call
120
- * allocation for small reads), but the read is positioned at `size - len` so the
121
- * window ends at EOF. When the file is shorter than `maxBytes`, the whole file is
71
+ * The tail mirror of {@link peekFile}: the read is positioned at `size - len` so
72
+ * the window ends at EOF. When the file is shorter than `maxBytes`, the whole file is
122
73
  * returned. A multi-byte codepoint straddling the leading cut decodes to a
123
74
  * replacement char — callers that parse line-oriented tails drop the partial
124
75
  * leading line anyway.
@@ -135,10 +86,9 @@ export async function peekFileTail<T>(filePath: string, maxBytes: number, op: (t
135
86
  if (len <= 0) {
136
87
  return op(EMPTY_BUFFER);
137
88
  }
138
- return await withAsyncPoolBuffer(len, async buffer => {
139
- const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, size - len);
140
- return op(buffer.subarray(0, bytesRead));
141
- });
89
+ const buffer = allocateWindow(len);
90
+ const { bytesRead } = await fileHandle.read(buffer, 0, buffer.byteLength, size - len);
91
+ return op(buffer.subarray(0, bytesRead));
142
92
  } finally {
143
93
  await fileHandle.close();
144
94
  }
@@ -168,7 +118,7 @@ export async function peekFileEnds<T>(
168
118
  const headLen = prefixBytes > 0 ? Math.min(prefixBytes, size) : 0;
169
119
  const tailLen = suffixBytes > 0 ? Math.min(suffixBytes, size) : 0;
170
120
 
171
- const head = headLen > 0 ? Buffer.allocUnsafe(headLen) : EMPTY_BUFFER;
121
+ const head = headLen > 0 ? allocateWindow(headLen) : EMPTY_BUFFER;
172
122
  const headBytesRead = headLen > 0 ? (await fileHandle.read(head, 0, head.byteLength, 0)).bytesRead : 0;
173
123
  const headSlice = head.subarray(0, headBytesRead);
174
124
 
@@ -179,7 +129,7 @@ export async function peekFileEnds<T>(
179
129
  return op(headSlice, headSlice.subarray(Math.max(0, headBytesRead - tailLen)));
180
130
  }
181
131
 
182
- const tail = Buffer.allocUnsafe(tailLen);
132
+ const tail = allocateWindow(tailLen);
183
133
  const { bytesRead: tailBytesRead } = await fileHandle.read(tail, 0, tail.byteLength, size - tailLen);
184
134
  return op(headSlice, tail.subarray(0, tailBytesRead));
185
135
  } finally {
package/src/postmortem.ts CHANGED
@@ -7,8 +7,6 @@
7
7
  */
8
8
 
9
9
  import * as fs from "node:fs";
10
- import inspector from "node:inspector";
11
- import { isMainThread } from "node:worker_threads";
12
10
  import * as logger from "./logger";
13
11
  import { restoreTerminalStderr } from "./stderr-guard";
14
12
 
@@ -422,22 +420,24 @@ async function exitAfterFatal(output: string, logMessage: string, err: Error, re
422
420
  export async function fatal(error: unknown): Promise<never> {
423
421
  const err = error instanceof Error ? error : new Error(String(error));
424
422
  const output = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n${formatFatalRecoveryHints()}`;
425
- if (!isMainThread) {
423
+ if (!Bun.isMainThread) {
426
424
  process.stderr.write(output);
427
425
  process.exit(1);
428
426
  }
429
427
  return exitAfterFatal(output, "Fatal error", err, Reason.UNHANDLED_REJECTION);
430
428
  }
431
429
 
432
- if (isMainThread) {
430
+ if (Bun.isMainThread) {
433
431
  process
434
432
  .on("SIGINT", async () => {
435
433
  await runCleanup(Reason.SIGINT);
436
434
  exitProcess(130); // 128 + SIGINT (2)
437
435
  })
438
- .on("SIGUSR1", () => {
436
+ .on("SIGUSR1", async () => {
439
437
  if (inspectorOpened) return;
440
438
  inspectorOpened = true;
439
+ // Signal-only boundary: successful startup never constructs inspector.
440
+ const { default: inspector } = await import("node:inspector");
441
441
  inspector.open(undefined, undefined, false);
442
442
  const url = inspector.url();
443
443
  process.stderr.write(`Inspector opened: ${url}\n`);
@@ -656,7 +656,7 @@ export async function drainStdout(): Promise<void> {
656
656
  async function runQuit(code: number, exitMode: "guarded" | "native", options: QuitOptions = {}): Promise<void> {
657
657
  await runCleanup(Reason.MANUAL);
658
658
 
659
- if (!isMainThread) {
659
+ if (!Bun.isMainThread) {
660
660
  return; // Workers: cleanup done, let worker exit naturally
661
661
  }
662
662
 
package/src/stream.ts CHANGED
@@ -4,6 +4,7 @@ import { abortableSource } from "./abortable";
4
4
  import { parseStreamingJson } from "./json-parse";
5
5
 
6
6
  const LF = 0x0a;
7
+ const CR = 0x0d;
7
8
 
8
9
  export async function* readLines(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<Uint8Array> {
9
10
  const buffer = new ConcatSink();
@@ -63,6 +64,7 @@ export async function* readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?:
63
64
  class ConcatSink {
64
65
  #space?: Buffer;
65
66
  #length = 0;
67
+ #skipLeadingLf = false;
66
68
 
67
69
  #ensureCapacity(size: number): Buffer {
68
70
  const space = this.#space;
@@ -133,19 +135,27 @@ class ConcatSink {
133
135
  }
134
136
 
135
137
  appendAndFlushText(chunk: Uint8Array, decoder: TextDecoder): string | undefined {
136
- const lastNewline = chunk.lastIndexOf(LF);
137
- if (lastNewline === -1) {
138
- this.append(chunk);
138
+ let start = 0;
139
+ if (this.#skipLeadingLf) {
140
+ if (chunk.length === 0) return undefined;
141
+ this.#skipLeadingLf = false;
142
+ if (chunk[0] === LF) start = 1;
143
+ }
144
+
145
+ const lastLineEnd = Math.max(chunk.lastIndexOf(LF), chunk.lastIndexOf(CR));
146
+ if (lastLineEnd < start) {
147
+ if (start < chunk.length) this.append(chunk.subarray(start));
139
148
  return undefined;
140
149
  }
141
150
 
142
- const completeEnd = lastNewline + 1;
151
+ const completeEnd = lastLineEnd + 1;
152
+ this.#skipLeadingLf = chunk[lastLineEnd] === CR && completeEnd === chunk.length;
143
153
  let text: string;
144
154
  if (this.isEmpty) {
145
- const complete = completeEnd === chunk.length ? chunk : chunk.subarray(0, completeEnd);
155
+ const complete = start === 0 && completeEnd === chunk.length ? chunk : chunk.subarray(start, completeEnd);
146
156
  text = decoder.decode(complete);
147
157
  } else {
148
- this.append(completeEnd === chunk.length ? chunk : chunk.subarray(0, completeEnd));
158
+ this.append(chunk.subarray(start, completeEnd));
149
159
  text = decoder.decode(this.flush());
150
160
  this.clear();
151
161
  }
@@ -294,7 +304,7 @@ interface SseEventState {
294
304
  }
295
305
 
296
306
  // Complete lines are decoded in one batch per source chunk. Each batch ends on
297
- // LF, which cannot split a multi-byte UTF-8 sequence.
307
+ // an ASCII line-ending byte, which cannot split a multi-byte UTF-8 sequence.
298
308
  const SSE_DECODER = new TextDecoder("utf-8");
299
309
 
300
310
  function flushSseEvent(state: SseEventState): ServerSentEvent | null {
@@ -318,11 +328,6 @@ function flushSseEvent(state: SseEventState): ServerSentEvent | null {
318
328
  }
319
329
 
320
330
  function pushSseLine(line: string, state: SseEventState): ServerSentEvent | null {
321
- // Complete-line batches split on LF only; strip a trailing CR so CRLF sources
322
- // don't leak `\r` into field values.
323
- if (line.charCodeAt(line.length - 1) === 0x0d /* '\r' */) {
324
- line = line.slice(0, -1);
325
- }
326
331
  if (line.length === 0) return flushSseEvent(state);
327
332
 
328
333
  // Comment line: keep in `raw` for diagnostic context, skip parsing.
@@ -398,13 +403,21 @@ export async function* readSseEvents(
398
403
  if (text === undefined) continue;
399
404
  let start = 0;
400
405
  while (start < text.length) {
401
- const newline = text.indexOf("\n", start);
402
- const event = pushSseLine(text.slice(start, newline), state);
406
+ let lineEnd = start;
407
+ while (lineEnd < text.length) {
408
+ const code = text.charCodeAt(lineEnd);
409
+ if (code === LF || code === CR) break;
410
+ lineEnd++;
411
+ }
412
+ const event = pushSseLine(text.slice(start, lineEnd), state);
403
413
  if (event) yield event;
404
- start = newline + 1;
414
+ if (text.charCodeAt(lineEnd) === CR && text.charCodeAt(lineEnd + 1) === LF) {
415
+ lineEnd++;
416
+ }
417
+ start = lineEnd + 1;
405
418
  }
406
419
  }
407
- // Treat any trailing partial line (no terminating LF) as a complete line.
420
+ // Treat any trailing partial line (no terminating line ending) as complete.
408
421
  if (!lineBuffer.isEmpty) {
409
422
  const tail = lineBuffer.flush();
410
423
  if (tail) {
@@ -1,28 +0,0 @@
1
- export interface GlobPathsOptions {
2
- /** Base directory for glob patterns. Defaults to getProjectDir(). */
3
- cwd?: string;
4
- /** Glob exclusion patterns. */
5
- exclude?: string[];
6
- /** Abort signal to cancel the glob. */
7
- signal?: AbortSignal;
8
- /** Timeout in milliseconds for the glob operation. */
9
- timeoutMs?: number;
10
- /** Include dotfiles when true. */
11
- dot?: boolean;
12
- /** Only return files (skip directories). Default: true. */
13
- onlyFiles?: boolean;
14
- /** Respect .gitignore files when true. Walks up directory tree to find all applicable .gitignore files. */
15
- gitignore?: boolean;
16
- }
17
- /**
18
- * Load .gitignore patterns from a directory and its parents.
19
- * Walks up the directory tree to find all applicable .gitignore files.
20
- * Returns glob-compatible exclude patterns.
21
- */
22
- export declare function loadGitignorePatterns(baseDir: string): Promise<string[]>;
23
- /**
24
- * Resolve filesystem paths matching glob patterns with optional exclude filters.
25
- * Returns paths relative to the provided cwd (or getProjectDir()).
26
- * Errors and abort/timeouts are surfaced to the caller.
27
- */
28
- export declare function globPaths(patterns: string | string[], options?: GlobPathsOptions): Promise<string[]>;