@oh-my-pi/pi-utils 18.15.0 → 18.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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/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/dirs.ts CHANGED
@@ -656,6 +656,15 @@ export function getReportsDir(): string {
656
656
  return dirs.rootSubdir("reports", "state");
657
657
  }
658
658
 
659
+ /**
660
+ * Get the self-update state directory (~/.omp/update): the background
661
+ * auto-update's status file and worker log, and the lock that keeps two
662
+ * installs (`omp update`, auto-update) from running at once.
663
+ */
664
+ export function getUpdateStateDir(): string {
665
+ return dirs.rootSubdir("update", "state");
666
+ }
667
+
659
668
  /**
660
669
  * Get the logs directory (~/.omp/logs). `bun test` processes get
661
670
  * `<tmpdir>/omp-test-logs-<uid>` instead, so suites never write into the
@@ -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 "@oh-my-pi/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";
@@ -22,6 +23,7 @@ export * from "./mime";
22
23
  export * from "./path";
23
24
  export * from "./path-tree";
24
25
  export * from "./peek-file";
26
+ export * from "./pid";
25
27
  export * as postmortem from "./postmortem";
26
28
  export * from "./process-name";
27
29
  export * as procmgr from "./procmgr";
package/src/logger.ts CHANGED
@@ -22,8 +22,10 @@ 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";
28
+ import { isRecord } from "./type-guards";
27
29
  /** Severity names accepted by the centralized logger. */
28
30
  export type LogLevel = "error" | "warn" | "info" | "debug";
29
31
 
