@linxiraos/pi-utils 1.1.14 → 1.1.16

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +2 -52
  2. package/THIRD-PARTY-NOTICES.txt +54 -25
  3. package/dist/types/async.d.ts +18 -0
  4. package/dist/types/browsers.d.ts +2 -30
  5. package/dist/types/color.d.ts +2 -0
  6. package/dist/types/dirs.d.ts +21 -0
  7. package/dist/types/env.d.ts +12 -2
  8. package/dist/types/executable.d.ts +4 -0
  9. package/dist/types/fetch-retry.d.ts +8 -2
  10. package/dist/types/file-lock.d.ts +12 -0
  11. package/dist/types/format.d.ts +7 -0
  12. package/dist/types/index.d.ts +2 -1
  13. package/dist/types/mime.d.ts +1 -0
  14. package/dist/types/path.d.ts +6 -0
  15. package/dist/types/peek-file.d.ts +2 -3
  16. package/dist/types/postmortem.d.ts +26 -5
  17. package/dist/types/procmgr.d.ts +2 -4
  18. package/dist/types/snowflake.d.ts +1 -0
  19. package/dist/types/sqlite.d.ts +27 -7
  20. package/dist/types/stream.d.ts +76 -1
  21. package/dist/types/which.d.ts +8 -2
  22. package/dist/types/yaml-config.d.ts +2 -0
  23. package/package.json +2 -2
  24. package/src/acp/transport.ts +37 -3
  25. package/src/async.ts +31 -0
  26. package/src/browsers.ts +46 -191
  27. package/src/color.ts +1 -1
  28. package/src/dirs.ts +34 -6
  29. package/src/env.ts +81 -34
  30. package/src/executable.ts +17 -0
  31. package/src/fetch-retry.ts +72 -13
  32. package/src/file-lock.ts +36 -2
  33. package/src/format.ts +16 -0
  34. package/src/index.ts +2 -1
  35. package/src/json.ts +12 -1
  36. package/src/logger/rotating-file.ts +40 -3
  37. package/src/logger.ts +16 -4
  38. package/src/mime.ts +3 -7
  39. package/src/path.ts +14 -0
  40. package/src/peek-file.ts +17 -67
  41. package/src/postmortem.ts +95 -47
  42. package/src/procmgr.ts +2 -12
  43. package/src/ptree.ts +36 -5
  44. package/src/snowflake.ts +12 -1
  45. package/src/sqlite.ts +242 -7
  46. package/src/stream.ts +167 -35
  47. package/src/which.ts +38 -14
  48. package/src/xml.ts +16 -0
  49. package/src/yaml-config.ts +8 -0
  50. package/dist/types/glob.d.ts +0 -28
  51. package/src/glob.ts +0 -189
@@ -1,5 +1,43 @@
1
+ /**
2
+ * Split a byte stream on LF boundaries.
3
+ *
4
+ * Every yielded line owns its bytes and remains unchanged after the generator
5
+ * advances or drains. Line terminators are excluded.
6
+ */
1
7
  export declare function readLines(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<Uint8Array>;
2
8
  export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<T>;
9
+ /**
10
+ * Amortized byte accumulator for chunked stream readers.
11
+ *
12
+ * Holds the unconsumed tail of a stream in a single growing `Buffer` so that
13
+ * appending N chunks costs O(total bytes) instead of re-copying the whole
14
+ * prefix per chunk. Backs {@link readLines}, {@link readJsonl} and
15
+ * {@link readSseEvents}; also usable directly when a reader needs its own
16
+ * framing loop (see `consume` and `flush`).
17
+ */
18
+ export declare class ConcatSink {
19
+ #private;
20
+ append(chunk: Uint8Array): void;
21
+ reset(chunk: Uint8Array): void;
22
+ get isEmpty(): boolean;
23
+ /**
24
+ * The buffered bytes as a live view — invalidated by the next `append`,
25
+ * `reset` or `consume`.
26
+ */
27
+ flush(): Uint8Array | undefined;
28
+ /** Drop the first `count` buffered bytes, keeping the remainder. */
29
+ consume(count: number): void;
30
+ clear(): void;
31
+ /**
32
+ * Append a chunk and yield each complete LF-delimited line.
33
+ *
34
+ * Yielded lines are owned snapshots. Unlike {@link flush}, they remain
35
+ * valid after this sink or the input chunk is mutated.
36
+ */
37
+ appendAndFlushLines(chunk: Uint8Array): Generator<Uint8Array>;
38
+ appendAndFlushText(chunk: Uint8Array, decoder: TextDecoder): string | undefined;
39
+ pullJSONL<T>(chunk: Uint8Array, beg: number, end: number): Generator<T, void, unknown>;
40
+ }
3
41
  /**
4
42
  * Stream parsed JSON objects from SSE `data:` lines.
5
43
  *
@@ -18,6 +56,25 @@ export declare function readJsonl<T>(stream: ReadableStream<Uint8Array>, signal?
18
56
  */
19
57
  export type SseEventObserver = (event: ServerSentEvent) => void;
20
58
  export declare function readSseJson<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver): AsyncGenerator<T>;
