@oh-my-pi/pi-utils 18.0.6 → 18.0.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
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.0.7] - 2026-08-26
6
+
7
+ ### Added
8
+
9
+ - Added `math-delimiters`, the LaTeX span/block delimiter grammar (`mathStartIndex`, `mathOpenerAt`, `mathSpanAt`, `mathBlockAt`) shared by every Markdown renderer: pandoc's anti-currency rules for `$…$`, own-line display blocks, and delimiters matched by backslash parity, so an escaped `\$x$` stays literal and a TeX row break cannot end a span early.
10
+ - Added `RequestError.sessionBusy(message, data)` to represent ACP session-busy errors (`-32003`) through the shared JSON-RPC transport.
11
+ - Exported `getComposerCacheDir` for resolving the per-project Composer cache directory, including support for `XDG_CACHE_HOME`.
12
+
13
+ ### Fixed
14
+
15
+ - Fixed OMP sessions unexpectedly exiting during socket cleanup or optional-worker communication on Bun.
16
+
5
17
  ## [18.0.6] - 2026-08-26
6
18
 
7
19
  ### Added
package/README.md CHANGED
@@ -17,6 +17,7 @@ Shared utilities for [oh-my-pi](https://github.com/can1357/oh-my-pi) packages. Z
17
17
  | `fs-error` | Errno guards (`isEnoent` and friends) |
18
18
  | `env` / `worker-host` | Environment plumbing and side-effect-free worker-host entry contract (`workerHostEntry`) |
19
19
  | `abortable` / `async` | AbortSignal-aware stream/promise helpers |
20
+ | `math-delimiters` | LaTeX span/block delimiter grammar (offsets only) shared by the TUI and collab-web renderers |
20
21
  | `peek-file` | Read the first N bytes of a file with pooled buffers |
21
22
  | `frontmatter`, `glob`, `mime`, `temp`, `format`, `color`, `snowflake`, `tab-spacing`, `path-tree`, `sanitize-text` | Smaller single-purpose helpers |
22
23
 
@@ -55,6 +55,13 @@ export declare class RequestError extends Error {
55
55
  static authRequired(data?: unknown, additionalMessage?: string): RequestError;
56
56
  /** Creates a resource-not-found error. */
57
57
  static resourceNotFound(uri?: string): RequestError;
58
+ /**
59
+ * Creates a session-busy error: the agent/session is already processing, so the
60
+ * request can be retried once idle (steer/follow-up/wait) instead of treating it
61
+ * as a fault. `message` carries the caller's user-facing wording; `data` should
62
+ * keep the stable discriminator shape (`reason: "session_busy"`).
63
+ */
64
+ static sessionBusy(message: string, data?: unknown): RequestError;
58
65
  /** Converts this error into a JSON-RPC result. */
59
66
  toResult(): {
60
67
  error: ErrorResponse;
@@ -228,6 +228,8 @@ export declare function getModelDbPath(agentDir?: string): string;
228
228
  export declare function getTinyModelsCacheDir(agentDir?: string): string;
229
229
  /** Get the document conversion cache directory (~/.omp/agent/cache/document-conversions; XDG default: $XDG_CACHE_HOME/omp/cache/document-conversions). */
230
230
  export declare function getDocumentConversionCacheDir(agentDir?: string): string;
231
+ /** Get the per-project composer speculative cache directory (~/.omp/agent/cache/composer; XDG default: $XDG_CACHE_HOME/omp/cache/composer). */
232
+ export declare function getComposerCacheDir(agentDir?: string): string;
231
233
  /** Get the sessions directory (~/.omp/agent/sessions). */
232
234
  export declare function getSessionsDir(agentDir?: string): string;
233
235
  /** Get the content-addressed blob store directory (~/.omp/agent/blobs). */
@@ -274,6 +276,13 @@ export declare function getProjectPluginOverridesPath(cwd?: string): string;
274
276
  export declare function getMCPConfigPath(scope: "user" | "project", cwd?: string): string;
275
277
  /** Get the SSH config file path. */
276
278
  export declare function getSSHConfigPath(scope: "user" | "project", cwd?: string): string;
279
+ /**
280
+ * Application label for usage attribution (`OMP_APP_NAME`), defaulting to
281
+ * `omp`. Embedders that drive omp programmatically (robomp, CI bots, …) set
282
+ * the env var so broker-side per-client burn tracking can answer "what did
283
+ * app X use" instead of folding everything into one install-wide bucket.
284
+ */
285
+ export declare function getAppName(): string;
277
286
  /**
278
287
  * Persistent per-install UUID stored at `~/.omp/install-id`.
279
288
  *
@@ -14,6 +14,7 @@ export * from "./json.js";
14
14
  export * from "./json-parse.js";
15
15
  export * as logger from "./logger.js";
16
16
  export * from "./loop-phase.js";
17
+ export * from "./math-delimiters.js";
17
18
  export * from "./mermaid-ascii.js";
18
19
  export * from "./mime.js";
19
20
  export * from "./path.js";
@@ -0,0 +1,45 @@
1
+ /**
2
+ * LaTeX delimiter grammar for agent-authored Markdown: where does a math span
3
+ * begin and end, in source offsets. Carries no rendering policy — what to do
4
+ * with an unclosed opener, whether a body is typesettable, and how it is
5
+ * displayed belong to the renderer (Unicode in the TUI, KaTeX in collab web).
6
+ */
7
+ /** Opening delimiter. Each closer (`$`, `$$`, `\)`, `\]`) is as wide as its opener. */
8
+ export type MathOpener = "$" | "$$" | "\\(" | "\\[";
9
+ /** A closed math span found in the source. */
10
+ export interface MathSpan {
11
+ opener: MathOpener;
12
+ /** True for the display forms `$$…$$` and `\[…\]`. */
13
+ display: boolean;
14
+ /** Offset one past the closing delimiter. */
15
+ end: number;
16
+ /** Source between the delimiters, verbatim. */
17
+ body: string;
18
+ }
19
+ /** An own-line display block: opener and closer each alone on their line. */
20
+ export interface MathBlock {
21
+ /** Both delimiter lines, the body, and the trailing newline. */
22
+ raw: string;
23
+ body: string;
24
+ }
25
+ /**
26
+ * Leftmost offset at or after `from` where an opener could begin. A scan hint,
27
+ * not a decision: whether that candidate is really math — escaped, currency,
28
+ * unclosed — is decided by {@link mathSpanAt}.
29
+ */
30
+ export declare function mathStartIndex(source: string, from?: number): number | undefined;
31
+ /** Math opener at `at`, or `undefined` when no delimiter starts there. */
32
+ export declare function mathOpenerAt(source: string, at: number): MathOpener | undefined;
33
+ /**
34
+ * The span opened at `at`, or `undefined` when the run is not math — including
35
+ * an opener the source escaped, so `\$x$` and `\\(x\)` are literal text.
36
+ *
37
+ * `from` bounds how far back the escape scan may look. Leave it at 0 when
38
+ * reading raw source. Pass the offset your own walk resumed at if you have
39
+ * already consumed the escapes behind it, as `renderMathInText` does: after it
40
+ * emits the `\\` of `\\\(x\)`, the `\(` that follows is a real opener even
41
+ * though a backslash precedes it.
42
+ */
43
+ export declare function mathSpanAt(source: string, at: number, from?: number): MathSpan | undefined;
44
+ /** The own-line display block starting at offset 0, or `undefined`. */
45
+ export declare function mathBlockAt(source: string): MathBlock | undefined;
@@ -44,6 +44,19 @@ export type BrokenPipeSource = "ipc-send" | "stdio-write";
44
44
  export declare function classifyBrokenPipe(err: Error): BrokenPipeSource | undefined;
45
45
  /** Whether an EPIPE came from an IPC `send()` to an optional worker. */
46
46
  export declare function isIpcSendEpipe(err: Error): boolean;
47
+ /**
48
+ * Whether an uncaught error is Bun's asynchronous `ERR_SOCKET_CLOSED` thrown
49
+ * from inside `node:net` internals with no application frames on the stack.
50
+ *
51
+ * Bun ≥1.4 can fire the close callback of an already-closed `node:net` socket
52
+ * on a fresh stack; the throw bypasses every callsite try/catch and surfaces
53
+ * here as a process-level uncaughtException. Closing an already-closed socket
54
+ * is inherently a no-op — the socket owner's own `error`/`close` handlers
55
+ * still drive recovery — so tearing the session down for it is pure loss.
56
+ * Only frameless internal stacks qualify: an `ERR_SOCKET_CLOSED` raised
57
+ * through application code keeps the fatal path.
58
+ */
59
+ export declare function isInternalSocketClosedError(err: unknown): boolean;
47
60
  /**
48
61
  * Detect Bun's advanced-serialization (structured-clone) IPC decode failure.
49
62
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "18.0.6",
4
+ "version": "18.0.7",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "18.0.6"
34
+ "@oh-my-pi/pi-natives": "18.0.7"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/bun": "^1.3.14"
@@ -77,6 +77,15 @@ export class RequestError extends Error {
77
77
  uri === undefined ? undefined : { uri },
78
78
  );
79
79
  }
80
+ /**
81
+ * Creates a session-busy error: the agent/session is already processing, so the
82
+ * request can be retried once idle (steer/follow-up/wait) instead of treating it
83
+ * as a fault. `message` carries the caller's user-facing wording; `data` should
84
+ * keep the stable discriminator shape (`reason: "session_busy"`).
85
+ */
86
+ static sessionBusy(message: string, data?: unknown): RequestError {
87
+ return new RequestError(-32003, message, data);
88
+ }
80
89
  /** Converts this error into a JSON-RPC result. */
81
90
  toResult(): { error: ErrorResponse } {
82
91
  return { error: this.toErrorResponse() };
package/src/dirs.ts CHANGED
@@ -791,6 +791,10 @@ export function getTinyModelsCacheDir(agentDir?: string): string {
791
791
  export function getDocumentConversionCacheDir(agentDir?: string): string {
792
792
  return dirs.agentSubdir(agentDir, path.join("cache", "document-conversions"), "cache");
793
793
  }
794
+ /** Get the per-project composer speculative cache directory (~/.omp/agent/cache/composer; XDG default: $XDG_CACHE_HOME/omp/cache/composer). */
795
+ export function getComposerCacheDir(agentDir?: string): string {
796
+ return dirs.agentSubdir(agentDir, path.join("cache", "composer"), "cache");
797
+ }
794
798
 
795
799
  /** Get the sessions directory (~/.omp/agent/sessions). */
796
800
  export function getSessionsDir(agentDir?: string): string {
@@ -955,6 +959,17 @@ export function getSSHConfigPath(scope: "user" | "project", cwd: string = getPro
955
959
  let cachedInstallId: string | null = null;
956
960
 
957
961
  const INSTALL_ID_FILE = "install-id";
962
+ /**
963
+ * Application label for usage attribution (`OMP_APP_NAME`), defaulting to
964
+ * `omp`. Embedders that drive omp programmatically (robomp, CI bots, …) set
965
+ * the env var so broker-side per-client burn tracking can answer "what did
966
+ * app X use" instead of folding everything into one install-wide bucket.
967
+ */
968
+ export function getAppName(): string {
969
+ const value = process.env.OMP_APP_NAME?.trim();
970
+ return value ? value : "omp";
971
+ }
972
+
958
973
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
959
974
 
960
975
  /**
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export * from "./json";
14
14
  export * from "./json-parse";
15
15
  export * as logger from "./logger";
16
16
  export * from "./loop-phase";
17
+ export * from "./math-delimiters";
17
18
  export * from "./mermaid-ascii";
18
19
  export * from "./mime";
19
20
  export * from "./path";
@@ -0,0 +1,143 @@
1
+ /**
2
+ * LaTeX delimiter grammar for agent-authored Markdown: where does a math span
3
+ * begin and end, in source offsets. Carries no rendering policy — what to do
4
+ * with an unclosed opener, whether a body is typesettable, and how it is
5
+ * displayed belong to the renderer (Unicode in the TUI, KaTeX in collab web).
6
+ */
7
+
8
+ /** Opening delimiter. Each closer (`$`, `$$`, `\)`, `\]`) is as wide as its opener. */
9
+ export type MathOpener = "$" | "$$" | "\\(" | "\\[";
10
+
11
+ /** A closed math span found in the source. */
12
+ export interface MathSpan {
13
+ opener: MathOpener;
14
+ /** True for the display forms `$$…$$` and `\[…\]`. */
15
+ display: boolean;
16
+ /** Offset one past the closing delimiter. */
17
+ end: number;
18
+ /** Source between the delimiters, verbatim. */
19
+ body: string;
20
+ }
21
+
22
+ /** An own-line display block: opener and closer each alone on their line. */
23
+ export interface MathBlock {
24
+ /** Both delimiter lines, the body, and the trailing newline. */
25
+ raw: string;
26
+ body: string;
27
+ }
28
+
29
+ // Display math blocks: opening `$$` / `\[` and closing `$$` / `\]` each alone on
30
+ // their own line (≤3 leading spaces). Matched at the block level — before
31
+ // paragraph/list parsing — so a multi-line equation (e.g. a matrix with `\\`
32
+ // row breaks) survives as one unit and blank lines inside the block don't split
33
+ // it. The own-line requirement leaves inline `$$…$$` inside prose to the span
34
+ // grammar below. `\r?\n` at each line boundary keeps the grammar CRLF-safe for
35
+ // direct callers; marked-fed renderers already normalize line endings first.
36
+ const MATH_BLOCK_DOLLAR = /^ {0,3}\$\$[ \t]*\r?\n([\s\S]+?)\r?\n {0,3}\$\$[ \t]*(?:\r?\n|$)/;
37
+ const MATH_BLOCK_BRACKET = /^ {0,3}\\\[[ \t]*\r?\n([\s\S]+?)\r?\n {0,3}\\\][ \t]*(?:\r?\n|$)/;
38
+
39
+ /**
40
+ * Leftmost offset at or after `from` where an opener could begin. A scan hint,
41
+ * not a decision: whether that candidate is really math — escaped, currency,
42
+ * unclosed — is decided by {@link mathSpanAt}.
43
+ */
44
+ // Three indexOf scans instead of a `/\$|\\\(|\\\[/` alternation — marked calls
45
+ // this on the remaining source at every inline position, where the alternation
46
+ // showed up in CPU profiles (part of a ~4.3% start() tail).
47
+ export function mathStartIndex(source: string, from = 0): number | undefined {
48
+ let best = source.indexOf("$", from);
49
+ const paren = source.indexOf("\\(", from);
50
+ if (paren !== -1 && (best === -1 || paren < best)) best = paren;
51
+ const bracket = source.indexOf("\\[", from);
52
+ if (bracket !== -1 && (best === -1 || bracket < best)) best = bracket;
53
+ return best === -1 ? undefined : best;
54
+ }
55
+
56
+ /** Math opener at `at`, or `undefined` when no delimiter starts there. */
57
+ export function mathOpenerAt(source: string, at: number): MathOpener | undefined {
58
+ const first = source.charCodeAt(at);
59
+ if (first === 0x24 /* $ */) return source.charCodeAt(at + 1) === 0x24 ? "$$" : "$";
60
+ if (first !== 0x5c /* \ */) return undefined;
61
+ const second = source.charCodeAt(at + 1);
62
+ if (second === 0x28 /* ( */) return "\\(";
63
+ if (second === 0x5b /* [ */) return "\\[";
64
+ return undefined;
65
+ }
66
+
67
+ /**
68
+ * The span opened at `at`, or `undefined` when the run is not math — including
69
+ * an opener the source escaped, so `\$x$` and `\\(x\)` are literal text.
70
+ *
71
+ * `from` bounds how far back the escape scan may look. Leave it at 0 when
72
+ * reading raw source. Pass the offset your own walk resumed at if you have
73
+ * already consumed the escapes behind it, as `renderMathInText` does: after it
74
+ * emits the `\\` of `\\\(x\)`, the `\(` that follows is a real opener even
75
+ * though a backslash precedes it.
76
+ */
77
+ export function mathSpanAt(source: string, at: number, from = 0): MathSpan | undefined {
78
+ const opener = mathOpenerAt(source, at);
79
+ if (opener === undefined || escapedAt(source, at, from)) return undefined;
80
+ const bodyStart = at + opener.length;
81
+ const closeAt = opener === "$" ? dollarCloserIndex(source, at) : closerIndex(source, opener, bodyStart);
82
+ if (closeAt === -1) return undefined;
83
+ const body = source.slice(bodyStart, closeAt);
84
+ // `dollarCloserIndex` already rejects an all-space `$…$`; `$$ $$` needs the
85
+ // same guard here, while `\(\)` and `\[\]` are unambiguous enough to keep.
86
+ if (opener === "$$" && body.trim() === "") return undefined;
87
+ return { opener, display: opener === "$$" || opener === "\\[", end: closeAt + opener.length, body };
88
+ }
89
+
90
+ /** The own-line display block starting at offset 0, or `undefined`. */
91
+ export function mathBlockAt(source: string): MathBlock | undefined {
92
+ const match = MATH_BLOCK_DOLLAR.exec(source) ?? MATH_BLOCK_BRACKET.exec(source);
93
+ if (!match || match[1].trim() === "") return undefined;
94
+ return { raw: match[0], body: match[1] };
95
+ }
96
+
97
+ /**
98
+ * Offset of the `$$` / `\)` / `\]` that closes a span, or -1. In `\(a \\) b\)`
99
+ * the `\\` is a TeX row break, so that `)` is body text and the span closes at
100
+ * the final `\)`.
101
+ */
102
+ function closerIndex(source: string, opener: MathOpener, from: number): number {
103
+ // Dollar closers equal their openers; the bracket forms flip the bracket.
104
+ const closer = opener === "\\(" ? "\\)" : opener === "\\[" ? "\\]" : opener;
105
+ for (let at = source.indexOf(closer, from); at !== -1; at = source.indexOf(closer, at + 1)) {
106
+ if (!escapedAt(source, at, from)) return at;
107
+ }
108
+ return -1;
109
+ }
110
+
111
+ /** An odd run of backslashes back to `from` escapes the delimiter at `index`. */
112
+ function escapedAt(source: string, index: number, from: number): boolean {
113
+ let backslashes = 0;
114
+ for (let at = index - 1; at >= from && source.charCodeAt(at) === 0x5c /* \ */; at--) backslashes++;
115
+ return backslashes % 2 === 1;
116
+ }
117
+
118
+ /**
119
+ * Offset of the `$` that closes an inline span opened at `open`, or -1. Pandoc's
120
+ * anti-currency heuristics: the opener must not be followed by whitespace, the
121
+ * closer must not be preceded by whitespace nor followed by a digit, `\$` is a
122
+ * literal dollar, and the span may not cross a newline — so "$5 and $10" is
123
+ * prose, not math.
124
+ */
125
+ function dollarCloserIndex(source: string, open: number): number {
126
+ const after = source[open + 1];
127
+ if (after === undefined || after === " " || after === "\t" || after === "\n" || after === "$") return -1;
128
+ for (let at = open + 1; at < source.length; at++) {
129
+ const char = source[at];
130
+ if (char === "\\") {
131
+ at++;
132
+ continue;
133
+ }
134
+ if (char === "\n") return -1;
135
+ if (char !== "$") continue;
136
+ const before = source[at - 1];
137
+ if (before === " " || before === "\t") return -1;
138
+ const next = source[at + 1];
139
+ if (next !== undefined && next >= "0" && next <= "9") continue; // currency: keep scanning
140
+ return source.slice(open + 1, at).trim().length > 0 ? at : -1;
141
+ }
142
+ return -1;
143
+ }
package/src/postmortem.ts CHANGED
@@ -146,6 +146,33 @@ export function isIpcSendEpipe(err: Error): boolean {
146
146
  return classifyBrokenPipe(err) === "ipc-send";
147
147
  }
148
148
 
149
+ /**
150
+ * Whether an uncaught error is Bun's asynchronous `ERR_SOCKET_CLOSED` thrown
151
+ * from inside `node:net` internals with no application frames on the stack.
152
+ *
153
+ * Bun ≥1.4 can fire the close callback of an already-closed `node:net` socket
154
+ * on a fresh stack; the throw bypasses every callsite try/catch and surfaces
155
+ * here as a process-level uncaughtException. Closing an already-closed socket
156
+ * is inherently a no-op — the socket owner's own `error`/`close` handlers
157
+ * still drive recovery — so tearing the session down for it is pure loss.
158
+ * Only frameless internal stacks qualify: an `ERR_SOCKET_CLOSED` raised
159
+ * through application code keeps the fatal path.
160
+ */
161
+ export function isInternalSocketClosedError(err: unknown): boolean {
162
+ if (!(err instanceof Error) || !("code" in err) || err.code !== "ERR_SOCKET_CLOSED") return false;
163
+ const frames = (err.stack ?? "").split("\n").slice(1);
164
+ if (frames.length === 0) return false;
165
+ let hasNetFrame = false;
166
+ const internal = frames.every(frame => {
167
+ const trimmed = frame.trim();
168
+ if (trimmed === "" || trimmed === "at unknown" || trimmed === "at native") return true;
169
+ if (!/\(node:[^)]*\)$/.test(trimmed) && !/^at node:/.test(trimmed)) return false;
170
+ hasNetFrame ||= trimmed.includes("node:net:");
171
+ return true;
172
+ });
173
+ return internal && hasNetFrame;
174
+ }
175
+
149
176
  /**
150
177
  * Detect Bun's advanced-serialization (structured-clone) IPC decode failure.
151
178
  *
@@ -338,9 +365,17 @@ if (isMainThread) {
338
365
  const url = inspector.url();
339
366
  process.stderr.write(`Inspector opened: ${url}\n`);
340
367
  })
341
- .on("uncaughtException", async err => {
342
- if (isExpectedCleanupError(err)) {
343
- logger.warn("Ignoring expected cleanup exception", { err });
368
+ .on("uncaughtException", async thrown => {
369
+ if (isExpectedCleanupError(thrown)) {
370
+ logger.warn("Ignoring expected cleanup exception", { err: thrown });
371
+ return;
372
+ }
373
+ const err = thrown instanceof Error ? thrown : new Error(String(thrown));
374
+ // Bun can surface a worker IPC send race through uncaughtException
375
+ // instead of unhandledRejection. Apply the same optional-worker
376
+ // containment in either global error channel.
377
+ if (isIpcSendEpipe(err)) {
378
+ logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
344
379
  return;
345
380
  }
346
381
  // A malformed advanced-serialization frame from a worker subprocess
@@ -357,6 +392,12 @@ if (isMainThread) {
357
392
  faultWorkerIpcChannels(err);
358
393
  return;
359
394
  }
395
+ if (isInternalSocketClosedError(err)) {
396
+ logger.warn("Ignoring async ERR_SOCKET_CLOSED from node:net internals; socket owner recovers itself", {
397
+ err,
398
+ });
399
+ return;
400
+ }
360
401
  await exitAfterFatal("Uncaught Exception", "Uncaught exception", err, Reason.UNCAUGHT_EXCEPTION);
361
402
  })
362
403
  .on("unhandledRejection", async reason => {