@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 +22 -0
- package/dist/types/dirs.d.ts +7 -0
- package/dist/types/env.d.ts +8 -0
- package/dist/types/executable.d.ts +4 -0
- package/dist/types/fetch-retry.d.ts +8 -3
- package/dist/types/file-lock.d.ts +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/mime.d.ts +1 -0
- package/dist/types/path.d.ts +6 -0
- package/dist/types/postmortem.d.ts +26 -5
- package/dist/types/procmgr.d.ts +2 -4
- package/dist/types/snowflake.d.ts +1 -0
- package/dist/types/sqlite.d.ts +27 -7
- package/dist/types/stream.d.ts +45 -0
- package/dist/types/which.d.ts +6 -0
- package/package.json +2 -2
- package/src/acp/transport.ts +37 -3
- package/src/dirs.ts +10 -0
- package/src/env.ts +45 -0
- package/src/executable.ts +17 -0
- package/src/fetch-retry.ts +26 -17
- package/src/file-lock.ts +23 -0
- package/src/index.ts +1 -0
- package/src/logger.ts +2 -3
- package/src/mime.ts +3 -7
- package/src/path.ts +14 -0
- package/src/postmortem.ts +89 -41
- package/src/procmgr.ts +2 -12
- package/src/ptree.ts +36 -5
- package/src/snowflake.ts +12 -1
- package/src/sqlite.ts +242 -7
- package/src/stream.ts +89 -8
- package/src/which.ts +27 -1
- package/src/xml.ts +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,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
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -281,6 +281,13 @@ export declare function getAgentModulesDir(agentDir?: string): string;
|
|
|
281
281
|
export declare function getMemoriesDir(agentDir?: string): string;
|
|
282
282
|
/** Get the terminal sessions directory (~/.omp/agent/terminal-sessions). */
|
|
283
283
|
export declare function getTerminalSessionsDir(agentDir?: string): string;
|
|
284
|
+
/**
|
|
285
|
+
* Get the persistent registry of custom session files
|
|
286
|
+
* (~/.omp/agent/custom-session-files). Each `--session-dir`/`--session`
|
|
287
|
+
* transcript is recorded here as one marker file so storage GC can scan its
|
|
288
|
+
* exact path after its terminal breadcrumb is overwritten by a later session.
|
|
289
|
+
*/
|
|
290
|
+
export declare function getCustomSessionFilesDir(agentDir?: string): string;
|
|
284
291
|
/** Get the crash log path (~/.omp/agent/omp-crash.log). */
|
|
285
292
|
export declare function getCrashLogPath(agentDir?: string): string;
|
|
286
293
|
/** Get the debug log path (~/.omp/agent/omp-debug.log). */
|
package/dist/types/env.d.ts
CHANGED
|
@@ -26,6 +26,14 @@ export declare function isMacosMallocStackLoggingEnvName(name: string): boolean;
|
|
|
26
26
|
*/
|
|
27
27
|
export declare function isWsl(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): boolean;
|
|
28
28
|
export declare function filterProcessEnv(env: Record<string, string | undefined>): Record<string, string>;
|
|
29
|
+
/**
|
|
30
|
+
* Removes {@link GIT_REPO_LOCATION_ENV_NAMES} from a copied child env in place.
|
|
31
|
+
*
|
|
32
|
+
* Windows environment lookups are case-insensitive, so a block that spells a
|
|
33
|
+
* variable `git_dir` is just as binding there; match case-insensitively on
|
|
34
|
+
* win32 and exactly elsewhere (POSIX env names are case-sensitive).
|
|
35
|
+
*/
|
|
36
|
+
export declare function stripGitRepoLocationEnv(env: Record<string, string>, platform?: NodeJS.Platform): void;
|
|
29
37
|
/** Filters process env for child shells without launch-cwd dotenv values. */
|
|
30
38
|
export declare function filterChildShellEnv(env: Record<string, string | undefined>, cwd?: string): Record<string, string>;
|
|
31
39
|
/**
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** Provider-specific interpretation for timezone-naive retry timestamps. */
|
|
2
|
+
export interface RetryHintOptions {
|
|
3
|
+
/** UTC offset appended to an absolute reset stamp that omits its timezone. */
|
|
4
|
+
naiveResetTimezoneOffset?: string;
|
|
5
|
+
}
|
|
1
6
|
/**
|
|
2
7
|
* Server-suggested retry delay extraction. Merges the patterns historically used
|
|
3
8
|
*
|
|
@@ -15,14 +20,14 @@
|
|
|
15
20
|
* - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
|
|
16
21
|
* - `retry-after-ms=98497000` / `retry-after-ms: 7200000` / `retry-after-ms = 7200000`
|
|
17
22
|
* - `Your limit will reset at 2026-09-01 09:44:51` / `将在 2026-09-01 09:44:51 重置`
|
|
18
|
-
* (offset
|
|
19
|
-
*
|
|
23
|
+
* (a provider offset makes a naive wall clock authoritative; otherwise it
|
|
24
|
+
* resolves only when no relative signal is present)
|
|
20
25
|
*
|
|
21
26
|
* Returns `undefined` if no signal is found, or `0` when the provider
|
|
22
27
|
* explicitly asks for an immediate retry (`retry-after…=0`, or an absolute
|
|
23
28
|
* reset timestamp that has already elapsed).
|
|
24
29
|
*/
|
|
25
|
-
export declare function extractRetryHint(source: Response | Headers | null | undefined, body?: string): number | undefined;
|
|
30
|
+
export declare function extractRetryHint(source: Response | Headers | null | undefined, body?: string, options?: RetryHintOptions): number | undefined;
|
|
26
31
|
export interface FetchWithRetryOptions extends RequestInit {
|
|
27
32
|
/** Total fetch attempts (initial + retries). Default `5`. */
|
|
28
33
|
maxAttempts?: number;
|
|
@@ -16,6 +16,8 @@ declare function tryAcquireLock(lockPath: string): NativeFileLock | null;
|
|
|
16
16
|
export declare function acquireFileLock(filePath: string, options?: FileLockOptions): Promise<FileLockHandle>;
|
|
17
17
|
/** Run `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
18
18
|
export declare function withFileLock<T>(filePath: string, fn: () => Promise<T>, options?: FileLockOptions): Promise<T>;
|
|
19
|
+
/** Run synchronous `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
20
|
+
export declare function withFileLockSync<T>(filePath: string, fn: () => T, options?: FileLockOptions): T;
|
|
19
21
|
/**
|
|
20
22
|
* Test-only acquisition handle for forcing ownership handoffs. This is not
|
|
21
23
|
* part of the supported package API.
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/mime.d.ts
CHANGED
package/dist/types/path.d.ts
CHANGED
|
@@ -2,3 +2,9 @@
|
|
|
2
2
|
export declare function windowsPathToWslMount(filePath: string): string | undefined;
|
|
3
3
|
/** Removes Win32 extended-length prefixes before passing paths to Bun APIs. */
|
|
4
4
|
export declare function stripWindowsExtendedLengthPathPrefix(filePath: string, platform?: NodeJS.Platform): string;
|
|
5
|
+
/**
|
|
6
|
+
* Test whether a path is fully qualified and drive-independent.
|
|
7
|
+
* On Windows, requires a drive letter with separator (e.g. `C:\`) or UNC (`\\server\share` or `//server/share`).
|
|
8
|
+
* On POSIX, requires an absolute path.
|
|
9
|
+
*/
|
|
10
|
+
export declare function isFullyQualifiedPath(filePath: string, platform?: NodeJS.Platform): boolean;
|
|
@@ -27,6 +27,27 @@ export declare enum Reason {
|
|
|
27
27
|
* instances across bundles/realms.
|
|
28
28
|
*/
|
|
29
29
|
export declare const NATIVE_PROCESS_EXIT: unique symbol;
|
|
30
|
+
/**
|
|
31
|
+
* Hard-exit the process through the native primitive, resolved on every call.
|
|
32
|
+
*
|
|
33
|
+
* The native exit is deliberately re-resolved here rather than bound at module
|
|
34
|
+
* load: the extension/hook loader's `withHostGuard` transiently swaps
|
|
35
|
+
* `process.reallyExit`/`process.exit` for a stub that throws
|
|
36
|
+
* `ExtensionExitError`, and the shipped bundle defers this module's evaluation
|
|
37
|
+
* until first access — which can land inside that guard window, so binding at
|
|
38
|
+
* init could freeze the throwing stub forever and turn every later shutdown
|
|
39
|
+
* (SIGHUP/SIGINT/fatal) into an unhandled-rejection loop (#7393). When the
|
|
40
|
+
* guard is active the stub carries the native exit under
|
|
41
|
+
* {@link NATIVE_PROCESS_EXIT} (#6488).
|
|
42
|
+
*
|
|
43
|
+
* Both globals are reinstalled to their natives before exiting: Bun's
|
|
44
|
+
* `process.exit` re-reads `process.reallyExit` at call time, so exiting through
|
|
45
|
+
* one primitive while its sibling still holds the throwing stub re-enters the
|
|
46
|
+
* guard and loops the rejection storm (#11789). After restoring, `reallyExit`
|
|
47
|
+
* (the low-level primitive) is preferred; `process.exit` and finally `SIGKILL`
|
|
48
|
+
* are fallbacks so a poisoned or absent chain can never leave the process alive.
|
|
49
|
+
*/
|
|
50
|
+
export declare function exitProcess(code: number): never;
|
|
30
51
|
/** User-facing command printed before fatal cleanup so interrupted work can be resumed. */
|
|
31
52
|
export interface FatalRecoveryHint {
|
|
32
53
|
/** Stable label identifying the recoverable session or process. */
|
|
@@ -91,11 +112,11 @@ export declare function isWorkerIpcDeserializeError(err: unknown): boolean;
|
|
|
91
112
|
*/
|
|
92
113
|
export declare function registerWorkerIpcFaultHandler(handler: (err: Error) => void): () => void;
|
|
93
114
|
/**
|
|
94
|
-
* Treat
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
115
|
+
* Treat a closed stdout consumer as a graceful peer disconnect for the caller's
|
|
116
|
+
* active lifetime. Attaches one shared `process.stdout` `error` listener,
|
|
117
|
+
* ref-counted across registrants (the ACP protocol server, the one-shot CLI
|
|
118
|
+
* entry). The returned callback removes the registration; the listener detaches
|
|
119
|
+
* when the last registrant unregisters.
|
|
99
120
|
*/
|
|
100
121
|
export declare function registerStdioDisconnectHandling(): () => void;
|
|
101
122
|
/**
|
package/dist/types/procmgr.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Subprocess } from "bun";
|
|
2
|
+
import { isExecutable } from "./executable.js";
|
|
3
|
+
export { isExecutable };
|
|
2
4
|
export interface ShellConfig {
|
|
3
5
|
shell: string;
|
|
4
6
|
args: string[];
|
|
@@ -10,10 +12,6 @@ export interface ShellConfigOptions {
|
|
|
10
12
|
/** File path or runtime layer that supplied the active shell setting. */
|
|
11
13
|
configSource?: string;
|
|
12
14
|
}
|
|
13
|
-
/**
|
|
14
|
-
* Check if a shell binary is executable.
|
|
15
|
-
*/
|
|
16
|
-
export declare function isExecutable(path: string): boolean;
|
|
17
15
|
/**
|
|
18
16
|
* Get shell args for the resolved shell.
|
|
19
17
|
* cmd.exe takes `/c`; PowerShell (powershell.exe / pwsh) takes
|
package/dist/types/sqlite.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
/**
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
export declare function readLines(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<Uint8Array>;
|
|
2
2
|
export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<T>;
|
|
3
|
+
/**
|
|
4
|
+
* Amortized byte accumulator for chunked stream readers.
|
|
5
|
+
*
|
|
6
|
+
* Holds the unconsumed tail of a stream in a single growing `Buffer` so that
|
|
7
|
+
* appending N chunks costs O(total bytes) instead of re-copying the whole
|
|
8
|
+
* prefix per chunk. Backs {@link readLines}, {@link readJsonl} and
|
|
9
|
+
* {@link readSseEvents}; also usable directly when a reader needs its own
|
|
10
|
+
* framing loop (see `consume` and `flush`).
|
|
11
|
+
*/
|
|
12
|
+
export declare class ConcatSink {
|
|
13
|
+
#private;
|
|
14
|
+
append(chunk: Uint8Array): void;
|
|
15
|
+
reset(chunk: Uint8Array): void;
|
|
16
|
+
get isEmpty(): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* The buffered bytes as a live view — invalidated by the next `append`,
|
|
19
|
+
* `reset` or `consume`.
|
|
20
|
+
*/
|
|
21
|
+
flush(): Uint8Array | undefined;
|
|
22
|
+
/** Drop the first `count` buffered bytes, keeping the remainder. */
|
|
23
|
+
consume(count: number): void;
|
|
24
|
+
clear(): void;
|
|
25
|
+
appendAndFlushLines(chunk: Uint8Array): Generator<Uint8Array<ArrayBufferLike>, void, unknown>;
|
|
26
|
+
appendAndFlushText(chunk: Uint8Array, decoder: TextDecoder): string | undefined;
|
|
27
|
+
pullJSONL<T>(chunk: Uint8Array, beg: number, end: number): Generator<T, void, unknown>;
|
|
28
|
+
}
|
|
3
29
|
/**
|
|
4
30
|
* Stream parsed JSON objects from SSE `data:` lines.
|
|
5
31
|
*
|
|
@@ -18,6 +44,25 @@ export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?
|
|
|
18
44
|
*/
|
|
19
45
|
export type SseEventObserver = (event: ServerSentEvent) => void;
|
|
20
46
|
export declare function readSseJson<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver): AsyncGenerator<T>;
|
|
47
|
+
/**
|
|
48
|
+
* Like {@link readSseJson}, but a `data:` frame that is not valid JSON is yielded
|
|
49
|
+
* as its raw text instead of raising a `SyntaxError`. Cut-off container-shaped
|
|
50
|
+
* stream tails stay recoverable, exactly as they are in {@link readSseJson}.
|
|
51
|
+
*
|
|
52
|
+
* Consumers that only understand objects must treat a `string` yield as a
|
|
53
|
+
* transport-level failure (for example a `429 Too Many Requests` or an HTML
|
|
54
|
+
* throttle page from a reverse proxy that already committed to the stream). This
|
|
55
|
+
* exists because `readSseJson`'s baseline consumers span unrelated transports
|
|
56
|
+
* whose error handling a text yield would subtly change; new call sites opt in.
|
|
57
|
+
*
|
|
58
|
+
* Note that the text lane is only the frames `JSON.parse` *rejected*: a frame
|
|
59
|
+
* carrying a JSON-encoded string (`data: "429 Too Many Requests"`) parses, so it
|
|
60
|
+
* is yielded as that string and is indistinguishable from a rejected frame by
|
|
61
|
+
* type alone. Consumers branching on `typeof === "string"` therefore see both,
|
|
62
|
+
* which is the safe direction — each is classified as text rather than trusted as
|
|
63
|
+
* an event object.
|
|
64
|
+
*/
|
|
65
|
+
export declare function readSseJsonOrText<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver): AsyncGenerator<T | string>;
|
|
21
66
|
/**
|
|
22
67
|
* A single Server-Sent Event dispatched on a blank-line boundary.
|
|
23
68
|
*
|
package/dist/types/which.d.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface WhichOptions extends Bun.WhichOptions {
|
|
|
25
25
|
* Defaults to `WhichCachePolicy.Fresh`.
|
|
26
26
|
*/
|
|
27
27
|
cache?: WhichCachePolicy;
|
|
28
|
+
/**
|
|
29
|
+
* Only search absolute directory entries in PATH, ignoring relative entries
|
|
30
|
+
* (e.g. `.` or `./bin`) and empty components to prevent resolving against
|
|
31
|
+
* an untrusted working directory.
|
|
32
|
+
*/
|
|
33
|
+
requireAbsolutePaths?: boolean;
|
|
28
34
|
}
|
|
29
35
|
declare function darwinWhich(command: string, options?: Bun.WhichOptions): string | null;
|
|
30
36
|
export declare const whichFresh: typeof darwinWhich;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-utils",
|
|
3
|
-
"version": "18.2.
|
|
3
|
+
"version": "18.2.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.
|
|
57
|
+
"@oh-my-pi/pi-natives": "18.2.2"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/bun": "^1.3.14"
|
package/src/acp/transport.ts
CHANGED
|
@@ -108,10 +108,15 @@ function createStandardError(
|
|
|
108
108
|
type Dispatcher = (method: string, params: unknown, notification: boolean) => MaybePromise<unknown>;
|
|
109
109
|
type Pending = { resolve(value: unknown): void; reject(reason: unknown): void };
|
|
110
110
|
|
|
111
|
+
/** Bound on clean-EOF inbound drain so `closed` cannot hang if a handler never settles. */
|
|
112
|
+
const INBOUND_DRAIN_TIMEOUT_MS = 30_000;
|
|
113
|
+
|
|
111
114
|
/** Correlated bidirectional JSON-RPC connection. */
|
|
112
115
|
export class RpcConnection {
|
|
113
116
|
#nextId = 0;
|
|
114
117
|
#pending = new Map<JsonRpcId, Pending>();
|
|
118
|
+
#inbound = new Set<Promise<void>>();
|
|
119
|
+
#openInboundIds = new Set<JsonRpcId>();
|
|
115
120
|
#writable: WritableStream<AnyMessage>;
|
|
116
121
|
#writeTail: Promise<void> = Promise.resolve();
|
|
117
122
|
#abort = new AbortController();
|
|
@@ -179,8 +184,9 @@ export class RpcConnection {
|
|
|
179
184
|
while (true) {
|
|
180
185
|
const next = await reader.read();
|
|
181
186
|
if (next.done) break;
|
|
182
|
-
|
|
187
|
+
this.#dispatch(next.value);
|
|
183
188
|
}
|
|
189
|
+
await this.#drainInbound();
|
|
184
190
|
this.close();
|
|
185
191
|
} catch (error) {
|
|
186
192
|
this.close(error);
|
|
@@ -189,6 +195,34 @@ export class RpcConnection {
|
|
|
189
195
|
}
|
|
190
196
|
}
|
|
191
197
|
|
|
198
|
+
#dispatch(message: AnyMessage): void {
|
|
199
|
+
if ("method" in message && "id" in message) this.#openInboundIds.add(message.id);
|
|
200
|
+
const task = this.#handle(message).catch(error => this.close(error));
|
|
201
|
+
this.#inbound.add(task);
|
|
202
|
+
void task.finally(() => this.#inbound.delete(task));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async #drainInbound(): Promise<void> {
|
|
206
|
+
if (this.#inbound.size === 0) return;
|
|
207
|
+
const drained = Promise.allSettled(this.#inbound).then(() => {});
|
|
208
|
+
let timer: Timer | undefined;
|
|
209
|
+
const timedOut = await Promise.race([
|
|
210
|
+
drained.then(() => false),
|
|
211
|
+
new Promise<boolean>(resolve => {
|
|
212
|
+
timer = setTimeout(() => resolve(true), INBOUND_DRAIN_TIMEOUT_MS);
|
|
213
|
+
}),
|
|
214
|
+
]);
|
|
215
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
216
|
+
if (!timedOut) return;
|
|
217
|
+
const error = RequestError.internalError(undefined, "Inbound request drain timed out").toErrorResponse();
|
|
218
|
+
await Promise.allSettled([...this.#openInboundIds].map(id => this.#respond(id, { error })));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#respond(id: JsonRpcId, body: { result: unknown } | { error: ErrorResponse }): Promise<void> {
|
|
222
|
+
if (!this.#openInboundIds.delete(id)) return Promise.resolve();
|
|
223
|
+
return this.#write({ jsonrpc: "2.0", id, ...body });
|
|
224
|
+
}
|
|
225
|
+
|
|
192
226
|
async #handle(message: AnyMessage): Promise<void> {
|
|
193
227
|
if ("id" in message && !("method" in message)) {
|
|
194
228
|
const pending = this.#pending.get(message.id);
|
|
@@ -210,13 +244,13 @@ export class RpcConnection {
|
|
|
210
244
|
}
|
|
211
245
|
try {
|
|
212
246
|
const result = await this.#dispatcher(message.method, message.params, false);
|
|
213
|
-
await this.#
|
|
247
|
+
await this.#respond(message.id, { result: result ?? {} });
|
|
214
248
|
} catch (error) {
|
|
215
249
|
const protocolError =
|
|
216
250
|
error instanceof RequestError
|
|
217
251
|
? error
|
|
218
252
|
: RequestError.internalError({ details: error instanceof Error ? error.message : String(error) });
|
|
219
|
-
await this.#
|
|
253
|
+
await this.#respond(message.id, { error: protocolError.toErrorResponse() });
|
|
220
254
|
}
|
|
221
255
|
}
|
|
222
256
|
}
|
package/src/dirs.ts
CHANGED
|
@@ -931,6 +931,16 @@ export function getTerminalSessionsDir(agentDir?: string): string {
|
|
|
931
931
|
return dirs.agentSubdir(agentDir, "terminal-sessions", "state");
|
|
932
932
|
}
|
|
933
933
|
|
|
934
|
+
/**
|
|
935
|
+
* Get the persistent registry of custom session files
|
|
936
|
+
* (~/.omp/agent/custom-session-files). Each `--session-dir`/`--session`
|
|
937
|
+
* transcript is recorded here as one marker file so storage GC can scan its
|
|
938
|
+
* exact path after its terminal breadcrumb is overwritten by a later session.
|
|
939
|
+
*/
|
|
940
|
+
export function getCustomSessionFilesDir(agentDir?: string): string {
|
|
941
|
+
return dirs.agentSubdir(agentDir, "custom-session-files", "state");
|
|
942
|
+
}
|
|
943
|
+
|
|
934
944
|
/** Get the crash log path (~/.omp/agent/omp-crash.log). */
|
|
935
945
|
export function getCrashLogPath(agentDir?: string): string {
|
|
936
946
|
return dirs.agentSubdir(agentDir, "omp-crash.log", "state");
|
package/src/env.ts
CHANGED
|
@@ -65,6 +65,48 @@ export function filterProcessEnv(env: Record<string, string | undefined>): Recor
|
|
|
65
65
|
}
|
|
66
66
|
return result;
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Git variables that pin a repository location. They describe the checkout the
|
|
70
|
+
* agent process itself was launched from (git hooks, `git --git-dir` wrappers),
|
|
71
|
+
* so forwarding them to a child shell makes `git` ignore the command's `cwd`
|
|
72
|
+
* and mutate the wrong worktree or index. Stripped from child shell envs so git
|
|
73
|
+
* rediscovers the repository from the working directory. Mirrors the
|
|
74
|
+
* `env_remove` list in `crates/pi-vcs/src/git/cli.rs`.
|
|
75
|
+
*/
|
|
76
|
+
const GIT_REPO_LOCATION_ENV_NAMES = [
|
|
77
|
+
"GIT_DIR",
|
|
78
|
+
"GIT_COMMON_DIR",
|
|
79
|
+
"GIT_WORK_TREE",
|
|
80
|
+
"GIT_INDEX_FILE",
|
|
81
|
+
"GIT_OBJECT_DIRECTORY",
|
|
82
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
|
83
|
+
] as const;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Removes {@link GIT_REPO_LOCATION_ENV_NAMES} from a copied child env in place.
|
|
87
|
+
*
|
|
88
|
+
* Windows environment lookups are case-insensitive, so a block that spells a
|
|
89
|
+
* variable `git_dir` is just as binding there; match case-insensitively on
|
|
90
|
+
* win32 and exactly elsewhere (POSIX env names are case-sensitive).
|
|
91
|
+
*/
|
|
92
|
+
export function stripGitRepoLocationEnv(
|
|
93
|
+
env: Record<string, string>,
|
|
94
|
+
platform: NodeJS.Platform = process.platform,
|
|
95
|
+
): void {
|
|
96
|
+
if (platform !== "win32") {
|
|
97
|
+
for (const name of GIT_REPO_LOCATION_ENV_NAMES) {
|
|
98
|
+
delete env[name];
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const folded = new Set<string>(GIT_REPO_LOCATION_ENV_NAMES.map(name => name.toLowerCase()));
|
|
103
|
+
for (const key of Object.keys(env)) {
|
|
104
|
+
if (folded.has(key.toLowerCase())) {
|
|
105
|
+
delete env[key];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
68
110
|
// Bun autoloads the project's dotenv files into `process.env` before user code
|
|
69
111
|
// runs — including inside `bun build --compile` binaries — so a snapshot of
|
|
70
112
|
// `Bun.env` is only pre-dotenv when autoloading was explicitly disabled. Linux
|
|
@@ -181,6 +223,9 @@ export function filterChildShellEnv(
|
|
|
181
223
|
delete result[key];
|
|
182
224
|
}
|
|
183
225
|
}
|
|
226
|
+
// Last, after dotenv merging: no source (inherited, launcher, or dotenv) may
|
|
227
|
+
// pin the child shell to the agent's own repository.
|
|
228
|
+
stripGitRepoLocationEnv(result);
|
|
184
229
|
return result;
|
|
185
230
|
}
|
|
186
231
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check if a file path exists, is a regular file, and has effective execute permission.
|
|
5
|
+
*/
|
|
6
|
+
export function isExecutable(filePath: string): boolean {
|
|
7
|
+
try {
|
|
8
|
+
const stat = fs.statSync(filePath);
|
|
9
|
+
if (!stat.isFile()) return false;
|
|
10
|
+
if (process.platform !== "win32") {
|
|
11
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
12
|
+
}
|
|
13
|
+
return true;
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
package/src/fetch-retry.ts
CHANGED
|
@@ -20,7 +20,11 @@ const RESET_IN_HR_MIN_PATTERN = /resets?\s+in\s+~?\s*(\d+(?:\.\d+)?)\s*hr\s*(\d+
|
|
|
20
20
|
// "Your limit will reset at 2026-09-01 09:44:51" / "reset at 2026-09-01T09:44:51Z"
|
|
21
21
|
const WILL_RESET_AT_PATTERN =
|
|
22
22
|
/(?:will\s+)?reset at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:?[0-9]{2})?)/i;
|
|
23
|
+
// Both grammars carry a timezone-naive wall clock. The default reading is UTC;
|
|
24
|
+
// a provider-specific offset (Z.AI/Zhipu Beijing time) is applied only through
|
|
25
|
+
// `RetryHintOptions.naiveResetTimezoneOffset`, never inferred from the language.
|
|
23
26
|
const CN_RESET_AT_PATTERN = /将在\s*([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-9]{2}:[0-9]{2})\s*重置/;
|
|
27
|
+
const RESET_AT_PATTERNS: readonly RegExp[] = [WILL_RESET_AT_PATTERN, CN_RESET_AT_PATTERN];
|
|
24
28
|
// "retry-after-ms=98497000" / "retry-after-ms: 7200000" / "retry-after-ms = 7200000"
|
|
25
29
|
const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
|
|
26
30
|
|
|
@@ -34,6 +38,12 @@ const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
|
|
|
34
38
|
// ignores the naive stamp) sleeps first, and the retry after it is the
|
|
35
39
|
// probe — success proves skew, a fresh 429 re-anchors with live timing.
|
|
36
40
|
|
|
41
|
+
/** Provider-specific interpretation for timezone-naive retry timestamps. */
|
|
42
|
+
export interface RetryHintOptions {
|
|
43
|
+
/** UTC offset appended to an absolute reset stamp that omits its timezone. */
|
|
44
|
+
naiveResetTimezoneOffset?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
/**
|
|
38
48
|
* Server-suggested retry delay extraction. Merges the patterns historically used
|
|
39
49
|
*
|
|
@@ -51,14 +61,18 @@ const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
|
|
|
51
61
|
* - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
|
|
52
62
|
* - `retry-after-ms=98497000` / `retry-after-ms: 7200000` / `retry-after-ms = 7200000`
|
|
53
63
|
* - `Your limit will reset at 2026-09-01 09:44:51` / `将在 2026-09-01 09:44:51 重置`
|
|
54
|
-
* (offset
|
|
55
|
-
*
|
|
64
|
+
* (a provider offset makes a naive wall clock authoritative; otherwise it
|
|
65
|
+
* resolves only when no relative signal is present)
|
|
56
66
|
*
|
|
57
67
|
* Returns `undefined` if no signal is found, or `0` when the provider
|
|
58
68
|
* explicitly asks for an immediate retry (`retry-after…=0`, or an absolute
|
|
59
69
|
* reset timestamp that has already elapsed).
|
|
60
70
|
*/
|
|
61
|
-
export function extractRetryHint(
|
|
71
|
+
export function extractRetryHint(
|
|
72
|
+
source: Response | Headers | null | undefined,
|
|
73
|
+
body?: string,
|
|
74
|
+
options?: RetryHintOptions,
|
|
75
|
+
): number | undefined {
|
|
62
76
|
const headers = source instanceof Headers ? source : (source?.headers ?? undefined);
|
|
63
77
|
if (headers) {
|
|
64
78
|
const retryAfterMs = headers.get("retry-after-ms");
|
|
@@ -142,25 +156,20 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
|
|
|
142
156
|
consider(totalMs > 0 ? totalMs : undefined);
|
|
143
157
|
}
|
|
144
158
|
}
|
|
145
|
-
for (const pattern of
|
|
159
|
+
for (const pattern of RESET_AT_PATTERNS) {
|
|
146
160
|
const match = pattern.exec(body);
|
|
147
161
|
if (!match?.[1]) continue;
|
|
148
162
|
// Offset-bearing stamps are unambiguous and compete by longest-wins.
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
163
|
+
// A configured provider offset makes an otherwise naive wall clock
|
|
164
|
+
// unambiguous too. Without one, preserve the relative-signal-first
|
|
165
|
+
// fallback and interpret the wall clock as UTC only when it stands alone.
|
|
152
166
|
const normalized = match[1].replace(" ", "T");
|
|
153
167
|
const hasOffset = /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/i.test(normalized);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
} else {
|
|
160
|
-
const parsed = Date.parse(`${normalized}Z`);
|
|
161
|
-
if (!Number.isNaN(parsed) && parsed > Date.now()) {
|
|
162
|
-
considerNaive(parsed - Date.now());
|
|
163
|
-
}
|
|
168
|
+
const configuredOffset = options?.naiveResetTimezoneOffset;
|
|
169
|
+
const parsed = Date.parse(hasOffset ? normalized : `${normalized}${configuredOffset ?? "Z"}`);
|
|
170
|
+
if (!Number.isNaN(parsed) && parsed > Date.now()) {
|
|
171
|
+
if (hasOffset || configuredOffset !== undefined) consider(parsed - Date.now());
|
|
172
|
+
else considerNaive(parsed - Date.now());
|
|
164
173
|
}
|
|
165
174
|
}
|
|
166
175
|
// OpenCode Go compound remainder ("Resets in 2hr 15min"): the generic
|
package/src/file-lock.ts
CHANGED
|
@@ -48,6 +48,19 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
|
|
|
48
48
|
throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function acquireLockSync(filePath: string, options: FileLockOptions = {}): NativeFileLock {
|
|
52
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
53
|
+
const lockPath = getLockPath(filePath);
|
|
54
|
+
|
|
55
|
+
for (let attempt = 0; attempt < opts.retries; attempt++) {
|
|
56
|
+
const lock = tryAcquireLock(lockPath);
|
|
57
|
+
if (lock) return lock;
|
|
58
|
+
if (attempt + 1 < opts.retries && opts.retryDelayMs > 0) Bun.sleepSync(opts.retryDelayMs);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
|
|
62
|
+
}
|
|
63
|
+
|
|
51
64
|
/** Run `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
52
65
|
export async function withFileLock<T>(
|
|
53
66
|
filePath: string,
|
|
@@ -62,6 +75,16 @@ export async function withFileLock<T>(
|
|
|
62
75
|
}
|
|
63
76
|
}
|
|
64
77
|
|
|
78
|
+
/** Run synchronous `fn` while holding an OS-backed exclusive lock for `filePath`. */
|
|
79
|
+
export function withFileLockSync<T>(filePath: string, fn: () => T, options: FileLockOptions = {}): T {
|
|
80
|
+
const lock = acquireLockSync(filePath, options);
|
|
81
|
+
try {
|
|
82
|
+
return fn();
|
|
83
|
+
} finally {
|
|
84
|
+
lock.release();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
/**
|
|
66
89
|
* Test-only acquisition handle for forcing ownership handoffs. This is not
|
|
67
90
|
* part of the supported package API.
|