59
+ /**
60
+ * Like {@link readSseJson}, but a `data:` frame that is not valid JSON is yielded
61
+ * as its raw text instead of raising a `SyntaxError`. Cut-off container-shaped
62
+ * stream tails stay recoverable, exactly as they are in {@link readSseJson}.
63
+ *
64
+ * Consumers that only understand objects must treat a `string` yield as a
65
+ * transport-level failure (for example a `429 Too Many Requests` or an HTML
66
+ * throttle page from a reverse proxy that already committed to the stream). This
67
+ * exists because `readSseJson`'s baseline consumers span unrelated transports
68
+ * whose error handling a text yield would subtly change; new call sites opt in.
69
+ *
70
+ * Note that the text lane is only the frames `JSON.parse` *rejected*: a frame
71
+ * carrying a JSON-encoded string (`data: "429 Too Many Requests"`) parses, so it
72
+ * is yielded as that string and is indistinguishable from a rejected frame by
73
+ * type alone. Consumers branching on `typeof === "string"` therefore see both,
74
+ * which is the safe direction — each is classified as text rather than trusted as
75
+ * an event object.
76
+ */
77
+ export declare function readSseJsonOrText<T>(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, onEvent?: SseEventObserver): AsyncGenerator<T | string>;
21
78
  /**
22
79
  * A single Server-Sent Event dispatched on a blank-line boundary.
23
80
  *
@@ -34,6 +91,15 @@ export declare function readSseJson<T>(stream: ReadableStream<Uint8Array>, signa
34
91
  export interface ServerSentEvent {
35
92
  event: string | null;
36
93
  data: string;
94
+ /**
95
+ * Decoded wire lines for this event (`event:`/`data:`/etc.), for the
96
+ * diagnostic pipeline. Populated only when the reader opts in via
97
+ * {@link ReadSseEventsOptions.captureRaw} (or attaches an `onSseEvent`
98
+ * observer to the JSON readers, which opt in automatically); otherwise
99
+ * `[]`. Direct `readSseEvents` callers that need wire text must pass
100
+ * `{ captureRaw: true }` — the field is allocation-free by default so
101
+ * the token path pays no per-frame array/slice cost.
102
+ */
37
103
  raw: string[];
38
104
  id?: string;
39
105
  retry?: number;
@@ -57,7 +123,16 @@ export interface ServerSentEvent {
57
123
  * }
