@oh-my-pi/pi-utils 18.18.1 → 18.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.18.2] - 2026-11-08
6
+
7
+ ### Added
8
+
9
+ - Added utilities for reading OpenTelemetry configuration from omp-managed `.env` files, including `$otelEnv` and `withOmpOtelEnv`.
10
+ - Added filesystem path utilities for checking paths through symlinked roots, including paths that do not yet exist, and for optionally propagating filesystem errors in directory checks.
11
+
12
+ ### Fixed
13
+
14
+ - Fixed relaxed JSON parsing so `__proto__` is preserved as a regular own property rather than modifying the parsed object's prototype.
15
+ - Fixed SIGHUP handling so stopped terminal sessions display recovery hints, including the `--resume` command.
16
+
5
17
  ## [18.18.0] - 2026-11-07
6
18
 
7
19
  ### Fixed
@@ -63,7 +63,8 @@ export declare function normalizePathForComparison(inputPath: string): string;
63
63
  * work.
64
64
  */
65
65
  export declare function relativePathWithinNormalizedRoot(normalizedRoot: string, normalizedCandidate: string): string | null;
66
- export declare function pathIsWithin(root: string, candidate: string): boolean;
66
+ /** `options` apply to `candidate` only. */
67
+ export declare function pathIsWithin(root: string, candidate: string, options?: EquivalentPathOptions): boolean;
67
68
  export declare function relativePathWithinRoot(root: string, candidate: string): string | null;
68
69
  /** Get the project directory. */
69
70
  export declare function getProjectDir(): string;
@@ -71,15 +72,14 @@ export declare function getProjectDir(): string;
71
72
  export declare function setProjectDir(dir: string): void;
72
73
  /** Reset the cached project directory (test seam). */
73
74
  export declare function __resetProjectDirCacheForTests(): void;
74
- /** Whether a path is absent or not a directory. Other stat failures return false. */
75
- export declare function directoryIsMissing(dir: string): Promise<boolean>;
76
- /**
77
- * Whether `dir` resolves to an existing directory. Any stat failure — a deleted
78
- * path (ENOENT), permission error, or a non-directory — returns `false`, so
79
- * callers can decide whether a directory is safe to `chdir` into or adopt as a
80
- * working directory before {@link setProjectDir} throws on it.
81
- */
82
- export declare function directoryExists(dir: string): Promise<boolean>;
75
+ /** Whether a path is absent or not a directory; `strict` propagates non-missing stat failures. */
76
+ export declare function directoryIsMissing(dir: string, options?: {
77
+ strict?: boolean;
78
+ }): Promise<boolean>;
79
+ /** Whether a path is an existing directory; `strict` propagates non-missing stat failures. */
80
+ export declare function directoryExists(dir: string, options?: {
81
+ strict?: boolean;
82
+ }): Promise<boolean>;
83
83
  /**
84
84
  * Whether `dir` both exists and can be entered. POSIX `stat` succeeds for a
85
85
  * directory whose own search/execute permission is denied (it only needs
@@ -45,6 +45,34 @@ export declare function getDotenvEnvValues(cwd?: string, env?: Record<string, st
45
45
  * `OMP_` variables to their `PI_` aliases.
46
46
  */
47
47
  export declare function parseEnvFile(filePath: string): Record<string, string>;
