@oh-my-pi/pi-utils 18.3.5 → 18.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.4.1] - 2026-09-28
6
+
7
+ ### Fixed
8
+
9
+ - 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))
10
+ - 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)).
11
+
5
12
  ## [18.3.1] - 2026-09-25
6
13
 
7
14
  ### Fixed
@@ -228,6 +228,11 @@ export declare function getGithubCacheDbPath(): string;
228
228
  * Honors `OMP_COMMIT_CACHE_DB` so tests and operators can isolate the cache.
229
229
  */
230
230
  export declare function getCommitCacheDbPath(): string;
231
+ /**
232
+ * Get the judgment answer cache database path (~/.omp/cache/judgment-cache.db).
233
+ * Honors `OMP_JUDGMENT_CACHE_DB` so tests and operators can isolate the cache.
234
+ */
235
+ export declare function getJudgmentCacheDbPath(): string;
231
236
  /** Get the legacy Pi extension parse cache database path. */
232
237
  export declare function getLegacyPiExtensionCacheDbPath(): string;
233
238
  /**
@@ -270,8 +275,8 @@ export declare function getModelDbPath(agentDir?: string): string;
270
275
  export declare function getTinyModelsCacheDir(agentDir?: string): string;
271
276
  /** Get the document conversion cache directory (~/.omp/agent/cache/document-conversions; XDG default: $XDG_CACHE_HOME/omp/cache/document-conversions). */
272
277
  export declare function getDocumentConversionCacheDir(agentDir?: string): string;
273
- /** Get the per-project composer speculative cache directory (~/.omp/agent/cache/composer; XDG default: $XDG_CACHE_HOME/omp/cache/composer). */
274
- export declare function getComposerCacheDir(agentDir?: string): string;
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;
275
280
  /** Get the sessions directory (~/.omp/agent/sessions). */
276
281
  export declare function getSessionsDir(agentDir?: string): string;
277
282
  /** Get the content-addressed blob store directory (~/.omp/agent/blobs). */
