@virtengine/openfleet 0.25.0

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.
Files changed (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
package/lib/logger.mjs ADDED
@@ -0,0 +1,645 @@
1
+ /**
2
+ * logger.mjs — Centralized logging for openfleet.
3
+ *
4
+ * Provides leveled logging that separates human-facing CLI output from
5
+ * debug/trace noise. All messages are always written to the log file;
6
+ * only messages at or above the configured console level appear in the terminal.
7
+ *
8
+ * Levels (lowest to highest):
9
+ * TRACE → DEBUG → INFO → WARN → ERROR → SILENT
10
+ *
11
+ * Usage:
12
+ * import { createLogger, setLogLevel, setLogFile } from "./lib/logger.mjs";
13
+ *
14
+ * const log = createLogger("monitor");
15
+ * log.info("Task completed"); // Shown in terminal (default)
16
+ * log.debug("Cache hit ratio: 0.95"); // Hidden in terminal, written to log file
17
+ * log.trace("Processing line 42"); // Only in log file at TRACE level
18
+ * log.error("Fatal: no config"); // Always shown
19
+ *
20
+ * The module prefix (e.g. [monitor]) is automatically prepended.
21
+ */
22
+
23
+ import { appendFileSync, mkdirSync } from "node:fs";
24
+ import { dirname } from "node:path";
25
+ import { stripAnsi } from "../utils.mjs";
26
+
27
+ // ── Log levels ──────────────────────────────────────────────────────────────
28
+
29
+ export const LogLevel = /** @type {const} */ ({
30
+ TRACE: 0,
31
+ DEBUG: 1,
32
+ INFO: 2,
33
+ WARN: 3,
34
+ ERROR: 4,
35
+ SILENT: 5,
36
+ });
37
+
38
+ /** @type {Record<string, number>} */
39
+ const LEVEL_MAP = {
40
+ trace: LogLevel.TRACE,
41
+ debug: LogLevel.DEBUG,
42
+ info: LogLevel.INFO,
43
+ warn: LogLevel.WARN,
44
+ error: LogLevel.ERROR,
45
+ silent: LogLevel.SILENT,
46
+ };
47
+
48
+ // ── Global state ────────────────────────────────────────────────────────────
49
+
50
+ /** @type {number} Console output threshold — messages below this level are suppressed in terminal */
51
+ let consoleLevel = LogLevel.INFO;
52
+
53
+ /** @type {number} File output threshold — messages below this level are not written to log file */
54
+ let fileLevel = LogLevel.DEBUG;
55
+
56
+ /** @type {string|null} Path to the log file */
57
+ let logFilePath = null;
58
+
59
+ /** @type {string|null} Path to the dedicated error/stderr log file */
60
+ let errorLogFilePath = null;
61
+
62
+ /** @type {boolean} Whether the log file directory has been ensured */
63
+ let logDirEnsured = false;
64
+
65
+ /** @type {boolean} Whether the error log directory has been ensured */
66
+ let errorLogDirEnsured = false;
67
+
68
+ /** @type {Set<string>} Modules to always show at DEBUG level even when console is at INFO */
69
+ const verboseModules = new Set();
70
+
71
+ // ── Configuration ───────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Set the minimum console log level.
75
+ * @param {string|number} level - Level name ("trace", "debug", "info", "warn", "error", "silent") or LogLevel value
76
+ */
77
+ export function setConsoleLevel(level) {
78
+ if (typeof level === "string") {
79
+ consoleLevel = LEVEL_MAP[level.toLowerCase()] ?? LogLevel.INFO;
80
+ } else {
81
+ consoleLevel = level;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Set the minimum file log level.
87
+ * @param {string|number} level
88
+ */
89
+ export function setFileLevel(level) {
90
+ if (typeof level === "string") {
91
+ fileLevel = LEVEL_MAP[level.toLowerCase()] ?? LogLevel.DEBUG;
92
+ } else {
93
+ fileLevel = level;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Set the log file path. All messages at or above the file level are appended here.
99
+ * @param {string|null} path
100
+ */
101
+ export function setLogFile(path) {
102
+ logFilePath = path;
103
+ logDirEnsured = false;
104
+ ensureLogFile(logFilePath, (value) => {
105
+ logDirEnsured = value;
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Set the dedicated error log file path.
111
+ * WARN, ERROR, uncaughtException/unhandledRejection, and raw stderr writes are appended here.
112
+ * @param {string|null} path
113
+ */
114
+ export function setErrorLogFile(path) {
115
+ errorLogFilePath = path;
116
+ errorLogDirEnsured = false;
117
+ ensureLogFile(errorLogFilePath, (value) => {
118
+ errorLogDirEnsured = value;
119
+ });
120
+ }
121
+
122
+ /**
123
+ * Get the current error log file path.
124
+ * @returns {string|null}
125
+ */
126
+ export function getErrorLogFilePath() {
127
+ return errorLogFilePath;
128
+ }
129
+
130
+ /**
131
+ * Parse CLI args for logging flags and configure accordingly.
132
+ * Call this once at startup before any logging.
133
+ *
134
+ * Flags:
135
+ * --quiet Only show errors and warnings
136
+ * --verbose Show debug messages too
137
+ * --trace Show everything
138
+ * --silent No console output
139
+ * --log-level X Set explicit level
140
+ *
141
+ * @param {string[]} argv
142
+ */
143
+ export function configureFromArgs(argv) {
144
+ if (argv.includes("--silent")) {
145
+ setConsoleLevel(LogLevel.SILENT);
146
+ } else if (argv.includes("--quiet") || argv.includes("-q")) {
147
+ setConsoleLevel(LogLevel.WARN);
148
+ } else if (argv.includes("--trace")) {
149
+ setConsoleLevel(LogLevel.TRACE);
150
+ } else if (argv.includes("--verbose") || argv.includes("-V")) {
151
+ setConsoleLevel(LogLevel.DEBUG);
152
+ }
153
+
154
+ const levelIdx = argv.indexOf("--log-level");
155
+ if (levelIdx !== -1 && argv[levelIdx + 1]) {
156
+ setConsoleLevel(argv[levelIdx + 1]);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Enable verbose (DEBUG-level) output for specific modules even at INFO level.
162
+ * @param {string[]} modules
163
+ */
164
+ export function setVerboseModules(modules) {
165
+ verboseModules.clear();
166
+ for (const m of modules) verboseModules.add(m.toLowerCase());
167
+ }
168
+
169
+ /**
170
+ * Get the current console log level.
171
+ * @returns {number}
172
+ */
173
+ export function getConsoleLevel() {
174
+ return consoleLevel;
175
+ }
176
+
177
+ // ── Timestamp ───────────────────────────────────────────────────────────────
178
+
179
+ function timestamp() {
180
+ const d = new Date();
181
+ const hh = String(d.getHours()).padStart(2, "0");
182
+ const mm = String(d.getMinutes()).padStart(2, "0");
183
+ const ss = String(d.getSeconds()).padStart(2, "0");
184
+ return `${hh}:${mm}:${ss}`;
185
+ }
186
+
187
+ function datestamp() {
188
+ return new Date().toISOString();
189
+ }
190
+
191
+ function ensureLogFile(path, markEnsured) {
192
+ if (!path) return;
193
+ try {
194
+ mkdirSync(dirname(path), { recursive: true });
195
+ appendFileSync(path, "");
196
+ if (markEnsured) markEnsured(true);
197
+ } catch {
198
+ if (markEnsured) markEnsured(false);
199
+ }
200
+ }
201
+
202
+ // ── File writing ────────────────────────────────────────────────────────────
203
+
204
+ function writeToFile(levelName, module, msg) {
205
+ if (!logFilePath) return;
206
+ if (!logDirEnsured) {
207
+ try {
208
+ mkdirSync(dirname(logFilePath), { recursive: true });
209
+ } catch {
210
+ /* best effort */
211
+ }
212
+ logDirEnsured = true;
213
+ }
214
+ const clean = typeof msg === "string" ? stripAnsi(msg) : String(msg);
215
+ const line = `${datestamp()} [${levelName}] [${module}] ${clean}\n`;
216
+ try {
217
+ appendFileSync(logFilePath, line);
218
+ } catch {
219
+ /* best effort */
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Write a message to the dedicated error log file.
225
+ * Called for WARN, ERROR, and raw stderr writes.
226
+ * @param {string} levelName
227
+ * @param {string} module
228
+ * @param {string} msg
229
+ */
230
+ function writeToErrorFile(levelName, module, msg) {
231
+ if (!errorLogFilePath) return;
232
+ if (!errorLogDirEnsured) {
233
+ try {
234
+ mkdirSync(dirname(errorLogFilePath), { recursive: true });
235
+ } catch {
236
+ /* best effort */
237
+ }
238
+ errorLogDirEnsured = true;
239
+ }
240
+ const clean = typeof msg === "string" ? stripAnsi(msg) : String(msg);
241
+ const line = `${datestamp()} [${levelName}] [${module}] ${clean}\n`;
242
+ try {
243
+ appendFileSync(errorLogFilePath, line);
244
+ } catch {
245
+ /* best effort */
246
+ }
247
+ }
248
+
249
+ // ── Logger factory ──────────────────────────────────────────────────────────
250
+
251
+ /**
252
+ * @typedef {Object} Logger
253
+ * @property {(...args: any[]) => void} error - Always shown in terminal
254
+ * @property {(...args: any[]) => void} warn - Shown at WARN+ level
255
+ * @property {(...args: any[]) => void} info - Shown at INFO+ level (default)
256
+ * @property {(...args: any[]) => void} debug - Shown at DEBUG+ level or via --verbose
257
+ * @property {(...args: any[]) => void} trace - Shown at TRACE+ level or via --trace
258
+ */
259
+
260
+ /**
261
+ * Create a logger for a specific module.
262
+ *
263
+ * @param {string} module - Module name (e.g. "monitor", "fleet", "telegram-bot")
264
+ * @returns {Logger}
265
+ */
266
+ export function createLogger(module) {
267
+ const prefix = `[${module}]`;
268
+ const moduleLower = module.toLowerCase();
269
+
270
+ function emit(level, levelName, consoleFn, args) {
271
+ const msg = args
272
+ .map((a) => (typeof a === "string" ? a : String(a)))
273
+ .join(" ");
274
+
275
+ // Always write to file if above file threshold
276
+ if (level >= fileLevel) {
277
+ writeToFile(levelName, module, msg);
278
+ }
279
+
280
+ // Console output: check level threshold
281
+ // Module-specific verbose override: show DEBUG for this module even at INFO level
282
+ const effectiveLevel =
283
+ level === LogLevel.DEBUG && verboseModules.has(moduleLower)
284
+ ? LogLevel.INFO
285
+ : level;
286
+
287
+ if (effectiveLevel >= consoleLevel) {
288
+ const ts = timestamp();
289
+ consoleFn(` ${ts} ${prefix} ${msg}`);
290
+ }
291
+ }
292
+
293
+ return {
294
+ error: (...args) => emit(LogLevel.ERROR, "ERROR", console.error, args),
295
+ warn: (...args) => emit(LogLevel.WARN, "WARN", console.warn, args),
296
+ info: (...args) => emit(LogLevel.INFO, "INFO", console.log, args),
297
+ debug: (...args) => emit(LogLevel.DEBUG, "DEBUG", console.log, args),
298
+ trace: (...args) => emit(LogLevel.TRACE, "TRACE", console.log, args),
299
+ };
300
+ }
301
+
302
+ // ── Console interceptor ─────────────────────────────────────────────────────
303
+ //
304
+ // Intercepts console.log / console.warn / console.error globally and routes
305
+ // messages through the leveled logger. This lets us filter 700+ existing
306
+ // console.* calls without touching every call-site.
307
+ //
308
+ // Classification rules:
309
+ // 1. Messages starting with `[tag] ` are auto-classified by tag + keywords.
310
+ // 2. Messages without a tag prefix pass through as INFO (human-facing output).
311
+ // 3. console.warn → WARN, console.error → ERROR (always pass through).
312
+ //
313
+
314
+ /** @type {boolean} */
315
+ let interceptorInstalled = false;
316
+
317
+ // Keywords that promote a tagged message to INFO — MUST be narrow.
318
+ // Only truly human-critical events that require operator attention.
319
+ const INFO_KEYWORDS = [
320
+ "fatal",
321
+ "crash",
322
+ "circuit breaker",
323
+ "self-restart",
324
+ "shutting down",
325
+ "all tasks complete",
326
+ "stuck",
327
+ "preflight failed",
328
+ "manual resolution",
329
+ "backlog empty",
330
+ "permanently failed",
331
+ "retries exhausted",
332
+ ];
333
+
334
+ // Keywords that push a tagged message down to TRACE (very noisy internals)
335
+ const TRACE_KEYWORDS = [
336
+ "skipping",
337
+ "dedup",
338
+ "rate limit",
339
+ "throttl",
340
+ "already in progress",
341
+ "no change",
342
+ "unchanged",
343
+ "cache miss",
344
+ "cache hit",
345
+ "nothing to",
346
+ "same as last",
347
+ "too soon",
348
+ "polling",
349
+ "heartbeat",
350
+ "ping",
351
+ "byte",
352
+ "chunk",
353
+ "offset",
354
+ "cursor",
355
+ "checking port",
356
+ "line count",
357
+ "no old completed",
358
+ "cooldown",
359
+ "score:",
360
+ "attempt ",
361
+ ];
362
+
363
+ // Modules whose tagged messages default to DEBUG unless INFO_KEYWORDS match
364
+ const DEBUG_MODULES = new Set([
365
+ "monitor",
366
+ "fleet",
367
+ "telegram-bot",
368
+ "telegram",
369
+ "vk",
370
+ "vk-log",
371
+ "vk-dispatch",
372
+ "workspace-monitor",
373
+ "maintenance",
374
+ "autofix",
375
+ "config",
376
+ "conflict",
377
+ "merge-strategy",
378
+ "task-complexity",
379
+ "shared-knowledge",
380
+ "preflight",
381
+ "codex-config",
382
+ "task-archiver",
383
+ "update-check",
384
+ "restart-controller",
385
+ "sdk-conflict",
386
+ "fleet-coordinator",
387
+ "conflict-resolver",
388
+ "primary-agent",
389
+ "task-assessment",
390
+ "setup",
391
+ "analyze",
392
+ ]);
393
+
394
+ // Pattern: [tag] message or [tag] message (extra space)
395
+ const TAG_RE = /^\[([^\]]+)\]\s*/;
396
+
397
+ /**
398
+ * Classify a console.log message into a LogLevel.
399
+ * @param {string} msg - The first argument to console.log
400
+ * @returns {number} LogLevel
401
+ */
402
+ function classifyMessage(msg) {
403
+ if (typeof msg !== "string") return LogLevel.INFO;
404
+
405
+ const tagMatch = msg.match(TAG_RE);
406
+ if (!tagMatch) {
407
+ // No [tag] prefix — human-facing output, always INFO
408
+ return LogLevel.INFO;
409
+ }
410
+
411
+ const tag = tagMatch[1].toLowerCase();
412
+ const body = msg.slice(tagMatch[0].length).toLowerCase();
413
+
414
+ // Check for TRACE keywords first (most restrictive)
415
+ for (const kw of TRACE_KEYWORDS) {
416
+ if (body.includes(kw)) return LogLevel.TRACE;
417
+ }
418
+
419
+ // Check for INFO keywords (important events worth showing)
420
+ for (const kw of INFO_KEYWORDS) {
421
+ if (body.includes(kw)) return LogLevel.INFO;
422
+ }
423
+
424
+ // If the module is in our DEBUG set, default to DEBUG
425
+ if (DEBUG_MODULES.has(tag)) return LogLevel.DEBUG;
426
+
427
+ // Unknown tags → INFO (assume user-facing)
428
+ return LogLevel.INFO;
429
+ }
430
+
431
+ /**
432
+ * Install the global console interceptor.
433
+ * Call once at startup before any significant logging.
434
+ *
435
+ * This replaces console.log / console.warn / console.error with filtered
436
+ * versions that respect the configured console level.
437
+ *
438
+ * - console.error → always ERROR level (passes through)
439
+ * - console.warn → always WARN level
440
+ * - console.log → auto-classified by tag/content
441
+ *
442
+ * @param {Object} [opts]
443
+ * @param {string} [opts.logFile] - Path to the log file
444
+ */
445
+ export function installConsoleInterceptor(opts = {}) {
446
+ if (interceptorInstalled) return;
447
+ interceptorInstalled = true;
448
+
449
+ if (opts.logFile) setLogFile(opts.logFile);
450
+
451
+ const _origLog = console.log.bind(console);
452
+ const _origWarn = console.warn.bind(console);
453
+ const _origError = console.error.bind(console);
454
+
455
+ // Replace console.log with filtered version
456
+ console.log = (...args) => {
457
+ const first = args[0];
458
+ const level = classifyMessage(first);
459
+
460
+ // Always write to file
461
+ if (logFilePath && level >= fileLevel) {
462
+ const msg = args
463
+ .map((a) => (typeof a === "string" ? a : String(a)))
464
+ .join(" ");
465
+ const tagMatch = typeof first === "string" ? first.match(TAG_RE) : null;
466
+ const mod = tagMatch ? tagMatch[1] : "stdout";
467
+ const levelName =
468
+ Object.keys(LogLevel).find((k) => LogLevel[k] === level) || "INFO";
469
+ writeToFile(levelName, mod, msg);
470
+ }
471
+
472
+ // Console output
473
+ if (level >= consoleLevel) {
474
+ _origLog(...args);
475
+ }
476
+ };
477
+
478
+ // console.warn → always WARN level
479
+ console.warn = (...args) => {
480
+ _inInterceptor = true;
481
+ try {
482
+ const msg = args
483
+ .map((a) => (typeof a === "string" ? a : String(a)))
484
+ .join(" ");
485
+ const tagMatch = typeof msg === "string" ? msg.match(TAG_RE) : null;
486
+ const mod = tagMatch?.[1] || "stderr";
487
+ if (logFilePath && LogLevel.WARN >= fileLevel) {
488
+ writeToFile("WARN", mod, msg);
489
+ }
490
+ // Also write to dedicated error log
491
+ writeToErrorFile("WARN", mod, msg);
492
+ if (LogLevel.WARN >= consoleLevel) {
493
+ _origWarn(...args);
494
+ }
495
+ } finally {
496
+ _inInterceptor = false;
497
+ }
498
+ };
499
+
500
+ // console.error → always ERROR level (always passes through)
501
+ console.error = (...args) => {
502
+ _inInterceptor = true;
503
+ try {
504
+ const msg = args
505
+ .map((a) => {
506
+ if (a instanceof Error) return a.stack || a.message;
507
+ return typeof a === "string" ? a : String(a);
508
+ })
509
+ .join(" ");
510
+ const tagMatch = typeof msg === "string" ? msg.match(TAG_RE) : null;
511
+ const mod = tagMatch?.[1] || "stderr";
512
+ if (logFilePath && LogLevel.ERROR >= fileLevel) {
513
+ writeToFile("ERROR", mod, msg);
514
+ }
515
+ // Also write to dedicated error log
516
+ writeToErrorFile("ERROR", mod, msg);
517
+ // Errors always pass through
518
+ _origError(...args);
519
+ } finally {
520
+ _inInterceptor = false;
521
+ }
522
+ };
523
+
524
+ // ── Intercept raw process.stderr.write ────────────────────────────────
525
+ // Some code (uncaughtException handlers, native Node errors, child_process
526
+ // stderr leaks) writes to process.stderr directly, bypassing console.
527
+ // Tee those writes to the error log file, but skip writes that originate
528
+ // from our own console.warn/error interceptors to avoid duplicates.
529
+ let _inInterceptor = false;
530
+ const _origStderrWrite = process.stderr.write.bind(process.stderr);
531
+ let _stderrBroken = false;
532
+ const isBenignStderrError = (err) =>
533
+ !!(
534
+ err &&
535
+ (err.code === "EPIPE" ||
536
+ err.code === "EIO" ||
537
+ err.code === "ERR_STREAM_DESTROYED" ||
538
+ err.code === "ERR_STREAM_WRITE_AFTER_END" ||
539
+ /\bEIO\b/.test(err.message) ||
540
+ /\bEPIPE\b/.test(err.message))
541
+ );
542
+ const markStderrBroken = (err) => {
543
+ if (_stderrBroken) return true;
544
+ if (isBenignStderrError(err)) {
545
+ _stderrBroken = true;
546
+ return true;
547
+ }
548
+ return false;
549
+ };
550
+ process.stderr.on("error", (err) => {
551
+ if (markStderrBroken(err)) {
552
+ writeToErrorFile("STDERR", "process", `stderr error: ${err?.message || err}`);
553
+ return;
554
+ }
555
+ // If stderr errors for an unexpected reason, record it but avoid crashing.
556
+ writeToErrorFile("STDERR", "process", `stderr unexpected error: ${err?.message || err}`);
557
+ });
558
+ const safeStderrWrite = (chunk, ...rest) => {
559
+ if (
560
+ _stderrBroken ||
561
+ !process.stderr.writable ||
562
+ process.stderr.destroyed ||
563
+ process.stderr.writableEnded
564
+ ) {
565
+ _stderrBroken = true;
566
+ return false;
567
+ }
568
+ try {
569
+ return _origStderrWrite(chunk, ...rest);
570
+ } catch (err) {
571
+ if (markStderrBroken(err)) {
572
+ return false;
573
+ }
574
+ throw err;
575
+ }
576
+ };
577
+ process.stderr.write = (chunk, ...rest) => {
578
+ if (!_inInterceptor) {
579
+ const text = typeof chunk === "string" ? chunk : chunk?.toString?.("utf8") || "";
580
+ if (text.trim()) {
581
+ writeToErrorFile("STDERR", "process", text.replace(/\n$/, ""));
582
+ }
583
+ }
584
+ return safeStderrWrite(chunk, ...rest);
585
+ };
586
+
587
+ // ── Harden stdout against broken pipes ────────────────────────────────
588
+ const _origStdoutWrite = process.stdout.write.bind(process.stdout);
589
+ let _stdoutBroken = false;
590
+ const isBenignStdoutError = (err) =>
591
+ !!(
592
+ err &&
593
+ (err.code === "EPIPE" ||
594
+ err.code === "EIO" ||
595
+ err.code === "ERR_STREAM_DESTROYED" ||
596
+ err.code === "ERR_STREAM_WRITE_AFTER_END" ||
597
+ /\bEIO\b/.test(err.message) ||
598
+ /\bEPIPE\b/.test(err.message))
599
+ );
600
+ const markStdoutBroken = (err) => {
601
+ if (_stdoutBroken) return true;
602
+ if (isBenignStdoutError(err)) {
603
+ _stdoutBroken = true;
604
+ return true;
605
+ }
606
+ return false;
607
+ };
608
+ process.stdout.on("error", (err) => {
609
+ if (markStdoutBroken(err)) {
610
+ writeToErrorFile("STDOUT", "process", `stdout error: ${err?.message || err}`);
611
+ return;
612
+ }
613
+ writeToErrorFile("STDOUT", "process", `stdout unexpected error: ${err?.message || err}`);
614
+ });
615
+ const safeStdoutWrite = (chunk, ...rest) => {
616
+ if (
617
+ _stdoutBroken ||
618
+ !process.stdout.writable ||
619
+ process.stdout.destroyed ||
620
+ process.stdout.writableEnded
621
+ ) {
622
+ _stdoutBroken = true;
623
+ return false;
624
+ }
625
+ try {
626
+ return _origStdoutWrite(chunk, ...rest);
627
+ } catch (err) {
628
+ if (markStdoutBroken(err)) {
629
+ return false;
630
+ }
631
+ throw err;
632
+ }
633
+ };
634
+ process.stdout.write = (chunk, ...rest) => safeStdoutWrite(chunk, ...rest);
635
+ }
636
+
637
+ /**
638
+ * Restore original console methods (for testing).
639
+ */
640
+ export function uninstallConsoleInterceptor() {
641
+ // Can't easily restore — this is a best-effort for tests
642
+ interceptorInstalled = false;
643
+ }
644
+
645
+ export default createLogger;