48
+ /**
49
+ * @internal Loader seam, exported for tests only. Claims an `OTEL_*` entry from one of OMP's own dotenv files: the value is
50
+ * kept in a private map, never in the process environment. Returns `false` for
51
+ * any other name, which the caller handles as usual.
52
+ *
53
+ * Precedence matches the process-environment loader: a non-empty live value
54
+ * (shell export, project `.env`) wins, and an earlier non-empty file value wins
55
+ * over a later file.
56
+ *
57
+ * @param platform - Name-folding semantics; injectable for tests.
58
+ */
59
+ export declare function claimOmpOtelEnvFileEntry(name: string, value: string, platform?: NodeJS.Platform): boolean;
60
+ /**
61
+ * `OTEL_*` lookup for OMP's own telemetry: the live environment wins (a shell
62
+ * export or project `.env`), then OMP's own `.env` files. Use this instead of
63
+ * `process.env` for any `OTEL_*` setting OMP reads itself.
64
+ */
65
+ export declare function $otelEnv(name: string): string | undefined;
66
+ /**
67
+ * Runs `fn` with OMP's `.env`-sourced `OTEL_*` values temporarily present in
68
+ * `process.env` (only for names with no live value), then removes them again in
69
+ * a `finally`. For code that reads the environment itself, such as the
70
+ * OpenTelemetry SDK constructors.
71
+ *
72
+ * Synchronous only: awaiting inside would let other code, including child
73
+ * process spawns, run while the values are set.
74
+ */
75
+ export declare function withOmpOtelEnv<T>(fn: () => T): T;
48
76
  /**
49
77
  * Intentional re-export of Bun.env.
50
78
  *
@@ -168,12 +168,22 @@ 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, an unhandled rejection, or SIGTERM.
171
+ * through an uncaught exception, an unhandled rejection, SIGTERM, or SIGHUP.
172
172
  *
173
173
  * Providers are read once cleanup finishes (or hits its deadline), so they
174
174
  * report what teardown left behind: a session it saved or one it dropped.
175
175
  */
176
176
  export declare function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void;
177
+ /**
178
+ * Record that the terminal is gone, so signal exits stop writing to fd 2.
179
+ *
180
+ * The terminal owner calls this before raising its own disconnect signal. The
181
+ * `EIO` try/catch in {@link writeTerminalReport} covers a descriptor revoked by
182
+ * an outside hangup; it cannot cover a write that blocks on a full pty.
183
+ */
184
+ export declare function declareTerminalDead(): void;
185
+ /** Whether a terminal owner has already declared the terminal gone. */
186
+ export declare function isTerminalDeclaredDead(): boolean;
177
187
  /**
178
188
  * Reports a caught top-level failure after terminal owners restore their display, then exits.
179
189
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.18.1",
3
+ "version": "18.18.2",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -56,8 +56,8 @@
56
56
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
57
57
  },
58
58
  "dependencies": {
59
- "@oh-my-pi/omptype": "18.18.1",
60
- "@oh-my-pi/pi-natives": "18.18.1"
59
+ "@oh-my-pi/omptype": "18.18.2",
60
+ "@oh-my-pi/pi-natives": "18.18.2"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
package/src/dirs.ts CHANGED
@@ -191,11 +191,15 @@ export function resolveEquivalentPath(inputPath: string, options?: EquivalentPat
191
191
  }
192
192
  }
193
193
 
194
- export function normalizePathForComparison(inputPath: string): string {
195
- const resolvedPath = resolveEquivalentPath(inputPath);
194
+ function comparablePath(inputPath: string, options?: EquivalentPathOptions): string {
195
+ const resolvedPath = resolveEquivalentPath(inputPath, options);
196
196
  return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
197
197
  }
198
198
 
199
+ export function normalizePathForComparison(inputPath: string): string {
200
+ return comparablePath(inputPath);
201
+ }
202
+
199
203
  /**
200
204
  * Compare paths already normalized by {@link normalizePathForComparison}.
201
205
  *
@@ -211,9 +215,10 @@ export function relativePathWithinNormalizedRoot(normalizedRoot: string, normali
211
215
  return relative;
212
216
  }
213
217
 
214
- export function pathIsWithin(root: string, candidate: string): boolean {
218
+ /** `options` apply to `candidate` only. */
219
+ export function pathIsWithin(root: string, candidate: string, options?: EquivalentPathOptions): boolean {
215
220
  const normalizedRoot = normalizePathForComparison(root);
216
- const normalizedCandidate = normalizePathForComparison(candidate);
221
+ const normalizedCandidate = comparablePath(candidate, options);
217
222
  return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) !== null;
