@oh-my-pi/pi-utils 18.1.22 → 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 +40 -0
- package/THIRD-PARTY-NOTICES.txt +54 -25
- package/dist/types/async.d.ts +25 -0
- package/dist/types/browsers.d.ts +2 -30
- package/dist/types/dirs.d.ts +17 -0
- package/dist/types/env.d.ts +10 -2
- 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 -1
- package/dist/types/mime.d.ts +1 -0
- package/dist/types/path.d.ts +6 -0
- package/dist/types/peek-file.d.ts +2 -3
- package/dist/types/postmortem.d.ts +26 -5
- package/dist/types/procmgr.d.ts +2 -4
- package/dist/types/snowflake.d.ts +1 -0
- package/dist/types/stream.d.ts +45 -0
- package/dist/types/which.d.ts +6 -0
- package/package.json +2 -2
- package/src/acp/transport.ts +37 -3
- package/src/async.ts +37 -0
- package/src/browsers.ts +10 -169
- package/src/dirs.ts +27 -5
- package/src/env.ts +52 -33
- package/src/executable.ts +17 -0
- package/src/fetch-retry.ts +46 -18
- package/src/file-lock.ts +23 -0
- package/src/index.ts +1 -1
- package/src/logger.ts +16 -4
- package/src/mime.ts +3 -7
- package/src/path.ts +14 -0
- package/src/peek-file.ts +17 -67
- package/src/postmortem.ts +95 -47
- package/src/procmgr.ts +2 -12
- package/src/ptree.ts +36 -5
- package/src/snowflake.ts +12 -1
- package/src/stream.ts +118 -24
- package/src/which.ts +27 -1
- package/src/xml.ts +16 -0
- package/dist/types/glob.d.ts +0 -28
- package/src/glob.ts +0 -189
package/src/async.ts
CHANGED
|
@@ -1,3 +1,40 @@
|
|
|
1
|
+
import { scheduler } from "node:timers/promises";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Largest delay `setTimeout` (and `timers/promises` `scheduler.wait`)
|
|
5
|
+
* accepts without 32-bit signed overflow: larger values wrap and fire
|
|
6
|
+
* almost immediately instead of sleeping. Chunk day-scale provider waits
|
|
7
|
+
* (e.g. a monthly quota reset parsed from an error hint) so the full
|
|
8
|
+
* duration elapses instead of overflowing the timer.
|
|
9
|
+
*/
|
|
10
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Abortable sleep for arbitrarily long delays. Waits longer than
|
|
14
|
+
* {@link MAX_TIMER_DELAY_MS} chunk the sleep into back-to-back timer waits
|
|
15
|
+
* so no single timer overflows; an abort during any chunk rejects like
|
|
16
|
+
* `scheduler.wait`.
|
|
17
|
+
*
|
|
18
|
+
* The remainder is deliberately consumed by chunk, not recomputed from a
|
|
19
|
+
* monotonic deadline: deadline tracking never terminates under the repo's
|
|
20
|
+
* instant `scheduler.wait` mocks (retry-cap suites spy it to resolve
|
|
21
|
+
* immediately, so `deadline - performance.now()` never reaches zero and the
|
|
22
|
+
* loop spins forever). A premature native wake (Bun `uv_async_send`, see
|
|
23
|
+
* `sleepAtLeast` in `packages/agent/src/utils/yield.ts`) can therefore
|
|
24
|
+
* under-wait by the unelapsed chunk time — but that self-corrects downstream:
|
|
25
|
+
* credential blocks carry the true deadline independently of this sleep, so
|
|
26
|
+
* an early retry re-hits 429 and re-sleeps on a fresh server hint.
|
|
27
|
+
*/
|
|
28
|
+
export async function sleepLong(delayMs: number, signal?: AbortSignal): Promise<void> {
|
|
29
|
+
signal?.throwIfAborted();
|
|
30
|
+
let remaining = delayMs;
|
|
31
|
+
while (remaining > 0) {
|
|
32
|
+
await scheduler.wait(Math.min(remaining, MAX_TIMER_DELAY_MS), { signal });
|
|
33
|
+
remaining -= MAX_TIMER_DELAY_MS;
|
|
34
|
+
signal?.throwIfAborted();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
1
38
|
/**
|
|
2
39
|
* Wrap a promise with a timeout and optional abort signal.
|
|
3
40
|
* Rejects with the given error or a new error containing the given message if
|
package/src/browsers.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
import type * as fs from "node:fs";
|
|
1
|
+
/** Managed Chrome-for-Testing installation with bounded downloads and atomic publication. */
|
|
4
2
|
import * as fsp from "node:fs/promises";
|
|
5
3
|
import * as os from "node:os";
|
|
6
4
|
import * as path from "node:path";
|
|
@@ -8,7 +6,6 @@ import { type ArchiveLimits, extractArchive } from "./ar";
|
|
|
8
6
|
import { withFileLock } from "./file-lock";
|
|
9
7
|
|
|
10
8
|
const CHROME_FOR_TESTING_BASE_URL = "https://storage.googleapis.com/chrome-for-testing-public";
|
|
11
|
-
const CHROME_METADATA_BASE_URL = "https://googlechromelabs.github.io/chrome-for-testing";
|
|
12
9
|
// Installation is outside browser/Eval operation deadlines, but a stalled CDN
|
|
13
10
|
// must still release the cached install promise and permit a later attempt.
|
|
14
11
|
const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
|
|
@@ -25,15 +22,6 @@ const BROWSER_ARCHIVE_LIMITS: Partial<ArchiveLimits> = {
|
|
|
25
22
|
maxInMemorySize: 1024 * 1024 * 1024,
|
|
26
23
|
};
|
|
27
24
|
|
|
28
|
-
/** Supported browser products. */
|
|
29
|
-
export enum Browser {
|
|
30
|
-
CHROME = "chrome",
|
|
31
|
-
CHROMEHEADLESSSHELL = "chrome-headless-shell",
|
|
32
|
-
CHROMIUM = "chromium",
|
|
33
|
-
FIREFOX = "firefox",
|
|
34
|
-
CHROMEDRIVER = "chromedriver",
|
|
35
|
-
}
|
|
36
|
-
|
|
37
25
|
/** Browser download platform identifiers. */
|
|
38
26
|
export enum BrowserPlatform {
|
|
39
27
|
LINUX = "linux",
|
|
@@ -51,34 +39,6 @@ function requireChromeForTestingPlatform(platform: BrowserPlatform): ChromeForTe
|
|
|
51
39
|
return platform;
|
|
52
40
|
}
|
|
53
41
|
|
|
54
|
-
const BROWSERS = [
|
|
55
|
-
Browser.CHROME,
|
|
56
|
-
Browser.CHROMEHEADLESSSHELL,
|
|
57
|
-
Browser.CHROMIUM,
|
|
58
|
-
Browser.FIREFOX,
|
|
59
|
-
Browser.CHROMEDRIVER,
|
|
60
|
-
] as const;
|
|
61
|
-
const BROWSER_PLATFORMS = [
|
|
62
|
-
BrowserPlatform.LINUX,
|
|
63
|
-
BrowserPlatform.LINUX_ARM,
|
|
64
|
-
BrowserPlatform.MAC,
|
|
65
|
-
BrowserPlatform.MAC_ARM,
|
|
66
|
-
BrowserPlatform.WIN32,
|
|
67
|
-
BrowserPlatform.WIN64,
|
|
68
|
-
] as const;
|
|
69
|
-
|
|
70
|
-
/** Chrome-for-Testing release channel tags accepted by {@link resolveBuildId}. */
|
|
71
|
-
export enum BrowserTag {
|
|
72
|
-
CANARY = "canary",
|
|
73
|
-
NIGHTLY = "nightly",
|
|
74
|
-
BETA = "beta",
|
|
75
|
-
DEV = "dev",
|
|
76
|
-
DEVEDITION = "devedition",
|
|
77
|
-
STABLE = "stable",
|
|
78
|
-
ESR = "esr",
|
|
79
|
-
LATEST = "latest",
|
|
80
|
-
}
|
|
81
|
-
|
|
82
42
|
/** Download progress reported while a browser archive is streamed to disk. */
|
|
83
43
|
export interface BrowserDownloadProgress {
|
|
84
44
|
downloadedBytes: number;
|
|
@@ -87,7 +47,6 @@ export interface BrowserDownloadProgress {
|
|
|
87
47
|
|
|
88
48
|
/** Inputs used to locate an installed browser executable. */
|
|
89
49
|
export interface ComputeExecutablePathOptions {
|
|
90
|
-
browser: Browser;
|
|
91
50
|
buildId: string;
|
|
92
51
|
cacheDir: string;
|
|
93
52
|
platform?: BrowserPlatform;
|
|
@@ -99,27 +58,14 @@ export interface InstallOptions extends ComputeExecutablePathOptions {
|
|
|
99
58
|
downloadProgressCallback?: (progress: BrowserDownloadProgress) => void;
|
|
100
59
|
}
|
|
101
60
|
|
|
102
|
-
/** Metadata for
|
|
61
|
+
/** Metadata for a managed Chrome installation in Puppeteer's cache layout. */
|
|
103
62
|
export interface InstalledBrowser {
|
|
104
|
-
browser: Browser;
|
|
105
63
|
buildId: string;
|
|
106
64
|
platform: BrowserPlatform;
|
|
107
65
|
path: string;
|
|
108
66
|
executablePath: string;
|
|
109
67
|
}
|
|
110
68
|
|
|
111
|
-
interface LastKnownGoodVersions {
|
|
112
|
-
channels: Record<string, { version: string }>;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
interface MilestoneVersions {
|
|
116
|
-
milestones: Record<string, { version: string }>;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
interface PatchVersions {
|
|
120
|
-
builds: Record<string, { version: string }>;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
69
|
/** Detect the current host's Puppeteer browser platform. */
|
|
124
70
|
export function detectBrowserPlatform(): BrowserPlatform | undefined {
|
|
125
71
|
const platform = os.platform();
|
|
@@ -130,43 +76,8 @@ export function detectBrowserPlatform(): BrowserPlatform | undefined {
|
|
|
130
76
|
return undefined;
|
|
131
77
|
}
|
|
132
78
|
|
|
133
|
-
/** Resolve a Chrome-for-Testing channel, milestone, or build prefix to a full build ID. */
|
|
134
|
-
export async function resolveBuildId(
|
|
135
|
-
browser: Browser,
|
|
136
|
-
_platform: BrowserPlatform,
|
|
137
|
-
tag: string | BrowserTag,
|
|
138
|
-
): Promise<string> {
|
|
139
|
-
if (browser !== Browser.CHROME && browser !== Browser.CHROMEHEADLESSSHELL && browser !== Browser.CHROMEDRIVER) {
|
|
140
|
-
return tag;
|
|
141
|
-
}
|
|
142
|
-
if (/^\d+\.\d+\.\d+\.\d+$/.test(tag)) return tag;
|
|
143
|
-
|
|
144
|
-
const channel = tag === BrowserTag.LATEST ? "Canary" : chromeChannelName(tag);
|
|
145
|
-
if (channel) {
|
|
146
|
-
const metadata = await fetchMetadata<LastKnownGoodVersions>("last-known-good-versions.json");
|
|
147
|
-
const version = metadata.channels[channel]?.version;
|
|
148
|
-
if (!version) throw new Error(`Chrome channel ${tag} was not found in Chrome-for-Testing metadata`);
|
|
149
|
-
return version;
|
|
150
|
-
}
|
|
151
|
-
if (/^\d+$/.test(tag)) {
|
|
152
|
-
const metadata = await fetchMetadata<MilestoneVersions>("latest-versions-per-milestone.json");
|
|
153
|
-
return metadata.milestones[tag]?.version ?? tag;
|
|
154
|
-
}
|
|
155
|
-
if (/^\d+\.\d+\.\d+$/.test(tag)) {
|
|
156
|
-
const metadata = await fetchMetadata<PatchVersions>("latest-patch-versions-per-build.json");
|
|
157
|
-
return metadata.builds[tag]?.version ?? tag;
|
|
158
|
-
}
|
|
159
|
-
return tag;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
79
|
/** Return the Chrome-for-Testing archive URL for a browser build. */
|
|
163
|
-
export function getDownloadUrl(
|
|
164
|
-
browser: Browser,
|
|
165
|
-
platform: BrowserPlatform,
|
|
166
|
-
buildId: string,
|
|
167
|
-
baseUrl = CHROME_FOR_TESTING_BASE_URL,
|
|
168
|
-
): URL {
|
|
169
|
-
if (browser !== Browser.CHROME) throw new Error(`Unsupported browser download: ${browser}`);
|
|
80
|
+
export function getDownloadUrl(platform: BrowserPlatform, buildId: string, baseUrl = CHROME_FOR_TESTING_BASE_URL): URL {
|
|
170
81
|
const archivePlatform = chromeArchivePlatform(platform);
|
|
171
82
|
const root = baseUrl.replace(/\/$/, "");
|
|
172
83
|
return new URL(`${root}/${buildId}/${archivePlatform}/chrome-${archivePlatform}.zip`);
|
|
@@ -176,9 +87,8 @@ export function getDownloadUrl(
|
|
|
176
87
|
export function computeExecutablePath(options: ComputeExecutablePathOptions): string {
|
|
177
88
|
const detectedPlatform = options.platform ?? detectBrowserPlatform();
|
|
178
89
|
if (!detectedPlatform) throw new Error("Cannot determine a browser platform for this host");
|
|
179
|
-
if (options.browser !== Browser.CHROME) throw new Error(`Unsupported browser executable: ${options.browser}`);
|
|
180
90
|
const platform = requireChromeForTestingPlatform(detectedPlatform);
|
|
181
|
-
const installDir = installationDir(options.cacheDir,
|
|
91
|
+
const installDir = installationDir(options.cacheDir, platform, options.buildId);
|
|
182
92
|
switch (platform) {
|
|
183
93
|
case BrowserPlatform.LINUX:
|
|
184
94
|
return path.join(installDir, "chrome-linux64", "chrome");
|
|
@@ -207,52 +117,14 @@ export function computeExecutablePath(options: ComputeExecutablePathOptions): st
|
|
|
207
117
|
}
|
|
208
118
|
}
|
|
209
119
|
|
|
210
|
-
/** Scan a Puppeteer cache for browser installation directories. */
|
|
211
|
-
export async function getInstalledBrowsers(options: { cacheDir: string }): Promise<InstalledBrowser[]> {
|
|
212
|
-
const installed: InstalledBrowser[] = [];
|
|
213
|
-
for (const browser of BROWSERS) {
|
|
214
|
-
const browserDir = path.join(options.cacheDir, browser);
|
|
215
|
-
let entries: fs.Dirent[];
|
|
216
|
-
try {
|
|
217
|
-
entries = await fsp.readdir(browserDir, { withFileTypes: true });
|
|
218
|
-
} catch (error) {
|
|
219
|
-
if (isMissingPath(error)) continue;
|
|
220
|
-
throw error;
|
|
221
|
-
}
|
|
222
|
-
for (const entry of entries) {
|
|
223
|
-
if (!entry.isDirectory()) continue;
|
|
224
|
-
const parsed = parseInstallationName(entry.name);
|
|
225
|
-
if (!parsed) continue;
|
|
226
|
-
const installPath = path.join(browserDir, entry.name);
|
|
227
|
-
try {
|
|
228
|
-
installed.push({
|
|
229
|
-
browser,
|
|
230
|
-
buildId: parsed.buildId,
|
|
231
|
-
platform: parsed.platform,
|
|
232
|
-
path: installPath,
|
|
233
|
-
executablePath: computeExecutablePath({
|
|
234
|
-
browser,
|
|
235
|
-
buildId: parsed.buildId,
|
|
236
|
-
cacheDir: options.cacheDir,
|
|
237
|
-
platform: parsed.platform,
|
|
238
|
-
}),
|
|
239
|
-
});
|
|
240
|
-
} catch {
|
|
241
|
-
// Other browser products are not part of the surface used by OMP.
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return installed;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
120
|
/** Download and unpack Chrome into Puppeteer's existing cache layout. */
|
|
249
121
|
export async function install(options: InstallOptions): Promise<InstalledBrowser> {
|
|
250
122
|
const platform = options.platform ?? detectBrowserPlatform();
|
|
251
123
|
if (!platform) throw new Error("Cannot determine a browser platform for this host");
|
|
252
124
|
const executablePath = computeExecutablePath({ ...options, platform });
|
|
253
|
-
const installPath = installationDir(options.cacheDir,
|
|
125
|
+
const installPath = installationDir(options.cacheDir, platform, options.buildId);
|
|
254
126
|
if (await pathExists(executablePath)) {
|
|
255
|
-
return {
|
|
127
|
+
return { buildId: options.buildId, platform, path: installPath, executablePath };
|
|
256
128
|
}
|
|
257
129
|
|
|
258
130
|
await fsp.mkdir(path.dirname(installPath), { recursive: true });
|
|
@@ -267,7 +139,7 @@ export async function install(options: InstallOptions): Promise<InstalledBrowser
|
|
|
267
139
|
const stagingPath = path.join(options.cacheDir, `.browser-${nonce}`);
|
|
268
140
|
try {
|
|
269
141
|
await downloadArchive(
|
|
270
|
-
getDownloadUrl(
|
|
142
|
+
getDownloadUrl(platform, options.buildId, options.baseUrl),
|
|
271
143
|
archivePath,
|
|
272
144
|
options.downloadProgressCallback,
|
|
273
145
|
);
|
|
@@ -284,34 +156,12 @@ export async function install(options: InstallOptions): Promise<InstalledBrowser
|
|
|
284
156
|
]);
|
|
285
157
|
}
|
|
286
158
|
}
|
|
287
|
-
return {
|
|
159
|
+
return { buildId: options.buildId, platform, path: installPath, executablePath };
|
|
288
160
|
},
|
|
289
161
|
{ retries: Math.ceil(DOWNLOAD_TIMEOUT_MS / 100) + 1, retryDelayMs: 100 },
|
|
290
162
|
);
|
|
291
163
|
}
|
|
292
164
|
|
|
293
|
-
function chromeChannelName(tag: string): string | undefined {
|
|
294
|
-
switch (tag) {
|
|
295
|
-
case BrowserTag.STABLE:
|
|
296
|
-
return "Stable";
|
|
297
|
-
case BrowserTag.BETA:
|
|
298
|
-
return "Beta";
|
|
299
|
-
case BrowserTag.DEV:
|
|
300
|
-
return "Dev";
|
|
301
|
-
case BrowserTag.CANARY:
|
|
302
|
-
return "Canary";
|
|
303
|
-
default:
|
|
304
|
-
return undefined;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
async function fetchMetadata<T>(filename: string): Promise<T> {
|
|
309
|
-
const response = await fetch(`${CHROME_METADATA_BASE_URL}/${filename}`, { signal: AbortSignal.timeout(30_000) });
|
|
310
|
-
if (!response.ok)
|
|
311
|
-
throw new Error(`Failed to fetch Chrome-for-Testing metadata (${response.status} ${response.statusText})`);
|
|
312
|
-
return (await response.json()) as T;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
165
|
function chromeArchivePlatform(platform: BrowserPlatform): string {
|
|
316
166
|
switch (requireChromeForTestingPlatform(platform)) {
|
|
317
167
|
case BrowserPlatform.LINUX:
|
|
@@ -327,17 +177,8 @@ function chromeArchivePlatform(platform: BrowserPlatform): string {
|
|
|
327
177
|
}
|
|
328
178
|
}
|
|
329
179
|
|
|
330
|
-
function installationDir(cacheDir: string,
|
|
331
|
-
return path.join(cacheDir,
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function parseInstallationName(name: string): { platform: BrowserPlatform; buildId: string } | undefined {
|
|
335
|
-
for (const platform of BROWSER_PLATFORMS) {
|
|
336
|
-
const prefix = `${platform}-`;
|
|
337
|
-
if (name.startsWith(prefix) && name.length > prefix.length)
|
|
338
|
-
return { platform, buildId: name.slice(prefix.length) };
|
|
339
|
-
}
|
|
340
|
-
return undefined;
|
|
180
|
+
function installationDir(cacheDir: string, platform: BrowserPlatform, buildId: string): string {
|
|
181
|
+
return path.join(cacheDir, "chrome", `${platform}-${buildId}`);
|
|
341
182
|
}
|
|
342
183
|
|
|
343
184
|
function isMissingPath(error: unknown): boolean {
|
package/src/dirs.ts
CHANGED
|
@@ -165,19 +165,31 @@ export function normalizePathForComparison(inputPath: string): string {
|
|
|
165
165
|
return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Compare paths already normalized by {@link normalizePathForComparison}.
|
|
170
|
+
*
|
|
171
|
+
* Returns the relative path (an empty string when the paths are equal), or
|
|
172
|
+
* `null` when the candidate is outside the root. Callers classifying one
|
|
173
|
+
* candidate against several static roots can normalize each side once and
|
|
174
|
+
* reuse the public helpers' containment semantics without repeating realpath
|
|
175
|
+
* work.
|
|
176
|
+
*/
|
|
177
|
+
export function relativePathWithinNormalizedRoot(normalizedRoot: string, normalizedCandidate: string): string | null {
|
|
178
|
+
const relative = path.relative(normalizedRoot, normalizedCandidate);
|
|
179
|
+
if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) return null;
|
|
180
|
+
return relative;
|
|
181
|
+
}
|
|
182
|
+
|
|
168
183
|
export function pathIsWithin(root: string, candidate: string): boolean {
|
|
169
184
|
const normalizedRoot = normalizePathForComparison(root);
|
|
170
185
|
const normalizedCandidate = normalizePathForComparison(candidate);
|
|
171
|
-
|
|
172
|
-
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
186
|
+
return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) !== null;
|
|
173
187
|
}
|
|
174
188
|
|
|
175
189
|
export function relativePathWithinRoot(root: string, candidate: string): string | null {
|
|
176
|
-
if (!pathIsWithin(root, candidate)) return null;
|
|
177
190
|
const normalizedRoot = normalizePathForComparison(root);
|
|
178
191
|
const normalizedCandidate = normalizePathForComparison(candidate);
|
|
179
|
-
|
|
180
|
-
return relative || null;
|
|
192
|
+
return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) || null;
|
|
181
193
|
}
|
|
182
194
|
|
|
183
195
|
let projectDir: string | undefined;
|
|
@@ -919,6 +931,16 @@ export function getTerminalSessionsDir(agentDir?: string): string {
|
|
|
919
931
|
return dirs.agentSubdir(agentDir, "terminal-sessions", "state");
|
|
920
932
|
}
|
|
921
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
|
+
|
|
922
944
|
/** Get the crash log path (~/.omp/agent/omp-crash.log). */
|
|
923
945
|
export function getCrashLogPath(agentDir?: string): string {
|
|
924
946
|
return dirs.agentSubdir(agentDir, "omp-crash.log", "state");
|
package/src/env.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { parseEnv } from "node:util";
|
|
4
5
|
import { getAgentDir, getConfigRootDir, getProjectDir, refreshDirsFromEnv } from "./dirs";
|
|
5
6
|
|
|
6
7
|
export * from "./worker-host";
|
|
@@ -64,6 +65,48 @@ export function filterProcessEnv(env: Record<string, string | undefined>): Recor
|
|
|
64
65
|
}
|
|
65
66
|
return result;
|
|
66
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
|
+
|
|
67
110
|
// Bun autoloads the project's dotenv files into `process.env` before user code
|
|
68
111
|
// runs — including inside `bun build --compile` binaries — so a snapshot of
|
|
69
112
|
// `Bun.env` is only pre-dotenv when autoloading was explicitly disabled. Linux
|
|
@@ -180,48 +223,24 @@ export function filterChildShellEnv(
|
|
|
180
223
|
delete result[key];
|
|
181
224
|
}
|
|
182
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);
|
|
183
229
|
return result;
|
|
184
230
|
}
|
|
185
231
|
|
|
186
232
|
/**
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* unquoted values, and single/double/backtick quoting (a `#` inside quotes
|
|
190
|
-
* stays literal). Returns undefined for blank lines, comments, and malformed
|
|
191
|
-
* names.
|
|
192
|
-
*/
|
|
193
|
-
function parseEnvLine(line: string): { key: string; value: string } | undefined {
|
|
194
|
-
const trimmed = line.trim();
|
|
195
|
-
if (!trimmed || trimmed.startsWith("#")) return undefined;
|
|
196
|
-
const eqIndex = trimmed.indexOf("=");
|
|
197
|
-
if (eqIndex === -1) return undefined;
|
|
198
|
-
let key = trimmed.slice(0, eqIndex).trim();
|
|
199
|
-
const exported = key.match(/^export[ \t]+(.*)$/);
|
|
200
|
-
if (exported) key = exported[1].trim();
|
|
201
|
-
if (!isValidEnvName(key)) return undefined;
|
|
202
|
-
const raw = trimmed.slice(eqIndex + 1).replace(/^[ \t]+/, "");
|
|
203
|
-
const quote = raw[0];
|
|
204
|
-
if (quote === '"' || quote === "'" || quote === "`") {
|
|
205
|
-
let close = raw.indexOf(quote, 1);
|
|
206
|
-
while (close !== -1 && raw[close - 1] === "\\") close = raw.indexOf(quote, close + 1);
|
|
207
|
-
return { key, value: close === -1 ? raw.slice(1) : raw.slice(1, close) };
|
|
208
|
-
}
|
|
209
|
-
const commentIndex = raw.search(/[ \t]#/);
|
|
210
|
-
return { key, value: (commentIndex === -1 ? raw : raw.slice(0, commentIndex)).trimEnd() };
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Parses a .env file synchronously into key-value string pairs using
|
|
215
|
-
* {@link parseEnvLine} for Bun-compatible line semantics, then mirrors valid
|
|
233
|
+
* Parses a complete .env file with the runtime's dotenv grammar, then retains
|
|
234
|
+
* only shell-identifier names and spawn-safe values before mirroring valid
|
|
216
235
|
* `OMP_` variables to their `PI_` aliases.
|
|
217
236
|
*/
|
|
218
237
|
export function parseEnvFile(filePath: string): Record<string, string> {
|
|
219
238
|
const result: Record<string, string> = {};
|
|
220
239
|
try {
|
|
221
|
-
const
|
|
222
|
-
for (const
|
|
223
|
-
const
|
|
224
|
-
if (
|
|
240
|
+
const parsed = parseEnv(fs.readFileSync(filePath, "utf-8"));
|
|
241
|
+
for (const key in parsed) {
|
|
242
|
+
const value = parsed[key];
|
|
243
|
+
if (value !== undefined && isValidEnvName(key) && isSafeEnvValue(value)) result[key] = value;
|
|
225
244
|
}
|
|
226
245
|
} catch {
|
|
227
246
|
// File doesn't exist or can't be read - return empty result
|
|
@@ -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
|
@@ -11,11 +11,20 @@ const RETRY_DELAY_FIELD_PATTERN = /"retryDelay":\s*"([0-9.]+)(ms|s)"/i;
|
|
|
11
11
|
// "try again in 90 minutes" / "try again in 1 hour"
|
|
12
12
|
const TRY_AGAIN_PATTERN = /try again in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i;
|
|
13
13
|
// "Your limit will reset in 13 minutes" / "reset in 13 minutes" / "will reset in 2h"
|
|
14
|
-
|
|
14
|
+
// OpenCode Go quota errors use "Resets in …" with day units and compound
|
|
15
|
+
// remainders ("Resets in 3 days", "Resets in 2hr 15min", "Resets in 45min").
|
|
16
|
+
const WILL_RESET_IN_PATTERN =
|
|
17
|
+
/(?:will\s+)?resets?\s+in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\b/i;
|
|
18
|
+
// OpenCode Go compound remainder: "Resets in 2hr 15min".
|
|
19
|
+
const RESET_IN_HR_MIN_PATTERN = /resets?\s+in\s+~?\s*(\d+(?:\.\d+)?)\s*hr\s*(\d+(?:\.\d+)?)\s*min\b/i;
|
|
15
20
|
// "Your limit will reset at 2026-09-01 09:44:51" / "reset at 2026-09-01T09:44:51Z"
|
|
16
21
|
const WILL_RESET_AT_PATTERN =
|
|
17
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.
|
|
18
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];
|
|
19
28
|
// "retry-after-ms=98497000" / "retry-after-ms: 7200000" / "retry-after-ms = 7200000"
|
|
20
29
|
const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
|
|
21
30
|
|
|
@@ -29,6 +38,12 @@ const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
|
|
|
29
38
|
// ignores the naive stamp) sleeps first, and the retry after it is the
|
|
30
39
|
// probe — success proves skew, a fresh 429 re-anchors with live timing.
|
|
31
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
|
+
|
|
32
47
|
/**
|
|
33
48
|
* Server-suggested retry delay extraction. Merges the patterns historically used
|
|
34
49
|
*
|
|
@@ -46,14 +61,18 @@ const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
|
|
|
46
61
|
* - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
|
|
47
62
|
* - `retry-after-ms=98497000` / `retry-after-ms: 7200000` / `retry-after-ms = 7200000`
|
|
48
63
|
* - `Your limit will reset at 2026-09-01 09:44:51` / `将在 2026-09-01 09:44:51 重置`
|
|
49
|
-
* (offset
|
|
50
|
-
*
|
|
64
|
+
* (a provider offset makes a naive wall clock authoritative; otherwise it
|
|
65
|
+
* resolves only when no relative signal is present)
|
|
51
66
|
*
|
|
52
67
|
* Returns `undefined` if no signal is found, or `0` when the provider
|
|
53
68
|
* explicitly asks for an immediate retry (`retry-after…=0`, or an absolute
|
|
54
69
|
* reset timestamp that has already elapsed).
|
|
55
70
|
*/
|
|
56
|
-
export function extractRetryHint(
|
|
71
|
+
export function extractRetryHint(
|
|
72
|
+
source: Response | Headers | null | undefined,
|
|
73
|
+
body?: string,
|
|
74
|
+
options?: RetryHintOptions,
|
|
75
|
+
): number | undefined {
|
|
57
76
|
const headers = source instanceof Headers ? source : (source?.headers ?? undefined);
|
|
58
77
|
if (headers) {
|
|
59
78
|
const retryAfterMs = headers.get("retry-after-ms");
|
|
@@ -137,25 +156,30 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
|
|
|
137
156
|
consider(totalMs > 0 ? totalMs : undefined);
|
|
138
157
|
}
|
|
139
158
|
}
|
|
140
|
-
for (const pattern of
|
|
159
|
+
for (const pattern of RESET_AT_PATTERNS) {
|
|
141
160
|
const match = pattern.exec(body);
|
|
142
161
|
if (!match?.[1]) continue;
|
|
143
162
|
// Offset-bearing stamps are unambiguous and compete by longest-wins.
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
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.
|
|
147
166
|
const normalized = match[1].replace(" ", "T");
|
|
148
167
|
const hasOffset = /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/i.test(normalized);
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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());
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// OpenCode Go compound remainder ("Resets in 2hr 15min"): the generic
|
|
176
|
+
// pattern only captures the leading "2hr", so add the trailing minutes.
|
|
177
|
+
const compoundResetMatch = RESET_IN_HR_MIN_PATTERN.exec(body);
|
|
178
|
+
if (compoundResetMatch?.[1] && compoundResetMatch[2]) {
|
|
179
|
+
const hours = Number.parseFloat(compoundResetMatch[1]);
|
|
180
|
+
const minutes = Number.parseFloat(compoundResetMatch[2]);
|
|
181
|
+
if (Number.isFinite(hours) && Number.isFinite(minutes) && hours >= 0 && minutes > 0) {
|
|
182
|
+
consider(hours * 60 * 60_000 + minutes * 60_000);
|
|
159
183
|
}
|
|
160
184
|
}
|
|
161
185
|
const accountResetMatch = WILL_RESET_IN_PATTERN.exec(body);
|
|
@@ -241,6 +265,10 @@ function unitToMs(unit: string): number | undefined {
|
|
|
241
265
|
case "hour":
|
|
242
266
|
case "hours":
|
|
243
267
|
return 60 * 60_000;
|
|
268
|
+
case "d":
|
|
269
|
+
case "day":
|
|
270
|
+
case "days":
|
|
271
|
+
return 24 * 60 * 60_000;
|
|
244
272
|
default:
|
|
245
273
|
return undefined;
|
|
246
274
|
}
|
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,12 +4,12 @@ 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";
|
|
10
11
|
export * from "./frontmatter";
|
|
11
12
|
export * from "./fs-error";
|
|
12
|
-
export * from "./glob";
|
|
13
13
|
export * from "./incoming-json";
|
|
14
14
|
export * from "./json";
|
|
15
15
|
export * from "./json-parse";
|