@oh-my-pi/pi-utils 17.1.3 → 17.1.4

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,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.1.4] - 2026-07-26
6
+
7
+ ### Fixed
8
+
9
+ - Fixed postmortem signal and fatal shutdown exits being intercepted by temporary `process.exit` guards during extension startup ([#6488](https://github.com/can1357/oh-my-pi/issues/6488)).
10
+ - Corrected Windows shell resolution errors to identify the active global, project, overlay, or runtime source for `shellPath` instead of directing every user to the retired `settings.json` file ([#6579](https://github.com/can1357/oh-my-pi/issues/6579)).
11
+ - Contained timed-out child lifecycle rejections so `ptree` callers cannot leak an unhandled `TimeoutError` after settling ([#6635](https://github.com/can1357/oh-my-pi/issues/6635)).
12
+ - Fixed an invalid configured `shellPath` being silently masked whenever an earlier caller had already resolved a shell in the same process; the guidance error now surfaces regardless of cache state.
13
+
5
14
  ## [17.0.9] - 2026-07-23
6
15
 
7
16
  ### Breaking Changes
@@ -49,10 +49,7 @@ export declare function markExpectedCleanupError<T extends object>(reason: T): T
49
49
  export declare function isExpectedCleanupError(reason: unknown): boolean;
50
50
  /**
51
51
  * Register an interceptor consulted before an unhandled rejection tears the
52
- * process down. Return `true` to consume the rejection the interceptor owns
53
- * reporting and the process continues. Used by embedded script runtimes (JS
54
- * eval cells) whose user code can float rejections the host must not die for.
55
- * Returns an unregister function.
52
+ * process down. A consuming interceptor owns reporting and keeps the process alive.
56
53
  */
57
54
  export declare function interceptUnhandledRejections(interceptor: (reason: unknown) => boolean): () => void;
58
55
  /**
@@ -68,9 +65,9 @@ export declare function register(id: string, callback: (reason: Reason) => void
68
65
  */
69
66
  export declare function cleanup(): Promise<void>;
70
67
  /**
71
- * Runs all cleanup callbacks and exits.
68
+ * Runs all cleanup callbacks and exits through the current `process.exit`.
72
69
  *
73
- * In main thread: waits for stdout drain, then calls process.exit().
70
+ * In main thread: waits for stdout drain, then calls `process.exit()`.
74
71
  * In workers: runs cleanup only (process.exit would kill entire process).
75
72
  */
76
73
  export declare function quit(code?: number): Promise<void>;
@@ -5,23 +5,52 @@ export interface ShellConfig {
5
5
  env: Record<string, string>;
6
6
  prefix: string | undefined;
7
7
  }
8
+ /** Identifies the settings source users should edit when shell resolution fails. */
9
+ export interface ShellConfigOptions {
10
+ /** File path or runtime layer that supplied the active shell setting. */
11
+ configSource?: string;
12
+ }
8
13
  /**
9
14
  * Check if a shell binary is executable.
10
15
  */
11
16
  export declare function isExecutable(path: string): boolean;
17
+ /** Whether the shell is Windows cmd.exe (spawn paths must use `/c`, not `-c`). */
18
+ export declare function isCmdShell(shell: string): boolean;
12
19
  /**
13
20
  * Resolve a basic shell (bash or sh) as fallback.
14
21
  */
15
22
  export declare function resolveBasicShell(): string | undefined;
23
+ /**
24
+ * Resolve the external shell to advertise on Windows.
25
+ *
26
+ * A host bash is OPTIONAL: bash tool commands always execute in the embedded
27
+ * brush-core shell. The resolved binary only serves the spawn-a-shell paths
28
+ * (interactive PTY sessions, ACP client terminals, SHELL env), so this
29
+ * prefers a real Git Bash when one exists and otherwise falls back to
30
+ * cmd.exe — it never fails.
31
+ *
32
+ * Search order:
33
+ * 1. Git for Windows install roots (machine + per-user installers)
34
+ * 2. scoop installs — scoop's git manifest sets GIT_INSTALL_ROOT and shims
35
+ * sh.exe/git.exe but never bash.exe, so PATH lookup alone misses it
36
+ * 3. bash.exe on PATH (Cygwin, MSYS2, ...)
37
+ * 4. sh.exe on PATH (Git for Windows' sh.exe is bash; prefer a sibling
38
+ * bash.exe when present)
39
+ * 5. cmd.exe from ComSpec
40
+ *
41
+ * Exported for tests; `env` overrides Bun.env-based discovery.
42
+ */
43
+ export declare function resolveWindowsShell(env?: Record<string, string | undefined>): string;
16
44
  /**
17
45
  * Get shell configuration based on platform.
18
46
  * Resolution order:
19
- * 1. User-specified shellPath in settings.json
20
- * 2. On Windows: Git Bash in known locations, then bash on PATH
47
+ * 1. User-specified shellPath from the active settings source
48
+ * 2. On Windows: Git Bash / bash / sh discovery, then cmd.exe (see
49
+ * {@link resolveWindowsShell}) — never fails
21
50
  * 3. On Unix: $SHELL if bash/zsh, then fallback paths
22
51
  * 4. Fallback: sh
23
52
  */
24
- export declare function getShellConfig(customShellPath?: string): ShellConfig;
53
+ export declare function getShellConfig(customShellPath?: string, options?: ShellConfigOptions): ShellConfig;
25
54
  /**
26
55
  * Check if a process is running.
27
56
  */
@@ -1,3 +1,7 @@
1
+ /** Prefix reserved for argv selectors dispatched by the shared CLI worker host. */
2
+ export declare const WORKER_HOST_SELECTOR_PREFIX = "__omp_worker_";
3
+ /** Whether an argv value selects a worker hosted by the shared CLI entrypoint. */
4
+ export declare function isWorkerHostSelector(value: string | undefined): value is string;
1
5
  /** Called by CLI entrypoints whose main module dispatches worker argv selectors. */
2
6
  export declare function declareWorkerHostEntry(): void;
3
7
  /** Main-module path of the self-dispatching CLI host, or null outside it. */
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.1.3",
4
+ "version": "17.1.4",
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.1.3",
34
+ "@oh-my-pi/pi-natives": "17.1.4",
35
35
  "handlebars": "^4.7.9",
36
36
  "winston": "^3.19.0",
37
37
  "winston-daily-rotate-file": "^5.0.0"
package/src/postmortem.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import * as fs from "node:fs";
10
10
  import inspector from "node:inspector";
11
11
  import { isMainThread } from "node:worker_threads";
12
- import { logger } from ".";
12
+ import * as logger from "./logger";
13
13
  import { restoreTerminalStderr } from "./stderr-guard";
14
14
 
15
15
  // Cleanup reasons, in order of priority/meaning.
@@ -29,6 +29,8 @@ 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
34
  let cleanupPromise: Promise<void> | undefined;
33
35
  let stdioDisconnectRegistrations = 0;
34
36
 
@@ -149,18 +151,12 @@ export function isExpectedCleanupError(reason: unknown): boolean {
149
151
  return false;
150
152
  }
151
153
 
152
- /**
153
- * Interceptors consulted by the global `unhandledRejection` handler before the
154
- * fatal path. See {@link interceptUnhandledRejections}.
155
- */
154
+ /** Interceptors consulted by the global `unhandledRejection` handler before the fatal path. */
156
155
  const rejectionInterceptors = new Set<(reason: unknown) => boolean>();
157
156
 
158
157
  /**
159
158
  * Register an interceptor consulted before an unhandled rejection tears the
160
- * process down. Return `true` to consume the rejection the interceptor owns
161
- * reporting and the process continues. Used by embedded script runtimes (JS
162
- * eval cells) whose user code can float rejections the host must not die for.
163
- * Returns an unregister function.
159
+ * process down. A consuming interceptor owns reporting and keeps the process alive.
164
160
  */
165
161
  export function interceptUnhandledRejections(interceptor: (reason: unknown) => boolean): () => void {
166
162
  rejectionInterceptors.add(interceptor);
@@ -177,7 +173,7 @@ function formatFatalError(label: string, err: Error): string {
177
173
  }
178
174
 
179
175
  async function exitAfterFatal(label: string, logMessage: string, err: Error, reason: Reason): Promise<void> {
180
- const forcedExit = setTimeout(() => process.exit(1), CLEANUP_DEADLINE_MS);
176
+ const forcedExit = setTimeout(() => exitProcess(1), CLEANUP_DEADLINE_MS);
181
177
  try {
182
178
  restoreTerminalStderr();
183
179
  // A revoked terminal can make stream writes raise another fatal error. Use
@@ -189,7 +185,7 @@ async function exitAfterFatal(label: string, logMessage: string, err: Error, rea
189
185
  await runCleanup(reason);
190
186
  } finally {
191
187
  clearTimeout(forcedExit);
192
- process.exit(1);
188
+ exitProcess(1);
193
189
  }
194
190
  }
195
191
 
@@ -197,7 +193,7 @@ if (isMainThread) {
197
193
  process
198
194
  .on("SIGINT", async () => {
199
195
  await runCleanup(Reason.SIGINT);
200
- process.exit(130); // 128 + SIGINT (2)
196
+ exitProcess(130); // 128 + SIGINT (2)
201
197
  })
202
198
  .on("SIGUSR1", () => {
203
199
  if (inspectorOpened) return;
@@ -231,7 +227,7 @@ if (isMainThread) {
231
227
  }
232
228
  if (brokenPipeSource === "stdio-write" && stdioDisconnectRegistrations > 0) {
233
229
  logger.warn("Stdio peer disconnected; shutting down gracefully", { err });
234
- await quit(0);
230
+ await runQuit(0, "native");
235
231
  return;
236
232
  }
237
233
  if (isExpectedCleanupError(reason)) {
@@ -254,11 +250,11 @@ if (isMainThread) {
254
250
  })
255
251
  .on("SIGTERM", async () => {
256
252
  await runCleanup(Reason.SIGTERM);
257
- process.exit(143); // 128 + SIGTERM (15)
253
+ exitProcess(143); // 128 + SIGTERM (15)
258
254
  })
259
255
  .on("SIGHUP", async () => {
260
256
  await runCleanup(Reason.SIGHUP);
261
- process.exit(129); // 128 + SIGHUP (1)
257
+ exitProcess(129); // 128 + SIGHUP (1)
262
258
  });
263
259
  } else {
264
260
  // Worker thread: only register exit handler for cleanup.
@@ -323,13 +319,7 @@ export function cleanup(): Promise<void> {
323
319
  return runCleanup(Reason.MANUAL);
324
320
  }
325
321
 
326
- /**
327
- * Runs all cleanup callbacks and exits.
328
- *
329
- * In main thread: waits for stdout drain, then calls process.exit().
330
- * In workers: runs cleanup only (process.exit would kill entire process).
331
- */
332
- export async function quit(code: number = 0): Promise<void> {
322
+ async function runQuit(code: number, exitMode: "guarded" | "native"): Promise<void> {
333
323
  await runCleanup(Reason.MANUAL);
334
324
 
335
325
  if (!isMainThread) {
@@ -341,5 +331,21 @@ export async function quit(code: number = 0): Promise<void> {
341
331
  process.stdout.once("drain", resolve);
342
332
  await Promise.race([promise, Bun.sleep(5000)]);
343
333
  }
344
- process.exit(code);
334
+
335
+ switch (exitMode) {
336
+ case "guarded":
337
+ return process.exit(code);
338
+ case "native":
339
+ return exitProcess(code);
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Runs all cleanup callbacks and exits through the current `process.exit`.
345
+ *
346
+ * In main thread: waits for stdout drain, then calls `process.exit()`.
347
+ * In workers: runs cleanup only (process.exit would kill entire process).
348
+ */
349
+ export function quit(code: number = 0): Promise<void> {
350
+ return runQuit(code, "guarded");
345
351
  }
package/src/procmgr.ts CHANGED
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { Process, ProcessStatus } from "@oh-my-pi/pi-natives";
4
4
  import type { Subprocess } from "bun";
5
+ import { getAgentDir, MAIN_CONFIG_FILENAMES } from "./dirs";
5
6
  import { $env, filterChildShellEnv } from "./env";
6
7
  import { $which } from "./which";
7
8
 
@@ -11,6 +12,12 @@ export interface ShellConfig {
11
12
  env: Record<string, string>;
12
13
  prefix: string | undefined;
13
14
  }
15
+
16
+ /** Identifies the settings source users should edit when shell resolution fails. */
17
+ export interface ShellConfigOptions {
18
+ /** File path or runtime layer that supplied the active shell setting. */
19
+ configSource?: string;
20
+ }
14
21
  let cachedShellConfig: ShellConfig | null = null;
15
22
 
16
23
  /**
@@ -42,14 +49,22 @@ function buildSpawnEnv(shell: string): Record<string, string> {
42
49
  }
43
50
 
44
51
  /**
45
- * Get shell args, optionally including login shell flag.
46
- * Supports PI_BASH_NO_LOGIN and CLAUDE_BASH_NO_LOGIN to skip -l.
52
+ * Get shell args for the resolved shell.
53
+ * cmd.exe takes `/c`; POSIX shells take `-c`, with `-l` unless
54
+ * PI_BASH_NO_LOGIN / CLAUDE_BASH_NO_LOGIN is set.
47
55
  */
48
- function getShellArgs(): string[] {
56
+ function getShellArgs(shell: string): string[] {
57
+ if (isCmdShell(shell)) return ["/c"];
49
58
  const noLogin = $env.PI_BASH_NO_LOGIN || $env.CLAUDE_BASH_NO_LOGIN;
50
59
  return noLogin ? ["-c"] : ["-l", "-c"];
51
60
  }
52
61
 
62
+ /** Whether the shell is Windows cmd.exe (spawn paths must use `/c`, not `-c`). */
63
+ export function isCmdShell(shell: string): boolean {
64
+ const basename = shell.replace(/\\/g, "/").split("/").pop()?.toLowerCase();
65
+ return basename === "cmd.exe" || basename === "cmd";
66
+ }
67
+
53
68
  /**
54
69
  * Get shell prefix for wrapping commands (profilers, strace, etc.).
55
70
  */
@@ -63,7 +78,7 @@ function getShellPrefix(): string | undefined {
63
78
  function buildConfig(shell: string): ShellConfig {
64
79
  return {
65
80
  shell,
66
- args: getShellArgs(),
81
+ args: getShellArgs(shell),
67
82
  env: buildSpawnEnv(shell),
68
83
  prefix: getShellPrefix(),
69
84
  };
@@ -93,63 +108,83 @@ export function resolveBasicShell(): string | undefined {
93
108
  return undefined;
94
109
  }
95
110
 
111
+ /**
112
+ * Resolve the external shell to advertise on Windows.
113
+ *
114
+ * A host bash is OPTIONAL: bash tool commands always execute in the embedded
115
+ * brush-core shell. The resolved binary only serves the spawn-a-shell paths
116
+ * (interactive PTY sessions, ACP client terminals, SHELL env), so this
117
+ * prefers a real Git Bash when one exists and otherwise falls back to
118
+ * cmd.exe — it never fails.
119
+ *
120
+ * Search order:
121
+ * 1. Git for Windows install roots (machine + per-user installers)
122
+ * 2. scoop installs — scoop's git manifest sets GIT_INSTALL_ROOT and shims
123
+ * sh.exe/git.exe but never bash.exe, so PATH lookup alone misses it
124
+ * 3. bash.exe on PATH (Cygwin, MSYS2, ...)
125
+ * 4. sh.exe on PATH (Git for Windows' sh.exe is bash; prefer a sibling
126
+ * bash.exe when present)
127
+ * 5. cmd.exe from ComSpec
128
+ *
129
+ * Exported for tests; `env` overrides Bun.env-based discovery.
130
+ */
131
+ export function resolveWindowsShell(env: Record<string, string | undefined> = Bun.env): string {
132
+ const gitRoots = [
133
+ env.ProgramFiles && path.join(env.ProgramFiles, "Git"),
134
+ env["ProgramFiles(x86)"] && path.join(env["ProgramFiles(x86)"], "Git"),
135
+ env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, "Programs", "Git"),
136
+ env.GIT_INSTALL_ROOT,
137
+ env.SCOOP && path.join(env.SCOOP, "apps", "git", "current"),
138
+ env.USERPROFILE && path.join(env.USERPROFILE, "scoop", "apps", "git", "current"),
139
+ ];
140
+ for (const root of gitRoots) {
141
+ if (!root) continue;
142
+ const candidate = path.join(root, "bin", "bash.exe");
143
+ if (fs.existsSync(candidate)) return candidate;
144
+ }
145
+
146
+ const bashOnPath = $which("bash.exe");
147
+ if (bashOnPath) return bashOnPath;
148
+
149
+ const shOnPath = $which("sh.exe");
150
+ if (shOnPath) {
151
+ const siblingBash = path.join(path.dirname(shOnPath), "bash.exe");
152
+ return fs.existsSync(siblingBash) ? siblingBash : shOnPath;
153
+ }
154
+
155
+ return env.ComSpec || env.COMSPEC || "C:\\Windows\\System32\\cmd.exe";
156
+ }
157
+
96
158
  /**
97
159
  * Get shell configuration based on platform.
98
160
  * Resolution order:
99
- * 1. User-specified shellPath in settings.json
100
- * 2. On Windows: Git Bash in known locations, then bash on PATH
161
+ * 1. User-specified shellPath from the active settings source
162
+ * 2. On Windows: Git Bash / bash / sh discovery, then cmd.exe (see
163
+ * {@link resolveWindowsShell}) — never fails
101
164
  * 3. On Unix: $SHELL if bash/zsh, then fallback paths
102
165
  * 4. Fallback: sh
103
166
  */
104
- export function getShellConfig(customShellPath?: string): ShellConfig {
105
- if (cachedShellConfig) {
106
- return cachedShellConfig;
107
- }
108
-
109
- // 1. Check user-specified shell path
167
+ export function getShellConfig(customShellPath?: string, options: ShellConfigOptions = {}): ShellConfig {
168
+ const configSource = options.configSource ?? path.join(getAgentDir(), MAIN_CONFIG_FILENAMES[0]);
169
+ // 1. Check user-specified shell path. Validated even on the cached path so a
170
+ // broken shellPath surfaces its guidance error instead of being masked by an
171
+ // earlier successful resolution in the same process.
110
172
  if (customShellPath) {
111
- if (fs.existsSync(customShellPath)) {
173
+ if (!fs.existsSync(customShellPath)) {
174
+ throw new Error(`Custom shell path not found: ${customShellPath}\nPlease update shellPath in ${configSource}`);
175
+ }
176
+ if (cachedShellConfig?.shell !== customShellPath) {
112
177
  cachedShellConfig = buildConfig(customShellPath);
113
- return cachedShellConfig;
114
178
  }
115
- throw new Error(
116
- `Custom shell path not found: ${customShellPath}\nPlease update shellPath in ~/.omp/agent/settings.json`,
117
- );
179
+ return cachedShellConfig;
180
+ }
181
+ if (cachedShellConfig) {
182
+ return cachedShellConfig;
118
183
  }
119
184
 
120
185
  if (process.platform === "win32") {
121
- // 2. Try Git Bash in known locations
122
- const paths: string[] = [];
123
- const programFiles = Bun.env.ProgramFiles;
124
- if (programFiles) {
125
- paths.push(`${programFiles}\\Git\\bin\\bash.exe`);
126
- }
127
- const programFilesX86 = Bun.env["ProgramFiles(x86)"];
128
- if (programFilesX86) {
129
- paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
130
- }
131
-
132
- for (const path of paths) {
133
- if (fs.existsSync(path)) {
134
- cachedShellConfig = buildConfig(path);
135
- return cachedShellConfig;
136
- }
137
- }
138
-
139
- // 3. Fallback: search bash.exe on PATH (Cygwin, MSYS2, WSL, etc.)
140
- const bashOnPath = $which("bash.exe");
141
- if (bashOnPath) {
142
- cachedShellConfig = buildConfig(bashOnPath);
143
- return cachedShellConfig;
144
- }
145
-
146
- throw new Error(
147
- `No bash shell found. Options:\n` +
148
- ` 1. Install Git for Windows: https://git-scm.com/download/win\n` +
149
- ` 2. Add your bash to PATH (Cygwin, MSYS2, etc.)\n` +
150
- ` 3. Set shellPath in ~/.omp/agent/settings.json\n\n` +
151
- `Searched Git Bash in:\n${paths.map(p => ` ${p}`).join("\n")}`,
152
- );
186
+ cachedShellConfig = buildConfig(resolveWindowsShell());
187
+ return cachedShellConfig;
153
188
  }
154
189
 
155
190
  // Unix: prefer user's shell from $SHELL if it's bash/zsh and executable
package/src/ptree.ts CHANGED
@@ -309,6 +309,7 @@ export class ChildProcess<In extends InMask = InMask> {
309
309
 
310
310
  attachTimeout(ms: number): void {
311
311
  if (ms <= 0 || this.proc.killed) return;
312
+ this.#exited.catch(() => {});
312
313
  Promise.race([
313
314
  Bun.sleep(ms).then(() => true),
314
315
  this.proc.exited.then(
@@ -1,5 +1,13 @@
1
1
  import { stripWindowsExtendedLengthPathPrefix } from "./path";
2
2
 
3
+ /** Prefix reserved for argv selectors dispatched by the shared CLI worker host. */
4
+ export const WORKER_HOST_SELECTOR_PREFIX = "__omp_worker_";
5
+
6
+ /** Whether an argv value selects a worker hosted by the shared CLI entrypoint. */
7
+ export function isWorkerHostSelector(value: string | undefined): value is string {
8
+ return value?.startsWith(WORKER_HOST_SELECTOR_PREFIX) ?? false;
9
+ }
10
+
3
11
  /**
4
12
  * Main-module path declared by self-dispatching CLI entrypoints — entries
5
13
  * whose top-level argv handling routes hidden `__omp_*` worker selectors.