@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
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
@@ -107,10 +150,10 @@ function expandDotenvValues(values: Record<string, string>, env: Record<string,
107
150
  return expanded;
108
151
  }
109
152
 
110
- /** Filters process env for child shells without launch-cwd dotenv values. */
111
- export function filterChildShellEnv(
153
+ function filterChildShellEnvInternal(
112
154
  env: Record<string, string | undefined>,
113
- cwd: string = process.cwd(),
155
+ cwd: string,
156
+ onDotenvValue?: (value: string) => void,
114
157
  ): Record<string, string> {
115
158
  const runtimeLaunchEnvValues = env === Bun.env || env === process.env ? launchEnvValues : undefined;
116
159
  const result = filterProcessEnv(env);
@@ -147,6 +190,12 @@ export function filterChildShellEnv(
147
190
  }
148
191
  }
149
192
  const allLaunchEnv = fallbackLaunchEnv ? { ...launchEnv, ...fallbackLaunchEnv } : launchEnv;
193
+ if (onDotenvValue) {
194
+ // Every value the project's dotenv files define is dotenv-sourced, whether
195
+ // or not this process loaded it (a `--cwd` launch never did).
196
+ for (const key in allLaunchEnv) onDotenvValue(allLaunchEnv[key]!);
197
+ for (const key in expandedLaunchEnv) onDotenvValue(expandedLaunchEnv[key]!);
198
+ }
150
199
  for (const key in allLaunchEnv) {
151
200
  const launchValue = runtimeLaunchEnvValues?.get(key);
152
201
  if (launchValue !== undefined) {
@@ -168,6 +217,8 @@ export function filterChildShellEnv(
168
217
  // Strong provenance: the launch environment is known and this name is
169
218
  // absent from it, or OMP itself injected the value — either way it came
170
219
  // from a project dotenv file, not the parent shell.
220
+ const value = result[key];
221
+ if (value !== undefined) onDotenvValue?.(value);
171
222
  delete result[key];
172
223
  } else if (
173
224
  result[key] === launchEnv[key] ||
@@ -177,50 +228,46 @@ export function filterChildShellEnv(
177
228
  ) {
178
229
  // No launch-env snapshot (dotenv autoloaded without procfs): best-effort
179
230
  // value match against the Bun-parsed dotenv.
231
+ const value = result[key];
232
+ if (value !== undefined) onDotenvValue?.(value);
180
233
  delete result[key];
181
234
  }
182
235
  }
236
+ // Last, after dotenv merging: no source (inherited, launcher, or dotenv) may
237
+ // pin the child shell to the agent's own repository.
238
+ stripGitRepoLocationEnv(result);
183
239
  return result;
184
240
  }
185
241
 
186
- /**
187
- * Parse one dotenv line with Bun-compatible semantics: an optional `export`
188
- * prefix, full-line `#` comments, inline `#` comments after whitespace on
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() };
242
+ /** Filters process env for child shells without launch-cwd dotenv values. */
243
+ export function filterChildShellEnv(
244
+ env: Record<string, string | undefined>,
245
+ cwd: string = getProjectDir(),
246
+ ): Record<string, string> {
247
+ return filterChildShellEnvInternal(env, cwd);
248
+ }
249
+
250
+ /** Return every value defined by `cwd`'s dotenv files, plus environment values that came from them. */
251
+ export function getDotenvEnvValues(
252
+ cwd: string = getProjectDir(),
253
+ env: Record<string, string | undefined> = process.env,
254
+ ): string[] {
255
+ const values = new Set<string>();
256
+ filterChildShellEnvInternal(env, cwd, value => values.add(value));
257
+ return [...values];
211
258
  }
212
259
 
213
260
  /**
214
- * Parses a .env file synchronously into key-value string pairs using
215
- * {@link parseEnvLine} for Bun-compatible line semantics.
261
+ * Parses a complete .env file with the runtime's dotenv grammar, then retains
262
+ * only shell-identifier names and spawn-safe values.
216
263
  */
217
264
  export function parseEnvFile(filePath: string): Record<string, string> {
218
265
  const result: Record<string, string> = {};
219
266
  try {
220
- const content = fs.readFileSync(filePath, "utf-8");
221
- for (const line of content.split("\n")) {
222
- const parsed = parseEnvLine(line);
223
- if (parsed && isSafeEnvValue(parsed.value)) result[parsed.key] = parsed.value;
267
+ const parsed = parseEnv(fs.readFileSync(filePath, "utf-8"));
268
+ for (const key in parsed) {
269
+ const value = parsed[key];
270
+ if (value !== undefined && isValidEnvName(key) && isSafeEnvValue(value)) result[key] = value;
224
271
  }
225
272
  } catch {
226
273
  // 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
+ }
@@ -11,17 +11,41 @@ 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
- const WILL_RESET_IN_PATTERN = /(?:will\s+)?reset in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i;
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
 
31
+ // A timezone-naive `reset at` stamp (no `Z`/offset) is the provider's
32
+ // wall clock in an unknown zone: it cannot be converted to a delay without
33
+ // guessing the zone, so it resolves only as a fallback when the body
34
+ // carries no unambiguous relative signal.
35
+
36
+ // A conflict probe needs no new signal: when a naive `reset at` wall stamp
37
+ // disagrees with the merged relative wait, the merged wait (which already
38
+ // ignores the naive stamp) sleeps first, and the retry after it is the
39
+ // probe — success proves skew, a fresh 429 re-anchors with live timing.
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
+
22
47
  /**
23
48
  * Server-suggested retry delay extraction. Merges the patterns historically used
24
- * by the OpenAI Codex and Google Gemini retry helpers.
25
49
  *
26
50
  * Header sources (checked in order):
27
51
  * - `retry-after-ms` (milliseconds)
@@ -37,12 +61,18 @@ const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
37
61
  * - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
38
62
  * - `retry-after-ms=98497000` / `retry-after-ms: 7200000` / `retry-after-ms = 7200000`
39
63
  * - `Your limit will reset at 2026-09-01 09:44:51` / `将在 2026-09-01 09:44:51 重置`
64
+ * (a provider offset makes a naive wall clock authoritative; otherwise it
65
+ * resolves only when no relative signal is present)
40
66
  *
41
67
  * Returns `undefined` if no signal is found, or `0` when the provider
42
68
  * explicitly asks for an immediate retry (`retry-after…=0`, or an absolute
43
69
  * reset timestamp that has already elapsed).
44
70
  */
45
- export function extractRetryHint(source: Response | Headers | null | undefined, body?: string): number | undefined {
71
+ export function extractRetryHint(
72
+ source: Response | Headers | null | undefined,
73
+ body?: string,
74
+ options?: RetryHintOptions,
75
+ ): number | undefined {
46
76
  const headers = source instanceof Headers ? source : (source?.headers ?? undefined);
47
77
  if (headers) {
48
78
  const retryAfterMs = headers.get("retry-after-ms");
@@ -98,6 +128,15 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
98
128
  // when the parse returns undefined, which would sleep a session the
99
129
  // provider told to retry immediately.
100
130
  let retryNow = false;
131
+
132
+ // Timezone-naive `reset at` stamps (no `Z`/offset) are the provider's
133
+ // wall clock in an unknown zone — converting them to a delay requires
134
+ // guessing the zone. They resolve AFTER every unambiguous signal below,
135
+ // and only as a fallback when none was found.
136
+ let longestNaiveMs: number | undefined;
137
+ const considerNaive = (ms: number | undefined): void => {
138
+ if (ms !== undefined && ms > 0 && (longestNaiveMs === undefined || ms > longestNaiveMs)) longestNaiveMs = ms;
139
+ };
101
140
  const consider = (ms: number | undefined): void => {
102
141
  if (ms !== undefined && ms > 0 && (longestMs === undefined || ms > longestMs)) longestMs = ms;
103
142
  };
@@ -117,16 +156,30 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
117
156
  consider(totalMs > 0 ? totalMs : undefined);
118
157
  }
119
158
  }
120
- for (const pattern of [WILL_RESET_AT_PATTERN, CN_RESET_AT_PATTERN]) {
159
+ for (const pattern of RESET_AT_PATTERNS) {
121
160
  const match = pattern.exec(body);
122
- if (match?.[1]) {
123
- // Provider timestamps without an explicit offset are interpreted as UTC.
124
- const normalized = match[1].replace(" ", "T");
125
- const hasOffset = /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/i.test(normalized);
126
- const parsed = Date.parse(hasOffset ? normalized : `${normalized}Z`);
127
- if (!Number.isNaN(parsed) && parsed > Date.now()) {
128
- consider(parsed - Date.now());
129
- }
161
+ if (!match?.[1]) continue;
162
+ // Offset-bearing stamps are unambiguous and compete by longest-wins.
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.
166
+ const normalized = match[1].replace(" ", "T");
167
+ const hasOffset = /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/i.test(normalized);
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);
130
183
  }
131
184
  }
132
185
  const accountResetMatch = WILL_RESET_IN_PATTERN.exec(body);
@@ -188,7 +241,9 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
188
241
  considerClamped(resetSeconds > 1_000_000_000 ? resetSeconds * 1000 - Date.now() : resetSeconds * 1000);
189
242
  }
190
243
  }
191
- return longestMs ?? (retryNow ? 0 : undefined);
244
+ // Elapsed naive stamps were ignored before this change and stay ignored:
245
+ // only an explicit zero/expired relative signal is authoritative retry-now.
246
+ return longestMs ?? longestNaiveMs ?? (retryNow ? 0 : undefined);
192
247
  }
193
248
 
194
249
  function unitToMs(unit: string): number | undefined {
@@ -210,6 +265,10 @@ function unitToMs(unit: string): number | undefined {
210
265
  case "hour":
211
266
  case "hours":
212
267
  return 60 * 60_000;
268
+ case "d":
269
+ case "day":
270
+ case "days":
271
+ return 24 * 60 * 60_000;
213
272
  default:
214
273
  return undefined;
215
274
  }
package/src/file-lock.ts CHANGED
@@ -17,6 +17,16 @@ export interface FileLockOptions {
17
17
  staleMs?: number;
18
18
  }
19
19
 
20
+ /** An exclusive OS-backed lease. Releasing an already released handle is safe. */
21
+ export interface FileLockHandle {
22
+ release(): void;
23
+ }
24
+
25
+ /** An exclusive OS-backed lease. Releasing an already released handle is safe. */
26
+ export interface FileLockHandle {
27
+ release(): void;
28
+ }
29
+
20
30
  const DEFAULT_OPTIONS: Required<FileLockOptions> = {
21
31
  retries: 50,
22
32
  retryDelayMs: 100,
@@ -32,7 +42,8 @@ function tryAcquireLock(lockPath: string): NativeFileLock | null {
32
42
  return lock.acquired ? lock : null;
33
43
  }
34
44
 
35
- async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<NativeFileLock> {
45
+ /** Acquire an exclusive lease; callers must release it when their operation ends. */
46
+ export async function acquireFileLock(filePath: string, options: FileLockOptions = {}): Promise<FileLockHandle> {
36
47
  const opts = { ...DEFAULT_OPTIONS, ...options };
37
48
  const lockPath = getLockPath(filePath);
38
49
 
@@ -45,13 +56,26 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro
45
56
  throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
46
57
  }
47
58
 
59
+ function acquireLockSync(filePath: string, options: FileLockOptions = {}): NativeFileLock {
60
+ const opts = { ...DEFAULT_OPTIONS, ...options };
61
+ const lockPath = getLockPath(filePath);
62
+
63
+ for (let attempt = 0; attempt < opts.retries; attempt++) {
64
+ const lock = tryAcquireLock(lockPath);
65
+ if (lock) return lock;
66
+ if (attempt + 1 < opts.retries && opts.retryDelayMs > 0) Bun.sleepSync(opts.retryDelayMs);
67
+ }
68
+
69
+ throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
70
+ }
71
+
48
72
  /** Run `fn` while holding an OS-backed exclusive lock for `filePath`. */
49
73
  export async function withFileLock<T>(
50
74
  filePath: string,
51
75
  fn: () => Promise<T>,
52
76
  options: FileLockOptions = {},
53
77
  ): Promise<T> {
54
- const lock = await acquireLock(filePath, options);
78
+ const lock = await acquireFileLock(filePath, options);
55
79
  try {
56
80
  return await fn();
57
81
  } finally {
@@ -59,6 +83,16 @@ export async function withFileLock<T>(
59
83
  }
60
84
  }
61
85
 
86
+ /** Run synchronous `fn` while holding an OS-backed exclusive lock for `filePath`. */
87
+ export function withFileLockSync<T>(filePath: string, fn: () => T, options: FileLockOptions = {}): T {
88
+ const lock = acquireLockSync(filePath, options);
89
+ try {
90
+ return fn();
91
+ } finally {
92
+ lock.release();
93
+ }
94
+ }
95
+
62
96
  /**
63
97
  * Test-only acquisition handle for forcing ownership handoffs. This is not
64
98
  * part of the supported package API.
package/src/format.ts CHANGED
@@ -58,6 +58,22 @@ export function formatBytes(bytes: number): string {
58
58
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
59
59
  }
60
60
 
61
+ /**
62
+ * Count `\n` code units via native `indexOf` — no split array, roughly an
63
+ * order of magnitude cheaper than a per-code-unit loop on multi-MiB text.
64
+ * Line-count semantics are the caller's (empty text is 0 or 1 lines
65
+ * depending on the contract).
66
+ */
67
+ export function countNewlines(text: string): number {
68
+ let count = 0;
69
+ let pos = text.indexOf("\n");
70
+ while (pos !== -1) {
71
+ count++;
72
+ pos = text.indexOf("\n", pos + 1);
73
+ }
74
+ return count;
75
+ }
76
+
61
77
  /**
62
78
  * Truncate a string to maxLen characters, appending an ellipsis if truncated.
63
79
  * For display-width-aware truncation (terminals), use truncateToWidth from @linxiraos/pi-tui.
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";
@@ -40,6 +40,7 @@ export * from "./tls-fetch";
40
40
  export * from "./type-guards";
41
41
  export * from "./version";
42
42
  export * from "./which";
43
+ export * from "./yaml-config";
43
44
 
44
45
  function isPlainObject(val: object): val is Record<string, unknown> {
45
46
  return Object.getPrototypeOf(val) === Object.prototype || Array.isArray(val);
package/src/json.ts CHANGED
@@ -19,7 +19,18 @@ export function tryParseJson<T = unknown>(content: string): T | null {
19
19
  * only lossless JSON representation.
20
20
  */
21
21
  export function stringifyJson(value: unknown, space?: string | number): string | undefined {
22
- return JSON.stringify(value, (_key, item) => (typeof item === "bigint" ? item.toString() : item), space);
22
+ // Fast path: a replacer forces the slow stringify on every call, but
23
+ // bigint payloads are vanishingly rare. Try plain stringify first and
24
+ // retry with the coercing replacer only on TypeError (the only error a
25
+ // bigint raises). A TypeError from elsewhere (circular value, throwing
26
+ // toJSON) recurs on the retry and surfaces from that second walk, so
27
+ // stateful serializers ahead of a bigint run twice on that path.
28
+ try {
29
+ return JSON.stringify(value, undefined, space);
30
+ } catch (error) {
31
+ if (!(error instanceof TypeError)) throw error;
32
+ return JSON.stringify(value, (_key, item) => (typeof item === "bigint" ? item.toString() : item), space);
33
+ }
23
34
  }
24
35
 
25
36
  function stableJsonClone(value: unknown): unknown {
@@ -47,6 +47,12 @@ export class RotatingFileSink {
47
47
  #activePath: string | undefined;
48
48
  #activeBytes = 0;
49
49
  #closed = false;
50
+ // Held append fd: the old code did open+write+close per line
51
+ // (appendFileSync) plus a throwaway open in the constructor. A single fd
52
+ // per active file removes two syscalls per log line; it is reopened on
53
+ // rotation and closed on close()/rotation (required for Windows
54
+ // delete-on-prune semantics).
55
+ #fd: number | undefined;
50
56
 
51
57
  constructor(options: RotatingFileOptions) {
52
58
  this.#directory = options.directory;
@@ -61,26 +67,57 @@ export class RotatingFileSink {
61
67
  const activePath = this.#activePath;
62
68
  if (activePath) {
63
69
  this.#registerFile(activePath, now.getTime());
64
- fs.closeSync(fs.openSync(activePath, "a"));
70
+ this.#openFd(activePath);
71
+ }
72
+ }
73
+
74
+ #openFd(filePath: string): void {
75
+ this.#closeFd();
76
+ this.#fd = fs.openSync(filePath, "a");
77
+ }
78
+
79
+ #closeFd(): void {
80
+ if (this.#fd !== undefined) {
81
+ try {
82
+ fs.closeSync(this.#fd);
83
+ } catch {
84
+ // Best-effort: the fd may already be invalid after rotation.
85
+ }
86
+ this.#fd = undefined;
65
87
  }
66
88
  }
67
89
 
68
90
  /** Append one already-formatted log record. */
69
91
  write(line: string): void {
70
92
  if (this.#closed) return;
93
+ const prevPath = this.#activePath;
71
94
  const now = new Date();
72
95
  this.#selectFile(this.#localDay(now));
73
96
  const activePath = this.#activePath;
74
97
  if (!activePath) return;
98
+ // Rotation moved the active path: close the old descriptor BEFORE
99
+ // registering the new path, because registration prunes beyond
100
+ // maxFiles — on Windows the pruned predecessor cannot be deleted
101
+ // while still open, which would leak it (audit entry removed, file
102
+ // left on disk, retention unbounded).
103
+ if (activePath !== prevPath) this.#closeFd();
75
104
  this.#registerFile(activePath, now.getTime());
105
+ if (this.#fd === undefined) this.#openFd(activePath);
76
106
  const record = `${line}${os.EOL}`;
77
- fs.appendFileSync(activePath, record, "utf8");
78
- this.#activeBytes += Buffer.byteLength(record);
107
+ const buf = Buffer.from(record, "utf8");
108
+ let off = 0;
109
+ while (off < buf.length) {
110
+ const written = fs.writeSync(this.#fd!, buf, off);
111
+ if (written <= 0) break;
112
+ off += written;
113
+ }
114
+ this.#activeBytes += buf.length;
79
115
  }
80
116
 
81
117
  /** Stop accepting records. Synchronous writes require no drain phase. */
82
118
  close(): void {
83
119
  this.#closed = true;
120
+ this.#closeFd();
84
121
  }
85
122
 
86
123
  #localDay(date: Date): string {
package/src/logger.ts CHANGED
@@ -152,6 +152,19 @@ function pruneStaleProcessLogs(dir: string): void {
152
152
  }
153
153
  }
154
154
 
155
+ const scheduledPruneDirs = new Set<string>();
156
+
157
+ /** Run shared retention only after logger construction has returned to the event loop. */
158
+ function schedulePruneStaleProcessLogs(dir: string): void {
159
+ if (scheduledPruneDirs.has(dir)) return;
160
+ scheduledPruneDirs.add(dir);
161
+ const immediate = setImmediate(() => {
162
+ scheduledPruneDirs.delete(dir);
163
+ pruneStaleProcessLogs(dir);
164
+ });
165
+ immediate.unref();
166
+ }
167
+
155
168
  /** Ensure a logs directory exists; return the resolved path. */
156
169
  function ensureDir(dir: string): string {
157
170
  if (!fs.existsSync(dir)) {
@@ -237,7 +250,7 @@ function formatLogInfo(info: NormalizedLogInfo): string {
237
250
  /** Build a rotating file sink with process-local rotation and shared retention. */
238
251
  function makeFileTransport(dir?: string): RotatingFileSink {
239
252
  const logsDir = ensureDir(dir ?? getLogsDir());
240
- pruneStaleProcessLogs(logsDir);
253
+ schedulePruneStaleProcessLogs(logsDir);
241
254
  return new RotatingFileSink({
242
255
  directory: logsDir,
243
256
  filenamePrefix: "zeta",
@@ -276,12 +289,11 @@ function getLocalTransports(): LocalTransports {
276
289
 
277
290
  function emitLocally(level: LogLevel, message: string, context: Record<string, unknown> | undefined): void {
278
291
  const transports = getLocalTransports();
279
- const info = normalizeLogInfo(level, message, context);
280
292
  if (!transports.file && !transports.console) return;
281
-
293
+ const info = normalizeLogInfo(level, message, context);
282
294
  const line = formatLogInfo(info);
283
295
  if (transports.file) transports.file.write(line);
284
- if (transports.console) fs.writeSync(1, `${formatLogInfo(info)}${os.EOL}`);
296
+ if (transports.console) fs.writeSync(1, `${line}${os.EOL}`);
285
297
  }
286
298
 
287
299
  /**
package/src/mime.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { peekFile, peekFileSync } from "./peek-file";
2
2
 
3
- const DEFAULT_IMAGE_METADATA_HEADER_BYTES = 256 * 1024;
4
-
3
+ export const IMAGE_METADATA_HEADER_BYTES = 256 * 1024;
5
4
  const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
6
5
  const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
7
6
  const WEBP_RIFF_MAGIC = Buffer.from([0x52, 0x49, 0x46, 0x46]);
@@ -144,16 +143,13 @@ export function parseImageMetadata(header: Uint8Array): ImageMetadata | null {
144
143
  );
145
144
  }
146
145
 
147
- export function readImageMetadataSync(
148
- filePath: string,
149
- maxBytes = DEFAULT_IMAGE_METADATA_HEADER_BYTES,
150
- ): ImageMetadata | null {
146
+ export function readImageMetadataSync(filePath: string, maxBytes = IMAGE_METADATA_HEADER_BYTES): ImageMetadata | null {
151
147
  return peekFileSync(filePath, maxBytes, parseImageMetadata);
152
148
  }
153
149
 
154
150
  export function readImageMetadata(
155
151
  filePath: string,
156
- maxBytes = DEFAULT_IMAGE_METADATA_HEADER_BYTES,
152
+ maxBytes = IMAGE_METADATA_HEADER_BYTES,
157
153
  ): Promise<ImageMetadata | null> {
158
154
  return peekFile(filePath, maxBytes, parseImageMetadata);
159
155
  }
package/src/path.ts CHANGED
@@ -40,3 +40,17 @@ export function stripWindowsExtendedLengthPathPrefix(
40
40
 
41
41
  return filePath;
42
42
  }
43
+
44
+ /**
45
+ * Test whether a path is fully qualified and drive-independent.
46
+ * On Windows, requires a drive letter with separator (e.g. `C:\`) or UNC (`\\server\share` or `//server/share`).
47
+ * On POSIX, requires an absolute path.
48
+ */
49
+ export function isFullyQualifiedPath(filePath: string, platform: NodeJS.Platform = process.platform): boolean {
50
+ const p = platform === "win32" ? path.win32 : path.posix;
51
+ if (!p.isAbsolute(filePath)) return false;
52
+ if (platform === "win32") {
53
+ return /^[a-zA-Z]:[/\\]/.test(filePath) || /^[\\/]{2}[^\\/]/.test(filePath);
54
+ }
55
+ return true;
56
+ }