58
124
  * ```
59
125
  */
60
- export declare function readSseEvents(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<ServerSentEvent>;
126
+ export interface ReadSseEventsOptions {
127
+ /**
128
+ * Capture per-line wire text into `event.raw` for the diagnostic
129
+ * pipeline (`onSseEvent` observers, raw-SSE viewer). Off by default:
130
+ * every frame otherwise pays an array allocation plus one string slice
131
+ * per line on the token path.
132
+ */
133
+ captureRaw?: boolean;
134
+ }
135
+ export declare function readSseEvents(stream: ReadableStream<Uint8Array>, signal?: AbortSignal, options?: ReadSseEventsOptions): AsyncGenerator<ServerSentEvent>;
61
136
  /**
62
137
  * Parse a complete JSONL string, skipping malformed lines instead of throwing.
63
138
  *
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cache policy for which lookups.
3
3
  */
4
- export declare const enum WhichCachePolicy {
4
+ export declare enum WhichCachePolicy {
5
5
  /**
6
6
  * Use cached result if available.
7
7
  */
@@ -22,9 +22,15 @@ export declare const enum WhichCachePolicy {
22
22
  export interface WhichOptions extends Bun.WhichOptions {
23
23
  /**
24
24
  * Cache policy for the lookup.
25
- * Defaults to `WhichCachePolicy.Fresh`.
25
+ * Defaults to `WhichCachePolicy.Cached`.
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;
@@ -0,0 +1,2 @@
1
+ /** Serialize config YAML without Bun's trailing space on block mapping headers. */
2
+ export declare function stringifyYamlConfig(value: unknown): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linxiraos/pi-utils",
3
- "version": "1.1.14",
3
+ "version": "1.1.16",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "keywords": [
6
6
  "utilities",
@@ -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
- "@linxiraos/pi-natives": "1.1.14"
57
+ "@linxiraos/pi-natives": "1.1.16"
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/async.ts CHANGED
@@ -1,3 +1,34 @@
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
+ * Uses a monotonic deadline so a timer that wakes prematurely is re-armed for
19
+ * the unelapsed duration instead of shortening the requested sleep.
20
+ */
21
+ export async function sleepLong(delayMs: number, signal?: AbortSignal): Promise<void> {
22
+ signal?.throwIfAborted();
23
+ const deadline = performance.now() + delayMs;
24
+ while (true) {
25
+ const remaining = deadline - performance.now();
26
+ if (!(remaining > 0)) return;
27
+ await scheduler.wait(Math.min(remaining, MAX_TIMER_DELAY_MS), { signal });
28
+ signal?.throwIfAborted();
29
+ }
30
+ }
31
+
1
32
  /**
2
33
  * Wrap a promise with a timeout and optional abort signal.
3
34
  * Rejects with the given error or a new error containing the given message if
package/src/browsers.ts CHANGED
@@ -1,13 +1,14 @@
1
- /** Behavior-compatible reimplementation of @puppeteer/browsers' used surface. */
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";
7
5
  import { type ArchiveLimits, extractArchive } from "./ar";
6
+ import { withFileLock } from "./file-lock";
8
7
 
9
8
  const CHROME_FOR_TESTING_BASE_URL = "https://storage.googleapis.com/chrome-for-testing-public";
10
- const CHROME_METADATA_BASE_URL = "https://googlechromelabs.github.io/chrome-for-testing";
9
+ // Installation is outside browser/Eval operation deadlines, but a stalled CDN
10
+ // must still release the cached install promise and permit a later attempt.
11
+ const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
11
12
 
12
13
  /**
13
14
  * Archive ceilings for the managed browser download. The default archive
@@ -21,15 +22,6 @@ const BROWSER_ARCHIVE_LIMITS: Partial<ArchiveLimits> = {
21
22
  maxInMemorySize: 1024 * 1024 * 1024,
22
23
  };
23
24
 
24
- /** Supported browser products. */
25
- export enum Browser {
26
- CHROME = "chrome",
27
- CHROMEHEADLESSSHELL = "chrome-headless-shell",
28
- CHROMIUM = "chromium",
29
- FIREFOX = "firefox",
30
- CHROMEDRIVER = "chromedriver",
31
- }
32
-
33
25
  /** Browser download platform identifiers. */
34
26
  export enum BrowserPlatform {
35
27
  LINUX = "linux",
@@ -47,34 +39,6 @@ function requireChromeForTestingPlatform(platform: BrowserPlatform): ChromeForTe
47
39
  return platform;
48
40
  }
49
41
 
50
- const BROWSERS = [
51
- Browser.CHROME,
52
- Browser.CHROMEHEADLESSSHELL,
53
- Browser.CHROMIUM,
54
- Browser.FIREFOX,
55
- Browser.CHROMEDRIVER,
56
- ] as const;
57
- const BROWSER_PLATFORMS = [
58
- BrowserPlatform.LINUX,
59
- BrowserPlatform.LINUX_ARM,
60
- BrowserPlatform.MAC,
61
- BrowserPlatform.MAC_ARM,
62
- BrowserPlatform.WIN32,
63
- BrowserPlatform.WIN64,
64
- ] as const;
65
-
66
- /** Chrome-for-Testing release channel tags accepted by {@link resolveBuildId}. */
67
- export enum BrowserTag {
68
- CANARY = "canary",
69
- NIGHTLY = "nightly",
70
- BETA = "beta",
71
- DEV = "dev",
72
- DEVEDITION = "devedition",
73
- STABLE = "stable",
74
- ESR = "esr",
75
- LATEST = "latest",
76
- }
77
-
78
42
  /** Download progress reported while a browser archive is streamed to disk. */
79
43
  export interface BrowserDownloadProgress {
80
44
  downloadedBytes: number;
@@ -83,7 +47,6 @@ export interface BrowserDownloadProgress {
83
47
 
84
48
  /** Inputs used to locate an installed browser executable. */
85
49
  export interface ComputeExecutablePathOptions {
86
- browser: Browser;
87
50
  buildId: string;
88
51
  cacheDir: string;
89
52
  platform?: BrowserPlatform;
@@ -95,27 +58,14 @@ export interface InstallOptions extends ComputeExecutablePathOptions {
95
58
  downloadProgressCallback?: (progress: BrowserDownloadProgress) => void;
96
59
  }
97
60
 
98
- /** Metadata for one browser installation found in a Puppeteer cache. */
61
+ /** Metadata for a managed Chrome installation in Puppeteer's cache layout. */
99
62
  export interface InstalledBrowser {
100
- browser: Browser;
101
63
  buildId: string;
102
64
  platform: BrowserPlatform;
103
65
  path: string;
104
66
  executablePath: string;
105
67
  }
106
68
 
107
- interface LastKnownGoodVersions {
108
- channels: Record<string, { version: string }>;
109
- }
110
-
111
- interface MilestoneVersions {
112
- milestones: Record<string, { version: string }>;
113
- }
114
-
115
- interface PatchVersions {
116
- builds: Record<string, { version: string }>;
117
- }
118
-
119
69
  /** Detect the current host's Puppeteer browser platform. */
120
70
  export function detectBrowserPlatform(): BrowserPlatform | undefined {
121
71
  const platform = os.platform();
@@ -126,43 +76,8 @@ export function detectBrowserPlatform(): BrowserPlatform | undefined {
126
76
  return undefined;
127
77
  }
128
78
 
129
- /** Resolve a Chrome-for-Testing channel, milestone, or build prefix to a full build ID. */
130
- export async function resolveBuildId(
131
- browser: Browser,
132
- _platform: BrowserPlatform,
133
- tag: string | BrowserTag,
134
- ): Promise<string> {
135
- if (browser !== Browser.CHROME && browser !== Browser.CHROMEHEADLESSSHELL && browser !== Browser.CHROMEDRIVER) {
136
- return tag;
137
- }
138
- if (/^\d+\.\d+\.\d+\.\d+$/.test(tag)) return tag;
139
-
140
- const channel = tag === BrowserTag.LATEST ? "Canary" : chromeChannelName(tag);
141
- if (channel) {
142
- const metadata = await fetchMetadata<LastKnownGoodVersions>("last-known-good-versions.json");
143
- const version = metadata.channels[channel]?.version;
144
- if (!version) throw new Error(`Chrome channel ${tag} was not found in Chrome-for-Testing metadata`);
145
- return version;
146
- }
147
- if (/^\d+$/.test(tag)) {
148
- const metadata = await fetchMetadata<MilestoneVersions>("latest-versions-per-milestone.json");
149
- return metadata.milestones[tag]?.version ?? tag;
150
- }
151
- if (/^\d+\.\d+\.\d+$/.test(tag)) {
152
- const metadata = await fetchMetadata<PatchVersions>("latest-patch-versions-per-build.json");
153
- return metadata.builds[tag]?.version ?? tag;
154
- }
155
- return tag;
156
- }
157
-
158
79
  /** Return the Chrome-for-Testing archive URL for a browser build. */
159
- export function getDownloadUrl(
160
- browser: Browser,
161
- platform: BrowserPlatform,
162
- buildId: string,
163
- baseUrl = CHROME_FOR_TESTING_BASE_URL,
164
- ): URL {
165
- 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 {
166
81
  const archivePlatform = chromeArchivePlatform(platform);
167
82
  const root = baseUrl.replace(/\/$/, "");
168
83
  return new URL(`${root}/${buildId}/${archivePlatform}/chrome-${archivePlatform}.zip`);
@@ -172,9 +87,8 @@ export function getDownloadUrl(
172
87
  export function computeExecutablePath(options: ComputeExecutablePathOptions): string {
173
88
  const detectedPlatform = options.platform ?? detectBrowserPlatform();
174
89
  if (!detectedPlatform) throw new Error("Cannot determine a browser platform for this host");
175
- if (options.browser !== Browser.CHROME) throw new Error(`Unsupported browser executable: ${options.browser}`);
176
90
  const platform = requireChromeForTestingPlatform(detectedPlatform);
177
- const installDir = installationDir(options.cacheDir, options.browser, platform, options.buildId);
91
+ const installDir = installationDir(options.cacheDir, platform, options.buildId);
178
92
  switch (platform) {
179
93
  case BrowserPlatform.LINUX:
180
94
  return path.join(installDir, "chrome-linux64", "chrome");
@@ -203,99 +117,49 @@ export function computeExecutablePath(options: ComputeExecutablePathOptions): st
203
117
  }
204
118
  }
205
119
 
206
- /** Scan a Puppeteer cache for browser installation directories. */
207
- export async function getInstalledBrowsers(options: { cacheDir: string }): Promise<InstalledBrowser[]> {
208
- const installed: InstalledBrowser[] = [];
209
- for (const browser of BROWSERS) {
210
- const browserDir = path.join(options.cacheDir, browser);
211
- let entries: fs.Dirent[];
212
- try {
213
- entries = await fsp.readdir(browserDir, { withFileTypes: true });
214
- } catch (error) {
215
- if (isMissingPath(error)) continue;
216
- throw error;
217
- }
218
- for (const entry of entries) {
219
- if (!entry.isDirectory()) continue;
220
- const parsed = parseInstallationName(entry.name);
221
- if (!parsed) continue;
222
- const installPath = path.join(browserDir, entry.name);
223
- try {
224
- installed.push({
225
- browser,
226
- buildId: parsed.buildId,
227
- platform: parsed.platform,
228
- path: installPath,
229
- executablePath: computeExecutablePath({
230
- browser,
231
- buildId: parsed.buildId,
232
- cacheDir: options.cacheDir,
233
- platform: parsed.platform,
234
- }),
235
- });
236
- } catch {
237
- // Other browser products are not part of the surface used by OMP.
238
- }
239
- }
240
- }
241
- return installed;
242
- }
243
-
244
120
  /** Download and unpack Chrome into Puppeteer's existing cache layout. */
245
121
  export async function install(options: InstallOptions): Promise<InstalledBrowser> {
246
122
  const platform = options.platform ?? detectBrowserPlatform();
247
123
  if (!platform) throw new Error("Cannot determine a browser platform for this host");
248
124
  const executablePath = computeExecutablePath({ ...options, platform });
249
- const installPath = installationDir(options.cacheDir, options.browser, platform, options.buildId);
125
+ const installPath = installationDir(options.cacheDir, platform, options.buildId);
250
126
  if (await pathExists(executablePath)) {
251
- return { browser: options.browser, buildId: options.buildId, platform, path: installPath, executablePath };
127
+ return { buildId: options.buildId, platform, path: installPath, executablePath };
252
128
  }
253
129
 
254
- await fsp.mkdir(options.cacheDir, { recursive: true });
255
- const nonce = `${process.pid}-${crypto.randomUUID()}`;
256
- const archivePath = path.join(options.cacheDir, `.browser-${nonce}.zip`);
257
- const stagingPath = path.join(options.cacheDir, `.browser-${nonce}`);
258
- try {
259
- await downloadArchive(
260
- getDownloadUrl(options.browser, platform, options.buildId, options.baseUrl),
261
- archivePath,
262
- options.downloadProgressCallback,
263
- );
264
- await extractArchive(archivePath, stagingPath, { limits: BROWSER_ARCHIVE_LIMITS });
265
- await fsp.mkdir(path.dirname(installPath), { recursive: true });
266
- await fsp.rm(installPath, { recursive: true, force: true });
267
- await fsp.rename(stagingPath, installPath);
268
- } finally {
269
- await Promise.all([
270
- fsp.rm(archivePath, { force: true }).catch(() => {}),
271
- fsp.rm(stagingPath, { recursive: true, force: true }).catch(() => {}),
272
- ]);
273
- }
274
- if (!(await pathExists(executablePath)))
275
- throw new Error(`Browser archive did not contain its expected executable: ${executablePath}`);
276
- return { browser: options.browser, buildId: options.buildId, platform, path: installPath, executablePath };
277
- }
278
-
279
- function chromeChannelName(tag: string): string | undefined {
280
- switch (tag) {
281
- case BrowserTag.STABLE:
282
- return "Stable";
283
- case BrowserTag.BETA:
284
- return "Beta";
285
- case BrowserTag.DEV:
286
- return "Dev";
287
- case BrowserTag.CANARY:
288
- return "Canary";
289
- default:
290
- return undefined;
291
- }
292
- }
293
-
294
- async function fetchMetadata<T>(filename: string): Promise<T> {
295
- const response = await fetch(`${CHROME_METADATA_BASE_URL}/${filename}`);
296
- if (!response.ok)
297
- throw new Error(`Failed to fetch Chrome-for-Testing metadata (${response.status} ${response.statusText})`);
298
- return (await response.json()) as T;
130
+ await fsp.mkdir(path.dirname(installPath), { recursive: true });
131
+ return withFileLock(
132
+ `${installPath}.install`,
133
+ async () => {
134
+ // The in-process launch promise cannot serialize separate OMP sessions.
135
+ // Recheck under the OS lock: never replace a winner's running Chrome.
136
+ if (!(await pathExists(executablePath))) {
137
+ const nonce = `${process.pid}-${crypto.randomUUID()}`;
138
+ const archivePath = path.join(options.cacheDir, `.browser-${nonce}.zip`);
139
+ const stagingPath = path.join(options.cacheDir, `.browser-${nonce}`);
140
+ try {
141
+ await downloadArchive(
142
+ getDownloadUrl(platform, options.buildId, options.baseUrl),
143
+ archivePath,
144
+ options.downloadProgressCallback,
145
+ );
146
+ await extractArchive(archivePath, stagingPath, { limits: BROWSER_ARCHIVE_LIMITS });
147
+ if (!(await pathExists(path.join(stagingPath, path.relative(installPath, executablePath))))) {
148
+ throw new Error(`Browser archive did not contain its expected executable: ${executablePath}`);
149
+ }
150
+ await fsp.rm(installPath, { recursive: true, force: true });
151
+ await fsp.rename(stagingPath, installPath);
152
+ } finally {
153
+ await Promise.all([
154
+ fsp.rm(archivePath, { force: true }).catch(() => {}),
155
+ fsp.rm(stagingPath, { recursive: true, force: true }).catch(() => {}),
156
+ ]);
157
+ }
158
+ }
159
+ return { buildId: options.buildId, platform, path: installPath, executablePath };
160
+ },
161
+ { retries: Math.ceil(DOWNLOAD_TIMEOUT_MS / 100) + 1, retryDelayMs: 100 },
162
+ );
299
163
  }
300
164
 
301
165
  function chromeArchivePlatform(platform: BrowserPlatform): string {
@@ -313,17 +177,8 @@ function chromeArchivePlatform(platform: BrowserPlatform): string {
313
177
  }
314
178
  }
315
179
 
316
- function installationDir(cacheDir: string, browser: Browser, platform: BrowserPlatform, buildId: string): string {
317
- return path.join(cacheDir, browser, `${platform}-${buildId}`);
318
- }
319
-
320
- function parseInstallationName(name: string): { platform: BrowserPlatform; buildId: string } | undefined {
321
- for (const platform of BROWSER_PLATFORMS) {
322
- const prefix = `${platform}-`;
323
- if (name.startsWith(prefix) && name.length > prefix.length)
324
- return { platform, buildId: name.slice(prefix.length) };
325
- }
326
- return undefined;
180
+ function installationDir(cacheDir: string, platform: BrowserPlatform, buildId: string): string {
181
+ return path.join(cacheDir, "chrome", `${platform}-${buildId}`);
327
182
  }
328
183
 
329
184
  function isMissingPath(error: unknown): boolean {
@@ -345,7 +200,7 @@ async function downloadArchive(
345
200
  destination: string,
346
201
  onProgress: ((progress: BrowserDownloadProgress) => void) | undefined,
347
202
  ): Promise<void> {
348
- const response = await fetch(url);
203
+ const response = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
349
204
  if (!response.ok || !response.body) {
350
205
  throw new Error(`Browser download failed (${response.status} ${response.statusText}) from ${url}`);
351
206
  }
package/src/color.ts CHANGED
@@ -239,7 +239,7 @@ const ANSI_16: readonly (readonly [number, number, number])[] = [
239
239
  const CUBE_STEPS = [0, 95, 135, 175, 215, 255] as const;
240
240
 
241
241
  /** Parse a 256-color palette index (0–255) to RGB (0..255). */
242
- function paletteToRgb(index: number): RGB | undefined {
242
+ export function paletteToRgb(index: number): RGB | undefined {
243
243
  if (!Number.isInteger(index) || index < 0 || index > 255) return undefined;
244
244
  if (index < 16) {
245
245
  const rgb = ANSI_16[index];
package/src/dirs.ts CHANGED
@@ -104,7 +104,8 @@ function readProfileFromEnvSafe(): string | undefined {
104
104
  }
105
105
  }
106
106
 
107
- function getBaseConfigRoot(): string {
107
+ /** Profile-independent config root (~/.zeta), shared by every zeta profile. */
108
+ export function getBaseConfigRoot(): string {
108
109
  return path.join(os.homedir(), getConfigDirName());
109
110
  }
110
111
 
@@ -153,19 +154,31 @@ export function normalizePathForComparison(inputPath: string): string {
153
154
  return process.platform === "win32" ? resolvedPath.toLowerCase() : resolvedPath;
154
155
  }
155
156
 
157
+ /**
158
+ * Compare paths already normalized by {@link normalizePathForComparison}.
159
+ *
160
+ * Returns the relative path (an empty string when the paths are equal), or
161
+ * `null` when the candidate is outside the root. Callers classifying one
162
+ * candidate against several static roots can normalize each side once and
163
+ * reuse the public helpers' containment semantics without repeating realpath
164
+ * work.
165
+ */
166
+ export function relativePathWithinNormalizedRoot(normalizedRoot: string, normalizedCandidate: string): string | null {
167
+ const relative = path.relative(normalizedRoot, normalizedCandidate);
168
+ if (relative !== "" && (relative.startsWith("..") || path.isAbsolute(relative))) return null;
169
+ return relative;
170
+ }
171
+
156
172
  export function pathIsWithin(root: string, candidate: string): boolean {
157
173
  const normalizedRoot = normalizePathForComparison(root);
158
174
  const normalizedCandidate = normalizePathForComparison(candidate);
159
- const relative = path.relative(normalizedRoot, normalizedCandidate);
160
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
175
+ return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) !== null;
161
176
  }
162
177
 
163
178
  export function relativePathWithinRoot(root: string, candidate: string): string | null {
164
- if (!pathIsWithin(root, candidate)) return null;
165
179
  const normalizedRoot = normalizePathForComparison(root);
166
180
  const normalizedCandidate = normalizePathForComparison(candidate);
167
- const relative = path.relative(normalizedRoot, normalizedCandidate);
168
- return relative || null;
181
+ return relativePathWithinNormalizedRoot(normalizedRoot, normalizedCandidate) || null;
169
182
  }
170
183
 
171
184
  let projectDir: string | undefined;
@@ -687,6 +700,11 @@ export function getBrowserRelayDir(): string {
687
700
  return dirs.rootSubdir("browser-relay", "data");
688
701
  }
689
702
 
703
+ /** Get the profile root for Chromium browsers the browser tool spawns via `app.path` (~/.zeta/browser-profiles). */
704
+ export function getBrowserProfilesDir(): string {
705
+ return dirs.rootSubdir("browser-profiles", "state");
706
+ }
707
+
690
708
  /** Get DOCS_RS cache directory () */
691
709
  export function getDocsRsCacheDir(): string {
692
710
  return dirs.rootSubdir("webcache", "cache");
@@ -893,6 +911,16 @@ export function getTerminalSessionsDir(agentDir?: string): string {
893
911
  return dirs.agentSubdir(agentDir, "terminal-sessions", "state");
894
912
  }
895
913
 
914
+ /**
915
+ * Get the persistent registry of custom session files
916
+ * (~/.zeta/agent/custom-session-files). Each `--session-dir`/`--session`
917
+ * transcript is recorded here as one marker file so storage GC can scan its
918
+ * exact path after its terminal breadcrumb is overwritten by a later session.
919
+ */
920
+ export function getCustomSessionFilesDir(agentDir?: string): string {
921
+ return dirs.agentSubdir(agentDir, "custom-session-files", "state");
922
+ }
923
+
896
924
  /** Get the crash log path (~/.zeta/agent/zeta-crash.log). */
897
925
  export function getCrashLogPath(agentDir?: string): string {
898
926
  return dirs.agentSubdir(agentDir, "zeta-crash.log", "state");