@oh-my-pi/pi-utils 18.4.0 → 18.4.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 +13 -0
- package/dist/types/dirs.d.ts +2 -2
- package/dist/types/index.d.ts +12 -0
- package/dist/types/logger/rotating-file.d.ts +9 -0
- package/dist/types/stderr-guard.d.ts +23 -4
- package/package.json +2 -2
- package/src/dirs.ts +3 -3
- package/src/index.ts +39 -0
- package/src/logger/rotating-file.ts +16 -0
- package/src/logger.ts +6 -7
- package/src/sqlite.ts +29 -2
- package/src/stderr-guard.ts +53 -21
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.4.2] - 2026-09-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added cloneJsonTree to provide a high-performance deep copy utility for JSON-shaped object trees
|
|
10
|
+
|
|
11
|
+
## [18.4.1] - 2026-09-28
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Fixed SQLite stores opened with `recoverCorruption` crashing on a corrupt file instead of being preserved and recreated when the corruption surfaced as a different initialization error such as `no such table` ([#13530](https://github.com/can1357/oh-my-pi/pull/13530) by [@Hunter-124](https://github.com/Hunter-124))
|
|
16
|
+
- Fixed raw stderr output staying on the previous day's log file after the log sink rotates at local midnight ([#13003](https://github.com/can1357/oh-my-pi/issues/13003)).
|
|
17
|
+
|
|
5
18
|
## [18.3.1] - 2026-09-25
|
|
6
19
|
|
|
7
20
|
### Fixed
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -275,8 +275,8 @@ export declare function getModelDbPath(agentDir?: string): string;
|
|
|
275
275
|
export declare function getTinyModelsCacheDir(agentDir?: string): string;
|
|
276
276
|
/** Get the document conversion cache directory (~/.omp/agent/cache/document-conversions; XDG default: $XDG_CACHE_HOME/omp/cache/document-conversions). */
|
|
277
277
|
export declare function getDocumentConversionCacheDir(agentDir?: string): string;
|
|
278
|
-
/** Get the
|
|
279
|
-
export declare function
|
|
278
|
+
/** Get the composer speculative cache database (~/.omp/agent/cache/composer.db; XDG default: $XDG_CACHE_HOME/omp/cache/composer.db). */
|
|
279
|
+
export declare function getComposerCacheDbPath(agentDir?: string): string;
|
|
280
280
|
/** Get the sessions directory (~/.omp/agent/sessions). */
|
|
281
281
|
export declare function getSessionsDir(agentDir?: string): string;
|
|
282
282
|
/** Get the content-addressed blob store directory (~/.omp/agent/blobs). */
|
package/dist/types/index.d.ts
CHANGED
|
@@ -43,3 +43,15 @@ export * from "./version.js";
|
|
|
43
43
|
export * from "./which.js";
|
|
44
44
|
export * from "./yaml-config.js";
|
|
45
45
|
export declare function structuredCloneJSON<T>(value: T): T;
|
|
46
|
+
/**
|
|
47
|
+
* Deep-copies a JSON-shaped tree (arrays and plain or null-prototype objects)
|
|
48
|
+
* so later mutation of the source can never reach the copy. Strings and other
|
|
49
|
+
* primitives are immutable and shared, so the cost is O(containers) instead of
|
|
50
|
+
* `structuredClone`'s O(bytes) — the difference matters for hot paths that
|
|
51
|
+
* re-copy large string payloads (e.g. streamed tool-call arguments) per delta.
|
|
52
|
+
*
|
|
53
|
+
* Copies own enumerable string keys only. Any other object (class instance,
|
|
54
|
+
* Date, Map, inherited-prototype object) is copied with {@link structuredCloneJSON}.
|
|
55
|
+
* Not cycle-safe: callers MUST pass acyclic data, as any JSON-serializable value is.
|
|
56
|
+
*/
|
|
57
|
+
export declare function cloneJsonTree<T>(value: T): T;
|
|
@@ -6,6 +6,15 @@ export interface RotatingFileOptions {
|
|
|
6
6
|
readonly auditFile: string;
|
|
7
7
|
readonly maxBytes: number;
|
|
8
8
|
readonly maxFiles: number;
|
|
9
|
+
/**
|
|
10
|
+
* Called with the new active file path whenever the sink opens a different
|
|
11
|
+
* file — construction, local-day rotation, and size rotation alike.
|
|
12
|
+
*
|
|
13
|
+
* Consumers that hold their own descriptor on the active log (the macOS
|
|
14
|
+
* stderr guard dup2s it onto fd 2) use this to follow the sink instead of
|
|
15
|
+
* staying pinned to a file the sink later prunes.
|
|
16
|
+
*/
|
|
17
|
+
readonly onRotate?: (filePath: string) => void;
|
|
9
18
|
}
|
|
10
19
|
/** Synchronous append sink with local-day and size rotation plus bounded retention. */
|
|
11
20
|
export declare class RotatingFileSink {
|
|
@@ -1,16 +1,35 @@
|
|
|
1
1
|
export interface SuppressTerminalStderrOptions {
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Redirect target path; defaults to the log file the rotating sink last
|
|
4
|
+
* reported (see {@link setStderrRedirectTarget}), then today's omp log
|
|
5
|
+
* file, then /dev/null.
|
|
6
|
+
*/
|
|
3
7
|
redirectPath?: string;
|
|
4
|
-
/** Bypass the
|
|
8
|
+
/** Bypass the same-terminal gate. Tests only. */
|
|
5
9
|
force?: boolean;
|
|
6
10
|
}
|
|
7
11
|
/**
|
|
8
12
|
* Redirect fd 2 away from the terminal while the TUI owns the viewport.
|
|
9
13
|
* Returns true when suppression is (already) active. No-op — returning
|
|
10
|
-
* false —
|
|
11
|
-
*
|
|
14
|
+
* false — when stderr does not target the stdout terminal or when libc fd
|
|
15
|
+
* operations are unavailable.
|
|
12
16
|
*/
|
|
13
17
|
export declare function suppressTerminalStderr(options?: SuppressTerminalStderrOptions): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Point fd 2 at `logPath`, the log file the rotating sink is currently
|
|
20
|
+
* writing.
|
|
21
|
+
*
|
|
22
|
+
* The logger calls this on every sink rotation. Without it fd 2 keeps the
|
|
23
|
+
* descriptor opened at TUI start, so after local midnight raw stderr — this
|
|
24
|
+
* process's and every child that inherited fd 2 — keeps landing in the
|
|
25
|
+
* previous day's file, which the sink eventually prunes out from under it.
|
|
26
|
+
*
|
|
27
|
+
* The path is recorded even while suppression is inactive, so a later
|
|
28
|
+
* {@link suppressTerminalStderr} redirects to the live file instead of
|
|
29
|
+
* re-deriving a dated name. Best-effort: if the new file cannot be opened,
|
|
30
|
+
* fd 2 keeps its current target.
|
|
31
|
+
*/
|
|
32
|
+
export declare function setStderrRedirectTarget(logPath: string): void;
|
|
14
33
|
/**
|
|
15
34
|
* Re-point fd 2 at the saved terminal stderr. Safe to call unconditionally:
|
|
16
35
|
* no-op when suppression is not active. Called at every terminal-ownership
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-utils",
|
|
3
|
-
"version": "18.4.
|
|
3
|
+
"version": "18.4.2",
|
|
4
4
|
"description": "Shared utilities for pi packages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@oh-my-pi/pi-natives": "18.4.
|
|
60
|
+
"@oh-my-pi/pi-natives": "18.4.2"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@types/bun": "^1.3.14"
|
package/src/dirs.ts
CHANGED
|
@@ -911,9 +911,9 @@ export function getTinyModelsCacheDir(agentDir?: string): string {
|
|
|
911
911
|
export function getDocumentConversionCacheDir(agentDir?: string): string {
|
|
912
912
|
return dirs.agentSubdir(agentDir, path.join("cache", "document-conversions"), "cache");
|
|
913
913
|
}
|
|
914
|
-
/** Get the
|
|
915
|
-
export function
|
|
916
|
-
return dirs.agentSubdir(agentDir, path.join("cache", "composer"), "cache");
|
|
914
|
+
/** Get the composer speculative cache database (~/.omp/agent/cache/composer.db; XDG default: $XDG_CACHE_HOME/omp/cache/composer.db). */
|
|
915
|
+
export function getComposerCacheDbPath(agentDir?: string): string {
|
|
916
|
+
return dirs.agentSubdir(agentDir, path.join("cache", "composer.db"), "cache");
|
|
917
917
|
}
|
|
918
918
|
|
|
919
919
|
/** Get the sessions directory (~/.omp/agent/sessions). */
|
package/src/index.ts
CHANGED
|
@@ -63,3 +63,42 @@ export function structuredCloneJSON<T>(value: T): T {
|
|
|
63
63
|
}
|
|
64
64
|
return JSON.parse(JSON.stringify(value)) as T;
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Deep-copies a JSON-shaped tree (arrays and plain or null-prototype objects)
|
|
69
|
+
* so later mutation of the source can never reach the copy. Strings and other
|
|
70
|
+
* primitives are immutable and shared, so the cost is O(containers) instead of
|
|
71
|
+
* `structuredClone`'s O(bytes) — the difference matters for hot paths that
|
|
72
|
+
* re-copy large string payloads (e.g. streamed tool-call arguments) per delta.
|
|
73
|
+
*
|
|
74
|
+
* Copies own enumerable string keys only. Any other object (class instance,
|
|
75
|
+
* Date, Map, inherited-prototype object) is copied with {@link structuredCloneJSON}.
|
|
76
|
+
* Not cycle-safe: callers MUST pass acyclic data, as any JSON-serializable value is.
|
|
77
|
+
*/
|
|
78
|
+
export function cloneJsonTree<T>(value: T): T {
|
|
79
|
+
return cloneJsonNode(value) as T;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isJsonRecord(value: object): value is Record<string, unknown> {
|
|
83
|
+
const proto = Object.getPrototypeOf(value);
|
|
84
|
+
return proto === Object.prototype || proto === null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cloneJsonNode(value: unknown): unknown {
|
|
88
|
+
if (value === null || typeof value !== "object") return value;
|
|
89
|
+
if (Array.isArray(value)) return value.map(cloneJsonNode);
|
|
90
|
+
if (!isJsonRecord(value)) return structuredCloneJSON(value);
|
|
91
|
+
const out: Record<string, unknown> = {};
|
|
92
|
+
for (const key in value) {
|
|
93
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
94
|
+
const child = cloneJsonNode(value[key]);
|
|
95
|
+
// Assigning `__proto__` would swap the copy's prototype instead of
|
|
96
|
+
// creating the own data property JSON.parse produces.
|
|
97
|
+
if (key === "__proto__") {
|
|
98
|
+
Object.defineProperty(out, key, { value: child, enumerable: true, writable: true, configurable: true });
|
|
99
|
+
} else {
|
|
100
|
+
out[key] = child;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
@@ -26,6 +26,15 @@ export interface RotatingFileOptions {
|
|
|
26
26
|
readonly auditFile: string;
|
|
27
27
|
readonly maxBytes: number;
|
|
28
28
|
readonly maxFiles: number;
|
|
29
|
+
/**
|
|
30
|
+
* Called with the new active file path whenever the sink opens a different
|
|
31
|
+
* file — construction, local-day rotation, and size rotation alike.
|
|
32
|
+
*
|
|
33
|
+
* Consumers that hold their own descriptor on the active log (the macOS
|
|
34
|
+
* stderr guard dup2s it onto fd 2) use this to follow the sink instead of
|
|
35
|
+
* staying pinned to a file the sink later prunes.
|
|
36
|
+
*/
|
|
37
|
+
readonly onRotate?: (filePath: string) => void;
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
function isAuditEntry(value: unknown): value is AuditEntry {
|
|
@@ -42,6 +51,7 @@ export class RotatingFileSink {
|
|
|
42
51
|
readonly #auditFile: string;
|
|
43
52
|
readonly #maxBytes: number;
|
|
44
53
|
readonly #maxFiles: number;
|
|
54
|
+
readonly #onRotate: ((filePath: string) => void) | undefined;
|
|
45
55
|
#files: AuditEntry[];
|
|
46
56
|
#activeDay: string | undefined;
|
|
47
57
|
#activeIndex = 0;
|
|
@@ -62,6 +72,7 @@ export class RotatingFileSink {
|
|
|
62
72
|
this.#auditFile = options.auditFile;
|
|
63
73
|
this.#maxBytes = options.maxBytes;
|
|
64
74
|
this.#maxFiles = options.maxFiles;
|
|
75
|
+
this.#onRotate = options.onRotate;
|
|
65
76
|
this.#files = this.#readAudit();
|
|
66
77
|
const now = new Date();
|
|
67
78
|
this.#selectFile(localDay(now));
|
|
@@ -75,6 +86,11 @@ export class RotatingFileSink {
|
|
|
75
86
|
#openFd(filePath: string): void {
|
|
76
87
|
this.#closeFd();
|
|
77
88
|
this.#fd = openCloexecSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND);
|
|
89
|
+
try {
|
|
90
|
+
this.#onRotate?.(filePath);
|
|
91
|
+
} catch {
|
|
92
|
+
// A rotation observer must never break logging.
|
|
93
|
+
}
|
|
78
94
|
}
|
|
79
95
|
|
|
80
96
|
#closeFd(): void {
|
package/src/logger.ts
CHANGED
|
@@ -14,8 +14,9 @@ import * as fs from "node:fs";
|
|
|
14
14
|
import * as os from "node:os";
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import { isPromise } from "node:util/types";
|
|
17
|
-
import { getLogsDir } from "./dirs";
|
|
17
|
+
import { getLogsDir, localDay } from "./dirs";
|
|
18
18
|
import { RotatingFileSink } from "./logger/rotating-file";
|
|
19
|
+
import { setStderrRedirectTarget } from "./stderr-guard";
|
|
19
20
|
import { drainModuleLoadEvents } from "./timing-buffer";
|
|
20
21
|
/** Severity names accepted by the centralized logger. */
|
|
21
22
|
export type LogLevel = "error" | "warn" | "info" | "debug";
|
|
@@ -82,14 +83,10 @@ function pruneStaleProcessLogs(dir: string): void {
|
|
|
82
83
|
return;
|
|
83
84
|
}
|
|
84
85
|
const current = new Date();
|
|
85
|
-
const currentDate =
|
|
86
|
-
`${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, "0")}-` +
|
|
87
|
-
String(current.getDate()).padStart(2, "0");
|
|
86
|
+
const currentDate = localDay(current);
|
|
88
87
|
const cutoff = new Date(current);
|
|
89
88
|
cutoff.setDate(cutoff.getDate() - (RETAINED_STALE_LOG_DAYS - 1));
|
|
90
|
-
const cutoffDate =
|
|
91
|
-
`${cutoff.getFullYear()}-${String(cutoff.getMonth() + 1).padStart(2, "0")}-` +
|
|
92
|
-
String(cutoff.getDate()).padStart(2, "0");
|
|
89
|
+
const cutoffDate = localDay(cutoff);
|
|
93
90
|
|
|
94
91
|
const staleLogsByProcessDay = new Map<string, Array<{ path: string; rollover: number }>>();
|
|
95
92
|
for (const entry of entries) {
|
|
@@ -258,6 +255,8 @@ function makeFileTransport(dir?: string): RotatingFileSink {
|
|
|
258
255
|
maxBytes: 10 * 1024 * 1024,
|
|
259
256
|
maxFiles: 5,
|
|
260
257
|
auditFile: path.join(logsDir, `.omp.${process.pid}-audit.json`),
|
|
258
|
+
// Keep the stderr guard's fd 2 on the file this sink is writing.
|
|
259
|
+
onRotate: setStderrRedirectTarget,
|
|
261
260
|
});
|
|
262
261
|
}
|
|
263
262
|
|
package/src/sqlite.ts
CHANGED
|
@@ -58,6 +58,31 @@ export interface SqliteOpenOptions {
|
|
|
58
58
|
onCorruptionPreserved?: (backupPath: string, error: unknown) => void;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Bun's multi-statement `db.run()` reports only the final statement's step
|
|
63
|
+
* error (oven-sh/bun#37415), so a corrupt page hit mid-script can resurface as
|
|
64
|
+
* an unrelated failure such as "no such table". When an initializer fails for
|
|
65
|
+
* any other reason, a `quick_check` on the still-open handle decides whether
|
|
66
|
+
* the store itself is damaged. Runs only on the failure path.
|
|
67
|
+
*/
|
|
68
|
+
function revealHiddenCorruption(db: Database | undefined, error: unknown): unknown {
|
|
69
|
+
if (!db || isSqliteCorruptionError(error) || isSqliteBusyError(error)) return error;
|
|
70
|
+
let detail: string;
|
|
71
|
+
let code: unknown = "SQLITE_CORRUPT";
|
|
72
|
+
let errno: unknown = 11;
|
|
73
|
+
try {
|
|
74
|
+
const rows = db.query<{ quick_check: string }, []>("PRAGMA quick_check(1)").all();
|
|
75
|
+
if (rows[0]?.quick_check === "ok") return error;
|
|
76
|
+
detail = `database disk image is malformed (${rows[0]?.quick_check})`;
|
|
77
|
+
} catch (probeError) {
|
|
78
|
+
if (!isSqliteCorruptionError(probeError)) return error;
|
|
79
|
+
detail = probeError instanceof Error ? probeError.message : String(probeError);
|
|
80
|
+
({ code, errno } = probeError as { code: unknown; errno?: unknown });
|
|
81
|
+
}
|
|
82
|
+
const original = error instanceof Error ? error.message : String(error);
|
|
83
|
+
return Object.assign(new Error(`${detail}; initialization failed: ${original}`, { cause: error }), { code, errno });
|
|
84
|
+
}
|
|
85
|
+
|
|
61
86
|
async function openWithBusyRetries<T>(
|
|
62
87
|
dbPath: string,
|
|
63
88
|
initialize: (db: Database) => T | Promise<T>,
|
|
@@ -71,7 +96,8 @@ async function openWithBusyRetries<T>(
|
|
|
71
96
|
// WAL recovery can bypass the busy handler; both it and retries are needed (#2421).
|
|
72
97
|
db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
|
|
73
98
|
return await initialize(db);
|
|
74
|
-
} catch (
|
|
99
|
+
} catch (caught) {
|
|
100
|
+
const error = options.recoverCorruption ? revealHiddenCorruption(db, caught) : caught;
|
|
75
101
|
if (options.recoverCorruption && isSqliteCorruptionError(error)) {
|
|
76
102
|
throw new SqliteAttemptFailure(error, identity, { db });
|
|
77
103
|
}
|
|
@@ -91,7 +117,8 @@ function openOnce<T>(dbPath: string, initialize: (db: Database) => T, options: S
|
|
|
91
117
|
db = new Database(dbPath);
|
|
92
118
|
db.run(`PRAGMA busy_timeout = ${getDbBusyTimeoutMs()}`);
|
|
93
119
|
return initialize(db);
|
|
94
|
-
} catch (
|
|
120
|
+
} catch (caught) {
|
|
121
|
+
const error = options.recoverCorruption ? revealHiddenCorruption(db, caught) : caught;
|
|
95
122
|
if (options.recoverCorruption && isSqliteCorruptionError(error)) {
|
|
96
123
|
throw new SqliteAttemptFailure(error, identity, { db });
|
|
97
124
|
}
|
package/src/stderr-guard.ts
CHANGED
|
@@ -2,20 +2,17 @@
|
|
|
2
2
|
* Terminal stderr guard: keeps unmanaged fd-2 writes off the terminal while a
|
|
3
3
|
* TUI owns the viewport.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
5
|
+
* Native runtimes and libraries can write directly to file descriptor 2 at
|
|
6
|
+
* arbitrary times. Examples include macOS libmalloc diagnostics and Bun HTTP
|
|
7
|
+
* decompression diagnostics on Linux. Those bytes bypass the renderer and
|
|
8
|
+
* paint straight into the viewport.
|
|
12
9
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
10
|
+
* While the TUI owns the terminal, dup fd 2 aside and dup2 a redirect target
|
|
11
|
+
* over it; restore the saved fd whenever terminal ownership is released
|
|
12
|
+
* (external editor, Ctrl+Z suspend, shutdown, crash restore). This mirrors
|
|
13
|
+
* openai/codex#24459, but redirects to the omp log file instead of /dev/null
|
|
14
|
+
* so diagnostics stay greppable and Bun native-crash reports (which abort
|
|
15
|
+
* before any JS cleanup can restore fd 2) are preserved.
|
|
19
16
|
*
|
|
20
17
|
* Only dup/dup2 go through bun:ffi. fcntl is deliberately avoided: it is
|
|
21
18
|
* variadic, and the arm64-darwin ABI passes variadic arguments on the stack,
|
|
@@ -79,35 +76,41 @@ function stderrSharesStdoutTerminal(): boolean {
|
|
|
79
76
|
/** Saved dup of the real stderr while suppression is active, else null. */
|
|
80
77
|
let savedStderrFd: number | null = null;
|
|
81
78
|
|
|
79
|
+
/** Newest log file the guard knows about; see {@link setStderrRedirectTarget}. */
|
|
80
|
+
let redirectTarget: string | null = null;
|
|
81
|
+
|
|
82
82
|
export interface SuppressTerminalStderrOptions {
|
|
83
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Redirect target path; defaults to the log file the rotating sink last
|
|
85
|
+
* reported (see {@link setStderrRedirectTarget}), then today's omp log
|
|
86
|
+
* file, then /dev/null.
|
|
87
|
+
*/
|
|
84
88
|
redirectPath?: string;
|
|
85
|
-
/** Bypass the
|
|
89
|
+
/** Bypass the same-terminal gate. Tests only. */
|
|
86
90
|
force?: boolean;
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
/**
|
|
90
94
|
* Redirect fd 2 away from the terminal while the TUI owns the viewport.
|
|
91
95
|
* Returns true when suppression is (already) active. No-op — returning
|
|
92
|
-
* false —
|
|
93
|
-
*
|
|
96
|
+
* false — when stderr does not target the stdout terminal or when libc fd
|
|
97
|
+
* operations are unavailable.
|
|
94
98
|
*/
|
|
95
99
|
export function suppressTerminalStderr(options?: SuppressTerminalStderrOptions): boolean {
|
|
96
100
|
if (savedStderrFd !== null) return true;
|
|
97
|
-
if (!options?.force &&
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
101
|
+
if (!options?.force && !stderrSharesStdoutTerminal()) return false;
|
|
100
102
|
const libc = libcFdOps();
|
|
101
103
|
if (!libc) return false;
|
|
102
104
|
|
|
103
105
|
let redirectFd: number;
|
|
104
106
|
try {
|
|
105
|
-
const redirectPath = options?.redirectPath ?? getLogPath();
|
|
107
|
+
const redirectPath = options?.redirectPath ?? redirectTarget ?? getLogPath();
|
|
106
108
|
// getLogsDir() only computes the path; the logger creates it lazily, so
|
|
107
109
|
// on a fresh profile ~/.omp/logs may not exist yet. Create it here so
|
|
108
110
|
// diagnostics land in the log instead of falling through to /dev/null.
|
|
109
111
|
fs.mkdirSync(path.dirname(redirectPath), { recursive: true });
|
|
110
112
|
redirectFd = fs.openSync(redirectPath, "a");
|
|
113
|
+
redirectTarget = redirectPath;
|
|
111
114
|
} catch {
|
|
112
115
|
try {
|
|
113
116
|
redirectFd = fs.openSync("/dev/null", "w");
|
|
@@ -131,6 +134,35 @@ export function suppressTerminalStderr(options?: SuppressTerminalStderrOptions):
|
|
|
131
134
|
return true;
|
|
132
135
|
}
|
|
133
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Point fd 2 at `logPath`, the log file the rotating sink is currently
|
|
139
|
+
* writing.
|
|
140
|
+
*
|
|
141
|
+
* The logger calls this on every sink rotation. Without it fd 2 keeps the
|
|
142
|
+
* descriptor opened at TUI start, so after local midnight raw stderr — this
|
|
143
|
+
* process's and every child that inherited fd 2 — keeps landing in the
|
|
144
|
+
* previous day's file, which the sink eventually prunes out from under it.
|
|
145
|
+
*
|
|
146
|
+
* The path is recorded even while suppression is inactive, so a later
|
|
147
|
+
* {@link suppressTerminalStderr} redirects to the live file instead of
|
|
148
|
+
* re-deriving a dated name. Best-effort: if the new file cannot be opened,
|
|
149
|
+
* fd 2 keeps its current target.
|
|
150
|
+
*/
|
|
151
|
+
export function setStderrRedirectTarget(logPath: string): void {
|
|
152
|
+
redirectTarget = logPath;
|
|
153
|
+
if (savedStderrFd === null) return;
|
|
154
|
+
const libc = libcFdOps();
|
|
155
|
+
if (!libc) return;
|
|
156
|
+
let redirectFd: number;
|
|
157
|
+
try {
|
|
158
|
+
redirectFd = fs.openSync(logPath, "a");
|
|
159
|
+
} catch {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
libc.dup2(redirectFd, STDERR_FILENO);
|
|
163
|
+
fs.closeSync(redirectFd);
|
|
164
|
+
}
|
|
165
|
+
|
|
134
166
|
/**
|
|
135
167
|
* Re-point fd 2 at the saved terminal stderr. Safe to call unconditionally:
|
|
136
168
|
* no-op when suppression is not active. Called at every terminal-ownership
|