@@ -38,8 +40,103 @@ export interface LogEvent {
38
40
  /** Receives each structured log event after the local transport path runs. */
39
41
  export type LogSink = (event: LogEvent) => void;
40
42
 
43
+ /**
44
+ * Destination that takes this thread's log records in place of its own
45
+ * transports and sinks (a worker thread routing records to the main thread's
46
+ * logger); see {@link setLogForwarder}.
47
+ */
48
+ export interface LogForwarder {
49
+ /**
50
+ * Take one record. Its context is already merged over the enclosing
51
+ * {@link withContext} fields and reduced to plain JSON data, so it survives
52
+ * a structured clone; `timestamp` is when the record was logged.
53
+ */
54
+ emit(event: LogEvent): void;
55
+ /** Write every record emitted so far on the receiving side; backs {@link flush}. */
56
+ flush(): void;
57
+ }
58
+
41
59
  const logSinks = new Set<LogSink>();
42
60
 
61
+ /** Set by {@link setLogForwarder}: records go there instead of to local transports and sinks. */
62
+ let logForwarder: LogForwarder | undefined;
63
+
64
+ /**
65
+ * Route every later record logged on this thread to `forwarder` instead of the
66
+ * local transports and sinks, which then stay unused (no log file is opened);
67
+ * {@link flush} asks the forwarder to flush. `undefined` restores local logging.
68
+ * For worker threads, whose main thread owns the process log file.
69
+ */
70
+ export function setLogForwarder(forwarder: LogForwarder | undefined): void {
71
+ logForwarder = forwarder;
72
+ }
73
+
74
+ /**
75
+ * Log a record another thread forwarded (see {@link setLogForwarder}): written
76
+ * to the local transports with its own timestamp, then handed to the sinks. Its
77
+ * context is taken as is; the caller's enclosing {@link withContext} is not
78
+ * merged in. On a thread that forwards itself, the record is forwarded on.
79
+ */
80
+ export function emitForeign(event: LogEvent): void {
81
+ const forwarder = logForwarder;
82
+ if (forwarder) {
83
+ try {
84
+ forwarder.emit(event);
85
+ } catch {
86
+ // Forwarding is as best-effort as local writes.
87
+ }
88
+ return;
89
+ }
90
+ try {
91
+ emitLocally(event.level, event.message, event.context, event.timestamp);
92
+ } catch {
93
+ // Silently ignore logging failures
94
+ }
95
+ dispatchToSinks(event);
96
+ }
97
+
98
+ /** Forward one record logged on this thread; never throws. */
99
+ function forwardRecord(
100
+ forwarder: LogForwarder,
101
+ level: LogLevel,
102
+ message: string,
103
+ context: Record<string, unknown> | undefined,
104
+ ): void {
105
+ try {
106
+ forwarder.emit({ level, message, context: forwardableContext(context), timestamp: new Date() });
107
+ } catch {
108
+ // Forwarding is as best-effort as local writes.
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The call's context merged over the enclosing {@link withContext} fields (call
114
+ * fields win, as for sinks), as plain JSON data: errors become
115
+ * `{ name, message, stack, … }` exactly as in the log file. A context that
116
+ * cannot be serialized (a cycle, a bigint) is dropped from the record.
117
+ */
118
+ function forwardableContext(context: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
119
+ // An Error passed as the context carries its fields as non-enumerable properties.
120
+ let merged = context instanceof Error ? errorToRecord(context) : context;
121
+ const scoped = logContextStorage.getStore();
122
+ if (scoped) {
123
+ try {
124
+ merged = { ...scoped, ...merged };
125
+ } catch {
126
+ // A throwing getter must not turn a log call into a failure; keep the call's own fields.
127
+ }
128
+ }
129
+ if (merged === undefined) return undefined;
130
+ try {
131
+ const parsed: unknown = JSON.parse(
132
+ mayContainError(merged) ? JSON.stringify(merged, jsonReplacer) : JSON.stringify(merged),
133
+ );
134
+ return isRecord(parsed) ? parsed : undefined;
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
43
140
  /** Register an out-of-band log sink and return a disposer. */
44
141
  export function registerLogSink(sink: LogSink): () => void {
45
142
  logSinks.add(sink);
@@ -61,7 +158,11 @@ function emitToSinks(level: LogLevel, message: string, context: Record<string, u
61
158
  // A throwing getter must not turn a log call into a failure; sinks get the call's own fields.
62
159
  }
63
160
  }
64
- const event: LogEvent = { level, message, context: merged, timestamp: new Date() };
161
+ dispatchToSinks({ level, message, context: merged, timestamp: new Date() });
162
+ }
163
+
164
+ function dispatchToSinks(event: LogEvent): void {
165
+ if (logSinks.size === 0) return;
65
166
  for (const sink of logSinks) {
66
167
  try {
67
168
  sink(event);
@@ -81,15 +182,6 @@ const LEGACY_AUDIT_PATTERN = /^\.[0-9a-f]{8,}-audit\.json$/;
81
182
  const RETAINED_STALE_LOGS_PER_PROCESS_DAY = 1;
82
183
  const RETAINED_STALE_LOG_DAYS = 5;
83
184
 
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
185
  /**
94
186
  * Retain one newest completed-process log per process/day within the current
95
187
  * and previous four local calendar days, remove completed-process audit files
@@ -133,7 +225,7 @@ function pruneStaleProcessLogs(dir: string): void {
133
225
  const logMatch = PROCESS_LOG_PATTERN.exec(entry.name);
134
226
  const auditMatch = PROCESS_AUDIT_PATTERN.exec(entry.name);
135
227
  const pidText = logMatch?.[2] ?? auditMatch?.[1];
136
- if (!pidText || processIsRunning(Number(pidText))) continue;
228
+ if (!pidText || isPidAlive(Number(pidText))) continue;
137
229
 
138
230
  if (auditMatch) {
139
231
  removeBestEffort(entryPath);
@@ -207,19 +299,21 @@ function ensureDir(dir: string): string {
207
299
  * forensic logs showed only an opaque empty object.
208
300
  */
209
301
  function jsonReplacer(_key: string, value: unknown): unknown {
210
- if (value instanceof Error) {
211
- const out: Record<string, unknown> = {
212
- name: value.name,
213
- message: value.message,
214
- stack: value.stack,
215
- };
216
- // Preserve `.cause` and any custom enumerable fields the caller attached.
217
- const errAsRecord = value as unknown as Record<string, unknown>;
218
- for (const k in errAsRecord) out[k] = errAsRecord[k];
219
- if (value.cause !== undefined) out.cause = value.cause;
220
- return out;
221
- }
222
- return value;
302
+ return value instanceof Error ? errorToRecord(value) : value;
303
+ }
304
+
305
+ /** An Error's fields as a plain object: name, message, stack, enumerable extras, and `cause`. */
306
+ function errorToRecord(value: Error): Record<string, unknown> {
307
+ const out: Record<string, unknown> = {
308
+ name: value.name,
309
+ message: value.message,
310
+ stack: value.stack,
311
+ };
312
+ // Preserve `.cause` and any custom enumerable fields the caller attached.
313
+ const errAsRecord = value as unknown as Record<string, unknown>;
314
+ for (const k in errAsRecord) out[k] = errAsRecord[k];
315
+ if (value.cause !== undefined) out.cause = value.cause;
316
+ return out;
223
317
  }
224
318
 
225
319
  // Values visited by the Error pre-scan before falling back to the replacer.
@@ -286,12 +380,16 @@ export function withoutContext<T>(fn: () => T): T {
286
380
  return logContextStorage.exit(fn);
287
381
  }
288
382
 
289
- /** Serialize one log record: timestamp, level, pid, message, then context fields. */
383
+ /**
384
+ * Serialize one log record: timestamp, level, pid, message, then the `scoped`
385
+ * ({@link withContext}) fields and the context fields.
386
+ */
290
387
  function formatLogRecord(
291
388
  level: LogLevel,
292
389
  message: string,
293
390
  context: Record<string, unknown> | undefined,
294
391
  timestamp: string,
392
+ scoped: Readonly<Record<string, unknown>> | undefined,
295
393
  ): string {
296
394
  const metadata =
297
395
  !FORMAT_TOKEN_PATTERN.test(message) && context !== null && typeof context === "object" ? context : undefined;
@@ -301,7 +399,6 @@ function formatLogRecord(
301
399
  pid: process.pid,
302
400
  message: metadata?.message ? `${message} ${metadata.message}` : message,
303
401
  };
304
- const scoped = logContextStorage.getStore();
305
402
  if (scoped) {
306
403
  for (const key of Object.keys(scoped)) {
307
404
  if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = scoped[key];
@@ -378,7 +475,8 @@ function resolveFileLevelRank(): number {
378
475
  */
379
476
  export function flush(): void {
380
477
  try {
381
- activeTransports?.file?.flush();
478
+ if (logForwarder) logForwarder.flush();
479
+ else activeTransports?.file?.flush();
382
480
  } catch {
383
481
  // Flushing diagnostics must never mask the caller's own work or failure.
384
482
  }
@@ -413,13 +511,25 @@ function getLocalTransports(): LocalTransports {
413
511
  return activeTransports;
414
512
  }
415
513
 
416
- function emitLocally(level: LogLevel, message: string, context: Record<string, unknown> | undefined): void {
514
+ /**
515
+ * Write one record to the local transports. A `foreignTimestamp` marks a record
516
+ * another thread logged ({@link emitForeign}): it keeps that time and gets no
517
+ * {@link withContext} fields of this call stack, while the file still rotates by
518
+ * the current day so a late record cannot reopen an earlier day's file.
519
+ */
520
+ function emitLocally(
521
+ level: LogLevel,
522
+ message: string,
523
+ context: Record<string, unknown> | undefined,
524
+ foreignTimestamp?: Date,
525
+ ): void {
417
526
  const transports = getLocalTransports();
418
527
  const rank = LOG_LEVEL_RANK[level];
419
528
  const file = rank <= transports.fileLevelRank ? transports.file : undefined;
420
529
  if (!file && !transports.console) return;
421
530
  const now = new Date();
422
- const line = formatLogRecord(level, message, context, formatLocalTimestamp(now));
531
+ const scoped = foreignTimestamp ? undefined : logContextStorage.getStore();
532
+ const line = formatLogRecord(level, message, context, formatLocalTimestamp(foreignTimestamp ?? now), scoped);
423
533
  file?.write(line, rank <= IMMEDIATE_FLUSH_RANK || exiting, localDay(now));
424
534
  if (transports.console) fs.writeSync(1, `${line}${os.EOL}`);
425
535
  }
@@ -446,6 +556,7 @@ export function setTransports(opts: { console?: boolean; file?: boolean | string
446
556
  * @param context - The context to log.
447
557
  */
448
558
  export function error(message: string, context?: Record<string, unknown>): void {
559
+ if (logForwarder) return forwardRecord(logForwarder, "error", message, context);
449
560
  try {
450
561
  emitLocally("error", message, context);
451
562
  } catch {
@@ -460,6 +571,7 @@ export function error(message: string, context?: Record<string, unknown>): void
460
571
  * @param context - The context to log.
461
572
  */
462
573
  export function warn(message: string, context?: Record<string, unknown>): void {
574
+ if (logForwarder) return forwardRecord(logForwarder, "warn", message, context);
463
575
  try {
464
576
  emitLocally("warn", message, context);
465
577
  } catch {
@@ -474,6 +586,7 @@ export function warn(message: string, context?: Record<string, unknown>): void {
474
586
  * @param context - The context to log.
475
587
  */
476
588
  export function info(message: string, context?: Record<string, unknown>): void {
589
+ if (logForwarder) return forwardRecord(logForwarder, "info", message, context);
477
590
  try {
478
591
  emitLocally("info", message, context);
479
592
  } catch {
@@ -488,6 +601,7 @@ export function info(message: string, context?: Record<string, unknown>): void {
488
601
  * @param context - The context to log.
489
602
  */
490
603
  export function debug(message: string, context?: Record<string, unknown>): void {
604
+ if (logForwarder) return forwardRecord(logForwarder, "debug", message, context);
491
605
  try {
492
606
  emitLocally("debug", message, context);
493
607
  } catch {
package/src/path.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as os from "node:os";
1
2
  import * as path from "node:path";
2
3
 
3
4
  const WINDOWS_DRIVE_PATH = /^([A-Za-z]):[\\/](.*)$/;
@@ -54,3 +55,16 @@ export function isFullyQualifiedPath(filePath: string, platform: NodeJS.Platform
54
55
  }
55
56
  return true;
56
57
  }
58
+
59
+ /** Expands a leading `~` (`~`, `~/…`, `~\…`, `~name`) to `home`, the user's home directory by default. */
60
+ export function expandTilde(filePath: string, home?: string): string {
61
+ const h = home ?? os.homedir();
62
+ if (filePath === "~") return h;
63
+ if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
64
+ return h + filePath.slice(1);
65
+ }
66
+ if (filePath.startsWith("~")) {
67
+ return path.join(h, filePath.slice(1));
68
+ }
69
+ return filePath;
70
+ }
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
+ }