@oh-my-pi-zen/pi-utils 16.3.6-zen.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.
Files changed (164) hide show
  1. package/CHANGELOG.md +245 -0
  2. package/README.md +36 -0
  3. package/dist/types/abortable.d.ts +32 -0
  4. package/dist/types/async.d.ts +6 -0
  5. package/dist/types/binary.d.ts +17 -0
  6. package/dist/types/cli.d.ts +117 -0
  7. package/dist/types/color.d.ts +102 -0
  8. package/dist/types/dirs.d.ts +260 -0
  9. package/dist/types/env.d.ts +74 -0
  10. package/dist/types/fetch-retry.d.ts +94 -0
  11. package/dist/types/format.d.ts +37 -0
  12. package/dist/types/frontmatter.d.ts +25 -0
  13. package/dist/types/fs-error.d.ts +31 -0
  14. package/dist/types/glob.d.ts +28 -0
  15. package/dist/types/index.d.ts +35 -0
  16. package/dist/types/json-parse.d.ts +48 -0
  17. package/dist/types/json.d.ts +4 -0
  18. package/dist/types/logger.d.ts +83 -0
  19. package/dist/types/loop-phase.d.ts +10 -0
  20. package/dist/types/mermaid-ascii.d.ts +11 -0
  21. package/dist/types/mime.d.ts +29 -0
  22. package/dist/types/module-timer.d.ts +1 -0
  23. package/dist/types/path-tree.d.ts +76 -0
  24. package/dist/types/path.d.ts +2 -0
  25. package/dist/types/peek-file.d.ts +29 -0
  26. package/dist/types/postmortem.d.ts +37 -0
  27. package/dist/types/procmgr.d.ts +29 -0
  28. package/dist/types/prompt.d.ts +18 -0
  29. package/dist/types/ptree.d.ts +108 -0
  30. package/dist/types/ring.d.ts +93 -0
  31. package/dist/types/runtime-install.d.ts +68 -0
  32. package/dist/types/sanitize-text.d.ts +29 -0
  33. package/dist/types/snowflake.d.ts +25 -0
  34. package/dist/types/stream.d.ts +68 -0
  35. package/dist/types/tab-spacing.d.ts +24 -0
  36. package/dist/types/temp.d.ts +17 -0
  37. package/dist/types/timing-buffer.d.ts +22 -0
  38. package/dist/types/tls-fetch.d.ts +37 -0
  39. package/dist/types/type-guards.d.ts +3 -0
  40. package/dist/types/vendor/mermaid-ascii/ascii/ansi.d.ts +41 -0
  41. package/dist/types/vendor/mermaid-ascii/ascii/canvas.d.ts +89 -0
  42. package/dist/types/vendor/mermaid-ascii/ascii/class-diagram.d.ts +7 -0
  43. package/dist/types/vendor/mermaid-ascii/ascii/converter.d.ts +12 -0
  44. package/dist/types/vendor/mermaid-ascii/ascii/draw.d.ts +66 -0
  45. package/dist/types/vendor/mermaid-ascii/ascii/edge-bundling.d.ts +48 -0
  46. package/dist/types/vendor/mermaid-ascii/ascii/edge-routing.d.ts +43 -0
  47. package/dist/types/vendor/mermaid-ascii/ascii/er-diagram.d.ts +7 -0
  48. package/dist/types/vendor/mermaid-ascii/ascii/grid.d.ts +56 -0
  49. package/dist/types/vendor/mermaid-ascii/ascii/index.d.ts +65 -0
  50. package/dist/types/vendor/mermaid-ascii/ascii/multiline-utils.d.ts +27 -0
  51. package/dist/types/vendor/mermaid-ascii/ascii/pathfinder.d.ts +17 -0
  52. package/dist/types/vendor/mermaid-ascii/ascii/sequence.d.ts +7 -0
  53. package/dist/types/vendor/mermaid-ascii/ascii/shapes/circle.d.ts +11 -0
  54. package/dist/types/vendor/mermaid-ascii/ascii/shapes/corners.d.ts +34 -0
  55. package/dist/types/vendor/mermaid-ascii/ascii/shapes/diamond.d.ts +11 -0
  56. package/dist/types/vendor/mermaid-ascii/ascii/shapes/hexagon.d.ts +11 -0
  57. package/dist/types/vendor/mermaid-ascii/ascii/shapes/index.d.ts +26 -0
  58. package/dist/types/vendor/mermaid-ascii/ascii/shapes/rectangle.d.ts +31 -0
  59. package/dist/types/vendor/mermaid-ascii/ascii/shapes/rounded.d.ts +11 -0
  60. package/dist/types/vendor/mermaid-ascii/ascii/shapes/special.d.ts +59 -0
  61. package/dist/types/vendor/mermaid-ascii/ascii/shapes/stadium.d.ts +17 -0
  62. package/dist/types/vendor/mermaid-ascii/ascii/shapes/state.d.ts +30 -0
  63. package/dist/types/vendor/mermaid-ascii/ascii/shapes/types.d.ts +55 -0
  64. package/dist/types/vendor/mermaid-ascii/ascii/types.d.ts +206 -0
  65. package/dist/types/vendor/mermaid-ascii/ascii/validate.d.ts +51 -0
  66. package/dist/types/vendor/mermaid-ascii/ascii/xychart.d.ts +2 -0
  67. package/dist/types/vendor/mermaid-ascii/class/parser.d.ts +6 -0
  68. package/dist/types/vendor/mermaid-ascii/class/types.d.ts +102 -0
  69. package/dist/types/vendor/mermaid-ascii/er/parser.d.ts +6 -0
  70. package/dist/types/vendor/mermaid-ascii/er/types.d.ts +76 -0
  71. package/dist/types/vendor/mermaid-ascii/index.d.ts +1 -0
  72. package/dist/types/vendor/mermaid-ascii/multiline-utils.d.ts +9 -0
  73. package/dist/types/vendor/mermaid-ascii/parser.d.ts +7 -0
  74. package/dist/types/vendor/mermaid-ascii/sequence/parser.d.ts +6 -0
  75. package/dist/types/vendor/mermaid-ascii/sequence/types.d.ts +130 -0
  76. package/dist/types/vendor/mermaid-ascii/text-metrics.d.ts +21 -0
  77. package/dist/types/vendor/mermaid-ascii/types.d.ts +114 -0
  78. package/dist/types/vendor/mermaid-ascii/xychart/colors.d.ts +25 -0
  79. package/dist/types/vendor/mermaid-ascii/xychart/parser.d.ts +6 -0
  80. package/dist/types/vendor/mermaid-ascii/xychart/types.d.ts +145 -0
  81. package/dist/types/which.d.ts +37 -0
  82. package/dist/types/worker-host.d.ts +47 -0
  83. package/package.json +62 -0
  84. package/src/abortable.ts +109 -0
  85. package/src/async.ts +50 -0
  86. package/src/binary.ts +50 -0
  87. package/src/cli.ts +457 -0
  88. package/src/color.ts +302 -0
  89. package/src/dirs.ts +925 -0
  90. package/src/env.ts +226 -0
  91. package/src/fetch-retry.ts +355 -0
  92. package/src/format.ts +113 -0
  93. package/src/frontmatter.ts +162 -0
  94. package/src/fs-error.ts +56 -0
  95. package/src/glob.ts +189 -0
  96. package/src/index.ts +55 -0
  97. package/src/json-parse.ts +612 -0
  98. package/src/json.ts +10 -0
  99. package/src/logger.ts +672 -0
  100. package/src/loop-phase.ts +49 -0
  101. package/src/mermaid-ascii.ts +31 -0
  102. package/src/mime.ts +159 -0
  103. package/src/module-timer.ts +148 -0
  104. package/src/path-tree.ts +147 -0
  105. package/src/path.ts +28 -0
  106. package/src/peek-file.ts +188 -0
  107. package/src/postmortem.ts +222 -0
  108. package/src/procmgr.ts +195 -0
  109. package/src/prompt.ts +562 -0
  110. package/src/ptree.ts +395 -0
  111. package/src/ring.ts +169 -0
  112. package/src/runtime-install.ts +372 -0
  113. package/src/sanitize-text.ts +96 -0
  114. package/src/snowflake.ts +121 -0
  115. package/src/stream.ts +432 -0
  116. package/src/tab-spacing.ts +336 -0
  117. package/src/temp.ts +134 -0
  118. package/src/timing-buffer.ts +47 -0
  119. package/src/tls-fetch.ts +178 -0
  120. package/src/type-guards.ts +11 -0
  121. package/src/vendor/mermaid-ascii/NOTICE +33 -0
  122. package/src/vendor/mermaid-ascii/ascii/ansi.ts +409 -0
  123. package/src/vendor/mermaid-ascii/ascii/canvas.ts +476 -0
  124. package/src/vendor/mermaid-ascii/ascii/class-diagram.ts +699 -0
  125. package/src/vendor/mermaid-ascii/ascii/converter.ts +271 -0
  126. package/src/vendor/mermaid-ascii/ascii/draw.ts +1382 -0
  127. package/src/vendor/mermaid-ascii/ascii/edge-bundling.ts +328 -0
  128. package/src/vendor/mermaid-ascii/ascii/edge-routing.ts +297 -0
  129. package/src/vendor/mermaid-ascii/ascii/er-diagram.ts +441 -0
  130. package/src/vendor/mermaid-ascii/ascii/grid.ts +578 -0
  131. package/src/vendor/mermaid-ascii/ascii/index.ts +187 -0
  132. package/src/vendor/mermaid-ascii/ascii/multiline-utils.ts +78 -0
  133. package/src/vendor/mermaid-ascii/ascii/pathfinder.ts +215 -0
  134. package/src/vendor/mermaid-ascii/ascii/sequence.ts +460 -0
  135. package/src/vendor/mermaid-ascii/ascii/shapes/circle.ts +27 -0
  136. package/src/vendor/mermaid-ascii/ascii/shapes/corners.ts +127 -0
  137. package/src/vendor/mermaid-ascii/ascii/shapes/diamond.ts +27 -0
  138. package/src/vendor/mermaid-ascii/ascii/shapes/hexagon.ts +27 -0
  139. package/src/vendor/mermaid-ascii/ascii/shapes/index.ts +101 -0
  140. package/src/vendor/mermaid-ascii/ascii/shapes/rectangle.ts +175 -0
  141. package/src/vendor/mermaid-ascii/ascii/shapes/rounded.ts +27 -0
  142. package/src/vendor/mermaid-ascii/ascii/shapes/special.ts +296 -0
  143. package/src/vendor/mermaid-ascii/ascii/shapes/stadium.ts +114 -0
  144. package/src/vendor/mermaid-ascii/ascii/shapes/state.ts +192 -0
  145. package/src/vendor/mermaid-ascii/ascii/shapes/types.ts +73 -0
  146. package/src/vendor/mermaid-ascii/ascii/types.ts +273 -0
  147. package/src/vendor/mermaid-ascii/ascii/validate.ts +120 -0
  148. package/src/vendor/mermaid-ascii/ascii/xychart.ts +875 -0
  149. package/src/vendor/mermaid-ascii/class/parser.ts +290 -0
  150. package/src/vendor/mermaid-ascii/class/types.ts +121 -0
  151. package/src/vendor/mermaid-ascii/er/parser.ts +181 -0
  152. package/src/vendor/mermaid-ascii/er/types.ts +91 -0
  153. package/src/vendor/mermaid-ascii/index.ts +14 -0
  154. package/src/vendor/mermaid-ascii/multiline-utils.ts +30 -0
  155. package/src/vendor/mermaid-ascii/parser.ts +645 -0
  156. package/src/vendor/mermaid-ascii/sequence/parser.ts +207 -0
  157. package/src/vendor/mermaid-ascii/sequence/types.ts +146 -0
  158. package/src/vendor/mermaid-ascii/text-metrics.ts +71 -0
  159. package/src/vendor/mermaid-ascii/types.ts +164 -0
  160. package/src/vendor/mermaid-ascii/xychart/colors.ts +140 -0
  161. package/src/vendor/mermaid-ascii/xychart/parser.ts +115 -0
  162. package/src/vendor/mermaid-ascii/xychart/types.ts +150 -0
  163. package/src/which.ts +232 -0
  164. package/src/worker-host.ts +92 -0
