File size: 11,499 Bytes
76289e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
// Console logging helpers format and write messages to console streams.
import util from "node:util";
import { stripAnsi } from "../../packages/terminal-core/src/ansi.js";
import { clearActiveProgressLine } from "../../packages/terminal-core/src/progress-line.js";
import { isVerbose } from "../global-state.js";
import { readLoggingConfig } from "./config.js";
import { resolveEnvLogLevelOverride } from "./env-log-level.js";
import { formatJsonConsoleLine } from "./json-console-line.js";
import { type LogLevel, normalizeLogLevel } from "./levels.js";
import { getLogger } from "./logger.js";
import { redactSensitiveText } from "./redact.js";
import { loggingState } from "./state.js";
import { formatTimestamp } from "./timestamps.js";
import type { ConsoleStyle, LoggerSettings } from "./types.js";

export type { ConsoleStyle } from "./types.js";
export { formatJsonConsoleLine };
type ConsoleSettings = {
  level: LogLevel;
  style: ConsoleStyle;
};
export type ConsoleLoggerSettings = ConsoleSettings;

function normalizeConsoleLevel(level?: string): LogLevel {
  if (isVerbose()) {
    return "debug";
  }
  if (!level && process.env.VITEST === "true" && process.env.OPENCLAW_TEST_CONSOLE !== "1") {
    return "silent";
  }
  return normalizeLogLevel(level, "info");
}

function normalizeConsoleStyle(style?: string): ConsoleStyle {
  if (style === "compact" || style === "json" || style === "pretty") {
    return style;
  }
  if (!process.stdout.isTTY) {
    return "compact";
  }
  return "pretty";
}

