@linxiraos/pi-utils 1.4.4 → 1.4.7

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
@@ -1,5 +1,23 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.4.7] - 2026-10-30
4
+
5
+ - Synced with upstream OMP v18.14.0; no package-specific user-visible changes.
6
+
7
+ ## [1.4.6] - 2026-10-28
8
+
9
+ ### Added
10
+
11
+ - Added `logger.withContext(fields, fn)` to attach contextual fields to all records produced during a function and its asynchronous work, plus `logger.withoutContext(fn)` for work that should not inherit caller context.
12
+
13
+ ### Fixed
14
+
15
+ - Fixed `untilAborted` so promises passed to an already-aborted signal are still observed, preventing later rejections from being reported as unhandled.
16
+
17
+ ## [1.4.5] - 2026-10-28
18
+
19
+ - Synced with upstream OMP v18.13.2 (shared utilities and runtime fixes); user-visible changes are in UPDATE-LOG.md under 1.4.5.
20
+
3
21
  ## [1.4.4] - 2026-10-26
4
22
 
5
23
  - Released as part of Zeta 1.4.4; no package-specific user-visible changes.
package/README.md CHANGED
@@ -29,7 +29,7 @@ Import from the root barrel or per-module subpaths (`@linxiraos/pi-utils/<module
29
29
  bun add @linxiraos/pi-utils
30
30
  ```
31
31
 
32
- Ships TypeScript source directly (no build step); requires Bun ≥ 1.3.14.
32
+ Ships TypeScript source directly (no build step); requires Bun ≥ 1.4.4.
33
33
 
34
34
  ## References
35
35
 
@@ -16,7 +16,8 @@ export declare class AbortError extends Error {
16
16
  export declare function abortableSource<T>(stream: ReadableStream<T>, signal?: AbortSignal): AsyncGenerator<T>;
17
17
  /**
18
18
  * Runs a promise-returning function (`pr`). If the given AbortSignal is aborted before or during
19
- * execution, the promise is rejected with a standard error.
19
+ * execution, the promise is rejected with a standard error. A promise passed directly stays
20
+ * observed, so its later rejection is never unhandled.
20
21
  *
21
22
  * @param signal - Optional AbortSignal to cancel the operation
22
23
  * @param pr - Function returning a promise to run
@@ -93,6 +93,7 @@ export interface ToolCallLocation extends Meta {
93
93
  export interface ToolCall extends Meta {
94
94
  toolCallId: ToolCallId;
95
95
  title: string;
96
+ name?: string | null;
96
97
  kind?: ToolKind | null;
97
98
  status?: ToolCallStatus | null;
98
99
  content?: ToolCallContent[] | null;
@@ -6,7 +6,7 @@ export declare function readTarEntriesFromBuffer(buffer: Uint8Array, options: Fo
6
6
  * pass over `open()` walks the headers, and member reads open it again.
7
7
  */
8
8
  export declare function readTarEntriesFromStream(open: () => AsyncIterable<Uint8Array>, options: FormatReadOptions): Promise<ArchiveIndexEntry[]>;
9
- /** Read and index a tar source after one bounded whole-stream read. */
9
+ /** Index a plain tar by ranging over headers and retaining lazy member offsets. */
10
10
  export declare const readTar: FormatReader;
11
11
  /** Detect a tar header, including legacy pre-ustar archives, by its checksum. */
12
12
  export declare function sniffTar(bytes: Uint8Array): boolean;
@@ -8,7 +8,7 @@ export type WritableArchiveFormat = "zip" | "tar" | "tar.gz" | "tar.zst" | "asar
8
8
  * Where to read an archive from: an extension-inferred filesystem path, a
9
9
  * format-tagged filesystem path, in-memory bytes, or any caller-provided
10
10
  * {@link ByteSource} (e.g. `httpByteSource` for ranged remote reads).
11
- * File- and source-backed ZIP/ASAR/RAR/7z/ISO are read lazily.
11
+ * File- and source-backed TAR/ZIP/ASAR/RAR/7z/ISO are read lazily.
12
12
  */
13
13
  export type ArchiveSource = string | {
14
14
  bytes: Uint8Array;
@@ -6,9 +6,10 @@ export declare function formatDuration(ms: number): string;
6
6
  /**
7
7
  * Format a number with K/M/B suffix for compact display.
8
8
  * Uses 1 decimal for small leading digits when non-zero, rounded otherwise.
9
+ * Values below 1,000 print as-is unless `maxFractionDigits` caps their decimals.
9
10
  * Examples: "999", "1K", "1.5K", "25K", "1M", "1.5M", "25M", "1.5B"
10
11
  */
11
- export declare function formatNumber(n: number): string;
12
+ export declare function formatNumber(n: number, maxFractionDigits?: number): string;
12
13
  /**
13
14
  * Format a byte count to a human-readable string.
14
15
  * Examples: "512B", "1.5KB", "2.3MB", "1.2GB"
@@ -10,7 +10,6 @@ export * from "./file-lock.js";
10
10
  export * from "./format.js";
11
11
  export * from "./frontmatter.js";
12
12
  export * from "./fs-error.js";
13
- export * from "./fs-open.js";
14
13
  export * from "./incoming-json.js";
15
14
  export * from "./json.js";
16
15
  export * from "./json-parse.js";
@@ -2,6 +2,13 @@
2
2
  * Try to parse JSON, returning null on failure.
3
3
  */
4
4
  export declare function tryParseJson<T = unknown>(content: string): T | null;
5
+ /**
6
+ * Index just past the JSON object or array that opens at `text[start]`, or -1
7
+ * when it does not close within `text`. String literals are skipped, honoring
8
+ * escapes, so brackets inside values do not count. Malformed input yields some
9
+ * end that a parser then rejects.
10
+ */
11
+ export declare function jsonContainerEnd(text: string, start: number): number;
5
12
  /**
6
13
  * Serialize JSON while preserving bigint precision as decimal strings.
7
14
  *
@@ -11,6 +11,20 @@ export interface LogEvent {
11
11
  export type LogSink = (event: LogEvent) => void;
12
12
  /** Register an out-of-band log sink and return a disposer. */
13
13
  export declare function registerLogSink(sink: LogSink): () => void;
14
+ /**
15
+ * Run `fn` with `context`'s fields on every record logged while it runs,
16
+ * including from the promises, timers and callbacks it starts. A nested call
17
+ * replaces the enclosing context, as a subagent's session replaces its
18
+ * parent's; a field passed to the log call itself wins. Fields are read as each
19
+ * record is written, so a getter stays current.
20
+ */
21
+ export declare function withContext<T>(context: Readonly<Record<string, unknown>>, fn: () => T): T;
22
+ /**
23
+ * Run `fn` outside any {@link withContext}: for process-wide work started on
24
+ * behalf of one caller, such as a shared server's read loop, whose later
25
+ * records belong to no single caller.
26
+ */
27
+ export declare function withoutContext<T>(fn: () => T): T;
14
28
  /**
15
29
  * Write buffered file records to disk now. The file transport batches records
16
30
  * (one write per second or per 64 KiB) and writes `warn`/`error` records
@@ -45,15 +45,23 @@ declare class EmphasisDelimiters {
45
45
  }
46
46
  /**
47
47
  * The runs of backticks in the root source, for the code span rule. A span opened by `width` backticks closes at the
48
- * first `width` backticks after its opening run that no backslash escapes: the start of a later run of at least
49
- * `width`, or, in a run whose first backtick a backslash escapes, the backticks from its `width`th on, since the
50
- * search for a closer goes on `width` past an escaped one.
48
+ * next run of exactly `width` backticks: a run of any other length is no closer, and the one that closes is the first
49
+ * run of that width at or after the opener. Backslash escapes do not apply inside a code span, so a backslash just
50
+ * before that closing backtick is content and still closes the span.
51
51
  */
52
52
  declare class CodeRuns {
53
53
  #private;
54
54
  constructor(src: string);
55
55
  /** Where the run of backticks holding the backtick at `at` ends. */
56
56
  endOf(at: number): number;
57
+ /**
58
+ * Whether a backtick string starts at `at` for a scan that walked to `at`: a string is a whole run, so it starts at
59
+ * the run's first backtick and nowhere else. A retry deeper inside a run is not a string, which keeps a run whose
60
+ * opener found no closer from opening a narrower span one backtick later. A position a tokenizer's raw resumed at,
61
+ * or the suffix an escape token left behind, is not a walked position — `codeOpenerWidth` admits those on its own,
62
+ * because it is told the scan resumed there rather than inferring it from the source text.
63
+ */
64
+ startsStringAt(at: number): boolean;
57
65
  /** Where the closer of a code span opened by `width` backticks that end at `from` starts, or -1. */
58
66
  closerAfter(from: number, width: number): number;
59
67
  }
@@ -102,8 +110,13 @@ export declare class InlineClosers {
102
110
  escapeWithin(rest: string, from: number, to: number): boolean;
103
111
  /** Where the emphasis opened by the `width` markers at the start of `rest` closes, or -1. */
104
112
  closeEmphasis(rest: string, marker: string, width: number): number;
105
- /** How many backticks start `rest`, which starts with one. */
106
- codeOpenerWidth(rest: string): number;
113
+ /**
114
+ * How many backticks start `rest`, which starts with one, or 0 where no code span opens because no backtick string
115
+ * starts there: the opener is a whole string, so the rest of a run whose opener found no closer is text, while the
116
+ * backticks left after a backslash escaped the run's first one do open a span. `resumed` says the scan resumed at
117
+ * `rest` after a tokenizer consumed a raw that ended here, where the backticks are a string wherever they sit.
118
+ */
119
+ codeOpenerWidth(rest: string, resumed?: boolean): number;
107
120
  /** Where the code span opened by the `width` backticks at the start of `rest` closes, or -1. */
108
121
  closeCode(rest: string, width: number): number;
109
122
  /**
@@ -26,6 +26,13 @@ export type ImageMetadata = {
26
26
  hasAlpha?: boolean;
27
27
  };
28
28
  export declare function parseImageMetadata(header: Uint8Array): ImageMetadata | null;
29
+ /** Still images in a HEIF (ISO BMFF) container, which the image pipeline cannot decode itself. */
30
+ export type HeifImageMimeType = "image/heic" | "image/heif" | "image/avif";
31
+ /**
32
+ * MIME type of a HEIC, AVIF or other HEIF image from the brands of its leading `ftyp` box:
33
+ * the major brand, then the compatible brands, with the generic `mif1`/`msf1` brands last.
34
+ */
35
+ export declare function parseHeifImageMimeType(header: Uint8Array): HeifImageMimeType | null;
29
36
  /** Why base64 image data is unusable; `undefined` when it is canonical and non-empty. */
30
37
  export type Base64ImageDataFailure = "invalid base64 image data" | "empty image data";
31
38
  /**
@@ -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 declare const ZETA_WEB_PORT = 43141;
14
15
  export declare const ZETA_GATEWAY_PORT = 43143;
15
16
  export declare const ZETA_STATS_PORT = 43147;
17
+ export declare const ZETA_BROWSER_RELAY_PORT = 43153;
@@ -52,7 +52,7 @@ export declare const NATIVE_PROCESS_EXIT: unique symbol;
52
52
  * signal and fatal exit.
53
53
  */
54
54
  export declare function exitProcess(code: number): never;
55
- /** User-facing command printed before fatal cleanup so interrupted work can be resumed. */
55
+ /** User-facing command printed after fatal or SIGTERM cleanup so interrupted work can be resumed. */
56
56
  export interface FatalRecoveryHint {
57
57
  /** Stable label identifying the recoverable session or process. */
58
58
  label: string;
@@ -168,7 +168,10 @@ export declare function isExpectedCleanupError(reason: unknown): boolean;
168
168
  export declare function interceptUnhandledRejections(interceptor: (reason: unknown) => boolean): () => void;
169
169
  /**
170
170
  * Register a synchronous recovery command to print when the process exits
171
- * through an uncaught exception or unhandled rejection.
171
+ * through an uncaught exception, an unhandled rejection, or SIGTERM.
172
+ *
173
+ * Providers are read once cleanup finishes (or hits its deadline), so they
174
+ * report what teardown left behind: a session it saved or one it dropped.
172
175
  */
173
176
  export declare function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void;
174
177
  /**
@@ -182,6 +185,20 @@ export interface CleanupRegistrationOptions {
182
185
  * The registration remains armed when a keep-alive pass skips it.
183
186
  */
184
187
  exitOnly?: boolean;
188
+ /**
189
+ * How long a pass waits for this callback, when that is longer than the
190
+ * default 10 s cleanup deadline. It extends the pass only while the callback
191
+ * is still pending: one that settles early ends its hold at once.
192
+ */
193
+ deadlineMs?: number;
194
+ /**
195
+ * With `deadlineMs`: a line printed to stderr, with the remaining wait
196
+ * appended, if exit is still waiting on this callback once every other
197
+ * callback has finished (a terminal owner has then released the screen), or
198
+ * at the default 10 s deadline when another callback is still running.
199
+ * Nothing is printed when the callback finishes first.
200
+ */
201
+ waitMessage?: string;
185
202
  }
186
203
  /**
187
204
  * Registers a cleanup callback for shutdown, signals, fatal errors, and
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Set both the JS `process.title` and — on Linux — the kernel `comm` name.
2
+ * Set the JS `process.title` (except on Windows, see above) and — on Linux —
3
+ * the kernel `comm` name.
3
4
  *
4
5
  * Never throws: `bun:ffi` unavailability or a failed syscall degrades silently
5
6
  * to the `process.title`-only behavior, so it is safe to call at startup.
@@ -42,4 +42,17 @@ export declare const whichFresh: typeof darwinWhich;
42
42
  * @returns Filesystem path if found, else null
43
43
  */
44
44
  export declare function $which(command: string, options?: WhichOptions): string | null;
45
+ /**
46
+ * Locate every match for `command` on PATH, in PATH order, uncached.
47
+ *
48
+ * Callers that probe candidates (e.g. the Python eval backend) use this so a broken
49
+ * first hit — a version-manager shim with no version selected, a Windows Store alias —
50
+ * does not hide a working binary further down PATH. On macOS the Xcode/CLT toolchain
51
+ * match is appended last, mirroring {@link $which}'s fallback.
52
+ *
53
+ * @param command - Binary name to resolve
54
+ * @param options - `PATH` (defaults to `process.env.PATH`) and `cwd` for relative entries
55
+ * @returns Distinct filesystem paths; empty when nothing matches
56
+ */
57
+ export declare function $whichAll(command: string, options?: Bun.WhichOptions): string[];
45
58
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linxiraos/pi-utils",
3
- "version": "1.4.4",
3
+ "version": "1.4.7",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "utilities",
@@ -54,12 +54,12 @@
54
54
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
55
55
  },
56
56
  "dependencies": {
57
- "@linxiraos/pi-natives": "1.4.4"
57
+ "@linxiraos/pi-natives": "1.4.7"
58
58
  },
59
59
  "devDependencies": {
60
- "@types/bun": "1.4.3"
60
+ "@types/bun": "1.4.4"
61
61
  },
62
62
  "engines": {
63
- "bun": ">=1.3.14"
63
+ "bun": ">=1.4.4"
64
64
  }
65
65
  }
package/src/abortable.ts CHANGED
@@ -60,7 +60,8 @@ export async function* abortableSource<T>(stream: ReadableStream<T>, signal?: Ab
60
60
 
61
61
  /**
62
62
  * Runs a promise-returning function (`pr`). If the given AbortSignal is aborted before or during
63
- * execution, the promise is rejected with a standard error.
63
+ * execution, the promise is rejected with a standard error. A promise passed directly stays
64
+ * observed, so its later rejection is never unhandled.
64
65
  *
65
66
  * @param signal - Optional AbortSignal to cancel the operation
66
67
  * @param pr - Function returning a promise to run
@@ -71,7 +72,10 @@ export function untilAborted<T>(
71
72
  pr: Promise<T> | (() => Promise<T>),
72
73
  ): Promise<T> {
73
74
  if (!signal) return typeof pr === "function" ? pr() : pr;
74
- if (signal.aborted) return Promise.reject(new AbortError(signal));
75
+ if (signal.aborted) {
76
+ if (typeof pr !== "function") void pr.catch(() => {});
77
+ return Promise.reject(new AbortError(signal));
78
+ }
75
79
 
76
80
  const { promise, resolve, reject } = Promise.withResolvers<T>();
77
81
  const onAbort = () => reject(new AbortError(signal));
@@ -102,6 +102,7 @@ export interface ToolCallLocation extends Meta {
102
102
  export interface ToolCall extends Meta {
103
103
  toolCallId: ToolCallId;
104
104
  title: string;
105
+ name?: string | null;
105
106
  kind?: ToolKind | null;
106
107
  status?: ToolCallStatus | null;
107
108
  content?: ToolCallContent[] | null;
package/src/ar/tar.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  normalizeArchiveEntryPath,
17
17
  normalizeArchiveLookupPath,
18
18
  } from "./paths";
19
- import { readAllBytes } from "./source";
19
+ import type { ByteSource } from "./source";
20
20
  import type { ArchiveIndexEntry, FormatReader, FormatReadOptions, MemberSource } from "./types";
21
21
 
22
22
  const BLOCK_SIZE = 512;
@@ -53,8 +53,44 @@ interface PendingTarLink {
53
53
  targetPath: string;
54
54
  }
55
55
 
56
+ interface TarCursor {
57
+ readonly position: number;
58
+ read(length: number): Promise<Uint8Array>;
59
+ skip(length: number): Promise<boolean>;
60
+ }
61
+
62
+ /** Seek over plain tar payloads without fetching their bytes. */
63
+ class TarSourceCursor implements TarCursor {
64
+ readonly #source: ByteSource;
65
+ #position = 0;
66
+
67
+ constructor(source: ByteSource) {
68
+ this.#source = source;
69
+ }
70
+
71
+ get position(): number {
72
+ return this.#position;
73
+ }
74
+
75
+ async read(length: number): Promise<Uint8Array> {
76
+ const size = Math.min(length, this.#source.size - this.#position);
77
+ if (size === 0) return new Uint8Array(0);
78
+ const end = this.#position + size;
79
+ const bytes = await this.#source.read(this.#position, end);
80
+ if (bytes.byteLength !== size) throw new ArchiveError("Invalid archive: truncated data");
81
+ this.#position = end;
82
+ return bytes;
83
+ }
84
+
85
+ async skip(length: number): Promise<boolean> {
86
+ if (length > this.#source.size - this.#position) return false;
87
+ this.#position += length;
88
+ return true;
89
+ }
90
+ }
91
+
56
92
  /** Forward-only reader over tar bytes that arrive in chunks: one buffer, or a decompressing stream. */
57
- class TarByteStream {
93
+ class TarByteStream implements TarCursor {
58
94
  readonly #chunks: AsyncIterator<Uint8Array> | Iterator<Uint8Array>;
59
95
  #chunk: Uint8Array = new Uint8Array(0);
60
96
  #position = 0;
@@ -119,6 +155,27 @@ interface TarPayload {
119
155
  read(offset: number, size: number, memberPath: string): Promise<Uint8Array>;
120
156
  }
121
157
 
158
+ class TarSourcePayload implements TarPayload {
159
+ readonly #source: ByteSource;
160
+
161
+ constructor(source: ByteSource) {
162
+ this.#source = source;
163
+ }
164
+
165
+ async read(offset: number, size: number, memberPath: string): Promise<Uint8Array> {
166
+ try {
167
+ const bytes = await this.#source.read(offset, offset + size);
168
+ if (bytes.byteLength !== size) {
169
+ throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' is truncated`);
170
+ }
171
+ return bytes;
172
+ } catch (error) {
173
+ if (error instanceof ArchiveError) throw error;
174
+ throw new ArchiveError(error instanceof Error ? error.message : "Failed to read tar member");
175
+ }
176
+ }
177
+ }
178
+
122
179
  class TarBufferPayload implements TarPayload {
123
180
  readonly #buffer: Uint8Array;
124
181
 
@@ -533,7 +590,7 @@ function resolvePendingLinks(
533
590
 
534
591
  /** Read one metadata record's payload, bounded by `maxIndexSize`, and step over its padding. */
535
592
  async function readTarMetadata(
536
- stream: TarByteStream,
593
+ stream: TarCursor,
537
594
  size: number,
538
595
  limits: ArchiveLimits,
539
596
  what: string,
@@ -551,7 +608,7 @@ async function readTarMetadata(
551
608
  * stepped over, never held: entries point into `payload` by stream offset.
552
609
  */
553
610
  async function readTarEntries(
554
- stream: TarByteStream,
611
+ stream: TarCursor,
555
612
  payload: TarPayload,
556
613
  options: FormatReadOptions,
557
614
  ): Promise<ArchiveIndexEntry[]> {
@@ -737,18 +794,17 @@ export async function readTarEntriesFromStream(
737
794
  }
738
795
  }
739
796
 
740
- /** Read and index a tar source after one bounded whole-stream read. */
797
+ /** Index a plain tar by ranging over headers and retaining lazy member offsets. */
741
798
  export const readTar: FormatReader = async (source, options) => {
742
- assertInMemorySize(source.size, options.limits);
743
- let bytes: Uint8Array;
799
+ if (!Number.isSafeInteger(source.size) || source.size < 0) {
800
+ throw new ArchiveError("Archive is too large to read safely");
801
+ }
744
802
  try {
745
- bytes = await readAllBytes(source);
803
+ return await readTarEntries(new TarSourceCursor(source), new TarSourcePayload(source), options);
746
804
  } catch (error) {
747
805
  if (error instanceof ArchiveError) throw error;
748
806
  throw new ArchiveError(error instanceof Error ? error.message : "Failed to read tar archive");
749
807
  }
750
- if (bytes.byteLength !== source.size) throw new ArchiveError("Invalid archive: truncated data");
751
- return readTarEntriesFromBuffer(bytes, options);
752
808
  };
753
809
 
754
810
  /** Detect a tar header, including legacy pre-ustar archives, by its checksum. */
package/src/ar/types.ts CHANGED
@@ -37,7 +37,7 @@ export type WritableArchiveFormat = "zip" | "tar" | "tar.gz" | "tar.zst" | "asar
37
37
  * Where to read an archive from: an extension-inferred filesystem path, a
38
38
  * format-tagged filesystem path, in-memory bytes, or any caller-provided
39
39
  * {@link ByteSource} (e.g. `httpByteSource` for ranged remote reads).
40
- * File- and source-backed ZIP/ASAR/RAR/7z/ISO are read lazily.
40
+ * File- and source-backed TAR/ZIP/ASAR/RAR/7z/ISO are read lazily.
41
41
  */
42
42
  export type ArchiveSource =
43
43
  | string
package/src/dirs.ts CHANGED
@@ -333,7 +333,21 @@ export function getSafeProjectCwd(): string {
333
333
 
334
334
  /** Get the config directory name relative to home (e.g. ".zeta" or ZETA_CONFIG_DIR override). */
335
335
  export function getConfigDirName(): string {
336
- return process.env.ZETA_CONFIG_DIR || CONFIG_DIR_NAME;
336
+ const configured = process.env.ZETA_CONFIG_DIR;
337
+ if (!configured) return CONFIG_DIR_NAME;
338
+ // This value is a directory *name* under the user's home, never a path to
339
+ // adopt directly. Absolute paths would be re-joined to home by every
340
+ // consumer (`getBaseConfigRoot`, project discovery, plugin roots) and
341
+ // produce inconsistent trees; `..` would escape home entirely. Reject
342
+ // both at the boundary instead of scattering files.
343
+ if (path.isAbsolute(configured) || path.parse(configured).root !== "") {
344
+ throw new Error("ZETA_CONFIG_DIR must be a relative directory name under the home directory");
345
+ }
346
+ const normalized = path.normalize(configured);
347
+ if (normalized === "." || normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
348
+ throw new Error("ZETA_CONFIG_DIR must stay within the home directory");
349
+ }
350
+ return normalized;
337
351
  }
338
352
 
339
353
  /** Get the config agent directory name relative to home (e.g. ".zeta/agent" or PI_CONFIG_DIR + "/agent"). */
package/src/format.ts CHANGED
@@ -33,10 +33,14 @@ export function formatDuration(ms: number): string {
33
33
  /**
34
34
  * Format a number with K/M/B suffix for compact display.
35
35
  * Uses 1 decimal for small leading digits when non-zero, rounded otherwise.
36
+ * Values below 1,000 print as-is unless `maxFractionDigits` caps their decimals.
36
37
  * Examples: "999", "1K", "1.5K", "25K", "1M", "1.5M", "25M", "1.5B"
37
38
  */
38
- export function formatNumber(n: number): string {
39
- if (n < 1_000) return n.toString();
39
+ export function formatNumber(n: number, maxFractionDigits?: number): string {
40
+ if (n < 1_000) {
41
+ if (maxFractionDigits === undefined || Number.isInteger(n)) return n.toString();
42
+ return Number(n.toFixed(maxFractionDigits)).toString();
43
+ }
40
44
  if (n < 10_000) return `${trim1(n / 1_000)}K`;
41
45
  if (n < 1_000_000) return `${Math.round(n / 1_000)}K`;
42
46
  if (n < 10_000_000) return `${trim1(n / 1_000_000)}M`;
package/src/index.ts CHANGED
@@ -10,7 +10,6 @@ export * from "./file-lock";
10
10
  export * from "./format";
11
11
  export * from "./frontmatter";
12
12
  export * from "./fs-error";
13
- export * from "./fs-open";
14
13
  export * from "./incoming-json";
15
14
  export * from "./json";
16
15
  export * from "./json-parse";
package/src/json.ts CHANGED
@@ -9,6 +9,31 @@ export function tryParseJson<T = unknown>(content: string): T | null {
9
9
  }
10
10
  }
11
11
 
12
+ /**
13
+ * Index just past the JSON object or array that opens at `text[start]`, or -1
14
+ * when it does not close within `text`. String literals are skipped, honoring
15
+ * escapes, so brackets inside values do not count. Malformed input yields some
16
+ * end that a parser then rejects.
17
+ */
18
+ export function jsonContainerEnd(text: string, start: number): number {
19
+ let depth = 0;
20
+ for (let index = start; index < text.length; index++) {
21
+ const code = text.charCodeAt(index);
22
+ if (code === 0x22) {
23
+ for (index++; index < text.length; index++) {
24
+ const inner = text.charCodeAt(index);
25
+ if (inner === 0x5c) index++;
26
+ else if (inner === 0x22) break;
27
+ }
28
+ } else if (code === 0x7b || code === 0x5b) {
29
+ depth++;
30
+ } else if ((code === 0x7d || code === 0x5d) && --depth === 0) {
31
+ return index + 1;
32
+ }
33
+ }
34
+ return -1;
35
+ }
36
+
12
37
  /**
13
38
  * Serialize JSON while preserving bigint precision as decimal strings.
14
39
  *
@@ -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 {