@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 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
@@ -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). */
@@ -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
  /**
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Check if a file path exists, is a regular file, and has effective execute permission.
3
+ */
4
+ export declare function isExecutable(filePath: string): boolean;
@@ -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-bearing only; a timezone-naive stamp is provider wall clock in
19
- * an unknown zone and only resolves when no relative signal is present)
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.
@@ -4,6 +4,7 @@ export * from "./binary.js";
4
4
  export * from "./color.js";
5
5
  export * from "./dirs.js";
6
6
  export * from "./env.js";
7
+ export * from "./executable.js";
7
8
  export * from "./fetch-retry.js";
8
9
  export * from "./file-lock.js";
9
10
  export * from "./format.js";
@@ -1,3 +1,4 @@
1
+ export declare const IMAGE_METADATA_HEADER_BYTES: number;
1
2
  export declare const SUPPORTED_IMAGE_MIME_TYPES: Set<string>;
2
3
  export type ImageMetadata = {
3
4
  mimeType: "image/png";
@@ -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 unhandled stdout EPIPE rejections as a graceful peer disconnect.
95
- *
96
- * Stdio protocol servers call this for their process lifetime so a closed
97
- * client pipe runs registered cleanup callbacks instead of the fatal path.
98
- * The returned callback removes the registration.
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
  /**
@@ -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
@@ -5,6 +5,7 @@ declare namespace Snowflake {
5
5
  const PATTERN: RegExp;
6
6
  const EPOCH_TIMESTAMP = 1420070400000;
7
7
  const MAX_SEQUENCE = 4194303;
8
+ const MAX_TIMESTAMP: number;
8
9
  function formatParts(dt: number, seq: number): Snowflake;
9
10
  class Source {
10
11
  #private;
@@ -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
  *
@@ -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.0",
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.0"
57
+ "@oh-my-pi/pi-natives": "18.2.1"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@types/bun": "^1.3.14"
@@ -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
- void this.#handle(next.value).catch(error => this.close(error));
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.#write({ jsonrpc: "2.0", id: message.id, result: result ?? {} });
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.#write({ jsonrpc: "2.0", id: message.id, error: protocolError.toErrorResponse() });
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
+ }
@@ -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-bearing only; a timezone-naive stamp is provider wall clock in
55
- * an unknown zone and only resolves when no relative signal is present)
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(source: Response | Headers | null | undefined, body?: string): number | undefined {
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 [WILL_RESET_AT_PATTERN, CN_RESET_AT_PATTERN]) {
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
- // Naive stamps (provider wall clock, unknown zone) resolve after the
150
- // relative signals below, and only when nothing unambiguous was
151
- // found never by guessing the zone against a conflicting signal.
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
- if (hasOffset) {
155
- const parsed = Date.parse(normalized);
156
- if (!Number.isNaN(parsed) && parsed > Date.now()) {
157
- consider(parsed - Date.now());
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
@@ -4,6 +4,7 @@ export * from "./binary";
4
4
  export * from "./color";
5
5
  export * from "./dirs";
6
6
  export * from "./env";
7
+ export * from "./executable";
7
8
  export * from "./fetch-retry";
8
9
  export * from "./file-lock";
9
10
  export * from "./format";
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, `${formatLogInfo(info)}${os.EOL}`);
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 DEFAULT_IMAGE_METADATA_HEADER_BYTES = 256 * 1024;
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 = DEFAULT_IMAGE_METADATA_HEADER_BYTES,
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}; unwrapping it lets a mid-guard signal still exit
72
- * (#6488). Otherwise the current `process.reallyExit`/`process.exit` is native.
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 current: HardExitFn = typeof process.reallyExit === "function" ? process.reallyExit : process.exit;
76
- const behind = Reflect.get(current, NATIVE_PROCESS_EXIT);
77
- const nativeExit = typeof behind === "function" ? (behind as HardExitFn) : current;
78
- return nativeExit.call(process, code) as never;
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
- * Treat unhandled stdout EPIPE rejections as a graceful peer disconnect.
327
+ * Graceful shutdown driven by `process.stdout`'s own `error` event.
292
328
  *
293
- * Stdio protocol servers call this for their process lifetime so a closed
294
- * client pipe runs registered cleanup callbacks instead of the fatal path.
295
- * The returned callback removes the registration.
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
- // Only explicitly marked exceptions are safe here. Structural
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
- // Bun can surface a worker IPC send race through uncaughtException
455
- // instead of unhandledRejection. Apply the same optional-worker
456
- // containment in either global error channel.
457
- if (isIpcSendEpipe(err)) {
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
- // Mirrors the ipc-send EPIPE containment below (#9158, #2997).
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
- const brokenPipeSource = classifyBrokenPipe(err);
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
- // 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.
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.killTree(9);
350
- this.#terminating = Promise.resolve();
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
- return ((BigInt(dt) << 22n) | BigInt(seq)).toString(16).padStart(16, "0") as Snowflake;
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
- // SSE (Server-Sent Events)
62
- // =============================================================================
63
-
64
- class ConcatSink {
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
- export async function* readSseJson<T>(
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
- const lookupOptions =
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);