feat(desktop): forward renderer console.* to desktop.log

Mirror all renderer console.log/warn/error/info/debug calls into
desktop.log via IPC. A side-effect module (console-forward.ts) monkey-
patches console.* at app init to send each call through a fire-and-forget
ipcRenderer.send to the main process, which routes it through
rememberLog() so the lines land in desktop.log alongside the main
process's own [hermes] lines.

Forwarded lines are prefixed [renderer:debug], [renderer:warn],
[renderer:error], or [renderer:info]. Objects are JSON-serialized
(capped at 4KB per line). The original console methods are preserved
— devtools still shows the full interactive object inspection.

This means the debug trace [trace:*] entries now land in desktop.log
too, so you can read them with 'hermes logs desktop' without needing
devtools open.
This commit is contained in:
ethernet 2026-07-23 16:59:33 -04:00
parent a71a90bc73
commit 4d81fea19e
5 changed files with 105 additions and 0 deletions

View file

@ -9721,6 +9721,18 @@ ipcMain.handle('hermes:logs:reveal', async () => {
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
// Renderer console forwarding: the renderer monkey-patches console.* to send
// each call here. We format and route through rememberLog() so they land in
// desktop.log alongside the main process's own [hermes] lines.
ipcMain.on('hermes:console:forward', (_event, { level, message }) => {
const tag = level === 'error' ? '[renderer:error]'
: level === 'warn' ? '[renderer:warn]'
: level === 'info' ? '[renderer:info]'
: '[renderer:debug]'
rememberLog(`${tag} ${message}`)
})
function isExecutableFile(filePath) {
if (!filePath || !path.isAbsolute(filePath)) {
return false

View file

@ -108,6 +108,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
},
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
forwardConsole: (level, message) => ipcRenderer.send('hermes:console:forward', { level, message }),
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath),

View file

@ -116,6 +116,7 @@ declare global {
}
revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }>
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
forwardConsole: (level: string, message: string) => void
readDir: (path: string) => Promise<HermesReadDirResult>
gitRoot?: (path: string) => Promise<string | null>
// Reveal a path in the OS file manager (Finder / Explorer).

View file

@ -0,0 +1,89 @@
/**
* Console forwarder mirrors renderer console.* calls into desktop.log via
* IPC. Always on: every console.log/warn/error/info/debug call is forwarded
* to the main process, which writes it through rememberLog() so it lands in
* desktop.log alongside the main process's own [hermes] lines.
*
* Objects/arrays are JSON-serialized (best-effort); non-serializable values
* fall back to their toString(). Multiple args are space-joined. This is a
* one-way fire-and-forget (ipcRenderer.send, no await) so it never blocks the
* renderer or affects call timing.
*
* The original console methods are preserved devtools still shows the full
* object inspection experience. This only adds a parallel write to desktop.log.
*/
type ConsoleMethod = 'log' | 'warn' | 'error' | 'info' | 'debug'
const LEVEL_MAP: Record<ConsoleMethod, string> = {
log: 'info',
warn: 'warn',
error: 'error',
info: 'info',
debug: 'debug'
}
function serializeArg(arg: unknown): string {
if (arg === null) {
return 'null'
}
if (arg === undefined) {
return 'undefined'
}
if (typeof arg === 'string') {
return arg
}
if (typeof arg === 'number' || typeof arg === 'boolean' || typeof arg === 'bigint') {
return String(arg)
}
if (arg instanceof Error) {
return `${arg.name}: ${arg.message}${arg.stack ? `\n${arg.stack}` : ''}`
}
// Objects/arrays — try JSON, fall back to String.
try {
return JSON.stringify(arg)
} catch {
try {
return String(arg)
} catch {
return '[unserializable]'
}
}
}
function forward(level: ConsoleMethod, args: unknown[]): void {
const forwarder = window.hermesDesktop?.forwardConsole
if (!forwarder) {
return
}
const message = args.map(serializeArg).join(' ')
// Cap at 4KB per line — a single huge object dump shouldn't flood desktop.log.
const capped = message.length > 4096 ? `${message.slice(0, 4096)}…(${message.length} chars)` : message
forwarder(LEVEL_MAP[level], capped)
}
if (typeof window !== 'undefined') {
for (const method of Object.keys(LEVEL_MAP) as ConsoleMethod[]) {
const original = console[method].bind(console)
console[method] = (...args: unknown[]) => {
original(...args)
try {
forward(method, args)
} catch {
// Forwarding must never break the original call or throw in the
// renderer — it already ran above.
}
}
}
}

View file

@ -4,6 +4,8 @@ import './store/translucency'
// Side-effect: attaches debug trace subscriptions (persistence, gateway
// events, session switch watchers). No-ops entirely when tracing is disabled.
import './lib/debug-trace'
// Side-effect: mirrors renderer console.* calls into desktop.log via IPC.
import './lib/console-forward'
import { QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'