@oh-my-pi/pi-utils 18.4.0 → 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
@@ -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 per-project composer speculative cache directory (~/.omp/agent/cache/composer; XDG default: $XDG_CACHE_HOME/omp/cache/composer). */
279
- 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;
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). */
@@ -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
- /** 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.4.0",
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.4.0"
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
@@ -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 per-project composer speculative cache directory (~/.omp/agent/cache/composer; XDG default: $XDG_CACHE_HOME/omp/cache/composer). */
915
- export function getComposerCacheDir(agentDir?: string): string {
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). */
@@ -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 (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