@linxiraos/pi-utils 1.4.7 → 1.4.10

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/ar/lzh.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readUInt16LE, readUInt32LE } from "./bytes";
1
2
  import { crc16Arc } from "./checksums";
2
3
  import { ArchiveError } from "./error";
3
4
  import { assertArchiveMemberSize, assertEntryCount, assertIndexSize, assertInMemorySize } from "./limits";
@@ -279,16 +280,8 @@ function crc16WithZeroRange(bytes: Uint8Array, zeroStart: number, zeroEnd: numbe
279
280
  return crc16Arc(bytes.subarray(zeroEnd), value);
280
281
  }
281
282
 
282
- function u16(bytes: Uint8Array, offset: number): number {
283
- return bytes[offset]! | (bytes[offset + 1]! << 8);
284
- }
285
-
286
- function u32(bytes: Uint8Array, offset: number): number {
287
- return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24)) >>> 0;
288
- }
289
-
290
283
  function u64(bytes: Uint8Array, offset: number): number {
291
- const value = u32(bytes, offset) + u32(bytes, offset + 4) * 0x100000000;
284
+ const value = readUInt32LE(bytes, offset) + readUInt32LE(bytes, offset + 4) * 0x100000000;
292
285
  if (!Number.isSafeInteger(value)) throw new ArchiveError("LZH uses sizes too large to read safely");
293
286
  return value;
294
287
  }
@@ -318,7 +311,8 @@ function decodeUtf16(bytes: Uint8Array): string {
318
311
  return UTF16LE_DECODER.decode(bytes.subarray(0, end));
319
312
  }
320
313
 
