@oh-my-pi/pi-utils 17.2.3 → 17.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.5] - 2026-08-03
6
+
7
+ ### Added
8
+
9
+ - Added utility functions `parseFlag()`, `getBrowserRelayDir()`, and `getGlobalDaemonRuntimeDir()` to support browser relay mode and global daemon runtime directory resolution.
10
+
11
+ ### Changed
12
+
13
+ - Updated the lightweight CLI runner to support static command metadata, allowing root help to render without importing full command implementations.
14
+
15
+ ## [17.2.4] - 2026-08-01
16
+
17
+ ### Added
18
+
19
+ - Added `getSecretPlaceholderKeyPath()`, `getDaemonRuntimeDir()`, `getProviderInFlightRoot()`, and `getMarketplacesRegistryPath()` to resolve secret key, daemon runtime, provider in-flight, and marketplace registry paths under their respective XDG categories (state, data) instead of the config root.
20
+ - Existing installs enabling XDG keep their data: a legacy `~/.omp/agent/secret-placeholder.key` or `~/.omp/marketplaces.json` is copied to its XDG location on first resolution, so persisted transcripts still deobfuscate and added marketplaces survive the move.
21
+
22
+ ### Changed
23
+
24
+ - Headless hosts (print/RPC/ACP/eval/SDK) now use a 1s SQLite `busy_timeout` for the session-critical databases (agent.db, history.db, stats.db) via `getDbBusyTimeoutMs()`, so lock contention no longer freezes the protocol loop for the full interactive 5s timeout; interactive hosts keep the 5s timeout.
25
+
26
+ ### Fixed
27
+
28
+ - Fixed Bun test-runtime detection treating application-owned `NODE_ENV=test` and `BUN_ENV=test` values as test-runner signals ([#7261](https://github.com/can1357/oh-my-pi/issues/7261)).
29
+
5
30
  ## [17.2.1] - 2026-07-30
6
31
 
7
32
  ### Added
@@ -70,22 +70,24 @@ export interface ParseOutput<F extends Record<string, FlagDescriptor> = Record<s
70
70
  args: ArgValues<A>;
71
71
  argv: string[];
72
72
  }
73
- export interface CommandCtor {
74
- new (argv: string[], config: CliConfig): Command;
73
+ export interface CommandMetadata {
75
74
  description?: string;
76
75
  hidden?: boolean;
77
- strict?: boolean;
78
- aliases?: string[];
79
- examples?: string[];
80
76
  flags?: Record<string, FlagDescriptor>;
81
77
  args?: Record<string, ArgDescriptor>;
78
+ examples?: string[];
79
+ }
80
+ export interface CommandCtor extends CommandMetadata {
81
+ new (argv: string[], config: CliConfig): Command;
82
+ strict?: boolean;
83
+ aliases?: string[];
82
84
  }
83
85
  /** Configuration passed to every command instance and help renderers. */
84
- export interface CliConfig {
86
+ export interface CliConfig<TCommand extends CommandMetadata = CommandCtor> {
85
87
  bin: string;
86
88
  version: string;
87
89
  /** All registered commands keyed by their canonical name. */
88
- commands: Map<string, CommandCtor>;
90
+ commands: Map<string, TCommand>;
89
91
  }
90
92
  /** Minimal Command base matching the oclif surface we use. */
91
93
  export declare abstract class Command {
@@ -100,7 +102,7 @@ export declare abstract class Command {
100
102
  parse<C extends CommandCtor>(_Cmd: C): Promise<ParseOutput<NonNullable<C["flags"]> extends Record<string, FlagDescriptor> ? NonNullable<C["flags"]> : Record<string, FlagDescriptor>, NonNullable<C["args"]> extends Record<string, ArgDescriptor> ? NonNullable<C["args"]> : Record<string, ArgDescriptor>>>;
101
103
  }
102
104
  /** Render full root help: header, default command details, subcommand list. */
103
- export declare function renderRootHelp(config: CliConfig): void;
105
+ export declare function renderRootHelp(config: CliConfig<CommandMetadata>): void;
104
106
  /** Build the single USAGE line for a command (without the leading label). */
105
107
  export declare function commandUsageLine(bin: string, id: string, Cmd: CommandCtor): string;
106
108
  /** Render help for a single command. */
@@ -109,6 +111,7 @@ export declare function renderCommandHelp(bin: string, id: string, Cmd: CommandC
109
111
  export interface CommandEntry {
110
112
  name: string;
111
113
  load: () => Promise<CommandCtor>;
114
+ help?: CommandMetadata;
112
115
  aliases?: string[];
113
116
  }
114
117
  export interface RunOptions {
@@ -116,8 +119,10 @@ export interface RunOptions {
116
119
  version: string;
117
120
  argv: string[];
118
121
  commands: CommandEntry[];
119
- /** Custom help renderer. Receives fully-populated config. */
122
+ /** Custom help renderer with the fully loaded command constructors. */
120
123
  help?: (config: CliConfig) => Promise<void> | void;
124
+ /** Lightweight help renderer backed by static command metadata. */
125
+ metadataHelp?: (config: CliConfig<CommandMetadata>) => Promise<void> | void;
121
126
  }
122
127
  /**
123
128
  * Main entry point — replaces `run()` from @oclif/core.
@@ -153,6 +153,8 @@ export declare function getPythonEnvDir(): string;
153
153
  export declare function getPythonGatewayDir(): string;
154
154
  /** Get the puppeteer sandbox directory (~/.omp/puppeteer). */
155
155
  export declare function getPuppeteerDir(): string;
156
+ /** Get the browser relay extension install directory (~/.omp/browser-relay). */
157
+ export declare function getBrowserRelayDir(): string;
156
158
  /** Get DOCS_RS cache directory () */
157
159
  export declare function getDocsRsCacheDir(): string;
158
160
  /** Get the auto-QA grievances SQLite database path (~/.omp/autoqa.db; XDG: $XDG_DATA_HOME/omp/autoqa.db). */
@@ -237,6 +239,16 @@ export declare function getTerminalSessionsDir(agentDir?: string): string;
237
239
  export declare function getCrashLogPath(agentDir?: string): string;
238
240
  /** Get the debug log path (~/.omp/agent/omp-debug.log). */
239
241
  export declare function getDebugLogPath(agentDir?: string): string;
242
+ /** Get the secret placeholder key path (~/.omp/agent/secret-placeholder.key; XDG default: $XDG_STATE_HOME/omp/secret-placeholder.key). Adopts a legacy key on first XDG resolution. */
243
+ export declare function getSecretPlaceholderKeyPath(): string;
244
+ /** Get the daemon runtime directory for a project (~/.omp/run/daemons/<hash>; XDG default: $XDG_STATE_HOME/omp/run/daemons/<hash>). */
245
+ export declare function getDaemonRuntimeDir(projectDir: string): string;
246
+ /** Get a profile-independent runtime directory for a machine-global daemon service. */
247
+ export declare function getGlobalDaemonRuntimeDir(service: string): string;
248
+ /** Get the provider in-flight root directory (~/.omp/run/provider-inflight; XDG default: $XDG_STATE_HOME/omp/run/provider-inflight). */
249
+ export declare function getProviderInFlightRoot(): string;
250
+ /** Get the marketplaces registry path (~/.omp/marketplaces.json; XDG default: $XDG_DATA_HOME/omp/marketplaces.json). Adopts a legacy registry on first XDG resolution. */
251
+ export declare function getMarketplacesRegistryPath(): string;
240
252
  /** Get the project-level Python modules directory (.omp/modules). */
241
253
  export declare function getProjectModulesDir(cwd?: string): string;
242
254
  /** Get the project-level prompts directory (.omp/prompts). */
@@ -39,12 +39,31 @@ export declare const $env: Record<string, string>;
39
39
  * @returns The first environment variable value, or undefined if no value is found.
40
40
  */
41
41
  export declare function $pickenv(...keys: string[]): string | undefined;
42
+ /**
43
+ * Read an environment variable by its EXACT, case-sensitive name.
44
+ *
45
+ * `process.env` / `Bun.env` lookups are case-insensitive on Windows (Node backs
46
+ * them with `uv_os_getenv`, Bun with a `CaseInsensitiveASCIIStringArrayHashMap`),
47
+ * so a lowercase literal like `public` silently resolves to a differently-cased
48
+ * system variable — Windows ships `PUBLIC=C:\Users\Public`. Enumerated keys are
49
+ * the only signal that preserves the real casing, so this trusts the lookup only
50
+ * when a key with identical casing is actually present. On POSIX (case-sensitive
51
+ * env) it is equivalent to a direct lookup.
52
+ *
53
+ * Use this instead of `process.env[name] ?? literal` wherever `name` may be a
54
+ * user-supplied literal (e.g. a stored API key) rather than a genuine env-var
55
+ * reference — otherwise the literal gets hijacked by a same-named system var.
56
+ *
57
+ * @param name - Environment variable name to look up.
58
+ * @param env - Environment source; defaults to `process.env`.
59
+ */
60
+ export declare function $envExact(name: string, env?: Record<string, string | undefined>): string | undefined;
42
61
  /**
43
62
  * Parses a positive decimal integer from `$env[name]`.
44
63
  * Empty, invalid, NaN, zero, or negative values return `defaultValue`.
45
64
  */
46
65
  export declare function $envpos(name: string, defaultValue: number): number;
47
- /** True when `BUN_ENV` or `NODE_ENV` is the string `test`. */
66
+ /** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
48
67
  export declare function isBunTestRuntime(): boolean;
49
68
  /**
50
69
  * True when real-terminal side effects must be suppressed: stdout escape/frame
@@ -77,6 +96,20 @@ export declare function isInteractiveHost(): boolean;
77
96
  * restore exact prior state. See {@link isInteractiveHost}.
78
97
  */
79
98
  export declare function setInteractiveHost(interactive: boolean): boolean;
99
+ /**
100
+ * SQLite `busy_timeout` for the session-critical databases (agent.db,
101
+ * history.db, stats.db).
102
+ *
103
+ * Interactive hosts tolerate a longer synchronous wait on lock contention
104
+ * (SQLITE_BUSY during WAL recovery/checkpoint — see oh-my-pi#2421): the
105
+ * operator sees a brief freeze and the statement eventually completes.
106
+ * Headless hosts (print/RPC/ACP/eval/SDK) run a protocol on the same thread —
107
+ * a multi-second synchronous busy-wait freezes their event loop and stalls
108
+ * every in-flight frame with no liveness signal, so they use a short timeout
109
+ * and rely on the existing asynchronous open/retry paths to recover from
110
+ * contention instead of blocking.
111
+ */
112
+ export declare function getDbBusyTimeoutMs(): number;
80
113
  /**
81
114
  * True when this code is running inside a `bun build --compile` standalone
82
115
  * binary. Detects via the embedded virtual-filesystem path markers
@@ -86,4 +119,6 @@ export declare function setInteractiveHost(interactive: boolean): boolean;
86
119
  * first for cheap fast-path detection.
87
120
  */
88
121
  export declare function isCompiledBinary(): boolean;
122
+ /** Parse a boolean-ish env value ("1", "yes", "on", …); `def` when unset/empty. */
123
+ export declare function parseFlag(value: string | undefined, def?: boolean): boolean;
89
124
  export declare function $flag(name: string, def?: boolean): boolean;
@@ -15,6 +15,18 @@ export declare enum Reason {
15
15
  UNHANDLED_REJECTION = "unhandled_rejection",// Unhandled promise rejection
16
16
  MANUAL = "manual"
17
17
  }
18
+ /**
19
+ * Symbol stamped by the extension-load guard onto the throwing replacement it
20
+ * installs over `process.exit` / `process.reallyExit`, carrying the native
21
+ * primitive that replacement shadows.
22
+ *
23
+ * Host-owned shutdown ({@link exitProcess}) reads through it so a signal that
24
+ * lands while the guard is active still terminates the process (#6488), while
25
+ * a signal that lands after the guard has restored the native exit also
26
+ * terminates cleanly (#7393). `Symbol.for` so it survives duplicate module
27
+ * instances across bundles/realms.
28
+ */
29
+ export declare const NATIVE_PROCESS_EXIT: unique symbol;
18
30
  /** Origin of an EPIPE raised by a process communication channel. */
19
31
  export type BrokenPipeSource = "ipc-send" | "stdio-write";
20
32
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "17.2.3",
4
+ "version": "17.2.5",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "17.2.3",
34
+ "@oh-my-pi/pi-natives": "17.2.5",
35
35
  "handlebars": "^4.7.9",
36
36
  "winston": "^3.19.0",
37
37
  "winston-daily-rotate-file": "5.0.0"
package/src/cli.ts CHANGED
@@ -133,23 +133,26 @@ export interface ParseOutput<
133
133
  // Command base class
134
134
  // ---------------------------------------------------------------------------
135
135
 
136
- export interface CommandCtor {
137
- new (argv: string[], config: CliConfig): Command;
136
+ export interface CommandMetadata {
138
137
  description?: string;
139
138
  hidden?: boolean;
140
- strict?: boolean;
141
- aliases?: string[];
142
- examples?: string[];
143
139
  flags?: Record<string, FlagDescriptor>;
144
140
  args?: Record<string, ArgDescriptor>;
141
+ examples?: string[];
142
+ }
143
+
144
+ export interface CommandCtor extends CommandMetadata {
145
+ new (argv: string[], config: CliConfig): Command;
146
+ strict?: boolean;
147
+ aliases?: string[];
145
148
  }
146
149
 
147
150
  /** Configuration passed to every command instance and help renderers. */
148
- export interface CliConfig {
151
+ export interface CliConfig<TCommand extends CommandMetadata = CommandCtor> {
149
152
  bin: string;
150
153
  version: string;
151
154
  /** All registered commands keyed by their canonical name. */
152
- commands: Map<string, CommandCtor>;
155
+ commands: Map<string, TCommand>;
153
156
  }
154
157
 
155
158
  /** Minimal Command base matching the oclif surface we use. */
@@ -290,7 +293,7 @@ export abstract class Command {
290
293
  // ---------------------------------------------------------------------------
291
294
 
292
295
  /** Render full root help: header, default command details, subcommand list. */
293
- export function renderRootHelp(config: CliConfig): void {
296
+ export function renderRootHelp(config: CliConfig<CommandMetadata>): void {
294
297
  const { bin, version, commands } = config;
295
298
  const lines: string[] = [];
296
299
  lines.push(`${bin} v${version}\n`);
@@ -299,7 +302,7 @@ export function renderRootHelp(config: CliConfig): void {
299
302
 
300
303
  // Show the default command's flags/args/examples inline.
301
304
  // The default command is the one marked hidden (it's the implicit entry point).
302
- const defaultCmd = [...commands.values()].find(C => C.hidden);
305
+ const defaultCmd = [...commands.values()].find(command => command.hidden);
303
306
  if (defaultCmd) {
304
307
  renderCommandBody(lines, defaultCmd);
305
308
  }
@@ -309,8 +312,8 @@ export function renderRootHelp(config: CliConfig): void {
309
312
  if (visible.length > 0) {
310
313
  lines.push("COMMANDS");
311
314
  const maxLen = Math.max(...visible.map(([n]) => n.length));
312
- for (const [name, C] of visible.sort((a, b) => a[0].localeCompare(b[0]))) {
313
- lines.push(` ${name.padEnd(maxLen + 2)}${C.description ?? ""}`);
315
+ for (const [name, command] of visible.sort((a, b) => a[0].localeCompare(b[0]))) {
316
+ lines.push(` ${name.padEnd(maxLen + 2)}${command.description ?? ""}`);
314
317
  }
315
318
  lines.push("");
316
319
  }
@@ -350,9 +353,9 @@ export function renderCommandHelp(bin: string, id: string, Cmd: CommandCtor): vo
350
353
  process.stdout.write(lines.join("\n"));
351
354
  }
352
355
 
353
- function renderCommandBody(lines: string[], Cmd: CommandCtor): void {
354
- const argDefs = Cmd.args ?? {};
355
- const flagDefs = Cmd.flags ?? {};
356
+ function renderCommandBody(lines: string[], command: CommandMetadata): void {
357
+ const argDefs = command.args ?? {};
358
+ const flagDefs = command.flags ?? {};
356
359
 
357
360
  // Arguments
358
361
  const argEntries = Object.entries(argDefs);
@@ -387,9 +390,9 @@ function renderCommandBody(lines: string[], Cmd: CommandCtor): void {
387
390
  }
388
391
 
389
392
  // Examples
390
- if (Cmd.examples && Cmd.examples.length > 0) {
393
+ if (command.examples && command.examples.length > 0) {
391
394
  lines.push("EXAMPLES");
392
- for (const ex of Cmd.examples) {
395
+ for (const ex of command.examples) {
393
396
  for (const line of ex.split("\n")) {
394
397
  lines.push(` ${line}`);
395
398
  }
@@ -406,6 +409,7 @@ function renderCommandBody(lines: string[], Cmd: CommandCtor): void {
406
409
  export interface CommandEntry {
407
410
  name: string;
408
411
  load: () => Promise<CommandCtor>;
412
+ help?: CommandMetadata;
409
413
  aliases?: string[];
410
414
  }
411
415
 
@@ -414,8 +418,10 @@ export interface RunOptions {
414
418
  version: string;
415
419
  argv: string[];
416
420
  commands: CommandEntry[];
417
- /** Custom help renderer. Receives fully-populated config. */
421
+ /** Custom help renderer with the fully loaded command constructors. */
418
422
  help?: (config: CliConfig) => Promise<void> | void;
423
+ /** Lightweight help renderer backed by static command metadata. */
424
+ metadataHelp?: (config: CliConfig<CommandMetadata>) => Promise<void> | void;
419
425
  }
420
426
 
421
427
  /** Find a command entry by exact name or alias. */
@@ -437,11 +443,15 @@ export async function run(opts: RunOptions): Promise<void> {
437
443
 
438
444
  // Top-level help
439
445
  if (commandId === "--help" || commandId === "-h" || commandId === "help" || commandId === "") {
440
- const config = await loadAllCommands(opts);
441
446
  if (opts.help) {
442
- await opts.help(config);
447
+ await opts.help(await loadAllCommands(opts));
443
448
  } else {
444
- renderRootHelp(config);
449
+ const config = await loadAllCommandMetadata(opts);
450
+ if (opts.metadataHelp) {
451
+ await opts.metadataHelp(config);
452
+ } else {
453
+ renderRootHelp(config);
454
+ }
445
455
  }
446
456
  return;
447
457
  }
@@ -504,12 +514,16 @@ async function loadEntry(entry: CommandEntry): Promise<CommandCtor> {
504
514
  return Cmd;
505
515
  }
506
516
 
507
- /** Resolve all command loaders for help/alias display. */
517
+ /** Load every command constructor for backward-compatible custom help callbacks. */
508
518
  async function loadAllCommands(opts: RunOptions): Promise<CliConfig> {
509
- const commands = new Map<string, CommandCtor>();
510
- const loaded = await Promise.all(opts.commands.map(async e => [e.name, await loadEntry(e)] as const));
511
- for (const [name, Cmd] of loaded) {
512
- commands.set(name, Cmd);
513
- }
514
- return { bin: opts.bin, version: opts.version, commands };
519
+ const loaded = await Promise.all(opts.commands.map(async entry => [entry.name, await loadEntry(entry)] as const));
520
+ return { bin: opts.bin, version: opts.version, commands: new Map(loaded) };
521
+ }
522
+
523
+ /** Resolve static command metadata for lightweight root help. */
524
+ async function loadAllCommandMetadata(opts: RunOptions): Promise<CliConfig<CommandMetadata>> {
525
+ const loaded = await Promise.all(
526
+ opts.commands.map(async entry => [entry.name, entry.help ?? (await loadEntry(entry))] as const),
527
+ );
528
+ return { bin: opts.bin, version: opts.version, commands: new Map(loaded) };
515
529
  }
package/src/dirs.ts CHANGED
@@ -627,6 +627,11 @@ export function getPuppeteerDir(): string {
627
627
  return dirs.rootSubdir("puppeteer", "cache");
628
628
  }
629
629
 
630
+ /** Get the browser relay extension install directory (~/.omp/browser-relay). */
631
+ export function getBrowserRelayDir(): string {
632
+ return dirs.rootSubdir("browser-relay", "data");
633
+ }
634
+
630
635
  /** Get DOCS_RS cache directory () */
631
636
  export function getDocsRsCacheDir(): string {
632
637
  return dirs.rootSubdir("webcache", "cache");
@@ -820,6 +825,58 @@ export function getDebugLogPath(agentDir?: string): string {
820
825
  return dirs.agentSubdir(agentDir, `${APP_NAME}-debug.log`, "state");
821
826
  }
822
827
 
828
+ /**
829
+ * Best-effort one-time copy of a legacy config-root file to its redirected XDG
830
+ * location. Existing installs that enable XDG after the file was created keep
831
+ * their data (e.g. a placeholder key whose loss would break deobfuscation of
832
+ * persisted transcripts). The legacy file is left in place for older omp
833
+ * versions sharing the profile.
834
+ */
835
+ function adoptLegacyFile(legacyPath: string, targetPath: string): void {
836
+ if (targetPath === legacyPath) return;
837
+ try {
838
+ if (fs.existsSync(targetPath) || !fs.existsSync(legacyPath)) return;
839
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
840
+ fs.copyFileSync(legacyPath, targetPath, fs.constants.COPYFILE_EXCL);
841
+ } catch {
842
+ // Opportunistic: a copy race or unwritable XDG dir falls back to a fresh
843
+ // file at the new path — the pre-adoption behavior.
844
+ }
845
+ }
846
+
847
+ /** Get the secret placeholder key path (~/.omp/agent/secret-placeholder.key; XDG default: $XDG_STATE_HOME/omp/secret-placeholder.key). Adopts a legacy key on first XDG resolution. */
848
+ export function getSecretPlaceholderKeyPath(): string {
849
+ const keyPath = dirs.agentSubdir(undefined, "secret-placeholder.key", "state");
850
+ adoptLegacyFile(path.join(dirs.agentDir, "secret-placeholder.key"), keyPath);
851
+ return keyPath;
852
+ }
853
+
854
+ /** Get the daemon runtime directory for a project (~/.omp/run/daemons/<hash>; XDG default: $XDG_STATE_HOME/omp/run/daemons/<hash>). */
855
+ export function getDaemonRuntimeDir(projectDir: string): string {
856
+ const key = Bun.hash.wyhash(path.resolve(projectDir)).toString(16).padStart(16, "0");
857
+ return dirs.rootSubdir(path.join("run", "daemons", key), "state");
858
+ }
859
+
860
+ /** Get a profile-independent runtime directory for a machine-global daemon service. */
861
+ export function getGlobalDaemonRuntimeDir(service: string): string {
862
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(service)) {
863
+ throw new Error(`Invalid global daemon service name: ${JSON.stringify(service)}`);
864
+ }
865
+ return path.join(getBaseConfigRoot(), "run", "daemons", "global", service);
866
+ }
867
+
868
+ /** Get the provider in-flight root directory (~/.omp/run/provider-inflight; XDG default: $XDG_STATE_HOME/omp/run/provider-inflight). */
869
+ export function getProviderInFlightRoot(): string {
870
+ return dirs.rootSubdir(path.join("run", "provider-inflight"), "state");
871
+ }
872
+
873
+ /** Get the marketplaces registry path (~/.omp/marketplaces.json; XDG default: $XDG_DATA_HOME/omp/marketplaces.json). Adopts a legacy registry on first XDG resolution. */
874
+ export function getMarketplacesRegistryPath(): string {
875
+ const registryPath = dirs.rootSubdir("marketplaces.json", "data");
876
+ adoptLegacyFile(path.join(dirs.configRoot, "marketplaces.json"), registryPath);
877
+ return registryPath;
878
+ }
879
+
823
880
  // =============================================================================
824
881
  // Project subdirectories (.omp/*)
825
882
  // =============================================================================
package/src/env.ts CHANGED
@@ -246,6 +246,35 @@ export function $pickenv(...keys: string[]): string | undefined {
246
246
  return undefined;
247
247
  }
248
248
 
249
+ /**
250
+ * Read an environment variable by its EXACT, case-sensitive name.
251
+ *
252
+ * `process.env` / `Bun.env` lookups are case-insensitive on Windows (Node backs
253
+ * them with `uv_os_getenv`, Bun with a `CaseInsensitiveASCIIStringArrayHashMap`),
254
+ * so a lowercase literal like `public` silently resolves to a differently-cased
255
+ * system variable — Windows ships `PUBLIC=C:\Users\Public`. Enumerated keys are
256
+ * the only signal that preserves the real casing, so this trusts the lookup only
257
+ * when a key with identical casing is actually present. On POSIX (case-sensitive
258
+ * env) it is equivalent to a direct lookup.
259
+ *
260
+ * Use this instead of `process.env[name] ?? literal` wherever `name` may be a
261
+ * user-supplied literal (e.g. a stored API key) rather than a genuine env-var
262
+ * reference — otherwise the literal gets hijacked by a same-named system var.
263
+ *
264
+ * @param name - Environment variable name to look up.
265
+ * @param env - Environment source; defaults to `process.env`.
266
+ */
267
+ export function $envExact(name: string, env: Record<string, string | undefined> = process.env): string | undefined {
268
+ const value = env[name];
269
+ if (value === undefined) return undefined;
270
+ // Enumeration preserves real key casing on Windows, unlike the getter; the
271
+ // value is trusted only when an exact-case entry actually exists.
272
+ for (const key in env) {
273
+ if (key === name) return value;
274
+ }
275
+ return undefined;
276
+ }
277
+
249
278
  /**
250
279
  * Parses a positive decimal integer from `$env[name]`.
251
280
  * Empty, invalid, NaN, zero, or negative values return `defaultValue`.
@@ -258,9 +287,13 @@ export function $envpos(name: string, defaultValue: number): number {
258
287
  return parsed;
259
288
  }
260
289
 
261
- /** True when `BUN_ENV` or `NODE_ENV` is the string `test`. */
290
+ const BUN_TEST_ENTRY_PATTERN = /[._](?:test|spec)\.[cm]?[jt]sx?$/;
291
+
292
+ /** True when the process is an explicitly marked test child or Bun is running a test entrypoint. */
262
293
  export function isBunTestRuntime(): boolean {
263
- return Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
294
+ if (Bun.env.PI_TEST_RUNTIME === "1") return true;
295
+ const hasTestEnvironment = Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
296
+ return hasTestEnvironment && BUN_TEST_ENTRY_PATTERN.test(Bun.main);
264
297
  }
265
298
 
266
299
  let terminalHeadless = isBunTestRuntime();
@@ -314,6 +347,23 @@ export function setInteractiveHost(interactive: boolean): boolean {
314
347
  return previous;
315
348
  }
316
349
 
350
+ /**
351
+ * SQLite `busy_timeout` for the session-critical databases (agent.db,
352
+ * history.db, stats.db).
353
+ *
354
+ * Interactive hosts tolerate a longer synchronous wait on lock contention
355
+ * (SQLITE_BUSY during WAL recovery/checkpoint — see oh-my-pi#2421): the
356
+ * operator sees a brief freeze and the statement eventually completes.
357
+ * Headless hosts (print/RPC/ACP/eval/SDK) run a protocol on the same thread —
358
+ * a multi-second synchronous busy-wait freezes their event loop and stalls
359
+ * every in-flight frame with no liveness signal, so they use a short timeout
360
+ * and rely on the existing asynchronous open/retry paths to recover from
361
+ * contention instead of blocking.
362
+ */
363
+ export function getDbBusyTimeoutMs(): number {
364
+ return isInteractiveHost() ? 5000 : 1000;
365
+ }
366
+
317
367
  /**
318
368
  * True when this code is running inside a `bun build --compile` standalone
319
369
  * binary. Detects via the embedded virtual-filesystem path markers
@@ -339,8 +389,12 @@ const TRUTHY: Dict<boolean> = {
339
389
  ON: true,
340
390
  on: true,
341
391
  };
342
- export function $flag(name: string, def: boolean = false): boolean {
343
- const value = $env[name];
392
+ /** Parse a boolean-ish env value ("1", "yes", "on", …); `def` when unset/empty. */
393
+ export function parseFlag(value: string | undefined, def = false): boolean {
344
394
  if (!value) return def;
345
395
  return TRUTHY[value] === true;
346
396
  }
397
+
398
+ export function $flag(name: string, def: boolean = false): boolean {
399
+ return parseFlag($env[name], def);
400
+ }
package/src/postmortem.ts CHANGED
@@ -29,8 +29,41 @@ const callbackList: ((reason: Reason) => Promise<void> | void)[] = [];
29
29
  // Tracks cleanup run state (to prevent recursion/reentry issues)
30
30
  let cleanupStage: "idle" | "running" | "complete" = "idle";
31
31
  const CLEANUP_DEADLINE_MS = 10_000;
32
- const exitProcess =
33
- typeof process.reallyExit === "function" ? process.reallyExit.bind(process) : process.exit.bind(process);
32
+ /**
33
+ * Symbol stamped by the extension-load guard onto the throwing replacement it
34
+ * installs over `process.exit` / `process.reallyExit`, carrying the native
35
+ * primitive that replacement shadows.
36
+ *
37
+ * Host-owned shutdown ({@link exitProcess}) reads through it so a signal that
38
+ * lands while the guard is active still terminates the process (#6488), while
39
+ * a signal that lands after the guard has restored the native exit also
40
+ * terminates cleanly (#7393). `Symbol.for` so it survives duplicate module
41
+ * instances across bundles/realms.
42
+ */
43
+ export const NATIVE_PROCESS_EXIT = Symbol.for("omp.postmortem.nativeProcessExit");
44
+
45
+ type HardExitFn = (code?: number) => never;
46
+
47
+ /**
48
+ * Hard-exit the process through the native primitive, resolved on every call.
49
+ *
50
+ * The native exit is deliberately re-resolved here rather than bound at module
51
+ * load: the extension/hook loader's `withHostGuard` transiently swaps
52
+ * `process.reallyExit`/`process.exit` for a stub that throws
53
+ * `ExtensionExitError`, and the shipped bundle defers this module's evaluation
54
+ * until first access — which can land inside that guard window, so binding at
55
+ * init could freeze the throwing stub forever and turn every later shutdown
56
+ * (SIGHUP/SIGINT/fatal) into an unhandled-rejection loop (#7393). When the
57
+ * guard is active the stub carries the native exit under
58
+ * {@link NATIVE_PROCESS_EXIT}; unwrapping it lets a mid-guard signal still exit
59
+ * (#6488). Otherwise the current `process.reallyExit`/`process.exit` is native.
60
+ */
61
+ function exitProcess(code: number): never {
62
+ const current: HardExitFn = typeof process.reallyExit === "function" ? process.reallyExit : process.exit;
63
+ const behind = Reflect.get(current, NATIVE_PROCESS_EXIT);
64
+ const nativeExit = typeof behind === "function" ? (behind as HardExitFn) : current;
65
+ return nativeExit.call(process, code) as never;
66
+ }
34
67
  let cleanupPromise: Promise<void> | undefined;
35
68
  let stdioDisconnectRegistrations = 0;
36
69