diff --git a/agent/runtime_cwd.py b/agent/runtime_cwd.py index d25d38cce..d57a9da7e 100644 --- a/agent/runtime_cwd.py +++ b/agent/runtime_cwd.py @@ -6,16 +6,42 @@ gateway/cron startup). The local-CLI backend deliberately leaves it unset and 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() @@ -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 diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index bd94e69a5..db6a2b7b9 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -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 @@ -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}`) + } + }) + } + }) + + 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 { @@ -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 }) diff --git a/apps/desktop/src/app/right-sidebar/files/tree.tsx b/apps/desktop/src/app/right-sidebar/files/tree.tsx index 95bea5fd4..40cc4ed82 100644 --- a/apps/desktop/src/app/right-sidebar/files/tree.tsx +++ b/apps/desktop/src/app/right-sidebar/files/tree.tsx @@ -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 @@ -21,6 +23,8 @@ interface ProjectTreeProps { } export function ProjectTree({ + collapseNonce, + cwd, data, onActivateFile, onActivateFolder, @@ -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) } }, @@ -72,7 +76,7 @@ export function ProjectTree({ const handleActivate = useCallback( (node: NodeApi) => { - if (!node.data.isDirectory) { + if (node.data && !node.data.isDirectory) { onPreviewFile?.(node.data.id) } }, @@ -83,7 +87,7 @@ export function ProjectTree({
{size.height > 0 && size.width > 0 ? ( - childrenAccessor={node => (node.isDirectory ? (node.children ?? []) : null)} + childrenAccessor={node => (node?.isDirectory ? (node.children ?? []) : null)} data={data} disableDrag disableDrop @@ -91,6 +95,7 @@ export function ProjectTree({ height={size.height} indent={INDENT} initialOpenState={openState} + key={`${cwd}:${collapseNonce}`} onActivate={handleActivate} onToggle={handleToggle} openByDefault={false} @@ -135,6 +140,10 @@ function ProjectTreeRow({ onAttachFolder: (path: string) => void onPreviewFile?: (path: string) => void }) { + if (!node.data) { + return
+ } + const isFolder = node.data.isDirectory const isPlaceholder = node.data.id.endsWith('::__loading__') diff --git a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts index 229bfcacd..23fb5efe2 100644 --- a/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts +++ b/apps/desktop/src/app/right-sidebar/files/use-project-tree.ts @@ -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 rootError: string | null rootLoading: boolean + collapseAll: () => void loadChildren: (id: string) => Promise refreshRoot: () => Promise setNodeOpen: (id: string, open: boolean) => void } interface ProjectTreeState { + collapseNonce: number cwd: string data: TreeNode[] loaded: boolean @@ -67,6 +71,7 @@ interface ProjectTreeState { } const initialState: ProjectTreeState = { + collapseNonce: 0, cwd: '', data: [], loaded: false, @@ -112,6 +117,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {} } $projectTree.set({ + collapseNonce: current.collapseNonce, cwd, data: [], loaded: false, @@ -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)) { @@ -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 : {}, @@ -231,10 +252,12 @@ export function useProjectTree(cwd: string): UseProjectTreeResult { setNodeOpen }), [ + collapseAll, cwd, loadChildren, refreshRoot, setNodeOpen, + state.collapseNonce, state.cwd, state.data, state.openState, diff --git a/apps/desktop/src/app/right-sidebar/index.tsx b/apps/desktop/src/app/right-sidebar/index.tsx index 02c9708ed..ae429988d 100644 --- a/apps/desktop/src/app/right-sidebar/index.tsx +++ b/apps/desktop/src/app/right-sidebar/index.tsx @@ -1,6 +1,7 @@ import { useStore } from '@nanostores/react' import type { ReactNode } from 'react' +import { ErrorBoundary } from '@/components/error-boundary' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Loader } from '@/components/ui/loader' @@ -52,7 +53,10 @@ export function RightSidebarPane({ .pop() ?? currentCwd) : 'No folder selected' - const { data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = useProjectTree(currentCwd) + const { collapseAll, collapseNonce, data, loadChildren, openState, refreshRoot, rootError, rootLoading, setNodeOpen } = + useProjectTree(currentCwd) + + const canCollapse = Object.values(openState).some(Boolean) const effectiveTab: RightSidebarTabId = terminalTakeover ? 'files' : activeTab const chooseFolder = async () => { @@ -97,6 +101,8 @@ export function RightSidebarPane({ ) : ( Promise | void + onCollapseAll: () => void onRefresh: () => void } +const HEADER_ACTION_CLASS = + 'size-6 shrink-0 rounded-md text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-2 focus-visible:ring-sidebar-ring' + +const HEADER_ACTION_REVEAL_CLASS = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100` + function FilesystemTab({ + canCollapse, + collapseNonce, cwd, cwdName, data, @@ -176,6 +192,7 @@ function FilesystemTab({ onActivateFile, onActivateFolder, onChangeFolder, + onCollapseAll, onLoadChildren, onNodeOpenChange, onPreviewFile, @@ -188,14 +205,35 @@ function FilesystemTab({ + + +
+ )} + key={cwd} + label="file-tree" + > + + ) } diff --git a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts index b1122d1c5..ab301e593 100644 --- a/apps/desktop/src/app/session/hooks/use-cwd-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-cwd-actions.ts @@ -50,16 +50,23 @@ export function useCwdActions({ } if (!activeSessionId) { + setCurrentCwd(trimmed) + try { const info = await requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: trimmed }) - setCurrentCwd(info.cwd || trimmed) + // Adopt the backend's normalized cwd so the persisted workspace and + // branch stay consistent with what the agent will use. + if (info.cwd) { + setCurrentCwd(info.cwd) + } + setCurrentBranch(info.branch || '') - } catch (err) { - notifyError(err, 'Working directory change failed') + } catch { + setCurrentBranch('') } return diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.ts b/apps/desktop/src/app/session/hooks/use-session-actions.ts index 82ef77c7e..066f1eee0 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions.ts @@ -15,6 +15,7 @@ import { $currentCwd, $messages, $sessions, + getRememberedWorkspaceCwd, setActiveSessionId, setAwaitingResponse, setBusy, @@ -291,7 +292,8 @@ export function useSessionActions({ }) setSessionStartedAt(null) setTurnStartedAt(null) - setCurrentCwd('') + // New chats inherit the current workspace. + setCurrentCwd(getRememberedWorkspaceCwd()) setCurrentBranch('') clearComposerDraft() clearComposerAttachments() @@ -308,7 +310,7 @@ export function useSessionActions({ creatingSessionRef.current = true try { - const cwd = $currentCwd.get().trim() + const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd() const created = await requestGateway('session.create', { cols: 96, ...(cwd && { cwd }) }) const stored = created.stored_session_id ?? null diff --git a/apps/desktop/src/components/error-boundary.tsx b/apps/desktop/src/components/error-boundary.tsx new file mode 100644 index 000000000..0afef1c9e --- /dev/null +++ b/apps/desktop/src/components/error-boundary.tsx @@ -0,0 +1,91 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' + +import { Button } from '@/components/ui/button' +import { AlertTriangle, RefreshCw } from '@/lib/icons' + +export interface ErrorBoundaryFallbackProps { + error: Error + reset: () => void +} + +interface ErrorBoundaryProps { + children: ReactNode + fallback?: (props: ErrorBoundaryFallbackProps) => ReactNode + label?: string + onError?: (error: Error, info: ErrorInfo) => void +} + +interface ErrorBoundaryState { + error: Error | null +} + +export class ErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + const tag = this.props.label ? `[error-boundary:${this.props.label}]` : '[error-boundary]' + console.error(tag, error, info.componentStack) + this.props.onError?.(error, info) + } + + reset = () => { + this.setState({ error: null }) + } + + render() { + const { error } = this.state + + if (!error) { + return this.props.children + } + + if (this.props.fallback) { + return this.props.fallback({ error, reset: this.reset }) + } + + return + } +} + +function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) { + return ( +
+
+
+
+ +
+
+

Something broke in the interface

+

+ The view hit an unexpected error. Your chats and settings are safe - try again, or reload the window. +

+
+
+ +
+
+ {error.message || String(error)} +
+ +
+ + + +
+
+
+
+ ) +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index f203e42d7..59341df1a 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -6,6 +6,7 @@ import { createRoot } from 'react-dom/client' import { HashRouter } from 'react-router-dom' import App from './app' +import { ErrorBoundary } from './components/error-boundary' import { HapticsProvider } from './components/haptics-provider' import { installClipboardShim } from './lib/clipboard' import { ThemeProvider } from './themes/context' @@ -32,14 +33,16 @@ const queryClient = new QueryClient({ createRoot(document.getElementById('root')!).render( - - - - - - - - - + + + + + + + + + + + ) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index cf2372f3d..06bbb1ff0 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -3,10 +3,15 @@ import { atom } from 'nanostores' import type { ContextSuggestion } from '@/app/types' import type { HermesConnection } from '@/global' import type { ChatMessage } from '@/lib/chat-messages' +import { persistString, storedString } from '@/lib/storage' import type { SessionInfo, UsageStats } from '@/types/hermes' type Updater = T | ((current: T) => T) +const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd' + +export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || '' + interface AppAtom { get: () => T set: (value: T) => void @@ -39,7 +44,7 @@ export const $currentProvider = atom('') export const $currentReasoningEffort = atom('') export const $currentServiceTier = atom('') export const $currentFastMode = atom(false) -export const $currentCwd = atom('') +export const $currentCwd = atom(getRememberedWorkspaceCwd()) export const $currentBranch = atom('') export const $currentUsage = atom({ calls: 0, @@ -73,7 +78,14 @@ export const setCurrentProvider = (next: Updater) => updateAtom($current export const setCurrentReasoningEffort = (next: Updater) => updateAtom($currentReasoningEffort, next) export const setCurrentServiceTier = (next: Updater) => updateAtom($currentServiceTier, next) export const setCurrentFastMode = (next: Updater) => updateAtom($currentFastMode, next) -export const setCurrentCwd = (next: Updater) => updateAtom($currentCwd, next) + +export const setCurrentCwd = (next: Updater) => { + updateAtom($currentCwd, next) + // Keep localStorage in sync with the atom: a real folder is remembered, an + // empty cwd clears the key (|| null → removeItem). + persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null) +} + export const setCurrentBranch = (next: Updater) => updateAtom($currentBranch, next) export const setCurrentUsage = (next: Updater) => updateAtom($currentUsage, next) export const setSessionStartedAt = (next: Updater) => updateAtom($sessionStartedAt, next) diff --git a/gateway/session_context.py b/gateway/session_context.py index ee43eca0f..8dfc84cac 100644 --- a/gateway/session_context.py +++ b/gateway/session_context.py @@ -107,14 +107,17 @@ def set_session_vars( user_name: str = "", session_key: str = "", message_id: str = "", + cwd: str = "", ) -> list: """Set all session context variables and return reset tokens. - Call ``clear_session_vars(tokens)`` in a ``finally`` block to restore - the previous values when the handler exits. + Call ``clear_session_vars(tokens)`` in a ``finally`` block when the handler + exits. Note ``clear_session_vars`` resets every var to ``""`` (to suppress + the ``os.environ`` fallback) rather than restoring prior values — these + helpers are not nestable/stack-safe, and the returned tokens are accepted + only for API compatibility. - Returns a list of ``Token`` objects (one per variable) that can be - passed to ``clear_session_vars``. + ``cwd`` pins the logical working directory for this context. """ tokens = [ _SESSION_PLATFORM.set(platform), @@ -126,6 +129,12 @@ def set_session_vars( _SESSION_KEY.set(session_key), _SESSION_MESSAGE_ID.set(message_id), ] + try: + from agent.runtime_cwd import set_session_cwd + + set_session_cwd(cwd) + except Exception: + pass return tokens @@ -151,6 +160,12 @@ def clear_session_vars(tokens: list) -> None: _SESSION_MESSAGE_ID, ): var.set("") + try: + from agent.runtime_cwd import clear_session_cwd + + clear_session_cwd() + except Exception: + pass def get_session_env(name: str, default: str = "") -> str: diff --git a/tests/agent/test_runtime_cwd.py b/tests/agent/test_runtime_cwd.py index 817e5271d..827cfc09c 100644 --- a/tests/agent/test_runtime_cwd.py +++ b/tests/agent/test_runtime_cwd.py @@ -6,7 +6,12 @@ from pathlib import Path import pytest import agent.runtime_cwd as rt -from agent.runtime_cwd import resolve_agent_cwd, resolve_context_cwd +from agent.runtime_cwd import ( + clear_session_cwd, + resolve_agent_cwd, + resolve_context_cwd, + set_session_cwd, +) def _raise_oserror(*args, **kwargs): @@ -77,3 +82,48 @@ class TestResolveContextCwd: # than building Path(" ") and resolving garbage under the launch dir. monkeypatch.setenv("TERMINAL_CWD", " ") assert resolve_context_cwd() is None + + +class TestSessionCwdOverride: + """The #29531 per-session arm: a contextvar cwd wins over TERMINAL_CWD so a + multi-session gateway can pin each session to its own folder.""" + + def test_session_cwd_overrides_terminal_cwd(self, monkeypatch, tmp_path): + other = tmp_path / "other" + other.mkdir() + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + token = set_session_cwd(str(other)) + try: + assert resolve_agent_cwd() == other + assert resolve_context_cwd() == other + finally: + rt._SESSION_CWD.reset(token) + + def test_empty_session_cwd_falls_back_to_terminal_cwd(self, monkeypatch, tmp_path): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + token = set_session_cwd("") + try: + assert resolve_agent_cwd() == tmp_path + assert resolve_context_cwd() == tmp_path + finally: + rt._SESSION_CWD.reset(token) + + def test_clear_session_cwd_restores_terminal_cwd(self, monkeypatch, tmp_path): + other = tmp_path / "other" + other.mkdir() + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + token = set_session_cwd(str(other)) + try: + clear_session_cwd() + assert resolve_agent_cwd() == tmp_path + finally: + rt._SESSION_CWD.reset(token) + + def test_nonexistent_session_cwd_falls_back(self, monkeypatch, tmp_path): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + token = set_session_cwd(str(tmp_path / "gone")) + try: + # resolve_agent_cwd guards on isdir; a missing session cwd must not win. + assert resolve_agent_cwd() == tmp_path + finally: + rt._SESSION_CWD.reset(token) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6c93dd629..df6adbc41 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -10,6 +10,55 @@ from unittest.mock import patch from tui_gateway import server +def test_session_context_uses_session_cwd(monkeypatch, tmp_path): + """Desktop/TUI sessions must pin the agent cwd per session. + + The gateway process itself is often launched from apps/desktop in dev, so + falling back to os.getcwd() makes agents answer from the desktop app folder + even when the sidebar/session cwd is a real project. + """ + from agent.runtime_cwd import resolve_agent_cwd + + sid = "cwd-sid" + session_key = "cwd-key" + project = tmp_path / "project" + project.mkdir() + launcher = tmp_path / "apps" / "desktop" + launcher.mkdir(parents=True) + + server._sessions[sid] = {"session_key": session_key, "cwd": str(project)} + monkeypatch.delenv("TERMINAL_CWD", raising=False) + monkeypatch.chdir(launcher) + + tokens = server._set_session_context(session_key) + try: + assert resolve_agent_cwd() == project + finally: + server._clear_session_context(tokens) + server._sessions.pop(sid, None) + + +def test_session_context_explicit_cwd_for_ephemeral_task(monkeypatch, tmp_path): + """Background/preview tasks use ephemeral ids absent from `_sessions`, so the + parent workspace is passed explicitly; it must pin instead of clearing back + to the gateway launch dir.""" + from agent.runtime_cwd import resolve_agent_cwd + + project = tmp_path / "project" + project.mkdir() + launcher = tmp_path / "apps" / "desktop" + launcher.mkdir(parents=True) + + monkeypatch.delenv("TERMINAL_CWD", raising=False) + monkeypatch.chdir(launcher) + + tokens = server._set_session_context("bg_deadbe", cwd=str(project)) + try: + assert resolve_agent_cwd() == project + finally: + server._clear_session_context(tokens) + + class _ChunkyStdout: def __init__(self): self.parts: list[str] = [] diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 528e0b2b1..7ef2a4cd1 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -809,11 +809,31 @@ def _save_cfg(cfg: dict): _cfg_mtime = None -def _set_session_context(session_key: str) -> list: +def _cwd_for_session_key(session_key: str) -> str: + """Reverse-map session_key to the session's logical cwd. + + Snapshots ``_sessions`` first: concurrent RPC handlers mutate it from the + thread pool, so iterating the live view risks ``RuntimeError: dictionary + changed size during iteration``. + """ + if not session_key: + return "" + for sess in list(_sessions.values()): + if sess.get("session_key") == session_key: + return str(sess.get("cwd") or "") + return "" + + +def _set_session_context(session_key: str, cwd: str | None = None) -> list: try: from gateway.session_context import set_session_vars - return set_session_vars(session_key=session_key) + # Ephemeral task IDs (background, preview) aren't in `_sessions`, so the + # reverse-map returns "" and would clear the cwd override. Callers that + # know the parent workspace pass it explicitly so spawned agents inherit + # it instead of falling back to the gateway launch dir. + resolved = cwd if cwd is not None else _cwd_for_session_key(session_key) + return set_session_vars(session_key=session_key, cwd=resolved) except Exception: return [] @@ -2764,6 +2784,7 @@ def _(rid, params: dict) -> dict: explicit_cwd = bool(raw_cwd) and os.path.isdir(os.path.abspath(os.path.expanduser(raw_cwd))) except Exception: explicit_cwd = False + resolved_cwd = _completion_cwd(params) _enable_gateway_prompts() ready = threading.Event() @@ -2782,7 +2803,7 @@ def _(rid, params: dict) -> dict: "history_lock": threading.Lock(), "history_version": 0, "image_counter": 0, - "cwd": _completion_cwd(params), + "cwd": resolved_cwd, "inflight_turn": None, "last_active": now, "pending_title": title or None, @@ -4624,7 +4645,7 @@ def _(rid, params: dict) -> dict: task_id = f"bg_{uuid.uuid4().hex[:6]}" def run(): - session_tokens = _set_session_context(task_id) + session_tokens = _set_session_context(task_id, cwd=_session_cwd(session)) try: from run_agent import AIAgent @@ -4709,14 +4730,25 @@ def _(rid, params: dict) -> dict: if line ) + # Normalize defensively: a malformed client path (embedded NUL, etc.) must + # not blow up the whole restart — treat it as "no validated cwd". + try: + preview_cwd = os.path.abspath(os.path.expanduser(cwd)) if cwd else "" + if preview_cwd and not os.path.isdir(preview_cwd): + preview_cwd = "" + except Exception: + preview_cwd = "" + def run(): - session_tokens = _set_session_context(task_id) + # Pin the validated preview cwd, else the parent workspace — never an + # invalid client path, which would silently fall back to the launch dir. + session_tokens = _set_session_context(task_id, cwd=(preview_cwd or _session_cwd(session))) try: from run_agent import AIAgent from tools.terminal_tool import register_task_env_overrides - if cwd and os.path.isdir(os.path.abspath(os.path.expanduser(cwd))): - register_task_env_overrides(task_id, {"cwd": os.path.abspath(os.path.expanduser(cwd))}) + if preview_cwd: + register_task_env_overrides(task_id, {"cwd": preview_cwd}) history_note = ( f" (with {len(parent_history)} parent-session messages of context)"