diff --git a/src/main/core/pty/controller.ts b/src/main/core/pty/controller.ts index 51620abc36..13e278140a 100644 --- a/src/main/core/pty/controller.ts +++ b/src/main/core/pty/controller.ts @@ -81,6 +81,58 @@ 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; + 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(); + } + + // 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..6cbb8a55c0 100644 --- a/src/renderer/features/command-palette/resource-monitor-view.tsx +++ b/src/renderer/features/command-palette/resource-monitor-view.tsx @@ -1,11 +1,15 @@ -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 { 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'; 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, @@ -160,6 +164,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 (