function resolveConsoleSettings(): ConsoleSettings {
  const envLevel = resolveEnvLogLevelOverride();
  // Test runs default to silent console logging unless explicitly overridden.
  // Skip config-file and full config fallback reads in this fast path.
  if (
    process.env.VITEST === "true" &&
    process.env.OPENCLAW_TEST_CONSOLE !== "1" &&
    !isVerbose() &&
    !envLevel &&
    !loggingState.overrideSettings
  ) {
    return { level: "silent", style: normalizeConsoleStyle(undefined) };
  }

  const cfg = (loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig();
  const level = envLevel ?? normalizeConsoleLevel(cfg?.consoleLevel);
  const style = normalizeConsoleStyle(cfg?.consoleStyle);
  return { level, style };
}

export function getConsoleSettings(): ConsoleLoggerSettings {
  const cached = loggingState.cachedConsoleSettings as ConsoleSettings | null;
  if (cached) {
    return cached;
  }
  const settings = resolveConsoleSettings();
  loggingState.cachedConsoleSettings = settings;
  return loggingState.cachedConsoleSettings as ConsoleSettings;
}

export function getResolvedConsoleSettings(): ConsoleLoggerSettings {
  return getConsoleSettings();
}

// Route all console output (including tslog console writes) to stderr.
// This keeps stdout clean for RPC/JSON modes.
export function routeLogsToStderr(): void {
  loggingState.forceConsoleToStderr = true;
}

export function setConsoleSubsystemFilter(filters?: string[] | null): void {
  if (!filters || filters.length === 0) {
    loggingState.consoleSubsystemFilter = null;
    return;
  }
  const normalized = filters.map((value) => value.trim()).filter((value) => value.length > 0);
  loggingState.consoleSubsystemFilter = normalized.length > 0 ? normalized : null;
}

/** Hides subsystem console lines for TTY-owned work while preserving file logging. */
export async function withConsoleSubsystemsSuppressed<T>(work: () => Promise<T>): Promise<T> {
  const previousFilter = loggingState.consoleSubsystemFilter
    ? [...loggingState.consoleSubsystemFilter]
    : null;
  setConsoleSubsystemFilter(["__openclaw_tui_quiet__"]);
  try {
    return await work();
  } finally {
    setConsoleSubsystemFilter(previousFilter);
  }
}

export function setConsoleTimestampPrefix(enabled: boolean): void {
  loggingState.consoleTimestampPrefix = enabled;
}

function normalizeConsoleSubsystem(subsystem?: string | null): string | null {
  if (typeof subsystem !== "string") {
    return null;
  }
  const normalized = subsystem.trim();
  return normalized.length > 0 ? normalized : null;
}

export function shouldLogSubsystemToConsole(subsystem?: string | null): boolean {
  const filter = loggingState.consoleSubsystemFilter;
  if (!filter || filter.length === 0) {
    return true;
  }
  const normalizedSubsystem = normalizeConsoleSubsystem(subsystem);
  if (!normalizedSubsystem) {
    return false;
  }
  return filter.some(
    (prefix) => normalizedSubsystem === prefix || normalizedSubsystem.startsWith(`${prefix}/`),
  );
}

const SUPPRESSED_CONSOLE_PREFIXES = [
  "Closing session:",
  "Opening session:",
  "Removing old closed session:",
  "Session already closed",
  "Session already open",
] as const;

// Node's default warning printer prefixes its single console.error call. Its
// internal caller proves ownership so matching application errors stay ERROR.
const NODE_PROCESS_WARNING_PREFIX = `(${process.release.name}:${process.pid}) `;

function shouldSuppressConsoleMessage(message: string): boolean {
  return SUPPRESSED_CONSOLE_PREFIXES.some((prefix) => message.startsWith(prefix));
}

function isEpipeError(err: unknown): boolean {
  const code = (err as { code?: string })?.code;
  return code === "EPIPE" || code === "EIO";
}

export function formatConsoleTimestamp(style: ConsoleStyle): string {
  const now = new Date();
  if (style === "pretty") {
    return formatTimestamp(now, { style: "short" }).replace(/[+-]\d{2}:\d{2}$/, "");
  }
  return formatTimestamp(now, { style: "long" });
}

function captureConsoleTraceStack(message: string, caller: (...args: unknown[]) => void): string {
  const trace = new Error(message);
  trace.name = "Trace";
  // An Error instance lets both Node and Bun exclude the console wrapper structurally.
  Error.captureStackTrace(trace, caller);
  return trace.stack === undefined
    ? `Trace: ${message}`
    : typeof trace.stack === "string"
      ? trace.stack
      : util.format(trace.stack);
}

function hasTimestampPrefix(value: string): boolean {
  return /^(?:\d{2}:\d{2}:\d{2}|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)/.test(
    value,
  );
}

function writeFormattedConsoleOutput(params: {
  level: LogLevel;
  args: unknown[];
  formatted: string;
  write: (...args: unknown[]) => void;
  caller?: (...args: unknown[]) => void;
}) {
  const consoleStyle = getConsoleSettings().style;
  const trimmed =
    consoleStyle !== "json" && loggingState.consoleTimestampPrefix
      ? stripAnsi(params.formatted).trimStart()
      : "";
  const timestamp =
    trimmed && !hasTimestampPrefix(trimmed) ? formatConsoleTimestamp(consoleStyle) : "";
  const stack =
    params.level === "trace" && params.caller
      ? captureConsoleTraceStack(params.formatted, params.caller)
      : undefined;
  try {
    const rendered =
      consoleStyle === "json"
        ? formatJsonConsoleLine({
            level: params.level,
            message: stripAnsi(params.formatted),
            meta: stack === undefined ? undefined : { stack: stripAnsi(stack) },
          })
        : redactSensitiveText(stack ?? params.formatted);
    const line = timestamp ? `${timestamp} ${rendered}` : rendered;
    clearActiveProgressLine();
    if (loggingState.forceConsoleToStderr) {
      process.stderr.write(`${line}\n`);
    } else if (
      consoleStyle !== "json" &&
      !timestamp &&
      params.args.length === 0 &&
      stack === undefined
    ) {
      params.write.apply(console, params.args as []);
    } else {
      params.write.call(console, line);
    }
  } catch (err) {
    if (isEpipeError(err)) {
      return;
    }
    throw err;
  }
}

/** Writes a root logger line to the pre-capture console sink without re-entering file capture. */
export function writeRootConsoleLine(method: "log" | "error", line: string): boolean {
  const rawConsole = loggingState.rawConsole;
  if (!rawConsole) {
    return false;
  }
  if (shouldSuppressConsoleMessage(line)) {
    return true;
  }
  const level = method === "error" ? "error" : "info";
  writeFormattedConsoleOutput({
    level,
    args: [line],
    formatted: line,
    write: rawConsole[method],
  });
  return true;
}

/**
 * Route console.* calls through file logging while still emitting to stdout/stderr.
 * This keeps user-facing output unchanged but guarantees every console call is captured in log files.
 */
export function enableConsoleCapture(): void {
  if (loggingState.consolePatched) {
    return;
  }
  loggingState.consolePatched = true;

  // Handle async EPIPE errors on stdout/stderr. The synchronous try/catch in
  // the forward() wrapper below only covers errors thrown during write dispatch.
  // When the receiving pipe closes (e.g. during shutdown), Node emits the error
  // asynchronously on the stream. Without a listener this becomes an uncaught
  // exception that crashes the gateway.
  // Guard separately from consolePatched so test resets don't stack listeners.
  if (!loggingState.streamErrorHandlersInstalled) {
    loggingState.streamErrorHandlersInstalled = true;
    for (const stream of [process.stdout, process.stderr]) {
      stream.on("error", (err) => {
        if (isEpipeError(err)) {
          // stdout/stderr broken means the process is orphaned (e.g. the parent
          // service restarted and closed the journal pipe). Exit cleanly instead
          // of spinning in a tight loop where every log attempt re-triggers EPIPE.
          const exitCode = process.exitCode;
          process.exit(exitCode !== undefined && exitCode !== 0 && exitCode !== "0" ? exitCode : 0);
          return;
        }
        throw err;
      });
    }
  }

  const original = {
    log: console.log,
    info: console.info,
    warn: console.warn,
    error: console.error,
    debug: console.debug,
  };
  loggingState.rawConsole = {
    log: original.log,
    info: original.info,
    warn: original.warn,
    error: original.error,
  };

  const forward = (
    level: Exclude<LogLevel, "fatal" | "silent">,
    orig: (...args: unknown[]) => void,
  ) => {
    const forwardedConsoleCall = (...args: unknown[]) => {
      const formatted = util.format(...args);
      let routedLevel = level;
      if (
        level === "error" &&
        formatted.startsWith(NODE_PROCESS_WARNING_PREFIX) &&
        typeof util.getCallSites === "function"
      ) {
        const caller = util.getCallSites(2, { sourceMap: false })[1];
        if (
          caller?.functionName === "writeOut" &&
          caller.scriptName === "node:internal/process/warning"
        ) {
          routedLevel = "warn";
        }
      }
      if (shouldSuppressConsoleMessage(formatted)) {
        return;
      }
      try {
        getLogger()[routedLevel](formatted);
      } catch {
        // never block console output on logging failures
      }
      writeFormattedConsoleOutput({
        level: routedLevel,
        args,
        formatted,
        write: orig,
        caller: forwardedConsoleCall,
      });
    };
    return forwardedConsoleCall;
  };

  console.log = forward("info", original.log);
  console.info = forward("info", original.info);
  console.warn = forward("warn", original.warn);
  console.error = forward("error", original.error);
  console.debug = forward("debug", original.debug);
  // Native trace delegates to console.error; write the prepared stack directly
  // so capture owns exactly one TRACE file record in every console style.
  console.trace = forward("trace", original.error);
}