218
223
  }
219
224
 
@@ -264,25 +269,23 @@ export function __resetProjectDirCacheForTests(): void {
264
269
  projectDir = undefined;
265
270
  }
266
271
 
267
- /** Whether a path is absent or not a directory. Other stat failures return false. */
268
- export async function directoryIsMissing(dir: string): Promise<boolean> {
272
+ /** Whether a path is absent or not a directory; `strict` propagates non-missing stat failures. */
273
+ export async function directoryIsMissing(dir: string, options?: { strict?: boolean }): Promise<boolean> {
269
274
  try {
270
275
  return !(await fs.promises.stat(dir)).isDirectory();
271
276
  } catch (error) {
272
- return isEnoent(error) || isEnotdir(error);
277
+ if (isEnoent(error) || isEnotdir(error)) return true;
278
+ if (options?.strict) throw error;
279
+ return false;
273
280
  }
274
281
  }
275
282
 
276
- /**
277
- * Whether `dir` resolves to an existing directory. Any stat failure — a deleted
278
- * path (ENOENT), permission error, or a non-directory — returns `false`, so
279
- * callers can decide whether a directory is safe to `chdir` into or adopt as a
280
- * working directory before {@link setProjectDir} throws on it.
281
- */
282
- export async function directoryExists(dir: string): Promise<boolean> {
283
+ /** Whether a path is an existing directory; `strict` propagates non-missing stat failures. */
284
+ export async function directoryExists(dir: string, options?: { strict?: boolean }): Promise<boolean> {
283
285
  try {
284
286
  return (await fs.promises.stat(dir)).isDirectory();
285
- } catch {
287
+ } catch (error) {
288
+ if (options?.strict && !isEnoent(error) && !isEnotdir(error)) throw error;
286
289
  return false;
287
290
  }
288
291
  }
package/src/env.ts CHANGED
@@ -299,9 +299,92 @@ for (const key of Object.keys(Bun.env)) {
299
299
  }
300
300
  }
301
301
 
302
+ /**
303
+ * `OTEL_*` values from OMP's own (non-project) dotenv files: the agent dir,
304
+ * config root, and home `.env`. Those files configure OMP's exporter (endpoint,
305
+ * auth headers, GenAI content capture), so the values are kept here instead of
306
+ * in `Bun.env`/`process.env`: no child process or in-process extension
307
+ * inherits them. Read through {@link $otelEnv}; {@link withOmpOtelEnv} exposes
308
+ * them to the OpenTelemetry SDK for the synchronous span of its construction.
309
+ *
310
+ * Keys are folded with {@link foldOtelEnvName}.
311
+ */
312
+ const ompOtelEnvFileValues = new Map<string, string>();
313
+
314
+ /**
315
+ * Environment names compare case-insensitively on Windows, so a file that spells
316
+ * `otel_exporter_otlp_headers` configures the same variable as the upper-case
317
+ * name; match and key them folded there, exactly on POSIX (names are
318
+ * case-sensitive).
319
+ */
320
+ function foldOtelEnvName(name: string, platform: NodeJS.Platform): string {
321
+ return platform === "win32" ? name.toUpperCase() : name;
322
+ }
323
+
324
+ /**
325
+ * @internal Loader seam, exported for tests only. Claims an `OTEL_*` entry from one of OMP's own dotenv files: the value is
326
+ * kept in a private map, never in the process environment. Returns `false` for
327
+ * any other name, which the caller handles as usual.
328
+ *
329
+ * Precedence matches the process-environment loader: a non-empty live value
330
+ * (shell export, project `.env`) wins, and an earlier non-empty file value wins
331
+ * over a later file.
332
+ *
333
+ * @param platform - Name-folding semantics; injectable for tests.
334
+ */
335
+ export function claimOmpOtelEnvFileEntry(
336
+ name: string,
337
+ value: string,
338
+ platform: NodeJS.Platform = process.platform,
339
+ ): boolean {
340
+ const key = foldOtelEnvName(name, platform);
341
+ if (!key.startsWith("OTEL_")) return false;
342
+ if (!Bun.env[name] && !ompOtelEnvFileValues.get(key)) ompOtelEnvFileValues.set(key, value);
343
+ return true;
344
+ }
345
+
346
+ /**
347
+ * `OTEL_*` lookup for OMP's own telemetry: the live environment wins (a shell
348
+ * export or project `.env`), then OMP's own `.env` files. Use this instead of
349
+ * `process.env` for any `OTEL_*` setting OMP reads itself.
350
+ */
351
+ export function $otelEnv(name: string): string | undefined {
352
+ const live = Bun.env[name];
353
+ if (live) return live;
354
+ return ompOtelEnvFileValues.get(foldOtelEnvName(name, process.platform)) ?? live;
355
+ }
356
+
357
+ /**
358
+ * Runs `fn` with OMP's `.env`-sourced `OTEL_*` values temporarily present in
359
+ * `process.env` (only for names with no live value), then removes them again in
360
+ * a `finally`. For code that reads the environment itself, such as the
361
+ * OpenTelemetry SDK constructors.
362
+ *
363
+ * Synchronous only: awaiting inside would let other code, including child
364
+ * process spawns, run while the values are set.
365
+ */
366
+ export function withOmpOtelEnv<T>(fn: () => T): T {
367
+ const previous = new Map<string, string | undefined>();
368
+ for (const [name, value] of ompOtelEnvFileValues) {
369
+ if (process.env[name]) continue;
370
+ previous.set(name, process.env[name]);
371
+ process.env[name] = value;
372
+ }
373
+ try {
374
+ return fn();
375
+ } finally {
376
+ for (const [name, prior] of previous) {
377
+ if (prior === undefined) delete process.env[name];
378
+ else process.env[name] = prior;
379
+ }
380
+ }
381
+ }
382
+
302
383
  for (const file of [projectEnv, agentEnv, piEnv, homeEnv]) {
303
384
  for (const key in file) {
304
- if (!isMacosMallocStackLoggingEnvName(key) && !Bun.env[key]) {
385
+ if (isMacosMallocStackLoggingEnvName(key)) continue;
386
+ if (file !== projectEnv && claimOmpOtelEnvFileEntry(key, file[key])) continue;
387
+ if (!Bun.env[key]) {
305
388
  Bun.env[key] = file[key];
306
389
  if (file === projectEnv) projectEnvNamesLoadedByOmp.add(key);
307
390
  }
package/src/json-parse.ts CHANGED
@@ -223,7 +223,14 @@ class RelaxedJson {
223
223
  }
224
224
  const value = this.#value(true);
225
225
  if (value === INCOMPLETE) return out;
226
- out[key] = value;
226
+ // A decoded __proto__ key must become an own property, not invoke
227
+ // the legacy prototype setter on the result object (same guard as
228
+ // schema/normalize.ts and validation.ts).
229
+ if (key === "__proto__") {
230
+ Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true });
231
+ } else {
232
+ out[key] = value;
233
+ }
227
234
  lex.ws();
228
235
  const d = lex.peek();
229
236
  if (d === COMMA) {
package/src/postmortem.ts CHANGED
@@ -602,7 +602,7 @@ function removeEmbeddedRejectionListener(): void {
602
602
 
603
603
  /**
604
604
  * Register a synchronous recovery command to print when the process exits
605
- * through an uncaught exception, an unhandled rejection, or SIGTERM.
605
+ * through an uncaught exception, an unhandled rejection, SIGTERM, or SIGHUP.
606
606
  *
607
607
  * Providers are read once cleanup finishes (or hits its deadline), so they
608
608
  * report what teardown left behind: a session it saved or one it dropped.
@@ -644,6 +644,30 @@ function formatFatalError(label: string, err: Error): string {
644
644
  return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
645
645
  }
646
646
 
647
+ /**
648
+ * Whether a terminal owner has declared the terminal gone, e.g. the TUI's
649
+ * `#markTerminalDisconnected`. The owner sets this before raising SIGHUP itself,
650
+ * so the signal handlers must not write afterwards: with stdout stalled, fd 2 is
651
+ * a blocking, full pty, and a write there hangs in the kernel before the exit.
652
+ */
653
+ let terminalDeclaredDead = false;
654
+
655
+ /**
656
+ * Record that the terminal is gone, so signal exits stop writing to fd 2.
657
+ *
658
+ * The terminal owner calls this before raising its own disconnect signal. The
659
+ * `EIO` try/catch in {@link writeTerminalReport} covers a descriptor revoked by
660
+ * an outside hangup; it cannot cover a write that blocks on a full pty.
661
+ */
662
+ export function declareTerminalDead(): void {
663
+ terminalDeclaredDead = true;
664
+ }
665
+
666
+ /** Whether a terminal owner has already declared the terminal gone. */
667
+ export function isTerminalDeclaredDead(): boolean {
668
+ return terminalDeclaredDead;
669
+ }
670
+
647
671
  /** Write a final report to stderr once terminal owners have handed the terminal back. */
648
672
  function writeTerminalReport(output: string): void {
649
673
  restoreTerminalStderr();
@@ -728,6 +752,21 @@ process.on("exit", () => {
728
752
 
729
753
  let processHandlersInstalled = false;
730
754
 
755
+ /**
756
+ * Exit through a termination signal: clean up, print the recovery hints, exit.
757
+ *
758
+ * The hint write is skipped once a terminal owner declared the terminal dead
759
+ * ({@link declareTerminalDead}): a stalled stdout leaves fd 2 a full, blocking
760
+ * pty, so the write would hang the process before the exit below. A repeated
761
+ * signal joins the same cleanup and the hints still print once.
762
+ */
763
+ async function exitAfterSignal(reason: Reason, heading: string, exitCode: number): Promise<never> {
764
+ const hintProviders = [...fatalRecoveryHintProviders];
765
+ await runCleanup(reason);
766
+ if (!terminalDeclaredDead) writeRecoveryHints(hintProviders, heading);
767
+ exitProcess(exitCode);
768
+ }
769
+
731
770
  /** Installs OMP's process-wide signal and fatal handlers for a CLI-owned process. */
732
771
  export function installProcessHandlers(): void {
733
772
  if (processHandlersInstalled) return;
@@ -805,16 +844,13 @@ export function installProcessHandlers(): void {
805
844
  })
806
845
  .on("SIGTERM", async () => {
807
846
  // A stopped session otherwise leaves its last frame above the shell prompt,
808
- // which reads as a crash. A repeated SIGTERM joins the same cleanup and the
809
- // hints still print once.
810
- const hintProviders = [...fatalRecoveryHintProviders];
811
- await runCleanup(Reason.SIGTERM);
812
- writeRecoveryHints(hintProviders, "\n[Stopped by SIGTERM]");
813
- exitProcess(143); // 128 + SIGTERM (15)
847
+ // which reads as a crash.
848
+ await exitAfterSignal(Reason.SIGTERM, "\n[Stopped by SIGTERM]", 143); // 128 + SIGTERM (15)
814
849
  })
815
850
  .on("SIGHUP", async () => {
816
- await runCleanup(Reason.SIGHUP);
817
- exitProcess(129); // 128 + SIGHUP (1)
851
+ // Same report as SIGTERM: a runtime that stops the program with a hangup
852
+ // keeps its screen, so the resume command survives the death.
853
+ await exitAfterSignal(Reason.SIGHUP, "\n[Stopped by SIGHUP]", 129); // 128 + SIGHUP (1)
818
854
  });
819
855
  }
820
856