@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/CHANGELOG.md +30 -0
- package/THIRD-PARTY-NOTICES.txt +54 -25
- package/dist/types/async.d.ts +25 -0
- package/dist/types/browsers.d.ts +2 -30
- package/dist/types/dirs.d.ts +10 -0
- package/dist/types/env.d.ts +2 -2
- package/dist/types/fetch-retry.d.ts +2 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/types/peek-file.d.ts +2 -3
- package/package.json +2 -2
- package/src/async.ts +37 -0
- package/src/browsers.ts +10 -169
- package/src/dirs.ts +17 -5
- package/src/env.ts +7 -33
- package/src/fetch-retry.ts +58 -8
- package/src/index.ts +0 -1
- package/src/logger.ts +14 -1
- package/src/peek-file.ts +17 -67
- package/src/postmortem.ts +6 -6
- package/src/stream.ts +29 -16
- package/dist/types/glob.d.ts +0 -28
- package/src/glob.ts +0 -189
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
188
|
-
*
|
|
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
|
|
222
|
-
for (const
|
|
223
|
-
const
|
|
224
|
-
if (
|
|
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
|
package/src/fetch-retry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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 =
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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}:
|
|
120
|
-
*
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 ?
|
|
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 =
|
|
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
|
-
|
|
137
|
-
if (
|
|
138
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|
package/dist/types/glob.d.ts
DELETED
|
@@ -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[]>;
|