From c54195d39f16c426ffda6c273d4fc693006b62e3 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 27 May 2026 18:27:38 +0200 Subject: [PATCH 1/3] fix(resource-monitor): stop sessions when killing --- src/main/core/pty/controller.ts | 44 ++++++++ .../command-palette/resource-monitor-view.tsx | 106 +++++++++++++++++- 2 files changed, 146 insertions(+), 4 deletions(-) diff --git a/src/main/core/pty/controller.ts b/src/main/core/pty/controller.ts index 51620abc36..50d1544622 100644 --- a/src/main/core/pty/controller.ts +++ b/src/main/core/pty/controller.ts @@ -81,6 +81,50 @@ export const ptyController = createRPCController({ return ok(); }, + /** + * Stop a session for good without deleting its conversation/terminal record. + * + * Unlike `kill`, which only terminates the OS process, this routes through + * the owning provider so the session is removed from its respawn tracking + * (`knownSessionIds`/`sessions`). A bare `kill` leaves those intact, so the + * provider's `onExit` handler respawns the PTY ~500ms later — which is what + * made killing from the resource monitor appear to do nothing. The tab and + * its history are preserved; the session simply stays stopped until the task + * is remounted or the user starts a new one. + */ + stopSession: async (sessionId: string) => { + const parsed = parsePtySessionId(sessionId); + if (!parsed) return err({ type: 'invalid_session' as const }); + const { scopeId, leafId } = parsed; + + // Agents and terminals are scoped by task id, so the task lookup resolves + // the owning provider. Conversation PTYs carry a providerId in their + // registry metadata; plain terminals do not — that distinguishes the two. + const task = taskManager.getTask(scopeId); + if (task) { + const isConversation = ptySessionRegistry.getMetadata(sessionId)?.providerId !== undefined; + if (isConversation) { + await task.conversations.stopSession(leafId); + } else { + await task.terminals.killTerminal(leafId); + } + return ok(); + } + + // Lifecycle scripts are scoped by workspace id (no task match) and never + // respawn, so a raw kill is sufficient and safe. + const pty = ptySessionRegistry.get(sessionId); + if (pty) { + try { + pty.kill(); + } catch (e) { + log.warn('ptyController.stopSession: error killing PTY', { sessionId, error: String(e) }); + } + } + ptySessionRegistry.unregister(sessionId); + return ok(); + }, + /** * Upload local files into the task's working directory on a remote SSH host * and return their remote paths. Uses the SFTP subsystem of the already- diff --git a/src/renderer/features/command-palette/resource-monitor-view.tsx b/src/renderer/features/command-palette/resource-monitor-view.tsx index 6edb901a15..0dfd8d4059 100644 --- a/src/renderer/features/command-palette/resource-monitor-view.tsx +++ b/src/renderer/features/command-palette/resource-monitor-view.tsx @@ -1,11 +1,13 @@ -import { Activity, ArrowLeft, Check, Copy, Folder, GitBranch, Terminal } from 'lucide-react'; +import { Activity, ArrowLeft, Check, Copy, Folder, GitBranch, Terminal, X } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import AgentLogo from '@renderer/lib/components/agent-logo'; +import { rpc } from '@renderer/lib/ipc'; import { agentMeta } from '@renderer/lib/providers/meta'; import { appState } from '@renderer/lib/stores/app-state'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip'; import { formatBytes } from '@renderer/utils/formatBytes'; +import { cn } from '@renderer/utils/utils'; import type { ResourceAppProcess, ResourceSnapshot } from '@shared/resource-monitor'; import { appProcessLabel, @@ -160,6 +162,7 @@ function AgentRow({ entry }: { entry: Entry }) { const norm = appState.resourceMonitor.normalizedCpu(entry); const meta = entry.providerId ? agentMeta[entry.providerId] : undefined; const label = entryLabel(entry); + const [armed, setArmed] = useState(false); return (
{label} {entry.pid === undefined ? SSH : null}
- - {norm.toFixed(0)}% · {formatBytes(entry.memory)} - +
+ + {norm.toFixed(0)}% · {formatBytes(entry.memory)} + + +
); } +function KillButton({ + sessionId, + label, + armed, + onArmedChange, +}: { + sessionId: string; + label: string; + armed: boolean; + onArmedChange: (armed: boolean) => void; +}) { + const [killing, setKilling] = useState(false); + const resetRef = useRef(null); + + const clearReset = useCallback(() => { + if (resetRef.current !== null) { + window.clearTimeout(resetRef.current); + resetRef.current = null; + } + }, []); + + useEffect(() => clearReset, [clearReset]); + + const handleClick = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + if (killing) return; + if (!armed) { + // First click arms; auto-disarm if the second click doesn't follow. + onArmedChange(true); + clearReset(); + resetRef.current = window.setTimeout(() => { + onArmedChange(false); + resetRef.current = null; + }, 3000); + return; + } + clearReset(); + setKilling(true); + try { + await rpc.pty.stopSession(sessionId); + await appState.resourceMonitor.refresh(); + } catch { + setKilling(false); + onArmedChange(false); + } + }, + [armed, killing, sessionId, clearReset, onArmedChange] + ); + + if (armed) { + return ( + + ); + } + + return ( + + + + + Kill session + + ); +} + function Badge({ children }: { children: ReactNode }) { return ( From 6e861183f556e27aad6fd0fbea99f181733c4281 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 27 May 2026 20:15:50 +0200 Subject: [PATCH 2/3] fix(resource-monitor): reset kill state --- src/main/core/pty/controller.ts | 16 ++++++++++++---- .../command-palette/resource-monitor-view.tsx | 2 ++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/core/pty/controller.ts b/src/main/core/pty/controller.ts index 50d1544622..13e278140a 100644 --- a/src/main/core/pty/controller.ts +++ b/src/main/core/pty/controller.ts @@ -103,10 +103,18 @@ export const ptyController = createRPCController({ const task = taskManager.getTask(scopeId); if (task) { const isConversation = ptySessionRegistry.getMetadata(sessionId)?.providerId !== undefined; - if (isConversation) { - await task.conversations.stopSession(leafId); - } else { - await task.terminals.killTerminal(leafId); + try { + if (isConversation) { + await task.conversations.stopSession(leafId); + } else { + await task.terminals.killTerminal(leafId); + } + } catch (e) { + log.warn('ptyController.stopSession: error stopping task PTY', { + sessionId, + error: String(e), + }); + return err({ type: 'stop_failed' as const, message: String((e as Error)?.message || e) }); } return ok(); } diff --git a/src/renderer/features/command-palette/resource-monitor-view.tsx b/src/renderer/features/command-palette/resource-monitor-view.tsx index 0dfd8d4059..dd5b71fbed 100644 --- a/src/renderer/features/command-palette/resource-monitor-view.tsx +++ b/src/renderer/features/command-palette/resource-monitor-view.tsx @@ -252,6 +252,8 @@ function KillButton({ try { await rpc.pty.stopSession(sessionId); await appState.resourceMonitor.refresh(); + setKilling(false); + onArmedChange(false); } catch { setKilling(false); onArmedChange(false); From 5d425de13f06e395c59a0b237c2ec0fae8e50a6e Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Wed, 27 May 2026 20:29:45 +0200 Subject: [PATCH 3/3] fix(resource-monitor): close stopped conversation tabs --- .../command-palette/resource-monitor-view.tsx | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/src/renderer/features/command-palette/resource-monitor-view.tsx b/src/renderer/features/command-palette/resource-monitor-view.tsx index dd5b71fbed..6cbb8a55c0 100644 --- a/src/renderer/features/command-palette/resource-monitor-view.tsx +++ b/src/renderer/features/command-palette/resource-monitor-view.tsx @@ -1,6 +1,7 @@ import { Activity, ArrowLeft, Check, Copy, Folder, GitBranch, Terminal, X } from 'lucide-react'; import { observer } from 'mobx-react-lite'; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { getTaskView } from '@renderer/features/tasks/stores/task-selectors'; import AgentLogo from '@renderer/lib/components/agent-logo'; import { rpc } from '@renderer/lib/ipc'; import { agentMeta } from '@renderer/lib/providers/meta'; @@ -8,6 +9,7 @@ import { appState } from '@renderer/lib/stores/app-state'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip'; import { formatBytes } from '@renderer/utils/formatBytes'; import { cn } from '@renderer/utils/utils'; +import { parsePtySessionId } from '@shared/ptySessionId'; import type { ResourceAppProcess, ResourceSnapshot } from '@shared/resource-monitor'; import { appProcessLabel, @@ -199,7 +201,7 @@ function AgentRow({ entry }: { entry: Entry }) { > {norm.toFixed(0)}% · {formatBytes(entry.memory)} - void; }) { - const [killing, setKilling] = useState(false); + const [stopping, setStopping] = useState(false); const resetRef = useRef(null); const clearReset = useCallback(() => { @@ -236,7 +254,7 @@ function KillButton({ const handleClick = useCallback( async (e: React.MouseEvent) => { e.stopPropagation(); - if (killing) return; + if (stopping) return; if (!armed) { // First click arms; auto-disarm if the second click doesn't follow. onArmedChange(true); @@ -248,30 +266,31 @@ function KillButton({ return; } clearReset(); - setKilling(true); + setStopping(true); try { await rpc.pty.stopSession(sessionId); + closeConversationTabsForSession(sessionId); await appState.resourceMonitor.refresh(); - setKilling(false); + setStopping(false); onArmedChange(false); } catch { - setKilling(false); + setStopping(false); onArmedChange(false); } }, - [armed, killing, sessionId, clearReset, onArmedChange] + [armed, stopping, sessionId, clearReset, onArmedChange] ); if (armed) { return ( ); } @@ -281,15 +300,15 @@ function KillButton({ - Kill session + Stop session ); }