@oh-my-pi/pi-utils 18.0.10 → 18.1.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 CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.1.0] - 2026-09-01
6
+
7
+ ### Added
8
+
9
+ - Added `postmortem.fatal` for terminal-safe top-level failure reporting.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed sub-second duration formatting so it no longer exposes floating-point precision noise.
14
+ - Managed Chrome-for-Testing downloads now reject unsupported Linux ARM64 hosts instead of installing an incompatible x86_64 browser.
15
+ - Fixed Markdown reference-link labels that match built-in `Object.prototype` names, such as `constructor` and `__proto__`, so they are no longer misidentified as definitions or emitted with an undefined URL ([#10283](https://github.com/can1357/oh-my-pi/issues/10283)).
16
+
17
+ ## [18.0.11] - 2026-08-29
18
+
19
+ ### Fixed
20
+
21
+ - Fixed runtime installation getting stuck for up to 60 seconds after an installer crash or forced termination, allowing subsequent installation attempts to proceed normally.
22
+
5
23
  ## [18.0.10] - 2026-08-28
6
24
 
7
25
  ### Added
@@ -16,6 +16,15 @@ export declare function isValidEnvName(name: string): boolean;
16
16
  export declare function isSafeEnvName(name: string): boolean;
17
17
  export declare function isSafeEnvValue(value: string): boolean;
18
18
  export declare function isMacosMallocStackLoggingEnvName(name: string): boolean;
19
+ /**
20
+ * True when running inside a WSL (Windows Subsystem for Linux) distribution.
21
+ *
22
+ * WSL reports `linux` for `process.platform`, so the only reliable signal is
23
+ * the `WSL_DISTRO_NAME`/`WSL_INTEROP` variables the interop layer injects.
24
+ * Callers use this to translate Windows drive paths to their `/mnt/<drive>`
25
+ * mounts and to route clipboard access through `powershell.exe`.
26
+ */
27
+ export declare function isWsl(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): boolean;
19
28
  export declare function filterProcessEnv(env: Record<string, string | undefined>): Record<string, string>;
20
29
  /** Filters process env for child shells without launch-cwd dotenv values. */
21
30
  export declare function filterChildShellEnv(env: Record<string, string | undefined>, cwd?: string): Record<string, string>;
@@ -1,2 +1,4 @@
1
+ /** Maps an absolute Windows drive path to the drive's default WSL mount. */
2
+ export declare function windowsPathToWslMount(filePath: string): string | undefined;
1
3
  /** Removes Win32 extended-length prefixes before passing paths to Bun APIs. */
2
4
  export declare function stripWindowsExtendedLengthPathPrefix(filePath: string, platform?: NodeJS.Platform): string;
@@ -123,6 +123,10 @@ export declare function interceptUnhandledRejections(interceptor: (reason: unkno
123
123
  * through an uncaught exception or unhandled rejection.
124
124
  */
125
125
  export declare function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void;
126
+ /**
127
+ * Reports a caught top-level failure after terminal owners restore their display, then exits.
128
+ */
129
+ export declare function fatal(error: unknown): Promise<never>;
126
130
  /** Controls when a registered cleanup callback participates in cleanup passes. */
127
131
  export interface CleanupRegistrationOptions {
128
132
  /**
@@ -73,6 +73,13 @@ export interface EnsureRuntimeInstalledOptions {
73
73
  export declare function writeRuntimeManifest(runtimeDir: string, install: RuntimeInstallSpec): Promise<void>;
74
74
  /**
75
75
  * Materialize a pinned dependency set into `runtimeDir` (idempotent,
76
- * cross-process safe via a lock directory). Returns `runtimeDir`.
76
+ * cross-process safe). Returns `runtimeDir`.
77
+ *
78
+ * Serialization uses the OS-backed {@link withFileLock} at
79
+ * `${runtimeDir}.install.lock`, which the kernel releases on process death, so
80
+ * a crashed installer cannot wedge later attempts (issue #10120). The path is
81
+ * deliberately distinct from the legacy `${runtimeDir}.lock` mkdir directory;
82
+ * {@link withLegacyInstallLock} atomically reserves that namespace during the
83
+ * new install so older processes cannot cross the migration boundary.
77
84
  */
78
85
  export declare function ensureRuntimeInstalled(options: EnsureRuntimeInstalledOptions): Promise<string>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "18.0.10",
4
+ "version": "18.1.0",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -23,15 +23,15 @@
23
23
  "main": "./src/index.ts",
24
24
  "types": "./dist/types/index.d.ts",
25
25
  "scripts": {
26
- "check": "biome check . && bun run check:types",
26
+ "check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
27
27
  "check:types": "tsgo -p tsconfig.json --noEmit",
28
- "lint": "biome lint .",
28
+ "lint": "oxlint .",
29
29
  "test": "bun test --parallel",
30
- "fix": "biome check --write --unsafe .",
31
- "fmt": "biome format --write ."
30
+ "fix": "oxlint --fix --fix-suggestions . && bun run fmt",
31
+ "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "18.0.10"
34
+ "@oh-my-pi/pi-natives": "18.1.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/bun": "^1.3.14"
@@ -67,13 +67,11 @@ export class AgentSideConnection {
67
67
  #connection: RpcConnection;
68
68
 
69
69
  constructor(toAgent: (connection: AgentSideConnection) => Agent, stream: Stream) {
70
- let agent: Agent | undefined;
71
70
  this.#connection = new RpcConnection(stream, async (method, params, notification) => {
72
- const target = agent;
73
- if (!target) throw RequestError.internalError(undefined, "Agent is not initialized");
74
- return dispatchAgent(target, method, params, notification);
71
+ if (!agent) throw RequestError.internalError(undefined, "Agent is not initialized");
72
+ return dispatchAgent(agent, method, params, notification);
75
73
  });
76
- agent = toAgent(this);
74
+ const agent = toAgent(this);
77
75
  }
78
76
 
79
77
  /** Signal aborted when the transport closes. */
@@ -170,13 +168,11 @@ export class ClientSideConnection {
170
168
  #connection: RpcConnection;
171
169
 
172
170
  constructor(toClient: (connection: ClientSideConnection) => Client, stream: Stream) {
173
- let client: Client | undefined;
174
171
  this.#connection = new RpcConnection(stream, async (method, params, notification) => {
175
- const target = client;
176
- if (!target) throw RequestError.internalError(undefined, "Client is not initialized");
177
- return dispatchClient(target, method, params, notification);
172
+ if (!client) throw RequestError.internalError(undefined, "Client is not initialized");
173
+ return dispatchClient(client, method, params, notification);
178
174
  });
179
- client = toClient(this);
175
+ const client = toClient(this);
180
176
  }
181
177
  /** Signal aborted when the transport closes. */
182
178
  get signal(): AbortSignal {
package/src/ar/entries.ts CHANGED
@@ -45,7 +45,7 @@ export function upsertArchiveEntry(
45
45
  */
46
46
  export function ensureParentDirectories(map: Map<string, ArchiveIndexEntry>, limits: ArchiveLimits): void {
47
47
  assertEntryCount(map.size, limits);
48
- for (const entry of [...map.values()]) {
48
+ for (const entry of Array.from(map.values())) {
49
49
  const parts = entry.path.split("/");
50
50
  const stop = parts.length - 1;
51
51
  for (let index = 1; index <= stop; index++) {
@@ -73,7 +73,7 @@ export function resolveArchiveLinkPath(
73
73
  ): string {
74
74
  let resolvedPath = archivePath;
75
75
  const seen = new Set<string>();
76
- for (let rewrites = 0; !seen.has(resolvedPath); ) {
76
+ for (let rewrites = 0; !seen.has(resolvedPath);) {
77
77
  seen.add(resolvedPath);
78
78
  let replacement: string | undefined;
79
79
  for (let end = resolvedPath.length; end > 0; end = resolvedPath.lastIndexOf("/", end - 1)) {
package/src/ar/iso.ts CHANGED
@@ -324,7 +324,7 @@ function decodeIdentifier(identifier: Uint8Array, joliet: boolean): string {
324
324
 
325
325
  function findSuspSkip(record: IsoRecord): number | undefined {
326
326
  const bytes = record.systemUse;
327
- for (let offset = 0; offset + 7 <= bytes.byteLength; ) {
327
+ for (let offset = 0; offset + 7 <= bytes.byteLength;) {
328
328
  const length = bytes[offset + 2]!;
329
329
  if (length < 4 || offset + length > bytes.byteLength) return undefined;
330
330
  if (
@@ -359,7 +359,7 @@ async function parseSusp(
359
359
  let relocation: SuspData["relocation"];
360
360
  for (let queueIndex = 0; queueIndex < queue.length; queueIndex++) {
361
361
  const area = queue[queueIndex]!;
362
- for (let offset = 0; offset + 4 <= area.byteLength; ) {
362
+ for (let offset = 0; offset + 4 <= area.byteLength;) {
363
363
  const signature = String.fromCharCode(area[offset]!, area[offset + 1]!);
364
364
  const length = area[offset + 2]!;
365
365
  const version = area[offset + 3]!;
@@ -466,7 +466,7 @@ async function readDirectoryRecords(
466
466
  const end = safeAdd(start, record.size, "directory extent range");
467
467
  const bytes = await budget.read(source, start, end, "directory extent");
468
468
  const records: IsoRecord[] = [];
469
- for (let offset = 0; offset < bytes.byteLength; ) {
469
+ for (let offset = 0; offset < bytes.byteLength;) {
470
470
  const sectorRemaining = blockSize - (offset % blockSize);
471
471
  const length = bytes[offset]!;
472
472
  if (length === 0) {
@@ -586,7 +586,7 @@ async function readIsoImpl(source: ByteSource, options: Parameters<FormatReader>
586
586
  ) {
587
587
  suspSkip = findSuspSkip(records[0]!);
588
588
  }
589
- for (let index = 0; index < records.length; ) {
589
+ for (let index = 0; index < records.length;) {
590
590
  const record = records[index]!;
591
591
  if (record.identifier.byteLength === 1 && (record.identifier[0] === 0 || record.identifier[0] === 1)) {
592
592
  index++;
@@ -45,7 +45,7 @@ class Bits {
45
45
  read(count: number): number {
46
46
  if (count === 0) return 0;
47
47
  let value = 0;
48
- for (let left = count; left > 0; ) {
48
+ for (let left = count; left > 0;) {
49
49
  if (this.pos >= this.bytes.byteLength - 4) corrupt("truncated compressed data");
50
50
  const take = Math.min(left, 8 - this.bit);
51
51
  value = value * 2 ** take + ((this.bytes[this.pos]! >>> (8 - this.bit - take)) & (2 ** take - 1));
@@ -340,7 +340,7 @@ class VmBits {
340
340
 
341
341
  read(count: number): number {
342
342
  let value = 0;
343
- for (let left = count; left > 0; ) {
343
+ for (let left = count; left > 0;) {
344
344
  const bytePosition = this.#position >>> 3;
345
345
  if (bytePosition >= this.#bytes.byteLength) corrupt("truncated RarVM code");
346
346
  const bitPosition = this.#position & 7;
@@ -393,7 +393,7 @@ function applyFilters(output: Uint8Array, filters: PendingFilter[]): void {
393
393
  continue;
394
394
  }
395
395
  const compareOpcode = filter.type === 2 ? 0xe9 : 0xe8;
396
- for (let position = 0; position + 4 < data.byteLength; ) {
396
+ for (let position = 0; position + 4 < data.byteLength;) {
397
397
  const opcode = data[position++]!;
398
398
  if (opcode !== 0xe8 && opcode !== compareOpcode) continue;
399
399
  const offset = position + (filter.fileOffset >>> 0);
@@ -437,7 +437,7 @@ function readLengthTable(bits: Bits, previous: Uint8Array, size: number): Uint8A
437
437
  const preTable = new Huffman(20);
438
438
  preTable.build(preLengths, 0, 20);
439
439
  const lengths = new Uint8Array(size);
440
- for (let position = 0; position < size; ) {
440
+ for (let position = 0; position < size;) {
441
441
  const symbol = preTable.decode(bits);
442
442
  if (symbol < 16) {
443
443
  lengths[position] = (symbol + previous[position]!) & 15;
@@ -33,7 +33,7 @@ class Bits {
33
33
  if (n === 0) return 0;
34
34
  if (n < 0 || n > 31) fail("invalid bit count");
35
35
  let value = 0;
36
- for (let left = n; left > 0; ) {
36
+ for (let left = n; left > 0;) {
37
37
  if (this.pos >= this.bytes.byteLength - 16) fail("truncated compressed data");
38
38
  const take = Math.min(left, 8 - this.bit);
39
39
  value = value * 2 ** take + ((this.bytes[this.pos]! >>> (8 - this.bit - take)) & (2 ** take - 1));
@@ -211,7 +211,7 @@ export class Rar5Decoder {
211
211
  if ((flags & 0x80) === 0) return;
212
212
 
213
213
  const levelLengths = new Uint8Array(20);
214
- for (let i = 0; i < 20; ) {
214
+ for (let i = 0; i < 20;) {
215
215
  const length = bits.read(4);
216
216
  if (length === 15) {
217
217
  const zeros = bits.read(4);
@@ -226,7 +226,7 @@ export class Rar5Decoder {
226
226
  level.build(levelLengths);
227
227
  const tableLength = version === 1 ? TABLE_SIZE : TABLE_SIZE - 16;
228
228
  const lengthsCompact = new Uint8Array(tableLength);
229
- for (let i = 0; i < tableLength; ) {
229
+ for (let i = 0; i < tableLength;) {
230
230
  const symbol = level.decode(bits);
231
231
  if (symbol < 16) {
232
232
  lengthsCompact[i++] = symbol;
@@ -358,7 +358,7 @@ function applyFilters(output: Uint8Array, filters: Filter[]): void {
358
358
  }
359
359
  } else if (filter.type === 1 || filter.type === 2) {
360
360
  const fileSize = 1 << 24;
361
- for (let pos = 0; pos + 4 < data.byteLength; ) {
361
+ for (let pos = 0; pos + 4 < data.byteLength;) {
362
362
  const opcode = data[pos++]!;
363
363
  if (opcode !== 0xe8 && (filter.type === 1 || opcode !== 0xe9)) continue;
364
364
  const offset = (filter.start + pos) & (fileSize - 1);
@@ -107,6 +107,7 @@ interface FileMetadata {
107
107
  }
108
108
 
109
109
  function readBoolVector(reader: HeaderReader, count: number): boolean[] {
110
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
110
111
  const result = new Array<boolean>(count);
111
112
  let byte = 0;
112
113
  let mask = 0;
@@ -122,12 +123,16 @@ function readBoolVector(reader: HeaderReader, count: number): boolean[] {
122
123
  }
123
124
 
124
125
  function readDefinedVector(reader: HeaderReader, count: number): boolean[] {
125
- if (reader.readByte() !== 0) return new Array<boolean>(count).fill(true);
126
+ if (reader.readByte() !== 0) {
127
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
128
+ return new Array<boolean>(count).fill(true);
129
+ }
126
130
  return readBoolVector(reader, count);
127
131
  }
128
132
 
129
133
  function readDigests(reader: HeaderReader, count: number): Digests {
130
134
  const defined = readDefinedVector(reader, count);
135
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
131
136
  const values = new Array<number | undefined>(count);
132
137
  for (let index = 0; index < count; index++) if (defined[index]) values[index] = reader.readUInt32();
133
138
  return { defined, values };
@@ -229,7 +234,10 @@ function parsePackInfo(reader: HeaderReader, streams: SevenZipStreams, options:
229
234
  } else reader.skipSizedProperty();
230
235
  }
231
236
  if (streams.packSizes.length !== count) throw new ArchiveError("Invalid 7z PackInfo without complete sizes");
232
- if (streams.packCrcs.length === 0) streams.packCrcs = new Array(count).fill(undefined);
237
+ if (streams.packCrcs.length === 0) {
238
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
239
+ streams.packCrcs = new Array(count).fill(undefined);
240
+ }
233
241
  }
234
242
 
235
243
  function parseUnpackInfo(reader: HeaderReader, streams: SevenZipStreams, options: FormatReadOptions): void {
@@ -254,6 +262,7 @@ function parseUnpackInfo(reader: HeaderReader, streams: SevenZipStreams, options
254
262
  }
255
263
 
256
264
  function parseSubStreamsInfo(reader: HeaderReader, streams: SevenZipStreams): void {
265
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
257
266
  const counts = new Array<number>(streams.folders.length).fill(1);
258
267
  let sizes: number[] | undefined;
259
268
  let rawDigests: Digests | undefined;
@@ -398,6 +407,7 @@ function parseNames(reader: HeaderReader, count: number, external: Uint8Array[],
398
407
  function parseTimes(reader: HeaderReader, count: number, external: Uint8Array[]): Array<number | undefined> {
399
408
  const defined = readDefinedVector(reader, count);
400
409
  const values = selectPropertyStream(reader, external);
410
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
401
411
  const result = new Array<number | undefined>(count);
402
412
  for (let index = 0; index < count; index++)
403
413
  if (defined[index]) {
@@ -411,6 +421,7 @@ function parseTimes(reader: HeaderReader, count: number, external: Uint8Array[])
411
421
  function parseAttributes(reader: HeaderReader, count: number, external: Uint8Array[]): Array<number | undefined> {
412
422
  const defined = readDefinedVector(reader, count);
413
423
  const values = selectPropertyStream(reader, external);
424
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
414
425
  const result = new Array<number | undefined>(count);
415
426
  for (let index = 0; index < count; index++) if (defined[index]) result[index] = values.readUInt32();
416
427
  return result;
@@ -420,11 +431,15 @@ function parseFilesInfo(reader: HeaderReader, external: Uint8Array[], options: F
420
431
  const count = reader.readNumber("file count");
421
432
  assertEntryCount(count, options.limits);
422
433
  const metadata: FileMetadata = {
434
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
423
435
  names: new Array<string>(count).fill(""),
436
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
424
437
  emptyStreams: new Array<boolean>(count).fill(false),
425
438
  emptyFiles: [],
426
439
  antiFiles: [],
440
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
427
441
  mtimes: new Array<number | undefined>(count),
442
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
428
443
  attributes: new Array<number | undefined>(count),
429
444
  };
430
445
  for (;;) {
package/src/ar/unix-ar.ts CHANGED
@@ -197,7 +197,7 @@ export function readUnixArEntriesFromBuffer(bytes: Uint8Array, options: FormatRe
197
197
  const records: RawArMember[] = [];
198
198
  let longNames: Uint8Array | undefined;
199
199
  let metadataSize = 0;
200
- for (let position = SIGNATURE.length; position < bytes.byteLength; ) {
200
+ for (let position = SIGNATURE.length; position < bytes.byteLength;) {
201
201
  if (bytes.byteLength - position < HEADER_SIZE)
202
202
  throw new ArchiveError("Invalid ar archive: truncated member header");
203
203
  const header = parseHeader(readMemoryRange(bytes, position, position + HEADER_SIZE));
@@ -255,7 +255,7 @@ async function readUnixArImpl(source: ByteSource, options: FormatReadOptions): P
255
255
  const records: RawArMember[] = [];
256
256
  let longNames: Uint8Array | undefined;
257
257
  let metadataSize = 0;
258
- for (let position = SIGNATURE.length; position < source.size; ) {
258
+ for (let position = SIGNATURE.length; position < source.size;) {
259
259
  const headerBytes = await readExact(source, position, position + HEADER_SIZE, "member header");
260
260
  const header = parseHeader(headerBytes);
261
261
  metadataSize += HEADER_SIZE;
package/src/browsers.ts CHANGED
@@ -39,6 +39,13 @@ export enum BrowserPlatform {
39
39
  WIN32 = "win32",
40
40
  WIN64 = "win64",
41
41
  }
42
+ type ChromeForTestingPlatform = Exclude<BrowserPlatform, BrowserPlatform.LINUX_ARM>;
43
+
44
+ function requireChromeForTestingPlatform(platform: BrowserPlatform): ChromeForTestingPlatform {
45
+ if (platform === BrowserPlatform.LINUX_ARM)
46
+ throw new Error("Chrome for Testing does not provide linux/arm64 builds");
47
+ return platform;
48
+ }
42
49
 
43
50
  const BROWSERS = [
44
51
  Browser.CHROME,
@@ -163,13 +170,13 @@ export function getDownloadUrl(
163
170
 
164
171
  /** Compute the executable path in Puppeteer's cache layout. */
165
172
  export function computeExecutablePath(options: ComputeExecutablePathOptions): string {
166
- const platform = options.platform ?? detectBrowserPlatform();
167
- if (!platform) throw new Error("Cannot determine a browser platform for this host");
173
+ const detectedPlatform = options.platform ?? detectBrowserPlatform();
174
+ if (!detectedPlatform) throw new Error("Cannot determine a browser platform for this host");
168
175
  if (options.browser !== Browser.CHROME) throw new Error(`Unsupported browser executable: ${options.browser}`);
176
+ const platform = requireChromeForTestingPlatform(detectedPlatform);
169
177
  const installDir = installationDir(options.cacheDir, options.browser, platform, options.buildId);
170
178
  switch (platform) {
171
179
  case BrowserPlatform.LINUX:
172
- case BrowserPlatform.LINUX_ARM:
173
180
  return path.join(installDir, "chrome-linux64", "chrome");
174
181
  case BrowserPlatform.MAC:
175
182
  return path.join(
@@ -292,9 +299,8 @@ async function fetchMetadata<T>(filename: string): Promise<T> {
292
299
  }
293
300
 
294
301
  function chromeArchivePlatform(platform: BrowserPlatform): string {
295
- switch (platform) {
302
+ switch (requireChromeForTestingPlatform(platform)) {
296
303
  case BrowserPlatform.LINUX:
297
- case BrowserPlatform.LINUX_ARM:
298
304
  return "linux64";
299
305
  case BrowserPlatform.MAC:
300
306
  return "mac-x64";
package/src/dates.ts CHANGED
@@ -100,7 +100,7 @@ export function format(value: Date | number, pattern: string): string {
100
100
  const date = asDate(value);
101
101
  let result = "";
102
102
 
103
- for (let index = 0; index < pattern.length; ) {
103
+ for (let index = 0; index < pattern.length;) {
104
104
  if (pattern[index] === "'") {
105
105
  if (pattern[index + 1] === "'") {
106
106
  result += "'";
@@ -530,7 +530,7 @@ function renderList(
530
530
 
531
531
  function renderBlocks(blocks: readonly Block[]): string {
532
532
  let html = "";
533
- for (let index = 0; index < blocks.length; ) {
533
+ for (let index = 0; index < blocks.length;) {
534
534
  const block = blocks[index];
535
535
  if (block.kind === "html") {
536
536
  html += block.html;
@@ -549,7 +549,7 @@ function renderBlocks(blocks: readonly Block[]): string {
549
549
  listItems.push(candidate);
550
550
  index++;
551
551
  }
552
- for (let itemIndex = 0; itemIndex < listItems.length; ) {
552
+ for (let itemIndex = 0; itemIndex < listItems.length;) {
553
553
  const rendered = renderList(listItems, itemIndex, listItems[itemIndex].list?.level ?? 0);
554
554
  html += rendered.html;
555
555
  itemIndex = rendered.next;
package/src/dom/core.ts CHANGED
@@ -105,7 +105,7 @@ export class EventTarget {
105
105
  dispatchEvent(event: Event): boolean {
106
106
  if (!event.target) event.target = this;
107
107
  event.currentTarget = this;
108
- for (const listener of [...(this.#listeners.get(event.type) ?? [])]) {
108
+ for (const listener of Array.from(this.#listeners.get(event.type) ?? [])) {
109
109
  if (typeof listener === "function") listener.call(this, event);
110
110
  else listener.handleEvent(event);
111
111
  if (event.propagationStopped) break;
@@ -196,7 +196,7 @@ export class Node extends EventTarget {
196
196
  const node: Node = child;
197
197
  if (node === this || node.contains(this)) throw new Error("The new child is an ancestor of this node");
198
198
  if (child instanceof DocumentFragment) {
199
- for (const nested of [...child.childNodes]) this.appendChild(nested);
199
+ for (const nested of Array.from(child.childNodes)) this.appendChild(nested);
200
200
  return child;
201
201
  }
202
202
  child.parentNode?.removeChild(child);
@@ -212,7 +212,7 @@ export class Node extends EventTarget {
212
212
  const index = this.childNodes.indexOf(reference);
213
213
  if (index < 0) throw new Error("The reference node is not a child of this node");
214
214
  if (child instanceof DocumentFragment) {
215
- for (const nested of [...child.childNodes]) this.insertBefore(nested, reference);
215
+ for (const nested of Array.from(child.childNodes)) this.insertBefore(nested, reference);
216
216
  return child;
217
217
  }
218
218
  child.parentNode?.removeChild(child);
@@ -681,7 +681,7 @@ export class Element extends Node {
681
681
  const parent = this.parentNode;
682
682
  if (!parent) return;
683
683
  const fragment = parseFragment(value, this.documentForCreation(), this.parentElement?.localName);
684
- for (const child of [...fragment.childNodes]) parent.insertBefore(child, this);
684
+ for (const child of Array.from(fragment.childNodes)) parent.insertBefore(child, this);
685
685
  parent.removeChild(this);
686
686
  }
687
687
 
package/src/env.ts CHANGED
@@ -36,6 +36,18 @@ export function isMacosMallocStackLoggingEnvName(name: string): boolean {
36
36
  return name === "MallocStackLogging" || name === "MallocStackLoggingNoCompact";
37
37
  }
38
38
 
39
+ /**
40
+ * True when running inside a WSL (Windows Subsystem for Linux) distribution.
41
+ *
42
+ * WSL reports `linux` for `process.platform`, so the only reliable signal is
43
+ * the `WSL_DISTRO_NAME`/`WSL_INTEROP` variables the interop layer injects.
44
+ * Callers use this to translate Windows drive paths to their `/mnt/<drive>`
45
+ * mounts and to route clipboard access through `powershell.exe`.
46
+ */
47
+ export function isWsl(platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env): boolean {
48
+ return platform === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP);
49
+ }
50
+
39
51
  export function filterProcessEnv(env: Record<string, string | undefined>): Record<string, string> {
40
52
  const result: Record<string, string> = {};
41
53
  for (const key in env) {
package/src/format.ts CHANGED
@@ -9,7 +9,7 @@ const DAY = 24 * HOUR;
9
9
  */
10
10
  export function formatDuration(ms: number): string {
11
11
  if (!Number.isFinite(ms) || ms <= 0) return "0ms";
12
- if (ms < SEC) return `${ms}ms`;
12
+ if (ms < SEC) return `${Math.floor(ms)}ms`;
13
13
  if (ms < MIN) return `${(ms / SEC).toFixed(1)}s`;
14
14
  if (ms < HOUR) {
15
15
  const mins = Math.floor(ms / MIN);
@@ -22,6 +22,7 @@ export function normalizeFrontmatterKeys<T>(obj: T): T {
22
22
  if (obj === null || typeof obj !== "object") return obj;
23
23
  if (Array.isArray(obj)) {
24
24
  let changed = false;
25
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
25
26
  const out: unknown[] = new Array(obj.length);
26
27
  for (let i = 0; i < obj.length; i++) {
27
28
  const v = obj[i];
@@ -343,8 +343,15 @@ const DEFAULTS: MarkedOptions = {
343
343
  };
344
344
  const PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/;
345
345
 
346
- function tokenList(links: Links = {}): TokensList {
346
+ function tokenList(links: Links = Object.create(null)): TokensList {
347
347
  const list = [] as unknown as TokensList;
348
+ // The reference-definition map is keyed by user-controlled labels. A plain
349
+ // `{}` inherits `Object.prototype`, so a reference-style link whose label is
350
+ // an inherited member (`[x][constructor]`, `[x][__proto__]`) resolves to a
351
+ // truthy non-definition and yields a link token with `href: undefined`
352
+ // (issue #10283). A null-prototype map makes such lookups miss, so the link
353
+ // correctly falls back to literal text, and label writes cannot pollute the
354
+ // prototype.
348
355
  list.links = links;
349
356
  return list;
350
357
  }
package/src/path.ts CHANGED
@@ -1,3 +1,17 @@
1
+ import * as path from "node:path";
2
+
3
+ const WINDOWS_DRIVE_PATH = /^([A-Za-z]):[\\/](.*)$/;
4
+
5
+ /** Maps an absolute Windows drive path to the drive's default WSL mount. */
6
+ export function windowsPathToWslMount(filePath: string): string | undefined {
7
+ const normalized = path.win32.normalize(filePath.trim());
8
+ const match = WINDOWS_DRIVE_PATH.exec(normalized);
9
+ if (!match) return undefined;
10
+ const [, drive, rest] = match;
11
+ const segments = rest.split("\\").filter(Boolean);
12
+ return path.posix.join("/mnt", drive!.toLowerCase(), ...segments);
13
+ }
14
+
1
15
  const WINDOWS_DRIVE_EXTENDED_PREFIX = /^\\\\[?]\\([A-Za-z]:[\\/].*)$/;
2
16
  const WINDOWS_UNC_EXTENDED_PREFIX = /^\\\\[?]\\UNC[\\/]([^\\/]+)[\\/](.+)$/i;
3
17
  const WINDOWS_DRIVE_EXTENDED_FORWARD_PREFIX = /^\/\/[?]\/([A-Za-z]:\/.*)$/;
package/src/postmortem.ts CHANGED
@@ -222,7 +222,7 @@ export function isInternalSocketClosedError(err: unknown): boolean {
222
222
  const internal = frames.every(frame => {
223
223
  const trimmed = frame.trim();
224
224
  if (trimmed === "" || trimmed === "at unknown" || trimmed === "at native") return true;
225
- if (!/\(node:[^)]*\)$/.test(trimmed) && !/^at node:/.test(trimmed)) return false;
225
+ if (!/\(node:[^)]*\)$/.test(trimmed) && !trimmed.startsWith("at node:")) return false;
226
226
  hasNetFrame ||= trimmed.includes("node:net:");
227
227
  return true;
228
228
  });
@@ -395,23 +395,40 @@ function formatFatalError(label: string, err: Error): string {
395
395
  return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
396
396
  }
397
397
 
398
- async function exitAfterFatal(label: string, logMessage: string, err: Error, reason: Reason): Promise<void> {
398
+ async function exitAfterFatal(output: string, logMessage: string, err: Error, reason: Reason): Promise<never> {
399
399
  const forcedExit = setTimeout(() => exitProcess(1), CLEANUP_DEADLINE_MS);
400
400
  try {
401
+ // Cleanup callbacks are invoked synchronously before runCleanup returns its
402
+ // completion promise. TUI owners therefore hand the cursor back before the
403
+ // fatal report is written, while slower resource cleanup continues afterward.
404
+ const cleanup = runCleanup(reason);
401
405
  restoreTerminalStderr();
402
406
  // A revoked terminal can make stream writes raise another fatal error. Use
403
407
  // the descriptor directly so failure stays synchronous and contained.
404
408
  try {
405
- fs.writeSync(2, `${formatFatalError(label, err)}${formatFatalRecoveryHints()}`);
409
+ fs.writeSync(2, output);
406
410
  } catch {}
407
411
  logger.error(logMessage, { err });
408
- await runCleanup(reason);
412
+ await cleanup;
409
413
  } finally {
410
414
  clearTimeout(forcedExit);
411
415
  exitProcess(1);
412
416
  }
413
417
  }
414
418
 
419
+ /**
420
+ * Reports a caught top-level failure after terminal owners restore their display, then exits.
421
+ */
422
+ export async function fatal(error: unknown): Promise<never> {
423
+ const err = error instanceof Error ? error : new Error(String(error));
424
+ const output = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n${formatFatalRecoveryHints()}`;
425
+ if (!isMainThread) {
426
+ process.stderr.write(output);
427
+ process.exit(1);
428
+ }
429
+ return exitAfterFatal(output, "Fatal error", err, Reason.UNHANDLED_REJECTION);
430
+ }
431
+
415
432
  if (isMainThread) {
416
433
  process
417
434
  .on("SIGINT", async () => {
@@ -461,7 +478,12 @@ if (isMainThread) {
461
478
  });
462
479
  return;
463
480
  }
464
- await exitAfterFatal("Uncaught Exception", "Uncaught exception", err, Reason.UNCAUGHT_EXCEPTION);
481
+ await exitAfterFatal(
482
+ `${formatFatalError("Uncaught Exception", err)}${formatFatalRecoveryHints()}`,
483
+ "Uncaught exception",
484
+ err,
485
+ Reason.UNCAUGHT_EXCEPTION,
486
+ );
465
487
  })
466
488
  .on("unhandledRejection", async reason => {
467
489
  const err = reason instanceof Error ? reason : new Error(String(reason));
@@ -497,7 +519,12 @@ if (isMainThread) {
497
519
  });
498
520
  }
499
521
  }
500
- await exitAfterFatal("Unhandled Rejection", "Unhandled rejection", err, Reason.UNHANDLED_REJECTION);
522
+ await exitAfterFatal(
523
+ `${formatFatalError("Unhandled Rejection", err)}${formatFatalRecoveryHints()}`,
524
+ "Unhandled rejection",
525
+ err,
526
+ Reason.UNHANDLED_REJECTION,
527
+ );
501
528
  })
502
529
  .on("exit", async () => {
503
530
  void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
package/src/prompt.ts CHANGED
@@ -196,6 +196,7 @@ export function format(content: string, options: PromptFormatOptions = {}): stri
196
196
  } = options;
197
197
  const isPreRender = renderPhase === "pre-render";
198
198
  const lines = content.split("\n");
199
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
199
200
  const result: string[] = new Array(lines.length);
200
201
  let n = 0; // logical length of `result` (pops are n--)
201
202
  let inCodeBlock = false;
package/src/ring.ts CHANGED
@@ -15,6 +15,7 @@ export class RingBuffer<T> {
15
15
  * @param capacity - The maximum number of elements the buffer can hold. Must be positive.
16
16
  */
17
17
  constructor(public readonly capacity: number) {
18
+ // oxlint-disable-next-line unicorn/no-new-array -- length preallocation
18
19
  this.#buf = new Array(capacity);
19
20
  }
20
21
 
@@ -2,6 +2,8 @@ import * as fs from "node:fs";
2
2
  import * as fsp from "node:fs/promises";
3
3
  import * as Module from "node:module";
4
4
  import * as path from "node:path";
5
+ import { withFileLock } from "./file-lock";
6
+ import { isEexist, isEnoent } from "./fs-error";
5
7
 
6
8
  /**
7
9
  * On-demand runtime dependency support for native-heavy optional packages
@@ -303,25 +305,62 @@ export interface EnsureRuntimeInstalledOptions {
303
305
  lockSleepMs?: number;
304
306
  }
305
307
 
306
- function isErrnoCode(error: unknown, code: string): boolean {
307
- return typeof error === "object" && error !== null && "code" in error && error.code === code;
308
- }
308
+ /** No runtime install plausibly runs this long, so older legacy lock directories are crash orphans. */
309
+ const STALE_LEGACY_LOCK_MS = 10 * 60_000;
309
310
 
310
- async function acquireInstallLock(runtimeDir: string, attempts: number, sleepMs: number): Promise<() => Promise<void>> {
311
- const lockDir = `${runtimeDir}.lock`;
312
- await fsp.mkdir(path.dirname(lockDir), { recursive: true });
313
- for (let attempt = 0; attempt < attempts; attempt++) {
311
+ /**
312
+ * Run `fn` while reserving the pre-crash-safe `${runtimeDir}.lock` namespace.
313
+ *
314
+ * Versions through 18.0.10 serialized installs with a bare lock *directory*
315
+ * that only its creator removed; an installer killed outside that window
316
+ * (SIGKILL/OOM/Ctrl-C) left it unreleasable, wedging every later install for
317
+ * the full wait envelope (issue #10120). During an in-flight upgrade a legacy
318
+ * process may still legitimately own this directory, so poll until it is
319
+ * released and only force-reclaim once the directory is older than any
320
+ * plausible install ({@link STALE_LEGACY_LOCK_MS}) — never merely because a
321
+ * retry budget elapsed, which would delete a still-active legacy lock and let
322
+ * two installers race the same tree. Once the namespace is free, atomically
323
+ * create and retain a regular file through `fn`: an older process cannot
324
+ * acquire it between the handoff check and the new install. A file left by a
325
+ * crashed new installer can be reused immediately because the outer OS lock
326
+ * proves its owner is gone, unlike a legacy directory whose owner is unknown.
327
+ */
328
+ async function withLegacyInstallLock<T>(runtimeDir: string, sleepMs: number, fn: () => Promise<T>): Promise<T> {
329
+ const legacy = `${runtimeDir}.lock`;
330
+ for (;;) {
314
331
  try {
315
- await fsp.mkdir(lockDir);
316
- return async () => {
317
- await fsp.rm(lockDir, { recursive: true, force: true });
318
- };
332
+ const reservation = await fsp.open(legacy, "wx");
333
+ await reservation.close();
319
334
  } catch (error) {
320
- if (!isErrnoCode(error, "EEXIST")) throw error;
321
- await Bun.sleep(sleepMs);
335
+ if (!isEexist(error)) throw error;
336
+ let stat: fs.Stats;
337
+ try {
338
+ stat = await fsp.stat(legacy);
339
+ } catch (statError) {
340
+ if (isEnoent(statError)) continue; // released between open and stat; retry
341
+ throw statError;
342
+ }
343
+ // A non-directory is a reservation left by a newer installer. The
344
+ // outer OS lock proves that installer is gone, so reuse it at once.
345
+ if (!stat.isDirectory()) break;
346
+ // A fresh directory may still belong to a live pre-18.x installer, so
347
+ // wait for it to finish; only a crash orphan (older than any plausible
348
+ // install) is force-reclaimed.
349
+ if (Date.now() - stat.mtimeMs > STALE_LEGACY_LOCK_MS) {
350
+ await fsp.rm(legacy, { recursive: true, force: true });
351
+ } else {
352
+ await Bun.sleep(sleepMs);
353
+ }
354
+ continue;
322
355
  }
356
+ break;
357
+ }
358
+ // Retain the regular-file reservation across the install.
359
+ try {
360
+ return await fn();
361
+ } finally {
362
+ await fsp.rm(legacy, { force: true });
323
363
  }
324
- throw new Error(`Timed out waiting for runtime install lock: ${lockDir}`);
325
364
  }
326
365
 
327
366
  export async function writeRuntimeManifest(runtimeDir: string, install: RuntimeInstallSpec): Promise<void> {
@@ -363,7 +402,14 @@ async function runRuntimeInstall(runtimeDir: string): Promise<void> {
363
402
 
364
403
  /**
365
404
  * Materialize a pinned dependency set into `runtimeDir` (idempotent,
366
- * cross-process safe via a lock directory). Returns `runtimeDir`.
405
+ * cross-process safe). Returns `runtimeDir`.
406
+ *
407
+ * Serialization uses the OS-backed {@link withFileLock} at
408
+ * `${runtimeDir}.install.lock`, which the kernel releases on process death, so
409
+ * a crashed installer cannot wedge later attempts (issue #10120). The path is
410
+ * deliberately distinct from the legacy `${runtimeDir}.lock` mkdir directory;
411
+ * {@link withLegacyInstallLock} atomically reserves that namespace during the
412
+ * new install so older processes cannot cross the migration boundary.
367
413
  */
368
414
  export async function ensureRuntimeInstalled(options: EnsureRuntimeInstalledOptions): Promise<string> {
369
415
  const { runtimeDir, install, onPhase, lockAttempts = 240, lockSleepMs = 250 } = options;
@@ -379,15 +425,20 @@ export async function ensureRuntimeInstalled(options: EnsureRuntimeInstalledOpti
379
425
  if (await probeManifest.exists()) return runtimeDir;
380
426
 
381
427
  onPhase?.("initiate");
382
- const releaseLock = await acquireInstallLock(runtimeDir, lockAttempts, lockSleepMs);
383
- try {
384
- if (await probeManifest.exists()) return runtimeDir;
385
- await writeRuntimeManifest(runtimeDir, install);
386
- onPhase?.("download");
387
- await runRuntimeInstall(runtimeDir);
388
- onPhase?.("done");
389
- return runtimeDir;
390
- } finally {
391
- await releaseLock();
392
- }
428
+ // withFileLock does not create parent directories; the runtime cache dir may
429
+ // not exist yet on the very first install.
430
+ await fsp.mkdir(path.dirname(runtimeDir), { recursive: true });
431
+ return withFileLock(
432
+ `${runtimeDir}.install`,
433
+ () =>
434
+ withLegacyInstallLock(runtimeDir, lockSleepMs, async () => {
435
+ if (await probeManifest.exists()) return runtimeDir;
436
+ await writeRuntimeManifest(runtimeDir, install);
437
+ onPhase?.("download");
438
+ await runRuntimeInstall(runtimeDir);
439
+ onPhase?.("done");
440
+ return runtimeDir;
441
+ }),
442
+ { retries: lockAttempts, retryDelayMs: lockSleepMs },
443
+ );
393
444
  }
package/src/template.ts CHANGED
@@ -549,7 +549,7 @@ export function compile<T = unknown>(source: string, options: CompileOptions = {
549
549
  const nodes = parseTemplate(stripStandalone(source));
550
550
  return (context, runtime = {}) => {
551
551
  const root = context ?? {};
552
- const frame: Frame = { context: root, parents: [], root, data: { root, ...(runtime.data ?? {}) } };
552
+ const frame: Frame = { context: root, parents: [], root, data: { root, ...runtime.data } };
553
553
  return renderNodes(nodes, frame, { helpers, partials, options, runtime });
554
554
  };
555
555
  }
@@ -579,7 +579,7 @@ export class TemplateEngine {
579
579
  const nodes = parseTemplate(stripStandalone(source));
580
580
  return (context, runtime = {}) => {
581
581
  const root = context ?? {};
582
- const frame: Frame = { context: root, parents: [], root, data: { root, ...(runtime.data ?? {}) } };
582
+ const frame: Frame = { context: root, parents: [], root, data: { root, ...runtime.data } };
583
583
  return renderNodes(nodes, frame, { helpers: this.#helpers, partials: this.#partials, options, runtime });
584
584
  };
585
585
  }
package/src/tls-fetch.ts CHANGED
@@ -174,5 +174,5 @@ export function withExtraCaFetch<T extends { fetch?: FetchImpl } | undefined>(op
174
174
  const fetchImpl = options?.fetch ?? (globalThis.fetch as FetchImpl);
175
175
  const wrapped = wrapFetchForExtraCa(fetchImpl);
176
176
  if (wrapped === fetchImpl && options?.fetch !== undefined) return options;
177
- return { ...(options ?? {}), fetch: wrapped } as T;
177
+ return { ...options, fetch: wrapped } as T;
178
178
  }
@@ -222,7 +222,7 @@ export function parseHtmlFragment(html: string): TurndownNode {
222
222
  const stack: HtmlNode[] = [root];
223
223
  for (const token of htmlTokens(html)) {
224
224
  const parent = stack[stack.length - 1] ?? root;
225
- if (token.startsWith("<!--") || /^<!/i.test(token)) continue;
225
+ if (token.startsWith("<!--") || token.startsWith("<!")) continue;
226
226
  if (!token.startsWith("<")) {
227
227
  parent.append(new HtmlText(decodeEntities(token)));
228
228
  continue;