package/src/env.ts ADDED
@@ -0,0 +1,226 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { getAgentDir, getConfigRootDir, refreshDirsFromEnv } from "./dirs";
5
+
6
+ export * from "./worker-host";
7
+
8
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
9
+
10
+ /**
11
+ * Strict shell-identifier shape. Used for dotenv keys we accept into
12
+ * `Bun.env` — those should be referenceable as `$NAME` from POSIX shells,
13
+ * so we reject anything outside `[A-Za-z_][A-Za-z0-9_]*`.
14
+ */
15
+ export function isValidEnvName(name: string): boolean {
16
+ return ENV_NAME_RE.test(name);
17
+ }
18
+
19
+ /**
20
+ * The only names that are genuinely unsafe to forward to a native `execve`
21
+ * spawn: empty, containing `=` (would corrupt the `KEY=VALUE` framing) or
22
+ * NUL (terminates the C string mid-entry). Windows ships standard variables
23
+ * whose names contain parentheses (e.g. `ProgramFiles(x86)`, `CommonProgramFiles(x86)`)
24
+ * — those MUST survive the scrub so downstream resolvers (Git Bash discovery
25
+ * in `procmgr.ts`, etc.) can still read them.
26
+ */
27
+ export function isSafeEnvName(name: string): boolean {
28
+ return name.length > 0 && !name.includes("=") && !name.includes("\0");
29
+ }
30
+
31
+ export function isSafeEnvValue(value: string): boolean {
32
+ return !value.includes("\0");
33
+ }
34
+
35
+ export function isMacosMallocStackLoggingEnvName(name: string): boolean {
36
+ return name === "MallocStackLogging" || name === "MallocStackLoggingNoCompact";
37
+ }
38
+
39
+ export function filterProcessEnv(env: Record<string, string | undefined>): Record<string, string> {
40
+ const result: Record<string, string> = {};
41
+ for (const key in env) {
42
+ const value = env[key];
43
+ if (
44
+ !isSafeEnvName(key) ||
45
+ isMacosMallocStackLoggingEnvName(key) ||
46
+ value === undefined ||
47
+ !isSafeEnvValue(value)
48
+ ) {
49
+ continue;
50
+ }
51
+ result[key] = value;
52
+ }
53
+ return result;
54
+ }
55
+
56
+ /**
57
+ * Parses a .env file synchronously and extracts key-value string pairs.
58
+ * Ignores lines that are empty or start with '#'. Trims whitespace.
59
+ * Allows values to be quoted with single or double quotes.
60
+ * Returns an object of key-value pairs.
61
+ */
62
+ export function parseEnvFile(filePath: string): Record<string, string> {
63
+ const result: Record<string, string> = {};
64
+ try {
65
+ const content = fs.readFileSync(filePath, "utf-8");
66
+ for (const line of content.split("\n")) {
67
+ const trimmed = line.trim();
68
+ // Skip comments and blank lines
69
+ if (!trimmed || trimmed.startsWith("#")) continue;
70
+
71
+ const eqIndex = trimmed.indexOf("=");
72
+ if (eqIndex === -1) continue;
73
+
74
+ const key = trimmed.slice(0, eqIndex).trim();
75
+ if (!isValidEnvName(key)) continue;
76
+
77
+ let value = trimmed.slice(eqIndex + 1).trim();
78
+
79
+ // Remove surrounding quotes (" or ')
80
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
81
+ value = value.slice(1, -1);
82
+ }
83
+ if (!isSafeEnvValue(value)) continue;
84
+
85
+ result[key] = value;
86
+ }
87
+ } catch {
88
+ // File doesn't exist or can't be read - return empty result
89
+ }
90
+
91
+ // OMP_ overrides PI_
92
+ for (const k in result) {
93
+ if (k.startsWith("OMP_")) {
94
+ result[`PI_${k.slice(4)}`] = result[k];
95
+ }
96
+ }
97
+
98
+ return result;
99
+ }
100
+
101
+ // Eagerly parse the user's $HOME/.env and the current project's .env (from cwd)
102
+ const homeEnv = parseEnvFile(path.join(os.homedir(), ".env"));
103
+ const piEnv = parseEnvFile(path.join(getConfigRootDir(), ".env"));
104
+ const agentEnv = parseEnvFile(path.join(getAgentDir(), ".env"));
105
+ const projectEnv = parseEnvFile(path.join(process.cwd(), ".env"));
106
+
107
+ for (const key of Object.keys(Bun.env)) {
108
+ const value = Bun.env[key];
109
+ if (!isSafeEnvName(key) || isMacosMallocStackLoggingEnvName(key) || value === undefined || !isSafeEnvValue(value)) {
110
+ delete Bun.env[key];
111
+ }
112
+ }
113
+
114
+ for (const file of [projectEnv, agentEnv, piEnv, homeEnv]) {
115
+ for (const key in file) {
116
+ if (!isMacosMallocStackLoggingEnvName(key) && !Bun.env[key]) {
117
+ Bun.env[key] = file[key];
118
+ }
119
+ }
120
+ }
121
+
122
+ // Directory-affecting keys (XDG_*_HOME, and in default mode PI_CODING_AGENT_DIR)
123
+ // may have just arrived from the profile/agent `.env` applied above. The dirs
124
+ // resolver cached its paths at module load — before this file ran — so rebuild
125
+ // it now from the updated env. `getAgentDir()` already located the `.env` from
126
+ // the profile name + home, so this re-reads only the directory vars.
127
+ refreshDirsFromEnv();
128
+
129
+ /**
130
+ * Intentional re-export of Bun.env.
131
+ *
132
+ * All users should import this env module (import { $env } from "@oh-my-pi-zen/pi-utils")
133
+ * before using environment variables. This ensures that .env files have been loaded and
134
+ * overrides (project, home) have been applied, so $env always reflects the correct values.
135
+ */
136
+ export const $env: Record<string, string> = Bun.env as Record<string, string>;
137
+
138
+ /**
139
+ * Resolve the first environment variable value from the given keys.
140
+ * @param keys - The keys to resolve.
141
+ * @returns The first environment variable value, or undefined if no value is found.
142
+ */
143
+ export function $pickenv(...keys: string[]): string | undefined {
144
+ for (const key of keys) {
145
+ const value = Bun.env[key]?.trim();
146
+ if (value) {
147
+ return value;
148
+ }
149
+ }
150
+ return undefined;
151
+ }
152
+
153
+ /**
154
+ * Parses a positive decimal integer from `$env[name]`.
155
+ * Empty, invalid, NaN, zero, or negative values return `defaultValue`.
156
+ */
157
+ export function $envpos(name: string, defaultValue: number): number {
158
+ const raw = $env[name];
159
+ if (!raw) return defaultValue;
160
+ const parsed = Number.parseInt(raw, 10);
161
+ if (Number.isNaN(parsed) || parsed <= 0) return defaultValue;
162
+ return parsed;
163
+ }
164
+
165
+ /** True when `BUN_ENV` or `NODE_ENV` is the string `test`. */
166
+ export function isBunTestRuntime(): boolean {
167
+ return Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
168
+ }
169
+
170
+ let terminalHeadless = isBunTestRuntime();
171
+
172
+ /**
173
+ * True when real-terminal side effects must be suppressed: stdout escape/frame
174
+ * writes, stdin raw-mode + resume, CSI/OSC capability probes, SIGWINCH, window
175
+ * title changes, and emergency restore. Defaults to {@link isBunTestRuntime} so
176
+ * `bun test` launched inside a real TTY never paints the TUI, leaks probe
177
+ * queries, or hijacks the developer's stdin; production runtimes stay
178
+ * interactive.
179
+ *
180
+ * Terminal-contract tests that must exercise the real I/O path opt out with
181
+ * `setTerminalHeadless(false)` and restore it afterwards.
182
+ */
183
+ export function isTerminalHeadless(): boolean {
184
+ return terminalHeadless;
185
+ }
186
+
187
+ /**
188
+ * Override the {@link isTerminalHeadless} default and return the previous value
189
+ * so callers can restore exact prior state (`const prev = setTerminalHeadless(false); … setTerminalHeadless(prev);`).
190
+ */
191
+ export function setTerminalHeadless(headless: boolean): boolean {
192
+ const previous = terminalHeadless;
193
+ terminalHeadless = headless;
194
+ return previous;
195
+ }
196
+
197
+ /**
198
+ * True when this code is running inside a `bun build --compile` standalone
199
+ * binary. Detects via the embedded virtual-filesystem path markers
200
+ * (`$bunfs`, `~BUN`, or its URL-encoded form `%7EBUN`) in `import.meta.url`,
201
+ * which Bun rewrites for every module bundled into the executable. The
202
+ * `PI_COMPILED` env var (set by the build script's `--define`) is checked
203
+ * first for cheap fast-path detection.
204
+ */
205
+ export function isCompiledBinary(): boolean {
206
+ if (process.env.PI_COMPILED || Bun.env.PI_COMPILED) return true;
207
+ const url = import.meta.url;
208
+ return url.includes("$bunfs") || url.includes("~BUN") || url.includes("%7EBUN");
209
+ }
210
+
211
+ const TRUTHY: Dict<boolean> = {
212
+ "1": true,
213
+ Y: true,
214
+ y: true,
215
+ TRUE: true,
216
+ true: true,
217
+ YES: true,
218
+ yes: true,
219
+ ON: true,
220
+ on: true,
221
+ };
222
+ export function $flag(name: string, def: boolean = false): boolean {
223
+ const value = $env[name];
224
+ if (!value) return def;
225
+ return TRUTHY[value] === true;
226
+ }
@@ -0,0 +1,355 @@
1
+ import { scheduler } from "node:timers/promises";
2
+
3
+ // "reset after 1h2m3s" / "10m15s" / "39s"
4
+ const QUOTA_RESET_PATTERN = /reset after (?:(\d+)h)?(?:(\d+)m)?(\d+(?:\.\d+)?)s/i;
5
+ // "Please retry in 250ms" / "Please retry in 12s"
6
+ const PLEASE_RETRY_PATTERN = /Please retry in ([0-9.]+)(ms|s)/i;
7
+ // JSON field: "retryDelay": "34.074824224s"
8
+ const RETRY_DELAY_FIELD_PATTERN = /"retryDelay":\s*"([0-9.]+)(ms|s)"/i;
9
+ // "try again in 250ms" / "try again in 12s" / "try again in 12sec" /
10
+ // "try again in 5 min" / "try again in ~158 min." / "try again in 2h" /
11
+ // "try again in 90 minutes" / "try again in 1 hour"
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
+
14
+ /**
15
+ * Server-suggested retry delay extraction. Merges the patterns historically used
16
+ * by the OpenAI Codex and Google Gemini retry helpers.
17
+ *
18
+ * Header sources (checked in order):
19
+ * - `Retry-After` (numeric seconds, or HTTP date)
20
+ * - `x-ratelimit-reset` (Unix epoch seconds)
21
+ * - `x-ratelimit-reset-after` (seconds)
22
+ *
23
+ * Body patterns:
24
+ * - `Your quota will reset after 18h31m10s` / `10m15s` / `39s`
25
+ * - `Please retry in 250ms` / `Please retry in 12s`
26
+ * - `"retryDelay": "34.074824224s"` (JSON error detail field)
27
+ * - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
28
+ *
29
+ * Returns `undefined` if no signal is found.
30
+ */
31
+ export function extractRetryHint(source: Response | Headers | null | undefined, body?: string): number | undefined {
32
+ const headers = source instanceof Headers ? source : (source?.headers ?? undefined);
33
+ if (headers) {
34
+ const retryAfter = headers.get("retry-after");
35
+ if (retryAfter) {
36
+ const seconds = Number(retryAfter);
37
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
38
+ const parsedDate = Date.parse(retryAfter);
39
+ if (!Number.isNaN(parsedDate)) return Math.max(0, parsedDate - Date.now());
40
+ }
41
+ const rateLimitReset = headers.get("x-ratelimit-reset");
42
+ if (rateLimitReset) {
43
+ const resetSeconds = Number.parseInt(rateLimitReset, 10);
44
+ if (!Number.isNaN(resetSeconds)) {
45
+ const delta = resetSeconds * 1000 - Date.now();
46
+ if (delta > 0) return delta;
47
+ }
48
+ }
49
+ const rateLimitResetAfter = headers.get("x-ratelimit-reset-after");
50
+ if (rateLimitResetAfter) {
51
+ const seconds = Number(rateLimitResetAfter);
52
+ if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000;
53
+ }
54
+ }
55
+
56
+ if (!body) return undefined;
57
+
58
+ const quotaMatch = QUOTA_RESET_PATTERN.exec(body);
59
+ if (quotaMatch) {
60
+ const hours = quotaMatch[1] ? Number.parseInt(quotaMatch[1], 10) : 0;
61
+ const minutes = quotaMatch[2] ? Number.parseInt(quotaMatch[2], 10) : 0;
62
+ const seconds = Number.parseFloat(quotaMatch[3]!);
63
+ if (!Number.isNaN(seconds)) {
64
+ const totalMs = ((hours * 60 + minutes) * 60 + seconds) * 1000;
65
+ if (totalMs > 0) return totalMs;
66
+ }
67
+ }
68
+ for (const pattern of [PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN]) {
69
+ const match = pattern.exec(body);
70
+ if (match?.[1]) {
71
+ const value = Number.parseFloat(match[1]);
72
+ if (Number.isFinite(value) && value > 0) {
73
+ const unitMs = unitToMs(match[2]!);
74
+ if (unitMs !== undefined) return value * unitMs;
75
+ }
76
+ }
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ function unitToMs(unit: string): number | undefined {
82
+ switch (unit.toLowerCase()) {
83
+ case "ms":
84
+ return 1;
85
+ case "s":
86
+ case "sec":
87
+ return 1000;
88
+ case "m":
89
+ case "min":
90
+ case "mins":
91
+ case "minute":
92
+ case "minutes":
93
+ return 60_000;
94
+ case "h":
95
+ case "hr":
96
+ case "hrs":
97
+ case "hour":
98
+ case "hours":
99
+ return 60 * 60_000;
100
+ default:
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ export interface FetchWithRetryOptions extends RequestInit {
106
+ /** Total fetch attempts (initial + retries). Default `5`. */
107
+ maxAttempts?: number;
108
+ /**
109
+ * Per-delay cap. Server-provided `Retry-After` hints exceeding this return
110
+ * the current response immediately — caller deals with the `!response.ok`.
111
+ * Default `60_000`.
112
+ */
113
+ maxDelayMs?: number;
114
+ /**
115
+ * Fallback delay schedule when no server hint is present. Number, array
116
+ * (indexed by attempt, clamped to last), or function. Default exponential
117
+ * `500ms * 2 ** attempt` capped at `maxDelayMs`.
118
+ */
119
+ defaultDelayMs?: number | readonly number[] | ((attempt: number) => number);
120
+ /**
121
+ * Optional per-attempt overlay merged into the base `RequestInit` each try.
122
+ * Headers from the overlay shallow-merge over the base. Useful for auth
123
+ * token refresh or user-agent rotation.
124
+ */
125
+ prepareInit?: (attempt: number) => RequestInit | Promise<RequestInit>;
126
+ /**
127
+ * Optional `fetch` implementation override. Defaults to `globalThis.fetch`.
128
+ * Useful for routing requests through a proxy, instrumented transport, or
129
+ * mock during tests.
130
+ */
131
+ fetch?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
132
+ /**
133
+ * Optional retry gate for HTTP responses whose status is retryable. Receives a
134
+ * cloned body string so callers can fail fast on deterministic provider
135
+ * failures that happen to use a 5xx status.
136
+ */
137
+ shouldRetryResponse?: (response: Response, bodyText: string, attempt: number) => boolean | Promise<boolean>;
138
+ /**
139
+ * Bun extension forwarded verbatim to the underlying `fetch` call. `false`
140
+ * disables Bun's native ~300s pre-response timeout (callers that own a
141
+ * configurable first-event/idle watchdog or an external `AbortSignal`
142
+ * supply this so the runtime ceiling cannot pre-empt them); a positive
143
+ * number sets a custom ceiling in ms. Bare browser/Node fetch ignores it.
144
+ */
145
+ timeout?: number | false;
146
+ }
147
+
148
+ const DEFAULT_MAX_DELAY_MS = 60_000;
149
+ const DEFAULT_MAX_ATTEMPTS = 5;
150
+
151
+ /**
152
+ * Fetch with bounded retries and sensible defaults. Retries on any
153
+ * `isRetryableStatus` (5xx, 408, 429) and on transient network errors. Server
154
+ * `Retry-After`/quota hints are honoured up to `maxDelayMs`; a hint that exceeds
155
+ * the cap returns the current response so the caller can fail fast. Aborts on
156
+ * `init.signal` propagate as `"Request was aborted"`.
157
+ *
158
+ * The caller is responsible for inspecting `!response.ok` once the call returns.
159
+ */
160
+ export async function fetchWithRetry(
161
+ url: string | URL | ((attempt: number) => string | URL),
162
+ options: FetchWithRetryOptions = {},
163
+ ): Promise<Response> {
164
+ const {
165
+ maxAttempts = DEFAULT_MAX_ATTEMPTS,
166
+ maxDelayMs = DEFAULT_MAX_DELAY_MS,
167
+ defaultDelayMs,
168
+ prepareInit,
169
+ shouldRetryResponse,
170
+ fetch: fetchImpl = fetch,
171
+ timeout = false,
172
+ ...baseInit
173
+ } = options;
174
+ const signal = baseInit.signal as AbortSignal | undefined;
175
+
176
+ for (let attempt = 0; ; attempt++) {
177
+ if (signal?.aborted) throw new Error("Request was aborted");
178
+ const requestUrl = typeof url === "function" ? url(attempt) : url;
179
+ // `timeout` is destructured out of `baseInit`, so forward it to the underlying
180
+ // fetch on the no-`prepareInit` path too. Without this, callers that pass
181
+ // `timeout: false` (every streaming provider, to disable Bun's native ~300s
182
+ // fetch ceiling in favor of their own first-event/idle watchdog) had it
183
+ // silently dropped, so long-running streams were killed at ~300s (issue #602).
184
+ // Only forward when the caller actually set `timeout`, so callers that never
185
+ // set it keep Bun's default ceiling.
186
+ const init = prepareInit
187
+ ? mergeInit(baseInit, await prepareInit(attempt), timeout)
188
+ : "timeout" in options
189
+ ? ({ ...baseInit, timeout } as unknown as RequestInit)
190
+ : baseInit;
191
+
192
+ let response: Response;
193
+ try {
194
+ response = await fetchImpl(requestUrl, init);
195
+ } catch (error) {
196
+ if (signal?.aborted) throw new Error("Request was aborted");
197
+ const wrapped = wrapNetworkError(error);
198
+ if (attempt + 1 >= maxAttempts) throw wrapped;
199
+ await scheduler.wait(resolveDefaultDelay(defaultDelayMs, attempt, maxDelayMs), { signal });
200
+ continue;
201
+ }
202
+
203
+ if (!isRetryableStatus(response.status)) return response;
204
+ if (attempt + 1 >= maxAttempts) return response;
205
+
206
+ const retryBody = await response.clone().text();
207
+ if (shouldRetryResponse && !(await shouldRetryResponse(response, retryBody, attempt))) return response;
208
+
209
+ const hint = extractRetryHint(response, retryBody);
210
+ if (hint !== undefined && hint > maxDelayMs) return response;
211
+
212
+ const delayMs = Math.min(hint ?? resolveDefaultDelay(defaultDelayMs, attempt, maxDelayMs), maxDelayMs);
213
+ await scheduler.wait(delayMs, { signal });
214
+ }
215
+ }
216
+
217
+ function mergeInit(base: RequestInit, overlay: RequestInit, timeout: number | false): RequestInit {
218
+ const merged = { ...base, ...overlay, timeout } as unknown as RequestInit;
219
+ if (base.headers || overlay.headers) {
220
+ const baseHeaders = new Headers(base.headers ?? undefined);
221
+ const overlayHeaders = new Headers(overlay.headers ?? undefined);
222
+ overlayHeaders.forEach((value, key) => {
223
+ baseHeaders.set(key, value);
224
+ });
225
+ merged.headers = baseHeaders;
226
+ }
227
+ return merged;
228
+ }
229
+
230
+ function wrapNetworkError(error: unknown): Error {
231
+ if (error instanceof Error) {
232
+ if (error.name === "AbortError" || error.message === "Request was aborted") {
233
+ return new Error("Request was aborted");
234
+ }
235
+ if (error.message === "fetch failed" && error.cause instanceof Error) {
236
+ return new Error(`Network error: ${error.cause.message}`);
237
+ }
238
+ return error;
239
+ }
240
+ return new Error(String(error));
241
+ }
242
+
243
+ function resolveDefaultDelay(
244
+ option: FetchWithRetryOptions["defaultDelayMs"],
245
+ attempt: number,
246
+ maxDelayMs: number,
247
+ ): number {
248
+ if (option === undefined) return Math.min(500 * 2 ** attempt, maxDelayMs);
249
+ if (typeof option === "number") return Math.min(option, maxDelayMs);
250
+ if (typeof option === "function") return Math.min(option(attempt), maxDelayMs);
251
+ return Math.min(option[Math.min(attempt, option.length - 1)] ?? 0, maxDelayMs);
252
+ }
253
+
254
+ /**
255
+ * Inspect an arbitrary error value (or its `cause` chain, up to depth 2) for an
256
+ * HTTP status code. Reads `status`, `statusCode`, and `response.status` fields,
257
+ * coerces string values, and falls back to scanning the error message for
258
+ * common patterns like `Error: 401`, `error (429)`, or `HTTP 503`.
259
+ */
260
+ export function extractHttpStatusFromError(error: unknown): number | undefined {
261
+ return extractHttpStatusFromErrorInternal(error, 0);
262
+ }
263
+
264
+ type HttpErrorLike = {
265
+ message?: string;
266
+ name?: string;
267
+ status?: number | string;
268
+ statusCode?: number | string;
269
+ response?: { status?: number | string };
270
+ cause?: unknown;
271
+ };
272
+
273
+ function extractHttpStatusFromErrorInternal(error: unknown, depth: number): number | undefined {
274
+ if (!error || typeof error !== "object" || depth > 2) return undefined;
275
+ const info = error as HttpErrorLike;
276
+ const rawStatus = info.status ?? info.statusCode ?? info.response?.status;
277
+
278
+ let status: number | undefined;
279
+ if (typeof rawStatus === "number" && Number.isFinite(rawStatus)) {
280
+ status = rawStatus;
281
+ } else if (typeof rawStatus === "string") {
282
+ const parsed = Number(rawStatus);
283
+ if (Number.isFinite(parsed)) status = parsed;
284
+ }
285
+ if (status !== undefined && status >= 100 && status <= 599) return status;
286
+
287
+ if (info.message) {
288
+ const extracted = extractStatusFromMessage(info.message);
289
+ if (extracted !== undefined) return extracted;
290
+ }
291
+ if (info.cause) return extractHttpStatusFromErrorInternal(info.cause, depth + 1);
292
+ return undefined;
293
+ }
294
+
295
+ const STATUS_MESSAGE_PATTERNS = [
296
+ /\berror\s*[:=]\s*(\d{3})\b/i,
297
+ /error\s*\((\d{3})\)/i,
298
+ /status\s*[:=]?\s*(\d{3})/i,
299
+ /\bhttp\s*(\d{3})\b/i,
300
+ /\b(\d{3})\s*(?:status|error)\b/i,
301
+ ] as const;
302
+
303
+ function extractStatusFromMessage(message: string): number | undefined {
304
+ for (const pattern of STATUS_MESSAGE_PATTERNS) {
305
+ const match = pattern.exec(message);
306
+ if (!match) continue;
307
+ const value = Number(match[1]);
308
+ if (Number.isFinite(value) && value >= 100 && value <= 599) return value;
309
+ }
310
+ return undefined;
311
+ }
312
+
313
+ /**
314
+ * `true` if the given HTTP status code is one we treat as transient: 408
315
+ * (Request Timeout), 429 (Too Many Requests), or any 5xx (server error).
316
+ */
317
+ export function isRetryableStatus(status: number): boolean {
318
+ return status >= 500 || status === 408 || status === 429;
319
+ }
320
+
321
+ /**
322
+ * `true` if the message describes an unexpected socket closure — Bun and some
323
+ * proxies surface these for any HTTP/2 stream reset.
324
+ */
325
+ export function isUnexpectedSocketCloseMessage(message: string): boolean {
326
+ return /\b(?:the\s+)?socket connection (?:was )?closed unexpectedly\b/i.test(message);
327
+ }
328
+
329
+ const TRANSIENT_MESSAGE_PATTERN =
330
+ /overloaded|rate.?limit|too many requests|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|network error|stream stall|other side closed|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)/i;
331
+
332
+ const VALIDATION_MESSAGE_PATTERN =
333
+ /invalid|validation|bad request|unsupported|schema|missing required|not found|unauthorized|forbidden/i;
334
+
335
+ /**
336
+ * Identify errors that should be retried: aborts/timeouts in the error name or
337
+ * message, retryable HTTP statuses (see `isRetryableStatus`), unexpected socket
338
+ * closes, and the standard transient phrases. 4xx statuses other than 408/429
339
+ * and validation-shaped messages short-circuit to `false`.
340
+ */
341
+ export function isRetryableError(error: unknown): boolean {
342
+ const info = error as { message?: string; name?: string } | null;
343
+ const message = info?.message ?? "";
344
+ const name = info?.name ?? "";
345
+ if (name === "AbortError" || /timeout|timed out|aborted/i.test(message)) return true;
346
+
347
+ const status = extractHttpStatusFromError(error);
348
+ if (status !== undefined) {
349
+ if (isRetryableStatus(status)) return true;
350
+ if (status >= 400 && status < 500) return false;
351
+ }
352
+
353
+ if (VALIDATION_MESSAGE_PATTERN.test(message)) return false;
354
+ return isUnexpectedSocketCloseMessage(message) || TRANSIENT_MESSAGE_PATTERN.test(message);
355
+ }
package/src/format.ts ADDED
@@ -0,0 +1,113 @@
1
+ const SEC = 1_000;
2
+ const MIN = 60 * SEC;
3
+ const HOUR = 60 * MIN;
4
+ const DAY = 24 * HOUR;
5
+
6
+ /**
7
+ * Format a duration in milliseconds to a short human-readable string.
8
+ * Examples: "123ms", "1.5s", "30m15s", "2h30m", "3d2h"
9
+ */
10
+ export function formatDuration(ms: number): string {
11
+ if (!Number.isFinite(ms) || ms <= 0) return "0ms";
12
+ if (ms < SEC) return `${ms}ms`;
13
+ if (ms < MIN) return `${(ms / SEC).toFixed(1)}s`;
14
+ if (ms < HOUR) {
15
+ const mins = Math.floor(ms / MIN);
16
+ const secs = Math.floor((ms % MIN) / SEC);
17
+ return secs > 0 ? `${mins}m${secs}s` : `${mins}m`;
18
+ }
19
+ if (ms < DAY) {
20
+ const hours = Math.floor(ms / HOUR);
21
+ const mins = Math.floor((ms % HOUR) / MIN);
22
+ return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
23
+ }
24
+ const days = Math.floor(ms / DAY);
25
+ const hours = Math.floor((ms % DAY) / HOUR);
26
+ return hours > 0 ? `${days}d${hours}h` : `${days}d`;
27
+ }
28
+
29
+ /**
30
+ * Format a number with K/M/B suffix for compact display.
31
+ * Uses 1 decimal for small leading digits when non-zero, rounded otherwise.
32
+ * Examples: "999", "1K", "1.5K", "25K", "1M", "1.5M", "25M", "1.5B"
33
+ */
34
+ export function formatNumber(n: number): string {
35
+ if (n < 1_000) return n.toString();
36
+ if (n < 10_000) return `${trim1(n / 1_000)}K`;
37
+ if (n < 1_000_000) return `${Math.round(n / 1_000)}K`;
38
+ if (n < 10_000_000) return `${trim1(n / 1_000_000)}M`;
39
+ if (n < 1_000_000_000) return `${Math.round(n / 1_000_000)}M`;
40
+ if (n < 10_000_000_000) return `${trim1(n / 1_000_000_000)}B`;
41
+ return `${Math.round(n / 1_000_000_000)}B`;
42
+ }
43
+
44
+ /** Format with up to 1 decimal place, dropping trailing `.0`. */
45
+ function trim1(n: number): string {
46
+ const s = n.toFixed(1);
47
+ return s.endsWith(".0") ? s.slice(0, -2) : s;
48
+ }
49
+
50
+ /**
51
+ * Format a byte count to a human-readable string.
52
+ * Examples: "512B", "1.5KB", "2.3MB", "1.2GB"
53
+ */
54
+ export function formatBytes(bytes: number): string {
55
+ if (bytes < 1024) return `${bytes}B`;
56
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
57
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
58
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
59
+ }
60
+
61
+ /**
62
+ * Truncate a string to maxLen characters, appending an ellipsis if truncated.
63
+ * For display-width-aware truncation (terminals), use truncateToWidth from @oh-my-pi-zen/pi-tui.
64
+ */
65
+ export function truncate(str: string, maxLen: number, ellipsis = "…"): string {
66
+ if (str.length <= maxLen) return str;
67
+ const sliceLen = Math.max(0, maxLen - ellipsis.length);
68
+ return `${str.slice(0, sliceLen)}${ellipsis}`;
69
+ }
70
+
71
+ /**
72
+ * Format count with pluralized label (e.g., "3 files", "1 error").
73
+ */
74
+ export function formatCount(label: string, count: number): string {
75
+ const safeCount = Number.isFinite(count) ? count : 0;
76
+ return `${safeCount} ${pluralize(label, safeCount)}`;
77
+ }
78
+
79
+ /**
80
+ * Format age from seconds to human-readable string.
81
+ */
82
+ export function formatAge(ageSeconds: number | null | undefined): string {
83
+ if (!ageSeconds) return "";
84
+ const mins = Math.floor(ageSeconds / 60);
85
+ const hours = Math.floor(mins / 60);
86
+ const days = Math.floor(hours / 24);
87
+ const weeks = Math.floor(days / 7);
88
+ const months = Math.floor(days / 30);
89
+
90
+ if (months > 0) return `${months}mo ago`;
91
+ if (weeks > 0) return `${weeks}w ago`;
92
+ if (days > 0) return `${days}d ago`;
93
+ if (hours > 0) return `${hours}h ago`;
94
+ if (mins > 0) return `${mins}m ago`;
95
+ return "just now";
96
+ }
97
+
98
+ /**
99
+ * Pluralize a label based on the count.
100
+ */
101
+ export function pluralize(label: string, count: number): string {
102
+ if (count === 1) return label;
103
+ if (/(?:ch|sh|s|x|z)$/i.test(label)) return `${label}es`;
104
+ if (/[^aeiou]y$/i.test(label)) return `${label.slice(0, -1)}ies`;
105
+ return `${label}s`;
106
+ }
107
+
108
+ /**
109
+ * Format a ratio as a percentage.
110
+ */
111
+ export function formatPercent(ratio: number): string {
112
+ return `${(ratio * 100).toFixed(1)}%`;
113
+ }