@oh-my-pi/pi-utils 18.13.3 → 18.14.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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.14.0] - 2026-10-29
6
+
7
+ ### Added
8
+
9
+ - 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.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed `untilAborted` so promises passed to an already-aborted signal are still observed, preventing later rejections from being reported as unhandled.
14
+
5
15
  ## [18.13.3] - 2026-10-28
6
16
 
7
17
  ### Changed
@@ -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
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "18.13.3",
3
+ "version": "18.14.0",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "cli",
@@ -27,7 +27,6 @@
27
27
  "README.md",
28
28
  "CHANGELOG.md",
29
29
  "LICENSE",
30
- "THIRD-PARTY-NOTICES.txt",
31
30
  "dist/types"
32
31
  ],
33
32
  "type": "module",
@@ -57,7 +56,7 @@
57
56
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
58
57
  },
59
58
  "dependencies": {
60
- "@oh-my-pi/pi-natives": "18.13.3"
59
+ "@oh-my-pi/pi-natives": "18.14.0"
61
60
  },
62
61
  "devDependencies": {
63
62
  "@types/bun": "^1.3.14"
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));
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 omp 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. `OMP_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. `OMP_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";
@@ -48,7 +50,18 @@ export function registerLogSink(sink: LogSink): () => void {
48
50
 
49
51
  function emitToSinks(level: LogLevel, message: string, context: Record<string, unknown> | undefined): void {
50
52
  if (logSinks.size === 0) return;
51
- const event: LogEvent = { level, message, context, timestamp: new Date() };
53
+ // The enclosing {@link withContext} fields reach sinks as they reach the file;
54
+ // call-site fields win, and a record outside any context keeps its own object.
55
+ const scoped = logContextStorage.getStore();
56
+ let merged = context;
57
+ if (scoped) {
58
+ try {
59
+ merged = { ...scoped, ...context };
60
+ } catch {
61
+ // A throwing getter must not turn a log call into a failure; sinks get the call's own fields.
62
+ }
63
+ }
64
+ const event: LogEvent = { level, message, context: merged, timestamp: new Date() };
52
65
  for (const sink of logSinks) {
53
66
  try {
54
67
  sink(event);
@@ -251,6 +264,28 @@ function formatLocalTimestamp(date: Date): string {
251
264
 
252
265
  const FORMAT_TOKEN_PATTERN = /%[scdjifoO%]/;
253
266
 
267
+ const logContextStorage = new AsyncLocalStorage<Readonly<Record<string, unknown>>>();
268
+
269
+ /**
270
+ * Run `fn` with `context`'s fields on every record logged while it runs,
271
+ * including from the promises, timers and callbacks it starts. A nested call
272
+ * replaces the enclosing context, as a subagent's session replaces its
273
+ * parent's; a field passed to the log call itself wins. Fields are read as each
274
+ * record is written, so a getter stays current.
275
+ */
276
+ export function withContext<T>(context: Readonly<Record<string, unknown>>, fn: () => T): T {
277
+ return logContextStorage.run(context, fn);
278
+ }
279
+
280
+ /**
281
+ * Run `fn` outside any {@link withContext}: for process-wide work started on
282
+ * behalf of one caller, such as a shared server's read loop, whose later
283
+ * records belong to no single caller.
284
+ */
285
+ export function withoutContext<T>(fn: () => T): T {
286
+ return logContextStorage.exit(fn);
287
+ }
288
+
254
289
  /** Serialize one log record: timestamp, level, pid, message, then context fields. */
255
290
  function formatLogRecord(
256
291
  level: LogLevel,
@@ -266,6 +301,12 @@ function formatLogRecord(
266
301
  pid: process.pid,
267
302
  message: metadata?.message ? `${message} ${metadata.message}` : message,
268
303
  };
304
+ const scoped = logContextStorage.getStore();
305
+ if (scoped) {
306
+ for (const key of Object.keys(scoped)) {
307
+ if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = scoped[key];
308
+ }
309
+ }
269
310
  if (metadata) {
270
311
  for (const key of Object.keys(metadata)) {
271
312
  if (key !== "level" && key !== "timestamp" && key !== "message") entry[key] = metadata[key];