Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d81fea19e | ||
|
|
a71a90bc73 | ||
|
|
d44674fe08 |
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -42,6 +42,7 @@ await build({
|
||||
target: 'node20',
|
||||
outfile: mainOut,
|
||||
external,
|
||||
sourcemap: true,
|
||||
banner: {
|
||||
js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
|
||||
},
|
||||
@@ -59,6 +60,7 @@ await build({
|
||||
target: 'node20',
|
||||
outfile: preloadOut,
|
||||
external,
|
||||
sourcemap: true,
|
||||
define,
|
||||
logLevel: 'info',
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { translateNow } from '@/i18n'
|
||||
import { type GatewayEventPayload, textPart } from '@/lib/chat-messages'
|
||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { playCompletionSound } from '@/lib/completion-sound'
|
||||
import { debugTrace } from '@/lib/debug-trace'
|
||||
import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { modelOptionsQueryKey } from '@/lib/model-options'
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$messages,
|
||||
sessionMatchesStoredId,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
@@ -541,6 +543,17 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
return
|
||||
}
|
||||
|
||||
debugTrace(
|
||||
'message',
|
||||
`complete session=${sessionId}`,
|
||||
{
|
||||
isActive: isActiveEvent,
|
||||
messageCount: $messages.get().length,
|
||||
hasUsage: Boolean(payload?.usage),
|
||||
hasBilling: Boolean(payload?.billing)
|
||||
}
|
||||
)
|
||||
|
||||
// Turn ended — drop any blocking prompt still open for THIS session
|
||||
// (e.g. interrupted, or the approval already resolved). Scoped to the
|
||||
// session so a background turn finishing can't wipe the active chat's
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSearchParams } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { $debugTraceEnabled, setDebugTraceEnabled } from '@/lib/debug-trace'
|
||||
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { repoDiscoveryPolicyFromConfig, repoDiscoveryPolicySignature, scanAndRecordRepos } from '@/store/projects'
|
||||
@@ -57,6 +58,7 @@ export function ConfigSettings({
|
||||
const { t } = useI18n()
|
||||
const c = t.settings.config
|
||||
const keepAwake = useStore($keepAwake)
|
||||
const debugTraceEnabled = useStore($debugTraceEnabled)
|
||||
// The editable draft is local (debounced autosave watches it), but it's seeded
|
||||
// from — and saved back through — the shared config cache, so edits are visible
|
||||
// in the MCP/model surfaces and reopening the page doesn't reload-flash.
|
||||
@@ -293,6 +295,9 @@ export function ConfigSettings({
|
||||
{activeSectionId === 'advanced' && (
|
||||
<ToggleRow checked={keepAwake} description={c.keepAwakeDesc} label={c.keepAwakeTitle} onChange={setKeepAwake} />
|
||||
)}
|
||||
{activeSectionId === 'advanced' && (
|
||||
<ToggleRow checked={debugTraceEnabled} description={c.debugTraceDesc} label={c.debugTraceTitle} onChange={setDebugTraceEnabled} />
|
||||
)}
|
||||
{visibleFields.length === 0 ? (
|
||||
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
|
||||
) : (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ErrorState } from '@/components/ui/error-state'
|
||||
import { debugTrace } from '@/lib/debug-trace'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
export interface ErrorBoundaryFallbackProps {
|
||||
@@ -30,6 +31,10 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]'
|
||||
console.error(tag, error, info.componentStack)
|
||||
debugTrace('error-boundary', `${this.props.label ?? 'root'}: ${error.message}`, {
|
||||
error,
|
||||
componentStack: info.componentStack
|
||||
})
|
||||
this.props.onError?.(error, info)
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
@@ -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).
|
||||
|
||||
@@ -541,7 +541,9 @@ export const en: Translations = {
|
||||
imported: 'Config imported',
|
||||
invalidJson: 'Invalid config JSON',
|
||||
keepAwakeTitle: 'Keep computer awake',
|
||||
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.'
|
||||
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.',
|
||||
debugTraceTitle: 'Debug trace logging',
|
||||
debugTraceDesc: 'Log verbose state transitions, session switches, compaction events, and gateway events to the devtools console for bug reproduction.'
|
||||
},
|
||||
credentials: {
|
||||
pasteKey: 'Paste key',
|
||||
|
||||
@@ -648,7 +648,9 @@ export const ja = defineLocale({
|
||||
imported: '設定をインポートしました',
|
||||
invalidJson: '設定 JSON が無効です',
|
||||
keepAwakeTitle: 'コンピューターをスリープさせない',
|
||||
keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。'
|
||||
keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。',
|
||||
debugTraceTitle: 'デバッグトレースログ',
|
||||
debugTraceDesc: 'バグ再現用に、状態遷移・セッション切替・圧縮イベント・ゲートウェイイベントをdevtoolsコンソールに詳細出力します。'
|
||||
},
|
||||
credentials: {
|
||||
pasteKey: 'キーを貼り付け',
|
||||
|
||||
@@ -448,6 +448,8 @@ export interface Translations {
|
||||
invalidJson: string
|
||||
keepAwakeTitle: string
|
||||
keepAwakeDesc: string
|
||||
debugTraceTitle: string
|
||||
debugTraceDesc: string
|
||||
}
|
||||
credentials: {
|
||||
pasteKey: string
|
||||
|
||||
@@ -636,7 +636,9 @@ export const zhHant = defineLocale({
|
||||
imported: '設定已匯入',
|
||||
invalidJson: '設定 JSON 無效',
|
||||
keepAwakeTitle: '保持電腦喚醒',
|
||||
keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。'
|
||||
keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。',
|
||||
debugTraceTitle: '除錯追蹤日誌',
|
||||
debugTraceDesc: '將狀態轉換、工作階段切換、壓縮事件和閘道事件詳細輸出到 devtools 控制台,用於重現 bug。'
|
||||
},
|
||||
credentials: {
|
||||
pasteKey: '貼上金鑰',
|
||||
|
||||
@@ -748,7 +748,9 @@ export const zh: Translations = {
|
||||
imported: '配置已导入',
|
||||
invalidJson: '配置 JSON 无效',
|
||||
keepAwakeTitle: '保持电脑唤醒',
|
||||
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。'
|
||||
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。',
|
||||
debugTraceTitle: '调试追踪日志',
|
||||
debugTraceDesc: '将状态转换、会话切换、压缩事件和网关事件详细输出到 devtools 控制台,用于复现 bug。'
|
||||
},
|
||||
credentials: {
|
||||
pasteKey: '粘贴密钥',
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Debug trace — optional, verbose instrumentation of stateful desktop events.
|
||||
*
|
||||
* When enabled (Settings → Advanced), dumps structured `console.debug` entries
|
||||
* for every session state transition, compaction event, message completion,
|
||||
* session switch, persistence write, and gateway event. The output lands in the
|
||||
* renderer devtools console and any attached log capture, so you can point at
|
||||
* the trace after reproducing a bug.
|
||||
*
|
||||
* Zero cost when disabled: every call site is a single function call that
|
||||
* early-returns when the atom is off. The subscriptions (persistence, gateway
|
||||
* events, atom watchers) are only attached once at module init, and their
|
||||
* callbacks also early-return — so the overhead is one closure call per event,
|
||||
* which is negligible.
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { onGatewayEvent } from '@/contrib/events'
|
||||
import { onPersistenceEvent } from '@/lib/storage'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$activeSessionStoredIdRotation,
|
||||
$awaitingResponse,
|
||||
$busy,
|
||||
$connection,
|
||||
$gatewayState,
|
||||
$messages,
|
||||
$resumeExhaustedSessionId,
|
||||
$resumeFailedSessionId,
|
||||
$selectedStoredSessionId,
|
||||
$sessions
|
||||
} from '@/store/session'
|
||||
import { $clarifyRequest } from '@/store/clarify'
|
||||
import { $approvalRequest, $secretRequest, $sudoRequest } from '@/store/prompts'
|
||||
|
||||
const KEY = 'hermes.desktop.debugTrace.v1'
|
||||
|
||||
/** Device-local preference — off by default, per machine. */
|
||||
export const $debugTraceEnabled = atom<boolean>(
|
||||
typeof window === 'undefined' ? false : (() => {
|
||||
try {
|
||||
return window.localStorage.getItem(KEY) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
)
|
||||
|
||||
export function setDebugTraceEnabled(on: boolean): void {
|
||||
$debugTraceEnabled.set(on)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
$debugTraceEnabled.subscribe(on => {
|
||||
try {
|
||||
window.localStorage.setItem(KEY, String(on))
|
||||
} catch {
|
||||
// Storage best-effort.
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type DebugCategory =
|
||||
| 'session-state'
|
||||
| 'compaction'
|
||||
| 'message'
|
||||
| 'session-switch'
|
||||
| 'persistence'
|
||||
| 'gateway-event'
|
||||
| 'connection'
|
||||
| 'profile-switch'
|
||||
| 'resume'
|
||||
| 'busy'
|
||||
| 'error-boundary'
|
||||
| 'prompt'
|
||||
| 'sessions-list'
|
||||
|
||||
const CATEGORY_PREFIX: Record<DebugCategory, string> = {
|
||||
'session-state': '[trace:session-state]',
|
||||
compaction: '[trace:compaction]',
|
||||
message: '[trace:message]',
|
||||
'session-switch': '[trace:session-switch]',
|
||||
persistence: '[trace:persistence]',
|
||||
'gateway-event': '[trace:gateway-event]',
|
||||
connection: '[trace:connection]',
|
||||
'profile-switch': '[trace:profile-switch]',
|
||||
resume: '[trace:resume]',
|
||||
busy: '[trace:busy]',
|
||||
'error-boundary': '[trace:error-boundary]',
|
||||
prompt: '[trace:prompt]',
|
||||
'sessions-list': '[trace:sessions-list]'
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a debug trace entry. No-ops entirely when tracing is disabled.
|
||||
*
|
||||
* `data` is spread as additional console arguments (not stringified) so
|
||||
* devtools can expand/inspect objects natively.
|
||||
*/
|
||||
export function debugTrace(
|
||||
category: DebugCategory,
|
||||
message: string,
|
||||
...data: unknown[]
|
||||
): void {
|
||||
if (!$debugTraceEnabled.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
const ts = new Date().toISOString()
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`${CATEGORY_PREFIX[category]} ${ts} ${message}`, ...data)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subscriptions — attached once at module init, no-op when disabled.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// --- Session switches ---
|
||||
let prevActive: string | null = null
|
||||
$activeSessionId.subscribe(id => {
|
||||
if (id !== prevActive) {
|
||||
debugTrace('session-switch', `activeSessionId ${prevActive ?? 'null'} → ${id ?? 'null'}`)
|
||||
prevActive = id
|
||||
}
|
||||
})
|
||||
|
||||
let prevSelected: string | null = null
|
||||
$selectedStoredSessionId.subscribe(id => {
|
||||
if (id !== prevSelected) {
|
||||
debugTrace('session-switch', `selectedStoredSessionId ${prevSelected ?? 'null'} → ${id ?? 'null'}`)
|
||||
prevSelected = id
|
||||
}
|
||||
})
|
||||
|
||||
// --- Persistence events ---
|
||||
onPersistenceEvent(event => {
|
||||
const valuePreview =
|
||||
event.value === null
|
||||
? 'null'
|
||||
: event.value.length > 120
|
||||
? `${event.value.slice(0, 120)}…(${event.value.length} chars)`
|
||||
: event.value
|
||||
|
||||
debugTrace('persistence', `${event.op} ${event.key}`, { value: valuePreview })
|
||||
})
|
||||
|
||||
// --- Gateway events (wildcard) ---
|
||||
onGatewayEvent('*', event => {
|
||||
const type = event.type ?? 'unknown'
|
||||
const raw = event as unknown as Record<string, unknown>
|
||||
const sessionId = raw.session_id ?? raw.sessionId ?? null
|
||||
|
||||
// Summarize — don't dump the full payload for high-frequency deltas.
|
||||
const summary: Record<string, unknown> = { type }
|
||||
|
||||
if (sessionId) {
|
||||
summary.sessionId = sessionId
|
||||
}
|
||||
|
||||
// For message.delta, just note it happened (they fire 30×/s during a turn).
|
||||
// For everything else, include the payload for inspection.
|
||||
if (type === 'message.delta') {
|
||||
summary.note = 'delta (streaming)'
|
||||
} else {
|
||||
summary.payload = event
|
||||
}
|
||||
|
||||
debugTrace('gateway-event', type, summary)
|
||||
})
|
||||
|
||||
// --- Gateway connection state ---
|
||||
let prevGatewayState: string | undefined
|
||||
$gatewayState.subscribe(state => {
|
||||
if (state !== prevGatewayState) {
|
||||
debugTrace('connection', `gatewayState ${prevGatewayState ?? 'undefined'} → ${state}`)
|
||||
prevGatewayState = state
|
||||
}
|
||||
})
|
||||
|
||||
// --- Connection (mode/baseUrl/profile) ---
|
||||
let prevConnMode: string | undefined
|
||||
let prevConnProfile: string | undefined
|
||||
$connection.subscribe(conn => {
|
||||
const mode = conn?.mode ?? 'null'
|
||||
const profile = conn?.profile ?? 'null'
|
||||
|
||||
if (mode !== prevConnMode || profile !== prevConnProfile) {
|
||||
debugTrace('connection', `connection mode=${mode} profile=${profile} baseUrl=${conn?.baseUrl ?? 'null'}`)
|
||||
prevConnMode = mode
|
||||
prevConnProfile = profile
|
||||
}
|
||||
})
|
||||
|
||||
// --- Profile switches ---
|
||||
let prevProfile: string | undefined
|
||||
$activeGatewayProfile.subscribe(profile => {
|
||||
if (profile !== prevProfile) {
|
||||
debugTrace('profile-switch', `${prevProfile ?? 'undefined'} → ${profile}`)
|
||||
prevProfile = profile
|
||||
}
|
||||
})
|
||||
|
||||
// --- Resume failures + exhaustion ---
|
||||
let prevResumeFailed: string | null = null
|
||||
$resumeFailedSessionId.subscribe(id => {
|
||||
if (id !== prevResumeFailed) {
|
||||
debugTrace('resume', `resumeFailedSessionId ${prevResumeFailed ?? 'null'} → ${id ?? 'null'}`)
|
||||
prevResumeFailed = id
|
||||
}
|
||||
})
|
||||
|
||||
let prevResumeExhausted: string | null = null
|
||||
$resumeExhaustedSessionId.subscribe(id => {
|
||||
if (id !== prevResumeExhausted) {
|
||||
debugTrace('resume', `resumeExhaustedSessionId ${prevResumeExhausted ?? 'null'} → ${id ?? 'null'}`)
|
||||
prevResumeExhausted = id
|
||||
}
|
||||
})
|
||||
|
||||
// --- Busy / awaitingResponse edges ---
|
||||
let prevBusy = false
|
||||
$busy.subscribe(busy => {
|
||||
if (busy !== prevBusy) {
|
||||
debugTrace('busy', `busy ${prevBusy} → ${busy}`, { activeSessionId: $activeSessionId.get() })
|
||||
prevBusy = busy
|
||||
}
|
||||
})
|
||||
|
||||
let prevAwaiting = false
|
||||
$awaitingResponse.subscribe(awaiting => {
|
||||
if (awaiting !== prevAwaiting) {
|
||||
debugTrace('busy', `awaitingResponse ${prevAwaiting} → ${awaiting}`, { activeSessionId: $activeSessionId.get() })
|
||||
prevAwaiting = awaiting
|
||||
}
|
||||
})
|
||||
|
||||
// --- Message array length changes ---
|
||||
// Not per-token (that'd be insane) — only when the count changes, which
|
||||
// captures: new message added, transcript cleared, session switched,
|
||||
// reconciliation replaced the array.
|
||||
let prevMessageCount = -1
|
||||
$messages.subscribe(messages => {
|
||||
const count = messages.length
|
||||
|
||||
if (count !== prevMessageCount) {
|
||||
debugTrace('message', `messages count ${prevMessageCount} → ${count}`, {
|
||||
activeSessionId: $activeSessionId.get()
|
||||
})
|
||||
prevMessageCount = count
|
||||
}
|
||||
})
|
||||
|
||||
// --- Compression id rotation ---
|
||||
// Fires when auto-compaction rotates the active session's stored id mid-turn.
|
||||
// One of the spookiest bug classes — the route / pin / draft key silently
|
||||
// changes under the user.
|
||||
$activeSessionStoredIdRotation.subscribe(rotation => {
|
||||
if (rotation) {
|
||||
debugTrace('session-switch', `compression id rotation`, {
|
||||
prev: rotation.previousStoredSessionId,
|
||||
next: rotation.nextStoredSessionId,
|
||||
runtime: rotation.runtimeSessionId,
|
||||
isActive: rotation.runtimeSessionId === $activeSessionId.get()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// --- Blocking prompts (clarify / approval / sudo / secret) ---
|
||||
// When these appear/disappear, the chat is blocked. Tracing the edges
|
||||
// catches "agent silently stalled" bugs.
|
||||
let prevClarify: unknown = null
|
||||
$clarifyRequest.subscribe(req => {
|
||||
if (req !== prevClarify) {
|
||||
debugTrace('prompt', `clarify ${prevClarify ? 'cleared' : 'raised'}`, {
|
||||
sessionId: $activeSessionId.get(),
|
||||
requestId: req ? 'present' : 'null'
|
||||
})
|
||||
prevClarify = req
|
||||
}
|
||||
})
|
||||
|
||||
let prevApproval: unknown = null
|
||||
$approvalRequest.subscribe(req => {
|
||||
if (req !== prevApproval) {
|
||||
debugTrace('prompt', `approval ${prevApproval ? 'cleared' : 'raised'}`, {
|
||||
sessionId: $activeSessionId.get()
|
||||
})
|
||||
prevApproval = req
|
||||
}
|
||||
})
|
||||
|
||||
let prevSudo: unknown = null
|
||||
$sudoRequest.subscribe(req => {
|
||||
if (req !== prevSudo) {
|
||||
debugTrace('prompt', `sudo ${prevSudo ? 'cleared' : 'raised'}`, {
|
||||
sessionId: $activeSessionId.get()
|
||||
})
|
||||
prevSudo = req
|
||||
}
|
||||
})
|
||||
|
||||
let prevSecret: unknown = null
|
||||
$secretRequest.subscribe(req => {
|
||||
if (req !== prevSecret) {
|
||||
debugTrace('prompt', `secret ${prevSecret ? 'cleared' : 'raised'}`, {
|
||||
sessionId: $activeSessionId.get()
|
||||
})
|
||||
prevSecret = req
|
||||
}
|
||||
})
|
||||
|
||||
// --- Sessions list length changes ---
|
||||
// Captures: new session created, session archived/deleted, sidebar merge
|
||||
// kept/dropped a row. Not per-field — just the count edge.
|
||||
let prevSessionsCount = -1
|
||||
$sessions.subscribe(list => {
|
||||
const count = list.length
|
||||
|
||||
if (count !== prevSessionsCount) {
|
||||
debugTrace('sessions-list', `sessions count ${prevSessionsCount} → ${count}`)
|
||||
prevSessionsCount = count
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import './styles.css'
|
||||
// Side-effect: applies the persisted window translucency on load.
|
||||
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'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { debugTrace } from '@/lib/debug-trace'
|
||||
import { $activeSessionId } from './session'
|
||||
|
||||
// Per-session flag while auto-compaction runs mid-turn. Without it the
|
||||
@@ -25,6 +26,8 @@ export function setSessionCompacting(sessionId: string | null | undefined, activ
|
||||
|
||||
$compactingSessions.set({ ...sessions, [key]: true })
|
||||
|
||||
debugTrace('compaction', `started session=${key}`, { isActive: key === $activeSessionId.get() })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,4 +38,6 @@ export function setSessionCompacting(sessionId: string | null | undefined, activ
|
||||
const next = { ...sessions }
|
||||
delete next[key]
|
||||
$compactingSessions.set(next)
|
||||
|
||||
debugTrace('compaction', `finished session=${key}`, { isActive: key === $activeSessionId.get() })
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
noteActiveTreeGroup,
|
||||
revealTreePane
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import { debugTrace } from '@/lib/debug-trace'
|
||||
import { stableArray } from '@/lib/stable-array'
|
||||
import { readJson, writeJson } from '@/lib/storage'
|
||||
|
||||
@@ -189,6 +190,18 @@ export function publishSessionState(runtimeId: string, state: ClientSessionState
|
||||
const prev = $sessionStates.get()[runtimeId] ?? null
|
||||
$sessionStates.set({ ...$sessionStates.get(), [runtimeId]: state })
|
||||
handleTransition(prev, state, runtimeId)
|
||||
|
||||
debugTrace(
|
||||
'session-state',
|
||||
`publish ${runtimeId}`,
|
||||
{
|
||||
prev: prev
|
||||
? { storedSessionId: prev.storedSessionId, busy: prev.busy, needsInput: prev.needsInput }
|
||||
: null,
|
||||
next: { storedSessionId: state.storedSessionId, busy: state.busy, needsInput: state.needsInput },
|
||||
isActive: runtimeId === $activeSessionId.get()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function dropSessionState(runtimeId: string) {
|
||||
|
||||
@@ -42,6 +42,11 @@ export default defineConfig({
|
||||
postcss: { plugins: [] }
|
||||
},
|
||||
build: {
|
||||
// Ship sourcemaps so crash reports and devtools stack traces point at
|
||||
// real TS/TSX source, not minified bundles. Separate .map files (not
|
||||
// inline) keep the executable bundle lean; both electron-builder
|
||||
// (files: ["dist/**"]) and nix (cp -rn dist) carry them through.
|
||||
sourcemap: true,
|
||||
// Keep desktop packaging stable: Shiki ships many dynamic chunks by
|
||||
// default, and electron-builder can OOM scanning thousands of files.
|
||||
// Collapsing to a single chunk is intentional, so the renderer bundle is
|
||||
|
||||
Reference in New Issue
Block a user