@oh-my-pi/pi-utils 18.2.0 → 18.2.2

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,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.2.2] - 2026-09-16
6
+
7
+ ### Added
8
+
9
+ - Added asynchronous and synchronous SQLite database opening APIs with path-attributed errors, optional corruption recovery that preserves private database and sidecar backups, and automatic retries for transient busy errors during asynchronous opens.
10
+
11
+ ## [18.2.1] - 2026-09-15
12
+
13
+ ### Added
14
+
15
+ - 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)).
16
+ - 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.
17
+
18
+ ### Fixed
19
+
20
+ - 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)).
21
+ - 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`.
22
+ - 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)).
23
+ - 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)).
24
+ - `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)).
25
+ - Fixed Linux `ptree` timeout cleanup occasionally leaving session-escaped descendants running during subreaper adoption.
26
+
5
27
  ## [18.2.0] - 2026-09-15
6
28
 
7
29
  ### 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,13 +1,33 @@
1
+ /** Shared SQLite opening, error attribution, and result-code classification for persistent stores. */
2
+ import { Database } from "bun:sqlite";
3
+ /** Controls opt-in replacement of an unrecoverably corrupt SQLite store. */
4
+ export interface SqliteOpenOptions {
5
+ /**
6
+ * Preserve a corrupt store and its sidecars, recreate it, and run the
7
+ * initializer once more. Disabled by default.
8
+ */
9
+ recoverCorruption?: boolean;
10
+ /** Runs after preservation and before the replacement is initialized. */
11
+ onCorruptionPreserved?: (backupPath: string, error: unknown) => void;
12
+ }
1
13
  /**
2
- * Shared classifiers for `bun:sqlite` error result codes.
14
+ * Opens and initializes a store, retrying BUSY failures up to four total attempts.
15
+ * Installs the busy handler before initialization and closes failed connections.
16
+ * The initializer may run again on a fresh connection; on success it owns the handle.
3
17
  *
4
- * Every omp SQLite store (`agent.db` credential/usage store, `models.db` model
5
- * cache, `history.db`) needs the same two distinctions: a transient BUSY that
6
- * clears by retrying, and an unrecoverable corruption that never does. Keeping
7
- * one implementation here prevents the classifiers from drifting between the
8
- * credential store and the model cache.
18
+ * With corruption recovery enabled, recovery is serialized across processes.
19
+ * The identity observed by the failed handle is checked under that lock, so a
20
+ * waiter adopts a replacement made by a peer instead of quarantining it.
21
+ * Final failures retain their SQLite codes and include the database path.
9
22
  */
10
- import type { Database } from "bun:sqlite";
23
+ export declare function openSqliteDatabase<T>(dbPath: string, initialize: (db: Database) => T | Promise<T>, options?: SqliteOpenOptions): Promise<T>;
24
+ /**
25
+ * Synchronous counterpart to {@link openSqliteDatabase}. It performs no BUSY
26
+ * retry loop; corruption recovery, when enabled, is bounded to one replacement.
27
+ */
28
+ export declare function openSqliteDatabaseSync<T>(dbPath: string, initialize: (db: Database) => T, options?: SqliteOpenOptions): T;
29
+ /** Adds the failing store's path to an error without losing SQLite result codes or its original stack. */
30
+ export declare function annotateSqliteError(error: unknown, dbPath: string): Error;
11
31
  /** Checkpoints committed WAL frames without waiting for concurrent readers. */
12
32
  export declare function checkpointWal(db: Database): void;
13
33
  /**
@@ -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.2",
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.2"
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";