@@ -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 {
@@ -126,13 +126,17 @@ export declare function registerStdioDisconnectHandling(): () => void;
126
126
  * `--bytecode` builds. When a one-shot command awaits a promise that never
127
127
  * settles and holds no live handle, the loop drains and Bun exits 0 without
128
128
  * output: an unfinished command reported as success. `beforeExit` fires in
129
- * exactly that state; explicit exits (`process.exit`, {@link quit},
129
+ * that state; explicit exits (`process.exit`, {@link quit},
130
130
  * {@link exitProcess}, the signal handlers) never emit it.
131
131
  *
132
- * The report runs once and only sets `process.exitCode`, so later `beforeExit`
133
- * listeners (LSP shutdown) still run before the process exits 1. It starts no
134
- * work of its own beyond the log write; `once` keeps the second `beforeExit`
135
- * that any drained follow-up work triggers from reporting again.
132
+ * `beforeExit` alone is not the verdict: on Windows Bun emits it while I/O is
133
+ * still in flight and then keeps running the loop, so a command that goes on
134
+ * to complete must keep its own exit code (#13470). The verdict is taken at
135
+ * `exit` instead, and only when the last `beforeExit` was final: an unref'd
136
+ * timer armed there runs only if the loop turns again, clearing the mark
137
+ * before a later explicit exit. Any later drain emits `beforeExit` again and
138
+ * re-arms it, so other `beforeExit` listeners (LSP shutdown) still run before
139
+ * the process exits 1.
136
140
  *
137
141
  * `describe` names what was running (e.g. the resolved subcommand) at report
138
142
  * time; it must return only a command name, never user arguments.
@@ -1,16 +1,35 @@
1
1
  export interface SuppressTerminalStderrOptions {
2
- /** Redirect target path; defaults to today's omp log file, then /dev/null. */
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 macOS + same-terminal gate. Tests only. */
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 — off macOS, when stderr does not target the stdout terminal, or
11
- * when the libc fd ops are unavailable.
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.3.5",
3
+ "version": "18.4.1",
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.3.5"
60
+ "@oh-my-pi/pi-natives": "18.4.1"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@types/bun": "^1.3.14"
package/src/dirs.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  import * as fs from "node:fs";
15
15
  import * as os from "node:os";
16
16
  import * as path from "node:path";
17
+ import { expandWindowsLongPath } from "@oh-my-pi/pi-natives/path";
17
18
  import { engines, version } from "../package.json" with { type: "json" };
18
19
  import { isEnoent, isEnotdir } from "./fs-error";
19
20
 
@@ -154,6 +155,11 @@ function standardizeMacOSPath(p: string): string {
154
155
  return p;
155
156
  }
156
157
 
158
+ /** Keep the current directory's spelling while expanding Windows 8.3 aliases. */
159
+ function standardizeProjectPath(p: string): string {
160
+ return process.platform === "win32" ? expandWindowsLongPath(p) : standardizeMacOSPath(p);
161
+ }
162
+
157
163
  export function resolveEquivalentPath(inputPath: string): string {
158
164
  const resolvedPath = path.resolve(inputPath);
159
165
  try {
@@ -200,29 +206,33 @@ let projectDir: string | undefined;
200
206
  /** Get the project directory. */
201
207
  export function getProjectDir(): string {
202
208
  if (projectDir === undefined) {
209
+ let cwd: string | undefined;
203
210
  try {
204
- projectDir = standardizeMacOSPath(process.cwd());
211
+ cwd = process.cwd();
205
212
  } catch {
206
213
  const candidates = [process.env.PWD, os.homedir(), os.tmpdir()];
207
214
  for (const candidate of candidates) {
208
215
  if (!candidate || !path.isAbsolute(candidate)) continue;
209
216
  try {
210
217
  process.chdir(candidate);
211
- projectDir = standardizeMacOSPath(candidate);
218
+ cwd = candidate;
212
219
  break;
213
220
  } catch {}
214
221
  }
215
- if (projectDir === undefined) {
222
+ if (cwd === undefined) {
216
223
  throw new Error("Unable to determine an accessible working directory");
217
224
  }
218
225
  }
226
+ // Normalize outside the fallback: a native-addon failure is not an inaccessible cwd,
227
+ // and must surface as itself instead of relocating the process.
228
+ projectDir = standardizeProjectPath(cwd);
219
229
  }
220
230
  return projectDir;
221
231
  }
222
232
 
223
233
  /** Set the project directory. */
224
234
  export function setProjectDir(dir: string): void {
225
- const resolved = standardizeMacOSPath(path.resolve(dir));
235
+ const resolved = standardizeProjectPath(path.resolve(dir));
226
236
  process.chdir(resolved);
227
237
  projectDir = resolved;
228
238
  }
@@ -787,6 +797,16 @@ export function getCommitCacheDbPath(): string {
787
797
  return dirs.rootSubdir(path.join("cache", "commit-inference.db"), "cache");
788
798
  }
789
799
 
800
+ /**
801
+ * Get the judgment answer cache database path (~/.omp/cache/judgment-cache.db).
802
+ * Honors `OMP_JUDGMENT_CACHE_DB` so tests and operators can isolate the cache.
803
+ */
804
+ export function getJudgmentCacheDbPath(): string {
805
+ const override = process.env.OMP_JUDGMENT_CACHE_DB;
806
+ if (override) return override;
807
+ return dirs.rootSubdir(path.join("cache", "judgment-cache.db"), "cache");
808
+ }
809
+
790
810
  /** Get the legacy Pi extension parse cache database path. */
791
811
  export function getLegacyPiExtensionCacheDbPath(): string {
792
812
  return dirs.rootSubdir(path.join("cache", "legacy-pi-extension-cache.db"), "cache");
@@ -891,9 +911,9 @@ export function getTinyModelsCacheDir(agentDir?: string): string {
891
911
  export function getDocumentConversionCacheDir(agentDir?: string): string {
892
912
  return dirs.agentSubdir(agentDir, path.join("cache", "document-conversions"), "cache");
893
913
  }
894
- /** Get the per-project composer speculative cache directory (~/.omp/agent/cache/composer; XDG default: $XDG_CACHE_HOME/omp/cache/composer). */
895
- export function getComposerCacheDir(agentDir?: string): string {
896
- 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");
897
917
  }
898
918
 
899
919
  /** Get the sessions directory (~/.omp/agent/sessions). */
@@ -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/postmortem.ts CHANGED
@@ -376,26 +376,38 @@ export function registerStdioDisconnectHandling(): () => void {
376
376
  * `--bytecode` builds. When a one-shot command awaits a promise that never
377
377
  * settles and holds no live handle, the loop drains and Bun exits 0 without
378
378
  * output: an unfinished command reported as success. `beforeExit` fires in
379
- * exactly that state; explicit exits (`process.exit`, {@link quit},
379
+ * that state; explicit exits (`process.exit`, {@link quit},
380
380
  * {@link exitProcess}, the signal handlers) never emit it.
381
381
  *
382
- * The report runs once and only sets `process.exitCode`, so later `beforeExit`
383
- * listeners (LSP shutdown) still run before the process exits 1. It starts no
384
- * work of its own beyond the log write; `once` keeps the second `beforeExit`
385
- * that any drained follow-up work triggers from reporting again.
382
+ * `beforeExit` alone is not the verdict: on Windows Bun emits it while I/O is
383
+ * still in flight and then keeps running the loop, so a command that goes on
384
+ * to complete must keep its own exit code (#13470). The verdict is taken at
385
+ * `exit` instead, and only when the last `beforeExit` was final: an unref'd
386
+ * timer armed there runs only if the loop turns again, clearing the mark
387
+ * before a later explicit exit. Any later drain emits `beforeExit` again and
388
+ * re-arms it, so other `beforeExit` listeners (LSP shutdown) still run before
389
+ * the process exits 1.
386
390
  *
387
391
  * `describe` names what was running (e.g. the resolved subcommand) at report
388
392
  * time; it must return only a command name, never user arguments.
389
393
  */
390
394
  export function reportUnsettledEntry(work: Promise<unknown>, describe?: () => string | undefined): void {
391
395
  let pending = true;
396
+ let drained = false;
392
397
  const settled = (): void => {
393
398
  pending = false;
394
399
  };
395
400
  // Observe both outcomes without claiming the rejection: the caller's `.catch` still owns it.
396
401
  void work.then(settled, settled);
397
- process.once("beforeExit", () => {
402
+ process.on("beforeExit", () => {
398
403
  if (!pending) return;
404
+ drained = true;
405
+ setTimeout(() => {
406
+ drained = false;
407
+ }, 0).unref();
408
+ });
409
+ process.once("exit", () => {
410
+ if (!pending || !drained) return;
399
411
  const command = describe?.();
400
412
  const subject = command ? `\`${APP_NAME} ${command}\`` : "command";
401
413
  const message = `${subject} ended before completing: the event loop drained while it was still pending (rerun with PI_DEBUG_STARTUP=1 to see the last phase reached)`;
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 (error) {
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 (error) {
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
  }
@@ -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
- * 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.
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
- * 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.
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
- /** Redirect target path; defaults to today's omp log file, then /dev/null. */
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 macOS + same-terminal gate. Tests only. */
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 — off macOS, when stderr does not target the stdout terminal, or
93
- * when the libc fd ops are unavailable.
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 && (process.platform !== "darwin" || !stderrSharesStdoutTerminal())) {
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