@linxiraos/pi-utils 1.4.6 → 1.4.9

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.
@@ -1,3 +1,4 @@
1
+ import { readUInt32BE } from "../bytes";
1
2
  import { crc32, crc64 } from "../checksums";
2
3
  import { ArchiveError } from "../error";
3
4
  import { lzma2Decompress } from "./lzma";
@@ -16,10 +17,6 @@ function write32LE(bytes: Uint8Array, offset: number, value: number): void {
16
17
  bytes[offset + 3] = value >>> 24;
17
18
  }
18
19
 
19
- function read32BE(bytes: Uint8Array, offset: number): number {
20
- return ((bytes[offset]! << 24) | (bytes[offset + 1]! << 16) | (bytes[offset + 2]! << 8) | bytes[offset + 3]!) >>> 0;
21
- }
22
-
23
20
  function write32BE(bytes: Uint8Array, offset: number, value: number): void {
24
21
  bytes[offset] = value >>> 24;
25
22
  bytes[offset + 1] = (value >>> 16) & 0xff;
@@ -155,7 +152,8 @@ interface XzFilter {
155
152
  properties: Uint8Array;
156
153
  }
157
154
 
158
- function deltaDecode(bytes: Uint8Array, distance: number): void {
155
+ /** Undo the delta filter (shared by XZ and 7z) in place. */
156
+ export function deltaDecode(bytes: Uint8Array, distance: number): void {
159
157
  const history = new Uint8Array(256);
160
158
  let position = 0;
161
159
  for (let index = 0; index < bytes.byteLength; index++) {
@@ -166,7 +164,8 @@ function deltaDecode(bytes: Uint8Array, distance: number): void {
166
164
  }
167
165
  }
168
166
 
169
- function x86Decode(bytes: Uint8Array, startOffset: number): void {
167
+ /** Undo the x86 BCJ filter (shared by XZ and 7z) in place; `startOffset` is the stream position of `bytes[0]`. */
168
+ export function x86Decode(bytes: Uint8Array, startOffset: number): void {
170
169
  const maskToBitNumber = [0, 1, 2, 2, 3] as const;
171
170
  let previousMask = 0;
172
171
  let previousPosition = 0xfffffffb;
@@ -267,7 +266,7 @@ function sparcDecode(bytes: Uint8Array, startOffset: number): void {
267
266
  )
268
267
  )
269
268
  continue;
270
- const source = (read32BE(bytes, index) << 2) >>> 0;
269
+ const source = (readUInt32BE(bytes, index) << 2) >>> 0;
271
270
  let destination = (source - startOffset - index) >>> 2;
272
271
  destination = (((0 - ((destination >>> 22) & 1)) << 22) & 0x3fffffff) | (destination & 0x3fffff) | 0x40000000;
273
272
  write32BE(bytes, index, destination >>> 0);
@@ -356,7 +355,7 @@ function riscvDecode(bytes: Uint8Array, startOffset: number): void {
356
355
  index += 2;
357
356
  continue;
358
357
  }
359
- const address = (read32BE(bytes, index + 4) - startOffset - index) >>> 0;
358
+ const address = (readUInt32BE(bytes, index + 4) - startOffset - index) >>> 0;
360
359
  instruction2 = ((instruction >>> 12) | (address << 20)) >>> 0;
361
360
  instruction = (0x17 | (register << 7) | ((address + 0x800) & 0xfffff000)) >>> 0;
362
361
  }
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/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";
@@ -10,7 +11,6 @@ export * from "./file-lock";
10
11
  export * from "./format";
11
12
  export * from "./frontmatter";
12
13
  export * from "./fs-error";
13
- export * from "./fs-open";
14
14
  export * from "./incoming-json";
15
15
  export * from "./json";
16
16
  export * from "./json-parse";
@@ -24,6 +24,7 @@ export * from "./path";
24
24
  export * from "./path-tree";
25
25
  export * from "./ports";
26
26
  export * from "./peek-file";
27
+ export * from "./pid";
27
28
  export * as postmortem from "./postmortem";
28
29
  export * from "./process-name";
29
30
  export * as procmgr from "./procmgr";
@@ -3,7 +3,6 @@ import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
5
  import { localDay } from "../dirs";
6
- import { openCloexecSync } from "../fs-open";
7
6
 
8
7
  /** Write batching for {@link RotatingFileSink}. */
9
8
  export interface RotatingFileBatchOptions {
@@ -87,7 +86,7 @@ export class RotatingFileSink {
87
86
 
88
87
  #openFd(filePath: string): void {
89
88
  this.#closeFd();
90
- this.#fd = openCloexecSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND);
89
+ this.#fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND);
91
90
  try {
92
91
  this.#onRotate?.(filePath);
93
92
  } catch {
package/src/logger.ts CHANGED
@@ -7,11 +7,13 @@
7
7
  * transport so a process supervisor (pm2, journald, k8s) captures the logs.
8
8
  *
9
9
  * Each entry includes `process.pid` so concurrent zeta instances stay
10
- * traceable. The file is created on the first record written. Records are
11
- * batched — one write per second or per 64 KiB — while `warn`/`error` records
12
- * are written at once together with everything buffered before them; exit,
13
- * fatal, and signal paths flush the rest. `ZETA_LOG_LEVEL` optionally limits
14
- * the levels written to the file (default: all).
10
+ * traceable, and the fields of the enclosing {@link withContext} (a session's
11
+ * `sessionId`) so sessions sharing one process stay apart. The file is created
12
+ * on the first record written. Records are batched — one write per second or
13
+ * per 64 KiB — while `warn`/`error` records are written at once together with
14
+ * everything buffered before them; exit, fatal, and signal paths flush the
15
+ * rest. `ZETA_LOG_LEVEL` optionally limits the levels written to the file
16
+ * (default: all).
15
17
  */
16
18
  import { AsyncLocalStorage } from "node:async_hooks";
17
19
  import * as fs from "node:fs";
@@ -20,6 +22,7 @@ import * as path from "node:path";
20
22
  import { isPromise } from "node:util/types";
21
23
  import { getLogsDir, localDay } from "./dirs";
22
24
  import { RotatingFileSink } from "./logger/rotating-file";
25
+ import { isPidAlive } from "./pid";
23
26
  import { setStderrRedirectTarget } from "./stderr-guard";
24
27
  import { drainModuleLoadEvents } from "./timing-buffer";
25
28
  /** Severity names accepted by the centralized logger. */
@@ -48,7 +51,18 @@ export function registerLogSink(sink: LogSink): () => void {
48
51
 
49
52
  function emitToSinks(level: LogLevel, message: string, context: Record<string, unknown> | undefined): void {
50
53
  if (logSinks.size === 0) return;
51
- const event: LogEvent = { level, message, context, timestamp: new Date() };
54
+ // The enclosing {@link withContext} fields reach sinks as they reach the file;
55
+ // call-site fields win, and a record outside any context keeps its own object.
56
+ const scoped = logContextStorage.getStore();
57
+ let merged = context;
58
+ if (scoped) {
59
+ try {
60
+ merged = { ...scoped, ...context };
61
+ } catch {
62
+ // A throwing getter must not turn a log call into a failure; sinks get the call's own fields.
63
+ }
64
+ }
65
+ const event: LogEvent = { level, message, context: merged, timestamp: new Date() };
52
66
  for (const sink of logSinks) {
53
67
  try {
54
68
  sink(event);
@@ -68,15 +82,6 @@ const LEGACY_AUDIT_PATTERN = /^\.[0-9a-f]{8,}-audit\.json$/;
68
82
  const RETAINED_STALE_LOGS_PER_PROCESS_DAY = 1;
69
83
  const RETAINED_STALE_LOG_DAYS = 5;
70
84
 
71
- function processIsRunning(pid: number): boolean {
72
- try {
73
- process.kill(pid, 0);
74
- return true;
75
- } catch (error) {
76
- return !(error instanceof Error && "code" in error && (error.code === "ESRCH" || error.code === "EINVAL"));
77
- }
78
- }
79
-
80
85
  /**
81
86
  * Retain one newest completed-process log per process/day within the current
82
87
  * and previous four local calendar days, remove completed-process audit files
@@ -120,7 +125,7 @@ function pruneStaleProcessLogs(dir: string): void {
120
125
  const logMatch = PROCESS_LOG_PATTERN.exec(entry.name);
121
126
  const auditMatch = PROCESS_AUDIT_PATTERN.exec(entry.name);
122
127
  const pidText = logMatch?.[2] ?? auditMatch?.[1];
123
- if (!pidText || processIsRunning(Number(pidText))) continue;
128
+ if (!pidText || isPidAlive(Number(pidText))) continue;
124
129
 
125
130
  if (auditMatch) {
126
131
  removeBestEffort(entryPath);
@@ -251,6 +256,28 @@ function formatLocalTimestamp(date: Date): string {
251
256
 
252
257
  const FORMAT_TOKEN_PATTERN = /%[scdjifoO%]/;
253
258
 
259
+ const logContextStorage = new AsyncLocalStorage<Readonly<Record<string, unknown>>>();
260
+
261
+ /**
262
+ * Run `fn` with `context`'s fields on every record logged while it runs,
263
+ * including from the promises, timers and callbacks it starts. A nested call
264
+ * replaces the enclosing context, as a subagent's session replaces its
265
+ * parent's; a field passed to the log call itself wins. Fields are read as each
266
+ * record is written, so a getter stays current.
267
+ */
268
+ export function withContext<T>(context: Readonly<Record<string, unknown>>, fn: () => T): T {
269
+ return logContextStorage.run(context, fn);
270
+ }
271
+
272
+ /**
273
+ * Run `fn` outside any {@link withContext}: for process-wide work started on
274
+ * behalf of one caller, such as a shared server's read loop, whose later
275
+ * records belong to no single caller.
276
+ */
277
+ export function withoutContext<T>(fn: () => T): T {
278
+ return logContextStorage.exit(fn);
279
+ }
280
+
254
281
  /** Serialize one log record: timestamp, level, pid, message, then context fields. */
255
282
  function formatLogRecord(
256
283
  level: LogLevel,
@@ -266,6 +293,12 @@ function formatLogRecord(
266
293
  pid: process.pid,
267
294
  message: metadata?.message ? `${message} ${metadata.message}` : message,
268
295
  };
296
+ const scoped = logContextStorage.getStore();
297
+ if (scoped) {
298
+ for (const key of Object.keys(scoped)) {
299
+ if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = scoped[key];
300
+ }
301
+ }
269
302
  if (metadata) {
270
303
  for (const key of Object.keys(metadata)) {
271
304
  if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = metadata[key];
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/ports.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * OMP installs on the same machine use 30141/30142/3847, and common developer
5
5
  * tools squat on 3000/8080/8787/3847. Zeta therefore owns a dedicated block:
6
- * three uncommon ports, at least two apart, so a side-by-side OMP install and
7
- * unrelated services cannot take the gateway or the stats dashboard away.
6
+ * uncommon ports, at least two apart, so a side-by-side OMP install and
7
+ * unrelated services cannot take the gateway, stats dashboard, or browser
8
+ * relay away.
8
9
  *
9
10
  * Every default in the repository derives from these constants. The standalone
10
11
  * Electron shell (`desktop/src/main.ts`, not a Bun workspace package) carries
@@ -13,3 +14,4 @@
13
14
  export const ZETA_WEB_PORT = 43141;
14
15
  export const ZETA_GATEWAY_PORT = 43143;
15
16
  export const ZETA_STATS_PORT = 43147;
17
+ export const ZETA_BROWSER_RELAY_PORT = 43153;