@oh-my-pi/pi-utils 18.0.6 → 18.0.8
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 +23 -0
- package/README.md +1 -0
- package/dist/types/acp/transport.d.ts +7 -0
- package/dist/types/dirs.d.ts +28 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/math-delimiters.d.ts +45 -0
- package/dist/types/postmortem.d.ts +13 -0
- package/dist/types/ptree.d.ts +15 -1
- package/package.json +2 -2
- package/src/acp/transport.ts +9 -0
- package/src/dirs.ts +90 -3
- package/src/env.ts +2 -2
- package/src/index.ts +1 -0
- package/src/math-delimiters.ts +143 -0
- package/src/postmortem.ts +44 -3
- package/src/ptree.ts +308 -39
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.0.8] - 2026-08-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added the Linux `subreaper` spawn option to retain reparented descendants for process-tree cleanup.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Keep project-directory state unchanged when changing directories fails.
|
|
14
|
+
- Fixed `ptree` timeout cleanup and output capture so timed commands retain their deadline through descendant-held pipes and untimed commands read output to EOF.
|
|
15
|
+
|
|
16
|
+
## [18.0.7] - 2026-08-26
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- 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.
|
|
21
|
+
- Added `RequestError.sessionBusy(message, data)` to represent ACP session-busy errors (`-32003`) through the shared JSON-RPC transport.
|
|
22
|
+
- Exported `getComposerCacheDir` for resolving the per-project Composer cache directory, including support for `XDG_CACHE_HOME`.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Fixed OMP sessions unexpectedly exiting during socket cleanup or optional-worker communication on Bun.
|
|
27
|
+
|
|
5
28
|
## [18.0.6] - 2026-08-26
|
|
6
29
|
|
|
7
30
|
### 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;
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -48,6 +48,10 @@ export declare function relativePathWithinRoot(root: string, candidate: string):
|
|
|
48
48
|
export declare function getProjectDir(): string;
|
|
49
49
|
/** Set the project directory. */
|
|
50
50
|
export declare function setProjectDir(dir: string): void;
|
|
51
|
+
/** Reset the cached project directory (test seam). */
|
|
52
|
+
export declare function __resetProjectDirCacheForTests(): void;
|
|
53
|
+
/** Whether a path is absent or not a directory. Other stat failures return false. */
|
|
54
|
+
export declare function directoryIsMissing(dir: string): Promise<boolean>;
|
|
51
55
|
/**
|
|
52
56
|
* Whether `dir` resolves to an existing directory. Any stat failure — a deleted
|
|
53
57
|
* path (ENOENT), permission error, or a non-directory — returns `false`, so
|
|
@@ -55,6 +59,21 @@ export declare function setProjectDir(dir: string): void;
|
|
|
55
59
|
* working directory before {@link setProjectDir} throws on it.
|
|
56
60
|
*/
|
|
57
61
|
export declare function directoryExists(dir: string): Promise<boolean>;
|
|
62
|
+
/**
|
|
63
|
+
* Whether `dir` both exists and can be entered. POSIX `stat` succeeds for a
|
|
64
|
+
* directory whose own search/execute permission is denied (it only needs
|
|
65
|
+
* +x on the parent chain), so existence alone does not imply `chdir` works.
|
|
66
|
+
* Callers that adopt a directory as a working directory must check this
|
|
67
|
+
* rather than {@link directoryExists} alone.
|
|
68
|
+
*/
|
|
69
|
+
export declare function directoryIsEnterable(dir: string): Promise<boolean>;
|
|
70
|
+
/** Whether `dir` is enterable, synchronous variant. See {@link directoryIsEnterable}. */
|
|
71
|
+
export declare function directoryIsEnterableSync(dir: string): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Project directory when it is enterable, otherwise a safe fallback.
|
|
74
|
+
* Used by spawns that must preserve project-relative behavior when healthy.
|
|
75
|
+
*/
|
|
76
|
+
export declare function getSafeProjectCwd(): string;
|
|
58
77
|
/** Get the config directory name relative to home (e.g. ".omp" or PI_CONFIG_DIR override). */
|
|
59
78
|
export declare function getConfigDirName(): string;
|
|
60
79
|
/** Get the config agent directory name relative to home (e.g. ".omp/agent" or PI_CONFIG_DIR + "/agent"). */
|
|
@@ -228,6 +247,8 @@ export declare function getModelDbPath(agentDir?: string): string;
|
|
|
228
247
|
export declare function getTinyModelsCacheDir(agentDir?: string): string;
|
|
229
248
|
/** Get the document conversion cache directory (~/.omp/agent/cache/document-conversions; XDG default: $XDG_CACHE_HOME/omp/cache/document-conversions). */
|
|
230
249
|
export declare function getDocumentConversionCacheDir(agentDir?: string): string;
|
|
250
|
+
/** Get the per-project composer speculative cache directory (~/.omp/agent/cache/composer; XDG default: $XDG_CACHE_HOME/omp/cache/composer). */
|
|
251
|
+
export declare function getComposerCacheDir(agentDir?: string): string;
|
|
231
252
|
/** Get the sessions directory (~/.omp/agent/sessions). */
|
|
232
253
|
export declare function getSessionsDir(agentDir?: string): string;
|
|
233
254
|
/** Get the content-addressed blob store directory (~/.omp/agent/blobs). */
|
|
@@ -274,6 +295,13 @@ export declare function getProjectPluginOverridesPath(cwd?: string): string;
|
|
|
274
295
|
export declare function getMCPConfigPath(scope: "user" | "project", cwd?: string): string;
|
|
275
296
|
/** Get the SSH config file path. */
|
|
276
297
|
export declare function getSSHConfigPath(scope: "user" | "project", cwd?: string): string;
|
|
298
|
+
/**
|
|
299
|
+
* Application label for usage attribution (`OMP_APP_NAME`), defaulting to
|
|
300
|
+
* `omp`. Embedders that drive omp programmatically (robomp, CI bots, …) set
|
|
301
|
+
* the env var so broker-side per-client burn tracking can answer "what did
|
|
302
|
+
* app X use" instead of folding everything into one install-wide bucket.
|
|
303
|
+
*/
|
|
304
|
+
export declare function getAppName(): string;
|
|
277
305
|
/**
|
|
278
306
|
* Persistent per-install UUID stored at `~/.omp/install-id`.
|
|
279
307
|
*
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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/dist/types/ptree.d.ts
CHANGED
|
@@ -10,6 +10,13 @@ import type { Spawn, Subprocess } from "bun";
|
|
|
10
10
|
type InMask = "pipe" | "ignore" | Buffer | Uint8Array | null;
|
|
11
11
|
/** A Bun subprocess with stdout/stderr always piped (stdin may vary). */
|
|
12
12
|
type PipedSubprocess<In extends InMask = InMask> = Subprocess<In, "pipe", "pipe">;
|
|
13
|
+
/**
|
|
14
|
+
* Build the Linux child-subreaper entrypoint.
|
|
15
|
+
*
|
|
16
|
+
* @internal Exported so tests can force a missing first libc soname and verify
|
|
17
|
+
* the loader continues to the next candidate.
|
|
18
|
+
*/
|
|
19
|
+
export declare function createLinuxSubreaperScript(libcCandidates?: readonly string[]): string;
|
|
13
20
|
/**
|
|
14
21
|
* Base for all exceptions representing child process nonzero exit, killed, or
|
|
15
22
|
* cancellation.
|
|
@@ -63,7 +70,7 @@ export declare class ChildProcess<In extends InMask = InMask> {
|
|
|
63
70
|
#private;
|
|
64
71
|
readonly proc: PipedSubprocess<In>;
|
|
65
72
|
readonly exposeStderr: boolean;
|
|
66
|
-
constructor(proc: PipedSubprocess<In>, exposeStderr: boolean, retainFullStderr?: boolean);
|
|
73
|
+
constructor(proc: PipedSubprocess<In>, exposeStderr: boolean, retainFullStderr?: boolean, terminateGroup?: boolean, hardKillTree?: boolean);
|
|
67
74
|
get pid(): number;
|
|
68
75
|
get exited(): Promise<number>;
|
|
69
76
|
get exitCode(): number | null;
|
|
@@ -93,6 +100,13 @@ export declare class ChildProcess<In extends InMask = InMask> {
|
|
|
93
100
|
type ChildSpawnOptions<In extends InMask = InMask> = Omit<Spawn.SpawnOptions<In, "pipe", "pipe">, "stdout" | "stderr" | "detached"> & {
|
|
94
101
|
signal?: AbortSignal;
|
|
95
102
|
detached?: boolean;
|
|
103
|
+
/**
|
|
104
|
+
* On Linux, supervise the command from a child subreaper so descendants
|
|
105
|
+
* remain reachable after changing session and reparenting. Other platforms
|
|
106
|
+
* ignore this option. macOS process groups cannot retain a daemonized
|
|
107
|
+
* descendant that creates a new session and reparents to launchd.
|
|
108
|
+
*/
|
|
109
|
+
subreaper?: boolean;
|
|
96
110
|
/** Expose and retain complete stderr for a later `wait({ stderr: "full" })`. */
|
|
97
111
|
stderr?: "full" | null;
|
|
98
112
|
};
|
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.
|
|
4
|
+
"version": "18.0.8",
|
|
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.
|
|
34
|
+
"@oh-my-pi/pi-natives": "18.0.8"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/bun": "^1.3.14"
|
package/src/acp/transport.ts
CHANGED
|
@@ -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
|
@@ -15,6 +15,7 @@ import * as fs from "node:fs";
|
|
|
15
15
|
import * as os from "node:os";
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import { engines, version } from "../package.json" with { type: "json" };
|
|
18
|
+
import { isEnoent, isEnotdir } from "./fs-error";
|
|
18
19
|
|
|
19
20
|
/** App name (e.g. "omp") */
|
|
20
21
|
export const APP_NAME: string = "omp";
|
|
@@ -178,17 +179,50 @@ export function relativePathWithinRoot(root: string, candidate: string): string
|
|
|
178
179
|
return relative || null;
|
|
179
180
|
}
|
|
180
181
|
|
|
181
|
-
let projectDir
|
|
182
|
+
let projectDir: string | undefined;
|
|
182
183
|
|
|
183
184
|
/** Get the project directory. */
|
|
184
185
|
export function getProjectDir(): string {
|
|
186
|
+
if (projectDir === undefined) {
|
|
187
|
+
try {
|
|
188
|
+
projectDir = standardizeMacOSPath(process.cwd());
|
|
189
|
+
} catch {
|
|
190
|
+
const candidates = [process.env.PWD, os.homedir(), os.tmpdir()];
|
|
191
|
+
for (const candidate of candidates) {
|
|
192
|
+
if (!candidate || !path.isAbsolute(candidate)) continue;
|
|
193
|
+
try {
|
|
194
|
+
process.chdir(candidate);
|
|
195
|
+
projectDir = standardizeMacOSPath(candidate);
|
|
196
|
+
break;
|
|
197
|
+
} catch {}
|
|
198
|
+
}
|
|
199
|
+
if (projectDir === undefined) {
|
|
200
|
+
throw new Error("Unable to determine an accessible working directory");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
185
204
|
return projectDir;
|
|
186
205
|
}
|
|
187
206
|
|
|
188
207
|
/** Set the project directory. */
|
|
189
208
|
export function setProjectDir(dir: string): void {
|
|
190
|
-
|
|
191
|
-
process.chdir(
|
|
209
|
+
const resolved = standardizeMacOSPath(path.resolve(dir));
|
|
210
|
+
process.chdir(resolved);
|
|
211
|
+
projectDir = resolved;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Reset the cached project directory (test seam). */
|
|
215
|
+
export function __resetProjectDirCacheForTests(): void {
|
|
216
|
+
projectDir = undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Whether a path is absent or not a directory. Other stat failures return false. */
|
|
220
|
+
export async function directoryIsMissing(dir: string): Promise<boolean> {
|
|
221
|
+
try {
|
|
222
|
+
return !(await fs.promises.stat(dir)).isDirectory();
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return isEnoent(error) || isEnotdir(error);
|
|
225
|
+
}
|
|
192
226
|
}
|
|
193
227
|
|
|
194
228
|
/**
|
|
@@ -205,6 +239,44 @@ export async function directoryExists(dir: string): Promise<boolean> {
|
|
|
205
239
|
}
|
|
206
240
|
}
|
|
207
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Whether `dir` both exists and can be entered. POSIX `stat` succeeds for a
|
|
244
|
+
* directory whose own search/execute permission is denied (it only needs
|
|
245
|
+
* +x on the parent chain), so existence alone does not imply `chdir` works.
|
|
246
|
+
* Callers that adopt a directory as a working directory must check this
|
|
247
|
+
* rather than {@link directoryExists} alone.
|
|
248
|
+
*/
|
|
249
|
+
export async function directoryIsEnterable(dir: string): Promise<boolean> {
|
|
250
|
+
try {
|
|
251
|
+
const [stats] = await Promise.all([fs.promises.stat(dir), fs.promises.access(dir, fs.constants.X_OK)]);
|
|
252
|
+
return stats.isDirectory();
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Whether `dir` is enterable, synchronous variant. See {@link directoryIsEnterable}. */
|
|
259
|
+
export function directoryIsEnterableSync(dir: string): boolean {
|
|
260
|
+
try {
|
|
261
|
+
fs.accessSync(dir, fs.constants.X_OK);
|
|
262
|
+
return fs.statSync(dir).isDirectory();
|
|
263
|
+
} catch {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Project directory when it is enterable, otherwise a safe fallback.
|
|
270
|
+
* Used by spawns that must preserve project-relative behavior when healthy.
|
|
271
|
+
*/
|
|
272
|
+
export function getSafeProjectCwd(): string {
|
|
273
|
+
try {
|
|
274
|
+
const dir = getProjectDir();
|
|
275
|
+
if (directoryIsEnterableSync(dir)) return dir;
|
|
276
|
+
} catch {}
|
|
277
|
+
return os.homedir();
|
|
278
|
+
}
|
|
279
|
+
|
|
208
280
|
/** Get the config directory name relative to home (e.g. ".omp" or PI_CONFIG_DIR override). */
|
|
209
281
|
export function getConfigDirName(): string {
|
|
210
282
|
return process.env.PI_CONFIG_DIR || CONFIG_DIR_NAME;
|
|
@@ -791,6 +863,10 @@ export function getTinyModelsCacheDir(agentDir?: string): string {
|
|
|
791
863
|
export function getDocumentConversionCacheDir(agentDir?: string): string {
|
|
792
864
|
return dirs.agentSubdir(agentDir, path.join("cache", "document-conversions"), "cache");
|
|
793
865
|
}
|
|
866
|
+
/** Get the per-project composer speculative cache directory (~/.omp/agent/cache/composer; XDG default: $XDG_CACHE_HOME/omp/cache/composer). */
|
|
867
|
+
export function getComposerCacheDir(agentDir?: string): string {
|
|
868
|
+
return dirs.agentSubdir(agentDir, path.join("cache", "composer"), "cache");
|
|
869
|
+
}
|
|
794
870
|
|
|
795
871
|
/** Get the sessions directory (~/.omp/agent/sessions). */
|
|
796
872
|
export function getSessionsDir(agentDir?: string): string {
|
|
@@ -955,6 +1031,17 @@ export function getSSHConfigPath(scope: "user" | "project", cwd: string = getPro
|
|
|
955
1031
|
let cachedInstallId: string | null = null;
|
|
956
1032
|
|
|
957
1033
|
const INSTALL_ID_FILE = "install-id";
|
|
1034
|
+
/**
|
|
1035
|
+
* Application label for usage attribution (`OMP_APP_NAME`), defaulting to
|
|
1036
|
+
* `omp`. Embedders that drive omp programmatically (robomp, CI bots, …) set
|
|
1037
|
+
* the env var so broker-side per-client burn tracking can answer "what did
|
|
1038
|
+
* app X use" instead of folding everything into one install-wide bucket.
|
|
1039
|
+
*/
|
|
1040
|
+
export function getAppName(): string {
|
|
1041
|
+
const value = process.env.OMP_APP_NAME?.trim();
|
|
1042
|
+
return value ? value : "omp";
|
|
1043
|
+
}
|
|
1044
|
+
|
|
958
1045
|
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
1046
|
|
|
960
1047
|
/**
|
package/src/env.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { getAgentDir, getConfigRootDir, refreshDirsFromEnv } from "./dirs";
|
|
4
|
+
import { getAgentDir, getConfigRootDir, getProjectDir, refreshDirsFromEnv } from "./dirs";
|
|
5
5
|
|
|
6
6
|
export * from "./worker-host";
|
|
7
7
|
|
|
@@ -228,7 +228,7 @@ export function parseEnvFile(filePath: string): Record<string, string> {
|
|
|
228
228
|
const homeEnv = parseEnvFile(path.join(os.homedir(), ".env"));
|
|
229
229
|
const piEnv = parseEnvFile(path.join(getConfigRootDir(), ".env"));
|
|
230
230
|
const agentEnv = parseEnvFile(path.join(getAgentDir(), ".env"));
|
|
231
|
-
const projectEnv = parseEnvFile(path.join(
|
|
231
|
+
const projectEnv = parseEnvFile(path.join(getProjectDir(), ".env"));
|
|
232
232
|
|
|
233
233
|
for (const key of Object.keys(Bun.env)) {
|
|
234
234
|
const value = Bun.env[key];
|
package/src/index.ts
CHANGED
|
@@ -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
|
|
342
|
-
if (isExpectedCleanupError(
|
|
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 => {
|
package/src/ptree.ts
CHANGED
|
@@ -15,6 +15,83 @@ type InMask = "pipe" | "ignore" | Buffer | Uint8Array | null;
|
|
|
15
15
|
/** A Bun subprocess with stdout/stderr always piped (stdin may vary). */
|
|
16
16
|
type PipedSubprocess<In extends InMask = InMask> = Subprocess<In, "pipe", "pipe">;
|
|
17
17
|
|
|
18
|
+
const LINUX_SUBREAPER_COMMAND_ENV = "OMP_PTREE_SUBREAPER_COMMAND";
|
|
19
|
+
const LINUX_SUBREAPER_BUN_BE_BUN_ENV = "OMP_PTREE_SUBREAPER_BUN_BE_BUN";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the Linux child-subreaper entrypoint.
|
|
23
|
+
*
|
|
24
|
+
* @internal Exported so tests can force a missing first libc soname and verify
|
|
25
|
+
* the loader continues to the next candidate.
|
|
26
|
+
*/
|
|
27
|
+
export function createLinuxSubreaperScript(libcCandidates: readonly string[] = ["libc.so.6", "libc.so"]): string {
|
|
28
|
+
return `
|
|
29
|
+
import { dlopen, FFIType } from "bun:ffi";
|
|
30
|
+
|
|
31
|
+
let libc;
|
|
32
|
+
for (const soname of ${JSON.stringify(libcCandidates)}) {
|
|
33
|
+
try {
|
|
34
|
+
libc = dlopen(soname, {
|
|
35
|
+
prctl: {
|
|
36
|
+
args: [FFIType.i32, FFIType.u64, FFIType.u64, FFIType.u64, FFIType.u64],
|
|
37
|
+
returns: FFIType.i32,
|
|
38
|
+
},
|
|
39
|
+
waitpid: {
|
|
40
|
+
args: [FFIType.i32, FFIType.ptr, FFIType.i32],
|
|
41
|
+
returns: FFIType.i32,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
break;
|
|
45
|
+
} catch {}
|
|
46
|
+
}
|
|
47
|
+
if (!libc) throw new Error("failed to load libc for Linux child supervision");
|
|
48
|
+
|
|
49
|
+
if (libc.symbols.prctl(36, 1, 0, 0, 0) !== 0) {
|
|
50
|
+
throw new Error("failed to become a Linux child subreaper");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const commandJson = Bun.env.${LINUX_SUBREAPER_COMMAND_ENV};
|
|
54
|
+
if (!commandJson) throw new Error("missing supervised command");
|
|
55
|
+
const callerBunBeBun = Bun.env.${LINUX_SUBREAPER_BUN_BE_BUN_ENV};
|
|
56
|
+
delete Bun.env.${LINUX_SUBREAPER_COMMAND_ENV};
|
|
57
|
+
delete Bun.env.${LINUX_SUBREAPER_BUN_BE_BUN_ENV};
|
|
58
|
+
if (callerBunBeBun === undefined) delete Bun.env.BUN_BE_BUN;
|
|
59
|
+
else Bun.env.BUN_BE_BUN = callerBunBeBun;
|
|
60
|
+
const command = JSON.parse(commandJson);
|
|
61
|
+
const child = Bun.spawn(command, {
|
|
62
|
+
stdin: "inherit",
|
|
63
|
+
stdout: "pipe",
|
|
64
|
+
stderr: "pipe",
|
|
65
|
+
windowsHide: true,
|
|
66
|
+
env: Bun.env,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
async function relay(stream, destination) {
|
|
70
|
+
const writer = destination.writer();
|
|
71
|
+
for await (const chunk of stream) writer.write(chunk);
|
|
72
|
+
await writer.flush();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hasLiveChildren() {
|
|
76
|
+
let childPid;
|
|
77
|
+
do {
|
|
78
|
+
childPid = libc.symbols.waitpid(-1, null, 1);
|
|
79
|
+
} while (childPid > 0);
|
|
80
|
+
return childPid === 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const [exitCode] = await Promise.all([
|
|
84
|
+
child.exited,
|
|
85
|
+
relay(child.stdout, Bun.stdout),
|
|
86
|
+
relay(child.stderr, Bun.stderr),
|
|
87
|
+
]);
|
|
88
|
+
while (hasLiveChildren()) await Bun.sleep(10);
|
|
89
|
+
process.exit(exitCode ?? 1);
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const LINUX_SUBREAPER_SCRIPT = createLinuxSubreaperScript();
|
|
94
|
+
|
|
18
95
|
// ── Exceptions ───────────────────────────────────────────────────────────────
|
|
19
96
|
|
|
20
97
|
/**
|
|
@@ -103,13 +180,30 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
103
180
|
#exitReasonPending?: Exception;
|
|
104
181
|
#stderrDone: Promise<void>;
|
|
105
182
|
#exited: Promise<number>;
|
|
183
|
+
#openPipeReaders = 1;
|
|
184
|
+
// Pipe reads race this cutoff only when attachTimeout() configures a
|
|
185
|
+
// command deadline. Untimed commands preserve complete EOF-based capture.
|
|
186
|
+
#drainCutoff: Promise<void>;
|
|
187
|
+
#resolveDrainCutoff: () => void;
|
|
188
|
+
#timeoutTimer?: NodeJS.Timeout;
|
|
106
189
|
#stderrStream?: ReadableStream<Uint8Array>;
|
|
107
|
-
|
|
190
|
+
// Termination in flight after kill(); aborted exits await it before reporting.
|
|
191
|
+
#terminating?: Promise<boolean | void>;
|
|
192
|
+
#terminateGroup: boolean;
|
|
193
|
+
#hardKillTree: boolean;
|
|
194
|
+
// Windows has no process groups. Retaining the root's native handle pins
|
|
195
|
+
// its PID after exit so killTree() can still enumerate its original children.
|
|
196
|
+
#windowsRootProcess?: Process;
|
|
108
197
|
constructor(
|
|
109
198
|
readonly proc: PipedSubprocess<In>,
|
|
110
199
|
readonly exposeStderr: boolean,
|
|
111
200
|
retainFullStderr = exposeStderr,
|
|
201
|
+
terminateGroup = false,
|
|
202
|
+
hardKillTree = false,
|
|
112
203
|
) {
|
|
204
|
+
this.#terminateGroup = terminateGroup;
|
|
205
|
+
this.#hardKillTree = hardKillTree;
|
|
206
|
+
this.#windowsRootProcess = process.platform === "win32" ? (Process.fromPid(proc.pid) ?? undefined) : undefined;
|
|
113
207
|
if (retainFullStderr) this.#stderrChunks = [];
|
|
114
208
|
// Eagerly drain stderr into a truncated tail, retaining raw chunks only for explicit full capture.
|
|
115
209
|
const dec = new TextDecoder();
|
|
@@ -123,22 +217,39 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
123
217
|
this.#stderrStream = teeStream;
|
|
124
218
|
stderrStream = drainStream;
|
|
125
219
|
}
|
|
220
|
+
// Normalize Bun's exited promise into our exitReason / exitedCleanly model.
|
|
221
|
+
const { promise, resolve, reject } = Promise.withResolvers<number>();
|
|
222
|
+
this.#exited = promise;
|
|
223
|
+
const drainCutoff = Promise.withResolvers<void>();
|
|
224
|
+
this.#drainCutoff = drainCutoff.promise;
|
|
225
|
+
this.#resolveDrainCutoff = drainCutoff.resolve;
|
|
226
|
+
// The cutoff remains pending for untimed commands, preserving complete
|
|
227
|
+
// EOF-based capture. attachTimeout() resolves it at the command deadline.
|
|
228
|
+
|
|
229
|
+
const pipeCutoff = this.#drainCutoff;
|
|
126
230
|
this.#stderrDone = (async () => {
|
|
231
|
+
const reader = stderrStream.getReader();
|
|
127
232
|
try {
|
|
128
|
-
for
|
|
129
|
-
|
|
130
|
-
|
|
233
|
+
for (;;) {
|
|
234
|
+
const chunk = await Promise.race([
|
|
235
|
+
reader.read().then(r => ({ cutoff: false as const, r })),
|
|
236
|
+
pipeCutoff.then(() => ({ cutoff: true as const })),
|
|
237
|
+
]);
|
|
238
|
+
if (chunk.cutoff) {
|
|
239
|
+
await reader.cancel().catch(() => {});
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
if (chunk.r.done) break;
|
|
243
|
+
this.#stderrChunks?.push(chunk.r.value);
|
|
244
|
+
this.#stderrTail += dec.decode(chunk.r.value, { stream: true });
|
|
131
245
|
trim();
|
|
132
246
|
}
|
|
133
247
|
} catch {}
|
|
248
|
+
this.#openPipeReaders--;
|
|
134
249
|
this.#stderrTail += dec.decode();
|
|
135
250
|
trim();
|
|
136
251
|
})();
|
|
137
252
|
|
|
138
|
-
// Normalize Bun's exited promise into our exitReason / exitedCleanly model.
|
|
139
|
-
const { promise, resolve, reject } = Promise.withResolvers<number>();
|
|
140
|
-
this.#exited = promise;
|
|
141
|
-
|
|
142
253
|
proc.exited
|
|
143
254
|
.catch(() => null)
|
|
144
255
|
.then(async exitCode => {
|
|
@@ -153,6 +264,11 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
153
264
|
}
|
|
154
265
|
|
|
155
266
|
await this.#stderrDone;
|
|
267
|
+
if (this.#exitReasonPending) {
|
|
268
|
+
this.#exitReason = this.#exitReasonPending;
|
|
269
|
+
reject(this.#exitReasonPending);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
156
272
|
|
|
157
273
|
if (exitCode !== null) {
|
|
158
274
|
this.#exitReason = new NonZeroExitError(exitCode, this.#stderrTail);
|
|
@@ -218,44 +334,160 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
218
334
|
}
|
|
219
335
|
|
|
220
336
|
kill(reason?: Exception, gracefulMs?: number) {
|
|
221
|
-
if (reason && !this.#exitReasonPending)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
337
|
+
if (reason && !this.#exitReasonPending) {
|
|
338
|
+
this.#exitReasonPending = reason;
|
|
339
|
+
// The normalized exit promise may already have resolved from a dead
|
|
340
|
+
// group leader; wait() still needs to report the later deadline.
|
|
341
|
+
if (this.proc.exitCode !== null) this.#exitReason = reason;
|
|
342
|
+
}
|
|
343
|
+
if (gracefulMs !== undefined && gracefulMs < 0 && this.#hardKillTree && this.proc.exitCode === null) {
|
|
344
|
+
// terminate() sends its polite wave to the root before rebuilding the
|
|
345
|
+
// hard-kill tree. A subreaper root can die in that gap and release its
|
|
346
|
+
// adopted descendants, so snapshot and hard-kill the live tree first.
|
|
347
|
+
const root = Process.fromPid(this.proc.pid);
|
|
348
|
+
if (root) {
|
|
349
|
+
root.killTree(9);
|
|
350
|
+
this.#terminating = Promise.resolve();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (
|
|
355
|
+
this.proc.exitCode !== null &&
|
|
356
|
+
this.#terminateGroup &&
|
|
357
|
+
this.#openPipeReaders > 0 &&
|
|
358
|
+
process.platform !== "win32"
|
|
359
|
+
) {
|
|
360
|
+
// Bun detached children are POSIX session/process-group leaders. If
|
|
361
|
+
// the leader has exited, the native Process handle cannot rediscover
|
|
362
|
+
// its PGID, but a pipe-holding descendant keeps that exact group alive.
|
|
363
|
+
try {
|
|
364
|
+
process.kill(-this.proc.pid, "SIGKILL");
|
|
365
|
+
} catch {}
|
|
366
|
+
this.#terminating = Promise.resolve();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (this.proc.exitCode !== null && this.#windowsRootProcess && this.#openPipeReaders > 0) {
|
|
370
|
+
// The retained handle keeps the dead root PID reserved, making the
|
|
371
|
+
// Windows Toolhelp descendant walk identity-safe after root exit.
|
|
372
|
+
this.#windowsRootProcess.killTree();
|
|
373
|
+
this.#terminating = Promise.resolve();
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (!this.proc.killed) {
|
|
377
|
+
const options =
|
|
378
|
+
gracefulMs === undefined
|
|
379
|
+
? this.#terminateGroup
|
|
380
|
+
? { group: true }
|
|
381
|
+
: undefined
|
|
382
|
+
: { gracefulMs, group: this.#terminateGroup };
|
|
383
|
+
this.#terminating = (this.#windowsRootProcess ?? Process.fromPid(this.proc.pid))
|
|
384
|
+
?.terminate(options)
|
|
225
385
|
?.catch(e => void e);
|
|
386
|
+
}
|
|
226
387
|
}
|
|
227
388
|
|
|
228
389
|
// ── Output helpers ───────────────────────────────────────────────────
|
|
229
390
|
|
|
391
|
+
async #throwIfAborted(): Promise<void> {
|
|
392
|
+
const exitReason = this.exitReason;
|
|
393
|
+
if (!exitReason?.aborted) return;
|
|
394
|
+
if (this.#terminating) await this.#terminating;
|
|
395
|
+
throw exitReason;
|
|
396
|
+
}
|
|
397
|
+
|
|
230
398
|
async text(): Promise<string> {
|
|
231
|
-
const p =
|
|
399
|
+
const p = this.#readStream(this.proc.stdout);
|
|
232
400
|
if (this.#nothrow) return p;
|
|
233
401
|
const [text] = await Promise.all([p, this.exitedCleanly]);
|
|
402
|
+
await this.#throwIfAborted();
|
|
234
403
|
return text;
|
|
235
404
|
}
|
|
236
405
|
|
|
237
|
-
|
|
238
|
-
|
|
406
|
+
/**
|
|
407
|
+
* Read a pipe fully, stopping early only at an explicit command deadline.
|
|
408
|
+
*/
|
|
409
|
+
async #readStream(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
410
|
+
this.#openPipeReaders++;
|
|
411
|
+
const reader = stream.getReader();
|
|
412
|
+
const dec = new TextDecoder();
|
|
413
|
+
let out = "";
|
|
414
|
+
try {
|
|
415
|
+
for (;;) {
|
|
416
|
+
const chunk = await Promise.race([
|
|
417
|
+
reader.read().then(r => ({ cutoff: false as const, r })),
|
|
418
|
+
this.#drainCutoff.then(() => ({ cutoff: true as const })),
|
|
419
|
+
]);
|
|
420
|
+
if (chunk.cutoff) {
|
|
421
|
+
await reader.cancel().catch(() => {});
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
if (chunk.r.done) break;
|
|
425
|
+
out += dec.decode(chunk.r.value, { stream: true });
|
|
426
|
+
}
|
|
427
|
+
} catch {
|
|
428
|
+
// A cancelled or failed read keeps whatever was already collected.
|
|
429
|
+
}
|
|
430
|
+
this.#openPipeReaders--;
|
|
431
|
+
return out + dec.decode();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async #readBytes(): Promise<Uint8Array> {
|
|
435
|
+
const reader = this.proc.stdout.getReader();
|
|
436
|
+
this.#openPipeReaders++;
|
|
437
|
+
const chunks: Uint8Array[] = [];
|
|
438
|
+
let length = 0;
|
|
439
|
+
try {
|
|
440
|
+
for (;;) {
|
|
441
|
+
const chunk = await Promise.race([
|
|
442
|
+
reader.read().then(r => ({ cutoff: false as const, r })),
|
|
443
|
+
this.#drainCutoff.then(() => ({ cutoff: true as const })),
|
|
444
|
+
]);
|
|
445
|
+
if (chunk.cutoff) {
|
|
446
|
+
await reader.cancel().catch(() => {});
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
if (chunk.r.done) break;
|
|
450
|
+
chunks.push(chunk.r.value);
|
|
451
|
+
length += chunk.r.value.byteLength;
|
|
452
|
+
}
|
|
453
|
+
} catch {
|
|
454
|
+
// A cancelled or failed read keeps whatever was already collected.
|
|
455
|
+
} finally {
|
|
456
|
+
this.#openPipeReaders--;
|
|
457
|
+
reader.releaseLock();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const bytes = new Uint8Array(length);
|
|
461
|
+
let offset = 0;
|
|
462
|
+
for (const chunk of chunks) {
|
|
463
|
+
bytes.set(chunk, offset);
|
|
464
|
+
offset += chunk.byteLength;
|
|
465
|
+
}
|
|
466
|
+
return bytes;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async #readOutputBytes(waitForCleanExit = false): Promise<Uint8Array> {
|
|
470
|
+
const p = this.#readBytes();
|
|
239
471
|
if (this.#nothrow) return p;
|
|
240
|
-
const
|
|
241
|
-
|
|
472
|
+
const bytes = waitForCleanExit ? (await Promise.all([p, this.exitedCleanly]))[0] : await p;
|
|
473
|
+
await this.#throwIfAborted();
|
|
474
|
+
return bytes;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async blob(): Promise<Blob> {
|
|
478
|
+
return new Blob([await this.#readOutputBytes(true)]);
|
|
242
479
|
}
|
|
243
480
|
|
|
244
481
|
async json(): Promise<unknown> {
|
|
245
|
-
return new
|
|
482
|
+
return JSON.parse(new TextDecoder().decode(await this.#readOutputBytes()));
|
|
246
483
|
}
|
|
247
484
|
|
|
248
485
|
async arrayBuffer(): Promise<ArrayBuffer> {
|
|
249
|
-
return
|
|
486
|
+
return (await this.#readOutputBytes()).buffer as ArrayBuffer;
|
|
250
487
|
}
|
|
251
488
|
|
|
252
489
|
async bytes(): Promise<Uint8Array> {
|
|
253
|
-
|
|
254
|
-
// stream emits more than one chunk (subprocess stdout chunks past ~128 KB).
|
|
255
|
-
// Normalize at the contract boundary so every caller — SSH read,
|
|
256
|
-
// `decodeUtf8Text`, callers slicing with `.subarray` — sees a `Uint8Array`.
|
|
257
|
-
const body = (await new Response(this.stdout).bytes()) as Uint8Array | ArrayBuffer;
|
|
258
|
-
return body instanceof Uint8Array ? body : new Uint8Array(body);
|
|
490
|
+
return this.#readOutputBytes();
|
|
259
491
|
}
|
|
260
492
|
|
|
261
493
|
// ── Wait ─────────────────────────────────────────────────────────────
|
|
@@ -267,7 +499,7 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
267
499
|
throw new Error('Full stderr capture must be requested when spawning the process (pass stderr: "full")');
|
|
268
500
|
}
|
|
269
501
|
|
|
270
|
-
const stdoutP =
|
|
502
|
+
const stdoutP = this.#readStream(this.proc.stdout);
|
|
271
503
|
const stderrP =
|
|
272
504
|
stderrMode === "full" && stderrChunks
|
|
273
505
|
? this.#stderrDone.then(() => new TextDecoder().decode(Buffer.concat(stderrChunks)))
|
|
@@ -282,12 +514,17 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
282
514
|
if (err instanceof Exception) exitError = err;
|
|
283
515
|
else throw err;
|
|
284
516
|
}
|
|
285
|
-
|
|
517
|
+
this.#clearTimeout();
|
|
286
518
|
if (!exitError) exitError = this.exitReason;
|
|
287
519
|
if (!exitError && this.exitCode !== null && this.exitCode !== 0) {
|
|
288
520
|
exitError = new NonZeroExitError(this.exitCode, this.#stderrTail);
|
|
289
521
|
}
|
|
290
522
|
|
|
523
|
+
// On abort/timeout, hold the result until the tree is actually gone: the
|
|
524
|
+
// native terminate() is graceful-first, and reporting before it finishes
|
|
525
|
+
// would leave timed-out descendants alive past the caller's budget.
|
|
526
|
+
if (exitError?.aborted && this.#terminating) await this.#terminating;
|
|
527
|
+
|
|
291
528
|
const exitCode = this.exitCode ?? (exitError && !exitError.aborted ? exitError.exitCode : null);
|
|
292
529
|
const ok = exitCode === 0;
|
|
293
530
|
|
|
@@ -307,18 +544,32 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
307
544
|
this.#exited.catch(() => {}).finally(() => signal.removeEventListener("abort", onAbort));
|
|
308
545
|
}
|
|
309
546
|
|
|
547
|
+
#clearTimeout(): void {
|
|
548
|
+
if (!this.#timeoutTimer) return;
|
|
549
|
+
clearTimeout(this.#timeoutTimer);
|
|
550
|
+
this.#timeoutTimer = undefined;
|
|
551
|
+
}
|
|
552
|
+
|
|
310
553
|
attachTimeout(ms: number): void {
|
|
311
554
|
if (ms <= 0 || this.proc.killed) return;
|
|
312
555
|
this.#exited.catch(() => {});
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
556
|
+
// One unref'd deadline controls both termination and pipe collection.
|
|
557
|
+
// A clean command clears it in wait(), so fast invocations do not hold
|
|
558
|
+
// the event loop for the unused remainder.
|
|
559
|
+
const timer = setTimeout(() => {
|
|
560
|
+
// A detached group can remain alive after its leader exits. Only use
|
|
561
|
+
// the dead-leader fallback while an inherited pipe proves that exact
|
|
562
|
+
// group still has a live member; this avoids stale-PGID reuse.
|
|
563
|
+
if (
|
|
564
|
+
this.proc.exitCode === null ||
|
|
565
|
+
(this.#openPipeReaders > 0 && (this.#terminateGroup || this.#windowsRootProcess))
|
|
566
|
+
) {
|
|
567
|
+
this.kill(new TimeoutError(ms, this.#stderrTail), -1);
|
|
568
|
+
}
|
|
569
|
+
this.#resolveDrainCutoff();
|
|
570
|
+
}, ms);
|
|
571
|
+
timer.unref?.();
|
|
572
|
+
this.#timeoutTimer = timer;
|
|
322
573
|
}
|
|
323
574
|
|
|
324
575
|
[Symbol.dispose](): void {
|
|
@@ -336,6 +587,13 @@ type ChildSpawnOptions<In extends InMask = InMask> = Omit<
|
|
|
336
587
|
> & {
|
|
337
588
|
signal?: AbortSignal;
|
|
338
589
|
detached?: boolean;
|
|
590
|
+
/**
|
|
591
|
+
* On Linux, supervise the command from a child subreaper so descendants
|
|
592
|
+
* remain reachable after changing session and reparenting. Other platforms
|
|
593
|
+
* ignore this option. macOS process groups cannot retain a daemonized
|
|
594
|
+
* descendant that creates a new session and reparents to launchd.
|
|
595
|
+
*/
|
|
596
|
+
subreaper?: boolean;
|
|
339
597
|
/** Expose and retain complete stderr for a later `wait({ stderr: "full" })`. */
|
|
340
598
|
stderr?: "full" | null;
|
|
341
599
|
};
|
|
@@ -345,15 +603,26 @@ function spawnInternal<In extends InMask = InMask>(
|
|
|
345
603
|
opts: ChildSpawnOptions<In> | undefined,
|
|
346
604
|
retainFullStderr: boolean,
|
|
347
605
|
): ChildProcess<In> {
|
|
348
|
-
const { timeout = -1, signal, stderr, ...rest } = opts ?? {};
|
|
349
|
-
const
|
|
606
|
+
const { timeout = -1, signal, stderr, detached, subreaper = false, ...rest } = opts ?? {};
|
|
607
|
+
const useSubreaper = subreaper && process.platform === "linux";
|
|
608
|
+
const commandEnv = rest.env ?? Bun.env;
|
|
609
|
+
const child = Bun.spawn(useSubreaper ? [process.execPath, "-e", LINUX_SUBREAPER_SCRIPT] : cmd, {
|
|
350
610
|
stdin: "ignore",
|
|
351
611
|
stdout: "pipe",
|
|
352
612
|
stderr: "pipe",
|
|
353
613
|
windowsHide: true,
|
|
614
|
+
detached,
|
|
354
615
|
...rest,
|
|
616
|
+
env: useSubreaper
|
|
617
|
+
? {
|
|
618
|
+
...commandEnv,
|
|
619
|
+
BUN_BE_BUN: "1",
|
|
620
|
+
[LINUX_SUBREAPER_COMMAND_ENV]: JSON.stringify(cmd),
|
|
621
|
+
[LINUX_SUBREAPER_BUN_BE_BUN_ENV]: commandEnv.BUN_BE_BUN,
|
|
622
|
+
}
|
|
623
|
+
: rest.env,
|
|
355
624
|
});
|
|
356
|
-
const cp = new ChildProcess(child, stderr === "full", retainFullStderr);
|
|
625
|
+
const cp = new ChildProcess(child, stderr === "full", retainFullStderr, detached === true, useSubreaper);
|
|
357
626
|
if (signal) cp.attachSignal(signal);
|
|
358
627
|
if (timeout > 0) cp.attachTimeout(timeout);
|
|
359
628
|
return cp;
|