@oh-my-pi/pi-utils 16.5.0 → 16.5.2
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 +17 -0
- package/dist/types/cli.d.ts +12 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/stderr-guard.d.ts +22 -0
- package/dist/types/vendor/mermaid-ascii/ascii/pathfinder.d.ts +1 -1
- package/package.json +2 -2
- package/src/cli.ts +74 -16
- package/src/index.ts +1 -0
- package/src/postmortem.ts +8 -0
- package/src/stderr-guard.ts +155 -0
- package/src/vendor/mermaid-ascii/ascii/pathfinder.ts +67 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.5.2] - 2026-07-14
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Improved CLI argument and flag validation error output to display a concise error message and command usage instead of a minified code frame.
|
|
10
|
+
- Corrected required variadic positionals to render as `MODELS...` instead of `[MODELS]` in usage help.
|
|
11
|
+
|
|
12
|
+
## [16.5.1] - 2026-07-14
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Added terminal stderr guard utilities (suppressTerminalStderr and restoreTerminalStderr) to prevent macOS runtime diagnostics from corrupting TUI viewports while ensuring crash reports remain visible.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Fixed an issue in Mermaid ASCII routing where unreachable edge attachment points caused unbounded pathfinder searches.
|
|
21
|
+
|
|
5
22
|
## [16.4.6] - 2026-07-12
|
|
6
23
|
|
|
7
24
|
### Added
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A user-facing argument/flag validation failure. Thrown by {@link Command.parse}
|
|
3
|
+
* for missing/invalid positionals and flags. The top-level {@link run} handler
|
|
4
|
+
* prints its message plus the command usage line to stderr and exits 1, instead
|
|
5
|
+
* of letting it bubble to the process-level catch — which would dump a minified
|
|
6
|
+
* `dist/cli.js` code frame over a plain argument mistake (issue #5369).
|
|
7
|
+
*/
|
|
8
|
+
export declare class CliUsageError extends Error {
|
|
9
|
+
constructor(message: string);
|
|
10
|
+
}
|
|
1
11
|
export interface FlagDescriptor<K extends "string" | "boolean" | "integer" = "string" | "boolean" | "integer"> {
|
|
2
12
|
kind: K;
|
|
3
13
|
description?: string;
|
|
@@ -91,6 +101,8 @@ export declare abstract class Command {
|
|
|
91
101
|
}
|
|
92
102
|
/** Render full root help: header, default command details, subcommand list. */
|
|
93
103
|
export declare function renderRootHelp(config: CliConfig): void;
|
|
104
|
+
/** Build the single USAGE line for a command (without the leading label). */
|
|
105
|
+
export declare function commandUsageLine(bin: string, id: string, Cmd: CommandCtor): string;
|
|
94
106
|
/** Render help for a single command. */
|
|
95
107
|
export declare function renderCommandHelp(bin: string, id: string, Cmd: CommandCtor): void;
|
|
96
108
|
/** A lazily-loaded command: canonical name, loader, and optional aliases. */
|
package/dist/types/index.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree.j
|
|
|
26
26
|
export * from "./runtime-install.js";
|
|
27
27
|
export * from "./sanitize-text.js";
|
|
28
28
|
export * from "./snowflake.js";
|
|
29
|
+
export * from "./stderr-guard.js";
|
|
29
30
|
export * from "./stream.js";
|
|
30
31
|
export * from "./tab-spacing.js";
|
|
31
32
|
export * from "./temp.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface SuppressTerminalStderrOptions {
|
|
2
|
+
/** Redirect target path; defaults to today's omp log file, then /dev/null. */
|
|
3
|
+
redirectPath?: string;
|
|
4
|
+
/** Bypass the macOS + same-terminal gate. Tests only. */
|
|
5
|
+
force?: boolean;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Redirect fd 2 away from the terminal while the TUI owns the viewport.
|
|
9
|
+
* Returns true when suppression is (already) active. No-op — returning
|
|
10
|
+
* false — off macOS, when stderr does not target the stdout terminal, or
|
|
11
|
+
* when the libc fd ops are unavailable.
|
|
12
|
+
*/
|
|
13
|
+
export declare function suppressTerminalStderr(options?: SuppressTerminalStderrOptions): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Re-point fd 2 at the saved terminal stderr. Safe to call unconditionally:
|
|
16
|
+
* no-op when suppression is not active. Called at every terminal-ownership
|
|
17
|
+
* release and by the postmortem fatal handlers before they print, so crash
|
|
18
|
+
* reports reach the real terminal.
|
|
19
|
+
*/
|
|
20
|
+
export declare function restoreTerminalStderr(): void;
|
|
21
|
+
/** Whether fd 2 is currently redirected away from the terminal. */
|
|
22
|
+
export declare function isTerminalStderrSuppressed(): boolean;
|
|
@@ -6,7 +6,7 @@ import type { GridCoord, AsciiNode } from './types.js';
|
|
|
6
6
|
export declare function heuristic(a: GridCoord, b: GridCoord): number;
|
|
7
7
|
/**
|
|
8
8
|
* Find a path from `from` to `to` on the grid using A*.
|
|
9
|
-
* Returns the path as an array of GridCoords, or null if no path exists.
|
|
9
|
+
* Returns the path as an array of GridCoords, or null if no bounded path exists.
|
|
10
10
|
*/
|
|
11
11
|
export declare function getPath(grid: Map<string, AsciiNode>, from: GridCoord, to: GridCoord): GridCoord[] | null;
|
|
12
12
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-utils",
|
|
4
|
-
"version": "16.5.
|
|
4
|
+
"version": "16.5.2",
|
|
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": "16.5.
|
|
34
|
+
"@oh-my-pi/pi-natives": "16.5.2",
|
|
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
|
@@ -28,6 +28,20 @@ function startupMarker(text: string): void {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* A user-facing argument/flag validation failure. Thrown by {@link Command.parse}
|
|
33
|
+
* for missing/invalid positionals and flags. The top-level {@link run} handler
|
|
34
|
+
* prints its message plus the command usage line to stderr and exits 1, instead
|
|
35
|
+
* of letting it bubble to the process-level catch — which would dump a minified
|
|
36
|
+
* `dist/cli.js` code frame over a plain argument mistake (issue #5369).
|
|
37
|
+
*/
|
|
38
|
+
export class CliUsageError extends Error {
|
|
39
|
+
constructor(message: string) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "CliUsageError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
// ---------------------------------------------------------------------------
|
|
32
46
|
// Flag & Arg descriptors
|
|
33
47
|
// ---------------------------------------------------------------------------
|
|
@@ -190,12 +204,18 @@ export abstract class Command {
|
|
|
190
204
|
|
|
191
205
|
// strict=false when command declares args (positionals must pass through)
|
|
192
206
|
// or when the command itself opts out
|
|
193
|
-
const { values: rawValues, positionals } =
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
207
|
+
const { values: rawValues, positionals } = (() => {
|
|
208
|
+
try {
|
|
209
|
+
return nodeParseArgs({
|
|
210
|
+
args: this.argv,
|
|
211
|
+
options,
|
|
212
|
+
allowPositionals: true,
|
|
213
|
+
strict,
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
throw new CliUsageError(error instanceof Error ? error.message : String(error));
|
|
217
|
+
}
|
|
218
|
+
})();
|
|
199
219
|
|
|
200
220
|
// Convert raw values to proper types and validate
|
|
201
221
|
const flags: Record<string, unknown> = {};
|
|
@@ -207,7 +227,7 @@ export abstract class Command {
|
|
|
207
227
|
} else {
|
|
208
228
|
const n = Number.parseInt(raw as string, 10);
|
|
209
229
|
if (Number.isNaN(n)) {
|
|
210
|
-
throw new
|
|
230
|
+
throw new CliUsageError(`Expected integer for --${name}, got "${raw}"`);
|
|
211
231
|
}
|
|
212
232
|
flags[name] = n;
|
|
213
233
|
}
|
|
@@ -220,14 +240,16 @@ export abstract class Command {
|
|
|
220
240
|
// Validate options constraint
|
|
221
241
|
if (val !== undefined && desc.options && !Array.isArray(val)) {
|
|
222
242
|
if (!desc.options.includes(val as string)) {
|
|
223
|
-
throw new
|
|
243
|
+
throw new CliUsageError(
|
|
244
|
+
`Expected --${name} to be one of: ${[...desc.options].join(", ")}; got "${val}"`,
|
|
245
|
+
);
|
|
224
246
|
}
|
|
225
247
|
}
|
|
226
248
|
flags[name] = val;
|
|
227
249
|
}
|
|
228
250
|
// Validate required
|
|
229
251
|
if (desc.required && flags[name] === undefined) {
|
|
230
|
-
throw new
|
|
252
|
+
throw new CliUsageError(`Missing required flag: --${name}`);
|
|
231
253
|
}
|
|
232
254
|
}
|
|
233
255
|
|
|
@@ -246,13 +268,15 @@ export abstract class Command {
|
|
|
246
268
|
}
|
|
247
269
|
// Validate required
|
|
248
270
|
if (desc.required && args[argName] === undefined) {
|
|
249
|
-
throw new
|
|
271
|
+
throw new CliUsageError(`Missing required argument: ${argName}`);
|
|
250
272
|
}
|
|
251
273
|
// Validate options constraint
|
|
252
274
|
const argVal = args[argName];
|
|
253
275
|
if (argVal !== undefined && desc.options && typeof argVal === "string") {
|
|
254
276
|
if (!desc.options.includes(argVal)) {
|
|
255
|
-
throw new
|
|
277
|
+
throw new CliUsageError(
|
|
278
|
+
`Expected ${argName} to be one of: ${[...desc.options].join(", ")}; got "${argVal}"`,
|
|
279
|
+
);
|
|
256
280
|
}
|
|
257
281
|
}
|
|
258
282
|
}
|
|
@@ -294,15 +318,34 @@ export function renderRootHelp(config: CliConfig): void {
|
|
|
294
318
|
process.stdout.write(lines.join("\n"));
|
|
295
319
|
}
|
|
296
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Format a command's positional args for a USAGE line. Required args render
|
|
323
|
+
* bare (`MODELS`), optional args wrapped in brackets (`[MODELS]`), and
|
|
324
|
+
* `multiple` args get a trailing ellipsis (`MODELS...`) so a required
|
|
325
|
+
* variadic reads as `MODELS...`, not the misleading optional `[MODELS]`.
|
|
326
|
+
*/
|
|
327
|
+
function formatUsageArgs(Cmd: CommandCtor): string {
|
|
328
|
+
const entries = Object.entries(Cmd.args ?? {});
|
|
329
|
+
if (entries.length === 0) return "";
|
|
330
|
+
const parts = entries.map(([name, desc]) => {
|
|
331
|
+
const label = `${name.toUpperCase()}${desc.multiple ? "..." : ""}`;
|
|
332
|
+
return desc.required ? label : `[${label}]`;
|
|
333
|
+
});
|
|
334
|
+
return ` ${parts.join(" ")}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Build the single USAGE line for a command (without the leading label). */
|
|
338
|
+
export function commandUsageLine(bin: string, id: string, Cmd: CommandCtor): string {
|
|
339
|
+
const hasFlags = Object.keys(Cmd.flags ?? {}).length > 0;
|
|
340
|
+
return `$ ${bin} ${id}${formatUsageArgs(Cmd)}${hasFlags ? " [FLAGS]" : ""}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
297
343
|
/** Render help for a single command. */
|
|
298
344
|
export function renderCommandHelp(bin: string, id: string, Cmd: CommandCtor): void {
|
|
299
345
|
const lines: string[] = [];
|
|
300
346
|
if (Cmd.description) lines.push(`${Cmd.description}\n`);
|
|
301
347
|
lines.push("USAGE");
|
|
302
|
-
|
|
303
|
-
const argStr = argNames.length > 0 ? ` ${argNames.map(n => `[${n.toUpperCase()}]`).join(" ")}` : "";
|
|
304
|
-
const hasFlags = Object.keys(Cmd.flags ?? {}).length > 0;
|
|
305
|
-
lines.push(` $ ${bin} ${id}${argStr}${hasFlags ? " [FLAGS]" : ""}\n`);
|
|
348
|
+
lines.push(` ${commandUsageLine(bin, id, Cmd)}\n`);
|
|
306
349
|
renderCommandBody(lines, Cmd);
|
|
307
350
|
process.stdout.write(lines.join("\n"));
|
|
308
351
|
}
|
|
@@ -435,7 +478,22 @@ export async function run(opts: RunOptions): Promise<void> {
|
|
|
435
478
|
const Cmd = await loadEntry(entry);
|
|
436
479
|
const config: CliConfig = { bin, version, commands: new Map([[entry.name, Cmd]]) };
|
|
437
480
|
const instance = new Cmd(commandArgv, config);
|
|
438
|
-
|
|
481
|
+
try {
|
|
482
|
+
await instance.run();
|
|
483
|
+
} catch (error) {
|
|
484
|
+
// A usage mistake (missing/invalid arg or flag) is not a crash: print the
|
|
485
|
+
// message and the command's usage line, then exit 1. Letting it reach the
|
|
486
|
+
// process-level catch would dump a minified `dist/cli.js` code frame over a
|
|
487
|
+
// plain argument error (issue #5369).
|
|
488
|
+
if (error instanceof CliUsageError) {
|
|
489
|
+
process.stderr.write(`error: ${error.message}\n\n`);
|
|
490
|
+
process.stderr.write(`USAGE\n ${commandUsageLine(bin, entry.name, Cmd)}\n`);
|
|
491
|
+
process.stderr.write(`\nRun \`${bin} ${entry.name} --help\` for details.\n`);
|
|
492
|
+
process.exitCode = 1;
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
439
497
|
}
|
|
440
498
|
|
|
441
499
|
/** Load one command module, leaving streaming markers around the import. */
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ export { AbortError, ChildProcess, Exception, NonZeroExitError } from "./ptree";
|
|
|
26
26
|
export * from "./runtime-install";
|
|
27
27
|
export * from "./sanitize-text";
|
|
28
28
|
export * from "./snowflake";
|
|
29
|
+
export * from "./stderr-guard";
|
|
29
30
|
export * from "./stream";
|
|
30
31
|
export * from "./tab-spacing";
|
|
31
32
|
export * from "./temp";
|
package/src/postmortem.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import inspector from "node:inspector";
|
|
9
9
|
import { isMainThread } from "node:worker_threads";
|
|
10
10
|
import { logger } from ".";
|
|
11
|
+
import { restoreTerminalStderr } from "./stderr-guard";
|
|
11
12
|
|
|
12
13
|
// Cleanup reasons, in order of priority/meaning.
|
|
13
14
|
export enum Reason {
|
|
@@ -166,6 +167,11 @@ if (isMainThread) {
|
|
|
166
167
|
logger.warn("Ignoring expected cleanup exception", { err });
|
|
167
168
|
return;
|
|
168
169
|
}
|
|
170
|
+
// fd 2 may be redirected to the log while a TUI owns the terminal
|
|
171
|
+
// (stderr-guard); re-point it at the real terminal so the fatal
|
|
172
|
+
// report is visible. Terminal modes are restored moments later by
|
|
173
|
+
// the terminal-restore cleanup callback inside runCleanup().
|
|
174
|
+
restoreTerminalStderr();
|
|
169
175
|
process.stderr.write(formatFatalError("Uncaught Exception", err));
|
|
170
176
|
logger.error("Uncaught exception", { err });
|
|
171
177
|
await runCleanup(Reason.UNCAUGHT_EXCEPTION);
|
|
@@ -199,6 +205,8 @@ if (isMainThread) {
|
|
|
199
205
|
});
|
|
200
206
|
}
|
|
201
207
|
}
|
|
208
|
+
// See uncaughtException above: surface the report on the real stderr.
|
|
209
|
+
restoreTerminalStderr();
|
|
202
210
|
process.stderr.write(formatFatalError("Unhandled Rejection", err));
|
|
203
211
|
logger.error("Unhandled rejection", { err });
|
|
204
212
|
await runCleanup(Reason.UNHANDLED_REJECTION);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal stderr guard: keeps unmanaged fd-2 writes off the terminal while a
|
|
3
|
+
* TUI owns the viewport.
|
|
4
|
+
*
|
|
5
|
+
* On macOS, runtime diagnostics are written by the platform directly to file
|
|
6
|
+
* descriptor 2 at arbitrary times — e.g. libmalloc's "MallocStackLogging:
|
|
7
|
+
* can't turn off malloc stack logging because it was not enabled" when the OS
|
|
8
|
+
* broadcasts a memory-diagnostic event to long-lived processes. Those bytes
|
|
9
|
+
* bypass the renderer and paint straight into the viewport. Stripping the
|
|
10
|
+
* MallocStackLogging* env vars (cli.ts) only protects child processes; it
|
|
11
|
+
* cannot stop libmalloc inside THIS process from logging.
|
|
12
|
+
*
|
|
13
|
+
* Fix (mirrors openai/codex#24459): while the TUI owns the terminal, dup fd 2
|
|
14
|
+
* aside and dup2 a redirect target over it; restore the saved fd whenever
|
|
15
|
+
* terminal ownership is released (external editor, Ctrl+Z suspend, shutdown,
|
|
16
|
+
* crash restore). Unlike codex we redirect to the omp log file — not
|
|
17
|
+
* /dev/null — so the diagnostics stay greppable and Bun native-crash reports
|
|
18
|
+
* (which abort before any JS cleanup can restore fd 2) are preserved.
|
|
19
|
+
*
|
|
20
|
+
* Only dup/dup2 go through bun:ffi. fcntl is deliberately avoided: it is
|
|
21
|
+
* variadic, and the arm64-darwin ABI passes variadic arguments on the stack,
|
|
22
|
+
* so a fixed-arity FFI signature would read garbage for the third argument.
|
|
23
|
+
*/
|
|
24
|
+
import { dlopen, FFIType } from "bun:ffi";
|
|
25
|
+
import * as fs from "node:fs";
|
|
26
|
+
import * as path from "node:path";
|
|
27
|
+
import { getLogPath } from "./dirs";
|
|
28
|
+
|
|
29
|
+
const STDOUT_FILENO = 1;
|
|
30
|
+
const STDERR_FILENO = 2;
|
|
31
|
+
|
|
32
|
+
interface LibcFdOps {
|
|
33
|
+
dup(fd: number): number;
|
|
34
|
+
dup2(oldFd: number, newFd: number): number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let libcFdOpsCache: LibcFdOps | null | undefined;
|
|
38
|
+
|
|
39
|
+
function libcFdOps(): LibcFdOps | null {
|
|
40
|
+
if (libcFdOpsCache !== undefined) return libcFdOpsCache;
|
|
41
|
+
libcFdOpsCache = null;
|
|
42
|
+
if (process.platform === "win32") return null;
|
|
43
|
+
// Darwin: dyld resolves libSystem from the shared cache. Linux: glibc
|
|
44
|
+
// first, then the generic soname for musl-style layouts.
|
|
45
|
+
const candidates =
|
|
46
|
+
process.platform === "darwin" ? ["libSystem.B.dylib", "/usr/lib/libSystem.B.dylib"] : ["libc.so.6", "libc.so"];
|
|
47
|
+
for (const candidate of candidates) {
|
|
48
|
+
try {
|
|
49
|
+
const libc = dlopen(candidate, {
|
|
50
|
+
dup: { args: [FFIType.i32], returns: FFIType.i32 },
|
|
51
|
+
dup2: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
52
|
+
});
|
|
53
|
+
libcFdOpsCache = libc.symbols;
|
|
54
|
+
return libcFdOpsCache;
|
|
55
|
+
} catch {
|
|
56
|
+
// Try the next candidate; the guard stays inert if none load.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return libcFdOpsCache;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* True when fd 2 writes would land on the same terminal the TUI paints to:
|
|
64
|
+
* both stdout and stderr are ttys backed by the same device file. A stderr
|
|
65
|
+
* the user already redirected (`2>file`, `2>/dev/null`, a different tty) must
|
|
66
|
+
* keep flowing untouched.
|
|
67
|
+
*/
|
|
68
|
+
function stderrSharesStdoutTerminal(): boolean {
|
|
69
|
+
if (!process.stdout.isTTY || !process.stderr.isTTY) return false;
|
|
70
|
+
try {
|
|
71
|
+
const stdoutStat = fs.fstatSync(STDOUT_FILENO);
|
|
72
|
+
const stderrStat = fs.fstatSync(STDERR_FILENO);
|
|
73
|
+
return stdoutStat.dev === stderrStat.dev && stdoutStat.ino === stderrStat.ino;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Saved dup of the real stderr while suppression is active, else null. */
|
|
80
|
+
let savedStderrFd: number | null = null;
|
|
81
|
+
|
|
82
|
+
export interface SuppressTerminalStderrOptions {
|
|
83
|
+
/** Redirect target path; defaults to today's omp log file, then /dev/null. */
|
|
84
|
+
redirectPath?: string;
|
|
85
|
+
/** Bypass the macOS + same-terminal gate. Tests only. */
|
|
86
|
+
force?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Redirect fd 2 away from the terminal while the TUI owns the viewport.
|
|
91
|
+
* Returns true when suppression is (already) active. No-op — returning
|
|
92
|
+
* false — off macOS, when stderr does not target the stdout terminal, or
|
|
93
|
+
* when the libc fd ops are unavailable.
|
|
94
|
+
*/
|
|
95
|
+
export function suppressTerminalStderr(options?: SuppressTerminalStderrOptions): boolean {
|
|
96
|
+
if (savedStderrFd !== null) return true;
|
|
97
|
+
if (!options?.force && (process.platform !== "darwin" || !stderrSharesStdoutTerminal())) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const libc = libcFdOps();
|
|
101
|
+
if (!libc) return false;
|
|
102
|
+
|
|
103
|
+
let redirectFd: number;
|
|
104
|
+
try {
|
|
105
|
+
const redirectPath = options?.redirectPath ?? getLogPath();
|
|
106
|
+
// getLogsDir() only computes the path; the logger creates it lazily, so
|
|
107
|
+
// on a fresh profile ~/.omp/logs may not exist yet. Create it here so
|
|
108
|
+
// diagnostics land in the log instead of falling through to /dev/null.
|
|
109
|
+
fs.mkdirSync(path.dirname(redirectPath), { recursive: true });
|
|
110
|
+
redirectFd = fs.openSync(redirectPath, "a");
|
|
111
|
+
} catch {
|
|
112
|
+
try {
|
|
113
|
+
redirectFd = fs.openSync("/dev/null", "w");
|
|
114
|
+
} catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const saved = libc.dup(STDERR_FILENO);
|
|
120
|
+
if (saved === -1) {
|
|
121
|
+
fs.closeSync(redirectFd);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
if (libc.dup2(redirectFd, STDERR_FILENO) === -1) {
|
|
125
|
+
fs.closeSync(redirectFd);
|
|
126
|
+
fs.closeSync(saved);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
fs.closeSync(redirectFd);
|
|
130
|
+
savedStderrFd = saved;
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Re-point fd 2 at the saved terminal stderr. Safe to call unconditionally:
|
|
136
|
+
* no-op when suppression is not active. Called at every terminal-ownership
|
|
137
|
+
* release and by the postmortem fatal handlers before they print, so crash
|
|
138
|
+
* reports reach the real terminal.
|
|
139
|
+
*/
|
|
140
|
+
export function restoreTerminalStderr(): void {
|
|
141
|
+
if (savedStderrFd === null) return;
|
|
142
|
+
const saved = savedStderrFd;
|
|
143
|
+
savedStderrFd = null;
|
|
144
|
+
libcFdOps()?.dup2(saved, STDERR_FILENO);
|
|
145
|
+
try {
|
|
146
|
+
fs.closeSync(saved);
|
|
147
|
+
} catch {
|
|
148
|
+
// The dup'ed fd is process-owned; a close failure leaves nothing to recover.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Whether fd 2 is currently redirected away from the terminal. */
|
|
153
|
+
export function isTerminalStderrSuppressed(): boolean {
|
|
154
|
+
return savedStderrFd !== null;
|
|
155
|
+
}
|
|
@@ -108,21 +108,75 @@ const MOVE_DIRS: GridCoord[] = [
|
|
|
108
108
|
{ x: 0, y: -1 },
|
|
109
109
|
]
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
interface SearchBounds {
|
|
112
|
+
minX: number
|
|
113
|
+
maxX: number
|
|
114
|
+
minY: number
|
|
115
|
+
maxY: number
|
|
116
|
+
expansionLimit: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const MIN_ROUTING_MARGIN = 8
|
|
120
|
+
const MIN_EXPANSION_BUDGET = 256
|
|
121
|
+
const MAX_EXPANSION_BUDGET = 50_000
|
|
122
|
+
|
|
123
|
+
function searchBoundsFor(grid: Map<string, AsciiNode>, from: GridCoord, to: GridCoord): SearchBounds {
|
|
124
|
+
let minX = Math.min(from.x, to.x)
|
|
125
|
+
let maxX = Math.max(from.x, to.x)
|
|
126
|
+
let minY = Math.min(from.y, to.y)
|
|
127
|
+
let maxY = Math.max(from.y, to.y)
|
|
128
|
+
|
|
129
|
+
for (const key of grid.keys()) {
|
|
130
|
+
const comma = key.indexOf(',')
|
|
131
|
+
if (comma === -1) continue
|
|
132
|
+
|
|
133
|
+
const x = Number(key.slice(0, comma))
|
|
134
|
+
const y = Number(key.slice(comma + 1))
|
|
135
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) continue
|
|
136
|
+
|
|
137
|
+
minX = Math.min(minX, x)
|
|
138
|
+
maxX = Math.max(maxX, x)
|
|
139
|
+
minY = Math.min(minY, y)
|
|
140
|
+
maxY = Math.max(maxY, y)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const width = maxX - minX + 1
|
|
144
|
+
const height = maxY - minY + 1
|
|
145
|
+
const margin = Math.max(MIN_ROUTING_MARGIN, Math.ceil(Math.max(width, height) / 2))
|
|
146
|
+
const boundedMinX = Math.max(0, minX - margin)
|
|
147
|
+
const boundedMaxX = maxX + margin
|
|
148
|
+
const boundedMinY = Math.max(0, minY - margin)
|
|
149
|
+
const boundedMaxY = maxY + margin
|
|
150
|
+
const area = (boundedMaxX - boundedMinX + 1) * (boundedMaxY - boundedMinY + 1)
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
minX: boundedMinX,
|
|
154
|
+
maxX: boundedMaxX,
|
|
155
|
+
minY: boundedMinY,
|
|
156
|
+
maxY: boundedMaxY,
|
|
157
|
+
expansionLimit: Math.min(MAX_EXPANSION_BUDGET, Math.max(MIN_EXPANSION_BUDGET, area * 4)),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Check if a grid cell is unoccupied and inside the bounded routing area. */
|
|
162
|
+
function isFreeInGrid(grid: Map<string, AsciiNode>, c: GridCoord, bounds: SearchBounds): boolean {
|
|
163
|
+
if (c.x < bounds.minX || c.x > bounds.maxX || c.y < bounds.minY || c.y > bounds.maxY) {
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
|
|
114
167
|
return !grid.has(gridKey(c))
|
|
115
168
|
}
|
|
116
169
|
|
|
117
170
|
/**
|
|
118
171
|
* Find a path from `from` to `to` on the grid using A*.
|
|
119
|
-
* Returns the path as an array of GridCoords, or null if no path exists.
|
|
172
|
+
* Returns the path as an array of GridCoords, or null if no bounded path exists.
|
|
120
173
|
*/
|
|
121
174
|
export function getPath(
|
|
122
175
|
grid: Map<string, AsciiNode>,
|
|
123
176
|
from: GridCoord,
|
|
124
177
|
to: GridCoord,
|
|
125
178
|
): GridCoord[] | null {
|
|
179
|
+
const bounds = searchBoundsFor(grid, from, to)
|
|
126
180
|
const pq = new MinHeap()
|
|
127
181
|
pq.push({ coord: from, priority: 0 })
|
|
128
182
|
|
|
@@ -132,7 +186,13 @@ export function getPath(
|
|
|
132
186
|
const cameFrom = new Map<string, GridCoord | null>()
|
|
133
187
|
cameFrom.set(gridKey(from), null)
|
|
134
188
|
|
|
189
|
+
let expansions = 0
|
|
190
|
+
|
|
135
191
|
while (pq.length > 0) {
|
|
192
|
+
if (expansions++ >= bounds.expansionLimit) {
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
|
|
136
196
|
const current = pq.pop()!.coord
|
|
137
197
|
|
|
138
198
|
if (gridCoordEquals(current, to)) {
|
|
@@ -150,9 +210,11 @@ export function getPath(
|
|
|
150
210
|
|
|
151
211
|
for (const dir of MOVE_DIRS) {
|
|
152
212
|
const next: GridCoord = { x: current.x + dir.x, y: current.y + dir.y }
|
|
213
|
+
const insideBounds = next.x >= bounds.minX && next.x <= bounds.maxX
|
|
214
|
+
&& next.y >= bounds.minY && next.y <= bounds.maxY
|
|
153
215
|
|
|
154
216
|
// Allow moving to the destination even if it's occupied (it's a node boundary)
|
|
155
|
-
if (!isFreeInGrid(grid, next) && !gridCoordEquals(next, to)) {
|
|
217
|
+
if (!insideBounds || (!isFreeInGrid(grid, next, bounds) && !gridCoordEquals(next, to))) {
|
|
156
218
|
continue
|
|
157
219
|
}
|
|
158
220
|
|