@oh-my-pi/pi-utils 18.2.0 → 18.2.1
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 +16 -0
- package/dist/types/dirs.d.ts +7 -0
- package/dist/types/env.d.ts +8 -0
- package/dist/types/executable.d.ts +4 -0
- package/dist/types/fetch-retry.d.ts +8 -3
- package/dist/types/file-lock.d.ts +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mime.d.ts +1 -0
- package/dist/types/path.d.ts +6 -0
- package/dist/types/postmortem.d.ts +26 -5
- package/dist/types/procmgr.d.ts +2 -4
- package/dist/types/snowflake.d.ts +1 -0
- package/dist/types/stream.d.ts +45 -0
- package/dist/types/which.d.ts +6 -0
- package/package.json +2 -2
- package/src/acp/transport.ts +37 -3
- package/src/dirs.ts +10 -0
- package/src/env.ts +45 -0
- package/src/executable.ts +17 -0
- package/src/fetch-retry.ts +26 -17
- package/src/file-lock.ts +23 -0
- package/src/index.ts +1 -0
- package/src/logger.ts +2 -3
- package/src/mime.ts +3 -7
- package/src/path.ts +14 -0
- package/src/postmortem.ts +89 -41
- package/src/procmgr.ts +2 -12
- package/src/ptree.ts +36 -5
- package/src/snowflake.ts +12 -1
- package/src/stream.ts +89 -8
- package/src/which.ts +27 -1
- package/src/xml.ts +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.2.1] - 2026-09-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added the public `postmortem.exitProcess()` utility for host-owned hard exits that must bypass temporary process-exit guards ([#11789](https://github.com/can1357/oh-my-pi/issues/11789)).
|
|
10
|
+
- Added `readSseJsonOrText`: like `readSseJson`, but a `data:` frame that is not valid JSON is yielded as its raw text instead of raising a `SyntaxError`, so a consumer can classify a reverse proxy's plain-text throttle page (`429 Too Many Requests`) that arrives after the stream headers were already sent. `readSseJson` is unchanged and shares the framing with it.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Reading an EPUB, PPTX or XLSX whose XML has a mismatched or stray end tag no longer hangs the session forever; the parser recovers and the document converts ([#12018](https://github.com/can1357/oh-my-pi/pull/12018) by [@kaluli123123](https://github.com/kaluli123123)).
|
|
15
|
+
- Fixed `filterChildShellEnv` forwarding the host process's `GIT_DIR`, `GIT_WORK_TREE`, and related repo-location overrides to child shells, where `git` would ignore the command's `cwd`.
|
|
16
|
+
- ACP JSON-RPC now drains accepted inbound requests on clean stdin EOF before resolving `closed`, so in-flight methods such as `session/new` still receive a success or explicit error response instead of being dropped on exit 0 ([#11567](https://github.com/can1357/oh-my-pi/issues/11567)).
|
|
17
|
+
- Fixed provider-local usage-limit reset timestamps making `waitForUsageReset` sessions resume up to eight hours late while preserving longest-window semantics for naive UTC timestamps ([#11014](https://github.com/can1357/oh-my-pi/issues/11014)).
|
|
18
|
+
- `registerStdioDisconnectHandling()` now drives graceful shutdown from `process.stdout`'s own `error` event, so a closed stdout consumer exits cleanly while an unrelated write EPIPE (subprocess stdin, socket) stays fatal ([#10930](https://github.com/can1357/oh-my-pi/issues/10930)).
|
|
19
|
+
- Fixed Linux `ptree` timeout cleanup occasionally leaving session-escaped descendants running during subreaper adoption.
|
|
20
|
+
|
|
5
21
|
## [18.2.0] - 2026-09-15
|
|
6
22
|
|
|
7
23
|
### Breaking Changes
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -281,6 +281,13 @@ export declare function getAgentModulesDir(agentDir?: string): string;
|
|
|
281
281
|
export declare function getMemoriesDir(agentDir?: string): string;
|
|
282
282
|
/** Get the terminal sessions directory (~/.omp/agent/terminal-sessions). */
|
|
283
283
|
export declare function getTerminalSessionsDir(agentDir?: string): string;
|
|
284
|
+
/**
|
|
285
|
+
* Get the persistent registry of custom session files
|
|
286
|
+
* (~/.omp/agent/custom-session-files). Each `--session-dir`/`--session`
|
|
287
|
+
* transcript is recorded here as one marker file so storage GC can scan its
|
|
288
|
+
* exact path after its terminal breadcrumb is overwritten by a later session.
|
|
289
|
+
*/
|
|
290
|
+
export declare function getCustomSessionFilesDir(agentDir?: string): string;
|
|
284
291
|
/** Get the crash log path (~/.omp/agent/omp-crash.log). */
|
|
285
292
|
export declare function getCrashLogPath(agentDir?: string): string;
|
|
286
293
|
/** Get the debug log path (~/.omp/agent/omp-debug.log). */
|
package/dist/types/env.d.ts
CHANGED
|
@@ -26,6 +26,14 @@ export declare function isMacosMallocStackLoggingEnvName(name: string): boolean;
|
|
|
26
26
|
*/
|
|
27
27
|
export declare function isWsl(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): boolean;
|
|
28
28
|
export declare function filterProcessEnv(env: Record<string, string | undefined>): Record<string, string>;
|
|
29
|
+
/**
|
|
30
|
+
* Removes {@link GIT_REPO_LOCATION_ENV_NAMES} from a copied child env in place.
|
|
31
|
+
*
|
|
32
|
+
* Windows environment lookups are case-insensitive, so a block that spells a
|
|
33
|
+
* variable `git_dir` is just as binding there; match case-insensitively on
|
|
34
|
+
* win32 and exactly elsewhere (POSIX env names are case-sensitive).
|
|
35
|
+
*/
|
|
36
|
+
export declare function stripGitRepoLocationEnv(env: Record<string, string>, platform?: NodeJS.Platform): void;
|
|
29
37
|
/** Filters process env for child shells without launch-cwd dotenv values. */
|
|
30
38
|
export declare function filterChildShellEnv(env: Record<string, string | undefined>, cwd?: string): Record<string, string>;
|
|
31
39
|
/**
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** Provider-specific interpretation for timezone-naive retry timestamps. */
|
|
2
|
+
export interface RetryHintOptions {
|
|
3
|
+
/** UTC offset appended to an absolute reset stamp that omits its timezone. */
|
|
4
|
+
naiveResetTimezoneOffset?: string;
|
|
5
|
+
}
|
|
1
6
|
/**
|
|
2
7
|
* Server-suggested retry delay extraction. Merges the patterns historically used
|
|
3
8
|
*
|
|
@@ -15,14 +20,14 @@
|
|
|
15
20
|
* - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
|
|
16
21
|
* - `retry-after-ms=98497000` / `retry-after-ms: 7200000` / `retry-after-ms = 7200000`
|
|
17
22
|
* - `Your limit will reset at 2026-09-01 09:44:51` / `将在 2026-09-01 09:44:51 重置`
|
|
18
|
-
* (offset
|
|
19
|
-
*
|
|
23
|
+
* (a provider offset makes a naive wall clock authoritative; otherwise it
|
|
24
|
+
* resolves only when no relative signal is present)
|
|
20
25
|
*
|
|
21
26
|
* Returns `undefined` if no signal is found, or `0` when the provider
|
|
22
27
|
* explicitly asks for an immediate retry (`retry-after…=0`, or an absolute
|
|
23
28
|
* reset timestamp that has already elapsed).
|
|
24
29
|
*/
|
|
25
|
-
export declare function extractRetryHint(source: Response | Headers | null | undefined, body?: string): number | undefined;
|
|
30
|
+
export declare function extractRetryHint(source: Response | Headers | null | undefined, body?: string, options?: RetryHintOptions): number | undefined;
|
|
26
31
|
export interface FetchWithRetryOptions extends RequestInit {
|
|
27
32
|
/** Total fetch attempts (initial + retries). Default `5`. */
|
|
28
33
|
maxAttempts?: number;
|
|
@@ -16,6 +16,8 @@ declare function tryAcquireLock(lockPath: string): NativeFileLock | null;
|
|
|
16
16
|
export declare function acquireFileLock(filePath: string, options?: FileLockOptions): Promise<FileLockHandle>;
|
|
17
17
|
/** Run `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
18
18
|
export declare function withFileLock<T>(filePath: string, fn: () => Promise<T>, options?: FileLockOptions): Promise<T>;
|
|
19
|
+
/** Run synchronous `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
20
|
+
export declare function withFileLockSync<T>(filePath: string, fn: () => T, options?: FileLockOptions): T;
|
|
19
21
|
/**
|
|
20
22
|
* Test-only acquisition handle for forcing ownership handoffs. This is not
|
|
21
23
|
* part of the supported package API.
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/mime.d.ts
CHANGED
package/dist/types/path.d.ts
CHANGED
|
@@ -2,3 +2,9 @@
|
|
|
2
2
|
export declare function windowsPathToWslMount(filePath: string): string | undefined;
|
|
3
3
|
/** Removes Win32 extended-length prefixes before passing paths to Bun APIs. */
|
|
4
4
|
export declare function stripWindowsExtendedLengthPathPrefix(filePath: string, platform?: NodeJS.Platform): string;
|
|
5
|
+
/**
|
|
6
|
+
* Test whether a path is fully qualified and drive-independent.
|
|
7
|
+
* On Windows, requires a drive letter with separator (e.g. `C:\`) or UNC (`\\server\share` or `//server/share`).
|
|
8
|
+
* On POSIX, requires an absolute path.
|
|
9
|
+
*/
|
|
10
|
+
export declare function isFullyQualifiedPath(filePath: string, platform?: NodeJS.Platform): boolean;
|
|
@@ -27,6 +27,27 @@ export declare enum Reason {
|
|
|
27
27
|
* instances across bundles/realms.
|
|
28
28
|
*/
|
|
29
29
|
export declare const NATIVE_PROCESS_EXIT: unique symbol;
|
|
30
|
+
/**
|
|
31
|
+
* Hard-exit the process through the native primitive, resolved on every call.
|
|
32
|
+
*
|
|
33
|
+
* The native exit is deliberately re-resolved here rather than bound at module
|
|
34
|
+
* load: the extension/hook loader's `withHostGuard` transiently swaps
|
|
35
|
+
* `process.reallyExit`/`process.exit` for a stub that throws
|
|
36
|
+
* `ExtensionExitError`, and the shipped bundle defers this module's evaluation
|
|
37
|
+
* until first access — which can land inside that guard window, so binding at
|
|
38
|
+
* init could freeze the throwing stub forever and turn every later shutdown
|
|
39
|
+
* (SIGHUP/SIGINT/fatal) into an unhandled-rejection loop (#7393). When the
|
|
40
|
+
* guard is active the stub carries the native exit under
|
|
41
|
+
* {@link NATIVE_PROCESS_EXIT} (#6488).
|
|
42
|
+
*
|
|
43
|
+
* Both globals are reinstalled to their natives before exiting: Bun's
|
|
44
|
+
* `process.exit` re-reads `process.reallyExit` at call time, so exiting through
|
|
45
|
+
* one primitive while its sibling still holds the throwing stub re-enters the
|
|
46
|
+
* guard and loops the rejection storm (#11789). After restoring, `reallyExit`
|
|
47
|
+
* (the low-level primitive) is preferred; `process.exit` and finally `SIGKILL`
|
|
48
|
+
* are fallbacks so a poisoned or absent chain can never leave the process alive.
|
|
49
|
+
*/
|
|
50
|
+
export declare function exitProcess(code: number): never;
|
|
30
51
|
/** User-facing command printed before fatal cleanup so interrupted work can be resumed. */
|
|
31
52
|
export interface FatalRecoveryHint {
|
|
32
53
|
/** Stable label identifying the recoverable session or process. */
|
|
@@ -91,11 +112,11 @@ export declare function isWorkerIpcDeserializeError(err: unknown): boolean;
|
|
|
91
112
|
*/
|
|
92
113
|
export declare function registerWorkerIpcFaultHandler(handler: (err: Error) => void): () => void;
|
|
93
114
|
/**
|
|
94
|
-
* Treat
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
115
|
+
* Treat a closed stdout consumer as a graceful peer disconnect for the caller's
|
|
116
|
+
* active lifetime. Attaches one shared `process.stdout` `error` listener,
|
|
117
|
+
* ref-counted across registrants (the ACP protocol server, the one-shot CLI
|
|
118
|
+
* entry). The returned callback removes the registration; the listener detaches
|
|
119
|
+
* when the last registrant unregisters.
|
|
99
120
|
*/
|
|
100
121
|
export declare function registerStdioDisconnectHandling(): () => void;
|
|
101
122
|
/**
|
package/dist/types/procmgr.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Subprocess } from "bun";
|
|
2
|
+
import { isExecutable } from "./executable.js";
|
|
3
|
+
export { isExecutable };
|
|
2
4
|
export interface ShellConfig {
|
|
3
5
|
shell: string;
|
|
4
6
|
args: string[];
|
|
@@ -10,10 +12,6 @@ export interface ShellConfigOptions {
|
|
|
10
12
|
/** File path or runtime layer that supplied the active shell setting. */
|
|
11
13
|
configSource?: string;
|
|
12
14
|
}
|
|
13
|
-
/**
|
|
14
|
-
* Check if a shell binary is executable.
|
|
15
|
-
*/
|
|
16
|
-
export declare function isExecutable(path: string): boolean;
|
|
17
15
|
/**
|
|
18
16
|
* Get shell args for the resolved shell.
|
|
19
17
|
* cmd.exe takes `/c`; PowerShell (powershell.exe / pwsh) takes
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
export declare function readLines(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<Uint8Array>;
|
|
2
2
|
export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<T>;
|
|
3
|
+
/**
|
|
4
|
+
* Amortized byte accumulator for chunked stream readers.
|
|
5
|
+
*
|
|
6
|
+
* Holds the unconsumed tail of a stream in a single growing `Buffer` so that
|
|
7
|
+
* appending N chunks costs O(total bytes) instead of re-copying the whole
|
|
8
|
+
* prefix per chunk. Backs {@link readLines}, {@link readJsonl} and
|
|
9
|
+
* {@link readSseEvents}; also usable directly when a reader needs its own
|
|
10
|
+
* framing loop (see `consume` and `flush`).
|
|
11
|
+
*/
|
|
12
|
+
export declare class ConcatSink {
|
|
13
|
+
#private;
|
|
14
|
+
append(chunk: Uint8Array): void;
|
|
15
|
+
reset(chunk: Uint8Array): void;
|
|
16
|
+
get isEmpty(): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* The buffered bytes as a live view — invalidated by the next `append`,
|
|
19
|
+
* `reset` or `consume`.
|
|
20
|
+
*/
|
|
21
|
+
flush(): Uint8Array | undefined;
|
|
22
|
+
/** Drop the first `count` buffered bytes, keeping the remainder. */
|
|
23
|
+
consume(count: number): void;
|
|
24
|
+
clear(): void;
|
|
25
|
+
appendAndFlushLines(chunk: Uint8Array): Generator<Uint8Array<ArrayBufferLike>, void, unknown>;
|
|
26
|
+
appendAndFlushText(chunk: Uint8Array, decoder: TextDecoder): string | undefined;
|
|
27
|
+
pullJSONL<T>(chunk: Uint8Array, beg: number, end: number): Generator<T, void, unknown>;
|
|
28
|
+
}
|
|
3
29
|
/**
|
|
4
30
|
* Stream parsed JSON objects from SSE `data:` lines.
|
|
5
31
|
*
|
|
@@ -18,6 +44,25 @@ export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?
|
|
|
18
44
|
*/
|
|
19
45
|
export type SseEventObserver = (event: ServerSentEvent) => void;
|
|
20
46
|
export declare function readSseJson<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver): AsyncGenerator<T>;
|
|
47
|
+
/**
|
|
48
|
+
* Like {@link readSseJson}, but a `data:` frame that is not valid JSON is yielded
|
|
49
|
+
* as its raw text instead of raising a `SyntaxError`. Cut-off container-shaped
|
|
50
|
+
* stream tails stay recoverable, exactly as they are in {@link readSseJson}.
|
|
51
|
+
*
|
|
52
|
+
* Consumers that only understand objects must treat a `string` yield as a
|
|
53
|
+
* transport-level failure (for example a `429 Too Many Requests` or an HTML
|
|
54
|
+
* throttle page from a reverse proxy that already committed to the stream). This
|
|
55
|
+
* exists because `readSseJson`'s baseline consumers span unrelated transports
|
|
56
|
+
* whose error handling a text yield would subtly change; new call sites opt in.
|
|
57
|
+
*
|
|
58
|
+
* Note that the text lane is only the frames `JSON.parse` *rejected*: a frame
|
|
59
|
+
* carrying a JSON-encoded string (`data: "429 Too Many Requests"`) parses, so it
|
|
60
|
+
* is yielded as that string and is indistinguishable from a rejected frame by
|
|
61
|
+
* type alone. Consumers branching on `typeof === "string"` therefore see both,
|
|
62
|
+
* which is the safe direction — each is classified as text rather than trusted as
|
|
63
|
+
* an event object.
|
|
64
|
+
*/
|
|
65
|
+
export declare function readSseJsonOrText<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver): AsyncGenerator<T | string>;
|
|
21
66
|
/**
|
|
22
67
|
* A single Server-Sent Event dispatched on a blank-line boundary.
|
|
23
68
|
*
|
package/dist/types/which.d.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface WhichOptions extends Bun.WhichOptions {
|
|
|
25
25
|
* Defaults to `WhichCachePolicy.Fresh`.
|
|
26
26
|
*/
|
|
27
27
|
cache?: WhichCachePolicy;
|
|
28
|
+
/**
|
|
29
|
+
* Only search absolute directory entries in PATH, ignoring relative entries
|
|
30
|
+
* (e.g. `.` or `./bin`) and empty components to prevent resolving against
|
|
31
|
+
* an untrusted working directory.
|
|
32
|
+
*/
|
|
33
|
+
requireAbsolutePaths?: boolean;
|
|
28
34
|
}
|
|
29
35
|
declare function darwinWhich(command: string, options?: Bun.WhichOptions): string | null;
|
|
30
36
|
export declare const whichFresh: typeof darwinWhich;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-utils",
|
|
3
|
-
"version": "18.2.
|
|
3
|
+
"version": "18.2.1",
|
|
4
4
|
"description": "Shared utilities for pi packages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@oh-my-pi/pi-natives": "18.2.
|
|
57
|
+
"@oh-my-pi/pi-natives": "18.2.1"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/bun": "^1.3.14"
|
package/src/acp/transport.ts
CHANGED
|
@@ -108,10 +108,15 @@ function createStandardError(
|
|
|
108
108
|
type Dispatcher = (method: string, params: unknown, notification: boolean) => MaybePromise<unknown>;
|
|
109
109
|
type Pending = { resolve(value: unknown): void; reject(reason: unknown): void };
|
|
110
110
|
|
|
111
|
+
/** Bound on clean-EOF inbound drain so `closed` cannot hang if a handler never settles. */
|
|
112
|
+
const INBOUND_DRAIN_TIMEOUT_MS = 30_000;
|
|
113
|
+
|
|
111
114
|
/** Correlated bidirectional JSON-RPC connection. */
|
|
112
115
|
export class RpcConnection {
|
|
113
116
|
#nextId = 0;
|
|
114
117
|
#pending = new Map<JsonRpcId, Pending>();
|
|
118
|
+
#inbound = new Set<Promise<void>>();
|
|
119
|
+
#openInboundIds = new Set<JsonRpcId>();
|
|
115
120
|
#writable: WritableStream<AnyMessage>;
|
|
116
121
|
#writeTail: Promise<void> = Promise.resolve();
|
|
117
122
|
#abort = new AbortController();
|
|
@@ -179,8 +184,9 @@ export class RpcConnection {
|
|
|
179
184
|
while (true) {
|
|
180
185
|
const next = await reader.read();
|
|
181
186
|
if (next.done) break;
|
|
182
|
-
|
|
187
|
+
this.#dispatch(next.value);
|
|
183
188
|
}
|
|
189
|
+
await this.#drainInbound();
|
|
184
190
|
this.close();
|
|
185
191
|
} catch (error) {
|
|
186
192
|
this.close(error);
|
|
@@ -189,6 +195,34 @@ export class RpcConnection {
|
|
|
189
195
|
}
|
|
190
196
|
}
|
|
191
197
|
|
|
198
|
+
#dispatch(message: AnyMessage): void {
|
|
199
|
+
if ("method" in message && "id" in message) this.#openInboundIds.add(message.id);
|
|
200
|
+
const task = this.#handle(message).catch(error => this.close(error));
|
|
201
|
+
this.#inbound.add(task);
|
|
202
|
+
void task.finally(() => this.#inbound.delete(task));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async #drainInbound(): Promise<void> {
|
|
206
|
+
if (this.#inbound.size === 0) return;
|
|
207
|
+
const drained = Promise.allSettled(this.#inbound).then(() => {});
|
|
208
|
+
let timer: Timer | undefined;
|
|
209
|
+
const timedOut = await Promise.race([
|
|
210
|
+
drained.then(() => false),
|
|
211
|
+
new Promise<boolean>(resolve => {
|
|
212
|
+
timer = setTimeout(() => resolve(true), INBOUND_DRAIN_TIMEOUT_MS);
|
|
213
|
+
}),
|
|
214
|
+
]);
|
|
215
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
216
|
+
if (!timedOut) return;
|
|
217
|
+
const error = RequestError.internalError(undefined, "Inbound request drain timed out").toErrorResponse();
|
|
218
|
+
await Promise.allSettled([...this.#openInboundIds].map(id => this.#respond(id, { error })));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#respond(id: JsonRpcId, body: { result: unknown } | { error: ErrorResponse }): Promise<void> {
|
|
222
|
+
if (!this.#openInboundIds.delete(id)) return Promise.resolve();
|
|
223
|
+
return this.#write({ jsonrpc: "2.0", id, ...body });
|
|
224
|
+
}
|
|
225
|
+
|
|
192
226
|
async #handle(message: AnyMessage): Promise<void> {
|
|
193
227
|
if ("id" in message && !("method" in message)) {
|
|
194
228
|
const pending = this.#pending.get(message.id);
|
|
@@ -210,13 +244,13 @@ export class RpcConnection {
|
|
|
210
244
|
}
|
|
211
245
|
try {
|
|
212
246
|
const result = await this.#dispatcher(message.method, message.params, false);
|
|
213
|
-
await this.#
|
|
247
|
+
await this.#respond(message.id, { result: result ?? {} });
|
|
214
248
|
} catch (error) {
|
|
215
249
|
const protocolError =
|
|
216
250
|
error instanceof RequestError
|
|
217
251
|
? error
|
|
218
252
|
: RequestError.internalError({ details: error instanceof Error ? error.message : String(error) });
|
|
219
|
-
await this.#
|
|
253
|
+
await this.#respond(message.id, { error: protocolError.toErrorResponse() });
|
|
220
254
|
}
|
|
221
255
|
}
|
|
222
256
|
}
|
package/src/dirs.ts
CHANGED
|
@@ -931,6 +931,16 @@ export function getTerminalSessionsDir(agentDir?: string): string {
|
|
|
931
931
|
return dirs.agentSubdir(agentDir, "terminal-sessions", "state");
|
|
932
932
|
}
|
|
933
933
|
|
|
934
|
+
/**
|
|
935
|
+
* Get the persistent registry of custom session files
|
|
936
|
+
* (~/.omp/agent/custom-session-files). Each `--session-dir`/`--session`
|
|
937
|
+
* transcript is recorded here as one marker file so storage GC can scan its
|
|
938
|
+
* exact path after its terminal breadcrumb is overwritten by a later session.
|
|
939
|
+
*/
|
|
940
|
+
export function getCustomSessionFilesDir(agentDir?: string): string {
|
|
941
|
+
return dirs.agentSubdir(agentDir, "custom-session-files", "state");
|
|
942
|
+
}
|
|
943
|
+
|
|
934
944
|
/** Get the crash log path (~/.omp/agent/omp-crash.log). */
|
|
935
945
|
export function getCrashLogPath(agentDir?: string): string {
|
|
936
946
|
return dirs.agentSubdir(agentDir, "omp-crash.log", "state");
|
package/src/env.ts
CHANGED
|
@@ -65,6 +65,48 @@ export function filterProcessEnv(env: Record<string, string | undefined>): Recor
|
|
|
65
65
|
}
|
|
66
66
|
return result;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Git variables that pin a repository location. They describe the checkout the
|
|
70
|
+
* agent process itself was launched from (git hooks, `git --git-dir` wrappers),
|
|
71
|
+
* so forwarding them to a child shell makes `git` ignore the command's `cwd`
|
|
72
|
+
* and mutate the wrong worktree or index. Stripped from child shell envs so git
|
|
73
|
+
* rediscovers the repository from the working directory. Mirrors the
|
|
74
|
+
* `env_remove` list in `crates/pi-vcs/src/git/cli.rs`.
|
|
75
|
+
*/
|
|
76
|
+
const GIT_REPO_LOCATION_ENV_NAMES = [
|
|
77
|
+
"GIT_DIR",
|
|
78
|
+
"GIT_COMMON_DIR",
|
|
79
|
+
"GIT_WORK_TREE",
|
|
80
|
+
"GIT_INDEX_FILE",
|
|
81
|
+
"GIT_OBJECT_DIRECTORY",
|
|
82
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
83
|
+
] as const;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Removes {@link GIT_REPO_LOCATION_ENV_NAMES} from a copied child env in place.
|
|
87
|
+
*
|
|
88
|
+
* Windows environment lookups are case-insensitive, so a block that spells a
|
|
89
|
+
* variable `git_dir` is just as binding there; match case-insensitively on
|
|
90
|
+
* win32 and exactly elsewhere (POSIX env names are case-sensitive).
|
|
91
|
+
*/
|
|
92
|
+
export function stripGitRepoLocationEnv(
|
|
93
|
+
env: Record<string, string>,
|
|
94
|
+
platform: NodeJS.Platform = process.platform,
|
|
95
|
+
): void {
|
|
96
|
+
if (platform !== "win32") {
|
|
97
|
+
for (const name of GIT_REPO_LOCATION_ENV_NAMES) {
|
|
98
|
+
delete env[name];
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const folded = new Set<string>(GIT_REPO_LOCATION_ENV_NAMES.map(name => name.toLowerCase()));
|
|
103
|
+
for (const key of Object.keys(env)) {
|
|
104
|
+
if (folded.has(key.toLowerCase())) {
|
|
105
|
+
delete env[key];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
68
110
|
// Bun autoloads the project's dotenv files into `process.env` before user code
|
|
69
111
|
// runs — including inside `bun build --compile` binaries — so a snapshot of
|
|
70
112
|
// `Bun.env` is only pre-dotenv when autoloading was explicitly disabled. Linux
|
|
@@ -181,6 +223,9 @@ export function filterChildShellEnv(
|
|
|
181
223
|
delete result[key];
|
|
182
224
|
}
|
|
183
225
|
}
|
|
226
|
+
// Last, after dotenv merging: no source (inherited, launcher, or dotenv) may
|
|
227
|
+
// pin the child shell to the agent's own repository.
|
|
228
|
+
stripGitRepoLocationEnv(result);
|
|
184
229
|
return result;
|
|
185
230
|
}
|
|
186
231
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check if a file path exists, is a regular file, and has effective execute permission.
|
|
5
|
+
*/
|
|
6
|
+
export function isExecutable(filePath: string): boolean {
|
|
7
|
+
try {
|
|
8
|
+
const stat = fs.statSync(filePath);
|
|
9
|
+
if (!stat.isFile()) return false;
|
|
10
|
+
if (process.platform !== "win32") {
|
|
11
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
12
|
+
}
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/fetch-retry.ts
CHANGED
|
@@ -20,7 +20,11 @@ const RESET_IN_HR_MIN_PATTERN = /resets?\s+in\s+~?\s*(\d+(?:\.\d+)?)\s*hr\s*(\d+
|
|
|
20
20
|
// "Your limit will reset at 2026-09-01 09:44:51" / "reset at 2026-09-01T09:44:51Z"
|
|
21
21
|
const WILL_RESET_AT_PATTERN =
|
|
22
22
|
/(?:will\s+)?reset at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:?[0-9]{2})?)/i;
|
|
23
|
+
// Both grammars carry a timezone-naive wall clock. The default reading is UTC;
|
|
24
|
+
// a provider-specific offset (Z.AI/Zhipu Beijing time) is applied only through
|
|
25
|
+
// `RetryHintOptions.naiveResetTimezoneOffset`, never inferred from the language.
|
|
23
26
|
const CN_RESET_AT_PATTERN = /将在\s*([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-9]{2}:[0-9]{2})\s*重置/;
|
|
27
|
+
const RESET_AT_PATTERNS: readonly RegExp[] = [WILL_RESET_AT_PATTERN, CN_RESET_AT_PATTERN];
|
|
24
28
|
// "retry-after-ms=98497000" / "retry-after-ms: 7200000" / "retry-after-ms = 7200000"
|
|
25
29
|
const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
|
|
26
30
|
|
|
@@ -34,6 +38,12 @@ const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
|
|
|
34
38
|
// ignores the naive stamp) sleeps first, and the retry after it is the
|
|
35
39
|
// probe — success proves skew, a fresh 429 re-anchors with live timing.
|
|
36
40
|
|
|
41
|
+
/** Provider-specific interpretation for timezone-naive retry timestamps. */
|
|
42
|
+
export interface RetryHintOptions {
|
|
43
|
+
/** UTC offset appended to an absolute reset stamp that omits its timezone. */
|
|
44
|
+
naiveResetTimezoneOffset?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
/**
|
|
38
48
|
* Server-suggested retry delay extraction. Merges the patterns historically used
|
|
39
49
|
*
|
|
@@ -51,14 +61,18 @@ const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
|
|
|
51
61
|
* - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
|
|
52
62
|
* - `retry-after-ms=98497000` / `retry-after-ms: 7200000` / `retry-after-ms = 7200000`
|
|
53
63
|
* - `Your limit will reset at 2026-09-01 09:44:51` / `将在 2026-09-01 09:44:51 重置`
|
|
54
|
-
* (offset
|
|
55
|
-
*
|
|
64
|
+
* (a provider offset makes a naive wall clock authoritative; otherwise it
|
|
65
|
+
* resolves only when no relative signal is present)
|
|
56
66
|
*
|
|
57
67
|
* Returns `undefined` if no signal is found, or `0` when the provider
|
|
58
68
|
* explicitly asks for an immediate retry (`retry-after…=0`, or an absolute
|
|
59
69
|
* reset timestamp that has already elapsed).
|
|
60
70
|
*/
|
|
61
|
-
export function extractRetryHint(
|
|
71
|
+
export function extractRetryHint(
|
|
72
|
+
source: Response | Headers | null | undefined,
|
|
73
|
+
body?: string,
|
|
74
|
+
options?: RetryHintOptions,
|
|
75
|
+
): number | undefined {
|
|
62
76
|
const headers = source instanceof Headers ? source : (source?.headers ?? undefined);
|
|
63
77
|
if (headers) {
|
|
64
78
|
const retryAfterMs = headers.get("retry-after-ms");
|
|
@@ -142,25 +156,20 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
|
|
|
142
156
|
consider(totalMs > 0 ? totalMs : undefined);
|
|
143
157
|
}
|
|
144
158
|
}
|
|
145
|
-
for (const pattern of
|
|
159
|
+
for (const pattern of RESET_AT_PATTERNS) {
|
|
146
160
|
const match = pattern.exec(body);
|
|
147
161
|
if (!match?.[1]) continue;
|
|
148
162
|
// Offset-bearing stamps are unambiguous and compete by longest-wins.
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
163
|
+
// A configured provider offset makes an otherwise naive wall clock
|
|
164
|
+
// unambiguous too. Without one, preserve the relative-signal-first
|
|
165
|
+
// fallback and interpret the wall clock as UTC only when it stands alone.
|
|
152
166
|
const normalized = match[1].replace(" ", "T");
|
|
153
167
|
const hasOffset = /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/i.test(normalized);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
} else {
|
|
160
|
-
const parsed = Date.parse(`${normalized}Z`);
|
|
161
|
-
if (!Number.isNaN(parsed) && parsed > Date.now()) {
|
|
162
|
-
considerNaive(parsed - Date.now());
|
|
163
|
-
}
|
|
168
|
+
const configuredOffset = options?.naiveResetTimezoneOffset;
|
|
169
|
+
const parsed = Date.parse(hasOffset ? normalized : `${normalized}${configuredOffset ?? "Z"}`);
|
|
170
|
+
if (!Number.isNaN(parsed) && parsed > Date.now()) {
|
|
171
|
+
if (hasOffset || configuredOffset !== undefined) consider(parsed - Date.now());
|
|
172
|
+
else considerNaive(parsed - Date.now());
|
|
164
173
|
}
|
|
165
174
|
}
|
|
166
175
|
// OpenCode Go compound remainder ("Resets in 2hr 15min"): the generic
|
package/src/file-lock.ts
CHANGED
|
@@ -48,6 +48,19 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
48
48
|
throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function acquireLockSync(filePath: string, options: FileLockOptions = {}): NativeFileLock {
|
|
52
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
53
|
+
const lockPath = getLockPath(filePath);
|
|
54
|
+
|
|
55
|
+
for (let attempt = 0; attempt < opts.retries; attempt++) {
|
|
56
|
+
const lock = tryAcquireLock(lockPath);
|
|
57
|
+
if (lock) return lock;
|
|
58
|
+
if (attempt + 1 < opts.retries && opts.retryDelayMs > 0) Bun.sleepSync(opts.retryDelayMs);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
/** Run `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
52
65
|
export async function withFileLock<T>(
|
|
53
66
|
filePath: string,
|
|
@@ -62,6 +75,16 @@ export async function withFileLock<T>(
|
|
|
62
75
|
}
|
|
63
76
|
}
|
|
64
77
|
|
|
78
|
+
/** Run synchronous `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
79
|
+
export function withFileLockSync<T>(filePath: string, fn: () => T, options: FileLockOptions = {}): T {
|
|
80
|
+
const lock = acquireLockSync(filePath, options);
|
|
81
|
+
try {
|
|
82
|
+
return fn();
|
|
83
|
+
} finally {
|
|
84
|
+
lock.release();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
/**
|
|
66
89
|
* Test-only acquisition handle for forcing ownership handoffs. This is not
|
|
67
90
|
* part of the supported package API.
|
package/src/index.ts
CHANGED
package/src/logger.ts
CHANGED
|
@@ -289,12 +289,11 @@ function getLocalTransports(): LocalTransports {
|
|
|
289
289
|
|
|
290
290
|
function emitLocally(level: LogLevel, message: string, context: Record<string, unknown> | undefined): void {
|
|
291
291
|
const transports = getLocalTransports();
|
|
292
|
-
const info = normalizeLogInfo(level, message, context);
|
|
293
292
|
if (!transports.file && !transports.console) return;
|
|
294
|
-
|
|
293
|
+
const info = normalizeLogInfo(level, message, context);
|
|
295
294
|
const line = formatLogInfo(info);
|
|
296
295
|
if (transports.file) transports.file.write(line);
|
|
297
|
-
if (transports.console) fs.writeSync(1, `${
|
|
296
|
+
if (transports.console) fs.writeSync(1, `${line}${os.EOL}`);
|
|
298
297
|
}
|
|
299
298
|
|
|
300
299
|
/**
|
package/src/mime.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { peekFile, peekFileSync } from "./peek-file";
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
|
|
3
|
+
export const IMAGE_METADATA_HEADER_BYTES = 256 * 1024;
|
|
5
4
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
6
5
|
const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
|
|
7
6
|
const WEBP_RIFF_MAGIC = Buffer.from([0x52, 0x49, 0x46, 0x46]);
|
|
@@ -144,16 +143,13 @@ export function parseImageMetadata(header: Uint8Array): ImageMetadata | null {
|
|
|
144
143
|
);
|
|
145
144
|
}
|
|
146
145
|
|
|
147
|
-
export function readImageMetadataSync(
|
|
148
|
-
filePath: string,
|
|
149
|
-
maxBytes = DEFAULT_IMAGE_METADATA_HEADER_BYTES,
|
|
150
|
-
): ImageMetadata | null {
|
|
146
|
+
export function readImageMetadataSync(filePath: string, maxBytes = IMAGE_METADATA_HEADER_BYTES): ImageMetadata | null {
|
|
151
147
|
return peekFileSync(filePath, maxBytes, parseImageMetadata);
|
|
152
148
|
}
|
|
153
149
|
|
|
154
150
|
export function readImageMetadata(
|
|
155
151
|
filePath: string,
|
|
156
|
-
maxBytes =
|
|
152
|
+
maxBytes = IMAGE_METADATA_HEADER_BYTES,
|
|
157
153
|
): Promise<ImageMetadata | null> {
|
|
158
154
|
return peekFile(filePath, maxBytes, parseImageMetadata);
|
|
159
155
|
}
|
package/src/path.ts
CHANGED
|
@@ -40,3 +40,17 @@ export function stripWindowsExtendedLengthPathPrefix(
|
|
|
40
40
|
|
|
41
41
|
return filePath;
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Test whether a path is fully qualified and drive-independent.
|
|
46
|
+
* On Windows, requires a drive letter with separator (e.g. `C:\`) or UNC (`\\server\share` or `//server/share`).
|
|
47
|
+
* On POSIX, requires an absolute path.
|
|
48
|
+
*/
|
|
49
|
+
export function isFullyQualifiedPath(filePath: string, platform: NodeJS.Platform = process.platform): boolean {
|
|
50
|
+
const p = platform === "win32" ? path.win32 : path.posix;
|
|
51
|
+
if (!p.isAbsolute(filePath)) return false;
|
|
52
|
+
if (platform === "win32") {
|
|
53
|
+
return /^[a-zA-Z]:[/\\]/.test(filePath) || /^[\\/]{2}[^\\/]/.test(filePath);
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
}
|
package/src/postmortem.ts
CHANGED
|
@@ -57,6 +57,26 @@ export const NATIVE_PROCESS_EXIT = Symbol.for("omp.postmortem.nativeProcessExit"
|
|
|
57
57
|
|
|
58
58
|
type HardExitFn = (code?: number) => never;
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Walk a guarded exit primitive down to the native it shadows.
|
|
62
|
+
*
|
|
63
|
+
* `withHostGuard` stamps each throwing replacement with the primitive it
|
|
64
|
+
* shadows under {@link NATIVE_PROCESS_EXIT}; nested guard windows stack, so a
|
|
65
|
+
* single unwrap can still land on another throwing stub. Follow the chain
|
|
66
|
+
* (cycle-guarded) until a link carries no stamp — that link is native.
|
|
67
|
+
*/
|
|
68
|
+
function nativeHardExit(fn: HardExitFn | undefined): HardExitFn | undefined {
|
|
69
|
+
let current = fn;
|
|
70
|
+
const seen = new Set<HardExitFn>();
|
|
71
|
+
while (typeof current === "function" && !seen.has(current)) {
|
|
72
|
+
seen.add(current);
|
|
73
|
+
const behind = Reflect.get(current, NATIVE_PROCESS_EXIT);
|
|
74
|
+
if (typeof behind !== "function") return current;
|
|
75
|
+
current = behind as HardExitFn;
|
|
76
|
+
}
|
|
77
|
+
return typeof current === "function" ? current : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
60
80
|
/**
|
|
61
81
|
* Hard-exit the process through the native primitive, resolved on every call.
|
|
62
82
|
*
|
|
@@ -68,14 +88,30 @@ type HardExitFn = (code?: number) => never;
|
|
|
68
88
|
* init could freeze the throwing stub forever and turn every later shutdown
|
|
69
89
|
* (SIGHUP/SIGINT/fatal) into an unhandled-rejection loop (#7393). When the
|
|
70
90
|
* guard is active the stub carries the native exit under
|
|
71
|
-
* {@link NATIVE_PROCESS_EXIT}
|
|
72
|
-
*
|
|
91
|
+
* {@link NATIVE_PROCESS_EXIT} (#6488).
|
|
92
|
+
*
|
|
93
|
+
* Both globals are reinstalled to their natives before exiting: Bun's
|
|
94
|
+
* `process.exit` re-reads `process.reallyExit` at call time, so exiting through
|
|
95
|
+
* one primitive while its sibling still holds the throwing stub re-enters the
|
|
96
|
+
* guard and loops the rejection storm (#11789). After restoring, `reallyExit`
|
|
97
|
+
* (the low-level primitive) is preferred; `process.exit` and finally `SIGKILL`
|
|
98
|
+
* are fallbacks so a poisoned or absent chain can never leave the process alive.
|
|
73
99
|
*/
|
|
74
|
-
function exitProcess(code: number): never {
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
100
|
+
export function exitProcess(code: number): never {
|
|
101
|
+
const reallyExit = nativeHardExit(typeof process.reallyExit === "function" ? process.reallyExit : undefined);
|
|
102
|
+
const exit = nativeHardExit(process.exit as HardExitFn);
|
|
103
|
+
if (reallyExit) process.reallyExit = reallyExit as typeof process.reallyExit;
|
|
104
|
+
if (exit) process.exit = exit as typeof process.exit;
|
|
105
|
+
try {
|
|
106
|
+
reallyExit?.call(process, code);
|
|
107
|
+
} catch {}
|
|
108
|
+
try {
|
|
109
|
+
exit?.call(process, code);
|
|
110
|
+
} catch {}
|
|
111
|
+
try {
|
|
112
|
+
process.kill(process.pid, "SIGKILL");
|
|
113
|
+
} catch {}
|
|
114
|
+
throw new Error(`exitProcess(${code}) failed to terminate the process`);
|
|
79
115
|
}
|
|
80
116
|
let cleanupPromise: Promise<void> | undefined;
|
|
81
117
|
let stdioDisconnectRegistrations = 0;
|
|
@@ -288,19 +324,47 @@ function faultWorkerIpcChannels(err: Error): void {
|
|
|
288
324
|
}
|
|
289
325
|
|
|
290
326
|
/**
|
|
291
|
-
*
|
|
327
|
+
* Graceful shutdown driven by `process.stdout`'s own `error` event.
|
|
292
328
|
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
329
|
+
* A closed stdout consumer (`omp --help | head`, an ACP client dropping the
|
|
330
|
+
* pipe) delivers the broken-pipe write here — attributable to stdout by
|
|
331
|
+
* construction, unlike a process-wide `syscall: "write"` match that a closed
|
|
332
|
+
* subprocess stdin or socket would also satisfy — so it runs cleanup and exits
|
|
333
|
+
* 0 (Unix `| head` semantics).
|
|
334
|
+
*
|
|
335
|
+
* Only the broken-pipe case is claimed. A non-EPIPE stdout error (a revoked PTY
|
|
336
|
+
* reporting `EIO`) is left for other `error` listeners: the TUI installs its own
|
|
337
|
+
* stdout handler that treats a disconnect as SIGHUP/exit-129, and this listener
|
|
338
|
+
* is installed first on an interactive launch, so forcing a fatal exit here
|
|
339
|
+
* would preempt that established path. Attaching a listener already suppresses
|
|
340
|
+
* Node's default throw, so deferring is a safe no-op when no other listener runs.
|
|
341
|
+
*/
|
|
342
|
+
function onStdoutDisconnect(err: Error): void {
|
|
343
|
+
if (classifyBrokenPipe(err) !== "stdio-write") return;
|
|
344
|
+
logger.warn("Stdout peer disconnected; shutting down gracefully", { err });
|
|
345
|
+
void runQuit(0, "native", { drainStdout: false });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Treat a closed stdout consumer as a graceful peer disconnect for the caller's
|
|
350
|
+
* active lifetime. Attaches one shared `process.stdout` `error` listener,
|
|
351
|
+
* ref-counted across registrants (the ACP protocol server, the one-shot CLI
|
|
352
|
+
* entry). The returned callback removes the registration; the listener detaches
|
|
353
|
+
* when the last registrant unregisters.
|
|
296
354
|
*/
|
|
297
355
|
export function registerStdioDisconnectHandling(): () => void {
|
|
298
356
|
let registered = true;
|
|
357
|
+
if (Bun.isMainThread && stdioDisconnectRegistrations === 0) {
|
|
358
|
+
process.stdout.on("error", onStdoutDisconnect);
|
|
359
|
+
}
|
|
299
360
|
stdioDisconnectRegistrations++;
|
|
300
361
|
return () => {
|
|
301
362
|
if (!registered) return;
|
|
302
363
|
registered = false;
|
|
303
364
|
stdioDisconnectRegistrations--;
|
|
365
|
+
if (Bun.isMainThread && stdioDisconnectRegistrations === 0) {
|
|
366
|
+
process.stdout.removeListener("error", onStdoutDisconnect);
|
|
367
|
+
}
|
|
304
368
|
};
|
|
305
369
|
}
|
|
306
370
|
|
|
@@ -414,6 +478,13 @@ async function exitAfterFatal(output: string, logMessage: string, err: Error, re
|
|
|
414
478
|
}
|
|
415
479
|
}
|
|
416
480
|
|
|
481
|
+
/** Contain an EPIPE from an optional worker IPC `send()` (#2997, #9158). */
|
|
482
|
+
function handleWorkerSendEpipe(err: Error): boolean {
|
|
483
|
+
if (!isIpcSendEpipe(err)) return false;
|
|
484
|
+
logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
|
|
417
488
|
/**
|
|
418
489
|
* Reports a caught top-level failure after terminal owners restore their display, then exits.
|
|
419
490
|
*/
|
|
@@ -443,21 +514,16 @@ if (Bun.isMainThread) {
|
|
|
443
514
|
process.stderr.write(`Inspector opened: ${url}\n`);
|
|
444
515
|
})
|
|
445
516
|
.on("uncaughtException", async thrown => {
|
|
446
|
-
//
|
|
447
|
-
// AbortError/socket classification is limited to promise rejections:
|
|
448
|
-
// a synchronously thrown error may indicate an application bug.
|
|
517
|
+
// Expected cleanup is safe globally; unrelated synchronous errors stay fatal.
|
|
449
518
|
if (hasExpectedCleanupMarker(thrown)) {
|
|
450
519
|
logger.warn("Ignoring expected cleanup exception", { err: thrown });
|
|
451
520
|
return;
|
|
452
521
|
}
|
|
453
522
|
const err = thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
if (
|
|
458
|
-
logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
523
|
+
// A worker IPC `send()` race can surface through either global error event;
|
|
524
|
+
// contain it in both. Stdout write disconnects are attributed to stdout by
|
|
525
|
+
// registerStdioDisconnectHandling's `error` listener, not classified here.
|
|
526
|
+
if (handleWorkerSendEpipe(err)) return;
|
|
461
527
|
// A malformed advanced-serialization frame from a worker subprocess
|
|
462
528
|
// surfaces here as a process-level uncaughtException (oven-sh/bun#37287)
|
|
463
529
|
// rather than in the channel's ipc() callback, and Bun gives no way to
|
|
@@ -466,7 +532,7 @@ if (Bun.isMainThread) {
|
|
|
466
532
|
// worker so its owning client rejects in-flight requests and recycles
|
|
467
533
|
// the subprocess — a worker that sent a bad frame but stays alive would
|
|
468
534
|
// otherwise never fire onExit and leave callers awaiting forever.
|
|
469
|
-
//
|
|
535
|
+
// See the analogous worker IPC containment in handleBrokenPipe (#9158, #2997).
|
|
470
536
|
if (isWorkerIpcDeserializeError(err)) {
|
|
471
537
|
logger.warn("Malformed worker IPC frame; faulting active worker subsystems", { err });
|
|
472
538
|
faultWorkerIpcChannels(err);
|
|
@@ -487,25 +553,7 @@ if (Bun.isMainThread) {
|
|
|
487
553
|
})
|
|
488
554
|
.on("unhandledRejection", async reason => {
|
|
489
555
|
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
490
|
-
|
|
491
|
-
// EPIPE from an IPC `send()` (`syscall: "send"`) originates from a
|
|
492
|
-
// worker subprocess whose pipe broke between the exit being observed
|
|
493
|
-
// and the next `proc.send()` — a race window that Bun surfaces as an
|
|
494
|
-
// async rejection rather than the synchronous "cannot be used after
|
|
495
|
-
// the process has exited" guard. Every `send()` target is an optional
|
|
496
|
-
// worker subsystem (TTS, STT, tiny-title, MCP servers), so a broken
|
|
497
|
-
// send pipe must never take down the whole session. Log and continue
|
|
498
|
-
// instead of exiting; the owning client detects the dead worker via
|
|
499
|
-
// its own `onExit`/error path and respawns or disables it. See #2997.
|
|
500
|
-
if (brokenPipeSource === "ipc-send") {
|
|
501
|
-
logger.warn("Ignoring EPIPE from worker IPC send; optional subsystem will self-recover", { err });
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
504
|
-
if (brokenPipeSource === "stdio-write" && stdioDisconnectRegistrations > 0) {
|
|
505
|
-
logger.warn("Stdio peer disconnected; shutting down gracefully", { err });
|
|
506
|
-
await runQuit(0, "native");
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
556
|
+
if (handleWorkerSendEpipe(err)) return;
|
|
509
557
|
if (isExpectedCleanupError(reason)) {
|
|
510
558
|
logger.warn("Ignoring expected cleanup rejection", { err });
|
|
511
559
|
return;
|
package/src/procmgr.ts
CHANGED
|
@@ -4,8 +4,10 @@ import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
|
|
|
4
4
|
import type { Subprocess } from "bun";
|
|
5
5
|
import { getAgentDir, MAIN_CONFIG_FILENAMES } from "./dirs";
|
|
6
6
|
import { $env, filterChildShellEnv } from "./env";
|
|
7
|
+
import { isExecutable } from "./executable";
|
|
7
8
|
import { $which } from "./which";
|
|
8
9
|
|
|
10
|
+
export { isExecutable };
|
|
9
11
|
export interface ShellConfig {
|
|
10
12
|
shell: string;
|
|
11
13
|
args: string[];
|
|
@@ -20,18 +22,6 @@ export interface ShellConfigOptions {
|
|
|
20
22
|
}
|
|
21
23
|
let cachedShellConfig: ShellConfig | null = null;
|
|
22
24
|
|
|
23
|
-
/**
|
|
24
|
-
* Check if a shell binary is executable.
|
|
25
|
-
*/
|
|
26
|
-
export function isExecutable(path: string): boolean {
|
|
27
|
-
try {
|
|
28
|
-
fs.accessSync(path, fs.constants.X_OK);
|
|
29
|
-
return true;
|
|
30
|
-
} catch {
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
25
|
/**
|
|
36
26
|
* Build the spawn environment (cached).
|
|
37
27
|
*/
|
package/src/ptree.ts
CHANGED
|
@@ -17,6 +17,8 @@ type PipedSubprocess<In extends InMask = InMask> = Subprocess<In, "pipe", "pipe"
|
|
|
17
17
|
|
|
18
18
|
const LINUX_SUBREAPER_COMMAND_ENV = "OMP_PTREE_SUBREAPER_COMMAND";
|
|
19
19
|
const LINUX_SUBREAPER_BUN_BE_BUN_ENV = "OMP_PTREE_SUBREAPER_BUN_BE_BUN";
|
|
20
|
+
const SUBREAPER_KILL_WINDOW_MS = 100;
|
|
21
|
+
const SUBREAPER_KILL_POLL_MS = 5;
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Build the Linux child-subreaper entrypoint.
|
|
@@ -189,6 +191,8 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
189
191
|
#stderrStream?: ReadableStream<Uint8Array>;
|
|
190
192
|
// Termination in flight after kill(); aborted exits await it before reporting.
|
|
191
193
|
#terminating?: Promise<boolean | void>;
|
|
194
|
+
// A hard subreaper sweep must remain authoritative across overlapping kill requests.
|
|
195
|
+
#hardKillSweep?: Promise<void>;
|
|
192
196
|
#terminateGroup: boolean;
|
|
193
197
|
#hardKillTree: boolean;
|
|
194
198
|
// Windows has no process groups. Retaining the root's native handle pins
|
|
@@ -340,14 +344,22 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
340
344
|
// group leader; wait() still needs to report the later deadline.
|
|
341
345
|
if (this.proc.exitCode !== null) this.#exitReason = reason;
|
|
342
346
|
}
|
|
347
|
+
// An AbortSignal can race a timeout after its hard subreaper sweep has
|
|
348
|
+
// started. Do not replace that sweep with a normal root termination: the
|
|
349
|
+
// root must stay alive until adopted descendants have been collected.
|
|
350
|
+
if (this.#hardKillSweep) return;
|
|
343
351
|
if (gracefulMs !== undefined && gracefulMs < 0 && this.#hardKillTree && this.proc.exitCode === null) {
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
//
|
|
352
|
+
// Keep the subreaper alive while descendants are killed. A single
|
|
353
|
+
// killTree() snapshot can miss a worker whose parent exits during the
|
|
354
|
+
// walk and reparents it to the subreaper after that root was enumerated.
|
|
347
355
|
const root = Process.fromPid(this.proc.pid);
|
|
348
356
|
if (root) {
|
|
349
|
-
root.
|
|
350
|
-
this.#
|
|
357
|
+
const sweep = this.#hardKillSubreaperTree(root).catch(e => void e);
|
|
358
|
+
this.#hardKillSweep = sweep;
|
|
359
|
+
this.#terminating = sweep;
|
|
360
|
+
void sweep.finally(() => {
|
|
361
|
+
if (this.#hardKillSweep === sweep) this.#hardKillSweep = undefined;
|
|
362
|
+
});
|
|
351
363
|
return;
|
|
352
364
|
}
|
|
353
365
|
}
|
|
@@ -386,6 +398,25 @@ export class ChildProcess<In extends InMask = InMask> {
|
|
|
386
398
|
}
|
|
387
399
|
}
|
|
388
400
|
|
|
401
|
+
async #hardKillSubreaperTree(root: Process): Promise<void> {
|
|
402
|
+
try {
|
|
403
|
+
const deadline = Date.now() + SUBREAPER_KILL_WINDOW_MS;
|
|
404
|
+
let emptySweeps = 0;
|
|
405
|
+
while (emptySweeps < 2 && Date.now() < deadline) {
|
|
406
|
+
const children = root.children();
|
|
407
|
+
if (children.length === 0) {
|
|
408
|
+
emptySweeps++;
|
|
409
|
+
} else {
|
|
410
|
+
emptySweeps = 0;
|
|
411
|
+
for (const child of children) child.killTree(9);
|
|
412
|
+
}
|
|
413
|
+
if (emptySweeps < 2) await Bun.sleep(SUBREAPER_KILL_POLL_MS);
|
|
414
|
+
}
|
|
415
|
+
} finally {
|
|
416
|
+
root.killTree(9);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
389
420
|
// ── Output helpers ───────────────────────────────────────────────────
|
|
390
421
|
|
|
391
422
|
async #throwIfAborted(): Promise<void> {
|
package/src/snowflake.ts
CHANGED
|
@@ -4,6 +4,7 @@ function randu32() {
|
|
|
4
4
|
|
|
5
5
|
const EPOCH = 1420070400000;
|
|
6
6
|
const MAX_SEQ = 0x3fffff;
|
|
7
|
+
const MAX_DT = 2 ** 42 - 1;
|
|
7
8
|
|
|
8
9
|
// Snowflake as a hex string (16 chars, zero-padded).
|
|
9
10
|
//
|
|
@@ -25,14 +26,24 @@ namespace Snowflake {
|
|
|
25
26
|
//
|
|
26
27
|
export const MAX_SEQUENCE = MAX_SEQ;
|
|
27
28
|
|
|
29
|
+
// Last timestamp representable in the 42-bit timestamp field (~year 2154).
|
|
30
|
+
//
|
|
31
|
+
export const MAX_TIMESTAMP = EPOCH + MAX_DT;
|
|
32
|
+
|
|
28
33
|
// Formats a sequence and timestamp into a snowflake hex string.
|
|
29
34
|
//
|
|
30
35
|
// dt fits well within BigInt range: (dt << 22) | seq stays under 2^64 for
|
|
31
36
|
// any dt < 2^42 (~year 2154), so a single 64-bit format is exact — and
|
|
32
37
|
// measures ~1.7x faster than stitching four 16-bit hex segments.
|
|
33
38
|
//
|
|
39
|
+
// dt is saturated into [0, 2^42) so the result is always a valid snowflake:
|
|
40
|
+
// a negative delta (a timestamp before EPOCH) would otherwise render a
|
|
41
|
+
// leading "-", and a delta past ~2154 would widen the string beyond 16
|
|
42
|
+
// chars. Both cases produce a value that fails this module's own valid().
|
|
43
|
+
//
|
|
34
44
|
export function formatParts(dt: number, seq: number): Snowflake {
|
|
35
|
-
|
|
45
|
+
const clamped = Math.min(Math.max(dt, 0), MAX_DT);
|
|
46
|
+
return ((BigInt(clamped) << 22n) | BigInt(seq)).toString(16).padStart(16, "0") as Snowflake;
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
// Snowflake generator type.
|
package/src/stream.ts
CHANGED
|
@@ -57,11 +57,16 @@ export async function* readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?:
|
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Amortized byte accumulator for chunked stream readers.
|
|
62
|
+
*
|
|
63
|
+
* Holds the unconsumed tail of a stream in a single growing `Buffer` so that
|
|
64
|
+
* appending N chunks costs O(total bytes) instead of re-copying the whole
|
|
65
|
+
* prefix per chunk. Backs {@link readLines}, {@link readJsonl} and
|
|
66
|
+
* {@link readSseEvents}; also usable directly when a reader needs its own
|
|
67
|
+
* framing loop (see `consume` and `flush`).
|
|
68
|
+
*/
|
|
69
|
+
export class ConcatSink {
|
|
65
70
|
#space?: Buffer;
|
|
66
71
|
#length = 0;
|
|
67
72
|
#skipLeadingLf = false;
|
|
@@ -102,11 +107,26 @@ class ConcatSink {
|
|
|
102
107
|
return this.#length === 0;
|
|
103
108
|
}
|
|
104
109
|
|
|
110
|
+
/**
|
|
111
|
+
* The buffered bytes as a live view — invalidated by the next `append`,
|
|
112
|
+
* `reset` or `consume`.
|
|
113
|
+
*/
|
|
105
114
|
flush(): Uint8Array | undefined {
|
|
106
115
|
if (!this.#length) return undefined;
|
|
107
116
|
return this.#space!.subarray(0, this.#length);
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
/** Drop the first `count` buffered bytes, keeping the remainder. */
|
|
120
|
+
consume(count: number) {
|
|
121
|
+
if (count <= 0) return;
|
|
122
|
+
if (count >= this.#length) {
|
|
123
|
+
this.#length = 0;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
this.#space!.copyWithin(0, count, this.#length);
|
|
127
|
+
this.#length -= count;
|
|
128
|
+
}
|
|
129
|
+
|
|
110
130
|
clear() {
|
|
111
131
|
this.#length = 0;
|
|
112
132
|
}
|
|
@@ -207,6 +227,10 @@ class ConcatSink {
|
|
|
207
227
|
}
|
|
208
228
|
}
|
|
209
229
|
|
|
230
|
+
// =============================================================================
|
|
231
|
+
// SSE (Server-Sent Events)
|
|
232
|
+
// =============================================================================
|
|
233
|
+
|
|
210
234
|
/**
|
|
211
235
|
* Stream parsed JSON objects from SSE `data:` lines.
|
|
212
236
|
*
|
|
@@ -246,11 +270,24 @@ function isRecoverableTrailingJson(data: string): boolean {
|
|
|
246
270
|
return typeof recovered === "object" && recovered !== null;
|
|
247
271
|
}
|
|
248
272
|
|
|
249
|
-
|
|
273
|
+
/**
|
|
274
|
+
* One dispatched `data:` frame from {@link readSseFrames}: either the parsed JSON
|
|
275
|
+
* value, or the text of a frame `JSON.parse` rejected together with the
|
|
276
|
+
* `SyntaxError` it raised (so the strict reader can rethrow it unchanged).
|
|
277
|
+
*/
|
|
278
|
+
type SseFrame<T> = { ok: true; value: T } | { ok: false; raw: string; error: SyntaxError };
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Shared `data:`-line framing for {@link readSseJson} and
|
|
282
|
+
* {@link readSseJsonOrText}: skips empty events, stops at the OpenAI `[DONE]`
|
|
283
|
+
* sentinel, notifies the diagnostic observer, and treats a container-shaped
|
|
284
|
+
* stream tail as a clean end of iteration.
|
|
285
|
+
*/
|
|
286
|
+
async function* readSseFrames<T>(
|
|
250
287
|
stream: ReadableStream<Uint8Array>,
|
|
251
288
|
signal?: AbortSignal,
|
|
252
289
|
onEvent?: SseEventObserver,
|
|
253
|
-
): AsyncGenerator<T
|
|
290
|
+
): AsyncGenerator<SseFrame<T>> {
|
|
254
291
|
for await (const sse of readSseEvents(stream, signal)) {
|
|
255
292
|
const isTrailing = trailingEvents.has(sse);
|
|
256
293
|
notifySseEventObserver(onEvent, sse);
|
|
@@ -260,16 +297,60 @@ export async function* readSseJson<T>(
|
|
|
260
297
|
continue;
|
|
261
298
|
}
|
|
262
299
|
try {
|
|
263
|
-
yield JSON.parse(data) as T;
|
|
300
|
+
yield { ok: true, value: JSON.parse(data) as T };
|
|
264
301
|
} catch (err) {
|
|
265
302
|
if (err instanceof SyntaxError && isTrailing && isRecoverableTrailingJson(data)) {
|
|
266
303
|
return;
|
|
267
304
|
}
|
|
305
|
+
if (err instanceof SyntaxError) {
|
|
306
|
+
yield { ok: false, raw: data, error: err };
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
268
309
|
throw err;
|
|
269
310
|
}
|
|
270
311
|
}
|
|
271
312
|
}
|
|
272
313
|
|
|
314
|
+
export async function* readSseJson<T>(
|
|
315
|
+
stream: ReadableStream<Uint8Array>,
|
|
316
|
+
signal?: AbortSignal,
|
|
317
|
+
onEvent?: SseEventObserver,
|
|
318
|
+
): AsyncGenerator<T> {
|
|
319
|
+
for await (const frame of readSseFrames<T>(stream, signal, onEvent)) {
|
|
320
|
+
if (!frame.ok) throw frame.error;
|
|
321
|
+
yield frame.value;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Like {@link readSseJson}, but a `data:` frame that is not valid JSON is yielded
|
|
327
|
+
* as its raw text instead of raising a `SyntaxError`. Cut-off container-shaped
|
|
328
|
+
* stream tails stay recoverable, exactly as they are in {@link readSseJson}.
|
|
329
|
+
*
|
|
330
|
+
* Consumers that only understand objects must treat a `string` yield as a
|
|
331
|
+
* transport-level failure (for example a `429 Too Many Requests` or an HTML
|
|
332
|
+
* throttle page from a reverse proxy that already committed to the stream). This
|
|
333
|
+
* exists because `readSseJson`'s baseline consumers span unrelated transports
|
|
334
|
+
* whose error handling a text yield would subtly change; new call sites opt in.
|
|
335
|
+
*
|
|
336
|
+
* Note that the text lane is only the frames `JSON.parse` *rejected*: a frame
|
|
337
|
+
* carrying a JSON-encoded string (`data: "429 Too Many Requests"`) parses, so it
|
|
338
|
+
* is yielded as that string and is indistinguishable from a rejected frame by
|
|
339
|
+
* type alone. Consumers branching on `typeof === "string"` therefore see both,
|
|
340
|
+
* which is the safe direction — each is classified as text rather than trusted as
|
|
341
|
+
* an event object.
|
|
342
|
+
*/
|
|
343
|
+
export async function* readSseJsonOrText<T>(
|
|
344
|
+
stream: ReadableStream<Uint8Array>,
|
|
345
|
+
signal?: AbortSignal,
|
|
346
|
+
onEvent?: SseEventObserver,
|
|
347
|
+
): AsyncGenerator<T | string> {
|
|
348
|
+
for await (const frame of readSseFrames<T>(stream, signal, onEvent)) {
|
|
349
|
+
if (!frame.ok) yield frame.raw;
|
|
350
|
+
else yield frame.value;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
273
354
|
/**
|
|
274
355
|
* A single Server-Sent Event dispatched on a blank-line boundary.
|
|
275
356
|
*
|
package/src/which.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as os from "node:os";
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
+
import { isFullyQualifiedPath } from "./path";
|
|
14
15
|
|
|
15
16
|
type CacheKey = string | bigint | number;
|
|
16
17
|
|
|
@@ -179,6 +180,12 @@ export interface WhichOptions extends Bun.WhichOptions {
|
|
|
179
180
|
* Defaults to `WhichCachePolicy.Fresh`.
|
|
180
181
|
*/
|
|
181
182
|
cache?: WhichCachePolicy;
|
|
183
|
+
/**
|
|
184
|
+
* Only search absolute directory entries in PATH, ignoring relative entries
|
|
185
|
+
* (e.g. `.` or `./bin`) and empty components to prevent resolving against
|
|
186
|
+
* an untrusted working directory.
|
|
187
|
+
*/
|
|
188
|
+
requireAbsolutePaths?: boolean;
|
|
182
189
|
}
|
|
183
190
|
|
|
184
191
|
// Darwin-specific "which" shim: consult Xcode/CLT toolchain directories after $PATH.
|
|
@@ -192,6 +199,15 @@ function darwinWhich(command: string, options?: Bun.WhichOptions): string | null
|
|
|
192
199
|
return null;
|
|
193
200
|
}
|
|
194
201
|
|
|
202
|
+
function filterAbsoluteSearchPath(rawPath: string | undefined): string | null {
|
|
203
|
+
if (!rawPath) return null;
|
|
204
|
+
const safePath = rawPath
|
|
205
|
+
.split(path.delimiter)
|
|
206
|
+
.filter(dir => dir.length > 0 && isFullyQualifiedPath(dir))
|
|
207
|
+
.join(path.delimiter);
|
|
208
|
+
return safePath || null;
|
|
209
|
+
}
|
|
210
|
+
|
|
195
211
|
// Which function that incorporates Darwin Xcode logic if platform reports as 'darwin'.
|
|
196
212
|
// Look `Bun.which` up per call rather than capturing it at import, so a `Bun.which`
|
|
197
213
|
// stub installed later (the per-test seam) is honoured on every platform.
|
|
@@ -219,8 +235,15 @@ function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey {
|
|
|
219
235
|
*/
|
|
220
236
|
export function $which(command: string, options?: WhichOptions): string | null {
|
|
221
237
|
const cachePolicy = options?.cache ?? WhichCachePolicy.Cached;
|
|
222
|
-
|
|
238
|
+
let lookupOptions =
|
|
223
239
|
options?.PATH !== undefined || process.env.PATH === undefined ? options : { ...options, PATH: process.env.PATH };
|
|
240
|
+
|
|
241
|
+
if (options?.requireAbsolutePaths) {
|
|
242
|
+
const safePath = filterAbsoluteSearchPath(lookupOptions?.PATH);
|
|
243
|
+
if (!safePath) return null;
|
|
244
|
+
lookupOptions = { ...lookupOptions, PATH: safePath };
|
|
245
|
+
}
|
|
246
|
+
|
|
224
247
|
let key: CacheKey | undefined;
|
|
225
248
|
|
|
226
249
|
if (cachePolicy !== WhichCachePolicy.Bypass) {
|
|
@@ -232,6 +255,9 @@ export function $which(command: string, options?: WhichOptions): string | null {
|
|
|
232
255
|
}
|
|
233
256
|
|
|
234
257
|
const result = whichFresh(command, lookupOptions);
|
|
258
|
+
if (result && options?.requireAbsolutePaths && !isFullyQualifiedPath(result)) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
235
261
|
if (key != null && cachePolicy !== WhichCachePolicy.ReadOnly) {
|
|
236
262
|
toolCache.set(key, result);
|
|
237
263
|
}
|
package/src/xml.ts
CHANGED
|
@@ -104,6 +104,12 @@ class XmlReader {
|
|
|
104
104
|
this.#readProcessingInstruction(document);
|
|
105
105
|
continue;
|
|
106
106
|
}
|
|
107
|
+
if (this.#xml.startsWith("</", this.#position)) {
|
|
108
|
+
// Stray end tag with no element open: skip it instead of parsing it as a new element,
|
|
109
|
+
// whose empty name would leave #readElement stuck on the `/`.
|
|
110
|
+
this.#skipThrough(">");
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
107
113
|
if (this.#xml[this.#position] === "<") {
|
|
108
114
|
const element = this.#readElement("");
|
|
109
115
|
this.#addValue(document, element.name, element.name, element.value, element.leaf, null);
|
|
@@ -138,6 +144,11 @@ class XmlReader {
|
|
|
138
144
|
this.#position++;
|
|
139
145
|
this.#skipWhitespace();
|
|
140
146
|
value = this.#readAttributeValue();
|
|
147
|
+
} else if (attributeName === "") {
|
|
148
|
+
// Markup #readName cannot consume (the stray `/` in `<a / >`): skip the character
|
|
149
|
+
// so the loop always makes progress.
|
|
150
|
+
this.#position++;
|
|
151
|
+
continue;
|
|
141
152
|
}
|
|
142
153
|
attributes.push([attributeName, value]);
|
|
143
154
|
}
|
|
@@ -153,6 +164,11 @@ class XmlReader {
|
|
|
153
164
|
this.#position = close < 0 ? this.#xml.length : close + 1;
|
|
154
165
|
break;
|
|
155
166
|
}
|
|
167
|
+
if (this.#xml.startsWith("</", this.#position)) {
|
|
168
|
+
// End tag for an ancestor (an unclosed `<br>` inside `<p>`, or plain mismatched
|
|
169
|
+
// markup): close this element implicitly and leave the tag to its owner.
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
156
172
|
if (this.#xml.startsWith("<![CDATA[", this.#position)) {
|
|
157
173
|
const end = this.#xml.indexOf("]]>", this.#position + 9);
|
|
158
174
|
const raw = this.#xml.slice(this.#position + 9, end < 0 ? this.#xml.length : end);
|