Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions agent/runtime_cwd.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,42 @@
relies on the launch dir. Reading it in one place keeps the system prompt, the
tool surfaces, and context-file discovery agreeing on where the agent lives.

The #29531 per-session extension point is this function: a future PR adds a
contextvar arm inside `resolve_agent_cwd` and `.set()`s it at the
`set_session_vars` seam — by design, not a reopening hazard.
Multi-session gateways can pin a logical cwd via the `_SESSION_CWD`
contextvar; CLI/cron fall through to `TERMINAL_CWD`/launch cwd.
"""

import os
from contextvars import ContextVar, Token
from pathlib import Path
from typing import Any

_UNSET: Any = object()

_SESSION_CWD: ContextVar = ContextVar("HERMES_SESSION_CWD", default=_UNSET)


def set_session_cwd(cwd: str | None) -> Token:
"""Pin the logical cwd for the current context."""
return _SESSION_CWD.set((cwd or "").strip())


def clear_session_cwd() -> None:
_SESSION_CWD.set("")


def _session_cwd_override() -> str:
value = _SESSION_CWD.get()
if value is _UNSET:
return ""
return str(value).strip()


def resolve_agent_cwd() -> Path:
override = _session_cwd_override()
if override:
p = Path(override).expanduser()
if p.is_dir():
return p
raw = os.environ.get("TERMINAL_CWD", "").strip()
if raw:
p = Path(raw).expanduser()
Expand All @@ -27,7 +53,10 @@ def resolve_agent_cwd() -> Path:
def resolve_context_cwd() -> Path | None:
# None means "no configured cwd": build_context_files_prompt then falls back
# to the launch dir (os.getcwd()) — correct for the local CLI. The gateway
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py).
# No getcwd arm here: that fallback is owned by the caller, not this resolver.
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py)
# or, per session, the _SESSION_CWD contextvar above.
override = _session_cwd_override()
if override:
return Path(override).expanduser()
raw = os.environ.get("TERMINAL_CWD", "").strip()
return Path(raw).expanduser() if raw else None
66 changes: 63 additions & 3 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,13 @@ function registerMediaProtocol() {
let mainWindow = null
let hermesProcess = null
let connectionPromise = null
// Auto-reload budget for renderer crashes. A deterministic startup crash would
// otherwise loop forever (reload → crash → reload), pinning CPU and spamming
// logs. Allow a few reloads per rolling window, then stop and leave the dead
// window so the user can read the error / quit.
const RENDERER_RELOAD_WINDOW_MS = 60_000
const RENDERER_RELOAD_MAX = 3
let rendererReloadTimes = []
// Latched bootstrap failure: when the first-launch install fails, we hold
// onto the error so subsequent startHermes() calls (e.g. the renderer's
// ensureGatewayOpen retrying after the WS won't open) return the same error
Expand Down Expand Up @@ -3222,6 +3229,51 @@ function createWindow() {
openExternalUrl(url)
})

mainWindow.webContents.on('render-process-gone', (_event, details) => {
rememberLog(`[renderer] render-process-gone reason=${details?.reason} exitCode=${details?.exitCode}`)

if (details?.reason === 'crashed' || details?.reason === 'oom') {
const now = Date.now()
rendererReloadTimes = rendererReloadTimes.filter(t => now - t < RENDERER_RELOAD_WINDOW_MS)

if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) {
rememberLog(
`[renderer] suppressing reload: ${rendererReloadTimes.length} crashes within ${RENDERER_RELOAD_WINDOW_MS}ms (likely a crash loop)`
)

return
}

rendererReloadTimes.push(now)
setImmediate(() => {
if (!mainWindow || mainWindow.isDestroyed()) return
try {
mainWindow.webContents.reload()
} catch (err) {
rememberLog(`[renderer] reload after crash failed: ${err?.message || err}`)
}
})
}
Comment thread
OutThisLife marked this conversation as resolved.
})

mainWindow.webContents.on('unresponsive', () => rememberLog('[renderer] webContents became unresponsive'))

// Electron always passes the event first. The canonical (Electron 36+) shape
// is (event, messageDetails); the deprecated positional shape is
// (event, level, message, line, sourceId). Handle both. `level` is numeric
// (0..3), where 3 === error.
mainWindow.webContents.on('console-message', (_event, detailsOrLevel, message, line, sourceId) => {
const details = detailsOrLevel && typeof detailsOrLevel === 'object' ? detailsOrLevel : null
const level = details ? details.level : detailsOrLevel

if (level !== 3) return

const text = details ? details.message : message
const src = details ? details.sourceUrl : sourceId
const lineNo = details ? details.lineNumber : line
rememberLog(`[renderer console] ${text} (${src}:${lineNo})`)
})

if (DEV_SERVER) {
mainWindow.loadURL(DEV_SERVER)
} else {
Expand Down Expand Up @@ -3372,13 +3424,21 @@ ipcMain.handle('hermes:readFileText', async (_event, filePath) => {
})

ipcMain.handle('hermes:selectPaths', async (_event, options = {}) => {
const properties = ['openFile']
if (options?.directories) properties.push('openDirectory')
const properties = options?.directories ? ['openDirectory'] : ['openFile']
if (options?.multiple !== false) properties.push('multiSelections')

let resolvedDefaultPath
if (options?.defaultPath) {
try {
resolvedDefaultPath = path.resolve(String(options.defaultPath))
} catch {
resolvedDefaultPath = undefined
}
}

const result = await dialog.showOpenDialog(mainWindow, {
title: options?.title || 'Add context',
defaultPath: options?.defaultPath ? path.resolve(String(options.defaultPath)) : undefined,
defaultPath: resolvedDefaultPath,
properties,
filters: Array.isArray(options?.filters) ? options.filters : undefined
})
Expand Down
15 changes: 12 additions & 3 deletions apps/desktop/src/app/right-sidebar/files/tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const ROW_HEIGHT = 22
const INDENT = 10

interface ProjectTreeProps {
collapseNonce: number
cwd: string
data: TreeNode[]
onActivateFile: (path: string) => void
onActivateFolder: (path: string) => void
Expand All @@ -21,6 +23,8 @@ interface ProjectTreeProps {
}

export function ProjectTree({
collapseNonce,
cwd,
data,
onActivateFile,
onActivateFolder,
Expand Down Expand Up @@ -63,7 +67,7 @@ export function ProjectTree({

onNodeOpenChange(id, node.isOpen)

if (node.isOpen && node.data.children === undefined) {
if (node.isOpen && node.data?.isDirectory && node.data.children === undefined) {
void onLoadChildren(id)
}
Comment thread
OutThisLife marked this conversation as resolved.
},
Expand All @@ -72,7 +76,7 @@ export function ProjectTree({

const handleActivate = useCallback(
(node: NodeApi<TreeNode>) => {
if (!node.data.isDirectory) {
if (node.data && !node.data.isDirectory) {
onPreviewFile?.(node.data.id)
}
},
Expand All @@ -83,14 +87,15 @@ export function ProjectTree({
<div className="min-h-0 flex-1 overflow-hidden" ref={containerRef}>
{size.height > 0 && size.width > 0 ? (
<Tree<TreeNode>
childrenAccessor={node => (node.isDirectory ? (node.children ?? []) : null)}
childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)}
data={data}
disableDrag
disableDrop
disableEdit
height={size.height}
indent={INDENT}
initialOpenState={openState}
key={`${cwd}:${collapseNonce}`}
onActivate={handleActivate}
onToggle={handleToggle}
openByDefault={false}
Expand Down Expand Up @@ -135,6 +140,10 @@ function ProjectTreeRow({
onAttachFolder: (path: string) => void
onPreviewFile?: (path: string) => void
}) {
if (!node.data) {
return <div style={style} />
}

const isFolder = node.data.isDirectory
const isPlaceholder = node.data.id.endsWith('::__loading__')

Expand Down
23 changes: 23 additions & 0 deletions apps/desktop/src/app/right-sidebar/files/use-project-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,20 @@ function placeholderChild(parentId: string): TreeNode {
}

export interface UseProjectTreeResult {
/** Bumped by collapseAll so callers can remount the tree fully collapsed. */
collapseNonce: number
data: TreeNode[]
openState: Record<string, boolean>
rootError: string | null
rootLoading: boolean
collapseAll: () => void
loadChildren: (id: string) => Promise<void>
refreshRoot: () => Promise<void>
setNodeOpen: (id: string, open: boolean) => void
}

interface ProjectTreeState {
collapseNonce: number
cwd: string
data: TreeNode[]
loaded: boolean
Expand All @@ -67,6 +71,7 @@ interface ProjectTreeState {
}

const initialState: ProjectTreeState = {
collapseNonce: 0,
cwd: '',
data: [],
loaded: false,
Expand Down Expand Up @@ -112,6 +117,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
}

$projectTree.set({
collapseNonce: current.collapseNonce,
cwd,
data: [],
loaded: false,
Expand Down Expand Up @@ -174,6 +180,19 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
[cwd]
)

// Clears the recorded open state and bumps the nonce; the tree is keyed on
// the nonce so it remounts with everything collapsed (loaded children stay
// cached in `data`, just hidden).
const collapseAll = useCallback(() => {
setProjectTree(current => {
if (current.cwd !== cwd) {
return current
}

return { ...current, collapseNonce: current.collapseNonce + 1, openState: {} }
})
}, [cwd])

const loadChildren = useCallback(
async (id: string) => {
if (!cwd || inflight.has(id)) {
Expand Down Expand Up @@ -222,6 +241,8 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {

return useMemo(
() => ({
collapseAll,
collapseNonce: state.cwd === cwd ? state.collapseNonce : 0,
data: state.cwd === cwd ? state.data : [],
loadChildren,
openState: state.cwd === cwd ? state.openState : {},
Expand All @@ -231,10 +252,12 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
setNodeOpen
}),
[
collapseAll,
cwd,
loadChildren,
refreshRoot,
setNodeOpen,
state.collapseNonce,
state.cwd,
state.data,
state.openState,
Expand Down
Loading
Loading