321
- function dosTimeToMs(value: number): number | undefined {
314
+ /** MS-DOS packed date/time as UTC milliseconds; `undefined` for 0 or an out-of-range field. */
315
+ export function dosTimeToMs(value: number): number | undefined {
322
316
  if (value === 0) return undefined;
323
317
  const year = 1980 + ((value >>> 25) & 0x7f);
324
318
  const month = (value >>> 21) & 0x0f;
@@ -352,7 +346,7 @@ function processExtendedHeader(
352
346
  switch (type) {
353
347
  case 0x00:
354
348
  if (data.byteLength < 2) throw new ArchiveError("Invalid LZH common extended header");
355
- fields.commonCrc = u16(data, 0);
349
+ fields.commonCrc = readUInt16LE(data, 0);
356
350
  fields.commonCrcOffset = absoluteDataOffset;
357
351
  break;
358
352
  case 0x01:
@@ -365,8 +359,8 @@ function processExtendedHeader(
365
359
  throw new ArchiveError("Multi-volume LZH archives are unsupported");
366
360
  case 0x41:
367
361
  if (data.byteLength >= 16) {
368
- const low = u32(data, 8);
369
- const high = u32(data, 12);
362
+ const low = readUInt32LE(data, 8);
363
+ const high = readUInt32LE(data, 12);
370
364
  const filetime = low + high * 0x100000000;
371
365
  if (Number.isSafeInteger(filetime)) fields.mtimeMs = filetime / 10_000 - 11_644_473_600_000;
372
366
  }
@@ -384,11 +378,11 @@ function processExtendedHeader(
384
378
  break;
385
379
  case 0x50:
386
380
  if (data.byteLength < 2) throw new ArchiveError("Invalid LZH Unix permissions extended header");
387
- fields.mode = u16(data, 0);
381
+ fields.mode = readUInt16LE(data, 0);
388
382
  break;
389
383
  case 0x54:
390
384
  if (data.byteLength < 4) throw new ArchiveError("Invalid LZH Unix timestamp extended header");
391
- fields.mtimeMs = u32(data, 0) * 1000;
385
+ fields.mtimeMs = readUInt32LE(data, 0) * 1000;
392
386
  break;
393
387
  }
394
388
  }
@@ -464,8 +458,8 @@ function parseLzhHeader(bytes: Uint8Array, offset: number, options: FormatReadOp
464
458
  if (level > 2) throw new ArchiveError(`Unsupported LZH header level ${level}`);
465
459
  const method = String.fromCharCode(...bytes.subarray(offset + 2, offset + 7));
466
460
  if (!LHA_METHOD_PATTERN.test(method)) throw new ArchiveError(`Invalid LZH compression method '${method}'`);
467
- let packedSize = u32(bytes, offset + 7);
468
- let size = u32(bytes, offset + 11);
461
+ let packedSize = readUInt32LE(bytes, offset + 7);
462
+ let size = readUInt32LE(bytes, offset + 11);
469
463
  let crc = 0;
470
464
  let legacyFilename: string | undefined;
471
465
  let mtimeMs: number | undefined;
@@ -486,22 +480,22 @@ function parseLzhHeader(bytes: Uint8Array, offset: number, options: FormatReadOp
486
480
  if (22 + nameLength + 2 > headerLength + 2) throw new ArchiveError("Invalid LZH filename length");
487
481
  assertArchivePathBytes(nameLength, "member path", options.limits.maxPathBytes);
488
482
  legacyFilename = decodeLegacy(bytes.subarray(offset + 22, offset + 22 + nameLength));
489
- crc = u16(bytes, offset + 22 + nameLength);
490
- mtimeMs = dosTimeToMs(u32(bytes, offset + 15));
483
+ crc = readUInt16LE(bytes, offset + 22 + nameLength);
484
+ mtimeMs = dosTimeToMs(readUInt32LE(bytes, offset + 15));
491
485
  if (level === 0) {
492
486
  const extendedStart = offset + 24 + nameLength;
493
487
  if (baseEnd - extendedStart >= 12) {
494
488
  const extended = bytes.subarray(extendedStart, baseEnd);
495
489
  if ((extended[0] === 0x55 || extended[0] === 0x4b) && extended[1] === 0) {
496
490
  osId = extended[0]!;
497
- fields.mtimeMs = u32(extended, 2) * 1000;
498
- fields.mode = u16(extended, extended.byteLength - 6);
491
+ fields.mtimeMs = readUInt32LE(extended, 2) * 1000;
492
+ fields.mode = readUInt16LE(extended, extended.byteLength - 6);
499
493
  }
500
494
  }
501
495
  dataStart = baseEnd;
502
496
  } else {
503
497
  osId = bytes[offset + 24 + nameLength]!;
504
- let extensionSize = u16(bytes, baseEnd - 2);
498
+ let extensionSize = readUInt16LE(bytes, baseEnd - 2);
505
499
  let cursor = baseEnd;
506
500
  let totalExtensionSize = 0;
507
501
  let extensionCount = 0;
@@ -519,7 +513,7 @@ function parseLzhHeader(bytes: Uint8Array, offset: number, options: FormatReadOp
519
513
  }
520
514
  processExtendedHeader(type, data, cursor + 1, fields);
521
515
  const currentSize = extensionSize;
522
- extensionSize = u16(bytes, dataEnd);
516
+ extensionSize = readUInt16LE(bytes, dataEnd);
523
517
  cursor += currentSize;
524
518
  }
525
519
  dataStart = baseEnd + totalExtensionSize;
@@ -527,17 +521,17 @@ function parseLzhHeader(bytes: Uint8Array, offset: number, options: FormatReadOp
527
521
  if (packedSize < 0) throw new ArchiveError("Invalid LZH level-1 packed size");
528
522
  }
529
523
  } else {
530
- const headerLength = u16(bytes, offset);
524
+ const headerLength = readUInt16LE(bytes, offset);
531
525
  if (headerLength < 26) throw new ArchiveError("Invalid LZH level-2 header size");
532
526
  const headerEnd = offset + headerLength;
533
527
  assertRange(bytes, offset, headerEnd, "header");
534
- crc = u16(bytes, offset + 21);
528
+ crc = readUInt16LE(bytes, offset + 21);
535
529
  osId = bytes[offset + 23]!;
536
- mtimeMs = u32(bytes, offset + 15) * 1000;
530
+ mtimeMs = readUInt32LE(bytes, offset + 15) * 1000;
537
531
  let cursor = offset + 24;
538
532
  let extensionCount = 0;
539
533
  while (cursor + 2 <= headerEnd) {
540
- const extensionSize = u16(bytes, cursor);
534
+ const extensionSize = readUInt16LE(bytes, cursor);
541
535
  if (extensionSize === 0) {
542
536
  cursor += 2;
543
537
  break;
@@ -1,5 +1,6 @@
1
1
  import { crc32 } from "../checksums";
2
2
  import { lzma2Decompress, lzmaDecompress } from "../codecs/lzma";
3
+ import { deltaDecode, x86Decode } from "../codecs/xz";
3
4
  import { ArchiveError } from "../error";
4
5
  import type { ArchiveLimits } from "../limits";
5
6
  import { assertInMemorySize } from "../limits";
@@ -81,62 +82,6 @@ function read32LE(bytes: Uint8Array, offset: number): number {
81
82
  return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24)) >>> 0;
82
83
  }
83
84
 
84
- function deltaDecode(bytes: Uint8Array, distance: number): void {
85
- const history = new Uint8Array(256);
86
- let position = 0;
87
- for (let index = 0; index < bytes.byteLength; index++) {
88
- const value = (bytes[index]! + history[(distance + position) & 0xff]!) & 0xff;
89
- history[position] = value;
90
- bytes[index] = value;
91
- position = (position - 1) & 0xff;
92
- }
93
- }
94
-
95
- function x86Decode(bytes: Uint8Array, startOffset: number): void {
96
- const maskToBitNumber = [0, 1, 2, 2, 3] as const;
97
- let previousMask = 0;
98
- let previousPosition = 0xfffffffb;
99
- if (bytes.byteLength < 5) return;
100
- let position = 0;
101
- const limit = bytes.byteLength - 5;
102
- while (position <= limit) {
103
- let byte = bytes[position]!;
104
- if (byte !== 0xe8 && byte !== 0xe9) {
105
- position++;
106
- continue;
107
- }
108
- const absolutePosition = (startOffset + position) >>> 0;
109
- const offset = (absolutePosition - previousPosition) >>> 0;
110
- previousPosition = absolutePosition;
111
- if (offset > 5) previousMask = 0;
112
- else for (let index = 0; index < offset; index++) previousMask = ((previousMask & 0x77) << 1) >>> 0;
113
- byte = bytes[position + 4]!;
114
- if ((byte === 0 || byte === 0xff) && previousMask >>> 1 <= 4 && previousMask >>> 1 !== 3) {
115
- let source = read32LE(bytes, position + 1);
116
- let destination = 0;
117
- for (;;) {
118
- destination = (source - absolutePosition - 5) >>> 0;
119
- if (previousMask === 0) break;
120
- const bitIndex = maskToBitNumber[previousMask >>> 1]! * 8;
121
- const testByte = (destination >>> (24 - bitIndex)) & 0xff;
122
- if (testByte !== 0 && testByte !== 0xff) break;
123
- const lowMask = bitIndex === 0 ? 0xffffffff : 2 ** (32 - bitIndex) - 1;
124
- source = (destination ^ lowMask) >>> 0;
125
- }
126
- bytes[position + 4] = ~(((destination >>> 24) & 1) - 1) & 0xff;
127
- bytes[position + 3] = destination >>> 16;
128
- bytes[position + 2] = destination >>> 8;
129
- bytes[position + 1] = destination;
130
- position += 5;
131
- previousMask = 0;
132
- } else {
133
- position++;
134
- previousMask |= 1;
135
- if (byte === 0 || byte === 0xff) previousMask |= 0x10;
136
- }
137
- }
138
- }
139
-
140
85
  /** Shared lazy decoder/cache for a single 7z folder (solid block). */
141
86
  export class SevenZipFolderSource {
142
87
  readonly #source: ByteSource;
package/src/ar/tar.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import { bytesMatchAscii } from "./bytes";
2
+ import { bytesMatchAscii, indexOfAscii } from "./bytes";
3
3
  import { resolveArchiveLinkPath, upsertArchiveEntry } from "./entries";
4
4
  import { ArchiveError } from "./error";
5
5
  import {
@@ -419,13 +419,6 @@ function paxDeclaresSparse(
419
419
  return paxAttribute(globalPax, localPax, PAX_SPARSE_MARKER) === "1";
420
420
  }
421
421
 
422
- function indexOfAscii(bytes: Uint8Array, value: string, start: number): number {
423
- for (let offset = start; offset <= bytes.byteLength - value.length; offset++) {
424
- if (bytesMatchAscii(bytes, offset, value)) return offset;
425
- }
426
- return -1;
427
- }
428
-
429
422
  function normalizeOldGnuName(value: string, field: string, limits: ArchiveLimits): string {
430
423
  const portable = value.replace(/\\/g, "/");
431
424
  if (path.posix.isAbsolute(portable)) throw new ArchiveError(`Invalid old-GNU ${field}`);
package/src/async.ts CHANGED
@@ -29,6 +29,19 @@ export async function sleepLong(delayMs: number, signal?: AbortSignal): Promise<
29
29
  }
30
30
  }
31
31
 
32
+ /**
33
+ * Wait for a wall-clock deadline, including time spent suspended. Some platforms
34
+ * exclude suspend from relative timers, so recheck at least once per second.
35
+ */
36
+ export async function sleepUntil(deadlineMs: number, signal?: AbortSignal): Promise<void> {
37
+ while (true) {
38
+ signal?.throwIfAborted();
39
+ const remaining = deadlineMs - Date.now();
40
+ if (!(remaining > 0)) return;
41
+ await scheduler.wait(Math.min(remaining, 1_000), { signal });
42
+ }
43
+ }
44
+
32
45
  /**
33
46
  * Wrap a promise with a timeout and optional abort signal.
34
47
  * Rejects with the given error or a new error containing the given message if
package/src/bytes.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Narrow `bytes` to a view over its own whole `ArrayBuffer`, which is what
3
+ * `crypto.subtle.{digest,sign,importKey,encrypt,decrypt}` require under the
4
+ * strict TS DOM typings. Returns `bytes` itself when it already is one,
5
+ * otherwise a copy.
6
+ */
7
+ export function asStrictBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
8
+ if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
9
+ return bytes as Uint8Array<ArrayBuffer>;
10
+ }
11
+ const copy = new Uint8Array(bytes.byteLength);
12
+ copy.set(bytes);
13
+ return copy;
14
+ }
package/src/color.ts CHANGED
@@ -311,6 +311,12 @@ function oklabToLinearRgb(L: number, a: number, b: number): { r: number; g: numb
311
311
  };
312
312
  }
313
313
 
314
+ /** Convert a hex color to OKLab. */
315
+ function hexToOklab(hex: string): { L: number; a: number; b: number } {
316
+ const rgb = hexToRgb(hex);
317
+ return linearRgbToOklab(linearizeChannel(rgb.r), linearizeChannel(rgb.g), linearizeChannel(rgb.b));
318
+ }
319
+
314
320
  /**
315
321
  * Convert a hex color to OKLCH (perceptual lightness/chroma/hue).
316
322
  *
@@ -318,14 +324,20 @@ function oklabToLinearRgb(L: number, a: number, b: number): { r: number; g: numb
318
324
  * hues, so carrying them between colors preserves the palette's "weight".
319
325
  */
320
326
  export function hexToOklch(hex: string): OKLCH {
321
- const rgb = hexToRgb(hex);
322
- const lab = linearRgbToOklab(linearizeChannel(rgb.r), linearizeChannel(rgb.g), linearizeChannel(rgb.b));
327
+ const lab = hexToOklab(hex);
323
328
  const c = Math.hypot(lab.a, lab.b);
324
329
  let h = (Math.atan2(lab.b, lab.a) * 180) / Math.PI;
325
330
  if (h < 0) h += 360;
326
331
  return { l: lab.L, c, h };
327
332
  }
328
333
 
334
+ /** Perceptual distance between two hex colors: Euclidean OKLab, 0 when identical, 1 from black to white. */
335
+ export function oklabDistance(a: string, b: string): number {
336
+ const x = hexToOklab(a);
337
+ const y = hexToOklab(b);
338
+ return Math.hypot(x.L - y.L, x.a - y.a, x.b - y.b);
339
+ }
340
+
329
341
  /**
330
342
  * Max OKLab saturation (C/L) that stays inside sRGB for the hue direction
331
343
  * `(a, b)`, via Björn Ottosson's polynomial fit plus one Halley refinement.
package/src/env.ts CHANGED
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { parseEnv } from "node:util";
5
- import { getAgentDir, getConfigRootDir, getProjectDir, refreshDirsFromEnv } from "./dirs";
5
+ import { getActiveProfile, getAgentDir, getConfigRootDir, getProjectDir, refreshDirsFromEnv } from "./dirs";
6
6
  import { isBunTestRuntime } from "./test-runtime";
7
7
 
8
8
  export * from "./test-runtime";
@@ -278,11 +278,55 @@ export function parseEnvFile(filePath: string): Record<string, string> {
278
278
  return result;
279
279
  }
280
280
 
281
+ // Read-only compatibility with the old OMP agent directory. Off by default:
282
+ // external OMP configuration must be explicitly imported. Set
283
+ // ZETA_OMP_COMPAT=1 in the shell or a Zeta dotenv file to enable both the
284
+ // legacy dotenv fallback and the models.yml/agent.db overlay.
285
+ export function isOmpCompatEnabled(env: Record<string, string | undefined> = Bun.env): boolean {
286
+ const value = env.ZETA_OMP_COMPAT;
287
+ if (!value) return false;
288
+ const normalized = value.toLowerCase();
289
+ return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
290
+ }
291
+
292
+ function loadLegacyOmpEnv(): Record<string, string> {
293
+ const home = os.homedir();
294
+ const project = getProjectDir();
295
+ const profile = getActiveProfile();
296
+ const files = [
297
+ { file: path.join(home, ".omp", ".env"), project: false },
298
+ { file: path.join(home, ".omp", "agent", ".env"), project: false },
299
+ ...(profile ? [{ file: path.join(home, ".omp", "profiles", profile, "agent", ".env"), project: false }] : []),
300
+ { file: path.join(project, ".omp", ".env"), project: true },
301
+ { file: path.join(project, ".omp", "agent", ".env"), project: true },
302
+ ];
303
+ const merged: Record<string, string> = {};
304
+ const seen = new Set<string>();
305
+ for (const { file, project: projectFile } of files) {
306
+ if (seen.has(file)) continue;
307
+ seen.add(file);
308
+ for (const [key, value] of Object.entries(parseEnvFile(file))) {
309
+ merged[key] = value;
310
+ if (projectFile) projectEnvNamesLoadedByOmp.add(key);
311
+ }
312
+ }
313
+ return merged;
314
+ }
315
+
281
316
  // Eagerly parse the user's $HOME/.env and the current project's .env (from cwd)
282
317
  const homeEnv = parseEnvFile(path.join(os.homedir(), ".env"));
283
318
  const piEnv = parseEnvFile(path.join(getConfigRootDir(), ".env"));
284
319
  const agentEnv = parseEnvFile(path.join(getAgentDir(), ".env"));
285
320
  const projectEnv = parseEnvFile(path.join(getProjectDir(), ".env"));
321
+ // The switch itself may come from the shell or any Zeta dotenv file. Check the
322
+ // same precedence before touching legacy OMP files; do not read them by default.
323
+ const ompCompatValue =
324
+ Bun.env.ZETA_OMP_COMPAT ??
325
+ projectEnv.ZETA_OMP_COMPAT ??
326
+ agentEnv.ZETA_OMP_COMPAT ??
327
+ piEnv.ZETA_OMP_COMPAT ??
328
+ homeEnv.ZETA_OMP_COMPAT;
329
+ const legacyOmpEnv = isOmpCompatEnabled({ ZETA_OMP_COMPAT: ompCompatValue }) ? loadLegacyOmpEnv() : {};
286
330
 
287
331
  for (const key of Object.keys(Bun.env)) {
288
332
  const value = Bun.env[key];
@@ -291,7 +335,7 @@ for (const key of Object.keys(Bun.env)) {
291
335
  }
292
336
  }
293
337
 
294
- for (const file of [projectEnv, agentEnv, piEnv, homeEnv]) {
338
+ for (const file of [projectEnv, agentEnv, piEnv, homeEnv, legacyOmpEnv]) {
295
339
  for (const key in file) {
296
340
  if (!isMacosMallocStackLoggingEnvName(key) && !Bun.env[key]) {
297
341
  Bun.env[key] = file[key];
package/src/file-lock.ts CHANGED
@@ -45,6 +45,15 @@ export function tryAcquireFileLock(filePath: string): FileLockHandle | null {
45
45
  return tryAcquireLock(getLockPath(filePath));
46
46
  }
47
47
 
48
+ /**
49
+ * Whether any holder, including this process, owns the lease, observed without
50
+ * taking it, so a concurrent `tryAcquireFileLock` never meets the probe.
51
+ * Throws when the lock cannot be probed.
52
+ */
53
+ export function isFileLockHeld(filePath: string): boolean {
54
+ return NativeFileLock.isHeld(getLockPath(filePath));
55
+ }
56
+
48
57
  /** Acquire an exclusive lease; callers must release it when their operation ends. */
49
58
  export async function acquireFileLock(filePath: string, options: FileLockOptions = {}): Promise<FileLockHandle> {
50
59
  const opts = { ...DEFAULT_OPTIONS, ...options };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Refuses overwriting a file this process holds open with locks (issue #15586).
3
+ *
4
+ * Writing over a live `agent.db` or its `-shm` destroys the store (a truncated
5
+ * `-shm` also faults the connection's memory map), so tools that write
6
+ * agent-chosen paths check here first.
7
+ */
8
+ import { heldFileKind } from "@linxiraos/pi-natives/sqlite-locks";
9
+
10
+ /**
11
+ * Throws an `EBUSY` error when `filePath` is a file this process holds open with
12
+ * locks: a SQLite database or `-shm` of a live connection. Always passes outside Linux.
13
+ */
14
+ export function assertNotHeldOpen(filePath: string): void {
15
+ const kind = heldFileKind(filePath);
16
+ if (!kind) return;
17
+ const holder = kind === "locked" ? "a file lock" : "a SQLite connection";
18
+ throw Object.assign(
19
+ new Error(`EBUSY: '${filePath}' is held open by ${holder} in this process; overwriting it would destroy it`),
20
+ { code: "EBUSY", path: filePath },
21
+ );
22
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { once, untilAborted } from "./abortable";
2
2
  export * from "./async";
3
3
  export * from "./binary";
4
+ export * from "./bytes";
4
5
  export * from "./color";
5
6
  export * from "./dirs";
6
7
  export * from "./env";
@@ -23,6 +24,7 @@ export * from "./path";
23
24
  export * from "./path-tree";
24
25
  export * from "./ports";
25
26
  export * from "./peek-file";
27
+ export * from "./pid";
26
28
  export * as postmortem from "./postmortem";
27
29
  export * from "./process-name";
28
30
  export * as procmgr from "./procmgr";
package/src/logger.ts CHANGED
@@ -22,6 +22,7 @@ import * as path from "node:path";
22
22
  import { isPromise } from "node:util/types";
23
23
  import { getLogsDir, localDay } from "./dirs";
24
24
  import { RotatingFileSink } from "./logger/rotating-file";
25
+ import { isPidAlive } from "./pid";
25
26
  import { setStderrRedirectTarget } from "./stderr-guard";
26
27
  import { drainModuleLoadEvents } from "./timing-buffer";
27
28
  /** Severity names accepted by the centralized logger. */
@@ -81,15 +82,6 @@ const LEGACY_AUDIT_PATTERN = /^\.[0-9a-f]{8,}-audit\.json$/;
81
82
  const RETAINED_STALE_LOGS_PER_PROCESS_DAY = 1;
82
83
  const RETAINED_STALE_LOG_DAYS = 5;
83
84
 
84
- function processIsRunning(pid: number): boolean {
85
- try {
86
- process.kill(pid, 0);
87
- return true;
88
- } catch (error) {
89
- return !(error instanceof Error && "code" in error && (error.code === "ESRCH" || error.code === "EINVAL"));
90
- }
91
- }
92
-
93
85
  /**
94
86
  * Retain one newest completed-process log per process/day within the current
95
87
  * and previous four local calendar days, remove completed-process audit files
@@ -133,7 +125,7 @@ function pruneStaleProcessLogs(dir: string): void {
133
125
  const logMatch = PROCESS_LOG_PATTERN.exec(entry.name);
134
126
  const auditMatch = PROCESS_AUDIT_PATTERN.exec(entry.name);
135
127
  const pidText = logMatch?.[2] ?? auditMatch?.[1];
136
- if (!pidText || processIsRunning(Number(pidText))) continue;
128
+ if (!pidText || isPidAlive(Number(pidText))) continue;
137
129
 
138
130
  if (auditMatch) {
139
131
  removeBestEffort(entryPath);
package/src/pid.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Signal-0 liveness probe: true unless the OS reports the pid as gone
3
+ * (`ESRCH`) or invalid (`EINVAL`). Any other failure, notably `EPERM` (alive
4
+ * but owned by another user), reads as alive, so callers never reclaim a
5
+ * resource a live process may still hold. Unlike `procmgr.isPidRunning`, this
6
+ * does not consult the process table's run state.
7
+ */
8
+ export function isPidAlive(pid: number): boolean {
9
+ try {
10
+ process.kill(pid, 0);
11
+ return true;
12
+ } catch (error) {
13
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
14
+ return code !== "ESRCH" && code !== "EINVAL";
15
+ }
16
+ }
package/src/postmortem.ts CHANGED
@@ -10,6 +10,7 @@ import * as fs from "node:fs";
10
10
  import { APP_NAME, CLI_BIN_NAME } from "./dirs";
11
11
  import * as logger from "./logger";
12
12
  import { restoreTerminalStderr } from "./stderr-guard";
13
+ import { toError } from "./type-guards";
13
14
 
14
15
  // Cleanup reasons, in order of priority/meaning.
15
16
  export enum Reason {
@@ -276,7 +277,7 @@ function runCleanup(reason: Reason, keepAlive = false): Promise<void> {
276
277
  const cleanupSettled = Promise.allSettled(promises).then(async results => {
277
278
  for (const result of results) {
278
279
  if (result.status === "rejected") {
279
- const err = result.reason instanceof Error ? result.reason : new Error(String(result.reason));
280
+ const err = toError(result.reason);
280
281
  logger.error("Cleanup callback failed", { err, stack: err.stack });
281
282
  }
282
283
  }
@@ -307,9 +308,7 @@ function runCleanup(reason: Reason, keepAlive = false): Promise<void> {
307
308
  return cleanupPromise;
308
309
  }
309
310
 
310
- // Register signal and error event handlers to trigger cleanup before exit.
311
- // Main thread: full signal handling (SIGINT, SIGTERM, SIGHUP) + exceptions + exit
312
- // Worker thread: exit only (workers use self.addEventListener for exceptions)
311
+ // CLI-owned main threads handle signals and fatal errors; workers handle exit only.
313
312
  let inspectorOpened = false;
314
313
 
315
314
  /** Origin of an EPIPE raised by a process communication channel. */
@@ -558,13 +557,47 @@ export function isExpectedCleanupError(reason: unknown): boolean {
558
557
  /** Interceptors consulted by the global `unhandledRejection` handler before the fatal path. */
559
558
  const rejectionInterceptors = new Set<(reason: unknown) => boolean>();
560
559
 
560
+ function interceptRejection(reason: unknown): boolean {
561
+ for (const interceptor of rejectionInterceptors) {
562
+ try {
563
+ if (interceptor(reason)) return true;
564
+ } catch (interceptorErr) {
565
+ logger.warn("Unhandled-rejection interceptor threw; continuing with fatal path", { err: interceptorErr });
566
+ }
567
+ }
568
+ return false;
569
+ }
570
+
571
+ /**
572
+ * Without the CLI's handlers (SDK embedding), interceptors still need a listener. It exists only while
573
+ * interceptors do, and rethrows what none consumes unless the host listens too, keeping Bun's default.
574
+ */
575
+ let embeddedRejectionListener: ((reason: unknown) => void) | undefined;
576
+
561
577
  /**
562
578
  * Register an interceptor consulted before an unhandled rejection tears the
563
579
  * process down. A consuming interceptor owns reporting and keeps the process alive.
564
580
  */
565
581
  export function interceptUnhandledRejections(interceptor: (reason: unknown) => boolean): () => void {
566
582
  rejectionInterceptors.add(interceptor);
567
- return () => rejectionInterceptors.delete(interceptor);
583
+ if (!processHandlersInstalled && Bun.isMainThread && !embeddedRejectionListener) {
584
+ const listener = (reason: unknown) => {
585
+ if (interceptRejection(reason) || process.listenerCount("unhandledRejection") > 1) return;
586
+ throw reason;
587
+ };
588
+ embeddedRejectionListener = listener;
589
+ process.on("unhandledRejection", listener);
590
+ }
591
+ return () => {
592
+ rejectionInterceptors.delete(interceptor);
593
+ if (rejectionInterceptors.size === 0) removeEmbeddedRejectionListener();
594
+ };
595
+ }
596
+
597
+ function removeEmbeddedRejectionListener(): void {
598
+ if (!embeddedRejectionListener) return;
599
+ process.off("unhandledRejection", embeddedRejectionListener);
600
+ embeddedRejectionListener = undefined;
568
601
  }
569
602
 
570
603
  /**
@@ -676,7 +709,7 @@ function handleWorkerSendEpipe(err: Error): boolean {
676
709
  * Reports a caught top-level failure after terminal owners restore their display, then exits.
677
710
  */
678
711
  export async function fatal(error: unknown): Promise<never> {
679
- const err = error instanceof Error ? error : new Error(String(error));
712
+ const err = toError(error);
680
713
  const report = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n`;
681
714
  if (!Bun.isMainThread) {
682
715
  process.stderr.write(`${report}${formatFatalRecoveryHints(fatalRecoveryHintProviders)}`);
@@ -685,7 +718,22 @@ export async function fatal(error: unknown): Promise<never> {
685
718
  return exitAfterFatal(report, "Fatal error", err, Reason.UNHANDLED_REJECTION);
686
719
  }
687
720
 
688
- if (Bun.isMainThread) {
721
+ // Exit-time cleanup is safe in any host: it neither delays nor triggers an exit. Signal and fatal
722
+ // handlers change the process lifecycle, so only {@link installProcessHandlers} adds them.
723
+ process.on("exit", () => {
724
+ void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
725
+ });
726
+
727
+ let processHandlersInstalled = false;
728
+
729
+ /** Installs OMP's process-wide signal and fatal handlers for a CLI-owned process. */
730
+ export function installProcessHandlers(): void {
731
+ if (processHandlersInstalled) return;
732
+ processHandlersInstalled = true;
733
+ removeEmbeddedRejectionListener();
734
+ // Workers report failures through their own error listeners to the parent.
735
+ if (!Bun.isMainThread) return;
736
+
689
737
  process
690
738
  .on("SIGINT", async () => {
691
739
  await runCleanup(Reason.SIGINT);
@@ -706,7 +754,7 @@ if (Bun.isMainThread) {
706
754
  logger.warn("Ignoring expected cleanup exception", { err: thrown });
707
755
  return;
708
756
  }
709
- const err = thrown instanceof Error ? thrown : new Error(String(thrown));
757
+ const err = toError(thrown);
710
758
  // A worker IPC `send()` race can surface through either global error event;
711
759
  // contain it in both. Stdout write disconnects are attributed to stdout by
712
760
  // registerStdioDisconnectHandling's `error` listener, not classified here.
@@ -739,21 +787,13 @@ if (Bun.isMainThread) {
739
787
  );
740
788
  })
741
789
  .on("unhandledRejection", async reason => {
742
- const err = reason instanceof Error ? reason : new Error(String(reason));
790
+ const err = toError(reason);
743
791
  if (handleWorkerSendEpipe(err)) return;
744
792
  if (isExpectedCleanupError(reason)) {
745
793
  logger.warn("Ignoring expected cleanup rejection", { err });
746
794
  return;
747
795
  }
748
- for (const interceptor of rejectionInterceptors) {
749
- try {
750
- if (interceptor(reason)) return;
751
- } catch (interceptorErr) {
752
- logger.warn("Unhandled-rejection interceptor threw; continuing with fatal path", {
753
- err: interceptorErr,
754
- });
755
- }
756
- }
796
+ if (interceptRejection(reason)) return;
757
797
  await exitAfterFatal(
758
798
  formatFatalError("Unhandled Rejection", err),
759
799
  "Unhandled rejection",
@@ -761,9 +801,6 @@ if (Bun.isMainThread) {
761
801
  Reason.UNHANDLED_REJECTION,
762
802
  );
763
803
  })
764
- .on("exit", async () => {
765
- void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
766
- })
767
804
  .on("SIGTERM", async () => {
768
805
  // A stopped session otherwise leaves its last frame above the shell prompt,
769
806
  // which reads as a crash. A repeated SIGTERM joins the same cleanup and the
@@ -777,14 +814,6 @@ if (Bun.isMainThread) {
777
814
  await runCleanup(Reason.SIGHUP);
778
815
  exitProcess(129); // 128 + SIGHUP (1)
779
816
  });
780
- } else {
781
- // Worker thread: only register exit handler for cleanup.
782
- // DO NOT register uncaughtException/unhandledRejection handlers here -
783
- // they would swallow errors before the worker's own handlers (self.addEventListener)
784
- // can report failures back to the parent thread.
785
- process.on("exit", () => {
786
- void runCleanup(Reason.EXIT);
787
- });
788
817
  }
789
818
 
790
819
  /** Controls when a registered cleanup callback participates in cleanup passes. */
@@ -811,8 +840,7 @@ export interface CleanupRegistrationOptions {
811
840
  }
812
841
 
813
842
  /**
814
- * Registers a cleanup callback for shutdown, signals, fatal errors, and
815
- * repeatable manual cleanup passes.
843
+ * Registers a cleanup callback for manual passes and, when installed, process shutdown.
816
844
  *
817
845
  * Registrations persist across keep-alive {@link cleanup} passes and run at
818
846
  * most once per pass. Set `exitOnly` for resources the continuing process still
@@ -849,7 +877,7 @@ export function register(
849
877
  const pending = invokeCleanup(registration, reason, keepAlive, cleanupPass);
850
878
  if (!pending) return;
851
879
  const tracked = pending.catch(error => {
852
- const err = error instanceof Error ? error : new Error(String(error));
880
+ const err = toError(error);
853
881
  logger.error("Cleanup callback failed", { err, id, stack: err.stack });
854
882
  });
855
883
  // Join the active pass so cleanup()/signal exits await it; after a
@@ -859,7 +887,7 @@ export function register(
859
887
  activePassDeadline?.extend(tracked, registration.deadlineMs, registration.waitMessage);
860
888
  }
861
889
  } catch (error) {
862
- const err = error instanceof Error ? error : new Error(String(error));
890
+ const err = toError(error);
863
891
  logger.error("Cleanup callback failed", { err, id, stack: err.stack });
864
892
  }
865
893
  };